diff --git a/.github/patches/testbox-full-null.patch b/.github/patches/testbox-full-null.patch new file mode 100644 index 00000000..b76c66ce --- /dev/null +++ b/.github/patches/testbox-full-null.patch @@ -0,0 +1,69 @@ +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 +@@ -175 +175 @@ +- if ( isNull( opts.coverageTresholds ) ) { ++ if ( !structKeyExists( opts, "coverageTresholds" ) || isNull( opts.coverageTresholds ) ) { +@@ -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/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/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 ) ) { diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index b4b7871d..24e43ed0 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" @@ -40,8 +38,11 @@ jobs: - name: Install dependencies run: | box install + git apply --unidiff-zero .github/patches/testbox-full-null.patch - 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 @@ -50,4 +51,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 reporter=mintext diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 45804fe8..45b60e71 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -23,12 +23,12 @@ jobs: experimental: [ false ] fullNull: ["true", "false"] include: - - cfengine: "lucee@be" - experimental: true - cfengine: "adobe@be" experimental: true + fullNull: "true" - cfengine: "boxlang@be" experimental: true + fullNull: "true" steps: - name: Checkout Repository uses: actions/checkout@v7 @@ -48,8 +48,11 @@ jobs: - name: Install dependencies run: | box install + git apply --unidiff-zero .github/patches/testbox-full-null.patch - 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 @@ -58,7 +61,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 @@ -80,9 +83,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 \ No newline at end of file + run: box run-script format:check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea7d51dc..87b76979 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,8 +36,11 @@ jobs: - name: Install dependencies run: | box install + git apply --unidiff-zero .github/patches/testbox-full-null.patch - 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 @@ -46,7 +49,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 @@ -104,4 +107,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/ModuleConfig.cfc b/ModuleConfig.cfc index 47148a83..46ed3f04 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -12,11 +12,14 @@ component { "defaultReturnFormat": "array", "preventDuplicateJoins": false, "validateOperatorsAndCombinators": true, + "validateDuplicateSelectColumns": false, + "validateQueryExecuteReturnType": false, "collectQueryLog": true, "convertEmptyStringsToNull": true, + "shouldWrapValues": true, "validateQueryParamStructKeys": true, - "numericSQLType": "NUMERIC", "integerSQLType": "INTEGER", + "bigIntegerSQLType": "BIGINT", "decimalSQLType": "DECIMAL", "defaultOptions": {}, "sqlCommenter": { @@ -29,7 +32,8 @@ component { }, "shouldMaxRowsOverrideToAll": function( maxRows ) { return maxRows <= 0; - } + }, + "returnFormatters": {} }; interceptorSettings = { "customInterceptionPoints": "preQBExecute,postQBExecute" }; @@ -51,17 +55,26 @@ 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 = "bigIntegerSQLType", value = settings.bigIntegerSQLType ) .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 = "validateDuplicateSelectColumns", value = settings.validateDuplicateSelectColumns ) + .initArg( name = "validateQueryExecuteReturnType", value = settings.validateQueryExecuteReturnType ) .initArg( name = "collectQueryLog", value = settings.collectQueryLog ) .initArg( name = "returnFormat", value = settings.defaultReturnFormat ) .initArg( name = "defaultOptions", value = settings.defaultOptions ) @@ -71,7 +84,15 @@ 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 + // onMissingMethod to whatever concrete grammar AutoDiscover resolves at runtime. + if ( structKeyExists( settings, "shouldWrapValues" ) ) { + wirebox.getInstance( settings.defaultGrammar ).setShouldWrapValues( settings.shouldWrapValues ); + } } } diff --git a/README.md b/README.md index 3278a2ff..322019df 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,88 @@ 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: + +```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: + +```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 +200,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/Grammars/AutoDiscover.cfc b/models/Grammars/AutoDiscover.cfc index 20914b70..3491d41d 100644 --- a/models/Grammars/AutoDiscover.cfc +++ b/models/Grammars/AutoDiscover.cfc @@ -2,34 +2,59 @@ 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; } - function onMissingMethod( missingMethodName, missingMethodArguments ) { - if ( isNull( variables.grammar ) || !structKeyExists( variables, "grammar" ) ) { + public AutoDiscover function setShouldWrapValues( required boolean shouldWrapValues ) { + variables.shouldWrapValues = arguments.shouldWrapValues; + if ( structKeyExists( variables, "grammar" ) && !isNull( variables.grammar ) ) { + variables.grammar.setShouldWrapValues( arguments.shouldWrapValues ); + } + return this; + } + + public any function getResolvedGrammar() { + if ( !structKeyExists( variables, "grammar" ) || isNull( variables.grammar ) ) { variables.grammar = autoDiscoverGrammar(); + if ( structKeyExists( variables, "shouldWrapValues" ) && !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 d61bfa48..dc95756c 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. */ @@ -60,12 +66,15 @@ 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 "; 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() { @@ -83,6 +92,56 @@ 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 ) { + if ( !arguments.query.getRawBindings().update.isEmpty() ) { + return getUpdateBindingOrder( arguments.query ); + } + + return [ + "commonTables", + "update", + "insert", + "aggregate", + "select", + "from", + "join", + "where", + "groupBy", + "having", + "union", + "orderBy" + ]; + } + + /** + * 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. + */ + 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. @@ -108,7 +167,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 }; @@ -134,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 ) }; } @@ -174,17 +235,31 @@ 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() ) ) { 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 ) ); } @@ -292,11 +367,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 ) { @@ -443,7 +514,28 @@ 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 ) { + 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 )# ?"; } /** @@ -576,11 +668,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() : ( - isSimpleValue( 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() : ( - isSimpleValue( 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#"; } @@ -594,7 +690,17 @@ 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 = !where.keyExists( "start" ) || isNull( where.start ) ? "?" : ( + variables.utils.isExpression( where.start ) ? where.start.getSql() : ( + variables.utils.isBuilder( where.start ) ? "(#compileSelect( where.start )#)" : "?" + ) + ); + 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#"; } /** @@ -606,11 +712,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"; } @@ -626,17 +728,69 @@ 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. + * + * @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. * @@ -704,7 +858,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#" ); } @@ -873,6 +1029,95 @@ component displayname="Grammar" accessors="true" singleton { } } + /** + * Whether this grammar provides a native bulk insert strategy. + */ + public boolean function supportsBulkInsert() { + 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. + * + * @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 = 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 ) { + 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. * @@ -899,6 +1144,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. * @@ -924,9 +1192,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() ) ) { @@ -971,7 +1240,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() ); @@ -995,6 +1264,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", @@ -1015,7 +1290,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 ); @@ -1047,13 +1322,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() ) ) { @@ -1137,20 +1405,40 @@ 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 ) }; // 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 ] ) ); } } @@ -1173,23 +1461,16 @@ component displayname="Grammar" accessors="true" singleton { return trim( wrapTable( "(#arguments.column.value.toSQL()#) AS #arguments.column.alias#" ) ); } - 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, " " ); + if ( arguments.column.type == "jsonPath" ) { + var jsonSql = compileJsonScalar( arguments.column.value ); + return arguments.column.keyExists( "alias" ) + ? jsonSql & " AS " & wrapValue( arguments.column.alias ) + : jsonSql; } + + var columnParts = explodeColumnAlias( arguments.column.value ); + arguments.column = columnParts.column; + var alias = columnParts.alias; arguments.column = arguments.column .listToArray( "." ) .map( wrapValue ) @@ -1200,6 +1481,92 @@ 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 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. + */ + 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( any value ) { + if ( isNull( arguments.value ) ) { + return javacast( "null", "" ); + } + if ( !isNull( arguments.value ) && !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 ( getUtils().isActuallyNumeric( segment ) ) { + compiledPath &= "[#segment#]"; + } else { + var escapedSegment = replace( + segment, + chr( 92 ), + chr( 92 ) & chr( 92 ), + "all" + ); + escapedSegment = replace( + escapedSegment, + """", + chr( 92 ) & """", + "all" + ); + compiledPath &= ".""#escapedSegment#"""; + } + } + return replace( compiledPath, "'", "''", "all" ); + } + /** * Extracts the alias from a column. Returns the column if no alias is found. * @@ -1210,26 +1577,73 @@ 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 ); } - 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 ); } /** @@ -1240,7 +1654,7 @@ component displayname="Grammar" accessors="true" singleton { * @return string */ function wrapValue( required any value ) { - if ( !variables.shouldWrapValues ) { + if ( !getShouldWrapValues() ) { return arguments.value; } @@ -1252,9 +1666,17 @@ component displayname="Grammar" accessors="true" singleton { return arguments.value; } - arguments.value = reReplace( 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 ); + } + normalizedValue = replace( normalizedValue, """", """""", "all" ); - return """#value#"""; + return """#normalizedValue#"""; } /** @@ -1362,14 +1784,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 ) { @@ -1377,10 +1809,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 ======*/ /*======================================= @@ -1452,13 +1906,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( @@ -1466,8 +1920,6 @@ component displayname="Grammar" accessors="true" singleton { ", " ); - blueprint.setIndexes( existingIndexes ); - return concatenate( [ "ALTER TABLE", wrapTable( blueprint.getTable() ), @@ -1475,6 +1927,7 @@ component displayname="Grammar" accessors="true" singleton { body ] ); } finally { + blueprint.setIndexes( existingIndexes ); if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); } @@ -1676,6 +2129,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()#)"; } @@ -1724,7 +2181,7 @@ component displayname="Grammar" accessors="true" singleton { var values = column .getValues() .map( function( value ) { - return "'#value#'"; + return quoteStringLiteral( value ); } ) .toList( ", " ); return "ENUM(#values#)"; @@ -2019,7 +2476,7 @@ component displayname="Grammar" accessors="true" singleton { var values = column .getValues() .map( function( val ) { - return "'#val#'"; + return quoteStringLiteral( val ); } ) .toList( ", " ); return concatenate( [ @@ -2032,6 +2489,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 != "" ) { @@ -2053,6 +2522,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." ); } @@ -2063,8 +2536,63 @@ 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; } + /** + * 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. + */ + public void function pushShouldWrapValuesContext( any shouldWrap ) { + var context = variables.shouldWrapValuesContext.get(); + if ( isNull( context ) ) { + context = []; + variables.shouldWrapValuesContext.set( context ); + } + + 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 { + popShouldWrapValuesContext(); + } + } + } diff --git a/models/Grammars/DerbyGrammar.cfc b/models/Grammars/DerbyGrammar.cfc index 6ea847b7..c88ca438 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. * @@ -191,6 +173,18 @@ 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", + message = "This grammar does not support UPDATE statements with Common Table Expressions." + ); + } if ( !query.getJoins().isEmpty() ) { throw( type = "UnsupportedOperation", @@ -221,7 +215,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() ); @@ -235,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, @@ -243,7 +251,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 +296,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 ) ) { @@ -339,7 +344,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 ""; } @@ -369,19 +374,27 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { * @return string */ function wrapValue( required any value ) { - if ( !variables.shouldWrapValues ) { + if ( !getShouldWrapValues() ) { return arguments.value; } if ( len( arguments.value ) == 0 || - arguments.value == "*" || - left( arguments.value, 1 ) == """" + arguments.value == "*" ) { return arguments.value; } - return """#arguments.value#"""; + var normalizedValue = toString( arguments.value ); + if ( + len( normalizedValue ) >= 2 && + left( normalizedValue, 1 ) == """" && + right( normalizedValue, 1 ) == """" + ) { + normalizedValue = mid( normalizedValue, 2, len( normalizedValue ) - 2 ); + } + normalizedValue = replace( normalizedValue, """", """""", "all" ); + return """#normalizedValue#"""; } function compileCreateAs( blueprint, commandParameters ) { @@ -456,13 +469,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 ) ], ", " ); @@ -471,8 +484,6 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { blueprint.addConstraint( index ); } - blueprint.setIndexes( originalIndexes ); - return concatenate( [ "ALTER TABLE", wrapTable( blueprint.getTable() ), @@ -480,6 +491,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { body ] ); } finally { + blueprint.setIndexes( originalIndexes ); if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); } @@ -574,12 +586,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(); } @@ -617,7 +629,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)"; @@ -728,7 +740,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; } @@ -740,7 +752,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; } @@ -752,7 +764,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 )#"; } ); @@ -763,4 +775,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 5f93f04d..bd14351c 100644 --- a/models/Grammars/MySQLGrammar.cfc +++ b/models/Grammars/MySQLGrammar.cfc @@ -1,5 +1,80 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { + public string function compileUpdate( + required QueryBuilder query, + 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 ) { + 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 ) { + 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 )#'))"; + } + + 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( any value ) { + return isNull( arguments.value ) ? "null" : serializeJSON( arguments.value ); + } + private string function orderByRandom() { return "RAND()"; } @@ -12,7 +87,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { * @return string */ function wrapValue( required any value ) { - if ( !variables.shouldWrapValues ) { + if ( !getShouldWrapValues() ) { return arguments.value; } @@ -20,9 +95,30 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { return value; } - arguments.value = reReplace( 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( normalizedValue ) >= 2 && + left( normalizedValue, 1 ) == quote && + right( normalizedValue, 1 ) == quote + ) { + normalizedValue = mid( normalizedValue, 2, len( normalizedValue ) - 2 ); + } + normalizedValue = replace( + normalizedValue, + quote, + quote & quote, + "all" + ); - return "`#value#`"; + return "#quote##normalizedValue##quote#"; } /** @@ -33,7 +129,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 ) { @@ -43,11 +139,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() ) ) { @@ -78,7 +179,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 ); @@ -103,9 +204,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" @@ -117,7 +219,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; } @@ -187,10 +292,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 ); @@ -206,12 +312,27 @@ 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", 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(); @@ -219,18 +340,25 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { setShouldWrapValues( arguments.query.getShouldWrapValues() ); } - var hasJoins = !arguments.query.getJoins().isEmpty(); - return trim( arrayToList( arrayFilter( [ + 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() ) + compileWheres( query, query.getWheres() ), + hasJoins ? "" : compileOrders( query, query.getOrders() ), + hasJoins ? "" : compileLimitValue( query, query.getLimitValue() ) ], function( sql ) { return sql != ""; @@ -254,8 +382,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" ); } @@ -311,7 +446,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { function generateDefault( column ) { if ( - column.getDefaultValue() == "" && + !column.getHasDefaultValue() && column.getType().findNoCase( "TIMESTAMP" ) > 0 ) { if ( column.getIsNullable() ) { @@ -324,12 +459,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 3401a755..94e0b3d7 100644 --- a/models/Grammars/OracleGrammar.cfc +++ b/models/Grammars/OracleGrammar.cfc @@ -1,5 +1,65 @@ 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 )#')"; + } + + public string function compileJsonContains( required struct jsonPath ) { + return "JSON_EXISTS(#wrapJsonColumn( arguments.jsonPath )#, '#buildJsonPath( arguments.jsonPath.path )#[*]?(@ == $value)' PASSING ? AS ""value"")"; + } + + public any function prepareJsonContainsBinding( 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 )#')"; + } + + 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. * @@ -27,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; @@ -188,6 +251,18 @@ 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", + message = "This grammar does not support UPDATE statements with Common Table Expressions." + ); + } if ( !query.getJoins().isEmpty() ) { throw( type = "UnsupportedOperation", @@ -197,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, @@ -205,7 +294,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" ); @@ -249,11 +339,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 ) ) { @@ -348,19 +434,23 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { * @return string */ function wrapValue( required any value ) { - if ( !variables.shouldWrapValues ) { + if ( !getShouldWrapValues() ) { 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 normalizedValue = toString( arguments.value ); + var isQuoted = len( normalizedValue ) >= 2 && left( normalizedValue, 1 ) == """" && right( normalizedValue, 1 ) == """"; + if ( isQuoted ) { + normalizedValue = mid( normalizedValue, 2, len( normalizedValue ) - 2 ); + } else { + normalizedValue = uCase( normalizedValue ); + } + normalizedValue = replace( normalizedValue, """", """""", "all" ); + return """#normalizedValue#"""; } function compileCreateColumn( column, blueprint ) { @@ -431,13 +521,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 ) ], ", " ); @@ -446,8 +536,6 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { blueprint.addConstraint( index ); } - blueprint.setIndexes( originalIndexes ); - return concatenate( [ "ALTER TABLE", wrapTable( blueprint.getTable() ), @@ -455,6 +543,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { body ] ); } finally { + blueprint.setIndexes( originalIndexes ); if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); } @@ -560,15 +649,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 ""; @@ -589,12 +686,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(); } @@ -632,7 +729,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)"; @@ -764,6 +861,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 != "" ) { @@ -789,15 +898,33 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { var statements = [ "DROP TABLE #wrapTable( arguments.blueprint.getTable() )#" ]; - var sequenceName = "SEQ_#uCase( arguments.blueprint.getTable() )#"; - if ( hasSequence( arguments.blueprint, sequenceName ) ) { - statements.append( "DROP SEQUENCE #wrapTable( sequenceName )#" ); - } + 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#"; + 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_#uCase( arguments.blueprint.getTable() )#"; - if ( hasTrigger( arguments.blueprint, triggerName ) ) { - statements.append( "DROP TRIGGER #wrapTable( triggerName )#" ); - } + var triggerName = "TRG_#table#"; + 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 { @@ -807,36 +934,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 66847823..bbf1fd96 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -1,5 +1,89 @@ 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 ); + } + + 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"; + } + + 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( any value ) { + return isNull( arguments.value ) ? "null" : 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(); + 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( + segment, + "'", + "''", + "all" + ) & "'"; + sql &= operator & pathSegment; + } ); + return sql; + } + /** * Creates a new Postgres Query Grammar. * @@ -85,7 +169,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; } /** @@ -101,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() ) ) { @@ -120,7 +215,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(); @@ -137,25 +232,39 @@ 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 ]; - 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( 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( "#updateStatement# #compileJoins( arguments.query, restJoins )# #whereStatement##returningClause#" ); + return trim( + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & returningClause + ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); @@ -163,6 +272,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. * @@ -171,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", @@ -189,7 +322,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 #wrapQueryTable( query )# #compileWheres( query, query.getWheres() )##returningClause#" + ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); @@ -205,8 +340,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" ); } @@ -217,7 +359,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 @@ -340,6 +482,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; @@ -360,9 +506,6 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { switch ( column.getType() ) { case "boolean": return uCase( defaultValue ); - case "char": - case "string": - return "'#defaultValue#'"; default: return defaultValue; } @@ -485,7 +628,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 ) { @@ -513,16 +668,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; } @@ -585,6 +737,10 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { = Column Types = ===================================*/ + function typeBinary( column ) { + return "BYTEA"; + } + function typeBoolean( column ) { return "BOOLEAN"; } @@ -610,7 +766,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 ) { @@ -754,9 +911,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 4b318090..d8d45be7 100644 --- a/models/Grammars/SQLiteGrammar.cfc +++ b/models/Grammars/SQLiteGrammar.cfc @@ -1,5 +1,60 @@ 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 )#')"; + } + + 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. * @@ -137,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(); @@ -145,34 +200,56 @@ 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 = arrayMap( [ returningClause, compileOrders( query, query.getOrders() ), rowLimitClause ], function( clause ) { + return trim( clause ); + } ); + trailingClauses = arrayFilter( trailingClauses, function( clause ) { + return clause != ""; + } ); + trailingClauses = arrayToList( trailingClauses, " " ); if ( joins.isEmpty() ) { - return updateStatement & returningClause; + return trim( + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & " " & trailingClauses + ); } - 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( 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( "#updateStatement# #compileJoins( arguments.query, restJoins )# #whereStatement##returningClause#" ); + return trim( + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & " " & trailingClauses + ); } finally { if ( !isNull( arguments.query.getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); @@ -180,6 +257,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. * @@ -206,7 +297,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 #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() ) ) { setShouldWrapValues( originalShouldWrapValues ); @@ -222,8 +315,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" ); } @@ -234,7 +334,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 @@ -293,12 +393,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(); } @@ -429,7 +529,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#))"; @@ -440,7 +540,7 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { function generateDefault( column ) { if ( - column.getDefaultValue() == "" && + !column.getHasDefaultValue() && column.getType().findNoCase( "TIMESTAMP" ) > 0 ) { if ( column.getIsNullable() ) { @@ -473,6 +573,37 @@ 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." + ); + } + 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." + ); + } + 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(); if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { @@ -500,6 +631,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()#", @@ -527,7 +664,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 ); @@ -542,7 +680,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 61d994b6..99a38451 100644 --- a/models/Grammars/SqlServerGrammar.cfc +++ b/models/Grammars/SqlServerGrammar.cfc @@ -1,5 +1,254 @@ 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.value.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" + ); + escapedPath = replace( escapedPath, "'", "''", "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# '$')"; + } + + 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 )#')"; + } + + 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 )#'))"; + } + + 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 )#'))"; + } + + /** + * 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 a UNION statement.", + 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() ) { + return false; + } + + 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 ); + } + + private boolean function isLimitedQuery( required QueryBuilder query ) { + return !isNull( arguments.query.getLimitValue() ) || !isNull( arguments.query.getOffsetValue() ); + } + /** * The parameter limit for SQL Server grammar. */ @@ -75,7 +324,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; @@ -279,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; } @@ -287,7 +536,10 @@ 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#]"; } @@ -301,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() ) ) { @@ -321,7 +579,9 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { .toList( ", " ); var updateTable = ""; - if ( !getUtils().isExpression( query.getTableName() ) ) { + if ( 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 ); } else { @@ -349,15 +609,23 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { .toList( ", " ); var returningClause = returningColumns != "" ? " OUTPUT #returningColumns#" : ""; - if ( arguments.query.getJoins().isEmpty() ) { - return trim( updateStatement & returningClause & " " & compileWheres( query, query.getWheres() ) ); + if ( arguments.query.getJoins().isEmpty() && arguments.query.getAlias() == "" ) { + return trim( + compileCommonTables( query, query.getCommonTables() ) & " " & updateStatement & returningClause & " " & compileWheres( + query, + query.getWheres() + ) + ); } return trim( - updateStatement & returningClause & " FROM #wrapTable( query.getTableName() )# " & 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() ) ) { @@ -374,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() ) ) { @@ -395,16 +669,46 @@ 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() != ""; + var topClause = isNull( arguments.query.getLimitValue() ) + ? "" + : "TOP (#arguments.query.getLimitValue()#)"; + + 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 != ""; + } + ), + " " + ) + ); + } return trim( arrayToList( arrayFilter( [ + compileCommonTables( query, query.getCommonTables() ), "DELETE", - hasJoins ? wrapTable( query.getTableName() ) : "", - "FROM", - wrapTable( query.getTableName() ), + topClause, + hasAlias + ? wrapAlias( getTablePrefix() & query.getAlias() ) + : wrapTable( query.getTableName(), false ), returningClause, + "FROM", + wrapQueryTable( query ), hasJoins ? compileJoins( query, query.getJoins() ) : "", compileWheres( query, query.getWheres() ) ], @@ -430,7 +734,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(); @@ -465,11 +770,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 ) ) { @@ -514,7 +815,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; @@ -524,8 +825,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 ); @@ -561,16 +864,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_#listLast( 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(); } @@ -580,6 +883,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(); @@ -588,12 +915,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 ); @@ -601,6 +923,117 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { } } + /** + * 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 ); + 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 && !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++; + } + + 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 ) { try { var originalShouldWrapValues = getShouldWrapValues(); @@ -627,9 +1060,9 @@ 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()#" )#" + "ALTER TABLE #wrapTable( blueprint.getTable() )# DROP CONSTRAINT #wrapValue( "df_#listLast( blueprint.getTable(), "." )#_#commandParameters.name.getName()#" )#" ); } return statements; @@ -648,7 +1081,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 ); @@ -663,7 +1096,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 ); @@ -678,7 +1111,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 ); @@ -686,6 +1119,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(); @@ -719,17 +1156,60 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { function compileModifyColumn( blueprint, commandParameters ) { 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() ); } - return concatenate( [ + if ( !originalHasDefaultValue ) { + return concatenate( [ + "ALTER TABLE", + wrapTable( blueprint.getTable() ), + "ALTER COLUMN", + compileCreateColumn( commandParameters.to, blueprint ) + ] ); + } + + commandParameters.to.setDefaultValue( "" ); + commandParameters.to.setHasDefaultValue( false ); + + 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 ); + 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))", + alterColumnSql, + concatenate( [ + "ALTER TABLE", + wrappedTable, + "ADD CONSTRAINT", + wrapValue( "df_#listLast( blueprint.getTable(), "." )#_#commandParameters.to.getName()#" ), + "DEFAULT", + wrapDefaultType( commandParameters.to ), + "FOR", + wrappedColumn + ] ) + ]; } finally { + commandParameters.to.setDefaultValue( originalDefaultValue ); + commandParameters.to.setHasDefaultValue( originalHasDefaultValue ); if ( !isNull( arguments.blueprint.getSchemaBuilder().getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); } @@ -737,16 +1217,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; } @@ -765,12 +1246,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#" @@ -794,6 +1276,10 @@ component extends="qb.models.Grammars.BaseGrammar" singleton accessors="true" { return "BIGINT"; } + function typeBinary( column ) { + return "VARBINARY(MAX)"; + } + function typeBit( column ) { return "BIT"; } @@ -821,7 +1307,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/AliasRewriter.cfc b/models/Query/AliasRewriter.cfc new file mode 100644 index 00000000..5b0fd0e1 --- /dev/null +++ b/models/Query/AliasRewriter.cfc @@ -0,0 +1,429 @@ +/** + * 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 ); + renameAliasesInUpdates( 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 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, + 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.where.keyExists( "start" ) && + !isNull( arguments.where.start ) && + arguments.builder.getUtils().isBuilder( arguments.where.start ) + ) { + renameAliasesInNestedQuery( + arguments.builder, + arguments.where.start, + arguments.oldAlias, + arguments.newAlias + ); + } + if ( + arguments.where.keyExists( "end" ) && + !isNull( arguments.where.end ) && + 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/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/JoinClause.cfc b/models/Query/JoinClause.cfc index cccd2b25..66db2eef 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,9 +81,31 @@ 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() ); - + 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/JoinClauseManager.cfc b/models/Query/JoinClauseManager.cfc new file mode 100644 index 00000000..459f0738 --- /dev/null +++ b/models/Query/JoinClauseManager.cfc @@ -0,0 +1,291 @@ +/** + * 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 + .getQueryExecutor() + .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.getQueryExecutor().captureCommonTableState( arguments.builder ); + try { + arguments.first( join ); + } catch ( any e ) { + arguments.builder.getQueryExecutor().restoreCommonTableState( arguments.builder, commonTableState ); + rethrow; + } + if ( arguments.preventDuplicateJoins && containsJoin( arguments.builder, join ) ) { + arguments.builder.getQueryExecutor().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 ) { + 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 ) { + return crossJoin( arguments.builder, arguments.builder.raw( arguments.table ) ); + } + + /** + * 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.getQueryExecutor(); + 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.getQueryExecutor(); + 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.getQueryExecutor(); + 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 result = crossJoin( arguments.builder, table ); + 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; + } + } + + /** + * 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( bindings, "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/JsonQueryBuilderSupport.cfc b/models/Query/JsonQueryBuilderSupport.cfc new file mode 100644 index 00000000..f2d66174 --- /dev/null +++ b/models/Query/JsonQueryBuilderSupport.cfc @@ -0,0 +1,179 @@ +/** + * 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 + * syntax is accepted as a shortcut and is normalized to the same shape. + * + * @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 ) { + return getCollaborator( "JsonQueryClause" ).jsonPath( + builder = this, + column = arguments.column, + path = arguments.path, + alias = arguments.keyExists( "alias" ) ? arguments.alias : "" + ); + } + + /** + * 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 ( getValidateOperatorsAndCombinators() ) { + getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); + } + + // 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: valueWasOmitted ? pathIsNull : isNull( arguments.value ) }; + if ( !valueDefinition.isNull ) { + valueDefinition.value = valueWasOmitted ? arguments.path : arguments.value; + } + + if ( valueWasOmitted || pathIsNull ) { + arguments.path = []; + } + + 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 ) { + 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 ( getValidateOperatorsAndCombinators() ) { + getCollaborator( "QueryValidator" ).validateCombinator( arguments.combinator ); + } + 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 = [] ) { + 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 ( getValidateOperatorsAndCombinators() ) { + getCollaborator( "QueryValidator" ).validateCombinator( arguments.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 ( getValidateOperatorsAndCombinators() ) { + getCollaborator( "QueryValidator" ).validateOperator( arguments.operator ); + } + + var valueDefinition = { isNull: isNull( arguments.value ) }; + if ( !valueDefinition.isNull ) { + valueDefinition.value = arguments.value; + } + return getCollaborator( "JsonQueryClause" ).whereJsonLength( + builder = this, + column = arguments.column, + path = arguments.path, + operator = arguments.operator, + valueDefinition = valueDefinition, + combinator = arguments.combinator + ); + } + + public QueryBuilder function orWhereJsonLength( + required string column, + any path = [], + any operator, + any value + ) { + return whereJsonLength( argumentCollection = arguments, combinator = "or" ); + } + +} diff --git a/models/Query/JsonQueryClause.cfc b/models/Query/JsonQueryClause.cfc new file mode 100644 index 00000000..a9f4062c --- /dev/null +++ b/models/Query/JsonQueryClause.cfc @@ -0,0 +1,158 @@ +/** + * 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; + 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( { + type: "jsonContains", + path: containsPath, + combinator: arguments.combinator, + negate: arguments.negate + } ); + arguments.builder.addBindings( binding, "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" + ) { + 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( { + type: "jsonLength", + path: jsonPath( builder = arguments.builder, column = arguments.column, path = arguments.path ), + operator: arguments.operator, + combinator: arguments.combinator + } ); + arguments.builder.addBindings( binding, "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/PredicateClause.cfc b/models/Query/PredicateClause.cfc new file mode 100644 index 00000000..86ee294d --- /dev/null +++ b/models/Query/PredicateClause.cfc @@ -0,0 +1,702 @@ +/** + * 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 arguments.builder.whereNested( 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 ); + var bindings = arguments.values.isEmpty() ? [] : arguments.builder.extractColumnBindings( [ typedColumn ] ); + 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 { + bindings.append( arguments.builder.getUtils().extractBinding( value, arguments.builder.getGrammar() ) ); + } + } + + arguments.builder + .getWheres() + .append( { + type: type, + column: typedColumn, + values: arguments.values, + combinator: arguments.combinator + } ); + 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 ); + var columnBindings = arguments.values.isEmpty() + ? [] + : arguments.builder.extractColumnBindings( [ typedColumn ] ); + 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() ) { + var serializedValues = extractedBindings.map( function( binding ) { + return binding.null ? javacast( "null", "" ) : binding.value; + } ); + arguments.builder.addBindings( columnBindings, "where" ); + 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 ); + var bindings = arguments.builder.extractColumnBindings( [ firstColumn, secondColumn ] ); + arguments.builder + .getWheres() + .append( { + type: "column", + first: firstColumn, + operator: arguments.operator, + second: secondColumn, + combinator: arguments.combinator + } ); + arguments.builder.addBindings( bindings, "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.getQueryExecutor().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 ); + 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; + } + + /** + * 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.getQueryExecutor().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.getQueryExecutor().snapshotBuilder( arguments.builder, arguments.start ); + } + if ( !isNull( arguments.end ) && arguments.builder.getUtils().isBuilder( arguments.end ) ) { + arguments.end = arguments.builder.getQueryExecutor().snapshotBuilder( arguments.builder, arguments.end ); + } + + var bindings = arguments.builder.extractColumnBindings( [ 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 ) && + 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 + } ); + arguments.builder.addBindings( bindings, "where" ); + 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 ) + ) { + var expressionBindings = arguments.builder.extractExpressionBindings( arguments.column ); + arguments.builder + .getHavings() + .append( { type: "raw", column: arguments.column, combinator: arguments.combinator } ); + arguments.builder.addBindings( expressionBindings, "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 ); + } + + var typedColumn = toColumnType( arguments.builder, arguments.column ); + var bindings = arguments.builder.extractColumnBindings( [ typedColumn ] ); + bindings.append( + extractPredicateBindings( + builder = arguments.builder, + value = isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value + ), + true + ); + arguments.builder + .getHavings() + .append( { + type: "normal", + column: typedColumn, + operator: arguments.operator, + value: isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value, + combinator: arguments.combinator + } ); + arguments.builder.addBindings( bindings, "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 ); + var bindings = arguments.builder.extractColumnBindings( [ typedColumn ] ); + bindings.append( + extractPredicateBindings( + builder = arguments.builder, + value = isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value + ), + true + ); + arguments.builder + .getWheres() + .append( { + column: typedColumn, + operator: arguments.operator, + value: isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value, + combinator: arguments.combinator, + type: "basic" + } ); + arguments.builder.addBindings( bindings, "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 ); + } + var typedColumn = toColumnType( arguments.builder, arguments.column ); + var columnBindings = arguments.builder.extractColumnBindings( [ typedColumn ] ); + arguments.query = arguments.builder.getQueryExecutor().snapshotBuilder( arguments.builder, arguments.query ); + arguments.builder + .getWheres() + .append( { + type: "sub", + column: typedColumn, + operator: arguments.operator, + query: arguments.query, + combinator: arguments.combinator + } ); + arguments.builder.addBindings( columnBindings, "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 ); + } + var typedColumn = toColumnType( arguments.builder, arguments.column ); + var columnBindings = arguments.builder.extractColumnBindings( [ typedColumn ] ); + arguments.query = arguments.builder.getQueryExecutor().snapshotBuilder( arguments.builder, arguments.query ); + + var type = arguments.negate ? "notInSub" : "inSub"; + arguments.builder + .getWheres() + .append( { + type: type, + column: typedColumn, + query: arguments.query, + combinator: arguments.combinator + } ); + arguments.builder.addBindings( columnBindings, "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.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" ); + return arguments.builder; + } + + /** + * Adds one expression or scalar binding. + */ + private array function extractPredicateBindings( required QueryBuilder builder, any value ) { + if ( !isNull( arguments.value ) && arguments.builder.getUtils().isExpression( arguments.value ) ) { + 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 ]; + } + + /** + * 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 a53bf0f0..0d8a7703 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. @@ -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,20 @@ 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. + * @default false + */ + property name="validateQueryExecuteReturnType"; + /** * paginationCollector * A component or struct with a `generateWithResults` method. @@ -101,6 +119,12 @@ component displayname="QueryBuilder" accessors="true" { */ property name="collectQueryLog" type="boolean"; + /** + * Tracks whether this builder contains SQL compiled by its current grammar. + */ + property name="grammarCompiledFrom" type="boolean"; + property name="grammarCompiledJoin" type="boolean"; + /******************** Query Properties ********************/ /** @@ -219,43 +243,6 @@ component displayname="QueryBuilder" accessors="true" { */ 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 @@ -263,10 +250,12 @@ component displayname="QueryBuilder" accessors="true" { */ variables.bindings = { "commonTables": [], + "aggregate": [], "select": [], "from": [], "join": [], "where": [], + "groupBy": [], "having": [], "orderBy": [], "union": [], @@ -289,11 +278,18 @@ 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 + * @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 * @paginationCollector The closure that processes the pagination result. * Default: cbpaginator.models.Pagination * @columnFormatter The closure that modifies each column before being @@ -315,21 +311,31 @@ 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, defaultOptions = {}, sqlCommenter = new qb.models.SQLCommenter.NullSQLCommenter(), shouldMaxRowsOverrideToAll, - boolean collectQueryLog = true + boolean collectQueryLog = true, + boolean validateDuplicateSelectColumns = false ) { + variables.collaborators = {}; 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 ); + } + setReturnFormatterRegistry( arguments.returnFormatterRegistry ); if ( isNull( arguments.columnFormatter ) ) { arguments.columnFormatter = function( column ) { return column; @@ -358,6 +364,66 @@ component displayname="QueryBuilder" accessors="true" { 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. * @@ -368,6 +434,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 = ""; @@ -384,10 +451,12 @@ component displayname="QueryBuilder" accessors="true" { variables.updates = {}; variables.bindings = { "commonTables": [], + "aggregate": [], "select": [], "from": [], "join": [], "where": [], + "groupBy": [], "having": [], "orderBy": [], "union": [], @@ -398,6 +467,8 @@ component displayname="QueryBuilder" accessors="true" { variables.pretending = false; variables.queryLog = []; variables.shouldWrapValues = javacast( "null", "" ); + variables.grammarCompiledFrom = false; + variables.grammarCompiledJoin = false; } /** @@ -410,13 +481,51 @@ component displayname="QueryBuilder" accessors="true" { 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" ); + } + + /** + * 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 + * 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. * * @return QueryBuilder */ public QueryBuilder function reset() { + var wasPretending = variables.pretending; setDefaultValues(); + variables.pretending = wasPretending; return this; } @@ -448,18 +557,48 @@ 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": "*" } ]; } + var newBindings = extractColumnBindings( newColumns ); + clearBindings( only = [ "select" ] ); + variables.columns = newColumns; + addBindings( newBindings, "select" ); return this; } - private struct function mapToColumnType( required any column ) { + /** + * Adds bindings carried by typed raw-expression and builder columns. + * 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" ) { + bindings.append( extractExpressionBindings( column.value ), true ); + } else if ( column.type == "builder" ) { + bindings.append( column.value.getBindings(), true ); + } + } + return bindings; + } + + public 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 }; @@ -481,6 +620,7 @@ component displayname="QueryBuilder" accessors="true" { } } + /** * Adds a sub-select to the query. * @@ -495,6 +635,7 @@ component displayname="QueryBuilder" accessors="true" { arguments.query = newQuery(); callback( 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; @@ -514,19 +655,24 @@ 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 newBindings = extractColumnBindings( newColumns ); + 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; + addBindings( newBindings, "select" ); return this; } @@ -544,13 +690,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 ) ); - if ( !arrayIsEmpty( arguments.bindings ) ) { - addBindings( arguments.bindings, "select" ); - } - } - return this; + var expressions = arrayWrap( arguments.expression ); + var rawBindings = arguments.bindings; + return addSelect( expressions.map( ( expression, index ) => raw( expression, index == 1 ? rawBindings : [] ) ) ); } /** @@ -578,7 +720,6 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function reselect( any columns = "*" ) { - clearSelect(); return select( argumentCollection = arguments ); } @@ -597,8 +738,9 @@ component displayname="QueryBuilder" accessors="true" { * @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 : [] ) ) ); } /********************************************************************************\ @@ -620,10 +762,19 @@ component displayname="QueryBuilder" accessors="true" { ); } + var fromBindings = []; + if ( !isSimpleValue( arguments.from ) && getUtils().isExpression( arguments.from ) ) { + fromBindings = extractExpressionBindings( arguments.from ); + } + + clearBindings( only = [ "from" ] ); + variables.grammarCompiledFrom = false; + variables.alias = ""; if ( isSimpleValue( arguments.from ) ) { parseIntoTableAndAlias( arguments.from ); } else { variables.tableName = arguments.from; + addBindings( fromBindings, "from" ); } return this; @@ -632,6 +783,7 @@ component displayname="QueryBuilder" accessors="true" { public QueryBuilder function clearFrom() { variables.tableName = ""; variables.alias = ""; + variables.grammarCompiledFrom = false; clearBindings( only = [ "from" ] ); return this; } @@ -642,10 +794,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 ] ) ); } } @@ -663,262 +822,11 @@ component displayname="QueryBuilder" accessors="true" { } 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 ); - return; - } - - 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 == "builder" ) { - column.value.renameAliases( 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 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 ); - } - } - } - - 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 ); - } - } - } - } - - 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 ); - } - } - } - } - - private void function renameAliasInWhereBasic( - required struct where, - 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 - ); - } - } - - private void function renameAliasInWhereColumn( - required struct where, - 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 - ); - } - } - - private void function renameAliasInWhereSub( - required struct where, - required string oldAlias, - required string newAlias - ) { - if ( where.column.type == "simple" ) { - arguments.where.column.value = swapAlias( - arguments.where.column.value, - arguments.oldAlias, - arguments.newAlias - ); - } - arguments.where.query.renameAliases( arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereIn( - required struct where, - 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 - ); - } - } - - private void function renameAliasInWhereNotIn( - required struct where, - 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 - ); - } - } - - 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 - ) { - arguments.where.query.renameAliases( arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereNotExists( - required struct where, - required string oldAlias, - required string newAlias - ) { - arguments.where.query.renameAliases( 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 - ) { - if ( arguments.where.column.type == "simple" ) { - arguments.where.column.value = swapAlias( - arguments.where.column.value, - arguments.oldAlias, - arguments.newAlias - ); - } - } - - private void function renameAliasInWhereNotNull( - required struct where, - 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 - ); - } - } - - private void function renameAliasInWhereNullSub( - required struct where, - required string oldAlias, - required string newAlias - ) { - arguments.where.query.renameAliases( arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereNotNullSub( - required struct where, - required string oldAlias, - required string newAlias - ) { - arguments.where.query.renameAliases( arguments.oldAlias, arguments.newAlias ); - } - - private void function renameAliasInWhereBetween( - required struct where, - 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 - ); - } - } - - private void function renameAliasInWhereNotBetween( - required struct where, - 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 - ); - } - } - - 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; + getCollaborator( "AliasRewriter" ).rewrite( + builder = this, + oldAlias = arguments.oldAlias, + newAlias = arguments.newAlias + ); } /** @@ -930,8 +838,7 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function table( required any table ) { - variables.tableName = arguments.table; - return this; + return from( arguments.table ); } /** @@ -944,17 +851,7 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function tableRaw( required string table, array bindings = [] ) { - // 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.table( raw( arguments.table ) ); + return this.table( raw( arguments.table, arguments.bindings ) ); } /** @@ -966,17 +863,7 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function fromRaw( required string from, array bindings = [] ) { - // 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.from( raw( arguments.from ) ); + return this.from( raw( arguments.from, arguments.bindings ) ); } /** @@ -988,18 +875,27 @@ component displayname="QueryBuilder" accessors="true" { * @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; + } - addBindings( arguments.input.getBindings(), "from" ); + arguments.input = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.input ); - // generate the derived table SQL - return this.fromRaw( getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ) ); + // 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; + } } /*******************************************************************************\ @@ -1076,7 +972,8 @@ component displayname="QueryBuilder" accessors="true" { * @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 ); } /** @@ -1105,56 +1002,8 @@ component displayname="QueryBuilder" accessors="true" { boolean where = false, boolean preventDuplicateJoins = this.getPreventDuplicateJoins() ) { - if ( getUtils().isBuilder( arguments.table ) ) { - if ( arguments.preventDuplicateJoins ) { - var hasThisJoin = variables.joins.find( function( existingJoin ) { - return existingJoin.isEqualTo( table ); - } ); - - if ( hasThisJoin ) { - return this; - } - } - variables.joins.append( arguments.table ); - addBindings( arguments.table.getBindings(), "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 ) ) { - first( join ); - if ( arguments.preventDuplicateJoins ) { - var hasThisJoin = variables.joins.find( function( existingJoin ) { - return existingJoin.isEqualTo( join ); - } ); - - if ( hasThisJoin ) { - return this; - } - } - variables.joins.append( join ); - addBindings( join.getBindings(), "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( join.getBindings(), "join" ); - - return this; + arguments.builder = this; + return getCollaborator( "JoinClauseManager" ).join( argumentCollection = arguments ); } /** @@ -1325,9 +1174,8 @@ 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 ) ); - - return this; + arguments.builder = this; + return getCollaborator( "JoinClauseManager" ).crossJoin( argumentCollection = arguments ); } /** @@ -1424,12 +1272,8 @@ component displayname="QueryBuilder" accessors="true" { * @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 ); } /** @@ -1458,79 +1302,18 @@ component displayname="QueryBuilder" accessors="true" { string type = "inner", boolean where = false ) { - // 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; - } - - // create the table reference - arguments.table = getGrammar().wrapTable( "(#arguments.input.toSQL()#) AS #arguments.alias#" ); - - // merge bindings - addBindings( arguments.input.getBindings(), "join" ); - - // remove the non-standard arguments - structDelete( arguments, "input" ); - structDelete( arguments, "alias" ); - - return joinRaw( argumentCollection = arguments ); + arguments.builder = this; + return getCollaborator( "JoinClauseManager" ).joinSub( argumentCollection = arguments ); } private function outerOrCrossApply( required string name, required string type, required tableLikeSource ) { - 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`]" - ); - } + arguments.builder = this; + return getCollaborator( "JoinClauseManager" ).applyJoin( argumentCollection = arguments ); + } - 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; - } - - var join = new qb.models.Query.JoinClause( - joiningQuery = this, - type = type, - table = arguments.name, - lateralRawExpression = arguments.tableLikeSource.toSQL() - ); - - if ( this.getPreventDuplicateJoins() ) { - var hasThisJoin = variables.joins.find( function( existingJoin ) { - return existingJoin.isEqualTo( join ); - } ); - - if ( hasThisJoin ) { - // Do nothing, early return - // We have not mutated `this` in any way. - return this; - } - } - - addBindings( tableLikeSource.getBindings(), "join" ); - variables.joins.append( join ); - - return this; - } - - public function outerApply( required string name, required any tableDef ) { - return outerOrCrossApply( name = name, type = "outer apply", tableLikeSource = tableDef ); - } + public function outerApply( required string name, required any tableDef ) { + return outerOrCrossApply( name = name, type = "outer apply", tableLikeSource = tableDef ); + } public function crossApply( required string name, required any tableDef ) { return outerOrCrossApply( name = name, type = "cross apply", tableLikeSource = tableDef ); @@ -1605,23 +1388,8 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function crossJoinSub( required any 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; - } - - // 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 ) ); - - return this; + arguments.builder = this; + return getCollaborator( "JoinClauseManager" ).crossJoinSub( argumentCollection = arguments ); } /** @@ -1713,8 +1481,9 @@ 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[ "RECURSIVE" ] != otherQB.getCommonTables()[ index ][ "RECURSIVE" ] || !cT[ "QUERY" ].isEqualTo( otherQB.getCommonTables()[ index ][ "QUERY" ] ) ); } ) @@ -1747,9 +1516,11 @@ component displayname="QueryBuilder" accessors="true" { }; if ( !isJoin() ) { + memento[ "alias" ] = variables.alias; 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 { @@ -1758,9 +1529,12 @@ 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(); + memento[ "tableBindings" ] = getTable().getBindings(); } else if ( getUtils().isBuilder( getTable() ) ) { memento[ "table" ] = getTable().toSQL(); } else { @@ -1792,82 +1566,8 @@ component displayname="QueryBuilder" accessors="true" { value, string combinator = "and" ) { - if ( isClosure( arguments.column ) || isCustomFunction( arguments.column ) ) { - return whereNested( arguments.column, arguments.combinator ); - } - - if ( this.getValidateOperatorsAndCombinators() && isInvalidCombinator( arguments.combinator ) ) { - throw( type = "InvalidSQLType", message = "Illegal combinator" ); - } - - if ( isNull( arguments.value ) && isInvalidOperator( arguments.operator ) ) { - arguments.value = arguments.operator; - arguments.operator = "="; - } else if ( this.getValidateOperatorsAndCombinators() && isInvalidOperator( arguments.operator ) ) { - throw( type = "InvalidSQLType", message = "Illegal 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" - ) { - arrayAppend( - variables.wheres, - { - column: mapToColumnType( applyColumnFormatter( arguments.column ) ), - operator: arguments.operator, - value: isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value, - combinator: arguments.combinator, - type: "basic" - } - ); - - if ( isNull( arguments.value ) || getUtils().isNotExpression( arguments.value ) ) { - addBindings( - utils.extractBinding( - isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value, - variables.grammar - ), - "where" - ); - } - - return this; + arguments.builder = this; + return getPredicateClause().where( argumentCollection = arguments ); } /** @@ -1886,38 +1586,6 @@ component displayname="QueryBuilder" accessors="true" { 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 ); - } - variables.wheres.append( { - type: "sub", - column: mapToColumnType( applyColumnFormatter( arguments.column ) ), - operator: arguments.operator, - query: arguments.query, - combinator: arguments.combinator - } ); - addBindings( query.getBindings(), "where" ); - return this; - } - /** * Adds an OR WHERE clause to the query. * @@ -1948,68 +1616,51 @@ component displayname="QueryBuilder" accessors="true" { combinator = "and", negate = false ) { - 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"; - variables.wheres.append( { - type: type, - column: mapToColumnType( applyColumnFormatter( arguments.column ) ), - values: arguments.values, - combinator: arguments.combinator - } ); - - var bindings = values - .filter( utils.isNotExpression ) - .map( function( value ) { - return utils.extractBinding( value, variables.grammar ); - } ); - - addBindings( bindings, "where" ); - - return this; + arguments.builder = this; + return getPredicateClause().whereIn( 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`). + * 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. - * @callback A closure that will contain the subquery with which to constain this clause. + * @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 */ - private QueryBuilder function whereInSub( - column, - query, - combinator = "and", - negate = false + public QueryBuilder function whereInBulk( + required column, + required values, + any sqlType = javacast( "null", "" ), + string combinator = "and", + boolean negate = false ) { - if ( isClosure( arguments.query ) || isCustomFunction( arguments.query ) ) { - var callback = arguments.query; - arguments.query = newQuery(); - callback( arguments.query ); - } - - var type = negate ? "notInSub" : "inSub"; - variables.wheres.append( { - type: type, - column: mapToColumnType( applyColumnFormatter( arguments.column ) ), - query: arguments.query, - combinator: arguments.combinator - } ); - addBindings( arguments.query.getBindings(), "where" ); + arguments.builder = this; + return getPredicateClause().whereInBulk( argumentCollection = arguments ); + } - 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 ); } /** @@ -2036,14 +1687,8 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereRaw( required string sql, array whereBindings = [], string combinator = "and" ) { - 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 ); } /** @@ -2062,37 +1707,8 @@ component displayname="QueryBuilder" accessors="true" { second, string combinator = "and" ) { - if ( isNull( arguments.second ) ) { - arguments.second = arguments.operator; - arguments.operator = "="; - } - - if ( this.getValidateOperatorsAndCombinators() && isInvalidOperator( operator ) ) { - throw( type = "InvalidSQLType", message = "Illegal operator" ); - } - - if ( - isClosure( arguments.second ) || - isCustomFunction( arguments.second ) || - getUtils().isBuilder( arguments.second ) - ) { - return whereSub( - arguments.first, - arguments.operator, - arguments.second, - arguments.combinator - ); - } - - variables.wheres.append( { - type: "column", - first: mapToColumnType( applyColumnFormatter( arguments.first ) ), - operator: arguments.operator, - second: mapToColumnType( applyColumnFormatter( arguments.second ) ), - combinator: arguments.combinator - } ); - - return this; + arguments.builder = this; + return getPredicateClause().whereColumn( argumentCollection = arguments ); } /** @@ -2105,28 +1721,8 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereExists( query, combinator = "and", negate = false ) { - 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 ) { - 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 ); } /** @@ -2152,9 +1748,8 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereNested( required callback, combinator = "and" ) { - var query = forNestedWhere(); - callback( query ); - return addNestedWhereQuery( query, combinator ); + arguments.builder = this; + return getPredicateClause().whereNested( argumentCollection = arguments ); } /** @@ -2166,11 +1761,8 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function addNestedWhereQuery( required QueryBuilder query, string combinator = "and" ) { - if ( !query.getWheres().isEmpty() ) { - 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 ); } /** @@ -2179,8 +1771,7 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function forNestedWhere() { - var query = newQuery(); - return query.from( getTableName() ); + return getPredicateClause().forNestedWhere( this ); } /** @@ -2193,21 +1784,8 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereNull( column, combinator = "and", negate = false ) { - if ( - isClosure( arguments.column ) || - isCustomFunction( arguments.column ) || - getUtils().isBuilder( arguments.column ) - ) { - return whereNullSub( arguments.column, arguments.combinator, arguments.negate ); - } - - var type = negate ? "notNull" : "null"; - variables.wheres.append( { - type: type, - column: mapToColumnType( applyColumnFormatter( arguments.column ) ), - combinator: arguments.combinator - } ); - return this; + arguments.builder = this; + return getPredicateClause().whereNull( argumentCollection = arguments ); } /** @@ -2220,16 +1798,8 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function whereNullSub( query, combinator = "and", negate = false ) { - if ( isClosure( arguments.query ) || isCustomFunction( arguments.query ) ) { - var callback = arguments.query; - arguments.query = newQuery(); - callback( arguments.query ); - } - - var type = arguments.negate ? "notNullSub" : "nullSub"; - variables.wheres.append( { type: type, query: arguments.query, combinator: arguments.combinator } ); - - return this; + arguments.builder = this; + return getPredicateClause().whereNullSub( argumentCollection = arguments ); } /** @@ -2263,63 +1833,8 @@ component displayname="QueryBuilder" accessors="true" { combinator = "and", negate = false ) { - var type = negate ? "notBetween" : "between"; - - if ( isClosure( arguments.start ) || isCustomFunction( arguments.start ) ) { - var callback = arguments.start; - arguments.start = newQuery(); - callback( arguments.start ); - } - - if ( isClosure( arguments.end ) || isCustomFunction( arguments.end ) ) { - var callback = arguments.end; - arguments.end = newQuery(); - 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 ( - isStruct( arguments.start ) && !structKeyExists( arguments.start, "isBuilder" ) && structKeyExists( - arguments.start, - "value" - ) - ) { - arguments.start = arguments.start.value; - } - - if ( - isStruct( arguments.end ) && !structKeyExists( arguments.end, "isBuilder" ) && structKeyExists( - arguments.end, - "value" - ) - ) { - arguments.end = arguments.end.value; - } - - variables.wheres.append( { - type: type, - column: mapToColumnType( applyColumnFormatter( arguments.column ) ), - start: arguments.start, - end: arguments.end, - combinator: arguments.combinator - } ); - - - return this; + arguments.builder = this; + return getPredicateClause().whereBetween( argumentCollection = arguments ); } /** @@ -2380,10 +1895,12 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function groupBy( required groups ) { - var groupBys = normalizeToArray( arguments.groups ); - for ( var groupBy in groupBys ) { - variables.groups.append( mapToColumnType( applyColumnFormatter( 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; } @@ -2403,64 +1920,8 @@ component displayname="QueryBuilder" accessors="true" { value, string combinator = "and" ) { - if ( this.getValidateOperatorsAndCombinators() && isInvalidCombinator( arguments.combinator ) ) { - throw( type = "InvalidSQLType", message = "Illegal 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 ) ) { - arguments.value = arguments.operator; - arguments.operator = "="; - } else if ( this.getValidateOperatorsAndCombinators() && isInvalidOperator( arguments.operator ) ) { - throw( type = "InvalidSQLType", message = "Illegal operator" ); - } - - arrayAppend( - variables.havings, - { - type: "normal", - column: mapToColumnType( applyColumnFormatter( arguments.column ) ), - operator: arguments.operator, - value: 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 ( getUtils().isNotExpression( arguments.value ) ) { - addBindings( utils.extractBinding( arguments.value, variables.grammar ), "having" ); - } - - return this; + arguments.builder = this; + return getPredicateClause().having( argumentCollection = arguments ); } /** @@ -2536,6 +1997,8 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function orderBy( required any column, string direction = "asc" ) { + 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. if ( @@ -2557,8 +2020,20 @@ component displayname="QueryBuilder" accessors="true" { 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; } @@ -2583,15 +2058,9 @@ component displayname="QueryBuilder" accessors="true" { // 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; } @@ -2618,7 +2087,9 @@ component displayname="QueryBuilder" accessors="true" { 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 } ); + addBindings( expressionBindings, "orderBy" ); } else { var dir = ( structKeyExists( column, "direction" ) && arrayFindNoCase( variables.directions, column.direction ) @@ -2722,15 +2193,7 @@ component displayname="QueryBuilder" accessors="true" { * @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 ) ); } /** @@ -2742,12 +2205,16 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ public QueryBuilder function orderBySub( required any query, string direction = "asc" ) { + getCollaborator( "QueryValidator" ).validateOrderDirection( arguments.direction ); + arguments.direction = lCase( trim( arguments.direction ) ); if ( !getUtils().isBuilder( arguments.query ) ) { var callback = arguments.query; arguments.query = newQuery(); callback( arguments.query ); } + arguments.query = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.query ); + variables.orders.append( { direction: arguments.direction, query: arguments.query } ); addBindings( arguments.query.getBindings(), "orderBy" ); return this; @@ -2790,8 +2257,16 @@ component displayname="QueryBuilder" accessors="true" { * @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; + } } /*******************************************************************************\ @@ -2814,6 +2289,7 @@ component displayname="QueryBuilder" accessors="true" { // replace the original query builder with the results of the sub-query arguments.input = subquery; } + arguments.input = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.input ); // track the union statement variables.unions.append( { query: arguments.input, all: arguments.all } ); @@ -2862,6 +2338,7 @@ component displayname="QueryBuilder" accessors="true" { // replace the original query builder with the results of the sub-query arguments.input = subquery; } + arguments.input = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.input ); // track the union statement arrayAppend( @@ -2902,7 +2379,7 @@ component displayname="QueryBuilder" accessors="true" { * @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; } @@ -2926,7 +2403,7 @@ component displayname="QueryBuilder" accessors="true" { * @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; } @@ -2945,6 +2422,7 @@ component displayname="QueryBuilder" accessors="true" { 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 ); @@ -2962,6 +2440,7 @@ component displayname="QueryBuilder" accessors="true" { * @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( @@ -2984,12 +2463,29 @@ 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( + arguments.page = arguments.page > 0 ? arguments.page : 1; + 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; } /** @@ -3002,11 +2498,11 @@ 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().clearOrders(); + return getCollaborator( "QueryExecutor" ) + .prepareInternalExecutionBuilder( this, newQuery() ) + .fromSub( "aggregate_table", countSource ) + .count( options = arguments.options ); } return count( options = arguments.options ); } @@ -3064,48 +2560,8 @@ component displayname="QueryBuilder" accessors="true" { * @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 ); } /** @@ -3150,8 +2606,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 }; @@ -3168,12 +2624,13 @@ component displayname="QueryBuilder" accessors="true" { } ); } ); + var newInsertBindings = []; newBindings.each( function( bindingsArray ) { bindingsArray.each( function( binding ) { if ( getUtils().isNotExpression( binding ) ) { - addBindings( binding, "insert" ); + newInsertBindings.append( binding ); } else { - addBindings( binding, "insertRaw" ); + newInsertBindings.append( extractExpressionBindings( binding ), true ); } } ); } ); @@ -3182,8 +2639,11 @@ component displayname="QueryBuilder" accessors="true" { c.formatted = mapToColumnType( c.formatted ); } ); - var sql = getGrammar().compileInsert( this, columns, newBindings ); + var sql = withWrappingContext( function() { + return getGrammar().compileInsert( this, columns, newBindings ); + } ); + variables.bindings.insert = newInsertBindings; clearBindings( except = "insert" ); if ( toSql ) { @@ -3193,6 +2653,84 @@ 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 = getGrammar().resolveInsertColumnNames( arguments.values ).len(); + if ( columnCount == 0 ) { + throw( type = "InvalidSQLType", message = "Please pass structs with at least one column to insertBulk." ); + } + + var originalBindings = {}; + for ( var bindingType in variables.bindings ) { + originalBindings[ bindingType ] = variables.bindings[ bindingType ].isEmpty() + ? [] + : arraySlice( variables.bindings[ bindingType ], 1 ); + } + + try { + 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 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 { + var batchQuery = getCollaborator( "QueryExecutor" ).prepareInternalExecutionBuilder( this, clone() ); + results.append( + batchQuery.insert( values = batch, options = arguments.options, toSql = arguments.toSql ) + ); + } + } + + return results; + } catch ( any e ) { + variables.bindings = originalBindings; + rethrow; + } + } + /** * Inserts data into a table based off of a query. * This call must come after setting the query's table using `from` or `table`. @@ -3210,32 +2748,56 @@ component displayname="QueryBuilder" accessors="true" { 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 ); } + var executor = getCollaborator( "QueryExecutor" ); + var commonTableState = executor.captureCommonTableState( this ); - if ( isNull( arguments.columns ) ) { - arguments.columns = arguments.source - .getColumns() - .map( function( column ) { - return getGrammar().extractAlias( mapToColumnType( column ) ); - } ); - } + 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 ); - var formattedColumns = arguments.columns.map( function( column ) { - var formatted = listLast( applyColumnFormatter( column ), "." ); - return { "original": column, "formatted": formatted }; - } ); + clearBindings( except = [ "commonTables" ] ); + + if ( isNull( arguments.columns ) ) { + arguments.columns = arguments.source + .getColumns() + .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 ) { + var formatted = listLast( applyColumnFormatter( column ), "." ); + return { "original": column, "formatted": formatted }; + } ); - addBindingsFromBuilder( arguments.source ); + addBindingsFromBuilder( arguments.source ); - formattedColumns.each( ( c ) => { - c.formatted = mapToColumnType( c.formatted ); - } ); + formattedColumns.each( ( c ) => { + c.formatted = mapToColumnType( c.formatted ); + } ); - var sql = getGrammar().compileInsertUsing( this, formattedColumns, arguments.source ); + 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; @@ -3275,8 +2837,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 }; @@ -3293,12 +2855,13 @@ component displayname="QueryBuilder" accessors="true" { } ); } ); + var newInsertBindings = []; newBindings.each( function( bindingsArray ) { bindingsArray.each( function( binding ) { if ( getUtils().isNotExpression( binding ) ) { - addBindings( binding, "insert" ); + newInsertBindings.append( binding ); } else { - addBindings( binding, "insertRaw" ); + newInsertBindings.append( extractExpressionBindings( binding ), true ); } } ); } ); @@ -3315,13 +2878,17 @@ component displayname="QueryBuilder" accessors="true" { 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 + ); + } ); + variables.bindings.insert = newInsertBindings; clearBindings( except = "insert" ); if ( toSql ) { @@ -3332,18 +2899,20 @@ component displayname="QueryBuilder" accessors="true" { } 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 : listToArray( 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; } @@ -3364,6 +2933,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() @@ -3376,32 +2946,53 @@ component displayname="QueryBuilder" accessors="true" { 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 ] = subselect; - addBindings( subselect.getBindings(), "update" ); - } else if ( getUtils().isBuilder( value ) ) { - arguments.values[ column.original ] = value; - addBindings( value.getBindings(), "update" ); - } else if ( !getUtils().isExpression( value ) ) { - 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; + sql = withWrappingContext( function() { + return getGrammar().compileUpdate( this, updateArray, updateValues ); + } ); + } catch ( any e ) { + executor.restoreCommonTableState( this, commonTableState ); + rethrow; + } - var sql = getGrammar().compileUpdate( this, updateArray, arguments.values ); + variables.bindings.update = newUpdateBindings; if ( toSql ) { return sql; } - return runQuery( sql, arguments.options, "result" ); + return runQuery( + sql, + arguments.options, + "result", + getBindings( order = getGrammar().getUpdateBindingOrder( this ) ) + ); } /** @@ -3436,6 +3027,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, @@ -3443,160 +3048,204 @@ 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; } - 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 ) ) { - addBindingsFromBuilder( arguments.source ); - } + 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() ) { - return this.insert( values = arguments.values, options = arguments.options, toSql = arguments.toSql ); - } + if ( !isNull( arguments.source ) ) { + arguments.source = getCollaborator( "QueryExecutor" ).snapshotBuilder( this, arguments.source ); + addBindings( arguments.source.getBindings(), "insert" ); + } - 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 = arguments.values[ 1 ].keyArray(); - } 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 { + 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 ); + } ); + } + + 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 }; + } ); + } + } + if ( isArray( arguments.update ) ) { - arguments.update = arguments.update.map( function( column ) { - var formatted = listLast( applyColumnFormatter( column ), "." ); - return { "original": column, "formatted": formatted }; + updateArray = arguments.update; + } else { + updateArray = arguments.update + .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 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 ], + variables.grammar + ), + "insert" + ); + } else { + addExpressionBindings( updates[ column.original ], "insert" ); + } + } ); + } - 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 ( 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; + } - newInsertBindings.each( function( bindingsArray ) { - bindingsArray.each( function( binding ) { - if ( getUtils().isNotExpression( binding ) ) { - addBindings( binding, "insert" ); - } else { - addBindings( binding, "insertRaw" ); - } - } ); - } ); + 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 - ), - "where" - ); - } + columns.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#"; + updateArray.each( ( c ) => { + c.formatted = mapToColumnType( c.formatted ); + } ); + arguments.target.each( ( c ) => { + c.formatted = mapToColumnType( c.formatted ); } ); - arguments.deleteUnmatched( deleteRestrictions ); - arguments.deleteUnmatched = deleteRestrictions; - } - if ( getUtils().isBuilder( arguments.deleteUnmatched ) ) { - addBindingsFromBuilder( arguments.deleteUnmatched ); + 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; } - 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 sql = getGrammar().compileUpsert( - this, - columns, - newInsertBindings, - updateArray, - arguments.update, - arguments.target, - isNull( arguments.source ) ? javacast( "null", "" ) : arguments.source, - arguments.deleteUnmatched - ); - if ( toSql ) { return sql; } @@ -3627,7 +3276,9 @@ component displayname="QueryBuilder" accessors="true" { where( arguments.idColumnName, "=", arguments.id ); } - var sql = getGrammar().compileDelete( this ); + var sql = withWrappingContext( function() { + return getGrammar().compileDelete( this ); + } ); if ( toSql ) { return sql; @@ -3647,24 +3298,13 @@ component displayname="QueryBuilder" accessors="true" { * * @return array of bindings */ - public array function getBindings( array except = [] ) { - var bindingOrder = arrayFilter( - [ - "commonTables", - "update", - "insert", - "select", - "from", - "join", - "where", - "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 ) { @@ -3694,16 +3334,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", - "select", - "join", - "where", - "orderBy", - "union" - ]; + arguments.only = variables.bindings.keyArray(); } for ( var bindingType in arguments.only ) { @@ -3733,6 +3364,34 @@ component displayname="QueryBuilder" accessors="true" { return this; } + /** + * Normalizes the bindings carried by an Expression for query execution. + */ + public array function extractExpressionBindings( required any expression ) { + 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; + } + + /** + * Adds normalized bindings carried by an Expression to a binding group. + */ + public 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. * @@ -3922,26 +3581,31 @@ component displayname="QueryBuilder" accessors="true" { boolean toSQL = false, any showBindings = false ) { - return withAggregate( - { + return getCollaborator( "QueryExecutor" ).withAggregate( + builder = this, + aggregate = { type: type, - column: mapToColumnType( arguments.column ), + column: mapToColumnType( applyColumnFormatter( 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 ); - } - - var result = get( options = options ); - if ( result.recordCount <= 0 && !isNull( defaultValue ) ) { - return defaultValue; - } else { - return result.aggregate; + 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; + } } - } ); + ); } ); } ); @@ -3955,19 +3619,24 @@ component displayname="QueryBuilder" accessors="true" { * @return boolean */ public any function exists( struct options = {}, boolean toSQL = false ) { - var originalLimit = this.getLimitValue(); - this.setLimitValue( 1 ); - var existsQuery = newQuery() - .clearFrom() - .selectRaw( - "CASE WHEN EXISTS (#getGrammar().compileSelect( this )#) THEN 1 ELSE 0 END AS aggregate", - this.getBindings() - ); - this.setLimitValue( isNull( originalLimit ) ? javacast( "null", "" ) : originalLimit ); - return arguments.toSQL ? existsQuery.toSQL() : existsQuery - .setReturnFormat( "query" ) - .get( options = arguments.options ) - .aggregate == 1; + var existsSource = clone().setLimitValue( 1 ); + var existsQuery = getCollaborator( "QueryExecutor" ) + .prepareInternalExecutionBuilder( this, newQuery() ) + .clearFrom(); + getCollaborator( "QueryExecutor" ).hoistNestedCommonTables( existsSource, existsQuery ); + var existsSql = withWrappingContext( function() { + return getGrammar().compileSelect( existsSource ); + } ); + existsQuery.selectRaw( + "CASE WHEN EXISTS (#existsSql#) THEN 1 ELSE 0 END AS aggregate", + existsSource.getBindings() + ); + if ( arguments.toSQL ) { + return existsQuery.toSQL(); + } + + var result = existsQuery.setReturnFormat( "query" ).get( options = arguments.options ); + return result.recordCount > 0 && result.aggregate == 1; } /** @@ -4025,8 +3694,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; } @@ -4148,10 +3821,8 @@ component displayname="QueryBuilder" accessors="true" { struct options = {} ) { return withReturnFormat( "query", function() { - var formattedColumn = applyColumnFormatter( column ); - select( formattedColumn ); take( 1 ); - var result = get( options = options ); + var result = get( columns = column, options = options ); if ( result.recordCount <= 0 ) { if ( throwWhenNotFound ) { throw( @@ -4200,9 +3871,7 @@ component displayname="QueryBuilder" accessors="true" { */ public array function values( required any column, struct options = {} ) { return withReturnFormat( "query", function() { - var formattedColumn = applyColumnFormatter( column ); - select( formattedColumn ); - var result = get( options = options ); + var result = get( columns = column, options = options ); var columnName = getFunctionList().keyExists( "queryColumnList" ) ? queryColumnList( result ).listFirst() : getMetadata( result )[ 1 ].name; @@ -4252,6 +3921,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( @@ -4296,73 +3968,33 @@ component displayname="QueryBuilder" accessors="true" { } /** - * 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 ) ) { - return; - } - - if ( isQuery( q ) ) { - return returnFormat( q ); - } - - if ( isArray( q ) ) { - return returnFormat( q ); - } - - if ( !q.keyExists( "result" ) || !q.keyExists( "query" ) ) { - return returnFormat( q ); - } - - return { result: q.result, query: returnFormat( q.query ) }; + 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( required string sql, struct options = {}, string returnObject = "query" ) { - structAppend( arguments.options, getDefaultOptions(), false ); - var bindings = getBindings( except = getAggregate().isEmpty() ? [] : [ "select" ] ); - - var result = grammar.runQuery( - sql = variables.sqlCommenter.appendSqlComments( - sql = sql, - datasource = arguments.options.keyExists( "datasource" ) && !isNull( arguments.options.datasource ) ? arguments.options.datasource : javacast( - "null", - "" - ), - bindings = bindings - ), - bindings = bindings, + public any function runQuery( + required string sql, + struct options = {}, + string returnObject = "query", + array bindings + ) { + 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 = returnObject, - pretend = variables.pretending, - postProcessHook = function( data ) { - if ( this.getCollectQueryLog() ) { - variables.queryLog.append( data ); - } - } + returnObject = arguments.returnObject, + bindingsDefinition = bindingsDefinition ); - - if ( !isNull( result ) ) { - return result; - } - return; } /*******************************************************************************\ @@ -4385,15 +4017,31 @@ 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(), + 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() + defaultOptions = getDefaultOptions(), + sqlCommenter = getSqlCommenter(), + shouldMaxRowsOverrideToAll = getShouldMaxRowsOverrideToAll(), + validateDuplicateSelectColumns = getValidateDuplicateSelectColumns(), + validateQueryExecuteReturnType = getValidateQueryExecuteReturnType(), + collectQueryLog = getCollectQueryLog() ); + if ( !isNull( getShouldWrapValues() ) ) { + if ( getShouldWrapValues() ) { + query.withWrappingValues(); + } else { + query.withoutWrappingValues(); + } + } + return query; } /** @@ -4402,30 +4050,10 @@ component displayname="QueryBuilder" accessors="true" { * @return qb.models.Query.QueryBuilder */ 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 ); - return clonedQuery; + return getCollaborator( "QueryExecutor" ).cloneBuilder( this ); } + /** * Wrap up any sql in an Expression. * Expressions are not parameterized or escaped in any way. @@ -4454,19 +4082,43 @@ component displayname="QueryBuilder" accessors="true" { ); } + private any function withWrappingContext( required function callback ) { + var grammar = getGrammar(); + grammar.pushShouldWrapValuesContext( getShouldWrapValues() ); + try { + return arguments.callback(); + } finally { + grammar.popShouldWrapValuesContext(); + } + } + /** * Returns the Builder compiled to grammar-specific sql. * * @return string */ public string function toSQL( any showBindings = false ) { - var sql = grammar.compileSelect( this ); + if ( getValidateDuplicateSelectColumns() && getAggregate().isEmpty() ) { + getCollaborator( "QueryValidator" ).validateUniqueSelectColumns( getColumns(), getGrammar() ); + } + var sql = withWrappingContext( function() { + return grammar.compileSelect( this ); + } ); if ( isBoolean( arguments.showBindings ) && arguments.showBindings == false ) { return sql; } - return getUtils().replaceBindings( sql, getBindings(), arguments.showBindings == "inline" ); + var aggregateBindingExclusions = getAggregate().isEmpty() + ? [] + : ( getUnions().isEmpty() ? [ "select", "orderBy" ] : [ "orderBy" ] ); + var bindings = getBindings( except = aggregateBindingExclusions ); + return getUtils().replaceBindings( + sql, + bindings, + arguments.showBindings == "inline", + getGrammar() + ); } /** @@ -4508,31 +4160,28 @@ 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 if ( + ( isStruct( arguments.format ) || isObject( arguments.format ) ) && + structKeyExists( arguments.format, "format" ) + ) { + variables.returnFormat = arguments.format; } else { - throw( type = "InvalidFormat", message = "The format passed to Builder is invalid." ); + variables.returnFormat = getReturnFormatterRegistry().getReturnFormatter( + arguments.format, + arguments.options + ); } return this; @@ -4556,65 +4205,30 @@ 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 ); - var result = callback(); - setReturnFormat( originalReturnFormat ); - return result; - } - - /** - * 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": "*" } ]; - if ( getUnions().isEmpty() ) { - originalColumns = getColumns(); - select( arguments.columns ); - } - var result = callback(); - if ( getUnions().isEmpty() ) { - select( originalColumns ); + setReturnFormat( arguments.returnFormat, arguments.options ); + var result = javacast( "null", "" ); + try { + result = callback(); + } finally { + variables.returnFormat = originalReturnFormat; } 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(); - setAggregate( arguments.aggregate ); - setOrders( [] ); - var result = callback(); - setAggregate( originalAggregate ); - setOrders( originalOrders ); - return result; - } - /** * Converts the arguments passed in to it into an array. * * @return array */ - private array function normalizeToArray( required listOrArray ) { + public array function normalizeToArray( required listOrArray ) { if ( isArray( arguments.listOrArray ) ) { return arguments.listOrArray; } @@ -4628,7 +4242,7 @@ component displayname="QueryBuilder" accessors="true" { return trim( item ); } ); } catch ( any e ) { - return arguments.listOrArray; + return [ arguments.listOrArray ]; } } @@ -4654,36 +4268,6 @@ component displayname="QueryBuilder" accessors="true" { } } - /** - * 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 ) ); - } - /** * onMissingMethod serves the following purpose for Builder: * @@ -4705,10 +4289,10 @@ component displayname="QueryBuilder" accessors="true" { * 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 ); } @@ -4801,7 +4385,12 @@ component displayname="QueryBuilder" accessors="true" { * 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 ); } @@ -4816,7 +4405,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 ) { @@ -4827,6 +4420,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.grammarCompiledFrom || variables.grammarCompiledJoin ) { + 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/QueryExecutor.cfc b/models/Query/QueryExecutor.cfc new file mode 100644 index 00000000..5c57e58e --- /dev/null +++ b/models/Query/QueryExecutor.cfc @@ -0,0 +1,325 @@ +/** + * 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 ); + if ( arguments.builder.isPretending() ) { + clonedQuery.pretend(); + } + 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().getResolvedGrammar(), "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; + 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 ); + 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/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 7c480d7d..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; @@ -89,11 +96,19 @@ component singleton displayname="QueryUtils" accessors="true" { checkForNonQueryParamStructKeys( value ); } - binding = value; + binding = structCopy( value ); } else { 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; } @@ -111,18 +126,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 ); } @@ -139,44 +157,341 @@ 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; - 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 ); + 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( 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 blockCommentDepth = 0; + var oracleQuoteClosing = ""; + var quoteUsesBackslashEscapes = false; + + 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 ( isPostgres && character == "/" && nextCharacter == "*" ) { + output.append( nextCharacter ); + position += 2; + blockCommentDepth++; + } else if ( character == "*" && nextCharacter == "/" ) { + output.append( nextCharacter ); + position += 2; + blockCommentDepth--; + if ( blockCommentDepth == 0 ) { + 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 == "oracleQuote" ) { + if ( mid( arguments.sql, position, len( oracleQuoteClosing ) ) == oracleQuoteClosing ) { + output.append( oracleQuoteClosing ); + position += len( oracleQuoteClosing ); + state = "sql"; + } else { + output.append( character ); + position++; } + continue; + } - var orderedBinding = structNew( "ordered" ); - for ( var type in [ "value", "cfsqltype", "null" ] ) { - orderedBinding[ type ] = thisBinding[ type ]; + if ( state != "sql" ) { + output.append( character ); + if ( + state != "bracketQuote" && + quoteUsesBackslashEscapes && + character == chr( 92 ) && + nextCharacter != "" + ) { + output.append( nextCharacter ); + position += 2; + continue; } - if ( isBinary( orderedBinding.value ) ) { - orderedBinding.value = toBase64( orderedBinding.value ); + + var closingCharacter = state == "singleQuote" ? "'" : ( + state == "doubleQuote" ? """" : ( state == "backtickQuote" ? chr( 96 ) : "]" ) + ); + if ( character == closingCharacter ) { + if ( nextCharacter == closingCharacter ) { + output.append( nextCharacter ); + position += 2; + } else { + position++; + state = "sql"; + } + } else { + position++; + } + continue; + } + + 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; + state = "lineComment"; + continue; + } + + if ( isMySQL && character == "##" ) { + output.append( character ); + position++; + state = "lineComment"; + continue; + } + + if ( character == "/" && nextCharacter == "*" ) { + output.append( character ); + output.append( nextCharacter ); + position += 2; + state = "blockComment"; + blockCommentDepth = 1; + continue; + } + + if ( ( isNull( arguments.grammar ) || isPostgres ) && character == "$" ) { + var dollarQuoteMatch = reFind( + "^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$", + mid( arguments.sql, position, sqlLength - position + 1 ), + 1, + true + ); + if ( dollarQuoteMatch.len[ 1 ] > 0 ) { + 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; + } } - var stringifiedBinding = serializeJSON( orderedBinding ); - return stringifiedBinding; - }, - "all" + } + + 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" ) + ); + quoteUsesBackslashEscapes = isNull( resolvedGrammar ) || + isMySQL || + ( + isPostgres && + ( character == "'" || character == """" ) && + isPostgresBackslashEscapedQuote( arguments.sql, position, character ) + ); + position++; + continue; + } + + if ( character == "?" ) { + if ( isPostgres && isPostgresQuestionMarkOperator( arguments.sql, position ) ) { + output.append( character ); + position++; + continue; + } + if ( index > arguments.bindings.len() ) { + throw( + type = "BindingMismatch", + message = "The SQL contains more parameter placeholders than supplied bindings." + ); + } + output.append( formatBindingForDisplay( arguments.bindings[ index ], arguments.inline ) ); + index++; + position++; + continue; + } + + output.append( character ); + position++; + } + + if ( index <= arguments.bindings.len() ) { + throw( + type = "BindingMismatch", + message = "The supplied bindings contain more values than the SQL parameter placeholders." + ); + } + + 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. + */ + 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. + */ + 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 ); + } + /** * Infer the correct type from a value. * @@ -190,13 +505,30 @@ 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 ( !arrayIsDefined( arguments.value, i ) || 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 ) ) { + if ( structKeyExists( value, "cfsqltype" ) ) { + return normalizeSqlType( value.cfsqltype ); + } + + if ( structKeyExists( value, "sqltype" ) ) { + return normalizeSqlType( value.sqltype ); + } + + return structKeyExists( value, "value" ) ? inferSqlType( value.value, grammar ) : "VARCHAR"; } if ( checkIsActuallyNumeric( value ) ) { @@ -219,7 +551,14 @@ component singleton displayname="QueryUtils" accessors="true" { return "NULL"; } - switch ( arguments.sqltype ) { + var normalizedSqlType = normalizeSqlType( arguments.sqltype ); + if ( + listFindNoCase( "BOOLEAN,OTHER", normalizedSqlType ) && + checkIsActuallyBoolean( arguments.value ) + ) { + return arguments.value ? "TRUE" : "FALSE"; + } + switch ( normalizedSqlType ) { case "INTEGER": case "NUMERIC": case "DECIMAL": @@ -242,8 +581,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": @@ -259,6 +598,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. * @@ -266,7 +609,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 ) && @@ -280,7 +623,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; } @@ -296,7 +639,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; } @@ -316,7 +659,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 ); } @@ -331,7 +674,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*$" ); } /** @@ -378,6 +721,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. * @@ -463,10 +832,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; } } @@ -474,6 +849,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 * @@ -494,6 +880,7 @@ component singleton displayname="QueryUtils" accessors="true" { "AtomicLong", "BigDecimal", "BigInteger", + "Byte", "CFDouble", "Double", "DoubleAccumulator", @@ -510,8 +897,13 @@ 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; + var isInteger = reFind( "^-?\d+$", arguments.value ) > 0; + if ( !isInteger ) { + return normalizeSqlType( variables.decimalSqlType ); + } + + var isBigInteger = arguments.value < -2147483648 || arguments.value > 2147483647; + return normalizeSqlType( isBigInteger ? variables.bigIntegerSqlType : variables.integerSqlType ); } /** @@ -534,7 +926,14 @@ component singleton displayname="QueryUtils" accessors="true" { } return isDate( arguments.value ) && arrayContainsNoCase( - [ "OleDateTime", "DateTimeImpl", "DateTime" ], + [ + "Date", + "DateTime", + "DateTimeImpl", + "OleDateTime", + "Time", + "Timestamp" + ], className ); } @@ -584,32 +983,16 @@ 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 ) { - return false; - } - } - // Key is a structure, call structCompare() - else if ( isStruct( arguments.LeftStruct[ key ] ) ) { - local.result = structCompare( arguments.LeftStruct[ key ], 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 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; @@ -641,23 +1024,51 @@ 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 ) { - // 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; + 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; + } + continue; + } + + 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 ) { @@ -692,13 +1103,19 @@ component singleton displayname="QueryUtils" accessors="true" { return 0; } - var numString = arguments.binding.value.toString(); - var numStringParts = listToArray( numString, "." ); - if ( numStringParts.len() != 2 ) { - return 0; + if ( isInstanceOf( arguments.binding.value, "java.math.BigDecimal" ) ) { + return max( 0, arguments.binding.value.scale() ); } - var decimalPortion = numStringParts[ 2 ]; - return len( decimalPortion ); + + var numString = arguments.binding.value.toString(); + 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/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/models/Query/ReturnFormatterRegistry.cfc b/models/Query/ReturnFormatterRegistry.cfc new file mode 100644 index 00000000..021a4392 --- /dev/null +++ b/models/Query/ReturnFormatterRegistry.cfc @@ -0,0 +1,199 @@ +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": cloneConfigurationValue( arguments.options ), + "properties": cloneConfigurationValue( 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 = cloneConfigurationValue( definition.options ); + structAppend( formatterOptions, cloneConfigurationValue( arguments.options ), true ); + + var factory = resolveFactory( definition.factory, cloneConfigurationValue( 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" ) ) { + 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 + }; + } + + return { + "factory": arguments.definition, + "options": {}, + "properties": {}, + "force": false + }; + } + + 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 ] = isNull( arguments.value[ key ] ) + ? javacast( "null", "" ) + : cloneConfigurationValue( arguments.value[ key ] ); + } + return clonedStruct; + } + + 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 ) ) { + clonedArray[ i ] = isNull( arguments.value[ i ] ) + ? javacast( "null", "" ) + : cloneConfigurationValue( arguments.value[ i ] ); + } + } + return clonedArray; + } + + return arguments.value; + } + + 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/models/SQLCommenter/SQLCommenter.cfc b/models/SQLCommenter/SQLCommenter.cfc index ce08a268..27af1eb4 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,122 @@ 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 = ""; + var oracleQuoteClosing = ""; + + 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 ( oracleQuoteClosing != "" ) { + if ( mid( arguments.sql, position, len( oracleQuoteClosing ) ) == oracleQuoteClosing ) { + position += len( oracleQuoteClosing ); + oracleQuoteClosing = ""; + } 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, sqlLength - position + 1 ), + 1, + true + ); + if ( dollarQuoteMatch.len[ 1 ] > 0 ) { + 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 == "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; + } + position++; + } + + return 0; } /** diff --git a/models/Schema/Blueprint.cfc b/models/Schema/Blueprint.cfc index d44d467c..9ef86bfb 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 ); } @@ -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 ); @@ -108,7 +113,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 +145,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 ); } @@ -178,32 +183,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; } @@ -225,7 +244,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 ); } @@ -236,12 +255,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; } @@ -283,8 +308,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; } @@ -294,14 +325,18 @@ 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; } 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 ); } @@ -360,7 +395,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 ); } @@ -375,7 +410,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 ); } @@ -390,7 +425,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 ); } @@ -405,7 +440,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 ); } @@ -419,8 +454,11 @@ component accessors="true" { * @returns The created TableIndex instance. */ public TableIndex function default( required string column, string name ) { - param arguments.name = "df_#getTable()#_#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." + ); } @@ -468,7 +506,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", { @@ -499,10 +537,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; @@ -544,28 +582,45 @@ 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 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 != "" ) { + statements.append( result ); + } } + return statements; + } finally { + setCommands( originalCommands ); + setIndexes( originalIndexes ); } - return statements; } private array function arrayWrap( required any value ) { 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/Column.cfc b/models/Schema/Column.cfc index e0e91f2e..ed09d9da 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. * @@ -145,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 ); } @@ -163,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 8eabb7af..3cb9ee3e 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(), @@ -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 @@ -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 ); @@ -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 @@ -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 ); @@ -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 @@ -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 ); @@ -204,30 +204,29 @@ component accessors="true" { ); blueprint.addCommand( "alterView", { query: query } ); blueprint.setCreating( true ); - blueprint.setTable( arguments.view ); + 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; } 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(), @@ -235,7 +234,7 @@ component accessors="true" { getDefaultSchema() ); blueprint.addCommand( "dropView" ); - blueprint.setTable( arguments.view ); + blueprint.setTable( qualifyTable( arguments.view ) ); if ( arguments.execute ) { blueprint @@ -243,7 +242,7 @@ component accessors="true" { .each( function( statement ) { getGrammar().runQuery( statement, - query.getBindings(), + [], options, "result", variables.pretending, @@ -267,7 +266,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(), @@ -275,7 +274,7 @@ component accessors="true" { getDefaultSchema() ); blueprint.addCommand( "drop" ); - blueprint.setTable( arguments.table ); + blueprint.setTable( qualifyTable( arguments.table ) ); if ( arguments.execute ) { blueprint .toSql() @@ -305,7 +304,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(), @@ -313,7 +312,7 @@ component accessors="true" { getDefaultSchema() ); blueprint.addCommand( "truncate" ); - blueprint.setTable( arguments.table ); + blueprint.setTable( qualifyTable( arguments.table ) ); if ( arguments.execute ) { blueprint .toSql() @@ -343,7 +342,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(), @@ -351,7 +350,7 @@ component accessors="true" { getDefaultSchema() ); blueprint.addCommand( "drop" ); - blueprint.setTable( arguments.table ); + blueprint.setTable( qualifyTable( arguments.table ) ); blueprint.setIfExists( true ); if ( arguments.execute ) { blueprint @@ -389,14 +388,14 @@ 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(), arguments.options, getDefaultSchema() ); - blueprint.setTable( arguments.table ); + blueprint.setTable( qualifyTable( arguments.table ) ); arguments.callback( blueprint ); if ( arguments.execute ) { blueprint @@ -433,14 +432,14 @@ 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(), arguments.options, getDefaultSchema() ); - blueprint.setTable( arguments.from ); + blueprint.setTable( qualifyTable( arguments.from ) ); blueprint.addCommand( "renameTable", { to: arguments.to } ); if ( arguments.execute ) { blueprint @@ -478,7 +477,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,10 +497,13 @@ component accessors="true" { struct options = {}, boolean execute = true ) { - structAppend( arguments.options, variables.defaultOptions, false ); - var args = [ listLast( arguments.name, "." ) ]; + arguments.options = mergeOptions( arguments.options ); + if ( listLen( arguments.name, "." ) > 1 ) { + arguments.schema = listDeleteAt( arguments.name, listLen( 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 ) { @@ -510,7 +512,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; } @@ -535,10 +540,16 @@ component accessors="true" { struct options = {}, boolean execute = true ) { - structAppend( arguments.options, variables.defaultOptions, false ); - var args = [ listLast( arguments.table, "." ), arguments.column ]; + arguments.options = mergeOptions( arguments.options ); + if ( listLen( arguments.table, "." ) > 1 ) { + arguments.schema = listDeleteAt( arguments.table, listLen( arguments.table, "." ), "." ); + } + 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 ) { @@ -570,8 +581,19 @@ component accessors="true" { boolean execute = true, string schema = variables.defaultSchema ) { - structAppend( arguments.options, variables.defaultOptions, false ); - var statements = getGrammar().compileDropAllObjects( arguments.options, arguments.schema, this ); + arguments.options = mergeOptions( arguments.options ); + if ( variables.pretending ) { + return []; + } + var dropOptions = arguments.options; + var dropSchema = arguments.schema; + 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( @@ -598,7 +620,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( @@ -624,7 +646,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( @@ -658,4 +680,28 @@ 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. + * + * @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/models/Schema/TableIndex.cfc b/models/Schema/TableIndex.cfc index 3558b25d..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. */ @@ -44,12 +46,23 @@ component accessors="true" { */ property name="onDeleteAction" default="NO ACTION"; + variables.validReferentialActions = [ + "RESTRICT", + "CASCADE", + "SET NULL", + "NO ACTION", + "SET DEFAULT" + ]; + /** * Create a new TableIndex instance. * * @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; } @@ -88,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; } @@ -118,6 +138,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 +164,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/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" }, diff --git a/tests/resources/AbstractQueryBuilderSpec.cfc b/tests/resources/AbstractQueryBuilderSpec.cfc index b7bb6803..bf9652a6 100644 --- a/tests/resources/AbstractQueryBuilderSpec.cfc +++ b/tests/resources/AbstractQueryBuilderSpec.cfc @@ -1,3141 +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( "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() ); - } ); - } ); - - 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() ); - } ); - } ); - - 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 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( "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 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() { - 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( "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( "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( "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( "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( "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( "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( "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( "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 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() - ); - } ); - } ); - - 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() ); - } ); - } ); - } ); - } - - 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" ); - } - - 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/AbstractSchemaBuilderSpec.cfc b/tests/resources/AbstractSchemaBuilderSpec.cfc index 12994edc..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( @@ -281,7 +294,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 +1068,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 +1120,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 +1128,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( @@ -1647,6 +1686,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( @@ -1800,6 +1852,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() { @@ -1823,6 +1887,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/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/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() ); + } ); + } ); + } ); + } ); + } ); + } + +} diff --git a/tests/specs/ModuleConfigSpec.cfc b/tests/specs/ModuleConfigSpec.cfc new file mode 100644 index 00000000..0148d6d2 --- /dev/null +++ b/tests/specs/ModuleConfigSpec.cfc @@ -0,0 +1,26 @@ +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" ); + } ); + + 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/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 ); + } ); + } ); + } + +} diff --git a/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc new file mode 100644 index 00000000..7951b6d8 --- /dev/null +++ b/tests/specs/Query/Abstract/BindingLifecycleSpec.cfc @@ -0,0 +1,362 @@ +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" ); + } ); + + 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"" = ?" ); + } ); + + 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(); + } ); + + 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(); + } ); + + 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" ); + } ); + + 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" ); + } ); + + 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" ); + } ); + + 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" ); + } ); + + 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" ); + } ); + + 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"" = ?" ); + } ); + + 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"" = ?" ); + } ); + + 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"" = ?" ); + } ); + } ); + } + +} diff --git a/tests/specs/Query/Abstract/BuilderAliasSpec.cfc b/tests/specs/Query/Abstract/BuilderAliasSpec.cfc index 154276ba..9d55a695 100644 --- a/tests/specs/Query/Abstract/BuilderAliasSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderAliasSpec.cfc @@ -2,6 +2,56 @@ 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( "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(); + + 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(); @@ -106,6 +156,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" ) @@ -199,6 +313,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" ) @@ -248,6 +386,128 @@ 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""" ); + } ); + + 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""" + ); + } ); + + 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"")" + ); + } ); + + 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/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/Query/Abstract/BuilderSelectSpec.cfc b/tests/specs/Query/Abstract/BuilderSelectSpec.cfc index ce1f172e..212e7514 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,216 @@ 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 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, + 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( "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() { @@ -62,6 +272,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() { @@ -73,6 +313,60 @@ 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" ); + } ); + + 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`" ); + } ); + + 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() { + 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/BuilderWhereSpec.cfc b/tests/specs/Query/Abstract/BuilderWhereSpec.cfc index 0f918554..2a612bfc 100644 --- a/tests/specs/Query/Abstract/BuilderWhereSpec.cfc +++ b/tests/specs/Query/Abstract/BuilderWhereSpec.cfc @@ -92,6 +92,62 @@ component extends="testbox.system.BaseSpec" { expect( where.type ).toBe( "notIn" ); } ); + it( "does not retain raw column bindings for empty IN predicates", function() { + 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", nullQueryParam, 10 ); + + expect( builder.toSQL() ).toBe( "SELECT * FROM ""users"" WHERE ""age"" BETWEEN ? AND ?" ); + 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::" ); @@ -162,6 +218,56 @@ component extends="testbox.system.BaseSpec" { } ).toThrow( type = "InvalidSQLType", regex = "Illegal operator" ); } ); + it( "validates combinators for every where clause type", function() { + 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" + ); + } ); + 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" ); + } ); + } ); + it( "can disable operator and combinator validation", function() { var relaxedQB = new qb.models.Query.QueryBuilder( validateOperatorsAndCombinators = false ); getMockBox().prepareMock( relaxedQB ); @@ -186,6 +292,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/JoinClauseSpec.cfc b/tests/specs/Query/Abstract/JoinClauseSpec.cfc index a13eb057..47fc9231 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() { @@ -264,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/PaginationSpec.cfc b/tests/specs/Query/Abstract/PaginationSpec.cfc index 119c0a52..238ae4bc 100644 --- a/tests/specs/Query/Abstract/PaginationSpec.cfc +++ b/tests/specs/Query/Abstract/PaginationSpec.cfc @@ -56,6 +56,35 @@ 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( "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 = []; @@ -104,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/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/QueryBuilderCollaboratorsSpec.cfc b/tests/specs/Query/Abstract/QueryBuilderCollaboratorsSpec.cfc new file mode 100644 index 00000000..4d21bad2 --- /dev/null +++ b/tests/specs/Query/Abstract/QueryBuilderCollaboratorsSpec.cfc @@ -0,0 +1,114 @@ +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( "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 ); + 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/QueryExecutionSpec.cfc b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc index 1980b386..f69673f5 100644 --- a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc +++ b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc @@ -92,6 +92,35 @@ 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" ] ); + } ); + + 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() { @@ -225,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", [] ); @@ -386,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( @@ -478,6 +539,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" ); @@ -685,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; @@ -704,6 +785,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; @@ -789,6 +895,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; @@ -885,8 +1012,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 = {} ) @@ -1054,6 +1182,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() { @@ -1088,6 +1233,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() { @@ -1287,6 +1444,329 @@ 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( "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" } ); + 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( "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 + ); + + var derivedBuilders = [ builder.newQuery(), builder.clone() ]; + derivedBuilders.each( function( derivedBuilder ) { + expect( derivedBuilder.getPreventDuplicateJoins() ).toBeTrue(); + $assert.isSameInstance( sqlCommenter, derivedBuilder.getSqlCommenter() ); + $assert.isSameInstance( shouldMaxRowsOverrideToAll, derivedBuilder.getShouldMaxRowsOverrideToAll() ); + } ); + } ); + + 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() { + 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" } ); + + 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" ); + 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( "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" } ); + 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() { @@ -1298,6 +1778,195 @@ component extends="testbox.system.BaseSpec" { expect( sql ).toBe( sqlAgain ); } ); } ); + + 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", {} ); + 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" ) + .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( "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( [] ); + } ); + + 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 (?), (?)" ] ); + } ); + } ); + + 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 d1f070bb..37dfb80a 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 ); @@ -28,10 +37,37 @@ 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" ); } ); + it( "negative integers", function() { + 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" ); } ); @@ -45,6 +81,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(); @@ -75,6 +118,18 @@ component extends="testbox.system.BaseSpec" { ).toBe( 0 ); } ); + it( "does not format null temporal query parameters", function() { + 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 ); + expect( utils.replaceBindings( "SELECT ?", [ binding ] ) ).toInclude( """null"":true" ); + } ); + } ); + describe( "boolean", () => { it( "infers boolean types correctly", () => { makePublic( utils, "checkIsActuallyBoolean", "publicCheckIsActuallyBoolean" ); @@ -172,6 +227,60 @@ 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( + [ { 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( @@ -188,7 +297,199 @@ 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 ); + 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 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( "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 ); + + 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( "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 ); + + expect( + utils.replaceBindings( + "SELECT $tag$, ? FROM records", + [ binding ], + true, + new qb.models.Grammars.MySQLGrammar() + ) + ).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 ); + + 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 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 ); + + 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" ); + } ); + + 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 ); + + 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( "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 ); + + expect( function() { + utils.replaceBindings( "SELECT 1", [ binding ], true ); + } ).toThrow( type = "BindingMismatch" ); + } ); + } ); + 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 ); @@ -221,6 +522,20 @@ 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( "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" }, @@ -380,6 +695,143 @@ component extends="testbox.system.BaseSpec" { var queryTwo = queryOne.clone(); 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" ) + .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( "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 ) { + 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(); + } ); + + 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() { + 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 ); + } ); } ); } 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" ); + } ); + } ); + } + +} diff --git a/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc b/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc new file mode 100644 index 00000000..43edec86 --- /dev/null +++ b/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc @@ -0,0 +1,172 @@ +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( "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( "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( "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( { + "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" ); + } ); + } ); + } + +} 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" ); + } ); + } ); + } + +} 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" ); + } ); + } ); + } + +} 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'" ); + } ); + } ); + } + +} 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 ); + } ); + } ); + } + +} 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 ); + } + } + +} diff --git a/tests/specs/Query/DerbyQueryBuilderSpec.cfc b/tests/specs/Query/DerbyQueryBuilderSpec.cfc index 94fe3d08..484aac29 100644 --- a/tests/specs/Query/DerbyQueryBuilderSpec.cfc +++ b/tests/specs/Query/DerbyQueryBuilderSpec.cfc @@ -1,5 +1,35 @@ 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" ); + } ); + + 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" ); + } ); + } ); + } + function selectAllColumns() { return "SELECT * FROM ""users"""; } @@ -340,6 +370,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 (?, ?, ?)", @@ -989,6 +1059,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"")", @@ -1055,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"" = ?)", @@ -1170,6 +1258,50 @@ 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 jsonEmptyCompoundContains() { + return { exception: "UnsupportedOperation" }; + } + + function jsonNullContains() { + return { exception: "UnsupportedOperation" }; + } + + function jsonNumericObjectKey() { + 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/DerbyRowNumberColumnRegressionSpec.cfc b/tests/specs/Query/DerbyRowNumberColumnRegressionSpec.cfc new file mode 100644 index 00000000..c4f648d4 --- /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( listToArray( lCase( result.columnList ) ) ).toInclude( "qb_rn" ); + expect( result.QB_RN[ 1 ] ).toBe( 7 ); + } ); + } ); + } + +} 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`" ); + } ); + } ); + } + +} 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" ); + } ); + } ); + } + +} 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" ); + } ); + } ); + } + +} 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" ); + } ); + } ); + } + +} diff --git a/tests/specs/Query/MySQLQueryBuilderSpec.cfc b/tests/specs/Query/MySQLQueryBuilderSpec.cfc index 1dad8d5f..e1f22788 100644 --- a/tests/specs/Query/MySQLQueryBuilderSpec.cfc +++ b/tests/specs/Query/MySQLQueryBuilderSpec.cfc @@ -1,5 +1,67 @@ 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" ] ); + } ); + + 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() { + 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( "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 ] ); + } ); + } ); + } + function selectAllColumns() { return "SELECT * FROM `users`"; } @@ -334,6 +396,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 ] }; } @@ -988,6 +1118,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`)", @@ -1057,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` = ?)", @@ -1172,6 +1313,74 @@ 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 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""')", + 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""') > ?", + 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/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" ); + } ); + } ); + } + +} 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" ); + } ); + } ); + } + +} 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" ); + } ); + } ); + } + +} 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 ); + } + +} 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 ); + } + } + +} diff --git a/tests/specs/Query/OracleQueryBuilderSpec.cfc b/tests/specs/Query/OracleQueryBuilderSpec.cfc index dfe27806..a270c591 100644 --- a/tests/specs/Query/OracleQueryBuilderSpec.cfc +++ b/tests/specs/Query/OracleQueryBuilderSpec.cfc @@ -1,5 +1,35 @@ 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" ); + } ); + + 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" ); + } ); + } ); + } + function selectAllColumns() { return "SELECT * FROM ""USERS"""; } @@ -346,6 +376,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 (?, ?, ?)", @@ -1007,6 +1105,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"")", @@ -1073,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"" = ?)", @@ -1188,6 +1304,68 @@ 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 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"")", + 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) > ?", + 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/OracleRowNumberColumnRegressionSpec.cfc b/tests/specs/Query/OracleRowNumberColumnRegressionSpec.cfc new file mode 100644 index 00000000..1b827d18 --- /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( listToArray( lCase( result.columnList ) ) ).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 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( listToArray( lCase( result.columnList ) ) ).notToInclude( "qb_rn" ); + expect( listToArray( lCase( result.columnList ) ) ).toInclude( "name" ); + } ); + } ); + } + +} 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 ); + } ); + } ); + } + +} 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 ); + } + } + +} 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(); + } ); + } ); + } + +} 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""" ); + } ); + } ); + } + +} diff --git a/tests/specs/Query/PostgresQueryBuilderSpec.cfc b/tests/specs/Query/PostgresQueryBuilderSpec.cfc index c7240bd8..4c821cc1 100644 --- a/tests/specs/Query/PostgresQueryBuilderSpec.cfc +++ b/tests/specs/Query/PostgresQueryBuilderSpec.cfc @@ -1,5 +1,102 @@ 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 ) ); + } ); + } ); + + 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 )#>>'{}' = ?" + ); + } ); + } ); + + 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 ] ); + } ); + + 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 ] ); + } ); + } ); + + 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() { return "SELECT * FROM ""users"""; } @@ -340,6 +437,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 (?, ?, ?)", @@ -1025,6 +1186,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""", @@ -1094,6 +1259,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"" = ?)", @@ -1209,6 +1378,74 @@ 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 CAST(""profile""->>'age' AS NUMERIC) >= ? AND CAST(""profile""->>'age' AS NUMERIC) < ?", + 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 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", + 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) > ?", + 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/PrefixedInferredSqlTypeRegressionSpec.cfc b/tests/specs/Query/PrefixedInferredSqlTypeRegressionSpec.cfc new file mode 100644 index 00000000..b3809db6 --- /dev/null +++ b/tests/specs/Query/PrefixedInferredSqlTypeRegressionSpec.cfc @@ -0,0 +1,43 @@ +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", + 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" ); + } ); + + 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) >= ?" ); + } ); + } ); + } + +} 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(); + } ); + } ); + } + +} 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(); + } ); + } ); + } + +} 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" ); + } ); + } ); + } + +} 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(); + } ); + } ); + } + +} diff --git a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc index 9ead9932..00963dae 100644 --- a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc +++ b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc @@ -1,5 +1,69 @@ 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 ) ); + } ); + } ); + + 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 ] ); + } ); + + 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 ] ); + } ); + } ); + } + private function getBuilder() { variables.utils = getMockBox().createMock( "qb.models.Query.QueryUtils" ).init(); variables.grammar = getMockBox().createMock( "qb.models.Grammars.SQLiteGrammar" ).init( variables.utils ); @@ -390,6 +454,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 (?, ?, ?)", @@ -1117,6 +1249,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""", @@ -1186,6 +1322,10 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { return { exception: "UnsupportedOperation" }; } + function deleteWithJoinsAndAliases() { + return { exception: "UnsupportedOperation" }; + } + function crossApply() { return { exception: "UnsupportedOperation" } } @@ -1209,6 +1349,68 @@ 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 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 ?)", + 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""') > ?", + 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/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" ); + } ); + } ); + } + +} 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 + ); + } ); + } ); + } + +} diff --git a/tests/specs/Query/ShouldWrapValuesSpec.cfc b/tests/specs/Query/ShouldWrapValuesSpec.cfc new file mode 100644 index 00000000..0b811b55 --- /dev/null +++ b/tests/specs/Query/ShouldWrapValuesSpec.cfc @@ -0,0 +1,210 @@ +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" ); + } ); + + 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" ); + } ); + + it( "isolates per-query wrapping overrides during concurrent compilation", function() { + var grammar = new qb.models.Grammars.PostgresGrammar(); + 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 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" + 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" + 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"; + + 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 ); + structDelete( server, unwrappedBuilderKey ); + structDelete( server, wrappedBuilderKey ); + structDelete( server, grammarKey ); + structDelete( server, unwrappedEnteredKey ); + structDelete( server, wrappedEnteredKey ); + } + } ); + } ); + + 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.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() { + 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/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] = ?" + ); + } ); + } ); + } + +} 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" ); + } ); + } + } ); + } + +} diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index ddef7734..7cc08c27 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -1,5 +1,542 @@ 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(); + 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( "infers bulk SQL types from negative and nullable values", function() { + 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 ] ) + .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" ) + .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" ) + .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""')" + ] ); + } ); + + 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() { + 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" + ); + } ); + + 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]" + ); + } ); + + 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 ] ); + } ); + + 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 ] ); + } ); + } ); + + 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() + .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( "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" ) + .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( "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( + 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() { return "SELECT * FROM [users]"; } @@ -334,6 +871,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 ] }; } @@ -1007,6 +1612,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]);", @@ -1095,6 +1714,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] = ?)", @@ -1222,6 +1848,68 @@ 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 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)", + 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""')) > ?", + 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", 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 ); + } + +} 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 + } ); + } ); + } ); + } + +} 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(); + } ); + } ); + } + +} 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'*/" + ); + } ); + } ); + } + +} diff --git a/tests/specs/SQLCommenterSpec.cfc b/tests/specs/SQLCommenterSpec.cfc index 09d0aa33..bdb03eee 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,35 @@ 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'*/" ); + } ); + + 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", () => { @@ -67,6 +118,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 new file mode 100644 index 00000000..b590357d --- /dev/null +++ b/tests/specs/Schema/BlueprintLifecycleSpec.cfc @@ -0,0 +1,160 @@ +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() + ]; + grammars.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" ] ); + } ); + + 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" ); + } ); + + 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 ); + } ); + } ); + } + + 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 + ); + } + +} diff --git a/tests/specs/Schema/DerbySchemaBuilderSpec.cfc b/tests/specs/Schema/DerbySchemaBuilderSpec.cfc index 91f0672d..9bf46ad8 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"" ()" ]; } @@ -28,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)" ]; } @@ -86,7 +111,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 +367,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 +384,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() { @@ -548,6 +581,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", @@ -594,7 +634,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"" = ?" ]; } @@ -606,7 +646,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/MySQLSchemaBuilderSpec.cfc b/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc index c9b6458a..587d987f 100644 --- a/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/MySQLSchemaBuilderSpec.cfc @@ -1,5 +1,42 @@ 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`" ] ); + } ); + 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'" + ); + } ); + } ); + } + function emptyTable() { return [ "CREATE TABLE `users` ()" ]; } @@ -28,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)" ]; } @@ -89,7 +130,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() { @@ -345,7 +386,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() { @@ -361,7 +402,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() { @@ -541,6 +590,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..336b128c 100644 --- a/tests/specs/Schema/OracleSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/OracleSchemaBuilderSpec.cfc @@ -4,11 +4,92 @@ 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" ); + + expect( schema.drop( "users", {}, false ).toSql() ).toBe( [ + "DROP TABLE ""APP"".""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;" + ] ); + } ); + + it( "normalizes unquoted identifiers for existence checks", () => { + var schema = getBuilder().setDefaultSchema( "app" ); + variables.mockGrammar.$( "runQuery", queryNew( "" ) ); + schema.hasTable( "audit.users" ); + + 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", () => { try { var schema = getBuilder(); - variables.mockGrammar.$( "hasSequence", true ); - variables.mockGrammar.$( "hasTrigger", true ); var statements = schema.drop( "users", {}, false ); if ( !isSimpleValue( statements ) ) { statements = statements.toSql(); @@ -19,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++ ) { @@ -34,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"".""'" ); + } ); } ); } @@ -69,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)" ]; } @@ -131,7 +225,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')))" ]; } @@ -394,7 +488,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'" ]; } @@ -411,7 +505,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() { @@ -605,6 +707,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", @@ -628,7 +737,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() { @@ -636,7 +749,7 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { } function dropIfExists() { - return [ "DROP TABLE ""USERS""" ]; + return dropTable(); } function dropColumn() { @@ -699,8 +812,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 525a7de9..e09638fa 100644 --- a/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc @@ -16,6 +16,74 @@ 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')" + ] ); + } ); + + 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)" + ] ); + } ); } ); } @@ -45,6 +113,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)" ]; } @@ -107,8 +179,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 +428,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 +445,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,14 +641,21 @@ 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" + ]; + } + + 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')", - "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" ]; } @@ -577,7 +664,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/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')" ] ); + } ); + } ); + } + +} 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" ); + } ); + } ); + } + +} 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" ); + } ); + } ); + } + +} 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" ); + } ); + } ); + } + +} diff --git a/tests/specs/Schema/SQLiteSchemaBuilderSpec.cfc b/tests/specs/Schema/SQLiteSchemaBuilderSpec.cfc index b343a650..2609d759 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"" ()" ]; } @@ -62,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)" ]; } @@ -124,7 +187,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 +453,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() { @@ -556,6 +627,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..6d42dd00 100644 --- a/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/SqlServerSchemaBuilderSpec.cfc @@ -1,5 +1,173 @@ 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']" + ] + ); + } ); + + 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() { + 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'" ] ); + } ); + } ); + + 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'" + ); + } ); + } ); + + 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( + "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]" + ] ); + } ); + } ); + + 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() { return [ "CREATE TABLE [users] ()" ]; } @@ -26,6 +194,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)" ]; } @@ -84,7 +256,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')))" ]; } @@ -357,7 +529,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() { @@ -402,7 +586,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() { @@ -504,17 +688,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'" ]; } @@ -535,6 +719,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'))", @@ -546,7 +737,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])",