From 232c7c5d7eebf04c91568d4c2067ac4124c9e721 Mon Sep 17 00:00:00 2001 From: Pierre Rineau Date: Mon, 26 Feb 2024 12:30:12 +0100 Subject: [PATCH] no issue - error handling --- docs/.vitepress/config.mts | 1 + src/Bridge/AbstractBridge.php | 49 +- src/Bridge/Bridge.php | 5 + src/Bridge/Doctrine/DoctrineQueryBuilder.php | 12 +- .../ErrorConverter/DoctrineErrorConverter.php | 95 ++++ src/Bridge/ErrorConverter.php | 20 + src/Bridge/PassthroughErrorConverter.php | 23 + .../ErrorConverter/PdoMySQLErrorConverter.php | 129 +++++ .../PdoPostgreSQLErrorConverter.php | 109 ++++ .../PdoSQLServerErrorConverter.php | 69 +++ .../PdoSQLiteErrorConverter.php | 114 ++++ src/Bridge/Pdo/PdoQueryBuilder.php | 25 +- src/Error/Bridge/AmbiguousIdentifierError.php | 9 + src/Error/Bridge/ColumnDoesNotExistError.php | 9 + src/Error/Bridge/ConstraintViolationError.php | 9 + .../DatabaseObjectDoesNotExistError.php | 9 + .../ForeignKeyConstraintViolationError.php | 9 + .../NotNullConstraintViolationError.php | 9 + src/Error/Bridge/ServerError.php | 9 + src/Error/Bridge/TableDoesNotExistError.php | 9 + src/Error/Bridge/TransactionDeadlockError.php | 9 + src/Error/Bridge/TransactionError.php | 9 + src/Error/Bridge/TransactionFailedError.php | 9 + .../TransactionLockWaitTimeoutError.php | 9 + src/Error/Bridge/UnableToConnectError.php | 9 + .../Bridge/UniqueConstraintViolationError.php | 9 + src/QueryExecutor.php | 2 + .../Bridge/AbstractErrorConverterTestCase.php | 316 +++++++++++ .../Doctrine/DoctrineErrorConverterTest.php | 13 + tests/Bridge/Pdo/PdoErrorConverterTest.php | 13 + .../Functional/TransactionFunctionalTest.php | 491 ++++++++++++++++++ 31 files changed, 1607 insertions(+), 5 deletions(-) create mode 100644 src/Bridge/Doctrine/ErrorConverter/DoctrineErrorConverter.php create mode 100644 src/Bridge/ErrorConverter.php create mode 100644 src/Bridge/PassthroughErrorConverter.php create mode 100644 src/Bridge/Pdo/ErrorConverter/PdoMySQLErrorConverter.php create mode 100644 src/Bridge/Pdo/ErrorConverter/PdoPostgreSQLErrorConverter.php create mode 100644 src/Bridge/Pdo/ErrorConverter/PdoSQLServerErrorConverter.php create mode 100644 src/Bridge/Pdo/ErrorConverter/PdoSQLiteErrorConverter.php create mode 100644 src/Error/Bridge/AmbiguousIdentifierError.php create mode 100644 src/Error/Bridge/ColumnDoesNotExistError.php create mode 100644 src/Error/Bridge/ConstraintViolationError.php create mode 100644 src/Error/Bridge/DatabaseObjectDoesNotExistError.php create mode 100644 src/Error/Bridge/ForeignKeyConstraintViolationError.php create mode 100644 src/Error/Bridge/NotNullConstraintViolationError.php create mode 100644 src/Error/Bridge/ServerError.php create mode 100644 src/Error/Bridge/TableDoesNotExistError.php create mode 100644 src/Error/Bridge/TransactionDeadlockError.php create mode 100644 src/Error/Bridge/TransactionError.php create mode 100644 src/Error/Bridge/TransactionFailedError.php create mode 100644 src/Error/Bridge/TransactionLockWaitTimeoutError.php create mode 100644 src/Error/Bridge/UnableToConnectError.php create mode 100644 src/Error/Bridge/UniqueConstraintViolationError.php create mode 100644 tests/Bridge/AbstractErrorConverterTestCase.php create mode 100644 tests/Bridge/Doctrine/DoctrineErrorConverterTest.php create mode 100644 tests/Bridge/Pdo/PdoErrorConverterTest.php create mode 100644 tests/Functional/TransactionFunctionalTest.php diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 9b2468f..62da096 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -55,6 +55,7 @@ export default defineConfig({ { text: 'Doctrine DBAL setup', link: '/introduction/getting-started#doctrine-dbal-setup' }, { text: 'PDO setup', link: '/introduction/getting-started#pdo-setup' }, { text: 'Symfony setup', link: '/introduction/getting-started#symfony-setup' }, + { text: 'Error handling', link: '/bridges/error' }, ] }, { text: 'Samples usages', link: '/introduction/usage' }, diff --git a/src/Bridge/AbstractBridge.php b/src/Bridge/AbstractBridge.php index 27c7306..6ce5500 100644 --- a/src/Bridge/AbstractBridge.php +++ b/src/Bridge/AbstractBridge.php @@ -34,6 +34,7 @@ abstract class AbstractBridge extends DefaultQueryBuilder implements Bridge private ?string $serverVersion = null; private bool $serverVersionLookekUp = false; private ?string $serverFlavor = null; + private ?ErrorConverter $errorConverter = null; public function __construct(?ConverterPluginRegistry $converterPluginRegistry = null) { @@ -41,6 +42,17 @@ public function __construct(?ConverterPluginRegistry $converterPluginRegistry = $this->setQueryExecutor($this); } + /** + * Disable error converter. Must be called prior to initilization. + */ + public function disableErrorConverter(): bool + { + if ($this->errorConverter) { + throw new QueryBuilderError("Bridge is already initialized, configuration must happend before it gets used."); + } + $this->errorConverter = new PassthroughErrorConverter(); + } + /** * @internal * For dependency injection only. @@ -82,6 +94,31 @@ public function getServerName(): ?string return $this->serverName; } + /** + * Get error converter. + */ + protected function getErrorConverter(): ErrorConverter + { + return $this->errorConverter ??= $this->createErrorConverter(); + } + + /** + * @internal + * For dependency injection only. + */ + public function setErrorConverter(ErrorConverter $errorConverter): void + { + $this->errorConverter = $errorConverter; + } + + /** + * Please override. + */ + protected function createErrorConverter(): ErrorConverter + { + return new PassthroughErrorConverter(); + } + /** * Please override. */ @@ -199,7 +236,11 @@ public function executeQuery(string|Expression $expression = null, mixed $argume $prepared = $this->getWriter()->prepare($expression); - return $this->doExecuteQuery($prepared->toString(), $prepared->getArguments()->getAll()); + try { + return $this->doExecuteQuery($prepared->toString(), $prepared->getArguments()->getAll()); + } catch (\Throwable $e) { + throw $this->getErrorConverter()->convertError($e); + } } /** @@ -223,7 +264,11 @@ public function executeStatement(string|Expression $expression = null, mixed $ar $prepared = $this->getWriter()->prepare($expression); - return $this->doExecuteStatement($prepared->toString(), $prepared->getArguments()->getAll()); + try { + return $this->doExecuteStatement($prepared->toString(), $prepared->getArguments()->getAll()); + } catch (\Throwable $e) { + throw $this->getErrorConverter()->convertError($e); + } } /** diff --git a/src/Bridge/Bridge.php b/src/Bridge/Bridge.php index 3a6b888..6c55310 100644 --- a/src/Bridge/Bridge.php +++ b/src/Bridge/Bridge.php @@ -10,6 +10,11 @@ interface Bridge extends QueryExecutor, QueryBuilder { + /** + * Disable error converter. Must be called prior to initilization. + */ + public function disableErrorConverter(): bool; + /** * Get server name. */ diff --git a/src/Bridge/Doctrine/DoctrineQueryBuilder.php b/src/Bridge/Doctrine/DoctrineQueryBuilder.php index d7ea09b..59ac253 100644 --- a/src/Bridge/Doctrine/DoctrineQueryBuilder.php +++ b/src/Bridge/Doctrine/DoctrineQueryBuilder.php @@ -6,13 +6,15 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Driver\ServerInfoAwareConnection; -use MakinaCorpus\QueryBuilder\Platform; use MakinaCorpus\QueryBuilder\Bridge\AbstractBridge; +use MakinaCorpus\QueryBuilder\Bridge\Doctrine\ErrorConverter\DoctrineErrorConverter; use MakinaCorpus\QueryBuilder\Bridge\Doctrine\Escaper\DoctrineEscaper; use MakinaCorpus\QueryBuilder\Bridge\Doctrine\Escaper\DoctrineMySQLEscaper; +use MakinaCorpus\QueryBuilder\Bridge\ErrorConverter; use MakinaCorpus\QueryBuilder\Converter\Converter; use MakinaCorpus\QueryBuilder\Error\QueryBuilderError; use MakinaCorpus\QueryBuilder\Escaper\Escaper; +use MakinaCorpus\QueryBuilder\Platform; use MakinaCorpus\QueryBuilder\Result\IterableResult; use MakinaCorpus\QueryBuilder\Result\Result; use MakinaCorpus\QueryBuilder\Writer\Writer; @@ -30,6 +32,14 @@ public function __construct( $this->connection = $connection; } + /** + * Please override. + */ + protected function createErrorConverter(): ErrorConverter + { + return new DoctrineErrorConverter(); + } + /** * {@inheritdoc} */ diff --git a/src/Bridge/Doctrine/ErrorConverter/DoctrineErrorConverter.php b/src/Bridge/Doctrine/ErrorConverter/DoctrineErrorConverter.php new file mode 100644 index 0000000..fc5dc03 --- /dev/null +++ b/src/Bridge/Doctrine/ErrorConverter/DoctrineErrorConverter.php @@ -0,0 +1,95 @@ +getMessage(); + + if ($error instanceof InvalidFieldNameException || $error instanceof ColumnDoesNotExist) { + return new ColumnDoesNotExistError($message, $error->getCode(), $error); + } + + if ($error instanceof DatabaseDoesNotExist) { + return new DatabaseObjectDoesNotExistError($message, $error->getCode(), $error); + } + + if ($error instanceof ForeignKeyConstraintViolationException) { + return new ForeignKeyConstraintViolationError($message, $error->getCode(), $error); + } + + if ($error instanceof NotNullConstraintViolationException) { + return new NotNullConstraintViolationError($message, $error->getCode(), $error); + } + + if ($error instanceof TableDoesNotExist || $error instanceof TableNotFoundException) { + return new TableDoesNotExistError($message, $error->getCode(), $error); + } + + /* if ($error instanceof Foo) { + return new TransactionDeadlockError(); + } */ + + /* if ($error instanceof Foo) { + return new TransactionLockWaitTimeoutError(); + } */ + + if ($error instanceof ConnectionException || $error instanceof ConnectionLost) { + return new UnableToConnectError($message, $error->getCode(), $error); + } + + if ($error instanceof UniqueConstraintViolationException) { + return new UniqueConstraintViolationError($message, $error->getCode(), $error); + } + + /* + * More generic errors after. + */ + + if ($error instanceof NonUniqueFieldNameException) { + return new AmbiguousIdentifierError(); + } + + /* if ($error instanceof Foo) { + return new TransactionFailedError(); + } */ + + if ($error instanceof ConstraintViolationException) { + return new ConstraintViolationError($message, $error->getCode(), $error); + } + + // Provide fallbacks for SQLite, because DBAL don't catch them all. + return PdoSQLiteErrorConverter::createErrorFromMessage($error, $sql, $message); + } +} diff --git a/src/Bridge/ErrorConverter.php b/src/Bridge/ErrorConverter.php new file mode 100644 index 0000000..9b45754 --- /dev/null +++ b/src/Bridge/ErrorConverter.php @@ -0,0 +1,20 @@ +errorInfo[1] ?? $error->getCode(); + + switch ($errorCode) { + + case '1213': + return new TransactionDeadlockError($error->getMessage(), $errorCode, $error); + + case '1205': + return new TransactionLockWaitTimeoutError($error->getMessage(), $errorCode, $error); + + /* + case '1050': + // Table exists. + */ + + case '1051': + case '1146': + return new TableDoesNotExistError($error->getMessage(), $errorCode, $error); + + case '1216': + case '1217': + case '1451': + case '1452': + case '1701': + return new ForeignKeyConstraintViolationError($error->getMessage(), $errorCode, $error); + + case '1062': + case '1557': + case '1569': + case '1586': + return new UniqueConstraintViolationError($error->getMessage(), $errorCode, $error); + + case '1054': + return new ColumnDoesNotExistError($error->getMessage(), $errorCode, $error); + + /* + case '1166': + case '1611': + // Invalid identifier. + */ + + case '1052': + case '1060': + case '1110': + return new AmbiguousIdentifierError($error->getMessage(), $errorCode, $error); + + /* + case '1064': + case '1149': + case '1287': + case '1341': + case '1342': + case '1343': + case '1344': + case '1382': + case '1479': + case '1541': + case '1554': + case '1626': + // Syntax error. + */ + + /* + case '1044': + case '1045': + case '1046': + case '1049': + case '1095': + case '1142': + case '1143': + case '1227': + case '1370': + case '1429': + case '2002': + case '2005': + // Connection error. + */ + + case '1048': + case '1121': + case '1138': + case '1171': + case '1252': + case '1263': + case '1364': + case '1566': + return new NotNullConstraintViolationError($error->getMessage(), $errorCode, $error); + } + + return new ServerError($error->getMessage(), $errorCode, $error); + } +} diff --git a/src/Bridge/Pdo/ErrorConverter/PdoPostgreSQLErrorConverter.php b/src/Bridge/Pdo/ErrorConverter/PdoPostgreSQLErrorConverter.php new file mode 100644 index 0000000..be2bbfc --- /dev/null +++ b/src/Bridge/Pdo/ErrorConverter/PdoPostgreSQLErrorConverter.php @@ -0,0 +1,109 @@ +errorInfo[1] ?? $error->getCode(); + + switch ($errorCode) { + + case '40001': + case '40P01': + return new TransactionDeadlockError($error->getMessage(), $errorCode, $error); + + case '0A000': + // Foreign key constraint violations during a TRUNCATE operation + // are considered "feature not supported" in PostgreSQL. + if (\strpos($error->getMessage(), 'truncate') !== false) { + return new ForeignKeyConstraintViolationError($error->getMessage(), $errorCode, $error); + } + + break; + + case '23502': + return new NotNullConstraintViolationError($error->getMessage(), $errorCode, $error); + + case '23503': + return new ForeignKeyConstraintViolationError($error->getMessage(), $errorCode, $error); + + case '23505': + return new UniqueConstraintViolationError($error->getMessage(), $errorCode, $error); + + /* + case '42601': + // Syntax error. + */ + + case '42702': + return new AmbiguousIdentifierError($error->getMessage(), $errorCode, $error); + + case '42703': + return new ColumnDoesNotExistError($error->getMessage(), $errorCode, $error); + + /* + case '42703': + // Invalid identifier. + */ + + case '42P01': + return new TableDoesNotExistError($error->getMessage(), $errorCode, $error); + + /* + case '42P07': + // Table exists. + */ + + /* + case '7': + // In some case (mainly connection errors) the PDO exception does not provide a SQLSTATE via its code. + // The exception code is always set to 7 here. + // We have to match against the SQLSTATE in the error message in these cases. + if (\strpos($error->getMessage(), 'SQLSTATE[08006]') !== false) { + // Connection error. + } + + break; + */ + } + + // Attempt with classes if we do not handle the specific SQL STATE code. + switch (\substr($sqlState, 2)) { + + case '40': + return new TransactionError($error->getMessage(), $errorCode, $error); + } + + return new ServerError($error->getMessage(), $errorCode, $error); + } +} diff --git a/src/Bridge/Pdo/ErrorConverter/PdoSQLServerErrorConverter.php b/src/Bridge/Pdo/ErrorConverter/PdoSQLServerErrorConverter.php new file mode 100644 index 0000000..ef96faf --- /dev/null +++ b/src/Bridge/Pdo/ErrorConverter/PdoSQLServerErrorConverter.php @@ -0,0 +1,69 @@ +errorInfo[1] ?? $error->getCode(); + + switch ($errorCode) { + /* case 102: + return new SyntaxErrorException($exception, $query); */ + + case 207: + return new ColumnDoesNotExistError($error->getMessage(), (int) $errorCode, $error); + + case 208: + return new TableDoesNotExistError($error->getMessage(), (int) $errorCode, $error); + + case 209: + return new AmbiguousIdentifierError($error->getMessage(), (int) $errorCode, $error); + + case 515: + return new NotNullConstraintViolationError($error->getMessage(), (int) $errorCode, $error); + + case 547: + case 4712: + return new ForeignKeyConstraintViolationError($error->getMessage(), (int) $errorCode, $error); + + case 2601: + case 2627: + return new UniqueConstraintViolationError($error->getMessage(), (int) $errorCode, $error); + + case 2714: + return new TableDoesNotExistError($error->getMessage(), (int) $errorCode, $error); + + case 3701: + case 15151: + return new DatabaseObjectDoesNotExistError($error->getMessage(), (int) $errorCode, $error); + + case 11001: + case 18456: + return new UnableToConnectError($error->getMessage(), (int) $errorCode, $error); + } + + return new ServerError($error->getMessage(), $errorCode, $error); + } +} diff --git a/src/Bridge/Pdo/ErrorConverter/PdoSQLiteErrorConverter.php b/src/Bridge/Pdo/ErrorConverter/PdoSQLiteErrorConverter.php new file mode 100644 index 0000000..bc01d11 --- /dev/null +++ b/src/Bridge/Pdo/ErrorConverter/PdoSQLiteErrorConverter.php @@ -0,0 +1,114 @@ +getMessage(); + $errorCode = $error->errorInfo[1] ?? $error->getCode(); + + // Missing: + // - Connexion error + // - ForeignKeyConstraintViolationError + // - TransactionError + // - UniqueConstraintViolationError + // - Invalid identifier + // - Syntax error + // - Table exists + + if (\str_contains($message, 'database is locked') !== false) { + return new TransactionDeadlockError($message, $errorCode, $error); + } + + if ( + \str_contains($message, 'must be unique') !== false || + \str_contains($message, 'is not unique') !== false || + \str_contains($message, 'are not unique') !== false || + \str_contains($message, 'UNIQUE constraint failed') !== false + ) { + return new UniqueConstraintViolationError($message, $errorCode, $error); + } + + if ( + \str_contains($message, 'may not be NULL') !== false || + \str_contains($message, 'NOT NULL constraint failed') !== false + ) { + return new NotNullConstraintViolationError($message, $errorCode, $error); + } + + if (\str_contains($message, 'no such table:') !== false) { + return new TableDoesNotExistError($message, $errorCode, $error); + } + + if (\str_contains($message, 'no such column:') !== false) { + return new ColumnDoesNotExistError($message, $errorCode, $error); + } + + /* + if (\str_contains($message, 'already exists') !== false) { + return new TableExistsException($exception, $query); + } + */ + + /* + if (\str_contains($message, 'has no column named') !== false) { + return new InvalidFieldNameException($exception, $query); + } + */ + + if (\str_contains($message, 'ambiguous column name') !== false) { + return new AmbiguousIdentifierError($message, $errorCode, $error); + } + + /* + if (\str_contains($message, 'syntax error') !== false) { + return new SyntaxErrorException($exception, $query); + } + */ + + /* + if (\str_contains($message, 'attempt to write a readonly database') !== false) { + return new ReadOnlyException($exception, $query); + } + */ + + /* + if (\str_contains($message, 'unable to open database file') !== false) { + return new ConnectionException($exception, $query); + } + */ + + return new ServerError($error->getMessage(), $error->getCode(), $error); + } + + /** + * {@inheritdoc} + * + * I have to admit, I was largely inspired by Doctrine DBAL for this one. + * All credits to the Doctrine team, developers and contributors. You do + * very impressive and qualitative work, I hope you will continue forever. + * Many thanks to all contributors. If someday you come to France, give me + * a call, an email, anything, and I'll pay you a drink, whoever you are. + */ + public function convertError(\Throwable $error, ?string $sql = null, ?string $message = null): \Throwable + { + if (!$error instanceof \PDOException) { + return $error; + } + + return self::createErrorFromMessage($error, $sql, $message); + } +} diff --git a/src/Bridge/Pdo/PdoQueryBuilder.php b/src/Bridge/Pdo/PdoQueryBuilder.php index 81e25cd..178218c 100644 --- a/src/Bridge/Pdo/PdoQueryBuilder.php +++ b/src/Bridge/Pdo/PdoQueryBuilder.php @@ -4,12 +4,18 @@ namespace MakinaCorpus\QueryBuilder\Bridge\Pdo; -use MakinaCorpus\QueryBuilder\Platform; use MakinaCorpus\QueryBuilder\Bridge\AbstractBridge; +use MakinaCorpus\QueryBuilder\Bridge\ErrorConverter; +use MakinaCorpus\QueryBuilder\Bridge\PassthroughErrorConverter; +use MakinaCorpus\QueryBuilder\Bridge\Pdo\ErrorConverter\PdoMySQLErrorConverter; +use MakinaCorpus\QueryBuilder\Bridge\Pdo\ErrorConverter\PdoPostgreSQLErrorConverter; +use MakinaCorpus\QueryBuilder\Bridge\Pdo\ErrorConverter\PdoSQLiteErrorConverter; +use MakinaCorpus\QueryBuilder\Bridge\Pdo\ErrorConverter\PdoSQLServerErrorConverter; use MakinaCorpus\QueryBuilder\Bridge\Pdo\Escaper\PdoEscaper; use MakinaCorpus\QueryBuilder\Bridge\Pdo\Escaper\PdoMySQLEscaper; use MakinaCorpus\QueryBuilder\Error\QueryBuilderError; use MakinaCorpus\QueryBuilder\Escaper\Escaper; +use MakinaCorpus\QueryBuilder\Platform; use MakinaCorpus\QueryBuilder\Result\IterableResult; use MakinaCorpus\QueryBuilder\Result\Result; @@ -25,6 +31,21 @@ public function __construct( $this->connection = $connection; } + /** + * Please override. + */ + protected function createErrorConverter(): ErrorConverter + { + return match ($this->getServerFlavor()) { + Platform::MARIADB => new PdoMySQLErrorConverter(), + Platform::MYSQL => new PdoMySQLErrorConverter(), + Platform::POSTGRESQL => new PdoPostgreSQLErrorConverter(), + Platform::SQLITE => new PdoSQLiteErrorConverter(), + Platform::SQLSERVER => new PdoSQLServerErrorConverter(), + default => new PassthroughErrorConverter(), + }; + } + /** * {@inheritdoc} */ @@ -71,7 +92,7 @@ protected function lookupServerVersion(): ?string } // Last resort version string lookup. - if (\preg_match('@(\d+\.\d+(\.\d+))@i', $rawVersion)) { + if (\preg_match('@(\d+\.\d+(\.\d+))@i', $rawVersion, $matches)) { return $matches[1]; } diff --git a/src/Error/Bridge/AmbiguousIdentifierError.php b/src/Error/Bridge/AmbiguousIdentifierError.php new file mode 100644 index 0000000..3d867ea --- /dev/null +++ b/src/Error/Bridge/AmbiguousIdentifierError.php @@ -0,0 +1,9 @@ +getBridge()->executeStatement( + <<getBridge()->executeStatement( + <<getBridge()->getServerFlavor()) { + + case Platform::MARIADB: + case Platform::MYSQL: + $this->getBridge()->executeStatement( + <<getBridge()->executeStatement( + <<getBridge()->executeStatement( + <<getBridge()->executeStatement( + <<getBridge()->executeStatement( + <<getBridge()->executeStatement( + <<getBridge()->executeStatement( + <<getBridge()->executeStatement( + <<getBridge()->executeStatement( + <<getBridge()->executeStatement( + <<getBridge()->executeStatement( + <<getBridge()->executeStatement( + <<getBridge(); + + if (!$runner1->getPlatform()->supportsSchema()) { + self::markTestSkipped("This test requires a schema."); + } + self::markTestIncomplete("Why does the heck it does not fail?"); + + $runner2 = $factory->getRunner(); + + $runner1->execute( + <<execute( + <<execute( + <<beginTransaction(Transaction::SERIALIZABLE); + $transaction2 = $runner2->beginTransaction(Transaction::SERIALIZABLE); + + $runner1->execute( + <<execute( + <<execute( + <<execute( + <<commit(); + $transaction2->commit(); + } + + public function testTransactionSerializationError2(): void + { + self::markTestIncomplete("This test requires two different connections, this is unhanlded yet."); + + $runner1 = $factory->getRunner(); + + if (!$runner1->getPlatform()->supportsSchema()) { + self::markTestSkipped("This test requires a schema."); + } + self::markTestIncomplete("This test requires that we send a query batch asynchronously in the second transaction."); + + $runner2 = $factory->getRunner(); + + $runner1->execute( + <<execute( + <<beginTransaction(Transaction::SERIALIZABLE); + $transaction2 = $runner2->beginTransaction(Transaction::SERIALIZABLE); + + $runner1->execute( + <<execute( + <<commit(); + $transaction2->commit(); + } + + public function testTransactionDeadlockError(): void + { + self::markTestIncomplete("Not implemented yet."); + } + + public function testTransactionWaitTimeoutError(): void + { + self::markTestIncomplete("Not implemented yet."); + } +} diff --git a/tests/Bridge/Doctrine/DoctrineErrorConverterTest.php b/tests/Bridge/Doctrine/DoctrineErrorConverterTest.php new file mode 100644 index 0000000..cfd1c9c --- /dev/null +++ b/tests/Bridge/Doctrine/DoctrineErrorConverterTest.php @@ -0,0 +1,13 @@ +getBridge(); + + try { + $bridge->executeStatement( + <<getServerFlavor()) { + + case Platform::MARIADB: + case Platform::MYSQL: + $bridge->executeStatement( + <<executeStatement( + <<executeStatement( + <<executeStatement( + <<executeStatement( + <<executeStatement( + <<executeStatement( + <<executeStatement( + <<executeStatement( + <<insert('transaction_test') + ->columns(['foo', 'bar']) + ->values([1, 'a']) + ->values([2, 'b']) + ->values([3, 'c']) + ->executeStatement() + ; + } + + /** + * Normal working transaction. + */ + public function testTransaction() + { + $bridge = $this->getBridge(); + $transaction = $bridge->beginTransaction(); + + $bridge + ->insert('transaction_test') + ->columns(['foo', 'bar']) + ->values([4, 'd']) + ->executeStatement() + ; + + $transaction->commit(); + + $result = $bridge + ->select('transaction_test') + ->orderBy('foo') + ->executeQuery() + ; + + self::assertSame(4, $result->rowCount()); + self::assertSame('a', $result->fetchRow()->get('bar')); + self::assertSame('b', $result->fetchRow()->get('bar')); + self::assertSame('c', $result->fetchRow()->get('bar')); + self::assertSame('d', $result->fetchRow()->get('bar')); + } + + public function testNestedTransactionCreatesSavepoint() + { + $bridge = $this->getBridge(); + + /* if (!$bridge->getPlatform()->supportsTransactionSavepoints()) { + self::markTestSkipped(\sprintf("Driver '%s' does not supports savepoints", $bridge->getDriverName())); + } */ + + $bridge->delete('transaction_test')->executeStatement(); + + $transaction = $bridge->beginTransaction(); + + $bridge + ->insert('transaction_test') + ->columns(['foo', 'bar']) + ->values([789, 'f']) + ->executeStatement() + ; + + $savepoint = $bridge->beginTransaction(); + + self::assertInstanceOf(TransactionSavepoint::class, $savepoint); + self::assertTrue($savepoint->isNested()); + self::assertNotNull($savepoint->getSavepointName()); + + $bridge + ->insert('transaction_test') + ->columns(['foo', 'bar']) + ->values([456, 'g']) + ->executeStatement() + ; + + $transaction->commit(); + + $result = $bridge + ->select('transaction_test') + ->orderBy('foo') + ->executeQuery() + ; + + self::assertSame(2, $result->rowCount()); + self::assertSame('g', $result->fetchRow()->get('bar')); + self::assertSame('f', $result->fetchRow()->get('bar')); + } + + public function testNestedTransactionRollbackToSavepointTransparently() + { + $bridge = $this->getBridge(); + + /* if (!$bridge->getPlatform()->supportsTransactionSavepoints()) { + self::markTestSkipped(\sprintf("Driver '%s' does not supports savepoints", $bridge->getDriverName())); + } */ + + $bridge->delete('transaction_test')->executeStatement(); + + $transaction = $bridge->beginTransaction(); + + $bridge + ->insert('transaction_test') + ->columns(['foo', 'bar']) + ->values([789, 'f']) + ->executeStatement() + ; + + $savepoint = $bridge->beginTransaction(); + + self::assertInstanceOf(TransactionSavepoint::class, $savepoint); + self::assertTrue($savepoint->isNested()); + self::assertNotNull($savepoint->getSavepointName()); + + $bridge + ->insert('transaction_test') + ->columns(['foo', 'bar']) + ->values([456, 'g']) + ->executeStatement() + ; + + $savepoint->rollback(); + $transaction->commit(); + + $result = $bridge + ->select('transaction_test') + ->orderBy('foo') + ->executeQuery() + ; + + self::assertSame(1, $result->rowCount()); + self::assertSame('f', $result->fetchRow()->get('bar')); + } + + /** + * Fail with immediate constraints (not deferred). + */ + public function testImmediateTransactionFail() + { + $bridge = $this->getBridge(); + + $transaction = $bridge + ->beginTransaction() + ->deferred() // Defer all + ->immediate('transaction_test_bar') + ; + + try { + // This should pass, foo constraint it deferred; + // if backend does not support defering, this will + // fail anyway, but the rest of the test is still + // valid + $bridge + ->insert('transaction_test') + ->columns(['foo', 'bar']) + ->values([2, 'd']) + ->executeStatement() + ; + + // This should fail, bar constraint it immediate + $bridge + ->insert('transaction_test') + ->columns(['foo', 'bar']) + ->values([5, 'b']) + ->executeStatement() + ; + + self::fail(); + + } catch (TransactionError $e) { + self::assertInstanceOf(UniqueConstraintViolationError::class, $e->getPrevious()); + } finally { + if ($transaction->isStarted()) { + $transaction->rollback(); + } + } + } + + /** + * Fail with deferred constraints. + */ + public function testDeferredTransactionFail() + { + $bridge = $this->getBridge(); + + /* if (!$bridge->getPlatform()->supportsDeferingConstraints()) { + self::markTestSkipped("driver does not support defering constraints"); + } */ + + $transaction = $bridge + ->beginTransaction() + ->immediate() // Immediate all + ->deferred('transaction_test_foo') + ; + + try { + + // This should pass, foo constraint it deferred + $bridge + ->insert('transaction_test') + ->columns(['foo', 'bar']) + ->values([2, 'd']) + ->executeStatement() + ; + + // This should fail, bar constraint it immediate + $bridge + ->insert('transaction_test') + ->columns(['foo', 'bar']) + ->values([5, 'b']) + ->executeStatement() + ; + + self::fail(); + + } catch (TransactionError $e) { + self::assertInstanceOf(UniqueConstraintViolationError::class, $e->getPrevious()); + } finally { + if ($transaction->isStarted()) { + $transaction->rollback(); + } + } + + self::assertTrue(true); + } + + /** + * Fail with ALL constraints deferred. + */ + public function testDeferredAllTransactionFail() + { + $bridge = $this->getBridge(); + + /* if (!$bridge->getPlatform()->supportsDeferingConstraints()) { + self::markTestSkipped("driver does not support defering constraints"); + } */ + + $transaction = $bridge + ->beginTransaction() + ->deferred() + ; + + try { + + // This should pass, all are deferred + $bridge + ->insert('transaction_test') + ->columns(['foo', 'bar']) + ->values([2, 'd']) + ->executeStatement() + ; + + // This should pass, all are deferred + $bridge + ->insert('transaction_test') + ->columns(['foo', 'bar']) + ->values([5, 'b']) + ->executeStatement() + ; + + $transaction->commit(); + + } catch (TransactionError $e) { + self::assertInstanceOf(UniqueConstraintViolationError::class, $e->getPrevious()); + } finally { + if ($transaction->isStarted()) { + $transaction->rollback(); + } + } + + self::assertTrue(true); + } + + /** + * Tests that rollback works. + */ + public function testTransactionRollback() + { + $bridge = $this->getBridge(); + + $transaction = $bridge->beginTransaction(); + + $bridge + ->insert('transaction_test') + ->columns(['foo', 'bar']) + ->values([4, 'd']) + ->executeStatement() + ; + + $transaction->rollback(); + + $result = $bridge + ->select('transaction_test') + ->executeQuery() + ; + + self::assertSame(3, $result->rowCount()); + } + + /** + * Test that fetching a pending transaction is disallowed. + */ + public function testPendingAllowed() + { + $bridge = $this->getBridge(); + + $transaction = $bridge->beginTransaction(); + + // Fetch another transaction, it should fail + try { + $bridge->beginTransaction(Transaction::REPEATABLE_READ, false); + self::fail(); + } catch (TransactionError $e) { + } + + // Fetch another transaction, it should NOT fail + $t3 = $bridge->beginTransaction(Transaction::REPEATABLE_READ, true); + // @todo temporary deactivating this test since that the profiling + // transaction makes it harder + //self::assertSame($t3, $transaction); + self::assertTrue($t3->isStarted()); + + // Force rollback of the second, ensure previous is stopped too + $t3->rollback(); + self::assertFalse($t3->isStarted()); + // Still true, because we acquired a savepoint + self::assertTrue($transaction->isStarted()); + + $transaction->rollback(); + self::assertFalse($transaction->isStarted()); + } + + /** + * Test the savepoint feature. + */ + public function testTransactionSavepoint() + { + $bridge = $this->getBridge(); + + $transaction = $bridge->beginTransaction(); + + $bridge + ->update('transaction_test') + ->set('bar', 'z') + ->where('foo', 1) + ->executeStatement() + ; + + $transaction->savepoint('bouyaya'); + + $bridge + ->update('transaction_test') + ->set('bar', 'y') + ->where('foo', 2) + ->executeStatement() + ; + + $transaction->rollbackToSavepoint('bouyaya'); + $transaction->commit(); + + $oneBar = $bridge + ->select('transaction_test') + ->column('bar') + ->where('foo', 1) + ->executeQuery() + ->fetchOne() + ; + // This should have pass since it's before the savepoint + self::assertSame('z', $oneBar); + + $twoBar = $bridge + ->select('transaction_test') + ->column('bar') + ->where('foo', 2) + ->executeQuery() + ->fetchOne() + ; + // This should not have pass thanks to savepoint + self::assertSame('b', $twoBar); + } +}