diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index 47148a8..c474330 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -10,8 +10,10 @@ component { settings = { "defaultGrammar": "AutoDiscover@qb", "defaultReturnFormat": "array", + "defaultReturnFormatOptions": {}, "preventDuplicateJoins": false, "validateOperatorsAndCombinators": true, + "validateQueryExecuteReturnType": false, "collectQueryLog": true, "convertEmptyStringsToNull": true, "validateQueryParamStructKeys": true, @@ -29,7 +31,8 @@ component { }, "shouldMaxRowsOverrideToAll": function( maxRows ) { return maxRows <= 0; - } + }, + "returnFormatters": {} }; interceptorSettings = { "customInterceptionPoints": "preQBExecute,postQBExecute" }; @@ -55,15 +58,29 @@ component { .initArg( name = "integerSQLType", value = settings.integerSQLType ) .initArg( name = "decimalSQLType", value = settings.decimalSQLType ); + binder + .map( alias = "StructFormatter@qb", force = true ) + .to( "qb.models.Query.Formatters.StructFormatter" ) + .initArg( name = "utils", ref = "QueryUtils@qb" ); + + binder + .map( alias = "ReturnFormatterRegistry@qb", force = true ) + .to( "qb.models.Query.ReturnFormatterRegistry" ) + .initArg( name = "utils", ref = "QueryUtils@qb" ) + .initArg( name = "returnFormatters", value = settings.returnFormatters ); + binder .map( alias = "QueryBuilder@qb", force = true ) .to( "qb.models.Query.QueryBuilder" ) .initArg( name = "grammar", ref = settings.defaultGrammar ) .initArg( name = "utils", ref = "QueryUtils@qb" ) + .initArg( name = "returnFormatterRegistry", ref = "ReturnFormatterRegistry@qb" ) .initArg( name = "preventDuplicateJoins", value = settings.preventDuplicateJoins ) .initArg( name = "validateOperatorsAndCombinators", value = settings.validateOperatorsAndCombinators ) + .initArg( name = "validateQueryExecuteReturnType", value = settings.validateQueryExecuteReturnType ) .initArg( name = "collectQueryLog", value = settings.collectQueryLog ) .initArg( name = "returnFormat", value = settings.defaultReturnFormat ) + .initArg( name = "defaultReturnFormatOptions", value = settings.defaultReturnFormatOptions ) .initArg( name = "defaultOptions", value = settings.defaultOptions ) .initArg( name = "sqlCommenter", ref = "ColdBoxSQLCommenter@qb" ) .initArg( name = "shouldMaxRowsOverrideToAll", value = settings.shouldMaxRowsOverrideToAll ); diff --git a/models/Query/Formatters/StructFormatter.cfc b/models/Query/Formatters/StructFormatter.cfc new file mode 100644 index 0000000..05554fd --- /dev/null +++ b/models/Query/Formatters/StructFormatter.cfc @@ -0,0 +1,14 @@ +component accessors="true" { + + property name="utils"; + + public StructFormatter function init( any utils = new qb.models.Query.QueryUtils() ) { + variables.utils = arguments.utils; + return this; + } + + public function toFormatter( struct options = {} ) { + return new qb.models.Query.Formatters.StructReturnFormatter( variables.utils, arguments.options ); + } + +} diff --git a/models/Query/Formatters/StructReturnFormatter.cfc b/models/Query/Formatters/StructReturnFormatter.cfc new file mode 100644 index 0000000..ebb5fa8 --- /dev/null +++ b/models/Query/Formatters/StructReturnFormatter.cfc @@ -0,0 +1,27 @@ +component accessors="true" { + + property name="utils"; + property name="options"; + + public StructReturnFormatter function init( any utils = new qb.models.Query.QueryUtils(), struct options = {} ) { + variables.utils = arguments.utils; + variables.options = arguments.options; + return this; + } + + public struct function format( required any q ) { + if ( + !variables.options.keyExists( "columnKey" ) || isNull( variables.options.columnKey ) || !len( + variables.options.columnKey + ) + ) { + throw( + type = "MissingColumnKey", + message = "A columnKey option is required for the [struct] return formatter." + ); + } + + return variables.utils.queryToStructOfStructs( arguments.q, variables.options.columnKey ); + } + +} diff --git a/models/Query/QueryBuilder.cfc b/models/Query/QueryBuilder.cfc index c3a10f5..d549a6c 100644 --- a/models/Query/QueryBuilder.cfc +++ b/models/Query/QueryBuilder.cfc @@ -23,6 +23,11 @@ component displayname="QueryBuilder" accessors="true" { */ 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 +43,13 @@ component displayname="QueryBuilder" accessors="true" { */ property name="validateOperatorsAndCombinators"; + /** + * If true, QB throws when queryExecute returntype options are passed. + * If false, QB strips those options so return formatters always receive a query. + * @default false + */ + property name="validateQueryExecuteReturnType"; + /** * paginationCollector * A component or struct with a `generateWithResults` method. @@ -289,11 +301,17 @@ 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' + * @defaultReturnFormatOptions Options passed to the default return formatter. + * Default: {} + * @returnFormatterRegistry Registry used to resolve named return formatters. * @preventDuplicateJoins Whether QB should ignore a .join() statement that matches an existing join * Default: false * @validateOperatorsAndCombinators * Whether QB validates operators/combinators before storing clauses. * Default: true + * @validateQueryExecuteReturnType + * Whether QB throws when queryExecute returntype options are passed. + * Default: false * @paginationCollector The closure that processes the pagination result. * Default: cbpaginator.models.Pagination * @columnFormatter The closure that modifies each column before being @@ -315,8 +333,11 @@ component displayname="QueryBuilder" accessors="true" { grammar = new qb.models.Grammars.BaseGrammar(), utils = new qb.models.Query.QueryUtils(), returnFormat = "array", + struct defaultReturnFormatOptions = {}, + returnFormatterRegistry, preventDuplicateJoins = false, validateOperatorsAndCombinators = true, + validateQueryExecuteReturnType = false, paginationCollector = new cbpaginator.models.Pagination(), columnFormatter, parentQuery, @@ -330,6 +351,11 @@ component displayname="QueryBuilder" accessors="true" { setPreventDuplicateJoins( arguments.preventDuplicateJoins ); setValidateOperatorsAndCombinators( arguments.validateOperatorsAndCombinators ); + setValidateQueryExecuteReturnType( arguments.validateQueryExecuteReturnType ); + if ( isNull( arguments.returnFormatterRegistry ) ) { + arguments.returnFormatterRegistry = new qb.models.Query.ReturnFormatterRegistry( arguments.utils ); + } + setReturnFormatterRegistry( arguments.returnFormatterRegistry ); if ( isNull( arguments.columnFormatter ) ) { arguments.columnFormatter = function( column ) { return column; @@ -341,7 +367,7 @@ component displayname="QueryBuilder" accessors="true" { setParentQuery( arguments.parentQuery ); } param variables.defaultOptions = {}; - setReturnFormat( arguments.returnFormat ); + setReturnFormat( arguments.returnFormat, arguments.defaultReturnFormatOptions ); mergeDefaultOptions( arguments.defaultOptions ); setSqlCommenter( arguments.sqlCommenter ); setCollectQueryLog( arguments.collectQueryLog ); @@ -3543,6 +3569,24 @@ component displayname="QueryBuilder" accessors="true" { } ); } ); + 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" + ); + } + } ); + } + if ( isClosure( arguments.deleteUnmatched ) || isCustomFunction( arguments.deleteUnmatched ) ) { var deleteRestrictions = newQuery().setColumnFormatter( ( column ) => { if ( listLen( column, "." ) > 1 ) { @@ -3554,6 +3598,10 @@ component displayname="QueryBuilder" accessors="true" { arguments.deleteUnmatched = deleteRestrictions; } + if ( getUtils().isBuilder( arguments.deleteUnmatched ) ) { + addBindingsFromBuilder( arguments.deleteUnmatched ); + } + columns.each( ( c ) => { c.formatted = mapToColumnType( c.formatted ); } ); @@ -4289,18 +4337,35 @@ component displayname="QueryBuilder" accessors="true" { } if ( isQuery( q ) ) { - return returnFormat( q ); + return applyReturnFormat( q ); } if ( isArray( q ) ) { - return returnFormat( q ); + return applyReturnFormat( q ); } if ( !q.keyExists( "result" ) || !q.keyExists( "query" ) ) { - return returnFormat( q ); + return applyReturnFormat( q ); } - return { result: q.result, query: returnFormat( q.query ) }; + return { result: q.result, query: applyReturnFormat( q.query ) }; + } + + private any function applyReturnFormat( required any q ) { + var formatter = getReturnFormat(); + + if ( isClosure( formatter ) || isCustomFunction( formatter ) ) { + return formatter( arguments.q ); + } + + if ( structKeyExists( formatter, "format" ) ) { + return formatter.format( arguments.q ); + } + + throw( + type = "InvalidFormat", + message = "The configured return formatter must be a closure or a component with a format method." + ); } /** @@ -4315,6 +4380,7 @@ component displayname="QueryBuilder" accessors="true" { */ private any function runQuery( required string sql, struct options = {}, string returnObject = "query" ) { structAppend( arguments.options, getDefaultOptions(), false ); + normalizeQueryExecuteReturnTypeOptions( arguments.options ); var bindings = getBindings( except = getAggregate().isEmpty() ? [] : [ "select" ] ); var result = grammar.runQuery( @@ -4367,10 +4433,12 @@ component displayname="QueryBuilder" accessors="true" { grammar = getGrammar(), utils = getUtils(), returnFormat = getReturnFormat(), + returnFormatterRegistry = getReturnFormatterRegistry(), paginationCollector = isNull( variables.paginationCollector ) ? javacast( "null", "" ) : variables.paginationCollector, columnFormatter = isNull( getColumnFormatter() ) ? javacast( "null", "" ) : getColumnFormatter(), parentQuery = isNull( getParentQuery() ) ? javacast( "null", "" ) : getParentQuery(), - defaultOptions = getDefaultOptions() + defaultOptions = getDefaultOptions(), + validateQueryExecuteReturnType = getValidateQueryExecuteReturnType() ); } @@ -4490,27 +4558,19 @@ component displayname="QueryBuilder" accessors="true" { * 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. * * @format "query", "array", or a closure. + * @options Options passed to named return formatter factories. * * @return qb.models.Query.QueryBuilder */ - public QueryBuilder function setReturnFormat( required any format ) { + public QueryBuilder function setReturnFormat( required any format, struct options = {} ) { structDelete( variables.defaultOptions, "returntype" ); if ( isClosure( arguments.format ) || isCustomFunction( arguments.format ) ) { variables.returnFormat = format; - } else if ( arguments.format == "array" ) { - variables.returnFormat = function( q ) { - return getUtils().queryToArrayOfStructs( q ); - }; - } else if ( arguments.format == "query" ) { - variables.returnFormat = function( q ) { - return q; - }; - } else if ( arguments.format == "none" ) { - variables.returnFormat = function( q ) { - return q; - }; } else { - throw( type = "InvalidFormat", message = "The format passed to Builder is invalid." ); + variables.returnFormat = getReturnFormatterRegistry().getReturnFormatter( + arguments.format, + arguments.options + ); } return this; @@ -4536,17 +4596,35 @@ component displayname="QueryBuilder" accessors="true" { * * @returnFormat "query", "array", or a closure. * @callback The code to execute with the given return format. + * @options Options passed to named return formatter factories. * * @return any */ - public any function withReturnFormat( required any returnFormat, required any callback ) { + public any function withReturnFormat( required any returnFormat, required any callback, struct options = {} ) { var originalReturnFormat = getReturnFormat(); - setReturnFormat( arguments.returnFormat ); + setReturnFormat( arguments.returnFormat, arguments.options ); var result = callback(); setReturnFormat( originalReturnFormat ); return result; } + private void function normalizeQueryExecuteReturnTypeOptions( required struct options ) { + if ( !arguments.options.keyExists( "returntype" ) ) { + return; + } + + if ( getValidateQueryExecuteReturnType() ) { + throw( + type = "InvalidQueryExecuteOption", + message = "The queryExecute returntype option cannot be used with qb return formatters." + ); + } + + structDelete( arguments.options, "returntype" ); + structDelete( arguments.options, "columnkey" ); + structDelete( arguments.options, "columnKey" ); + } + /** * Runs the code inside the callback with the given columns selected and then sets the columns back to its original value. * diff --git a/models/Query/QueryUtils.cfc b/models/Query/QueryUtils.cfc index 7c480d7..5b0b13e 100644 --- a/models/Query/QueryUtils.cfc +++ b/models/Query/QueryUtils.cfc @@ -378,6 +378,32 @@ component singleton displayname="QueryUtils" accessors="true" { return results; } + /** + * Converts a query object to a struct of structs keyed by the provided column. + * + * @q The query to convert. + * @columnKey The query column to use as the key for the returned struct. + * + * @return struct + */ + public struct function queryToStructOfStructs( required any q, required string columnKey ) { + var rows = queryToArrayOfStructs( arguments.q ); + var results = {}; + + for ( var row in rows ) { + if ( !row.keyExists( arguments.columnKey ) ) { + throw( + type = "MissingColumnKey", + message = "The columnKey [#arguments.columnKey#] was not found in the query results." + ); + } + + results[ row[ arguments.columnKey ] ] = row; + } + + return results; + } + /** * Remove a list of columns from a specified query. * diff --git a/models/Query/ReturnFormatterRegistry.cfc b/models/Query/ReturnFormatterRegistry.cfc new file mode 100644 index 0000000..ff6f29d --- /dev/null +++ b/models/Query/ReturnFormatterRegistry.cfc @@ -0,0 +1,165 @@ +component accessors="true" singleton { + + property name="utils"; + property name="wirebox" inject="wirebox"; + + public ReturnFormatterRegistry function init( + any utils = new qb.models.Query.QueryUtils(), + struct returnFormatters = {} + ) { + variables.utils = arguments.utils; + variables.wirebox = javacast( "null", "" ); + variables.returnFormatters = {}; + + registerBuiltInReturnFormatters(); + registerReturnFormatters( arguments.returnFormatters ); + + return this; + } + + public ReturnFormatterRegistry function registerReturnFormatters( required struct returnFormatters ) { + for ( var name in arguments.returnFormatters ) { + var definition = normalizeFormatterDefinition( arguments.returnFormatters[ name ] ); + registerReturnFormatter( + name = name, + factory = definition.factory, + options = definition.options, + properties = definition.properties, + force = definition.force + ); + } + + return this; + } + + public ReturnFormatterRegistry function registerReturnFormatter( + required string name, + required any factory, + struct options = {}, + struct properties = {}, + boolean force = false + ) { + if ( hasReturnFormatter( arguments.name ) && !arguments.force ) { + throw( + type = "DuplicateReturnFormatter", + message = "A return formatter named [#arguments.name#] has already been registered. Pass force = true to replace it." + ); + } + + variables.returnFormatters[ arguments.name ] = { + "factory": arguments.factory, + "options": arguments.options, + "properties": arguments.properties + }; + + return this; + } + + public function getReturnFormatter( required string name, struct options = {} ) { + if ( !hasReturnFormatter( arguments.name ) ) { + throw( + type = "UnknownReturnFormatter", + message = "No return formatter named [#arguments.name#] has been registered." + ); + } + + var definition = variables.returnFormatters[ arguments.name ]; + var formatterOptions = duplicate( definition.options ); + structAppend( formatterOptions, arguments.options, true ); + + var factory = resolveFactory( definition.factory, definition.properties ); + + if ( isClosure( factory ) || isCustomFunction( factory ) ) { + return factory( formatterOptions ); + } + + return factory.toFormatter( formatterOptions ); + } + + public boolean function hasReturnFormatter( required string name ) { + return variables.returnFormatters.keyExists( arguments.name ); + } + + private void function registerBuiltInReturnFormatters() { + registerReturnFormatter( + name = "query", + factory = function( options ) { + return function( q ) { + return q; + }; + } + ); + registerReturnFormatter( + name = "none", + factory = function( options ) { + return function( q ) { + return q; + }; + } + ); + registerReturnFormatter( + name = "array", + factory = function( options ) { + return function( q ) { + return variables.utils.queryToArrayOfStructs( q ); + }; + } + ); + registerReturnFormatter( + name = "struct", + factory = new qb.models.Query.Formatters.StructFormatter( variables.utils ) + ); + } + + private struct function normalizeFormatterDefinition( required any definition ) { + if ( isStruct( arguments.definition ) && arguments.definition.keyExists( "factory" ) ) { + param arguments.definition.options = {}; + param arguments.definition.properties = {}; + param arguments.definition.force = false; + + return { + "factory": arguments.definition.factory, + "options": arguments.definition.options, + "properties": arguments.definition.properties, + "force": arguments.definition.force + }; + } + + return { + "factory": arguments.definition, + "options": {}, + "properties": {}, + "force": false + }; + } + + private function resolveFactory( required any factory, struct properties = {} ) { + if ( isClosure( arguments.factory ) || isCustomFunction( arguments.factory ) ) { + return arguments.factory; + } + + if ( isSimpleValue( arguments.factory ) ) { + if ( isNull( variables.wirebox ) ) { + throw( + type = "WireBoxRequired", + message = "A WireBox instance is required to resolve the [#arguments.factory#] return formatter factory." + ); + } + + return variables.wirebox.getInstance( + name = arguments.factory, + initArguments = { "properties": arguments.properties } + ); + } + + if ( !structKeyExists( arguments.factory, "toFormatter" ) ) { + throw( + type = "InvalidReturnFormatter", + message = "Return formatter factories must be a closure, WireBox mapping name, or component with a toFormatter method." + ); + } + + return arguments.factory; + } + +} diff --git a/tests/resources/AbstractQueryBuilderSpec.cfc b/tests/resources/AbstractQueryBuilderSpec.cfc index 21f2160..b7bb680 100644 --- a/tests/resources/AbstractQueryBuilderSpec.cfc +++ b/tests/resources/AbstractQueryBuilderSpec.cfc @@ -2945,34 +2945,38 @@ component extends="testbox.system.BaseSpec" { } ); it( "can delete unmatched source rows in an upsert with additional restrictions (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 = ( q ) => { - q.where( "active", 1 ); - }, - toSql = true - ); - }, upsertWithDeleteRestricted() ); + 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", () => { @@ -2992,6 +2996,27 @@ component extends="testbox.system.BaseSpec" { ); }, 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() { @@ -3067,7 +3092,9 @@ component extends="testbox.system.BaseSpec" { } expect( local.sql ).toBeWithCase( expected.sql ); - getTestBindings( builder, arguments.withFullBindings ).each( ( testBinding, index ) => { + 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(); diff --git a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc index 1980b38..2e7b851 100644 --- a/tests/specs/Query/Abstract/QueryExecutionSpec.cfc +++ b/tests/specs/Query/Abstract/QueryExecutionSpec.cfc @@ -885,8 +885,9 @@ component extends="testbox.system.BaseSpec" { it( "can return the max date of a table", function() { var builder = getBuilder(); - var expectedMax = now(); - var expectedQuery = queryNew( "aggregate", "timestamp", [ { aggregate: expectedMax } ] ); + var maxDate = now(); + var expectedQuery = queryNew( "aggregate", "timestamp", [ { aggregate: maxDate } ] ); + var expectedMax = expectedQuery.aggregate; builder .$( "runQuery" ) .$args( sql = "SELECT MAX(""login_date"") AS ""aggregate"" FROM ""users""", options = {} ) @@ -1287,6 +1288,193 @@ component extends="testbox.system.BaseSpec" { expect( runQueryLog ).toHaveLength( 1, "runQuery should have been called once" ); expect( runQueryLog[ 1 ].sql ).toBe( "SELECT ""id"" FROM ""users""" ); } ); + + it( "can return a struct of structs", function() { + var builder = getBuilder(); + builder.setReturnFormat( "struct", { "columnKey": "name" } ); + var data = [ { "id": 1, "name": "jane" }, { "id": 2, "name": "john" } ]; + var expectedQuery = queryNew( "id,name", "integer,varchar", data ); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""id"", ""name"" FROM ""users""", options = { "result": "local.result" } ) + .$results( expectedQuery ); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""id"", ""name"" FROM ""users""", options = {} ) + .$results( expectedQuery ); + + var results = builder + .select( [ "id", "name" ] ) + .from( "users" ) + .get(); + + expect( results ).toBe( { "jane": data[ 1 ], "john": data[ 2 ] } ); + var runQueryLog = builder.$callLog().runQuery; + expect( runQueryLog ).toBeArray(); + expect( runQueryLog ).toHaveLength( 1, "runQuery should have been called once" ); + expect( runQueryLog[ 1 ].sql ).toBe( "SELECT ""id"", ""name"" FROM ""users""" ); + } ); + + it( "can return a struct of structs using withReturnFormat", function() { + var builder = getBuilder(); + var data = [ { "id": 1, "name": "jane" }, { "id": 2, "name": "john" } ]; + var expectedQuery = queryNew( "id,name", "integer,varchar", data ); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""id"", ""name"" FROM ""users""", options = { "result": "local.result" } ) + .$results( expectedQuery ); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""id"", ""name"" FROM ""users""", options = {} ) + .$results( expectedQuery ); + + var results = builder.withReturnFormat( + "struct", + function() { + return builder + .select( [ "id", "name" ] ) + .from( "users" ) + .get(); + }, + { "columnKey": "name" } + ); + + expect( results ).toBe( { "jane": data[ 1 ], "john": data[ 2 ] } ); + } ); + + it( "uses the last row when struct return format keys are duplicated", function() { + var builder = getBuilder(); + builder.setReturnFormat( "struct", { "columnKey": "name" } ); + var data = [ { "id": 1, "name": "jane" }, { "id": 2, "name": "jane" } ]; + var expectedQuery = queryNew( "id,name", "integer,varchar", data ); + builder.$( "runQuery", expectedQuery ); + + var results = builder + .select( [ "id", "name" ] ) + .from( "users" ) + .get(); + + expect( results ).toBe( { "jane": data[ 2 ] } ); + } ); + + it( "can use custom registered return formatters", function() { + var registry = new qb.models.Query.ReturnFormatterRegistry(); + registry.registerReturnFormatter( + "firstId", + function( options ) { + return function( q ) { + return options.prefix & q.id[ 1 ]; + }; + }, + { "prefix": "user-" } + ); + var builder = getMockBox() + .createMock( "qb.models.Query.QueryBuilder" ) + .init( + grammar = getMockBox().createMock( "qb.models.Grammars.BaseGrammar" ).init(), + returnFormatterRegistry = registry + ); + builder.setReturnFormat( "firstId", { "prefix": "account-" } ); + var expectedQuery = queryNew( "id", "integer", [ { "id": 1 } ] ); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""id"" FROM ""users""", options = { "result": "local.result" } ) + .$results( expectedQuery ); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""id"" FROM ""users""", options = {} ) + .$results( expectedQuery ); + + var results = builder + .select( "id" ) + .from( "users" ) + .get(); + + expect( results ).toBe( "account-1" ); + } ); + + it( "creates a default return formatter registry when none is passed", function() { + var builder = getBuilder(); + builder.setReturnFormat( "none" ); + var expectedQuery = queryNew( "id", "integer", [ { "id": 1 } ] ); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""id"" FROM ""users""", options = { "result": "local.result" } ) + .$results( expectedQuery ); + builder + .$( "runQuery" ) + .$args( sql = "SELECT ""id"" FROM ""users""", options = {} ) + .$results( expectedQuery ); + + expect( + builder + .select( "id" ) + .from( "users" ) + .get() + ).toBe( expectedQuery ); + } ); + + it( "throws from the struct formatter at runtime if columnKey is missing", function() { + var builder = getBuilder(); + builder.setReturnFormat( "struct" ); + var expectedQuery = queryNew( "id,name", "integer,varchar", [ { "id": 1, "name": "jane" } ] ); + builder.$( "runQuery", expectedQuery ); + + expect( function() { + builder + .select( [ "id", "name" ] ) + .from( "users" ) + .get(); + } ).toThrow( type = "MissingColumnKey" ); + } ); + + it( "throws from the struct formatter at runtime if the columnKey column is missing", function() { + var builder = getBuilder(); + builder.setReturnFormat( "struct", { "columnKey": "name" } ); + var expectedQuery = queryNew( "id", "integer", [ { "id": 1 } ] ); + builder.$( "runQuery", expectedQuery ); + + expect( function() { + builder + .select( "id" ) + .from( "users" ) + .get(); + } ).toThrow( type = "MissingColumnKey" ); + } ); + + it( "can strip native queryExecute returntype options", function() { + var builder = getBuilder(); + builder.setReturnFormat( "query" ); + var expectedQuery = queryNew( "id", "integer", [ { "id": 1 } ] ); + builder + .getGrammar() + .$( "runQuery" ) + .$results( expectedQuery ); + + var results = builder + .select( "id" ) + .from( "users" ) + .get( options = { "returntype": "array", "columnkey": "id", "columnKey": "id" } ); + + expect( results ).toBe( expectedQuery ); + expect( builder.getGrammar().$callLog().runQuery[ 1 ].options ).toBe( {} ); + } ); + + it( "can validate native queryExecute returntype options", function() { + var builder = getMockBox() + .createMock( "qb.models.Query.QueryBuilder" ) + .init( + grammar = getMockBox().createMock( "qb.models.Grammars.BaseGrammar" ).init(), + validateQueryExecuteReturnType = true + ); + + expect( function() { + builder + .select( "id" ) + .from( "users" ) + .get( options = { "returntype": "array" } ); + } ).toThrow( type = "InvalidQueryExecuteOption" ); + } ); } ); describe( "compiling the same builder multiple times", function() { diff --git a/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc b/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc new file mode 100644 index 0000000..b9ee69e --- /dev/null +++ b/tests/specs/Query/Abstract/ReturnFormatterRegistrySpec.cfc @@ -0,0 +1,117 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "ReturnFormatterRegistry", function() { + it( "registers the built-in return formatters", function() { + var registry = new qb.models.Query.ReturnFormatterRegistry(); + + expect( registry.hasReturnFormatter( "array" ) ).toBeTrue(); + expect( registry.hasReturnFormatter( "query" ) ).toBeTrue(); + expect( registry.hasReturnFormatter( "none" ) ).toBeTrue(); + expect( registry.hasReturnFormatter( "struct" ) ).toBeTrue(); + } ); + + it( "registers closure factories", function() { + var registry = new qb.models.Query.ReturnFormatterRegistry(); + registry.registerReturnFormatter( + "ids", + function( options ) { + return function( q ) { + return options.prefix & q.id[ 1 ]; + }; + }, + { "prefix": "user-" } + ); + + var formatter = registry.getReturnFormatter( "ids" ); + var q = queryNew( "id", "integer", [ { "id": 1 } ] ); + + expect( formatter( q ) ).toBe( "user-1" ); + } ); + + it( "merges runtime options over registered options", function() { + var registry = new qb.models.Query.ReturnFormatterRegistry(); + registry.registerReturnFormatter( + "ids", + function( options ) { + return function( q ) { + return options.prefix & q.id[ 1 ]; + }; + }, + { "prefix": "user-" } + ); + + var formatter = registry.getReturnFormatter( "ids", { "prefix": "account-" } ); + var q = queryNew( "id", "integer", [ { "id": 1 } ] ); + + expect( formatter( q ) ).toBe( "account-1" ); + } ); + + it( "throws for duplicate formatter names unless force is true", function() { + var registry = new qb.models.Query.ReturnFormatterRegistry(); + var factory = function( options ) { + return function( q ) { + return q; + }; + }; + registry.registerReturnFormatter( "custom", factory ); + + expect( function() { + registry.registerReturnFormatter( "custom", factory ); + } ).toThrow( type = "DuplicateReturnFormatter" ); + + registry.registerReturnFormatter( + name = "custom", + factory = function( options ) { + return function( q ) { + return "forced"; + }; + }, + force = true + ); + + expect( registry.getReturnFormatter( "custom" )( queryNew( "" ) ) ).toBe( "forced" ); + } ); + + it( "normalizes shorthand return formatter definitions", function() { + var registry = new qb.models.Query.ReturnFormatterRegistry( + returnFormatters = { + "custom": function( options ) { + return function( q ) { + return "custom"; + }; + } + } + ); + + expect( registry.getReturnFormatter( "custom" )( queryNew( "" ) ) ).toBe( "custom" ); + } ); + + it( "resolves WireBox formatter factories with properties", function() { + var registry = new qb.models.Query.ReturnFormatterRegistry(); + registry.setWirebox( { + "getInstance": function( name, initArguments ) { + expect( name ).toBe( "MyFormatter@testing" ); + expect( initArguments ).toBe( { "properties": { "prefix": "user-" } } ); + return { + "toFormatter": function( options ) { + return function( q ) { + return initArguments.properties.prefix & options.suffix; + }; + } + }; + } + } ); + registry.registerReturnFormatter( + name = "wirebox", + factory = "MyFormatter@testing", + properties = { "prefix": "user-" }, + options = { "suffix": "1" } + ); + + expect( registry.getReturnFormatter( "wirebox" )( queryNew( "" ) ) ).toBe( "user-1" ); + } ); + } ); + } + +} diff --git a/tests/specs/Query/DerbyQueryBuilderSpec.cfc b/tests/specs/Query/DerbyQueryBuilderSpec.cfc index 7ea6549..94fe3d0 100644 --- a/tests/specs/Query/DerbyQueryBuilderSpec.cfc +++ b/tests/specs/Query/DerbyQueryBuilderSpec.cfc @@ -1018,6 +1018,19 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function upsertUpdateWithExplicitValue() { + return { + sql: "MERGE INTO ""vendors"" ""qb_target"" USING (VALUES (?, ?, ?, ?)) AS ""qb_src"" ON ""qb_target"".""vendorCode"" = ""qb_src"".""vendorCode"" AND ""qb_target"".""code"" = ""qb_src"".""code"" WHEN MATCHED THEN UPDATE SET ""count"" = vendors.count + 1, ""name"" = ? WHEN NOT MATCHED THEN INSERT (""code"", ""count"", ""name"", ""vendorCode"") VALUES (""qb_src"".""code"", ""qb_src"".""count"", ""qb_src"".""name"", ""qb_src"".""vendorCode"")", + bindings: [ + "BB", + 1, + "New Name", + "AA", + "New Name" + ] + }; + } + function deleteAll() { return "DELETE FROM ""users"""; } diff --git a/tests/specs/Query/MySQLQueryBuilderSpec.cfc b/tests/specs/Query/MySQLQueryBuilderSpec.cfc index 7ba8158..1dad8d5 100644 --- a/tests/specs/Query/MySQLQueryBuilderSpec.cfc +++ b/tests/specs/Query/MySQLQueryBuilderSpec.cfc @@ -1017,6 +1017,19 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function upsertUpdateWithExplicitValue() { + return { + sql: "INSERT INTO `vendors` (`code`, `count`, `name`, `vendorCode`) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE `count` = vendors.count + 1, `name` = ?", + bindings: [ + "BB", + 1, + "New Name", + "AA", + "New Name" + ] + }; + } + function deleteAll() { return "DELETE FROM `users`"; } diff --git a/tests/specs/Query/OracleQueryBuilderSpec.cfc b/tests/specs/Query/OracleQueryBuilderSpec.cfc index 63b6412..dfe2780 100644 --- a/tests/specs/Query/OracleQueryBuilderSpec.cfc +++ b/tests/specs/Query/OracleQueryBuilderSpec.cfc @@ -1036,6 +1036,19 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function upsertUpdateWithExplicitValue() { + return { + sql: "MERGE INTO ""VENDORS"" ""QB_TARGET"" USING (SELECT ?, ?, ?, ? FROM dual) ""QB_SRC"" ON ""QB_TARGET"".""VENDORCODE"" = ""QB_SRC"".""VENDORCODE"" AND ""QB_TARGET"".""CODE"" = ""QB_SRC"".""CODE"" WHEN MATCHED THEN UPDATE SET ""COUNT"" = vendors.count + 1, ""NAME"" = ? WHEN NOT MATCHED THEN INSERT (""CODE"", ""COUNT"", ""NAME"", ""VENDORCODE"") VALUES (""QB_SRC"".""CODE"", ""QB_SRC"".""COUNT"", ""QB_SRC"".""NAME"", ""QB_SRC"".""VENDORCODE"")", + bindings: [ + "BB", + 1, + "New Name", + "AA", + "New Name" + ] + }; + } + function deleteAll() { return "DELETE FROM ""USERS"""; } diff --git a/tests/specs/Query/PostgresQueryBuilderSpec.cfc b/tests/specs/Query/PostgresQueryBuilderSpec.cfc index 5de9fd9..c7240bd 100644 --- a/tests/specs/Query/PostgresQueryBuilderSpec.cfc +++ b/tests/specs/Query/PostgresQueryBuilderSpec.cfc @@ -1054,6 +1054,19 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function upsertUpdateWithExplicitValue() { + return { + sql: "INSERT INTO ""vendors"" (""code"", ""count"", ""name"", ""vendorCode"") VALUES (?, ?, ?, ?) ON CONFLICT (""vendorCode"", ""code"") DO UPDATE SET ""count"" = vendors.count + 1, ""name"" = ?", + bindings: [ + "BB", + 1, + "New Name", + "AA", + "New Name" + ] + }; + } + function deleteAll() { return "DELETE FROM ""users"""; } diff --git a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc index d92772e..9ead993 100644 --- a/tests/specs/Query/SQLiteQueryBuilderSpec.cfc +++ b/tests/specs/Query/SQLiteQueryBuilderSpec.cfc @@ -1146,6 +1146,19 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function upsertUpdateWithExplicitValue() { + return { + sql: "INSERT INTO ""vendors"" (""code"", ""count"", ""name"", ""vendorCode"") VALUES (?, ?, ?, ?) ON CONFLICT (""vendorCode"", ""code"") DO UPDATE SET ""count"" = vendors.count + 1, ""name"" = ?", + bindings: [ + "BB", + 1, + "New Name", + "AA", + "New Name" + ] + }; + } + function deleteAll() { return "DELETE FROM ""users"""; } diff --git a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc index c983878..ddef773 100644 --- a/tests/specs/Query/SqlServerQueryBuilderSpec.cfc +++ b/tests/specs/Query/SqlServerQueryBuilderSpec.cfc @@ -1031,7 +1031,20 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { function upsertWithDeleteRestricted() { 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]) WHEN NOT MATCHED BY SOURCE AND [qb_target].[active] = ? THEN DELETE;", - bindings: [ 1, 1 ] + bindings: [ + { + "value": 1, + "cfsqltype": "INTEGER", + "null": false, + "list": false + }, + { + "value": 0, + "cfsqltype": "INTEGER", + "null": false, + "list": false + } + ] }; } @@ -1042,6 +1055,19 @@ component extends="tests.resources.AbstractQueryBuilderSpec" { }; } + function upsertUpdateWithExplicitValue() { + return { + sql: "MERGE [vendors] AS [qb_target] USING (VALUES (?, ?, ?, ?)) AS [qb_src] ([code], [count], [name], [vendorCode]) ON [qb_target].[vendorCode] = [qb_src].[vendorCode] AND [qb_target].[code] = [qb_src].[code] WHEN MATCHED THEN UPDATE SET [count] = vendors.count + 1, [name] = ? WHEN NOT MATCHED BY TARGET THEN INSERT ([code], [count], [name], [vendorCode]) VALUES ([code], [count], [name], [vendorCode]);", + bindings: [ + "BB", + 1, + "New Name", + "AA", + "New Name" + ] + }; + } + function deleteAll() { return "DELETE FROM [users]"; }