From d99f0c269ec4375c0c22b047100443ed3d9221a5 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Tue, 20 Oct 2026 21:50:03 +0200 Subject: [PATCH 1/8] fix(migration): create text_steps index ascending on oracle Oracle stores a DESC key as a hidden virtual column (SYS_NC...$). The index then references a column the table does not list, so the schema comparison of every later migration fails with "There is no column with name ...". Oracle scans an ascending index backwards for ORDER BY id DESC, so use the schema API as on MySQL. Fresh installs only run changeSchema, so the test drives the migration through the upgrade path on Oracle. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: James Manuel --- .../Version080000Date20260331132113.php | 7 ++- .../Migration/TextStepsIndexOracleTest.php | 55 +++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 tests/unit/Migration/TextStepsIndexOracleTest.php diff --git a/lib/Migration/Version080000Date20260331132113.php b/lib/Migration/Version080000Date20260331132113.php index 86e70b593e9..2ac76c6dad4 100644 --- a/lib/Migration/Version080000Date20260331132113.php +++ b/lib/Migration/Version080000Date20260331132113.php @@ -34,8 +34,11 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $table = $schema->getTable('text_steps'); if (!$table->hasIndex('text_steps_doc_id_id_index')) { + // Oracle stores a DESC key as a hidden virtual column, which breaks the schema + // comparison of every later migration, and it scans an ascending index backwards anyway if ($this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_MARIADB - || $this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_MYSQL) { + || $this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_MYSQL + || $this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_ORACLE) { $table->addIndex(['document_id', 'id'], 'text_steps_doc_id_id_index'); } else { $this->addIndexLater = true; @@ -55,8 +58,6 @@ public function postSchemaChange(IOutput $output, \Closure $schemaClosure, array // See https://github.com/doctrine/orm/issues/8128 if ($this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_POSTGRES) { $stm = $this->connection->prepare('CREATE INDEX CONCURRENTLY text_steps_doc_id_id_index ON *PREFIX*text_steps (document_id, id DESC);'); - } elseif ($this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_ORACLE) { - $stm = $this->connection->prepare('CREATE INDEX text_steps_doc_id_id_index ON `*PREFIX*text_steps` (`document_id`, `id` DESC) ONLINE'); } elseif ($this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_SQLITE) { $stm = $this->connection->prepare('CREATE INDEX text_steps_doc_id_id_index ON *PREFIX*text_steps (document_id, id DESC);'); } else { diff --git a/tests/unit/Migration/TextStepsIndexOracleTest.php b/tests/unit/Migration/TextStepsIndexOracleTest.php new file mode 100644 index 00000000000..7cbfca70bd1 --- /dev/null +++ b/tests/unit/Migration/TextStepsIndexOracleTest.php @@ -0,0 +1,55 @@ +connection = Server::get(Connection::class); + if ($this->connection->getDatabaseProvider() !== IDBConnection::PLATFORM_ORACLE) { + $this->markTestSkipped('Oracle only'); + } + } + + public function testUpgradeCreatesAscendingIndex(): void { + if ($this->getTextStepsTable()->hasIndex(self::INDEX_NAME)) { + $this->connection->executeStatement('DROP INDEX ' . self::INDEX_NAME); + } + + (new MigrationService('text', $this->connection))->executeStep('080000Date20260331132113'); + + $columns = array_map( + static fn (string $column): string => trim($column, '"'), + $this->getTextStepsTable()->getIndex(self::INDEX_NAME)->getColumns(), + ); + self::assertSame(['document_id', 'id'], $columns); + + // A DESC key would be a hidden column here and break this comparison for every later migration + self::assertIsString($this->connection->migrateToSchema($this->connection->createSchema(), true)); + } + + private function getTextStepsTable(): \Doctrine\DBAL\Schema\Table { + return $this->connection->createSchema()->getTable($this->connection->getPrefix() . 'text_steps'); + } +} From 1ae7bacecdca910d3b10219ed4067ced06126b2b Mon Sep 17 00:00:00 2001 From: James Manuel Date: Tue, 20 Oct 2026 23:17:41 +0200 Subject: [PATCH 2/8] test(migration): check fresh oracle installs get the text_steps index Assert the index exists after install instead of skipping the drop when it is missing, and keep the class constant untyped so the test backports to PHP 8.1 and 8.2. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: James Manuel --- .../Migration/TextStepsIndexOracleTest.php | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/tests/unit/Migration/TextStepsIndexOracleTest.php b/tests/unit/Migration/TextStepsIndexOracleTest.php index 7cbfca70bd1..11433b73da0 100644 --- a/tests/unit/Migration/TextStepsIndexOracleTest.php +++ b/tests/unit/Migration/TextStepsIndexOracleTest.php @@ -15,12 +15,12 @@ use OCP\Server; /** - * Fresh installs only run changeSchema, so this drives the upgrade path explicitly. + * Fresh installs only run changeSchema, so the upgrade path is driven explicitly. * * @group DB */ class TextStepsIndexOracleTest extends \Test\TestCase { - private const string INDEX_NAME = 'text_steps_doc_id_id_index'; + private const INDEX_NAME = 'text_steps_doc_id_id_index'; private Connection $connection; @@ -32,24 +32,33 @@ public function setUp(): void { } } + public function testFreshInstallHasAscendingIndex(): void { + self::assertSame(['document_id', 'id'], $this->getIndexColumns()); + } + public function testUpgradeCreatesAscendingIndex(): void { - if ($this->getTextStepsTable()->hasIndex(self::INDEX_NAME)) { - $this->connection->executeStatement('DROP INDEX ' . self::INDEX_NAME); - } + $this->connection->executeStatement('DROP INDEX ' . self::INDEX_NAME); + + $this->executeStep('080000Date20260331132113'); + + self::assertSame(['document_id', 'id'], $this->getIndexColumns()); + $this->assertSchemaComparisonWorks(); + } - (new MigrationService('text', $this->connection))->executeStep('080000Date20260331132113'); + private function executeStep(string $version): void { + (new MigrationService('text', $this->connection))->executeStep($version); + } - $columns = array_map( + private function getIndexColumns(): array { + $table = $this->connection->createSchema()->getTable($this->connection->getPrefix() . 'text_steps'); + return array_map( static fn (string $column): string => trim($column, '"'), - $this->getTextStepsTable()->getIndex(self::INDEX_NAME)->getColumns(), + $table->getIndex(self::INDEX_NAME)->getColumns(), ); - self::assertSame(['document_id', 'id'], $columns); + } + private function assertSchemaComparisonWorks(): void { // A DESC key would be a hidden column here and break this comparison for every later migration self::assertIsString($this->connection->migrateToSchema($this->connection->createSchema(), true)); } - - private function getTextStepsTable(): \Doctrine\DBAL\Schema\Table { - return $this->connection->createSchema()->getTable($this->connection->getPrefix() . 'text_steps'); - } } From 0ede0bdb94d6e286150439a0dcf25e40c0506578 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Tue, 20 Oct 2026 23:18:10 +0200 Subject: [PATCH 3/8] fix(migration): replace descending text_steps index on oracle An Oracle instance with text_steps_doc_id_id_index created as "id" DESC cannot run any later migration that changes the schema. Drop that index and recreate it ascending. This only helps when no schema-changing step runs before it. Core migrations run before app migrations, so on a major upgrade an instance with the descending index still needs it replaced by hand. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: James Manuel --- .../Version080000Date20261020120000.php | 84 +++++++++++++++++++ .../Migration/TextStepsIndexOracleTest.php | 27 ++++++ 2 files changed, 111 insertions(+) create mode 100644 lib/Migration/Version080000Date20261020120000.php diff --git a/lib/Migration/Version080000Date20261020120000.php b/lib/Migration/Version080000Date20261020120000.php new file mode 100644 index 00000000000..b82d946ea34 --- /dev/null +++ b/lib/Migration/Version080000Date20261020120000.php @@ -0,0 +1,84 @@ +connection->getDatabaseProvider() !== IDBConnection::PLATFORM_ORACLE) { + return; + } + + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + if ($this->hasHiddenIndexColumn($schema)) { + // The schema API cannot drop it, because the comparison is what fails + $this->connection->executeStatement('DROP INDEX text_steps_doc_id_id_index'); + } + } + + #[Override] + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + if ($this->connection->getDatabaseProvider() !== IDBConnection::PLATFORM_ORACLE) { + return null; + } + + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + if ($this->hasHiddenIndexColumn($schema)) { + throw new \RuntimeException('Index text_steps_doc_id_id_index on text_steps still references a hidden column, drop it and run the upgrade again'); + } + + $table = $schema->getTable('text_steps'); + if (!$table->hasIndex('text_steps_doc_id_id_index')) { + $table->addIndex(['document_id', 'id'], 'text_steps_doc_id_id_index'); + return $schema; + } + + return null; + } + + private function hasHiddenIndexColumn(ISchemaWrapper $schema): bool { + $table = $schema->getTable('text_steps'); + if (!$table->hasIndex('text_steps_doc_id_id_index')) { + return false; + } + + foreach ($table->getIndex('text_steps_doc_id_id_index')->getColumns() as $column) { + if ($column === '' || !$table->hasColumn($column)) { + return true; + } + } + + return false; + } +} diff --git a/tests/unit/Migration/TextStepsIndexOracleTest.php b/tests/unit/Migration/TextStepsIndexOracleTest.php index 11433b73da0..9f3ae3e8d85 100644 --- a/tests/unit/Migration/TextStepsIndexOracleTest.php +++ b/tests/unit/Migration/TextStepsIndexOracleTest.php @@ -45,6 +45,33 @@ public function testUpgradeCreatesAscendingIndex(): void { $this->assertSchemaComparisonWorks(); } + public function testRepairReplacesDescendingIndex(): void { + $this->connection->executeStatement('DROP INDEX ' . self::INDEX_NAME); + $this->connection->executeStatement('CREATE INDEX ' . self::INDEX_NAME . ' ON `*PREFIX*text_steps` (`document_id`, `id` DESC)'); + + try { + try { + $this->connection->migrateToSchema($this->connection->createSchema(), true); + self::fail('A descending index on Oracle is expected to break the schema comparison'); + } catch (\Doctrine\DBAL\Schema\SchemaException) { + } + + $this->executeStep('080000Date20261020120000'); + + self::assertSame(['document_id', 'id'], $this->getIndexColumns()); + $this->assertSchemaComparisonWorks(); + } finally { + // Leave an ascending index behind for the rest of the suite, whatever happened above + $table = $this->connection->createSchema()->getTable($this->connection->getPrefix() . 'text_steps'); + if ($table->hasIndex(self::INDEX_NAME) && $this->getIndexColumns() !== ['document_id', 'id']) { + $this->connection->executeStatement('DROP INDEX ' . self::INDEX_NAME); + } + if (!$this->connection->createSchema()->getTable($this->connection->getPrefix() . 'text_steps')->hasIndex(self::INDEX_NAME)) { + $this->connection->executeStatement('CREATE INDEX ' . self::INDEX_NAME . ' ON `*PREFIX*text_steps` (`document_id`, `id`)'); + } + } + } + private function executeStep(string $version): void { (new MigrationService('text', $this->connection))->executeStep($version); } From 1636a0a9a49aa80b9a19247ba1bd2ff733d2311c Mon Sep 17 00:00:00 2001 From: James Manuel Date: Tue, 20 Oct 2026 23:18:10 +0200 Subject: [PATCH 4/8] fix(db): register text_steps_doc_id_id_index as a missing index Fresh installs on PostgreSQL and SQLite, and Oracle installs that ran the migration before it created the index, have no text_steps_doc_id_id_index. Let occ db:add-missing-indices add it, and let the schema check treat it as optional. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: James Manuel --- lib/Listeners/AddMissingIndicesListener.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/Listeners/AddMissingIndicesListener.php b/lib/Listeners/AddMissingIndicesListener.php index 4880de83941..ea56b68eb8f 100644 --- a/lib/Listeners/AddMissingIndicesListener.php +++ b/lib/Listeners/AddMissingIndicesListener.php @@ -22,5 +22,7 @@ public function handle(Event $event): void { } $event->addMissingIndex('text_steps', 'textstep_session', ['session_id']); + // Fresh PostgreSQL and SQLite installs skip the migration's postSchemaChange, which creates it there + $event->addMissingIndex('text_steps', 'text_steps_doc_id_id_index', ['document_id', 'id']); } } From e4feef37c4230325872e9f976fcfcb8ccd9f2442 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Tue, 20 Oct 2026 23:18:10 +0200 Subject: [PATCH 5/8] chore(version): bump to run migration Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: James Manuel --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 13ec4dea494..665ee4d3734 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -15,7 +15,7 @@ - **๐Ÿ’พ Open format:** Files are saved as [Markdown](https://en.wikipedia.org/wiki/Markdown), so you can edit them from any other text app too. - **โœŠ Strong foundation:** We use [๐Ÿˆ tiptap](https://tiptap.scrumpy.io) which is based on [๐Ÿฆ‰ ProseMirror](https://prosemirror.net) โ€“ huge thanks to them! ]]> - 10.0.0-dev.2 + 10.0.0-dev.3 agpl Julius Hรคrtl Text From 8f76d7f346f08c88c8b8c7c8b23379b6067539a5 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Tue, 20 Oct 2026 23:26:43 +0200 Subject: [PATCH 6/8] chore(autoload): add text_steps repair migration to the classmap Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: James Manuel --- composer/composer/autoload_classmap.php | 1 + composer/composer/autoload_static.php | 1 + 2 files changed, 2 insertions(+) diff --git a/composer/composer/autoload_classmap.php b/composer/composer/autoload_classmap.php index 38c8396e024..91dc2b839a3 100644 --- a/composer/composer/autoload_classmap.php +++ b/composer/composer/autoload_classmap.php @@ -80,6 +80,7 @@ 'OCA\\Text\\Migration\\Version040100Date20240611165300' => $baseDir . '/../lib/Migration/Version040100Date20240611165300.php', 'OCA\\Text\\Migration\\Version070000Date20250925110024' => $baseDir . '/../lib/Migration/Version070000Date20250925110024.php', 'OCA\\Text\\Migration\\Version080000Date20260331132113' => $baseDir . '/../lib/Migration/Version080000Date20260331132113.php', + 'OCA\\Text\\Migration\\Version080000Date20261020120000' => $baseDir . '/../lib/Migration/Version080000Date20261020120000.php', 'OCA\\Text\\Migration\\Version090000Date20260817110024' => $baseDir . '/../lib/Migration/Version090000Date20260817110024.php', 'OCA\\Text\\Migration\\Version090000Date20260819110024' => $baseDir . '/../lib/Migration/Version090000Date20260819110024.php', 'OCA\\Text\\Migration\\Version090000Date20260820132113' => $baseDir . '/../lib/Migration/Version090000Date20260820132113.php', diff --git a/composer/composer/autoload_static.php b/composer/composer/autoload_static.php index 64514d97563..0a506511f39 100644 --- a/composer/composer/autoload_static.php +++ b/composer/composer/autoload_static.php @@ -95,6 +95,7 @@ class ComposerStaticInitText 'OCA\\Text\\Migration\\Version040100Date20240611165300' => __DIR__ . '/..' . '/../lib/Migration/Version040100Date20240611165300.php', 'OCA\\Text\\Migration\\Version070000Date20250925110024' => __DIR__ . '/..' . '/../lib/Migration/Version070000Date20250925110024.php', 'OCA\\Text\\Migration\\Version080000Date20260331132113' => __DIR__ . '/..' . '/../lib/Migration/Version080000Date20260331132113.php', + 'OCA\\Text\\Migration\\Version080000Date20261020120000' => __DIR__ . '/..' . '/../lib/Migration/Version080000Date20261020120000.php', 'OCA\\Text\\Migration\\Version090000Date20260817110024' => __DIR__ . '/..' . '/../lib/Migration/Version090000Date20260817110024.php', 'OCA\\Text\\Migration\\Version090000Date20260819110024' => __DIR__ . '/..' . '/../lib/Migration/Version090000Date20260819110024.php', 'OCA\\Text\\Migration\\Version090000Date20260820132113' => __DIR__ . '/..' . '/../lib/Migration/Version090000Date20260820132113.php', From 047351b6de4b8d3e89fde8920a7cb2b8f8140930 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Tue, 20 Oct 2026 23:43:23 +0200 Subject: [PATCH 7/8] test(migration): inline the text_steps index name Rector requires typed class constants on main, which stable32 to 34 cannot parse, so use the literal as the migrations do. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: James Manuel --- .../Migration/TextStepsIndexOracleTest.php | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/tests/unit/Migration/TextStepsIndexOracleTest.php b/tests/unit/Migration/TextStepsIndexOracleTest.php index 9f3ae3e8d85..62425f04199 100644 --- a/tests/unit/Migration/TextStepsIndexOracleTest.php +++ b/tests/unit/Migration/TextStepsIndexOracleTest.php @@ -20,8 +20,6 @@ * @group DB */ class TextStepsIndexOracleTest extends \Test\TestCase { - private const INDEX_NAME = 'text_steps_doc_id_id_index'; - private Connection $connection; public function setUp(): void { @@ -37,7 +35,7 @@ public function testFreshInstallHasAscendingIndex(): void { } public function testUpgradeCreatesAscendingIndex(): void { - $this->connection->executeStatement('DROP INDEX ' . self::INDEX_NAME); + $this->connection->executeStatement('DROP INDEX text_steps_doc_id_id_index'); $this->executeStep('080000Date20260331132113'); @@ -46,8 +44,8 @@ public function testUpgradeCreatesAscendingIndex(): void { } public function testRepairReplacesDescendingIndex(): void { - $this->connection->executeStatement('DROP INDEX ' . self::INDEX_NAME); - $this->connection->executeStatement('CREATE INDEX ' . self::INDEX_NAME . ' ON `*PREFIX*text_steps` (`document_id`, `id` DESC)'); + $this->connection->executeStatement('DROP INDEX text_steps_doc_id_id_index'); + $this->connection->executeStatement('CREATE INDEX text_steps_doc_id_id_index ON `*PREFIX*text_steps` (`document_id`, `id` DESC)'); try { try { @@ -63,11 +61,11 @@ public function testRepairReplacesDescendingIndex(): void { } finally { // Leave an ascending index behind for the rest of the suite, whatever happened above $table = $this->connection->createSchema()->getTable($this->connection->getPrefix() . 'text_steps'); - if ($table->hasIndex(self::INDEX_NAME) && $this->getIndexColumns() !== ['document_id', 'id']) { - $this->connection->executeStatement('DROP INDEX ' . self::INDEX_NAME); + if ($table->hasIndex('text_steps_doc_id_id_index') && $this->getIndexColumns() !== ['document_id', 'id']) { + $this->connection->executeStatement('DROP INDEX text_steps_doc_id_id_index'); } - if (!$this->connection->createSchema()->getTable($this->connection->getPrefix() . 'text_steps')->hasIndex(self::INDEX_NAME)) { - $this->connection->executeStatement('CREATE INDEX ' . self::INDEX_NAME . ' ON `*PREFIX*text_steps` (`document_id`, `id`)'); + if (!$this->connection->createSchema()->getTable($this->connection->getPrefix() . 'text_steps')->hasIndex('text_steps_doc_id_id_index')) { + $this->connection->executeStatement('CREATE INDEX text_steps_doc_id_id_index ON `*PREFIX*text_steps` (`document_id`, `id`)'); } } } @@ -80,7 +78,7 @@ private function getIndexColumns(): array { $table = $this->connection->createSchema()->getTable($this->connection->getPrefix() . 'text_steps'); return array_map( static fn (string $column): string => trim($column, '"'), - $table->getIndex(self::INDEX_NAME)->getColumns(), + $table->getIndex('text_steps_doc_id_id_index')->getColumns(), ); } From 7edb9fafc3a970c42d11b2a2c999e529f53c9c60 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Wed, 21 Oct 2026 16:41:49 +0200 Subject: [PATCH 8/8] fix(migration): repair the text_steps index through the schema API Drop the descending index from the target schema in changeSchema instead of with raw SQL in preSchemaChange. The comparison only fails while rebuilding target indexes, so it then just emits a DROP INDEX. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: James Manuel --- .../Version080000Date20261020120000.php | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/lib/Migration/Version080000Date20261020120000.php b/lib/Migration/Version080000Date20261020120000.php index b82d946ea34..66b2014daf8 100644 --- a/lib/Migration/Version080000Date20261020120000.php +++ b/lib/Migration/Version080000Date20261020120000.php @@ -32,20 +32,6 @@ public function __construct( ) { } - #[Override] - public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { - if ($this->connection->getDatabaseProvider() !== IDBConnection::PLATFORM_ORACLE) { - return; - } - - /** @var ISchemaWrapper $schema */ - $schema = $schemaClosure(); - if ($this->hasHiddenIndexColumn($schema)) { - // The schema API cannot drop it, because the comparison is what fails - $this->connection->executeStatement('DROP INDEX text_steps_doc_id_id_index'); - } - } - #[Override] public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { if ($this->connection->getDatabaseProvider() !== IDBConnection::PLATFORM_ORACLE) { @@ -54,11 +40,12 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); + $table = $schema->getTable('text_steps'); if ($this->hasHiddenIndexColumn($schema)) { - throw new \RuntimeException('Index text_steps_doc_id_id_index on text_steps still references a hidden column, drop it and run the upgrade again'); + // Dropped from the target schema, so the comparison never rebuilds it and only emits a DROP INDEX + $table->dropIndex('text_steps_doc_id_id_index'); } - $table = $schema->getTable('text_steps'); if (!$table->hasIndex('text_steps_doc_id_id_index')) { $table->addIndex(['document_id', 'id'], 'text_steps_doc_id_id_index'); return $schema;