From 10bc19742e5f980f6013764bc065096655251978 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 02:05:04 -0600 Subject: [PATCH 1/3] 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 ef09a9c8..91ad35d1 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -151,6 +151,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 **/ @@ -257,6 +262,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; @@ -1064,9 +1070,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; } @@ -1267,6 +1274,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", @@ -1624,8 +1640,9 @@ component accessors="true" { * @returns quick.models.BaseEntity */ public any function clearRelationships() { - variables._relationshipsData = {}; - variables._relationshipsLoaded = {}; + variables._relationshipsData = {}; + variables._relationshipsLoaded = {}; + variables._deferredRelationships = []; return this; } @@ -1639,6 +1656,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; } @@ -2633,6 +2651,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 5b1c7b32..80862822 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 10b0ae246332f8db76f5572abc49616c729fec35 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 23 Aug 2026 08:14:28 -0600 Subject: [PATCH 2/3] 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 80862822..761bd072 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 60597ba1767f7dda6863d124cb39c5b521f16e20 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 23 Aug 2026 08:16:04 -0600 Subject: [PATCH 3/3] 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 91ad35d1..1d05a6a1 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -151,11 +151,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 **/ @@ -262,7 +257,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; @@ -1070,10 +1064,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; } @@ -1274,15 +1267,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", @@ -1640,9 +1624,8 @@ component accessors="true" { * @returns quick.models.BaseEntity */ public any function clearRelationships() { - variables._relationshipsData = {}; - variables._relationshipsLoaded = {}; - variables._deferredRelationships = []; + variables._relationshipsData = {}; + variables._relationshipsLoaded = {}; return this; } @@ -1656,7 +1639,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; } @@ -2663,9 +2645,6 @@ component accessors="true" { ? relationshipValue.map( fillRelatedEntity ) : fillRelatedEntity( relationshipValue ); assignRelationship( relationshipName, filledRelationship ); - if ( !variables._deferredRelationships.findNoCase( relationshipName ) ) { - variables._deferredRelationships.append( relationshipName ); - } return filledRelationship; } guardAgainstNotLoaded(