diff --git a/appinfo/info.xml b/appinfo/info.xml index bbdf12a8..9327fb3b 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -99,6 +99,14 @@ Vrij en open source onder de EUPL-licentie. OCA\SoftwareCatalog\Repair\InitializeSettings OCA\SoftwareCatalog\Repair\MigrateContactsToNc OCA\SoftwareCatalog\Repair\BackfillContractApprovalState + + OCA\SoftwareCatalog\Repair\RenameDutchCatalogColumns diff --git a/lib/Repair/RenameDutchCatalogColumns.php b/lib/Repair/RenameDutchCatalogColumns.php new file mode 100644 index 00000000..548130b5 --- /dev/null +++ b/lib/Repair/RenameDutchCatalogColumns.php @@ -0,0 +1,457 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Repair; + +use OCP\DB\Exception; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; + +/** + * Rename the catalog's Dutch columns to their English equivalents. + * + * @spec openspec/specs/english-vocabulary-migration/spec.md + */ +class RenameDutchCatalogColumns implements IRepairStep +{ + /** + * The register slug whose shard tables are in scope. + * + * @var string + */ + private const REGISTER_SLUG = 'softwarecatalog'; + + /** + * Schema slugs holding externally-standardised field names, which are + * exempt from the vocabulary rule and must NOT be migrated. + * + * `element`, `relation` and `view` carry the GEMMA/GGM architecture model + * imported from VNG. Their property names are the import's wire format: + * `view` alone holds gemma_status, gemma_thema, gemma_type, gemma_url, + * detailniveau, publiceren and titel_view_swc. + * + * `model` and `property-definition` are the ArchiMate Open Exchange File + * Format containers — `model` carries xmlns, xsi, schema_location and + * identifier straight off the exchange root element. Neither holds a column + * this step's map targets today, so listing them changes nothing now; they + * are here so that a property added later is exempt by default rather than + * migrated by omission. + * + * @var array + */ + private const WIRE_SCHEMA_SLUGS = [ + 'element', + 'relation', + 'view', + 'model', + 'property-definition', + ]; + + /** + * Old snake_case column name => new snake_case column name. + * + * Snake_case, not camelCase: MagicMapper stores `shortDescription` as + * `short_description`, and a camelCase column is exactly what its + * de-duplication path then drops. + * + * @var array + */ + private const COLUMN_MAP = [ + 'naam' => 'name', + 'beschrijving' => 'description', + 'beschrijving_kort' => 'short_description', + 'beschrijving_lang' => 'description', + 'omschrijving' => 'description', + 'contactpersoon' => 'contact_person', + 'publicatiedatum' => 'publication_date', + 'depublicatiedatum' => 'depublication_date', + ]; + + /** + * Constructor. + * + * @param IDBConnection $db Database connection. + * @param LoggerInterface $logger Logger. + */ + public function __construct( + private readonly IDBConnection $db, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Human-readable step name. + * + * @return string + * + * @spec openspec/specs/english-vocabulary-migration/spec.md + */ + public function getName(): string + { + return 'Move catalog data from the Dutch columns to the English ones'; + + }//end getName() + + /** + * Run the column migration across every in-scope shard table. + * + * @param IOutput $output Repair output. + * + * @return void + * + * @spec openspec/specs/english-vocabulary-migration/spec.md + */ + public function run(IOutput $output): void + { + $tables = $this->inScopeShardTables(); + if ($tables === []) { + $output->info('RenameDutchCatalogColumns: no in-scope shard tables on this install; nothing to do.'); + return; + } + + $renamed = 0; + $copied = 0; + $refused = 0; + + foreach ($tables as $table) { + $columns = $this->columnsOf(table: $table); + $qTable = $this->quote(identifier: $table); + + foreach (self::COLUMN_MAP as $old => $new) { + if (in_array($old, $columns, true) === false) { + // Already migrated, or this schema never had the property. + continue; + } + + if ($this->hasCollision(table: $table, columns: $columns, target: $new) === true) { + $refused++; + continue; + } + + $qOld = $this->quote(identifier: $old); + $qNew = $this->quote(identifier: $new); + + if (in_array($new, $columns, true) === false) { + $sql = 'ALTER TABLE '.$qTable.' RENAME COLUMN '.$qOld.' TO '.$qNew; + if ($this->exec(sql: $sql) === true) { + $renamed++; + } + + continue; + } + + // The mapper already added an empty English column: back-fill and + // leave the Dutch one, so this stays reversible. + $sql = 'UPDATE '.$qTable.' SET '.$qNew.' = '.$qOld + .' WHERE '.$qNew.' IS NULL AND '.$qOld.' IS NOT NULL'; + if ($this->exec(sql: $sql) === true) { + $copied++; + } + }//end foreach + }//end foreach + + $output->info( + 'RenameDutchCatalogColumns: '.$renamed.' column(s) renamed, ' + .$copied.' back-filled, '.$refused.' refused for ambiguity, across ' + .count($tables).' shard table(s).' + ); + + }//end run() + + /** + * Whether two Dutch columns in this table both target one English name. + * + * Merging them would silently destroy one of the two values, so the step + * refuses both and leaves a log line for a human to resolve. + * + * @param string $table Table name. + * @param array $columns Its column names. + * @param string $target The English destination name. + * + * @return bool True when the rename is ambiguous and must be skipped. + */ + private function hasCollision(string $table, array $columns, string $target): bool + { + $sources = []; + foreach (self::COLUMN_MAP as $old => $new) { + if ($new === $target && in_array($old, $columns, true) === true) { + $sources[] = $old; + } + } + + if (count($sources) < 2) { + return false; + } + + $this->logger->warning( + 'RenameDutchCatalogColumns: refusing an ambiguous rename; two source columns target one destination.', + ['table' => $table, 'sources' => $sources, 'target' => $target] + ); + + return true; + + }//end hasCollision() + + /** + * Resolve the shard tables in scope: this register, minus the wire schemas. + * + * Ids are looked up at runtime — both the register id and the schema ids + * differ per install. + * + * @return array + */ + private function inScopeShardTables(): array + { + try { + $registerId = $this->db->executeQuery( + 'SELECT id FROM `*PREFIX*openregister_registers` WHERE slug = ?', + [self::REGISTER_SLUG] + )->fetchOne(); + } catch (Exception $e) { + $this->logger->warning( + 'RenameDutchCatalogColumns: could not resolve the register; skipping.', + ['exception' => $e->getMessage()] + ); + return []; + } + + if ($registerId === false || $registerId === null) { + return []; + } + + $excluded = $this->wireSchemaIds(); + + // Table discovery goes through information_schema, NOT IDBConnection. + // OCP\IDBConnection exposes neither getSchema() nor getPrefix(); both + // exist only on the concrete OC\DB\Connection. Calling them is a runtime + // fatal that `php -l` and phpcs both report as clean — only phpstan + // catches it. Pattern follows openregister's own RegisterService: anchor + // on the `openregister_table_` MARKER, never on a computed prefix. + try { + $stmt = $this->db->prepare( + 'SELECT table_name FROM information_schema.tables WHERE table_name LIKE :pattern' + ); + $stmt->bindValue('pattern', '%openregister\_table\_%'); + $stmt->execute(); + } catch (\Throwable $e) { + $this->logger->warning( + 'RenameDutchCatalogColumns: could not list tables; skipping.', + ['exception' => $e->getMessage()] + ); + return []; + } + + $marker = 'openregister_table_'.((int) $registerId).'_'; + + $tables = []; + while (($row = $stmt->fetch(\PDO::FETCH_ASSOC)) !== false) { + $name = (string) ($row['table_name'] ?? ''); + if ($this->isMigratableShard(table: $name, marker: $marker, excluded: $excluded) === true) { + $tables[] = $name; + } + } + + return $tables; + + }//end inScopeShardTables() + + /** + * Whether a table is a shard of this register that is NOT wire-exempt. + * + * @param string $table Table name from information_schema. + * @param string $marker `openregister_table__`. + * @param array $excluded Schema ids exempt as external wire formats. + * + * @return bool + */ + private function isMigratableShard(string $table, string $marker, array $excluded): bool + { + $offset = strpos($table, $marker); + if ($offset === false) { + return false; + } + + // Everything after the marker must be the numeric schema id, so a + // derived table (…_13_50_backup) or a non-shard (…_13_audit) is left + // alone. Note this is NOT what stops register 13 matching register + // 130's tables — the marker already ends in '_', so `…_table_13_` is + // not a substring of `…_table_130_50` in the first place. + $schemaId = substr($table, ($offset + strlen($marker))); + if (ctype_digit($schemaId) === false) { + return false; + } + + // GEMMA/ArchiMate schemas carry an external wire format and are exempt. + return in_array((int) $schemaId, $excluded, true) === false; + + }//end isMigratableShard() + + /** + * Resolve the schema ids of the externally-standardised schemas. + * + * @return array + */ + private function wireSchemaIds(): array + { + $placeholders = implode(',', array_fill(0, count(self::WIRE_SCHEMA_SLUGS), '?')); + + try { + $ids = $this->db->executeQuery( + 'SELECT id FROM `*PREFIX*openregister_schemas` WHERE slug IN ('.$placeholders.')', + self::WIRE_SCHEMA_SLUGS + )->fetchAll(\PDO::FETCH_COLUMN); + } catch (Exception $e) { + // Fail CLOSED: if the exempt set cannot be resolved, migrate nothing + // rather than risk rewriting the GEMMA import contract. + $this->logger->error( + 'RenameDutchCatalogColumns: could not resolve the exempt GEMMA schemas; refusing to migrate anything.', + ['exception' => $e->getMessage()] + ); + throw $e; + } + + return array_map('intval', $ids); + + }//end wireSchemaIds() + + /** + * List the column names of a table. + * + * @param string $table Table name. + * + * @return array + */ + private function columnsOf(string $table): array + { + // Queried from information_schema — IDBConnection has no getSchema(). + try { + $stmt = $this->db->prepare( + 'SELECT column_name FROM information_schema.columns WHERE table_name = :table' + ); + $stmt->bindValue('table', $table); + $stmt->execute(); + } catch (\Throwable $e) { + $this->logger->warning( + 'RenameDutchCatalogColumns: could not read columns; skipping table.', + ['table' => $table, 'exception' => $e->getMessage()] + ); + return []; + } + + $columns = []; + while (($row = $stmt->fetch(\PDO::FETCH_ASSOC)) !== false) { + $name = (string) ($row['column_name'] ?? ''); + if ($name !== '') { + $columns[] = $name; + } + } + + return $columns; + + }//end columnsOf() + + /** + * Execute one DDL/DML statement, logging and swallowing failure. + * + * A failure must not abort the repair run: the remaining tables are + * independent, and an un-migrated column is still readable. + * + * @param string $sql The statement. + * + * @return bool Whether it succeeded. + */ + private function exec(string $sql): bool + { + try { + $this->db->executeStatement($sql); + return true; + } catch (Exception $e) { + $this->logger->warning( + 'RenameDutchCatalogColumns: statement failed; leaving the column as it was.', + ['sql' => $sql, 'exception' => $e->getMessage()] + ); + return false; + } + + }//end exec() + + /** + * Quote an identifier for the active platform. + * + * @param string $identifier Table or column name. + * + * @return string + */ + private function quote(string $identifier): string + { + return $this->db->getDatabasePlatform()->quoteSingleIdentifier($identifier); + + }//end quote() +}//end class diff --git a/openspec/specs/english-vocabulary-migration/spec.md b/openspec/specs/english-vocabulary-migration/spec.md new file mode 100644 index 00000000..ebfb12c6 --- /dev/null +++ b/openspec/specs/english-vocabulary-migration/spec.md @@ -0,0 +1,82 @@ +# English Vocabulary Migration + +Adoption of the ratified fleet English-vocabulary decision for this app's stored +data. The decision itself is owned by `hydra/openspec/changes/fleet-english-vocabulary`; +this spec records only what SoftwareCatalog must do to its own rows. + +## Why a migration is required at all + +OpenRegister does not store an object as a JSON blob keyed by property name. +Each schema property is a real, snake_cased **column** in the per-schema shard +table `oc_openregister_table_{register}_{schema}`. On schema sync MagicMapper +ADDS a column when the snake_cased name is absent and it never renames — there +is no `RENAME COLUMN` anywhere in openregister. + +A register-only rename therefore leaves the data in the Dutch column while every +read looks at the English one and finds null: no error, no data loss, and +invisible to a suite that asserts against fixtures rather than migrated rows. + +## Requirements + +### Requirement: Renaming a stored property ships a data migration + +Every rename of a property that OpenRegister materialises as a column SHALL ship +in the same change as a repair step that moves the stored values, or with +evidence recorded in the change that no rows exist. + +#### Scenario: A property rename lands without its migration + + + +- **WHEN** a register fragment renames a property that has a materialised column +- **THEN** the change SHALL NOT be merged until a repair step covers that column + or the change records a measured zero-row count for it + +### Requirement: Externally-standardised schemas are exempt + +The migration SHALL NOT touch schemas whose property names are the wire format +of an external standard, and SHALL resolve that exempt set at runtime rather +than assuming per-install ids. + +The `element`, `relation` and `view` schemas carry the GEMMA/GGM architecture +model imported from VNG. Their `toelichting`, `bron`, `ggm-*` and `gemma-*` +properties are that import's field names, exempt under the fleet rule as +external standard names inside the adapter layer. + +#### Scenario: The exempt set cannot be resolved + + + +- **WHEN** the repair step cannot resolve the ids of the exempt schemas +- **THEN** it SHALL fail closed and migrate nothing, rather than risk rewriting + the import contract + +### Requirement: Ambiguous renames are refused, never merged + +The migration SHALL refuse to migrate a column when two source columns in one +table target the same destination name, and SHALL log the refusal. + +`beschrijving`, `beschrijving_lang` and `omschrijving` all mean `description`. +They do not co-occur in any schema today, but a later fragment could introduce a +pair, and a silent merge would destroy one of the two values. + +#### Scenario: Two Dutch columns target one English name + + + +- **WHEN** a shard table holds both `beschrijving` and `beschrijving_lang` +- **THEN** the step SHALL migrate neither and SHALL log the table, both sources, + and the destination + +### Requirement: The migration is non-destructive and idempotent + +The migration SHALL leave every original column readable and SHALL be safe to +re-run. + +#### Scenario: The English column already exists and is empty + + + +- **WHEN** MagicMapper has already added the English column before the step runs +- **THEN** the step SHALL copy the values across and SHALL leave the Dutch column + in place, so the change remains reversible and a second run is a no-op diff --git a/phpunit.xml b/phpunit.xml index cd93e7d9..20af447a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -26,6 +26,32 @@ lib/ + + + lib/Repair/RenameDutchCatalogColumns.php + diff --git a/tests/Unit/Repair/RenameDutchCatalogColumnsTest.php b/tests/Unit/Repair/RenameDutchCatalogColumnsTest.php new file mode 100644 index 00000000..d30ab79f --- /dev/null +++ b/tests/Unit/Repair/RenameDutchCatalogColumnsTest.php @@ -0,0 +1,250 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Repair; + +use OCA\SoftwareCatalog\Repair\RenameDutchCatalogColumns; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use ReflectionClass; +use ReflectionMethod; + +/** + * @covers \OCA\SoftwareCatalog\Repair\RenameDutchCatalogColumns + */ +class RenameDutchCatalogColumnsTest extends TestCase +{ + /** + * The step under test. + * + * @var RenameDutchCatalogColumns + */ + private RenameDutchCatalogColumns $step; + + /** + * Build the step WITHOUT running its constructor, then inject a logger. + * + * The constructor is skipped because mocking IDBConnection drags in + * Doctrine types this app's unit environment does not install. + * + * $logger IS still required, though: hasCollision() logs when it refuses an + * ambiguous rename, and a readonly promoted property left uninitialised + * throws "must not be accessed before initialization" the moment that path + * runs. An earlier version of this file skipped the constructor and set + * nothing; the collision test then errored in CI for exactly that reason, + * while the local standalone check of the same logic passed — because it + * exercised the algorithm as a free function, with no object state at all. + * + * @return void + */ + protected function setUp(): void + { + $class = new ReflectionClass(RenameDutchCatalogColumns::class); + $this->step = $class->newInstanceWithoutConstructor(); + + $logger = $class->getProperty('logger'); + $logger->setAccessible(true); + $logger->setValue($this->step, new NullLogger()); + + }//end setUp() + + /** + * Invoke a private method on the step. + * + * @param string $name Method name. + * @param array $args Positional arguments. + * + * @return mixed + */ + private function call(string $name, array $args) + { + $m = new ReflectionMethod(RenameDutchCatalogColumns::class, $name); + $m->setAccessible(true); + return $m->invokeArgs($this->step, $args); + + }//end call() + + /** + * Read a private constant off the step. + * + * @param string $name Constant name. + * + * @return mixed + */ + private function constant(string $name) + { + return (new ReflectionClass(RenameDutchCatalogColumns::class))->getConstant($name); + + }//end constant() + + /** + * A shard of this register with a non-exempt schema is migrated. + * + * @return void + */ + public function testMatchesAnOrdinaryShard(): void + { + self::assertTrue( + $this->call('isMigratableShard', ['oc_openregister_table_13_50', 'openregister_table_13_', []]) + ); + + }//end testMatchesAnOrdinaryShard() + + /** + * A wire-exempt schema is NOT migrated. + * + * This is the heart of the step. Schemas 44 (`element`), 49 (`relation`) + * and 45 (`view`) hold the GEMMA/GGM model imported from VNG, and 46 + * (`model`) / 48 (`property-definition`) the ArchiMate Open Exchange + * containers. Their property names ARE the import's wire format. Migrating + * them would rewrite the import contract, and the symptom would be a GEMMA + * re-import silently writing nulls. + * + * @return void + */ + public function testDoesNotMigrateWireExemptSchemas(): void + { + $marker = 'openregister_table_13_'; + $excluded = [44, 45, 46, 48, 49]; + + foreach ([44, 45, 46, 48, 49] as $id) { + self::assertFalse( + $this->call('isMigratableShard', ["oc_openregister_table_13_$id", $marker, $excluded]), + "Schema $id is wire-exempt and must not be migrated" + ); + } + + // A non-exempt neighbour in the same register still is. + self::assertTrue( + $this->call('isMigratableShard', ['oc_openregister_table_13_50', $marker, $excluded]) + ); + + }//end testDoesNotMigrateWireExemptSchemas() + + /** + * A derived or non-shard table sharing the marker is left alone. + * + * This is what the digits-only suffix check guards. It is NOT what stops + * register 13 matching register 130 — the marker already ends in '_', so + * that collision cannot occur. + * + * @return void + */ + public function testDoesNotMatchDerivedOrNonShardTables(): void + { + $marker = 'openregister_table_13_'; + self::assertFalse($this->call('isMigratableShard', ['oc_openregister_table_13_50_backup', $marker, []])); + self::assertFalse($this->call('isMigratableShard', ['oc_openregister_table_13_audit', $marker, []])); + self::assertFalse($this->call('isMigratableShard', ['oc_openregister_registers', $marker, []])); + + }//end testDoesNotMatchDerivedOrNonShardTables() + + /** + * Two Dutch columns targeting one English name are refused, not merged. + * + * `beschrijving`, `beschrijving_lang` and `omschrijving` all mean + * `description`. They do not co-occur today, but a silent merge would + * destroy one of two values, so the step must migrate neither. + * + * @return void + */ + public function testRefusesAmbiguousRename(): void + { + $columns = ['beschrijving', 'beschrijving_lang', 'naam']; + self::assertTrue($this->call('hasCollision', ['tbl', $columns, 'description'])); + + }//end testRefusesAmbiguousRename() + + /** + * A single source for a destination is not a collision. + * + * @return void + */ + public function testSingleSourceIsNotACollision(): void + { + $columns = ['beschrijving_kort', 'naam']; + self::assertFalse($this->call('hasCollision', ['tbl', $columns, 'short_description'])); + self::assertFalse($this->call('hasCollision', ['tbl', $columns, 'name'])); + + }//end testSingleSourceIsNotACollision() + + /** + * The GEMMA and ArchiMate schemas are all listed as exempt. + * + * Listing `model` and `property-definition` is deliberate even though + * neither carries a column the map targets today: it makes a property added + * later exempt by default rather than migrated by omission. + * + * @return void + */ + public function testWireSchemasAreExempt(): void + { + $slugs = $this->constant('WIRE_SCHEMA_SLUGS'); + self::assertIsArray($slugs); + foreach (['element', 'relation', 'view', 'model', 'property-definition'] as $slug) { + self::assertContains($slug, $slugs, "$slug carries an external wire format and must be exempt"); + } + + }//end testWireSchemasAreExempt() + + /** + * Every destination is snake_case, never camelCase. + * + * MagicMapper stores `shortDescription` as `short_description`, and its + * de-duplication path DROPS a camelCase column whose snake_case twin + * exists — so a camelCase destination would be deleted on the next sync. + * + * @return void + */ + public function testEveryDestinationIsSnakeCase(): void + { + $map = $this->constant('COLUMN_MAP'); + self::assertIsArray($map); + foreach ($map as $old => $new) { + self::assertSame( + strtolower($new), + $new, + "Destination '$new' (from '$old') must be snake_case, not camelCase" + ); + } + + }//end testEveryDestinationIsSnakeCase() + + /** + * The step reports a human-readable name. + * + * @return void + */ + public function testGetName(): void + { + self::assertNotSame('', $this->step->getName()); + + }//end testGetName() +}//end class