commit
32cad49529
6 changed files with 337 additions and 0 deletions
@ -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 |
||||
|
||||
@ -0,0 +1,101 @@
|
||||
<?php |
||||
|
||||
namespace Drupal\zombie_reference_hunter\Controller; |
||||
|
||||
use Drupal\Core\Access\AccessResult; |
||||
use Drupal\Core\Controller\ControllerBase; |
||||
use Drupal\Core\Messenger\MessengerTrait; |
||||
use Drupal\Core\Session\AccountInterface; |
||||
use Drupal\zombie_reference_hunter\Service\ZombieReferenceHunterQuery; |
||||
use Symfony\Component\DependencyInjection\ContainerInterface; |
||||
|
||||
/** |
||||
* Creating a controller. |
||||
*/ |
||||
class ZombieReferenceHunterController extends ControllerBase |
||||
{ |
||||
use MessengerTrait; |
||||
|
||||
/** |
||||
* The integrity checker query service |
||||
* @var \Drupal\zombie_reference_hunter\Service\ZombieReferenceHunterQuery |
||||
*/ |
||||
protected ZombieReferenceHunterQuery $query; |
||||
|
||||
/** |
||||
* Constructs a new IntegrityReportController. |
||||
*/ |
||||
public function __construct(ZombieReferenceHunterQuery $query) { |
||||
$this->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(); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,190 @@
|
||||
<?php |
||||
|
||||
namespace Drupal\zombie_reference_hunter\Service; |
||||
|
||||
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException; |
||||
use Drupal\Component\Plugin\Exception\PluginNotFoundException; |
||||
use Drupal\Core\Entity\EntityFieldManagerInterface; |
||||
use Drupal\Core\Entity\EntityTypeManagerInterface; |
||||
use Drupal\Core\Logger\LoggerChannelInterface; |
||||
|
||||
class ZombieReferenceHunterQuery |
||||
{ |
||||
/** |
||||
* Default number of entities loaded per batch. |
||||
*/ |
||||
const BATCH_SIZE = 200; |
||||
/** |
||||
* The entity type manager. |
||||
* |
||||
* @var \Drupal\Core\Entity\EntityTypeManagerInterface |
||||
*/ |
||||
protected EntityTypeManagerInterface $entityTypeManager; |
||||
|
||||
/** |
||||
* The entity field manager. |
||||
* |
||||
* @var \Drupal\Core\Entity\EntityFieldManagerInterface |
||||
*/ |
||||
protected EntityFieldManagerInterface $entityFieldManager; |
||||
|
||||
/** |
||||
* The logger channel. |
||||
* |
||||
* @var \Drupal\Core\Logger\LoggerChannelInterface |
||||
*/ |
||||
protected LoggerChannelInterface $logger; |
||||
|
||||
/** |
||||
* Constructs a new IntegrityChecker. |
||||
* |
||||
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager |
||||
* The entity type manager. |
||||
* @param \Drupal\Core\Entity\EntityFieldManagerInterface $entityFieldManager |
||||
* The entity field manager. |
||||
* @param \Drupal\Core\Logger\LoggerChannelInterface $logger |
||||
* The logger channel. |
||||
*/ |
||||
public function __construct( |
||||
EntityTypeManagerInterface $entityTypeManager, |
||||
EntityFieldManagerInterface $entityFieldManager, |
||||
LoggerChannelInterface $logger |
||||
) { |
||||
$this->entityTypeManager = $entityTypeManager; |
||||
$this->entityFieldManager = $entityFieldManager; |
||||
$this->logger = $logger; |
||||
} |
||||
|
||||
/** |
||||
* Returns the field map for all entity reference field types. |
||||
* |
||||
* @return array<string, array<string, array<string, mixed>>> |
||||
* 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<int, array<string, string|int>> |
||||
* 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; |
||||
} |
||||
} |
||||
@ -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 |
||||
@ -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' |
||||
@ -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'] |
||||
Loading…
Reference in new issue