Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
95 / 95
100.00% covered (success)
100.00%
24 / 24
CRAP
100.00% covered (success)
100.00%
1 / 1
AutocompleteService
100.00% covered (success)
100.00%
95 / 95
100.00% covered (success)
100.00%
24 / 24
35
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMatches
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 buildMatchContext
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 appendStaticOptionMatches
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 appendFieldDataMatches
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 normalizeAutocompleteSettings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveTargetComponents
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveCompositeTargetComponents
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildAutocompletePlan
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 appendSeparatorCharacters
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 pregSplitPieces
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 splitPiecesCallable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 splitAutocompleteInput
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 collectOptionMatches
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveMatchMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stringMatches
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 collectEntityFieldMatches
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 mapAssoc
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findFieldValues
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 canLookupFieldValues
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 resolveFieldValueLookupTarget
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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
1
 queryFieldValueEntityIds
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\name\Service;
6
7use Drupal\Core\Entity\EntityStorageInterface;
8use Drupal\Core\Entity\EntityTypeManagerInterface;
9use Drupal\Core\Field\FieldDefinitionInterface;
10use Drupal\name\Utility\AutocompleteFieldValueLookup;
11use Drupal\name\Utility\AutocompleteMatcher;
12use Drupal\name\Utility\AutocompletePlanBuilder;
13
14/**
15 * Name field autocompletion results.
16 *
17 * @see \Drupal\name\Utility\AutocompletePlanBuilder
18 * @see \Drupal\name\Utility\AutocompleteMatcher
19 * @see \Drupal\name\Utility\AutocompleteFieldValueLookup
20 */
21class AutocompleteService implements AutocompleteInterface {
22
23  /**
24   * Name options provider.
25   */
26  protected NameOptionInterface $optionsProvider;
27
28  /**
29   * Entity type manager for field-data lookups.
30   */
31  protected ?EntityTypeManagerInterface $entityTypeManager;
32
33  /**
34   * Constructs an AutocompleteService object.
35   */
36  public function __construct(NameOptionInterface $options_provider, ?EntityTypeManagerInterface $entity_type_manager = NULL) {
37    $this->optionsProvider = $options_provider;
38    // @phpstan-ignore-next-line
39    $this->entityTypeManager = $entity_type_manager ?? \Drupal::entityTypeManager();
40  }
41
42  /**
43   * {@inheritdoc}
44   */
45  public function getMatches(FieldDefinitionInterface $field, string $target, string $string): array {
46    $context = $this->buildMatchContext($field, $target, $string);
47    if ($context === NULL) {
48      return [];
49    }
50
51    $matches = [];
52    $limit   = 10;
53    $this->appendStaticOptionMatches($field, $context, $matches, $limit);
54    $this->appendFieldDataMatches($field, $context, $matches, $limit);
55
56    return $matches;
57  }
58
59  /**
60   * Builds match context from field settings, target, and user input.
61   *
62   * @return array{
63   *   settings: array<string, mixed>,
64   *   plan: array{
65   *     components: array<string, string>,
66   *     source: array{
67   *       title: array<int, string>,
68   *       generational: array<int, string>,
69   *       data: array<int, string>
70   *     },
71   *     separator: string
72   *   },
73   *   base_string: string,
74   *   test_string: string
75   *   }|null
76   *   Context for match collection, or NULL when no matches are possible.
77   */
78  protected function buildMatchContext(FieldDefinitionInterface $field, string $target, string $string): ?array {
79    return AutocompletePlanBuilder::buildMatchContext(
80      $field,
81      $target,
82      $string,
83      $this->splitPiecesCallable(),
84    );
85  }
86
87  /**
88   * Appends title and generational option matches to the result set.
89   *
90   * @param \Drupal\Core\Field\FieldDefinitionInterface $field
91   *   The field definition.
92   * @param array $context
93   *   Match context from buildMatchContext().
94   * @param array<string, string> $matches
95   *   The current match list.
96   * @param int $limit
97   *   The remaining match limit.
98   */
99  protected function appendStaticOptionMatches(FieldDefinitionInterface $field, array $context, array &$matches, int &$limit): void {
100    foreach (['title', 'generational'] as $source_component) {
101      $limit_exhausted = $limit <= 0;
102      $source_empty    = empty($context['plan']['source'][$source_component]);
103      $should_skip     = $limit_exhausted || $source_empty;
104      if ($should_skip) {
105        continue;
106      }
107      $mode    = $this->resolveMatchMode($context['settings'], $source_component);
108      $options = $this->optionsProvider->getOptions($field, $source_component);
109      $this->collectOptionMatches(
110        $options,
111        $context['test_string'],
112        $mode,
113        $context['base_string'],
114        $matches,
115        $limit,
116      );
117    }
118  }
119
120  /**
121   * Appends stored field-data matches to the result set.
122   *
123   * Per-component field-data lookup. Each component queries only its own
124   * column of the field storage â€” a "given" request never reads "family".
125   *
126   * @param \Drupal\Core\Field\FieldDefinitionInterface $field
127   *   The field definition.
128   * @param array $context
129   *   Match context from buildMatchContext().
130   * @param array<string, string> $matches
131   *   The current match list.
132   * @param int $limit
133   *   The remaining match limit.
134   */
135  protected function appendFieldDataMatches(FieldDefinitionInterface $field, array $context, array &$matches, int &$limit): void {
136    foreach ($context['plan']['source']['data'] as $component) {
137      if ($limit <= 0) {
138        break;
139      }
140      $mode   = $this->resolveMatchMode($context['settings'], $component);
141      $values = $this->findFieldValues(
142        $field,
143        $component,
144        $context['test_string'],
145        $limit,
146        $mode,
147      );
148      foreach ($values as $value) {
149        $matches[$context['base_string'] . $value] = $value;
150        $limit -= 1;
151        if ($limit <= 0) {
152          break;
153        }
154      }
155    }
156  }
157
158  /**
159   * Normalizes the required autocomplete settings for all components.
160   *
161   * @param array<string, mixed> $settings
162   *   The field settings.
163   *
164   * @return array<string, mixed>
165   *   The settings with normalized autocomplete source keys.
166   */
167  protected function normalizeAutocompleteSettings(array $settings): array {
168    return AutocompletePlanBuilder::normalizeAutocompleteSettings($settings);
169  }
170
171  /**
172   * Resolves an autocomplete target into an associative component map.
173   *
174   * @return array<string, string>
175   *   A map keyed by component machine name.
176   */
177  protected function resolveTargetComponents(string $target): array {
178    return AutocompletePlanBuilder::resolveTargetComponents($target);
179  }
180
181  /**
182   * Resolves a hyphen-delimited target into valid core components.
183   *
184   * @return array<string, string>
185   *   A map keyed by component machine name.
186   */
187  protected function resolveCompositeTargetComponents(string $target): array {
188    return AutocompletePlanBuilder::resolveCompositeTargetComponents($target);
189  }
190
191  /**
192   * Builds the executable autocomplete plan for component and source lookups.
193   *
194   * @param array<string, mixed> $settings
195   *   Normalized field settings.
196   * @param array<string, string> $components
197   *   Requested components keyed by component name.
198   *
199   * @return array{
200   *   components: array<string, string>,
201   *   source: array{
202   *     title: array<int, string>,
203   *     generational: array<int, string>,
204   *     data: array<int, string>
205   *   },
206   *   separator: string
207   *   }
208   *   The actionable plan for source resolution and input splitting.
209   */
210  protected function buildAutocompletePlan(array $settings, array $components): array {
211    return AutocompletePlanBuilder::buildAutocompletePlan($settings, $components);
212  }
213
214  /**
215   * Adds unique separator characters from one component into the set.
216   */
217  protected function appendSeparatorCharacters(string $separator, string $component_separator): string {
218    return AutocompletePlanBuilder::appendSeparatorCharacters($separator, $component_separator);
219  }
220
221  /**
222   * Wraps preg_split to allow substitution in tests.
223   *
224   * @return list<string>|false
225   *   The split result, or FALSE on PCRE failure.
226   */
227  protected function pregSplitPieces(string $pattern, string $subject): array|false {
228    return \preg_split($pattern, $subject);
229  }
230
231  /**
232   * Returns a callable that delegates input splitting to pregSplitPieces().
233   *
234   * @return callable(string, string): (list<string>|false)
235   *   Callable that splits input using pregSplitPieces().
236   */
237  protected function splitPiecesCallable(): callable {
238    return fn (string $pattern, string $subject): array|false => $this->pregSplitPieces($pattern, $subject);
239  }
240
241  /**
242   * Splits autocomplete input into base prefix and searchable token.
243   *
244   * @return array{base: string, test: string}|null
245   *   The parsed input parts, or NULL when no usable split can be performed.
246   */
247  protected function splitAutocompleteInput(string $string, string $separator): ?array {
248    return AutocompletePlanBuilder::splitAutocompleteInput(
249      $string,
250      $separator,
251      $this->splitPiecesCallable(),
252    );
253  }
254
255  /**
256   * Adds matching option values while honoring the shared result limit.
257   *
258   * @param array<string, mixed> $options
259   *   The option list keyed by stored value.
260   * @param string $test_string
261   *   The lowercase token to match.
262   * @param string $mode
263   *   The match mode.
264   * @param string $base_string
265   *   The prefix to prepend to matched option keys.
266   * @param array<string, string> $matches
267   *   The current match list.
268   * @param int $limit
269   *   The remaining match limit.
270   */
271  protected function collectOptionMatches(array $options, string $test_string, string $mode, string $base_string, array &$matches, int &$limit): void {
272    AutocompleteMatcher::collectOptionMatches($options, $test_string, $mode, $base_string, $matches, $limit);
273  }
274
275  /**
276   * Resolves the effective autocomplete match mode for a single component.
277   *
278   * @param array<string, mixed> $settings
279   *   The field settings array from FieldDefinitionInterface::getSettings().
280   * @param string $component
281   *   The component machine name (for example, "given").
282   *
283   * @return string
284   *   Either "starts_with" or "contains". Falls back to "starts_with" for any
285   *   legacy configuration that predates these settings.
286   */
287  protected function resolveMatchMode(array $settings, string $component): string {
288    return AutocompleteMatcher::resolveMatchMode($settings, $component);
289  }
290
291  /**
292   * Applies the resolved match mode to an in-memory string comparison.
293   */
294  protected function stringMatches(string $haystack, string $needle, string $mode): bool {
295    return AutocompleteMatcher::stringMatches($haystack, $needle, $mode);
296  }
297
298  /**
299   * Collects matching field item values from loaded entities.
300   *
301   * @param array<int|string, \Drupal\Core\Entity\FieldableEntityInterface> $entities
302   *   The entities to inspect.
303   * @param string $field_name
304   *   The field machine name.
305   * @param string $component
306   *   The name field component machine name.
307   * @param string $needle
308   *   The lowercase search term.
309   * @param int $limit
310   *   The maximum number of unique matches to return.
311   * @param string $mode
312   *   The match mode.
313   *
314   * @return array<string, string>
315   *   Matching values keyed by value.
316   */
317  protected function collectEntityFieldMatches(
318    array $entities,
319    string $field_name,
320    string $component,
321    string $needle,
322    int $limit,
323    string $mode,
324  ): array {
325    return AutocompleteFieldValueLookup::collectEntityFieldMatches(
326      $entities,
327      $field_name,
328      $component,
329      $needle,
330      $limit,
331      $mode,
332    );
333  }
334
335  /**
336   * {@inheritdoc}
337   */
338  public function mapAssoc(array $values): array {
339    return array_combine($values, $values);
340  }
341
342  /**
343   * {@inheritdoc}
344   */
345  public function findFieldValues(FieldDefinitionInterface $field, string $component, string $term, int $limit, string $mode = 'starts_with'): array {
346    if (!$this->canLookupFieldValues($component, $term, $limit)) {
347      return [];
348    }
349    return AutocompleteFieldValueLookup::findFieldValues(
350      $this->entityTypeManager,
351      $field,
352      $component,
353      $term,
354      $limit,
355      $mode,
356    );
357  }
358
359  /**
360   * Whether field-value lookup can run for the given arguments.
361   */
362  protected function canLookupFieldValues(string $component, string $term, int $limit): bool {
363    if ($this->entityTypeManager === NULL) {
364      return FALSE;
365    }
366    return AutocompleteFieldValueLookup::canLookupFieldValues($component, $term, $limit);
367  }
368
369  /**
370   * Resolves entity type and field name for field-value storage queries.
371   *
372   * @return array{entity_type_id: string, field_name: string}|null
373   *   Lookup target, or NULL when the field definition is incomplete.
374   */
375  protected function resolveFieldValueLookupTarget(FieldDefinitionInterface $field): ?array {
376    return AutocompleteFieldValueLookup::resolveFieldValueLookupTarget($field);
377  }
378
379  /**
380   * Loads entity storage for field-value queries.
381   */
382  protected function loadFieldValueStorage(string $entity_type_id): ?EntityStorageInterface {
383    if ($this->entityTypeManager === NULL) {
384      return NULL;
385    }
386    return AutocompleteFieldValueLookup::loadFieldValueStorage($this->entityTypeManager, $entity_type_id);
387  }
388
389  /**
390   * Maps autocomplete match mode to an entity query operator.
391   */
392  protected function fieldValueQueryOperator(string $mode): string {
393    return AutocompleteFieldValueLookup::fieldValueQueryOperator($mode);
394  }
395
396  /**
397   * Queries entity IDs whose field component values match the search term.
398   *
399   * @return array<int|string>
400   *   Matching entity IDs.
401   */
402  protected function queryFieldValueEntityIds(
403    EntityStorageInterface $storage,
404    string $field_name,
405    string $component,
406    string $term,
407    int $limit,
408    string $mode,
409  ): array {
410    return AutocompleteFieldValueLookup::queryFieldValueEntityIds(
411      $storage,
412      $field_name,
413      $component,
414      $term,
415      $limit,
416      $mode,
417    );
418  }
419
420}