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
122 changes: 114 additions & 8 deletions models/QuickBuilder.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -486,14 +486,120 @@ component accessors="true" transientCache="false" {
getEntity().guardReadOnly();
getEntity().guardAgainstReadOnlyAttributes( arguments.attributes );
}
return variables.qb.update(
arguments.attributes.map( function( key, value ) {
return getEntity().generateQueryParamStruct(
column = key,
value = isNull( value ) ? javacast( "null", "" ) : value
);
} )
);
return variables.qb.update( prepareBulkMutationAttributes( arguments.attributes ) );
}

/**
* Inserts rows that do not exist and updates rows matching the target columns.
*
* Like `updateAll`, this is a bulk mutation. It applies Quick attribute metadata
* and read-only guards, but does not hydrate entities or fire per-entity events.
*
* @values The values to insert or the columns selected by the source query.
* @target The columns used to determine whether a row already exists.
* @update The columns or explicit values to update when a row matches.
* @source An optional query builder or callback used as the source rows.
* @deleteUnmatched Whether to delete target rows missing from the source, or a callback constraining those deletes.
* @options Options passed to `queryExecute`.
* @toSql Whether to return SQL instead of executing the query.
* @matchNulls Whether two NULL target values should be considered a match. Supported by MERGE grammars.
* @force If true, skips read-only entity and read-only attribute checks.
*
* @throws QuickReadOnlyException
*
* @return The qb bulk execution result, or SQL when `toSql` is true.
*/
public any function upsert(
required any values,
required any target,
any update,
any source,
any deleteUnmatched = false,
struct options = {},
boolean toSql = false,
boolean matchNulls = false,
boolean force = false
) {
if ( !arguments.force ) {
getEntity().guardReadOnly();
guardBulkMutationAttributes( arguments.values );
if ( structKeyExists( arguments, "update" ) ) {
guardBulkMutationAttributes( arguments.update );
}
}

arguments.values = prepareBulkMutationValues( arguments.values );
if ( structKeyExists( arguments, "update" ) && isStruct( arguments.update ) ) {
arguments.update = prepareBulkMutationAttributes( arguments.update );
}

var qbArguments = duplicate( arguments );
structDelete( qbArguments, "force" );
return variables.qb.upsert( argumentCollection = qbArguments );
}

/**
* Applies Quick query parameter metadata to a bulk mutation attribute struct.
*/
private struct function prepareBulkMutationAttributes( required struct attributes ) {
var preparedAttributes = {};
for ( var key in arguments.attributes ) {
preparedAttributes[ key ] = getEntity().generateQueryParamStruct(
column = key,
value = isNull( arguments.attributes[ key ] ) ? javacast( "null", "" ) : arguments.attributes[ key ]
);
}
return preparedAttributes;
}

/**
* Applies Quick query parameter metadata to literal upsert rows.
*/
private any function prepareBulkMutationValues( required any values ) {
if ( isArray( arguments.values ) ) {
var preparedValues = [];
for ( var value in arguments.values ) {
preparedValues.append( isStruct( value ) ? prepareBulkMutationAttributes( value ) : value );
}
return preparedValues;
}

if (
isStruct( arguments.values ) &&
!structKeyExists( arguments.values, "isBuilder" ) &&
!structKeyExists( arguments.values, "isQuickBuilder" )
) {
return prepareBulkMutationAttributes( arguments.values );
}

return arguments.values;
}

/**
* Guards literal rows or column collections used by a bulk mutation.
*/
private void function guardBulkMutationAttributes( required any attributes ) {
if ( isArray( arguments.attributes ) ) {
for ( var item in arguments.attributes ) {
guardBulkMutationAttributes( item );
}
return;
}

if (
isStruct( arguments.attributes ) &&
!structKeyExists( arguments.attributes, "isBuilder" ) &&
!structKeyExists( arguments.attributes, "isQuickBuilder" )
) {
getEntity().guardAgainstReadOnlyAttributes( arguments.attributes );
return;
}

if ( isSimpleValue( arguments.attributes ) ) {
for ( var attribute in listToArray( arguments.attributes ) ) {
getEntity().guardAgainstReadOnlyAttributes( { "#attribute#" : true } );
}
}
}

/**
Expand Down
19 changes: 16 additions & 3 deletions models/QuickQB.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -340,14 +340,27 @@ component
return super.update( argumentCollection = arguments );
}

/**
* Inserts rows that do not exist and updates rows matching the target columns.
*
* @values The values to insert or the columns selected by the source query.
* @target The columns used to determine whether a row already exists.
* @update The columns or explicit values to update when a row matches.
* @source An optional query builder or callback used as the source rows.
* @deleteUnmatched Whether to delete target rows missing from the source, or a callback constraining those deletes.
* @options Options passed to `queryExecute`.
* @toSql Whether to return SQL instead of executing the query.
* @matchNulls Whether two NULL target values should be considered a match. Supported by MERGE grammars.
*/
public any function upsert(
required any values,
required any target,
any update,
any source,
boolean deleteUnmatched = false,
struct options = {},
boolean toSql = false
any deleteUnmatched = false,
struct options = {},
boolean toSql = false,
boolean matchNulls = false
) {
if (
!isNull( arguments.source ) && isStruct( arguments.source ) && structKeyExists(
Expand Down
86 changes: 86 additions & 0 deletions tests/specs/integration/BaseEntity/QuerySpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,92 @@ component extends="tests.resources.ModuleIntegrationSpec" {

expect( sql ).toInclude( "UPDATE `users` SET `username` = ?" );
} );

it( "can upsert records through the entity query API", function() {
var result = getInstance( "User" ).upsert(
values = [
{
"id" : 1,
"username" : "elpete",
"firstName" : "Updated",
"lastName" : "Peterson"
},
{
"id" : 99,
"username" : "new-user",
"firstName" : "New",
"lastName" : "User"
}
],
target = "id",
update = [ "firstName" ],
matchNulls = false
);

expect( result ).toBeStruct();
expect( arrayFindNoCase( structKeyArray( result ), "query" ) ).toBeGT( 0 );
expect( arrayFindNoCase( structKeyArray( result ), "result" ) ).toBeGT( 0 );
expect( getInstance( "User" ).findOrFail( 1 ).getFirstName() ).toBe( "Updated" );
expect( getInstance( "User" ).findOrFail( 99 ).getFirstName() ).toBe( "New" );
} );

it( "guards read-only entities and attributes when upserting", function() {
expect( function() {
getInstance( "Referral" ).upsert(
values = [ { "id" : 1, "type" : "external" } ],
target = "id",
update = [ "type" ],
toSql = true
);
} ).toThrow( "QuickReadOnlyException" );

expect( function() {
getInstance( "Link" ).upsert(
values = [
{
"link_id" : 1,
"url" : "https://example.com",
"createdDate" : now()
}
],
target = "link_id",
update = [ "url" ],
toSql = true
);
} ).toThrow( "QuickReadOnlyException" );

expect( function() {
getInstance( "Link" ).upsert(
values = [
{
"link_id" : 1,
"url" : "https://example.com"
}
],
target = "link_id",
update = { "createdDate" : now() },
toSql = true
);
} ).toThrow( "QuickReadOnlyException" );
} );

it( "can force an upsert of read-only attributes like updateAll", function() {
var sql = getInstance( "Link" ).upsert(
values = [
{
"link_id" : 1,
"url" : "https://example.com",
"createdDate" : now()
}
],
target = "link_id",
update = { "createdDate" : now() },
toSql = true,
force = true
);

expect( sql ).toInclude( "`created_date`" );
} );
} );
}

Expand Down
Loading