Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ModuleConfig.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ component {
"defaultQueryOptions" : {},
"preventDuplicateJoins" : true,
"preventLazyLoading" : false,
"refreshOnSaveFallback" : true,
"lazyLoadingViolationCallback" : ( entity, relationName ) => {
throw(
type = "QuickLazyLoadingException",
Expand Down
203 changes: 196 additions & 7 deletions models/BaseEntity.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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() ) {
Expand All @@ -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 ],
Expand All @@ -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",
Expand Down Expand Up @@ -1449,7 +1465,14 @@ component accessors="true" {
updateConstraints.where( entityKeyNames[ i ], entityKeyValues[ i ] );
}
builder.getQB().addNestedWhereQuery( updateConstraints );
builder.update( updateAttributes, arguments.options );
configureRefreshOnSaveReturning( builder, refreshOnSaveAttributes );
result = builder.update( updateAttributes, arguments.options );
refreshAttributesOnSave(
result = result,
attributes = refreshOnSaveAttributes,
allowFallback = arguments.refreshOnSaveFallback,
options = arguments.options
);
assignOriginalAttributes( retrieveAttributesData() );
markLoaded();
fireEvent(
Expand Down Expand Up @@ -1484,8 +1507,19 @@ 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 );
refreshAttributesOnSave(
result = result,
attributes = refreshOnSaveAttributes,
allowFallback = arguments.refreshOnSaveFallback,
options = arguments.options
);
assignOriginalAttributes( retrieveAttributesData() );
markLoaded();
fireEvent(
Expand Down Expand Up @@ -1519,6 +1553,157 @@ 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,
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 ( !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.
Expand Down Expand Up @@ -3857,6 +4042,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;
Expand All @@ -3866,6 +4052,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;
}

Expand Down
15 changes: 12 additions & 3 deletions models/KeyTypes/AutoIncrementingKeyType.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) ) );
}

Expand Down
36 changes: 36 additions & 0 deletions tests/resources/app/models/DatabaseGeneratedUser.cfc
Original file line number Diff line number Diff line change
@@ -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();
}

}
19 changes: 19 additions & 0 deletions tests/resources/app/models/UppercaseCast.cfc
Original file line number Diff line number Diff line change
@@ -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 );
}

}
Loading
Loading