From 413fb58071b6c5acdb11f8615f7a39455be71ff6 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 11 Aug 2026 20:22:34 -0600 Subject: [PATCH 001/119] ci: disable Lucee bleeding-edge checks (#316) --- .github/workflows/cron.yml | 4 +--- .github/workflows/pr.yml | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index b4b7871d..6cd65a6c 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -15,8 +15,6 @@ jobs: experimental: [ false ] fullNull: ["true", "false"] include: - - cfengine: "lucee@be" - experimental: true - cfengine: "adobe@be" experimental: true - cfengine: "boxlang@be" @@ -50,4 +48,4 @@ jobs: env: FULL_NULL: ${{matrix.fullNull}} continue-on-error: ${{ matrix.experimental }} - run: box testbox run \ No newline at end of file + run: box testbox run diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 45804fe8..0b4999a8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -23,8 +23,6 @@ jobs: experimental: [ false ] fullNull: ["true", "false"] include: - - cfengine: "lucee@be" - experimental: true - cfengine: "adobe@be" experimental: true - cfengine: "boxlang@be" @@ -85,4 +83,4 @@ jobs: - name: Commit Format Changes uses: stefanzweifel/git-auto-commit-action@v7.2.0 with: - commit_message: Apply cfformat changes \ No newline at end of file + commit_message: Apply cfformat changes From e4f670da547d2839e8b35237bd7b907c4f7a892c Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 11 Aug 2026 20:28:35 -0600 Subject: [PATCH 002/119] fix(SchemaBuilder): allow timestamps in alter statements (#314) --- models/Schema/Blueprint.cfc | 10 ++++++++-- tests/resources/AbstractSchemaBuilderSpec.cfc | 13 +++++++++++++ tests/specs/Schema/DerbySchemaBuilderSpec.cfc | 7 +++++++ tests/specs/Schema/MySQLSchemaBuilderSpec.cfc | 7 +++++++ tests/specs/Schema/OracleSchemaBuilderSpec.cfc | 7 +++++++ tests/specs/Schema/PostgresSchemaBuilderSpec.cfc | 7 +++++++ tests/specs/Schema/SQLiteSchemaBuilderSpec.cfc | 7 +++++++ tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc | 7 +++++++ 8 files changed, 63 insertions(+), 2 deletions(-) diff --git a/models/Schema/Blueprint.cfc b/models/Schema/Blueprint.cfc index d44d467c..2899cb87 100644 --- a/models/Schema/Blueprint.cfc +++ b/models/Schema/Blueprint.cfc @@ -283,8 +283,14 @@ component accessors="true" { } public Blueprint function timestamps() { - appendColumn( name = "createdDate", type = "timestamp" ).withCurrent(); - appendColumn( name = "modifiedDate", type = "timestamp" ).withCurrent(); + var createdDate = appendColumn( name = "createdDate", type = "timestamp" ).withCurrent(); + var modifiedDate = appendColumn( name = "modifiedDate", type = "timestamp" ).withCurrent(); + + if ( !getCreating() ) { + addColumn( createdDate ); + addColumn( modifiedDate ); + } + return this; } diff --git a/tests/resources/AbstractSchemaBuilderSpec.cfc b/tests/resources/AbstractSchemaBuilderSpec.cfc index 12994edc..b1aa5ac0 100644 --- a/tests/resources/AbstractSchemaBuilderSpec.cfc +++ b/tests/resources/AbstractSchemaBuilderSpec.cfc @@ -1647,6 +1647,19 @@ component extends="testbox.system.BaseSpec" { } ); describe( "adding columns", function() { + it( "can add timestamp columns", function() { + testCase( function( schema ) { + return schema.alter( + "users", + function( table ) { + table.timestamps(); + }, + {}, + false + ); + }, addTimestamps() ); + } ); + it( "can add a new column", function() { testCase( function( schema ) { return schema.alter( diff --git a/tests/specs/Schema/DerbySchemaBuilderSpec.cfc b/tests/specs/Schema/DerbySchemaBuilderSpec.cfc index 91f0672d..b94d9af5 100644 --- a/tests/specs/Schema/DerbySchemaBuilderSpec.cfc +++ b/tests/specs/Schema/DerbySchemaBuilderSpec.cfc @@ -548,6 +548,13 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { ]; } + function addTimestamps() { + return [ + "ALTER TABLE ""users"" ADD COLUMN ""createdDate"" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", + "ALTER TABLE ""users"" ADD COLUMN ""modifiedDate"" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP" + ]; + } + function addMultiple() { return [ "ALTER TABLE ""users"" ADD COLUMN ""tshirt_size"" VARCHAR(255) NOT NULL", diff --git a/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc b/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc index c9b6458a..e3236b6f 100644 --- a/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc @@ -541,6 +541,13 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { return [ "ALTER TABLE `users` ADD `tshirt_size` ENUM('S', 'M', 'L', 'XL', 'XXL') NOT NULL" ]; } + function addTimestamps() { + return [ + "ALTER TABLE `users` ADD `createdDate` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", + "ALTER TABLE `users` ADD `modifiedDate` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP" + ]; + } + function addMultiple() { return [ "ALTER TABLE `users` ADD `tshirt_size` ENUM('S', 'M', 'L', 'XL', 'XXL') NOT NULL", diff --git a/tests/specs/Schema/OracleSchemaBuilderSpec.cfc b/tests/specs/Schema/OracleSchemaBuilderSpec.cfc index 69cdce41..d993e012 100644 --- a/tests/specs/Schema/OracleSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/OracleSchemaBuilderSpec.cfc @@ -605,6 +605,13 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { ]; } + function addTimestamps() { + return [ + "ALTER TABLE ""USERS"" ADD ""CREATEDDATE"" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL", + "ALTER TABLE ""USERS"" ADD ""MODIFIEDDATE"" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL" + ]; + } + function addMultiple() { return [ "ALTER TABLE ""USERS"" ADD ""TSHIRT_SIZE"" VARCHAR2(255) NOT NULL", diff --git a/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc b/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc index 525a7de9..e29daccd 100644 --- a/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc @@ -565,6 +565,13 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { ]; } + function addTimestamps() { + return [ + "ALTER TABLE ""users"" ADD COLUMN ""createdDate"" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", + "ALTER TABLE ""users"" ADD COLUMN ""modifiedDate"" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP" + ]; + } + function addMultiple() { return [ "CREATE TYPE ""tshirt_size"" AS ENUM ('S', 'M', 'L', 'XL', 'XXL')", diff --git a/tests/specs/Schema/SQLiteSchemaBuilderSpec.cfc b/tests/specs/Schema/SQLiteSchemaBuilderSpec.cfc index b343a650..9ba4b9e7 100644 --- a/tests/specs/Schema/SQLiteSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/SQLiteSchemaBuilderSpec.cfc @@ -556,6 +556,13 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { ]; } + function addTimestamps() { + return [ + "ALTER TABLE ""users"" ADD COLUMN ""createdDate"" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP", + "ALTER TABLE ""users"" ADD COLUMN ""modifiedDate"" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP" + ]; + } + function addMultiple() { return [ "ALTER TABLE ""users"" ADD COLUMN ""tshirt_size"" TEXT NOT NULL CHECK (""tshirt_size"" IN ('S', 'M', 'L', 'XL', 'XXL'))", diff --git a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc index f477da46..0ec630ae 100644 --- a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc @@ -535,6 +535,13 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { ]; } + function addTimestamps() { + return [ + "ALTER TABLE [users] ADD [createdDate] DATETIME2 NOT NULL CONSTRAINT [df_users_createdDate] DEFAULT CURRENT_TIMESTAMP", + "ALTER TABLE [users] ADD [modifiedDate] DATETIME2 NOT NULL CONSTRAINT [df_users_modifiedDate] DEFAULT CURRENT_TIMESTAMP" + ]; + } + function addMultiple() { return [ "ALTER TABLE [users] ADD [tshirt_size] NVARCHAR(255) NOT NULL, CONSTRAINT [enum_users_tshirt_size] CHECK ([tshirt_size] IN ('S', 'M', 'L', 'XL', 'XXL'))", From 19a5eb856da5018a4a90fe0a04e50762c4e33567 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 11 Aug 2026 20:39:15 -0600 Subject: [PATCH 003/119] feat(QueryBuilder): add named return formatters (#315) BREAKING CHANGE: Native queryExecute `returntype`, `columnkey`, and `columnKey` options are no longer honored. Use qb return formatters instead. --- ModuleConfig.cfc | 12 +- README.md | 35 ++- models/Query/Formatters/StructFormatter.cfc | 31 +++ models/Query/QueryBuilder.cfc | 113 +++++--- models/Query/QueryUtils.cfc | 26 ++ models/Query/ReturnFormatterRegistry.cfc | 166 ++++++++++++ .../Query/Abstract/QueryExecutionSpec.cfc | 242 +++++++++++++++++- .../Abstract/ReturnFormatterRegistrySpec.cfc | 117 +++++++++ 8 files changed, 708 insertions(+), 34 deletions(-) create mode 100644 models/Query/Formatters/StructFormatter.cfc create mode 100644 models/Query/ReturnFormatterRegistry.cfc create mode 100644 tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index 47148a83..e8d3c820 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -12,6 +12,7 @@ component { "defaultReturnFormat": "array", "preventDuplicateJoins": false, "validateOperatorsAndCombinators": true, + "validateQueryExecuteReturnType": false, "collectQueryLog": true, "convertEmptyStringsToNull": true, "validateQueryParamStructKeys": true, @@ -29,7 +30,8 @@ component { }, "shouldMaxRowsOverrideToAll": function( maxRows ) { return maxRows <= 0; - } + }, + "returnFormatters": {} }; interceptorSettings = { "customInterceptionPoints": "preQBExecute,postQBExecute" }; @@ -55,13 +57,21 @@ component { .initArg( name = "integerSQLType", value = settings.integerSQLType ) .initArg( name = "decimalSQLType", value = settings.decimalSQLType ); + binder + .map( alias = "ReturnFormatterRegistry@qb", force = true ) + .to( "qb.models.Query.ReturnFormatterRegistry" ) + .initArg( name = "utils", ref = "QueryUtils@qb" ) + .initArg( name = "returnFormatters", value = settings.returnFormatters ); + binder .map( alias = "QueryBuilder@qb", force = true ) .to( "qb.models.Query.QueryBuilder" ) .initArg( name = "grammar", ref = settings.defaultGrammar ) .initArg( name = "utils", ref = "QueryUtils@qb" ) + .initArg( name = "returnFormatterRegistry", ref = "ReturnFormatterRegistry@qb" ) .initArg( name = "preventDuplicateJoins", value = settings.preventDuplicateJoins ) .initArg( name = "validateOperatorsAndCombinators", value = settings.validateOperatorsAndCombinators ) + .initArg( name = "validateQueryExecuteReturnType", value = settings.validateQueryExecuteReturnType ) .initArg( name = "collectQueryLog", value = settings.collectQueryLog ) .initArg( name = "returnFormat", value = settings.defaultReturnFormat ) .initArg( name = "defaultOptions", value = settings.defaultOptions ) diff --git a/README.md b/README.md index 3278a2ff..ef9078f9 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,40 @@ q = queryExecute( qb enables you to explore new ways of organizing your code by letting you pass around a query builder object that will compile down to the right SQL without you having to keep track of the order, whitespace, or other SQL gotchas! +## Return Formatters + +qb includes named return formatters for `array`, `query`, `none`, and `struct`. The `struct` formatter returns a struct of rows keyed by a selected column: + +```cfc +usersByUsername = query + .setReturnFormat( "struct", { "columnKey": "username" } ) + .from( "users" ) + .get(); +``` + +Applications can register reusable custom formatter factories in their qb module settings: + +```cfc +moduleSettings = { + "qb": { + "returnFormatters": { + "ids": function( options ) { + return function( q ) { + return queryColumnData( q, options.column ); + }; + } + } + } +}; + +ids = query + .setReturnFormat( "ids", { "column": "id" } ) + .from( "users" ) + .get(); +``` + +Formatter factories can also be WireBox mapping names or components with a `toFormatter( options )` method. + Here's a gist with an example of the powerful models you can create with this! https://gist.github.com/elpete/80d641b98025f16059f6476561d88202 @@ -118,4 +152,3 @@ For both Lucee and ACF you need to set the JDBC Driver class to `org.sqlite.JDBC ## Full Docs You can browse the full documentation at https://qb.ortusbooks.com - diff --git a/models/Query/Formatters/StructFormatter.cfc b/models/Query/Formatters/StructFormatter.cfc new file mode 100644 index 00000000..f0682294 --- /dev/null +++ b/models/Query/Formatters/StructFormatter.cfc @@ -0,0 +1,31 @@ +component accessors="true" { + + property name="utils"; + property name="options"; + + public StructFormatter function init( any utils = new qb.models.Query.QueryUtils(), struct options = {} ) { + variables.utils = arguments.utils; + variables.options = structCopy( arguments.options ); + return this; + } + + public function toFormatter( struct options = {} ) { + return new qb.models.Query.Formatters.StructFormatter( utils = variables.utils, options = arguments.options ); + } + + public struct function format( required any q ) { + if ( + !variables.options.keyExists( "columnKey" ) || isNull( variables.options.columnKey ) || !len( + variables.options.columnKey + ) + ) { + throw( + type = "MissingColumnKey", + message = "A columnKey option is required for the [struct] return formatter." + ); + } + + return variables.utils.queryToStructOfStructs( arguments.q, variables.options.columnKey ); + } + +} diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index a53bf0f0..69863e62 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -15,14 +15,18 @@ component displayname="QueryBuilder" accessors="true" { property name="utils"; /** - * returnFormat callback - * If provided, the result of the callback is returned as the result of builder. - * Can optionally pass either "array" or "query" - * and the correct callback will be generated + * Resolved return formatter. + * If provided as a callback, its result is returned as the result of the builder. + * Named formatters are resolved through the return formatter registry. * @default "array" */ property name="returnFormat"; + /** + * Registry used to resolve named return formats. + */ + property name="returnFormatterRegistry"; + /** * preventDuplicateJoins * If true, QB will introspect all existing JoinClauses for a match before creating a new join clause. @@ -38,6 +42,13 @@ component displayname="QueryBuilder" accessors="true" { */ property name="validateOperatorsAndCombinators"; + /** + * If true, QB throws when queryExecute returntype options are passed. + * If false, QB strips those options so return formatters always receive a query. + * @default false + */ + property name="validateQueryExecuteReturnType"; + /** * paginationCollector * A component or struct with a `generateWithResults` method. @@ -289,11 +300,15 @@ component displayname="QueryBuilder" accessors="true" { * Default: qb.models.Query.QueryUtils * @returnFormat The closure (or string format shortcut) that modifies the query * and is eventually returned to the caller. Default: 'array' + * @returnFormatterRegistry Registry used to resolve named return formatters. * @preventDuplicateJoins Whether QB should ignore a .join() statement that matches an existing join * Default: false * @validateOperatorsAndCombinators * Whether QB validates operators/combinators before storing clauses. * Default: true + * @validateQueryExecuteReturnType + * Whether QB throws when queryExecute returntype options are passed. + * Default: false * @paginationCollector The closure that processes the pagination result. * Default: cbpaginator.models.Pagination * @columnFormatter The closure that modifies each column before being @@ -315,8 +330,10 @@ component displayname="QueryBuilder" accessors="true" { grammar = new qb.models.Grammars.BaseGrammar(), utils = new qb.models.Query.QueryUtils(), returnFormat = "array", + returnFormatterRegistry, preventDuplicateJoins = false, validateOperatorsAndCombinators = true, + validateQueryExecuteReturnType = false, paginationCollector = new cbpaginator.models.Pagination(), columnFormatter, parentQuery, @@ -330,6 +347,11 @@ component displayname="QueryBuilder" accessors="true" { setPreventDuplicateJoins( arguments.preventDuplicateJoins ); setValidateOperatorsAndCombinators( arguments.validateOperatorsAndCombinators ); + setValidateQueryExecuteReturnType( arguments.validateQueryExecuteReturnType ); + if ( isNull( arguments.returnFormatterRegistry ) ) { + arguments.returnFormatterRegistry = new qb.models.Query.ReturnFormatterRegistry( arguments.utils ); + } + setReturnFormatterRegistry( arguments.returnFormatterRegistry ); if ( isNull( arguments.columnFormatter ) ) { arguments.columnFormatter = function( column ) { return column; @@ -4311,18 +4333,35 @@ component displayname="QueryBuilder" accessors="true" { } if ( isQuery( q ) ) { - return returnFormat( q ); + return applyReturnFormat( q ); } if ( isArray( q ) ) { - return returnFormat( q ); + return applyReturnFormat( q ); } if ( !q.keyExists( "result" ) || !q.keyExists( "query" ) ) { - return returnFormat( q ); + return applyReturnFormat( q ); } - return { result: q.result, query: returnFormat( q.query ) }; + return { result: q.result, query: applyReturnFormat( q.query ) }; + } + + private any function applyReturnFormat( required any q ) { + var formatter = getReturnFormat(); + + if ( isClosure( formatter ) || isCustomFunction( formatter ) ) { + return formatter( arguments.q ); + } + + if ( structKeyExists( formatter, "format" ) ) { + return formatter.format( arguments.q ); + } + + throw( + type = "InvalidFormat", + message = "The configured return formatter must be a closure or a component with a format method." + ); } /** @@ -4337,6 +4376,7 @@ component displayname="QueryBuilder" accessors="true" { */ private any function runQuery( required string sql, struct options = {}, string returnObject = "query" ) { structAppend( arguments.options, getDefaultOptions(), false ); + guardAgainstReturnTypeOption( arguments.options ); var bindings = getBindings( except = getAggregate().isEmpty() ? [] : [ "select" ] ); var result = grammar.runQuery( @@ -4389,10 +4429,13 @@ component displayname="QueryBuilder" accessors="true" { grammar = getGrammar(), utils = getUtils(), returnFormat = getReturnFormat(), + returnFormatterRegistry = getReturnFormatterRegistry(), paginationCollector = isNull( variables.paginationCollector ) ? javacast( "null", "" ) : variables.paginationCollector, columnFormatter = isNull( getColumnFormatter() ) ? javacast( "null", "" ) : getColumnFormatter(), parentQuery = isNull( getParentQuery() ) ? javacast( "null", "" ) : getParentQuery(), - defaultOptions = getDefaultOptions() + defaultOptions = getDefaultOptions(), + validateQueryExecuteReturnType = getValidateQueryExecuteReturnType(), + collectQueryLog = getCollectQueryLog() ); } @@ -4508,31 +4551,23 @@ component displayname="QueryBuilder" accessors="true" { /** * Sets the return format for the query. - * The return format can be a simple string like "query" to return queries or "array" to return an array of structs. - * Alternative, the return format can be a closure. The closure is passed the query as the only argument. The result of the closure is returned as the result of the query. + * The format can be a registered formatter name such as "array", "query", "none", or "struct". + * Alternatively, the format can be a closure. The closure receives the query as its only argument, + * and its result is returned as the result of the builder. * - * @format "query", "array", or a closure. + * @format A registered formatter name or closure. + * @options Options passed to named return formatter factories. * * @return qb.models.Query.QueryBuilder */ - public QueryBuilder function setReturnFormat( required any format ) { - structDelete( variables.defaultOptions, "returntype" ); + public QueryBuilder function setReturnFormat( required any format, struct options = {} ) { if ( isClosure( arguments.format ) || isCustomFunction( arguments.format ) ) { variables.returnFormat = format; - } else if ( arguments.format == "array" ) { - variables.returnFormat = function( q ) { - return getUtils().queryToArrayOfStructs( q ); - }; - } else if ( arguments.format == "query" ) { - variables.returnFormat = function( q ) { - return q; - }; - } else if ( arguments.format == "none" ) { - variables.returnFormat = function( q ) { - return q; - }; } else { - throw( type = "InvalidFormat", message = "The format passed to Builder is invalid." ); + variables.returnFormat = getReturnFormatterRegistry().getReturnFormatter( + arguments.format, + arguments.options + ); } return this; @@ -4556,19 +4591,37 @@ component displayname="QueryBuilder" accessors="true" { /** * Runs the code inside the callback with the return format specified and then sets the return format back to its original value. * - * @returnFormat "query", "array", or a closure. + * @returnFormat A registered formatter name or closure. * @callback The code to execute with the given return format. + * @options Options passed to named return formatter factories. * * @return any */ - public any function withReturnFormat( required any returnFormat, required any callback ) { + public any function withReturnFormat( required any returnFormat, required any callback, struct options = {} ) { var originalReturnFormat = getReturnFormat(); - setReturnFormat( arguments.returnFormat ); + setReturnFormat( arguments.returnFormat, arguments.options ); var result = callback(); setReturnFormat( originalReturnFormat ); return result; } + private void function guardAgainstReturnTypeOption( required struct options ) { + if ( !arguments.options.keyExists( "returntype" ) ) { + return; + } + + if ( getValidateQueryExecuteReturnType() ) { + throw( + type = "InvalidQueryExecuteOption", + message = "The queryExecute returntype option cannot be used with qb return formatters." + ); + } + + structDelete( arguments.options, "returntype" ); + structDelete( arguments.options, "columnkey" ); + structDelete( arguments.options, "columnKey" ); + } + /** * Runs the code inside the callback with the given columns selected and then sets the columns back to its original value. * diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 7c480d7d..5b0b13ef 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -378,6 +378,32 @@ component singleton displayname="QueryUtils" accessors="true" { return results; } + /** + * Converts a query object to a struct of structs keyed by the provided column. + * + * @q The query to convert. + * @columnKey The query column to use as the key for the returned struct. + * + * @return struct + */ + public struct function queryToStructOfStructs( required any q, required string columnKey ) { + var rows = queryToArrayOfStructs( arguments.q ); + var results = {}; + + for ( var row in rows ) { + if ( !row.keyExists( arguments.columnKey ) ) { + throw( + type = "MissingColumnKey", + message = "The columnKey [#arguments.columnKey#] was not found in the query results." + ); + } + + results[ row[ arguments.columnKey ] ] = row; + } + + return results; + } + /** * Remove a list of columns from a specified query. * diff --git a/models/Query/ReturnFormatterRegistry.cfc b/models/Query/ReturnFormatterRegistry.cfc new file mode 100644 index 00000000..b76f8397 --- /dev/null +++ b/models/Query/ReturnFormatterRegistry.cfc @@ -0,0 +1,166 @@ +component accessors="true" singleton { + + property name="utils"; + property name="wirebox" inject="wirebox"; + + public ReturnFormatterRegistry function init( + any utils = new qb.models.Query.QueryUtils(), + struct returnFormatters = {} + ) { + variables.utils = arguments.utils; + variables.wirebox = javacast( "null", "" ); + variables.returnFormatters = {}; + + registerBuiltInReturnFormatters(); + registerReturnFormatters( arguments.returnFormatters ); + + return this; + } + + public ReturnFormatterRegistry function registerReturnFormatters( required struct returnFormatters ) { + for ( var name in arguments.returnFormatters ) { + var definition = normalizeFormatterDefinition( arguments.returnFormatters[ name ] ); + registerReturnFormatter( + name = name, + factory = definition.factory, + options = definition.options, + properties = definition.properties, + force = definition.force + ); + } + + return this; + } + + public ReturnFormatterRegistry function registerReturnFormatter( + required string name, + required any factory, + struct options = {}, + struct properties = {}, + boolean force = false + ) { + if ( hasReturnFormatter( arguments.name ) && !arguments.force ) { + throw( + type = "DuplicateReturnFormatter", + message = "A return formatter named [#arguments.name#] has already been registered. Pass force = true to replace it." + ); + } + + variables.returnFormatters[ arguments.name ] = { + "factory": arguments.factory, + "options": arguments.options, + "properties": arguments.properties + }; + + return this; + } + + public function getReturnFormatter( required string name, struct options = {} ) { + if ( !hasReturnFormatter( arguments.name ) ) { + throw( + type = "UnknownReturnFormatter", + message = "No return formatter named [#arguments.name#] has been registered." + ); + } + + var definition = variables.returnFormatters[ arguments.name ]; + var formatterOptions = {}; + structAppend( formatterOptions, definition.options, true ); + structAppend( formatterOptions, arguments.options, true ); + + var factory = resolveFactory( definition.factory, definition.properties ); + + if ( isClosure( factory ) || isCustomFunction( factory ) ) { + return factory( formatterOptions ); + } + + return factory.toFormatter( formatterOptions ); + } + + public boolean function hasReturnFormatter( required string name ) { + return variables.returnFormatters.keyExists( arguments.name ); + } + + private void function registerBuiltInReturnFormatters() { + registerReturnFormatter( + name = "query", + factory = function( options ) { + return function( q ) { + return q; + }; + } + ); + registerReturnFormatter( + name = "none", + factory = function( options ) { + return function( q ) { + return q; + }; + } + ); + registerReturnFormatter( + name = "array", + factory = function( options ) { + return function( q ) { + return variables.utils.queryToArrayOfStructs( q ); + }; + } + ); + registerReturnFormatter( + name = "struct", + factory = new qb.models.Query.Formatters.StructFormatter( variables.utils ) + ); + } + + private struct function normalizeFormatterDefinition( required any definition ) { + if ( isStruct( arguments.definition ) && arguments.definition.keyExists( "factory" ) ) { + param arguments.definition.options = {}; + param arguments.definition.properties = {}; + param arguments.definition.force = false; + + return { + "factory": arguments.definition.factory, + "options": arguments.definition.options, + "properties": arguments.definition.properties, + "force": arguments.definition.force + }; + } + + return { + "factory": arguments.definition, + "options": {}, + "properties": {}, + "force": false + }; + } + + private function resolveFactory( required any factory, struct properties = {} ) { + if ( isClosure( arguments.factory ) || isCustomFunction( arguments.factory ) ) { + return arguments.factory; + } + + if ( isSimpleValue( arguments.factory ) ) { + if ( isNull( variables.wirebox ) ) { + throw( + type = "WireBoxRequired", + message = "A WireBox instance is required to resolve the [#arguments.factory#] return formatter factory." + ); + } + + return variables.wirebox.getInstance( + name = arguments.factory, + initArguments = { "properties": arguments.properties } + ); + } + + if ( !structKeyExists( arguments.factory, "toFormatter" ) ) { + throw( + type = "InvalidReturnFormatter", + message = "Return formatter factories must be a closure, WireBox mapping name, or component with a toFormatter method." + ); + } + + return arguments.factory; + } + +} diff --git a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc index 1980b386..fa1d951b 100644 --- a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc +++ b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc @@ -885,8 +885,9 @@ component extends="testbox.system.BaseSpec" { it( "can return the max date of a table", function() { var builder = getBuilder(); - var expectedMax = now(); - var expectedQuery = queryNew( "aggregate", "timestamp", [ { aggregate: expectedMax } ] ); + var maxDate = now(); + var expectedQuery = queryNew( "aggregate", "timestamp", [ { aggregate: maxDate } ] ); + var expectedMax = expectedQuery.aggregate; builder .$( "runQuery" ) .$args( sql = "SELECT MAX(""login_date"") AS ""aggregate"" FROM ""users""", options = {} ) @@ -1287,6 +1288,243 @@ component extends="testbox.system.BaseSpec" { expect( runQueryLog ).toHaveLength( 1, "runQuery should have been called once" ); expect( runQueryLog[ 1 ].sql ).toBe( "SELECT ""id"" FROM ""users""" ); } ); + + it( "can return a struct of structs", function() { + var builder = getBuilder(); + builder.setReturnFormat( "struct", { "columnKey": "name" } ); + var data = [ { "id": 1, "name": "jane" }, { "id": 2, "name": "john" } ]; + var expectedQuery = queryNew( "id,name", "integer,varchar", data ); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""id"", ""name"" FROM ""users""", options = { "result": "local.result" } ) + .$results( expectedQuery ); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""id"", ""name"" FROM ""users""", options = {} ) + .$results( expectedQuery ); + + var results = builder + .select( [ "id", "name" ] ) + .from( "users" ) + .get(); + + expect( results ).toBe( { "jane": data[ 1 ], "john": data[ 2 ] } ); + var runQueryLog = builder.$callLog().runQuery; + expect( runQueryLog ).toBeArray(); + expect( runQueryLog ).toHaveLength( 1, "runQuery should have been called once" ); + expect( runQueryLog[ 1 ].sql ).toBe( "SELECT ""id"", ""name"" FROM ""users""" ); + } ); + + it( "can return a struct of structs using withReturnFormat", function() { + var builder = getBuilder(); + var data = [ { "id": 1, "name": "jane" }, { "id": 2, "name": "john" } ]; + var expectedQuery = queryNew( "id,name", "integer,varchar", data ); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""id"", ""name"" FROM ""users""", options = { "result": "local.result" } ) + .$results( expectedQuery ); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""id"", ""name"" FROM ""users""", options = {} ) + .$results( expectedQuery ); + + var results = builder.withReturnFormat( + "struct", + function() { + return builder + .select( [ "id", "name" ] ) + .from( "users" ) + .get(); + }, + { "columnKey": "name" } + ); + + expect( results ).toBe( { "jane": data[ 1 ], "john": data[ 2 ] } ); + } ); + + it( "uses the last row when struct return format keys are duplicated", function() { + var builder = getBuilder(); + builder.setReturnFormat( "struct", { "columnKey": "name" } ); + var data = [ { "id": 1, "name": "jane" }, { "id": 2, "name": "jane" } ]; + var expectedQuery = queryNew( "id,name", "integer,varchar", data ); + builder.$( "runQuery", expectedQuery ); + + var results = builder + .select( [ "id", "name" ] ) + .from( "users" ) + .get(); + + expect( results ).toBe( { "jane": data[ 2 ] } ); + } ); + + it( "can use custom registered return formatters", function() { + var registry = new qb.models.Query.ReturnFormatterRegistry(); + registry.registerReturnFormatter( + "firstId", + function( options ) { + return function( q ) { + return options.prefix & q.id[ 1 ]; + }; + }, + { "prefix": "user-" } + ); + var builder = getMockBox() + .createMock( "qb.models.Query.QueryBuilder" ) + .init( + grammar = getMockBox().createMock( "qb.models.Grammars.BaseGrammar" ).init(), + returnFormatterRegistry = registry + ); + builder.setReturnFormat( "firstId", { "prefix": "account-" } ); + var expectedQuery = queryNew( "id", "integer", [ { "id": 1 } ] ); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""id"" FROM ""users""", options = { "result": "local.result" } ) + .$results( expectedQuery ); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""id"" FROM ""users""", options = {} ) + .$results( expectedQuery ); + + var results = builder + .select( "id" ) + .from( "users" ) + .get(); + + expect( results ).toBe( "account-1" ); + } ); + + it( "carries return formatter settings to new queries and clones", function() { + var registry = new qb.models.Query.ReturnFormatterRegistry(); + registry.registerReturnFormatter( "firstId", function( options ) { + return function( q ) { + return q.id[ 1 ]; + }; + } ); + var builder = getMockBox() + .createMock( "qb.models.Query.QueryBuilder" ) + .init( + grammar = getMockBox().createMock( "qb.models.Grammars.BaseGrammar" ).init(), + returnFormatterRegistry = registry, + validateQueryExecuteReturnType = true, + collectQueryLog = false + ); + + var newBuilder = builder.newQuery(); + var clonedBuilder = builder.clone(); + + expect( newBuilder.getReturnFormatterRegistry() ).toBe( registry ); + expect( newBuilder.getValidateQueryExecuteReturnType() ).toBeTrue(); + expect( newBuilder.getCollectQueryLog() ).toBeFalse(); + newBuilder.setReturnFormat( "firstId" ); + + expect( clonedBuilder.getReturnFormatterRegistry() ).toBe( registry ); + expect( clonedBuilder.getValidateQueryExecuteReturnType() ).toBeTrue(); + expect( clonedBuilder.getCollectQueryLog() ).toBeFalse(); + clonedBuilder.setReturnFormat( "firstId" ); + } ); + + it( "creates a default return formatter registry when none is passed", function() { + var builder = getBuilder(); + builder.setReturnFormat( "none" ); + var expectedQuery = queryNew( "id", "integer", [ { "id": 1 } ] ); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""id"" FROM ""users""", options = { "result": "local.result" } ) + .$results( expectedQuery ); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""id"" FROM ""users""", options = {} ) + .$results( expectedQuery ); + + expect( + builder + .select( "id" ) + .from( "users" ) + .get() + ).toBe( expectedQuery ); + } ); + + it( "throws from the struct formatter at runtime if columnKey is missing", function() { + var builder = getBuilder(); + builder.setReturnFormat( "struct" ); + var expectedQuery = queryNew( "id,name", "integer,varchar", [ { "id": 1, "name": "jane" } ] ); + builder.$( "runQuery", expectedQuery ); + + expect( function() { + builder + .select( [ "id", "name" ] ) + .from( "users" ) + .get(); + } ).toThrow( type = "MissingColumnKey" ); + } ); + + it( "throws from the struct formatter at runtime if the columnKey column is missing", function() { + var builder = getBuilder(); + builder.setReturnFormat( "struct", { "columnKey": "name" } ); + var expectedQuery = queryNew( "id", "integer", [ { "id": 1 } ] ); + builder.$( "runQuery", expectedQuery ); + + expect( function() { + builder + .select( "id" ) + .from( "users" ) + .get(); + } ).toThrow( type = "MissingColumnKey" ); + } ); + + it( "can strip native queryExecute returntype options", function() { + var builder = getBuilder(); + builder.setReturnFormat( "query" ); + var expectedQuery = queryNew( "id", "integer", [ { "id": 1 } ] ); + builder + .getGrammar() + .$( "runQuery" ) + .$results( expectedQuery ); + + var results = builder + .select( "id" ) + .from( "users" ) + .get( options = { "returntype": "array", "columnkey": "id", "columnKey": "id" } ); + + expect( results ).toBe( expectedQuery ); + expect( builder.getGrammar().$callLog().runQuery[ 1 ].options ).toBe( {} ); + } ); + + it( "can strip native queryExecute returntype options from default options without mutating them", function() { + var builder = getBuilder(); + builder.mergeDefaultOptions( { "returntype": "array", "columnkey": "id", "columnKey": "id" } ); + builder.setReturnFormat( "query" ); + var expectedQuery = queryNew( "id", "integer", [ { "id": 1 } ] ); + builder + .getGrammar() + .$( "runQuery" ) + .$results( expectedQuery ); + + var results = builder + .select( "id" ) + .from( "users" ) + .get(); + + expect( results ).toBe( expectedQuery ); + expect( builder.getGrammar().$callLog().runQuery[ 1 ].options ).toBe( {} ); + expect( builder.getDefaultOptions() ).toBe( { "returntype": "array", "columnkey": "id", "columnKey": "id" } ); + } ); + + it( "can validate native queryExecute returntype options", function() { + var builder = getMockBox() + .createMock( "qb.models.Query.QueryBuilder" ) + .init( + grammar = getMockBox().createMock( "qb.models.Grammars.BaseGrammar" ).init(), + validateQueryExecuteReturnType = true + ); + + expect( function() { + builder + .select( "id" ) + .from( "users" ) + .get( options = { "returntype": "array" } ); + } ).toThrow( type = "InvalidQueryExecuteOption" ); + } ); } ); describe( "compiling the same builder multiple times", function() { diff --git a/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc b/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc new file mode 100644 index 00000000..b9ee69e7 --- /dev/null +++ b/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc @@ -0,0 +1,117 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "ReturnFormatterRegistry", function() { + it( "registers the built-in return formatters", function() { + var registry = new qb.models.Query.ReturnFormatterRegistry(); + + expect( registry.hasReturnFormatter( "array" ) ).toBeTrue(); + expect( registry.hasReturnFormatter( "query" ) ).toBeTrue(); + expect( registry.hasReturnFormatter( "none" ) ).toBeTrue(); + expect( registry.hasReturnFormatter( "struct" ) ).toBeTrue(); + } ); + + it( "registers closure factories", function() { + var registry = new qb.models.Query.ReturnFormatterRegistry(); + registry.registerReturnFormatter( + "ids", + function( options ) { + return function( q ) { + return options.prefix & q.id[ 1 ]; + }; + }, + { "prefix": "user-" } + ); + + var formatter = registry.getReturnFormatter( "ids" ); + var q = queryNew( "id", "integer", [ { "id": 1 } ] ); + + expect( formatter( q ) ).toBe( "user-1" ); + } ); + + it( "merges runtime options over registered options", function() { + var registry = new qb.models.Query.ReturnFormatterRegistry(); + registry.registerReturnFormatter( + "ids", + function( options ) { + return function( q ) { + return options.prefix & q.id[ 1 ]; + }; + }, + { "prefix": "user-" } + ); + + var formatter = registry.getReturnFormatter( "ids", { "prefix": "account-" } ); + var q = queryNew( "id", "integer", [ { "id": 1 } ] ); + + expect( formatter( q ) ).toBe( "account-1" ); + } ); + + it( "throws for duplicate formatter names unless force is true", function() { + var registry = new qb.models.Query.ReturnFormatterRegistry(); + var factory = function( options ) { + return function( q ) { + return q; + }; + }; + registry.registerReturnFormatter( "custom", factory ); + + expect( function() { + registry.registerReturnFormatter( "custom", factory ); + } ).toThrow( type = "DuplicateReturnFormatter" ); + + registry.registerReturnFormatter( + name = "custom", + factory = function( options ) { + return function( q ) { + return "forced"; + }; + }, + force = true + ); + + expect( registry.getReturnFormatter( "custom" )( queryNew( "" ) ) ).toBe( "forced" ); + } ); + + it( "normalizes shorthand return formatter definitions", function() { + var registry = new qb.models.Query.ReturnFormatterRegistry( + returnFormatters = { + "custom": function( options ) { + return function( q ) { + return "custom"; + }; + } + } + ); + + expect( registry.getReturnFormatter( "custom" )( queryNew( "" ) ) ).toBe( "custom" ); + } ); + + it( "resolves WireBox formatter factories with properties", function() { + var registry = new qb.models.Query.ReturnFormatterRegistry(); + registry.setWirebox( { + "getInstance": function( name, initArguments ) { + expect( name ).toBe( "MyFormatter@testing" ); + expect( initArguments ).toBe( { "properties": { "prefix": "user-" } } ); + return { + "toFormatter": function( options ) { + return function( q ) { + return initArguments.properties.prefix & options.suffix; + }; + } + }; + } + } ); + registry.registerReturnFormatter( + name = "wirebox", + factory = "MyFormatter@testing", + properties = { "prefix": "user-" }, + options = { "suffix": "1" } + ); + + expect( registry.getReturnFormatter( "wirebox" )( queryNew( "" ) ) ).toBe( "user-1" ); + } ); + } ); + } + +} From 857f124f43a6877051393c843aa34b56a376a3c0 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 11 Aug 2026 22:29:13 -0600 Subject: [PATCH 004/119] feat(QueryBuilder): add cross-grammar JSON support (#317) --- models/Grammars/BaseGrammar.cfc | 97 +++++++ models/Grammars/MySQLGrammar.cfc | 20 ++ models/Grammars/OracleGrammar.cfc | 16 ++ models/Grammars/PostgresGrammar.cfc | 32 +++ models/Grammars/SQLiteGrammar.cfc | 17 ++ models/Grammars/SqlServerGrammar.cfc | 24 ++ models/Query/QueryBuilder.cfc | 271 +++++++++++++++++- tests/resources/AbstractQueryBuilderSpec.cfc | 99 +++++++ tests/specs/Query/DerbyQueryBuilderSpec.cfc | 32 +++ tests/specs/Query/MySQLQueryBuilderSpec.cfc | 50 ++++ tests/specs/Query/OracleQueryBuilderSpec.cfc | 47 +++ .../specs/Query/PostgresQueryBuilderSpec.cfc | 50 ++++ tests/specs/Query/SQLiteQueryBuilderSpec.cfc | 47 +++ .../specs/Query/SqlServerQueryBuilderSpec.cfc | 47 +++ 14 files changed, 847 insertions(+), 2 deletions(-) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index d61bfa48..55eb13ae 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -446,6 +446,20 @@ component displayname="Grammar" accessors="true" singleton { return trim( "#wrapColumn( where.column )# #uCase( where.operator )# #placeholder#" ); } + private string function whereJsonContains( required QueryBuilder query, required struct where ) { + var predicate = compileJsonContains( where.path.value ); + return where.negate ? "NOT (#predicate#)" : predicate; + } + + private string function whereJsonExists( required QueryBuilder query, required struct where ) { + var predicate = compileJsonExists( where.path.value ); + return where.negate ? "NOT (#predicate#)" : predicate; + } + + private string function whereJsonLength( required QueryBuilder query, required struct where ) { + return "#compileJsonLength( where.path.value )# #uCase( where.operator )# ?"; + } + /** * Compiles a raw where statement. * @@ -1173,6 +1187,13 @@ component displayname="Grammar" accessors="true" singleton { return trim( wrapTable( "(#arguments.column.value.toSQL()#) AS #arguments.column.alias#" ) ); } + if ( arguments.column.type == "jsonPath" ) { + var jsonSql = compileJsonScalar( arguments.column.value ); + return arguments.column.keyExists( "alias" ) + ? jsonSql & " AS " & wrapValue( arguments.column.alias ) + : jsonSql; + } + arguments.column = trim( arguments.column.value ); var alias = ""; if ( arguments.column.findNoCase( " as " ) > 0 ) { @@ -1200,6 +1221,75 @@ component displayname="Grammar" accessors="true" singleton { return arguments.column & " AS " & wrapValue( alias ); } + /** + * Compiles scalar extraction for a JSON path. + */ + public string function compileJsonScalar( required struct jsonPath ) { + throw( type = "UnsupportedOperation", message = "This grammar does not support JSON paths" ); + } + + /** + * Compiles a JSON containment predicate. + */ + public string function compileJsonContains( required struct jsonPath ) { + throw( type = "UnsupportedOperation", message = "This grammar does not support JSON containment" ); + } + + /** + * Compiles a JSON path existence predicate. + */ + public string function compileJsonExists( required struct jsonPath ) { + throw( type = "UnsupportedOperation", message = "This grammar does not support JSON path existence" ); + } + + /** + * Compiles JSON array length extraction. + */ + public string function compileJsonLength( required struct jsonPath ) { + throw( type = "UnsupportedOperation", message = "This grammar does not support JSON array lengths" ); + } + + /** + * Allows grammars to serialize containment bindings where required. + */ + public any function prepareJsonContainsBinding( required any value ) { + if ( !isSimpleValue( arguments.value ) ) { + throw( + type = "UnsupportedOperation", + message = "This grammar only supports scalar JSON containment values" + ); + } + return arguments.value; + } + + /** + * Wraps the relational column portion of a JSON expression. + */ + public string function wrapJsonColumn( required struct jsonPath ) { + return wrapColumn( { type: "simple", value: arguments.jsonPath.column } ); + } + + /** + * Builds a portable SQL/JSON path literal. + */ + public string function buildJsonPath( required array path ) { + var compiledPath = "$"; + for ( var segment in arguments.path ) { + if ( isNumeric( segment ) ) { + compiledPath &= "[#segment#]"; + } else { + var escapedSegment = replace( + segment, + """", + chr( 92 ) & """", + "all" + ); + compiledPath &= ".""#escapedSegment#"""; + } + } + return replace( compiledPath, "'", "''", "all" ); + } + /** * Extracts the alias from a column. Returns the column if no alias is found. * @@ -1210,6 +1300,13 @@ component displayname="Grammar" accessors="true" singleton { public string function extractAlias( required any column ) { if ( arguments.column.type == "raw" ) { arguments.column = trim( arguments.column.value.getSQL() ); + } else if ( arguments.column.type == "jsonPath" ) { + if ( arguments.column.keyExists( "alias" ) ) { + return arguments.column.alias; + } + return arguments.column.value.path.isEmpty() + ? listLast( arguments.column.value.column, "." ) + : arguments.column.value.path.last(); } else { arguments.column = trim( arguments.column.value ); } diff --git a/models/Grammars/MySQLGrammar.cfc b/models/Grammars/MySQLGrammar.cfc index 5f93f04d..04e84956 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -1,5 +1,25 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { + public string function compileJsonScalar( required struct jsonPath ) { + return "JSON_UNQUOTE(JSON_EXTRACT(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#'))"; + } + + public string function compileJsonContains( required struct jsonPath ) { + return "JSON_CONTAINS(#wrapJsonColumn( arguments.jsonPath )#, ?, '#buildJsonPath( arguments.jsonPath.path )#')"; + } + + public string function compileJsonExists( required struct jsonPath ) { + return "IFNULL(JSON_CONTAINS_PATH(#wrapJsonColumn( arguments.jsonPath )#, 'one', '#buildJsonPath( arguments.jsonPath.path )#'), 0)"; + } + + public string function compileJsonLength( required struct jsonPath ) { + return "JSON_LENGTH(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#')"; + } + + public any function prepareJsonContainsBinding( required any value ) { + return serializeJSON( arguments.value ); + } + private string function orderByRandom() { return "RAND()"; } diff --git a/models/Grammars/OracleGrammar.cfc b/models/Grammars/OracleGrammar.cfc index 3401a755..c7d9e9a6 100644 --- a/models/Grammars/OracleGrammar.cfc +++ b/models/Grammars/OracleGrammar.cfc @@ -1,5 +1,21 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { + public string function compileJsonScalar( required struct jsonPath ) { + return "JSON_VALUE(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#')"; + } + + public string function compileJsonContains( required struct jsonPath ) { + return "JSON_EXISTS(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#[*]?(@ == $value)' PASSING ? AS ""value"")"; + } + + public string function compileJsonExists( required struct jsonPath ) { + return "JSON_EXISTS(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#')"; + } + + public string function compileJsonLength( required struct jsonPath ) { + return "JSON_VALUE(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#.size()' RETURNING NUMBER)"; + } + /** * Creates a new Oracle Query Grammar. * diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index 66847823..5e543bcf 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -1,5 +1,37 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { + public string function compileJsonScalar( required struct jsonPath ) { + return compilePostgresJsonTraversal( arguments.jsonPath, true ); + } + + public string function compileJsonContains( required struct jsonPath ) { + return "(#compilePostgresJsonTraversal( arguments.jsonPath, false )#)::jsonb @> ?::jsonb"; + } + + public string function compileJsonExists( required struct jsonPath ) { + return "#compilePostgresJsonTraversal( arguments.jsonPath, false )# IS NOT NULL"; + } + + public string function compileJsonLength( required struct jsonPath ) { + return "JSONB_ARRAY_LENGTH((#compilePostgresJsonTraversal( arguments.jsonPath, false )#)::jsonb)"; + } + + public any function prepareJsonContainsBinding( required any value ) { + return serializeJSON( arguments.value ); + } + + private string function compilePostgresJsonTraversal( required struct jsonPath, boolean scalar = false ) { + var sql = wrapJsonColumn( arguments.jsonPath ); + var scalarExtraction = arguments.scalar; + var pathLength = arguments.jsonPath.path.len(); + arguments.jsonPath.path.each( function( segment, index ) { + var operator = scalarExtraction && index == pathLength ? "->>" : "->"; + var pathSegment = isNumeric( segment ) ? segment : "'" & replace( segment, "'", "''", "all" ) & "'"; + sql &= operator & pathSegment; + } ); + return sql; + } + /** * Creates a new Postgres Query Grammar. * diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index 4b318090..d2a271c3 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -1,5 +1,22 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { + public string function compileJsonScalar( required struct jsonPath ) { + return "JSON_EXTRACT(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#')"; + } + + public string function compileJsonContains( required struct jsonPath ) { + var path = buildJsonPath( arguments.jsonPath.path ); + return "EXISTS (SELECT 1 FROM JSON_EACH(#wrapJsonColumn( arguments.jsonPath )#, '#path#') WHERE ""json_each"".""value"" IS ?)"; + } + + public string function compileJsonExists( required struct jsonPath ) { + return "JSON_TYPE(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#') IS NOT NULL"; + } + + public string function compileJsonLength( required struct jsonPath ) { + return "JSON_ARRAY_LENGTH(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#')"; + } + /** * Creates a new SQLite Query Grammar. * diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index 61d994b6..9cf0749f 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -1,5 +1,29 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { + public string function compileJsonScalar( required struct jsonPath ) { + return "JSON_VALUE(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#')"; + } + + public string function compileJsonContains( required struct jsonPath ) { + return "? IN (SELECT [value] FROM OPENJSON(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#'))"; + } + + public string function compileJsonExists( required struct jsonPath ) { + if ( arguments.jsonPath.path.isEmpty() ) { + return "#wrapJsonColumn( arguments.jsonPath )# IS NOT NULL"; + } + var path = duplicate( arguments.jsonPath.path ); + var key = path.pop(); + var openJson = path.isEmpty() + ? "OPENJSON(#wrapJsonColumn( arguments.jsonPath )#)" + : "OPENJSON(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( path )#')"; + return "'#replace( key, "'", "''", "all" )#' IN (SELECT [key] FROM #openJson#)"; + } + + public string function compileJsonLength( required struct jsonPath ) { + return "(SELECT COUNT(*) FROM OPENJSON(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#'))"; + } + /** * The parameter limit for SQL Server grammar. */ diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 69863e62..28839041 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -482,6 +482,9 @@ component displayname="QueryBuilder" accessors="true" { private struct function mapToColumnType( required any column ) { if ( isSimpleValue( arguments.column ) ) { + if ( find( "->", arguments.column ) ) { + return jsonPath( column = arguments.column ); + } return { "type": "simple", "value": arguments.column }; } else if ( getUtils().isExpression( arguments.column ) ) { return { "type": "raw", "value": arguments.column }; @@ -503,6 +506,72 @@ component displayname="QueryBuilder" accessors="true" { } } + /** + * Creates a grammar-aware JSON scalar path expression. + * + * The explicit form accepts a column and an array of path segments. Arrow + * syntax is accepted as a shortcut and is normalized to the same shape. + * Numeric path segments address JSON array indexes. + * + * @column The JSON column, or an arrow path such as `profile->name`. + * @path The JSON object keys and array indexes to traverse. + * @alias An optional select alias. + * + * @return A typed column definition understood by each grammar. + */ + public struct function jsonPath( required string column, array path = [], string alias ) { + var parsedColumn = trim( arguments.column ); + var parsedAlias = structKeyExists( arguments, "alias" ) ? arguments.alias : ""; + + var aliasMatch = reFindNoCase( + "(.*)(?:\sAS\s)(.*)", + parsedColumn, + 1, + true + ); + if ( aliasMatch.pos.len() >= 3 && aliasMatch.pos[ 1 ] > 0 ) { + parsedAlias = trim( mid( parsedColumn, aliasMatch.pos[ 3 ], aliasMatch.len[ 3 ] ) ); + parsedColumn = trim( mid( parsedColumn, aliasMatch.pos[ 2 ], aliasMatch.len[ 2 ] ) ); + } + + var arrowParts = listToArray( parsedColumn, "->", false, true ); + if ( arrowParts.len() > 1 ) { + if ( !arguments.path.isEmpty() ) { + throw( + type = "QBInvalidJsonPath", + message = "JSON paths cannot combine arrow syntax with an explicit path array." + ); + } + parsedColumn = trim( arrowParts.shift() ); + arguments.path = arrowParts.map( ( segment ) => normalizeJsonPathSegment( segment ) ); + } else { + arguments.path = arguments.path.map( ( segment ) => normalizeJsonPathSegment( segment ) ); + } + + var definition = { + type: "jsonPath", + value: { column: variables.columnFormatter( parsedColumn ), path: arguments.path } + }; + if ( len( parsedAlias ) ) { + definition.alias = parsedAlias; + } + return definition; + } + + /** + * Normalizes a JSON path segment for grammar compilation. + * Numeric segments are converted to numbers so grammars can distinguish + * JSON array indexes from object keys. + * + * @segment The JSON object key or array index to normalize. + * + * @return The trimmed object key or numeric array index. + */ + private any function normalizeJsonPathSegment( required any segment ) { + var normalized = trim( arguments.segment ); + return reFind( "^\d+$", normalized ) ? val( normalized ) : normalized; + } + /** * Adds a sub-select to the query. * @@ -702,6 +771,12 @@ component displayname="QueryBuilder" accessors="true" { "type": "simple", "value": swapAlias( column.value, arguments.oldAlias, arguments.newAlias ) }; + } else if ( column.type == "jsonPath" ) { + variables.columns[ i ].value.column = swapAlias( + column.value.column, + arguments.oldAlias, + arguments.newAlias + ); } else if ( column.type == "builder" ) { column.value.renameAliases( arguments.oldAlias, arguments.newAlias ); } @@ -726,6 +801,12 @@ component displayname="QueryBuilder" accessors="true" { var column = variables.groups[ i ]; if ( column.type == "simple" ) { variables.groups[ i ].value = swapAlias( column.value, arguments.oldAlias, arguments.newAlias ); + } else if ( column.type == "jsonPath" ) { + variables.groups[ i ].value.column = swapAlias( + column.value.column, + arguments.oldAlias, + arguments.newAlias + ); } } } @@ -735,6 +816,12 @@ component displayname="QueryBuilder" accessors="true" { if ( structKeyExists( having, "column" ) ) { if ( having.column.type == "simple" ) { having.column.value = swapAlias( having.column.value, arguments.oldAlias, arguments.newAlias ); + } else if ( having.column.type == "jsonPath" ) { + having.column.value.column = swapAlias( + having.column.value.column, + arguments.oldAlias, + arguments.newAlias + ); } } } @@ -745,6 +832,12 @@ component displayname="QueryBuilder" accessors="true" { if ( order.direction != "raw" ) { if ( order.column.type == "simple" ) { order.column.value = swapAlias( order.column.value, arguments.oldAlias, arguments.newAlias ); + } else if ( order.column.type == "jsonPath" ) { + order.column.value.column = swapAlias( + order.column.value.column, + arguments.oldAlias, + arguments.newAlias + ); } } } @@ -761,9 +854,43 @@ component displayname="QueryBuilder" accessors="true" { arguments.oldAlias, arguments.newAlias ); + } else if ( arguments.where.column.type == "jsonPath" ) { + arguments.where.column.value.column = swapAlias( + arguments.where.column.value.column, + arguments.oldAlias, + arguments.newAlias + ); } } + private void function renameAliasInWhereJsonContains( + required struct where, + required string oldAlias, + required string newAlias + ) { + arguments.where.path.value.column = swapAlias( + arguments.where.path.value.column, + arguments.oldAlias, + arguments.newAlias + ); + } + + private void function renameAliasInWhereJsonExists( + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInWhereJsonContains( argumentCollection = arguments ); + } + + private void function renameAliasInWhereJsonLength( + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInWhereJsonContains( argumentCollection = arguments ); + } + private void function renameAliasInWhereColumn( required struct where, required string oldAlias, @@ -1892,6 +2019,142 @@ component displayname="QueryBuilder" accessors="true" { return this; } + /** + * Adds a JSON containment predicate. + * + * Explicit: `whereJsonContains( "profile", [ "languages" ], "en" )` + * Shortcut: `whereJsonContains( "profile->languages", "en" )` + */ + public QueryBuilder function whereJsonContains( + required string column, + any path = [], + any value, + string combinator = "and", + boolean negate = false + ) { + if ( this.getValidateOperatorsAndCombinators() && isInvalidCombinator( arguments.combinator ) ) { + throw( type = "InvalidSQLType", message = "Illegal combinator" ); + } + if ( isNull( arguments.value ) ) { + arguments.value = arguments.path; + arguments.path = []; + } + variables.wheres.append( { + type: "jsonContains", + path: jsonPath( column = arguments.column, path = arguments.path ), + combinator: arguments.combinator, + negate: arguments.negate + } ); + addBindings( + utils.extractBinding( variables.grammar.prepareJsonContainsBinding( arguments.value ), variables.grammar ), + "where" + ); + return this; + } + + public QueryBuilder function orWhereJsonContains( required string column, any path = [], any value ) { + return whereJsonContains( argumentCollection = arguments, combinator = "or" ); + } + + public QueryBuilder function whereJsonDoesntContain( required string column, any path = [], any value ) { + return whereJsonContains( argumentCollection = arguments, negate = true ); + } + + public QueryBuilder function orWhereJsonDoesntContain( required string column, any path = [], any value ) { + return whereJsonContains( argumentCollection = arguments, combinator = "or", negate = true ); + } + + /** + * Adds a JSON path existence predicate. + * + * Explicit: `whereJsonExists( "profile", [ "name" ] )` + * Shortcut: `whereJsonExists( "profile->name" )` + */ + public QueryBuilder function whereJsonExists( + required string column, + array path = [], + string combinator = "and", + boolean negate = false + ) { + if ( this.getValidateOperatorsAndCombinators() && isInvalidCombinator( arguments.combinator ) ) { + throw( type = "InvalidSQLType", message = "Illegal combinator" ); + } + variables.wheres.append( { + type: "jsonExists", + path: jsonPath( column = arguments.column, path = arguments.path ), + combinator: arguments.combinator, + negate: arguments.negate + } ); + return this; + } + + public QueryBuilder function orWhereJsonExists( required string column, array path = [] ) { + return whereJsonExists( argumentCollection = arguments, combinator = "or" ); + } + + public QueryBuilder function whereJsonDoesntExist( required string column, array path = [] ) { + return whereJsonExists( argumentCollection = arguments, negate = true ); + } + + public QueryBuilder function orWhereJsonDoesntExist( required string column, array path = [] ) { + return whereJsonExists( argumentCollection = arguments, combinator = "or", negate = true ); + } + + /** + * Adds a JSON array length predicate. + * + * Explicit: `whereJsonLength( "profile", [ "languages" ], ">", 1 )` + * Shortcut: `whereJsonLength( "profile->languages", ">", 1 )` + * Explicit equality shortcut: `whereJsonLength( "profile", [ "languages" ], 1 )` + * Arrow equality shortcut: `whereJsonLength( "profile->languages", 1 )` + */ + public QueryBuilder function whereJsonLength( + required string column, + any path = [], + any operator, + any value, + string combinator = "and" + ) { + if ( this.getValidateOperatorsAndCombinators() && isInvalidCombinator( arguments.combinator ) ) { + throw( type = "InvalidSQLType", message = "Illegal combinator" ); + } + if ( isNull( arguments.operator ) ) { + if ( isNull( arguments.value ) ) { + arguments.value = arguments.path; + arguments.path = []; + } + arguments.operator = "="; + } else if ( isNull( arguments.value ) ) { + arguments.value = arguments.operator; + if ( isArray( arguments.path ) ) { + arguments.operator = "="; + } else { + arguments.operator = arguments.path; + arguments.path = []; + } + } + if ( this.getValidateOperatorsAndCombinators() && isInvalidOperator( arguments.operator ) ) { + throw( type = "InvalidSQLType", message = "Illegal operator" ); + } + variables.wheres.append( { + type: "jsonLength", + path: jsonPath( column = arguments.column, path = arguments.path ), + operator: arguments.operator, + combinator: arguments.combinator + } ); + addBindings( utils.extractBinding( arguments.value, variables.grammar ), "where" ); + return this; + } + + public QueryBuilder function orWhereJsonLength( + required string column, + any path = [], + any operator, + any value + ) { + return whereJsonLength( argumentCollection = arguments, combinator = "or" ); + } + /** * Adds a WHERE clause to the query. * Alias for `where`. @@ -4681,7 +4944,7 @@ component displayname="QueryBuilder" accessors="true" { return trim( item ); } ); } catch ( any e ) { - return arguments.listOrArray; + return [ arguments.listOrArray ]; } } @@ -4869,7 +5132,11 @@ component displayname="QueryBuilder" accessors="true" { * @returns The formatted column. */ function applyColumnFormatter( column ) { - return isSimpleValue( column ) ? variables.columnFormatter( column ) : column; + if ( !isSimpleValue( arguments.column ) ) { + return arguments.column; + } + // Arrow paths are normalized later so only the relational column is formatted. + return find( "->", arguments.column ) ? arguments.column : variables.columnFormatter( arguments.column ); } public QueryBuilder function setGrammar( required grammar ) { diff --git a/tests/resources/AbstractQueryBuilderSpec.cfc b/tests/resources/AbstractQueryBuilderSpec.cfc index b7bb6803..d01dc357 100644 --- a/tests/resources/AbstractQueryBuilderSpec.cfc +++ b/tests/resources/AbstractQueryBuilderSpec.cfc @@ -2407,6 +2407,105 @@ component extends="testbox.system.BaseSpec" { } ); } ); + describe( "JSON support", function() { + it( "selects scalar values with explicit and arrow syntax", function() { + testCase( function( builder ) { + return builder + .select( [ + builder.jsonPath( + column = "profile", + path = [ "contacts", 0, "email" ], + alias = "explicitName" + ), + "profile->contacts->0->email AS shortcutName" + ] ) + .from( "users" ); + }, jsonScalarSelect() ); + } ); + + it( "uses scalar values in predicates with explicit and arrow syntax", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .where( builder.jsonPath( column = "profile", path = [ "age" ] ), ">=", 21 ) + .where( "profile->age", "<", 65 ); + }, jsonScalarWhere() ); + } ); + + it( "checks containment with explicit and arrow syntax", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .whereJsonContains( column = "profile", path = [ "languages" ], value = "en" ) + .whereJsonContains( "profile->languages", "en" ); + }, jsonContains() ); + } ); + + it( "checks path existence with explicit and arrow syntax", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .whereJsonExists( column = "profile", path = [ "name" ] ) + .whereJsonExists( "profile->name" ); + }, jsonExists() ); + } ); + + it( "checks array length and orders scalar values with both syntaxes", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .whereJsonLength( + column = "profile", + path = [ "languages" ], + operator = ">", + value = 1 + ) + .whereJsonLength( "profile->languages", ">", 1 ) + .orderBy( builder.jsonPath( "profile", [ "name" ] ) ) + .orderByDesc( "profile->name" ); + }, jsonLengthAndOrder() ); + } ); + + it( "defaults JSON length comparisons to equality with both syntaxes", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .whereJsonLength( column = "profile", path = [ "languages" ], value = 1 ) + .whereJsonLength( "profile->languages", 1 ) + .orWhereJsonLength( column = "profile", path = [ "languages" ], value = 2 ) + .orWhereJsonLength( "profile->languages", 2 ); + }, jsonLengthEqualityShortcut() ); + } ); + + it( "supports compound containment values with both syntaxes", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .whereJsonContains( column = "profile", path = [ "languages" ], value = [ "en", "de" ] ) + .whereJsonContains( "profile->languages", [ "en", "de" ] ); + }, jsonCompoundContains() ); + } ); + + it( "supports JSON boolean and negative convenience methods", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .whereJsonDoesntContain( column = "profile", path = [ "languages" ], value = "en" ) + .orWhereJsonDoesntContain( "profile->languages", "fr" ) + .orWhereJsonContains( column = "profile", path = [ "languages" ], value = "de" ) + .whereJsonDoesntExist( column = "profile", path = [ "nickname" ] ) + .orWhereJsonExists( "profile->name" ) + .orWhereJsonDoesntExist( "profile->timezone" ) + .orWhereJsonLength( + column = "profile", + path = [ "languages" ], + operator = ">", + value = 1 + ); + }, jsonConveniencePredicates() ); + } ); + } ); + describe( "insert statements", function() { it( "can insert a struct of data into a table", function() { testCase( function( builder ) { diff --git a/tests/specs/Query/DerbyQueryBuilderSpec.cfc b/tests/specs/Query/DerbyQueryBuilderSpec.cfc index 94fe3d08..f7bae0ea 100644 --- a/tests/specs/Query/DerbyQueryBuilderSpec.cfc +++ b/tests/specs/Query/DerbyQueryBuilderSpec.cfc @@ -1170,6 +1170,38 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function jsonScalarSelect() { + return { exception: "UnsupportedOperation" }; + } + + function jsonScalarWhere() { + return { exception: "UnsupportedOperation" }; + } + + function jsonContains() { + return { exception: "UnsupportedOperation" }; + } + + function jsonExists() { + return { exception: "UnsupportedOperation" }; + } + + function jsonLengthAndOrder() { + return { exception: "UnsupportedOperation" }; + } + + function jsonLengthEqualityShortcut() { + return { exception: "UnsupportedOperation" }; + } + + function jsonCompoundContains() { + return { exception: "UnsupportedOperation" }; + } + + function jsonConveniencePredicates() { + return { exception: "UnsupportedOperation" }; + } + function aggregateExists() { return { "sql": "SELECT CASE WHEN EXISTS (SELECT * FROM ""users"" WHERE ""id"" = ? OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY) THEN 1 ELSE 0 END AS aggregate", diff --git a/tests/specs/Query/MySQLQueryBuilderSpec.cfc b/tests/specs/Query/MySQLQueryBuilderSpec.cfc index 1dad8d5f..a1a00775 100644 --- a/tests/specs/Query/MySQLQueryBuilderSpec.cfc +++ b/tests/specs/Query/MySQLQueryBuilderSpec.cfc @@ -1172,6 +1172,56 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function jsonScalarSelect() { + return "SELECT JSON_UNQUOTE(JSON_EXTRACT(`profile`, '$.""contacts""[0].""email""')) AS `explicitName`, JSON_UNQUOTE(JSON_EXTRACT(`profile`, '$.""contacts""[0].""email""')) AS `shortcutName` FROM `users`"; + } + + function jsonScalarWhere() { + return { + sql: "SELECT * FROM `users` WHERE JSON_UNQUOTE(JSON_EXTRACT(`profile`, '$.""age""')) >= ? AND JSON_UNQUOTE(JSON_EXTRACT(`profile`, '$.""age""')) < ?", + bindings: [ 21, 65 ] + }; + } + + function jsonContains() { + return { + sql: "SELECT * FROM `users` WHERE JSON_CONTAINS(`profile`, ?, '$.""languages""') AND JSON_CONTAINS(`profile`, ?, '$.""languages""')", + bindings: [ """en""", """en""" ] + }; + } + + function jsonExists() { + return "SELECT * FROM `users` WHERE IFNULL(JSON_CONTAINS_PATH(`profile`, 'one', '$.""name""'), 0) AND IFNULL(JSON_CONTAINS_PATH(`profile`, 'one', '$.""name""'), 0)"; + } + + function jsonLengthAndOrder() { + return { + sql: "SELECT * FROM `users` WHERE JSON_LENGTH(`profile`, '$.""languages""') > ? AND JSON_LENGTH(`profile`, '$.""languages""') > ? ORDER BY JSON_UNQUOTE(JSON_EXTRACT(`profile`, '$.""name""')) ASC, JSON_UNQUOTE(JSON_EXTRACT(`profile`, '$.""name""')) DESC", + bindings: [ 1, 1 ] + }; + } + + function jsonLengthEqualityShortcut() { + return { + sql: "SELECT * FROM `users` WHERE JSON_LENGTH(`profile`, '$.""languages""') = ? AND JSON_LENGTH(`profile`, '$.""languages""') = ? OR JSON_LENGTH(`profile`, '$.""languages""') = ? OR JSON_LENGTH(`profile`, '$.""languages""') = ?", + bindings: [ 1, 1, 2, 2 ] + }; + } + + function jsonCompoundContains() { + return { + sql: "SELECT * FROM `users` WHERE JSON_CONTAINS(`profile`, ?, '$.""languages""') AND JSON_CONTAINS(`profile`, ?, '$.""languages""')", + bindings: [ serializeJSON( [ "en", "de" ] ), serializeJSON( [ "en", "de" ] ) ] + }; + } + + function jsonConveniencePredicates() { + return { + sql: "SELECT * FROM `users` WHERE NOT (JSON_CONTAINS(`profile`, ?, '$.""languages""')) OR NOT (JSON_CONTAINS(`profile`, ?, '$.""languages""')) OR JSON_CONTAINS(`profile`, ?, '$.""languages""') AND NOT (IFNULL(JSON_CONTAINS_PATH(`profile`, 'one', '$.""nickname""'), 0)) OR IFNULL(JSON_CONTAINS_PATH(`profile`, 'one', '$.""name""'), 0) OR NOT (IFNULL(JSON_CONTAINS_PATH(`profile`, 'one', '$.""timezone""'), 0)) OR JSON_LENGTH(`profile`, '$.""languages""') > ?", + bindings: [ """en""", """fr""", """de""", 1 ] + }; + } + function aggregateExists() { return { "sql": "SELECT CASE WHEN EXISTS (SELECT * FROM `users` WHERE `id` = ? LIMIT 1) THEN 1 ELSE 0 END AS aggregate", diff --git a/tests/specs/Query/OracleQueryBuilderSpec.cfc b/tests/specs/Query/OracleQueryBuilderSpec.cfc index dfe27806..59fa912f 100644 --- a/tests/specs/Query/OracleQueryBuilderSpec.cfc +++ b/tests/specs/Query/OracleQueryBuilderSpec.cfc @@ -1188,6 +1188,53 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function jsonScalarSelect() { + return "SELECT JSON_VALUE(""PROFILE"", '$.""contacts""[0].""email""') AS ""EXPLICITNAME"", JSON_VALUE(""PROFILE"", '$.""contacts""[0].""email""') AS ""SHORTCUTNAME"" FROM ""USERS"""; + } + + function jsonScalarWhere() { + return { + sql: "SELECT * FROM ""USERS"" WHERE JSON_VALUE(""PROFILE"", '$.""age""') >= ? AND JSON_VALUE(""PROFILE"", '$.""age""') < ?", + bindings: [ 21, 65 ] + }; + } + + function jsonContains() { + return { + sql: "SELECT * FROM ""USERS"" WHERE JSON_EXISTS(""PROFILE"", '$.""languages""[*]?(@ == $value)' PASSING ? AS ""value"") AND JSON_EXISTS(""PROFILE"", '$.""languages""[*]?(@ == $value)' PASSING ? AS ""value"")", + bindings: [ "en", "en" ] + }; + } + + function jsonExists() { + return "SELECT * FROM ""USERS"" WHERE JSON_EXISTS(""PROFILE"", '$.""name""') AND JSON_EXISTS(""PROFILE"", '$.""name""')"; + } + + function jsonLengthAndOrder() { + return { + sql: "SELECT * FROM ""USERS"" WHERE JSON_VALUE(""PROFILE"", '$.""languages"".size()' RETURNING NUMBER) > ? AND JSON_VALUE(""PROFILE"", '$.""languages"".size()' RETURNING NUMBER) > ? ORDER BY JSON_VALUE(""PROFILE"", '$.""name""') ASC, JSON_VALUE(""PROFILE"", '$.""name""') DESC", + bindings: [ 1, 1 ] + }; + } + + function jsonLengthEqualityShortcut() { + return { + sql: "SELECT * FROM ""USERS"" WHERE JSON_VALUE(""PROFILE"", '$.""languages"".size()' RETURNING NUMBER) = ? AND JSON_VALUE(""PROFILE"", '$.""languages"".size()' RETURNING NUMBER) = ? OR JSON_VALUE(""PROFILE"", '$.""languages"".size()' RETURNING NUMBER) = ? OR JSON_VALUE(""PROFILE"", '$.""languages"".size()' RETURNING NUMBER) = ?", + bindings: [ 1, 1, 2, 2 ] + }; + } + + function jsonCompoundContains() { + return { exception: "UnsupportedOperation" }; + } + + function jsonConveniencePredicates() { + return { + sql: "SELECT * FROM ""USERS"" WHERE NOT (JSON_EXISTS(""PROFILE"", '$.""languages""[*]?(@ == $value)' PASSING ? AS ""value"")) OR NOT (JSON_EXISTS(""PROFILE"", '$.""languages""[*]?(@ == $value)' PASSING ? AS ""value"")) OR JSON_EXISTS(""PROFILE"", '$.""languages""[*]?(@ == $value)' PASSING ? AS ""value"") AND NOT (JSON_EXISTS(""PROFILE"", '$.""nickname""')) OR JSON_EXISTS(""PROFILE"", '$.""name""') OR NOT (JSON_EXISTS(""PROFILE"", '$.""timezone""')) OR JSON_VALUE(""PROFILE"", '$.""languages"".size()' RETURNING NUMBER) > ?", + bindings: [ "en", "fr", "de", 1 ] + }; + } + function aggregateExists() { return { "sql": "SELECT CASE WHEN EXISTS (SELECT * FROM (SELECT results.*, ROWNUM AS ""QB_RN"" FROM (SELECT * FROM ""USERS"" WHERE ""ID"" = ?) results ) WHERE ""QB_RN"" <= 1) THEN 1 ELSE 0 END AS aggregate FROM DUAL", diff --git a/tests/specs/Query/PostgresQueryBuilderSpec.cfc b/tests/specs/Query/PostgresQueryBuilderSpec.cfc index c7240bd8..7e71eecf 100644 --- a/tests/specs/Query/PostgresQueryBuilderSpec.cfc +++ b/tests/specs/Query/PostgresQueryBuilderSpec.cfc @@ -1209,6 +1209,56 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function jsonScalarSelect() { + return "SELECT ""profile""->'contacts'->0->>'email' AS ""explicitName"", ""profile""->'contacts'->0->>'email' AS ""shortcutName"" FROM ""users"""; + } + + function jsonScalarWhere() { + return { + sql: "SELECT * FROM ""users"" WHERE ""profile""->>'age' >= ? AND ""profile""->>'age' < ?", + bindings: [ 21, 65 ] + }; + } + + function jsonContains() { + return { + sql: "SELECT * FROM ""users"" WHERE (""profile""->'languages')::jsonb @> ?::jsonb AND (""profile""->'languages')::jsonb @> ?::jsonb", + bindings: [ """en""", """en""" ] + }; + } + + function jsonExists() { + return "SELECT * FROM ""users"" WHERE ""profile""->'name' IS NOT NULL AND ""profile""->'name' IS NOT NULL"; + } + + function jsonLengthAndOrder() { + return { + sql: "SELECT * FROM ""users"" WHERE JSONB_ARRAY_LENGTH((""profile""->'languages')::jsonb) > ? AND JSONB_ARRAY_LENGTH((""profile""->'languages')::jsonb) > ? ORDER BY ""profile""->>'name' ASC, ""profile""->>'name' DESC", + bindings: [ 1, 1 ] + }; + } + + function jsonLengthEqualityShortcut() { + return { + sql: "SELECT * FROM ""users"" WHERE JSONB_ARRAY_LENGTH((""profile""->'languages')::jsonb) = ? AND JSONB_ARRAY_LENGTH((""profile""->'languages')::jsonb) = ? OR JSONB_ARRAY_LENGTH((""profile""->'languages')::jsonb) = ? OR JSONB_ARRAY_LENGTH((""profile""->'languages')::jsonb) = ?", + bindings: [ 1, 1, 2, 2 ] + }; + } + + function jsonCompoundContains() { + return { + sql: "SELECT * FROM ""users"" WHERE (""profile""->'languages')::jsonb @> ?::jsonb AND (""profile""->'languages')::jsonb @> ?::jsonb", + bindings: [ serializeJSON( [ "en", "de" ] ), serializeJSON( [ "en", "de" ] ) ] + }; + } + + function jsonConveniencePredicates() { + return { + sql: "SELECT * FROM ""users"" WHERE NOT ((""profile""->'languages')::jsonb @> ?::jsonb) OR NOT ((""profile""->'languages')::jsonb @> ?::jsonb) OR (""profile""->'languages')::jsonb @> ?::jsonb AND NOT (""profile""->'nickname' IS NOT NULL) OR ""profile""->'name' IS NOT NULL OR NOT (""profile""->'timezone' IS NOT NULL) OR JSONB_ARRAY_LENGTH((""profile""->'languages')::jsonb) > ?", + bindings: [ """en""", """fr""", """de""", 1 ] + }; + } + function aggregateExists() { return { "sql": "SELECT CASE WHEN EXISTS (SELECT * FROM ""users"" WHERE ""id"" = ? LIMIT 1) THEN 1 ELSE 0 END AS aggregate", diff --git a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc index 9ead9932..b0aed0a4 100644 --- a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc +++ b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc @@ -1209,6 +1209,53 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function jsonScalarSelect() { + return "SELECT JSON_EXTRACT(""profile"", '$.""contacts""[0].""email""') AS ""explicitName"", JSON_EXTRACT(""profile"", '$.""contacts""[0].""email""') AS ""shortcutName"" FROM ""users"""; + } + + function jsonScalarWhere() { + return { + sql: "SELECT * FROM ""users"" WHERE JSON_EXTRACT(""profile"", '$.""age""') >= ? AND JSON_EXTRACT(""profile"", '$.""age""') < ?", + bindings: [ 21, 65 ] + }; + } + + function jsonContains() { + return { + sql: "SELECT * FROM ""users"" WHERE EXISTS (SELECT 1 FROM JSON_EACH(""profile"", '$.""languages""') WHERE ""json_each"".""value"" IS ?) AND EXISTS (SELECT 1 FROM JSON_EACH(""profile"", '$.""languages""') WHERE ""json_each"".""value"" IS ?)", + bindings: [ "en", "en" ] + }; + } + + function jsonExists() { + return "SELECT * FROM ""users"" WHERE JSON_TYPE(""profile"", '$.""name""') IS NOT NULL AND JSON_TYPE(""profile"", '$.""name""') IS NOT NULL"; + } + + function jsonLengthAndOrder() { + return { + sql: "SELECT * FROM ""users"" WHERE JSON_ARRAY_LENGTH(""profile"", '$.""languages""') > ? AND JSON_ARRAY_LENGTH(""profile"", '$.""languages""') > ? ORDER BY JSON_EXTRACT(""profile"", '$.""name""') ASC, JSON_EXTRACT(""profile"", '$.""name""') DESC", + bindings: [ 1, 1 ] + }; + } + + function jsonLengthEqualityShortcut() { + return { + sql: "SELECT * FROM ""users"" WHERE JSON_ARRAY_LENGTH(""profile"", '$.""languages""') = ? AND JSON_ARRAY_LENGTH(""profile"", '$.""languages""') = ? OR JSON_ARRAY_LENGTH(""profile"", '$.""languages""') = ? OR JSON_ARRAY_LENGTH(""profile"", '$.""languages""') = ?", + bindings: [ 1, 1, 2, 2 ] + }; + } + + function jsonCompoundContains() { + return { exception: "UnsupportedOperation" }; + } + + function jsonConveniencePredicates() { + return { + sql: "SELECT * FROM ""users"" WHERE NOT (EXISTS (SELECT 1 FROM JSON_EACH(""profile"", '$.""languages""') WHERE ""json_each"".""value"" IS ?)) OR NOT (EXISTS (SELECT 1 FROM JSON_EACH(""profile"", '$.""languages""') WHERE ""json_each"".""value"" IS ?)) OR EXISTS (SELECT 1 FROM JSON_EACH(""profile"", '$.""languages""') WHERE ""json_each"".""value"" IS ?) AND NOT (JSON_TYPE(""profile"", '$.""nickname""') IS NOT NULL) OR JSON_TYPE(""profile"", '$.""name""') IS NOT NULL OR NOT (JSON_TYPE(""profile"", '$.""timezone""') IS NOT NULL) OR JSON_ARRAY_LENGTH(""profile"", '$.""languages""') > ?", + bindings: [ "en", "fr", "de", 1 ] + }; + } + function aggregateExists() { return { "sql": "SELECT CASE WHEN EXISTS (SELECT * FROM ""users"" WHERE ""id"" = ? LIMIT 1) THEN 1 ELSE 0 END AS aggregate", diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index ddef7734..7dc3ab60 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -1222,6 +1222,53 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return builder; } + function jsonScalarSelect() { + return "SELECT JSON_VALUE([profile], '$.""contacts""[0].""email""') AS [explicitName], JSON_VALUE([profile], '$.""contacts""[0].""email""') AS [shortcutName] FROM [users]"; + } + + function jsonScalarWhere() { + return { + sql: "SELECT * FROM [users] WHERE JSON_VALUE([profile], '$.""age""') >= ? AND JSON_VALUE([profile], '$.""age""') < ?", + bindings: [ 21, 65 ] + }; + } + + function jsonContains() { + return { + sql: "SELECT * FROM [users] WHERE ? IN (SELECT [value] FROM OPENJSON([profile], '$.""languages""')) AND ? IN (SELECT [value] FROM OPENJSON([profile], '$.""languages""'))", + bindings: [ "en", "en" ] + }; + } + + function jsonExists() { + return "SELECT * FROM [users] WHERE 'name' IN (SELECT [key] FROM OPENJSON([profile])) AND 'name' IN (SELECT [key] FROM OPENJSON([profile]))"; + } + + function jsonLengthAndOrder() { + return { + sql: "SELECT * FROM [users] WHERE (SELECT COUNT(*) FROM OPENJSON([profile], '$.""languages""')) > ? AND (SELECT COUNT(*) FROM OPENJSON([profile], '$.""languages""')) > ? ORDER BY JSON_VALUE([profile], '$.""name""') ASC, JSON_VALUE([profile], '$.""name""') DESC", + bindings: [ 1, 1 ] + }; + } + + function jsonLengthEqualityShortcut() { + return { + sql: "SELECT * FROM [users] WHERE (SELECT COUNT(*) FROM OPENJSON([profile], '$.""languages""')) = ? AND (SELECT COUNT(*) FROM OPENJSON([profile], '$.""languages""')) = ? OR (SELECT COUNT(*) FROM OPENJSON([profile], '$.""languages""')) = ? OR (SELECT COUNT(*) FROM OPENJSON([profile], '$.""languages""')) = ?", + bindings: [ 1, 1, 2, 2 ] + }; + } + + function jsonCompoundContains() { + return { exception: "UnsupportedOperation" }; + } + + function jsonConveniencePredicates() { + return { + sql: "SELECT * FROM [users] WHERE NOT (? IN (SELECT [value] FROM OPENJSON([profile], '$.""languages""'))) OR NOT (? IN (SELECT [value] FROM OPENJSON([profile], '$.""languages""'))) OR ? IN (SELECT [value] FROM OPENJSON([profile], '$.""languages""')) AND NOT ('nickname' IN (SELECT [key] FROM OPENJSON([profile]))) OR 'name' IN (SELECT [key] FROM OPENJSON([profile])) OR NOT ('timezone' IN (SELECT [key] FROM OPENJSON([profile]))) OR (SELECT COUNT(*) FROM OPENJSON([profile], '$.""languages""')) > ?", + bindings: [ "en", "fr", "de", 1 ] + }; + } + function aggregateExists() { return { "sql": "SELECT CASE WHEN EXISTS (SELECT TOP (1) * FROM [users] WHERE [id] = ?) THEN 1 ELSE 0 END AS aggregate", From 2d0d8cc846212053c3036cb4eb84e47b81e25f85 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 11 Aug 2026 23:02:25 -0600 Subject: [PATCH 005/119] feat(QueryBuilder): validate duplicate select columns (#318) --- ModuleConfig.cfc | 2 + README.md | 14 ++ models/Query/QueryBuilder.cfc | 120 +++++++++- .../Query/Abstract/BuilderSelectSpec.cfc | 216 +++++++++++++++++- 4 files changed, 342 insertions(+), 10 deletions(-) diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index e8d3c820..91f40df1 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -12,6 +12,7 @@ component { "defaultReturnFormat": "array", "preventDuplicateJoins": false, "validateOperatorsAndCombinators": true, + "validateDuplicateSelectColumns": false, "validateQueryExecuteReturnType": false, "collectQueryLog": true, "convertEmptyStringsToNull": true, @@ -71,6 +72,7 @@ component { .initArg( name = "returnFormatterRegistry", ref = "ReturnFormatterRegistry@qb" ) .initArg( name = "preventDuplicateJoins", value = settings.preventDuplicateJoins ) .initArg( name = "validateOperatorsAndCombinators", value = settings.validateOperatorsAndCombinators ) + .initArg( name = "validateDuplicateSelectColumns", value = settings.validateDuplicateSelectColumns ) .initArg( name = "validateQueryExecuteReturnType", value = settings.validateQueryExecuteReturnType ) .initArg( name = "collectQueryLog", value = settings.collectQueryLog ) .initArg( name = "returnFormat", value = settings.defaultReturnFormat ) diff --git a/README.md b/README.md index ef9078f9..8fc6a1ed 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,20 @@ q = queryExecute( qb enables you to explore new ways of organizing your code by letting you pass around a query builder object that will compile down to the right SQL without you having to keep track of the order, whitespace, or other SQL gotchas! +## Development Validation + +qb can detect statically identifiable duplicate select output names before they are silently collapsed by CFML query results. Enable this validation in development and leave it disabled in production: + +```cfc +moduleSettings = { + "qb": { + "validateDuplicateSelectColumns": true + } +}; +``` + +The validation checks the final selection when the query is compiled, including simple columns, explicit aliases, subselect aliases, and explicitly aliased typed columns. Wildcards and expressions without explicit aliases are skipped because their output names cannot be known until the query executes. + ## Return Formatters qb includes named return formatters for `array`, `query`, `none`, and `struct`. The `struct` formatter returns a struct of rows keyed by a selected column: diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 28839041..4f20de23 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -42,6 +42,13 @@ component displayname="QueryBuilder" accessors="true" { */ property name="validateOperatorsAndCombinators"; + /** + * If true, QB validates that selected columns have unique output names. + * This validation is recommended in development and should be disabled in production. + * @default false + */ + property name="validateDuplicateSelectColumns"; + /** * If true, QB throws when queryExecute returntype options are passed. * If false, QB strips those options so return formatters always receive a query. @@ -306,6 +313,9 @@ component displayname="QueryBuilder" accessors="true" { * @validateOperatorsAndCombinators * Whether QB validates operators/combinators before storing clauses. * Default: true + * @validateDuplicateSelectColumns + * Whether QB validates selected columns have unique output names. + * Recommended in development and disabled in production. Default: false * @validateQueryExecuteReturnType * Whether QB throws when queryExecute returntype options are passed. * Default: false @@ -340,13 +350,15 @@ component displayname="QueryBuilder" accessors="true" { defaultOptions = {}, sqlCommenter = new qb.models.SQLCommenter.NullSQLCommenter(), shouldMaxRowsOverrideToAll, - boolean collectQueryLog = true + boolean collectQueryLog = true, + boolean validateDuplicateSelectColumns = false ) { variables.grammar = arguments.grammar; variables.utils = arguments.utils; setPreventDuplicateJoins( arguments.preventDuplicateJoins ); setValidateOperatorsAndCombinators( arguments.validateOperatorsAndCombinators ); + setValidateDuplicateSelectColumns( arguments.validateDuplicateSelectColumns ); setValidateQueryExecuteReturnType( arguments.validateQueryExecuteReturnType ); if ( isNull( arguments.returnFormatterRegistry ) ) { arguments.returnFormatterRegistry = new qb.models.Query.ReturnFormatterRegistry( arguments.utils ); @@ -470,13 +482,14 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function select( any columns = "*" ) { - variables.columns = normalizeToArray( arguments.columns ) + var newColumns = normalizeToArray( arguments.columns ) .map( ( column ) => applyColumnFormatter( column ) ) .map( ( column ) => mapToColumnType( column ) ); - if ( variables.columns.isEmpty() ) { - variables.columns = [ { "type": "simple", "value": "*" } ]; + if ( newColumns.isEmpty() ) { + newColumns = [ { "type": "simple", "value": "*" } ]; } + variables.columns = newColumns; return this; } @@ -506,6 +519,88 @@ component displayname="QueryBuilder" accessors="true" { } } + /** + * Validates that all statically identifiable select output names are unique. + * Wildcards and raw expressions without explicit aliases are skipped because + * their output names cannot be determined without executing the query. + */ + private void function validateUniqueSelectColumns( required array columns ) { + if ( !getValidateDuplicateSelectColumns() ) { + return; + } + + var outputNames = {}; + for ( var column in arguments.columns ) { + var outputName = getSelectOutputName( column ); + if ( isNull( outputName ) ) { + continue; + } + + var normalizedName = normalizeSelectOutputName( outputName ); + if ( structKeyExists( outputNames, normalizedName ) ) { + throw( + type = "DuplicateSelectColumn", + message = "Multiple selected columns produce the output name [#outputName#].", + detail = "Alias one of the columns to produce unique result keys." + ); + } + outputNames[ normalizedName ] = true; + } + } + + /** + * Returns a statically identifiable output name for a selected column. + */ + private any function getSelectOutputName( required struct column ) { + if ( arguments.column.type == "builder" ) { + return arguments.column.alias; + } + + if ( arguments.column.type == "raw" ) { + var rawSql = trim( arguments.column.value.getSQL() ); + var aliasMatch = reFindNoCase( + "\s+AS\s+((?:`[^`]+`)|(?:\[[^\]]+\])|(?:""[^""]+"")|(?:[A-Za-z_][A-Za-z0-9_$]*))\s*$", + rawSql, + 1, + true + ); + if ( aliasMatch.pos.len() < 2 || aliasMatch.pos[ 1 ] == 0 ) { + return; + } + return mid( rawSql, aliasMatch.pos[ 2 ], aliasMatch.len[ 2 ] ); + } + + if ( arguments.column.type == "simple" && find( "*", arguments.column.value ) ) { + return; + } + + if ( arguments.column.type == "jsonPath" && !arguments.column.keyExists( "alias" ) ) { + return; + } + + if ( listFindNoCase( "simple,jsonPath", arguments.column.type ) ) { + return getGrammar().extractAlias( arguments.column ); + } + } + + /** + * Normalizes an output name for the case-insensitive keys used by CFML structs. + */ + private string function normalizeSelectOutputName( required string outputName ) { + var normalizedName = trim( arguments.outputName ); + if ( + len( normalizedName ) >= 2 && + ( + ( left( normalizedName, 1 ) == "[" && right( normalizedName, 1 ) == "]" ) || + ( left( normalizedName, 1 ) == "`" && right( normalizedName, 1 ) == "`" ) || + ( left( normalizedName, 1 ) == """" && right( normalizedName, 1 ) == """" ) + ) + ) { + normalizedName = mid( normalizedName, 2, len( normalizedName ) - 2 ); + } + return lCase( normalizedName ); + } + /** * Creates a grammar-aware JSON scalar path expression. * @@ -605,19 +700,22 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function addSelect( required any columns ) { + var newColumns = normalizeToArray( arguments.columns ) + .map( ( column ) => applyColumnFormatter( column ) ) + .map( ( column ) => mapToColumnType( column ) ); + var selectedColumns = variables.columns.isEmpty() ? [] : arraySlice( variables.columns, 1 ); + if ( variables.columns.isEmpty() || ( variables.columns.len() == 1 && isSimpleValue( variables.columns[ 1 ].value ) && variables.columns[ 1 ].value == "*" ) ) { - variables.columns = []; + selectedColumns = []; } - var newColumns = normalizeToArray( arguments.columns ) - .map( ( column ) => applyColumnFormatter( column ) ) - .map( ( column ) => mapToColumnType( column ) ); - arrayAppend( variables.columns, newColumns, true ); + arrayAppend( selectedColumns, newColumns, true ); + variables.columns = selectedColumns; return this; } @@ -4697,6 +4795,7 @@ component displayname="QueryBuilder" accessors="true" { columnFormatter = isNull( getColumnFormatter() ) ? javacast( "null", "" ) : getColumnFormatter(), parentQuery = isNull( getParentQuery() ) ? javacast( "null", "" ) : getParentQuery(), defaultOptions = getDefaultOptions(), + validateDuplicateSelectColumns = getValidateDuplicateSelectColumns(), validateQueryExecuteReturnType = getValidateQueryExecuteReturnType(), collectQueryLog = getCollectQueryLog() ); @@ -4766,6 +4865,9 @@ component displayname="QueryBuilder" accessors="true" { * @return string */ public string function toSQL( any showBindings = false ) { + if ( getAggregate().isEmpty() ) { + validateUniqueSelectColumns( getColumns() ); + } var sql = grammar.compileSelect( this ); if ( isBoolean( arguments.showBindings ) && arguments.showBindings == false ) { diff --git a/tests/specs/Query/Abstract/BuilderSelectSpec.cfc b/tests/specs/Query/Abstract/BuilderSelectSpec.cfc index ce1f172e..9dfa85fd 100644 --- a/tests/specs/Query/Abstract/BuilderSelectSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderSelectSpec.cfc @@ -3,7 +3,7 @@ component extends="testbox.system.BaseSpec" { function run() { describe( "select methods", function() { beforeEach( function() { - variables.mockGrammar = getMockBox().createMock( "qb.models.Grammars.BaseGrammar" ); + variables.mockGrammar = getMockBox().createMock( "qb.models.Grammars.BaseGrammar" ).init(); variables.query = new qb.models.Query.QueryBuilder( variables.mockGrammar ); } ); @@ -38,6 +38,190 @@ component extends="testbox.system.BaseSpec" { expect( query.getColumns().map( ( c ) => c.value ) ).toBe( [ "::some_column::", "::another_column::" ] ); } ); } ); + + describe( "duplicate output name validation", function() { + it( "is disabled by default", function() { + expect( function() { + query + .select( [ "equipment.id", "racks.id" ] ) + .from( "equipment" ) + .toSQL(); + } ).notToThrow(); + } ); + + it( "throws for duplicate qualified column names when enabled", function() { + var validatingQuery = new qb.models.Query.QueryBuilder( + grammar = variables.mockGrammar, + validateDuplicateSelectColumns = true + ); + + validatingQuery.select( [ "equipment.id", "racks.id" ] ).from( "equipment" ); + + expect( function() { + validatingQuery.toSQL(); + } ).toThrow( type = "DuplicateSelectColumn", regex = "output name \[id\]" ); + } ); + + it( "compares output names case-insensitively", function() { + var validatingQuery = new qb.models.Query.QueryBuilder( + grammar = variables.mockGrammar, + validateDuplicateSelectColumns = true + ); + + validatingQuery + .select( [ "equipment.id AS equipmentId", "racks.id AS EquipmentID" ] ) + .from( "equipment" ); + + expect( function() { + validatingQuery.toSQL(); + } ).toThrow( type = "DuplicateSelectColumn" ); + } ); + + it( "normalizes quoted output names", function() { + var validatingQuery = new qb.models.Query.QueryBuilder( + grammar = variables.mockGrammar, + validateDuplicateSelectColumns = true + ); + + validatingQuery + .select( [ "equipment.id AS `equipmentId`", "racks.id AS equipmentId" ] ) + .from( "equipment" ); + + expect( function() { + validatingQuery.toSQL(); + } ).toThrow( type = "DuplicateSelectColumn" ); + } ); + + it( "allows unique aliases", function() { + var validatingQuery = new qb.models.Query.QueryBuilder( + grammar = variables.mockGrammar, + validateDuplicateSelectColumns = true + ); + + expect( function() { + validatingQuery + .select( [ "equipment.id AS equipmentId", "racks.id AS rackId" ] ) + .from( "equipment" ) + .toSQL(); + } ).notToThrow(); + } ); + + it( "ignores wildcard selections whose output names are not known", function() { + var validatingQuery = new qb.models.Query.QueryBuilder( + grammar = variables.mockGrammar, + validateDuplicateSelectColumns = true + ); + + expect( function() { + validatingQuery + .select( [ "equipment.*", "racks.*" ] ) + .from( "equipment" ) + .toSQL(); + } ).notToThrow(); + } ); + + it( "validates explicit aliases on raw expressions", function() { + var validatingQuery = new qb.models.Query.QueryBuilder( + grammar = variables.mockGrammar, + validateDuplicateSelectColumns = true + ); + + validatingQuery.selectRaw( [ "COUNT(*) AS total", "SUM(amount) AS total" ] ).from( "equipment" ); + + expect( function() { + validatingQuery.toSQL(); + } ).toThrow( type = "DuplicateSelectColumn" ); + } ); + + it( "does not treat SQL AS operators as output aliases", function() { + var validatingQuery = new qb.models.Query.QueryBuilder( + grammar = variables.mockGrammar, + validateDuplicateSelectColumns = true + ); + + expect( function() { + validatingQuery + .selectRaw( [ "CAST(id AS VARCHAR)", "CAST(name AS VARCHAR)" ] ) + .from( "equipment" ) + .toSQL(); + } ).notToThrow(); + } ); + + it( "validates typed column aliases", function() { + var validatingQuery = new qb.models.Query.QueryBuilder( + grammar = variables.mockGrammar, + validateDuplicateSelectColumns = true + ); + + validatingQuery + .select( [ validatingQuery.jsonPath( "profile", [ "name" ], "name" ), "users.name" ] ) + .from( "users" ); + + expect( function() { + validatingQuery.toSQL(); + } ).toThrow( type = "DuplicateSelectColumn" ); + } ); + + it( "does not infer output names for unaliased typed expressions", function() { + var validatingQuery = new qb.models.Query.QueryBuilder( + grammar = new qb.models.Grammars.MySQLGrammar( new qb.models.Query.QueryUtils() ), + validateDuplicateSelectColumns = true + ); + + expect( function() { + validatingQuery + .select( [ + validatingQuery.jsonPath( "profile", [ "name" ] ), + validatingQuery.jsonPath( "metadata", [ "name" ] ) + ] ) + .from( "users" ) + .toSQL(); + } ).notToThrow(); + } ); + + it( "propagates validation to new queries", function() { + var validatingQuery = new qb.models.Query.QueryBuilder( + grammar = variables.mockGrammar, + validateDuplicateSelectColumns = true + ); + + var childQuery = validatingQuery + .newQuery() + .select( [ "equipment.id", "racks.id" ] ) + .from( "equipment" ); + + expect( function() { + childQuery.toSQL(); + } ).toThrow( type = "DuplicateSelectColumn" ); + } ); + + it( "validates the final selection after a reselect", function() { + var validatingQuery = new qb.models.Query.QueryBuilder( + grammar = variables.mockGrammar, + validateDuplicateSelectColumns = true + ); + + expect( function() { + validatingQuery + .select( [ "equipment.id", "racks.id" ] ) + .reselect( [ "equipment.id AS equipmentId", "racks.id AS rackId" ] ) + .from( "equipment" ) + .toSQL(); + } ).notToThrow(); + } ); + + it( "does not validate columns excluded from aggregate output", function() { + var validatingQuery = new qb.models.Query.QueryBuilder( + grammar = variables.mockGrammar, + validateDuplicateSelectColumns = true + ); + validatingQuery.select( [ "equipment.id", "racks.id" ] ).from( "equipment" ); + + expect( function() { + validatingQuery.count( toSQL = true ); + } ).notToThrow(); + } ); + } ); } ); describe( "addSelect()", function() { @@ -62,6 +246,36 @@ component extends="testbox.system.BaseSpec" { expect( query.getColumns().map( ( c ) => c.value ) ).toBe( [ "::some_column::", "::another_column::", "::yet_another_column::" ] ); } ); } ); + + it( "validates duplicate output names across calls", function() { + var validatingQuery = new qb.models.Query.QueryBuilder( + grammar = variables.mockGrammar, + validateDuplicateSelectColumns = true + ); + validatingQuery.select( "equipment.id" ); + validatingQuery.addSelect( "racks.id" ).from( "equipment" ); + + expect( function() { + validatingQuery.toSQL(); + } ).toThrow( type = "DuplicateSelectColumn" ); + } ); + + it( "validates subselect aliases against selected columns", function() { + var validatingQuery = new qb.models.Query.QueryBuilder( + grammar = variables.mockGrammar, + validateDuplicateSelectColumns = true + ); + validatingQuery.select( "users.id" ); + validatingQuery + .subSelect( "id", function( subquery ) { + subquery.from( "contacts" ).select( "contacts.id" ); + } ) + .from( "users" ); + + expect( function() { + validatingQuery.toSQL(); + } ).toThrow( type = "DuplicateSelectColumn" ); + } ); } ); describe( "distinct()", function() { From 90b2978954c03239903631cc98224a073641d73a Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 12 Aug 2026 00:29:43 -0600 Subject: [PATCH 006/119] feat(QueryBuilder): add whereInBulk (#319) --- README.md | 34 +++++ models/Grammars/BaseGrammar.cfc | 40 ++++++ models/Grammars/MySQLGrammar.cfc | 25 ++++ models/Grammars/OracleGrammar.cfc | 32 +++++ models/Grammars/PostgresGrammar.cfc | 25 ++++ models/Grammars/SQLiteGrammar.cfc | 38 +++++ models/Grammars/SqlServerGrammar.cfc | 25 ++++ models/Query/QueryBuilder.cfc | 97 +++++++++++++ models/Query/QueryUtils.cfc | 12 ++ tests/resources/AbstractQueryBuilderSpec.cfc | 131 ++++++++++++++++++ tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 31 +++++ tests/specs/Query/DerbyQueryBuilderSpec.cfc | 40 ++++++ tests/specs/Query/MySQLQueryBuilderSpec.cfc | 68 +++++++++ tests/specs/Query/OracleQueryBuilderSpec.cfc | 68 +++++++++ .../specs/Query/PostgresQueryBuilderSpec.cfc | 64 +++++++++ tests/specs/Query/SQLiteQueryBuilderSpec.cfc | 68 +++++++++ .../specs/Query/SqlServerQueryBuilderSpec.cfc | 68 +++++++++ 17 files changed, 866 insertions(+) diff --git a/README.md b/README.md index 8fc6a1ed..322019df 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,40 @@ q = queryExecute( qb enables you to explore new ways of organizing your code by letting you pass around a query builder object that will compile down to the right SQL without you having to keep track of the order, whitespace, or other SQL gotchas! +## Bulk WHERE IN + +For large value collections, `whereInBulk` serializes the values into one bound parameter and lets the active grammar expand them into rows. This avoids database parameter limits without changing the behavior or performance of regular `whereIn` calls. + +```cfc +query + .from( "users" ) + .whereInBulk( "id", userIds ) + .get(); +``` + +qb infers a common type from the values and translates it to the active database grammar. Matching `cfsqltype` values in query parameter structs are preserved. Mixed values fall back to the grammar's string type. + +You can pass an explicit `sqlType` as the third argument when the column needs a more specific database type, such as `BIGINT`, `UUID`, or a particular decimal precision: + +```cfc +query + .from( "users" ) + .whereInBulk( "id", userIds, "BIGINT" ) + .get(); +``` + +The explicit `sqlType` should match the constrained column so the database can avoid implicit conversions. `whereNotInBulk`, `andWhereInBulk`, `orWhereInBulk`, `andWhereNotInBulk`, and `orWhereNotInBulk` are also available. + +Bulk value expansion is supported by these grammars and database features: + ++ SQL Server 2016+ using `OPENJSON`; database compatibility level 130+ is required ++ PostgreSQL 9.4+ using `JSONB_ARRAY_ELEMENTS_TEXT` ++ MySQL 8.0.4+ and MariaDB 10.6+ using `JSON_TABLE` ++ Oracle Database 12c Release 1 (12.1.0.2)+ using `JSON_TABLE` ++ SQLite with JSON functions enabled; they are built in by default as of SQLite 3.38.0 + +Derby does not support bulk value expansion and throws an `UnsupportedOperation` exception for non-empty collections. + ## Development Validation qb can detect statically identifiable duplicate select output names before they are silently collapsed by CFML query results. Enable this validation in development and leave it disabled in production: diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 55eb13ae..81a10344 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -651,6 +651,46 @@ component displayname="Grammar" accessors="true" singleton { return "#wrapColumn( where.column )# NOT IN (#placeholderString#)"; } + /** + * Compiles a bulk IN or NOT IN statement using one serialized binding. + * + * @query The Builder instance. + * @where The where clause to compile. + * + * @return string + */ + private string function whereInBulk( required QueryBuilder query, required struct where ) { + if ( arguments.where.isEmpty ) { + return arguments.where.negate ? "1 = 1" : "0 = 1"; + } + + var operator = arguments.where.negate ? "NOT IN" : "IN"; + return "#wrapColumn( arguments.where.column )# #operator# (#compileWhereInBulkValues( arguments.where.sqlType )#)"; + } + + /** + * Compiles the row-producing subquery for a bulk IN statement. + * Grammars with a native single-parameter strategy should override this method. + * + * @sqlType The database SQL type to use for each value. + * + * @return string + */ + public string function compileWhereInBulkValues( required string sqlType ) { + throw( type = "UnsupportedOperation", message = "This grammar does not support bulk IN statements." ); + } + + /** + * Maps an inferred CF SQL type to a database-native type for a bulk IN statement. + * + * @sqlType The inferred CF SQL type. + * + * @return string + */ + public string function resolveWhereInBulkSqlType( required string sqlType ) { + return reReplaceNoCase( trim( arguments.sqlType ), "^CF_SQL_", "" ).uCase(); + } + /** * Compiles a in subselect where statement. * diff --git a/models/Grammars/MySQLGrammar.cfc b/models/Grammars/MySQLGrammar.cfc index 04e84956..30de3a32 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -1,5 +1,30 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { + public string function compileWhereInBulkValues( required string sqlType ) { + return "SELECT `value` FROM JSON_TABLE(?, '$[*]' COLUMNS(`value` #arguments.sqlType# PATH '$')) AS `qb_bulk_values`"; + } + + public string function resolveWhereInBulkSqlType( required string sqlType ) { + var normalizedType = super.resolveWhereInBulkSqlType( arguments.sqlType ); + switch ( normalizedType ) { + case "CHAR": + case "NCHAR": + case "VARCHAR": + case "NVARCHAR": + case "LONGVARCHAR": + case "LONGNVARCHAR": + case "CLOB": + case "NCLOB": + return "VARCHAR(4000)"; + case "TIMESTAMP": + return "DATETIME(6)"; + case "BOOLEAN": + return "TINYINT"; + default: + return normalizedType; + } + } + public string function compileJsonScalar( required struct jsonPath ) { return "JSON_UNQUOTE(JSON_EXTRACT(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#'))"; } diff --git a/models/Grammars/OracleGrammar.cfc b/models/Grammars/OracleGrammar.cfc index c7d9e9a6..9a4ad95c 100644 --- a/models/Grammars/OracleGrammar.cfc +++ b/models/Grammars/OracleGrammar.cfc @@ -1,5 +1,37 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { + public string function compileWhereInBulkValues( required string sqlType ) { + return "SELECT ""VALUE"" FROM JSON_TABLE(?, '$[*]' COLUMNS(""VALUE"" #arguments.sqlType# PATH '$')) ""QB_BULK_VALUES"""; + } + + public string function resolveWhereInBulkSqlType( required string sqlType ) { + var normalizedType = super.resolveWhereInBulkSqlType( arguments.sqlType ); + switch ( normalizedType ) { + case "CHAR": + case "NCHAR": + case "VARCHAR": + case "NVARCHAR": + case "LONGVARCHAR": + case "LONGNVARCHAR": + case "CLOB": + case "NCLOB": + return "VARCHAR2(4000)"; + case "TINYINT": + case "SMALLINT": + case "INTEGER": + return "NUMBER"; + case "BIGINT": + return "NUMBER(19, 0)"; + case "DECIMAL": + case "NUMERIC": + case "BIT": + case "BOOLEAN": + return "NUMBER"; + default: + return normalizedType; + } + } + public string function compileJsonScalar( required struct jsonPath ) { return "JSON_VALUE(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#')"; } diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index 5e543bcf..7981c082 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -1,5 +1,30 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { + public string function compileWhereInBulkValues( required string sqlType ) { + return "SELECT CAST(""value"" AS #arguments.sqlType#) FROM JSONB_ARRAY_ELEMENTS_TEXT(CAST(? AS JSONB)) AS ""qb_bulk_values""(""value"")"; + } + + public string function resolveWhereInBulkSqlType( required string sqlType ) { + var normalizedType = super.resolveWhereInBulkSqlType( arguments.sqlType ); + switch ( normalizedType ) { + case "CHAR": + case "NCHAR": + case "VARCHAR": + case "NVARCHAR": + case "LONGVARCHAR": + case "LONGNVARCHAR": + case "CLOB": + case "NCLOB": + return "TEXT"; + case "OTHER": + case "BIT": + case "TINYINT": + return "BOOLEAN"; + default: + return normalizedType; + } + } + public string function compileJsonScalar( required struct jsonPath ) { return compilePostgresJsonTraversal( arguments.jsonPath, true ); } diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index d2a271c3..1aca35cc 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -1,5 +1,43 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { + public string function compileWhereInBulkValues( required string sqlType ) { + return "SELECT CAST(""value"" AS #arguments.sqlType#) FROM JSON_EACH(?)"; + } + + public string function resolveWhereInBulkSqlType( required string sqlType ) { + var normalizedType = super.resolveWhereInBulkSqlType( arguments.sqlType ); + switch ( normalizedType ) { + case "CHAR": + case "NCHAR": + case "VARCHAR": + case "NVARCHAR": + case "LONGVARCHAR": + case "LONGNVARCHAR": + case "CLOB": + case "NCLOB": + case "DATE": + case "TIME": + case "TIMESTAMP": + return "TEXT"; + case "BIT": + case "BOOLEAN": + case "TINYINT": + case "SMALLINT": + case "INTEGER": + case "BIGINT": + case "OTHER": + return "INTEGER"; + case "DECIMAL": + case "NUMERIC": + case "REAL": + case "FLOAT": + case "DOUBLE": + return "REAL"; + default: + return normalizedType; + } + } + public string function compileJsonScalar( required struct jsonPath ) { return "JSON_EXTRACT(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#')"; } diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index 9cf0749f..bd3f2e98 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -1,5 +1,30 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { + public string function compileWhereInBulkValues( required string sqlType ) { + return "SELECT [value] FROM OPENJSON(?) WITH ([value] #arguments.sqlType# '$')"; + } + + public string function resolveWhereInBulkSqlType( required string sqlType ) { + var normalizedType = super.resolveWhereInBulkSqlType( arguments.sqlType ); + switch ( normalizedType ) { + case "CHAR": + case "NCHAR": + case "VARCHAR": + case "NVARCHAR": + case "LONGVARCHAR": + case "LONGNVARCHAR": + case "CLOB": + case "NCLOB": + return "NVARCHAR(MAX)"; + case "TIMESTAMP": + return "DATETIME2"; + case "BOOLEAN": + return "BIT"; + default: + return normalizedType; + } + } + public string function compileJsonScalar( required struct jsonPath ) { return "JSON_VALUE(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#')"; } diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 4f20de23..ac2becad 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -2361,6 +2361,103 @@ component displayname="QueryBuilder" accessors="true" { return this; } + /** + * Adds a WHERE IN clause that serializes all values into one bound parameter. + * The active grammar determines how the parameter is expanded into rows. + * + * @column The name of the column with which to constrain the query. + * @values The values to serialize into the single bound parameter. + * @sqlType The database SQL type to use for each expanded value. Inferred from the values when omitted. + * @combinator The boolean combinator for the clause (e.g. "and" or "or"). Default: "and" + * @negate False for IN, True for NOT IN. Default: false. + * + * @return qb.models.Query.QueryBuilder + */ + public QueryBuilder function whereInBulk( + required column, + required values, + any sqlType = javacast( "null", "" ), + string combinator = "and", + boolean negate = false + ) { + arguments.values = normalizeToArray( arguments.values ); + + if ( arguments.values.some( getUtils().isExpression ) ) { + throw( type = "InvalidBulkValue", message = "Bulk IN values cannot contain SQL expressions." ); + } + + var extractedBindings = arguments.values.map( function( value ) { + return getUtils().extractBinding( arguments.value, variables.grammar ); + } ); + + if ( isNull( arguments.sqlType ) ) { + arguments.sqlType = variables.grammar.resolveWhereInBulkSqlType( + getUtils().inferSqlType( arguments.values, variables.grammar ) + ); + } + + arguments.sqlType = trim( arguments.sqlType ); + + if ( + arguments.sqlType == "" || + !reFindNoCase( + "^[a-z][a-z0-9_]*(?:\s+[a-z][a-z0-9_]*)*(?:\s*\(\s*(?:max|\d+)(?:\s*,\s*\d+)?\s*\))?$", + arguments.sqlType + ) + ) { + throw( + type = "InvalidSQLType", + message = "Invalid SQL type [#arguments.sqlType#] for a bulk IN statement." + ); + } + + variables.wheres.append( { + type: "inBulk", + column: mapToColumnType( applyColumnFormatter( arguments.column ) ), + sqlType: arguments.sqlType, + isEmpty: arguments.values.isEmpty(), + negate: arguments.negate, + combinator: arguments.combinator + } ); + + if ( !arguments.values.isEmpty() ) { + var serializedValues = extractedBindings.map( function( binding ) { + return binding.null ? javacast( "null", "" ) : binding.value; + } ); + addBindings( + [ + getUtils().extractBinding( + { value: serializeJSON( serializedValues ), cfsqltype: "LONGVARCHAR" }, + variables.grammar + ) + ], + "where" + ); + } + + return this; + } + + /** + * Adds a WHERE NOT IN clause that serializes all values into one bound parameter. + * + * @column The name of the column with which to constrain the query. + * @values The values to serialize into the single bound parameter. + * @sqlType The database SQL type to use for each expanded value. Inferred from the values when omitted. + * @combinator The boolean combinator for the clause (e.g. "and" or "or"). Default: "and" + * + * @return qb.models.Query.QueryBuilder + */ + public QueryBuilder function whereNotInBulk( + required column, + required values, + any sqlType = javacast( "null", "" ), + string combinator = "and" + ) { + arguments.negate = true; + return whereInBulk( argumentCollection = arguments ); + } + /** * Adds a WHERE IN clause to the query using a subselect. To call this using the public api, pass a closure to `whereIn` as the second argument (`values`). * diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 5b0b13ef..769f2858 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -199,6 +199,18 @@ component singleton displayname="QueryUtils" accessors="true" { ); } + if ( isStruct( value ) ) { + if ( structKeyExists( value, "cfsqltype" ) ) { + return value.cfsqltype; + } + + if ( structKeyExists( value, "sqltype" ) ) { + return value.sqltype; + } + + return structKeyExists( value, "value" ) ? inferSqlType( value.value, grammar ) : "VARCHAR"; + } + if ( checkIsActuallyNumeric( value ) ) { return deriveNumericSqlType( value ); } diff --git a/tests/resources/AbstractQueryBuilderSpec.cfc b/tests/resources/AbstractQueryBuilderSpec.cfc index d01dc357..fc1abbb1 100644 --- a/tests/resources/AbstractQueryBuilderSpec.cfc +++ b/tests/resources/AbstractQueryBuilderSpec.cfc @@ -950,6 +950,129 @@ component extends="testbox.system.BaseSpec" { ); }, whereInBuilderInstance() ); } ); + + describe( "bulk values", function() { + it( "binds an array as a single parameter", function() { + testCase( function( builder ) { + builder.from( "users" ).whereInBulk( "id", [ 1, 2, 3 ] ); + }, whereInBulk() ); + } ); + + it( "uses a large text binding for the serialized values", function() { + var builder = getBuilder().from( "users" ).whereInBulk( "id", [ 1, 2, 3 ] ); + var bindings = builder.getBindings(); + expect( bindings ).toHaveLength( 1 ); + expect( bindings[ 1 ].value ).toBe( "[1,2,3]" ); + expect( bindings[ 1 ].cfsqltype ).toBe( "LONGVARCHAR" ); + } ); + + it( "serializes values from query parameter structs", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .whereInBulk( + "id", + [ + { value: 1, cfsqltype: "INTEGER" }, + { value: 2, cfsqltype: "INTEGER" }, + { value: 3, cfsqltype: "INTEGER" } + ] + ); + }, whereInBulk() ); + } ); + + it( "infers string values using the grammar-specific string type", function() { + testCase( function( builder ) { + builder.from( "users" ).whereInBulk( "status", [ "active", "pending" ] ); + }, whereInBulkStrings() ); + } ); + + it( "falls back to the grammar-specific string type for mixed values", function() { + testCase( function( builder ) { + builder.from( "users" ).whereInBulk( "externalId", [ 1, "two" ] ); + }, whereInBulkMixed() ); + } ); + + it( "infers boolean values using the grammar-specific boolean type", function() { + testCase( function( builder ) { + builder.from( "users" ).whereInBulk( "active", [ true, false ] ); + }, whereInBulkBooleans() ); + } ); + + it( "uses matching query parameter types as the inferred type", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .whereInBulk( + "id", + [ { value: 1, cfsqltype: "BIGINT" }, { value: 2, cfsqltype: "BIGINT" } ] + ); + }, whereInBulkBigInt() ); + } ); + + it( "allows an explicit SQL type to override inference", function() { + testCase( function( builder ) { + builder.from( "users" ).whereInBulk( "id", [ 1, 2, 3 ], bulkExplicitSqlType() ); + }, whereInBulkExplicitType() ); + } ); + + it( "infers the SQL type when explicitly passed null", function() { + testCase( function( builder ) { + builder.from( "users" ).whereInBulk( "id", [ 1, 2, 3 ], javacast( "null", "" ) ); + }, whereInBulk() ); + } ); + + it( "maps inferred timestamp types for the active grammar", function() { + expect( getBuilder().getGrammar().resolveWhereInBulkSqlType( "TIMESTAMP" ) ).toBe( + bulkTimestampSqlType() + ); + } ); + + it( "supports dynamic or where shortcuts", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .where( "active", 1 ) + .orWhereInBulk( "id", [ 1, 2, 3 ] ); + }, orWhereInBulk() ); + } ); + + it( "supports negated bulk values", function() { + testCase( function( builder ) { + builder.from( "users" ).whereNotInBulk( "id", [ 1, 2, 3 ] ); + }, whereNotInBulk() ); + } ); + + it( "handles empty bulk values without a binding", function() { + testCase( function( builder ) { + builder.from( "users" ).whereInBulk( "id", [] ); + }, whereInBulkEmpty() ); + } ); + + it( "handles empty negated bulk values without a binding", function() { + testCase( function( builder ) { + builder.from( "users" ).whereNotInBulk( "id", [] ); + }, whereNotInBulkEmpty() ); + } ); + + it( "rejects SQL expressions in bulk values", function() { + expect( function() { + getBuilder().whereInBulk( "id", [ getBuilder().raw( "SELECT 1" ) ] ); + } ).toThrow( type = "InvalidBulkValue" ); + } ); + + it( "rejects unsafe SQL types", function() { + expect( function() { + getBuilder().whereInBulk( "id", [ 1, 2, 3 ], "INTEGER); DROP TABLE users; --" ); + } ).toThrow( type = "InvalidSQLType" ); + } ); + + it( "rejects an explicitly empty SQL type", function() { + expect( function() { + getBuilder().whereInBulk( "id", [ 1, 2, 3 ], "" ); + } ).toThrow( type = "InvalidSQLType" ); + } ); + } ); } ); describe( "where like shortcuts", function() { @@ -3221,6 +3344,14 @@ component extends="testbox.system.BaseSpec" { throw( "Must be implemented in a subclass" ); } + string function bulkTimestampSqlType() { + return "TIMESTAMP"; + } + + string function bulkExplicitSqlType() { + return "BIGINT"; + } + private array function getTestBindings( required QueryBuilder builder, boolean withFullBindings = false ) { return builder .getBindings() diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index d1f070bb..9ca10d67 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -172,6 +172,37 @@ component extends="testbox.system.BaseSpec" { expect( utils.inferSqlType( [ 1, 2 ], variables.mockGrammar ) ).toBe( "INTEGER" ); } ); + it( "uses matching cfsqltypes from query parameter structs", function() { + expect( + utils.inferSqlType( + [ { value: 1, cfsqltype: "BIGINT" }, { value: 2, cfsqltype: "BIGINT" } ], + variables.mockGrammar + ) + ).toBe( "BIGINT" ); + } ); + + it( "uses matching sqltypes from query parameter structs", function() { + expect( + utils.inferSqlType( + [ { value: 1, sqltype: "BIGINT" }, { value: 2, sqltype: "BIGINT" } ], + variables.mockGrammar + ) + ).toBe( "BIGINT" ); + } ); + + it( "infers values from untyped query parameter structs", function() { + expect( utils.inferSqlType( [ { value: 1 }, { value: 2 } ], variables.mockGrammar ) ).toBe( "INTEGER" ); + } ); + + it( "defaults to VARCHAR when query parameter struct types differ", function() { + expect( + utils.inferSqlType( + [ { value: 1, cfsqltype: "INTEGER" }, { value: 2, cfsqltype: "BIGINT" } ], + variables.mockGrammar + ) + ).toBe( "VARCHAR" ); + } ); + it( "but defaults to VARCHAR if they are different", function() { expect( utils.inferSqlType( diff --git a/tests/specs/Query/DerbyQueryBuilderSpec.cfc b/tests/specs/Query/DerbyQueryBuilderSpec.cfc index f7bae0ea..75dd3563 100644 --- a/tests/specs/Query/DerbyQueryBuilderSpec.cfc +++ b/tests/specs/Query/DerbyQueryBuilderSpec.cfc @@ -340,6 +340,46 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { sql: "SELECT * FROM ""users"" WHERE ""id"" IN (?, ?, ?)", bindings: [ 1, 2, 3 ] }; } + function whereInBulk() { + return { exception: "UnsupportedOperation" }; + } + + function whereInBulkStrings() { + return { exception: "UnsupportedOperation" }; + } + + function whereInBulkMixed() { + return { exception: "UnsupportedOperation" }; + } + + function whereInBulkBooleans() { + return { exception: "UnsupportedOperation" }; + } + + function whereInBulkBigInt() { + return { exception: "UnsupportedOperation" }; + } + + function whereInBulkExplicitType() { + return { exception: "UnsupportedOperation" }; + } + + function orWhereInBulk() { + return { exception: "UnsupportedOperation" }; + } + + function whereNotInBulk() { + return { exception: "UnsupportedOperation" }; + } + + function whereInBulkEmpty() { + return "SELECT * FROM ""users"" WHERE 0 = 1"; + } + + function whereNotInBulkEmpty() { + return "SELECT * FROM ""users"" WHERE 1 = 1"; + } + function orWhereIn() { return { sql: "SELECT * FROM ""users"" WHERE ""email"" = ? OR ""id"" IN (?, ?, ?)", diff --git a/tests/specs/Query/MySQLQueryBuilderSpec.cfc b/tests/specs/Query/MySQLQueryBuilderSpec.cfc index a1a00775..6ddd0968 100644 --- a/tests/specs/Query/MySQLQueryBuilderSpec.cfc +++ b/tests/specs/Query/MySQLQueryBuilderSpec.cfc @@ -334,6 +334,74 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { sql: "SELECT * FROM `users` WHERE `id` IN (?, ?, ?)", bindings: [ 1, 2, 3 ] }; } + function whereInBulk() { + return { + sql: "SELECT * FROM `users` WHERE `id` IN (SELECT `value` FROM JSON_TABLE(?, '$[*]' COLUMNS(`value` INTEGER PATH '$')) AS `qb_bulk_values`)", + bindings: [ "[1,2,3]" ] + }; + } + + function whereInBulkStrings() { + return { + sql: "SELECT * FROM `users` WHERE `status` IN (SELECT `value` FROM JSON_TABLE(?, '$[*]' COLUMNS(`value` VARCHAR(4000) PATH '$')) AS `qb_bulk_values`)", + bindings: [ "[""active"",""pending""]" ] + }; + } + + function whereInBulkMixed() { + return { + sql: "SELECT * FROM `users` WHERE `externalId` IN (SELECT `value` FROM JSON_TABLE(?, '$[*]' COLUMNS(`value` VARCHAR(4000) PATH '$')) AS `qb_bulk_values`)", + bindings: [ "[1,""two""]" ] + }; + } + + function whereInBulkBooleans() { + return { + sql: "SELECT * FROM `users` WHERE `active` IN (SELECT `value` FROM JSON_TABLE(?, '$[*]' COLUMNS(`value` TINYINT PATH '$')) AS `qb_bulk_values`)", + bindings: [ "[1,0]" ] + }; + } + + function whereInBulkBigInt() { + return { + sql: "SELECT * FROM `users` WHERE `id` IN (SELECT `value` FROM JSON_TABLE(?, '$[*]' COLUMNS(`value` BIGINT PATH '$')) AS `qb_bulk_values`)", + bindings: [ "[1,2]" ] + }; + } + + function whereInBulkExplicitType() { + return { + sql: "SELECT * FROM `users` WHERE `id` IN (SELECT `value` FROM JSON_TABLE(?, '$[*]' COLUMNS(`value` BIGINT PATH '$')) AS `qb_bulk_values`)", + bindings: [ "[1,2,3]" ] + }; + } + + function bulkTimestampSqlType() { + return "DATETIME(6)"; + } + + function orWhereInBulk() { + return { + sql: "SELECT * FROM `users` WHERE `active` = ? OR `id` IN (SELECT `value` FROM JSON_TABLE(?, '$[*]' COLUMNS(`value` INTEGER PATH '$')) AS `qb_bulk_values`)", + bindings: [ 1, "[1,2,3]" ] + }; + } + + function whereNotInBulk() { + return { + sql: "SELECT * FROM `users` WHERE `id` NOT IN (SELECT `value` FROM JSON_TABLE(?, '$[*]' COLUMNS(`value` INTEGER PATH '$')) AS `qb_bulk_values`)", + bindings: [ "[1,2,3]" ] + }; + } + + function whereInBulkEmpty() { + return "SELECT * FROM `users` WHERE 0 = 1"; + } + + function whereNotInBulkEmpty() { + return "SELECT * FROM `users` WHERE 1 = 1"; + } + function orWhereIn() { return { sql: "SELECT * FROM `users` WHERE `email` = ? OR `id` IN (?, ?, ?)", bindings: [ "foo", 1, 2, 3 ] }; } diff --git a/tests/specs/Query/OracleQueryBuilderSpec.cfc b/tests/specs/Query/OracleQueryBuilderSpec.cfc index 59fa912f..8213938b 100644 --- a/tests/specs/Query/OracleQueryBuilderSpec.cfc +++ b/tests/specs/Query/OracleQueryBuilderSpec.cfc @@ -346,6 +346,74 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { sql: "SELECT * FROM ""USERS"" WHERE ""ID"" IN (?, ?, ?)", bindings: [ 1, 2, 3 ] }; } + function whereInBulk() { + return { + sql: "SELECT * FROM ""USERS"" WHERE ""ID"" IN (SELECT ""VALUE"" FROM JSON_TABLE(?, '$[*]' COLUMNS(""VALUE"" NUMBER PATH '$')) ""QB_BULK_VALUES"")", + bindings: [ "[1,2,3]" ] + }; + } + + function whereInBulkStrings() { + return { + sql: "SELECT * FROM ""USERS"" WHERE ""STATUS"" IN (SELECT ""VALUE"" FROM JSON_TABLE(?, '$[*]' COLUMNS(""VALUE"" VARCHAR2(4000) PATH '$')) ""QB_BULK_VALUES"")", + bindings: [ "[""active"",""pending""]" ] + }; + } + + function whereInBulkMixed() { + return { + sql: "SELECT * FROM ""USERS"" WHERE ""EXTERNALID"" IN (SELECT ""VALUE"" FROM JSON_TABLE(?, '$[*]' COLUMNS(""VALUE"" VARCHAR2(4000) PATH '$')) ""QB_BULK_VALUES"")", + bindings: [ "[1,""two""]" ] + }; + } + + function whereInBulkBooleans() { + return { + sql: "SELECT * FROM ""USERS"" WHERE ""ACTIVE"" IN (SELECT ""VALUE"" FROM JSON_TABLE(?, '$[*]' COLUMNS(""VALUE"" NUMBER PATH '$')) ""QB_BULK_VALUES"")", + bindings: [ "[1,0]" ] + }; + } + + function whereInBulkBigInt() { + return { + sql: "SELECT * FROM ""USERS"" WHERE ""ID"" IN (SELECT ""VALUE"" FROM JSON_TABLE(?, '$[*]' COLUMNS(""VALUE"" NUMBER(19, 0) PATH '$')) ""QB_BULK_VALUES"")", + bindings: [ "[1,2]" ] + }; + } + + function whereInBulkExplicitType() { + return { + sql: "SELECT * FROM ""USERS"" WHERE ""ID"" IN (SELECT ""VALUE"" FROM JSON_TABLE(?, '$[*]' COLUMNS(""VALUE"" NUMBER(19, 0) PATH '$')) ""QB_BULK_VALUES"")", + bindings: [ "[1,2,3]" ] + }; + } + + function orWhereInBulk() { + return { + sql: "SELECT * FROM ""USERS"" WHERE ""ACTIVE"" = ? OR ""ID"" IN (SELECT ""VALUE"" FROM JSON_TABLE(?, '$[*]' COLUMNS(""VALUE"" NUMBER PATH '$')) ""QB_BULK_VALUES"")", + bindings: [ 1, "[1,2,3]" ] + }; + } + + function whereNotInBulk() { + return { + sql: "SELECT * FROM ""USERS"" WHERE ""ID"" NOT IN (SELECT ""VALUE"" FROM JSON_TABLE(?, '$[*]' COLUMNS(""VALUE"" NUMBER PATH '$')) ""QB_BULK_VALUES"")", + bindings: [ "[1,2,3]" ] + }; + } + + function bulkExplicitSqlType() { + return "NUMBER(19, 0)"; + } + + function whereInBulkEmpty() { + return "SELECT * FROM ""USERS"" WHERE 0 = 1"; + } + + function whereNotInBulkEmpty() { + return "SELECT * FROM ""USERS"" WHERE 1 = 1"; + } + function orWhereIn() { return { sql: "SELECT * FROM ""USERS"" WHERE ""EMAIL"" = ? OR ""ID"" IN (?, ?, ?)", diff --git a/tests/specs/Query/PostgresQueryBuilderSpec.cfc b/tests/specs/Query/PostgresQueryBuilderSpec.cfc index 7e71eecf..deaf0a8f 100644 --- a/tests/specs/Query/PostgresQueryBuilderSpec.cfc +++ b/tests/specs/Query/PostgresQueryBuilderSpec.cfc @@ -340,6 +340,70 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { sql: "SELECT * FROM ""users"" WHERE ""id"" IN (?, ?, ?)", bindings: [ 1, 2, 3 ] }; } + function whereInBulk() { + return { + sql: "SELECT * FROM ""users"" WHERE ""id"" IN (SELECT CAST(""value"" AS INTEGER) FROM JSONB_ARRAY_ELEMENTS_TEXT(CAST(? AS JSONB)) AS ""qb_bulk_values""(""value""))", + bindings: [ "[1,2,3]" ] + }; + } + + function whereInBulkStrings() { + return { + sql: "SELECT * FROM ""users"" WHERE ""status"" IN (SELECT CAST(""value"" AS TEXT) FROM JSONB_ARRAY_ELEMENTS_TEXT(CAST(? AS JSONB)) AS ""qb_bulk_values""(""value""))", + bindings: [ "[""active"",""pending""]" ] + }; + } + + function whereInBulkMixed() { + return { + sql: "SELECT * FROM ""users"" WHERE ""externalId"" IN (SELECT CAST(""value"" AS TEXT) FROM JSONB_ARRAY_ELEMENTS_TEXT(CAST(? AS JSONB)) AS ""qb_bulk_values""(""value""))", + bindings: [ "[1,""two""]" ] + }; + } + + function whereInBulkBooleans() { + return { + sql: "SELECT * FROM ""users"" WHERE ""active"" IN (SELECT CAST(""value"" AS BOOLEAN) FROM JSONB_ARRAY_ELEMENTS_TEXT(CAST(? AS JSONB)) AS ""qb_bulk_values""(""value""))", + bindings: [ "[true,false]" ] + }; + } + + function whereInBulkBigInt() { + return { + sql: "SELECT * FROM ""users"" WHERE ""id"" IN (SELECT CAST(""value"" AS BIGINT) FROM JSONB_ARRAY_ELEMENTS_TEXT(CAST(? AS JSONB)) AS ""qb_bulk_values""(""value""))", + bindings: [ "[1,2]" ] + }; + } + + function whereInBulkExplicitType() { + return { + sql: "SELECT * FROM ""users"" WHERE ""id"" IN (SELECT CAST(""value"" AS BIGINT) FROM JSONB_ARRAY_ELEMENTS_TEXT(CAST(? AS JSONB)) AS ""qb_bulk_values""(""value""))", + bindings: [ "[1,2,3]" ] + }; + } + + function orWhereInBulk() { + return { + sql: "SELECT * FROM ""users"" WHERE ""active"" = ? OR ""id"" IN (SELECT CAST(""value"" AS INTEGER) FROM JSONB_ARRAY_ELEMENTS_TEXT(CAST(? AS JSONB)) AS ""qb_bulk_values""(""value""))", + bindings: [ 1, "[1,2,3]" ] + }; + } + + function whereNotInBulk() { + return { + sql: "SELECT * FROM ""users"" WHERE ""id"" NOT IN (SELECT CAST(""value"" AS INTEGER) FROM JSONB_ARRAY_ELEMENTS_TEXT(CAST(? AS JSONB)) AS ""qb_bulk_values""(""value""))", + bindings: [ "[1,2,3]" ] + }; + } + + function whereInBulkEmpty() { + return "SELECT * FROM ""users"" WHERE 0 = 1"; + } + + function whereNotInBulkEmpty() { + return "SELECT * FROM ""users"" WHERE 1 = 1"; + } + function orWhereIn() { return { sql: "SELECT * FROM ""users"" WHERE ""email"" = ? OR ""id"" IN (?, ?, ?)", diff --git a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc index b0aed0a4..b04965f1 100644 --- a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc +++ b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc @@ -390,6 +390,74 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { sql: "SELECT * FROM ""users"" WHERE ""id"" IN (?, ?, ?)", bindings: [ 1, 2, 3 ] }; } + function whereInBulk() { + return { + sql: "SELECT * FROM ""users"" WHERE ""id"" IN (SELECT CAST(""value"" AS INTEGER) FROM JSON_EACH(?))", + bindings: [ "[1,2,3]" ] + }; + } + + function whereInBulkStrings() { + return { + sql: "SELECT * FROM ""users"" WHERE ""status"" IN (SELECT CAST(""value"" AS TEXT) FROM JSON_EACH(?))", + bindings: [ "[""active"",""pending""]" ] + }; + } + + function whereInBulkMixed() { + return { + sql: "SELECT * FROM ""users"" WHERE ""externalId"" IN (SELECT CAST(""value"" AS TEXT) FROM JSON_EACH(?))", + bindings: [ "[1,""two""]" ] + }; + } + + function whereInBulkBooleans() { + return { + sql: "SELECT * FROM ""users"" WHERE ""active"" IN (SELECT CAST(""value"" AS INTEGER) FROM JSON_EACH(?))", + bindings: [ "[true,false]" ] + }; + } + + function whereInBulkBigInt() { + return { + sql: "SELECT * FROM ""users"" WHERE ""id"" IN (SELECT CAST(""value"" AS INTEGER) FROM JSON_EACH(?))", + bindings: [ "[1,2]" ] + }; + } + + function whereInBulkExplicitType() { + return { + sql: "SELECT * FROM ""users"" WHERE ""id"" IN (SELECT CAST(""value"" AS BIGINT) FROM JSON_EACH(?))", + bindings: [ "[1,2,3]" ] + }; + } + + function bulkTimestampSqlType() { + return "TEXT"; + } + + function orWhereInBulk() { + return { + sql: "SELECT * FROM ""users"" WHERE ""active"" = ? OR ""id"" IN (SELECT CAST(""value"" AS INTEGER) FROM JSON_EACH(?))", + bindings: [ 1, "[1,2,3]" ] + }; + } + + function whereNotInBulk() { + return { + sql: "SELECT * FROM ""users"" WHERE ""id"" NOT IN (SELECT CAST(""value"" AS INTEGER) FROM JSON_EACH(?))", + bindings: [ "[1,2,3]" ] + }; + } + + function whereInBulkEmpty() { + return "SELECT * FROM ""users"" WHERE 0 = 1"; + } + + function whereNotInBulkEmpty() { + return "SELECT * FROM ""users"" WHERE 1 = 1"; + } + function orWhereIn() { return { sql: "SELECT * FROM ""users"" WHERE ""email"" = ? OR ""id"" IN (?, ?, ?)", diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index 7dc3ab60..25f714e7 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -334,6 +334,74 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { sql: "SELECT * FROM [users] WHERE [id] IN (?, ?, ?)", bindings: [ 1, 2, 3 ] }; } + function whereInBulk() { + return { + sql: "SELECT * FROM [users] WHERE [id] IN (SELECT [value] FROM OPENJSON(?) WITH ([value] INTEGER '$'))", + bindings: [ "[1,2,3]" ] + }; + } + + function whereInBulkStrings() { + return { + sql: "SELECT * FROM [users] WHERE [status] IN (SELECT [value] FROM OPENJSON(?) WITH ([value] NVARCHAR(MAX) '$'))", + bindings: [ "[""active"",""pending""]" ] + }; + } + + function whereInBulkMixed() { + return { + sql: "SELECT * FROM [users] WHERE [externalId] IN (SELECT [value] FROM OPENJSON(?) WITH ([value] NVARCHAR(MAX) '$'))", + bindings: [ "[1,""two""]" ] + }; + } + + function whereInBulkBooleans() { + return { + sql: "SELECT * FROM [users] WHERE [active] IN (SELECT [value] FROM OPENJSON(?) WITH ([value] BIT '$'))", + bindings: [ "[1,0]" ] + }; + } + + function whereInBulkBigInt() { + return { + sql: "SELECT * FROM [users] WHERE [id] IN (SELECT [value] FROM OPENJSON(?) WITH ([value] BIGINT '$'))", + bindings: [ "[1,2]" ] + }; + } + + function whereInBulkExplicitType() { + return { + sql: "SELECT * FROM [users] WHERE [id] IN (SELECT [value] FROM OPENJSON(?) WITH ([value] BIGINT '$'))", + bindings: [ "[1,2,3]" ] + }; + } + + function bulkTimestampSqlType() { + return "DATETIME2"; + } + + function orWhereInBulk() { + return { + sql: "SELECT * FROM [users] WHERE [active] = ? OR [id] IN (SELECT [value] FROM OPENJSON(?) WITH ([value] INTEGER '$'))", + bindings: [ 1, "[1,2,3]" ] + }; + } + + function whereNotInBulk() { + return { + sql: "SELECT * FROM [users] WHERE [id] NOT IN (SELECT [value] FROM OPENJSON(?) WITH ([value] INTEGER '$'))", + bindings: [ "[1,2,3]" ] + }; + } + + function whereInBulkEmpty() { + return "SELECT * FROM [users] WHERE 0 = 1"; + } + + function whereNotInBulkEmpty() { + return "SELECT * FROM [users] WHERE 1 = 1"; + } + function orWhereIn() { return { sql: "SELECT * FROM [users] WHERE [email] = ? OR [id] IN (?, ?, ?)", bindings: [ "foo", 1, 2, 3 ] }; } From dcaede61c45a483465c696b8313a1e82c980b4f9 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 12 Aug 2026 12:45:11 -0600 Subject: [PATCH 007/119] fix(SqlServerGrammar): replace defaults when modifying columns (#320) --- models/Grammars/SqlServerGrammar.cfc | 44 +++++++++++++++++- .../Schema/SqlServerSchemaBuilderSpec.cfc | 46 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index bd3f2e98..11736c5a 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -337,6 +337,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } arguments.value = reReplace( arguments.value, """", "", "all" ); + arguments.value = replace( arguments.value, "]", "]]", "all" ); return "[#value#]"; } @@ -768,17 +769,56 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { function compileModifyColumn( blueprint, commandParameters ) { try { var originalShouldWrapValues = getShouldWrapValues(); + var originalDefaultValue = commandParameters.to.getDefaultValue(); if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ); } - return concatenate( [ + if ( originalDefaultValue == "" ) { + return concatenate( [ + "ALTER TABLE", + wrapTable( blueprint.getTable() ), + "ALTER COLUMN", + compileCreateColumn( commandParameters.to, blueprint ) + ] ); + } + + commandParameters.to.setDefaultValue( "" ); + + var wrappedTable = wrapTable( blueprint.getTable(), false ); + var wrappedColumn = wrapValue( commandParameters.to.getName() ); + var escapedTable = replace( wrappedTable, "'", "''", "all" ); + var escapedColumn = replace( + commandParameters.to.getName(), + "'", + "''", + "all" + ); + var alterColumnSql = concatenate( [ "ALTER TABLE", - wrapTable( blueprint.getTable() ), + wrappedTable, "ALTER COLUMN", compileCreateColumn( commandParameters.to, blueprint ) ] ); + + commandParameters.to.setDefaultValue( originalDefaultValue ); + + return [ + "DECLARE @objectId INT = OBJECT_ID(N'#escapedTable#'), @constraintName SYSNAME, @schemaName SYSNAME, @tableName SYSNAME; SELECT @constraintName = [dc].[name], @schemaName = OBJECT_SCHEMA_NAME([dc].[parent_object_id]), @tableName = OBJECT_NAME([dc].[parent_object_id]) FROM [sys].[default_constraints] AS [dc] INNER JOIN [sys].[columns] AS [c] ON [c].[default_object_id] = [dc].[object_id] WHERE [dc].[parent_object_id] = @objectId AND [c].[name] = N'#escapedColumn#'; IF @constraintName IS NOT NULL EXEC(N'ALTER TABLE ' + QUOTENAME(@schemaName) + N'.' + QUOTENAME(@tableName) + N' DROP CONSTRAINT ' + QUOTENAME(@constraintName))", + alterColumnSql, + concatenate( [ + "ALTER TABLE", + wrappedTable, + "ADD CONSTRAINT", + wrapValue( "df_#blueprint.getTable()#_#commandParameters.to.getName()#" ), + "DEFAULT", + wrapDefaultType( commandParameters.to ), + "FOR", + wrappedColumn + ] ) + ]; } finally { + commandParameters.to.setDefaultValue( originalDefaultValue ); if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); } diff --git a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc index 0ec630ae..6148f045 100644 --- a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc @@ -1,5 +1,51 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { + function run() { + super.run(); + + describe( "SQL Server column modifications", function() { + it( "replaces a default constraint when modifying a column", function() { + testCase( + function( schema ) { + return schema.alter( + "mars_in_wash_sales", + function( table ) { + table.modifyColumn( "shares", table.smallinteger( "shares" ).default( -999 ) ); + }, + {}, + false + ); + }, + [ + "DECLARE @objectId INT = OBJECT_ID(N'[mars_in_wash_sales]'), @constraintName SYSNAME, @schemaName SYSNAME, @tableName SYSNAME; SELECT @constraintName = [dc].[name], @schemaName = OBJECT_SCHEMA_NAME([dc].[parent_object_id]), @tableName = OBJECT_NAME([dc].[parent_object_id]) FROM [sys].[default_constraints] AS [dc] INNER JOIN [sys].[columns] AS [c] ON [c].[default_object_id] = [dc].[object_id] WHERE [dc].[parent_object_id] = @objectId AND [c].[name] = N'shares'; IF @constraintName IS NOT NULL EXEC(N'ALTER TABLE ' + QUOTENAME(@schemaName) + N'.' + QUOTENAME(@tableName) + N' DROP CONSTRAINT ' + QUOTENAME(@constraintName))", + "ALTER TABLE [mars_in_wash_sales] ALTER COLUMN [shares] SMALLINT NOT NULL", + "ALTER TABLE [mars_in_wash_sales] ADD CONSTRAINT [df_mars_in_wash_sales_shares] DEFAULT -999 FOR [shares]" + ] + ); + } ); + + it( "does not interpolate caller-provided identifiers into dynamic SQL", function() { + testCase( + function( schema ) { + return schema.alter( + "odd]name'", + function( table ) { + table.modifyColumn( "sha]res'", table.smallinteger( "sha]res'" ).default( -999 ) ); + }, + {}, + false + ); + }, + [ + "DECLARE @objectId INT = OBJECT_ID(N'[odd]]name'']'), @constraintName SYSNAME, @schemaName SYSNAME, @tableName SYSNAME; SELECT @constraintName = [dc].[name], @schemaName = OBJECT_SCHEMA_NAME([dc].[parent_object_id]), @tableName = OBJECT_NAME([dc].[parent_object_id]) FROM [sys].[default_constraints] AS [dc] INNER JOIN [sys].[columns] AS [c] ON [c].[default_object_id] = [dc].[object_id] WHERE [dc].[parent_object_id] = @objectId AND [c].[name] = N'sha]res'''; IF @constraintName IS NOT NULL EXEC(N'ALTER TABLE ' + QUOTENAME(@schemaName) + N'.' + QUOTENAME(@tableName) + N' DROP CONSTRAINT ' + QUOTENAME(@constraintName))", + "ALTER TABLE [odd]]name'] ALTER COLUMN [sha]]res'] SMALLINT NOT NULL", + "ALTER TABLE [odd]]name'] ADD CONSTRAINT [df_odd]]name'_sha]]res'] DEFAULT -999 FOR [sha]]res']" + ] + ); + } ); + } ); + } + function emptyTable() { return [ "CREATE TABLE [users] ()" ]; } From d1791107266535ee62181292b0636d56b8400cc6 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 12 Aug 2026 14:46:34 -0600 Subject: [PATCH 008/119] feat(QueryBuilder): add parameter-aware bulk inserts (#321) --- models/Grammars/BaseGrammar.cfc | 70 ++++++++++++++ models/Grammars/SqlServerGrammar.cfc | 96 +++++++++++++++++++ models/Query/QueryBuilder.cfc | 62 ++++++++++++ .../Query/Abstract/QueryExecutionSpec.cfc | 56 +++++++++++ .../specs/Query/SqlServerQueryBuilderSpec.cfc | 45 +++++++++ 5 files changed, 329 insertions(+) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 81a10344..a781e456 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -32,6 +32,12 @@ component displayname="Grammar" accessors="true" singleton { */ property name="tableAliasOperator" type="string" default=" AS "; + /** + * The maximum number of parameters supported in a single statement. + * A value of zero indicates no grammar-specific limit. + */ + this.parameterLimit = 0; + /** * The different components of a select statement in the order of compilation. */ @@ -927,6 +933,70 @@ component displayname="Grammar" accessors="true" singleton { } } + /** + * Whether this grammar provides a native bulk insert strategy. + */ + public boolean function supportsBulkInsert() { + return false; + } + + /** + * Prepare values and column metadata for a grammar's native bulk insert compiler. + * + * @query The Builder instance. + * @values The rows to insert. + * @sqlTypes Explicit SQL types keyed by column name. + */ + public struct function prepareBulkInsert( required any query, required array values, required struct sqlTypes ) { + var builder = arguments.query; + var columns = arguments.values[ 1 ] + .keyArray() + .map( function( column ) { + var formatted = listLast( builder.applyColumnFormatter( column ), "." ); + return { "original": column, "formatted": { "type": "simple", "value": formatted } }; + } ); + columns.sort( ( a, b ) => compareNoCase( a.formatted.value, b.formatted.value ) ); + + arguments.values.each( function( row ) { + columns.each( function( column ) { + if ( + row.keyExists( column.original ) && + !isNull( row[ column.original ] ) && + getUtils().isExpression( row[ column.original ] ) + ) { + throw( type = "InvalidBulkValue", message = "Bulk insert values cannot contain SQL expressions." ); + } + } ); + } ); + + return prepareBulkInsertValues( arguments.values, columns, arguments.sqlTypes ); + } + + /** + * Prepare database-specific values and metadata for a native bulk insert. + * + * @values The rows to insert. + * @columns The normalized columns to insert. + * @sqlTypes Explicit SQL types keyed by column name. + */ + public struct function prepareBulkInsertValues( + required array values, + required array columns, + required struct sqlTypes + ) { + throw( type = "UnsupportedOperation", message = "This grammar does not support native bulk inserts." ); + } + + /** + * Compile a native bulk insert statement. + * + * @query The Builder instance. + * @columns The columns and resolved SQL types to insert. + */ + public string function compileBulkInsert( required any query, required array columns ) { + throw( type = "UnsupportedOperation", message = "This grammar does not support native bulk inserts." ); + } + /** * Compile a Builder's query into an insert string ignoring duplicate key values. * diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index 11736c5a..10d7c11b 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -1,5 +1,101 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { + public boolean function supportsBulkInsert() { + return true; + } + + public struct function prepareBulkInsertValues( + required array values, + required array columns, + required struct sqlTypes + ) { + var grammar = this; + var normalizedColumns = arguments.columns; + var serializedValues = arguments.values.map( function( row ) { + var serializedRow = {}; + normalizedColumns.each( function( column ) { + if ( !row.keyExists( column.original ) || isNull( row[ column.original ] ) ) { + serializedRow[ column.original ] = javacast( "null", "" ); + return; + } + var binding = getUtils().extractBinding( row[ column.original ], grammar ); + serializedRow[ column.original ] = binding.null ? javacast( "null", "" ) : binding.value; + } ); + return serializedRow; + } ); + + var bulkValues = arguments.values; + var explicitSqlTypes = arguments.sqlTypes; + normalizedColumns.each( function( column ) { + var columnValues = bulkValues.map( function( row ) { + return row.keyExists( column.original ) ? row[ column.original ] : javacast( "null", "" ); + } ); + var sqlType = explicitSqlTypes.keyExists( column.original ) + ? explicitSqlTypes[ column.original ] + : resolveWhereInBulkSqlType( getUtils().inferSqlType( columnValues, grammar ) ); + sqlType = trim( sqlType ); + if ( + sqlType == "" || + !reFindNoCase( + "^[a-z][a-z0-9_]*(?:\s+[a-z][a-z0-9_]*)*(?:\s*\(\s*(?:max|\d+)(?:\s*,\s*\d+)?\s*\))?$", + sqlType + ) + ) { + throw( type = "InvalidSQLType", message = "Invalid SQL type [#sqlType#] for a bulk insert." ); + } + column.bulkSqlType = sqlType; + } ); + + return { + "columns": normalizedColumns, + "binding": getUtils().extractBinding( + { value: serializeJSON( serializedValues ), cfsqltype: "LONGVARCHAR" }, + grammar + ) + }; + } + + public string function compileBulkInsert( required any query, required array columns ) { + try { + var originalShouldWrapValues = getShouldWrapValues(); + if ( !isNull( arguments.query.getShouldWrapValues() ) ) { + setShouldWrapValues( arguments.query.getShouldWrapValues() ); + } + + var columnsString = arguments.columns.map( ( column ) => wrapColumn( column.formatted ) ).toList( ", " ); + var returningColumns = arguments.query + .getReturning() + .map( function( column ) { + if ( column.type == "raw" ) { + return trim( column.getSQL() ); + } + if ( listLen( column.value, "." ) > 1 ) { + return column.value; + } + return "INSERTED." & wrapColumn( column ); + } ) + .toList( ", " ); + var returningClause = returningColumns != "" ? " OUTPUT #returningColumns#" : ""; + var withColumns = arguments.columns + .map( function( column ) { + var escapedPath = replace( + replace( column.original, "\", "\\", "all" ), + """", + "\""", + "all" + ); + return "#wrapColumn( column.formatted )# #column.bulkSqlType# '$.""#escapedPath#""'"; + } ) + .toList( ", " ); + + return "INSERT INTO #wrapTable( query.getTableName() )# (#columnsString#)#returningClause# SELECT #columnsString# FROM OPENJSON(?) WITH (#withColumns#)"; + } finally { + if ( !isNull( arguments.query.getShouldWrapValues() ) ) { + setShouldWrapValues( originalShouldWrapValues ); + } + } + } + public string function compileWhereInBulkValues( required string sqlType ) { return "SELECT [value] FROM OPENJSON(?) WITH ([value] #arguments.sqlType# '$')"; } diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index ac2becad..c8d2eb06 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -3673,6 +3673,68 @@ component displayname="QueryBuilder" accessors="true" { return runQuery( sql, arguments.options, "result" ); } + /** + * Inserts a large set of rows using a grammar's native bulk strategy when available, + * falling back to parameter-aware batches otherwise. + * + * @values An array of structs to insert in to the table. + * @sqlTypes SQL types keyed by column name. Types are inferred for columns not provided. + * @chunkSize The preferred number of rows per batch. A non-positive value uses all rows, subject to grammar parameter limits. Default: 0. + * @options Any options to pass to `queryExecute`. Default: {}. + * @toSql If true, returns the raw SQL strings instead of running the queries. Useful for debugging. Default: false. + * + * @return array + */ + public array function insertBulk( + required array values, + struct sqlTypes = {}, + numeric chunkSize = 0, + struct options = {}, + boolean toSql = false + ) { + if ( arguments.values.isEmpty() ) { + return []; + } + + var columnCount = arguments.values[ 1 ].count(); + if ( columnCount == 0 ) { + throw( type = "InvalidSQLType", message = "Please pass structs with at least one column to insertBulk." ); + } + + var safeChunkSize = arguments.values.len(); + if ( !getGrammar().supportsBulkInsert() && getGrammar().parameterLimit > 0 ) { + safeChunkSize = max( 1, floor( getGrammar().parameterLimit / columnCount ) ); + } + if ( arguments.chunkSize > 0 ) { + safeChunkSize = min( safeChunkSize, arguments.chunkSize ); + } + + var results = []; + for ( var offset = 1; offset <= arguments.values.len(); offset += safeChunkSize ) { + var batchSize = min( safeChunkSize, arguments.values.len() - offset + 1 ); + var batch = arguments.values.slice( offset, batchSize ); + + if ( getGrammar().supportsBulkInsert() ) { + var bulkInsert = getGrammar().prepareBulkInsert( this, batch, arguments.sqlTypes ); + addBindings( [ bulkInsert.binding ], "insert" ); + var sql = getGrammar().compileBulkInsert( this, bulkInsert.columns ); + if ( arguments.toSql ) { + results.append( sql ); + } else { + results.append( runQuery( sql, arguments.options, "result" ) ); + clearBindings( only = [ "insert" ] ); + } + } else { + var batchQuery = clone(); + results.append( + batchQuery.insert( values = batch, options = arguments.options, toSql = arguments.toSql ) + ); + } + } + + return results; + } + /** * Inserts data into a table based off of a query. * This call must come after setting the query's table using `from` or `table`. diff --git a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc index fa1d951b..9806a0fa 100644 --- a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc +++ b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc @@ -1536,6 +1536,62 @@ component extends="testbox.system.BaseSpec" { expect( sql ).toBe( sqlAgain ); } ); } ); + + describe( "bulk inserts", function() { + it( "inserts values in explicit batches", function() { + var sql = getBuilder() + .from( "users" ) + .insertBulk( + values = [ + { "email": "one@example.com" }, + { "email": "two@example.com" }, + { "email": "three@example.com" } + ], + chunkSize = 2, + toSql = true + ); + + expect( sql ).toBe( [ "INSERT INTO ""users"" (""email"") VALUES (?), (?)", "INSERT INTO ""users"" (""email"") VALUES (?)" ] ); + } ); + + it( "caps batches using the grammar parameter limit", function() { + var builder = getBuilder(); + builder.getGrammar().parameterLimit = 4; + + var sql = builder + .from( "users" ) + .insertBulk( + values = [ + { "email": "one@example.com", "name": "One" }, + { "email": "two@example.com", "name": "Two" }, + { "email": "three@example.com", "name": "Three" } + ], + chunkSize = 100, + toSql = true + ); + + expect( sql ).toBe( [ + "INSERT INTO ""users"" (""email"", ""name"") VALUES (?, ?), (?, ?)", + "INSERT INTO ""users"" (""email"", ""name"") VALUES (?, ?)" + ] ); + } ); + + it( "returns an empty array for no values", function() { + expect( getBuilder().from( "users" ).insertBulk( values = [], toSql = true ) ).toBe( [] ); + } ); + + it( "uses a non-positive chunk size to insert all rows", function() { + var sql = getBuilder() + .from( "users" ) + .insertBulk( + values = [ { "email": "one@example.com" }, { "email": "two@example.com" } ], + chunkSize = -1, + toSql = true + ); + + expect( sql ).toBe( [ "INSERT INTO ""users"" (""email"") VALUES (?), (?)" ] ); + } ); + } ); } private function getBuilder() { diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index 25f714e7..bfadd2e9 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -1,5 +1,50 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { + function run() { + super.run(); + + describe( "SQL Server bulk inserts", function() { + it( "inserts all rows from one JSON parameter", function() { + var builder = getBuilder(); + var sql = builder + .from( "users" ) + .insertBulk( values = [ { "id": 1, "name": "One" }, { "id": 2, "name": "Two" } ], toSql = true ); + + expect( sql ).toBe( [ + "INSERT INTO [users] ([id], [name]) SELECT [id], [name] FROM OPENJSON(?) WITH ([id] INTEGER '$.""id""', [name] NVARCHAR(MAX) '$.""name""')" + ] ); + expect( builder.getBindings() ).toHaveLength( 1 ); + expect( deserializeJSON( builder.getBindings()[ 1 ].value ) ).toBe( [ { "id": 1, "name": "One" }, { "id": 2, "name": "Two" } ] ); + } ); + + it( "supports explicit SQL types", function() { + var builder = getBuilder(); + var sql = builder + .from( "measurements" ) + .insertBulk( + values = [ { "reading": 1.5 } ], + sqlTypes = { "reading": "DECIMAL(10, 2)" }, + toSql = true + ); + + expect( sql ).toBe( [ + "INSERT INTO [measurements] ([reading]) SELECT [reading] FROM OPENJSON(?) WITH ([reading] DECIMAL(10, 2) '$.""reading""')" + ] ); + } ); + + it( "applies returning columns to bulk inserts", function() { + var sql = getBuilder() + .from( "users" ) + .returning( "id" ) + .insertBulk( values = [ { "name": "One" } ], toSql = true ); + + expect( sql ).toBe( [ + "INSERT INTO [users] ([name]) OUTPUT INSERTED.[id] SELECT [name] FROM OPENJSON(?) WITH ([name] NVARCHAR(MAX) '$.""name""')" + ] ); + } ); + } ); + } + function selectAllColumns() { return "SELECT * FROM [users]"; } From 722ec43bff8430c5a25604a557c0569e347da6e1 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 12 Aug 2026 14:56:25 -0600 Subject: [PATCH 009/119] fix(SqlServerGrammar): compile ordered union branches (#322) --- models/Grammars/SqlServerGrammar.cfc | 65 +++++++++++++++++++ .../specs/Query/SqlServerQueryBuilderSpec.cfc | 28 ++++++++ 2 files changed, 93 insertions(+) diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index 10d7c11b..4b03c421 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -145,6 +145,71 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { return "(SELECT COUNT(*) FROM OPENJSON(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#'))"; } + /** + * Compile independently ordered and limited UNION branches as derived queries. + * SQL Server requires this shape for ORDER BY to determine each branch's TOP rows. + */ + public string function compileSelect( required QueryBuilder query ) { + if ( !shouldCompileOrderedUnionBranches( arguments.query ) ) { + return super.compileSelect( arguments.query ); + } + + try { + var originalShouldWrapValues = getShouldWrapValues(); + if ( !isNull( arguments.query.getShouldWrapValues() ) ) { + setShouldWrapValues( arguments.query.getShouldWrapValues() ); + } + + var commonTables = arguments.query.getCommonTables(); + var rootQuery = arguments.query.clone(); + var unions = rootQuery.getUnions(); + rootQuery.setUnions( [] ); + rootQuery.setCommonTables( [] ); + + var sql = [ + commonTables.isEmpty() ? "" : compileCommonTables( arguments.query, commonTables ), + "SELECT * FROM (#super.compileSelect( rootQuery )#) AS #wrapValue( "qb_union_0" )#" + ]; + + unions.each( function( union, index ) { + if ( union.query.getOrders().len() && !isLimitedQuery( union.query ) ) { + throw( + type = "OrderByNotAllowed", + message = "The ORDER BY clause is not allowed in an unlimited UNION branch.", + detail = "SQL Server only allows an ORDER BY clause in a UNION branch when TOP, OFFSET, or FETCH limits that branch." + ); + } + + var unionOperator = union.all ? "UNION ALL" : "UNION"; + sql.append( + "#unionOperator# SELECT * FROM (#compileSelect( union.query )#) AS #wrapValue( "qb_union_#index#" )#" + ); + } ); + + return trim( concatenate( sql ) ); + } finally { + if ( !isNull( arguments.query.getShouldWrapValues() ) ) { + setShouldWrapValues( originalShouldWrapValues ); + } + } + } + + private boolean function shouldCompileOrderedUnionBranches( required QueryBuilder query ) { + if ( arguments.query.getUnions().isEmpty() || !isOrderedLimitedQuery( arguments.query ) ) { + return false; + } + + return arguments.query.getUnions().some( ( union ) => isOrderedLimitedQuery( union.query ) ); + } + + private boolean function isOrderedLimitedQuery( required QueryBuilder query ) { + return arguments.query.getOrders().len() && isLimitedQuery( arguments.query ); + } + + private boolean function isLimitedQuery( required QueryBuilder query ) { + return !isNull( arguments.query.getLimitValue() ) || !isNull( arguments.query.getOffsetValue() ); + } + /** * The parameter limit for SQL Server grammar. */ diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index bfadd2e9..73585bc7 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -43,6 +43,34 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { ] ); } ); } ); + + describe( "SQL Server ordered unions", function() { + it( "can limit independently ordered union branches", function() { + var sql = getBuilder() + .select( "*" ) + .fromSub( "t", function( q ) { + q.select( "id, name, modifiedDate" ) + .selectRaw( "'Page' AS typeName" ) + .from( "page" ) + .orderBy( "modifiedDate", "DESC" ) + .limit( 5 ) + .unionAll( function( q ) { + q.select( "id, name, modifiedDate" ) + .selectRaw( "'Document' AS typeName" ) + .from( "document" ) + .orderBy( "modifiedDate", "DESC" ) + .limit( 5 ); + } ); + } ) + .limit( 5 ) + .orderBy( "modifiedDate", "DESC" ) + .toSql(); + + expect( sql ).toBe( + "SELECT TOP (5) * FROM (SELECT * FROM (SELECT TOP (5) [id], [name], [modifiedDate], 'Page' AS typeName FROM [page] ORDER BY [modifiedDate] DESC) AS [qb_union_0] UNION ALL SELECT * FROM (SELECT TOP (5) [id], [name], [modifiedDate], 'Document' AS typeName FROM [document] ORDER BY [modifiedDate] DESC) AS [qb_union_1]) AS [t] ORDER BY [modifiedDate] DESC" + ); + } ); + } ); } function selectAllColumns() { From 54a58d640503922dedfbd5aad307815424f3c0bd Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 12 Aug 2026 15:17:44 -0600 Subject: [PATCH 010/119] fix(SchemaBuilder): qualify tables with default schema (#323) --- models/Grammars/OracleGrammar.cfc | 28 ++++++-- models/Schema/Blueprint.cfc | 26 ++++--- models/Schema/SchemaBuilder.cfc | 41 ++++++++--- .../specs/Schema/OracleSchemaBuilderSpec.cfc | 72 +++++++++++++++++++ 4 files changed, 139 insertions(+), 28 deletions(-) diff --git a/models/Grammars/OracleGrammar.cfc b/models/Grammars/OracleGrammar.cfc index 9a4ad95c..18740e92 100644 --- a/models/Grammars/OracleGrammar.cfc +++ b/models/Grammars/OracleGrammar.cfc @@ -608,15 +608,23 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { return ""; } - var table = uCase( blueprint.getTable() ); + var qualifiedTable = uCase( blueprint.getTable() ); + var table = listLast( qualifiedTable, "." ); + var schema = listLen( qualifiedTable, "." ) > 1 ? listDeleteAt( + qualifiedTable, + listLen( qualifiedTable, "." ), + "." + ) : ""; var columnName = uCase( column.getName() ); var sequenceName = "SEQ_#table#"; var triggerName = "TRG_#table#"; - blueprint.addCommand( "raw", { "sql": "CREATE SEQUENCE ""#sequenceName#""" } ); + var qualifiedSequenceName = schema == "" ? sequenceName : "#schema#.#sequenceName#"; + var qualifiedTriggerName = schema == "" ? triggerName : "#schema#.#triggerName#"; + blueprint.addCommand( "raw", { "sql": "CREATE SEQUENCE #wrapTable( qualifiedSequenceName )#" } ); blueprint.addCommand( "raw", { - "sql": "CREATE OR REPLACE TRIGGER ""#triggerName#"" BEFORE INSERT ON ""#table#"" FOR EACH ROW WHEN (NEW.""#columnName#"" IS NULL) BEGIN SELECT ""#sequenceName#"".NEXTVAL INTO ::NEW.""#columnName#"" FROM dual; END" + "sql": "CREATE OR REPLACE TRIGGER #wrapTable( qualifiedTriggerName )# BEFORE INSERT ON #wrapTable( qualifiedTable )# FOR EACH ROW WHEN (NEW.#wrapColumn( { "type": "simple", "value": columnName } )# IS NULL) BEGIN SELECT #wrapTable( qualifiedSequenceName )#.NEXTVAL INTO ::NEW.#wrapColumn( { "type": "simple", "value": columnName } )# FROM dual; END" } ); return ""; @@ -837,14 +845,20 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { var statements = [ "DROP TABLE #wrapTable( arguments.blueprint.getTable() )#" ]; - var sequenceName = "SEQ_#uCase( arguments.blueprint.getTable() )#"; + var table = uCase( listLast( arguments.blueprint.getTable(), "." ) ); + var schema = listLen( arguments.blueprint.getTable(), "." ) > 1 ? listDeleteAt( + arguments.blueprint.getTable(), + listLen( arguments.blueprint.getTable(), "." ), + "." + ) : ""; + var sequenceName = "SEQ_#table#"; if ( hasSequence( arguments.blueprint, sequenceName ) ) { - statements.append( "DROP SEQUENCE #wrapTable( sequenceName )#" ); + statements.append( "DROP SEQUENCE #wrapTable( schema == "" ? sequenceName : "#schema#.#sequenceName#" )#" ); } - var triggerName = "TRG_#uCase( arguments.blueprint.getTable() )#"; + var triggerName = "TRG_#table#"; if ( hasTrigger( arguments.blueprint, triggerName ) ) { - statements.append( "DROP TRIGGER #wrapTable( triggerName )#" ); + statements.append( "DROP TRIGGER #wrapTable( schema == "" ? triggerName : "#schema#.#triggerName#" )#" ); } return statements; diff --git a/models/Schema/Blueprint.cfc b/models/Schema/Blueprint.cfc index 2899cb87..d1deeae0 100644 --- a/models/Schema/Blueprint.cfc +++ b/models/Schema/Blueprint.cfc @@ -42,7 +42,7 @@ component accessors="true" { public Column function bigIncrements( required string name, string indexName ) { arguments.autoIncrement = true; - param arguments.indexName = "pk_#getTable()#_#name#"; + param arguments.indexName = "pk_#getUnqualifiedTableName()#_#name#"; appendIndex( type = "primary", columns = arguments.name, name = arguments.indexName ); return unsignedBigInteger( argumentCollection = arguments ); } @@ -108,7 +108,7 @@ component accessors="true" { public Column function increments( required string name, string indexName ) { arguments.autoIncrement = true; - param arguments.indexName = "pk_#getTable()#_#name#"; + param arguments.indexName = "pk_#getUnqualifiedTableName()#_#name#"; appendIndex( type = "primary", columns = arguments.name, name = arguments.indexName ); return unsignedInteger( argumentCollection = arguments ); } @@ -140,7 +140,7 @@ component accessors="true" { public Column function mediumIncrements( required string name, string indexName ) { arguments.autoIncrement = true; - param arguments.indexName = "pk_#getTable()#_#name#"; + param arguments.indexName = "pk_#getUnqualifiedTableName()#_#name#"; appendIndex( type = "primary", columns = arguments.name, name = arguments.indexName ); return unsignedMediumInteger( argumentCollection = arguments ); } @@ -225,7 +225,7 @@ component accessors="true" { public Column function smallIncrements( required string name, string indexName ) { arguments.autoIncrement = true; - param arguments.indexName = "pk_#getTable()#_#name#"; + param arguments.indexName = "pk_#getUnqualifiedTableName()#_#name#"; appendIndex( type = "primary", columns = arguments.name, name = arguments.indexName ); return unsignedSmallInteger( argumentCollection = arguments ); } @@ -307,7 +307,7 @@ component accessors="true" { public Column function tinyIncrements( required string name, string indexName ) { arguments.autoIncrement = true; - param arguments.indexName = "pk_#getTable()#_#name#"; + param arguments.indexName = "pk_#getUnqualifiedTableName()#_#name#"; appendIndex( type = "primary", columns = arguments.name, name = arguments.indexName ); return unsignedTinyInteger( argumentCollection = arguments ); } @@ -366,7 +366,7 @@ component accessors="true" { */ public TableIndex function foreignKey( required any columns, string name ) { arguments.columns = arrayWrap( arguments.columns ); - param arguments.name = "fk_#getTable()#_#arrayToList( columns, "_" )#"; + param arguments.name = "fk_#getUnqualifiedTableName()#_#arrayToList( columns, "_" )#"; return appendIndex( type = "foreign", foreignKey = arguments.columns, name = arguments.name ); } @@ -381,7 +381,7 @@ component accessors="true" { */ public TableIndex function index( required any columns, string name ) { arguments.columns = arrayWrap( arguments.columns ); - param arguments.name = "idx_#getTable()#_#arrayToList( columns, "_" )#"; + param arguments.name = "idx_#getUnqualifiedTableName()#_#arrayToList( columns, "_" )#"; return appendIndex( type = "basic", columns = arguments.columns, name = arguments.name ); } @@ -396,7 +396,7 @@ component accessors="true" { */ public TableIndex function primaryKey( required any columns, string name ) { arguments.columns = arrayWrap( arguments.columns ); - param arguments.name = "pk_#getTable()#_#arrayToList( columns, "_" )#"; + param arguments.name = "pk_#getUnqualifiedTableName()#_#arrayToList( columns, "_" )#"; return appendIndex( type = "primary", columns = arguments.columns, name = arguments.name ); } @@ -411,7 +411,7 @@ component accessors="true" { */ public TableIndex function unique( required any columns, string name ) { arguments.columns = arrayWrap( arguments.columns ); - param arguments.name = "unq_#getTable()#_#arrayToList( columns, "_" )#"; + param arguments.name = "unq_#getUnqualifiedTableName()#_#arrayToList( columns, "_" )#"; return appendIndex( type = "unique", columns = arguments.columns, name = arguments.name ); } @@ -425,7 +425,7 @@ component accessors="true" { * @returns The created TableIndex instance. */ public TableIndex function default( required string column, string name ) { - param arguments.name = "df_#getTable()#_#column#"; + param arguments.name = "df_#getUnqualifiedTableName()#_#column#"; return createIndex( type = "default", columns = arguments.column, name = arguments.name ); } @@ -474,7 +474,7 @@ component accessors="true" { } arguments.columns = arrayWrap( arguments.columns ); - param arguments.name = "idx_#getTable()#_#arrayToList( columns, "_" )#"; + param arguments.name = "idx_#getUnqualifiedTableName()#_#arrayToList( columns, "_" )#"; addCommand( "addIndex", { @@ -572,6 +572,10 @@ component accessors="true" { return isArray( arguments.value ) ? arguments.value : [ arguments.value ]; } + private string function getUnqualifiedTableName() { + return listLast( getTable(), "." ); + } + private numeric function clamp( required numeric lowerLimit, required numeric result, required numeric upperLimit ) { arguments.result = ceiling( arguments.result ); arguments.result = min( arguments.result, arguments.upperLimit ); diff --git a/models/Schema/SchemaBuilder.cfc b/models/Schema/SchemaBuilder.cfc index 8eabb7af..3e71f3a7 100644 --- a/models/Schema/SchemaBuilder.cfc +++ b/models/Schema/SchemaBuilder.cfc @@ -85,7 +85,7 @@ component accessors="true" { ); blueprint.addCommand( "create" ); blueprint.setCreating( true ); - blueprint.setTable( arguments.table ); + blueprint.setTable( qualifyTable( arguments.table ) ); arguments.callback( blueprint ); if ( arguments.execute ) { blueprint @@ -124,7 +124,7 @@ component accessors="true" { ); blueprint.addCommand( "createView", { query: query } ); blueprint.setCreating( true ); - blueprint.setTable( arguments.view ); + blueprint.setTable( qualifyTable( arguments.view ) ); if ( arguments.execute ) { blueprint @@ -164,7 +164,7 @@ component accessors="true" { ); blueprint.addCommand( "createAs", { query: query } ); blueprint.setCreating( true ); - blueprint.setTable( arguments.newTableName ); + blueprint.setTable( qualifyTable( arguments.newTableName ) ); if ( arguments.execute ) { blueprint @@ -204,7 +204,7 @@ component accessors="true" { ); blueprint.addCommand( "alterView", { query: query } ); blueprint.setCreating( true ); - blueprint.setTable( arguments.view ); + blueprint.setTable( qualifyTable( arguments.view ) ); if ( arguments.execute ) { blueprint @@ -235,7 +235,7 @@ component accessors="true" { getDefaultSchema() ); blueprint.addCommand( "dropView" ); - blueprint.setTable( arguments.view ); + blueprint.setTable( qualifyTable( arguments.view ) ); if ( arguments.execute ) { blueprint @@ -275,7 +275,7 @@ component accessors="true" { getDefaultSchema() ); blueprint.addCommand( "drop" ); - blueprint.setTable( arguments.table ); + blueprint.setTable( qualifyTable( arguments.table ) ); if ( arguments.execute ) { blueprint .toSql() @@ -313,7 +313,7 @@ component accessors="true" { getDefaultSchema() ); blueprint.addCommand( "truncate" ); - blueprint.setTable( arguments.table ); + blueprint.setTable( qualifyTable( arguments.table ) ); if ( arguments.execute ) { blueprint .toSql() @@ -351,7 +351,7 @@ component accessors="true" { getDefaultSchema() ); blueprint.addCommand( "drop" ); - blueprint.setTable( arguments.table ); + blueprint.setTable( qualifyTable( arguments.table ) ); blueprint.setIfExists( true ); if ( arguments.execute ) { blueprint @@ -396,7 +396,7 @@ component accessors="true" { arguments.options, getDefaultSchema() ); - blueprint.setTable( arguments.table ); + blueprint.setTable( qualifyTable( arguments.table ) ); arguments.callback( blueprint ); if ( arguments.execute ) { blueprint @@ -440,7 +440,7 @@ component accessors="true" { arguments.options, getDefaultSchema() ); - blueprint.setTable( arguments.from ); + blueprint.setTable( qualifyTable( arguments.from ) ); blueprint.addCommand( "renameTable", { to: arguments.to } ); if ( arguments.execute ) { blueprint @@ -499,6 +499,9 @@ component accessors="true" { boolean execute = true ) { structAppend( arguments.options, variables.defaultOptions, false ); + if ( listLen( arguments.name, "." ) > 1 ) { + arguments.schema = listDeleteAt( arguments.name, listLen( arguments.name, "." ), "." ); + } var args = [ listLast( arguments.name, "." ) ]; if ( arguments.schema != "" ) { arrayAppend( args, arguments.schema ); @@ -536,6 +539,9 @@ component accessors="true" { boolean execute = true ) { structAppend( arguments.options, variables.defaultOptions, false ); + if ( listLen( arguments.table, "." ) > 1 ) { + arguments.schema = listDeleteAt( arguments.table, listLen( arguments.table, "." ), "." ); + } var args = [ listLast( arguments.table, "." ), arguments.column ]; if ( arguments.schema != "" ) { arrayAppend( args, arguments.schema ); @@ -658,4 +664,19 @@ component accessors="true" { return variables.shouldWrapValues; } + /** + * Prefixes an unqualified schema object with the configured default schema. + * Explicitly qualified object names are returned unchanged. + * + * @table The table or view name to qualify. + * + * @return The qualified table or view name. + */ + private string function qualifyTable( required string table ) { + if ( variables.defaultSchema == "" || listLen( arguments.table, "." ) > 1 ) { + return arguments.table; + } + return "#variables.defaultSchema#.#arguments.table#"; + } + } diff --git a/tests/specs/Schema/OracleSchemaBuilderSpec.cfc b/tests/specs/Schema/OracleSchemaBuilderSpec.cfc index d993e012..089e352a 100644 --- a/tests/specs/Schema/OracleSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/OracleSchemaBuilderSpec.cfc @@ -4,6 +4,78 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { super.run(); describe( "Oracle Grammar-specific tests", function() { + it( "prefixes schema builder tables with the default schema", () => { + var schema = getBuilder().setDefaultSchema( "app" ); + var statements = schema + .create( + "users", + ( table ) => { + table.string( "username" ); + }, + {}, + false + ) + .toSql(); + + expect( statements ).toBe( [ "CREATE TABLE ""APP"".""USERS"" (""USERNAME"" VARCHAR2(255) NOT NULL)" ] ); + } ); + + it( "preserves explicitly qualified table names", () => { + var schema = getBuilder().setDefaultSchema( "app" ); + var statements = schema + .create( + "audit.users", + ( table ) => { + table.string( "username" ); + }, + {}, + false + ) + .toSql(); + + expect( statements ).toBe( [ "CREATE TABLE ""AUDIT"".""USERS"" (""USERNAME"" VARCHAR2(255) NOT NULL)" ] ); + } ); + + it( "prefixes generated sequences and triggers without including the schema in their names", () => { + var schema = getBuilder().setDefaultSchema( "app" ); + var statements = schema + .create( + "users", + ( table ) => { + table.increments( "id" ); + }, + {}, + false + ) + .toSql(); + + expect( statements ).toBe( [ + "CREATE TABLE ""APP"".""USERS"" (""ID"" NUMBER(10, 0) NOT NULL, CONSTRAINT ""PK_USERS_ID"" PRIMARY KEY (""ID""))", + "CREATE SEQUENCE ""APP"".""SEQ_USERS""", + "CREATE OR REPLACE TRIGGER ""APP"".""TRG_USERS"" BEFORE INSERT ON ""APP"".""USERS"" FOR EACH ROW WHEN (NEW.""ID"" IS NULL) BEGIN SELECT ""APP"".""SEQ_USERS"".NEXTVAL INTO ::NEW.""ID"" FROM dual; END" + ] ); + } ); + + it( "drops generated sequences and triggers from the default schema", () => { + var schema = getBuilder().setDefaultSchema( "app" ); + variables.mockGrammar.$( "hasSequence", true ); + variables.mockGrammar.$( "hasTrigger", true ); + + expect( schema.drop( "users", {}, false ).toSql() ).toBe( [ + "DROP TABLE ""APP"".""USERS""", + "DROP SEQUENCE ""APP"".""SEQ_USERS""", + "DROP TRIGGER ""APP"".""TRG_USERS""" + ] ); + } ); + + it( "uses an explicit table schema for existence checks", () => { + var schema = getBuilder().setDefaultSchema( "app" ); + variables.mockGrammar.$( "runQuery", queryNew( "" ) ); + schema.hasTable( "audit.users" ); + + expect( variables.mockGrammar.$callLog().runQuery[ 1 ][ 2 ] ).toBe( [ "users", "audit" ] ); + } ); + it( "attempts to drop sequences and triggers when dropping a table", () => { try { var schema = getBuilder(); From 1ad11d86e3a425a680c0c40ad02de7cfbbe2632b Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 12 Aug 2026 15:23:31 -0600 Subject: [PATCH 011/119] feat(QueryBuilder): match null upsert targets (#324) BREAKING CHANGE: Custom grammars overriding `compileUpsert` must accept the new trailing `matchNulls` boolean argument. --- models/Grammars/BaseGrammar.cfc | 23 +++++++++++++++++++ models/Grammars/DerbyGrammar.cfc | 9 +++----- models/Grammars/MySQLGrammar.cfc | 9 +++++++- models/Grammars/OracleGrammar.cfc | 9 +++----- models/Grammars/PostgresGrammar.cfc | 9 +++++++- models/Grammars/SQLiteGrammar.cfc | 9 +++++++- models/Grammars/SqlServerGrammar.cfc | 9 +++----- models/Query/QueryBuilder.cfc | 20 ++++++++++++++-- tests/resources/AbstractQueryBuilderSpec.cfc | 17 ++++++++++++++ tests/specs/Query/DerbyQueryBuilderSpec.cfc | 14 +++++++++++ tests/specs/Query/MySQLQueryBuilderSpec.cfc | 4 ++++ tests/specs/Query/OracleQueryBuilderSpec.cfc | 14 +++++++++++ .../specs/Query/PostgresQueryBuilderSpec.cfc | 4 ++++ tests/specs/Query/SQLiteQueryBuilderSpec.cfc | 4 ++++ .../specs/Query/SqlServerQueryBuilderSpec.cfc | 14 +++++++++++ 15 files changed, 145 insertions(+), 23 deletions(-) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index a781e456..fabcf7c7 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -1023,6 +1023,29 @@ component displayname="Grammar" accessors="true" singleton { ); } + /** + * Compiles the target-column comparisons for a MERGE-style upsert. + * + * @target The columns used to match source and target rows. + * @matchNulls Whether two NULL target values should be considered a match. + * + * @return The compiled match predicate. + */ + public string function compileUpsertTargetConstraint( required array target, boolean matchNulls = false ) { + var shouldMatchNulls = arguments.matchNulls; + return arguments.target + .map( function( column ) { + var targetColumn = wrapColumn( { "type": "simple", "value": "qb_target.#column.formatted.value#" } ); + var sourceColumn = wrapColumn( { "type": "simple", "value": "qb_src.#column.formatted.value#" } ); + var equality = "#targetColumn# = #sourceColumn#"; + if ( !shouldMatchNulls ) { + return equality; + } + return "(#equality# OR (#targetColumn# IS NULL AND #sourceColumn# IS NULL))"; + } ) + .toList( " AND " ); + } + /** * Compile a Builder's query into an insert using string. * diff --git a/models/Grammars/DerbyGrammar.cfc b/models/Grammars/DerbyGrammar.cfc index 6ea847b7..a3af34fa 100644 --- a/models/Grammars/DerbyGrammar.cfc +++ b/models/Grammars/DerbyGrammar.cfc @@ -243,7 +243,8 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { required any updates, required array target, QueryBuilder source, - any deleteUnmatched = false + any deleteUnmatched = false, + boolean matchNulls = false ) { if ( !isBoolean( arguments.deleteUnmatched ) || arguments.deleteUnmatched ) { throw( type = "UnsupportedOperation", message = "This grammar does not support DELETE in a upsert clause" ); @@ -287,11 +288,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { .toList( ", " ); } - var constraintString = arguments.target - .map( function( column ) { - return "#wrapColumn( { "type": "simple", "value": "qb_target.#column.formatted.value#" } )# = #wrapColumn( { "type": "simple", "value": "qb_src.#column.formatted.value#" } )#"; - } ) - .toList( " AND " ); + var constraintString = compileUpsertTargetConstraint( arguments.target, arguments.matchNulls ); var updateList = ""; if ( isArray( arguments.updates ) ) { diff --git a/models/Grammars/MySQLGrammar.cfc b/models/Grammars/MySQLGrammar.cfc index 30de3a32..baaea067 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -299,8 +299,15 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { required any updates, required array target, QueryBuilder source, - any deleteUnmatched = false + any deleteUnmatched = false, + boolean matchNulls = false ) { + if ( arguments.matchNulls ) { + throw( + type = "UnsupportedOperation", + message = "This grammar does not support matching NULL target values during an upsert" + ); + } if ( !isBoolean( arguments.deleteUnmatched ) || arguments.deleteUnmatched ) { throw( type = "UnsupportedOperation", message = "This grammar does not support DELETE in a upsert clause" ); } diff --git a/models/Grammars/OracleGrammar.cfc b/models/Grammars/OracleGrammar.cfc index 18740e92..9692e244 100644 --- a/models/Grammars/OracleGrammar.cfc +++ b/models/Grammars/OracleGrammar.cfc @@ -253,7 +253,8 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { required any updates, required array target, QueryBuilder source, - any deleteUnmatched = false + any deleteUnmatched = false, + boolean matchNulls = false ) { if ( !isBoolean( arguments.deleteUnmatched ) || arguments.deleteUnmatched ) { throw( type = "UnsupportedOperation", message = "This grammar does not support DELETE in a upsert clause" ); @@ -297,11 +298,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { .toList( " UNION ALL " ); } - var constraintString = arguments.target - .map( function( column ) { - return "#wrapColumn( { "type": "simple", "value": "qb_target.#column.formatted.value#" } )# = #wrapColumn( { "type": "simple", "value": "qb_src.#column.formatted.value#" } )#"; - } ) - .toList( " AND " ); + var constraintString = compileUpsertTargetConstraint( arguments.target, arguments.matchNulls ); var updateList = ""; if ( isArray( arguments.updates ) ) { diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index 7981c082..f7d7fcd7 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -262,8 +262,15 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { required any updates, required array target, QueryBuilder source, - any deleteUnmatched = false + any deleteUnmatched = false, + boolean matchNulls = false ) { + if ( arguments.matchNulls ) { + throw( + type = "UnsupportedOperation", + message = "This grammar does not support matching NULL target values during an upsert" + ); + } if ( !isBoolean( arguments.deleteUnmatched ) || arguments.deleteUnmatched ) { throw( type = "UnsupportedOperation", message = "This grammar does not support DELETE in a upsert clause" ); } diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index 1aca35cc..e1f0a9b9 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -277,8 +277,15 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { required any updates, required array target, QueryBuilder source, - any deleteUnmatched = false + any deleteUnmatched = false, + boolean matchNulls = false ) { + if ( arguments.matchNulls ) { + throw( + type = "UnsupportedOperation", + message = "This grammar does not support matching NULL target values during an upsert" + ); + } if ( !isBoolean( arguments.deleteUnmatched ) || arguments.deleteUnmatched ) { throw( type = "UnsupportedOperation", message = "This grammar does not support DELETE in a upsert clause" ); } diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index 4b03c421..e7306e42 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -641,7 +641,8 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { required any updates, required array target, QueryBuilder source, - any deleteUnmatched = false + any deleteUnmatched = false, + boolean matchNulls = false ) { try { var originalShouldWrapValues = getShouldWrapValues(); @@ -676,11 +677,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { sourceString = "(VALUES #placeholderString#) AS [qb_src] (#columnsString#)"; } - var constraintString = arguments.target - .map( function( column ) { - return "#wrapColumn( { "type": "simple", "value": "qb_target.#column.formatted.value#" } )# = #wrapColumn( { "type": "simple", "value": "qb_src.#column.formatted.value#" } )#"; - } ) - .toList( " AND " ); + var constraintString = compileUpsertTargetConstraint( arguments.target, arguments.matchNulls ); var updateList = ""; if ( isArray( arguments.updates ) ) { diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index c8d2eb06..8870119b 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -3978,6 +3978,20 @@ component displayname="QueryBuilder" accessors="true" { } + /** + * 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. + * + * @return The query result, compiled SQL, or nothing when no values are provided. + */ public any function upsert( required any values, required any target, @@ -3985,7 +3999,8 @@ component displayname="QueryBuilder" accessors="true" { any source, any deleteUnmatched = false, struct options = {}, - boolean toSql = false + boolean toSql = false, + boolean matchNulls = false ) { if ( arguments.values.isEmpty() ) { return; @@ -4136,7 +4151,8 @@ component displayname="QueryBuilder" accessors="true" { arguments.update, arguments.target, isNull( arguments.source ) ? javacast( "null", "" ) : arguments.source, - arguments.deleteUnmatched + arguments.deleteUnmatched, + arguments.matchNulls ); if ( toSql ) { diff --git a/tests/resources/AbstractQueryBuilderSpec.cfc b/tests/resources/AbstractQueryBuilderSpec.cfc index fc1abbb1..d3185ebb 100644 --- a/tests/resources/AbstractQueryBuilderSpec.cfc +++ b/tests/resources/AbstractQueryBuilderSpec.cfc @@ -3081,6 +3081,23 @@ component extends="testbox.system.BaseSpec" { }, upsertSingleTarget() ); } ); + it( "can opt in to matching null target values", function() { + testCase( function( builder ) { + return builder + .table( "records" ) + .upsert( + values = [ + { "a": 1, "b": javacast( "null", "" ), "c": "first" }, + { "a": 2, "b": "value", "c": "second" } + ], + target = [ "a", "b" ], + update = [ "c" ], + matchNulls = true, + toSql = true + ); + }, upsertMatchNulls() ); + } ); + it( "can perform an upsert with a closure as the source", function() { testCase( function( builder ) { return builder diff --git a/tests/specs/Query/DerbyQueryBuilderSpec.cfc b/tests/specs/Query/DerbyQueryBuilderSpec.cfc index 75dd3563..b3e8032b 100644 --- a/tests/specs/Query/DerbyQueryBuilderSpec.cfc +++ b/tests/specs/Query/DerbyQueryBuilderSpec.cfc @@ -1029,6 +1029,20 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function upsertMatchNulls() { + return { + sql: "MERGE INTO ""records"" ""qb_target"" USING (VALUES (?, ?, ?), (?, ?, ?)) AS ""qb_src"" ON (""qb_target"".""a"" = ""qb_src"".""a"" OR (""qb_target"".""a"" IS NULL AND ""qb_src"".""a"" IS NULL)) AND (""qb_target"".""b"" = ""qb_src"".""b"" OR (""qb_target"".""b"" IS NULL AND ""qb_src"".""b"" IS NULL)) WHEN MATCHED THEN UPDATE SET ""c"" = ""qb_src"".""c"" WHEN NOT MATCHED THEN INSERT (""a"", ""b"", ""c"") VALUES (""qb_src"".""a"", ""qb_src"".""b"", ""qb_src"".""c"")", + bindings: [ + 1, + "NULL", + "first", + 2, + "value", + "second" + ] + }; + } + function upsertFromClosure() { return { sql: "MERGE INTO ""users"" ""qb_target"" USING (SELECT ""username"", ""active"", ""createdDate"", ""modifiedDate"" FROM ""activeDirectoryUsers"" WHERE ""active"" = ?) AS ""qb_src"" ON ""qb_target"".""username"" = ""qb_src"".""username"" WHEN MATCHED THEN UPDATE SET ""active"" = ""qb_src"".""active"", ""modifiedDate"" = ""qb_src"".""modifiedDate"" WHEN NOT MATCHED THEN INSERT (""username"", ""active"", ""createdDate"", ""modifiedDate"") VALUES (""qb_src"".""username"", ""qb_src"".""active"", ""qb_src"".""createdDate"", ""qb_src"".""modifiedDate"")", diff --git a/tests/specs/Query/MySQLQueryBuilderSpec.cfc b/tests/specs/Query/MySQLQueryBuilderSpec.cfc index 6ddd0968..5f6b20a7 100644 --- a/tests/specs/Query/MySQLQueryBuilderSpec.cfc +++ b/tests/specs/Query/MySQLQueryBuilderSpec.cfc @@ -1056,6 +1056,10 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function upsertMatchNulls() { + return { exception: "UnsupportedOperation" }; + } + function upsertFromClosure() { return { sql: "INSERT INTO `users` (`username`, `active`, `createdDate`, `modifiedDate`) SELECT `username`, `active`, `createdDate`, `modifiedDate` FROM `activeDirectoryUsers` WHERE `active` = ? ON DUPLICATE KEY UPDATE `active` = VALUES(`active`), `modifiedDate` = VALUES(`modifiedDate`)", diff --git a/tests/specs/Query/OracleQueryBuilderSpec.cfc b/tests/specs/Query/OracleQueryBuilderSpec.cfc index 8213938b..c230ce0f 100644 --- a/tests/specs/Query/OracleQueryBuilderSpec.cfc +++ b/tests/specs/Query/OracleQueryBuilderSpec.cfc @@ -1075,6 +1075,20 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function upsertMatchNulls() { + return { + sql: "MERGE INTO ""RECORDS"" ""QB_TARGET"" USING (SELECT ?, ?, ? FROM dual UNION ALL SELECT ?, ?, ? FROM dual) ""QB_SRC"" ON (""QB_TARGET"".""A"" = ""QB_SRC"".""A"" OR (""QB_TARGET"".""A"" IS NULL AND ""QB_SRC"".""A"" IS NULL)) AND (""QB_TARGET"".""B"" = ""QB_SRC"".""B"" OR (""QB_TARGET"".""B"" IS NULL AND ""QB_SRC"".""B"" IS NULL)) WHEN MATCHED THEN UPDATE SET ""C"" = ""QB_SRC"".""C"" WHEN NOT MATCHED THEN INSERT (""A"", ""B"", ""C"") VALUES (""QB_SRC"".""A"", ""QB_SRC"".""B"", ""QB_SRC"".""C"")", + bindings: [ + 1, + "NULL", + "first", + 2, + "value", + "second" + ] + }; + } + function upsertFromClosure() { return { sql: "MERGE INTO ""USERS"" ""QB_TARGET"" USING (SELECT ""USERNAME"", ""ACTIVE"", ""CREATEDDATE"", ""MODIFIEDDATE"" FROM ""ACTIVEDIRECTORYUSERS"" WHERE ""ACTIVE"" = ?) ""QB_SRC"" ON ""QB_TARGET"".""USERNAME"" = ""QB_SRC"".""USERNAME"" WHEN MATCHED THEN UPDATE SET ""ACTIVE"" = ""QB_SRC"".""ACTIVE"", ""MODIFIEDDATE"" = ""QB_SRC"".""MODIFIEDDATE"" WHEN NOT MATCHED THEN INSERT (""USERNAME"", ""ACTIVE"", ""CREATEDDATE"", ""MODIFIEDDATE"") VALUES (""QB_SRC"".""USERNAME"", ""QB_SRC"".""ACTIVE"", ""QB_SRC"".""CREATEDDATE"", ""QB_SRC"".""MODIFIEDDATE"")", diff --git a/tests/specs/Query/PostgresQueryBuilderSpec.cfc b/tests/specs/Query/PostgresQueryBuilderSpec.cfc index deaf0a8f..46bb8043 100644 --- a/tests/specs/Query/PostgresQueryBuilderSpec.cfc +++ b/tests/specs/Query/PostgresQueryBuilderSpec.cfc @@ -1089,6 +1089,10 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function upsertMatchNulls() { + return { exception: "UnsupportedOperation" }; + } + function upsertFromClosure() { return { sql: "INSERT INTO ""users"" (""username"", ""active"", ""createdDate"", ""modifiedDate"") SELECT ""username"", ""active"", ""createdDate"", ""modifiedDate"" FROM ""activeDirectoryUsers"" WHERE ""active"" = ? ON CONFLICT (""username"") DO UPDATE SET ""active"" = EXCLUDED.""active"", ""modifiedDate"" = EXCLUDED.""modifiedDate""", diff --git a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc index b04965f1..4cd1e4a8 100644 --- a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc +++ b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc @@ -1185,6 +1185,10 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function upsertMatchNulls() { + return { exception: "UnsupportedOperation" }; + } + function upsertFromClosure() { return { sql: "INSERT INTO ""users"" (""username"", ""active"", ""createdDate"", ""modifiedDate"") SELECT ""username"", ""active"", ""createdDate"", ""modifiedDate"" FROM ""activeDirectoryUsers"" WHERE ""active"" = ? ON CONFLICT (""username"") DO UPDATE SET ""active"" = EXCLUDED.""active"", ""modifiedDate"" = EXCLUDED.""modifiedDate""", diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index 73585bc7..12454513 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -1148,6 +1148,20 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function upsertMatchNulls() { + return { + sql: "MERGE [records] AS [qb_target] USING (VALUES (?, ?, ?), (?, ?, ?)) AS [qb_src] ([a], [b], [c]) ON ([qb_target].[a] = [qb_src].[a] OR ([qb_target].[a] IS NULL AND [qb_src].[a] IS NULL)) AND ([qb_target].[b] = [qb_src].[b] OR ([qb_target].[b] IS NULL AND [qb_src].[b] IS NULL)) WHEN MATCHED THEN UPDATE SET [c] = [qb_src].[c] WHEN NOT MATCHED BY TARGET THEN INSERT ([a], [b], [c]) VALUES ([a], [b], [c]);", + bindings: [ + 1, + "NULL", + "first", + 2, + "value", + "second" + ] + }; + } + function upsertFromClosure() { return { sql: "MERGE [users] AS [qb_target] USING (SELECT [username], [active], [createdDate], [modifiedDate] FROM [activeDirectoryUsers] WHERE [active] = ?) AS [qb_src] ON [qb_target].[username] = [qb_src].[username] WHEN MATCHED THEN UPDATE SET [active] = [qb_src].[active], [modifiedDate] = [qb_src].[modifiedDate] WHEN NOT MATCHED BY TARGET THEN INSERT ([username], [active], [createdDate], [modifiedDate]) VALUES ([username], [active], [createdDate], [modifiedDate]);", From cf5bcd56350118b423a2dc9ef3cbdd201c23f383 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 12 Aug 2026 16:17:19 -0600 Subject: [PATCH 012/119] feat(moduleconfig): add shouldWrapValues setting (#325) Co-authored-by: Karlin [bot] --- ModuleConfig.cfc | 8 ++ models/Grammars/AutoDiscover.cfc | 35 +++++++-- tests/specs/Query/ShouldWrapValuesSpec.cfc | 91 ++++++++++++++++++++++ 3 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 tests/specs/Query/ShouldWrapValuesSpec.cfc diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index 91f40df1..c5a8748c 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -16,6 +16,7 @@ component { "validateQueryExecuteReturnType": false, "collectQueryLog": true, "convertEmptyStringsToNull": true, + "shouldWrapValues": true, "validateQueryParamStructKeys": true, "numericSQLType": "NUMERIC", "integerSQLType": "INTEGER", @@ -84,6 +85,13 @@ component { .map( alias = "SchemaBuilder@qb", force = true ) .to( "qb.models.Schema.SchemaBuilder" ) .initArg( name = "grammar", ref = settings.defaultGrammar ); + + // Apply shouldWrapValues setting to the configured grammar singleton. + // When defaultGrammar is AutoDiscover@qb, the setting is forwarded via + // onMissingMethod to whatever concrete grammar AutoDiscover resolves at runtime. + if ( structKeyExists( settings, "shouldWrapValues" ) ) { + wirebox.getInstance( settings.defaultGrammar ).setShouldWrapValues( settings.shouldWrapValues ); + } } } diff --git a/models/Grammars/AutoDiscover.cfc b/models/Grammars/AutoDiscover.cfc index 20914b70..90259524 100644 --- a/models/Grammars/AutoDiscover.cfc +++ b/models/Grammars/AutoDiscover.cfc @@ -2,32 +2,53 @@ component singleton { property name="wirebox" inject="wirebox"; property name="grammar"; + property name="shouldWrapValues"; function autoDiscoverGrammar() { cfdbinfo( type = "Version", name = "local.dbInfo" ); + var discoveredGrammar = ""; switch ( dbInfo.DATABASE_PRODUCTNAME ) { case "MySQL": case "MariaDB": - return wirebox.getInstance( "MySQLGrammar@qb" ); + discoveredGrammar = wirebox.getInstance( "MySQLGrammar@qb" ); + break; case "Derby": - return wirebox.getInstance( "DerbyGrammar@qb" ); + discoveredGrammar = wirebox.getInstance( "DerbyGrammar@qb" ); + break; case "PostgreSQL": - return wirebox.getInstance( "PostgresGrammar@qb" ); + discoveredGrammar = wirebox.getInstance( "PostgresGrammar@qb" ); + break; case "Microsoft SQL Server": - return wirebox.getInstance( "SQLServerGrammar@qb" ); + discoveredGrammar = wirebox.getInstance( "SQLServerGrammar@qb" ); + break; case "Oracle": - return wirebox.getInstance( "OracleGrammar@qb" ); + discoveredGrammar = wirebox.getInstance( "OracleGrammar@qb" ); + break; case "SQLite": - return wirebox.getInstance( "SQLiteGrammar@qb" ); + discoveredGrammar = wirebox.getInstance( "SQLiteGrammar@qb" ); + break; default: - return wirebox.getInstance( "BaseGrammar@qb" ); + discoveredGrammar = wirebox.getInstance( "BaseGrammar@qb" ); } + + return discoveredGrammar; + } + + public AutoDiscover function setShouldWrapValues( required boolean shouldWrapValues ) { + variables.shouldWrapValues = arguments.shouldWrapValues; + if ( !isNull( variables.grammar ) ) { + variables.grammar.setShouldWrapValues( arguments.shouldWrapValues ); + } + return this; } function onMissingMethod( missingMethodName, missingMethodArguments ) { if ( isNull( variables.grammar ) || !structKeyExists( variables, "grammar" ) ) { variables.grammar = autoDiscoverGrammar(); + if ( !isNull( variables.shouldWrapValues ) ) { + variables.grammar.setShouldWrapValues( variables.shouldWrapValues ); + } } return invoke( variables.grammar, missingMethodName, missingMethodArguments ); } diff --git a/tests/specs/Query/ShouldWrapValuesSpec.cfc b/tests/specs/Query/ShouldWrapValuesSpec.cfc new file mode 100644 index 00000000..eed37abe --- /dev/null +++ b/tests/specs/Query/ShouldWrapValuesSpec.cfc @@ -0,0 +1,91 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "shouldWrapValues setting", function() { + it( "does not eagerly discover a grammar when configured", function() { + var grammar = getMockBox().createMock( "qb.models.Grammars.PostgresGrammar" ).init(); + var autoDiscover = getMockBox() + .createMock( "qb.models.Grammars.AutoDiscover" ) + .$( "autoDiscoverGrammar", grammar ); + + autoDiscover.setShouldWrapValues( false ); + + expect( autoDiscover.$count( "autoDiscoverGrammar" ) ).toBe( 0 ); + expect( autoDiscover.onMissingMethod( "wrapValue", { "value": "users" } ) ).toBe( "users" ); + expect( autoDiscover.$count( "autoDiscoverGrammar" ) ).toBe( 1 ); + expect( grammar.getShouldWrapValues() ).toBeFalse(); + } ); + + it( "defaults to true in BaseGrammar", function() { + var utils = getMockBox().createMock( "qb.models.Query.QueryUtils" ).init(); + var grammar = getMockBox().createMock( "qb.models.Grammars.PostgresGrammar" ).init( utils ); + + expect( grammar.getShouldWrapValues() ).toBeTrue( "shouldWrapValues should default to true" ); + } ); + + it( "wraps identifiers in double quotes when shouldWrapValues is true", function() { + var utils = getMockBox().createMock( "qb.models.Query.QueryUtils" ).init(); + var grammar = getMockBox().createMock( "qb.models.Grammars.PostgresGrammar" ).init( utils ); + grammar.setShouldWrapValues( true ); + + var builder = getMockBox().createMock( "qb.models.Query.QueryBuilder" ).init( grammar ); + + var sql = builder + .from( "users" ) + .select( "name" ) + .toSQL(); + + expect( sql ).toBe( "SELECT ""name"" FROM ""users""" ); + + sql = builder + .from( "users" ) + .select( "id" ) + .where( "email", "test@test.com" ) + .toSQL( withBindings = true ); + + expect( sql ).toBe( "SELECT ""id"" FROM ""users"" WHERE ""email"" = ?" ); + } ); + + it( "does not wrap identifiers when shouldWrapValues is false", function() { + var utils = getMockBox().createMock( "qb.models.Query.QueryUtils" ).init(); + var grammar = getMockBox().createMock( "qb.models.Grammars.PostgresGrammar" ).init( utils ); + grammar.setShouldWrapValues( false ); + + var builder = getMockBox().createMock( "qb.models.Query.QueryBuilder" ).init( grammar ); + + var sql = builder + .from( "users" ) + .select( "name" ) + .toSQL(); + + expect( sql ).toBe( "SELECT name FROM users" ); + + sql = builder + .from( "users" ) + .select( "id" ) + .where( "email", "test@test.com" ) + .toSQL(); + + expect( sql ).toBe( "SELECT id FROM users WHERE email = ?" ); + } ); + + it( "per-query withoutWrappingValues overrides grammar default", function() { + var utils = getMockBox().createMock( "qb.models.Query.QueryUtils" ).init(); + var grammar = getMockBox().createMock( "qb.models.Grammars.PostgresGrammar" ).init( utils ); + grammar.setShouldWrapValues( true ); + + var builder = getMockBox().createMock( "qb.models.Query.QueryBuilder" ).init( grammar ); + + // Grammar default is true, but per-query override to false + var sql = builder + .withoutWrappingValues() + .from( "users" ) + .select( "name" ) + .toSQL(); + + expect( sql ).toBe( "SELECT name FROM users" ); + } ); + } ); + } + +} From edd1786e195c47d76c118685d59debb604774546 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 13 Aug 2026 09:21:04 -0600 Subject: [PATCH 013/119] test(QueryBuilder): cover unlimited grammar parameter limits --- .../Query/Abstract/QueryExecutionSpec.cfc | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc index 9806a0fa..581fed43 100644 --- a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc +++ b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc @@ -1576,6 +1576,25 @@ component extends="testbox.system.BaseSpec" { ] ); } ); + it( "treats a zero grammar parameter limit as unlimited", function() { + var builder = getBuilder(); + builder.getGrammar().parameterLimit = 0; + + var sql = builder + .from( "users" ) + .insertBulk( + values = [ + { "email": "one@example.com" }, + { "email": "two@example.com" }, + { "email": "three@example.com" } + ], + chunkSize = 100, + toSql = true + ); + + expect( sql ).toBe( [ "INSERT INTO ""users"" (""email"") VALUES (?), (?), (?)" ] ); + } ); + it( "returns an empty array for no values", function() { expect( getBuilder().from( "users" ).insertBulk( values = [], toSql = true ) ).toBe( [] ); } ); From 9973ae730d9e548283cdd1b0d7fb186ee5cadc14 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 13 Aug 2026 09:41:51 -0600 Subject: [PATCH 014/119] fix(QueryBuilder): address v14 regression audit --- models/Grammars/BaseGrammar.cfc | 17 ++++++- models/Grammars/PostgresGrammar.cfc | 19 ++++++++ models/Grammars/SqlServerGrammar.cfc | 12 ++--- models/Query/QueryBuilder.cfc | 21 +++++++-- .../specs/Query/Abstract/BuilderWhereSpec.cfc | 11 +++++ .../Query/Abstract/QueryExecutionSpec.cfc | 20 ++++++++ .../specs/Query/PostgresQueryBuilderSpec.cfc | 2 +- tests/specs/Query/ShouldWrapValuesSpec.cfc | 11 +++++ .../specs/Query/SqlServerQueryBuilderSpec.cfc | 47 +++++++++++++++++++ 9 files changed, 149 insertions(+), 11 deletions(-) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index fabcf7c7..82d8999a 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -449,7 +449,14 @@ component displayname="Grammar" accessors="true" singleton { placeholder = where.value.getSql(); } - return trim( "#wrapColumn( where.column )# #uCase( where.operator )# #placeholder#" ); + var column = where.column.type == "jsonPath" + ? compileJsonScalarComparison( + where.column.value, + isNull( where.value ) ? javacast( "null", "" ) : where.value + ) + : wrapColumn( where.column ); + + return trim( "#column# #uCase( where.operator )# #placeholder#" ); } private string function whereJsonContains( required QueryBuilder query, required struct where ) { @@ -1361,6 +1368,14 @@ component displayname="Grammar" accessors="true" singleton { throw( type = "UnsupportedOperation", message = "This grammar does not support JSON paths" ); } + /** + * Compiles a JSON scalar used in a comparison. Grammars may cast the scalar + * based on the comparison value when their JSON extraction is text-only. + */ + public string function compileJsonScalarComparison( required struct jsonPath, any value ) { + return compileJsonScalar( arguments.jsonPath ); + } + /** * Compiles a JSON containment predicate. */ diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index f7d7fcd7..898deaaa 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -29,6 +29,25 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { return compilePostgresJsonTraversal( arguments.jsonPath, true ); } + public string function compileJsonScalarComparison( required struct jsonPath, any value ) { + var scalar = compileJsonScalar( arguments.jsonPath ); + if ( isNull( arguments.value ) ) { + return scalar; + } + + var sqlType = getUtils().inferSqlType( arguments.value, this ); + if ( listFindNoCase( "TINYINT,SMALLINT,INTEGER,BIGINT,DECIMAL,NUMERIC,REAL,FLOAT,DOUBLE", sqlType ) ) { + return "CAST(#scalar# AS NUMERIC)"; + } + if ( listFindNoCase( "BIT,BOOLEAN,OTHER", sqlType ) ) { + return "CAST(#scalar# AS BOOLEAN)"; + } + if ( listFindNoCase( "DATE,TIME,TIMESTAMP", sqlType ) ) { + return "CAST(#scalar# AS #sqlType#)"; + } + return scalar; + } + public string function compileJsonContains( required struct jsonPath ) { return "(#compilePostgresJsonTraversal( arguments.jsonPath, false )#)::jsonb @> ?::jsonb"; } diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index e7306e42..d7c52243 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -67,7 +67,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { .getReturning() .map( function( column ) { if ( column.type == "raw" ) { - return trim( column.getSQL() ); + return trim( column.value.getSQL() ); } if ( listLen( column.value, "." ) > 1 ) { return column.value; @@ -175,7 +175,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { if ( union.query.getOrders().len() && !isLimitedQuery( union.query ) ) { throw( type = "OrderByNotAllowed", - message = "The ORDER BY clause is not allowed in an unlimited UNION branch.", + message = "The ORDER BY clause is not allowed in a UNION statement.", detail = "SQL Server only allows an ORDER BY clause in a UNION branch when TOP, OFFSET, or FETCH limits that branch." ); } @@ -195,11 +195,11 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } private boolean function shouldCompileOrderedUnionBranches( required QueryBuilder query ) { - if ( arguments.query.getUnions().isEmpty() || !isOrderedLimitedQuery( arguments.query ) ) { + if ( arguments.query.getUnions().isEmpty() ) { return false; } - return arguments.query.getUnions().some( ( union ) => isOrderedLimitedQuery( union.query ) ); + return arguments.query.getUnions().some( ( union ) => union.query.getOrders().len() ); } private boolean function isOrderedLimitedQuery( required QueryBuilder query ) { @@ -285,7 +285,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { .getReturning() .map( function( column ) { if ( column.type == "raw" ) { - return trim( column.getSQL() ); + return trim( column.value.getSQL() ); } if ( listLen( column.value, "." ) > 1 ) { return column.value; @@ -722,7 +722,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { .getReturning() .map( function( column ) { if ( column.type == "raw" ) { - return trim( column.getSQL() ); + return trim( column.value.getSQL() ); } if ( listLen( column.value, "." ) > 1 ) { return column.value; diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 8870119b..01a9d185 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -4961,11 +4961,12 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function newQuery() { - return new qb.models.Query.QueryBuilder( + var query = new qb.models.Query.QueryBuilder( grammar = getGrammar(), utils = getUtils(), returnFormat = getReturnFormat(), returnFormatterRegistry = getReturnFormatterRegistry(), + validateOperatorsAndCombinators = getValidateOperatorsAndCombinators(), paginationCollector = isNull( variables.paginationCollector ) ? javacast( "null", "" ) : variables.paginationCollector, columnFormatter = isNull( getColumnFormatter() ) ? javacast( "null", "" ) : getColumnFormatter(), parentQuery = isNull( getParentQuery() ) ? javacast( "null", "" ) : getParentQuery(), @@ -4974,6 +4975,14 @@ component displayname="QueryBuilder" accessors="true" { validateQueryExecuteReturnType = getValidateQueryExecuteReturnType(), collectQueryLog = getCollectQueryLog() ); + if ( !isNull( getShouldWrapValues() ) ) { + if ( getShouldWrapValues() ) { + query.withWrappingValues(); + } else { + query.withoutWrappingValues(); + } + } + return query; } /** @@ -5103,6 +5112,8 @@ component displayname="QueryBuilder" accessors="true" { public QueryBuilder function setReturnFormat( required any format, struct options = {} ) { if ( isClosure( arguments.format ) || isCustomFunction( arguments.format ) ) { variables.returnFormat = format; + } else if ( isObject( arguments.format ) && structKeyExists( arguments.format, "format" ) ) { + variables.returnFormat = arguments.format; } else { variables.returnFormat = getReturnFormatterRegistry().getReturnFormatter( arguments.format, @@ -5140,8 +5151,12 @@ component displayname="QueryBuilder" accessors="true" { public any function withReturnFormat( required any returnFormat, required any callback, struct options = {} ) { var originalReturnFormat = getReturnFormat(); setReturnFormat( arguments.returnFormat, arguments.options ); - var result = callback(); - setReturnFormat( originalReturnFormat ); + var result = javacast( "null", "" ); + try { + result = callback(); + } finally { + variables.returnFormat = originalReturnFormat; + } return result; } diff --git a/tests/specs/Query/Abstract/BuilderWhereSpec.cfc b/tests/specs/Query/Abstract/BuilderWhereSpec.cfc index 0f918554..b36b3591 100644 --- a/tests/specs/Query/Abstract/BuilderWhereSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderWhereSpec.cfc @@ -186,6 +186,17 @@ component extends="testbox.system.BaseSpec" { } ] ); } ); + + it( "preserves disabled operator validation in nested queries", function() { + var relaxedQB = new qb.models.Query.QueryBuilder( validateOperatorsAndCombinators = false ); + + expect( relaxedQB.newQuery().getValidateOperatorsAndCombinators() ).toBeFalse(); + expect( function() { + relaxedQB.whereExists( function( query ) { + query.from( "users" ).where( "name", "CUSTOM_OPERATOR", "value" ); + } ); + } ).notToThrow(); + } ); } ); } ); } ); diff --git a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc index 581fed43..acbf1338 100644 --- a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc +++ b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc @@ -1342,6 +1342,19 @@ component extends="testbox.system.BaseSpec" { expect( results ).toBe( { "jane": data[ 1 ], "john": data[ 2 ] } ); } ); + it( "restores the return formatter when withReturnFormat throws", function() { + var builder = getBuilder(); + var originalReturnFormat = builder.getReturnFormat(); + + expect( function() { + builder.withReturnFormat( "query", function() { + throw( type = "ExpectedReturnFormatException" ); + } ); + } ).toThrow( type = "ExpectedReturnFormatException" ); + + expect( builder.getReturnFormat() ).toBe( originalReturnFormat ); + } ); + it( "uses the last row when struct return format keys are duplicated", function() { var builder = getBuilder(); builder.setReturnFormat( "struct", { "columnKey": "name" } ); @@ -1423,6 +1436,13 @@ component extends="testbox.system.BaseSpec" { clonedBuilder.setReturnFormat( "firstId" ); } ); + it( "carries a resolved component return formatter to new queries and clones", function() { + var builder = getBuilder().setReturnFormat( "struct", { "columnKey": "id" } ); + + expect( builder.newQuery().getReturnFormat() ).toBe( builder.getReturnFormat() ); + expect( builder.clone().getReturnFormat() ).toBe( builder.getReturnFormat() ); + } ); + it( "creates a default return formatter registry when none is passed", function() { var builder = getBuilder(); builder.setReturnFormat( "none" ); diff --git a/tests/specs/Query/PostgresQueryBuilderSpec.cfc b/tests/specs/Query/PostgresQueryBuilderSpec.cfc index 46bb8043..d35e3fc2 100644 --- a/tests/specs/Query/PostgresQueryBuilderSpec.cfc +++ b/tests/specs/Query/PostgresQueryBuilderSpec.cfc @@ -1283,7 +1283,7 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { function jsonScalarWhere() { return { - sql: "SELECT * FROM ""users"" WHERE ""profile""->>'age' >= ? AND ""profile""->>'age' < ?", + sql: "SELECT * FROM ""users"" WHERE CAST(""profile""->>'age' AS NUMERIC) >= ? AND CAST(""profile""->>'age' AS NUMERIC) < ?", bindings: [ 21, 65 ] }; } diff --git a/tests/specs/Query/ShouldWrapValuesSpec.cfc b/tests/specs/Query/ShouldWrapValuesSpec.cfc index eed37abe..6efeba99 100644 --- a/tests/specs/Query/ShouldWrapValuesSpec.cfc +++ b/tests/specs/Query/ShouldWrapValuesSpec.cfc @@ -85,6 +85,17 @@ component extends="testbox.system.BaseSpec" { expect( sql ).toBe( "SELECT name FROM users" ); } ); + + it( "preserves per-query wrapping overrides in new queries and clones", function() { + var grammar = new qb.models.Grammars.PostgresGrammar(); + var builder = new qb.models.Query.QueryBuilder( grammar ) + .withoutWrappingValues() + .select( "id" ) + .from( "users" ); + + expect( builder.newQuery().getShouldWrapValues() ).toBeFalse(); + expect( builder.clone().toSQL() ).toBe( "SELECT id FROM users" ); + } ); } ); } diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index 12454513..e665a1a6 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -42,6 +42,36 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { "INSERT INTO [users] ([name]) OUTPUT INSERTED.[id] SELECT [name] FROM OPENJSON(?) WITH ([name] NVARCHAR(MAX) '$.""name""')" ] ); } ); + + it( "applies raw returning expressions to bulk inserts", function() { + var sql = getBuilder() + .from( "users" ) + .returningRaw( "INSERTED.id AS insertedId" ) + .insertBulk( values = [ { "name": "One" } ], toSql = true ); + + expect( sql ).toBe( [ + "INSERT INTO [users] ([name]) OUTPUT INSERTED.id AS insertedId SELECT [name] FROM OPENJSON(?) WITH ([name] NVARCHAR(MAX) '$.""name""')" + ] ); + } ); + + it( "applies raw returning expressions to regular inserts and upserts", function() { + var insertSql = getBuilder() + .from( "users" ) + .returningRaw( "INSERTED.id AS insertedId" ) + .insert( values = { "name": "One" }, toSql = true ); + var upsertSql = getBuilder() + .from( "users" ) + .returningRaw( "INSERTED.id AS insertedId" ) + .upsert( + values = [ { "id": 1, "name": "One" } ], + target = [ "id" ], + update = [ "name" ], + toSql = true + ); + + expect( insertSql ).toInclude( "OUTPUT INSERTED.id AS insertedId" ); + expect( upsertSql ).toInclude( "OUTPUT INSERTED.id AS insertedId" ); + } ); } ); describe( "SQL Server ordered unions", function() { @@ -70,6 +100,23 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { "SELECT TOP (5) * FROM (SELECT * FROM (SELECT TOP (5) [id], [name], [modifiedDate], 'Page' AS typeName FROM [page] ORDER BY [modifiedDate] DESC) AS [qb_union_0] UNION ALL SELECT * FROM (SELECT TOP (5) [id], [name], [modifiedDate], 'Document' AS typeName FROM [document] ORDER BY [modifiedDate] DESC) AS [qb_union_1]) AS [t] ORDER BY [modifiedDate] DESC" ); } ); + + it( "can limit an ordered union branch without ordering the root branch", function() { + var sql = getBuilder() + .select( "id" ) + .from( "users" ) + .unionAll( function( q ) { + q.select( "id" ) + .from( "archivedUsers" ) + .orderByDesc( "id" ) + .limit( 5 ); + } ) + .toSQL(); + + expect( sql ).toBe( + "SELECT * FROM (SELECT [id] FROM [users]) AS [qb_union_0] UNION ALL SELECT * FROM (SELECT TOP (5) [id] FROM [archivedUsers] ORDER BY [id] DESC) AS [qb_union_1]" + ); + } ); } ); } From 554ca20c304eb6c7838a65798ab9680c2364bf94 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 13 Aug 2026 10:18:58 -0600 Subject: [PATCH 015/119] fix: resolve query and schema regression audit --- models/Grammars/BaseGrammar.cfc | 4 +- models/Grammars/MySQLGrammar.cfc | 7 +- models/Grammars/OracleGrammar.cfc | 12 +++ models/Grammars/PostgresGrammar.cfc | 7 +- models/Grammars/SqlServerGrammar.cfc | 3 + models/Query/QueryBuilder.cfc | 56 +++++++++---- models/Query/QueryUtils.cfc | 11 +++ models/Schema/SchemaBuilder.cfc | 2 +- tests/resources/AbstractQueryBuilderSpec.cfc | 28 +++++++ tests/resources/AbstractSchemaBuilderSpec.cfc | 12 +++ .../Query/Abstract/QueryExecutionSpec.cfc | 84 +++++++++++++++++++ tests/specs/Query/DerbyQueryBuilderSpec.cfc | 8 ++ tests/specs/Query/MySQLQueryBuilderSpec.cfc | 11 +++ tests/specs/Query/OracleQueryBuilderSpec.cfc | 11 +++ .../specs/Query/PostgresQueryBuilderSpec.cfc | 11 +++ tests/specs/Query/SQLiteQueryBuilderSpec.cfc | 11 +++ .../specs/Query/SqlServerQueryBuilderSpec.cfc | 11 +++ tests/specs/Schema/MySQLSchemaBuilderSpec.cfc | 18 ++++ 18 files changed, 285 insertions(+), 22 deletions(-) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 82d8999a..48e802aa 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -1401,7 +1401,7 @@ component displayname="Grammar" accessors="true" singleton { * Allows grammars to serialize containment bindings where required. */ public any function prepareJsonContainsBinding( required any value ) { - if ( !isSimpleValue( arguments.value ) ) { + if ( !isNull( arguments.value ) && !isSimpleValue( arguments.value ) ) { throw( type = "UnsupportedOperation", message = "This grammar only supports scalar JSON containment values" @@ -1423,7 +1423,7 @@ component displayname="Grammar" accessors="true" singleton { public string function buildJsonPath( required array path ) { var compiledPath = "$"; for ( var segment in arguments.path ) { - if ( isNumeric( segment ) ) { + if ( getUtils().isActuallyNumeric( segment ) ) { compiledPath &= "[#segment#]"; } else { var escapedSegment = replace( diff --git a/models/Grammars/MySQLGrammar.cfc b/models/Grammars/MySQLGrammar.cfc index baaea067..9c5ccc3a 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -88,11 +88,16 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ); } + var destinationTable = arguments.commandParameters.to; + if ( listLen( destinationTable, "." ) == 1 && listLen( blueprint.getTable(), "." ) > 1 ) { + destinationTable = listDeleteAt( blueprint.getTable(), listLen( blueprint.getTable(), "." ), "." ) & "." & destinationTable; + } + return concatenate( [ "RENAME TABLE", wrapTable( blueprint.getTable() ), "TO", - wrapTable( commandParameters.to ) + wrapTable( destinationTable ) ] ); } finally { if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { diff --git a/models/Grammars/OracleGrammar.cfc b/models/Grammars/OracleGrammar.cfc index 9692e244..6bdcc753 100644 --- a/models/Grammars/OracleGrammar.cfc +++ b/models/Grammars/OracleGrammar.cfc @@ -40,6 +40,18 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { return "JSON_EXISTS(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#[*]?(@ == $value)' PASSING ? AS ""value"")"; } + public any function prepareJsonContainsBinding( required any value ) { + if ( isNull( arguments.value ) ) { + return { + "value": "", + "null": true, + "cfsqltype": "NUMERIC", + "sqltype": "NUMERIC" + }; + } + return super.prepareJsonContainsBinding( arguments.value ); + } + public string function compileJsonExists( required struct jsonPath ) { return "JSON_EXISTS(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#')"; } diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index 898deaaa..75e97116 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -70,7 +70,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { var pathLength = arguments.jsonPath.path.len(); arguments.jsonPath.path.each( function( segment, index ) { var operator = scalarExtraction && index == pathLength ? "->>" : "->"; - var pathSegment = isNumeric( segment ) ? segment : "'" & replace( segment, "'", "''", "all" ) & "'"; + var pathSegment = getUtils().isActuallyNumeric( segment ) ? segment : "'" & replace( + segment, + "'", + "''", + "all" + ) & "'"; sql &= operator & pathSegment; } ); return sql; diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index d7c52243..560f5e40 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -126,6 +126,9 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } public string function compileJsonContains( required struct jsonPath ) { + if ( arguments.jsonPath.keyExists( "nullValue" ) && arguments.jsonPath.nullValue ) { + return "EXISTS (SELECT 1 FROM OPENJSON(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#') WHERE [type] = 0 AND ? IS NULL)"; + } return "? IN (SELECT [value] FROM OPENJSON(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#'))"; } diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 01a9d185..9cea0bac 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -640,7 +640,7 @@ component displayname="QueryBuilder" accessors="true" { parsedColumn = trim( arrowParts.shift() ); arguments.path = arrowParts.map( ( segment ) => normalizeJsonPathSegment( segment ) ); } else { - arguments.path = arguments.path.map( ( segment ) => normalizeJsonPathSegment( segment ) ); + arguments.path = arguments.path.map( ( segment ) => segment ); } var definition = { @@ -654,11 +654,12 @@ component displayname="QueryBuilder" accessors="true" { } /** - * Normalizes a JSON path segment for grammar compilation. - * Numeric segments are converted to numbers so grammars can distinguish - * JSON array indexes from object keys. + * Normalizes an arrow-syntax JSON path segment for grammar compilation. + * Numeric shortcut segments are converted to numbers so grammars can + * distinguish JSON array indexes from object keys. Explicit path segments + * preserve their CFML types and do not pass through this function. * - * @segment The JSON object key or array index to normalize. + * @segment The shortcut JSON object key or array index to normalize. * * @return The trimmed object key or numeric array index. */ @@ -2133,13 +2134,15 @@ component displayname="QueryBuilder" accessors="true" { if ( this.getValidateOperatorsAndCombinators() && isInvalidCombinator( arguments.combinator ) ) { throw( type = "InvalidSQLType", message = "Illegal combinator" ); } - if ( isNull( arguments.value ) ) { + if ( !arguments.keyExists( "value" ) ) { arguments.value = arguments.path; arguments.path = []; } + var containsPath = jsonPath( column = arguments.column, path = arguments.path ); + containsPath.value.nullValue = isNull( arguments.value ); variables.wheres.append( { type: "jsonContains", - path: jsonPath( column = arguments.column, path = arguments.path ), + path: containsPath, combinator: arguments.combinator, negate: arguments.negate } ); @@ -4583,8 +4586,12 @@ component displayname="QueryBuilder" accessors="true" { if ( !isNull( arguments.columns ) ) { select( arguments.columns ); } - var result = run( sql = this.toSql(), options = arguments.options ); - select( originalColumns ); + var result = javacast( "null", "" ); + try { + result = run( sql = this.toSql(), options = arguments.options ); + } finally { + select( originalColumns ); + } return isNull( result ) ? javacast( "null", "" ) : result; } @@ -4966,11 +4973,14 @@ component displayname="QueryBuilder" accessors="true" { utils = getUtils(), returnFormat = getReturnFormat(), returnFormatterRegistry = getReturnFormatterRegistry(), + preventDuplicateJoins = getPreventDuplicateJoins(), validateOperatorsAndCombinators = getValidateOperatorsAndCombinators(), paginationCollector = isNull( variables.paginationCollector ) ? javacast( "null", "" ) : variables.paginationCollector, columnFormatter = isNull( getColumnFormatter() ) ? javacast( "null", "" ) : getColumnFormatter(), parentQuery = isNull( getParentQuery() ) ? javacast( "null", "" ) : getParentQuery(), defaultOptions = getDefaultOptions(), + sqlCommenter = getSqlCommenter(), + shouldMaxRowsOverrideToAll = getShouldMaxRowsOverrideToAll(), validateDuplicateSelectColumns = getValidateDuplicateSelectColumns(), validateQueryExecuteReturnType = getValidateQueryExecuteReturnType(), collectQueryLog = getCollectQueryLog() @@ -5112,7 +5122,10 @@ component displayname="QueryBuilder" accessors="true" { public QueryBuilder function setReturnFormat( required any format, struct options = {} ) { if ( isClosure( arguments.format ) || isCustomFunction( arguments.format ) ) { variables.returnFormat = format; - } else if ( isObject( arguments.format ) && structKeyExists( arguments.format, "format" ) ) { + } else if ( + ( isStruct( arguments.format ) || isObject( arguments.format ) ) && + structKeyExists( arguments.format, "format" ) + ) { variables.returnFormat = arguments.format; } else { variables.returnFormat = getReturnFormatterRegistry().getReturnFormatter( @@ -5187,13 +5200,18 @@ component displayname="QueryBuilder" accessors="true" { */ private any function withColumns( required any columns, required any callback ) { var originalColumns = [ { "type": "simple", "value": "*" } ]; - if ( getUnions().isEmpty() ) { + var shouldRestoreColumns = getUnions().isEmpty(); + if ( shouldRestoreColumns ) { originalColumns = getColumns(); select( arguments.columns ); } - var result = callback(); - if ( getUnions().isEmpty() ) { - select( originalColumns ); + var result = javacast( "null", "" ); + try { + result = callback(); + } finally { + if ( shouldRestoreColumns ) { + select( originalColumns ); + } } return result; } @@ -5211,9 +5229,13 @@ component displayname="QueryBuilder" accessors="true" { var originalOrders = getOrders(); setAggregate( arguments.aggregate ); setOrders( [] ); - var result = callback(); - setAggregate( originalAggregate ); - setOrders( originalOrders ); + var result = javacast( "null", "" ); + try { + result = callback(); + } finally { + setAggregate( originalAggregate ); + setOrders( originalOrders ); + } return result; } diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 769f2858..b02760bd 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -512,6 +512,17 @@ component singleton displayname="QueryUtils" accessors="true" { return initial; } + /** + * Detects if a value is backed by a numeric type instead of a numeric string. + * + * @value The value to inspect. + * + * @return True when the value is backed by a numeric type. + */ + public boolean function isActuallyNumeric( any value ) { + return checkIsActuallyNumeric( arguments.value ); + } + /** * Detects if value is numeric based on className * diff --git a/models/Schema/SchemaBuilder.cfc b/models/Schema/SchemaBuilder.cfc index 3e71f3a7..cce40fd5 100644 --- a/models/Schema/SchemaBuilder.cfc +++ b/models/Schema/SchemaBuilder.cfc @@ -243,7 +243,7 @@ component accessors="true" { .each( function( statement ) { getGrammar().runQuery( statement, - query.getBindings(), + [], options, "result", variables.pretending, diff --git a/tests/resources/AbstractQueryBuilderSpec.cfc b/tests/resources/AbstractQueryBuilderSpec.cfc index d3185ebb..7b6d30f3 100644 --- a/tests/resources/AbstractQueryBuilderSpec.cfc +++ b/tests/resources/AbstractQueryBuilderSpec.cfc @@ -2609,6 +2609,34 @@ component extends="testbox.system.BaseSpec" { }, jsonCompoundContains() ); } ); + it( + title = "preserves explicit paths when checking containment for JSON null", + body = function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .whereJsonContains( + column = "profile", + path = [ "languages" ], + value = javacast( "null", "" ) + ) + .whereJsonContains( "profile->languages", javacast( "null", "" ) ); + }, jsonNullContains() ); + }, + skip = function() { + var fullNull = createObject( "java", "java.lang.System" ).getEnv( "FULL_NULL" ); + return isNull( fullNull ) || !fullNull; + } + ); + + it( "distinguishes explicit numeric object keys from shortcut array indexes", function() { + testCase( function( builder ) { + return builder + .select( [ builder.jsonPath( "profile", [ "0" ], "explicitKey" ), "profile->0 AS shortcutIndex" ] ) + .from( "users" ); + }, jsonNumericObjectKey() ); + } ); + it( "supports JSON boolean and negative convenience methods", function() { testCase( function( builder ) { return builder diff --git a/tests/resources/AbstractSchemaBuilderSpec.cfc b/tests/resources/AbstractSchemaBuilderSpec.cfc index b1aa5ac0..8490ac39 100644 --- a/tests/resources/AbstractSchemaBuilderSpec.cfc +++ b/tests/resources/AbstractSchemaBuilderSpec.cfc @@ -1813,6 +1813,18 @@ component extends="testbox.system.BaseSpec" { return schema.dropView( "active_users", {}, false ); }, dropView() ); } ); + + it( "can execute a drop view statement", function() { + var schema = getBuilder(); + schema.getGrammar().$( "runQuery", {} ); + + schema.dropView( "active_users" ); + + var runQueryLog = schema.getGrammar().$callLog().runQuery; + expect( runQueryLog ).toHaveLength( 1 ); + expect( runQueryLog[ 1 ][ 1 ] ).toBeWithCase( dropView()[ 1 ] ); + expect( runQueryLog[ 1 ][ 2 ] ).toBe( [] ); + } ); } ); describe( "create table as and select into", function() { diff --git a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc index acbf1338..3687d1a1 100644 --- a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc +++ b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc @@ -92,6 +92,22 @@ component extends="testbox.system.BaseSpec" { builder.get( "name" ); expect( builder.getColumns().map( ( c ) => c.value ) ).toBe( [ "id" ] ); } ); + + it( "preserves original columns when executing a get with columns throws", function() { + var builder = getMockBox() + .createMock( "qb.models.Query.QueryBuilder" ) + .init( + grammar = getMockBox().createMock( "qb.models.Grammars.BaseGrammar" ).init(), + validateQueryExecuteReturnType = true + ); + builder.select( "id" ).from( "users" ); + + expect( function() { + builder.get( columns = "name", options = { "returntype": "array" } ); + } ).toThrow( type = "InvalidQueryExecuteOption" ); + + expect( builder.getColumns().map( ( column ) => column.value ) ).toBe( [ "id" ] ); + } ); } ); describe( "first", function() { @@ -789,6 +805,27 @@ component extends="testbox.system.BaseSpec" { expect( builder.getAggregate() ).toBeEmpty( "Aggregate should have been cleared after running" ); } ); + it( "restores aggregate query state when execution throws", function() { + var builder = getMockBox() + .createMock( "qb.models.Query.QueryBuilder" ) + .init( + grammar = getMockBox().createMock( "qb.models.Grammars.BaseGrammar" ).init(), + validateQueryExecuteReturnType = true + ); + builder + .select( "id" ) + .from( "users" ) + .orderBy( "name" ); + + expect( function() { + builder.count( options = { "returntype": "array" } ); + } ).toThrow( type = "InvalidQueryExecuteOption" ); + + expect( builder.getAggregate() ).toBeEmpty(); + expect( builder.getColumns().map( ( column ) => column.value ) ).toBe( [ "id" ] ); + expect( builder.getOrders() ).toBe( [ { "column": { "type": "simple", "value": "name" }, "direction": "asc" } ] ); + } ); + it( "correctly orders a distinct count", function() { var builder = getBuilder(); var expectedCount = 1; @@ -1436,6 +1473,53 @@ component extends="testbox.system.BaseSpec" { clonedBuilder.setReturnFormat( "firstId" ); } ); + it( "carries behavioral settings to new queries and clones", function() { + var sqlCommenter = { + "appendSqlComments": function( sql ) { + return sql; + } + }; + var shouldMaxRowsOverrideToAll = function( maxRows ) { + return maxRows == 99; + }; + var builder = getMockBox() + .createMock( "qb.models.Query.QueryBuilder" ) + .init( + grammar = getMockBox().createMock( "qb.models.Grammars.BaseGrammar" ).init(), + preventDuplicateJoins = true, + sqlCommenter = sqlCommenter, + shouldMaxRowsOverrideToAll = shouldMaxRowsOverrideToAll + ); + + [ builder.newQuery(), builder.clone() ].each( function( derivedBuilder ) { + expect( derivedBuilder.getPreventDuplicateJoins() ).toBeTrue(); + $assert.isSameInstance( sqlCommenter, derivedBuilder.getSqlCommenter() ); + $assert.isSameInstance( shouldMaxRowsOverrideToAll, derivedBuilder.getShouldMaxRowsOverrideToAll() ); + } ); + } ); + + it( "carries a resolved struct return formatter to new queries and clones", function() { + var registry = new qb.models.Query.ReturnFormatterRegistry(); + registry.registerReturnFormatter( "structFormatter", function() { + return { + "format": function( q ) { + return q; + } + }; + } ); + var builder = getMockBox() + .createMock( "qb.models.Query.QueryBuilder" ) + .init( + grammar = getMockBox().createMock( "qb.models.Grammars.BaseGrammar" ).init(), + returnFormatterRegistry = registry + ) + .setReturnFormat( "structFormatter" ); + + var returnFormat = builder.getReturnFormat(); + $assert.isSameInstance( returnFormat, builder.newQuery().getReturnFormat() ); + $assert.isSameInstance( returnFormat, builder.clone().getReturnFormat() ); + } ); + it( "carries a resolved component return formatter to new queries and clones", function() { var builder = getBuilder().setReturnFormat( "struct", { "columnKey": "id" } ); diff --git a/tests/specs/Query/DerbyQueryBuilderSpec.cfc b/tests/specs/Query/DerbyQueryBuilderSpec.cfc index b3e8032b..11283a6e 100644 --- a/tests/specs/Query/DerbyQueryBuilderSpec.cfc +++ b/tests/specs/Query/DerbyQueryBuilderSpec.cfc @@ -1252,6 +1252,14 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { exception: "UnsupportedOperation" }; } + function jsonNullContains() { + return { exception: "UnsupportedOperation" }; + } + + function jsonNumericObjectKey() { + return { exception: "UnsupportedOperation" }; + } + function jsonConveniencePredicates() { return { exception: "UnsupportedOperation" }; } diff --git a/tests/specs/Query/MySQLQueryBuilderSpec.cfc b/tests/specs/Query/MySQLQueryBuilderSpec.cfc index 5f6b20a7..7ff7db50 100644 --- a/tests/specs/Query/MySQLQueryBuilderSpec.cfc +++ b/tests/specs/Query/MySQLQueryBuilderSpec.cfc @@ -1287,6 +1287,17 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function jsonNullContains() { + return { + sql: "SELECT * FROM `users` WHERE JSON_CONTAINS(`profile`, ?, '$.""languages""') AND JSON_CONTAINS(`profile`, ?, '$.""languages""')", + bindings: [ "null", "null" ] + }; + } + + function jsonNumericObjectKey() { + return "SELECT JSON_UNQUOTE(JSON_EXTRACT(`profile`, '$.""0""')) AS `explicitKey`, JSON_UNQUOTE(JSON_EXTRACT(`profile`, '$[0]')) AS `shortcutIndex` FROM `users`"; + } + function jsonConveniencePredicates() { return { sql: "SELECT * FROM `users` WHERE NOT (JSON_CONTAINS(`profile`, ?, '$.""languages""')) OR NOT (JSON_CONTAINS(`profile`, ?, '$.""languages""')) OR JSON_CONTAINS(`profile`, ?, '$.""languages""') AND NOT (IFNULL(JSON_CONTAINS_PATH(`profile`, 'one', '$.""nickname""'), 0)) OR IFNULL(JSON_CONTAINS_PATH(`profile`, 'one', '$.""name""'), 0) OR NOT (IFNULL(JSON_CONTAINS_PATH(`profile`, 'one', '$.""timezone""'), 0)) OR JSON_LENGTH(`profile`, '$.""languages""') > ?", diff --git a/tests/specs/Query/OracleQueryBuilderSpec.cfc b/tests/specs/Query/OracleQueryBuilderSpec.cfc index c230ce0f..f264ec67 100644 --- a/tests/specs/Query/OracleQueryBuilderSpec.cfc +++ b/tests/specs/Query/OracleQueryBuilderSpec.cfc @@ -1310,6 +1310,17 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { exception: "UnsupportedOperation" }; } + function jsonNullContains() { + return { + sql: "SELECT * FROM ""USERS"" WHERE JSON_EXISTS(""PROFILE"", '$.""languages""[*]?(@ == $value)' PASSING ? AS ""value"") AND JSON_EXISTS(""PROFILE"", '$.""languages""[*]?(@ == $value)' PASSING ? AS ""value"")", + bindings: [ "NULL", "NULL" ] + }; + } + + function jsonNumericObjectKey() { + return "SELECT JSON_VALUE(""PROFILE"", '$.""0""') AS ""EXPLICITKEY"", JSON_VALUE(""PROFILE"", '$[0]') AS ""SHORTCUTINDEX"" FROM ""USERS"""; + } + function jsonConveniencePredicates() { return { sql: "SELECT * FROM ""USERS"" WHERE NOT (JSON_EXISTS(""PROFILE"", '$.""languages""[*]?(@ == $value)' PASSING ? AS ""value"")) OR NOT (JSON_EXISTS(""PROFILE"", '$.""languages""[*]?(@ == $value)' PASSING ? AS ""value"")) OR JSON_EXISTS(""PROFILE"", '$.""languages""[*]?(@ == $value)' PASSING ? AS ""value"") AND NOT (JSON_EXISTS(""PROFILE"", '$.""nickname""')) OR JSON_EXISTS(""PROFILE"", '$.""name""') OR NOT (JSON_EXISTS(""PROFILE"", '$.""timezone""')) OR JSON_VALUE(""PROFILE"", '$.""languages"".size()' RETURNING NUMBER) > ?", diff --git a/tests/specs/Query/PostgresQueryBuilderSpec.cfc b/tests/specs/Query/PostgresQueryBuilderSpec.cfc index d35e3fc2..915c8ea6 100644 --- a/tests/specs/Query/PostgresQueryBuilderSpec.cfc +++ b/tests/specs/Query/PostgresQueryBuilderSpec.cfc @@ -1320,6 +1320,17 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function jsonNullContains() { + return { + sql: "SELECT * FROM ""users"" WHERE (""profile""->'languages')::jsonb @> ?::jsonb AND (""profile""->'languages')::jsonb @> ?::jsonb", + bindings: [ "null", "null" ] + }; + } + + function jsonNumericObjectKey() { + return "SELECT ""profile""->>'0' AS ""explicitKey"", ""profile""->>0 AS ""shortcutIndex"" FROM ""users"""; + } + function jsonConveniencePredicates() { return { sql: "SELECT * FROM ""users"" WHERE NOT ((""profile""->'languages')::jsonb @> ?::jsonb) OR NOT ((""profile""->'languages')::jsonb @> ?::jsonb) OR (""profile""->'languages')::jsonb @> ?::jsonb AND NOT (""profile""->'nickname' IS NOT NULL) OR ""profile""->'name' IS NOT NULL OR NOT (""profile""->'timezone' IS NOT NULL) OR JSONB_ARRAY_LENGTH((""profile""->'languages')::jsonb) > ?", diff --git a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc index 4cd1e4a8..94c319d4 100644 --- a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc +++ b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc @@ -1321,6 +1321,17 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { exception: "UnsupportedOperation" }; } + function jsonNullContains() { + return { + sql: "SELECT * FROM ""users"" WHERE EXISTS (SELECT 1 FROM JSON_EACH(""profile"", '$.""languages""') WHERE ""json_each"".""value"" IS ?) AND EXISTS (SELECT 1 FROM JSON_EACH(""profile"", '$.""languages""') WHERE ""json_each"".""value"" IS ?)", + bindings: [ "NULL", "NULL" ] + }; + } + + function jsonNumericObjectKey() { + return "SELECT JSON_EXTRACT(""profile"", '$.""0""') AS ""explicitKey"", JSON_EXTRACT(""profile"", '$[0]') AS ""shortcutIndex"" FROM ""users"""; + } + function jsonConveniencePredicates() { return { sql: "SELECT * FROM ""users"" WHERE NOT (EXISTS (SELECT 1 FROM JSON_EACH(""profile"", '$.""languages""') WHERE ""json_each"".""value"" IS ?)) OR NOT (EXISTS (SELECT 1 FROM JSON_EACH(""profile"", '$.""languages""') WHERE ""json_each"".""value"" IS ?)) OR EXISTS (SELECT 1 FROM JSON_EACH(""profile"", '$.""languages""') WHERE ""json_each"".""value"" IS ?) AND NOT (JSON_TYPE(""profile"", '$.""nickname""') IS NOT NULL) OR JSON_TYPE(""profile"", '$.""name""') IS NOT NULL OR NOT (JSON_TYPE(""profile"", '$.""timezone""') IS NOT NULL) OR JSON_ARRAY_LENGTH(""profile"", '$.""languages""') > ?", diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index e665a1a6..65073443 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -1464,6 +1464,17 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { exception: "UnsupportedOperation" }; } + function jsonNullContains() { + return { + sql: "SELECT * FROM [users] WHERE EXISTS (SELECT 1 FROM OPENJSON([profile], '$.""languages""') WHERE [type] = 0 AND ? IS NULL) AND EXISTS (SELECT 1 FROM OPENJSON([profile], '$.""languages""') WHERE [type] = 0 AND ? IS NULL)", + bindings: [ "NULL", "NULL" ] + }; + } + + function jsonNumericObjectKey() { + return "SELECT JSON_VALUE([profile], '$.""0""') AS [explicitKey], JSON_VALUE([profile], '$[0]') AS [shortcutIndex] FROM [users]"; + } + function jsonConveniencePredicates() { return { sql: "SELECT * FROM [users] WHERE NOT (? IN (SELECT [value] FROM OPENJSON([profile], '$.""languages""'))) OR NOT (? IN (SELECT [value] FROM OPENJSON([profile], '$.""languages""'))) OR ? IN (SELECT [value] FROM OPENJSON([profile], '$.""languages""')) AND NOT ('nickname' IN (SELECT [key] FROM OPENJSON([profile]))) OR 'name' IN (SELECT [key] FROM OPENJSON([profile])) OR NOT ('timezone' IN (SELECT [key] FROM OPENJSON([profile]))) OR (SELECT COUNT(*) FROM OPENJSON([profile], '$.""languages""')) > ?", diff --git a/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc b/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc index e3236b6f..83c02e3e 100644 --- a/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc @@ -1,5 +1,23 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { + function run() { + super.run(); + + describe( "MySQL Grammar-specific tests", function() { + it( "keeps renamed tables in the configured default schema", function() { + var schema = getBuilder().setDefaultSchema( "app" ); + + expect( schema.rename( "users", "accounts", {}, false ).toSql() ).toBe( [ "RENAME TABLE `app`.`users` TO `app`.`accounts`" ] ); + } ); + + it( "keeps renamed tables in an explicitly configured schema", function() { + var schema = getBuilder().setDefaultSchema( "app" ); + + expect( schema.rename( "audit.users", "accounts", {}, false ).toSql() ).toBe( [ "RENAME TABLE `audit`.`users` TO `audit`.`accounts`" ] ); + } ); + } ); + } + function emptyTable() { return [ "CREATE TABLE `users` ()" ]; } From c6df4faa525c0fb6c35781ebe01cd640e496aece Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 13 Aug 2026 11:25:43 -0600 Subject: [PATCH 016/119] fix: isolate query state and full-null behavior --- .github/workflows/cron.yml | 2 + .github/workflows/pr.yml | 2 + .github/workflows/release.yml | 4 +- models/Grammars/BaseGrammar.cfc | 19 +-- models/Grammars/DerbyGrammar.cfc | 2 +- models/Query/QueryBuilder.cfc | 124 +++++++++++++----- tests/specs/Query/Abstract/PaginationSpec.cfc | 14 ++ .../Query/Abstract/QueryExecutionSpec.cfc | 12 ++ tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 74 +++++++++++ 9 files changed, 211 insertions(+), 42 deletions(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 6cd65a6c..80de344f 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -40,6 +40,8 @@ jobs: box install - name: Start server + env: + FULL_NULL: ${{matrix.fullNull}} run: | box server start serverConfigFile="server-${{ matrix.cfengine }}.json" --noSaveSettings --debug curl http://127.0.0.1:60298 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0b4999a8..9e9c6e95 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -48,6 +48,8 @@ jobs: box install - name: Start server + env: + FULL_NULL: ${{matrix.fullNull}} run: | box server start serverConfigFile="server-${{ matrix.cfengine }}.json" --noSaveSettings --debug curl http://127.0.0.1:60298 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea7d51dc..dd40ef1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,8 @@ jobs: box install - name: Start server + env: + FULL_NULL: ${{matrix.fullNull}} run: | box server start serverConfigFile="server-${{ matrix.cfengine }}.json" --noSaveSettings --debug curl http://127.0.0.1:60298 @@ -104,4 +106,4 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_ACCESS_SECRET }} SOURCE_DIR: ".tmp/apidocs" - DEST_DIR: "${{ github.repository }}/${{ steps.current_version.outputs.version }}" \ No newline at end of file + DEST_DIR: "${{ github.repository }}/${{ steps.current_version.outputs.version }}" diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 48e802aa..551822ea 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -186,11 +186,21 @@ component displayname="Grammar" accessors="true" singleton { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } + var queryToCompile = arguments.query; + if ( !queryToCompile.getAggregate().isEmpty() && !queryToCompile.getUnions().isEmpty() ) { + var aggregate = queryToCompile.getAggregate(); + var unionQuery = queryToCompile.clone().setAggregate( {} ); + queryToCompile = queryToCompile + .newQuery() + .setAggregate( aggregate ) + .fromSub( "qb_aggregate_source", unionQuery ); + } + var sql = []; for ( var component in selectComponents ) { var func = variables[ "compile#component#" ]; - var args = { "query": query, "#component#": invoke( query, "get" & component ) }; + var args = { "query": queryToCompile, "#component#": invoke( queryToCompile, "get" & component ) }; arrayAppend( sql, func( argumentCollection = args ) ); } @@ -1201,13 +1211,6 @@ component displayname="Grammar" accessors="true" singleton { aggString = "COALESCE(#aggString#, #aggregate.defaultValue#)"; } - if ( !query.getUnions().isEmpty() ) { - var clonedQuery = query.clone().setAggregate( {} ); - query.reset(); - query.setAggregate( arguments.aggregate ); - query.fromSub( "qb_aggregate_source", clonedQuery ); - } - return "SELECT #aggString# AS ""aggregate"""; } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { diff --git a/models/Grammars/DerbyGrammar.cfc b/models/Grammars/DerbyGrammar.cfc index a3af34fa..1789696d 100644 --- a/models/Grammars/DerbyGrammar.cfc +++ b/models/Grammars/DerbyGrammar.cfc @@ -336,7 +336,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { */ private string function compileOffsetValue( required query, offsetValue ) { if ( !isNull( arguments.query.getLimitValue() ) && isNull( arguments.offsetValue ) ) { - param arguments.offsetValue = 0; + return "OFFSET 0 ROWS"; } else if ( isNull( arguments.offsetValue ) ) { return ""; } diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 9cea0bac..397158e5 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -402,6 +402,7 @@ component displayname="QueryBuilder" accessors="true" { variables.aggregate = {}; variables.columns = [ { "type": "simple", "value": "*" } ]; variables.tableName = ""; + variables.forClause = javacast( "null", "" ); variables.alias = ""; variables.lockType = "none"; variables.lockValue = ""; @@ -1961,8 +1962,8 @@ component displayname="QueryBuilder" accessors="true" { if ( variables.commonTables.some( function( cT, index ) { return ( - !getUtils().arrayCompare( cT[ "COLUMNS" ], otherQB.getCommonTables()[ "index" ][ "COLUMNS" ] ) || - cT[ "NAME" ] != otherQB.getCommonTables()[ "index" ][ "NAME" ] || + !getUtils().arrayCompare( cT[ "COLUMNS" ], otherQB.getCommonTables()[ index ][ "COLUMNS" ] ) || + !getUtils().structCompare( cT[ "NAME" ], otherQB.getCommonTables()[ index ][ "NAME" ] ) || !cT[ "QUERY" ].isEqualTo( otherQB.getCommonTables()[ index ][ "QUERY" ] ) ); } ) @@ -3485,11 +3486,8 @@ component displayname="QueryBuilder" accessors="true" { */ private numeric function getCountForPagination( struct options = {} ) { if ( !variables.groups.isEmpty() || !variables.havings.isEmpty() || variables.distinct ) { - var originalOrders = this.getOrders(); - this.setOrders( [] ); - var count = newQuery().fromSub( "aggregate_table", this ).count( options = arguments.options ); - this.setOrders( originalOrders ); - return count; + var countSource = clone().setOrders( [] ); + return newQuery().fromSub( "aggregate_table", countSource ).count( options = arguments.options ); } return count( options = arguments.options ); } @@ -4516,15 +4514,13 @@ component displayname="QueryBuilder" accessors="true" { * @return boolean */ public any function exists( struct options = {}, boolean toSQL = false ) { - var originalLimit = this.getLimitValue(); - this.setLimitValue( 1 ); + var existsSource = clone().setLimitValue( 1 ); var existsQuery = newQuery() .clearFrom() .selectRaw( - "CASE WHEN EXISTS (#getGrammar().compileSelect( this )#) THEN 1 ELSE 0 END AS aggregate", - this.getBindings() + "CASE WHEN EXISTS (#getGrammar().compileSelect( existsSource )#) THEN 1 ELSE 0 END AS aggregate", + existsSource.getBindings() ); - this.setLimitValue( isNull( originalLimit ) ? javacast( "null", "" ) : originalLimit ); return arguments.toSQL ? existsQuery.toSQL() : existsQuery .setReturnFormat( "query" ) .get( options = arguments.options ) @@ -5002,29 +4998,93 @@ component displayname="QueryBuilder" accessors="true" { */ public QueryBuilder function clone() { var clonedQuery = newQuery(); - clonedQuery.setDistinct( this.getDistinct() ); - var newAggregate = {}; - for ( var key in this.getAggregate() ) { - newAggregate[ key ] = this.getAggregate()[ key ]; - } - clonedQuery.setAggregate( newAggregate ); - clonedQuery.setColumns( this.getColumns().isEmpty() ? [] : arraySlice( this.getColumns(), 1 ) ); - clonedQuery.setTableName( this.getTableName() ); - clonedQuery.setAlias( this.getAlias() ); - clonedQuery.setJoins( this.getJoins().isEmpty() ? [] : arraySlice( this.getJoins(), 1 ) ); - clonedQuery.setWheres( this.getWheres().isEmpty() ? [] : arraySlice( this.getWheres(), 1 ) ); - clonedQuery.setGroups( this.getGroups().isEmpty() ? [] : arraySlice( this.getGroups(), 1 ) ); - clonedQuery.setHavings( this.getHavings().isEmpty() ? [] : arraySlice( this.getHavings(), 1 ) ); - clonedQuery.setUnions( this.getUnions().isEmpty() ? [] : arraySlice( this.getUnions(), 1 ) ); - clonedQuery.setOrders( this.getOrders().isEmpty() ? [] : arraySlice( this.getOrders(), 1 ) ); - clonedQuery.setCommonTables( this.getCommonTables().isEmpty() ? [] : arraySlice( this.getCommonTables(), 1 ) ); - clonedQuery.setLimitValue( this.getLimitValue() ); - clonedQuery.setOffsetValue( this.getOffsetValue() ); - clonedQuery.setReturning( this.getReturning().isEmpty() ? [] : arraySlice( this.getReturning(), 1 ) ); - clonedQuery.mergeBindings( this ); + copyQueryState( this, clonedQuery ); return clonedQuery; } + private void function copyQueryState( required QueryBuilder source, required QueryBuilder target ) { + var targetQuery = arguments.target; + arguments.target.setDistinct( arguments.source.getDistinct() ); + arguments.target.setAggregate( cloneQueryStateValue( arguments.source.getAggregate() ) ); + arguments.target.setColumns( cloneQueryStateValue( arguments.source.getColumns() ) ); + arguments.target.setTableName( cloneQueryStateValue( arguments.source.getTableName() ) ); + if ( !isNull( arguments.source.getForClause() ) ) { + arguments.target.setForClause( cloneQueryStateValue( arguments.source.getForClause() ) ); + } + arguments.target.setAlias( arguments.source.getAlias() ); + arguments.target.setLockType( arguments.source.getLockType() ); + arguments.target.setLockValue( arguments.source.getLockValue() ); + var clonedJoins = []; + for ( var join in arguments.source.getJoins() ) { + clonedJoins.append( cloneJoinClause( join, targetQuery ) ); + } + arguments.target.setJoins( clonedJoins ); + arguments.target.setWheres( cloneQueryStateValue( arguments.source.getWheres() ) ); + arguments.target.setGroups( cloneQueryStateValue( arguments.source.getGroups() ) ); + arguments.target.setHavings( cloneQueryStateValue( arguments.source.getHavings() ) ); + arguments.target.setUnions( cloneQueryStateValue( arguments.source.getUnions() ) ); + arguments.target.setOrders( cloneQueryStateValue( arguments.source.getOrders() ) ); + arguments.target.setCommonTables( cloneQueryStateValue( arguments.source.getCommonTables() ) ); + if ( !isNull( arguments.source.getLimitValue() ) ) { + arguments.target.setLimitValue( arguments.source.getLimitValue() ); + } + if ( !isNull( arguments.source.getOffsetValue() ) ) { + arguments.target.setOffsetValue( arguments.source.getOffsetValue() ); + } + arguments.target.setReturning( cloneQueryStateValue( arguments.source.getReturning() ) ); + arguments.target.setUpdates( cloneQueryStateValue( arguments.source.getUpdates() ) ); + + var sourceBindings = arguments.source.getRawBindings(); + for ( var bindingType in sourceBindings ) { + arguments.target.addBindings( cloneQueryStateValue( sourceBindings[ bindingType ] ), bindingType ); + } + } + + private JoinClause function cloneJoinClause( required JoinClause join, required QueryBuilder joiningQuery ) { + var clonedJoin = new qb.models.Query.JoinClause( + arguments.joiningQuery, + arguments.join.getType(), + cloneQueryStateValue( arguments.join.getTable() ), + arguments.join.getLateralRawExpression() + ); + copyQueryState( arguments.join, clonedJoin ); + return clonedJoin; + } + + private any function cloneQueryStateValue( any value ) { + if ( isNull( arguments.value ) ) { + return javacast( "null", "" ); + } + if ( getUtils().isBuilder( arguments.value ) ) { + return arguments.value.clone(); + } + if ( getUtils().isExpression( arguments.value ) || isObject( arguments.value ) ) { + return arguments.value; + } + if ( isArray( arguments.value ) ) { + var clonedArray = []; + arrayResize( clonedArray, arguments.value.len() ); + for ( var i = 1; i <= arguments.value.len(); i++ ) { + if ( !isNull( arguments.value[ i ] ) ) { + clonedArray[ i ] = cloneQueryStateValue( arguments.value[ i ] ); + } + } + return clonedArray; + } + if ( isStruct( arguments.value ) ) { + var clonedStruct = {}; + for ( var key in arguments.value ) { + if ( isNull( arguments.value[ key ] ) ) { + clonedStruct[ key ] = javacast( "null", "" ); + } else { + clonedStruct[ key ] = cloneQueryStateValue( arguments.value[ key ] ); + } + } + return clonedStruct; + } + return arguments.value; + } + /** * Wrap up any sql in an Expression. * Expressions are not parameterized or escaped in any way. diff --git a/tests/specs/Query/Abstract/PaginationSpec.cfc b/tests/specs/Query/Abstract/PaginationSpec.cfc index 119c0a52..7aca5a61 100644 --- a/tests/specs/Query/Abstract/PaginationSpec.cfc +++ b/tests/specs/Query/Abstract/PaginationSpec.cfc @@ -56,6 +56,20 @@ component extends="testbox.system.BaseSpec" { } ); } ); + it( "restores orders when the pagination count fails", function() { + var builder = new qb.models.Query.QueryBuilder( + grammar = new qb.models.Grammars.BaseGrammar(), + validateQueryExecuteReturnType = true + ).from( "users" ) + .distinct() + .orderBy( "name" ); + + expect( function() { + builder.paginate( options = { "returntype": "array" } ); + } ).toThrow( type = "InvalidQueryExecuteOption" ); + expect( builder.getOrders() ).toHaveLength( 1 ); + } ); + it( "can get results for subsequent pages", function() { var builder = getBuilder(); var expectedResults = []; diff --git a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc index 3687d1a1..1e5d0908 100644 --- a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc +++ b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc @@ -1126,6 +1126,18 @@ component extends="testbox.system.BaseSpec" { .exists( toSQL = true ); expect( sql ).toBe( "SELECT CASE WHEN EXISTS (SELECT * FROM ""users"" WHERE ""active"" = ? LIMIT 1) THEN 1 ELSE 0 END AS aggregate" ); } ); + + it( "restores the original limit when exists compilation fails", function() { + var builder = getBuilder() + .from( "users" ) + .limit( 5 ) + .whereJsonExists( "profile->name" ); + + expect( function() { + builder.exists( toSQL = true ); + } ).toThrow( type = "UnsupportedOperation" ); + expect( builder.getLimitValue() ).toBe( 5 ); + } ); } ); describe( "existsOrFail", function() { diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index 9ca10d67..df6999c2 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -411,6 +411,80 @@ component extends="testbox.system.BaseSpec" { var queryTwo = queryOne.clone(); expect( queryTwo.toSql( showBindings = "inline" ) ).toBe( queryOne.toSql( showBindings = "inline" ) ); } ); + + it( "does not share mutable query clauses with the original", function() { + var original = new qb.models.Query.QueryBuilder() + .from( "users AS u" ) + .select( "u.id" ) + .where( "u.active", 1 ) + .where( function( q ) { + q.where( "u.status", "active" ); + } ) + .join( "profiles AS p", "p.userId", "u.id" ) + .groupBy( "u.id" ) + .orderBy( "u.name" ); + var originalSql = original.toSQL(); + + original.clone().withAlias( "member" ); + + expect( original.toSQL() ).toBe( originalSql ); + } ); + + it( "preserves all query state in a clone", function() { + var original = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.SqlServerGrammar() ) + .from( "users" ) + .forRaw( "JSON PATH" ) + .noLock() + .addUpdate( { "active": 1 } ); + var cloned = original.clone(); + + expect( cloned.toSQL() ).toBe( original.toSQL() ); + expect( cloned.getUpdates() ).toBe( original.getUpdates() ); + } ); + } ); + + describe( "reset()", function() { + it( "clears a SQL Server FOR clause", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.SqlServerGrammar() ) + .from( "users" ) + .forRaw( "JSON PATH" ) + .reset() + .from( "accounts" ); + + expect( builder.toSQL() ).toBe( "SELECT * FROM [accounts]" ); + } ); + } ); + + describe( "isEqualTo()", function() { + it( "compares equivalent common table expressions", function() { + var first = new qb.models.Query.QueryBuilder().with( "active_users", function( q ) { + q.from( "users" ).where( "active", 1 ); + } ); + var second = new qb.models.Query.QueryBuilder().with( "active_users", function( q ) { + q.from( "users" ).where( "active", 1 ); + } ); + + expect( first.isEqualTo( second ) ).toBeTrue(); + } ); + } ); + + describe( "aggregate state", function() { + it( "does not mutate a union query while compiling an aggregate", function() { + var builder = new qb.models.Query.QueryBuilder() + .select( "name" ) + .from( "users" ) + .where( "id", 1 ) + .union( function( q ) { + q.select( "name" ) + .from( "users" ) + .where( "id", 2 ); + } ); + var originalSql = builder.toSQL(); + + builder.count( toSQL = true ); + + expect( builder.toSQL() ).toBe( originalSql ); + } ); } ); } From 40f8af869b6bd474475f77361582da7ae87d74d6 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 13 Aug 2026 14:33:39 -0600 Subject: [PATCH 017/119] fix: restore query and schema builder lifecycle --- models/Grammars/BaseGrammar.cfc | 5 +- models/Grammars/DerbyGrammar.cfc | 5 +- models/Grammars/OracleGrammar.cfc | 5 +- models/Query/JoinClause.cfc | 13 ++- models/Query/QueryBuilder.cfc | 27 ++++-- models/Schema/Blueprint.cfc | 52 ++++++++--- .../Query/Abstract/BindingLifecycleSpec.cfc | 41 +++++++++ .../specs/Query/Abstract/BuilderJoinSpec.cfc | 26 ++++++ tests/specs/Schema/BlueprintLifecycleSpec.cfc | 86 +++++++++++++++++++ 9 files changed, 228 insertions(+), 32 deletions(-) create mode 100644 tests/specs/Query/Abstract/BindingLifecycleSpec.cfc create mode 100644 tests/specs/Schema/BlueprintLifecycleSpec.cfc diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 551822ea..88e46029 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -1700,13 +1700,13 @@ component displayname="Grammar" accessors="true" singleton { ========================================*/ function compileAddColumn( blueprint, commandParameters ) { + var existingIndexes = blueprint.getIndexes(); try { var originalShouldWrapValues = getShouldWrapValues(); if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ); } - var existingIndexes = blueprint.getIndexes(); blueprint.setIndexes( [] ); var body = concatenate( @@ -1714,8 +1714,6 @@ component displayname="Grammar" accessors="true" singleton { ", " ); - blueprint.setIndexes( existingIndexes ); - return concatenate( [ "ALTER TABLE", wrapTable( blueprint.getTable() ), @@ -1723,6 +1721,7 @@ component displayname="Grammar" accessors="true" singleton { body ] ); } finally { + blueprint.setIndexes( existingIndexes ); if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); } diff --git a/models/Grammars/DerbyGrammar.cfc b/models/Grammars/DerbyGrammar.cfc index 1789696d..f5eb65fa 100644 --- a/models/Grammars/DerbyGrammar.cfc +++ b/models/Grammars/DerbyGrammar.cfc @@ -453,13 +453,13 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } function compileAddColumn( blueprint, commandParameters ) { + var originalIndexes = blueprint.getIndexes(); try { var originalShouldWrapValues = getShouldWrapValues(); if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ); } - var originalIndexes = blueprint.getIndexes(); blueprint.setIndexes( [] ); var body = concatenate( [ compileCreateColumn( commandParameters.column, blueprint ) ], ", " ); @@ -468,8 +468,6 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { blueprint.addConstraint( index ); } - blueprint.setIndexes( originalIndexes ); - return concatenate( [ "ALTER TABLE", wrapTable( blueprint.getTable() ), @@ -477,6 +475,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { body ] ); } finally { + blueprint.setIndexes( originalIndexes ); if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); } diff --git a/models/Grammars/OracleGrammar.cfc b/models/Grammars/OracleGrammar.cfc index 6bdcc753..edf37bd1 100644 --- a/models/Grammars/OracleGrammar.cfc +++ b/models/Grammars/OracleGrammar.cfc @@ -488,13 +488,13 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } function compileAddColumn( blueprint, commandParameters ) { + var originalIndexes = blueprint.getIndexes(); try { var originalShouldWrapValues = getShouldWrapValues(); if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ); } - var originalIndexes = blueprint.getIndexes(); blueprint.setIndexes( [] ); var body = concatenate( [ compileCreateColumn( commandParameters.column, blueprint ) ], ", " ); @@ -503,8 +503,6 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { blueprint.addConstraint( index ); } - blueprint.setIndexes( originalIndexes ); - return concatenate( [ "ALTER TABLE", wrapTable( blueprint.getTable() ), @@ -512,6 +510,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { body ] ); } finally { + blueprint.setIndexes( originalIndexes ); if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); } diff --git a/models/Query/JoinClause.cfc b/models/Query/JoinClause.cfc index cccd2b25..6aaaaa59 100644 --- a/models/Query/JoinClause.cfc +++ b/models/Query/JoinClause.cfc @@ -24,6 +24,12 @@ component displayname="JoinClause" accessors="true" extends="qb.models.Query.Que */ property name="lateralRawExpression" type="string"; + /** + * Bindings belonging to a lateral source. These participate in duplicate + * join comparison while the parent query owns the executable bindings. + */ + property name="lateralBindings" type="array"; + /** * Valid join types for join clauses. */ @@ -47,7 +53,8 @@ component displayname="JoinClause" accessors="true" extends="qb.models.Query.Que * @joiningQuery A reference to the query to which this join clause belongs. * @type The join type of this join clause. * @table The table to join. - * @crossApplySqlStringWithBindParams The already-`toSql`'d table expression for the {cross,outer}Apply case + * @lateralRawExpression The already-compiled table expression for a lateral join. + * @lateralBindings Bindings belonging to the lateral table expression. * * @return qb.models.Query.JoinClause */ @@ -55,7 +62,8 @@ component displayname="JoinClause" accessors="true" extends="qb.models.Query.Que required QueryBuilder joiningQuery, required string type, required any table, - string lateralRawExpression + string lateralRawExpression, + array lateralBindings = [] ) { var typeIsValid = false; for ( var validType in variables.types ) { @@ -73,6 +81,7 @@ component displayname="JoinClause" accessors="true" extends="qb.models.Query.Que variables.lateralRawExpression = isNull( arguments.lateralRawExpression ) ? "" : arguments.lateralRawExpression; + variables.lateralBindings = arguments.lateralBindings; super.init( joiningQuery.getGrammar(), joiningQuery.getUtils() ); diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 397158e5..9aaf199e 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -811,6 +811,8 @@ component displayname="QueryBuilder" accessors="true" { ); } + clearBindings( only = [ "from" ] ); + variables.alias = ""; if ( isSimpleValue( arguments.from ) ) { parseIntoTableAndAlias( arguments.from ); } else { @@ -1179,6 +1181,8 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function table( required any table ) { + clearBindings( only = [ "from" ] ); + variables.alias = ""; variables.tableName = arguments.table; return this; } @@ -1193,6 +1197,8 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function tableRaw( required string table, array bindings = [] ) { + this.table( raw( arguments.table ) ); + // add the bindings required by the table if ( !arrayIsEmpty( arguments.bindings ) ) { addBindings( @@ -1203,7 +1209,7 @@ component displayname="QueryBuilder" accessors="true" { ); } - return this.table( raw( arguments.table ) ); + return this; } /** @@ -1215,6 +1221,8 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function fromRaw( required string from, array bindings = [] ) { + this.from( raw( arguments.from ) ); + // add the bindings required by the table if ( !arrayIsEmpty( arguments.bindings ) ) { addBindings( @@ -1225,7 +1233,7 @@ component displayname="QueryBuilder" accessors="true" { ); } - return this.from( raw( arguments.from ) ); + return this; } /** @@ -1245,10 +1253,10 @@ component displayname="QueryBuilder" accessors="true" { arguments.input = subquery; } - addBindings( arguments.input.getBindings(), "from" ); - // generate the derived table SQL - return this.fromRaw( getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ) ); + this.fromRaw( getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ) ); + addBindings( arguments.input.getBindings(), "from" ); + return this; } /*******************************************************************************\ @@ -1756,7 +1764,8 @@ component displayname="QueryBuilder" accessors="true" { joiningQuery = this, type = type, table = arguments.name, - lateralRawExpression = arguments.tableLikeSource.toSQL() + lateralRawExpression = arguments.tableLikeSource.toSQL(), + lateralBindings = arguments.tableLikeSource.getBindings() ); if ( this.getPreventDuplicateJoins() ) { @@ -2007,6 +2016,8 @@ component displayname="QueryBuilder" accessors="true" { } } else { memento[ "type" ] = variables.type; + memento[ "lateralRawExpression" ] = getLateralRawExpression(); + memento[ "lateralBindings" ] = getLateralBindings(); if ( !isCustomFunction( getTable() ) ) { if ( getUtils().isExpression( getTable() ) ) { memento[ "table" ] = getTable().getSQL(); @@ -4260,6 +4271,7 @@ component displayname="QueryBuilder" accessors="true" { "select", "join", "where", + "having", "orderBy", "union" ]; @@ -5045,7 +5057,8 @@ component displayname="QueryBuilder" accessors="true" { arguments.joiningQuery, arguments.join.getType(), cloneQueryStateValue( arguments.join.getTable() ), - arguments.join.getLateralRawExpression() + arguments.join.getLateralRawExpression(), + cloneQueryStateValue( arguments.join.getLateralBindings() ) ); copyQueryState( arguments.join, clonedJoin ); return clonedJoin; diff --git a/models/Schema/Blueprint.cfc b/models/Schema/Blueprint.cfc index d1deeae0..9ed08987 100644 --- a/models/Schema/Blueprint.cfc +++ b/models/Schema/Blueprint.cfc @@ -178,32 +178,46 @@ component accessors="true" { public Blueprint function morphs( required string name ) { var morphIdColumnName = arguments.name & "_id"; var morphTypeColumnName = arguments.name & "_type"; - unsignedInteger( morphIdColumnName ); - string( morphTypeColumnName ); - appendIndex( + var morphIdColumn = unsignedInteger( morphIdColumnName ); + var morphTypeColumn = string( morphTypeColumnName ); + var morphIndex = appendIndex( type = "basic", name = "#arguments.name#_index", columns = [ morphIdColumnName, morphTypeColumnName ] ); + if ( !getCreating() ) { + addColumn( morphIdColumn ); + addColumn( morphTypeColumn ); + addIndex( morphIndex ); + } return this; } public Blueprint function nullableMorphs( required string name ) { var morphIdColumnName = arguments.name & "_id"; var morphTypeColumnName = arguments.name & "_type"; - unsignedInteger( morphIdColumnName ).nullable(); - string( morphTypeColumnName ).nullable(); - appendIndex( + var morphIdColumn = unsignedInteger( morphIdColumnName ).nullable(); + var morphTypeColumn = string( morphTypeColumnName ).nullable(); + var morphIndex = appendIndex( type = "basic", name = "#arguments.name#_index", columns = [ morphIdColumnName, morphTypeColumnName ] ); + if ( !getCreating() ) { + addColumn( morphIdColumn ); + addColumn( morphTypeColumn ); + addIndex( morphIndex ); + } return this; } public Blueprint function nullableTimestamps() { - appendColumn( name = "createdDate", type = "timestamp", isNullable = true ); - appendColumn( name = "modifiedDate", type = "timestamp", isNullable = true ); + var createdDate = appendColumn( name = "createdDate", type = "timestamp", isNullable = true ); + var modifiedDate = appendColumn( name = "modifiedDate", type = "timestamp", isNullable = true ); + if ( !getCreating() ) { + addColumn( createdDate ); + addColumn( modifiedDate ); + } return this; } @@ -236,12 +250,18 @@ component accessors="true" { } public Blueprint function softDeletes() { - appendColumn( name = "deletedDate", type = "timestamp", isNullable = true ); + var deletedDate = appendColumn( name = "deletedDate", type = "timestamp", isNullable = true ); + if ( !getCreating() ) { + addColumn( deletedDate ); + } return this; } public Blueprint function softDeletesTz() { - appendColumn( name = "deletedDate", type = "timestampTz", isNullable = true ); + var deletedDate = appendColumn( name = "deletedDate", type = "timestampTz", isNullable = true ); + if ( !getCreating() ) { + addColumn( deletedDate ); + } return this; } @@ -300,8 +320,12 @@ component accessors="true" { } public Blueprint function timestampsTz() { - appendColumn( name = "createdDate", type = "timestampTz" ).withCurrent(); - appendColumn( name = "modifiedDate", type = "timestampTz" ).withCurrent(); + var createdDate = appendColumn( name = "createdDate", type = "timestampTz" ).withCurrent(); + var modifiedDate = appendColumn( name = "modifiedDate", type = "timestampTz" ).withCurrent(); + if ( !getCreating() ) { + addColumn( createdDate ); + addColumn( modifiedDate ); + } return this; } @@ -505,10 +529,10 @@ component accessors="true" { public Blueprint function renameConstraint( required any oldName, required any newName ) { if ( !isSimpleValue( arguments.oldName ) ) { - arguments.oldName = dropConstraint( arguments.oldName.getName() ); + arguments.oldName = arguments.oldName.getName(); } if ( !isSimpleValue( arguments.newName ) ) { - arguments.newName = dropConstraint( arguments.newName.getName() ); + arguments.newName = arguments.newName.getName(); } addCommand( "renameConstraint", { from: arguments.oldName, to: arguments.newName } ); return this; diff --git a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc new file mode 100644 index 00000000..de68ee87 --- /dev/null +++ b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc @@ -0,0 +1,41 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "binding lifecycle", function() { + it( "replaces derived-table bindings when replacing the FROM source", function() { + var builder = new qb.models.Query.QueryBuilder(); + + builder.fromSub( "source", function( query ) { + query.from( "orders" ).where( "kind", "retail" ); + } ); + builder.fromSub( "source", function( query ) { + query.from( "payments" ).where( "status", "settled" ); + } ); + + expect( builder.toSql() ).toBe( + "SELECT * FROM (SELECT * FROM ""payments"" WHERE ""status"" = ?) AS ""source""" + ); + expect( builder.getBindings() ).toHaveLength( 1 ); + expect( builder.getBindings()[ 1 ].value ).toBe( "settled" ); + } ); + + it( "clears the previous alias when replacing the FROM source", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users AS old_source" ).from( "payments" ); + + expect( builder.toSql() ).toBe( "SELECT * FROM ""payments""" ); + } ); + + it( "removes HAVING bindings that are not part of an INSERT", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ).having( "age", ">", 21 ); + + expect( builder.insert( { name: "new user" }, {}, true ) ).toBe( + "INSERT INTO ""users"" (""name"") VALUES (?)" + ); + + expect( builder.getBindings() ).toHaveLength( 1 ); + expect( builder.getBindings()[ 1 ].value ).toBe( "new user" ); + } ); + } ); + } + +} diff --git a/tests/specs/Query/Abstract/BuilderJoinSpec.cfc b/tests/specs/Query/Abstract/BuilderJoinSpec.cfc index 6d273281..eefc0a8a 100644 --- a/tests/specs/Query/Abstract/BuilderJoinSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderJoinSpec.cfc @@ -109,6 +109,32 @@ component extends="testbox.system.BaseSpec" { expect( clause.combinator ).toBe( "and" ); } ); + it( "distinguishes lateral joins with different source SQL", function() { + query.setPreventDuplicateJoins( true ); + + query.crossApply( "source", function( builder ) { + builder.from( "orders" ); + } ); + query.crossApply( "source", function( builder ) { + builder.from( "payments" ); + } ); + + expect( query.getJoins() ).toHaveLength( 2 ); + } ); + + it( "distinguishes lateral joins with different source bindings", function() { + query.setPreventDuplicateJoins( true ); + + query.crossApply( "source", function( builder ) { + builder.from( "orders" ).where( "status", "open" ); + } ); + query.crossApply( "source", function( builder ) { + builder.from( "orders" ).where( "status", "closed" ); + } ); + + expect( query.getJoins() ).toHaveLength( 2 ); + } ); + it( "can use a callback to specify advanced join clauses", function() { query.join( "second", function( join ) { join.on( "first.id", "=", "second.first_id" ).on( "first.locale", "=", "second.locale" ); diff --git a/tests/specs/Schema/BlueprintLifecycleSpec.cfc b/tests/specs/Schema/BlueprintLifecycleSpec.cfc new file mode 100644 index 00000000..0034d2eb --- /dev/null +++ b/tests/specs/Schema/BlueprintLifecycleSpec.cfc @@ -0,0 +1,86 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "Blueprint lifecycle", function() { + it( "restores indexes when add-column compilation throws", function() { + [ + new qb.models.Grammars.BaseGrammar(), + new qb.models.Grammars.DerbyGrammar(), + new qb.models.Grammars.OracleGrammar() + ].each( function( grammar ) { + var blueprint = newBlueprint( grammar ); + blueprint.appendIndex( type = "basic", columns = [ "email" ], name = "idx_users_email" ); + + expect( function() { + grammar.compileAddColumn( blueprint, { column: {} } ); + } ).toThrow(); + + expect( blueprint.getIndexes() ).toHaveLength( 1 ); + } ); + } ); + + it( "renames TableIndex instances by name", function() { + var blueprint = newBlueprint( new qb.models.Grammars.BaseGrammar() ); + var oldConstraint = blueprint.createIndex( + type = "unique", + columns = [ "email" ], + name = "unq_users_email" + ); + var newConstraint = blueprint.createIndex( + type = "unique", + columns = [ "email" ], + name = "unq_users_login" + ); + + blueprint.renameConstraint( oldConstraint, newConstraint ); + + expectCommandTypes( blueprint, [ "renameConstraint" ] ); + expect( blueprint.getCommands()[ 1 ].getParameters() ).toBe( { from: "unq_users_email", to: "unq_users_login" } ); + } ); + + it( "registers morph columns and indexes when altering a table", function() { + var blueprint = newBlueprint( new qb.models.Grammars.BaseGrammar() ); + blueprint.morphs( "taggable" ); + expectCommandTypes( blueprint, [ "addColumn", "addColumn", "addIndex" ] ); + + blueprint = newBlueprint( new qb.models.Grammars.BaseGrammar() ); + blueprint.nullableMorphs( "taggable" ); + expectCommandTypes( blueprint, [ "addColumn", "addColumn", "addIndex" ] ); + } ); + + it( "registers timestamp shortcuts when altering a table", function() { + var blueprint = newBlueprint( new qb.models.Grammars.BaseGrammar() ); + blueprint.nullableTimestamps(); + expectCommandTypes( blueprint, [ "addColumn", "addColumn" ] ); + + blueprint = newBlueprint( new qb.models.Grammars.BaseGrammar() ); + blueprint.timestampsTz(); + expectCommandTypes( blueprint, [ "addColumn", "addColumn" ] ); + } ); + + it( "registers soft-delete shortcuts when altering a table", function() { + var blueprint = newBlueprint( new qb.models.Grammars.BaseGrammar() ); + blueprint.softDeletes(); + expectCommandTypes( blueprint, [ "addColumn" ] ); + + blueprint = newBlueprint( new qb.models.Grammars.BaseGrammar() ); + blueprint.softDeletesTz(); + expectCommandTypes( blueprint, [ "addColumn" ] ); + } ); + } ); + } + + private function newBlueprint( required grammar ) { + var schema = new qb.models.Schema.SchemaBuilder( arguments.grammar ); + var blueprint = new qb.models.Schema.Blueprint( schema, arguments.grammar ); + blueprint.setTable( "users" ); + return blueprint; + } + + private void function expectCommandTypes( required blueprint, required array expectedTypes ) { + expect( arguments.blueprint.getCommands().map( ( command ) => command.getType() ) ).toBe( + arguments.expectedTypes + ); + } + +} From 5c686de9dda2f1b0b2c6238682df4ace4a82b545 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 13 Aug 2026 15:27:07 -0600 Subject: [PATCH 018/119] fix(QueryBuilder): preserve compiled binding alignment --- models/Grammars/BaseGrammar.cfc | 8 +- models/Grammars/PostgresGrammar.cfc | 9 +- models/Grammars/SQLiteGrammar.cfc | 2 +- models/Query/QueryBuilder.cfc | 122 +++++++++++++----- tests/resources/AbstractQueryBuilderSpec.cfc | 107 +++++++++++++++ .../Query/Abstract/QueryExecutionSpec.cfc | 49 +++++++ .../specs/Query/PostgresQueryBuilderSpec.cfc | 31 +++++ tests/specs/Query/SQLiteQueryBuilderSpec.cfc | 21 +++ 8 files changed, 314 insertions(+), 35 deletions(-) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 88e46029..2e43cab8 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -631,7 +631,13 @@ component displayname="Grammar" accessors="true" singleton { * @return string */ private string function whereNotBetween( required QueryBuilder query, required struct where ) { - return "#wrapColumn( where.column )# NOT BETWEEN ? AND ?"; + var start = variables.utils.isExpression( where.start ) ? where.start.getSql() : ( + isSimpleValue( where.start ) ? "?" : "(#compileSelect( where.start )#)" + ); + var end = variables.utils.isExpression( where.end ) ? where.end.getSql() : ( + isSimpleValue( where.end ) ? "?" : "(#compileSelect( where.end )#)" + ); + return "#wrapColumn( where.column )# NOT BETWEEN #start# AND #end#"; } /** diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index 75e97116..48da50ff 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -166,7 +166,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { required array target, required array values ) { - return compileInsert( arguments.qb, arguments.columns, arguments.values ) & " ON CONFLICT DO NOTHING"; + var returningColumns = arguments.qb + .getReturning() + .map( wrapColumn ) + .toList( ", " ); + var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; + return super.compileInsert( arguments.qb, arguments.columns, arguments.values ) & " ON CONFLICT DO NOTHING" & returningClause; } /** @@ -305,7 +310,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.qb.getShouldWrapValues() ); } - var insertString = isNull( arguments.source ) ? this.compileInsert( + var insertString = isNull( arguments.source ) ? super.compileInsert( arguments.qb, arguments.insertColumns, arguments.values diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index e1f0a9b9..1af1d253 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -296,7 +296,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.qb.getShouldWrapValues() ); } - var insertString = isNull( arguments.source ) ? this.compileInsert( + var insertString = isNull( arguments.source ) ? super.compileInsert( arguments.qb, arguments.insertColumns, arguments.values diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 9aaf199e..1ce47224 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -285,6 +285,7 @@ component displayname="QueryBuilder" accessors="true" { "from": [], "join": [], "where": [], + "groupBy": [], "having": [], "orderBy": [], "union": [], @@ -423,6 +424,7 @@ component displayname="QueryBuilder" accessors="true" { "from": [], "join": [], "where": [], + "groupBy": [], "having": [], "orderBy": [], "union": [], @@ -490,10 +492,25 @@ component displayname="QueryBuilder" accessors="true" { if ( newColumns.isEmpty() ) { newColumns = [ { "type": "simple", "value": "*" } ]; } + clearBindings( only = [ "select" ] ); variables.columns = newColumns; + addColumnBindings( newColumns, "select" ); return this; } + /** + * Adds bindings carried by typed raw-expression and builder columns. + */ + private void function addColumnBindings( required array columns, required string type ) { + for ( var column in arguments.columns ) { + if ( column.type == "raw" ) { + addExpressionBindings( column.value, arguments.type ); + } else if ( column.type == "builder" ) { + addBindings( column.value.getBindings(), arguments.type ); + } + } + } + private struct function mapToColumnType( required any column ) { if ( isSimpleValue( arguments.column ) ) { if ( find( "->", arguments.column ) ) { @@ -718,6 +735,7 @@ component displayname="QueryBuilder" accessors="true" { arrayAppend( selectedColumns, newColumns, true ); variables.columns = selectedColumns; + addColumnBindings( newColumns, "select" ); return this; } @@ -736,10 +754,7 @@ component displayname="QueryBuilder" accessors="true" { */ public QueryBuilder function selectRaw( required any expression, array bindings = [] ) { for ( var sql in arrayWrap( arguments.expression ) ) { - addSelect( raw( sql ) ); - if ( !arrayIsEmpty( arguments.bindings ) ) { - addBindings( arguments.bindings, "select" ); - } + addSelect( raw( sql, arguments.bindings ) ); } return this; } @@ -817,6 +832,9 @@ component displayname="QueryBuilder" accessors="true" { parseIntoTableAndAlias( arguments.from ); } else { variables.tableName = arguments.from; + if ( getUtils().isExpression( arguments.from ) ) { + addExpressionBindings( arguments.from, "from" ); + } } return this; @@ -1184,6 +1202,9 @@ component displayname="QueryBuilder" accessors="true" { clearBindings( only = [ "from" ] ); variables.alias = ""; variables.tableName = arguments.table; + if ( getUtils().isExpression( arguments.table ) ) { + addExpressionBindings( arguments.table, "from" ); + } return this; } @@ -2117,7 +2138,13 @@ component displayname="QueryBuilder" accessors="true" { } ); - if ( isNull( arguments.value ) || getUtils().isNotExpression( arguments.value ) ) { + if ( getUtils().isExpression( arguments.column ) ) { + addExpressionBindings( arguments.column, "where" ); + } + + if ( !isNull( arguments.value ) && getUtils().isExpression( arguments.value ) ) { + addExpressionBindings( arguments.value, "where" ); + } else { addBindings( utils.extractBinding( isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value, @@ -2365,11 +2392,14 @@ component displayname="QueryBuilder" accessors="true" { combinator: arguments.combinator } ); - var bindings = values - .filter( utils.isNotExpression ) - .map( function( value ) { - return utils.extractBinding( value, variables.grammar ); - } ); + var bindings = []; + for ( var value in arguments.values ) { + if ( getUtils().isExpression( value ) ) { + bindings.append( extractExpressionBindings( value ), true ); + } else { + bindings.append( utils.extractBinding( value, variables.grammar ) ); + } + } addBindings( bindings, "where" ); @@ -2723,6 +2753,7 @@ component displayname="QueryBuilder" accessors="true" { var type = arguments.negate ? "notNullSub" : "nullSub"; variables.wheres.append( { type: type, query: arguments.query, combinator: arguments.combinator } ); + addBindings( arguments.query.getBindings(), "where" ); return this; } @@ -2772,20 +2803,16 @@ component displayname="QueryBuilder" accessors="true" { callback( arguments.end ); } - addBindings( - utils.isExpression( arguments.start ) ? arguments.start.getBindings() : utils.extractBinding( - arguments.start, - variables.grammar - ), - "where" - ); - addBindings( - utils.isExpression( arguments.end ) ? arguments.end.getBindings() : utils.extractBinding( - arguments.end, - variables.grammar - ), - "where" - ); + if ( utils.isExpression( arguments.start ) ) { + addExpressionBindings( arguments.start, "where" ); + } else { + addBindings( utils.extractBinding( arguments.start, variables.grammar ), "where" ); + } + if ( utils.isExpression( arguments.end ) ) { + addExpressionBindings( arguments.end, "where" ); + } else { + addBindings( utils.extractBinding( arguments.end, variables.grammar ), "where" ); + } if ( isStruct( arguments.start ) && !structKeyExists( arguments.start, "isBuilder" ) && structKeyExists( @@ -2877,7 +2904,9 @@ component displayname="QueryBuilder" accessors="true" { public QueryBuilder function groupBy( required groups ) { var groupBys = normalizeToArray( arguments.groups ); for ( var groupBy in groupBys ) { - variables.groups.append( mapToColumnType( applyColumnFormatter( groupBy ) ) ); + var typedGroupBy = mapToColumnType( applyColumnFormatter( groupBy ) ); + variables.groups.append( typedGroupBy ); + addColumnBindings( [ typedGroupBy ], "groupBy" ); } return this; } @@ -2951,7 +2980,9 @@ component displayname="QueryBuilder" accessors="true" { ); } - if ( getUtils().isNotExpression( arguments.value ) ) { + if ( getUtils().isExpression( arguments.value ) ) { + addExpressionBindings( arguments.value, "having" ); + } else { addBindings( utils.extractBinding( arguments.value, variables.grammar ), "having" ); } @@ -3114,6 +3145,7 @@ component displayname="QueryBuilder" accessors="true" { // as long as the struct provided contains the column keyName then we can append it. If the direction column is omitted we will assume direction argument's value if ( getUtils().isExpression( column.column ) ) { variables.orders.append( { direction: "raw", column: column.column } ); + addExpressionBindings( column.column, "orderBy" ); } else { var dir = ( structKeyExists( column, "direction" ) && arrayFindNoCase( variables.directions, column.direction ) @@ -3665,7 +3697,7 @@ component displayname="QueryBuilder" accessors="true" { if ( getUtils().isNotExpression( binding ) ) { addBindings( binding, "insert" ); } else { - addBindings( binding, "insertRaw" ); + addExpressionBindings( binding, "insert" ); } } ); } ); @@ -3852,7 +3884,7 @@ component displayname="QueryBuilder" accessors="true" { if ( getUtils().isNotExpression( binding ) ) { addBindings( binding, "insert" ); } else { - addBindings( binding, "insertRaw" ); + addExpressionBindings( binding, "insert" ); } } ); } ); @@ -3940,7 +3972,9 @@ component displayname="QueryBuilder" accessors="true" { } else if ( getUtils().isBuilder( value ) ) { arguments.values[ column.original ] = value; addBindings( value.getBindings(), "update" ); - } else if ( !getUtils().isExpression( value ) ) { + } else if ( getUtils().isExpression( value ) ) { + addExpressionBindings( value, "update" ); + } else { addBindings( getUtils().extractBinding( value, variables.grammar ), "update" ); } } @@ -4107,7 +4141,7 @@ component displayname="QueryBuilder" accessors="true" { if ( getUtils().isNotExpression( binding ) ) { addBindings( binding, "insert" ); } else { - addBindings( binding, "insertRaw" ); + addExpressionBindings( binding, "insert" ); } } ); } ); @@ -4126,6 +4160,8 @@ component displayname="QueryBuilder" accessors="true" { ), "where" ); + } else { + addExpressionBindings( updates[ column.original ], "where" ); } } ); } @@ -4227,6 +4263,7 @@ component displayname="QueryBuilder" accessors="true" { "from", "join", "where", + "groupBy", "having", "orderBy", "union" @@ -4271,6 +4308,7 @@ component displayname="QueryBuilder" accessors="true" { "select", "join", "where", + "groupBy", "having", "orderBy", "union" @@ -4304,6 +4342,25 @@ component displayname="QueryBuilder" accessors="true" { return this; } + /** + * Normalizes the bindings carried by an Expression for query execution. + */ + private array function extractExpressionBindings( required any expression ) { + return arguments.expression + .getBindings() + .map( function( binding ) { + return utils.extractBinding( binding, variables.grammar ); + } ); + } + + /** + * Adds normalized bindings carried by an Expression to a binding group. + */ + private QueryBuilder function addExpressionBindings( required any expression, required string type ) { + addBindings( extractExpressionBindings( arguments.expression ), arguments.type ); + return this; + } + /** * Adds all of the bindings from another builder instance. * @@ -4825,6 +4882,9 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function chunk( required numeric max, required callback, struct options = {} ) { + if ( arguments.max <= 0 ) { + throw( type = "InvalidChunkSize", message = "Chunk size must be greater than zero." ); + } var count = getCountForPagination( options = options ); for ( var i = 1; i <= count; i += max ) { var shouldContinue = callback( @@ -4928,7 +4988,7 @@ component displayname="QueryBuilder" accessors="true" { private any function runQuery( required string sql, struct options = {}, string returnObject = "query" ) { structAppend( arguments.options, getDefaultOptions(), false ); guardAgainstReturnTypeOption( arguments.options ); - var bindings = getBindings( except = getAggregate().isEmpty() ? [] : [ "select" ] ); + var bindings = getBindings( except = getAggregate().isEmpty() ? [] : [ "select", "orderBy" ] ); var result = grammar.runQuery( sql = variables.sqlCommenter.appendSqlComments( diff --git a/tests/resources/AbstractQueryBuilderSpec.cfc b/tests/resources/AbstractQueryBuilderSpec.cfc index 7b6d30f3..bdd97892 100644 --- a/tests/resources/AbstractQueryBuilderSpec.cfc +++ b/tests/resources/AbstractQueryBuilderSpec.cfc @@ -168,6 +168,21 @@ component extends="testbox.system.BaseSpec" { }, selectRawArray() ); } ); + it( "preserves bindings carried by expressions across select clauses", function() { + var builder = getBuilder(); + builder + .select( builder.raw( "? AS selectedValue", [ 1 ] ) ) + .from( builder.raw( "(SELECT ? AS id) source", [ 2 ] ) ) + .where( "id", ">", builder.raw( "?", [ 3 ] ) ) + .whereIn( "id", [ 4, builder.raw( "?", [ 5 ] ) ] ) + .groupBy( builder.raw( "?", [ 6 ] ) ) + .having( "id", ">", builder.raw( "?", [ 7 ] ) ) + .orderBy( { column: builder.raw( "?", [ 8 ] ) } ); + + expect( reMatch( "\?", builder.toSQL() ) ).toHaveLength( 8 ); + expect( getTestBindings( builder ) ).toBe( [ 1, 2, 3, 4, 5, 6, 7, 8 ] ); + } ); + it( "provides a grammar-specific helper for concat", function() { testCase( function( builder ) { builder.select( builder.concat( "my_alias", "a,b,c,d" ) ).from( "users" ); @@ -767,6 +782,20 @@ component extends="testbox.system.BaseSpec" { ); }, whereNullSubquery() ); } ); + + it( "preserves bindings from where null subqueries", function() { + var builder = getBuilder() + .from( "users" ) + .whereNull( function( query ) { + query + .select( "deletedAt" ) + .from( "accounts" ) + .where( "status", "closed" ); + } ); + + expect( reMatch( "\?", builder.toSQL() ) ).toHaveLength( 1 ); + expect( getTestBindings( builder ) ).toBe( [ "closed" ] ); + } ); } ); describe( "where between", function() { @@ -814,6 +843,42 @@ component extends="testbox.system.BaseSpec" { }, whereNotBetween() ); } ); + it( "can add where not between statements with expression boundaries", function() { + var builder = getBuilder() + .from( "users" ) + .whereNotBetween( + "score", + getBuilder().raw( "COALESCE(?, 0)", [ 10 ] ), + getBuilder().raw( "COALESCE(?, 100)", [ 90 ] ) + ); + + expect( builder.toSQL() ).toInclude( "NOT BETWEEN COALESCE(?, 0) AND COALESCE(?, 100)" ); + expect( getTestBindings( builder ) ).toBe( [ 10, 90 ] ); + } ); + + it( "can add where not between statements with subquery boundaries", function() { + var builder = getBuilder() + .from( "users" ) + .whereNotBetween( + "id", + function( query ) { + query + .selectRaw( "MIN(id)" ) + .from( "users" ) + .where( "type", "minimum" ); + }, + function( query ) { + query + .selectRaw( "MAX(id)" ) + .from( "users" ) + .where( "type", "maximum" ); + } + ); + + expect( builder.toSQL() ).toInclude( "NOT BETWEEN (SELECT" ); + expect( getTestBindings( builder ) ).toBe( [ "minimum", "maximum" ] ); + } ); + it( "can add where between statements using closures", function() { testCase( function( builder ) { builder @@ -2749,6 +2814,23 @@ component extends="testbox.system.BaseSpec" { }, insertWithRaw() ); } ); + it( "preserves bindings carried by insert expressions", function() { + var builder = getBuilder(); + var sql = builder + .from( "users" ) + .insert( + values = { + "first": builder.raw( "COALESCE(?, 0)", [ 10 ] ), + "second": 20, + "third": builder.raw( "COALESCE(?, ?)", [ 30, 40 ] ) + }, + toSql = true + ); + + expect( reMatch( "\?", sql ) ).toHaveLength( 4 ); + expect( getTestBindings( builder ) ).toBe( [ 10, 20, 30, 40 ] ); + } ); + it( "can insert with null values", function() { testCase( function( builder ) { return builder @@ -2863,6 +2945,16 @@ component extends="testbox.system.BaseSpec" { }, updateWithRaw() ); } ); + it( "preserves bindings carried by update expressions", function() { + var builder = getBuilder(); + var sql = builder + .from( "hits" ) + .update( values = { "count": builder.raw( "COALESCE(?, 0) + ?", [ 10, 1 ] ) }, toSql = true ); + + expect( reMatch( "\?", sql ) ).toHaveLength( 2 ); + expect( getTestBindings( builder ) ).toBe( [ 10, 1 ] ); + } ); + it( "can use an expression in an update table or from clause", function() { testCase( function( builder ) { return builder @@ -3284,6 +3376,21 @@ component extends="testbox.system.BaseSpec" { expected = upsertUpdateWithExplicitValue() ); } ); + + it( "preserves bindings carried by upsert expressions", function() { + var builder = getBuilder(); + var sql = builder + .table( "scores" ) + .upsert( + values = { "id": 1, "score": builder.raw( "COALESCE(?, 0)", [ 2 ] ) }, + target = [ "id" ], + update = { "score": builder.raw( "? + 1", [ 3 ] ) }, + toSql = true + ); + + expect( reMatch( "\?", sql ) ).toHaveLength( 3 ); + expect( getTestBindings( builder ) ).toBe( [ 1, 2, 3 ] ); + } ); } ); describe( "delete statements", function() { diff --git a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc index 1e5d0908..b6291abf 100644 --- a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc +++ b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc @@ -108,6 +108,19 @@ component extends="testbox.system.BaseSpec" { expect( builder.getColumns().map( ( column ) => column.value ) ).toBe( [ "id" ] ); } ); + + it( "does not execute temporary get columns with stale select bindings", function() { + var builder = new qb.models.Query.QueryBuilder() + .pretend() + .selectRaw( "CASE WHEN id = ? THEN name END AS selectedName", [ 10 ] ) + .from( "users" ); + + builder.get( columns = "name" ); + + expect( builder.getQueryLog()[ 1 ].sql ).toBe( "SELECT ""name"" FROM ""users""" ); + expect( builder.getQueryLog()[ 1 ].bindings ).toBeEmpty(); + expect( getTestBindings( builder ) ).toBe( [ 10 ] ); + } ); } ); describe( "first", function() { @@ -494,6 +507,17 @@ component extends="testbox.system.BaseSpec" { } ); describe( "chunk", function() { + it( "rejects non-positive chunk sizes", function() { + for ( var max in [ 0, -1 ] ) { + expect( function() { + getBuilder() + .from( "users" ) + .chunk( max, function() { + } ); + } ).toThrow( type = "InvalidChunkSize" ); + } + } ); + it( "can chunk a query into smaller sections", function() { var builder = getBuilder(); var expectedQuery100 = queryNew( "name", "varchar" ); @@ -720,6 +744,31 @@ component extends="testbox.system.BaseSpec" { expect( runQueryLog[ 1 ] ).toBe( { sql: "SELECT COALESCE(COUNT(*), 0) AS ""aggregate"" FROM ""users""", options: {} } ); } ); + it( "does not execute aggregates with bindings from removed orders", function() { + var executions = []; + var grammar = new qb.models.Grammars.BaseGrammar(); + grammar.setInterceptorService( { + processState: function( state, data ) { + if ( arguments.state == "preQBExecute" ) { + executions.append( arguments.data ); + } + } + } ); + var builder = new qb.models.Query.QueryBuilder( grammar = grammar ) + .pretend() + .from( "users" ) + .orderByRaw( "CASE WHEN id = ? THEN 0 ELSE 1 END", [ 10 ] ); + + try { + builder.count(); + } catch ( any ignored ) { + } + + expect( executions ).toHaveLength( 1 ); + expect( executions[ 1 ].sql ).toBe( "SELECT COALESCE(COUNT(*), 0) AS ""aggregate"" FROM ""users""" ); + expect( executions[ 1 ].bindings ).toBeEmpty(); + } ); + it( "can count a specific column", function() { var builder = getBuilder(); var expectedCount = 1; diff --git a/tests/specs/Query/PostgresQueryBuilderSpec.cfc b/tests/specs/Query/PostgresQueryBuilderSpec.cfc index 915c8ea6..3bab3e2d 100644 --- a/tests/specs/Query/PostgresQueryBuilderSpec.cfc +++ b/tests/specs/Query/PostgresQueryBuilderSpec.cfc @@ -1,5 +1,36 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { + function run() { + super.run(); + + describe( "PostgreSQL conflict returning clauses", function() { + it( "places returning after an upsert conflict clause exactly once", function() { + var sql = getBuilder() + .from( "users" ) + .returning( "id" ) + .upsert( + values = { "id": 1, "name": "Jane" }, + target = [ "id" ], + update = [ "name" ], + toSql = true + ); + + expect( reMatchNoCase( "RETURNING", sql ) ).toHaveLength( 1 ); + expect( findNoCase( "RETURNING", sql ) ).toBeGT( findNoCase( "ON CONFLICT", sql ) ); + } ); + + it( "places returning after insert ignore conflict handling", function() { + var sql = getBuilder() + .from( "users" ) + .returning( "id" ) + .insertIgnore( values = { "id": 1, "name": "Jane" }, toSql = true ); + + expect( reMatchNoCase( "RETURNING", sql ) ).toHaveLength( 1 ); + expect( findNoCase( "RETURNING", sql ) ).toBeGT( findNoCase( "ON CONFLICT", sql ) ); + } ); + } ); + } + function selectAllColumns() { return "SELECT * FROM ""users"""; } diff --git a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc index 94c319d4..543f37cf 100644 --- a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc +++ b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc @@ -1,5 +1,26 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { + function run() { + super.run(); + + describe( "SQLite conflict returning clauses", function() { + it( "places returning after an upsert conflict clause exactly once", function() { + var sql = getBuilder() + .from( "users" ) + .returning( "id" ) + .upsert( + values = { "id": 1, "name": "Jane" }, + target = [ "id" ], + update = [ "name" ], + toSql = true + ); + + expect( reMatchNoCase( "RETURNING", sql ) ).toHaveLength( 1 ); + expect( findNoCase( "RETURNING", sql ) ).toBeGT( findNoCase( "ON CONFLICT", sql ) ); + } ); + } ); + } + private function getBuilder() { variables.utils = getMockBox().createMock( "qb.models.Query.QueryUtils" ).init(); variables.grammar = getMockBox().createMock( "qb.models.Grammars.SQLiteGrammar" ).init( variables.utils ); From fb269a6b8bc418db3ae626106bd526a710d1ef16 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 13 Aug 2026 16:00:29 -0600 Subject: [PATCH 019/119] fix(QueryBuilder): preserve specialized query behavior --- models/Grammars/SqlServerGrammar.cfc | 1 + models/Query/JoinClause.cfc | 25 ++++- models/Query/QueryBuilder.cfc | 102 +++++++++++++----- tests/resources/AbstractQueryBuilderSpec.cfc | 71 ++++++++++++ tests/specs/Query/Abstract/JoinClauseSpec.cfc | 21 ++++ tests/specs/Query/Abstract/PaginationSpec.cfc | 34 ++++++ .../Query/Abstract/QueryExecutionSpec.cfc | 17 +++ .../specs/Query/SqlServerQueryBuilderSpec.cfc | 10 ++ 8 files changed, 254 insertions(+), 27 deletions(-) diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index 560f5e40..dc98cc56 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -84,6 +84,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { "\""", "all" ); + escapedPath = replace( escapedPath, "'", "''", "all" ); return "#wrapColumn( column.formatted )# #column.bulkSqlType# '$.""#escapedPath#""'"; } ) .toList( ", " ); diff --git a/models/Query/JoinClause.cfc b/models/Query/JoinClause.cfc index 6aaaaa59..66db2eef 100644 --- a/models/Query/JoinClause.cfc +++ b/models/Query/JoinClause.cfc @@ -83,8 +83,29 @@ component displayname="JoinClause" accessors="true" extends="qb.models.Query.Que : arguments.lateralRawExpression; variables.lateralBindings = arguments.lateralBindings; - super.init( joiningQuery.getGrammar(), joiningQuery.getUtils() ); - + super.init( + grammar = joiningQuery.getGrammar(), + utils = joiningQuery.getUtils(), + returnFormat = joiningQuery.getReturnFormat(), + returnFormatterRegistry = joiningQuery.getReturnFormatterRegistry(), + preventDuplicateJoins = joiningQuery.getPreventDuplicateJoins(), + validateOperatorsAndCombinators = joiningQuery.getValidateOperatorsAndCombinators(), + validateQueryExecuteReturnType = joiningQuery.getValidateQueryExecuteReturnType(), + paginationCollector = isNull( joiningQuery.getPaginationCollector() ) ? javacast( "null", "" ) : joiningQuery.getPaginationCollector(), + columnFormatter = isNull( joiningQuery.getColumnFormatter() ) ? javacast( "null", "" ) : joiningQuery.getColumnFormatter(), + defaultOptions = joiningQuery.getDefaultOptions(), + sqlCommenter = joiningQuery.getSqlCommenter(), + shouldMaxRowsOverrideToAll = joiningQuery.getShouldMaxRowsOverrideToAll(), + collectQueryLog = joiningQuery.getCollectQueryLog(), + validateDuplicateSelectColumns = joiningQuery.getValidateDuplicateSelectColumns() + ); + if ( !isNull( joiningQuery.getShouldWrapValues() ) ) { + if ( joiningQuery.getShouldWrapValues() ) { + withWrappingValues(); + } else { + withoutWrappingValues(); + } + } return this; } diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 1ce47224..5287f815 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -281,6 +281,7 @@ component displayname="QueryBuilder" accessors="true" { */ variables.bindings = { "commonTables": [], + "aggregate": [], "select": [], "from": [], "join": [], @@ -420,6 +421,7 @@ component displayname="QueryBuilder" accessors="true" { variables.updates = {}; variables.bindings = { "commonTables": [], + "aggregate": [], "select": [], "from": [], "join": [], @@ -1394,7 +1396,7 @@ component displayname="QueryBuilder" accessors="true" { } } variables.joins.append( arguments.table ); - addBindings( arguments.table.getBindings(), "join" ); + addBindings( getJoinBindings( arguments.table ), "join" ); return this; } @@ -1412,7 +1414,7 @@ component displayname="QueryBuilder" accessors="true" { } } variables.joins.append( join ); - addBindings( join.getBindings(), "join" ); + addBindings( getJoinBindings( join ), "join" ); return this; } @@ -1430,11 +1432,26 @@ component displayname="QueryBuilder" accessors="true" { } } variables.joins.append( join ); - addBindings( join.getBindings(), "join" ); + addBindings( getJoinBindings( join ), "join" ); return this; } + /** + * Returns bindings in the order they appear within a compiled join. + */ + private array function getJoinBindings( required any join ) { + var bindings = []; + if ( + arguments.join.isJoin() && + getUtils().isExpression( arguments.join.getTable() ) + ) { + bindings.append( extractExpressionBindings( arguments.join.getTable() ), true ); + } + bindings.append( arguments.join.getBindings(), true ); + return bindings; + } + /** * Adds a FULL JOIN to another table. * @@ -1603,7 +1620,9 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function crossJoin( required any table ) { - variables.joins.append( new qb.models.Query.JoinClause( this, "cross", arguments.table ) ); + var join = new qb.models.Query.JoinClause( this, "cross", arguments.table ); + variables.joins.append( join ); + addBindings( getJoinBindings( join ), "join" ); return this; } @@ -2127,10 +2146,11 @@ component displayname="QueryBuilder" accessors="true" { any value, string combinator = "and" ) { + var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); arrayAppend( variables.wheres, { - column: mapToColumnType( applyColumnFormatter( arguments.column ) ), + column: typedColumn, operator: arguments.operator, value: isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value, combinator: arguments.combinator, @@ -2138,9 +2158,7 @@ component displayname="QueryBuilder" accessors="true" { } ); - if ( getUtils().isExpression( arguments.column ) ) { - addExpressionBindings( arguments.column, "where" ); - } + addColumnBindings( [ typedColumn ], "where" ); if ( !isNull( arguments.value ) && getUtils().isExpression( arguments.value ) ) { addExpressionBindings( arguments.value, "where" ); @@ -2332,13 +2350,15 @@ component displayname="QueryBuilder" accessors="true" { arguments.query = newQuery(); callback( arguments.query ); } + var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); variables.wheres.append( { type: "sub", - column: mapToColumnType( applyColumnFormatter( arguments.column ) ), + column: typedColumn, operator: arguments.operator, query: arguments.query, combinator: arguments.combinator } ); + addColumnBindings( [ typedColumn ], "where" ); addBindings( query.getBindings(), "where" ); return this; } @@ -2385,14 +2405,16 @@ component displayname="QueryBuilder" accessors="true" { arguments.values = normalizeToArray( arguments.values ); var type = negate ? "notIn" : "in"; + var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); variables.wheres.append( { type: type, - column: mapToColumnType( applyColumnFormatter( arguments.column ) ), + column: typedColumn, values: arguments.values, combinator: arguments.combinator } ); var bindings = []; + addColumnBindings( [ typedColumn ], "where" ); for ( var value in arguments.values ) { if ( getUtils().isExpression( value ) ) { bindings.append( extractExpressionBindings( value ), true ); @@ -2456,15 +2478,17 @@ component displayname="QueryBuilder" accessors="true" { ); } + var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); variables.wheres.append( { type: "inBulk", - column: mapToColumnType( applyColumnFormatter( arguments.column ) ), + column: typedColumn, sqlType: arguments.sqlType, isEmpty: arguments.values.isEmpty(), negate: arguments.negate, combinator: arguments.combinator } ); + addColumnBindings( [ typedColumn ], "where" ); if ( !arguments.values.isEmpty() ) { var serializedValues = extractedBindings.map( function( binding ) { return binding.null ? javacast( "null", "" ) : binding.value; @@ -2526,12 +2550,14 @@ component displayname="QueryBuilder" accessors="true" { } var type = negate ? "notInSub" : "inSub"; + var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); variables.wheres.append( { type: type, - column: mapToColumnType( applyColumnFormatter( arguments.column ) ), + column: typedColumn, query: arguments.query, combinator: arguments.combinator } ); + addColumnBindings( [ typedColumn ], "where" ); addBindings( arguments.query.getBindings(), "where" ); return this; @@ -2609,13 +2635,16 @@ component displayname="QueryBuilder" accessors="true" { ); } + var firstColumn = mapToColumnType( applyColumnFormatter( arguments.first ) ); + var secondColumn = mapToColumnType( applyColumnFormatter( arguments.second ) ); variables.wheres.append( { type: "column", - first: mapToColumnType( applyColumnFormatter( arguments.first ) ), + first: firstColumn, operator: arguments.operator, - second: mapToColumnType( applyColumnFormatter( arguments.second ) ), + second: secondColumn, combinator: arguments.combinator } ); + addColumnBindings( [ firstColumn, secondColumn ], "where" ); return this; } @@ -2727,11 +2756,9 @@ component displayname="QueryBuilder" accessors="true" { } var type = negate ? "notNull" : "null"; - variables.wheres.append( { - type: type, - column: mapToColumnType( applyColumnFormatter( arguments.column ) ), - combinator: arguments.combinator - } ); + var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); + variables.wheres.append( { type: type, column: typedColumn, combinator: arguments.combinator } ); + addColumnBindings( [ typedColumn ], "where" ); return this; } @@ -2790,6 +2817,7 @@ component displayname="QueryBuilder" accessors="true" { negate = false ) { var type = negate ? "notBetween" : "between"; + var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); if ( isClosure( arguments.start ) || isCustomFunction( arguments.start ) ) { var callback = arguments.start; @@ -2803,6 +2831,7 @@ component displayname="QueryBuilder" accessors="true" { callback( arguments.end ); } + addColumnBindings( [ typedColumn ], "where" ); if ( utils.isExpression( arguments.start ) ) { addExpressionBindings( arguments.start, "where" ); } else { @@ -2834,7 +2863,7 @@ component displayname="QueryBuilder" accessors="true" { variables.wheres.append( { type: type, - column: mapToColumnType( applyColumnFormatter( arguments.column ) ), + column: typedColumn, start: arguments.start, end: arguments.end, combinator: arguments.combinator @@ -3511,12 +3540,28 @@ component displayname="QueryBuilder" accessors="true" { * @return PaginationCollector */ public any function simplePaginate( numeric page = 1, numeric maxRows = 25, struct options = {} ) { - var results = forPage( page, maxRows ).limit( maxRows + 1 ).get( options = options ); - return getPaginationCollector().generateSimpleWithResults( + var shouldReturnAllRows = shouldMaxRowsOverrideToAll( arguments.maxRows ); + var paginationQuery = forPage( arguments.page, arguments.maxRows ); + if ( !shouldReturnAllRows ) { + paginationQuery.limit( arguments.maxRows + 1 ); + } + var results = paginationQuery.get( options = arguments.options ); + var collectedResults = getPaginationCollector().generateSimpleWithResults( results = results, page = arguments.page, - maxRows = arguments.maxRows + maxRows = shouldReturnAllRows ? max( 1, results.len() ) : arguments.maxRows ); + if ( + shouldReturnAllRows && + isStruct( collectedResults ) && + collectedResults.keyExists( "pagination" ) && + isStruct( collectedResults.pagination ) + ) { + collectedResults.pagination.maxRows = 0; + collectedResults.pagination.offset = 0; + collectedResults.pagination.hasMore = false; + } + return collectedResults; } /** @@ -3529,7 +3574,7 @@ component displayname="QueryBuilder" accessors="true" { */ private numeric function getCountForPagination( struct options = {} ) { if ( !variables.groups.isEmpty() || !variables.havings.isEmpty() || variables.distinct ) { - var countSource = clone().setOrders( [] ); + var countSource = clone().clearOrders(); return newQuery().fromSub( "aggregate_table", countSource ).count( options = arguments.options ); } return count( options = arguments.options ); @@ -4259,6 +4304,7 @@ component displayname="QueryBuilder" accessors="true" { "commonTables", "update", "insert", + "aggregate", "select", "from", "join", @@ -4305,6 +4351,7 @@ component displayname="QueryBuilder" accessors="true" { "commonTables", "update", "insert", + "aggregate", "select", "join", "where", @@ -5201,7 +5248,8 @@ component displayname="QueryBuilder" accessors="true" { return sql; } - return getUtils().replaceBindings( sql, getBindings(), arguments.showBindings == "inline" ); + var bindings = getBindings( except = getAggregate().isEmpty() ? [] : [ "select", "orderBy" ] ); + return getUtils().replaceBindings( sql, bindings, arguments.showBindings == "inline" ); } /** @@ -5360,14 +5408,18 @@ component displayname="QueryBuilder" accessors="true" { private any function withAggregate( required struct aggregate, required any callback ) { var originalAggregate = getAggregate(); var originalOrders = getOrders(); + var originalAggregateBindings = variables.bindings.aggregate; setAggregate( arguments.aggregate ); setOrders( [] ); + variables.bindings.aggregate = []; + addColumnBindings( [ arguments.aggregate.column ], "aggregate" ); var result = javacast( "null", "" ); try { result = callback(); } finally { setAggregate( originalAggregate ); setOrders( originalOrders ); + variables.bindings.aggregate = originalAggregateBindings; } return result; } diff --git a/tests/resources/AbstractQueryBuilderSpec.cfc b/tests/resources/AbstractQueryBuilderSpec.cfc index bdd97892..4f8cdd91 100644 --- a/tests/resources/AbstractQueryBuilderSpec.cfc +++ b/tests/resources/AbstractQueryBuilderSpec.cfc @@ -183,6 +183,77 @@ component extends="testbox.system.BaseSpec" { expect( getTestBindings( builder ) ).toBe( [ 1, 2, 3, 4, 5, 6, 7, 8 ] ); } ); + it( "preserves bindings carried by expression columns across predicates", function() { + var builder = getBuilder(); + builder + .from( "users" ) + .whereIn( builder.raw( "COALESCE(?, id)", [ 1 ] ), [ 2 ] ) + .whereNull( builder.raw( "NULLIF(?, id)", [ 3 ] ) ) + .whereBetween( builder.raw( "COALESCE(?, id)", [ 4 ] ), 5, 6 ) + .whereColumn( + builder.raw( "COALESCE(?, id)", [ 7 ] ), + "=", + builder.raw( "COALESCE(?, other_id)", [ 8 ] ) + ) + .where( + builder.raw( "COALESCE(?, id)", [ 9 ] ), + "=", + function( query ) { + query + .select( "id" ) + .from( "accounts" ) + .where( "active", 10 ); + } + ) + .whereIn( builder.raw( "COALESCE(?, id)", [ 11 ] ), function( query ) { + query + .select( "id" ) + .from( "accounts" ) + .where( "active", 12 ); + } ); + + expect( reMatch( "\?", builder.toSQL() ) ).toHaveLength( 12 ); + expect( getTestBindings( builder ) ).toBe( [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] ); + } ); + + it( "preserves bindings carried by expression columns in bulk predicates", function() { + var builder = getBuilder(); + builder.from( "users" ).whereInBulk( builder.raw( "COALESCE(?, id)", [ 1 ] ), [ 2, 3 ] ); + + expect( getTestBindings( builder )[ 1 ] ).toBe( 1 ); + expect( deserializeJSON( getTestBindings( builder )[ 2 ] ) ).toBe( [ 2, 3 ] ); + } ); + + it( "preserves bindings carried by expression join tables", function() { + var builder = getBuilder(); + builder + .from( "users" ) + .join( + builder.raw( "(SELECT ? AS id) joined", [ 1 ] ), + "joined.id", + "=", + "users.id" + ) + .crossJoin( builder.raw( "(SELECT ? AS id) crossed", [ 2 ] ) ); + + expect( reMatch( "\?", builder.toSQL() ) ).toHaveLength( 2 ); + expect( getTestBindings( builder ) ).toBe( [ 1, 2 ] ); + expect( getTestBindings( builder.clone() ) ).toBe( [ 1, 2 ] ); + } ); + it( "provides a grammar-specific helper for concat", function() { testCase( function( builder ) { builder.select( builder.concat( "my_alias", "a,b,c,d" ) ).from( "users" ); diff --git a/tests/specs/Query/Abstract/JoinClauseSpec.cfc b/tests/specs/Query/Abstract/JoinClauseSpec.cfc index a13eb057..eaf6784e 100644 --- a/tests/specs/Query/Abstract/JoinClauseSpec.cfc +++ b/tests/specs/Query/Abstract/JoinClauseSpec.cfc @@ -32,6 +32,27 @@ component extends="testbox.system.BaseSpec" { new qb.models.Query.JoinClause( query, "left outer", "sometable" ); } ).notToThrow(); } ); + + it( "inherits column formatting from the joining query", function() { + query.setColumnFormatter( function( column ) { + return listLen( column, "." ) == 1 ? "qualified.#column#" : column; + } ); + + var join = new qb.models.Query.JoinClause( query, "inner", "posts" ); + join.on( "id", "=", "user_id" ); + + expect( join.getWheres()[ 1 ].first.value ).toBe( "qualified.id" ); + expect( join.getWheres()[ 1 ].second.value ).toBe( "qualified.user_id" ); + } ); + + it( "inherits operator validation from the joining query", function() { + query.setValidateOperatorsAndCombinators( false ); + var join = new qb.models.Query.JoinClause( query, "inner", "posts" ); + + expect( function() { + join.on( "posts.user_id", "IS NOT DISTINCT FROM", "users.id" ); + } ).notToThrow(); + } ); } ); describe( "adding join conditions", function() { diff --git a/tests/specs/Query/Abstract/PaginationSpec.cfc b/tests/specs/Query/Abstract/PaginationSpec.cfc index 7aca5a61..238ae4bc 100644 --- a/tests/specs/Query/Abstract/PaginationSpec.cfc +++ b/tests/specs/Query/Abstract/PaginationSpec.cfc @@ -70,6 +70,21 @@ component extends="testbox.system.BaseSpec" { expect( builder.getOrders() ).toHaveLength( 1 ); } ); + it( "removes order bindings from grouped pagination count subqueries", function() { + var builder = getBuilder(); + builder.getGrammar().$( "runQuery", queryNew( "aggregate", "integer", [ { aggregate: 1 } ] ) ); + + builder + .from( "users" ) + .groupBy( "status" ) + .orderBy( builder.raw( "CASE WHEN ? = 1 THEN id END", [ 99 ] ) ) + .paginate(); + + var countCall = builder.getGrammar().$callLog().runQuery[ 1 ]; + expect( countCall.sql ).notToInclude( "CASE WHEN" ); + expect( countCall.bindings ).toBeEmpty(); + } ); + it( "can get results for subsequent pages", function() { var builder = getBuilder(); var expectedResults = []; @@ -118,6 +133,25 @@ component extends="testbox.system.BaseSpec" { } ); } ); + it( "returns all rows when maxRows passes the override check", function() { + var builder = getBuilder(); + var expectedResults = [ { "id": 1 }, { "id": 2 }, { "id": 3 } ]; + builder.$( "runQuery", queryNew( "id", "integer", expectedResults ) ); + + var results = builder.from( "users" ).simplePaginate( page = 1, maxRows = -1 ); + + expect( builder.$callLog().runQuery[ 1 ].sql ).notToInclude( "LIMIT" ); + expect( results ).toBe( { + "pagination": { + "maxRows": 0, + "offset": 0, + "page": 1, + "hasMore": false + }, + "results": expectedResults + } ); + } ); + it( "can does not limit the query when the maxrows passes the override check", function() { var builder = getBuilder(); builder.setShouldMaxRowsOverrideToAll( function( maxrows ) { diff --git a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc index b6291abf..e6340f33 100644 --- a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc +++ b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc @@ -1141,6 +1141,23 @@ component extends="testbox.system.BaseSpec" { expect( builder.getAggregate() ).toBeEmpty( "Aggregate should have been cleared after running" ); } ); + + it( "passes bindings carried by aggregate expressions to the grammar", function() { + var builder = getBuilder(); + var expectedQuery = queryNew( "aggregate", "integer", [ { aggregate: 42 } ] ); + builder.getGrammar().$( "runQuery", expectedQuery ); + + var result = builder + .from( "users" ) + .sum( builder.raw( "CASE WHEN active = ? THEN amount ELSE 0 END", [ 1 ] ) ); + + expect( result ).toBe( 42 ); + expect( + builder.getGrammar().$callLog().runQuery[ 1 ].bindings.map( function( binding ) { + return binding.value; + } ) + ).toBe( [ 1 ] ); + } ); } ); describe( "exists", function() { diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index 65073443..98ab5ae6 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -32,6 +32,16 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { ] ); } ); + it( "escapes apostrophes in bulk insert JSON paths", function() { + var sql = getBuilder() + .from( "records" ) + .insertBulk( values = [ { "author's_note": "value" } ], toSql = true ); + + expect( sql ).toBe( [ + "INSERT INTO [records] ([author's_note]) SELECT [author's_note] FROM OPENJSON(?) WITH ([author's_note] NVARCHAR(MAX) '$.""author''s_note""')" + ] ); + } ); + it( "applies returning columns to bulk inserts", function() { var sql = getBuilder() .from( "users" ) From 2b607aa33bd53362c9d1f1e4a7adb21a3b9bcebd Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 13 Aug 2026 16:34:13 -0600 Subject: [PATCH 020/119] fix(QueryBuilder): preserve query utility semantics --- models/Query/QueryBuilder.cfc | 24 +-- models/Query/QueryUtils.cfc | 201 +++++++++++++++--- models/Schema/SchemaBuilder.cfc | 5 +- tests/resources/AbstractQueryBuilderSpec.cfc | 45 ++++ tests/resources/AbstractSchemaBuilderSpec.cfc | 24 +++ tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 52 +++++ .../specs/Query/SqlServerQueryBuilderSpec.cfc | 13 ++ 7 files changed, 317 insertions(+), 47 deletions(-) diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 5287f815..9745d3a9 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -1764,16 +1764,16 @@ component displayname="QueryBuilder" accessors="true" { } // create the table reference - arguments.table = getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ); - - // merge bindings - addBindings( arguments.input.getBindings(), "join" ); + arguments.table = raw( + getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ), + arguments.input.getBindings() + ); // remove the non-standard arguments structDelete( arguments, "input" ); structDelete( arguments, "alias" ); - return joinRaw( argumentCollection = arguments ); + return join( argumentCollection = arguments ); } private function outerOrCrossApply( required string name, required string type, required tableLikeSource ) { @@ -1912,14 +1912,12 @@ component displayname="QueryBuilder" accessors="true" { } // create the table reference - var table = raw( getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ) ); - - // merge bindings - mergeBindings( arguments.input ); - - arrayAppend( variables.joins, new qb.models.Query.JoinClause( this, "cross", table ) ); + var table = raw( + getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ), + arguments.input.getBindings() + ); - return this; + return crossJoin( table ); } /** @@ -3971,7 +3969,7 @@ component displayname="QueryBuilder" accessors="true" { } public QueryBuilder function returningRaw( required any columns ) { - variables.returning = isArray( arguments.columns ) ? arguments.columns : listToArray( arguments.columns ); + variables.returning = isArray( arguments.columns ) ? arguments.columns : [ arguments.columns ]; variables.returning = variables.returning.map( function( column ) { return mapToColumnType( new Expression( column ) ); } ); diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index b02760bd..8cf319d4 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -143,38 +143,162 @@ component singleton displayname="QueryUtils" accessors="true" { * @return string */ public string function replaceBindings( required string sql, required array bindings, boolean inline = false ) { + var output = []; var index = 1; - return replace( - arguments.sql, - "?", - function( pattern, position, originalString ) { - var thisBinding = bindings[ index ]; + var position = 1; + var state = "sql"; + var dollarQuoteDelimiter = ""; + var sqlLength = len( arguments.sql ); + + while ( position <= sqlLength ) { + var character = mid( arguments.sql, position, 1 ); + var nextCharacter = position < sqlLength ? mid( arguments.sql, position + 1, 1 ) : ""; + + if ( state == "lineComment" ) { + output.append( character ); + if ( character == chr( 10 ) || character == chr( 13 ) ) { + state = "sql"; + } + position++; + continue; + } - index++; + if ( state == "blockComment" ) { + output.append( character ); + if ( character == "*" && nextCharacter == "/" ) { + output.append( nextCharacter ); + position += 2; + state = "sql"; + } else { + position++; + } + continue; + } - if ( !isStruct( thisBinding ) ) { - return castAsSqlType( value = thisBinding, sqltype = "varchar" ); + if ( state == "dollarQuote" ) { + if ( + mid( arguments.sql, position, len( dollarQuoteDelimiter ) ) == + dollarQuoteDelimiter + ) { + output.append( dollarQuoteDelimiter ); + position += len( dollarQuoteDelimiter ); + state = "sql"; + } else { + output.append( character ); + position++; } + continue; + } - if ( inline ) { - return castAsSqlType( - value = thisBinding.null ? javacast( "null", "" ) : thisBinding.value, - sqltype = thisBinding.cfsqltype - ); + if ( state != "sql" ) { + output.append( character ); + if ( + ( state == "singleQuote" || state == "doubleQuote" || state == "backtickQuote" ) && + character == chr( 92 ) && + nextCharacter != "" + ) { + output.append( nextCharacter ); + position += 2; + continue; + } + + var closingCharacter = state == "singleQuote" ? "'" : ( state == "doubleQuote" ? """" : chr( 96 ) ); + if ( character == closingCharacter ) { + if ( nextCharacter == closingCharacter ) { + output.append( nextCharacter ); + position += 2; + } else { + position++; + state = "sql"; + } + } else { + position++; } + continue; + } + + if ( character == "-" && nextCharacter == "-" ) { + output.append( character ); + output.append( nextCharacter ); + position += 2; + state = "lineComment"; + continue; + } + + if ( character == "/" && nextCharacter == "*" ) { + output.append( character ); + output.append( nextCharacter ); + position += 2; + state = "blockComment"; + continue; + } - var orderedBinding = structNew( "ordered" ); - for ( var type in [ "value", "cfsqltype", "null" ] ) { - orderedBinding[ type ] = thisBinding[ type ]; + if ( character == "$" ) { + var dollarQuoteMatch = reFind( + "^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$", + mid( arguments.sql, position ), + 1, + true + ); + if ( dollarQuoteMatch.len[ 1 ] > 0 ) { + dollarQuoteDelimiter = mid( arguments.sql, position, dollarQuoteMatch.len[ 1 ] ); + output.append( dollarQuoteDelimiter ); + position += len( dollarQuoteDelimiter ); + state = "dollarQuote"; + continue; } - if ( isBinary( orderedBinding.value ) ) { - orderedBinding.value = toBase64( orderedBinding.value ); + } + + if ( character == "'" || character == """" || character == chr( 96 ) ) { + output.append( character ); + state = character == "'" ? "singleQuote" : ( character == """" ? "doubleQuote" : "backtickQuote" ); + position++; + continue; + } + + if ( character == "?" ) { + if ( index > arguments.bindings.len() ) { + throw( + type = "BindingMismatch", + message = "The SQL contains more parameter placeholders than supplied bindings." + ); } - var stringifiedBinding = serializeJSON( orderedBinding ); - return stringifiedBinding; - }, - "all" - ); + output.append( formatBindingForDisplay( arguments.bindings[ index ], arguments.inline ) ); + index++; + position++; + continue; + } + + output.append( character ); + position++; + } + + return output.toList( "" ); + } + + /** + * Formats a single binding for diagnostic SQL output. + */ + private string function formatBindingForDisplay( required any binding, boolean inline = false ) { + if ( !isStruct( arguments.binding ) ) { + return castAsSqlType( value = arguments.binding, sqltype = "varchar" ); + } + + if ( arguments.inline ) { + return castAsSqlType( + value = arguments.binding.null ? javacast( "null", "" ) : arguments.binding.value, + sqltype = arguments.binding.cfsqltype + ); + } + + var orderedBinding = structNew( "ordered" ); + for ( var type in [ "value", "cfsqltype", "null" ] ) { + orderedBinding[ type ] = arguments.binding[ type ]; + } + if ( isBinary( orderedBinding.value ) ) { + orderedBinding.value = toBase64( orderedBinding.value ); + } + return serializeJSON( orderedBinding ); } /** @@ -190,13 +314,18 @@ component singleton displayname="QueryUtils" accessors="true" { } if ( isArray( value ) ) { - return arraySame( - value, - function( val ) { - return inferSqlType( val, grammar ); - }, - "VARCHAR" - ); + var inferredTypes = []; + for ( var i = 1; i <= arguments.value.len(); i++ ) { + if ( isNull( arguments.value[ i ] ) ) { + continue; + } + var item = arguments.value[ i ]; + if ( isStruct( item ) && item.keyExists( "null" ) && item.null ) { + continue; + } + inferredTypes.append( inferSqlType( item, arguments.grammar ) ); + } + return arraySame( inferredTypes, ( sqlType ) => sqlType, "VARCHAR" ); } if ( isStruct( value ) ) { @@ -501,10 +630,16 @@ component singleton displayname="QueryUtils" accessors="true" { return arguments.defaultValue; } + if ( isNull( arguments.args[ 1 ] ) ) { + return arguments.defaultValue; + } var initial = closure( arguments.args[ 1 ] ); - for ( var arg in arguments.args ) { - if ( closure( arg ) != initial ) { + for ( var i = 1; i <= arguments.args.len(); i++ ) { + if ( + isNull( arguments.args[ i ] ) || + closure( arguments.args[ i ] ) != initial + ) { return defaultValue; } } @@ -559,7 +694,7 @@ component singleton displayname="QueryUtils" accessors="true" { } private string function deriveNumericSqlType( required numeric value ) { - var isInteger = reFind( "^\d+$", arguments.value ) > 0; + var isInteger = reFind( "^-?\d+$", arguments.value ) > 0; return isInteger ? variables.integerSqlType : variables.decimalSqlType; } diff --git a/models/Schema/SchemaBuilder.cfc b/models/Schema/SchemaBuilder.cfc index cce40fd5..03e30bf5 100644 --- a/models/Schema/SchemaBuilder.cfc +++ b/models/Schema/SchemaBuilder.cfc @@ -513,7 +513,10 @@ component accessors="true" { args, arguments.options, "query", - variables.pretending + variables.pretending, + function( data ) { + variables.queryLog.append( data ); + } ); return isDefined( "q.RecordCount" ) ? q.RecordCount > 0 : false; } diff --git a/tests/resources/AbstractQueryBuilderSpec.cfc b/tests/resources/AbstractQueryBuilderSpec.cfc index 4f8cdd91..8a25bc53 100644 --- a/tests/resources/AbstractQueryBuilderSpec.cfc +++ b/tests/resources/AbstractQueryBuilderSpec.cfc @@ -1709,6 +1709,44 @@ component extends="testbox.system.BaseSpec" { }, crossJoinSub() ); } ); + it( "correctly positions bindings using crossJoinSub", function() { + var builder = getBuilder(); + builder + .from( "A" ) + .where( "A.A", "=", "A" ) + .crossJoinSub( "B", function( query ) { + query.from( "B" ).where( "B.B", "=", "B" ); + } ) + .where( "A.C", "=", "C" ); + + expect( getTestBindings( builder ) ).toBe( [ "B", "A", "C" ] ); + } ); + + it( "does not retain bindings from prevented duplicate joinSub clauses", function() { + var builder = getBuilder().setPreventDuplicateJoins( true ); + var derivedTable = getBuilder().from( "contacts" ).where( "contacts.kind", "personal" ); + + builder + .from( "users AS u" ) + .joinSub( + "c", + derivedTable, + "u.id", + "=", + "c.user_id" + ) + .joinSub( + "c", + derivedTable, + "u.id", + "=", + "c.user_id" + ); + + expect( builder.getJoins() ).toHaveLength( 1 ); + expect( getTestBindings( builder ) ).toBe( [ "personal" ] ); + } ); + it( "correctly positions bindings using joinSub", function() { testCase( function( builder ) { builder @@ -2853,6 +2891,13 @@ component extends="testbox.system.BaseSpec" { }, returning() ); } ); + it( "preserves commas inside returningRaw expressions", function() { + var builder = getBuilder().returningRaw( "'last,first' AS label" ); + + expect( builder.getReturning() ).toHaveLength( 1 ); + expect( builder.getReturning()[ 1 ].value.getSQL() ).toBe( "'last,first' AS label" ); + } ); + it( "can return all from an insert", function() { testCase( function( builder ) { return builder diff --git a/tests/resources/AbstractSchemaBuilderSpec.cfc b/tests/resources/AbstractSchemaBuilderSpec.cfc index 8490ac39..984a1721 100644 --- a/tests/resources/AbstractSchemaBuilderSpec.cfc +++ b/tests/resources/AbstractSchemaBuilderSpec.cfc @@ -1848,6 +1848,30 @@ component extends="testbox.system.BaseSpec" { }, hasTable() ); } ); + it( "logs has table executions", function() { + var schema = getBuilder(); + schema + .getGrammar() + .$( "runQuery" ) + .$callback( function( + sql, + bindings, + options, + returnObject, + pretend, + postProcessHook + ) { + if ( !isNull( arguments.postProcessHook ) ) { + arguments.postProcessHook( { sql: arguments.sql, bindings: arguments.bindings, options: arguments.options } ); + } + return queryNew( "exists", "integer", [ { exists: 1 } ] ); + } ); + + schema.hasTable( "users" ); + + expect( schema.getQueryLog() ).toHaveLength( 1 ); + } ); + it( "has table in a schema", function() { testCase( function( schema ) { return schema.hasTable( diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index df6999c2..c7e05682 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -32,6 +32,10 @@ component extends="testbox.system.BaseSpec" { expect( utils.inferSqlType( 100, variables.mockGrammar ) ).toBe( "INTEGER" ); } ); + it( "negative integers", function() { + expect( utils.inferSqlType( -100, variables.mockGrammar ) ).toBe( "INTEGER" ); + } ); + it( "decimals", function() { expect( utils.inferSqlType( 4.50, variables.mockGrammar ) ).toBe( "DECIMAL" ); } ); @@ -172,6 +176,29 @@ component extends="testbox.system.BaseSpec" { expect( utils.inferSqlType( [ 1, 2 ], variables.mockGrammar ) ).toBe( "INTEGER" ); } ); + it( "infers matching negative and positive integers as integers", function() { + expect( utils.inferSqlType( [ -1, 2 ], variables.mockGrammar ) ).toBe( "INTEGER" ); + } ); + + it( "ignores null members when inferring an array type", function() { + expect( utils.inferSqlType( [ 1, javacast( "null", "" ) ], variables.mockGrammar ) ).toBe( "INTEGER" ); + expect( + utils.inferSqlType( + [ + utils.extractBinding( 1, variables.mockGrammar ), + utils.extractBinding( javacast( "null", "" ), variables.mockGrammar ) + ], + variables.mockGrammar + ) + ).toBe( "INTEGER" ); + } ); + + it( "defaults all-null arrays to VARCHAR", function() { + expect( + utils.inferSqlType( [ javacast( "null", "" ), javacast( "null", "" ) ], variables.mockGrammar ) + ).toBe( "VARCHAR" ); + } ); + it( "uses matching cfsqltypes from query parameter structs", function() { expect( utils.inferSqlType( @@ -219,6 +246,31 @@ component extends="testbox.system.BaseSpec" { } ); } ); + describe( "replaceBindings()", function() { + it( "only replaces parameter placeholders in executable SQL", function() { + var binding = utils.extractBinding( 42, variables.mockGrammar ); + var sql = "SELECT '?' AS single_quoted, ""why?"" AS double_quoted, #chr( 96 )#why?#chr( 96 )# AS backticked, $$?$$ AS dollar_quoted -- ?#chr( 10 )#FROM users WHERE id = ? /* ? */"; + + expect( utils.replaceBindings( sql, [ binding ], true ) ).toBe( + "SELECT '?' AS single_quoted, ""why?"" AS double_quoted, #chr( 96 )#why?#chr( 96 )# AS backticked, $$?$$ AS dollar_quoted -- ?#chr( 10 )#FROM users WHERE id = 42 /* ? */" + ); + } ); + + it( "replaces placeholders inside PostgreSQL array constructors", function() { + var binding = utils.extractBinding( "name", variables.mockGrammar ); + + expect( utils.replaceBindings( "SELECT ARRAY[?]", [ binding ], true ) ).toBe( "SELECT ARRAY['name']" ); + } ); + + it( "preserves question marks in escaped string literals", function() { + var binding = utils.extractBinding( 42, variables.mockGrammar ); + + expect( + utils.replaceBindings( "SELECT 'isn''t ?' AS marker FROM users WHERE id = ?", [ binding ], true ) + ).toBe( "SELECT 'isn''t ?' AS marker FROM users WHERE id = 42" ); + } ); + } ); + describe( "extractBinding()", function() { it( "includes sensible defaults", function() { var datetime = parseDateTime( "05/10/2016" ); diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index 98ab5ae6..a80208dc 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -32,6 +32,19 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { ] ); } ); + it( "infers bulk SQL types from negative and nullable values", function() { + var insertSql = getBuilder() + .from( "measurements" ) + .insertBulk( values = [ { "reading": -1 }, { "reading": javacast( "null", "" ) } ], toSql = true ); + var whereSql = getBuilder() + .from( "measurements" ) + .whereInBulk( "reading", [ -1, javacast( "null", "" ), 2 ] ) + .toSQL(); + + expect( insertSql[ 1 ] ).toInclude( "[reading] INTEGER" ); + expect( whereSql ).toInclude( "WITH ([value] INTEGER '$')" ); + } ); + it( "escapes apostrophes in bulk insert JSON paths", function() { var sql = getBuilder() .from( "records" ) From 619e54f36a08d70c339866da03789c4bdfd94f86 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 13 Aug 2026 19:20:27 -0600 Subject: [PATCH 021/119] fix(QueryBuilder): preserve binding ownership --- models/Query/QueryBuilder.cfc | 58 +++++++++---- models/Query/QueryUtils.cfc | 87 +++++++++++++++++-- tests/resources/AbstractQueryBuilderSpec.cfc | 87 +++++++++++++++++++ tests/specs/Query/Abstract/JoinClauseSpec.cfc | 9 ++ .../Query/Abstract/QueryExecutionSpec.cfc | 13 +++ tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 39 +++++++++ 6 files changed, 272 insertions(+), 21 deletions(-) diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 9745d3a9..30e17c70 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -702,6 +702,7 @@ component displayname="QueryBuilder" accessors="true" { arguments.query = newQuery(); callback( arguments.query ); } + arguments.query = snapshotBuilder( arguments.query ); variables.columns.append( { "type": "builder", "value": arguments.query, "alias": arguments.alias } ); addBindings( arguments.query.getBindings(), "select" ); return this; @@ -1386,6 +1387,7 @@ component displayname="QueryBuilder" accessors="true" { boolean preventDuplicateJoins = this.getPreventDuplicateJoins() ) { if ( getUtils().isBuilder( arguments.table ) ) { + arguments.table = cloneJoinClause( arguments.table, this ); if ( arguments.preventDuplicateJoins ) { var hasThisJoin = variables.joins.find( function( existingJoin ) { return existingJoin.isEqualTo( table ); @@ -2046,6 +2048,7 @@ component displayname="QueryBuilder" accessors="true" { if ( !isCustomFunction( variables.tableName ) ) { if ( getUtils().isExpression( getTableName() ) ) { memento[ "from" ] = getTableName().getSQL(); + memento[ "fromBindings" ] = getTableName().getBindings(); } else if ( getUtils().isBuilder( getTableName() ) ) { memento[ "from" ] = getTableName().toSQL(); } else { @@ -2059,6 +2062,7 @@ component displayname="QueryBuilder" accessors="true" { if ( !isCustomFunction( getTable() ) ) { if ( getUtils().isExpression( getTable() ) ) { memento[ "table" ] = getTable().getSQL(); + memento[ "tableBindings" ] = getTable().getBindings(); } else if ( getUtils().isBuilder( getTable() ) ) { memento[ "table" ] = getTable().toSQL(); } else { @@ -2348,6 +2352,7 @@ component displayname="QueryBuilder" accessors="true" { arguments.query = newQuery(); callback( arguments.query ); } + arguments.query = snapshotBuilder( arguments.query ); var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); variables.wheres.append( { type: "sub", @@ -2546,6 +2551,7 @@ component displayname="QueryBuilder" accessors="true" { arguments.query = newQuery(); callback( arguments.query ); } + arguments.query = snapshotBuilder( arguments.query ); var type = negate ? "notInSub" : "inSub"; var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); @@ -2675,6 +2681,7 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ private QueryBuilder function addWhereExistsQuery( query, combinator = "and", negate = false ) { + arguments.query = snapshotBuilder( arguments.query ); var type = negate ? "notExists" : "exists"; variables.wheres.append( { type: type, query: arguments.query, combinator: arguments.combinator } ); addBindings( query.getBindings(), "where" ); @@ -2719,6 +2726,7 @@ component displayname="QueryBuilder" accessors="true" { */ public QueryBuilder function addNestedWhereQuery( required QueryBuilder query, string combinator = "and" ) { if ( !query.getWheres().isEmpty() ) { + arguments.query = snapshotBuilder( arguments.query ); variables.wheres.append( { type: "nested", query: arguments.query, combinator: arguments.combinator } ); addBindings( query.getBindings(), "where" ); } @@ -2775,6 +2783,7 @@ component displayname="QueryBuilder" accessors="true" { arguments.query = newQuery(); callback( arguments.query ); } + arguments.query = snapshotBuilder( arguments.query ); var type = arguments.negate ? "notNullSub" : "nullSub"; variables.wheres.append( { type: type, query: arguments.query, combinator: arguments.combinator } ); @@ -2829,6 +2838,13 @@ component displayname="QueryBuilder" accessors="true" { callback( arguments.end ); } + if ( getUtils().isBuilder( arguments.start ) ) { + arguments.start = snapshotBuilder( arguments.start ); + } + if ( getUtils().isBuilder( arguments.end ) ) { + arguments.end = snapshotBuilder( arguments.end ); + } + addColumnBindings( [ typedColumn ], "where" ); if ( utils.isExpression( arguments.start ) ) { addExpressionBindings( arguments.start, "where" ); @@ -3302,6 +3318,8 @@ component displayname="QueryBuilder" accessors="true" { callback( arguments.query ); } + arguments.query = snapshotBuilder( arguments.query ); + variables.orders.append( { direction: arguments.direction, query: arguments.query } ); addBindings( arguments.query.getBindings(), "orderBy" ); return this; @@ -3368,6 +3386,7 @@ component displayname="QueryBuilder" accessors="true" { // replace the original query builder with the results of the sub-query arguments.input = subquery; } + arguments.input = snapshotBuilder( arguments.input ); // track the union statement variables.unions.append( { query: arguments.input, all: arguments.all } ); @@ -3416,6 +3435,7 @@ component displayname="QueryBuilder" accessors="true" { // replace the original query builder with the results of the sub-query arguments.input = subquery; } + arguments.input = snapshotBuilder( arguments.input ); // track the union statement arrayAppend( @@ -3788,6 +3808,8 @@ component displayname="QueryBuilder" accessors="true" { throw( type = "InvalidSQLType", message = "Please pass structs with at least one column to insertBulk." ); } + clearBindings(); + var safeChunkSize = arguments.values.len(); if ( !getGrammar().supportsBulkInsert() && getGrammar().parameterLimit > 0 ) { safeChunkSize = max( 1, floor( getGrammar().parameterLimit / columnCount ) ); @@ -3845,6 +3867,8 @@ component displayname="QueryBuilder" accessors="true" { callback( arguments.source ); } + clearBindings( except = [ "commonTables" ] ); + if ( isNull( arguments.columns ) ) { arguments.columns = arguments.source .getColumns() @@ -4013,8 +4037,8 @@ component displayname="QueryBuilder" accessors="true" { arguments.values[ column.original ] = subselect; addBindings( subselect.getBindings(), "update" ); } else if ( getUtils().isBuilder( value ) ) { - arguments.values[ column.original ] = value; - addBindings( value.getBindings(), "update" ); + arguments.values[ column.original ] = snapshotBuilder( value ); + addBindings( arguments.values[ column.original ].getBindings(), "update" ); } else if ( getUtils().isExpression( value ) ) { addExpressionBindings( value, "update" ); } else { @@ -4095,6 +4119,8 @@ component displayname="QueryBuilder" accessors="true" { return; } + clearBindings( except = [ "commonTables" ] ); + if ( !isNull( arguments.source ) && ( isClosure( arguments.source ) || isCustomFunction( arguments.source ) ) ) { var callback = arguments.source; arguments.source = newQuery(); @@ -4345,19 +4371,7 @@ component displayname="QueryBuilder" accessors="true" { arguments.only = isArray( arguments.only ) ? arguments.only : [ arguments.only ]; arguments.except = isArray( arguments.except ) ? arguments.except : [ arguments.except ]; if ( arguments.only.isEmpty() ) { - arguments.only = [ - "commonTables", - "update", - "insert", - "aggregate", - "select", - "join", - "where", - "groupBy", - "having", - "orderBy", - "union" - ]; + arguments.only = variables.bindings.keyArray(); } for ( var bindingType in arguments.only ) { @@ -4406,6 +4420,13 @@ component displayname="QueryBuilder" accessors="true" { return this; } + /** + * Clones a child builder when it is attached so its SQL and copied bindings cannot diverge later. + */ + private QueryBuilder function snapshotBuilder( required QueryBuilder builder ) { + return arguments.builder.clone(); + } + /** * Adds all of the bindings from another builder instance. * @@ -5247,7 +5268,12 @@ component displayname="QueryBuilder" accessors="true" { } var bindings = getBindings( except = getAggregate().isEmpty() ? [] : [ "select", "orderBy" ] ); - return getUtils().replaceBindings( sql, bindings, arguments.showBindings == "inline" ); + return getUtils().replaceBindings( + sql, + bindings, + arguments.showBindings == "inline", + getGrammar() + ); } /** diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 8cf319d4..4b79fa44 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -139,16 +139,34 @@ component singleton displayname="QueryUtils" accessors="true" { * @sql The sql string to replace the bindings in. * @bindings The bindings to replace the question marks with. * @inline Whether or not to inline the bindings. + * @grammar The active grammar, used to distinguish dialect operators, comments, and identifiers. * * @return string */ - public string function replaceBindings( required string sql, required array bindings, boolean inline = false ) { + public string function replaceBindings( + required string sql, + required array bindings, + boolean inline = false, + any grammar + ) { var output = []; var index = 1; var position = 1; var state = "sql"; var dollarQuoteDelimiter = ""; var sqlLength = len( arguments.sql ); + var isMySQL = !isNull( arguments.grammar ) && isInstanceOf( + arguments.grammar, + "qb.models.Grammars.MySQLGrammar" + ); + var isPostgres = !isNull( arguments.grammar ) && isInstanceOf( + arguments.grammar, + "qb.models.Grammars.PostgresGrammar" + ); + var isSqlServer = !isNull( arguments.grammar ) && isInstanceOf( + arguments.grammar, + "qb.models.Grammars.SqlServerGrammar" + ); while ( position <= sqlLength ) { var character = mid( arguments.sql, position, 1 ); @@ -193,7 +211,7 @@ component singleton displayname="QueryUtils" accessors="true" { if ( state != "sql" ) { output.append( character ); if ( - ( state == "singleQuote" || state == "doubleQuote" || state == "backtickQuote" ) && + state != "bracketQuote" && character == chr( 92 ) && nextCharacter != "" ) { @@ -202,7 +220,9 @@ component singleton displayname="QueryUtils" accessors="true" { continue; } - var closingCharacter = state == "singleQuote" ? "'" : ( state == "doubleQuote" ? """" : chr( 96 ) ); + var closingCharacter = state == "singleQuote" ? "'" : ( + state == "doubleQuote" ? """" : ( state == "backtickQuote" ? chr( 96 ) : "]" ) + ); if ( character == closingCharacter ) { if ( nextCharacter == closingCharacter ) { output.append( nextCharacter ); @@ -225,6 +245,13 @@ component singleton displayname="QueryUtils" accessors="true" { continue; } + if ( isMySQL && character == "##" ) { + output.append( character ); + position++; + state = "lineComment"; + continue; + } + if ( character == "/" && nextCharacter == "*" ) { output.append( character ); output.append( nextCharacter ); @@ -249,14 +276,21 @@ component singleton displayname="QueryUtils" accessors="true" { } } - if ( character == "'" || character == """" || character == chr( 96 ) ) { + if ( character == "'" || character == """" || character == chr( 96 ) || ( isSqlServer && character == "[" ) ) { output.append( character ); - state = character == "'" ? "singleQuote" : ( character == """" ? "doubleQuote" : "backtickQuote" ); + state = character == "'" ? "singleQuote" : ( + character == """" ? "doubleQuote" : ( character == chr( 96 ) ? "backtickQuote" : "bracketQuote" ) + ); position++; continue; } if ( character == "?" ) { + if ( isPostgres && isPostgresQuestionMarkOperator( arguments.sql, position ) ) { + output.append( character ); + position++; + continue; + } if ( index > arguments.bindings.len() ) { throw( type = "BindingMismatch", @@ -276,6 +310,49 @@ component singleton displayname="QueryUtils" accessors="true" { return output.toList( "" ); } + /** + * Determines whether a PostgreSQL question mark is a JSON existence operator instead of a parameter placeholder. + */ + private boolean function isPostgresQuestionMarkOperator( required string sql, required numeric position ) { + var sqlLength = len( arguments.sql ); + var immediateNext = arguments.position < sqlLength ? mid( arguments.sql, arguments.position + 1, 1 ) : ""; + if ( immediateNext == "|" || immediateNext == "&" ) { + return true; + } + + var previousPosition = arguments.position - 1; + while ( previousPosition > 0 && reFind( "\s", mid( arguments.sql, previousPosition, 1 ) ) ) { + previousPosition--; + } + var nextPosition = arguments.position + 1; + while ( nextPosition <= sqlLength && reFind( "\s", mid( arguments.sql, nextPosition, 1 ) ) ) { + nextPosition++; + } + if ( previousPosition == 0 || nextPosition > sqlLength ) { + return false; + } + + var previousCharacter = mid( arguments.sql, previousPosition, 1 ); + var nextCharacter = mid( arguments.sql, nextPosition, 1 ); + if ( + !reFind( "[A-Za-z0-9_)\]""'#chr( 96 )#]", previousCharacter ) || + !reFind( "[A-Za-z0-9_(\[""'$?#chr( 96 )#]", nextCharacter ) + ) { + return false; + } + + var previousSql = left( arguments.sql, previousPosition ); + var previousWord = reReplace( previousSql, "(?s)^.*?([A-Za-z_][A-Za-z0-9_]*)$", "\1" ); + if ( previousWord == previousSql && !reFind( "^[A-Za-z_][A-Za-z0-9_]*$", previousSql ) ) { + previousWord = ""; + } + + return !listFindNoCase( + "SELECT,WHERE,AND,OR,WHEN,THEN,ELSE,CASE,ON,HAVING,BY,VALUES,VALUE,SET,RETURNING,AS,DISTINCT,LIMIT,OFFSET,FETCH,FIRST,NEXT,ROWS,ROW,IN,NOT,LIKE,ILIKE,IS,AT,FROM,JOIN,USING,INTO,UPDATE,INSERT,DELETE,OVER,PARTITION,ESCAPE,UNION,ALL", + previousWord + ); + } + /** * Formats a single binding for diagnostic SQL output. */ diff --git a/tests/resources/AbstractQueryBuilderSpec.cfc b/tests/resources/AbstractQueryBuilderSpec.cfc index 8a25bc53..70d2e083 100644 --- a/tests/resources/AbstractQueryBuilderSpec.cfc +++ b/tests/resources/AbstractQueryBuilderSpec.cfc @@ -373,6 +373,16 @@ component extends="testbox.system.BaseSpec" { } ); }, subSelectWithBindings() ); } ); + + it( "snapshots a builder passed to a sub-select", function() { + var child = getBuilder().from( "posts" ).selectRaw( "MAX(updated_date)" ); + var builder = getBuilder().from( "users" ).subSelect( "latestUpdatedDate", child ); + + child.where( "posts.user_id", 1 ); + + expect( builder.toSQL() ).notToInclude( "user_id" ); + expect( getTestBindings( builder ) ).toBe( [] ); + } ); } ); describe( "from", function() { @@ -1747,6 +1757,32 @@ component extends="testbox.system.BaseSpec" { expect( getTestBindings( builder ) ).toBe( [ "personal" ] ); } ); + it( "distinguishes joinSub clauses with the same SQL and different bindings", function() { + var builder = getBuilder().setPreventDuplicateJoins( true ); + var personalContacts = getBuilder().from( "contacts" ).where( "contacts.kind", "personal" ); + var businessContacts = getBuilder().from( "contacts" ).where( "contacts.kind", "business" ); + + builder + .from( "users AS u" ) + .joinSub( + "c", + personalContacts, + "u.id", + "=", + "c.user_id" + ) + .joinSub( + "c", + businessContacts, + "u.id", + "=", + "c.user_id" + ); + + expect( builder.getJoins() ).toHaveLength( 2 ); + expect( getTestBindings( builder ) ).toBe( [ "personal", "business" ] ); + } ); + it( "correctly positions bindings using joinSub", function() { testCase( function( builder ) { builder @@ -2465,6 +2501,19 @@ component extends="testbox.system.BaseSpec" { }, unionAll() ); } ); + it( "snapshots a builder passed to a union", function() { + var unionQuery = getBuilder().select( "name" ).from( "archived_users" ); + var builder = getBuilder() + .select( "name" ) + .from( "users" ) + .unionAll( unionQuery ); + + unionQuery.where( "active", 1 ); + + expect( builder.toSQL() ).notToInclude( "active" ); + expect( getTestBindings( builder ) ).toBe( [] ); + } ); + it( "can run an aggregate query like count on a union query", function() { testCase( function( builder ) { return builder @@ -2526,6 +2575,16 @@ component extends="testbox.system.BaseSpec" { }, commonTableExpression() ); } ); + it( "snapshots a builder passed to a common table expression", function() { + var cte = getBuilder().select( "id" ).from( "users" ); + var builder = getBuilder().with( "UsersCTE", cte ).from( "UsersCTE" ); + + cte.where( "active", 1 ); + + expect( builder.toSQL() ).notToInclude( "active" ); + expect( getTestBindings( builder ) ).toBe( [] ); + } ); + it( "can correctly bind parameters regardless of order", function() { testCase( function( builder ) { builder @@ -2990,6 +3049,20 @@ component extends="testbox.system.BaseSpec" { }, insertUsingSelectBuilder() ); } ); + it( "does not include unrelated parent bindings in insert using statements", function() { + var builder = getBuilder().from( "users" ).where( "tenant_id", 42 ); + var source = builder + .newQuery() + .from( "activeDirectoryUsers" ) + .select( "email" ) + .where( "active", 1 ); + + var sql = builder.insertUsing( columns = [ "email" ], source = source, toSql = true ); + + expect( reMatch( "\?", sql ) ).toHaveLength( 1 ); + expect( getTestBindings( builder ) ).toBe( [ 1 ] ); + } ); + it( "can derive the columns to insert from the source query", function() { testCase( function( builder ) { return builder @@ -3230,6 +3303,20 @@ component extends="testbox.system.BaseSpec" { } ); describe( "upsert statements", function() { + it( "does not include unrelated parent bindings in upserts", function() { + var builder = getBuilder().from( "users" ).where( "tenant_id", 42 ); + + var sql = builder.upsert( + values = { "email": "eric@example.com" }, + target = [ "email" ], + update = [ "email" ], + toSql = true + ); + + expect( getTestBindings( builder ) ).toBe( [ "eric@example.com" ] ); + expect( reMatch( "\?", sql ) ).toHaveLength( 1 ); + } ); + it( "can perform an upsert", function() { testCase( function( builder ) { return builder diff --git a/tests/specs/Query/Abstract/JoinClauseSpec.cfc b/tests/specs/Query/Abstract/JoinClauseSpec.cfc index eaf6784e..47fc9231 100644 --- a/tests/specs/Query/Abstract/JoinClauseSpec.cfc +++ b/tests/specs/Query/Abstract/JoinClauseSpec.cfc @@ -285,6 +285,15 @@ component extends="testbox.system.BaseSpec" { variables.qb.join( variables.joinOther ); expect( variables.qb.getJoins().len() ).toBe( 2 ); } ); + + it( "snapshots a join clause when it is attached", function() { + variables.qb.join( variables.join ); + + variables.join.where( "second.kind", "personal" ); + + expect( variables.qb.getJoins()[ 1 ].getWheres() ).toBeEmpty(); + expect( variables.qb.getBindings() ).toBeEmpty(); + } ); } ); } ); } ); diff --git a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc index e6340f33..9148217a 100644 --- a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc +++ b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc @@ -1720,6 +1720,19 @@ component extends="testbox.system.BaseSpec" { } ); describe( "bulk inserts", function() { + it( "does not include unrelated builder bindings in native bulk inserts", function() { + var grammar = getMockBox().createMock( "qb.models.Grammars.SqlServerGrammar" ).init(); + grammar.$( "runQuery", {} ); + var builder = new qb.models.Query.QueryBuilder( grammar ) + .fromRaw( "users", [ "unused-from" ] ) + .where( "active", 1 ); + + builder.insertBulk( [ { "email": "one@example.com" } ] ); + + expect( grammar.$callLog().runQuery[ 1 ].bindings ).toHaveLength( 1 ); + expect( deserializeJSON( grammar.$callLog().runQuery[ 1 ].bindings[ 1 ].value ) ).toBe( [ { "email": "one@example.com" } ] ); + } ); + it( "inserts values in explicit batches", function() { var sql = getBuilder() .from( "users" ) diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index c7e05682..244c3c93 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -262,6 +262,45 @@ component extends="testbox.system.BaseSpec" { expect( utils.replaceBindings( "SELECT ARRAY[?]", [ binding ], true ) ).toBe( "SELECT ARRAY['name']" ); } ); + it( "preserves PostgreSQL question mark operators", function() { + var binding = utils.extractBinding( "name", variables.mockGrammar ); + + expect( + utils.replaceBindings( + "SELECT * FROM records WHERE payload ? ?", + [ binding ], + true, + new qb.models.Grammars.PostgresGrammar() + ) + ).toBe( "SELECT * FROM records WHERE payload ? 'name'" ); + } ); + + it( "preserves question marks in MySQL hash comments", function() { + var binding = utils.extractBinding( 42, variables.mockGrammar ); + + expect( + utils.replaceBindings( + "SELECT * FROM records WHERE id = ? ## why?#chr( 10 )#", + [ binding ], + true, + new qb.models.Grammars.MySQLGrammar() + ) + ).toBe( "SELECT * FROM records WHERE id = 42 ## why?#chr( 10 )#" ); + } ); + + it( "preserves question marks in SQL Server bracketed identifiers", function() { + var binding = utils.extractBinding( 42, variables.mockGrammar ); + + expect( + utils.replaceBindings( + "SELECT [why?] FROM records WHERE id = ?", + [ binding ], + true, + new qb.models.Grammars.SqlServerGrammar() + ) + ).toBe( "SELECT [why?] FROM records WHERE id = 42" ); + } ); + it( "preserves question marks in escaped string literals", function() { var binding = utils.extractBinding( 42, variables.mockGrammar ); From b6409378f1f26b0c02f8a56a46b9c82a1fe859b5 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 13 Aug 2026 20:16:52 -0600 Subject: [PATCH 022/119] fix(Grammar): correct dialect edge cases --- models/Grammars/BaseGrammar.cfc | 12 +++++++++++ models/Grammars/DerbyGrammar.cfc | 4 ++-- models/Grammars/OracleGrammar.cfc | 12 +++++++++++ models/Grammars/PostgresGrammar.cfc | 3 +++ models/Grammars/SqlServerGrammar.cfc | 10 +++++++--- models/Query/QueryUtils.cfc | 7 +++++++ models/Schema/SchemaBuilder.cfc | 11 ++++++---- tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 8 ++++++++ .../specs/Query/PostgresQueryBuilderSpec.cfc | 13 ++++++++++++ tests/specs/Schema/DerbySchemaBuilderSpec.cfc | 4 ++-- .../specs/Schema/OracleSchemaBuilderSpec.cfc | 17 ++++++++++++++-- .../Schema/SqlServerSchemaBuilderSpec.cfc | 20 +++++++++++++------ 12 files changed, 102 insertions(+), 19 deletions(-) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 2e43cab8..0689b4c3 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -2285,6 +2285,18 @@ component displayname="Grammar" accessors="true" singleton { /*===== End of Index Types ======*/ + /** + * Prepares an identifier for use as a schema catalog lookup binding. + * Grammars with case-sensitive catalogs can override this method. + * + * @identifier The table, column, or schema identifier to prepare. + * + * @return The identifier value to bind to the catalog query. + */ + public string function prepareSchemaIdentifierForLookup( required string identifier ) { + return arguments.identifier; + } + function compileTableExists( tableName, schemaName = "" ) { var sql = "SELECT 1 FROM #wrapTable( "information_schema.tables" )# WHERE #wrapColumn( { "type": "simple", "value": "table_name" } )# = ?"; if ( schemaName != "" ) { diff --git a/models/Grammars/DerbyGrammar.cfc b/models/Grammars/DerbyGrammar.cfc index f5eb65fa..4efb45c5 100644 --- a/models/Grammars/DerbyGrammar.cfc +++ b/models/Grammars/DerbyGrammar.cfc @@ -724,7 +724,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } sql &= " WHERE #wrapColumn( { "type": "simple", "value": "t.tablename" } )# = ?"; if ( schemaName != "" ) { - sql &= " AND #wrapColumn( { "type": "simple", "value": "s.schemanname" } )# = ?"; + sql &= " AND #wrapColumn( { "type": "simple", "value": "s.schemaname" } )# = ?"; } return sql; } @@ -736,7 +736,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } sql &= " WHERE #wrapColumn( { "type": "simple", "value": "t.tablename" } )# = ? AND #wrapColumn( { "type": "simple", "value": "c.columnname" } )# = ?"; if ( schema != "" ) { - sql &= " AND #wrapColumn( { "type": "simple", "value": "s.schemanname" } )# = ?"; + sql &= " AND #wrapColumn( { "type": "simple", "value": "s.schemaname" } )# = ?"; } return sql; } diff --git a/models/Grammars/OracleGrammar.cfc b/models/Grammars/OracleGrammar.cfc index edf37bd1..5bf6bd8b 100644 --- a/models/Grammars/OracleGrammar.cfc +++ b/models/Grammars/OracleGrammar.cfc @@ -828,6 +828,18 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { return ""; } + public string function prepareSchemaIdentifierForLookup( required string identifier ) { + var normalizedIdentifier = trim( arguments.identifier ); + if ( + len( normalizedIdentifier ) >= 2 && + left( normalizedIdentifier, 1 ) == """" && + right( normalizedIdentifier, 1 ) == """" + ) { + return mid( normalizedIdentifier, 2, len( normalizedIdentifier ) - 2 ); + } + return uCase( normalizedIdentifier ); + } + function compileTableExists( tableName, schemaName = "" ) { var sql = "SELECT 1 FROM #wrapTable( "all_tables" )# WHERE #wrapColumn( { "type": "simple", "value": "table_name" } )# = ?"; if ( schemaName != "" ) { diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index 48da50ff..33aee021 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -68,6 +68,9 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { var sql = wrapJsonColumn( arguments.jsonPath ); var scalarExtraction = arguments.scalar; var pathLength = arguments.jsonPath.path.len(); + if ( scalarExtraction && pathLength == 0 ) { + return sql & chr( 35 ) & ">>'{}'"; + } arguments.jsonPath.path.each( function( segment, index ) { var operator = scalarExtraction && index == pathLength ? "->>" : "->"; var pathSegment = getUtils().isActuallyNumeric( segment ) ? segment : "'" & replace( diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index dc98cc56..07176b10 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -860,7 +860,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { setShouldWrapValues( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ); } - return "EXEC sp_rename #wrapTable( blueprint.getTable() )#, #wrapTable( commandParameters.to )#"; + return "EXEC sp_rename #quoteUnicodeStringLiteral( blueprint.getTable() )#, #quoteUnicodeStringLiteral( commandParameters.to )#"; } finally { if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); @@ -875,7 +875,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { setShouldWrapValues( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ); } - return "EXEC sp_rename #wrapValue( blueprint.getTable() & "." & commandParameters.from )#, #wrapColumn( { "type": "simple", "value": commandParameters.to.getName() } )#, [COLUMN]"; + return "EXEC sp_rename #quoteUnicodeStringLiteral( blueprint.getTable() & "." & commandParameters.from )#, #quoteUnicodeStringLiteral( commandParameters.to.getName() )#, #quoteUnicodeStringLiteral( "COLUMN" )#"; } finally { if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); @@ -890,7 +890,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { setShouldWrapValues( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ); } - return "EXEC sp_rename #wrapValue( commandParameters.from )#, #wrapValue( commandParameters.to )#"; + return "EXEC sp_rename #quoteUnicodeStringLiteral( commandParameters.from )#, #quoteUnicodeStringLiteral( commandParameters.to )#"; } finally { if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); @@ -898,6 +898,10 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } } + private string function quoteUnicodeStringLiteral( required string value ) { + return "N'" & replace( arguments.value, "'", "''", "all" ) & "'"; + } + function compileDropConstraint( blueprint, commandParameters ) { try { var originalShouldWrapValues = getShouldWrapValues(); diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 4b79fa44..cb111f6c 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -307,6 +307,13 @@ component singleton displayname="QueryUtils" accessors="true" { position++; } + if ( index <= arguments.bindings.len() ) { + throw( + type = "BindingMismatch", + message = "The supplied bindings contain more values than the SQL parameter placeholders." + ); + } + return output.toList( "" ); } diff --git a/models/Schema/SchemaBuilder.cfc b/models/Schema/SchemaBuilder.cfc index 03e30bf5..7535d876 100644 --- a/models/Schema/SchemaBuilder.cfc +++ b/models/Schema/SchemaBuilder.cfc @@ -502,9 +502,9 @@ component accessors="true" { if ( listLen( arguments.name, "." ) > 1 ) { arguments.schema = listDeleteAt( arguments.name, listLen( arguments.name, "." ), "." ); } - var args = [ listLast( arguments.name, "." ) ]; + var args = [ getGrammar().prepareSchemaIdentifierForLookup( listLast( arguments.name, "." ) ) ]; if ( arguments.schema != "" ) { - arrayAppend( args, arguments.schema ); + arrayAppend( args, getGrammar().prepareSchemaIdentifierForLookup( arguments.schema ) ); } var sql = getGrammar().compileTableExists( arguments.name, arguments.schema ); if ( arguments.execute ) { @@ -545,9 +545,12 @@ component accessors="true" { if ( listLen( arguments.table, "." ) > 1 ) { arguments.schema = listDeleteAt( arguments.table, listLen( arguments.table, "." ), "." ); } - var args = [ listLast( arguments.table, "." ), arguments.column ]; + var args = [ + getGrammar().prepareSchemaIdentifierForLookup( listLast( arguments.table, "." ) ), + getGrammar().prepareSchemaIdentifierForLookup( arguments.column ) + ]; if ( arguments.schema != "" ) { - arrayAppend( args, arguments.schema ); + arrayAppend( args, getGrammar().prepareSchemaIdentifierForLookup( arguments.schema ) ); } var sql = getGrammar().compileColumnExists( arguments.table, arguments.column, arguments.schema ); if ( arguments.execute ) { diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index 244c3c93..37cfc946 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -308,6 +308,14 @@ component extends="testbox.system.BaseSpec" { utils.replaceBindings( "SELECT 'isn''t ?' AS marker FROM users WHERE id = ?", [ binding ], true ) ).toBe( "SELECT 'isn''t ?' AS marker FROM users WHERE id = 42" ); } ); + + it( "rejects bindings without matching placeholders", function() { + var binding = utils.extractBinding( 42, variables.mockGrammar ); + + expect( function() { + utils.replaceBindings( "SELECT 1", [ binding ], true ); + } ).toThrow( type = "BindingMismatch" ); + } ); } ); describe( "extractBinding()", function() { diff --git a/tests/specs/Query/PostgresQueryBuilderSpec.cfc b/tests/specs/Query/PostgresQueryBuilderSpec.cfc index 3bab3e2d..10dc0ac3 100644 --- a/tests/specs/Query/PostgresQueryBuilderSpec.cfc +++ b/tests/specs/Query/PostgresQueryBuilderSpec.cfc @@ -29,6 +29,19 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { expect( findNoCase( "RETURNING", sql ) ).toBeGT( findNoCase( "ON CONFLICT", sql ) ); } ); } ); + + describe( "PostgreSQL root JSON scalars", function() { + it( "extracts root JSON scalar values as text", function() { + var builder = getBuilder() + .select( [ getBuilder().jsonPath( "payload" ) ] ) + .from( "records" ) + .where( getBuilder().jsonPath( "payload" ), "name" ); + + expect( builder.toSQL() ).toBeWithCase( + "SELECT ""payload""#chr( 35 )#>>'{}' FROM ""records"" WHERE ""payload""#chr( 35 )#>>'{}' = ?" + ); + } ); + } ); } function selectAllColumns() { diff --git a/tests/specs/Schema/DerbySchemaBuilderSpec.cfc b/tests/specs/Schema/DerbySchemaBuilderSpec.cfc index b94d9af5..aa4bdeb3 100644 --- a/tests/specs/Schema/DerbySchemaBuilderSpec.cfc +++ b/tests/specs/Schema/DerbySchemaBuilderSpec.cfc @@ -601,7 +601,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { function hasTableInSchema() { return [ - "SELECT 1 FROM ""sys"".""systables"" AS ""t"" JOIN ""sys"".""sysschemas"" AS ""s"" ON ""t"".""schemaid"" = ""s"".""schemaid"" WHERE ""t"".""tablename"" = ? AND ""s"".""schemanname"" = ?" + "SELECT 1 FROM ""sys"".""systables"" AS ""t"" JOIN ""sys"".""sysschemas"" AS ""s"" ON ""t"".""schemaid"" = ""s"".""schemaid"" WHERE ""t"".""tablename"" = ? AND ""s"".""schemaname"" = ?" ]; } @@ -613,7 +613,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { function hasColumnInSchema() { return [ - "SELECT 1 FROM ""sys"".""syscolumns"" AS ""c"" JOIN ""sys"".""systables"" AS ""t"" ON ""c"".""referenceid"" = ""t"".""tableid"" JOIN ""sys"".""sysschemas"" AS ""s"" ON ""t"".""schemaid"" = ""s"".""schemaid"" WHERE ""t"".""tablename"" = ? AND ""c"".""columnname"" = ? AND ""s"".""schemanname"" = ?" + "SELECT 1 FROM ""sys"".""syscolumns"" AS ""c"" JOIN ""sys"".""systables"" AS ""t"" ON ""c"".""referenceid"" = ""t"".""tableid"" JOIN ""sys"".""sysschemas"" AS ""s"" ON ""t"".""schemaid"" = ""s"".""schemaid"" WHERE ""t"".""tablename"" = ? AND ""c"".""columnname"" = ? AND ""s"".""schemaname"" = ?" ]; } diff --git a/tests/specs/Schema/OracleSchemaBuilderSpec.cfc b/tests/specs/Schema/OracleSchemaBuilderSpec.cfc index 089e352a..fc0c3527 100644 --- a/tests/specs/Schema/OracleSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/OracleSchemaBuilderSpec.cfc @@ -68,12 +68,25 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { ] ); } ); - it( "uses an explicit table schema for existence checks", () => { + it( "normalizes unquoted identifiers for existence checks", () => { var schema = getBuilder().setDefaultSchema( "app" ); variables.mockGrammar.$( "runQuery", queryNew( "" ) ); schema.hasTable( "audit.users" ); - expect( variables.mockGrammar.$callLog().runQuery[ 1 ][ 2 ] ).toBe( [ "users", "audit" ] ); + var bindings = variables.mockGrammar.$callLog().runQuery[ 1 ][ 2 ]; + expect( compare( bindings[ 1 ], "USERS" ) ).toBe( 0 ); + expect( compare( bindings[ 2 ], "AUDIT" ) ).toBe( 0 ); + } ); + + it( "preserves quoted identifier case for existence checks", () => { + var schema = getBuilder(); + variables.mockGrammar.$( "runQuery", queryNew( "" ) ); + schema.hasColumn( """audit"".""users""", """emailAddress""" ); + + var bindings = variables.mockGrammar.$callLog().runQuery[ 1 ][ 2 ]; + expect( compare( bindings[ 1 ], "users" ) ).toBe( 0 ); + expect( compare( bindings[ 2 ], "emailAddress" ) ).toBe( 0 ); + expect( compare( bindings[ 3 ], "audit" ) ).toBe( 0 ); } ); it( "attempts to drop sequences and triggers when dropping a table", () => { diff --git a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc index 6148f045..1332c876 100644 --- a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc @@ -44,6 +44,14 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { ); } ); } ); + + describe( "SQL Server rename literals", function() { + it( "escapes apostrophes in object names", function() { + var statements = getBuilder().rename( "worker's", "employee's", {}, false ).toSQL(); + + expect( statements ).toBe( [ "EXEC sp_rename N'worker''s', N'employee''s'" ] ); + } ); + } ); } function emptyTable() { @@ -448,7 +456,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { } function renameConstraint() { - return [ "EXEC sp_rename [unq_users_first_name_last_name], [unq_users_full_name]" ]; + return [ "EXEC sp_rename N'unq_users_first_name_last_name', N'unq_users_full_name'" ]; } function dropConstraintFromName() { @@ -550,17 +558,17 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { } function renameTable() { - return [ "EXEC sp_rename [workers], [employees]" ]; + return [ "EXEC sp_rename N'workers', N'employees'" ]; } function renameColumn() { - return [ "EXEC sp_rename [users.name], [username], [COLUMN]" ]; + return [ "EXEC sp_rename N'users.name', N'username', N'COLUMN'" ]; } function renameMultipleColumns() { return [ - "EXEC sp_rename [users.name], [username], [COLUMN]", - "EXEC sp_rename [users.purchase_date], [purchased_at], [COLUMN]" + "EXEC sp_rename N'users.name', N'username', N'COLUMN'", + "EXEC sp_rename N'users.purchase_date', N'purchased_at', N'COLUMN'" ]; } @@ -599,7 +607,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { return [ "ALTER TABLE [users] DROP COLUMN [is_active]", "ALTER TABLE [users] ADD [tshirt_size] NVARCHAR(255) NOT NULL, CONSTRAINT [enum_users_tshirt_size] CHECK ([tshirt_size] IN ('S', 'M', 'L', 'XL', 'XXL'))", - "EXEC sp_rename [users.name], [username], [COLUMN]", + "EXEC sp_rename N'users.name', N'username', N'COLUMN'", "ALTER TABLE [users] ALTER COLUMN [purchase_date] DATETIME2", "ALTER TABLE [users] ADD CONSTRAINT [unq_users_username] UNIQUE ([username])", "ALTER TABLE [users] ADD CONSTRAINT [unq_users_email] UNIQUE ([email])", From 0f375aa6c50e278398ccccd295e67c394e751d55 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 14 Aug 2026 01:37:39 -0600 Subject: [PATCH 023/119] fix: harden query and schema compilation --- models/Grammars/BaseGrammar.cfc | 58 ++++++++++-- models/Grammars/DerbyGrammar.cfc | 38 ++++++-- models/Grammars/MySQLGrammar.cfc | 36 ++++--- models/Grammars/OracleGrammar.cfc | 94 +++++++++++-------- models/Grammars/PostgresGrammar.cfc | 40 +++++--- models/Grammars/SQLiteGrammar.cfc | 22 +++-- models/Grammars/SqlServerGrammar.cfc | 26 +++-- models/Query/QueryBuilder.cfc | 37 +++++++- models/Schema/Column.cfc | 20 ++++ models/Schema/TableIndex.cfc | 26 +++++ tests/resources/AbstractQueryBuilderSpec.cfc | 9 ++ tests/resources/AbstractSchemaBuilderSpec.cfc | 32 ++++++- .../specs/Query/Abstract/BuilderWhereSpec.cfc | 26 +++++ tests/specs/Query/ShouldWrapValuesSpec.cfc | 20 ++++ tests/specs/Schema/BlueprintLifecycleSpec.cfc | 14 +++ tests/specs/Schema/DerbySchemaBuilderSpec.cfc | 35 ++++++- tests/specs/Schema/MySQLSchemaBuilderSpec.cfc | 33 ++++++- .../specs/Schema/OracleSchemaBuilderSpec.cfc | 45 ++++++--- .../Schema/PostgresSchemaBuilderSpec.cfc | 68 ++++++++++++-- .../specs/Schema/SQLiteSchemaBuilderSpec.cfc | 71 +++++++++++++- .../Schema/SqlServerSchemaBuilderSpec.cfc | 39 +++++++- 21 files changed, 657 insertions(+), 132 deletions(-) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 0689b4c3..22e137bb 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -66,7 +66,9 @@ component displayname="Grammar" accessors="true" singleton { * @return qb.models.Grammars.BaseGrammar */ public BaseGrammar function init( qb.models.Query.QueryUtils utils ) { - param arguments.utils = new qb.models.Query.QueryUtils(); + if ( isNull( arguments.utils ) ) { + arguments.utils = new qb.models.Query.QueryUtils(); + } variables.utils = arguments.utils; variables.tablePrefix = ""; variables.tableAliasOperator = " AS "; @@ -1437,6 +1439,12 @@ component displayname="Grammar" accessors="true" singleton { } else { var escapedSegment = replace( segment, + chr( 92 ), + chr( 92 ) & chr( 92 ), + "all" + ); + escapedSegment = replace( + escapedSegment, """", chr( 92 ) & """", "all" @@ -1506,7 +1514,11 @@ component displayname="Grammar" accessors="true" singleton { return arguments.value; } - arguments.value = reReplace( arguments.value, """", "", "all" ); + var value = toString( arguments.value ); + if ( len( value ) >= 2 && left( value, 1 ) == """" && right( value, 1 ) == """" ) { + value = mid( value, 2, len( value ) - 2 ); + } + value = replace( value, """", """""", "all" ); return """#value#"""; } @@ -1616,14 +1628,24 @@ component displayname="Grammar" accessors="true" singleton { } function generateDefault( column ) { - if ( column.getDefaultValue() == "" ) { + if ( !column.getHasDefaultValue() ) { return ""; } return "DEFAULT #wrapDefaultType( column )#"; } + /** + * Determines whether a column's default is a textual SQL literal. + */ + function shouldQuoteDefaultValue( required column ) { + return listFindNoCase( + "char,string,unicodeString,text,unicodeText,mediumText,unicodeMediumText,longText,unicodeLongText,GUID,UUID,enum", + arguments.column.getType() + ) > 0; + } + function generateComment( column ) { - return column.getCommentValue() != "" ? "COMMENT '#column.getCommentValue()#'" : ""; + return column.getCommentValue() != "" ? "COMMENT #quoteStringLiteral( column.getCommentValue() )#" : ""; } function compileAddComment( blueprint, commandParameters ) { @@ -1631,10 +1653,32 @@ component displayname="Grammar" accessors="true" singleton { "COMMENT ON COLUMN", wrapColumn( { "type": "simple", "value": commandParameters.table & "." & commandParameters.column.getName() } ), "IS", - "'" & commandParameters.column.getCommentValue() & "'" + quoteStringLiteral( commandParameters.column.getCommentValue() ) ] ); } + /** + * Quotes a value for use as a SQL string literal in generated DDL. + */ + public string function quoteStringLiteral( required any value ) { + return "'" & replace( + toString( arguments.value ), + "'", + "''", + "all" + ) & "'"; + } + + /** + * Places a standalone schema object in the same schema as its table. + */ + public string function qualifyObjectNameForTable( required string table, required string objectName ) { + if ( listLen( arguments.table, "." ) == 1 ) { + return arguments.objectName; + } + return listDeleteAt( arguments.table, listLen( arguments.table, "." ), "." ) & "." & arguments.objectName; + } + /*===== End of Blueprint: Create ======*/ /*======================================= @@ -1977,7 +2021,7 @@ component displayname="Grammar" accessors="true" singleton { var values = column .getValues() .map( function( value ) { - return "'#value#'"; + return quoteStringLiteral( value ); } ) .toList( ", " ); return "ENUM(#values#)"; @@ -2272,7 +2316,7 @@ component displayname="Grammar" accessors="true" singleton { var values = column .getValues() .map( function( val ) { - return "'#val#'"; + return quoteStringLiteral( val ); } ) .toList( ", " ); return concatenate( [ diff --git a/models/Grammars/DerbyGrammar.cfc b/models/Grammars/DerbyGrammar.cfc index 4efb45c5..94b873a1 100644 --- a/models/Grammars/DerbyGrammar.cfc +++ b/models/Grammars/DerbyGrammar.cfc @@ -372,13 +372,17 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { if ( len( arguments.value ) == 0 || - arguments.value == "*" || - left( arguments.value, 1 ) == """" + arguments.value == "*" ) { return arguments.value; } - return """#arguments.value#"""; + var value = toString( arguments.value ); + if ( len( value ) >= 2 && left( value, 1 ) == """" && right( value, 1 ) == """" ) { + value = mid( value, 2, len( value ) - 2 ); + } + value = replace( value, """", """""", "all" ); + return """#value#"""; } function compileCreateAs( blueprint, commandParameters ) { @@ -570,12 +574,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } function wrapDefaultType( column ) { + if ( shouldQuoteDefaultValue( arguments.column ) ) { + return quoteStringLiteral( column.getDefaultValue() ); + } switch ( column.getType() ) { case "boolean": return column.getDefaultValue() ? "TRUE" : "FALSE"; - case "char": - case "string": - return "'#column.getDefaultValue()#'"; default: return column.getDefaultValue(); } @@ -748,7 +752,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.sb.getShouldWrapValues() ); } - var tables = getAllTableNames( options ); + var tables = getAllTableNames( options, schema ); return arrayMap( tables, function( table ) { return "DROP TABLE #wrapTable( table )#"; } ); @@ -759,4 +763,24 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } } + function getAllTableNames( options, schema = "" ) { + var sql = "SELECT #wrapColumn( { "type": "simple", "value": "t.tablename" } )# AS #wrapValue( "table_name" )#, #wrapColumn( { "type": "simple", "value": "s.schemaname" } )# AS #wrapValue( "table_schema" )# FROM #wrapTable( "sys.systables t" )# JOIN #wrapTable( "sys.sysschemas s" )# ON #wrapColumn( { "type": "simple", "value": "t.schemaid" } )# = #wrapColumn( { "type": "simple", "value": "s.schemaid" } )# WHERE #wrapColumn( { "type": "simple", "value": "t.tabletype" } )# = 'T'"; + var args = []; + if ( arguments.schema != "" ) { + sql &= " AND #wrapColumn( { "type": "simple", "value": "s.schemaname" } )# = ?"; + args.append( arguments.schema ); + } + var tablesQuery = runQuery( + sql, + args, + arguments.options, + "query" + ); + var tables = []; + for ( var table in tablesQuery ) { + tables.append( "#table[ "table_schema" ]#.#table[ "table_name" ]#" ); + } + return tables; + } + } diff --git a/models/Grammars/MySQLGrammar.cfc b/models/Grammars/MySQLGrammar.cfc index 9c5ccc3a..6d1c90ca 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -65,9 +65,19 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { return value; } - arguments.value = reReplace( arguments.value, """", "", "all" ); + var value = reReplace( + toString( arguments.value ), + """", + "", + "all" + ); + var quote = chr( 96 ); + if ( len( value ) >= 2 && left( value, 1 ) == quote && right( value, 1 ) == quote ) { + value = mid( value, 2, len( value ) - 2 ); + } + value = replace( value, quote, quote & quote, "all" ); - return "`#value#`"; + return "#quote##value##quote#"; } /** @@ -78,7 +88,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { * @return string */ public string function wrapAlias( required any value ) { - return "`#value#`"; + return wrapValue( arguments.value ); } function compileRenameTable( blueprint, commandParameters ) { @@ -128,7 +138,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.sb.getShouldWrapValues() ); } - var tables = getAllTableNames( options ); + var tables = getAllTableNames( options, schema ); var tableList = arrayToList( arrayMap( tables, function( table ) { return wrapTable( table ); @@ -153,9 +163,10 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } } - function getAllTableNames( options ) { + function getAllTableNames( options, schema = "" ) { + var schemaClause = arguments.schema == "" ? "" : " FROM #wrapValue( arguments.schema )#"; var tablesQuery = runQuery( - "SHOW FULL TABLES WHERE table_type = 'BASE TABLE'", + "SHOW FULL TABLES#schemaClause# WHERE table_type = 'BASE TABLE'", {}, options, "query" @@ -167,7 +178,10 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { ); var tables = []; for ( var table in tablesQuery ) { - arrayAppend( tables, table[ columnName ] ); + arrayAppend( + tables, + arguments.schema == "" ? table[ columnName ] : "#arguments.schema#.#table[ columnName ]#" + ); } return tables; } @@ -368,7 +382,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { function generateDefault( column ) { if ( - column.getDefaultValue() == "" && + !column.getHasDefaultValue() && column.getType().findNoCase( "TIMESTAMP" ) > 0 ) { if ( column.getIsNullable() ) { @@ -381,12 +395,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } function wrapDefaultType( column ) { + if ( shouldQuoteDefaultValue( arguments.column ) ) { + return quoteStringLiteral( column.getDefaultValue() ); + } switch ( column.getType() ) { case "boolean": return column.getDefaultValue() ? 1 : 0; - case "char": - case "string": - return "'#column.getDefaultValue()#'"; default: return column.getDefaultValue(); } diff --git a/models/Grammars/OracleGrammar.cfc b/models/Grammars/OracleGrammar.cfc index 5bf6bd8b..6c6a43c2 100644 --- a/models/Grammars/OracleGrammar.cfc +++ b/models/Grammars/OracleGrammar.cfc @@ -409,15 +409,19 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { return arguments.value; } - if ( - len( arguments.value ) == 0 || - arguments.value == "*" || - left( arguments.value, 1 ) == """" - ) { + if ( len( arguments.value ) == 0 || arguments.value == "*" ) { return arguments.value; } - return """#uCase( arguments.value )#"""; + var value = toString( arguments.value ); + var isQuoted = len( value ) >= 2 && left( value, 1 ) == """" && right( value, 1 ) == """"; + if ( isQuoted ) { + value = mid( value, 2, len( value ) - 2 ); + } else { + value = uCase( value ); + } + value = replace( value, """", """""", "all" ); + return """#value#"""; } function compileCreateColumn( column, blueprint ) { @@ -653,12 +657,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } function wrapDefaultType( column ) { + if ( shouldQuoteDefaultValue( arguments.column ) ) { + return quoteStringLiteral( column.getDefaultValue() ); + } switch ( column.getType() ) { case "boolean": return column.getDefaultValue() ? 1 : 0; - case "char": - case "string": - return "'#column.getDefaultValue()#'"; default: return column.getDefaultValue(); } @@ -872,14 +876,26 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { "." ) : ""; var sequenceName = "SEQ_#table#"; - if ( hasSequence( arguments.blueprint, sequenceName ) ) { - statements.append( "DROP SEQUENCE #wrapTable( schema == "" ? sequenceName : "#schema#.#sequenceName#" )#" ); - } + var qualifiedSequenceName = wrapTable( schema == "" ? sequenceName : "#schema#.#sequenceName#" ); + statements.append( + "BEGIN EXECUTE IMMEDIATE 'DROP SEQUENCE #replace( + qualifiedSequenceName, + "'", + "''", + "all" + )#'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -2289 THEN RAISE; END IF; END;" + ); var triggerName = "TRG_#table#"; - if ( hasTrigger( arguments.blueprint, triggerName ) ) { - statements.append( "DROP TRIGGER #wrapTable( schema == "" ? triggerName : "#schema#.#triggerName#" )#" ); - } + var qualifiedTriggerName = wrapTable( schema == "" ? triggerName : "#schema#.#triggerName#" ); + statements.append( + "BEGIN EXECUTE IMMEDIATE 'DROP TRIGGER #replace( + qualifiedTriggerName, + "'", + "''", + "all" + )#'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -4080 THEN RAISE; END IF; END;" + ); return statements; } finally { @@ -889,36 +905,34 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } } - private boolean function hasSequence( required Blueprint blueprint, required string sequenceName ) { - var sql = "SELECT 1 FROM #wrapTable( "all_sequences" )# WHERE #wrapColumn( { "type": "simple", "value": "sequence_name" } )# = ?"; - var params = [ arguments.sequenceName ]; - if ( arguments.blueprint.getDefaultSchema() != "" ) { - sql &= " AND #wrapColumn( { "type": "simple", "value": "owner" } )# = ?"; - params.append( arguments.blueprint.getDefaultSchema() ); - } - var result = queryExecute( sql, params, arguments.blueprint.getQueryOptions() ); - return result.recordCount > 0; - } - - private boolean function hasTrigger( required Blueprint blueprint, required string triggerName ) { - var sql = "SELECT 1 FROM #wrapTable( "all_triggers" )# WHERE #wrapColumn( { "type": "simple", "value": "trigger_name" } )# = ?"; - var params = [ arguments.triggerName ]; - if ( arguments.blueprint.getDefaultSchema() != "" ) { - sql &= " AND #wrapColumn( { "type": "simple", "value": "owner" } )# = ?"; - params.append( arguments.blueprint.getDefaultSchema() ); + function compileDropAllObjects( required struct options, string schema = "", SchemaBuilder sb ) { + var tableCatalog = "user_tables"; + var sequenceCatalog = "user_sequences"; + var tablePredicate = ""; + var sequencePredicate = ""; + var qualifiedPrefix = ""; + if ( arguments.schema != "" ) { + var schemaLookup = prepareSchemaIdentifierForLookup( arguments.schema ); + var escapedSchemaLookup = replace( schemaLookup, "'", "''", "all" ); + tableCatalog = "all_tables"; + sequenceCatalog = "all_sequences"; + tablePredicate = " WHERE owner = '#escapedSchemaLookup#'"; + sequencePredicate = " WHERE sequence_owner = '#escapedSchemaLookup#'"; + qualifiedPrefix = replace( + wrapValue( schemaLookup ), + "'", + "''", + "all" + ) & "."; } - var result = queryExecute( sql, params, arguments.blueprint.getQueryOptions() ); - return result.recordCount > 0; - } - function compileDropAllObjects( required struct options, string schema = "", SchemaBuilder sb ) { return [ "BEGIN - FOR c IN (SELECT table_name FROM user_tables) LOOP - EXECUTE IMMEDIATE ('DROP TABLE ""' || c.table_name || '"" CASCADE CONSTRAINTS'); + FOR c IN (SELECT table_name FROM #tableCatalog##tablePredicate#) LOOP + EXECUTE IMMEDIATE ('DROP TABLE #qualifiedPrefix#""' || REPLACE(c.table_name, '""', '""""') || '"" CASCADE CONSTRAINTS'); END LOOP; - FOR s IN (SELECT sequence_name FROM user_sequences) LOOP - EXECUTE IMMEDIATE ('DROP SEQUENCE ' || s.sequence_name); + FOR s IN (SELECT sequence_name FROM #sequenceCatalog##sequencePredicate#) LOOP + EXECUTE IMMEDIATE ('DROP SEQUENCE #qualifiedPrefix#""' || REPLACE(s.sequence_name, '""', '""""') || '""'); END LOOP; END;" ]; diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index 33aee021..545d338c 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -453,12 +453,13 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } } + if ( shouldQuoteDefaultValue( arguments.column ) ) { + return quoteStringLiteral( defaultValue ); + } + switch ( column.getType() ) { case "boolean": return uCase( defaultValue ); - case "char": - case "string": - return "'#defaultValue#'"; default: return defaultValue; } @@ -581,7 +582,19 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } function compileDropIndex( blueprint, commandParameters ) { - return "DROP INDEX #wrapValue( commandParameters.name )#"; + try { + var originalShouldWrapValues = getShouldWrapValues(); + if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { + setShouldWrapValues( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ); + } + + var indexName = qualifyObjectNameForTable( blueprint.getTable(), commandParameters.name ); + return "DROP INDEX #wrapTable( indexName )#"; + } finally { + if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { + setShouldWrapValues( originalShouldWrapValues ); + } + } } function compileDropAllObjects( required struct options, string schema = "", SchemaBuilder sb ) { @@ -609,16 +622,13 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } function getAllTableNames( options, schema = "" ) { - var sql = "SELECT #wrapColumn( { "type": "simple", "value": "table_name" } )# FROM #wrapTable( "information_schema.tables" )# WHERE #wrapColumn( { "type": "simple", "value": "table_schema" } )# = 'public'"; - var args = []; - if ( schema != "" ) { - sql &= " AND #wrapColumn( { "type": "simple", "value": "table_schema" } )# = ?"; - args.append( schema ); - } + var effectiveSchema = arguments.schema == "" ? "public" : arguments.schema; + var sql = "SELECT #wrapColumn( { "type": "simple", "value": "table_name" } )# FROM #wrapTable( "information_schema.tables" )# WHERE #wrapColumn( { "type": "simple", "value": "table_schema" } )# = ? AND #wrapColumn( { "type": "simple", "value": "table_type" } )# = 'BASE TABLE'"; + var args = [ effectiveSchema ]; var tablesQuery = runQuery( sql, args, options, "query" ); var tables = []; for ( var table in tablesQuery ) { - arrayAppend( tables, table[ "table_name" ] ); + arrayAppend( tables, "#effectiveSchema#.#table[ "table_name" ]#" ); } return tables; } @@ -706,7 +716,8 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } function typeEnum( column ) { - return column.getName(); + var typeName = qualifyObjectNameForTable( column.getBlueprint().getTable(), column.getName() ); + return wrapTable( typeName ); } function typeFloat( column ) { @@ -850,9 +861,10 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } var values = arrayMap( commandParameters.values, function( val ) { - return "'" & val & "'"; + return quoteStringLiteral( val ); } ); - return "CREATE TYPE #wrapColumn( { "type": "simple", "value": commandParameters.name } )# AS ENUM (#arrayToList( values, ", " )#)"; + var typeName = qualifyObjectNameForTable( blueprint.getTable(), commandParameters.name ); + return "CREATE TYPE #wrapTable( typeName )# AS ENUM (#arrayToList( values, ", " )#)"; } finally { if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index 1af1d253..0c4ab211 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -355,12 +355,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { ===================================*/ function wrapDefaultType( column ) { + if ( shouldQuoteDefaultValue( arguments.column ) ) { + return quoteStringLiteral( column.getDefaultValue() ); + } switch ( column.getType() ) { case "boolean": return column.getDefaultValue() ? 1 : 0; - case "char": - case "string": - return "'#column.getDefaultValue()#'"; default: return column.getDefaultValue(); } @@ -491,7 +491,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { var values = column .getValues() .map( function( value ) { - return "'#value#'"; + return quoteStringLiteral( value ); } ) .toList( ", " ); return "CHECK (#wrapColumn( { "type": "simple", "value": column.getName() } )# IN (#values#))"; @@ -502,7 +502,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { function generateDefault( column ) { if ( - column.getDefaultValue() == "" && + !column.getHasDefaultValue() && column.getType().findNoCase( "TIMESTAMP" ) > 0 ) { if ( column.getIsNullable() ) { @@ -562,6 +562,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } var index = commandParameters.index; + if ( index.getType() != "unique" ) { + throw( + type = "UnsupportedOperation", + message = "SQLite only supports adding unique constraints to existing tables." + ); + } var constraint = invoke( this, "index#index.getType()#", @@ -589,7 +595,8 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ); } - return "DROP INDEX #wrapValue( commandParameters.name )#"; + var indexName = qualifyObjectNameForTable( blueprint.getTable(), commandParameters.name ); + return "DROP INDEX #wrapTable( indexName )#"; } finally { if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); @@ -604,7 +611,8 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ); } - return "DROP INDEX #wrapValue( commandParameters.name )#"; + var indexName = qualifyObjectNameForTable( blueprint.getTable(), commandParameters.name ); + return "DROP INDEX #wrapTable( indexName )#"; } finally { if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index 07176b10..339c3bd5 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -773,16 +773,16 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } function generateDefault( column, blueprint ) { - return column.getDefaultValue() != "" ? "CONSTRAINT #wrapValue( "df_#blueprint.getTable()#_#column.getName()#" )# DEFAULT #wrapDefaultType( column )#" : ""; + return column.getHasDefaultValue() ? "CONSTRAINT #wrapValue( "df_#blueprint.getTable()#_#column.getName()#" )# DEFAULT #wrapDefaultType( column )#" : ""; } function wrapDefaultType( column ) { + if ( shouldQuoteDefaultValue( arguments.column ) ) { + return quoteStringLiteral( column.getDefaultValue() ); + } switch ( column.getType() ) { case "boolean": return column.getDefaultValue() ? 1 : 0; - case "char": - case "string": - return "'#column.getDefaultValue()#'"; default: return column.getDefaultValue(); } @@ -839,7 +839,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { " " ) ]; - if ( commandParameters.name.getDefaultValue() != "" ) { + if ( commandParameters.name.getHasDefaultValue() ) { statements.prepend( "ALTER TABLE #wrapTable( blueprint.getTable() )# DROP CONSTRAINT #wrapValue( "df_#blueprint.getTable()#_#commandParameters.name.getName()#" )#" ); @@ -936,11 +936,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { try { var originalShouldWrapValues = getShouldWrapValues(); var originalDefaultValue = commandParameters.to.getDefaultValue(); + var originalHasDefaultValue = commandParameters.to.getHasDefaultValue(); if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ); } - if ( originalDefaultValue == "" ) { + if ( !originalHasDefaultValue ) { return concatenate( [ "ALTER TABLE", wrapTable( blueprint.getTable() ), @@ -950,6 +951,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } commandParameters.to.setDefaultValue( "" ); + commandParameters.to.setHasDefaultValue( false ); var wrappedTable = wrapTable( blueprint.getTable(), false ); var wrappedColumn = wrapValue( commandParameters.to.getName() ); @@ -968,6 +970,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { ] ); commandParameters.to.setDefaultValue( originalDefaultValue ); + commandParameters.to.setHasDefaultValue( originalHasDefaultValue ); return [ "DECLARE @objectId INT = OBJECT_ID(N'#escapedTable#'), @constraintName SYSNAME, @schemaName SYSNAME, @tableName SYSNAME; SELECT @constraintName = [dc].[name], @schemaName = OBJECT_SCHEMA_NAME([dc].[parent_object_id]), @tableName = OBJECT_NAME([dc].[parent_object_id]) FROM [sys].[default_constraints] AS [dc] INNER JOIN [sys].[columns] AS [c] ON [c].[default_object_id] = [dc].[object_id] WHERE [dc].[parent_object_id] = @objectId AND [c].[name] = N'#escapedColumn#'; IF @constraintName IS NOT NULL EXEC(N'ALTER TABLE ' + QUOTENAME(@schemaName) + N'.' + QUOTENAME(@tableName) + N' DROP CONSTRAINT ' + QUOTENAME(@constraintName))", @@ -985,6 +988,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { ]; } finally { commandParameters.to.setDefaultValue( originalDefaultValue ); + commandParameters.to.setHasDefaultValue( originalHasDefaultValue ); if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); } @@ -992,16 +996,17 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } function getAllTableNames( options, schema = "" ) { - var sql = "SELECT #wrapColumn( { "type": "simple", "value": "table_name" } )# FROM #wrapTable( "information_schema.tables" )#"; + var sql = "SELECT #wrapColumn( { "type": "simple", "value": "table_name" } )#, #wrapColumn( { "type": "simple", "value": "table_schema" } )# FROM #wrapTable( "information_schema.tables" )#"; var args = []; if ( schema != "" ) { sql &= " WHERE #wrapColumn( { "type": "simple", "value": "table_schema" } )# = ?"; args.append( schema ); } + sql &= "#arguments.schema == "" ? " WHERE" : " AND"# #wrapColumn( { "type": "simple", "value": "table_type" } )# = 'BASE TABLE'"; var tablesQuery = runQuery( sql, args, options, "query" ); var tables = []; for ( var table in tablesQuery ) { - arrayAppend( tables, table[ "table_name" ] ); + arrayAppend( tables, "#table[ "table_schema" ]#.#table[ "table_name" ]#" ); } return tables; } @@ -1020,12 +1025,13 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } ), ", " ); + var foreignKeySchemaFilter = arguments.schema == "" ? "" : " WHERE OBJECT_SCHEMA_NAME(parent_object_id) = #quoteUnicodeStringLiteral( arguments.schema )#"; return arrayFilter( [ "DECLARE @sql NVARCHAR(MAX) = N''; - SELECT @sql += 'ALTER TABLE ' + QUOTENAME(OBJECT_NAME(parent_object_id)) + SELECT @sql += 'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(parent_object_id)) + '.' + QUOTENAME(OBJECT_NAME(parent_object_id)) + ' DROP CONSTRAINT ' + QUOTENAME(name) + ';' - FROM sys.foreign_keys; + FROM sys.foreign_keys#foreignKeySchemaFilter#; EXEC sp_executesql @sql;", arrayIsEmpty( tables ) ? "" : "DROP TABLE #tableList#" diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 30e17c70..aaf3994e 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -2193,7 +2193,10 @@ component displayname="QueryBuilder" accessors="true" { if ( this.getValidateOperatorsAndCombinators() && isInvalidCombinator( arguments.combinator ) ) { throw( type = "InvalidSQLType", message = "Illegal combinator" ); } - if ( !arguments.keyExists( "value" ) ) { + if ( + !arguments.keyExists( "value" ) || + ( isNull( arguments.value ) && arguments.column.find( "->" ) > 0 ) + ) { arguments.value = arguments.path; arguments.path = []; } @@ -2396,6 +2399,7 @@ component displayname="QueryBuilder" accessors="true" { combinator = "and", negate = false ) { + guardAgainstInvalidCombinator( arguments.combinator ); if ( isClosure( values ) || isCustomFunction( values ) || @@ -2450,6 +2454,7 @@ component displayname="QueryBuilder" accessors="true" { string combinator = "and", boolean negate = false ) { + guardAgainstInvalidCombinator( arguments.combinator ); arguments.values = normalizeToArray( arguments.values ); if ( arguments.values.some( getUtils().isExpression ) ) { @@ -2591,6 +2596,7 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereRaw( required string sql, array whereBindings = [], string combinator = "and" ) { + guardAgainstInvalidCombinator( arguments.combinator ); addBindings( whereBindings.map( function( binding ) { return utils.extractBinding( binding, variables.grammar ); @@ -2617,6 +2623,7 @@ component displayname="QueryBuilder" accessors="true" { second, string combinator = "and" ) { + guardAgainstInvalidCombinator( arguments.combinator ); if ( isNull( arguments.second ) ) { arguments.second = arguments.operator; arguments.operator = "="; @@ -2663,6 +2670,7 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereExists( query, combinator = "and", negate = false ) { + guardAgainstInvalidCombinator( arguments.combinator ); if ( isClosure( arguments.query ) || isCustomFunction( arguments.query ) ) { var callback = arguments.query; arguments.query = newQuery(); @@ -2711,6 +2719,7 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereNested( required callback, combinator = "and" ) { + guardAgainstInvalidCombinator( arguments.combinator ); var query = forNestedWhere(); callback( query ); return addNestedWhereQuery( query, combinator ); @@ -2725,6 +2734,7 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function addNestedWhereQuery( required QueryBuilder query, string combinator = "and" ) { + guardAgainstInvalidCombinator( arguments.combinator ); if ( !query.getWheres().isEmpty() ) { arguments.query = snapshotBuilder( arguments.query ); variables.wheres.append( { type: "nested", query: arguments.query, combinator: arguments.combinator } ); @@ -2753,6 +2763,7 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereNull( column, combinator = "and", negate = false ) { + guardAgainstInvalidCombinator( arguments.combinator ); if ( isClosure( arguments.column ) || isCustomFunction( arguments.column ) || @@ -2778,6 +2789,7 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereNullSub( query, combinator = "and", negate = false ) { + guardAgainstInvalidCombinator( arguments.combinator ); if ( isClosure( arguments.query ) || isCustomFunction( arguments.query ) ) { var callback = arguments.query; arguments.query = newQuery(); @@ -2823,6 +2835,7 @@ component displayname="QueryBuilder" accessors="true" { combinator = "and", negate = false ) { + guardAgainstInvalidCombinator( arguments.combinator ); var type = negate ? "notBetween" : "between"; var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); @@ -3105,6 +3118,8 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function orderBy( required any column, string direction = "asc" ) { + guardAgainstInvalidOrderDirection( arguments.direction ); + arguments.direction = lCase( trim( arguments.direction ) ); // We are trying to determine if a positional array of [ column, direction ] // was passed in. This is the craziness that does that. if ( @@ -3312,6 +3327,8 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function orderBySub( required any query, string direction = "asc" ) { + guardAgainstInvalidOrderDirection( arguments.direction ); + arguments.direction = lCase( trim( arguments.direction ) ); if ( !getUtils().isBuilder( arguments.query ) ) { var callback = arguments.query; arguments.query = newQuery(); @@ -5523,6 +5540,24 @@ component displayname="QueryBuilder" accessors="true" { return !arrayContains( variables.combinators, uCase( arguments.combinator ) ); } + /** + * Throws when combinator validation is enabled and the value is unsupported. + */ + private void function guardAgainstInvalidCombinator( required string combinator ) { + if ( this.getValidateOperatorsAndCombinators() && isInvalidCombinator( arguments.combinator ) ) { + throw( type = "InvalidSQLType", message = "Illegal combinator" ); + } + } + + /** + * Throws when an ORDER BY direction is unsupported. + */ + private void function guardAgainstInvalidOrderDirection( required string direction ) { + if ( !arrayFindNoCase( variables.directions, trim( arguments.direction ) ) ) { + throw( type = "InvalidSQLType", message = "Illegal order direction" ); + } + } + /** * onMissingMethod serves the following purpose for Builder: * diff --git a/models/Schema/Column.cfc b/models/Schema/Column.cfc index e0e91f2e..373d1cce 100644 --- a/models/Schema/Column.cfc +++ b/models/Schema/Column.cfc @@ -49,6 +49,11 @@ component accessors="true" { */ property name="defaultValue" default=""; + /** + * Whether a default value was explicitly assigned. + */ + property name="hasDefaultValue" default="false"; + /** * A comment for the column. */ @@ -87,6 +92,7 @@ component accessors="true" { variables.values = []; variables.computedType = "none"; variables.computedDefinition = ""; + variables.hasDefaultValue = false; return this; } @@ -126,6 +132,20 @@ component accessors="true" { return this; } + /** + * Assigns a default value and records that the value was explicitly set. + * This distinguishes an empty-string default from a column with no default. + * + * @defaultValue The default value. + * + * @returns The Column instance. + */ + public Column function setDefaultValue( required string defaultValue ) { + variables.defaultValue = arguments.defaultValue; + variables.hasDefaultValue = true; + return this; + } + /** * Sets the column to allow null values. * diff --git a/models/Schema/TableIndex.cfc b/models/Schema/TableIndex.cfc index 3558b25d..da6bd66f 100644 --- a/models/Schema/TableIndex.cfc +++ b/models/Schema/TableIndex.cfc @@ -3,6 +3,14 @@ */ component accessors="true" { + variables.validReferentialActions = [ + "RESTRICT", + "CASCADE", + "SET NULL", + "NO ACTION", + "SET DEFAULT" + ]; + /** * The constraint type. */ @@ -118,6 +126,16 @@ component accessors="true" { return this; } + public TableIndex function setOnUpdateAction( required string onUpdateAction ) { + variables.onUpdateAction = normalizeReferentialAction( arguments.onUpdateAction ); + return this; + } + + public TableIndex function setOnDeleteAction( required string onDeleteAction ) { + variables.onDeleteAction = normalizeReferentialAction( arguments.onDeleteAction ); + return this; + } + /** * Set the column or columns that make up the constraint. * @@ -134,4 +152,12 @@ component accessors="true" { return isArray( arguments.value ) ? arguments.value : [ arguments.value ]; } + private string function normalizeReferentialAction( required string action ) { + var normalizedAction = uCase( trim( arguments.action ) ); + if ( !variables.validReferentialActions.contains( normalizedAction ) ) { + throw( type = "InvalidReferentialAction", message = "Invalid foreign-key action [#arguments.action#]." ); + } + return normalizedAction; + } + } diff --git a/tests/resources/AbstractQueryBuilderSpec.cfc b/tests/resources/AbstractQueryBuilderSpec.cfc index 70d2e083..75e6903c 100644 --- a/tests/resources/AbstractQueryBuilderSpec.cfc +++ b/tests/resources/AbstractQueryBuilderSpec.cfc @@ -2180,6 +2180,15 @@ component extends="testbox.system.BaseSpec" { } ); describe( "can accept an array for the column argument", function() { + it( "rejects invalid default directions", function() { + expect( function() { + getBuilder().from( "users" ).orderBy( "email", "DESC; DROP TABLE users" ); + } ).toThrow( type = "InvalidSQLType", regex = "Illegal order direction" ); + expect( function() { + getBuilder().orderBySub( ( query ) => query.selectRaw( "1" ), "DESC; DROP TABLE users" ); + } ).toThrow( type = "InvalidSQLType", regex = "Illegal order direction" ); + } ); + describe( "with the array values", function() { it( "as simple strings", function() { testCase( function( builder ) { diff --git a/tests/resources/AbstractSchemaBuilderSpec.cfc b/tests/resources/AbstractSchemaBuilderSpec.cfc index 984a1721..7fb7dd5c 100644 --- a/tests/resources/AbstractSchemaBuilderSpec.cfc +++ b/tests/resources/AbstractSchemaBuilderSpec.cfc @@ -281,7 +281,7 @@ component extends="testbox.system.BaseSpec" { return schema.create( "employees", function( table ) { - table.enum( "tshirt_size", [ "S", "M", "L", "XL", "XXL" ] ); + table.enum( "tshirt_size", [ "S's", "M", "L", "XL", "XXL" ] ); }, {}, false @@ -1055,7 +1055,7 @@ component extends="testbox.system.BaseSpec" { return schema.create( "users", function( table ) { - table.boolean( "active" ).comment( "This is a comment" ); + table.boolean( "active" ).comment( "Pete's comment" ); }, {}, false @@ -1107,7 +1107,7 @@ component extends="testbox.system.BaseSpec" { return schema.create( "users", function( table ) { - table.string( "country" ).default( "USA" ); + table.string( "country" ).default( "O'Brien" ); }, {}, false @@ -1115,6 +1115,32 @@ component extends="testbox.system.BaseSpec" { }, defaultForString() ); } ); + it( "default for empty string", function() { + testCase( function( schema ) { + return schema.create( + "users", + function( table ) { + table.string( "nickname" ).default( "" ); + }, + {}, + false + ); + }, defaultForEmptyString() ); + } ); + + it( "default for unicode string", function() { + testCase( function( schema ) { + return schema.create( + "users", + function( table ) { + table.unicodeString( "nickname" ).default( "O'Brien" ); + }, + {}, + false + ); + }, defaultForUnicodeString() ); + } ); + it( "timestamp withCurrent", function() { testCase( function( schema ) { return schema.create( diff --git a/tests/specs/Query/Abstract/BuilderWhereSpec.cfc b/tests/specs/Query/Abstract/BuilderWhereSpec.cfc index b36b3591..10765006 100644 --- a/tests/specs/Query/Abstract/BuilderWhereSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderWhereSpec.cfc @@ -162,6 +162,32 @@ component extends="testbox.system.BaseSpec" { } ).toThrow( type = "InvalidSQLType", regex = "Illegal operator" ); } ); + it( "validates combinators for every where clause type", function() { + var invalidCalls = [ + ( builder ) => builder.whereIn( "id", [ 1 ], "xor" ), + ( builder ) => builder.whereInBulk( + "id", + [ 1 ], + javacast( "null", "" ), + "xor" + ), + ( builder ) => builder.whereRaw( "1 = 1", [], "xor" ), + ( builder ) => builder.whereColumn( "id", "=", "otherId", "xor" ), + ( builder ) => builder.whereExists( ( query ) => query.from( "users" ), "xor" ), + ( builder ) => builder.whereNested( ( query ) => query.where( "id", 1 ), "xor" ), + ( builder ) => builder.addNestedWhereQuery( builder.newQuery().where( "id", 1 ), "xor" ), + ( builder ) => builder.whereNull( "deletedDate", "xor" ), + ( builder ) => builder.whereNullSub( ( query ) => query.select( "deletedDate" ).from( "users" ), "xor" ), + ( builder ) => builder.whereBetween( "id", 1, 2, "xor" ) + ]; + + invalidCalls.each( function( invalidCall ) { + expect( function() { + invalidCall( new qb.models.Query.QueryBuilder() ); + } ).toThrow( type = "InvalidSQLType", regex = "Illegal combinator" ); + } ); + } ); + it( "can disable operator and combinator validation", function() { var relaxedQB = new qb.models.Query.QueryBuilder( validateOperatorsAndCombinators = false ); getMockBox().prepareMock( relaxedQB ); diff --git a/tests/specs/Query/ShouldWrapValuesSpec.cfc b/tests/specs/Query/ShouldWrapValuesSpec.cfc index 6efeba99..0c100592 100644 --- a/tests/specs/Query/ShouldWrapValuesSpec.cfc +++ b/tests/specs/Query/ShouldWrapValuesSpec.cfc @@ -97,6 +97,26 @@ component extends="testbox.system.BaseSpec" { expect( builder.clone().toSQL() ).toBe( "SELECT id FROM users" ); } ); } ); + + describe( "SQL literal escaping", function() { + it( "escapes each grammar's identifier delimiter", function() { + expect( new qb.models.Grammars.PostgresGrammar().wrapValue( "odd""name" ) ).toBe( """odd""""name""" ); + expect( new qb.models.Grammars.SQLiteGrammar().wrapValue( "odd""name" ) ).toBe( """odd""""name""" ); + expect( new qb.models.Grammars.DerbyGrammar().wrapValue( "odd""name" ) ).toBe( """odd""""name""" ); + expect( new qb.models.Grammars.OracleGrammar().wrapValue( "odd""name" ) ).toBe( """ODD""""NAME""" ); + expect( new qb.models.Grammars.MySQLGrammar().wrapValue( "odd#chr( 96 )#name" ) ).toBe( + "#chr( 96 )#odd#chr( 96 )##chr( 96 )#name#chr( 96 )#" + ); + expect( new qb.models.Grammars.SqlServerGrammar().wrapValue( "odd]name" ) ).toBe( "[odd]]name]" ); + } ); + + it( "escapes backslashes in JSON path segments", function() { + var slash = chr( 92 ); + var grammar = new qb.models.Grammars.BaseGrammar(); + + expect( grammar.buildJsonPath( [ "folder#slash#name" ] ) ).toBe( "$.""folder#slash##slash#name""" ); + } ); + } ); } } diff --git a/tests/specs/Schema/BlueprintLifecycleSpec.cfc b/tests/specs/Schema/BlueprintLifecycleSpec.cfc index 0034d2eb..6abffef4 100644 --- a/tests/specs/Schema/BlueprintLifecycleSpec.cfc +++ b/tests/specs/Schema/BlueprintLifecycleSpec.cfc @@ -67,6 +67,20 @@ component extends="testbox.system.BaseSpec" { blueprint.softDeletesTz(); expectCommandTypes( blueprint, [ "addColumn" ] ); } ); + + it( "rejects invalid foreign-key actions", function() { + var index = new qb.models.Schema.TableIndex(); + + expect( function() { + index.onDelete( "CASCADE; DROP TABLE users" ); + } ).toThrow( type = "InvalidReferentialAction" ); + expect( function() { + index.setOnUpdateAction( "CUSTOM ACTION" ); + } ).toThrow( type = "InvalidReferentialAction" ); + + expect( index.onDelete( " set null " ).getOnDeleteAction() ).toBe( "SET NULL" ); + expect( index.onUpdate( "cascade" ).getOnUpdateAction() ).toBe( "CASCADE" ); + } ); } ); } diff --git a/tests/specs/Schema/DerbySchemaBuilderSpec.cfc b/tests/specs/Schema/DerbySchemaBuilderSpec.cfc index aa4bdeb3..29d85ea3 100644 --- a/tests/specs/Schema/DerbySchemaBuilderSpec.cfc +++ b/tests/specs/Schema/DerbySchemaBuilderSpec.cfc @@ -1,5 +1,26 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { + function run() { + super.run(); + + describe( "Derby drop all objects", function() { + it( "discovers and qualifies tables in the requested schema", function() { + var schema = getBuilder(); + variables.mockGrammar.$( + "runQuery", + queryNew( + "table_name,table_schema", + "varchar,varchar", + [ { table_name: "users", table_schema: "tenant" } ] + ) + ); + + expect( schema.dropAllObjects( {}, false, "tenant" ) ).toBe( [ "DROP TABLE ""tenant"".""users""" ] ); + expect( variables.mockGrammar.$callLog().runQuery[ 1 ][ 2 ] ).toBe( [ "tenant" ] ); + } ); + } ); + } + function emptyTable() { return [ "CREATE TABLE ""users"" ()" ]; } @@ -86,7 +107,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { function enum() { return [ - "CREATE TABLE ""employees"" (""tshirt_size"" VARCHAR(255) NOT NULL, CONSTRAINT ""enum_employees_tshirt_size"" CHECK (""tshirt_size"" IN ('S', 'M', 'L', 'XL', 'XXL')))" + "CREATE TABLE ""employees"" (""tshirt_size"" VARCHAR(255) NOT NULL, CONSTRAINT ""enum_employees_tshirt_size"" CHECK (""tshirt_size"" IN ('S''s', 'M', 'L', 'XL', 'XXL')))" ]; } @@ -342,7 +363,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { function comment() { return [ "CREATE TABLE ""users"" (""active"" BOOLEAN NOT NULL)", - "COMMENT ON COLUMN ""users"".""active"" IS 'This is a comment'" + "COMMENT ON COLUMN ""users"".""active"" IS 'Pete''s comment'" ]; } @@ -359,7 +380,15 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { } function defaultForString() { - return [ "CREATE TABLE ""users"" (""country"" VARCHAR(255) NOT NULL DEFAULT 'USA')" ]; + return [ "CREATE TABLE ""users"" (""country"" VARCHAR(255) NOT NULL DEFAULT 'O''Brien')" ]; + } + + function defaultForEmptyString() { + return [ "CREATE TABLE ""users"" (""nickname"" VARCHAR(255) NOT NULL DEFAULT '')" ]; + } + + function defaultForUnicodeString() { + return [ "CREATE TABLE ""users"" (""nickname"" VARCHAR(255) NOT NULL DEFAULT 'O''Brien')" ]; } function timestampWithCurrent() { diff --git a/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc b/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc index 83c02e3e..d1c2f573 100644 --- a/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc @@ -15,6 +15,25 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { expect( schema.rename( "audit.users", "accounts", {}, false ).toSql() ).toBe( [ "RENAME TABLE `audit`.`users` TO `audit`.`accounts`" ] ); } ); + it( "discovers and qualifies base tables in the requested schema when dropping all objects", function() { + var schema = getBuilder(); + variables.mockGrammar.$( + "runQuery", + queryNew( + "Tables_in_tenant,Table_type", + "varchar,varchar", + [ { Tables_in_tenant: "users", Table_type: "BASE TABLE" } ] + ) + ); + + var statements = schema.dropAllObjects( {}, false, "tenant" ); + var quote = chr( 96 ); + + expect( statements[ 2 ] ).toBeWithCase( "DROP TABLE #quote#tenant#quote#.#quote#users#quote#" ); + expect( variables.mockGrammar.$callLog().runQuery[ 1 ][ 1 ] ).toBeWithCase( + "SHOW FULL TABLES FROM #quote#tenant#quote# WHERE table_type = 'BASE TABLE'" + ); + } ); } ); } @@ -107,7 +126,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { } function enum() { - return [ "CREATE TABLE `employees` (`tshirt_size` ENUM('S', 'M', 'L', 'XL', 'XXL') NOT NULL)" ]; + return [ "CREATE TABLE `employees` (`tshirt_size` ENUM('S''s', 'M', 'L', 'XL', 'XXL') NOT NULL)" ]; } function float() { @@ -363,7 +382,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { } function comment() { - return [ "CREATE TABLE `users` (`active` TINYINT(1) NOT NULL COMMENT 'This is a comment')" ]; + return [ "CREATE TABLE `users` (`active` TINYINT(1) NOT NULL COMMENT 'Pete''s comment')" ]; } function defaultForChar() { @@ -379,7 +398,15 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { } function defaultForString() { - return [ "CREATE TABLE `users` (`country` VARCHAR(255) NOT NULL DEFAULT 'USA')" ]; + return [ "CREATE TABLE `users` (`country` VARCHAR(255) NOT NULL DEFAULT 'O''Brien')" ]; + } + + function defaultForEmptyString() { + return [ "CREATE TABLE `users` (`nickname` VARCHAR(255) NOT NULL DEFAULT '')" ]; + } + + function defaultForUnicodeString() { + return [ "CREATE TABLE `users` (`nickname` NVARCHAR(255) NOT NULL DEFAULT 'O''Brien')" ]; } function timestampWithCurrent() { diff --git a/tests/specs/Schema/OracleSchemaBuilderSpec.cfc b/tests/specs/Schema/OracleSchemaBuilderSpec.cfc index fc0c3527..bb0d3dfa 100644 --- a/tests/specs/Schema/OracleSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/OracleSchemaBuilderSpec.cfc @@ -58,13 +58,11 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { it( "drops generated sequences and triggers from the default schema", () => { var schema = getBuilder().setDefaultSchema( "app" ); - variables.mockGrammar.$( "hasSequence", true ); - variables.mockGrammar.$( "hasTrigger", true ); expect( schema.drop( "users", {}, false ).toSql() ).toBe( [ "DROP TABLE ""APP"".""USERS""", - "DROP SEQUENCE ""APP"".""SEQ_USERS""", - "DROP TRIGGER ""APP"".""TRG_USERS""" + "BEGIN EXECUTE IMMEDIATE 'DROP SEQUENCE ""APP"".""SEQ_USERS""'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -2289 THEN RAISE; END IF; END;", + "BEGIN EXECUTE IMMEDIATE 'DROP TRIGGER ""APP"".""TRG_USERS""'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -4080 THEN RAISE; END IF; END;" ] ); } ); @@ -92,8 +90,6 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { it( "attempts to drop sequences and triggers when dropping a table", () => { try { var schema = getBuilder(); - variables.mockGrammar.$( "hasSequence", true ); - variables.mockGrammar.$( "hasTrigger", true ); var statements = schema.drop( "users", {}, false ); if ( !isSimpleValue( statements ) ) { statements = statements.toSql(); @@ -104,8 +100,8 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { expect( statements ).toBeArray(); var expected = [ "DROP TABLE ""USERS""", - "DROP SEQUENCE ""SEQ_USERS""", - "DROP TRIGGER ""TRG_USERS""" + "BEGIN EXECUTE IMMEDIATE 'DROP SEQUENCE ""SEQ_USERS""'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -2289 THEN RAISE; END IF; END;", + "BEGIN EXECUTE IMMEDIATE 'DROP TRIGGER ""TRG_USERS""'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -4080 THEN RAISE; END IF; END;" ]; expect( statements ).toHaveLength( arrayLen( expected ) ); for ( var i = 1; i <= expected.len(); i++ ) { @@ -119,6 +115,15 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { rethrow; } } ); + + it( "scopes drop all objects to the requested schema", function() { + var statement = getBuilder().dropAllObjects( {}, false, "tenant's" )[ 1 ]; + + expect( statement ).toInclude( "FROM all_tables WHERE owner = 'TENANT''S'" ); + expect( statement ).toInclude( "DROP TABLE ""TENANT''S"".""'" ); + expect( statement ).toInclude( "FROM all_sequences WHERE sequence_owner = 'TENANT''S'" ); + expect( statement ).toInclude( "DROP SEQUENCE ""TENANT''S"".""'" ); + } ); } ); } @@ -216,7 +221,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { function enum() { return [ - "CREATE TABLE ""EMPLOYEES"" (""TSHIRT_SIZE"" VARCHAR2(255) NOT NULL, CONSTRAINT ""ENUM_EMPLOYEES_TSHIRT_SIZE"" CHECK (""TSHIRT_SIZE"" IN ('S', 'M', 'L', 'XL', 'XXL')))" + "CREATE TABLE ""EMPLOYEES"" (""TSHIRT_SIZE"" VARCHAR2(255) NOT NULL, CONSTRAINT ""ENUM_EMPLOYEES_TSHIRT_SIZE"" CHECK (""TSHIRT_SIZE"" IN ('S''s', 'M', 'L', 'XL', 'XXL')))" ]; } @@ -479,7 +484,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { function comment() { return [ "CREATE TABLE ""USERS"" (""ACTIVE"" NUMBER(1, 0) NOT NULL)", - "COMMENT ON COLUMN ""USERS"".""ACTIVE"" IS 'This is a comment'" + "COMMENT ON COLUMN ""USERS"".""ACTIVE"" IS 'Pete''s comment'" ]; } @@ -496,7 +501,15 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { } function defaultForString() { - return [ "CREATE TABLE ""USERS"" (""COUNTRY"" VARCHAR2(255) DEFAULT 'USA' NOT NULL)" ]; + return [ "CREATE TABLE ""USERS"" (""COUNTRY"" VARCHAR2(255) DEFAULT 'O''Brien' NOT NULL)" ]; + } + + function defaultForEmptyString() { + return [ "CREATE TABLE ""USERS"" (""NICKNAME"" VARCHAR2(255) DEFAULT '' NOT NULL)" ]; + } + + function defaultForUnicodeString() { + return [ "CREATE TABLE ""USERS"" (""NICKNAME"" NVARCHAR2(255) DEFAULT 'O''Brien' NOT NULL)" ]; } function timestampWithCurrent() { @@ -720,7 +733,11 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { } function dropTable() { - return [ "DROP TABLE ""USERS""" ]; + return [ + "DROP TABLE ""USERS""", + "BEGIN EXECUTE IMMEDIATE 'DROP SEQUENCE ""SEQ_USERS""'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -2289 THEN RAISE; END IF; END;", + "BEGIN EXECUTE IMMEDIATE 'DROP TRIGGER ""TRG_USERS""'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -4080 THEN RAISE; END IF; END;" + ]; } function truncateTable() { @@ -728,7 +745,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { } function dropIfExists() { - return [ "DROP TABLE ""USERS""" ]; + return dropTable(); } function dropColumn() { @@ -791,8 +808,6 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { .init( utils ) : arguments.mockGrammar; var builder = getMockBox().createMock( "qb.models.Schema.SchemaBuilder" ).init( arguments.mockGrammar ); variables.mockGrammar = arguments.mockGrammar; - variables.mockGrammar.$( "hasSequence", false ); - variables.mockGrammar.$( "hasTrigger", false ); return builder; } diff --git a/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc b/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc index e29daccd..0451f47e 100644 --- a/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc @@ -16,6 +16,52 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { ); }, defaultForJsonbCastShorthand() ); } ); + + it( "discovers and qualifies tables in the requested schema when dropping all objects", function() { + var schema = getBuilder(); + variables.mockGrammar.$( "runQuery", queryNew( "table_name", "varchar", [ { table_name: "users" } ] ) ); + + expect( schema.dropAllObjects( {}, false, "tenant" ) ).toBe( [ "DROP TABLE ""tenant"".""users"" CASCADE" ] ); + expect( variables.mockGrammar.$callLog().runQuery[ 1 ][ 1 ] ).toBeWithCase( + "SELECT ""table_name"" FROM ""information_schema"".""tables"" WHERE ""table_schema"" = ? AND ""table_type"" = 'BASE TABLE'" + ); + expect( variables.mockGrammar.$callLog().runQuery[ 1 ][ 2 ] ).toBe( [ "tenant" ] ); + } ); + + it( "drops indexes from the table's schema", function() { + var statements = getBuilder() + .setDefaultSchema( "tenant" ) + .alter( + "users", + function( table ) { + table.dropIndex( "idx_users_email" ); + }, + {}, + false + ) + .toSQL(); + + expect( statements ).toBe( [ "DROP INDEX ""tenant"".""idx_users_email""" ] ); + } ); + + it( "creates enum types in the table's schema", function() { + var statements = getBuilder() + .setDefaultSchema( "tenant" ) + .create( + "users", + function( table ) { + table.enum( "status", [ "active", "inactive" ] ).default( "active" ); + }, + {}, + false + ) + .toSQL(); + + expect( statements ).toBe( [ + "CREATE TYPE ""tenant"".""status"" AS ENUM ('active', 'inactive')", + "CREATE TABLE ""tenant"".""users"" (""status"" ""tenant"".""status"" NOT NULL DEFAULT 'active')" + ] ); + } ); } ); } @@ -107,8 +153,8 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { function enum() { return [ - "CREATE TYPE ""tshirt_size"" AS ENUM ('S', 'M', 'L', 'XL', 'XXL')", - "CREATE TABLE ""employees"" (""tshirt_size"" tshirt_size NOT NULL)" + "CREATE TYPE ""tshirt_size"" AS ENUM ('S''s', 'M', 'L', 'XL', 'XXL')", + "CREATE TABLE ""employees"" (""tshirt_size"" ""tshirt_size"" NOT NULL)" ]; } @@ -356,7 +402,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { function comment() { return [ "CREATE TABLE ""users"" (""active"" BOOLEAN NOT NULL)", - "COMMENT ON COLUMN ""users"".""active"" IS 'This is a comment'" + "COMMENT ON COLUMN ""users"".""active"" IS 'Pete''s comment'" ]; } @@ -373,7 +419,15 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { } function defaultForString() { - return [ "CREATE TABLE ""users"" (""country"" VARCHAR(255) NOT NULL DEFAULT 'USA')" ]; + return [ "CREATE TABLE ""users"" (""country"" VARCHAR(255) NOT NULL DEFAULT 'O''Brien')" ]; + } + + function defaultForEmptyString() { + return [ "CREATE TABLE ""users"" (""nickname"" VARCHAR(255) NOT NULL DEFAULT '')" ]; + } + + function defaultForUnicodeString() { + return [ "CREATE TABLE ""users"" (""nickname"" VARCHAR(255) NOT NULL DEFAULT 'O''Brien')" ]; } function timestampWithCurrent() { @@ -561,7 +615,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { function addColumn() { return [ "CREATE TYPE ""tshirt_size"" AS ENUM ('S', 'M', 'L', 'XL', 'XXL')", - "ALTER TABLE ""users"" ADD COLUMN ""tshirt_size"" tshirt_size NOT NULL" + "ALTER TABLE ""users"" ADD COLUMN ""tshirt_size"" ""tshirt_size"" NOT NULL" ]; } @@ -575,7 +629,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { function addMultiple() { return [ "CREATE TYPE ""tshirt_size"" AS ENUM ('S', 'M', 'L', 'XL', 'XXL')", - "ALTER TABLE ""users"" ADD COLUMN ""tshirt_size"" tshirt_size NOT NULL", + "ALTER TABLE ""users"" ADD COLUMN ""tshirt_size"" ""tshirt_size"" NOT NULL", "ALTER TABLE ""users"" ADD COLUMN ""is_active"" BOOLEAN NOT NULL" ]; } @@ -584,7 +638,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { return [ "CREATE TYPE ""tshirt_size"" AS ENUM ('S', 'M', 'L', 'XL', 'XXL')", "ALTER TABLE ""users"" DROP COLUMN ""is_active"" CASCADE", - "ALTER TABLE ""users"" ADD COLUMN ""tshirt_size"" tshirt_size NOT NULL", + "ALTER TABLE ""users"" ADD COLUMN ""tshirt_size"" ""tshirt_size"" NOT NULL", "ALTER TABLE ""users"" RENAME COLUMN ""name"" TO ""username""", "ALTER TABLE ""users"" ALTER COLUMN ""purchase_date"" TYPE TIMESTAMP, ALTER COLUMN ""purchase_date"" DROP NOT NULL", "ALTER TABLE ""users"" ADD CONSTRAINT ""unq_users_username"" UNIQUE (""username"")", diff --git a/tests/specs/Schema/SQLiteSchemaBuilderSpec.cfc b/tests/specs/Schema/SQLiteSchemaBuilderSpec.cfc index 9ba4b9e7..54484227 100644 --- a/tests/specs/Schema/SQLiteSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/SQLiteSchemaBuilderSpec.cfc @@ -1,5 +1,64 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { + function run() { + super.run(); + + describe( "SQLite schema-scoped indexes", function() { + it( "drops indexes from the table's schema", function() { + var statements = getBuilder() + .setDefaultSchema( "tenant" ) + .alter( + "users", + function( table ) { + table.dropIndex( "idx_users_email" ); + }, + {}, + false + ) + .toSQL(); + + expect( statements ).toBe( [ "DROP INDEX ""tenant"".""idx_users_email""" ] ); + } ); + } ); + + describe( "SQLite altered constraints", function() { + it( "rejects adding primary-key constraints", function() { + expect( function() { + getBuilder() + .alter( + "users", + function( table ) { + table.addConstraint( table.primaryKey( "id" ) ); + }, + {}, + false + ) + .toSQL(); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + + it( "rejects adding foreign-key constraints", function() { + expect( function() { + getBuilder() + .alter( + "posts", + function( table ) { + table.addConstraint( + table + .foreignKey( "author_id" ) + .references( "id" ) + .onTable( "users" ) + ); + }, + {}, + false + ) + .toSQL(); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + } ); + } + function emptyTable() { return [ "CREATE TABLE ""users"" ()" ]; } @@ -124,7 +183,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { function enum() { return [ - "CREATE TABLE ""employees"" (""tshirt_size"" TEXT NOT NULL CHECK (""tshirt_size"" IN ('S', 'M', 'L', 'XL', 'XXL')))" + "CREATE TABLE ""employees"" (""tshirt_size"" TEXT NOT NULL CHECK (""tshirt_size"" IN ('S''s', 'M', 'L', 'XL', 'XXL')))" ]; } @@ -390,7 +449,15 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { } function defaultForString() { - return [ "CREATE TABLE ""users"" (""country"" TEXT NOT NULL DEFAULT 'USA')" ]; + return [ "CREATE TABLE ""users"" (""country"" TEXT NOT NULL DEFAULT 'O''Brien')" ]; + } + + function defaultForEmptyString() { + return [ "CREATE TABLE ""users"" (""nickname"" TEXT NOT NULL DEFAULT '')" ]; + } + + function defaultForUnicodeString() { + return [ "CREATE TABLE ""users"" (""nickname"" TEXT NOT NULL DEFAULT 'O''Brien')" ]; } function timestampWithCurrent() { diff --git a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc index 1332c876..cbeb96b0 100644 --- a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc @@ -52,6 +52,29 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { expect( statements ).toBe( [ "EXEC sp_rename N'worker''s', N'employee''s'" ] ); } ); } ); + + describe( "SQL Server drop all objects", function() { + it( "scopes foreign keys and qualifies tables in the requested schema", function() { + var schema = getBuilder(); + variables.mockGrammar.$( + "runQuery", + queryNew( + "table_name,table_schema", + "varchar,varchar", + [ { table_name: "users", table_schema: "tenant" } ] + ) + ); + + var statements = schema.dropAllObjects( {}, false, "tenant" ); + + expect( statements[ 1 ] ).toInclude( "QUOTENAME(OBJECT_SCHEMA_NAME(parent_object_id))" ); + expect( statements[ 1 ] ).toInclude( "WHERE OBJECT_SCHEMA_NAME(parent_object_id) = N'tenant'" ); + expect( statements[ 2 ] ).toBeWithCase( "DROP TABLE [tenant].[users]" ); + expect( variables.mockGrammar.$callLog().runQuery[ 1 ][ 1 ] ).toInclude( + "WHERE [table_schema] = ? AND [table_type] = 'BASE TABLE'" + ); + } ); + } ); } function emptyTable() { @@ -138,7 +161,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { function enum() { return [ - "CREATE TABLE [employees] ([tshirt_size] NVARCHAR(255) NOT NULL, CONSTRAINT [enum_employees_tshirt_size] CHECK ([tshirt_size] IN ('S', 'M', 'L', 'XL', 'XXL')))" + "CREATE TABLE [employees] ([tshirt_size] NVARCHAR(255) NOT NULL, CONSTRAINT [enum_employees_tshirt_size] CHECK ([tshirt_size] IN ('S''s', 'M', 'L', 'XL', 'XXL')))" ]; } @@ -411,7 +434,19 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { } function defaultForString() { - return [ "CREATE TABLE [users] ([country] VARCHAR(255) NOT NULL CONSTRAINT [df_users_country] DEFAULT 'USA')" ]; + return [ + "CREATE TABLE [users] ([country] VARCHAR(255) NOT NULL CONSTRAINT [df_users_country] DEFAULT 'O''Brien')" + ]; + } + + function defaultForEmptyString() { + return [ "CREATE TABLE [users] ([nickname] VARCHAR(255) NOT NULL CONSTRAINT [df_users_nickname] DEFAULT '')" ]; + } + + function defaultForUnicodeString() { + return [ + "CREATE TABLE [users] ([nickname] NVARCHAR(255) NOT NULL CONSTRAINT [df_users_nickname] DEFAULT 'O''Brien')" + ]; } function nullable() { From 7b5ac66dba6f4c5a329943e944930cac3c766ae4 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 15 Aug 2026 15:24:32 -0600 Subject: [PATCH 024/119] fix: harden query and schema edge cases --- models/Grammars/BaseGrammar.cfc | 53 +++- models/Grammars/DerbyGrammar.cfc | 14 +- models/Grammars/MySQLGrammar.cfc | 31 ++- models/Grammars/OracleGrammar.cfc | 12 +- models/Grammars/SqlServerGrammar.cfc | 4 +- models/Query/QueryBuilder.cfc | 239 ++++++++---------- models/Query/QueryUtils.cfc | 18 +- models/SQLCommenter/SQLCommenter.cfc | 89 ++++++- models/Schema/Blueprint.cfc | 40 +-- models/Schema/SchemaBuilder.cfc | 44 ++-- .../specs/Query/Abstract/BuilderAliasSpec.cfc | 91 +++++++ .../Query/Abstract/BuilderSelectSpec.cfc | 9 + tests/specs/Query/Abstract/PretendSpec.cfc | 33 +++ .../Query/Abstract/QueryExecutionSpec.cfc | 94 +++++++ tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 24 ++ tests/specs/Query/ShouldWrapValuesSpec.cfc | 4 + tests/specs/SQLCommenterSpec.cfc | 49 ++++ tests/specs/Schema/BlueprintLifecycleSpec.cfc | 59 +++++ 18 files changed, 694 insertions(+), 213 deletions(-) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 22e137bb..8165c937 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -116,7 +116,7 @@ component displayname="Grammar" accessors="true" singleton { var data = { "sql": arguments.sql, "bindings": arguments.bindings, - "options": arguments.options, + "options": structCopy( arguments.options ), "returnObject": arguments.returnObject, "pretend": arguments.pretend }; @@ -965,6 +965,33 @@ component displayname="Grammar" accessors="true" singleton { return false; } + /** + * Resolve the complete set of columns present across all rows in a multi-row insert. + * + * @values The rows to inspect. + * + * @return The unique column names in first-seen order. + */ + public array function resolveInsertColumnNames( required array values ) { + var columnNames = []; + var seenColumns = {}; + + arguments.values.each( function( row ) { + if ( !isStruct( arguments.row ) ) { + throw( type = "InvalidSQLType", message = "Please pass an array of structs mapping columns to values" ); + } + + for ( var key in arguments.row ) { + if ( !seenColumns.keyExists( key ) ) { + seenColumns[ key ] = true; + columnNames.append( key ); + } + } + } ); + + return columnNames; + } + /** * Prepare values and column metadata for a grammar's native bulk insert compiler. * @@ -974,12 +1001,10 @@ component displayname="Grammar" accessors="true" singleton { */ public struct function prepareBulkInsert( required any query, required array values, required struct sqlTypes ) { var builder = arguments.query; - var columns = arguments.values[ 1 ] - .keyArray() - .map( function( column ) { - var formatted = listLast( builder.applyColumnFormatter( column ), "." ); - return { "original": column, "formatted": { "type": "simple", "value": formatted } }; - } ); + var columns = resolveInsertColumnNames( arguments.values ).map( function( column ) { + var formatted = listLast( builder.applyColumnFormatter( column ), "." ); + return { "original": column, "formatted": { "type": "simple", "value": formatted } }; + } ); columns.sort( ( a, b ) => compareNoCase( a.formatted.value, b.formatted.value ) ); arguments.values.each( function( row ) { @@ -1514,13 +1539,17 @@ component displayname="Grammar" accessors="true" singleton { return arguments.value; } - var value = toString( arguments.value ); - if ( len( value ) >= 2 && left( value, 1 ) == """" && right( value, 1 ) == """" ) { - value = mid( value, 2, len( value ) - 2 ); + var normalizedValue = toString( arguments.value ); + if ( + len( normalizedValue ) >= 2 && + left( normalizedValue, 1 ) == """" && + right( normalizedValue, 1 ) == """" + ) { + normalizedValue = mid( normalizedValue, 2, len( normalizedValue ) - 2 ); } - value = replace( value, """", """""", "all" ); + normalizedValue = replace( normalizedValue, """", """""", "all" ); - return """#value#"""; + return """#normalizedValue#"""; } /** diff --git a/models/Grammars/DerbyGrammar.cfc b/models/Grammars/DerbyGrammar.cfc index 94b873a1..1d185163 100644 --- a/models/Grammars/DerbyGrammar.cfc +++ b/models/Grammars/DerbyGrammar.cfc @@ -377,12 +377,16 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { return arguments.value; } - var value = toString( arguments.value ); - if ( len( value ) >= 2 && left( value, 1 ) == """" && right( value, 1 ) == """" ) { - value = mid( value, 2, len( value ) - 2 ); + var normalizedValue = toString( arguments.value ); + if ( + len( normalizedValue ) >= 2 && + left( normalizedValue, 1 ) == """" && + right( normalizedValue, 1 ) == """" + ) { + normalizedValue = mid( normalizedValue, 2, len( normalizedValue ) - 2 ); } - value = replace( value, """", """""", "all" ); - return """#value#"""; + normalizedValue = replace( normalizedValue, """", """""", "all" ); + return """#normalizedValue#"""; } function compileCreateAs( blueprint, commandParameters ) { diff --git a/models/Grammars/MySQLGrammar.cfc b/models/Grammars/MySQLGrammar.cfc index 6d1c90ca..b59bfea5 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -65,19 +65,30 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { return value; } - var value = reReplace( - toString( arguments.value ), - """", - "", - "all" - ); + var normalizedValue = toString( arguments.value ); + if ( + len( normalizedValue ) >= 2 && + left( normalizedValue, 1 ) == """" && + right( normalizedValue, 1 ) == """" + ) { + normalizedValue = mid( normalizedValue, 2, len( normalizedValue ) - 2 ); + } var quote = chr( 96 ); - if ( len( value ) >= 2 && left( value, 1 ) == quote && right( value, 1 ) == quote ) { - value = mid( value, 2, len( value ) - 2 ); + if ( + len( normalizedValue ) >= 2 && + left( normalizedValue, 1 ) == quote && + right( normalizedValue, 1 ) == quote + ) { + normalizedValue = mid( normalizedValue, 2, len( normalizedValue ) - 2 ); } - value = replace( value, quote, quote & quote, "all" ); + normalizedValue = replace( + normalizedValue, + quote, + quote & quote, + "all" + ); - return "#quote##value##quote#"; + return "#quote##normalizedValue##quote#"; } /** diff --git a/models/Grammars/OracleGrammar.cfc b/models/Grammars/OracleGrammar.cfc index 6c6a43c2..7a2c800f 100644 --- a/models/Grammars/OracleGrammar.cfc +++ b/models/Grammars/OracleGrammar.cfc @@ -413,15 +413,15 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { return arguments.value; } - var value = toString( arguments.value ); - var isQuoted = len( value ) >= 2 && left( value, 1 ) == """" && right( value, 1 ) == """"; + var normalizedValue = toString( arguments.value ); + var isQuoted = len( normalizedValue ) >= 2 && left( normalizedValue, 1 ) == """" && right( normalizedValue, 1 ) == """"; if ( isQuoted ) { - value = mid( value, 2, len( value ) - 2 ); + normalizedValue = mid( normalizedValue, 2, len( normalizedValue ) - 2 ); } else { - value = uCase( value ); + normalizedValue = uCase( normalizedValue ); } - value = replace( value, """", """""", "all" ); - return """#value#"""; + normalizedValue = replace( normalizedValue, """", """""", "all" ); + return """#normalizedValue#"""; } function compileCreateColumn( column, blueprint ) { diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index 339c3bd5..29579c55 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -501,7 +501,9 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { return value; } - arguments.value = reReplace( arguments.value, """", "", "all" ); + if ( len( arguments.value ) >= 2 && left( arguments.value, 1 ) == """" && right( arguments.value, 1 ) == """" ) { + arguments.value = mid( arguments.value, 2, len( arguments.value ) - 2 ); + } arguments.value = replace( arguments.value, "]", "]]", "all" ); return "[#value#]"; diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index aaf3994e..c9813673 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -756,8 +756,9 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function selectRaw( required any expression, array bindings = [] ) { - for ( var sql in arrayWrap( arguments.expression ) ) { - addSelect( raw( sql, arguments.bindings ) ); + var expressions = arrayWrap( arguments.expression ); + for ( var index = 1; index <= expressions.len(); index++ ) { + addSelect( raw( expressions[ index ], index == 1 ? arguments.bindings : [] ) ); } return this; } @@ -889,18 +890,8 @@ component displayname="QueryBuilder" accessors="true" { private void function renameAliasesInColumns( required string oldAlias, required string newAlias ) { for ( var i = 1; i <= variables.columns.len(); i++ ) { var column = variables.columns[ i ]; - if ( column.type == "simple" ) { - variables.columns[ i ] = { - "type": "simple", - "value": swapAlias( column.value, arguments.oldAlias, arguments.newAlias ) - }; - } else if ( column.type == "jsonPath" ) { - variables.columns[ i ].value.column = swapAlias( - column.value.column, - arguments.oldAlias, - arguments.newAlias - ); - } else if ( column.type == "builder" ) { + renameAliasInTypedColumn( column, arguments.oldAlias, arguments.newAlias ); + if ( column.type == "builder" ) { column.value.renameAliases( arguments.oldAlias, arguments.newAlias ); } } @@ -920,72 +911,53 @@ component displayname="QueryBuilder" accessors="true" { } private void function renameAliasesInGroups( required string oldAlias, required string newAlias ) { - for ( var i = 1; i <= variables.groups.len(); i++ ) { - var column = variables.groups[ i ]; - if ( column.type == "simple" ) { - variables.groups[ i ].value = swapAlias( column.value, arguments.oldAlias, arguments.newAlias ); - } else if ( column.type == "jsonPath" ) { - variables.groups[ i ].value.column = swapAlias( - column.value.column, - arguments.oldAlias, - arguments.newAlias - ); - } + for ( var column in variables.groups ) { + renameAliasInTypedColumn( column, arguments.oldAlias, arguments.newAlias ); } } private void function renameAliasesInHavings( required string oldAlias, required string newAlias ) { for ( var having in variables.havings ) { if ( structKeyExists( having, "column" ) ) { - if ( having.column.type == "simple" ) { - having.column.value = swapAlias( having.column.value, arguments.oldAlias, arguments.newAlias ); - } else if ( having.column.type == "jsonPath" ) { - having.column.value.column = swapAlias( - having.column.value.column, - arguments.oldAlias, - arguments.newAlias - ); - } + renameAliasInTypedColumn( having.column, arguments.oldAlias, arguments.newAlias ); } } } private void function renameAliasesInOrders( required string oldAlias, required string newAlias ) { for ( var order in variables.orders ) { - if ( order.direction != "raw" ) { - if ( order.column.type == "simple" ) { - order.column.value = swapAlias( order.column.value, arguments.oldAlias, arguments.newAlias ); - } else if ( order.column.type == "jsonPath" ) { - order.column.value.column = swapAlias( - order.column.value.column, - arguments.oldAlias, - arguments.newAlias - ); - } + if ( order.keyExists( "query" ) ) { + order.query.renameAliases( arguments.oldAlias, arguments.newAlias ); + } else if ( order.keyExists( "column" ) && order.direction != "raw" ) { + renameAliasInTypedColumn( order.column, arguments.oldAlias, arguments.newAlias ); } } } - private void function renameAliasInWhereBasic( - required struct where, + private void function renameAliasInTypedColumn( + required struct column, required string oldAlias, required string newAlias ) { - if ( arguments.where.column.type == "simple" ) { - arguments.where.column.value = swapAlias( - arguments.where.column.value, - arguments.oldAlias, - arguments.newAlias - ); - } else if ( arguments.where.column.type == "jsonPath" ) { - arguments.where.column.value.column = swapAlias( - arguments.where.column.value.column, + if ( arguments.column.type == "simple" ) { + arguments.column.value = swapAlias( arguments.column.value, arguments.oldAlias, arguments.newAlias ); + } else if ( arguments.column.type == "jsonPath" ) { + arguments.column.value.column = swapAlias( + arguments.column.value.column, arguments.oldAlias, arguments.newAlias ); } } + private void function renameAliasInWhereBasic( + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); + } + private void function renameAliasInWhereJsonContains( required struct where, required string oldAlias, @@ -1019,20 +991,8 @@ component displayname="QueryBuilder" accessors="true" { required string oldAlias, required string newAlias ) { - if ( arguments.where.first.type == "simple" ) { - arguments.where.first.value = swapAlias( - arguments.where.first.value, - arguments.oldAlias, - arguments.newAlias - ); - } - if ( arguments.where.second.type == "simple" ) { - arguments.where.second.value = swapAlias( - arguments.where.second.value, - arguments.oldAlias, - arguments.newAlias - ); - } + renameAliasInTypedColumn( arguments.where.first, arguments.oldAlias, arguments.newAlias ); + renameAliasInTypedColumn( arguments.where.second, arguments.oldAlias, arguments.newAlias ); } private void function renameAliasInWhereSub( @@ -1040,13 +1000,7 @@ component displayname="QueryBuilder" accessors="true" { required string oldAlias, required string newAlias ) { - if ( where.column.type == "simple" ) { - arguments.where.column.value = swapAlias( - arguments.where.column.value, - arguments.oldAlias, - arguments.newAlias - ); - } + renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); arguments.where.query.renameAliases( arguments.oldAlias, arguments.newAlias ); } @@ -1055,13 +1009,7 @@ component displayname="QueryBuilder" accessors="true" { required string oldAlias, required string newAlias ) { - if ( arguments.where.column.type == "simple" ) { - arguments.where.column.value = swapAlias( - arguments.where.column.value, - arguments.oldAlias, - arguments.newAlias - ); - } + renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); } private void function renameAliasInWhereNotIn( @@ -1069,13 +1017,32 @@ component displayname="QueryBuilder" accessors="true" { required string oldAlias, required string newAlias ) { - if ( arguments.where.column.type == "simple" ) { - arguments.where.column.value = swapAlias( - arguments.where.column.value, - arguments.oldAlias, - arguments.newAlias - ); - } + renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); + } + + private void function renameAliasInWhereInBulk( + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); + } + + private void function renameAliasInWhereInSub( + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); + arguments.where.query.renameAliases( arguments.oldAlias, arguments.newAlias ); + } + + private void function renameAliasInWhereNotInSub( + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInWhereInSub( argumentCollection = arguments ); } private void function renameAliasInWhereRaw( @@ -1115,13 +1082,7 @@ component displayname="QueryBuilder" accessors="true" { required string oldAlias, required string newAlias ) { - if ( arguments.where.column.type == "simple" ) { - arguments.where.column.value = swapAlias( - arguments.where.column.value, - arguments.oldAlias, - arguments.newAlias - ); - } + renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); } private void function renameAliasInWhereNotNull( @@ -1129,13 +1090,7 @@ component displayname="QueryBuilder" accessors="true" { required string oldAlias, required string newAlias ) { - if ( arguments.where.column.type == "simple" ) { - arguments.where.column.value = swapAlias( - arguments.where.column.value, - arguments.oldAlias, - arguments.newAlias - ); - } + renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); } private void function renameAliasInWhereNullSub( @@ -1159,13 +1114,7 @@ component displayname="QueryBuilder" accessors="true" { required string oldAlias, required string newAlias ) { - if ( arguments.where.column.type == "simple" ) { - arguments.where.column.value = swapAlias( - arguments.where.column.value, - arguments.oldAlias, - arguments.newAlias - ); - } + renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); } private void function renameAliasInWhereNotBetween( @@ -1173,17 +1122,11 @@ component displayname="QueryBuilder" accessors="true" { required string oldAlias, required string newAlias ) { - if ( arguments.where.column.type == "simple" ) { - arguments.where.column.value = swapAlias( - arguments.where.column.value, - arguments.oldAlias, - arguments.newAlias - ); - } + renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); } private string function swapAlias( required string column, required string oldAlias, required string newAlias ) { - if ( startsWith( arguments.column, arguments.oldAlias ) ) { + if ( startsWith( arguments.column, arguments.oldAlias & "." ) ) { return arguments.newAlias & "." & listLast( arguments.column, "." ); } return arguments.column; @@ -3610,7 +3553,9 @@ component displayname="QueryBuilder" accessors="true" { private numeric function getCountForPagination( struct options = {} ) { if ( !variables.groups.isEmpty() || !variables.havings.isEmpty() || variables.distinct ) { var countSource = clone().clearOrders(); - return newQuery().fromSub( "aggregate_table", countSource ).count( options = arguments.options ); + return prepareInternalExecutionBuilder( newQuery() ) + .fromSub( "aggregate_table", countSource ) + .count( options = arguments.options ); } return count( options = arguments.options ); } @@ -3754,8 +3699,8 @@ component displayname="QueryBuilder" accessors="true" { arguments.values = [ arguments.values ]; } - var columns = arguments.values[ 1 ] - .keyArray() + var columns = getGrammar() + .resolveInsertColumnNames( arguments.values ) .map( function( column ) { var formatted = listLast( applyColumnFormatter( column ), "." ); return { "original": column, "formatted": formatted }; @@ -3820,7 +3765,7 @@ component displayname="QueryBuilder" accessors="true" { return []; } - var columnCount = arguments.values[ 1 ].count(); + var columnCount = getGrammar().resolveInsertColumnNames( arguments.values ).len(); if ( columnCount == 0 ) { throw( type = "InvalidSQLType", message = "Please pass structs with at least one column to insertBulk." ); } @@ -3851,7 +3796,7 @@ component displayname="QueryBuilder" accessors="true" { clearBindings( only = [ "insert" ] ); } } else { - var batchQuery = clone(); + var batchQuery = prepareInternalExecutionBuilder( clone() ); results.append( batchQuery.insert( values = batch, options = arguments.options, toSql = arguments.toSql ) ); @@ -3945,8 +3890,8 @@ component displayname="QueryBuilder" accessors="true" { values = [ values ]; } - var columns = arguments.values[ 1 ] - .keyArray() + var columns = getGrammar() + .resolveInsertColumnNames( arguments.values ) .map( function( column ) { var formatted = listLast( applyColumnFormatter( column ), "." ); return { "original": column, "formatted": formatted }; @@ -4034,6 +3979,7 @@ component displayname="QueryBuilder" accessors="true" { * @return query */ public any function update( struct values = {}, struct options = {}, boolean toSql = false ) { + arguments.values = structCopy( arguments.values ); structAppend( arguments.values, variables.updates, false ); var updateArray = arguments.values .keyArray() @@ -4145,7 +4091,7 @@ component displayname="QueryBuilder" accessors="true" { } if ( !isNull( arguments.source ) ) { - addBindingsFromBuilder( arguments.source ); + addBindings( arguments.source.getBindings(), "insert" ); } if ( !isArray( arguments.values ) ) { @@ -4169,7 +4115,7 @@ component displayname="QueryBuilder" accessors="true" { var columns = []; if ( isStruct( arguments.values[ 1 ] ) ) { - columns = arguments.values[ 1 ].keyArray(); + columns = getGrammar().resolveInsertColumnNames( arguments.values ); } else { columns = arguments.values; } @@ -4244,10 +4190,10 @@ component displayname="QueryBuilder" accessors="true" { isNull( updates[ column.original ] ) ? javacast( "null", "" ) : updates[ column.original ], variables.grammar ), - "where" + "insert" ); } else { - addExpressionBindings( updates[ column.original ], "where" ); + addExpressionBindings( updates[ column.original ], "insert" ); } } ); } @@ -4264,7 +4210,7 @@ component displayname="QueryBuilder" accessors="true" { } if ( getUtils().isBuilder( arguments.deleteUnmatched ) ) { - addBindingsFromBuilder( arguments.deleteUnmatched ); + addBindings( arguments.deleteUnmatched.getBindings(), "insert" ); } columns.each( ( c ) => { @@ -4667,16 +4613,18 @@ component displayname="QueryBuilder" accessors="true" { */ public any function exists( struct options = {}, boolean toSQL = false ) { var existsSource = clone().setLimitValue( 1 ); - var existsQuery = newQuery() + var existsQuery = prepareInternalExecutionBuilder( newQuery() ) .clearFrom() .selectRaw( "CASE WHEN EXISTS (#getGrammar().compileSelect( existsSource )#) THEN 1 ELSE 0 END AS aggregate", existsSource.getBindings() ); - return arguments.toSQL ? existsQuery.toSQL() : existsQuery - .setReturnFormat( "query" ) - .get( options = arguments.options ) - .aggregate == 1; + if ( arguments.toSQL ) { + return existsQuery.toSQL(); + } + + var result = existsQuery.setReturnFormat( "query" ).get( options = arguments.options ); + return result.recordCount > 0 && result.aggregate == 1; } /** @@ -5023,6 +4971,9 @@ component displayname="QueryBuilder" accessors="true" { var q = runQuery( argumentCollection = arguments ); if ( isNull( q ) ) { + if ( variables.pretending ) { + return applyReturnFormat( queryNew( "" ) ); + } return; } @@ -5069,21 +5020,22 @@ component displayname="QueryBuilder" accessors="true" { * @return any */ private any function runQuery( required string sql, struct options = {}, string returnObject = "query" ) { - structAppend( arguments.options, getDefaultOptions(), false ); - guardAgainstReturnTypeOption( arguments.options ); + var queryOptions = structCopy( arguments.options ); + structAppend( queryOptions, getDefaultOptions(), false ); + guardAgainstReturnTypeOption( queryOptions ); var bindings = getBindings( except = getAggregate().isEmpty() ? [] : [ "select", "orderBy" ] ); var result = grammar.runQuery( sql = variables.sqlCommenter.appendSqlComments( sql = sql, - datasource = arguments.options.keyExists( "datasource" ) && !isNull( arguments.options.datasource ) ? arguments.options.datasource : javacast( + datasource = queryOptions.keyExists( "datasource" ) && !isNull( queryOptions.datasource ) ? queryOptions.datasource : javacast( "null", "" ), bindings = bindings ), bindings = bindings, - options = arguments.options, + options = queryOptions, returnObject = returnObject, pretend = variables.pretending, postProcessHook = function( data ) { @@ -5146,6 +5098,13 @@ component displayname="QueryBuilder" accessors="true" { return query; } + private QueryBuilder function prepareInternalExecutionBuilder( required QueryBuilder query ) { + if ( variables.pretending ) { + arguments.query.pretend(); + } + return arguments.query; + } + /** * Clones the current query into a new query instance. * diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index cb111f6c..606864e3 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -852,12 +852,13 @@ component singleton displayname="QueryUtils" accessors="true" { // Loop through the keys and compare them one at a time for ( var key in arguments.LeftStruct ) { - // key is null, null check the other side - if ( isNull( arguments.leftStruct[ key ] ) ) { - local.result = isNull( arguments.rightStruct[ key ] ); - if ( !local.result ) { + var leftIsNull = isNull( arguments.leftStruct[ key ] ); + var rightIsNull = isNull( arguments.rightStruct[ key ] ); + if ( leftIsNull || rightIsNull ) { + if ( leftIsNull != rightIsNull ) { return false; } + continue; } // Key is a structure, call structCompare() else if ( isStruct( arguments.LeftStruct[ key ] ) ) { @@ -909,6 +910,15 @@ component singleton displayname="QueryUtils" accessors="true" { // Loop through the elements and compare them one at a time for ( var i = 1; local.i lte arrayLen( LeftArray ); local.i = local.i + 1 ) { + var leftIsNull = isNull( arguments.LeftArray[ i ] ); + var rightIsNull = isNull( arguments.RightArray[ i ] ); + if ( leftIsNull || rightIsNull ) { + if ( leftIsNull != rightIsNull ) { + return false; + } + continue; + } + // elements is a structure, call structCompare() if ( isStruct( arguments.LeftArray[ i ] ) ) { local.result = structCompare( arguments.LeftArray[ i ], arguments.RightArray[ i ] ); diff --git a/models/SQLCommenter/SQLCommenter.cfc b/models/SQLCommenter/SQLCommenter.cfc index ce08a268..99d65308 100644 --- a/models/SQLCommenter/SQLCommenter.cfc +++ b/models/SQLCommenter/SQLCommenter.cfc @@ -49,11 +49,11 @@ component singleton { * @return { "sql": string, "comments": struct } */ public struct function parseCommentedSQL( required string sql ) { - var commentStartPosition = find( "/*", arguments.sql ) - 1; + var commentStartPosition = findSQLCommentPosition( arguments.sql ); return { - "sql": left( arguments.sql, commentStartPosition - 1 ), + "sql": trim( left( arguments.sql, commentStartPosition - 1 ) ), "comments": parseCommentString( - mid( arguments.sql, commentStartPosition + 1, len( arguments.sql ) - commentStartPosition ) + mid( arguments.sql, commentStartPosition, len( arguments.sql ) - commentStartPosition + 1 ) ) }; } @@ -87,7 +87,88 @@ component singleton { * @return True if the SQL already contains a comment. */ private boolean function containsSQLComment( required string sql ) { - return find( "--", arguments.sql ) > 0 || find( "/*", arguments.sql ) > 0; + return findSQLCommentPosition( arguments.sql ) > 0; + } + + /** + * Finds the first SQL comment token outside of quoted strings and identifiers. + * + * @sql The SQL to inspect. + * + * @return The one-based comment position, or zero when no comment is present. + */ + private numeric function findSQLCommentPosition( required string sql ) { + var position = 1; + var sqlLength = len( arguments.sql ); + var quote = ""; + var dollarQuoteDelimiter = ""; + + while ( position <= sqlLength ) { + var character = mid( arguments.sql, position, 1 ); + var nextCharacter = position < sqlLength ? mid( arguments.sql, position + 1, 1 ) : ""; + + if ( dollarQuoteDelimiter != "" ) { + if ( + mid( arguments.sql, position, len( dollarQuoteDelimiter ) ) == + dollarQuoteDelimiter + ) { + position += len( dollarQuoteDelimiter ); + dollarQuoteDelimiter = ""; + } else { + position++; + } + continue; + } + + if ( quote != "" ) { + if ( quote == "[" ) { + if ( character == "]" ) { + if ( nextCharacter == "]" ) { + position += 2; + continue; + } + quote = ""; + } + } else if ( character == chr( 92 ) ) { + position += 2; + continue; + } else if ( character == quote ) { + if ( nextCharacter == quote ) { + position += 2; + continue; + } + quote = ""; + } + position++; + continue; + } + + if ( character == "-" && nextCharacter == "-" ) { + return position; + } + if ( character == "/" && nextCharacter == "*" ) { + return position; + } + if ( character == "$" ) { + var dollarQuoteMatch = reFind( + "^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$", + mid( arguments.sql, position ), + 1, + true + ); + if ( dollarQuoteMatch.len[ 1 ] > 0 ) { + dollarQuoteDelimiter = mid( arguments.sql, position, dollarQuoteMatch.len[ 1 ] ); + position += len( dollarQuoteDelimiter ); + continue; + } + } + if ( character == "'" || character == """" || character == chr( 96 ) || character == "[" ) { + quote = character; + } + position++; + } + + return 0; } /** diff --git a/models/Schema/Blueprint.cfc b/models/Schema/Blueprint.cfc index 9ed08987..12667f4a 100644 --- a/models/Schema/Blueprint.cfc +++ b/models/Schema/Blueprint.cfc @@ -449,8 +449,11 @@ component accessors="true" { * @returns The created TableIndex instance. */ public TableIndex function default( required string column, string name ) { - param arguments.name = "df_#getUnqualifiedTableName()#_#column#"; - return createIndex( type = "default", columns = arguments.column, name = arguments.name ); + throw( + type = "UnsupportedOperation", + message = "Named default constraints are not supported.", + detail = "Set a column default by chaining `.default( value )` from the column definition." + ); } @@ -574,22 +577,29 @@ component accessors="true" { } public array function toSql() { + var originalCommands = variables.commands.map( ( command ) => command ); + var originalIndexes = variables.indexes.map( ( index ) => index ); var statements = []; - // we use a for loop here because we can potentially modify this array while looping over it. - for ( var i = 1; i <= variables.commands.len(); i++ ) { - var command = variables.commands[ i ]; - var result = invoke( - getGrammar(), - "compile#command.getType()#", - { blueprint: this, commandParameters: command.getParameters() } - ); - if ( isArray( result ) ) { - statements.append( result, true ); - } else if ( isSimpleValue( result ) && result != "" ) { - statements.append( result ); + try { + // we use a for loop here because we can potentially modify this array while looping over it. + for ( var i = 1; i <= variables.commands.len(); i++ ) { + var command = variables.commands[ i ]; + var result = invoke( + getGrammar(), + "compile#command.getType()#", + { blueprint: this, commandParameters: command.getParameters() } + ); + if ( isArray( result ) ) { + statements.append( result, true ); + } else if ( isSimpleValue( result ) && result != "" ) { + statements.append( result ); + } } + return statements; + } finally { + setCommands( originalCommands ); + setIndexes( originalIndexes ); } - return statements; } private array function arrayWrap( required any value ) { diff --git a/models/Schema/SchemaBuilder.cfc b/models/Schema/SchemaBuilder.cfc index 7535d876..a2646d3d 100644 --- a/models/Schema/SchemaBuilder.cfc +++ b/models/Schema/SchemaBuilder.cfc @@ -76,7 +76,7 @@ component accessors="true" { struct options = {}, boolean execute = true ) { - structAppend( arguments.options, variables.defaultOptions, false ); + arguments.options = mergeOptions( arguments.options ); var blueprint = new Blueprint( this, getGrammar(), @@ -112,7 +112,7 @@ component accessors="true" { struct options = {}, boolean execute = true ) { - structAppend( arguments.options, variables.defaultOptions, false ); + arguments.options = mergeOptions( arguments.options ); var query = new models.Query.QueryBuilder( getGrammar() ); arguments.callback( query ); @@ -152,7 +152,7 @@ component accessors="true" { struct options = {}, boolean execute = true ) { - structAppend( arguments.options, variables.defaultOptions, false ); + arguments.options = mergeOptions( arguments.options ); var query = new models.Query.QueryBuilder( getGrammar() ); arguments.callback( query ); @@ -192,7 +192,7 @@ component accessors="true" { struct options = {}, boolean execute = true ) { - structAppend( arguments.options, variables.defaultOptions, false ); + arguments.options = mergeOptions( arguments.options ); var query = new models.Query.QueryBuilder( getGrammar() ); arguments.callback( query ); @@ -227,7 +227,7 @@ component accessors="true" { } public Blueprint function dropView( required string view, struct options = {}, boolean execute = true ) { - structAppend( arguments.options, variables.defaultOptions, false ); + arguments.options = mergeOptions( arguments.options ); var blueprint = new Blueprint( this, getGrammar(), @@ -267,7 +267,7 @@ component accessors="true" { * @returns The blueprint instance */ public Blueprint function drop( required string table, struct options = {}, boolean execute = true ) { - structAppend( arguments.options, variables.defaultOptions, false ); + arguments.options = mergeOptions( arguments.options ); var blueprint = new Blueprint( this, getGrammar(), @@ -305,7 +305,7 @@ component accessors="true" { * @returns The blueprint instance */ public Blueprint function truncate( required string table, struct options = {}, boolean execute = true ) { - structAppend( arguments.options, variables.defaultOptions, false ); + arguments.options = mergeOptions( arguments.options ); var blueprint = new Blueprint( this, getGrammar(), @@ -343,7 +343,7 @@ component accessors="true" { * @returns The blueprint instance */ public Blueprint function dropIfExists( required string table, struct options = {}, boolean execute = true ) { - structAppend( arguments.options, variables.defaultOptions, false ); + arguments.options = mergeOptions( arguments.options ); var blueprint = new Blueprint( this, getGrammar(), @@ -389,7 +389,7 @@ component accessors="true" { struct options = {}, boolean execute = true ) { - structAppend( arguments.options, variables.defaultOptions, false ); + arguments.options = mergeOptions( arguments.options ); var blueprint = new Blueprint( this, getGrammar(), @@ -433,7 +433,7 @@ component accessors="true" { struct options = {}, boolean execute = true ) { - structAppend( arguments.options, variables.defaultOptions, false ); + arguments.options = mergeOptions( arguments.options ); var blueprint = new Blueprint( this, getGrammar(), @@ -478,7 +478,7 @@ component accessors="true" { struct options = {}, boolean execute = true ) { - structAppend( arguments.options, variables.defaultOptions, false ); + arguments.options = mergeOptions( arguments.options ); return rename( argumentCollection = arguments ); } @@ -498,7 +498,7 @@ component accessors="true" { struct options = {}, boolean execute = true ) { - structAppend( arguments.options, variables.defaultOptions, false ); + arguments.options = mergeOptions( arguments.options ); if ( listLen( arguments.name, "." ) > 1 ) { arguments.schema = listDeleteAt( arguments.name, listLen( arguments.name, "." ), "." ); } @@ -541,7 +541,7 @@ component accessors="true" { struct options = {}, boolean execute = true ) { - structAppend( arguments.options, variables.defaultOptions, false ); + arguments.options = mergeOptions( arguments.options ); if ( listLen( arguments.table, "." ) > 1 ) { arguments.schema = listDeleteAt( arguments.table, listLen( arguments.table, "." ), "." ); } @@ -582,7 +582,10 @@ component accessors="true" { boolean execute = true, string schema = variables.defaultSchema ) { - structAppend( arguments.options, variables.defaultOptions, false ); + arguments.options = mergeOptions( arguments.options ); + if ( variables.pretending ) { + return []; + } var statements = getGrammar().compileDropAllObjects( arguments.options, arguments.schema, this ); if ( arguments.execute ) { statements.each( function( statement ) { @@ -610,7 +613,7 @@ component accessors="true" { * @returns The executed sql statement. */ public string function enableForeignKeyConstraints( struct options = {}, boolean execute = true ) { - structAppend( arguments.options, variables.defaultOptions, false ); + arguments.options = mergeOptions( arguments.options ); var statement = getGrammar().compileEnableForeignKeyConstraints( arguments.options ); if ( arguments.execute ) { getGrammar().runQuery( @@ -636,7 +639,7 @@ component accessors="true" { * @returns The executed sql statement. */ public string function disableForeignKeyConstraints( struct options = {}, boolean execute = true ) { - structAppend( arguments.options, variables.defaultOptions, false ); + arguments.options = mergeOptions( arguments.options ); var statement = getGrammar().compileDisableForeignKeyConstraints( arguments.options ); if ( arguments.execute ) { getGrammar().runQuery( @@ -670,6 +673,15 @@ component accessors="true" { return variables.shouldWrapValues; } + /** + * Merges per-operation options with schema defaults without mutating the caller's struct. + */ + private struct function mergeOptions( required struct options ) { + var mergedOptions = structCopy( arguments.options ); + structAppend( mergedOptions, variables.defaultOptions, false ); + return mergedOptions; + } + /** * Prefixes an unqualified schema object with the configured default schema. * Explicitly qualified object names are returned unchanged. diff --git a/tests/specs/Query/Abstract/BuilderAliasSpec.cfc b/tests/specs/Query/Abstract/BuilderAliasSpec.cfc index 154276ba..8e5146cb 100644 --- a/tests/specs/Query/Abstract/BuilderAliasSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderAliasSpec.cfc @@ -106,6 +106,70 @@ component extends="testbox.system.BaseSpec" { expect( qb.toSQL() ).toBe( "SELECT * FROM ""users"" AS ""u"" WHERE ""u"".""id"" NOT IN (?, ?, ?)" ); } ); + it( "renames the columns used in bulk and subquery where in clauses", () => { + var bulkQuery = new qb.models.Query.QueryBuilder(); + bulkQuery + .from( "users" ) + .whereInBulk( "users.id", [ 1, 2, 3 ] ) + .withAlias( "u" ); + expect( bulkQuery.getWheres()[ 1 ].column.value ).toBe( "u.id" ); + + var inSubQuery = new qb.models.Query.QueryBuilder(); + inSubQuery + .from( "users" ) + .whereIn( "users.id", function( query ) { + query.from( "members" ).select( "members.userId" ); + } ) + .withAlias( "u" ); + expect( inSubQuery.getWheres()[ 1 ].column.value ).toBe( "u.id" ); + + var notInSubQuery = new qb.models.Query.QueryBuilder(); + notInSubQuery + .from( "users" ) + .whereNotIn( "users.id", function( query ) { + query.from( "members" ).select( "members.userId" ); + } ) + .withAlias( "u" ); + expect( notInSubQuery.getWheres()[ 1 ].column.value ).toBe( "u.id" ); + } ); + + it( "renames aliases inside JSON path columns for every supported where shape", () => { + var queries = []; + queries.append( + new qb.models.Query.QueryBuilder().from( "users" ).whereIn( "users.profile->id", [ 1 ] ) + ); + queries.append( + new qb.models.Query.QueryBuilder().from( "users" ).whereNull( "users.profile->id" ) + ); + queries.append( + new qb.models.Query.QueryBuilder().from( "users" ).whereBetween( "users.profile->id", 1, 2 ) + ); + + queries.each( function( query ) { + arguments.query.withAlias( "u" ); + expect( arguments.query.getWheres()[ 1 ].column.value.column ).toBe( "u.profile" ); + } ); + + var columnQuery = new qb.models.Query.QueryBuilder() + .from( "users" ) + .whereColumn( "users.profile->id", "users.settings->profileId" ) + .withAlias( "u" ); + expect( columnQuery.getWheres()[ 1 ].first.value.column ).toBe( "u.profile" ); + expect( columnQuery.getWheres()[ 1 ].second.value.column ).toBe( "u.settings" ); + + var subQuery = new qb.models.Query.QueryBuilder() + .from( "users" ) + .where( + "users.profile->id", + "=", + function( query ) { + query.from( "members" ).select( "members.userId" ); + } + ) + .withAlias( "u" ); + expect( subQuery.getWheres()[ 1 ].column.value.column ).toBe( "u.profile" ); + } ); + it( "renames the columns used in where exists clauses", () => { var qb = new qb.models.Query.QueryBuilder(); qb.from( "users" ) @@ -248,6 +312,33 @@ component extends="testbox.system.BaseSpec" { qb.withAlias( "u" ); expect( qb.toSQL() ).toBe( "SELECT * FROM ""users"" AS ""u"" ORDER BY ""u"".""lastLoginDate"" DESC" ); } ); + + it( "supports random and subquery orders while renaming aliases", () => { + var randomQuery = new qb.models.Query.QueryBuilder() + .from( "users" ) + .orderByRandom() + .withAlias( "u" ); + expect( randomQuery.toSQL() ).toBe( "SELECT * FROM ""users"" AS ""u"" ORDER BY RANDOM()" ); + + var subQuery = new qb.models.Query.QueryBuilder() + .from( "users" ) + .orderBy( function( query ) { + query.from( "logins" ).selectRaw( "MAX(logins.createdDate)" ); + } ) + .withAlias( "u" ); + expect( subQuery.toSQL() ).toBe( + "SELECT * FROM ""users"" AS ""u"" ORDER BY (SELECT MAX(logins.createdDate) FROM ""logins"") ASC" + ); + } ); + } ); + + it( "does not rewrite aliases that only share a prefix", () => { + var qb = new qb.models.Query.QueryBuilder() + .from( "users" ) + .select( "usersArchive.id" ) + .withAlias( "u" ); + + expect( qb.toSQL() ).toBe( "SELECT ""usersArchive"".""id"" FROM ""users"" AS ""u""" ); } ); } ); } diff --git a/tests/specs/Query/Abstract/BuilderSelectSpec.cfc b/tests/specs/Query/Abstract/BuilderSelectSpec.cfc index 9dfa85fd..1b6421fe 100644 --- a/tests/specs/Query/Abstract/BuilderSelectSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderSelectSpec.cfc @@ -224,6 +224,15 @@ component extends="testbox.system.BaseSpec" { } ); } ); + describe( "selectRaw()", function() { + it( "applies a flat binding list once across multiple expressions", function() { + query.selectRaw( [ "? AS firstValue", "? AS secondValue" ], [ 1, 2 ] ).from( "users" ); + + expect( query.toSql() ).toBe( "SELECT ? AS firstValue, ? AS secondValue FROM ""users""" ); + expect( query.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 1, 2 ] ); + } ); + } ); + describe( "addSelect()", function() { beforeEach( function() { query.select( "::some_column::" ); diff --git a/tests/specs/Query/Abstract/PretendSpec.cfc b/tests/specs/Query/Abstract/PretendSpec.cfc index b5f4d0d1..c547e5c6 100644 --- a/tests/specs/Query/Abstract/PretendSpec.cfc +++ b/tests/specs/Query/Abstract/PretendSpec.cfc @@ -39,6 +39,39 @@ component extends="testbox.system.BaseSpec" { } ); } ).notToThrow(); } ); + + it( "keeps internally-created execution builders in pretend mode", function() { + expect( function() { + new qb.models.Query.QueryBuilder() + .from( "users" ) + .pretend() + .exists(); + } ).notToThrow(); + + expect( function() { + new qb.models.Query.QueryBuilder() + .from( "users" ) + .pretend() + .insertBulk( [ { "id": 1 } ] ); + } ).notToThrow(); + + expect( function() { + new qb.models.Query.QueryBuilder() + .from( "users" ) + .select( "status" ) + .groupBy( "status" ) + .pretend() + .paginate(); + } ).notToThrow(); + } ); + + it( "does not query database catalogs when pretending to drop all objects", function() { + var schema = new qb.models.Schema.SchemaBuilder( grammar = new qb.models.Grammars.PostgresGrammar() ); + + expect( function() { + schema.pretend().dropAllObjects(); + } ).notToThrow(); + } ); } ); } diff --git a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc index 9148217a..dc12779b 100644 --- a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc +++ b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc @@ -1672,6 +1672,18 @@ component extends="testbox.system.BaseSpec" { expect( builder.getGrammar().$callLog().runQuery[ 1 ].options ).toBe( {} ); } ); + it( "does not mutate per-query options while preparing them for execution", function() { + var options = { "returntype": "array", "columnkey": "id", "timeout": 5 }; + var originalOptions = duplicate( options ); + + new qb.models.Query.QueryBuilder( new qb.models.Grammars.BaseGrammar() ) + .pretend() + .from( "users" ) + .get( options = options ); + + expect( options ).toBe( originalOptions ); + } ); + it( "can strip native queryExecute returntype options from default options without mutating them", function() { var builder = getBuilder(); builder.mergeDefaultOptions( { "returntype": "array", "columnkey": "id", "columnKey": "id" } ); @@ -1719,7 +1731,89 @@ component extends="testbox.system.BaseSpec" { } ); } ); + describe( "write input immutability", function() { + it( "does not merge configured update values into the caller's struct", function() { + var values = { "name": "Jane" }; + var originalValues = duplicate( values ); + var builder = new qb.models.Query.QueryBuilder( new qb.models.Grammars.BaseGrammar() ) + .from( "users" ) + .addUpdate( { "active": true } ); + + builder.update( values = values, toSql = true ); + + expect( values ).toBe( originalValues ); + } ); + } ); + describe( "bulk inserts", function() { + it( "includes columns introduced by later insert rows", function() { + var builder = getBuilder().from( "users" ); + + var sql = builder.insert( + values = [ { "id": 1 }, { "email": "two@example.com", "id": 2 } ], + toSql = true + ); + + expect( sql ).toBe( "INSERT INTO ""users"" (""email"", ""id"") VALUES (?, ?), (?, ?)" ); + expect( builder.getBindings() ).toHaveLength( 4 ); + expect( builder.getBindings()[ 1 ].null ).toBeTrue(); + expect( builder.getBindings()[ 2 ].value ).toBe( 1 ); + expect( builder.getBindings()[ 3 ].value ).toBe( "two@example.com" ); + expect( builder.getBindings()[ 4 ].value ).toBe( 2 ); + } ); + + it( "includes columns introduced by later upsert rows", function() { + var builder = new qb.models.Query.QueryBuilder( new qb.models.Grammars.PostgresGrammar() ).from( "users" ); + + var sql = builder.upsert( + values = [ { "id": 1 }, { "email": "two@example.com", "id": 2 } ], + target = "id", + update = [ "email" ], + toSql = true + ); + + expect( sql ).toBe( + "INSERT INTO ""users"" (""email"", ""id"") VALUES (?, ?), (?, ?) ON CONFLICT (""id"") DO UPDATE SET ""email"" = EXCLUDED.""email""" + ); + expect( builder.getBindings() ).toHaveLength( 4 ); + expect( builder.getBindings()[ 1 ].null ).toBeTrue(); + expect( builder.getBindings()[ 2 ].value ).toBe( 1 ); + expect( builder.getBindings()[ 3 ].value ).toBe( "two@example.com" ); + expect( builder.getBindings()[ 4 ].value ).toBe( 2 ); + } ); + + it( "keeps all source bindings before explicit upsert update bindings", function() { + var grammar = new qb.models.Grammars.PostgresGrammar(); + var source = new qb.models.Query.QueryBuilder( grammar ) + .selectRaw( "? AS id", [ 1 ] ) + .unionAll( ( query ) => query.selectRaw( "? AS id", [ 2 ] ) ); + var builder = new qb.models.Query.QueryBuilder( grammar ).from( "users" ); + + builder.upsert( + values = [ "id" ], + target = [ "id" ], + update = { "id": 3 }, + source = source, + toSql = true + ); + + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 1, 2, 3 ] ); + } ); + + it( "includes columns introduced by later native bulk insert rows", function() { + var grammar = new qb.models.Grammars.SqlServerGrammar(); + var builder = new qb.models.Query.QueryBuilder( grammar ).from( "users" ); + + var prepared = grammar.prepareBulkInsert( + builder, + [ { "id": 1 }, { "email": "two@example.com", "id": 2 } ], + {} + ); + + expect( prepared.columns.map( ( column ) => column.original ) ).toBe( [ "email", "id" ] ); + expect( deserializeJSON( prepared.binding.value ) ).toBe( [ { "email": javacast( "null", "" ), "id": 1 }, { "email": "two@example.com", "id": 2 } ] ); + } ); + it( "does not include unrelated builder bindings in native bulk inserts", function() { var grammar = getMockBox().createMock( "qb.models.Grammars.SqlServerGrammar" ).init(); grammar.$( "runQuery", {} ); diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index 37cfc946..4e6daf04 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -554,6 +554,30 @@ component extends="testbox.system.BaseSpec" { } ); } ); + describe( "null-aware comparisons", function() { + it( "compares null array elements without throwing", function() { + var left = [ javacast( "null", "" ) ]; + var right = [ javacast( "null", "" ) ]; + + expect( utils.arrayCompare( left, right ) ).toBeTrue(); + expect( utils.arrayCompare( left, [ "value" ] ) ).toBeFalse(); + expect( utils.arrayCompare( [ "value" ], right ) ).toBeFalse(); + } ); + + it( "compares null struct values symmetrically when full null support is enabled", function() { + var fullNull = createObject( "java", "java.lang.System" ).getEnv( "FULL_NULL" ); + if ( isNull( fullNull ) || !fullNull ) { + return; + } + + expect( + utils.structCompare( { "value": javacast( "null", "" ) }, { "value": javacast( "null", "" ) } ) + ).toBeTrue(); + expect( utils.structCompare( { "value": javacast( "null", "" ) }, { "value": "present" } ) ).toBeFalse(); + expect( utils.structCompare( { "value": "present" }, { "value": javacast( "null", "" ) } ) ).toBeFalse(); + } ); + } ); + describe( "isEqualTo()", function() { it( "compares equivalent common table expressions", function() { var first = new qb.models.Query.QueryBuilder().with( "active_users", function( q ) { diff --git a/tests/specs/Query/ShouldWrapValuesSpec.cfc b/tests/specs/Query/ShouldWrapValuesSpec.cfc index 0c100592..eccfcdf6 100644 --- a/tests/specs/Query/ShouldWrapValuesSpec.cfc +++ b/tests/specs/Query/ShouldWrapValuesSpec.cfc @@ -107,7 +107,11 @@ component extends="testbox.system.BaseSpec" { expect( new qb.models.Grammars.MySQLGrammar().wrapValue( "odd#chr( 96 )#name" ) ).toBe( "#chr( 96 )#odd#chr( 96 )##chr( 96 )#name#chr( 96 )#" ); + expect( new qb.models.Grammars.MySQLGrammar().wrapValue( "odd""name" ) ).toBe( + "#chr( 96 )#odd""name#chr( 96 )#" + ); expect( new qb.models.Grammars.SqlServerGrammar().wrapValue( "odd]name" ) ).toBe( "[odd]]name]" ); + expect( new qb.models.Grammars.SqlServerGrammar().wrapValue( "odd""name" ) ).toBe( "[odd""name]" ); } ); it( "escapes backslashes in JSON path segments", function() { diff --git a/tests/specs/SQLCommenterSpec.cfc b/tests/specs/SQLCommenterSpec.cfc index 09d0aa33..1e2e6c41 100644 --- a/tests/specs/SQLCommenterSpec.cfc +++ b/tests/specs/SQLCommenterSpec.cfc @@ -14,6 +14,28 @@ component extends="testbox.system.BaseSpec" { ).toBeTrue(); expect( variables.sqlCommenter.containsSQLCommentPublic( "SELECT * FROM users" ) ).toBeFalse(); } ); + + it( "ignores comment tokens inside SQL string and identifier literals", function() { + makePublic( variables.sqlCommenter, "containsSQLComment", "containsSQLCommentPublic" ); + + expect( variables.sqlCommenter.containsSQLCommentPublic( "SELECT '--' AS marker" ) ).toBeFalse(); + expect( variables.sqlCommenter.containsSQLCommentPublic( "SELECT '/*' AS marker" ) ).toBeFalse(); + expect( variables.sqlCommenter.containsSQLCommentPublic( "SELECT ""--"" FROM users" ) ).toBeFalse(); + expect( + variables.sqlCommenter.containsSQLCommentPublic( "SELECT '--' AS marker /* actual comment */" ) + ).toBeTrue(); + } ); + + it( "ignores comment tokens inside PostgreSQL dollar-quoted literals", function() { + makePublic( variables.sqlCommenter, "containsSQLComment", "containsSQLCommentPublic" ); + + expect( variables.sqlCommenter.containsSQLCommentPublic( "SELECT $$-- not a comment$$" ) ).toBeFalse(); + expect( + variables.sqlCommenter.containsSQLCommentPublic( + "SELECT $payload$/* not a comment */$payload$" + ) + ).toBeFalse(); + } ); } ); describe( "serializeValue", () => { @@ -51,6 +73,24 @@ component extends="testbox.system.BaseSpec" { "SELECT * FROM foo /*action='index',dbDriver='mysql-connector-java-8.0.25%20%28Revision%3A%2008be9e9b4cba6aa115f9b27b215887af40b159e0%29',event='Main.index',framework='coldbox-6.0.0',handler='Main',route='%2F'*/" ); } ); + + it( "appends comments when comment tokens only appear inside literals", function() { + expect( + variables.sqlCommenter.appendCommentsToSQL( + sql = "SELECT '--' AS marker", + comments = { "framework": "qb" } + ) + ).toBeWithCase( "SELECT '--' AS marker /*framework='qb'*/" ); + } ); + + it( "appends comments when comment tokens only appear inside dollar-quoted literals", function() { + expect( + variables.sqlCommenter.appendCommentsToSQL( + sql = "SELECT $payload$-- not a comment$payload$ AS marker", + comments = { "framework": "qb" } + ) + ).toBeWithCase( "SELECT $payload$-- not a comment$payload$ AS marker /*framework='qb'*/" ); + } ); } ); describe( "parseCommentedSQL", () => { @@ -67,6 +107,15 @@ component extends="testbox.system.BaseSpec" { "dbDriver": "mysql-connector-java-8.0.25 (Revision: 08be9e9b4cba6aa115f9b27b215887af40b159e0)" } ); } ); + + it( "skips comment tokens inside literals when parsing appended comments", function() { + var sqlAndComments = variables.sqlCommenter.parseCommentedSQL( + "SELECT '/*' AS marker /*framework='qb'*/" + ); + + expect( sqlAndComments.sql ).toBeWithCase( "SELECT '/*' AS marker" ); + expect( sqlAndComments.comments ).toBe( { "framework": "qb" } ); + } ); } ); } ); } diff --git a/tests/specs/Schema/BlueprintLifecycleSpec.cfc b/tests/specs/Schema/BlueprintLifecycleSpec.cfc index 6abffef4..d279d499 100644 --- a/tests/specs/Schema/BlueprintLifecycleSpec.cfc +++ b/tests/specs/Schema/BlueprintLifecycleSpec.cfc @@ -81,6 +81,65 @@ component extends="testbox.system.BaseSpec" { expect( index.onDelete( " set null " ).getOnDeleteAction() ).toBe( "SET NULL" ); expect( index.onUpdate( "cascade" ).getOnUpdateAction() ).toBe( "CASCADE" ); } ); + + it( "rejects unsupported named default constraints when declared", function() { + var blueprint = newBlueprint( new qb.models.Grammars.BaseGrammar() ); + + expect( function() { + blueprint.default( "status" ); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + + it( "does not mutate per-operation schema options", function() { + var options = { "timeout": 5 }; + var originalOptions = duplicate( options ); + var schema = new qb.models.Schema.SchemaBuilder( + grammar = new qb.models.Grammars.BaseGrammar(), + defaultOptions = { "datasource": "main" } + ).pretend(); + + schema.create( "users", ( table ) => table.integer( "id" ), options ); + + expect( options ).toBe( originalOptions ); + } ); + + it( "does not retain PostgreSQL comment commands generated during compilation", function() { + var grammar = new qb.models.Grammars.PostgresGrammar(); + var schema = new qb.models.Schema.SchemaBuilder( grammar ); + var blueprint = schema.create( + "users", + function( table ) { + table.string( "name" ).comment( "Display name" ); + }, + {}, + false + ); + + var firstCompilation = blueprint.toSql(); + var secondCompilation = blueprint.toSql(); + + expect( secondCompilation ).toBe( firstCompilation ); + expect( blueprint.getCommands() ).toHaveLength( 1 ); + } ); + + it( "does not retain Oracle sequence and trigger commands generated during compilation", function() { + var grammar = new qb.models.Grammars.OracleGrammar(); + var schema = new qb.models.Schema.SchemaBuilder( grammar ); + var blueprint = schema.create( + "users", + function( table ) { + table.increments( "id" ); + }, + {}, + false + ); + + var firstCompilation = blueprint.toSql(); + var secondCompilation = blueprint.toSql(); + + expect( secondCompilation ).toBe( firstCompilation ); + expect( blueprint.getCommands() ).toHaveLength( 1 ); + } ); } ); } From 9ccbf9e4090c93d1ffa5b440622a39bc08139101 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 15 Aug 2026 15:39:41 -0600 Subject: [PATCH 025/119] fix: preserve nested SQL compilation semantics --- models/Grammars/BaseGrammar.cfc | 37 ++++++ models/Grammars/DerbyGrammar.cfc | 6 + models/Grammars/MySQLGrammar.cfc | 12 ++ models/Grammars/OracleGrammar.cfc | 6 + models/Grammars/PostgresGrammar.cfc | 29 ++++- models/Grammars/SQLiteGrammar.cfc | 29 ++++- models/Grammars/SqlServerGrammar.cfc | 44 ++++++- models/Query/QueryBuilder.cfc | 119 ++++++++++++++---- models/Query/QueryUtils.cfc | 4 +- models/Query/ReturnFormatterRegistry.cfc | 15 +-- tests/resources/AbstractQueryBuilderSpec.cfc | 29 +++++ .../specs/Query/Abstract/BuilderAliasSpec.cfc | 89 +++++++++++++ .../Query/Abstract/BuilderSelectSpec.cfc | 12 ++ tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 22 ++++ .../Abstract/ReturnFormatterRegistrySpec.cfc | 20 +++ tests/specs/Query/DerbyQueryBuilderSpec.cfc | 18 +++ tests/specs/Query/MySQLQueryBuilderSpec.cfc | 35 ++++++ tests/specs/Query/OracleQueryBuilderSpec.cfc | 18 +++ .../specs/Query/PostgresQueryBuilderSpec.cfc | 16 +++ tests/specs/Query/SQLiteQueryBuilderSpec.cfc | 29 +++++ .../specs/Query/SqlServerQueryBuilderSpec.cfc | 34 +++++ 21 files changed, 580 insertions(+), 43 deletions(-) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 8165c937..231ae82f 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -91,6 +91,43 @@ component displayname="Grammar" accessors="true" singleton { return this; } + /** + * Returns the binding groups in the order they appear in a SELECT statement. + */ + public array function getSelectBindingOrder( required QueryBuilder query ) { + return [ + "commonTables", + "update", + "insert", + "aggregate", + "select", + "from", + "join", + "where", + "groupBy", + "having", + "union", + "orderBy" + ]; + } + + /** + * Returns the binding groups in the order they appear in an UPDATE statement. + */ + public array function getUpdateBindingOrder( required QueryBuilder query ) { + return [ + "commonTables", + "from", + "join", + "update", + "where", + "groupBy", + "having", + "orderBy", + "union" + ]; + } + /** * Runs a query through `queryExecute`. * This function exists so that platform-specific grammars can override it if needed. diff --git a/models/Grammars/DerbyGrammar.cfc b/models/Grammars/DerbyGrammar.cfc index 1d185163..832d5bc2 100644 --- a/models/Grammars/DerbyGrammar.cfc +++ b/models/Grammars/DerbyGrammar.cfc @@ -191,6 +191,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { required array columns, required struct updateMap ) { + if ( !query.getCommonTables().isEmpty() ) { + throw( + type = "UnsupportedOperation", + message = "This grammar does not support UPDATE statements with Common Table Expressions." + ); + } if ( !query.getJoins().isEmpty() ) { throw( type = "UnsupportedOperation", diff --git a/models/Grammars/MySQLGrammar.cfc b/models/Grammars/MySQLGrammar.cfc index b59bfea5..d31adc5c 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -1,5 +1,17 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { + public string function compileUpdate( + required QueryBuilder query, + required array columns, + required struct updateMap + ) { + return trim( + compileCommonTables( arguments.query, arguments.query.getCommonTables() ) & " " & super.compileUpdate( + argumentCollection = arguments + ) + ); + } + public string function compileWhereInBulkValues( required string sqlType ) { return "SELECT `value` FROM JSON_TABLE(?, '$[*]' COLUMNS(`value` #arguments.sqlType# PATH '$')) AS `qb_bulk_values`"; } diff --git a/models/Grammars/OracleGrammar.cfc b/models/Grammars/OracleGrammar.cfc index 7a2c800f..874a55f0 100644 --- a/models/Grammars/OracleGrammar.cfc +++ b/models/Grammars/OracleGrammar.cfc @@ -248,6 +248,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { required array columns, required struct updateMap ) { + if ( !query.getCommonTables().isEmpty() ) { + throw( + type = "UnsupportedOperation", + message = "This grammar does not support UPDATE statements with Common Table Expressions." + ); + } if ( !query.getJoins().isEmpty() ) { throw( type = "UnsupportedOperation", diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index 545d338c..924fe54a 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -226,7 +226,9 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; if ( joins.isEmpty() ) { - return updateStatement & returningClause; + return trim( + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & returningClause + ); } var firstJoin = joins[ 1 ]; @@ -239,12 +241,19 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { updateStatement &= " FROM #wrapTable( firstJoin.getTable() )# #compileWheres( arguments.query, firstJoin.getWheres() )#"; if ( joins.len() <= 1 ) { - return trim( updateStatement & " " & whereStatement & returningClause ); + return trim( + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & " " & whereStatement & returningClause + ); } var restJoins = joins.len() <= 1 ? [] : joins.slice( 2 ); - return trim( "#updateStatement# #compileJoins( arguments.query, restJoins )# #whereStatement##returningClause#" ); + return trim( + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & " " & compileJoins( + arguments.query, + restJoins + ) & " " & whereStatement & returningClause + ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); @@ -252,6 +261,20 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } } + public array function getUpdateBindingOrder( required QueryBuilder query ) { + return [ + "commonTables", + "from", + "update", + "join", + "where", + "groupBy", + "having", + "orderBy", + "union" + ]; + } + /** * Compile a Builder's query into a delete string. * diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index 0c4ab211..6f158a25 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -209,7 +209,9 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; if ( joins.isEmpty() ) { - return updateStatement & returningClause; + return trim( + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & returningClause + ); } var firstJoin = joins[ 1 ]; @@ -222,12 +224,19 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { updateStatement &= " FROM #wrapTable( firstJoin.getTable() )# #compileWheres( arguments.query, firstJoin.getWheres() )#"; if ( joins.len() <= 1 ) { - return trim( updateStatement & " " & whereStatement & returningClause ); + return trim( + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & " " & whereStatement & returningClause + ); } var restJoins = joins.len() <= 1 ? [] : joins.slice( 2 ); - return trim( "#updateStatement# #compileJoins( arguments.query, restJoins )# #whereStatement##returningClause#" ); + return trim( + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & " " & compileJoins( + arguments.query, + restJoins + ) & " " & whereStatement & returningClause + ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); @@ -235,6 +244,20 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } } + public array function getUpdateBindingOrder( required QueryBuilder query ) { + return [ + "commonTables", + "from", + "update", + "join", + "where", + "groupBy", + "having", + "orderBy", + "union" + ]; + } + /** * Compile a Builder's query into a delete string. * diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index 29579c55..8fc51b24 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -206,6 +206,41 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { return arguments.query.getUnions().some( ( union ) => union.query.getOrders().len() ); } + public array function getSelectBindingOrder( required QueryBuilder query ) { + if ( !shouldCompileOrderedUnionBranches( arguments.query ) ) { + return super.getSelectBindingOrder( arguments.query ); + } + + return [ + "commonTables", + "update", + "insert", + "aggregate", + "select", + "from", + "join", + "where", + "groupBy", + "having", + "orderBy", + "union" + ]; + } + + public array function getUpdateBindingOrder( required QueryBuilder query ) { + return [ + "commonTables", + "from", + "update", + "join", + "where", + "groupBy", + "having", + "orderBy", + "union" + ]; + } + private boolean function isOrderedLimitedQuery( required QueryBuilder query ) { return arguments.query.getOrders().len() && isLimitedQuery( arguments.query ); } @@ -567,11 +602,16 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { var returningClause = returningColumns != "" ? " OUTPUT #returningColumns#" : ""; if ( arguments.query.getJoins().isEmpty() ) { - return trim( updateStatement & returningClause & " " & compileWheres( query, query.getWheres() ) ); + return trim( + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & returningClause & " " & compileWheres( + query, + query.getWheres() + ) + ); } return trim( - updateStatement & returningClause & " FROM #wrapTable( query.getTableName() )# " & compileJoins( + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & returningClause & " FROM #wrapTable( query.getTableName() )# " & compileJoins( arguments.query, arguments.query.getJoins() ) & " " & compileWheres( query, query.getWheres() ) diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index c9813673..6d6f56fc 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -119,6 +119,11 @@ component displayname="QueryBuilder" accessors="true" { */ property name="collectQueryLog" type="boolean"; + /** + * Tracks whether this builder contains SQL compiled by its current grammar. + */ + property name="grammarCompilationLocked" type="boolean"; + /******************** Query Properties ********************/ /** @@ -437,6 +442,7 @@ component displayname="QueryBuilder" accessors="true" { variables.pretending = false; variables.queryLog = []; variables.shouldWrapValues = javacast( "null", "" ); + variables.grammarCompilationLocked = false; } /** @@ -884,9 +890,41 @@ component displayname="QueryBuilder" accessors="true" { renameAliasesInGroups( oldAlias, newAlias ); renameAliasesInHavings( oldAlias, newAlias ); renameAliasesInOrders( oldAlias, newAlias ); + renameAliasesInUnions( oldAlias, newAlias ); + renameAliasesInCommonTables( oldAlias, newAlias ); return; } + private void function renameAliasesInUnions( required string oldAlias, required string newAlias ) { + for ( var union in variables.unions ) { + renameAliasesInNestedQuery( union.query, arguments.oldAlias, arguments.newAlias ); + } + } + + private void function renameAliasesInCommonTables( required string oldAlias, required string newAlias ) { + for ( var commonTable in variables.commonTables ) { + renameAliasesInNestedQuery( commonTable.query, arguments.oldAlias, arguments.newAlias ); + } + } + + private void function renameAliasesInNestedQuery( + required QueryBuilder query, + required string oldAlias, + required string newAlias + ) { + var nestedAlias = arguments.query.getAlias(); + var nestedTable = arguments.query.getTableName(); + var shadowsAlias = compareNoCase( nestedAlias, arguments.oldAlias ) == 0; + + if ( !shadowsAlias && nestedAlias == "" && isSimpleValue( nestedTable ) ) { + shadowsAlias = compareNoCase( listLast( nestedTable, "." ), arguments.oldAlias ) == 0; + } + + if ( !shadowsAlias ) { + arguments.query.renameAliases( arguments.oldAlias, arguments.newAlias ); + } + } + private void function renameAliasesInColumns( required string oldAlias, required string newAlias ) { for ( var i = 1; i <= variables.columns.len(); i++ ) { var column = variables.columns[ i ]; @@ -1115,6 +1153,12 @@ component displayname="QueryBuilder" accessors="true" { required string newAlias ) { renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); + if ( getUtils().isBuilder( arguments.where.start ) ) { + renameAliasesInNestedQuery( arguments.where.start, arguments.oldAlias, arguments.newAlias ); + } + if ( getUtils().isBuilder( arguments.where.end ) ) { + renameAliasesInNestedQuery( arguments.where.end, arguments.oldAlias, arguments.newAlias ); + } } private void function renameAliasInWhereNotBetween( @@ -1123,6 +1167,12 @@ component displayname="QueryBuilder" accessors="true" { required string newAlias ) { renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); + if ( getUtils().isBuilder( arguments.where.start ) ) { + renameAliasesInNestedQuery( arguments.where.start, arguments.oldAlias, arguments.newAlias ); + } + if ( getUtils().isBuilder( arguments.where.end ) ) { + renameAliasesInNestedQuery( arguments.where.end, arguments.oldAlias, arguments.newAlias ); + } } private string function swapAlias( required string column, required string oldAlias, required string newAlias ) { @@ -1222,6 +1272,7 @@ component displayname="QueryBuilder" accessors="true" { // generate the derived table SQL this.fromRaw( getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ) ); + variables.grammarCompilationLocked = true; addBindings( arguments.input.getBindings(), "from" ); return this; } @@ -1713,6 +1764,7 @@ component displayname="QueryBuilder" accessors="true" { getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ), arguments.input.getBindings() ); + variables.grammarCompilationLocked = true; // remove the non-standard arguments structDelete( arguments, "input" ); @@ -1767,6 +1819,7 @@ component displayname="QueryBuilder" accessors="true" { addBindings( tableLikeSource.getBindings(), "join" ); variables.joins.append( join ); + variables.grammarCompilationLocked = true; return this; } @@ -1861,6 +1914,7 @@ component displayname="QueryBuilder" accessors="true" { getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ), arguments.input.getBindings() ); + variables.grammarCompilationLocked = true; return crossJoin( table ); } @@ -4019,7 +4073,12 @@ component displayname="QueryBuilder" accessors="true" { return sql; } - return runQuery( sql, arguments.options, "result" ); + return runQuery( + sql, + arguments.options, + "result", + getBindings( order = getGrammar().getUpdateBindingOrder( this ) ) + ); } /** @@ -4285,26 +4344,13 @@ component displayname="QueryBuilder" accessors="true" { * * @return array of bindings */ - public array function getBindings( array except = [] ) { - var bindingOrder = arrayFilter( - [ - "commonTables", - "update", - "insert", - "aggregate", - "select", - "from", - "join", - "where", - "groupBy", - "having", - "orderBy", - "union" - ], - function( type ) { - return !arrayContainsNoCase( except, type ); - } - ); + public array function getBindings( array except = [], array order = [] ) { + if ( arguments.order.isEmpty() ) { + arguments.order = getGrammar().getSelectBindingOrder( this ); + } + var bindingOrder = arrayFilter( arguments.order, function( type ) { + return !arrayContainsNoCase( except, type ); + } ); var flatBindings = []; for ( var key in bindingOrder ) { @@ -5019,11 +5065,21 @@ component displayname="QueryBuilder" accessors="true" { * * @return any */ - private any function runQuery( required string sql, struct options = {}, string returnObject = "query" ) { + private any function runQuery( + required string sql, + struct options = {}, + string returnObject = "query", + array bindings + ) { var queryOptions = structCopy( arguments.options ); structAppend( queryOptions, getDefaultOptions(), false ); guardAgainstReturnTypeOption( queryOptions ); - var bindings = getBindings( except = getAggregate().isEmpty() ? [] : [ "select", "orderBy" ] ); + var aggregateBindingExclusions = getAggregate().isEmpty() + ? [] + : ( getUnions().isEmpty() ? [ "select", "orderBy" ] : [ "orderBy" ] ); + var queryBindings = isNull( arguments.bindings ) + ? getBindings( except = aggregateBindingExclusions ) + : arguments.bindings; var result = grammar.runQuery( sql = variables.sqlCommenter.appendSqlComments( @@ -5032,9 +5088,9 @@ component displayname="QueryBuilder" accessors="true" { "null", "" ), - bindings = bindings + bindings = queryBindings ), - bindings = bindings, + bindings = queryBindings, options = queryOptions, returnObject = returnObject, pretend = variables.pretending, @@ -5147,6 +5203,7 @@ component displayname="QueryBuilder" accessors="true" { } arguments.target.setReturning( cloneQueryStateValue( arguments.source.getReturning() ) ); arguments.target.setUpdates( cloneQueryStateValue( arguments.source.getUpdates() ) ); + arguments.target.setGrammarCompilationLocked( arguments.source.getGrammarCompilationLocked() ); var sourceBindings = arguments.source.getRawBindings(); for ( var bindingType in sourceBindings ) { @@ -5243,7 +5300,10 @@ component displayname="QueryBuilder" accessors="true" { return sql; } - var bindings = getBindings( except = getAggregate().isEmpty() ? [] : [ "select", "orderBy" ] ); + var aggregateBindingExclusions = getAggregate().isEmpty() + ? [] + : ( getUnions().isEmpty() ? [ "select", "orderBy" ] : [ "orderBy" ] ); + var bindings = getBindings( except = aggregateBindingExclusions ); return getUtils().replaceBindings( sql, bindings, @@ -5664,6 +5724,13 @@ component displayname="QueryBuilder" accessors="true" { detail = "The easiest way to fix this error is to set the grammar before any other actions on the query builder." ); } + if ( variables.grammarCompilationLocked ) { + throw( + type = "QBSetGrammarAfterCompilationError", + message = "You cannot switch grammars after adding a grammar-compiled subquery.", + detail = "Set the grammar before adding derived tables, subquery joins, or lateral joins." + ); + } variables.grammar = arguments.grammar; return this; } diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 606864e3..d567f7ab 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -89,7 +89,7 @@ component singleton displayname="QueryUtils" accessors="true" { checkForNonQueryParamStructKeys( value ); } - binding = value; + binding = structCopy( value ); } else { binding = { value: normalizeSqlValue( value ) }; } @@ -260,7 +260,7 @@ component singleton displayname="QueryUtils" accessors="true" { continue; } - if ( character == "$" ) { + if ( ( isNull( arguments.grammar ) || isPostgres ) && character == "$" ) { var dollarQuoteMatch = reFind( "^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$", mid( arguments.sql, position ), diff --git a/models/Query/ReturnFormatterRegistry.cfc b/models/Query/ReturnFormatterRegistry.cfc index b76f8397..e4db6ade 100644 --- a/models/Query/ReturnFormatterRegistry.cfc +++ b/models/Query/ReturnFormatterRegistry.cfc @@ -114,15 +114,16 @@ component accessors="true" singleton { private struct function normalizeFormatterDefinition( required any definition ) { if ( isStruct( arguments.definition ) && arguments.definition.keyExists( "factory" ) ) { - param arguments.definition.options = {}; - param arguments.definition.properties = {}; - param arguments.definition.force = false; + var normalizedDefinition = structCopy( arguments.definition ); + param normalizedDefinition.options = {}; + param normalizedDefinition.properties = {}; + param normalizedDefinition.force = false; return { - "factory": arguments.definition.factory, - "options": arguments.definition.options, - "properties": arguments.definition.properties, - "force": arguments.definition.force + "factory": normalizedDefinition.factory, + "options": normalizedDefinition.options, + "properties": normalizedDefinition.properties, + "force": normalizedDefinition.force }; } diff --git a/tests/resources/AbstractQueryBuilderSpec.cfc b/tests/resources/AbstractQueryBuilderSpec.cfc index 75e6903c..d300c022 100644 --- a/tests/resources/AbstractQueryBuilderSpec.cfc +++ b/tests/resources/AbstractQueryBuilderSpec.cfc @@ -2523,6 +2523,35 @@ component extends="testbox.system.BaseSpec" { expect( getTestBindings( builder ) ).toBe( [] ); } ); + it( "orders union bindings before outer order bindings", function() { + var builder = getBuilder() + .select( "name" ) + .from( "users" ) + .where( "status", "current" ) + .union( function( unionQuery ) { + unionQuery + .select( "name" ) + .from( "archived_users" ) + .where( "status", "archived" ); + } ) + .orderByRaw( "CASE WHEN name = ? THEN 0 ELSE 1 END", [ "preferred" ] ); + + expect( getTestBindings( builder ) ).toBe( [ "current", "archived", "preferred" ] ); + } ); + + it( "retains root select bindings when aggregating a union", function() { + var builder = getBuilder() + .selectRaw( "? AS name", [ "current" ] ) + .from( "users" ) + .union( function( unionQuery ) { + unionQuery.selectRaw( "? AS name", [ "archived" ] ).from( "archived_users" ); + } ); + + expect( function() { + builder.count( toSQL = true, showBindings = "inline" ); + } ).notToThrow(); + } ); + it( "can run an aggregate query like count on a union query", function() { testCase( function( builder ) { return builder diff --git a/tests/specs/Query/Abstract/BuilderAliasSpec.cfc b/tests/specs/Query/Abstract/BuilderAliasSpec.cfc index 8e5146cb..439df103 100644 --- a/tests/specs/Query/Abstract/BuilderAliasSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderAliasSpec.cfc @@ -263,6 +263,30 @@ component extends="testbox.system.BaseSpec" { expect( qb.toSQL() ).toBe( "SELECT * FROM ""users"" AS ""u"" WHERE ""u"".""lastLoginDate"" BETWEEN ? AND ?" ); } ); + it( "renames correlated aliases inside where between subqueries", function() { + var qb = new qb.models.Query.QueryBuilder(); + qb.from( "users" ) + .whereBetween( + "users.score", + function( lowerBound ) { + lowerBound + .selectRaw( "MIN(score)" ) + .from( "scores" ) + .whereColumn( "scores.userId", "users.id" ); + }, + function( upperBound ) { + upperBound + .selectRaw( "MAX(score)" ) + .from( "scores" ) + .whereColumn( "scores.userId", "users.id" ); + } + ) + .withAlias( "u" ); + + expect( qb.toSQL() ).notToInclude( """users"".""id""" ); + expect( qb.toSQL() ).toInclude( """u"".""id""" ); + } ); + it( "renames the columns used in where not between clauses", () => { var qb = new qb.models.Query.QueryBuilder(); qb.from( "users" ) @@ -340,6 +364,71 @@ component extends="testbox.system.BaseSpec" { expect( qb.toSQL() ).toBe( "SELECT ""usersArchive"".""id"" FROM ""users"" AS ""u""" ); } ); + + it( "renames correlated aliases inside union branches", function() { + var qb = new qb.models.Query.QueryBuilder(); + qb.from( "users" ) + .whereExists( function( existsQuery ) { + existsQuery + .selectRaw( "1" ) + .from( "logins" ) + .whereColumn( "logins.userId", "users.id" ) + .union( function( unionQuery ) { + unionQuery + .selectRaw( "1" ) + .from( "archived_logins" ) + .whereColumn( "archived_logins.userId", "users.id" ); + } ); + } ) + .withAlias( "u" ); + + expect( qb.toSQL() ).notToInclude( """users"".""id""" ); + expect( qb.toSQL() ).toInclude( """u"".""id""" ); + } ); + + it( "does not rename aliases shadowed by a union branch table", function() { + var qb = new qb.models.Query.QueryBuilder(); + qb.from( "users" ) + .select( "users.id" ) + .union( function( unionQuery ) { + unionQuery.from( "users" ).select( "users.id" ); + } ) + .withAlias( "u" ); + + expect( qb.toSQL() ).toBe( + "SELECT ""u"".""id"" FROM ""users"" AS ""u"" UNION SELECT ""users"".""id"" FROM ""users""" + ); + } ); + + it( "renames correlated aliases inside common table expressions", function() { + var qb = new qb.models.Query.QueryBuilder(); + qb.from( "users" ) + .whereExists( function( existsQuery ) { + existsQuery + .with( "recent_logins", function( cte ) { + cte.from( "logins" ).whereColumn( "logins.userId", "users.id" ); + } ) + .from( "recent_logins" ); + } ) + .withAlias( "u" ); + + expect( qb.toSQL() ).notToInclude( """users"".""id""" ); + expect( qb.toSQL() ).toInclude( """u"".""id""" ); + } ); + + it( "does not rename aliases shadowed by a common table expression table", function() { + var qb = new qb.models.Query.QueryBuilder(); + qb.with( "local_users", function( cte ) { + cte.from( "users" ).select( "users.id" ); + } ) + .from( "users" ) + .select( "users.id" ) + .withAlias( "u" ); + + expect( qb.toSQL() ).toBe( + "WITH ""local_users"" AS (SELECT ""users"".""id"" FROM ""users"") SELECT ""u"".""id"" FROM ""users"" AS ""u""" + ); + } ); } ); } diff --git a/tests/specs/Query/Abstract/BuilderSelectSpec.cfc b/tests/specs/Query/Abstract/BuilderSelectSpec.cfc index 1b6421fe..32820bb2 100644 --- a/tests/specs/Query/Abstract/BuilderSelectSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderSelectSpec.cfc @@ -296,6 +296,18 @@ component extends="testbox.system.BaseSpec" { expect( query.getDistinct() ).toBe( true, "Distinct should be set to true" ); } ); } ); + + describe( "setGrammar()", function() { + it( "rejects grammar changes after compiling a derived table", function() { + query.fromSub( "active_users", function( subquery ) { + subquery.from( "users" ); + } ); + + expect( function() { + query.setGrammar( new qb.models.Grammars.MySQLGrammar() ); + } ).toThrow( type = "QBSetGrammarAfterCompilationError" ); + } ); + } ); } ); } diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index 4e6daf04..fac0ad06 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -288,6 +288,19 @@ component extends="testbox.system.BaseSpec" { ).toBe( "SELECT * FROM records WHERE id = 42 ## why?#chr( 10 )#" ); } ); + it( "does not treat MySQL dollar-delimited identifiers as PostgreSQL dollar quotes", function() { + var binding = utils.extractBinding( 42, variables.mockGrammar ); + + expect( + utils.replaceBindings( + "SELECT $tag$, ? FROM records", + [ binding ], + true, + new qb.models.Grammars.MySQLGrammar() + ) + ).toBe( "SELECT $tag$, 42 FROM records" ); + } ); + it( "preserves question marks in SQL Server bracketed identifiers", function() { var binding = utils.extractBinding( 42, variables.mockGrammar ); @@ -319,6 +332,15 @@ component extends="testbox.system.BaseSpec" { } ); describe( "extractBinding()", function() { + it( "does not mutate query parameter structs supplied by callers", function() { + var queryParam = { "value": 42, "cfsqltype": "INTEGER" }; + var originalQueryParam = duplicate( queryParam ); + + utils.extractBinding( queryParam, variables.mockGrammar ); + + expect( queryParam ).toBe( originalQueryParam ); + } ); + it( "includes sensible defaults", function() { var datetime = parseDateTime( "05/10/2016" ); var binding = utils.extractBinding( datetime, variables.mockGrammar ); diff --git a/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc b/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc index b9ee69e7..c436f921 100644 --- a/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc +++ b/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc @@ -87,6 +87,26 @@ component extends="testbox.system.BaseSpec" { expect( registry.getReturnFormatter( "custom" )( queryNew( "" ) ) ).toBe( "custom" ); } ); + it( "does not mutate formatter definitions while normalizing them", function() { + var definition = { + "factory": function( options ) { + return function( q ) { + return q; + }; + } + }; + var originalKeys = definition.keyArray(); + var registry = new qb.models.Query.ReturnFormatterRegistry( + returnFormatters = { "custom": definition } + ); + + expect( definition.keyArray() ).toBe( originalKeys ); + expect( definition ).notToHaveKey( "options" ); + expect( definition ).notToHaveKey( "properties" ); + expect( definition ).notToHaveKey( "force" ); + expect( registry.hasReturnFormatter( "custom" ) ).toBeTrue(); + } ); + it( "resolves WireBox formatter factories with properties", function() { var registry = new qb.models.Query.ReturnFormatterRegistry(); registry.setWirebox( { diff --git a/tests/specs/Query/DerbyQueryBuilderSpec.cfc b/tests/specs/Query/DerbyQueryBuilderSpec.cfc index 11283a6e..d83c6037 100644 --- a/tests/specs/Query/DerbyQueryBuilderSpec.cfc +++ b/tests/specs/Query/DerbyQueryBuilderSpec.cfc @@ -1,5 +1,23 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { + function run() { + super.run(); + + describe( "Derby data modification CTEs", function() { + it( "rejects unsupported CTE update statements", function() { + var builder = getBuilder() + .with( "active_users", function( cte ) { + cte.from( "users" ).where( "active", 1 ); + } ) + .from( "active_users" ); + + expect( function() { + builder.update( values = { "name": "changed" }, toSQL = true ); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + } ); + } + function selectAllColumns() { return "SELECT * FROM ""users"""; } diff --git a/tests/specs/Query/MySQLQueryBuilderSpec.cfc b/tests/specs/Query/MySQLQueryBuilderSpec.cfc index 7ff7db50..3b53b527 100644 --- a/tests/specs/Query/MySQLQueryBuilderSpec.cfc +++ b/tests/specs/Query/MySQLQueryBuilderSpec.cfc @@ -1,5 +1,40 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { + function run() { + super.run(); + + describe( "MySQL update binding order", function() { + it( "binds join predicates before update values", function() { + var builder = getBuilder().pretend(); + + builder + .table( "employees" ) + .join( "departments", function( join ) { + join.on( "departments.id", "employees.departmentId" ).where( "departments.active", 1 ); + } ) + .update( values = { "departmentName": "changed" } ); + + expect( builder.getQueryLog()[ 1 ].bindings.map( ( binding ) => binding.value ) ).toBe( [ 1, "changed" ] ); + } ); + } ); + + describe( "MySQL data modification CTEs", function() { + it( "compiles CTEs before update statements", function() { + var builder = getBuilder() + .with( "active_users", function( cte ) { + cte.from( "users" ).where( "active", 1 ); + } ) + .from( "active_users" ) + .where( "id", 42 ); + + var sql = builder.update( values = { "name": "changed" }, toSQL = true ); + + expect( sql ).toStartWith( "WITH" ); + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 1, "changed", 42 ] ); + } ); + } ); + } + function selectAllColumns() { return "SELECT * FROM `users`"; } diff --git a/tests/specs/Query/OracleQueryBuilderSpec.cfc b/tests/specs/Query/OracleQueryBuilderSpec.cfc index f264ec67..a401c80b 100644 --- a/tests/specs/Query/OracleQueryBuilderSpec.cfc +++ b/tests/specs/Query/OracleQueryBuilderSpec.cfc @@ -1,5 +1,23 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { + function run() { + super.run(); + + describe( "Oracle data modification CTEs", function() { + it( "rejects unsupported CTE update statements", function() { + var builder = getBuilder() + .with( "active_users", function( cte ) { + cte.from( "users" ).where( "active", 1 ); + } ) + .from( "active_users" ); + + expect( function() { + builder.update( values = { "name": "changed" }, toSQL = true ); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + } ); + } + function selectAllColumns() { return "SELECT * FROM ""USERS"""; } diff --git a/tests/specs/Query/PostgresQueryBuilderSpec.cfc b/tests/specs/Query/PostgresQueryBuilderSpec.cfc index 10dc0ac3..84c72ba6 100644 --- a/tests/specs/Query/PostgresQueryBuilderSpec.cfc +++ b/tests/specs/Query/PostgresQueryBuilderSpec.cfc @@ -42,6 +42,22 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { ); } ); } ); + + describe( "PostgreSQL data modification CTEs", function() { + it( "compiles CTEs before update statements", function() { + var builder = getBuilder() + .with( "active_users", function( cte ) { + cte.from( "users" ).where( "active", true ); + } ) + .from( "active_users" ) + .where( "id", 42 ); + + var sql = builder.update( values = { "name": "changed" }, toSQL = true ); + + expect( sql ).toStartWith( "WITH" ); + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ true, "changed", 42 ] ); + } ); + } ); } function selectAllColumns() { diff --git a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc index 543f37cf..6b7bc462 100644 --- a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc +++ b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc @@ -19,6 +19,35 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { expect( findNoCase( "RETURNING", sql ) ).toBeGT( findNoCase( "ON CONFLICT", sql ) ); } ); } ); + + describe( "SQLite data modification CTEs", function() { + it( "compiles CTEs before update statements", function() { + var builder = getBuilder() + .with( "active_users", function( cte ) { + cte.from( "users" ).where( "active", 1 ); + } ) + .from( "active_users" ) + .where( "id", 42 ); + + var sql = builder.update( values = { "name": "changed" }, toSQL = true ); + + expect( sql ).toStartWith( "WITH" ); + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 1, "changed", 42 ] ); + } ); + + it( "binds update values before joined predicates", function() { + var builder = getBuilder().pretend(); + + builder + .table( "employees" ) + .join( "departments", function( join ) { + join.on( "departments.id", "employees.departmentId" ).where( "departments.active", 1 ); + } ) + .update( values = { "departmentName": "changed" } ); + + expect( builder.getQueryLog()[ 1 ].bindings.map( ( binding ) => binding.value ) ).toBe( [ "changed", 1 ] ); + } ); + } ); } private function getBuilder() { diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index a80208dc..4cdafb5c 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -140,6 +140,40 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { "SELECT * FROM (SELECT [id] FROM [users]) AS [qb_union_0] UNION ALL SELECT * FROM (SELECT TOP (5) [id] FROM [archivedUsers] ORDER BY [id] DESC) AS [qb_union_1]" ); } ); + + it( "keeps root order bindings before independently ordered union branches", function() { + var builder = getBuilder() + .select( "id" ) + .from( "users" ) + .where( "status", "current" ) + .orderByRaw( "CASE WHEN id = ? THEN 0 ELSE 1 END", [ 10 ] ) + .unionAll( function( unionQuery ) { + unionQuery + .select( "id" ) + .from( "archivedUsers" ) + .where( "status", "archived" ) + .orderByRaw( "CASE WHEN id = ? THEN 0 ELSE 1 END", [ 20 ] ) + .limit( 5 ); + } ); + + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ "current", 10, "archived", 20 ] ); + } ); + } ); + + describe( "SQL Server data modification CTEs", function() { + it( "compiles CTEs before update statements", function() { + var builder = getBuilder() + .with( "active_users", function( cte ) { + cte.from( "users" ).where( "active", 1 ); + } ) + .from( "active_users" ) + .where( "id", 42 ); + + var sql = builder.update( values = { "name": "changed" }, toSQL = true ); + + expect( sql ).toMatch( "^;?WITH" ); + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 1, "changed", 42 ] ); + } ); } ); } From 3e747f9767716307e71c0fcc57c6b653f88b27aa Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 15 Aug 2026 15:45:50 -0600 Subject: [PATCH 026/119] fix: isolate nested query state and DML CTEs --- models/Grammars/BaseGrammar.cfc | 10 ++++ models/Grammars/MySQLGrammar.cfc | 1 + models/Grammars/PostgresGrammar.cfc | 4 +- models/Grammars/SQLiteGrammar.cfc | 4 +- models/Grammars/SqlServerGrammar.cfc | 1 + models/Query/QueryBuilder.cfc | 46 ++++++++++++------- models/Query/ReturnFormatterRegistry.cfc | 27 ++++++++--- models/SQLCommenter/SQLCommenter.cfc | 9 ++-- .../specs/Query/Abstract/BuilderAliasSpec.cfc | 13 ++++++ .../Query/Abstract/BuilderSelectSpec.cfc | 24 ++++++++++ .../Abstract/ReturnFormatterRegistrySpec.cfc | 19 ++++++++ tests/specs/Query/DerbyQueryBuilderSpec.cfc | 12 +++++ tests/specs/Query/MySQLQueryBuilderSpec.cfc | 27 +++++++++++ tests/specs/Query/OracleQueryBuilderSpec.cfc | 12 +++++ .../specs/Query/PostgresQueryBuilderSpec.cfc | 14 ++++++ tests/specs/Query/SQLiteQueryBuilderSpec.cfc | 14 ++++++ .../specs/Query/SqlServerQueryBuilderSpec.cfc | 14 ++++++ tests/specs/SQLCommenterSpec.cfc | 11 +++++ 18 files changed, 234 insertions(+), 28 deletions(-) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 231ae82f..8e7e1560 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -95,6 +95,10 @@ component displayname="Grammar" accessors="true" singleton { * Returns the binding groups in the order they appear in a SELECT statement. */ public array function getSelectBindingOrder( required QueryBuilder query ) { + if ( !arguments.query.getRawBindings().update.isEmpty() ) { + return getUpdateBindingOrder( arguments.query ); + } + return [ "commonTables", "update", @@ -1229,6 +1233,12 @@ component displayname="Grammar" accessors="true" singleton { * @return string */ public string function compileDelete( required QueryBuilder query ) { + if ( !arguments.query.getCommonTables().isEmpty() ) { + throw( + type = "UnsupportedOperation", + message = "This grammar does not support DELETE statements with Common Table Expressions." + ); + } if ( !arguments.query.getReturning().isEmpty() ) { throw( type = "UnsupportedOperation", diff --git a/models/Grammars/MySQLGrammar.cfc b/models/Grammars/MySQLGrammar.cfc index d31adc5c..4ca46171 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -312,6 +312,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { arrayToList( arrayFilter( [ + compileCommonTables( query, query.getCommonTables() ), "DELETE", hasJoins ? wrapTable( query.getTableName() ) : "", "FROM", diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index 924fe54a..67ecabbf 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -301,7 +301,9 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { .map( wrapColumn ) .toList( ", " ); var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; - return trim( "DELETE FROM #wrapTable( query.getTableName() )# #compileWheres( query, query.getWheres() )##returningClause#" ); + return trim( + compileCommonTables( query, query.getCommonTables() ) & " DELETE FROM #wrapTable( query.getTableName() )# #compileWheres( query, query.getWheres() )##returningClause#" + ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index 6f158a25..8fb7a542 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -284,7 +284,9 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { .map( wrapColumn ) .toList( ", " ); var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; - return trim( "DELETE FROM #wrapTable( query.getTableName() )# #compileWheres( query, query.getWheres() )##returningClause#" ); + return trim( + compileCommonTables( query, query.getCommonTables() ) & " DELETE FROM #wrapTable( query.getTableName() )# #compileWheres( query, query.getWheres() )##returningClause#" + ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index 8fc51b24..bde8cfd7 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -657,6 +657,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { arrayToList( arrayFilter( [ + compileCommonTables( query, query.getCommonTables() ), "DELETE", hasJoins ? wrapTable( query.getTableName() ) : "", "FROM", diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 6d6f56fc..4a4b2ccb 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -122,7 +122,8 @@ component displayname="QueryBuilder" accessors="true" { /** * Tracks whether this builder contains SQL compiled by its current grammar. */ - property name="grammarCompilationLocked" type="boolean"; + property name="grammarCompiledFrom" type="boolean"; + property name="grammarCompiledJoin" type="boolean"; /******************** Query Properties ********************/ @@ -442,7 +443,8 @@ component displayname="QueryBuilder" accessors="true" { variables.pretending = false; variables.queryLog = []; variables.shouldWrapValues = javacast( "null", "" ); - variables.grammarCompilationLocked = false; + variables.grammarCompiledFrom = false; + variables.grammarCompiledJoin = false; } /** @@ -837,6 +839,7 @@ component displayname="QueryBuilder" accessors="true" { } clearBindings( only = [ "from" ] ); + variables.grammarCompiledFrom = false; variables.alias = ""; if ( isSimpleValue( arguments.from ) ) { parseIntoTableAndAlias( arguments.from ); @@ -853,6 +856,7 @@ component displayname="QueryBuilder" accessors="true" { public QueryBuilder function clearFrom() { variables.tableName = ""; variables.alias = ""; + variables.grammarCompiledFrom = false; clearBindings( only = [ "from" ] ); return this; } @@ -930,7 +934,7 @@ component displayname="QueryBuilder" accessors="true" { var column = variables.columns[ i ]; renameAliasInTypedColumn( column, arguments.oldAlias, arguments.newAlias ); if ( column.type == "builder" ) { - column.value.renameAliases( arguments.oldAlias, arguments.newAlias ); + renameAliasesInNestedQuery( column.value, arguments.oldAlias, arguments.newAlias ); } } } @@ -965,7 +969,7 @@ component displayname="QueryBuilder" accessors="true" { private void function renameAliasesInOrders( required string oldAlias, required string newAlias ) { for ( var order in variables.orders ) { if ( order.keyExists( "query" ) ) { - order.query.renameAliases( arguments.oldAlias, arguments.newAlias ); + renameAliasesInNestedQuery( order.query, arguments.oldAlias, arguments.newAlias ); } else if ( order.keyExists( "column" ) && order.direction != "raw" ) { renameAliasInTypedColumn( order.column, arguments.oldAlias, arguments.newAlias ); } @@ -1039,7 +1043,7 @@ component displayname="QueryBuilder" accessors="true" { required string newAlias ) { renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); - arguments.where.query.renameAliases( arguments.oldAlias, arguments.newAlias ); + renameAliasesInNestedQuery( arguments.where.query, arguments.oldAlias, arguments.newAlias ); } private void function renameAliasInWhereIn( @@ -1072,7 +1076,7 @@ component displayname="QueryBuilder" accessors="true" { required string newAlias ) { renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); - arguments.where.query.renameAliases( arguments.oldAlias, arguments.newAlias ); + renameAliasesInNestedQuery( arguments.where.query, arguments.oldAlias, arguments.newAlias ); } private void function renameAliasInWhereNotInSub( @@ -1096,7 +1100,7 @@ component displayname="QueryBuilder" accessors="true" { required string oldAlias, required string newAlias ) { - arguments.where.query.renameAliases( arguments.oldAlias, arguments.newAlias ); + renameAliasesInNestedQuery( arguments.where.query, arguments.oldAlias, arguments.newAlias ); } private void function renameAliasInWhereNotExists( @@ -1104,7 +1108,7 @@ component displayname="QueryBuilder" accessors="true" { required string oldAlias, required string newAlias ) { - arguments.where.query.renameAliases( arguments.oldAlias, arguments.newAlias ); + renameAliasesInNestedQuery( arguments.where.query, arguments.oldAlias, arguments.newAlias ); } private void function renameAliasInWhereNested( @@ -1136,7 +1140,7 @@ component displayname="QueryBuilder" accessors="true" { required string oldAlias, required string newAlias ) { - arguments.where.query.renameAliases( arguments.oldAlias, arguments.newAlias ); + renameAliasesInNestedQuery( arguments.where.query, arguments.oldAlias, arguments.newAlias ); } private void function renameAliasInWhereNotNullSub( @@ -1144,7 +1148,7 @@ component displayname="QueryBuilder" accessors="true" { required string oldAlias, required string newAlias ) { - arguments.where.query.renameAliases( arguments.oldAlias, arguments.newAlias ); + renameAliasesInNestedQuery( arguments.where.query, arguments.oldAlias, arguments.newAlias ); } private void function renameAliasInWhereBetween( @@ -1196,6 +1200,7 @@ component displayname="QueryBuilder" accessors="true" { */ public QueryBuilder function table( required any table ) { clearBindings( only = [ "from" ] ); + variables.grammarCompiledFrom = false; variables.alias = ""; variables.tableName = arguments.table; if ( getUtils().isExpression( arguments.table ) ) { @@ -1272,7 +1277,7 @@ component displayname="QueryBuilder" accessors="true" { // generate the derived table SQL this.fromRaw( getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ) ); - variables.grammarCompilationLocked = true; + variables.grammarCompiledFrom = true; addBindings( arguments.input.getBindings(), "from" ); return this; } @@ -1764,7 +1769,7 @@ component displayname="QueryBuilder" accessors="true" { getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ), arguments.input.getBindings() ); - variables.grammarCompilationLocked = true; + variables.grammarCompiledJoin = true; // remove the non-standard arguments structDelete( arguments, "input" ); @@ -1819,7 +1824,7 @@ component displayname="QueryBuilder" accessors="true" { addBindings( tableLikeSource.getBindings(), "join" ); variables.joins.append( join ); - variables.grammarCompilationLocked = true; + variables.grammarCompiledJoin = true; return this; } @@ -1914,7 +1919,7 @@ component displayname="QueryBuilder" accessors="true" { getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ), arguments.input.getBindings() ); - variables.grammarCompilationLocked = true; + variables.grammarCompiledJoin = true; return crossJoin( table ); } @@ -5203,7 +5208,8 @@ component displayname="QueryBuilder" accessors="true" { } arguments.target.setReturning( cloneQueryStateValue( arguments.source.getReturning() ) ); arguments.target.setUpdates( cloneQueryStateValue( arguments.source.getUpdates() ) ); - arguments.target.setGrammarCompilationLocked( arguments.source.getGrammarCompilationLocked() ); + arguments.target.setGrammarCompiledFrom( arguments.source.getGrammarCompiledFrom() ); + arguments.target.setGrammarCompiledJoin( arguments.source.getGrammarCompiledJoin() ); var sourceBindings = arguments.source.getRawBindings(); for ( var bindingType in sourceBindings ) { @@ -5230,7 +5236,13 @@ component displayname="QueryBuilder" accessors="true" { if ( getUtils().isBuilder( arguments.value ) ) { return arguments.value.clone(); } - if ( getUtils().isExpression( arguments.value ) || isObject( arguments.value ) ) { + if ( getUtils().isExpression( arguments.value ) ) { + return new qb.models.Query.Expression( + arguments.value.getSQL(), + cloneQueryStateValue( arguments.value.getBindings() ) + ); + } + if ( isObject( arguments.value ) ) { return arguments.value; } if ( isArray( arguments.value ) ) { @@ -5724,7 +5736,7 @@ component displayname="QueryBuilder" accessors="true" { detail = "The easiest way to fix this error is to set the grammar before any other actions on the query builder." ); } - if ( variables.grammarCompilationLocked ) { + if ( variables.grammarCompiledFrom || variables.grammarCompiledJoin ) { throw( type = "QBSetGrammarAfterCompilationError", message = "You cannot switch grammars after adding a grammar-compiled subquery.", diff --git a/models/Query/ReturnFormatterRegistry.cfc b/models/Query/ReturnFormatterRegistry.cfc index e4db6ade..c6dd86ce 100644 --- a/models/Query/ReturnFormatterRegistry.cfc +++ b/models/Query/ReturnFormatterRegistry.cfc @@ -48,8 +48,8 @@ component accessors="true" singleton { variables.returnFormatters[ arguments.name ] = { "factory": arguments.factory, - "options": arguments.options, - "properties": arguments.properties + "options": cloneConfigurationValue( arguments.options ), + "properties": cloneConfigurationValue( arguments.properties ) }; return this; @@ -64,11 +64,10 @@ component accessors="true" singleton { } var definition = variables.returnFormatters[ arguments.name ]; - var formatterOptions = {}; - structAppend( formatterOptions, definition.options, true ); - structAppend( formatterOptions, arguments.options, true ); + var formatterOptions = cloneConfigurationValue( definition.options ); + structAppend( formatterOptions, cloneConfigurationValue( arguments.options ), true ); - var factory = resolveFactory( definition.factory, definition.properties ); + var factory = resolveFactory( definition.factory, cloneConfigurationValue( definition.properties ) ); if ( isClosure( factory ) || isCustomFunction( factory ) ) { return factory( formatterOptions ); @@ -135,6 +134,22 @@ component accessors="true" singleton { }; } + private any function cloneConfigurationValue( required any value ) { + if ( isStruct( arguments.value ) ) { + var clonedStruct = {}; + for ( var key in arguments.value ) { + clonedStruct[ key ] = cloneConfigurationValue( arguments.value[ key ] ); + } + return clonedStruct; + } + + if ( isArray( arguments.value ) ) { + return arguments.value.map( ( item ) => cloneConfigurationValue( item ) ); + } + + return arguments.value; + } + private function resolveFactory( required any factory, struct properties = {} ) { if ( isClosure( arguments.factory ) || isCustomFunction( arguments.factory ) ) { return arguments.factory; diff --git a/models/SQLCommenter/SQLCommenter.cfc b/models/SQLCommenter/SQLCommenter.cfc index 99d65308..6ccfbbfb 100644 --- a/models/SQLCommenter/SQLCommenter.cfc +++ b/models/SQLCommenter/SQLCommenter.cfc @@ -157,9 +157,12 @@ component singleton { true ); if ( dollarQuoteMatch.len[ 1 ] > 0 ) { - dollarQuoteDelimiter = mid( arguments.sql, position, dollarQuoteMatch.len[ 1 ] ); - position += len( dollarQuoteDelimiter ); - continue; + var candidateDelimiter = mid( arguments.sql, position, dollarQuoteMatch.len[ 1 ] ); + if ( find( candidateDelimiter, arguments.sql, position + len( candidateDelimiter ) ) > 0 ) { + dollarQuoteDelimiter = candidateDelimiter; + position += len( dollarQuoteDelimiter ); + continue; + } } } if ( character == "'" || character == """" || character == chr( 96 ) || character == "[" ) { diff --git a/tests/specs/Query/Abstract/BuilderAliasSpec.cfc b/tests/specs/Query/Abstract/BuilderAliasSpec.cfc index 439df103..f5b5c37d 100644 --- a/tests/specs/Query/Abstract/BuilderAliasSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderAliasSpec.cfc @@ -429,6 +429,19 @@ component extends="testbox.system.BaseSpec" { "WITH ""local_users"" AS (SELECT ""users"".""id"" FROM ""users"") SELECT ""u"".""id"" FROM ""users"" AS ""u""" ); } ); + + it( "does not rename aliases shadowed by an exists subquery table", function() { + var qb = new qb.models.Query.QueryBuilder(); + qb.from( "users" ) + .whereExists( function( existsQuery ) { + existsQuery.from( "users" ).whereColumn( "users.managerId", "users.id" ); + } ) + .withAlias( "u" ); + + expect( qb.toSQL() ).toBe( + "SELECT * FROM ""users"" AS ""u"" WHERE EXISTS (SELECT * FROM ""users"" WHERE ""users"".""managerId"" = ""users"".""id"")" + ); + } ); } ); } diff --git a/tests/specs/Query/Abstract/BuilderSelectSpec.cfc b/tests/specs/Query/Abstract/BuilderSelectSpec.cfc index 32820bb2..210c571c 100644 --- a/tests/specs/Query/Abstract/BuilderSelectSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderSelectSpec.cfc @@ -307,6 +307,30 @@ component extends="testbox.system.BaseSpec" { query.setGrammar( new qb.models.Grammars.MySQLGrammar() ); } ).toThrow( type = "QBSetGrammarAfterCompilationError" ); } ); + + it( "allows grammar changes after replacing a compiled derived table", function() { + query.fromSub( "active_users", function( subquery ) { + subquery.from( "users" ); + } ); + query.from( "users" ); + + query.setGrammar( new qb.models.Grammars.MySQLGrammar() ); + + expect( query.toSQL() ).toBe( "SELECT * FROM `users`" ); + } ); + } ); + + describe( "clone()", function() { + it( "owns cloned raw expression state independently", function() { + var expression = query.raw( "1 AS value" ); + query.select( expression ).from( "users" ); + var clonedQuery = query.clone(); + + clonedQuery.getColumns()[ 1 ].value.setSql( "2 AS value" ); + + expect( query.toSQL() ).toBe( "SELECT 1 AS value FROM ""users""" ); + expect( clonedQuery.toSQL() ).toBe( "SELECT 2 AS value FROM ""users""" ); + } ); } ); } ); } diff --git a/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc b/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc index c436f921..2efa6496 100644 --- a/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc +++ b/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc @@ -107,6 +107,25 @@ component extends="testbox.system.BaseSpec" { expect( registry.hasReturnFormatter( "custom" ) ).toBeTrue(); } ); + it( "snapshots formatter options supplied by callers", function() { + var options = { "prefix": { "value": "user-" } }; + var registry = new qb.models.Query.ReturnFormatterRegistry(); + registry.registerReturnFormatter( + name = "ids", + factory = function( formatterOptions ) { + return function( q ) { + return formatterOptions.prefix.value & q.id[ 1 ]; + }; + }, + options = options + ); + + options.prefix.value = "account-"; + var q = queryNew( "id", "integer", [ { "id": 1 } ] ); + + expect( registry.getReturnFormatter( "ids" )( q ) ).toBe( "user-1" ); + } ); + it( "resolves WireBox formatter factories with properties", function() { var registry = new qb.models.Query.ReturnFormatterRegistry(); registry.setWirebox( { diff --git a/tests/specs/Query/DerbyQueryBuilderSpec.cfc b/tests/specs/Query/DerbyQueryBuilderSpec.cfc index d83c6037..2fbafc21 100644 --- a/tests/specs/Query/DerbyQueryBuilderSpec.cfc +++ b/tests/specs/Query/DerbyQueryBuilderSpec.cfc @@ -15,6 +15,18 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { builder.update( values = { "name": "changed" }, toSQL = true ); } ).toThrow( type = "UnsupportedOperation" ); } ); + + it( "rejects unsupported CTE delete statements", function() { + var builder = getBuilder() + .with( "inactive_users", function( cte ) { + cte.from( "users" ).where( "active", 0 ); + } ) + .from( "inactive_users" ); + + expect( function() { + builder.delete( toSQL = true ); + } ).toThrow( type = "UnsupportedOperation" ); + } ); } ); } diff --git a/tests/specs/Query/MySQLQueryBuilderSpec.cfc b/tests/specs/Query/MySQLQueryBuilderSpec.cfc index 3b53b527..7d730b7c 100644 --- a/tests/specs/Query/MySQLQueryBuilderSpec.cfc +++ b/tests/specs/Query/MySQLQueryBuilderSpec.cfc @@ -16,6 +16,19 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { expect( builder.getQueryLog()[ 1 ].bindings.map( ( binding ) => binding.value ) ).toBe( [ 1, "changed" ] ); } ); + + it( "returns bindings in update SQL order for manual execution", function() { + var builder = getBuilder(); + + builder + .table( "employees" ) + .join( "departments", function( join ) { + join.on( "departments.id", "employees.departmentId" ).where( "departments.active", 1 ); + } ) + .update( values = { "departmentName": "changed" }, toSQL = true ); + + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 1, "changed" ] ); + } ); } ); describe( "MySQL data modification CTEs", function() { @@ -32,6 +45,20 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { expect( sql ).toStartWith( "WITH" ); expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 1, "changed", 42 ] ); } ); + + it( "compiles CTEs before delete statements", function() { + var builder = getBuilder() + .with( "inactive_users", function( cte ) { + cte.from( "users" ).where( "active", 0 ); + } ) + .from( "inactive_users" ) + .where( "id", 42 ); + + var sql = builder.delete( toSQL = true ); + + expect( sql ).toStartWith( "WITH" ); + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 0, 42 ] ); + } ); } ); } diff --git a/tests/specs/Query/OracleQueryBuilderSpec.cfc b/tests/specs/Query/OracleQueryBuilderSpec.cfc index a401c80b..6663ef51 100644 --- a/tests/specs/Query/OracleQueryBuilderSpec.cfc +++ b/tests/specs/Query/OracleQueryBuilderSpec.cfc @@ -15,6 +15,18 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { builder.update( values = { "name": "changed" }, toSQL = true ); } ).toThrow( type = "UnsupportedOperation" ); } ); + + it( "rejects unsupported CTE delete statements", function() { + var builder = getBuilder() + .with( "inactive_users", function( cte ) { + cte.from( "users" ).where( "active", 0 ); + } ) + .from( "inactive_users" ); + + expect( function() { + builder.delete( toSQL = true ); + } ).toThrow( type = "UnsupportedOperation" ); + } ); } ); } diff --git a/tests/specs/Query/PostgresQueryBuilderSpec.cfc b/tests/specs/Query/PostgresQueryBuilderSpec.cfc index 84c72ba6..2e212533 100644 --- a/tests/specs/Query/PostgresQueryBuilderSpec.cfc +++ b/tests/specs/Query/PostgresQueryBuilderSpec.cfc @@ -57,6 +57,20 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { expect( sql ).toStartWith( "WITH" ); expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ true, "changed", 42 ] ); } ); + + it( "compiles CTEs before delete statements", function() { + var builder = getBuilder() + .with( "inactive_users", function( cte ) { + cte.from( "users" ).where( "active", false ); + } ) + .from( "inactive_users" ) + .where( "id", 42 ); + + var sql = builder.delete( toSQL = true ); + + expect( sql ).toStartWith( "WITH" ); + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ false, 42 ] ); + } ); } ); } diff --git a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc index 6b7bc462..08719cf7 100644 --- a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc +++ b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc @@ -47,6 +47,20 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { expect( builder.getQueryLog()[ 1 ].bindings.map( ( binding ) => binding.value ) ).toBe( [ "changed", 1 ] ); } ); + + it( "compiles CTEs before delete statements", function() { + var builder = getBuilder() + .with( "inactive_users", function( cte ) { + cte.from( "users" ).where( "active", 0 ); + } ) + .from( "inactive_users" ) + .where( "id", 42 ); + + var sql = builder.delete( toSQL = true ); + + expect( sql ).toStartWith( "WITH" ); + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 0, 42 ] ); + } ); } ); } diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index 4cdafb5c..d59c6045 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -174,6 +174,20 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { expect( sql ).toMatch( "^;?WITH" ); expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 1, "changed", 42 ] ); } ); + + it( "compiles CTEs before delete statements", function() { + var builder = getBuilder() + .with( "inactive_users", function( cte ) { + cte.from( "users" ).where( "active", 0 ); + } ) + .from( "inactive_users" ) + .where( "id", 42 ); + + var sql = builder.delete( toSQL = true ); + + expect( sql ).toMatch( "^;?WITH" ); + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 0, 42 ] ); + } ); } ); } diff --git a/tests/specs/SQLCommenterSpec.cfc b/tests/specs/SQLCommenterSpec.cfc index 1e2e6c41..bdb03eee 100644 --- a/tests/specs/SQLCommenterSpec.cfc +++ b/tests/specs/SQLCommenterSpec.cfc @@ -91,6 +91,17 @@ component extends="testbox.system.BaseSpec" { ) ).toBeWithCase( "SELECT $payload$-- not a comment$payload$ AS marker /*framework='qb'*/" ); } ); + + it( "detects existing comments after unmatched dollar-delimited identifiers", function() { + var sql = "SELECT $tag$ FROM records /*framework='existing'*/"; + + expect( + variables.sqlCommenter.appendCommentsToSQL( + sql = sql, + comments = { "framework": "replacement" } + ) + ).toBeWithCase( sql ); + } ); } ); describe( "parseCommentedSQL", () => { From 80396c134331b2f2c603d4981a6cd117baba8d0b Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 15 Aug 2026 15:48:56 -0600 Subject: [PATCH 027/119] fix: close remaining state ownership gaps --- models/Query/QueryBuilder.cfc | 13 +++++++++---- models/Query/QueryUtils.cfc | 13 ++++++++----- models/Query/ReturnFormatterRegistry.cfc | 19 ++++++++++++++++--- .../Query/Abstract/BuilderSelectSpec.cfc | 18 ++++++++++++++++++ tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 8 ++++++++ .../Abstract/ReturnFormatterRegistrySpec.cfc | 16 ++++++++++++++++ 6 files changed, 75 insertions(+), 12 deletions(-) diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 4a4b2ccb..4b5ba222 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -1769,13 +1769,17 @@ component displayname="QueryBuilder" accessors="true" { getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ), arguments.input.getBindings() ); - variables.grammarCompiledJoin = true; // remove the non-standard arguments structDelete( arguments, "input" ); structDelete( arguments, "alias" ); - return join( argumentCollection = arguments ); + var joinCount = variables.joins.len(); + var result = join( argumentCollection = arguments ); + if ( variables.joins.len() > joinCount ) { + variables.grammarCompiledJoin = true; + } + return result; } private function outerOrCrossApply( required string name, required string type, required tableLikeSource ) { @@ -1919,9 +1923,10 @@ component displayname="QueryBuilder" accessors="true" { getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ), arguments.input.getBindings() ); - variables.grammarCompiledJoin = true; - return crossJoin( table ); + var result = crossJoin( table ); + variables.grammarCompiledJoin = true; + return result; } /** diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index d567f7ab..583e6750 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -268,11 +268,14 @@ component singleton displayname="QueryUtils" accessors="true" { true ); if ( dollarQuoteMatch.len[ 1 ] > 0 ) { - dollarQuoteDelimiter = mid( arguments.sql, position, dollarQuoteMatch.len[ 1 ] ); - output.append( dollarQuoteDelimiter ); - position += len( dollarQuoteDelimiter ); - state = "dollarQuote"; - continue; + var candidateDelimiter = mid( arguments.sql, position, dollarQuoteMatch.len[ 1 ] ); + if ( find( candidateDelimiter, arguments.sql, position + len( candidateDelimiter ) ) > 0 ) { + dollarQuoteDelimiter = candidateDelimiter; + output.append( dollarQuoteDelimiter ); + position += len( dollarQuoteDelimiter ); + state = "dollarQuote"; + continue; + } } } diff --git a/models/Query/ReturnFormatterRegistry.cfc b/models/Query/ReturnFormatterRegistry.cfc index c6dd86ce..bacff6d1 100644 --- a/models/Query/ReturnFormatterRegistry.cfc +++ b/models/Query/ReturnFormatterRegistry.cfc @@ -134,17 +134,30 @@ component accessors="true" singleton { }; } - private any function cloneConfigurationValue( required any value ) { + private any function cloneConfigurationValue( any value ) { + if ( isNull( arguments.value ) ) { + return javacast( "null", "" ); + } + if ( isStruct( arguments.value ) ) { var clonedStruct = {}; for ( var key in arguments.value ) { - clonedStruct[ key ] = cloneConfigurationValue( arguments.value[ key ] ); + clonedStruct[ key ] = isNull( arguments.value[ key ] ) + ? javacast( "null", "" ) + : cloneConfigurationValue( arguments.value[ key ] ); } return clonedStruct; } if ( isArray( arguments.value ) ) { - return arguments.value.map( ( item ) => cloneConfigurationValue( item ) ); + var clonedArray = []; + arrayResize( clonedArray, arguments.value.len() ); + for ( var i = 1; i <= arguments.value.len(); i++ ) { + clonedArray[ i ] = isNull( arguments.value[ i ] ) + ? javacast( "null", "" ) + : cloneConfigurationValue( arguments.value[ i ] ); + } + return clonedArray; } return arguments.value; diff --git a/tests/specs/Query/Abstract/BuilderSelectSpec.cfc b/tests/specs/Query/Abstract/BuilderSelectSpec.cfc index 210c571c..84d47845 100644 --- a/tests/specs/Query/Abstract/BuilderSelectSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderSelectSpec.cfc @@ -318,6 +318,24 @@ component extends="testbox.system.BaseSpec" { expect( query.toSQL() ).toBe( "SELECT * FROM `users`" ); } ); + + it( "does not retain a grammar lock when adding a derived join fails", function() { + expect( function() { + query.joinSub( + alias = "active_users", + input = function( subquery ) { + subquery.from( "users" ); + }, + first = "active_users.id", + operator = "invalid", + second = "users.id" + ); + } ).toThrow(); + + expect( function() { + query.setGrammar( new qb.models.Grammars.MySQLGrammar() ); + } ).notToThrow(); + } ); } ); describe( "clone()", function() { diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index fac0ad06..296de250 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -301,6 +301,14 @@ component extends="testbox.system.BaseSpec" { ).toBe( "SELECT $tag$, 42 FROM records" ); } ); + it( "requires a closing delimiter before treating generic SQL as dollar quoted", function() { + var binding = utils.extractBinding( 42, variables.mockGrammar ); + + expect( utils.replaceBindings( "SELECT $tag$, ? FROM records", [ binding ], true ) ).toBe( + "SELECT $tag$, 42 FROM records" + ); + } ); + it( "preserves question marks in SQL Server bracketed identifiers", function() { var binding = utils.extractBinding( 42, variables.mockGrammar ); diff --git a/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc b/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc index 2efa6496..43edec86 100644 --- a/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc +++ b/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc @@ -126,6 +126,22 @@ component extends="testbox.system.BaseSpec" { expect( registry.getReturnFormatter( "ids" )( q ) ).toBe( "user-1" ); } ); + it( "preserves null values while snapshotting formatter configuration", function() { + var registry = new qb.models.Query.ReturnFormatterRegistry(); + + expect( function() { + registry.registerReturnFormatter( + name = "nullable", + factory = function( options ) { + return function( q ) { + return q; + }; + }, + options = { "nullable": javacast( "null", "" ) } + ); + } ).notToThrow(); + } ); + it( "resolves WireBox formatter factories with properties", function() { var registry = new qb.models.Query.ReturnFormatterRegistry(); registry.setWirebox( { From 19d2344fe4b2acecea9a47aad2cd12cd2a228039 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 15 Aug 2026 16:47:16 -0600 Subject: [PATCH 028/119] fix: align nested query and binding compilation --- models/Grammars/BaseGrammar.cfc | 8 +- models/Grammars/SqlServerGrammar.cfc | 123 +++++++++- models/Query/QueryBuilder.cfc | 167 +++++++++---- models/Query/QueryUtils.cfc | 13 +- .../specs/Query/Abstract/BuilderWhereSpec.cfc | 32 +++ tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 13 + .../specs/Query/SqlServerQueryBuilderSpec.cfc | 223 ++++++++++++++++++ .../Schema/SqlServerSchemaBuilderSpec.cfc | 44 ++++ 8 files changed, 560 insertions(+), 63 deletions(-) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 8e7e1560..2c139a4c 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -657,10 +657,10 @@ component displayname="Grammar" accessors="true" singleton { */ private string function whereBetween( required QueryBuilder query, required struct where ) { var start = variables.utils.isExpression( where.start ) ? where.start.getSql() : ( - isSimpleValue( where.start ) ? "?" : "(#compileSelect( where.start )#)" + variables.utils.isBuilder( where.start ) ? "(#compileSelect( where.start )#)" : "?" ); var end = variables.utils.isExpression( where.end ) ? where.end.getSql() : ( - isSimpleValue( where.end ) ? "?" : "(#compileSelect( where.end )#)" + variables.utils.isBuilder( where.end ) ? "(#compileSelect( where.end )#)" : "?" ); return "#wrapColumn( where.column )# BETWEEN #start# AND #end#"; } @@ -675,10 +675,10 @@ component displayname="Grammar" accessors="true" singleton { */ private string function whereNotBetween( required QueryBuilder query, required struct where ) { var start = variables.utils.isExpression( where.start ) ? where.start.getSql() : ( - isSimpleValue( where.start ) ? "?" : "(#compileSelect( where.start )#)" + variables.utils.isBuilder( where.start ) ? "(#compileSelect( where.start )#)" : "?" ); var end = variables.utils.isExpression( where.end ) ? where.end.getSql() : ( - isSimpleValue( where.end ) ? "?" : "(#compileSelect( where.end )#)" + variables.utils.isBuilder( where.end ) ? "(#compileSelect( where.end )#)" : "?" ); return "#wrapColumn( where.column )# NOT BETWEEN #start# AND #end#"; } diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index bde8cfd7..51c9e7cf 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -779,8 +779,10 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { .toList( ", " ); var returningClause = returningColumns != "" ? " OUTPUT #returningColumns#" : ""; - - return "MERGE #wrapTable( arguments.qb.getTableName() )# AS [qb_target] USING #sourceString# ON #constraintString##updateStatement# WHEN NOT MATCHED BY TARGET THEN INSERT (#columnsString#) VALUES (#columnsString#)#deleteStatement##returningClause#;"; + return trim( + compileCommonTables( arguments.qb, arguments.qb.getCommonTables() ) & + " MERGE #wrapTable( arguments.qb.getTableName() )# AS [qb_target] USING #sourceString# ON #constraintString##updateStatement# WHEN NOT MATCHED BY TARGET THEN INSERT (#columnsString#) VALUES (#columnsString#)#deleteStatement##returningClause#;" + ); } finally { if ( !isNull( arguments.qb.getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); @@ -835,6 +837,30 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { return ""; } + function compileCreateView( blueprint, commandParameters ) { + var query = arguments.commandParameters[ "query" ]; + if ( query.getCommonTables().isEmpty() ) { + return super.compileCreateView( argumentCollection = arguments ); + } + + try { + var originalShouldWrapValues = getShouldWrapValues(); + if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { + setShouldWrapValues( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ); + } + + var selectStatement = compileSelect( query ); + if ( selectStatement.left( 1 ) == ";" ) { + selectStatement = mid( selectStatement, 2, selectStatement.len() - 1 ); + } + return "CREATE VIEW #wrapTable( blueprint.getTable() )# AS #selectStatement#"; + } finally { + if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { + setShouldWrapValues( originalShouldWrapValues ); + } + } + } + function compileCreateAs( blueprint, commandParameters ) { try { var originalShouldWrapValues = getShouldWrapValues(); @@ -843,12 +869,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } var query = commandParameters[ "query" ]; - return replace( - compileSelect( query ), - "FROM", - "INTO #wrapTable( blueprint.getTable() )# FROM", - "one" - ); + return insertIntoOuterSelect( compileSelect( query ), wrapTable( blueprint.getTable() ) ); } finally { if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); @@ -856,6 +877,92 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } } + /** + * Inserts a SQL Server INTO clause before the outer query's FROM clause. + * FROM tokens inside CTEs, subqueries, literals, identifiers, and comments are ignored. + */ + private string function insertIntoOuterSelect( required string sql, required string table ) { + var position = 1; + var depth = 0; + var state = "sql"; + var sqlLength = arguments.sql.len(); + + while ( position <= sqlLength ) { + var character = arguments.sql.mid( position, 1 ); + var nextCharacter = position < sqlLength ? arguments.sql.mid( position + 1, 1 ) : ""; + + if ( state == "lineComment" ) { + if ( character == chr( 10 ) || character == chr( 13 ) ) { + state = "sql"; + } + position++; + continue; + } + if ( state == "blockComment" ) { + if ( character == "*" && nextCharacter == "/" ) { + position += 2; + state = "sql"; + } else { + position++; + } + continue; + } + if ( state != "sql" ) { + var closingCharacter = state == "singleQuote" ? "'" : ( state == "doubleQuote" ? """" : "]" ); + if ( character == closingCharacter ) { + if ( nextCharacter == closingCharacter ) { + position += 2; + } else { + position++; + state = "sql"; + } + } else { + position++; + } + continue; + } + + if ( character == "-" && nextCharacter == "-" ) { + position += 2; + state = "lineComment"; + continue; + } + if ( character == "/" && nextCharacter == "*" ) { + position += 2; + state = "blockComment"; + continue; + } + if ( character == "'" || character == """" || character == "[" ) { + state = character == "'" ? "singleQuote" : ( character == """" ? "doubleQuote" : "bracketQuote" ); + position++; + continue; + } + if ( character == "(" ) { + depth++; + position++; + continue; + } + if ( character == ")" ) { + depth = max( 0, depth - 1 ); + position++; + continue; + } + if ( + depth == 0 && + position + 5 <= sqlLength && + compareNoCase( arguments.sql.mid( position, 6 ), " FROM " ) == 0 + ) { + return arguments.sql.left( position - 1 ) & + " INTO #arguments.table#" & + mid( arguments.sql, position, sqlLength - position + 1 ); + } + + position++; + } + + throw( type = "InvalidCreateAsQuery", message = "SQL Server CREATE AS queries require an outer FROM clause." ); + } + function compileDropColumn( blueprint, commandParameters ) { try { var originalShouldWrapValues = getShouldWrapValues(); diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 4b5ba222..4e4d0c8d 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -1275,6 +1275,8 @@ component displayname="QueryBuilder" accessors="true" { arguments.input = subquery; } + arguments.input = snapshotBuilder( arguments.input ); + // generate the derived table SQL this.fromRaw( getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ) ); variables.grammarCompiledFrom = true; @@ -1404,13 +1406,20 @@ component displayname="QueryBuilder" accessors="true" { var join = new qb.models.Query.JoinClause( joiningQuery = this, type = arguments.type, table = arguments.table ); if ( isClosure( arguments.first ) || isCustomFunction( arguments.first ) ) { - first( join ); + var commonTableState = captureCommonTableState(); + try { + first( join ); + } catch ( any e ) { + restoreCommonTableState( commonTableState ); + rethrow; + } if ( arguments.preventDuplicateJoins ) { var hasThisJoin = variables.joins.find( function( existingJoin ) { return existingJoin.isEqualTo( join ); } ); if ( hasThisJoin ) { + restoreCommonTableState( commonTableState ); return this; } } @@ -1756,33 +1765,43 @@ component displayname="QueryBuilder" accessors="true" { string type = "inner", boolean where = false ) { + var commonTableState = captureCommonTableState(); // since we have a callback, we generate a new query object and pass it into the callback - if ( isClosure( arguments.input ) || isCustomFunction( arguments.input ) ) { - var subquery = newQuery(); - arguments.input( subquery ); - // replace the original query builder with the results of the sub-query - arguments.input = subquery; - } + try { + if ( isClosure( arguments.input ) || isCustomFunction( arguments.input ) ) { + var subquery = newQuery(); + arguments.input( subquery ); + // replace the original query builder with the results of the sub-query + arguments.input = subquery; + } + arguments.input = snapshotBuilder( arguments.input ); - // create the table reference - arguments.table = raw( - getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ), - arguments.input.getBindings() - ); + // create the table reference + arguments.table = raw( + getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ), + arguments.input.getBindings() + ); - // remove the non-standard arguments - structDelete( arguments, "input" ); - structDelete( arguments, "alias" ); + // remove the non-standard arguments + structDelete( arguments, "input" ); + structDelete( arguments, "alias" ); - var joinCount = variables.joins.len(); - var result = join( argumentCollection = arguments ); - if ( variables.joins.len() > joinCount ) { - variables.grammarCompiledJoin = true; + var joinCount = variables.joins.len(); + var result = join( argumentCollection = arguments ); + if ( variables.joins.len() > joinCount ) { + variables.grammarCompiledJoin = true; + } else { + restoreCommonTableState( commonTableState ); + } + return result; + } catch ( any e ) { + restoreCommonTableState( commonTableState ); + rethrow; } - return result; } private function outerOrCrossApply( required string name, required string type, required tableLikeSource ) { + var commonTableState = captureCommonTableState(); if ( type != "outer apply" && type != "cross apply" && type != "lateral" ) { throw( type = "QBInvalidJoinType", @@ -1806,6 +1825,8 @@ component displayname="QueryBuilder" accessors="true" { arguments.tableLikeSource = subquery; } + arguments.tableLikeSource = snapshotBuilder( arguments.tableLikeSource ); + var join = new qb.models.Query.JoinClause( joiningQuery = this, type = type, @@ -1821,7 +1842,7 @@ component displayname="QueryBuilder" accessors="true" { if ( hasThisJoin ) { // Do nothing, early return - // We have not mutated `this` in any way. + restoreCommonTableState( commonTableState ); return this; } } @@ -1910,23 +1931,30 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function crossJoinSub( required any alias, required any input ) { + var commonTableState = captureCommonTableState(); // since we have a callback, we generate a new query object and pass it into the callback - if ( isClosure( arguments.input ) || isCustomFunction( arguments.input ) ) { - var subquery = newQuery(); - arguments.input( subquery ); - // replace the original query builder with the results of the sub-query - arguments.input = subquery; - } + try { + if ( isClosure( arguments.input ) || isCustomFunction( arguments.input ) ) { + var subquery = newQuery(); + arguments.input( subquery ); + // replace the original query builder with the results of the sub-query + arguments.input = subquery; + } + arguments.input = snapshotBuilder( arguments.input ); - // create the table reference - var table = raw( - getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ), - arguments.input.getBindings() - ); + // create the table reference + var table = raw( + getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ), + arguments.input.getBindings() + ); - var result = crossJoin( table ); - variables.grammarCompiledJoin = true; - return result; + var result = crossJoin( table ); + variables.grammarCompiledJoin = true; + return result; + } catch ( any e ) { + restoreCommonTableState( commonTableState ); + rethrow; + } } /** @@ -2428,7 +2456,9 @@ component displayname="QueryBuilder" accessors="true" { } ); var bindings = []; - addColumnBindings( [ typedColumn ], "where" ); + if ( !arguments.values.isEmpty() ) { + addColumnBindings( [ typedColumn ], "where" ); + } for ( var value in arguments.values ) { if ( getUtils().isExpression( value ) ) { bindings.append( extractExpressionBindings( value ), true ); @@ -2503,8 +2533,8 @@ component displayname="QueryBuilder" accessors="true" { combinator: arguments.combinator } ); - addColumnBindings( [ typedColumn ], "where" ); if ( !arguments.values.isEmpty() ) { + addColumnBindings( [ typedColumn ], "where" ); var serializedValues = extractedBindings.map( function( binding ) { return binding.null ? javacast( "null", "" ) : binding.value; } ); @@ -3892,6 +3922,7 @@ component displayname="QueryBuilder" accessors="true" { arguments.source = newQuery(); callback( arguments.source ); } + arguments.source = snapshotBuilder( arguments.source ); clearBindings( except = [ "commonTables" ] ); @@ -4061,8 +4092,8 @@ component displayname="QueryBuilder" accessors="true" { if ( isCustomFunction( value ) || isClosure( value ) ) { var subselect = newQuery(); value( subselect ); - arguments.values[ column.original ] = subselect; - addBindings( subselect.getBindings(), "update" ); + arguments.values[ column.original ] = snapshotBuilder( subselect ); + addBindings( arguments.values[ column.original ].getBindings(), "update" ); } else if ( getUtils().isBuilder( value ) ) { arguments.values[ column.original ] = snapshotBuilder( value ); addBindings( arguments.values[ column.original ].getBindings(), "update" ); @@ -4160,6 +4191,7 @@ component displayname="QueryBuilder" accessors="true" { } if ( !isNull( arguments.source ) ) { + arguments.source = snapshotBuilder( arguments.source ); addBindings( arguments.source.getBindings(), "insert" ); } @@ -4279,6 +4311,7 @@ component displayname="QueryBuilder" accessors="true" { } if ( getUtils().isBuilder( arguments.deleteUnmatched ) ) { + arguments.deleteUnmatched = snapshotBuilder( arguments.deleteUnmatched ); addBindings( arguments.deleteUnmatched.getBindings(), "insert" ); } @@ -4443,7 +4476,49 @@ component displayname="QueryBuilder" accessors="true" { * Clones a child builder when it is attached so its SQL and copied bindings cannot diverge later. */ private QueryBuilder function snapshotBuilder( required QueryBuilder builder ) { - return arguments.builder.clone(); + var snapshot = arguments.builder.clone(); + var hoistTarget = isJoin() ? getJoiningQuery() : this; + hoistNestedCommonTables( snapshot, hoistTarget ); + return snapshot; + } + + private struct function captureCommonTableState() { + return { + commonTableCount: variables.commonTables.len(), + commonTableBindingCount: variables.bindings.commonTables.len() + }; + } + + private void function restoreCommonTableState( required struct state ) { + variables.commonTables = arguments.state.commonTableCount == 0 + ? [] + : variables.commonTables.slice( 1, arguments.state.commonTableCount ); + variables.bindings.commonTables = arguments.state.commonTableBindingCount == 0 + ? [] + : variables.bindings.commonTables.slice( 1, arguments.state.commonTableBindingCount ); + } + + /** + * Moves SQL Server CTEs from an embedded query to the statement that contains it. + * T-SQL only permits the WITH clause at the statement level, not inside the + * parentheses used for derived tables and predicate subqueries. + */ + private QueryBuilder function hoistNestedCommonTables( required QueryBuilder source, required QueryBuilder target ) { + if ( + !isInstanceOf( arguments.target.getGrammar(), "qb.models.Grammars.SqlServerGrammar" ) || + arguments.source.getCommonTables().isEmpty() + ) { + return arguments.source; + } + + var targetCommonTables = arguments.target.getCommonTables(); + targetCommonTables.append( arguments.source.getCommonTables(), true ); + arguments.target.setCommonTables( targetCommonTables ); + arguments.target.addBindings( arguments.source.getRawBindings().commonTables, "commonTables" ); + + arguments.source.setCommonTables( [] ); + arguments.source.getRawBindings().commonTables = []; + return arguments.source; } /** @@ -4669,12 +4744,12 @@ component displayname="QueryBuilder" accessors="true" { */ public any function exists( struct options = {}, boolean toSQL = false ) { var existsSource = clone().setLimitValue( 1 ); - var existsQuery = prepareInternalExecutionBuilder( newQuery() ) - .clearFrom() - .selectRaw( - "CASE WHEN EXISTS (#getGrammar().compileSelect( existsSource )#) THEN 1 ELSE 0 END AS aggregate", - existsSource.getBindings() - ); + var existsQuery = prepareInternalExecutionBuilder( newQuery() ).clearFrom(); + hoistNestedCommonTables( existsSource, existsQuery ); + existsQuery.selectRaw( + "CASE WHEN EXISTS (#getGrammar().compileSelect( existsSource )#) THEN 1 ELSE 0 END AS aggregate", + existsSource.getBindings() + ); if ( arguments.toSQL ) { return existsQuery.toSQL(); } diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 583e6750..9cf28a89 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -111,18 +111,21 @@ component singleton displayname="QueryUtils" accessors="true" { } } - if ( binding.cfsqltype == "TIMESTAMP" ) { + structAppend( binding, { list: false, null: false }, false ); + if ( binding.null && ( !binding.keyExists( "value" ) || isNull( binding.value ) ) ) { + binding.value = ""; + } + + if ( !binding.null && binding.cfsqltype == "TIMESTAMP" ) { binding.value = isBoxLang() ? dateTimeFormat( binding.value, "yyyy-MM-dd'T'HH:mm:ss.SSSXXX" ) : dateTimeFormat( binding.value, "yyyy-mm-dd'T'HH:nn:ss.lllXXX" ); - } else if ( binding.cfsqltype == "DATE" ) { + } else if ( !binding.null && binding.cfsqltype == "DATE" ) { binding.value = dateFormat( binding.value, "yyyy-MM-dd" ); - } else if ( binding.cfsqltype == "TIME" ) { + } else if ( !binding.null && binding.cfsqltype == "TIME" ) { binding.value = timeFormat( binding.value, "HH:mm:ss.nZ" ); } - structAppend( binding, { list: false, null: false }, false ); - if ( isFloatingPoint( binding ) ) { param binding.scale = calculateNumberOfDecimalDigits( binding ); } diff --git a/tests/specs/Query/Abstract/BuilderWhereSpec.cfc b/tests/specs/Query/Abstract/BuilderWhereSpec.cfc index 10765006..90a17516 100644 --- a/tests/specs/Query/Abstract/BuilderWhereSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderWhereSpec.cfc @@ -92,6 +92,38 @@ component extends="testbox.system.BaseSpec" { expect( where.type ).toBe( "notIn" ); } ); + it( "does not retain raw column bindings for empty IN predicates", function() { + var cases = [ + { apply: ( builder, column ) => builder.whereIn( column, [] ), predicate: "0 = 1" }, + { apply: ( builder, column ) => builder.whereNotIn( column, [] ), predicate: "1 = 1" }, + { apply: ( builder, column ) => builder.whereInBulk( column, [] ), predicate: "0 = 1" }, + { apply: ( builder, column ) => builder.whereNotInBulk( column, [] ), predicate: "1 = 1" } + ]; + + cases.each( function( testCase ) { + var builder = new qb.models.Query.QueryBuilder().from( "users" ); + testCase.apply( builder, builder.raw( "COALESCE(?, id)", [ 99 ] ) ); + + expect( builder.toSQL( showBindings = true ) ).toBe( + "SELECT * FROM ""users"" WHERE #testCase.predicate#" + ); + expect( builder.getBindings() ).toBeEmpty(); + } ); + } ); + + it( "treats null query parameter structs as BETWEEN bindings", function() { + var builder = new qb.models.Query.QueryBuilder() + .from( "users" ) + .whereBetween( + "age", + { value: javacast( "null", "" ), cfsqltype: "INTEGER", null: true }, + 10 + ); + + expect( builder.toSQL() ).toBe( "SELECT * FROM ""users"" WHERE ""age"" BETWEEN ? AND ?" ); + expect( builder.getBindings()[ 1 ].null ).toBeTrue(); + } ); + it( "has a orWhere shortcut", function() { qb.orWhere( "::some column::", "<>", "::some value::" ); diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index 296de250..679260a3 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -79,6 +79,19 @@ component extends="testbox.system.BaseSpec" { ).toBe( 0 ); } ); + it( "does not format null temporal query parameters", function() { + [ "DATE", "TIME", "TIMESTAMP" ].each( function( sqlType ) { + var binding = utils.extractBinding( + { value: javacast( "null", "" ), cfsqltype: sqlType, null: true }, + variables.mockGrammar + ); + + expect( binding.null ).toBeTrue(); + expect( binding.cfsqltype ).toBe( sqlType ); + expect( utils.replaceBindings( "SELECT ?", [ binding ] ) ).toInclude( """null"":true" ); + } ); + } ); + describe( "boolean", () => { it( "infers boolean types correctly", () => { makePublic( utils, "checkIsActuallyBoolean", "publicCheckIsActuallyBoolean" ); diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index d59c6045..9e060c9d 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -189,6 +189,229 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 0, 42 ] ); } ); } ); + + describe( "SQL Server nested CTE queries", function() { + it( "hoists common table expressions outside the EXISTS subquery", function() { + var builder = getBuilder() + .with( "active_users", function( cte ) { + cte.from( "users" ).where( "active", 1 ); + } ) + .from( "active_users" ) + .where( "id", 42 ); + + expect( builder.exists( toSQL = true ) ).toBe( + ";WITH [active_users] AS (SELECT * FROM [users] WHERE [active] = ?) SELECT CASE WHEN EXISTS (SELECT TOP (1) * FROM [active_users] WHERE [id] = ?) THEN 1 ELSE 0 END AS aggregate" + ); + } ); + + it( "hoists common table expressions outside derived tables", function() { + var builder = getBuilder().fromSub( "active_users", function( source ) { + source + .with( "filtered_users", function( cte ) { + cte.from( "users" ).where( "active", 1 ); + } ) + .from( "filtered_users" ) + .where( "id", 42 ); + } ); + + expect( builder.toSQL() ).toBe( + ";WITH [filtered_users] AS (SELECT * FROM [users] WHERE [active] = ?) SELECT * FROM (SELECT * FROM [filtered_users] WHERE [id] = ?) AS [active_users]" + ); + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 1, 42 ] ); + } ); + + it( "hoists common table expressions outside predicate subqueries", function() { + var builder = getBuilder() + .from( "accounts" ) + .whereExists( function( source ) { + source + .with( "active_users", function( cte ) { + cte.from( "users" ).where( "active", 1 ); + } ) + .from( "active_users" ) + .whereColumn( "active_users.id", "accounts.userId" ); + } ); + + expect( builder.toSQL() ).toBe( + ";WITH [active_users] AS (SELECT * FROM [users] WHERE [active] = ?) SELECT * FROM [accounts] WHERE EXISTS (SELECT * FROM [active_users] WHERE [active_users].[id] = [accounts].[userId])" + ); + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 1 ] ); + } ); + + it( "hoists common table expressions outside derived joins", function() { + var builder = getBuilder() + .from( "accounts" ) + .joinSub( + "active_users", + function( source ) { + source + .with( "filtered_users", function( cte ) { + cte.from( "users" ).where( "active", 1 ); + } ) + .from( "filtered_users" ); + }, + "accounts.userId", + "=", + "active_users.id" + ); + + expect( builder.toSQL() ).toBe( + ";WITH [filtered_users] AS (SELECT * FROM [users] WHERE [active] = ?) SELECT * FROM [accounts] INNER JOIN (SELECT * FROM [filtered_users]) AS [active_users] ON [accounts].[userId] = [active_users].[id]" + ); + } ); + + it( "rolls back hoisted CTEs when a derived join is deduplicated", function() { + var builder = getBuilder().setPreventDuplicateJoins( true ).from( "accounts" ); + var addActiveUsers = function() { + builder.joinSub( + "active_users", + function( source ) { + source + .with( "filtered_users", function( cte ) { + cte.from( "users" ).where( "active", 1 ); + } ) + .from( "filtered_users" ); + }, + "accounts.userId", + "=", + "active_users.id" + ); + }; + + addActiveUsers(); + addActiveUsers(); + + expect( builder.getCommonTables() ).toHaveLength( 1 ); + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 1 ] ); + } ); + + it( "hoists common table expressions outside cross joins", function() { + var builder = getBuilder() + .from( "accounts" ) + .crossJoinSub( "active_users", function( source ) { + source + .with( "filtered_users", function( cte ) { + cte.from( "users" ).where( "active", 1 ); + } ) + .from( "filtered_users" ); + } ); + + expect( builder.toSQL() ).toBe( + ";WITH [filtered_users] AS (SELECT * FROM [users] WHERE [active] = ?) SELECT * FROM [accounts] CROSS JOIN (SELECT * FROM [filtered_users]) AS [active_users]" + ); + } ); + + it( "hoists common table expressions outside APPLY sources", function() { + var builder = getBuilder() + .from( "accounts" ) + .crossApply( "active_users", function( source ) { + source + .with( "filtered_users", function( cte ) { + cte.from( "users" ).where( "active", 1 ); + } ) + .from( "filtered_users" ); + } ); + + expect( builder.toSQL() ).toBe( + ";WITH [filtered_users] AS (SELECT * FROM [users] WHERE [active] = ?) SELECT * FROM [accounts] CROSS APPLY (SELECT * FROM [filtered_users]) AS [active_users]" + ); + } ); + + it( "hoists common table expressions outside insert sources", function() { + var builder = getBuilder().from( "archived_users" ); + var sql = builder.insertUsing( + columns = [ "id" ], + source = function( source ) { + source + .with( "active_users", function( cte ) { + cte.from( "users" ).where( "active", 1 ); + } ) + .select( "id" ) + .from( "active_users" ); + }, + toSQL = true + ); + + expect( sql ).toBe( + ";WITH [active_users] AS (SELECT * FROM [users] WHERE [active] = ?) INSERT INTO [archived_users] ([id]) SELECT [id] FROM [active_users]" + ); + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 1 ] ); + } ); + + it( "hoists common table expressions outside upsert sources", function() { + var builder = getBuilder().from( "users" ); + var sql = builder.upsert( + values = [ "id", "name" ], + target = [ "id" ], + update = [ "name" ], + source = function( source ) { + source + .with( "incoming_users", function( cte ) { + cte.from( "staged_users" ).where( "active", 1 ); + } ) + .select( "id, name" ) + .from( "incoming_users" ); + }, + toSQL = true + ); + + expect( sql ).toStartWith( + ";WITH [incoming_users] AS (SELECT * FROM [staged_users] WHERE [active] = ?) MERGE [users] AS [qb_target] USING (SELECT [id], [name] FROM [incoming_users]) AS [qb_src]" + ); + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 1 ] ); + } ); + + it( "hoists common table expressions outside scalar update subqueries", function() { + var builder = getBuilder().from( "users" ); + var sql = builder.update( + values = { + status: function( source ) { + source + .with( "latest_status", function( cte ) { + cte.from( "statuses" ).where( "active", 1 ); + } ) + .select( "name" ) + .from( "latest_status" ) + .limit( 1 ); + } + }, + toSQL = true + ); + + expect( sql ).toBe( + ";WITH [latest_status] AS (SELECT * FROM [statuses] WHERE [active] = ?) UPDATE [users] SET [STATUS] = (SELECT TOP (1) [name] FROM [latest_status])" + ); + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 1 ] ); + } ); + + it( "hoists common table expressions from upsert delete restrictions", function() { + var builder = getBuilder().from( "users" ); + var sql = builder.upsert( + values = { id: 42, name: "Jane" }, + target = [ "id" ], + update = [ "name" ], + deleteUnmatched = function( restrictions ) { + restrictions.whereExists( function( source ) { + source + .with( "protected_users", function( cte ) { + cte.from( "user_flags" ).where( "user_flags.protected", 1 ); + } ) + .from( "protected_users" ) + .whereColumn( "protected_users.userId", "qb_target.id" ); + } ); + }, + toSQL = true + ); + + expect( sql ).toStartWith( + ";WITH [protected_users] AS (SELECT * FROM [user_flags] WHERE [user_flags].[protected] = ?) MERGE [users] AS [qb_target]" + ); + expect( sql ).toInclude( + "WHEN NOT MATCHED BY SOURCE AND EXISTS (SELECT * FROM [protected_users] WHERE [protected_users].[userId] = [qb_target].[id]) THEN DELETE" + ); + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 1, 42, "Jane" ] ); + } ); + } ); } function selectAllColumns() { diff --git a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc index cbeb96b0..d1012fcd 100644 --- a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc @@ -75,6 +75,50 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { ); } ); } ); + + describe( "SQL Server CTE-backed schema queries", function() { + it( "creates views with a statement-level common table expression", function() { + var statements = getBuilder() + .createView( + "active_users", + function( query ) { + query + .with( "filtered_users", function( cte ) { + cte.from( "users" ).where( "active", 1 ); + } ) + .from( "filtered_users" ); + }, + {}, + false + ) + .toSQL(); + + expect( statements ).toBe( [ + "CREATE VIEW [active_users] AS WITH [filtered_users] AS (SELECT * FROM [users] WHERE [active] = ?) SELECT * FROM [filtered_users]" + ] ); + } ); + + it( "adds SELECT INTO to the outer query after a common table expression", function() { + var statements = getBuilder() + .createAs( + "active_users", + function( query ) { + query + .with( "filtered_users", function( cte ) { + cte.from( "users" ).where( "active", 1 ); + } ) + .from( "filtered_users" ); + }, + {}, + false + ) + .toSQL(); + + expect( statements ).toBe( [ + ";WITH [filtered_users] AS (SELECT * FROM [users] WHERE [active] = ?) SELECT * INTO [active_users] FROM [filtered_users]" + ] ); + } ); + } ); } function emptyTable() { From 6f0ca884accf6a443fadcae3b514e088b148a516 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 15 Aug 2026 16:50:59 -0600 Subject: [PATCH 029/119] fix: scope alter view bindings to creation --- models/Schema/SchemaBuilder.cfc | 27 +++++++++---------- .../Schema/SqlServerSchemaBuilderSpec.cfc | 16 +++++++++++ 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/models/Schema/SchemaBuilder.cfc b/models/Schema/SchemaBuilder.cfc index a2646d3d..5a319791 100644 --- a/models/Schema/SchemaBuilder.cfc +++ b/models/Schema/SchemaBuilder.cfc @@ -207,20 +207,19 @@ component accessors="true" { blueprint.setTable( qualifyTable( arguments.view ) ); if ( arguments.execute ) { - blueprint - .toSql() - .each( function( statement ) { - getGrammar().runQuery( - statement, - query.getBindings(), - options, - "result", - variables.pretending, - function( data ) { - variables.queryLog.append( data ); - } - ); - } ); + var statements = blueprint.toSql(); + statements.each( function( statement, index ) { + getGrammar().runQuery( + statement, + index == statements.len() ? query.getBindings() : [], + options, + "result", + variables.pretending, + function( data ) { + variables.queryLog.append( data ); + } + ); + } ); } return blueprint; diff --git a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc index d1012fcd..47ab05ed 100644 --- a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc @@ -119,6 +119,22 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { ] ); } ); } ); + + describe( "SQL Server view execution", function() { + it( "only binds the CREATE statement when altering a view", function() { + var schema = getBuilder(); + schema.getGrammar().$( "runQuery", {} ); + + schema.alterView( "active_users", function( query ) { + query.from( "users" ).where( "active", 1 ); + } ); + + var calls = schema.getGrammar().$callLog().runQuery; + expect( calls ).toHaveLength( 2 ); + expect( calls[ 1 ][ 2 ] ).toBeEmpty(); + expect( calls[ 2 ][ 2 ].map( ( binding ) => binding.value ) ).toBe( [ 1 ] ); + } ); + } ); } function emptyTable() { From edcd3d09e71e287b9076e721bbc71a9ed04d31ce Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 15 Aug 2026 17:25:33 -0600 Subject: [PATCH 030/119] fix: harden dialect parsing edge cases --- models/Grammars/BaseGrammar.cfc | 90 ++++++++++++------- models/Grammars/SqlServerGrammar.cfc | 47 +++++++--- models/Query/QueryBuilder.cfc | 15 +++- models/Query/QueryUtils.cfc | 63 ++++++++++++- .../specs/Query/Abstract/BuilderAliasSpec.cfc | 38 ++++++++ tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 46 ++++++++++ .../Schema/SqlServerSchemaBuilderSpec.cfc | 15 ++++ 7 files changed, 264 insertions(+), 50 deletions(-) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 2c139a4c..f9a917ef 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -1378,16 +1378,16 @@ component displayname="Grammar" accessors="true" singleton { var parts = { "alias": "", "table": trim( arguments.table ) }; // Quick check to see if we should bother to use a regex to look for a table alias - if ( parts.table.find( " " ) ) { + if ( reFind( "\s", parts.table ) ) { var matches = reFindNoCase( - "(.*?)(?:\s(?:AS\s){0,1})([^\)]+)$", + "^(.+?)\s+(?:AS\s+)?(\S+)\s*$", parts.table, 1, true ); - if ( matches.pos.len() >= 3 ) { - parts.alias = mid( parts.table, matches.pos[ 3 ], matches.len[ 3 ] ); - parts.table = mid( parts.table, matches.pos[ 2 ], matches.len[ 2 ] ); + if ( matches.pos.len() >= 3 && matches.pos[ 1 ] > 0 ) { + parts.alias = trim( mid( parts.table, matches.pos[ 3 ], matches.len[ 3 ] ) ); + parts.table = trim( mid( parts.table, matches.pos[ 2 ], matches.len[ 2 ] ) ); } } @@ -1417,23 +1417,9 @@ component displayname="Grammar" accessors="true" singleton { : jsonSql; } - arguments.column = trim( arguments.column.value ); - var alias = ""; - if ( arguments.column.findNoCase( " as " ) > 0 ) { - var matches = reFindNoCase( - "(.*)(?:\sAS\s)(.*)", - arguments.column, - 1, - true - ); - if ( matches.pos.len() >= 3 ) { - alias = mid( arguments.column, matches.pos[ 3 ], matches.len[ 3 ] ); - arguments.column = mid( arguments.column, matches.pos[ 2 ], matches.len[ 2 ] ); - } - } else if ( arguments.column.findNoCase( " " ) > 0 ) { - alias = listGetAt( arguments.column, 2, " " ); - arguments.column = listGetAt( arguments.column, 1, " " ); - } + var columnParts = explodeColumnAlias( arguments.column.value ); + arguments.column = columnParts.column; + var alias = columnParts.alias; arguments.column = arguments.column .listToArray( "." ) .map( wrapValue ) @@ -1548,22 +1534,62 @@ component displayname="Grammar" accessors="true" singleton { arguments.column = trim( arguments.column.value ); } - var alias = ""; - if ( arguments.column.findNoCase( " as " ) > 0 ) { - var matches = reFindNoCase( - "(.*)(?:\sAS\s)(.*)", - arguments.column, + var columnParts = explodeColumnAlias( arguments.column ); + if ( columnParts.alias != "" ) { + return columnParts.alias; + } + + return listLast( columnParts.column, "." ); + } + + /** + * Splits a column expression from a trailing explicit or implicit alias. + */ + private struct function explodeColumnAlias( required string column ) { + var parts = { "alias": "", "column": trim( arguments.column ) }; + var matches = reFindNoCase( + "^(.+?)\s+AS\s+(.+?)\s*$", + parts.column, + 1, + true + ); + + if ( matches.pos.len() < 3 || matches.pos[ 1 ] == 0 ) { + matches = reFind( + "^(.+?)\s+([^\s]+)\s*$", + parts.column, 1, true ); - if ( matches.pos.len() >= 3 ) { - return mid( arguments.column, matches.pos[ 3 ], matches.len[ 3 ] ); + } + + if ( matches.pos.len() >= 3 && matches.pos[ 1 ] > 0 ) { + var alias = trim( mid( parts.column, matches.pos[ 3 ], matches.len[ 3 ] ) ); + if ( isValidColumnAlias( alias ) ) { + parts.alias = alias; + parts.column = trim( mid( parts.column, matches.pos[ 2 ], matches.len[ 2 ] ) ); } - } else if ( arguments.column.findNoCase( " " ) > 0 ) { - return listLast( arguments.column, " " ); } - return listLast( arguments.column, "." ); + return parts; + } + + /** + * Rejects parenthesized SQL fragments mistaken for trailing aliases. + */ + private boolean function isValidColumnAlias( required string alias ) { + if ( len( arguments.alias ) >= 2 ) { + var firstCharacter = left( arguments.alias, 1 ); + var lastCharacter = right( arguments.alias, 1 ); + if ( + ( firstCharacter == """" && lastCharacter == """" ) || + ( firstCharacter == chr( 96 ) && lastCharacter == chr( 96 ) ) || + ( firstCharacter == "[" && lastCharacter == "]" ) + ) { + return true; + } + } + return !reFind( "[()]", arguments.alias ); } /** diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index 51c9e7cf..c25d122a 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -878,14 +878,23 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } /** - * Inserts a SQL Server INTO clause before the outer query's FROM clause. - * FROM tokens inside CTEs, subqueries, literals, identifiers, and comments are ignored. + * Inserts a SQL Server INTO clause after the outer query's select list. + * Tokens inside CTEs, subqueries, literals, identifiers, and comments are ignored. */ private string function insertIntoOuterSelect( required string sql, required string table ) { var position = 1; var depth = 0; var state = "sql"; var sqlLength = arguments.sql.len(); + var hasOuterSelect = false; + var selectListBoundaries = [ + " FROM ", + " UNION ", + " ORDER BY ", + " OFFSET ", + " FOR ", + " OPTION " + ]; while ( position <= sqlLength ) { var character = arguments.sql.mid( position, 1 ); @@ -947,20 +956,36 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { position++; continue; } - if ( - depth == 0 && - position + 5 <= sqlLength && - compareNoCase( arguments.sql.mid( position, 6 ), " FROM " ) == 0 - ) { - return arguments.sql.left( position - 1 ) & - " INTO #arguments.table#" & - mid( arguments.sql, position, sqlLength - position + 1 ); + + if ( depth == 0 && !hasOuterSelect && position + 5 <= sqlLength ) { + var previousCharacter = position == 1 ? "" : arguments.sql.mid( position - 1, 1 ); + var characterAfterSelect = position + 6 > sqlLength ? "" : arguments.sql.mid( position + 6, 1 ); + hasOuterSelect = compareNoCase( arguments.sql.mid( position, 6 ), "SELECT" ) == 0 && + ( previousCharacter == "" || previousCharacter == ";" || reFind( "\s", previousCharacter ) ) && + ( characterAfterSelect == "" || reFind( "\s", characterAfterSelect ) ); + } + + if ( depth == 0 && hasOuterSelect ) { + for ( var boundary in selectListBoundaries ) { + if ( + position + len( boundary ) - 1 <= sqlLength && + compareNoCase( arguments.sql.mid( position, len( boundary ) ), boundary ) == 0 + ) { + return arguments.sql.left( position - 1 ) & + " INTO #arguments.table#" & + mid( arguments.sql, position, sqlLength - position + 1 ); + } + } } position++; } - throw( type = "InvalidCreateAsQuery", message = "SQL Server CREATE AS queries require an outer FROM clause." ); + if ( hasOuterSelect ) { + return rTrim( arguments.sql ) & " INTO #arguments.table#"; + } + + throw( type = "InvalidCreateAsQuery", message = "SQL Server CREATE AS queries require an outer SELECT clause." ); } function compileDropColumn( blueprint, commandParameters ) { diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 4e4d0c8d..c95a9d6c 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -867,10 +867,17 @@ component displayname="QueryBuilder" accessors="true" { } private void function parseIntoTableAndAlias( required string table ) { - var parts = arguments.table.split( "\s(?:[Aa][Ss]\s)?" ); - variables.tableName = trim( parts[ 1 ] ); - if ( arrayLen( parts ) > 1 ) { - variables.alias = trim( parts[ 2 ] ); + var normalizedTable = trim( arguments.table ); + var aliasMatch = reFindNoCase( + "^(.+?)\s+(?:AS\s+)?(\S+)\s*$", + normalizedTable, + 1, + true + ); + variables.tableName = normalizedTable; + if ( aliasMatch.pos.len() >= 3 && aliasMatch.pos[ 1 ] > 0 ) { + variables.tableName = trim( mid( normalizedTable, aliasMatch.pos[ 2 ], aliasMatch.len[ 2 ] ) ); + variables.alias = trim( mid( normalizedTable, aliasMatch.pos[ 3 ], aliasMatch.len[ 3 ] ) ); } } diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 9cf28a89..30242913 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -166,10 +166,19 @@ component singleton displayname="QueryUtils" accessors="true" { arguments.grammar, "qb.models.Grammars.PostgresGrammar" ); + var isOracle = !isNull( arguments.grammar ) && isInstanceOf( + arguments.grammar, + "qb.models.Grammars.OracleGrammar" + ); + var isSQLite = !isNull( arguments.grammar ) && isInstanceOf( + arguments.grammar, + "qb.models.Grammars.SQLiteGrammar" + ); var isSqlServer = !isNull( arguments.grammar ) && isInstanceOf( arguments.grammar, "qb.models.Grammars.SqlServerGrammar" ); + var oracleQuoteClosing = ""; while ( position <= sqlLength ) { var character = mid( arguments.sql, position, 1 ); @@ -211,6 +220,18 @@ component singleton displayname="QueryUtils" accessors="true" { continue; } + if ( state == "oracleQuote" ) { + if ( mid( arguments.sql, position, len( oracleQuoteClosing ) ) == oracleQuoteClosing ) { + output.append( oracleQuoteClosing ); + position += len( oracleQuoteClosing ); + state = "sql"; + } else { + output.append( character ); + position++; + } + continue; + } + if ( state != "sql" ) { output.append( character ); if ( @@ -240,7 +261,14 @@ component singleton displayname="QueryUtils" accessors="true" { continue; } - if ( character == "-" && nextCharacter == "-" ) { + var startsLineComment = character == "-" && + nextCharacter == "-" && + ( + !isMySQL || + position + 2 > sqlLength || + asc( mid( arguments.sql, position + 2, 1 ) ) <= 32 + ); + if ( startsLineComment ) { output.append( character ); output.append( nextCharacter ); position += 2; @@ -282,7 +310,36 @@ component singleton displayname="QueryUtils" accessors="true" { } } - if ( character == "'" || character == """" || character == chr( 96 ) || ( isSqlServer && character == "[" ) ) { + if ( + isOracle && + ( character == "q" || character == "Q" ) && + nextCharacter == "'" && + position + 2 <= sqlLength + ) { + var oracleQuoteOpening = mid( arguments.sql, position + 2, 1 ); + var oracleQuotePairs = { + "[": "]", + "{": "}", + "(": ")", + "<": ">" + }; + oracleQuoteClosing = ( + oracleQuotePairs.keyExists( oracleQuoteOpening ) + ? oracleQuotePairs[ oracleQuoteOpening ] + : oracleQuoteOpening + ) & "'"; + output.append( mid( arguments.sql, position, 3 ) ); + position += 3; + state = "oracleQuote"; + continue; + } + + if ( + character == "'" || + character == """" || + character == chr( 96 ) || + ( ( isSqlServer || isSQLite ) && character == "[" ) + ) { output.append( character ); state = character == "'" ? "singleQuote" : ( character == """" ? "doubleQuote" : ( character == chr( 96 ) ? "backtickQuote" : "bracketQuote" ) @@ -562,7 +619,7 @@ component singleton displayname="QueryUtils" accessors="true" { // Includes quick check for a "(" to avoid the regex to look for the subquery pattern if possible return isSimpleValue( arguments.value ) && arguments.value.find( "(" ) && - arguments.value.reFindNoCase( "^\s*\(.+\)(\s|\sAS\s){0,1}[^\(\s]*\s*$" ); + arguments.value.reFindNoCase( "(?s)^\s*\(.+\)(?:\s+AS\s+|\s*)[^\(\s]*\s*$" ); } /** diff --git a/tests/specs/Query/Abstract/BuilderAliasSpec.cfc b/tests/specs/Query/Abstract/BuilderAliasSpec.cfc index f5b5c37d..42ed0c43 100644 --- a/tests/specs/Query/Abstract/BuilderAliasSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderAliasSpec.cfc @@ -2,6 +2,44 @@ component extends="testbox.system.BaseSpec" { function run() { describe( "builder alias", () => { + it( "parses table aliases separated by repeated whitespace", () => { + var qb = new qb.models.Query.QueryBuilder(); + + qb.from( "users AS u" ).select( "u.id" ); + + expect( qb.getTableName() ).toBe( "users" ); + expect( qb.getAlias() ).toBe( "u" ); + expect( qb.toSQL() ).toBe( "SELECT ""u"".""id"" FROM ""users"" AS ""u""" ); + } ); + + it( "parses join aliases separated by repeated whitespace", () => { + var qb = new qb.models.Query.QueryBuilder(); + + qb.from( "users AS u" ).join( "profiles AS p", "p.userId", "u.id" ); + + expect( qb.toSQL() ).toBe( + "SELECT * FROM ""users"" AS ""u"" INNER JOIN ""profiles"" AS ""p"" ON ""p"".""userId"" = ""u"".""id""" + ); + } ); + + it( "parses join aliases separated by tabs", () => { + var qb = new qb.models.Query.QueryBuilder(); + + qb.from( "users AS u" ).join( "profiles#chr( 9 )#AS#chr( 9 )#p", "p.userId", "u.id" ); + + expect( qb.toSQL() ).toBe( + "SELECT * FROM ""users"" AS ""u"" INNER JOIN ""profiles"" AS ""p"" ON ""p"".""userId"" = ""u"".""id""" + ); + } ); + + it( "parses column aliases separated by repeated whitespace", () => { + var qb = new qb.models.Query.QueryBuilder(); + + qb.from( "users" ).select( "users.id AS userId" ); + + expect( qb.toSQL() ).toBe( "SELECT ""users"".""id"" AS ""userId"" FROM ""users""" ); + } ); + describe( "columns", () => { it( "it renames aliases in the select clause", () => { var qb = new qb.models.Query.QueryBuilder(); diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index 679260a3..ca865dc3 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -259,6 +259,12 @@ component extends="testbox.system.BaseSpec" { } ); } ); + describe( "isSubQuery()", function() { + it( "recognizes aliases separated by repeated whitespace", function() { + expect( utils.isSubQuery( "(SELECT id FROM users) AS activeUsers" ) ).toBeTrue(); + } ); + } ); + describe( "replaceBindings()", function() { it( "only replaces parameter placeholders in executable SQL", function() { var binding = utils.extractBinding( 42, variables.mockGrammar ); @@ -301,6 +307,19 @@ component extends="testbox.system.BaseSpec" { ).toBe( "SELECT * FROM records WHERE id = 42 ## why?#chr( 10 )#" ); } ); + it( "replaces question marks after MySQL double minus operators", function() { + var binding = utils.extractBinding( 42, variables.mockGrammar ); + + expect( + utils.replaceBindings( + "SELECT 10--? AS result", + [ binding ], + true, + new qb.models.Grammars.MySQLGrammar() + ) + ).toBe( "SELECT 10--42 AS result" ); + } ); + it( "does not treat MySQL dollar-delimited identifiers as PostgreSQL dollar quotes", function() { var binding = utils.extractBinding( 42, variables.mockGrammar ); @@ -335,6 +354,19 @@ component extends="testbox.system.BaseSpec" { ).toBe( "SELECT [why?] FROM records WHERE id = 42" ); } ); + it( "preserves question marks in SQLite bracketed identifiers", function() { + var binding = utils.extractBinding( 42, variables.mockGrammar ); + + expect( + utils.replaceBindings( + "SELECT [why?] FROM records WHERE id = ?", + [ binding ], + true, + new qb.models.Grammars.SQLiteGrammar() + ) + ).toBe( "SELECT [why?] FROM records WHERE id = 42" ); + } ); + it( "preserves question marks in escaped string literals", function() { var binding = utils.extractBinding( 42, variables.mockGrammar ); @@ -343,6 +375,20 @@ component extends="testbox.system.BaseSpec" { ).toBe( "SELECT 'isn''t ?' AS marker FROM users WHERE id = 42" ); } ); + it( "preserves question marks in Oracle alternative quoted literals", function() { + var binding = utils.extractBinding( 42, variables.mockGrammar ); + var oracleGrammar = new qb.models.Grammars.OracleGrammar( utils ); + + expect( + utils.replaceBindings( + "SELECT q'[isn't ? -- /* a placeholder */]' AS marker FROM users WHERE id = ?", + [ binding ], + true, + oracleGrammar + ) + ).toBe( "SELECT q'[isn't ? -- /* a placeholder */]' AS marker FROM users WHERE id = 42" ); + } ); + it( "rejects bindings without matching placeholders", function() { var binding = utils.extractBinding( 42, variables.mockGrammar ); diff --git a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc index 47ab05ed..7e9a71e4 100644 --- a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc @@ -77,6 +77,21 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { } ); describe( "SQL Server CTE-backed schema queries", function() { + it( "creates a table from a projection without a FROM clause", function() { + var statements = getBuilder() + .createAs( + "answer", + function( query ) { + query.selectRaw( "42 AS value" ); + }, + {}, + false + ) + .toSQL(); + + expect( statements ).toBe( [ "SELECT 42 AS value INTO [answer]" ] ); + } ); + it( "creates views with a statement-level common table expression", function() { var statements = getBuilder() .createView( From 9eb8067229eecc0f863e8f657508121ea06f9e76 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 15 Aug 2026 18:17:54 -0600 Subject: [PATCH 031/119] fix: preserve Adobe ColdFusion compatibility --- models/Grammars/BaseGrammar.cfc | 2 +- models/Grammars/MySQLGrammar.cfc | 4 +- models/Grammars/OracleGrammar.cfc | 2 +- models/Grammars/PostgresGrammar.cfc | 4 +- models/Query/QueryBuilder.cfc | 52 ++++++-- models/Query/QueryUtils.cfc | 14 ++- models/Query/ReturnFormatterRegistry.cfc | 12 +- models/SQLCommenter/SQLCommenter.cfc | 2 +- models/Schema/TableIndex.cfc | 16 +-- tests/resources/AbstractQueryBuilderSpec.cfc | 6 +- .../specs/Query/Abstract/BuilderWhereSpec.cfc | 113 ++++++++++++------ .../Query/Abstract/QueryExecutionSpec.cfc | 3 +- tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 19 ++- .../specs/Query/SqlServerQueryBuilderSpec.cfc | 6 +- tests/specs/Schema/BlueprintLifecycleSpec.cfc | 5 +- 15 files changed, 173 insertions(+), 87 deletions(-) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index f9a917ef..baff7485 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -1469,7 +1469,7 @@ component displayname="Grammar" accessors="true" singleton { /** * Allows grammars to serialize containment bindings where required. */ - public any function prepareJsonContainsBinding( required any value ) { + public any function prepareJsonContainsBinding( any value ) { if ( !isNull( arguments.value ) && !isSimpleValue( arguments.value ) ) { throw( type = "UnsupportedOperation", diff --git a/models/Grammars/MySQLGrammar.cfc b/models/Grammars/MySQLGrammar.cfc index 4ca46171..99340bd9 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -53,8 +53,8 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { return "JSON_LENGTH(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#')"; } - public any function prepareJsonContainsBinding( required any value ) { - return serializeJSON( arguments.value ); + public any function prepareJsonContainsBinding( any value ) { + return isNull( arguments.value ) ? "null" : serializeJSON( arguments.value ); } private string function orderByRandom() { diff --git a/models/Grammars/OracleGrammar.cfc b/models/Grammars/OracleGrammar.cfc index 874a55f0..9d67e069 100644 --- a/models/Grammars/OracleGrammar.cfc +++ b/models/Grammars/OracleGrammar.cfc @@ -40,7 +40,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { return "JSON_EXISTS(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#[*]?(@ == $value)' PASSING ? AS ""value"")"; } - public any function prepareJsonContainsBinding( required any value ) { + public any function prepareJsonContainsBinding( any value ) { if ( isNull( arguments.value ) ) { return { "value": "", diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index 67ecabbf..ba74bb33 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -60,8 +60,8 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { return "JSONB_ARRAY_LENGTH((#compilePostgresJsonTraversal( arguments.jsonPath, false )#)::jsonb)"; } - public any function prepareJsonContainsBinding( required any value ) { - return serializeJSON( arguments.value ); + public any function prepareJsonContainsBinding( any value ) { + return isNull( arguments.value ) ? "null" : serializeJSON( arguments.value ); } private string function compilePostgresJsonTraversal( required struct jsonPath, boolean scalar = false ) { diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index c95a9d6c..58450901 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -2250,10 +2250,23 @@ component displayname="QueryBuilder" accessors="true" { combinator: arguments.combinator, negate: arguments.negate } ); - addBindings( - utils.extractBinding( variables.grammar.prepareJsonContainsBinding( arguments.value ), variables.grammar ), - "where" - ); + if ( isNull( arguments.value ) ) { + var preparedNullValue = variables.grammar.prepareJsonContainsBinding(); + addBindings( + isNull( preparedNullValue ) + ? utils.extractBinding( grammar = variables.grammar ) + : utils.extractBinding( preparedNullValue, variables.grammar ), + "where" + ); + } else { + addBindings( + utils.extractBinding( + variables.grammar.prepareJsonContainsBinding( arguments.value ), + variables.grammar + ), + "where" + ); + } return this; } @@ -2501,13 +2514,23 @@ component displayname="QueryBuilder" accessors="true" { guardAgainstInvalidCombinator( arguments.combinator ); arguments.values = normalizeToArray( arguments.values ); - if ( arguments.values.some( getUtils().isExpression ) ) { - throw( type = "InvalidBulkValue", message = "Bulk IN values cannot contain SQL expressions." ); + var extractedBindings = []; + if ( !arguments.values.isEmpty() ) { + arrayResize( extractedBindings, arguments.values.len() ); + } + for ( var valueIndex = 1; valueIndex <= arguments.values.len(); valueIndex++ ) { + if ( !arrayIsDefined( arguments.values, valueIndex ) || isNull( arguments.values[ valueIndex ] ) ) { + extractedBindings[ valueIndex ] = getUtils().extractBinding( grammar = variables.grammar ); + continue; + } + if ( getUtils().isExpression( arguments.values[ valueIndex ] ) ) { + throw( type = "InvalidBulkValue", message = "Bulk IN values cannot contain SQL expressions." ); + } + extractedBindings[ valueIndex ] = getUtils().extractBinding( + arguments.values[ valueIndex ], + variables.grammar + ); } - - var extractedBindings = arguments.values.map( function( value ) { - return getUtils().extractBinding( arguments.value, variables.grammar ); - } ); if ( isNull( arguments.sqlType ) ) { arguments.sqlType = variables.grammar.resolveWhereInBulkSqlType( @@ -5317,6 +5340,9 @@ component displayname="QueryBuilder" accessors="true" { } private any function cloneQueryStateValue( any value ) { + if ( isSimpleValue( arguments.value ) ) { + return arguments.value; + } if ( isNull( arguments.value ) ) { return javacast( "null", "" ); } @@ -5334,9 +5360,11 @@ component displayname="QueryBuilder" accessors="true" { } if ( isArray( arguments.value ) ) { var clonedArray = []; - arrayResize( clonedArray, arguments.value.len() ); + if ( !arguments.value.isEmpty() ) { + arrayResize( clonedArray, arguments.value.len() ); + } for ( var i = 1; i <= arguments.value.len(); i++ ) { - if ( !isNull( arguments.value[ i ] ) ) { + if ( arrayIsDefined( arguments.value, i ) && !isNull( arguments.value[ i ] ) ) { clonedArray[ i ] = cloneQueryStateValue( arguments.value[ i ] ); } } diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 30242913..c1466568 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -294,7 +294,7 @@ component singleton displayname="QueryUtils" accessors="true" { if ( ( isNull( arguments.grammar ) || isPostgres ) && character == "$" ) { var dollarQuoteMatch = reFind( "^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$", - mid( arguments.sql, position ), + mid( arguments.sql, position, sqlLength - position + 1 ), 1, true ); @@ -463,7 +463,7 @@ component singleton displayname="QueryUtils" accessors="true" { if ( isArray( value ) ) { var inferredTypes = []; for ( var i = 1; i <= arguments.value.len(); i++ ) { - if ( isNull( arguments.value[ i ] ) ) { + if ( !arrayIsDefined( arguments.value, i ) || isNull( arguments.value[ i ] ) ) { continue; } var item = arguments.value[ i ]; @@ -973,8 +973,14 @@ component singleton displayname="QueryUtils" accessors="true" { // Loop through the elements and compare them one at a time for ( var i = 1; local.i lte arrayLen( LeftArray ); local.i = local.i + 1 ) { - var leftIsNull = isNull( arguments.LeftArray[ i ] ); - var rightIsNull = isNull( arguments.RightArray[ i ] ); + var leftIsNull = !arrayIsDefined( arguments.LeftArray, i ); + var rightIsNull = !arrayIsDefined( arguments.RightArray, i ); + if ( !leftIsNull ) { + leftIsNull = isNull( arguments.LeftArray[ i ] ); + } + if ( !rightIsNull ) { + rightIsNull = isNull( arguments.RightArray[ i ] ); + } if ( leftIsNull || rightIsNull ) { if ( leftIsNull != rightIsNull ) { return false; diff --git a/models/Query/ReturnFormatterRegistry.cfc b/models/Query/ReturnFormatterRegistry.cfc index bacff6d1..021a4392 100644 --- a/models/Query/ReturnFormatterRegistry.cfc +++ b/models/Query/ReturnFormatterRegistry.cfc @@ -151,11 +151,15 @@ component accessors="true" singleton { if ( isArray( arguments.value ) ) { var clonedArray = []; - arrayResize( clonedArray, arguments.value.len() ); + if ( !arguments.value.isEmpty() ) { + arrayResize( clonedArray, arguments.value.len() ); + } for ( var i = 1; i <= arguments.value.len(); i++ ) { - clonedArray[ i ] = isNull( arguments.value[ i ] ) - ? javacast( "null", "" ) - : cloneConfigurationValue( arguments.value[ i ] ); + if ( arrayIsDefined( arguments.value, i ) ) { + clonedArray[ i ] = isNull( arguments.value[ i ] ) + ? javacast( "null", "" ) + : cloneConfigurationValue( arguments.value[ i ] ); + } } return clonedArray; } diff --git a/models/SQLCommenter/SQLCommenter.cfc b/models/SQLCommenter/SQLCommenter.cfc index 6ccfbbfb..05a95eb1 100644 --- a/models/SQLCommenter/SQLCommenter.cfc +++ b/models/SQLCommenter/SQLCommenter.cfc @@ -152,7 +152,7 @@ component singleton { if ( character == "$" ) { var dollarQuoteMatch = reFind( "^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$", - mid( arguments.sql, position ), + mid( arguments.sql, position, sqlLength - position + 1 ), 1, true ); diff --git a/models/Schema/TableIndex.cfc b/models/Schema/TableIndex.cfc index da6bd66f..27c54c81 100644 --- a/models/Schema/TableIndex.cfc +++ b/models/Schema/TableIndex.cfc @@ -3,14 +3,6 @@ */ component accessors="true" { - variables.validReferentialActions = [ - "RESTRICT", - "CASCADE", - "SET NULL", - "NO ACTION", - "SET DEFAULT" - ]; - /** * The constraint type. */ @@ -52,6 +44,14 @@ component accessors="true" { */ property name="onDeleteAction" default="NO ACTION"; + variables.validReferentialActions = [ + "RESTRICT", + "CASCADE", + "SET NULL", + "NO ACTION", + "SET DEFAULT" + ]; + /** * Create a new TableIndex instance. * diff --git a/tests/resources/AbstractQueryBuilderSpec.cfc b/tests/resources/AbstractQueryBuilderSpec.cfc index d300c022..df15a220 100644 --- a/tests/resources/AbstractQueryBuilderSpec.cfc +++ b/tests/resources/AbstractQueryBuilderSpec.cfc @@ -375,8 +375,10 @@ component extends="testbox.system.BaseSpec" { } ); it( "snapshots a builder passed to a sub-select", function() { - var child = getBuilder().from( "posts" ).selectRaw( "MAX(updated_date)" ); - var builder = getBuilder().from( "users" ).subSelect( "latestUpdatedDate", child ); + var child = getBuilder(); + child.from( "posts" ).selectRaw( "MAX(updated_date)" ); + var builder = getBuilder(); + builder.from( "users" ).subSelect( "latestUpdatedDate", child ); child.where( "posts.user_id", 1 ); diff --git a/tests/specs/Query/Abstract/BuilderWhereSpec.cfc b/tests/specs/Query/Abstract/BuilderWhereSpec.cfc index 90a17516..cee1e83f 100644 --- a/tests/specs/Query/Abstract/BuilderWhereSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderWhereSpec.cfc @@ -93,32 +93,43 @@ component extends="testbox.system.BaseSpec" { } ); it( "does not retain raw column bindings for empty IN predicates", function() { - var cases = [ - { apply: ( builder, column ) => builder.whereIn( column, [] ), predicate: "0 = 1" }, - { apply: ( builder, column ) => builder.whereNotIn( column, [] ), predicate: "1 = 1" }, - { apply: ( builder, column ) => builder.whereInBulk( column, [] ), predicate: "0 = 1" }, - { apply: ( builder, column ) => builder.whereNotInBulk( column, [] ), predicate: "1 = 1" } - ]; - - cases.each( function( testCase ) { - var builder = new qb.models.Query.QueryBuilder().from( "users" ); - testCase.apply( builder, builder.raw( "COALESCE(?, id)", [ 99 ] ) ); - - expect( builder.toSQL( showBindings = true ) ).toBe( - "SELECT * FROM ""users"" WHERE #testCase.predicate#" - ); - expect( builder.getBindings() ).toBeEmpty(); - } ); + var whereInBuilder = new qb.models.Query.QueryBuilder().from( "users" ); + whereInBuilder.whereIn( whereInBuilder.raw( "COALESCE(?, id)", [ 99 ] ), [] ); + expect( whereInBuilder.toSQL( showBindings = true ) ).toBe( + "SELECT * FROM ""users"" WHERE 0 = 1" + ); + expect( whereInBuilder.getBindings() ).toBeEmpty(); + + var whereNotInBuilder = new qb.models.Query.QueryBuilder().from( "users" ); + whereNotInBuilder.whereNotIn( whereNotInBuilder.raw( "COALESCE(?, id)", [ 99 ] ), [] ); + expect( whereNotInBuilder.toSQL( showBindings = true ) ).toBe( + "SELECT * FROM ""users"" WHERE 1 = 1" + ); + expect( whereNotInBuilder.getBindings() ).toBeEmpty(); + + var whereInBulkBuilder = new qb.models.Query.QueryBuilder().from( "users" ); + whereInBulkBuilder.whereInBulk( whereInBulkBuilder.raw( "COALESCE(?, id)", [ 99 ] ), [] ); + expect( whereInBulkBuilder.toSQL( showBindings = true ) ).toBe( + "SELECT * FROM ""users"" WHERE 0 = 1" + ); + expect( whereInBulkBuilder.getBindings() ).toBeEmpty(); + + var whereNotInBulkBuilder = new qb.models.Query.QueryBuilder().from( "users" ); + whereNotInBulkBuilder.whereNotInBulk( + whereNotInBulkBuilder.raw( "COALESCE(?, id)", [ 99 ] ), + [] + ); + expect( whereNotInBulkBuilder.toSQL( showBindings = true ) ).toBe( + "SELECT * FROM ""users"" WHERE 1 = 1" + ); + expect( whereNotInBulkBuilder.getBindings() ).toBeEmpty(); } ); it( "treats null query parameter structs as BETWEEN bindings", function() { + var nullQueryParam = { "value": javacast( "null", "" ), "cfsqltype": "INTEGER", "null": true }; var builder = new qb.models.Query.QueryBuilder() .from( "users" ) - .whereBetween( - "age", - { value: javacast( "null", "" ), cfsqltype: "INTEGER", null: true }, - 10 - ); + .whereBetween( "age", nullQueryParam, 10 ); expect( builder.toSQL() ).toBe( "SELECT * FROM ""users"" WHERE ""age"" BETWEEN ? AND ?" ); expect( builder.getBindings()[ 1 ].null ).toBeTrue(); @@ -195,28 +206,52 @@ component extends="testbox.system.BaseSpec" { } ); it( "validates combinators for every where clause type", function() { - var invalidCalls = [ - ( builder ) => builder.whereIn( "id", [ 1 ], "xor" ), - ( builder ) => builder.whereInBulk( + var expectInvalidCombinator = function( invalidCall ) { + expect( function() { + invalidCall( new qb.models.Query.QueryBuilder() ); + } ).toThrow( type = "InvalidSQLType", regex = "Illegal combinator" ); + }; + + expectInvalidCombinator( function( builder ) { + builder.whereIn( "id", [ 1 ], "xor" ); + } ); + expectInvalidCombinator( function( builder ) { + builder.whereInBulk( "id", [ 1 ], javacast( "null", "" ), "xor" - ), - ( builder ) => builder.whereRaw( "1 = 1", [], "xor" ), - ( builder ) => builder.whereColumn( "id", "=", "otherId", "xor" ), - ( builder ) => builder.whereExists( ( query ) => query.from( "users" ), "xor" ), - ( builder ) => builder.whereNested( ( query ) => query.where( "id", 1 ), "xor" ), - ( builder ) => builder.addNestedWhereQuery( builder.newQuery().where( "id", 1 ), "xor" ), - ( builder ) => builder.whereNull( "deletedDate", "xor" ), - ( builder ) => builder.whereNullSub( ( query ) => query.select( "deletedDate" ).from( "users" ), "xor" ), - ( builder ) => builder.whereBetween( "id", 1, 2, "xor" ) - ]; - - invalidCalls.each( function( invalidCall ) { - expect( function() { - invalidCall( new qb.models.Query.QueryBuilder() ); - } ).toThrow( type = "InvalidSQLType", regex = "Illegal combinator" ); + ); + } ); + expectInvalidCombinator( function( builder ) { + builder.whereRaw( "1 = 1", [], "xor" ); + } ); + expectInvalidCombinator( function( builder ) { + builder.whereColumn( "id", "=", "otherId", "xor" ); + } ); + expectInvalidCombinator( function( builder ) { + builder.whereExists( function( query ) { + query.from( "users" ); + }, "xor" ); + } ); + expectInvalidCombinator( function( builder ) { + builder.whereNested( function( query ) { + query.where( "id", 1 ); + }, "xor" ); + } ); + expectInvalidCombinator( function( builder ) { + builder.addNestedWhereQuery( builder.newQuery().where( "id", 1 ), "xor" ); + } ); + expectInvalidCombinator( function( builder ) { + builder.whereNull( "deletedDate", "xor" ); + } ); + expectInvalidCombinator( function( builder ) { + builder.whereNullSub( function( query ) { + query.select( "deletedDate" ).from( "users" ); + }, "xor" ); + } ); + expectInvalidCombinator( function( builder ) { + builder.whereBetween( "id", 1, 2, "xor" ); } ); } ); diff --git a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc index dc12779b..749c470c 100644 --- a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc +++ b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc @@ -1569,7 +1569,8 @@ component extends="testbox.system.BaseSpec" { shouldMaxRowsOverrideToAll = shouldMaxRowsOverrideToAll ); - [ builder.newQuery(), builder.clone() ].each( function( derivedBuilder ) { + var derivedBuilders = [ builder.newQuery(), builder.clone() ]; + derivedBuilders.each( function( derivedBuilder ) { expect( derivedBuilder.getPreventDuplicateJoins() ).toBeTrue(); $assert.isSameInstance( sqlCommenter, derivedBuilder.getSqlCommenter() ); $assert.isSameInstance( shouldMaxRowsOverrideToAll, derivedBuilder.getShouldMaxRowsOverrideToAll() ); diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index ca865dc3..05edc0ea 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -80,11 +80,10 @@ component extends="testbox.system.BaseSpec" { } ); it( "does not format null temporal query parameters", function() { - [ "DATE", "TIME", "TIMESTAMP" ].each( function( sqlType ) { - var binding = utils.extractBinding( - { value: javacast( "null", "" ), cfsqltype: sqlType, null: true }, - variables.mockGrammar - ); + var temporalTypes = [ "DATE", "TIME", "TIMESTAMP" ]; + temporalTypes.each( function( sqlType ) { + var queryParam = { "value": javacast( "null", "" ), "cfsqltype": sqlType, "null": true }; + var binding = utils.extractBinding( queryParam, variables.mockGrammar ); expect( binding.null ).toBeTrue(); expect( binding.cfsqltype ).toBe( sqlType ); @@ -600,6 +599,16 @@ component extends="testbox.system.BaseSpec" { expect( queryTwo.toSql( showBindings = "inline" ) ).toBe( queryOne.toSql( showBindings = "inline" ) ); } ); + it( "preserves null predicate types as strings", function() { + var cloned = new qb.models.Query.QueryBuilder() + .from( "users" ) + .whereNull( "deletedAt" ) + .clone(); + + expect( cloned.getWheres()[ 1 ].type ).toBe( "null" ); + expect( cloned.toSQL() ).toBe( "SELECT * FROM ""users"" WHERE ""deletedAt"" IS NULL" ); + } ); + it( "does not share mutable query clauses with the original", function() { var original = new qb.models.Query.QueryBuilder() .from( "users AS u" ) diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index 9e060c9d..dfaf0f8d 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -33,9 +33,9 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { } ); it( "infers bulk SQL types from negative and nullable values", function() { - var insertSql = getBuilder() - .from( "measurements" ) - .insertBulk( values = [ { "reading": -1 }, { "reading": javacast( "null", "" ) } ], toSql = true ); + var insertValues = [ { "reading": -1 }, { "reading": javacast( "null", "" ) } ]; + var insertBuilder = getBuilder().from( "measurements" ); + var insertSql = insertBuilder.insertBulk( values = insertValues, toSql = true ); var whereSql = getBuilder() .from( "measurements" ) .whereInBulk( "reading", [ -1, javacast( "null", "" ), 2 ] ) diff --git a/tests/specs/Schema/BlueprintLifecycleSpec.cfc b/tests/specs/Schema/BlueprintLifecycleSpec.cfc index d279d499..b590357d 100644 --- a/tests/specs/Schema/BlueprintLifecycleSpec.cfc +++ b/tests/specs/Schema/BlueprintLifecycleSpec.cfc @@ -3,11 +3,12 @@ component extends="testbox.system.BaseSpec" { function run() { describe( "Blueprint lifecycle", function() { it( "restores indexes when add-column compilation throws", function() { - [ + var grammars = [ new qb.models.Grammars.BaseGrammar(), new qb.models.Grammars.DerbyGrammar(), new qb.models.Grammars.OracleGrammar() - ].each( function( grammar ) { + ]; + grammars.each( function( grammar ) { var blueprint = newBlueprint( grammar ); blueprint.appendIndex( type = "basic", columns = [ "email" ], name = "idx_users_email" ); From f6e63d811ef3f332cb44b788d4fcad5381a4f647 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 15 Aug 2026 18:27:04 -0600 Subject: [PATCH 032/119] fix: handle omitted null JSON bindings --- models/Grammars/BaseGrammar.cfc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index baff7485..9e5ee465 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -1470,6 +1470,9 @@ component displayname="Grammar" accessors="true" singleton { * Allows grammars to serialize containment bindings where required. */ public any function prepareJsonContainsBinding( any value ) { + if ( isNull( arguments.value ) ) { + return javacast( "null", "" ); + } if ( !isNull( arguments.value ) && !isSimpleValue( arguments.value ) ) { throw( type = "UnsupportedOperation", From 7ac2ca6d95460bdc35259e07fe3a7a08709f7cf9 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 15 Aug 2026 18:43:39 -0600 Subject: [PATCH 033/119] ci: exclude unsupported Adobe full-null checks --- .github/workflows/cron.yml | 7 +++++++ .github/workflows/pr.yml | 7 +++++++ .github/workflows/release.yml | 7 +++++++ 3 files changed, 21 insertions(+) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 80de344f..99e534c6 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -14,6 +14,13 @@ jobs: cfengine: ["lucee@5", "lucee@6", "adobe@2021", "adobe@2023", "adobe@2025", "boxlang@1", "boxlang-cfml@1"] experimental: [ false ] fullNull: ["true", "false"] + exclude: + - cfengine: "adobe@2021" + fullNull: "true" + - cfengine: "adobe@2023" + fullNull: "true" + - cfengine: "adobe@2025" + fullNull: "true" include: - cfengine: "adobe@be" experimental: true diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9e9c6e95..72c07b39 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -22,6 +22,13 @@ jobs: cfengine: ["lucee@5", "lucee@6", "adobe@2021", "adobe@2023", "adobe@2025", "boxlang@1", "boxlang-cfml@1"] experimental: [ false ] fullNull: ["true", "false"] + exclude: + - cfengine: "adobe@2021" + fullNull: "true" + - cfengine: "adobe@2023" + fullNull: "true" + - cfengine: "adobe@2025" + fullNull: "true" include: - cfengine: "adobe@be" experimental: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd40ef1f..c34a7566 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,13 @@ jobs: cfengine: ["lucee@5", "lucee@6", "adobe@2021", "adobe@2023", "adobe@2025", "boxlang@1", "boxlang-cfml@1"] experimental: [ false ] fullNull: ["true", "false"] + exclude: + - cfengine: "adobe@2021" + fullNull: "true" + - cfengine: "adobe@2023" + fullNull: "true" + - cfengine: "adobe@2025" + fullNull: "true" steps: - name: Checkout Repository uses: actions/checkout@v7 From fd77bb4556348b3ac4294847bbec0f4e9cd64bb4 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 15 Aug 2026 19:01:09 -0600 Subject: [PATCH 034/119] ci: increase stable BoxLang test heap --- server-boxlang-cfml@1.json | 2 +- server-boxlang@1.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server-boxlang-cfml@1.json b/server-boxlang-cfml@1.json index d6f176a2..f2c94548 100644 --- a/server-boxlang-cfml@1.json +++ b/server-boxlang-cfml@1.json @@ -13,7 +13,7 @@ } }, "JVM":{ - "heapSize":"1024", + "heapSize":"2048", "javaVersion":"openjdk21_jre", "args":"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8889" }, diff --git a/server-boxlang@1.json b/server-boxlang@1.json index 04c32863..48581f5b 100644 --- a/server-boxlang@1.json +++ b/server-boxlang@1.json @@ -13,7 +13,7 @@ } }, "JVM":{ - "heapSize":"1024", + "heapSize":"2048", "javaVersion":"openjdk21_jre", "args":"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8888" }, From d46bcb3d908a24211539a0c1c6885d753e90bd74 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 15 Aug 2026 19:17:31 -0600 Subject: [PATCH 035/119] ci: use compact TestBox reports --- .github/workflows/cron.yml | 2 +- .github/workflows/pr.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 99e534c6..3763e3e6 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -57,4 +57,4 @@ jobs: env: FULL_NULL: ${{matrix.fullNull}} continue-on-error: ${{ matrix.experimental }} - run: box testbox run + run: box testbox run reporter=mintext diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 72c07b39..dd663d16 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -65,7 +65,7 @@ jobs: env: FULL_NULL: ${{matrix.fullNull}} continue-on-error: ${{ matrix.experimental }} - run: box testbox run + run: box testbox run reporter=mintext format: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c34a7566..6377dddc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,7 +55,7 @@ jobs: env: FULL_NULL: ${{matrix.fullNull}} continue-on-error: ${{ matrix.experimental }} - run: box testbox run + run: box testbox run reporter=mintext release: name: Semantic Release From 504ffa4dfc35bb3d8c60e50342fde2d8d5ab3bac Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 15 Aug 2026 19:43:56 -0600 Subject: [PATCH 036/119] fix(QueryBuilder): keep generated methods within JVM limits --- models/Query/JsonQueryBuilderSupport.cfc | 247 +++++++++++++++++++++++ models/Query/QueryBuilder.cfc | 223 +------------------- 2 files changed, 248 insertions(+), 222 deletions(-) create mode 100644 models/Query/JsonQueryBuilderSupport.cfc diff --git a/models/Query/JsonQueryBuilderSupport.cfc b/models/Query/JsonQueryBuilderSupport.cfc new file mode 100644 index 00000000..631e3727 --- /dev/null +++ b/models/Query/JsonQueryBuilderSupport.cfc @@ -0,0 +1,247 @@ +/** + * JSON query builder methods split from QueryBuilder to keep component + * metadata within JVM method-size limits on all supported CFML engines. + */ +component { + + /** + * Creates a grammar-aware JSON scalar path expression. + * + * The explicit form accepts a column and an array of path segments. Arrow + * syntax is accepted as a shortcut and is normalized to the same shape. + * Numeric path segments address JSON array indexes. + * + * @column The JSON column, or an arrow path such as `profile->name`. + * @path The JSON object keys and array indexes to traverse. + * @alias An optional select alias. + * + * @return A typed column definition understood by each grammar. + */ + public struct function jsonPath( required string column, array path = [], string alias ) { + var parsedColumn = trim( arguments.column ); + var parsedAlias = structKeyExists( arguments, "alias" ) ? arguments.alias : ""; + + var aliasMatch = reFindNoCase( + "(.*)(?:\sAS\s)(.*)", + parsedColumn, + 1, + true + ); + if ( aliasMatch.pos.len() >= 3 && aliasMatch.pos[ 1 ] > 0 ) { + parsedAlias = trim( mid( parsedColumn, aliasMatch.pos[ 3 ], aliasMatch.len[ 3 ] ) ); + parsedColumn = trim( mid( parsedColumn, aliasMatch.pos[ 2 ], aliasMatch.len[ 2 ] ) ); + } + + var arrowParts = listToArray( parsedColumn, "->", false, true ); + if ( arrowParts.len() > 1 ) { + if ( !arguments.path.isEmpty() ) { + throw( + type = "QBInvalidJsonPath", + message = "JSON paths cannot combine arrow syntax with an explicit path array." + ); + } + parsedColumn = trim( arrowParts.shift() ); + arguments.path = arrowParts.map( ( segment ) => normalizeJsonPathSegment( segment ) ); + } else { + arguments.path = arguments.path.map( ( segment ) => segment ); + } + + var definition = { + type: "jsonPath", + value: { column: variables.columnFormatter( parsedColumn ), path: arguments.path } + }; + if ( len( parsedAlias ) ) { + definition.alias = parsedAlias; + } + return definition; + } + + /** + * Normalizes an arrow-syntax JSON path segment for grammar compilation. + * Numeric shortcut segments are converted to numbers so grammars can + * distinguish JSON array indexes from object keys. Explicit path segments + * preserve their CFML types and do not pass through this function. + * + * @segment The shortcut JSON object key or array index to normalize. + * + * @return The trimmed object key or numeric array index. + */ + private any function normalizeJsonPathSegment( required any segment ) { + var normalized = trim( arguments.segment ); + return reFind( "^\d+$", normalized ) ? val( normalized ) : normalized; + } + + /** + * Adds a JSON containment predicate. + * + * Explicit: `whereJsonContains( "profile", [ "languages" ], "en" )` + * Shortcut: `whereJsonContains( "profile->languages", "en" )` + */ + public QueryBuilder function whereJsonContains( + required string column, + any path = [], + any value, + string combinator = "and", + boolean negate = false + ) { + if ( this.getValidateOperatorsAndCombinators() && isInvalidJsonCombinator( arguments.combinator ) ) { + throw( type = "InvalidSQLType", message = "Illegal combinator" ); + } + if ( + !arguments.keyExists( "value" ) || + ( isNull( arguments.value ) && arguments.column.find( "->" ) > 0 ) + ) { + arguments.value = arguments.path; + arguments.path = []; + } + var containsPath = jsonPath( column = arguments.column, path = arguments.path ); + containsPath.value.nullValue = isNull( arguments.value ); + variables.wheres.append( { + type: "jsonContains", + path: containsPath, + combinator: arguments.combinator, + negate: arguments.negate + } ); + if ( isNull( arguments.value ) ) { + var preparedNullValue = variables.grammar.prepareJsonContainsBinding(); + addBindings( + isNull( preparedNullValue ) + ? utils.extractBinding( grammar = variables.grammar ) + : utils.extractBinding( preparedNullValue, variables.grammar ), + "where" + ); + } else { + addBindings( + utils.extractBinding( + variables.grammar.prepareJsonContainsBinding( arguments.value ), + variables.grammar + ), + "where" + ); + } + return this; + } + + public QueryBuilder function orWhereJsonContains( required string column, any path = [], any value ) { + return whereJsonContains( argumentCollection = arguments, combinator = "or" ); + } + + public QueryBuilder function whereJsonDoesntContain( + required string column, + any path = [], + any value + ) { + return whereJsonContains( argumentCollection = arguments, negate = true ); + } + + public QueryBuilder function orWhereJsonDoesntContain( + required string column, + any path = [], + any value + ) { + return whereJsonContains( argumentCollection = arguments, combinator = "or", negate = true ); + } + + /** + * Adds a JSON path existence predicate. + * + * Explicit: `whereJsonExists( "profile", [ "name" ] )` + * Shortcut: `whereJsonExists( "profile->name" )` + */ + public QueryBuilder function whereJsonExists( + required string column, + array path = [], + string combinator = "and", + boolean negate = false + ) { + if ( this.getValidateOperatorsAndCombinators() && isInvalidJsonCombinator( arguments.combinator ) ) { + throw( type = "InvalidSQLType", message = "Illegal combinator" ); + } + variables.wheres.append( { + type: "jsonExists", + path: jsonPath( column = arguments.column, path = arguments.path ), + combinator: arguments.combinator, + negate: arguments.negate + } ); + return this; + } + + public QueryBuilder function orWhereJsonExists( required string column, array path = [] ) { + return whereJsonExists( argumentCollection = arguments, combinator = "or" ); + } + + public QueryBuilder function whereJsonDoesntExist( required string column, array path = [] ) { + return whereJsonExists( argumentCollection = arguments, negate = true ); + } + + public QueryBuilder function orWhereJsonDoesntExist( required string column, array path = [] ) { + return whereJsonExists( argumentCollection = arguments, combinator = "or", negate = true ); + } + + /** + * Adds a JSON array length predicate. + * + * Explicit: `whereJsonLength( "profile", [ "languages" ], ">", 1 )` + * Shortcut: `whereJsonLength( "profile->languages", ">", 1 )` + * Explicit equality shortcut: `whereJsonLength( "profile", [ "languages" ], 1 )` + * Arrow equality shortcut: `whereJsonLength( "profile->languages", 1 )` + */ + public QueryBuilder function whereJsonLength( + required string column, + any path = [], + any operator, + any value, + string combinator = "and" + ) { + if ( this.getValidateOperatorsAndCombinators() && isInvalidJsonCombinator( arguments.combinator ) ) { + throw( type = "InvalidSQLType", message = "Illegal combinator" ); + } + if ( isNull( arguments.operator ) ) { + if ( isNull( arguments.value ) ) { + arguments.value = arguments.path; + arguments.path = []; + } + arguments.operator = "="; + } else if ( isNull( arguments.value ) ) { + arguments.value = arguments.operator; + if ( isArray( arguments.path ) ) { + arguments.operator = "="; + } else { + arguments.operator = arguments.path; + arguments.path = []; + } + } + if ( this.getValidateOperatorsAndCombinators() && isInvalidJsonOperator( arguments.operator ) ) { + throw( type = "InvalidSQLType", message = "Illegal operator" ); + } + variables.wheres.append( { + type: "jsonLength", + path: jsonPath( column = arguments.column, path = arguments.path ), + operator: arguments.operator, + combinator: arguments.combinator + } ); + addBindings( utils.extractBinding( arguments.value, variables.grammar ), "where" ); + return this; + } + + public QueryBuilder function orWhereJsonLength( + required string column, + any path = [], + any operator, + any value + ) { + return whereJsonLength( argumentCollection = arguments, combinator = "or" ); + } + + private boolean function isInvalidJsonOperator( required any operator ) { + if ( isNull( arguments.operator ) || !isSimpleValue( arguments.operator ) ) { + return true; + } + return !arrayContains( variables.operators, lCase( arguments.operator ) ); + } + + private boolean function isInvalidJsonCombinator( required string combinator ) { + return !arrayContains( variables.combinators, uCase( arguments.combinator ) ); + } + +} diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 58450901..88e85ef1 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -1,7 +1,7 @@ /** * Query Builder for fluently creating SQL queries. */ -component displayname="QueryBuilder" accessors="true" { +component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.JsonQueryBuilderSupport" { /** * The specific grammar that will compile the builder statements. @@ -629,73 +629,6 @@ component displayname="QueryBuilder" accessors="true" { return lCase( normalizedName ); } - /** - * Creates a grammar-aware JSON scalar path expression. - * - * The explicit form accepts a column and an array of path segments. Arrow - * syntax is accepted as a shortcut and is normalized to the same shape. - * Numeric path segments address JSON array indexes. - * - * @column The JSON column, or an arrow path such as `profile->name`. - * @path The JSON object keys and array indexes to traverse. - * @alias An optional select alias. - * - * @return A typed column definition understood by each grammar. - */ - public struct function jsonPath( required string column, array path = [], string alias ) { - var parsedColumn = trim( arguments.column ); - var parsedAlias = structKeyExists( arguments, "alias" ) ? arguments.alias : ""; - - var aliasMatch = reFindNoCase( - "(.*)(?:\sAS\s)(.*)", - parsedColumn, - 1, - true - ); - if ( aliasMatch.pos.len() >= 3 && aliasMatch.pos[ 1 ] > 0 ) { - parsedAlias = trim( mid( parsedColumn, aliasMatch.pos[ 3 ], aliasMatch.len[ 3 ] ) ); - parsedColumn = trim( mid( parsedColumn, aliasMatch.pos[ 2 ], aliasMatch.len[ 2 ] ) ); - } - - var arrowParts = listToArray( parsedColumn, "->", false, true ); - if ( arrowParts.len() > 1 ) { - if ( !arguments.path.isEmpty() ) { - throw( - type = "QBInvalidJsonPath", - message = "JSON paths cannot combine arrow syntax with an explicit path array." - ); - } - parsedColumn = trim( arrowParts.shift() ); - arguments.path = arrowParts.map( ( segment ) => normalizeJsonPathSegment( segment ) ); - } else { - arguments.path = arguments.path.map( ( segment ) => segment ); - } - - var definition = { - type: "jsonPath", - value: { column: variables.columnFormatter( parsedColumn ), path: arguments.path } - }; - if ( len( parsedAlias ) ) { - definition.alias = parsedAlias; - } - return definition; - } - - /** - * Normalizes an arrow-syntax JSON path segment for grammar compilation. - * Numeric shortcut segments are converted to numbers so grammars can - * distinguish JSON array indexes from object keys. Explicit path segments - * preserve their CFML types and do not pass through this function. - * - * @segment The shortcut JSON object key or array index to normalize. - * - * @return The trimmed object key or numeric array index. - */ - private any function normalizeJsonPathSegment( required any segment ) { - var normalized = trim( arguments.segment ); - return reFind( "^\d+$", normalized ) ? val( normalized ) : normalized; - } - /** * Adds a sub-select to the query. * @@ -2219,160 +2152,6 @@ component displayname="QueryBuilder" accessors="true" { return this; } - /** - * Adds a JSON containment predicate. - * - * Explicit: `whereJsonContains( "profile", [ "languages" ], "en" )` - * Shortcut: `whereJsonContains( "profile->languages", "en" )` - */ - public QueryBuilder function whereJsonContains( - required string column, - any path = [], - any value, - string combinator = "and", - boolean negate = false - ) { - if ( this.getValidateOperatorsAndCombinators() && isInvalidCombinator( arguments.combinator ) ) { - throw( type = "InvalidSQLType", message = "Illegal combinator" ); - } - if ( - !arguments.keyExists( "value" ) || - ( isNull( arguments.value ) && arguments.column.find( "->" ) > 0 ) - ) { - arguments.value = arguments.path; - arguments.path = []; - } - var containsPath = jsonPath( column = arguments.column, path = arguments.path ); - containsPath.value.nullValue = isNull( arguments.value ); - variables.wheres.append( { - type: "jsonContains", - path: containsPath, - combinator: arguments.combinator, - negate: arguments.negate - } ); - if ( isNull( arguments.value ) ) { - var preparedNullValue = variables.grammar.prepareJsonContainsBinding(); - addBindings( - isNull( preparedNullValue ) - ? utils.extractBinding( grammar = variables.grammar ) - : utils.extractBinding( preparedNullValue, variables.grammar ), - "where" - ); - } else { - addBindings( - utils.extractBinding( - variables.grammar.prepareJsonContainsBinding( arguments.value ), - variables.grammar - ), - "where" - ); - } - return this; - } - - public QueryBuilder function orWhereJsonContains( required string column, any path = [], any value ) { - return whereJsonContains( argumentCollection = arguments, combinator = "or" ); - } - - public QueryBuilder function whereJsonDoesntContain( required string column, any path = [], any value ) { - return whereJsonContains( argumentCollection = arguments, negate = true ); - } - - public QueryBuilder function orWhereJsonDoesntContain( required string column, any path = [], any value ) { - return whereJsonContains( argumentCollection = arguments, combinator = "or", negate = true ); - } - - /** - * Adds a JSON path existence predicate. - * - * Explicit: `whereJsonExists( "profile", [ "name" ] )` - * Shortcut: `whereJsonExists( "profile->name" )` - */ - public QueryBuilder function whereJsonExists( - required string column, - array path = [], - string combinator = "and", - boolean negate = false - ) { - if ( this.getValidateOperatorsAndCombinators() && isInvalidCombinator( arguments.combinator ) ) { - throw( type = "InvalidSQLType", message = "Illegal combinator" ); - } - variables.wheres.append( { - type: "jsonExists", - path: jsonPath( column = arguments.column, path = arguments.path ), - combinator: arguments.combinator, - negate: arguments.negate - } ); - return this; - } - - public QueryBuilder function orWhereJsonExists( required string column, array path = [] ) { - return whereJsonExists( argumentCollection = arguments, combinator = "or" ); - } - - public QueryBuilder function whereJsonDoesntExist( required string column, array path = [] ) { - return whereJsonExists( argumentCollection = arguments, negate = true ); - } - - public QueryBuilder function orWhereJsonDoesntExist( required string column, array path = [] ) { - return whereJsonExists( argumentCollection = arguments, combinator = "or", negate = true ); - } - - /** - * Adds a JSON array length predicate. - * - * Explicit: `whereJsonLength( "profile", [ "languages" ], ">", 1 )` - * Shortcut: `whereJsonLength( "profile->languages", ">", 1 )` - * Explicit equality shortcut: `whereJsonLength( "profile", [ "languages" ], 1 )` - * Arrow equality shortcut: `whereJsonLength( "profile->languages", 1 )` - */ - public QueryBuilder function whereJsonLength( - required string column, - any path = [], - any operator, - any value, - string combinator = "and" - ) { - if ( this.getValidateOperatorsAndCombinators() && isInvalidCombinator( arguments.combinator ) ) { - throw( type = "InvalidSQLType", message = "Illegal combinator" ); - } - if ( isNull( arguments.operator ) ) { - if ( isNull( arguments.value ) ) { - arguments.value = arguments.path; - arguments.path = []; - } - arguments.operator = "="; - } else if ( isNull( arguments.value ) ) { - arguments.value = arguments.operator; - if ( isArray( arguments.path ) ) { - arguments.operator = "="; - } else { - arguments.operator = arguments.path; - arguments.path = []; - } - } - if ( this.getValidateOperatorsAndCombinators() && isInvalidOperator( arguments.operator ) ) { - throw( type = "InvalidSQLType", message = "Illegal operator" ); - } - variables.wheres.append( { - type: "jsonLength", - path: jsonPath( column = arguments.column, path = arguments.path ), - operator: arguments.operator, - combinator: arguments.combinator - } ); - addBindings( utils.extractBinding( arguments.value, variables.grammar ), "where" ); - return this; - } - - public QueryBuilder function orWhereJsonLength( - required string column, - any path = [], - any operator, - any value - ) { - return whereJsonLength( argumentCollection = arguments, combinator = "or" ); - } - /** * Adds a WHERE clause to the query. * Alias for `where`. From 86af77e98888b1334045193812a8c6b955d4ea17 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 15 Aug 2026 19:57:00 -0600 Subject: [PATCH 037/119] fix(QueryBuilder): preserve explicit BoxLang JSON nulls --- models/Query/JsonQueryBuilderSupport.cfc | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/models/Query/JsonQueryBuilderSupport.cfc b/models/Query/JsonQueryBuilderSupport.cfc index 631e3727..9f8240e0 100644 --- a/models/Query/JsonQueryBuilderSupport.cfc +++ b/models/Query/JsonQueryBuilderSupport.cfc @@ -87,10 +87,12 @@ component { if ( this.getValidateOperatorsAndCombinators() && isInvalidJsonCombinator( arguments.combinator ) ) { throw( type = "InvalidSQLType", message = "Illegal combinator" ); } - if ( - !arguments.keyExists( "value" ) || - ( isNull( arguments.value ) && arguments.column.find( "->" ) > 0 ) - ) { + var valueWasOmitted = !arguments.keyExists( "value" ); + if ( server.keyExists( "boxlang" ) && isNull( arguments.value ) ) { + valueWasOmitted = !isArray( arguments.path ) || + ( arguments.column.find( "->" ) > 0 && !arguments.path.isEmpty() ); + } + if ( valueWasOmitted ) { arguments.value = arguments.path; arguments.path = []; } @@ -126,19 +128,11 @@ component { return whereJsonContains( argumentCollection = arguments, combinator = "or" ); } - public QueryBuilder function whereJsonDoesntContain( - required string column, - any path = [], - any value - ) { + public QueryBuilder function whereJsonDoesntContain( required string column, any path = [], any value ) { return whereJsonContains( argumentCollection = arguments, negate = true ); } - public QueryBuilder function orWhereJsonDoesntContain( - required string column, - any path = [], - any value - ) { + public QueryBuilder function orWhereJsonDoesntContain( required string column, any path = [], any value ) { return whereJsonContains( argumentCollection = arguments, combinator = "or", negate = true ); } From 1cddb7cb6899d0209cb4d19a1db4972ac7937797 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 15 Aug 2026 20:04:37 -0600 Subject: [PATCH 038/119] fix(QueryBuilder): normalize full-null JSON shortcuts --- models/Query/JsonQueryBuilderSupport.cfc | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/models/Query/JsonQueryBuilderSupport.cfc b/models/Query/JsonQueryBuilderSupport.cfc index 9f8240e0..1902d509 100644 --- a/models/Query/JsonQueryBuilderSupport.cfc +++ b/models/Query/JsonQueryBuilderSupport.cfc @@ -88,9 +88,12 @@ component { throw( type = "InvalidSQLType", message = "Illegal combinator" ); } var valueWasOmitted = !arguments.keyExists( "value" ); - if ( server.keyExists( "boxlang" ) && isNull( arguments.value ) ) { - valueWasOmitted = !isArray( arguments.path ) || + if ( isNull( arguments.value ) ) { + var pathCarriesValue = !isArray( arguments.path ) || ( arguments.column.find( "->" ) > 0 && !arguments.path.isEmpty() ); + valueWasOmitted = server.keyExists( "boxlang" ) + ? pathCarriesValue + : valueWasOmitted || pathCarriesValue; } if ( valueWasOmitted ) { arguments.value = arguments.path; From a3ef396ba04c77fe155651fe85627c3135f40772 Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Sat, 15 Aug 2026 20:10:25 -0600 Subject: [PATCH 039/119] feat(schema): add binary() column type to the schema builder (#327) Adds a grammar-agnostic Blueprint.binary() method so BLOB/binary columns no longer require a raw() expression. Maps to BLOB by default, with BYTEA on Postgres and VARBINARY(MAX) on SQL Server. Claude-Session: https://claude.ai/code/session_0132NWUQm9iZ3AALG4GDpTFh Co-authored-by: Claude --- models/Grammars/BaseGrammar.cfc | 4 ++++ models/Grammars/PostgresGrammar.cfc | 4 ++++ models/Grammars/SqlServerGrammar.cfc | 4 ++++ models/Schema/Blueprint.cfc | 5 +++++ tests/resources/AbstractSchemaBuilderSpec.cfc | 13 +++++++++++++ tests/specs/Schema/DerbySchemaBuilderSpec.cfc | 4 ++++ tests/specs/Schema/MySQLSchemaBuilderSpec.cfc | 4 ++++ tests/specs/Schema/OracleSchemaBuilderSpec.cfc | 4 ++++ tests/specs/Schema/PostgresSchemaBuilderSpec.cfc | 4 ++++ tests/specs/Schema/SQLiteSchemaBuilderSpec.cfc | 4 ++++ tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc | 4 ++++ 11 files changed, 54 insertions(+) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 9e5ee465..7b7bdea5 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -2078,6 +2078,10 @@ component displayname="Grammar" accessors="true" singleton { return concatenate( [ "BIGINT", isNull( column.getPrecision() ) ? "" : "(#column.getPrecision()#)" ], "" ); } + function typeBinary( column ) { + return "BLOB"; + } + function typeBit( column ) { return "BIT(#column.getLength()#)"; } diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index ba74bb33..ea662f7f 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -716,6 +716,10 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { = Column Types = ===================================*/ + function typeBinary( column ) { + return "BYTEA"; + } + function typeBoolean( column ) { return "BOOLEAN"; } diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index c25d122a..fa26f89e 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -1230,6 +1230,10 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { return "BIGINT"; } + function typeBinary( column ) { + return "VARBINARY(MAX)"; + } + function typeBit( column ) { return "BIT"; } diff --git a/models/Schema/Blueprint.cfc b/models/Schema/Blueprint.cfc index 12667f4a..b41b9189 100644 --- a/models/Schema/Blueprint.cfc +++ b/models/Schema/Blueprint.cfc @@ -52,6 +52,11 @@ component accessors="true" { return appendColumn( argumentCollection = arguments ); } + public Column function binary( required string name ) { + arguments.type = "binary"; + return appendColumn( argumentCollection = arguments ); + } + public Column function bit( required string name, numeric length = 1 ) { arguments.type = "bit"; return appendColumn( argumentCollection = arguments ); diff --git a/tests/resources/AbstractSchemaBuilderSpec.cfc b/tests/resources/AbstractSchemaBuilderSpec.cfc index 7fb7dd5c..e9d631d5 100644 --- a/tests/resources/AbstractSchemaBuilderSpec.cfc +++ b/tests/resources/AbstractSchemaBuilderSpec.cfc @@ -92,6 +92,19 @@ component extends="testbox.system.BaseSpec" { }, bigIntegerWithPrecision() ); } ); + it( "binary", function() { + testCase( function( schema ) { + return schema.create( + "users", + function( table ) { + table.binary( "avatar" ); + }, + {}, + false + ); + }, binary() ); + } ); + it( "bit", function() { testCase( function( schema ) { return schema.create( diff --git a/tests/specs/Schema/DerbySchemaBuilderSpec.cfc b/tests/specs/Schema/DerbySchemaBuilderSpec.cfc index 29d85ea3..9bf46ad8 100644 --- a/tests/specs/Schema/DerbySchemaBuilderSpec.cfc +++ b/tests/specs/Schema/DerbySchemaBuilderSpec.cfc @@ -49,6 +49,10 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { return [ "CREATE TABLE ""weather_reports"" (""temperature"" BIGINT NOT NULL)" ]; } + function binary() { + return [ "CREATE TABLE ""users"" (""avatar"" BLOB NOT NULL)" ]; + } + function bit() { return [ "CREATE TABLE ""users"" (""active"" CHAR(1) NOT NULL)" ]; } diff --git a/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc b/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc index d1c2f573..587d987f 100644 --- a/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc @@ -65,6 +65,10 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { return [ "CREATE TABLE `weather_reports` (`temperature` BIGINT(5) NOT NULL)" ]; } + function binary() { + return [ "CREATE TABLE `users` (`avatar` BLOB NOT NULL)" ]; + } + function bit() { return [ "CREATE TABLE `users` (`active` BIT(1) NOT NULL)" ]; } diff --git a/tests/specs/Schema/OracleSchemaBuilderSpec.cfc b/tests/specs/Schema/OracleSchemaBuilderSpec.cfc index bb0d3dfa..336b128c 100644 --- a/tests/specs/Schema/OracleSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/OracleSchemaBuilderSpec.cfc @@ -159,6 +159,10 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { return [ "CREATE TABLE ""WEATHER_REPORTS"" (""TEMPERATURE"" NUMBER(5, 0) NOT NULL)" ]; } + function binary() { + return [ "CREATE TABLE ""USERS"" (""AVATAR"" BLOB NOT NULL)" ]; + } + function bit() { return [ "CREATE TABLE ""USERS"" (""ACTIVE"" RAW NOT NULL)" ]; } diff --git a/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc b/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc index 0451f47e..1d5558e0 100644 --- a/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc @@ -91,6 +91,10 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { return [ "CREATE TABLE ""weather_reports"" (""temperature"" NUMERIC(5) NOT NULL)" ]; } + function binary() { + return [ "CREATE TABLE ""users"" (""avatar"" BYTEA NOT NULL)" ]; + } + function bit() { return [ "CREATE TABLE ""users"" (""active"" BIT(1) NOT NULL)" ]; } diff --git a/tests/specs/Schema/SQLiteSchemaBuilderSpec.cfc b/tests/specs/Schema/SQLiteSchemaBuilderSpec.cfc index 54484227..2609d759 100644 --- a/tests/specs/Schema/SQLiteSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/SQLiteSchemaBuilderSpec.cfc @@ -121,6 +121,10 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { return [ "CREATE TABLE ""weather_reports"" (""temperature"" BIGINT NOT NULL)" ]; } + function binary() { + return [ "CREATE TABLE ""users"" (""avatar"" BLOB NOT NULL)" ]; + } + function bit() { return [ "CREATE TABLE ""users"" (""active"" BOOLEAN NOT NULL)" ]; } diff --git a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc index 7e9a71e4..fc0caacb 100644 --- a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc @@ -178,6 +178,10 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { return [ "CREATE TABLE [weather_reports] ([temperature] NUMERIC(5) NOT NULL)" ]; } + function binary() { + return [ "CREATE TABLE [users] ([avatar] VARBINARY(MAX) NOT NULL)" ]; + } + function bit() { return [ "CREATE TABLE [users] ([active] BIT NOT NULL)" ]; } From 41efafa9aa18563e7493236329a8e20f3ec92855 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 15 Aug 2026 23:20:53 -0600 Subject: [PATCH 040/119] refactor(QueryBuilder): extract lazy collaborators --- models/Query/AliasRewriter.cfc | 401 +++++++ models/Query/JsonQueryBuilderSupport.cfc | 163 +-- models/Query/JsonQueryClause.cfc | 164 +++ models/Query/QueryBuilder.cfc | 1042 +++-------------- models/Query/QueryExecutor.cfc | 322 +++++ models/Query/QueryValidator.cfc | 196 ++++ .../QueryBuilderCollaboratorsSpec.cfc | 101 ++ .../Query/Abstract/QueryValidatorSpec.cfc | 49 + 8 files changed, 1470 insertions(+), 968 deletions(-) create mode 100644 models/Query/AliasRewriter.cfc create mode 100644 models/Query/JsonQueryClause.cfc create mode 100644 models/Query/QueryExecutor.cfc create mode 100644 models/Query/QueryValidator.cfc create mode 100644 tests/specs/Query/Abstract/QueryBuilderCollaboratorsSpec.cfc create mode 100644 tests/specs/Query/Abstract/QueryValidatorSpec.cfc diff --git a/models/Query/AliasRewriter.cfc b/models/Query/AliasRewriter.cfc new file mode 100644 index 00000000..dc231095 --- /dev/null +++ b/models/Query/AliasRewriter.cfc @@ -0,0 +1,401 @@ +/** + * Rewrites table aliases throughout a query graph without retaining query + * state between calls. + */ +component { + + public void function rewrite( required QueryBuilder builder, required string oldAlias, required string newAlias ) { + renameAliasesInColumns( argumentCollection = arguments ); + renameAliasesInJoins( argumentCollection = arguments ); + renameAliasesInWheres( argumentCollection = arguments ); + renameAliasesInGroups( argumentCollection = arguments ); + renameAliasesInHavings( argumentCollection = arguments ); + renameAliasesInOrders( argumentCollection = arguments ); + renameAliasesInUnions( argumentCollection = arguments ); + renameAliasesInCommonTables( argumentCollection = arguments ); + } + + private void function renameAliasesInUnions( + required QueryBuilder builder, + required string oldAlias, + required string newAlias + ) { + for ( var union in arguments.builder.getUnions() ) { + renameAliasesInNestedQuery( + arguments.builder, + union.query, + arguments.oldAlias, + arguments.newAlias + ); + } + } + + private void function renameAliasesInCommonTables( + required QueryBuilder builder, + required string oldAlias, + required string newAlias + ) { + for ( var commonTable in arguments.builder.getCommonTables() ) { + renameAliasesInNestedQuery( + arguments.builder, + commonTable.query, + arguments.oldAlias, + arguments.newAlias + ); + } + } + + private void function renameAliasesInNestedQuery( + required QueryBuilder builder, + required QueryBuilder query, + required string oldAlias, + required string newAlias + ) { + var nestedAlias = arguments.query.getAlias(); + var nestedTable = arguments.query.getTableName(); + var shadowsAlias = compareNoCase( nestedAlias, arguments.oldAlias ) == 0; + + if ( !shadowsAlias && nestedAlias == "" && isSimpleValue( nestedTable ) ) { + shadowsAlias = compareNoCase( listLast( nestedTable, "." ), arguments.oldAlias ) == 0; + } + + if ( !shadowsAlias ) { + arguments.query.renameAliases( arguments.oldAlias, arguments.newAlias ); + } + } + + private void function renameAliasesInColumns( + required QueryBuilder builder, + required string oldAlias, + required string newAlias + ) { + for ( var column in arguments.builder.getColumns() ) { + renameAliasInTypedColumn( column, arguments.oldAlias, arguments.newAlias ); + if ( column.type == "builder" ) { + renameAliasesInNestedQuery( + arguments.builder, + column.value, + arguments.oldAlias, + arguments.newAlias + ); + } + } + } + + private void function renameAliasesInJoins( + required QueryBuilder builder, + required string oldAlias, + required string newAlias + ) { + for ( var join in arguments.builder.getJoins() ) { + join.renameAliases( arguments.oldAlias, arguments.newAlias ); + } + } + + private void function renameAliasesInWheres( + required QueryBuilder builder, + required string oldAlias, + required string newAlias + ) { + for ( var where in arguments.builder.getWheres() ) { + var renameWhere = variables[ "renameAliasInWhere#where.type#" ]; + renameWhere( + builder = arguments.builder, + where = where, + oldAlias = arguments.oldAlias, + newAlias = arguments.newAlias + ); + } + } + + private void function renameAliasesInGroups( + required QueryBuilder builder, + required string oldAlias, + required string newAlias + ) { + for ( var column in arguments.builder.getGroups() ) { + renameAliasInTypedColumn( column, arguments.oldAlias, arguments.newAlias ); + } + } + + private void function renameAliasesInHavings( + required QueryBuilder builder, + required string oldAlias, + required string newAlias + ) { + for ( var having in arguments.builder.getHavings() ) { + if ( structKeyExists( having, "column" ) ) { + renameAliasInTypedColumn( having.column, arguments.oldAlias, arguments.newAlias ); + } + } + } + + private void function renameAliasesInOrders( + required QueryBuilder builder, + required string oldAlias, + required string newAlias + ) { + for ( var order in arguments.builder.getOrders() ) { + if ( order.keyExists( "query" ) ) { + renameAliasesInNestedQuery( + arguments.builder, + order.query, + arguments.oldAlias, + arguments.newAlias + ); + } else if ( order.keyExists( "column" ) && order.direction != "raw" ) { + renameAliasInTypedColumn( order.column, arguments.oldAlias, arguments.newAlias ); + } + } + } + + private void function renameAliasInTypedColumn( + required struct column, + required string oldAlias, + required string newAlias + ) { + if ( arguments.column.type == "simple" ) { + arguments.column.value = swapAlias( arguments.column.value, arguments.oldAlias, arguments.newAlias ); + } else if ( arguments.column.type == "jsonPath" ) { + arguments.column.value.column = swapAlias( + arguments.column.value.column, + arguments.oldAlias, + arguments.newAlias + ); + } + } + + private void function renameAliasInWhereBasic( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); + } + + private void function renameAliasInWhereJsonContains( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + arguments.where.path.value.column = swapAlias( + arguments.where.path.value.column, + arguments.oldAlias, + arguments.newAlias + ); + } + + private void function renameAliasInWhereJsonExists( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInWhereJsonContains( argumentCollection = arguments ); + } + + private void function renameAliasInWhereJsonLength( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInWhereJsonContains( argumentCollection = arguments ); + } + + private void function renameAliasInWhereColumn( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInTypedColumn( arguments.where.first, arguments.oldAlias, arguments.newAlias ); + renameAliasInTypedColumn( arguments.where.second, arguments.oldAlias, arguments.newAlias ); + } + + private void function renameAliasInWhereSub( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); + renameAliasesInNestedQuery( + arguments.builder, + arguments.where.query, + arguments.oldAlias, + arguments.newAlias + ); + } + + private void function renameAliasInWhereIn( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInWhereBasic( argumentCollection = arguments ); + } + + private void function renameAliasInWhereNotIn( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInWhereBasic( argumentCollection = arguments ); + } + + private void function renameAliasInWhereInBulk( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInWhereBasic( argumentCollection = arguments ); + } + + private void function renameAliasInWhereInSub( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); + renameAliasesInNestedQuery( + arguments.builder, + arguments.where.query, + arguments.oldAlias, + arguments.newAlias + ); + } + + private void function renameAliasInWhereNotInSub( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInWhereInSub( argumentCollection = arguments ); + } + + private void function renameAliasInWhereRaw( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + } + + private void function renameAliasInWhereExists( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasesInNestedQuery( + arguments.builder, + arguments.where.query, + arguments.oldAlias, + arguments.newAlias + ); + } + + private void function renameAliasInWhereNotExists( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInWhereExists( argumentCollection = arguments ); + } + + private void function renameAliasInWhereNested( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + arguments.where.query.renameAliases( arguments.oldAlias, arguments.newAlias ); + } + + private void function renameAliasInWhereNull( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInWhereBasic( argumentCollection = arguments ); + } + + private void function renameAliasInWhereNotNull( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInWhereBasic( argumentCollection = arguments ); + } + + private void function renameAliasInWhereNullSub( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInWhereExists( argumentCollection = arguments ); + } + + private void function renameAliasInWhereNotNullSub( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInWhereExists( argumentCollection = arguments ); + } + + private void function renameAliasInWhereBetween( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); + if ( arguments.builder.getUtils().isBuilder( arguments.where.start ) ) { + renameAliasesInNestedQuery( + arguments.builder, + arguments.where.start, + arguments.oldAlias, + arguments.newAlias + ); + } + if ( arguments.builder.getUtils().isBuilder( arguments.where.end ) ) { + renameAliasesInNestedQuery( + arguments.builder, + arguments.where.end, + arguments.oldAlias, + arguments.newAlias + ); + } + } + + private void function renameAliasInWhereNotBetween( + required QueryBuilder builder, + required struct where, + required string oldAlias, + required string newAlias + ) { + renameAliasInWhereBetween( argumentCollection = arguments ); + } + + private string function swapAlias( required string column, required string oldAlias, required string newAlias ) { + if ( left( arguments.column, len( arguments.oldAlias ) + 1 ) == arguments.oldAlias & "." ) { + return arguments.newAlias & "." & listLast( arguments.column, "." ); + } + return arguments.column; + } + +} diff --git a/models/Query/JsonQueryBuilderSupport.cfc b/models/Query/JsonQueryBuilderSupport.cfc index 1902d509..2f75be1f 100644 --- a/models/Query/JsonQueryBuilderSupport.cfc +++ b/models/Query/JsonQueryBuilderSupport.cfc @@ -1,15 +1,14 @@ /** - * JSON query builder methods split from QueryBuilder to keep component - * metadata within JVM method-size limits on all supported CFML engines. + * Exposes the public JSON builder API while delegating JSON definition work + * to the lazily instantiated JsonQueryClause collaborator. */ component { /** * Creates a grammar-aware JSON scalar path expression. * - * The explicit form accepts a column and an array of path segments. Arrow + * The explicit form accepts a column and an array of path segments. Arrow * syntax is accepted as a shortcut and is normalized to the same shape. - * Numeric path segments address JSON array indexes. * * @column The JSON column, or an arrow path such as `profile->name`. * @path The JSON object keys and array indexes to traverse. @@ -18,57 +17,12 @@ component { * @return A typed column definition understood by each grammar. */ public struct function jsonPath( required string column, array path = [], string alias ) { - var parsedColumn = trim( arguments.column ); - var parsedAlias = structKeyExists( arguments, "alias" ) ? arguments.alias : ""; - - var aliasMatch = reFindNoCase( - "(.*)(?:\sAS\s)(.*)", - parsedColumn, - 1, - true + return getCollaborator( "JsonQueryClause" ).jsonPath( + builder = this, + column = arguments.column, + path = arguments.path, + alias = arguments.keyExists( "alias" ) ? arguments.alias : "" ); - if ( aliasMatch.pos.len() >= 3 && aliasMatch.pos[ 1 ] > 0 ) { - parsedAlias = trim( mid( parsedColumn, aliasMatch.pos[ 3 ], aliasMatch.len[ 3 ] ) ); - parsedColumn = trim( mid( parsedColumn, aliasMatch.pos[ 2 ], aliasMatch.len[ 2 ] ) ); - } - - var arrowParts = listToArray( parsedColumn, "->", false, true ); - if ( arrowParts.len() > 1 ) { - if ( !arguments.path.isEmpty() ) { - throw( - type = "QBInvalidJsonPath", - message = "JSON paths cannot combine arrow syntax with an explicit path array." - ); - } - parsedColumn = trim( arrowParts.shift() ); - arguments.path = arrowParts.map( ( segment ) => normalizeJsonPathSegment( segment ) ); - } else { - arguments.path = arguments.path.map( ( segment ) => segment ); - } - - var definition = { - type: "jsonPath", - value: { column: variables.columnFormatter( parsedColumn ), path: arguments.path } - }; - if ( len( parsedAlias ) ) { - definition.alias = parsedAlias; - } - return definition; - } - - /** - * Normalizes an arrow-syntax JSON path segment for grammar compilation. - * Numeric shortcut segments are converted to numbers so grammars can - * distinguish JSON array indexes from object keys. Explicit path segments - * preserve their CFML types and do not pass through this function. - * - * @segment The shortcut JSON object key or array index to normalize. - * - * @return The trimmed object key or numeric array index. - */ - private any function normalizeJsonPathSegment( required any segment ) { - var normalized = trim( arguments.segment ); - return reFind( "^\d+$", normalized ) ? val( normalized ) : normalized; } /** @@ -84,9 +38,10 @@ component { string combinator = "and", boolean negate = false ) { - if ( this.getValidateOperatorsAndCombinators() && isInvalidJsonCombinator( arguments.combinator ) ) { - throw( type = "InvalidSQLType", message = "Illegal combinator" ); + if ( getValidateOperatorsAndCombinators() ) { + getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); } + var valueWasOmitted = !arguments.keyExists( "value" ); if ( isNull( arguments.value ) ) { var pathCarriesValue = !isArray( arguments.path ) || @@ -99,32 +54,20 @@ component { arguments.value = arguments.path; arguments.path = []; } - var containsPath = jsonPath( column = arguments.column, path = arguments.path ); - containsPath.value.nullValue = isNull( arguments.value ); - variables.wheres.append( { - type: "jsonContains", - path: containsPath, - combinator: arguments.combinator, - negate: arguments.negate - } ); - if ( isNull( arguments.value ) ) { - var preparedNullValue = variables.grammar.prepareJsonContainsBinding(); - addBindings( - isNull( preparedNullValue ) - ? utils.extractBinding( grammar = variables.grammar ) - : utils.extractBinding( preparedNullValue, variables.grammar ), - "where" - ); - } else { - addBindings( - utils.extractBinding( - variables.grammar.prepareJsonContainsBinding( arguments.value ), - variables.grammar - ), - "where" - ); + + var valueDefinition = { isNull: isNull( arguments.value ) }; + if ( !valueDefinition.isNull ) { + valueDefinition.value = arguments.value; } - return this; + + return getCollaborator( "JsonQueryClause" ).whereJsonContains( + builder = this, + column = arguments.column, + path = arguments.path, + valueDefinition = valueDefinition, + combinator = arguments.combinator, + negate = arguments.negate + ); } public QueryBuilder function orWhereJsonContains( required string column, any path = [], any value ) { @@ -151,16 +94,16 @@ component { string combinator = "and", boolean negate = false ) { - if ( this.getValidateOperatorsAndCombinators() && isInvalidJsonCombinator( arguments.combinator ) ) { - throw( type = "InvalidSQLType", message = "Illegal combinator" ); + if ( getValidateOperatorsAndCombinators() ) { + getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); } - variables.wheres.append( { - type: "jsonExists", - path: jsonPath( column = arguments.column, path = arguments.path ), - combinator: arguments.combinator, - negate: arguments.negate - } ); - return this; + return getCollaborator( "JsonQueryClause" ).whereJsonExists( + builder = this, + column = arguments.column, + path = arguments.path, + combinator = arguments.combinator, + negate = arguments.negate + ); } public QueryBuilder function orWhereJsonExists( required string column, array path = [] ) { @@ -190,8 +133,8 @@ component { any value, string combinator = "and" ) { - if ( this.getValidateOperatorsAndCombinators() && isInvalidJsonCombinator( arguments.combinator ) ) { - throw( type = "InvalidSQLType", message = "Illegal combinator" ); + if ( getValidateOperatorsAndCombinators() ) { + getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); } if ( isNull( arguments.operator ) ) { if ( isNull( arguments.value ) ) { @@ -208,17 +151,22 @@ component { arguments.path = []; } } - if ( this.getValidateOperatorsAndCombinators() && isInvalidJsonOperator( arguments.operator ) ) { - throw( type = "InvalidSQLType", message = "Illegal operator" ); + if ( getValidateOperatorsAndCombinators() ) { + getCollaborator( "QueryValidator" ).validateOperator( arguments.operator ); + } + + var valueDefinition = { isNull: isNull( arguments.value ) }; + if ( !valueDefinition.isNull ) { + valueDefinition.value = arguments.value; } - variables.wheres.append( { - type: "jsonLength", - path: jsonPath( column = arguments.column, path = arguments.path ), - operator: arguments.operator, - combinator: arguments.combinator - } ); - addBindings( utils.extractBinding( arguments.value, variables.grammar ), "where" ); - return this; + return getCollaborator( "JsonQueryClause" ).whereJsonLength( + builder = this, + column = arguments.column, + path = arguments.path, + operator = arguments.operator, + valueDefinition = valueDefinition, + combinator = arguments.combinator + ); } public QueryBuilder function orWhereJsonLength( @@ -230,15 +178,4 @@ component { return whereJsonLength( argumentCollection = arguments, combinator = "or" ); } - private boolean function isInvalidJsonOperator( required any operator ) { - if ( isNull( arguments.operator ) || !isSimpleValue( arguments.operator ) ) { - return true; - } - return !arrayContains( variables.operators, lCase( arguments.operator ) ); - } - - private boolean function isInvalidJsonCombinator( required string combinator ) { - return !arrayContains( variables.combinators, uCase( arguments.combinator ) ); - } - } diff --git a/models/Query/JsonQueryClause.cfc b/models/Query/JsonQueryClause.cfc new file mode 100644 index 00000000..092cff06 --- /dev/null +++ b/models/Query/JsonQueryClause.cfc @@ -0,0 +1,164 @@ +/** + * Builds JSON query definitions for a QueryBuilder without retaining builder + * state between calls. + */ +component { + + /** + * Creates a grammar-aware JSON scalar path expression. + */ + public struct function jsonPath( + required QueryBuilder builder, + required string column, + array path = [], + string alias = "" + ) { + var parsedColumn = trim( arguments.column ); + var parsedAlias = arguments.alias; + + var aliasMatch = reFindNoCase( + "(.*)(?:\sAS\s)(.*)", + parsedColumn, + 1, + true + ); + if ( aliasMatch.pos.len() >= 3 && aliasMatch.pos[ 1 ] > 0 ) { + parsedAlias = trim( mid( parsedColumn, aliasMatch.pos[ 3 ], aliasMatch.len[ 3 ] ) ); + parsedColumn = trim( mid( parsedColumn, aliasMatch.pos[ 2 ], aliasMatch.len[ 2 ] ) ); + } + + var arrowParts = listToArray( parsedColumn, "->", false, true ); + if ( arrowParts.len() > 1 ) { + if ( !arguments.path.isEmpty() ) { + throw( + type = "QBInvalidJsonPath", + message = "JSON paths cannot combine arrow syntax with an explicit path array." + ); + } + parsedColumn = trim( arrowParts.shift() ); + arguments.path = arrowParts.map( ( segment ) => normalizeJsonPathSegment( segment ) ); + } else { + arguments.path = arguments.path.map( ( segment ) => segment ); + } + + var definition = { + type: "jsonPath", + value: { column: arguments.builder.applyColumnFormatter( parsedColumn ), path: arguments.path } + }; + if ( len( parsedAlias ) ) { + definition.alias = parsedAlias; + } + return definition; + } + + /** + * Adds a normalized JSON containment predicate. + */ + public QueryBuilder function whereJsonContains( + required QueryBuilder builder, + required string column, + array path = [], + required struct valueDefinition, + string combinator = "and", + boolean negate = false + ) { + var containsPath = jsonPath( builder = arguments.builder, column = arguments.column, path = arguments.path ); + containsPath.value.nullValue = arguments.valueDefinition.isNull; + arguments.builder + .getWheres() + .append( { + type: "jsonContains", + path: containsPath, + combinator: arguments.combinator, + negate: arguments.negate + } ); + + if ( arguments.valueDefinition.isNull ) { + var preparedNullValue = arguments.builder.getGrammar().prepareJsonContainsBinding(); + arguments.builder.addBindings( + isNull( preparedNullValue ) + ? arguments.builder.getUtils().extractBinding( grammar = arguments.builder.getGrammar() ) + : arguments.builder.getUtils().extractBinding( preparedNullValue, arguments.builder.getGrammar() ), + "where" + ); + } else { + arguments.builder.addBindings( + arguments.builder + .getUtils() + .extractBinding( + arguments.builder.getGrammar().prepareJsonContainsBinding( arguments.valueDefinition.value ), + arguments.builder.getGrammar() + ), + "where" + ); + } + return arguments.builder; + } + + /** + * Adds a JSON path existence predicate. + */ + public QueryBuilder function whereJsonExists( + required QueryBuilder builder, + required string column, + array path = [], + string combinator = "and", + boolean negate = false + ) { + arguments.builder + .getWheres() + .append( { + type: "jsonExists", + path: jsonPath( builder = arguments.builder, column = arguments.column, path = arguments.path ), + combinator: arguments.combinator, + negate: arguments.negate + } ); + return arguments.builder; + } + + /** + * Adds a normalized JSON array length predicate. + */ + public QueryBuilder function whereJsonLength( + required QueryBuilder builder, + required string column, + array path = [], + required any operator, + required struct valueDefinition, + string combinator = "and" + ) { + arguments.builder + .getWheres() + .append( { + type: "jsonLength", + path: jsonPath( builder = arguments.builder, column = arguments.column, path = arguments.path ), + operator: arguments.operator, + combinator: arguments.combinator + } ); + arguments.builder.addBindings( + arguments.valueDefinition.isNull + ? arguments.builder.getUtils().extractBinding( grammar = arguments.builder.getGrammar() ) + : arguments.builder + .getUtils() + .extractBinding( arguments.valueDefinition.value, arguments.builder.getGrammar() ), + "where" + ); + return arguments.builder; + } + + /** + * Normalizes an arrow-syntax JSON path segment for grammar compilation. + * Numeric shortcut segments are converted to numbers so grammars can + * distinguish JSON array indexes from object keys. Explicit path segments + * preserve their CFML types and do not pass through this function. + * + * @segment The shortcut JSON object key or array index to normalize. + * + * @return The trimmed object key or numeric array index. + */ + private any function normalizeJsonPathSegment( required any segment ) { + var normalized = trim( arguments.segment ); + return reFind( "^\d+$", normalized ) ? val( normalized ) : normalized; + } + +} diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 88e85ef1..9f8e3003 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -243,43 +243,6 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J */ this.isBuilder = true; - /** - * The list of allowed operators in join and where statements. - */ - variables.operators = [ - "=", - "<", - ">", - "<=", - ">=", - "<>", - "!=", - "like", - "like binary", - "not like", - "between", - "ilike", - "&", - "|", - "^", - "<<", - ">>", - "rlike", - "regexp", - "not regexp", - "~", - "~*", - "!~", - "!~*", - "similar to", - "not similar to" - ]; - - /** - * The list of allowed combinators between statements. - */ - variables.combinators = [ "AND", "OR" ]; - /** * Object holding all of the different bindings. * Bindings are separated by the different clauses @@ -361,6 +324,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J boolean collectQueryLog = true, boolean validateDuplicateSelectColumns = false ) { + variables.collaborators = {}; variables.grammar = arguments.grammar; variables.utils = arguments.utils; @@ -400,6 +364,66 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return this; } + /** + * Updates operator and combinator validation and invalidates any validator + * created with the previous settings. + */ + public QueryBuilder function setValidateOperatorsAndCombinators( required boolean state ) { + variables.validateOperatorsAndCombinators = arguments.state; + invalidateCollaborator( "QueryValidator" ); + return this; + } + + /** + * Updates duplicate select validation and invalidates any validator created + * with the previous settings. + */ + public QueryBuilder function setValidateDuplicateSelectColumns( required boolean state ) { + variables.validateDuplicateSelectColumns = arguments.state; + invalidateCollaborator( "QueryValidator" ); + return this; + } + + /** + * Updates queryExecute return type validation and invalidates any validator + * created with the previous settings. + */ + public QueryBuilder function setValidateQueryExecuteReturnType( required boolean state ) { + variables.validateQueryExecuteReturnType = arguments.state; + invalidateCollaborator( "QueryValidator" ); + return this; + } + + /** + * Resolves and caches an internal collaborator on first use. + */ + package any function getCollaborator( required string name ) { + if ( !variables.collaborators.keyExists( arguments.name ) ) { + if ( arguments.name == "QueryValidator" ) { + variables.collaborators[ arguments.name ] = new qb.models.Query.QueryValidator( + validateOperatorsAndCombinators = getValidateOperatorsAndCombinators(), + validateDuplicateSelectColumns = getValidateDuplicateSelectColumns(), + validateQueryExecuteReturnType = getValidateQueryExecuteReturnType() + ); + } else { + variables.collaborators[ arguments.name ] = createObject( + "component", + "qb.models.Query.#arguments.name#" + ); + } + } + return variables.collaborators[ arguments.name ]; + } + + /** + * Removes a cached collaborator after its configuration changes. + */ + private void function invalidateCollaborator( required string name ) { + if ( variables.keyExists( "collaborators" ) ) { + variables.collaborators.delete( arguments.name ); + } + } + /** * Sets up the default values for a new builder instance. * @@ -457,6 +481,22 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return this; } + /** + * Returns whether this builder is collecting SQL instead of executing it. + * This bridge is public so collaborators can operate on QueryBuilder subclasses. + */ + public boolean function isPretending() { + return variables.pretending; + } + + /** + * Returns the lazily instantiated validator for collaborators operating on + * this builder or one of its subclasses. + */ + public QueryValidator function getQueryValidator() { + return getCollaborator( "QueryValidator" ); + } + /** * Resets the query builder instance. * @@ -510,8 +550,9 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J /** * Adds bindings carried by typed raw-expression and builder columns. + * This bridge is public so collaborators can operate on QueryBuilder subclasses. */ - private void function addColumnBindings( required array columns, required string type ) { + public void function addColumnBindings( required array columns, required string type ) { for ( var column in arguments.columns ) { if ( column.type == "raw" ) { addExpressionBindings( column.value, arguments.type ); @@ -547,87 +588,6 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } } - /** - * Validates that all statically identifiable select output names are unique. - * Wildcards and raw expressions without explicit aliases are skipped because - * their output names cannot be determined without executing the query. - */ - private void function validateUniqueSelectColumns( required array columns ) { - if ( !getValidateDuplicateSelectColumns() ) { - return; - } - - var outputNames = {}; - for ( var column in arguments.columns ) { - var outputName = getSelectOutputName( column ); - if ( isNull( outputName ) ) { - continue; - } - - var normalizedName = normalizeSelectOutputName( outputName ); - if ( structKeyExists( outputNames, normalizedName ) ) { - throw( - type = "DuplicateSelectColumn", - message = "Multiple selected columns produce the output name [#outputName#].", - detail = "Alias one of the columns to produce unique result keys." - ); - } - outputNames[ normalizedName ] = true; - } - } - - /** - * Returns a statically identifiable output name for a selected column. - */ - private any function getSelectOutputName( required struct column ) { - if ( arguments.column.type == "builder" ) { - return arguments.column.alias; - } - - if ( arguments.column.type == "raw" ) { - var rawSql = trim( arguments.column.value.getSQL() ); - var aliasMatch = reFindNoCase( - "\s+AS\s+((?:`[^`]+`)|(?:\[[^\]]+\])|(?:""[^""]+"")|(?:[A-Za-z_][A-Za-z0-9_$]*))\s*$", - rawSql, - 1, - true - ); - if ( aliasMatch.pos.len() < 2 || aliasMatch.pos[ 1 ] == 0 ) { - return; - } - return mid( rawSql, aliasMatch.pos[ 2 ], aliasMatch.len[ 2 ] ); - } - - if ( arguments.column.type == "simple" && find( "*", arguments.column.value ) ) { - return; - } - - if ( arguments.column.type == "jsonPath" && !arguments.column.keyExists( "alias" ) ) { - return; - } - - if ( listFindNoCase( "simple,jsonPath", arguments.column.type ) ) { - return getGrammar().extractAlias( arguments.column ); - } - } - - /** - * Normalizes an output name for the case-insensitive keys used by CFML structs. - */ - private string function normalizeSelectOutputName( required string outputName ) { - var normalizedName = trim( arguments.outputName ); - if ( - len( normalizedName ) >= 2 && - ( - ( left( normalizedName, 1 ) == "[" && right( normalizedName, 1 ) == "]" ) || - ( left( normalizedName, 1 ) == "`" && right( normalizedName, 1 ) == "`" ) || - ( left( normalizedName, 1 ) == """" && right( normalizedName, 1 ) == """" ) - ) - ) { - normalizedName = mid( normalizedName, 2, len( normalizedName ) - 2 ); - } - return lCase( normalizedName ); - } /** * Adds a sub-select to the query. @@ -643,7 +603,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J arguments.query = newQuery(); callback( arguments.query ); } - arguments.query = snapshotBuilder( arguments.query ); + arguments.query = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.query ); variables.columns.append( { "type": "builder", "value": arguments.query, "alias": arguments.alias } ); addBindings( arguments.query.getBindings(), "select" ); return this; @@ -828,308 +788,13 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } public void function renameAliases( required string oldAlias, required string newAlias ) { - renameAliasesInColumns( oldAlias, newAlias ); - renameAliasesInJoins( oldAlias, newAlias ); - renameAliasesInWheres( oldAlias, newAlias ); - renameAliasesInGroups( oldAlias, newAlias ); - renameAliasesInHavings( oldAlias, newAlias ); - renameAliasesInOrders( oldAlias, newAlias ); - renameAliasesInUnions( oldAlias, newAlias ); - renameAliasesInCommonTables( oldAlias, newAlias ); - return; - } - - private void function renameAliasesInUnions( required string oldAlias, required string newAlias ) { - for ( var union in variables.unions ) { - renameAliasesInNestedQuery( union.query, arguments.oldAlias, arguments.newAlias ); - } - } - - private void function renameAliasesInCommonTables( required string oldAlias, required string newAlias ) { - for ( var commonTable in variables.commonTables ) { - renameAliasesInNestedQuery( commonTable.query, arguments.oldAlias, arguments.newAlias ); - } - } - - private void function renameAliasesInNestedQuery( - required QueryBuilder query, - required string oldAlias, - required string newAlias - ) { - var nestedAlias = arguments.query.getAlias(); - var nestedTable = arguments.query.getTableName(); - var shadowsAlias = compareNoCase( nestedAlias, arguments.oldAlias ) == 0; - - if ( !shadowsAlias && nestedAlias == "" && isSimpleValue( nestedTable ) ) { - shadowsAlias = compareNoCase( listLast( nestedTable, "." ), arguments.oldAlias ) == 0; - } - - if ( !shadowsAlias ) { - arguments.query.renameAliases( arguments.oldAlias, arguments.newAlias ); - } - } - - private void function renameAliasesInColumns( required string oldAlias, required string newAlias ) { - for ( var i = 1; i <= variables.columns.len(); i++ ) { - var column = variables.columns[ i ]; - renameAliasInTypedColumn( column, arguments.oldAlias, arguments.newAlias ); - if ( column.type == "builder" ) { - renameAliasesInNestedQuery( column.value, arguments.oldAlias, arguments.newAlias ); - } - } - } - - private void function renameAliasesInJoins( required string oldAlias, required string newAlias ) { - for ( var join in variables.joins ) { - join.renameAliases( arguments.oldAlias, arguments.newAlias ); - } - } - - private void function renameAliasesInWheres( required string oldAlias, required string newAlias ) { - for ( var where in variables.wheres ) { - var renameWhereFunc = variables[ "renameAliasInWhere#where.type#" ]; - renameWhereFunc( where, arguments.oldAlias, arguments.newAlias ); - } - } - - private void function renameAliasesInGroups( required string oldAlias, required string newAlias ) { - for ( var column in variables.groups ) { - renameAliasInTypedColumn( column, arguments.oldAlias, arguments.newAlias ); - } - } - - private void function renameAliasesInHavings( required string oldAlias, required string newAlias ) { - for ( var having in variables.havings ) { - if ( structKeyExists( having, "column" ) ) { - renameAliasInTypedColumn( having.column, arguments.oldAlias, arguments.newAlias ); - } - } - } - - private void function renameAliasesInOrders( required string oldAlias, required string newAlias ) { - for ( var order in variables.orders ) { - if ( order.keyExists( "query" ) ) { - renameAliasesInNestedQuery( order.query, arguments.oldAlias, arguments.newAlias ); - } else if ( order.keyExists( "column" ) && order.direction != "raw" ) { - renameAliasInTypedColumn( order.column, arguments.oldAlias, arguments.newAlias ); - } - } - } - - private void function renameAliasInTypedColumn( - required struct column, - required string oldAlias, - required string newAlias - ) { - if ( arguments.column.type == "simple" ) { - arguments.column.value = swapAlias( arguments.column.value, arguments.oldAlias, arguments.newAlias ); - } else if ( arguments.column.type == "jsonPath" ) { - arguments.column.value.column = swapAlias( - arguments.column.value.column, - arguments.oldAlias, - arguments.newAlias - ); - } - } - - private void function renameAliasInWhereBasic( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereJsonContains( - required struct where, - required string oldAlias, - required string newAlias - ) { - arguments.where.path.value.column = swapAlias( - arguments.where.path.value.column, - arguments.oldAlias, - arguments.newAlias + getCollaborator( "AliasRewriter" ).rewrite( + builder = this, + oldAlias = arguments.oldAlias, + newAlias = arguments.newAlias ); } - private void function renameAliasInWhereJsonExists( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasInWhereJsonContains( argumentCollection = arguments ); - } - - private void function renameAliasInWhereJsonLength( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasInWhereJsonContains( argumentCollection = arguments ); - } - - private void function renameAliasInWhereColumn( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasInTypedColumn( arguments.where.first, arguments.oldAlias, arguments.newAlias ); - renameAliasInTypedColumn( arguments.where.second, arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereSub( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); - renameAliasesInNestedQuery( arguments.where.query, arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereIn( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereNotIn( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereInBulk( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereInSub( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); - renameAliasesInNestedQuery( arguments.where.query, arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereNotInSub( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasInWhereInSub( argumentCollection = arguments ); - } - - private void function renameAliasInWhereRaw( - required struct where, - required string oldAlias, - required string newAlias - ) { - return; - } - - private void function renameAliasInWhereExists( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasesInNestedQuery( arguments.where.query, arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereNotExists( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasesInNestedQuery( arguments.where.query, arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereNested( - required struct where, - required string oldAlias, - required string newAlias - ) { - arguments.where.query.renameAliases( arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereNull( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereNotNull( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereNullSub( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasesInNestedQuery( arguments.where.query, arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereNotNullSub( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasesInNestedQuery( arguments.where.query, arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereBetween( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); - if ( getUtils().isBuilder( arguments.where.start ) ) { - renameAliasesInNestedQuery( arguments.where.start, arguments.oldAlias, arguments.newAlias ); - } - if ( getUtils().isBuilder( arguments.where.end ) ) { - renameAliasesInNestedQuery( arguments.where.end, arguments.oldAlias, arguments.newAlias ); - } - } - - private void function renameAliasInWhereNotBetween( - required struct where, - required string oldAlias, - required string newAlias - ) { - renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); - if ( getUtils().isBuilder( arguments.where.start ) ) { - renameAliasesInNestedQuery( arguments.where.start, arguments.oldAlias, arguments.newAlias ); - } - if ( getUtils().isBuilder( arguments.where.end ) ) { - renameAliasesInNestedQuery( arguments.where.end, arguments.oldAlias, arguments.newAlias ); - } - } - - private string function swapAlias( required string column, required string oldAlias, required string newAlias ) { - if ( startsWith( arguments.column, arguments.oldAlias & "." ) ) { - return arguments.newAlias & "." & listLast( arguments.column, "." ); - } - return arguments.column; - } - - private boolean function startsWith( required string word, required string substring ) { - return left( arguments.word, len( arguments.substring ) ) == arguments.substring; - } - /** * Sets the FROM table of the query. * Alias for `from`. @@ -1215,7 +880,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J arguments.input = subquery; } - arguments.input = snapshotBuilder( arguments.input ); + arguments.input = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.input ); // generate the derived table SQL this.fromRaw( getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ) ); @@ -1328,7 +993,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J boolean preventDuplicateJoins = this.getPreventDuplicateJoins() ) { if ( getUtils().isBuilder( arguments.table ) ) { - arguments.table = cloneJoinClause( arguments.table, this ); + arguments.table = getCollaborator( "QueryExecutor" ).cloneJoinClause( this, arguments.table, this ); if ( arguments.preventDuplicateJoins ) { var hasThisJoin = variables.joins.find( function( existingJoin ) { return existingJoin.isEqualTo( table ); @@ -1346,11 +1011,11 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J var join = new qb.models.Query.JoinClause( joiningQuery = this, type = arguments.type, table = arguments.table ); if ( isClosure( arguments.first ) || isCustomFunction( arguments.first ) ) { - var commonTableState = captureCommonTableState(); + var commonTableState = getCollaborator( "QueryExecutor" ).captureCommonTableState( this ); try { first( join ); } catch ( any e ) { - restoreCommonTableState( commonTableState ); + getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); rethrow; } if ( arguments.preventDuplicateJoins ) { @@ -1359,7 +1024,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } ); if ( hasThisJoin ) { - restoreCommonTableState( commonTableState ); + getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); return this; } } @@ -1705,7 +1370,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J string type = "inner", boolean where = false ) { - var commonTableState = captureCommonTableState(); + var commonTableState = getCollaborator( "QueryExecutor" ).captureCommonTableState( this ); // since we have a callback, we generate a new query object and pass it into the callback try { if ( isClosure( arguments.input ) || isCustomFunction( arguments.input ) ) { @@ -1714,7 +1379,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J // replace the original query builder with the results of the sub-query arguments.input = subquery; } - arguments.input = snapshotBuilder( arguments.input ); + arguments.input = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.input ); // create the table reference arguments.table = raw( @@ -1731,17 +1396,17 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J if ( variables.joins.len() > joinCount ) { variables.grammarCompiledJoin = true; } else { - restoreCommonTableState( commonTableState ); + getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); } return result; } catch ( any e ) { - restoreCommonTableState( commonTableState ); + getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); rethrow; } } private function outerOrCrossApply( required string name, required string type, required tableLikeSource ) { - var commonTableState = captureCommonTableState(); + var commonTableState = getCollaborator( "QueryExecutor" ).captureCommonTableState( this ); if ( type != "outer apply" && type != "cross apply" && type != "lateral" ) { throw( type = "QBInvalidJoinType", @@ -1765,7 +1430,10 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J arguments.tableLikeSource = subquery; } - arguments.tableLikeSource = snapshotBuilder( arguments.tableLikeSource ); + arguments.tableLikeSource = getCollaborator( "QueryExecutor" ).snapshotBuilder( + this, + arguments.tableLikeSource + ); var join = new qb.models.Query.JoinClause( joiningQuery = this, @@ -1782,7 +1450,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J if ( hasThisJoin ) { // Do nothing, early return - restoreCommonTableState( commonTableState ); + getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); return this; } } @@ -1871,7 +1539,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function crossJoinSub( required any alias, required any input ) { - var commonTableState = captureCommonTableState(); + var commonTableState = getCollaborator( "QueryExecutor" ).captureCommonTableState( this ); // since we have a callback, we generate a new query object and pass it into the callback try { if ( isClosure( arguments.input ) || isCustomFunction( arguments.input ) ) { @@ -1880,7 +1548,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J // replace the original query builder with the results of the sub-query arguments.input = subquery; } - arguments.input = snapshotBuilder( arguments.input ); + arguments.input = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.input ); // create the table reference var table = raw( @@ -1892,7 +1560,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J variables.grammarCompiledJoin = true; return result; } catch ( any e ) { - restoreCommonTableState( commonTableState ); + getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); rethrow; } } @@ -2073,15 +1741,13 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return whereNested( arguments.column, arguments.combinator ); } - if ( this.getValidateOperatorsAndCombinators() && isInvalidCombinator( arguments.combinator ) ) { - throw( type = "InvalidSQLType", message = "Illegal combinator" ); - } + getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); - if ( isNull( arguments.value ) && isInvalidOperator( arguments.operator ) ) { + if ( isNull( arguments.value ) && getCollaborator( "QueryValidator" ).isInvalidOperator( arguments.operator ) ) { arguments.value = arguments.operator; arguments.operator = "="; - } else if ( this.getValidateOperatorsAndCombinators() && isInvalidOperator( arguments.operator ) ) { - throw( type = "InvalidSQLType", message = "Illegal operator" ); + } else { + getCollaborator( "QueryValidator" ).validateOperator( arguments.operator ); } if ( @@ -2189,7 +1855,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J arguments.query = newQuery(); callback( arguments.query ); } - arguments.query = snapshotBuilder( arguments.query ); + arguments.query = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.query ); var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); variables.wheres.append( { type: "sub", @@ -2233,7 +1899,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J combinator = "and", negate = false ) { - guardAgainstInvalidCombinator( arguments.combinator ); + getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); if ( isClosure( values ) || isCustomFunction( values ) || @@ -2290,7 +1956,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J string combinator = "and", boolean negate = false ) { - guardAgainstInvalidCombinator( arguments.combinator ); + getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); arguments.values = normalizeToArray( arguments.values ); var extractedBindings = []; @@ -2402,7 +2068,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J arguments.query = newQuery(); callback( arguments.query ); } - arguments.query = snapshotBuilder( arguments.query ); + arguments.query = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.query ); var type = negate ? "notInSub" : "inSub"; var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); @@ -2442,7 +2108,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereRaw( required string sql, array whereBindings = [], string combinator = "and" ) { - guardAgainstInvalidCombinator( arguments.combinator ); + getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); addBindings( whereBindings.map( function( binding ) { return utils.extractBinding( binding, variables.grammar ); @@ -2469,15 +2135,13 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J second, string combinator = "and" ) { - guardAgainstInvalidCombinator( arguments.combinator ); + getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); if ( isNull( arguments.second ) ) { arguments.second = arguments.operator; arguments.operator = "="; } - if ( this.getValidateOperatorsAndCombinators() && isInvalidOperator( operator ) ) { - throw( type = "InvalidSQLType", message = "Illegal operator" ); - } + getCollaborator( "QueryValidator" ).validateOperator( arguments.operator ); if ( isClosure( arguments.second ) || @@ -2516,7 +2180,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereExists( query, combinator = "and", negate = false ) { - guardAgainstInvalidCombinator( arguments.combinator ); + getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); if ( isClosure( arguments.query ) || isCustomFunction( arguments.query ) ) { var callback = arguments.query; arguments.query = newQuery(); @@ -2535,7 +2199,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ private QueryBuilder function addWhereExistsQuery( query, combinator = "and", negate = false ) { - arguments.query = snapshotBuilder( arguments.query ); + arguments.query = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.query ); var type = negate ? "notExists" : "exists"; variables.wheres.append( { type: type, query: arguments.query, combinator: arguments.combinator } ); addBindings( query.getBindings(), "where" ); @@ -2565,7 +2229,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereNested( required callback, combinator = "and" ) { - guardAgainstInvalidCombinator( arguments.combinator ); + getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); var query = forNestedWhere(); callback( query ); return addNestedWhereQuery( query, combinator ); @@ -2580,9 +2244,9 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function addNestedWhereQuery( required QueryBuilder query, string combinator = "and" ) { - guardAgainstInvalidCombinator( arguments.combinator ); + getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); if ( !query.getWheres().isEmpty() ) { - arguments.query = snapshotBuilder( arguments.query ); + arguments.query = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.query ); variables.wheres.append( { type: "nested", query: arguments.query, combinator: arguments.combinator } ); addBindings( query.getBindings(), "where" ); } @@ -2609,7 +2273,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereNull( column, combinator = "and", negate = false ) { - guardAgainstInvalidCombinator( arguments.combinator ); + getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); if ( isClosure( arguments.column ) || isCustomFunction( arguments.column ) || @@ -2635,13 +2299,13 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereNullSub( query, combinator = "and", negate = false ) { - guardAgainstInvalidCombinator( arguments.combinator ); + getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); if ( isClosure( arguments.query ) || isCustomFunction( arguments.query ) ) { var callback = arguments.query; arguments.query = newQuery(); callback( arguments.query ); } - arguments.query = snapshotBuilder( arguments.query ); + arguments.query = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.query ); var type = arguments.negate ? "notNullSub" : "nullSub"; variables.wheres.append( { type: type, query: arguments.query, combinator: arguments.combinator } ); @@ -2681,7 +2345,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J combinator = "and", negate = false ) { - guardAgainstInvalidCombinator( arguments.combinator ); + getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); var type = negate ? "notBetween" : "between"; var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); @@ -2698,10 +2362,10 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } if ( getUtils().isBuilder( arguments.start ) ) { - arguments.start = snapshotBuilder( arguments.start ); + arguments.start = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.start ); } if ( getUtils().isBuilder( arguments.end ) ) { - arguments.end = snapshotBuilder( arguments.end ); + arguments.end = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.end ); } addColumnBindings( [ typedColumn ], "where" ); @@ -2829,9 +2493,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J value, string combinator = "and" ) { - if ( this.getValidateOperatorsAndCombinators() && isInvalidCombinator( arguments.combinator ) ) { - throw( type = "InvalidSQLType", message = "Illegal combinator" ); - } + getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); if ( isNull( arguments.value ) && @@ -2856,8 +2518,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J if ( isNull( arguments.value ) ) { arguments.value = arguments.operator; arguments.operator = "="; - } else if ( this.getValidateOperatorsAndCombinators() && isInvalidOperator( arguments.operator ) ) { - throw( type = "InvalidSQLType", message = "Illegal operator" ); + } else { + getCollaborator( "QueryValidator" ).validateOperator( arguments.operator ); } arrayAppend( @@ -2964,7 +2626,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function orderBy( required any column, string direction = "asc" ) { - guardAgainstInvalidOrderDirection( arguments.direction ); + getCollaborator( "QueryValidator" ).validateOrderDirection( arguments.direction ); arguments.direction = lCase( trim( arguments.direction ) ); // We are trying to determine if a positional array of [ column, direction ] // was passed in. This is the craziness that does that. @@ -3173,7 +2835,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function orderBySub( required any query, string direction = "asc" ) { - guardAgainstInvalidOrderDirection( arguments.direction ); + getCollaborator( "QueryValidator" ).validateOrderDirection( arguments.direction ); arguments.direction = lCase( trim( arguments.direction ) ); if ( !getUtils().isBuilder( arguments.query ) ) { var callback = arguments.query; @@ -3181,7 +2843,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J callback( arguments.query ); } - arguments.query = snapshotBuilder( arguments.query ); + arguments.query = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.query ); variables.orders.append( { direction: arguments.direction, query: arguments.query } ); addBindings( arguments.query.getBindings(), "orderBy" ); @@ -3249,7 +2911,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J // replace the original query builder with the results of the sub-query arguments.input = subquery; } - arguments.input = snapshotBuilder( arguments.input ); + arguments.input = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.input ); // track the union statement variables.unions.append( { query: arguments.input, all: arguments.all } ); @@ -3298,7 +2960,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J // replace the original query builder with the results of the sub-query arguments.input = subquery; } - arguments.input = snapshotBuilder( arguments.input ); + arguments.input = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.input ); // track the union statement arrayAppend( @@ -3456,7 +3118,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J private numeric function getCountForPagination( struct options = {} ) { if ( !variables.groups.isEmpty() || !variables.havings.isEmpty() || variables.distinct ) { var countSource = clone().clearOrders(); - return prepareInternalExecutionBuilder( newQuery() ) + return getCollaborator( "QueryExecutor" ) + .prepareInternalExecutionBuilder( this, newQuery() ) .fromSub( "aggregate_table", countSource ) .count( options = arguments.options ); } @@ -3699,7 +3362,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J clearBindings( only = [ "insert" ] ); } } else { - var batchQuery = prepareInternalExecutionBuilder( clone() ); + var batchQuery = getCollaborator( "QueryExecutor" ).prepareInternalExecutionBuilder( this, clone() ); results.append( batchQuery.insert( values = batch, options = arguments.options, toSql = arguments.toSql ) ); @@ -3731,7 +3394,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J arguments.source = newQuery(); callback( arguments.source ); } - arguments.source = snapshotBuilder( arguments.source ); + arguments.source = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.source ); clearBindings( except = [ "commonTables" ] ); @@ -3901,10 +3564,13 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J if ( isCustomFunction( value ) || isClosure( value ) ) { var subselect = newQuery(); value( subselect ); - arguments.values[ column.original ] = snapshotBuilder( subselect ); + arguments.values[ column.original ] = getCollaborator( "QueryExecutor" ).snapshotBuilder( + this, + subselect + ); addBindings( arguments.values[ column.original ].getBindings(), "update" ); } else if ( getUtils().isBuilder( value ) ) { - arguments.values[ column.original ] = snapshotBuilder( value ); + arguments.values[ column.original ] = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, value ); addBindings( arguments.values[ column.original ].getBindings(), "update" ); } else if ( getUtils().isExpression( value ) ) { addExpressionBindings( value, "update" ); @@ -4000,7 +3666,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } if ( !isNull( arguments.source ) ) { - arguments.source = snapshotBuilder( arguments.source ); + arguments.source = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.source ); addBindings( arguments.source.getBindings(), "insert" ); } @@ -4120,7 +3786,10 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } if ( getUtils().isBuilder( arguments.deleteUnmatched ) ) { - arguments.deleteUnmatched = snapshotBuilder( arguments.deleteUnmatched ); + arguments.deleteUnmatched = getCollaborator( "QueryExecutor" ).snapshotBuilder( + this, + arguments.deleteUnmatched + ); addBindings( arguments.deleteUnmatched.getBindings(), "insert" ); } @@ -4281,55 +3950,6 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return this; } - /** - * Clones a child builder when it is attached so its SQL and copied bindings cannot diverge later. - */ - private QueryBuilder function snapshotBuilder( required QueryBuilder builder ) { - var snapshot = arguments.builder.clone(); - var hoistTarget = isJoin() ? getJoiningQuery() : this; - hoistNestedCommonTables( snapshot, hoistTarget ); - return snapshot; - } - - private struct function captureCommonTableState() { - return { - commonTableCount: variables.commonTables.len(), - commonTableBindingCount: variables.bindings.commonTables.len() - }; - } - - private void function restoreCommonTableState( required struct state ) { - variables.commonTables = arguments.state.commonTableCount == 0 - ? [] - : variables.commonTables.slice( 1, arguments.state.commonTableCount ); - variables.bindings.commonTables = arguments.state.commonTableBindingCount == 0 - ? [] - : variables.bindings.commonTables.slice( 1, arguments.state.commonTableBindingCount ); - } - - /** - * Moves SQL Server CTEs from an embedded query to the statement that contains it. - * T-SQL only permits the WITH clause at the statement level, not inside the - * parentheses used for derived tables and predicate subqueries. - */ - private QueryBuilder function hoistNestedCommonTables( required QueryBuilder source, required QueryBuilder target ) { - if ( - !isInstanceOf( arguments.target.getGrammar(), "qb.models.Grammars.SqlServerGrammar" ) || - arguments.source.getCommonTables().isEmpty() - ) { - return arguments.source; - } - - var targetCommonTables = arguments.target.getCommonTables(); - targetCommonTables.append( arguments.source.getCommonTables(), true ); - arguments.target.setCommonTables( targetCommonTables ); - arguments.target.addBindings( arguments.source.getRawBindings().commonTables, "commonTables" ); - - arguments.source.setCommonTables( [] ); - arguments.source.getRawBindings().commonTables = []; - return arguments.source; - } - /** * Adds all of the bindings from another builder instance. * @@ -4519,26 +4139,31 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J boolean toSQL = false, any showBindings = false ) { - return withAggregate( - { + return getCollaborator( "QueryExecutor" ).withAggregate( + builder = this, + aggregate = { type: type, column: mapToColumnType( arguments.column ), defaultValue: isNull( arguments.defaultValue ) ? javacast( "null", "" ) : arguments.defaultValue }, - function() { + callback = function() { return withReturnFormat( "query", function() { - return withColumns( column, function() { - if ( toSQL ) { - return this.toSQL( showBindings = showBindings ); + return getCollaborator( "QueryExecutor" ).withColumns( + builder = this, + columns = column, + callback = function() { + if ( toSQL ) { + return this.toSQL( showBindings = showBindings ); + } + + var result = get( options = options ); + if ( result.recordCount <= 0 && !isNull( defaultValue ) ) { + return defaultValue; + } else { + return result.aggregate; + } } - - var result = get( options = options ); - if ( result.recordCount <= 0 && !isNull( defaultValue ) ) { - return defaultValue; - } else { - return result.aggregate; - } - } ); + ); } ); } ); @@ -4553,8 +4178,10 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J */ public any function exists( struct options = {}, boolean toSQL = false ) { var existsSource = clone().setLimitValue( 1 ); - var existsQuery = prepareInternalExecutionBuilder( newQuery() ).clearFrom(); - hoistNestedCommonTables( existsSource, existsQuery ); + var existsQuery = getCollaborator( "QueryExecutor" ) + .prepareInternalExecutionBuilder( this, newQuery() ) + .clearFrom(); + getCollaborator( "QueryExecutor" ).hoistNestedCommonTables( existsSource, existsQuery ); existsQuery.selectRaw( "CASE WHEN EXISTS (#getGrammar().compileSelect( existsSource )#) THEN 1 ELSE 0 END AS aggregate", existsSource.getBindings() @@ -4900,105 +4527,33 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } /** - * Execute a query and convert it to the proper return format. - * - * @sql The sql string to execute. - * @options Any options to pass to `queryExecute`. Default: {}. - * - * @return any + * Delegates select execution to the lazily instantiated executor. */ private any function run( required string sql, struct options = {} ) { - var q = runQuery( argumentCollection = arguments ); - - if ( isNull( q ) ) { - if ( variables.pretending ) { - return applyReturnFormat( queryNew( "" ) ); - } - return; - } - - if ( isQuery( q ) ) { - return applyReturnFormat( q ); - } - - if ( isArray( q ) ) { - return applyReturnFormat( q ); - } - - if ( !q.keyExists( "result" ) || !q.keyExists( "query" ) ) { - return applyReturnFormat( q ); - } - - return { result: q.result, query: applyReturnFormat( q.query ) }; - } - - private any function applyReturnFormat( required any q ) { - var formatter = getReturnFormat(); - - if ( isClosure( formatter ) || isCustomFunction( formatter ) ) { - return formatter( arguments.q ); - } - - if ( structKeyExists( formatter, "format" ) ) { - return formatter.format( arguments.q ); - } - - throw( - type = "InvalidFormat", - message = "The configured return formatter must be a closure or a component with a format method." - ); + return getCollaborator( "QueryExecutor" ).run( this, arguments.sql, arguments.options ); } /** - * Run a query through the specified grammar then clear all bindings. - * - * @sql The sql string to execute. - * @options Any options to pass to `queryExecute`. Default: {}. - * @returnObject The return object that running the query should return. - * Can be either `query` or `result`. Default: `query`. - * - * @return any + * Delegates grammar execution while preserving the builder's established + * test seam and supporting collaborators operating on subclasses. */ - private any function runQuery( + public any function runQuery( required string sql, struct options = {}, string returnObject = "query", array bindings ) { - var queryOptions = structCopy( arguments.options ); - structAppend( queryOptions, getDefaultOptions(), false ); - guardAgainstReturnTypeOption( queryOptions ); - var aggregateBindingExclusions = getAggregate().isEmpty() - ? [] - : ( getUnions().isEmpty() ? [ "select", "orderBy" ] : [ "orderBy" ] ); - var queryBindings = isNull( arguments.bindings ) - ? getBindings( except = aggregateBindingExclusions ) - : arguments.bindings; - - var result = grammar.runQuery( - sql = variables.sqlCommenter.appendSqlComments( - sql = sql, - datasource = queryOptions.keyExists( "datasource" ) && !isNull( queryOptions.datasource ) ? queryOptions.datasource : javacast( - "null", - "" - ), - bindings = queryBindings - ), - bindings = queryBindings, - options = queryOptions, - returnObject = returnObject, - pretend = variables.pretending, - postProcessHook = function( data ) { - if ( this.getCollectQueryLog() ) { - variables.queryLog.append( data ); - } - } + var bindingsDefinition = { provided: !isNull( arguments.bindings ) }; + if ( bindingsDefinition.provided ) { + bindingsDefinition.value = arguments.bindings; + } + return getCollaborator( "QueryExecutor" ).runQuery( + builder = this, + sql = arguments.sql, + options = arguments.options, + returnObject = arguments.returnObject, + bindingsDefinition = bindingsDefinition ); - - if ( !isNull( result ) ) { - return result; - } - return; } /*******************************************************************************\ @@ -5048,120 +4603,15 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return query; } - private QueryBuilder function prepareInternalExecutionBuilder( required QueryBuilder query ) { - if ( variables.pretending ) { - arguments.query.pretend(); - } - return arguments.query; - } - /** * Clones the current query into a new query instance. * * @return qb.models.Query.QueryBuilder */ public QueryBuilder function clone() { - var clonedQuery = newQuery(); - copyQueryState( this, clonedQuery ); - return clonedQuery; - } - - private void function copyQueryState( required QueryBuilder source, required QueryBuilder target ) { - var targetQuery = arguments.target; - arguments.target.setDistinct( arguments.source.getDistinct() ); - arguments.target.setAggregate( cloneQueryStateValue( arguments.source.getAggregate() ) ); - arguments.target.setColumns( cloneQueryStateValue( arguments.source.getColumns() ) ); - arguments.target.setTableName( cloneQueryStateValue( arguments.source.getTableName() ) ); - if ( !isNull( arguments.source.getForClause() ) ) { - arguments.target.setForClause( cloneQueryStateValue( arguments.source.getForClause() ) ); - } - arguments.target.setAlias( arguments.source.getAlias() ); - arguments.target.setLockType( arguments.source.getLockType() ); - arguments.target.setLockValue( arguments.source.getLockValue() ); - var clonedJoins = []; - for ( var join in arguments.source.getJoins() ) { - clonedJoins.append( cloneJoinClause( join, targetQuery ) ); - } - arguments.target.setJoins( clonedJoins ); - arguments.target.setWheres( cloneQueryStateValue( arguments.source.getWheres() ) ); - arguments.target.setGroups( cloneQueryStateValue( arguments.source.getGroups() ) ); - arguments.target.setHavings( cloneQueryStateValue( arguments.source.getHavings() ) ); - arguments.target.setUnions( cloneQueryStateValue( arguments.source.getUnions() ) ); - arguments.target.setOrders( cloneQueryStateValue( arguments.source.getOrders() ) ); - arguments.target.setCommonTables( cloneQueryStateValue( arguments.source.getCommonTables() ) ); - if ( !isNull( arguments.source.getLimitValue() ) ) { - arguments.target.setLimitValue( arguments.source.getLimitValue() ); - } - if ( !isNull( arguments.source.getOffsetValue() ) ) { - arguments.target.setOffsetValue( arguments.source.getOffsetValue() ); - } - arguments.target.setReturning( cloneQueryStateValue( arguments.source.getReturning() ) ); - arguments.target.setUpdates( cloneQueryStateValue( arguments.source.getUpdates() ) ); - arguments.target.setGrammarCompiledFrom( arguments.source.getGrammarCompiledFrom() ); - arguments.target.setGrammarCompiledJoin( arguments.source.getGrammarCompiledJoin() ); - - var sourceBindings = arguments.source.getRawBindings(); - for ( var bindingType in sourceBindings ) { - arguments.target.addBindings( cloneQueryStateValue( sourceBindings[ bindingType ] ), bindingType ); - } - } - - private JoinClause function cloneJoinClause( required JoinClause join, required QueryBuilder joiningQuery ) { - var clonedJoin = new qb.models.Query.JoinClause( - arguments.joiningQuery, - arguments.join.getType(), - cloneQueryStateValue( arguments.join.getTable() ), - arguments.join.getLateralRawExpression(), - cloneQueryStateValue( arguments.join.getLateralBindings() ) - ); - copyQueryState( arguments.join, clonedJoin ); - return clonedJoin; + return getCollaborator( "QueryExecutor" ).cloneBuilder( this ); } - private any function cloneQueryStateValue( any value ) { - if ( isSimpleValue( arguments.value ) ) { - return arguments.value; - } - if ( isNull( arguments.value ) ) { - return javacast( "null", "" ); - } - if ( getUtils().isBuilder( arguments.value ) ) { - return arguments.value.clone(); - } - if ( getUtils().isExpression( arguments.value ) ) { - return new qb.models.Query.Expression( - arguments.value.getSQL(), - cloneQueryStateValue( arguments.value.getBindings() ) - ); - } - if ( isObject( arguments.value ) ) { - return arguments.value; - } - if ( isArray( arguments.value ) ) { - var clonedArray = []; - if ( !arguments.value.isEmpty() ) { - arrayResize( clonedArray, arguments.value.len() ); - } - for ( var i = 1; i <= arguments.value.len(); i++ ) { - if ( arrayIsDefined( arguments.value, i ) && !isNull( arguments.value[ i ] ) ) { - clonedArray[ i ] = cloneQueryStateValue( arguments.value[ i ] ); - } - } - return clonedArray; - } - if ( isStruct( arguments.value ) ) { - var clonedStruct = {}; - for ( var key in arguments.value ) { - if ( isNull( arguments.value[ key ] ) ) { - clonedStruct[ key ] = javacast( "null", "" ); - } else { - clonedStruct[ key ] = cloneQueryStateValue( arguments.value[ key ] ); - } - } - return clonedStruct; - } - return arguments.value; - } /** * Wrap up any sql in an Expression. @@ -5197,8 +4647,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return string */ public string function toSQL( any showBindings = false ) { - if ( getAggregate().isEmpty() ) { - validateUniqueSelectColumns( getColumns() ); + if ( getValidateDuplicateSelectColumns() && getAggregate().isEmpty() ) { + getCollaborator( "QueryValidator" ).validateUniqueSelectColumns( getColumns(), getGrammar() ); } var sql = grammar.compileSelect( this ); @@ -5320,76 +4770,6 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return result; } - private void function guardAgainstReturnTypeOption( required struct options ) { - if ( !arguments.options.keyExists( "returntype" ) ) { - return; - } - - if ( getValidateQueryExecuteReturnType() ) { - throw( - type = "InvalidQueryExecuteOption", - message = "The queryExecute returntype option cannot be used with qb return formatters." - ); - } - - structDelete( arguments.options, "returntype" ); - structDelete( arguments.options, "columnkey" ); - structDelete( arguments.options, "columnKey" ); - } - - /** - * Runs the code inside the callback with the given columns selected and then sets the columns back to its original value. - * - * @columns A single column, a list or columns (comma-separated), or an array of columns. - * @callback The code to execute with the given columns. - * - * @return any - */ - private any function withColumns( required any columns, required any callback ) { - var originalColumns = [ { "type": "simple", "value": "*" } ]; - var shouldRestoreColumns = getUnions().isEmpty(); - if ( shouldRestoreColumns ) { - originalColumns = getColumns(); - select( arguments.columns ); - } - var result = javacast( "null", "" ); - try { - result = callback(); - } finally { - if ( shouldRestoreColumns ) { - select( originalColumns ); - } - } - return result; - } - - /** - * Runs the code inside the callback with the given aggregate in place and then sets the aggregate back to its original value. - * - * @aggregate he aggregate option and column to execute. (e.g. `{ type = "count", column = "*" }`). - * @callback The code to execute with the given aggregate. - * - * @return any - */ - private any function withAggregate( required struct aggregate, required any callback ) { - var originalAggregate = getAggregate(); - var originalOrders = getOrders(); - var originalAggregateBindings = variables.bindings.aggregate; - setAggregate( arguments.aggregate ); - setOrders( [] ); - variables.bindings.aggregate = []; - addColumnBindings( [ arguments.aggregate.column ], "aggregate" ); - var result = javacast( "null", "" ); - try { - result = callback(); - } finally { - setAggregate( originalAggregate ); - setOrders( originalOrders ); - variables.bindings.aggregate = originalAggregateBindings; - } - return result; - } - /** * Converts the arguments passed in to it into an array. * @@ -5435,54 +4815,6 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } } - /** - * Checks if an operator is an invalid sql operator (according to qb). - * - * @operator The operator to check. - * - * @return boolean - */ - private boolean function isInvalidOperator( required any operator ) { - if ( isNull( arguments.operator ) ) { - return true; - } - - if ( !isSimpleValue( arguments.operator ) ) { - return true; - } - - return !arrayContains( variables.operators, lCase( arguments.operator ) ); - } - - /** - * Checks if a combinator is an invalid sql combinator (according to qb). - * - * @combinator The combinator to check. - * - * @return boolean - */ - private boolean function isInvalidCombinator( required string combinator ) { - return !arrayContains( variables.combinators, uCase( arguments.combinator ) ); - } - - /** - * Throws when combinator validation is enabled and the value is unsupported. - */ - private void function guardAgainstInvalidCombinator( required string combinator ) { - if ( this.getValidateOperatorsAndCombinators() && isInvalidCombinator( arguments.combinator ) ) { - throw( type = "InvalidSQLType", message = "Illegal combinator" ); - } - } - - /** - * Throws when an ORDER BY direction is unsupported. - */ - private void function guardAgainstInvalidOrderDirection( required string direction ) { - if ( !arrayFindNoCase( variables.directions, trim( arguments.direction ) ) ) { - throw( type = "InvalidSQLType", message = "Illegal order direction" ); - } - } - /** * onMissingMethod serves the following purpose for Builder: * diff --git a/models/Query/QueryExecutor.cfc b/models/Query/QueryExecutor.cfc new file mode 100644 index 00000000..6cef02e3 --- /dev/null +++ b/models/Query/QueryExecutor.cfc @@ -0,0 +1,322 @@ +/** + * Coordinates isolated query execution, formatting, and query state snapshots + * without retaining builder state between calls. + */ +component { + + /** + * Executes a select and applies the builder's return format. + */ + public any function run( required QueryBuilder builder, required string sql, struct options = {} ) { + var q = arguments.builder.runQuery( sql = arguments.sql, options = arguments.options ); + + if ( isNull( q ) ) { + if ( arguments.builder.isPretending() ) { + return applyReturnFormat( arguments.builder, queryNew( "" ) ); + } + return; + } + + if ( isQuery( q ) || isArray( q ) ) { + return applyReturnFormat( arguments.builder, q ); + } + + if ( !q.keyExists( "result" ) || !q.keyExists( "query" ) ) { + return applyReturnFormat( arguments.builder, q ); + } + + return { result: q.result, query: applyReturnFormat( arguments.builder, q.query ) }; + } + + /** + * Runs a query through the configured grammar. + */ + public any function runQuery( + required QueryBuilder builder, + required string sql, + struct options = {}, + string returnObject = "query", + struct bindingsDefinition = { provided: false } + ) { + var queryOptions = structCopy( arguments.options ); + var queryBuilder = arguments.builder; + structAppend( queryOptions, arguments.builder.getDefaultOptions(), false ); + if ( queryOptions.keyExists( "returntype" ) ) { + arguments.builder.getQueryValidator().validateQueryExecuteOptions( queryOptions ); + } + + var aggregateBindingExclusions = arguments.builder.getAggregate().isEmpty() + ? [] + : ( arguments.builder.getUnions().isEmpty() ? [ "select", "orderBy" ] : [ "orderBy" ] ); + var queryBindings = arguments.bindingsDefinition.provided + ? arguments.bindingsDefinition.value + : arguments.builder.getBindings( except = aggregateBindingExclusions ); + + var result = arguments.builder + .getGrammar() + .runQuery( + sql = arguments.builder + .getSqlCommenter() + .appendSqlComments( + sql = arguments.sql, + datasource = queryOptions.keyExists( "datasource" ) && !isNull( queryOptions.datasource ) ? queryOptions.datasource : javacast( + "null", + "" + ), + bindings = queryBindings + ), + bindings = queryBindings, + options = queryOptions, + returnObject = arguments.returnObject, + pretend = arguments.builder.isPretending(), + postProcessHook = function( data ) { + if ( queryBuilder.getCollectQueryLog() ) { + queryBuilder.getQueryLog().append( data ); + } + } + ); + + if ( !isNull( result ) ) { + return result; + } + } + + /** + * Copies a builder for an internal operation and propagates pretend mode. + */ + public QueryBuilder function prepareInternalExecutionBuilder( + required QueryBuilder builder, + required QueryBuilder query + ) { + if ( arguments.builder.isPretending() ) { + arguments.query.pretend(); + } + return arguments.query; + } + + /** + * Creates an isolated clone without copying lazily instantiated collaborators. + */ + public QueryBuilder function cloneBuilder( required QueryBuilder builder ) { + var clonedQuery = arguments.builder.newQuery(); + copyQueryState( arguments.builder, clonedQuery ); + return clonedQuery; + } + + /** + * Clones a child builder and hoists statement-level CTEs when needed. + */ + public QueryBuilder function snapshotBuilder( required QueryBuilder owner, required QueryBuilder builder ) { + var snapshot = cloneBuilder( arguments.builder ); + var hoistTarget = arguments.owner.isJoin() ? arguments.owner.getJoiningQuery() : arguments.owner; + hoistNestedCommonTables( snapshot, hoistTarget ); + return snapshot; + } + + /** + * Captures the part of a builder changed while attaching nested CTEs. + */ + public struct function captureCommonTableState( required QueryBuilder builder ) { + return { + commonTableCount: arguments.builder.getCommonTables().len(), + commonTableBindingCount: arguments.builder.getRawBindings().commonTables.len() + }; + } + + /** + * Restores statement-level CTE state after a failed nested operation. + */ + public void function restoreCommonTableState( required QueryBuilder builder, required struct state ) { + arguments.builder.setCommonTables( + arguments.state.commonTableCount == 0 + ? [] + : arguments.builder.getCommonTables().slice( 1, arguments.state.commonTableCount ) + ); + arguments.builder.getRawBindings().commonTables = arguments.state.commonTableBindingCount == 0 + ? [] + : arguments.builder.getRawBindings().commonTables.slice( 1, arguments.state.commonTableBindingCount ); + } + + /** + * Moves SQL Server CTEs from an embedded query to its containing statement. + */ + public QueryBuilder function hoistNestedCommonTables( required QueryBuilder source, required QueryBuilder target ) { + if ( + !isInstanceOf( arguments.target.getGrammar(), "qb.models.Grammars.SqlServerGrammar" ) || + arguments.source.getCommonTables().isEmpty() + ) { + return arguments.source; + } + + var targetCommonTables = arguments.target.getCommonTables(); + targetCommonTables.append( arguments.source.getCommonTables(), true ); + arguments.target.setCommonTables( targetCommonTables ); + arguments.target.addBindings( arguments.source.getRawBindings().commonTables, "commonTables" ); + + arguments.source.setCommonTables( [] ); + arguments.source.getRawBindings().commonTables = []; + return arguments.source; + } + + /** + * Runs a callback with temporary selected columns and restores them. + */ + public any function withColumns( required QueryBuilder builder, required any columns, required any callback ) { + var originalColumns = [ { "type": "simple", "value": "*" } ]; + var shouldRestoreColumns = arguments.builder.getUnions().isEmpty(); + if ( shouldRestoreColumns ) { + originalColumns = arguments.builder.getColumns(); + arguments.builder.select( arguments.columns ); + } + var result = javacast( "null", "" ); + try { + result = arguments.callback(); + } finally { + if ( shouldRestoreColumns ) { + arguments.builder.select( originalColumns ); + } + } + return result; + } + + /** + * Runs a callback with temporary aggregate state and restores it. + */ + public any function withAggregate( required QueryBuilder builder, required struct aggregate, required any callback ) { + var originalAggregate = arguments.builder.getAggregate(); + var originalOrders = arguments.builder.getOrders(); + var originalAggregateBindings = arguments.builder.getRawBindings().aggregate; + arguments.builder.setAggregate( arguments.aggregate ); + arguments.builder.setOrders( [] ); + arguments.builder.getRawBindings().aggregate = []; + arguments.builder.addColumnBindings( [ arguments.aggregate.column ], "aggregate" ); + var result = javacast( "null", "" ); + try { + result = arguments.callback(); + } finally { + arguments.builder.setAggregate( originalAggregate ); + arguments.builder.setOrders( originalOrders ); + arguments.builder.getRawBindings().aggregate = originalAggregateBindings; + } + return result; + } + + private any function applyReturnFormat( required QueryBuilder builder, required any value ) { + var formatter = arguments.builder.getReturnFormat(); + + if ( isClosure( formatter ) || isCustomFunction( formatter ) ) { + return formatter( arguments.value ); + } + + if ( structKeyExists( formatter, "format" ) ) { + return formatter.format( arguments.value ); + } + + throw( + type = "InvalidFormat", + message = "The configured return formatter must be a closure or a component with a format method." + ); + } + + private void function copyQueryState( required QueryBuilder source, required QueryBuilder target ) { + arguments.target.setDistinct( arguments.source.getDistinct() ); + arguments.target.setAggregate( cloneQueryStateValue( arguments.source, arguments.source.getAggregate() ) ); + arguments.target.setColumns( cloneQueryStateValue( arguments.source, arguments.source.getColumns() ) ); + arguments.target.setTableName( cloneQueryStateValue( arguments.source, arguments.source.getTableName() ) ); + if ( !isNull( arguments.source.getForClause() ) ) { + arguments.target.setForClause( cloneQueryStateValue( arguments.source, arguments.source.getForClause() ) ); + } + arguments.target.setAlias( arguments.source.getAlias() ); + arguments.target.setLockType( arguments.source.getLockType() ); + arguments.target.setLockValue( arguments.source.getLockValue() ); + var clonedJoins = []; + for ( var join in arguments.source.getJoins() ) { + clonedJoins.append( cloneJoinClause( arguments.source, join, arguments.target ) ); + } + arguments.target.setJoins( clonedJoins ); + arguments.target.setWheres( cloneQueryStateValue( arguments.source, arguments.source.getWheres() ) ); + arguments.target.setGroups( cloneQueryStateValue( arguments.source, arguments.source.getGroups() ) ); + arguments.target.setHavings( cloneQueryStateValue( arguments.source, arguments.source.getHavings() ) ); + arguments.target.setUnions( cloneQueryStateValue( arguments.source, arguments.source.getUnions() ) ); + arguments.target.setOrders( cloneQueryStateValue( arguments.source, arguments.source.getOrders() ) ); + arguments.target.setCommonTables( cloneQueryStateValue( arguments.source, arguments.source.getCommonTables() ) ); + if ( !isNull( arguments.source.getLimitValue() ) ) { + arguments.target.setLimitValue( arguments.source.getLimitValue() ); + } + if ( !isNull( arguments.source.getOffsetValue() ) ) { + arguments.target.setOffsetValue( arguments.source.getOffsetValue() ); + } + arguments.target.setReturning( cloneQueryStateValue( arguments.source, arguments.source.getReturning() ) ); + arguments.target.setUpdates( cloneQueryStateValue( arguments.source, arguments.source.getUpdates() ) ); + arguments.target.setGrammarCompiledFrom( arguments.source.getGrammarCompiledFrom() ); + arguments.target.setGrammarCompiledJoin( arguments.source.getGrammarCompiledJoin() ); + + var sourceBindings = arguments.source.getRawBindings(); + for ( var bindingType in sourceBindings ) { + arguments.target.addBindings( + cloneQueryStateValue( arguments.source, sourceBindings[ bindingType ] ), + bindingType + ); + } + } + + public JoinClause function cloneJoinClause( + required QueryBuilder source, + required JoinClause join, + required QueryBuilder joiningQuery + ) { + var clonedJoin = new qb.models.Query.JoinClause( + arguments.joiningQuery, + arguments.join.getType(), + cloneQueryStateValue( arguments.source, arguments.join.getTable() ), + arguments.join.getLateralRawExpression(), + cloneQueryStateValue( arguments.source, arguments.join.getLateralBindings() ) + ); + copyQueryState( arguments.join, clonedJoin ); + return clonedJoin; + } + + private any function cloneQueryStateValue( required QueryBuilder source, any value ) { + if ( isSimpleValue( arguments.value ) ) { + return arguments.value; + } + if ( isNull( arguments.value ) ) { + return javacast( "null", "" ); + } + if ( arguments.source.getUtils().isBuilder( arguments.value ) ) { + return cloneBuilder( arguments.value ); + } + if ( arguments.source.getUtils().isExpression( arguments.value ) ) { + return new qb.models.Query.Expression( + arguments.value.getSQL(), + cloneQueryStateValue( arguments.source, arguments.value.getBindings() ) + ); + } + if ( isObject( arguments.value ) ) { + return arguments.value; + } + if ( isArray( arguments.value ) ) { + var clonedArray = []; + if ( !arguments.value.isEmpty() ) { + arrayResize( clonedArray, arguments.value.len() ); + } + for ( var i = 1; i <= arguments.value.len(); i++ ) { + if ( arrayIsDefined( arguments.value, i ) && !isNull( arguments.value[ i ] ) ) { + clonedArray[ i ] = cloneQueryStateValue( arguments.source, arguments.value[ i ] ); + } + } + return clonedArray; + } + if ( isStruct( arguments.value ) ) { + var clonedStruct = {}; + for ( var key in arguments.value ) { + clonedStruct[ key ] = isNull( arguments.value[ key ] ) + ? javacast( "null", "" ) + : cloneQueryStateValue( arguments.source, arguments.value[ key ] ); + } + return clonedStruct; + } + return arguments.value; + } + +} diff --git a/models/Query/QueryValidator.cfc b/models/Query/QueryValidator.cfc new file mode 100644 index 00000000..8e6449a6 --- /dev/null +++ b/models/Query/QueryValidator.cfc @@ -0,0 +1,196 @@ +/** + * Validates query builder inputs using an immutable snapshot of the builder's + * validation settings. + */ +component accessors="true" { + + property name="validateOperatorsAndCombinators" type="boolean"; + property name="validateDuplicateSelectColumns" type="boolean"; + property name="validateQueryExecuteReturnType" type="boolean"; + + variables.operators = [ + "=", + "<", + ">", + "<=", + ">=", + "<>", + "!=", + "like", + "like binary", + "not like", + "between", + "ilike", + "&", + "|", + "^", + "<<", + ">>", + "rlike", + "regexp", + "not regexp", + "~", + "~*", + "!~", + "!~*", + "similar to", + "not similar to" + ]; + variables.combinators = [ "AND", "OR" ]; + variables.directions = [ "asc", "desc" ]; + + /** + * Creates a validator for one immutable set of builder settings. + */ + public QueryValidator function init( + boolean validateOperatorsAndCombinators = true, + boolean validateDuplicateSelectColumns = false, + boolean validateQueryExecuteReturnType = false + ) { + variables.validateOperatorsAndCombinators = arguments.validateOperatorsAndCombinators; + variables.validateDuplicateSelectColumns = arguments.validateDuplicateSelectColumns; + variables.validateQueryExecuteReturnType = arguments.validateQueryExecuteReturnType; + return this; + } + + /** + * Returns whether a value is not a supported SQL operator. + */ + public boolean function isInvalidOperator( any operator ) { + if ( isNull( arguments.operator ) || !isSimpleValue( arguments.operator ) ) { + return true; + } + return !arrayContains( variables.operators, lCase( arguments.operator ) ); + } + + /** + * Throws when operator validation is enabled and the value is unsupported. + */ + public void function validateOperator( any operator ) { + if ( variables.validateOperatorsAndCombinators && isInvalidOperator( arguments.operator ) ) { + throw( type = "InvalidSQLType", message = "Illegal operator" ); + } + } + + /** + * Throws when combinator validation is enabled and the value is unsupported. + */ + public void function validateCombinator( required string combinator ) { + if ( + variables.validateOperatorsAndCombinators && + !arrayContains( variables.combinators, uCase( arguments.combinator ) ) + ) { + throw( type = "InvalidSQLType", message = "Illegal combinator" ); + } + } + + /** + * Throws when an ORDER BY direction is unsupported. + */ + public void function validateOrderDirection( required string direction ) { + if ( !arrayFindNoCase( variables.directions, trim( arguments.direction ) ) ) { + throw( type = "InvalidSQLType", message = "Illegal order direction" ); + } + } + + /** + * Validates that all statically identifiable select output names are unique. + */ + public void function validateUniqueSelectColumns( required array columns, required grammar ) { + if ( !variables.validateDuplicateSelectColumns ) { + return; + } + + var outputNames = {}; + for ( var column in arguments.columns ) { + var outputName = getSelectOutputName( column, arguments.grammar ); + if ( isNull( outputName ) ) { + continue; + } + + var normalizedName = normalizeSelectOutputName( outputName ); + if ( structKeyExists( outputNames, normalizedName ) ) { + throw( + type = "DuplicateSelectColumn", + message = "Multiple selected columns produce the output name [#outputName#].", + detail = "Alias one of the columns to produce unique result keys." + ); + } + outputNames[ normalizedName ] = true; + } + } + + /** + * Guards or removes native queryExecute return type options. + */ + public void function validateQueryExecuteOptions( required struct options ) { + if ( !arguments.options.keyExists( "returntype" ) ) { + return; + } + + if ( variables.validateQueryExecuteReturnType ) { + throw( + type = "InvalidQueryExecuteOption", + message = "The queryExecute returntype option cannot be used with qb return formatters." + ); + } + + structDelete( arguments.options, "returntype" ); + structDelete( arguments.options, "columnkey" ); + structDelete( arguments.options, "columnKey" ); + } + + /** + * Returns a statically identifiable output name for a selected column. + */ + private any function getSelectOutputName( required struct column, required grammar ) { + if ( arguments.column.type == "builder" ) { + return arguments.column.alias; + } + + if ( arguments.column.type == "raw" ) { + var rawSql = trim( arguments.column.value.getSQL() ); + var aliasMatch = reFindNoCase( + "\s+AS\s+((?:`[^`]+`)|(?:\[[^\]]+\])|(?:""[^""]+"")|(?:[A-Za-z_][A-Za-z0-9_$]*))\s*$", + rawSql, + 1, + true + ); + if ( aliasMatch.pos.len() < 2 || aliasMatch.pos[ 1 ] == 0 ) { + return; + } + return mid( rawSql, aliasMatch.pos[ 2 ], aliasMatch.len[ 2 ] ); + } + + if ( arguments.column.type == "simple" && find( "*", arguments.column.value ) ) { + return; + } + + if ( arguments.column.type == "jsonPath" && !arguments.column.keyExists( "alias" ) ) { + return; + } + + if ( listFindNoCase( "simple,jsonPath", arguments.column.type ) ) { + return arguments.grammar.extractAlias( arguments.column ); + } + } + + /** + * Normalizes an output name for the case-insensitive keys used by CFML structs. + */ + private string function normalizeSelectOutputName( required string outputName ) { + var normalizedName = trim( arguments.outputName ); + if ( + len( normalizedName ) >= 2 && + ( + ( left( normalizedName, 1 ) == "[" && right( normalizedName, 1 ) == "]" ) || + ( left( normalizedName, 1 ) == "`" && right( normalizedName, 1 ) == "`" ) || + ( left( normalizedName, 1 ) == """" && right( normalizedName, 1 ) == """" ) + ) + ) { + normalizedName = mid( normalizedName, 2, len( normalizedName ) - 2 ); + } + return lCase( normalizedName ); + } + +} diff --git a/tests/specs/Query/Abstract/QueryBuilderCollaboratorsSpec.cfc b/tests/specs/Query/Abstract/QueryBuilderCollaboratorsSpec.cfc new file mode 100644 index 00000000..3eb5c190 --- /dev/null +++ b/tests/specs/Query/Abstract/QueryBuilderCollaboratorsSpec.cfc @@ -0,0 +1,101 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "QueryBuilder collaborators", function() { + it( "does not instantiate collaborators during construction", function() { + var builder = prepareBuilder(); + + expect( getCollaborators( builder ) ).toBeEmpty(); + } ); + + it( "lazily instantiates JSON support", function() { + var builder = prepareBuilder( validateOperatorsAndCombinators = false ); + + builder.jsonPath( "profile->name" ); + + expect( getCollaborators( builder ) ).toHaveKey( "JsonQueryClause" ); + expect( getCollaborators( builder ) ).toHaveLength( 1 ); + } ); + + it( "lazily instantiates alias rewriting", function() { + var builder = prepareBuilder(); + + builder.from( "users AS u" ).withAlias( "members" ); + + expect( getCollaborators( builder ) ).toHaveKey( "AliasRewriter" ); + expect( getCollaborators( builder ) ).toHaveLength( 1 ); + } ); + + it( "lazily instantiates query execution", function() { + var builder = prepareBuilder( + validateOperatorsAndCombinators = false, + validateDuplicateSelectColumns = false, + validateQueryExecuteReturnType = false + ); + + builder + .pretend() + .from( "users" ) + .get(); + + expect( getCollaborators( builder ) ).toHaveKey( "QueryExecutor" ); + expect( getCollaborators( builder ) ).toHaveLength( 1 ); + } ); + + it( "lazily instantiates validation", function() { + var builder = prepareBuilder(); + + builder.where( "id", 1 ); + + expect( getCollaborators( builder ) ).toHaveKey( "QueryValidator" ); + expect( getCollaborators( builder ) ).toHaveLength( 1 ); + } ); + + it( "rebuilds validation after settings change", function() { + var builder = prepareBuilder(); + builder.where( "id", 1 ); + var originalValidator = getCollaborators( builder ).QueryValidator; + + builder.setValidateOperatorsAndCombinators( false ); + expect( getCollaborators( builder ) ).notToHaveKey( "QueryValidator" ); + + builder.where( "name", "Eric" ); + var rebuiltValidator = getCollaborators( builder ).QueryValidator; + + expect( originalValidator.getValidateOperatorsAndCombinators() ).toBeTrue(); + expect( rebuiltValidator.getValidateOperatorsAndCombinators() ).toBeFalse(); + expect( rebuiltValidator.getValidateDuplicateSelectColumns() ).toBeFalse(); + expect( rebuiltValidator.getValidateQueryExecuteReturnType() ).toBeFalse(); + } ); + + it( "copies validation settings without copying collaborators", function() { + var builder = prepareBuilder( + validateOperatorsAndCombinators = false, + validateDuplicateSelectColumns = true, + validateQueryExecuteReturnType = true + ); + builder.select( "id" ); + expect( function() { + builder.toSQL(); + } ).notToThrow(); + + var newBuilder = prepareMock( builder.newQuery() ); + + expect( getCollaborators( newBuilder ) ).toBeEmpty(); + expect( newBuilder.getValidateOperatorsAndCombinators() ).toBeFalse(); + expect( newBuilder.getValidateDuplicateSelectColumns() ).toBeTrue(); + expect( newBuilder.getValidateQueryExecuteReturnType() ).toBeTrue(); + } ); + } ); + } + + private QueryBuilder function prepareBuilder() { + var builder = new qb.models.Query.QueryBuilder( argumentCollection = arguments ); + return prepareMock( builder ); + } + + private struct function getCollaborators( required QueryBuilder builder ) { + return arguments.builder.$getProperty( name = "collaborators", scope = "variables" ); + } + +} diff --git a/tests/specs/Query/Abstract/QueryValidatorSpec.cfc b/tests/specs/Query/Abstract/QueryValidatorSpec.cfc new file mode 100644 index 00000000..8d308d8d --- /dev/null +++ b/tests/specs/Query/Abstract/QueryValidatorSpec.cfc @@ -0,0 +1,49 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "QueryValidator", function() { + it( "captures all validation settings", function() { + var validator = new qb.models.Query.QueryValidator( + validateOperatorsAndCombinators = false, + validateDuplicateSelectColumns = true, + validateQueryExecuteReturnType = true + ); + + expect( validator.getValidateOperatorsAndCombinators() ).toBeFalse(); + expect( validator.getValidateDuplicateSelectColumns() ).toBeTrue(); + expect( validator.getValidateQueryExecuteReturnType() ).toBeTrue(); + } ); + + it( "validates operators and combinators according to its configuration", function() { + var strictValidator = new qb.models.Query.QueryValidator( validateOperatorsAndCombinators = true ); + var relaxedValidator = new qb.models.Query.QueryValidator( validateOperatorsAndCombinators = false ); + + expect( function() { + strictValidator.validateOperator( "not-an-operator" ); + } ).toThrow( type = "InvalidSQLType" ); + expect( function() { + strictValidator.validateCombinator( "not-a-combinator" ); + } ).toThrow( type = "InvalidSQLType" ); + expect( function() { + relaxedValidator.validateOperator( "not-an-operator" ); + relaxedValidator.validateCombinator( "not-a-combinator" ); + } ).notToThrow(); + } ); + + it( "validates queryExecute return types according to its configuration", function() { + var strictValidator = new qb.models.Query.QueryValidator( validateQueryExecuteReturnType = true ); + var relaxedValidator = new qb.models.Query.QueryValidator( validateQueryExecuteReturnType = false ); + + expect( function() { + strictValidator.validateQueryExecuteOptions( { "returntype": "array" } ); + } ).toThrow( type = "InvalidQueryExecuteOption" ); + + var options = { "returntype": "array", "columnkey": "id" }; + relaxedValidator.validateQueryExecuteOptions( options ); + expect( options ).notToHaveKey( "returntype" ); + expect( options ).notToHaveKey( "columnkey" ); + } ); + } ); + } + +} From da8acd5074706f289cb2f00695f6aaecf7c5b7f9 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 16 Aug 2026 07:40:34 -0600 Subject: [PATCH 041/119] fix(QueryBuilder): handle aliases and null edge cases --- models/Grammars/BaseGrammar.cfc | 58 ++++++++++++----- models/Grammars/DerbyGrammar.cfc | 2 +- models/Grammars/MySQLGrammar.cfc | 10 ++- models/Grammars/PostgresGrammar.cfc | 4 +- models/Grammars/SQLiteGrammar.cfc | 4 +- models/Grammars/SqlServerGrammar.cfc | 16 +++-- models/Query/AliasRewriter.cfc | 12 +++- models/Query/JsonQueryBuilderSupport.cfc | 33 ++++++---- models/Query/QueryBuilder.cfc | 63 +++++++++++-------- models/Query/QueryUtils.cfc | 8 +-- tests/resources/AbstractQueryBuilderSpec.cfc | 15 +++++ .../Query/Abstract/BindingLifecycleSpec.cfc | 11 ++++ .../specs/Query/Abstract/BuilderAliasSpec.cfc | 12 ++++ .../specs/Query/Abstract/BuilderWhereSpec.cfc | 13 ++++ tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 9 +++ tests/specs/Query/DerbyQueryBuilderSpec.cfc | 8 +++ tests/specs/Query/MySQLQueryBuilderSpec.cfc | 14 +++++ tests/specs/Query/OracleQueryBuilderSpec.cfc | 8 +++ .../specs/Query/PostgresQueryBuilderSpec.cfc | 11 ++++ tests/specs/Query/SQLiteQueryBuilderSpec.cfc | 8 +++ .../specs/Query/SqlServerQueryBuilderSpec.cfc | 11 ++++ 21 files changed, 259 insertions(+), 71 deletions(-) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 7b7bdea5..6413ff58 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -351,11 +351,7 @@ component displayname="Grammar" accessors="true" singleton { return ""; } - var fullTable = arguments.tableName; - if ( query.getAlias() != "" ) { - fullTable &= " #query.getAlias()#"; - } - return "FROM " & wrapTable( fullTable ); + return "FROM " & wrapQueryTable( arguments.query ); } private string function compileForClause( required QueryBuilder query, any forClause ) { @@ -656,11 +652,15 @@ component displayname="Grammar" accessors="true" singleton { * @return string */ private string function whereBetween( required QueryBuilder query, required struct where ) { - var start = variables.utils.isExpression( where.start ) ? where.start.getSql() : ( - variables.utils.isBuilder( where.start ) ? "(#compileSelect( where.start )#)" : "?" + var start = !where.keyExists( "start" ) || isNull( where.start ) ? "?" : ( + variables.utils.isExpression( where.start ) ? where.start.getSql() : ( + variables.utils.isBuilder( where.start ) ? "(#compileSelect( where.start )#)" : "?" + ) ); - var end = variables.utils.isExpression( where.end ) ? where.end.getSql() : ( - variables.utils.isBuilder( where.end ) ? "(#compileSelect( where.end )#)" : "?" + var end = !where.keyExists( "end" ) || isNull( where.end ) ? "?" : ( + variables.utils.isExpression( where.end ) ? where.end.getSql() : ( + variables.utils.isBuilder( where.end ) ? "(#compileSelect( where.end )#)" : "?" + ) ); return "#wrapColumn( where.column )# BETWEEN #start# AND #end#"; } @@ -674,11 +674,15 @@ component displayname="Grammar" accessors="true" singleton { * @return string */ private string function whereNotBetween( required QueryBuilder query, required struct where ) { - var start = variables.utils.isExpression( where.start ) ? where.start.getSql() : ( - variables.utils.isBuilder( where.start ) ? "(#compileSelect( where.start )#)" : "?" + var start = !where.keyExists( "start" ) || isNull( where.start ) ? "?" : ( + variables.utils.isExpression( where.start ) ? where.start.getSql() : ( + variables.utils.isBuilder( where.start ) ? "(#compileSelect( where.start )#)" : "?" + ) ); - var end = variables.utils.isExpression( where.end ) ? where.end.getSql() : ( - variables.utils.isBuilder( where.end ) ? "(#compileSelect( where.end )#)" : "?" + var end = !where.keyExists( "end" ) || isNull( where.end ) ? "?" : ( + variables.utils.isExpression( where.end ) ? where.end.getSql() : ( + variables.utils.isBuilder( where.end ) ? "(#compileSelect( where.end )#)" : "?" + ) ); return "#wrapColumn( where.column )# NOT BETWEEN #start# AND #end#"; } @@ -830,7 +834,9 @@ component displayname="Grammar" accessors="true" singleton { if ( having.type == "raw" ) { return trim( "#having.combinator# #having.column.getSQL()#" ); } - var placeholder = variables.utils.isExpression( having.value ) ? having.value.getSQL() : "?"; + var placeholder = !isNull( having.value ) && variables.utils.isExpression( having.value ) + ? having.value.getSQL() + : "?"; return trim( "#having.combinator# #wrapColumn( having.column )# #having.operator# #placeholder#" ); } @@ -1209,7 +1215,7 @@ component displayname="Grammar" accessors="true" singleton { } ) .toList( ", " ); - var updateStatement = "UPDATE #wrapTable( query.getTableName() )#"; + var updateStatement = "UPDATE #wrapQueryTable( query )#"; if ( !arguments.query.getJoins().isEmpty() ) { updateStatement &= " " & compileJoins( arguments.query, arguments.query.getJoins() ); @@ -1259,7 +1265,7 @@ component displayname="Grammar" accessors="true" singleton { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - return trim( "DELETE FROM #wrapTable( query.getTableName() )# #compileWheres( query, query.getWheres() )#" ); + return trim( "DELETE FROM #wrapQueryTable( query )# #compileWheres( query, query.getWheres() )#" ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); @@ -1374,6 +1380,26 @@ component displayname="Grammar" accessors="true" singleton { return parts.table & getTableAliasOperator() & wrapAlias( getTablePrefix() & parts.alias ); } + /** + * Wraps a builder's table together with its separately tracked alias. + * + * @query The query builder whose table should be wrapped. + * @includeAlias Whether to include the builder's table alias. + * + * @return string + */ + public string function wrapQueryTable( required QueryBuilder query, boolean includeAlias = true ) { + var table = arguments.query.getTableName(); + if ( + arguments.includeAlias && + !getUtils().isExpression( table ) && + arguments.query.getAlias() != "" + ) { + table &= " #arguments.query.getAlias()#"; + } + return wrapTable( table, arguments.includeAlias ); + } + public struct function explodeTable( required string table ) { var parts = { "alias": "", "table": trim( arguments.table ) }; diff --git a/models/Grammars/DerbyGrammar.cfc b/models/Grammars/DerbyGrammar.cfc index 832d5bc2..8f8fc03e 100644 --- a/models/Grammars/DerbyGrammar.cfc +++ b/models/Grammars/DerbyGrammar.cfc @@ -227,7 +227,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } ) .toList( ", " ); - var updateStatement = "UPDATE #wrapTable( query.getTableName() )#"; + var updateStatement = "UPDATE #wrapQueryTable( query )#"; if ( !arguments.query.getJoins().isEmpty() ) { updateStatement &= " " & compileJoins( arguments.query, arguments.query.getJoins() ); diff --git a/models/Grammars/MySQLGrammar.cfc b/models/Grammars/MySQLGrammar.cfc index 99340bd9..88d92168 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -314,9 +314,15 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { [ compileCommonTables( query, query.getCommonTables() ), "DELETE", - hasJoins ? wrapTable( query.getTableName() ) : "", + hasJoins + ? ( + query.getAlias() != "" + ? wrapAlias( getTablePrefix() & query.getAlias() ) + : wrapTable( query.getTableName(), false ) + ) + : "", "FROM", - wrapTable( query.getTableName() ), + wrapQueryTable( query ), hasJoins ? compileJoins( query, query.getJoins() ) : "", compileWheres( query, query.getWheres() ) ], diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index ea662f7f..1807cdba 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -209,7 +209,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } ) .toList( ", " ); - var updateStatement = "UPDATE #wrapTable( query.getTableName() )# SET #updateList#"; + var updateStatement = "UPDATE #wrapQueryTable( query )# SET #updateList#"; var joins = arguments.query.getJoins(); @@ -302,7 +302,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { .toList( ", " ); var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; return trim( - compileCommonTables( query, query.getCommonTables() ) & " DELETE FROM #wrapTable( query.getTableName() )# #compileWheres( query, query.getWheres() )##returningClause#" + compileCommonTables( query, query.getCommonTables() ) & " DELETE FROM #wrapQueryTable( query )# #compileWheres( query, query.getWheres() )##returningClause#" ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index 8fb7a542..75abd2b8 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -192,7 +192,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } ) .toList( ", " ); - var updateStatement = "UPDATE #wrapTable( query.getTableName() )# SET #updateList#"; + var updateStatement = "UPDATE #wrapQueryTable( query )# SET #updateList#"; var joins = arguments.query.getJoins(); @@ -285,7 +285,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { .toList( ", " ); var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; return trim( - compileCommonTables( query, query.getCommonTables() ) & " DELETE FROM #wrapTable( query.getTableName() )# #compileWheres( query, query.getWheres() )##returningClause#" + compileCommonTables( query, query.getCommonTables() ) & " DELETE FROM #wrapQueryTable( query )# #compileWheres( query, query.getWheres() )##returningClause#" ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index fa26f89e..19544a44 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -573,7 +573,9 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { .toList( ", " ); var updateTable = ""; - if ( !getUtils().isExpression( query.getTableName() ) ) { + if ( arguments.query.getAlias() != "" ) { + updateTable = wrapAlias( arguments.query.getAlias() ); + } else if ( !getUtils().isExpression( query.getTableName() ) ) { var parts = explodeTable( query.getTableName() ); updateTable = parts.alias.len() ? wrapAlias( parts.alias ) : wrapTable( parts.table ); } else { @@ -611,7 +613,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } return trim( - compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & returningClause & " FROM #wrapTable( query.getTableName() )# " & compileJoins( + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & returningClause & " FROM #wrapQueryTable( query )# " & compileJoins( arguments.query, arguments.query.getJoins() ) & " " & compileWheres( query, query.getWheres() ) @@ -659,9 +661,15 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { [ compileCommonTables( query, query.getCommonTables() ), "DELETE", - hasJoins ? wrapTable( query.getTableName() ) : "", + hasJoins + ? ( + query.getAlias() != "" + ? wrapAlias( getTablePrefix() & query.getAlias() ) + : wrapTable( query.getTableName(), false ) + ) + : "", "FROM", - wrapTable( query.getTableName() ), + wrapQueryTable( query ), returningClause, hasJoins ? compileJoins( query, query.getJoins() ) : "", compileWheres( query, query.getWheres() ) diff --git a/models/Query/AliasRewriter.cfc b/models/Query/AliasRewriter.cfc index dc231095..1401631f 100644 --- a/models/Query/AliasRewriter.cfc +++ b/models/Query/AliasRewriter.cfc @@ -364,7 +364,11 @@ component { required string newAlias ) { renameAliasInTypedColumn( arguments.where.column, arguments.oldAlias, arguments.newAlias ); - if ( arguments.builder.getUtils().isBuilder( arguments.where.start ) ) { + if ( + arguments.where.keyExists( "start" ) && + !isNull( arguments.where.start ) && + arguments.builder.getUtils().isBuilder( arguments.where.start ) + ) { renameAliasesInNestedQuery( arguments.builder, arguments.where.start, @@ -372,7 +376,11 @@ component { arguments.newAlias ); } - if ( arguments.builder.getUtils().isBuilder( arguments.where.end ) ) { + if ( + arguments.where.keyExists( "end" ) && + !isNull( arguments.where.end ) && + arguments.builder.getUtils().isBuilder( arguments.where.end ) + ) { renameAliasesInNestedQuery( arguments.builder, arguments.where.end, diff --git a/models/Query/JsonQueryBuilderSupport.cfc b/models/Query/JsonQueryBuilderSupport.cfc index 2f75be1f..08a7f2c9 100644 --- a/models/Query/JsonQueryBuilderSupport.cfc +++ b/models/Query/JsonQueryBuilderSupport.cfc @@ -34,7 +34,7 @@ component { public QueryBuilder function whereJsonContains( required string column, any path = [], - any value, + any value = "__QB_INTERNAL_JSON_VALUE_OMITTED_8E1A33F6__", string combinator = "and", boolean negate = false ) { @@ -42,14 +42,11 @@ component { getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); } - var valueWasOmitted = !arguments.keyExists( "value" ); - if ( isNull( arguments.value ) ) { - var pathCarriesValue = !isArray( arguments.path ) || - ( arguments.column.find( "->" ) > 0 && !arguments.path.isEmpty() ); - valueWasOmitted = server.keyExists( "boxlang" ) - ? pathCarriesValue - : valueWasOmitted || pathCarriesValue; - } + // Native BoxLang materializes omitted optional arguments as null, so use a non-null + // default token here because null is also a valid JSON containment value. + var valueWasOmitted = !isNull( arguments.value ) && + isSimpleValue( arguments.value ) && + arguments.value == "__QB_INTERNAL_JSON_VALUE_OMITTED_8E1A33F6__"; if ( valueWasOmitted ) { arguments.value = arguments.path; arguments.path = []; @@ -70,15 +67,27 @@ component { ); } - public QueryBuilder function orWhereJsonContains( required string column, any path = [], any value ) { + public QueryBuilder function orWhereJsonContains( + required string column, + any path = [], + any value = "__QB_INTERNAL_JSON_VALUE_OMITTED_8E1A33F6__" + ) { return whereJsonContains( argumentCollection = arguments, combinator = "or" ); } - public QueryBuilder function whereJsonDoesntContain( required string column, any path = [], any value ) { + public QueryBuilder function whereJsonDoesntContain( + required string column, + any path = [], + any value = "__QB_INTERNAL_JSON_VALUE_OMITTED_8E1A33F6__" + ) { return whereJsonContains( argumentCollection = arguments, negate = true ); } - public QueryBuilder function orWhereJsonDoesntContain( required string column, any path = [], any value ) { + public QueryBuilder function orWhereJsonDoesntContain( + required string column, + any path = [], + any value = "__QB_INTERNAL_JSON_VALUE_OMITTED_8E1A33F6__" + ) { return whereJsonContains( argumentCollection = arguments, combinator = "or", negate = true ); } diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 9f8e3003..bd67bb5b 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -804,14 +804,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function table( required any table ) { - clearBindings( only = [ "from" ] ); - variables.grammarCompiledFrom = false; - variables.alias = ""; - variables.tableName = arguments.table; - if ( getUtils().isExpression( arguments.table ) ) { - addExpressionBindings( arguments.table, "from" ); - } - return this; + return from( arguments.table ); } /** @@ -2349,48 +2342,58 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J var type = negate ? "notBetween" : "between"; var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); - if ( isClosure( arguments.start ) || isCustomFunction( arguments.start ) ) { + if ( !isNull( arguments.start ) && ( isClosure( arguments.start ) || isCustomFunction( arguments.start ) ) ) { var callback = arguments.start; arguments.start = newQuery(); callback( arguments.start ); } - if ( isClosure( arguments.end ) || isCustomFunction( arguments.end ) ) { + if ( !isNull( arguments.end ) && ( isClosure( arguments.end ) || isCustomFunction( arguments.end ) ) ) { var callback = arguments.end; arguments.end = newQuery(); callback( arguments.end ); } - if ( getUtils().isBuilder( arguments.start ) ) { + if ( !isNull( arguments.start ) && getUtils().isBuilder( arguments.start ) ) { arguments.start = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.start ); } - if ( getUtils().isBuilder( arguments.end ) ) { + if ( !isNull( arguments.end ) && getUtils().isBuilder( arguments.end ) ) { arguments.end = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.end ); } addColumnBindings( [ typedColumn ], "where" ); - if ( utils.isExpression( arguments.start ) ) { + if ( !isNull( arguments.start ) && utils.isExpression( arguments.start ) ) { addExpressionBindings( arguments.start, "where" ); } else { - addBindings( utils.extractBinding( arguments.start, variables.grammar ), "where" ); + addBindings( + isNull( arguments.start ) + ? utils.extractBinding( grammar = variables.grammar ) + : utils.extractBinding( arguments.start, variables.grammar ), + "where" + ); } - if ( utils.isExpression( arguments.end ) ) { + if ( !isNull( arguments.end ) && utils.isExpression( arguments.end ) ) { addExpressionBindings( arguments.end, "where" ); } else { - addBindings( utils.extractBinding( arguments.end, variables.grammar ), "where" ); + addBindings( + isNull( arguments.end ) + ? utils.extractBinding( grammar = variables.grammar ) + : utils.extractBinding( arguments.end, variables.grammar ), + "where" + ); } if ( - isStruct( arguments.start ) && !structKeyExists( arguments.start, "isBuilder" ) && structKeyExists( + !isNull( arguments.start ) && isStruct( arguments.start ) && !structKeyExists( arguments.start, - "value" - ) + "isBuilder" + ) && structKeyExists( arguments.start, "value" ) ) { arguments.start = arguments.start.value; } if ( - isStruct( arguments.end ) && !structKeyExists( arguments.end, "isBuilder" ) && structKeyExists( + !isNull( arguments.end ) && isStruct( arguments.end ) && !structKeyExists( arguments.end, "isBuilder" ) && structKeyExists( arguments.end, "value" ) @@ -2401,8 +2404,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J variables.wheres.append( { type: type, column: typedColumn, - start: arguments.start, - end: arguments.end, + start: isNull( arguments.start ) ? javacast( "null", "" ) : arguments.start, + end: isNull( arguments.end ) ? javacast( "null", "" ) : arguments.end, combinator: arguments.combinator } ); @@ -2515,7 +2518,10 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return this; } - if ( isNull( arguments.value ) ) { + if ( + isNull( arguments.value ) && + getCollaborator( "QueryValidator" ).isInvalidOperator( arguments.operator ) + ) { arguments.value = arguments.operator; arguments.operator = "="; } else { @@ -2528,7 +2534,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J type: "normal", column: mapToColumnType( applyColumnFormatter( arguments.column ) ), operator: arguments.operator, - value: arguments.value, + value: isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value, combinator: arguments.combinator } ); @@ -2544,10 +2550,15 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J ); } - if ( getUtils().isExpression( arguments.value ) ) { + if ( !isNull( arguments.value ) && getUtils().isExpression( arguments.value ) ) { addExpressionBindings( arguments.value, "having" ); } else { - addBindings( utils.extractBinding( arguments.value, variables.grammar ), "having" ); + addBindings( + isNull( arguments.value ) + ? utils.extractBinding( grammar = variables.grammar ) + : utils.extractBinding( arguments.value, variables.grammar ), + "having" + ); } return this; diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index c1466568..88efb179 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -554,7 +554,7 @@ component singleton displayname="QueryUtils" accessors="true" { * * @return boolean */ - public boolean function isExpression( required any value ) { + public boolean function isExpression( any value ) { return !isNull( arguments.value ) && !isSimpleValue( arguments.value ) && !isArray( arguments.value ) && @@ -568,7 +568,7 @@ component singleton displayname="QueryUtils" accessors="true" { * * @return boolean */ - public boolean function isNotExpression( required any value ) { + public boolean function isNotExpression( any value ) { if ( isNull( arguments.value ) ) { return true; } @@ -584,7 +584,7 @@ component singleton displayname="QueryUtils" accessors="true" { * * @return boolean */ - public boolean function isBuilder( required any value ) { + public boolean function isBuilder( any value ) { if ( isNull( arguments.value ) ) { return false; } @@ -604,7 +604,7 @@ component singleton displayname="QueryUtils" accessors="true" { * * @return boolean */ - public boolean function isNotBuilder( required any value ) { + public boolean function isNotBuilder( any value ) { return !isBuilder( isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value ); } diff --git a/tests/resources/AbstractQueryBuilderSpec.cfc b/tests/resources/AbstractQueryBuilderSpec.cfc index df15a220..dde55a2b 100644 --- a/tests/resources/AbstractQueryBuilderSpec.cfc +++ b/tests/resources/AbstractQueryBuilderSpec.cfc @@ -2882,6 +2882,12 @@ component extends="testbox.system.BaseSpec" { }, jsonCompoundContains() ); } ); + it( "preserves an empty array passed as a shortcut containment value", function() { + testCase( function( builder ) { + return builder.from( "users" ).whereJsonContains( "profile->languages", [] ); + }, jsonEmptyCompoundContains() ); + } ); + it( title = "preserves explicit paths when checking containment for JSON null", body = function() { @@ -3689,6 +3695,15 @@ component extends="testbox.system.BaseSpec" { .delete( toSql = true ); }, deleteWithJoins() ); } ); + + it( "can handle delete statements with aliased joins", function() { + testCase( function( builder ) { + return builder + .from( "users u" ) + .join( "warnings w", "u.id", "w.userId" ) + .delete( toSql = true ); + }, deleteWithJoinsAndAliases() ); + } ); } ); } ); } diff --git a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc index de68ee87..ae6eda2f 100644 --- a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc +++ b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc @@ -35,6 +35,17 @@ component extends="testbox.system.BaseSpec" { expect( builder.getBindings() ).toHaveLength( 1 ); expect( builder.getBindings()[ 1 ].value ).toBe( "new user" ); } ); + + it( "preserves an explicit null HAVING value", function() { + var builder = new qb.models.Query.QueryBuilder() + .from( "users" ) + .having( "score", "=", javacast( "null", "" ) ); + + expect( builder.getHavings()[ 1 ].operator ).toBe( "=" ); + expect( builder.getRawBindings().having[ 1 ].null ).toBeTrue(); + expect( builder.getRawBindings().having[ 1 ].value ).toBe( "" ); + expect( builder.toSQL() ).toBe( "SELECT * FROM ""users"" HAVING ""score"" = ?" ); + } ); } ); } diff --git a/tests/specs/Query/Abstract/BuilderAliasSpec.cfc b/tests/specs/Query/Abstract/BuilderAliasSpec.cfc index 42ed0c43..dae4e78d 100644 --- a/tests/specs/Query/Abstract/BuilderAliasSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderAliasSpec.cfc @@ -12,6 +12,18 @@ component extends="testbox.system.BaseSpec" { expect( qb.toSQL() ).toBe( "SELECT ""u"".""id"" FROM ""users"" AS ""u""" ); } ); + it( "keeps table() equivalent to from() when replacing an alias", () => { + var qb = new qb.models.Query.QueryBuilder(); + + qb.table( "users AS u" ) + .select( "u.id" ) + .withAlias( "members" ); + + expect( qb.getTableName() ).toBe( "users" ); + expect( qb.getAlias() ).toBe( "members" ); + expect( qb.toSQL() ).toBe( "SELECT ""members"".""id"" FROM ""users"" AS ""members""" ); + } ); + it( "parses join aliases separated by repeated whitespace", () => { var qb = new qb.models.Query.QueryBuilder(); diff --git a/tests/specs/Query/Abstract/BuilderWhereSpec.cfc b/tests/specs/Query/Abstract/BuilderWhereSpec.cfc index cee1e83f..2a612bfc 100644 --- a/tests/specs/Query/Abstract/BuilderWhereSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderWhereSpec.cfc @@ -135,6 +135,19 @@ component extends="testbox.system.BaseSpec" { expect( builder.getBindings()[ 1 ].null ).toBeTrue(); } ); + it( "accepts explicit null BETWEEN bounds", function() { + var builder = new qb.models.Query.QueryBuilder() + .from( "users" ) + .whereBetween( "users.age", javacast( "null", "" ), 10 ) + .withAlias( "u" ); + + expect( builder.toSQL() ).toBe( + "SELECT * FROM ""users"" AS ""u"" WHERE ""u"".""age"" BETWEEN ? AND ?" + ); + expect( builder.getBindings()[ 1 ].null ).toBeTrue(); + expect( builder.getBindings()[ 2 ].value ).toBe( 10 ); + } ); + it( "has a orWhere shortcut", function() { qb.orWhere( "::some column::", "<>", "::some value::" ); diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index 05edc0ea..22a3b266 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -10,6 +10,15 @@ component extends="testbox.system.BaseSpec" { } function run() { + describe( "null type predicates", function() { + it( "classifies null values without throwing", function() { + expect( utils.isExpression( javacast( "null", "" ) ) ).toBeFalse(); + expect( utils.isNotExpression( javacast( "null", "" ) ) ).toBeTrue(); + expect( utils.isBuilder( javacast( "null", "" ) ) ).toBeFalse(); + expect( utils.isNotBuilder( javacast( "null", "" ) ) ).toBeTrue(); + } ); + } ); + describe( "inferSqlType()", function() { it( "maintains the passed in cfsqltype if provided", () => { var binding = utils.extractBinding( { "value": 1, "cfsqltype": "BIT" }, variables.mockGrammar ); diff --git a/tests/specs/Query/DerbyQueryBuilderSpec.cfc b/tests/specs/Query/DerbyQueryBuilderSpec.cfc index 2fbafc21..484aac29 100644 --- a/tests/specs/Query/DerbyQueryBuilderSpec.cfc +++ b/tests/specs/Query/DerbyQueryBuilderSpec.cfc @@ -1139,6 +1139,10 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { exception: "UnsupportedOperation" }; } + function deleteWithJoinsAndAliases() { + return { exception: "UnsupportedOperation" }; + } + function whereBuilderInstance() { return { sql: "SELECT * FROM ""users"" WHERE ""email"" = ? OR ""id"" = (SELECT MAX(id) FROM ""users"" WHERE ""email"" = ?)", @@ -1282,6 +1286,10 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { exception: "UnsupportedOperation" }; } + function jsonEmptyCompoundContains() { + return { exception: "UnsupportedOperation" }; + } + function jsonNullContains() { return { exception: "UnsupportedOperation" }; } diff --git a/tests/specs/Query/MySQLQueryBuilderSpec.cfc b/tests/specs/Query/MySQLQueryBuilderSpec.cfc index 7d730b7c..e1f22788 100644 --- a/tests/specs/Query/MySQLQueryBuilderSpec.cfc +++ b/tests/specs/Query/MySQLQueryBuilderSpec.cfc @@ -1191,6 +1191,13 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function deleteWithJoinsAndAliases() { + return { + sql: "DELETE `u` FROM `users` AS `u` INNER JOIN `warnings` AS `w` ON `u`.`id` = `w`.`userId`", + bindings: [] + }; + } + function whereBuilderInstance() { return { sql: "SELECT * FROM `users` WHERE `email` = ? OR `id` = (SELECT MAX(id) FROM `users` WHERE `email` = ?)", @@ -1349,6 +1356,13 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function jsonEmptyCompoundContains() { + return { + sql: "SELECT * FROM `users` WHERE JSON_CONTAINS(`profile`, ?, '$.""languages""')", + bindings: [ serializeJSON( [] ) ] + }; + } + function jsonNullContains() { return { sql: "SELECT * FROM `users` WHERE JSON_CONTAINS(`profile`, ?, '$.""languages""') AND JSON_CONTAINS(`profile`, ?, '$.""languages""')", diff --git a/tests/specs/Query/OracleQueryBuilderSpec.cfc b/tests/specs/Query/OracleQueryBuilderSpec.cfc index 6663ef51..a270c591 100644 --- a/tests/specs/Query/OracleQueryBuilderSpec.cfc +++ b/tests/specs/Query/OracleQueryBuilderSpec.cfc @@ -1185,6 +1185,10 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { exception: "UnsupportedOperation" }; } + function deleteWithJoinsAndAliases() { + return { exception: "UnsupportedOperation" }; + } + function whereBuilderInstance() { return { sql: "SELECT * FROM ""USERS"" WHERE ""EMAIL"" = ? OR ""ID"" = (SELECT MAX(id) FROM ""USERS"" WHERE ""EMAIL"" = ?)", @@ -1340,6 +1344,10 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { exception: "UnsupportedOperation" }; } + function jsonEmptyCompoundContains() { + return { exception: "UnsupportedOperation" }; + } + function jsonNullContains() { return { sql: "SELECT * FROM ""USERS"" WHERE JSON_EXISTS(""PROFILE"", '$.""languages""[*]?(@ == $value)' PASSING ? AS ""value"") AND JSON_EXISTS(""PROFILE"", '$.""languages""[*]?(@ == $value)' PASSING ? AS ""value"")", diff --git a/tests/specs/Query/PostgresQueryBuilderSpec.cfc b/tests/specs/Query/PostgresQueryBuilderSpec.cfc index 2e212533..774c7a3b 100644 --- a/tests/specs/Query/PostgresQueryBuilderSpec.cfc +++ b/tests/specs/Query/PostgresQueryBuilderSpec.cfc @@ -1236,6 +1236,10 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { exception: "UnsupportedOperation" }; } + function deleteWithJoinsAndAliases() { + return { exception: "UnsupportedOperation" }; + } + function whereBuilderInstance() { return { sql: "SELECT * FROM ""users"" WHERE ""email"" = ? OR ""id"" = (SELECT MAX(id) FROM ""users"" WHERE ""email"" = ?)", @@ -1394,6 +1398,13 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function jsonEmptyCompoundContains() { + return { + sql: "SELECT * FROM ""users"" WHERE (""profile""->'languages')::jsonb @> ?::jsonb", + bindings: [ serializeJSON( [] ) ] + }; + } + function jsonNullContains() { return { sql: "SELECT * FROM ""users"" WHERE (""profile""->'languages')::jsonb @> ?::jsonb AND (""profile""->'languages')::jsonb @> ?::jsonb", diff --git a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc index 08719cf7..00963dae 100644 --- a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc +++ b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc @@ -1322,6 +1322,10 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { exception: "UnsupportedOperation" }; } + function deleteWithJoinsAndAliases() { + return { exception: "UnsupportedOperation" }; + } + function crossApply() { return { exception: "UnsupportedOperation" } } @@ -1385,6 +1389,10 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { exception: "UnsupportedOperation" }; } + function jsonEmptyCompoundContains() { + return { exception: "UnsupportedOperation" }; + } + function jsonNullContains() { return { sql: "SELECT * FROM ""users"" WHERE EXISTS (SELECT 1 FROM JSON_EACH(""profile"", '$.""languages""') WHERE ""json_each"".""value"" IS ?) AND EXISTS (SELECT 1 FROM JSON_EACH(""profile"", '$.""languages""') WHERE ""json_each"".""value"" IS ?)", diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index dfaf0f8d..56809be4 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -1591,6 +1591,13 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function deleteWithJoinsAndAliases() { + return { + sql: "DELETE [u] FROM [users] AS [u] INNER JOIN [warnings] AS [w] ON [u].[id] = [w].[userId]", + bindings: [] + }; + } + function whereBuilderInstance() { return { sql: "SELECT * FROM [users] WHERE [email] = ? OR [id] = (SELECT MAX(id) FROM [users] WHERE [email] = ?)", @@ -1758,6 +1765,10 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { exception: "UnsupportedOperation" }; } + function jsonEmptyCompoundContains() { + return { exception: "UnsupportedOperation" }; + } + function jsonNullContains() { return { sql: "SELECT * FROM [users] WHERE EXISTS (SELECT 1 FROM OPENJSON([profile], '$.""languages""') WHERE [type] = 0 AND ? IS NULL) AND EXISTS (SELECT 1 FROM OPENJSON([profile], '$.""languages""') WHERE [type] = 0 AND ? IS NULL)", From cb840d7e34276c7267afe5eaecf9d41c62d2ab53 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 16 Aug 2026 07:52:53 -0600 Subject: [PATCH 042/119] fix(JsonQueryClause): preserve BoxLang null containment shortcuts --- models/Query/JsonQueryBuilderSupport.cfc | 45 +++++++++--------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/models/Query/JsonQueryBuilderSupport.cfc b/models/Query/JsonQueryBuilderSupport.cfc index 08a7f2c9..f2d66174 100644 --- a/models/Query/JsonQueryBuilderSupport.cfc +++ b/models/Query/JsonQueryBuilderSupport.cfc @@ -33,8 +33,8 @@ component { */ public QueryBuilder function whereJsonContains( required string column, - any path = [], - any value = "__QB_INTERNAL_JSON_VALUE_OMITTED_8E1A33F6__", + any path, + any value, string combinator = "and", boolean negate = false ) { @@ -42,19 +42,20 @@ component { getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); } - // Native BoxLang materializes omitted optional arguments as null, so use a non-null - // default token here because null is also a valid JSON containment value. - var valueWasOmitted = !isNull( arguments.value ) && - isSimpleValue( arguments.value ) && - arguments.value == "__QB_INTERNAL_JSON_VALUE_OMITTED_8E1A33F6__"; - if ( valueWasOmitted ) { - arguments.value = arguments.path; - arguments.path = []; - } + // BoxLang applies defaults to explicit nulls in full-null mode, so `path` intentionally + // has no default. Arrow syntax then distinguishes shortcut null and empty-array values, + // while explicit path arrays retain their separately supplied null value. + var pathIsNull = isNull( arguments.path ); + var valueWasOmitted = isNull( arguments.value ) && + ( pathIsNull || !isArray( arguments.path ) || arguments.column.find( "->" ) > 0 ); - var valueDefinition = { isNull: isNull( arguments.value ) }; + var valueDefinition = { isNull: valueWasOmitted ? pathIsNull : isNull( arguments.value ) }; if ( !valueDefinition.isNull ) { - valueDefinition.value = arguments.value; + valueDefinition.value = valueWasOmitted ? arguments.path : arguments.value; + } + + if ( valueWasOmitted || pathIsNull ) { + arguments.path = []; } return getCollaborator( "JsonQueryClause" ).whereJsonContains( @@ -67,27 +68,15 @@ component { ); } - public QueryBuilder function orWhereJsonContains( - required string column, - any path = [], - any value = "__QB_INTERNAL_JSON_VALUE_OMITTED_8E1A33F6__" - ) { + public QueryBuilder function orWhereJsonContains( required string column, any path, any value ) { return whereJsonContains( argumentCollection = arguments, combinator = "or" ); } - public QueryBuilder function whereJsonDoesntContain( - required string column, - any path = [], - any value = "__QB_INTERNAL_JSON_VALUE_OMITTED_8E1A33F6__" - ) { + public QueryBuilder function whereJsonDoesntContain( required string column, any path, any value ) { return whereJsonContains( argumentCollection = arguments, negate = true ); } - public QueryBuilder function orWhereJsonDoesntContain( - required string column, - any path = [], - any value = "__QB_INTERNAL_JSON_VALUE_OMITTED_8E1A33F6__" - ) { + public QueryBuilder function orWhereJsonDoesntContain( required string column, any path, any value ) { return whereJsonContains( argumentCollection = arguments, combinator = "or", negate = true ); } From 9716e1bb9f8ec1696e64b4bdfd0ee2d54ac53be1 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 16 Aug 2026 08:05:29 -0600 Subject: [PATCH 043/119] fix(SqlServerGrammar): declare aliases in data modifications --- models/Grammars/SqlServerGrammar.cfc | 48 +++++++++---- .../specs/Query/SqlServerQueryBuilderSpec.cfc | 68 +++++++++++++++++++ 2 files changed, 102 insertions(+), 14 deletions(-) diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index 19544a44..af881caf 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -574,7 +574,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { var updateTable = ""; if ( arguments.query.getAlias() != "" ) { - updateTable = wrapAlias( arguments.query.getAlias() ); + updateTable = wrapAlias( getTablePrefix() & arguments.query.getAlias() ); } else if ( !getUtils().isExpression( query.getTableName() ) ) { var parts = explodeTable( query.getTableName() ); updateTable = parts.alias.len() ? wrapAlias( parts.alias ) : wrapTable( parts.table ); @@ -603,7 +603,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { .toList( ", " ); var returningClause = returningColumns != "" ? " OUTPUT #returningColumns#" : ""; - if ( arguments.query.getJoins().isEmpty() ) { + if ( arguments.query.getJoins().isEmpty() && arguments.query.getAlias() == "" ) { return trim( compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & returningClause & " " & compileWheres( query, @@ -613,10 +613,13 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } return trim( - compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & returningClause & " FROM #wrapQueryTable( query )# " & compileJoins( - arguments.query, - arguments.query.getJoins() - ) & " " & compileWheres( query, query.getWheres() ) + concatenate( [ + compileCommonTables( query, query.getCommonTables() ), + updateStatement & returningClause, + "FROM #wrapQueryTable( query )#", + compileJoins( arguments.query, arguments.query.getJoins() ), + compileWheres( query, query.getWheres() ) + ] ) ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { @@ -654,6 +657,27 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { var returningClause = returningColumns != "" ? "OUTPUT #returningColumns#" : ""; var hasJoins = !arguments.query.getJoins().isEmpty(); + var hasAlias = arguments.query.getAlias() != ""; + + if ( !hasJoins && !hasAlias ) { + return trim( + arrayToList( + arrayFilter( + [ + compileCommonTables( query, query.getCommonTables() ), + "DELETE FROM", + wrapQueryTable( query ), + returningClause, + compileWheres( query, query.getWheres() ) + ], + function( sql ) { + return sql != ""; + } + ), + " " + ) + ); + } return trim( arrayToList( @@ -661,16 +685,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { [ compileCommonTables( query, query.getCommonTables() ), "DELETE", - hasJoins - ? ( - query.getAlias() != "" - ? wrapAlias( getTablePrefix() & query.getAlias() ) - : wrapTable( query.getTableName(), false ) - ) - : "", + hasAlias + ? wrapAlias( getTablePrefix() & query.getAlias() ) + : wrapTable( query.getTableName(), false ), + returningClause, "FROM", wrapQueryTable( query ), - returningClause, hasJoins ? compileJoins( query, query.getJoins() ) : "", compileWheres( query, query.getWheres() ) ], diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index 56809be4..06845b8c 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -190,6 +190,74 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { } ); } ); + describe( "SQL Server aliased data modifications", function() { + it( "declares the target alias for updates without joins", function() { + var sql = getBuilder() + .from( "users AS u" ) + .where( "u.id", 42 ) + .update( values = { "name": "changed" }, toSQL = true ); + + expect( sql ).toBe( "UPDATE [u] SET [name] = ? FROM [users] AS [u] WHERE [u].[id] = ?" ); + } ); + + it( "declares the target alias for deletes without joins", function() { + var sql = getBuilder() + .from( "users AS u" ) + .where( "u.id", 42 ) + .delete( toSQL = true ); + + expect( sql ).toBe( "DELETE [u] FROM [users] AS [u] WHERE [u].[id] = ?" ); + } ); + + it( "uses the prefixed target alias for updates", function() { + var builder = getBuilder(); + builder.getGrammar().setTablePrefix( "prefix_" ); + + var sql = builder + .from( "users AS u" ) + .where( "id", 42 ) + .update( values = { "name": "changed" }, toSQL = true ); + + expect( sql ).toBe( + "UPDATE [prefix_u] SET [name] = ? FROM [prefix_users] AS [prefix_u] WHERE [id] = ?" + ); + } ); + + it( "uses the prefixed target alias for deletes", function() { + var builder = getBuilder(); + builder.getGrammar().setTablePrefix( "prefix_" ); + + var sql = builder + .from( "users AS u" ) + .where( "id", 42 ) + .delete( toSQL = true ); + + expect( sql ).toBe( "DELETE [prefix_u] FROM [prefix_users] AS [prefix_u] WHERE [id] = ?" ); + } ); + + it( "places returning columns before the source table for aliased deletes", function() { + var sql = getBuilder() + .from( "users AS u" ) + .where( "u.id", 42 ) + .returning( "id" ) + .delete( toSQL = true ); + + expect( sql ).toBe( "DELETE [u] OUTPUT DELETED.[id] FROM [users] AS [u] WHERE [u].[id] = ?" ); + } ); + + it( "places returning columns before the source table for joined deletes", function() { + var sql = getBuilder() + .from( "users AS u" ) + .join( "warnings AS w", "u.id", "w.userId" ) + .returning( "id" ) + .delete( toSQL = true ); + + expect( sql ).toBe( + "DELETE [u] OUTPUT DELETED.[id] FROM [users] AS [u] INNER JOIN [warnings] AS [w] ON [u].[id] = [w].[userId]" + ); + } ); + } ); + describe( "SQL Server nested CTE queries", function() { it( "hoists common table expressions outside the EXISTS subquery", function() { var builder = getBuilder() From fe0fd86a22c3fec8e8d3f4079a12f2e44e49e6ee Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 16 Aug 2026 11:00:38 -0600 Subject: [PATCH 044/119] fix(QueryBuilder): preserve nested query invariants --- models/Grammars/BaseGrammar.cfc | 4 + models/Query/AliasRewriter.cfc | 20 +++ models/Query/QueryBuilder.cfc | 123 ++++++++++-------- .../specs/Query/Abstract/BuilderAliasSpec.cfc | 17 +++ .../Query/Abstract/BuilderSelectSpec.cfc | 17 +++ .../specs/Query/SqlServerQueryBuilderSpec.cfc | 42 ++++++ 6 files changed, 167 insertions(+), 56 deletions(-) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 6413ff58..e4922f78 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -223,6 +223,10 @@ component displayname="Grammar" accessors="true" singleton { * @return string */ public string function compileSelect( required QueryBuilder query ) { + if ( arguments.query.getValidateDuplicateSelectColumns() && arguments.query.getAggregate().isEmpty() ) { + arguments.query.getQueryValidator().validateUniqueSelectColumns( arguments.query.getColumns(), this ); + } + try { var originalShouldWrapValues = getShouldWrapValues(); if ( !isNull( arguments.query.getShouldWrapValues() ) ) { diff --git a/models/Query/AliasRewriter.cfc b/models/Query/AliasRewriter.cfc index 1401631f..5b0fd0e1 100644 --- a/models/Query/AliasRewriter.cfc +++ b/models/Query/AliasRewriter.cfc @@ -13,6 +13,7 @@ component { renameAliasesInOrders( argumentCollection = arguments ); renameAliasesInUnions( argumentCollection = arguments ); renameAliasesInCommonTables( argumentCollection = arguments ); + renameAliasesInUpdates( argumentCollection = arguments ); } private void function renameAliasesInUnions( @@ -45,6 +46,25 @@ component { } } + private void function renameAliasesInUpdates( + required QueryBuilder builder, + required string oldAlias, + required string newAlias + ) { + var updates = arguments.builder.getUpdates(); + for ( var column in updates ) { + var value = updates[ column ]; + if ( arguments.builder.getUtils().isBuilder( value ) ) { + renameAliasesInNestedQuery( + arguments.builder, + value, + arguments.oldAlias, + arguments.newAlias + ); + } + } + } + private void function renameAliasesInNestedQuery( required QueryBuilder builder, required QueryBuilder query, diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index bd67bb5b..0a0d8cf3 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -865,21 +865,27 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function fromSub( required string alias, required any input ) { - // since we have a callback, we generate a new query object and pass it into the callback - if ( isClosure( arguments.input ) || isCustomFunction( arguments.input ) ) { - var subquery = newQuery(); - arguments.input( subquery ); - // replace the original query builder with the results of the sub-query - arguments.input = subquery; - } + var commonTableState = getCollaborator( "QueryExecutor" ).captureCommonTableState( this ); + try { + // since we have a callback, we generate a new query object and pass it into the callback + if ( isClosure( arguments.input ) || isCustomFunction( arguments.input ) ) { + var subquery = newQuery(); + arguments.input( subquery ); + // replace the original query builder with the results of the sub-query + arguments.input = subquery; + } - arguments.input = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.input ); + arguments.input = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.input ); - // generate the derived table SQL - this.fromRaw( getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ) ); - variables.grammarCompiledFrom = true; - addBindings( arguments.input.getBindings(), "from" ); - return this; + // generate the derived table SQL + this.fromRaw( getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ) ); + variables.grammarCompiledFrom = true; + addBindings( arguments.input.getBindings(), "from" ); + return this; + } catch ( any e ) { + getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); + rethrow; + } } /*******************************************************************************\ @@ -1400,59 +1406,64 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J private function outerOrCrossApply( required string name, required string type, required tableLikeSource ) { var commonTableState = getCollaborator( "QueryExecutor" ).captureCommonTableState( this ); - if ( type != "outer apply" && type != "cross apply" && type != "lateral" ) { - throw( - type = "QBInvalidJoinType", - message = "Invalid join type: #arguments.type#. Valid types are [`outer apply`, `cross apply`, or `lateral`]" - ); - } + try { + if ( type != "outer apply" && type != "cross apply" && type != "lateral" ) { + throw( + type = "QBInvalidJoinType", + message = "Invalid join type: #arguments.type#. Valid types are [`outer apply`, `cross apply`, or `lateral`]" + ); + } - var sourceIsBuilder = getUtils().isBuilder( arguments.tableLikeSource ) - var sourceIsFunc = isClosure( arguments.tableLikeSource ) || isCustomFunction( arguments.tableLikeSource ) + var sourceIsBuilder = getUtils().isBuilder( arguments.tableLikeSource ) + var sourceIsFunc = isClosure( arguments.tableLikeSource ) || isCustomFunction( arguments.tableLikeSource ) - if ( !sourceIsBuilder && !sourceIsFunc ) { - throw( - type = "QBInvalidJoinSource", - message = "Invalid join source. Valid types are a QueryBuilder instance or a callback function that receives a new QueryBuilder instance." - ); - } + if ( !sourceIsBuilder && !sourceIsFunc ) { + throw( + type = "QBInvalidJoinSource", + message = "Invalid join source. Valid types are a QueryBuilder instance or a callback function that receives a new QueryBuilder instance." + ); + } - if ( sourceIsFunc ) { - var subquery = newQuery(); - arguments.tableLikeSource( subquery ); - arguments.tableLikeSource = subquery; - } + if ( sourceIsFunc ) { + var subquery = newQuery(); + arguments.tableLikeSource( subquery ); + arguments.tableLikeSource = subquery; + } - arguments.tableLikeSource = getCollaborator( "QueryExecutor" ).snapshotBuilder( - this, - arguments.tableLikeSource - ); + arguments.tableLikeSource = getCollaborator( "QueryExecutor" ).snapshotBuilder( + this, + arguments.tableLikeSource + ); - var join = new qb.models.Query.JoinClause( - joiningQuery = this, - type = type, - table = arguments.name, - lateralRawExpression = arguments.tableLikeSource.toSQL(), - lateralBindings = arguments.tableLikeSource.getBindings() - ); + var join = new qb.models.Query.JoinClause( + joiningQuery = this, + type = type, + table = arguments.name, + lateralRawExpression = arguments.tableLikeSource.toSQL(), + lateralBindings = arguments.tableLikeSource.getBindings() + ); - if ( this.getPreventDuplicateJoins() ) { - var hasThisJoin = variables.joins.find( function( existingJoin ) { - return existingJoin.isEqualTo( join ); - } ); + if ( this.getPreventDuplicateJoins() ) { + var hasThisJoin = variables.joins.find( function( existingJoin ) { + return existingJoin.isEqualTo( join ); + } ); - if ( hasThisJoin ) { - // Do nothing, early return - getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); - return this; + if ( hasThisJoin ) { + // Do nothing, early return + getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); + return this; + } } - } - addBindings( tableLikeSource.getBindings(), "join" ); - variables.joins.append( join ); - variables.grammarCompiledJoin = true; + addBindings( tableLikeSource.getBindings(), "join" ); + variables.joins.append( join ); + variables.grammarCompiledJoin = true; - return this; + return this; + } catch ( any e ) { + getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); + rethrow; + } } public function outerApply( required string name, required any tableDef ) { diff --git a/tests/specs/Query/Abstract/BuilderAliasSpec.cfc b/tests/specs/Query/Abstract/BuilderAliasSpec.cfc index dae4e78d..9d55a695 100644 --- a/tests/specs/Query/Abstract/BuilderAliasSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderAliasSpec.cfc @@ -492,6 +492,23 @@ component extends="testbox.system.BaseSpec" { "SELECT * FROM ""users"" AS ""u"" WHERE EXISTS (SELECT * FROM ""users"" WHERE ""users"".""managerId"" = ""users"".""id"")" ); } ); + + it( "renames correlated aliases inside deferred update subqueries", function() { + var qb = new qb.models.Query.QueryBuilder().from( "users" ); + qb.addUpdate( { + score: qb + .newQuery() + .from( "scores" ) + .select( "scores.value" ) + .whereColumn( "scores.userId", "users.id" ) + } ) + .withAlias( "u" ); + + var sql = qb.update( toSQL = true ); + + expect( sql ).notToInclude( """users"".""id""" ); + expect( sql ).toInclude( """u"".""id""" ); + } ); } ); } diff --git a/tests/specs/Query/Abstract/BuilderSelectSpec.cfc b/tests/specs/Query/Abstract/BuilderSelectSpec.cfc index 84d47845..212e7514 100644 --- a/tests/specs/Query/Abstract/BuilderSelectSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderSelectSpec.cfc @@ -195,6 +195,23 @@ component extends="testbox.system.BaseSpec" { } ).toThrow( type = "DuplicateSelectColumn" ); } ); + it( "validates duplicate output names in nested predicate queries", function() { + var validatingQuery = new qb.models.Query.QueryBuilder( + grammar = variables.mockGrammar, + validateDuplicateSelectColumns = true + ); + + validatingQuery + .from( "users" ) + .whereExists( function( childQuery ) { + childQuery.select( [ "equipment.id", "racks.id" ] ).from( "equipment" ); + } ); + + expect( function() { + validatingQuery.toSQL(); + } ).toThrow( type = "DuplicateSelectColumn" ); + } ); + it( "validates the final selection after a reselect", function() { var validatingQuery = new qb.models.Query.QueryBuilder( grammar = variables.mockGrammar, diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index 06845b8c..c24ddec9 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -288,6 +288,27 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 1, 42 ] ); } ); + it( "rolls back hoisted CTEs when derived table compilation fails", function() { + var builder = getBuilder().from( "accounts" ); + + expect( function() { + builder.fromSub( "active_users", function( source ) { + source + .with( "filtered_users", function( cte ) { + cte.from( "users" ).where( "active", 1 ); + } ) + .from( "filtered_users" ) + .unionAll( function( archived ) { + archived.from( "archived_users" ).orderBy( "id" ); + } ); + } ); + } ).toThrow( type = "OrderByNotAllowed" ); + + expect( builder.getCommonTables() ).toBeEmpty(); + expect( builder.getRawBindings().commonTables ).toBeEmpty(); + expect( builder.toSQL() ).toBe( "SELECT * FROM [accounts]" ); + } ); + it( "hoists common table expressions outside predicate subqueries", function() { var builder = getBuilder() .from( "accounts" ) @@ -385,6 +406,27 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { ); } ); + it( "rolls back hoisted CTEs when APPLY source compilation fails", function() { + var builder = getBuilder().from( "accounts" ); + + expect( function() { + builder.crossApply( "active_users", function( source ) { + source + .with( "filtered_users", function( cte ) { + cte.from( "users" ).where( "active", 1 ); + } ) + .from( "filtered_users" ) + .unionAll( function( archived ) { + archived.from( "archived_users" ).orderBy( "id" ); + } ); + } ); + } ).toThrow( type = "OrderByNotAllowed" ); + + expect( builder.getCommonTables() ).toBeEmpty(); + expect( builder.getRawBindings().commonTables ).toBeEmpty(); + expect( builder.toSQL() ).toBe( "SELECT * FROM [accounts]" ); + } ); + it( "hoists common table expressions outside insert sources", function() { var builder = getBuilder().from( "archived_users" ); var sql = builder.insertUsing( From 97d54147dff5f46accf5c28618538b795f930efc Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 16 Aug 2026 11:15:14 -0600 Subject: [PATCH 045/119] test(QueryBuilder): split abstract specs by behavior --- tests/resources/AbstractQueryBuilderSpec.cfc | 3778 +---------------- .../AbstractQueryBuilderAggregateSpec.cfc | 22 + .../AbstractQueryBuilderBaseSpec.cfc | 76 + .../AbstractQueryBuilderCteSpec.cfc | 167 + .../AbstractQueryBuilderDeleteSpec.cfc | 73 + .../AbstractQueryBuilderGroupingSpec.cfc | 487 +++ .../AbstractQueryBuilderInsertSpec.cfc | 226 + .../AbstractQueryBuilderJoinSpec.cfc | 664 +++ .../AbstractQueryBuilderJsonSpec.cfc | 142 + .../AbstractQueryBuilderPaginationSpec.cfc | 76 + .../AbstractQueryBuilderSelectSpec.cfc | 337 ++ .../AbstractQueryBuilderSourceSpec.cfc | 203 + .../AbstractQueryBuilderSubselectSpec.cfc | 69 + .../AbstractQueryBuilderUnionSpec.cfc | 214 + .../AbstractQueryBuilderUpdateSpec.cfc | 498 +++ .../AbstractQueryBuilderWhereSpec.cfc | 670 +++ 16 files changed, 3926 insertions(+), 3776 deletions(-) create mode 100644 tests/resources/querybuilder/AbstractQueryBuilderAggregateSpec.cfc create mode 100644 tests/resources/querybuilder/AbstractQueryBuilderBaseSpec.cfc create mode 100644 tests/resources/querybuilder/AbstractQueryBuilderCteSpec.cfc create mode 100644 tests/resources/querybuilder/AbstractQueryBuilderDeleteSpec.cfc create mode 100644 tests/resources/querybuilder/AbstractQueryBuilderGroupingSpec.cfc create mode 100644 tests/resources/querybuilder/AbstractQueryBuilderInsertSpec.cfc create mode 100644 tests/resources/querybuilder/AbstractQueryBuilderJoinSpec.cfc create mode 100644 tests/resources/querybuilder/AbstractQueryBuilderJsonSpec.cfc create mode 100644 tests/resources/querybuilder/AbstractQueryBuilderPaginationSpec.cfc create mode 100644 tests/resources/querybuilder/AbstractQueryBuilderSelectSpec.cfc create mode 100644 tests/resources/querybuilder/AbstractQueryBuilderSourceSpec.cfc create mode 100644 tests/resources/querybuilder/AbstractQueryBuilderSubselectSpec.cfc create mode 100644 tests/resources/querybuilder/AbstractQueryBuilderUnionSpec.cfc create mode 100644 tests/resources/querybuilder/AbstractQueryBuilderUpdateSpec.cfc create mode 100644 tests/resources/querybuilder/AbstractQueryBuilderWhereSpec.cfc diff --git a/tests/resources/AbstractQueryBuilderSpec.cfc b/tests/resources/AbstractQueryBuilderSpec.cfc index dde55a2b..bf9652a6 100644 --- a/tests/resources/AbstractQueryBuilderSpec.cfc +++ b/tests/resources/AbstractQueryBuilderSpec.cfc @@ -1,3781 +1,7 @@ -component extends="testbox.system.BaseSpec" { +component extends="tests.resources.querybuilder.AbstractQueryBuilderDeleteSpec" { function run() { - describe( "query builder + grammar integration", function() { - describe( "select statements", function() { - describe( "basic selects", function() { - it( "can select all columns from a table", function() { - testCase( function( builder ) { - builder.select( "*" ).from( "users" ); - }, selectAllColumns() ); - } ); - - it( "can specify the column to select", function() { - testCase( function( builder ) { - builder.select( "name" ).from( "users" ); - }, selectSpecificColumn() ); - } ); - - it( "can select multiple columns using an array", function() { - testCase( function( builder ) { - builder.select( [ "name", builder.raw( "COUNT(*)" ) ] ).from( "users" ); - }, selectMultipleArray() ); - } ); - - it( "can add selects to a query", function() { - testCase( function( builder ) { - builder - .select( "foo" ) - .addSelect( "bar" ) - .addSelect( [ "baz", "boom" ] ) - .from( "users" ); - }, addSelect() ); - } ); - - it( "adding a select to a * query gets rid of the star", function() { - testCase( function( builder ) { - builder.addSelect( "foo" ).from( "users" ); - }, addSelectRemovesStar() ); - } ); - - it( "can select distinct records", function() { - testCase( function( builder ) { - builder - .distinct() - .select( [ "foo", "bar" ] ) - .from( "users" ); - }, selectDistinct() ); - } ); - - it( "can parse column aliases", function() { - testCase( function( builder ) { - builder.select( "foo as bar" ).from( "users" ); - }, parseColumnAlias() ); - } ); - - it( "does not change aliases when quoted", function() { - testCase( function( builder ) { - builder.select( "foo as ""bar""" ).from( "users" ); - }, parseColumnAliasWithQuotes() ); - } ); - - it( "can parse column aliases in where clauses", function() { - testCase( function( builder ) { - builder - .select( "users.foo" ) - .from( "users" ) - .where( "users.foo", "bar" ); - }, parseColumnAliasInWhere() ); - } ); - - it( "can parse column aliases in where clauses with subselects", function() { - testCase( function( builder ) { - builder - .from( "users u" ) - .select( "u.*, user_roles.roleid, roles.rolecode" ) - .join( "user_roles", "user_roles.userid", "u.userid" ) - .leftjoin( "roles", "user_roles.roleid", "roles.roleid" ) - .where( - "user_roles.roleid", - "=", - function( q ) { - q.select( "roleid" ) - .from( "roles" ) - .where( "rolecode", "SYSADMIN" ); - } - ); - }, parseColumnAliasInWhereSubselect() ); - } ); - - it( "can also parse column aliases in whereColumn clauses with subselects", function() { - testCase( function( builder ) { - builder - .from( "users u" ) - .select( "u.*, user_roles.roleid, roles.rolecode" ) - .join( "user_roles", "user_roles.userid", "u.userid" ) - .leftjoin( "roles", "user_roles.roleid", "roles.roleid" ) - .whereColumn( - "user_roles.roleid", - "=", - function( q ) { - q.select( "roleid" ) - .from( "roles" ) - .where( "rolecode", "SYSADMIN" ); - } - ); - }, parseColumnAliasInWhereSubselect() ); - } ); - - it( "wraps columns and aliases correctly", function() { - testCase( function( builder ) { - builder.select( "x.y as foo.bar" ).from( "public.users" ); - }, wrapColumnsAndAliases() ); - } ); - - it( "handles dynamic whereColumns", function() { - testCase( function( builder ) { - builder - .select( "ID" ) - .from( "users" ) - .whereID( 1 ); - }, dynamicWhere() ); - } ); - - it( "parses operators in dynamic whereColumns", function() { - testCase( function( builder ) { - builder - .select( "ID" ) - .from( "users" ) - .whereID( ">", 1 ); - }, parseOperatorsWithDynamicWhere() ); - } ); - - it( "parses operators in dynamic andWhereColumns", function() { - testCase( function( builder ) { - builder - .select( "ID" ) - .from( "users" ) - .whereID( ">", 1 ) - .andWhereID( "<", 10 ); - }, parseOperatorsWithDynamicAndWhere() ); - } ); - - it( "parses operators in dynamic orWhereColumns", function() { - testCase( function( builder ) { - builder - .select( "ID" ) - .from( "users" ) - .whereID( ">", 1 ) - .orWhereID( "<", 0 ); - }, parseOperatorsWithDynamicOrWhere() ); - } ); - - it( "selects raw values correctly", function() { - testCase( function( builder ) { - builder.select( builder.raw( "substr( foo, 6 )" ) ).from( "users" ); - }, selectWithRaw() ); - } ); - - it( "can easily select raw values with `selectRaw`", function() { - testCase( function( builder ) { - builder.selectRaw( "substr( foo, 6 )" ).from( "users" ); - }, selectRaw() ); - } ); - - it( "can select multiple raw values with `selectRaw` when passing in an array", function() { - testCase( function( builder ) { - builder.from( "users" ).selectRaw( [ "substr( foo, 6 )", "trim( bar )" ] ); - }, selectRawArray() ); - } ); - - it( "preserves bindings carried by expressions across select clauses", function() { - var builder = getBuilder(); - builder - .select( builder.raw( "? AS selectedValue", [ 1 ] ) ) - .from( builder.raw( "(SELECT ? AS id) source", [ 2 ] ) ) - .where( "id", ">", builder.raw( "?", [ 3 ] ) ) - .whereIn( "id", [ 4, builder.raw( "?", [ 5 ] ) ] ) - .groupBy( builder.raw( "?", [ 6 ] ) ) - .having( "id", ">", builder.raw( "?", [ 7 ] ) ) - .orderBy( { column: builder.raw( "?", [ 8 ] ) } ); - - expect( reMatch( "\?", builder.toSQL() ) ).toHaveLength( 8 ); - expect( getTestBindings( builder ) ).toBe( [ 1, 2, 3, 4, 5, 6, 7, 8 ] ); - } ); - - it( "preserves bindings carried by expression columns across predicates", function() { - var builder = getBuilder(); - builder - .from( "users" ) - .whereIn( builder.raw( "COALESCE(?, id)", [ 1 ] ), [ 2 ] ) - .whereNull( builder.raw( "NULLIF(?, id)", [ 3 ] ) ) - .whereBetween( builder.raw( "COALESCE(?, id)", [ 4 ] ), 5, 6 ) - .whereColumn( - builder.raw( "COALESCE(?, id)", [ 7 ] ), - "=", - builder.raw( "COALESCE(?, other_id)", [ 8 ] ) - ) - .where( - builder.raw( "COALESCE(?, id)", [ 9 ] ), - "=", - function( query ) { - query - .select( "id" ) - .from( "accounts" ) - .where( "active", 10 ); - } - ) - .whereIn( builder.raw( "COALESCE(?, id)", [ 11 ] ), function( query ) { - query - .select( "id" ) - .from( "accounts" ) - .where( "active", 12 ); - } ); - - expect( reMatch( "\?", builder.toSQL() ) ).toHaveLength( 12 ); - expect( getTestBindings( builder ) ).toBe( [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12 - ] ); - } ); - - it( "preserves bindings carried by expression columns in bulk predicates", function() { - var builder = getBuilder(); - builder.from( "users" ).whereInBulk( builder.raw( "COALESCE(?, id)", [ 1 ] ), [ 2, 3 ] ); - - expect( getTestBindings( builder )[ 1 ] ).toBe( 1 ); - expect( deserializeJSON( getTestBindings( builder )[ 2 ] ) ).toBe( [ 2, 3 ] ); - } ); - - it( "preserves bindings carried by expression join tables", function() { - var builder = getBuilder(); - builder - .from( "users" ) - .join( - builder.raw( "(SELECT ? AS id) joined", [ 1 ] ), - "joined.id", - "=", - "users.id" - ) - .crossJoin( builder.raw( "(SELECT ? AS id) crossed", [ 2 ] ) ); - - expect( reMatch( "\?", builder.toSQL() ) ).toHaveLength( 2 ); - expect( getTestBindings( builder ) ).toBe( [ 1, 2 ] ); - expect( getTestBindings( builder.clone() ) ).toBe( [ 1, 2 ] ); - } ); - - it( "provides a grammar-specific helper for concat", function() { - testCase( function( builder ) { - builder.select( builder.concat( "my_alias", "a,b,c,d" ) ).from( "users" ); - }, selectConcat() ); - } ); - - it( "concat can accept an array of values", function() { - testCase( function( builder ) { - // I kid you not, ACF2018 wouldn't let me pass `[ "a", "b", "c", "d" ]` - var items = []; - items - .append( "a" ) - .append( "b" ) - .append( "c" ) - .append( "d" ); - - builder.select( builder.concat( "my_alias", items ) ).from( "users" ); - }, selectConcatArray() ); - } ); - - it( "can clear the selected columns for a query", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .select( [ "foo", "bar" ] ) - .clearSelect(); - }, clearSelect() ); - } ); - - it( "can reselect the columns for a query", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .select( [ "foo", "bar" ] ) - .reselect( "baz" ); - }, reselect() ); - } ); - - it( "can reselect the columns for a query with raw expressions", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .select( [ "foo", "bar" ] ) - .reselectRaw( [ "substr( foo, 6 )", "trim( bar )" ] ); - }, reselectRaw() ); - } ); - - describe( "wrapping values", function() { - it( "wraps values by default", () => { - testCase( function( builder ) { - builder.from( "users" ).select( [ "foo", "bar" ] ); - }, wrappingDefault() ); - } ); - - it( "can configure the grammar to not wrap values by default", () => { - testCase( function( builder ) { - builder.getGrammar().setShouldWrapValues( false ); - - builder.from( "users" ).select( [ "foo", "bar" ] ); - }, wrappingGrammarOff() ); - } ); - - it( "can configure the query builder to not wrap values by default", () => { - testCase( function( builder ) { - builder.getGrammar().setShouldWrapValues( true ); - - builder - .withoutWrappingValues() - .from( "users" ) - .select( [ "foo", "bar" ] ); - }, wrappingBuilderOverride() ); - } ); - } ); - } ); - - describe( "sub-selects", function() { - it( "can execute sub-selects", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .select( "name" ) - .subSelect( "latestUpdatedDate", function( q ) { - return q - .from( "posts" ) - .selectRaw( "MAX(updated_date)" ) - .whereColumn( "posts.user_id", "users.id" ); - } ); - }, subSelect() ); - } ); - - it( "can take a query object in a sub-selects", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .select( "name" ) - .subSelect( - "latestUpdatedDate", - builder - .newQuery() - .from( "posts" ) - .selectRaw( "MAX(updated_date)" ) - .whereColumn( "posts.user_id", "users.id" ) - ); - }, subSelectQueryObject() ); - } ); - - it( "can execute sub-selects with bindings", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .select( "name" ) - .subSelect( "latestUpdatedDate", function( q ) { - return q - .from( "posts" ) - .selectRaw( "MAX(updated_date)" ) - .where( "posts.user_id", 1 ); - } ); - }, subSelectWithBindings() ); - } ); - - it( "snapshots a builder passed to a sub-select", function() { - var child = getBuilder(); - child.from( "posts" ).selectRaw( "MAX(updated_date)" ); - var builder = getBuilder(); - builder.from( "users" ).subSelect( "latestUpdatedDate", child ); - - child.where( "posts.user_id", 1 ); - - expect( builder.toSQL() ).notToInclude( "user_id" ); - expect( getTestBindings( builder ) ).toBe( [] ); - } ); - } ); - - describe( "from", function() { - it( "can specify the table to select from", function() { - testCase( function( builder ) { - builder.from( "users" ); - }, from() ); - } ); - - it( "can specify a Expression object as the input for from", function() { - testCase( function( builder ) { - builder.from( builder.raw( "Test (nolock)" ) ); - }, fromRaw() ); - } ); - - it( "can use `table` as an alias for from", function() { - testCase( function( builder ) { - builder.table( "users" ); - }, table() ); - } ); - - it( "can specify a Expression object as the input for table", function() { - testCase( function( builder ) { - builder.table( builder.raw( "Test (nolock)" ) ); - }, fromRaw() ); - } ); - - it( "can specify the table to select from as a string using fromRaw", function() { - testCase( function( builder ) { - builder.fromRaw( "Test (nolock)" ); - }, fromRaw() ); - } ); - - it( "can add bindings to fromRaw", function() { - testCase( function( builder ) { - builder.fromRaw( "Test (nolock)", [ 1, 2, 3 ] ); - }, { sql: fromRaw(), bindings: [ 1, 2, 3 ] } ); - } ); - - it( "can specify the table using fromSub as QueryBuilder", function() { - testCase( function( builder ) { - var derivedTable = getBuilder() - .select( [ "id", "name" ] ) - .from( "users" ) - .where( "age", ">=", "21" ); - - builder.fromSub( "u", derivedTable ); - }, fromDerivedTable() ); - } ); - - it( "can specify the table using fromSub as a closure", function() { - testCase( function( builder ) { - builder.fromSub( "u", function( q ) { - q.select( [ "id", "name" ] ) - .from( "users" ) - .where( "age", ">=", "21" ); - } ); - }, fromDerivedTable() ); - } ); - - it( "correctly positions bindings using fromSub", function() { - testCase( function( builder ) { - builder - .select( "accounts.id" ) - .fromSub( "u", function( q ) { - q.select( [ "id", "name" ] ) - .from( "users" ) - .where( "age", ">=", "21" ); - } ) - .join( "accounts", ( j ) => { - j.on( "accounts.userId", "=", "u.id" ); - j.where( "accounts.active", 1 ); - } ); - }, fromSubBindings() ); - } ); - - it( "can select from no table or a dummy table like DUAL", () => { - testCase( function( builder ) { - builder.selectRaw( "1 + 1" ); - }, fromEmpty() ); - } ); - - it( "can clear a configured table", () => { - testCase( function( builder ) { - builder - .from( "users" ) - .selectRaw( "1 + 1" ) - .clearFrom(); - }, clearFrom() ); - } ); - - it( "can add raw expressions after the from clause", function() { - testCase( function( builder ) { - builder - .select( [ "id", "name" ] ) - .from( "users" ) - .forRaw( "JSON AUTO" ); - }, forRaw() ); - } ); - } ); - - describe( "locking", function() { - it( "can set no lock", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .where( "id", 1 ) - .noLock(); - }, noLock() ); - } ); - - it( "can set a shared lock", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .where( "id", 1 ) - .sharedLock(); - }, sharedLock() ); - } ); - - it( "can lock for update", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .where( "id", 1 ) - .lockForUpdate(); - }, lockForUpdate() ); - } ); - - it( "can lock for update skipping locked rows", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .where( "id", 1 ) - .lockForUpdate( skipLocked = true ); - }, lockForUpdateSkipLocked() ); - } ); - - it( "can pass an arbitrary string to lock", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .where( "id", 1 ) - .lock( "foobar" ); - }, lockArbitraryString() ); - } ); - } ); - - describe( "using table prefixes", function() { - it( "can perform a basic select with a table prefix", function() { - testCase( function( builder ) { - builder.getGrammar().setTablePrefix( "prefix_" ); - builder.select( "*" ).from( "users" ); - }, tablePrefix() ); - } ); - - it( "can parse column aliases with a table prefix", function() { - testCase( function( builder ) { - builder.getGrammar().setTablePrefix( "prefix_" ); - builder.select( "*" ).from( "users as people" ); - }, tablePrefixWithAlias() ); - } ); - } ); - - describe( "aliases", function() { - describe( "column aliases", function() { - it( "can parse column aliases with AS in them", function() { - testCase( function( builder ) { - builder.select( "id AS user_id" ).from( "users" ); - }, columnAliasWithAs() ); - } ); - - it( "can parse column aliases without AS in them", function() { - testCase( function( builder ) { - builder.select( "id user_id" ).from( "users" ); - }, columnAliasWithoutAs() ); - } ); - } ); - - describe( "table aliases", function() { - it( "can parse table aliases with AS in them", function() { - testCase( function( builder ) { - builder.select( "*" ).from( "users as people" ); - }, tableAliasWithAs() ); - } ); - - it( "can parse table aliases without AS in them", function() { - testCase( function( builder ) { - builder.select( "*" ).from( "users people" ); - }, tableAliasWithoutAs() ); - } ); - } ); - } ); - - describe( "wheres", function() { - describe( "basic wheres", function() { - it( "can add a where statement", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .where( "id", "=", 1 ); - }, basicWhere() ); - } ); - - it( "can add a where statement with a query param struct", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .where( "createdDate", ">=", { value: "01/01/2019", cfsqltype: "DATE" } ); - }, basicWhereWithQueryParamStruct() ); - } ); - - it( "can add or where statements", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .where( "id", "=", 1 ) - .orWhere( "email", "foo" ); - }, orWhere() ); - } ); - - it( "can add and where statements", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .where( "id", "=", 1 ) - .andWhere( "email", "foo" ); - }, andWhere() ); - } ); - - it( "can add raw where statements", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .whereRaw( "id = ? OR email = ?", [ 1, "foo" ] ); - }, whereRaw() ); - } ); - - it( "can add raw or where statements", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .where( "id", "=", 1 ) - .orWhereRaw( "email = ?", [ "foo" ] ); - }, orWhereRaw() ); - } ); - - it( "can specify a where between two columns", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .whereColumn( "first_name", "last_name" ); - }, whereColumn() ); - } ); - - it( "can specify an or where between two columns", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .whereColumn( "first_name", "last_name" ) - .orWhereColumn( "updated_date", ">", "created_date" ); - }, orWhereColumn() ); - } ); - - it( "can add nested where statements", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .where( "email", "foo" ) - .orWhere( function( q ) { - q.where( "name", "bar" ).where( "age", ">=", "21" ); - } ); - }, whereNested() ); - } ); - - it( "can have full sub-selects in where statements", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .where( "email", "foo" ) - .orWhere( - "id", - "=", - function( q ) { - q.select( q.raw( "MAX(id)" ) ) - .from( "users" ) - .where( "email", "bar" ); - } - ); - }, whereSubSelect() ); - } ); - - it( "can configure a where with a builder instance", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .where( "email", "foo" ) - .orWhere( - "id", - "=", - builder - .newQuery() - .select( builder.raw( "MAX(id)" ) ) - .from( "users" ) - .where( "email", "bar" ) - ); - }, whereBuilderInstance() ); - } ); - - it( "can add a where statement with a boolean literal", function() { - testCase( - callback = function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .where( "active", "=", true ); - }, - expected = whereBoolean(), - withFullBindings = true - ); - } ); - - it( "can handle null values passed to where clauses", () => { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .where( "id", "=", javacast( "null", "" ) ); - }, nullWhere() ); - } ); - } ); - - describe( "where exists", function() { - it( "can add a where exists clause", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "orders" ) - .whereExists( function( q ) { - q.select( q.raw( 1 ) ) - .from( "products" ) - .whereColumn( "products.id", "orders.id" ); - } ); - }, whereExists() ); - } ); - - it( "can add an or where exists clause", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "orders" ) - .where( "id", 1 ) - .orWhereExists( function( q ) { - q.select( q.raw( 1 ) ) - .from( "products" ) - .whereColumn( "products.id", "orders.id" ); - } ); - }, orWhereExists() ); - } ); - - it( "can add a where not exists clause", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "orders" ) - .whereNotExists( function( q ) { - q.select( q.raw( 1 ) ) - .from( "products" ) - .whereColumn( "products.id", "orders.id" ); - } ); - }, whereNotExists() ); - } ); - - it( "can add an or where not exists clause", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "orders" ) - .where( "id", 1 ) - .orWhereNotExists( function( q ) { - q.select( q.raw( 1 ) ) - .from( "products" ) - .whereColumn( "products.id", "orders.id" ); - } ); - }, orWhereNotExists() ); - } ); - - it( "can add a where exists clause using a builder instance", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "orders" ) - .whereExists( - builder - .newQuery() - .select( builder.raw( 1 ) ) - .from( "products" ) - .whereColumn( "products.id", "orders.id" ) - ); - }, whereExistsBuilderInstance() ); - } ); - } ); - - describe( "where null", function() { - it( "can add where null statements", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .whereNull( "id" ); - }, whereNull() ); - } ); - - it( "can add or where null statements", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .where( "id", 1 ) - .orWhereNull( "id" ); - }, orWhereNull() ); - } ); - - it( "can add where not null statements", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .whereNotNull( "id" ); - }, whereNotNull() ); - } ); - - it( "can add or where not null statements", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .where( "id", 1 ) - .orWhereNotNull( "id" ); - }, orWhereNotNull() ); - } ); - - it( "can add a where null with a subselect", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .whereNull( function( q ) { - q.selectRaw( "MAX(created_date)" ) - .from( "logins" ) - .whereColumn( "logins.user_id", "users.id" ); - } ); - }, whereNullSubselect() ); - } ); - - it( "can add a where null with a builder instance", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .whereNull( - builder - .newQuery() - .selectRaw( "MAX(created_date)" ) - .from( "logins" ) - .whereColumn( "logins.user_id", "users.id" ) - ); - }, whereNullSubquery() ); - } ); - - it( "preserves bindings from where null subqueries", function() { - var builder = getBuilder() - .from( "users" ) - .whereNull( function( query ) { - query - .select( "deletedAt" ) - .from( "accounts" ) - .where( "status", "closed" ); - } ); - - expect( reMatch( "\?", builder.toSQL() ) ).toHaveLength( 1 ); - expect( getTestBindings( builder ) ).toBe( [ "closed" ] ); - } ); - } ); - - describe( "where between", function() { - it( "can add where between statements", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .whereBetween( "id", 1, 2 ); - }, whereBetween() ); - } ); - - it( "can add where between statements with raw expressions", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .whereBetween( - "createdDate", - builder.raw( "GETDATE() - 7" ), - builder.raw( "GETDATE()" ) - ); - }, whereBetweenRaw() ); - } ); - - it( "can add where between statements with query param structs", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .whereBetween( - "createdDate", - { value: "1/1/2019", cfsqltype: "DATE" }, - { value: "12/31/2019", cfsqltype: "DATE" } - ); - }, whereBetweenWithQueryParamStructs() ); - } ); - - it( "can add where not between statements", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .whereNotBetween( "id", 1, 2 ); - }, whereNotBetween() ); - } ); - - it( "can add where not between statements with expression boundaries", function() { - var builder = getBuilder() - .from( "users" ) - .whereNotBetween( - "score", - getBuilder().raw( "COALESCE(?, 0)", [ 10 ] ), - getBuilder().raw( "COALESCE(?, 100)", [ 90 ] ) - ); - - expect( builder.toSQL() ).toInclude( "NOT BETWEEN COALESCE(?, 0) AND COALESCE(?, 100)" ); - expect( getTestBindings( builder ) ).toBe( [ 10, 90 ] ); - } ); - - it( "can add where not between statements with subquery boundaries", function() { - var builder = getBuilder() - .from( "users" ) - .whereNotBetween( - "id", - function( query ) { - query - .selectRaw( "MIN(id)" ) - .from( "users" ) - .where( "type", "minimum" ); - }, - function( query ) { - query - .selectRaw( "MAX(id)" ) - .from( "users" ) - .where( "type", "maximum" ); - } - ); - - expect( builder.toSQL() ).toInclude( "NOT BETWEEN (SELECT" ); - expect( getTestBindings( builder ) ).toBe( [ "minimum", "maximum" ] ); - } ); - - it( "can add where between statements using closures", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .whereBetween( - "id", - function( q ) { - q.select( q.raw( "MIN(id)" ) ) - .from( "users" ) - .where( "email", "bar" ); - }, - function( q ) { - q.select( q.raw( "MAX(id)" ) ) - .from( "users" ) - .where( "email", "bar" ); - } - ); - }, whereBetweenClosures() ); - } ); - - it( "can add where between statements using builder instances", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .whereBetween( - "id", - builder - .newQuery() - .select( builder.raw( "MIN(id)" ) ) - .from( "users" ) - .where( "email", "bar" ), - builder - .newQuery() - .select( builder.raw( "MAX(id)" ) ) - .from( "users" ) - .where( "email", "bar" ) - ); - }, whereBetweenBuilderInstances() ); - } ); - - it( "can add where between statements using both closures and builder instances", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .whereBetween( - "id", - function( q ) { - q.select( q.raw( "MIN(id)" ) ) - .from( "users" ) - .where( "email", "bar" ); - }, - builder - .newQuery() - .select( builder.raw( "MAX(id)" ) ) - .from( "users" ) - .where( "email", "bar" ) - ); - }, whereBetweenMixed() ); - } ); - } ); - - describe( "where in", function() { - it( "can add where in statements from a list", function() { - testCase( function( builder ) { - builder.from( "users" ).whereIn( "id", "1,2,3" ); - }, whereInList() ); - } ); - - it( "can add where in statements from an array", function() { - testCase( function( builder ) { - builder.from( "users" ).whereIn( "id", [ 1, 2, 3 ] ); - }, whereInArray() ); - } ); - - it( "can add where in statements from an array", function() { - testCase( function( builder ) { - builder.from( "users" ).whereIn( "id", [ 1, { value: 2, cfsqltype: "INTEGER" }, 3 ] ); - }, whereInArrayOfQueryParamStructs() ); - } ); - - it( "can add or where in statements", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .where( "email", "foo" ) - .orWhereIn( "id", [ 1, 2, 3 ] ); - }, orWhereIn() ); - } ); - - it( "can add raw where in statements", function() { - testCase( function( builder ) { - builder.from( "users" ).whereIn( "id", [ builder.raw( 1 ) ] ); - }, whereInRaw() ); - } ); - - it( "correctly handles empty where ins", function() { - testCase( function( builder ) { - builder.from( "users" ).whereIn( "id", [] ); - }, whereInEmpty() ); - } ); - - it( "correctly handles empty where not ins", function() { - testCase( function( builder ) { - builder.from( "users" ).whereNotIn( "id", [] ); - }, whereNotInEmpty() ); - } ); - - it( "handles sub selects in 'in' statements", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .whereIn( "id", function( q ) { - q.select( "id" ) - .from( "users" ) - .where( "age", ">", 25 ); - } ); - }, whereInSubselect() ); - } ); - - it( "handles builder instances in 'in' statements", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .whereIn( - "id", - builder - .newQuery() - .select( "id" ) - .from( "users" ) - .where( "age", ">", 25 ) - ); - }, whereInBuilderInstance() ); - } ); - - describe( "bulk values", function() { - it( "binds an array as a single parameter", function() { - testCase( function( builder ) { - builder.from( "users" ).whereInBulk( "id", [ 1, 2, 3 ] ); - }, whereInBulk() ); - } ); - - it( "uses a large text binding for the serialized values", function() { - var builder = getBuilder().from( "users" ).whereInBulk( "id", [ 1, 2, 3 ] ); - var bindings = builder.getBindings(); - expect( bindings ).toHaveLength( 1 ); - expect( bindings[ 1 ].value ).toBe( "[1,2,3]" ); - expect( bindings[ 1 ].cfsqltype ).toBe( "LONGVARCHAR" ); - } ); - - it( "serializes values from query parameter structs", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .whereInBulk( - "id", - [ - { value: 1, cfsqltype: "INTEGER" }, - { value: 2, cfsqltype: "INTEGER" }, - { value: 3, cfsqltype: "INTEGER" } - ] - ); - }, whereInBulk() ); - } ); - - it( "infers string values using the grammar-specific string type", function() { - testCase( function( builder ) { - builder.from( "users" ).whereInBulk( "status", [ "active", "pending" ] ); - }, whereInBulkStrings() ); - } ); - - it( "falls back to the grammar-specific string type for mixed values", function() { - testCase( function( builder ) { - builder.from( "users" ).whereInBulk( "externalId", [ 1, "two" ] ); - }, whereInBulkMixed() ); - } ); - - it( "infers boolean values using the grammar-specific boolean type", function() { - testCase( function( builder ) { - builder.from( "users" ).whereInBulk( "active", [ true, false ] ); - }, whereInBulkBooleans() ); - } ); - - it( "uses matching query parameter types as the inferred type", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .whereInBulk( - "id", - [ { value: 1, cfsqltype: "BIGINT" }, { value: 2, cfsqltype: "BIGINT" } ] - ); - }, whereInBulkBigInt() ); - } ); - - it( "allows an explicit SQL type to override inference", function() { - testCase( function( builder ) { - builder.from( "users" ).whereInBulk( "id", [ 1, 2, 3 ], bulkExplicitSqlType() ); - }, whereInBulkExplicitType() ); - } ); - - it( "infers the SQL type when explicitly passed null", function() { - testCase( function( builder ) { - builder.from( "users" ).whereInBulk( "id", [ 1, 2, 3 ], javacast( "null", "" ) ); - }, whereInBulk() ); - } ); - - it( "maps inferred timestamp types for the active grammar", function() { - expect( getBuilder().getGrammar().resolveWhereInBulkSqlType( "TIMESTAMP" ) ).toBe( - bulkTimestampSqlType() - ); - } ); - - it( "supports dynamic or where shortcuts", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .where( "active", 1 ) - .orWhereInBulk( "id", [ 1, 2, 3 ] ); - }, orWhereInBulk() ); - } ); - - it( "supports negated bulk values", function() { - testCase( function( builder ) { - builder.from( "users" ).whereNotInBulk( "id", [ 1, 2, 3 ] ); - }, whereNotInBulk() ); - } ); - - it( "handles empty bulk values without a binding", function() { - testCase( function( builder ) { - builder.from( "users" ).whereInBulk( "id", [] ); - }, whereInBulkEmpty() ); - } ); - - it( "handles empty negated bulk values without a binding", function() { - testCase( function( builder ) { - builder.from( "users" ).whereNotInBulk( "id", [] ); - }, whereNotInBulkEmpty() ); - } ); - - it( "rejects SQL expressions in bulk values", function() { - expect( function() { - getBuilder().whereInBulk( "id", [ getBuilder().raw( "SELECT 1" ) ] ); - } ).toThrow( type = "InvalidBulkValue" ); - } ); - - it( "rejects unsafe SQL types", function() { - expect( function() { - getBuilder().whereInBulk( "id", [ 1, 2, 3 ], "INTEGER); DROP TABLE users; --" ); - } ).toThrow( type = "InvalidSQLType" ); - } ); - - it( "rejects an explicitly empty SQL type", function() { - expect( function() { - getBuilder().whereInBulk( "id", [ 1, 2, 3 ], "" ); - } ).toThrow( type = "InvalidSQLType" ); - } ); - } ); - } ); - - describe( "where like shortcuts", function() { - it( "can add like statements using a shortcut method", function() { - testCase( function( builder ) { - builder.from( "users" ).whereLike( "username", "Jo%" ); - }, whereLike() ); - } ); - - it( "can add where not like statements using a shortcut method", function() { - testCase( function( builder ) { - builder.from( "users" ).whereNotLike( "username", "Jo%" ); - }, whereNotLike() ); - } ); - } ); - } ); - - describe( "joins", function() { - it( "can inner join", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .join( - "contacts", - "users.id", - "=", - "contacts.id" - ); - }, innerJoin() ); - } ); - - it( "can inner join on table as expression", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .join( - builder.raw( "contacts (nolock)" ), - "users.id", - "=", - "contacts.id" - ); - }, innerJoinRaw() ); - } ); - - it( "can inner join on raw sql", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .joinRaw( - "contacts (nolock)", - "users.id", - "=", - "contacts.id" - ); - }, innerJoinRaw() ); - } ); - - it( "can inner join using the shorthand", function() { - testCase( function( builder ) { - builder.from( "users" ).join( "contacts", "users.id", "contacts.id" ); - }, innerJoinShorthand() ); - } ); - - it( "can specify multiple joins", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .join( "contacts", "users.id", "contacts.id" ) - .join( "addresses AS a", "a.contact_id", "contacts.id" ); - }, multipleJoins() ); - } ); - - it( "can join with where bindings instead of columns", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .joinWhere( - "contacts", - "contacts.balance", - "<", - 100 - ); - }, joinWithWhere() ); - } ); - - it( "can join with a callback", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .join( "contacts", function( j ) { - j.on( "users.id", "=", "contacts.id" ); - } ); - }, innerJoinCallback() ); - } ); - - it( "can join with a standalone join clause", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .join( builder.newJoin( "contacts" ).on( "users.id", "=", "contacts.id" ) ); - }, innerJoinWithJoinInstance() ); - } ); - - it( "can left join", function() { - testCase( function( builder ) { - builder.from( "users" ).leftJoin( "orders", "users.id", "orders.user_id" ); - }, leftJoin() ); - } ); - - it( "can left outer join", function() { - testCase( function( builder ) { - builder.from( "users" ).leftOuterJoin( "orders", "users.id", "orders.user_id" ); - }, leftOuterJoin() ); - } ); - - it( "can full join", function() { - testCase( function( builder ) { - builder.from( "users" ).fullJoin( "orders", "users.id", "orders.user_id" ); - }, fullJoin() ); - } ); - - it( "can full outer join", function() { - testCase( function( builder ) { - builder.from( "users" ).fullOuterJoin( "orders", "users.id", "orders.user_id" ); - }, fullOuterJoin() ); - } ); - - it( "can left join on table as expression", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .leftJoin( - builder.raw( "contacts (nolock)" ), - "users.id", - "=", - "contacts.id" - ); - }, leftJoinRaw() ); - } ); - - it( "can left join on raw sql", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .leftJoinRaw( - "contacts (nolock)", - "users.id", - "=", - "contacts.id" - ); - }, leftJoinRaw() ); - } ); - - it( "can left join using a nested query", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .leftJoin( "orders", function( j ) { - j.on( "users.id", "=", "orders.user_id" ); - } ); - }, leftJoinNested() ); - } ); - - it( "it can handle nested on queries without truncating text", function() { - testCase( function( builder ) { - builder - .from( "test" ) - .leftJoin( "last_team_tasks_queue_record", function( j ) { - j.on( - "last_team_tasks_queue_record.task_territory_id", - "team_tasks_queue.task_territory_id" - ); - j.where( function( q ) { - q.whereNull( "last_team_tasks_queue_record.when_created" ); - q.whereColumn( - "last_team_tasks_queue_record.when_created", - "<=", - "team_tasks_queue.when_created", - "OR" - ); - } ); - } ); - }, leftJoinTruncatingText() ); - } ); - - it( "can right join", function() { - testCase( function( builder ) { - builder.from( "orders" ).rightJoin( "users", "orders.user_id", "users.id" ); - }, rightJoin() ); - } ); - - it( "can right outer join", function() { - testCase( function( builder ) { - builder.from( "orders" ).rightOuterJoin( "users", "orders.user_id", "users.id" ); - }, rightOuterJoin() ); - } ); - - it( "can right join on table as expression", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .rightJoin( - builder.raw( "contacts (nolock)" ), - "users.id", - "=", - "contacts.id" - ); - }, rightJoinRaw() ); - } ); - - it( "can right join on raw sql", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .rightJoinRaw( - "contacts (nolock)", - "users.id", - "=", - "contacts.id" - ); - }, rightJoinRaw() ); - } ); - - it( "can cross join", function() { - testCase( function( builder ) { - builder.from( "sizes" ).crossJoin( "colors" ); - }, crossJoin() ); - } ); - - it( "can cross join on table as expression", function() { - testCase( function( builder ) { - builder.from( "users" ).crossJoin( builder.raw( "contacts (nolock)" ) ); - }, crossJoinRaw() ); - } ); - - it( "can cross join on raw sql", function() { - testCase( function( builder ) { - builder.from( "users" ).crossJoinRaw( "contacts (nolock)" ); - }, crossJoinRaw() ); - } ); - - it( "can accept a callback for complex joins", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .join( "contacts", function( j ) { - j.on( "users.id", "=", "contacts.id" ) - .orOn( "users.name", "=", "contacts.name" ) - .orWhere( "users.admin", 1 ); - } ); - }, complexJoin() ); - } ); - - it( "can specify where null in a join", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .join( "contacts", function( j ) { - j.on( "users.id", "=", "contacts.id" ).whereNull( "contacts.deleted_date" ); - } ); - }, joinWithWhereNull() ); - } ); - - it( "can specify or where null in a join", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .join( "contacts", function( j ) { - j.on( "users.id", "=", "contacts.id" ).orWhereNull( "contacts.deleted_date" ); - } ); - }, joinWithOrWhereNull() ); - } ); - - it( "can specify where not null in a join", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .join( "contacts", function( j ) { - j.on( "users.id", "=", "contacts.id" ).whereNotNull( "contacts.deleted_date" ); - } ); - }, joinWithWhereNotNull() ); - } ); - - it( "can specify or where not null in a join", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .join( "contacts", function( j ) { - j.on( "users.id", "=", "contacts.id" ).orWhereNotNull( "contacts.deleted_date" ); - } ); - }, joinWithOrWhereNotNull() ); - } ); - - it( "can specify where in inside a join", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .join( "contacts", function( j ) { - j.on( "users.id", "=", "contacts.id" ).whereIn( "contacts.id", [ 1, 2, 3 ] ); - } ); - }, joinWithWhereIn() ); - } ); - - it( "can specify or where in inside a join", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .join( "contacts", function( j ) { - j.on( "users.id", "=", "contacts.id" ).orWhereIn( "contacts.id", [ 1, 2, 3 ] ); - } ); - }, joinWithOrWhereIn() ); - } ); - - it( "can specify where not in inside a join", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .join( "contacts", function( j ) { - j.on( "users.id", "=", "contacts.id" ).whereNotIn( "contacts.id", [ 1, 2, 3 ] ); - } ); - }, joinWithWhereNotIn() ); - } ); - - it( "can specify or where not in inside a join", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .join( "contacts", function( j ) { - j.on( "users.id", "=", "contacts.id" ).orWhereNotIn( "contacts.id", [ 1, 2, 3 ] ); - } ); - }, joinWithOrWhereNotIn() ); - } ); - - it( "can inner join to a derived table with joinSub using a QueryBuilder object", function() { - testCase( function( builder ) { - var derivedTable = getBuilder() - .select( "id" ) - .from( "contacts" ) - .whereNotIn( "id", [ 1, 2, 3 ] ); - - builder - .from( "users as u" ) - .joinSub( - "c", - derivedTable, - "u.id", - "=", - "c.id" - ); - }, joinSub() ); - } ); - - it( "can inner join to a derived table with joinSub using a closure", function() { - testCase( function( builder ) { - builder - .from( "users as u" ) - .joinSub( - "c", - function( qb ) { - qb.select( "id" ) - .from( "contacts" ) - .whereNotIn( "id", [ 1, 2, 3 ] ); - }, - "u.id", - "=", - "c.id" - ); - }, joinSub() ); - } ); - - it( "can inner join to a derived table with joinSub using the shorthand", function() { - testCase( function( builder ) { - var derivedTable = getBuilder() - .select( "id" ) - .from( "contacts" ) - .whereNotIn( "id", [ 1, 2, 3 ] ); - - builder.from( "users as u" ).joinSub( "c", derivedTable, "u.id", "c.id" ); - }, joinSub() ); - } ); - - it( "can left join to a derived table with joinSub using a QueryBuilder object", function() { - testCase( function( builder ) { - var derivedTable = getBuilder() - .select( "id" ) - .from( "contacts" ) - .whereNotIn( "id", [ 1, 2, 3 ] ); - - builder - .from( "users as u" ) - .leftJoinSub( - "c", - derivedTable, - "u.id", - "=", - "c.id" - ); - }, leftJoinSub() ); - } ); - - it( "can left join to a derived table with joinSub using a closure", function() { - testCase( function( builder ) { - builder - .from( "users as u" ) - .leftJoinSub( - "c", - function( qb ) { - qb.select( "id" ) - .from( "contacts" ) - .whereNotIn( "id", [ 1, 2, 3 ] ); - }, - "u.id", - "=", - "c.id" - ); - }, leftJoinSub() ); - } ); - - it( "can left join to a derived table with joinSub using the shorthand", function() { - testCase( function( builder ) { - var derivedTable = getBuilder() - .select( "id" ) - .from( "contacts" ) - .whereNotIn( "id", [ 1, 2, 3 ] ); - - builder.from( "users as u" ).leftJoinSub( "c", derivedTable, "u.id", "c.id" ); - }, leftJoinSub() ); - } ); - - it( "can right join to a derived table with joinSub using a QueryBuilder object", function() { - testCase( function( builder ) { - var derivedTable = getBuilder() - .select( "id" ) - .from( "contacts" ) - .whereNotIn( "id", [ 1, 2, 3 ] ); - - builder - .from( "users as u" ) - .rightJoinSub( - "c", - derivedTable, - "u.id", - "=", - "c.id" - ); - }, rightJoinSub() ); - } ); - - it( "can right join to a derived table with joinSub using a closure", function() { - testCase( function( builder ) { - builder - .from( "users as u" ) - .rightJoinSub( - "c", - function( qb ) { - qb.select( "id" ) - .from( "contacts" ) - .whereNotIn( "id", [ 1, 2, 3 ] ); - }, - "u.id", - "=", - "c.id" - ); - }, rightJoinSub() ); - } ); - - it( "can right join to a derived table with joinSub using the shorthand", function() { - testCase( function( builder ) { - var derivedTable = getBuilder() - .select( "id" ) - .from( "contacts" ) - .whereNotIn( "id", [ 1, 2, 3 ] ); - - builder.from( "users as u" ).rightJoinSub( "c", derivedTable, "u.id", "c.id" ); - }, rightJoinSub() ); - } ); - - it( "can cross join to a derived table with joinSub using a QueryBuilder object", function() { - testCase( function( builder ) { - var derivedTable = getBuilder() - .select( "id" ) - .from( "contacts" ) - .whereNotIn( "id", [ 1, 2, 3 ] ); - - builder.from( "users as u" ).crossJoinSub( "c", derivedTable ); - }, crossJoinSub() ); - } ); - - it( "can cross join to a derived table with joinSub using a closure", function() { - testCase( function( builder ) { - builder - .from( "users as u" ) - .crossJoinSub( "c", function( qb ) { - qb.select( "id" ) - .from( "contacts" ) - .whereNotIn( "id", [ 1, 2, 3 ] ); - } ); - }, crossJoinSub() ); - } ); - - it( "correctly positions bindings using crossJoinSub", function() { - var builder = getBuilder(); - builder - .from( "A" ) - .where( "A.A", "=", "A" ) - .crossJoinSub( "B", function( query ) { - query.from( "B" ).where( "B.B", "=", "B" ); - } ) - .where( "A.C", "=", "C" ); - - expect( getTestBindings( builder ) ).toBe( [ "B", "A", "C" ] ); - } ); - - it( "does not retain bindings from prevented duplicate joinSub clauses", function() { - var builder = getBuilder().setPreventDuplicateJoins( true ); - var derivedTable = getBuilder().from( "contacts" ).where( "contacts.kind", "personal" ); - - builder - .from( "users AS u" ) - .joinSub( - "c", - derivedTable, - "u.id", - "=", - "c.user_id" - ) - .joinSub( - "c", - derivedTable, - "u.id", - "=", - "c.user_id" - ); - - expect( builder.getJoins() ).toHaveLength( 1 ); - expect( getTestBindings( builder ) ).toBe( [ "personal" ] ); - } ); - - it( "distinguishes joinSub clauses with the same SQL and different bindings", function() { - var builder = getBuilder().setPreventDuplicateJoins( true ); - var personalContacts = getBuilder().from( "contacts" ).where( "contacts.kind", "personal" ); - var businessContacts = getBuilder().from( "contacts" ).where( "contacts.kind", "business" ); - - builder - .from( "users AS u" ) - .joinSub( - "c", - personalContacts, - "u.id", - "=", - "c.user_id" - ) - .joinSub( - "c", - businessContacts, - "u.id", - "=", - "c.user_id" - ); - - expect( builder.getJoins() ).toHaveLength( 2 ); - expect( getTestBindings( builder ) ).toBe( [ "personal", "business" ] ); - } ); - - it( "correctly positions bindings using joinSub", function() { - testCase( function( builder ) { - builder - .from( "A" ) - .where( "A.A", "=", "A" ) - .joinSub( - "B", - ( qb ) => { - return qb.from( "B" ).where( "B.B", "=", "B" ); - }, - "A.A", - "=", - "B.B" - ) - .where( "A.C", "=", "C" ); - }, joinSubBindings() ); - } ); - - it( "can cross apply", function() { - testCase( function( builder ) { - builder - .from( "users as u" ) - .crossApply( "childCount", function( qb ) { - qb.selectRaw( "count(*) c" ) - .from( "children" ) - .whereColumn( "children.parentID", "=", "users.ID" ) - .where( "children.someCol", "=", 0 ) - } ) - .select( [ "u.ID", "childCount.c" ] ) - .where( "childCount.c", ">", 1 ) - }, crossApply() ); - } ); - - it( "can outer apply", function() { - testCase( function( builder ) { - builder - .from( "users as u" ) - .outerApply( "childCount", function( qb ) { - qb.selectRaw( "count(*) c" ) - .from( "children" ) - .whereColumn( "children.parentID", "=", "users.ID" ) - .where( "children.someCol", "=", 0 ) - } ) - .select( [ "u.ID", "childCount.c" ] ) - .where( "childCount.c", ">", 1 ) - }, outerApply() ); - } ); - - it( "correctly positions bindings using crossApply", function() { - testCase( function( builder ) { - builder - .from( "A" ) - .where( "A.A", "=", "A" ) - .crossApply( - "B", - getBuilder() - .from( "x" ) - .where( "x.x", "=", "B" ) - .whereColumn( "x.b", "=", "a.b" ) - ) - .where( "A.C", "=", "C" ) - .outerApply( "D", ( qb ) => { - qb.from( "y" ) - .where( "y.y", "=", "D" ) - .whereColumn( "y.d", "=", "a.d" ) - } ) - }, correctlyPositionsBindingsUsingCrossApply() ); - } ); - - it( "eliminates duplicate cross or outer applies", function() { - testCase( function( builder ) { - var gen = function( name ) { - return function( qb ) { - qb.from( name ).select( "someColumn" ); - }; - }; - builder - .setPreventDuplicateJoins( true ) - .from( "A" ) - .crossApply( "B", gen( "crossapply_B" ) ) - .outerApply( "C", gen( "outerapply_C" ) ) - .crossApply( "B", gen( "crossapply_B" ) ) - .outerApply( "C", gen( "outerapply_C" ) ) - .crossApply( "D", gen( "crossapply_D" ) ) - .outerApply( "E", gen( "outerapply_E" ) ) - .crossApply( "D", gen( "crossapply_D" ) ) - .outerApply( "E", gen( "outerapply_E" ) ) - }, duplicateCrossAndOuterAppliesEliminated() ); - } ); - - it( "can join with a callback that includes a whereExists clause", function() { - testCase( ( builder ) => { - builder - .from( "LeftTable AS lt" ) - .leftJoin( "RightTable AS rt", ( j ) => { - j.on( "rt.id", "lt.id" ) - .whereExists( ( qb ) => { - qb.selectRaw( 1 ) - .from( "ExistsTable AS et" ) - .whereColumn( "et.id", "lt.id" ); - } ); - } ); - }, joinCallbackWhereExists() ); - } ); - } ); - - describe( "group bys", function() { - it( "can add a simple group by", function() { - testCase( function( builder ) { - builder - .select( "*" ) - .from( "users" ) - .groupBy( "email" ); - }, groupBy() ); - } ); - - it( "can group by multiple fields using an array", function() { - testCase( function( builder ) { - builder.from( "users" ).groupBy( [ "id", "email" ] ); - }, groupByArray() ); - } ); - - it( "can group by multiple fields using raw sql", function() { - testCase( function( builder ) { - builder.from( "users" ).groupBy( builder.raw( "DATE(created_at)" ) ); - }, groupByRaw() ); - } ); - } ); - - describe( "havings", function() { - it( "can add a basic having clause", function() { - testCase( function( builder ) { - builder.from( "users" ).having( "email", ">", 1 ); - }, havingBasic() ); - } ); - - it( "can add a having clause with a raw column", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .groupBy( "email" ) - .having( builder.raw( "COUNT(email)" ), ">", 1 ); - }, havingRawColumn() ); - } ); - - it( "can use a raw expression as the entire having clause", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .groupBy( "email" ) - .having( builder.raw( "COUNT(email) > ?", [ 1 ] ) ); - }, havingRawExpression() ); - } ); - - it( "can use a havingRaw shortcut method", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .groupBy( "email" ) - .havingRaw( "COUNT(email) > ?", [ 1 ] ); - }, havingRawExpression() ); - } ); - - it( "can add a having clause with a raw column that contains bindings", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .groupBy( "email" ) - .having( - builder.raw( "CASE WHEN active = ? THEN COUNT(email) ELSE 0 END", [ 1 ] ), - ">", - 2 - ); - }, havingRawColumnWithBindings() ); - } ); - - it( "can add a having clause with a raw value", function() { - testCase( function( builder ) { - builder - .select( builder.raw( "COUNT(*) AS ""total""" ) ) - .from( "items" ) - .where( "department", "=", "popular" ) - .groupBy( "category" ) - .having( "total", ">", builder.raw( 3 ) ); - }, havingRawValue() ); - } ); - - it( "correctly orders bindings with having and raw statements and whereIn", function() { - testCase( function( builder ) { - builder - .from( "holdings h" ) - .join( "accounts a", "h.account_id", "a.account_id" ) - .where( "shares", "<>", "-999" ) - .andWhere( "investment_type", "taxable" ) - .andWhereNotLike( "security_id", "*%" ) - .select( "h.account_id, security_id" ) - .groupBy( "h.account_id, security_id" ) - .having( builder.raw( "COUNT(security_id)" ), ">", 1 ) - .orderBy( "h.account_id, security_id" ) - .when( true, ( q ) => { - q.whereIn( "h.account_id", ( q ) => { - q.select( "portfolioCode" ) - .from( "accounts" ) - .whereIn( "id", [ 662 ] ); - } ) - } ); - }, havingRawWhereIn() ); - } ); - } ); - - describe( "order bys", function() { - it( "can add a simple order by", function() { - testCase( function( builder ) { - builder.from( "users" ).orderBy( "email" ); - }, orderBy() ); - } ); - - it( "can order by random", function() { - testCase( function( builder ) { - builder.from( "users" ).orderByRandom(); - }, orderByRandom() ); - } ); - - it( "can add a simple order by using the asc shortcut method", function() { - testCase( function( builder ) { - builder.from( "users" ).orderByAsc( "email" ); - }, orderBy() ); - } ); - - it( "can order in descending order", function() { - testCase( function( builder ) { - builder.from( "users" ).orderBy( "email", "desc" ); - }, orderByDesc() ); - } ); - - it( "can order in descending order using the desc shortcut method", function() { - testCase( function( builder ) { - builder.from( "users" ).orderByDesc( "email" ); - }, orderByDesc() ); - } ); - - it( "combines all order by calls", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( "id" ) - .orderBy( "email", "desc" ); - }, combinesOrderBy() ); - } ); - - it( "can order by a raw expression", function() { - testCase( function( builder ) { - builder.from( "users" ).orderBy( builder.raw( "DATE(created_at)" ) ); - }, orderByRaw() ); - } ); - - it( "has an orderByRaw shortcut method", function() { - testCase( function( builder ) { - builder.from( "users" ).orderByRaw( "DATE(created_at)" ); - }, orderByRaw() ); - } ); - - it( "can accept bindings in orderByRaw", function() { - testCase( function( builder ) { - builder.from( "users" ).orderByRaw( "CASE WHEN id = ? THEN 1 ELSE 0 END DESC", [ 1 ] ); - }, orderByRawWithBindings() ); - } ); - - it( "can accept bindings in a raw expression in orderBy", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( builder.raw( "CASE WHEN id = ? THEN 1 ELSE 0 END DESC", [ 1 ] ) ); - }, orderByWithRawBindings() ); - } ); - - it( "can order by a subselect", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( function( q ) { - q.selectRaw( "MAX(created_date)" ) - .from( "logins" ) - .whereColumn( "users.id", "logins.user_id" ); - } ); - }, orderBySubselect() ); - } ); - - it( "can order by a subselect using the asc shortcut method", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderByAsc( function( q ) { - q.selectRaw( "MAX(created_date)" ) - .from( "logins" ) - .whereColumn( "users.id", "logins.user_id" ); - } ); - }, orderBySubselect() ); - } ); - - it( "can order by a subselect descending", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( function( q ) { - q.selectRaw( "MAX(created_date)" ) - .from( "logins" ) - .whereColumn( "users.id", "logins.user_id" ); - }, "desc" ); - }, orderBySubselectDescending() ); - } ); - - it( "can order by a subselect descending using the desc shortcut method", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderByDesc( function( q ) { - q.selectRaw( "MAX(created_date)" ) - .from( "logins" ) - .whereColumn( "users.id", "logins.user_id" ); - } ); - }, orderBySubselectDescending() ); - } ); - - it( "can order by a builder instance", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( - builder - .newQuery() - .selectRaw( "MAX(created_date)" ) - .from( "logins" ) - .whereColumn( "users.id", "logins.user_id" ) - ); - }, orderByBuilderInstance() ); - } ); - - it( "can order by a builder instance using the asc shortcut method", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderByAsc( - builder - .newQuery() - .selectRaw( "MAX(created_date)" ) - .from( "logins" ) - .whereColumn( "users.id", "logins.user_id" ) - ); - }, orderByBuilderInstance() ); - } ); - - it( "can order by a builder instance descending", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( - builder - .newQuery() - .selectRaw( "MAX(created_date)" ) - .from( "logins" ) - .whereColumn( "users.id", "logins.user_id" ), - "desc" - ); - }, orderByBuilderInstanceDescending() ); - } ); - - it( "can order by a builder instance descending using the desc shortcut method", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderByDesc( - builder - .newQuery() - .selectRaw( "MAX(created_date)" ) - .from( "logins" ) - .whereColumn( "users.id", "logins.user_id" ) - ); - }, orderByBuilderInstanceDescending() ); - } ); - - it( "can order by a builder instance with bindings", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( - builder - .newQuery() - .selectRaw( "MAX(created_date)" ) - .from( "logins" ) - .whereColumn( "users.id", "logins.user_id" ) - .where( "created_date", ">", "2020-01-01 00:00:00" ) - ); - }, orderByBuilderWithBindings() ); - } ); - - describe( "can accept an array for the column argument", function() { - it( "rejects invalid default directions", function() { - expect( function() { - getBuilder().from( "users" ).orderBy( "email", "DESC; DROP TABLE users" ); - } ).toThrow( type = "InvalidSQLType", regex = "Illegal order direction" ); - expect( function() { - getBuilder().orderBySub( ( query ) => query.selectRaw( "1" ), "DESC; DROP TABLE users" ); - } ).toThrow( type = "InvalidSQLType", regex = "Illegal order direction" ); - } ); - - describe( "with the array values", function() { - it( "as simple strings", function() { - testCase( function( builder ) { - builder.from( "users" ).orderBy( [ "last_name", "age", "favorite_color" ] ); - }, orderByArray() ); - } ); - - it( "can clear already configured orders", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( [ "last_name", "age", "favorite_color" ] ) - .clearOrders(); - }, orderByClearOrders() ); - } ); - - it( "can reorder a query", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( [ "last_name", "favorite_color" ] ) - .reorder( "age" ); - }, reorder() ); - } ); - - it( "as pipe delimited strings", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( [ "last_name|desc", "age|asc", "favorite_color|desc" ] ); - }, orderByPipeDelimited() ); - } ); - - it( "as a nested positional array", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( [ [ "last_name", "desc" ], [ "age", "asc" ], [ "favorite_color" ] ] ); - }, orderByArrayOfArrays() ); - } ); - - it( "as a nested positional array with leniency for arrays of length 1 or longer than 2 which assumes position 1 is column name and position 2 is the direction and ignores other entries in the nested array", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( [ - [ "last_name", "desc" ], - [ "age", "asc" ], - [ "favorite_color" ], - [ - "height", - "asc", - "will", - "be", - "ignored" - ] - ] ); - }, orderByArrayOfArraysIgnoringExtraValues() ); - } ); - - it( "as a any combo of values and ignores then inherits the direction's argument value if an invalid direction is supplied (anything other than (asc|desc)", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( [ - [ "last_name", "desc" ], - [ "age", "forward" ], - "favorite_color|backward", - "favorite_food|desc", - { column: "height", direction: "tallest" }, - { column: "weight", direction: "desc" }, - builder.raw( "DATE(created_at)" ), - { column: builder.raw( "DATE(modified_at)" ), direction: "desc" } // desc will be ignored in this case because it's an expression - ] ); - }, orderByComplex() ); - } ); - - it( "as raw expressions", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( [ - builder.raw( "DATE(created_at)" ), - { column: builder.raw( "DATE(modified_at)" ) } - ] ); - }, orderByRawInStruct() ); - } ); - - it( "as simple strings OR pipe delimited strings intermingled", function() { - testCase( function( builder ) { - builder.from( "users" ).orderBy( [ "last_name", "age|desc", "favorite_color" ] ); - }, orderByMixSimpleAndPipeDelimited() ); - } ); - - it( "can accept a struct with a column key and optionally the direction key", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( - [ - { column: "last_name" }, - { column: "age", direction: "asc" }, - { column: "favorite_color", direction: "desc" } - ], - "desc" - ); - }, orderByStruct() ); - } ); - - it( "as values that when additional orderBy() calls are chained the chained calls preserve the order of the calls", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( "last_name,age desc" ) - .orderBy( "favorite_color desc" ) - .orderBy( - column = [ { column: "height" }, { column: "weight", direction: "asc" } ], - direction = "desc" - ) - .orderBy( column = "eye_color", direction = "desc" ) - .orderBy( [ - { column: "is_athletic", direction: "desc", extraKey: "ignored" }, - builder.raw( "DATE(created_at)" ) - ] ) - .orderBy( builder.raw( "DATE(modified_at)" ) ); - }, multipleOrderByCalls() ); - } ); - - it( "as any combo of any valid values intermingled", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( [ - "last_name", - "age|desc", - [ "eye_color", "desc" ], - [ "hair_color" ], - { column: "is_musical" }, - { column: "is_athletic", direction: "desc", extraKey: "ignored" }, - builder.raw( "DATE(created_at)" ), - { column: builder.raw( "DATE(modified_at)" ), direction: "desc" } // direction is ignored because it should be RAW - ] ); - }, orderByMixed() ); - } ); - } ); - } ); - - describe( "can accept a comma delimited list for the column argument", function() { - describe( "with the list values", function() { - it( "as simple column names that inherit the default direction", function() { - testCase( function( builder ) { - builder.from( "users" ).orderBy( "last_name,age,favorite_color" ); - }, orderByList() ); - } ); - - it( "as simple column names while inheriting the direction argument's supplied value", function() { - testCase( function( builder ) { - builder.from( "users" ).orderBy( "last_name,age,favorite_color", "desc" ); - }, orderByListDefaultDirection() ); - } ); - - it( "as column names with secondary piped delimited value representing the direction for each column", function() { - testCase( function( builder ) { - builder.from( "users" ).orderBy( "last_name|desc,age|desc,favorite_color|asc" ); - }, orderByListPipeDelimited() ); - } ); - - it( "as column names with optional secondary piped delimited value representing the direction for that column and inherits the direction argument's value when supplied", function() { - testCase( function( builder ) { - builder.from( "users" ).orderBy( "last_name|asc,age,favorite_color|asc", "desc" ); - }, orderByListPipeDelimitedWithDefaultDirection() ); - } ); - } ); - } ); - } ); - - describe( "unions", function() { - it( "can union multiple statements using a closure", function() { - testCase( function( builder ) { - builder - .select( "name" ) - .from( "users" ) - .where( "id", 1 ) - .union( function( q ) { - q.select( "name" ) - .from( "users" ) - .where( "id", 2 ) - ; - } ) - .union( function( q ) { - q.select( "name" ) - .from( "users" ) - .where( "id", 3 ) - ; - } ) - ; - }, union() ); - } ); - - it( "can union multiple statements using a QueryBuilder instance", function() { - testCase( function( builder ) { - var union2 = getBuilder() - .select( "name" ) - .from( "users" ) - .where( "id", 2 ); - var union3 = getBuilder() - .select( "name" ) - .from( "users" ) - .where( "id", 3 ); - - builder - .select( "name" ) - .from( "users" ) - .where( "id", 1 ) - .union( union2 ) - .union( union3 ) - ; - }, union() ); - } ); - - it( "union can contain order by on main query only", function() { - testCase( function( builder ) { - builder - .select( "name" ) - .from( "users" ) - .where( "id", 1 ) - .union( function( q ) { - q.select( "name" ) - .from( "users" ) - .where( "id", 2 ) - ; - } ) - .union( function( q ) { - q.select( "name" ) - .from( "users" ) - .where( "id", 3 ) - ; - } ) - .orderBy( "name" ) - ; - }, unionOrderBy() ); - } ); - - it( "union query cannot contain orderBy", function() { - var builder = getBuilder(); - - builder - .select( "name" ) - .from( "users" ) - .where( "id", 1 ) - .union( function( q ) { - q.select( "name" ) - .from( "users" ) - .where( "id", 2 ) - .orderBy( "name" ) - ; - } ) - .union( function( q ) { - q.select( "name" ) - .from( "users" ) - .where( "id", 3 ) - ; - } ) - .orderBy( "name" ) - ; - - - try { - var statements = builder.toSql(); - } catch ( any e ) { - // Darn ACF nests the exception message. 😠 - if ( e.message == "An exception occurred while calling the function map." ) { - expect( e.detail ).toBe( "The ORDER BY clause is not allowed in a UNION statement." ); - } else { - expect( e.message ).toBe( "The ORDER BY clause is not allowed in a UNION statement." ); - } - return; - } - fail( "Should have caught an exception, but didn't." ); - } ); - - it( "can union all multiple statements using a closure", function() { - testCase( function( builder ) { - builder - .select( "name" ) - .from( "users" ) - .where( "id", 1 ) - .unionAll( function( q ) { - q.select( "name" ) - .from( "users" ) - .where( "id", 2 ); - } ) - .unionAll( function( q ) { - q.select( "name" ) - .from( "users" ) - .where( "id", 3 ); - } ); - }, unionAll() ); - } ); - - it( "can union all multiple statements using a QueryBuilder instance", function() { - testCase( function( builder ) { - var union2 = getBuilder() - .select( "name" ) - .from( "users" ) - .where( "id", 2 ); - var union3 = getBuilder() - .select( "name" ) - .from( "users" ) - .where( "id", 3 ); - - builder - .select( "name" ) - .from( "users" ) - .where( "id", 1 ) - .unionAll( union2 ) - .unionAll( union3 ) - ; - }, unionAll() ); - } ); - - it( "snapshots a builder passed to a union", function() { - var unionQuery = getBuilder().select( "name" ).from( "archived_users" ); - var builder = getBuilder() - .select( "name" ) - .from( "users" ) - .unionAll( unionQuery ); - - unionQuery.where( "active", 1 ); - - expect( builder.toSQL() ).notToInclude( "active" ); - expect( getTestBindings( builder ) ).toBe( [] ); - } ); - - it( "orders union bindings before outer order bindings", function() { - var builder = getBuilder() - .select( "name" ) - .from( "users" ) - .where( "status", "current" ) - .union( function( unionQuery ) { - unionQuery - .select( "name" ) - .from( "archived_users" ) - .where( "status", "archived" ); - } ) - .orderByRaw( "CASE WHEN name = ? THEN 0 ELSE 1 END", [ "preferred" ] ); - - expect( getTestBindings( builder ) ).toBe( [ "current", "archived", "preferred" ] ); - } ); - - it( "retains root select bindings when aggregating a union", function() { - var builder = getBuilder() - .selectRaw( "? AS name", [ "current" ] ) - .from( "users" ) - .union( function( unionQuery ) { - unionQuery.selectRaw( "? AS name", [ "archived" ] ).from( "archived_users" ); - } ); - - expect( function() { - builder.count( toSQL = true, showBindings = "inline" ); - } ).notToThrow(); - } ); - - it( "can run an aggregate query like count on a union query", function() { - testCase( function( builder ) { - return builder - .select( "name" ) - .from( "users" ) - .where( "id", 1 ) - .union( function( q ) { - q.select( "name" ) - .from( "users" ) - .where( "id", 2 ); - } ) - .count( toSQL = true ); - }, unionCount() ); - } ); - } ); - - describe( "aggregates", function() { - it( "exists", () => { - testCase( function( builder ) { - return builder - .from( "users" ) - .where( "id", 1 ) - .exists( toSql = true ) - }, aggregateExists() ); - } ); - } ); - - describe( "common table expressions (i.e. CTEs)", function() { - it( "can create CTE from closure", function() { - testCase( function( builder ) { - builder - .with( "UsersCTE", function( q ) { - q.select( "*" ) - .from( "users" ) - .join( "contacts", "users.id", "contacts.id" ) - .where( "users.age", ">", 25 ) - ; - } ) - .from( "UsersCTE" ) - .whereNotIn( "user.id", [ 1, 2 ] ) - ; - }, commonTableExpression() ); - } ); - - it( "can create CTE from QueryBuilder instance", function() { - testCase( function( builder ) { - var cte = getBuilder() - .select( "*" ) - .from( "users" ) - .join( "contacts", "users.id", "contacts.id" ) - .where( "users.age", ">", 25 ) - ; - - builder - .with( "UsersCTE", cte ) - .from( "UsersCTE" ) - .whereNotIn( "user.id", [ 1, 2 ] ) - ; - }, commonTableExpression() ); - } ); - - it( "snapshots a builder passed to a common table expression", function() { - var cte = getBuilder().select( "id" ).from( "users" ); - var builder = getBuilder().with( "UsersCTE", cte ).from( "UsersCTE" ); - - cte.where( "active", 1 ); - - expect( builder.toSQL() ).notToInclude( "active" ); - expect( getTestBindings( builder ) ).toBe( [] ); - } ); - - it( "can correctly bind parameters regardless of order", function() { - testCase( function( builder ) { - builder - .from( "UsersCTE" ) - .whereNotIn( "user.id", [ 1, 2 ] ) - .with( "UsersCTE", function( q ) { - q.select( "*" ) - .from( "users" ) - .join( "contacts", "users.id", "contacts.id" ) - .where( "users.age", ">", 25 ) - ; - } ) - ; - }, commonTableExpression() ); - } ); - - it( "can create recursive CTE", function() { - testCase( function( builder ) { - builder - .withRecursive( "UsersCTE", function( q ) { - q.select( "*" ) - .from( "users" ) - .join( "contacts", "users.id", "contacts.id" ) - .where( "users.age", ">", 25 ) - ; - } ) - .from( "UsersCTE" ) - .whereNotIn( "user.id", [ 1, 2 ] ) - ; - }, commonTableExpressionWithRecursive() ); - } ); - - it( "properly handles recursive CTEs with included columns", function() { - testCase( function( builder ) { - builder - .withRecursive( - "UsersCTE", - function( q ) { - q.select( [ "users.id AS usersId", "contacts.id AS contactsId" ] ) - .from( "users" ) - .join( "contacts", "users.id", "contacts.id" ) - .where( "users.age", ">", 25 ) - ; - }, - [ "usersId", "contactsId" ] - ) - .from( "UsersCTE" ) - .whereNotIn( "user.id", [ 1, 2 ] ) - ; - }, commonTableExpressionWithRecursiveWithColumns() ); - } ); - - it( "can create multiple CTEs where the second CTE is not recursive", function() { - testCase( function( builder ) { - builder - .withRecursive( "UsersCTE", function( q ) { - q.select( "*" ) - .from( "users" ) - .join( "contacts", "users.id", "contacts.id" ) - .where( "users.age", ">", 25 ) - ; - } ) - .with( "OrderCTE", function( q ) { - q.from( "orders" ).where( "created", ">", "2018-04-30" ) - ; - } ) - .from( "UsersCTE" ) - .whereNotIn( "user.id", [ 1, 2 ] ) - ; - }, commonTableExpressionMultipleCTEsWithRecursive() ); - } ); - - it( "can create bindings in the correct order", function() { - testCase( function( builder ) { - builder - .from( "UsersCTE" ) - .whereNotIn( "user.id", [ 1, 2 ] ) - .with( "OrderCTE", function( q ) { - q.from( "orders" ).where( "created", ">", "2018-04-30" ) - ; - } ) - .withRecursive( "UsersCTE", function( q ) { - q.select( "*" ) - .from( "users" ) - .join( "contacts", "users.id", "contacts.id" ) - .where( "users.age", ">", 25 ) - ; - } ) - ; - }, commonTableExpressionBindingOrder() ); - } ); - - it( "can insert based off of a cte", function() { - testCase( function( builder ) { - return builder - .with( "UsersCTE", function( q ) { - q.select( "*" ) - .from( "users" ) - .where( "users.age", ">", 25 ) - ; - } ) - .table( "oldUsers" ) - .insertUsing( - source = function( qb ) { - qb.from( "UsersCTE" ).select( [ "fname", "lname", "username", "age" ] ); - }, - toSQL = true - ); - }, cteInsertUsing() ); - } ); - } ); - - describe( "limits", function() { - it( "can limit the record set returned", function() { - testCase( function( builder ) { - builder.from( "users" ).limit( 3 ); - }, limit() ); - } ); - - it( "has an alias of ""take""", function() { - testCase( function( builder ) { - builder.from( "users" ).take( 1 ); - }, take() ); - } ); - } ); - - describe( "offsets", function() { - it( "can offset the record set returned", function() { - testCase( function( builder ) { - builder.from( "users" ).offset( 3 ); - }, this.offset() ); - } ); - - it( "can offset with an order by", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .orderBy( "id" ) - .offset( 3 ); - }, offsetWithOrderBy() ); - } ); - } ); - - describe( "forPage", function() { - it( "combines limits and offsets for easy pagination", function() { - testCase( function( builder ) { - builder.from( "users" ).forPage( 3, 15 ); - }, forPage() ); - } ); - - it( "returns zeros values less than zero", function() { - testCase( function( builder ) { - builder - .setShouldMaxRowsOverrideToAll( function() { - return false; - } ) - .from( "users" ) - .forPage( 0, -2 ); - }, forPageWithLessThanZeroValues() ); - } ); - } ); - - describe( "reset", function() { - it( "can reset the query to default values", function() { - testCase( function( builder ) { - builder - .from( "users" ) - .where( "id", 1 ) - .where( "active", 1 ) - .orderByAsc( "createdDate" ) - .forPage( 3, 15 ) - .reset() - .from( "otherTable" ); - }, reset() ); - } ); - } ); - } ); - - describe( "JSON support", function() { - it( "selects scalar values with explicit and arrow syntax", function() { - testCase( function( builder ) { - return builder - .select( [ - builder.jsonPath( - column = "profile", - path = [ "contacts", 0, "email" ], - alias = "explicitName" - ), - "profile->contacts->0->email AS shortcutName" - ] ) - .from( "users" ); - }, jsonScalarSelect() ); - } ); - - it( "uses scalar values in predicates with explicit and arrow syntax", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .where( builder.jsonPath( column = "profile", path = [ "age" ] ), ">=", 21 ) - .where( "profile->age", "<", 65 ); - }, jsonScalarWhere() ); - } ); - - it( "checks containment with explicit and arrow syntax", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .whereJsonContains( column = "profile", path = [ "languages" ], value = "en" ) - .whereJsonContains( "profile->languages", "en" ); - }, jsonContains() ); - } ); - - it( "checks path existence with explicit and arrow syntax", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .whereJsonExists( column = "profile", path = [ "name" ] ) - .whereJsonExists( "profile->name" ); - }, jsonExists() ); - } ); - - it( "checks array length and orders scalar values with both syntaxes", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .whereJsonLength( - column = "profile", - path = [ "languages" ], - operator = ">", - value = 1 - ) - .whereJsonLength( "profile->languages", ">", 1 ) - .orderBy( builder.jsonPath( "profile", [ "name" ] ) ) - .orderByDesc( "profile->name" ); - }, jsonLengthAndOrder() ); - } ); - - it( "defaults JSON length comparisons to equality with both syntaxes", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .whereJsonLength( column = "profile", path = [ "languages" ], value = 1 ) - .whereJsonLength( "profile->languages", 1 ) - .orWhereJsonLength( column = "profile", path = [ "languages" ], value = 2 ) - .orWhereJsonLength( "profile->languages", 2 ); - }, jsonLengthEqualityShortcut() ); - } ); - - it( "supports compound containment values with both syntaxes", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .whereJsonContains( column = "profile", path = [ "languages" ], value = [ "en", "de" ] ) - .whereJsonContains( "profile->languages", [ "en", "de" ] ); - }, jsonCompoundContains() ); - } ); - - it( "preserves an empty array passed as a shortcut containment value", function() { - testCase( function( builder ) { - return builder.from( "users" ).whereJsonContains( "profile->languages", [] ); - }, jsonEmptyCompoundContains() ); - } ); - - it( - title = "preserves explicit paths when checking containment for JSON null", - body = function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .whereJsonContains( - column = "profile", - path = [ "languages" ], - value = javacast( "null", "" ) - ) - .whereJsonContains( "profile->languages", javacast( "null", "" ) ); - }, jsonNullContains() ); - }, - skip = function() { - var fullNull = createObject( "java", "java.lang.System" ).getEnv( "FULL_NULL" ); - return isNull( fullNull ) || !fullNull; - } - ); - - it( "distinguishes explicit numeric object keys from shortcut array indexes", function() { - testCase( function( builder ) { - return builder - .select( [ builder.jsonPath( "profile", [ "0" ], "explicitKey" ), "profile->0 AS shortcutIndex" ] ) - .from( "users" ); - }, jsonNumericObjectKey() ); - } ); - - it( "supports JSON boolean and negative convenience methods", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .whereJsonDoesntContain( column = "profile", path = [ "languages" ], value = "en" ) - .orWhereJsonDoesntContain( "profile->languages", "fr" ) - .orWhereJsonContains( column = "profile", path = [ "languages" ], value = "de" ) - .whereJsonDoesntExist( column = "profile", path = [ "nickname" ] ) - .orWhereJsonExists( "profile->name" ) - .orWhereJsonDoesntExist( "profile->timezone" ) - .orWhereJsonLength( - column = "profile", - path = [ "languages" ], - operator = ">", - value = 1 - ); - }, jsonConveniencePredicates() ); - } ); - } ); - - describe( "insert statements", function() { - it( "can insert a struct of data into a table", function() { - testCase( function( builder ) { - return builder.from( "users" ).insert( values = { "email": "foo" }, toSql = true ); - }, insertSingleColumn() ); - } ); - - it( "correctly formats booleans during an insert", function() { - testCase( - callback = function( builder ) { - return builder.from( "users" ).insert( values = { "active": true }, toSql = true ); - }, - expected = insertBoolean(), - withFullBindings = true - ); - } ); - - it( "always uses passed in cfsqltypes if available", function() { - testCase( - callback = function( builder ) { - return builder - .from( "users" ) - .insert( - values = { "active": { "value": true, "cfsqltype": "BOOLEAN" } }, - toSql = true - ); - }, - expected = insertBooleanExplicitSqlType(), - withFullBindings = true - ); - } ); - - it( "can insert a struct of data with multiple columns into a table", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .insert( values = { "email": "foo", "name": "bar" }, toSql = true ); - }, insertMultipleColumns() ); - } ); - - it( "can batch insert multiple records", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .insert( - values = [ { "email": "foo", "name": "bar" }, { "email": "baz", "name": "bleh" } ], - toSql = true - ); - }, batchInsert() ); - } ); - - it( "can insert with returning", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .returning( "id" ) - .insert( values = { "email": "foo", "name": "bar" }, toSql = true ); - }, returning() ); - } ); - - it( "preserves commas inside returningRaw expressions", function() { - var builder = getBuilder().returningRaw( "'last,first' AS label" ); - - expect( builder.getReturning() ).toHaveLength( 1 ); - expect( builder.getReturning()[ 1 ].value.getSQL() ).toBe( "'last,first' AS label" ); - } ); - - it( "can return all from an insert", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .returningAll() - .insert( values = { "email": "foo", "name": "bar" }, toSql = true ); - }, returningAll() ); - } ); - - it( "returning ignores table qualifiers", function() { - testCase( function( builder ) { - return builder - .setColumnFormatter( function( column ) { - return "tablePrefix." & column; - } ) - .from( "users" ) - .returning( "id" ) - .insert( values = { "email": "foo", "name": "bar" }, toSql = true ); - }, returningIgnoresTableQualifiers() ); - } ); - - it( "can insert with raw values", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .insert( - values = { "email": "john@example.com", "created_date": builder.raw( "now()" ) }, - toSql = true - ); - }, insertWithRaw() ); - } ); - - it( "preserves bindings carried by insert expressions", function() { - var builder = getBuilder(); - var sql = builder - .from( "users" ) - .insert( - values = { - "first": builder.raw( "COALESCE(?, 0)", [ 10 ] ), - "second": 20, - "third": builder.raw( "COALESCE(?, ?)", [ 30, 40 ] ) - }, - toSql = true - ); - - expect( reMatch( "\?", sql ) ).toHaveLength( 4 ); - expect( getTestBindings( builder ) ).toBe( [ 10, 20, 30, 40 ] ); - } ); - - it( "can insert with null values", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .insert( - values = { "email": "john@example.com", "optional_field": javacast( "null", "" ) }, - toSql = true - ); - }, insertWithNull() ); - } ); - - it( "can insert using a select statement and a callback", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .insertUsing( - columns = [ "email", "createdDate" ], // purposefully not in alphabetical order - source = function( q ) { - q.from( "activeDirectoryUsers" ) - .select( [ "email", "createdDate" ] ) - .where( "active", 1 ); - }, - toSql = true - ); - }, insertUsingSelectCallback() ); - } ); - - it( "can insert using a select statement and a builder object", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .insertUsing( - columns = [ "email", "createdDate" ], // purposefully not in alphabetical order - source = builder - .newQuery() - .from( "activeDirectoryUsers" ) - .select( [ "email", "createdDate" ] ) - .where( "active", 1 ), - toSql = true - ); - }, insertUsingSelectBuilder() ); - } ); - - it( "does not include unrelated parent bindings in insert using statements", function() { - var builder = getBuilder().from( "users" ).where( "tenant_id", 42 ); - var source = builder - .newQuery() - .from( "activeDirectoryUsers" ) - .select( "email" ) - .where( "active", 1 ); - - var sql = builder.insertUsing( columns = [ "email" ], source = source, toSql = true ); - - expect( reMatch( "\?", sql ) ).toHaveLength( 1 ); - expect( getTestBindings( builder ) ).toBe( [ 1 ] ); - } ); - - it( "can derive the columns to insert from the source query", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .insertUsing( - source = function( q ) { - q.from( "activeDirectoryUsers" ) - .select( [ "email", "modifiedDate AS createdDate" ] ) - .where( "active", 1 ); - }, - toSql = true - ); - }, insertUsingDerivingColumnNames() ); - } ); - - it( "can guess column names from raw statements in an insert using query", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .insertUsing( - source = function( q ) { - q.from( "activeDirectoryUsers" ) - .select( "email" ) - .selectRaw( "COALESCE(modifiedDate, NOW()) AS createdDate" ) - .where( "active", 1 ); - }, - toSql = true - ); - }, insertUsingDerivedColumnNamesFromRawStatements() ); - } ); - - it( "can insert ignoring conflicts", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .insertIgnore( - values = [ { "email": "foo", "name": "bar" }, { "email": "baz", "name": "bleh" } ], - target = [ "email" ], - toSql = true - ); - }, insertIgnore() ); - } ); - } ); - - describe( "update statements", function() { - it( "can update all records in a table", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .update( values = { "email": "foo", "name": "bar" }, toSql = true ); - }, updateAllRecords() ); - } ); - - it( "can be constrained by a where statement", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .whereId( 1 ) - .update( values = { "email": "foo", "name": "bar" }, toSql = true ); - }, updateWithWhere() ); - } ); - - it( "can use an expression in an update", function() { - testCase( function( builder ) { - return builder - .from( "hits" ) - .where( "page", "someUrl" ) - .update( values = { "count": builder.raw( "count + 1" ) }, toSql = true ); - }, updateWithRaw() ); - } ); - - it( "preserves bindings carried by update expressions", function() { - var builder = getBuilder(); - var sql = builder - .from( "hits" ) - .update( values = { "count": builder.raw( "COALESCE(?, 0) + ?", [ 10, 1 ] ) }, toSql = true ); - - expect( reMatch( "\?", sql ) ).toHaveLength( 2 ); - expect( getTestBindings( builder ) ).toBe( [ 10, 1 ] ); - } ); - - it( "can use an expression in an update table or from clause", function() { - testCase( function( builder ) { - return builder - .tableRaw( "LogFiles..Browsers" ) - .where( "ID", 1 ) - .update( values = { "UserAgent": "Mozilla/5.0" }, toSql = true ); - }, updateWithRawTable() ); - } ); - - it( "can add incrementally with addUpdate", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .whereId( 1 ) - .addUpdate( { "email": "foo", "name": "bar" } ) - .when( true, function( q ) { - q.addUpdate( { "foo": "yes" } ); - } ) - .when( false, function( q ) { - q.addUpdate( { "bar": "no" } ); - } ) - .update( toSql = true ); - }, addUpdate() ); - } ); - - it( "can update with a join", function() { - testCase( function( builder ) { - return builder - .table( "employees" ) - .join( "departments", "departments.id", "employees.departmentId" ) - .update( - values = { "employees.departmentName": builder.raw( "departments.name" ) }, - toSql = true - ); - }, updateWithJoin() ); - } ); - - it( "can update with a join using aliases", function() { - testCase( function( builder ) { - return builder - .table( "employees e" ) - .join( "departments d", "d.id", "e.departmentId" ) - .update( values = { "departmentName": builder.raw( "d.name" ) }, toSql = true ); - }, updateWithJoinAndAliases() ); - } ); - - it( "can update with a join and a where", function() { - testCase( function( builder ) { - return builder - .table( "employees" ) - .join( "departments", "departments.id", "employees.departmentId" ) - .where( "departments.active", 1 ) - .update( - values = { "employees.departmentName": builder.raw( "departments.name" ) }, - toSql = true - ); - }, updateWithJoinAndWhere() ); - } ); - - it( "turns a function into a subselect", function() { - testCase( function( builder ) { - var subselect = function( qb ) { - qb.from( "departments" ) - .select( "name" ) - .whereColumn( "employees.departmentId", "departments.id" ); - }; - return builder - .table( "employees" ) - .update( values = { "departmentName": subselect }, toSql = true ); - }, updateWithSubselect() ); - } ); - - it( "turns a builder instance into a subselect", function() { - testCase( function( builder ) { - return builder - .table( "employees" ) - .update( - values = { - "departmentName": builder - .newQuery() - .from( "departments" ) - .select( "name" ) - .whereColumn( "employees.departmentId", "departments.id" ) - }, - toSql = true - ); - }, updateWithBuilder() ); - } ); - - it( "can update with returning", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .where( "id", 1 ) - .returning( "modifiedDate" ) - .update( values = { "email": "john@example.com" }, toSql = true ); - }, updateReturning() ); - } ); - - it( "can update with raw returning columns", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .where( "id", 1 ) - .returningRaw( [ "DELETED.modifiedDate AS oldModifiedDate", "INSERTED.modifiedDate AS newModifiedDate" ] ) - .update( values = { "email": "john@example.com" }, toSql = true ); - }, updateReturningRaw() ); - } ); - - it( "can update with returning and joins", function() { - testCase( function( builder ) { - return builder - .from( "zzz" ) - .returning( "xxx" ) - .join( "aaa", ( j ) => { - j.on( "aaa.ddd", "zzz.ddd" ) - } ) - .whereIn( "aaa.id", [ 1, 2, 3 ] ) - .update( values = { "zzz.user_id": 1, "zzz.created": "2025-01-01 00:00:00" }, toSQL = true ); - }, updateReturningWithJoin() ); - } ); - - it( "returning ignores table qualifiers in update statements", function() { - testCase( function( builder ) { - return builder - .setColumnFormatter( function( column ) { - return "tablePrefix." & column; - } ) - .from( "users" ) - .where( "id", 1 ) - .returning( "modifiedDate" ) - .update( values = { "email": "john@example.com" }, toSql = true ); - }, updateReturningIgnoresTableQualifiers() ); - } ); - } ); - - describe( "updateOrInsert statements", function() { - it( "inserts a new record when the where clause does not bring back any records", function() { - testCase( function( builder ) { - grammar.$( "runQuery", queryNew( "aggregate", "varchar", [ { "aggregate": 0 } ] ) ); - return builder - .from( "users" ) - .where( "email", "foo" ) - .updateOrInsert( values = { "name": "baz" }, toSql = true ); - }, updateOrInsertNotExists() ); - } ); - - it( "updates an existing record when the where clause brings back at least one record", function() { - testCase( function( builder ) { - grammar.$( "runQuery", queryNew( "aggregate", "varchar", [ { "aggregate": 1 } ] ) ); - return builder - .from( "users" ) - .where( "email", "foo" ) - .updateOrInsert( values = { "name": "baz" }, toSql = true ); - }, updateOrInsertExists() ); - } ); - } ); - - describe( "upsert statements", function() { - it( "does not include unrelated parent bindings in upserts", function() { - var builder = getBuilder().from( "users" ).where( "tenant_id", 42 ); - - var sql = builder.upsert( - values = { "email": "eric@example.com" }, - target = [ "email" ], - update = [ "email" ], - toSql = true - ); - - expect( getTestBindings( builder ) ).toBe( [ "eric@example.com" ] ); - expect( reMatch( "\?", sql ) ).toHaveLength( 1 ); - } ); - - it( "can perform an upsert", function() { - testCase( function( builder ) { - return builder - .table( "users" ) - .upsert( - values = { - "username": "foo", - "active": 1, - "createdDate": "2021-09-08 12:00:00", - "modifiedDate": "2021-09-08 12:00:00" - }, - target = [ "username" ], - update = [ "active", "modifiedDate" ], - toSql = true - ); - }, upsert() ); - } ); - - it( "updates all values if none are passed to update", function() { - testCase( function( builder ) { - return builder - .table( "users" ) - .upsert( - values = { - "username": "foo", - "active": 1, - "createdDate": "2021-09-08 12:00:00", - "modifiedDate": "2021-09-08 12:00:00" - }, - target = [ "username" ], - toSql = true - ); - }, upsertAllValues() ); - } ); - - it( "just performs an insert when given an empty struct or array to update", function() { - testCase( function( builder ) { - return builder - .table( "users" ) - .upsert( - values = { - "username": "foo", - "active": 1, - "createdDate": "2021-09-08 12:00:00", - "modifiedDate": "2021-09-08 12:00:00" - }, - target = [ "username" ], - update = [], - toSql = true - ); - }, upsertEmptyUpdate() ); - } ); - - it( "can specify specific update values", function() { - testCase( function( builder ) { - return builder - .table( "stats" ) - .upsert( - values = [ - { "postId": 1, "viewedDate": "2021-09-08", "views": 1 }, - { "postId": 2, "viewedDate": "2021-09-08", "views": 1 } - ], - target = [ "postId", "viewedDate" ], - update = { "views": builder.raw( "stats.views + 1" ) }, - toSql = true - ); - }, upsertWithInsertedValue() ); - } ); - - it( "can match the target as a single value", function() { - testCase( function( builder ) { - return builder - .table( "users" ) - .upsert( - values = { - "username": "foo", - "active": 1, - "createdDate": "2021-09-08 12:00:00", - "modifiedDate": "2021-09-08 12:00:00" - }, - target = "username", - update = [ "active", "modifiedDate" ], - toSql = true - ); - }, upsertSingleTarget() ); - } ); - - it( "can opt in to matching null target values", function() { - testCase( function( builder ) { - return builder - .table( "records" ) - .upsert( - values = [ - { "a": 1, "b": javacast( "null", "" ), "c": "first" }, - { "a": 2, "b": "value", "c": "second" } - ], - target = [ "a", "b" ], - update = [ "c" ], - matchNulls = true, - toSql = true - ); - }, upsertMatchNulls() ); - } ); - - it( "can perform an upsert with a closure as the source", function() { - testCase( function( builder ) { - return builder - .table( "users" ) - .upsert( - source = function( q ) { - q.from( "activeDirectoryUsers" ) - .select( [ - "username", - "active", - "createdDate", - "modifiedDate" - ] ) - .where( "active", 1 ); - }, - values = [ - "username", - "active", - "createdDate", - "modifiedDate" - ], - target = [ "username" ], - update = [ "active", "modifiedDate" ], - toSql = true - ); - }, upsertFromClosure() ); - } ); - - it( "can perform an upsert with a builder object as the source", function() { - testCase( function( builder ) { - return builder - .table( "users" ) - .upsert( - source = builder - .newQuery() - .from( "activeDirectoryUsers" ) - .select( [ - "username", - "active", - "createdDate", - "modifiedDate" - ] ) - .where( "active", 1 ), - values = [ - "username", - "active", - "createdDate", - "modifiedDate" - ], - target = [ "username" ], - update = [ "active", "modifiedDate" ], - toSql = true - ); - }, upsertFromBuilder() ); - } ); - - it( "can delete unmatched source rows in an upsert (SQL Server)", function() { - testCase( function( builder ) { - return builder - .table( "users" ) - .upsert( - source = function( q ) { - q.from( "activeDirectoryUsers" ) - .select( [ - "username", - "active", - "createdDate", - "modifiedDate" - ] ) - .where( "active", 1 ); - }, - values = [ - "username", - "active", - "createdDate", - "modifiedDate" - ], - target = [ "username" ], - update = [ "active", "modifiedDate" ], - deleteUnmatched = true, - toSql = true - ); - }, upsertWithDelete() ); - } ); - - it( "can delete unmatched source rows in an upsert with additional restrictions (SQL Server)", function() { - testCase( - callback = function( builder ) { - return builder - .table( "users" ) - .upsert( - source = function( q ) { - q.from( "activeDirectoryUsers" ) - .select( [ - "username", - "active", - "createdDate", - "modifiedDate" - ] ) - .where( "active", { value: 1, cfsqltype: "INTEGER" } ); - }, - values = [ - "username", - "active", - "createdDate", - "modifiedDate" - ], - target = [ "username" ], - update = [ "active", "modifiedDate" ], - deleteUnmatched = ( q ) => { - q.where( "active", { value: 0, cfsqltype: "INTEGER" } ); - }, - toSql = true - ); - }, - expected = upsertWithDeleteRestricted(), - withFullBindings = true - ); - } ); - - it( "can update fields to null", () => { - testCase( function( builder ) { - return builder - .table( "vendors" ) - .upsert( - target = [ "vendorCode", "code" ], - values = { - "vendorCode": "AA", - "code": "BB", - "name": javacast( "null", "" ), - "count": 1 - }, - update = { "count": builder.raw( "vendors.count + 1" ), "name": javacast( "null", "" ) }, - toSQL = true - ); - }, upsertUpdateToNull() ); - } ); - - it( "adds bindings for explicit update values", () => { - testCase( - callback = function( builder ) { - return builder - .table( "vendors" ) - .upsert( - target = [ "vendorCode", "code" ], - values = { - "vendorCode": "AA", - "code": "BB", - "name": "New Name", - "count": 1 - }, - update = { "count": builder.raw( "vendors.count + 1" ), "name": "New Name" }, - toSQL = true - ); - }, - expected = upsertUpdateWithExplicitValue() - ); - } ); - - it( "preserves bindings carried by upsert expressions", function() { - var builder = getBuilder(); - var sql = builder - .table( "scores" ) - .upsert( - values = { "id": 1, "score": builder.raw( "COALESCE(?, 0)", [ 2 ] ) }, - target = [ "id" ], - update = { "score": builder.raw( "? + 1", [ 3 ] ) }, - toSql = true - ); - - expect( reMatch( "\?", sql ) ).toHaveLength( 3 ); - expect( getTestBindings( builder ) ).toBe( [ 1, 2, 3 ] ); - } ); - } ); - - describe( "delete statements", function() { - it( "can delete an entire table", function() { - testCase( function( builder ) { - return builder.from( "users" ).delete( toSql = true ); - }, deleteAll() ); - } ); - - it( "can delete a specific id quickly", function() { - testCase( function( builder ) { - return builder.from( "users" ).delete( id = 1, toSql = true ); - }, deleteById() ); - } ); - - it( "can be constrained with a where statement", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .where( "email", "foo" ) - .delete( toSql = true ); - }, deleteWhere() ); - } ); - - it( "can delete with returning", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .where( "active", 0 ) - .returning( "id" ) - .delete( toSql = true ); - }, deleteReturning() ); - } ); - - it( "returning ignores table qualifiers in delete statements", function() { - testCase( function( builder ) { - return builder - .setColumnFormatter( function( column ) { - return "tablePrefix." & column; - } ) - .from( "users" ) - .where( "active", 0 ) - .returning( "id" ) - .delete( toSql = true ); - }, deleteReturningIgnoresTableQualifiers() ); - } ); - - it( "can handle delete statements with joins", function() { - testCase( function( builder ) { - return builder - .from( "users" ) - .join( "warnings", "users.id", "warnings.userId" ) - .delete( toSql = true ); - }, deleteWithJoins() ); - } ); - - it( "can handle delete statements with aliased joins", function() { - testCase( function( builder ) { - return builder - .from( "users u" ) - .join( "warnings w", "u.id", "w.userId" ) - .delete( toSql = true ); - }, deleteWithJoinsAndAliases() ); - } ); - } ); - } ); - } - - private function testCase( required function callback, required any expected, boolean withFullBindings = false ) { - try { - var builder = getBuilder(); - local.sql = callback( builder ); - if ( !isNull( local.sql ) ) { - if ( !isSimpleValue( local.sql ) ) { - local.sql = local.sql.toSQL(); - } - } else { - local.sql = builder.toSQL(); - } - if ( isSimpleValue( expected ) ) { - expected = { sql: expected, bindings: [] }; - } - - expect( local.sql ).toBeWithCase( expected.sql ); - var testBindings = getTestBindings( builder, arguments.withFullBindings ); - expect( testBindings ).toHaveLength( expected.bindings.len() ); - testBindings.each( ( testBinding, index ) => { - var expectedBinding = expected.bindings[ index ]; - if ( isStruct( expectedBinding ) ) { - expect( testBinding ).toBeStruct(); - for ( var key in expectedBinding ) { - expect( testBinding ).toHaveKey( key ); - expect( testBinding[ key ] ).toBe( expectedBinding[ key ] ); - } - } else { - expect( testBinding ).toBe( expectedBinding ); - } - } ); - } catch ( any e ) { - if ( !isSimpleValue( expected ) && structKeyExists( expected, "exception" ) ) { - if ( e.type != expected.exception ) { - debug( e ); - expect( e.type ).toBe( expected.exception ); - } - return; - } - rethrow; - } - } - - private function getBuilder() { - throw( "Must be implemented in a subclass" ); - } - - string function bulkTimestampSqlType() { - return "TIMESTAMP"; - } - - string function bulkExplicitSqlType() { - return "BIGINT"; - } - - private array function getTestBindings( required QueryBuilder builder, boolean withFullBindings = false ) { - return builder - .getBindings() - .map( function( binding ) { - if ( builder.getUtils().isExpression( binding ) ) { - return binding.getSQL(); - } else { - if ( binding.null ) { - return "NULL"; - } else { - return withFullBindings ? binding : binding.value; - } - } - } ); + super.run(); } } diff --git a/tests/resources/querybuilder/AbstractQueryBuilderAggregateSpec.cfc b/tests/resources/querybuilder/AbstractQueryBuilderAggregateSpec.cfc new file mode 100644 index 00000000..6795de5a --- /dev/null +++ b/tests/resources/querybuilder/AbstractQueryBuilderAggregateSpec.cfc @@ -0,0 +1,22 @@ +component extends="tests.resources.querybuilder.AbstractQueryBuilderUnionSpec" { + + function run() { + super.run(); + + describe( "query builder + grammar integration", function() { + describe( "select statements", function() { + describe( "aggregates", function() { + it( "exists", () => { + testCase( function( builder ) { + return builder + .from( "users" ) + .where( "id", 1 ) + .exists( toSql = true ) + }, aggregateExists() ); + } ); + } ); + } ); + } ); + } + +} diff --git a/tests/resources/querybuilder/AbstractQueryBuilderBaseSpec.cfc b/tests/resources/querybuilder/AbstractQueryBuilderBaseSpec.cfc new file mode 100644 index 00000000..7435b7cb --- /dev/null +++ b/tests/resources/querybuilder/AbstractQueryBuilderBaseSpec.cfc @@ -0,0 +1,76 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + } + + private function testCase( required function callback, required any expected, boolean withFullBindings = false ) { + try { + var builder = getBuilder(); + local.sql = callback( builder ); + if ( !isNull( local.sql ) ) { + if ( !isSimpleValue( local.sql ) ) { + local.sql = local.sql.toSQL(); + } + } else { + local.sql = builder.toSQL(); + } + if ( isSimpleValue( expected ) ) { + expected = { sql: expected, bindings: [] }; + } + + expect( local.sql ).toBeWithCase( expected.sql ); + var testBindings = getTestBindings( builder, arguments.withFullBindings ); + expect( testBindings ).toHaveLength( expected.bindings.len() ); + testBindings.each( ( testBinding, index ) => { + var expectedBinding = expected.bindings[ index ]; + if ( isStruct( expectedBinding ) ) { + expect( testBinding ).toBeStruct(); + for ( var key in expectedBinding ) { + expect( testBinding ).toHaveKey( key ); + expect( testBinding[ key ] ).toBe( expectedBinding[ key ] ); + } + } else { + expect( testBinding ).toBe( expectedBinding ); + } + } ); + } catch ( any e ) { + if ( !isSimpleValue( expected ) && structKeyExists( expected, "exception" ) ) { + if ( e.type != expected.exception ) { + debug( e ); + expect( e.type ).toBe( expected.exception ); + } + return; + } + rethrow; + } + } + + private function getBuilder() { + throw( "Must be implemented in a subclass" ); + } + + string function bulkTimestampSqlType() { + return "TIMESTAMP"; + } + + string function bulkExplicitSqlType() { + return "BIGINT"; + } + + private array function getTestBindings( required QueryBuilder builder, boolean withFullBindings = false ) { + return builder + .getBindings() + .map( function( binding ) { + if ( builder.getUtils().isExpression( binding ) ) { + return binding.getSQL(); + } else { + if ( binding.null ) { + return "NULL"; + } else { + return withFullBindings ? binding : binding.value; + } + } + } ); + } + +} diff --git a/tests/resources/querybuilder/AbstractQueryBuilderCteSpec.cfc b/tests/resources/querybuilder/AbstractQueryBuilderCteSpec.cfc new file mode 100644 index 00000000..73b4ac28 --- /dev/null +++ b/tests/resources/querybuilder/AbstractQueryBuilderCteSpec.cfc @@ -0,0 +1,167 @@ +component extends="tests.resources.querybuilder.AbstractQueryBuilderAggregateSpec" { + + function run() { + super.run(); + + describe( "query builder + grammar integration", function() { + describe( "select statements", function() { + describe( "common table expressions (i.e. CTEs)", function() { + it( "can create CTE from closure", function() { + testCase( function( builder ) { + builder + .with( "UsersCTE", function( q ) { + q.select( "*" ) + .from( "users" ) + .join( "contacts", "users.id", "contacts.id" ) + .where( "users.age", ">", 25 ) + ; + } ) + .from( "UsersCTE" ) + .whereNotIn( "user.id", [ 1, 2 ] ) + ; + }, commonTableExpression() ); + } ); + + it( "can create CTE from QueryBuilder instance", function() { + testCase( function( builder ) { + var cte = getBuilder() + .select( "*" ) + .from( "users" ) + .join( "contacts", "users.id", "contacts.id" ) + .where( "users.age", ">", 25 ) + ; + + builder + .with( "UsersCTE", cte ) + .from( "UsersCTE" ) + .whereNotIn( "user.id", [ 1, 2 ] ) + ; + }, commonTableExpression() ); + } ); + + it( "snapshots a builder passed to a common table expression", function() { + var cte = getBuilder().select( "id" ).from( "users" ); + var builder = getBuilder().with( "UsersCTE", cte ).from( "UsersCTE" ); + + cte.where( "active", 1 ); + + expect( builder.toSQL() ).notToInclude( "active" ); + expect( getTestBindings( builder ) ).toBe( [] ); + } ); + + it( "can correctly bind parameters regardless of order", function() { + testCase( function( builder ) { + builder + .from( "UsersCTE" ) + .whereNotIn( "user.id", [ 1, 2 ] ) + .with( "UsersCTE", function( q ) { + q.select( "*" ) + .from( "users" ) + .join( "contacts", "users.id", "contacts.id" ) + .where( "users.age", ">", 25 ) + ; + } ) + ; + }, commonTableExpression() ); + } ); + + it( "can create recursive CTE", function() { + testCase( function( builder ) { + builder + .withRecursive( "UsersCTE", function( q ) { + q.select( "*" ) + .from( "users" ) + .join( "contacts", "users.id", "contacts.id" ) + .where( "users.age", ">", 25 ) + ; + } ) + .from( "UsersCTE" ) + .whereNotIn( "user.id", [ 1, 2 ] ) + ; + }, commonTableExpressionWithRecursive() ); + } ); + + it( "properly handles recursive CTEs with included columns", function() { + testCase( function( builder ) { + builder + .withRecursive( + "UsersCTE", + function( q ) { + q.select( [ "users.id AS usersId", "contacts.id AS contactsId" ] ) + .from( "users" ) + .join( "contacts", "users.id", "contacts.id" ) + .where( "users.age", ">", 25 ) + ; + }, + [ "usersId", "contactsId" ] + ) + .from( "UsersCTE" ) + .whereNotIn( "user.id", [ 1, 2 ] ) + ; + }, commonTableExpressionWithRecursiveWithColumns() ); + } ); + + it( "can create multiple CTEs where the second CTE is not recursive", function() { + testCase( function( builder ) { + builder + .withRecursive( "UsersCTE", function( q ) { + q.select( "*" ) + .from( "users" ) + .join( "contacts", "users.id", "contacts.id" ) + .where( "users.age", ">", 25 ) + ; + } ) + .with( "OrderCTE", function( q ) { + q.from( "orders" ).where( "created", ">", "2018-04-30" ) + ; + } ) + .from( "UsersCTE" ) + .whereNotIn( "user.id", [ 1, 2 ] ) + ; + }, commonTableExpressionMultipleCTEsWithRecursive() ); + } ); + + it( "can create bindings in the correct order", function() { + testCase( function( builder ) { + builder + .from( "UsersCTE" ) + .whereNotIn( "user.id", [ 1, 2 ] ) + .with( "OrderCTE", function( q ) { + q.from( "orders" ).where( "created", ">", "2018-04-30" ) + ; + } ) + .withRecursive( "UsersCTE", function( q ) { + q.select( "*" ) + .from( "users" ) + .join( "contacts", "users.id", "contacts.id" ) + .where( "users.age", ">", 25 ) + ; + } ) + ; + }, commonTableExpressionBindingOrder() ); + } ); + + it( "can insert based off of a cte", function() { + testCase( function( builder ) { + return builder + .with( "UsersCTE", function( q ) { + q.select( "*" ) + .from( "users" ) + .where( "users.age", ">", 25 ) + ; + } ) + .table( "oldUsers" ) + .insertUsing( + source = function( qb ) { + qb.from( "UsersCTE" ).select( [ "fname", "lname", "username", "age" ] ); + }, + toSQL = true + ); + }, cteInsertUsing() ); + } ); + } ); + } ); + } ); + } + +} diff --git a/tests/resources/querybuilder/AbstractQueryBuilderDeleteSpec.cfc b/tests/resources/querybuilder/AbstractQueryBuilderDeleteSpec.cfc new file mode 100644 index 00000000..98291fd3 --- /dev/null +++ b/tests/resources/querybuilder/AbstractQueryBuilderDeleteSpec.cfc @@ -0,0 +1,73 @@ +component extends="tests.resources.querybuilder.AbstractQueryBuilderUpdateSpec" { + + function run() { + super.run(); + + describe( "query builder + grammar integration", function() { + describe( "delete statements", function() { + it( "can delete an entire table", function() { + testCase( function( builder ) { + return builder.from( "users" ).delete( toSql = true ); + }, deleteAll() ); + } ); + + it( "can delete a specific id quickly", function() { + testCase( function( builder ) { + return builder.from( "users" ).delete( id = 1, toSql = true ); + }, deleteById() ); + } ); + + it( "can be constrained with a where statement", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .where( "email", "foo" ) + .delete( toSql = true ); + }, deleteWhere() ); + } ); + + it( "can delete with returning", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .where( "active", 0 ) + .returning( "id" ) + .delete( toSql = true ); + }, deleteReturning() ); + } ); + + it( "returning ignores table qualifiers in delete statements", function() { + testCase( function( builder ) { + return builder + .setColumnFormatter( function( column ) { + return "tablePrefix." & column; + } ) + .from( "users" ) + .where( "active", 0 ) + .returning( "id" ) + .delete( toSql = true ); + }, deleteReturningIgnoresTableQualifiers() ); + } ); + + it( "can handle delete statements with joins", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .join( "warnings", "users.id", "warnings.userId" ) + .delete( toSql = true ); + }, deleteWithJoins() ); + } ); + + it( "can handle delete statements with aliased joins", function() { + testCase( function( builder ) { + return builder + .from( "users u" ) + .join( "warnings w", "u.id", "w.userId" ) + .delete( toSql = true ); + }, deleteWithJoinsAndAliases() ); + } ); + } ); + } ); + } + +} diff --git a/tests/resources/querybuilder/AbstractQueryBuilderGroupingSpec.cfc b/tests/resources/querybuilder/AbstractQueryBuilderGroupingSpec.cfc new file mode 100644 index 00000000..78a77cfa --- /dev/null +++ b/tests/resources/querybuilder/AbstractQueryBuilderGroupingSpec.cfc @@ -0,0 +1,487 @@ +component extends="tests.resources.querybuilder.AbstractQueryBuilderJoinSpec" { + + function run() { + super.run(); + + describe( "query builder + grammar integration", function() { + describe( "select statements", function() { + describe( "group bys", function() { + it( "can add a simple group by", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .groupBy( "email" ); + }, groupBy() ); + } ); + + it( "can group by multiple fields using an array", function() { + testCase( function( builder ) { + builder.from( "users" ).groupBy( [ "id", "email" ] ); + }, groupByArray() ); + } ); + + it( "can group by multiple fields using raw sql", function() { + testCase( function( builder ) { + builder.from( "users" ).groupBy( builder.raw( "DATE(created_at)" ) ); + }, groupByRaw() ); + } ); + } ); + + describe( "havings", function() { + it( "can add a basic having clause", function() { + testCase( function( builder ) { + builder.from( "users" ).having( "email", ">", 1 ); + }, havingBasic() ); + } ); + + it( "can add a having clause with a raw column", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .groupBy( "email" ) + .having( builder.raw( "COUNT(email)" ), ">", 1 ); + }, havingRawColumn() ); + } ); + + it( "can use a raw expression as the entire having clause", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .groupBy( "email" ) + .having( builder.raw( "COUNT(email) > ?", [ 1 ] ) ); + }, havingRawExpression() ); + } ); + + it( "can use a havingRaw shortcut method", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .groupBy( "email" ) + .havingRaw( "COUNT(email) > ?", [ 1 ] ); + }, havingRawExpression() ); + } ); + + it( "can add a having clause with a raw column that contains bindings", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .groupBy( "email" ) + .having( + builder.raw( "CASE WHEN active = ? THEN COUNT(email) ELSE 0 END", [ 1 ] ), + ">", + 2 + ); + }, havingRawColumnWithBindings() ); + } ); + + it( "can add a having clause with a raw value", function() { + testCase( function( builder ) { + builder + .select( builder.raw( "COUNT(*) AS ""total""" ) ) + .from( "items" ) + .where( "department", "=", "popular" ) + .groupBy( "category" ) + .having( "total", ">", builder.raw( 3 ) ); + }, havingRawValue() ); + } ); + + it( "correctly orders bindings with having and raw statements and whereIn", function() { + testCase( function( builder ) { + builder + .from( "holdings h" ) + .join( "accounts a", "h.account_id", "a.account_id" ) + .where( "shares", "<>", "-999" ) + .andWhere( "investment_type", "taxable" ) + .andWhereNotLike( "security_id", "*%" ) + .select( "h.account_id, security_id" ) + .groupBy( "h.account_id, security_id" ) + .having( builder.raw( "COUNT(security_id)" ), ">", 1 ) + .orderBy( "h.account_id, security_id" ) + .when( true, ( q ) => { + q.whereIn( "h.account_id", ( q ) => { + q.select( "portfolioCode" ) + .from( "accounts" ) + .whereIn( "id", [ 662 ] ); + } ) + } ); + }, havingRawWhereIn() ); + } ); + } ); + + describe( "order bys", function() { + it( "can add a simple order by", function() { + testCase( function( builder ) { + builder.from( "users" ).orderBy( "email" ); + }, orderBy() ); + } ); + + it( "can order by random", function() { + testCase( function( builder ) { + builder.from( "users" ).orderByRandom(); + }, orderByRandom() ); + } ); + + it( "can add a simple order by using the asc shortcut method", function() { + testCase( function( builder ) { + builder.from( "users" ).orderByAsc( "email" ); + }, orderBy() ); + } ); + + it( "can order in descending order", function() { + testCase( function( builder ) { + builder.from( "users" ).orderBy( "email", "desc" ); + }, orderByDesc() ); + } ); + + it( "can order in descending order using the desc shortcut method", function() { + testCase( function( builder ) { + builder.from( "users" ).orderByDesc( "email" ); + }, orderByDesc() ); + } ); + + it( "combines all order by calls", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( "id" ) + .orderBy( "email", "desc" ); + }, combinesOrderBy() ); + } ); + + it( "can order by a raw expression", function() { + testCase( function( builder ) { + builder.from( "users" ).orderBy( builder.raw( "DATE(created_at)" ) ); + }, orderByRaw() ); + } ); + + it( "has an orderByRaw shortcut method", function() { + testCase( function( builder ) { + builder.from( "users" ).orderByRaw( "DATE(created_at)" ); + }, orderByRaw() ); + } ); + + it( "can accept bindings in orderByRaw", function() { + testCase( function( builder ) { + builder.from( "users" ).orderByRaw( "CASE WHEN id = ? THEN 1 ELSE 0 END DESC", [ 1 ] ); + }, orderByRawWithBindings() ); + } ); + + it( "can accept bindings in a raw expression in orderBy", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( builder.raw( "CASE WHEN id = ? THEN 1 ELSE 0 END DESC", [ 1 ] ) ); + }, orderByWithRawBindings() ); + } ); + + it( "can order by a subselect", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( function( q ) { + q.selectRaw( "MAX(created_date)" ) + .from( "logins" ) + .whereColumn( "users.id", "logins.user_id" ); + } ); + }, orderBySubselect() ); + } ); + + it( "can order by a subselect using the asc shortcut method", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderByAsc( function( q ) { + q.selectRaw( "MAX(created_date)" ) + .from( "logins" ) + .whereColumn( "users.id", "logins.user_id" ); + } ); + }, orderBySubselect() ); + } ); + + it( "can order by a subselect descending", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( function( q ) { + q.selectRaw( "MAX(created_date)" ) + .from( "logins" ) + .whereColumn( "users.id", "logins.user_id" ); + }, "desc" ); + }, orderBySubselectDescending() ); + } ); + + it( "can order by a subselect descending using the desc shortcut method", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderByDesc( function( q ) { + q.selectRaw( "MAX(created_date)" ) + .from( "logins" ) + .whereColumn( "users.id", "logins.user_id" ); + } ); + }, orderBySubselectDescending() ); + } ); + + it( "can order by a builder instance", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( + builder + .newQuery() + .selectRaw( "MAX(created_date)" ) + .from( "logins" ) + .whereColumn( "users.id", "logins.user_id" ) + ); + }, orderByBuilderInstance() ); + } ); + + it( "can order by a builder instance using the asc shortcut method", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderByAsc( + builder + .newQuery() + .selectRaw( "MAX(created_date)" ) + .from( "logins" ) + .whereColumn( "users.id", "logins.user_id" ) + ); + }, orderByBuilderInstance() ); + } ); + + it( "can order by a builder instance descending", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( + builder + .newQuery() + .selectRaw( "MAX(created_date)" ) + .from( "logins" ) + .whereColumn( "users.id", "logins.user_id" ), + "desc" + ); + }, orderByBuilderInstanceDescending() ); + } ); + + it( "can order by a builder instance descending using the desc shortcut method", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderByDesc( + builder + .newQuery() + .selectRaw( "MAX(created_date)" ) + .from( "logins" ) + .whereColumn( "users.id", "logins.user_id" ) + ); + }, orderByBuilderInstanceDescending() ); + } ); + + it( "can order by a builder instance with bindings", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( + builder + .newQuery() + .selectRaw( "MAX(created_date)" ) + .from( "logins" ) + .whereColumn( "users.id", "logins.user_id" ) + .where( "created_date", ">", "2020-01-01 00:00:00" ) + ); + }, orderByBuilderWithBindings() ); + } ); + + describe( "can accept an array for the column argument", function() { + it( "rejects invalid default directions", function() { + expect( function() { + getBuilder().from( "users" ).orderBy( "email", "DESC; DROP TABLE users" ); + } ).toThrow( type = "InvalidSQLType", regex = "Illegal order direction" ); + expect( function() { + getBuilder().orderBySub( ( query ) => query.selectRaw( "1" ), "DESC; DROP TABLE users" ); + } ).toThrow( type = "InvalidSQLType", regex = "Illegal order direction" ); + } ); + + describe( "with the array values", function() { + it( "as simple strings", function() { + testCase( function( builder ) { + builder.from( "users" ).orderBy( [ "last_name", "age", "favorite_color" ] ); + }, orderByArray() ); + } ); + + it( "can clear already configured orders", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( [ "last_name", "age", "favorite_color" ] ) + .clearOrders(); + }, orderByClearOrders() ); + } ); + + it( "can reorder a query", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( [ "last_name", "favorite_color" ] ) + .reorder( "age" ); + }, reorder() ); + } ); + + it( "as pipe delimited strings", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( [ "last_name|desc", "age|asc", "favorite_color|desc" ] ); + }, orderByPipeDelimited() ); + } ); + + it( "as a nested positional array", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( [ [ "last_name", "desc" ], [ "age", "asc" ], [ "favorite_color" ] ] ); + }, orderByArrayOfArrays() ); + } ); + + it( "as a nested positional array with leniency for arrays of length 1 or longer than 2 which assumes position 1 is column name and position 2 is the direction and ignores other entries in the nested array", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( [ + [ "last_name", "desc" ], + [ "age", "asc" ], + [ "favorite_color" ], + [ + "height", + "asc", + "will", + "be", + "ignored" + ] + ] ); + }, orderByArrayOfArraysIgnoringExtraValues() ); + } ); + + it( "as a any combo of values and ignores then inherits the direction's argument value if an invalid direction is supplied (anything other than (asc|desc)", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( [ + [ "last_name", "desc" ], + [ "age", "forward" ], + "favorite_color|backward", + "favorite_food|desc", + { column: "height", direction: "tallest" }, + { column: "weight", direction: "desc" }, + builder.raw( "DATE(created_at)" ), + { column: builder.raw( "DATE(modified_at)" ), direction: "desc" } // desc will be ignored in this case because it's an expression + ] ); + }, orderByComplex() ); + } ); + + it( "as raw expressions", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( [ + builder.raw( "DATE(created_at)" ), + { column: builder.raw( "DATE(modified_at)" ) } + ] ); + }, orderByRawInStruct() ); + } ); + + it( "as simple strings OR pipe delimited strings intermingled", function() { + testCase( function( builder ) { + builder.from( "users" ).orderBy( [ "last_name", "age|desc", "favorite_color" ] ); + }, orderByMixSimpleAndPipeDelimited() ); + } ); + + it( "can accept a struct with a column key and optionally the direction key", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( + [ + { column: "last_name" }, + { column: "age", direction: "asc" }, + { column: "favorite_color", direction: "desc" } + ], + "desc" + ); + }, orderByStruct() ); + } ); + + it( "as values that when additional orderBy() calls are chained the chained calls preserve the order of the calls", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( "last_name,age desc" ) + .orderBy( "favorite_color desc" ) + .orderBy( + column = [ { column: "height" }, { column: "weight", direction: "asc" } ], + direction = "desc" + ) + .orderBy( column = "eye_color", direction = "desc" ) + .orderBy( [ + { column: "is_athletic", direction: "desc", extraKey: "ignored" }, + builder.raw( "DATE(created_at)" ) + ] ) + .orderBy( builder.raw( "DATE(modified_at)" ) ); + }, multipleOrderByCalls() ); + } ); + + it( "as any combo of any valid values intermingled", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( [ + "last_name", + "age|desc", + [ "eye_color", "desc" ], + [ "hair_color" ], + { column: "is_musical" }, + { column: "is_athletic", direction: "desc", extraKey: "ignored" }, + builder.raw( "DATE(created_at)" ), + { column: builder.raw( "DATE(modified_at)" ), direction: "desc" } // direction is ignored because it should be RAW + ] ); + }, orderByMixed() ); + } ); + } ); + } ); + + describe( "can accept a comma delimited list for the column argument", function() { + describe( "with the list values", function() { + it( "as simple column names that inherit the default direction", function() { + testCase( function( builder ) { + builder.from( "users" ).orderBy( "last_name,age,favorite_color" ); + }, orderByList() ); + } ); + + it( "as simple column names while inheriting the direction argument's supplied value", function() { + testCase( function( builder ) { + builder.from( "users" ).orderBy( "last_name,age,favorite_color", "desc" ); + }, orderByListDefaultDirection() ); + } ); + + it( "as column names with secondary piped delimited value representing the direction for each column", function() { + testCase( function( builder ) { + builder.from( "users" ).orderBy( "last_name|desc,age|desc,favorite_color|asc" ); + }, orderByListPipeDelimited() ); + } ); + + it( "as column names with optional secondary piped delimited value representing the direction for that column and inherits the direction argument's value when supplied", function() { + testCase( function( builder ) { + builder.from( "users" ).orderBy( "last_name|asc,age,favorite_color|asc", "desc" ); + }, orderByListPipeDelimitedWithDefaultDirection() ); + } ); + } ); + } ); + } ); + } ); + } ); + } + +} diff --git a/tests/resources/querybuilder/AbstractQueryBuilderInsertSpec.cfc b/tests/resources/querybuilder/AbstractQueryBuilderInsertSpec.cfc new file mode 100644 index 00000000..fa65b9e0 --- /dev/null +++ b/tests/resources/querybuilder/AbstractQueryBuilderInsertSpec.cfc @@ -0,0 +1,226 @@ +component extends="tests.resources.querybuilder.AbstractQueryBuilderJsonSpec" { + + function run() { + super.run(); + + describe( "query builder + grammar integration", function() { + describe( "insert statements", function() { + it( "can insert a struct of data into a table", function() { + testCase( function( builder ) { + return builder.from( "users" ).insert( values = { "email": "foo" }, toSql = true ); + }, insertSingleColumn() ); + } ); + + it( "correctly formats booleans during an insert", function() { + testCase( + callback = function( builder ) { + return builder.from( "users" ).insert( values = { "active": true }, toSql = true ); + }, + expected = insertBoolean(), + withFullBindings = true + ); + } ); + + it( "always uses passed in cfsqltypes if available", function() { + testCase( + callback = function( builder ) { + return builder + .from( "users" ) + .insert( + values = { "active": { "value": true, "cfsqltype": "BOOLEAN" } }, + toSql = true + ); + }, + expected = insertBooleanExplicitSqlType(), + withFullBindings = true + ); + } ); + + it( "can insert a struct of data with multiple columns into a table", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .insert( values = { "email": "foo", "name": "bar" }, toSql = true ); + }, insertMultipleColumns() ); + } ); + + it( "can batch insert multiple records", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .insert( + values = [ { "email": "foo", "name": "bar" }, { "email": "baz", "name": "bleh" } ], + toSql = true + ); + }, batchInsert() ); + } ); + + it( "can insert with returning", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .returning( "id" ) + .insert( values = { "email": "foo", "name": "bar" }, toSql = true ); + }, returning() ); + } ); + + it( "preserves commas inside returningRaw expressions", function() { + var builder = getBuilder().returningRaw( "'last,first' AS label" ); + + expect( builder.getReturning() ).toHaveLength( 1 ); + expect( builder.getReturning()[ 1 ].value.getSQL() ).toBe( "'last,first' AS label" ); + } ); + + it( "can return all from an insert", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .returningAll() + .insert( values = { "email": "foo", "name": "bar" }, toSql = true ); + }, returningAll() ); + } ); + + it( "returning ignores table qualifiers", function() { + testCase( function( builder ) { + return builder + .setColumnFormatter( function( column ) { + return "tablePrefix." & column; + } ) + .from( "users" ) + .returning( "id" ) + .insert( values = { "email": "foo", "name": "bar" }, toSql = true ); + }, returningIgnoresTableQualifiers() ); + } ); + + it( "can insert with raw values", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .insert( + values = { "email": "john@example.com", "created_date": builder.raw( "now()" ) }, + toSql = true + ); + }, insertWithRaw() ); + } ); + + it( "preserves bindings carried by insert expressions", function() { + var builder = getBuilder(); + var sql = builder + .from( "users" ) + .insert( + values = { + "first": builder.raw( "COALESCE(?, 0)", [ 10 ] ), + "second": 20, + "third": builder.raw( "COALESCE(?, ?)", [ 30, 40 ] ) + }, + toSql = true + ); + + expect( reMatch( "\?", sql ) ).toHaveLength( 4 ); + expect( getTestBindings( builder ) ).toBe( [ 10, 20, 30, 40 ] ); + } ); + + it( "can insert with null values", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .insert( + values = { "email": "john@example.com", "optional_field": javacast( "null", "" ) }, + toSql = true + ); + }, insertWithNull() ); + } ); + + it( "can insert using a select statement and a callback", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .insertUsing( + columns = [ "email", "createdDate" ], // purposefully not in alphabetical order + source = function( q ) { + q.from( "activeDirectoryUsers" ) + .select( [ "email", "createdDate" ] ) + .where( "active", 1 ); + }, + toSql = true + ); + }, insertUsingSelectCallback() ); + } ); + + it( "can insert using a select statement and a builder object", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .insertUsing( + columns = [ "email", "createdDate" ], // purposefully not in alphabetical order + source = builder + .newQuery() + .from( "activeDirectoryUsers" ) + .select( [ "email", "createdDate" ] ) + .where( "active", 1 ), + toSql = true + ); + }, insertUsingSelectBuilder() ); + } ); + + it( "does not include unrelated parent bindings in insert using statements", function() { + var builder = getBuilder().from( "users" ).where( "tenant_id", 42 ); + var source = builder + .newQuery() + .from( "activeDirectoryUsers" ) + .select( "email" ) + .where( "active", 1 ); + + var sql = builder.insertUsing( columns = [ "email" ], source = source, toSql = true ); + + expect( reMatch( "\?", sql ) ).toHaveLength( 1 ); + expect( getTestBindings( builder ) ).toBe( [ 1 ] ); + } ); + + it( "can derive the columns to insert from the source query", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .insertUsing( + source = function( q ) { + q.from( "activeDirectoryUsers" ) + .select( [ "email", "modifiedDate AS createdDate" ] ) + .where( "active", 1 ); + }, + toSql = true + ); + }, insertUsingDerivingColumnNames() ); + } ); + + it( "can guess column names from raw statements in an insert using query", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .insertUsing( + source = function( q ) { + q.from( "activeDirectoryUsers" ) + .select( "email" ) + .selectRaw( "COALESCE(modifiedDate, NOW()) AS createdDate" ) + .where( "active", 1 ); + }, + toSql = true + ); + }, insertUsingDerivedColumnNamesFromRawStatements() ); + } ); + + it( "can insert ignoring conflicts", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .insertIgnore( + values = [ { "email": "foo", "name": "bar" }, { "email": "baz", "name": "bleh" } ], + target = [ "email" ], + toSql = true + ); + }, insertIgnore() ); + } ); + } ); + } ); + } + +} diff --git a/tests/resources/querybuilder/AbstractQueryBuilderJoinSpec.cfc b/tests/resources/querybuilder/AbstractQueryBuilderJoinSpec.cfc new file mode 100644 index 00000000..e15f1302 --- /dev/null +++ b/tests/resources/querybuilder/AbstractQueryBuilderJoinSpec.cfc @@ -0,0 +1,664 @@ +component extends="tests.resources.querybuilder.AbstractQueryBuilderWhereSpec" { + + function run() { + super.run(); + + describe( "query builder + grammar integration", function() { + describe( "select statements", function() { + describe( "joins", function() { + it( "can inner join", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .join( + "contacts", + "users.id", + "=", + "contacts.id" + ); + }, innerJoin() ); + } ); + + it( "can inner join on table as expression", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .join( + builder.raw( "contacts (nolock)" ), + "users.id", + "=", + "contacts.id" + ); + }, innerJoinRaw() ); + } ); + + it( "can inner join on raw sql", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .joinRaw( + "contacts (nolock)", + "users.id", + "=", + "contacts.id" + ); + }, innerJoinRaw() ); + } ); + + it( "can inner join using the shorthand", function() { + testCase( function( builder ) { + builder.from( "users" ).join( "contacts", "users.id", "contacts.id" ); + }, innerJoinShorthand() ); + } ); + + it( "can specify multiple joins", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .join( "contacts", "users.id", "contacts.id" ) + .join( "addresses AS a", "a.contact_id", "contacts.id" ); + }, multipleJoins() ); + } ); + + it( "can join with where bindings instead of columns", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .joinWhere( + "contacts", + "contacts.balance", + "<", + 100 + ); + }, joinWithWhere() ); + } ); + + it( "can join with a callback", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .join( "contacts", function( j ) { + j.on( "users.id", "=", "contacts.id" ); + } ); + }, innerJoinCallback() ); + } ); + + it( "can join with a standalone join clause", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .join( builder.newJoin( "contacts" ).on( "users.id", "=", "contacts.id" ) ); + }, innerJoinWithJoinInstance() ); + } ); + + it( "can left join", function() { + testCase( function( builder ) { + builder.from( "users" ).leftJoin( "orders", "users.id", "orders.user_id" ); + }, leftJoin() ); + } ); + + it( "can left outer join", function() { + testCase( function( builder ) { + builder.from( "users" ).leftOuterJoin( "orders", "users.id", "orders.user_id" ); + }, leftOuterJoin() ); + } ); + + it( "can full join", function() { + testCase( function( builder ) { + builder.from( "users" ).fullJoin( "orders", "users.id", "orders.user_id" ); + }, fullJoin() ); + } ); + + it( "can full outer join", function() { + testCase( function( builder ) { + builder.from( "users" ).fullOuterJoin( "orders", "users.id", "orders.user_id" ); + }, fullOuterJoin() ); + } ); + + it( "can left join on table as expression", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .leftJoin( + builder.raw( "contacts (nolock)" ), + "users.id", + "=", + "contacts.id" + ); + }, leftJoinRaw() ); + } ); + + it( "can left join on raw sql", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .leftJoinRaw( + "contacts (nolock)", + "users.id", + "=", + "contacts.id" + ); + }, leftJoinRaw() ); + } ); + + it( "can left join using a nested query", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .leftJoin( "orders", function( j ) { + j.on( "users.id", "=", "orders.user_id" ); + } ); + }, leftJoinNested() ); + } ); + + it( "it can handle nested on queries without truncating text", function() { + testCase( function( builder ) { + builder + .from( "test" ) + .leftJoin( "last_team_tasks_queue_record", function( j ) { + j.on( + "last_team_tasks_queue_record.task_territory_id", + "team_tasks_queue.task_territory_id" + ); + j.where( function( q ) { + q.whereNull( "last_team_tasks_queue_record.when_created" ); + q.whereColumn( + "last_team_tasks_queue_record.when_created", + "<=", + "team_tasks_queue.when_created", + "OR" + ); + } ); + } ); + }, leftJoinTruncatingText() ); + } ); + + it( "can right join", function() { + testCase( function( builder ) { + builder.from( "orders" ).rightJoin( "users", "orders.user_id", "users.id" ); + }, rightJoin() ); + } ); + + it( "can right outer join", function() { + testCase( function( builder ) { + builder.from( "orders" ).rightOuterJoin( "users", "orders.user_id", "users.id" ); + }, rightOuterJoin() ); + } ); + + it( "can right join on table as expression", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .rightJoin( + builder.raw( "contacts (nolock)" ), + "users.id", + "=", + "contacts.id" + ); + }, rightJoinRaw() ); + } ); + + it( "can right join on raw sql", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .rightJoinRaw( + "contacts (nolock)", + "users.id", + "=", + "contacts.id" + ); + }, rightJoinRaw() ); + } ); + + it( "can cross join", function() { + testCase( function( builder ) { + builder.from( "sizes" ).crossJoin( "colors" ); + }, crossJoin() ); + } ); + + it( "can cross join on table as expression", function() { + testCase( function( builder ) { + builder.from( "users" ).crossJoin( builder.raw( "contacts (nolock)" ) ); + }, crossJoinRaw() ); + } ); + + it( "can cross join on raw sql", function() { + testCase( function( builder ) { + builder.from( "users" ).crossJoinRaw( "contacts (nolock)" ); + }, crossJoinRaw() ); + } ); + + it( "can accept a callback for complex joins", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .join( "contacts", function( j ) { + j.on( "users.id", "=", "contacts.id" ) + .orOn( "users.name", "=", "contacts.name" ) + .orWhere( "users.admin", 1 ); + } ); + }, complexJoin() ); + } ); + + it( "can specify where null in a join", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .join( "contacts", function( j ) { + j.on( "users.id", "=", "contacts.id" ).whereNull( "contacts.deleted_date" ); + } ); + }, joinWithWhereNull() ); + } ); + + it( "can specify or where null in a join", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .join( "contacts", function( j ) { + j.on( "users.id", "=", "contacts.id" ).orWhereNull( "contacts.deleted_date" ); + } ); + }, joinWithOrWhereNull() ); + } ); + + it( "can specify where not null in a join", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .join( "contacts", function( j ) { + j.on( "users.id", "=", "contacts.id" ).whereNotNull( "contacts.deleted_date" ); + } ); + }, joinWithWhereNotNull() ); + } ); + + it( "can specify or where not null in a join", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .join( "contacts", function( j ) { + j.on( "users.id", "=", "contacts.id" ).orWhereNotNull( "contacts.deleted_date" ); + } ); + }, joinWithOrWhereNotNull() ); + } ); + + it( "can specify where in inside a join", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .join( "contacts", function( j ) { + j.on( "users.id", "=", "contacts.id" ).whereIn( "contacts.id", [ 1, 2, 3 ] ); + } ); + }, joinWithWhereIn() ); + } ); + + it( "can specify or where in inside a join", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .join( "contacts", function( j ) { + j.on( "users.id", "=", "contacts.id" ).orWhereIn( "contacts.id", [ 1, 2, 3 ] ); + } ); + }, joinWithOrWhereIn() ); + } ); + + it( "can specify where not in inside a join", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .join( "contacts", function( j ) { + j.on( "users.id", "=", "contacts.id" ).whereNotIn( "contacts.id", [ 1, 2, 3 ] ); + } ); + }, joinWithWhereNotIn() ); + } ); + + it( "can specify or where not in inside a join", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .join( "contacts", function( j ) { + j.on( "users.id", "=", "contacts.id" ).orWhereNotIn( "contacts.id", [ 1, 2, 3 ] ); + } ); + }, joinWithOrWhereNotIn() ); + } ); + + it( "can inner join to a derived table with joinSub using a QueryBuilder object", function() { + testCase( function( builder ) { + var derivedTable = getBuilder() + .select( "id" ) + .from( "contacts" ) + .whereNotIn( "id", [ 1, 2, 3 ] ); + + builder + .from( "users as u" ) + .joinSub( + "c", + derivedTable, + "u.id", + "=", + "c.id" + ); + }, joinSub() ); + } ); + + it( "can inner join to a derived table with joinSub using a closure", function() { + testCase( function( builder ) { + builder + .from( "users as u" ) + .joinSub( + "c", + function( qb ) { + qb.select( "id" ) + .from( "contacts" ) + .whereNotIn( "id", [ 1, 2, 3 ] ); + }, + "u.id", + "=", + "c.id" + ); + }, joinSub() ); + } ); + + it( "can inner join to a derived table with joinSub using the shorthand", function() { + testCase( function( builder ) { + var derivedTable = getBuilder() + .select( "id" ) + .from( "contacts" ) + .whereNotIn( "id", [ 1, 2, 3 ] ); + + builder.from( "users as u" ).joinSub( "c", derivedTable, "u.id", "c.id" ); + }, joinSub() ); + } ); + + it( "can left join to a derived table with joinSub using a QueryBuilder object", function() { + testCase( function( builder ) { + var derivedTable = getBuilder() + .select( "id" ) + .from( "contacts" ) + .whereNotIn( "id", [ 1, 2, 3 ] ); + + builder + .from( "users as u" ) + .leftJoinSub( + "c", + derivedTable, + "u.id", + "=", + "c.id" + ); + }, leftJoinSub() ); + } ); + + it( "can left join to a derived table with joinSub using a closure", function() { + testCase( function( builder ) { + builder + .from( "users as u" ) + .leftJoinSub( + "c", + function( qb ) { + qb.select( "id" ) + .from( "contacts" ) + .whereNotIn( "id", [ 1, 2, 3 ] ); + }, + "u.id", + "=", + "c.id" + ); + }, leftJoinSub() ); + } ); + + it( "can left join to a derived table with joinSub using the shorthand", function() { + testCase( function( builder ) { + var derivedTable = getBuilder() + .select( "id" ) + .from( "contacts" ) + .whereNotIn( "id", [ 1, 2, 3 ] ); + + builder.from( "users as u" ).leftJoinSub( "c", derivedTable, "u.id", "c.id" ); + }, leftJoinSub() ); + } ); + + it( "can right join to a derived table with joinSub using a QueryBuilder object", function() { + testCase( function( builder ) { + var derivedTable = getBuilder() + .select( "id" ) + .from( "contacts" ) + .whereNotIn( "id", [ 1, 2, 3 ] ); + + builder + .from( "users as u" ) + .rightJoinSub( + "c", + derivedTable, + "u.id", + "=", + "c.id" + ); + }, rightJoinSub() ); + } ); + + it( "can right join to a derived table with joinSub using a closure", function() { + testCase( function( builder ) { + builder + .from( "users as u" ) + .rightJoinSub( + "c", + function( qb ) { + qb.select( "id" ) + .from( "contacts" ) + .whereNotIn( "id", [ 1, 2, 3 ] ); + }, + "u.id", + "=", + "c.id" + ); + }, rightJoinSub() ); + } ); + + it( "can right join to a derived table with joinSub using the shorthand", function() { + testCase( function( builder ) { + var derivedTable = getBuilder() + .select( "id" ) + .from( "contacts" ) + .whereNotIn( "id", [ 1, 2, 3 ] ); + + builder.from( "users as u" ).rightJoinSub( "c", derivedTable, "u.id", "c.id" ); + }, rightJoinSub() ); + } ); + + it( "can cross join to a derived table with joinSub using a QueryBuilder object", function() { + testCase( function( builder ) { + var derivedTable = getBuilder() + .select( "id" ) + .from( "contacts" ) + .whereNotIn( "id", [ 1, 2, 3 ] ); + + builder.from( "users as u" ).crossJoinSub( "c", derivedTable ); + }, crossJoinSub() ); + } ); + + it( "can cross join to a derived table with joinSub using a closure", function() { + testCase( function( builder ) { + builder + .from( "users as u" ) + .crossJoinSub( "c", function( qb ) { + qb.select( "id" ) + .from( "contacts" ) + .whereNotIn( "id", [ 1, 2, 3 ] ); + } ); + }, crossJoinSub() ); + } ); + + it( "correctly positions bindings using crossJoinSub", function() { + var builder = getBuilder(); + builder + .from( "A" ) + .where( "A.A", "=", "A" ) + .crossJoinSub( "B", function( query ) { + query.from( "B" ).where( "B.B", "=", "B" ); + } ) + .where( "A.C", "=", "C" ); + + expect( getTestBindings( builder ) ).toBe( [ "B", "A", "C" ] ); + } ); + + it( "does not retain bindings from prevented duplicate joinSub clauses", function() { + var builder = getBuilder().setPreventDuplicateJoins( true ); + var derivedTable = getBuilder().from( "contacts" ).where( "contacts.kind", "personal" ); + + builder + .from( "users AS u" ) + .joinSub( + "c", + derivedTable, + "u.id", + "=", + "c.user_id" + ) + .joinSub( + "c", + derivedTable, + "u.id", + "=", + "c.user_id" + ); + + expect( builder.getJoins() ).toHaveLength( 1 ); + expect( getTestBindings( builder ) ).toBe( [ "personal" ] ); + } ); + + it( "distinguishes joinSub clauses with the same SQL and different bindings", function() { + var builder = getBuilder().setPreventDuplicateJoins( true ); + var personalContacts = getBuilder().from( "contacts" ).where( "contacts.kind", "personal" ); + var businessContacts = getBuilder().from( "contacts" ).where( "contacts.kind", "business" ); + + builder + .from( "users AS u" ) + .joinSub( + "c", + personalContacts, + "u.id", + "=", + "c.user_id" + ) + .joinSub( + "c", + businessContacts, + "u.id", + "=", + "c.user_id" + ); + + expect( builder.getJoins() ).toHaveLength( 2 ); + expect( getTestBindings( builder ) ).toBe( [ "personal", "business" ] ); + } ); + + it( "correctly positions bindings using joinSub", function() { + testCase( function( builder ) { + builder + .from( "A" ) + .where( "A.A", "=", "A" ) + .joinSub( + "B", + ( qb ) => { + return qb.from( "B" ).where( "B.B", "=", "B" ); + }, + "A.A", + "=", + "B.B" + ) + .where( "A.C", "=", "C" ); + }, joinSubBindings() ); + } ); + + it( "can cross apply", function() { + testCase( function( builder ) { + builder + .from( "users as u" ) + .crossApply( "childCount", function( qb ) { + qb.selectRaw( "count(*) c" ) + .from( "children" ) + .whereColumn( "children.parentID", "=", "users.ID" ) + .where( "children.someCol", "=", 0 ) + } ) + .select( [ "u.ID", "childCount.c" ] ) + .where( "childCount.c", ">", 1 ) + }, crossApply() ); + } ); + + it( "can outer apply", function() { + testCase( function( builder ) { + builder + .from( "users as u" ) + .outerApply( "childCount", function( qb ) { + qb.selectRaw( "count(*) c" ) + .from( "children" ) + .whereColumn( "children.parentID", "=", "users.ID" ) + .where( "children.someCol", "=", 0 ) + } ) + .select( [ "u.ID", "childCount.c" ] ) + .where( "childCount.c", ">", 1 ) + }, outerApply() ); + } ); + + it( "correctly positions bindings using crossApply", function() { + testCase( function( builder ) { + builder + .from( "A" ) + .where( "A.A", "=", "A" ) + .crossApply( + "B", + getBuilder() + .from( "x" ) + .where( "x.x", "=", "B" ) + .whereColumn( "x.b", "=", "a.b" ) + ) + .where( "A.C", "=", "C" ) + .outerApply( "D", ( qb ) => { + qb.from( "y" ) + .where( "y.y", "=", "D" ) + .whereColumn( "y.d", "=", "a.d" ) + } ) + }, correctlyPositionsBindingsUsingCrossApply() ); + } ); + + it( "eliminates duplicate cross or outer applies", function() { + testCase( function( builder ) { + var gen = function( name ) { + return function( qb ) { + qb.from( name ).select( "someColumn" ); + }; + }; + builder + .setPreventDuplicateJoins( true ) + .from( "A" ) + .crossApply( "B", gen( "crossapply_B" ) ) + .outerApply( "C", gen( "outerapply_C" ) ) + .crossApply( "B", gen( "crossapply_B" ) ) + .outerApply( "C", gen( "outerapply_C" ) ) + .crossApply( "D", gen( "crossapply_D" ) ) + .outerApply( "E", gen( "outerapply_E" ) ) + .crossApply( "D", gen( "crossapply_D" ) ) + .outerApply( "E", gen( "outerapply_E" ) ) + }, duplicateCrossAndOuterAppliesEliminated() ); + } ); + + it( "can join with a callback that includes a whereExists clause", function() { + testCase( ( builder ) => { + builder + .from( "LeftTable AS lt" ) + .leftJoin( "RightTable AS rt", ( j ) => { + j.on( "rt.id", "lt.id" ) + .whereExists( ( qb ) => { + qb.selectRaw( 1 ) + .from( "ExistsTable AS et" ) + .whereColumn( "et.id", "lt.id" ); + } ); + } ); + }, joinCallbackWhereExists() ); + } ); + } ); + } ); + } ); + } + +} diff --git a/tests/resources/querybuilder/AbstractQueryBuilderJsonSpec.cfc b/tests/resources/querybuilder/AbstractQueryBuilderJsonSpec.cfc new file mode 100644 index 00000000..0c83ff3b --- /dev/null +++ b/tests/resources/querybuilder/AbstractQueryBuilderJsonSpec.cfc @@ -0,0 +1,142 @@ +component extends="tests.resources.querybuilder.AbstractQueryBuilderPaginationSpec" { + + function run() { + super.run(); + + describe( "query builder + grammar integration", function() { + describe( "JSON support", function() { + it( "selects scalar values with explicit and arrow syntax", function() { + testCase( function( builder ) { + return builder + .select( [ + builder.jsonPath( + column = "profile", + path = [ "contacts", 0, "email" ], + alias = "explicitName" + ), + "profile->contacts->0->email AS shortcutName" + ] ) + .from( "users" ); + }, jsonScalarSelect() ); + } ); + + it( "uses scalar values in predicates with explicit and arrow syntax", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .where( builder.jsonPath( column = "profile", path = [ "age" ] ), ">=", 21 ) + .where( "profile->age", "<", 65 ); + }, jsonScalarWhere() ); + } ); + + it( "checks containment with explicit and arrow syntax", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .whereJsonContains( column = "profile", path = [ "languages" ], value = "en" ) + .whereJsonContains( "profile->languages", "en" ); + }, jsonContains() ); + } ); + + it( "checks path existence with explicit and arrow syntax", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .whereJsonExists( column = "profile", path = [ "name" ] ) + .whereJsonExists( "profile->name" ); + }, jsonExists() ); + } ); + + it( "checks array length and orders scalar values with both syntaxes", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .whereJsonLength( + column = "profile", + path = [ "languages" ], + operator = ">", + value = 1 + ) + .whereJsonLength( "profile->languages", ">", 1 ) + .orderBy( builder.jsonPath( "profile", [ "name" ] ) ) + .orderByDesc( "profile->name" ); + }, jsonLengthAndOrder() ); + } ); + + it( "defaults JSON length comparisons to equality with both syntaxes", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .whereJsonLength( column = "profile", path = [ "languages" ], value = 1 ) + .whereJsonLength( "profile->languages", 1 ) + .orWhereJsonLength( column = "profile", path = [ "languages" ], value = 2 ) + .orWhereJsonLength( "profile->languages", 2 ); + }, jsonLengthEqualityShortcut() ); + } ); + + it( "supports compound containment values with both syntaxes", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .whereJsonContains( column = "profile", path = [ "languages" ], value = [ "en", "de" ] ) + .whereJsonContains( "profile->languages", [ "en", "de" ] ); + }, jsonCompoundContains() ); + } ); + + it( "preserves an empty array passed as a shortcut containment value", function() { + testCase( function( builder ) { + return builder.from( "users" ).whereJsonContains( "profile->languages", [] ); + }, jsonEmptyCompoundContains() ); + } ); + + it( + title = "preserves explicit paths when checking containment for JSON null", + body = function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .whereJsonContains( + column = "profile", + path = [ "languages" ], + value = javacast( "null", "" ) + ) + .whereJsonContains( "profile->languages", javacast( "null", "" ) ); + }, jsonNullContains() ); + }, + skip = function() { + var fullNull = createObject( "java", "java.lang.System" ).getEnv( "FULL_NULL" ); + return isNull( fullNull ) || !fullNull; + } + ); + + it( "distinguishes explicit numeric object keys from shortcut array indexes", function() { + testCase( function( builder ) { + return builder + .select( [ builder.jsonPath( "profile", [ "0" ], "explicitKey" ), "profile->0 AS shortcutIndex" ] ) + .from( "users" ); + }, jsonNumericObjectKey() ); + } ); + + it( "supports JSON boolean and negative convenience methods", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .whereJsonDoesntContain( column = "profile", path = [ "languages" ], value = "en" ) + .orWhereJsonDoesntContain( "profile->languages", "fr" ) + .orWhereJsonContains( column = "profile", path = [ "languages" ], value = "de" ) + .whereJsonDoesntExist( column = "profile", path = [ "nickname" ] ) + .orWhereJsonExists( "profile->name" ) + .orWhereJsonDoesntExist( "profile->timezone" ) + .orWhereJsonLength( + column = "profile", + path = [ "languages" ], + operator = ">", + value = 1 + ); + }, jsonConveniencePredicates() ); + } ); + } ); + } ); + } + +} diff --git a/tests/resources/querybuilder/AbstractQueryBuilderPaginationSpec.cfc b/tests/resources/querybuilder/AbstractQueryBuilderPaginationSpec.cfc new file mode 100644 index 00000000..6f7b927b --- /dev/null +++ b/tests/resources/querybuilder/AbstractQueryBuilderPaginationSpec.cfc @@ -0,0 +1,76 @@ +component extends="tests.resources.querybuilder.AbstractQueryBuilderCteSpec" { + + function run() { + super.run(); + + describe( "query builder + grammar integration", function() { + describe( "select statements", function() { + describe( "limits", function() { + it( "can limit the record set returned", function() { + testCase( function( builder ) { + builder.from( "users" ).limit( 3 ); + }, limit() ); + } ); + + it( "has an alias of ""take""", function() { + testCase( function( builder ) { + builder.from( "users" ).take( 1 ); + }, take() ); + } ); + } ); + + describe( "offsets", function() { + it( "can offset the record set returned", function() { + testCase( function( builder ) { + builder.from( "users" ).offset( 3 ); + }, this.offset() ); + } ); + + it( "can offset with an order by", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .orderBy( "id" ) + .offset( 3 ); + }, offsetWithOrderBy() ); + } ); + } ); + + describe( "forPage", function() { + it( "combines limits and offsets for easy pagination", function() { + testCase( function( builder ) { + builder.from( "users" ).forPage( 3, 15 ); + }, forPage() ); + } ); + + it( "returns zeros values less than zero", function() { + testCase( function( builder ) { + builder + .setShouldMaxRowsOverrideToAll( function() { + return false; + } ) + .from( "users" ) + .forPage( 0, -2 ); + }, forPageWithLessThanZeroValues() ); + } ); + } ); + + describe( "reset", function() { + it( "can reset the query to default values", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .where( "id", 1 ) + .where( "active", 1 ) + .orderByAsc( "createdDate" ) + .forPage( 3, 15 ) + .reset() + .from( "otherTable" ); + }, reset() ); + } ); + } ); + } ); + } ); + } + +} diff --git a/tests/resources/querybuilder/AbstractQueryBuilderSelectSpec.cfc b/tests/resources/querybuilder/AbstractQueryBuilderSelectSpec.cfc new file mode 100644 index 00000000..bcbd6c44 --- /dev/null +++ b/tests/resources/querybuilder/AbstractQueryBuilderSelectSpec.cfc @@ -0,0 +1,337 @@ +component extends="tests.resources.querybuilder.AbstractQueryBuilderBaseSpec" { + + function run() { + super.run(); + + describe( "query builder + grammar integration", function() { + describe( "select statements", function() { + describe( "basic selects", function() { + it( "can select all columns from a table", function() { + testCase( function( builder ) { + builder.select( "*" ).from( "users" ); + }, selectAllColumns() ); + } ); + + it( "can specify the column to select", function() { + testCase( function( builder ) { + builder.select( "name" ).from( "users" ); + }, selectSpecificColumn() ); + } ); + + it( "can select multiple columns using an array", function() { + testCase( function( builder ) { + builder.select( [ "name", builder.raw( "COUNT(*)" ) ] ).from( "users" ); + }, selectMultipleArray() ); + } ); + + it( "can add selects to a query", function() { + testCase( function( builder ) { + builder + .select( "foo" ) + .addSelect( "bar" ) + .addSelect( [ "baz", "boom" ] ) + .from( "users" ); + }, addSelect() ); + } ); + + it( "adding a select to a * query gets rid of the star", function() { + testCase( function( builder ) { + builder.addSelect( "foo" ).from( "users" ); + }, addSelectRemovesStar() ); + } ); + + it( "can select distinct records", function() { + testCase( function( builder ) { + builder + .distinct() + .select( [ "foo", "bar" ] ) + .from( "users" ); + }, selectDistinct() ); + } ); + + it( "can parse column aliases", function() { + testCase( function( builder ) { + builder.select( "foo as bar" ).from( "users" ); + }, parseColumnAlias() ); + } ); + + it( "does not change aliases when quoted", function() { + testCase( function( builder ) { + builder.select( "foo as ""bar""" ).from( "users" ); + }, parseColumnAliasWithQuotes() ); + } ); + + it( "can parse column aliases in where clauses", function() { + testCase( function( builder ) { + builder + .select( "users.foo" ) + .from( "users" ) + .where( "users.foo", "bar" ); + }, parseColumnAliasInWhere() ); + } ); + + it( "can parse column aliases in where clauses with subselects", function() { + testCase( function( builder ) { + builder + .from( "users u" ) + .select( "u.*, user_roles.roleid, roles.rolecode" ) + .join( "user_roles", "user_roles.userid", "u.userid" ) + .leftjoin( "roles", "user_roles.roleid", "roles.roleid" ) + .where( + "user_roles.roleid", + "=", + function( q ) { + q.select( "roleid" ) + .from( "roles" ) + .where( "rolecode", "SYSADMIN" ); + } + ); + }, parseColumnAliasInWhereSubselect() ); + } ); + + it( "can also parse column aliases in whereColumn clauses with subselects", function() { + testCase( function( builder ) { + builder + .from( "users u" ) + .select( "u.*, user_roles.roleid, roles.rolecode" ) + .join( "user_roles", "user_roles.userid", "u.userid" ) + .leftjoin( "roles", "user_roles.roleid", "roles.roleid" ) + .whereColumn( + "user_roles.roleid", + "=", + function( q ) { + q.select( "roleid" ) + .from( "roles" ) + .where( "rolecode", "SYSADMIN" ); + } + ); + }, parseColumnAliasInWhereSubselect() ); + } ); + + it( "wraps columns and aliases correctly", function() { + testCase( function( builder ) { + builder.select( "x.y as foo.bar" ).from( "public.users" ); + }, wrapColumnsAndAliases() ); + } ); + + it( "handles dynamic whereColumns", function() { + testCase( function( builder ) { + builder + .select( "ID" ) + .from( "users" ) + .whereID( 1 ); + }, dynamicWhere() ); + } ); + + it( "parses operators in dynamic whereColumns", function() { + testCase( function( builder ) { + builder + .select( "ID" ) + .from( "users" ) + .whereID( ">", 1 ); + }, parseOperatorsWithDynamicWhere() ); + } ); + + it( "parses operators in dynamic andWhereColumns", function() { + testCase( function( builder ) { + builder + .select( "ID" ) + .from( "users" ) + .whereID( ">", 1 ) + .andWhereID( "<", 10 ); + }, parseOperatorsWithDynamicAndWhere() ); + } ); + + it( "parses operators in dynamic orWhereColumns", function() { + testCase( function( builder ) { + builder + .select( "ID" ) + .from( "users" ) + .whereID( ">", 1 ) + .orWhereID( "<", 0 ); + }, parseOperatorsWithDynamicOrWhere() ); + } ); + + it( "selects raw values correctly", function() { + testCase( function( builder ) { + builder.select( builder.raw( "substr( foo, 6 )" ) ).from( "users" ); + }, selectWithRaw() ); + } ); + + it( "can easily select raw values with `selectRaw`", function() { + testCase( function( builder ) { + builder.selectRaw( "substr( foo, 6 )" ).from( "users" ); + }, selectRaw() ); + } ); + + it( "can select multiple raw values with `selectRaw` when passing in an array", function() { + testCase( function( builder ) { + builder.from( "users" ).selectRaw( [ "substr( foo, 6 )", "trim( bar )" ] ); + }, selectRawArray() ); + } ); + + it( "preserves bindings carried by expressions across select clauses", function() { + var builder = getBuilder(); + builder + .select( builder.raw( "? AS selectedValue", [ 1 ] ) ) + .from( builder.raw( "(SELECT ? AS id) source", [ 2 ] ) ) + .where( "id", ">", builder.raw( "?", [ 3 ] ) ) + .whereIn( "id", [ 4, builder.raw( "?", [ 5 ] ) ] ) + .groupBy( builder.raw( "?", [ 6 ] ) ) + .having( "id", ">", builder.raw( "?", [ 7 ] ) ) + .orderBy( { column: builder.raw( "?", [ 8 ] ) } ); + + expect( reMatch( "\?", builder.toSQL() ) ).toHaveLength( 8 ); + expect( getTestBindings( builder ) ).toBe( [ 1, 2, 3, 4, 5, 6, 7, 8 ] ); + } ); + + it( "preserves bindings carried by expression columns across predicates", function() { + var builder = getBuilder(); + builder + .from( "users" ) + .whereIn( builder.raw( "COALESCE(?, id)", [ 1 ] ), [ 2 ] ) + .whereNull( builder.raw( "NULLIF(?, id)", [ 3 ] ) ) + .whereBetween( builder.raw( "COALESCE(?, id)", [ 4 ] ), 5, 6 ) + .whereColumn( + builder.raw( "COALESCE(?, id)", [ 7 ] ), + "=", + builder.raw( "COALESCE(?, other_id)", [ 8 ] ) + ) + .where( + builder.raw( "COALESCE(?, id)", [ 9 ] ), + "=", + function( query ) { + query + .select( "id" ) + .from( "accounts" ) + .where( "active", 10 ); + } + ) + .whereIn( builder.raw( "COALESCE(?, id)", [ 11 ] ), function( query ) { + query + .select( "id" ) + .from( "accounts" ) + .where( "active", 12 ); + } ); + + expect( reMatch( "\?", builder.toSQL() ) ).toHaveLength( 12 ); + expect( getTestBindings( builder ) ).toBe( [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] ); + } ); + + it( "preserves bindings carried by expression columns in bulk predicates", function() { + var builder = getBuilder(); + builder.from( "users" ).whereInBulk( builder.raw( "COALESCE(?, id)", [ 1 ] ), [ 2, 3 ] ); + + expect( getTestBindings( builder )[ 1 ] ).toBe( 1 ); + expect( deserializeJSON( getTestBindings( builder )[ 2 ] ) ).toBe( [ 2, 3 ] ); + } ); + + it( "preserves bindings carried by expression join tables", function() { + var builder = getBuilder(); + builder + .from( "users" ) + .join( + builder.raw( "(SELECT ? AS id) joined", [ 1 ] ), + "joined.id", + "=", + "users.id" + ) + .crossJoin( builder.raw( "(SELECT ? AS id) crossed", [ 2 ] ) ); + + expect( reMatch( "\?", builder.toSQL() ) ).toHaveLength( 2 ); + expect( getTestBindings( builder ) ).toBe( [ 1, 2 ] ); + expect( getTestBindings( builder.clone() ) ).toBe( [ 1, 2 ] ); + } ); + + it( "provides a grammar-specific helper for concat", function() { + testCase( function( builder ) { + builder.select( builder.concat( "my_alias", "a,b,c,d" ) ).from( "users" ); + }, selectConcat() ); + } ); + + it( "concat can accept an array of values", function() { + testCase( function( builder ) { + // I kid you not, ACF2018 wouldn't let me pass `[ "a", "b", "c", "d" ]` + var items = []; + items + .append( "a" ) + .append( "b" ) + .append( "c" ) + .append( "d" ); + + builder.select( builder.concat( "my_alias", items ) ).from( "users" ); + }, selectConcatArray() ); + } ); + + it( "can clear the selected columns for a query", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .select( [ "foo", "bar" ] ) + .clearSelect(); + }, clearSelect() ); + } ); + + it( "can reselect the columns for a query", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .select( [ "foo", "bar" ] ) + .reselect( "baz" ); + }, reselect() ); + } ); + + it( "can reselect the columns for a query with raw expressions", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .select( [ "foo", "bar" ] ) + .reselectRaw( [ "substr( foo, 6 )", "trim( bar )" ] ); + }, reselectRaw() ); + } ); + + describe( "wrapping values", function() { + it( "wraps values by default", () => { + testCase( function( builder ) { + builder.from( "users" ).select( [ "foo", "bar" ] ); + }, wrappingDefault() ); + } ); + + it( "can configure the grammar to not wrap values by default", () => { + testCase( function( builder ) { + builder.getGrammar().setShouldWrapValues( false ); + + builder.from( "users" ).select( [ "foo", "bar" ] ); + }, wrappingGrammarOff() ); + } ); + + it( "can configure the query builder to not wrap values by default", () => { + testCase( function( builder ) { + builder.getGrammar().setShouldWrapValues( true ); + + builder + .withoutWrappingValues() + .from( "users" ) + .select( [ "foo", "bar" ] ); + }, wrappingBuilderOverride() ); + } ); + } ); + } ); + } ); + } ); + } + +} diff --git a/tests/resources/querybuilder/AbstractQueryBuilderSourceSpec.cfc b/tests/resources/querybuilder/AbstractQueryBuilderSourceSpec.cfc new file mode 100644 index 00000000..451db0a3 --- /dev/null +++ b/tests/resources/querybuilder/AbstractQueryBuilderSourceSpec.cfc @@ -0,0 +1,203 @@ +component extends="tests.resources.querybuilder.AbstractQueryBuilderSubselectSpec" { + + function run() { + super.run(); + + describe( "query builder + grammar integration", function() { + describe( "select statements", function() { + describe( "from", function() { + it( "can specify the table to select from", function() { + testCase( function( builder ) { + builder.from( "users" ); + }, from() ); + } ); + + it( "can specify a Expression object as the input for from", function() { + testCase( function( builder ) { + builder.from( builder.raw( "Test (nolock)" ) ); + }, fromRaw() ); + } ); + + it( "can use `table` as an alias for from", function() { + testCase( function( builder ) { + builder.table( "users" ); + }, table() ); + } ); + + it( "can specify a Expression object as the input for table", function() { + testCase( function( builder ) { + builder.table( builder.raw( "Test (nolock)" ) ); + }, fromRaw() ); + } ); + + it( "can specify the table to select from as a string using fromRaw", function() { + testCase( function( builder ) { + builder.fromRaw( "Test (nolock)" ); + }, fromRaw() ); + } ); + + it( "can add bindings to fromRaw", function() { + testCase( function( builder ) { + builder.fromRaw( "Test (nolock)", [ 1, 2, 3 ] ); + }, { sql: fromRaw(), bindings: [ 1, 2, 3 ] } ); + } ); + + it( "can specify the table using fromSub as QueryBuilder", function() { + testCase( function( builder ) { + var derivedTable = getBuilder() + .select( [ "id", "name" ] ) + .from( "users" ) + .where( "age", ">=", "21" ); + + builder.fromSub( "u", derivedTable ); + }, fromDerivedTable() ); + } ); + + it( "can specify the table using fromSub as a closure", function() { + testCase( function( builder ) { + builder.fromSub( "u", function( q ) { + q.select( [ "id", "name" ] ) + .from( "users" ) + .where( "age", ">=", "21" ); + } ); + }, fromDerivedTable() ); + } ); + + it( "correctly positions bindings using fromSub", function() { + testCase( function( builder ) { + builder + .select( "accounts.id" ) + .fromSub( "u", function( q ) { + q.select( [ "id", "name" ] ) + .from( "users" ) + .where( "age", ">=", "21" ); + } ) + .join( "accounts", ( j ) => { + j.on( "accounts.userId", "=", "u.id" ); + j.where( "accounts.active", 1 ); + } ); + }, fromSubBindings() ); + } ); + + it( "can select from no table or a dummy table like DUAL", () => { + testCase( function( builder ) { + builder.selectRaw( "1 + 1" ); + }, fromEmpty() ); + } ); + + it( "can clear a configured table", () => { + testCase( function( builder ) { + builder + .from( "users" ) + .selectRaw( "1 + 1" ) + .clearFrom(); + }, clearFrom() ); + } ); + + it( "can add raw expressions after the from clause", function() { + testCase( function( builder ) { + builder + .select( [ "id", "name" ] ) + .from( "users" ) + .forRaw( "JSON AUTO" ); + }, forRaw() ); + } ); + } ); + + describe( "locking", function() { + it( "can set no lock", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .where( "id", 1 ) + .noLock(); + }, noLock() ); + } ); + + it( "can set a shared lock", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .where( "id", 1 ) + .sharedLock(); + }, sharedLock() ); + } ); + + it( "can lock for update", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .where( "id", 1 ) + .lockForUpdate(); + }, lockForUpdate() ); + } ); + + it( "can lock for update skipping locked rows", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .where( "id", 1 ) + .lockForUpdate( skipLocked = true ); + }, lockForUpdateSkipLocked() ); + } ); + + it( "can pass an arbitrary string to lock", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .where( "id", 1 ) + .lock( "foobar" ); + }, lockArbitraryString() ); + } ); + } ); + + describe( "using table prefixes", function() { + it( "can perform a basic select with a table prefix", function() { + testCase( function( builder ) { + builder.getGrammar().setTablePrefix( "prefix_" ); + builder.select( "*" ).from( "users" ); + }, tablePrefix() ); + } ); + + it( "can parse column aliases with a table prefix", function() { + testCase( function( builder ) { + builder.getGrammar().setTablePrefix( "prefix_" ); + builder.select( "*" ).from( "users as people" ); + }, tablePrefixWithAlias() ); + } ); + } ); + + describe( "aliases", function() { + describe( "column aliases", function() { + it( "can parse column aliases with AS in them", function() { + testCase( function( builder ) { + builder.select( "id AS user_id" ).from( "users" ); + }, columnAliasWithAs() ); + } ); + + it( "can parse column aliases without AS in them", function() { + testCase( function( builder ) { + builder.select( "id user_id" ).from( "users" ); + }, columnAliasWithoutAs() ); + } ); + } ); + + describe( "table aliases", function() { + it( "can parse table aliases with AS in them", function() { + testCase( function( builder ) { + builder.select( "*" ).from( "users as people" ); + }, tableAliasWithAs() ); + } ); + + it( "can parse table aliases without AS in them", function() { + testCase( function( builder ) { + builder.select( "*" ).from( "users people" ); + }, tableAliasWithoutAs() ); + } ); + } ); + } ); + } ); + } ); + } + +} diff --git a/tests/resources/querybuilder/AbstractQueryBuilderSubselectSpec.cfc b/tests/resources/querybuilder/AbstractQueryBuilderSubselectSpec.cfc new file mode 100644 index 00000000..24090b8d --- /dev/null +++ b/tests/resources/querybuilder/AbstractQueryBuilderSubselectSpec.cfc @@ -0,0 +1,69 @@ +component extends="tests.resources.querybuilder.AbstractQueryBuilderSelectSpec" { + + function run() { + super.run(); + + describe( "query builder + grammar integration", function() { + describe( "select statements", function() { + describe( "sub-selects", function() { + it( "can execute sub-selects", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .select( "name" ) + .subSelect( "latestUpdatedDate", function( q ) { + return q + .from( "posts" ) + .selectRaw( "MAX(updated_date)" ) + .whereColumn( "posts.user_id", "users.id" ); + } ); + }, subSelect() ); + } ); + + it( "can take a query object in a sub-selects", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .select( "name" ) + .subSelect( + "latestUpdatedDate", + builder + .newQuery() + .from( "posts" ) + .selectRaw( "MAX(updated_date)" ) + .whereColumn( "posts.user_id", "users.id" ) + ); + }, subSelectQueryObject() ); + } ); + + it( "can execute sub-selects with bindings", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .select( "name" ) + .subSelect( "latestUpdatedDate", function( q ) { + return q + .from( "posts" ) + .selectRaw( "MAX(updated_date)" ) + .where( "posts.user_id", 1 ); + } ); + }, subSelectWithBindings() ); + } ); + + it( "snapshots a builder passed to a sub-select", function() { + var child = getBuilder(); + child.from( "posts" ).selectRaw( "MAX(updated_date)" ); + var builder = getBuilder(); + builder.from( "users" ).subSelect( "latestUpdatedDate", child ); + + child.where( "posts.user_id", 1 ); + + expect( builder.toSQL() ).notToInclude( "user_id" ); + expect( getTestBindings( builder ) ).toBe( [] ); + } ); + } ); + } ); + } ); + } + +} diff --git a/tests/resources/querybuilder/AbstractQueryBuilderUnionSpec.cfc b/tests/resources/querybuilder/AbstractQueryBuilderUnionSpec.cfc new file mode 100644 index 00000000..678a6cfe --- /dev/null +++ b/tests/resources/querybuilder/AbstractQueryBuilderUnionSpec.cfc @@ -0,0 +1,214 @@ +component extends="tests.resources.querybuilder.AbstractQueryBuilderGroupingSpec" { + + function run() { + super.run(); + + describe( "query builder + grammar integration", function() { + describe( "select statements", function() { + describe( "unions", function() { + it( "can union multiple statements using a closure", function() { + testCase( function( builder ) { + builder + .select( "name" ) + .from( "users" ) + .where( "id", 1 ) + .union( function( q ) { + q.select( "name" ) + .from( "users" ) + .where( "id", 2 ) + ; + } ) + .union( function( q ) { + q.select( "name" ) + .from( "users" ) + .where( "id", 3 ) + ; + } ) + ; + }, union() ); + } ); + + it( "can union multiple statements using a QueryBuilder instance", function() { + testCase( function( builder ) { + var union2 = getBuilder() + .select( "name" ) + .from( "users" ) + .where( "id", 2 ); + var union3 = getBuilder() + .select( "name" ) + .from( "users" ) + .where( "id", 3 ); + + builder + .select( "name" ) + .from( "users" ) + .where( "id", 1 ) + .union( union2 ) + .union( union3 ) + ; + }, union() ); + } ); + + it( "union can contain order by on main query only", function() { + testCase( function( builder ) { + builder + .select( "name" ) + .from( "users" ) + .where( "id", 1 ) + .union( function( q ) { + q.select( "name" ) + .from( "users" ) + .where( "id", 2 ) + ; + } ) + .union( function( q ) { + q.select( "name" ) + .from( "users" ) + .where( "id", 3 ) + ; + } ) + .orderBy( "name" ) + ; + }, unionOrderBy() ); + } ); + + it( "union query cannot contain orderBy", function() { + var builder = getBuilder(); + + builder + .select( "name" ) + .from( "users" ) + .where( "id", 1 ) + .union( function( q ) { + q.select( "name" ) + .from( "users" ) + .where( "id", 2 ) + .orderBy( "name" ) + ; + } ) + .union( function( q ) { + q.select( "name" ) + .from( "users" ) + .where( "id", 3 ) + ; + } ) + .orderBy( "name" ) + ; + + + try { + var statements = builder.toSql(); + } catch ( any e ) { + // Darn ACF nests the exception message. 😠 + if ( e.message == "An exception occurred while calling the function map." ) { + expect( e.detail ).toBe( "The ORDER BY clause is not allowed in a UNION statement." ); + } else { + expect( e.message ).toBe( "The ORDER BY clause is not allowed in a UNION statement." ); + } + return; + } + fail( "Should have caught an exception, but didn't." ); + } ); + + it( "can union all multiple statements using a closure", function() { + testCase( function( builder ) { + builder + .select( "name" ) + .from( "users" ) + .where( "id", 1 ) + .unionAll( function( q ) { + q.select( "name" ) + .from( "users" ) + .where( "id", 2 ); + } ) + .unionAll( function( q ) { + q.select( "name" ) + .from( "users" ) + .where( "id", 3 ); + } ); + }, unionAll() ); + } ); + + it( "can union all multiple statements using a QueryBuilder instance", function() { + testCase( function( builder ) { + var union2 = getBuilder() + .select( "name" ) + .from( "users" ) + .where( "id", 2 ); + var union3 = getBuilder() + .select( "name" ) + .from( "users" ) + .where( "id", 3 ); + + builder + .select( "name" ) + .from( "users" ) + .where( "id", 1 ) + .unionAll( union2 ) + .unionAll( union3 ) + ; + }, unionAll() ); + } ); + + it( "snapshots a builder passed to a union", function() { + var unionQuery = getBuilder().select( "name" ).from( "archived_users" ); + var builder = getBuilder() + .select( "name" ) + .from( "users" ) + .unionAll( unionQuery ); + + unionQuery.where( "active", 1 ); + + expect( builder.toSQL() ).notToInclude( "active" ); + expect( getTestBindings( builder ) ).toBe( [] ); + } ); + + it( "orders union bindings before outer order bindings", function() { + var builder = getBuilder() + .select( "name" ) + .from( "users" ) + .where( "status", "current" ) + .union( function( unionQuery ) { + unionQuery + .select( "name" ) + .from( "archived_users" ) + .where( "status", "archived" ); + } ) + .orderByRaw( "CASE WHEN name = ? THEN 0 ELSE 1 END", [ "preferred" ] ); + + expect( getTestBindings( builder ) ).toBe( [ "current", "archived", "preferred" ] ); + } ); + + it( "retains root select bindings when aggregating a union", function() { + var builder = getBuilder() + .selectRaw( "? AS name", [ "current" ] ) + .from( "users" ) + .union( function( unionQuery ) { + unionQuery.selectRaw( "? AS name", [ "archived" ] ).from( "archived_users" ); + } ); + + expect( function() { + builder.count( toSQL = true, showBindings = "inline" ); + } ).notToThrow(); + } ); + + it( "can run an aggregate query like count on a union query", function() { + testCase( function( builder ) { + return builder + .select( "name" ) + .from( "users" ) + .where( "id", 1 ) + .union( function( q ) { + q.select( "name" ) + .from( "users" ) + .where( "id", 2 ); + } ) + .count( toSQL = true ); + }, unionCount() ); + } ); + } ); + } ); + } ); + } + +} diff --git a/tests/resources/querybuilder/AbstractQueryBuilderUpdateSpec.cfc b/tests/resources/querybuilder/AbstractQueryBuilderUpdateSpec.cfc new file mode 100644 index 00000000..28c42983 --- /dev/null +++ b/tests/resources/querybuilder/AbstractQueryBuilderUpdateSpec.cfc @@ -0,0 +1,498 @@ +component extends="tests.resources.querybuilder.AbstractQueryBuilderInsertSpec" { + + function run() { + super.run(); + + describe( "query builder + grammar integration", function() { + describe( "update statements", function() { + it( "can update all records in a table", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .update( values = { "email": "foo", "name": "bar" }, toSql = true ); + }, updateAllRecords() ); + } ); + + it( "can be constrained by a where statement", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .whereId( 1 ) + .update( values = { "email": "foo", "name": "bar" }, toSql = true ); + }, updateWithWhere() ); + } ); + + it( "can use an expression in an update", function() { + testCase( function( builder ) { + return builder + .from( "hits" ) + .where( "page", "someUrl" ) + .update( values = { "count": builder.raw( "count + 1" ) }, toSql = true ); + }, updateWithRaw() ); + } ); + + it( "preserves bindings carried by update expressions", function() { + var builder = getBuilder(); + var sql = builder + .from( "hits" ) + .update( values = { "count": builder.raw( "COALESCE(?, 0) + ?", [ 10, 1 ] ) }, toSql = true ); + + expect( reMatch( "\?", sql ) ).toHaveLength( 2 ); + expect( getTestBindings( builder ) ).toBe( [ 10, 1 ] ); + } ); + + it( "can use an expression in an update table or from clause", function() { + testCase( function( builder ) { + return builder + .tableRaw( "LogFiles..Browsers" ) + .where( "ID", 1 ) + .update( values = { "UserAgent": "Mozilla/5.0" }, toSql = true ); + }, updateWithRawTable() ); + } ); + + it( "can add incrementally with addUpdate", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .whereId( 1 ) + .addUpdate( { "email": "foo", "name": "bar" } ) + .when( true, function( q ) { + q.addUpdate( { "foo": "yes" } ); + } ) + .when( false, function( q ) { + q.addUpdate( { "bar": "no" } ); + } ) + .update( toSql = true ); + }, addUpdate() ); + } ); + + it( "can update with a join", function() { + testCase( function( builder ) { + return builder + .table( "employees" ) + .join( "departments", "departments.id", "employees.departmentId" ) + .update( + values = { "employees.departmentName": builder.raw( "departments.name" ) }, + toSql = true + ); + }, updateWithJoin() ); + } ); + + it( "can update with a join using aliases", function() { + testCase( function( builder ) { + return builder + .table( "employees e" ) + .join( "departments d", "d.id", "e.departmentId" ) + .update( values = { "departmentName": builder.raw( "d.name" ) }, toSql = true ); + }, updateWithJoinAndAliases() ); + } ); + + it( "can update with a join and a where", function() { + testCase( function( builder ) { + return builder + .table( "employees" ) + .join( "departments", "departments.id", "employees.departmentId" ) + .where( "departments.active", 1 ) + .update( + values = { "employees.departmentName": builder.raw( "departments.name" ) }, + toSql = true + ); + }, updateWithJoinAndWhere() ); + } ); + + it( "turns a function into a subselect", function() { + testCase( function( builder ) { + var subselect = function( qb ) { + qb.from( "departments" ) + .select( "name" ) + .whereColumn( "employees.departmentId", "departments.id" ); + }; + return builder + .table( "employees" ) + .update( values = { "departmentName": subselect }, toSql = true ); + }, updateWithSubselect() ); + } ); + + it( "turns a builder instance into a subselect", function() { + testCase( function( builder ) { + return builder + .table( "employees" ) + .update( + values = { + "departmentName": builder + .newQuery() + .from( "departments" ) + .select( "name" ) + .whereColumn( "employees.departmentId", "departments.id" ) + }, + toSql = true + ); + }, updateWithBuilder() ); + } ); + + it( "can update with returning", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .where( "id", 1 ) + .returning( "modifiedDate" ) + .update( values = { "email": "john@example.com" }, toSql = true ); + }, updateReturning() ); + } ); + + it( "can update with raw returning columns", function() { + testCase( function( builder ) { + return builder + .from( "users" ) + .where( "id", 1 ) + .returningRaw( [ "DELETED.modifiedDate AS oldModifiedDate", "INSERTED.modifiedDate AS newModifiedDate" ] ) + .update( values = { "email": "john@example.com" }, toSql = true ); + }, updateReturningRaw() ); + } ); + + it( "can update with returning and joins", function() { + testCase( function( builder ) { + return builder + .from( "zzz" ) + .returning( "xxx" ) + .join( "aaa", ( j ) => { + j.on( "aaa.ddd", "zzz.ddd" ) + } ) + .whereIn( "aaa.id", [ 1, 2, 3 ] ) + .update( values = { "zzz.user_id": 1, "zzz.created": "2025-01-01 00:00:00" }, toSQL = true ); + }, updateReturningWithJoin() ); + } ); + + it( "returning ignores table qualifiers in update statements", function() { + testCase( function( builder ) { + return builder + .setColumnFormatter( function( column ) { + return "tablePrefix." & column; + } ) + .from( "users" ) + .where( "id", 1 ) + .returning( "modifiedDate" ) + .update( values = { "email": "john@example.com" }, toSql = true ); + }, updateReturningIgnoresTableQualifiers() ); + } ); + } ); + + describe( "updateOrInsert statements", function() { + it( "inserts a new record when the where clause does not bring back any records", function() { + testCase( function( builder ) { + grammar.$( "runQuery", queryNew( "aggregate", "varchar", [ { "aggregate": 0 } ] ) ); + return builder + .from( "users" ) + .where( "email", "foo" ) + .updateOrInsert( values = { "name": "baz" }, toSql = true ); + }, updateOrInsertNotExists() ); + } ); + + it( "updates an existing record when the where clause brings back at least one record", function() { + testCase( function( builder ) { + grammar.$( "runQuery", queryNew( "aggregate", "varchar", [ { "aggregate": 1 } ] ) ); + return builder + .from( "users" ) + .where( "email", "foo" ) + .updateOrInsert( values = { "name": "baz" }, toSql = true ); + }, updateOrInsertExists() ); + } ); + } ); + + describe( "upsert statements", function() { + it( "does not include unrelated parent bindings in upserts", function() { + var builder = getBuilder().from( "users" ).where( "tenant_id", 42 ); + + var sql = builder.upsert( + values = { "email": "eric@example.com" }, + target = [ "email" ], + update = [ "email" ], + toSql = true + ); + + expect( getTestBindings( builder ) ).toBe( [ "eric@example.com" ] ); + expect( reMatch( "\?", sql ) ).toHaveLength( 1 ); + } ); + + it( "can perform an upsert", function() { + testCase( function( builder ) { + return builder + .table( "users" ) + .upsert( + values = { + "username": "foo", + "active": 1, + "createdDate": "2021-09-08 12:00:00", + "modifiedDate": "2021-09-08 12:00:00" + }, + target = [ "username" ], + update = [ "active", "modifiedDate" ], + toSql = true + ); + }, upsert() ); + } ); + + it( "updates all values if none are passed to update", function() { + testCase( function( builder ) { + return builder + .table( "users" ) + .upsert( + values = { + "username": "foo", + "active": 1, + "createdDate": "2021-09-08 12:00:00", + "modifiedDate": "2021-09-08 12:00:00" + }, + target = [ "username" ], + toSql = true + ); + }, upsertAllValues() ); + } ); + + it( "just performs an insert when given an empty struct or array to update", function() { + testCase( function( builder ) { + return builder + .table( "users" ) + .upsert( + values = { + "username": "foo", + "active": 1, + "createdDate": "2021-09-08 12:00:00", + "modifiedDate": "2021-09-08 12:00:00" + }, + target = [ "username" ], + update = [], + toSql = true + ); + }, upsertEmptyUpdate() ); + } ); + + it( "can specify specific update values", function() { + testCase( function( builder ) { + return builder + .table( "stats" ) + .upsert( + values = [ + { "postId": 1, "viewedDate": "2021-09-08", "views": 1 }, + { "postId": 2, "viewedDate": "2021-09-08", "views": 1 } + ], + target = [ "postId", "viewedDate" ], + update = { "views": builder.raw( "stats.views + 1" ) }, + toSql = true + ); + }, upsertWithInsertedValue() ); + } ); + + it( "can match the target as a single value", function() { + testCase( function( builder ) { + return builder + .table( "users" ) + .upsert( + values = { + "username": "foo", + "active": 1, + "createdDate": "2021-09-08 12:00:00", + "modifiedDate": "2021-09-08 12:00:00" + }, + target = "username", + update = [ "active", "modifiedDate" ], + toSql = true + ); + }, upsertSingleTarget() ); + } ); + + it( "can opt in to matching null target values", function() { + testCase( function( builder ) { + return builder + .table( "records" ) + .upsert( + values = [ + { "a": 1, "b": javacast( "null", "" ), "c": "first" }, + { "a": 2, "b": "value", "c": "second" } + ], + target = [ "a", "b" ], + update = [ "c" ], + matchNulls = true, + toSql = true + ); + }, upsertMatchNulls() ); + } ); + + it( "can perform an upsert with a closure as the source", function() { + testCase( function( builder ) { + return builder + .table( "users" ) + .upsert( + source = function( q ) { + q.from( "activeDirectoryUsers" ) + .select( [ + "username", + "active", + "createdDate", + "modifiedDate" + ] ) + .where( "active", 1 ); + }, + values = [ + "username", + "active", + "createdDate", + "modifiedDate" + ], + target = [ "username" ], + update = [ "active", "modifiedDate" ], + toSql = true + ); + }, upsertFromClosure() ); + } ); + + it( "can perform an upsert with a builder object as the source", function() { + testCase( function( builder ) { + return builder + .table( "users" ) + .upsert( + source = builder + .newQuery() + .from( "activeDirectoryUsers" ) + .select( [ + "username", + "active", + "createdDate", + "modifiedDate" + ] ) + .where( "active", 1 ), + values = [ + "username", + "active", + "createdDate", + "modifiedDate" + ], + target = [ "username" ], + update = [ "active", "modifiedDate" ], + toSql = true + ); + }, upsertFromBuilder() ); + } ); + + it( "can delete unmatched source rows in an upsert (SQL Server)", function() { + testCase( function( builder ) { + return builder + .table( "users" ) + .upsert( + source = function( q ) { + q.from( "activeDirectoryUsers" ) + .select( [ + "username", + "active", + "createdDate", + "modifiedDate" + ] ) + .where( "active", 1 ); + }, + values = [ + "username", + "active", + "createdDate", + "modifiedDate" + ], + target = [ "username" ], + update = [ "active", "modifiedDate" ], + deleteUnmatched = true, + toSql = true + ); + }, upsertWithDelete() ); + } ); + + it( "can delete unmatched source rows in an upsert with additional restrictions (SQL Server)", function() { + testCase( + callback = function( builder ) { + return builder + .table( "users" ) + .upsert( + source = function( q ) { + q.from( "activeDirectoryUsers" ) + .select( [ + "username", + "active", + "createdDate", + "modifiedDate" + ] ) + .where( "active", { value: 1, cfsqltype: "INTEGER" } ); + }, + values = [ + "username", + "active", + "createdDate", + "modifiedDate" + ], + target = [ "username" ], + update = [ "active", "modifiedDate" ], + deleteUnmatched = ( q ) => { + q.where( "active", { value: 0, cfsqltype: "INTEGER" } ); + }, + toSql = true + ); + }, + expected = upsertWithDeleteRestricted(), + withFullBindings = true + ); + } ); + + it( "can update fields to null", () => { + testCase( function( builder ) { + return builder + .table( "vendors" ) + .upsert( + target = [ "vendorCode", "code" ], + values = { + "vendorCode": "AA", + "code": "BB", + "name": javacast( "null", "" ), + "count": 1 + }, + update = { "count": builder.raw( "vendors.count + 1" ), "name": javacast( "null", "" ) }, + toSQL = true + ); + }, upsertUpdateToNull() ); + } ); + + it( "adds bindings for explicit update values", () => { + testCase( + callback = function( builder ) { + return builder + .table( "vendors" ) + .upsert( + target = [ "vendorCode", "code" ], + values = { + "vendorCode": "AA", + "code": "BB", + "name": "New Name", + "count": 1 + }, + update = { "count": builder.raw( "vendors.count + 1" ), "name": "New Name" }, + toSQL = true + ); + }, + expected = upsertUpdateWithExplicitValue() + ); + } ); + + it( "preserves bindings carried by upsert expressions", function() { + var builder = getBuilder(); + var sql = builder + .table( "scores" ) + .upsert( + values = { "id": 1, "score": builder.raw( "COALESCE(?, 0)", [ 2 ] ) }, + target = [ "id" ], + update = { "score": builder.raw( "? + 1", [ 3 ] ) }, + toSql = true + ); + + expect( reMatch( "\?", sql ) ).toHaveLength( 3 ); + expect( getTestBindings( builder ) ).toBe( [ 1, 2, 3 ] ); + } ); + } ); + } ); + } + +} diff --git a/tests/resources/querybuilder/AbstractQueryBuilderWhereSpec.cfc b/tests/resources/querybuilder/AbstractQueryBuilderWhereSpec.cfc new file mode 100644 index 00000000..94a18dee --- /dev/null +++ b/tests/resources/querybuilder/AbstractQueryBuilderWhereSpec.cfc @@ -0,0 +1,670 @@ +component extends="tests.resources.querybuilder.AbstractQueryBuilderSourceSpec" { + + function run() { + super.run(); + + describe( "query builder + grammar integration", function() { + describe( "select statements", function() { + describe( "wheres", function() { + describe( "basic wheres", function() { + it( "can add a where statement", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .where( "id", "=", 1 ); + }, basicWhere() ); + } ); + + it( "can add a where statement with a query param struct", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .where( "createdDate", ">=", { value: "01/01/2019", cfsqltype: "DATE" } ); + }, basicWhereWithQueryParamStruct() ); + } ); + + it( "can add or where statements", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .where( "id", "=", 1 ) + .orWhere( "email", "foo" ); + }, orWhere() ); + } ); + + it( "can add and where statements", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .where( "id", "=", 1 ) + .andWhere( "email", "foo" ); + }, andWhere() ); + } ); + + it( "can add raw where statements", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .whereRaw( "id = ? OR email = ?", [ 1, "foo" ] ); + }, whereRaw() ); + } ); + + it( "can add raw or where statements", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .where( "id", "=", 1 ) + .orWhereRaw( "email = ?", [ "foo" ] ); + }, orWhereRaw() ); + } ); + + it( "can specify a where between two columns", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .whereColumn( "first_name", "last_name" ); + }, whereColumn() ); + } ); + + it( "can specify an or where between two columns", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .whereColumn( "first_name", "last_name" ) + .orWhereColumn( "updated_date", ">", "created_date" ); + }, orWhereColumn() ); + } ); + + it( "can add nested where statements", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .where( "email", "foo" ) + .orWhere( function( q ) { + q.where( "name", "bar" ).where( "age", ">=", "21" ); + } ); + }, whereNested() ); + } ); + + it( "can have full sub-selects in where statements", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .where( "email", "foo" ) + .orWhere( + "id", + "=", + function( q ) { + q.select( q.raw( "MAX(id)" ) ) + .from( "users" ) + .where( "email", "bar" ); + } + ); + }, whereSubSelect() ); + } ); + + it( "can configure a where with a builder instance", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .where( "email", "foo" ) + .orWhere( + "id", + "=", + builder + .newQuery() + .select( builder.raw( "MAX(id)" ) ) + .from( "users" ) + .where( "email", "bar" ) + ); + }, whereBuilderInstance() ); + } ); + + it( "can add a where statement with a boolean literal", function() { + testCase( + callback = function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .where( "active", "=", true ); + }, + expected = whereBoolean(), + withFullBindings = true + ); + } ); + + it( "can handle null values passed to where clauses", () => { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .where( "id", "=", javacast( "null", "" ) ); + }, nullWhere() ); + } ); + } ); + + describe( "where exists", function() { + it( "can add a where exists clause", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "orders" ) + .whereExists( function( q ) { + q.select( q.raw( 1 ) ) + .from( "products" ) + .whereColumn( "products.id", "orders.id" ); + } ); + }, whereExists() ); + } ); + + it( "can add an or where exists clause", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "orders" ) + .where( "id", 1 ) + .orWhereExists( function( q ) { + q.select( q.raw( 1 ) ) + .from( "products" ) + .whereColumn( "products.id", "orders.id" ); + } ); + }, orWhereExists() ); + } ); + + it( "can add a where not exists clause", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "orders" ) + .whereNotExists( function( q ) { + q.select( q.raw( 1 ) ) + .from( "products" ) + .whereColumn( "products.id", "orders.id" ); + } ); + }, whereNotExists() ); + } ); + + it( "can add an or where not exists clause", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "orders" ) + .where( "id", 1 ) + .orWhereNotExists( function( q ) { + q.select( q.raw( 1 ) ) + .from( "products" ) + .whereColumn( "products.id", "orders.id" ); + } ); + }, orWhereNotExists() ); + } ); + + it( "can add a where exists clause using a builder instance", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "orders" ) + .whereExists( + builder + .newQuery() + .select( builder.raw( 1 ) ) + .from( "products" ) + .whereColumn( "products.id", "orders.id" ) + ); + }, whereExistsBuilderInstance() ); + } ); + } ); + + describe( "where null", function() { + it( "can add where null statements", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .whereNull( "id" ); + }, whereNull() ); + } ); + + it( "can add or where null statements", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .where( "id", 1 ) + .orWhereNull( "id" ); + }, orWhereNull() ); + } ); + + it( "can add where not null statements", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .whereNotNull( "id" ); + }, whereNotNull() ); + } ); + + it( "can add or where not null statements", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .where( "id", 1 ) + .orWhereNotNull( "id" ); + }, orWhereNotNull() ); + } ); + + it( "can add a where null with a subselect", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .whereNull( function( q ) { + q.selectRaw( "MAX(created_date)" ) + .from( "logins" ) + .whereColumn( "logins.user_id", "users.id" ); + } ); + }, whereNullSubselect() ); + } ); + + it( "can add a where null with a builder instance", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .whereNull( + builder + .newQuery() + .selectRaw( "MAX(created_date)" ) + .from( "logins" ) + .whereColumn( "logins.user_id", "users.id" ) + ); + }, whereNullSubquery() ); + } ); + + it( "preserves bindings from where null subqueries", function() { + var builder = getBuilder() + .from( "users" ) + .whereNull( function( query ) { + query + .select( "deletedAt" ) + .from( "accounts" ) + .where( "status", "closed" ); + } ); + + expect( reMatch( "\?", builder.toSQL() ) ).toHaveLength( 1 ); + expect( getTestBindings( builder ) ).toBe( [ "closed" ] ); + } ); + } ); + + describe( "where between", function() { + it( "can add where between statements", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .whereBetween( "id", 1, 2 ); + }, whereBetween() ); + } ); + + it( "can add where between statements with raw expressions", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .whereBetween( + "createdDate", + builder.raw( "GETDATE() - 7" ), + builder.raw( "GETDATE()" ) + ); + }, whereBetweenRaw() ); + } ); + + it( "can add where between statements with query param structs", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .whereBetween( + "createdDate", + { value: "1/1/2019", cfsqltype: "DATE" }, + { value: "12/31/2019", cfsqltype: "DATE" } + ); + }, whereBetweenWithQueryParamStructs() ); + } ); + + it( "can add where not between statements", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .whereNotBetween( "id", 1, 2 ); + }, whereNotBetween() ); + } ); + + it( "can add where not between statements with expression boundaries", function() { + var builder = getBuilder() + .from( "users" ) + .whereNotBetween( + "score", + getBuilder().raw( "COALESCE(?, 0)", [ 10 ] ), + getBuilder().raw( "COALESCE(?, 100)", [ 90 ] ) + ); + + expect( builder.toSQL() ).toInclude( "NOT BETWEEN COALESCE(?, 0) AND COALESCE(?, 100)" ); + expect( getTestBindings( builder ) ).toBe( [ 10, 90 ] ); + } ); + + it( "can add where not between statements with subquery boundaries", function() { + var builder = getBuilder() + .from( "users" ) + .whereNotBetween( + "id", + function( query ) { + query + .selectRaw( "MIN(id)" ) + .from( "users" ) + .where( "type", "minimum" ); + }, + function( query ) { + query + .selectRaw( "MAX(id)" ) + .from( "users" ) + .where( "type", "maximum" ); + } + ); + + expect( builder.toSQL() ).toInclude( "NOT BETWEEN (SELECT" ); + expect( getTestBindings( builder ) ).toBe( [ "minimum", "maximum" ] ); + } ); + + it( "can add where between statements using closures", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .whereBetween( + "id", + function( q ) { + q.select( q.raw( "MIN(id)" ) ) + .from( "users" ) + .where( "email", "bar" ); + }, + function( q ) { + q.select( q.raw( "MAX(id)" ) ) + .from( "users" ) + .where( "email", "bar" ); + } + ); + }, whereBetweenClosures() ); + } ); + + it( "can add where between statements using builder instances", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .whereBetween( + "id", + builder + .newQuery() + .select( builder.raw( "MIN(id)" ) ) + .from( "users" ) + .where( "email", "bar" ), + builder + .newQuery() + .select( builder.raw( "MAX(id)" ) ) + .from( "users" ) + .where( "email", "bar" ) + ); + }, whereBetweenBuilderInstances() ); + } ); + + it( "can add where between statements using both closures and builder instances", function() { + testCase( function( builder ) { + builder + .select( "*" ) + .from( "users" ) + .whereBetween( + "id", + function( q ) { + q.select( q.raw( "MIN(id)" ) ) + .from( "users" ) + .where( "email", "bar" ); + }, + builder + .newQuery() + .select( builder.raw( "MAX(id)" ) ) + .from( "users" ) + .where( "email", "bar" ) + ); + }, whereBetweenMixed() ); + } ); + } ); + + describe( "where in", function() { + it( "can add where in statements from a list", function() { + testCase( function( builder ) { + builder.from( "users" ).whereIn( "id", "1,2,3" ); + }, whereInList() ); + } ); + + it( "can add where in statements from an array", function() { + testCase( function( builder ) { + builder.from( "users" ).whereIn( "id", [ 1, 2, 3 ] ); + }, whereInArray() ); + } ); + + it( "can add where in statements from an array", function() { + testCase( function( builder ) { + builder.from( "users" ).whereIn( "id", [ 1, { value: 2, cfsqltype: "INTEGER" }, 3 ] ); + }, whereInArrayOfQueryParamStructs() ); + } ); + + it( "can add or where in statements", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .where( "email", "foo" ) + .orWhereIn( "id", [ 1, 2, 3 ] ); + }, orWhereIn() ); + } ); + + it( "can add raw where in statements", function() { + testCase( function( builder ) { + builder.from( "users" ).whereIn( "id", [ builder.raw( 1 ) ] ); + }, whereInRaw() ); + } ); + + it( "correctly handles empty where ins", function() { + testCase( function( builder ) { + builder.from( "users" ).whereIn( "id", [] ); + }, whereInEmpty() ); + } ); + + it( "correctly handles empty where not ins", function() { + testCase( function( builder ) { + builder.from( "users" ).whereNotIn( "id", [] ); + }, whereNotInEmpty() ); + } ); + + it( "handles sub selects in 'in' statements", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .whereIn( "id", function( q ) { + q.select( "id" ) + .from( "users" ) + .where( "age", ">", 25 ); + } ); + }, whereInSubselect() ); + } ); + + it( "handles builder instances in 'in' statements", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .whereIn( + "id", + builder + .newQuery() + .select( "id" ) + .from( "users" ) + .where( "age", ">", 25 ) + ); + }, whereInBuilderInstance() ); + } ); + + describe( "bulk values", function() { + it( "binds an array as a single parameter", function() { + testCase( function( builder ) { + builder.from( "users" ).whereInBulk( "id", [ 1, 2, 3 ] ); + }, whereInBulk() ); + } ); + + it( "uses a large text binding for the serialized values", function() { + var builder = getBuilder().from( "users" ).whereInBulk( "id", [ 1, 2, 3 ] ); + var bindings = builder.getBindings(); + expect( bindings ).toHaveLength( 1 ); + expect( bindings[ 1 ].value ).toBe( "[1,2,3]" ); + expect( bindings[ 1 ].cfsqltype ).toBe( "LONGVARCHAR" ); + } ); + + it( "serializes values from query parameter structs", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .whereInBulk( + "id", + [ + { value: 1, cfsqltype: "INTEGER" }, + { value: 2, cfsqltype: "INTEGER" }, + { value: 3, cfsqltype: "INTEGER" } + ] + ); + }, whereInBulk() ); + } ); + + it( "infers string values using the grammar-specific string type", function() { + testCase( function( builder ) { + builder.from( "users" ).whereInBulk( "status", [ "active", "pending" ] ); + }, whereInBulkStrings() ); + } ); + + it( "falls back to the grammar-specific string type for mixed values", function() { + testCase( function( builder ) { + builder.from( "users" ).whereInBulk( "externalId", [ 1, "two" ] ); + }, whereInBulkMixed() ); + } ); + + it( "infers boolean values using the grammar-specific boolean type", function() { + testCase( function( builder ) { + builder.from( "users" ).whereInBulk( "active", [ true, false ] ); + }, whereInBulkBooleans() ); + } ); + + it( "uses matching query parameter types as the inferred type", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .whereInBulk( + "id", + [ { value: 1, cfsqltype: "BIGINT" }, { value: 2, cfsqltype: "BIGINT" } ] + ); + }, whereInBulkBigInt() ); + } ); + + it( "allows an explicit SQL type to override inference", function() { + testCase( function( builder ) { + builder.from( "users" ).whereInBulk( "id", [ 1, 2, 3 ], bulkExplicitSqlType() ); + }, whereInBulkExplicitType() ); + } ); + + it( "infers the SQL type when explicitly passed null", function() { + testCase( function( builder ) { + builder.from( "users" ).whereInBulk( "id", [ 1, 2, 3 ], javacast( "null", "" ) ); + }, whereInBulk() ); + } ); + + it( "maps inferred timestamp types for the active grammar", function() { + expect( getBuilder().getGrammar().resolveWhereInBulkSqlType( "TIMESTAMP" ) ).toBe( + bulkTimestampSqlType() + ); + } ); + + it( "supports dynamic or where shortcuts", function() { + testCase( function( builder ) { + builder + .from( "users" ) + .where( "active", 1 ) + .orWhereInBulk( "id", [ 1, 2, 3 ] ); + }, orWhereInBulk() ); + } ); + + it( "supports negated bulk values", function() { + testCase( function( builder ) { + builder.from( "users" ).whereNotInBulk( "id", [ 1, 2, 3 ] ); + }, whereNotInBulk() ); + } ); + + it( "handles empty bulk values without a binding", function() { + testCase( function( builder ) { + builder.from( "users" ).whereInBulk( "id", [] ); + }, whereInBulkEmpty() ); + } ); + + it( "handles empty negated bulk values without a binding", function() { + testCase( function( builder ) { + builder.from( "users" ).whereNotInBulk( "id", [] ); + }, whereNotInBulkEmpty() ); + } ); + + it( "rejects SQL expressions in bulk values", function() { + expect( function() { + getBuilder().whereInBulk( "id", [ getBuilder().raw( "SELECT 1" ) ] ); + } ).toThrow( type = "InvalidBulkValue" ); + } ); + + it( "rejects unsafe SQL types", function() { + expect( function() { + getBuilder().whereInBulk( "id", [ 1, 2, 3 ], "INTEGER); DROP TABLE users; --" ); + } ).toThrow( type = "InvalidSQLType" ); + } ); + + it( "rejects an explicitly empty SQL type", function() { + expect( function() { + getBuilder().whereInBulk( "id", [ 1, 2, 3 ], "" ); + } ).toThrow( type = "InvalidSQLType" ); + } ); + } ); + } ); + + describe( "where like shortcuts", function() { + it( "can add like statements using a shortcut method", function() { + testCase( function( builder ) { + builder.from( "users" ).whereLike( "username", "Jo%" ); + }, whereLike() ); + } ); + + it( "can add where not like statements using a shortcut method", function() { + testCase( function( builder ) { + builder.from( "users" ).whereNotLike( "username", "Jo%" ); + }, whereNotLike() ); + } ); + } ); + } ); + } ); + } ); + } + +} From 866acab53a2ef84ab29f1f7ba2bf2f945626a316 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 16 Aug 2026 15:10:00 -0600 Subject: [PATCH 046/119] refactor(QueryBuilder): extract join clause manager --- models/Query/JoinClauseManager.cfc | 295 +++++++++++++++++++++++++++++ models/Query/QueryBuilder.cfc | 215 ++------------------- 2 files changed, 309 insertions(+), 201 deletions(-) create mode 100644 models/Query/JoinClauseManager.cfc diff --git a/models/Query/JoinClauseManager.cfc b/models/Query/JoinClauseManager.cfc new file mode 100644 index 00000000..a06c9538 --- /dev/null +++ b/models/Query/JoinClauseManager.cfc @@ -0,0 +1,295 @@ +/** + * Creates and attaches JoinClause instances without retaining builder state. + */ +component { + + /** + * Creates a detached join clause for the supplied builder. + */ + public JoinClause function newJoin( required QueryBuilder builder, required any table, string type = "inner" ) { + return new qb.models.Query.JoinClause( + joiningQuery = arguments.builder, + type = arguments.type, + table = arguments.table + ); + } + + /** + * Creates and attaches a join clause to the supplied builder. + */ + public QueryBuilder function join( + required QueryBuilder builder, + required any table, + any first, + string operator = "=", + string second, + string type = "inner", + boolean where = false, + boolean preventDuplicateJoins = arguments.builder.getPreventDuplicateJoins() + ) { + if ( arguments.builder.getUtils().isBuilder( arguments.table ) ) { + arguments.table = arguments.builder + .getCollaborator( "QueryExecutor" ) + .cloneJoinClause( arguments.builder, arguments.table, arguments.builder ); + if ( arguments.preventDuplicateJoins && containsJoin( arguments.builder, arguments.table ) ) { + return arguments.builder; + } + return attachJoin( arguments.builder, arguments.table ); + } + + var join = newJoin( builder = arguments.builder, type = arguments.type, table = arguments.table ); + + if ( isClosure( arguments.first ) || isCustomFunction( arguments.first ) ) { + var commonTableState = arguments.builder + .getCollaborator( "QueryExecutor" ) + .captureCommonTableState( arguments.builder ); + try { + arguments.first( join ); + } catch ( any e ) { + arguments.builder + .getCollaborator( "QueryExecutor" ) + .restoreCommonTableState( arguments.builder, commonTableState ); + rethrow; + } + if ( arguments.preventDuplicateJoins && containsJoin( arguments.builder, join ) ) { + arguments.builder + .getCollaborator( "QueryExecutor" ) + .restoreCommonTableState( arguments.builder, commonTableState ); + return arguments.builder; + } + return attachJoin( arguments.builder, join ); + } + + if ( arguments.where ) { + join.where( + column = arguments.first, + operator = arguments.operator, + value = isNull( arguments.second ) ? javacast( "null", "" ) : arguments.second + ); + } else { + join.on( + first = arguments.first, + operator = arguments.operator, + second = isNull( arguments.second ) ? javacast( "null", "" ) : arguments.second + ); + } + + if ( arguments.preventDuplicateJoins && containsJoin( arguments.builder, join ) ) { + return arguments.builder; + } + return attachJoin( arguments.builder, join ); + } + + /** + * Attaches a cross join to the supplied builder. + */ + public QueryBuilder function crossJoin( required QueryBuilder builder, required any table ) { + return attachJoin( + arguments.builder, + newJoin( builder = arguments.builder, type = "cross", table = arguments.table ) + ); + } + + /** + * Attaches a raw cross join while preserving its expression for grammar compilation. + */ + public QueryBuilder function crossJoinRaw( required QueryBuilder builder, required string table ) { + arguments.builder + .getJoins() + .append( + newJoin( builder = arguments.builder, type = "cross", table = arguments.builder.raw( arguments.table ) ) + ); + return arguments.builder; + } + + /** + * Builds and attaches a join against a derived table. + */ + public QueryBuilder function joinSub( + required QueryBuilder builder, + required string alias, + required any input, + required any first, + string operator = "=", + string second, + string type = "inner", + boolean where = false + ) { + var executor = arguments.builder.getCollaborator( "QueryExecutor" ); + var commonTableState = executor.captureCommonTableState( arguments.builder ); + try { + if ( isClosure( arguments.input ) || isCustomFunction( arguments.input ) ) { + var subquery = arguments.builder.newQuery(); + arguments.input( subquery ); + arguments.input = subquery; + } + arguments.input = executor.snapshotBuilder( arguments.builder, arguments.input ); + + var table = arguments.builder.raw( + arguments.builder.getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ), + arguments.input.getBindings() + ); + + var joinCount = arguments.builder.getJoins().len(); + var joinArguments = { + builder: arguments.builder, + table: table, + first: arguments.first, + operator: arguments.operator, + type: arguments.type, + where: arguments.where + }; + if ( !isNull( arguments.second ) ) { + joinArguments.second = arguments.second; + } + + var result = join( argumentCollection = joinArguments ); + if ( arguments.builder.getJoins().len() > joinCount ) { + arguments.builder.setGrammarCompiledJoin( true ); + } else { + executor.restoreCommonTableState( arguments.builder, commonTableState ); + } + return result; + } catch ( any e ) { + executor.restoreCommonTableState( arguments.builder, commonTableState ); + rethrow; + } + } + + /** + * Builds and attaches an APPLY or LATERAL join. + */ + public QueryBuilder function applyJoin( + required QueryBuilder builder, + required string name, + required string type, + required any tableLikeSource + ) { + var executor = arguments.builder.getCollaborator( "QueryExecutor" ); + var commonTableState = executor.captureCommonTableState( arguments.builder ); + try { + if ( + arguments.type != "outer apply" && + arguments.type != "cross apply" && + arguments.type != "lateral" + ) { + throw( + type = "QBInvalidJoinType", + message = "Invalid join type: #arguments.type#. Valid types are [`outer apply`, `cross apply`, or `lateral`]" + ); + } + + var sourceIsBuilder = arguments.builder.getUtils().isBuilder( arguments.tableLikeSource ); + var sourceIsFunc = isClosure( arguments.tableLikeSource ) || isCustomFunction( arguments.tableLikeSource ); + + if ( !sourceIsBuilder && !sourceIsFunc ) { + throw( + type = "QBInvalidJoinSource", + message = "Invalid join source. Valid types are a QueryBuilder instance or a callback function that receives a new QueryBuilder instance." + ); + } + + if ( sourceIsFunc ) { + var subquery = arguments.builder.newQuery(); + arguments.tableLikeSource( subquery ); + arguments.tableLikeSource = subquery; + } + + arguments.tableLikeSource = executor.snapshotBuilder( arguments.builder, arguments.tableLikeSource ); + + var join = new qb.models.Query.JoinClause( + joiningQuery = arguments.builder, + type = arguments.type, + table = arguments.name, + lateralRawExpression = arguments.tableLikeSource.toSQL(), + lateralBindings = arguments.tableLikeSource.getBindings() + ); + + if ( arguments.builder.getPreventDuplicateJoins() && containsJoin( arguments.builder, join ) ) { + executor.restoreCommonTableState( arguments.builder, commonTableState ); + return arguments.builder; + } + + arguments.builder.addBindings( arguments.tableLikeSource.getBindings(), "join" ); + arguments.builder.getJoins().append( join ); + arguments.builder.setGrammarCompiledJoin( true ); + return arguments.builder; + } catch ( any e ) { + executor.restoreCommonTableState( arguments.builder, commonTableState ); + rethrow; + } + } + + /** + * Builds and attaches a cross join against a derived table. + */ + public QueryBuilder function crossJoinSub( required QueryBuilder builder, required any alias, required any input ) { + var executor = arguments.builder.getCollaborator( "QueryExecutor" ); + var commonTableState = executor.captureCommonTableState( arguments.builder ); + try { + if ( isClosure( arguments.input ) || isCustomFunction( arguments.input ) ) { + var subquery = arguments.builder.newQuery(); + arguments.input( subquery ); + arguments.input = subquery; + } + arguments.input = executor.snapshotBuilder( arguments.builder, arguments.input ); + + var table = arguments.builder.raw( + arguments.builder.getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ), + arguments.input.getBindings() + ); + + var result = crossJoin( arguments.builder, table ); + arguments.builder.setGrammarCompiledJoin( true ); + return result; + } catch ( any e ) { + executor.restoreCommonTableState( arguments.builder, commonTableState ); + rethrow; + } + } + + /** + * Adds a clause and its bindings to a builder. + */ + private QueryBuilder function attachJoin( required QueryBuilder builder, required JoinClause join ) { + arguments.builder.getJoins().append( arguments.join ); + arguments.builder.addBindings( getJoinBindings( arguments.builder, arguments.join ), "join" ); + return arguments.builder; + } + + /** + * Returns whether an equivalent join is already attached. + */ + private boolean function containsJoin( required QueryBuilder builder, required JoinClause join ) { + return arguments.builder + .getJoins() + .find( function( existingJoin ) { + return existingJoin.isEqualTo( join ); + } ) > 0; + } + + /** + * Returns bindings in their compiled join order. + */ + private array function getJoinBindings( required QueryBuilder builder, required JoinClause join ) { + var queryBuilder = arguments.builder; + var bindings = []; + if ( + arguments.join.isJoin() && + arguments.builder.getUtils().isExpression( arguments.join.getTable() ) + ) { + bindings.append( + arguments.join + .getTable() + .getBindings() + .map( function( binding ) { + return queryBuilder.getUtils().extractBinding( binding, queryBuilder.getGrammar() ); + } ), + true + ); + } + bindings.append( arguments.join.getBindings(), true ); + return bindings; + } + +} diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 0a0d8cf3..be6d7730 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -962,7 +962,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @returns qb.models.Query.JoinClause */ public JoinClause function newJoin( required any table, string type = "inner" ) { - return new qb.models.Query.JoinClause( joiningQuery = this, type = arguments.type, table = arguments.table ); + arguments.builder = this; + return getCollaborator( "JoinClauseManager" ).newJoin( argumentCollection = arguments ); } /** @@ -991,79 +992,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J boolean where = false, boolean preventDuplicateJoins = this.getPreventDuplicateJoins() ) { - if ( getUtils().isBuilder( arguments.table ) ) { - arguments.table = getCollaborator( "QueryExecutor" ).cloneJoinClause( this, arguments.table, this ); - if ( arguments.preventDuplicateJoins ) { - var hasThisJoin = variables.joins.find( function( existingJoin ) { - return existingJoin.isEqualTo( table ); - } ); - - if ( hasThisJoin ) { - return this; - } - } - variables.joins.append( arguments.table ); - addBindings( getJoinBindings( arguments.table ), "join" ); - return this; - } - - var join = new qb.models.Query.JoinClause( joiningQuery = this, type = arguments.type, table = arguments.table ); - - if ( isClosure( arguments.first ) || isCustomFunction( arguments.first ) ) { - var commonTableState = getCollaborator( "QueryExecutor" ).captureCommonTableState( this ); - try { - first( join ); - } catch ( any e ) { - getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); - rethrow; - } - if ( arguments.preventDuplicateJoins ) { - var hasThisJoin = variables.joins.find( function( existingJoin ) { - return existingJoin.isEqualTo( join ); - } ); - - if ( hasThisJoin ) { - getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); - return this; - } - } - variables.joins.append( join ); - addBindings( getJoinBindings( join ), "join" ); - return this; - } - - var method = where ? "where" : "on"; - arguments.column = arguments.first; - arguments.value = isNull( arguments.second ) ? javacast( "null", "" ) : arguments.second; - join = invoke( join, method, arguments ); - if ( arguments.preventDuplicateJoins ) { - var hasThisJoin = variables.joins.find( function( existingJoin ) { - return existingJoin.isEqualTo( join ); - } ); - - if ( hasThisJoin ) { - return this; - } - } - variables.joins.append( join ); - addBindings( getJoinBindings( join ), "join" ); - - return this; - } - - /** - * Returns bindings in the order they appear within a compiled join. - */ - private array function getJoinBindings( required any join ) { - var bindings = []; - if ( - arguments.join.isJoin() && - getUtils().isExpression( arguments.join.getTable() ) - ) { - bindings.append( extractExpressionBindings( arguments.join.getTable() ), true ); - } - bindings.append( arguments.join.getBindings(), true ); - return bindings; + arguments.builder = this; + return getCollaborator( "JoinClauseManager" ).join( argumentCollection = arguments ); } /** @@ -1234,11 +1164,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function crossJoin( required any table ) { - var join = new qb.models.Query.JoinClause( this, "cross", arguments.table ); - variables.joins.append( join ); - addBindings( getJoinBindings( join ), "join" ); - - return this; + arguments.builder = this; + return getCollaborator( "JoinClauseManager" ).crossJoin( argumentCollection = arguments ); } /** @@ -1335,12 +1262,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function crossJoinRaw( required string table ) { - // create the table reference - arguments.table = raw( arguments.table ); - - variables.joins.append( new qb.models.Query.JoinClause( this, "cross", arguments.table ) ); - - return this; + arguments.builder = this; + return getCollaborator( "JoinClauseManager" ).crossJoinRaw( argumentCollection = arguments ); } /** @@ -1369,101 +1292,13 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J string type = "inner", boolean where = false ) { - var commonTableState = getCollaborator( "QueryExecutor" ).captureCommonTableState( this ); - // since we have a callback, we generate a new query object and pass it into the callback - try { - if ( isClosure( arguments.input ) || isCustomFunction( arguments.input ) ) { - var subquery = newQuery(); - arguments.input( subquery ); - // replace the original query builder with the results of the sub-query - arguments.input = subquery; - } - arguments.input = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.input ); - - // create the table reference - arguments.table = raw( - getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ), - arguments.input.getBindings() - ); - - // remove the non-standard arguments - structDelete( arguments, "input" ); - structDelete( arguments, "alias" ); - - var joinCount = variables.joins.len(); - var result = join( argumentCollection = arguments ); - if ( variables.joins.len() > joinCount ) { - variables.grammarCompiledJoin = true; - } else { - getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); - } - return result; - } catch ( any e ) { - getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); - rethrow; - } + arguments.builder = this; + return getCollaborator( "JoinClauseManager" ).joinSub( argumentCollection = arguments ); } private function outerOrCrossApply( required string name, required string type, required tableLikeSource ) { - var commonTableState = getCollaborator( "QueryExecutor" ).captureCommonTableState( this ); - try { - if ( type != "outer apply" && type != "cross apply" && type != "lateral" ) { - throw( - type = "QBInvalidJoinType", - message = "Invalid join type: #arguments.type#. Valid types are [`outer apply`, `cross apply`, or `lateral`]" - ); - } - - var sourceIsBuilder = getUtils().isBuilder( arguments.tableLikeSource ) - var sourceIsFunc = isClosure( arguments.tableLikeSource ) || isCustomFunction( arguments.tableLikeSource ) - - if ( !sourceIsBuilder && !sourceIsFunc ) { - throw( - type = "QBInvalidJoinSource", - message = "Invalid join source. Valid types are a QueryBuilder instance or a callback function that receives a new QueryBuilder instance." - ); - } - - if ( sourceIsFunc ) { - var subquery = newQuery(); - arguments.tableLikeSource( subquery ); - arguments.tableLikeSource = subquery; - } - - arguments.tableLikeSource = getCollaborator( "QueryExecutor" ).snapshotBuilder( - this, - arguments.tableLikeSource - ); - - var join = new qb.models.Query.JoinClause( - joiningQuery = this, - type = type, - table = arguments.name, - lateralRawExpression = arguments.tableLikeSource.toSQL(), - lateralBindings = arguments.tableLikeSource.getBindings() - ); - - if ( this.getPreventDuplicateJoins() ) { - var hasThisJoin = variables.joins.find( function( existingJoin ) { - return existingJoin.isEqualTo( join ); - } ); - - if ( hasThisJoin ) { - // Do nothing, early return - getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); - return this; - } - } - - addBindings( tableLikeSource.getBindings(), "join" ); - variables.joins.append( join ); - variables.grammarCompiledJoin = true; - - return this; - } catch ( any e ) { - getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); - rethrow; - } + arguments.builder = this; + return getCollaborator( "JoinClauseManager" ).applyJoin( argumentCollection = arguments ); } public function outerApply( required string name, required any tableDef ) { @@ -1543,30 +1378,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function crossJoinSub( required any alias, required any input ) { - var commonTableState = getCollaborator( "QueryExecutor" ).captureCommonTableState( this ); - // since we have a callback, we generate a new query object and pass it into the callback - try { - if ( isClosure( arguments.input ) || isCustomFunction( arguments.input ) ) { - var subquery = newQuery(); - arguments.input( subquery ); - // replace the original query builder with the results of the sub-query - arguments.input = subquery; - } - arguments.input = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.input ); - - // create the table reference - var table = raw( - getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ), - arguments.input.getBindings() - ); - - var result = crossJoin( table ); - variables.grammarCompiledJoin = true; - return result; - } catch ( any e ) { - getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); - rethrow; - } + arguments.builder = this; + return getCollaborator( "JoinClauseManager" ).crossJoinSub( argumentCollection = arguments ); } /** From 7a027459978e84da318a9ce3e99f1dfbb9d29452 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 16 Aug 2026 15:17:31 -0600 Subject: [PATCH 047/119] refactor(QueryBuilder): extract predicate clause --- models/Query/PredicateClause.cfc | 684 +++++++++++++++++++++++++++++++ models/Query/QueryBuilder.cfc | 590 ++------------------------ 2 files changed, 727 insertions(+), 547 deletions(-) create mode 100644 models/Query/PredicateClause.cfc diff --git a/models/Query/PredicateClause.cfc b/models/Query/PredicateClause.cfc new file mode 100644 index 00000000..cc047cef --- /dev/null +++ b/models/Query/PredicateClause.cfc @@ -0,0 +1,684 @@ +/** + * Builds predicate definitions for a QueryBuilder without retaining builder state. + */ +component { + + /** + * Adds a basic, nested, or subquery WHERE predicate. + */ + public QueryBuilder function where( + required QueryBuilder builder, + column, + operator, + value, + string combinator = "and" + ) { + if ( isClosure( arguments.column ) || isCustomFunction( arguments.column ) ) { + return whereNested( arguments.builder, arguments.column, arguments.combinator ); + } + + arguments.builder.getQueryValidator().validateCombinator( arguments.combinator ); + + if ( + isNull( arguments.value ) && + arguments.builder.getQueryValidator().isInvalidOperator( arguments.operator ) + ) { + arguments.value = arguments.operator; + arguments.operator = "="; + } else { + arguments.builder.getQueryValidator().validateOperator( arguments.operator ); + } + + if ( + !isNull( arguments.value ) && ( + isClosure( arguments.value ) || + isCustomFunction( arguments.value ) || + arguments.builder.getUtils().isBuilder( arguments.value ) + ) + ) { + return whereSub( + arguments.builder, + arguments.column, + arguments.operator, + arguments.value, + arguments.combinator + ); + } + + return whereBasic( + arguments.builder, + arguments.column, + arguments.operator, + isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value, + arguments.combinator + ); + } + + /** + * Adds a WHERE IN predicate. + */ + public QueryBuilder function whereIn( + required QueryBuilder builder, + column, + values, + combinator = "and", + negate = false + ) { + arguments.builder.getQueryValidator().validateCombinator( arguments.combinator ); + if ( + isClosure( arguments.values ) || + isCustomFunction( arguments.values ) || + arguments.builder.getUtils().isBuilder( arguments.values ) + ) { + return whereInSub( + builder = arguments.builder, + column = arguments.column, + query = arguments.values, + combinator = arguments.combinator, + negate = arguments.negate + ); + } + + arguments.values = arguments.builder.normalizeToArray( arguments.values ); + + var type = arguments.negate ? "notIn" : "in"; + var typedColumn = toColumnType( arguments.builder, arguments.column ); + arguments.builder + .getWheres() + .append( { + type: type, + column: typedColumn, + values: arguments.values, + combinator: arguments.combinator + } ); + + var bindings = []; + if ( !arguments.values.isEmpty() ) { + arguments.builder.addColumnBindings( [ typedColumn ], "where" ); + } + for ( var value in arguments.values ) { + if ( arguments.builder.getUtils().isExpression( value ) ) { + bindings.append( arguments.builder.extractExpressionBindings( value ), true ); + } else { + bindings.append( arguments.builder.getUtils().extractBinding( value, arguments.builder.getGrammar() ) ); + } + } + + arguments.builder.addBindings( bindings, "where" ); + return arguments.builder; + } + + /** + * Adds a bulk WHERE IN predicate. + */ + public QueryBuilder function whereInBulk( + required QueryBuilder builder, + required column, + required values, + any sqlType = javacast( "null", "" ), + string combinator = "and", + boolean negate = false + ) { + arguments.builder.getQueryValidator().validateCombinator( arguments.combinator ); + arguments.values = arguments.builder.normalizeToArray( arguments.values ); + + var extractedBindings = []; + if ( !arguments.values.isEmpty() ) { + arrayResize( extractedBindings, arguments.values.len() ); + } + for ( var valueIndex = 1; valueIndex <= arguments.values.len(); valueIndex++ ) { + if ( !arrayIsDefined( arguments.values, valueIndex ) || isNull( arguments.values[ valueIndex ] ) ) { + extractedBindings[ valueIndex ] = arguments.builder + .getUtils() + .extractBinding( grammar = arguments.builder.getGrammar() ); + continue; + } + if ( arguments.builder.getUtils().isExpression( arguments.values[ valueIndex ] ) ) { + throw( type = "InvalidBulkValue", message = "Bulk IN values cannot contain SQL expressions." ); + } + extractedBindings[ valueIndex ] = arguments.builder + .getUtils() + .extractBinding( arguments.values[ valueIndex ], arguments.builder.getGrammar() ); + } + + if ( isNull( arguments.sqlType ) ) { + arguments.sqlType = arguments.builder + .getGrammar() + .resolveWhereInBulkSqlType( + arguments.builder.getUtils().inferSqlType( arguments.values, arguments.builder.getGrammar() ) + ); + } + + arguments.sqlType = trim( arguments.sqlType ); + if ( + arguments.sqlType == "" || + !reFindNoCase( + "^[a-z][a-z0-9_]*(?:\s+[a-z][a-z0-9_]*)*(?:\s*\(\s*(?:max|\d+)(?:\s*,\s*\d+)?\s*\))?$", + arguments.sqlType + ) + ) { + throw( + type = "InvalidSQLType", + message = "Invalid SQL type [#arguments.sqlType#] for a bulk IN statement." + ); + } + + var typedColumn = toColumnType( arguments.builder, arguments.column ); + arguments.builder + .getWheres() + .append( { + type: "inBulk", + column: typedColumn, + sqlType: arguments.sqlType, + isEmpty: arguments.values.isEmpty(), + negate: arguments.negate, + combinator: arguments.combinator + } ); + + if ( !arguments.values.isEmpty() ) { + arguments.builder.addColumnBindings( [ typedColumn ], "where" ); + var serializedValues = extractedBindings.map( function( binding ) { + return binding.null ? javacast( "null", "" ) : binding.value; + } ); + arguments.builder.addBindings( + [ + arguments.builder + .getUtils() + .extractBinding( + { value: serializeJSON( serializedValues ), cfsqltype: "LONGVARCHAR" }, + arguments.builder.getGrammar() + ) + ], + "where" + ); + } + + return arguments.builder; + } + + /** + * Adds a raw WHERE predicate. + */ + public QueryBuilder function whereRaw( + required QueryBuilder builder, + required string sql, + array whereBindings = [], + string combinator = "and" + ) { + arguments.builder.getQueryValidator().validateCombinator( arguments.combinator ); + var queryBuilder = arguments.builder; + arguments.builder.addBindings( + arguments.whereBindings.map( function( binding ) { + return queryBuilder.getUtils().extractBinding( binding, queryBuilder.getGrammar() ); + } ), + "where" + ); + arguments.builder.getWheres().append( { type: "raw", sql: arguments.sql, combinator: arguments.combinator } ); + return arguments.builder; + } + + /** + * Adds a column comparison predicate. + */ + public QueryBuilder function whereColumn( + required QueryBuilder builder, + required first, + operator, + second, + string combinator = "and" + ) { + arguments.builder.getQueryValidator().validateCombinator( arguments.combinator ); + if ( isNull( arguments.second ) ) { + arguments.second = arguments.operator; + arguments.operator = "="; + } + + arguments.builder.getQueryValidator().validateOperator( arguments.operator ); + + if ( + isClosure( arguments.second ) || + isCustomFunction( arguments.second ) || + arguments.builder.getUtils().isBuilder( arguments.second ) + ) { + return whereSub( + arguments.builder, + arguments.first, + arguments.operator, + arguments.second, + arguments.combinator + ); + } + + var firstColumn = toColumnType( arguments.builder, arguments.first ); + var secondColumn = toColumnType( arguments.builder, arguments.second ); + arguments.builder + .getWheres() + .append( { + type: "column", + first: firstColumn, + operator: arguments.operator, + second: secondColumn, + combinator: arguments.combinator + } ); + arguments.builder.addColumnBindings( [ firstColumn, secondColumn ], "where" ); + return arguments.builder; + } + + /** + * Adds an EXISTS predicate. + */ + public QueryBuilder function whereExists( + required QueryBuilder builder, + query, + combinator = "and", + negate = false + ) { + arguments.builder.getQueryValidator().validateCombinator( arguments.combinator ); + if ( isClosure( arguments.query ) || isCustomFunction( arguments.query ) ) { + var callback = arguments.query; + arguments.query = arguments.builder.newQuery(); + callback( arguments.query ); + } + return addWhereExistsQuery( + arguments.builder, + arguments.query, + arguments.combinator, + arguments.negate + ); + } + + /** + * Adds a nested WHERE predicate. + */ + public QueryBuilder function whereNested( required QueryBuilder builder, required callback, combinator = "and" ) { + arguments.builder.getQueryValidator().validateCombinator( arguments.combinator ); + var query = forNestedWhere( arguments.builder ); + arguments.callback( query ); + return addNestedWhereQuery( arguments.builder, query, arguments.combinator ); + } + + /** + * Attaches an existing nested WHERE query. + */ + public QueryBuilder function addNestedWhereQuery( + required QueryBuilder builder, + required QueryBuilder query, + string combinator = "and" + ) { + arguments.builder.getQueryValidator().validateCombinator( arguments.combinator ); + if ( !arguments.query.getWheres().isEmpty() ) { + arguments.query = arguments.builder + .getCollaborator( "QueryExecutor" ) + .snapshotBuilder( arguments.builder, arguments.query ); + arguments.builder + .getWheres() + .append( { type: "nested", query: arguments.query, combinator: arguments.combinator } ); + arguments.builder.addBindings( arguments.query.getBindings(), "where" ); + } + return arguments.builder; + } + + /** + * Creates a builder for a nested WHERE predicate. + */ + public QueryBuilder function forNestedWhere( required QueryBuilder builder ) { + return arguments.builder.newQuery().from( arguments.builder.getTableName() ); + } + + /** + * Adds a NULL predicate. + */ + public QueryBuilder function whereNull( + required QueryBuilder builder, + column, + combinator = "and", + negate = false + ) { + arguments.builder.getQueryValidator().validateCombinator( arguments.combinator ); + if ( + isClosure( arguments.column ) || + isCustomFunction( arguments.column ) || + arguments.builder.getUtils().isBuilder( arguments.column ) + ) { + return whereNullSub( + arguments.builder, + arguments.column, + arguments.combinator, + arguments.negate + ); + } + + var type = arguments.negate ? "notNull" : "null"; + var typedColumn = toColumnType( arguments.builder, arguments.column ); + arguments.builder.getWheres().append( { type: type, column: typedColumn, combinator: arguments.combinator } ); + arguments.builder.addColumnBindings( [ typedColumn ], "where" ); + return arguments.builder; + } + + /** + * Adds a NULL predicate against a subquery. + */ + public QueryBuilder function whereNullSub( + required QueryBuilder builder, + query, + combinator = "and", + negate = false + ) { + arguments.builder.getQueryValidator().validateCombinator( arguments.combinator ); + if ( isClosure( arguments.query ) || isCustomFunction( arguments.query ) ) { + var callback = arguments.query; + arguments.query = arguments.builder.newQuery(); + callback( arguments.query ); + } + arguments.query = arguments.builder + .getCollaborator( "QueryExecutor" ) + .snapshotBuilder( arguments.builder, arguments.query ); + + var type = arguments.negate ? "notNullSub" : "nullSub"; + arguments.builder.getWheres().append( { type: type, query: arguments.query, combinator: arguments.combinator } ); + arguments.builder.addBindings( arguments.query.getBindings(), "where" ); + return arguments.builder; + } + + /** + * Adds a BETWEEN predicate. + */ + public QueryBuilder function whereBetween( + required QueryBuilder builder, + column, + start, + end, + combinator = "and", + negate = false + ) { + arguments.builder.getQueryValidator().validateCombinator( arguments.combinator ); + var type = arguments.negate ? "notBetween" : "between"; + var typedColumn = toColumnType( arguments.builder, arguments.column ); + + if ( !isNull( arguments.start ) && ( isClosure( arguments.start ) || isCustomFunction( arguments.start ) ) ) { + var callback = arguments.start; + arguments.start = arguments.builder.newQuery(); + callback( arguments.start ); + } + + if ( !isNull( arguments.end ) && ( isClosure( arguments.end ) || isCustomFunction( arguments.end ) ) ) { + var callback = arguments.end; + arguments.end = arguments.builder.newQuery(); + callback( arguments.end ); + } + + if ( !isNull( arguments.start ) && arguments.builder.getUtils().isBuilder( arguments.start ) ) { + arguments.start = arguments.builder + .getCollaborator( "QueryExecutor" ) + .snapshotBuilder( arguments.builder, arguments.start ); + } + if ( !isNull( arguments.end ) && arguments.builder.getUtils().isBuilder( arguments.end ) ) { + arguments.end = arguments.builder + .getCollaborator( "QueryExecutor" ) + .snapshotBuilder( arguments.builder, arguments.end ); + } + + arguments.builder.addColumnBindings( [ typedColumn ], "where" ); + addPredicateBinding( arguments.builder, arguments.start, "where" ); + addPredicateBinding( arguments.builder, arguments.end, "where" ); + + if ( + !isNull( arguments.start ) && + isStruct( arguments.start ) && + !structKeyExists( arguments.start, "isBuilder" ) && + structKeyExists( arguments.start, "value" ) + ) { + arguments.start = arguments.start.value; + } + + if ( + !isNull( arguments.end ) && + isStruct( arguments.end ) && + !structKeyExists( arguments.end, "isBuilder" ) && + structKeyExists( arguments.end, "value" ) + ) { + arguments.end = arguments.end.value; + } + + arguments.builder + .getWheres() + .append( { + type: type, + column: typedColumn, + start: isNull( arguments.start ) ? javacast( "null", "" ) : arguments.start, + end: isNull( arguments.end ) ? javacast( "null", "" ) : arguments.end, + combinator: arguments.combinator + } ); + return arguments.builder; + } + + /** + * Adds a HAVING predicate. + */ + public QueryBuilder function having( + required QueryBuilder builder, + column, + operator, + value, + string combinator = "and" + ) { + arguments.builder.getQueryValidator().validateCombinator( arguments.combinator ); + + if ( + isNull( arguments.value ) && + isNull( arguments.operator ) && + arguments.builder.getUtils().isExpression( arguments.column ) + ) { + arguments.builder + .getHavings() + .append( { type: "raw", column: arguments.column, combinator: arguments.combinator } ); + arguments.builder.addExpressionBindings( arguments.column, "having" ); + return arguments.builder; + } + + if ( + isNull( arguments.value ) && + arguments.builder.getQueryValidator().isInvalidOperator( arguments.operator ) + ) { + arguments.value = arguments.operator; + arguments.operator = "="; + } else { + arguments.builder.getQueryValidator().validateOperator( arguments.operator ); + } + + arguments.builder + .getHavings() + .append( { + type: "normal", + column: toColumnType( arguments.builder, arguments.column ), + operator: arguments.operator, + value: isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value, + combinator: arguments.combinator + } ); + + if ( arguments.builder.getUtils().isExpression( arguments.column ) ) { + arguments.builder.addExpressionBindings( arguments.column, "having" ); + } + addPredicateBinding( arguments.builder, arguments.value, "having" ); + return arguments.builder; + } + + /** + * Runs a scope and groups newly added OR predicates when needed. + */ + public QueryBuilder function withScoping( required QueryBuilder builder, required function callback ) { + var originalWhereCount = arguments.builder.getWheres().len(); + arguments.callback(); + if ( arguments.builder.getWheres().len() > originalWhereCount ) { + addNewWheresWithinGroup( arguments.builder, originalWhereCount ); + } + return arguments.builder; + } + + /** + * Adds a simple WHERE predicate and its bindings. + */ + private QueryBuilder function whereBasic( + required QueryBuilder builder, + required any column, + required any operator, + any value, + string combinator = "and" + ) { + var typedColumn = toColumnType( arguments.builder, arguments.column ); + arguments.builder + .getWheres() + .append( { + column: typedColumn, + operator: arguments.operator, + value: isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value, + combinator: arguments.combinator, + type: "basic" + } ); + + arguments.builder.addColumnBindings( [ typedColumn ], "where" ); + addPredicateBinding( arguments.builder, arguments.value, "where" ); + return arguments.builder; + } + + /** + * Adds a subquery WHERE predicate. + */ + private QueryBuilder function whereSub( + required QueryBuilder builder, + column, + operator, + query, + combinator = "and" + ) { + if ( isClosure( arguments.query ) || isCustomFunction( arguments.query ) ) { + var callback = arguments.query; + arguments.query = arguments.builder.newQuery(); + callback( arguments.query ); + } + arguments.query = arguments.builder + .getCollaborator( "QueryExecutor" ) + .snapshotBuilder( arguments.builder, arguments.query ); + var typedColumn = toColumnType( arguments.builder, arguments.column ); + arguments.builder + .getWheres() + .append( { + type: "sub", + column: typedColumn, + operator: arguments.operator, + query: arguments.query, + combinator: arguments.combinator + } ); + arguments.builder.addColumnBindings( [ typedColumn ], "where" ); + arguments.builder.addBindings( arguments.query.getBindings(), "where" ); + return arguments.builder; + } + + /** + * Adds a subquery WHERE IN predicate. + */ + private QueryBuilder function whereInSub( + required QueryBuilder builder, + column, + query, + combinator = "and", + negate = false + ) { + if ( isClosure( arguments.query ) || isCustomFunction( arguments.query ) ) { + var callback = arguments.query; + arguments.query = arguments.builder.newQuery(); + callback( arguments.query ); + } + arguments.query = arguments.builder + .getCollaborator( "QueryExecutor" ) + .snapshotBuilder( arguments.builder, arguments.query ); + + var type = arguments.negate ? "notInSub" : "inSub"; + var typedColumn = toColumnType( arguments.builder, arguments.column ); + arguments.builder + .getWheres() + .append( { + type: type, + column: typedColumn, + query: arguments.query, + combinator: arguments.combinator + } ); + arguments.builder.addColumnBindings( [ typedColumn ], "where" ); + arguments.builder.addBindings( arguments.query.getBindings(), "where" ); + return arguments.builder; + } + + /** + * Attaches an EXISTS query. + */ + private QueryBuilder function addWhereExistsQuery( + required QueryBuilder builder, + query, + combinator = "and", + negate = false + ) { + arguments.query = arguments.builder + .getCollaborator( "QueryExecutor" ) + .snapshotBuilder( arguments.builder, arguments.query ); + var type = arguments.negate ? "notExists" : "exists"; + arguments.builder.getWheres().append( { type: type, query: arguments.query, combinator: arguments.combinator } ); + arguments.builder.addBindings( arguments.query.getBindings(), "where" ); + return arguments.builder; + } + + /** + * Adds one expression or scalar binding. + */ + private void function addPredicateBinding( required QueryBuilder builder, any value, required string type ) { + if ( !isNull( arguments.value ) && arguments.builder.getUtils().isExpression( arguments.value ) ) { + arguments.builder.addExpressionBindings( arguments.value, arguments.type ); + } else { + arguments.builder.addBindings( + isNull( arguments.value ) + ? arguments.builder.getUtils().extractBinding( grammar = arguments.builder.getGrammar() ) + : arguments.builder.getUtils().extractBinding( arguments.value, arguments.builder.getGrammar() ), + arguments.type + ); + } + } + + /** + * Formats and classifies a predicate column. + */ + private struct function toColumnType( required QueryBuilder builder, required any column ) { + return arguments.builder.mapToColumnType( arguments.builder.applyColumnFormatter( arguments.column ) ); + } + + /** + * Regroups predicates added by a scope. + */ + private void function addNewWheresWithinGroup( required QueryBuilder builder, required numeric originalWhereCount ) { + var allWheres = arguments.builder.getWheres(); + arguments.builder.setWheres( [] ); + + if ( arguments.originalWhereCount > 0 ) { + groupWhereSliceForScope( arguments.builder, arraySlice( allWheres, 1, arguments.originalWhereCount ) ); + } + + groupWhereSliceForScope( arguments.builder, arraySlice( allWheres, arguments.originalWhereCount + 1 ) ); + } + + /** + * Groups a predicate slice when it contains an OR combinator. + */ + private void function groupWhereSliceForScope( required QueryBuilder builder, required array whereSlice ) { + for ( var where in arguments.whereSlice ) { + if ( compareNoCase( where.combinator, "OR" ) == 0 ) { + addNestedWhereQuery( + arguments.builder, + forNestedWhere( arguments.builder ).setWheres( arguments.whereSlice ) + ); + return; + } + } + var newWheres = arguments.builder.getWheres(); + arrayAppend( newWheres, arguments.whereSlice, true ); + arguments.builder.setWheres( newWheres ); + } + +} diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index be6d7730..9b22cf8f 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -497,6 +497,18 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return getCollaborator( "QueryValidator" ); } + /** + * Returns the lazily instantiated predicate builder. It is cached separately + * because predicate validation remains the only general collaborator involved + * in a basic WHERE clause. + */ + package PredicateClause function getPredicateClause() { + if ( !variables.keyExists( "predicateClause" ) ) { + variables.predicateClause = new qb.models.Query.PredicateClause(); + } + return variables.predicateClause; + } + /** * Resets the query builder instance. * @@ -562,7 +574,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } } - private struct function mapToColumnType( required any column ) { + public struct function mapToColumnType( required any column ) { if ( isSimpleValue( arguments.column ) ) { if ( find( "->", arguments.column ) ) { return jsonPath( column = arguments.column ); @@ -1554,85 +1566,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J value, string combinator = "and" ) { - if ( isClosure( arguments.column ) || isCustomFunction( arguments.column ) ) { - return whereNested( arguments.column, arguments.combinator ); - } - - getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); - - if ( isNull( arguments.value ) && getCollaborator( "QueryValidator" ).isInvalidOperator( arguments.operator ) ) { - arguments.value = arguments.operator; - arguments.operator = "="; - } else { - getCollaborator( "QueryValidator" ).validateOperator( arguments.operator ); - } - - if ( - !isNull( arguments.value ) && ( - isClosure( arguments.value ) || - isCustomFunction( arguments.value ) || - getUtils().isBuilder( arguments.value ) - ) - ) { - return whereSub( - arguments.column, - arguments.operator, - arguments.value, - arguments.combinator - ); - } - - return whereBasic( - arguments.column, - arguments.operator, - isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value, - arguments.combinator - ); - } - - /** - * Adds a WHERE clause to the query. - * - * @column The name of the column with which to constrain the query. A closure can be passed to begin a nested where statement. - * @operator The operator to use for the constraint (i.e. "=", "<", ">=", etc.). A value can be passed as the `operator` and the `value` left null as a shortcut for equals (e.g. where( "column", 1 ) == where( "column", "=", 1 ) ). - * @value The value with which to constrain the column. An expression (`builder.raw()`) can be passed as well. - * @combinator The boolean combinator for the clause (e.g. "and" or "or"). Default: "and" - * - * @return qb.models.Query.QueryBuilder - */ - private QueryBuilder function whereBasic( - required any column, - required any operator, - any value, - string combinator = "and" - ) { - var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); - arrayAppend( - variables.wheres, - { - column: typedColumn, - operator: arguments.operator, - value: isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value, - combinator: arguments.combinator, - type: "basic" - } - ); - - addColumnBindings( [ typedColumn ], "where" ); - - if ( !isNull( arguments.value ) && getUtils().isExpression( arguments.value ) ) { - addExpressionBindings( arguments.value, "where" ); - } else { - addBindings( - utils.extractBinding( - isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value, - variables.grammar - ), - "where" - ); - } - - return this; + arguments.builder = this; + return getPredicateClause().where( argumentCollection = arguments ); } /** @@ -1651,41 +1586,6 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return where( argumentCollection = arguments ); } - /** - * Adds a where clause where the value is a subquery. - * - * @column The name of the column with which to constrain the query. - * @operator The operator to use for the constraint (i.e. "=", "<", ">=", etc.). - * @callback The closure that defines the subquery. A new query will be passed to the closure as the only argument. - * @combinator The boolean combinator for the clause (e.g. "and" or "or"). Default: "and" - * - * @return qb.models.Query.QueryBuilder - */ - private QueryBuilder function whereSub( - column, - operator, - query, - combinator = "and" - ) { - if ( isClosure( arguments.query ) || isCustomFunction( arguments.query ) ) { - var callback = arguments.query; - arguments.query = newQuery(); - callback( arguments.query ); - } - arguments.query = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.query ); - var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); - variables.wheres.append( { - type: "sub", - column: typedColumn, - operator: arguments.operator, - query: arguments.query, - combinator: arguments.combinator - } ); - addColumnBindings( [ typedColumn ], "where" ); - addBindings( query.getBindings(), "where" ); - return this; - } - /** * Adds an OR WHERE clause to the query. * @@ -1716,42 +1616,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J combinator = "and", negate = false ) { - getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); - if ( - isClosure( values ) || - isCustomFunction( values ) || - getUtils().isBuilder( values ) - ) { - arguments.query = arguments.values; - return whereInSub( argumentCollection = arguments ); - } - - arguments.values = normalizeToArray( arguments.values ); - - var type = negate ? "notIn" : "in"; - var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); - variables.wheres.append( { - type: type, - column: typedColumn, - values: arguments.values, - combinator: arguments.combinator - } ); - - var bindings = []; - if ( !arguments.values.isEmpty() ) { - addColumnBindings( [ typedColumn ], "where" ); - } - for ( var value in arguments.values ) { - if ( getUtils().isExpression( value ) ) { - bindings.append( extractExpressionBindings( value ), true ); - } else { - bindings.append( utils.extractBinding( value, variables.grammar ) ); - } - } - - addBindings( bindings, "where" ); - - return this; + arguments.builder = this; + return getPredicateClause().whereIn( argumentCollection = arguments ); } /** @@ -1773,75 +1639,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J string combinator = "and", boolean negate = false ) { - getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); - arguments.values = normalizeToArray( arguments.values ); - - var extractedBindings = []; - if ( !arguments.values.isEmpty() ) { - arrayResize( extractedBindings, arguments.values.len() ); - } - for ( var valueIndex = 1; valueIndex <= arguments.values.len(); valueIndex++ ) { - if ( !arrayIsDefined( arguments.values, valueIndex ) || isNull( arguments.values[ valueIndex ] ) ) { - extractedBindings[ valueIndex ] = getUtils().extractBinding( grammar = variables.grammar ); - continue; - } - if ( getUtils().isExpression( arguments.values[ valueIndex ] ) ) { - throw( type = "InvalidBulkValue", message = "Bulk IN values cannot contain SQL expressions." ); - } - extractedBindings[ valueIndex ] = getUtils().extractBinding( - arguments.values[ valueIndex ], - variables.grammar - ); - } - - if ( isNull( arguments.sqlType ) ) { - arguments.sqlType = variables.grammar.resolveWhereInBulkSqlType( - getUtils().inferSqlType( arguments.values, variables.grammar ) - ); - } - - arguments.sqlType = trim( arguments.sqlType ); - - if ( - arguments.sqlType == "" || - !reFindNoCase( - "^[a-z][a-z0-9_]*(?:\s+[a-z][a-z0-9_]*)*(?:\s*\(\s*(?:max|\d+)(?:\s*,\s*\d+)?\s*\))?$", - arguments.sqlType - ) - ) { - throw( - type = "InvalidSQLType", - message = "Invalid SQL type [#arguments.sqlType#] for a bulk IN statement." - ); - } - - var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); - variables.wheres.append( { - type: "inBulk", - column: typedColumn, - sqlType: arguments.sqlType, - isEmpty: arguments.values.isEmpty(), - negate: arguments.negate, - combinator: arguments.combinator - } ); - - if ( !arguments.values.isEmpty() ) { - addColumnBindings( [ typedColumn ], "where" ); - var serializedValues = extractedBindings.map( function( binding ) { - return binding.null ? javacast( "null", "" ) : binding.value; - } ); - addBindings( - [ - getUtils().extractBinding( - { value: serializeJSON( serializedValues ), cfsqltype: "LONGVARCHAR" }, - variables.grammar - ) - ], - "where" - ); - } - - return this; + arguments.builder = this; + return getPredicateClause().whereInBulk( argumentCollection = arguments ); } /** @@ -1864,43 +1663,6 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return whereInBulk( argumentCollection = arguments ); } - /** - * Adds a WHERE IN clause to the query using a subselect. To call this using the public api, pass a closure to `whereIn` as the second argument (`values`). - * - * @column The name of the column with which to constrain the query. - * @callback A closure that will contain the subquery with which to constain this clause. - * @combinator The boolean combinator for the clause (e.g. "and" or "or"). Default: "and" - * @negate False for IN, True for NOT IN. Default: false. - * - * @return qb.models.Query.QueryBuilder - */ - private QueryBuilder function whereInSub( - column, - query, - combinator = "and", - negate = false - ) { - if ( isClosure( arguments.query ) || isCustomFunction( arguments.query ) ) { - var callback = arguments.query; - arguments.query = newQuery(); - callback( arguments.query ); - } - arguments.query = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.query ); - - var type = negate ? "notInSub" : "inSub"; - var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); - variables.wheres.append( { - type: type, - column: typedColumn, - query: arguments.query, - combinator: arguments.combinator - } ); - addColumnBindings( [ typedColumn ], "where" ); - addBindings( arguments.query.getBindings(), "where" ); - - return this; - } - /** * Adds a WHERE NOT IN clause to the query. * @@ -1925,15 +1687,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereRaw( required string sql, array whereBindings = [], string combinator = "and" ) { - getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); - addBindings( - whereBindings.map( function( binding ) { - return utils.extractBinding( binding, variables.grammar ); - } ), - "where" - ); - variables.wheres.append( { type: "raw", sql: sql, combinator: arguments.combinator } ); - return this; + arguments.builder = this; + return getPredicateClause().whereRaw( argumentCollection = arguments ); } /** @@ -1952,39 +1707,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J second, string combinator = "and" ) { - getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); - if ( isNull( arguments.second ) ) { - arguments.second = arguments.operator; - arguments.operator = "="; - } - - getCollaborator( "QueryValidator" ).validateOperator( arguments.operator ); - - if ( - isClosure( arguments.second ) || - isCustomFunction( arguments.second ) || - getUtils().isBuilder( arguments.second ) - ) { - return whereSub( - arguments.first, - arguments.operator, - arguments.second, - arguments.combinator - ); - } - - var firstColumn = mapToColumnType( applyColumnFormatter( arguments.first ) ); - var secondColumn = mapToColumnType( applyColumnFormatter( arguments.second ) ); - variables.wheres.append( { - type: "column", - first: firstColumn, - operator: arguments.operator, - second: secondColumn, - combinator: arguments.combinator - } ); - addColumnBindings( [ firstColumn, secondColumn ], "where" ); - - return this; + arguments.builder = this; + return getPredicateClause().whereColumn( argumentCollection = arguments ); } /** @@ -1997,30 +1721,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereExists( query, combinator = "and", negate = false ) { - getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); - if ( isClosure( arguments.query ) || isCustomFunction( arguments.query ) ) { - var callback = arguments.query; - arguments.query = newQuery(); - callback( arguments.query ); - } - return addWhereExistsQuery( arguments.query, arguments.combinator, arguments.negate ); - } - - /** - * Adds a WHERE EXISTS clause to the query. - * - * @query The EXISTS query to add as a constraint. - * @combinator The boolean combinator for the clause (e.g. "and" or "or"). Default: "and" - * @negate False for EXISTS, True for NOT EXISTS. Default: false. - * - * @return qb.models.Query.QueryBuilder - */ - private QueryBuilder function addWhereExistsQuery( query, combinator = "and", negate = false ) { - arguments.query = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.query ); - var type = negate ? "notExists" : "exists"; - variables.wheres.append( { type: type, query: arguments.query, combinator: arguments.combinator } ); - addBindings( query.getBindings(), "where" ); - return this; + arguments.builder = this; + return getPredicateClause().whereExists( argumentCollection = arguments ); } /** @@ -2046,10 +1748,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereNested( required callback, combinator = "and" ) { - getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); - var query = forNestedWhere(); - callback( query ); - return addNestedWhereQuery( query, combinator ); + arguments.builder = this; + return getPredicateClause().whereNested( argumentCollection = arguments ); } /** @@ -2061,13 +1761,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function addNestedWhereQuery( required QueryBuilder query, string combinator = "and" ) { - getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); - if ( !query.getWheres().isEmpty() ) { - arguments.query = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.query ); - variables.wheres.append( { type: "nested", query: arguments.query, combinator: arguments.combinator } ); - addBindings( query.getBindings(), "where" ); - } - return this; + arguments.builder = this; + return getPredicateClause().addNestedWhereQuery( argumentCollection = arguments ); } /** @@ -2076,8 +1771,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function forNestedWhere() { - var query = newQuery(); - return query.from( getTableName() ); + return getPredicateClause().forNestedWhere( this ); } /** @@ -2090,20 +1784,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereNull( column, combinator = "and", negate = false ) { - getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); - if ( - isClosure( arguments.column ) || - isCustomFunction( arguments.column ) || - getUtils().isBuilder( arguments.column ) - ) { - return whereNullSub( arguments.column, arguments.combinator, arguments.negate ); - } - - var type = negate ? "notNull" : "null"; - var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); - variables.wheres.append( { type: type, column: typedColumn, combinator: arguments.combinator } ); - addColumnBindings( [ typedColumn ], "where" ); - return this; + arguments.builder = this; + return getPredicateClause().whereNull( argumentCollection = arguments ); } /** @@ -2116,19 +1798,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereNullSub( query, combinator = "and", negate = false ) { - getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); - if ( isClosure( arguments.query ) || isCustomFunction( arguments.query ) ) { - var callback = arguments.query; - arguments.query = newQuery(); - callback( arguments.query ); - } - arguments.query = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.query ); - - var type = arguments.negate ? "notNullSub" : "nullSub"; - variables.wheres.append( { type: type, query: arguments.query, combinator: arguments.combinator } ); - addBindings( arguments.query.getBindings(), "where" ); - - return this; + arguments.builder = this; + return getPredicateClause().whereNullSub( argumentCollection = arguments ); } /** @@ -2162,79 +1833,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J combinator = "and", negate = false ) { - getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); - var type = negate ? "notBetween" : "between"; - var typedColumn = mapToColumnType( applyColumnFormatter( arguments.column ) ); - - if ( !isNull( arguments.start ) && ( isClosure( arguments.start ) || isCustomFunction( arguments.start ) ) ) { - var callback = arguments.start; - arguments.start = newQuery(); - callback( arguments.start ); - } - - if ( !isNull( arguments.end ) && ( isClosure( arguments.end ) || isCustomFunction( arguments.end ) ) ) { - var callback = arguments.end; - arguments.end = newQuery(); - callback( arguments.end ); - } - - if ( !isNull( arguments.start ) && getUtils().isBuilder( arguments.start ) ) { - arguments.start = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.start ); - } - if ( !isNull( arguments.end ) && getUtils().isBuilder( arguments.end ) ) { - arguments.end = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.end ); - } - - addColumnBindings( [ typedColumn ], "where" ); - if ( !isNull( arguments.start ) && utils.isExpression( arguments.start ) ) { - addExpressionBindings( arguments.start, "where" ); - } else { - addBindings( - isNull( arguments.start ) - ? utils.extractBinding( grammar = variables.grammar ) - : utils.extractBinding( arguments.start, variables.grammar ), - "where" - ); - } - if ( !isNull( arguments.end ) && utils.isExpression( arguments.end ) ) { - addExpressionBindings( arguments.end, "where" ); - } else { - addBindings( - isNull( arguments.end ) - ? utils.extractBinding( grammar = variables.grammar ) - : utils.extractBinding( arguments.end, variables.grammar ), - "where" - ); - } - - if ( - !isNull( arguments.start ) && isStruct( arguments.start ) && !structKeyExists( - arguments.start, - "isBuilder" - ) && structKeyExists( arguments.start, "value" ) - ) { - arguments.start = arguments.start.value; - } - - if ( - !isNull( arguments.end ) && isStruct( arguments.end ) && !structKeyExists( arguments.end, "isBuilder" ) && structKeyExists( - arguments.end, - "value" - ) - ) { - arguments.end = arguments.end.value; - } - - variables.wheres.append( { - type: type, - column: typedColumn, - start: isNull( arguments.start ) ? javacast( "null", "" ) : arguments.start, - end: isNull( arguments.end ) ? javacast( "null", "" ) : arguments.end, - combinator: arguments.combinator - } ); - - - return this; + arguments.builder = this; + return getPredicateClause().whereBetween( argumentCollection = arguments ); } /** @@ -2320,72 +1920,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J value, string combinator = "and" ) { - getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); - - if ( - isNull( arguments.value ) && - isNull( arguments.operator ) && - getUtils().isExpression( arguments.column ) - ) { - arrayAppend( - variables.havings, - { type: "raw", column: arguments.column, combinator: arguments.combinator } - ); - addBindings( - arguments.column - .getBindings() - .map( function( binding ) { - return utils.extractBinding( binding, variables.grammar ); - } ), - "having" - ); - return this; - } - - if ( - isNull( arguments.value ) && - getCollaborator( "QueryValidator" ).isInvalidOperator( arguments.operator ) - ) { - arguments.value = arguments.operator; - arguments.operator = "="; - } else { - getCollaborator( "QueryValidator" ).validateOperator( arguments.operator ); - } - - arrayAppend( - variables.havings, - { - type: "normal", - column: mapToColumnType( applyColumnFormatter( arguments.column ) ), - operator: arguments.operator, - value: isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value, - combinator: arguments.combinator - } - ); - - if ( getUtils().isExpression( arguments.column ) ) { - addBindings( - arguments.column - .getBindings() - .map( function( binding ) { - return utils.extractBinding( binding, variables.grammar ); - } ), - "having" - ); - } - - if ( !isNull( arguments.value ) && getUtils().isExpression( arguments.value ) ) { - addExpressionBindings( arguments.value, "having" ); - } else { - addBindings( - isNull( arguments.value ) - ? utils.extractBinding( grammar = variables.grammar ) - : utils.extractBinding( arguments.value, variables.grammar ), - "having" - ); - } - - return this; + arguments.builder = this; + return getPredicateClause().having( argumentCollection = arguments ); } /** @@ -3014,48 +2550,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function withScoping( required function callback ) { - var originalWhereCount = this.getWheres().len(); - arguments.callback(); - if ( this.getWheres().len() > originalWhereCount ) { - addNewWheresWithinGroup( originalWhereCount ); - } - return this; - } - - /** - * Adds a new nested where clause for the wheres added in a scope. - * It only does this when there is an OR combinator inside the scope. - * - * @originalWhereCount The number of where clauses before the scope was added. - */ - private void function addNewWheresWithinGroup( required numeric originalWhereCount ) { - var allWheres = this.getWheres(); - this.setWheres( [] ); - - if ( arguments.originalWhereCount > 0 ) { - groupWhereSliceForScope( arraySlice( allWheres, 1, arguments.originalWhereCount ) ); - } - - groupWhereSliceForScope( arraySlice( allWheres, arguments.originalWhereCount + 1 ) ); - } - - /** - * Checks if a where slice needs to be grouped in parenthesis. - * It only does this when there is an OR combinator inside the scope. - * - * @whereSlice The array of where clauses to maybe be grouped. - */ - private void function groupWhereSliceForScope( required array whereSlice ) { - var hasOrCombinator = false; - for ( var where in arguments.whereSlice ) { - if ( compareNoCase( where.combinator, "OR" ) == 0 ) { - this.addNestedWhereQuery( this.forNestedWhere().setWheres( arguments.whereSlice ) ); - return; - } - } - var newWheres = this.getWheres(); - arrayAppend( newWheres, arguments.whereSlice, true ); - this.setWheres( newWheres ); + arguments.builder = this; + return getPredicateClause().withScoping( argumentCollection = arguments ); } /** @@ -3769,7 +3265,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J /** * Normalizes the bindings carried by an Expression for query execution. */ - private array function extractExpressionBindings( required any expression ) { + public array function extractExpressionBindings( required any expression ) { return arguments.expression .getBindings() .map( function( binding ) { @@ -3780,7 +3276,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J /** * Adds normalized bindings carried by an Expression to a binding group. */ - private QueryBuilder function addExpressionBindings( required any expression, required string type ) { + public QueryBuilder function addExpressionBindings( required any expression, required string type ) { addBindings( extractExpressionBindings( arguments.expression ), arguments.type ); return this; } @@ -4610,7 +4106,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * * @return array */ - private array function normalizeToArray( required listOrArray ) { + public array function normalizeToArray( required listOrArray ) { if ( isArray( arguments.listOrArray ) ) { return arguments.listOrArray; } From f12710a4a49de3c56ef3cc82af6f813af5c9f68d Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 16 Aug 2026 21:00:02 -0600 Subject: [PATCH 048/119] fix: resolve query and schema regressions --- models/Grammars/BaseGrammar.cfc | 39 ++++++++- models/Grammars/DerbyGrammar.cfc | 4 +- models/Grammars/MySQLGrammar.cfc | 2 +- models/Grammars/OracleGrammar.cfc | 4 +- models/Grammars/SqlServerGrammar.cfc | 10 +-- models/Query/QueryBuilder.cfc | 85 +++++++++++++------ models/Query/QueryExecutor.cfc | 3 + models/Query/QueryUtils.cfc | 56 ++++++------ models/Schema/Blueprint.cfc | 12 +-- models/Schema/Column.cfc | 4 +- models/Schema/SchemaBuilder.cfc | 6 +- models/Schema/TableIndex.cfc | 14 ++- tests/resources/ConcurrentWrappingGrammar.cfc | 25 ++++++ .../Query/Abstract/QueryExecutionSpec.cfc | 6 ++ tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 29 +++++++ .../specs/Query/PostgresQueryBuilderSpec.cfc | 23 +++++ tests/specs/Query/ShouldWrapValuesSpec.cfc | 35 ++++++++ .../Schema/PostgresSchemaBuilderSpec.cfc | 22 +++++ .../Schema/SqlServerSchemaBuilderSpec.cfc | 16 ++++ 19 files changed, 320 insertions(+), 75 deletions(-) create mode 100644 tests/resources/ConcurrentWrappingGrammar.cfc diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index e4922f78..4c87dd19 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -74,6 +74,7 @@ component displayname="Grammar" accessors="true" singleton { variables.tableAliasOperator = " AS "; variables.cteColumnsRequireParentheses = false; variables.shouldWrapValues = true; + variables.shouldWrapValuesContext = createObject( "java", "java.lang.ThreadLocal" ).init(); // These are overwritten by WireBox, if it exists. variables.interceptorService = { "processState": function() { @@ -1633,7 +1634,7 @@ component displayname="Grammar" accessors="true" singleton { * @return string */ function wrapValue( required any value ) { - if ( !variables.shouldWrapValues ) { + if ( !getShouldWrapValues() ) { return arguments.value; } @@ -2501,6 +2502,10 @@ component displayname="Grammar" accessors="true" singleton { } function getShouldWrapValues() { + var context = variables.shouldWrapValuesContext.get(); + if ( !isNull( context ) && !context.isEmpty() ) { + return context.last(); + } if ( isNull( variables.shouldWrapValues ) ) { throw( type = "InvalidState", message = "The shouldWrapValues property has not been set." ); } @@ -2511,8 +2516,38 @@ component displayname="Grammar" accessors="true" singleton { if ( isNull( arguments.shouldWrap ) ) { throw( type = "InvalidState", message = "The shouldWrapValues property has not been set." ); } - variables.shouldWrapValues = arguments.shouldWrap; + var context = variables.shouldWrapValuesContext.get(); + if ( !isNull( context ) && !context.isEmpty() ) { + context[ context.len() ] = arguments.shouldWrap; + } else { + variables.shouldWrapValues = arguments.shouldWrap; + } return this; } + /** + * Runs a compiler callback with a wrapping preference isolated to the current thread. + * Nested compiler calls restore the previous preference when they complete. + * + * @shouldWrap The wrapping preference, or null to use the current grammar preference. + * @callback The compiler callback to run. + */ + public any function withShouldWrapValuesContext( any shouldWrap, required function callback ) { + var context = variables.shouldWrapValuesContext.get(); + if ( isNull( context ) ) { + context = []; + variables.shouldWrapValuesContext.set( context ); + } + + context.append( isNull( arguments.shouldWrap ) ? getShouldWrapValues() : arguments.shouldWrap ); + try { + return arguments.callback(); + } finally { + context.deleteAt( context.len() ); + if ( context.isEmpty() ) { + variables.shouldWrapValuesContext.remove(); + } + } + } + } diff --git a/models/Grammars/DerbyGrammar.cfc b/models/Grammars/DerbyGrammar.cfc index 8f8fc03e..67c3bdab 100644 --- a/models/Grammars/DerbyGrammar.cfc +++ b/models/Grammars/DerbyGrammar.cfc @@ -372,7 +372,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { * @return string */ function wrapValue( required any value ) { - if ( !variables.shouldWrapValues ) { + if ( !getShouldWrapValues() ) { return arguments.value; } @@ -627,7 +627,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { function typeEnum( column ) { blueprint.appendIndex( type = "check", - name = "enum_#blueprint.getTable()#_#column.getName()#", + name = "enum_#listLast( blueprint.getTable(), "." )#_#column.getName()#", columns = column ); return "VARCHAR(255)"; diff --git a/models/Grammars/MySQLGrammar.cfc b/models/Grammars/MySQLGrammar.cfc index 88d92168..18c5ebd5 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -69,7 +69,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { * @return string */ function wrapValue( required any value ) { - if ( !variables.shouldWrapValues ) { + if ( !getShouldWrapValues() ) { return arguments.value; } diff --git a/models/Grammars/OracleGrammar.cfc b/models/Grammars/OracleGrammar.cfc index 9d67e069..25c268c3 100644 --- a/models/Grammars/OracleGrammar.cfc +++ b/models/Grammars/OracleGrammar.cfc @@ -411,7 +411,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { * @return string */ function wrapValue( required any value ) { - if ( !variables.shouldWrapValues ) { + if ( !getShouldWrapValues() ) { return arguments.value; } @@ -706,7 +706,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { function typeEnum( column ) { blueprint.appendIndex( type = "check", - name = "enum_#blueprint.getTable()#_#column.getName()#", + name = "enum_#listLast( blueprint.getTable(), "." )#_#column.getName()#", columns = column ); return "VARCHAR2(255)"; diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index af881caf..a0b3730a 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -528,7 +528,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { * @return string */ function wrapValue( required any value ) { - if ( !variables.shouldWrapValues ) { + if ( !getShouldWrapValues() ) { return arguments.value; } @@ -846,7 +846,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } function generateDefault( column, blueprint ) { - return column.getHasDefaultValue() ? "CONSTRAINT #wrapValue( "df_#blueprint.getTable()#_#column.getName()#" )# DEFAULT #wrapDefaultType( column )#" : ""; + return column.getHasDefaultValue() ? "CONSTRAINT #wrapValue( "df_#listLast( blueprint.getTable(), "." )#_#column.getName()#" )# DEFAULT #wrapDefaultType( column )#" : ""; } function wrapDefaultType( column ) { @@ -1044,7 +1044,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { ]; if ( commandParameters.name.getHasDefaultValue() ) { statements.prepend( - "ALTER TABLE #wrapTable( blueprint.getTable() )# DROP CONSTRAINT #wrapValue( "df_#blueprint.getTable()#_#commandParameters.name.getName()#" )#" + "ALTER TABLE #wrapTable( blueprint.getTable() )# DROP CONSTRAINT #wrapValue( "df_#listLast( blueprint.getTable(), "." )#_#commandParameters.name.getName()#" )#" ); } return statements; @@ -1182,7 +1182,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { "ALTER TABLE", wrappedTable, "ADD CONSTRAINT", - wrapValue( "df_#blueprint.getTable()#_#commandParameters.to.getName()#" ), + wrapValue( "df_#listLast( blueprint.getTable(), "." )#_#commandParameters.to.getName()#" ), "DEFAULT", wrapDefaultType( commandParameters.to ), "FOR", @@ -1289,7 +1289,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { function typeEnum( column, blueprint ) { blueprint.appendIndex( type = "check", - name = "enum_#blueprint.getTable()#_#column.getName()#", + name = "enum_#listLast( blueprint.getTable(), "." )#_#column.getName()#", columns = column ); return "NVARCHAR(255)"; diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 9b22cf8f..68bbad74 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -1485,6 +1485,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return ( !getUtils().arrayCompare( cT[ "COLUMNS" ], otherQB.getCommonTables()[ index ][ "COLUMNS" ] ) || !getUtils().structCompare( cT[ "NAME" ], otherQB.getCommonTables()[ index ][ "NAME" ] ) || + cT[ "RECURSIVE" ] != otherQB.getCommonTables()[ index ][ "RECURSIVE" ] || !cT[ "QUERY" ].isEqualTo( otherQB.getCommonTables()[ index ][ "QUERY" ] ) ); } ) @@ -1517,6 +1518,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J }; if ( !isJoin() ) { + memento[ "alias" ] = variables.alias; if ( !isCustomFunction( variables.tableName ) ) { if ( getUtils().isExpression( getTableName() ) ) { memento[ "from" ] = getTableName().getSQL(); @@ -2628,7 +2630,9 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J c.formatted = mapToColumnType( c.formatted ); } ); - var sql = getGrammar().compileInsert( this, columns, newBindings ); + var sql = withWrappingContext( function() { + return getGrammar().compileInsert( this, columns, newBindings ); + } ); clearBindings( except = "insert" ); @@ -2685,7 +2689,9 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J if ( getGrammar().supportsBulkInsert() ) { var bulkInsert = getGrammar().prepareBulkInsert( this, batch, arguments.sqlTypes ); addBindings( [ bulkInsert.binding ], "insert" ); - var sql = getGrammar().compileBulkInsert( this, bulkInsert.columns ); + var sql = withWrappingContext( function() { + return getGrammar().compileBulkInsert( this, bulkInsert.columns ); + } ); if ( arguments.toSql ) { results.append( sql ); } else { @@ -2748,7 +2754,10 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J c.formatted = mapToColumnType( c.formatted ); } ); - var sql = getGrammar().compileInsertUsing( this, formattedColumns, arguments.source ); + var source = arguments.source; + var sql = withWrappingContext( function() { + return getGrammar().compileInsertUsing( this, formattedColumns, source ); + } ); if ( toSql ) { return sql; @@ -2828,12 +2837,15 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J c.formatted = mapToColumnType( c.formatted ); } ); - var sql = getGrammar().compileInsertIgnore( - this, - columns, - arguments.target, - newBindings - ); + var targetColumns = arguments.target; + var sql = withWrappingContext( function() { + return getGrammar().compileInsertIgnore( + this, + columns, + targetColumns, + newBindings + ); + } ); clearBindings( except = "insert" ); @@ -2914,7 +2926,10 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J c.formatted = mapToColumnType( c.formatted ); } ); - var sql = getGrammar().compileUpdate( this, updateArray, arguments.values ); + var updateValues = arguments.values; + var sql = withWrappingContext( function() { + return getGrammar().compileUpdate( this, updateArray, updateValues ); + } ); if ( toSql ) { return sql; @@ -3012,6 +3027,14 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } if ( !isNull( arguments.update ) && arguments.update.isEmpty() ) { + if ( !isNull( arguments.source ) ) { + return this.insertUsing( + columns = arguments.values, + source = arguments.source, + options = arguments.options, + toSql = arguments.toSql + ); + } return this.insert( values = arguments.values, options = arguments.options, toSql = arguments.toSql ); } @@ -3134,17 +3157,20 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J c.formatted = mapToColumnType( c.formatted ); } ); - var sql = getGrammar().compileUpsert( - this, - columns, - newInsertBindings, - updateArray, - arguments.update, - arguments.target, - isNull( arguments.source ) ? javacast( "null", "" ) : arguments.source, - arguments.deleteUnmatched, - arguments.matchNulls - ); + var upsertArguments = arguments; + var sql = withWrappingContext( function() { + return getGrammar().compileUpsert( + this, + columns, + newInsertBindings, + updateArray, + upsertArguments.update, + upsertArguments.target, + isNull( upsertArguments.source ) ? javacast( "null", "" ) : upsertArguments.source, + upsertArguments.deleteUnmatched, + upsertArguments.matchNulls + ); + } ); if ( toSql ) { return sql; @@ -3176,7 +3202,9 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J where( arguments.idColumnName, "=", arguments.id ); } - var sql = getGrammar().compileDelete( this ); + var sql = withWrappingContext( function() { + return getGrammar().compileDelete( this ); + } ); if ( toSql ) { return sql; @@ -3513,8 +3541,11 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J .prepareInternalExecutionBuilder( this, newQuery() ) .clearFrom(); getCollaborator( "QueryExecutor" ).hoistNestedCommonTables( existsSource, existsQuery ); + var existsSql = withWrappingContext( function() { + return getGrammar().compileSelect( existsSource ); + } ); existsQuery.selectRaw( - "CASE WHEN EXISTS (#getGrammar().compileSelect( existsSource )#) THEN 1 ELSE 0 END AS aggregate", + "CASE WHEN EXISTS (#existsSql#) THEN 1 ELSE 0 END AS aggregate", existsSource.getBindings() ); if ( arguments.toSQL ) { @@ -3972,6 +4003,10 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J ); } + private any function withWrappingContext( required function callback ) { + return getGrammar().withShouldWrapValuesContext( getShouldWrapValues(), arguments.callback ); + } + /** * Returns the Builder compiled to grammar-specific sql. * @@ -3981,7 +4016,9 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J if ( getValidateDuplicateSelectColumns() && getAggregate().isEmpty() ) { getCollaborator( "QueryValidator" ).validateUniqueSelectColumns( getColumns(), getGrammar() ); } - var sql = grammar.compileSelect( this ); + var sql = withWrappingContext( function() { + return grammar.compileSelect( this ); + } ); if ( isBoolean( arguments.showBindings ) && arguments.showBindings == false ) { return sql; diff --git a/models/Query/QueryExecutor.cfc b/models/Query/QueryExecutor.cfc index 6cef02e3..429d5b48 100644 --- a/models/Query/QueryExecutor.cfc +++ b/models/Query/QueryExecutor.cfc @@ -100,6 +100,9 @@ component { public QueryBuilder function cloneBuilder( required QueryBuilder builder ) { var clonedQuery = arguments.builder.newQuery(); copyQueryState( arguments.builder, clonedQuery ); + if ( arguments.builder.isPretending() ) { + clonedQuery.pretend(); + } return clonedQuery; } diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 88efb179..55024e77 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -923,25 +923,8 @@ component singleton displayname="QueryUtils" accessors="true" { } continue; } - // Key is a structure, call structCompare() - else if ( isStruct( arguments.LeftStruct[ key ] ) ) { - local.result = structCompare( arguments.LeftStruct[ key ], arguments.RightStruct[ key ] ); - if ( !local.result ) { - return false; - } - } - // Key is an array, call arrayCompare() - else if ( isArray( arguments.LeftStruct[ key ] ) ) { - local.result = arrayCompare( arguments.LeftStruct[ key ], arguments.RightStruct[ key ] ); - if ( !local.result ) { - return false; - } - } - // A simple type comparison here - else { - if ( arguments.LeftStruct[ key ] != arguments.RightStruct[ key ] ) { - return false; - } + if ( !compareValues( arguments.LeftStruct[ key ], arguments.RightStruct[ key ] ) ) { + return false; } } return true; @@ -988,23 +971,36 @@ component singleton displayname="QueryUtils" accessors="true" { continue; } - // elements is a structure, call structCompare() - if ( isStruct( arguments.LeftArray[ i ] ) ) { - local.result = structCompare( arguments.LeftArray[ i ], arguments.RightArray[ i ] ); - if ( !local.result ) return false; - // elements is an array, call arrayCompare() - } else if ( isArray( arguments.LeftArray[ i ] ) ) { - local.result = arrayCompare( arguments.LeftArray[ i ], arguments.RightArray[ i ] ); - if ( !local.result ) return false; - // A simple type comparison here - } else { - if ( arguments.LeftArray[ i ] != arguments.RightArray[ i ] ) return false; + if ( !compareValues( arguments.LeftArray[ i ], arguments.RightArray[ i ] ) ) { + return false; } } return true; } + private boolean function compareValues( required any left, required any right ) { + if ( isExpression( arguments.left ) || isExpression( arguments.right ) ) { + if ( !isExpression( arguments.left ) || !isExpression( arguments.right ) ) { + return false; + } + return arguments.left.getSQL() == arguments.right.getSQL() && + arrayCompare( arguments.left.getBindings(), arguments.right.getBindings() ); + } + + if ( isStruct( arguments.left ) || isStruct( arguments.right ) ) { + return isStruct( arguments.left ) && isStruct( arguments.right ) && + structCompare( arguments.left, arguments.right ); + } + + if ( isArray( arguments.left ) || isArray( arguments.right ) ) { + return isArray( arguments.left ) && isArray( arguments.right ) && + arrayCompare( arguments.left, arguments.right ); + } + + return arguments.left == arguments.right; + } + public string function serializeBindings( required array bindings, required any grammar ) { return serializeJSON( arguments.bindings.map( function( binding ) { diff --git a/models/Schema/Blueprint.cfc b/models/Schema/Blueprint.cfc index b41b9189..62d07501 100644 --- a/models/Schema/Blueprint.cfc +++ b/models/Schema/Blueprint.cfc @@ -589,11 +589,13 @@ component accessors="true" { // we use a for loop here because we can potentially modify this array while looping over it. for ( var i = 1; i <= variables.commands.len(); i++ ) { var command = variables.commands[ i ]; - var result = invoke( - getGrammar(), - "compile#command.getType()#", - { blueprint: this, commandParameters: command.getParameters() } - ); + var result = getGrammar().withShouldWrapValuesContext( getSchemaBuilder().getShouldWrapValues(), function() { + return invoke( + getGrammar(), + "compile#command.getType()#", + { blueprint: this, commandParameters: command.getParameters() } + ); + } ); if ( isArray( result ) ) { statements.append( result, true ); } else if ( isSimpleValue( result ) && result != "" ) { diff --git a/models/Schema/Column.cfc b/models/Schema/Column.cfc index 373d1cce..ed09d9da 100644 --- a/models/Schema/Column.cfc +++ b/models/Schema/Column.cfc @@ -165,7 +165,7 @@ component accessors="true" { * @returns The TableIndex instance created. */ public TableIndex function primaryKey( string indexName ) { - param arguments.indexName = "pk_#getBlueprint().getTable()#_#getName()#"; + param arguments.indexName = "pk_#listLast( getBlueprint().getTable(), "." )#_#getName()#"; return getBlueprint().appendIndex( type = "primary", columns = getName(), name = arguments.indexName ); } @@ -183,7 +183,7 @@ component accessors="true" { type = "foreign", columns = [ column ], foreignKey = [ getName() ], - name = "fk_#getBlueprint().getTable()#_#getName()#" + name = "fk_#listLast( getBlueprint().getTable(), "." )#_#getName()#" ); } diff --git a/models/Schema/SchemaBuilder.cfc b/models/Schema/SchemaBuilder.cfc index 5a319791..a4ac6d57 100644 --- a/models/Schema/SchemaBuilder.cfc +++ b/models/Schema/SchemaBuilder.cfc @@ -585,7 +585,11 @@ component accessors="true" { if ( variables.pretending ) { return []; } - var statements = getGrammar().compileDropAllObjects( arguments.options, arguments.schema, this ); + var dropOptions = arguments.options; + var dropSchema = arguments.schema; + var statements = getGrammar().withShouldWrapValuesContext( getShouldWrapValues(), function() { + return getGrammar().compileDropAllObjects( dropOptions, dropSchema, this ); + } ); if ( arguments.execute ) { statements.each( function( statement ) { getGrammar().runQuery( diff --git a/models/Schema/TableIndex.cfc b/models/Schema/TableIndex.cfc index 27c54c81..886ab439 100644 --- a/models/Schema/TableIndex.cfc +++ b/models/Schema/TableIndex.cfc @@ -3,6 +3,8 @@ */ component accessors="true" { + property name="blueprint"; + /** * The constraint type. */ @@ -57,7 +59,10 @@ component accessors="true" { * * @returns A TableIndex instance. */ - public TableIndex function init() { + public TableIndex function init( Blueprint blueprint ) { + if ( !isNull( arguments.blueprint ) ) { + setBlueprint( arguments.blueprint ); + } variables.columns = []; return this; } @@ -96,6 +101,13 @@ component accessors="true" { * @returns The TableIndex instance. */ public TableIndex function onTable( required string table ) { + if ( + listLen( arguments.table, "." ) == 1 && + !isNull( getBlueprint() ) && + getBlueprint().getDefaultSchema() != "" + ) { + arguments.table = "#getBlueprint().getDefaultSchema()#.#arguments.table#"; + } setTable( arguments.table ); return this; } diff --git a/tests/resources/ConcurrentWrappingGrammar.cfc b/tests/resources/ConcurrentWrappingGrammar.cfc new file mode 100644 index 00000000..607c18f7 --- /dev/null +++ b/tests/resources/ConcurrentWrappingGrammar.cfc @@ -0,0 +1,25 @@ +component extends="qb.models.Grammars.PostgresGrammar" { + + variables.unwrappedEntered = createObject( "java", "java.util.concurrent.CountDownLatch" ).init( 1 ); + variables.wrappedEntered = createObject( "java", "java.util.concurrent.CountDownLatch" ).init( 1 ); + variables.unwrappedRead = createObject( "java", "java.util.concurrent.CountDownLatch" ).init( 1 ); + + function wrapValue( required any value ) { + if ( arguments.value == "unwrapped_column" ) { + variables.unwrappedEntered.countDown(); + variables.wrappedEntered.await(); + var result = super.wrapValue( arguments.value ); + variables.unwrappedRead.countDown(); + return result; + } + + if ( arguments.value == "wrapped_column" ) { + variables.unwrappedEntered.await(); + variables.wrappedEntered.countDown(); + variables.unwrappedRead.await(); + } + + return super.wrapValue( arguments.value ); + } + +} diff --git a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc index 749c470c..b9896138 100644 --- a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc +++ b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc @@ -1577,6 +1577,12 @@ component extends="testbox.system.BaseSpec" { } ); } ); + it( "preserves pretend mode when cloning a builder", function() { + var builder = getBuilder().pretend(); + + expect( builder.clone().isPretending() ).toBeTrue(); + } ); + it( "carries a resolved struct return formatter to new queries and clones", function() { var registry = new qb.models.Query.ReturnFormatterRegistry(); registry.registerReturnFormatter( "structFormatter", function() { diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index 22a3b266..ee19b72d 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -696,6 +696,35 @@ component extends="testbox.system.BaseSpec" { expect( first.isEqualTo( second ) ).toBeTrue(); } ); + + it( "distinguishes table aliases", function() { + var first = new qb.models.Query.QueryBuilder().from( "users AS first_user" ); + var second = new qb.models.Query.QueryBuilder().from( "users AS second_user" ); + + expect( first.isEqualTo( second ) ).toBeFalse(); + } ); + + it( "distinguishes recursive common table expressions", function() { + var recursive = new qb.models.Query.QueryBuilder().withRecursive( "numbers", function( q ) { + q.select( "id" ).from( "numbers" ); + } ); + var nonRecursive = new qb.models.Query.QueryBuilder().with( "numbers", function( q ) { + q.select( "id" ).from( "numbers" ); + } ); + + expect( recursive.isEqualTo( nonRecursive ) ).toBeFalse(); + } ); + + it( "compares raw expressions without throwing", function() { + var first = new qb.models.Query.QueryBuilder().selectRaw( "? AS id", [ 1 ] ); + var equivalent = new qb.models.Query.QueryBuilder().selectRaw( "? AS id", [ 1 ] ); + var differentBinding = new qb.models.Query.QueryBuilder().selectRaw( "? AS id", [ 2 ] ); + var differentSql = new qb.models.Query.QueryBuilder().selectRaw( "? AS user_id", [ 1 ] ); + + expect( first.isEqualTo( equivalent ) ).toBeTrue(); + expect( first.isEqualTo( differentBinding ) ).toBeFalse(); + expect( first.isEqualTo( differentSql ) ).toBeFalse(); + } ); } ); describe( "aggregate state", function() { diff --git a/tests/specs/Query/PostgresQueryBuilderSpec.cfc b/tests/specs/Query/PostgresQueryBuilderSpec.cfc index 774c7a3b..4c821cc1 100644 --- a/tests/specs/Query/PostgresQueryBuilderSpec.cfc +++ b/tests/specs/Query/PostgresQueryBuilderSpec.cfc @@ -72,6 +72,29 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ false, 42 ] ); } ); } ); + + describe( "PostgreSQL source upserts", function() { + it( "falls back to insert using when the update list is explicitly empty", function() { + var source = getBuilder() + .select( [ "id", "email" ] ) + .from( "incoming_users" ) + .where( "active", true ); + + var destination = getBuilder().from( "users" ); + var sql = destination.upsert( + source = source, + values = [ "id", "email" ], + target = [ "id" ], + update = [], + toSql = true + ); + + expect( sql ).toBeWithCase( + "INSERT INTO ""users"" (""id"", ""email"") SELECT ""id"", ""email"" FROM ""incoming_users"" WHERE ""active"" = ?" + ); + expect( getTestBindings( destination ) ).toBe( [ true ] ); + } ); + } ); } function selectAllColumns() { diff --git a/tests/specs/Query/ShouldWrapValuesSpec.cfc b/tests/specs/Query/ShouldWrapValuesSpec.cfc index eccfcdf6..ed2bf39b 100644 --- a/tests/specs/Query/ShouldWrapValuesSpec.cfc +++ b/tests/specs/Query/ShouldWrapValuesSpec.cfc @@ -96,6 +96,41 @@ component extends="testbox.system.BaseSpec" { expect( builder.newQuery().getShouldWrapValues() ).toBeFalse(); expect( builder.clone().toSQL() ).toBe( "SELECT id FROM users" ); } ); + + it( "isolates per-query wrapping overrides during concurrent compilation", function() { + var grammar = new tests.resources.ConcurrentWrappingGrammar(); + var unwrappedBuilder = new qb.models.Query.QueryBuilder( grammar ) + .withoutWrappingValues() + .select( "unwrapped_column" ) + .from( "users" ); + var wrappedBuilder = new qb.models.Query.QueryBuilder( grammar ) + .withWrappingValues() + .select( "wrapped_column" ) + .from( "users" ); + var unwrappedThreadName = "unwrappedCompilation#replace( createUUID(), "-", "", "all" )#"; + var wrappedThreadName = "wrappedCompilation#replace( createUUID(), "-", "", "all" )#"; + var compilationResults = createObject( "java", "java.util.concurrent.ConcurrentHashMap" ).init(); + + thread name=unwrappedThreadName action="run" builder=unwrappedBuilder results=compilationResults { + attributes.results.put( "unwrapped", attributes.builder.toSQL() ); + } + thread name=wrappedThreadName action="run" builder=wrappedBuilder results=compilationResults { + attributes.results.put( "wrapped", attributes.builder.toSQL() ); + } + thread action="join" name="#unwrappedThreadName#,#wrappedThreadName#" timeout="10000"; + + if ( + cfthread[ unwrappedThreadName ].status != "COMPLETED" || + cfthread[ wrappedThreadName ].status != "COMPLETED" + ) { + throw( + message = "Concurrent compilation did not complete", + detail = serializeJSON( { "unwrapped": cfthread[ unwrappedThreadName ], "wrapped": cfthread[ wrappedThreadName ] } ) + ); + } + expect( compilationResults.get( "unwrapped" ) ).toBe( "SELECT unwrapped_column FROM users" ); + expect( compilationResults.get( "wrapped" ) ).toBe( "SELECT ""wrapped_column"" FROM ""users""" ); + } ); } ); describe( "SQL literal escaping", function() { diff --git a/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc b/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc index 1d5558e0..e09638fa 100644 --- a/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc @@ -62,6 +62,28 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { "CREATE TABLE ""tenant"".""users"" (""status"" ""tenant"".""status"" NOT NULL DEFAULT 'active')" ] ); } ); + + it( "keeps generated constraint names unqualified and qualifies foreign targets", function() { + var statements = getBuilder() + .setDefaultSchema( "app" ) + .create( + "accounts", + function( table ) { + table.integer( "id" ).primaryKey(); + table + .integer( "owner_id" ) + .references( "id" ) + .onTable( "users" ); + }, + {}, + false + ) + .toSQL(); + + expect( statements ).toBe( [ + "CREATE TABLE ""app"".""accounts"" (""id"" INTEGER NOT NULL, ""owner_id"" INTEGER NOT NULL, CONSTRAINT ""pk_accounts_id"" PRIMARY KEY (""id""), CONSTRAINT ""fk_accounts_owner_id"" FOREIGN KEY (""owner_id"") REFERENCES ""app"".""users"" (""id"") ON UPDATE NO ACTION ON DELETE NO ACTION)" + ] ); + } ); } ); } diff --git a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc index fc0caacb..6d42dd00 100644 --- a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc @@ -43,6 +43,22 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { ] ); } ); + + it( "keeps generated default constraint names unqualified", function() { + var statements = getBuilder() + .setDefaultSchema( "app" ) + .create( + "accounts", + function( table ) { + table.boolean( "active" ).default( true ); + }, + {}, + false + ) + .toSQL(); + + expect( statements ).toBe( [ "CREATE TABLE [app].[accounts] ([active] BIT NOT NULL CONSTRAINT [df_accounts_active] DEFAULT 1)" ] ); + } ); } ); describe( "SQL Server rename literals", function() { From 1958d810010006fb417947b61d7e269ade725144 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 16 Aug 2026 22:05:55 -0600 Subject: [PATCH 049/119] fix: support Adobe null predicate arguments --- models/Query/PredicateClause.cfc | 24 +++++++++-- models/Query/QueryBuilder.cfc | 23 +++++++---- tests/specs/Query/ShouldWrapValuesSpec.cfc | 46 ++++++++++++---------- 3 files changed, 61 insertions(+), 32 deletions(-) diff --git a/models/Query/PredicateClause.cfc b/models/Query/PredicateClause.cfc index cc047cef..a80eef6a 100644 --- a/models/Query/PredicateClause.cfc +++ b/models/Query/PredicateClause.cfc @@ -419,8 +419,16 @@ component { } arguments.builder.addColumnBindings( [ typedColumn ], "where" ); - addPredicateBinding( arguments.builder, arguments.start, "where" ); - addPredicateBinding( arguments.builder, arguments.end, "where" ); + if ( isNull( arguments.start ) ) { + addPredicateBinding( builder = arguments.builder, type = "where" ); + } else { + addPredicateBinding( arguments.builder, arguments.start, "where" ); + } + if ( isNull( arguments.end ) ) { + addPredicateBinding( builder = arguments.builder, type = "where" ); + } else { + addPredicateBinding( arguments.builder, arguments.end, "where" ); + } if ( !isNull( arguments.start ) && @@ -499,7 +507,11 @@ component { if ( arguments.builder.getUtils().isExpression( arguments.column ) ) { arguments.builder.addExpressionBindings( arguments.column, "having" ); } - addPredicateBinding( arguments.builder, arguments.value, "having" ); + if ( isNull( arguments.value ) ) { + addPredicateBinding( builder = arguments.builder, type = "having" ); + } else { + addPredicateBinding( arguments.builder, arguments.value, "having" ); + } return arguments.builder; } @@ -537,7 +549,11 @@ component { } ); arguments.builder.addColumnBindings( [ typedColumn ], "where" ); - addPredicateBinding( arguments.builder, arguments.value, "where" ); + if ( isNull( arguments.value ) ) { + addPredicateBinding( builder = arguments.builder, type = "where" ); + } else { + addPredicateBinding( arguments.builder, arguments.value, "where" ); + } return arguments.builder; } diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 68bbad74..029847b4 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -2754,9 +2754,9 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J c.formatted = mapToColumnType( c.formatted ); } ); - var source = arguments.source; + var sourceQuery = arguments.source; var sql = withWrappingContext( function() { - return getGrammar().compileInsertUsing( this, formattedColumns, source ); + return getGrammar().compileInsertUsing( this, formattedColumns, sourceQuery ); } ); if ( toSql ) { @@ -3157,18 +3157,25 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J c.formatted = mapToColumnType( c.formatted ); } ); - var upsertArguments = arguments; + var updateForUpsert = arguments.update; + var targetForUpsert = arguments.target; + var hasSourceForUpsert = !isNull( arguments.source ); + if ( hasSourceForUpsert ) { + var sourceForUpsert = arguments.source; + } + var deleteUnmatchedForUpsert = arguments.deleteUnmatched; + var matchNullsForUpsert = arguments.matchNulls; var sql = withWrappingContext( function() { return getGrammar().compileUpsert( this, columns, newInsertBindings, updateArray, - upsertArguments.update, - upsertArguments.target, - isNull( upsertArguments.source ) ? javacast( "null", "" ) : upsertArguments.source, - upsertArguments.deleteUnmatched, - upsertArguments.matchNulls + updateForUpsert, + targetForUpsert, + hasSourceForUpsert ? sourceForUpsert : javacast( "null", "" ), + deleteUnmatchedForUpsert, + matchNullsForUpsert ); } ); diff --git a/tests/specs/Query/ShouldWrapValuesSpec.cfc b/tests/specs/Query/ShouldWrapValuesSpec.cfc index ed2bf39b..dc905340 100644 --- a/tests/specs/Query/ShouldWrapValuesSpec.cfc +++ b/tests/specs/Query/ShouldWrapValuesSpec.cfc @@ -109,27 +109,33 @@ component extends="testbox.system.BaseSpec" { .from( "users" ); var unwrappedThreadName = "unwrappedCompilation#replace( createUUID(), "-", "", "all" )#"; var wrappedThreadName = "wrappedCompilation#replace( createUUID(), "-", "", "all" )#"; - var compilationResults = createObject( "java", "java.util.concurrent.ConcurrentHashMap" ).init(); - - thread name=unwrappedThreadName action="run" builder=unwrappedBuilder results=compilationResults { - attributes.results.put( "unwrapped", attributes.builder.toSQL() ); - } - thread name=wrappedThreadName action="run" builder=wrappedBuilder results=compilationResults { - attributes.results.put( "wrapped", attributes.builder.toSQL() ); - } - thread action="join" name="#unwrappedThreadName#,#wrappedThreadName#" timeout="10000"; - - if ( - cfthread[ unwrappedThreadName ].status != "COMPLETED" || - cfthread[ wrappedThreadName ].status != "COMPLETED" - ) { - throw( - message = "Concurrent compilation did not complete", - detail = serializeJSON( { "unwrapped": cfthread[ unwrappedThreadName ], "wrapped": cfthread[ wrappedThreadName ] } ) - ); + var unwrappedResultKey = "qb_#unwrappedThreadName#"; + var wrappedResultKey = "qb_#wrappedThreadName#"; + + try { + thread name=unwrappedThreadName action="run" builder=unwrappedBuilder resultKey=unwrappedResultKey { + server[ attributes.resultKey ] = attributes.builder.toSQL(); + } + thread name=wrappedThreadName action="run" builder=wrappedBuilder resultKey=wrappedResultKey { + server[ attributes.resultKey ] = attributes.builder.toSQL(); + } + thread action="join" name="#unwrappedThreadName#,#wrappedThreadName#" timeout="10000"; + + if ( + cfthread[ unwrappedThreadName ].status != "COMPLETED" || + cfthread[ wrappedThreadName ].status != "COMPLETED" + ) { + throw( + message = "Concurrent compilation did not complete", + detail = serializeJSON( { "unwrapped": cfthread[ unwrappedThreadName ], "wrapped": cfthread[ wrappedThreadName ] } ) + ); + } + expect( server[ unwrappedResultKey ] ).toBe( "SELECT unwrapped_column FROM users" ); + expect( server[ wrappedResultKey ] ).toBe( "SELECT ""wrapped_column"" FROM ""users""" ); + } finally { + structDelete( server, unwrappedResultKey ); + structDelete( server, wrappedResultKey ); } - expect( compilationResults.get( "unwrapped" ) ).toBe( "SELECT unwrapped_column FROM users" ); - expect( compilationResults.get( "wrapped" ) ).toBe( "SELECT ""wrapped_column"" FROM ""users""" ); } ); } ); From 8e10683861a6fb7678f57358880aa40c7b3fc3dd Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 16 Aug 2026 22:42:53 -0600 Subject: [PATCH 050/119] fix: support concurrent Adobe compilation --- models/Grammars/BaseGrammar.cfc | 41 +++++++++++--- models/Query/QueryBuilder.cfc | 8 ++- models/Schema/Blueprint.cfc | 12 +++-- models/Schema/SchemaBuilder.cfc | 10 ++-- tests/resources/ConcurrentWrappingGrammar.cfc | 25 --------- tests/specs/Query/ShouldWrapValuesSpec.cfc | 53 +++++++++++++++++-- 6 files changed, 103 insertions(+), 46 deletions(-) delete mode 100644 tests/resources/ConcurrentWrappingGrammar.cfc diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 4c87dd19..b6c40a59 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -2526,13 +2526,12 @@ component displayname="Grammar" accessors="true" singleton { } /** - * Runs a compiler callback with a wrapping preference isolated to the current thread. - * Nested compiler calls restore the previous preference when they complete. + * Adds a wrapping preference isolated to the current thread. + * Each call must be paired with `popShouldWrapValuesContext`. * * @shouldWrap The wrapping preference, or null to use the current grammar preference. - * @callback The compiler callback to run. */ - public any function withShouldWrapValuesContext( any shouldWrap, required function callback ) { + public void function pushShouldWrapValuesContext( any shouldWrap ) { var context = variables.shouldWrapValuesContext.get(); if ( isNull( context ) ) { context = []; @@ -2540,13 +2539,39 @@ component displayname="Grammar" accessors="true" singleton { } context.append( isNull( arguments.shouldWrap ) ? getShouldWrapValues() : arguments.shouldWrap ); + variables.shouldWrapValuesContext.set( context ); + } + + /** + * Removes the current thread's wrapping preference. + */ + public void function popShouldWrapValuesContext() { + var context = variables.shouldWrapValuesContext.get(); + if ( isNull( context ) || context.isEmpty() ) { + throw( type = "InvalidState", message = "There is no shouldWrapValues context to remove." ); + } + + context.deleteAt( context.len() ); + if ( context.isEmpty() ) { + variables.shouldWrapValuesContext.remove(); + } else { + variables.shouldWrapValuesContext.set( context ); + } + } + + /** + * Runs a compiler callback with a wrapping preference isolated to the current thread. + * Nested compiler calls restore the previous preference when they complete. + * + * @shouldWrap The wrapping preference, or null to use the current grammar preference. + * @callback The compiler callback to run. + */ + public any function withShouldWrapValuesContext( any shouldWrap, required function callback ) { + pushShouldWrapValuesContext( arguments.shouldWrap ); try { return arguments.callback(); } finally { - context.deleteAt( context.len() ); - if ( context.isEmpty() ) { - variables.shouldWrapValuesContext.remove(); - } + popShouldWrapValuesContext(); } } diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 029847b4..9b15f517 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -4011,7 +4011,13 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } private any function withWrappingContext( required function callback ) { - return getGrammar().withShouldWrapValuesContext( getShouldWrapValues(), arguments.callback ); + var grammar = getGrammar(); + grammar.pushShouldWrapValuesContext( getShouldWrapValues() ); + try { + return arguments.callback(); + } finally { + grammar.popShouldWrapValuesContext(); + } } /** diff --git a/models/Schema/Blueprint.cfc b/models/Schema/Blueprint.cfc index 62d07501..9ef86bfb 100644 --- a/models/Schema/Blueprint.cfc +++ b/models/Schema/Blueprint.cfc @@ -589,13 +589,17 @@ component accessors="true" { // we use a for loop here because we can potentially modify this array while looping over it. for ( var i = 1; i <= variables.commands.len(); i++ ) { var command = variables.commands[ i ]; - var result = getGrammar().withShouldWrapValuesContext( getSchemaBuilder().getShouldWrapValues(), function() { - return invoke( - getGrammar(), + var grammar = getGrammar(); + grammar.pushShouldWrapValuesContext( getSchemaBuilder().getShouldWrapValues() ); + try { + var result = invoke( + grammar, "compile#command.getType()#", { blueprint: this, commandParameters: command.getParameters() } ); - } ); + } finally { + grammar.popShouldWrapValuesContext(); + } if ( isArray( result ) ) { statements.append( result, true ); } else if ( isSimpleValue( result ) && result != "" ) { diff --git a/models/Schema/SchemaBuilder.cfc b/models/Schema/SchemaBuilder.cfc index a4ac6d57..3cb9ee3e 100644 --- a/models/Schema/SchemaBuilder.cfc +++ b/models/Schema/SchemaBuilder.cfc @@ -587,9 +587,13 @@ component accessors="true" { } var dropOptions = arguments.options; var dropSchema = arguments.schema; - var statements = getGrammar().withShouldWrapValuesContext( getShouldWrapValues(), function() { - return getGrammar().compileDropAllObjects( dropOptions, dropSchema, this ); - } ); + var grammar = getGrammar(); + grammar.pushShouldWrapValuesContext( getShouldWrapValues() ); + try { + var statements = grammar.compileDropAllObjects( dropOptions, dropSchema, this ); + } finally { + grammar.popShouldWrapValuesContext(); + } if ( arguments.execute ) { statements.each( function( statement ) { getGrammar().runQuery( diff --git a/tests/resources/ConcurrentWrappingGrammar.cfc b/tests/resources/ConcurrentWrappingGrammar.cfc deleted file mode 100644 index 607c18f7..00000000 --- a/tests/resources/ConcurrentWrappingGrammar.cfc +++ /dev/null @@ -1,25 +0,0 @@ -component extends="qb.models.Grammars.PostgresGrammar" { - - variables.unwrappedEntered = createObject( "java", "java.util.concurrent.CountDownLatch" ).init( 1 ); - variables.wrappedEntered = createObject( "java", "java.util.concurrent.CountDownLatch" ).init( 1 ); - variables.unwrappedRead = createObject( "java", "java.util.concurrent.CountDownLatch" ).init( 1 ); - - function wrapValue( required any value ) { - if ( arguments.value == "unwrapped_column" ) { - variables.unwrappedEntered.countDown(); - variables.wrappedEntered.await(); - var result = super.wrapValue( arguments.value ); - variables.unwrappedRead.countDown(); - return result; - } - - if ( arguments.value == "wrapped_column" ) { - variables.unwrappedEntered.await(); - variables.wrappedEntered.countDown(); - variables.unwrappedRead.await(); - } - - return super.wrapValue( arguments.value ); - } - -} diff --git a/tests/specs/Query/ShouldWrapValuesSpec.cfc b/tests/specs/Query/ShouldWrapValuesSpec.cfc index dc905340..0b811b55 100644 --- a/tests/specs/Query/ShouldWrapValuesSpec.cfc +++ b/tests/specs/Query/ShouldWrapValuesSpec.cfc @@ -98,7 +98,7 @@ component extends="testbox.system.BaseSpec" { } ); it( "isolates per-query wrapping overrides during concurrent compilation", function() { - var grammar = new tests.resources.ConcurrentWrappingGrammar(); + var grammar = new qb.models.Grammars.PostgresGrammar(); var unwrappedBuilder = new qb.models.Query.QueryBuilder( grammar ) .withoutWrappingValues() .select( "unwrapped_column" ) @@ -111,13 +111,51 @@ component extends="testbox.system.BaseSpec" { var wrappedThreadName = "wrappedCompilation#replace( createUUID(), "-", "", "all" )#"; var unwrappedResultKey = "qb_#unwrappedThreadName#"; var wrappedResultKey = "qb_#wrappedThreadName#"; + var unwrappedBuilderKey = "#unwrappedResultKey#_builder"; + var wrappedBuilderKey = "#wrappedResultKey#_builder"; + var grammarKey = "#unwrappedResultKey#_grammar"; + var unwrappedEnteredKey = "#unwrappedResultKey#_entered"; + var wrappedEnteredKey = "#wrappedResultKey#_entered"; + server[ unwrappedBuilderKey ] = unwrappedBuilder; + server[ wrappedBuilderKey ] = wrappedBuilder; + server[ grammarKey ] = grammar; + server[ unwrappedEnteredKey ] = createObject( "java", "java.util.concurrent.CountDownLatch" ).init( 1 ); + server[ wrappedEnteredKey ] = createObject( "java", "java.util.concurrent.CountDownLatch" ).init( 1 ); try { - thread name=unwrappedThreadName action="run" builder=unwrappedBuilder resultKey=unwrappedResultKey { - server[ attributes.resultKey ] = attributes.builder.toSQL(); + thread + name=unwrappedThreadName + action="run" + grammarKey=grammarKey + builderKey=unwrappedBuilderKey + resultKey=unwrappedResultKey + enteredKey=unwrappedEnteredKey + otherEnteredKey=wrappedEnteredKey { + server[ attributes.grammarKey ].pushShouldWrapValuesContext( false ); + try { + server[ attributes.enteredKey ].countDown(); + server[ attributes.otherEnteredKey ].await(); + server[ attributes.resultKey ] = server[ attributes.builderKey ].toSQL(); + } finally { + server[ attributes.grammarKey ].popShouldWrapValuesContext(); + } } - thread name=wrappedThreadName action="run" builder=wrappedBuilder resultKey=wrappedResultKey { - server[ attributes.resultKey ] = attributes.builder.toSQL(); + thread + name=wrappedThreadName + action="run" + grammarKey=grammarKey + builderKey=wrappedBuilderKey + resultKey=wrappedResultKey + enteredKey=wrappedEnteredKey + otherEnteredKey=unwrappedEnteredKey { + server[ attributes.grammarKey ].pushShouldWrapValuesContext( true ); + try { + server[ attributes.otherEnteredKey ].await(); + server[ attributes.enteredKey ].countDown(); + server[ attributes.resultKey ] = server[ attributes.builderKey ].toSQL(); + } finally { + server[ attributes.grammarKey ].popShouldWrapValuesContext(); + } } thread action="join" name="#unwrappedThreadName#,#wrappedThreadName#" timeout="10000"; @@ -135,6 +173,11 @@ component extends="testbox.system.BaseSpec" { } finally { structDelete( server, unwrappedResultKey ); structDelete( server, wrappedResultKey ); + structDelete( server, unwrappedBuilderKey ); + structDelete( server, wrappedBuilderKey ); + structDelete( server, grammarKey ); + structDelete( server, unwrappedEnteredKey ); + structDelete( server, wrappedEnteredKey ); } } ); } ); From 226cfce1df24e99a337ced4aa0088d6d5f2c540a Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 16 Aug 2026 23:32:56 -0600 Subject: [PATCH 051/119] fix: harden query edge case handling --- ModuleConfig.cfc | 2 - models/Grammars/AutoDiscover.cfc | 8 ++- models/Grammars/BaseGrammar.cfc | 9 +++ models/Query/JsonQueryClause.cfc | 36 +++++----- models/Query/QueryBuilder.cfc | 8 +-- models/Query/QueryExecutor.cfc | 2 +- models/Query/QueryUtils.cfc | 66 ++++++++++++------- tests/specs/ModuleConfigSpec.cfc | 15 +++++ .../Query/Abstract/QueryExecutionSpec.cfc | 60 +++++++++++++++++ tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 39 +++++++++++ .../specs/Query/SqlServerQueryBuilderSpec.cfc | 13 ++++ 11 files changed, 206 insertions(+), 52 deletions(-) create mode 100644 tests/specs/ModuleConfigSpec.cfc diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index c5a8748c..c0b855d0 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -18,7 +18,6 @@ component { "convertEmptyStringsToNull": true, "shouldWrapValues": true, "validateQueryParamStructKeys": true, - "numericSQLType": "NUMERIC", "integerSQLType": "INTEGER", "decimalSQLType": "DECIMAL", "defaultOptions": {}, @@ -55,7 +54,6 @@ component { .to( "qb.models.Query.QueryUtils" ) .initArg( name = "convertEmptyStringsToNull", value = settings.convertEmptyStringsToNull ) .initArg( name = "validateQueryParamStructKeys", value = settings.validateQueryParamStructKeys ) - .initArg( name = "numericSQLType", value = settings.numericSQLType ) .initArg( name = "integerSQLType", value = settings.integerSQLType ) .initArg( name = "decimalSQLType", value = settings.decimalSQLType ); diff --git a/models/Grammars/AutoDiscover.cfc b/models/Grammars/AutoDiscover.cfc index 90259524..1d46ad71 100644 --- a/models/Grammars/AutoDiscover.cfc +++ b/models/Grammars/AutoDiscover.cfc @@ -43,14 +43,18 @@ component singleton { return this; } - function onMissingMethod( missingMethodName, missingMethodArguments ) { + public any function getResolvedGrammar() { if ( isNull( variables.grammar ) || !structKeyExists( variables, "grammar" ) ) { variables.grammar = autoDiscoverGrammar(); if ( !isNull( variables.shouldWrapValues ) ) { variables.grammar.setShouldWrapValues( variables.shouldWrapValues ); } } - return invoke( variables.grammar, missingMethodName, missingMethodArguments ); + return variables.grammar; + } + + function onMissingMethod( missingMethodName, missingMethodArguments ) { + return invoke( getResolvedGrammar(), missingMethodName, missingMethodArguments ); } } diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index b6c40a59..12413a24 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -116,6 +116,15 @@ component displayname="Grammar" accessors="true" singleton { ]; } + /** + * Returns the concrete grammar used for dialect-specific behavior. + * Concrete grammars resolve to themselves while proxy grammars may override + * this method to expose their discovered grammar. + */ + public any function getResolvedGrammar() { + return this; + } + /** * Returns the binding groups in the order they appear in an UPDATE statement. */ diff --git a/models/Query/JsonQueryClause.cfc b/models/Query/JsonQueryClause.cfc index 092cff06..4f4a8673 100644 --- a/models/Query/JsonQueryClause.cfc +++ b/models/Query/JsonQueryClause.cfc @@ -64,6 +64,21 @@ component { ) { var containsPath = jsonPath( builder = arguments.builder, column = arguments.column, path = arguments.path ); containsPath.value.nullValue = arguments.valueDefinition.isNull; + var binding = {}; + if ( arguments.valueDefinition.isNull ) { + var preparedNullValue = arguments.builder.getGrammar().prepareJsonContainsBinding(); + binding = isNull( preparedNullValue ) + ? arguments.builder.getUtils().extractBinding( grammar = arguments.builder.getGrammar() ) + : arguments.builder.getUtils().extractBinding( preparedNullValue, arguments.builder.getGrammar() ); + } else { + binding = arguments.builder + .getUtils() + .extractBinding( + arguments.builder.getGrammar().prepareJsonContainsBinding( arguments.valueDefinition.value ), + arguments.builder.getGrammar() + ); + } + arguments.builder .getWheres() .append( { @@ -72,26 +87,7 @@ component { combinator: arguments.combinator, negate: arguments.negate } ); - - if ( arguments.valueDefinition.isNull ) { - var preparedNullValue = arguments.builder.getGrammar().prepareJsonContainsBinding(); - arguments.builder.addBindings( - isNull( preparedNullValue ) - ? arguments.builder.getUtils().extractBinding( grammar = arguments.builder.getGrammar() ) - : arguments.builder.getUtils().extractBinding( preparedNullValue, arguments.builder.getGrammar() ), - "where" - ); - } else { - arguments.builder.addBindings( - arguments.builder - .getUtils() - .extractBinding( - arguments.builder.getGrammar().prepareJsonContainsBinding( arguments.valueDefinition.value ), - arguments.builder.getGrammar() - ), - "where" - ); - } + arguments.builder.addBindings( binding, "where" ); return arguments.builder; } diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 9b15f517..150313fc 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -3509,7 +3509,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J builder = this, aggregate = { type: type, - column: mapToColumnType( arguments.column ), + column: mapToColumnType( applyColumnFormatter( arguments.column ) ), defaultValue: isNull( arguments.defaultValue ) ? javacast( "null", "" ) : arguments.defaultValue }, callback = function() { @@ -3745,8 +3745,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J struct options = {} ) { return withReturnFormat( "query", function() { - var formattedColumn = applyColumnFormatter( column ); - select( formattedColumn ); + select( column ); take( 1 ); var result = get( options = options ); if ( result.recordCount <= 0 ) { @@ -3797,8 +3796,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J */ public array function values( required any column, struct options = {} ) { return withReturnFormat( "query", function() { - var formattedColumn = applyColumnFormatter( column ); - select( formattedColumn ); + select( column ); var result = get( options = options ); var columnName = getFunctionList().keyExists( "queryColumnList" ) ? queryColumnList( result ).listFirst() : getMetadata( result diff --git a/models/Query/QueryExecutor.cfc b/models/Query/QueryExecutor.cfc index 429d5b48..930b8aab 100644 --- a/models/Query/QueryExecutor.cfc +++ b/models/Query/QueryExecutor.cfc @@ -145,7 +145,7 @@ component { */ public QueryBuilder function hoistNestedCommonTables( required QueryBuilder source, required QueryBuilder target ) { if ( - !isInstanceOf( arguments.target.getGrammar(), "qb.models.Grammars.SqlServerGrammar" ) || + !isInstanceOf( arguments.target.getGrammar().getResolvedGrammar(), "qb.models.Grammars.SqlServerGrammar" ) || arguments.source.getCommonTables().isEmpty() ) { return arguments.source; diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 55024e77..67efd42a 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -158,27 +158,22 @@ component singleton displayname="QueryUtils" accessors="true" { var state = "sql"; var dollarQuoteDelimiter = ""; var sqlLength = len( arguments.sql ); - var isMySQL = !isNull( arguments.grammar ) && isInstanceOf( - arguments.grammar, - "qb.models.Grammars.MySQLGrammar" - ); - var isPostgres = !isNull( arguments.grammar ) && isInstanceOf( - arguments.grammar, + var resolvedGrammar = isNull( arguments.grammar ) + ? javacast( "null", "" ) + : arguments.grammar.getResolvedGrammar(); + var isMySQL = !isNull( resolvedGrammar ) && isInstanceOf( resolvedGrammar, "qb.models.Grammars.MySQLGrammar" ); + var isPostgres = !isNull( resolvedGrammar ) && isInstanceOf( + resolvedGrammar, "qb.models.Grammars.PostgresGrammar" ); - var isOracle = !isNull( arguments.grammar ) && isInstanceOf( - arguments.grammar, - "qb.models.Grammars.OracleGrammar" - ); - var isSQLite = !isNull( arguments.grammar ) && isInstanceOf( - arguments.grammar, - "qb.models.Grammars.SQLiteGrammar" - ); - var isSqlServer = !isNull( arguments.grammar ) && isInstanceOf( - arguments.grammar, + var isOracle = !isNull( resolvedGrammar ) && isInstanceOf( resolvedGrammar, "qb.models.Grammars.OracleGrammar" ); + var isSQLite = !isNull( resolvedGrammar ) && isInstanceOf( resolvedGrammar, "qb.models.Grammars.SQLiteGrammar" ); + var isSqlServer = !isNull( resolvedGrammar ) && isInstanceOf( + resolvedGrammar, "qb.models.Grammars.SqlServerGrammar" ); var oracleQuoteClosing = ""; + var quoteUsesBackslashEscapes = false; while ( position <= sqlLength ) { var character = mid( arguments.sql, position, 1 ); @@ -236,6 +231,7 @@ component singleton displayname="QueryUtils" accessors="true" { output.append( character ); if ( state != "bracketQuote" && + quoteUsesBackslashEscapes && character == chr( 92 ) && nextCharacter != "" ) { @@ -344,6 +340,13 @@ component singleton displayname="QueryUtils" accessors="true" { state = character == "'" ? "singleQuote" : ( character == """" ? "doubleQuote" : ( character == chr( 96 ) ? "backtickQuote" : "bracketQuote" ) ); + quoteUsesBackslashEscapes = isNull( resolvedGrammar ) || + isMySQL || + ( + isPostgres && + ( character == "'" || character == """" ) && + isPostgresBackslashEscapedQuote( arguments.sql, position, character ) + ); position++; continue; } @@ -380,6 +383,23 @@ component singleton displayname="QueryUtils" accessors="true" { return output.toList( "" ); } + /** + * Detects PostgreSQL escape and Unicode-escape string or identifier prefixes. + */ + private boolean function isPostgresBackslashEscapedQuote( + required string sql, + required numeric position, + required string quote + ) { + if ( arguments.position <= 1 ) { + return false; + } + + var prefix = left( arguments.sql, arguments.position - 1 ); + var escapePrefix = arguments.quote == "'" ? "(?:E|U&)" : "U&"; + return reFindNoCase( "(^|[^A-Za-z0-9_$])#escapePrefix#$", prefix ) > 0; + } + /** * Determines whether a PostgreSQL question mark is a JSON existence operator instead of a parameter placeholder. */ @@ -1036,12 +1056,14 @@ component singleton displayname="QueryUtils" accessors="true" { } var numString = arguments.binding.value.toString(); - var numStringParts = listToArray( numString, "." ); - if ( numStringParts.len() != 2 ) { - return 0; - } - var decimalPortion = numStringParts[ 2 ]; - return len( decimalPortion ); + var exponentPosition = findNoCase( "E", numString ); + var exponent = exponentPosition > 0 + ? val( mid( numString, exponentPosition + 1, len( numString ) - exponentPosition ) ) + : 0; + var mantissa = exponentPosition > 0 ? left( numString, exponentPosition - 1 ) : numString; + var decimalPosition = find( ".", mantissa ); + var decimalDigits = decimalPosition > 0 ? len( mantissa ) - decimalPosition : 0; + return max( 0, decimalDigits - exponent ); } private boolean function isPureBoxLang() { diff --git a/tests/specs/ModuleConfigSpec.cfc b/tests/specs/ModuleConfigSpec.cfc new file mode 100644 index 00000000..03af2ee0 --- /dev/null +++ b/tests/specs/ModuleConfigSpec.cfc @@ -0,0 +1,15 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "module settings", function() { + it( "does not expose the removed numericSQLType setting", function() { + var moduleConfig = prepareMock( new qb.ModuleConfig() ); + + moduleConfig.configure(); + + expect( moduleConfig.$getProperty( "settings", "variables" ) ).notToHaveKey( "numericSQLType" ); + } ); + } ); + } + +} diff --git a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc index b9896138..f69673f5 100644 --- a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc +++ b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc @@ -254,6 +254,22 @@ component extends="testbox.system.BaseSpec" { expect( runQueryLog[ 1 ] ).toBe( { sql: "SELECT ""some_table"".""name"" FROM ""users"" LIMIT 1", options: {} } ); } ); + it( "applies the column formatter once when retrieving a value", function() { + var builder = getBuilder(); + var expectedQuery = queryNew( "name", "varchar", [ { name: "foo" } ] ); + builder.$( "runQuery", expectedQuery ); + + var result = builder + .setColumnFormatter( ( column ) => "some_table.#column#" ) + .from( "users" ) + .value( "name" ); + + expect( result ).toBe( "foo" ); + expect( builder.$callLog().runQuery[ 1 ].sql ).toBe( + "SELECT ""some_table"".""name"" FROM ""users"" LIMIT 1" + ); + } ); + it( "returns the defaultValue when calling value with an empty query", function() { var builder = getBuilder(); var expectedQuery = queryNew( "name", "varchar", [] ); @@ -415,6 +431,22 @@ component extends="testbox.system.BaseSpec" { expect( runQueryLog[ 1 ] ).toBe( { sql: "SELECT ""some_table"".""name"" FROM ""users""", options: {} } ); } ); + it( "applies the column formatter once when retrieving values", function() { + var builder = getBuilder(); + var expectedQuery = queryNew( "name", "varchar", [ { name: "foo" }, { name: "bar" } ] ); + builder.$( "runQuery", expectedQuery ); + + var result = builder + .setColumnFormatter( ( column ) => "some_table.#column#" ) + .from( "users" ) + .values( "name" ); + + expect( result ).toBe( [ "foo", "bar" ] ); + expect( builder.$callLog().runQuery[ 1 ].sql ).toBe( + "SELECT ""some_table"".""name"" FROM ""users""" + ); + } ); + it( "can call values with a raw expression", function() { var builder = getBuilder(); var expectedQuery = queryNew( @@ -725,6 +757,15 @@ component extends="testbox.system.BaseSpec" { describe( "aggregate functions", function() { describe( "count", function() { + it( "applies the column formatter to aggregate columns", function() { + var sql = getBuilder() + .setColumnFormatter( ( column ) => column == "*" ? column : "users.#column#" ) + .from( "users" ) + .count( column = "id", toSQL = true ); + + expect( sql ).toBe( "SELECT COALESCE(COUNT(""users"".""id""), 0) AS ""aggregate"" FROM ""users""" ); + } ); + it( "can count all the records on a table", function() { var builder = getBuilder(); var expectedCount = 1; @@ -1907,6 +1948,25 @@ component extends="testbox.system.BaseSpec" { expect( sql ).toBe( [ "INSERT INTO ""users"" (""email"") VALUES (?), (?)" ] ); } ); } ); + + describe( "resolved grammar behavior", function() { + it( "hoists SQL Server common tables through auto discovery", function() { + var sqlServerGrammar = new qb.models.Grammars.SqlServerGrammar(); + var autoDiscover = getMockBox() + .createMock( "qb.models.Grammars.AutoDiscover" ) + .$( "autoDiscoverGrammar", sqlServerGrammar ); + var source = new qb.models.Query.QueryBuilder( grammar = autoDiscover ); + var target = new qb.models.Query.QueryBuilder( grammar = autoDiscover ); + source.setCommonTables( [ { name: "active_users" } ] ); + source.getRawBindings().commonTables = [ { value: 1 } ]; + + new qb.models.Query.QueryExecutor().hoistNestedCommonTables( source, target ); + + expect( source.getCommonTables() ).toBeEmpty(); + expect( target.getCommonTables() ).toBe( [ { name: "active_users" } ] ); + expect( target.getRawBindings().commonTables ).toBe( [ { value: 1 } ] ); + } ); + } ); } private function getBuilder() { diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index ee19b72d..0653f98c 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -302,6 +302,23 @@ component extends="testbox.system.BaseSpec" { ).toBe( "SELECT * FROM records WHERE payload ? 'name'" ); } ); + it( "uses the resolved grammar when replacing bindings", function() { + var binding = utils.extractBinding( "name", variables.mockGrammar ); + var postgresGrammar = new qb.models.Grammars.PostgresGrammar(); + var autoDiscover = getMockBox() + .createMock( "qb.models.Grammars.AutoDiscover" ) + .$( "autoDiscoverGrammar", postgresGrammar ); + + expect( + utils.replaceBindings( + "SELECT * FROM records WHERE payload ? ?", + [ binding ], + true, + autoDiscover + ) + ).toBe( "SELECT * FROM records WHERE payload ? 'name'" ); + } ); + it( "preserves question marks in MySQL hash comments", function() { var binding = utils.extractBinding( 42, variables.mockGrammar ); @@ -383,6 +400,20 @@ component extends="testbox.system.BaseSpec" { ).toBe( "SELECT 'isn''t ?' AS marker FROM users WHERE id = 42" ); } ); + it( "treats backslashes as ordinary characters in PostgreSQL string literals", function() { + var binding = utils.extractBinding( 42, variables.mockGrammar ); + var slash = chr( 92 ); + + expect( + utils.replaceBindings( + "SELECT 'C:#slash#' AS path FROM users WHERE id = ?", + [ binding ], + true, + new qb.models.Grammars.PostgresGrammar() + ) + ).toBe( "SELECT 'C:#slash#' AS path FROM users WHERE id = 42" ); + } ); + it( "preserves question marks in Oracle alternative quoted literals", function() { var binding = utils.extractBinding( 42, variables.mockGrammar ); var oracleGrammar = new qb.models.Grammars.OracleGrammar( utils ); @@ -448,6 +479,14 @@ component extends="testbox.system.BaseSpec" { expect( binding.null ).toBe( false ); } ); + it( "calculates scale for scientific notation", function() { + var smallDecimal = createObject( "java", "java.math.BigDecimal" ).init( "1.23E-4" ); + var smallerDecimal = createObject( "java", "java.math.BigDecimal" ).init( "1.0E-7" ); + + expect( utils.extractBinding( smallDecimal, variables.mockGrammar ).scale ).toBe( 6 ); + expect( utils.extractBinding( smallerDecimal, variables.mockGrammar ).scale ).toBe( 8 ); + } ); + it( "does not set a scale for integers", function() { var binding = utils.extractBinding( { "value": 3.14159, "cfsqltype": "INTEGER" }, diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index c24ddec9..7cc08c27 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -3,6 +3,19 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { function run() { super.run(); + describe( "SQL Server JSON validation", function() { + it( "does not retain a predicate when compound containment is unsupported", function() { + var builder = getBuilder().from( "users" ); + + expect( function() { + builder.whereJsonContains( "profile->languages", [ "en", "de" ] ); + } ).toThrow( type = "UnsupportedOperation" ); + + expect( builder.getWheres() ).toBeEmpty(); + expect( builder.getRawBindings().where ).toBeEmpty(); + } ); + } ); + describe( "SQL Server bulk inserts", function() { it( "inserts all rows from one JSON parameter", function() { var builder = getBuilder(); From d98fbed28a83448f8622cab0611cee183c94459f Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 04:45:19 -0600 Subject: [PATCH 052/119] fix: keep failed clause mutations atomic --- models/Query/JoinClauseManager.cfc | 3 +- models/Query/JsonQueryClause.cfc | 14 +- models/Query/PredicateClause.cfc | 142 +++++++++++------- models/Query/QueryExecutor.cfc | 8 +- .../Query/Abstract/BindingLifecycleSpec.cfc | 94 ++++++++++++ 5 files changed, 190 insertions(+), 71 deletions(-) diff --git a/models/Query/JoinClauseManager.cfc b/models/Query/JoinClauseManager.cfc index a06c9538..593cfc3f 100644 --- a/models/Query/JoinClauseManager.cfc +++ b/models/Query/JoinClauseManager.cfc @@ -252,8 +252,9 @@ component { * Adds a clause and its bindings to a builder. */ private QueryBuilder function attachJoin( required QueryBuilder builder, required JoinClause join ) { + var bindings = getJoinBindings( arguments.builder, arguments.join ); arguments.builder.getJoins().append( arguments.join ); - arguments.builder.addBindings( getJoinBindings( arguments.builder, arguments.join ), "join" ); + arguments.builder.addBindings( bindings, "join" ); return arguments.builder; } diff --git a/models/Query/JsonQueryClause.cfc b/models/Query/JsonQueryClause.cfc index 4f4a8673..e28b2be1 100644 --- a/models/Query/JsonQueryClause.cfc +++ b/models/Query/JsonQueryClause.cfc @@ -123,6 +123,11 @@ component { required struct valueDefinition, string combinator = "and" ) { + var binding = arguments.valueDefinition.isNull + ? arguments.builder.getUtils().extractBinding( grammar = arguments.builder.getGrammar() ) + : arguments.builder + .getUtils() + .extractBinding( arguments.valueDefinition.value, arguments.builder.getGrammar() ); arguments.builder .getWheres() .append( { @@ -131,14 +136,7 @@ component { operator: arguments.operator, combinator: arguments.combinator } ); - arguments.builder.addBindings( - arguments.valueDefinition.isNull - ? arguments.builder.getUtils().extractBinding( grammar = arguments.builder.getGrammar() ) - : arguments.builder - .getUtils() - .extractBinding( arguments.valueDefinition.value, arguments.builder.getGrammar() ), - "where" - ); + arguments.builder.addBindings( binding, "where" ); return arguments.builder; } diff --git a/models/Query/PredicateClause.cfc b/models/Query/PredicateClause.cfc index a80eef6a..30c09591 100644 --- a/models/Query/PredicateClause.cfc +++ b/models/Query/PredicateClause.cfc @@ -83,19 +83,9 @@ component { var type = arguments.negate ? "notIn" : "in"; var typedColumn = toColumnType( arguments.builder, arguments.column ); - arguments.builder - .getWheres() - .append( { - type: type, - column: typedColumn, - values: arguments.values, - combinator: arguments.combinator - } ); - - var bindings = []; - if ( !arguments.values.isEmpty() ) { - arguments.builder.addColumnBindings( [ typedColumn ], "where" ); - } + var bindings = arguments.values.isEmpty() + ? [] + : extractColumnBindings( arguments.builder, [ typedColumn ] ); for ( var value in arguments.values ) { if ( arguments.builder.getUtils().isExpression( value ) ) { bindings.append( arguments.builder.extractExpressionBindings( value ), true ); @@ -104,6 +94,14 @@ component { } } + arguments.builder + .getWheres() + .append( { + type: type, + column: typedColumn, + values: arguments.values, + combinator: arguments.combinator + } ); arguments.builder.addBindings( bindings, "where" ); return arguments.builder; } @@ -164,6 +162,9 @@ component { } var typedColumn = toColumnType( arguments.builder, arguments.column ); + var columnBindings = arguments.values.isEmpty() + ? [] + : extractColumnBindings( arguments.builder, [ typedColumn ] ); arguments.builder .getWheres() .append( { @@ -176,10 +177,10 @@ component { } ); if ( !arguments.values.isEmpty() ) { - arguments.builder.addColumnBindings( [ typedColumn ], "where" ); var serializedValues = extractedBindings.map( function( binding ) { return binding.null ? javacast( "null", "" ) : binding.value; } ); + arguments.builder.addBindings( columnBindings, "where" ); arguments.builder.addBindings( [ arguments.builder @@ -251,6 +252,7 @@ component { var firstColumn = toColumnType( arguments.builder, arguments.first ); var secondColumn = toColumnType( arguments.builder, arguments.second ); + var bindings = extractColumnBindings( arguments.builder, [ firstColumn, secondColumn ] ); arguments.builder .getWheres() .append( { @@ -260,7 +262,7 @@ component { second: secondColumn, combinator: arguments.combinator } ); - arguments.builder.addColumnBindings( [ firstColumn, secondColumn ], "where" ); + arguments.builder.addBindings( bindings, "where" ); return arguments.builder; } @@ -350,8 +352,9 @@ component { var type = arguments.negate ? "notNull" : "null"; var typedColumn = toColumnType( arguments.builder, arguments.column ); + var bindings = extractColumnBindings( arguments.builder, [ typedColumn ] ); arguments.builder.getWheres().append( { type: type, column: typedColumn, combinator: arguments.combinator } ); - arguments.builder.addColumnBindings( [ typedColumn ], "where" ); + arguments.builder.addBindings( bindings, "where" ); return arguments.builder; } @@ -418,17 +421,21 @@ component { .snapshotBuilder( arguments.builder, arguments.end ); } - arguments.builder.addColumnBindings( [ typedColumn ], "where" ); - if ( isNull( arguments.start ) ) { - addPredicateBinding( builder = arguments.builder, type = "where" ); - } else { - addPredicateBinding( arguments.builder, arguments.start, "where" ); - } - if ( isNull( arguments.end ) ) { - addPredicateBinding( builder = arguments.builder, type = "where" ); - } else { - addPredicateBinding( arguments.builder, arguments.end, "where" ); - } + var bindings = extractColumnBindings( arguments.builder, [ typedColumn ] ); + bindings.append( + extractPredicateBindings( + builder = arguments.builder, + value = isNull( arguments.start ) ? javacast( "null", "" ) : arguments.start + ), + true + ); + bindings.append( + extractPredicateBindings( + builder = arguments.builder, + value = isNull( arguments.end ) ? javacast( "null", "" ) : arguments.end + ), + true + ); if ( !isNull( arguments.start ) && @@ -457,6 +464,7 @@ component { end: isNull( arguments.end ) ? javacast( "null", "" ) : arguments.end, combinator: arguments.combinator } ); + arguments.builder.addBindings( bindings, "where" ); return arguments.builder; } @@ -477,10 +485,11 @@ component { isNull( arguments.operator ) && arguments.builder.getUtils().isExpression( arguments.column ) ) { + var expressionBindings = arguments.builder.extractExpressionBindings( arguments.column ); arguments.builder .getHavings() .append( { type: "raw", column: arguments.column, combinator: arguments.combinator } ); - arguments.builder.addExpressionBindings( arguments.column, "having" ); + arguments.builder.addBindings( expressionBindings, "having" ); return arguments.builder; } @@ -494,24 +503,25 @@ component { arguments.builder.getQueryValidator().validateOperator( arguments.operator ); } + var typedColumn = toColumnType( arguments.builder, arguments.column ); + var bindings = extractColumnBindings( arguments.builder, [ typedColumn ] ); + bindings.append( + extractPredicateBindings( + builder = arguments.builder, + value = isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value + ), + true + ); arguments.builder .getHavings() .append( { type: "normal", - column: toColumnType( arguments.builder, arguments.column ), + column: typedColumn, operator: arguments.operator, value: isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value, combinator: arguments.combinator } ); - - if ( arguments.builder.getUtils().isExpression( arguments.column ) ) { - arguments.builder.addExpressionBindings( arguments.column, "having" ); - } - if ( isNull( arguments.value ) ) { - addPredicateBinding( builder = arguments.builder, type = "having" ); - } else { - addPredicateBinding( arguments.builder, arguments.value, "having" ); - } + arguments.builder.addBindings( bindings, "having" ); return arguments.builder; } @@ -538,6 +548,14 @@ component { string combinator = "and" ) { var typedColumn = toColumnType( arguments.builder, arguments.column ); + var bindings = extractColumnBindings( arguments.builder, [ typedColumn ] ); + bindings.append( + extractPredicateBindings( + builder = arguments.builder, + value = isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value + ), + true + ); arguments.builder .getWheres() .append( { @@ -547,13 +565,7 @@ component { combinator: arguments.combinator, type: "basic" } ); - - arguments.builder.addColumnBindings( [ typedColumn ], "where" ); - if ( isNull( arguments.value ) ) { - addPredicateBinding( builder = arguments.builder, type = "where" ); - } else { - addPredicateBinding( arguments.builder, arguments.value, "where" ); - } + arguments.builder.addBindings( bindings, "where" ); return arguments.builder; } @@ -572,10 +584,11 @@ component { arguments.query = arguments.builder.newQuery(); callback( arguments.query ); } + var typedColumn = toColumnType( arguments.builder, arguments.column ); + var columnBindings = extractColumnBindings( arguments.builder, [ typedColumn ] ); arguments.query = arguments.builder .getCollaborator( "QueryExecutor" ) .snapshotBuilder( arguments.builder, arguments.query ); - var typedColumn = toColumnType( arguments.builder, arguments.column ); arguments.builder .getWheres() .append( { @@ -585,7 +598,7 @@ component { query: arguments.query, combinator: arguments.combinator } ); - arguments.builder.addColumnBindings( [ typedColumn ], "where" ); + arguments.builder.addBindings( columnBindings, "where" ); arguments.builder.addBindings( arguments.query.getBindings(), "where" ); return arguments.builder; } @@ -605,12 +618,13 @@ component { arguments.query = arguments.builder.newQuery(); callback( arguments.query ); } + var typedColumn = toColumnType( arguments.builder, arguments.column ); + var columnBindings = extractColumnBindings( arguments.builder, [ typedColumn ] ); arguments.query = arguments.builder .getCollaborator( "QueryExecutor" ) .snapshotBuilder( arguments.builder, arguments.query ); var type = arguments.negate ? "notInSub" : "inSub"; - var typedColumn = toColumnType( arguments.builder, arguments.column ); arguments.builder .getWheres() .append( { @@ -619,7 +633,7 @@ component { query: arguments.query, combinator: arguments.combinator } ); - arguments.builder.addColumnBindings( [ typedColumn ], "where" ); + arguments.builder.addBindings( columnBindings, "where" ); arguments.builder.addBindings( arguments.query.getBindings(), "where" ); return arguments.builder; } @@ -645,17 +659,29 @@ component { /** * Adds one expression or scalar binding. */ - private void function addPredicateBinding( required QueryBuilder builder, any value, required string type ) { + private array function extractPredicateBindings( required QueryBuilder builder, any value ) { if ( !isNull( arguments.value ) && arguments.builder.getUtils().isExpression( arguments.value ) ) { - arguments.builder.addExpressionBindings( arguments.value, arguments.type ); - } else { - arguments.builder.addBindings( - isNull( arguments.value ) - ? arguments.builder.getUtils().extractBinding( grammar = arguments.builder.getGrammar() ) - : arguments.builder.getUtils().extractBinding( arguments.value, arguments.builder.getGrammar() ), - arguments.type - ); + return arguments.builder.extractExpressionBindings( arguments.value ); + } + var binding = isNull( arguments.value ) + ? arguments.builder.getUtils().extractBinding( grammar = arguments.builder.getGrammar() ) + : arguments.builder.getUtils().extractBinding( arguments.value, arguments.builder.getGrammar() ); + return isArray( binding ) ? binding : [ binding ]; + } + + /** + * Extracts bindings carried by typed columns without mutating the builder. + */ + private array function extractColumnBindings( required QueryBuilder builder, required array columns ) { + var bindings = []; + for ( var column in arguments.columns ) { + if ( column.type == "raw" ) { + bindings.append( arguments.builder.extractExpressionBindings( column.value ), true ); + } else if ( column.type == "builder" ) { + bindings.append( column.value.getBindings(), true ); + } } + return bindings; } /** diff --git a/models/Query/QueryExecutor.cfc b/models/Query/QueryExecutor.cfc index 930b8aab..5c57e58e 100644 --- a/models/Query/QueryExecutor.cfc +++ b/models/Query/QueryExecutor.cfc @@ -189,12 +189,12 @@ component { var originalAggregate = arguments.builder.getAggregate(); var originalOrders = arguments.builder.getOrders(); var originalAggregateBindings = arguments.builder.getRawBindings().aggregate; - arguments.builder.setAggregate( arguments.aggregate ); - arguments.builder.setOrders( [] ); - arguments.builder.getRawBindings().aggregate = []; - arguments.builder.addColumnBindings( [ arguments.aggregate.column ], "aggregate" ); var result = javacast( "null", "" ); try { + arguments.builder.setAggregate( arguments.aggregate ); + arguments.builder.setOrders( [] ); + arguments.builder.getRawBindings().aggregate = []; + arguments.builder.addColumnBindings( [ arguments.aggregate.column ], "aggregate" ); result = arguments.callback(); } finally { arguments.builder.setAggregate( originalAggregate ); diff --git a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc index ae6eda2f..a6e8f05e 100644 --- a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc +++ b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc @@ -46,6 +46,100 @@ component extends="testbox.system.BaseSpec" { expect( builder.getRawBindings().having[ 1 ].value ).toBe( "" ); expect( builder.toSQL() ).toBe( "SELECT * FROM ""users"" HAVING ""score"" = ?" ); } ); + + it( "does not retain basic predicate state when binding validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ); + + expect( function() { + builder.where( "id", "=", { unexpected: "value" } ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.getWheres() ).toBeEmpty(); + expect( builder.getRawBindings().where ).toBeEmpty(); + } ); + + it( "does not retain IN predicate state when binding validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ); + + expect( function() { + builder.whereIn( "id", [ 1, { unexpected: "value" } ] ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.getWheres() ).toBeEmpty(); + expect( builder.getRawBindings().where ).toBeEmpty(); + } ); + + it( "does not retain BETWEEN bindings when binding validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ); + + expect( function() { + builder.whereBetween( "id", 1, { unexpected: "value" } ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.getWheres() ).toBeEmpty(); + expect( builder.getRawBindings().where ).toBeEmpty(); + } ); + + it( "does not retain HAVING state when binding validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ); + + expect( function() { + builder.having( "score", ">", { unexpected: "value" } ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.getHavings() ).toBeEmpty(); + expect( builder.getRawBindings().having ).toBeEmpty(); + } ); + + it( "does not retain raw HAVING state when binding validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ); + + expect( function() { + builder.having( builder.raw( "COUNT(*) > ?", [ { unexpected: "value" } ] ) ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.getHavings() ).toBeEmpty(); + expect( builder.getRawBindings().having ).toBeEmpty(); + } ); + + it( "does not retain JSON length state when binding validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ); + + expect( function() { + builder.whereJsonLength( "profile->languages", "=", { unexpected: "value" } ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.getWheres() ).toBeEmpty(); + expect( builder.getRawBindings().where ).toBeEmpty(); + } ); + + it( "restores aggregate state when aggregate binding validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ).orderBy( "name" ); + + expect( function() { + builder.sum( builder.raw( "?", [ { unexpected: "value" } ] ) ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.getAggregate() ).toBeEmpty(); + expect( builder.getOrders() ).toHaveLength( 1 ); + expect( builder.getRawBindings().aggregate ).toBeEmpty(); + } ); + + it( "does not retain joins when raw table binding validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ); + + expect( function() { + builder.join( + builder.raw( "accounts ?", [ { unexpected: "value" } ] ), + "users.id", + "=", + "accounts.user_id" + ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.getJoins() ).toBeEmpty(); + expect( builder.getRawBindings().join ).toBeEmpty(); + } ); } ); } From f2f3ae80051675ea2fe3f0c4f9903afcd4a52924 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 04:50:31 -0600 Subject: [PATCH 053/119] fix: preserve query state after binding errors --- models/Query/PredicateClause.cfc | 35 ++---- models/Query/QueryBuilder.cfc | 109 +++++++++--------- .../Query/Abstract/BindingLifecycleSpec.cfc | 66 +++++++++++ 3 files changed, 127 insertions(+), 83 deletions(-) diff --git a/models/Query/PredicateClause.cfc b/models/Query/PredicateClause.cfc index 30c09591..9b072816 100644 --- a/models/Query/PredicateClause.cfc +++ b/models/Query/PredicateClause.cfc @@ -83,9 +83,7 @@ component { var type = arguments.negate ? "notIn" : "in"; var typedColumn = toColumnType( arguments.builder, arguments.column ); - var bindings = arguments.values.isEmpty() - ? [] - : extractColumnBindings( arguments.builder, [ typedColumn ] ); + var bindings = arguments.values.isEmpty() ? [] : arguments.builder.extractColumnBindings( [ typedColumn ] ); for ( var value in arguments.values ) { if ( arguments.builder.getUtils().isExpression( value ) ) { bindings.append( arguments.builder.extractExpressionBindings( value ), true ); @@ -164,7 +162,7 @@ component { var typedColumn = toColumnType( arguments.builder, arguments.column ); var columnBindings = arguments.values.isEmpty() ? [] - : extractColumnBindings( arguments.builder, [ typedColumn ] ); + : arguments.builder.extractColumnBindings( [ typedColumn ] ); arguments.builder .getWheres() .append( { @@ -252,7 +250,7 @@ component { var firstColumn = toColumnType( arguments.builder, arguments.first ); var secondColumn = toColumnType( arguments.builder, arguments.second ); - var bindings = extractColumnBindings( arguments.builder, [ firstColumn, secondColumn ] ); + var bindings = arguments.builder.extractColumnBindings( [ firstColumn, secondColumn ] ); arguments.builder .getWheres() .append( { @@ -352,7 +350,7 @@ component { var type = arguments.negate ? "notNull" : "null"; var typedColumn = toColumnType( arguments.builder, arguments.column ); - var bindings = extractColumnBindings( arguments.builder, [ typedColumn ] ); + var bindings = arguments.builder.extractColumnBindings( [ typedColumn ] ); arguments.builder.getWheres().append( { type: type, column: typedColumn, combinator: arguments.combinator } ); arguments.builder.addBindings( bindings, "where" ); return arguments.builder; @@ -421,7 +419,7 @@ component { .snapshotBuilder( arguments.builder, arguments.end ); } - var bindings = extractColumnBindings( arguments.builder, [ typedColumn ] ); + var bindings = arguments.builder.extractColumnBindings( [ typedColumn ] ); bindings.append( extractPredicateBindings( builder = arguments.builder, @@ -504,7 +502,7 @@ component { } var typedColumn = toColumnType( arguments.builder, arguments.column ); - var bindings = extractColumnBindings( arguments.builder, [ typedColumn ] ); + var bindings = arguments.builder.extractColumnBindings( [ typedColumn ] ); bindings.append( extractPredicateBindings( builder = arguments.builder, @@ -548,7 +546,7 @@ component { string combinator = "and" ) { var typedColumn = toColumnType( arguments.builder, arguments.column ); - var bindings = extractColumnBindings( arguments.builder, [ typedColumn ] ); + var bindings = arguments.builder.extractColumnBindings( [ typedColumn ] ); bindings.append( extractPredicateBindings( builder = arguments.builder, @@ -585,7 +583,7 @@ component { callback( arguments.query ); } var typedColumn = toColumnType( arguments.builder, arguments.column ); - var columnBindings = extractColumnBindings( arguments.builder, [ typedColumn ] ); + var columnBindings = arguments.builder.extractColumnBindings( [ typedColumn ] ); arguments.query = arguments.builder .getCollaborator( "QueryExecutor" ) .snapshotBuilder( arguments.builder, arguments.query ); @@ -619,7 +617,7 @@ component { callback( arguments.query ); } var typedColumn = toColumnType( arguments.builder, arguments.column ); - var columnBindings = extractColumnBindings( arguments.builder, [ typedColumn ] ); + var columnBindings = arguments.builder.extractColumnBindings( [ typedColumn ] ); arguments.query = arguments.builder .getCollaborator( "QueryExecutor" ) .snapshotBuilder( arguments.builder, arguments.query ); @@ -669,21 +667,6 @@ component { return isArray( binding ) ? binding : [ binding ]; } - /** - * Extracts bindings carried by typed columns without mutating the builder. - */ - private array function extractColumnBindings( required QueryBuilder builder, required array columns ) { - var bindings = []; - for ( var column in arguments.columns ) { - if ( column.type == "raw" ) { - bindings.append( arguments.builder.extractExpressionBindings( column.value ), true ); - } else if ( column.type == "builder" ) { - bindings.append( column.value.getBindings(), true ); - } - } - return bindings; - } - /** * Formats and classifies a predicate column. */ diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 150313fc..77da8461 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -554,9 +554,10 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J if ( newColumns.isEmpty() ) { newColumns = [ { "type": "simple", "value": "*" } ]; } + var newBindings = extractColumnBindings( newColumns ); clearBindings( only = [ "select" ] ); variables.columns = newColumns; - addColumnBindings( newColumns, "select" ); + addBindings( newBindings, "select" ); return this; } @@ -565,13 +566,22 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * This bridge is public so collaborators can operate on QueryBuilder subclasses. */ public void function addColumnBindings( required array columns, required string type ) { + addBindings( extractColumnBindings( arguments.columns ), arguments.type ); + } + + /** + * Normalizes bindings carried by typed raw-expression and builder columns. + */ + public array function extractColumnBindings( required array columns ) { + var bindings = []; for ( var column in arguments.columns ) { if ( column.type == "raw" ) { - addExpressionBindings( column.value, arguments.type ); + bindings.append( extractExpressionBindings( column.value ), true ); } else if ( column.type == "builder" ) { - addBindings( column.value.getBindings(), arguments.type ); + bindings.append( column.value.getBindings(), true ); } } + return bindings; } public struct function mapToColumnType( required any column ) { @@ -638,6 +648,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J var newColumns = normalizeToArray( arguments.columns ) .map( ( column ) => applyColumnFormatter( column ) ) .map( ( column ) => mapToColumnType( column ) ); + var newBindings = extractColumnBindings( newColumns ); var selectedColumns = variables.columns.isEmpty() ? [] : arraySlice( variables.columns, 1 ); if ( @@ -651,7 +662,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J arrayAppend( selectedColumns, newColumns, true ); variables.columns = selectedColumns; - addColumnBindings( newColumns, "select" ); + addBindings( newBindings, "select" ); return this; } @@ -670,10 +681,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J */ public QueryBuilder function selectRaw( required any expression, array bindings = [] ) { var expressions = arrayWrap( arguments.expression ); - for ( var index = 1; index <= expressions.len(); index++ ) { - addSelect( raw( expressions[ index ], index == 1 ? arguments.bindings : [] ) ); - } - return this; + var rawBindings = arguments.bindings; + return addSelect( expressions.map( ( expression, index ) => raw( expression, index == 1 ? rawBindings : [] ) ) ); } /** @@ -701,7 +710,6 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function reselect( any columns = "*" ) { - clearSelect(); return select( argumentCollection = arguments ); } @@ -720,8 +728,9 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function reselectRaw( required any expression, array bindings = [] ) { - clearSelect(); - return selectRaw( argumentCollection = arguments ); + var expressions = arrayWrap( arguments.expression ); + var rawBindings = arguments.bindings; + return select( expressions.map( ( expression, index ) => raw( expression, index == 1 ? rawBindings : [] ) ) ); } /********************************************************************************\ @@ -743,6 +752,11 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J ); } + var fromBindings = []; + if ( !isSimpleValue( arguments.from ) && getUtils().isExpression( arguments.from ) ) { + fromBindings = extractExpressionBindings( arguments.from ); + } + clearBindings( only = [ "from" ] ); variables.grammarCompiledFrom = false; variables.alias = ""; @@ -750,9 +764,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J parseIntoTableAndAlias( arguments.from ); } else { variables.tableName = arguments.from; - if ( getUtils().isExpression( arguments.from ) ) { - addExpressionBindings( arguments.from, "from" ); - } + addBindings( fromBindings, "from" ); } return this; @@ -829,19 +841,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function tableRaw( required string table, array bindings = [] ) { - this.table( raw( arguments.table ) ); - - // add the bindings required by the table - if ( !arrayIsEmpty( arguments.bindings ) ) { - addBindings( - arguments.bindings.map( function( value ) { - return utils.extractBinding( value, variables.grammar ); - } ), - "from" - ); - } - - return this; + return this.table( raw( arguments.table, arguments.bindings ) ); } /** @@ -853,19 +853,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function fromRaw( required string from, array bindings = [] ) { - this.from( raw( arguments.from ) ); - - // add the bindings required by the table - if ( !arrayIsEmpty( arguments.bindings ) ) { - addBindings( - arguments.bindings.map( function( value ) { - return utils.extractBinding( value, variables.grammar ); - } ), - "from" - ); - } - - return this; + return this.from( raw( arguments.from, arguments.bindings ) ); } /** @@ -1897,12 +1885,12 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function groupBy( required groups ) { - var groupBys = normalizeToArray( arguments.groups ); - for ( var groupBy in groupBys ) { - var typedGroupBy = mapToColumnType( applyColumnFormatter( groupBy ) ); - variables.groups.append( typedGroupBy ); - addColumnBindings( [ typedGroupBy ], "groupBy" ); - } + var groupBys = normalizeToArray( arguments.groups ) + .map( ( groupBy ) => applyColumnFormatter( groupBy ) ) + .map( ( groupBy ) => mapToColumnType( groupBy ) ); + var groupBindings = extractColumnBindings( groupBys ); + variables.groups.append( groupBys, true ); + addBindings( groupBindings, "groupBy" ); return this; } @@ -2022,8 +2010,20 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J arguments.column = listToArray( arguments.column ); } - for ( var col in arrayWrap( arguments.column ) ) { - orderBySingle( col, arguments.direction ); + var originalOrders = variables.orders.isEmpty() ? [] : arraySlice( variables.orders, 1 ); + var originalOrderBindings = variables.bindings.orderBy.isEmpty() + ? [] + : arraySlice( variables.bindings.orderBy, 1 ); + var commonTableState = getCollaborator( "QueryExecutor" ).captureCommonTableState( this ); + try { + for ( var col in arrayWrap( arguments.column ) ) { + orderBySingle( col, arguments.direction ); + } + } catch ( any e ) { + variables.orders = originalOrders; + variables.bindings.orderBy = originalOrderBindings; + getCollaborator( "QueryExecutor" ).restoreCommonTableState( this, commonTableState ); + rethrow; } return this; } @@ -2048,15 +2048,9 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J // check the value of the current iteration to determine what blend of column def they went with // ex: "DATE(created_at)" -- RAW expression if ( getUtils().isExpression( column ) ) { + var expressionBindings = extractExpressionBindings( column ); variables.orders.append( { direction: "raw", column: column } ); - addBindings( - column - .getBindings() - .map( function( value ) { - return variables.utils.extractBinding( arguments.value, variables.grammar ); - } ), - "orderBy" - ); + addBindings( expressionBindings, "orderBy" ); return this; } @@ -2083,8 +2077,9 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J if ( isStruct( column ) && structKeyExists( column, "column" ) ) { // as long as the struct provided contains the column keyName then we can append it. If the direction column is omitted we will assume direction argument's value if ( getUtils().isExpression( column.column ) ) { + var expressionBindings = extractExpressionBindings( column.column ); variables.orders.append( { direction: "raw", column: column.column } ); - addExpressionBindings( column.column, "orderBy" ); + addBindings( expressionBindings, "orderBy" ); } else { var dir = ( structKeyExists( column, "direction" ) && arrayFindNoCase( variables.directions, column.direction ) diff --git a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc index a6e8f05e..21281e7b 100644 --- a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc +++ b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc @@ -140,6 +140,72 @@ component extends="testbox.system.BaseSpec" { expect( builder.getJoins() ).toBeEmpty(); expect( builder.getRawBindings().join ).toBeEmpty(); } ); + + it( "preserves selected columns when replacement binding validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ).select( "id" ); + + expect( function() { + builder.select( builder.raw( "?", [ { unexpected: "value" } ] ) ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.toSQL() ).toBe( "SELECT ""id"" FROM ""users""" ); + expect( builder.getRawBindings().select ).toBeEmpty(); + } ); + + it( "does not retain added columns when binding validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ).select( "id" ); + + expect( function() { + builder.addSelect( builder.raw( "?", [ { unexpected: "value" } ] ) ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.toSQL() ).toBe( "SELECT ""id"" FROM ""users""" ); + expect( builder.getRawBindings().select ).toBeEmpty(); + } ); + + it( "preserves the FROM source when raw binding validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ); + + expect( function() { + builder.fromRaw( "accounts ?", [ { unexpected: "value" } ] ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.toSQL() ).toBe( "SELECT * FROM ""users""" ); + expect( builder.getRawBindings().from ).toBeEmpty(); + } ); + + it( "does not retain grouping state when binding validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ).groupBy( "team_id" ); + + expect( function() { + builder.groupBy( builder.raw( "?", [ { unexpected: "value" } ] ) ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.getGroups() ).toHaveLength( 1 ); + expect( builder.getRawBindings().groupBy ).toBeEmpty(); + } ); + + it( "does not retain ordering state when binding validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ).orderBy( "name" ); + + expect( function() { + builder.orderBy( builder.raw( "?", [ { unexpected: "value" } ] ) ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.getOrders() ).toHaveLength( 1 ); + expect( builder.getRawBindings().orderBy ).toBeEmpty(); + } ); + + it( "preserves selected columns when raw reselection binding validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ).select( "id" ); + + expect( function() { + builder.reselectRaw( "?", [ { unexpected: "value" } ] ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.toSQL() ).toBe( "SELECT ""id"" FROM ""users""" ); + expect( builder.getRawBindings().select ).toBeEmpty(); + } ); } ); } From 2c8bb45eade9f718d68e5b546337877f01328f62 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 05:07:49 -0600 Subject: [PATCH 054/119] fix(QueryUtils): preserve BigDecimal scale on BoxLang --- models/Query/QueryUtils.cfc | 4 ++++ tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 67efd42a..637f6c45 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -1055,6 +1055,10 @@ component singleton displayname="QueryUtils" accessors="true" { return 0; } + if ( isInstanceOf( arguments.binding.value, "java.math.BigDecimal" ) ) { + return max( 0, arguments.binding.value.scale() ); + } + var numString = arguments.binding.value.toString(); var exponentPosition = findNoCase( "E", numString ); var exponent = exponentPosition > 0 diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index 0653f98c..cbe4d9bd 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -487,6 +487,12 @@ component extends="testbox.system.BaseSpec" { expect( utils.extractBinding( smallerDecimal, variables.mockGrammar ).scale ).toBe( 8 ); } ); + it( "preserves significant zeroes in scientific notation scale", function() { + var decimal = createObject( "java", "java.math.BigDecimal" ).init( "1.00E-7" ); + + expect( utils.extractBinding( decimal, variables.mockGrammar ).scale ).toBe( 9 ); + } ); + it( "does not set a scale for integers", function() { var binding = utils.extractBinding( { "value": 3.14159, "cfsqltype": "INTEGER" }, From 0efdc0f5ea22c7f0a00ec91dab27c00cfa9d61bf Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 05:11:37 -0600 Subject: [PATCH 055/119] fix(QueryUtils): handle nested PostgreSQL comments --- models/Query/QueryUtils.cfc | 13 +++++++++++-- tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 13 +++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 637f6c45..6cf7b386 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -172,6 +172,7 @@ component singleton displayname="QueryUtils" accessors="true" { resolvedGrammar, "qb.models.Grammars.SqlServerGrammar" ); + var blockCommentDepth = 0; var oracleQuoteClosing = ""; var quoteUsesBackslashEscapes = false; @@ -190,10 +191,17 @@ component singleton displayname="QueryUtils" accessors="true" { if ( state == "blockComment" ) { output.append( character ); - if ( character == "*" && nextCharacter == "/" ) { + if ( isPostgres && character == "/" && nextCharacter == "*" ) { output.append( nextCharacter ); position += 2; - state = "sql"; + blockCommentDepth++; + } else if ( character == "*" && nextCharacter == "/" ) { + output.append( nextCharacter ); + position += 2; + blockCommentDepth--; + if ( blockCommentDepth == 0 ) { + state = "sql"; + } } else { position++; } @@ -284,6 +292,7 @@ component singleton displayname="QueryUtils" accessors="true" { output.append( nextCharacter ); position += 2; state = "blockComment"; + blockCommentDepth = 1; continue; } diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index cbe4d9bd..78bbb02e 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -428,6 +428,19 @@ component extends="testbox.system.BaseSpec" { ).toBe( "SELECT q'[isn't ? -- /* a placeholder */]' AS marker FROM users WHERE id = 42" ); } ); + it( "preserves question marks in nested PostgreSQL block comments", function() { + var binding = utils.extractBinding( 42, variables.mockGrammar ); + + expect( + utils.replaceBindings( + "SELECT 1 /* outer ? /* inner ? */ still outer ? */ WHERE id = ?", + [ binding ], + true, + new qb.models.Grammars.PostgresGrammar() + ) + ).toBe( "SELECT 1 /* outer ? /* inner ? */ still outer ? */ WHERE id = 42" ); + } ); + it( "rejects bindings without matching placeholders", function() { var binding = utils.extractBinding( 42, variables.mockGrammar ); From 9e7ec5b5765341373da95e9bb89a3e59d6cdbcc3 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 05:15:57 -0600 Subject: [PATCH 056/119] fix(QueryBuilder): replace bindings between updates --- models/Query/QueryBuilder.cfc | 58 +++++++++++-------- .../Query/Abstract/BindingLifecycleSpec.cfc | 22 +++++++ 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 77da8461..bf9967eb 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -2897,34 +2897,42 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return compareNoCase( a.formatted, b.formatted ); } ); - for ( var column in updateArray ) { - var value = arguments.values[ column.original ]; - if ( isCustomFunction( value ) || isClosure( value ) ) { - var subselect = newQuery(); - value( subselect ); - arguments.values[ column.original ] = getCollaborator( "QueryExecutor" ).snapshotBuilder( - this, - subselect - ); - addBindings( arguments.values[ column.original ].getBindings(), "update" ); - } else if ( getUtils().isBuilder( value ) ) { - arguments.values[ column.original ] = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, value ); - addBindings( arguments.values[ column.original ].getBindings(), "update" ); - } else if ( getUtils().isExpression( value ) ) { - addExpressionBindings( value, "update" ); - } else { - addBindings( getUtils().extractBinding( value, variables.grammar ), "update" ); + var newUpdateBindings = []; + var executor = getCollaborator( "QueryExecutor" ); + var commonTableState = executor.captureCommonTableState( this ); + var sql = ""; + try { + for ( var column in updateArray ) { + var value = arguments.values[ column.original ]; + if ( isCustomFunction( value ) || isClosure( value ) ) { + var subselect = newQuery(); + value( subselect ); + arguments.values[ column.original ] = executor.snapshotBuilder( this, subselect ); + newUpdateBindings.append( arguments.values[ column.original ].getBindings(), true ); + } else if ( getUtils().isBuilder( value ) ) { + arguments.values[ column.original ] = executor.snapshotBuilder( this, value ); + newUpdateBindings.append( arguments.values[ column.original ].getBindings(), true ); + } else if ( getUtils().isExpression( value ) ) { + newUpdateBindings.append( extractExpressionBindings( value ), true ); + } else { + newUpdateBindings.append( getUtils().extractBinding( value, variables.grammar ) ); + } } - } - updateArray.each( ( c ) => { - c.formatted = mapToColumnType( c.formatted ); - } ); + updateArray.each( ( c ) => { + c.formatted = mapToColumnType( c.formatted ); + } ); - var updateValues = arguments.values; - var sql = withWrappingContext( function() { - return getGrammar().compileUpdate( this, updateArray, updateValues ); - } ); + var updateValues = arguments.values; + sql = withWrappingContext( function() { + return getGrammar().compileUpdate( this, updateArray, updateValues ); + } ); + } catch ( any e ) { + executor.restoreCommonTableState( this, commonTableState ); + rethrow; + } + + variables.bindings.update = newUpdateBindings; if ( toSql ) { return sql; diff --git a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc index 21281e7b..2b82b5b6 100644 --- a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc +++ b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc @@ -206,6 +206,28 @@ component extends="testbox.system.BaseSpec" { expect( builder.toSQL() ).toBe( "SELECT ""id"" FROM ""users""" ); expect( builder.getRawBindings().select ).toBeEmpty(); } ); + + it( "replaces update bindings when reusing a builder", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ); + + builder.update( values = { "name": "first" }, toSQL = true ); + builder.update( values = { "name": "second" }, toSQL = true ); + + expect( builder.getRawBindings().update ).toHaveLength( 1 ); + expect( builder.getRawBindings().update[ 1 ].value ).toBe( "second" ); + } ); + + it( "preserves update bindings when replacement validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ); + builder.update( values = { "name": "first" }, toSQL = true ); + + expect( function() { + builder.update( values = { "name": { "unexpected": "value" } }, toSQL = true ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.getRawBindings().update ).toHaveLength( 1 ); + expect( builder.getRawBindings().update[ 1 ].value ).toBe( "first" ); + } ); } ); } From 1a776b433001b687721a99b5acee749b168af626 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 05:17:50 -0600 Subject: [PATCH 057/119] fix(QueryBuilder): replace bindings between inserts --- models/Query/QueryBuilder.cfc | 6 +++-- .../Query/Abstract/BindingLifecycleSpec.cfc | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index bf9967eb..073f0772 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -2611,12 +2611,13 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } ); } ); + var newInsertBindings = []; newBindings.each( function( bindingsArray ) { bindingsArray.each( function( binding ) { if ( getUtils().isNotExpression( binding ) ) { - addBindings( binding, "insert" ); + newInsertBindings.append( binding ); } else { - addExpressionBindings( binding, "insert" ); + newInsertBindings.append( extractExpressionBindings( binding ), true ); } } ); } ); @@ -2629,6 +2630,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return getGrammar().compileInsert( this, columns, newBindings ); } ); + variables.bindings.insert = newInsertBindings; clearBindings( except = "insert" ); if ( toSql ) { diff --git a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc index 2b82b5b6..2d74b18d 100644 --- a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc +++ b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc @@ -228,6 +228,28 @@ component extends="testbox.system.BaseSpec" { expect( builder.getRawBindings().update ).toHaveLength( 1 ); expect( builder.getRawBindings().update[ 1 ].value ).toBe( "first" ); } ); + + it( "replaces insert bindings when reusing a builder", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ); + + builder.insert( values = { "name": "first" }, toSQL = true ); + builder.insert( values = { "name": "second" }, toSQL = true ); + + expect( builder.getRawBindings().insert ).toHaveLength( 1 ); + expect( builder.getRawBindings().insert[ 1 ].value ).toBe( "second" ); + } ); + + it( "preserves insert bindings when replacement validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ); + builder.insert( values = { "name": "first" }, toSQL = true ); + + expect( function() { + builder.insert( values = { "name": { "unexpected": "value" } }, toSQL = true ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.getRawBindings().insert ).toHaveLength( 1 ); + expect( builder.getRawBindings().insert[ 1 ].value ).toBe( "first" ); + } ); } ); } From b24f23fb93ea6180c3adff13c420c3b497863736 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 05:20:16 -0600 Subject: [PATCH 058/119] fix(QueryBuilder): replace bindings between insert ignores --- models/Query/QueryBuilder.cfc | 6 +++-- .../Query/Abstract/BindingLifecycleSpec.cfc | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 073f0772..bdba2629 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -2812,12 +2812,13 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } ); } ); + var newInsertBindings = []; newBindings.each( function( bindingsArray ) { bindingsArray.each( function( binding ) { if ( getUtils().isNotExpression( binding ) ) { - addBindings( binding, "insert" ); + newInsertBindings.append( binding ); } else { - addExpressionBindings( binding, "insert" ); + newInsertBindings.append( extractExpressionBindings( binding ), true ); } } ); } ); @@ -2844,6 +2845,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J ); } ); + variables.bindings.insert = newInsertBindings; clearBindings( except = "insert" ); if ( toSql ) { diff --git a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc index 2d74b18d..25611157 100644 --- a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc +++ b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc @@ -250,6 +250,32 @@ component extends="testbox.system.BaseSpec" { expect( builder.getRawBindings().insert ).toHaveLength( 1 ); expect( builder.getRawBindings().insert[ 1 ].value ).toBe( "first" ); } ); + + it( "replaces insert-ignore bindings when reusing a builder", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.MySQLGrammar() ).from( + "users" + ); + + builder.insertIgnore( values = { "name": "first" }, toSQL = true ); + builder.insertIgnore( values = { "name": "second" }, toSQL = true ); + + expect( builder.getRawBindings().insert ).toHaveLength( 1 ); + expect( builder.getRawBindings().insert[ 1 ].value ).toBe( "second" ); + } ); + + it( "preserves insert-ignore bindings when replacement validation fails", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.MySQLGrammar() ).from( + "users" + ); + builder.insertIgnore( values = { "name": "first" }, toSQL = true ); + + expect( function() { + builder.insertIgnore( values = { "name": { "unexpected": "value" } }, toSQL = true ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.getRawBindings().insert ).toHaveLength( 1 ); + expect( builder.getRawBindings().insert[ 1 ].value ).toBe( "first" ); + } ); } ); } From e2bc3f79d3833999a8aadc5639e5366a9c5f1200 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 05:23:00 -0600 Subject: [PATCH 059/119] fix(QueryBuilder): preserve orders after failed reorder --- models/Query/QueryBuilder.cfc | 12 ++++++++++-- tests/specs/Query/Abstract/BindingLifecycleSpec.cfc | 13 +++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index bdba2629..a82dd768 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -2255,8 +2255,16 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function reorder( required any column, string direction = "asc" ) { - clearOrders(); - return orderBy( argumentCollection = arguments ); + var originalOrders = variables.orders; + var originalOrderBindings = variables.bindings.orderBy; + try { + clearOrders(); + return orderBy( argumentCollection = arguments ); + } catch ( any e ) { + variables.orders = originalOrders; + variables.bindings.orderBy = originalOrderBindings; + rethrow; + } } /*******************************************************************************\ diff --git a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc index 25611157..355b7fa5 100644 --- a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc +++ b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc @@ -276,6 +276,19 @@ component extends="testbox.system.BaseSpec" { expect( builder.getRawBindings().insert ).toHaveLength( 1 ); expect( builder.getRawBindings().insert[ 1 ].value ).toBe( "first" ); } ); + + it( "preserves ordering state when reorder validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ); + builder.orderBy( builder.raw( "FIELD(status, ?)", [ "active" ] ) ); + + expect( function() { + builder.reorder( builder.raw( "FIELD(status, ?)", [ { "unexpected": "value" } ] ) ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.getOrders() ).toHaveLength( 1 ); + expect( builder.getRawBindings().orderBy ).toHaveLength( 1 ); + expect( builder.getRawBindings().orderBy[ 1 ].value ).toBe( "active" ); + } ); } ); } From b0b5a6014e12afe68cf2e4c8dd898ebce09380ab Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 05:24:49 -0600 Subject: [PATCH 060/119] fix(QueryBuilder): preserve returning state on errors --- models/Query/QueryBuilder.cfc | 10 +++++---- .../Query/Abstract/BindingLifecycleSpec.cfc | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index a82dd768..72c22e88 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -2864,18 +2864,20 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } public QueryBuilder function returning( required any columns ) { - variables.returning = isArray( arguments.columns ) ? arguments.columns : listToArray( arguments.columns ); - variables.returning = variables.returning.map( function( column ) { + var returningColumns = isArray( arguments.columns ) ? arguments.columns : listToArray( arguments.columns ); + returningColumns = returningColumns.map( function( column ) { return mapToColumnType( listLast( applyColumnFormatter( column ), "." ) ); } ); + variables.returning = returningColumns; return this; } public QueryBuilder function returningRaw( required any columns ) { - variables.returning = isArray( arguments.columns ) ? arguments.columns : [ arguments.columns ]; - variables.returning = variables.returning.map( function( column ) { + var returningColumns = isArray( arguments.columns ) ? arguments.columns : [ arguments.columns ]; + returningColumns = returningColumns.map( function( column ) { return mapToColumnType( new Expression( column ) ); } ); + variables.returning = returningColumns; return this; } diff --git a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc index 355b7fa5..021d456e 100644 --- a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc +++ b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc @@ -289,6 +289,28 @@ component extends="testbox.system.BaseSpec" { expect( builder.getRawBindings().orderBy ).toHaveLength( 1 ); expect( builder.getRawBindings().orderBy[ 1 ].value ).toBe( "active" ); } ); + + it( "preserves returning columns when replacement validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().returning( "id" ); + + expect( function() { + builder.returning( [ { "unexpected": "value" } ] ); + } ).toThrow(); + + expect( builder.getReturning() ).toHaveLength( 1 ); + expect( builder.getReturning()[ 1 ].value ).toBe( "id" ); + } ); + + it( "preserves raw returning columns when replacement validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().returningRaw( "id" ); + + expect( function() { + builder.returningRaw( [ { "unexpected": "value" } ] ); + } ).toThrow(); + + expect( builder.getReturning() ).toHaveLength( 1 ); + expect( builder.getReturning()[ 1 ].value.getSQL() ).toBe( "id" ); + } ); } ); } From 19df3a2a7a8f6a5231ed69e2e2a49a38edce46f1 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 05:28:30 -0600 Subject: [PATCH 061/119] fix(QueryBuilder): restore bindings after failed upserts --- models/Query/QueryBuilder.cfc | 315 +++++++++--------- .../Query/Abstract/BindingLifecycleSpec.cfc | 17 + 2 files changed, 182 insertions(+), 150 deletions(-) diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 72c22e88..a54e6fd8 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -3020,181 +3020,196 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return; } - clearBindings( except = [ "commonTables" ] ); - - if ( !isNull( arguments.source ) && ( isClosure( arguments.source ) || isCustomFunction( arguments.source ) ) ) { - var callback = arguments.source; - arguments.source = newQuery(); - callback( arguments.source ); + var originalBindings = {}; + for ( var bindingType in variables.bindings ) { + originalBindings[ bindingType ] = variables.bindings[ bindingType ].isEmpty() + ? [] + : arraySlice( variables.bindings[ bindingType ], 1 ); } + var executor = getCollaborator( "QueryExecutor" ); + var commonTableState = executor.captureCommonTableState( this ); - if ( !isNull( arguments.source ) ) { - arguments.source = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.source ); - addBindings( arguments.source.getBindings(), "insert" ); - } + try { + clearBindings( except = [ "commonTables" ] ); - if ( !isArray( arguments.values ) ) { - if ( !isStruct( arguments.values ) ) { - throw( - type = "InvalidSQLType", - message = "Please pass a struct or an array of structs mapping columns to values" - ); + if ( !isNull( arguments.source ) && ( isClosure( arguments.source ) || isCustomFunction( arguments.source ) ) ) { + var callback = arguments.source; + arguments.source = newQuery(); + callback( arguments.source ); } - arguments.values = arrayWrap( arguments.values ); - } - if ( !isNull( arguments.update ) && arguments.update.isEmpty() ) { if ( !isNull( arguments.source ) ) { - return this.insertUsing( - columns = arguments.values, - source = arguments.source, - options = arguments.options, - toSql = arguments.toSql - ); + arguments.source = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.source ); + addBindings( arguments.source.getBindings(), "insert" ); } - return this.insert( values = arguments.values, options = arguments.options, toSql = arguments.toSql ); - } - arguments.target = arrayWrap( arguments.target ).map( function( column ) { - var formatted = listLast( applyColumnFormatter( column ), "." ); - return { "original": column, "formatted": formatted }; - } ); + if ( !isArray( arguments.values ) ) { + if ( !isStruct( arguments.values ) ) { + throw( + type = "InvalidSQLType", + message = "Please pass a struct or an array of structs mapping columns to values" + ); + } + arguments.values = arrayWrap( arguments.values ); + } - var columns = []; - if ( isStruct( arguments.values[ 1 ] ) ) { - columns = getGrammar().resolveInsertColumnNames( arguments.values ); - } else { - columns = arguments.values; - } - columns = columns.map( function( column ) { - var formatted = listLast( applyColumnFormatter( column ), "." ); - return { "original": column, "formatted": formatted }; - } ); - if ( isStruct( arguments.values[ 1 ] ) ) { - columns.sort( function( a, b ) { - return compareNoCase( a.formatted, b.formatted ); + if ( !isNull( arguments.update ) && arguments.update.isEmpty() ) { + if ( !isNull( arguments.source ) ) { + return this.insertUsing( + columns = arguments.values, + source = arguments.source, + options = arguments.options, + toSql = arguments.toSql + ); + } + return this.insert( values = arguments.values, options = arguments.options, toSql = arguments.toSql ); + } + + arguments.target = arrayWrap( arguments.target ).map( function( column ) { + var formatted = listLast( applyColumnFormatter( column ), "." ); + return { "original": column, "formatted": formatted }; } ); - } - var updateArray = []; - if ( isNull( arguments.update ) ) { - arguments.update = columns; - } else { - if ( isArray( arguments.update ) ) { - arguments.update = arguments.update.map( function( column ) { - var formatted = listLast( applyColumnFormatter( column ), "." ); - return { "original": column, "formatted": formatted }; - } ); + var columns = []; + if ( isStruct( arguments.values[ 1 ] ) ) { + columns = getGrammar().resolveInsertColumnNames( arguments.values ); + } else { + columns = arguments.values; } - } - - if ( isArray( arguments.update ) ) { - updateArray = arguments.update; - } else { - updateArray = arguments.update - .keyArray() - .map( function( column ) { - var formatted = listLast( applyColumnFormatter( column ), "." ); - return { original: column, formatted: formatted }; + columns = columns.map( function( column ) { + var formatted = listLast( applyColumnFormatter( column ), "." ); + return { "original": column, "formatted": formatted }; + } ); + if ( isStruct( arguments.values[ 1 ] ) ) { + columns.sort( function( a, b ) { + return compareNoCase( a.formatted, b.formatted ); } ); - } + } - updateArray.sort( function( a, b ) { - return compareNoCase( a.formatted, b.formatted ); - } ); + var updateArray = []; + if ( isNull( arguments.update ) ) { + arguments.update = columns; + } else { + if ( isArray( arguments.update ) ) { + arguments.update = arguments.update.map( function( column ) { + var formatted = listLast( applyColumnFormatter( column ), "." ); + return { "original": column, "formatted": formatted }; + } ); + } + } - var newInsertBindings = []; - if ( isStruct( arguments.values[ 1 ] ) ) { - newInsertBindings = arguments.values.map( function( value ) { - return columns.map( function( column ) { - return getUtils().extractBinding( - value.keyExists( column.original ) ? value[ column.original ] : javacast( "null", "" ), - variables.grammar - ); - } ); - } ); - } + if ( isArray( arguments.update ) ) { + updateArray = arguments.update; + } else { + updateArray = arguments.update + .keyArray() + .map( function( column ) { + var formatted = listLast( applyColumnFormatter( column ), "." ); + return { original: column, formatted: formatted }; + } ); + } - newInsertBindings.each( function( bindingsArray ) { - bindingsArray.each( function( binding ) { - if ( getUtils().isNotExpression( binding ) ) { - addBindings( binding, "insert" ); - } else { - addExpressionBindings( binding, "insert" ); - } + updateArray.sort( function( a, b ) { + return compareNoCase( a.formatted, b.formatted ); } ); - } ); - if ( isStruct( arguments.update ) ) { - var updates = arguments.update; - updateArray.each( function( column ) { - if ( - isNull( updates[ column.original ] ) || - getUtils().isNotExpression( updates[ column.original ] ) - ) { - addBindings( - getUtils().extractBinding( - isNull( updates[ column.original ] ) ? javacast( "null", "" ) : updates[ column.original ], + var newInsertBindings = []; + if ( isStruct( arguments.values[ 1 ] ) ) { + newInsertBindings = arguments.values.map( function( value ) { + return columns.map( function( column ) { + return getUtils().extractBinding( + value.keyExists( column.original ) ? value[ column.original ] : javacast( "null", "" ), variables.grammar - ), - "insert" - ); - } else { - addExpressionBindings( updates[ column.original ], "insert" ); - } - } ); - } + ); + } ); + } ); + } - if ( isClosure( arguments.deleteUnmatched ) || isCustomFunction( arguments.deleteUnmatched ) ) { - var deleteRestrictions = newQuery().setColumnFormatter( ( column ) => { - if ( listLen( column, "." ) > 1 ) { - return column; - } - return "qb_target.#column#"; + newInsertBindings.each( function( bindingsArray ) { + bindingsArray.each( function( binding ) { + if ( getUtils().isNotExpression( binding ) ) { + addBindings( binding, "insert" ); + } else { + addExpressionBindings( binding, "insert" ); + } + } ); } ); - arguments.deleteUnmatched( deleteRestrictions ); - arguments.deleteUnmatched = deleteRestrictions; - } - if ( getUtils().isBuilder( arguments.deleteUnmatched ) ) { - arguments.deleteUnmatched = getCollaborator( "QueryExecutor" ).snapshotBuilder( - this, - arguments.deleteUnmatched - ); - addBindings( arguments.deleteUnmatched.getBindings(), "insert" ); - } + if ( isStruct( arguments.update ) ) { + var updates = arguments.update; + updateArray.each( function( column ) { + if ( + isNull( updates[ column.original ] ) || + getUtils().isNotExpression( updates[ column.original ] ) + ) { + addBindings( + getUtils().extractBinding( + isNull( updates[ column.original ] ) ? javacast( "null", "" ) : updates[ column.original ], + variables.grammar + ), + "insert" + ); + } else { + addExpressionBindings( updates[ column.original ], "insert" ); + } + } ); + } - columns.each( ( c ) => { - c.formatted = mapToColumnType( c.formatted ); - } ); - updateArray.each( ( c ) => { - c.formatted = mapToColumnType( c.formatted ); - } ); - arguments.target.each( ( c ) => { - c.formatted = mapToColumnType( c.formatted ); - } ); + if ( isClosure( arguments.deleteUnmatched ) || isCustomFunction( arguments.deleteUnmatched ) ) { + var deleteRestrictions = newQuery().setColumnFormatter( ( column ) => { + if ( listLen( column, "." ) > 1 ) { + return column; + } + return "qb_target.#column#"; + } ); + arguments.deleteUnmatched( deleteRestrictions ); + arguments.deleteUnmatched = deleteRestrictions; + } - var updateForUpsert = arguments.update; - var targetForUpsert = arguments.target; - var hasSourceForUpsert = !isNull( arguments.source ); - if ( hasSourceForUpsert ) { - var sourceForUpsert = arguments.source; + if ( getUtils().isBuilder( arguments.deleteUnmatched ) ) { + arguments.deleteUnmatched = getCollaborator( "QueryExecutor" ).snapshotBuilder( + this, + arguments.deleteUnmatched + ); + addBindings( arguments.deleteUnmatched.getBindings(), "insert" ); + } + + columns.each( ( c ) => { + c.formatted = mapToColumnType( c.formatted ); + } ); + updateArray.each( ( c ) => { + c.formatted = mapToColumnType( c.formatted ); + } ); + arguments.target.each( ( c ) => { + c.formatted = mapToColumnType( c.formatted ); + } ); + + var updateForUpsert = arguments.update; + var targetForUpsert = arguments.target; + var hasSourceForUpsert = !isNull( arguments.source ); + if ( hasSourceForUpsert ) { + var sourceForUpsert = arguments.source; + } + var deleteUnmatchedForUpsert = arguments.deleteUnmatched; + var matchNullsForUpsert = arguments.matchNulls; + var sql = withWrappingContext( function() { + return getGrammar().compileUpsert( + this, + columns, + newInsertBindings, + updateArray, + updateForUpsert, + targetForUpsert, + hasSourceForUpsert ? sourceForUpsert : javacast( "null", "" ), + deleteUnmatchedForUpsert, + matchNullsForUpsert + ); + } ); + } catch ( any e ) { + executor.restoreCommonTableState( this, commonTableState ); + variables.bindings = originalBindings; + rethrow; } - var deleteUnmatchedForUpsert = arguments.deleteUnmatched; - var matchNullsForUpsert = arguments.matchNulls; - var sql = withWrappingContext( function() { - return getGrammar().compileUpsert( - this, - columns, - newInsertBindings, - updateArray, - updateForUpsert, - targetForUpsert, - hasSourceForUpsert ? sourceForUpsert : javacast( "null", "" ), - deleteUnmatchedForUpsert, - matchNullsForUpsert - ); - } ); if ( toSql ) { return sql; diff --git a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc index 021d456e..f8d088ad 100644 --- a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc +++ b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc @@ -311,6 +311,23 @@ component extends="testbox.system.BaseSpec" { expect( builder.getReturning() ).toHaveLength( 1 ); expect( builder.getReturning()[ 1 ].value.getSQL() ).toBe( "id" ); } ); + + it( "preserves existing bindings when upsert validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ).where( "tenant_id", 42 ); + + expect( function() { + builder.upsert( + values = { "email": { "unexpected": "value" } }, + target = [ "email" ], + update = [ "email" ], + toSQL = true + ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.getRawBindings().where ).toHaveLength( 1 ); + expect( builder.getRawBindings().where[ 1 ].value ).toBe( 42 ); + expect( builder.toSQL() ).toBe( "SELECT * FROM ""users"" WHERE ""tenant_id"" = ?" ); + } ); } ); } From 16798c1dca41f7644b91df8259feade71f34873c Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 05:32:27 -0600 Subject: [PATCH 062/119] fix(QueryBuilder): restore bindings after failed insert using --- models/Query/QueryBuilder.cfc | 65 ++++++++++++------- .../Query/Abstract/BindingLifecycleSpec.cfc | 16 +++++ 2 files changed, 56 insertions(+), 25 deletions(-) diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index a54e6fd8..153f37f0 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -2731,38 +2731,53 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J struct options = {}, boolean toSql = false ) { - if ( isClosure( arguments.source ) || isCustomFunction( arguments.source ) ) { - var callback = arguments.source; - arguments.source = newQuery(); - callback( arguments.source ); + var originalBindings = {}; + for ( var bindingType in variables.bindings ) { + originalBindings[ bindingType ] = variables.bindings[ bindingType ].isEmpty() + ? [] + : arraySlice( variables.bindings[ bindingType ], 1 ); } - arguments.source = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.source ); + var executor = getCollaborator( "QueryExecutor" ); + var commonTableState = executor.captureCommonTableState( this ); - clearBindings( except = [ "commonTables" ] ); + try { + if ( isClosure( arguments.source ) || isCustomFunction( arguments.source ) ) { + var callback = arguments.source; + arguments.source = newQuery(); + callback( arguments.source ); + } + arguments.source = executor.snapshotBuilder( this, arguments.source ); - if ( isNull( arguments.columns ) ) { - arguments.columns = arguments.source - .getColumns() - .map( function( column ) { - return getGrammar().extractAlias( mapToColumnType( column ) ); - } ); - } + clearBindings( except = [ "commonTables" ] ); - var formattedColumns = arguments.columns.map( function( column ) { - var formatted = listLast( applyColumnFormatter( column ), "." ); - return { "original": column, "formatted": formatted }; - } ); + if ( isNull( arguments.columns ) ) { + arguments.columns = arguments.source + .getColumns() + .map( function( column ) { + return getGrammar().extractAlias( mapToColumnType( column ) ); + } ); + } - addBindingsFromBuilder( arguments.source ); + var formattedColumns = arguments.columns.map( function( column ) { + var formatted = listLast( applyColumnFormatter( column ), "." ); + return { "original": column, "formatted": formatted }; + } ); - formattedColumns.each( ( c ) => { - c.formatted = mapToColumnType( c.formatted ); - } ); + addBindingsFromBuilder( arguments.source ); - var sourceQuery = arguments.source; - var sql = withWrappingContext( function() { - return getGrammar().compileInsertUsing( this, formattedColumns, sourceQuery ); - } ); + formattedColumns.each( ( c ) => { + c.formatted = mapToColumnType( c.formatted ); + } ); + + var sourceQuery = arguments.source; + var sql = withWrappingContext( function() { + return getGrammar().compileInsertUsing( this, formattedColumns, sourceQuery ); + } ); + } catch ( any e ) { + executor.restoreCommonTableState( this, commonTableState ); + variables.bindings = originalBindings; + rethrow; + } if ( toSql ) { return sql; diff --git a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc index f8d088ad..640ad19f 100644 --- a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc +++ b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc @@ -328,6 +328,22 @@ component extends="testbox.system.BaseSpec" { expect( builder.getRawBindings().where[ 1 ].value ).toBe( 42 ); expect( builder.toSQL() ).toBe( "SELECT * FROM ""users"" WHERE ""tenant_id"" = ?" ); } ); + + it( "preserves existing bindings when insert-using validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ).where( "tenant_id", 42 ); + var source = builder + .newQuery() + .from( "pending_users" ) + .select( "email" ); + + expect( function() { + builder.insertUsing( source = source, columns = [ { "unexpected": "value" } ], toSQL = true ); + } ).toThrow(); + + expect( builder.getRawBindings().where ).toHaveLength( 1 ); + expect( builder.getRawBindings().where[ 1 ].value ).toBe( 42 ); + expect( builder.toSQL() ).toBe( "SELECT * FROM ""users"" WHERE ""tenant_id"" = ?" ); + } ); } ); } From 6539010e7fbb3e09c4c5161c6836898e5fd6931b Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 05:35:01 -0600 Subject: [PATCH 063/119] fix(QueryBuilder): restore bindings after failed bulk inserts --- models/Query/QueryBuilder.cfc | 70 +++++++++++-------- .../Query/Abstract/BindingLifecycleSpec.cfc | 12 ++++ 2 files changed, 53 insertions(+), 29 deletions(-) diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 153f37f0..9c17d1b0 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -2676,42 +2676,54 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J throw( type = "InvalidSQLType", message = "Please pass structs with at least one column to insertBulk." ); } - clearBindings(); - - var safeChunkSize = arguments.values.len(); - if ( !getGrammar().supportsBulkInsert() && getGrammar().parameterLimit > 0 ) { - safeChunkSize = max( 1, floor( getGrammar().parameterLimit / columnCount ) ); - } - if ( arguments.chunkSize > 0 ) { - safeChunkSize = min( safeChunkSize, arguments.chunkSize ); + var originalBindings = {}; + for ( var bindingType in variables.bindings ) { + originalBindings[ bindingType ] = variables.bindings[ bindingType ].isEmpty() + ? [] + : arraySlice( variables.bindings[ bindingType ], 1 ); } - var results = []; - for ( var offset = 1; offset <= arguments.values.len(); offset += safeChunkSize ) { - var batchSize = min( safeChunkSize, arguments.values.len() - offset + 1 ); - var batch = arguments.values.slice( offset, batchSize ); + try { + clearBindings(); - if ( getGrammar().supportsBulkInsert() ) { - var bulkInsert = getGrammar().prepareBulkInsert( this, batch, arguments.sqlTypes ); - addBindings( [ bulkInsert.binding ], "insert" ); - var sql = withWrappingContext( function() { - return getGrammar().compileBulkInsert( this, bulkInsert.columns ); - } ); - if ( arguments.toSql ) { - results.append( sql ); + var safeChunkSize = arguments.values.len(); + if ( !getGrammar().supportsBulkInsert() && getGrammar().parameterLimit > 0 ) { + safeChunkSize = max( 1, floor( getGrammar().parameterLimit / columnCount ) ); + } + if ( arguments.chunkSize > 0 ) { + safeChunkSize = min( safeChunkSize, arguments.chunkSize ); + } + + var results = []; + for ( var offset = 1; offset <= arguments.values.len(); offset += safeChunkSize ) { + var batchSize = min( safeChunkSize, arguments.values.len() - offset + 1 ); + var batch = arguments.values.slice( offset, batchSize ); + + if ( getGrammar().supportsBulkInsert() ) { + var bulkInsert = getGrammar().prepareBulkInsert( this, batch, arguments.sqlTypes ); + addBindings( [ bulkInsert.binding ], "insert" ); + var sql = withWrappingContext( function() { + return getGrammar().compileBulkInsert( this, bulkInsert.columns ); + } ); + if ( arguments.toSql ) { + results.append( sql ); + } else { + results.append( runQuery( sql, arguments.options, "result" ) ); + clearBindings( only = [ "insert" ] ); + } } else { - results.append( runQuery( sql, arguments.options, "result" ) ); - clearBindings( only = [ "insert" ] ); + var batchQuery = getCollaborator( "QueryExecutor" ).prepareInternalExecutionBuilder( this, clone() ); + results.append( + batchQuery.insert( values = batch, options = arguments.options, toSql = arguments.toSql ) + ); } - } else { - var batchQuery = getCollaborator( "QueryExecutor" ).prepareInternalExecutionBuilder( this, clone() ); - results.append( - batchQuery.insert( values = batch, options = arguments.options, toSql = arguments.toSql ) - ); } - } - return results; + return results; + } catch ( any e ) { + variables.bindings = originalBindings; + rethrow; + } } /** diff --git a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc index 640ad19f..7951b6d8 100644 --- a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc +++ b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc @@ -344,6 +344,18 @@ component extends="testbox.system.BaseSpec" { expect( builder.getRawBindings().where[ 1 ].value ).toBe( 42 ); expect( builder.toSQL() ).toBe( "SELECT * FROM ""users"" WHERE ""tenant_id"" = ?" ); } ); + + it( "preserves existing bindings when bulk-insert validation fails", function() { + var builder = new qb.models.Query.QueryBuilder().from( "users" ).where( "tenant_id", 42 ); + + expect( function() { + builder.insertBulk( values = [ { "email": { "unexpected": "value" } } ], toSQL = true ); + } ).toThrow( type = "QBInvalidQueryParam" ); + + expect( builder.getRawBindings().where ).toHaveLength( 1 ); + expect( builder.getRawBindings().where[ 1 ].value ).toBe( 42 ); + expect( builder.toSQL() ).toBe( "SELECT * FROM ""users"" WHERE ""tenant_id"" = ?" ); + } ); } ); } From 361cc382d528491c6195d485f331ddbe908b31b0 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 05:39:46 -0600 Subject: [PATCH 064/119] fix(QueryUtils): infer byte values as integers --- models/Query/QueryUtils.cfc | 1 + tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 6cf7b386..58d66be1 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -854,6 +854,7 @@ component singleton displayname="QueryUtils" accessors="true" { "AtomicLong", "BigDecimal", "BigInteger", + "Byte", "CFDouble", "Double", "DoubleAccumulator", diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index 78bbb02e..e971c2c1 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -37,6 +37,10 @@ component extends="testbox.system.BaseSpec" { } ); describe( "numbers", function() { + it( "recognizes byte values as integers", function() { + expect( utils.inferSqlType( javacast( "byte", 7 ), variables.mockGrammar ) ).toBe( "INTEGER" ); + } ); + it( "integers", function() { expect( utils.inferSqlType( 100, variables.mockGrammar ) ).toBe( "INTEGER" ); } ); From 34305edec7942b727447a29cb363e5e826d44964 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 05:42:38 -0600 Subject: [PATCH 065/119] fix(QueryUtils): infer Java dates as timestamps --- models/Query/QueryUtils.cfc | 9 ++++++++- tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 58d66be1..a12e1a8c 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -895,7 +895,14 @@ component singleton displayname="QueryUtils" accessors="true" { } return isDate( arguments.value ) && arrayContainsNoCase( - [ "OleDateTime", "DateTimeImpl", "DateTime" ], + [ + "Date", + "DateTime", + "DateTimeImpl", + "OleDateTime", + "Time", + "Timestamp" + ], className ); } diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index e971c2c1..c8008e03 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -62,6 +62,13 @@ component extends="testbox.system.BaseSpec" { expect( utils.inferSqlType( now(), variables.mockGrammar ) ).toBe( "TIMESTAMP" ); } ); + it( "recognizes Java date values as timestamps", function() { + var javaDate = createObject( "java", "java.util.Date" ).init(); + + expect( isDate( javaDate ) ).toBeTrue(); + expect( utils.inferSqlType( javaDate, variables.mockGrammar ) ).toBe( "TIMESTAMP" ); + } ); + it( "empty strings as null", () => { var bindingA = utils.extractBinding( "", variables.mockGrammar ); expect( bindingA.null ).toBeFalse(); From a79c6624d99316227e7545e506a955637a0c796e Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 06:53:28 -0600 Subject: [PATCH 066/119] fix(SQLCommenter): handle Oracle quoted literals --- models/SQLCommenter/SQLCommenter.cfc | 31 +++++++++++++++++++ ...enterOracleQuotedLiteralRegressionSpec.cfc | 20 ++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 tests/specs/SQLCommenterOracleQuotedLiteralRegressionSpec.cfc diff --git a/models/SQLCommenter/SQLCommenter.cfc b/models/SQLCommenter/SQLCommenter.cfc index 05a95eb1..27af1eb4 100644 --- a/models/SQLCommenter/SQLCommenter.cfc +++ b/models/SQLCommenter/SQLCommenter.cfc @@ -102,6 +102,7 @@ component singleton { var sqlLength = len( arguments.sql ); var quote = ""; var dollarQuoteDelimiter = ""; + var oracleQuoteClosing = ""; while ( position <= sqlLength ) { var character = mid( arguments.sql, position, 1 ); @@ -120,6 +121,16 @@ component singleton { continue; } + if ( oracleQuoteClosing != "" ) { + if ( mid( arguments.sql, position, len( oracleQuoteClosing ) ) == oracleQuoteClosing ) { + position += len( oracleQuoteClosing ); + oracleQuoteClosing = ""; + } else { + position++; + } + continue; + } + if ( quote != "" ) { if ( quote == "[" ) { if ( character == "]" ) { @@ -165,6 +176,26 @@ component singleton { } } } + if ( + ( character == "q" || character == "Q" ) && + nextCharacter == "'" && + position + 2 <= sqlLength + ) { + var oracleQuoteOpening = mid( arguments.sql, position + 2, 1 ); + var oracleQuotePairs = { + "[": "]", + "{": "}", + "(": ")", + "<": ">" + }; + oracleQuoteClosing = ( + oracleQuotePairs.keyExists( oracleQuoteOpening ) + ? oracleQuotePairs[ oracleQuoteOpening ] + : oracleQuoteOpening + ) & "'"; + position += 3; + continue; + } if ( character == "'" || character == """" || character == chr( 96 ) || character == "[" ) { quote = character; } diff --git a/tests/specs/SQLCommenterOracleQuotedLiteralRegressionSpec.cfc b/tests/specs/SQLCommenterOracleQuotedLiteralRegressionSpec.cfc new file mode 100644 index 00000000..3047d02b --- /dev/null +++ b/tests/specs/SQLCommenterOracleQuotedLiteralRegressionSpec.cfc @@ -0,0 +1,20 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "SQL commenter Oracle quoted literal regression", function() { + it( "appends comments when comment tokens occur inside alternative quoted literals", function() { + var sqlCommenter = new qb.models.SQLCommenter.SQLCommenter(); + + expect( + sqlCommenter.appendCommentsToSQL( + sql = "SELECT q'[isn't -- a comment /* still literal */]' AS marker FROM dual", + comments = { "framework": "qb" } + ) + ).toBeWithCase( + "SELECT q'[isn't -- a comment /* still literal */]' AS marker FROM dual /*framework='qb'*/" + ); + } ); + } ); + } + +} From af3276923a1af4a558b9b353d8a43da4f782bfee Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 06:55:09 -0600 Subject: [PATCH 067/119] fix(JsonQueryClause): parse negative array indexes --- models/Query/JsonQueryClause.cfc | 2 +- .../PostgresNegativeJsonIndexRegressionSpec.cfc | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 tests/specs/Query/PostgresNegativeJsonIndexRegressionSpec.cfc diff --git a/models/Query/JsonQueryClause.cfc b/models/Query/JsonQueryClause.cfc index e28b2be1..a9f4062c 100644 --- a/models/Query/JsonQueryClause.cfc +++ b/models/Query/JsonQueryClause.cfc @@ -152,7 +152,7 @@ component { */ private any function normalizeJsonPathSegment( required any segment ) { var normalized = trim( arguments.segment ); - return reFind( "^\d+$", normalized ) ? val( normalized ) : normalized; + return reFind( "^-?\d+$", normalized ) ? val( normalized ) : normalized; } } diff --git a/tests/specs/Query/PostgresNegativeJsonIndexRegressionSpec.cfc b/tests/specs/Query/PostgresNegativeJsonIndexRegressionSpec.cfc new file mode 100644 index 00000000..d6c8c926 --- /dev/null +++ b/tests/specs/Query/PostgresNegativeJsonIndexRegressionSpec.cfc @@ -0,0 +1,15 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "PostgreSQL negative JSON index regression", function() { + it( "compiles negative arrow path segments as array indexes", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.PostgresGrammar() ); + + builder.from( "events" ).select( "payload->items->-1 AS lastItem" ); + + expect( builder.toSQL() ).toBe( "SELECT ""payload""->'items'->>-1 AS ""lastItem"" FROM ""events""" ); + } ); + } ); + } + +} From e136c455147379d3b3b2b78da03578109db0d5bf Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 06:56:44 -0600 Subject: [PATCH 068/119] fix(QueryBuilder): keep raw order bindings atomic --- models/Query/QueryBuilder.cfc | 10 +--------- ...derByRawBindingAtomicityRegressionSpec.cfc | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 9 deletions(-) create mode 100644 tests/specs/Query/OrderByRawBindingAtomicityRegressionSpec.cfc diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 9c17d1b0..ff24dfd1 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -2183,15 +2183,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function orderByRaw( required any sql, array bindings = [] ) { - if ( !arrayIsEmpty( arguments.bindings ) ) { - addBindings( - arguments.bindings.map( function( value ) { - return variables.utils.extractBinding( arguments.value, variables.grammar ); - } ), - "orderBy" - ); - } - return orderBy( new Expression( arguments.sql ) ); + return orderBy( raw( arguments.sql, arguments.bindings ) ); } /** diff --git a/tests/specs/Query/OrderByRawBindingAtomicityRegressionSpec.cfc b/tests/specs/Query/OrderByRawBindingAtomicityRegressionSpec.cfc new file mode 100644 index 00000000..13a579d4 --- /dev/null +++ b/tests/specs/Query/OrderByRawBindingAtomicityRegressionSpec.cfc @@ -0,0 +1,20 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "orderByRaw binding atomicity regression", function() { + it( "does not retain bindings when the raw expression is invalid", function() { + var builder = new qb.models.Query.QueryBuilder() + .from( "users" ) + .orderByRaw( "CASE WHEN id = ? THEN 0 ELSE 1 END", [ 1 ] ); + var originalBindings = duplicate( builder.getBindings() ); + + expect( function() { + builder.orderByRaw( { "invalid": true }, [ 2 ] ); + } ).toThrow(); + + expect( builder.getBindings() ).toBe( originalBindings ); + } ); + } ); + } + +} From caf4ff008808303e849d7a041ebefa400890e28d Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 06:58:42 -0600 Subject: [PATCH 069/119] fix(QueryUtils): normalize prefixed SQL types --- models/Query/QueryUtils.cfc | 3 ++- .../CfSqlTypeInlineBindingRegressionSpec.cfc | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 tests/specs/Query/CfSqlTypeInlineBindingRegressionSpec.cfc diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index a12e1a8c..a25c3458 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -536,7 +536,8 @@ component singleton displayname="QueryUtils" accessors="true" { return "NULL"; } - switch ( arguments.sqltype ) { + var normalizedSqlType = reReplaceNoCase( trim( arguments.sqltype ), "^cf_sql_", "" ); + switch ( normalizedSqlType ) { case "INTEGER": case "NUMERIC": case "DECIMAL": diff --git a/tests/specs/Query/CfSqlTypeInlineBindingRegressionSpec.cfc b/tests/specs/Query/CfSqlTypeInlineBindingRegressionSpec.cfc new file mode 100644 index 00000000..350407ec --- /dev/null +++ b/tests/specs/Query/CfSqlTypeInlineBindingRegressionSpec.cfc @@ -0,0 +1,22 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "cf_sql inline binding regression", function() { + it( "renders prefixed numeric SQL types without string quotes", function() { + var utils = new qb.models.Query.QueryUtils(); + var grammar = new qb.models.Grammars.BaseGrammar( utils ); + var binding = utils.extractBinding( { "value": 42, "cfsqltype": "cf_sql_integer" }, grammar ); + + expect( + utils.replaceBindings( + "SELECT ?", + [ binding ], + true, + grammar + ) + ).toBe( "SELECT 42" ); + } ); + } ); + } + +} From c7af550ca4f210fd37095231d3376271b6d46fb0 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 07:00:12 -0600 Subject: [PATCH 070/119] fix(PostgresGrammar): preserve text default colons --- models/Grammars/PostgresGrammar.cfc | 8 +++---- .../PostgresTextDefaultCastRegressionSpec.cfc | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 tests/specs/Schema/PostgresTextDefaultCastRegressionSpec.cfc diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index 1807cdba..481f0870 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -461,6 +461,10 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { function wrapDefaultType( column ) { var defaultValue = column.getDefaultValue(); + if ( shouldQuoteDefaultValue( arguments.column ) ) { + return quoteStringLiteral( defaultValue ); + } + // Normalize PostgreSQL cast shorthand (value::TYPE) so runtimes that // parse ":" for named params don't break schema DDL execution. var castPosition = 0; @@ -478,10 +482,6 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } } - if ( shouldQuoteDefaultValue( arguments.column ) ) { - return quoteStringLiteral( defaultValue ); - } - switch ( column.getType() ) { case "boolean": return uCase( defaultValue ); diff --git a/tests/specs/Schema/PostgresTextDefaultCastRegressionSpec.cfc b/tests/specs/Schema/PostgresTextDefaultCastRegressionSpec.cfc new file mode 100644 index 00000000..b8e09090 --- /dev/null +++ b/tests/specs/Schema/PostgresTextDefaultCastRegressionSpec.cfc @@ -0,0 +1,24 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "PostgreSQL text default cast regression", function() { + it( "preserves double colons inside text defaults", function() { + var schema = new qb.models.Schema.SchemaBuilder( grammar = new qb.models.Grammars.PostgresGrammar() ); + + var statements = schema + .create( + "hosts", + function( table ) { + table.string( "address" ).default( "::1" ); + }, + {}, + false + ) + .toSQL(); + + expect( statements ).toBe( [ "CREATE TABLE ""hosts"" (""address"" VARCHAR(255) NOT NULL DEFAULT '::1')" ] ); + } ); + } ); + } + +} From 533ee643cf0d917ed99898afd2ded0765e8ba3e0 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 07:03:58 -0600 Subject: [PATCH 071/119] fix(QueryUtils): render Boolean SQL literals --- models/Query/QueryUtils.cfc | 6 +++++ .../BooleanInlineBindingRegressionSpec.cfc | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 tests/specs/Query/BooleanInlineBindingRegressionSpec.cfc diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index a25c3458..e8890c45 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -537,6 +537,12 @@ component singleton displayname="QueryUtils" accessors="true" { } var normalizedSqlType = reReplaceNoCase( trim( arguments.sqltype ), "^cf_sql_", "" ); + if ( + listFindNoCase( "BOOLEAN,OTHER", normalizedSqlType ) && + checkIsActuallyBoolean( arguments.value ) + ) { + return arguments.value ? "TRUE" : "FALSE"; + } switch ( normalizedSqlType ) { case "INTEGER": case "NUMERIC": diff --git a/tests/specs/Query/BooleanInlineBindingRegressionSpec.cfc b/tests/specs/Query/BooleanInlineBindingRegressionSpec.cfc new file mode 100644 index 00000000..b5a4099e --- /dev/null +++ b/tests/specs/Query/BooleanInlineBindingRegressionSpec.cfc @@ -0,0 +1,23 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "boolean inline binding regression", function() { + it( "renders PostgreSQL Boolean bindings as SQL literals", function() { + var utils = new qb.models.Query.QueryUtils(); + var grammar = new qb.models.Grammars.PostgresGrammar( utils ); + var binding = utils.extractBinding( true, grammar ); + + expect( binding.cfsqltype ).toBe( "OTHER" ); + expect( + utils.replaceBindings( + "SELECT ?", + [ binding ], + true, + grammar + ) + ).toBe( "SELECT TRUE" ); + } ); + } ); + } + +} From 28eaa79245b97ecd3e574521c4f0ceb7633ee089 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 07:06:38 -0600 Subject: [PATCH 072/119] fix(QueryBuilder): clamp pagination page offsets --- models/Query/QueryBuilder.cfc | 1 + .../Query/NegativePageOffsetRegressionSpec.cfc | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 tests/specs/Query/NegativePageOffsetRegressionSpec.cfc diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index ff24dfd1..d2799343 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -2412,6 +2412,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return this; } + arguments.page = arguments.page > 0 ? arguments.page : 1; arguments.maxRows = arguments.maxRows > 0 ? arguments.maxRows : 0; this.offset( arguments.page * arguments.maxRows - arguments.maxRows ); this.limit( arguments.maxRows ); diff --git a/tests/specs/Query/NegativePageOffsetRegressionSpec.cfc b/tests/specs/Query/NegativePageOffsetRegressionSpec.cfc new file mode 100644 index 00000000..ff722ea3 --- /dev/null +++ b/tests/specs/Query/NegativePageOffsetRegressionSpec.cfc @@ -0,0 +1,15 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "negative page offset regression", function() { + it( "clamps non-positive pages before calculating the offset", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.PostgresGrammar() ); + + builder.from( "users" ).forPage( -1, 10 ); + + expect( builder.toSQL() ).toBe( "SELECT * FROM ""users"" LIMIT 10 OFFSET 0" ); + } ); + } ); + } + +} From fc32148b9b3e13f3105dc7f2b8906e5bcafcd36d Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 07:07:52 -0600 Subject: [PATCH 073/119] fix(QueryBuilder): clamp negative row bounds --- models/Query/QueryBuilder.cfc | 4 ++-- .../NegativeLimitOffsetRegressionSpec.cfc | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 tests/specs/Query/NegativeLimitOffsetRegressionSpec.cfc diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index d2799343..eba3ae14 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -2369,7 +2369,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function limit( required numeric value ) { - variables.limitValue = value; + variables.limitValue = arguments.value > 0 ? arguments.value : 0; return this; } @@ -2393,7 +2393,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function offset( required numeric value ) { - variables.offsetValue = value; + variables.offsetValue = arguments.value > 0 ? arguments.value : 0; return this; } diff --git a/tests/specs/Query/NegativeLimitOffsetRegressionSpec.cfc b/tests/specs/Query/NegativeLimitOffsetRegressionSpec.cfc new file mode 100644 index 00000000..4a753cca --- /dev/null +++ b/tests/specs/Query/NegativeLimitOffsetRegressionSpec.cfc @@ -0,0 +1,18 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "negative limit and offset regression", function() { + it( "clamps direct row bounds to valid SQL values", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.PostgresGrammar() ); + + builder + .from( "users" ) + .limit( -10 ) + .offset( -20 ); + + expect( builder.toSQL() ).toBe( "SELECT * FROM ""users"" LIMIT 0 OFFSET 0" ); + } ); + } ); + } + +} From 7653fb79be16d48b7f1877dbc9a9b04f147c81c1 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 07:10:40 -0600 Subject: [PATCH 074/119] fix(PredicateClause): preserve null IN values --- models/Grammars/BaseGrammar.cfc | 28 ++++++++++++------- models/Query/PredicateClause.cfc | 9 +++++- .../WhereInNullBindingRegressionSpec.cfc | 27 ++++++++++++++++++ 3 files changed, 53 insertions(+), 11 deletions(-) create mode 100644 tests/specs/Query/WhereInNullBindingRegressionSpec.cfc diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 12413a24..3615d25e 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -710,11 +710,7 @@ component displayname="Grammar" accessors="true" singleton { * @return string */ private string function whereIn( required QueryBuilder query, required struct where ) { - var placeholderString = where.values - .map( function( value ) { - return variables.utils.isExpression( value ) ? value.getSql() : "?"; - } ) - .toList( ", " ); + var placeholderString = compileWhereInPlaceholders( where.values ); if ( placeholderString == "" ) { return "0 = 1"; } @@ -730,17 +726,29 @@ component displayname="Grammar" accessors="true" singleton { * @return string */ private string function whereNotIn( required QueryBuilder query, required struct where ) { - var placeholderString = where.values - .map( function( value ) { - return variables.utils.isExpression( value ) ? value.getSql() : "?"; - } ) - .toList( ", " ); + var placeholderString = compileWhereInPlaceholders( where.values ); if ( placeholderString == "" ) { return "1 = 1"; } return "#wrapColumn( where.column )# NOT IN (#placeholderString#)"; } + /** + * Compiles placeholders for IN values while preserving sparse and null array positions. + */ + private string function compileWhereInPlaceholders( required array values ) { + var placeholders = []; + for ( var valueIndex = 1; valueIndex <= arguments.values.len(); valueIndex++ ) { + if ( !arrayIsDefined( arguments.values, valueIndex ) || isNull( arguments.values[ valueIndex ] ) ) { + placeholders.append( "?" ); + continue; + } + var value = arguments.values[ valueIndex ]; + placeholders.append( variables.utils.isExpression( value ) ? value.getSql() : "?" ); + } + return placeholders.toList( ", " ); + } + /** * Compiles a bulk IN or NOT IN statement using one serialized binding. * diff --git a/models/Query/PredicateClause.cfc b/models/Query/PredicateClause.cfc index 9b072816..1e6e53bd 100644 --- a/models/Query/PredicateClause.cfc +++ b/models/Query/PredicateClause.cfc @@ -84,7 +84,14 @@ component { var type = arguments.negate ? "notIn" : "in"; var typedColumn = toColumnType( arguments.builder, arguments.column ); var bindings = arguments.values.isEmpty() ? [] : arguments.builder.extractColumnBindings( [ typedColumn ] ); - for ( var value in arguments.values ) { + for ( var valueIndex = 1; valueIndex <= arguments.values.len(); valueIndex++ ) { + if ( !arrayIsDefined( arguments.values, valueIndex ) || isNull( arguments.values[ valueIndex ] ) ) { + bindings.append( + arguments.builder.getUtils().extractBinding( grammar = arguments.builder.getGrammar() ) + ); + continue; + } + var value = arguments.values[ valueIndex ]; if ( arguments.builder.getUtils().isExpression( value ) ) { bindings.append( arguments.builder.extractExpressionBindings( value ), true ); } else { diff --git a/tests/specs/Query/WhereInNullBindingRegressionSpec.cfc b/tests/specs/Query/WhereInNullBindingRegressionSpec.cfc new file mode 100644 index 00000000..400f8d6f --- /dev/null +++ b/tests/specs/Query/WhereInNullBindingRegressionSpec.cfc @@ -0,0 +1,27 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "WHERE IN null binding regression", function() { + it( "preserves null array positions as null bindings", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.PostgresGrammar() ); + + builder.from( "users" ).whereIn( "id", [ 1, javacast( "null", "" ), 2 ] ); + + expect( builder.toSQL() ).toBe( "SELECT * FROM ""users"" WHERE ""id"" IN (?, ?, ?)" ); + expect( builder.getBindings() ).toHaveLength( 3 ); + expect( builder.getBindings()[ 2 ].null ).toBeTrue(); + } ); + + it( "preserves null array positions in NOT IN predicates", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.PostgresGrammar() ); + + builder.from( "users" ).whereNotIn( "id", [ 1, javacast( "null", "" ), 2 ] ); + + expect( builder.toSQL() ).toBe( "SELECT * FROM ""users"" WHERE ""id"" NOT IN (?, ?, ?)" ); + expect( builder.getBindings() ).toHaveLength( 3 ); + expect( builder.getBindings()[ 2 ].null ).toBeTrue(); + } ); + } ); + } + +} From 015f4a2cea1f1df6b831f386ec754632430282ef Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 07:15:23 -0600 Subject: [PATCH 075/119] fix(QueryBuilder): preserve null expression bindings --- models/Query/QueryBuilder.cfc | 19 ++++++++++++++----- ...RawExpressionNullBindingRegressionSpec.cfc | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 tests/specs/Query/RawExpressionNullBindingRegressionSpec.cfc diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index eba3ae14..72f314e1 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -3353,11 +3353,20 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * Normalizes the bindings carried by an Expression for query execution. */ public array function extractExpressionBindings( required any expression ) { - return arguments.expression - .getBindings() - .map( function( binding ) { - return utils.extractBinding( binding, variables.grammar ); - } ); + var expressionBindings = arguments.expression.getBindings(); + var extractedBindings = []; + if ( !expressionBindings.isEmpty() ) { + arrayResize( extractedBindings, expressionBindings.len() ); + } + for ( var bindingIndex = 1; bindingIndex <= expressionBindings.len(); bindingIndex++ ) { + extractedBindings[ bindingIndex ] = !arrayIsDefined( expressionBindings, bindingIndex ) || isNull( + expressionBindings[ bindingIndex ] + ) ? utils.extractBinding( grammar = variables.grammar ) : utils.extractBinding( + expressionBindings[ bindingIndex ], + variables.grammar + ); + } + return extractedBindings; } /** diff --git a/tests/specs/Query/RawExpressionNullBindingRegressionSpec.cfc b/tests/specs/Query/RawExpressionNullBindingRegressionSpec.cfc new file mode 100644 index 00000000..ea3a223a --- /dev/null +++ b/tests/specs/Query/RawExpressionNullBindingRegressionSpec.cfc @@ -0,0 +1,17 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "raw expression null binding regression", function() { + it( "preserves null positions in raw expression bindings", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.PostgresGrammar() ); + + builder.from( "users" ).selectRaw( "COALESCE(?, 0) AS score", [ javacast( "null", "" ) ] ); + + expect( builder.toSQL() ).toBe( "SELECT COALESCE(?, 0) AS score FROM ""users""" ); + expect( builder.getBindings() ).toHaveLength( 1 ); + expect( builder.getBindings()[ 1 ].null ).toBeTrue(); + } ); + } ); + } + +} From 86042bca3438f5d00a48db9b39768c180f9e6cc4 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 07:30:49 -0600 Subject: [PATCH 076/119] fix(QueryUtils): render CLOB values as text --- models/Query/QueryUtils.cfc | 2 +- .../Query/ClobInlineBindingRegressionSpec.cfc | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 tests/specs/Query/ClobInlineBindingRegressionSpec.cfc diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index e8890c45..58b8b53c 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -566,8 +566,8 @@ component singleton displayname="QueryUtils" accessors="true" { case "NULL": return "NULL"; case "BLOB": - case "CLOB": return toBase64( value ); + case "CLOB": case "VARCHAR": case "NVARCHAR": case "CHAR": diff --git a/tests/specs/Query/ClobInlineBindingRegressionSpec.cfc b/tests/specs/Query/ClobInlineBindingRegressionSpec.cfc new file mode 100644 index 00000000..3252a5c1 --- /dev/null +++ b/tests/specs/Query/ClobInlineBindingRegressionSpec.cfc @@ -0,0 +1,22 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "CLOB inline binding regression", function() { + it( "renders CLOB bindings as escaped text literals", function() { + var utils = new qb.models.Query.QueryUtils(); + var grammar = new qb.models.Grammars.BaseGrammar( utils ); + var binding = utils.extractBinding( { "value": "Pete's notes", "cfsqltype": "CLOB" }, grammar ); + + expect( + utils.replaceBindings( + "SELECT ?", + [ binding ], + true, + grammar + ) + ).toBe( "SELECT 'Pete''s notes'" ); + } ); + } ); + } + +} From f74050836224e64347ae06dc50436c0e019da05d Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 07:34:28 -0600 Subject: [PATCH 077/119] fix(QueryBuilder): restore value query columns --- models/Query/QueryBuilder.cfc | 6 +-- .../TemporaryValueColumnsRegressionSpec.cfc | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 tests/specs/Query/TemporaryValueColumnsRegressionSpec.cfc diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 72f314e1..e603af2a 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -3806,9 +3806,8 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J struct options = {} ) { return withReturnFormat( "query", function() { - select( column ); take( 1 ); - var result = get( options = options ); + var result = get( columns = column, options = options ); if ( result.recordCount <= 0 ) { if ( throwWhenNotFound ) { throw( @@ -3857,8 +3856,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J */ public array function values( required any column, struct options = {} ) { return withReturnFormat( "query", function() { - select( column ); - var result = get( options = options ); + var result = get( columns = column, options = options ); var columnName = getFunctionList().keyExists( "queryColumnList" ) ? queryColumnList( result ).listFirst() : getMetadata( result )[ 1 ].name; diff --git a/tests/specs/Query/TemporaryValueColumnsRegressionSpec.cfc b/tests/specs/Query/TemporaryValueColumnsRegressionSpec.cfc new file mode 100644 index 00000000..9273b0fe --- /dev/null +++ b/tests/specs/Query/TemporaryValueColumnsRegressionSpec.cfc @@ -0,0 +1,38 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "value column restoration regression", function() { + it( "restores selected columns after retrieving one value", function() { + var builder = getBuilder(); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""name"" FROM ""users"" LIMIT 1", options = {} ) + .$results( queryNew( "name", "varchar", [ { name: "foo" } ] ) ); + + builder.select( "id" ).from( "users" ); + + expect( builder.value( "name" ) ).toBe( "foo" ); + expect( builder.getColumns().map( ( column ) => column.value ) ).toBe( [ "id" ] ); + } ); + + it( "restores selected columns after retrieving a value list", function() { + var builder = getBuilder(); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""name"" FROM ""users""", options = {} ) + .$results( queryNew( "name", "varchar", [ { name: "foo" }, { name: "bar" } ] ) ); + + builder.select( "id" ).from( "users" ); + + expect( builder.values( "name" ) ).toBe( [ "foo", "bar" ] ); + expect( builder.getColumns().map( ( column ) => column.value ) ).toBe( [ "id" ] ); + } ); + } ); + } + + private function getBuilder() { + var grammar = getMockBox().createMock( "qb.models.Grammars.BaseGrammar" ).init(); + return getMockBox().createMock( "qb.models.Query.QueryBuilder" ).init( grammar ); + } + +} From 686159a6ff4d583ee50e84415868cc9b5145a680 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 07:38:11 -0600 Subject: [PATCH 078/119] fix(QueryUtils): normalize inferred SQL types --- models/Query/QueryUtils.cfc | 12 ++++-- .../PrefixedInferredSqlTypeRegressionSpec.cfc | 41 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 tests/specs/Query/PrefixedInferredSqlTypeRegressionSpec.cfc diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 58b8b53c..53bf4ba3 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -506,11 +506,11 @@ component singleton displayname="QueryUtils" accessors="true" { if ( isStruct( value ) ) { if ( structKeyExists( value, "cfsqltype" ) ) { - return value.cfsqltype; + return normalizeSqlType( value.cfsqltype ); } if ( structKeyExists( value, "sqltype" ) ) { - return value.sqltype; + return normalizeSqlType( value.sqltype ); } return structKeyExists( value, "value" ) ? inferSqlType( value.value, grammar ) : "VARCHAR"; @@ -536,7 +536,7 @@ component singleton displayname="QueryUtils" accessors="true" { return "NULL"; } - var normalizedSqlType = reReplaceNoCase( trim( arguments.sqltype ), "^cf_sql_", "" ); + var normalizedSqlType = normalizeSqlType( arguments.sqltype ); if ( listFindNoCase( "BOOLEAN,OTHER", normalizedSqlType ) && checkIsActuallyBoolean( arguments.value ) @@ -583,6 +583,10 @@ component singleton displayname="QueryUtils" accessors="true" { } } + private string function normalizeSqlType( required string sqltype ) { + return reReplaceNoCase( trim( arguments.sqltype ), "^cf_sql_", "" ).uCase(); + } + /** * Returns true if a value is an Expression. * @@ -879,7 +883,7 @@ component singleton displayname="QueryUtils" accessors="true" { private string function deriveNumericSqlType( required numeric value ) { var isInteger = reFind( "^-?\d+$", arguments.value ) > 0; - return isInteger ? variables.integerSqlType : variables.decimalSqlType; + return normalizeSqlType( isInteger ? variables.integerSqlType : variables.decimalSqlType ); } /** diff --git a/tests/specs/Query/PrefixedInferredSqlTypeRegressionSpec.cfc b/tests/specs/Query/PrefixedInferredSqlTypeRegressionSpec.cfc new file mode 100644 index 00000000..fc4ff2fa --- /dev/null +++ b/tests/specs/Query/PrefixedInferredSqlTypeRegressionSpec.cfc @@ -0,0 +1,41 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "prefixed inferred SQL type regression", function() { + it( "normalizes declared query parameter SQL types", function() { + var utils = new qb.models.Query.QueryUtils(); + var grammar = new qb.models.Grammars.BaseGrammar( utils ); + + expect( + utils.inferSqlType( + [ { value: 18, cfsqltype: "cf_sql_integer" }, { value: 21, sqltype: "INTEGER" } ], + grammar + ) + ).toBe( "INTEGER" ); + } ); + + it( "normalizes configured numeric SQL types", function() { + var utils = new qb.models.Query.QueryUtils( + integerSqlType = "cf_sql_bigint", + decimalSqlType = "cf_sql_numeric" + ); + var grammar = new qb.models.Grammars.BaseGrammar( utils ); + + expect( utils.inferSqlType( 18, grammar ) ).toBe( "BIGINT" ); + expect( utils.inferSqlType( 18.5, grammar ) ).toBe( "NUMERIC" ); + } ); + + it( "casts PostgreSQL JSON scalars using prefixed query parameter types", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.PostgresGrammar() ); + + var sql = builder + .from( "users" ) + .where( "profile->age", ">=", { value: 18, cfsqltype: "cf_sql_integer" } ) + .toSQL(); + + expect( sql ).toBe( "SELECT * FROM ""users"" WHERE CAST(""profile""->>'age' AS NUMERIC) >= ?" ); + } ); + } ); + } + +} From b44c493e62883691c482a615c5784f4ebbe12f8a Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 07:44:43 -0600 Subject: [PATCH 079/119] fix(QueryBuilder): normalize pagination metadata pages --- models/Query/QueryBuilder.cfc | 2 + ...alizedPaginationMetadataRegressionSpec.cfc | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 tests/specs/Query/NormalizedPaginationMetadataRegressionSpec.cfc diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index e603af2a..452b210c 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -2430,6 +2430,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return PaginationCollector */ public any function paginate( numeric page = 1, numeric maxRows = 25, struct options = {} ) { + arguments.page = arguments.page > 0 ? arguments.page : 1; var totalRecords = getCountForPagination( options = options ); var results = forPage( page, maxRows ).get( options = options ); return getPaginationCollector().generateWithResults( @@ -2452,6 +2453,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return PaginationCollector */ public any function simplePaginate( numeric page = 1, numeric maxRows = 25, struct options = {} ) { + arguments.page = arguments.page > 0 ? arguments.page : 1; var shouldReturnAllRows = shouldMaxRowsOverrideToAll( arguments.maxRows ); var paginationQuery = forPage( arguments.page, arguments.maxRows ); if ( !shouldReturnAllRows ) { diff --git a/tests/specs/Query/NormalizedPaginationMetadataRegressionSpec.cfc b/tests/specs/Query/NormalizedPaginationMetadataRegressionSpec.cfc new file mode 100644 index 00000000..d40ed09e --- /dev/null +++ b/tests/specs/Query/NormalizedPaginationMetadataRegressionSpec.cfc @@ -0,0 +1,41 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "normalized pagination metadata regression", function() { + it( "passes the normalized page to pagination collectors", function() { + var builder = getBuilder(); + builder.$( "count", 1 ); + builder.$( "runQuery", queryNew( "id", "integer", [ { id: 1 } ] ) ); + builder.setPaginationCollector( { + "generateWithResults": function( totalRecords, results, page, maxRows ) { + return { page: page }; + } + } ); + + var results = builder.from( "users" ).paginate( page = -1 ); + + expect( results.page ).toBe( 1 ); + } ); + + it( "passes the normalized page to simple pagination collectors", function() { + var builder = getBuilder(); + builder.$( "runQuery", queryNew( "id", "integer", [ { id: 1 } ] ) ); + builder.setPaginationCollector( { + "generateSimpleWithResults": function( results, page, maxRows ) { + return { page: page }; + } + } ); + + var results = builder.from( "users" ).simplePaginate( page = -1 ); + + expect( results.page ).toBe( 1 ); + } ); + } ); + } + + private function getBuilder() { + var grammar = getMockBox().createMock( "qb.models.Grammars.BaseGrammar" ).init(); + return getMockBox().createMock( "qb.models.Query.QueryBuilder" ).init( grammar ); + } + +} From 2c5c767fbb4bbf5d7b669cf8296d94def224de63 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 08:23:50 -0600 Subject: [PATCH 080/119] fix(QueryBuilder): preserve pretend mode on reset --- models/Query/QueryBuilder.cfc | 2 ++ tests/specs/Query/PretendResetRegressionSpec.cfc | 15 +++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 tests/specs/Query/PretendResetRegressionSpec.cfc diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 452b210c..7c44fe7e 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -515,7 +515,9 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return QueryBuilder */ public QueryBuilder function reset() { + var wasPretending = variables.pretending; setDefaultValues(); + variables.pretending = wasPretending; return this; } diff --git a/tests/specs/Query/PretendResetRegressionSpec.cfc b/tests/specs/Query/PretendResetRegressionSpec.cfc new file mode 100644 index 00000000..049ac14b --- /dev/null +++ b/tests/specs/Query/PretendResetRegressionSpec.cfc @@ -0,0 +1,15 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "pretend reset regression", function() { + it( "keeps pretend mode enabled when resetting the same builder", function() { + var builder = new qb.models.Query.QueryBuilder().pretend(); + + builder.reset(); + + expect( builder.isPretending() ).toBeTrue(); + } ); + } ); + } + +} From 9589833ce6b8dc06f06e3450b171a8c9e9f37b38 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 08:27:33 -0600 Subject: [PATCH 081/119] fix(QueryBuilder): omit wildcard insert targets --- models/Grammars/BaseGrammar.cfc | 3 ++- models/Grammars/MySQLGrammar.cfc | 3 ++- models/Query/QueryBuilder.cfc | 3 +++ .../InsertUsingWildcardRegressionSpec.cfc | 23 +++++++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 tests/specs/Query/InsertUsingWildcardRegressionSpec.cfc diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 3615d25e..d8aa1be3 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -1190,9 +1190,10 @@ component displayname="Grammar" accessors="true" singleton { return wrapColumn( column.formatted ); } ) .toList( ", " ); + var targetColumns = columnsString == "" ? "" : " (#columnsString#)"; return trim( - compileCommonTables( query, query.getCommonTables() ) & " INSERT INTO #wrapTable( arguments.query.getTableName() )# (#columnsString#) #compileSelect( arguments.source )#" + compileCommonTables( query, query.getCommonTables() ) & " INSERT INTO #wrapTable( arguments.query.getTableName() )##targetColumns# #compileSelect( arguments.source )#" ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { diff --git a/models/Grammars/MySQLGrammar.cfc b/models/Grammars/MySQLGrammar.cfc index 18c5ebd5..7b5ec372 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -274,10 +274,11 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { return wrapColumn( column.formatted ); } ) .toList( ", " ); + var targetColumns = columnsString == "" ? "" : " (#columnsString#)"; var cteClause = query.getCommonTables().isEmpty() ? "" : " #compileCommonTables( query, query.getCommonTables() )#"; - return "INSERT INTO #wrapTable( arguments.query.getTableName() )# (#columnsString#)#cteClause# #compileSelect( arguments.source )#"; + return "INSERT INTO #wrapTable( arguments.query.getTableName() )##targetColumns##cteClause# #compileSelect( arguments.source )#"; } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 7c44fe7e..07d10810 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -2765,6 +2765,9 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J .map( function( column ) { return getGrammar().extractAlias( mapToColumnType( column ) ); } ); + if ( arguments.columns.len() == 1 && arguments.columns[ 1 ] == "*" ) { + arguments.columns = []; + } } var formattedColumns = arguments.columns.map( function( column ) { diff --git a/tests/specs/Query/InsertUsingWildcardRegressionSpec.cfc b/tests/specs/Query/InsertUsingWildcardRegressionSpec.cfc new file mode 100644 index 00000000..e3932176 --- /dev/null +++ b/tests/specs/Query/InsertUsingWildcardRegressionSpec.cfc @@ -0,0 +1,23 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "insert using wildcard regression", function() { + it( "omits the target column list for an implicit wildcard source", function() { + var builder = new qb.models.Query.QueryBuilder().from( "archived_users" ); + + var sql = builder.insertUsing( source = builder.newQuery().from( "users" ), toSql = true ); + + expect( sql ).toBe( "INSERT INTO ""archived_users"" SELECT * FROM ""users""" ); + } ); + + it( "omits the target column list for MySQL wildcard sources", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.MySQLGrammar() ).from( "archived_users" ); + + var sql = builder.insertUsing( source = builder.newQuery().from( "users" ), toSql = true ); + + expect( sql ).toBe( "INSERT INTO `archived_users` SELECT * FROM `users`" ); + } ); + } ); + } + +} From 71b9e4221e19acd791ec607732f24bf0388625db Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 08:30:44 -0600 Subject: [PATCH 082/119] fix(OracleGrammar): preserve user row number columns --- models/Grammars/OracleGrammar.cfc | 5 ++- .../OracleRowNumberColumnRegressionSpec.cfc | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 tests/specs/Query/OracleRowNumberColumnRegressionSpec.cfc diff --git a/models/Grammars/OracleGrammar.cfc b/models/Grammars/OracleGrammar.cfc index 25c268c3..c4513586 100644 --- a/models/Grammars/OracleGrammar.cfc +++ b/models/Grammars/OracleGrammar.cfc @@ -87,7 +87,10 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { */ public any function runQuery( sql, bindings, options ) { var result = super.runQuery( argumentCollection = arguments ); - if ( isQuery( result ) && result.recordCount > 0 ) { + if ( + isQuery( result ) && + findNoCase( "SELECT * FROM (SELECT results.*, ROWNUM AS ""QB_RN"" FROM (", arguments.sql ) > 0 + ) { return utils.queryRemoveColumns( result, "QB_RN" ); } return result; diff --git a/tests/specs/Query/OracleRowNumberColumnRegressionSpec.cfc b/tests/specs/Query/OracleRowNumberColumnRegressionSpec.cfc new file mode 100644 index 00000000..194193c4 --- /dev/null +++ b/tests/specs/Query/OracleRowNumberColumnRegressionSpec.cfc @@ -0,0 +1,40 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "Oracle row number column regression", function() { + it( "preserves application QB_RN columns outside generated pagination queries", function() { + var grammar = getMockBox().createMock( "qb.models.Grammars.OracleGrammar" ).init(); + grammar.$property( + propertyName = "userRows", + mock = queryNew( "QB_RN,name", "integer,varchar", [ { QB_RN: 7, name: "Ada" } ] ) + ); + + var result = grammar.runQuery( + sql = "SELECT QB_RN, name FROM userRows", + bindings = [], + options = { dbtype: "query" } + ); + + expect( queryColumnList( result ) ).toInclude( "QB_RN" ); + expect( result.QB_RN[ 1 ] ).toBe( 7 ); + } ); + + it( "removes generated QB_RN metadata from empty pagination results", function() { + var grammar = getMockBox().createMock( "qb.models.Grammars.OracleGrammar" ).init(); + grammar.$property( propertyName = "userRows", mock = queryNew( "QB_RN,name", "integer,varchar" ) ); + + var result = grammar.runQuery( + sql = "/* SELECT * FROM (SELECT results.*, ROWNUM AS ""QB_RN"" FROM ( */ SELECT QB_RN, name FROM userRows", + bindings = [], + options = { dbtype: "query" } + ); + + expect( result ).toBeQuery(); + expect( result.recordCount ).toBe( 0 ); + expect( queryColumnList( result ) ).notToInclude( "QB_RN" ); + expect( queryColumnList( result ) ).toInclude( "name" ); + } ); + } ); + } + +} From 59ee441151553833929f3e076f6f078c12405b4a Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 08:34:54 -0600 Subject: [PATCH 083/119] fix(DerbyGrammar): preserve user row number columns --- models/Grammars/DerbyGrammar.cfc | 18 -------------- .../DerbyRowNumberColumnRegressionSpec.cfc | 24 +++++++++++++++++++ 2 files changed, 24 insertions(+), 18 deletions(-) create mode 100644 tests/specs/Query/DerbyRowNumberColumnRegressionSpec.cfc diff --git a/models/Grammars/DerbyGrammar.cfc b/models/Grammars/DerbyGrammar.cfc index 67c3bdab..4251dcf8 100644 --- a/models/Grammars/DerbyGrammar.cfc +++ b/models/Grammars/DerbyGrammar.cfc @@ -20,24 +20,6 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { "lockType" ]; - /** - * Runs a query through `queryExecute`. - * This function exists so that platform-specific grammars can override it if needed. - * - * @sql The sql string to execute. - * @bindings The bindings to apply to the query. - * @options Any options to pass to `queryExecute`. Default: {}. - * - * @return any - */ - public any function runQuery( sql, bindings, options ) { - var result = super.runQuery( argumentCollection = arguments ); - if ( isQuery( result ) && result.recordCount > 0 ) { - return utils.queryRemoveColumns( result, "QB_RN" ); - } - return result; - } - /** * Compiles the lock portion of a sql statement. * diff --git a/tests/specs/Query/DerbyRowNumberColumnRegressionSpec.cfc b/tests/specs/Query/DerbyRowNumberColumnRegressionSpec.cfc new file mode 100644 index 00000000..2cf342d5 --- /dev/null +++ b/tests/specs/Query/DerbyRowNumberColumnRegressionSpec.cfc @@ -0,0 +1,24 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "Derby row number column regression", function() { + it( "preserves application QB_RN columns", function() { + var grammar = getMockBox().createMock( "qb.models.Grammars.DerbyGrammar" ).init(); + grammar.$property( + propertyName = "userRows", + mock = queryNew( "QB_RN,name", "integer,varchar", [ { QB_RN: 7, name: "Ada" } ] ) + ); + + var result = grammar.runQuery( + sql = "SELECT QB_RN, name FROM userRows", + bindings = [], + options = { dbtype: "query" } + ); + + expect( queryColumnList( result ) ).toInclude( "QB_RN" ); + expect( result.QB_RN[ 1 ] ).toBe( 7 ); + } ); + } ); + } + +} From 89b3c16dc0a7b05407ec83ecb85db64c935d350e Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 08:37:58 -0600 Subject: [PATCH 084/119] fix(QueryBuilder): prevent duplicate cross joins --- models/Query/JoinClauseManager.cfc | 23 +++++------ .../CrossJoinDuplicateRegressionSpec.cfc | 39 +++++++++++++++++++ 2 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 tests/specs/Query/CrossJoinDuplicateRegressionSpec.cfc diff --git a/models/Query/JoinClauseManager.cfc b/models/Query/JoinClauseManager.cfc index 593cfc3f..c8736f70 100644 --- a/models/Query/JoinClauseManager.cfc +++ b/models/Query/JoinClauseManager.cfc @@ -84,22 +84,18 @@ component { * Attaches a cross join to the supplied builder. */ public QueryBuilder function crossJoin( required QueryBuilder builder, required any table ) { - return attachJoin( - arguments.builder, - newJoin( builder = arguments.builder, type = "cross", table = arguments.table ) - ); + var join = newJoin( builder = arguments.builder, type = "cross", table = arguments.table ); + if ( arguments.builder.getPreventDuplicateJoins() && containsJoin( arguments.builder, join ) ) { + return arguments.builder; + } + return attachJoin( arguments.builder, join ); } /** * Attaches a raw cross join while preserving its expression for grammar compilation. */ public QueryBuilder function crossJoinRaw( required QueryBuilder builder, required string table ) { - arguments.builder - .getJoins() - .append( - newJoin( builder = arguments.builder, type = "cross", table = arguments.builder.raw( arguments.table ) ) - ); - return arguments.builder; + return crossJoin( arguments.builder, arguments.builder.raw( arguments.table ) ); } /** @@ -239,8 +235,13 @@ component { arguments.input.getBindings() ); + var joinCount = arguments.builder.getJoins().len(); var result = crossJoin( arguments.builder, table ); - arguments.builder.setGrammarCompiledJoin( true ); + if ( arguments.builder.getJoins().len() > joinCount ) { + arguments.builder.setGrammarCompiledJoin( true ); + } else { + executor.restoreCommonTableState( arguments.builder, commonTableState ); + } return result; } catch ( any e ) { executor.restoreCommonTableState( arguments.builder, commonTableState ); diff --git a/tests/specs/Query/CrossJoinDuplicateRegressionSpec.cfc b/tests/specs/Query/CrossJoinDuplicateRegressionSpec.cfc new file mode 100644 index 00000000..98023976 --- /dev/null +++ b/tests/specs/Query/CrossJoinDuplicateRegressionSpec.cfc @@ -0,0 +1,39 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "Cross join duplicate regression", function() { + it( "prevents duplicate simple cross joins", function() { + var builder = new qb.models.Query.QueryBuilder( preventDuplicateJoins = true ) + .from( "users" ) + .crossJoin( "roles" ) + .crossJoin( "roles" ); + + expect( builder.getJoins() ).toHaveLength( 1 ); + expect( builder.toSQL() ).toBe( "SELECT * FROM ""users"" CROSS JOIN ""roles""" ); + } ); + + it( "prevents duplicate raw cross joins", function() { + var builder = new qb.models.Query.QueryBuilder( preventDuplicateJoins = true ) + .from( "users" ) + .crossJoinRaw( "generate_series(1, 3) AS n" ) + .crossJoinRaw( "generate_series(1, 3) AS n" ); + + expect( builder.getJoins() ).toHaveLength( 1 ); + } ); + + it( "prevents duplicate derived cross joins and their bindings", function() { + var builder = new qb.models.Query.QueryBuilder( preventDuplicateJoins = true ).from( "users" ); + var source = function( query ) { + query.from( "roles" ).where( "active", true ); + }; + + builder.crossJoinSub( "active_roles", source ); + builder.crossJoinSub( "active_roles", source ); + + expect( builder.getJoins() ).toHaveLength( 1 ); + expect( builder.getBindings() ).toHaveLength( 1 ); + } ); + } ); + } + +} From a7e070a66d5759c8dac6753f2d0115a1fe0252e6 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 08:43:24 -0600 Subject: [PATCH 085/119] fix(SchemaBuilder): apply configured default options --- ModuleConfig.cfc | 3 +- ...duleSchemaDefaultOptionsRegressionSpec.cfc | 50 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/specs/ModuleSchemaDefaultOptionsRegressionSpec.cfc diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index c0b855d0..3c5fbc19 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -82,7 +82,8 @@ component { binder .map( alias = "SchemaBuilder@qb", force = true ) .to( "qb.models.Schema.SchemaBuilder" ) - .initArg( name = "grammar", ref = settings.defaultGrammar ); + .initArg( name = "grammar", ref = settings.defaultGrammar ) + .initArg( name = "defaultOptions", value = settings.defaultOptions ); // Apply shouldWrapValues setting to the configured grammar singleton. // When defaultGrammar is AutoDiscover@qb, the setting is forwarded via diff --git a/tests/specs/ModuleSchemaDefaultOptionsRegressionSpec.cfc b/tests/specs/ModuleSchemaDefaultOptionsRegressionSpec.cfc new file mode 100644 index 00000000..bf7d6db1 --- /dev/null +++ b/tests/specs/ModuleSchemaDefaultOptionsRegressionSpec.cfc @@ -0,0 +1,50 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "SchemaBuilder module default options regression", function() { + it( "wires configured default options into SchemaBuilder", function() { + var mappings = {}; + var currentAlias = ""; + var fakeBinder = {}; + fakeBinder.map = function( required string alias, boolean force = false ) { + currentAlias = arguments.alias; + mappings[ currentAlias ] = { initArguments: {} }; + return fakeBinder; + }; + fakeBinder.to = function( required string path ) { + mappings[ currentAlias ].path = arguments.path; + return fakeBinder; + }; + fakeBinder.initArg = function( required string name, any value, string ref ) { + mappings[ currentAlias ].initArguments[ arguments.name ] = arguments.keyExists( "value" ) + ? arguments.value + : arguments.ref; + return fakeBinder; + }; + + var moduleConfig = prepareMock( new qb.ModuleConfig() ); + moduleConfig.configure(); + var settings = moduleConfig.$getProperty( "settings", "variables" ); + settings.defaultOptions = { datasource: "reporting", timeout: 15 }; + moduleConfig.$property( propertyName = "binder", mock = fakeBinder ); + moduleConfig.$property( + propertyName = "wirebox", + mock = { + getInstance: function() { + return { + setShouldWrapValues: function() { + } + }; + } + } + ); + + moduleConfig.onLoad(); + + expect( mappings[ "SchemaBuilder@qb" ].initArguments ).toHaveKey( "defaultOptions" ); + expect( mappings[ "SchemaBuilder@qb" ].initArguments.defaultOptions ).toBe( settings.defaultOptions ); + } ); + } ); + } + +} From e5bcf5397db0524e00f7491a1cb1ad1628803067 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 08:49:03 -0600 Subject: [PATCH 086/119] fix(PostgresGrammar): compile multi-join updates --- models/Grammars/PostgresGrammar.cfc | 39 +++++++++-------- .../PostgresMultiJoinUpdateRegressionSpec.cfc | 42 +++++++++++++++++++ 2 files changed, 64 insertions(+), 17 deletions(-) create mode 100644 tests/specs/Query/PostgresMultiJoinUpdateRegressionSpec.cfc diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index 481f0870..420971d2 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -231,28 +231,33 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { ); } - var firstJoin = joins[ 1 ]; - var whereStatement = replace( - compileWheres( query, query.getWheres() ), - "WHERE", - "AND", - "one" + var updateQuery = arguments.query; + var joinedTables = joins + .map( function( join ) { + return wrapTable( join.getTable() ); + } ) + .toList( ", " ); + var predicates = joins + .map( function( join ) { + return trim( removeLeadingFilterKeyword( compileWheres( updateQuery, join.getWheres() ) ) ); + } ) + .filter( function( predicate ) { + return predicate != ""; + } ); + var queryPredicate = trim( + removeLeadingFilterKeyword( compileWheres( arguments.query, query.getWheres() ) ) ); - updateStatement &= " FROM #wrapTable( firstJoin.getTable() )# #compileWheres( arguments.query, firstJoin.getWheres() )#"; - - if ( joins.len() <= 1 ) { - return trim( - compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & " " & whereStatement & returningClause - ); + if ( queryPredicate != "" ) { + predicates.append( queryPredicate ); } - var restJoins = joins.len() <= 1 ? [] : joins.slice( 2 ); + updateStatement &= " FROM #joinedTables#"; + if ( !predicates.isEmpty() ) { + updateStatement &= " WHERE #predicates.toList( " AND " )#"; + } return trim( - compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & " " & compileJoins( - arguments.query, - restJoins - ) & " " & whereStatement & returningClause + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & returningClause ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { diff --git a/tests/specs/Query/PostgresMultiJoinUpdateRegressionSpec.cfc b/tests/specs/Query/PostgresMultiJoinUpdateRegressionSpec.cfc new file mode 100644 index 00000000..dca2d9ca --- /dev/null +++ b/tests/specs/Query/PostgresMultiJoinUpdateRegressionSpec.cfc @@ -0,0 +1,42 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "PostgreSQL multi-join update regression", function() { + it( "places every joined table before the update predicates", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.PostgresGrammar() ) + .from( "employees" ) + .join( "departments", function( join ) { + join.on( "departments.id", "=", "employees.departmentId" ).where( "departments.active", true ); + } ) + .join( "locations", function( join ) { + join.on( "locations.id", "=", "departments.locationId" ).where( "locations.region", "west" ); + } ) + .where( "employees.active", true ); + + var values = structNew( "ordered" ); + values[ "departmentName" ] = "Operations"; + var sql = builder.update( values = values, toSql = true ); + + expect( sql ).toBe( + "UPDATE ""employees"" SET ""departmentName"" = ? FROM ""departments"", ""locations"" WHERE ""departments"".""id"" = ""employees"".""departmentId"" AND ""departments"".""active"" = ? AND ""locations"".""id"" = ""departments"".""locationId"" AND ""locations"".""region"" = ? AND ""employees"".""active"" = ?" + ); + expect( builder.getBindings( order = builder.getGrammar().getUpdateBindingOrder( builder ) ) ).toHaveLength( + 4 + ); + expect( + builder.getBindings( order = builder.getGrammar().getUpdateBindingOrder( builder ) )[ 1 ].value + ).toBe( "Operations" ); + expect( + builder.getBindings( order = builder.getGrammar().getUpdateBindingOrder( builder ) )[ 2 ].value + ).toBeTrue(); + expect( + builder.getBindings( order = builder.getGrammar().getUpdateBindingOrder( builder ) )[ 3 ].value + ).toBe( "west" ); + expect( + builder.getBindings( order = builder.getGrammar().getUpdateBindingOrder( builder ) )[ 4 ].value + ).toBeTrue(); + } ); + } ); + } + +} From 993f6bbf55b34eb674e9ca3efab16b6d5147b484 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 08:50:38 -0600 Subject: [PATCH 087/119] fix(SQLiteGrammar): compile multi-join updates --- models/Grammars/SQLiteGrammar.cfc | 39 +++++++++-------- .../SQLiteMultiJoinUpdateRegressionSpec.cfc | 42 +++++++++++++++++++ 2 files changed, 64 insertions(+), 17 deletions(-) create mode 100644 tests/specs/Query/SQLiteMultiJoinUpdateRegressionSpec.cfc diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index 75abd2b8..c93a65ec 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -214,28 +214,33 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { ); } - var firstJoin = joins[ 1 ]; - var whereStatement = replace( - compileWheres( query, query.getWheres() ), - "WHERE", - "AND", - "one" + var updateQuery = arguments.query; + var joinedTables = joins + .map( function( join ) { + return wrapTable( join.getTable() ); + } ) + .toList( ", " ); + var predicates = joins + .map( function( join ) { + return trim( removeLeadingFilterKeyword( compileWheres( updateQuery, join.getWheres() ) ) ); + } ) + .filter( function( predicate ) { + return predicate != ""; + } ); + var queryPredicate = trim( + removeLeadingFilterKeyword( compileWheres( arguments.query, query.getWheres() ) ) ); - updateStatement &= " FROM #wrapTable( firstJoin.getTable() )# #compileWheres( arguments.query, firstJoin.getWheres() )#"; - - if ( joins.len() <= 1 ) { - return trim( - compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & " " & whereStatement & returningClause - ); + if ( queryPredicate != "" ) { + predicates.append( queryPredicate ); } - var restJoins = joins.len() <= 1 ? [] : joins.slice( 2 ); + updateStatement &= " FROM #joinedTables#"; + if ( !predicates.isEmpty() ) { + updateStatement &= " WHERE #predicates.toList( " AND " )#"; + } return trim( - compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & " " & compileJoins( - arguments.query, - restJoins - ) & " " & whereStatement & returningClause + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & returningClause ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { diff --git a/tests/specs/Query/SQLiteMultiJoinUpdateRegressionSpec.cfc b/tests/specs/Query/SQLiteMultiJoinUpdateRegressionSpec.cfc new file mode 100644 index 00000000..92f05d3d --- /dev/null +++ b/tests/specs/Query/SQLiteMultiJoinUpdateRegressionSpec.cfc @@ -0,0 +1,42 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "SQLite multi-join update regression", function() { + it( "places every joined table before the update predicates", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.SQLiteGrammar() ) + .from( "employees" ) + .join( "departments", function( join ) { + join.on( "departments.id", "=", "employees.departmentId" ).where( "departments.active", true ); + } ) + .join( "locations", function( join ) { + join.on( "locations.id", "=", "departments.locationId" ).where( "locations.region", "west" ); + } ) + .where( "employees.active", true ); + + var values = structNew( "ordered" ); + values[ "departmentName" ] = "Operations"; + var sql = builder.update( values = values, toSql = true ); + + expect( sql ).toBe( + "UPDATE ""employees"" SET ""departmentName"" = ? FROM ""departments"", ""locations"" WHERE ""departments"".""id"" = ""employees"".""departmentId"" AND ""departments"".""active"" = ? AND ""locations"".""id"" = ""departments"".""locationId"" AND ""locations"".""region"" = ? AND ""employees"".""active"" = ?" + ); + expect( builder.getBindings( order = builder.getGrammar().getUpdateBindingOrder( builder ) ) ).toHaveLength( + 4 + ); + expect( + builder.getBindings( order = builder.getGrammar().getUpdateBindingOrder( builder ) )[ 1 ].value + ).toBe( "Operations" ); + expect( + builder.getBindings( order = builder.getGrammar().getUpdateBindingOrder( builder ) )[ 2 ].value + ).toBeTrue(); + expect( + builder.getBindings( order = builder.getGrammar().getUpdateBindingOrder( builder ) )[ 3 ].value + ).toBe( "west" ); + expect( + builder.getBindings( order = builder.getGrammar().getUpdateBindingOrder( builder ) )[ 4 ].value + ).toBeTrue(); + } ); + } ); + } + +} From f1d604409959a0b1bd6631f46ad31c2a62605878 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 08:52:28 -0600 Subject: [PATCH 088/119] fix(SQLiteGrammar): order update row limits --- models/Grammars/SQLiteGrammar.cfc | 10 ++-- ...QLiteUpdateLimitOrderingRegressionSpec.cfc | 51 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 tests/specs/Query/SQLiteUpdateLimitOrderingRegressionSpec.cfc diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index c93a65ec..29ac465d 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -200,17 +200,19 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { updateStatement = trim( "#updateStatement# #compileWheres( query, query.getWheres() )#" ); } - updateStatement = trim( "#updateStatement# #compileLimitValue( query, query.getLimitValue() )#" ); - var returningColumns = arguments.query .getReturning() .map( wrapColumn ) .toList( ", " ); var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; + var rowLimitClause = trim( + "#compileLimitValue( query, query.getLimitValue() )# #compileOffsetValue( query, query.getOffsetValue() )#" + ); + var trailingClauses = trim( "#returningClause# #rowLimitClause#" ); if ( joins.isEmpty() ) { return trim( - compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & returningClause + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & " " & trailingClauses ); } @@ -240,7 +242,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } return trim( - compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & returningClause + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & " " & trailingClauses ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { diff --git a/tests/specs/Query/SQLiteUpdateLimitOrderingRegressionSpec.cfc b/tests/specs/Query/SQLiteUpdateLimitOrderingRegressionSpec.cfc new file mode 100644 index 00000000..5fc8b7fb --- /dev/null +++ b/tests/specs/Query/SQLiteUpdateLimitOrderingRegressionSpec.cfc @@ -0,0 +1,51 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "SQLite update row-limit regression", function() { + it( "places LIMIT after UPDATE FROM predicates", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.SQLiteGrammar() ) + .from( "employees" ) + .join( + "departments", + "departments.id", + "=", + "employees.departmentId" + ) + .limit( 1 ); + + var sql = builder.update( values = { name: "Operations" }, toSql = true ); + + expect( sql ).toBe( + "UPDATE ""employees"" SET ""NAME"" = ? FROM ""departments"" WHERE ""departments"".""id"" = ""employees"".""departmentId"" LIMIT 1" + ); + } ); + + it( "places LIMIT after RETURNING", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.SQLiteGrammar() ) + .from( "employees" ) + .where( "active", true ) + .returning( "id" ) + .limit( 1 ); + + var sql = builder.update( values = { name: "Operations" }, toSql = true ); + + expect( sql ).toBe( + "UPDATE ""employees"" SET ""NAME"" = ? WHERE ""active"" = ? RETURNING ""id"" LIMIT 1" + ); + } ); + + it( "includes UPDATE offsets after the limit", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.SQLiteGrammar() ) + .from( "employees" ) + .where( "active", true ) + .limit( 1 ) + .offset( 2 ); + + var sql = builder.update( values = { name: "Operations" }, toSql = true ); + + expect( sql ).toBe( "UPDATE ""employees"" SET ""NAME"" = ? WHERE ""active"" = ? LIMIT 1 OFFSET 2" ); + } ); + } ); + } + +} From d453430167314b3dbc0176293596ea6edc29c5a4 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 08:53:44 -0600 Subject: [PATCH 089/119] fix(MySQLGrammar): preserve delete row selection --- models/Grammars/MySQLGrammar.cfc | 4 +++- .../MySQLDeleteRowSelectionRegressionSpec.cfc | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 tests/specs/Query/MySQLDeleteRowSelectionRegressionSpec.cfc diff --git a/models/Grammars/MySQLGrammar.cfc b/models/Grammars/MySQLGrammar.cfc index 7b5ec372..31c8fa45 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -325,7 +325,9 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { "FROM", wrapQueryTable( query ), hasJoins ? compileJoins( query, query.getJoins() ) : "", - compileWheres( query, query.getWheres() ) + compileWheres( query, query.getWheres() ), + hasJoins ? "" : compileOrders( query, query.getOrders() ), + hasJoins ? "" : compileLimitValue( query, query.getLimitValue() ) ], function( sql ) { return sql != ""; diff --git a/tests/specs/Query/MySQLDeleteRowSelectionRegressionSpec.cfc b/tests/specs/Query/MySQLDeleteRowSelectionRegressionSpec.cfc new file mode 100644 index 00000000..88121a35 --- /dev/null +++ b/tests/specs/Query/MySQLDeleteRowSelectionRegressionSpec.cfc @@ -0,0 +1,22 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "MySQL delete row-selection regression", function() { + it( "preserves ORDER BY and LIMIT on single-table deletes", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.MySQLGrammar() ) + .from( "jobs" ) + .where( "queue", "mail" ) + .orderByRaw( "FIELD(status, ?)", [ "stale" ] ) + .limit( 10 ); + + var sql = builder.delete( toSql = true ); + + expect( sql ).toBe( "DELETE FROM `jobs` WHERE `queue` = ? ORDER BY FIELD(status, ?) LIMIT 10" ); + expect( builder.getBindings() ).toHaveLength( 2 ); + expect( builder.getBindings()[ 1 ].value ).toBe( "mail" ); + expect( builder.getBindings()[ 2 ].value ).toBe( "stale" ); + } ); + } ); + } + +} From 90082dd7d0497b107d3e87e332e451fa548a5a1d Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 08:54:50 -0600 Subject: [PATCH 090/119] fix(SQLiteGrammar): preserve delete row selection --- models/Grammars/SQLiteGrammar.cfc | 2 +- ...SQLiteDeleteRowSelectionRegressionSpec.cfc | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tests/specs/Query/SQLiteDeleteRowSelectionRegressionSpec.cfc diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index 29ac465d..d5532b4e 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -292,7 +292,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { .toList( ", " ); var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; return trim( - compileCommonTables( query, query.getCommonTables() ) & " DELETE FROM #wrapQueryTable( query )# #compileWheres( query, query.getWheres() )##returningClause#" + compileCommonTables( query, query.getCommonTables() ) & " DELETE FROM #wrapQueryTable( query )# #compileWheres( query, query.getWheres() )##returningClause# #compileOrders( query, query.getOrders() )# #compileLimitValue( query, query.getLimitValue() )# #compileOffsetValue( query, query.getOffsetValue() )#" ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { diff --git a/tests/specs/Query/SQLiteDeleteRowSelectionRegressionSpec.cfc b/tests/specs/Query/SQLiteDeleteRowSelectionRegressionSpec.cfc new file mode 100644 index 00000000..a25042dc --- /dev/null +++ b/tests/specs/Query/SQLiteDeleteRowSelectionRegressionSpec.cfc @@ -0,0 +1,26 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "SQLite delete row-selection regression", function() { + it( "preserves RETURNING, ORDER BY, LIMIT, and OFFSET", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.SQLiteGrammar() ) + .from( "jobs" ) + .where( "queue", "mail" ) + .returning( "id" ) + .orderByRaw( "CASE WHEN status = ? THEN 0 ELSE 1 END", [ "stale" ] ) + .limit( 10 ) + .offset( 2 ); + + var sql = builder.delete( toSql = true ); + + expect( sql ).toBe( + "DELETE FROM ""jobs"" WHERE ""queue"" = ? RETURNING ""id"" ORDER BY CASE WHEN status = ? THEN 0 ELSE 1 END LIMIT 10 OFFSET 2" + ); + expect( builder.getBindings() ).toHaveLength( 2 ); + expect( builder.getBindings()[ 1 ].value ).toBe( "mail" ); + expect( builder.getBindings()[ 2 ].value ).toBe( "stale" ); + } ); + } ); + } + +} From 39f61ec76b6358f273d6e95f44280b42c648a11d Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 08:56:22 -0600 Subject: [PATCH 091/119] fix(MySQLGrammar): preserve update row selection --- models/Grammars/MySQLGrammar.cfc | 24 ++++++++--- .../MySQLUpdateRowSelectionRegressionSpec.cfc | 40 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 tests/specs/Query/MySQLUpdateRowSelectionRegressionSpec.cfc diff --git a/models/Grammars/MySQLGrammar.cfc b/models/Grammars/MySQLGrammar.cfc index 31c8fa45..b7a7bb6c 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -5,11 +5,25 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { required array columns, required struct updateMap ) { - return trim( - compileCommonTables( arguments.query, arguments.query.getCommonTables() ) & " " & super.compileUpdate( - argumentCollection = arguments - ) - ); + var hasJoins = !arguments.query.getJoins().isEmpty(); + var hasRowSelection = !arguments.query.getOrders().isEmpty() || !isNull( arguments.query.getLimitValue() ); + if ( hasJoins && hasRowSelection ) { + throw( + type = "UnsupportedOperation", + message = "MySQL does not support ORDER BY or LIMIT on multi-table UPDATE statements." + ); + } + + var updateSql = super.compileUpdate( argumentCollection = arguments ); + if ( !hasJoins && !arguments.query.getOrders().isEmpty() ) { + var limitClause = compileLimitValue( arguments.query, arguments.query.getLimitValue() ); + if ( limitClause != "" ) { + updateSql = trim( left( updateSql, len( updateSql ) - len( limitClause ) ) ); + } + updateSql = trim( "#updateSql# #compileOrders( arguments.query, arguments.query.getOrders() )# #limitClause#" ); + } + + return trim( compileCommonTables( arguments.query, arguments.query.getCommonTables() ) & " " & updateSql ); } public string function compileWhereInBulkValues( required string sqlType ) { diff --git a/tests/specs/Query/MySQLUpdateRowSelectionRegressionSpec.cfc b/tests/specs/Query/MySQLUpdateRowSelectionRegressionSpec.cfc new file mode 100644 index 00000000..1d4ce4c1 --- /dev/null +++ b/tests/specs/Query/MySQLUpdateRowSelectionRegressionSpec.cfc @@ -0,0 +1,40 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "MySQL update row-selection regression", function() { + it( "preserves ORDER BY before LIMIT on single-table updates", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.MySQLGrammar() ) + .from( "jobs" ) + .where( "queue", "mail" ) + .orderByRaw( "FIELD(status, ?)", [ "stale" ] ) + .limit( 10 ); + + var sql = builder.update( values = { status: "archived" }, toSql = true ); + + expect( sql ).toBe( + "UPDATE `jobs` SET `STATUS` = ? WHERE `queue` = ? ORDER BY FIELD(status, ?) LIMIT 10" + ); + expect( builder.getBindings( order = builder.getGrammar().getUpdateBindingOrder( builder ) ) ).toHaveLength( + 3 + ); + } ); + + it( "rejects row selection on multi-table updates", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.MySQLGrammar() ) + .from( "jobs" ) + .join( + "queues", + "queues.id", + "=", + "jobs.queueId" + ) + .limit( 1 ); + + expect( function() { + builder.update( values = { status: "archived" }, toSql = true ); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + } ); + } + +} From c703b20c36accce94a0f9cd64787b674d583e007 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 08:57:39 -0600 Subject: [PATCH 092/119] fix(SQLiteGrammar): preserve update ordering --- models/Grammars/SQLiteGrammar.cfc | 9 ++++++- .../Query/SQLiteUpdateOrderRegressionSpec.cfc | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 tests/specs/Query/SQLiteUpdateOrderRegressionSpec.cfc diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index d5532b4e..8fb9a388 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -208,7 +208,14 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { var rowLimitClause = trim( "#compileLimitValue( query, query.getLimitValue() )# #compileOffsetValue( query, query.getOffsetValue() )#" ); - var trailingClauses = trim( "#returningClause# #rowLimitClause#" ); + var trailingClauses = [ returningClause, compileOrders( query, query.getOrders() ), rowLimitClause ] + .map( function( clause ) { + return trim( clause ); + } ) + .filter( function( clause ) { + return clause != ""; + } ) + .toList( " " ); if ( joins.isEmpty() ) { return trim( diff --git a/tests/specs/Query/SQLiteUpdateOrderRegressionSpec.cfc b/tests/specs/Query/SQLiteUpdateOrderRegressionSpec.cfc new file mode 100644 index 00000000..0dcd6e00 --- /dev/null +++ b/tests/specs/Query/SQLiteUpdateOrderRegressionSpec.cfc @@ -0,0 +1,25 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "SQLite update ordering regression", function() { + it( "preserves ORDER BY between RETURNING and LIMIT", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.SQLiteGrammar() ) + .from( "jobs" ) + .where( "queue", "mail" ) + .returning( "id" ) + .orderByRaw( "CASE WHEN status = ? THEN 0 ELSE 1 END", [ "stale" ] ) + .limit( 10 ); + + var sql = builder.update( values = { status: "archived" }, toSql = true ); + + expect( sql ).toBe( + "UPDATE ""jobs"" SET ""STATUS"" = ? WHERE ""queue"" = ? RETURNING ""id"" ORDER BY CASE WHEN status = ? THEN 0 ELSE 1 END LIMIT 10" + ); + expect( builder.getBindings( order = builder.getGrammar().getUpdateBindingOrder( builder ) ) ).toHaveLength( + 3 + ); + } ); + } ); + } + +} From 9cfe1179a1559e5d9de263634e20823e2fbef2dc Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 08:58:50 -0600 Subject: [PATCH 093/119] fix(MySQLGrammar): reject DML offsets --- models/Grammars/MySQLGrammar.cfc | 7 +++++ .../Query/MySQLDmlOffsetRegressionSpec.cfc | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 tests/specs/Query/MySQLDmlOffsetRegressionSpec.cfc diff --git a/models/Grammars/MySQLGrammar.cfc b/models/Grammars/MySQLGrammar.cfc index b7a7bb6c..c8dc06e9 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -5,6 +5,10 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { required array columns, required struct updateMap ) { + if ( !isNull( arguments.query.getOffsetValue() ) ) { + throw( type = "UnsupportedOperation", message = "MySQL does not support OFFSET on UPDATE statements." ); + } + var hasJoins = !arguments.query.getJoins().isEmpty(); var hasRowSelection = !arguments.query.getOrders().isEmpty() || !isNull( arguments.query.getLimitValue() ); if ( hasJoins && hasRowSelection ) { @@ -308,6 +312,9 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { * @return string */ public string function compileDelete( required QueryBuilder query ) { + if ( !isNull( arguments.query.getOffsetValue() ) ) { + throw( type = "UnsupportedOperation", message = "MySQL does not support OFFSET on DELETE statements." ); + } if ( !arguments.query.getReturning().isEmpty() ) { throw( type = "UnsupportedOperation", diff --git a/tests/specs/Query/MySQLDmlOffsetRegressionSpec.cfc b/tests/specs/Query/MySQLDmlOffsetRegressionSpec.cfc new file mode 100644 index 00000000..2288ce59 --- /dev/null +++ b/tests/specs/Query/MySQLDmlOffsetRegressionSpec.cfc @@ -0,0 +1,29 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "MySQL DML offset regression", function() { + it( "rejects offsets on updates instead of silently updating the wrong rows", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.MySQLGrammar() ) + .from( "jobs" ) + .limit( 1 ) + .offset( 2 ); + + expect( function() { + builder.update( values = { status: "archived" }, toSql = true ); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + + it( "rejects offsets on deletes instead of silently deleting the wrong rows", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.MySQLGrammar() ) + .from( "jobs" ) + .limit( 1 ) + .offset( 2 ); + + expect( function() { + builder.delete( toSql = true ); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + } ); + } + +} From 64039014b5dc5efb6e30540df1f4ae66d7d460f0 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 08:59:53 -0600 Subject: [PATCH 094/119] fix(MySQLGrammar): reject joined delete row selection --- models/Grammars/MySQLGrammar.cfc | 14 +++++++++-- ...ultiTableDeleteSelectionRegressionSpec.cfc | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 tests/specs/Query/MySQLMultiTableDeleteSelectionRegressionSpec.cfc diff --git a/models/Grammars/MySQLGrammar.cfc b/models/Grammars/MySQLGrammar.cfc index c8dc06e9..bd14351c 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -321,6 +321,18 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { message = "This grammar does not support DELETE actions with a RETURNING clause." ); } + var hasJoins = !arguments.query.getJoins().isEmpty(); + if ( + hasJoins && ( + !arguments.query.getOrders().isEmpty() || + !isNull( arguments.query.getLimitValue() ) + ) + ) { + throw( + type = "UnsupportedOperation", + message = "MySQL does not support ORDER BY or LIMIT on multi-table DELETE statements." + ); + } try { var originalShouldWrapValues = getShouldWrapValues(); @@ -328,8 +340,6 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - var hasJoins = !arguments.query.getJoins().isEmpty(); - return trim( arrayToList( arrayFilter( diff --git a/tests/specs/Query/MySQLMultiTableDeleteSelectionRegressionSpec.cfc b/tests/specs/Query/MySQLMultiTableDeleteSelectionRegressionSpec.cfc new file mode 100644 index 00000000..0b771bbd --- /dev/null +++ b/tests/specs/Query/MySQLMultiTableDeleteSelectionRegressionSpec.cfc @@ -0,0 +1,24 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "MySQL multi-table delete row-selection regression", function() { + it( "rejects ORDER BY and LIMIT instead of silently ignoring them", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.MySQLGrammar() ) + .from( "jobs" ) + .join( + "queues", + "queues.id", + "=", + "jobs.queueId" + ) + .orderBy( "jobs.id" ) + .limit( 1 ); + + expect( function() { + builder.delete( toSql = true ); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + } ); + } + +} From 882b9beb02fbb7200622027ded790ccb4fce72b5 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 09:01:01 -0600 Subject: [PATCH 095/119] fix(SqlServerGrammar): preserve delete limits --- models/Grammars/SqlServerGrammar.cfc | 8 ++++- .../SqlServerDeleteLimitRegressionSpec.cfc | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tests/specs/Query/SqlServerDeleteLimitRegressionSpec.cfc diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index a0b3730a..3815d03e 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -658,6 +658,9 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { var hasJoins = !arguments.query.getJoins().isEmpty(); var hasAlias = arguments.query.getAlias() != ""; + var topClause = isNull( arguments.query.getLimitValue() ) + ? "" + : "TOP (#arguments.query.getLimitValue()#)"; if ( !hasJoins && !hasAlias ) { return trim( @@ -665,7 +668,9 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { arrayFilter( [ compileCommonTables( query, query.getCommonTables() ), - "DELETE FROM", + "DELETE", + topClause, + "FROM", wrapQueryTable( query ), returningClause, compileWheres( query, query.getWheres() ) @@ -685,6 +690,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { [ compileCommonTables( query, query.getCommonTables() ), "DELETE", + topClause, hasAlias ? wrapAlias( getTablePrefix() & query.getAlias() ) : wrapTable( query.getTableName(), false ), diff --git a/tests/specs/Query/SqlServerDeleteLimitRegressionSpec.cfc b/tests/specs/Query/SqlServerDeleteLimitRegressionSpec.cfc new file mode 100644 index 00000000..279e8690 --- /dev/null +++ b/tests/specs/Query/SqlServerDeleteLimitRegressionSpec.cfc @@ -0,0 +1,33 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "SQL Server delete limit regression", function() { + it( "compiles TOP for single-table deletes", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.SqlServerGrammar() ) + .from( "jobs" ) + .where( "queue", "mail" ) + .limit( 1 ); + + expect( builder.delete( toSql = true ) ).toBe( "DELETE TOP (1) FROM [jobs] WHERE [queue] = ?" ); + } ); + + it( "compiles TOP for joined deletes", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.SqlServerGrammar() ) + .from( "jobs AS j" ) + .join( + "queues AS q", + "q.id", + "=", + "j.queueId" + ) + .where( "q.name", "mail" ) + .limit( 1 ); + + expect( builder.delete( toSql = true ) ).toBe( + "DELETE TOP (1) [j] FROM [jobs] AS [j] INNER JOIN [queues] AS [q] ON [q].[id] = [j].[queueId] WHERE [q].[name] = ?" + ); + } ); + } ); + } + +} From 184bc6389492015cdc26b44bf90895aa72592b81 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 09:02:15 -0600 Subject: [PATCH 096/119] fix(SqlServerGrammar): reject unsupported DML selection --- models/Grammars/SqlServerGrammar.cfc | 12 ++++++ ...SqlServerDmlRowSelectionRegressionSpec.cfc | 41 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 tests/specs/Query/SqlServerDmlRowSelectionRegressionSpec.cfc diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index 3815d03e..99a38451 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -553,6 +553,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { * @return string */ public string function compileUpdate( required query, required array columns, required struct updateMap ) { + if ( !arguments.query.getOrders().isEmpty() || !isNull( arguments.query.getOffsetValue() ) ) { + throw( + type = "UnsupportedOperation", + message = "SQL Server does not support direct ORDER BY or OFFSET clauses on UPDATE statements." + ); + } try { var originalShouldWrapValues = getShouldWrapValues(); if ( !isNull( arguments.query.getShouldWrapValues() ) ) { @@ -636,6 +642,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { * @return string */ public string function compileDelete( required QueryBuilder query ) { + if ( !arguments.query.getOrders().isEmpty() || !isNull( arguments.query.getOffsetValue() ) ) { + throw( + type = "UnsupportedOperation", + message = "SQL Server does not support direct ORDER BY or OFFSET clauses on DELETE statements." + ); + } try { var originalShouldWrapValues = getShouldWrapValues(); if ( !isNull( arguments.query.getShouldWrapValues() ) ) { diff --git a/tests/specs/Query/SqlServerDmlRowSelectionRegressionSpec.cfc b/tests/specs/Query/SqlServerDmlRowSelectionRegressionSpec.cfc new file mode 100644 index 00000000..c2e47142 --- /dev/null +++ b/tests/specs/Query/SqlServerDmlRowSelectionRegressionSpec.cfc @@ -0,0 +1,41 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "SQL Server unsupported DML row-selection regression", function() { + for ( var operation in [ "update", "delete" ] ) { + it( "rejects direct ordering on #operation# statements", function() { + var builder = new qb.models.Query.QueryBuilder( + grammar = new qb.models.Grammars.SqlServerGrammar() + ).from( "jobs" ) + .orderBy( "createdDate" ) + .limit( 1 ); + + expect( function() { + if ( operation == "update" ) { + builder.update( values = { status: "archived" }, toSql = true ); + } else { + builder.delete( toSql = true ); + } + } ).toThrow( type = "UnsupportedOperation" ); + } ); + + it( "rejects offsets on #operation# statements", function() { + var builder = new qb.models.Query.QueryBuilder( + grammar = new qb.models.Grammars.SqlServerGrammar() + ).from( "jobs" ) + .limit( 1 ) + .offset( 2 ); + + expect( function() { + if ( operation == "update" ) { + builder.update( values = { status: "archived" }, toSql = true ); + } else { + builder.delete( toSql = true ); + } + } ).toThrow( type = "UnsupportedOperation" ); + } ); + } + } ); + } + +} From 654a4b35f943bb94a84650252718c1ca4fa26207 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 09:03:23 -0600 Subject: [PATCH 097/119] fix(PostgresGrammar): reject unsupported DML selection --- models/Grammars/PostgresGrammar.cfc | 16 ++++++++ .../PostgresDmlRowSelectionRegressionSpec.cfc | 40 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tests/specs/Query/PostgresDmlRowSelectionRegressionSpec.cfc diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index 420971d2..bbf1fd96 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -190,6 +190,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { required array columns, required struct updateMap ) { + if ( !arguments.query.getOrders().isEmpty() || !isNull( arguments.query.getOffsetValue() ) ) { + throw( + type = "UnsupportedOperation", + message = "PostgreSQL does not support direct ORDER BY or OFFSET clauses on UPDATE statements." + ); + } try { var originalShouldWrapValues = getShouldWrapValues(); if ( !isNull( arguments.query.getShouldWrapValues() ) ) { @@ -288,6 +294,16 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { * @return string */ public string function compileDelete( required QueryBuilder query ) { + if ( + !arguments.query.getOrders().isEmpty() || + !isNull( arguments.query.getLimitValue() ) || + !isNull( arguments.query.getOffsetValue() ) + ) { + throw( + type = "UnsupportedOperation", + message = "PostgreSQL does not support direct ORDER BY, LIMIT, or OFFSET clauses on DELETE statements." + ); + } if ( !arguments.query.getJoins().isEmpty() ) { throw( type = "UnsupportedOperation", diff --git a/tests/specs/Query/PostgresDmlRowSelectionRegressionSpec.cfc b/tests/specs/Query/PostgresDmlRowSelectionRegressionSpec.cfc new file mode 100644 index 00000000..db0faa50 --- /dev/null +++ b/tests/specs/Query/PostgresDmlRowSelectionRegressionSpec.cfc @@ -0,0 +1,40 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "PostgreSQL unsupported DML row-selection regression", function() { + for ( var selection in [ "order", "offset" ] ) { + it( "rejects #selection# on update statements", function() { + var builder = configuredBuilder( selection ); + + expect( function() { + builder.update( values = { status: "archived" }, toSql = true ); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + } + + for ( var selection in [ "order", "limit", "offset" ] ) { + it( "rejects #selection# on delete statements", function() { + var builder = configuredBuilder( selection ); + + expect( function() { + builder.delete( toSql = true ); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + } + } ); + } + + private any function configuredBuilder( required string selection ) { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.PostgresGrammar() ).from( "jobs" ); + + switch ( arguments.selection ) { + case "order": + return builder.orderBy( "createdDate" ); + case "limit": + return builder.limit( 1 ); + case "offset": + return builder.offset( 2 ); + } + } + +} From ffe83e6300a530f73abb3c731dc8c696abf5a221 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 09:04:44 -0600 Subject: [PATCH 098/119] fix(OracleGrammar): reject unsupported DML selection --- models/Grammars/OracleGrammar.cfc | 20 ++++++++++ .../OracleDmlRowSelectionRegressionSpec.cfc | 40 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 tests/specs/Query/OracleDmlRowSelectionRegressionSpec.cfc diff --git a/models/Grammars/OracleGrammar.cfc b/models/Grammars/OracleGrammar.cfc index c4513586..94e0b3d7 100644 --- a/models/Grammars/OracleGrammar.cfc +++ b/models/Grammars/OracleGrammar.cfc @@ -251,6 +251,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { required array columns, required struct updateMap ) { + if ( !arguments.query.getOrders().isEmpty() || !isNull( arguments.query.getOffsetValue() ) ) { + throw( + type = "UnsupportedOperation", + message = "Oracle does not support direct ORDER BY or OFFSET clauses on UPDATE statements." + ); + } if ( !query.getCommonTables().isEmpty() ) { throw( type = "UnsupportedOperation", @@ -266,6 +272,20 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { return super.compileUpdate( argumentCollection = arguments ); } + public string function compileDelete( required QueryBuilder query ) { + if ( + !arguments.query.getOrders().isEmpty() || + !isNull( arguments.query.getLimitValue() ) || + !isNull( arguments.query.getOffsetValue() ) + ) { + throw( + type = "UnsupportedOperation", + message = "Oracle does not support direct ORDER BY, LIMIT, or OFFSET clauses on DELETE statements." + ); + } + return super.compileDelete( arguments.query ); + } + public string function compileUpsert( required QueryBuilder qb, required array insertColumns, diff --git a/tests/specs/Query/OracleDmlRowSelectionRegressionSpec.cfc b/tests/specs/Query/OracleDmlRowSelectionRegressionSpec.cfc new file mode 100644 index 00000000..816a882f --- /dev/null +++ b/tests/specs/Query/OracleDmlRowSelectionRegressionSpec.cfc @@ -0,0 +1,40 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "Oracle unsupported DML row-selection regression", function() { + for ( var selection in [ "order", "offset" ] ) { + it( "rejects #selection# on update statements", function() { + var builder = configuredBuilder( selection ); + + expect( function() { + builder.update( values = { status: "archived" }, toSql = true ); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + } + + for ( var selection in [ "order", "limit", "offset" ] ) { + it( "rejects #selection# on delete statements", function() { + var builder = configuredBuilder( selection ); + + expect( function() { + builder.delete( toSql = true ); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + } + } ); + } + + private any function configuredBuilder( required string selection ) { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.OracleGrammar() ).from( "jobs" ); + + switch ( arguments.selection ) { + case "order": + return builder.orderBy( "createdDate" ); + case "limit": + return builder.limit( 1 ); + case "offset": + return builder.offset( 2 ); + } + } + +} From e7cb622d762e054fe952cf2fe4f161a78abd1e63 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 09:05:48 -0600 Subject: [PATCH 099/119] fix(DerbyGrammar): reject unsupported DML selection --- models/Grammars/DerbyGrammar.cfc | 20 ++++++++++ .../DerbyDmlRowSelectionRegressionSpec.cfc | 40 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 tests/specs/Query/DerbyDmlRowSelectionRegressionSpec.cfc diff --git a/models/Grammars/DerbyGrammar.cfc b/models/Grammars/DerbyGrammar.cfc index 4251dcf8..c88ca438 100644 --- a/models/Grammars/DerbyGrammar.cfc +++ b/models/Grammars/DerbyGrammar.cfc @@ -173,6 +173,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { required array columns, required struct updateMap ) { + if ( !arguments.query.getOrders().isEmpty() || !isNull( arguments.query.getOffsetValue() ) ) { + throw( + type = "UnsupportedOperation", + message = "Derby does not support direct ORDER BY or OFFSET clauses on UPDATE statements." + ); + } if ( !query.getCommonTables().isEmpty() ) { throw( type = "UnsupportedOperation", @@ -223,6 +229,20 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } } + public string function compileDelete( required QueryBuilder query ) { + if ( + !arguments.query.getOrders().isEmpty() || + !isNull( arguments.query.getLimitValue() ) || + !isNull( arguments.query.getOffsetValue() ) + ) { + throw( + type = "UnsupportedOperation", + message = "Derby does not support direct ORDER BY, LIMIT, or OFFSET clauses on DELETE statements." + ); + } + return super.compileDelete( arguments.query ); + } + public string function compileUpsert( required QueryBuilder qb, required array insertColumns, diff --git a/tests/specs/Query/DerbyDmlRowSelectionRegressionSpec.cfc b/tests/specs/Query/DerbyDmlRowSelectionRegressionSpec.cfc new file mode 100644 index 00000000..f195311b --- /dev/null +++ b/tests/specs/Query/DerbyDmlRowSelectionRegressionSpec.cfc @@ -0,0 +1,40 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "Derby unsupported DML row-selection regression", function() { + for ( var selection in [ "order", "offset" ] ) { + it( "rejects #selection# on update statements", function() { + var builder = configuredBuilder( selection ); + + expect( function() { + builder.update( values = { status: "archived" }, toSql = true ); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + } + + for ( var selection in [ "order", "limit", "offset" ] ) { + it( "rejects #selection# on delete statements", function() { + var builder = configuredBuilder( selection ); + + expect( function() { + builder.delete( toSql = true ); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + } + } ); + } + + private any function configuredBuilder( required string selection ) { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.DerbyGrammar() ).from( "jobs" ); + + switch ( arguments.selection ) { + case "order": + return builder.orderBy( "createdDate" ); + case "limit": + return builder.limit( 1 ); + case "offset": + return builder.offset( 2 ); + } + } + +} From 7e2118ed6a0d96489500aeee815cd17f7a7a6caf Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 09:11:55 -0600 Subject: [PATCH 100/119] fix(QueryUtils): normalize untyped null bindings --- models/Query/QueryUtils.cfc | 8 ++++++++ .../UntypedNullBindingRegressionSpec.cfc | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 tests/specs/Query/UntypedNullBindingRegressionSpec.cfc diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 53bf4ba3..86619a36 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -94,6 +94,14 @@ component singleton displayname="QueryUtils" accessors="true" { binding = { value: normalizeSqlValue( value ) }; } + if ( + !structKeyExists( binding, "value" ) && + structKeyExists( binding, "null" ) && + binding.null + ) { + binding.value = ""; + } + if ( structKeyExists( binding, "sqltype" ) && !structKeyExists( binding, "cfsqltype" ) ) { param binding.cfsqltype = binding.sqltype; } diff --git a/tests/specs/Query/UntypedNullBindingRegressionSpec.cfc b/tests/specs/Query/UntypedNullBindingRegressionSpec.cfc new file mode 100644 index 00000000..203af6e3 --- /dev/null +++ b/tests/specs/Query/UntypedNullBindingRegressionSpec.cfc @@ -0,0 +1,20 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "untyped null binding regression", function() { + it( "normalizes null parameter structs that omit a value", function() { + var utils = new qb.models.Query.QueryUtils(); + var grammar = new qb.models.Grammars.BaseGrammar( utils ); + + expect( utils.extractBinding( { null: true }, grammar ) ).toBe( { + cfsqltype: "VARCHAR", + sqltype: "VARCHAR", + value: "", + list: false, + null: true + } ); + } ); + } ); + } + +} From 55db41dbb5c6d4992af9beeacca362d652659631 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 09:14:29 -0600 Subject: [PATCH 101/119] fix(SQLiteGrammar): reject added auto-increment columns --- models/Grammars/SQLiteGrammar.cfc | 11 +++++++++ ...SQLiteAlterAutoIncrementRegressionSpec.cfc | 23 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 tests/specs/Schema/SQLiteAlterAutoIncrementRegressionSpec.cfc diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index 8fb9a388..33d66492 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -574,6 +574,17 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } function compileAddColumn( blueprint, commandParameters ) { + if ( + !getUtils().isExpression( arguments.commandParameters.column ) && + structKeyExists( arguments.commandParameters.column, "getAutoIncrement" ) && + arguments.commandParameters.column.getAutoIncrement() + ) { + throw( + type = "UnsupportedOperation", + message = "SQLite does not support adding auto-incrementing primary-key columns to existing tables." + ); + } + try { var originalShouldWrapValues = getShouldWrapValues(); if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { diff --git a/tests/specs/Schema/SQLiteAlterAutoIncrementRegressionSpec.cfc b/tests/specs/Schema/SQLiteAlterAutoIncrementRegressionSpec.cfc new file mode 100644 index 00000000..64d689ac --- /dev/null +++ b/tests/specs/Schema/SQLiteAlterAutoIncrementRegressionSpec.cfc @@ -0,0 +1,23 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "SQLite alter auto-increment regression", function() { + it( "rejects adding auto-incrementing primary-key columns", function() { + var schema = new qb.models.Schema.SchemaBuilder( grammar = new qb.models.Grammars.SQLiteGrammar() ); + + expect( function() { + schema + .alter( + table = "users", + callback = function( table ) { + table.addColumn( table.increments( "id" ) ); + }, + execute = false + ) + .toSQL(); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + } ); + } + +} From 1a216139e60bca9431b9485eed87df155a51f71a Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 09:15:42 -0600 Subject: [PATCH 102/119] fix(SQLiteGrammar): reject added unique columns --- models/Grammars/SQLiteGrammar.cfc | 10 ++++++++ .../SQLiteAlterUniqueColumnRegressionSpec.cfc | 23 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 tests/specs/Schema/SQLiteAlterUniqueColumnRegressionSpec.cfc diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index 33d66492..20dd8bbf 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -584,6 +584,16 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { message = "SQLite does not support adding auto-incrementing primary-key columns to existing tables." ); } + if ( + !getUtils().isExpression( arguments.commandParameters.column ) && + structKeyExists( arguments.commandParameters.column, "getIsUnique" ) && + arguments.commandParameters.column.getIsUnique() + ) { + throw( + type = "UnsupportedOperation", + message = "SQLite does not support adding columns with inline UNIQUE constraints to existing tables." + ); + } try { var originalShouldWrapValues = getShouldWrapValues(); diff --git a/tests/specs/Schema/SQLiteAlterUniqueColumnRegressionSpec.cfc b/tests/specs/Schema/SQLiteAlterUniqueColumnRegressionSpec.cfc new file mode 100644 index 00000000..4e9ed831 --- /dev/null +++ b/tests/specs/Schema/SQLiteAlterUniqueColumnRegressionSpec.cfc @@ -0,0 +1,23 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "SQLite alter unique-column regression", function() { + it( "rejects adding columns with inline unique constraints", function() { + var schema = new qb.models.Schema.SchemaBuilder( grammar = new qb.models.Grammars.SQLiteGrammar() ); + + expect( function() { + schema + .alter( + table = "users", + callback = function( table ) { + table.addColumn( table.string( "email" ).unique() ); + }, + execute = false + ) + .toSQL(); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + } ); + } + +} From e05a67b2448214b8608e862c8961f1c1ed9ce574 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 09:16:39 -0600 Subject: [PATCH 103/119] fix(SQLiteGrammar): reject added stored columns --- models/Grammars/SQLiteGrammar.cfc | 10 ++++++++ .../SQLiteAlterStoredColumnRegressionSpec.cfc | 23 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 tests/specs/Schema/SQLiteAlterStoredColumnRegressionSpec.cfc diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index 20dd8bbf..7be10bfd 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -594,6 +594,16 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { message = "SQLite does not support adding columns with inline UNIQUE constraints to existing tables." ); } + if ( + !getUtils().isExpression( arguments.commandParameters.column ) && + structKeyExists( arguments.commandParameters.column, "getComputedType" ) && + arguments.commandParameters.column.getComputedType() == "stored" + ) { + throw( + type = "UnsupportedOperation", + message = "SQLite does not support adding stored generated columns to existing tables." + ); + } try { var originalShouldWrapValues = getShouldWrapValues(); diff --git a/tests/specs/Schema/SQLiteAlterStoredColumnRegressionSpec.cfc b/tests/specs/Schema/SQLiteAlterStoredColumnRegressionSpec.cfc new file mode 100644 index 00000000..c8edc032 --- /dev/null +++ b/tests/specs/Schema/SQLiteAlterStoredColumnRegressionSpec.cfc @@ -0,0 +1,23 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "SQLite alter stored-column regression", function() { + it( "rejects adding stored generated columns", function() { + var schema = new qb.models.Schema.SchemaBuilder( grammar = new qb.models.Grammars.SQLiteGrammar() ); + + expect( function() { + schema + .alter( + table = "orders", + callback = function( table ) { + table.addColumn( table.decimal( "total" ).storedAs( "quantity * price" ) ); + }, + execute = false + ) + .toSQL(); + } ).toThrow( type = "UnsupportedOperation" ); + } ); + } ); + } + +} From 6b17a36d57b64d70dc662611bf2beaf9b7ab703c Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 14:25:04 -0600 Subject: [PATCH 104/119] ci: enable Adobe full-null test matrix --- .github/workflows/cron.yml | 7 ------- .github/workflows/pr.yml | 7 ------- .github/workflows/release.yml | 7 ------- 3 files changed, 21 deletions(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 3763e3e6..7f81487b 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -14,13 +14,6 @@ jobs: cfengine: ["lucee@5", "lucee@6", "adobe@2021", "adobe@2023", "adobe@2025", "boxlang@1", "boxlang-cfml@1"] experimental: [ false ] fullNull: ["true", "false"] - exclude: - - cfengine: "adobe@2021" - fullNull: "true" - - cfengine: "adobe@2023" - fullNull: "true" - - cfengine: "adobe@2025" - fullNull: "true" include: - cfengine: "adobe@be" experimental: true diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index dd663d16..336b3219 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -22,13 +22,6 @@ jobs: cfengine: ["lucee@5", "lucee@6", "adobe@2021", "adobe@2023", "adobe@2025", "boxlang@1", "boxlang-cfml@1"] experimental: [ false ] fullNull: ["true", "false"] - exclude: - - cfengine: "adobe@2021" - fullNull: "true" - - cfengine: "adobe@2023" - fullNull: "true" - - cfengine: "adobe@2025" - fullNull: "true" include: - cfengine: "adobe@be" experimental: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6377dddc..3173267a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,13 +17,6 @@ jobs: cfengine: ["lucee@5", "lucee@6", "adobe@2021", "adobe@2023", "adobe@2025", "boxlang@1", "boxlang-cfml@1"] experimental: [ false ] fullNull: ["true", "false"] - exclude: - - cfengine: "adobe@2021" - fullNull: "true" - - cfengine: "adobe@2023" - fullNull: "true" - - cfengine: "adobe@2025" - fullNull: "true" steps: - name: Checkout Repository uses: actions/checkout@v7 From b5740d55b062048821f1f36f4673fb8b58ceb8af Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 14:32:22 -0600 Subject: [PATCH 105/119] test: support Adobe row number regressions --- tests/specs/Query/DerbyRowNumberColumnRegressionSpec.cfc | 2 +- tests/specs/Query/OracleRowNumberColumnRegressionSpec.cfc | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/specs/Query/DerbyRowNumberColumnRegressionSpec.cfc b/tests/specs/Query/DerbyRowNumberColumnRegressionSpec.cfc index 2cf342d5..c4f648d4 100644 --- a/tests/specs/Query/DerbyRowNumberColumnRegressionSpec.cfc +++ b/tests/specs/Query/DerbyRowNumberColumnRegressionSpec.cfc @@ -15,7 +15,7 @@ component extends="testbox.system.BaseSpec" { options = { dbtype: "query" } ); - expect( queryColumnList( result ) ).toInclude( "QB_RN" ); + expect( listToArray( lCase( result.columnList ) ) ).toInclude( "qb_rn" ); expect( result.QB_RN[ 1 ] ).toBe( 7 ); } ); } ); diff --git a/tests/specs/Query/OracleRowNumberColumnRegressionSpec.cfc b/tests/specs/Query/OracleRowNumberColumnRegressionSpec.cfc index 194193c4..1b827d18 100644 --- a/tests/specs/Query/OracleRowNumberColumnRegressionSpec.cfc +++ b/tests/specs/Query/OracleRowNumberColumnRegressionSpec.cfc @@ -15,7 +15,7 @@ component extends="testbox.system.BaseSpec" { options = { dbtype: "query" } ); - expect( queryColumnList( result ) ).toInclude( "QB_RN" ); + expect( listToArray( lCase( result.columnList ) ) ).toInclude( "qb_rn" ); expect( result.QB_RN[ 1 ] ).toBe( 7 ); } ); @@ -24,15 +24,15 @@ component extends="testbox.system.BaseSpec" { grammar.$property( propertyName = "userRows", mock = queryNew( "QB_RN,name", "integer,varchar" ) ); var result = grammar.runQuery( - sql = "/* SELECT * FROM (SELECT results.*, ROWNUM AS ""QB_RN"" FROM ( */ SELECT QB_RN, name FROM userRows", + sql = "SELECT QB_RN, name FROM userRows WHERE name = 'SELECT * FROM (SELECT results.*, ROWNUM AS ""QB_RN"" FROM ('", bindings = [], options = { dbtype: "query" } ); expect( result ).toBeQuery(); expect( result.recordCount ).toBe( 0 ); - expect( queryColumnList( result ) ).notToInclude( "QB_RN" ); - expect( queryColumnList( result ) ).toInclude( "name" ); + expect( listToArray( lCase( result.columnList ) ) ).notToInclude( "qb_rn" ); + expect( listToArray( lCase( result.columnList ) ) ).toInclude( "name" ); } ); } ); } From f912f2aa9db18a51b39c2b18fde1b796a6b9b03e Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 14:48:09 -0600 Subject: [PATCH 106/119] fix(SQLiteGrammar): support Adobe array compilation --- models/Grammars/SQLiteGrammar.cfc | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index 7be10bfd..1529d907 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -208,14 +208,16 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { var rowLimitClause = trim( "#compileLimitValue( query, query.getLimitValue() )# #compileOffsetValue( query, query.getOffsetValue() )#" ); - var trailingClauses = [ returningClause, compileOrders( query, query.getOrders() ), rowLimitClause ] - .map( function( clause ) { + var trailingClauses = arrayMap( + [ returningClause, compileOrders( query, query.getOrders() ), rowLimitClause ], + function( clause ) { return trim( clause ); - } ) - .filter( function( clause ) { + } + ); + trailingClauses = arrayFilter( trailingClauses, function( clause ) { return clause != ""; - } ) - .toList( " " ); + } ); + trailingClauses = arrayToList( trailingClauses, " " ); if ( joins.isEmpty() ) { return trim( From d4edeae851a720c13a3481f106da0a3a8712ee72 Mon Sep 17 00:00:00 2001 From: elpete <2583646+elpete@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:49:44 +0000 Subject: [PATCH 107/119] Apply cfformat changes --- models/Grammars/SQLiteGrammar.cfc | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index 1529d907..d8d45be7 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -208,15 +208,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { var rowLimitClause = trim( "#compileLimitValue( query, query.getLimitValue() )# #compileOffsetValue( query, query.getOffsetValue() )#" ); - var trailingClauses = arrayMap( - [ returningClause, compileOrders( query, query.getOrders() ), rowLimitClause ], - function( clause ) { - return trim( clause ); - } - ); + var trailingClauses = arrayMap( [ returningClause, compileOrders( query, query.getOrders() ), rowLimitClause ], function( clause ) { + return trim( clause ); + } ); trailingClauses = arrayFilter( trailingClauses, function( clause ) { - return clause != ""; - } ); + return clause != ""; + } ); trailingClauses = arrayToList( trailingClauses, " " ); if ( joins.isEmpty() ) { From af2305be41f7465d7bc4be684332d027aaf87bd4 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 19:26:31 -0600 Subject: [PATCH 108/119] ci: check formatting without auto-commit --- .github/workflows/pr.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 336b3219..296f196a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -80,9 +80,4 @@ jobs: run: box install commandbox-cfformat - name: Run CFFormat - run: box run-script format - - - name: Commit Format Changes - uses: stefanzweifel/git-auto-commit-action@v7.2.0 - with: - commit_message: Apply cfformat changes + run: box run-script format:check From 2cb91a81193904724526c49c72c3fa5fd70a4605 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 19:33:16 -0600 Subject: [PATCH 109/119] ci: patch TestBox for Adobe full null --- .github/patches/testbox-full-null.patch | 13 +++++++++++++ .github/workflows/cron.yml | 1 + .github/workflows/pr.yml | 1 + .github/workflows/release.yml | 1 + 4 files changed, 16 insertions(+) create mode 100644 .github/patches/testbox-full-null.patch diff --git a/.github/patches/testbox-full-null.patch b/.github/patches/testbox-full-null.patch new file mode 100644 index 00000000..eae27a8b --- /dev/null +++ b/.github/patches/testbox-full-null.patch @@ -0,0 +1,13 @@ +diff --git a/testbox/system/coverage/CoverageService.cfc b/testbox/system/coverage/CoverageService.cfc +--- a/testbox/system/coverage/CoverageService.cfc ++++ b/testbox/system/coverage/CoverageService.cfc +@@ -172,8 +172,8 @@ component accessors="true" { + } + } + +- if ( isNull( opts.coverageTresholds ) ) { ++ if ( !structKeyExists( opts, "coverageTresholds" ) || isNull( opts.coverageTresholds ) ) { + opts.coverageTresholds = {}; + } + if ( isNull( opts.coverageTresholds.good ) ) { + opts.coverageTresholds.good = 85; diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 7f81487b..f42e73d6 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -38,6 +38,7 @@ jobs: - name: Install dependencies run: | box install + git apply .github/patches/testbox-full-null.patch - name: Start server env: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 296f196a..a4824ba4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -46,6 +46,7 @@ jobs: - name: Install dependencies run: | box install + git apply .github/patches/testbox-full-null.patch - name: Start server env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3173267a..342c8d05 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,7 @@ jobs: - name: Install dependencies run: | box install + git apply .github/patches/testbox-full-null.patch - name: Start server env: From 3f5f837cdaf7ac36487d4f0a16207806f655ceca Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 19:38:51 -0600 Subject: [PATCH 110/119] ci: complete TestBox full null patch --- .github/patches/testbox-full-null.patch | 15 +++++++-------- .github/workflows/cron.yml | 2 +- .github/workflows/pr.yml | 2 +- .github/workflows/release.yml | 2 +- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/patches/testbox-full-null.patch b/.github/patches/testbox-full-null.patch index eae27a8b..10fbd632 100644 --- a/.github/patches/testbox-full-null.patch +++ b/.github/patches/testbox-full-null.patch @@ -1,13 +1,12 @@ diff --git a/testbox/system/coverage/CoverageService.cfc b/testbox/system/coverage/CoverageService.cfc --- a/testbox/system/coverage/CoverageService.cfc +++ b/testbox/system/coverage/CoverageService.cfc -@@ -172,8 +172,8 @@ component accessors="true" { - } - } - +@@ -175 +175 @@ - if ( isNull( opts.coverageTresholds ) ) { + if ( !structKeyExists( opts, "coverageTresholds" ) || isNull( opts.coverageTresholds ) ) { - opts.coverageTresholds = {}; - } - if ( isNull( opts.coverageTresholds.good ) ) { - opts.coverageTresholds.good = 85; +@@ -178 +178 @@ +- if ( isNull( opts.coverageTresholds.good ) ) { ++ if ( !structKeyExists( opts.coverageTresholds, "good" ) || isNull( opts.coverageTresholds.good ) ) { +@@ -181 +181 @@ +- if ( isNull( opts.coverageTresholds.bad ) ) { ++ if ( !structKeyExists( opts.coverageTresholds, "bad" ) || isNull( opts.coverageTresholds.bad ) ) { diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index f42e73d6..24e43ed0 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -38,7 +38,7 @@ jobs: - name: Install dependencies run: | box install - git apply .github/patches/testbox-full-null.patch + git apply --unidiff-zero .github/patches/testbox-full-null.patch - name: Start server env: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a4824ba4..00e440ce 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -46,7 +46,7 @@ jobs: - name: Install dependencies run: | box install - git apply .github/patches/testbox-full-null.patch + git apply --unidiff-zero .github/patches/testbox-full-null.patch - name: Start server env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 342c8d05..87b76979 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: - name: Install dependencies run: | box install - git apply .github/patches/testbox-full-null.patch + git apply --unidiff-zero .github/patches/testbox-full-null.patch - name: Start server env: From ee40cff528ddca85e11a6af4616e00d1f6babe6f Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 19:51:22 -0600 Subject: [PATCH 111/119] fix: support Adobe full null execution --- .github/patches/testbox-full-null.patch | 51 +++++++++++++++++++++++++ models/Grammars/AutoDiscover.cfc | 6 +-- models/Grammars/BaseGrammar.cfc | 6 ++- models/Query/QueryBuilder.cfc | 13 +++++-- 4 files changed, 67 insertions(+), 9 deletions(-) diff --git a/.github/patches/testbox-full-null.patch b/.github/patches/testbox-full-null.patch index 10fbd632..ec53024c 100644 --- a/.github/patches/testbox-full-null.patch +++ b/.github/patches/testbox-full-null.patch @@ -10,3 +10,54 @@ diff --git a/testbox/system/coverage/CoverageService.cfc b/testbox/system/covera @@ -181 +181 @@ - if ( isNull( opts.coverageTresholds.bad ) ) { + if ( !structKeyExists( opts.coverageTresholds, "bad" ) || isNull( opts.coverageTresholds.bad ) ) { +diff --git a/testbox/system/TestBox.cfc b/testbox/system/TestBox.cfc +--- a/testbox/system/TestBox.cfc ++++ b/testbox/system/TestBox.cfc +@@ -408 +408 @@ +- if ( !isNull( url.testBundles ) ) { ++ if ( structKeyExists( url, "testBundles" ) && !isNull( url.testBundles ) ) { +@@ -411 +411 @@ +- if ( !isNull( url.testSuites ) ) { ++ if ( structKeyExists( url, "testSuites" ) && !isNull( url.testSuites ) ) { +@@ -414 +414 @@ +- if ( !isNull( url.testSpecs ) ) { ++ if ( structKeyExists( url, "testSpecs" ) && !isNull( url.testSpecs ) ) { +@@ -417 +417 @@ +- if ( !isNull( url.testMethod ) ) { ++ if ( structKeyExists( url, "testMethod" ) && !isNull( url.testMethod ) ) { +@@ -259 +259 @@ +- if ( isNull( variables.env ) ) { ++ if ( !structKeyExists( variables, "env" ) || isNull( variables.env ) ) { +diff --git a/testbox/system/util/Util.cfc b/testbox/system/util/Util.cfc +--- a/testbox/system/util/Util.cfc ++++ b/testbox/system/util/Util.cfc +@@ -203 +203 @@ +- if ( isNull( variables.engineMappingHelper ) ) { ++ if ( !structKeyExists( variables, "engineMappingHelper" ) || isNull( variables.engineMappingHelper ) ) { +diff --git a/testbox/system/util/Env.cfc b/testbox/system/util/Env.cfc +--- a/testbox/system/util/Env.cfc ++++ b/testbox/system/util/Env.cfc +@@ -87 +87 @@ +- if ( isNull( variables.javaSystem ) ) { ++ if ( !structKeyExists( variables, "javaSystem" ) || isNull( variables.javaSystem ) ) { +diff --git a/testbox/system/BaseSpec.cfc b/testbox/system/BaseSpec.cfc +--- a/testbox/system/BaseSpec.cfc ++++ b/testbox/system/BaseSpec.cfc +@@ -1627 +1627 @@ +- if ( isNull( variables.$cbMockData ) ) { ++ if ( !structKeyExists( variables, "$cbMockData" ) || isNull( variables.$cbMockData ) ) { +@@ -1640 +1640 @@ +- if ( isNull( variables.$utility ) ) { ++ if ( !structKeyExists( variables, "$utility" ) || isNull( variables.$utility ) ) { +@@ -1653 +1653 @@ +- if ( isNull( variables.$env ) ) { ++ if ( !structKeyExists( variables, "$env" ) || isNull( variables.$env ) ) { +@@ -1668 +1668 @@ +- if ( isNull( this.$mockbox ) ) { ++ if ( !structKeyExists( this, "$mockbox" ) || isNull( this.$mockbox ) ) { +diff --git a/testbox/system/runners/BDDRunner.cfc b/testbox/system/runners/BDDRunner.cfc +--- a/testbox/system/runners/BDDRunner.cfc ++++ b/testbox/system/runners/BDDRunner.cfc +@@ -159 +159 @@ +- isNull( thisSuite ) ? {} : thisSuite ++ !structKeyExists( local, "thisSuite" ) || isNull( local.thisSuite ) ? {} : local.thisSuite diff --git a/models/Grammars/AutoDiscover.cfc b/models/Grammars/AutoDiscover.cfc index 1d46ad71..3491d41d 100644 --- a/models/Grammars/AutoDiscover.cfc +++ b/models/Grammars/AutoDiscover.cfc @@ -37,16 +37,16 @@ component singleton { public AutoDiscover function setShouldWrapValues( required boolean shouldWrapValues ) { variables.shouldWrapValues = arguments.shouldWrapValues; - if ( !isNull( variables.grammar ) ) { + if ( structKeyExists( variables, "grammar" ) && !isNull( variables.grammar ) ) { variables.grammar.setShouldWrapValues( arguments.shouldWrapValues ); } return this; } public any function getResolvedGrammar() { - if ( isNull( variables.grammar ) || !structKeyExists( variables, "grammar" ) ) { + if ( !structKeyExists( variables, "grammar" ) || isNull( variables.grammar ) ) { variables.grammar = autoDiscoverGrammar(); - if ( !isNull( variables.shouldWrapValues ) ) { + if ( structKeyExists( variables, "shouldWrapValues" ) && !isNull( variables.shouldWrapValues ) ) { variables.grammar.setShouldWrapValues( variables.shouldWrapValues ); } } diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index d8aa1be3..dc95756c 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -193,9 +193,11 @@ component displayname="Grammar" accessors="true" singleton { if ( !isNull( arguments.postProcessHook ) ) { arguments.postProcessHook( data ); } - return arguments.returnObject == "query" ? ( isNull( q ) ? javacast( "null", "" ) : q ) : { + return arguments.returnObject == "query" ? ( + !structKeyExists( data, "query" ) || isNull( data.query ) ? javacast( "null", "" ) : data.query + ) : { result: data.result, - query: ( isNull( q ) ? javacast( "null", "" ) : q ) + query: ( !structKeyExists( data, "query" ) || isNull( data.query ) ? javacast( "null", "" ) : data.query ) }; } diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 07d10810..8f35f0ca 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -4281,10 +4281,10 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * forward on the method call to the parent query. */ if ( - !isNull( variables.parentQuery ) && structKeyExists( variables.parentQuery, "populateQuery" ) && structKeyExists( + structKeyExists( variables, "parentQuery" ) && !isNull( variables.parentQuery ) && structKeyExists( variables.parentQuery, - missingMethodName - ) + "populateQuery" + ) && structKeyExists( variables.parentQuery, missingMethodName ) ) { return invoke( variables.parentQuery.populateQuery( this ), missingMethodName, missingMethodArguments ); } @@ -4377,7 +4377,12 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * If a parent query has been set, populate it with this query * and then forward on the method call to the parent query. */ - if ( !isNull( variables.parentQuery ) && structKeyExists( variables.parentQuery, "populateQuery" ) ) { + if ( + structKeyExists( variables, "parentQuery" ) && !isNull( variables.parentQuery ) && structKeyExists( + variables.parentQuery, + "populateQuery" + ) + ) { return invoke( variables.parentQuery.populateQuery( this ), missingMethodName, missingMethodArguments ); } From 756b3c1b7840fb35c06d13423799b908a1601df0 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 20:12:37 -0600 Subject: [PATCH 112/119] fix: guard missing TestBox skip metadata --- .github/patches/testbox-full-null.patch | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/patches/testbox-full-null.patch b/.github/patches/testbox-full-null.patch index ec53024c..b76c66ce 100644 --- a/.github/patches/testbox-full-null.patch +++ b/.github/patches/testbox-full-null.patch @@ -61,3 +61,9 @@ diff --git a/testbox/system/runners/BDDRunner.cfc b/testbox/system/runners/BDDRu @@ -159 +159 @@ - isNull( thisSuite ) ? {} : thisSuite + !structKeyExists( local, "thisSuite" ) || isNull( local.thisSuite ) ? {} : local.thisSuite +diff --git a/testbox/system/runners/BaseRunner.cfc b/testbox/system/runners/BaseRunner.cfc +--- a/testbox/system/runners/BaseRunner.cfc ++++ b/testbox/system/runners/BaseRunner.cfc +@@ -275 +275 @@ +- if ( isNull( md.skip ) ) { ++ if ( !md.keyExists( "skip" ) || isNull( md.skip ) ) { From 229464dce1a45c33f9d912157276376c25ef3187 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 17 Aug 2026 20:30:10 -0600 Subject: [PATCH 113/119] ci: run experimental engines with full null support --- .github/workflows/pr.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 00e440ce..45b60e71 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -25,8 +25,10 @@ jobs: include: - cfengine: "adobe@be" experimental: true + fullNull: "true" - cfengine: "boxlang@be" experimental: true + fullNull: "true" steps: - name: Checkout Repository uses: actions/checkout@v7 From 4d50ae09618919bb0d579bec7da1cdd84c7dc56f Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 21 Aug 2026 18:27:06 -0600 Subject: [PATCH 114/119] fix(QueryBuilder): support external subclass collaborators --- models/Query/JoinClauseManager.cfc | 20 +++++-------- models/Query/PredicateClause.cfc | 30 +++++-------------- models/Query/QueryBuilder.cfc | 8 +++++ tests/resources/ExternalQueryBuilder.cfc | 14 +++++++++ .../QueryBuilderCollaboratorsSpec.cfc | 13 ++++++++ 5 files changed, 50 insertions(+), 35 deletions(-) create mode 100644 tests/resources/ExternalQueryBuilder.cfc diff --git a/models/Query/JoinClauseManager.cfc b/models/Query/JoinClauseManager.cfc index c8736f70..459f0738 100644 --- a/models/Query/JoinClauseManager.cfc +++ b/models/Query/JoinClauseManager.cfc @@ -29,7 +29,7 @@ component { ) { if ( arguments.builder.getUtils().isBuilder( arguments.table ) ) { arguments.table = arguments.builder - .getCollaborator( "QueryExecutor" ) + .getQueryExecutor() .cloneJoinClause( arguments.builder, arguments.table, arguments.builder ); if ( arguments.preventDuplicateJoins && containsJoin( arguments.builder, arguments.table ) ) { return arguments.builder; @@ -40,21 +40,15 @@ component { var join = newJoin( builder = arguments.builder, type = arguments.type, table = arguments.table ); if ( isClosure( arguments.first ) || isCustomFunction( arguments.first ) ) { - var commonTableState = arguments.builder - .getCollaborator( "QueryExecutor" ) - .captureCommonTableState( arguments.builder ); + var commonTableState = arguments.builder.getQueryExecutor().captureCommonTableState( arguments.builder ); try { arguments.first( join ); } catch ( any e ) { - arguments.builder - .getCollaborator( "QueryExecutor" ) - .restoreCommonTableState( arguments.builder, commonTableState ); + arguments.builder.getQueryExecutor().restoreCommonTableState( arguments.builder, commonTableState ); rethrow; } if ( arguments.preventDuplicateJoins && containsJoin( arguments.builder, join ) ) { - arguments.builder - .getCollaborator( "QueryExecutor" ) - .restoreCommonTableState( arguments.builder, commonTableState ); + arguments.builder.getQueryExecutor().restoreCommonTableState( arguments.builder, commonTableState ); return arguments.builder; } return attachJoin( arguments.builder, join ); @@ -111,7 +105,7 @@ component { string type = "inner", boolean where = false ) { - var executor = arguments.builder.getCollaborator( "QueryExecutor" ); + var executor = arguments.builder.getQueryExecutor(); var commonTableState = executor.captureCommonTableState( arguments.builder ); try { if ( isClosure( arguments.input ) || isCustomFunction( arguments.input ) ) { @@ -161,7 +155,7 @@ component { required string type, required any tableLikeSource ) { - var executor = arguments.builder.getCollaborator( "QueryExecutor" ); + var executor = arguments.builder.getQueryExecutor(); var commonTableState = executor.captureCommonTableState( arguments.builder ); try { if ( @@ -220,7 +214,7 @@ component { * Builds and attaches a cross join against a derived table. */ public QueryBuilder function crossJoinSub( required QueryBuilder builder, required any alias, required any input ) { - var executor = arguments.builder.getCollaborator( "QueryExecutor" ); + var executor = arguments.builder.getQueryExecutor(); var commonTableState = executor.captureCommonTableState( arguments.builder ); try { if ( isClosure( arguments.input ) || isCustomFunction( arguments.input ) ) { diff --git a/models/Query/PredicateClause.cfc b/models/Query/PredicateClause.cfc index 1e6e53bd..86ee294d 100644 --- a/models/Query/PredicateClause.cfc +++ b/models/Query/PredicateClause.cfc @@ -14,7 +14,7 @@ component { string combinator = "and" ) { if ( isClosure( arguments.column ) || isCustomFunction( arguments.column ) ) { - return whereNested( arguments.builder, arguments.column, arguments.combinator ); + return arguments.builder.whereNested( arguments.column, arguments.combinator ); } arguments.builder.getQueryValidator().validateCombinator( arguments.combinator ); @@ -314,9 +314,7 @@ component { ) { arguments.builder.getQueryValidator().validateCombinator( arguments.combinator ); if ( !arguments.query.getWheres().isEmpty() ) { - arguments.query = arguments.builder - .getCollaborator( "QueryExecutor" ) - .snapshotBuilder( arguments.builder, arguments.query ); + arguments.query = arguments.builder.getQueryExecutor().snapshotBuilder( arguments.builder, arguments.query ); arguments.builder .getWheres() .append( { type: "nested", query: arguments.query, combinator: arguments.combinator } ); @@ -378,9 +376,7 @@ component { arguments.query = arguments.builder.newQuery(); callback( arguments.query ); } - arguments.query = arguments.builder - .getCollaborator( "QueryExecutor" ) - .snapshotBuilder( arguments.builder, arguments.query ); + arguments.query = arguments.builder.getQueryExecutor().snapshotBuilder( arguments.builder, arguments.query ); var type = arguments.negate ? "notNullSub" : "nullSub"; arguments.builder.getWheres().append( { type: type, query: arguments.query, combinator: arguments.combinator } ); @@ -416,14 +412,10 @@ component { } if ( !isNull( arguments.start ) && arguments.builder.getUtils().isBuilder( arguments.start ) ) { - arguments.start = arguments.builder - .getCollaborator( "QueryExecutor" ) - .snapshotBuilder( arguments.builder, arguments.start ); + arguments.start = arguments.builder.getQueryExecutor().snapshotBuilder( arguments.builder, arguments.start ); } if ( !isNull( arguments.end ) && arguments.builder.getUtils().isBuilder( arguments.end ) ) { - arguments.end = arguments.builder - .getCollaborator( "QueryExecutor" ) - .snapshotBuilder( arguments.builder, arguments.end ); + arguments.end = arguments.builder.getQueryExecutor().snapshotBuilder( arguments.builder, arguments.end ); } var bindings = arguments.builder.extractColumnBindings( [ typedColumn ] ); @@ -591,9 +583,7 @@ component { } var typedColumn = toColumnType( arguments.builder, arguments.column ); var columnBindings = arguments.builder.extractColumnBindings( [ typedColumn ] ); - arguments.query = arguments.builder - .getCollaborator( "QueryExecutor" ) - .snapshotBuilder( arguments.builder, arguments.query ); + arguments.query = arguments.builder.getQueryExecutor().snapshotBuilder( arguments.builder, arguments.query ); arguments.builder .getWheres() .append( { @@ -625,9 +615,7 @@ component { } var typedColumn = toColumnType( arguments.builder, arguments.column ); var columnBindings = arguments.builder.extractColumnBindings( [ typedColumn ] ); - arguments.query = arguments.builder - .getCollaborator( "QueryExecutor" ) - .snapshotBuilder( arguments.builder, arguments.query ); + arguments.query = arguments.builder.getQueryExecutor().snapshotBuilder( arguments.builder, arguments.query ); var type = arguments.negate ? "notInSub" : "inSub"; arguments.builder @@ -652,9 +640,7 @@ component { combinator = "and", negate = false ) { - arguments.query = arguments.builder - .getCollaborator( "QueryExecutor" ) - .snapshotBuilder( arguments.builder, arguments.query ); + arguments.query = arguments.builder.getQueryExecutor().snapshotBuilder( arguments.builder, arguments.query ); var type = arguments.negate ? "notExists" : "exists"; arguments.builder.getWheres().append( { type: type, query: arguments.query, combinator: arguments.combinator } ); arguments.builder.addBindings( arguments.query.getBindings(), "where" ); diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 8f35f0ca..0d8a7703 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -497,6 +497,14 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return getCollaborator( "QueryValidator" ); } + /** + * Returns the lazily instantiated executor for collaborators operating on + * this builder or one of its subclasses. + */ + public QueryExecutor function getQueryExecutor() { + return getCollaborator( "QueryExecutor" ); + } + /** * Returns the lazily instantiated predicate builder. It is cached separately * because predicate validation remains the only general collaborator involved diff --git a/tests/resources/ExternalQueryBuilder.cfc b/tests/resources/ExternalQueryBuilder.cfc new file mode 100644 index 00000000..68ad7082 --- /dev/null +++ b/tests/resources/ExternalQueryBuilder.cfc @@ -0,0 +1,14 @@ +component extends="qb.models.Query.QueryBuilder" { + + variables.whereNestedCalled = false; + + public QueryBuilder function whereNested( required callback, combinator = "and" ) { + variables.whereNestedCalled = true; + return super.whereNested( argumentCollection = arguments ); + } + + public boolean function wasWhereNestedCalled() { + return variables.whereNestedCalled; + } + +} diff --git a/tests/specs/Query/Abstract/QueryBuilderCollaboratorsSpec.cfc b/tests/specs/Query/Abstract/QueryBuilderCollaboratorsSpec.cfc index 3eb5c190..4d21bad2 100644 --- a/tests/specs/Query/Abstract/QueryBuilderCollaboratorsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryBuilderCollaboratorsSpec.cfc @@ -51,6 +51,19 @@ component extends="testbox.system.BaseSpec" { expect( getCollaborators( builder ) ).toHaveLength( 1 ); } ); + it( "supports collaborators on QueryBuilder subclasses outside the qb package", function() { + var builder = prepareMock( new tests.resources.ExternalQueryBuilder() ); + + expect( function() { + builder.where( function( query ) { + query.where( "id", 1 ); + } ); + } ).notToThrow(); + + expect( getCollaborators( builder ) ).toHaveKey( "QueryExecutor" ); + expect( builder.wasWhereNestedCalled() ).toBeTrue(); + } ); + it( "rebuilds validation after settings change", function() { var builder = prepareBuilder(); builder.where( "id", 1 ); From 28879b1740335f59be4b6983e42de3a1a34df5e4 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 21 Aug 2026 18:27:06 -0600 Subject: [PATCH 115/119] fix(QueryUtils): infer BIGINT for large integers --- ModuleConfig.cfc | 2 ++ models/Query/QueryUtils.cfc | 16 ++++++++++++++-- tests/specs/ModuleConfigSpec.cfc | 11 +++++++++++ tests/specs/Query/Abstract/QueryUtilsSpec.cfc | 19 +++++++++++++++++++ .../PrefixedInferredSqlTypeRegressionSpec.cfc | 2 ++ 5 files changed, 48 insertions(+), 2 deletions(-) diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index 3c5fbc19..46ed3f04 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -19,6 +19,7 @@ component { "shouldWrapValues": true, "validateQueryParamStructKeys": true, "integerSQLType": "INTEGER", + "bigIntegerSQLType": "BIGINT", "decimalSQLType": "DECIMAL", "defaultOptions": {}, "sqlCommenter": { @@ -55,6 +56,7 @@ component { .initArg( name = "convertEmptyStringsToNull", value = settings.convertEmptyStringsToNull ) .initArg( name = "validateQueryParamStructKeys", value = settings.validateQueryParamStructKeys ) .initArg( name = "integerSQLType", value = settings.integerSQLType ) + .initArg( name = "bigIntegerSQLType", value = settings.bigIntegerSQLType ) .initArg( name = "decimalSQLType", value = settings.decimalSQLType ); binder diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 86619a36..f9778394 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -23,6 +23,11 @@ component singleton displayname="QueryUtils" accessors="true" { */ property name="integerSQLType" default="INTEGER"; + /** + * Allow overriding default big integer numeric SQL type inferral. + */ + property name="bigIntegerSQLType" default="BIGINT"; + /** * Allow overriding default decimal numeric SQL type inferral. */ @@ -37,11 +42,13 @@ component singleton displayname="QueryUtils" accessors="true" { boolean validateQueryParamStructKeys = true, string integerSqlType = "INTEGER", string decimalSqlType = "DECIMAL", - any log + any log, + string bigIntegerSqlType = "BIGINT" ) { variables.convertEmptyStringsToNull = arguments.convertEmptyStringsToNull; variables.validateQueryParamStructKeys = arguments.validateQueryParamStructKeys; variables.integerSqlType = arguments.integerSqlType; + variables.bigIntegerSqlType = arguments.bigIntegerSqlType; variables.decimalSqlType = arguments.decimalSqlType; if ( !isNull( arguments.log ) ) { variables.log = arguments.log; @@ -891,7 +898,12 @@ component singleton displayname="QueryUtils" accessors="true" { private string function deriveNumericSqlType( required numeric value ) { var isInteger = reFind( "^-?\d+$", arguments.value ) > 0; - return normalizeSqlType( isInteger ? variables.integerSqlType : variables.decimalSqlType ); + if ( !isInteger ) { + return normalizeSqlType( variables.decimalSqlType ); + } + + var isBigInteger = arguments.value < -2147483648 || arguments.value > 2147483647; + return normalizeSqlType( isBigInteger ? variables.bigIntegerSqlType : variables.integerSqlType ); } /** diff --git a/tests/specs/ModuleConfigSpec.cfc b/tests/specs/ModuleConfigSpec.cfc index 03af2ee0..0148d6d2 100644 --- a/tests/specs/ModuleConfigSpec.cfc +++ b/tests/specs/ModuleConfigSpec.cfc @@ -9,6 +9,17 @@ component extends="testbox.system.BaseSpec" { expect( moduleConfig.$getProperty( "settings", "variables" ) ).notToHaveKey( "numericSQLType" ); } ); + + it( "exposes separate integer SQL type settings", function() { + var moduleConfig = prepareMock( new qb.ModuleConfig() ); + + moduleConfig.configure(); + + var settings = moduleConfig.$getProperty( "settings", "variables" ); + expect( settings.integerSQLType ).toBe( "INTEGER" ); + expect( settings.bigIntegerSQLType ).toBe( "BIGINT" ); + expect( settings.decimalSQLType ).toBe( "DECIMAL" ); + } ); } ); } diff --git a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc index c8008e03..37dfb80a 100644 --- a/tests/specs/Query/Abstract/QueryUtilsSpec.cfc +++ b/tests/specs/Query/Abstract/QueryUtilsSpec.cfc @@ -49,6 +49,25 @@ component extends="testbox.system.BaseSpec" { expect( utils.inferSqlType( -100, variables.mockGrammar ) ).toBe( "INTEGER" ); } ); + it( "uses integers through the signed 32-bit boundaries", function() { + expect( utils.inferSqlType( javacast( "long", "2147483647" ), variables.mockGrammar ) ).toBe( + "INTEGER" + ); + expect( utils.inferSqlType( javacast( "long", "-2147483648" ), variables.mockGrammar ) ).toBe( + "INTEGER" + ); + } ); + + it( "uses big integers outside the signed 32-bit boundaries", function() { + expect( utils.inferSqlType( javacast( "long", "2147483648" ), variables.mockGrammar ) ).toBe( + "BIGINT" + ); + expect( utils.inferSqlType( javacast( "long", "-2147483649" ), variables.mockGrammar ) ).toBe( + "BIGINT" + ); + expect( utils.inferSqlType( javacast( "long", "348060777867223040" ), variables.mockGrammar ) ).toBe( "BIGINT" ); + } ); + it( "decimals", function() { expect( utils.inferSqlType( 4.50, variables.mockGrammar ) ).toBe( "DECIMAL" ); } ); diff --git a/tests/specs/Query/PrefixedInferredSqlTypeRegressionSpec.cfc b/tests/specs/Query/PrefixedInferredSqlTypeRegressionSpec.cfc index fc4ff2fa..b3809db6 100644 --- a/tests/specs/Query/PrefixedInferredSqlTypeRegressionSpec.cfc +++ b/tests/specs/Query/PrefixedInferredSqlTypeRegressionSpec.cfc @@ -17,11 +17,13 @@ component extends="testbox.system.BaseSpec" { it( "normalizes configured numeric SQL types", function() { var utils = new qb.models.Query.QueryUtils( integerSqlType = "cf_sql_bigint", + bigIntegerSqlType = "cf_sql_numeric", decimalSqlType = "cf_sql_numeric" ); var grammar = new qb.models.Grammars.BaseGrammar( utils ); expect( utils.inferSqlType( 18, grammar ) ).toBe( "BIGINT" ); + expect( utils.inferSqlType( javacast( "long", "2147483648" ), grammar ) ).toBe( "NUMERIC" ); expect( utils.inferSqlType( 18.5, grammar ) ).toBe( "NUMERIC" ); } ); From d12838c77061900308d0f463ba4cc943ac4ac425 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 11:56:40 -0600 Subject: [PATCH 116/119] refactor: reduce internal closure usage --- models/Grammars/BaseGrammar.cfc | 391 +++++----- models/Grammars/DerbyGrammar.cfc | 180 ++--- models/Grammars/MySQLGrammar.cfc | 134 ++-- models/Grammars/OracleGrammar.cfc | 142 ++-- models/Grammars/PostgresGrammar.cfc | 187 ++--- models/Grammars/SQLiteGrammar.cfc | 226 +++--- models/Grammars/SqlServerGrammar.cfc | 402 +++++----- .../Formatters/ArrayFormatterFactory.cfc | 17 + .../Formatters/IdentityFormatterFactory.cfc | 14 + models/Query/JoinClauseManager.cfc | 26 +- models/Query/JsonQueryClause.cfc | 10 +- models/Query/PredicateClause.cfc | 36 +- models/Query/QueryBuilder.cfc | 697 +++++++++--------- models/Query/QueryUtils.cfc | 124 ++-- models/Query/ReturnFormatterRegistry.cfc | 24 +- models/SQLCommenter/ColdBoxSQLCommenter.cfc | 17 +- .../Commenters/BindingsCommenter.cfc | 14 +- models/SQLCommenter/SQLCommenter.cfc | 9 +- models/Schema/Blueprint.cfc | 10 +- models/Schema/SchemaBuilder.cfc | 210 ++---- models/Support/NullInterceptorService.cfc | 12 + models/Support/NullLogger.cfc | 13 + 22 files changed, 1407 insertions(+), 1488 deletions(-) create mode 100644 models/Query/Formatters/ArrayFormatterFactory.cfc create mode 100644 models/Query/Formatters/IdentityFormatterFactory.cfc create mode 100644 models/Support/NullInterceptorService.cfc create mode 100644 models/Support/NullLogger.cfc diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index dc95756c..7b91b42c 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -76,19 +76,8 @@ component displayname="Grammar" accessors="true" singleton { variables.shouldWrapValues = true; variables.shouldWrapValuesContext = createObject( "java", "java.lang.ThreadLocal" ).init(); // These are overwritten by WireBox, if it exists. - variables.interceptorService = { - "processState": function() { - }, - "announce": function() { - } - }; - variables.log = { - "canDebug": function() { - return false; - }, - "debug": function() { - } - }; + variables.interceptorService = new qb.models.Support.NullInterceptorService(); + variables.log = new qb.models.Support.NullLogger(); return this; } @@ -303,27 +292,30 @@ component displayname="Grammar" accessors="true" singleton { var hasRecursion = false; - var sql = arguments.commonTables.map( function( commonTable ) { - var sql = arguments.commonTable.query.toSQL(); + var sql = []; + for ( var commonTable in arguments.commonTables ) { + var commonTableSql = commonTable.query.toSQL(); // generate the optional column definition - var columns = arguments.commonTable.columns - .map( function( value ) { - return wrapColumn( arguments.value ); - } ) - .toList(); + var wrappedColumns = []; + for ( var value in commonTable.columns ) { + wrappedColumns.append( wrapColumn( value ) ); + } + var columns = wrappedColumns.toList(); // we need to track if any of the CTEs are recursive - if ( arguments.commonTable.recursive ) { + if ( commonTable.recursive ) { hasRecursion = true; } - return wrapColumn( arguments.commonTable.name ) & ( - len( columns ) ? " " & ( variables.cteColumnsRequireParentheses ? "(" : "" ) & columns & ( - variables.cteColumnsRequireParentheses ? ")" : "" - ) : "" - ) & " AS (" & sql & ")"; - } ); + sql.append( + wrapColumn( commonTable.name ) & ( + len( columns ) ? " " & ( variables.cteColumnsRequireParentheses ? "(" : "" ) & columns & ( + variables.cteColumnsRequireParentheses ? ")" : "" + ) : "" + ) & " AS (" & commonTableSql & ")" + ); + } /* Most implementations of CTE require the RECURSIVE keyword if *any* single CTE uses recursive, @@ -347,7 +339,11 @@ component displayname="Grammar" accessors="true" singleton { return ""; } var select = query.getDistinct() && query.getAggregate().isEmpty() ? "SELECT DISTINCT " : "SELECT "; - return select & columns.map( wrapColumn ).toList( ", " ); + var wrappedColumns = []; + for ( var column in arguments.columns ) { + wrappedColumns.append( wrapColumn( column ) ); + } + return select & wrappedColumns.toList( ", " ); } public string function compileConcat( required string alias, required array items ) { @@ -828,7 +824,11 @@ component displayname="Grammar" accessors="true" singleton { return ""; } - return trim( "GROUP BY #groups.map( wrapColumn ).toList( ", " )#" ); + var wrappedGroups = []; + for ( var group in arguments.groups ) { + wrappedGroups.append( wrapColumn( group ) ); + } + return trim( "GROUP BY #wrappedGroups.toList( ", " )#" ); } /** @@ -843,7 +843,10 @@ component displayname="Grammar" accessors="true" singleton { if ( arguments.havings.isEmpty() ) { return ""; } - var sql = arguments.havings.map( compileHaving ); + var sql = []; + for ( var having in arguments.havings ) { + sql.append( compileHaving( having ) ); + } return trim( "HAVING #removeLeadingCombinator( sql.toList( " " ) )#" ); } @@ -878,12 +881,13 @@ component displayname="Grammar" accessors="true" singleton { return ""; } - var sql = arguments.unions.map( function( union ) { + var sql = []; + for ( var union in arguments.unions ) { /* * No queries being unioned to the origin query can contain an ORDER BY clause, only the outer-most * QueryBuilder instance can actually have a defined orderBy(). */ - if ( arguments.union.query.getOrders().len() ) { + if ( union.query.getOrders().len() ) { throw( type = "OrderByNotAllowed", message = "The ORDER BY clause is not allowed in a UNION statement.", @@ -891,10 +895,9 @@ component displayname="Grammar" accessors="true" singleton { ); } - var sql = arguments.union.query.toSQL(); - - return "UNION " & ( arguments.union.all ? "ALL " : "" ) & sql; - } ); + var unionSql = union.query.toSQL(); + sql.append( "UNION " & ( union.all ? "ALL " : "" ) & unionSql ); + } return trim( arrayToList( sql, " " ) ); } @@ -912,17 +915,18 @@ component displayname="Grammar" accessors="true" singleton { return ""; } - var orderBys = orders.map( function( orderBy ) { + var orderBys = []; + for ( var orderBy in arguments.orders ) { if ( orderBy.direction == "raw" ) { - return orderBy.column.getSQL(); + orderBys.append( orderBy.column.getSQL() ); } else if ( orderBy.direction == "random" ) { - return orderByRandom(); + orderBys.append( orderByRandom() ); } else if ( orderBy.keyExists( "query" ) ) { - return "(#this.compileSelect( orderBy.query )#) #uCase( orderBy.direction )#"; + orderBys.append( "(#this.compileSelect( orderBy.query )#) #uCase( orderBy.direction )#" ); } else { - return "#wrapColumn( orderBy.column )# #uCase( orderBy.direction )#"; + orderBys.append( "#wrapColumn( orderBy.column )# #uCase( orderBy.direction )#" ); } - } ); + } return "ORDER BY #orderBys.toList( ", " )#"; } @@ -1002,25 +1006,21 @@ component displayname="Grammar" accessors="true" singleton { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - var columnsString = arguments.columns - .map( function( column ) { - return wrapColumn( column.formatted ); - } ) - .toList( ", " ); - - var placeholderString = values - .map( function( valueArray ) { - return "(" & valueArray - .map( function( item ) { - if ( getUtils().isExpression( item ) ) { - return item.getSQL(); - } else { - return "?"; - } - } ) - .toList( ", " ) & ")"; - } ) - .toList( ", " ); + var wrappedColumns = []; + for ( var column in arguments.columns ) { + wrappedColumns.append( wrapColumn( column.formatted ) ); + } + var columnsString = wrappedColumns.toList( ", " ); + + var placeholderRows = []; + for ( var valueArray in arguments.values ) { + var placeholders = []; + for ( var item in valueArray ) { + placeholders.append( getUtils().isExpression( item ) ? item.getSQL() : "?" ); + } + placeholderRows.append( "(" & placeholders.toList( ", " ) & ")" ); + } + var placeholderString = placeholderRows.toList( ", " ); return trim( "INSERT INTO #wrapTable( query.getTableName() )# (#columnsString#) VALUES #placeholderString#" ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { @@ -1047,18 +1047,18 @@ component displayname="Grammar" accessors="true" singleton { var columnNames = []; var seenColumns = {}; - arguments.values.each( function( row ) { - if ( !isStruct( arguments.row ) ) { + for ( var row in arguments.values ) { + if ( !isStruct( row ) ) { throw( type = "InvalidSQLType", message = "Please pass an array of structs mapping columns to values" ); } - for ( var key in arguments.row ) { + for ( var key in row ) { if ( !seenColumns.keyExists( key ) ) { seenColumns[ key ] = true; columnNames.append( key ); } } - } ); + } return columnNames; } @@ -1071,15 +1071,15 @@ component displayname="Grammar" accessors="true" singleton { * @sqlTypes Explicit SQL types keyed by column name. */ public struct function prepareBulkInsert( required any query, required array values, required struct sqlTypes ) { - var builder = arguments.query; - var columns = resolveInsertColumnNames( arguments.values ).map( function( column ) { - var formatted = listLast( builder.applyColumnFormatter( column ), "." ); - return { "original": column, "formatted": { "type": "simple", "value": formatted } }; - } ); - columns.sort( ( a, b ) => compareNoCase( a.formatted.value, b.formatted.value ) ); - - arguments.values.each( function( row ) { - columns.each( function( column ) { + var columns = []; + for ( var columnName in resolveInsertColumnNames( arguments.values ) ) { + var formatted = listLast( arguments.query.applyColumnFormatter( columnName ), "." ); + columns.append( { "original": columnName, "formatted": { "type": "simple", "value": formatted } } ); + } + columns = sortColumnsByFormattedValue( columns ); + + for ( var row in arguments.values ) { + for ( var column in columns ) { if ( row.keyExists( column.original ) && !isNull( row[ column.original ] ) && @@ -1087,12 +1087,31 @@ component displayname="Grammar" accessors="true" singleton { ) { throw( type = "InvalidBulkValue", message = "Bulk insert values cannot contain SQL expressions." ); } - } ); - } ); + } + } return prepareBulkInsertValues( arguments.values, columns, arguments.sqlTypes ); } + /** + * Sort normalized column definitions without allocating a comparator closure. + */ + private array function sortColumnsByFormattedValue( required array columns ) { + for ( var i = 2; i <= arguments.columns.len(); i++ ) { + var currentColumn = arguments.columns[ i ]; + var position = i - 1; + while ( + position >= 1 && + compareNoCase( currentColumn.formatted.value, arguments.columns[ position ].formatted.value ) < 0 + ) { + arguments.columns[ position + 1 ] = arguments.columns[ position ]; + position--; + } + arguments.columns[ position + 1 ] = currentColumn; + } + return arguments.columns; + } + /** * Prepare database-specific values and metadata for a native bulk insert. * @@ -1153,18 +1172,18 @@ component displayname="Grammar" accessors="true" singleton { * @return The compiled match predicate. */ public string function compileUpsertTargetConstraint( required array target, boolean matchNulls = false ) { - var shouldMatchNulls = arguments.matchNulls; - return arguments.target - .map( function( column ) { - var targetColumn = wrapColumn( { "type": "simple", "value": "qb_target.#column.formatted.value#" } ); - var sourceColumn = wrapColumn( { "type": "simple", "value": "qb_src.#column.formatted.value#" } ); - var equality = "#targetColumn# = #sourceColumn#"; - if ( !shouldMatchNulls ) { - return equality; - } - return "(#equality# OR (#targetColumn# IS NULL AND #sourceColumn# IS NULL))"; - } ) - .toList( " AND " ); + var constraints = []; + for ( var column in arguments.target ) { + var targetColumn = wrapColumn( { "type": "simple", "value": "qb_target.#column.formatted.value#" } ); + var sourceColumn = wrapColumn( { "type": "simple", "value": "qb_src.#column.formatted.value#" } ); + var equality = "#targetColumn# = #sourceColumn#"; + constraints.append( + arguments.matchNulls + ? "(#equality# OR (#targetColumn# IS NULL AND #sourceColumn# IS NULL))" + : equality + ); + } + return constraints.toList( " AND " ); } /** @@ -1187,11 +1206,11 @@ component displayname="Grammar" accessors="true" singleton { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - var columnsString = arguments.columns - .map( function( column ) { - return wrapColumn( column.formatted ); - } ) - .toList( ", " ); + var wrappedColumns = []; + for ( var column in arguments.columns ) { + wrappedColumns.append( wrapColumn( column.formatted ) ); + } + var columnsString = wrappedColumns.toList( ", " ); var targetColumns = columnsString == "" ? "" : " (#columnsString#)"; return trim( @@ -1227,18 +1246,18 @@ component displayname="Grammar" accessors="true" singleton { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - var updateList = columns - .map( function( column ) { - var value = updateMap[ column.original ]; - var assignment = "?"; - if ( utils.isExpression( value ) ) { - assignment = value.getSql(); - } else if ( utils.isBuilder( value ) ) { - assignment = "(#value.toSQL()#)"; - } - return "#wrapColumn( column.formatted )# = #assignment#"; - } ) - .toList( ", " ); + var updateAssignments = []; + for ( var column in arguments.columns ) { + var value = arguments.updateMap[ column.original ]; + var assignment = "?"; + if ( utils.isExpression( value ) ) { + assignment = value.getSql(); + } else if ( utils.isBuilder( value ) ) { + assignment = "(#value.toSQL()#)"; + } + updateAssignments.append( "#wrapColumn( column.formatted )# = #assignment#" ); + } + var updateList = updateAssignments.toList( ", " ); var updateStatement = "UPDATE #wrapQueryTable( query )#"; @@ -1338,12 +1357,13 @@ component displayname="Grammar" accessors="true" singleton { * @return string */ private string function concatenate( required array sql, string separator = " " ) { - return arrayToList( - arrayFilter( arguments.sql, function( item ) { - return item != ""; - } ), - arguments.separator - ); + var fragments = []; + for ( var item in arguments.sql ) { + if ( item != "" ) { + fragments.append( item ); + } + } + return arrayToList( fragments, arguments.separator ); } /** @@ -1383,16 +1403,14 @@ component displayname="Grammar" accessors="true" singleton { var parts = explodeTable( arguments.table ); if ( getUtils().isNotSubQuery( parts.table ) ) { - parts.table = parts.table - .listToArray( "." ) - .map( function( tablePart, index, tableParts ) { - // Add the tableprefix when we get to the last element - if ( index == tableParts.len() ) { - return wrapValue( getTablePrefix() & tablePart ); - } - return wrapValue( tablePart ); - } ) - .toList( "." ); + var tableParts = parts.table.listToArray( "." ); + var wrappedTableParts = []; + for ( var i = 1; i <= tableParts.len(); i++ ) { + wrappedTableParts.append( + wrapValue( i == tableParts.len() ? getTablePrefix() & tableParts[ i ] : tableParts[ i ] ) + ); + } + parts.table = wrappedTableParts.toList( "." ); } if ( !parts.alias.len() ) { return parts.table; @@ -1471,10 +1489,11 @@ component displayname="Grammar" accessors="true" singleton { var columnParts = explodeColumnAlias( arguments.column.value ); arguments.column = columnParts.column; var alias = columnParts.alias; - arguments.column = arguments.column - .listToArray( "." ) - .map( wrapValue ) - .toList( "." ); + var wrappedColumnParts = []; + for ( var columnPart in arguments.column.listToArray( "." ) ) { + wrappedColumnParts.append( wrapValue( columnPart ) ); + } + arguments.column = wrappedColumnParts.toList( "." ); if ( !alias.len() ) { return arguments.column; } @@ -1718,12 +1737,11 @@ component displayname="Grammar" accessors="true" singleton { } function compileCreateColumns( required blueprint ) { - return blueprint - .getColumns() - .map( function( column ) { - return compileCreateColumn( column, blueprint ); - } ) - .toList( ", " ); + var columns = []; + for ( var column in arguments.blueprint.getColumns() ) { + columns.append( compileCreateColumn( column, arguments.blueprint ) ); + } + return columns.toList( ", " ); } function compileCreateColumn( column, blueprint ) { @@ -2178,12 +2196,11 @@ component displayname="Grammar" accessors="true" singleton { } function typeEnum( column ) { - var values = column - .getValues() - .map( function( value ) { - return quoteStringLiteral( value ); - } ) - .toList( ", " ); + var quotedValues = []; + for ( var value in arguments.column.getValues() ) { + quotedValues.append( quoteStringLiteral( value ) ); + } + var values = quotedValues.toList( ", " ); return "ENUM(#values#)"; } @@ -2373,15 +2390,18 @@ component displayname="Grammar" accessors="true" singleton { setShouldWrapValues( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ); } - return blueprint - .getIndexes() - .map( function( index ) { - return invoke( this, "index#index.getType()#", { index: index, blueprint: blueprint } ); - } ) - .filter( function( item ) { - return item != ""; - } ) - .toList( ", " ); + var compiledIndexes = []; + for ( var index in arguments.blueprint.getIndexes() ) { + var compiledIndex = invoke( + this, + "index#index.getType()#", + { index: index, blueprint: arguments.blueprint } + ); + if ( compiledIndex != "" ) { + compiledIndexes.append( compiledIndex ); + } + } + return compiledIndexes.toList( ", " ); } finally { if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); @@ -2396,13 +2416,12 @@ component displayname="Grammar" accessors="true" singleton { setShouldWrapValues( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ); } - var columnList = commandParameters.index - .getColumns() - .map( function( column ) { - column = isSimpleValue( column ) ? column : column.getName(); - return wrapValue( column ); - } ) - .toList( ", " ); + var wrappedColumns = []; + for ( var column in commandParameters.index.getColumns() ) { + column = isSimpleValue( column ) ? column : column.getName(); + wrappedColumns.append( wrapValue( column ) ); + } + var columnList = wrappedColumns.toList( ", " ); return concatenate( [ "CREATE INDEX", @@ -2419,29 +2438,26 @@ component displayname="Grammar" accessors="true" singleton { } function indexBasic( index, blueprint ) { - var columnsString = arguments.index - .getColumns() - .map( function( column ) { - return wrapColumn( { "type": "simple", "value": column } ); - } ) - .toList( ", " ); + var wrappedColumns = []; + for ( var column in arguments.index.getColumns() ) { + wrappedColumns.append( wrapColumn( { "type": "simple", "value": column } ) ); + } + var columnsString = wrappedColumns.toList( ", " ); return "INDEX #wrapValue( arguments.index.getName() )# (#columnsString#)"; } function indexForeign( index ) { // FOREIGN KEY ("country_id") REFERENCES countries ("id") ON DELETE CASCADE - var keys = arguments.index - .getForeignKey() - .map( function( key ) { - return wrapColumn( { "type": "simple", "value": key } ); - } ) - .toList( ", " ); - var references = arguments.index - .getColumns() - .map( function( column ) { - return wrapColumn( { "type": "simple", "value": column } ); - } ) - .toList( ", " ); + var wrappedKeys = []; + for ( var key in arguments.index.getForeignKey() ) { + wrappedKeys.append( wrapColumn( { "type": "simple", "value": key } ) ); + } + var keys = wrappedKeys.toList( ", " ); + var wrappedReferences = []; + for ( var column in arguments.index.getColumns() ) { + wrappedReferences.append( wrapColumn( { "type": "simple", "value": column } ) ); + } + var references = wrappedReferences.toList( ", " ); return concatenate( [ "CONSTRAINT #wrapValue( arguments.index.getName() )#", "FOREIGN KEY (#keys#)", @@ -2452,33 +2468,30 @@ component displayname="Grammar" accessors="true" singleton { } function indexPrimary( index ) { - var references = arguments.index - .getColumns() - .map( function( column ) { - return wrapColumn( { "type": "simple", "value": column } ); - } ) - .toList( ", " ); + var wrappedReferences = []; + for ( var column in arguments.index.getColumns() ) { + wrappedReferences.append( wrapColumn( { "type": "simple", "value": column } ) ); + } + var references = wrappedReferences.toList( ", " ); return "CONSTRAINT #wrapValue( arguments.index.getName() )# PRIMARY KEY (#references#)"; } function indexUnique( index ) { - var references = arguments.index - .getColumns() - .map( function( column ) { - return wrapColumn( { "type": "simple", "value": column } ); - } ) - .toList( ", " ); + var wrappedReferences = []; + for ( var column in arguments.index.getColumns() ) { + wrappedReferences.append( wrapColumn( { "type": "simple", "value": column } ) ); + } + var references = wrappedReferences.toList( ", " ); return "CONSTRAINT #wrapValue( arguments.index.getName() )# UNIQUE (#references#)"; } function indexCheck( index ) { var column = arguments.index.getColumns()[ 1 ]; - var values = column - .getValues() - .map( function( val ) { - return quoteStringLiteral( val ); - } ) - .toList( ", " ); + var quotedValues = []; + for ( var val in column.getValues() ) { + quotedValues.append( quoteStringLiteral( val ) ); + } + var values = quotedValues.toList( ", " ); return concatenate( [ "CONSTRAINT", wrapValue( arguments.index.getName() ), diff --git a/models/Grammars/DerbyGrammar.cfc b/models/Grammars/DerbyGrammar.cfc index c88ca438..8242f043 100644 --- a/models/Grammars/DerbyGrammar.cfc +++ b/models/Grammars/DerbyGrammar.cfc @@ -81,27 +81,30 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { var hasRecursion = false; - var sql = arguments.commonTables.map( function( commonTable ) { - var sql = arguments.commonTable.query.toSQL(); + var sql = []; + for ( var commonTable in arguments.commonTables ) { + var commonTableSql = commonTable.query.toSQL(); // generate the optional column definition - var columns = arguments.commonTable.columns - .map( function( value ) { - return wrapColumn( arguments.value ); - } ) - .toList(); + var wrappedColumns = []; + for ( var value in commonTable.columns ) { + wrappedColumns.append( wrapColumn( value ) ); + } + var columns = wrappedColumns.toList(); // we need to track if any of the CTEs are recursive - if ( arguments.commonTable.recursive ) { + if ( commonTable.recursive ) { throw( type = "UnsupportedOperation", message = "This grammar does not support recursive CTEs." ); } - return wrapColumn( arguments.commonTable.name ) & ( - len( columns ) ? " " & ( variables.cteColumnsRequireParentheses ? "(" : "" ) & columns & ( - variables.cteColumnsRequireParentheses ? ")" : "" - ) : "" - ) & " AS (" & sql & ")"; - } ); + sql.append( + wrapColumn( commonTable.name ) & ( + len( columns ) ? " " & ( variables.cteColumnsRequireParentheses ? "(" : "" ) & columns & ( + variables.cteColumnsRequireParentheses ? ")" : "" + ) : "" + ) & " AS (" & commonTableSql & ")" + ); + } /* Most implementations of CTE require the RECURSIVE keyword if *any* single CTE uses recursive, @@ -134,23 +137,24 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { var multiple = arguments.values.len() > 1; - var columnsString = arguments.columns - .map( function( column ) { - return wrapColumn( column.formatted ); - } ) - .toList( ", " ); - - var results = arguments.values.map( function( valueArray ) { - return "INSERT INTO #wrapTable( query.getTableName() )# (#columnsString#) VALUES (" & valueArray - .map( function( item ) { - if ( getUtils().isExpression( item ) ) { - return item.getSQL(); - } else { - return "?"; - } - } ) - .toList( ", " ) & ")"; - } ); + var wrappedColumns = []; + for ( var column in arguments.columns ) { + wrappedColumns.append( wrapColumn( column.formatted ) ); + } + var columnsString = wrappedColumns.toList( ", " ); + + var results = []; + for ( var valueArray in arguments.values ) { + var placeholders = []; + for ( var item in valueArray ) { + placeholders.append( getUtils().isExpression( item ) ? item.getSQL() : "?" ); + } + results.append( + "INSERT INTO #wrapTable( query.getTableName() )# (#columnsString#) VALUES (" & + placeholders.toList( ", " ) & + ")" + ); + } return trim( results.toList( "; " ) ); } finally { @@ -202,18 +206,18 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - var updateList = columns - .map( function( column ) { - var value = updateMap[ column.original ]; - var assignment = "?"; - if ( utils.isExpression( value ) ) { - assignment = value.getSql(); - } else if ( utils.isBuilder( value ) ) { - assignment = "(#value.toSQL()#)"; - } - return "#wrapColumn( column.formatted )# = #assignment#"; - } ) - .toList( ", " ); + var updateAssignments = []; + for ( var column in arguments.columns ) { + var value = arguments.updateMap[ column.original ]; + var assignment = "?"; + if ( utils.isExpression( value ) ) { + assignment = value.getSql(); + } else if ( utils.isBuilder( value ) ) { + assignment = "(#value.toSQL()#)"; + } + updateAssignments.append( "#wrapColumn( column.formatted )# = #assignment#" ); + } + var updateList = updateAssignments.toList( ", " ); var updateStatement = "UPDATE #wrapQueryTable( query )#"; @@ -264,61 +268,57 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.qb.getShouldWrapValues() ); } - var columnsString = arguments.insertColumns - .map( function( column ) { - return wrapColumn( column.formatted ); - } ) - .toList( ", " ); - - var valuesString = arrayToList( - arguments.insertColumns.map( function( column ) { - return wrapColumn( { "type": "simple", "value": "qb_src.#column.formatted.value#" } ); - } ), - ", " - ); + var wrappedInsertColumns = []; + var wrappedSourceColumns = []; + for ( var column in arguments.insertColumns ) { + wrappedInsertColumns.append( wrapColumn( column.formatted ) ); + wrappedSourceColumns.append( + wrapColumn( { "type": "simple", "value": "qb_src.#column.formatted.value#" } ) + ); + } + var columnsString = wrappedInsertColumns.toList( ", " ); + var valuesString = wrappedSourceColumns.toList( ", " ); var placeholderString = ""; if ( !isNull( arguments.source ) ) { placeholderString = compileSelect( arguments.source ); } else { - placeholderString = "VALUES " & arguments.values - .map( function( valueArray ) { - return "(" & valueArray - .map( function( item ) { - if ( getUtils().isExpression( item ) ) { - return item.getSQL(); - } else { - return "?"; - } - } ) - .toList( ", " ) & ")"; - } ) - .toList( ", " ); + var placeholderRows = []; + for ( var valueArray in arguments.values ) { + var placeholders = []; + for ( var item in valueArray ) { + placeholders.append( getUtils().isExpression( item ) ? item.getSQL() : "?" ); + } + placeholderRows.append( "(" & placeholders.toList( ", " ) & ")" ); + } + placeholderString = "VALUES " & placeholderRows.toList( ", " ); } var constraintString = compileUpsertTargetConstraint( arguments.target, arguments.matchNulls ); var updateList = ""; if ( isArray( arguments.updates ) ) { - updateList = arguments.updates - .map( function( column ) { - return "#wrapColumn( column.formatted )# = #wrapColumn( { "type": "simple", "value": "qb_src.#column.formatted.value#" } )#"; - } ) - .toList( ", " ); + var updateAssignments = []; + for ( var column in arguments.updates ) { + updateAssignments.append( + "#wrapColumn( column.formatted )# = #wrapColumn( { "type": "simple", "value": "qb_src.#column.formatted.value#" } )#" + ); + } + updateList = updateAssignments.toList( ", " ); } else { - updateList = arguments.updateColumns - .map( function( column ) { - var equalsClause = "?"; - if ( - !isNull( updates[ column.original ] ) && getUtils().isExpression( - updates[ column.original ] - ) - ) { - equalsClause = updates[ column.original ].getSQL(); - } - return "#wrapColumn( column.formatted )# = #equalsClause#"; - } ) - .toList( ", " ); + var updateAssignments = []; + for ( var column in arguments.updateColumns ) { + var equalsClause = "?"; + if ( + !isNull( arguments.updates[ column.original ] ) && getUtils().isExpression( + arguments.updates[ column.original ] + ) + ) { + equalsClause = arguments.updates[ column.original ].getSQL(); + } + updateAssignments.append( "#wrapColumn( column.formatted )# = #equalsClause#" ); + } + updateList = updateAssignments.toList( ", " ); } var updateStatement = updateList == "" ? "" : " WHEN MATCHED THEN UPDATE SET #updateList#"; @@ -765,9 +765,11 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } var tables = getAllTableNames( options, schema ); - return arrayMap( tables, function( table ) { - return "DROP TABLE #wrapTable( table )#"; - } ); + var statements = []; + for ( var table in tables ) { + statements.append( "DROP TABLE #wrapTable( table )#" ); + } + return statements; } finally { if ( !isNull( arguments.sb.getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); diff --git a/models/Grammars/MySQLGrammar.cfc b/models/Grammars/MySQLGrammar.cfc index bd14351c..44760176 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -180,23 +180,25 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } var tables = getAllTableNames( options, schema ); - var tableList = arrayToList( - arrayMap( tables, function( table ) { - return wrapTable( table ); - } ), - ", " - ); + var wrappedTables = []; + for ( var table in tables ) { + wrappedTables.append( wrapTable( table ) ); + } + var tableList = wrappedTables.toList( ", " ); - return arrayFilter( - [ + var statements = []; + for ( + var sql in [ compileDisableForeignKeyConstraints(), arrayIsEmpty( tables ) ? "" : "DROP TABLE #tableList#", compileEnableForeignKeyConstraints() - ], - function( sql ) { - return sql != ""; + ] + ) { + if ( sql != "" ) { + statements.append( sql ); } - ); + } + return statements; } finally { if ( !isNull( arguments.sb.getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); @@ -212,11 +214,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { options, "query" ); - var columnName = arrayToList( - arrayFilter( tablesQuery.getColumnNames(), function( columnName ) { - return columnName != "Table_type"; - } ) - ); + var columnName = ""; + for ( var candidateColumnName in tablesQuery.getColumnNames() ) { + if ( candidateColumnName != "Table_type" ) { + columnName = listAppend( columnName, candidateColumnName ); + } + } var tables = []; for ( var table in tablesQuery ) { arrayAppend( @@ -287,11 +290,11 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - var columnsString = arguments.columns - .map( function( column ) { - return wrapColumn( column.formatted ); - } ) - .toList( ", " ); + var wrappedColumns = []; + for ( var column in arguments.columns ) { + wrappedColumns.append( wrapColumn( column.formatted ) ); + } + var columnsString = wrappedColumns.toList( ", " ); var targetColumns = columnsString == "" ? "" : " (#columnsString#)"; var cteClause = query.getCommonTables().isEmpty() ? "" : " #compileCommonTables( query, query.getCommonTables() )#"; @@ -340,33 +343,30 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - return trim( - arrayToList( - arrayFilter( - [ - compileCommonTables( query, query.getCommonTables() ), - "DELETE", - hasJoins - ? ( - query.getAlias() != "" - ? wrapAlias( getTablePrefix() & query.getAlias() ) - : wrapTable( query.getTableName(), false ) - ) - : "", - "FROM", - wrapQueryTable( query ), - hasJoins ? compileJoins( query, query.getJoins() ) : "", - compileWheres( query, query.getWheres() ), - hasJoins ? "" : compileOrders( query, query.getOrders() ), - hasJoins ? "" : compileLimitValue( query, query.getLimitValue() ) - ], - function( sql ) { - return sql != ""; - } - ), - " " + var sqlFragments = [ + compileCommonTables( query, query.getCommonTables() ), + "DELETE", + hasJoins + ? ( + query.getAlias() != "" + ? wrapAlias( getTablePrefix() & query.getAlias() ) + : wrapTable( query.getTableName(), false ) ) - ); + : "", + "FROM", + wrapQueryTable( query ), + hasJoins ? compileJoins( query, query.getJoins() ) : "", + compileWheres( query, query.getWheres() ), + hasJoins ? "" : compileOrders( query, query.getOrders() ), + hasJoins ? "" : compileLimitValue( query, query.getLimitValue() ) + ]; + var nonEmptyFragments = []; + for ( var sql in sqlFragments ) { + if ( sql != "" ) { + nonEmptyFragments.append( sql ); + } + } + return trim( nonEmptyFragments.toList( " " ) ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); @@ -408,25 +408,27 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { ) : this.compileInsertUsing( arguments.qb, arguments.insertColumns, arguments.source ); var updateString = ""; if ( isArray( arguments.updates ) ) { - updateString = arguments.updateColumns - .map( function( column ) { - return "#wrapColumn( column.formatted )# = VALUES(#wrapColumn( column.formatted )#)"; - } ) - .toList( ", " ); + var updateAssignments = []; + for ( var column in arguments.updateColumns ) { + updateAssignments.append( + "#wrapColumn( column.formatted )# = VALUES(#wrapColumn( column.formatted )#)" + ); + } + updateString = updateAssignments.toList( ", " ); } else { - updateString = arguments.updateColumns - .map( function( column ) { - var equalsClause = "?"; - if ( - !isNull( updates[ column.original ] ) && getUtils().isExpression( - updates[ column.original ] - ) - ) { - equalsClause = updates[ column.original ].getSQL(); - } - return "#wrapColumn( column.formatted )# = #equalsClause#"; - } ) - .toList( ", " ); + var updateAssignments = []; + for ( var column in arguments.updateColumns ) { + var equalsClause = "?"; + if ( + !isNull( arguments.updates[ column.original ] ) && getUtils().isExpression( + arguments.updates[ column.original ] + ) + ) { + equalsClause = arguments.updates[ column.original ].getSQL(); + } + updateAssignments.append( "#wrapColumn( column.formatted )# = #equalsClause#" ); + } + updateString = updateAssignments.toList( ", " ); } return insertString & " ON DUPLICATE KEY UPDATE #updateString#"; } finally { diff --git a/models/Grammars/OracleGrammar.cfc b/models/Grammars/OracleGrammar.cfc index 94e0b3d7..efcef544 100644 --- a/models/Grammars/OracleGrammar.cfc +++ b/models/Grammars/OracleGrammar.cfc @@ -211,25 +211,25 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { var multiple = arguments.values.len() > 1; - var columnsString = arguments.columns - .map( function( column ) { - return wrapColumn( column.formatted ); - } ) - .toList( ", " ); - - var placeholderString = values - .map( function( valueArray ) { - return "INTO #wrapTable( query.getTableName() )# (#columnsString#) VALUES (" & valueArray - .map( function( item ) { - if ( getUtils().isExpression( item ) ) { - return item.getSQL(); - } else { - return "?"; - } - } ) - .toList( ", " ) & ")"; - } ) - .toList( " " ); + var wrappedColumns = []; + for ( var column in arguments.columns ) { + wrappedColumns.append( wrapColumn( column.formatted ) ); + } + var columnsString = wrappedColumns.toList( ", " ); + + var placeholderRows = []; + for ( var valueArray in arguments.values ) { + var placeholders = []; + for ( var item in valueArray ) { + placeholders.append( getUtils().isExpression( item ) ? item.getSQL() : "?" ); + } + placeholderRows.append( + "INTO #wrapTable( query.getTableName() )# (#columnsString#) VALUES (" & + placeholders.toList( ", " ) & + ")" + ); + } + var placeholderString = placeholderRows.toList( " " ); return trim( "INSERT#multiple ? " ALL" : ""# #placeholderString##multiple ? " SELECT 1 FROM dual" : ""#" ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { @@ -307,61 +307,57 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.qb.getShouldWrapValues() ); } - var columnsString = arguments.insertColumns - .map( function( column ) { - return wrapColumn( column.formatted ); - } ) - .toList( ", " ); - - var valuesString = arrayToList( - arguments.insertColumns.map( function( column ) { - return wrapColumn( { "type": "simple", "value": "QB_SRC.#column.formatted.value#" } ); - } ), - ", " - ); + var wrappedInsertColumns = []; + var wrappedSourceColumns = []; + for ( var column in arguments.insertColumns ) { + wrappedInsertColumns.append( wrapColumn( column.formatted ) ); + wrappedSourceColumns.append( + wrapColumn( { "type": "simple", "value": "QB_SRC.#column.formatted.value#" } ) + ); + } + var columnsString = wrappedInsertColumns.toList( ", " ); + var valuesString = wrappedSourceColumns.toList( ", " ); var placeholderString = ""; if ( !isNull( arguments.source ) ) { placeholderString = compileSelect( arguments.source ); } else { - placeholderString = arguments.values - .map( function( valueArray ) { - return "SELECT " & valueArray - .map( function( item ) { - if ( getUtils().isExpression( item ) ) { - return item.getSQL(); - } else { - return "?"; - } - } ) - .toList( ", " ) & " FROM dual"; - } ) - .toList( " UNION ALL " ); + var placeholderRows = []; + for ( var valueArray in arguments.values ) { + var placeholders = []; + for ( var item in valueArray ) { + placeholders.append( getUtils().isExpression( item ) ? item.getSQL() : "?" ); + } + placeholderRows.append( "SELECT " & placeholders.toList( ", " ) & " FROM dual" ); + } + placeholderString = placeholderRows.toList( " UNION ALL " ); } var constraintString = compileUpsertTargetConstraint( arguments.target, arguments.matchNulls ); var updateList = ""; if ( isArray( arguments.updates ) ) { - updateList = arguments.updates - .map( function( column ) { - return "#wrapColumn( column.formatted )# = #wrapColumn( { "type": "simple", "value": "qb_src.#column.formatted.value#" } )#"; - } ) - .toList( ", " ); + var updateAssignments = []; + for ( var column in arguments.updates ) { + updateAssignments.append( + "#wrapColumn( column.formatted )# = #wrapColumn( { "type": "simple", "value": "qb_src.#column.formatted.value#" } )#" + ); + } + updateList = updateAssignments.toList( ", " ); } else { - updateList = arguments.updateColumns - .map( function( column ) { - var equalsClause = "?"; - if ( - !isNull( updates[ column.original ] ) && getUtils().isExpression( - updates[ column.original ] - ) - ) { - equalsClause = updates[ column.original ].getSQL(); - } - return "#wrapColumn( column.formatted )# = #equalsClause#"; - } ) - .toList( ", " ); + var updateAssignments = []; + for ( var column in arguments.updateColumns ) { + var equalsClause = "?"; + if ( + !isNull( arguments.updates[ column.original ] ) && getUtils().isExpression( + arguments.updates[ column.original ] + ) + ) { + equalsClause = arguments.updates[ column.original ].getSQL(); + } + updateAssignments.append( "#wrapColumn( column.formatted )# = #equalsClause#" ); + } + updateList = updateAssignments.toList( ", " ); } var updateStatement = updateList == "" ? "" : " WHEN MATCHED THEN UPDATE SET #updateList#"; @@ -833,18 +829,16 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { function indexForeign( index ) { // FOREIGN KEY ("country_id") REFERENCES countries ("id") ON DELETE CASCADE - var keys = arguments.index - .getForeignKey() - .map( function( key ) { - return wrapColumn( { "type": "simple", "value": key } ); - } ) - .toList( ", " ); - var references = arguments.index - .getColumns() - .map( function( column ) { - return wrapColumn( { "type": "simple", "value": column } ); - } ) - .toList( ", " ); + var wrappedKeys = []; + for ( var key in arguments.index.getForeignKey() ) { + wrappedKeys.append( wrapColumn( { "type": "simple", "value": key } ) ); + } + var keys = wrappedKeys.toList( ", " ); + var wrappedReferences = []; + for ( var column in arguments.index.getColumns() ) { + wrappedReferences.append( wrapColumn( { "type": "simple", "value": column } ) ); + } + var references = wrappedReferences.toList( ", " ); return arrayToList( [ "CONSTRAINT #wrapValue( arguments.index.getName() )#", diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index bbf1fd96..0b1f0ff4 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -71,7 +71,8 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { if ( scalarExtraction && pathLength == 0 ) { return sql & chr( 35 ) & ">>'{}'"; } - arguments.jsonPath.path.each( function( segment, index ) { + for ( var index = 1; index <= arguments.jsonPath.path.len(); index++ ) { + var segment = arguments.jsonPath.path[ index ]; var operator = scalarExtraction && index == pathLength ? "->>" : "->"; var pathSegment = getUtils().isActuallyNumeric( segment ) ? segment : "'" & replace( segment, @@ -80,7 +81,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { "all" ) & "'"; sql &= operator & pathSegment; - } ); + } return sql; } @@ -140,10 +141,11 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - var returningColumns = arguments.query - .getReturning() - .map( wrapColumn ) - .toList( ", " ); + var wrappedReturningColumns = []; + for ( var column in arguments.query.getReturning() ) { + wrappedReturningColumns.append( wrapColumn( column ) ); + } + var returningColumns = wrappedReturningColumns.toList( ", " ); var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; return super.compileInsert( argumentCollection = arguments ) & returningClause; } finally { @@ -169,10 +171,11 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { required array target, required array values ) { - var returningColumns = arguments.qb - .getReturning() - .map( wrapColumn ) - .toList( ", " ); + var wrappedReturningColumns = []; + for ( var column in arguments.qb.getReturning() ) { + wrappedReturningColumns.append( wrapColumn( column ) ); + } + var returningColumns = wrappedReturningColumns.toList( ", " ); var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; return super.compileInsert( arguments.qb, arguments.columns, arguments.values ) & " ON CONFLICT DO NOTHING" & returningClause; } @@ -202,18 +205,18 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - var updateList = columns - .map( function( column ) { - var value = updateMap[ column.original ]; - var assignment = "?"; - if ( utils.isExpression( value ) ) { - assignment = value.getSql(); - } else if ( utils.isBuilder( value ) ) { - assignment = "(#value.toSQL()#)"; - } - return "#wrapColumn( column.formatted )# = #assignment#"; - } ) - .toList( ", " ); + var updateAssignments = []; + for ( var column in arguments.columns ) { + var value = arguments.updateMap[ column.original ]; + var assignment = "?"; + if ( utils.isExpression( value ) ) { + assignment = value.getSql(); + } else if ( utils.isBuilder( value ) ) { + assignment = "(#value.toSQL()#)"; + } + updateAssignments.append( "#wrapColumn( column.formatted )# = #assignment#" ); + } + var updateList = updateAssignments.toList( ", " ); var updateStatement = "UPDATE #wrapQueryTable( query )# SET #updateList#"; @@ -225,10 +228,11 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { updateStatement = trim( "#updateStatement# #compileLimitValue( query, query.getLimitValue() )#" ); - var returningColumns = arguments.query - .getReturning() - .map( wrapColumn ) - .toList( ", " ); + var wrappedReturningColumns = []; + for ( var column in arguments.query.getReturning() ) { + wrappedReturningColumns.append( wrapColumn( column ) ); + } + var returningColumns = wrappedReturningColumns.toList( ", " ); var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; if ( joins.isEmpty() ) { @@ -237,19 +241,15 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { ); } - var updateQuery = arguments.query; - var joinedTables = joins - .map( function( join ) { - return wrapTable( join.getTable() ); - } ) - .toList( ", " ); - var predicates = joins - .map( function( join ) { - return trim( removeLeadingFilterKeyword( compileWheres( updateQuery, join.getWheres() ) ) ); - } ) - .filter( function( predicate ) { - return predicate != ""; - } ); + var joinedTables = []; + var predicates = []; + for ( var join in joins ) { + joinedTables.append( wrapTable( join.getTable() ) ); + var predicate = trim( removeLeadingFilterKeyword( compileWheres( arguments.query, join.getWheres() ) ) ); + if ( predicate != "" ) { + predicates.append( predicate ); + } + } var queryPredicate = trim( removeLeadingFilterKeyword( compileWheres( arguments.query, query.getWheres() ) ) ); @@ -257,7 +257,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { predicates.append( queryPredicate ); } - updateStatement &= " FROM #joinedTables#"; + updateStatement &= " FROM #joinedTables.toList( ", " )#"; if ( !predicates.isEmpty() ) { updateStatement &= " WHERE #predicates.toList( " AND " )#"; } @@ -317,10 +317,11 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - var returningColumns = arguments.query - .getReturning() - .map( wrapColumn ) - .toList( ", " ); + var wrappedReturningColumns = []; + for ( var column in arguments.query.getReturning() ) { + wrappedReturningColumns.append( wrapColumn( column ) ); + } + var returningColumns = wrappedReturningColumns.toList( ", " ); var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; return trim( compileCommonTables( query, query.getCommonTables() ) & " DELETE FROM #wrapQueryTable( query )# #compileWheres( query, query.getWheres() )##returningClause#" @@ -366,37 +367,40 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { ) : this.compileInsertUsing( arguments.qb, arguments.insertColumns, arguments.source ); var updateString = ""; if ( isArray( arguments.updates ) ) { - updateString = arguments.updateColumns - .map( function( column ) { - return "#wrapColumn( column.formatted )# = EXCLUDED.#wrapColumn( column.formatted )#"; - } ) - .toList( ", " ); + var updateAssignments = []; + for ( var column in arguments.updateColumns ) { + updateAssignments.append( + "#wrapColumn( column.formatted )# = EXCLUDED.#wrapColumn( column.formatted )#" + ); + } + updateString = updateAssignments.toList( ", " ); } else { - updateString = arguments.updateColumns - .map( function( column ) { - var equalsClause = "?"; - if ( - !isNull( updates[ column.original ] ) && getUtils().isExpression( - updates[ column.original ] - ) - ) { - equalsClause = updates[ column.original ].getSQL(); - } - return "#wrapColumn( column.formatted )# = #equalsClause#"; - } ) - .toList( ", " ); - } - - var constraintString = arguments.target - .map( function( column ) { - return wrapColumn( column.formatted ); - } ) - .toList( ", " ); - - var returningColumns = arguments.qb - .getReturning() - .map( wrapColumn ) - .toList( ", " ); + var updateAssignments = []; + for ( var column in arguments.updateColumns ) { + var equalsClause = "?"; + if ( + !isNull( arguments.updates[ column.original ] ) && getUtils().isExpression( + arguments.updates[ column.original ] + ) + ) { + equalsClause = arguments.updates[ column.original ].getSQL(); + } + updateAssignments.append( "#wrapColumn( column.formatted )# = #equalsClause#" ); + } + updateString = updateAssignments.toList( ", " ); + } + + var wrappedTargetColumns = []; + for ( var column in arguments.target ) { + wrappedTargetColumns.append( wrapColumn( column.formatted ) ); + } + var constraintString = wrappedTargetColumns.toList( ", " ); + + var wrappedReturningColumns = []; + for ( var column in arguments.qb.getReturning() ) { + wrappedReturningColumns.append( wrapColumn( column ) ); + } + var returningColumns = wrappedReturningColumns.toList( ", " ); var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; return insertString & " ON CONFLICT (#constraintString#) DO UPDATE SET #updateString##returningClause#"; @@ -651,15 +655,14 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } var tables = getAllTableNames( options, schema ); - var tableList = arrayToList( - arrayMap( tables, function( table ) { - return wrapTable( table ); - } ), - ", " - ); - return arrayFilter( [ arrayIsEmpty( tables ) ? "" : "DROP TABLE #tableList# CASCADE" ], function( sql ) { - return sql != ""; - } ); + var wrappedTables = []; + for ( var table in tables ) { + wrappedTables.append( wrapTable( table ) ); + } + if ( arrayIsEmpty( tables ) ) { + return []; + } + return [ "DROP TABLE #wrappedTables.toList( ", " )# CASCADE" ]; } finally { if ( !isNull( arguments.sb.getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); @@ -892,12 +895,11 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } function indexUnique( index ) { - var references = arguments.index - .getColumns() - .map( function( column ) { - return wrapColumn( { "type": "simple", "value": column } ); - } ) - .toList( ", " ); + var wrappedReferences = []; + for ( var column in arguments.index.getColumns() ) { + wrappedReferences.append( wrapColumn( { "type": "simple", "value": column } ) ); + } + var references = wrappedReferences.toList( ", " ); return "CONSTRAINT #wrapValue( index.getName() )# UNIQUE (#references#)"; } @@ -910,9 +912,10 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ); } - var values = arrayMap( commandParameters.values, function( val ) { - return quoteStringLiteral( val ); - } ); + var values = []; + for ( var val in commandParameters.values ) { + values.append( quoteStringLiteral( val ) ); + } var typeName = qualifyObjectNameForTable( blueprint.getTable(), commandParameters.name ); return "CREATE TYPE #wrapTable( typeName )# AS ENUM (#arrayToList( values, ", " )#)"; } finally { diff --git a/models/Grammars/SQLiteGrammar.cfc b/models/Grammars/SQLiteGrammar.cfc index d8d45be7..7fc8e3e2 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -123,10 +123,11 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - var returningColumns = arguments.query - .getReturning() - .map( wrapColumn ) - .toList( ", " ); + var wrappedReturningColumns = []; + for ( var column in arguments.query.getReturning() ) { + wrappedReturningColumns.append( wrapColumn( column ) ); + } + var returningColumns = wrappedReturningColumns.toList( ", " ); var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; return super.compileInsert( argumentCollection = arguments ) & returningClause; } finally { @@ -179,18 +180,18 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - var updateList = columns - .map( function( column ) { - var value = updateMap[ column.original ]; - var assignment = "?"; - if ( utils.isExpression( value ) ) { - assignment = value.getSql(); - } else if ( utils.isBuilder( value ) ) { - assignment = "(#value.toSQL()#)"; - } - return "#wrapColumn( column.formatted )# = #assignment#"; - } ) - .toList( ", " ); + var updateAssignments = []; + for ( var column in arguments.columns ) { + var value = arguments.updateMap[ column.original ]; + var assignment = "?"; + if ( utils.isExpression( value ) ) { + assignment = value.getSql(); + } else if ( utils.isBuilder( value ) ) { + assignment = "(#value.toSQL()#)"; + } + updateAssignments.append( "#wrapColumn( column.formatted )# = #assignment#" ); + } + var updateList = updateAssignments.toList( ", " ); var updateStatement = "UPDATE #wrapQueryTable( query )# SET #updateList#"; @@ -200,41 +201,39 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { updateStatement = trim( "#updateStatement# #compileWheres( query, query.getWheres() )#" ); } - var returningColumns = arguments.query - .getReturning() - .map( wrapColumn ) - .toList( ", " ); + var wrappedReturningColumns = []; + for ( var column in arguments.query.getReturning() ) { + wrappedReturningColumns.append( wrapColumn( column ) ); + } + var returningColumns = wrappedReturningColumns.toList( ", " ); var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; var rowLimitClause = trim( "#compileLimitValue( query, query.getLimitValue() )# #compileOffsetValue( query, query.getOffsetValue() )#" ); - var trailingClauses = arrayMap( [ returningClause, compileOrders( query, query.getOrders() ), rowLimitClause ], function( clause ) { - return trim( clause ); - } ); - trailingClauses = arrayFilter( trailingClauses, function( clause ) { - return clause != ""; - } ); - trailingClauses = arrayToList( trailingClauses, " " ); + var trailingClauses = []; + for ( var clause in [ returningClause, compileOrders( query, query.getOrders() ), rowLimitClause ] ) { + clause = trim( clause ); + if ( clause != "" ) { + trailingClauses.append( clause ); + } + } + var trailingClauseSql = arrayToList( trailingClauses, " " ); if ( joins.isEmpty() ) { return trim( - compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & " " & trailingClauses + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & " " & trailingClauseSql ); } - var updateQuery = arguments.query; - var joinedTables = joins - .map( function( join ) { - return wrapTable( join.getTable() ); - } ) - .toList( ", " ); - var predicates = joins - .map( function( join ) { - return trim( removeLeadingFilterKeyword( compileWheres( updateQuery, join.getWheres() ) ) ); - } ) - .filter( function( predicate ) { - return predicate != ""; - } ); + var joinedTables = []; + var predicates = []; + for ( var join in joins ) { + joinedTables.append( wrapTable( join.getTable() ) ); + var predicate = trim( removeLeadingFilterKeyword( compileWheres( arguments.query, join.getWheres() ) ) ); + if ( predicate != "" ) { + predicates.append( predicate ); + } + } var queryPredicate = trim( removeLeadingFilterKeyword( compileWheres( arguments.query, query.getWheres() ) ) ); @@ -242,13 +241,13 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { predicates.append( queryPredicate ); } - updateStatement &= " FROM #joinedTables#"; + updateStatement &= " FROM #joinedTables.toList( ", " )#"; if ( !predicates.isEmpty() ) { updateStatement &= " WHERE #predicates.toList( " AND " )#"; } return trim( - compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & " " & trailingClauses + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & " " & trailingClauseSql ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { @@ -292,10 +291,11 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - var returningColumns = arguments.query - .getReturning() - .map( wrapColumn ) - .toList( ", " ); + var wrappedReturningColumns = []; + for ( var column in arguments.query.getReturning() ) { + wrappedReturningColumns.append( wrapColumn( column ) ); + } + var returningColumns = wrappedReturningColumns.toList( ", " ); var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; return trim( compileCommonTables( query, query.getCommonTables() ) & " DELETE FROM #wrapQueryTable( query )# #compileWheres( query, query.getWheres() )##returningClause# #compileOrders( query, query.getOrders() )# #compileLimitValue( query, query.getLimitValue() )# #compileOffsetValue( query, query.getOffsetValue() )#" @@ -341,37 +341,40 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { ) : this.compileInsertUsing( arguments.qb, arguments.insertColumns, arguments.source ); var updateString = ""; if ( isArray( arguments.updates ) ) { - updateString = arguments.updateColumns - .map( function( column ) { - return "#wrapColumn( column.formatted )# = EXCLUDED.#wrapColumn( column.formatted )#"; - } ) - .toList( ", " ); + var updateAssignments = []; + for ( var column in arguments.updateColumns ) { + updateAssignments.append( + "#wrapColumn( column.formatted )# = EXCLUDED.#wrapColumn( column.formatted )#" + ); + } + updateString = updateAssignments.toList( ", " ); } else { - updateString = arguments.updateColumns - .map( function( column ) { - var equalsClause = "?"; - if ( - !isNull( updates[ column.original ] ) && getUtils().isExpression( - updates[ column.original ] - ) - ) { - equalsClause = updates[ column.original ].getSQL(); - } - return "#wrapColumn( column.formatted )# = #equalsClause#"; - } ) - .toList( ", " ); - } - - var constraintString = arguments.target - .map( function( column ) { - return wrapColumn( column.formatted ); - } ) - .toList( ", " ); - - var returningColumns = arguments.qb - .getReturning() - .map( wrapColumn ) - .toList( ", " ); + var updateAssignments = []; + for ( var column in arguments.updateColumns ) { + var equalsClause = "?"; + if ( + !isNull( arguments.updates[ column.original ] ) && getUtils().isExpression( + arguments.updates[ column.original ] + ) + ) { + equalsClause = arguments.updates[ column.original ].getSQL(); + } + updateAssignments.append( "#wrapColumn( column.formatted )# = #equalsClause#" ); + } + updateString = updateAssignments.toList( ", " ); + } + + var wrappedTargetColumns = []; + for ( var column in arguments.target ) { + wrappedTargetColumns.append( wrapColumn( column.formatted ) ); + } + var constraintString = wrappedTargetColumns.toList( ", " ); + + var wrappedReturningColumns = []; + for ( var column in arguments.qb.getReturning() ) { + wrappedReturningColumns.append( wrapColumn( column ) ); + } + var returningColumns = wrappedReturningColumns.toList( ", " ); var returningClause = returningColumns != "" ? " RETURNING #returningColumns#" : ""; return insertString & " ON CONFLICT (#constraintString#) DO UPDATE SET #updateString##returningClause#"; @@ -512,13 +515,13 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { function generateAutoIncrement( column, blueprint ) { // SQLite does not allow the primary key defined as a constraint when using autoincrement if ( column.getAutoIncrement() ) { - blueprint.setIndexes( - blueprint - .getIndexes() - .filter( function( index ) { - return index.getType() != "primary"; - } ) - ); + var nonPrimaryIndexes = []; + for ( var index in blueprint.getIndexes() ) { + if ( index.getType() != "primary" ) { + nonPrimaryIndexes.append( index ); + } + } + blueprint.setIndexes( nonPrimaryIndexes ); } return column.getAutoIncrement() ? "PRIMARY KEY AUTOINCREMENT" : ""; } @@ -526,12 +529,11 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { function generateUniqueConstraint( column, blueprint ) { // SQLite does not have an enum type so we add an CHECK constraint to enforce specific values if ( column.getType() == "enum" ) { - var values = column - .getValues() - .map( function( value ) { - return quoteStringLiteral( value ); - } ) - .toList( ", " ); + var quotedValues = []; + for ( var value in column.getValues() ) { + quotedValues.append( quoteStringLiteral( value ) ); + } + var values = quotedValues.toList( ", " ); return "CHECK (#wrapColumn( { "type": "simple", "value": column.getName() } )# IN (#values#))"; } @@ -752,12 +754,11 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { ===================================*/ function indexUnique( index, tableName, isAlter = false ) { - var references = arguments.index - .getColumns() - .map( function( column ) { - return wrapColumn( { "type": "simple", "value": column } ); - } ) - .toList( ", " ); + var wrappedReferences = []; + for ( var column in arguments.index.getColumns() ) { + wrappedReferences.append( wrapColumn( { "type": "simple", "value": column } ) ); + } + var references = wrappedReferences.toList( ", " ); if ( isAlter ) { return "CREATE UNIQUE INDEX #wrapValue( arguments.index.getName() )# ON #wrapTable( tableName )#(#references#)"; @@ -767,29 +768,26 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { } function indexPrimary( index ) { - var references = arguments.index - .getColumns() - .map( function( column ) { - return wrapColumn( { "type": "simple", "value": column } ); - } ) - .toList( ", " ); + var wrappedReferences = []; + for ( var column in arguments.index.getColumns() ) { + wrappedReferences.append( wrapColumn( { "type": "simple", "value": column } ) ); + } + var references = wrappedReferences.toList( ", " ); return "PRIMARY KEY (#references#)"; } function indexForeign( index ) { // FOREIGN KEY ("country_id") REFERENCES countries ("id") ON DELETE CASCADE - var keys = arguments.index - .getForeignKey() - .map( function( key ) { - return wrapColumn( { "type": "simple", "value": key } ); - } ) - .toList( ", " ); - var references = arguments.index - .getColumns() - .map( function( column ) { - return wrapColumn( { "type": "simple", "value": column } ); - } ) - .toList( ", " ); + var wrappedKeys = []; + for ( var key in arguments.index.getForeignKey() ) { + wrappedKeys.append( wrapColumn( { "type": "simple", "value": key } ) ); + } + var keys = wrappedKeys.toList( ", " ); + var wrappedReferences = []; + for ( var column in arguments.index.getColumns() ) { + wrappedReferences.append( wrapColumn( { "type": "simple", "value": column } ) ); + } + var references = wrappedReferences.toList( ", " ); return arrayToList( [ "FOREIGN KEY (#keys#)", diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index 99a38451..5861a5f5 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -9,30 +9,32 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { required array columns, required struct sqlTypes ) { - var grammar = this; var normalizedColumns = arguments.columns; - var serializedValues = arguments.values.map( function( row ) { + var serializedValues = []; + for ( var row in arguments.values ) { var serializedRow = {}; - normalizedColumns.each( function( column ) { + for ( var column in normalizedColumns ) { if ( !row.keyExists( column.original ) || isNull( row[ column.original ] ) ) { serializedRow[ column.original ] = javacast( "null", "" ); - return; + continue; } - var binding = getUtils().extractBinding( row[ column.original ], grammar ); + var binding = getUtils().extractBinding( row[ column.original ], this ); serializedRow[ column.original ] = binding.null ? javacast( "null", "" ) : binding.value; - } ); - return serializedRow; - } ); - - var bulkValues = arguments.values; - var explicitSqlTypes = arguments.sqlTypes; - normalizedColumns.each( function( column ) { - var columnValues = bulkValues.map( function( row ) { - return row.keyExists( column.original ) ? row[ column.original ] : javacast( "null", "" ); - } ); - var sqlType = explicitSqlTypes.keyExists( column.original ) - ? explicitSqlTypes[ column.original ] - : resolveWhereInBulkSqlType( getUtils().inferSqlType( columnValues, grammar ) ); + } + serializedValues.append( serializedRow ); + } + + for ( var column in normalizedColumns ) { + var columnValues = []; + arrayResize( columnValues, arguments.values.len() ); + for ( var i = 1; i <= arguments.values.len(); i++ ) { + columnValues[ i ] = arguments.values[ i ].keyExists( column.original ) + ? arguments.values[ i ][ column.original ] + : javacast( "null", "" ); + } + var sqlType = arguments.sqlTypes.keyExists( column.original ) + ? arguments.sqlTypes[ column.original ] + : resolveWhereInBulkSqlType( getUtils().inferSqlType( columnValues, this ) ); sqlType = trim( sqlType ); if ( sqlType == "" || @@ -44,13 +46,13 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { throw( type = "InvalidSQLType", message = "Invalid SQL type [#sqlType#] for a bulk insert." ); } column.bulkSqlType = sqlType; - } ); + } return { "columns": normalizedColumns, "binding": getUtils().extractBinding( { value: serializeJSON( serializedValues ), cfsqltype: "LONGVARCHAR" }, - grammar + this ) }; } @@ -62,32 +64,27 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - var columnsString = arguments.columns.map( ( column ) => wrapColumn( column.formatted ) ).toList( ", " ); - var returningColumns = arguments.query - .getReturning() - .map( function( column ) { - if ( column.type == "raw" ) { - return trim( column.value.getSQL() ); - } - if ( listLen( column.value, "." ) > 1 ) { - return column.value; - } - return "INSERTED." & wrapColumn( column ); - } ) - .toList( ", " ); + var wrappedColumns = []; + for ( var column in arguments.columns ) { + wrappedColumns.append( wrapColumn( column.formatted ) ); + } + var columnsString = wrappedColumns.toList( ", " ); + var returningColumns = compileOutputColumns( arguments.query.getReturning(), "INSERTED." ); var returningClause = returningColumns != "" ? " OUTPUT #returningColumns#" : ""; - var withColumns = arguments.columns - .map( function( column ) { - var escapedPath = replace( - replace( column.original, "\", "\\", "all" ), - """", - "\""", - "all" - ); - escapedPath = replace( escapedPath, "'", "''", "all" ); - return "#wrapColumn( column.formatted )# #column.bulkSqlType# '$.""#escapedPath#""'"; - } ) - .toList( ", " ); + var withColumnDefinitions = []; + for ( var column in arguments.columns ) { + var escapedPath = replace( + replace( column.original, "\", "\\", "all" ), + """", + "\""", + "all" + ); + escapedPath = replace( escapedPath, "'", "''", "all" ); + withColumnDefinitions.append( + "#wrapColumn( column.formatted )# #column.bulkSqlType# '$.""#escapedPath#""'" + ); + } + var withColumns = withColumnDefinitions.toList( ", " ); return "INSERT INTO #wrapTable( query.getTableName() )# (#columnsString#)#returningClause# SELECT #columnsString# FROM OPENJSON(?) WITH (#withColumns#)"; } finally { @@ -122,6 +119,23 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } } + /** + * Compiles SQL Server OUTPUT columns without allocating a formatter closure. + */ + private string function compileOutputColumns( required array columns, required string prefix ) { + var outputColumns = []; + for ( var column in arguments.columns ) { + if ( column.type == "raw" ) { + outputColumns.append( trim( column.value.getSQL() ) ); + } else if ( listLen( column.value, "." ) > 1 ) { + outputColumns.append( column.value ); + } else { + outputColumns.append( arguments.prefix & wrapColumn( column ) ); + } + } + return outputColumns.toList( ", " ); + } + public string function compileJsonScalar( required struct jsonPath ) { return "JSON_VALUE(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#')"; } @@ -175,7 +189,8 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { "SELECT * FROM (#super.compileSelect( rootQuery )#) AS #wrapValue( "qb_union_0" )#" ]; - unions.each( function( union, index ) { + for ( var index = 1; index <= unions.len(); index++ ) { + var union = unions[ index ]; if ( union.query.getOrders().len() && !isLimitedQuery( union.query ) ) { throw( type = "OrderByNotAllowed", @@ -188,7 +203,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { sql.append( "#unionOperator# SELECT * FROM (#compileSelect( union.query )#) AS #wrapValue( "qb_union_#index#" )#" ); - } ); + } return trim( concatenate( sql ) ); } finally { @@ -203,7 +218,12 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { return false; } - return arguments.query.getUnions().some( ( union ) => union.query.getOrders().len() ); + for ( var union in arguments.query.getUnions() ) { + if ( union.query.getOrders().len() ) { + return true; + } + } + return false; } public array function getSelectBindingOrder( required QueryBuilder query ) { @@ -314,39 +334,24 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - var columnsString = arguments.columns - .map( function( column ) { - return wrapColumn( column.formatted ); - } ) - .toList( ", " ); + var wrappedColumns = []; + for ( var column in arguments.columns ) { + wrappedColumns.append( wrapColumn( column.formatted ) ); + } + var columnsString = wrappedColumns.toList( ", " ); - var returningColumns = arguments.query - .getReturning() - .map( function( column ) { - if ( column.type == "raw" ) { - return trim( column.value.getSQL() ); - } - if ( listLen( column.value, "." ) > 1 ) { - return column.value; - } - return "INSERTED." & wrapColumn( column ); - } ) - .toList( ", " ); + var returningColumns = compileOutputColumns( arguments.query.getReturning(), "INSERTED." ); var returningClause = returningColumns != "" ? " OUTPUT #returningColumns#" : ""; - var placeholderString = values - .map( function( valueArray ) { - return "(" & valueArray - .map( function( item ) { - if ( getUtils().isExpression( item ) ) { - return item.getSQL(); - } else { - return "?"; - } - } ) - .toList( ", " ) & ")"; - } ) - .toList( ", " ); + var placeholderRows = []; + for ( var valueArray in arguments.values ) { + var placeholders = []; + for ( var item in valueArray ) { + placeholders.append( getUtils().isExpression( item ) ? item.getSQL() : "?" ); + } + placeholderRows.append( "(" & placeholders.toList( ", " ) & ")" ); + } + var placeholderString = placeholderRows.toList( ", " ); return trim( "INSERT INTO #wrapTable( query.getTableName() )# (#columnsString#)#returningClause# VALUES #placeholderString#" ); @@ -425,7 +430,11 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { if ( !isNull( query.getLimitValue() ) && isNull( query.getOffsetValue() ) ) { select &= "TOP (#query.getLimitValue()#) "; } - return select & columns.map( wrapColumn ).toList( ", " ); + var wrappedColumns = []; + for ( var column in arguments.columns ) { + wrappedColumns.append( wrapColumn( column ) ); + } + return select & wrappedColumns.toList( ", " ); } /** @@ -470,17 +479,18 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { return "ORDER BY 1"; } - var orderBys = orders.map( function( orderBy ) { + var orderBys = []; + for ( var orderBy in arguments.orders ) { if ( orderBy.direction == "raw" ) { - return orderBy.column.getSQL(); + orderBys.append( orderBy.column.getSQL() ); } else if ( orderBy.direction == "random" ) { - return orderByRandom(); + orderBys.append( orderByRandom() ); } else if ( orderBy.keyExists( "query" ) ) { - return "(#compileSelect( orderBy.query )#) #uCase( orderBy.direction )#"; + orderBys.append( "(#compileSelect( orderBy.query )#) #uCase( orderBy.direction )#" ); } else { - return "#wrapColumn( orderBy.column )# #uCase( orderBy.direction )#"; + orderBys.append( "#wrapColumn( orderBy.column )# #uCase( orderBy.direction )#" ); } - } ); + } return "ORDER BY #orderBys.toList( ", " )#"; } @@ -565,18 +575,18 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - var updateList = columns - .map( function( column ) { - var value = updateMap[ column.original ]; - var assignment = "?"; - if ( utils.isExpression( value ) ) { - assignment = value.getSql(); - } else if ( utils.isBuilder( value ) ) { - assignment = "(#value.toSQL()#)"; - } - return "#wrapColumn( column.formatted )# = #assignment#"; - } ) - .toList( ", " ); + var updateAssignments = []; + for ( var column in arguments.columns ) { + var value = arguments.updateMap[ column.original ]; + var assignment = "?"; + if ( utils.isExpression( value ) ) { + assignment = value.getSql(); + } else if ( utils.isBuilder( value ) ) { + assignment = "(#value.toSQL()#)"; + } + updateAssignments.append( "#wrapColumn( column.formatted )# = #assignment#" ); + } + var updateList = updateAssignments.toList( ", " ); var updateTable = ""; if ( arguments.query.getAlias() != "" ) { @@ -595,18 +605,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { updateList ] ); - var returningColumns = arguments.query - .getReturning() - .map( function( column ) { - if ( column.type == "raw" ) { - return trim( column.value.getSQL() ); - } - if ( listLen( column.value, "." ) > 1 ) { - return column.value; - } - return "INSERTED." & wrapColumn( column ); - } ) - .toList( ", " ); + var returningColumns = compileOutputColumns( arguments.query.getReturning(), "INSERTED." ); var returningClause = returningColumns != "" ? " OUTPUT #returningColumns#" : ""; if ( arguments.query.getJoins().isEmpty() && arguments.query.getAlias() == "" ) { @@ -654,18 +653,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - var returningColumns = arguments.query - .getReturning() - .map( function( column ) { - if ( column.type == "raw" ) { - return trim( column.value.getSQL() ); - } - if ( listLen( column.value, "." ) > 1 ) { - return column.value; - } - return "DELETED." & wrapColumn( column ); - } ) - .toList( ", " ); + var returningColumns = compileOutputColumns( arguments.query.getReturning(), "DELETED." ); var returningClause = returningColumns != "" ? "OUTPUT #returningColumns#" : ""; var hasJoins = !arguments.query.getJoins().isEmpty(); @@ -676,48 +664,32 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { if ( !hasJoins && !hasAlias ) { return trim( - arrayToList( - arrayFilter( - [ - compileCommonTables( query, query.getCommonTables() ), - "DELETE", - topClause, - "FROM", - wrapQueryTable( query ), - returningClause, - compileWheres( query, query.getWheres() ) - ], - function( sql ) { - return sql != ""; - } - ), - " " - ) + concatenate( [ + compileCommonTables( query, query.getCommonTables() ), + "DELETE", + topClause, + "FROM", + wrapQueryTable( query ), + returningClause, + compileWheres( query, query.getWheres() ) + ] ) ); } return trim( - arrayToList( - arrayFilter( - [ - compileCommonTables( query, query.getCommonTables() ), - "DELETE", - topClause, - hasAlias - ? wrapAlias( getTablePrefix() & query.getAlias() ) - : wrapTable( query.getTableName(), false ), - returningClause, - "FROM", - wrapQueryTable( query ), - hasJoins ? compileJoins( query, query.getJoins() ) : "", - compileWheres( query, query.getWheres() ) - ], - function( sql ) { - return sql != ""; - } - ), - " " - ) + concatenate( [ + compileCommonTables( query, query.getCommonTables() ), + "DELETE", + topClause, + hasAlias + ? wrapAlias( getTablePrefix() & query.getAlias() ) + : wrapTable( query.getTableName(), false ), + returningClause, + "FROM", + wrapQueryTable( query ), + hasJoins ? compileJoins( query, query.getJoins() ) : "", + compileWheres( query, query.getWheres() ) + ] ) ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { @@ -744,28 +716,24 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } var sourceString = ""; - var columnsString = arguments.insertColumns - .map( function( column ) { - return wrapColumn( column.formatted ); - } ) - .toList( ", " ); + var wrappedInsertColumns = []; + for ( var column in arguments.insertColumns ) { + wrappedInsertColumns.append( wrapColumn( column.formatted ) ); + } + var columnsString = wrappedInsertColumns.toList( ", " ); if ( !isNull( arguments.source ) ) { sourceString = "(#compileSelect( arguments.source )#) AS [qb_src]"; } else { - var placeholderString = arguments.values - .map( function( valueArray ) { - return "(" & valueArray - .map( function( item ) { - if ( getUtils().isExpression( item ) ) { - return item.getSQL(); - } else { - return "?"; - } - } ) - .toList( ", " ) & ")"; - } ) - .toList( ", " ); + var placeholderRows = []; + for ( var valueArray in arguments.values ) { + var placeholders = []; + for ( var item in valueArray ) { + placeholders.append( getUtils().isExpression( item ) ? item.getSQL() : "?" ); + } + placeholderRows.append( "(" & placeholders.toList( ", " ) & ")" ); + } + var placeholderString = placeholderRows.toList( ", " ); sourceString = "(VALUES #placeholderString#) AS [qb_src] (#columnsString#)"; } @@ -774,25 +742,27 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { var updateList = ""; if ( isArray( arguments.updates ) ) { - updateList = arguments.updates - .map( function( column ) { - return "#wrapColumn( column.formatted )# = #wrapColumn( { "type": "simple", "value": "qb_src.#column.formatted.value#" } )#"; - } ) - .toList( ", " ); + var updateAssignments = []; + for ( var column in arguments.updates ) { + updateAssignments.append( + "#wrapColumn( column.formatted )# = #wrapColumn( { "type": "simple", "value": "qb_src.#column.formatted.value#" } )#" + ); + } + updateList = updateAssignments.toList( ", " ); } else { - updateList = arguments.updateColumns - .map( function( column ) { - var equalsClause = "?"; - if ( - !isNull( updates[ column.original ] ) && getUtils().isExpression( - updates[ column.original ] - ) - ) { - equalsClause = updates[ column.original ].getSQL(); - } - return "#wrapColumn( column.formatted )# = #equalsClause#"; - } ) - .toList( ", " ); + var updateAssignments = []; + for ( var column in arguments.updateColumns ) { + var equalsClause = "?"; + if ( + !isNull( arguments.updates[ column.original ] ) && getUtils().isExpression( + arguments.updates[ column.original ] + ) + ) { + equalsClause = arguments.updates[ column.original ].getSQL(); + } + updateAssignments.append( "#wrapColumn( column.formatted )# = #equalsClause#" ); + } + updateList = updateAssignments.toList( ", " ); } var updateStatement = updateList == "" ? "" : " WHEN MATCHED THEN UPDATE SET #updateList#"; @@ -811,18 +781,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { deleteStatement = " WHEN NOT MATCHED BY SOURCE #deleteRestrictionsStatement#THEN DELETE"; } - var returningColumns = arguments.qb - .getReturning() - .map( function( column ) { - if ( column.type == "raw" ) { - return trim( column.value.getSQL() ); - } - if ( listLen( column.value, "." ) > 1 ) { - return column.value; - } - return "INSERTED." & wrapColumn( column ); - } ) - .toList( ", " ); + var returningColumns = compileOutputColumns( arguments.qb.getReturning(), "INSERTED." ); var returningClause = returningColumns != "" ? " OUTPUT #returningColumns#" : ""; return trim( @@ -1240,27 +1199,28 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } var tables = getAllTableNames( options, schema ); - var tableList = arrayToList( - arrayMap( tables, function( table ) { - return wrapTable( table ); - } ), - ", " - ); + var wrappedTables = []; + for ( var table in tables ) { + wrappedTables.append( wrapTable( table ) ); + } + var tableList = wrappedTables.toList( ", " ); var foreignKeySchemaFilter = arguments.schema == "" ? "" : " WHERE OBJECT_SCHEMA_NAME(parent_object_id) = #quoteUnicodeStringLiteral( arguments.schema )#"; - return arrayFilter( - [ - "DECLARE @sql NVARCHAR(MAX) = N''; + var statements = [ + "DECLARE @sql NVARCHAR(MAX) = N''; SELECT @sql += 'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(parent_object_id)) + '.' + QUOTENAME(OBJECT_NAME(parent_object_id)) + ' DROP CONSTRAINT ' + QUOTENAME(name) + ';' FROM sys.foreign_keys#foreignKeySchemaFilter#; EXEC sp_executesql @sql;", - arrayIsEmpty( tables ) ? "" : "DROP TABLE #tableList#" - ], - function( sql ) { - return sql != ""; + arrayIsEmpty( tables ) ? "" : "DROP TABLE #tableList#" + ]; + var nonEmptyStatements = []; + for ( var sql in statements ) { + if ( sql != "" ) { + nonEmptyStatements.append( sql ); } - ); + } + return nonEmptyStatements; } finally { if ( !isNull( arguments.sb.getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); diff --git a/models/Query/Formatters/ArrayFormatterFactory.cfc b/models/Query/Formatters/ArrayFormatterFactory.cfc new file mode 100644 index 00000000..115280ab --- /dev/null +++ b/models/Query/Formatters/ArrayFormatterFactory.cfc @@ -0,0 +1,17 @@ +/** + * Creates the array return formatter without nested factory closures. + */ +component { + + public ArrayFormatterFactory function init( any utils = new qb.models.Query.QueryUtils() ) { + variables.utils = arguments.utils; + return this; + } + + public function toFormatter( struct options = {} ) { + return function( q ) { + return variables.utils.queryToArrayOfStructs( arguments.q ); + }; + } + +} diff --git a/models/Query/Formatters/IdentityFormatterFactory.cfc b/models/Query/Formatters/IdentityFormatterFactory.cfc new file mode 100644 index 00000000..fbda39d3 --- /dev/null +++ b/models/Query/Formatters/IdentityFormatterFactory.cfc @@ -0,0 +1,14 @@ +/** + * Creates the identity formatter used by query and none return formats. + */ +component { + + public function toFormatter( struct options = {} ) { + return format; + } + + public any function format( required any q ) { + return arguments.q; + } + +} diff --git a/models/Query/JoinClauseManager.cfc b/models/Query/JoinClauseManager.cfc index 459f0738..4cee6a93 100644 --- a/models/Query/JoinClauseManager.cfc +++ b/models/Query/JoinClauseManager.cfc @@ -257,32 +257,28 @@ component { * Returns whether an equivalent join is already attached. */ private boolean function containsJoin( required QueryBuilder builder, required JoinClause join ) { - return arguments.builder - .getJoins() - .find( function( existingJoin ) { - return existingJoin.isEqualTo( join ); - } ) > 0; + for ( var existingJoin in arguments.builder.getJoins() ) { + if ( existingJoin.isEqualTo( arguments.join ) ) { + return true; + } + } + return false; } /** * Returns bindings in their compiled join order. */ private array function getJoinBindings( required QueryBuilder builder, required JoinClause join ) { - var queryBuilder = arguments.builder; var bindings = []; if ( arguments.join.isJoin() && arguments.builder.getUtils().isExpression( arguments.join.getTable() ) ) { - bindings.append( - arguments.join - .getTable() - .getBindings() - .map( function( binding ) { - return queryBuilder.getUtils().extractBinding( binding, queryBuilder.getGrammar() ); - } ), - true - ); + for ( var binding in arguments.join.getTable().getBindings() ) { + bindings.append( + arguments.builder.getUtils().extractBinding( binding, arguments.builder.getGrammar() ) + ); + } } bindings.append( arguments.join.getBindings(), true ); return bindings; diff --git a/models/Query/JsonQueryClause.cfc b/models/Query/JsonQueryClause.cfc index a9f4062c..4c04f2b6 100644 --- a/models/Query/JsonQueryClause.cfc +++ b/models/Query/JsonQueryClause.cfc @@ -28,6 +28,7 @@ component { } var arrowParts = listToArray( parsedColumn, "->", false, true ); + var normalizedPath = []; if ( arrowParts.len() > 1 ) { if ( !arguments.path.isEmpty() ) { throw( @@ -36,10 +37,15 @@ component { ); } parsedColumn = trim( arrowParts.shift() ); - arguments.path = arrowParts.map( ( segment ) => normalizeJsonPathSegment( segment ) ); + for ( var segment in arrowParts ) { + normalizedPath.append( normalizeJsonPathSegment( segment ) ); + } } else { - arguments.path = arguments.path.map( ( segment ) => segment ); + for ( var segment in arguments.path ) { + normalizedPath.append( segment ); + } } + arguments.path = normalizedPath; var definition = { type: "jsonPath", diff --git a/models/Query/PredicateClause.cfc b/models/Query/PredicateClause.cfc index 86ee294d..f02a652f 100644 --- a/models/Query/PredicateClause.cfc +++ b/models/Query/PredicateClause.cfc @@ -182,9 +182,13 @@ component { } ); if ( !arguments.values.isEmpty() ) { - var serializedValues = extractedBindings.map( function( binding ) { - return binding.null ? javacast( "null", "" ) : binding.value; - } ); + var serializedValues = []; + arrayResize( serializedValues, extractedBindings.len() ); + for ( var i = 1; i <= extractedBindings.len(); i++ ) { + serializedValues[ i ] = extractedBindings[ i ].null + ? javacast( "null", "" ) + : extractedBindings[ i ].value; + } arguments.builder.addBindings( columnBindings, "where" ); arguments.builder.addBindings( [ @@ -212,13 +216,13 @@ component { string combinator = "and" ) { arguments.builder.getQueryValidator().validateCombinator( arguments.combinator ); - var queryBuilder = arguments.builder; - arguments.builder.addBindings( - arguments.whereBindings.map( function( binding ) { - return queryBuilder.getUtils().extractBinding( binding, queryBuilder.getGrammar() ); - } ), - "where" - ); + var extractedWhereBindings = []; + for ( var binding in arguments.whereBindings ) { + extractedWhereBindings.append( + arguments.builder.getUtils().extractBinding( binding, arguments.builder.getGrammar() ) + ); + } + arguments.builder.addBindings( extractedWhereBindings, "where" ); arguments.builder.getWheres().append( { type: "raw", sql: arguments.sql, combinator: arguments.combinator } ); return arguments.builder; } @@ -528,8 +532,16 @@ component { public QueryBuilder function withScoping( required QueryBuilder builder, required function callback ) { var originalWhereCount = arguments.builder.getWheres().len(); arguments.callback(); - if ( arguments.builder.getWheres().len() > originalWhereCount ) { - addNewWheresWithinGroup( arguments.builder, originalWhereCount ); + scopeNewWheres( arguments.builder, originalWhereCount ); + return arguments.builder; + } + + /** + * Groups predicates added after a known where-clause count. + */ + package QueryBuilder function scopeNewWheres( required QueryBuilder builder, required numeric originalWhereCount ) { + if ( arguments.builder.getWheres().len() > arguments.originalWhereCount ) { + addNewWheresWithinGroup( arguments.builder, arguments.originalWhereCount ); } return arguments.builder; } diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 0d8a7703..2bd9dc97 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -337,9 +337,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } setReturnFormatterRegistry( arguments.returnFormatterRegistry ); if ( isNull( arguments.columnFormatter ) ) { - arguments.columnFormatter = function( column ) { - return column; - }; + arguments.columnFormatter = identityColumnFormatter; } setPaginationCollector( arguments.paginationCollector ); setColumnFormatter( arguments.columnFormatter ); @@ -353,9 +351,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J setCollectQueryLog( arguments.collectQueryLog ); if ( isNull( arguments.shouldMaxRowsOverrideToAll ) ) { - arguments.shouldMaxRowsOverrideToAll = function( maxRows ) { - return maxRows <= 0; - }; + arguments.shouldMaxRowsOverrideToAll = defaultShouldMaxRowsOverrideToAll; } setShouldMaxRowsOverrideToAll( arguments.shouldMaxRowsOverrideToAll ); @@ -364,6 +360,14 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return this; } + private any function identityColumnFormatter( required any column ) { + return arguments.column; + } + + private boolean function defaultShouldMaxRowsOverrideToAll( required numeric maxRows ) { + return arguments.maxRows <= 0; + } + /** * Updates operator and combinator validation and invalidates any validator * created with the previous settings. @@ -557,9 +561,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function select( any columns = "*" ) { - var newColumns = normalizeToArray( arguments.columns ) - .map( ( column ) => applyColumnFormatter( column ) ) - .map( ( column ) => mapToColumnType( column ) ); + var newColumns = normalizeColumns( arguments.columns ); if ( newColumns.isEmpty() ) { newColumns = [ { "type": "simple", "value": "*" } ]; @@ -620,6 +622,102 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } } + /** + * Applies the configured formatter and type mapping without collection callbacks. + */ + private array function normalizeColumns( required any columns ) { + var normalizedColumns = []; + for ( var column in normalizeToArray( arguments.columns ) ) { + normalizedColumns.append( mapToColumnType( applyColumnFormatter( column ) ) ); + } + return normalizedColumns; + } + + /** + * Builds raw expressions while assigning bindings only to the first expression. + */ + private array function mapRawExpressions( required array expressions, required array bindings ) { + var rawExpressions = []; + for ( var i = 1; i <= arguments.expressions.len(); i++ ) { + rawExpressions.append( raw( arguments.expressions[ i ], i == 1 ? arguments.bindings : [] ) ); + } + return rawExpressions; + } + + /** + * Builds original/formatted column definitions for data modification statements. + */ + private array function buildColumnDefinitions( required array columns, boolean sort = false ) { + var definitions = []; + for ( var column in arguments.columns ) { + definitions.append( { "original": column, "formatted": listLast( applyColumnFormatter( column ), "." ) } ); + } + if ( arguments.sort ) { + definitions = sortColumnDefinitions( definitions ); + } + return definitions; + } + + /** + * Sorts column definitions without allocating a comparator closure. + */ + private array function sortColumnDefinitions( required array columns ) { + for ( var i = 2; i <= arguments.columns.len(); i++ ) { + var currentColumn = arguments.columns[ i ]; + var position = i - 1; + while ( + position >= 1 && + compareNoCase( currentColumn.formatted, arguments.columns[ position ].formatted ) < 0 + ) { + arguments.columns[ position + 1 ] = arguments.columns[ position ]; + position--; + } + arguments.columns[ position + 1 ] = currentColumn; + } + return arguments.columns; + } + + /** + * Converts formatted column names to grammar-ready typed definitions. + */ + private void function typeColumnDefinitions( required array columns ) { + for ( var column in arguments.columns ) { + column.formatted = mapToColumnType( column.formatted ); + } + } + + /** + * Builds per-row bindings and their flattened execution-order equivalent. + */ + private struct function buildInsertBindingData( required array values, required array columns ) { + var rows = []; + var flattened = []; + for ( var value in arguments.values ) { + var row = []; + for ( var column in arguments.columns ) { + var binding = getUtils().extractBinding( + value.keyExists( column.original ) ? value[ column.original ] : javacast( "null", "" ), + variables.grammar + ); + row.append( binding ); + if ( getUtils().isNotExpression( binding ) ) { + flattened.append( binding ); + } else { + flattened.append( extractExpressionBindings( binding ), true ); + } + } + rows.append( row ); + } + return { "rows": rows, "flattened": flattened }; + } + + private any function qualifyUpsertTargetColumn( required any column ) { + if ( listLen( arguments.column, "." ) > 1 ) { + return arguments.column; + } + return "qb_target.#arguments.column#"; + } + /** * Adds a sub-select to the query. @@ -655,9 +753,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function addSelect( required any columns ) { - var newColumns = normalizeToArray( arguments.columns ) - .map( ( column ) => applyColumnFormatter( column ) ) - .map( ( column ) => mapToColumnType( column ) ); + var newColumns = normalizeColumns( arguments.columns ); var newBindings = extractColumnBindings( newColumns ); var selectedColumns = variables.columns.isEmpty() ? [] : arraySlice( variables.columns, 1 ); @@ -690,9 +786,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function selectRaw( required any expression, array bindings = [] ) { - var expressions = arrayWrap( arguments.expression ); - var rawBindings = arguments.bindings; - return addSelect( expressions.map( ( expression, index ) => raw( expression, index == 1 ? rawBindings : [] ) ) ); + return addSelect( mapRawExpressions( arrayWrap( arguments.expression ), arguments.bindings ) ); } /** @@ -738,9 +832,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function reselectRaw( required any expression, array bindings = [] ) { - var expressions = arrayWrap( arguments.expression ); - var rawBindings = arguments.bindings; - return select( expressions.map( ( expression, index ) => raw( expression, index == 1 ? rawBindings : [] ) ) ); + return select( mapRawExpressions( arrayWrap( arguments.expression ), arguments.bindings ) ); } /********************************************************************************\ @@ -1449,12 +1541,10 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J if ( variables.joins.len() != arguments.otherQB.getJoins().len() ) { return false; } - if ( - variables.joins.some( function( j, index ) { - return ( !j.isEqualTo( otherQB.getJoins()[ index ] ) ); - } ) - ) { - return false; + for ( var i = 1; i <= variables.joins.len(); i++ ) { + if ( !variables.joins[ i ].isEqualTo( arguments.otherQB.getJoins()[ i ] ) ) { + return false; + } } } @@ -1462,15 +1552,13 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J if ( variables.unions.len() != arguments.otherQB.getUnions().len() ) { return false; } - if ( - variables.unions.some( function( u, index ) { - return ( - u[ "ALL" ] != otherQB.getUnions()[ index ][ "ALL" ] || - !u[ "QUERY" ].isEqualTo( otherQB.getUnions()[ index ][ "QUERY" ] ) - ); - } ) - ) { - return false; + for ( var i = 1; i <= variables.unions.len(); i++ ) { + if ( + variables.unions[ i ][ "ALL" ] != arguments.otherQB.getUnions()[ i ][ "ALL" ] || + !variables.unions[ i ][ "QUERY" ].isEqualTo( arguments.otherQB.getUnions()[ i ][ "QUERY" ] ) + ) { + return false; + } } } @@ -1478,17 +1566,22 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J if ( variables.commonTables.len() != arguments.otherQB.getCommonTables().len() ) { return false; } - if ( - variables.commonTables.some( function( cT, index ) { - return ( - !getUtils().arrayCompare( cT[ "COLUMNS" ], otherQB.getCommonTables()[ index ][ "COLUMNS" ] ) || - !getUtils().structCompare( cT[ "NAME" ], otherQB.getCommonTables()[ index ][ "NAME" ] ) || - cT[ "RECURSIVE" ] != otherQB.getCommonTables()[ index ][ "RECURSIVE" ] || - !cT[ "QUERY" ].isEqualTo( otherQB.getCommonTables()[ index ][ "QUERY" ] ) - ); - } ) - ) { - return false; + for ( var i = 1; i <= variables.commonTables.len(); i++ ) { + var commonTable = variables.commonTables[ i ]; + if ( + !getUtils().arrayCompare( + commonTable[ "COLUMNS" ], + arguments.otherQB.getCommonTables()[ i ][ "COLUMNS" ] + ) || + !getUtils().structCompare( + commonTable[ "NAME" ], + arguments.otherQB.getCommonTables()[ i ][ "NAME" ] + ) || + commonTable[ "RECURSIVE" ] != arguments.otherQB.getCommonTables()[ i ][ "RECURSIVE" ] || + !commonTable[ "QUERY" ].isEqualTo( arguments.otherQB.getCommonTables()[ i ][ "QUERY" ] ) + ) { + return false; + } } } @@ -1895,9 +1988,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function groupBy( required groups ) { - var groupBys = normalizeToArray( arguments.groups ) - .map( ( groupBy ) => applyColumnFormatter( groupBy ) ) - .map( ( groupBy ) => mapToColumnType( groupBy ) ); + var groupBys = normalizeColumns( arguments.groups ); var groupBindings = extractColumnBindings( groupBys ); variables.groups.append( groupBys, true ); addBindings( groupBindings, "groupBy" ); @@ -2340,13 +2431,18 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } arguments.input = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.input ); + var cteColumns = []; + for ( var column in arguments.columns ) { + cteColumns.append( mapToColumnType( applyColumnFormatter( column ) ) ); + } + // track the union statement arrayAppend( variables.commonTables, { name: mapToColumnType( arguments.name ), query: arguments.input, - columns: arguments.columns.map( applyColumnFormatter ).map( mapToColumnType ), + columns: cteColumns, recursive: arguments.recursive } ); @@ -2528,25 +2624,20 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J function onFalse, boolean withoutScoping = false ) { - var defaultCallback = function( q ) { - return q; - }; - arguments.onFalse = isNull( arguments.onFalse ) ? defaultCallback : arguments.onFalse; - if ( arguments.withoutScoping ) { if ( arguments.condition ) { arguments.onTrue( this ); - } else { + } else if ( !isNull( arguments.onFalse ) ) { arguments.onFalse( this ); } } else { - withScoping( function() { - if ( condition ) { - onTrue( this ); - } else { - onFalse( this ); - } - } ); + var originalWhereCount = getWheres().len(); + if ( arguments.condition ) { + arguments.onTrue( this ); + } else if ( !isNull( arguments.onFalse ) ) { + arguments.onFalse( this ); + } + getPredicateClause().scopeNewWheres( this, originalWhereCount ); } return this; @@ -2606,42 +2697,14 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J arguments.values = [ arguments.values ]; } - var columns = getGrammar() - .resolveInsertColumnNames( arguments.values ) - .map( function( column ) { - var formatted = listLast( applyColumnFormatter( column ), "." ); - return { "original": column, "formatted": formatted }; - } ); - columns.sort( function( a, b ) { - return compareNoCase( a.formatted, b.formatted ); - } ); - var newBindings = arguments.values.map( function( value ) { - return columns.map( function( column ) { - return getUtils().extractBinding( - value.keyExists( column.original ) ? value[ column.original ] : javacast( "null", "" ), - variables.grammar - ); - } ); - } ); - - var newInsertBindings = []; - newBindings.each( function( bindingsArray ) { - bindingsArray.each( function( binding ) { - if ( getUtils().isNotExpression( binding ) ) { - newInsertBindings.append( binding ); - } else { - newInsertBindings.append( extractExpressionBindings( binding ), true ); - } - } ); - } ); + var columns = buildColumnDefinitions( getGrammar().resolveInsertColumnNames( arguments.values ), true ); + var bindingData = buildInsertBindingData( arguments.values, columns ); + var newBindings = bindingData.rows; + var newInsertBindings = bindingData.flattened; - columns.each( ( c ) => { - c.formatted = mapToColumnType( c.formatted ); - } ); + typeColumnDefinitions( columns ); - var sql = withWrappingContext( function() { - return getGrammar().compileInsert( this, columns, newBindings ); - } ); + var sql = withGrammarWrapping( "compileInsert", { "query": this, "columns": columns, "values": newBindings } ); variables.bindings.insert = newInsertBindings; clearBindings( except = "insert" ); @@ -2707,9 +2770,10 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J if ( getGrammar().supportsBulkInsert() ) { var bulkInsert = getGrammar().prepareBulkInsert( this, batch, arguments.sqlTypes ); addBindings( [ bulkInsert.binding ], "insert" ); - var sql = withWrappingContext( function() { - return getGrammar().compileBulkInsert( this, bulkInsert.columns ); - } ); + var sql = withGrammarWrapping( + "compileBulkInsert", + { "query": this, "columns": bulkInsert.columns } + ); if ( arguments.toSql ) { results.append( sql ); } else { @@ -2768,31 +2832,25 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J clearBindings( except = [ "commonTables" ] ); if ( isNull( arguments.columns ) ) { - arguments.columns = arguments.source - .getColumns() - .map( function( column ) { - return getGrammar().extractAlias( mapToColumnType( column ) ); - } ); + arguments.columns = []; + for ( var column in arguments.source.getColumns() ) { + arguments.columns.append( getGrammar().extractAlias( mapToColumnType( column ) ) ); + } if ( arguments.columns.len() == 1 && arguments.columns[ 1 ] == "*" ) { arguments.columns = []; } } - var formattedColumns = arguments.columns.map( function( column ) { - var formatted = listLast( applyColumnFormatter( column ), "." ); - return { "original": column, "formatted": formatted }; - } ); + var formattedColumns = buildColumnDefinitions( arguments.columns ); addBindingsFromBuilder( arguments.source ); - formattedColumns.each( ( c ) => { - c.formatted = mapToColumnType( c.formatted ); - } ); + typeColumnDefinitions( formattedColumns ); - var sourceQuery = arguments.source; - var sql = withWrappingContext( function() { - return getGrammar().compileInsertUsing( this, formattedColumns, sourceQuery ); - } ); + var sql = withGrammarWrapping( + "compileInsertUsing", + { "query": this, "columns": formattedColumns, "source": arguments.source } + ); } catch ( any e ) { executor.restoreCommonTableState( this, commonTableState ); variables.bindings = originalBindings; @@ -2837,56 +2895,25 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J values = [ values ]; } - var columns = getGrammar() - .resolveInsertColumnNames( arguments.values ) - .map( function( column ) { - var formatted = listLast( applyColumnFormatter( column ), "." ); - return { "original": column, "formatted": formatted }; - } ); - columns.sort( function( a, b ) { - return compareNoCase( a.formatted, b.formatted ); - } ); - var newBindings = arguments.values.map( function( value ) { - return columns.map( function( column ) { - return getUtils().extractBinding( - value.keyExists( column.original ) ? value[ column.original ] : javacast( "null", "" ), - variables.grammar - ); - } ); - } ); + var columns = buildColumnDefinitions( getGrammar().resolveInsertColumnNames( arguments.values ), true ); + var bindingData = buildInsertBindingData( arguments.values, columns ); + var newBindings = bindingData.rows; + var newInsertBindings = bindingData.flattened; - var newInsertBindings = []; - newBindings.each( function( bindingsArray ) { - bindingsArray.each( function( binding ) { - if ( getUtils().isNotExpression( binding ) ) { - newInsertBindings.append( binding ); - } else { - newInsertBindings.append( extractExpressionBindings( binding ), true ); - } - } ); - } ); - - arguments.target = arrayWrap( arguments.target ).map( function( column ) { - var formatted = listLast( applyColumnFormatter( column ), "." ); - return { "original": column, "formatted": formatted }; - } ); - - columns.each( ( c ) => { - c.formatted = mapToColumnType( c.formatted ); - } ); - arguments.target.each( ( c ) => { - c.formatted = mapToColumnType( c.formatted ); - } ); - - var targetColumns = arguments.target; - var sql = withWrappingContext( function() { - return getGrammar().compileInsertIgnore( - this, - columns, - targetColumns, - newBindings - ); - } ); + arguments.target = buildColumnDefinitions( arrayWrap( arguments.target ) ); + + typeColumnDefinitions( columns ); + typeColumnDefinitions( arguments.target ); + + var sql = withGrammarWrapping( + "compileInsertIgnore", + { + "qb": this, + "columns": columns, + "target": arguments.target, + "values": newBindings + } + ); variables.bindings.insert = newInsertBindings; clearBindings( except = "insert" ); @@ -2900,19 +2927,21 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J public QueryBuilder function returning( required any columns ) { var returningColumns = isArray( arguments.columns ) ? arguments.columns : listToArray( arguments.columns ); - returningColumns = returningColumns.map( function( column ) { - return mapToColumnType( listLast( applyColumnFormatter( column ), "." ) ); - } ); - variables.returning = returningColumns; + var formattedReturningColumns = []; + for ( var column in returningColumns ) { + formattedReturningColumns.append( mapToColumnType( listLast( applyColumnFormatter( column ), "." ) ) ); + } + variables.returning = formattedReturningColumns; return this; } public QueryBuilder function returningRaw( required any columns ) { var returningColumns = isArray( arguments.columns ) ? arguments.columns : [ arguments.columns ]; - returningColumns = returningColumns.map( function( column ) { - return mapToColumnType( new Expression( column ) ); - } ); - variables.returning = returningColumns; + var rawReturningColumns = []; + for ( var column in returningColumns ) { + rawReturningColumns.append( mapToColumnType( new Expression( column ) ) ); + } + variables.returning = rawReturningColumns; return this; } @@ -2935,16 +2964,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J public any function update( struct values = {}, struct options = {}, boolean toSql = false ) { arguments.values = structCopy( arguments.values ); structAppend( arguments.values, variables.updates, false ); - var updateArray = arguments.values - .keyArray() - .map( function( column ) { - var formatted = listLast( applyColumnFormatter( column ), "." ); - return { original: column, formatted: formatted }; - } ); - - updateArray.sort( function( a, b ) { - return compareNoCase( a.formatted, b.formatted ); - } ); + var updateArray = buildColumnDefinitions( arguments.values.keyArray(), true ); var newUpdateBindings = []; var executor = getCollaborator( "QueryExecutor" ); @@ -2968,14 +2988,12 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } } - updateArray.each( ( c ) => { - c.formatted = mapToColumnType( c.formatted ); - } ); + typeColumnDefinitions( updateArray ); - var updateValues = arguments.values; - sql = withWrappingContext( function() { - return getGrammar().compileUpdate( this, updateArray, updateValues ); - } ); + sql = withGrammarWrapping( + "compileUpdate", + { "query": this, "columns": updateArray, "updateMap": arguments.values } + ); } catch ( any e ) { executor.restoreCommonTableState( this, commonTableState ); rethrow; @@ -3100,10 +3118,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return this.insert( values = arguments.values, options = arguments.options, toSql = arguments.toSql ); } - arguments.target = arrayWrap( arguments.target ).map( function( column ) { - var formatted = listLast( applyColumnFormatter( column ), "." ); - return { "original": column, "formatted": formatted }; - } ); + arguments.target = buildColumnDefinitions( arrayWrap( arguments.target ) ); var columns = []; if ( isStruct( arguments.values[ 1 ] ) ) { @@ -3111,14 +3126,9 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } else { columns = arguments.values; } - columns = columns.map( function( column ) { - var formatted = listLast( applyColumnFormatter( column ), "." ); - return { "original": column, "formatted": formatted }; - } ); + columns = buildColumnDefinitions( columns ); if ( isStruct( arguments.values[ 1 ] ) ) { - columns.sort( function( a, b ) { - return compareNoCase( a.formatted, b.formatted ); - } ); + columns = sortColumnDefinitions( columns ); } var updateArray = []; @@ -3126,77 +3136,48 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J arguments.update = columns; } else { if ( isArray( arguments.update ) ) { - arguments.update = arguments.update.map( function( column ) { - var formatted = listLast( applyColumnFormatter( column ), "." ); - return { "original": column, "formatted": formatted }; - } ); + arguments.update = buildColumnDefinitions( arguments.update ); } } if ( isArray( arguments.update ) ) { updateArray = arguments.update; } else { - updateArray = arguments.update - .keyArray() - .map( function( column ) { - var formatted = listLast( applyColumnFormatter( column ), "." ); - return { original: column, formatted: formatted }; - } ); + updateArray = buildColumnDefinitions( arguments.update.keyArray() ); } - updateArray.sort( function( a, b ) { - return compareNoCase( a.formatted, b.formatted ); - } ); + updateArray = sortColumnDefinitions( updateArray ); var newInsertBindings = []; if ( isStruct( arguments.values[ 1 ] ) ) { - newInsertBindings = arguments.values.map( function( value ) { - return columns.map( function( column ) { - return getUtils().extractBinding( - value.keyExists( column.original ) ? value[ column.original ] : javacast( "null", "" ), - variables.grammar - ); - } ); - } ); + var bindingData = buildInsertBindingData( arguments.values, columns ); + newInsertBindings = bindingData.rows; + addBindings( bindingData.flattened, "insert" ); } - newInsertBindings.each( function( bindingsArray ) { - bindingsArray.each( function( binding ) { - if ( getUtils().isNotExpression( binding ) ) { - addBindings( binding, "insert" ); - } else { - addExpressionBindings( binding, "insert" ); - } - } ); - } ); - if ( isStruct( arguments.update ) ) { - var updates = arguments.update; - updateArray.each( function( column ) { + for ( var column in updateArray ) { if ( - isNull( updates[ column.original ] ) || - getUtils().isNotExpression( updates[ column.original ] ) + isNull( arguments.update[ column.original ] ) || + getUtils().isNotExpression( arguments.update[ column.original ] ) ) { addBindings( getUtils().extractBinding( - isNull( updates[ column.original ] ) ? javacast( "null", "" ) : updates[ column.original ], + isNull( arguments.update[ column.original ] ) + ? javacast( "null", "" ) + : arguments.update[ column.original ], variables.grammar ), "insert" ); } else { - addExpressionBindings( updates[ column.original ], "insert" ); + addExpressionBindings( arguments.update[ column.original ], "insert" ); } - } ); + } } if ( isClosure( arguments.deleteUnmatched ) || isCustomFunction( arguments.deleteUnmatched ) ) { - var deleteRestrictions = newQuery().setColumnFormatter( ( column ) => { - if ( listLen( column, "." ) > 1 ) { - return column; - } - return "qb_target.#column#"; - } ); + var deleteRestrictions = newQuery().setColumnFormatter( qualifyUpsertTargetColumn ); arguments.deleteUnmatched( deleteRestrictions ); arguments.deleteUnmatched = deleteRestrictions; } @@ -3209,37 +3190,24 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J addBindings( arguments.deleteUnmatched.getBindings(), "insert" ); } - columns.each( ( c ) => { - c.formatted = mapToColumnType( c.formatted ); - } ); - updateArray.each( ( c ) => { - c.formatted = mapToColumnType( c.formatted ); - } ); - arguments.target.each( ( c ) => { - c.formatted = mapToColumnType( c.formatted ); - } ); - - var updateForUpsert = arguments.update; - var targetForUpsert = arguments.target; - var hasSourceForUpsert = !isNull( arguments.source ); - if ( hasSourceForUpsert ) { - var sourceForUpsert = arguments.source; - } - var deleteUnmatchedForUpsert = arguments.deleteUnmatched; - var matchNullsForUpsert = arguments.matchNulls; - var sql = withWrappingContext( function() { - return getGrammar().compileUpsert( - this, - columns, - newInsertBindings, - updateArray, - updateForUpsert, - targetForUpsert, - hasSourceForUpsert ? sourceForUpsert : javacast( "null", "" ), - deleteUnmatchedForUpsert, - matchNullsForUpsert - ); - } ); + typeColumnDefinitions( columns ); + typeColumnDefinitions( updateArray ); + typeColumnDefinitions( arguments.target ); + + var sql = withGrammarWrapping( + "compileUpsert", + { + "qb": this, + "insertColumns": columns, + "values": newInsertBindings, + "updateColumns": updateArray, + "updates": arguments.update, + "target": arguments.target, + "source": isNull( arguments.source ) ? javacast( "null", "" ) : arguments.source, + "deleteUnmatched": arguments.deleteUnmatched, + "matchNulls": arguments.matchNulls + } + ); } catch ( any e ) { executor.restoreCommonTableState( this, commonTableState ); variables.bindings = originalBindings; @@ -3276,9 +3244,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J where( arguments.idColumnName, "=", arguments.id ); } - var sql = withWrappingContext( function() { - return getGrammar().compileDelete( this ); - } ); + var sql = withGrammarWrapping( "compileDelete", { "query": this } ); if ( toSql ) { return sql; @@ -3302,9 +3268,12 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J if ( arguments.order.isEmpty() ) { arguments.order = getGrammar().getSelectBindingOrder( this ); } - var bindingOrder = arrayFilter( arguments.order, function( type ) { - return !arrayContainsNoCase( except, type ); - } ); + var bindingOrder = []; + for ( var type in arguments.order ) { + if ( !arrayContainsNoCase( arguments.except, type ) ) { + bindingOrder.append( type ); + } + } var flatBindings = []; for ( var key in bindingOrder ) { @@ -3581,34 +3550,43 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J boolean toSQL = false, any showBindings = false ) { - return getCollaborator( "QueryExecutor" ).withAggregate( - builder = this, - aggregate = { - type: type, - column: mapToColumnType( applyColumnFormatter( arguments.column ) ), - defaultValue: isNull( arguments.defaultValue ) ? javacast( "null", "" ) : arguments.defaultValue - }, - callback = function() { - return withReturnFormat( "query", function() { - return getCollaborator( "QueryExecutor" ).withColumns( - builder = this, - columns = column, - callback = function() { - if ( toSQL ) { - return this.toSQL( showBindings = showBindings ); - } - - var result = get( options = options ); - if ( result.recordCount <= 0 && !isNull( defaultValue ) ) { - return defaultValue; - } else { - return result.aggregate; - } - } - ); - } ); + var aggregate = { + type: arguments.type, + column: mapToColumnType( applyColumnFormatter( arguments.column ) ), + defaultValue: isNull( arguments.defaultValue ) ? javacast( "null", "" ) : arguments.defaultValue + }; + var originalAggregate = getAggregate(); + var originalOrders = getOrders(); + var originalAggregateBindings = getRawBindings().aggregate; + var originalColumns = [ { "type": "simple", "value": "*" } ]; + var shouldRestoreColumns = getUnions().isEmpty(); + try { + setAggregate( aggregate ); + setOrders( [] ); + getRawBindings().aggregate = []; + addColumnBindings( [ aggregate.column ], "aggregate" ); + if ( shouldRestoreColumns ) { + originalColumns = getColumns(); + select( arguments.column ); } - ); + + if ( arguments.toSQL ) { + return this.toSQL( showBindings = arguments.showBindings ); + } + + var result = getUsingReturnFormat( returnFormat = "query", options = arguments.options ); + if ( result.recordCount <= 0 && !isNull( arguments.defaultValue ) ) { + return arguments.defaultValue; + } + return result.aggregate; + } finally { + if ( shouldRestoreColumns ) { + select( originalColumns ); + } + setAggregate( originalAggregate ); + setOrders( originalOrders ); + getRawBindings().aggregate = originalAggregateBindings; + } } /** @@ -3624,9 +3602,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J .prepareInternalExecutionBuilder( this, newQuery() ) .clearFrom(); getCollaborator( "QueryExecutor" ).hoistNestedCommonTables( existsSource, existsQuery ); - var existsSql = withWrappingContext( function() { - return getGrammar().compileSelect( existsSource ); - } ); + var existsSql = withGrammarWrapping( "compileSelect", { "query": existsSource } ); existsQuery.selectRaw( "CASE WHEN EXISTS (#existsSql#) THEN 1 ELSE 0 END AS aggregate", existsSource.getBindings() @@ -3712,9 +3688,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J */ public any function first( struct options = {} ) { take( 1 ); - var results = withReturnFormat( "array", function() { - return get( options = options ); - } ); + var results = getUsingReturnFormat( returnFormat = "array", options = arguments.options ); if ( arrayIsEmpty( results ) ) { return {}; } @@ -3755,9 +3729,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return any */ public any function last( struct options = {} ) { - var results = withReturnFormat( "array", function() { - return get( options = options ); - } ); + var results = getUsingReturnFormat( returnFormat = "array", options = arguments.options ); if ( arrayIsEmpty( results ) ) { return {}; } @@ -3820,25 +3792,27 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J boolean throwWhenNotFound = false, struct options = {} ) { - return withReturnFormat( "query", function() { - take( 1 ); - var result = get( columns = column, options = options ); - if ( result.recordCount <= 0 ) { - if ( throwWhenNotFound ) { - throw( - type = "RecordCountException", - message = "Expected at least one row to be returned for `value` function." - ); - } else { - return defaultValue; - } + take( 1 ); + var result = getUsingReturnFormat( + returnFormat = "query", + columns = arguments.column, + options = arguments.options + ); + if ( result.recordCount <= 0 ) { + if ( arguments.throwWhenNotFound ) { + throw( + type = "RecordCountException", + message = "Expected at least one row to be returned for `value` function." + ); } else { - var firstColumnName = getFunctionList().keyExists( "queryColumnList" ) ? queryColumnList( result ).listFirst() : getMetadata( - result - )[ 1 ].name - return result[ firstColumnName ][ 1 ]; + return arguments.defaultValue; } - } ); + } else { + var firstColumnName = getFunctionList().keyExists( "queryColumnList" ) ? queryColumnList( result ).listFirst() : getMetadata( + result + )[ 1 ].name + return result[ firstColumnName ][ 1 ]; + } } /** @@ -3870,17 +3844,19 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return [any] */ public array function values( required any column, struct options = {} ) { - return withReturnFormat( "query", function() { - var result = get( columns = column, options = options ); - var columnName = getFunctionList().keyExists( "queryColumnList" ) ? queryColumnList( result ).listFirst() : getMetadata( - result - )[ 1 ].name; - var results = []; - for ( var row in result ) { - results.append( row[ columnName ] ); - } - return results; - } ); + var result = getUsingReturnFormat( + returnFormat = "query", + columns = arguments.column, + options = arguments.options + ); + var columnName = getFunctionList().keyExists( "queryColumnList" ) ? queryColumnList( result ).listFirst() : getMetadata( + result + )[ 1 ].name; + var results = []; + for ( var row in result ) { + results.append( row[ columnName ] ); + } + return results; } /** @@ -4082,11 +4058,11 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J ); } - private any function withWrappingContext( required function callback ) { + private any function withGrammarWrapping( required string compiler, required struct compilerArguments ) { var grammar = getGrammar(); grammar.pushShouldWrapValuesContext( getShouldWrapValues() ); try { - return arguments.callback(); + return invoke( grammar, arguments.compiler, arguments.compilerArguments ); } finally { grammar.popShouldWrapValuesContext(); } @@ -4101,9 +4077,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J if ( getValidateDuplicateSelectColumns() && getAggregate().isEmpty() ) { getCollaborator( "QueryValidator" ).validateUniqueSelectColumns( getColumns(), getGrammar() ); } - var sql = withWrappingContext( function() { - return grammar.compileSelect( this ); - } ); + var sql = withGrammarWrapping( "compileSelect", { "query": this } ); if ( isBoolean( arguments.showBindings ) && arguments.showBindings == false ) { return sql; @@ -4223,6 +4197,23 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J return result; } + /** + * Executes get with a temporary return format without allocating a callback closure. + */ + private any function getUsingReturnFormat( required any returnFormat, any columns, struct options = {} ) { + var originalReturnFormat = getReturnFormat(); + setReturnFormat( arguments.returnFormat ); + var result = javacast( "null", "" ); + try { + result = isNull( arguments.columns ) + ? get( options = arguments.options ) + : get( columns = arguments.columns, options = arguments.options ); + } finally { + variables.returnFormat = originalReturnFormat; + } + return result; + } + /** * Converts the arguments passed in to it into an array. * @@ -4238,9 +4229,11 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } try { - return arrayMap( trim( arguments.listOrArray ).split( ",\s*" ), function( item ) { - return trim( item ); - } ); + var values = []; + for ( var item in trim( arguments.listOrArray ).split( ",\s*" ) ) { + values.append( trim( item ) ); + } + return values; } catch ( any e ) { return [ arguments.listOrArray ]; } diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index f9778394..8aa44f3f 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -53,10 +53,7 @@ component singleton displayname="QueryUtils" accessors="true" { if ( !isNull( arguments.log ) ) { variables.log = arguments.log; } else { - variables.log = { - "debug": function() { - } - }; + variables.log = new qb.models.Support.NullLogger(); } return this; } @@ -516,7 +513,7 @@ component singleton displayname="QueryUtils" accessors="true" { } inferredTypes.append( inferSqlType( item, arguments.grammar ) ); } - return arraySame( inferredTypes, ( sqlType ) => sqlType, "VARCHAR" ); + return arraySame( inferredTypes, "VARCHAR" ); } if ( isStruct( value ) ) { @@ -704,9 +701,9 @@ component singleton displayname="QueryUtils" accessors="true" { if ( isPureBoxLang() ) { queryColumns = arguments.q.getColumnNames(); } else { - queryColumns = getMetadata( arguments.q ).map( function( item ) { - return item.name; - } ); + for ( var item in getMetadata( arguments.q ) ) { + queryColumns.append( item.name ); + } } var results = []; @@ -757,47 +754,42 @@ component singleton displayname="QueryUtils" accessors="true" { */ public query function queryRemoveColumns( required query q, required string columns ) { var columnsToRemove = arguments.columns.listToArray(); - var queryColumnInfo = isPureBoxLang() ? q - .getColumnNames() - .map( ( name ) => { - return { "name": name, "TypeName": "varchar" }; - } ) : getMetadata( q ); + var queryColumnInfo = []; + if ( isPureBoxLang() ) { + for ( var name in q.getColumnNames() ) { + queryColumnInfo.append( { "name": name, "TypeName": "varchar" } ); + } + } else { + queryColumnInfo = getMetadata( q ); + } var queryAsArray = queryToArrayOfStructs( q ); - queryAsArray.each( function( row ) { - columnsToRemove.each( function( col ) { + for ( var row in queryAsArray ) { + for ( var col in columnsToRemove ) { structDelete( row, col ); - } ); - } ); - - var newColumns = queryColumnInfo - .filter( function( column ) { - return !arrayContainsNoCase( columnsToRemove, column.name ); - } ) - .map( function( column ) { - return column.name; - } ); - - var newColumnTypes = newColumns.map( function( col ) { - var foundColumn = queryColumnInfo.filter( function( c ) { - return c.name == col; - } ); - if ( arrayIsEmpty( foundColumn ) ) { - return "varchar"; } - var foundType = lCase( foundColumn[ 1 ].TypeName ); + } + + var newColumns = []; + var newColumnTypes = []; + for ( var column in queryColumnInfo ) { + if ( arrayContainsNoCase( columnsToRemove, column.name ) ) { + continue; + } + newColumns.append( column.name ); + var foundType = lCase( column.TypeName ); switch ( foundType ) { case "number": - return "double"; + newColumnTypes.append( "double" ); + break; case "varchar2": - return "varchar"; case "char": - return "varchar"; case "clob": - return "varchar"; + newColumnTypes.append( "varchar" ); + break; default: - return foundType; + newColumnTypes.append( foundType ); } - } ); + } return queryNew( newColumns.toList(), newColumnTypes.toList(), queryAsArray ); } @@ -818,16 +810,15 @@ component singleton displayname="QueryUtils" accessors="true" { } /** - * Returns the value of the closure if every element in the array returns the same value. + * Returns the first value if every element in the array is the same. * Otherwise, it returns the default value. * * @args The array of elements. - * @closure The closure to execute and retrieve the compared value. * @defaultValue The default value to return if the array does not return all the same values. Default: "". * * @return any */ - private any function arraySame( required array args, required any closure, any defaultValue = "" ) { + private any function arraySame( required array args, any defaultValue = "" ) { if ( arrayLen( arguments.args ) == 0 ) { return arguments.defaultValue; } @@ -835,12 +826,12 @@ component singleton displayname="QueryUtils" accessors="true" { if ( isNull( arguments.args[ 1 ] ) ) { return arguments.defaultValue; } - var initial = closure( arguments.args[ 1 ] ); + var initial = arguments.args[ 1 ]; for ( var i = 1; i <= arguments.args.len(); i++ ) { if ( isNull( arguments.args[ i ] ) || - closure( arguments.args[ i ] ) != initial + arguments.args[ i ] != initial ) { return defaultValue; } @@ -1070,15 +1061,15 @@ component singleton displayname="QueryUtils" accessors="true" { } public string function serializeBindings( required array bindings, required any grammar ) { - return serializeJSON( - arguments.bindings.map( function( binding ) { - var newBinding = extractBinding( binding, grammar ); - if ( isBinary( newBinding.value ) ) { - newBinding.value = toBase64( newBinding.value ); - } - return newBinding; - } ) - ); + var serializedBindings = []; + for ( var binding in arguments.bindings ) { + var newBinding = extractBinding( binding, arguments.grammar ); + if ( isBinary( newBinding.value ) ) { + newBinding.value = toBase64( newBinding.value ); + } + serializedBindings.append( newBinding ); + } + return serializeJSON( serializedBindings ); } private boolean function isFloatingPoint( required struct binding ) { @@ -1119,7 +1110,15 @@ component singleton displayname="QueryUtils" accessors="true" { } private boolean function isPureBoxLang() { - return server.keyExists( "boxlang" ) && !server.boxlang.modules.some( ( moduleName ) => findNoCase( "compat-cfml", moduleName ) > 0 ); + if ( !server.keyExists( "boxlang" ) ) { + return false; + } + for ( var moduleName in server.boxlang.modules ) { + if ( findNoCase( "compat-cfml", moduleName ) > 0 ) { + return false; + } + } + return true; } private void function checkForNonQueryParamStructKeys( required struct param ) { @@ -1135,7 +1134,12 @@ component singleton displayname="QueryUtils" accessors="true" { "scale", "value" ]; - var extraKeys = param.keyArray().filter( ( key ) => !validKeys.containsNoCase( key ) ); + var extraKeys = []; + for ( var key in param.keyArray() ) { + if ( !validKeys.containsNoCase( key ) ) { + extraKeys.append( key ); + } + } if ( !extraKeys.isEmpty() ) { throw( type = "QBInvalidQueryParam", @@ -1161,10 +1165,12 @@ component singleton displayname="QueryUtils" accessors="true" { "scale", "value" ]; - return param - .keyArray() - .filter( ( key ) => !validKeys.containsNoCase( key ) ) - .isEmpty(); + for ( var key in param.keyArray() ) { + if ( !validKeys.containsNoCase( key ) ) { + return false; + } + } + return true; } private boolean function isBoxLang() { diff --git a/models/Query/ReturnFormatterRegistry.cfc b/models/Query/ReturnFormatterRegistry.cfc index 021a4392..44f8beb3 100644 --- a/models/Query/ReturnFormatterRegistry.cfc +++ b/models/Query/ReturnFormatterRegistry.cfc @@ -81,29 +81,11 @@ component accessors="true" singleton { } private void function registerBuiltInReturnFormatters() { - registerReturnFormatter( - name = "query", - factory = function( options ) { - return function( q ) { - return q; - }; - } - ); - registerReturnFormatter( - name = "none", - factory = function( options ) { - return function( q ) { - return q; - }; - } - ); + registerReturnFormatter( name = "query", factory = new qb.models.Query.Formatters.IdentityFormatterFactory() ); + registerReturnFormatter( name = "none", factory = new qb.models.Query.Formatters.IdentityFormatterFactory() ); registerReturnFormatter( name = "array", - factory = function( options ) { - return function( q ) { - return variables.utils.queryToArrayOfStructs( q ); - }; - } + factory = new qb.models.Query.Formatters.ArrayFormatterFactory( variables.utils ) ); registerReturnFormatter( name = "struct", diff --git a/models/SQLCommenter/ColdBoxSQLCommenter.cfc b/models/SQLCommenter/ColdBoxSQLCommenter.cfc index 77cde7c8..81862591 100644 --- a/models/SQLCommenter/ColdBoxSQLCommenter.cfc +++ b/models/SQLCommenter/ColdBoxSQLCommenter.cfc @@ -20,14 +20,17 @@ component extends="SQLCommenter" singleton accessors="true" { * Set up the commenters array with configured Commenter components. */ function onDIComplete() { - variables.commenters = variables.settings.sqlCommenter.commenters.map( ( commenterInfo ) => { + variables.commenters = []; + for ( var commenterInfo in variables.settings.sqlCommenter.commenters ) { param commenterInfo.properties = {}; if ( !commenterInfo.keyExists( "class" ) ) { throw( "A commenter must have a class pointing to a WireBox mapping" ); } - return variables.wirebox.getInstance( commenterInfo.class ).setProperties( commenterInfo.properties ); - } ); + variables.commenters.append( + variables.wirebox.getInstance( commenterInfo.class ).setProperties( commenterInfo.properties ) + ); + } } /** @@ -45,8 +48,9 @@ component extends="SQLCommenter" singleton accessors="true" { return arguments.sql; } - var comments = variables.commenters.reduce( ( acc, commenter ) => { - acc.append( + var comments = {}; + for ( var commenter in variables.commenters ) { + comments.append( commenter.getComments( sql = sql, datasource = isNull( datasource ) ? javacast( "null", "" ) : datasource, @@ -54,8 +58,7 @@ component extends="SQLCommenter" singleton accessors="true" { ), true ); - return acc; - }, {} ); + } return appendCommentsToSQL( arguments.sql, comments ); } diff --git a/models/SQLCommenter/Commenters/BindingsCommenter.cfc b/models/SQLCommenter/Commenters/BindingsCommenter.cfc index 29484628..57a4735f 100644 --- a/models/SQLCommenter/Commenters/BindingsCommenter.cfc +++ b/models/SQLCommenter/Commenters/BindingsCommenter.cfc @@ -16,17 +16,19 @@ component singleton accessors="true" { } private string function serializeBindings( required array bindings, string delimiter = ";" ) { - return serializeJSON( - bindings.map( ( binding ) => { - return limitString( + var serializedBindings = []; + for ( var binding in arguments.bindings ) { + serializedBindings.append( + limitString( str = isSimpleValue( binding ) ? binding : variables.queryUtil.castAsSqlType( value = binding.null ? javacast( "null", "" ) : binding.value, sqltype = binding.cfsqltype ), limit = 100 - ); - } ) - ); + ) + ); + } + return serializeJSON( serializedBindings ); } private string function limitString( required string str, required numeric limit, string end = "..." ) { diff --git a/models/SQLCommenter/SQLCommenter.cfc b/models/SQLCommenter/SQLCommenter.cfc index 27af1eb4..1f6f5e34 100644 --- a/models/SQLCommenter/SQLCommenter.cfc +++ b/models/SQLCommenter/SQLCommenter.cfc @@ -69,14 +69,15 @@ component singleton { arguments.commentString = trim( arguments.commentString ); arguments.commentString = replace( arguments.commentString, "/*", "" ); arguments.commentString = replace( arguments.commentString, "*/", "" ); - return listToArray( arguments.commentString ).reduce( ( acc, serializedKeyValuePair ) => { + var comments = {}; + for ( var serializedKeyValuePair in listToArray( arguments.commentString ) ) { var key = decodeFromURL( unescapeMetaCharacters( listFirst( serializedKeyValuePair, "=" ) ) ); var value = decodeFromURL( unescapeMetaCharacters( unescapeSQL( listLast( serializedKeyValuePair, "=" ) ) ) ); - acc[ key ] = value; - return acc; - }, {} ); + comments[ key ] = value; + } + return comments; } /** diff --git a/models/Schema/Blueprint.cfc b/models/Schema/Blueprint.cfc index 9ef86bfb..57699b39 100644 --- a/models/Schema/Blueprint.cfc +++ b/models/Schema/Blueprint.cfc @@ -582,8 +582,14 @@ component accessors="true" { } public array function toSql() { - var originalCommands = variables.commands.map( ( command ) => command ); - var originalIndexes = variables.indexes.map( ( index ) => index ); + var originalCommands = []; + for ( var command in variables.commands ) { + originalCommands.append( command ); + } + var originalIndexes = []; + for ( var index in variables.indexes ) { + originalIndexes.append( index ); + } var statements = []; try { // we use a for loop here because we can potentially modify this array while looping over it. diff --git a/models/Schema/SchemaBuilder.cfc b/models/Schema/SchemaBuilder.cfc index 3cb9ee3e..880dbf76 100644 --- a/models/Schema/SchemaBuilder.cfc +++ b/models/Schema/SchemaBuilder.cfc @@ -50,6 +50,9 @@ component accessors="true" { variables.defaultSchema = arguments.defaultSchema; variables.pretending = false; variables.queryLog = []; + variables.queryLogHook = function( data ) { + variables.queryLog.append( arguments.data ); + }; variables.shouldWrapValues = javacast( "null", "" ); return this; } @@ -88,20 +91,7 @@ component accessors="true" { blueprint.setTable( qualifyTable( arguments.table ) ); arguments.callback( blueprint ); if ( arguments.execute ) { - blueprint - .toSql() - .each( function( statement ) { - getGrammar().runQuery( - statement, - [], - options, - "result", - variables.pretending, - function( data ) { - variables.queryLog.append( data ); - } - ); - } ); + executeStatements( blueprint.toSql(), [], arguments.options ); } return blueprint; } @@ -127,20 +117,7 @@ component accessors="true" { blueprint.setTable( qualifyTable( arguments.view ) ); if ( arguments.execute ) { - blueprint - .toSql() - .each( function( statement ) { - getGrammar().runQuery( - statement, - query.getBindings(), - options, - "result", - variables.pretending, - function( data ) { - variables.queryLog.append( data ); - } - ); - } ); + executeStatements( blueprint.toSql(), query.getBindings(), arguments.options ); } return blueprint; @@ -167,20 +144,7 @@ component accessors="true" { blueprint.setTable( qualifyTable( arguments.newTableName ) ); if ( arguments.execute ) { - blueprint - .toSql() - .each( function( statement ) { - getGrammar().runQuery( - statement, - query.getBindings(), - options, - "result", - variables.pretending, - function( data ) { - variables.queryLog.append( data ); - } - ); - } ); + executeStatements( blueprint.toSql(), query.getBindings(), arguments.options ); } return blueprint; @@ -207,19 +171,12 @@ component accessors="true" { blueprint.setTable( qualifyTable( arguments.view ) ); if ( arguments.execute ) { - var statements = blueprint.toSql(); - statements.each( function( statement, index ) { - getGrammar().runQuery( - statement, - index == statements.len() ? query.getBindings() : [], - options, - "result", - variables.pretending, - function( data ) { - variables.queryLog.append( data ); - } - ); - } ); + executeStatements( + blueprint.toSql(), + query.getBindings(), + arguments.options, + true + ); } return blueprint; @@ -237,20 +194,7 @@ component accessors="true" { blueprint.setTable( qualifyTable( arguments.view ) ); if ( arguments.execute ) { - blueprint - .toSql() - .each( function( statement ) { - getGrammar().runQuery( - statement, - [], - options, - "result", - variables.pretending, - function( data ) { - variables.queryLog.append( data ); - } - ); - } ); + executeStatements( blueprint.toSql(), [], arguments.options ); } return blueprint; @@ -276,20 +220,7 @@ component accessors="true" { blueprint.addCommand( "drop" ); blueprint.setTable( qualifyTable( arguments.table ) ); if ( arguments.execute ) { - blueprint - .toSql() - .each( function( statement ) { - getGrammar().runQuery( - statement, - [], - options, - "result", - variables.pretending, - function( data ) { - variables.queryLog.append( data ); - } - ); - } ); + executeStatements( blueprint.toSql(), [], arguments.options ); } return blueprint; } @@ -314,20 +245,7 @@ component accessors="true" { blueprint.addCommand( "truncate" ); blueprint.setTable( qualifyTable( arguments.table ) ); if ( arguments.execute ) { - blueprint - .toSql() - .each( function( statement ) { - getGrammar().runQuery( - statement, - [], - options, - "result", - variables.pretending, - function( data ) { - variables.queryLog.append( data ); - } - ); - } ); + executeStatements( blueprint.toSql(), [], arguments.options ); } return blueprint; } @@ -353,20 +271,7 @@ component accessors="true" { blueprint.setTable( qualifyTable( arguments.table ) ); blueprint.setIfExists( true ); if ( arguments.execute ) { - blueprint - .toSql() - .each( function( statement ) { - getGrammar().runQuery( - statement, - [], - options, - "result", - variables.pretending, - function( data ) { - variables.queryLog.append( data ); - } - ); - } ); + executeStatements( blueprint.toSql(), [], arguments.options ); } return blueprint; } @@ -398,20 +303,7 @@ component accessors="true" { blueprint.setTable( qualifyTable( arguments.table ) ); arguments.callback( blueprint ); if ( arguments.execute ) { - blueprint - .toSql() - .each( function( statement ) { - getGrammar().runQuery( - statement, - [], - options, - "result", - variables.pretending, - function( data ) { - variables.queryLog.append( data ); - } - ); - } ); + executeStatements( blueprint.toSql(), [], arguments.options ); } return blueprint; } @@ -442,20 +334,7 @@ component accessors="true" { blueprint.setTable( qualifyTable( arguments.from ) ); blueprint.addCommand( "renameTable", { to: arguments.to } ); if ( arguments.execute ) { - blueprint - .toSql() - .each( function( statement ) { - getGrammar().runQuery( - statement, - [], - options, - "result", - variables.pretending, - function( data ) { - variables.queryLog.append( data ); - } - ); - } ); + executeStatements( blueprint.toSql(), [], arguments.options ); } return blueprint; } @@ -513,9 +392,7 @@ component accessors="true" { arguments.options, "query", variables.pretending, - function( data ) { - variables.queryLog.append( data ); - } + variables.queryLogHook ); return isDefined( "q.RecordCount" ) ? q.RecordCount > 0 : false; } @@ -559,9 +436,7 @@ component accessors="true" { arguments.options, "query", variables.pretending, - function( data ) { - variables.queryLog.append( data ); - } + variables.queryLogHook ); return isDefined( "q.RecordCount" ) ? q.RecordCount > 0 : false; } @@ -595,18 +470,7 @@ component accessors="true" { grammar.popShouldWrapValuesContext(); } if ( arguments.execute ) { - statements.each( function( statement ) { - getGrammar().runQuery( - statement, - [], - options, - "result", - variables.pretending, - function( data ) { - variables.queryLog.append( data ); - } - ); - } ); + executeStatements( statements, [], arguments.options ); } return statements; } @@ -629,9 +493,7 @@ component accessors="true" { arguments.options, "result", variables.pretending, - function( data ) { - variables.queryLog.append( data ); - } + variables.queryLogHook ); } return statement; @@ -655,9 +517,7 @@ component accessors="true" { arguments.options, "result", variables.pretending, - function( data ) { - variables.queryLog.append( data ); - } + variables.queryLogHook ); } return statement; @@ -689,6 +549,30 @@ component accessors="true" { return mergedOptions; } + /** + * Executes compiled schema statements without per-statement callback closures. + */ + private void function executeStatements( + required array statements, + array bindings = [], + required struct options, + boolean bindingsOnlyOnLastStatement = false + ) { + for ( var i = 1; i <= arguments.statements.len(); i++ ) { + var statementBindings = arguments.bindingsOnlyOnLastStatement && i != arguments.statements.len() + ? [] + : arguments.bindings; + getGrammar().runQuery( + arguments.statements[ i ], + statementBindings, + arguments.options, + "result", + variables.pretending, + variables.queryLogHook + ); + } + } + /** * Prefixes an unqualified schema object with the configured default schema. * Explicitly qualified object names are returned unchanged. diff --git a/models/Support/NullInterceptorService.cfc b/models/Support/NullInterceptorService.cfc new file mode 100644 index 00000000..847ba1fc --- /dev/null +++ b/models/Support/NullInterceptorService.cfc @@ -0,0 +1,12 @@ +/** + * No-op interception service used outside of a ColdBox application. + */ +component singleton { + + public void function processState( any state, any data ) { + } + + public void function announce( any state, any data ) { + } + +} diff --git a/models/Support/NullLogger.cfc b/models/Support/NullLogger.cfc new file mode 100644 index 00000000..29c41bda --- /dev/null +++ b/models/Support/NullLogger.cfc @@ -0,0 +1,13 @@ +/** + * No-op logger used outside of a ColdBox application. + */ +component singleton { + + public boolean function canDebug() { + return false; + } + + public void function debug( any message, any extraInfo ) { + } + +} From 6a197d2e3c5f875f726aee0092a65d3976f048b4 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 17:58:26 -0600 Subject: [PATCH 117/119] perf: remove production copy calls --- models/Grammars/BaseGrammar.cfc | 12 ++++--- models/Grammars/SqlServerGrammar.cfc | 8 ++--- models/Query/Formatters/StructFormatter.cfc | 14 ++++---- models/Query/QueryBuilder.cfc | 27 ++++++++++----- models/Query/QueryExecutor.cfc | 5 +-- models/Query/QueryUtils.cfc | 4 ++- models/Query/ReturnFormatterRegistry.cfc | 21 +++++++----- models/Schema/SchemaBuilder.cfc | 5 +-- .../ProductionCopyCallRegressionSpec.cfc | 33 +++++++++++++++++++ 9 files changed, 90 insertions(+), 39 deletions(-) create mode 100644 tests/specs/ProductionCopyCallRegressionSpec.cfc diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 7b91b42c..22eff83e 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -153,15 +153,17 @@ component displayname="Grammar" accessors="true" singleton { function postProcessHook ) { local.result = ""; + var executionOptions = {}; + structAppend( executionOptions, arguments.options, true ); var data = { "sql": arguments.sql, "bindings": arguments.bindings, - "options": structCopy( arguments.options ), + "options": executionOptions, "returnObject": arguments.returnObject, "pretend": arguments.pretend }; tryPreInterceptor( data ); - structAppend( data.options, { result: "local.result" }, true ); + data.options.result = "local.result"; if ( variables.log.canDebug() ) { variables.log.debug( "Executing sql: #data.sql#", @@ -1562,9 +1564,11 @@ component displayname="Grammar" accessors="true" singleton { /** * Builds a portable SQL/JSON path literal. */ - public string function buildJsonPath( required array path ) { + public string function buildJsonPath( required array path, numeric segmentCount ) { + var pathSegmentCount = isNull( arguments.segmentCount ) ? arguments.path.len() : arguments.segmentCount; var compiledPath = "$"; - for ( var segment in arguments.path ) { + for ( var i = 1; i <= pathSegmentCount; i++ ) { + var segment = arguments.path[ i ]; if ( getUtils().isActuallyNumeric( segment ) ) { compiledPath &= "[#segment#]"; } else { diff --git a/models/Grammars/SqlServerGrammar.cfc b/models/Grammars/SqlServerGrammar.cfc index 5861a5f5..e6e86566 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -151,11 +151,11 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { if ( arguments.jsonPath.path.isEmpty() ) { return "#wrapJsonColumn( arguments.jsonPath )# IS NOT NULL"; } - var path = duplicate( arguments.jsonPath.path ); - var key = path.pop(); - var openJson = path.isEmpty() + var pathSegmentCount = arguments.jsonPath.path.len(); + var key = arguments.jsonPath.path[ pathSegmentCount ]; + var openJson = pathSegmentCount == 1 ? "OPENJSON(#wrapJsonColumn( arguments.jsonPath )#)" - : "OPENJSON(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( path )#')"; + : "OPENJSON(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path, pathSegmentCount - 1 )#')"; return "'#replace( key, "'", "''", "all" )#' IN (SELECT [key] FROM #openJson#)"; } diff --git a/models/Query/Formatters/StructFormatter.cfc b/models/Query/Formatters/StructFormatter.cfc index f0682294..dfa2da92 100644 --- a/models/Query/Formatters/StructFormatter.cfc +++ b/models/Query/Formatters/StructFormatter.cfc @@ -1,11 +1,13 @@ component accessors="true" { property name="utils"; - property name="options"; + property name="columnKey"; public StructFormatter function init( any utils = new qb.models.Query.QueryUtils(), struct options = {} ) { variables.utils = arguments.utils; - variables.options = structCopy( arguments.options ); + variables.columnKey = arguments.options.keyExists( "columnKey" ) && !isNull( arguments.options.columnKey ) + ? arguments.options.columnKey + : javacast( "null", "" ); return this; } @@ -14,18 +16,14 @@ component accessors="true" { } public struct function format( required any q ) { - if ( - !variables.options.keyExists( "columnKey" ) || isNull( variables.options.columnKey ) || !len( - variables.options.columnKey - ) - ) { + if ( isNull( variables.columnKey ) || !len( variables.columnKey ) ) { throw( type = "MissingColumnKey", message = "A columnKey option is required for the [struct] return formatter." ); } - return variables.utils.queryToStructOfStructs( arguments.q, variables.options.columnKey ); + return variables.utils.queryToStructOfStructs( arguments.q, variables.columnKey ); } } diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 2bd9dc97..a3e3cf64 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -2962,28 +2962,37 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return query */ public any function update( struct values = {}, struct options = {}, boolean toSql = false ) { - arguments.values = structCopy( arguments.values ); - structAppend( arguments.values, variables.updates, false ); - var updateArray = buildColumnDefinitions( arguments.values.keyArray(), true ); + var updateKeys = arguments.values.keyArray(); + for ( var configuredUpdateKey in variables.updates ) { + if ( !arguments.values.keyExists( configuredUpdateKey ) ) { + updateKeys.append( configuredUpdateKey ); + } + } + var updateArray = buildColumnDefinitions( updateKeys, true ); + var resolvedUpdateValues = {}; var newUpdateBindings = []; var executor = getCollaborator( "QueryExecutor" ); var commonTableState = executor.captureCommonTableState( this ); var sql = ""; try { for ( var column in updateArray ) { - var value = arguments.values[ column.original ]; + var value = arguments.values.keyExists( column.original ) + ? arguments.values[ column.original ] + : variables.updates[ column.original ]; if ( isCustomFunction( value ) || isClosure( value ) ) { var subselect = newQuery(); value( subselect ); - arguments.values[ column.original ] = executor.snapshotBuilder( this, subselect ); - newUpdateBindings.append( arguments.values[ column.original ].getBindings(), true ); + resolvedUpdateValues[ column.original ] = executor.snapshotBuilder( this, subselect ); + newUpdateBindings.append( resolvedUpdateValues[ column.original ].getBindings(), true ); } else if ( getUtils().isBuilder( value ) ) { - arguments.values[ column.original ] = executor.snapshotBuilder( this, value ); - newUpdateBindings.append( arguments.values[ column.original ].getBindings(), true ); + resolvedUpdateValues[ column.original ] = executor.snapshotBuilder( this, value ); + newUpdateBindings.append( resolvedUpdateValues[ column.original ].getBindings(), true ); } else if ( getUtils().isExpression( value ) ) { + resolvedUpdateValues[ column.original ] = value; newUpdateBindings.append( extractExpressionBindings( value ), true ); } else { + resolvedUpdateValues[ column.original ] = value; newUpdateBindings.append( getUtils().extractBinding( value, variables.grammar ) ); } } @@ -2992,7 +3001,7 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J sql = withGrammarWrapping( "compileUpdate", - { "query": this, "columns": updateArray, "updateMap": arguments.values } + { "query": this, "columns": updateArray, "updateMap": resolvedUpdateValues } ); } catch ( any e ) { executor.restoreCommonTableState( this, commonTableState ); diff --git a/models/Query/QueryExecutor.cfc b/models/Query/QueryExecutor.cfc index 5c57e58e..816942e0 100644 --- a/models/Query/QueryExecutor.cfc +++ b/models/Query/QueryExecutor.cfc @@ -38,9 +38,10 @@ component { string returnObject = "query", struct bindingsDefinition = { provided: false } ) { - var queryOptions = structCopy( arguments.options ); + var queryOptions = {}; var queryBuilder = arguments.builder; - structAppend( queryOptions, arguments.builder.getDefaultOptions(), false ); + structAppend( queryOptions, arguments.builder.getDefaultOptions(), true ); + structAppend( queryOptions, arguments.options, true ); if ( queryOptions.keyExists( "returntype" ) ) { arguments.builder.getQueryValidator().validateQueryExecuteOptions( queryOptions ); } diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 8aa44f3f..60e18170 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -93,7 +93,9 @@ component singleton displayname="QueryUtils" accessors="true" { checkForNonQueryParamStructKeys( value ); } - binding = structCopy( value ); + for ( var key in value ) { + binding[ key ] = isNull( value[ key ] ) ? javacast( "null", "" ) : value[ key ]; + } } else { binding = { value: normalizeSqlValue( value ) }; } diff --git a/models/Query/ReturnFormatterRegistry.cfc b/models/Query/ReturnFormatterRegistry.cfc index 44f8beb3..4f1d371a 100644 --- a/models/Query/ReturnFormatterRegistry.cfc +++ b/models/Query/ReturnFormatterRegistry.cfc @@ -95,16 +95,19 @@ component accessors="true" singleton { private struct function normalizeFormatterDefinition( required any definition ) { if ( isStruct( arguments.definition ) && arguments.definition.keyExists( "factory" ) ) { - var normalizedDefinition = structCopy( arguments.definition ); - param normalizedDefinition.options = {}; - param normalizedDefinition.properties = {}; - param normalizedDefinition.force = false; - return { - "factory": normalizedDefinition.factory, - "options": normalizedDefinition.options, - "properties": normalizedDefinition.properties, - "force": normalizedDefinition.force + "factory": arguments.definition.factory, + "options": arguments.definition.keyExists( "options" ) && !isNull( arguments.definition.options ) + ? arguments.definition.options + : {}, + "properties": arguments.definition.keyExists( "properties" ) && !isNull( + arguments.definition.properties + ) + ? arguments.definition.properties + : {}, + "force": arguments.definition.keyExists( "force" ) && !isNull( arguments.definition.force ) + ? arguments.definition.force + : false }; } diff --git a/models/Schema/SchemaBuilder.cfc b/models/Schema/SchemaBuilder.cfc index 880dbf76..98385b4c 100644 --- a/models/Schema/SchemaBuilder.cfc +++ b/models/Schema/SchemaBuilder.cfc @@ -544,8 +544,9 @@ component accessors="true" { * Merges per-operation options with schema defaults without mutating the caller's struct. */ private struct function mergeOptions( required struct options ) { - var mergedOptions = structCopy( arguments.options ); - structAppend( mergedOptions, variables.defaultOptions, false ); + var mergedOptions = {}; + structAppend( mergedOptions, variables.defaultOptions, true ); + structAppend( mergedOptions, arguments.options, true ); return mergedOptions; } diff --git a/tests/specs/ProductionCopyCallRegressionSpec.cfc b/tests/specs/ProductionCopyCallRegressionSpec.cfc new file mode 100644 index 00000000..6b8a0882 --- /dev/null +++ b/tests/specs/ProductionCopyCallRegressionSpec.cfc @@ -0,0 +1,33 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "production copy calls", function() { + it( "does not use duplicate or structCopy", function() { + var productionFiles = []; + for ( + var modelFile in directoryList( + expandPath( "/qb/models" ), + true, + "path", + "*.cfc" + ) + ) { + productionFiles.append( modelFile ); + } + productionFiles.append( expandPath( "/qb/ModuleConfig.cfc" ) ); + + var matchingFiles = []; + for ( var filePath in productionFiles ) { + if ( reFindNoCase( "\b(?:duplicate|structCopy)\s*\(", fileRead( filePath ) ) ) { + matchingFiles.append( filePath ); + } + } + + expect( matchingFiles ).toBeEmpty( + "Production code should construct owned values directly. Matches: #matchingFiles.toList( ", " )#" + ); + } ); + } ); + } + +} From 9b1b4c609a2761f29fb374d7a491c0f1df546992 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 26 Aug 2026 12:46:34 -0600 Subject: [PATCH 118/119] perf: optimize query construction hot paths --- models/Grammars/BaseGrammar.cfc | 10 +- models/Query/PredicateClause.cfc | 88 ++++--- models/Query/QueryBuilder.cfc | 6 +- models/Query/QueryExecutor.cfc | 4 +- models/Query/QueryUtils.cfc | 219 ++++++++++-------- .../PerformanceOptimizationRegressionSpec.cfc | 144 ++++++++++++ 6 files changed, 331 insertions(+), 140 deletions(-) create mode 100644 tests/specs/Query/PerformanceOptimizationRegressionSpec.cfc diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 22eff83e..e3f99520 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -738,13 +738,19 @@ component displayname="Grammar" accessors="true" singleton { */ private string function compileWhereInPlaceholders( required array values ) { var placeholders = []; + if ( arguments.values.isEmpty() ) { + return ""; + } + arrayResize( placeholders, arguments.values.len() ); for ( var valueIndex = 1; valueIndex <= arguments.values.len(); valueIndex++ ) { if ( !arrayIsDefined( arguments.values, valueIndex ) || isNull( arguments.values[ valueIndex ] ) ) { - placeholders.append( "?" ); + placeholders[ valueIndex ] = "?"; continue; } var value = arguments.values[ valueIndex ]; - placeholders.append( variables.utils.isExpression( value ) ? value.getSql() : "?" ); + placeholders[ valueIndex ] = isSimpleValue( value ) || !variables.utils.isExpression( value ) + ? "?" + : value.getSql(); } return placeholders.toList( ", " ); } diff --git a/models/Query/PredicateClause.cfc b/models/Query/PredicateClause.cfc index f02a652f..695d6382 100644 --- a/models/Query/PredicateClause.cfc +++ b/models/Query/PredicateClause.cfc @@ -84,18 +84,20 @@ component { var type = arguments.negate ? "notIn" : "in"; var typedColumn = toColumnType( arguments.builder, arguments.column ); var bindings = arguments.values.isEmpty() ? [] : arguments.builder.extractColumnBindings( [ typedColumn ] ); + var utils = arguments.builder.getUtils(); + var grammar = arguments.builder.getGrammar(); for ( var valueIndex = 1; valueIndex <= arguments.values.len(); valueIndex++ ) { if ( !arrayIsDefined( arguments.values, valueIndex ) || isNull( arguments.values[ valueIndex ] ) ) { - bindings.append( - arguments.builder.getUtils().extractBinding( grammar = arguments.builder.getGrammar() ) - ); + bindings.append( utils.extractBinding( grammar = grammar ) ); continue; } var value = arguments.values[ valueIndex ]; - if ( arguments.builder.getUtils().isExpression( value ) ) { + if ( isSimpleValue( value ) ) { + bindings.append( utils.extractBinding( value, grammar ) ); + } else if ( utils.isExpression( value ) ) { bindings.append( arguments.builder.extractExpressionBindings( value ), true ); } else { - bindings.append( arguments.builder.getUtils().extractBinding( value, arguments.builder.getGrammar() ) ); + bindings.append( utils.extractBinding( value, grammar ) ); } } @@ -125,31 +127,40 @@ component { arguments.builder.getQueryValidator().validateCombinator( arguments.combinator ); arguments.values = arguments.builder.normalizeToArray( arguments.values ); - var extractedBindings = []; + var utils = arguments.builder.getUtils(); + var grammar = arguments.builder.getGrammar(); + var shouldInferSqlType = isNull( arguments.sqlType ); + var inferredSqlType = "VARCHAR"; + var hasInferredSqlType = false; + var hasMixedSqlTypes = false; + var serializedValues = []; if ( !arguments.values.isEmpty() ) { - arrayResize( extractedBindings, arguments.values.len() ); + arrayResize( serializedValues, arguments.values.len() ); } for ( var valueIndex = 1; valueIndex <= arguments.values.len(); valueIndex++ ) { if ( !arrayIsDefined( arguments.values, valueIndex ) || isNull( arguments.values[ valueIndex ] ) ) { - extractedBindings[ valueIndex ] = arguments.builder - .getUtils() - .extractBinding( grammar = arguments.builder.getGrammar() ); + serializedValues[ valueIndex ] = javacast( "null", "" ); continue; } - if ( arguments.builder.getUtils().isExpression( arguments.values[ valueIndex ] ) ) { + var value = arguments.values[ valueIndex ]; + if ( !isSimpleValue( value ) && utils.isExpression( value ) ) { throw( type = "InvalidBulkValue", message = "Bulk IN values cannot contain SQL expressions." ); } - extractedBindings[ valueIndex ] = arguments.builder - .getUtils() - .extractBinding( arguments.values[ valueIndex ], arguments.builder.getGrammar() ); + var binding = utils.extractBinding( value, grammar ); + serializedValues[ valueIndex ] = binding.null ? javacast( "null", "" ) : binding.value; + if ( shouldInferSqlType && !binding.null ) { + var bindingSqlType = reReplaceNoCase( trim( binding.cfsqltype ), "^cf_sql_", "" ).uCase(); + if ( !hasInferredSqlType ) { + inferredSqlType = bindingSqlType; + hasInferredSqlType = true; + } else if ( compareNoCase( inferredSqlType, bindingSqlType ) != 0 ) { + hasMixedSqlTypes = true; + } + } } - if ( isNull( arguments.sqlType ) ) { - arguments.sqlType = arguments.builder - .getGrammar() - .resolveWhereInBulkSqlType( - arguments.builder.getUtils().inferSqlType( arguments.values, arguments.builder.getGrammar() ) - ); + if ( shouldInferSqlType ) { + arguments.sqlType = grammar.resolveWhereInBulkSqlType( hasMixedSqlTypes ? "VARCHAR" : inferredSqlType ); } arguments.sqlType = trim( arguments.sqlType ); @@ -182,22 +193,13 @@ component { } ); if ( !arguments.values.isEmpty() ) { - var serializedValues = []; - arrayResize( serializedValues, extractedBindings.len() ); - for ( var i = 1; i <= extractedBindings.len(); i++ ) { - serializedValues[ i ] = extractedBindings[ i ].null - ? javacast( "null", "" ) - : extractedBindings[ i ].value; - } arguments.builder.addBindings( columnBindings, "where" ); arguments.builder.addBindings( [ - arguments.builder - .getUtils() - .extractBinding( - { value: serializeJSON( serializedValues ), cfsqltype: "LONGVARCHAR" }, - arguments.builder.getGrammar() - ) + utils.extractBinding( + { value: serializeJSON( serializedValues ), cfsqltype: "LONGVARCHAR" }, + grammar + ) ], "where" ); @@ -557,6 +559,26 @@ component { string combinator = "and" ) { var typedColumn = toColumnType( arguments.builder, arguments.column ); + if ( + typedColumn.type == "simple" && + !isNull( arguments.value ) && + isSimpleValue( arguments.value ) + ) { + var utils = arguments.builder.getUtils(); + var binding = utils.extractBinding( arguments.value, arguments.builder.getGrammar() ); + arguments.builder + .getWheres() + .append( { + column: typedColumn, + operator: arguments.operator, + value: arguments.value, + combinator: arguments.combinator, + type: "basic" + } ); + arguments.builder.addBindings( binding, "where" ); + return arguments.builder; + } + var bindings = arguments.builder.extractColumnBindings( [ typedColumn ] ); bindings.append( extractPredicateBindings( diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index a3e3cf64..27fd0804 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -3858,9 +3858,13 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J columns = arguments.column, options = arguments.options ); - var columnName = getFunctionList().keyExists( "queryColumnList" ) ? queryColumnList( result ).listFirst() : getMetadata( + var functionList = getFunctionList(); + var columnName = functionList.keyExists( "queryColumnList" ) ? queryColumnList( result ).listFirst() : getMetadata( result )[ 1 ].name; + if ( functionList.keyExists( "queryColumnData" ) ) { + return queryColumnData( result, columnName ); + } var results = []; for ( var row in result ) { results.append( row[ columnName ] ); diff --git a/models/Query/QueryExecutor.cfc b/models/Query/QueryExecutor.cfc index 816942e0..23f59974 100644 --- a/models/Query/QueryExecutor.cfc +++ b/models/Query/QueryExecutor.cfc @@ -146,8 +146,8 @@ component { */ public QueryBuilder function hoistNestedCommonTables( required QueryBuilder source, required QueryBuilder target ) { if ( - !isInstanceOf( arguments.target.getGrammar().getResolvedGrammar(), "qb.models.Grammars.SqlServerGrammar" ) || - arguments.source.getCommonTables().isEmpty() + arguments.source.getCommonTables().isEmpty() || + !isInstanceOf( arguments.target.getGrammar().getResolvedGrammar(), "qb.models.Grammars.SqlServerGrammar" ) ) { return arguments.source; } diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 60e18170..3524413b 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -33,6 +33,48 @@ component singleton displayname="QueryUtils" accessors="true" { */ property name="decimalSQLType" default="DECIMAL"; + variables.numericValueTypes = { + "AtomicInteger": true, + "AtomicLong": true, + "BigDecimal": true, + "BigInteger": true, + "Byte": true, + "CFDouble": true, + "Double": true, + "DoubleAccumulator": true, + "DoubleAdder": true, + "Float": true, + "Integer": true, + "Long": true, + "LongAccumulator": true, + "LongAdder": true, + "Short": true + }; + + variables.dateValueTypes = { + "Date": true, + "DateTime": true, + "DateTimeImpl": true, + "OleDateTime": true, + "Time": true, + "Timestamp": true + }; + + variables.booleanValueTypes = { "CFBoolean": true, "Boolean": true }; + + variables.validQueryParamKeys = { + "cfsqltype": true, + "list": true, + "maxlength": true, + "name": true, + "null": true, + "nulls": true, + "sqltype": true, + "separator": true, + "scale": true, + "value": true + }; + /** * Creates a new QueryUtils helper. * @return qb.models.Query.QueryUtils @@ -89,13 +131,22 @@ component singleton displayname="QueryUtils" accessors="true" { return value; } - if ( variables.validateQueryParamStructKeys ) { - checkForNonQueryParamStructKeys( value ); - } - + var invalidKeys = []; for ( var key in value ) { + if ( + variables.validateQueryParamStructKeys && + !variables.validQueryParamKeys.keyExists( key ) + ) { + invalidKeys.append( key ); + } binding[ key ] = isNull( value[ key ] ) ? javacast( "null", "" ) : value[ key ]; } + if ( !invalidKeys.isEmpty() ) { + throw( + type = "QBInvalidQueryParam", + message = "Invalid keys detected in your query param struct: [#invalidKeys.sort( "textnocase" ).toList( ", " )#]. Usually this happens when you meant to serialize the struct to JSON first." + ); + } } else { binding = { value: normalizeSqlValue( value ) }; } @@ -117,10 +168,15 @@ component singleton displayname="QueryUtils" accessors="true" { } if ( !structKeyExists( binding, "cfsqltype" ) ) { - if ( checkIsActuallyBoolean( binding.value ) ) { + var valueType = isArray( binding.value ) || isStruct( binding.value ) + ? javacast( "null", "" ) + : listLast( toString( getMetadata( binding.value ) ), ". " ); + if ( !isNull( valueType ) && variables.booleanValueTypes.keyExists( valueType ) ) { structAppend( binding, arguments.grammar.convertToBooleanType( binding.value ), true ); } else { - binding.sqltype = inferSqlType( binding.value, arguments.grammar ); + binding.sqltype = isNull( valueType ) + ? inferSqlType( binding.value, arguments.grammar ) + : inferSqlType( binding.value, arguments.grammar, valueType ); binding.cfsqltype = binding.sqltype; } } @@ -494,11 +550,12 @@ component singleton displayname="QueryUtils" accessors="true" { /** * Infer the correct type from a value. * - * @value The value from which to infer the type. + * @value The value from which to infer the type. + * @valueType A previously resolved runtime type for the value. * * @return string */ - public string function inferSqlType( any value, required any grammar ) { + public string function inferSqlType( any value, required any grammar, string valueType ) { if ( isNull( arguments.value ) ) { return "VARCHAR"; } @@ -530,16 +587,23 @@ component singleton displayname="QueryUtils" accessors="true" { return structKeyExists( value, "value" ) ? inferSqlType( value.value, grammar ) : "VARCHAR"; } - if ( checkIsActuallyNumeric( value ) ) { + var resolvedValueType = isNull( arguments.valueType ) + ? listLast( toString( getMetadata( arguments.value ) ), ". " ) + : arguments.valueType; + if ( isSimpleValue( arguments.value ) && variables.numericValueTypes.keyExists( resolvedValueType ) ) { return deriveNumericSqlType( value ); } - if ( checkIsActuallyDate( value ) ) { - return "TIMESTAMP"; + if ( variables.booleanValueTypes.keyExists( resolvedValueType ) ) { + return arguments.grammar.getBooleanSqlType(); } - if ( checkIsActuallyBoolean( value ) ) { - return arguments.grammar.getBooleanSqlType(); + var dateValueType = resolvedValueType; + if ( isPureBoxLang() && isDate( arguments.value ) ) { + dateValueType = listLast( arguments.value.$bx.$class.getName(), "." ); + } + if ( isDate( arguments.value ) && variables.dateValueTypes.keyExists( dateValueType ) ) { + return "TIMESTAMP"; } return "VARCHAR"; @@ -729,18 +793,33 @@ component singleton displayname="QueryUtils" accessors="true" { * @return struct */ public struct function queryToStructOfStructs( required any q, required string columnKey ) { - var rows = queryToArrayOfStructs( arguments.q ); var results = {}; + if ( arguments.q.recordCount == 0 ) { + return results; + } - for ( var row in rows ) { - if ( !row.keyExists( arguments.columnKey ) ) { + var queryColumns = []; + if ( isPureBoxLang() ) { + queryColumns = arguments.q.getColumnNames(); + } else { + for ( var item in getMetadata( arguments.q ) ) { + queryColumns.append( item.name ); + } + } + + for ( var queryRow in arguments.q ) { + var rowData = structNew( "ordered" ); + for ( var column in queryColumns ) { + rowData[ column ] = queryRow[ column ]; + } + if ( !rowData.keyExists( arguments.columnKey ) ) { throw( type = "MissingColumnKey", message = "The columnKey [#arguments.columnKey#] was not found in the query results." ); } - results[ row[ arguments.columnKey ] ] = row; + results[ rowData[ arguments.columnKey ] ] = rowData; } return results; @@ -867,26 +946,7 @@ component singleton displayname="QueryUtils" accessors="true" { } var type = listLast( toString( getMetadata( arguments.value ) ), ". " ); variables.log.debug( "checkIsActuallyNumeric: #arguments.value# is #type#" ); - return isSimpleValue( arguments.value ) && arrayContainsNoCase( - [ - "AtomicInteger", - "AtomicLong", - "BigDecimal", - "BigInteger", - "Byte", - "CFDouble", - "Double", - "DoubleAccumulator", - "DoubleAdder", - "Float", - "Integer", - "Long", - "LongAccumulator", - "LongAdder", - "Short" - ], - type - ); + return isSimpleValue( arguments.value ) && variables.numericValueTypes.keyExists( type ); } private string function deriveNumericSqlType( required numeric value ) { @@ -918,17 +978,7 @@ component singleton displayname="QueryUtils" accessors="true" { className = listLast( toString( getMetadata( arguments.value ) ), "." ) } - return isDate( arguments.value ) && arrayContainsNoCase( - [ - "Date", - "DateTime", - "DateTimeImpl", - "OleDateTime", - "Time", - "Timestamp" - ], - className - ); + return isDate( arguments.value ) && variables.dateValueTypes.keyExists( className ); } /** @@ -943,10 +993,7 @@ component singleton displayname="QueryUtils" accessors="true" { return false; } - return arrayContainsNoCase( - [ "CFBoolean", "Boolean" ], - listLast( toString( getMetadata( arguments.value ) ), "." ) - ); + return variables.booleanValueTypes.keyExists( listLast( toString( getMetadata( arguments.value ) ), "." ) ); } @@ -1079,16 +1126,23 @@ component singleton displayname="QueryUtils" accessors="true" { return false; } - return arguments.binding.cfsqltype.findNoCase( "decimal" ) > 0 || - arguments.binding.cfsqltype.findNoCase( "double" ) > 0 || - arguments.binding.cfsqltype.findNoCase( "float" ) > 0 || - arguments.binding.cfsqltype.findNoCase( "money" ) > 0 || - arguments.binding.cfsqltype.findNoCase( "money4" ) > 0 || - ( - arguments.binding.cfsqltype.findNoCase( "numeric" ) > 0 && arguments.binding.value - .toString() - .findNoCase( "." ) > 0 - ); + var sqlType = uCase( arguments.binding.cfsqltype ); + if ( left( sqlType, 7 ) == "CF_SQL_" ) { + sqlType = right( sqlType, len( sqlType ) - 7 ); + } + + switch ( sqlType ) { + case "DECIMAL": + case "DOUBLE": + case "FLOAT": + case "MONEY": + case "MONEY4": + return true; + case "NUMERIC": + return arguments.binding.value.toString().find( "." ) > 0; + default: + return false; + } } private numeric function calculateNumberOfDecimalDigits( required struct binding ) { @@ -1123,52 +1177,13 @@ component singleton displayname="QueryUtils" accessors="true" { return true; } - private void function checkForNonQueryParamStructKeys( required struct param ) { - var validKeys = [ - "cfsqltype", - "list", - "maxlength", - "name", - "null", - "nulls", - "sqltype", - "separator", - "scale", - "value" - ]; - var extraKeys = []; - for ( var key in param.keyArray() ) { - if ( !validKeys.containsNoCase( key ) ) { - extraKeys.append( key ); - } - } - if ( !extraKeys.isEmpty() ) { - throw( - type = "QBInvalidQueryParam", - message = "Invalid keys detected in your query param struct: [#extraKeys.sort( "textnocase" ).toList( ", " )#]. Usually this happens when you meant to serialize the struct to JSON first." - ); - } - } - public boolean function isValidQueryParamStruct( required any param ) { if ( !isStruct( arguments.param ) || isObject( arguments.param ) ) { return false; } - var validKeys = [ - "cfsqltype", - "list", - "maxlength", - "name", - "null", - "nulls", - "sqltype", - "separator", - "scale", - "value" - ]; for ( var key in param.keyArray() ) { - if ( !validKeys.containsNoCase( key ) ) { + if ( !variables.validQueryParamKeys.keyExists( key ) ) { return false; } } diff --git a/tests/specs/Query/PerformanceOptimizationRegressionSpec.cfc b/tests/specs/Query/PerformanceOptimizationRegressionSpec.cfc new file mode 100644 index 00000000..c59b2d4d --- /dev/null +++ b/tests/specs/Query/PerformanceOptimizationRegressionSpec.cfc @@ -0,0 +1,144 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "performance optimization regressions", function() { + it( "preserves scalar binding type inference and decimal scale", function() { + var utils = new qb.models.Query.QueryUtils(); + var grammar = new qb.models.Grammars.BaseGrammar( utils ); + + expect( utils.extractBinding( "42", grammar ).cfsqltype ).toBe( "VARCHAR" ); + expect( utils.extractBinding( 42, grammar ).cfsqltype ).toBe( "INTEGER" ); + expect( utils.extractBinding( true, grammar ).cfsqltype ).toBe( "TINYINT" ); + expect( + utils.extractBinding( + { + value: createObject( "java", "java.math.BigDecimal" ).init( "3.1400" ), + cfsqltype: "cf_sql_decimal" + }, + grammar + ).scale + ).toBe( 4 ); + } ); + + it( "copies and validates query parameter keys in one pass", function() { + var utils = new qb.models.Query.QueryUtils(); + var grammar = new qb.models.Grammars.BaseGrammar( utils ); + var queryParam = { + value: 42, + cfsqltype: "INTEGER", + maxlength: 10, + scale: 0, + null: false + }; + + var binding = utils.extractBinding( queryParam, grammar ); + + expect( binding ).toBe( { + value: 42, + cfsqltype: "INTEGER", + sqltype: "INTEGER", + maxlength: 10, + scale: 0, + list: false, + null: false + } ); + expect( queryParam ).notToHaveKey( "sqltype" ); + expect( function() { + utils.extractBinding( { value: 42, zebra: true, alpha: true }, grammar ); + } ).toThrow( + type = "QBInvalidQueryParam", + regex = "Invalid keys detected in your query param struct: \[alpha, zebra\]" + ); + } ); + + it( "preserves the scalar WHERE fast path representation", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.PostgresGrammar() ); + + builder.from( "users" ).where( "users.id", "=", 42 ); + + expect( builder.toSQL() ).toBe( "SELECT * FROM ""users"" WHERE ""users"".""id"" = ?" ); + expect( builder.getWheres() ).toBe( [ + { + column: { type: "simple", value: "users.id" }, + operator: "=", + value: 42, + combinator: "and", + type: "basic" + } + ] ); + expect( builder.getBindings() ).toBe( [ + { + value: 42, + cfsqltype: "INTEGER", + sqltype: "INTEGER", + list: false, + null: false + } + ] ); + } ); + + it( "serializes and infers bulk values without retaining per-value bindings", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.PostgresGrammar() ); + var values = []; + arrayResize( values, 3 ); + values[ 1 ] = { value: 1, cfsqltype: "cf_sql_bigint" }; + values[ 3 ] = { value: 3, sqltype: "BIGINT" }; + + builder.from( "users" ).whereInBulk( "id", values ); + + expect( builder.getWheres()[ 1 ].sqlType ).toBe( "BIGINT" ); + expect( deserializeJSON( builder.getBindings().last().value ) ).toBe( [ 1, javacast( "null", "" ), 3 ] ); + expect( builder.getBindings().len() ).toBe( 1 ); + } ); + + it( "preserves sparse, scalar, expression, and query parameter WHERE IN values", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.PostgresGrammar() ); + var values = []; + arrayResize( values, 4 ); + values[ 1 ] = 1; + values[ 3 ] = builder.raw( "COALESCE(?, 3)", [ 3 ] ); + values[ 4 ] = { value: 4, cfsqltype: "INTEGER" }; + + builder.from( "users" ).whereIn( "id", values ); + + expect( builder.toSQL() ).toBe( "SELECT * FROM ""users"" WHERE ""id"" IN (?, ?, COALESCE(?, 3), ?)" ); + expect( builder.getBindings().map( ( binding ) => binding.value ) ).toBe( [ 1, "", 3, 4 ] ); + expect( builder.getBindings()[ 2 ].null ).toBeTrue(); + } ); + + it( "converts query results directly to keyed row structs", function() { + var utils = new qb.models.Query.QueryUtils(); + var rows = queryNew( + "id,name", + "integer,varchar", + [ { id: 1, name: "Ada" }, { id: 2, name: "Grace" }, { id: 1, name: "Augusta" } ] + ); + + var result = utils.queryToStructOfStructs( rows, "id" ); + + expect( result ).toHaveKey( "1" ); + expect( result ).toHaveKey( "2" ); + expect( result.count() ).toBe( 2 ); + expect( result[ 1 ].name ).toBe( "Augusta" ); + expect( result[ 2 ].name ).toBe( "Grace" ); + expect( utils.queryToStructOfStructs( queryNew( "name", "varchar" ), "id" ) ).toBe( {} ); + } ); + + it( "does not resolve the target grammar when there are no nested common tables", function() { + var grammar = createMock( "qb.models.Grammars.BaseGrammar" ) + .init() + .$( "getResolvedGrammar" ) + .$callback( function() { + throw( type = "UnexpectedGrammarResolution" ); + } ); + var source = new qb.models.Query.QueryBuilder( grammar = grammar ); + var target = new qb.models.Query.QueryBuilder( grammar = grammar ); + + expect( function() { + new qb.models.Query.QueryExecutor().hoistNestedCommonTables( source, target ); + } ).notToThrow(); + } ); + } ); + } + +} From 91e6f19810ca9ba7dfe39f5e21b52aef6e021b1e Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 26 Aug 2026 14:26:20 -0600 Subject: [PATCH 119/119] perf: reduce query construction allocations --- models/Grammars/BaseGrammar.cfc | 47 +++++++- models/Query/QueryBuilder.cfc | 30 +++--- models/Query/QueryUtils.cfc | 87 +++++++-------- .../PerformanceOptimizationRegressionSpec.cfc | 101 ++++++++++++++++++ 4 files changed, 200 insertions(+), 65 deletions(-) diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index e3f99520..e97be09a 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -792,7 +792,10 @@ component displayname="Grammar" accessors="true" singleton { * @return string */ public string function resolveWhereInBulkSqlType( required string sqlType ) { - return reReplaceNoCase( trim( arguments.sqlType ), "^CF_SQL_", "" ).uCase(); + var normalizedSqlType = trim( arguments.sqlType ).uCase(); + return left( normalizedSqlType, 7 ) == "CF_SQL_" + ? right( normalizedSqlType, len( normalizedSqlType ) - 7 ) + : normalizedSqlType; } /** @@ -1409,6 +1412,30 @@ component displayname="Grammar" accessors="true" singleton { return arguments.table.getSql(); } + var tableValue = trim( toString( arguments.table ) ); + if ( + !find( " ", tableValue ) && + !find( chr( 9 ), tableValue ) && + !find( chr( 10 ), tableValue ) && + !find( chr( 11 ), tableValue ) && + !find( chr( 12 ), tableValue ) && + !find( chr( 13 ), tableValue ) && + variables.utils.isNotSubQuery( tableValue ) + ) { + var simpleTableParts = tableValue.listToArray( "." ); + var wrappedSimpleTableParts = []; + for ( var simpleTableIndex = 1; simpleTableIndex <= simpleTableParts.len(); simpleTableIndex++ ) { + wrappedSimpleTableParts.append( + wrapValue( + simpleTableIndex == simpleTableParts.len() + ? getTablePrefix() & simpleTableParts[ simpleTableIndex ] + : simpleTableParts[ simpleTableIndex ] + ) + ); + } + return wrappedSimpleTableParts.toList( "." ); + } + var parts = explodeTable( arguments.table ); if ( getUtils().isNotSubQuery( parts.table ) ) { var tableParts = parts.table.listToArray( "." ); @@ -1494,7 +1521,23 @@ component displayname="Grammar" accessors="true" singleton { : jsonSql; } - var columnParts = explodeColumnAlias( arguments.column.value ); + var columnValue = arguments.column.value; + if ( + !find( " ", columnValue ) && + !find( chr( 9 ), columnValue ) && + !find( chr( 10 ), columnValue ) && + !find( chr( 11 ), columnValue ) && + !find( chr( 12 ), columnValue ) && + !find( chr( 13 ), columnValue ) + ) { + var simpleColumnParts = []; + for ( var simpleColumnPart in columnValue.listToArray( "." ) ) { + simpleColumnParts.append( wrapValue( simpleColumnPart ) ); + } + return simpleColumnParts.toList( "." ); + } + + var columnParts = explodeColumnAlias( columnValue ); arguments.column = columnParts.column; var alias = columnParts.alias; var wrappedColumnParts = []; diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index 27fd0804..3a5879ae 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -3277,17 +3277,14 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J if ( arguments.order.isEmpty() ) { arguments.order = getGrammar().getSelectBindingOrder( this ); } - var bindingOrder = []; - for ( var type in arguments.order ) { - if ( !arrayContainsNoCase( arguments.except, type ) ) { - bindingOrder.append( type ); - } - } var flatBindings = []; - for ( var key in bindingOrder ) { - if ( structKeyExists( bindings, key ) ) { - arrayAppend( flatBindings, bindings[ key ], true ); + for ( var type in arguments.order ) { + if ( + !arrayContainsNoCase( arguments.except, type ) && + structKeyExists( variables.bindings, type ) + ) { + flatBindings.append( variables.bindings[ type ], true ); } } @@ -3333,12 +3330,12 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J * @return qb.models.Query.QueryBuilder */ public QueryBuilder function addBindings( required any newBindings, string type = "where" ) { - if ( !isArray( newBindings ) ) { - newBindings = [ newBindings ]; + if ( isArray( arguments.newBindings ) ) { + variables.bindings[ arguments.type ].append( arguments.newBindings, true ); + } else { + variables.bindings[ arguments.type ].append( arguments.newBindings ); } - variables.bindings[ type ].append( newBindings, true ); - return this; } @@ -4242,8 +4239,13 @@ component displayname="QueryBuilder" accessors="true" extends="qb.models.Query.J } try { + var normalizedValue = trim( arguments.listOrArray ); + if ( !find( ",", normalizedValue ) ) { + return [ normalizedValue ]; + } + var values = []; - for ( var item in trim( arguments.listOrArray ).split( ",\s*" ) ) { + for ( var item in normalizedValue.split( ",\s*" ) ) { values.append( trim( item ) ); } return values; diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 3524413b..71bdd712 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -561,18 +561,25 @@ component singleton displayname="QueryUtils" accessors="true" { } if ( isArray( value ) ) { - var inferredTypes = []; - for ( var i = 1; i <= arguments.value.len(); i++ ) { - if ( !arrayIsDefined( arguments.value, i ) || isNull( arguments.value[ i ] ) ) { + var inferredType = ""; + var hasInferredType = false; + for ( var valueIndex = 1; valueIndex <= arguments.value.len(); valueIndex++ ) { + if ( !arrayIsDefined( arguments.value, valueIndex ) || isNull( arguments.value[ valueIndex ] ) ) { continue; } - var item = arguments.value[ i ]; + var item = arguments.value[ valueIndex ]; if ( isStruct( item ) && item.keyExists( "null" ) && item.null ) { continue; } - inferredTypes.append( inferSqlType( item, arguments.grammar ) ); + var itemType = inferSqlType( item, arguments.grammar ); + if ( !hasInferredType ) { + inferredType = itemType; + hasInferredType = true; + } else if ( itemType != inferredType ) { + return "VARCHAR"; + } } - return arraySame( inferredTypes, "VARCHAR" ); + return hasInferredType ? inferredType : "VARCHAR"; } if ( isStruct( value ) ) { @@ -662,7 +669,10 @@ component singleton displayname="QueryUtils" accessors="true" { } private string function normalizeSqlType( required string sqltype ) { - return reReplaceNoCase( trim( arguments.sqltype ), "^cf_sql_", "" ).uCase(); + var normalizedSqlType = trim( arguments.sqltype ).uCase(); + return left( normalizedSqlType, 7 ) == "CF_SQL_" + ? right( normalizedSqlType, len( normalizedSqlType ) - 7 ) + : normalizedSqlType; } /** @@ -834,26 +844,24 @@ component singleton displayname="QueryUtils" accessors="true" { * @return query */ public query function queryRemoveColumns( required query q, required string columns ) { - var columnsToRemove = arguments.columns.listToArray(); + var columnsToRemove = {}; + for ( var columnToRemove in arguments.columns.listToArray() ) { + columnsToRemove[ columnToRemove ] = true; + } + var queryColumnInfo = []; if ( isPureBoxLang() ) { - for ( var name in q.getColumnNames() ) { + for ( var name in arguments.q.getColumnNames() ) { queryColumnInfo.append( { "name": name, "TypeName": "varchar" } ); } } else { - queryColumnInfo = getMetadata( q ); - } - var queryAsArray = queryToArrayOfStructs( q ); - for ( var row in queryAsArray ) { - for ( var col in columnsToRemove ) { - structDelete( row, col ); - } + queryColumnInfo = getMetadata( arguments.q ); } var newColumns = []; var newColumnTypes = []; for ( var column in queryColumnInfo ) { - if ( arrayContainsNoCase( columnsToRemove, column.name ) ) { + if ( columnsToRemove.keyExists( column.name ) ) { continue; } newColumns.append( column.name ); @@ -872,7 +880,19 @@ component singleton displayname="QueryUtils" accessors="true" { } } - return queryNew( newColumns.toList(), newColumnTypes.toList(), queryAsArray ); + var queryRows = []; + if ( arguments.q.recordCount > 0 ) { + arrayResize( queryRows, arguments.q.recordCount ); + } + for ( var queryRow in arguments.q ) { + var rowData = structNew( "ordered" ); + for ( var retainedColumn in newColumns ) { + rowData[ retainedColumn ] = queryRow[ retainedColumn ]; + } + queryRows[ arguments.q.currentRow ] = rowData; + } + + return queryNew( newColumns.toList(), newColumnTypes.toList(), queryRows ); } /** @@ -890,37 +910,6 @@ component singleton displayname="QueryUtils" accessors="true" { return arguments.value; } - /** - * Returns the first value if every element in the array is the same. - * Otherwise, it returns the default value. - * - * @args The array of elements. - * @defaultValue The default value to return if the array does not return all the same values. Default: "". - * - * @return any - */ - private any function arraySame( required array args, any defaultValue = "" ) { - if ( arrayLen( arguments.args ) == 0 ) { - return arguments.defaultValue; - } - - if ( isNull( arguments.args[ 1 ] ) ) { - return arguments.defaultValue; - } - var initial = arguments.args[ 1 ]; - - for ( var i = 1; i <= arguments.args.len(); i++ ) { - if ( - isNull( arguments.args[ i ] ) || - arguments.args[ i ] != initial - ) { - return defaultValue; - } - } - - return initial; - } - /** * Detects if a value is backed by a numeric type instead of a numeric string. * diff --git a/tests/specs/Query/PerformanceOptimizationRegressionSpec.cfc b/tests/specs/Query/PerformanceOptimizationRegressionSpec.cfc index c59b2d4d..8429bef5 100644 --- a/tests/specs/Query/PerformanceOptimizationRegressionSpec.cfc +++ b/tests/specs/Query/PerformanceOptimizationRegressionSpec.cfc @@ -124,6 +124,107 @@ component extends="testbox.system.BaseSpec" { expect( utils.queryToStructOfStructs( queryNew( "name", "varchar" ), "id" ) ).toBe( {} ); } ); + it( "projects retained query columns without mutating the source query", function() { + var utils = new qb.models.Query.QueryUtils(); + var source = queryNew( + "id,name,age", + "integer,varchar,integer", + [ { id: 1, name: "Ada", age: 36 }, { id: 2, name: "Grace", age: 85 } ] + ); + + var result = utils.queryRemoveColumns( source, "NaMe" ); + + expect( listLen( result.columnList ) ).toBe( 2 ); + expect( listFindNoCase( result.columnList, "id" ) > 0 ).toBeTrue(); + expect( listFindNoCase( result.columnList, "age" ) > 0 ).toBeTrue(); + expect( listFindNoCase( result.columnList, "name" ) ).toBe( 0 ); + expect( result.recordCount ).toBe( 2 ); + expect( result.id[ 1 ] ).toBe( 1 ); + expect( result.id[ 2 ] ).toBe( 2 ); + expect( result.age[ 1 ] ).toBe( 36 ); + expect( result.age[ 2 ] ).toBe( 85 ); + expect( listLen( source.columnList ) ).toBe( 3 ); + expect( listFindNoCase( source.columnList, "name" ) > 0 ).toBeTrue(); + expect( source.name[ 1 ] ).toBe( "Ada" ); + expect( source.name[ 2 ] ).toBe( "Grace" ); + } ); + + it( "flattens requested binding groups in order while honoring exclusions", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.PostgresGrammar() ); + builder.addBindings( { value: "selected" }, "select" ); + builder.addBindings( { value: "joined" }, "join" ); + builder.addBindings( { value: "filtered" }, "where" ); + + var bindings = builder.getBindings( except = [ "SELECT" ], order = [ "select", "join", "where" ] ); + + expect( bindings.map( ( binding ) => binding.value ) ).toBe( [ "joined", "filtered" ] ); + } ); + + it( "wraps simple identifiers through the fast path and preserves alias parsing", function() { + var grammar = new qb.models.Grammars.PostgresGrammar(); + + expect( grammar.wrapColumn( { type: "simple", value: "accounts.users.id" } ) ).toBe( + """accounts"".""users"".""id""" + ); + expect( grammar.wrapColumn( { type: "simple", value: "users.id AS userId" } ) ).toBe( + """users"".""id"" AS ""userId""" + ); + expect( grammar.wrapColumn( { type: "simple", value: "users.id#chr( 9 )#userId" } ) ).toBe( + """users"".""id"" AS ""userId""" + ); + } ); + + it( "normalizes prefixed SQL types without changing public results", function() { + var utils = new qb.models.Query.QueryUtils(); + var grammar = new qb.models.Grammars.BaseGrammar( utils ); + + expect( utils.inferSqlType( { cfsqltype: " cf_sql_bigint " }, grammar ) ).toBe( "BIGINT" ); + expect( utils.inferSqlType( { sqltype: " Decimal " }, grammar ) ).toBe( "DECIMAL" ); + expect( grammar.resolveWhereInBulkSqlType( " cf_sql_varchar " ) ).toBe( "VARCHAR" ); + expect( grammar.resolveWhereInBulkSqlType( " timestamp " ) ).toBe( "TIMESTAMP" ); + } ); + + it( "infers array SQL types without retaining per-item type results", function() { + var utils = new qb.models.Query.QueryUtils(); + var grammar = new qb.models.Grammars.PostgresGrammar( utils ); + var sparseValues = []; + arrayResize( sparseValues, 4 ); + sparseValues[ 1 ] = 1; + sparseValues[ 3 ] = 3; + sparseValues[ 4 ] = { null: true }; + + expect( utils.inferSqlType( sparseValues, grammar ) ).toBe( "INTEGER" ); + expect( utils.inferSqlType( [ 1, "mixed" ], grammar ) ).toBe( "VARCHAR" ); + expect( utils.inferSqlType( [], grammar ) ).toBe( "VARCHAR" ); + } ); + + it( "appends scalar and array binding inputs without changing their order", function() { + var builder = new qb.models.Query.QueryBuilder( grammar = new qb.models.Grammars.PostgresGrammar() ); + + builder.addBindings( { value: "first" }, "where" ); + builder.addBindings( [ { value: "second" }, { value: "third" } ], "where" ); + + expect( builder.getBindings( order = [ "where" ] ).map( ( binding ) => binding.value ) ).toBe( [ "first", "second", "third" ] ); + } ); + + it( "normalizes a single column without splitting and preserves list behavior", function() { + var builder = new qb.models.Query.QueryBuilder(); + + expect( builder.normalizeToArray( " users.id " ) ).toBe( [ "users.id" ] ); + expect( builder.normalizeToArray( "users.id, users.name" ) ).toBe( [ "users.id", "users.name" ] ); + expect( builder.normalizeToArray( "" ) ).toBe( [ "" ] ); + } ); + + it( "wraps simple table names without changing prefix or alias behavior", function() { + var grammar = new qb.models.Grammars.PostgresGrammar(); + + expect( grammar.wrapTable( "analytics.users" ) ).toBe( """analytics"".""users""" ); + grammar.setTablePrefix( "qb_" ); + expect( grammar.wrapTable( "analytics.users" ) ).toBe( """analytics"".""qb_users""" ); + grammar.setTablePrefix( "" ); + expect( grammar.wrapTable( "users AS u" ) ).toBe( """users"" AS ""u""" ); + } ); + it( "does not resolve the target grammar when there are no nested common tables", function() { var grammar = createMock( "qb.models.Grammars.BaseGrammar" ) .init()