From 9ab059a2696bd657284ed392530ad8b1fb7cb158 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 05:44:24 -0600 Subject: [PATCH 1/6] Support upserts through Quick entity queries Closes #63 --- models/QuickQB.cfc | 19 +++++++++++--- .../integration/BaseEntity/QuerySpec.cfc | 25 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/models/QuickQB.cfc b/models/QuickQB.cfc index 9f1e932b..cf9950fe 100644 --- a/models/QuickQB.cfc +++ b/models/QuickQB.cfc @@ -354,14 +354,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( diff --git a/tests/specs/integration/BaseEntity/QuerySpec.cfc b/tests/specs/integration/BaseEntity/QuerySpec.cfc index 9a11ed3a..3ce3540c 100644 --- a/tests/specs/integration/BaseEntity/QuerySpec.cfc +++ b/tests/specs/integration/BaseEntity/QuerySpec.cfc @@ -51,6 +51,31 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( sql ).toInclude( "UPDATE `users` SET `username` = ?" ); } ); + + it( "can upsert records through the entity query API", function() { + 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( getInstance( "User" ).findOrFail( 1 ).getFirstName() ).toBe( "Updated" ); + expect( getInstance( "User" ).findOrFail( 99 ).getFirstName() ).toBe( "New" ); + } ); } ); } From afee055a5c9a4f14f32c0ce5728832e74cea8d42 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 24 Aug 2026 14:03:51 -0600 Subject: [PATCH 2/6] test: define Quick upsert entity concerns (#63) --- .../integration/BaseEntity/QuerySpec.cfc | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/tests/specs/integration/BaseEntity/QuerySpec.cfc b/tests/specs/integration/BaseEntity/QuerySpec.cfc index 3ce3540c..f6c453aa 100644 --- a/tests/specs/integration/BaseEntity/QuerySpec.cfc +++ b/tests/specs/integration/BaseEntity/QuerySpec.cfc @@ -53,7 +53,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { } ); it( "can upsert records through the entity query API", function() { - getInstance( "User" ).upsert( + var result = getInstance( "User" ).upsert( values = [ { "id" : 1, @@ -73,9 +73,70 @@ component extends="tests.resources.ModuleIntegrationSpec" { matchNulls = false ); + expect( result ).toBeStruct(); + expect( result ).toHaveKey( "query" ); + expect( result ).toHaveKey( "result" ); 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`" ); + } ); } ); } From 4d66012c50d220d030cd09ad71e931ba3c48b8a4 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 24 Aug 2026 14:05:04 -0600 Subject: [PATCH 3/6] feat: handle upserts through Quick (#63) --- models/QuickBuilder.cfc | 112 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 3 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 0b033a59..b9030b79 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -493,14 +493,120 @@ component accessors="true" transientCache="false" { getEntity().guardReadOnly(); getEntity().guardAgainstReadOnlyAttributes( arguments.attributes ); } - var updateAttributes = {}; + 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 ) { - updateAttributes[ key ] = getEntity().generateQueryParamStruct( + preparedAttributes[ key ] = getEntity().generateQueryParamStruct( column = key, value = isNull( arguments.attributes[ key ] ) ? javacast( "null", "" ) : arguments.attributes[ key ] ); } - return variables.qb.update( updateAttributes ); + 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 } ); + } + } } /** From 6a440627c0418d5363f8dde5ab4d4b17da26760a Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 24 Aug 2026 14:11:30 -0600 Subject: [PATCH 4/6] test: use portable upsert result key checks --- tests/specs/integration/BaseEntity/QuerySpec.cfc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/specs/integration/BaseEntity/QuerySpec.cfc b/tests/specs/integration/BaseEntity/QuerySpec.cfc index f6c453aa..4da0c27e 100644 --- a/tests/specs/integration/BaseEntity/QuerySpec.cfc +++ b/tests/specs/integration/BaseEntity/QuerySpec.cfc @@ -74,8 +74,8 @@ component extends="tests.resources.ModuleIntegrationSpec" { ); expect( result ).toBeStruct(); - expect( result ).toHaveKey( "query" ); - expect( result ).toHaveKey( "result" ); + expect( structKeyExists( result, "query" ) ).toBeTrue(); + expect( structKeyExists( result, "result" ) ).toBeTrue(); expect( getInstance( "User" ).findOrFail( 1 ).getFirstName() ).toBe( "Updated" ); expect( getInstance( "User" ).findOrFail( 99 ).getFirstName() ).toBe( "New" ); } ); From f1495968a497ab5e4d652312efd3a02e2aeffad5 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 24 Aug 2026 14:17:28 -0600 Subject: [PATCH 5/6] test: compare upsert result keys without case --- tests/specs/integration/BaseEntity/QuerySpec.cfc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/specs/integration/BaseEntity/QuerySpec.cfc b/tests/specs/integration/BaseEntity/QuerySpec.cfc index 4da0c27e..38a2e938 100644 --- a/tests/specs/integration/BaseEntity/QuerySpec.cfc +++ b/tests/specs/integration/BaseEntity/QuerySpec.cfc @@ -74,8 +74,8 @@ component extends="tests.resources.ModuleIntegrationSpec" { ); expect( result ).toBeStruct(); - expect( structKeyExists( result, "query" ) ).toBeTrue(); - expect( structKeyExists( result, "result" ) ).toBeTrue(); + 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" ); } ); From 411815804c17d0e0b25dd3d4a6cff09768e70355 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 26 Aug 2026 13:58:01 -0600 Subject: [PATCH 6/6] perf: avoid copying bulk upsert arguments --- models/QuickBuilder.cfc | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index b9030b79..23983303 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -540,9 +540,8 @@ component accessors="true" transientCache="false" { arguments.update = prepareBulkMutationAttributes( arguments.update ); } - var qbArguments = duplicate( arguments ); - structDelete( qbArguments, "force" ); - return variables.qb.upsert( argumentCollection = qbArguments ); + structDelete( arguments, "force" ); + return variables.qb.upsert( argumentCollection = arguments ); } /**