diff --git a/modules/rl_menu_link/rl_menu_link.install b/modules/rl_menu_link/rl_menu_link.install new file mode 100644 index 0000000..f92102e --- /dev/null +++ b/modules/rl_menu_link/rl_menu_link.install @@ -0,0 +1,109 @@ +schema(); + $table = 'rl_menu_link_experiment'; + $entity_type_id = 'rl_menu_link_experiment'; + + // 1. Add the column if missing. NOT NULL with empty default so existing + // rows get a placeholder which we immediately backfill below. + if (!$schema->fieldExists($table, 'lookup_hash')) { + $schema->addField($table, 'lookup_hash', [ + 'type' => 'varchar', + 'length' => 64, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Computed hash of (menu_link_plugin_id | langcode) for indexed runtime lookup.', + ]); + } + + // 2. Backfill hashes from each row's (menu_link_plugin_id, langcode) + // using the same algorithm preSave() uses so new code's lookups match. + $rows = $database->select($table, 't') + ->fields('t', ['id', 'menu_link_plugin_id', 'langcode']) + ->execute() + ->fetchAll(); + foreach ($rows as $row) { + $plugin_id = (string) ($row->menu_link_plugin_id ?? ''); + $langcode = (string) ($row->langcode ?: LanguageInterface::LANGCODE_NOT_SPECIFIED); + $hash = MenuLinkExperiment::computeLookupHash($plugin_id, $langcode); + $database->update($table) + ->fields(['lookup_hash' => $hash]) + ->condition('id', $row->id) + ->execute(); + } + + // 3. Refuse to add the UNIQUE index if any pre-existing duplicate + // (plugin_id, langcode) rows exist. + $duplicates = $database->query( + "SELECT lookup_hash, COUNT(*) AS n FROM {" . $table . "} GROUP BY lookup_hash HAVING n > 1" + )->fetchAll(); + if ($duplicates) { + $hashes = array_map(static fn ($row) => $row->lookup_hash, $duplicates); + throw new UpdateException('rl_menu_link_experiment contains duplicate (menu_link_plugin_id, langcode) rows. Delete duplicates before re-running this update. Offending lookup_hash values: ' . implode(', ', $hashes)); + } + + // 4. Apply the UNIQUE key and secondary composite index declared by + // MenuLinkExperimentStorageSchema. indexExists() is true for unique + // keys on MySQL, so both branches are idempotent. + if (!$schema->indexExists($table, 'rl_menu_link_lookup_hash')) { + $schema->addUniqueKey($table, 'rl_menu_link_lookup_hash', ['lookup_hash']); + } + if (!$schema->indexExists($table, 'rl_menu_link_plugin_langcode')) { + $schema->addIndex($table, 'rl_menu_link_plugin_langcode', [['menu_link_plugin_id', 191], 'langcode'], [ + 'fields' => [ + 'menu_link_plugin_id' => [ + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + ], + 'langcode' => [ + 'type' => 'varchar', + 'length' => 12, + 'not null' => TRUE, + ], + ], + ]); + } + + // 5. Register the base field with Drupal's entity system. We bypass + // EntityDefinitionUpdateManager::installFieldStorageDefinition() here + // because it would try to re-apply the full storage schema (including + // the UNIQUE key we already added) and blow up on "key already exists". + // Writing directly to the last-installed schema repository records + // the field so getChangeSummary() stops reporting drift, without + // touching the SQL layer. + $lookup_hash_definition = BaseFieldDefinition::create('string') + ->setName('lookup_hash') + ->setTargetEntityTypeId($entity_type_id) + ->setLabel(t('Lookup hash')) + ->setDescription(t('Computed sha256(menu_link_plugin_id|langcode). Uniquely identifies an experiment for indexed runtime lookup.')) + ->setRequired(TRUE) + ->setSetting('max_length', 64) + ->setReadOnly(TRUE); + \Drupal::service('entity.last_installed_schema.repository') + ->setLastInstalledFieldStorageDefinition($lookup_hash_definition); + + return (string) t('Added lookup_hash column, backfilled @count existing rows, and applied hash-based indexes on rl_menu_link_experiment.', ['@count' => count($rows)]); +} diff --git a/modules/rl_page_title/rl_page_title.install b/modules/rl_page_title/rl_page_title.install new file mode 100644 index 0000000..13666cf --- /dev/null +++ b/modules/rl_page_title/rl_page_title.install @@ -0,0 +1,110 @@ +schema(); + $table = 'rl_page_title_experiment'; + $entity_type_id = 'rl_page_title_experiment'; + + // 1. Add the column if missing. NOT NULL with empty default so existing + // rows get a placeholder which we immediately backfill below. + if (!$schema->fieldExists($table, 'lookup_hash')) { + $schema->addField($table, 'lookup_hash', [ + 'type' => 'varchar', + 'length' => 64, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Computed hash of (normalized path | langcode) for indexed runtime lookup.', + ]); + } + + // 2. Backfill hashes from each row's (path, langcode) using the same + // algorithm preSave() uses so new code's lookups match the backfilled + // values exactly. + $rows = $database->select($table, 't') + ->fields('t', ['id', 'path', 'langcode']) + ->execute() + ->fetchAll(); + foreach ($rows as $row) { + $path = (string) ($row->path ?? ''); + $langcode = (string) ($row->langcode ?: LanguageInterface::LANGCODE_NOT_SPECIFIED); + $hash = PageTitleExperiment::computeLookupHash($path, $langcode); + $database->update($table) + ->fields(['lookup_hash' => $hash]) + ->condition('id', $row->id) + ->execute(); + } + + // 3. Refuse to add the UNIQUE index if any pre-existing duplicate + // (path, langcode) rows exist. Prior releases did form-level duplicate + // detection but pre-normalization rows may still collide. + $duplicates = $database->query( + "SELECT lookup_hash, COUNT(*) AS n FROM {" . $table . "} GROUP BY lookup_hash HAVING n > 1" + )->fetchAll(); + if ($duplicates) { + $hashes = array_map(static fn ($row) => $row->lookup_hash, $duplicates); + throw new UpdateException('rl_page_title_experiment contains duplicate (path, langcode) rows. Delete duplicates before re-running this update. Offending lookup_hash values: ' . implode(', ', $hashes)); + } + + // 4. Apply the UNIQUE key and secondary composite index declared by + // PageTitleExperimentStorageSchema. indexExists() is true for unique + // keys on MySQL, so both branches are idempotent. + if (!$schema->indexExists($table, 'rl_page_title_lookup_hash')) { + $schema->addUniqueKey($table, 'rl_page_title_lookup_hash', ['lookup_hash']); + } + if (!$schema->indexExists($table, 'rl_page_title_path_langcode')) { + $schema->addIndex($table, 'rl_page_title_path_langcode', [['path', 191], 'langcode'], [ + 'fields' => [ + 'path' => [ + 'type' => 'varchar', + 'length' => 2048, + 'not null' => FALSE, + ], + 'langcode' => [ + 'type' => 'varchar', + 'length' => 12, + 'not null' => TRUE, + ], + ], + ]); + } + + // 5. Register the base field with Drupal's entity system. We bypass + // EntityDefinitionUpdateManager::installFieldStorageDefinition() here + // because it would try to re-apply the full storage schema (including + // the UNIQUE key we already added) and blow up on "key already exists". + // Writing directly to the last-installed schema repository records + // the field so getChangeSummary() stops reporting drift, without + // touching the SQL layer. + $lookup_hash_definition = BaseFieldDefinition::create('string') + ->setName('lookup_hash') + ->setTargetEntityTypeId($entity_type_id) + ->setLabel(t('Lookup hash')) + ->setDescription(t('Computed sha256(path|langcode). Uniquely identifies an experiment for indexed runtime lookup.')) + ->setRequired(TRUE) + ->setSetting('max_length', 64) + ->setReadOnly(TRUE); + \Drupal::service('entity.last_installed_schema.repository') + ->setLastInstalledFieldStorageDefinition($lookup_hash_definition); + + return (string) t('Added lookup_hash column, backfilled @count existing rows, and applied hash-based indexes on rl_page_title_experiment.', ['@count' => count($rows)]); +}