From 7bb23f1953cc1ed41a79db9076fab29c04044b2f Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 7 May 2026 01:25:23 +0200 Subject: [PATCH 1/4] LDEV-6303 ExpressionDefault wrapper and duplicate re-fire --- .../java/lucee/runtime/ComponentImpl.java | 68 +++ .../runtime/component/ExpressionDefault.java | 32 ++ .../lucee/runtime/component/PropertyImpl.java | 2 + .../lucee/transformer/bytecode/PageImpl.java | 84 ++- test/tickets/LDEV6303.cfc | 481 ++++++++++++++++++ test/tickets/LDEV6303/ChildExprDefCfc.cfc | 3 + .../LDEV6303/ExpressionDefaultsCfc.cfc | 8 + test/tickets/LDEV6303/LeakProbeCfc.cfc | 7 + test/tickets/LDEV6303/NullProbeCfc.cfc | 23 + test/tickets/LDEV6303/ParentExprDefCfc.cfc | 5 + test/tickets/LDEV6303/null-probe.cfm | 70 +++ test/tickets/LDEV6303/orm/Application.cfc | 21 + test/tickets/LDEV6303/orm/Org6303.cfc | 5 + test/tickets/LDEV6303/orm/test.cfm | 18 + 14 files changed, 824 insertions(+), 3 deletions(-) create mode 100644 core/src/main/java/lucee/runtime/component/ExpressionDefault.java create mode 100644 test/tickets/LDEV6303.cfc create mode 100644 test/tickets/LDEV6303/ChildExprDefCfc.cfc create mode 100644 test/tickets/LDEV6303/ExpressionDefaultsCfc.cfc create mode 100644 test/tickets/LDEV6303/LeakProbeCfc.cfc create mode 100644 test/tickets/LDEV6303/NullProbeCfc.cfc create mode 100644 test/tickets/LDEV6303/ParentExprDefCfc.cfc create mode 100644 test/tickets/LDEV6303/null-probe.cfm create mode 100644 test/tickets/LDEV6303/orm/Application.cfc create mode 100644 test/tickets/LDEV6303/orm/Org6303.cfc create mode 100644 test/tickets/LDEV6303/orm/test.cfm diff --git a/core/src/main/java/lucee/runtime/ComponentImpl.java b/core/src/main/java/lucee/runtime/ComponentImpl.java index e7fd2b6c770..dd8cfdbc791 100755 --- a/core/src/main/java/lucee/runtime/ComponentImpl.java +++ b/core/src/main/java/lucee/runtime/ComponentImpl.java @@ -115,6 +115,9 @@ import lucee.runtime.type.scope.ArgumentImpl; import lucee.runtime.type.scope.ArgumentIntKey; import lucee.runtime.type.scope.Variables; + +import java.lang.reflect.Method; +import java.util.concurrent.ConcurrentHashMap; import lucee.runtime.type.util.ArrayUtil; import lucee.runtime.type.util.ComponentUtil; import lucee.runtime.type.util.KeyConstants; @@ -367,6 +370,8 @@ public ComponentImpl _duplicate(boolean deepCopy, boolean isTop) { if (useShadow) { addUDFS(trg, ((ComponentScopeShadow) scope).getShadow(), ((ComponentScopeShadow) trg.scope).getShadow()); } + + reFireExpressionDefaults(trg); } } finally { @@ -376,6 +381,69 @@ public ComponentImpl _duplicate(boolean deepCopy, boolean isTop) { return trg; } + // SEED_METHOD_NONE flags "Page class compiled by pre-LDEV-6303 Lucee" (cross-version compat). + private static final java.util.Map, Method> SEED_METHOD_CACHE = new ConcurrentHashMap, Method>(); + private static final Method SEED_METHOD_NONE; + static { + Method tmp = null; + try { + tmp = Object.class.getDeclaredMethod("hashCode"); + } + catch (NoSuchMethodException e) {} + SEED_METHOD_NONE = tmp; + } + + private static Method lookupSeedMethod(Class pageClass) { + Method m = SEED_METHOD_CACHE.get(pageClass); + if (m != null) return m == SEED_METHOD_NONE ? null : m; + try { + m = pageClass.getDeclaredMethod("_seedExpressionDefaults", PageContext.class); + SEED_METHOD_CACHE.put(pageClass, m); + return m; + } + catch (NoSuchMethodException e) { + SEED_METHOD_CACHE.put(pageClass, SEED_METHOD_NONE); + return null; + } + } + + private static void reFireExpressionDefaults(ComponentImpl trg) { + PageContext pc = ThreadLocalPageContext.get(); + if (pc == null) return; + + Variables prevVars = pc.variablesScope(); + try { + if (trg.scope instanceof Variables) pc.setVariablesScope((Variables) trg.scope); + + ComponentImpl walker = trg; + while (walker != null) { + if (walker.pageSource != null) { + try { + lucee.runtime.Page page = walker.pageSource.loadPage(pc, false); + if (page != null) { + Method seed = lookupSeedMethod(page.getClass()); + if (seed != null) { + try { + seed.invoke(null, pc); + } + catch (Throwable t) { + ExceptionUtil.rethrowIfNecessary(t); + } + } + } + } + catch (PageException pe) { + break; + } + } + walker = (ComponentImpl) walker.base; + } + } + finally { + if (prevVars != null) pc.setVariablesScope(prevVars); + } + } + private static void addUDFS(ComponentImpl trgComp, Map src, Map trg) { if (!src.isEmpty()) { Iterator it = src.entrySet().iterator(); 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..113fa0f05f7 --- /dev/null +++ b/core/src/main/java/lucee/runtime/component/ExpressionDefault.java @@ -0,0 +1,32 @@ +package lucee.runtime.component; + +/** Immutable source-string wrapper for non-foldable cfproperty expression-form defaults. */ +public final class ExpressionDefault { + + 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 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..c5ae717db96 100755 --- a/core/src/main/java/lucee/transformer/bytecode/PageImpl.java +++ b/core/src/main/java/lucee/transformer/bytecode/PageImpl.java @@ -765,6 +765,8 @@ else if (functions.length <= 10) { // newInstance/initComponent/call writeOutStatic(optionalPS, constr, keys, cw, comp, className); + writeOutSeedExpressionDefaults(optionalPS, constr, keys, cw, comp, className); + // set field subs FieldVisitor fv = cw.visitField(Opcodes.ACC_PRIVATE, "subs", "[Llucee/runtime/CIPage;", null, null); fv.visitEnd(); @@ -1229,10 +1231,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 +1254,31 @@ 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; + try { + int start = defaultExpr.getStart().pos; + int end = defaultExpr.getEnd().pos; + // json() consumes opening { or [ before recording position; back up + if (start > 0) { + String peek = sourceCode.subCFMLString(start - 1, 1).toString(); + if (peek.length() == 1 && (peek.charAt(0) == '{' || peek.charAt(0) == '[')) { + start--; + } + } + source = sourceCode.subCFMLString(start, end - start).toString(); + } + catch (Exception e) { + 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) @@ -1540,6 +1565,59 @@ else if (defaultExpr instanceof LitBooleanImpl) { } + private void writeOutSeedExpressionDefaults(PageSource optionalPS, ConstrBytecodeContext constr, Map keys, ClassWriter cw, TagCIObject component, + String name) throws TransformerException { + if (component == null || component.getBody() == null) return; + List statements = component.getBody().getStatements(); + if (statements == null) return; + + List exprDefProps = new ArrayList(); + for (Statement stmt: statements) { + if (!(stmt instanceof TagProperty)) continue; + TagProperty tagProp = (TagProperty) stmt; + Attribute defaultAttr = tagProp.getAttribute("default"); + if (defaultAttr == null || defaultAttr.getValue() == null) continue; + Expression defaultExpr = defaultAttr.getValue(); + if (defaultExpr instanceof LitStringImpl || defaultExpr instanceof LitNumberImpl || defaultExpr instanceof LitBooleanImpl) continue; + Attribute nameAttr = tagProp.getAttribute("name"); + if (nameAttr == null || nameAttr.getValue() == null) continue; + exprDefProps.add(tagProp); + } + + // Method shell always emitted on component classes — empty body is JIT no-op; ensures + // presence-of-method is stable for ComponentImpl._duplicate's reflection lookup. + Method seedMethod = new Method("_seedExpressionDefaults", Types.VOID, new Type[] { Types.PAGE_CONTEXT }); + GeneratorAdapter ga = new GeneratorAdapter(Opcodes.ACC_PUBLIC + Opcodes.ACC_STATIC, seedMethod, null, new Type[] { Types.THROWABLE }, cw); + BytecodeContext bc = new BytecodeContext(config, optionalPS, constr, this, keys, cw, name, ga, seedMethod, writeLog(), suppressWSbeforeArg, output, returnValue, + sourceCode.getSourceOffset()); + + Method METHOD_VARIABLES_SCOPE = new Method("variablesScope", Types.VARIABLES, new Type[] {}); + Method METHOD_SET_EL = new Method("setEL", Types.OBJECT, new Type[] { Types.COLLECTION_KEY, Types.OBJECT }); + + for (TagProperty tagProp: exprDefProps) { + Attribute nameAttr = tagProp.getAttribute("name"); + Attribute defaultAttr = tagProp.getAttribute("default"); + String propName = nameAttr.getValue() instanceof Literal ? ((Literal) nameAttr.getValue()).getString() : null; + if (propName == null) continue; + Expression defaultExpr = defaultAttr.getValue(); + + defaultExpr.writeOut(bc, Expression.MODE_REF); + int defaultLocal = ga.newLocal(Types.OBJECT); + ga.storeLocal(defaultLocal); + + ga.loadArg(0); + ga.invokeVirtual(Types.PAGE_CONTEXT, METHOD_VARIABLES_SCOPE); + ga.push(propName); + ga.invokeStatic(KEY_IMPL, KEY_INIT); + ga.loadLocal(defaultLocal); + ga.invokeInterface(Types.SCOPE, METHOD_SET_EL); + ga.pop(); + } + + ga.returnValue(); + ga.endMethod(); + } + private String getTagAttributeValue(Tag tag, String attrName) { Attribute attr = tag.getAttribute(attrName); if (attr != null && attr.getValue() != null) { diff --git a/test/tickets/LDEV6303.cfc b/test/tickets/LDEV6303.cfc new file mode 100644 index 00000000000..2d912eaa9f4 --- /dev/null +++ b/test/tickets/LDEV6303.cfc @@ -0,0 +1,481 @@ +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() re-fires expression-form defaults — fresh per-instance evaluation", function(){ + + it( "duplicate fires fresh #now()# — dup gets a later timestamp than src after sleep", function(){ + // Contract: duplicate re-fires expression-form defaults so each duplicate + // gets a fresh per-instance evaluation. The natural reading of + // default="#now()#" is "the time this instance came into existence" — and + // duplicates ARE new instances coming into existence. + var src = new LDEV6303.ExpressionDefaultsCfc(); + sleep( 1100 ); + var dup = duplicate( src ); + + expect( dateCompare( dup.getNowDef(), src.getNowDef(), "s" ) ).toBeGT( 0, + "duplicate must re-evaluate now() — dup's timestamp should be later than src's" + ); + }); + + it( "duplicate fires fresh #createUUID()# — dup's UUID differs from src's", function(){ + var src = new LDEV6303.ExpressionDefaultsCfc(); + var dup = duplicate( src ); + + expect( dup.getUuidDef() ).notToBe( src.getUuidDef(), + "duplicate must re-evaluate createUUID() — dup gets a fresh UUID" + ); + // sanity: both look like UUIDs + expect( len( dup.getUuidDef() ) ).toBeGT( 0 ); + expect( len( src.getUuidDef() ) ).toBeGT( 0 ); + }); + + it( "duplicate fires fresh deterministic expression (eval gives same value but separate computation)", function(){ + // Deterministic expressions naturally give the same value either way. + // This test documents that they continue to work — fresh eval of + // repeatString('x',5) gives "xxxxx" same as src's value. + var src = new LDEV6303.ExpressionDefaultsCfc(); + var dup = duplicate( src ); + expect( dup.getExpressionDef() ).toBe( "xxxxx" ); + expect( dup.getExpressionDef() ).toBe( src.getExpressionDef() ); + }); + + it( "duplicate gives dup its own array — mutating dup does not leak to src", function(){ + // Mutable expression-form defaults (#[]#, #{}#) must produce a fresh + // container per duplicate. Without re-fire, a shallow-copy duplicate + // would share the array reference with src — silent leak. + 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 array (separate references via re-fire)" + ); + expect( arrayLen( dup.getFreshArrayDef() ) ).toBe( 1 ); + }); + + it( "duplicate(src, false) — shallow duplicate also re-fires expression-form (Hibernate path)", function(){ + // Hibernate's CFCInstantiator uses cfc.duplicate(false) to create + // entity instances. The shallow flag must NOT prevent re-fire of + // expression-form defaults — otherwise every entity from a cached + // template gets the template's frozen UUID/now/[]/{}. + var src = new LDEV6303.ExpressionDefaultsCfc(); + var dup = duplicate( src, false ); + + expect( dup.getUuidDef() ).notToBe( src.getUuidDef(), + "duplicate(src, false) must re-evaluate expression-form defaults too" + ); + + // Mutation on dup's struct must not leak to src + var s = dup.getNowStructDef(); + s.n = 999; + + expect( src.getNowStructDef().n ).toBe( 1, + "src's struct must be a separate reference from dup's (no shared-ref leak via shallow duplicate)" + ); + }); + + it( "explicit assignment after duplicate still wins — user values aren't clobbered by re-fire", function(){ + // Re-fire seeds dup's scope at duplicate-time. Subsequent explicit + // assignment by the user must override the re-fire'd value. + var src = new LDEV6303.ExpressionDefaultsCfc(); + var dup = duplicate( src ); + + var freshTs = now(); + dup.setNowDef( freshTs ); + + expect( dateCompare( dup.getNowDef(), freshTs, "s" ) ).toBe( 0, + "explicit setNowDef on dup must win over re-fire'd value" + ); + expect( dateCompare( src.getNowDef(), freshTs, "s" ) ).toBeLTE( 0, + "src's nowDef must be unchanged by mutation on the duplicate" + ); + }); + + it( "inherited expression-form default re-fires on the child duplicate", function(){ + // Child class instance, inheriting a #now()# default from parent. + // duplicate of the child must re-fire the parent's expression-form + // default — inheritance walk in _duplicate. + var src = new LDEV6303.ChildExprDefCfc(); + sleep( 1100 ); + var dup = duplicate( src ); + + expect( dateCompare( dup.getParentNowDef(), src.getParentNowDef(), "s" ) ).toBeGT( 0, + "inherited #now()# default must re-fire on the child's duplicate" + ); + }); + + }); + + }); + } + + 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" ); + } + From 1a89e95427953ecb62ac628fdc5ebed1cd4d71e7 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 7 May 2026 13:54:57 +0200 Subject: [PATCH 2/4] LDEV-6303 metadata.default naked expression source + cfdump/JSON DX --- .../java/lucee/runtime/ComponentImpl.java | 48 +++---------- .../runtime/component/ExpressionDefault.java | 36 +++++++++- .../component/ExpressionDefaultSeeder.java | 9 +++ .../lucee/transformer/bytecode/PageImpl.java | 72 ++++++++----------- .../bytecode/statement/tag/TagProperty.java | 43 +++++------ .../script/AbstrCFMLScriptTransformer.java | 18 ++++- .../transformer/cfml/tag/CFMLTransformer.java | 17 ++++- .../transformer/statement/tag/Attribute.java | 11 +++ 8 files changed, 146 insertions(+), 108 deletions(-) create mode 100644 core/src/main/java/lucee/runtime/component/ExpressionDefaultSeeder.java diff --git a/core/src/main/java/lucee/runtime/ComponentImpl.java b/core/src/main/java/lucee/runtime/ComponentImpl.java index dd8cfdbc791..9795292d4ef 100755 --- a/core/src/main/java/lucee/runtime/ComponentImpl.java +++ b/core/src/main/java/lucee/runtime/ComponentImpl.java @@ -57,6 +57,7 @@ import lucee.runtime.component.ComponentLoader; import lucee.runtime.component.ComponentPageRef; import lucee.runtime.component.DataMember; +import lucee.runtime.component.ExpressionDefaultSeeder; import lucee.runtime.component.ImportDefintion; import lucee.runtime.component.Member; import lucee.runtime.component.MetaDataSoftReference; @@ -115,9 +116,6 @@ import lucee.runtime.type.scope.ArgumentImpl; import lucee.runtime.type.scope.ArgumentIntKey; import lucee.runtime.type.scope.Variables; - -import java.lang.reflect.Method; -import java.util.concurrent.ConcurrentHashMap; import lucee.runtime.type.util.ArrayUtil; import lucee.runtime.type.util.ComponentUtil; import lucee.runtime.type.util.KeyConstants; @@ -381,32 +379,6 @@ public ComponentImpl _duplicate(boolean deepCopy, boolean isTop) { return trg; } - // SEED_METHOD_NONE flags "Page class compiled by pre-LDEV-6303 Lucee" (cross-version compat). - private static final java.util.Map, Method> SEED_METHOD_CACHE = new ConcurrentHashMap, Method>(); - private static final Method SEED_METHOD_NONE; - static { - Method tmp = null; - try { - tmp = Object.class.getDeclaredMethod("hashCode"); - } - catch (NoSuchMethodException e) {} - SEED_METHOD_NONE = tmp; - } - - private static Method lookupSeedMethod(Class pageClass) { - Method m = SEED_METHOD_CACHE.get(pageClass); - if (m != null) return m == SEED_METHOD_NONE ? null : m; - try { - m = pageClass.getDeclaredMethod("_seedExpressionDefaults", PageContext.class); - SEED_METHOD_CACHE.put(pageClass, m); - return m; - } - catch (NoSuchMethodException e) { - SEED_METHOD_CACHE.put(pageClass, SEED_METHOD_NONE); - return null; - } - } - private static void reFireExpressionDefaults(ComponentImpl trg) { PageContext pc = ThreadLocalPageContext.get(); if (pc == null) return; @@ -420,20 +392,18 @@ private static void reFireExpressionDefaults(ComponentImpl trg) { if (walker.pageSource != null) { try { lucee.runtime.Page page = walker.pageSource.loadPage(pc, false); - if (page != null) { - Method seed = lookupSeedMethod(page.getClass()); - if (seed != null) { - try { - seed.invoke(null, pc); - } - catch (Throwable t) { - ExceptionUtil.rethrowIfNecessary(t); - } + if (page instanceof ExpressionDefaultSeeder) { + try { + ((ExpressionDefaultSeeder) page)._seedExpressionDefaults(pc); + } + catch (Throwable t) { + ExceptionUtil.rethrowIfNecessary(t); + throw new lucee.runtime.exp.PageRuntimeException(Caster.toPageException(t)); } } } catch (PageException pe) { - break; + throw new lucee.runtime.exp.PageRuntimeException(pe); } } walker = (ComponentImpl) walker.base; diff --git a/core/src/main/java/lucee/runtime/component/ExpressionDefault.java b/core/src/main/java/lucee/runtime/component/ExpressionDefault.java index 113fa0f05f7..e3d4cbcfcfc 100644 --- a/core/src/main/java/lucee/runtime/component/ExpressionDefault.java +++ b/core/src/main/java/lucee/runtime/component/ExpressionDefault.java @@ -1,7 +1,22 @@ package lucee.runtime.component; -/** Immutable source-string wrapper for non-foldable cfproperty expression-form defaults. */ -public final class ExpressionDefault { +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 source-string wrapper for non-foldable cfproperty expression-form defaults. + * Serialises to JSON as { "expression": "" } so consumers can programmatically + * distinguish expression-form metadata.default from literal-form (which serialises as a bare string). */ +public final class ExpressionDefault implements Dumpable, ScriptConvertable { + + private static final long serialVersionUID = 1L; private final String source; @@ -18,6 +33,23 @@ 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) { + return "{\"expression\":" + StringUtil.escapeJS(source, '"') + "}"; + } + @Override public boolean equals(Object obj) { if (this == obj) return true; diff --git a/core/src/main/java/lucee/runtime/component/ExpressionDefaultSeeder.java b/core/src/main/java/lucee/runtime/component/ExpressionDefaultSeeder.java new file mode 100644 index 00000000000..661c5c8c6c4 --- /dev/null +++ b/core/src/main/java/lucee/runtime/component/ExpressionDefaultSeeder.java @@ -0,0 +1,9 @@ +package lucee.runtime.component; + +import lucee.runtime.PageContext; + +/** Implemented by emitted CFC classes that declare cfproperty expression-form defaults. */ +public interface ExpressionDefaultSeeder { + + void _seedExpressionDefaults(PageContext pc) throws Throwable; +} diff --git a/core/src/main/java/lucee/transformer/bytecode/PageImpl.java b/core/src/main/java/lucee/transformer/bytecode/PageImpl.java index c5ae717db96..f3b0375d0f6 100755 --- a/core/src/main/java/lucee/transformer/bytecode/PageImpl.java +++ b/core/src/main/java/lucee/transformer/bytecode/PageImpl.java @@ -380,7 +380,17 @@ public byte[] execute(String className) throws TransformerException { String[] interfaces = null; if (isComponent(comp)) { parent = ComponentPageImpl.class.getName();// "lucee/runtime/ComponentPage"; - if (isSub) interfaces = new String[] { SubPage.class.getName().replace('.', '/') }; + List exprDefProps = collectExpressionFormProperties(comp); + boolean hasExprDefaults = !exprDefProps.isEmpty(); + if (isSub && hasExprDefaults) { + interfaces = new String[] { SubPage.class.getName().replace('.', '/'), "lucee/runtime/component/ExpressionDefaultSeeder" }; + } + else if (isSub) { + interfaces = new String[] { SubPage.class.getName().replace('.', '/') }; + } + else if (hasExprDefaults) { + interfaces = new String[] { "lucee/runtime/component/ExpressionDefaultSeeder" }; + } } else if (isInterface(comp)) parent = InterfacePageImpl.class.getName();// "lucee/runtime/InterfacePage"; parent = parent.replace('.', '/'); @@ -1255,22 +1265,10 @@ else if (defaultExpr instanceof LitBooleanImpl) { ga.invokeVirtual(Types.PROPERTY_IMPL, new Method("setDefault", Type.VOID_TYPE, new Type[] { Types.OBJECT })); } else { - String source; - try { - int start = defaultExpr.getStart().pos; - int end = defaultExpr.getEnd().pos; - // json() consumes opening { or [ before recording position; back up - if (start > 0) { - String peek = sourceCode.subCFMLString(start - 1, 1).toString(); - if (peek.length() == 1 && (peek.charAt(0) == '{' || peek.charAt(0) == '[')) { - start--; - } - } - source = sourceCode.subCFMLString(start, end - start).toString(); - } - catch (Exception e) { - source = ""; - } + // WIP: raw value includes attribute wrapping syntax (quotes + hashes); contract for + // metadata.default source TBD. See LDEV-6303-code-review.md for state. + String source = propDefaultAttr.getRawValue(); + if (source == null) source = ""; Type EXPRESSION_DEFAULT = Type.getType("Llucee/runtime/component/ExpressionDefault;"); ga.loadLocal(propLocal); ga.newInstance(EXPRESSION_DEFAULT); @@ -1565,13 +1563,11 @@ else if (defaultExpr instanceof LitBooleanImpl) { } - private void writeOutSeedExpressionDefaults(PageSource optionalPS, ConstrBytecodeContext constr, Map keys, ClassWriter cw, TagCIObject component, - String name) throws TransformerException { - if (component == null || component.getBody() == null) return; + private static List collectExpressionFormProperties(TagCIObject component) { + List result = new ArrayList(); + if (component == null || component.getBody() == null) return result; List statements = component.getBody().getStatements(); - if (statements == null) return; - - List exprDefProps = new ArrayList(); + if (statements == null) return result; for (Statement stmt: statements) { if (!(stmt instanceof TagProperty)) continue; TagProperty tagProp = (TagProperty) stmt; @@ -1581,37 +1577,27 @@ private void writeOutSeedExpressionDefaults(PageSource optionalPS, ConstrBytecod if (defaultExpr instanceof LitStringImpl || defaultExpr instanceof LitNumberImpl || defaultExpr instanceof LitBooleanImpl) continue; Attribute nameAttr = tagProp.getAttribute("name"); if (nameAttr == null || nameAttr.getValue() == null) continue; - exprDefProps.add(tagProp); + result.add(tagProp); } + return result; + } + + private void writeOutSeedExpressionDefaults(PageSource optionalPS, ConstrBytecodeContext constr, Map keys, ClassWriter cw, TagCIObject component, + String name) throws TransformerException { + List exprDefProps = collectExpressionFormProperties(component); + if (exprDefProps.isEmpty()) return; - // Method shell always emitted on component classes — empty body is JIT no-op; ensures - // presence-of-method is stable for ComponentImpl._duplicate's reflection lookup. Method seedMethod = new Method("_seedExpressionDefaults", Types.VOID, new Type[] { Types.PAGE_CONTEXT }); - GeneratorAdapter ga = new GeneratorAdapter(Opcodes.ACC_PUBLIC + Opcodes.ACC_STATIC, seedMethod, null, new Type[] { Types.THROWABLE }, cw); + GeneratorAdapter ga = new GeneratorAdapter(Opcodes.ACC_PUBLIC, seedMethod, null, new Type[] { Types.THROWABLE }, cw); BytecodeContext bc = new BytecodeContext(config, optionalPS, constr, this, keys, cw, name, ga, seedMethod, writeLog(), suppressWSbeforeArg, output, returnValue, sourceCode.getSourceOffset()); - Method METHOD_VARIABLES_SCOPE = new Method("variablesScope", Types.VARIABLES, new Type[] {}); - Method METHOD_SET_EL = new Method("setEL", Types.OBJECT, new Type[] { Types.COLLECTION_KEY, Types.OBJECT }); - for (TagProperty tagProp: exprDefProps) { Attribute nameAttr = tagProp.getAttribute("name"); Attribute defaultAttr = tagProp.getAttribute("default"); String propName = nameAttr.getValue() instanceof Literal ? ((Literal) nameAttr.getValue()).getString() : null; if (propName == null) continue; - Expression defaultExpr = defaultAttr.getValue(); - - defaultExpr.writeOut(bc, Expression.MODE_REF); - int defaultLocal = ga.newLocal(Types.OBJECT); - ga.storeLocal(defaultLocal); - - ga.loadArg(0); - ga.invokeVirtual(Types.PAGE_CONTEXT, METHOD_VARIABLES_SCOPE); - ga.push(propName); - ga.invokeStatic(KEY_IMPL, KEY_INIT); - ga.loadLocal(defaultLocal); - ga.invokeInterface(Types.SCOPE, METHOD_SET_EL); - ga.pop(); + TagProperty.emitExpressionEvalAndSet(bc, propName, defaultAttr.getValue()); } ga.returnValue(); 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..1b5d059b6bc 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,38 @@ 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 a single cfproperty expression-form default. + * Shared by construction-time pseudo-constructor and the duplicate-time _seedExpressionDefaults + * method so both paths emit identical eval bytecode and stay in lockstep. + */ + 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..f476bf1fcc5 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,18 @@ private final Attribute attribute(TagLibTag tlt, Data data, ArrayList ar if (hasValue) { comments(data); value = attributeValue(data, allowExpression); - + if (value != null && value.getStart() != null && value.getEnd() != null) { + int start = value.getStart().pos; + int end = value.getEnd().pos; + // json() records position after consuming opening { or [; back up so the slice + // includes the opening delimiter (the post-delimiter behaviour is correct for + // json()'s 40 stack-trace callers — only the source-slice case wants pre-delimiter) + if (start > 0) { + String peek = data.srcCode.subCFMLString(start - 1, 1).toString(); + if (peek.length() == 1 && (peek.charAt(0) == '{' || peek.charAt(0) == '[')) start--; + } + if (end > start) rawValue = data.srcCode.subCFMLString(start, end - start).toString(); + } } else { value = defaultValue; @@ -2553,7 +2565,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..36552b47938 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,23 @@ 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("")); + if (value != null && value.getStart() != null && value.getEnd() != null) { + int start = value.getStart().pos; + int end = value.getEnd().pos; + // json() records position after consuming opening { or [; back up so the slice + // includes the opening delimiter (the post-delimiter behaviour is correct for + // json()'s 40 stack-trace callers — only the source-slice case wants pre-delimiter) + if (start > 0) { + String peek = data.srcCode.subCFMLString(start - 1, 1).toString(); + if (peek.length() == 1 && (peek.charAt(0) == '{' || peek.charAt(0) == '[')) start--; + } + if (end > start) rawValue = data.srcCode.subCFMLString(start, end - start).toString(); + } } // default value boolean true else { @@ -1143,7 +1156,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..9a5516f7621 100755 --- a/core/src/main/java/lucee/transformer/statement/tag/Attribute.java +++ b/core/src/main/java/lucee/transformer/statement/tag/Attribute.java @@ -30,6 +30,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 +45,16 @@ public Attribute(boolean dynamicType, String name, Expression value, String type this.isDefaultValue = isDefaultValue; } + /** Raw source text of the attribute value (between the `=` and the next attribute boundary). + * Captured at parse time when available; null for synthesised or default-injected attributes. */ + public String getRawValue() { + return rawValue; + } + + public void setRawValue(String rawValue) { + this.rawValue = rawValue; + } + public boolean isDefaultValue() { return isDefaultValue; } From 7bd91d06ef5004d2706528b0e841451f91ab3aec Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 7 May 2026 17:42:33 +0200 Subject: [PATCH 3/4] LDEV-6303 factor sliceSource helper, comment cleanup, lock contracts via tests --- .../runtime/component/ExpressionDefault.java | 8 +- .../lucee/transformer/bytecode/PageImpl.java | 2 - .../script/AbstrCFMLScriptTransformer.java | 13 +- .../transformer/cfml/tag/CFMLTransformer.java | 13 +- .../transformer/statement/tag/Attribute.java | 23 +++- test/tickets/LDEV6303.cfc | 112 ++++++++++++++++++ 6 files changed, 140 insertions(+), 31 deletions(-) diff --git a/core/src/main/java/lucee/runtime/component/ExpressionDefault.java b/core/src/main/java/lucee/runtime/component/ExpressionDefault.java index e3d4cbcfcfc..dea7b5fa043 100644 --- a/core/src/main/java/lucee/runtime/component/ExpressionDefault.java +++ b/core/src/main/java/lucee/runtime/component/ExpressionDefault.java @@ -11,11 +11,11 @@ import lucee.runtime.dump.Dumpable; import lucee.runtime.dump.SimpleDumpData; -/** Immutable source-string wrapper for non-foldable cfproperty expression-form defaults. - * Serialises to JSON as { "expression": "" } so consumers can programmatically - * distinguish expression-form metadata.default from literal-form (which serialises as a bare string). */ +/** 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; @@ -47,6 +47,8 @@ public String serialize() { @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, '"') + "}"; } diff --git a/core/src/main/java/lucee/transformer/bytecode/PageImpl.java b/core/src/main/java/lucee/transformer/bytecode/PageImpl.java index f3b0375d0f6..04367bb23c8 100755 --- a/core/src/main/java/lucee/transformer/bytecode/PageImpl.java +++ b/core/src/main/java/lucee/transformer/bytecode/PageImpl.java @@ -1265,8 +1265,6 @@ else if (defaultExpr instanceof LitBooleanImpl) { ga.invokeVirtual(Types.PROPERTY_IMPL, new Method("setDefault", Type.VOID_TYPE, new Type[] { Types.OBJECT })); } else { - // WIP: raw value includes attribute wrapping syntax (quotes + hashes); contract for - // metadata.default source TBD. See LDEV-6303-code-review.md for state. String source = propDefaultAttr.getRawValue(); if (source == null) source = ""; Type EXPRESSION_DEFAULT = Type.getType("Llucee/runtime/component/ExpressionDefault;"); 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 f476bf1fcc5..c20ffd44687 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -2541,18 +2541,7 @@ private final Attribute attribute(TagLibTag tlt, Data data, ArrayList ar if (hasValue) { comments(data); value = attributeValue(data, allowExpression); - if (value != null && value.getStart() != null && value.getEnd() != null) { - int start = value.getStart().pos; - int end = value.getEnd().pos; - // json() records position after consuming opening { or [; back up so the slice - // includes the opening delimiter (the post-delimiter behaviour is correct for - // json()'s 40 stack-trace callers — only the source-slice case wants pre-delimiter) - if (start > 0) { - String peek = data.srcCode.subCFMLString(start - 1, 1).toString(); - if (peek.length() == 1 && (peek.charAt(0) == '{' || peek.charAt(0) == '[')) start--; - } - if (end > start) rawValue = data.srcCode.subCFMLString(start, end - start).toString(); - } + rawValue = Attribute.sliceSource(data.srcCode, value); } else { value = defaultValue; 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 36552b47938..ebee1229328 100755 --- a/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java @@ -1131,18 +1131,7 @@ private static Attribute attribute(Data data, TagLibTag tag, ArrayList a comment(data.srcCode, true); // Value value = attributeValue(data, tag, sbType.toString(), parseExpression[0], false, data.factory.createLitString("")); - if (value != null && value.getStart() != null && value.getEnd() != null) { - int start = value.getStart().pos; - int end = value.getEnd().pos; - // json() records position after consuming opening { or [; back up so the slice - // includes the opening delimiter (the post-delimiter behaviour is correct for - // json()'s 40 stack-trace callers — only the source-slice case wants pre-delimiter) - if (start > 0) { - String peek = data.srcCode.subCFMLString(start - 1, 1).toString(); - if (peek.length() == 1 && (peek.charAt(0) == '{' || peek.charAt(0) == '[')) start--; - } - if (end > start) rawValue = data.srcCode.subCFMLString(start, end - start).toString(); - } + rawValue = Attribute.sliceSource(data.srcCode, value); } // default value boolean true else { 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 9a5516f7621..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 { @@ -45,8 +46,10 @@ public Attribute(boolean dynamicType, String name, Expression value, String type this.isDefaultValue = isDefaultValue; } - /** Raw source text of the attribute value (between the `=` and the next attribute boundary). - * Captured at parse time when available; null for synthesised or default-injected attributes. */ + /** 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; } @@ -55,6 +58,22 @@ 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 index 2d912eaa9f4..5b157036fee 100644 --- a/test/tickets/LDEV6303.cfc +++ b/test/tickets/LDEV6303.cfc @@ -468,6 +468,118 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="orm" { }); + 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(); + }); + + }); + }); } From 5b4fd99e09d5bcd77f4947c8ace6e95872974403 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 7 May 2026 19:37:48 +0200 Subject: [PATCH 4/4] LDEV-6303 drop duplicate re-fire, restore deep-copy contract; defer Hibernate LDEV-4121 fix to extension --- .../java/lucee/runtime/ComponentImpl.java | 45 ++-------- .../component/ExpressionDefaultSeeder.java | 9 -- .../lucee/transformer/bytecode/PageImpl.java | 55 +----------- .../bytecode/statement/tag/TagProperty.java | 8 +- test/tickets/LDEV6303.cfc | 88 +++---------------- 5 files changed, 21 insertions(+), 184 deletions(-) delete mode 100644 core/src/main/java/lucee/runtime/component/ExpressionDefaultSeeder.java diff --git a/core/src/main/java/lucee/runtime/ComponentImpl.java b/core/src/main/java/lucee/runtime/ComponentImpl.java index 9795292d4ef..304364a0934 100755 --- a/core/src/main/java/lucee/runtime/ComponentImpl.java +++ b/core/src/main/java/lucee/runtime/ComponentImpl.java @@ -57,7 +57,6 @@ import lucee.runtime.component.ComponentLoader; import lucee.runtime.component.ComponentPageRef; import lucee.runtime.component.DataMember; -import lucee.runtime.component.ExpressionDefaultSeeder; import lucee.runtime.component.ImportDefintion; import lucee.runtime.component.Member; import lucee.runtime.component.MetaDataSoftReference; @@ -368,8 +367,6 @@ public ComponentImpl _duplicate(boolean deepCopy, boolean isTop) { if (useShadow) { addUDFS(trg, ((ComponentScopeShadow) scope).getShadow(), ((ComponentScopeShadow) trg.scope).getShadow()); } - - reFireExpressionDefaults(trg); } } finally { @@ -379,41 +376,6 @@ public ComponentImpl _duplicate(boolean deepCopy, boolean isTop) { return trg; } - private static void reFireExpressionDefaults(ComponentImpl trg) { - PageContext pc = ThreadLocalPageContext.get(); - if (pc == null) return; - - Variables prevVars = pc.variablesScope(); - try { - if (trg.scope instanceof Variables) pc.setVariablesScope((Variables) trg.scope); - - ComponentImpl walker = trg; - while (walker != null) { - if (walker.pageSource != null) { - try { - lucee.runtime.Page page = walker.pageSource.loadPage(pc, false); - if (page instanceof ExpressionDefaultSeeder) { - try { - ((ExpressionDefaultSeeder) page)._seedExpressionDefaults(pc); - } - catch (Throwable t) { - ExceptionUtil.rethrowIfNecessary(t); - throw new lucee.runtime.exp.PageRuntimeException(Caster.toPageException(t)); - } - } - } - catch (PageException pe) { - throw new lucee.runtime.exp.PageRuntimeException(pe); - } - } - walker = (ComponentImpl) walker.base; - } - } - finally { - if (prevVars != null) pc.setVariablesScope(prevVars); - } - } - private static void addUDFS(ComponentImpl trgComp, Map src, Map trg) { if (!src.isEmpty()) { Iterator it = src.entrySet().iterator(); @@ -2480,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/ExpressionDefaultSeeder.java b/core/src/main/java/lucee/runtime/component/ExpressionDefaultSeeder.java deleted file mode 100644 index 661c5c8c6c4..00000000000 --- a/core/src/main/java/lucee/runtime/component/ExpressionDefaultSeeder.java +++ /dev/null @@ -1,9 +0,0 @@ -package lucee.runtime.component; - -import lucee.runtime.PageContext; - -/** Implemented by emitted CFC classes that declare cfproperty expression-form defaults. */ -public interface ExpressionDefaultSeeder { - - void _seedExpressionDefaults(PageContext pc) throws Throwable; -} diff --git a/core/src/main/java/lucee/transformer/bytecode/PageImpl.java b/core/src/main/java/lucee/transformer/bytecode/PageImpl.java index 04367bb23c8..057a1f0f65e 100755 --- a/core/src/main/java/lucee/transformer/bytecode/PageImpl.java +++ b/core/src/main/java/lucee/transformer/bytecode/PageImpl.java @@ -380,17 +380,7 @@ public byte[] execute(String className) throws TransformerException { String[] interfaces = null; if (isComponent(comp)) { parent = ComponentPageImpl.class.getName();// "lucee/runtime/ComponentPage"; - List exprDefProps = collectExpressionFormProperties(comp); - boolean hasExprDefaults = !exprDefProps.isEmpty(); - if (isSub && hasExprDefaults) { - interfaces = new String[] { SubPage.class.getName().replace('.', '/'), "lucee/runtime/component/ExpressionDefaultSeeder" }; - } - else if (isSub) { - interfaces = new String[] { SubPage.class.getName().replace('.', '/') }; - } - else if (hasExprDefaults) { - interfaces = new String[] { "lucee/runtime/component/ExpressionDefaultSeeder" }; - } + if (isSub) interfaces = new String[] { SubPage.class.getName().replace('.', '/') }; } else if (isInterface(comp)) parent = InterfacePageImpl.class.getName();// "lucee/runtime/InterfacePage"; parent = parent.replace('.', '/'); @@ -775,8 +765,6 @@ else if (functions.length <= 10) { // newInstance/initComponent/call writeOutStatic(optionalPS, constr, keys, cw, comp, className); - writeOutSeedExpressionDefaults(optionalPS, constr, keys, cw, comp, className); - // set field subs FieldVisitor fv = cw.visitField(Opcodes.ACC_PRIVATE, "subs", "[Llucee/runtime/CIPage;", null, null); fv.visitEnd(); @@ -1561,47 +1549,6 @@ else if (defaultExpr instanceof LitBooleanImpl) { } - private static List collectExpressionFormProperties(TagCIObject component) { - List result = new ArrayList(); - if (component == null || component.getBody() == null) return result; - List statements = component.getBody().getStatements(); - if (statements == null) return result; - for (Statement stmt: statements) { - if (!(stmt instanceof TagProperty)) continue; - TagProperty tagProp = (TagProperty) stmt; - Attribute defaultAttr = tagProp.getAttribute("default"); - if (defaultAttr == null || defaultAttr.getValue() == null) continue; - Expression defaultExpr = defaultAttr.getValue(); - if (defaultExpr instanceof LitStringImpl || defaultExpr instanceof LitNumberImpl || defaultExpr instanceof LitBooleanImpl) continue; - Attribute nameAttr = tagProp.getAttribute("name"); - if (nameAttr == null || nameAttr.getValue() == null) continue; - result.add(tagProp); - } - return result; - } - - private void writeOutSeedExpressionDefaults(PageSource optionalPS, ConstrBytecodeContext constr, Map keys, ClassWriter cw, TagCIObject component, - String name) throws TransformerException { - List exprDefProps = collectExpressionFormProperties(component); - if (exprDefProps.isEmpty()) return; - - Method seedMethod = new Method("_seedExpressionDefaults", Types.VOID, new Type[] { Types.PAGE_CONTEXT }); - GeneratorAdapter ga = new GeneratorAdapter(Opcodes.ACC_PUBLIC, seedMethod, null, new Type[] { Types.THROWABLE }, cw); - BytecodeContext bc = new BytecodeContext(config, optionalPS, constr, this, keys, cw, name, ga, seedMethod, writeLog(), suppressWSbeforeArg, output, returnValue, - sourceCode.getSourceOffset()); - - for (TagProperty tagProp: exprDefProps) { - Attribute nameAttr = tagProp.getAttribute("name"); - Attribute defaultAttr = tagProp.getAttribute("default"); - String propName = nameAttr.getValue() instanceof Literal ? ((Literal) nameAttr.getValue()).getString() : null; - if (propName == null) continue; - TagProperty.emitExpressionEvalAndSet(bc, propName, defaultAttr.getValue()); - } - - ga.returnValue(); - ga.endMethod(); - } - private String getTagAttributeValue(Tag tag, String attrName) { Attribute attr = tag.getAttribute(attrName); if (attr != null && attr.getValue() != null) { 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 1b5d059b6bc..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 @@ -75,11 +75,9 @@ public void _writeOut(BytecodeContext bc) throws TransformerException { bc.visitLine(tag.getEnd()); } - /** - * Emits eval + variablesScope.setEL bytecode for a single cfproperty expression-form default. - * Shared by construction-time pseudo-constructor and the duplicate-time _seedExpressionDefaults - * method so both paths emit identical eval bytecode and stay in lockstep. - */ + /** 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); diff --git a/test/tickets/LDEV6303.cfc b/test/tickets/LDEV6303.cfc index 5b157036fee..ae9d879b8f4 100644 --- a/test/tickets/LDEV6303.cfc +++ b/test/tickets/LDEV6303.cfc @@ -362,110 +362,46 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="orm" { }); - describe( "duplicate() re-fires expression-form defaults — fresh per-instance evaluation", function(){ + 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( "duplicate fires fresh #now()# — dup gets a later timestamp than src after sleep", function(){ - // Contract: duplicate re-fires expression-form defaults so each duplicate - // gets a fresh per-instance evaluation. The natural reading of - // default="#now()#" is "the time this instance came into existence" — and - // duplicates ARE new instances coming into existence. - var src = new LDEV6303.ExpressionDefaultsCfc(); - sleep( 1100 ); - var dup = duplicate( src ); - - expect( dateCompare( dup.getNowDef(), src.getNowDef(), "s" ) ).toBeGT( 0, - "duplicate must re-evaluate now() — dup's timestamp should be later than src's" - ); - }); - - it( "duplicate fires fresh #createUUID()# — dup's UUID differs from src's", function(){ - var src = new LDEV6303.ExpressionDefaultsCfc(); - var dup = duplicate( src ); - - expect( dup.getUuidDef() ).notToBe( src.getUuidDef(), - "duplicate must re-evaluate createUUID() — dup gets a fresh UUID" - ); - // sanity: both look like UUIDs - expect( len( dup.getUuidDef() ) ).toBeGT( 0 ); - expect( len( src.getUuidDef() ) ).toBeGT( 0 ); - }); - - it( "duplicate fires fresh deterministic expression (eval gives same value but separate computation)", function(){ - // Deterministic expressions naturally give the same value either way. - // This test documents that they continue to work — fresh eval of - // repeatString('x',5) gives "xxxxx" same as src's value. + 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( "duplicate gives dup its own array — mutating dup does not leak to src", function(){ - // Mutable expression-form defaults (#[]#, #{}#) must produce a fresh - // container per duplicate. Without re-fire, a shallow-copy duplicate - // would share the array reference with src — silent leak. + 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 array (separate references via re-fire)" + "src's array must be unaffected by mutation on dup's deep-copy array" ); expect( arrayLen( dup.getFreshArrayDef() ) ).toBe( 1 ); }); - it( "duplicate(src, false) — shallow duplicate also re-fires expression-form (Hibernate path)", function(){ - // Hibernate's CFCInstantiator uses cfc.duplicate(false) to create - // entity instances. The shallow flag must NOT prevent re-fire of - // expression-form defaults — otherwise every entity from a cached - // template gets the template's frozen UUID/now/[]/{}. - var src = new LDEV6303.ExpressionDefaultsCfc(); - var dup = duplicate( src, false ); - - expect( dup.getUuidDef() ).notToBe( src.getUuidDef(), - "duplicate(src, false) must re-evaluate expression-form defaults too" - ); - - // Mutation on dup's struct must not leak to src - var s = dup.getNowStructDef(); - s.n = 999; - - expect( src.getNowStructDef().n ).toBe( 1, - "src's struct must be a separate reference from dup's (no shared-ref leak via shallow duplicate)" - ); - }); - - it( "explicit assignment after duplicate still wins — user values aren't clobbered by re-fire", function(){ - // Re-fire seeds dup's scope at duplicate-time. Subsequent explicit - // assignment by the user must override the re-fire'd value. + 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, - "explicit setNowDef on dup must win over re-fire'd value" - ); + 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" ); }); - it( "inherited expression-form default re-fires on the child duplicate", function(){ - // Child class instance, inheriting a #now()# default from parent. - // duplicate of the child must re-fire the parent's expression-form - // default — inheritance walk in _duplicate. - var src = new LDEV6303.ChildExprDefCfc(); - sleep( 1100 ); - var dup = duplicate( src ); - - expect( dateCompare( dup.getParentNowDef(), src.getParentNowDef(), "s" ) ).toBeGT( 0, - "inherited #now()# default must re-fire on the child's duplicate" - ); - }); - }); describe( "serializeJson shape — programmatic discriminability for consumers", function(){