From 2a204a24a435752cf2b2ae512d6384ac0e69b48d 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 fd02fc3ed3b84bc8d7f8818996e84d02a912f243 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 9c97e4a73fb12d3622cef41cb4cf8dfa4f73813d 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 c915a9d75b39763804389bb55d66dedc19389bbc 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 08554bb7c5ecbb828db2c6a6d6747d8b6efc9f65 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Tue, 20 Oct 2026 23:43:23 +0200 Subject: [PATCH 5/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 35a887c5f14202264f8e43b33109d3efee02268b Mon Sep 17 00:00:00 2001 From: James Manuel Date: Wed, 21 Oct 2026 16:41:49 +0200 Subject: [PATCH 6/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; From e590563e1fe1d4d866710381cead1729f1ea1b8c Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 23 Oct 2026 09:42:14 +0200 Subject: [PATCH 7/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 7870d9c40ca..3ee07dc190c 100644 --- a/composer/composer/autoload_classmap.php +++ b/composer/composer/autoload_classmap.php @@ -65,6 +65,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\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php', 'OCA\\Text\\Service\\ApiService' => $baseDir . '/../lib/Service/ApiService.php', 'OCA\\Text\\Service\\AttachmentService' => $baseDir . '/../lib/Service/AttachmentService.php', diff --git a/composer/composer/autoload_static.php b/composer/composer/autoload_static.php index a6f26850d97..bff4934c6ff 100644 --- a/composer/composer/autoload_static.php +++ b/composer/composer/autoload_static.php @@ -80,6 +80,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\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php', 'OCA\\Text\\Service\\ApiService' => __DIR__ . '/..' . '/../lib/Service/ApiService.php', 'OCA\\Text\\Service\\AttachmentService' => __DIR__ . '/..' . '/../lib/Service/AttachmentService.php', From 7566da28191602a9d1423a4c1cb71ae98697ab58 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 23 Oct 2026 09:42:14 +0200 Subject: [PATCH 8/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 73409a8524c..5df2fb9e3bb 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! ]]> - 7.0.1 + 7.0.2 agpl Julius Hรคrtl Text