From 8b4691d7cd90b322cef23f0d33ede1ee6e849645 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 02:05:04 -0600 Subject: [PATCH 1/4] feat: fill relationships on new entities (#179) --- models/BaseEntity.cfc | 45 ++++++++++++++++--- .../integration/BaseEntity/CreateSpec.cfc | 20 +++++++++ .../specs/integration/BaseEntity/FillSpec.cfc | 18 ++++++++ .../integration/GoodErrorMessagesSpec.cfc | 23 +++++----- 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index 8900ec48..4eb42498 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -156,6 +156,11 @@ component accessors="true" { */ property name="_relationshipsLoaded" persistent="false"; + /** + * Relationships filled while this entity is new and waiting to be persisted. + */ + property name="_deferredRelationships" persistent="false"; + /** * Discriminated chilrent property **/ @@ -262,6 +267,7 @@ component accessors="true" { param variables._data = {}; param variables._relationshipsData = {}; param variables._relationshipsLoaded = {}; + variables._deferredRelationships = []; param variables._with = []; variables._withoutRelationshipConstraints = createObject( "java", "java.util.HashSet" ).init(); variables._applyingGlobalScopes = false; @@ -1133,9 +1139,10 @@ component accessors="true" { if ( arguments.toNew ) { assignOriginalAttributes( {} ); } - variables._relationshipsData = {}; - variables._relationshipsLoaded = {}; - variables._loaded = arguments.toNew ? false : variables._loaded; + variables._relationshipsData = {}; + variables._relationshipsLoaded = {}; + variables._deferredRelationships = []; + variables._loaded = arguments.toNew ? false : variables._loaded; return this; } @@ -1336,6 +1343,15 @@ component accessors="true" { } ); } + var deferredRelationships = variables._deferredRelationships.duplicate(); + variables._deferredRelationships = []; + for ( var relationshipName in deferredRelationships ) { + invoke( + this, + "set#relationshipName#", + { "1" : retrieveRelationship( relationshipName ) } + ); + } variables._saving = false; fireEvent( "postSave", @@ -1764,8 +1780,9 @@ component accessors="true" { * @returns quick.models.BaseEntity */ public any function clearRelationships() { - variables._relationshipsData = {}; - variables._relationshipsLoaded = {}; + variables._relationshipsData = {}; + variables._relationshipsLoaded = {}; + variables._deferredRelationships = []; return this; } @@ -1779,6 +1796,7 @@ component accessors="true" { public any function clearRelationship( required string name ) { variables._relationshipsData.delete( arguments.name ); variables._relationshipsLoaded.delete( arguments.name ); + variables._deferredRelationships.delete( arguments.name ); return this; } @@ -2777,6 +2795,23 @@ component accessors="true" { relationship.relationshipClass != "BelongsTo" && relationship.relationshipClass != "PolymorphicBelongsTo" ) { + if ( !isLoaded() ) { + var relationshipValue = arguments.missingMethodArguments[ 1 ]; + var relatedEntity = relationship.getRelated(); + var fillRelatedEntity = function( value ) { + return isStruct( arguments.value ) && !structKeyExists( arguments.value, "isQuickEntity" ) + ? relatedEntity.newEntity().fill( arguments.value ) + : arguments.value; + }; + var filledRelationship = isArray( relationshipValue ) + ? relationshipValue.map( fillRelatedEntity ) + : fillRelatedEntity( relationshipValue ); + assignRelationship( relationshipName, filledRelationship ); + if ( !variables._deferredRelationships.findNoCase( relationshipName ) ) { + variables._deferredRelationships.append( relationshipName ); + } + return filledRelationship; + } guardAgainstNotLoaded( "This instance is not loaded so it cannot set the [#relationshipName#] relationship. " & "Save the new entity first before trying to save related entities." diff --git a/tests/specs/integration/BaseEntity/CreateSpec.cfc b/tests/specs/integration/BaseEntity/CreateSpec.cfc index e294a3cc..d484c997 100644 --- a/tests/specs/integration/BaseEntity/CreateSpec.cfc +++ b/tests/specs/integration/BaseEntity/CreateSpec.cfc @@ -36,6 +36,26 @@ component extends="tests.resources.ModuleIntegrationSpec" { ).notToBeNull(); } ); + it( "persists relationships filled before creating the parent", function() { + var user = getInstance( "User" ).create( { + "username" : "aggregate-user", + "first_name" : "Aggregate", + "last_name" : "User", + "password" : hash( "password" ), + "posts" : [ + { "body" : "First child" }, + { "body" : "Second child" } + ] + } ); + + expect( user.isLoaded() ).toBeTrue(); + expect( user.getPosts() ).toHaveLength( 2 ); + expect( user.getPosts()[ 1 ].isLoaded() ).toBeTrue(); + expect( user.getPosts()[ 1 ].getUser_Id() ).toBe( user.getId() ); + expect( user.getPosts()[ 2 ].isLoaded() ).toBeTrue(); + expect( user.getPosts()[ 2 ].getUser_Id() ).toBe( user.getId() ); + } ); + it( "can create a new entity with a json cast", () => { var newTheme = getInstance( "Theme" ).create( { slug : "theme-new", diff --git a/tests/specs/integration/BaseEntity/FillSpec.cfc b/tests/specs/integration/BaseEntity/FillSpec.cfc index 287c3797..1047e2e8 100644 --- a/tests/specs/integration/BaseEntity/FillSpec.cfc +++ b/tests/specs/integration/BaseEntity/FillSpec.cfc @@ -60,6 +60,24 @@ component extends="tests.resources.ModuleIntegrationSpec" { } ).notToThrow(); expect( user.isNullAttribute( "updatedDate" ) ).toBeTrue(); } ); + + it( "can fill relationships on a new entity without persisting the aggregate", function() { + var user = getInstance( "User" ).fill( { + "posts" : [ + getInstance( "Post" ).fill( { "body" : "Entity child" } ), + { "body" : "Struct child" } + ] + } ); + + expect( user.isLoaded() ).toBeFalse(); + expect( user.getPosts() ).toHaveLength( 2 ); + expect( user.getPosts()[ 1 ] ).toBeInstanceOf( "Post" ); + expect( user.getPosts()[ 1 ].isLoaded() ).toBeFalse(); + expect( user.getPosts()[ 1 ].getBody() ).toBe( "Entity child" ); + expect( user.getPosts()[ 2 ] ).toBeInstanceOf( "Post" ); + expect( user.getPosts()[ 2 ].isLoaded() ).toBeFalse(); + expect( user.getPosts()[ 2 ].getBody() ).toBe( "Struct child" ); + } ); } ); } diff --git a/tests/specs/integration/GoodErrorMessagesSpec.cfc b/tests/specs/integration/GoodErrorMessagesSpec.cfc index b70a0adf..881e6339 100644 --- a/tests/specs/integration/GoodErrorMessagesSpec.cfc +++ b/tests/specs/integration/GoodErrorMessagesSpec.cfc @@ -64,18 +64,17 @@ component extends="tests.resources.ModuleIntegrationSpec" { skip = server.keyExists( "boxlang" ) ); - it( "throws a helpful error message when trying to set a belongsToMany relationship when the relationship is not loaded", function() { - expect( function() { - getInstance( "Post" ).create( { - "user_id" : 1, - "body" : "A new post body", - "publishedDate" : now(), - "tags" : [ 1, 2 ] - } ); - } ).toThrow( - type = "QuickEntityNotLoaded", - regex = "This instance is not loaded so it cannot set the \[tags\] relationship\. Save the new entity first before trying to save related entities\." - ); + it( "persists filled relationships after creating the parent entity", function() { + var post = getInstance( "Post" ).create( { + "user_id" : 1, + "body" : "A new post body", + "publishedDate" : now(), + "tags" : [ 1, 2 ] + } ); + + expect( post.isLoaded() ).toBeTrue(); + expect( post.getTags() ).toHaveLength( 2 ); + expect( post.getTags().map( ( tag ) => tag.getId() ) ).toBe( [ 1, 2 ] ); } ); } ); } From 37d97c3ca5c9924418f764c3b0ac246ce4ab147f Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 23 Aug 2026 08:14:28 -0600 Subject: [PATCH 2/4] test: keep aggregate relationship saves explicit --- tests/specs/integration/BaseEntity/CreateSpec.cfc | 9 ++++----- tests/specs/integration/GoodErrorMessagesSpec.cfc | 6 +++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/specs/integration/BaseEntity/CreateSpec.cfc b/tests/specs/integration/BaseEntity/CreateSpec.cfc index d484c997..9344aacc 100644 --- a/tests/specs/integration/BaseEntity/CreateSpec.cfc +++ b/tests/specs/integration/BaseEntity/CreateSpec.cfc @@ -36,7 +36,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { ).notToBeNull(); } ); - it( "persists relationships filled before creating the parent", function() { + it( "creates only the root while retaining filled relationships in memory", function() { var user = getInstance( "User" ).create( { "username" : "aggregate-user", "first_name" : "Aggregate", @@ -50,10 +50,9 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( user.isLoaded() ).toBeTrue(); expect( user.getPosts() ).toHaveLength( 2 ); - expect( user.getPosts()[ 1 ].isLoaded() ).toBeTrue(); - expect( user.getPosts()[ 1 ].getUser_Id() ).toBe( user.getId() ); - expect( user.getPosts()[ 2 ].isLoaded() ).toBeTrue(); - expect( user.getPosts()[ 2 ].getUser_Id() ).toBe( user.getId() ); + expect( user.getPosts()[ 1 ].isLoaded() ).toBeFalse(); + expect( user.getPosts()[ 2 ].isLoaded() ).toBeFalse(); + expect( user.fresh().getPosts() ).toBeEmpty(); } ); it( "can create a new entity with a json cast", () => { diff --git a/tests/specs/integration/GoodErrorMessagesSpec.cfc b/tests/specs/integration/GoodErrorMessagesSpec.cfc index 881e6339..69abe5cc 100644 --- a/tests/specs/integration/GoodErrorMessagesSpec.cfc +++ b/tests/specs/integration/GoodErrorMessagesSpec.cfc @@ -64,7 +64,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { skip = server.keyExists( "boxlang" ) ); - it( "persists filled relationships after creating the parent entity", function() { + it( "does not persist a filled belongsToMany relationship when creating the parent", function() { var post = getInstance( "Post" ).create( { "user_id" : 1, "body" : "A new post body", @@ -73,8 +73,8 @@ component extends="tests.resources.ModuleIntegrationSpec" { } ); expect( post.isLoaded() ).toBeTrue(); - expect( post.getTags() ).toHaveLength( 2 ); - expect( post.getTags().map( ( tag ) => tag.getId() ) ).toBe( [ 1, 2 ] ); + expect( post.getTags() ).toBe( [ 1, 2 ] ); + expect( post.fresh().getTags() ).toBeEmpty(); } ); } ); } From 6db09b62c9dbdb7ec87b78991f3c291c196e55fe Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 23 Aug 2026 08:16:04 -0600 Subject: [PATCH 3/4] fix: keep relationship persistence explicit --- models/BaseEntity.cfc | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index 4eb42498..f8ce2515 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -156,11 +156,6 @@ component accessors="true" { */ property name="_relationshipsLoaded" persistent="false"; - /** - * Relationships filled while this entity is new and waiting to be persisted. - */ - property name="_deferredRelationships" persistent="false"; - /** * Discriminated chilrent property **/ @@ -267,7 +262,6 @@ component accessors="true" { param variables._data = {}; param variables._relationshipsData = {}; param variables._relationshipsLoaded = {}; - variables._deferredRelationships = []; param variables._with = []; variables._withoutRelationshipConstraints = createObject( "java", "java.util.HashSet" ).init(); variables._applyingGlobalScopes = false; @@ -1139,10 +1133,9 @@ component accessors="true" { if ( arguments.toNew ) { assignOriginalAttributes( {} ); } - variables._relationshipsData = {}; - variables._relationshipsLoaded = {}; - variables._deferredRelationships = []; - variables._loaded = arguments.toNew ? false : variables._loaded; + variables._relationshipsData = {}; + variables._relationshipsLoaded = {}; + variables._loaded = arguments.toNew ? false : variables._loaded; return this; } @@ -1343,15 +1336,6 @@ component accessors="true" { } ); } - var deferredRelationships = variables._deferredRelationships.duplicate(); - variables._deferredRelationships = []; - for ( var relationshipName in deferredRelationships ) { - invoke( - this, - "set#relationshipName#", - { "1" : retrieveRelationship( relationshipName ) } - ); - } variables._saving = false; fireEvent( "postSave", @@ -1780,9 +1764,8 @@ component accessors="true" { * @returns quick.models.BaseEntity */ public any function clearRelationships() { - variables._relationshipsData = {}; - variables._relationshipsLoaded = {}; - variables._deferredRelationships = []; + variables._relationshipsData = {}; + variables._relationshipsLoaded = {}; return this; } @@ -1796,7 +1779,6 @@ component accessors="true" { public any function clearRelationship( required string name ) { variables._relationshipsData.delete( arguments.name ); variables._relationshipsLoaded.delete( arguments.name ); - variables._deferredRelationships.delete( arguments.name ); return this; } @@ -2807,9 +2789,6 @@ component accessors="true" { ? relationshipValue.map( fillRelatedEntity ) : fillRelatedEntity( relationshipValue ); assignRelationship( relationshipName, filledRelationship ); - if ( !variables._deferredRelationships.findNoCase( relationshipName ) ) { - variables._deferredRelationships.append( relationshipName ); - } return filledRelationship; } guardAgainstNotLoaded( From 3ec240d9e940ac553c1b20629309b82e472021e0 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 13:45:20 -0600 Subject: [PATCH 4/4] refactor: avoid internal closures --- models/BaseEntity.cfc | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index f8ce2515..6a070b5f 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -2778,16 +2778,21 @@ component accessors="true" { relationship.relationshipClass != "PolymorphicBelongsTo" ) { if ( !isLoaded() ) { - var relationshipValue = arguments.missingMethodArguments[ 1 ]; - var relatedEntity = relationship.getRelated(); - var fillRelatedEntity = function( value ) { - return isStruct( arguments.value ) && !structKeyExists( arguments.value, "isQuickEntity" ) - ? relatedEntity.newEntity().fill( arguments.value ) - : arguments.value; - }; - var filledRelationship = isArray( relationshipValue ) - ? relationshipValue.map( fillRelatedEntity ) - : fillRelatedEntity( relationshipValue ); + var relationshipValue = arguments.missingMethodArguments[ 1 ]; + var relatedEntity = relationship.getRelated(); + var filledRelationship = relationshipValue; + if ( isArray( relationshipValue ) ) { + filledRelationship = []; + for ( var value in relationshipValue ) { + filledRelationship.append( + isStruct( value ) && !structKeyExists( value, "isQuickEntity" ) + ? relatedEntity.newEntity().fill( value ) + : value + ); + } + } else if ( isStruct( relationshipValue ) && !structKeyExists( relationshipValue, "isQuickEntity" ) ) { + filledRelationship = relatedEntity.newEntity().fill( relationshipValue ); + } assignRelationship( relationshipName, filledRelationship ); return filledRelationship; }