Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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!
]]></description>
<version>10.0.0-dev.2</version>
<version>10.0.0-dev.3</version>
<licence>agpl</licence>
<author mail="jus@bitgrid.net">Julius HΓ€rtl</author>
<namespace>Text</namespace>
Expand Down
1 change: 1 addition & 0 deletions composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions lib/Listeners/AddMissingIndicesListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
}
}
7 changes: 4 additions & 3 deletions lib/Migration/Version080000Date20260331132113.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
84 changes: 84 additions & 0 deletions lib/Migration/Version080000Date20261020120000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Text\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\IDBConnection;
use OCP\Migration\Attributes\AddIndex;
use OCP\Migration\Attributes\IndexType;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use Override;

/**
* Replaces a descending text_steps_doc_id_id_index on Oracle with an ascending one.
*
* Oracle stores a DESC key as a hidden virtual column (SYS_NC...$). Its index then
* references a column the table does not list, and the schema comparison of every
* later migration fails with "There is no column with name ...".
*/
#[AddIndex(table: 'text_steps', type: IndexType::INDEX, description: 'Replace descending index on Oracle')]
class Version080000Date20261020120000 extends SimpleMigrationStep {
public function __construct(
private readonly IDBConnection $connection,
) {
}

#[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) {
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;
}
}
89 changes: 89 additions & 0 deletions tests/unit/Migration/TextStepsIndexOracleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Text\Migration;

use OC\DB\Connection;
use OC\DB\MigrationService;
use OCP\IDBConnection;
use OCP\Server;

/**
* Fresh installs only run changeSchema, so the upgrade path is driven explicitly.
*
* @group DB
*/
class TextStepsIndexOracleTest extends \Test\TestCase {
private Connection $connection;

public function setUp(): void {
parent::setUp();
$this->connection = Server::get(Connection::class);
if ($this->connection->getDatabaseProvider() !== IDBConnection::PLATFORM_ORACLE) {
$this->markTestSkipped('Oracle only');
}
}

public function testFreshInstallHasAscendingIndex(): void {
self::assertSame(['document_id', 'id'], $this->getIndexColumns());
}

public function testUpgradeCreatesAscendingIndex(): void {
$this->connection->executeStatement('DROP INDEX text_steps_doc_id_id_index');

$this->executeStep('080000Date20260331132113');

self::assertSame(['document_id', 'id'], $this->getIndexColumns());
$this->assertSchemaComparisonWorks();
}

public function testRepairReplacesDescendingIndex(): void {
$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 {
$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('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('text_steps_doc_id_id_index')) {
$this->connection->executeStatement('CREATE INDEX text_steps_doc_id_id_index ON `*PREFIX*text_steps` (`document_id`, `id`)');
}
}
}

private function executeStep(string $version): void {
(new MigrationService('text', $this->connection))->executeStep($version);
}

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('text_steps_doc_id_id_index')->getColumns(),
);
}

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));
}
}
Loading