commit 32cad4952937ce90308b4c48f4ffc48d7945ea07 Author: Rosie Le Faive Date: Fri Aug 14 12:05:19 2026 -0300 First commit, works on dev. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e0e122f --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Zombie Reference Hunter + +Sometimes you delete a taxonomy term and things were still pointing to it. +You've got zombie references. They're kind of hard to track down. + +You could check out https://github.com/victorstack-ai/drupal-entity-reference-integrity, +though it probably won't work for non-base fields. +This module is mostly copied from that module, without the automatic wiping out of zombie references. + +This module is currently hardcoded to search only for [Typed Relation](https://www.drupal.org/project/controlled_access_terms/) issues, since +it's easy to forget that they [don't work with Term Merge](https://www.drupal.org/project/term_reference_change/issues/3231527) (leading to Term Merge deleting terms and creating zombie references). + +# Usage +1. install +2. Go to `/admin/zombies` to see the list of problems. +3. Fix the broken links manually. + +# Who to Blame +Rosie Le Faive (rlefaive@upei.ca) and the human behind / the AI Bot that made the original. + +# License +GPLv3 + diff --git a/src/Controller/ZombieReferenceHunterController.php b/src/Controller/ZombieReferenceHunterController.php new file mode 100644 index 0000000..e02b17b --- /dev/null +++ b/src/Controller/ZombieReferenceHunterController.php @@ -0,0 +1,101 @@ +query = $query; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): self { + return new static( + $container->get('zombie_reference_hunter.query') + ); + } + + /** + * Returns the Report page. + * + */ + public function view(): array + { + $issues = $this->query->scan(); + $count = count($issues); + $build = [ + '#cache' => ['max-age' => 0], + ]; + + $build['summary'] = [ + '#markup' => $this->t('Found @count broken reference(s).', ['@count' => $count]), + ]; + + if ($count === 0) { + $build['empty'] = [ + '#markup' => $this->t('No broken references found.'), + ]; + return $build; + } + + $rows = []; + foreach ($issues as $issue) { + $rows[] = [ + $issue['source_entity_type'], + $issue['source_id'], + $issue['field_name'], + $issue['field_type'], + $issue['target_type'], + $issue['target_id'], + ]; + } + + $build['table'] = [ + '#type' => 'table', + '#header' => [ + $this->t('Source entity type'), + $this->t('Source ID'), + $this->t('Field name'), + $this->t('Field type'), + $this->t('Target entity type'), + $this->t('Target ID'), + ], + '#rows' => $rows, + '#empty' => $this->t('No broken references found.'), + ]; + + return $build; + } + + public function access(AccountInterface $account) + { + if ($account->hasPermission('access content overview')) { + return AccessResult::allowed(); + } + return AccessResult::forbidden(); + } + +} diff --git a/src/Service/ZombieReferenceHunterQuery.php b/src/Service/ZombieReferenceHunterQuery.php new file mode 100644 index 0000000..63e9615 --- /dev/null +++ b/src/Service/ZombieReferenceHunterQuery.php @@ -0,0 +1,190 @@ +entityTypeManager = $entityTypeManager; + $this->entityFieldManager = $entityFieldManager; + $this->logger = $logger; + } + + /** + * Returns the field map for all entity reference field types. + * + * @return array>> + * Keyed by field type, then entity type, then field name. + */ + protected function getFieldMaps(): array { + return [ +// 'entity_reference' => $this->entityFieldManager->getFieldMapByFieldType('entity_reference'), +// 'entity_reference_revisions' => $this->entityFieldManager->getFieldMapByFieldType('entity_reference_revisions'), + 'typed_relation' => $this->entityFieldManager->getFieldMapByFieldType('typed_relation'), + ]; + } + + /** + * Scans for broken entity references. + * + * Entities are loaded in chunks of $batchSize to control memory usage + * on large sites. + * + * @param int $batchSize + * Number of entities to load per batch. Defaults to self::BATCH_SIZE. + * + * @return array> + * A list of broken references with source and target metadata. + */ + public function scan(int $batchSize = self::BATCH_SIZE): array { + $issues = []; + + foreach ($this->getFieldMaps() as $field_type => $field_map) { + foreach ($field_map as $entity_type_id => $fields) { + try { + $source_storage = $this->entityTypeManager->getStorage($entity_type_id); + $storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions($entity_type_id); + } + catch (PluginNotFoundException | InvalidPluginDefinitionException $exception) { + $this->logger->warning('Skipping entity type %type due to storage error: %message', [ + '%type' => $entity_type_id, + '%message' => $exception->getMessage(), + ]); + continue; + } + + foreach (array_keys($fields) as $field_name) { + if (!isset($storage_definitions[$field_name])) { + continue; + } + + $field_storage = $storage_definitions[$field_name]; +// $field_storage->isComputed() || !$field_storage->isQueryable()) { +// continue; +// } + + $target_type = $field_storage->getSetting('target_type'); + if (empty($target_type)) { + continue; + } + + try { + $target_storage = $this->entityTypeManager->getStorage($target_type); + } + catch (PluginNotFoundException | InvalidPluginDefinitionException $exception) { + $this->logger->warning('Skipping target type %type due to storage error: %message', [ + '%type' => $target_type, + '%message' => $exception->getMessage(), + ]); + continue; + } + + $query = $source_storage->getQuery()->accessCheck(FALSE); + $query->condition($field_name . '.target_id', NULL, 'IS NOT NULL'); + $entity_ids = array_values($query->execute()); + + if (empty($entity_ids)) { + continue; + } + + foreach (array_chunk($entity_ids, $batchSize) as $entity_id_chunk) { + $entities = $source_storage->loadMultiple($entity_id_chunk); + if (!$entities) { + continue; + } + + $target_ids = []; + foreach ($entities as $entity) { + if (!$entity->hasField($field_name)) { + continue; + } + foreach ($entity->get($field_name) as $item) { + $target_id = $item->target_id ?? NULL; + if ($target_id !== NULL && $target_id !== '') { + $target_ids[(string) $target_id] = $target_id; + } + } + } + + if (empty($target_ids)) { + continue; + } + + $existing_targets = $target_storage->loadMultiple(array_values($target_ids)); + $existing_lookup = array_fill_keys(array_keys($existing_targets), TRUE); + + foreach ($entities as $entity) { + if (!$entity->hasField($field_name)) { + continue; + } + $source_id = $entity->id(); + foreach ($entity->get($field_name) as $item) { + $target_id = $item->target_id ?? NULL; + if ($target_id === NULL || $target_id === '') { + continue; + } + if (!isset($existing_lookup[(string) $target_id])) { + $issues[] = [ + 'source_entity_type' => $entity_type_id, + 'source_id' => $source_id, + 'field_name' => $field_name, + 'field_type' => $field_type, + 'target_type' => $target_type, + 'target_id' => $target_id, + ]; + } + } + } + } + } + } + } + + return $issues; + } +} diff --git a/zombie_reference_hunter.info.yml b/zombie_reference_hunter.info.yml new file mode 100644 index 0000000..493c662 --- /dev/null +++ b/zombie_reference_hunter.info.yml @@ -0,0 +1,5 @@ +name: Zombie Reference Hunter +type: module +description: "Finds references to taxonomy terms that have been deleted." +package: custom +core_version_requirement: ^10 || ^11 diff --git a/zombie_reference_hunter.routing.yml b/zombie_reference_hunter.routing.yml new file mode 100644 index 0000000..018bea0 --- /dev/null +++ b/zombie_reference_hunter.routing.yml @@ -0,0 +1,6 @@ +zombie_reference_hunter.prepare: + path: '/admin/zombies' + defaults: + _controller: '\Drupal\zombie_reference_hunter\Controller\ZombieReferenceHunterController::view' + requirements: + _custom_access: '\Drupal\zombie_reference_hunter\Controller\ZombieReferenceHunterController::access' diff --git a/zombie_reference_hunter.services.yml b/zombie_reference_hunter.services.yml new file mode 100644 index 0000000..636e974 --- /dev/null +++ b/zombie_reference_hunter.services.yml @@ -0,0 +1,12 @@ +services: + zombie_reference_hunter.query: + class: Drupal\zombie_reference_hunter\Service\ZombieReferenceHunterQuery + _title: 'ZOMBIES' + arguments: + - '@entity_type.manager' + - '@entity_field.manager' + - '@logger.channel.zombie_reference_hunter' + + logger.channel.zombie_reference_hunter: + parent: logger.channel_base + arguments: ['zombie_reference_hunter']