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/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 d6b3b9e3..67460aa8 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,7 +1465,18 @@ component accessors="true" { updateConstraints.where( entityKeyNames[ i ], entityKeyValues[ i ] ); } builder.getQB().addNestedWhereQuery( updateConstraints ); - builder.update( updateAttributes, arguments.options ); + configureRefreshOnSaveReturning( + builder = builder, + attributes = refreshOnSaveAttributes, + operation = "update" + ); + result = builder.update( updateAttributes, arguments.options ); + refreshAttributesOnSave( + result = result, + attributes = refreshOnSaveAttributes, + allowFallback = arguments.refreshOnSaveFallback, + options = arguments.options + ); assignOriginalAttributes( retrieveAttributesData() ); markLoaded(); fireEvent( @@ -1484,8 +1511,20 @@ component accessors="true" { } guardEmptyAttributeData( attrs ); - var result = builder.insert( attrs, arguments.options ); + configureRefreshOnSaveReturning( + builder = builder, + attributes = refreshOnSaveAttributes, + operation = "insert", + includeKeyColumns = true + ); + result = builder.insert( attrs, arguments.options ); retrieveKeyType().postInsert( this, result ); + refreshAttributesOnSave( + result = result, + attributes = refreshOnSaveAttributes, + allowFallback = arguments.refreshOnSaveFallback, + options = arguments.options + ); assignOriginalAttributes( retrieveAttributesData() ); markLoaded(); fireEvent( @@ -1519,6 +1558,169 @@ component accessors="true" { return this; } + /** + * Retrieves the attributes whose values should be refreshed after a write. + */ + private struct function retrieveRefreshOnSaveAttributes() { + var attributesToRefresh = {}; + 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, + required string operation, + boolean includeKeyColumns = false + ) { + 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; + } + + 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; + } + + /** + * 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 ( !arguments.allowFallback ) { + return; + } + + var refreshQuery = newQuery().withoutGlobalScope(); + var entityKeys = keyNames(); + for ( var i = 1; i <= entityKeys.len(); i++ ) { + refreshQuery.where( entityKeys[ i ], retrieveAttribute( entityKeys[ i ] ) ); + } + + 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 ); + } + + /** + * 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 ); + } + /** * Deletes the entity from the database. * This function can only be called on loaded entities. @@ -3857,6 +4059,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 +4069,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/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/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/resources/app/models/DatabaseGeneratedUser.cfc b/tests/resources/app/models/DatabaseGeneratedUser.cfc new file mode 100644 index 00000000..04c0c4e8 --- /dev/null +++ b/tests/resources/app/models/DatabaseGeneratedUser.cfc @@ -0,0 +1,36 @@ +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" + 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 a3b99ef1..0074a7f8 100644 --- a/tests/specs/integration/BaseEntity/SaveSpec.cfc +++ b/tests/specs/integration/BaseEntity/SaveSpec.cfc @@ -56,6 +56,184 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( newUser.retrieveAttributesData() ).toHaveKey( "id" ); } ); + 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( { "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" ); + + 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 + .getQB() + .setGrammar( getInstance( "PostgresGrammar@qb" ) ) + .returning( "id" ); + entity.configureRefreshOnSaveReturning( + builder = builder, + attributes = entity.retrieveRefreshOnSaveAttributes(), + operation = "insert", + 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(), + operation = "update" + ); + var updateReturningSql = updateBuilder.update( values = { "first_name" : "Native" }, toSql = true ); + expect( updateReturningSql ).toInclude( "RETURNING" ); + expect( updateReturningSql ).toInclude( '"created_date"' ); + 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 ); + 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() { var newUser = getInstance( "User" ); newUser.setUsername( "new_user" );