diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index a5ee5eafc..440b1ed54 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -162,6 +162,12 @@ jobs: php -d opcache.enable_cli=1 vendor/bin/phpunit --exclude-group none $(if [ -n "$LOG_COVERAGE" ]; then echo --coverage-text; else echo --no-coverage; fi) --fail-on-warning --fail-on-risky $(if vendor/bin/phpunit --version | grep -q '^PHPUnit 9\.'; then echo -v; else echo --fail-on-notice --fail-on-deprecation --display-notices --display-deprecations --display-warnings --display-errors --display-incomplete --display-skipped; fi) if [ -n "$LOG_COVERAGE" ]; then mv coverage/phpunit.cov coverage/phpunit-sqlite.cov; fi + - name: "Run tests: SQLite 3.25.3" + run: | + apk add sqlite-dev=3.25.3-r0 --repository=https://dl-cdn.alpinelinux.org/alpine/v3.6/main + php -d opcache.enable_cli=1 vendor/bin/phpunit --exclude-group none $(if [ -n "$LOG_COVERAGE" ]; then echo --coverage-text; else echo --no-coverage; fi) --fail-on-warning --fail-on-risky $(if vendor/bin/phpunit --version | grep -q '^PHPUnit 9\.'; then echo -v; else echo --fail-on-notice --fail-on-deprecation --display-notices --display-deprecations --display-warnings --display-errors --display-incomplete --display-skipped; fi) + if [ -n "$LOG_COVERAGE" ]; then mv coverage/phpunit.cov coverage/phpunit-sqlite325.cov; fi + - name: "Run tests: MySQL - PDO" if: success() || failure() env: diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 55b2ec1a3..29d515d1b 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -42,7 +42,7 @@ parameters: - message: '~^Class Doctrine\\DBAL\\Platforms\\SqlitePlatform referenced with incorrect case: Doctrine\\DBAL\\Platforms\\SQLitePlatform\.$~' path: '*' - count: 25 + count: 24 # TODO these rules are generated, this ignores should be fixed in the code # for src/Schema/TestCase.php diff --git a/src/Persistence/Sql/Sqlite/Connection.php b/src/Persistence/Sql/Sqlite/Connection.php index 6801414a2..4cb4744cf 100644 --- a/src/Persistence/Sql/Sqlite/Connection.php +++ b/src/Persistence/Sql/Sqlite/Connection.php @@ -21,6 +21,7 @@ protected static function createDbalConfiguration(): Configuration $configuration->setMiddlewares([ ...$configuration->getMiddlewares(), new EnableForeignKeys(), + new PreserveAutoincrementOnRollbackMiddleware(), ]); return $configuration; diff --git a/src/Persistence/Sql/Sqlite/PreserveAutoincrementOnRollbackConnectionMiddleware.php b/src/Persistence/Sql/Sqlite/PreserveAutoincrementOnRollbackConnectionMiddleware.php new file mode 100644 index 000000000..078d32e29 --- /dev/null +++ b/src/Persistence/Sql/Sqlite/PreserveAutoincrementOnRollbackConnectionMiddleware.php @@ -0,0 +1,154 @@ +> + */ + protected function listSequences(): array + { + if ((self::$libraryVersion ?? null) === null) { + $getLibraryVersionSql = (new Query()) + ->field('sqlite_version()') + ->render()[0]; + self::$libraryVersion = $this->query($getLibraryVersionSql)->fetchOne(); + } + + if (version_compare(self::$libraryVersion, '3.37') < 0) { + $listAllSchemasSql = (new Query()) + ->table('pragma_database_list') + ->field('name') + ->render()[0]; + $allSchemas = $this->query($listAllSchemasSql)->fetchFirstColumn(); + + $schemas = []; + foreach ($allSchemas as $schema) { + $dummySelectFromSqliteSequenceTableSql = (new Query()) + ->table($schema . '.sqlite_sequence') + ->field('name') + ->render()[0]; + try { + $this->query($dummySelectFromSqliteSequenceTableSql)->fetchFirstColumn(); + $schemas[] = $schema; + } catch (\Exception $e) { + while ($e->getPrevious() !== null) { + $e = $e->getPrevious(); + } + + if (!str_contains($e->getMessage(), 'HY000') + || !str_contains($e->getMessage(), 'no such table: ' . $schema . '.sqlite_sequence') + ) { + throw $e; + } + } + } + } else { + $listSchemasSql = (new Query()) + ->table('pragma_table_list') + ->field('schema') + ->where('name', $this->createExpressionFromStringLiteral('sqlite_sequence')) + ->render()[0]; + $schemas = $this->query($listSchemasSql)->fetchFirstColumn(); + } + + $res = []; + if ($schemas !== []) { + $listSequencesSql = implode("\nUNION ALL\n", array_map(function (string $schema) { + return (new Query()) + ->table($schema . '.sqlite_sequence') + ->field($this->createExpressionFromStringLiteral($schema), 'schema') + ->field('name') + ->field('seq', 'value') + ->render()[0]; + }, $schemas)); + + $res = []; + foreach ($this->query($listSequencesSql)->fetchAllAssociative() as $row) { + $value = (int) $row['value']; + if (!is_int($row['value']) && (string) $value !== $row['value']) { + throw (new Exception('Unexpected SQLite sequence value')) + ->addMoreInfo('value', $row['value']); + } + + $res[$row['schema']][$row['name']] = $value; + } + } + + return $res; + } + + /** + * @param array> $beforeRollbackSequences + */ + protected function restoreSequencesIfDecremented(array $beforeRollbackSequences): void + { + $afterRollbackSequences = $this->listSequences(); + + foreach ($beforeRollbackSequences as $schema => $beforeRollbackSequences2) { + foreach ($beforeRollbackSequences2 as $table => $beforeRollbackValue) { + $afterRollbackValue = $afterRollbackSequences[$schema][$table] ?? null; + if ($afterRollbackValue >= $beforeRollbackValue) { + continue; + } + + if ($afterRollbackValue === null) { // https://sqlite.org/forum/info/3e7cc380f0a159c6 + $query = (new Query()) + ->mode('insert') + ->set('name', $this->createExpressionFromStringLiteral($table)); + } else { + $query = (new Query()) + ->mode('update') + ->where('name', $this->createExpressionFromStringLiteral($table)); + } + $query->table($schema . '.sqlite_sequence'); + $query->set('seq', $this->createExpressionFromStringLiteral((string) $beforeRollbackValue)); + + $this->exec($query->render()[0]); + } + } + } + + #[\Override] + public function exec(string $sql): int + { + $isRollback = str_starts_with(strtoupper(ltrim($sql)), 'ROLLBACK '); + + if ($isRollback) { + $beforeRollbackSequences = $this->listSequences(); + } + + $res = parent::exec($sql); + + if ($isRollback) { + $this->restoreSequencesIfDecremented($beforeRollbackSequences); + } + + return $res; + } + + #[\Override] + public function rollBack() + { + $beforeRollbackSequences = $this->listSequences(); + + $res = parent::rollBack(); + + $this->restoreSequencesIfDecremented($beforeRollbackSequences); + + return $res; + } +} diff --git a/src/Persistence/Sql/Sqlite/PreserveAutoincrementOnRollbackMiddleware.php b/src/Persistence/Sql/Sqlite/PreserveAutoincrementOnRollbackMiddleware.php new file mode 100644 index 000000000..77021074a --- /dev/null +++ b/src/Persistence/Sql/Sqlite/PreserveAutoincrementOnRollbackMiddleware.php @@ -0,0 +1,27 @@ +insert(['f1' => 'N'])); }); - // TODO workaround SQLite to be consistent with other databases - // https://stackoverflow.com/questions/27947712/sqlite-repeats-primary-key-autoincrement-value-after-rollback - // https://github.com/atk4/data/issues/1162 - if ($this->getDatabasePlatform() instanceof SQLitePlatform) { - return; - } - $invokeInAtomicAndThrowFx(static function () use ($invokeInAtomicAndThrowFx, $m) { self::assertSame(104, $m->insert(['f1' => 'O1'])); $invokeInAtomicAndThrowFx(static function () use ($invokeInAtomicAndThrowFx, $m) {