From dbf04ac7c8e5825d31e3fc34488884da7e45a89e Mon Sep 17 00:00:00 2001
From: Raf Philtjens <raf@rafatwork.be>
Date: Wed, 16 Apr 2025 16:35:31 +0200
Subject: [PATCH 01/14] Add tracker plugin to register usage in paragraphs on
 the host

---
 src/EntityUpdateManager.php                |   7 +-
 src/EntityUpdateManagerInterface.php       |   8 ++
 src/EntityUsageTrackBase.php               |   2 +-
 src/Plugin/EntityUsage/Track/Paragraph.php | 116 +++++++++++++++++++++
 4 files changed, 127 insertions(+), 6 deletions(-)
 create mode 100644 src/Plugin/EntityUsage/Track/Paragraph.php

diff --git a/src/EntityUpdateManager.php b/src/EntityUpdateManager.php
index 71e1768..f8717ca 100644
--- a/src/EntityUpdateManager.php
+++ b/src/EntityUpdateManager.php
@@ -215,12 +215,9 @@ class EntityUpdateManager implements EntityUpdateManagerInterface {
   }
 
   /**
-   * Gets the enabled tracking plugins, all plugins are enabled by default.
-   *
-   * @return array<string, \Drupal\entity_usage\EntityUsageTrackInterface>
-   *   The enabled plugin instances keyed by plugin ID.
+   * {@inheritdoc}
    */
-  protected function getEnabledPlugins(): array {
+  public function getEnabledPlugins(): array {
     $all_plugin_ids = array_keys($this->trackManager->getDefinitions());
     $enabled_plugins = $this->config->get('track_enabled_plugins');
     $enabled_plugin_ids = is_array($enabled_plugins) ? $enabled_plugins : $all_plugin_ids;
diff --git a/src/EntityUpdateManagerInterface.php b/src/EntityUpdateManagerInterface.php
index 79e2d1d..414ea57 100644
--- a/src/EntityUpdateManagerInterface.php
+++ b/src/EntityUpdateManagerInterface.php
@@ -43,4 +43,12 @@ interface EntityUpdateManagerInterface {
    */
   public function trackUpdateOnDeletion(EntityInterface $entity, $type = 'default'): void;
 
+  /**
+   * Gets the enabled tracking plugins, all plugins are enabled by default.
+   *
+   * @return array<string, \Drupal\entity_usage\EntityUsageTrackInterface>
+   *   The enabled plugin instances keyed by plugin ID.
+   */
+  public function getEnabledPlugins(): array;
+
 }
diff --git a/src/EntityUsageTrackBase.php b/src/EntityUsageTrackBase.php
index eddcf77..0d0fdc6 100644
--- a/src/EntityUsageTrackBase.php
+++ b/src/EntityUsageTrackBase.php
@@ -566,7 +566,7 @@ abstract class EntityUsageTrackBase extends PluginBase implements EntityUsageTra
    * @param string $field_name
    *   The field name that caused the exception.
    */
-  private function logTrackingException(\Exception $e, EntityInterface $source_entity, string $field_name): void {
+  protected function logTrackingException(\Exception $e, EntityInterface $source_entity, string $field_name): void {
     Error::logException(
       $this->entityUsageLogger,
       $e,
diff --git a/src/Plugin/EntityUsage/Track/Paragraph.php b/src/Plugin/EntityUsage/Track/Paragraph.php
new file mode 100644
index 0000000..a2f331d
--- /dev/null
+++ b/src/Plugin/EntityUsage/Track/Paragraph.php
@@ -0,0 +1,116 @@
+<?php
+
+namespace Drupal\entity_usage\Plugin\EntityUsage\Track;
+
+use Drupal\Core\Entity\TranslatableInterface;
+use Drupal\Core\Field\FieldItemInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\entity_usage\EntityUpdateManagerInterface;
+use Drupal\entity_usage\EntityUsageTrackMultipleLoadInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Tracks usage of entities referenced in referenced paragraphs.
+ *
+ * @EntityUsageTrack(
+ *   id = "paragraph",
+ *   label = @Translation("Paragraph"),
+ *   description = @Translation("Tracks relationships created within referenced paragraphs."),
+ *   field_types = {
+ *     "entity_reference_revisions",
+ *   },
+ *   source_entity_class = "Drupal\Core\Entity\FieldableEntityInterface",
+ * )
+ */
+class Paragraph extends EntityReference implements EntityUsageTrackMultipleLoadInterface {
+
+  /**
+   * The entity update manager.
+   */
+  protected EntityUpdateManagerInterface $entityUpdateManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
+    $tracker = parent::create($container, $configuration, $plugin_id, $plugin_definition);
+    $tracker->entityUpdateManager = $container->get('entity_usage.entity_update_manager');
+    return $tracker;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getTargetEntities(FieldItemInterface $item): array {
+    return $this->doGetTargetEntities($item->getParent(), $item);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getTargetEntitiesFromField(FieldItemListInterface $field): array {
+    return $this->doGetTargetEntities($field);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  private function doGetTargetEntities(FieldItemListInterface $field, ?FieldItemInterface $field_item = NULL): array {
+    $target_entities = [];
+
+    $target_type_id = $field->getFieldDefinition()->getSetting('target_type');
+    if ($target_type_id !== 'paragraph') {
+      return $target_entities;
+    }
+
+    /** @var \Drupal\Core\Entity\EntityInterface $parent_entity */
+    $parent_entity = $field->getParent()->getValue();
+    $referenced_paragraphs = $field_item instanceof FieldItemInterface
+      ? array_filter([$field_item->entity])
+      : $field->referencedEntities();
+    foreach ($referenced_paragraphs as $referenced_paragraph) {
+      // @todo Is this logic correct?
+      if ($referenced_paragraph instanceof TranslatableInterface
+        && $referenced_paragraph->language()->getId() !== $parent_entity->language()->getId()
+        && $referenced_paragraph->hasTranslation($parent_entity->language()->getId())
+      ) {
+        $referenced_paragraph = $referenced_paragraph->getTranslation($parent_entity->language()->getId());
+      }
+
+      foreach ($this->entityUpdateManager->getEnabledPlugins() as $plugin) {
+        $trackable_field_types = $plugin->getApplicableFieldTypes();
+        $fields = array_keys($this->getReferencingFields($referenced_paragraph, $trackable_field_types));
+        foreach ($fields as $field_name) {
+          if (!$referenced_paragraph->hasField($field_name) || $referenced_paragraph->{$field_name}->isEmpty()) {
+            continue;
+          }
+
+          try {
+            if ($plugin instanceof EntityUsageTrackMultipleLoadInterface) {
+              $target_entities = array_merge(
+                $target_entities,
+                $plugin->getTargetEntitiesFromField($referenced_paragraph->{$field_name}),
+              );
+            }
+            else {
+              /** @var \Drupal\Core\Field\FieldItemInterface $field_item */
+              foreach ($referenced_paragraph->get($field_name) as $field_item) {
+                $target_entities = array_merge(
+                  $target_entities,
+                  $plugin->getTargetEntities($field_item),
+                );
+              }
+            }
+          }
+          catch (\Exception $e) {
+            $this->logTrackingException($e, $referenced_paragraph, $field_name);
+            continue;
+          }
+        }
+      }
+    }
+
+    return $target_entities;
+  }
+
+}
-- 
GitLab


From ed12bab6cbf5633620534010095a946bbc60e68d Mon Sep 17 00:00:00 2001
From: Raf Philtjens <raf@rafatwork.be>
Date: Thu, 17 Apr 2025 07:17:30 +0200
Subject: [PATCH 02/14] Rename tracker plugin to make it more clear what it
 does

---
 .../Track/{Paragraph.php => ParagraphInherited.php}       | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)
 rename src/Plugin/EntityUsage/Track/{Paragraph.php => ParagraphInherited.php} (91%)

diff --git a/src/Plugin/EntityUsage/Track/Paragraph.php b/src/Plugin/EntityUsage/Track/ParagraphInherited.php
similarity index 91%
rename from src/Plugin/EntityUsage/Track/Paragraph.php
rename to src/Plugin/EntityUsage/Track/ParagraphInherited.php
index a2f331d..34afc26 100644
--- a/src/Plugin/EntityUsage/Track/Paragraph.php
+++ b/src/Plugin/EntityUsage/Track/ParagraphInherited.php
@@ -13,16 +13,16 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
  * Tracks usage of entities referenced in referenced paragraphs.
  *
  * @EntityUsageTrack(
- *   id = "paragraph",
- *   label = @Translation("Paragraph"),
- *   description = @Translation("Tracks relationships created within referenced paragraphs."),
+ *   id = "paragraph_inherited",
+ *   label = @Translation("Paragraph (inherited)"),
+ *   description = @Translation("Tracks entity relationships found inside direct and nested paragraphs as if they belong to the root parent entity."),
  *   field_types = {
  *     "entity_reference_revisions",
  *   },
  *   source_entity_class = "Drupal\Core\Entity\FieldableEntityInterface",
  * )
  */
-class Paragraph extends EntityReference implements EntityUsageTrackMultipleLoadInterface {
+class ParagraphInherited extends EntityReference implements EntityUsageTrackMultipleLoadInterface {
 
   /**
    * The entity update manager.
-- 
GitLab


From c97e2c71f37f2e34ab5a181389ae2cf1c39f7d23 Mon Sep 17 00:00:00 2001
From: Raf Philtjens <raf@rafatwork.be>
Date: Thu, 17 Apr 2025 15:57:11 +0200
Subject: [PATCH 03/14] Take direct paragraph updates into account

---
 entity_usage.module                           |   4 +
 entity_usage.services.yml                     |  10 +
 src/ParagraphInheritedUsageUpdater.php        | 173 ++++++++++++++++++
 .../EntityUsage/Track/ParagraphInherited.php  |  13 +-
 4 files changed, 199 insertions(+), 1 deletion(-)
 create mode 100644 src/ParagraphInheritedUsageUpdater.php

diff --git a/entity_usage.module b/entity_usage.module
index 2597e34..c4bf296 100644
--- a/entity_usage.module
+++ b/entity_usage.module
@@ -57,6 +57,7 @@ function entity_usage_entity_insert(EntityInterface $entity): void {
  * Implements hook_entity_update().
  */
 function entity_usage_entity_update(EntityInterface $entity): void {
+  \Drupal::service('entity_usage.paragraph_inherited_usage_updater')->queueEntity($entity);
   $entity_usage_update_manager = \Drupal::service('entity_usage.entity_update_manager');
   assert($entity_usage_update_manager instanceof EntityUpdateManagerInterface);
   $entity_usage_update_manager->trackUpdateOnEdition($entity);
@@ -70,6 +71,7 @@ function entity_usage_entity_update(EntityInterface $entity): void {
  * Implements hook_entity_predelete().
  */
 function entity_usage_entity_predelete(EntityInterface $entity): void {
+  \Drupal::service('entity_usage.paragraph_inherited_usage_updater')->queueEntity($entity);
   $entity_usage_update_manager = \Drupal::service('entity_usage.entity_update_manager');
   assert($entity_usage_update_manager instanceof EntityUpdateManagerInterface);
   $entity_usage_update_manager->trackUpdateOnDeletion($entity);
@@ -79,6 +81,7 @@ function entity_usage_entity_predelete(EntityInterface $entity): void {
  * Implements hook_entity_translation_delete().
  */
 function entity_usage_entity_translation_delete(EntityInterface $translation): void {
+  \Drupal::service('entity_usage.paragraph_inherited_usage_updater')->queueEntity($translation);
   $entity_usage_update_manager = \Drupal::service('entity_usage.entity_update_manager');
   assert($entity_usage_update_manager instanceof EntityUpdateManagerInterface);
   $entity_usage_update_manager->trackUpdateOnDeletion($translation, 'translation');
@@ -88,6 +91,7 @@ function entity_usage_entity_translation_delete(EntityInterface $translation): v
  * Implements hook_entity_revision_delete().
  */
 function entity_usage_entity_revision_delete(EntityInterface $entity): void {
+  \Drupal::service('entity_usage.paragraph_inherited_usage_updater')->queueEntity($entity);
   $entity_usage_update_manager = \Drupal::service('entity_usage.entity_update_manager');
   assert($entity_usage_update_manager instanceof EntityUpdateManagerInterface);
   $entity_usage_update_manager->trackUpdateOnDeletion($entity, 'revision');
diff --git a/entity_usage.services.yml b/entity_usage.services.yml
index 8103e64..0bf49c1 100644
--- a/entity_usage.services.yml
+++ b/entity_usage.services.yml
@@ -50,6 +50,7 @@ services:
 
   Drupal\entity_usage\PreSaveUrlRecorder: ~
   Drupal\entity_usage\RecreateTrackingDataForFieldQueuer: ~
+  entity_usage.recreate_tracking_data_for_field_queuer: '@Drupal\entity_usage\RecreateTrackingDataForFieldQueuer'
 
   # By default we enable Entity and Language subscribers to the
   # \Drupal\entity_usage\Events\Events::URL_TO_ENTITY event. If the file and/or
@@ -57,3 +58,12 @@ services:
   # \Drupal\entity_usage\EntityUsageServiceProvider::register().
   Drupal\entity_usage\UrlToEntityIntegrations\EntityRouting: ~
   Drupal\entity_usage\UrlToEntityIntegrations\LanguageIntegration: ~
+
+  entity_usage.paragraph_inherited_usage_updater:
+    class: Drupal\entity_usage\ParagraphInheritedUsageUpdater
+    arguments:
+      - '@entity_type.manager'
+      - '@plugin.manager.entity_usage.track'
+      - '@entity_usage.recreate_tracking_data_for_field_queuer'
+    tags:
+      - { name: needs_destruction }
diff --git a/src/ParagraphInheritedUsageUpdater.php b/src/ParagraphInheritedUsageUpdater.php
new file mode 100644
index 0000000..7cca29a
--- /dev/null
+++ b/src/ParagraphInheritedUsageUpdater.php
@@ -0,0 +1,173 @@
+<?php
+
+namespace Drupal\entity_usage;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\RevisionableStorageInterface;
+use Drupal\paragraphs\ParagraphInterface;
+
+/**
+ * Updates usage info for outer host entities of paragraphs.
+ *
+ * This service complements the "paragraph_inherited" entity usage tracker
+ * plugin, which automatically updates usage when a host entity is modified.
+ *
+ * When a paragraph is updated directly — without triggering an update
+ * on its host entity — this service makes sure the host entity's usage data
+ * is updated accordingly.
+ *
+ * @internal
+ */
+class ParagraphInheritedUsageUpdater {
+
+  /**
+   * ParagraphInheritedUsageUpdater constructor.
+   */
+  public function __construct(
+    protected EntityTypeManagerInterface $entityTypeManager,
+    protected EntityUsageTrackManager $entityUsageTrackManager,
+    protected RecreateTrackingDataForFieldQueuer $recreateTrackingDataForFieldQueuer,
+  ) {}
+
+  /**
+   * A list of paragraph revision IDs for which the usage must be recalculated.
+   *
+   * @var int[]
+   */
+  protected array $paragraphRevisionIds = [];
+
+  /**
+   * Records a paragraph's revision.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to record.
+   */
+  public function queueEntity(EntityInterface $entity): void {
+    if ($entity instanceof ParagraphInterface) {
+      $this->paragraphRevisionIds[$entity->getRevisionId()] = $entity->getRevisionId();
+    }
+  }
+
+  /**
+   * Removes a paragraph revision.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The paragraph revision to remove.
+   */
+  public function removeEntityFromQueue(EntityInterface $entity): void {
+    if ($entity instanceof ParagraphInterface) {
+      unset($this->paragraphRevisionIds[$entity->getRevisionId()]);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function destruct() {
+    if (empty($this->paragraphRevisionIds)) {
+      return;
+    }
+    $paragraph_revisions = $this
+      ->entityTypeManager
+      ->getStorage('paragraph')
+      ->loadMultipleRevisions($this->paragraphRevisionIds);
+    foreach ($paragraph_revisions as $paragraph_revision) {
+      $outer_host_revisions = $this->getParagraphOuterHostRevisions($paragraph_revision);
+      foreach ($outer_host_revisions as $outer_host_revision) {
+        $this
+          ->recreateTrackingDataForFieldQueuer
+          ->addRecord(
+            $outer_host_revision->getEntityTypeId(),
+            $outer_host_revision->id(),
+            $outer_host_revision->getRevisionId(),
+            'paragraph_inherited',
+            $paragraph_revision->get('parent_field_name')->value,
+          );
+      }
+    }
+  }
+
+  /**
+   * Gets the outermost host entity of a paragraph.
+   *
+   * Walks up the hierarchy until the top-level host is found.
+   *
+   * @param \Drupal\paragraphs\ParagraphInterface $paragraph_revision
+   *   The paragraph revision.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface[]|null
+   *   An array of entity revisions keyed by their revision ID, or an empty
+   *   array if none found.
+   */
+  public function getParagraphOuterHostRevisions(ParagraphInterface $paragraph_revision): array|null {
+    $outer_host_revisions = [];
+
+    $parent_revisions = $this->getParagraphParentRevisions($paragraph_revision);
+    while (!empty($parent_revisions)) {
+      foreach ($parent_revisions as $key => $parent_revision) {
+        if (!$parent_revision instanceof ParagraphInterface) {
+          $outer_host_revisions[] = $parent_revision;
+          unset($parent_revisions[$key]);
+        }
+      }
+
+      if (empty($parent_revisions)) {
+        break;
+      }
+
+      $next_parent_revisions = [];
+      foreach ($parent_revisions as $parent_revision) {
+        $next_parent_revisions = array_merge(
+          $next_parent_revisions,
+          $this->getParagraphParentRevisions($parent_revision),
+        );
+      }
+
+      $parent_revisions = array_filter($next_parent_revisions);
+    }
+
+    return $outer_host_revisions;
+  }
+
+  /**
+   * Gets all revisions that reference the given paragraph revision.
+   *
+   * @param \Drupal\paragraphs\ParagraphInterface $paragraph_revision
+   *   The paragraph revision.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface[]|null
+   *   An array of entity revisions keyed by their revision ID, or an empty
+   *   array if none found.
+   */
+  public function getParagraphParentRevisions(ParagraphInterface $paragraph_revision): array|null {
+    $parent_entity_type_id = $paragraph_revision->get('parent_type')->value;
+    $parent_field_name = $paragraph_revision->get('parent_field_name')->value;
+    if (empty($parent_entity_type_id) || empty($parent_field_name)) {
+      return NULL;
+    }
+
+    $parent_entity_type = $this
+      ->entityTypeManager
+      ->getDefinition($parent_entity_type_id);
+
+    $parent_entity_storage = $this
+      ->entityTypeManager
+      ->getStorage($parent_entity_type->id());
+
+    $parent_revision_ids = $parent_entity_storage
+      ->getQuery()
+      ->accessCheck(FALSE)
+      ->allRevisions()
+      ->condition("{$parent_field_name}.target_revision_id", $paragraph_revision->getRevisionId())
+      ->execute();
+    if (empty($parent_revision_ids)) {
+      return [];
+    }
+
+    return $parent_entity_type->isRevisionable() && $parent_entity_storage instanceof RevisionableStorageInterface
+      ? $parent_entity_storage->loadMultipleRevisions(array_keys($parent_revision_ids))
+      : $parent_entity_storage->loadMultiple($parent_revision_ids);
+  }
+
+}
diff --git a/src/Plugin/EntityUsage/Track/ParagraphInherited.php b/src/Plugin/EntityUsage/Track/ParagraphInherited.php
index 34afc26..a4b2acf 100644
--- a/src/Plugin/EntityUsage/Track/ParagraphInherited.php
+++ b/src/Plugin/EntityUsage/Track/ParagraphInherited.php
@@ -7,6 +7,7 @@ use Drupal\Core\Field\FieldItemInterface;
 use Drupal\Core\Field\FieldItemListInterface;
 use Drupal\entity_usage\EntityUpdateManagerInterface;
 use Drupal\entity_usage\EntityUsageTrackMultipleLoadInterface;
+use Drupal\entity_usage\ParagraphInheritedUsageUpdater;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
@@ -15,7 +16,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
  * @EntityUsageTrack(
  *   id = "paragraph_inherited",
  *   label = @Translation("Paragraph (inherited)"),
- *   description = @Translation("Tracks entity relationships found inside direct and nested paragraphs as if they belong to the root parent entity."),
+ *   description = @Translation("Tracks entity relationships found inside direct and nested paragraphs as if they belong to the (outer) host entity."),
  *   field_types = {
  *     "entity_reference_revisions",
  *   },
@@ -29,6 +30,11 @@ class ParagraphInherited extends EntityReference implements EntityUsageTrackMult
    */
   protected EntityUpdateManagerInterface $entityUpdateManager;
 
+  /**
+   * The paragraph inherited usage updated.
+   */
+  protected ParagraphInheritedUsageUpdater $paragraphInheritedUsageUpdater;
+
   /**
    * {@inheritdoc}
    */
@@ -65,6 +71,7 @@ class ParagraphInherited extends EntityReference implements EntityUsageTrackMult
 
     /** @var \Drupal\Core\Entity\EntityInterface $parent_entity */
     $parent_entity = $field->getParent()->getValue();
+    /** @var \Drupal\paragraphs\ParagraphInterface[] $referenced_paragraphs */
     $referenced_paragraphs = $field_item instanceof FieldItemInterface
       ? array_filter([$field_item->entity])
       : $field->referencedEntities();
@@ -108,6 +115,10 @@ class ParagraphInherited extends EntityReference implements EntityUsageTrackMult
           }
         }
       }
+
+      $this
+        ->paragraphInheritedUsageUpdater
+        ->removeEntityFromQueue($referenced_paragraph);
     }
 
     return $target_entities;
-- 
GitLab


From 459c0c82dc4f6f03488c3863d43c573e94926486 Mon Sep 17 00:00:00 2001
From: Raf Philtjens <raf@rafatwork.be>
Date: Thu, 17 Apr 2025 17:07:48 +0200
Subject: [PATCH 04/14] Actually initialize paragraphInheritedUsageUpdater

---
 src/Plugin/EntityUsage/Track/ParagraphInherited.php | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/Plugin/EntityUsage/Track/ParagraphInherited.php b/src/Plugin/EntityUsage/Track/ParagraphInherited.php
index a4b2acf..fe15a7c 100644
--- a/src/Plugin/EntityUsage/Track/ParagraphInherited.php
+++ b/src/Plugin/EntityUsage/Track/ParagraphInherited.php
@@ -41,6 +41,7 @@ class ParagraphInherited extends EntityReference implements EntityUsageTrackMult
   public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
     $tracker = parent::create($container, $configuration, $plugin_id, $plugin_definition);
     $tracker->entityUpdateManager = $container->get('entity_usage.entity_update_manager');
+    $tracker->paragraphInheritedUsageUpdater = $container->get('entity_usage.paragraph_inherited_usage_updater');
     return $tracker;
   }
 
-- 
GitLab


From cff58e0c2b5e969578880c5ae0279e6e2e823ded Mon Sep 17 00:00:00 2001
From: Raf Philtjens <raf@rafatwork.be>
Date: Thu, 17 Apr 2025 17:26:22 +0200
Subject: [PATCH 05/14] Add more documentation

---
 src/Plugin/EntityUsage/Track/ParagraphInherited.php | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/src/Plugin/EntityUsage/Track/ParagraphInherited.php b/src/Plugin/EntityUsage/Track/ParagraphInherited.php
index fe15a7c..2410971 100644
--- a/src/Plugin/EntityUsage/Track/ParagraphInherited.php
+++ b/src/Plugin/EntityUsage/Track/ParagraphInherited.php
@@ -117,6 +117,10 @@ class ParagraphInherited extends EntityReference implements EntityUsageTrackMult
         }
       }
 
+      // Since this paragraph has now been processed, we can safely remove it
+      // from the usage updater queue. This prevents the usage tracking from
+      // happening multiple times (for example, when a user saves a paragraph
+      // through the paragraphs field widget).
       $this
         ->paragraphInheritedUsageUpdater
         ->removeEntityFromQueue($referenced_paragraph);
-- 
GitLab


From 4b5de81b5ac9c5356270bf0b61660e42bc0738d2 Mon Sep 17 00:00:00 2001
From: Raf Philtjens <raf@rafatwork.be>
Date: Thu, 17 Apr 2025 18:39:52 +0200
Subject: [PATCH 06/14] Fix some return types

---
 src/ParagraphInheritedUsageUpdater.php | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/src/ParagraphInheritedUsageUpdater.php b/src/ParagraphInheritedUsageUpdater.php
index 7cca29a..a4cd5b4 100644
--- a/src/ParagraphInheritedUsageUpdater.php
+++ b/src/ParagraphInheritedUsageUpdater.php
@@ -96,11 +96,11 @@ class ParagraphInheritedUsageUpdater {
    * @param \Drupal\paragraphs\ParagraphInterface $paragraph_revision
    *   The paragraph revision.
    *
-   * @return \Drupal\Core\Entity\EntityInterface[]|null
+   * @return \Drupal\Core\Entity\EntityInterface[]
    *   An array of entity revisions keyed by their revision ID, or an empty
    *   array if none found.
    */
-  public function getParagraphOuterHostRevisions(ParagraphInterface $paragraph_revision): array|null {
+  public function getParagraphOuterHostRevisions(ParagraphInterface $paragraph_revision): array {
     $outer_host_revisions = [];
 
     $parent_revisions = $this->getParagraphParentRevisions($paragraph_revision);
@@ -136,15 +136,15 @@ class ParagraphInheritedUsageUpdater {
    * @param \Drupal\paragraphs\ParagraphInterface $paragraph_revision
    *   The paragraph revision.
    *
-   * @return \Drupal\Core\Entity\EntityInterface[]|null
+   * @return \Drupal\Core\Entity\EntityInterface[]
    *   An array of entity revisions keyed by their revision ID, or an empty
    *   array if none found.
    */
-  public function getParagraphParentRevisions(ParagraphInterface $paragraph_revision): array|null {
+  public function getParagraphParentRevisions(ParagraphInterface $paragraph_revision): array {
     $parent_entity_type_id = $paragraph_revision->get('parent_type')->value;
     $parent_field_name = $paragraph_revision->get('parent_field_name')->value;
     if (empty($parent_entity_type_id) || empty($parent_field_name)) {
-      return NULL;
+      return [];
     }
 
     $parent_entity_type = $this
-- 
GitLab


From 35327b878e2204194f69ce3b3e5c52221063fd63 Mon Sep 17 00:00:00 2001
From: Raf Philtjens <raf@rafatwork.be>
Date: Fri, 18 Apr 2025 13:38:34 +0200
Subject: [PATCH 07/14] A few fixes + allow to derive hierarchy from field name
 stored in entity usage table

---
 src/EntityUsage.php                           |  39 +++++--
 src/EntityUsageInterface.php                  |  15 ++-
 src/EntityUsageTrackBase.php                  | 101 +++++++++++++++---
 src/EntityUsageTrackInterface.php             |  11 +-
 src/ParagraphInheritedUsageUpdater.php        |  63 ++++++-----
 .../EntityUsage/Track/ParagraphInherited.php  |  30 ++++--
 6 files changed, 193 insertions(+), 66 deletions(-)

diff --git a/src/EntityUsage.php b/src/EntityUsage.php
index 1a8a371..43f825f 100644
--- a/src/EntityUsage.php
+++ b/src/EntityUsage.php
@@ -469,28 +469,55 @@ class EntityUsage implements EntityUsageBulkInterface {
   /**
    * {@inheritdoc}
    */
-  public function listTargetEntitiesByFieldAndMethod(string|int $source_id, string $source_entity_type_id, string $source_langcode, string|int $source_vid, string $method, string $field_name): array {
+  public function listTargetEntitiesByFieldAndMethod(string|int $source_id, string $source_entity_type_id, string $source_langcode, string|int $source_vid, string $method, string $field_name, bool $return_as_arrays = FALSE): array {
     // Entities can have string IDs. We support that by using different columns
     // on each case.
     $source_id_column = $this->isInt($source_id) ? 'source_id' : 'source_id_string';
     $query = $this->connection->select($this->tableName, 'e')
-      ->fields('e', [
+      ->fields('e', array_filter([
         'target_id',
         'target_id_string',
         'target_type',
-      ])
+        $return_as_arrays ? 'method' : NULL,
+        $return_as_arrays ? 'field_name' : NULL,
+      ]))
       ->condition($source_id_column, $source_id)
       ->condition('source_type', $source_entity_type_id)
       ->condition('source_vid', $source_vid ?: 0)
-      ->condition('field_name', $field_name)
-      ->condition('method', $method)
       ->condition('count', 0, '>')
       ->orderBy('target_id', 'DESC');
 
+    $query->condition(
+      $query
+        ->orConditionGroup()
+        ->condition('method', $method)
+        ->condition('method', "{$method}|%", 'LIKE')
+        ->condition('method', "%|{$method}", 'LIKE')
+        ->condition('method', "%|{$method}|%", 'LIKE')
+    );
+
+    $query->condition(
+      $query
+        ->orConditionGroup()
+        ->condition('field_name', $field_name)
+        ->condition('field_name', "{$field_name}|%", 'LIKE')
+        ->condition('field_name', "%|{$field_name}", 'LIKE')
+        ->condition('field_name', "%|{$field_name}|%", 'LIKE')
+        ->condition('field_name', "%|%:{$field_name}", 'LIKE')
+        ->condition('field_name', "%|%:{$field_name}|%", 'LIKE')
+    );
+
     $entities = [];
     foreach ($query->execute() as $usage) {
       $target_id_value = !empty($usage->target_id) ? $usage->target_id : $usage->target_id_string;
-      $entities[] = $usage->target_type . '|' . $target_id_value;
+      $entities[] = !$return_as_arrays
+        ? $usage->target_type . '|' . $target_id_value
+        : [
+          'target_type' => $usage->target_type,
+          'target_id' => $target_id_value,
+          'method' => $usage->method,
+          'field_name' => $usage->field_name,
+        ];
     }
     return $entities;
   }
diff --git a/src/EntityUsageInterface.php b/src/EntityUsageInterface.php
index 4d93183..bc47c2f 100644
--- a/src/EntityUsageInterface.php
+++ b/src/EntityUsageInterface.php
@@ -242,14 +242,19 @@ interface EntityUsageInterface {
    *   the plugin id.
    * @param string $field_name
    *   The field name.
+   * @param bool $return_as_arrays
+   *   If TRUE, the target entities are returned as arrays.
    *
-   * @return string[]
-   *   An indexed array of strings where each target entity type and ID are
-   *   concatenated with a "|" character. Will return an empty array if no
-   *   target entities found.
+   * @return array
+   *   An indexed array where each value is:
+   *   - if $return_as_arrays = FALSE: a string of the target entity type and ID
+   *     concatenated with a "|" character.
+   *   - if $return_as_arrays = TRUE: an array with the target_type, target_id,
+   *     method and field_name keys.
+   *  Will return an empty array if no target entities found.
    *
    * @see \Drupal\entity_usage\EntityUsageTrackInterface::getTargetEntities()
    */
-  public function listTargetEntitiesByFieldAndMethod(string|int $source_id, string $source_entity_type_id, string $source_langcode, string|int $source_vid, string $method, string $field_name): array;
+  public function listTargetEntitiesByFieldAndMethod(string|int $source_id, string $source_entity_type_id, string $source_langcode, string|int $source_vid, string $method, string $field_name, bool $return_as_arrays = FALSE): array;
 
 }
diff --git a/src/EntityUsageTrackBase.php b/src/EntityUsageTrackBase.php
index 0d0fdc6..750db54 100644
--- a/src/EntityUsageTrackBase.php
+++ b/src/EntityUsageTrackBase.php
@@ -241,13 +241,22 @@ abstract class EntityUsageTrackBase extends PluginBase implements EntityUsageTra
           $this->logTrackingException($e, $source_entity, $field_name);
           continue;
         }
-        // If a field references the same target entity, we record only one
-        // usage.
-        $target_entities = array_unique($target_entities);
+
         foreach ($target_entities as $target_entity) {
-          [$target_type, $target_id] = explode("|", $target_entity);
+          $normalized_target_entity = $this->normalizeTargetEntity($target_entity);
           $source_vid = ($source_entity instanceof RevisionableInterface && $source_entity->getRevisionId()) ? $source_entity->getRevisionId() : 0;
-          $this->usageService->registerUsage($target_id, $target_type, $source_entity->id(), $source_entity->getEntityTypeId(), $source_entity->language()->getId(), $source_vid, $this->pluginId, $field_name);
+          $this
+            ->usageService
+            ->registerUsage(
+              $normalized_target_entity['target_id'],
+              $normalized_target_entity['target_type'],
+              $source_entity->id(),
+              $source_entity->getEntityTypeId(),
+              $source_entity->language()->getId(),
+              $source_vid,
+              $normalized_target_entity['method'] ?? $this->pluginId,
+              $normalized_target_entity['field_name'] ?? $field_name,
+            );
         }
       }
     }
@@ -327,24 +336,64 @@ abstract class EntityUsageTrackBase extends PluginBase implements EntityUsageTra
       $this->logTrackingException($e, $source_entity, $field_name);
     }
 
+    foreach ($current_targets as $key => $current_target) {
+      unset($current_targets[$key]);
+      $current_target = $this->normalizeTargetEntity($current_target);
+      $new_key = implode('|', array_filter([
+        $current_target['target_type'],
+        $current_target['target_id'],
+        $current_target['field_name'] ?? $field_name,
+      ]));
+      $current_targets[$new_key] = $current_target;
+    }
+
     $source_entity_langcode = $source_entity->language()->getId();
     $source_vid = ($source_entity instanceof RevisionableInterface && $source_entity->getRevisionId()) ? $source_entity->getRevisionId() : 0;
-    $original_targets = $this->usageService->listTargetEntitiesByFieldAndMethod($source_entity->id(), $source_entity->getEntityTypeId(), $source_entity_langcode, $source_vid, $this->pluginId, $field_name);
-
-    // If a field references the same target entity, we record only one usage.
-    $original_targets = array_unique($original_targets);
-    $current_targets = array_unique($current_targets);
+    $original_targets = $this
+      ->usageService
+      ->listTargetEntitiesByFieldAndMethod($source_entity->id(), $source_entity->getEntityTypeId(), $source_entity_langcode, $source_vid, $this->pluginId, $field_name, TRUE);
+    foreach ($original_targets as $key => $original_target) {
+      unset($original_targets[$key]);
+      $original_target = $this->normalizeTargetEntity($original_target);
+      $new_key = implode('|', array_filter([
+        $original_target['target_type'],
+        $original_target['target_id'],
+        $original_target['field_name'] ?? $field_name,
+      ]));
+      $original_targets[$new_key] = $original_target;
+    }
 
-    $added_ids = array_diff($current_targets, $original_targets);
-    $removed_ids = array_diff($original_targets, $current_targets);
+    $added_ids = array_diff_key($current_targets, $original_targets);
+    $removed_ids = array_diff_key($original_targets, $current_targets);
 
     foreach ($added_ids as $added_entity) {
-      [$target_type, $target_id] = explode('|', $added_entity);
-      $this->usageService->registerUsage($target_id, $target_type, $source_entity->id(), $source_entity->getEntityTypeId(), $source_entity_langcode, $source_vid, $this->pluginId, $field_name);
+      $this
+        ->usageService
+        ->registerUsage(
+          $added_entity['target_id'],
+          $added_entity['target_type'],
+          $source_entity->id(),
+          $source_entity->getEntityTypeId(),
+          $source_entity_langcode,
+          $source_vid,
+          $added_entity['method'] ?? $this->pluginId,
+          $added_entity['field_name'] ?? $field_name,
+        );
     }
     foreach ($removed_ids as $removed_entity) {
-      [$target_type, $target_id] = explode('|', $removed_entity);
-      $this->usageService->registerUsage($target_id, $target_type, $source_entity->id(), $source_entity->getEntityTypeId(), $source_entity_langcode, $source_vid, $this->pluginId, $field_name, 0);
+      $this
+        ->usageService
+        ->registerUsage(
+          $removed_entity['target_id'],
+          $removed_entity['target_type'],
+          $source_entity->id(),
+          $source_entity->getEntityTypeId(),
+          $source_entity_langcode,
+          $source_vid,
+          $removed_entity['method'] ?? $this->pluginId,
+          $removed_entity['field_name'] ?? $field_name,
+          0
+        );
     }
   }
 
@@ -580,4 +629,24 @@ abstract class EntityUsageTrackBase extends PluginBase implements EntityUsageTra
     );
   }
 
+  /**
+   * Normalized a target entity.
+   *
+   * @param string|array $target_entity
+   *   The target array, which can either be a string or an array.
+   *
+   * @return array
+   *   If the target entity was already an array, it is returned unchanged. If
+   *   it was a string, the target entity is retured as an array with the
+   *   target_type and target_id keys.
+   */
+  protected function normalizeTargetEntity(string|array $target_entity): array {
+    return is_array($target_entity)
+      ? $target_entity
+      : array_combine(
+        ['target_type', 'target_id'],
+        explode('|', $target_entity)
+      );
+  }
+
 }
diff --git a/src/EntityUsageTrackInterface.php b/src/EntityUsageTrackInterface.php
index dac7dbb..eab342f 100644
--- a/src/EntityUsageTrackInterface.php
+++ b/src/EntityUsageTrackInterface.php
@@ -109,10 +109,19 @@ interface EntityUsageTrackInterface extends PluginInspectionInterface {
    * @param \Drupal\Core\Field\FieldItemInterface $item
    *   The field item to get the target entity(ies) from.
    *
-   * @return string[]
+   * @return string[]|array[]
    *   An indexed array of strings where each target entity type and ID are
    *   concatenated with a "|" character. Will return an empty array if no
    *   target entity could be retrieved from the received field item value.
+   *
+   * @return array
+   *   An indexed array where each value can be a string or an array:
+   *     - if a string: the target entity type and ID concatenated with a "|"
+   *       character.
+   *     - if an array: an array with the target_type and target_id keys, and
+   *       optionally the method and field_name keys.
+   *   Will return an empty array if no target entities could be retrieved from
+   *   the received field item value.
    */
   public function getTargetEntities(FieldItemInterface $item): array;
 
diff --git a/src/ParagraphInheritedUsageUpdater.php b/src/ParagraphInheritedUsageUpdater.php
index a4cd5b4..ec79987 100644
--- a/src/ParagraphInheritedUsageUpdater.php
+++ b/src/ParagraphInheritedUsageUpdater.php
@@ -73,74 +73,75 @@ class ParagraphInheritedUsageUpdater {
       ->getStorage('paragraph')
       ->loadMultipleRevisions($this->paragraphRevisionIds);
     foreach ($paragraph_revisions as $paragraph_revision) {
-      $outer_host_revisions = $this->getParagraphOuterHostRevisions($paragraph_revision);
+      $outer_host_revisions = $this->getParagraphOuterHostRevisionsFieldItemLists($paragraph_revision);
       foreach ($outer_host_revisions as $outer_host_revision) {
         $this
           ->recreateTrackingDataForFieldQueuer
           ->addRecord(
-            $outer_host_revision->getEntityTypeId(),
-            $outer_host_revision->id(),
-            $outer_host_revision->getRevisionId(),
+            $outer_host_revision->getEntity()->getEntityTypeId(),
+            $outer_host_revision->getEntity()->id(),
+            $outer_host_revision->getEntity()->getRevisionId(),
             'paragraph_inherited',
-            $paragraph_revision->get('parent_field_name')->value,
+            $outer_host_revision->getName(),
           );
       }
     }
   }
 
   /**
-   * Gets the outermost host entity of a paragraph.
+   * Gets the outermost host entity's field item list for a paragraph.
    *
-   * Walks up the hierarchy until the top-level host is found.
+   * Walks up the hierarchy until the top-level host is found. The field item
+   * list that starts the chain is returned.
    *
    * @param \Drupal\paragraphs\ParagraphInterface $paragraph_revision
    *   The paragraph revision.
    *
-   * @return \Drupal\Core\Entity\EntityInterface[]
-   *   An array of entity revisions keyed by their revision ID, or an empty
-   *   array if none found.
+   * @return \Drupal\Core\Field\FieldItemListInterface[]
+   *   An array of field item lists or an empty array if none found.
    */
-  public function getParagraphOuterHostRevisions(ParagraphInterface $paragraph_revision): array {
+  public function getParagraphOuterHostRevisionsFieldItemLists(ParagraphInterface $paragraph_revision): array {
     $outer_host_revisions = [];
 
-    $parent_revisions = $this->getParagraphParentRevisions($paragraph_revision);
-    while (!empty($parent_revisions)) {
-      foreach ($parent_revisions as $key => $parent_revision) {
+    $parent_revision_field_item_lists = $this->getParagraphParentRevisionsFieldItemLists($paragraph_revision);
+    while (!empty($parent_revision_field_item_lists)) {
+      foreach ($parent_revision_field_item_lists as $key => $parent_revision_field_item_list) {
+        $parent_revision = $parent_revision_field_item_list->getEntity();
         if (!$parent_revision instanceof ParagraphInterface) {
-          $outer_host_revisions[] = $parent_revision;
-          unset($parent_revisions[$key]);
+          $outer_host_revisions[] = $parent_revision_field_item_list;
+          unset($parent_revision_field_item_lists[$key]);
         }
       }
 
-      if (empty($parent_revisions)) {
+      if (empty($parent_revision_field_item_lists)) {
         break;
       }
 
-      $next_parent_revisions = [];
-      foreach ($parent_revisions as $parent_revision) {
-        $next_parent_revisions = array_merge(
-          $next_parent_revisions,
-          $this->getParagraphParentRevisions($parent_revision),
+      $next_parent_revision_field_item_lists = [];
+      foreach ($parent_revision_field_item_lists as $parent_revision_field_item_list) {
+        $next_parent_revision_field_item_lists = array_merge(
+          $next_parent_revision_field_item_lists,
+          $this->getParagraphParentRevisionsFieldItemLists($parent_revision_field_item_list->getEntity()),
         );
       }
 
-      $parent_revisions = array_filter($next_parent_revisions);
+      $parent_revision_field_item_lists = array_filter($next_parent_revision_field_item_lists);
     }
 
     return $outer_host_revisions;
   }
 
   /**
-   * Gets all revisions that reference the given paragraph revision.
+   * Gets all field item lists that reference the given paragraph revision.
    *
    * @param \Drupal\paragraphs\ParagraphInterface $paragraph_revision
    *   The paragraph revision.
    *
-   * @return \Drupal\Core\Entity\EntityInterface[]
-   *   An array of entity revisions keyed by their revision ID, or an empty
-   *   array if none found.
+   * @return \Drupal\Core\Field\FieldItemListInterface[]
+   *   An array of field item lists keyed by the paragraph revision's ID or an
+   *   empty array if none found.
    */
-  public function getParagraphParentRevisions(ParagraphInterface $paragraph_revision): array {
+  public function getParagraphParentRevisionsFieldItemLists(ParagraphInterface $paragraph_revision): array {
     $parent_entity_type_id = $paragraph_revision->get('parent_type')->value;
     $parent_field_name = $paragraph_revision->get('parent_field_name')->value;
     if (empty($parent_entity_type_id) || empty($parent_field_name)) {
@@ -165,9 +166,13 @@ class ParagraphInheritedUsageUpdater {
       return [];
     }
 
-    return $parent_entity_type->isRevisionable() && $parent_entity_storage instanceof RevisionableStorageInterface
+    $parent_revisions = $parent_entity_type->isRevisionable() && $parent_entity_storage instanceof RevisionableStorageInterface
       ? $parent_entity_storage->loadMultipleRevisions(array_keys($parent_revision_ids))
       : $parent_entity_storage->loadMultiple($parent_revision_ids);
+
+    return array_map(function (EntityInterface $parent_revision) use ($parent_field_name) {
+      return $parent_revision->get($parent_field_name);
+    }, $parent_revisions);
   }
 
 }
diff --git a/src/Plugin/EntityUsage/Track/ParagraphInherited.php b/src/Plugin/EntityUsage/Track/ParagraphInherited.php
index 2410971..5eaa21e 100644
--- a/src/Plugin/EntityUsage/Track/ParagraphInherited.php
+++ b/src/Plugin/EntityUsage/Track/ParagraphInherited.php
@@ -6,8 +6,10 @@ use Drupal\Core\Entity\TranslatableInterface;
 use Drupal\Core\Field\FieldItemInterface;
 use Drupal\Core\Field\FieldItemListInterface;
 use Drupal\entity_usage\EntityUpdateManagerInterface;
+use Drupal\entity_usage\EntityUsageTrackBase;
 use Drupal\entity_usage\EntityUsageTrackMultipleLoadInterface;
 use Drupal\entity_usage\ParagraphInheritedUsageUpdater;
+use Drupal\paragraphs\ParagraphInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
@@ -23,7 +25,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
  *   source_entity_class = "Drupal\Core\Entity\FieldableEntityInterface",
  * )
  */
-class ParagraphInherited extends EntityReference implements EntityUsageTrackMultipleLoadInterface {
+class ParagraphInherited extends EntityUsageTrackBase implements EntityUsageTrackMultipleLoadInterface {
 
   /**
    * The entity update manager.
@@ -93,20 +95,15 @@ class ParagraphInherited extends EntityReference implements EntityUsageTrackMult
             continue;
           }
 
+          $field_target_entities = [];
           try {
             if ($plugin instanceof EntityUsageTrackMultipleLoadInterface) {
-              $target_entities = array_merge(
-                $target_entities,
-                $plugin->getTargetEntitiesFromField($referenced_paragraph->{$field_name}),
-              );
+              $field_target_entities = $plugin->getTargetEntitiesFromField($referenced_paragraph->{$field_name});
             }
             else {
               /** @var \Drupal\Core\Field\FieldItemInterface $field_item */
               foreach ($referenced_paragraph->get($field_name) as $field_item) {
-                $target_entities = array_merge(
-                  $target_entities,
-                  $plugin->getTargetEntities($field_item),
-                );
+                $field_target_entities = $plugin->getTargetEntities($field_item);
               }
             }
           }
@@ -114,6 +111,13 @@ class ParagraphInherited extends EntityReference implements EntityUsageTrackMult
             $this->logTrackingException($e, $referenced_paragraph, $field_name);
             continue;
           }
+
+          foreach ($field_target_entities as $field_target_entity) {
+            $target_entities[] = $this->normalizeTargetEntity($field_target_entity) + [
+              'method' => "{$this->getPluginId()}|{$plugin->getPluginId()}",
+              'field_name' => "{$referenced_paragraph->getRevisionId()}:{$field_name}",
+            ];
+          }
         }
       }
 
@@ -126,6 +130,14 @@ class ParagraphInherited extends EntityReference implements EntityUsageTrackMult
         ->removeEntityFromQueue($referenced_paragraph);
     }
 
+    foreach ($target_entities as &$target_entity) {
+      $field_name = $field->getName();
+      if ($parent_entity instanceof ParagraphInterface) {
+        $field_name = "{$parent_entity->getRevisionId()}:{$field_name}";
+      }
+      $target_entity['field_name'] = "$field_name|{$target_entity['field_name']}";
+    }
+
     return $target_entities;
   }
 
-- 
GitLab


From 11abb2d84ca1f887f1f6808d49737ab7db0e6fe7 Mon Sep 17 00:00:00 2001
From: Raf Philtjens <raf@rafatwork.be>
Date: Tue, 22 Apr 2025 14:58:05 +0200
Subject: [PATCH 08/14] Increase field name column length

---
 entity_usage.install | 20 +++++++++++++++++++-
 1 file changed, 19 insertions(+), 1 deletion(-)

diff --git a/entity_usage.install b/entity_usage.install
index f573370..c8f4ed2 100644
--- a/entity_usage.install
+++ b/entity_usage.install
@@ -77,7 +77,7 @@ function entity_usage_schema(): array {
       'field_name' => [
         'description' => 'The field in the source entity containing the target entity.',
         'type' => 'varchar_ascii',
-        'length' => 128,
+        'length' => 1024,
         'not null' => TRUE,
         'default' => '',
       ],
@@ -116,3 +116,21 @@ function entity_usage_schema(): array {
 function entity_usage_update_last_removed(): int {
   return 8206;
 }
+
+/**
+ * Increase field_name length in entity_usage table.
+ */
+function entity_usage_update_8208() {
+  \Drupal::database()->schema()->changeField(
+    'entity_usage',
+    'field_name',
+    'field_name',
+    [
+      'type' => 'varchar_ascii',
+      'length' => 1024,
+      'not null' => TRUE,
+      'default' => '',
+      'description' => 'The field in the source entity containing the target entity.',
+    ]
+  );
+}
-- 
GitLab


From 693e22777052b9d4c49378548adb8336875917eb Mon Sep 17 00:00:00 2001
From: Raf Philtjens <raf@rafatwork.be>
Date: Thu, 24 Apr 2025 11:24:30 +0200
Subject: [PATCH 09/14] Spelling, phpcs and phpstan fixes

---
 entity_usage.install                          |  2 +-
 src/EntityUsageInterface.php                  |  2 +-
 src/EntityUsageTrackBase.php                  |  2 +-
 src/EntityUsageTrackInterface.php             |  7 +----
 src/ParagraphInheritedUsageUpdater.php        | 27 ++++++++++++-------
 .../EntityUsage/Track/ParagraphInherited.php  | 18 ++++++++++---
 6 files changed, 37 insertions(+), 21 deletions(-)

diff --git a/entity_usage.install b/entity_usage.install
index c8f4ed2..4fafe05 100644
--- a/entity_usage.install
+++ b/entity_usage.install
@@ -120,7 +120,7 @@ function entity_usage_update_last_removed(): int {
 /**
  * Increase field_name length in entity_usage table.
  */
-function entity_usage_update_8208() {
+function entity_usage_update_8208(): void {
   \Drupal::database()->schema()->changeField(
     'entity_usage',
     'field_name',
diff --git a/src/EntityUsageInterface.php b/src/EntityUsageInterface.php
index bc47c2f..9626b14 100644
--- a/src/EntityUsageInterface.php
+++ b/src/EntityUsageInterface.php
@@ -251,7 +251,7 @@ interface EntityUsageInterface {
    *     concatenated with a "|" character.
    *   - if $return_as_arrays = TRUE: an array with the target_type, target_id,
    *     method and field_name keys.
-   *  Will return an empty array if no target entities found.
+   *   Will return an empty array if no target entities found.
    *
    * @see \Drupal\entity_usage\EntityUsageTrackInterface::getTargetEntities()
    */
diff --git a/src/EntityUsageTrackBase.php b/src/EntityUsageTrackBase.php
index 750db54..546b875 100644
--- a/src/EntityUsageTrackBase.php
+++ b/src/EntityUsageTrackBase.php
@@ -637,7 +637,7 @@ abstract class EntityUsageTrackBase extends PluginBase implements EntityUsageTra
    *
    * @return array
    *   If the target entity was already an array, it is returned unchanged. If
-   *   it was a string, the target entity is retured as an array with the
+   *   it was a string, the target entity is returned as an array with the
    *   target_type and target_id keys.
    */
   protected function normalizeTargetEntity(string|array $target_entity): array {
diff --git a/src/EntityUsageTrackInterface.php b/src/EntityUsageTrackInterface.php
index eab342f..e87ab25 100644
--- a/src/EntityUsageTrackInterface.php
+++ b/src/EntityUsageTrackInterface.php
@@ -109,12 +109,7 @@ interface EntityUsageTrackInterface extends PluginInspectionInterface {
    * @param \Drupal\Core\Field\FieldItemInterface $item
    *   The field item to get the target entity(ies) from.
    *
-   * @return string[]|array[]
-   *   An indexed array of strings where each target entity type and ID are
-   *   concatenated with a "|" character. Will return an empty array if no
-   *   target entity could be retrieved from the received field item value.
-   *
-   * @return array
+   * @return string[]|array
    *   An indexed array where each value can be a string or an array:
    *     - if a string: the target entity type and ID concatenated with a "|"
    *       character.
diff --git a/src/ParagraphInheritedUsageUpdater.php b/src/ParagraphInheritedUsageUpdater.php
index ec79987..2612729 100644
--- a/src/ParagraphInheritedUsageUpdater.php
+++ b/src/ParagraphInheritedUsageUpdater.php
@@ -4,6 +4,8 @@ namespace Drupal\entity_usage;
 
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Entity\RevisionableInterface;
 use Drupal\Core\Entity\RevisionableStorageInterface;
 use Drupal\paragraphs\ParagraphInterface;
 
@@ -64,23 +66,27 @@ class ParagraphInheritedUsageUpdater {
   /**
    * {@inheritdoc}
    */
-  public function destruct() {
+  public function destruct(): void {
     if (empty($this->paragraphRevisionIds)) {
       return;
     }
-    $paragraph_revisions = $this
+    $paragraph_storage = $this
       ->entityTypeManager
-      ->getStorage('paragraph')
-      ->loadMultipleRevisions($this->paragraphRevisionIds);
+      ->getStorage('paragraph');
+    assert($paragraph_storage instanceof RevisionableStorageInterface);
+    $paragraph_revisions = $paragraph_storage->loadMultipleRevisions($this->paragraphRevisionIds);
     foreach ($paragraph_revisions as $paragraph_revision) {
       $outer_host_revisions = $this->getParagraphOuterHostRevisionsFieldItemLists($paragraph_revision);
       foreach ($outer_host_revisions as $outer_host_revision) {
+        $outer_host_entity = $outer_host_revision->getEntity();
         $this
           ->recreateTrackingDataForFieldQueuer
           ->addRecord(
-            $outer_host_revision->getEntity()->getEntityTypeId(),
-            $outer_host_revision->getEntity()->id(),
-            $outer_host_revision->getEntity()->getRevisionId(),
+            $outer_host_entity->getEntityTypeId(),
+            $outer_host_entity->id(),
+            $outer_host_entity instanceof RevisionableInterface && $outer_host_entity->getRevisionId()
+              ? $outer_host_entity->getRevisionId()
+              : 0,
             'paragraph_inherited',
             $outer_host_revision->getName(),
           );
@@ -97,7 +103,7 @@ class ParagraphInheritedUsageUpdater {
    * @param \Drupal\paragraphs\ParagraphInterface $paragraph_revision
    *   The paragraph revision.
    *
-   * @return \Drupal\Core\Field\FieldItemListInterface[]
+   * @return \Drupal\Core\Field\FieldItemListInterface<\Drupal\Core\Field\FieldItemInterface>[]
    *   An array of field item lists or an empty array if none found.
    */
   public function getParagraphOuterHostRevisionsFieldItemLists(ParagraphInterface $paragraph_revision): array {
@@ -119,9 +125,11 @@ class ParagraphInheritedUsageUpdater {
 
       $next_parent_revision_field_item_lists = [];
       foreach ($parent_revision_field_item_lists as $parent_revision_field_item_list) {
+        $parent_revision_field_item_list_entity = $parent_revision_field_item_list->getEntity();
+        assert($parent_revision_field_item_list_entity instanceof ParagraphInterface);
         $next_parent_revision_field_item_lists = array_merge(
           $next_parent_revision_field_item_lists,
-          $this->getParagraphParentRevisionsFieldItemLists($parent_revision_field_item_list->getEntity()),
+          $this->getParagraphParentRevisionsFieldItemLists($parent_revision_field_item_list_entity),
         );
       }
 
@@ -171,6 +179,7 @@ class ParagraphInheritedUsageUpdater {
       : $parent_entity_storage->loadMultiple($parent_revision_ids);
 
     return array_map(function (EntityInterface $parent_revision) use ($parent_field_name) {
+      assert($parent_revision instanceof FieldableEntityInterface);
       return $parent_revision->get($parent_field_name);
     }, $parent_revisions);
   }
diff --git a/src/Plugin/EntityUsage/Track/ParagraphInherited.php b/src/Plugin/EntityUsage/Track/ParagraphInherited.php
index 5eaa21e..5c4ee46 100644
--- a/src/Plugin/EntityUsage/Track/ParagraphInherited.php
+++ b/src/Plugin/EntityUsage/Track/ParagraphInherited.php
@@ -51,18 +51,30 @@ class ParagraphInherited extends EntityUsageTrackBase implements EntityUsageTrac
    * {@inheritdoc}
    */
   public function getTargetEntities(FieldItemInterface $item): array {
-    return $this->doGetTargetEntities($item->getParent(), $item);
+    $parent = $item->getParent();
+    return $parent instanceof FieldItemListInterface
+      ? $this->doGetTargetEntities($parent, $item)
+      : [];
   }
 
   /**
    * {@inheritdoc}
+   *
+   * @param \Drupal\Core\Field\FieldItemListInterface<\Drupal\Core\Field\FieldItemInterface> $field
    */
   public function getTargetEntitiesFromField(FieldItemListInterface $field): array {
     return $this->doGetTargetEntities($field);
   }
 
   /**
-   * {@inheritdoc}
+   * Gets the target entities.
+   *
+   * @param \Drupal\Core\Field\FieldItemListInterface<\Drupal\Core\Field\FieldItemInterface> $field
+   *   The field.
+   * @param \Drupal\Core\Field\FieldItemInterface|null $field_item
+   *   (optional) The field item.
+   *
+   * @return array<int, array<string, mixed>>
    */
   private function doGetTargetEntities(FieldItemListInterface $field, ?FieldItemInterface $field_item = NULL): array {
     $target_entities = [];
@@ -76,7 +88,7 @@ class ParagraphInherited extends EntityUsageTrackBase implements EntityUsageTrac
     $parent_entity = $field->getParent()->getValue();
     /** @var \Drupal\paragraphs\ParagraphInterface[] $referenced_paragraphs */
     $referenced_paragraphs = $field_item instanceof FieldItemInterface
-      ? array_filter([$field_item->entity])
+      ? array_filter([$field_item->entity ?? NULL])
       : $field->referencedEntities();
     foreach ($referenced_paragraphs as $referenced_paragraph) {
       // @todo Is this logic correct?
-- 
GitLab


From fdc246d58e743bc5f239203782133afdfc0ff826 Mon Sep 17 00:00:00 2001
From: Raf Philtjens <raf@rafatwork.be>
Date: Thu, 24 Apr 2025 11:38:41 +0200
Subject: [PATCH 10/14] phpcs and phpstan fixes

---
 src/EntityUsageTrackInterface.php                  | 10 +++++-----
 src/EntityUsageTrackMultipleLoadInterface.php      | 14 +++++++++-----
 src/ParagraphInheritedUsageUpdater.php             |  3 ++-
 .../EntityUsage/Track/ParagraphInherited.php       | 11 ++++++++---
 4 files changed, 24 insertions(+), 14 deletions(-)

diff --git a/src/EntityUsageTrackInterface.php b/src/EntityUsageTrackInterface.php
index e87ab25..b2ace6c 100644
--- a/src/EntityUsageTrackInterface.php
+++ b/src/EntityUsageTrackInterface.php
@@ -109,12 +109,12 @@ interface EntityUsageTrackInterface extends PluginInspectionInterface {
    * @param \Drupal\Core\Field\FieldItemInterface $item
    *   The field item to get the target entity(ies) from.
    *
-   * @return string[]|array
+   * @return array<int, string|array{target_type: string, target_id: string|int, field_name?: string, method?: string}>
    *   An indexed array where each value can be a string or an array:
-   *     - if a string: the target entity type and ID concatenated with a "|"
-   *       character.
-   *     - if an array: an array with the target_type and target_id keys, and
-   *       optionally the method and field_name keys.
+   *   - if a string: the target entity type and ID concatenated with a "|"
+   *     character.
+   *   - if an array: an array with the target_type and target_id keys, and
+   *     optionally the method and field_name keys.
    *   Will return an empty array if no target entities could be retrieved from
    *   the received field item value.
    */
diff --git a/src/EntityUsageTrackMultipleLoadInterface.php b/src/EntityUsageTrackMultipleLoadInterface.php
index 195261e..e9e0302 100644
--- a/src/EntityUsageTrackMultipleLoadInterface.php
+++ b/src/EntityUsageTrackMultipleLoadInterface.php
@@ -19,13 +19,17 @@ interface EntityUsageTrackMultipleLoadInterface extends EntityUsageTrackInterfac
   /**
    * Retrieve the target entity(ies) from a field.
    *
-   * @param \Drupal\Core\Field\FieldItemListInterface $field
+   * @param \Drupal\Core\Field\FieldItemListInterface<\Drupal\Core\Field\FieldItemInterface> $field
    *   The field to get the target entity(ies) from.
    *
-   * @return string[]
-   *   An indexed array of strings where each target entity type and ID are
-   *   concatenated with a "|" character. Will return an empty array if no
-   *   target entity could be retrieved from the received field item value.
+   * @return array<int, string|array{target_type: string, target_id: string|int, field_name?: string, method?: string}>
+   *   An indexed array where each value can be a string or an array:
+   *   - if a string: the target entity type and ID concatenated with a "|"
+  *      character.
+   *   - if an array: an array with the target_type and target_id keys, and
+   *     optionally the method and field_name keys.
+   *   Will return an empty array if no target entities could be retrieved from
+   *   the received field item value.
    */
   public function getTargetEntitiesFromField(FieldItemListInterface $field): array;
 
diff --git a/src/ParagraphInheritedUsageUpdater.php b/src/ParagraphInheritedUsageUpdater.php
index 2612729..c1ac2fa 100644
--- a/src/ParagraphInheritedUsageUpdater.php
+++ b/src/ParagraphInheritedUsageUpdater.php
@@ -76,6 +76,7 @@ class ParagraphInheritedUsageUpdater {
     assert($paragraph_storage instanceof RevisionableStorageInterface);
     $paragraph_revisions = $paragraph_storage->loadMultipleRevisions($this->paragraphRevisionIds);
     foreach ($paragraph_revisions as $paragraph_revision) {
+      assert($paragraph_revision instanceof ParagraphInterface);
       $outer_host_revisions = $this->getParagraphOuterHostRevisionsFieldItemLists($paragraph_revision);
       foreach ($outer_host_revisions as $outer_host_revision) {
         $outer_host_entity = $outer_host_revision->getEntity();
@@ -145,7 +146,7 @@ class ParagraphInheritedUsageUpdater {
    * @param \Drupal\paragraphs\ParagraphInterface $paragraph_revision
    *   The paragraph revision.
    *
-   * @return \Drupal\Core\Field\FieldItemListInterface[]
+   * @return \Drupal\Core\Field\FieldItemListInterface<\Drupal\Core\Field\FieldItemInterface>[]
    *   An array of field item lists keyed by the paragraph revision's ID or an
    *   empty array if none found.
    */
diff --git a/src/Plugin/EntityUsage/Track/ParagraphInherited.php b/src/Plugin/EntityUsage/Track/ParagraphInherited.php
index 5c4ee46..5b51270 100644
--- a/src/Plugin/EntityUsage/Track/ParagraphInherited.php
+++ b/src/Plugin/EntityUsage/Track/ParagraphInherited.php
@@ -59,8 +59,6 @@ class ParagraphInherited extends EntityUsageTrackBase implements EntityUsageTrac
 
   /**
    * {@inheritdoc}
-   *
-   * @param \Drupal\Core\Field\FieldItemListInterface<\Drupal\Core\Field\FieldItemInterface> $field
    */
   public function getTargetEntitiesFromField(FieldItemListInterface $field): array {
     return $this->doGetTargetEntities($field);
@@ -74,7 +72,14 @@ class ParagraphInherited extends EntityUsageTrackBase implements EntityUsageTrac
    * @param \Drupal\Core\Field\FieldItemInterface|null $field_item
    *   (optional) The field item.
    *
-   * @return array<int, array<string, mixed>>
+   * @return array<int, string|array{target_type: string, target_id: string|int, field_name?: string, method?: string}>
+   *   An indexed array where each value can be a string or an array:
+   *   - if a string: the target entity type and ID concatenated with a "|"
+   *     character.
+   *   - if an array: an array with the target_type and target_id keys, and
+   *     optionally the method and field_name keys.
+   *   Will return an empty array if no target entities could be retrieved from
+   *   the received field item value.
    */
   private function doGetTargetEntities(FieldItemListInterface $field, ?FieldItemInterface $field_item = NULL): array {
     $target_entities = [];
-- 
GitLab


From 88d4f7be5504a8d8c1819d1dcca9f8ad19b0a2c2 Mon Sep 17 00:00:00 2001
From: Raf Philtjens <raf@rafatwork.be>
Date: Thu, 24 Apr 2025 11:47:17 +0200
Subject: [PATCH 11/14] phpcs and phpstan fixes

---
 src/EntityUsageTrackMultipleLoadInterface.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/EntityUsageTrackMultipleLoadInterface.php b/src/EntityUsageTrackMultipleLoadInterface.php
index e9e0302..34c0a93 100644
--- a/src/EntityUsageTrackMultipleLoadInterface.php
+++ b/src/EntityUsageTrackMultipleLoadInterface.php
@@ -19,7 +19,7 @@ interface EntityUsageTrackMultipleLoadInterface extends EntityUsageTrackInterfac
   /**
    * Retrieve the target entity(ies) from a field.
    *
-   * @param \Drupal\Core\Field\FieldItemListInterface<\Drupal\Core\Field\FieldItemInterface> $field
+   * @param \Drupal\Core\Field\FieldItemListInterface $field
    *   The field to get the target entity(ies) from.
    *
    * @return array<int, string|array{target_type: string, target_id: string|int, field_name?: string, method?: string}>
-- 
GitLab


From 559f3c3c03dc283ae049107eb1b7bf7a9d068e63 Mon Sep 17 00:00:00 2001
From: Raf Philtjens <raf@rafatwork.be>
Date: Thu, 24 Apr 2025 11:54:08 +0200
Subject: [PATCH 12/14] phpcs fixes

---
 src/EntityUsageTrackMultipleLoadInterface.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/EntityUsageTrackMultipleLoadInterface.php b/src/EntityUsageTrackMultipleLoadInterface.php
index 34c0a93..04c6cff 100644
--- a/src/EntityUsageTrackMultipleLoadInterface.php
+++ b/src/EntityUsageTrackMultipleLoadInterface.php
@@ -25,7 +25,7 @@ interface EntityUsageTrackMultipleLoadInterface extends EntityUsageTrackInterfac
    * @return array<int, string|array{target_type: string, target_id: string|int, field_name?: string, method?: string}>
    *   An indexed array where each value can be a string or an array:
    *   - if a string: the target entity type and ID concatenated with a "|"
-  *      character.
+   *     character.
    *   - if an array: an array with the target_type and target_id keys, and
    *     optionally the method and field_name keys.
    *   Will return an empty array if no target entities could be retrieved from
-- 
GitLab


From ffe3a1be621d79b2dd6706e205ee8eb153ad3274 Mon Sep 17 00:00:00 2001
From: Raf Philtjens <raf@rafatwork.be>
Date: Thu, 24 Apr 2025 11:57:12 +0200
Subject: [PATCH 13/14] phpstan fixes

---
 src/EntityUsageTrackMultipleLoadInterface.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/EntityUsageTrackMultipleLoadInterface.php b/src/EntityUsageTrackMultipleLoadInterface.php
index 04c6cff..b81fe4c 100644
--- a/src/EntityUsageTrackMultipleLoadInterface.php
+++ b/src/EntityUsageTrackMultipleLoadInterface.php
@@ -19,7 +19,7 @@ interface EntityUsageTrackMultipleLoadInterface extends EntityUsageTrackInterfac
   /**
    * Retrieve the target entity(ies) from a field.
    *
-   * @param \Drupal\Core\Field\FieldItemListInterface $field
+   * @param \Drupal\Core\Field\FieldItemListInterface<\Drupal\Core\Field\FieldItemInterface> $field
    *   The field to get the target entity(ies) from.
    *
    * @return array<int, string|array{target_type: string, target_id: string|int, field_name?: string, method?: string}>
-- 
GitLab


From 545eafc05720f55214ab593e288ec295ac377f19 Mon Sep 17 00:00:00 2001
From: Raf Philtjens <raf@rafatwork.be>
Date: Thu, 24 Apr 2025 12:02:55 +0200
Subject: [PATCH 14/14] phpstan fixes

---
 src/EntityUsageTrackMultipleLoadInterface.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/EntityUsageTrackMultipleLoadInterface.php b/src/EntityUsageTrackMultipleLoadInterface.php
index b81fe4c..04c6cff 100644
--- a/src/EntityUsageTrackMultipleLoadInterface.php
+++ b/src/EntityUsageTrackMultipleLoadInterface.php
@@ -19,7 +19,7 @@ interface EntityUsageTrackMultipleLoadInterface extends EntityUsageTrackInterfac
   /**
    * Retrieve the target entity(ies) from a field.
    *
-   * @param \Drupal\Core\Field\FieldItemListInterface<\Drupal\Core\Field\FieldItemInterface> $field
+   * @param \Drupal\Core\Field\FieldItemListInterface $field
    *   The field to get the target entity(ies) from.
    *
    * @return array<int, string|array{target_type: string, target_id: string|int, field_name?: string, method?: string}>
-- 
GitLab