|
| 1 | +<?php |
| 2 | + |
| 3 | +/** |
| 4 | + * Translates the Dutch ENUM VALUES stored in this app's shard tables. |
| 5 | + * |
| 6 | + * Renaming a value in the schema is only half the job, and the quieter half. |
| 7 | + * The declaration changes, but every row already written still holds the old |
| 8 | + * string — and a filter on the new one then returns NULL rather than an error, |
| 9 | + * so the feature reports "nothing found" instead of failing. That is the shape |
| 10 | + * of every value migration: the code looks right and the data disagrees. |
| 11 | + * |
| 12 | + * Scoped by COLUMN, never by value alone. `intern` is a connection's |
| 13 | + * integration type here and a statutory ZGW confidentiality value elsewhere; |
| 14 | + * `Concept` is a lifecycle state on one column and an ordinary word on the |
| 15 | + * next. A value migration that matches on the string alone corrupts every |
| 16 | + * column that happens to share it. |
| 17 | + * |
| 18 | + * Idempotent: an already-migrated row simply matches no WHERE clause. |
| 19 | + * |
| 20 | + * @category Repair |
| 21 | + * @package OCA\SoftwareCatalog\Repair |
| 22 | + * @author Conduction B.V. <info@conduction.nl> |
| 23 | + * @copyright 2026 Conduction B.V. |
| 24 | + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 |
| 25 | + * @link https://www.conduction.nl |
| 26 | + * |
| 27 | + * SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl> |
| 28 | + * SPDX-License-Identifier: EUPL-1.2 |
| 29 | + */ |
| 30 | + |
| 31 | +declare(strict_types=1); |
| 32 | + |
| 33 | +namespace OCA\SoftwareCatalog\Repair; |
| 34 | + |
| 35 | +use OCP\DB\Exception; |
| 36 | +use OCP\IDBConnection; |
| 37 | +use OCP\Migration\IOutput; |
| 38 | +use OCP\Migration\IRepairStep; |
| 39 | +use Psr\Log\LoggerInterface; |
| 40 | + |
| 41 | +/** |
| 42 | + * Migrates stored Dutch enum values to their English spelling. |
| 43 | + */ |
| 44 | +class RenameDutchCatalogValues implements IRepairStep { |
| 45 | + |
| 46 | + /** |
| 47 | + * Property name => old value => new value. |
| 48 | + * |
| 49 | + * Keyed by the PROPERTY as declared in the schema; the column is derived |
| 50 | + * with MagicMapper's own rule (see RenameDutchCatalogDecisions). |
| 51 | + * |
| 52 | + * `roles` is absent on purpose: its members mirror Nextcloud GROUP names |
| 53 | + * that `ContactpersonenController` checks with `isInGroup()`, and renaming |
| 54 | + * the stored value while the group keeps its name desynchronises the two. |
| 55 | + * |
| 56 | + * @var array<string, array<string, string>> |
| 57 | + */ |
| 58 | + public const VALUE_MAP = [ |
| 59 | + 'type' => [ |
| 60 | + 'Functioneel beheer' => 'Functional management', |
| 61 | + 'Applicatiebeheer' => 'Application management', |
| 62 | + 'Technisch beheer' => 'Technical management', |
| 63 | + 'Implementatieondersteuning' => 'Implementation support', |
| 64 | + 'Opleidingen' => 'Training', |
| 65 | + 'Licentiereseller' => 'Licence reseller', |
| 66 | + 'Gemeente' => 'Municipality', |
| 67 | + 'Leverancier' => 'Supplier', |
| 68 | + 'Samenwerking' => 'Collaboration', |
| 69 | + 'Applicatie' => 'Application', |
| 70 | + 'Systeemsoftware' => 'System software', |
| 71 | + 'n.v.t.' => 'n/a', |
| 72 | + 'bestandsoverdracht' => 'file transfer', |
| 73 | + 'upload naar portaal' => 'upload to portal', |
| 74 | + ], |
| 75 | + 'status' => [ |
| 76 | + 'Concept' => 'Draft', |
| 77 | + 'Actief' => 'Active', |
| 78 | + 'Deactief' => 'Inactive', |
| 79 | + 'samengevoegd' => 'merged', |
| 80 | + 'Verwerving' => 'Acquisition', |
| 81 | + 'Gepland' => 'Planned', |
| 82 | + 'In productie' => 'In production', |
| 83 | + 'Uit te faseren' => 'To be phased out', |
| 84 | + 'Uitgefaseerd' => 'Phased out', |
| 85 | + 'Verlopen' => 'Expired', |
| 86 | + 'In onderhandeling' => 'In negotiation', |
| 87 | + 'in ontwikkeling' => 'in development', |
| 88 | + 'in gebruik' => 'in use', |
| 89 | + 'einde ondersteuning' => 'end of support', |
| 90 | + 'teruggetrokken' => 'withdrawn', |
| 91 | + ], |
| 92 | + 'registeredBy' => [ |
| 93 | + 'Gemeente' => 'Municipality', |
| 94 | + 'Applicatie' => 'Application', |
| 95 | + 'Samenwerking' => 'Collaboration', |
| 96 | + 'Leverancier' => 'Supplier', |
| 97 | + ], |
| 98 | + 'samenwerkingtype' => [ |
| 99 | + 'Uitvoeringsorganisatie' => 'Implementing organisation', |
| 100 | + 'Sociaal Domein samenwerking' => 'Social Domain collaboration', |
| 101 | + 'Omgevingsdienst' => 'Environmental agency', |
| 102 | + 'ICT (bijvoorbeeld Shared Service Center)' => 'ICT (for example Shared Service Center)', |
| 103 | + 'Gemeentelijke herindeling (gepland)' => 'Municipal reorganisation (planned)', |
| 104 | + 'Gemeenschappelijke Regeling (samenwerking meerdere domeinen)' => 'Joint Arrangement (collaboration across multiple domains)', |
| 105 | + 'Gemeenschappelijke Regeling' => 'Joint Arrangement', |
| 106 | + 'Centrumgemeenteregeling' => 'Central municipality arrangement', |
| 107 | + 'Belastingsamenwerking' => 'Tax collaboration', |
| 108 | + 'Bedrijfsvoeringsorganisatie' => 'Operations organisation', |
| 109 | + 'Archiefdienst (regionaal)' => 'Archive service (regional)', |
| 110 | + 'Ambtelijke fusie' => 'Administrative merger', |
| 111 | + ], |
| 112 | + 'contractType' => [ |
| 113 | + 'Licentie' => 'Licence', |
| 114 | + 'Onderhoud' => 'Maintenance', |
| 115 | + ], |
| 116 | + 'costPeriod' => [ |
| 117 | + 'Maandelijks' => 'Monthly', |
| 118 | + 'Jaarlijks' => 'Annually', |
| 119 | + 'Eenmalig' => 'One-off', |
| 120 | + ], |
| 121 | + 'dataExchangeDirection' => [ |
| 122 | + 'AnaarB' => 'AtoB', |
| 123 | + 'BnaarA' => 'BtoA', |
| 124 | + 'bi-directioneel' => 'bi-directional', |
| 125 | + ], |
| 126 | + 'integrationType' => [ |
| 127 | + 'extern' => 'external', |
| 128 | + 'intern' => 'internal', |
| 129 | + ], |
| 130 | + 'hostingJurisdiction' => ['Elders' => 'Elsewhere'], |
| 131 | + 'hostingLocation' => ['Elders' => 'Elsewhere'], |
| 132 | + 'licence' => [ |
| 133 | + 'BSD Licentie (Berkeley Software Distribution)' => 'BSD License (Berkeley Software Distribution)', |
| 134 | + 'European Union Public Licence (EUPL), versie 1.2' => 'European Union Public Licence (EUPL), version 1.2', |
| 135 | + ], |
| 136 | + ]; |
| 137 | + |
| 138 | + /** |
| 139 | + * Constructor. |
| 140 | + * |
| 141 | + * @param IDBConnection $db Database connection. |
| 142 | + * @param LoggerInterface $logger Logger. |
| 143 | + * @param RenameDutchCatalogDecisions $decisions Column-name predicates. |
| 144 | + */ |
| 145 | + public function __construct( |
| 146 | + private readonly IDBConnection $db, |
| 147 | + private readonly LoggerInterface $logger, |
| 148 | + private readonly RenameDutchCatalogDecisions $decisions = new RenameDutchCatalogDecisions(), |
| 149 | + ) { |
| 150 | + }//end __construct() |
| 151 | + |
| 152 | + /** |
| 153 | + * Step name shown by `occ maintenance:repair`. |
| 154 | + * |
| 155 | + * @return string |
| 156 | + */ |
| 157 | + public function getName(): string { |
| 158 | + return 'Translate stored Dutch SoftwareCatalog enum values'; |
| 159 | + }//end getName() |
| 160 | + |
| 161 | + /** |
| 162 | + * Rewrite the stored values, one column at a time. |
| 163 | + * |
| 164 | + * @param IOutput $output Repair output. |
| 165 | + * |
| 166 | + * @return void |
| 167 | + */ |
| 168 | + public function run(IOutput $output): void { |
| 169 | + $tables = $this->shardTables(); |
| 170 | + if ($tables === []) { |
| 171 | + $output->info('RenameDutchCatalogValues: no SoftwareCatalog shard tables on this install; nothing to do.'); |
| 172 | + return; |
| 173 | + } |
| 174 | + |
| 175 | + $updated = 0; |
| 176 | + foreach ($tables as $table) { |
| 177 | + $planned = $this->decisions->plannedRewrites( |
| 178 | + valueMap: self::VALUE_MAP, |
| 179 | + columns: $this->columnsOf(table: $table) |
| 180 | + ); |
| 181 | + |
| 182 | + foreach ($planned as $job) { |
| 183 | + $updated += $this->rewrite( |
| 184 | + table: $table, |
| 185 | + column: $job['column'], |
| 186 | + old: $job['old'], |
| 187 | + new: $job['new'] |
| 188 | + ); |
| 189 | + } |
| 190 | + } |
| 191 | + |
| 192 | + $output->info(sprintf('RenameDutchCatalogValues: %d row value(s) translated.', $updated)); |
| 193 | + }//end run() |
| 194 | + |
| 195 | + /** |
| 196 | + * Rewrite one value in one column. |
| 197 | + * |
| 198 | + * @param string $table Shard table. |
| 199 | + * @param string $column Column name. |
| 200 | + * @param string $old Stored Dutch value. |
| 201 | + * @param string $new English replacement. |
| 202 | + * |
| 203 | + * @return int Rows affected. |
| 204 | + */ |
| 205 | + private function rewrite(string $table, string $column, string $old, string $new): int { |
| 206 | + $sql = 'UPDATE ' . $this->quote(identifier: $table) |
| 207 | + . ' SET ' . $this->quote(identifier: $column) . ' = ?' |
| 208 | + . ' WHERE ' . $this->quote(identifier: $column) . ' = ?'; |
| 209 | + |
| 210 | + try { |
| 211 | + return $this->db->executeStatement($sql, [$new, $old]); |
| 212 | + } catch (Exception $e) { |
| 213 | + $this->logger->warning( |
| 214 | + 'RenameDutchCatalogValues: value rewrite failed.', |
| 215 | + ['table' => $table, 'column' => $column, 'exception' => $e->getMessage()] |
| 216 | + ); |
| 217 | + return 0; |
| 218 | + } |
| 219 | + }//end rewrite() |
| 220 | + |
| 221 | + /** |
| 222 | + * Discover this app's shard tables. |
| 223 | + * |
| 224 | + * Anchors on the `openregister_table_` marker rather than a computed |
| 225 | + * prefix: OCP\IDBConnection exposes neither getSchema() nor getPrefix(), |
| 226 | + * and calling them is a runtime fatal that only phpstan catches. |
| 227 | + * |
| 228 | + * @return array<int, string> |
| 229 | + */ |
| 230 | + private function shardTables(): array { |
| 231 | + try { |
| 232 | + $stmt = $this->db->prepare( |
| 233 | + 'SELECT table_name FROM information_schema.tables WHERE table_name LIKE :pattern' |
| 234 | + ); |
| 235 | + $stmt->bindValue('pattern', '%openregister\_table\_%'); |
| 236 | + $stmt->execute(); |
| 237 | + $rows = $stmt->fetchAll(); |
| 238 | + } catch (\Throwable $e) { |
| 239 | + $this->logger->warning( |
| 240 | + 'RenameDutchCatalogValues: could not list tables; skipping.', |
| 241 | + ['exception' => $e->getMessage()] |
| 242 | + ); |
| 243 | + return []; |
| 244 | + } |
| 245 | + |
| 246 | + return array_map(static fn (array $r): string => (string)$r['table_name'], $rows); |
| 247 | + }//end shardTables() |
| 248 | + |
| 249 | + /** |
| 250 | + * Read a table's column names. |
| 251 | + * |
| 252 | + * @param string $table Table name. |
| 253 | + * |
| 254 | + * @return array<int, string> |
| 255 | + */ |
| 256 | + private function columnsOf(string $table): array { |
| 257 | + try { |
| 258 | + $stmt = $this->db->prepare( |
| 259 | + 'SELECT column_name FROM information_schema.columns WHERE table_name = :table' |
| 260 | + ); |
| 261 | + $stmt->bindValue('table', $table); |
| 262 | + $stmt->execute(); |
| 263 | + $rows = $stmt->fetchAll(); |
| 264 | + } catch (\Throwable $e) { |
| 265 | + $this->logger->warning( |
| 266 | + 'RenameDutchCatalogValues: could not read columns; skipping table.', |
| 267 | + ['table' => $table, 'exception' => $e->getMessage()] |
| 268 | + ); |
| 269 | + return []; |
| 270 | + } |
| 271 | + |
| 272 | + return array_map(static fn (array $r): string => (string)$r['column_name'], $rows); |
| 273 | + }//end columnsOf() |
| 274 | + |
| 275 | + /** |
| 276 | + * Quote an identifier for the active platform. |
| 277 | + * |
| 278 | + * @param string $identifier Table or column name. |
| 279 | + * |
| 280 | + * @return string |
| 281 | + */ |
| 282 | + private function quote(string $identifier): string { |
| 283 | + return $this->db->getDatabasePlatform()->quoteSingleIdentifier($identifier); |
| 284 | + }//end quote() |
| 285 | +}//end class |
0 commit comments