From 47468c0724ed2679f59dbcd5335cc7d8be9b0865 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 05:44:24 -0600 Subject: [PATCH 1/5] 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 9f362d19..bc001f57 100644 --- a/models/QuickQB.cfc +++ b/models/QuickQB.cfc @@ -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( 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 1c0549531410283d7e9d0dffc06ae2fe0d6e22e3 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 24 Aug 2026 14:03:51 -0600 Subject: [PATCH 2/5] 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 af58431e050564dd631f2009438aee84d69c2d61 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 24 Aug 2026 14:05:04 -0600 Subject: [PATCH 3/5] feat: handle upserts through Quick (#63) --- models/QuickBuilder.cfc | 118 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 110 insertions(+), 8 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 7d6d8dc9..c16cb8ab 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -486,14 +486,116 @@ 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 ) { + return arguments.attributes.map( function( key, value ) { + return getEntity().generateQueryParamStruct( + column = key, + value = isNull( value ) ? javacast( "null", "" ) : value + ); + } ); + } + + /** + * Applies Quick query parameter metadata to literal upsert rows. + */ + private any function prepareBulkMutationValues( required any values ) { + if ( isArray( arguments.values ) ) { + return arguments.values.map( function( value ) { + return isStruct( arguments.value ) ? prepareBulkMutationAttributes( arguments.value ) : arguments.value; + } ); + } + + 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 fd9cb178eb5eb54e1bcec027614648a319893226 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 24 Aug 2026 14:11:30 -0600 Subject: [PATCH 4/5] 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 9f6e60450ed8f86f8ad202562b7f13f0782b8ed5 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 24 Aug 2026 14:17:28 -0600 Subject: [PATCH 5/5] 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" ); } );