Skip to content
Merged

Next #338

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ModuleConfig.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ component {
"returnFormatters": {}
};

interceptorSettings = { "customInterceptionPoints": "preQBExecute,postQBExecute" };
interceptorSettings = { "customInterceptionPoints": "preQBExecute,postQBExecute,onQBExecuteException" };
}

function onLoad() {
Expand Down
2 changes: 1 addition & 1 deletion box.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
21 changes: 20 additions & 1 deletion models/Grammars/BaseGrammar.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 18 additions & 3 deletions models/Grammars/PostgresGrammar.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down
110 changes: 110 additions & 0 deletions tests/specs/Query/QueryFailureHookSpec.cfc
Original file line number Diff line number Diff line change
@@ -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 );
}
}

}
67 changes: 67 additions & 0 deletions tests/specs/Schema/PostgresDropAllObjectsRegressionSpec.cfc
Original file line number Diff line number Diff line change
@@ -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 ];
}

}
20 changes: 19 additions & 1 deletion tests/specs/Schema/PostgresSchemaBuilderSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading