diff --git a/tests/data/nc-ora22858-preflight.sql b/tests/data/nc-ora22858-preflight.sql new file mode 100644 index 0000000000000..678f242b9ad22 --- /dev/null +++ b/tests/data/nc-ora22858-preflight.sql @@ -0,0 +1,251 @@ +-- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +-- SPDX-License-Identifier: AGPL-3.0-or-later +-- +-- READ-ONLY pre-flight diagnostics for the Nextcloud ORA-22858 workaround. +-- +-- Run this BEFORE nc-ora22858-workaround.sql and send back the log file. +-- It performs no changes of any kind - only data-dictionary and count +-- queries - and is safe to run at any time, on any state of the table. +-- Sections that cannot be read are reported with the reason instead of +-- failing. +-- +-- Run as the Nextcloud database user: +-- sqlplus @ @nc-ora22858-preflight.sql +-- +-- The transcript is written to nc_ora22858_preflight.log in the directory +-- you run sqlplus from (a re-run overwrites it). + +SET SERVEROUTPUT ON SIZE UNLIMITED +SET LINESIZE 200 +SET TRIMSPOOL ON +SET VERIFY OFF +SET FEEDBACK OFF +WHENEVER SQLERROR CONTINUE + +SPOOL nc_ora22858_preflight.log + +ACCEPT table_name CHAR DEFAULT 'oc_jobs' PROMPT 'Jobs table name (check dbtableprefix in config.php) [oc_jobs]: ' + +DECLARE + l_table VARCHAR2(4000); + l_exists INTEGER; + l_count INTEGER; + l_count2 INTEGER; + l_count3 INTEGER; + l_sysnc INTEGER; + l_text VARCHAR2(4000); + + PROCEDURE say(p_msg IN VARCHAR2) IS + BEGIN + DBMS_OUTPUT.PUT_LINE(p_msg); + END; + + PROCEDURE section(p_title IN VARCHAR2) IS + BEGIN + say(''); + say('=== ' || p_title || ' ==='); + END; +BEGIN + l_table := TRIM('&table_name'); + + say('NC-ORA22858 PREFLIGHT v2 - read-only, no changes are made'); + + section('Context'); + say('User: ' || USER); + SELECT TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS') INTO l_text FROM dual; + say('Date: ' || l_text); + say('Table under inspection: "' || l_table || '"'); + + section('Oracle server version'); + BEGIN + -- version_full only exists since 18c, so this must be dynamic SQL + EXECUTE IMMEDIATE 'SELECT version_full FROM product_component_version' + || ' WHERE product LIKE ''Oracle%'' AND ROWNUM = 1' INTO l_text; + say(l_text); + EXCEPTION WHEN OTHERS THEN + BEGIN + EXECUTE IMMEDIATE 'SELECT version FROM product_component_version' + || ' WHERE product LIKE ''Oracle%'' AND ROWNUM = 1' INTO l_text; + say(l_text); + EXCEPTION WHEN OTHERS THEN + say('skipped: ' || SQLERRM || ' - please supply the output of: ' + || 'SELECT banner FROM v$version;'); + END; + END; + + section('Table and columns'); + SELECT COUNT(*) INTO l_exists FROM user_tables WHERE table_name = l_table; + IF l_exists = 0 THEN + say('TABLE NOT FOUND in this schema.'); + SELECT COUNT(*) INTO l_count FROM user_tables + WHERE table_name IN (UPPER(l_table), LOWER(l_table)); + IF l_count > 0 THEN + say('note: a table with this name exists in different case - ' + || 'Nextcloud table names are case-sensitive (lowercase). ' + || 'Re-run and enter the name exactly as it appears in the schema.'); + ELSE + say('Check dbtableprefix in config.php and that you are connected ' + || 'as the Nextcloud database user.'); + END IF; + say('Row statistics and storage sections are skipped; the remaining ' + || 'sections will report "none".'); + ELSE + say('Table found.'); + FOR r IN (SELECT column_name, data_type, data_length, char_used, nullable + FROM user_tab_columns + WHERE table_name = l_table ORDER BY column_id) LOOP + say(' "' || r.column_name || '" ' || r.data_type + || '(' || r.data_length + || CASE r.char_used WHEN 'C' THEN ' CHAR' WHEN 'B' THEN ' BYTE' ELSE '' END + || ') nullable=' || r.nullable); + END LOOP; + END IF; + + section('Row statistics'); + IF l_exists = 0 THEN + say(' skipped - table not found'); + ELSE + BEGIN + EXECUTE IMMEDIATE 'SELECT COUNT(*), COUNT(*) - COUNT("argument"),' + || ' NVL(MAX(LENGTH("argument")), 0) FROM "' || l_table || '"' + INTO l_count, l_count2, l_count3; + say('Rows: ' || l_count || ', NULL arguments: ' || l_count2 + || ', longest argument value: ' || l_count3 || ' chars'); + EXCEPTION WHEN OTHERS THEN + say('could not read rows: ' || SQLERRM); + END; + END IF; + + section('Indexes on the table (any on "argument" blocks the workaround)'); + l_count := 0; + l_sysnc := 0; + FOR r IN (SELECT index_name, column_name FROM user_ind_columns + WHERE table_name = l_table ORDER BY index_name, column_position) LOOP + l_count := l_count + 1; + IF r.column_name LIKE 'SYS_NC%' THEN + l_sysnc := l_sysnc + 1; + END IF; + say(' ' || r.index_name || ' (' || r.column_name || ')' + || CASE WHEN r.column_name = 'argument' THEN ' <-- ON THE TARGET COLUMN' ELSE '' END); + END LOOP; + IF l_count = 0 THEN say(' none'); END IF; + IF l_sysnc > 0 THEN + say(' note: SYS_NC% entries indicate function-based indexes or ' + || 'virtual columns - these need review, tell us'); + END IF; + + section('Constraints referencing "argument" (named ones block the workaround)'); + l_count := 0; + FOR r IN (SELECT c.constraint_name, c.constraint_type, c.generated + FROM user_cons_columns cc + JOIN user_constraints c + ON c.constraint_name = cc.constraint_name AND c.table_name = cc.table_name + WHERE cc.table_name = l_table AND cc.column_name = 'argument') LOOP + l_count := l_count + 1; + say(' ' || r.constraint_name || ' type=' || r.constraint_type + || ' generated=' || r.generated); + END LOOP; + IF l_count = 0 THEN say(' none'); END IF; + + section('Triggers on the table (would need review before the swap)'); + l_count := 0; + FOR r IN (SELECT trigger_name, status FROM user_triggers + WHERE table_name = l_table) LOOP + l_count := l_count + 1; + say(' ' || r.trigger_name || ' (' || r.status || ')'); + END LOOP; + IF l_count = 0 THEN say(' none'); END IF; + + section('Materialized view logs on the table'); + BEGIN + SELECT COUNT(*) INTO l_count FROM user_mview_logs + WHERE master = l_table; + say(CASE WHEN l_count = 0 THEN ' none' ELSE ' ' || l_count || ' FOUND - tell us' END); + EXCEPTION WHEN OTHERS THEN + say(' skipped: ' || SQLERRM); + END; + + section('Flashback archive enrollment'); + BEGIN + EXECUTE IMMEDIATE + 'SELECT COUNT(*) FROM user_flashback_archive_tables WHERE table_name = :t' + INTO l_count USING l_table; + say(CASE WHEN l_count = 0 THEN ' not enrolled' ELSE ' ENROLLED - tell us before running' END); + EXCEPTION WHEN OTHERS THEN + say(' skipped: ' || SQLERRM); + END; + + section('VPD / security policies on the table'); + BEGIN + EXECUTE IMMEDIATE + 'SELECT COUNT(*) FROM user_policies WHERE object_name = :t' + INTO l_count USING l_table; + say(CASE WHEN l_count = 0 THEN ' none' ELSE ' ' || l_count || ' FOUND - tell us' END); + EXCEPTION WHEN OTHERS THEN + say(' skipped: ' || SQLERRM); + END; + + section('Objects depending on the table (views etc. - briefly invalid during the swap)'); + l_count := 0; + FOR r IN (SELECT name, type FROM user_dependencies + WHERE referenced_name = l_table AND referenced_type = 'TABLE') LOOP + l_count := l_count + 1; + say(' ' || r.type || ' ' || r.name); + END LOOP; + IF l_count = 0 THEN say(' none'); END IF; + + section('Storage headroom (the copy briefly needs extra space)'); + IF l_exists = 0 THEN + say(' skipped - table not found'); + ELSE + BEGIN + SELECT NVL(SUM(bytes), 0) INTO l_count + FROM user_segments WHERE segment_name = l_table; + say(' Table segment size: ' || ROUND(l_count / 1024 / 1024) || ' MB'); + SELECT tablespace_name INTO l_text + FROM user_tables WHERE table_name = l_table; + IF l_text IS NULL THEN + say(' Tablespace: (none at table level - partitioned table? tell us)'); + ELSE + say(' Tablespace: ' || l_text); + BEGIN + SELECT NVL(SUM(bytes), 0) INTO l_count2 + FROM user_free_space WHERE tablespace_name = l_text; + say(' Free in tablespace: ' || ROUND(l_count2 / 1024 / 1024) || ' MB' + || CASE WHEN l_count2 < l_count THEN + ' <-- less than table size (may be fine with autoextend), tell us' + ELSE '' END); + EXCEPTION WHEN OTHERS THEN + say(' free space skipped: ' || SQLERRM); + END; + END IF; + EXCEPTION WHEN OTHERS THEN + say(' skipped: ' || SQLERRM); + END; + END IF; + + section('Replication indicators (best effort)'); + BEGIN + EXECUTE IMMEDIATE + 'SELECT supplemental_log_data_min FROM v$database' INTO l_text; + say(' Supplemental logging (minimal): ' || l_text + || CASE WHEN l_text <> 'NO' THEN ' <-- replication may be configured, tell us' ELSE '' END); + EXCEPTION WHEN OTHERS THEN + say(' v$database not visible to this user - please confirm with your ' + || 'DBA whether this database is replicated (GoldenGate, standby, CDC)'); + END; + + say(''); + say('PREFLIGHT COMPLETE - no changes were made. Please send back ' + || 'nc_ora22858_preflight.log.'); +EXCEPTION WHEN OTHERS THEN + say(''); + say('PREFLIGHT INTERNAL ERROR (diagnostics incomplete, still no changes ' + || 'were made): ' || SQLERRM); + say(DBMS_UTILITY.FORMAT_ERROR_BACKTRACE); + say('Please send back nc_ora22858_preflight.log anyway.'); +END; +/ + +SPOOL OFF +EXIT diff --git a/tests/data/nc-ora22858-workaround.sql b/tests/data/nc-ora22858-workaround.sql new file mode 100644 index 0000000000000..7ee208b341eff --- /dev/null +++ b/tests/data/nc-ora22858-workaround.sql @@ -0,0 +1,289 @@ +-- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +-- SPDX-License-Identifier: AGPL-3.0-or-later +-- +-- 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. +-- * Refuses to proceed if any customer-added index or named constraint +-- references the column (DROP COLUMN would silently remove them). +-- * 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; + l_names VARCHAR2(4000); + + 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; + + -- Wait up to 60s for transient locks instead of failing immediately + -- with ORA-00054 (DDL default is NOWAIT). + EXECUTE IMMEDIATE 'ALTER SESSION SET DDL_LOCK_TIMEOUT = 60'; + + 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; + + -- Pre-flight: DROP COLUMN silently removes indexes and constraints that + -- reference the column. The stock schema has none on "argument" (the + -- system NOT NULL constraint is recreated by this script); anything else + -- is a customisation that must be reviewed by a human first. + SELECT COUNT(*), LISTAGG(index_name, ', ') INTO l_count, l_names + FROM user_ind_columns + WHERE table_name = l_table AND column_name = 'argument'; + IF l_count > 0 THEN + fail('Non-stock schema: index(es) reference "argument": ' || l_names + || '. Dropping the column would silently remove them. Manual ' + || 'review required. Nothing was changed.'); + END IF; + SELECT COUNT(*), LISTAGG(c.constraint_name, ', ') INTO l_count, l_names + FROM user_cons_columns cc + JOIN user_constraints c + ON c.constraint_name = cc.constraint_name AND c.table_name = cc.table_name + WHERE cc.table_name = l_table AND cc.column_name = 'argument' + AND c.generated <> 'GENERATED NAME'; + IF l_count > 0 THEN + fail('Non-stock schema: named constraint(s) reference "argument": ' + || l_names || '. Dropping the column would silently remove them. ' + || 'Manual review required. 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..67414b54e7189 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,337 @@ 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()); + } + } + + // a customer-added constraint referencing the column must abort the + // run (DROP COLUMN would silently remove it); the system NOT NULL + // constraint must not trigger this + $quotedTable = $this->connection->quoteIdentifier($this->tableName); + $constraintName = strtoupper($this->tableName) . '_CX'; + $this->connection->executeStatement('ALTER TABLE ' . $quotedTable + . ' ADD CONSTRAINT ' . $constraintName . ' CHECK ("argument" IS NOT NULL)'); + try { + $this->connection->executeStatement($this->getWorkaroundScriptBlock($this->tableName, 'yes')); + $this->fail('Expected the script to refuse a customer-added constraint on the column'); + } catch (\Doctrine\DBAL\Exception\DriverException $e) { + $this->assertStringContainsString($constraintName, $e->getMessage()); + } + $this->connection->executeStatement('ALTER TABLE ' . $quotedTable . ' DROP CONSTRAINT ' . $constraintName); + + // an argument2 column this script did not create must not be dropped + $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); + } + + private function getPreflightScriptBlock(string $tableName): string { + $script = file_get_contents(\OC::$SERVERROOT . '/tests/data/nc-ora22858-preflight.sql'); + $this->assertNotFalse($script, 'preflight script file not readable'); + $this->assertSame(1, preg_match('/^DECLARE\R.*?^END;/ms', $script, $matches), 'PL/SQL block not found in preflight script'); + return str_replace('&table_name', $tableName, $matches[0]); + } + + /** + * Fetch and clear the DBMS_OUTPUT buffer of the current session. + */ + private function fetchDbmsOutput(): string { + $oci = $this->connection->getNativeConnection(); + $stmt = oci_parse($oci, + 'DECLARE + l_lines DBMS_OUTPUT.CHARARR; + l_n INTEGER := 1000; + l_all VARCHAR2(32767) := \'\'; + BEGIN + DBMS_OUTPUT.GET_LINES(l_lines, l_n); + FOR i IN 1 .. l_n LOOP + l_all := l_all || l_lines(i) || CHR(10); + END LOOP; + :output := l_all; + END;'); + $output = ''; + oci_bind_by_name($stmt, ':output', $output, 32767); + $this->assertTrue(oci_execute($stmt), 'fetching DBMS_OUTPUT failed'); + return (string)$output; + } + + /** + * The read-only pre-flight diagnostics must produce a complete, honest + * report on a real table without modifying it, and report (not throw on) + * a missing table. Output markers are asserted via DBMS_OUTPUT because + * the script's outer exception handler means "did not throw" proves + * nothing by itself. + */ + public function testPreflightScriptOnOracle(): void { + if ($this->connection->getDatabaseProvider() !== IDBConnection::PLATFORM_ORACLE) { + $this->markTestSkipped('Validates the Oracle-specific preflight script'); + } + + $values = $this->createWorkaroundTestTable(); + $this->connection->executeStatement('BEGIN DBMS_OUTPUT.ENABLE(NULL); END;'); + + $this->connection->executeStatement($this->getPreflightScriptBlock($this->tableName)); + $output = $this->fetchDbmsOutput(); + $this->assertStringContainsString('PREFLIGHT COMPLETE', $output); + $this->assertStringContainsString('Table found.', $output); + $this->assertStringContainsString('Rows: 3, NULL arguments: 0', $output); + $this->assertStringNotContainsString('INTERNAL ERROR', $output); + $this->assertStringNotContainsString('could not read rows', $output); + // CI runs privileged, so no section may take a degradation path here + $this->assertStringNotContainsString('skipped', $output); + + // table untouched: same rows, column still the original string type + $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::STRING), $column->getType()); + + // a nonexistent table must be reported cleanly, not thrown, and must + // not produce misleading storage output + $this->connection->executeStatement($this->getPreflightScriptBlock($this->tableNameTmp)); + $output = $this->fetchDbmsOutput(); + $this->assertStringContainsString('TABLE NOT FOUND', $output); + $this->assertStringContainsString('PREFLIGHT COMPLETE', $output); + $this->assertStringNotContainsString('INTERNAL ERROR', $output); + $this->assertStringNotContainsString('Table segment size', $output); + } + + /** + * 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);