From bd26e26230322658c094f98c63e19162212fdaa6 Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Wed, 31 Aug 2022 09:41:04 +0200 Subject: [PATCH] SQLite3 driver --- .github/workflows/continuous-integration.yml | 22 +++- ci/github/phpunit/pdo_sqlite.xml | 30 +++++ ci/github/phpunit/{sqlite.xml => sqlite3.xml} | 4 + docs/en/reference/configuration.rst | 10 ++ phpcs.xml.dist | 6 + .../API/SQLite/UserDefinedFunctions.php | 22 ++++ src/Driver/PDO/SQLite/Driver.php | 26 ++-- src/Driver/SQLite3/Connection.php | 107 ++++++++++++++++ src/Driver/SQLite3/Driver.php | 46 +++++++ src/Driver/SQLite3/Exception.php | 18 +++ src/Driver/SQLite3/Result.php | 91 ++++++++++++++ src/Driver/SQLite3/Statement.php | 119 ++++++++++++++++++ src/DriverManager.php | 2 + src/Types/DecimalType.php | 3 +- .../Functional/Driver/SQLite3/DriverTest.php | 78 ++++++++++++ tests/Functional/StatementTest.php | 4 + tests/TestUtil.php | 1 + 17 files changed, 566 insertions(+), 23 deletions(-) create mode 100644 ci/github/phpunit/pdo_sqlite.xml rename ci/github/phpunit/{sqlite.xml => sqlite3.xml} (80%) create mode 100644 src/Driver/SQLite3/Connection.php create mode 100644 src/Driver/SQLite3/Driver.php create mode 100644 src/Driver/SQLite3/Exception.php create mode 100644 src/Driver/SQLite3/Result.php create mode 100644 src/Driver/SQLite3/Statement.php create mode 100644 tests/Functional/Driver/SQLite3/DriverTest.php diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index e3f1373d8c6..d06e6d1df84 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -44,13 +44,21 @@ jobs: - "8.2" dependencies: - "highest" + extension: + - "pdo_sqlite" include: - os: "ubuntu-20.04" php-version: "7.4" dependencies: "lowest" + extension: "pdo_sqlite" + - os: "ubuntu-22.04" + php-version: "7.4" + dependencies: "highest" + extension: "sqlite3" - os: "ubuntu-22.04" php-version: "8.1" dependencies: "highest" + extension: "sqlite3" steps: - name: "Checkout" @@ -74,14 +82,20 @@ jobs: - name: "Print SQLite version" run: > php -r 'printf("Testing with libsqlite version %s\n", (new PDO("sqlite::memory:"))->query("select sqlite_version()")->fetch()[0]);' + if: "${{ matrix.extension == 'pdo_sqlite' }}" + + - name: "Print SQLite version" + run: > + php -r 'printf("Testing with libsqlite version %s\n", SQLite3::version()["versionString"]);' + if: "${{ matrix.extension == 'sqlite3' }}" - name: "Run PHPUnit" - run: "vendor/bin/phpunit -c ci/github/phpunit/sqlite.xml --coverage-clover=coverage.xml" + run: "vendor/bin/phpunit -c ci/github/phpunit/${{ matrix.extension }}.xml --coverage-clover=coverage.xml" - name: "Upload coverage file" uses: "actions/upload-artifact@v3" with: - name: "phpunit-sqlite-${{ matrix.deps }}-${{ matrix.php-version }}.coverage" + name: "phpunit-${{ matrix.extension }}-${{ matrix.deps }}-${{ matrix.php-version }}.coverage" path: "coverage.xml" phpunit-oci8: @@ -551,7 +565,7 @@ jobs: path: "coverage.xml" development-deps: - name: "PHPUnit with SQLite and development dependencies" + name: "PHPUnit with PDO_SQLite and development dependencies" runs-on: "ubuntu-22.04" strategy: @@ -577,7 +591,7 @@ jobs: composer-options: "--prefer-dist" - name: "Run PHPUnit" - run: "vendor/bin/phpunit -c ci/github/phpunit/sqlite.xml" + run: "vendor/bin/phpunit -c ci/github/phpunit/pdo_sqlite.xml" upload_coverage: name: "Upload coverage to Codecov" diff --git a/ci/github/phpunit/pdo_sqlite.xml b/ci/github/phpunit/pdo_sqlite.xml new file mode 100644 index 00000000000..2ff89cc6f10 --- /dev/null +++ b/ci/github/phpunit/pdo_sqlite.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + ../../../tests + + + + + + ../../../src + + + diff --git a/ci/github/phpunit/sqlite.xml b/ci/github/phpunit/sqlite3.xml similarity index 80% rename from ci/github/phpunit/sqlite.xml rename to ci/github/phpunit/sqlite3.xml index 5b3b7f613c7..3c58120444c 100644 --- a/ci/github/phpunit/sqlite.xml +++ b/ci/github/phpunit/sqlite3.xml @@ -10,6 +10,10 @@ > + + + + diff --git a/docs/en/reference/configuration.rst b/docs/en/reference/configuration.rst index e8ac39b8af3..4086b13df32 100644 --- a/docs/en/reference/configuration.rst +++ b/docs/en/reference/configuration.rst @@ -113,6 +113,7 @@ interfaces to use. It can be configured in one of three ways: - ``mysqli``: A MySQL driver that uses the mysqli extension. - ``pdo_sqlite``: An SQLite driver that uses the pdo_sqlite PDO extension. + - ``sqlite3``: An SQLite driver that uses the sqlite3 extension. - ``pdo_pgsql``: A PostgreSQL driver that uses the pdo_pgsql PDO extension. - ``pdo_oci``: An Oracle driver that uses the pdo_oci PDO @@ -155,6 +156,15 @@ pdo_sqlite in-memory (non-persistent). Mutually exclusive with ``path``. ``path`` takes precedence. +sqlite3 +^^^^^^^ + +- ``path`` (string): The filesystem path to the database file. + Mutually exclusive with ``memory``. ``path`` takes precedence. +- ``memory`` (boolean): True if the SQLite database should be + in-memory (non-persistent). Mutually exclusive with ``path``. + ``path`` takes precedence. + pdo_mysql ^^^^^^^^^ diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 23e215e2b46..5771c4252a7 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -56,6 +56,12 @@ */src/* + + + + src/Driver/SQLite3/* + + */tests/* diff --git a/src/Driver/API/SQLite/UserDefinedFunctions.php b/src/Driver/API/SQLite/UserDefinedFunctions.php index 29e73d1f271..1df93226b45 100644 --- a/src/Driver/API/SQLite/UserDefinedFunctions.php +++ b/src/Driver/API/SQLite/UserDefinedFunctions.php @@ -2,6 +2,9 @@ namespace Doctrine\DBAL\Driver\API\SQLite; +use Doctrine\DBAL\Platforms\SqlitePlatform; + +use function array_merge; use function strpos; /** @@ -11,6 +14,25 @@ */ final class UserDefinedFunctions { + private const DEFAULT_FUNCTIONS = [ + 'sqrt' => ['callback' => [SqlitePlatform::class, 'udfSqrt'], 'numArgs' => 1], + 'mod' => ['callback' => [SqlitePlatform::class, 'udfMod'], 'numArgs' => 2], + 'locate' => ['callback' => [SqlitePlatform::class, 'udfLocate'], 'numArgs' => -1], + ]; + + /** + * @param callable(string, callable, int): bool $callback + * @param array $additionalFunctions + */ + public static function register(callable $callback, array $additionalFunctions = []): void + { + $userDefinedFunctions = array_merge(self::DEFAULT_FUNCTIONS, $additionalFunctions); + + foreach ($userDefinedFunctions as $function => $data) { + $callback($function, $data['callback'], $data['numArgs']); + } + } + /** * User-defined function that implements MOD(). * diff --git a/src/Driver/PDO/SQLite/Driver.php b/src/Driver/PDO/SQLite/Driver.php index ea442d8d376..4840b67880a 100644 --- a/src/Driver/PDO/SQLite/Driver.php +++ b/src/Driver/PDO/SQLite/Driver.php @@ -3,24 +3,15 @@ namespace Doctrine\DBAL\Driver\PDO\SQLite; use Doctrine\DBAL\Driver\AbstractSQLiteDriver; +use Doctrine\DBAL\Driver\API\SQLite\UserDefinedFunctions; use Doctrine\DBAL\Driver\PDO\Connection; use Doctrine\DBAL\Driver\PDO\Exception; -use Doctrine\DBAL\Platforms\SqlitePlatform; use Doctrine\Deprecations\Deprecation; use PDO; use PDOException; -use function array_merge; - final class Driver extends AbstractSQLiteDriver { - /** @var mixed[] */ - private array $userDefinedFunctions = [ - 'sqrt' => ['callback' => [SqlitePlatform::class, 'udfSqrt'], 'numArgs' => 1], - 'mod' => ['callback' => [SqlitePlatform::class, 'udfMod'], 'numArgs' => 2], - 'locate' => ['callback' => [SqlitePlatform::class, 'udfLocate'], 'numArgs' => -1], - ]; - /** * {@inheritdoc} * @@ -28,7 +19,8 @@ final class Driver extends AbstractSQLiteDriver */ public function connect(array $params) { - $driverOptions = $params['driverOptions'] ?? []; + $driverOptions = $params['driverOptions'] ?? []; + $userDefinedFunctions = []; if (isset($driverOptions['userDefinedFunctions'])) { Deprecation::trigger( @@ -38,10 +30,7 @@ public function connect(array $params) . ' Register function directly on the native connection instead.', ); - $this->userDefinedFunctions = array_merge( - $this->userDefinedFunctions, - $driverOptions['userDefinedFunctions'], - ); + $userDefinedFunctions = $driverOptions['userDefinedFunctions']; unset($driverOptions['userDefinedFunctions']); } @@ -56,9 +45,10 @@ public function connect(array $params) throw Exception::new($exception); } - foreach ($this->userDefinedFunctions as $fn => $data) { - $pdo->sqliteCreateFunction($fn, $data['callback'], $data['numArgs']); - } + UserDefinedFunctions::register( + [$pdo, 'sqliteCreateFunction'], + $userDefinedFunctions, + ); return new Connection($pdo); } diff --git a/src/Driver/SQLite3/Connection.php b/src/Driver/SQLite3/Connection.php new file mode 100644 index 00000000000..de9a8e031ba --- /dev/null +++ b/src/Driver/SQLite3/Connection.php @@ -0,0 +1,107 @@ +connection = $connection; + } + + public function prepare(string $sql): Statement + { + try { + $statement = $this->connection->prepare($sql); + } catch (\Exception $e) { + throw Exception::new($e); + } + + assert($statement !== false); + + return new Statement($this->connection, $statement); + } + + public function query(string $sql): Result + { + try { + $result = $this->connection->query($sql); + } catch (\Exception $e) { + throw Exception::new($e); + } + + assert($result !== false); + + return new Result($result, $this->connection->changes()); + } + + /** @inheritdoc */ + public function quote($value, $type = ParameterType::STRING): string + { + return sprintf('\'%s\'', SQLite3::escapeString($value)); + } + + public function exec(string $sql): int + { + try { + $this->connection->exec($sql); + } catch (\Exception $e) { + throw Exception::new($e); + } + + return $this->connection->changes(); + } + + /** @inheritdoc */ + public function lastInsertId($name = null): int + { + return $this->connection->lastInsertRowID(); + } + + public function beginTransaction(): bool + { + try { + return $this->connection->exec('BEGIN'); + } catch (\Exception $e) { + return false; + } + } + + public function commit(): bool + { + try { + return $this->connection->exec('COMMIT'); + } catch (\Exception $e) { + return false; + } + } + + public function rollBack(): bool + { + try { + return $this->connection->exec('ROLLBACK'); + } catch (\Exception $e) { + return false; + } + } + + public function getNativeConnection(): SQLite3 + { + return $this->connection; + } + + public function getServerVersion(): string + { + return SQLite3::version()['versionString']; + } +} diff --git a/src/Driver/SQLite3/Driver.php b/src/Driver/SQLite3/Driver.php new file mode 100644 index 00000000000..a2276d52850 --- /dev/null +++ b/src/Driver/SQLite3/Driver.php @@ -0,0 +1,46 @@ +enableExceptions(true); + + UserDefinedFunctions::register([$connection, 'createFunction']); + + return new Connection($connection); + } +} diff --git a/src/Driver/SQLite3/Exception.php b/src/Driver/SQLite3/Exception.php new file mode 100644 index 00000000000..3ca1190bc8c --- /dev/null +++ b/src/Driver/SQLite3/Exception.php @@ -0,0 +1,18 @@ +getMessage(), null, (int) $exception->getCode(), $exception); + } +} diff --git a/src/Driver/SQLite3/Result.php b/src/Driver/SQLite3/Result.php new file mode 100644 index 00000000000..a51bd4e8be4 --- /dev/null +++ b/src/Driver/SQLite3/Result.php @@ -0,0 +1,91 @@ +result = $result; + $this->changes = $changes; + } + + /** @inheritdoc */ + public function fetchNumeric() + { + if ($this->result === null) { + return false; + } + + return $this->result->fetchArray(SQLITE3_NUM); + } + + /** @inheritdoc */ + public function fetchAssociative() + { + if ($this->result === null) { + return false; + } + + return $this->result->fetchArray(SQLITE3_ASSOC); + } + + /** @inheritdoc */ + public function fetchOne() + { + return FetchUtils::fetchOne($this); + } + + /** @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 + { + return $this->changes; + } + + public function columnCount(): int + { + if ($this->result === null) { + return 0; + } + + return $this->result->numColumns(); + } + + public function free(): void + { + if ($this->result === null) { + return; + } + + $this->result->finalize(); + $this->result = null; + } +} diff --git a/src/Driver/SQLite3/Statement.php b/src/Driver/SQLite3/Statement.php new file mode 100644 index 00000000000..878627a6a2c --- /dev/null +++ b/src/Driver/SQLite3/Statement.php @@ -0,0 +1,119 @@ + SQLITE3_NULL, + ParameterType::INTEGER => SQLITE3_INTEGER, + ParameterType::STRING => SQLITE3_TEXT, + ParameterType::ASCII => SQLITE3_TEXT, + ParameterType::BINARY => SQLITE3_BLOB, + ParameterType::LARGE_OBJECT => SQLITE3_BLOB, + ParameterType::BOOLEAN => SQLITE3_INTEGER, + ]; + + private SQLite3 $connection; + private SQLite3Stmt $statement; + + /** @internal The statement can be only instantiated by its driver connection. */ + public function __construct(SQLite3 $connection, SQLite3Stmt $statement) + { + $this->connection = $connection; + $this->statement = $statement; + } + + /** @inheritdoc */ + public function bindValue($param, $value, $type = ParameterType::STRING): bool + { + if (func_num_args() < 3) { + Deprecation::trigger( + 'doctrine/dbal', + 'https://github.com/doctrine/dbal/pull/5558', + 'Not passing $type to Statement::bindValue() is deprecated.' + . ' Pass the type corresponding to the parameter being bound.', + ); + } + + return $this->statement->bindValue($param, $value, $this->convertParamType($type)); + } + + /** @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.', + ); + } + + return $this->statement->bindParam($param, $variable, $this->convertParamType($type)); + } + + /** @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); + } + } + } + + try { + $result = $this->statement->execute(); + } catch (\Exception $e) { + throw Exception::new($e); + } + + assert($result !== false); + + return new Result($result, $this->connection->changes()); + } + + private function convertParamType(int $type): int + { + if (! isset(self::PARAM_TYPE_MAP[$type])) { + throw UnknownParameterType::new($type); + } + + return self::PARAM_TYPE_MAP[$type]; + } +} diff --git a/src/DriverManager.php b/src/DriverManager.php index a1a2a2185b8..5c30088ec28 100644 --- a/src/DriverManager.php +++ b/src/DriverManager.php @@ -7,6 +7,7 @@ use Doctrine\DBAL\Driver\Mysqli; use Doctrine\DBAL\Driver\OCI8; use Doctrine\DBAL\Driver\PDO; +use Doctrine\DBAL\Driver\SQLite3; use Doctrine\DBAL\Driver\SQLSrv; use Doctrine\Deprecations\Deprecation; @@ -88,6 +89,7 @@ final class DriverManager 'pdo_sqlsrv' => PDO\SQLSrv\Driver::class, 'mysqli' => Mysqli\Driver::class, 'sqlsrv' => SQLSrv\Driver::class, + 'sqlite3' => SQLite3\Driver::class, ]; /** diff --git a/src/Types/DecimalType.php b/src/Types/DecimalType.php index c70067f2b4f..144e97a0492 100644 --- a/src/Types/DecimalType.php +++ b/src/Types/DecimalType.php @@ -3,6 +3,7 @@ namespace Doctrine\DBAL\Types; use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Platforms\SqlitePlatform; use function is_float; use function is_int; @@ -37,7 +38,7 @@ public function convertToPHPValue($value, AbstractPlatform $platform) { // Some drivers starting from PHP 8.1 can represent decimals as float/int // See also: https://github.com/doctrine/dbal/pull/4818 - if (PHP_VERSION_ID >= 80100 && (is_float($value) || is_int($value))) { + if ((PHP_VERSION_ID >= 80100 || $platform instanceof SqlitePlatform) && (is_float($value) || is_int($value))) { return (string) $value; } diff --git a/tests/Functional/Driver/SQLite3/DriverTest.php b/tests/Functional/Driver/SQLite3/DriverTest.php new file mode 100644 index 00000000000..99ef2b421f5 --- /dev/null +++ b/tests/Functional/Driver/SQLite3/DriverTest.php @@ -0,0 +1,78 @@ +connection->getParams(); + unset($params['path'], $params['memory']); + + $this->expectException(DriverException::class); + $this->expectExceptionMessage( + 'An exception occurred in the driver: ' + . 'Invalid connection settings: specify either the "path" or the "memory" parameter for SQLite3.', + ); + + $connection = new Connection( + $params, + $this->connection->getDriver(), + $this->connection->getConfiguration(), + $this->connection->getEventManager(), + ); + + $connection->fetchOne('SELECT 1'); + } + + public function testAmbiguousParams(): void + { + $params = $this->connection->getParams(); + $params['path'] = __DIR__ . '/dont-create-me.db'; + $params['memory'] = true; + + $this->expectException(DriverException::class); + $this->expectExceptionMessage( + 'An exception occurred in the driver: ' + . 'Invalid connection settings: specifying both parameters "path" and "memory" ambiguous.', + ); + + $connection = new Connection( + $params, + $this->connection->getDriver(), + $this->connection->getConfiguration(), + $this->connection->getEventManager(), + ); + + $connection->fetchOne('SELECT 1'); + } +} diff --git a/tests/Functional/StatementTest.php b/tests/Functional/StatementTest.php index 8f3863f837b..668636e8f30 100644 --- a/tests/Functional/StatementTest.php +++ b/tests/Functional/StatementTest.php @@ -247,6 +247,10 @@ public function testBindInvalidNamedParameter(): void self::markTestSkipped('The driver does not support named statement parameters'); } + if (TestUtil::isDriverOneOf('sqlite3')) { + self::markTestSkipped('SQLite3 does not report this error'); + } + if (PHP_VERSION_ID < 80000 && TestUtil::isDriverOneOf('pdo_oci')) { self::markTestSkipped('pdo_oci on PHP 7 does not report this error'); } diff --git a/tests/TestUtil.php b/tests/TestUtil.php index 74fede57b5c..c6dd4b33289 100644 --- a/tests/TestUtil.php +++ b/tests/TestUtil.php @@ -200,6 +200,7 @@ private static function mapConnectionParameters(array $configuration, string $pr 'password', 'host', 'dbname', + 'memory', 'port', 'server', 'ssl_key',