From 414a6ebc0b1cf6dfaa471e66fef60a5b0b53e33f Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Tue, 27 Jun 2023 15:28:24 +0200 Subject: [PATCH] Introduce ODBC drivers --- .github/workflows/continuous-integration.yml | 9 +- ci/github/phpunit/odbc_sqlsrv.xml | 36 ++++ phpstan.neon.dist | 1 + src/Driver/API/ODBC/ExceptionConverter.php | 40 +++++ src/Driver/ODBC/Connection.php | 112 ++++++++++++ src/Driver/ODBC/ConvertParameters.php | 49 +++++ src/Driver/ODBC/Driver.php | 95 ++++++++++ .../ODBC/Exception/ConnectionFailed.php | 19 ++ src/Driver/ODBC/Exception/Error.php | 20 +++ .../ODBC/Exception/UnknownParameter.php | 20 +++ src/Driver/ODBC/PostgreSQL/Connection.php | 11 ++ src/Driver/ODBC/PostgreSQL/Driver.php | 33 ++++ src/Driver/ODBC/Result.php | 170 ++++++++++++++++++ src/Driver/ODBC/SQLServer/Connection.php | 54 ++++++ src/Driver/ODBC/SQLServer/Driver.php | 42 +++++ src/Driver/ODBC/Statement.php | 134 ++++++++++++++ src/DriverManager.php | 3 + src/Schema/SQLServerSchemaManager.php | 2 +- tests/Functional/ConnectionTest.php | 33 +++- tests/Functional/ExceptionTest.php | 134 +++++++------- .../SchemaManagerFunctionalTestCase.php | 3 +- tests/TestUtil.php | 5 + 22 files changed, 946 insertions(+), 79 deletions(-) create mode 100644 ci/github/phpunit/odbc_sqlsrv.xml create mode 100644 src/Driver/API/ODBC/ExceptionConverter.php create mode 100644 src/Driver/ODBC/Connection.php create mode 100644 src/Driver/ODBC/ConvertParameters.php create mode 100644 src/Driver/ODBC/Driver.php create mode 100644 src/Driver/ODBC/Exception/ConnectionFailed.php create mode 100644 src/Driver/ODBC/Exception/Error.php create mode 100644 src/Driver/ODBC/Exception/UnknownParameter.php create mode 100644 src/Driver/ODBC/PostgreSQL/Connection.php create mode 100644 src/Driver/ODBC/PostgreSQL/Driver.php create mode 100644 src/Driver/ODBC/Result.php create mode 100644 src/Driver/ODBC/SQLServer/Connection.php create mode 100644 src/Driver/ODBC/SQLServer/Driver.php create mode 100644 src/Driver/ODBC/Statement.php diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 5c9ae3a0554..42e6afa9ffd 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -12,8 +12,8 @@ on: - src/** - tests/** push: - branches: - - "*.x" +# branches: +# - "*.x" paths: - .github/workflows/continuous-integration.yml - ci/** @@ -476,6 +476,9 @@ jobs: - collation: "Latin1_General_100_CS_AS_SC_UTF8" php-version: "7.4" extension: "pdo_sqlsrv" + - collation: "Latin1_General_100_CI_AS_SC_UTF8" + php-version: "8.2" + extension: "odbc_sqlsrv" services: mssql: @@ -504,7 +507,7 @@ jobs: coverage: "pcov" ini-values: "zend.assertions=1" tools: "pecl" - extensions: "${{ matrix.extension }}-5.10.0beta1" + extensions: "odbc sqlsrv pdo_sqlsrv" - name: "Install dependencies with Composer" uses: "ramsey/composer-install@v2" diff --git a/ci/github/phpunit/odbc_sqlsrv.xml b/ci/github/phpunit/odbc_sqlsrv.xml new file mode 100644 index 00000000000..992d84ec3e9 --- /dev/null +++ b/ci/github/phpunit/odbc_sqlsrv.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + ../../../tests + + + + + + ../../../src + + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 3dfbb66ac8d..560ba5a8edc 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -128,6 +128,7 @@ parameters: - '~^Parameter #1 \$row of method Doctrine\\DBAL\\Driver\\PgSQL\\Result\:\:mapNumericRow\(\) expects array, array given\.$~' # Ignore isset() checks in destructors. + - '~^Property Doctrine\\DBAL\\Driver\\ODBC\\Connection\:\:\$connection \(resource\) in isset\(\) is not nullable\.$~' - '~^Property Doctrine\\DBAL\\Driver\\PgSQL\\Connection\:\:\$connection \(PgSql\\Connection\|resource\) in isset\(\) is not nullable\.$~' - '~^Property Doctrine\\DBAL\\Driver\\PgSQL\\Statement\:\:\$connection \(PgSql\\Connection\|resource\) in isset\(\) is not nullable\.$~' diff --git a/src/Driver/API/ODBC/ExceptionConverter.php b/src/Driver/API/ODBC/ExceptionConverter.php new file mode 100644 index 00000000000..c74c0ce8259 --- /dev/null +++ b/src/Driver/API/ODBC/ExceptionConverter.php @@ -0,0 +1,40 @@ +getSQLState()) { + case '23000': + return new ConstraintViolationException($exception, $query); + case '28000': + case 'S1T00': + return new ConnectionException($exception, $query); + case '37000': + return new SyntaxErrorException($exception, $query); + case 'S0001': + return new TableExistsException($exception, $query); + case 'S0002': + return new TableNotFoundException($exception, $query); + case 'S0022': + return new DatabaseObjectNotFoundException($exception, $query); + } + + return new DriverException($exception, $query); + } +} diff --git a/src/Driver/ODBC/Connection.php b/src/Driver/ODBC/Connection.php new file mode 100644 index 00000000000..b00c200bd01 --- /dev/null +++ b/src/Driver/ODBC/Connection.php @@ -0,0 +1,112 @@ +connection = $connection; + $this->parser = new Parser(false); + } + + public function __destruct() + { + if (! isset($this->connection)) { + return; + } + + @odbc_rollback($this->connection); + @odbc_close($this->connection); + } + + public function prepare(string $sql): Statement + { + $visitor = new ConvertParameters(); + $this->parser->parse($sql, $visitor); + + return new Statement($this->connection, $visitor->getSQL(), $visitor->getParameterMap()); + } + + public function query(string $sql): Result + { + $result = @odbc_exec($this->connection, $sql); + if ($result === false) { + throw Error::new($this->connection); + } + + return new Result($result); + } + + /** + * {@inheritDoc} + * + * @psalm-return never + */ + public function quote($value, $type = ParameterType::STRING): string + { + throw new LogicException('The ODBC driver does not support quoting values.'); + } + + public function exec(string $sql): int + { + return $this->query($sql)->rowCount(); + } + + /** + * {@inheritDoc} + * + * @psalm-return never + */ + public function lastInsertId($name = null) + { + throw new LogicException('The ODBC driver does not support retrieving the last inserted ID.'); + } + + public function beginTransaction(): bool + { + return (bool) odbc_autocommit($this->connection, false); + } + + public function commit(): bool + { + $result = odbc_commit($this->connection); + odbc_autocommit($this->connection, true); + + return $result; + } + + public function rollBack(): bool + { + $result = odbc_rollback($this->connection); + odbc_autocommit($this->connection, true); + + return $result; + } + + /** @return resource */ + public function getNativeConnection() + { + return $this->connection; + } +} diff --git a/src/Driver/ODBC/ConvertParameters.php b/src/Driver/ODBC/ConvertParameters.php new file mode 100644 index 00000000000..3f71ce083ed --- /dev/null +++ b/src/Driver/ODBC/ConvertParameters.php @@ -0,0 +1,49 @@ + */ + private array $buffer = []; + + /** @var array */ + private array $parameterMap = []; + + public function acceptPositionalParameter(string $sql): void + { + $position = count($this->parameterMap) + 1; + $this->parameterMap[$position] = $position; + $this->buffer[] = '?'; + } + + public function acceptNamedParameter(string $sql): void + { + $position = count($this->parameterMap) + 1; + $this->parameterMap[$sql] = $position; + $this->buffer[] = '?'; + } + + public function acceptOther(string $sql): void + { + $this->buffer[] = $sql; + } + + public function getSQL(): string + { + return implode('', $this->buffer); + } + + /** @return array */ + public function getParameterMap(): array + { + return $this->parameterMap; + } +} diff --git a/src/Driver/ODBC/Driver.php b/src/Driver/ODBC/Driver.php new file mode 100644 index 00000000000..9bb141bf3ee --- /dev/null +++ b/src/Driver/ODBC/Driver.php @@ -0,0 +1,95 @@ +assembleDsn($params['driverOptions'] ?? []), + $params['user'] ?? '', + $params['password'] ?? '', + ); + + if ($connection === false) { + throw ConnectionFailed::new(); + } + + return new Connection($connection); + } + + public function getDatabasePlatform(): AbstractPlatform + { + throw new LogicException('The ODBC driver does not support platform detection.'); + } + + public function getSchemaManager(ConnectionInterface $conn, AbstractPlatform $platform): AbstractSchemaManager + { + Deprecation::triggerIfCalledFromOutside( + 'doctrine/dbal', + 'https://github.com/doctrine/dbal/pull/5458', + '%s() is deprecated. Use AbstractPlatform::createSchemaManager() instead.', + __METHOD__, + ); + + return $platform->createSchemaManager($conn); + } + + public function getExceptionConverter(): ExceptionConverter + { + return new ExceptionConverter(); + } + + /** @param array $driverOptions */ + private function assembleDsn(array $driverOptions): string + { + $dsn = ''; + foreach ($driverOptions as $key => $value) { + $dsn .= $key . '=' . $this->quoteForDsn($value) . ';'; + } + + return $dsn; + } + + /** @param mixed $value */ + private function quoteForDsn($value): string + { + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } else { + $value = (string) $value; + } + + if (PHP_VERSION_ID >= 80200) { + return odbc_connection_string_should_quote($value) + ? odbc_connection_string_quote($value) + : $value; + } + + throw new RuntimeException('TODO'); + } +} diff --git a/src/Driver/ODBC/Exception/ConnectionFailed.php b/src/Driver/ODBC/Exception/ConnectionFailed.php new file mode 100644 index 00000000000..bcbd59548ce --- /dev/null +++ b/src/Driver/ODBC/Exception/ConnectionFailed.php @@ -0,0 +1,19 @@ +odbcDriver = new ODBCDriver(); + } + + /** {@inheritDoc} */ + public function connect( + #[SensitiveParameter] + array $params + ): Connection { + return new Connection($this->odbcDriver->connect($params)); + } + + public function getExceptionConverter(): ExceptionConverter + { + return $this->odbcDriver->getExceptionConverter(); + } +} diff --git a/src/Driver/ODBC/Result.php b/src/Driver/ODBC/Result.php new file mode 100644 index 00000000000..29e0a61fe2a --- /dev/null +++ b/src/Driver/ODBC/Result.php @@ -0,0 +1,170 @@ +result = $result; + } + + public function __destruct() + { + if (! isset($this->result)) { + return; + } + + $this->free(); + } + + /** {@inheritDoc} */ + public function fetchNumeric() + { + if ($this->result === null || ! odbc_fetch_row($this->result)) { + return false; + } + + $row = []; + for ($i = 1; $i <= $this->columnCount(); $i++) { + $row[] = $this->readResultColumn($this->result, $i); + } + + return $row; + } + + /** {@inheritDoc} */ + public function fetchAssociative() + { + if ($this->result === null || ! odbc_fetch_row($this->result)) { + return false; + } + + $row = []; + for ($i = 1; $i <= $this->columnCount(); $i++) { + $fieldName = odbc_field_name($this->result, $i); + assert($fieldName !== false); + + $row[$fieldName] = $this->readResultColumn($this->result, $i); + } + + return $row; + } + + /** {@inheritDoc} */ + public function fetchOne() + { + if ($this->result === null || ! odbc_fetch_row($this->result)) { + return false; + } + + return $this->readResultColumn($this->result, 1); + } + + /** {@inheritDoc} */ + public function fetchAllNumeric(): array + { + return FetchUtils::fetchAllNumeric($this); + } + + /** {@inheritDoc} */ + public function fetchAllAssociative(): array + { + return FetchUtils::fetchAllAssociative($this); + } + + /** {@inheritDoc} */ + public function fetchFirstColumn(): array + { + return FetchUtils::fetchFirstColumn($this); + } + + public function rowCount(): int + { + if ($this->result === null) { + return 0; + } + + return odbc_num_rows($this->result); + } + + public function columnCount(): int + { + if ($this->result === null) { + return 0; + } + + return odbc_num_fields($this->result); + } + + public function free(): void + { + if ($this->result === null) { + return; + } + + @odbc_free_result($this->result); + $this->result = null; + } + + /** + * @param resource $result + * + * @return mixed + */ + private function readResultColumn($result, int $i) + { + ob_start(); + try { + $data = odbc_result($result, $i); + if ($data === true) { + $data = ob_get_contents(); + } + } finally { + ob_end_clean(); + } + + return $data; + } +} diff --git a/src/Driver/ODBC/SQLServer/Connection.php b/src/Driver/ODBC/SQLServer/Connection.php new file mode 100644 index 00000000000..c53f95fbcfe --- /dev/null +++ b/src/Driver/ODBC/SQLServer/Connection.php @@ -0,0 +1,54 @@ +prepare('SELECT CONVERT(VARCHAR(MAX), current_value) FROM sys.sequences WHERE name = ?'); + $statement->bindValue(1, $name); + $result = $statement->execute(); + } else { + $result = $this->query('SELECT @@IDENTITY'); + } + + return $result->fetchOne(); + } + + public function getServerVersion(): string + { + $version = $this->query('SELECT SERVERPROPERTY(\'productversion\')')->fetchOne(); + if ($version === false) { + throw new RuntimeException('Unable to determine server version.'); + } + + return $version; + } +} diff --git a/src/Driver/ODBC/SQLServer/Driver.php b/src/Driver/ODBC/SQLServer/Driver.php new file mode 100644 index 00000000000..dc89f17f3df --- /dev/null +++ b/src/Driver/ODBC/SQLServer/Driver.php @@ -0,0 +1,42 @@ +odbcDriver = new ODBCDriver(); + } + + /** {@inheritDoc} */ + public function connect( + #[SensitiveParameter] + array $params + ): Connection { + if (isset($params['host'])) { + $params['driverOptions']['Server'] ??= 'tcp:' . $params['host'] + . (isset($params['port']) ? ',' . $params['port'] : ''); + } + + if (isset($params['dbname'])) { + $params['driverOptions']['Database'] ??= $params['dbname']; + } + + return new Connection($this->odbcDriver->connect($params)); + } + + public function getExceptionConverter(): ExceptionConverter + { + return $this->odbcDriver->getExceptionConverter(); + } +} diff --git a/src/Driver/ODBC/Statement.php b/src/Driver/ODBC/Statement.php new file mode 100644 index 00000000000..5d1fd5669ac --- /dev/null +++ b/src/Driver/ODBC/Statement.php @@ -0,0 +1,134 @@ + */ + private array $parameterMap; + + /** @var array */ + private array $parameters = []; + + /** + * @param resource $connection + * @param array $parameterMap + */ + public function __construct($connection, string $sql, array $parameterMap) + { + $this->connection = $connection; + $this->sql = $sql; + $this->parameterMap = $parameterMap; + } + + /** {@inheritDoc} */ + public function bindValue($param, $value, $type = ParameterType::STRING): bool + { + if (! isset($this->parameterMap[$param])) { + throw UnknownParameter::new((string) $param); + } + + $this->parameters[$this->parameterMap[$param]] = $value; + + return true; + } + + /** {@inheritDoc} */ + public function bindParam($param, &$variable, $type = ParameterType::STRING, $length = null): bool + { + Deprecation::trigger( + 'doctrine/dbal', + 'https://github.com/doctrine/dbal/pull/5563', + '%s is deprecated. Use bindValue() instead.', + __METHOD__, + ); + + if (func_num_args() < 3) { + Deprecation::trigger( + 'doctrine/dbal', + 'https://github.com/doctrine/dbal/pull/5558', + 'Not passing $type to Statement::bindParam() is deprecated.' + . ' Pass the type corresponding to the parameter being bound.', + ); + } + + if (func_num_args() > 4) { + Deprecation::triggerIfCalledFromOutside( + 'doctrine/dbal', + 'https://github.com/doctrine/dbal/issues/4533', + 'The $length argument of Statement::bindParam() is deprecated.', + ); + } + + if (! isset($this->parameterMap[$param])) { + throw UnknownParameter::new((string) $param); + } + + $this->parameters[$this->parameterMap[$param]] = &$variable; + + return true; + } + + /** {@inheritDoc} */ + public function execute($params = null): Result + { + if ($params !== null) { + Deprecation::trigger( + 'doctrine/dbal', + 'https://github.com/doctrine/dbal/pull/5556', + 'Passing $params to Statement::execute() is deprecated. Bind parameters using' + . ' Statement::bindParam() or Statement::bindValue() instead.', + ); + + foreach ($params as $param => $value) { + if (is_int($param)) { + $this->bindValue($param + 1, $value, ParameterType::STRING); + } else { + $this->bindValue($param, $value, ParameterType::STRING); + } + } + } + + ksort($this->parameters); + + $parameters = array_map( + static fn ($value) => is_resource($value) + ? stream_get_contents($value) + : $value, + $this->parameters, + ); + + $statement = @odbc_prepare($this->connection, $this->sql); + if ($statement === false) { + throw Error::new($this->connection); + } + + if (@odbc_execute($statement, $parameters) === false) { + throw Error::new($this->connection); + } + + return new Result($statement); + } +} diff --git a/src/DriverManager.php b/src/DriverManager.php index 056f42084eb..6cee5cf76c4 100644 --- a/src/DriverManager.php +++ b/src/DriverManager.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Driver\IBMDB2; use Doctrine\DBAL\Driver\Mysqli; use Doctrine\DBAL\Driver\OCI8; +use Doctrine\DBAL\Driver\ODBC; use Doctrine\DBAL\Driver\PDO; use Doctrine\DBAL\Driver\PgSQL; use Doctrine\DBAL\Driver\SQLite3; @@ -84,6 +85,8 @@ final class DriverManager 'pdo_pgsql' => PDO\PgSQL\Driver::class, 'pdo_oci' => PDO\OCI\Driver::class, 'oci8' => OCI8\Driver::class, + 'odbc' => ODBC\Driver::class, + 'odbc_sqlsrv' => ODBC\SQLServer\Driver::class, 'ibm_db2' => IBMDB2\Driver::class, 'pdo_sqlsrv' => PDO\SQLSrv\Driver::class, 'mysqli' => Mysqli\Driver::class, diff --git a/src/Schema/SQLServerSchemaManager.php b/src/Schema/SQLServerSchemaManager.php index 69e328fea0d..f717593af31 100644 --- a/src/Schema/SQLServerSchemaManager.php +++ b/src/Schema/SQLServerSchemaManager.php @@ -403,7 +403,7 @@ protected function selectTableNames(string $databaseName): Result ORDER BY name SQL; - return $this->_conn->executeQuery($sql, [$databaseName]); + return $this->_conn->executeQuery($sql); } protected function selectTableColumns(string $databaseName, ?string $tableName = null): Result diff --git a/tests/Functional/ConnectionTest.php b/tests/Functional/ConnectionTest.php index 567f7bcddb4..cd9b50e65ab 100644 --- a/tests/Functional/ConnectionTest.php +++ b/tests/Functional/ConnectionTest.php @@ -7,6 +7,7 @@ use Doctrine\DBAL\Driver\Connection as DriverConnection; use Doctrine\DBAL\Driver\PDO\Connection as PDOConnection; use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Exception\ConstraintViolationException; use Doctrine\DBAL\Exception\DriverException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\DBAL\ParameterType; @@ -82,7 +83,13 @@ public function testTransactionNestingBehavior(): void $this->connection->insert(self::TABLE, ['id' => 1]); self::fail('Expected exception to be thrown because of the unique constraint.'); } catch (Throwable $e) { - self::assertInstanceOf(UniqueConstraintViolationException::class, $e); + if (TestUtil::isOdbcDriver()) { + // ODBC defines a generic error code for all kinds of constraint violations + self::assertInstanceOf(ConstraintViolationException::class, $e); + } else { + self::assertInstanceOf(UniqueConstraintViolationException::class, $e); + } + $this->connection->rollBack(); self::assertSame(1, $this->connection->getTransactionNestingLevel()); } @@ -157,7 +164,13 @@ public function testTransactionNestingBehaviorWithSavepoints(): void $this->connection->insert(self::TABLE, ['id' => 1]); self::fail('Expected exception to be thrown because of the unique constraint.'); } catch (Throwable $e) { - self::assertInstanceOf(UniqueConstraintViolationException::class, $e); + if (TestUtil::isOdbcDriver()) { + // ODBC defines a generic error code for all kinds of constraint violations + self::assertInstanceOf(ConstraintViolationException::class, $e); + } else { + self::assertInstanceOf(UniqueConstraintViolationException::class, $e); + } + $this->connection->rollBack(); self::assertSame(1, $this->connection->getTransactionNestingLevel()); } @@ -254,7 +267,13 @@ public function testTransactionBehaviorWithRollback(): void $this->connection->insert(self::TABLE, ['id' => 1]); self::fail('Expected exception to be thrown because of the unique constraint.'); } catch (Throwable $e) { - self::assertInstanceOf(UniqueConstraintViolationException::class, $e); + if (TestUtil::isOdbcDriver()) { + // ODBC defines a generic error code for all kinds of constraint violations + self::assertInstanceOf(ConstraintViolationException::class, $e); + } else { + self::assertInstanceOf(UniqueConstraintViolationException::class, $e); + } + self::assertSame(1, $this->connection->getTransactionNestingLevel()); $this->connection->rollBack(); self::assertSame(0, $this->connection->getTransactionNestingLevel()); @@ -282,7 +301,13 @@ public function testTransactionalWithException(): void }); self::fail('Expected exception to be thrown because of the unique constraint.'); } catch (Throwable $e) { - self::assertInstanceOf(UniqueConstraintViolationException::class, $e); + if (TestUtil::isOdbcDriver()) { + // ODBC defines a generic error code for all kinds of constraint violations + self::assertInstanceOf(ConstraintViolationException::class, $e); + } else { + self::assertInstanceOf(UniqueConstraintViolationException::class, $e); + } + self::assertSame(0, $this->connection->getTransactionNestingLevel()); } } diff --git a/tests/Functional/ExceptionTest.php b/tests/Functional/ExceptionTest.php index 7c958a0e7eb..27b05865c38 100644 --- a/tests/Functional/ExceptionTest.php +++ b/tests/Functional/ExceptionTest.php @@ -10,7 +10,6 @@ use Doctrine\DBAL\Tests\FunctionalTestCase; use Doctrine\DBAL\Tests\TestUtil; use Doctrine\DBAL\Types\Types; -use Throwable; use function array_merge; use function chmod; @@ -39,7 +38,13 @@ public function testPrimaryConstraintViolationException(): void $this->connection->insert('duplicatekey_table', ['id' => 1]); - $this->expectException(Exception\UniqueConstraintViolationException::class); + if (TestUtil::isOdbcDriver()) { + // ODBC defines a generic error code for all kinds of constraint violations + $this->expectException(Exception\ConstraintViolationException::class); + } else { + $this->expectException(Exception\UniqueConstraintViolationException::class); + } + $this->connection->insert('duplicatekey_table', ['id' => 1]); } @@ -70,27 +75,18 @@ public function testForeignKeyConstraintViolationExceptionOnInsert(): void try { $this->connection->insert('constraint_error_table', ['id' => 1]); $this->connection->insert('owning_table', ['id' => 1, 'constraint_id' => 1]); - } catch (Throwable $exception) { - $this->tearDownForeignKeyConstraintViolationExceptionTest(); - throw $exception; - } - - $this->expectException(Exception\ForeignKeyConstraintViolationException::class); + if (TestUtil::isOdbcDriver()) { + // ODBC defines a generic error code for all kinds of constraint violations + $this->expectException(Exception\ConstraintViolationException::class); + } else { + $this->expectException(Exception\ForeignKeyConstraintViolationException::class); + } - try { $this->connection->insert('owning_table', ['id' => 2, 'constraint_id' => 2]); - } catch (Exception\ForeignKeyConstraintViolationException $exception) { - $this->tearDownForeignKeyConstraintViolationExceptionTest(); - - throw $exception; - } catch (Throwable $exception) { + } finally { $this->tearDownForeignKeyConstraintViolationExceptionTest(); - - throw $exception; } - - $this->tearDownForeignKeyConstraintViolationExceptionTest(); } public function testForeignKeyConstraintViolationExceptionOnUpdate(): void @@ -100,27 +96,18 @@ public function testForeignKeyConstraintViolationExceptionOnUpdate(): void try { $this->connection->insert('constraint_error_table', ['id' => 1]); $this->connection->insert('owning_table', ['id' => 1, 'constraint_id' => 1]); - } catch (Throwable $exception) { - $this->tearDownForeignKeyConstraintViolationExceptionTest(); - throw $exception; - } - - $this->expectException(Exception\ForeignKeyConstraintViolationException::class); + if (TestUtil::isOdbcDriver()) { + // ODBC defines a generic error code for all kinds of constraint violations + $this->expectException(Exception\ConstraintViolationException::class); + } else { + $this->expectException(Exception\ForeignKeyConstraintViolationException::class); + } - try { $this->connection->update('constraint_error_table', ['id' => 2], ['id' => 1]); - } catch (Exception\ForeignKeyConstraintViolationException $exception) { - $this->tearDownForeignKeyConstraintViolationExceptionTest(); - - throw $exception; - } catch (Throwable $exception) { + } finally { $this->tearDownForeignKeyConstraintViolationExceptionTest(); - - throw $exception; } - - $this->tearDownForeignKeyConstraintViolationExceptionTest(); } public function testForeignKeyConstraintViolationExceptionOnDelete(): void @@ -130,27 +117,18 @@ public function testForeignKeyConstraintViolationExceptionOnDelete(): void try { $this->connection->insert('constraint_error_table', ['id' => 1]); $this->connection->insert('owning_table', ['id' => 1, 'constraint_id' => 1]); - } catch (Throwable $exception) { - $this->tearDownForeignKeyConstraintViolationExceptionTest(); - throw $exception; - } - - $this->expectException(Exception\ForeignKeyConstraintViolationException::class); + if (TestUtil::isOdbcDriver()) { + // ODBC defines a generic error code for all kinds of constraint violations + $this->expectException(Exception\ConstraintViolationException::class); + } else { + $this->expectException(Exception\ForeignKeyConstraintViolationException::class); + } - try { $this->connection->delete('constraint_error_table', ['id' => 1]); - } catch (Exception\ForeignKeyConstraintViolationException $exception) { - $this->tearDownForeignKeyConstraintViolationExceptionTest(); - - throw $exception; - } catch (Throwable $exception) { + } finally { $this->tearDownForeignKeyConstraintViolationExceptionTest(); - - throw $exception; } - - $this->tearDownForeignKeyConstraintViolationExceptionTest(); } public function testForeignKeyConstraintViolationExceptionOnTruncate(): void @@ -162,27 +140,18 @@ public function testForeignKeyConstraintViolationExceptionOnTruncate(): void try { $this->connection->insert('constraint_error_table', ['id' => 1]); $this->connection->insert('owning_table', ['id' => 1, 'constraint_id' => 1]); - } catch (Throwable $exception) { - $this->tearDownForeignKeyConstraintViolationExceptionTest(); - throw $exception; - } - - $this->expectException(Exception\ForeignKeyConstraintViolationException::class); + if (TestUtil::isOdbcDriver()) { + // ODBC reports this as "Syntax error or access violation" + $this->expectException(Exception\SyntaxErrorException::class); + } else { + $this->expectException(Exception\ForeignKeyConstraintViolationException::class); + } - try { $this->connection->executeStatement($platform->getTruncateTableSQL('constraint_error_table')); - } catch (Exception\ForeignKeyConstraintViolationException $exception) { - $this->tearDownForeignKeyConstraintViolationExceptionTest(); - - throw $exception; - } catch (Throwable $exception) { + } finally { $this->tearDownForeignKeyConstraintViolationExceptionTest(); - - throw $exception; } - - $this->tearDownForeignKeyConstraintViolationExceptionTest(); } public function testNotNullConstraintViolationException(): void @@ -193,7 +162,13 @@ public function testNotNullConstraintViolationException(): void $table->setPrimaryKey(['id']); $this->dropAndCreateTable($table); - $this->expectException(Exception\NotNullConstraintViolationException::class); + if (TestUtil::isOdbcDriver()) { + // ODBC defines a generic error code for all kinds of constraint violations + $this->expectException(Exception\ConstraintViolationException::class); + } else { + $this->expectException(Exception\NotNullConstraintViolationException::class); + } + $this->connection->insert('notnull_table', ['id' => 1, 'val' => null]); } @@ -206,7 +181,13 @@ public function testInvalidFieldNameException(): void // prevent the PHPUnit error handler from handling the warning that db2_bind_param() may trigger $this->iniSet('error_reporting', (string) (E_ALL & ~E_WARNING)); - $this->expectException(Exception\InvalidFieldNameException::class); + if (TestUtil::isOdbcDriver()) { + // ODBC does not report this error as fine-grained as we need it. + $this->expectException(Exception\DatabaseObjectNotFoundException::class); + } else { + $this->expectException(Exception\InvalidFieldNameException::class); + } + $this->connection->insert('bad_columnname_table', ['name' => 5]); } @@ -221,7 +202,13 @@ public function testNonUniqueFieldNameException(): void $this->dropAndCreateTable($table2); $sql = 'SELECT id FROM ambiguous_list_table_1, ambiguous_list_table_2'; - $this->expectException(Exception\NonUniqueFieldNameException::class); + if (TestUtil::isOdbcDriver()) { + // ODBC reports this as "Syntax error or access violation". + $this->expectException(Exception\SyntaxErrorException::class); + } else { + $this->expectException(Exception\NonUniqueFieldNameException::class); + } + $this->connection->executeQuery($sql); } @@ -234,7 +221,14 @@ public function testUniqueConstraintViolationException(): void $this->dropAndCreateTable($table); $this->connection->insert('unique_column_table', ['id' => 5]); - $this->expectException(Exception\UniqueConstraintViolationException::class); + + if (TestUtil::isOdbcDriver()) { + // ODBC defines a generic error code for all kinds of constraint violations + $this->expectException(Exception\ConstraintViolationException::class); + } else { + $this->expectException(Exception\UniqueConstraintViolationException::class); + } + $this->connection->insert('unique_column_table', ['id' => 5]); } diff --git a/tests/Functional/Schema/SchemaManagerFunctionalTestCase.php b/tests/Functional/Schema/SchemaManagerFunctionalTestCase.php index b88bcdec901..9f42ae86351 100644 --- a/tests/Functional/Schema/SchemaManagerFunctionalTestCase.php +++ b/tests/Functional/Schema/SchemaManagerFunctionalTestCase.php @@ -7,6 +7,7 @@ use Doctrine\DBAL\Events; use Doctrine\DBAL\Exception; use Doctrine\DBAL\Exception\DatabaseObjectNotFoundException; +use Doctrine\DBAL\Exception\SyntaxErrorException; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\OraclePlatform; use Doctrine\DBAL\Platforms\SqlitePlatform; @@ -178,7 +179,7 @@ public function testListSchemaNames(callable $method): void try { $this->schemaManager->dropSchema('test_create_schema'); - } catch (DatabaseObjectNotFoundException $e) { + } catch (DatabaseObjectNotFoundException | SyntaxErrorException $e) { } self::assertNotContains('test_create_schema', $this->schemaManager->listSchemaNames()); diff --git a/tests/TestUtil.php b/tests/TestUtil.php index 3da92507daf..1b0ba43dd44 100644 --- a/tests/TestUtil.php +++ b/tests/TestUtil.php @@ -237,6 +237,11 @@ public static function isDriverOneOf(string ...$names): bool return in_array(self::getConnectionParams()['driver'], $names, true); } + public static function isOdbcDriver(): bool + { + return self::isDriverOneOf('odbc_sqlsrv'); + } + /** * Generates a query that will return the given rows without the need to create a temporary table. *