diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index f9b70e97..1533e588 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -37,7 +37,7 @@ component { "returnFormatters": {} }; - interceptorSettings = { "customInterceptionPoints": "preQBExecute,postQBExecute" }; + interceptorSettings = { "customInterceptionPoints": "preQBExecute,postQBExecute,onQBExecuteException" }; } function onLoad() { diff --git a/box.json b/box.json index b3d582b4..2d676cd4 100644 --- a/box.json +++ b/box.json @@ -1,6 +1,6 @@ { "name":"qb", - "version":"13.1.0", + "version":"14.0.3-alpha.1", "author":"Eric Peterson", "homepage":"https://github.com/coldbox-modules/qb", "documentation":"https://github.com/coldbox-modules/qb", diff --git a/models/Grammars/BaseGrammar.cfc b/models/Grammars/BaseGrammar.cfc index 1cfec3a2..b03bcc14 100644 --- a/models/Grammars/BaseGrammar.cfc +++ b/models/Grammars/BaseGrammar.cfc @@ -189,7 +189,26 @@ component displayname="Grammar" accessors="true" singleton { data.executionTime = 0; data.query = javacast( "null", "" ); if ( !arguments.pretend ) { - var q = queryExecute( data.sql, data.bindings, data.options ); + var q = javacast( "null", "" ); + try { + q = queryExecute( data.sql, data.bindings, data.options ); + } catch ( any failure ) { + data.executionTime = getTickCount() - startTick; + data.exception = failure; + // Observers cannot replace the original query failure. + try { + if ( variables.useAnnounceMethodForInterceptorService ) { + variables.interceptorService.announce( "onQBExecuteException", data ); + } else { + variables.interceptorService.processState( "onQBExecuteException", data ); + } + } catch ( any observerFailure ) { + variables.log.debug( "Failed to notify onQBExecuteException observers", observerFailure ); + } finally { + // Neither an observer nor its diagnostic logger may replace the query failure. + rethrow; + } + } data.executionTime = getTickCount() - startTick; data.query = isNull( q ) ? javacast( "null", "" ) : q; data.result = local.result; diff --git a/models/Grammars/PostgresGrammar.cfc b/models/Grammars/PostgresGrammar.cfc index 6792bd29..d1aad7bc 100644 --- a/models/Grammars/PostgresGrammar.cfc +++ b/models/Grammars/PostgresGrammar.cfc @@ -667,10 +667,25 @@ component extends="qb.models.Grammars.BaseGrammar" singleton { for ( var table in tables ) { wrappedTables.append( wrapTable( table ) ); } - if ( arrayIsEmpty( tables ) ) { - return []; + var statements = []; + if ( !arrayIsEmpty( tables ) ) { + statements.append( "DROP TABLE #wrappedTables.toList( ", " )# CASCADE" ); + } + // Dropping tables removes their triggers, but leaves standalone routines behind. + // Discover before executing any drops: table dependencies can remove some routines, + // so each routine drop must tolerate it having disappeared already. + // Use qualified catalog type names (e.g. pg_catalog.timestamptz) in signatures. + // This avoids search_path ambiguity and JDBC parsing WITH in timestamp type aliases. + var routines = runQuery( + "SELECT format('DROP %s IF EXISTS %I.%I(%s) CASCADE', CASE WHEN p.prokind = 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END, n.nspname, p.proname, COALESCE((SELECT string_agg(format('%I.%I', tn.nspname, t.typname), ', ' ORDER BY a.position) FROM generate_subscripts(p.proargtypes, 1) a(position) JOIN pg_type t ON t.oid = p.proargtypes[a.position] JOIN pg_namespace tn ON tn.oid = t.typnamespace), '')) AS drop_statement FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = ? AND p.prokind IN ('f', 'p', 'w') AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.classid = CAST('pg_proc' AS regclass) AND d.objid = p.oid AND d.refclassid = CAST('pg_extension' AS regclass) AND d.deptype = 'e') ORDER BY p.oid", + [ arguments.schema == "" ? "public" : arguments.schema ], + arguments.options, + "query" + ); + for ( var routine in routines ) { + statements.append( routine.drop_statement ); } - return [ "DROP TABLE #wrappedTables.toList( ", " )# CASCADE" ]; + return statements; } finally { if ( !isNull( arguments.sb.getShouldWrapValues() ) ) { setShouldWrapValues( originalShouldWrapValues ); diff --git a/tests/specs/Query/QueryFailureHookSpec.cfc b/tests/specs/Query/QueryFailureHookSpec.cfc new file mode 100644 index 00000000..c83e1812 --- /dev/null +++ b/tests/specs/Query/QueryFailureHookSpec.cfc @@ -0,0 +1,110 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "query failure observation", function() { + it( "announces a failed execution once, diagnoses observer failure, and preserves its original exception", function() { + var observations = []; + var diagnostics = []; + var grammar = new qb.models.Grammars.BaseGrammar(); + grammar.setLog( { + canDebug: function() { + return false; + }, + debug: function( message, extraInfo ) { + diagnostics.append( extraInfo ); + } + } ); + grammar.setInterceptorService( { + announce: function( state, data ) { + if ( state == "onQBExecuteException" ) { + observations.append( data ); + throw( type = "ObserverFailure", message = "must not replace database error" ); + } + } + } ); + var failure = {}; + try { + grammar.runQuery( + sql = "SELECT nonexistent_column FROM nonexistent_table", + options = { dbtype: "query" } + ); + } catch ( any caught ) { + failure = caught; + } + expect( observations.len() ).toBe( 1 ); + expect( observations[ 1 ].executionTime ).toBeGTE( 0 ); + assertQueryExceptionPreserved( observations[ 1 ].exception, failure ); + expect( failure.type ).notToBe( "ObserverFailure" ); + expect( diagnostics.len() ).toBe( 1 ); + expect( diagnostics[ 1 ].type ).toBe( "ObserverFailure" ); + } ); + it( "preserves the query exception even when the observer diagnostic logger fails", function() { + var original = {}; + var grammar = new qb.models.Grammars.BaseGrammar(); + grammar.setLog( { + canDebug: function() { + return false; + }, + debug: function( message, extraInfo ) { + throw( type = "DiagnosticFailure" ); + } + } ); + grammar.setInterceptorService( { + announce: function( state, data ) { + if ( state == "onQBExecuteException" ) { + original = data.exception; + throw( type = "ObserverFailure" ); + } + } + } ); + var failure = {}; + try { + grammar.runQuery( + sql = "SELECT nonexistent_column FROM nonexistent_table", + options = { dbtype: "query" } + ); + } catch ( any caught ) { + failure = caught; + } + assertQueryExceptionPreserved( original, failure ); + expect( failure.type ).notToBe( "DiagnosticFailure" ); + expect( failure.type ).notToBe( "ObserverFailure" ); + } ); + it( "uses the existing processState compatibility path for failure interception", function() { + var states = []; + var grammar = new qb.models.Grammars.BaseGrammar(); + grammar.setInterceptorService( { + processState: function( state, data ) { + states.append( state ); + } + } ); + expect( () => grammar.runQuery( + sql = "SELECT nonexistent_column FROM nonexistent_table", + options = { dbtype: "query" } + ) ).toThrow(); + expect( states ).toBe( [ "preQBExecute", "onQBExecuteException" ] ); + } ); + it( "keeps pretend execution hooks compatible without announcing a failure", function() { + var states = []; + var grammar = new qb.models.Grammars.BaseGrammar(); + grammar.setInterceptorService( { + announce: function( state, data ) { + states.append( state ); + } + } ); + grammar.runQuery( sql = "not executable", pretend = true ); + expect( states ).toBe( [ "preQBExecute", "postQBExecute" ] ); + } ); + } ); + } + + private void function assertQueryExceptionPreserved( required any original, required any caught ) { + // CFML engines may create a new catch struct around the same rethrown exception. + if ( isObject( arguments.original ) ) { + expect( arguments.caught ).toBeSameInstanceAs( arguments.original ); + } else { + expect( arguments.caught ).toBe( arguments.original ); + } + } + +} diff --git a/tests/specs/Schema/PostgresDropAllObjectsRegressionSpec.cfc b/tests/specs/Schema/PostgresDropAllObjectsRegressionSpec.cfc new file mode 100644 index 00000000..5379d2e5 --- /dev/null +++ b/tests/specs/Schema/PostgresDropAllObjectsRegressionSpec.cfc @@ -0,0 +1,67 @@ +/** Opt-in real PostgreSQL regression; every object is transactionally rolled back. */ +component extends="testbox.system.BaseSpec" { + + function run() { + var enabled = createObject( "java", "java.lang.System" ).getenv( "QB_POSTGRES_RESET_TEST" ) ?: "false"; + describe( "PostgreSQL reset routines", function() { + it( + title = "resets tables and overloaded routines while preserving extensions and other schemas", + skip = enabled != "true", + body = function() { + transaction { + try { + var target = "qb_reset_" & lCase( left( replace( createUUID(), "-", "", "all" ), 12 ) ); + var other = target & "_other"; + queryExecute( "CREATE SCHEMA " & target ); + queryExecute( "CREATE SCHEMA " & other ); + queryExecute( "CREATE EXTENSION pgcrypto WITH SCHEMA " & target ); + queryExecute( "CREATE TABLE " & target & ".records (id integer)" ); + queryExecute( "CREATE FUNCTION " & target & ".echo(input integer) RETURNS integer LANGUAGE sql AS 'SELECT input'" ); + queryExecute( "CREATE FUNCTION " & target & ".echo(input text) RETURNS text LANGUAGE sql AS 'SELECT input'" ); + queryExecute( "CREATE FUNCTION " & target & ".""Mixed Case""() RETURNS integer LANGUAGE sql AS 'SELECT 1'" ); + queryExecute( "CREATE FUNCTION " & target & ".rows() RETURNS SETOF " & target & ".records LANGUAGE sql AS 'SELECT * FROM " & target & ".records'" ); + queryExecute( "CREATE PROCEDURE " & target & ".noop() LANGUAGE plpgsql AS 'BEGIN NULL; END'" ); + queryExecute( "CREATE FUNCTION " & other & ".keep() RETURNS integer LANGUAGE sql AS 'SELECT 1'" ); + queryExecute( "CREATE FUNCTION " & target & ".dated(input timestamp with time zone) RETURNS timestamp with time zone LANGUAGE sql AS 'SELECT input'" ); + var extensionsBefore = extensionRoutineCount( target ); + expect( extensionsBefore ).toBeGT( 0 ); + var schema = new qb.models.Schema.SchemaBuilder( + grammar = new qb.models.Grammars.PostgresGrammar() + ); + schema.dropAllObjects( schema = target ); + expect( + queryExecute( + "SELECT tablename FROM pg_tables WHERE schemaname=:name", + { name: target } + ).recordCount + ).toBe( 0 ); + expect( applicationRoutineCount( target ) ).toBe( 0 ); + expect( applicationRoutineCount( other ) ).toBe( 1 ); + expect( extensionRoutineCount( target ) ).toBe( extensionsBefore ); + // Reapply the same migration definitions, then exercise a routine-only reset. + queryExecute( "CREATE FUNCTION " & target & ".echo(input integer) RETURNS integer LANGUAGE sql AS 'SELECT input'" ); + schema.dropAllObjects( schema = target ); + expect( applicationRoutineCount( target ) ).toBe( 0 ); + expect( extensionRoutineCount( target ) ).toBe( extensionsBefore ); + } finally { + transactionRollback(); + } + } + } + ); + } ); + } + private numeric function applicationRoutineCount( required string schema ) { + return queryExecute( + "SELECT count(*) AS n FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname=:name AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.classid=CAST('pg_proc' AS regclass) AND d.objid=p.oid AND d.refclassid=CAST('pg_extension' AS regclass) AND d.deptype='e')", + { name: arguments.schema } + ).n[ 1 ]; + } + private numeric function extensionRoutineCount( required string schema ) { + return queryExecute( + "SELECT count(*) AS n FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace JOIN pg_depend d ON d.classid=CAST('pg_proc' AS regclass) AND d.objid=p.oid AND d.refclassid=CAST('pg_extension' AS regclass) AND d.deptype='e' WHERE n.nspname=:name", + { name: arguments.schema } + ).n[ 1 ]; + } + +} diff --git a/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc b/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc index e09638fa..d68ad4c3 100644 --- a/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc +++ b/tests/specs/Schema/PostgresSchemaBuilderSpec.cfc @@ -19,13 +19,31 @@ component extends="tests.resources.AbstractSchemaBuilderSpec" { 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" } ] ) ); + variables.mockGrammar + .$( "runQuery" ) + .$results( + queryNew( "table_name", "varchar", [ { table_name: "users" } ] ), + queryNew( "drop_statement", "varchar", [] ) + ); 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" ] ); + expect( variables.mockGrammar.$callLog().runQuery[ 2 ][ 2 ] ).toBe( [ "tenant" ] ); + } ); + + it( "drops routines even when no tables remain", function() { + var schema = getBuilder(); + var dropRoutine = "DROP FUNCTION IF EXISTS tenant.example(integer) CASCADE"; + variables.mockGrammar + .$( "runQuery" ) + .$results( + queryNew( "table_name", "varchar", [] ), + queryNew( "drop_statement", "varchar", [ { drop_statement: dropRoutine } ] ) + ); + expect( schema.dropAllObjects( {}, false, "tenant" ) ).toBe( [ dropRoutine ] ); } ); it( "drops indexes from the table's schema", function() {