Browse Source

First commit, works on dev.

main
Rosie Le Faive 2 weeks ago
commit
32cad49529
  1. 23
      README.md
  2. 101
      src/Controller/ZombieReferenceHunterController.php
  3. 190
      src/Service/ZombieReferenceHunterQuery.php
  4. 5
      zombie_reference_hunter.info.yml
  5. 6
      zombie_reference_hunter.routing.yml
  6. 12
      zombie_reference_hunter.services.yml

23
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

101
src/Controller/ZombieReferenceHunterController.php

@ -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();
}
}

190
src/Service/ZombieReferenceHunterQuery.php

@ -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;
}
}

5
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

6
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'

12
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']
Loading…
Cancel
Save