From 814c5c100a2de32f8b64c354889a0dcc140bd52a Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 07:06:56 -0600 Subject: [PATCH 1/2] feat: add entity replication Closes #42 --- ModuleConfig.cfc | 1 + models/BaseEntity.cfc | 25 +++++++++ tests/resources/app/models/Song.cfc | 10 ++-- .../integration/BaseEntity/CloneSpec.cfc | 23 +++++++++ .../BaseEntity/Events/PostReplicateSpec.cfc | 51 +++++++++++++++++++ 5 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 tests/specs/integration/BaseEntity/Events/PostReplicateSpec.cfc diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index e6cb2407..46d6782b 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -35,6 +35,7 @@ component { "quickInstanceReady", "quickPreLoad", "quickPostLoad", + "quickPostReplicate", "quickPreSave", "quickPostSave", "quickPreInsert", diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index 8900ec48..6f44aaa5 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -1219,6 +1219,31 @@ component accessors="true" { return entityClone; } + /** + * Creates a new, unloaded entity with this entity's attributes except for + * its primary key and any additional excluded attributes. + * + * @except Additional attribute aliases or column names to exclude. + * + * @return quick.models.BaseEntity + */ + public any function replicate( array except = [] ) { + var attributes = retrieveAttributesData( withoutKey = true, withNulls = true ); + for ( var attribute in arguments.except ) { + attributes.delete( retrieveColumnForAlias( attribute ) ); + } + + var replica = newEntity().fill( attributes ); + replica.fireEvent( + "postReplicate", + { + "entity" : replica, + "original" : this + } + ); + return replica; + } + /*=========================================== diff --git a/tests/resources/app/models/Song.cfc b/tests/resources/app/models/Song.cfc index d29f8cb9..6b09d598 100644 --- a/tests/resources/app/models/Song.cfc +++ b/tests/resources/app/models/Song.cfc @@ -19,9 +19,13 @@ component extends="quick.models.BaseEntity" accessors="true" { request.preLoadCalled = eventData; } - function postLoad( eventData ) { - request.postLoadCalled = eventData; - } + function postLoad( eventData ) { + request.postLoadCalled = eventData; + } + + function postReplicate( eventData ) { + request.postReplicateCalled = eventData; + } function preInsert( eventData ) { request.preInsertCalled = { diff --git a/tests/specs/integration/BaseEntity/CloneSpec.cfc b/tests/specs/integration/BaseEntity/CloneSpec.cfc index a671cc17..ad4cdaa8 100644 --- a/tests/specs/integration/BaseEntity/CloneSpec.cfc +++ b/tests/specs/integration/BaseEntity/CloneSpec.cfc @@ -26,6 +26,29 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( clonedUser.isLoaded() ).toBeTrue( "The cloned user instance should be marked as loaded, but was not." ); } ); + it( "can replicate a loaded entity as a new entity without its key", function() { + var user = getInstance( "User" ).findOrFail( 1 ); + var replica = user.replicate(); + + expect( replica.isLoaded() ).toBeFalse(); + expect( replica.retrieveAttributesData() ).notToHaveKey( "id" ); + expect( replica.getUsername() ).toBe( user.getUsername() ); + + replica + .setUsername( "replicated-user" ) + .setEmail( "replicated@example.com" ) + .save(); + expect( replica.isLoaded() ).toBeTrue(); + expect( replica.getId() ).notToBe( user.getId() ); + } ); + + it( "can exclude additional attributes when replicating", function() { + var replica = getInstance( "User" ).findOrFail( 1 ).replicate( [ "email" ] ); + + expect( replica.retrieveAttributesData() ).notToHaveKey( "id" ); + expect( replica.retrieveAttributesData() ).notToHaveKey( "email" ); + } ); + it( "can clone a QuickBuilder instance", function() { var userBuilder = getInstance( "User" ).orderBy( "id" ); var userBuilder2 = userBuilder.clone(); diff --git a/tests/specs/integration/BaseEntity/Events/PostReplicateSpec.cfc b/tests/specs/integration/BaseEntity/Events/PostReplicateSpec.cfc new file mode 100644 index 00000000..a6bd5ad6 --- /dev/null +++ b/tests/specs/integration/BaseEntity/Events/PostReplicateSpec.cfc @@ -0,0 +1,51 @@ +component extends="tests.resources.ModuleIntegrationSpec" { + + function beforeAll() { + super.beforeAll(); + controller + .getInterceptorService() + .registerInterceptor( interceptorObject = this, interceptorName = "PostReplicateSpec" ); + } + + function afterAll() { + controller.getInterceptorService().unregister( "PostReplicateSpec" ); + super.afterAll(); + } + + function run() { + describe( "postReplicate spec", function() { + it( "announces a quickPostReplicate interception point", function() { + var original = getInstance( "Song" ).findOrFail( 1 ); + var replica = original.replicate(); + + expect( variables ).toHaveKey( "quickPostReplicateCalled" ); + expect( variables.quickPostReplicateCalled.entity.isLoaded() ).toBeFalse(); + expect( variables.quickPostReplicateCalled.entity.getTitle() ).toBe( replica.getTitle() ); + expect( variables.quickPostReplicateCalled.original.getId() ).toBe( original.getId() ); + structDelete( variables, "quickPostReplicateCalled" ); + } ); + + it( "calls a postReplicate method on the replicated component", function() { + var original = getInstance( "Song" ).findOrFail( 1 ); + var replica = original.replicate(); + + expect( request ).toHaveKey( "postReplicateCalled" ); + expect( request.postReplicateCalled.entity.isLoaded() ).toBeFalse(); + expect( request.postReplicateCalled.entity.getTitle() ).toBe( replica.getTitle() ); + expect( request.postReplicateCalled.original.getId() ).toBe( original.getId() ); + structDelete( request, "postReplicateCalled" ); + } ); + } ); + } + + function quickPostReplicate( + event, + interceptData, + buffer, + rc, + prc + ) { + variables.quickPostReplicateCalled = arguments.interceptData; + } + +} From b1a0a1e320cc738b03782dd606039bee4bda00e1 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 13:45:19 -0600 Subject: [PATCH 2/2] style: format replication fixture --- tests/resources/app/models/Song.cfc | 119 ++++++++++++++-------------- 1 file changed, 58 insertions(+), 61 deletions(-) diff --git a/tests/resources/app/models/Song.cfc b/tests/resources/app/models/Song.cfc index 6b09d598..1211064d 100644 --- a/tests/resources/app/models/Song.cfc +++ b/tests/resources/app/models/Song.cfc @@ -1,23 +1,23 @@ component extends="quick.models.BaseEntity" accessors="true" { - property name="id"; - property name="title" nullValue="REALLY_NULL"; - property name="downloadUrl" column="download_url"; - property name="createdDate" column="created_date"; - property name="modifiedDate" column="modified_date"; + property name="id"; + property name="title" nullValue="REALLY_NULL"; + property name="downloadUrl" column="download_url"; + property name="createdDate" column="created_date"; + property name="modifiedDate" column="modified_date"; variables._dispatchesEvents = { "postInsert" : "onSongCreated", "postSave" : [ "onSongSaved", "onMediaSaved" ] }; - function instanceReady( eventData ) { - request.instanceReadyCalled = eventData; - } + function instanceReady( eventData ) { + request.instanceReadyCalled = eventData; + } - function preLoad( eventData ) { - request.preLoadCalled = eventData; - } + function preLoad( eventData ) { + request.preLoadCalled = eventData; + } function postLoad( eventData ) { request.postLoadCalled = eventData; @@ -27,62 +27,59 @@ component extends="quick.models.BaseEntity" accessors="true" { request.postReplicateCalled = eventData; } - function preInsert( eventData ) { - request.preInsertCalled = { - "entity": arguments.eventData.entity.getMemento(), - "isLoaded": arguments.eventData.entity.isLoaded() - }; - } + function preInsert( eventData ) { + request.preInsertCalled = { + "entity" : arguments.eventData.entity.getMemento(), + "isLoaded" : arguments.eventData.entity.isLoaded() + }; + } - function postInsert( eventData ) { - request.postInsertCalled = { - "entity": eventData.entity.getMemento(), - "isLoaded": eventData.entity.isLoaded() - }; - } + function postInsert( eventData ) { + request.postInsertCalled = { + "entity" : eventData.entity.getMemento(), + "isLoaded" : eventData.entity.isLoaded() + }; + } - function preUpdate( eventData ) { - param request.preUpdateCalled = []; - arrayAppend( request.preUpdateCalled, { - "entity": eventData.entity.getMemento(), - "originalAttributes": eventData.originalAttributes, - "newAttributes": eventData.newAttributes - } ); - } + function preUpdate( eventData ) { + param request.preUpdateCalled = []; + arrayAppend( + request.preUpdateCalled, + { + "entity" : eventData.entity.getMemento(), + "originalAttributes" : eventData.originalAttributes, + "newAttributes" : eventData.newAttributes + } + ); + } - function postUpdate( eventData ) { - param request.postUpdateCalled = []; - arrayAppend( request.postUpdateCalled, { - "entity": eventData.entity.getMemento() - } ); - } + function postUpdate( eventData ) { + param request.postUpdateCalled = []; + arrayAppend( request.postUpdateCalled, { "entity" : eventData.entity.getMemento() } ); + } - function preSave( eventData ) { - request.preSaveCalled = { - "entity": arguments.eventData.entity.getMemento(), - "isLoaded": arguments.eventData.entity.isLoaded() - }; - } + function preSave( eventData ) { + request.preSaveCalled = { + "entity" : arguments.eventData.entity.getMemento(), + "isLoaded" : arguments.eventData.entity.isLoaded() + }; + } - function postSave( eventData ) { - request.postSaveCalled = { - "entity": arguments.eventData.entity.getMemento(), - "isLoaded": arguments.eventData.entity.isLoaded() - }; - } + function postSave( eventData ) { + request.postSaveCalled = { + "entity" : arguments.eventData.entity.getMemento(), + "isLoaded" : arguments.eventData.entity.isLoaded() + }; + } - function preDelete( eventData ) { - param request.preDeleteCalled = []; - arrayAppend( request.preDeleteCalled, { - "entity": eventData.entity.getMemento() - } ); - } + function preDelete( eventData ) { + param request.preDeleteCalled = []; + arrayAppend( request.preDeleteCalled, { "entity" : eventData.entity.getMemento() } ); + } - function postDelete( eventData ) { - param request.postDeleteCalled = []; - arrayAppend( request.postDeleteCalled, { - "entity": eventData.entity.getMemento() - } ); - } + function postDelete( eventData ) { + param request.postDeleteCalled = []; + arrayAppend( request.postDeleteCalled, { "entity" : eventData.entity.getMemento() } ); + } }