From 469392446312288163e5c42f262bba5f159f5adb Mon Sep 17 00:00:00 2001 From: Kyle Kincer <69128842+KyleKincer@users.noreply.github.com> Date: Mon, 22 Sep 2025 09:58:01 -0400 Subject: [PATCH] Disable triggers by default and add trigger helpers --- README.md | 1 + docs/guide.md | 26 ++++ testing/Project/Sources/Classes/Testing.4dm | 119 +++++++++++++++++- .../Project/Sources/Classes/_TestingTest.4dm | 114 +++++++++++++++++ .../Sources/Classes/_TestingTriggerMock.4dm | 17 +++ 5 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 testing/Project/Sources/Classes/_TestingTriggerMock.4dm diff --git a/README.md b/README.md index 4e16be3..82e94da 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A comprehensive unit testing framework for the 4D platform with test tagging, fi - **Multiple output formats** - Human-readable and JSON output - **CI/CD ready** - Structured JSON output for automated testing - **Transaction management** - Automatic test isolation with rollback +- **Trigger control** - Database triggers disabled by default with opt-in helpers - **Subtests** - Run table-driven tests with `t.run` ## Quick Example diff --git a/docs/guide.md b/docs/guide.md index 60f4437..94f2fa5 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -805,6 +805,32 @@ Function _checkMathCase($t : cs.Testing.Testing; $case : Object) $t.assert.areEqual($t; $case.want; $case.in+1; "math works") ``` +## Trigger Management + +Database triggers are disabled for every test context by default. The framework issues `ALTER DATABASE DISABLE TRIGGERS` whenever a `Testing` instance is created or reset so tests begin from a consistent state without trigger side effects. Any table-level trigger overrides are tracked and reverted automatically before the next test runs. + +Use the following helpers on the `$t` context when a test needs trigger support: + +- `$t.disableAllTriggers()` – Explicitly disable triggers for the current session and clear any table overrides +- `$t.enableAllTriggers()` – Re-enable triggers for all tables in the current session +- `$t.enableTableTriggers(tableName)` – Enable triggers for a single table while keeping others disabled +- `$t.disableTableTriggers(tableName)` – Disable triggers for a specific table and remove it from the override list +- `$t.restoreTriggerDefaults()` – Convenience wrapper that restores the default “all triggers disabled” state + +Table names are quoted automatically, so you can enable triggers on tables with spaces or special characters without additional escaping. Any double quotes inside the name are doubled per SQL rules. + +```4d +Function test_trigger_callbacks($t : cs.Testing.Testing) + // Enable triggers only for the tables needed by this scenario + $t.enableTableTriggers("AuditLog") + + // Execute behavior that relies on table triggers + This._performOperation() + + // Return to the default trigger state for the remainder of the test + $t.restoreTriggerDefaults() +``` + ## Transaction Management The framework provides automatic transaction management for test isolation and manual transaction control for advanced scenarios. diff --git a/testing/Project/Sources/Classes/Testing.4dm b/testing/Project/Sources/Classes/Testing.4dm index 361f4b6..36bc7c3 100644 --- a/testing/Project/Sources/Classes/Testing.4dm +++ b/testing/Project/Sources/Classes/Testing.4dm @@ -8,15 +8,19 @@ property assert : cs:C1710.Assert property stats : cs:C1710.UnitStatsTracker property failureCallChain : Collection property classInstance : 4D:C1709.Object +property triggersGloballyEnabled : Boolean +property triggersEnabledTables : Collection Class constructor() - This:C1470.failed:=False:C215 - This:C1470.done:=False:C215 + This:C1470.failed:=False:C215 + This:C1470.done:=False:C215 This:C1470.logMessages:=[] This:C1470.assertions:=[] This:C1470.assert:=cs:C1710.Assert.new() This:C1470.stats:=cs:C1710.UnitStatsTracker.new() This:C1470.failureCallChain:=Null + This:C1470._resetTriggerTracking() + This:C1470.disableAllTriggers() Function log($message : Text) This:C1470.logMessages.push($message) @@ -59,12 +63,119 @@ Function fatal() This:C1470.failureCallChain:=Call chain:C1662 Function resetForNewTest() - This:C1470.failed:=False:C215 - This:C1470.done:=False:C215 + This:C1470.disableAllTriggers() + This:C1470.failed:=False:C215 + This:C1470.done:=False:C215 This:C1470.logMessages:=[] This:C1470.assertions:=[] This:C1470.stats.resetStatistics() This:C1470.failureCallChain:=Null + +Function disableAllTriggers() + This:C1470._executeSQL("ALTER DATABASE DISABLE TRIGGERS") + + If (This:C1470.triggersEnabledTables#Null:C1517) + var $tableName : Text + For each ($tableName; This:C1470.triggersEnabledTables) + This:C1470._disableTriggersForTable($tableName) + End for each + End if + + This:C1470._resetTriggerTracking() + +Function enableAllTriggers() + This:C1470._executeSQL("ALTER DATABASE ENABLE TRIGGERS") + This:C1470.triggersGloballyEnabled:=True:C214 + +Function enableTableTriggers($tableName : Text) + If ($tableName=Null:C1517) + return + End if + + If ($tableName="") + return + End if + + var $statement : Text + $statement:="ALTER TABLE "+This:C1470._quoteIdentifier($tableName)+" ENABLE TRIGGERS" + This:C1470._executeSQL($statement) + + If (This:C1470.triggersEnabledTables=Null:C1517) + This:C1470.triggersEnabledTables:=[] + End if + + If (This:C1470.triggersEnabledTables.indexOf($tableName)<0) + This:C1470.triggersEnabledTables.push($tableName) + End if + +Function disableTableTriggers($tableName : Text) + If ($tableName=Null:C1517) + return + End if + + If ($tableName="") + return + End if + + This:C1470._disableTriggersForTable($tableName) + + If (This:C1470.triggersEnabledTables#Null:C1517) + var $index : Integer + $index:=This:C1470.triggersEnabledTables.indexOf($tableName) + If ($index>=0) + This:C1470.triggersEnabledTables.remove($index) + End if + End if + +Function restoreTriggerDefaults() + This:C1470.disableAllTriggers() + +Function _disableTriggersForTable($tableName : Text) + If ($tableName=Null:C1517) + return + End if + + If ($tableName="") + return + End if + + var $statement : Text + $statement:="ALTER TABLE "+This:C1470._quoteIdentifier($tableName)+" DISABLE TRIGGERS" + This:C1470._executeSQL($statement) + +Function _quoteIdentifier($identifier : Text) : Text + If ($identifier=Null:C1517) + return "\"\"" + End if + + var $escaped : Text + $escaped:=Replace string:C233($identifier; "\""; "\"\"") + return "\""+$escaped+"\"" + +Function _executeSQL($statement : Text) + If ($statement="") + return + End if + + var $finalStatement : Text + $finalStatement:=$statement + + If (Length:C16($finalStatement)>0) + var $lastChar : Text + $lastChar:=Substring:C12($finalStatement; Length:C16($finalStatement); 1) + If ($lastChar#";") + $finalStatement:=$finalStatement+";" + End if + End if + + This:C1470._performSQLExecution($finalStatement) + +Function _performSQLExecution($statement : Text) + SQL EXECUTE($statement) + +Function _resetTriggerTracking() + This:C1470.triggersGloballyEnabled:=False:C215 + This:C1470.triggersEnabledTables:=[] Function run($name : Text; $subtest : 4D:C1709.Function; $data : Variant) : Boolean // Execute a named subtest with its own Testing context diff --git a/testing/Project/Sources/Classes/_TestingTest.4dm b/testing/Project/Sources/Classes/_TestingTest.4dm index a9acf2b..3a21d7c 100644 --- a/testing/Project/Sources/Classes/_TestingTest.4dm +++ b/testing/Project/Sources/Classes/_TestingTest.4dm @@ -188,6 +188,120 @@ Function test_run_subtest_stats_isolated($t : cs:C1710.Testing) $parentStat:=$testing.stats.getStat("mocked") $t.assert.areEqual($t; 0; $parentStat.getNumberOfCalls(); "Parent stats should remain unaffected") +Function test_triggers_disabled_by_default($t : cs:C1710.Testing) + + var $mock : cs:C1710._TestingTriggerMock + $mock:=cs:C1710._TestingTriggerMock.new() + + $t.assert.isTrue($t; ($mock.executedStatements.length>0); "Should execute SQL on initialization") + $t.assert.areEqual($t; "ALTER DATABASE DISABLE TRIGGERS;"; $mock.executedStatements[0]; "Should disable triggers by default") + $t.assert.isFalse($t; $mock.triggersGloballyEnabled; "Global trigger flag should be cleared") + +Function test_enable_all_triggers_executes_sql($t : cs:C1710.Testing) + + var $mock : cs:C1710._TestingTriggerMock + $mock:=cs:C1710._TestingTriggerMock.new() + $mock.executedStatements:=[] + + $mock.enableAllTriggers() + + $t.assert.areEqual($t; 1; $mock.executedStatements.length; "Should execute a single SQL statement") + $t.assert.areEqual($t; "ALTER DATABASE ENABLE TRIGGERS;"; $mock.executedStatements[0]; "Should enable triggers globally") + $t.assert.isTrue($t; $mock.triggersGloballyEnabled; "Global trigger flag should be set") + +Function test_enable_table_triggers_tracks_tables($t : cs:C1710.Testing) + + var $mock : cs:C1710._TestingTriggerMock + $mock:=cs:C1710._TestingTriggerMock.new() + $mock.executedStatements:=[] + + $mock.enableTableTriggers("Orders") + + $t.assert.areEqual($t; 1; $mock.executedStatements.length; "Should execute table enable SQL") + $t.assert.areEqual($t; "ALTER TABLE \"Orders\" ENABLE TRIGGERS;"; $mock.executedStatements[0]; "Should quote table name") + $t.assert.areEqual($t; 1; $mock.triggersEnabledTables.length; "Should track enabled table") + $t.assert.areEqual($t; "Orders"; $mock.triggersEnabledTables[0]; "Should store table identifier") + + $mock.enableTableTriggers("Orders") + $t.assert.areEqual($t; 1; $mock.triggersEnabledTables.length; "Should avoid duplicate tracking entries") + +Function test_enable_table_triggers_escapes_quotes($t : cs:C1710.Testing) + + var $mock : cs:C1710._TestingTriggerMock + $mock:=cs:C1710._TestingTriggerMock.new() + $mock.executedStatements:=[] + + $mock.enableTableTriggers("Order\"Detail") + + $t.assert.areEqual($t; "ALTER TABLE \"Order\"\"Detail\" ENABLE TRIGGERS;"; $mock.executedStatements[0]; "Should escape embedded quotes") + +Function test_disable_table_triggers_updates_tracking($t : cs:C1710.Testing) + + var $mock : cs:C1710._TestingTriggerMock + $mock:=cs:C1710._TestingTriggerMock.new() + $mock.executedStatements:=[] + + $mock.enableTableTriggers("Orders") + $mock.executedStatements:=[] + + $mock.disableTableTriggers("Orders") + + $t.assert.areEqual($t; 1; $mock.executedStatements.length; "Should execute table disable SQL") + $t.assert.areEqual($t; "ALTER TABLE \"Orders\" DISABLE TRIGGERS;"; $mock.executedStatements[0]; "Should disable triggers for table") + $t.assert.areEqual($t; 0; $mock.triggersEnabledTables.length; "Should remove table from tracking") + +Function test_disable_all_triggers_resets_tracking($t : cs:C1710.Testing) + + var $mock : cs:C1710._TestingTriggerMock + $mock:=cs:C1710._TestingTriggerMock.new() + $mock.executedStatements:=[] + + $mock.enableTableTriggers("Orders") + $mock.enableTableTriggers("Customers") + $mock.executedStatements:=[] + + $mock.disableAllTriggers() + + $t.assert.areEqual($t; 3; $mock.executedStatements.length; "Should disable database and tracked tables") + $t.assert.areEqual($t; "ALTER DATABASE DISABLE TRIGGERS;"; $mock.executedStatements[0]; "Should disable globally") + $t.assert.areEqual($t; "ALTER TABLE \"Orders\" DISABLE TRIGGERS;"; $mock.executedStatements[1]; "Should disable first table") + $t.assert.areEqual($t; "ALTER TABLE \"Customers\" DISABLE TRIGGERS;"; $mock.executedStatements[2]; "Should disable second table") + $t.assert.areEqual($t; 0; $mock.triggersEnabledTables.length; "Should clear table tracking") + $t.assert.isFalse($t; $mock.triggersGloballyEnabled; "Should reset global flag") + +Function test_restore_trigger_defaults_disables_triggers($t : cs:C1710.Testing) + + var $mock : cs:C1710._TestingTriggerMock + $mock:=cs:C1710._TestingTriggerMock.new() + $mock.executedStatements:=[] + + $mock.enableAllTriggers() + $mock.executedStatements:=[] + + $mock.restoreTriggerDefaults() + + $t.assert.areEqual($t; 1; $mock.executedStatements.length; "Should call disable when restoring defaults") + $t.assert.areEqual($t; "ALTER DATABASE DISABLE TRIGGERS;"; $mock.executedStatements[0]; "Should disable triggers when restoring") + $t.assert.isFalse($t; $mock.triggersGloballyEnabled; "Should clear global state on restore") + +Function test_reset_for_new_test_restores_trigger_state($t : cs:C1710.Testing) + + var $mock : cs:C1710._TestingTriggerMock + $mock:=cs:C1710._TestingTriggerMock.new() + $mock.executedStatements:=[] + + $mock.enableTableTriggers("Orders") + $mock.enableAllTriggers() + $mock.executedStatements:=[] + + $mock.resetForNewTest() + + $t.assert.areEqual($t; 2; $mock.executedStatements.length; "Reset should disable database and tracked tables") + $t.assert.areEqual($t; "ALTER DATABASE DISABLE TRIGGERS;"; $mock.executedStatements[0]; "Reset should disable globally") + $t.assert.areEqual($t; "ALTER TABLE \"Orders\" DISABLE TRIGGERS;"; $mock.executedStatements[1]; "Reset should disable tracked table") + $t.assert.areEqual($t; 0; $mock.triggersEnabledTables.length; "Reset should clear tracking") + $t.assert.isFalse($t; $mock.triggersGloballyEnabled; "Reset should clear global trigger flag") + Function _addOneCase($t : cs:C1710.Testing; $case : Object) var $got : Integer diff --git a/testing/Project/Sources/Classes/_TestingTriggerMock.4dm b/testing/Project/Sources/Classes/_TestingTriggerMock.4dm new file mode 100644 index 0000000..517ae93 --- /dev/null +++ b/testing/Project/Sources/Classes/_TestingTriggerMock.4dm @@ -0,0 +1,17 @@ +Class extends Testing + +property executedStatements : Collection + +Class constructor() + Super:C1705() + + If (This:C1470.executedStatements=Null:C1517) + This:C1470.executedStatements:=[] + End if + +Function _performSQLExecution($statement : Text) + If (This:C1470.executedStatements=Null:C1517) + This:C1470.executedStatements:=[] + End if + + This:C1470.executedStatements.push($statement)