From 4c74e3439ebbb62514d848d26830e2a9a6892160 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 6 Mar 2026 23:33:18 +0000 Subject: [PATCH] fix(db): skip orphaned sequences in resynchronizeDatabaseSequences When converting a database to PostgreSQL via `occ db:convert-type`, the sequence resynchronization crashes on orphaned sequences that no longer have an associated column (e.g. after autoincrement was removed via dropAutoincrementColumn in a migration). The query against information_schema.columns returns no row for these sequences, and the code tried to access fields on a `false` result, producing malformed SQL like: SELECT setval('...', (SELECT MAX() FROM )) Skip sequences with no matching column. Also handle empty tables where MAX returns NULL, and use schema-qualified table names. Fixes #58715 --- lib/private/DB/PgSqlTools.php | 15 ++- tests/lib/DB/PgSqlToolsTest.php | 175 ++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 tests/lib/DB/PgSqlToolsTest.php diff --git a/lib/private/DB/PgSqlTools.php b/lib/private/DB/PgSqlTools.php index cc8812e202bec..cc50971fec17e 100644 --- a/lib/private/DB/PgSqlTools.php +++ b/lib/private/DB/PgSqlTools.php @@ -47,13 +47,22 @@ public function resynchronizeDatabaseSequences(Connection $conn): void { ]); $sequenceInfo = $result->fetchAssociative(); $result->free(); + if ($sequenceInfo === false) { + continue; + } + /** @var string $tableSchema */ + $tableSchema = $sequenceInfo['table_schema']; /** @var string $tableName */ $tableName = $sequenceInfo['table_name']; /** @var string $columnName */ $columnName = $sequenceInfo['column_name']; - $sqlMaxId = "SELECT MAX($columnName) FROM $tableName"; - $sqlSetval = "SELECT setval('$sequenceName', ($sqlMaxId))"; - $conn->executeQuery($sqlSetval); + $qualifiedTable = "$tableSchema.$tableName"; + $maxResult = $conn->executeQuery("SELECT MAX($columnName) FROM $qualifiedTable"); + $maxId = $maxResult->fetchOne(); + $maxResult->free(); + if ($maxId !== null && $maxId !== false) { + $conn->executeQuery("SELECT setval('$sequenceName', $maxId)"); + } } } } diff --git a/tests/lib/DB/PgSqlToolsTest.php b/tests/lib/DB/PgSqlToolsTest.php new file mode 100644 index 0000000000000..6dcaf799934c0 --- /dev/null +++ b/tests/lib/DB/PgSqlToolsTest.php @@ -0,0 +1,175 @@ +config = $this->createMock(IConfig::class); + $this->config->method('getSystemValueString') + ->with('dbtableprefix', 'oc_') + ->willReturn('oc_'); + $this->conn = $this->createMock(Connection::class); + $this->pgSqlTools = new PgSqlTools($this->config); + } + + public function testOrphanedSequenceIsSkipped(): void { + $schemaManager = $this->createMock(PostgreSQLSchemaManager::class); + $schemaManager->method('listSequences')->willReturn([ + new Sequence('oc_preview_locations_id_seq'), + ]); + + $configuration = $this->createMock(\Doctrine\DBAL\Configuration::class); + $this->conn->method('getConfiguration')->willReturn($configuration); + $this->conn->method('createSchemaManager')->willReturn($schemaManager); + $this->conn->method('getDatabase')->willReturn('nextcloud'); + + $infoResult = $this->createMock(Result::class); + $infoResult->method('fetchAssociative')->willReturn(false); + + $this->conn->expects($this->once()) + ->method('executeQuery') + ->willReturn($infoResult); + + $this->pgSqlTools->resynchronizeDatabaseSequences($this->conn); + } + + public function testSequenceWithValidColumnIsSynced(): void { + $schemaManager = $this->createMock(PostgreSQLSchemaManager::class); + $schemaManager->method('listSequences')->willReturn([ + new Sequence('oc_users_id_seq'), + ]); + + $configuration = $this->createMock(\Doctrine\DBAL\Configuration::class); + $this->conn->method('getConfiguration')->willReturn($configuration); + $this->conn->method('createSchemaManager')->willReturn($schemaManager); + $this->conn->method('getDatabase')->willReturn('nextcloud'); + + $infoResult = $this->createMock(Result::class); + $infoResult->method('fetchAssociative')->willReturn([ + 'table_schema' => 'public', + 'table_name' => 'oc_users', + 'column_name' => 'id', + ]); + + $maxResult = $this->createMock(Result::class); + $maxResult->method('fetchOne')->willReturn(42); + + $matcher = $this->exactly(3); + $this->conn->expects($matcher) + ->method('executeQuery') + ->willReturnCallback(function (string $sql) use ($matcher, $infoResult, $maxResult) { + match ($matcher->numberOfInvocations()) { + 1 => $this->assertStringContainsString('information_schema', $sql), + 2 => $this->assertStringContainsString('MAX(id)', $sql), + 3 => $this->assertStringContainsString("setval('oc_users_id_seq', 42)", $sql), + }; + return match ($matcher->numberOfInvocations()) { + 1 => $infoResult, + 2 => $maxResult, + 3 => $this->createMock(Result::class), + }; + }); + + $this->pgSqlTools->resynchronizeDatabaseSequences($this->conn); + } + + public function testEmptyTableDoesNotResetSequence(): void { + $schemaManager = $this->createMock(PostgreSQLSchemaManager::class); + $schemaManager->method('listSequences')->willReturn([ + new Sequence('oc_empty_table_id_seq'), + ]); + + $configuration = $this->createMock(\Doctrine\DBAL\Configuration::class); + $this->conn->method('getConfiguration')->willReturn($configuration); + $this->conn->method('createSchemaManager')->willReturn($schemaManager); + $this->conn->method('getDatabase')->willReturn('nextcloud'); + + $infoResult = $this->createMock(Result::class); + $infoResult->method('fetchAssociative')->willReturn([ + 'table_schema' => 'public', + 'table_name' => 'oc_empty_table', + 'column_name' => 'id', + ]); + + $maxResult = $this->createMock(Result::class); + $maxResult->method('fetchOne')->willReturn(null); + + $matcher = $this->exactly(2); + $this->conn->expects($matcher) + ->method('executeQuery') + ->willReturnCallback(function () use ($matcher, $infoResult, $maxResult) { + return match ($matcher->numberOfInvocations()) { + 1 => $infoResult, + 2 => $maxResult, + }; + }); + + $this->pgSqlTools->resynchronizeDatabaseSequences($this->conn); + } + + public function testMultipleSequencesMixedState(): void { + $schemaManager = $this->createMock(PostgreSQLSchemaManager::class); + $schemaManager->method('listSequences')->willReturn([ + new Sequence('oc_orphaned_id_seq'), + new Sequence('oc_valid_id_seq'), + new Sequence('oc_another_orphan_id_seq'), + ]); + + $configuration = $this->createMock(\Doctrine\DBAL\Configuration::class); + $this->conn->method('getConfiguration')->willReturn($configuration); + $this->conn->method('createSchemaManager')->willReturn($schemaManager); + $this->conn->method('getDatabase')->willReturn('nextcloud'); + + $orphanResult = $this->createMock(Result::class); + $orphanResult->method('fetchAssociative')->willReturn(false); + + $validInfoResult = $this->createMock(Result::class); + $validInfoResult->method('fetchAssociative')->willReturn([ + 'table_schema' => 'public', + 'table_name' => 'oc_valid', + 'column_name' => 'id', + ]); + + $maxResult = $this->createMock(Result::class); + $maxResult->method('fetchOne')->willReturn(10); + + $callCount = 0; + $this->conn->method('executeQuery') + ->willReturnCallback(function (string $sql) use (&$callCount, $orphanResult, $validInfoResult, $maxResult) { + $callCount++; + if (str_contains($sql, 'information_schema')) { + if ($callCount === 1 || $callCount === 5) { + return $orphanResult; + } + return $validInfoResult; + } + if (str_contains($sql, 'MAX')) { + return $maxResult; + } + return $this->createMock(Result::class); + }); + + $this->pgSqlTools->resynchronizeDatabaseSequences($this->conn); + } +}