From 5f766dd5f810d6621cc22ee42f58961567a81b4b Mon Sep 17 00:00:00 2001 From: Ahed Wakim Date: Fri, 24 Oct 2025 10:29:51 +0300 Subject: [PATCH] Adds runtime toggle to enable/disable retries Introduces Artisan commands to enable/disable database transaction retries at runtime without modifying configuration files. This is achieved by creating/removing a marker file. Adds RetryToggle class to handle the logic for checking and managing the marker file. Updates the transaction retrier to respect the runtime toggle status. --- .gitignore | 2 + .php-cs-fixer.cache | 2 +- README.md | 15 +- config/database-transaction-retry.php | 12 + src/Console/StartRetryCommand.php | 48 ++++ src/Console/StopRetryCommand.php | 49 ++++ ...atabaseTransactionRetryServiceProvider.php | 7 + src/Services/TransactionRetrier.php | 26 +- src/Support/RetryToggle.php | 140 +++++++++++ tests/TestCase.php | 5 + tests/Unit/DBTransactionRetryHelperTest.php | 224 ++++++++++++++++++ 11 files changed, 526 insertions(+), 4 deletions(-) create mode 100644 src/Console/StartRetryCommand.php create mode 100644 src/Console/StopRetryCommand.php create mode 100644 src/Support/RetryToggle.php diff --git a/.gitignore b/.gitignore index 46ced0d..efea3ea 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ vendor/ .vscode/ .DS_Store resources/ + +storage/runtime/retry-disabled.marker diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache index f3e784c..04d07c6 100644 --- a/.php-cs-fixer.cache +++ b/.php-cs-fixer.cache @@ -1 +1 @@ -{"php":"8.2.29","version":"3.89.0:v3.89.0#4dd6768cb7558440d27d18f54909eee417317ce9","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"align_single_space_minimal"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"single_quote":true,"no_unused_imports":true,"no_superfluous_phpdoc_tags":true,"phpdoc_trim":true,"phpdoc_align":{"align":"left"},"blank_line_before_statement":{"statements":["return"]},"simplified_null_return":true,"void_return":true},"hashes":{"tests\/bootstrap.php":"4ae74313e457f6662f4831ee2140eb34","src\/Providers\/DatabaseTransactionRetryServiceProvider.php":"1071a19b80835472e035374cc17b7056","src\/Services\/TransactionRetrier.php":"7d99d773c44861e3f12524a5119f5240","src\/Support\/BindingStringifier.php":"bbead2bae37761124652320cd28db412","src\/Support\/TransactionRetryLogWriter.php":"2bc60103437973d7b471ad6bc91c43cf","src\/Support\/TraceFormatter.php":"e640e32b17149f1cfec9a1d990f04c84","tests\/TestCase.php":"a45ed4c82e3b3f4ad47544b81fda41f5","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","tests\/Unit\/DBTransactionRetryHelperTest.php":"07bb6b8e6c8b3ce61a7e67f128f12f4a","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b"}} \ No newline at end of file +{"php":"8.2.29","version":"3.89.0:v3.89.0#4dd6768cb7558440d27d18f54909eee417317ce9","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"align_single_space_minimal"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"single_quote":true,"no_unused_imports":true,"no_superfluous_phpdoc_tags":true,"phpdoc_trim":true,"phpdoc_align":{"align":"left"},"blank_line_before_statement":{"statements":["return"]},"simplified_null_return":true,"void_return":true},"hashes":{"tests\/Unit\/DBTransactionRetryHelperTest.php":"27f37268e9ae100d356ca1c06c756616","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b","tests\/bootstrap.php":"4ae74313e457f6662f4831ee2140eb34","src\/Console\/StartRetryCommand.php":"b0e6c76186a59d1341fa6600e096f9a3","src\/Console\/StopRetryCommand.php":"ad3d7cbb9841006db54f12168c58d369","src\/Providers\/DatabaseTransactionRetryServiceProvider.php":"638c2f84c78a86c8e8c5e4daef07c46e","src\/Services\/TransactionRetrier.php":"504c45ab8315c7b3d60a64e0a52759af","src\/Support\/RetryToggle.php":"2516f4c290940019b1f1c069fec64be8","src\/Support\/BindingStringifier.php":"bbead2bae37761124652320cd28db412","src\/Support\/TransactionRetryLogWriter.php":"2bc60103437973d7b471ad6bc91c43cf","src\/Support\/TraceFormatter.php":"e640e32b17149f1cfec9a1d990f04c84","tests\/TestCase.php":"897cfbd81822f4b71075ccb1739df70d","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087"}} \ No newline at end of file diff --git a/README.md b/README.md index bd7f709..359b915 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ MIT License - Laravel ^11 + Laravel 11 or 12 PHP ^8.2 PHP CS Fixer

@@ -117,6 +117,19 @@ Each log entry includes: Set `logFileName` to segment logs by feature or workload (e.g., `logFileName: 'database/queues/payments'`). +## Runtime Toggle + +Use the built-in Artisan commands to temporarily disable or re-enable retries without touching configuration files: + +```bash +php artisan db-transaction-retry:stop # disable retries +php artisan db-transaction-retry:start # enable retries +``` + +The commands write a small marker file inside the package (`storage/runtime/retry-disabled.marker`). As long as that file exists retries stay off; removing it or running `db-transaction-retry:start` brings them back. You can still set the `DB_TRANSACTION_RETRY_ENABLED` environment variable for a permanent default. + +> **Heads up:** The `db-transaction-retry:start` command only removes the disable marker—it does not override an explicit `database-transaction-retry.enabled=false` configuration (including the `DB_TRANSACTION_RETRY_ENABLED=false` environment variable). Update that setting to `true` if you want retries to remain enabled after the current process. + ## Helper Utilities The package exposes dedicated support classes you can reuse in your own instrumentation: diff --git a/config/database-transaction-retry.php b/config/database-transaction-retry.php index 0021ce6..458a41f 100644 --- a/config/database-transaction-retry.php +++ b/config/database-transaction-retry.php @@ -1,6 +1,18 @@ env('DB_TRANSACTION_RETRY_ENABLED', true), + /* |-------------------------------------------------------------------------- | Retry Defaults diff --git a/src/Console/StartRetryCommand.php b/src/Console/StartRetryCommand.php new file mode 100644 index 0000000..c7feaa2 --- /dev/null +++ b/src/Console/StartRetryCommand.php @@ -0,0 +1,48 @@ +warn('Base configuration keeps retries disabled. Update `database-transaction-retry.enabled` (or `DB_TRANSACTION_RETRY_ENABLED`) if you want retries to stay on after this run.'); + + return self::SUCCESS; + } + config(['database-transaction-retry.enabled' => true]); + + $persisted = RetryToggle::enable(); + $marker = RetryToggle::markerPath(); + + if ($persisted) { + $this->info('Database transaction retries have been enabled.'); + $this->line("Cleared toggle marker: {$marker}"); + } else { + $this->warn('Database transaction retries could not be fully enabled because the toggle marker could not be removed.'); + $this->line("Please delete {$marker} manually or adjust permissions."); + } + + $state = RetryToggle::isEnabled(config('database-transaction-retry', [])) + ? 'ENABLED' + : 'DISABLED'; + + if ($persisted) { + $this->line("Current status: {$state}"); + } else { + $this->line("Current status: {$state} (marker still present; retries remain disabled)"); + } + + return self::SUCCESS; + } +} diff --git a/src/Console/StopRetryCommand.php b/src/Console/StopRetryCommand.php new file mode 100644 index 0000000..0cd131f --- /dev/null +++ b/src/Console/StopRetryCommand.php @@ -0,0 +1,49 @@ +warn('Base configuration already disables retries via `database-transaction-retry.enabled` or `DB_TRANSACTION_RETRY_ENABLED`.'); + + return self::SUCCESS; + } + config(['database-transaction-retry.enabled' => false]); + + $persisted = RetryToggle::disable(); + $marker = RetryToggle::markerPath(); + + if ($persisted) { + $this->info('Database transaction retries have been disabled.'); + $this->line("Created toggle marker: {$marker}"); + } else { + $this->warn('Database transaction retries disabled for this process, but the toggle marker could not be written.'); + $this->line("Please create {$marker} manually or adjust permissions."); + } + + $state = RetryToggle::isEnabled(config('database-transaction-retry', [])) + ? 'ENABLED' + : 'DISABLED'; + + if ($persisted) { + $this->line("Current status: {$state}"); + } else { + $this->line("Current status: {$state} (runtime only; persistence failed)"); + $this->warn('Retries will be re-enabled on the next run unless the marker is created manually.'); + } + + return self::SUCCESS; + } +} diff --git a/src/Providers/DatabaseTransactionRetryServiceProvider.php b/src/Providers/DatabaseTransactionRetryServiceProvider.php index e4791b5..17cc485 100644 --- a/src/Providers/DatabaseTransactionRetryServiceProvider.php +++ b/src/Providers/DatabaseTransactionRetryServiceProvider.php @@ -2,6 +2,8 @@ namespace DatabaseTransactions\RetryHelper\Providers; +use DatabaseTransactions\RetryHelper\Console\StartRetryCommand; +use DatabaseTransactions\RetryHelper\Console\StopRetryCommand; use Illuminate\Support\ServiceProvider; class DatabaseTransactionRetryServiceProvider extends ServiceProvider @@ -27,6 +29,11 @@ public function boot(): void $this->publishes([ __DIR__ . '/../../config/database-transaction-retry.php' => $configPath, ], 'database-transaction-retry-config'); + + $this->commands([ + StartRetryCommand::class, + StopRetryCommand::class, + ]); } } } diff --git a/src/Services/TransactionRetrier.php b/src/Services/TransactionRetrier.php index f15033c..0913add 100644 --- a/src/Services/TransactionRetrier.php +++ b/src/Services/TransactionRetrier.php @@ -3,6 +3,7 @@ namespace DatabaseTransactions\RetryHelper\Services; use Closure; +use DatabaseTransactions\RetryHelper\Support\RetryToggle; use DatabaseTransactions\RetryHelper\Support\TraceFormatter; use DatabaseTransactions\RetryHelper\Support\TransactionRetryLogWriter; use Illuminate\Database\QueryException; @@ -31,7 +32,15 @@ public static function runWithRetry( ?string $logFileName = null, string $trxLabel = '' ): mixed { - $config = function_exists('config') ? config('database-transaction-retry', []) : []; + $config = function_exists('config') ? config('database-transaction-retry', []) : []; + $config = is_array($config) ? $config : []; + $trxLabel = $trxLabel ?? ''; + + if (! RetryToggle::isEnabled($config)) { + static::exposeTransactionLabel($trxLabel); + + return DB::transaction($callback); + } $maxRetries ??= (int) ($config['max_retries'] ?? 3); $retryDelay ??= (int) ($config['retry_delay'] ?? 2); @@ -53,7 +62,7 @@ public static function runWithRetry( static::applyLockWaitTimeout($config); // Expose the transaction label if the app wants to read it during the callback. - $trxLabel === '' || app()->instance('tx.label', $trxLabel); + static::exposeTransactionLabel($trxLabel); return DB::transaction($callback); } catch (Throwable $exception) { @@ -340,4 +349,17 @@ protected static function isLockWaitRetryEnabled(array $config): bool return in_array(1205, $driverCodes, true); } + + protected static function exposeTransactionLabel(string $trxLabel): void + { + if ($trxLabel === '') { + return; + } + + if (! function_exists('app')) { + return; + } + + app()->instance('tx.label', $trxLabel); + } } diff --git a/src/Support/RetryToggle.php b/src/Support/RetryToggle.php new file mode 100644 index 0000000..d2f8f80 --- /dev/null +++ b/src/Support/RetryToggle.php @@ -0,0 +1,140 @@ +database = new FakeDatabaseManager(); @@ -22,6 +26,7 @@ function sleep(int $seconds): void $this->app->instance('log', $this->logManager); SleepSpy::reset(); + RetryToggle::enable(); }); test('returns callback result without retries', function (): void { @@ -33,6 +38,59 @@ function sleep(int $seconds): void expect(SleepSpy::$delays)->toBe([]); }); +test('bypasses retry logic when disabled', function (): void { + Container::getInstance()->make('config')->set( + 'database-transaction-retry.enabled', + false + ); + + $attempts = 0; + + try { + TransactionRetrier::runWithRetry(function () use (&$attempts): void { + $attempts++; + + throw makeQueryException(1213); + }, maxRetries: 3, retryDelay: 1, trxLabel: 'disabled'); + + $this->fail('Expected QueryException was not thrown.'); + } catch (QueryException $th) { + expect($th->errorInfo[1])->toBe(1213); + } + + expect($attempts)->toBe(1); + expect($this->database->transactionCalls)->toBe(1); + expect($this->logManager->records)->toBe([]); + expect(SleepSpy::$delays)->toBe([]); +}); + +test('bypasses retry logic when persisted marker exists', function (): void { + expect(RetryToggle::disable())->toBeTrue(); + expect(is_file(RetryToggle::markerPath()))->toBeTrue(); + + $attempts = 0; + + try { + TransactionRetrier::runWithRetry(function () use (&$attempts): void { + $attempts++; + + throw makeQueryException(1213); + }, maxRetries: 3, retryDelay: 1, trxLabel: 'disabled-marker'); + + $this->fail('Expected QueryException was not thrown.'); + } catch (QueryException $th) { + expect($th->errorInfo[1])->toBe(1213); + } finally { + RetryToggle::enable(); + } + + expect($attempts)->toBe(1); + expect($this->database->transactionCalls)->toBe(1); + expect($this->logManager->records)->toBe([]); + expect(SleepSpy::$delays)->toBe([]); + expect(is_file(RetryToggle::markerPath()))->toBeFalse(); +}); + test('retries on deadlock and logs warning', function (): void { $attempts = 0; @@ -64,6 +122,55 @@ function sleep(int $seconds): void expect($record['context']['driverCode'])->toBe(1213); }); +test('uses configured success log level for retry logging', function (): void { + Container::getInstance()->make('config')->set( + 'database-transaction-retry.logging.levels', + ['success' => 'notice', 'failure' => 'alert'] + ); + + $attempts = 0; + + $result = TransactionRetrier::runWithRetry(function () use (&$attempts) { + $attempts++; + + if ($attempts === 1) { + throw makeQueryException(1213); + } + + return 'ok'; + }, maxRetries: 2, retryDelay: 1, trxLabel: 'level-success'); + + expect($result)->toBe('ok'); + expect($this->logManager->records)->toHaveCount(1); + $record = $this->logManager->records[0]; + + expect($record['level'])->toBe('notice'); + expect($record['message'])->toContain('Notice'); +}); + +test('uses configured failure log level for retry logging', function (): void { + Container::getInstance()->make('config')->set( + 'database-transaction-retry.logging.levels', + ['success' => 'info', 'failure' => 'critical'] + ); + + try { + TransactionRetrier::runWithRetry(function (): void { + throw makeQueryException(1213); + }, maxRetries: 2, retryDelay: 1, trxLabel: 'level-failure'); + + $this->fail('Expected QueryException was not thrown.'); + } catch (QueryException $exception) { + expect($exception->errorInfo[1])->toBe(1213); + } + + expect($this->logManager->records)->toHaveCount(1); + $record = $this->logManager->records[0]; + + expect($record['level'])->toBe('critical'); + expect($record['message'])->toContain('Critical'); +}); + test('throws after max retries and logs error', function (): void { try { TransactionRetrier::runWithRetry(function (): void { @@ -235,6 +342,123 @@ function sleep(int $seconds): void expect(array_key_exists('sqlState', $record['context']))->toBeFalse(); }); +test('binds transaction label into container during execution', function (): void { + $captured = null; + + TransactionRetrier::runWithRetry(function () use (&$captured) { + $captured = app()->make('tx.label'); + + return 'done'; + }, trxLabel: 'orders-sync'); + + expect($captured)->toBe('orders-sync'); + expect(app()->make('tx.label'))->toBe('orders-sync'); +}); + +test('does not bind empty transaction label', function (): void { + TransactionRetrier::runWithRetry(fn () => 'ok', trxLabel: ''); + + expect(app()->bound('tx.label'))->toBeFalse(); +}); + +test('detects explicitly disabled configuration values', function (mixed $value, bool $expected): void { + expect(RetryToggle::isExplicitlyDisabledValue($value))->toBe($expected); +})->with([ + 'boolean false' => [false, true], + 'boolean true' => [true, false], + 'string false' => ['false', true], + 'string true' => ['true', false], + 'numeric zero' => [0, true], + 'numeric one' => [1, false], + 'empty string' => ['', false], + 'null value' => [null, false], + 'off keyword' => ['off', true], + 'yes keyword' => ['yes', false], +]); + +test('start command enables retries and removes marker', function (): void { + RetryToggle::disable(); + expect(is_file(RetryToggle::markerPath()))->toBeTrue(); + + try { + $command = new StartRetryCommand(); + $command->setLaravel($this->app); + + $tester = new CommandTester($command); + $exitCode = $tester->execute([]); + + expect($exitCode)->toBe(0); + + $config = Container::getInstance()->make('config'); + expect($config->get('database-transaction-retry.enabled'))->toBeTrue(); + expect(is_file(RetryToggle::markerPath()))->toBeFalse(); + expect($tester->getDisplay())->toContain('Database transaction retries have been enabled.'); + expect($tester->getDisplay())->toContain('Current status: ENABLED'); + } finally { + RetryToggle::enable(); + } +}); + +test('start command honours explicitly disabled configuration', function (): void { + Container::getInstance()->make('config')->set('database-transaction-retry.enabled', false); + + try { + $command = new StartRetryCommand(); + $command->setLaravel($this->app); + + $tester = new CommandTester($command); + $exitCode = $tester->execute([]); + + expect($exitCode)->toBe(0); + $config = Container::getInstance()->make('config'); + expect($config->get('database-transaction-retry.enabled'))->toBeFalse(); + expect(RetryToggle::isEnabled($config->get('database-transaction-retry')))->toBeFalse(); + expect($tester->getDisplay())->toContain('Base configuration keeps retries disabled'); + } finally { + RetryToggle::enable(); + } +}); + +test('stop command disables retries and creates marker', function (): void { + RetryToggle::enable(); + if (is_file(RetryToggle::markerPath())) { + unlink(RetryToggle::markerPath()); + } + + $command = new StopRetryCommand(); + $command->setLaravel($this->app); + + $tester = new CommandTester($command); + $exitCode = $tester->execute([]); + + expect($exitCode)->toBe(0); + $config = Container::getInstance()->make('config'); + expect($config->get('database-transaction-retry.enabled'))->toBeFalse(); + expect(is_file(RetryToggle::markerPath()))->toBeTrue(); + expect($tester->getDisplay())->toContain('Database transaction retries have been disabled.'); + expect($tester->getDisplay())->toContain('Current status: DISABLED'); + + RetryToggle::enable(); +}); + +test('stop command honours explicitly disabled configuration', function (): void { + Container::getInstance()->make('config')->set('database-transaction-retry.enabled', false); + + $command = new StopRetryCommand(); + $command->setLaravel($this->app); + + $tester = new CommandTester($command); + $exitCode = $tester->execute([]); + + expect($exitCode)->toBe(0); + $config = Container::getInstance()->make('config'); + expect($config->get('database-transaction-retry.enabled'))->toBeFalse(); + expect($tester->getDisplay())->toContain('Base configuration already disables retries'); + expect(is_file(RetryToggle::markerPath()))->toBeFalse(); + + RetryToggle::enable(); +}); + function makeQueryException(int $driverCode, string|int $sqlState = 40001): QueryException { $sqlStateString = strtoupper((string) $sqlState);