Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
70 / 70
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
AutocompleteFieldValueLookup
100.00% covered (success)
100.00%
70 / 70
100.00% covered (success)
100.00%
7 / 7
25
100.00% covered (success)
100.00%
1 / 1
 findFieldValues
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
5
 canLookupFieldValues
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 resolveFieldValueLookupTarget
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 loadFieldValueStorage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 fieldValueQueryOperator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 queryFieldValueEntityIds
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 collectEntityFieldMatches
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\name\Utility;
6
7use Drupal\Core\Entity\EntityStorageInterface;
8use Drupal\Core\Entity\EntityTypeManagerInterface;
9use Drupal\Core\Field\FieldDefinitionInterface;
10
11/**
12 * Queries stored name field values for autocomplete suggestions.
13 *
14 * @internal
15 */
16final class AutocompleteFieldValueLookup {
17
18  /**
19   * Name field components eligible for field-data lookup.
20   *
21   * @var list<string>
22   */
23  private const ALL_COMPONENTS = [
24    'given',
25    'middle',
26    'family',
27    'title',
28    'credentials',
29    'generational',
30  ];
31
32  /**
33   * Finds matching stored values for a single component of a name field.
34   *
35   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
36   *   The entity type manager.
37   * @param \Drupal\Core\Field\FieldDefinitionInterface $field
38   *   The field definition.
39   * @param string $component
40   *   The name field component machine name.
41   * @param string $term
42   *   The search term typed by the user.
43   * @param int $limit
44   *   The maximum number of unique values to return.
45   * @param string $mode
46   *   The match mode.
47   *
48   * @return array<string, string>
49   *   Matching values keyed by value.
50   */
51  public static function findFieldValues(EntityTypeManagerInterface $entity_type_manager, FieldDefinitionInterface $field, string $component, string $term, int $limit, string $mode = 'starts_with'): array {
52    if (!self::canLookupFieldValues($component, $term, $limit)) {
53      return [];
54    }
55
56    $target = self::resolveFieldValueLookupTarget($field);
57    if ($target === NULL) {
58      return [];
59    }
60
61    $storage = self::loadFieldValueStorage($entity_type_manager, $target['entity_type_id']);
62    if ($storage === NULL) {
63      return [];
64    }
65
66    $ids = self::queryFieldValueEntityIds(
67      $storage,
68      $target['field_name'],
69      $component,
70      $term,
71      $limit,
72      $mode,
73    );
74    if ($ids === []) {
75      return [];
76    }
77
78    return self::collectEntityFieldMatches(
79      $storage->loadMultiple($ids),
80      $target['field_name'],
81      $component,
82      mb_strtolower($term),
83      $limit,
84      $mode,
85    );
86  }
87
88  /**
89   * Whether field-value lookup can run for the given arguments.
90   */
91  public static function canLookupFieldValues(string $component, string $term, int $limit): bool {
92    $limit_exhausted = $limit <= 0;
93    $term_empty      = $term === '';
94    $cannot_lookup   = $limit_exhausted || $term_empty;
95    if ($cannot_lookup) {
96      return FALSE;
97    }
98    return in_array($component, self::ALL_COMPONENTS, TRUE);
99  }
100
101  /**
102   * Resolves entity type and field name for field-value storage queries.
103   *
104   * @return array{entity_type_id: string, field_name: string}|null
105   *   Lookup target, or NULL when the field definition is incomplete.
106   */
107  public static function resolveFieldValueLookupTarget(FieldDefinitionInterface $field): ?array {
108    $entity_type_id      = $field->getTargetEntityTypeId();
109    $field_name          = $field->getName();
110    $missing_entity_type = $entity_type_id === NULL || $entity_type_id === '';
111    $missing_field_name  = $field_name === '';
112    $target_incomplete   = $missing_entity_type || $missing_field_name;
113    if ($target_incomplete) {
114      return NULL;
115    }
116
117    return [
118      'entity_type_id' => $entity_type_id,
119      'field_name'     => $field_name,
120    ];
121  }
122
123  /**
124   * Loads entity storage for field-value queries.
125   *
126   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
127   *   The entity type manager.
128   * @param string $entity_type_id
129   *   The entity type machine name.
130   *
131   * @return \Drupal\Core\Entity\EntityStorageInterface|null
132   *   Entity storage, or NULL when lookup fails.
133   */
134  public static function loadFieldValueStorage(EntityTypeManagerInterface $entity_type_manager, string $entity_type_id): ?EntityStorageInterface {
135    try {
136      return $entity_type_manager->getStorage($entity_type_id);
137    }
138    catch (\Exception $e) {
139      return NULL;
140    }
141  }
142
143  /**
144   * Maps autocomplete match mode to an entity query operator.
145   */
146  public static function fieldValueQueryOperator(string $mode): string {
147    return $mode === 'contains' ? 'CONTAINS' : 'STARTS_WITH';
148  }
149
150  /**
151   * Queries entity IDs whose field component values match the search term.
152   *
153   * @return array<int|string>
154   *   Matching entity IDs.
155   */
156  public static function queryFieldValueEntityIds(
157    EntityStorageInterface $storage,
158    string $field_name,
159    string $component,
160    string $term,
161    int $limit,
162    string $mode,
163  ): array {
164    $property_path = $field_name . '.' . $component;
165    $range         = max($limit * 4, $limit);
166    $operator      = self::fieldValueQueryOperator($mode);
167
168    return $storage->getQuery()
169      ->accessCheck(TRUE)
170      ->condition($property_path, $term, $operator)
171      ->sort($property_path)
172      ->range(0, $range)
173      ->execute();
174  }
175
176  /**
177   * Collects matching field item values from loaded entities.
178   *
179   * @param array<int|string, \Drupal\Core\Entity\FieldableEntityInterface> $entities
180   *   The entities to inspect.
181   * @param string $field_name
182   *   The field machine name.
183   * @param string $component
184   *   The name field component machine name.
185   * @param string $needle
186   *   The lowercase search term.
187   * @param int $limit
188   *   The maximum number of unique matches to return.
189   * @param string $mode
190   *   The match mode.
191   *
192   * @return array<string, string>
193   *   Matching values keyed by value.
194   */
195  public static function collectEntityFieldMatches(
196    array $entities,
197    string $field_name,
198    string $component,
199    string $needle,
200    int $limit,
201    string $mode,
202  ): array {
203    $matches = [];
204    foreach ($entities as $entity) {
205      if (!$entity->hasField($field_name)) {
206        continue;
207      }
208      foreach ($entity->get($field_name) as $item) {
209        $value         = $item->{$component} ?? NULL;
210        $not_string    = !is_string($value);
211        $is_empty      = $value === '';
212        $invalid_value = $not_string || $is_empty;
213        if ($invalid_value) {
214          continue;
215        }
216        if (!AutocompleteMatcher::stringMatches($value, $needle, $mode)) {
217          continue;
218        }
219        $matches[$value] = $value;
220        if (count($matches) >= $limit) {
221          return $matches;
222        }
223      }
224    }
225    return $matches;
226  }
227
228}