From 3a5ab0ed1487678c08176ae306cd1f75f2f82df4 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 02:05:04 -0600 Subject: [PATCH 1/5] 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 c900d867..b5b4dabe 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -158,6 +158,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 **/ @@ -270,6 +275,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; @@ -1284,9 +1290,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; } @@ -1496,6 +1503,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", @@ -1954,8 +1970,9 @@ component accessors="true" { * @returns quick.models.BaseEntity */ public any function clearRelationships() { - variables._relationshipsData = {}; - variables._relationshipsLoaded = {}; + variables._relationshipsData = {}; + variables._relationshipsLoaded = {}; + variables._deferredRelationships = []; return this; } @@ -1969,6 +1986,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; } @@ -2983,6 +3001,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 7e2e9d7d7b807282578d1b4474cbe3b34beefeed Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 23 Aug 2026 08:14:28 -0600 Subject: [PATCH 2/5] 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 b6e31290c189eda549b0df50df15013832873659 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 23 Aug 2026 08:16:04 -0600 Subject: [PATCH 3/5] 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 b5b4dabe..48619eef 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -158,11 +158,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 **/ @@ -275,7 +270,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; @@ -1290,10 +1284,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; } @@ -1503,15 +1496,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", @@ -1970,9 +1954,8 @@ component accessors="true" { * @returns quick.models.BaseEntity */ public any function clearRelationships() { - variables._relationshipsData = {}; - variables._relationshipsLoaded = {}; - variables._deferredRelationships = []; + variables._relationshipsData = {}; + variables._relationshipsLoaded = {}; return this; } @@ -1986,7 +1969,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; } @@ -3013,9 +2995,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 6a6ad0abd15f5a9f8e08a60ddee7ff22c267813b Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 13:45:20 -0600 Subject: [PATCH 4/5] 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 48619eef..4b743821 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -2984,16 +2984,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; } From 8ad398071a2f7785d3cd00fa0458d739a7b923e2 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 26 Aug 2026 10:32:53 -0600 Subject: [PATCH 5/5] test: cover repeated relationship fills --- tests/specs/integration/BaseEntity/FillSpec.cfc | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/specs/integration/BaseEntity/FillSpec.cfc b/tests/specs/integration/BaseEntity/FillSpec.cfc index 1047e2e8..1a1d54db 100644 --- a/tests/specs/integration/BaseEntity/FillSpec.cfc +++ b/tests/specs/integration/BaseEntity/FillSpec.cfc @@ -78,6 +78,20 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( user.getPosts()[ 2 ].isLoaded() ).toBeFalse(); expect( user.getPosts()[ 2 ].getBody() ).toBe( "Struct child" ); } ); + + it( "creates new relationship instances when filling the same relationship multiple times", function() { + var user = getInstance( "User" ); + + user.fill( { "posts" : [ { "body" : "First fill" } ] } ); + var firstPost = user.getPosts()[ 1 ]; + + user.fill( { "posts" : [ { "body" : "Second fill" } ] } ); + var secondPost = user.getPosts()[ 1 ]; + + expect( user.getPosts() ).toHaveLength( 1 ); + expect( secondPost.getBody() ).toBe( "Second fill" ); + expect( firstPost.getBody() ).toBe( "First fill" ); + } ); } ); }