diff --git a/tests/data/nc-ora22858-workaround.sql b/tests/data/nc-ora22858-workaround.sql new file mode 100644 index 0000000000000..433301ab45cd9 --- /dev/null +++ b/tests/data/nc-ora22858-workaround.sql @@ -0,0 +1,255 @@ +-- Nextcloud ORA-22858 upgrade failure: manual conversion of the jobs +-- "argument" column from VARCHAR2(4000) to CLOB. +-- +-- Context: `occ upgrade` to Nextcloud >= 32.0.7 / 33.0.1 / 34.x fails on +-- Oracle with "ORA-22858: invalid alteration of datatype" in core migration +-- 34000Date20260318095645, because Oracle cannot ALTER a VARCHAR2 column to +-- CLOB in place. This script performs the conversion the way Oracle +-- requires (add CLOB column, copy, verify, drop, rename). Afterwards, +-- re-run `occ upgrade`: the migration detects the already-converted column +-- and completes without further changes. +-- +-- Run as the Nextcloud database user, with Nextcloud in maintenance mode. +-- IMPORTANT: also verify that cron jobs and web/app workers are actually +-- stopped, not merely that maintenance mode is set. +-- sqlplus @ @nc-ora22858-workaround.sql +-- (SQLcl works identically. If you use a different client: replace the two +-- substitution variables - the table name and the backup confirmation - in +-- the DECLARE...END; block below with literal values and execute that block +-- on its own. The prompts, SPOOL and the state listings are the only +-- SQL*Plus-specific parts.) +-- +-- Safety properties: +-- * Aborts before any change unless a restorable backup is explicitly +-- confirmed (empty input aborts). +-- * Inspects the current column state first and handles a previously +-- interrupted run: it is safe to run this script multiple times. +-- * The data copy is verified (DBMS_LOB.COMPARE) BEFORE it is committed; +-- on any mismatch everything is rolled back and the temporary column is +-- removed - the schema is left exactly as it was found. +-- * The copy column is locked down (NOT NULL) before the original column +-- is dropped, so a forgotten writer fails loudly instead of losing data. +-- * A full transcript is written to nc_ora22858_workaround.log - attach +-- it to the support ticket. +-- +-- Runtime scales with table size (the verification compares every row); +-- on a normally-sized jobs table this completes in seconds. + +SET SERVEROUTPUT ON SIZE UNLIMITED +SET VERIFY OFF +SET FEEDBACK ON +WHENEVER SQLERROR EXIT FAILURE ROLLBACK + +SPOOL nc_ora22858_workaround.log + +ACCEPT table_name CHAR DEFAULT 'oc_jobs' PROMPT 'Jobs table name (check dbtableprefix in config.php) [oc_jobs]: ' +ACCEPT backup_confirmed CHAR DEFAULT 'no' PROMPT 'Is a restorable database backup confirmed? (yes/no) [no]: ' + +PROMPT +PROMPT === Column state before === +SELECT column_name, data_type, data_length, nullable + FROM user_tab_columns + WHERE table_name = '&table_name' + ORDER BY column_id; + +DECLARE + l_table CONSTANT VARCHAR2(128) := '&table_name'; + l_table_exists INTEGER; + l_old_type user_tab_columns.data_type%TYPE; + l_old_nullable user_tab_columns.nullable%TYPE; + l_new_type user_tab_columns.data_type%TYPE; + l_rows INTEGER; + l_nulls INTEGER; + l_count INTEGER; + + PROCEDURE fail(p_msg IN VARCHAR2) IS + BEGIN + RAISE_APPLICATION_ERROR(-20001, p_msg); + END; + + PROCEDURE say(p_msg IN VARCHAR2) IS + BEGIN + DBMS_OUTPUT.PUT_LINE(p_msg); + END; + + -- Restore NOT NULL on "argument" if it is currently nullable and holds + -- no NULLs. Used by every path that can encounter a rebuilt column, so + -- a run interrupted before the constraint was restored is repaired by + -- the next run. + PROCEDURE restore_not_null IS + l_nullable user_tab_columns.nullable%TYPE; + l_null_rows INTEGER; + BEGIN + SELECT nullable INTO l_nullable + FROM user_tab_columns + WHERE table_name = l_table AND column_name = 'argument'; + IF l_nullable = 'Y' THEN + EXECUTE IMMEDIATE 'SELECT COUNT(*) FROM "' || l_table + || '" WHERE "argument" IS NULL' INTO l_null_rows; + IF l_null_rows = 0 THEN + EXECUTE IMMEDIATE 'ALTER TABLE "' || l_table + || '" MODIFY ("argument" NOT NULL)'; + say('Restored NOT NULL constraint on "argument".'); + ELSE + say('WARNING: ' || l_null_rows || ' NULL values present, NOT ' + || 'NULL constraint not restored - report this in the ticket.'); + END IF; + END IF; + END; +BEGIN + IF NVL(LOWER(TRIM('&backup_confirmed')), 'no') NOT IN ('yes', 'y') THEN + fail('Aborted: restorable backup not confirmed. Nothing was changed.'); + END IF; + + SELECT COUNT(*) INTO l_table_exists + FROM user_tables WHERE table_name = l_table; + IF l_table_exists = 0 THEN + fail('Table "' || l_table || '" not found in this schema. Check the ' + || 'dbtableprefix in config.php and that you are connected as the ' + || 'Nextcloud database user. Nothing was changed.'); + END IF; + + BEGIN + SELECT data_type, nullable INTO l_old_type, l_old_nullable + FROM user_tab_columns + WHERE table_name = l_table AND column_name = 'argument'; + EXCEPTION WHEN NO_DATA_FOUND THEN + l_old_type := NULL; + END; + + BEGIN + SELECT data_type INTO l_new_type + FROM user_tab_columns + WHERE table_name = l_table AND column_name = 'argument2'; + EXCEPTION WHEN NO_DATA_FOUND THEN + l_new_type := NULL; + END; + + -- State: already converted (also the state after a successful run, or + -- after a run interrupted before the NOT NULL constraint was restored). + IF l_old_type = 'CLOB' AND l_new_type IS NULL THEN + restore_not_null; + say('Column "argument" is already CLOB - nothing else to do.'); + say('Re-run `occ upgrade` if it has not completed yet.'); + RETURN; + END IF; + + -- State: unexpected - do not guess. + IF l_old_type = 'CLOB' AND l_new_type IS NOT NULL THEN + fail('Unexpected state: "argument" is already CLOB but "argument2" ' + || 'also exists. Manual inspection required. Nothing was changed.'); + END IF; + + -- State: interrupted between DROP and RENAME (copy was verified and + -- committed before the drop, so finishing the rename is safe). + IF l_old_type IS NULL AND l_new_type = 'CLOB' THEN + say('Resuming interrupted run: renaming "argument2" to "argument".'); + EXECUTE IMMEDIATE 'ALTER TABLE "' || l_table + || '" RENAME COLUMN "argument2" TO "argument"'; + EXECUTE IMMEDIATE 'ALTER TABLE "' || l_table + || '" MODIFY ("argument" DEFAULT '''')'; + restore_not_null; + say('SUCCESS (resumed run completed). Re-run `occ upgrade` now.'); + RETURN; + END IF; + + IF l_old_type IS NULL THEN + fail('Column "argument" not found on "' || l_table || '" - ' + || 'unexpected schema. Nothing was changed.'); + END IF; + + IF l_old_type <> 'VARCHAR2' THEN + fail('Unexpected type for "argument": ' || l_old_type + || ' (expected VARCHAR2). Nothing was changed.'); + END IF; + + -- State: interrupted after ADD/copy but before DROP - the temporary + -- CLOB column may hold a partial copy; remove it and redo from scratch. + -- An "argument2" of any other type was not created by this script. + IF l_new_type IS NOT NULL AND l_new_type <> 'CLOB' THEN + fail('Unexpected state: column "argument2" exists with type ' + || l_new_type || ' - not created by this script. Manual ' + || 'inspection required. Nothing was changed.'); + ELSIF l_new_type IS NOT NULL THEN + say('Previous interrupted run detected: dropping "argument2" and ' + || 'redoing the copy.'); + EXECUTE IMMEDIATE 'ALTER TABLE "' || l_table + || '" DROP COLUMN "argument2"'; + END IF; + + -- Fresh conversion. + EXECUTE IMMEDIATE 'SELECT COUNT(*) FROM "' || l_table || '"' INTO l_rows; + EXECUTE IMMEDIATE 'SELECT COUNT(*) FROM "' || l_table + || '" WHERE "argument" IS NULL' INTO l_nulls; + say('Rows: ' || l_rows || ', NULL arguments: ' || l_nulls); + IF l_nulls > 0 THEN + fail(l_nulls || ' NULL values in "argument" - stop and report this ' + || 'in the ticket. Nothing was changed.'); + END IF; + + say('Adding temporary CLOB column and copying data...'); + EXECUTE IMMEDIATE 'ALTER TABLE "' || l_table || '" ADD ("argument2" CLOB)'; + EXECUTE IMMEDIATE 'UPDATE "' || l_table || '" SET "argument2" = "argument"'; + say('Copied ' || SQL%ROWCOUNT || ' rows.'); + + -- Verification gate - runs BEFORE the copy is committed. + EXECUTE IMMEDIATE 'SELECT COUNT(*) FROM "' || l_table + || '" WHERE ("argument" IS NOT NULL AND "argument2" IS NULL)' + || ' OR ("argument" IS NULL AND "argument2" IS NOT NULL)' + || ' OR DBMS_LOB.COMPARE("argument2", TO_CLOB("argument")) <> 0' + INTO l_count; + IF l_count <> 0 THEN + ROLLBACK; + EXECUTE IMMEDIATE 'ALTER TABLE "' || l_table + || '" DROP COLUMN "argument2"'; + fail('Verification failed: ' || l_count || ' rows did not copy ' + || 'identically. The copy was rolled back and the temporary ' + || 'column removed - the original column is untouched. Report ' + || 'this in the ticket.'); + END IF; + COMMIT; + say('Copy verified: 0 mismatches.'); + + -- Lock the copy down before dropping the original: a straggling writer + -- (cron/web worker that should have been stopped) now fails loudly on + -- insert instead of silently losing its "argument" value in the drop. + IF l_old_nullable = 'N' THEN + EXECUTE IMMEDIATE 'ALTER TABLE "' || l_table + || '" MODIFY ("argument2" NOT NULL)'; + END IF; + + say('Swapping columns...'); + EXECUTE IMMEDIATE 'ALTER TABLE "' || l_table || '" DROP COLUMN "argument"'; + EXECUTE IMMEDIATE 'ALTER TABLE "' || l_table + || '" RENAME COLUMN "argument2" TO "argument"'; + EXECUTE IMMEDIATE 'ALTER TABLE "' || l_table + || '" MODIFY ("argument" DEFAULT '''')'; + + -- Final checks. + EXECUTE IMMEDIATE 'SELECT COUNT(*) FROM "' || l_table || '"' INTO l_count; + IF l_count <> l_rows THEN + fail('Row count changed during conversion: ' || l_rows || ' -> ' + || l_count || '. Restore from backup and report this.'); + END IF; + SELECT data_type INTO l_old_type + FROM user_tab_columns + WHERE table_name = l_table AND column_name = 'argument'; + IF l_old_type <> 'CLOB' THEN + fail('Post-check failed: "argument" is ' || l_old_type + || ', expected CLOB.'); + END IF; + + say('SUCCESS: "argument" converted to CLOB, ' || l_rows + || ' rows verified intact. Re-run `occ upgrade` now.'); +END; +/ + +PROMPT +PROMPT === Column state after === +SELECT column_name, data_type, data_length, nullable + FROM user_tab_columns + WHERE table_name = '&table_name' + ORDER BY column_id; + +SPOOL OFF +EXIT diff --git a/tests/lib/DB/MigratorTest.php b/tests/lib/DB/MigratorTest.php index 099419884e072..b74d03381f027 100644 --- a/tests/lib/DB/MigratorTest.php +++ b/tests/lib/DB/MigratorTest.php @@ -12,8 +12,10 @@ use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaConfig; +use Doctrine\DBAL\Types\Type; use OC\DB\Migrator; use OC\DB\OracleMigrator; +use OC\DB\SchemaWrapper; use OC\DB\SQLiteMigrator; use OCP\DB\Types; use OCP\EventDispatcher\IEventDispatcher; @@ -244,6 +246,247 @@ public function testColumnCommentsInUpdate(): void { $this->addToAssertionCount(1); } + /** + * Validates the manual workaround for the ORA-22858 upgrade failure: + * converting a string column to text via add-copy-drop-rename in plain + * SQL, after which the type guard of a migration like + * Version34000Date20260318095645 (oc_jobs.argument) sees a text column + * and no-ops. + */ + public function testStringToTextConversionWorkaroundOnOracle(): void { + if ($this->connection->getDatabaseProvider() !== IDBConnection::PLATFORM_ORACLE) { + $this->markTestSkipped('Validates Oracle-specific workaround SQL'); + } + + $startSchema = new Schema([], [], $this->getSchemaConfig()); + $table = $startSchema->createTable($this->tableName); + $table->addColumn('id', Types::BIGINT); + $table->addColumn('argument', Types::STRING, [ + 'notnull' => true, + 'length' => 4000, + 'default' => '', + ]); + $table->addIndex(['id'], $this->tableName . '_id'); + + $migrator = $this->getMigrator(); + $migrator->migrate($startSchema); + + $values = [ + '{"foo":"bar"}', + json_encode(['data' => str_repeat('x', 3900)]), + json_encode(['emoji' => 'üñïçødé 🥘 "quoted" \\']), + ]; + foreach ($values as $i => $value) { + $this->connection->insert($this->tableName, ['id' => $i + 1, 'argument' => $value]); + } + + $quotedTable = $this->connection->quoteIdentifier($this->tableName); + $this->connection->executeStatement('ALTER TABLE ' . $quotedTable . ' ADD ("argument2" CLOB)'); + $this->connection->executeStatement('UPDATE ' . $quotedTable . ' SET "argument2" = "argument"'); + + // verification gate: must be 0 before the old column may be dropped + $mismatches = $this->connection->executeQuery( + 'SELECT COUNT(*) FROM ' . $quotedTable + . ' WHERE ("argument" IS NOT NULL AND "argument2" IS NULL)' + . ' OR DBMS_LOB.COMPARE("argument2", TO_CLOB("argument")) <> 0' + )->fetchOne(); + $this->assertSame(0, (int)$mismatches); + + $this->connection->executeStatement('ALTER TABLE ' . $quotedTable . ' DROP COLUMN "argument"'); + $this->connection->executeStatement('ALTER TABLE ' . $quotedTable . ' RENAME COLUMN "argument2" TO "argument"'); + $this->connection->executeStatement('ALTER TABLE ' . $quotedTable . ' MODIFY ("argument" NOT NULL)'); + + $this->assertSame($values, $this->connection->executeQuery( + 'SELECT ' . $this->connection->quoteIdentifier('argument') + . ' FROM ' . $quotedTable + . ' ORDER BY ' . $this->connection->quoteIdentifier('id') . ' ASC' + )->fetchFirstColumn()); + + // same code path as the migration's type guard (ISchemaWrapper) + $schema = new SchemaWrapper($this->connection); + $column = $schema->getTable(substr($this->tableName, strlen($this->connection->getPrefix()))) + ->getColumn('argument'); + $this->assertSame(Type::getType(Types::TEXT), $column->getType()); + $this->assertTrue($column->getNotnull()); + } + + /** + * The string to text conversion fails on Oracle even with zero rows: + * ORA-22858 does not depend on the column's contents, so emptying the + * table is not a workaround. + */ + public function testChangeStringToTextEmptyTableFailsOnOracle(): void { + if ($this->connection->getDatabaseProvider() !== IDBConnection::PLATFORM_ORACLE) { + $this->markTestSkipped('Documents an Oracle-specific limitation'); + } + + $startSchema = new Schema([], [], $this->getSchemaConfig()); + $table = $startSchema->createTable($this->tableName); + $table->addColumn('id', Types::BIGINT); + $table->addColumn('argument', Types::STRING, [ + 'notnull' => true, + 'length' => 4000, + ]); + + $endSchema = new Schema([], [], $this->getSchemaConfig()); + $table = $endSchema->createTable($this->tableName); + $table->addColumn('id', Types::BIGINT); + $table->addColumn('argument', Types::TEXT, [ + 'notnull' => true, + ]); + + $migrator = $this->getMigrator(); + $migrator->migrate($startSchema); + + try { + $migrator->migrate($endSchema); + $this->fail('Expected the conversion of an empty table to fail with ORA-22858'); + } catch (\Doctrine\DBAL\Exception\DriverException $e) { + $this->assertStringContainsString('ORA-22858', $e->getMessage()); + } + if ($this->connection->isTransactionActive()) { + $this->connection->rollBack(); + } + } + + /** + * The PL/SQL block of the customer workaround script, read from the + * shipped script file so CI validates the literal artifact, with the + * sqlplus substitution variables replaced the way sqlplus would. + */ + private function getWorkaroundScriptBlock(string $tableName, string $backupConfirmed): string { + $script = file_get_contents(\OC::$SERVERROOT . '/tests/data/nc-ora22858-workaround.sql'); + $this->assertNotFalse($script, 'workaround script file not readable'); + $this->assertSame(1, preg_match('/^DECLARE\R.*?^END;/ms', $script, $matches), 'PL/SQL block not found in workaround script'); + return str_replace( + ['&table_name', '&backup_confirmed'], + [$tableName, $backupConfirmed], + $matches[0], + ); + } + + private function createWorkaroundTestTable(): array { + $startSchema = new Schema([], [], $this->getSchemaConfig()); + $table = $startSchema->createTable($this->tableName); + $table->addColumn('id', Types::BIGINT); + $table->addColumn('argument', Types::STRING, [ + 'notnull' => true, + 'length' => 4000, + 'default' => '', + ]); + $table->addIndex(['id'], $this->tableName . '_id'); + $this->getMigrator()->migrate($startSchema); + + $values = [ + '{"foo":"bar"}', + json_encode(['data' => str_repeat('x', 3900)]), + json_encode(['emoji' => 'üñïçødé 🥘 "quoted" \\']), + ]; + foreach ($values as $i => $value) { + $this->connection->insert($this->tableName, ['id' => $i + 1, 'argument' => $value]); + } + return $values; + } + + private function assertWorkaroundResult(array $values): void { + $this->assertSame($values, $this->connection->executeQuery( + 'SELECT ' . $this->connection->quoteIdentifier('argument') + . ' FROM ' . $this->connection->quoteIdentifier($this->tableName) + . ' ORDER BY ' . $this->connection->quoteIdentifier('id') . ' ASC' + )->fetchFirstColumn()); + + $schema = new SchemaWrapper($this->connection); + $column = $schema->getTable(substr($this->tableName, strlen($this->connection->getPrefix()))) + ->getColumn('argument'); + $this->assertSame(Type::getType(Types::TEXT), $column->getType()); + $this->assertTrue($column->getNotnull()); + } + + /** + * Validates the customer workaround script for the ORA-22858 upgrade + * failure: abort without backup confirmation, fresh conversion, and + * idempotent re-run. + */ + public function testWorkaroundScriptOnOracle(): void { + if ($this->connection->getDatabaseProvider() !== IDBConnection::PLATFORM_ORACLE) { + $this->markTestSkipped('Validates the Oracle-specific workaround script'); + } + + $values = $this->createWorkaroundTestTable(); + + // pressing enter at the prompt yields an empty value - must abort + foreach (['no', ''] as $notConfirmed) { + try { + $this->connection->executeStatement($this->getWorkaroundScriptBlock($this->tableName, $notConfirmed)); + $this->fail('Expected the script to abort without backup confirmation'); + } catch (\Doctrine\DBAL\Exception\DriverException $e) { + $this->assertStringContainsString('backup not confirmed', $e->getMessage()); + } + } + + // an argument2 column this script did not create must not be dropped + $quotedTable = $this->connection->quoteIdentifier($this->tableName); + $this->connection->executeStatement('ALTER TABLE ' . $quotedTable . ' ADD ("argument2" NUMBER)'); + try { + $this->connection->executeStatement($this->getWorkaroundScriptBlock($this->tableName, 'yes')); + $this->fail('Expected the script to refuse a foreign argument2 column'); + } catch (\Doctrine\DBAL\Exception\DriverException $e) { + $this->assertStringContainsString('argument2', $e->getMessage()); + } + $this->connection->executeStatement('ALTER TABLE ' . $quotedTable . ' DROP COLUMN "argument2"'); + + $this->connection->executeStatement($this->getWorkaroundScriptBlock($this->tableName, 'yes')); + $this->assertWorkaroundResult($values); + + // re-running must be a clean no-op + $this->connection->executeStatement($this->getWorkaroundScriptBlock($this->tableName, 'yes')); + $this->assertWorkaroundResult($values); + } + + /** + * The script must finish a run that was interrupted between dropping the + * original column and renaming the copy. + */ + public function testWorkaroundScriptResumesInterruptedRunOnOracle(): void { + if ($this->connection->getDatabaseProvider() !== IDBConnection::PLATFORM_ORACLE) { + $this->markTestSkipped('Validates the Oracle-specific workaround script'); + } + + $values = $this->createWorkaroundTestTable(); + + // reproduce the state after DROP COLUMN but before RENAME + $quotedTable = $this->connection->quoteIdentifier($this->tableName); + $this->connection->executeStatement('ALTER TABLE ' . $quotedTable . ' ADD ("argument2" CLOB)'); + $this->connection->executeStatement('UPDATE ' . $quotedTable . ' SET "argument2" = "argument"'); + $this->connection->executeStatement('ALTER TABLE ' . $quotedTable . ' DROP COLUMN "argument"'); + + $this->connection->executeStatement($this->getWorkaroundScriptBlock($this->tableName, 'yes')); + $this->assertWorkaroundResult($values); + } + + /** + * A run interrupted between the RENAME and the NOT NULL restore leaves + * a nullable CLOB column - a re-run must repair the constraint instead + * of declaring the state done. + */ + public function testWorkaroundScriptRestoresNotNullOnOracle(): void { + if ($this->connection->getDatabaseProvider() !== IDBConnection::PLATFORM_ORACLE) { + $this->markTestSkipped('Validates the Oracle-specific workaround script'); + } + + $values = $this->createWorkaroundTestTable(); + + // reproduce the state after RENAME but before the NOT NULL restore + $quotedTable = $this->connection->quoteIdentifier($this->tableName); + $this->connection->executeStatement('ALTER TABLE ' . $quotedTable . ' ADD ("argument2" CLOB)'); + $this->connection->executeStatement('UPDATE ' . $quotedTable . ' SET "argument2" = "argument"'); + $this->connection->executeStatement('ALTER TABLE ' . $quotedTable . ' DROP COLUMN "argument"'); + $this->connection->executeStatement('ALTER TABLE ' . $quotedTable . ' RENAME COLUMN "argument2" TO "argument"'); + + $this->connection->executeStatement($this->getWorkaroundScriptBlock($this->tableName, 'yes')); + $this->assertWorkaroundResult($values); + } + public function testAddingForeignKey(): void { $startSchema = new Schema([], [], $this->getSchemaConfig()); $table = $startSchema->createTable($this->tableName);