diff --git a/core/src/main/java/lucee/runtime/ComponentImpl.java b/core/src/main/java/lucee/runtime/ComponentImpl.java index e7fd2b6c770..304364a0934 100755 --- a/core/src/main/java/lucee/runtime/ComponentImpl.java +++ b/core/src/main/java/lucee/runtime/ComponentImpl.java @@ -2442,9 +2442,12 @@ else if (propOwnerPS == null) { } top.properties.properties.put(propNameLower, propImpl); - if (propImpl.getDefaultAsObject() != null) { + Object defaultObj = propImpl.getDefaultAsObject(); + if (defaultObj != null && !(defaultObj instanceof lucee.runtime.component.ExpressionDefault)) { + // Expression-form defaults are seeded by the pseudo-constructor's inline emission + // (see TagProperty.emitExpressionEvalAndSet); only literal defaults are eager-set here. Key propKey = propImpl.getNameAsKey(); - scope.setEL(propKey, propImpl.getDefaultAsObject()); + scope.setEL(propKey, defaultObj); if (ownPropertyDefaults == null) ownPropertyDefaults = new HashSet<>(); ownPropertyDefaults.add(propKey); } diff --git a/core/src/main/java/lucee/runtime/component/ExpressionDefault.java b/core/src/main/java/lucee/runtime/component/ExpressionDefault.java new file mode 100644 index 00000000000..dea7b5fa043 --- /dev/null +++ b/core/src/main/java/lucee/runtime/component/ExpressionDefault.java @@ -0,0 +1,66 @@ +package lucee.runtime.component; + +import java.util.Set; + +import lucee.commons.lang.StringUtil; +import lucee.runtime.PageContext; +import lucee.runtime.converter.ScriptConvertable; +import lucee.runtime.dump.DumpData; +import lucee.runtime.dump.DumpProperties; +import lucee.runtime.dump.DumpTable; +import lucee.runtime.dump.Dumpable; +import lucee.runtime.dump.SimpleDumpData; + +/** Immutable wrapper for cfproperty expression-form defaults; surfaces in metadata.default. */ +public final class ExpressionDefault implements Dumpable, ScriptConvertable { + + // Required by Serializable (inherited via Dumpable) but never consulted: components rehydrate + // via ComponentImpl.readExternal which re-instantiates from the compiled class, not byte-level. + private static final long serialVersionUID = 1L; + + private final String source; + + public ExpressionDefault(String source) { + this.source = source == null ? "" : source; + } + + public String getSource() { + return source; + } + + @Override + public String toString() { + return source; + } + + @Override + public DumpData toDumpData(PageContext pageContext, int maxlevel, DumpProperties properties) { + DumpTable table = new DumpTable("Expression", "#ff6600", "#ffcc99", "#000000"); + table.appendRow(1, new SimpleDumpData("Expression"), new SimpleDumpData(source)); + return table; + } + + @Override + public String serialize() { + return serialize(null); + } + + @Override + public String serialize(Set done) { + // escapeJS produces JSON-compatible string output (already wraps in quotes); same pattern + // JSONConverter uses for date serialisation. + return "{\"expression\":" + StringUtil.escapeJS(source, '"') + "}"; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof ExpressionDefault)) return false; + return source.equals(((ExpressionDefault) obj).source); + } + + @Override + public int hashCode() { + return source.hashCode(); + } +} diff --git a/core/src/main/java/lucee/runtime/component/PropertyImpl.java b/core/src/main/java/lucee/runtime/component/PropertyImpl.java index 11e8c78dd9f..f935f6e14d8 100644 --- a/core/src/main/java/lucee/runtime/component/PropertyImpl.java +++ b/core/src/main/java/lucee/runtime/component/PropertyImpl.java @@ -81,6 +81,8 @@ protected final int hash() { @Override public String getDefault() { if (_default == null) return null; + // expression-form: source string would corrupt callers piping it into scope.set + if (_default instanceof ExpressionDefault) return null; try { return Caster.toString(_default); } diff --git a/core/src/main/java/lucee/transformer/bytecode/PageImpl.java b/core/src/main/java/lucee/transformer/bytecode/PageImpl.java index fc0a402ae9c..057a1f0f65e 100755 --- a/core/src/main/java/lucee/transformer/bytecode/PageImpl.java +++ b/core/src/main/java/lucee/transformer/bytecode/PageImpl.java @@ -1229,10 +1229,9 @@ private void writeOutStatic(PageSource optionalPS, ConstrBytecodeContext constr, // prop.setDefault(value) if it's a simple literal // Only handle simple literals (Literal interface) - complex expressions like now() // or #myVar# need PageContext and must be evaluated at runtime, not class-load time - if (propDefaultAttr != null && propDefaultAttr.getValue() instanceof Literal) { + if (propDefaultAttr != null && propDefaultAttr.getValue() != null) { Expression defaultExpr = propDefaultAttr.getValue(); - // Handle simple literals only - complex expressions handled at runtime if (defaultExpr instanceof LitStringImpl) { String value = ((LitStringImpl) defaultExpr).getString(); ga.loadLocal(propLocal); @@ -1253,7 +1252,17 @@ else if (defaultExpr instanceof LitBooleanImpl) { ga.box(Type.BOOLEAN_TYPE); ga.invokeVirtual(Types.PROPERTY_IMPL, new Method("setDefault", Type.VOID_TYPE, new Type[] { Types.OBJECT })); } - // else: complex expression - will be handled at runtime in TagProperty + else { + String source = propDefaultAttr.getRawValue(); + if (source == null) source = ""; + Type EXPRESSION_DEFAULT = Type.getType("Llucee/runtime/component/ExpressionDefault;"); + ga.loadLocal(propLocal); + ga.newInstance(EXPRESSION_DEFAULT); + ga.dup(); + ga.push(source); + ga.invokeConstructor(EXPRESSION_DEFAULT, new Method("", Type.VOID_TYPE, new Type[] { Types.STRING })); + ga.invokeVirtual(Types.PROPERTY_IMPL, new Method("setDefault", Type.VOID_TYPE, new Type[] { Types.OBJECT })); + } } // Collect dynamic attributes (non-standard attributes) diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagProperty.java b/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagProperty.java index bb9a7ad274e..190dc8432d7 100644 --- a/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagProperty.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagProperty.java @@ -63,37 +63,36 @@ public void _writeOut(BytecodeContext bc) throws TransformerException { Attribute defaultAttr = tag.getAttribute("default"); Attribute nameAttr = tag.getAttribute("name"); - // Only handle complex defaults - simple literals are already handled in CLINIT if (defaultAttr != null && nameAttr != null && defaultAttr.getValue() != null) { Expression defaultExpr = defaultAttr.getValue(); String propName = getLiteralString(nameAttr); - // Check if it's a complex expression (not a simple literal) - boolean isComplex = !isSimpleLiteral(defaultExpr); - - if (isComplex && propName != null) { - final GeneratorAdapter adapter = bc.getAdapter(); - - // Evaluate the default expression with the current PageContext - defaultExpr.writeOut(bc, Expression.MODE_REF); - int defaultLocal = adapter.newLocal(Types.OBJECT); - adapter.storeLocal(defaultLocal); - - // Get PageContext from arg0 and set the value in variables scope - // pc.variablesScope().setEL(KeyImpl.init(propName), defaultValue) - adapter.loadArg(0); // Load PageContext pc - adapter.invokeVirtual(Types.PAGE_CONTEXT, new Method("variablesScope", Types.VARIABLES, new Type[] {})); - adapter.push(propName); - adapter.invokeStatic(Type.getType("Llucee/runtime/type/KeyImpl;"), new Method("init", Types.COLLECTION_KEY, new Type[] { Types.STRING })); - adapter.loadLocal(defaultLocal); - adapter.invokeInterface(Types.SCOPE, new Method("setEL", Types.OBJECT, new Type[] { Types.COLLECTION_KEY, Types.OBJECT })); - adapter.pop(); // Pop return value + if (!isSimpleLiteral(defaultExpr) && propName != null) { + emitExpressionEvalAndSet(bc, propName, defaultExpr); } } bc.visitLine(tag.getEnd()); } + /** Emits eval + variablesScope.setEL bytecode for one cfproperty expression-form default, + * inline in the pseudo-constructor. Slot is empty at this point — ComponentImpl skips + * eager-setting the wrapper into scope at init for expression-form defaults. */ + public static void emitExpressionEvalAndSet(BytecodeContext bc, String propName, Expression defaultExpr) throws TransformerException { + final GeneratorAdapter adapter = bc.getAdapter(); + defaultExpr.writeOut(bc, Expression.MODE_REF); + int defaultLocal = adapter.newLocal(Types.OBJECT); + adapter.storeLocal(defaultLocal); + + adapter.loadArg(0); + adapter.invokeVirtual(Types.PAGE_CONTEXT, new Method("variablesScope", Types.VARIABLES, new Type[] {})); + adapter.push(propName); + adapter.invokeStatic(Type.getType("Llucee/runtime/type/KeyImpl;"), new Method("init", Types.COLLECTION_KEY, new Type[] { Types.STRING })); + adapter.loadLocal(defaultLocal); + adapter.invokeInterface(Types.SCOPE, new Method("setEL", Types.OBJECT, new Type[] { Types.COLLECTION_KEY, Types.OBJECT })); + adapter.pop(); + } + private boolean isSimpleLiteral(Expression expr) { return expr instanceof LitString || expr instanceof LitNumber || expr instanceof LitBoolean || expr instanceof LitInteger || diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index ee804e8ae04..c20ffd44687 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -2532,6 +2532,7 @@ private final Attribute attribute(TagLibTag tlt, Data data, ArrayList ar else if (oAllowExpression instanceof String) allowExpression = ((String) oAllowExpression).equalsIgnoreCase(nameLC); Expression value = null; + String rawValue = null; comments(data); @@ -2540,7 +2541,7 @@ private final Attribute attribute(TagLibTag tlt, Data data, ArrayList ar if (hasValue) { comments(data); value = attributeValue(data, allowExpression); - + rawValue = Attribute.sliceSource(data.srcCode, value); } else { value = defaultValue; @@ -2553,7 +2554,9 @@ private final Attribute attribute(TagLibTag tlt, Data data, ArrayList ar tlta = tlt.getAttribute(nameLC, true); if (tlta != null && tlta.getName() != null) nameLC = tlta.getName(); } - return new Attribute(dynamic.toBooleanValue(), name, tlta != null ? data.factory.toExpression(value, tlta.getType()) : value, sbType.toString(), !hasValue); + Attribute attr = new Attribute(dynamic.toBooleanValue(), name, tlta != null ? data.factory.toExpression(value, tlta.getType()) : value, sbType.toString(), !hasValue); + if (rawValue != null) attr.setRawValue(rawValue); + return attr; } private final String attributeName(SourceCode cfml, ArrayList args, TagLibTag tag, RefBoolean dynamic, StringBuilder sbType, boolean allowTwiceAttr, boolean allowColon) diff --git a/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java b/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java index 97e9ceee53b..ebee1229328 100755 --- a/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java @@ -1126,10 +1126,12 @@ private static Attribute attribute(Data data, TagLibTag tag, ArrayList a comment(data.srcCode, true); + String rawValue = null; if (isDefaultValue || data.srcCode.forwardIfCurrent('=')) { comment(data.srcCode, true); // Value value = attributeValue(data, tag, sbType.toString(), parseExpression[0], false, data.factory.createLitString("")); + rawValue = Attribute.sliceSource(data.srcCode, value); } // default value boolean true else { @@ -1143,7 +1145,9 @@ private static Attribute attribute(Data data, TagLibTag tag, ArrayList a } comment(data.srcCode, true); - return new Attribute(dynamic.toBooleanValue(), name, value, sbType.toString()); + Attribute attr = new Attribute(dynamic.toBooleanValue(), name, value, sbType.toString()); + if (rawValue != null) attr.setRawValue(rawValue); + return attr; } /** diff --git a/core/src/main/java/lucee/transformer/statement/tag/Attribute.java b/core/src/main/java/lucee/transformer/statement/tag/Attribute.java index b1b5d045466..35ea0a2b5bb 100755 --- a/core/src/main/java/lucee/transformer/statement/tag/Attribute.java +++ b/core/src/main/java/lucee/transformer/statement/tag/Attribute.java @@ -19,6 +19,7 @@ package lucee.transformer.statement.tag; import lucee.transformer.expression.Expression; +import lucee.transformer.util.SourceCode; public final class Attribute { @@ -30,6 +31,7 @@ public final class Attribute { private boolean defaultAttribute; private String setterName; private final boolean isDefaultValue; + private String rawValue; public Attribute(boolean dynamicType, String name, Expression value, String type) { this(dynamicType, name, value, type, false); @@ -44,6 +46,34 @@ public Attribute(boolean dynamicType, String name, Expression value, String type this.isDefaultValue = isDefaultValue; } + /** Raw source text of the attribute value, post-unwrap (i.e. now() for both + * default="#now()#" and default=#now()#). Null for synthesised + * or default-injected attributes. Public so PageImpl bytecode emission can read it + * cross-package — only loaded for cfproperty expression-form defaults today. */ + public String getRawValue() { + return rawValue; + } + + public void setRawValue(String rawValue) { + this.rawValue = rawValue; + } + + /** Capture the post-unwrap source for an Expression by slicing from the parse-time SourceCode. + * Uses Expression.getStart()/getEnd() — these point at the inner expression already, so quotes + * and hash markers fall outside the slice. The peek-back handles json()'s post-delimiter + * position-recording for { and [ literals. Returns null when positions are unavailable. */ + public static String sliceSource(SourceCode srcCode, Expression value) { + if (value == null || value.getStart() == null || value.getEnd() == null) return null; + int start = value.getStart().pos; + int end = value.getEnd().pos; + if (start > 0) { + String peek = srcCode.subCFMLString(start - 1, 1).toString(); + if (peek.length() == 1 && (peek.charAt(0) == '{' || peek.charAt(0) == '[')) start--; + } + if (end <= start) return null; + return srcCode.subCFMLString(start, end - start).toString(); + } + public boolean isDefaultValue() { return isDefaultValue; } diff --git a/test/tickets/LDEV6303.cfc b/test/tickets/LDEV6303.cfc new file mode 100644 index 00000000000..ae9d879b8f4 --- /dev/null +++ b/test/tickets/LDEV6303.cfc @@ -0,0 +1,529 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="orm" { + + function beforeAll() { + variables.uri = getDirectoryFromPath( contractPath( getCurrentTemplatePath() ) ) & "LDEV6303/orm"; + } + + private function noOrm() { + // works under mvn (server.getTestService) and http (graceful fallback) + try { + return ( structCount( server.getTestService( "orm" ) ) eq 0 ); + } catch ( any e ) { + return false; + } + } + + function run( testResults, testBox ){ + describe( "LDEV-6303: cfproperty default expression-form metadata contract", function(){ + + describe( "metadata.default surface for expression-form defaults", function(){ + + it( "exposes a default key for non-foldable expression defaults", function(){ + var inst = new LDEV6303.ExpressionDefaultsCfc(); + var meta = findProperty( getMetadata( inst ).properties, "nowDef" ); + expect( structKeyExists( meta, "default" ) ).toBeTrue( + "metadata.default key should exist for expression-form properties" + ); + }); + + it( "metadata.default for literal-form is a simple value", function(){ + var inst = new LDEV6303.ExpressionDefaultsCfc(); + var meta = findProperty( getMetadata( inst ).properties, "literalDef" ); + expect( isSimpleValue( meta.default ) ).toBeTrue(); + expect( meta.default ).toBe( "hello" ); + }); + + it( "metadata.default for expression-form is not a simple value (programmatic discriminability)", function(){ + var inst = new LDEV6303.ExpressionDefaultsCfc(); + var meta = findProperty( getMetadata( inst ).properties, "nowDef" ); + expect( isSimpleValue( meta.default ) ).toBeFalse( + "metadata.default for now() should be programmatically distinguishable from a literal-form default" + ); + }); + + it( "metadata.default for expression-form is an object (isObject=true)", function(){ + var inst = new LDEV6303.ExpressionDefaultsCfc(); + var nowMeta = findProperty( getMetadata( inst ).properties, "nowDef" ); + var literalMeta = findProperty( getMetadata( inst ).properties, "literalDef" ); + expect( isObject( nowMeta.default ) ).toBeTrue( + "expression-form metadata.default should be an object wrapper" + ); + expect( isObject( literalMeta.default ) ).toBeFalse( + "literal-form metadata.default should not be an object — it's a simple string" + ); + }); + + it( "metadata.default for expression-form stringifies to source CFML", function(){ + var inst = new LDEV6303.ExpressionDefaultsCfc(); + var meta = findProperty( getMetadata( inst ).properties, "nowDef" ); + var asString = "" & meta.default; + expect( asString ).toInclude( "now" ); + }); + + it( "metadata.default for #now()# is not a date (catches 7.0 frozen-evaluated-value)", function(){ + // 7.0 stored the evaluated DateTimeImpl in _default; metadata.default was + // therefore a Date and isDate() returned true. After fix: metadata.default + // is the source CFML string, not a Date. + var inst = new LDEV6303.ExpressionDefaultsCfc(); + var meta = findProperty( getMetadata( inst ).properties, "nowDef" ); + expect( isDate( meta.default ) ).toBeFalse( + "metadata.default for #now()# must not be a Date — that means an evaluated value was stored class-level" + ); + }); + + }); + + describe( "cross-instance isolation under mutation", function(){ + + it( "instance2 variables scope is independent of instance1 mutations", function(){ + var instance1 = new LDEV6303.ExpressionDefaultsCfc(); + var instance2 = new LDEV6303.ExpressionDefaultsCfc(); + + var s1 = instance1.getNowStructDef(); + s1.n++; + s1.n++; + s1.n++; + + expect( instance1.getNowStructDef().n ).toBe( 4 ); + expect( instance2.getNowStructDef().n ).toBe( 1 ); + }); + + it( "duplicate() variables scope is independent of source mutations", function(){ + var src = new LDEV6303.ExpressionDefaultsCfc(); + var dup = duplicate( src ); + + var ss = src.getNowStructDef(); + ss.n++; + ss.n++; + ss.n++; + + expect( src.getNowStructDef().n ).toBe( 4 ); + expect( dup.getNowStructDef().n ).toBe( 1 ); + }); + + it( "mutating instance1's struct does not propagate to instance2's metadata.default", function(){ + var instance1 = new LDEV6303.ExpressionDefaultsCfc(); + var instance2 = new LDEV6303.ExpressionDefaultsCfc(); + + var s1 = instance1.getNowStructDef(); + s1.n++; + s1.n++; + s1.n++; + + var meta2 = findProperty( getMetadata( instance2 ).properties, "nowStructDef" ); + expect( structKeyExists( meta2, "default" ) ).toBeTrue(); + + // 7.0 latent bug: metadata.default was a shared mutable struct, n became 4 here. + // After fix: metadata.default is an immutable wrapper, no mutation surface. + if ( isStruct( meta2.default ) ) { + expect( meta2.default.n ).toBe( 1, + "metadata.default for instance2 must not see instance1's variables-scope mutations" + ); + } + }); + + it( "first-instance mutation does not propagate to second-instance metadata (catches 7.0 shared _default leak)", function(){ + // LeakProbeCfc must NOT be instantiated outside this test — otherwise + // the first-instance-aliased-with-_default behaviour has already + // happened on a different instance, and this test silently passes. + var instance1 = new LDEV6303.LeakProbeCfc(); + var bag1 = instance1.getBag(); + bag1.n++; + bag1.n++; + bag1.n++; + + var instance2 = new LDEV6303.LeakProbeCfc(); + var meta2 = findProperty( getMetadata( instance2 ).properties, "bag" ); + expect( structKeyExists( meta2, "default" ) ).toBeTrue(); + + // On 7.0 with the leak: instance1.variables.bag === _default, + // mutating bag.n leaks into _default which is read by metadata for + // any other instance. instance2's metadata.bag.default.n would be 4. + // After fix: metadata.default is an immutable wrapper, no leak path. + // Serialize defensively — meta2.default may be a String wrapper or a struct. + var defaultStr = isSimpleValue( meta2.default ) ? "" & meta2.default : serializeJson( meta2.default ); + expect( defaultStr ).notToInclude( """n"":4", + "metadata.default for instance2 must not carry instance1's mutated counter" + ); + + // instance2's variables scope must also be unaffected + expect( instance2.getBag().n ).toBe( 1 ); + }); + + it( "metadata.default for nowDef is stable across instances and reads", function(){ + // 7.0 latent bug: metadata.default was frozen at first-eval time of any instance, + // returning the same value across all instances of the class — even though each + // instance's own variables-scope nowDef is correctly fresh per construction. + // After fix: metadata.default is the source CFML, identical and stable across reads. + var instance1 = new LDEV6303.ExpressionDefaultsCfc(); + sleep( 1100 ); + var instance2 = new LDEV6303.ExpressionDefaultsCfc(); + + var meta1 = findProperty( getMetadata( instance1 ).properties, "nowDef" ); + var meta2 = findProperty( getMetadata( instance2 ).properties, "nowDef" ); + + // instance variables-scope values must differ (per-instance freshness preserved) + expect( dateCompare( instance2.getNowDef(), instance1.getNowDef(), "s" ) ).toBeGT( 0, + "per-instance nowDef must be fresh per construction" + ); + + // metadata.default values must stringify equally — stable contract, + // source string, not a frozen evaluated value that differs between instances + expect( "" & meta1.default ).toBe( "" & meta2.default ); + }); + + }); + + describe( "ORM null-substitution end-to-end (LDEV-4121 contract)", function(){ + + it( "expression-form default is reachable via variables scope, the canonical source for ORM null-substitution", function(){ + // Construction-time evaluated value in variables scope is the authoritative + // answer for ORM null-substitution. This is what direction-3 in the Hibernate + // extension's CFCSetter.set reads; this test confirms scope holds the right + // value for that read to find. + var inst = new LDEV6303.ExpressionDefaultsCfc(); + expect( inst.getExpressionDef() ).toBe( "xxxxx" ); + expect( isStruct( inst.getNowStructDef() ) ).toBeTrue(); + expect( inst.getNowStructDef().n ).toBe( 1 ); + }); + + it( "construction-time evaluation captures the evaluated value, not the source string", function(){ + // Distinguishes "the wrapper landed in variables scope" (wrong) from + // "the evaluated expression result landed in variables scope" (correct). + // If the bytecode mistakenly stamps the wrapper into variables scope + // instead of the evaluated value, this test catches it. + var inst = new LDEV6303.ExpressionDefaultsCfc(); + expect( isSimpleValue( inst.getNowStructDef() ) ).toBeFalse( + "per-instance variables scope must hold the evaluated struct, not the source-string wrapper" + ); + expect( isDate( inst.getNowDef() ) ).toBeTrue( + "per-instance variables scope for #now()# must hold the evaluated DateTime" + ); + }); + + it( title="entityLoad with NULL DB column substitutes the literal-form default", skip=noOrm(), body=function(){ + var result = _InternalRequest( template: "#variables.uri#/test.cfm", forms: { scene: "literal" } ); + expect( result.filecontent.trim() ).toBe( "literal-default" ); + }); + + it( title="entityLoad with NULL DB column substitutes the expression-form default (LDEV-4121b)", skip=noOrm(), body=function(){ + var result = _InternalRequest( template: "#variables.uri#/test.cfm", forms: { scene: "expression" } ); + expect( result.filecontent.trim() ).toBe( "xxxxx" ); + }); + + }); + + describe( "per-instance variables scope is correctly seeded at construction", function(){ + + it( "expression-form default is evaluated and seeded into variables scope", function(){ + var inst = new LDEV6303.ExpressionDefaultsCfc(); + expect( inst.getExpressionDef() ).toBe( "xxxxx" ); + }); + + it( "non-deterministic expression evaluates fresh per construction", function(){ + var t1 = new LDEV6303.ExpressionDefaultsCfc().getNowDef(); + sleep( 1100 ); + var t2 = new LDEV6303.ExpressionDefaultsCfc().getNowDef(); + expect( dateCompare( t2, t1, "s" ) ).toBeGT( 0 ); + }); + + }); + + describe( "source-slice precision for expression-form defaults — locked invariants", function(){ + + it( "function-call source captured verbatim (start at function name)", function(){ + var inst = new LDEV6303.NullProbeCfc(); + var meta = getMetadata( inst ); + var prop = ""; + for ( var p in meta.properties ) { + if ( p.name == "funcCallDef" ) { prop = p; break; } + } + expect( "" & prop.default ).toBe( "repeatString( 'x', 5 )" ); + }); + + it( "empty struct literal source includes both braces", function(){ + var inst = new LDEV6303.NullProbeCfc(); + var meta = getMetadata( inst ); + var prop = ""; + for ( var p in meta.properties ) { + if ( p.name == "emptyStructDef" ) { prop = p; break; } + } + expect( "" & prop.default ).toBe( "{}" ); + }); + + it( "empty array literal source includes both brackets", function(){ + var inst = new LDEV6303.NullProbeCfc(); + var meta = getMetadata( inst ); + var prop = ""; + for ( var p in meta.properties ) { + if ( p.name == "emptyArrayDef" ) { prop = p; break; } + } + expect( "" & prop.default ).toBe( "[]" ); + }); + + it( "populated struct literal source includes opening brace", function(){ + var inst = new LDEV6303.NullProbeCfc(); + var meta = getMetadata( inst ); + var prop = ""; + for ( var p in meta.properties ) { + if ( p.name == "twoKeyStruct" ) { prop = p; break; } + } + expect( "" & prop.default ).toBe( "{a:1,b:2}" ); + }); + + it( "populated array literal source includes opening bracket", function(){ + var inst = new LDEV6303.NullProbeCfc(); + var meta = getMetadata( inst ); + var prop = ""; + for ( var p in meta.properties ) { + if ( p.name == "threeItemArray" ) { prop = p; break; } + } + expect( "" & prop.default ).toBe( "[""x"",""y"",""z""]" ); + }); + + }); + + describe( "null-edge probe — observation only, surfaces actual current behavior to test output", function(){ + + it( "probes null-edge cases for the null-evaluating-expression vs no-default vs empty-string distinction", function(){ + var inst = new LDEV6303.NullProbeCfc(); + var props = getMetadata( inst ).properties; + + systemOutput( chr(10) & "===== LDEV-6303 null-edge probe =====" & chr(10), true ); + for ( var p in props ) { + var sub = inst._probe( p.name ); + var line = " [" & p.name & "]" + & " metaHasDefault=" & structKeyExists( p, "default" ) + & " metaIsSimple=" & ( structKeyExists( p, "default" ) ? ( isNull( p.default ) ? "" : isSimpleValue( p.default ) ) : "" ) + & " metaIsObject=" & ( structKeyExists( p, "default" ) ? ( isNull( p.default ) ? "" : isObject( p.default ) ) : "" ) + & " metaDefault=" & ( structKeyExists( p, "default" ) ? ( isNull( p.default ) ? "" : "[" & ( isSimpleValue( p.default ) ? p.default : serializeJson( p.default ) ) & "]" ) : "" ) + & " varsHasKey=" & sub.varsHasKey + & " varsValue=" & sub.varsValue; + systemOutput( line, true ); + } + systemOutput( "===== probe end =====" & chr(10), true ); + + // Trivial assertion to keep the spec green; the probe is observation-only. + expect( arrayLen( props ) ).toBeGTE( 5 ); + }); + + }); + + describe( "inherited expression-form defaults — child must see evaluated values, not wrappers", function(){ + + it( "child instance scope holds the evaluated value for inherited deterministic expression default", function(){ + var inst = new LDEV6303.ChildExprDefCfc(); + expect( inst.getParentExprDef() ).toBe( "ppppp", + "inherited expression-form default must be evaluated, not the wrapper source string" + ); + }); + + it( "child instance scope holds the evaluated value for inherited literal default", function(){ + var inst = new LDEV6303.ChildExprDefCfc(); + expect( inst.getParentLiteralDef() ).toBe( "parent-literal" ); + }); + + it( "child instance scope holds an evaluated DateTime for inherited #now()# default", function(){ + var inst = new LDEV6303.ChildExprDefCfc(); + expect( isDate( inst.getParentNowDef() ) ).toBeTrue( + "inherited #now()# default must be evaluated to a DateTime, not stay as wrapper" + ); + }); + + it( "child's own expression-form default still works alongside inherited ones", function(){ + var inst = new LDEV6303.ChildExprDefCfc(); + expect( inst.getChildExprDef() ).toBe( "ccc" ); + }); + + it( "metadata.default for inherited expression-form is preserved on meta.extends.properties", function(){ + // Inherited properties live under meta.extends.properties, not meta.properties — + // standard CFML inheritance metadata shape. The wrapper's source CFML must be + // preserved through inheritance. + var inst = new LDEV6303.ChildExprDefCfc(); + var meta = getMetadata( inst ); + + expect( structKeyExists( meta, "extends" ) ).toBeTrue(); + expect( structKeyExists( meta.extends, "properties" ) ).toBeTrue(); + + var inherited = ""; + for ( var p in meta.extends.properties ) { + if ( p.name == "parentExprDef" ) { inherited = p; break; } + } + expect( isStruct( inherited ) ).toBeTrue( + "parentExprDef must be in meta.extends.properties for the child instance" + ); + expect( structKeyExists( inherited, "default" ) ).toBeTrue( + "metadata.default must be preserved on the inherited expression-form property" + ); + expect( "" & inherited.default ).toInclude( "repeatString", + "inherited metadata.default should stringify to source CFML" + ); + }); + + }); + + describe( "duplicate() — deep-copy semantic, no re-fire (option 6 contract)", function(){ + // Core duplicate is a deep copy of state, not a re-construction. Hibernate (which + // needs fresh per-entity evaluation) handles its template→entity re-firing at the + // extension layer. See test/functions/Duplicate.cfc for the broader user-mutation + // preservation contract. + + it( "deterministic expression-form value round-trips through duplicate as deep copy", function(){ + var src = new LDEV6303.ExpressionDefaultsCfc(); + var dup = duplicate( src ); + expect( dup.getExpressionDef() ).toBe( "xxxxx" ); + expect( dup.getExpressionDef() ).toBe( src.getExpressionDef() ); + }); + + it( "deep duplicate gives dup its own array — mutating dup does not leak to src", function(){ + // Even without re-fire, deep duplicate must give dup a fresh array reference + // (Duplicator.duplicate handles container deep-copy). + var src = new LDEV6303.ExpressionDefaultsCfc(); + var dup = duplicate( src ); + + arrayAppend( dup.getFreshArrayDef(), "added-to-dup" ); + + expect( arrayLen( src.getFreshArrayDef() ) ).toBe( 0, + "src's array must be unaffected by mutation on dup's deep-copy array" + ); + expect( arrayLen( dup.getFreshArrayDef() ) ).toBe( 1 ); + }); + + it( "explicit assignment after duplicate wins over the deep-copied value", function(){ + var src = new LDEV6303.ExpressionDefaultsCfc(); + var dup = duplicate( src ); + + var freshTs = now(); + dup.setNowDef( freshTs ); + + expect( dateCompare( dup.getNowDef(), freshTs, "s" ) ).toBe( 0 ); + expect( dateCompare( src.getNowDef(), freshTs, "s" ) ).toBeLTE( 0, + "src's nowDef must be unchanged by mutation on the duplicate" + ); + }); + + }); + + describe( "serializeJson shape — programmatic discriminability for consumers", function(){ + + it( "literal-form metadata.default serialises as a bare string in JSON", function(){ + var inst = new LDEV6303.ExpressionDefaultsCfc(); + var json = serializeJson( getMetadata( inst ) ); + var parsed = deserializeJson( json ); + var meta = findProperty( parsed.properties, "literalDef" ); + expect( meta.default ).toBe( "hello" ); + expect( isSimpleValue( meta.default ) ).toBeTrue( + "literal-form must round-trip through JSON as a simple string" + ); + }); + + it( "expression-form metadata.default serialises as a struct with key 'expression' in JSON", function(){ + var inst = new LDEV6303.ExpressionDefaultsCfc(); + var json = serializeJson( getMetadata( inst ) ); + var parsed = deserializeJson( json ); + var meta = findProperty( parsed.properties, "expressionDef" ); + expect( isStruct( meta.default ) ).toBeTrue( + "expression-form must round-trip through JSON as a struct (programmatically discriminable from a literal default)" + ); + expect( structKeyExists( meta.default, "expression" ) ).toBeTrue( + "the JSON struct must use the lowercase key 'expression'" + ); + expect( meta.default.expression ).toBe( "repeatString( 'x', 5 )" ); + }); + + it( "JSON for #now()# expression-form is a struct with the source CFML, not an evaluated DateTime", function(){ + var inst = new LDEV6303.ExpressionDefaultsCfc(); + var json = serializeJson( getMetadata( inst ) ); + var parsed = deserializeJson( json ); + var meta = findProperty( parsed.properties, "nowDef" ); + expect( isStruct( meta.default ) ).toBeTrue(); + expect( meta.default.expression ).toBe( "now()", + "JSON for #now()# must carry the source string, not a frozen evaluated DateTime" + ); + }); + + it( "JSON for struct-literal expression-form preserves the source verbatim with embedded quotes escaped", function(){ + var inst = new LDEV6303.ExpressionDefaultsCfc(); + var json = serializeJson( getMetadata( inst ) ); + var parsed = deserializeJson( json ); + var meta = findProperty( parsed.properties, "nowStructDef" ); + expect( isStruct( meta.default ) ).toBeTrue(); + // The CFC declares default='#{"ts":now(),"n":1}#' so source is {"ts":now(),"n":1} + expect( meta.default.expression ).toInclude( "now()" ); + expect( meta.default.expression ).toInclude( "ts" ); + expect( meta.default.expression ).toInclude( "n" ); + }); + + }); + + describe( "Castable behaviour — wrapper coerces to source string in CFML coercion paths", function(){ + + it( "string concat coerces metadata.default to the source CFML", function(){ + var inst = new LDEV6303.ExpressionDefaultsCfc(); + var meta = findProperty( getMetadata( inst ).properties, "expressionDef" ); + var concat = meta.default & ""; + expect( concat ).toBe( "repeatString( 'x', 5 )", + "string-concat with empty string must coerce wrapper to source via toString()" + ); + }); + + it( "len() of the wrapper returns the source string length", function(){ + var inst = new LDEV6303.ExpressionDefaultsCfc(); + var meta = findProperty( getMetadata( inst ).properties, "nowDef" ); + expect( len( meta.default ) ).toBe( len( "now()" ) ); + }); + + it( "ucase()/lcase() coerce the wrapper through string casting", function(){ + var inst = new LDEV6303.ExpressionDefaultsCfc(); + var meta = findProperty( getMetadata( inst ).properties, "expressionDef" ); + expect( ucase( meta.default ) ).toBe( "REPEATSTRING( 'X', 5 )" ); + }); + + }); + + describe( "external serialisation — ExpressionDefault survives objectSave/objectLoad rehydration", function(){ + + it( "objectSave + objectLoad round-trips an instance and metadata.default for expression-form is still an Expression wrapper", function(){ + var src = new LDEV6303.ExpressionDefaultsCfc(); + var bin = objectSave( src ); + var dup = objectLoad( bin ); + + // instance scope values must round-trip (variables-scope per-instance evaluation) + expect( dup.getExpressionDef() ).toBe( "xxxxx", + "variables-scope evaluated value must survive objectSave/objectLoad" + ); + + // metadata.default for expression-form must still be the wrapper after rehydration + var meta = findProperty( getMetadata( dup ).properties, "expressionDef" ); + expect( structKeyExists( meta, "default" ) ).toBeTrue(); + expect( isObject( meta.default ) ).toBeTrue( + "rehydrated metadata.default for expression-form must still be an Expression wrapper, not flattened to a string" + ); + expect( "" & meta.default ).toBe( "repeatString( 'x', 5 )", + "rehydrated wrapper must stringify to the same source CFML" + ); + }); + + it( "objectSave + objectLoad preserves literal-form metadata.default as a simple string", function(){ + var src = new LDEV6303.ExpressionDefaultsCfc(); + var bin = objectSave( src ); + var dup = objectLoad( bin ); + + var meta = findProperty( getMetadata( dup ).properties, "literalDef" ); + expect( meta.default ).toBe( "hello" ); + expect( isSimpleValue( meta.default ) ).toBeTrue(); + }); + + }); + + }); + } + + private struct function findProperty( required array properties, required string name ) { + for ( var p in arguments.properties ) { + if ( p.name == arguments.name ) return p; + } + throw "property [#arguments.name#] not found in metadata"; + } + +} diff --git a/test/tickets/LDEV6303/ChildExprDefCfc.cfc b/test/tickets/LDEV6303/ChildExprDefCfc.cfc new file mode 100644 index 00000000000..bc87926bdf5 --- /dev/null +++ b/test/tickets/LDEV6303/ChildExprDefCfc.cfc @@ -0,0 +1,3 @@ +component accessors=true extends="ParentExprDefCfc" { + property name="childExprDef" type="any" default="#repeatString( 'c', 3 )#"; +} diff --git a/test/tickets/LDEV6303/ExpressionDefaultsCfc.cfc b/test/tickets/LDEV6303/ExpressionDefaultsCfc.cfc new file mode 100644 index 00000000000..6636a9dc702 --- /dev/null +++ b/test/tickets/LDEV6303/ExpressionDefaultsCfc.cfc @@ -0,0 +1,8 @@ +component accessors=true { + property name="literalDef" type="string" default="hello"; + property name="expressionDef" type="any" default="#repeatString( 'x', 5 )#"; + property name="nowDef" type="any" default="#now()#"; + property name="nowStructDef" type="any" default='#{"ts":now(),"n":1}#'; + property name="uuidDef" type="string" default="#createUUID()#"; + property name="freshArrayDef" type="any" default='#[]#'; +} diff --git a/test/tickets/LDEV6303/LeakProbeCfc.cfc b/test/tickets/LDEV6303/LeakProbeCfc.cfc new file mode 100644 index 00000000000..b57b35dde6c --- /dev/null +++ b/test/tickets/LDEV6303/LeakProbeCfc.cfc @@ -0,0 +1,7 @@ +// Dedicated CFC class for the leak-catcher test. Must NOT be instantiated +// outside the leak test, so the test's instance is genuinely the first +// construction of this class — that's the moment when (on 7.0) the first +// instance's variables-scope value gets aliased with the class-level _default. +component accessors=true { + property name="bag" type="any" default='#{"n":1}#'; +} diff --git a/test/tickets/LDEV6303/NullProbeCfc.cfc b/test/tickets/LDEV6303/NullProbeCfc.cfc new file mode 100644 index 00000000000..b2835b22b22 --- /dev/null +++ b/test/tickets/LDEV6303/NullProbeCfc.cfc @@ -0,0 +1,23 @@ +component accessors=true { + property name="literalHello" type="any" default="hello"; + property name="literalEmpty" type="any" default=""; + property name="noDefault" type="any"; + property name="nullExprDef" type="any" default="#nullValue()#"; + property name="emptyStructDef" type="any" default='#{}#'; + property name="emptyArrayDef" type="any" default='#[]#'; + property name="oneKeyStruct" type="any" default='#{a:1}#'; + property name="twoKeyStruct" type="any" default='#{a:1,b:2}#'; + property name="oneItemArray" type="any" default='#[42]#'; + property name="threeItemArray" type="any" default='#["x","y","z"]#'; + property name="funcCallDef" type="any" default="#repeatString( 'x', 5 )#"; + property name="nestedFuncDef" type="any" default="#listToArray( 'a,b,c' )#"; + + // Expose variables scope for the probe — that's the scope ORM CFCSetter actually reads. + function _probe( required string key ) { + return { + "varsHasKey" : structKeyExists( variables, arguments.key ) + , "varsValueIsNull" : structKeyExists( variables, arguments.key ) ? isNull( variables[ arguments.key ] ) : "" + , "varsValue" : structKeyExists( variables, arguments.key ) ? ( isNull( variables[ arguments.key ] ) ? "" : "[" & ( isSimpleValue( variables[ arguments.key ] ) ? variables[ arguments.key ] : serializeJson( variables[ arguments.key ] ) ) & "]" ) : "" + }; + } +} diff --git a/test/tickets/LDEV6303/ParentExprDefCfc.cfc b/test/tickets/LDEV6303/ParentExprDefCfc.cfc new file mode 100644 index 00000000000..c011a18926a --- /dev/null +++ b/test/tickets/LDEV6303/ParentExprDefCfc.cfc @@ -0,0 +1,5 @@ +component accessors=true { + property name="parentLiteralDef" type="string" default="parent-literal"; + property name="parentExprDef" type="any" default="#repeatString( 'p', 5 )#"; + property name="parentNowDef" type="any" default="#now()#"; +} diff --git a/test/tickets/LDEV6303/null-probe.cfm b/test/tickets/LDEV6303/null-probe.cfm new file mode 100644 index 00000000000..eca0f2ed3ee --- /dev/null +++ b/test/tickets/LDEV6303/null-probe.cfm @@ -0,0 +1,70 @@ + +// LDEV-6303 null-edge probe. Exercises each form of default to record actual current behavior +// under both NULLSupport modes. Pure observation — no assertions. +// Run via: http://7.localhost:7888/test71/tickets/LDEV6303/null-probe.cfm + +setting showdebugoutput="false"; + +function probeOnce( required string label ) { + var inst = new NullProbeCfc(); + var props = getMetadata( inst ).properties; + var rows = []; + for ( var p in props ) { + var sub = inst._probe( p.name ); + var row = { + "name" : p.name + , "metaHasDefault" : structKeyExists( p, "default" ) + , "metaDefaultValue": structKeyExists( p, "default" ) ? ( isNull( p.default ) ? "" : "[" & ( isSimpleValue( p.default ) ? p.default : serializeJson( p.default ) ) & "]" ) : "" + , "metaIsSimple" : structKeyExists( p, "default" ) ? ( isNull( p.default ) ? "" : isSimpleValue( p.default ) ) : "" + , "metaIsObject" : structKeyExists( p, "default" ) ? ( isNull( p.default ) ? "" : isObject( p.default ) ) : "" + , "varsHasKey" : sub.varsHasKey + , "varsValueIsNull" : sub.varsValueIsNull + , "varsValue" : sub.varsValue + }; + arrayAppend( rows, row ); + } + return { label: arguments.label, rows: rows }; +} + +scenarios = []; + +// Default mode (probably fullNullSupport=false depending on Application.cfc inheritance) +arrayAppend( scenarios, probeOnce( "default mode" ) ); + +// Force fullNullSupport=true for the second probe +application action="update" nullSupport=true; +arrayAppend( scenarios, probeOnce( "fullNullSupport=true" ) ); + +// Force fullNullSupport=false for the third probe +application action="update" nullSupport=false; +arrayAppend( scenarios, probeOnce( "fullNullSupport=false" ) ); + +// Render +echo( "

LDEV-6303 null-edge probe — engine: " & server.lucee.version & "

" ); +for ( s in scenarios ) { + echo( "

scenario: " & s.label & "

" ); + echo( "" ); + echo( "" ); + for ( r in s.rows ) { + echo( "" ); + for ( k in [ "name", "metaHasDefault", "metaDefaultValue", "metaIsSimple", "metaIsObject", "varsHasKey", "varsValueIsNull", "varsValue" ] ) { + echo( "" ); + } + echo( "" ); + } + echo( "
propertymetaHasDefaultmetaDefaultValuemetaIsSimplemetaIsObjectvarsHasKeyvarsValueIsNullvarsValue
#encodeForHtml( "" & r[ k ] )#
" ); +} + +echo( "
" );
+echo( chr( 10 ) & "engine: " & server.lucee.version & chr( 10 ) );
+for ( s in scenarios ) {
+    echo( chr( 10 ) & "===== scenario: " & s.label & " =====" & chr( 10 ) );
+    for ( r in s.rows ) {
+        echo( chr( 10 ) & "[#r.name#]" & chr( 10 ) );
+        for ( k in [ "metaHasDefault", "metaDefaultValue", "metaIsSimple", "metaIsObject", "varsHasKey", "varsValueIsNull", "varsValue" ] ) {
+            echo( "  #k# = #r[k]#" & chr( 10 ) );
+        }
+    }
+}
+echo( "
" ); +
diff --git a/test/tickets/LDEV6303/orm/Application.cfc b/test/tickets/LDEV6303/orm/Application.cfc new file mode 100644 index 00000000000..df08e080121 --- /dev/null +++ b/test/tickets/LDEV6303/orm/Application.cfc @@ -0,0 +1,21 @@ +component { + this.name = "LDEV6303_orm_test"; + this.ORMenabled = "true"; + this.ORMsettings = { + datasource = "LDEV6303", + dbCreate = "dropCreate", + dialect = "MySQL" + }; + this.datasource = "LDEV6303"; + + // hardwired h2 — works under both HTTP runner and mvn test runner + tempDb = getTempDirectory() & "/LDEV6303-" & hash( getCurrentTemplatePath() ); + if ( !directoryExists( tempDb ) ) directoryCreate( tempDb, true ); + + this.datasources[ "LDEV6303" ] = { + class: "org.h2.Driver", + bundleName: "org.lucee.h2", + bundleVersion: "2.1.214.0001L", + connectionString: "jdbc:h2:" & tempDb & "/db;MODE=MySQL" + }; +} diff --git a/test/tickets/LDEV6303/orm/Org6303.cfc b/test/tickets/LDEV6303/orm/Org6303.cfc new file mode 100644 index 00000000000..75fcb5ee018 --- /dev/null +++ b/test/tickets/LDEV6303/orm/Org6303.cfc @@ -0,0 +1,5 @@ +component persistent="true" table="LDEV6303" { + property name="id" type="string" fieldtype="id" ormtype="string"; + property name="literalDef" type="string" default="literal-default"; + property name="exprDef" type="string" default="#repeatString( 'x', 5 )#"; +} diff --git a/test/tickets/LDEV6303/orm/test.cfm b/test/tickets/LDEV6303/orm/test.cfm new file mode 100644 index 00000000000..2dea8a7160a --- /dev/null +++ b/test/tickets/LDEV6303/orm/test.cfm @@ -0,0 +1,18 @@ + + scene = form.scene ?: "literal"; + + // fresh row with NULL columns for both properties + queryExecute( "DELETE FROM LDEV6303 WHERE id='r1'", {}, { datasource: "LDEV6303" } ); + queryExecute( "INSERT INTO LDEV6303( id, literalDef, exprDef ) VALUES( 'r1', NULL, NULL )", {}, { datasource: "LDEV6303" } ); + + ormClearSession(); + + row = entityLoadByPK( "Org6303", "r1" ); + if ( isNull( row ) ) throw( message="entity not found" ); + + switch( scene ) { + case "literal": echo( row.getLiteralDef() ); break; + case "expression": echo( row.getExprDef() ); break; + default: echo( "unknown scene" ); + } +