diff --git a/entity_usage.install b/entity_usage.install index f573370053640cad27745297bf7f4a9c8d75d7d9..4fafe05637c760542652bd41c89a7c64399a0670 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(): void { + \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.', + ] + ); +} diff --git a/entity_usage.module b/entity_usage.module index 2597e34aea8cf7414370f15637e74d13f7989f27..c4bf296179b3f502b80cbf3bae5543dbe7a0f84e 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 8103e645492650646c9b10853f37396608bd277f..0bf49c109d2cb572957f426e994a48d1da48e006 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/EntityUpdateManager.php b/src/EntityUpdateManager.php index 71e1768a2ca6c4c56ce19900a3dccf398e26a676..f8717ca4e1a96165a9b02573ffe91fac30ee8127 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 79e2d1dd626ec9ec140db0559e0d837a50db6e82..414ea57cb1e18c1bf2826b88c43cced46806124f 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/EntityUsage.php b/src/EntityUsage.php index 1a8a371e3ccede7ef0f6b0eb1c1866665348c788..43f825fa6042d5a6a61333a91bd9cd01b0fd15c4 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 4d931836fd41ec9b609a149fa10b25b9d9175c46..9626b14093d61c186da1b03dbe9e7bd715ddb427 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 eddcf77840265f865ad365b2a3b2ddb220fa1730..546b875da0ee18e63f7c10d6904618e70e910351 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 + ); } } @@ -566,7 +615,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, @@ -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 returned 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 dac7dbb25d72489cd5d1d165bdf9905447c7e06a..b2ace6c3b8f7bb74f56b2a89da9396e99b6dfd14 100644 --- a/src/EntityUsageTrackInterface.php +++ b/src/EntityUsageTrackInterface.php @@ -109,10 +109,14 @@ interface EntityUsageTrackInterface extends PluginInspectionInterface { * @param \Drupal\Core\Field\FieldItemInterface $item * The field item 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 getTargetEntities(FieldItemInterface $item): array; diff --git a/src/EntityUsageTrackMultipleLoadInterface.php b/src/EntityUsageTrackMultipleLoadInterface.php index 195261e9bc4479d9e5fb72d0b55dbae856129314..04c6cff62eb689fd4a550736cc96390cb8f5e082 100644 --- a/src/EntityUsageTrackMultipleLoadInterface.php +++ b/src/EntityUsageTrackMultipleLoadInterface.php @@ -22,10 +22,14 @@ interface EntityUsageTrackMultipleLoadInterface extends EntityUsageTrackInterfac * @param \Drupal\Core\Field\FieldItemListInterface $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 new file mode 100644 index 0000000000000000000000000000000000000000..c1ac2fac354585fbcad8bada8c4105872fa937dc --- /dev/null +++ b/src/ParagraphInheritedUsageUpdater.php @@ -0,0 +1,188 @@ +<?php + +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; + +/** + * 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(): void { + if (empty($this->paragraphRevisionIds)) { + return; + } + $paragraph_storage = $this + ->entityTypeManager + ->getStorage('paragraph'); + 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(); + $this + ->recreateTrackingDataForFieldQueuer + ->addRecord( + $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(), + ); + } + } + } + + /** + * Gets the outermost host entity's field item list for a paragraph. + * + * 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\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 { + $outer_host_revisions = []; + + $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_field_item_list; + unset($parent_revision_field_item_lists[$key]); + } + } + + if (empty($parent_revision_field_item_lists)) { + break; + } + + $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_entity), + ); + } + + $parent_revision_field_item_lists = array_filter($next_parent_revision_field_item_lists); + } + + return $outer_host_revisions; + } + + /** + * Gets all field item lists that reference the given paragraph revision. + * + * @param \Drupal\paragraphs\ParagraphInterface $paragraph_revision + * The paragraph revision. + * + * @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. + */ + 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)) { + return []; + } + + $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 []; + } + + $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) { + 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 new file mode 100644 index 0000000000000000000000000000000000000000..5b51270d4f84ff7748e0dcb0393a496c6ca32ee6 --- /dev/null +++ b/src/Plugin/EntityUsage/Track/ParagraphInherited.php @@ -0,0 +1,161 @@ +<?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\EntityUsageTrackBase; +use Drupal\entity_usage\EntityUsageTrackMultipleLoadInterface; +use Drupal\entity_usage\ParagraphInheritedUsageUpdater; +use Drupal\paragraphs\ParagraphInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Tracks usage of entities referenced in referenced paragraphs. + * + * @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 (outer) host entity."), + * field_types = { + * "entity_reference_revisions", + * }, + * source_entity_class = "Drupal\Core\Entity\FieldableEntityInterface", + * ) + */ +class ParagraphInherited extends EntityUsageTrackBase implements EntityUsageTrackMultipleLoadInterface { + + /** + * The entity update manager. + */ + protected EntityUpdateManagerInterface $entityUpdateManager; + + /** + * The paragraph inherited usage updated. + */ + protected ParagraphInheritedUsageUpdater $paragraphInheritedUsageUpdater; + + /** + * {@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'); + $tracker->paragraphInheritedUsageUpdater = $container->get('entity_usage.paragraph_inherited_usage_updater'); + return $tracker; + } + + /** + * {@inheritdoc} + */ + public function getTargetEntities(FieldItemInterface $item): array { + $parent = $item->getParent(); + return $parent instanceof FieldItemListInterface + ? $this->doGetTargetEntities($parent, $item) + : []; + } + + /** + * {@inheritdoc} + */ + public function getTargetEntitiesFromField(FieldItemListInterface $field): array { + return $this->doGetTargetEntities($field); + } + + /** + * 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, 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 = []; + + $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(); + /** @var \Drupal\paragraphs\ParagraphInterface[] $referenced_paragraphs */ + $referenced_paragraphs = $field_item instanceof FieldItemInterface + ? array_filter([$field_item->entity ?? NULL]) + : $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; + } + + $field_target_entities = []; + try { + if ($plugin instanceof EntityUsageTrackMultipleLoadInterface) { + $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) { + $field_target_entities = $plugin->getTargetEntities($field_item); + } + } + } + catch (\Exception $e) { + $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}", + ]; + } + } + } + + // 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); + } + + 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; + } + +}