From 3390d4f418f0996962e95b662491cd5ab380aafd Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 06:00:22 -0600 Subject: [PATCH 1/4] Refresh database-generated attributes on save Closes #58 --- models/BaseEntity.cfc | 41 ++++++++++++++++++- .../app/models/DatabaseGeneratedUser.cfc | 9 ++++ .../specs/integration/BaseEntity/SaveSpec.cfc | 12 ++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tests/resources/app/models/DatabaseGeneratedUser.cfc diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index d6b3b9e3..c4d18fb6 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -1450,6 +1450,7 @@ component accessors="true" { } builder.getQB().addNestedWhereQuery( updateConstraints ); builder.update( updateAttributes, arguments.options ); + refreshAttributesOnSave(); assignOriginalAttributes( retrieveAttributesData() ); markLoaded(); fireEvent( @@ -1486,8 +1487,9 @@ component accessors="true" { var result = builder.insert( attrs, arguments.options ); retrieveKeyType().postInsert( this, result ); - assignOriginalAttributes( retrieveAttributesData() ); markLoaded(); + refreshAttributesOnSave(); + assignOriginalAttributes( retrieveAttributesData() ); fireEvent( "postInsert", { @@ -1519,6 +1521,39 @@ component accessors="true" { return this; } + /** + * Refreshes attributes whose values are generated or changed by the database + * during persistence. + */ + private void function refreshAttributesOnSave() { + var attributesToRefresh = variables._attributes.filter( function( name, attribute ) { + return attribute.refreshOnSave; + } ); + + if ( attributesToRefresh.isEmpty() ) { + return; + } + + var refreshedEntity = newQuery() + .withoutGlobalScope() + .where( function( q ) { + arrayZipEach( [ keyNames(), keyValues() ], function( keyName, keyValue ) { + q.where( keyName, keyValue ); + } ); + } ) + .first(); + if ( isNull( refreshedEntity ) ) { + return; + } + + var refreshedData = refreshedEntity.retrieveAttributesData( withNulls = true ); + attributesToRefresh.each( function( name, attribute ) { + var value = refreshedData[ attribute.column ]; + variables._data[ attribute.column ] = isNull( value ) ? javacast( "null", "" ) : value; + variables[ attribute.name ] = isNull( value ) ? javacast( "null", "" ) : value; + } ); + } + /** * Deletes the entity from the database. * This function can only be called on loaded entities. @@ -3857,6 +3892,7 @@ component accessors="true" { param attr.sqltype = ""; param attr.insert = true; param attr.update = true; + param attr.refreshOnSave = false; param attr.virtual = false; param attr.exclude = false; param attr.isParentColumn = false; @@ -3866,6 +3902,9 @@ component accessors="true" { if ( !isBoolean( attr.fillable ) ) { attr.fillable = lCase( trim( attr.fillable & "" ) ) == "true"; } + if ( !isBoolean( attr.refreshOnSave ) ) { + attr.refreshOnSave = lCase( trim( attr.refreshOnSave & "" ) ) == "true"; + } return arguments.attr; } diff --git a/tests/resources/app/models/DatabaseGeneratedUser.cfc b/tests/resources/app/models/DatabaseGeneratedUser.cfc new file mode 100644 index 00000000..8dfd2902 --- /dev/null +++ b/tests/resources/app/models/DatabaseGeneratedUser.cfc @@ -0,0 +1,9 @@ +component extends="quick.models.BaseEntity" accessors="true" table="users" { + + property name="id"; + property name="username"; + property name="firstName" column="first_name"; + property name="lastName" column="last_name"; + property name="createdDate" column="created_date" refreshOnSave="true"; + +} diff --git a/tests/specs/integration/BaseEntity/SaveSpec.cfc b/tests/specs/integration/BaseEntity/SaveSpec.cfc index a3b99ef1..5e2eba00 100644 --- a/tests/specs/integration/BaseEntity/SaveSpec.cfc +++ b/tests/specs/integration/BaseEntity/SaveSpec.cfc @@ -56,6 +56,18 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( newUser.retrieveAttributesData() ).toHaveKey( "id" ); } ); + it( "retrieves database-generated attributes marked to refresh on save", function() { + var newUser = getInstance( "DatabaseGeneratedUser" ) + .setUsername( "database-timestamp-user" ) + .setFirstName( "Database" ) + .setLastName( "Timestamp" ) + .save(); + + expect( newUser.getCreatedDate() ).notToBe( "" ); + expect( newUser.getCreatedDate() ).toBeDate(); + expect( newUser.isDirty( "createdDate" ) ).toBeFalse(); + } ); + it( "a saved entity is not dirty", function() { var newUser = getInstance( "User" ); newUser.setUsername( "new_user" ); From 5490b70f310e9752e05bda9bd4ae086a3ca589ae Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 13:45:19 -0600 Subject: [PATCH 2/4] refactor: avoid internal closures --- models/BaseEntity.cfc | 39 +++++++++++-------- .../app/models/DatabaseGeneratedUser.cfc | 15 +++++-- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index c4d18fb6..e51459f4 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -1526,32 +1526,39 @@ component accessors="true" { * during persistence. */ private void function refreshAttributesOnSave() { - var attributesToRefresh = variables._attributes.filter( function( name, attribute ) { - return attribute.refreshOnSave; - } ); + var attributesToRefresh = {}; + for ( var name in variables._attributes ) { + if ( variables._attributes[ name ].refreshOnSave ) { + attributesToRefresh[ name ] = variables._attributes[ name ]; + } + } if ( attributesToRefresh.isEmpty() ) { return; } - var refreshedEntity = newQuery() - .withoutGlobalScope() - .where( function( q ) { - arrayZipEach( [ keyNames(), keyValues() ], function( keyName, keyValue ) { - q.where( keyName, keyValue ); - } ); - } ) - .first(); + var refreshQuery = newQuery().withoutGlobalScope(); + var entityKeys = keyNames(); + var entityValues = keyValues(); + for ( var i = 1; i <= entityKeys.len(); i++ ) { + refreshQuery.where( entityKeys[ i ], entityValues[ i ] ); + } + var refreshedEntity = refreshQuery.first(); if ( isNull( refreshedEntity ) ) { return; } var refreshedData = refreshedEntity.retrieveAttributesData( withNulls = true ); - attributesToRefresh.each( function( name, attribute ) { - var value = refreshedData[ attribute.column ]; - variables._data[ attribute.column ] = isNull( value ) ? javacast( "null", "" ) : value; - variables[ attribute.name ] = isNull( value ) ? javacast( "null", "" ) : value; - } ); + for ( var name in attributesToRefresh ) { + var attribute = attributesToRefresh[ name ]; + if ( isNull( refreshedData[ attribute.column ] ) ) { + variables._data[ attribute.column ] = javacast( "null", "" ); + variables[ attribute.name ] = javacast( "null", "" ); + } else { + variables._data[ attribute.column ] = refreshedData[ attribute.column ]; + variables[ attribute.name ] = refreshedData[ attribute.column ]; + } + } } /** diff --git a/tests/resources/app/models/DatabaseGeneratedUser.cfc b/tests/resources/app/models/DatabaseGeneratedUser.cfc index 8dfd2902..df746bdd 100644 --- a/tests/resources/app/models/DatabaseGeneratedUser.cfc +++ b/tests/resources/app/models/DatabaseGeneratedUser.cfc @@ -1,9 +1,16 @@ -component extends="quick.models.BaseEntity" accessors="true" table="users" { +component + extends ="quick.models.BaseEntity" + accessors="true" + table ="users" +{ property name="id"; property name="username"; - property name="firstName" column="first_name"; - property name="lastName" column="last_name"; - property name="createdDate" column="created_date" refreshOnSave="true"; + property name="firstName" column="first_name"; + property name="lastName" column="last_name"; + property + name ="createdDate" + column ="created_date" + refreshOnSave="true"; } From e5d375b4beda7cb2eef1d3f1f1d427da4f243673 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 26 Aug 2026 14:36:40 -0600 Subject: [PATCH 3/4] Prefer returned values when refreshing after save --- ModuleConfig.cfc | 1 + models/BaseEntity.cfc | 203 +++++++++++++++--- models/KeyTypes/AutoIncrementingKeyType.cfc | 15 +- .../app/models/DatabaseGeneratedUser.cfc | 20 ++ tests/resources/app/models/UppercaseCast.cfc | 19 ++ .../specs/integration/BaseEntity/SaveSpec.cfc | 135 +++++++++++- 6 files changed, 358 insertions(+), 35 deletions(-) create mode 100644 tests/resources/app/models/UppercaseCast.cfc diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index e6cb2407..002b4d78 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -12,6 +12,7 @@ component { "defaultQueryOptions" : {}, "preventDuplicateJoins" : true, "preventLazyLoading" : false, + "refreshOnSaveFallback" : true, "lazyLoadingViolationCallback" : ( entity, relationName ) => { throw( type = "QuickLazyLoadingException", diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index e51459f4..21710d2f 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -196,6 +196,15 @@ component accessors="true" { persistent="false" inject ="box:setting:lazyLoadingViolationCallback@quick"; + /** + * Whether attributes marked `refreshOnSave` may use a follow-up read when + * the database cannot return their values from the write statement. + */ + property + name ="_refreshOnSaveFallback" + persistent="false" + inject ="box:setting:refreshOnSaveFallback@quick"; + /** * A boolean flag representing that events should not be fired. */ @@ -278,6 +287,7 @@ component accessors="true" { variables._withoutFiringEvents = false; variables._nullValueArgumentSentinel = createObject( "java", "java.lang.Object" ).init(); param variables._preventLazyLoading = false; + param variables._refreshOnSaveFallback = true; if ( !variables.keyExists( "_lazyLoadingViolationCallback" ) || isNull( variables._lazyLoadingViolationCallback ) ) { variables._lazyLoadingViolationCallback = ( entity, relationName ) => { throw( @@ -1385,11 +1395,13 @@ component accessors="true" { * If the entity is not loaded, it inserts the data into the database. * Otherwise it updates the database. * - * @options Any options to pass to `queryExecute`. Default: {}. + * @options Any options to pass to `queryExecute`. Default: {}. + * @refreshOnSaveFallback Whether attributes marked `refreshOnSave` may use a follow-up read when + * the database cannot return their values from the write statement. * * @return quick.models.BaseEntity */ - public any function save( struct options = {} ) { + public any function save( struct options = {}, boolean refreshOnSaveFallback = variables._refreshOnSaveFallback ) { if ( hasParentEntity() ) { var parentDefinition = getParentDefinition(); if ( isLoaded() ) { @@ -1401,7 +1413,9 @@ component accessors="true" { var parent = variables._wirebox.getInstance( parentDefinition.meta.fullName ); } - parent.fill( retrieveAttributesData(), true ).save( arguments.options ); + parent + .fill( retrieveAttributesData(), true ) + .save( options = arguments.options, refreshOnSaveFallback = arguments.refreshOnSaveFallback ); assignAttributesData( { "#parentDefinition.key#" : parent.keyValues()[ 1 ], @@ -1418,8 +1432,10 @@ component accessors="true" { } ); mergeAttributesFromCastCache(); - variables._saving = true; - var builder = newQuery(); + variables._saving = true; + var builder = newQuery(); + var refreshOnSaveAttributes = retrieveRefreshOnSaveAttributes(); + var result = {}; if ( variables._loaded ) { fireEvent( "preUpdate", @@ -1449,8 +1465,14 @@ component accessors="true" { updateConstraints.where( entityKeyNames[ i ], entityKeyValues[ i ] ); } builder.getQB().addNestedWhereQuery( updateConstraints ); - builder.update( updateAttributes, arguments.options ); - refreshAttributesOnSave(); + configureRefreshOnSaveReturning( builder, refreshOnSaveAttributes ); + result = builder.update( updateAttributes, arguments.options ); + refreshAttributesOnSave( + result = result, + attributes = refreshOnSaveAttributes, + allowFallback = arguments.refreshOnSaveFallback, + options = arguments.options + ); assignOriginalAttributes( retrieveAttributesData() ); markLoaded(); fireEvent( @@ -1485,11 +1507,21 @@ component accessors="true" { } guardEmptyAttributeData( attrs ); - var result = builder.insert( attrs, arguments.options ); + configureRefreshOnSaveReturning( + builder = builder, + attributes = refreshOnSaveAttributes, + includeKeyColumns = true + ); + result = builder.insert( attrs, arguments.options ); retrieveKeyType().postInsert( this, result ); - markLoaded(); - refreshAttributesOnSave(); + refreshAttributesOnSave( + result = result, + attributes = refreshOnSaveAttributes, + allowFallback = arguments.refreshOnSaveFallback, + options = arguments.options + ); assignOriginalAttributes( retrieveAttributesData() ); + markLoaded(); fireEvent( "postInsert", { @@ -1522,43 +1554,154 @@ component accessors="true" { } /** - * Refreshes attributes whose values are generated or changed by the database - * during persistence. + * Retrieves the attributes whose values should be refreshed after a write. */ - private void function refreshAttributesOnSave() { + private struct function retrieveRefreshOnSaveAttributes() { var attributesToRefresh = {}; - for ( var name in variables._attributes ) { - if ( variables._attributes[ name ].refreshOnSave ) { - attributesToRefresh[ name ] = variables._attributes[ name ]; + for ( var name in retrieveAttributeNames() ) { + var attribute = retrieveAttributeDefinition( name ); + if ( attribute.refreshOnSave ) { + attributesToRefresh[ name ] = attribute; } } + return attributesToRefresh; + } + + /** + * Adds refresh-on-save columns to native RETURNING or OUTPUT clauses when + * the active grammar supports them. Existing returning columns are retained. + */ + private boolean function configureRefreshOnSaveReturning( + required any builder, + required struct attributes, + boolean includeKeyColumns = false + ) { + if ( arguments.attributes.isEmpty() || !grammarSupportsReturning( arguments.builder.getQB().getGrammar() ) ) { + return false; + } + + var returning = []; + for ( var existingReturning in arguments.builder.getQB().getReturning() ) { + returning.append( existingReturning ); + } + + var columns = []; + if ( arguments.includeKeyColumns ) { + columns.append( keyColumns(), true ); + } + for ( var name in arguments.attributes ) { + columns.append( arguments.attributes[ name ].column ); + } + + for ( var column in columns ) { + var alreadyReturning = false; + for ( var returningColumn in returning ) { + if ( + returningColumn.type == "simple" && + compareNoCase( returningColumn.value, column ) == 0 + ) { + alreadyReturning = true; + break; + } + } + if ( !alreadyReturning ) { + returning.append( { "type" : "simple", "value" : column } ); + } + } + + arguments.builder.getQB().setReturning( returning ); + return true; + } + + /** + * Returns whether the concrete qb grammar supports returned rows on inserts + * and updates. + */ + private boolean function grammarSupportsReturning( required any grammar ) { + var resolvedGrammar = arguments.grammar.getResolvedGrammar(); + return isInstanceOf( resolvedGrammar, "qb.models.Grammars.PostgresGrammar" ) || + isInstanceOf( resolvedGrammar, "qb.models.Grammars.SQLiteGrammar" ) || + isInstanceOf( resolvedGrammar, "qb.models.Grammars.SqlServerGrammar" ); + } + + /** + * Refreshes database-generated values from the write result when available, + * falling back to one narrow keyed read when allowed. + */ + private void function refreshAttributesOnSave( + required struct result, + required struct attributes, + required boolean allowFallback, + struct options = {} + ) { + if ( + arguments.attributes.isEmpty() || populateRefreshAttributesFromWrite( + arguments.result, + arguments.attributes + ) + ) { + return; + } - if ( attributesToRefresh.isEmpty() ) { + if ( !arguments.allowFallback ) { return; } var refreshQuery = newQuery().withoutGlobalScope(); var entityKeys = keyNames(); - var entityValues = keyValues(); for ( var i = 1; i <= entityKeys.len(); i++ ) { - refreshQuery.where( entityKeys[ i ], entityValues[ i ] ); + refreshQuery.where( entityKeys[ i ], retrieveAttribute( entityKeys[ i ] ) ); } - var refreshedEntity = refreshQuery.first(); - if ( isNull( refreshedEntity ) ) { + + var refreshColumns = []; + for ( var name in arguments.attributes ) { + refreshColumns.append( arguments.attributes[ name ].column ); + } + var refreshedData = refreshQuery + .getQB() + .select( refreshColumns ) + .first( arguments.options ); + if ( refreshedData.isEmpty() ) { return; } + populateRefreshAttributes( refreshedData, arguments.attributes ); + } - var refreshedData = refreshedEntity.retrieveAttributesData( withNulls = true ); - for ( var name in attributesToRefresh ) { - var attribute = attributesToRefresh[ name ]; - if ( isNull( refreshedData[ attribute.column ] ) ) { - variables._data[ attribute.column ] = javacast( "null", "" ); - variables[ attribute.name ] = javacast( "null", "" ); - } else { - variables._data[ attribute.column ] = refreshedData[ attribute.column ]; - variables[ attribute.name ] = refreshedData[ attribute.column ]; + /** + * Populates refresh-on-save attributes from a returned query row. + */ + private boolean function populateRefreshAttributesFromWrite( required struct result, required struct attributes ) { + if ( + !arguments.result.keyExists( "query" ) || + isNull( arguments.result.query ) || + !isQuery( arguments.result.query ) || + arguments.result.query.recordCount == 0 + ) { + return false; + } + + var refreshedData = {}; + for ( var name in arguments.attributes ) { + var column = arguments.attributes[ name ].column; + if ( !listFindNoCase( arguments.result.query.columnList, column ) ) { + return false; } + refreshedData[ column ] = isNull( arguments.result.query[ column ][ 1 ] ) + ? javacast( "null", "" ) + : arguments.result.query[ column ][ 1 ]; + } + populateRefreshAttributes( refreshedData, arguments.attributes ); + return true; + } + + /** + * Populates refreshed values through Quick's normal hydration and cast path. + */ + private void function populateRefreshAttributes( required struct refreshedData, required struct attributes ) { + for ( var name in arguments.attributes ) { + structDelete( variables._castCache, name ); } + populateAttributes( arguments.refreshedData ); } /** diff --git a/models/KeyTypes/AutoIncrementingKeyType.cfc b/models/KeyTypes/AutoIncrementingKeyType.cfc index ae9ab02c..f2419489 100644 --- a/models/KeyTypes/AutoIncrementingKeyType.cfc +++ b/models/KeyTypes/AutoIncrementingKeyType.cfc @@ -36,9 +36,18 @@ component implements="KeyType" { return; } var keyName = arguments.entity.keyNames()[ 1 ]; - var generatedKey = arguments.result.result.keyExists( keyName ) ? arguments.result.result[ keyName ] : arguments.result.result.keyExists( - "generated_key" - ) ? arguments.result.result[ "generated_key" ] : arguments.result.result[ "generatedKey" ]; + var keyColumn = arguments.entity.keyColumns()[ 1 ]; + var generatedKey = arguments.result.keyExists( "query" ) && + !isNull( arguments.result.query ) && + isQuery( arguments.result.query ) && + arguments.result.query.recordCount > 0 && + listFindNoCase( arguments.result.query.columnList, keyColumn ) + ? arguments.result.query[ keyColumn ][ 1 ] + : arguments.result.result.keyExists( keyName ) + ? arguments.result.result[ keyName ] + : arguments.result.result.keyExists( "generated_key" ) + ? arguments.result.result[ "generated_key" ] + : arguments.result.result[ "generatedKey" ]; arguments.entity.assignAttribute( keyName, int( val( generatedKey ) ) ); } diff --git a/tests/resources/app/models/DatabaseGeneratedUser.cfc b/tests/resources/app/models/DatabaseGeneratedUser.cfc index df746bdd..04c0c4e8 100644 --- a/tests/resources/app/models/DatabaseGeneratedUser.cfc +++ b/tests/resources/app/models/DatabaseGeneratedUser.cfc @@ -11,6 +11,26 @@ component property name ="createdDate" column ="created_date" + update ="false" refreshOnSave="true"; + property + name ="type" + insert ="false" + update ="false" + refreshOnSave="true" + casts ="UppercaseCast"; + + function postLoad( eventData ) { + param request.databaseGeneratedUserPostLoadCount = 0; + request.databaseGeneratedUserPostLoadCount++; + } + + function postInsert( eventData ) { + request.databaseGeneratedUserPostInsertCreatedDate = arguments.eventData.entity.getCreatedDate(); + } + + function postUpdate( eventData ) { + request.databaseGeneratedUserPostUpdateCreatedDate = arguments.eventData.entity.getCreatedDate(); + } } diff --git a/tests/resources/app/models/UppercaseCast.cfc b/tests/resources/app/models/UppercaseCast.cfc new file mode 100644 index 00000000..485b0559 --- /dev/null +++ b/tests/resources/app/models/UppercaseCast.cfc @@ -0,0 +1,19 @@ +component singleton { + + public any function get( + required any entity, + required string key, + any value + ) { + return isNull( arguments.value ) ? javacast( "null", "" ) : uCase( arguments.value ); + } + + public any function set( + required any entity, + required string key, + any value + ) { + return isNull( arguments.value ) ? javacast( "null", "" ) : lCase( arguments.value ); + } + +} diff --git a/tests/specs/integration/BaseEntity/SaveSpec.cfc b/tests/specs/integration/BaseEntity/SaveSpec.cfc index 5e2eba00..ac35a7d8 100644 --- a/tests/specs/integration/BaseEntity/SaveSpec.cfc +++ b/tests/specs/integration/BaseEntity/SaveSpec.cfc @@ -56,16 +56,147 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( newUser.retrieveAttributesData() ).toHaveKey( "id" ); } ); - it( "retrieves database-generated attributes marked to refresh on save", function() { + it( "refreshes database-generated attributes after inserts with one fallback read", function() { + structDelete( request, "saveSpecPreQBExecute" ); + structDelete( request, "databaseGeneratedUserPostLoadCount" ); + structDelete( request, "databaseGeneratedUserPostInsertCreatedDate" ); + var newUser = getInstance( "DatabaseGeneratedUser" ) .setUsername( "database-timestamp-user" ) .setFirstName( "Database" ) .setLastName( "Timestamp" ) - .save(); + .save( { "timeout" : 30 } ); expect( newUser.getCreatedDate() ).notToBe( "" ); expect( newUser.getCreatedDate() ).toBeDate(); + expect( newUser.getType() ).toBe( "LIMITED" ); expect( newUser.isDirty( "createdDate" ) ).toBeFalse(); + expect( request.saveSpecPreQBExecute ).toHaveLength( 2 ); + expect( request.saveSpecPreQBExecute[ 1 ].options.timeout ).toBe( 30 ); + expect( request.saveSpecPreQBExecute[ 2 ].options.timeout ).toBe( 30 ); + expect( request.databaseGeneratedUserPostLoadCount ).toBe( 1 ); + expect( request.databaseGeneratedUserPostInsertCreatedDate ).toBe( newUser.getCreatedDate() ); + } ); + + it( "refreshes database-generated attributes after updates with one fallback read", function() { + var existingUser = getInstance( "DatabaseGeneratedUser" ).findOrFail( 1 ); + var originalCreatedDate = existingUser.getCreatedDate(); + + structDelete( request, "saveSpecPreQBExecute" ); + structDelete( request, "databaseGeneratedUserPostLoadCount" ); + structDelete( request, "databaseGeneratedUserPostUpdateCreatedDate" ); + + existingUser + .setCreatedDate( dateAdd( "d", 1, originalCreatedDate ) ) + .setType( "POISONED" ) + .setFirstName( "Updated" ) + .save(); + + expect( dateCompare( existingUser.getCreatedDate(), originalCreatedDate ) ).toBe( 0 ); + expect( existingUser.getType() ).toBe( "ADMIN" ); + expect( existingUser.isDirty( "createdDate" ) ).toBeFalse(); + expect( request.saveSpecPreQBExecute ).toHaveLength( 2 ); + expect( request.databaseGeneratedUserPostLoadCount ).toBe( 1 ); + expect( request.databaseGeneratedUserPostUpdateCreatedDate ).toBe( existingUser.getCreatedDate() ); + } ); + + it( "can disable the refresh-on-save fallback read for one save", function() { + structDelete( request, "saveSpecPreQBExecute" ); + + var newUser = getInstance( "DatabaseGeneratedUser" ) + .setUsername( "database-timestamp-without-fallback" ) + .setFirstName( "Database" ) + .setLastName( "No Fallback" ) + .save( refreshOnSaveFallback = false ); + + expect( request.saveSpecPreQBExecute ).toHaveLength( 1 ); + expect( newUser.retrieveAttributesData() ).notToHaveKey( "created_date" ); + } ); + + it( "uses the injected global refresh-on-save fallback setting", function() { + structDelete( request, "saveSpecPreQBExecute" ); + var newUser = getInstance( "DatabaseGeneratedUser" ); + expect( newUser.get_refreshOnSaveFallback() ).toBeTrue(); + + newUser + .set_refreshOnSaveFallback( false ) + .setUsername( "database-timestamp-global-without-fallback" ) + .setFirstName( "Database" ) + .setLastName( "Global No Fallback" ) + .save(); + + expect( request.saveSpecPreQBExecute ).toHaveLength( 1 ); + expect( newUser.retrieveAttributesData() ).notToHaveKey( "created_date" ); + } ); + + it( "uses native returning support without replacing existing returning columns", function() { + var entity = getInstance( "DatabaseGeneratedUser" ); + makePublic( entity, "retrieveRefreshOnSaveAttributes" ); + makePublic( entity, "configureRefreshOnSaveReturning" ); + makePublic( entity, "grammarSupportsReturning" ); + + expect( entity.grammarSupportsReturning( getInstance( "PostgresGrammar@qb" ) ) ).toBeTrue(); + expect( entity.grammarSupportsReturning( getInstance( "SQLiteGrammar@qb" ) ) ).toBeTrue(); + expect( entity.grammarSupportsReturning( getInstance( "SqlServerGrammar@qb" ) ) ).toBeTrue(); + expect( entity.grammarSupportsReturning( getInstance( "MySQLGrammar@qb" ) ) ).toBeFalse(); + + var builder = entity.newQuery(); + builder + .getQB() + .setGrammar( getInstance( "PostgresGrammar@qb" ) ) + .returning( "id" ); + entity.configureRefreshOnSaveReturning( + builder = builder, + attributes = entity.retrieveRefreshOnSaveAttributes(), + includeKeyColumns = true + ); + + var returning = builder.getQB().getReturning(); + var returningValues = []; + for ( var returningColumn in returning ) { + returningValues.append( returningColumn.value ); + } + expect( returningValues ).toHaveLength( 3 ); + expect( arrayFindNoCase( returningValues, "id" ) ).toBeGT( 0 ); + expect( arrayFindNoCase( returningValues, "created_date" ) ).toBeGT( 0 ); + expect( arrayFindNoCase( returningValues, "type" ) ).toBeGT( 0 ); + + var returningSql = builder.getQB().insert( values = { "username" : "native-returning" }, toSql = true ); + expect( returningSql ).toInclude( "RETURNING" ); + expect( returningSql ).toInclude( '"id"' ); + expect( returningSql ).toInclude( '"created_date"' ); + expect( returningSql ).toInclude( '"type"' ); + + var updateBuilder = entity.newQuery(); + updateBuilder + .getQB() + .setGrammar( getInstance( "PostgresGrammar@qb" ) ) + .where( "id", 1 ); + entity.configureRefreshOnSaveReturning( + builder = updateBuilder, + attributes = entity.retrieveRefreshOnSaveAttributes() + ); + var updateReturningSql = updateBuilder.update( values = { "first_name" : "Native" }, toSql = true ); + expect( updateReturningSql ).toInclude( "RETURNING" ); + expect( updateReturningSql ).toInclude( '"created_date"' ); + expect( updateReturningSql ).toInclude( '"type"' ); + } ); + + it( "uses a returned key row for auto-incrementing entities", function() { + var returnedKeys = queryNew( "id", "integer" ); + queryAddRow( returnedKeys ); + querySetCell( returnedKeys, "id", 42 ); + var entity = getInstance( "DatabaseGeneratedUser" ); + + getInstance( "AutoIncrementingKeyType@quick" ).postInsert( + entity, + { + "query" : returnedKeys, + "result" : {} + } + ); + + expect( entity.getId() ).toBe( 42 ); } ); it( "a saved entity is not dirty", function() { From 7added1d4a258a9978dc0cc88694c76aaa992d83 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 26 Aug 2026 16:56:30 -0600 Subject: [PATCH 4/4] Use qb returning row capabilities --- box.json | 2 +- models/BaseEntity.cfc | 43 ++++++++++++----- .../resources/InsertOnlyReturningGrammar.cfc | 7 +++ .../specs/integration/BaseEntity/SaveSpec.cfc | 47 ++++++++++++++++--- 4 files changed, 79 insertions(+), 20 deletions(-) create mode 100644 tests/resources/InsertOnlyReturningGrammar.cfc diff --git a/box.json b/box.json index e81e77df..05e6a673 100644 --- a/box.json +++ b/box.json @@ -29,7 +29,7 @@ }, "type":"modules", "dependencies":{ - "qb":"14.0.0-beta.3", + "qb":"14.0.0-beta.5", "str":"^4.0.0", "mementifier":"^3.0.0" }, diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index 21710d2f..67460aa8 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -1465,7 +1465,11 @@ component accessors="true" { updateConstraints.where( entityKeyNames[ i ], entityKeyValues[ i ] ); } builder.getQB().addNestedWhereQuery( updateConstraints ); - configureRefreshOnSaveReturning( builder, refreshOnSaveAttributes ); + configureRefreshOnSaveReturning( + builder = builder, + attributes = refreshOnSaveAttributes, + operation = "update" + ); result = builder.update( updateAttributes, arguments.options ); refreshAttributesOnSave( result = result, @@ -1510,6 +1514,7 @@ component accessors="true" { configureRefreshOnSaveReturning( builder = builder, attributes = refreshOnSaveAttributes, + operation = "insert", includeKeyColumns = true ); result = builder.insert( attrs, arguments.options ); @@ -1574,9 +1579,32 @@ component accessors="true" { private boolean function configureRefreshOnSaveReturning( required any builder, required struct attributes, + required string operation, boolean includeKeyColumns = false ) { - if ( arguments.attributes.isEmpty() || !grammarSupportsReturning( arguments.builder.getQB().getGrammar() ) ) { + if ( arguments.attributes.isEmpty() ) { + return false; + } + + var grammar = arguments.builder + .getQB() + .getGrammar() + .getResolvedGrammar(); + var supportsReturning = false; + switch ( arguments.operation ) { + case "insert": + supportsReturning = grammar.supportsReturningRowsOnInsert(); + break; + case "update": + supportsReturning = grammar.supportsReturningRowsOnUpdate(); + break; + default: + throw( + type = "QuickInvalidRefreshOnSaveOperation", + message = "Invalid refresh-on-save operation [#arguments.operation#]. Expected [insert] or [update]." + ); + } + if ( !supportsReturning ) { return false; } @@ -1613,17 +1641,6 @@ component accessors="true" { return true; } - /** - * Returns whether the concrete qb grammar supports returned rows on inserts - * and updates. - */ - private boolean function grammarSupportsReturning( required any grammar ) { - var resolvedGrammar = arguments.grammar.getResolvedGrammar(); - return isInstanceOf( resolvedGrammar, "qb.models.Grammars.PostgresGrammar" ) || - isInstanceOf( resolvedGrammar, "qb.models.Grammars.SQLiteGrammar" ) || - isInstanceOf( resolvedGrammar, "qb.models.Grammars.SqlServerGrammar" ); - } - /** * Refreshes database-generated values from the write result when available, * falling back to one narrow keyed read when allowed. diff --git a/tests/resources/InsertOnlyReturningGrammar.cfc b/tests/resources/InsertOnlyReturningGrammar.cfc new file mode 100644 index 00000000..4e1ca6cf --- /dev/null +++ b/tests/resources/InsertOnlyReturningGrammar.cfc @@ -0,0 +1,7 @@ +component extends="qb.models.Grammars.BaseGrammar" { + + public boolean function supportsReturningRowsOnInsert() { + return true; + } + +} diff --git a/tests/specs/integration/BaseEntity/SaveSpec.cfc b/tests/specs/integration/BaseEntity/SaveSpec.cfc index ac35a7d8..0074a7f8 100644 --- a/tests/specs/integration/BaseEntity/SaveSpec.cfc +++ b/tests/specs/integration/BaseEntity/SaveSpec.cfc @@ -133,12 +133,15 @@ component extends="tests.resources.ModuleIntegrationSpec" { var entity = getInstance( "DatabaseGeneratedUser" ); makePublic( entity, "retrieveRefreshOnSaveAttributes" ); makePublic( entity, "configureRefreshOnSaveReturning" ); - makePublic( entity, "grammarSupportsReturning" ); - expect( entity.grammarSupportsReturning( getInstance( "PostgresGrammar@qb" ) ) ).toBeTrue(); - expect( entity.grammarSupportsReturning( getInstance( "SQLiteGrammar@qb" ) ) ).toBeTrue(); - expect( entity.grammarSupportsReturning( getInstance( "SqlServerGrammar@qb" ) ) ).toBeTrue(); - expect( entity.grammarSupportsReturning( getInstance( "MySQLGrammar@qb" ) ) ).toBeFalse(); + expect( getInstance( "PostgresGrammar@qb" ).supportsReturningRowsOnInsert() ).toBeTrue(); + expect( getInstance( "PostgresGrammar@qb" ).supportsReturningRowsOnUpdate() ).toBeTrue(); + expect( getInstance( "SQLiteGrammar@qb" ).supportsReturningRowsOnInsert() ).toBeTrue(); + expect( getInstance( "SQLiteGrammar@qb" ).supportsReturningRowsOnUpdate() ).toBeTrue(); + expect( getInstance( "SqlServerGrammar@qb" ).supportsReturningRowsOnInsert() ).toBeTrue(); + expect( getInstance( "SqlServerGrammar@qb" ).supportsReturningRowsOnUpdate() ).toBeTrue(); + expect( getInstance( "MySQLGrammar@qb" ).supportsReturningRowsOnInsert() ).toBeFalse(); + expect( getInstance( "MySQLGrammar@qb" ).supportsReturningRowsOnUpdate() ).toBeFalse(); var builder = entity.newQuery(); builder @@ -148,6 +151,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { entity.configureRefreshOnSaveReturning( builder = builder, attributes = entity.retrieveRefreshOnSaveAttributes(), + operation = "insert", includeKeyColumns = true ); @@ -174,7 +178,8 @@ component extends="tests.resources.ModuleIntegrationSpec" { .where( "id", 1 ); entity.configureRefreshOnSaveReturning( builder = updateBuilder, - attributes = entity.retrieveRefreshOnSaveAttributes() + attributes = entity.retrieveRefreshOnSaveAttributes(), + operation = "update" ); var updateReturningSql = updateBuilder.update( values = { "first_name" : "Native" }, toSql = true ); expect( updateReturningSql ).toInclude( "RETURNING" ); @@ -182,6 +187,36 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( updateReturningSql ).toInclude( '"type"' ); } ); + it( "checks returning-row support for the current write operation", function() { + var entity = getInstance( "DatabaseGeneratedUser" ); + makePublic( entity, "retrieveRefreshOnSaveAttributes" ); + makePublic( entity, "configureRefreshOnSaveReturning" ); + var attributes = entity.retrieveRefreshOnSaveAttributes(); + var grammar = new tests.resources.InsertOnlyReturningGrammar(); + + var insertBuilder = entity.newQuery(); + insertBuilder.getQB().setGrammar( grammar ); + expect( + entity.configureRefreshOnSaveReturning( + builder = insertBuilder, + attributes = attributes, + operation = "insert" + ) + ).toBeTrue(); + expect( insertBuilder.getQB().getReturning() ).notToBeEmpty(); + + var updateBuilder = entity.newQuery(); + updateBuilder.getQB().setGrammar( grammar ); + expect( + entity.configureRefreshOnSaveReturning( + builder = updateBuilder, + attributes = attributes, + operation = "update" + ) + ).toBeFalse(); + expect( updateBuilder.getQB().getReturning() ).toBeEmpty(); + } ); + it( "uses a returned key row for auto-incrementing entities", function() { var returnedKeys = queryNew( "id", "integer" ); queryAddRow( returnedKeys );