Wednesday 9 July 2014

Some more TestBox testing, case/when for CFML and some dodgy code

G'day:
This is an odd one. I was looking at some Ruby code the other day... well it was CoffeeScript but one of the bits influenced by Ruby, and I was reminded that languages like Ruby and various SQL flavours have - in addition to switch/case constructs - have a case/when construct too. And in Ruby's case it's in the form of an expression. This is pretty cool as one can do this:

myVar = case
    when colour == "blue" then
        "it's blue"
    when number == 1 then
        "it's one"
    else
        "shrug"
end

And depending on the values of colour or number, myVar will be assigned accordingly. I like this. And think it would be good for CFML. So was gonna raise an E/R for it.

But then I wondered... "Cameron, you could probably implement this using 'clever' (for me) use of function expressions, and somehow recursive calls to themselves to... um... well I dunno, but there's a challenge. Do it".

So I set out to write a case/when/then/else/end implementation... as a single UDF. The syntax would be thus:

// example.cfm
param name="URL.number" default="";
param name="URL.colour" default="";

include "case.cfm"

result =
    case()
        .when(URL.number=="tahi")
            .then(function(){return "one"})
        .when(function(){return URL.colour=="whero"})
            .then(function(){return "red"})
        .else(function(){return "I dunno what to say"})
    .end()

echo(result)

This is obviously not as elegant as the Ruby code, but I can only play the hand I am dealt, so it needs to be in familiar CFML syntax.

Basically the construct is this:

case().when(cond).then(value).when(cond).then(value).else(value).end()

Where the condition can be either a boolean value or a function which returns one, and the value is represented as a function (so it's only actually called if it needs to be). And then when()/then() calls can be chained as much as one likes, with only the then() value for the first preceding when() condition that is true being processed. Clear? You probably already understood how the construct worked before I tried to explain it. Sorry.

Anyway, doing the design for this was greatly helped by using the BDD-flavoured unit tests that TestBox provides. I could just write out my rules, then then infill them with tests after that.

So I started with this lot (below). Just a note: this code is specifically aimed at Railo, because a few things I needed to do simply weren't possible with ColdFusion.

// TestCase.cfc
component extends="testbox.system.BaseSpec" {

    function run(){
        describe("Tests for case()", function(){
            describe("Tests for case() function", function(){
                it("compiles when called", function(){})
                it("returns when() function", function(){})
            });
            describe("Tests for when() function", function(){
                it("is a function", function(){})
                it("requires a condition argument", function(){})
                it("accepts a condition argument which is a function", function(){})
                it("accepts a condition argument which is a boolean", function(){})
                it("rejects a condition argument is neither a function nor a boolean", function(){})
                it("returns a struct containing a then() function", function(){})
                it("can be chained", function(){
                })
                it("correctly handles a function returning true as a condition", function(){})
                it("correctly handles a function returning false as a condition", function(){})
                it("correctly handles a boolean true as a condition", function(){})
                it("correctly handles a boolean false as a condition", function(){})
            })
            describe("Tests for then() function", function(){
                it("is a function", function(){})
                it("requires a value argument", function(){})
                it("requires a value argument which is a function", function(){})
                it("returns a struct containing when(), else() and end() functions", function(){})
                it("can be chained", function(){})
                it("executes the value", function(){})
                it("doesn't execute a subsequent value when the condition is already true", function(){})
                it("doesn't execute a false condition", function(){})
            })
            describe("Tests for else() function", function(){
                it("is a function", function(){})
                it("requires a value argument", function(){})
                it("requires a value argument which is a function", function(){})
                it("returns a struct containing an end() function", function(){})
                it("cannot be chained", function(){})
                it("executes when the condition is not already true", function(){})
                it("doesn't execute when the condition is already true", function(){})
            })
            describe("Tests for end() function", function(){
                it("is a function", function(){})
                it("returns the result", function(){})
                it("returns the result of an earlier true condition followed by false conditions", function(){})
                it("returns the result of the first true condition", function(){})
            })
        })
    }
}

TestBox is cool in that I can group the sets of tests with nested describe() calls. This doesn't impact how the tests are run - well as far as it impacts my intent, anyhow - it just makes for clearer visual output, and also helps me scan down to make sure I've covered all the necessary bases for the intended functionality.

I then chipped away at the functionality of each individual sub function, making sure they all worked as I went. I ended up with this test code:



// TestCase.cfc
component extends="testbox.system.BaseSpec" {

    function beforeAll(){
        include "case.cfm"

        variables.validateKeysReturnedFromCase = function(required struct struct, required array keys){
            if (struct.count() != keys.len()){
                return false
            }
            return keys.every(function(key){
                return struct.keyExists(key) && isClosure(struct[key])
            })
        }
        variables.passException = function(){
            throw(type="ValueExecutedCorrectlyException")
        }
        variables.failException = function(){
            throw(type="ValueExecutedIncorrectlyException")
        }
        variables.passValue = function(){
            return "ValueExecutedCorrectly"
        }
        variables.failValue = function(){
            return "ValueExecutedIncorrectly"
        }
        variables.callback = function(){}
    }

    function run(){
        describe("Tests for case()", function(){
            describe("Tests for case() function", function(){
                it("compiles when called case()", function(){
                    expect(
                        isCustomFunction(case)
                    ).toBeTrue()
                })
                it("returns when() function", function(){
                    var test = case()
                    expect(validateKeysReturnedFromCase(test, ["when"])).toBeTrue()
                })
            });
            describe("Tests for when() function", function(){
                beforeEach(function(){
                    test = case()
                })
                it("is a function", function(){
                    expect(
                        isClosure(test.when) 
                    ).toBeTrue()
                })
                it("requires a condition argument", function(){
                    expect(function(){
                        test.when()
                    }).toThrow("MissingArgumentException")
                })
                it("accepts a condition argument which is a function", function(){
                    expect(function(){
                        test.when(callback)
                    })._not().toThrow("InvalidArgumentException")
                })
                it("accepts a condition argument which is a boolean", function(){
                    expect(function(){
                        test.when(true)
                    })._not().toThrow("InvalidArgumentException")
                })
                it("rejects a condition argument is neither a function nor a boolean", function(){
                    expect(function(){
                        test.when("NOT_A_FUNCTION")
                    }).toThrow("InvalidArgumentException")
                })
                it("returns a struct containing a then() function", function(){
                    var result = test.when(true)
                    expect(validateKeysReturnedFromCase(result, ["then"])).toBeTrue()
                })
                it("can be chained", function(){
                    var result = test.when(true).then(callback).when(true)
                    expect(validateKeysReturnedFromCase(result, ["then"])).toBeTrue()
                })
                it("correctly handles a function returning true as a condition", function(){
                    var result = test.when(function(){return true}).then(passValue).end()
                    expect(result).toBe(variables.passValue())
                })
                it("correctly handles a function returning false as a condition", function(){
                    var result = test.when(function(){return false}).then(failValue).end() ?: variables.passValue()
                    expect(result).toBe(variables.passValue())
                })
                it("correctly handles a boolean true as a condition", function(){
                    var result = test.when(true).then(passValue).end()
                    expect(result).toBe(variables.passValue())
                })
                it("correctly handles a boolean false as a condition", function(){
                    var result = test.when(false).then(failValue).end() ?: variables.passValue()
                    expect(result).toBe(variables.passValue())
                })
            })
            describe("Tests for then() function", function(){
                beforeEach(function(){
                    test = case()
                })
                it("is a function", function(){
                    expect(
                        isClosure(test.when(true).then) 
                    ).toBeTrue()
                })
                it("requires a value argument", function(){
                    expect(function(){
                        test.when(true).then(notvalue=callback)
                    }).toThrow("MissingArgumentException")
                })
                it("requires a value argument which is a function", function(){
                    expect(function(){
                        test.when(true).then("NOT_A_FUNCTION")
                    }).toThrow("InvalidArgumentException")
                })
                it("returns a struct containing when(), else() and end() functions", function(){
                    var result = test.when(true).then(callback)
                    expect(validateKeysReturnedFromCase(result, ["when", "else", "end"])).toBeTrue()
                })
                it("can be chained", function(){
                    var result = test.when(true).then(callback).when(true).then(callback)
                    expect(validateKeysReturnedFromCase(result, ["when", "else", "end"])).toBeTrue()
                })
                it("executes the value", function(){
                    expect(function(){
                        test.when(true).then(passException)
                    }).toThrow("ValueExecutedCorrectlyException")
                })
                it("doesn't execute a subsequent value when the condition is already true", function(){
                    expect(function(){
                        test.when(true).then(passException).when(true).then(failException)
                    })._not().toThrow("ValueExecutedIncorrectlyException")
                })
                it("doesn't execute a false condition", function(){
                    expect(function(){
                        test.when(false).then(failException).when(true).then(passException)
                    })._not().toThrow("ValueExecutedIncorrectlyException")
                })
            })
            describe("Tests for else() function", function(){
                beforeEach(function(){
                    test = case()
                })
                it("is a function", function(){
                    expect(
                        isClosure(test.when(true).then(callback).else) 
                    ).toBeTrue()
                })
                it("requires a value argument", function(){
                    expect(function(){
                        test.when(true).then(callback).else()
                    }).toThrow("MissingArgumentException")
                })
                it("requires a value argument which is a function", function(){
                    expect(function(){
                        test.when(true).then(callback).else(value="NOT_A_FUNCTION")
                    }).toThrow("InvalidArgumentException")
                })
                it("returns a struct containing an end() function", function(){
                    var result = test.when(true).then(callback).else(callback)
                    expect(validateKeysReturnedFromCase(result, ["end"])).toBeTrue()
                })
                it("cannot be chained", function(){
                    var result = 
                    expect(function(){
                        test.when(true).then(callback).else(callback).else(callback)
                    }).toThrow()
                })
                it("executes when the condition is not already true", function(){
                    expect(function(){
                        test.when(false).then(passException).else(failException)
                    }).toThrow("ValueExecutedIncorrectlyException")
                })
                it("doesn't execute when the condition is already true", function(){
                    expect(function(){
                        test.when(true).then(passException).else(failException)
                    })._not().toThrow("ValueExecutedIncorrectlyException")
                })
            })
            describe("Tests for end() function", function(){
                it("is a function", function(){
                    expect(
                        isClosure(test.when(true).then(callback).end) 
                    ).toBeTrue()
                })
                it("returns the result", function(){
                    expect(
                        case().when(true).then(passValue).end() 
                    ).toBe(passValue())
                })
                it("returns the result of an earlier true condition followed by false conditions", function(){
                    expect(
                        case().when(true).then(passValue).when(false).then(failValue).end() 
                    ).toBe(passValue())
                })
                it("returns the result of the first true condition", function(){
                    expect(
                        case().when(true).then(passValue).when(false).then(failValue).end() 
                    ).toBe(passValue())
                })
            })
        })
    }
}

Don't worry about poring over that: most of it is repetition, but there's a few key points:
  • validateKeyReturnedFromCase(). I factored this out because I was calling the same logic a few times. In hindsight I should have made this into a custom assertion, as that'd be a good demonstration of TestBox's functionality. I might do this later.
  • for a few of the tests I need to test that code doesn't get called, or does get called in favour of latter code. To do this I created a pair of functions which simply raise exceptions, which I'd use as values for calls to then(). So if those values get used, processing halts and I can catch the exception. I've given these functions clear name so it makes reading the test code easier. Well that's my intent here.
  • similarly I was repeatedly needing when() conditions which were true or false, so instead of having to write the function out inline, I factored those out into separate functions too. Again, just to make the test code clearer when reading it.
  • plus I created a vanilla empty callback() function which I use in intermediary calls when not actually testing that functionality (eg: where testing else(), but need to give some arbitrary value for the preceding then() call.
  • _not() is a cool approach to switching the expectation logic around, I think.
  • one general thing I did not notice when writing this code, but is apparent now that I'm going over it now is how short all the tests are: just a line or two of code. But I think this is really rigorous testing for some moderately complicated code. Speaking of which...

// case.cfm
struct function case(){
    var hasConditionBeenMet    = hasConditionBeenMet    ?: false
    var hasResultBeenSet    = hasResultBeenSet        ?: false

    var result = result ?: javacast("null", "")

    var validateCondition = function(condition){
        condition ?: throw(type="MissingArgumentException")
        isBoolean(condition) || isCustomFunction(condition) || isClosure(condition) ? true : throw(type="InvalidArgumentException")
    }

    var validateValue = function(value){
        value ?: throw(type="MissingArgumentException")
        isCustomFunction(value) || isClosure(value) ? true : throw(type="InvalidArgumentException")
    }

    var caseArgs = function(){
        return {
            hasConditionBeenMet    = hasConditionBeenMet,
            hasResultBeenSet    = hasResultBeenSet,
            result                = result ?: javacast("null", ""),
            functionsToReturn    = arguments[1]
        }
    }

    param functionsToReturn = ["when"];
    var functions = {}
    if (arrayFind(functionsToReturn, "when")){
        functions.when = function(condition){
            validateCondition(argumentCollection=arguments)
            if (!hasConditionBeenMet){
                if (isBoolean(condition)){
                    hasConditionBeenMet = condition
                } else{
                    hasConditionBeenMet = condition() ?: false
                }
            }
            return case(argumentCollection=caseArgs(["then"]))
        }
    }
    if (arrayFind(functionsToReturn, "then")){
        functions.then = function(value){
            validateValue(argumentCollection=arguments)
            if (hasConditionBeenMet && !hasResultBeenSet){
                result        = value()
                hasResultBeenSet    = true
            }
            return case(argumentCollection=caseArgs(["when","else","end"]))
        }
    }
    if (arrayFind(functionsToReturn, "else")){
        functions.else = function(value){
            validateValue(argumentCollection=arguments)
            if (!hasConditionBeenMet){
                result = value()
            }
            return case(argumentCollection=caseArgs(["end"]))
        }
    }
    if (arrayFind(functionsToReturn, "end")){
        functions.end = function(){
            return result ?: javacast("null", "")
        }
    }

    return functions
}

I realise this is very long for a UDF, but I do think it's all cohesive as a "single" bit of functionality. I could have made it more terse, but I wanted to keep the code as clear as possible.

The trick to the whole thing, and the bit that took some effort to get my poor old brain around, is that each function returns a call to the case() function itself, and passes in the state of itself to its next call, plus advice on what to return next (if that makes sense). This was the bit I was most unhappy about here, as it seems like a pretty jerry-built way of doing things. Instead of manually doing this, I kinda wanted to "return this", or something like that, where this is the instance of the function itself. Still: this works, and it's OK logic to follow, if not perfect.

Basically each function knows the state of the whole chain of calls. The state is comprises three key parts:
  • whether or not one of the conditions of a previous when() call has been met. We need to do this because as soon as a true condition is met, we don't need to check any further conditions. but we still need to go through the motions of traversing through the chain through to the exit point at end().
  • similarly, once the condition is met, the next then() call needs to actually process its value. And once that's done, no subsequent then() call should execute its value.
  • and each sub-function can only be followed by a subset of the other sub-functions: a when() can only be followed by a then(), but a then can be followed by another when(), or an else(), or an end(), etc.

So each call in the chain needs to pass these to the next call in the chain.

Beyond that, it's down to the functionality of each sub-function:
  • when() checks to see if the condition has already been met, and if not, checks its own condition, passing on the over-all result to the next element in the chain.
  • then() checks if the condition has been met, and if a value has already been set. Because then() should only execute its value if it's the first call to then() after the condition has been met. Subsequent ones are ignored. This is why the value... erm... values.... are best implemented as function expressions, so the code doesn't execute until it's needed. Unlike say a <cfparam> call which always executes the default value, even if it's not needed.
  • else() behaves like then(), for all intents and purposes. It just doesn't need to set the hasResultBeenSet variable, as the only thing that can follow an else() call is end(), which doesn't need to know about that.
  • end() simply returns the result.
There's a coupla general coding notes to make too:

  • I probably should scope this one: it's saying that the local variable hasConditionBeenMet will take the value of a passed-in argument of that name if it was passed, otherwise initialise it as false.
  • One cannot do this in ColdFusion. Despite looking like a function, in ColdFusion throw() actually isn't, it seems. Because one cannot use it in an expression. Railo works properly. So this is just a quick way of validating the passed-in arguments, throwing an exception if they don't validate.
  • Oh yeah... the first show-stopper on ColdFusion is that one cannot have a UDF called case() as it's a reserved word. Railo is clever enough to understand the context I'm using it here is different from the context I'd use a case statement.
  • On rare occasions, one still needs to use semi-colons in Railo's CFML implementation. This syntax of param statement is one of them. This is just because the syntax becomes ambiguous to the parser without it.

I think that's about it. I'm not really suggesting this function ought to be used... it was just an interesting exercise for some TDD / BDD tests, as well as getting my brain around how to do that chaining of function calls which maintain awareness of what the preceding elements in the chain had done. It'd quite possibly well-trod ground, but it's good to try these things for one's self sometimes.

Finally, what do you think about the idea of having a case/when construct in CFML itself? I haven't raised tickets with Adobe/Railo yet..?

Righto.

--

Adam