Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.47% covered (warning)
73.47%
72 / 98
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
PrimaryEntityReferenceTokenHooks
73.47% covered (warning)
73.47%
72 / 98
42.86% covered (danger)
42.86%
3 / 7
64.97
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
78.05% covered (warning)
78.05%
32 / 41
0.00% covered (danger)
0.00%
0 / 1
16.07
 processIsPrimaryTokens
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
7.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) {
180        $replacements[$tokens[$field_name . ':primary']] = $referenced_entity->label();
181        $bubbleable_metadata->addCacheableDependency($referenced_entity);
182      }
183    }
184
185    // Handle field_name:primary:target_id token.
186    if (isset($tokens[$field_name . ':primary:target_id'])) {
187      $replacements[$tokens[$field_name . ':primary:target_id']] = $primary_item->target_id;
188    }
189
190    // Handle field_name:primary:entity and chained tokens.
191    if ($primary_entity_tokens = $this->token->findWithPrefix($tokens, $field_name . ':primary')) {
192      $referenced_entity = $primary_item->entity;
193      if ($referenced_entity) {
194        $bubbleable_metadata->addCacheableDependency($referenced_entity);
195
196        // Handle :entity chained tokens.
197        if ($entity_tokens = $this->token->findWithPrefix($primary_entity_tokens, 'entity')) {
198          $replacements += $this->token->generate(
199            $target_type,
200            $entity_tokens,
201            [$target_type => $referenced_entity],
202            $options,
203            $bubbleable_metadata
204          );
205        }
206
207        // Also handle direct chaining without :entity (e.g., :primary:title).
208        // Remove known tokens and entity-prefixed which are handled above.
209        $chained_tokens = $primary_entity_tokens;
210        unset($chained_tokens['target_id']);
211
212        // Remove all entity-prefixed tokens (they're handled above).
213        foreach (array_keys($chained_tokens) as $key) {
214          if ($key === 'entity' || str_starts_with($key, 'entity:')) {
215            unset($chained_tokens[$key]);
216          }
217        }
218
219        if (!empty($chained_tokens)) {
220          $replacements += $this->token->generate(
221            $target_type,
222            $chained_tokens,
223            [$target_type => $referenced_entity],
224            $options,
225            $bubbleable_metadata
226          );
227        }
228      }
229    }
230  }
231
232  /**
233   * Process is_primary tokens for a field.
234   *
235   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
236   *   The entity.
237   * @param string $field_name
238   *   The field name.
239   * @param array $tokens
240   *   The tokens to process.
241   * @param array $replacements
242   *   The replacements array to add to.
243   */
244  protected function processIsPrimaryTokens(
245    ContentEntityInterface $entity,
246    string $field_name,
247    array $tokens,
248    array &$replacements,
249  ): void {
250    if (!$entity->hasField($field_name)) {
251      return;
252    }
253
254    $field = $entity->get($field_name);
255    if (!$field instanceof PrimaryEntityReferenceFieldItemList) {
256      return;
257    }
258
259    // Look for field_name:N:is_primary patterns.
260    foreach ($tokens as $name => $original) {
261      // Match patterns like field_name:0:is_primary, field_name:1:is_primary.
262      if (preg_match('/^' . preg_quote($field_name, '/') . ':(\d+):is_primary$/', $name, $matches)) {
263        $delta = (int) $matches[1];
264
265        if (isset($field[$delta])) {
266          $item = $field[$delta];
267          $is_primary = (bool) $item->get('primary')->getValue();
268          $replacements[$original] = $is_primary ? '1' : '0';
269        }
270      }
271    }
272  }
273
274  /**
275   * Gets all primary entity reference fields grouped by entity type.
276   *
277   * @return array
278   *   An array of field definitions keyed by entity type ID and field name.
279   */
280  protected function getPrimaryEntityReferenceFields(): array {
281    $fields = [];
282
283    $field_map = $this->entityFieldManager->getFieldMapByFieldType('primary_entity_reference');
284
285    foreach ($field_map as $entity_type_id => $entity_fields) {
286      foreach ($entity_fields as $field_name => $field_info) {
287        // Get the field definition from the first bundle.
288        $bundle = reset($field_info['bundles']);
289        $field_definitions = $this->entityFieldManager->getFieldDefinitions($entity_type_id, $bundle);
290
291        if (isset($field_definitions[$field_name])) {
292          $fields[$entity_type_id][$field_name] = $field_definitions[$field_name];
293        }
294      }
295    }
296
297    return $fields;
298  }
299
300  /**
301   * Gets primary entity reference fields for a specific entity type and bundle.
302   *
303   * @param string $entity_type_id
304   *   The entity type ID.
305   * @param string $bundle
306   *   The bundle.
307   *
308   * @return array
309   *   An array of field definitions keyed by field name.
310   */
311  protected function getPrimaryEntityReferenceFieldsForEntityType(string $entity_type_id, string $bundle): array {
312    $fields = [];
313
314    $field_definitions = $this->entityFieldManager->getFieldDefinitions($entity_type_id, $bundle);
315
316    foreach ($field_definitions as $field_name => $field_definition) {
317      if ($field_definition->getType() === 'primary_entity_reference') {
318        $fields[$field_name] = $field_definition;
319      }
320    }
321
322    return $fields;
323  }
324
325}