From ab496bca7e5c184de308c5b69af557e6c736f13a Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 06:20:43 -0600 Subject: [PATCH 01/10] Add touch for entity timestamps Closes #53 --- models/BaseEntity.cfc | 14 ++++++++++++++ tests/specs/integration/BaseEntity/SaveSpec.cfc | 11 +++++++++++ 2 files changed, 25 insertions(+) diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index 043ae6b9..187d8347 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -1414,6 +1414,20 @@ component accessors="true" { return save(); } + /** + * Updates a timestamp attribute to the current time and saves the entity. + * + * @attribute The timestamp attribute to update. Default: `modifiedDate`. + * @options Any options to pass to `queryExecute`. Default: {}. + * + * @return quick.models.BaseEntity + */ + public any function touch( string attribute = "modifiedDate", struct options = {} ) { + guardAgainstNotLoaded( "This instance is not loaded so it cannot be touched." ); + assignAttribute( arguments.attribute, now() ); + return save( arguments.options ); + } + /** * Creates a new entity with the given attributes and then saves the entity. * diff --git a/tests/specs/integration/BaseEntity/SaveSpec.cfc b/tests/specs/integration/BaseEntity/SaveSpec.cfc index 9ffc5f6d..00e5982a 100644 --- a/tests/specs/integration/BaseEntity/SaveSpec.cfc +++ b/tests/specs/integration/BaseEntity/SaveSpec.cfc @@ -76,6 +76,17 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( userRowsPostSave ).toHaveLength( 5 ); } ); + it( "can touch an entity timestamp", function() { + var user = getInstance( "User" ).findOrFail( 1 ); + var originalModified = user.getModifiedDate(); + + user.touch(); + + expect( dateCompare( user.getModifiedDate(), originalModified ) ).toBe( 1 ); + expect( user.isDirty( "modifiedDate" ) ).toBeFalse(); + expect( dateCompare( user.fresh().getModifiedDate(), originalModified ) ).toBe( 1 ); + } ); + it( "does not allow updating of column where update=false in property", function() { var existingUser = getInstance( "User" ).find( 1 ); existingUser.setEmail( "test2@test.com" ); From 8956428742ac9fd2269846f450b2a53669fb04c7 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 24 Aug 2026 15:02:10 -0600 Subject: [PATCH 02/10] test: constrain touch to timestamp fields --- .../app/models/CustomTimestampUser.cfc | 11 +++++++++++ .../specs/integration/BaseEntity/SaveSpec.cfc | 19 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 tests/resources/app/models/CustomTimestampUser.cfc diff --git a/tests/resources/app/models/CustomTimestampUser.cfc b/tests/resources/app/models/CustomTimestampUser.cfc new file mode 100644 index 00000000..19591907 --- /dev/null +++ b/tests/resources/app/models/CustomTimestampUser.cfc @@ -0,0 +1,11 @@ +component extends="quick.models.BaseEntity" accessors="true" table="users" { + + property name="id"; + property name="createdDate" column="created_date"; + property name="modifiedDate" column="modified_date"; + + public array function timestampFields() { + return [ "createdDate" ]; + } + +} diff --git a/tests/specs/integration/BaseEntity/SaveSpec.cfc b/tests/specs/integration/BaseEntity/SaveSpec.cfc index 00e5982a..b2b91d5b 100644 --- a/tests/specs/integration/BaseEntity/SaveSpec.cfc +++ b/tests/specs/integration/BaseEntity/SaveSpec.cfc @@ -79,12 +79,29 @@ component extends="tests.resources.ModuleIntegrationSpec" { it( "can touch an entity timestamp", function() { var user = getInstance( "User" ).findOrFail( 1 ); var originalModified = user.getModifiedDate(); + var originalFirstName = user.getFirstName(); + + user.setFirstName( "This must not be persisted" ); user.touch(); expect( dateCompare( user.getModifiedDate(), originalModified ) ).toBe( 1 ); + expect( user.getFirstName() ).toBe( originalFirstName ); expect( user.isDirty( "modifiedDate" ) ).toBeFalse(); - expect( dateCompare( user.fresh().getModifiedDate(), originalModified ) ).toBe( 1 ); + var freshUser = user.fresh(); + expect( dateCompare( freshUser.getModifiedDate(), originalModified ) ).toBe( 1 ); + expect( freshUser.getFirstName() ).toBe( originalFirstName ); + } ); + + it( "can override the timestamp fields used by touch", function() { + var user = getInstance( "CustomTimestampUser" ).findOrFail( 1 ); + var originalCreated = user.getCreatedDate(); + var originalModified = user.getModifiedDate(); + + user.touch(); + + expect( dateCompare( user.getCreatedDate(), originalCreated ) ).toBe( 1 ); + expect( dateCompare( user.getModifiedDate(), originalModified ) ).toBe( 0 ); } ); it( "does not allow updating of column where update=false in property", function() { From a7e78761e85b9da7a411c04df4c3b6cd8c99f753 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 24 Aug 2026 15:12:42 -0600 Subject: [PATCH 03/10] feat: touch only configured timestamp fields --- models/BaseEntity.cfc | 23 +++++++++++++++---- .../app/models/CustomTimestampUser.cfc | 6 ++++- .../specs/integration/BaseEntity/SaveSpec.cfc | 4 ++-- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index 187d8347..e3787c54 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -439,6 +439,15 @@ component accessors="true" { return arrayWrap( variables._key ); } + /** + * Returns the timestamp fields updated by `touch`. + * + * @return [String] + */ + public array function timestampFields() { + return [ "modifiedDate" ]; + } + /** * Returns the column name for the primary key. * @@ -1415,16 +1424,20 @@ component accessors="true" { } /** - * Updates a timestamp attribute to the current time and saves the entity. + * Resets the entity to its clean state, updates its timestamp fields to the + * current time, and saves the entity. * - * @attribute The timestamp attribute to update. Default: `modifiedDate`. - * @options Any options to pass to `queryExecute`. Default: {}. + * @options Any options to pass to `queryExecute`. Default: {}. * * @return quick.models.BaseEntity */ - public any function touch( string attribute = "modifiedDate", struct options = {} ) { + public any function touch( struct options = {} ) { guardAgainstNotLoaded( "This instance is not loaded so it cannot be touched." ); - assignAttribute( arguments.attribute, now() ); + reset(); + var timestamp = now(); + timestampFields().each( function( field ) { + assignAttribute( arguments.field, timestamp ); + } ); return save( arguments.options ); } diff --git a/tests/resources/app/models/CustomTimestampUser.cfc b/tests/resources/app/models/CustomTimestampUser.cfc index 19591907..7c3866d8 100644 --- a/tests/resources/app/models/CustomTimestampUser.cfc +++ b/tests/resources/app/models/CustomTimestampUser.cfc @@ -1,4 +1,8 @@ -component extends="quick.models.BaseEntity" accessors="true" table="users" { +component + extends ="quick.models.BaseEntity" + accessors="true" + table ="users" +{ property name="id"; property name="createdDate" column="created_date"; diff --git a/tests/specs/integration/BaseEntity/SaveSpec.cfc b/tests/specs/integration/BaseEntity/SaveSpec.cfc index b2b91d5b..16af7397 100644 --- a/tests/specs/integration/BaseEntity/SaveSpec.cfc +++ b/tests/specs/integration/BaseEntity/SaveSpec.cfc @@ -77,8 +77,8 @@ component extends="tests.resources.ModuleIntegrationSpec" { } ); it( "can touch an entity timestamp", function() { - var user = getInstance( "User" ).findOrFail( 1 ); - var originalModified = user.getModifiedDate(); + var user = getInstance( "User" ).findOrFail( 1 ); + var originalModified = user.getModifiedDate(); var originalFirstName = user.getFirstName(); user.setFirstName( "This must not be persisted" ); From 6fc5437b189253a5a92db264fbd98d76647cb8a0 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 11:07:13 -0600 Subject: [PATCH 04/10] test: preserve dirty state when touching --- tests/specs/integration/BaseEntity/SaveSpec.cfc | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/specs/integration/BaseEntity/SaveSpec.cfc b/tests/specs/integration/BaseEntity/SaveSpec.cfc index 16af7397..9fd29e7c 100644 --- a/tests/specs/integration/BaseEntity/SaveSpec.cfc +++ b/tests/specs/integration/BaseEntity/SaveSpec.cfc @@ -80,13 +80,15 @@ component extends="tests.resources.ModuleIntegrationSpec" { var user = getInstance( "User" ).findOrFail( 1 ); var originalModified = user.getModifiedDate(); var originalFirstName = user.getFirstName(); + var dirtyFirstName = "This must not be persisted"; - user.setFirstName( "This must not be persisted" ); + user.setFirstName( dirtyFirstName ); user.touch(); expect( dateCompare( user.getModifiedDate(), originalModified ) ).toBe( 1 ); - expect( user.getFirstName() ).toBe( originalFirstName ); + expect( user.getFirstName() ).toBe( dirtyFirstName ); + expect( user.isDirty( "firstName" ) ).toBeTrue(); expect( user.isDirty( "modifiedDate" ) ).toBeFalse(); var freshUser = user.fresh(); expect( dateCompare( freshUser.getModifiedDate(), originalModified ) ).toBe( 1 ); From 4218d9f3aba405474419352cc9665c7a90c5f7eb Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 11:10:14 -0600 Subject: [PATCH 05/10] fix: preserve entity state when touching --- models/BaseEntity.cfc | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index e3787c54..89ea14ae 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -1424,8 +1424,8 @@ component accessors="true" { } /** - * Resets the entity to its clean state, updates its timestamp fields to the - * current time, and saves the entity. + * Saves updated timestamp fields from the entity's clean state, then restores + * any dirty attribute values that existed before the touch. * * @options Any options to pass to `queryExecute`. Default: {}. * @@ -1433,12 +1433,34 @@ component accessors="true" { */ public any function touch( struct options = {} ) { guardAgainstNotLoaded( "This instance is not loaded so it cannot be touched." ); - reset(); - var timestamp = now(); - timestampFields().each( function( field ) { + var currentAttributes = retrieveAttributesData(); + var originalAttributes = duplicate( variables._originalAttributes ); + var fields = timestampFields(); + var timestamp = now(); + + assignAttributesData( originalAttributes ); + fields.each( function( field ) { assignAttribute( arguments.field, timestamp ); } ); - return save( arguments.options ); + + try { + save( arguments.options ); + var touchedAttributes = fields.reduce( function( attributes, field ) { + arguments.attributes[ arguments.field ] = retrieveAttribute( arguments.field ); + return arguments.attributes; + }, {} ); + } catch ( any e ) { + assignAttributesData( currentAttributes ); + assignOriginalAttributes( originalAttributes ); + rethrow; + } + + assignAttributesData( currentAttributes ); + touchedAttributes.each( function( field, value ) { + assignAttribute( arguments.field, arguments.value ); + } ); + + return this; } /** From c088712655cdbe2473342444fb50c982388c1921 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 11:11:06 -0600 Subject: [PATCH 06/10] test: cover default timestamp fields --- tests/specs/integration/BaseEntity/SaveSpec.cfc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/specs/integration/BaseEntity/SaveSpec.cfc b/tests/specs/integration/BaseEntity/SaveSpec.cfc index 9fd29e7c..133cbcef 100644 --- a/tests/specs/integration/BaseEntity/SaveSpec.cfc +++ b/tests/specs/integration/BaseEntity/SaveSpec.cfc @@ -78,6 +78,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { it( "can touch an entity timestamp", function() { var user = getInstance( "User" ).findOrFail( 1 ); + var originalCreated = user.getCreatedDate(); var originalModified = user.getModifiedDate(); var originalFirstName = user.getFirstName(); var dirtyFirstName = "This must not be persisted"; @@ -86,6 +87,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { user.touch(); + expect( dateCompare( user.getCreatedDate(), originalCreated ) ).toBe( 1 ); expect( dateCompare( user.getModifiedDate(), originalModified ) ).toBe( 1 ); expect( user.getFirstName() ).toBe( dirtyFirstName ); expect( user.isDirty( "firstName" ) ).toBeTrue(); From d5fcf4787d2c155717dc51cd53eb92c2b9cabb38 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 11:03:50 -0600 Subject: [PATCH 07/10] Add createdDate to default timestampFields --- models/BaseEntity.cfc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index 89ea14ae..160a8cfc 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -445,7 +445,7 @@ component accessors="true" { * @return [String] */ public array function timestampFields() { - return [ "modifiedDate" ]; + return [ "createdDate", "modifiedDate" ]; } /** From 1a0869d0515178c4f3f10b13aa7610819e0b5c86 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 11:25:33 -0600 Subject: [PATCH 08/10] test: leave touched entity state unchanged --- tests/specs/integration/BaseEntity/SaveSpec.cfc | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/specs/integration/BaseEntity/SaveSpec.cfc b/tests/specs/integration/BaseEntity/SaveSpec.cfc index 133cbcef..ed5a0a6f 100644 --- a/tests/specs/integration/BaseEntity/SaveSpec.cfc +++ b/tests/specs/integration/BaseEntity/SaveSpec.cfc @@ -87,12 +87,14 @@ component extends="tests.resources.ModuleIntegrationSpec" { user.touch(); - expect( dateCompare( user.getCreatedDate(), originalCreated ) ).toBe( 1 ); - expect( dateCompare( user.getModifiedDate(), originalModified ) ).toBe( 1 ); + expect( dateCompare( user.getCreatedDate(), originalCreated ) ).toBe( 0 ); + expect( dateCompare( user.getModifiedDate(), originalModified ) ).toBe( 0 ); expect( user.getFirstName() ).toBe( dirtyFirstName ); expect( user.isDirty( "firstName" ) ).toBeTrue(); + expect( user.isDirty( "createdDate" ) ).toBeFalse(); expect( user.isDirty( "modifiedDate" ) ).toBeFalse(); var freshUser = user.fresh(); + expect( dateCompare( freshUser.getCreatedDate(), originalCreated ) ).toBe( 1 ); expect( dateCompare( freshUser.getModifiedDate(), originalModified ) ).toBe( 1 ); expect( freshUser.getFirstName() ).toBe( originalFirstName ); } ); @@ -104,8 +106,11 @@ component extends="tests.resources.ModuleIntegrationSpec" { user.touch(); - expect( dateCompare( user.getCreatedDate(), originalCreated ) ).toBe( 1 ); + expect( dateCompare( user.getCreatedDate(), originalCreated ) ).toBe( 0 ); expect( dateCompare( user.getModifiedDate(), originalModified ) ).toBe( 0 ); + var freshUser = user.fresh(); + expect( dateCompare( freshUser.getCreatedDate(), originalCreated ) ).toBe( 1 ); + expect( dateCompare( freshUser.getModifiedDate(), originalModified ) ).toBe( 0 ); } ); it( "does not allow updating of column where update=false in property", function() { From d81fc64e4f8f6e35f1e658695e65beeb4533fefe Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 11:27:17 -0600 Subject: [PATCH 09/10] test: touch by the original entity key --- tests/specs/integration/BaseEntity/SaveSpec.cfc | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/specs/integration/BaseEntity/SaveSpec.cfc b/tests/specs/integration/BaseEntity/SaveSpec.cfc index ed5a0a6f..a3b99ef1 100644 --- a/tests/specs/integration/BaseEntity/SaveSpec.cfc +++ b/tests/specs/integration/BaseEntity/SaveSpec.cfc @@ -81,19 +81,24 @@ component extends="tests.resources.ModuleIntegrationSpec" { var originalCreated = user.getCreatedDate(); var originalModified = user.getModifiedDate(); var originalFirstName = user.getFirstName(); + var originalId = user.getId(); var dirtyFirstName = "This must not be persisted"; + var dirtyId = 9999; user.setFirstName( dirtyFirstName ); + user.setId( dirtyId ); user.touch(); expect( dateCompare( user.getCreatedDate(), originalCreated ) ).toBe( 0 ); expect( dateCompare( user.getModifiedDate(), originalModified ) ).toBe( 0 ); expect( user.getFirstName() ).toBe( dirtyFirstName ); + expect( user.getId() ).toBe( dirtyId ); expect( user.isDirty( "firstName" ) ).toBeTrue(); + expect( user.isDirty( "id" ) ).toBeTrue(); expect( user.isDirty( "createdDate" ) ).toBeFalse(); expect( user.isDirty( "modifiedDate" ) ).toBeFalse(); - var freshUser = user.fresh(); + var freshUser = getInstance( "User" ).findOrFail( originalId ); expect( dateCompare( freshUser.getCreatedDate(), originalCreated ) ).toBe( 1 ); expect( dateCompare( freshUser.getModifiedDate(), originalModified ) ).toBe( 1 ); expect( freshUser.getFirstName() ).toBe( originalFirstName ); From 4ee726693085ecdd0ab1b7ff0d9334a324ae5f0d Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 11:30:04 -0600 Subject: [PATCH 10/10] refactor: touch timestamps with a new query --- models/BaseEntity.cfc | 50 +++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index 160a8cfc..8900ec48 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -1424,8 +1424,8 @@ component accessors="true" { } /** - * Saves updated timestamp fields from the entity's clean state, then restores - * any dirty attribute values that existed before the touch. + * Updates the configured timestamp fields using a new query without changing + * the current entity state. * * @options Any options to pass to `queryExecute`. Default: {}. * @@ -1433,32 +1433,30 @@ component accessors="true" { */ public any function touch( struct options = {} ) { guardAgainstNotLoaded( "This instance is not loaded so it cannot be touched." ); - var currentAttributes = retrieveAttributesData(); - var originalAttributes = duplicate( variables._originalAttributes ); - var fields = timestampFields(); - var timestamp = now(); - - assignAttributesData( originalAttributes ); - fields.each( function( field ) { - assignAttribute( arguments.field, timestamp ); - } ); - - try { - save( arguments.options ); - var touchedAttributes = fields.reduce( function( attributes, field ) { - arguments.attributes[ arguments.field ] = retrieveAttribute( arguments.field ); - return arguments.attributes; - }, {} ); - } catch ( any e ) { - assignAttributesData( currentAttributes ); - assignOriginalAttributes( originalAttributes ); - rethrow; - } + guardReadOnly(); + var timestamp = now(); + var timestampAttributes = timestampFields().reduce( function( attributes, field ) { + arguments.attributes[ arguments.field ] = timestamp; + return arguments.attributes; + }, {} ); + guardAgainstReadOnlyAttributes( timestampAttributes ); - assignAttributesData( currentAttributes ); - touchedAttributes.each( function( field, value ) { - assignAttribute( arguments.field, arguments.value ); + var builder = newQuery().where( function( q ) { + keyNames().each( function( keyName ) { + q.where( + arguments.keyName, + variables._originalAttributes[ retrieveColumnForAlias( arguments.keyName ) ] + ); + } ); } ); + builder + .getQB() + .update( + timestampAttributes.map( function( field, value ) { + return builder.generateQueryParamStruct( field, value ); + } ), + arguments.options + ); return this; }