From 778f4597bd16a733a14ebe422b5ee483d4c5724e 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 f7c77024ec9117edc0bffc8879d630f7782bb960 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 fca310a9fffeacc7f335116dadfc2090bcd74d59 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 9c7dc1e52da04c881dd28e9086f0c8308361a194 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 8993c1b38f2d315973845c27b85d4cb12c81cb61 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 4211b775e04cbcf7e0d9f7aecd16f1367643c2b5 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 65fa90fc2b9697e12bbfc1aef0db32115a29e54b 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 5d6bacd9321df0d292a2d192b8e06eb8cbb06c47 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;