Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.26% covered (warning)
74.26%
75 / 101
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
PrimaryEntityReferenceTokenHooks
74.26% covered (warning)
74.26%
75 / 101
42.86% covered (danger)
42.86%
3 / 7
69.68
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tokenInfoAlter
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 tokens
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 processPrimaryTokens
79.07% covered (warning)
79.07%
34 / 43
0.00% covered (danger)
0.00%
0 / 1
18.35
 processIsPrimaryTokens
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
8.23
 getPrimaryEntityReferenceFields
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getPrimaryEntityReferenceFieldsForEntityType
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\primary_entity_reference\Hook;
6
7use Drupal\Core\Entity\ContentEntityInterface;
8use Drupal\Core\Entity\EntityFieldManagerInterface;
9use Drupal\Core\Entity\EntityTypeManagerInterface;
10use Drupal\Core\Hook\Attribute\Hook;
11use Drupal\Core\Render\BubbleableMetadata;
12use Drupal\Core\StringTranslation\StringTranslationTrait;
13use Drupal\Core\Utility\Token;
14use Drupal\primary_entity_reference\Plugin\Field\PrimaryEntityReferenceFieldItemList;
15
16/**
17 * Token hook implementations for primary_entity_reference.
18 */
19class PrimaryEntityReferenceTokenHooks {
20
21  use StringTranslationTrait;
22
23  /**
24   * Constructs a new PrimaryEntityReferenceTokenHooks instance.
25   *
26   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
27   *   The entity type manager.
28   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entityFieldManager
29   *   The entity field manager.
30   * @param \Drupal\Core\Utility\Token $token
31   *   The token service.
32   */
33  public function __construct(
34    protected readonly EntityTypeManagerInterface $entityTypeManager,
35    protected readonly EntityFieldManagerInterface $entityFieldManager,
36    protected readonly Token $token,
37  ) {}
38
39  /**
40   * Implements hook_token_info_alter().
41   *
42   * Adds token info for primary entity reference fields:
43   * - [entity:field_name:primary] - The primary referenced entity.
44   * - [entity:field_name:primary:*] - Chained tokens for the primary entity.
45   * - [entity:field_name:N:is_primary] - Whether delta N is the primary.
46   *
47   * @param array $info
48   *   The token info array to alter.
49   */
50  #[Hook('token_info_alter')]
51  public function tokenInfoAlter(array &$info): void {
52    // Get all primary entity reference fields across all entity types.
53    foreach ($this->getPrimaryEntityReferenceFields() as $entity_type_id => $fields) {
54      // Skip if no tokens for this entity type.
55      if (!isset($info['tokens'][$entity_type_id])) {
56        continue;
57      }
58
59      foreach ($fields as $field_name => $field_definition) {
60        $target_type = $field_definition->getSetting('target_type');
61        $target_entity_type = $this->entityTypeManager->getDefinition($target_type, FALSE);
62
63        if (!$target_entity_type) {
64          continue;
65        }
66
67        // Add the 'primary' pseudo-delta token.
68        $info['tokens'][$entity_type_id][$field_name . ':primary'] = [
69          'name' => $this->t('@field_name: Primary', ['@field_name' => $field_definition->getLabel()]),
70          'description' => $this->t('The primary referenced @type.', [
71            '@type' => $target_entity_type->getSingularLabel(),
72          ]),
73          'type' => $target_type,
74        ];
75
76        // Add the 'is_primary' property token.
77        // This is added as a dynamic token that works with any delta.
78        $info['tokens'][$entity_type_id][$field_name . ':?:is_primary'] = [
79          'name' => $this->t('@field_name: Is primary', ['@field_name' => $field_definition->getLabel()]),
80          'description' => $this->t('Whether the item at the specified delta is the primary reference (1 or 0).'),
81          'dynamic' => TRUE,
82        ];
83      }
84    }
85  }
86
87  /**
88   * Implements hook_tokens().
89   *
90   * Provides token replacements for primary entity reference fields.
91   *
92   * @param string $type
93   *   The machine-readable name of the type of token being replaced.
94   * @param array $tokens
95   *   An array of tokens to be replaced.
96   * @param array $data
97   *   An associative array of data objects to be used for replacement.
98   * @param array $options
99   *   An associative array of options for token replacement.
100   * @param \Drupal\Core\Render\BubbleableMetadata $bubbleable_metadata
101   *   The bubbleable metadata.
102   *
103   * @return array
104   *   An associative array of replacement values.
105   */
106  #[Hook('tokens')]
107  public function tokens(string $type, array $tokens, array $data, array $options, BubbleableMetadata $bubbleable_metadata): array {
108    $replacements = [];
109
110    // Get the entity from data if available.
111    $entity = $data[$type] ?? NULL;
112
113    if (!$entity instanceof ContentEntityInterface) {
114      return $replacements;
115    }
116
117    // Get primary entity reference fields for this entity type.
118    $fields = $this->getPrimaryEntityReferenceFieldsForEntityType($entity->getEntityTypeId(), $entity->bundle());
119
120    if (empty($fields)) {
121      return $replacements;
122    }
123
124    foreach ($fields as $field_name => $field_definition) {
125      // Handle :primary tokens.
126      $this->processPrimaryTokens($entity, $field_name, $tokens, $options, $bubbleable_metadata, $replacements);
127
128      // Handle :N:is_primary tokens.
129      $this->processIsPrimaryTokens($entity, $field_name, $tokens, $replacements);
130    }
131
132    return $replacements;
133  }
134
135  /**
136   * Process primary-related tokens for a field.
137   *
138   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
139   *   The entity.
140   * @param string $field_name
141   *   The field name.
142   * @param array $tokens
143   *   The tokens to process.
144   * @param array $options
145   *   Token replacement options.
146   * @param \Drupal\Core\Render\BubbleableMetadata $bubbleable_metadata
147   *   The bubbleable metadata.
148   * @param array $replacements
149   *   The replacements array to add to.
150   */
151  protected function processPrimaryTokens(
152    ContentEntityInterface $entity,
153    string $field_name,
154    array $tokens,
155    array $options,
156    BubbleableMetadata $bubbleable_metadata,
157    array &$replacements,
158  ): void {
159    if (!$entity->hasField($field_name)) {
160      return;
161    }
162
163    $field = $entity->get($field_name);
164    if (!$field instanceof PrimaryEntityReferenceFieldItemList) {
165      return;
166    }
167
168    // Get the primary item.
169    $primary_item = $field->primary();
170    if (!$primary_item) {
171      return;
172    }
173
174    $target_type = $field->getFieldDefinition()->getSetting('target_type');
175
176    // Handle field_name:primary token (returns entity label).
177    if (isset($tokens[$field_name . ':primary'])) {
178      $referenced_entity = $primary_item->entity;
179      if ($referenced_entity && $referenced_entity->access('view')) {
180        $replacements[$tokens[$field_name . ':primary']] = $referenced_entity->label();
181        $bubbleable_metadata->addCacheableDependency($referenced_entity);
182        $bubbleable_metadata->addCacheContexts(['user.permissions']);
183      }
184    }
185
186    // Handle field_name:primary:target_id token.
187    if (isset($tokens[$field_name . ':primary:target_id'])) {
188      $replacements[$tokens[$field_name . ':primary:target_id']] = $primary_item->target_id;
189    }
190
191    // Handle field_name:primary:entity and chained tokens.
192    if ($primary_entity_tokens = $this->token->findWithPrefix($tokens, $field_name . ':primary')) {
193      $referenced_entity = $primary_item->entity;
194      if ($referenced_entity && $referenced_entity->access('view')) {
195        $bubbleable_metadata->addCacheableDependency($referenced_entity);
196        $bubbleable_metadata->addCacheContexts(['user.permissions']);
197
198        // Handle :entity chained tokens.
199        if ($entity_tokens = $this->token->findWithPrefix($primary_entity_tokens, 'entity')) {
200          $replacements += $this->token->generate(
201            $target_type,
202            $entity_tokens,
203            [$target_type => $referenced_entity],
204            $options,
205            $bubbleable_metadata
206          );
207        }
208
209        // Also handle direct chaining without :entity (e.g., :primary:title).
210        // Remove known tokens and entity-prefixed which are handled above.
211        $chained_tokens = $primary_entity_tokens;
212        unset($chained_tokens['target_id']);
213
214        // Remove all entity-prefixed tokens (they're handled above).
215        foreach (array_keys($chained_tokens) as $key) {
216          if ($key === 'entity' || str_starts_with($key, 'entity:')) {
217            unset($chained_tokens[$key]);
218          }
219        }
220
221        if (!empty($chained_tokens)) {
222          $replacements += $this->token->generate(
223            $target_type,
224            $chained_tokens,
225            [$target_type => $referenced_entity],
226            $options,
227            $bubbleable_metadata
228          );
229        }
230      }
231    }
232  }
233
234  /**
235   * Process is_primary tokens for a field.
236   *
237   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
238   *   The entity.
239   * @param string $field_name
240   *   The field name.
241   * @param array $tokens
242   *   The tokens to process.
243   * @param array $replacements
244   *   The replacements array to add to.
245   */
246  protected function processIsPrimaryTokens(
247    ContentEntityInterface $entity,
248    string $field_name,
249    array $tokens,
250    array &$replacements,
251  ): void {
252    if (!$entity->hasField($field_name)) {
253      return;
254    }
255
256    $field = $entity->get($field_name);
257    if (!$field instanceof PrimaryEntityReferenceFieldItemList) {
258      return;
259    }
260
261    // Look for field_name:N:is_primary patterns.
262    foreach ($tokens as $name => $original) {
263      // Skip if field name is unreasonably long to prevent ReDoS attacks.
264      // Field names are typically < 32 characters, but we allow up to 255
265      // for defense in depth without being overly restrictive.
266      if (strlen($field_name) > 255) {
267        continue;
268      }
269
270      // Match patterns like field_name:0:is_primary, field_name:1:is_primary.
271      if (preg_match('/^' . preg_quote($field_name, '/') . ':(\d+):is_primary$/', $name, $matches)) {
272        $delta = (int) $matches[1];
273
274        if (isset($field[$delta])) {
275          $item = $field[$delta];
276          $is_primary = (bool) $item->get('primary')->getValue();
277          $replacements[$original] = $is_primary ? '1' : '0';
278        }
279      }
280    }
281  }
282
283  /**
284   * Gets all primary entity reference fields grouped by entity type.
285   *
286   * @return array
287   *   An array of field definitions keyed by entity type ID and field name.
288   */
289  protected function getPrimaryEntityReferenceFields(): array {
290    $fields = [];
291
292    $field_map = $this->entityFieldManager->getFieldMapByFieldType('primary_entity_reference');
293
294    foreach ($field_map as $entity_type_id => $entity_fields) {
295      foreach ($entity_fields as $field_name => $field_info) {
296        // Get the field definition from the first bundle.
297        $bundle = reset($field_info['bundles']);
298        $field_definitions = $this->entityFieldManager->getFieldDefinitions($entity_type_id, $bundle);
299
300        if (isset($field_definitions[$field_name])) {
301          $fields[$entity_type_id][$field_name] = $field_definitions[$field_name];
302        }
303      }
304    }
305
306    return $fields;
307  }
308
309  /**
310   * Gets primary entity reference fields for a specific entity type and bundle.
311   *
312   * @param string $entity_type_id
313   *   The entity type ID.
314   * @param string $bundle
315   *   The bundle.
316   *
317   * @return array
318   *   An array of field definitions keyed by field name.
319   */
320  protected function getPrimaryEntityReferenceFieldsForEntityType(string $entity_type_id, string $bundle): array {
321    $fields = [];
322
323    $field_definitions = $this->entityFieldManager->getFieldDefinitions($entity_type_id, $bundle);
324
325    foreach ($field_definitions as $field_name => $field_definition) {
326      if ($field_definition->getType() === 'primary_entity_reference') {
327        $fields[$field_name] = $field_definition;
328      }
329    }
330
331    return $fields;
332  }
333
334}