diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index e3f1373d8c6..8513a0f7cd0 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" @@ -76,12 +84,12 @@ jobs: php -r 'printf("Testing with libsqlite version %s\n", (new PDO("sqlite::memory:"))->query("select sqlite_version()")->fetch()[0]);' - 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 +559,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 +585,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..0da61280d47 100644 --- a/src/Driver/API/SQLite/UserDefinedFunctions.php +++ b/src/Driver/API/SQLite/UserDefinedFunctions.php @@ -2,6 +2,10 @@ namespace Doctrine\DBAL\Driver\API\SQLite; +use Closure; +use Doctrine\DBAL\Platforms\SqlitePlatform; + +use function array_merge; use function strpos; /** @@ -11,6 +15,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 Closure(string, callable, int): bool $callback + * @param array $additionalFunctions + */ + public static function register(Closure $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 9d56526c519..8c3bd7db86d 100644 --- a/src/Driver/PDO/SQLite/Driver.php +++ b/src/Driver/PDO/SQLite/Driver.php @@ -2,24 +2,16 @@ namespace Doctrine\DBAL\Driver\PDO\SQLite; +use Closure; 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 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} * @@ -27,15 +19,9 @@ final class Driver extends AbstractSQLiteDriver */ public function connect(array $params) { - $driverOptions = $params['driverOptions'] ?? []; - - if (isset($driverOptions['userDefinedFunctions'])) { - $this->userDefinedFunctions = array_merge( - $this->userDefinedFunctions, - $driverOptions['userDefinedFunctions'], - ); - unset($driverOptions['userDefinedFunctions']); - } + $driverOptions = $params['driverOptions'] ?? []; + $userDefinedFunctions = $driverOptions['userDefinedFunctions'] ?? []; + unset($driverOptions['userDefinedFunctions']); try { $pdo = new PDO( @@ -48,9 +34,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( + Closure::fromCallable([$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..55186235bff --- /dev/null +++ b/src/Driver/SQLite3/Driver.php @@ -0,0 +1,33 @@ +enableExceptions(true); + + UserDefinedFunctions::register( + Closure::fromCallable([$connection, 'createFunction']), + $params['driverOptions']['userDefinedFunctions'] ?? [], + ); + + 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/PDO/SQLite/DriverTest.php b/tests/Functional/Driver/PDO/SQLite/DriverTest.php index 38f1395ba4c..294bb251d51 100644 --- a/tests/Functional/Driver/PDO/SQLite/DriverTest.php +++ b/tests/Functional/Driver/PDO/SQLite/DriverTest.php @@ -2,6 +2,7 @@ namespace Doctrine\DBAL\Tests\Functional\Driver\PDO\SQLite; +use Doctrine\DBAL\Connection; use Doctrine\DBAL\Driver as DriverInterface; use Doctrine\DBAL\Driver\PDO\SQLite\Driver; use Doctrine\DBAL\Tests\Functional\Driver\AbstractDriverTest; @@ -21,13 +22,30 @@ protected function setUp(): void self::markTestSkipped('This test requires the pdo_sqlite driver.'); } - public function testReturnsDatabaseNameWithoutDatabaseNameParameter(): void + protected static function getDatabaseNameForConnectionWithoutDatabaseNameParameter(): ?string { - self::markTestSkipped('SQLite does not support the concept of a database.'); + return 'main'; } protected function createDriver(): DriverInterface { return new Driver(); } + + public function testRegisterCustomFunction(): void + { + $params = $this->connection->getParams(); + $params['driverOptions']['userDefinedFunctions'] = [ + 'my_add' => ['callback' => static fn (int $a, int $b): int => $a + $b, 'numArgs' => 2], + ]; + + $connection = new Connection( + $params, + $this->connection->getDriver(), + $this->connection->getConfiguration(), + $this->connection->getEventManager(), + ); + + self::assertSame(42, (int) $connection->fetchOne('SELECT my_add(20, 22)')); + } } diff --git a/tests/Functional/Driver/SQLite3/DriverTest.php b/tests/Functional/Driver/SQLite3/DriverTest.php new file mode 100644 index 00000000000..97c2b53e5b0 --- /dev/null +++ b/tests/Functional/Driver/SQLite3/DriverTest.php @@ -0,0 +1,51 @@ +connection->getParams(); + $params['driverOptions']['userDefinedFunctions'] = [ + 'my_add' => ['callback' => static fn (int $a, int $b): int => $a + $b, 'numArgs' => 2], + ]; + + $connection = new Connection( + $params, + $this->connection->getDriver(), + $this->connection->getConfiguration(), + $this->connection->getEventManager(), + ); + + self::assertSame(42, $connection->fetchOne('SELECT my_add(20, 22)')); + } +} 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',