Search code examples
coldfusioncastingrailocfclucee

Any way to enforce a property's type in a CFC?


I'm experiencing some weird typecasting issues here, not specific to Lucee (also in Railo). Could be that I'm just missing some crucial point here though...

I have a component:

<cfcomponent output="false">
    <cfproperty name="thisId" type="String" default="-1" />
    <cfproperty name="thatId" type="String" default="-1" />
</cfcomponent>

Both properties are clearly typed as strings. I would expect that when I try to set an object or a number to one of these, the code would return an error. But, seeing as I'm used to cfml doing the typecasting for me by now, I never thought twice about the fact that setting a number here is no problem at all. I was in fact under the assumption that all number I would try to set here would be cast to strings for me.

It seems this is not the case though. After implementing some REST calls that contain derivatives of these components in the form of a serialized struct, I onticed that some where included as an integer and some were included as a string. When I noticed that, I dumped out the component itself and notice that setting a number where a string was expected as a property, the typing had been overwritten as a number.

The fact that Railo / Lucee still validates is in my opinion beyond useless. Either validate strict typing and throw an error / pass in a correctly typed variable, or validate loose typing and convert to the type the CFC is expecting if that is possible. Railo / lucee implemented loose type validation here, but still decides to pass in the variable in its original type, not what the cfc expects per sé.

Seeing as I don't want to be typecasting every number to a string right now, is there a simple oversight here that could salvage my typing?

(I've already posted this in the Lucee mailing list, but without any results, just people confirming what I already said / disregarding the possibility that this is not expected behaviour.)


update (as asked by Adam): What I see is the following (in my cfc component described above):

<!--- setting a string returns a string afterwards, as expected since the property is a type string initialy --->
<cfset componentName.setThisId('1') />
<cfset local.thisIsStillAString = componentName.getThisId() />

<!--- setting a number returns a number, which means we can no longer assume the property is a string, as it was initially set up --->
<cfset componentName.setThatId(12345) />
<cfset local.thisIsNoLongerAString = componentName.getThatId() />

In both cases I would expect that either: - the input variable would get strictly evaluated as a string, which means that the second example would raise an error, seeing that it is actually a number - the input variable would get loosely evaluated as a string, but would be cast to a string when passing the evaluation, which would mean that the second example would pass but would ultimately return a string, no longer a number.

In any case I would expect the property's original typing to be preserved, instead it gets changed to whatever type you're trying to set, as long as it passes the current loose evaluation.


Solution

  • As I understand it, the built-in accessor setters will do automatic type-validation, but will only do casting where necessary and possible.

    Numeric/string/date/boolean values are all considered "simple", which is why the numeric data is passing the "string" type-validation. Therefore, because it passed the validation, the casting is skipped. On a personal note, I would prefer if it did more rigorous validation, but that's an issue for the bugtracker.

    Now, if you must ensure that only actual string data can make its way into those properties, you can override the generated setter for the property to do more rigorous type-casting and/or validation (I've tested this only on Lucee):

    /** Example.cfc */
    component accessors=true {
        property type="string" name="thisId";
        property type="string" name="thatId";
    
        public function setThisId(required string newId) {
            // convert numeric value to string value
            if (isNumeric(newId)) {
                newId = toString(newId);
    
            // throw an exception for non-string/numeric values
            // !isSimpleValue() is a catch-all btw, structs and arrays will
            // be prevented by the "newId" argument's type hint
            } else if (isBoolean(newId) || isDate(newId) || !isSimpleValue(newId)) {
                throw(message="Invalid value specified for thisId");
            }
    
            variables.thisId = newId;
            return this;
        }
    }
    
    var example = new Example();
    
    example.setThisId(54321);
    example.setThatId(54321);
    writeoutput(serializeJson(example)); //{"thisId":"54321","thatId":54321}
    
    // throws exceptions:
    example.setThisId(true);
    example.setThisId({});
    

    Finally, getting back to the "casting where necessary and possible" part. For the given example, if you were to try passing a component instance to the setThisId() method, it fails the type-validation step, meaning type-casting is necessary for the operation to succeed. So then the value is checked for the possibility of type-casting. If the component (and this only works on Railo/Lucee) has a _toString() "magic method" defined, then type-casting is possible. Since it is possible, the component is then cast to a string, and the result then passed into setThisId(). If that magic method is not defined on the component, it is not possible to do type casting, and an exception is thrown. Similarly for structs/arrays, type-casting is necessary, but not possible as there is no automatic serialization defined for those types, thus resulting in an exception being thrown.

    TL;DR

    You can override the setter accessor to do more rigorous type-validation/type-casting.