Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
164 / 164
100.00% covered (success)
100.00%
18 / 18
CRAP
100.00% covered (success)
100.00%
1 / 1
NameField
100.00% covered (success)
100.00%
164 / 164
100.00% covered (success)
100.00%
18 / 18
49
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getComponentOptions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 transform
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 normalizeWhitespace
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 familyOnlyComponentsIfSingleWord
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 extractCredentialOrCommaSuffix
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 tryParentheticalCredentials
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 tryCommaThenSlashTrailingCredentials
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 tryLeadingCredentialWord
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 trySlashTrailingCredentials
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 trySpacedDashTrailingCredentials
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 tryCommaGenerationalOrCredentialsSuffix
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 tryTrailingCredentialWord
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 mergeStructuredNameOntoParsed
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 configuredComponentList
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 fieldSettingsComponentList
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
 optionListWithoutEmptyPlaceholders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\name\Plugin\migrate\process;
6
7use Drupal\Core\Entity\EntityFieldManagerInterface;
8use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
9use Drupal\migrate\MigrateExecutableInterface;
10use Drupal\migrate\ProcessPluginBase;
11use Drupal\migrate\Row;
12use Symfony\Component\DependencyInjection\ContainerInterface;
13
14/**
15 * Parses text values into name values.
16 *
17 * Available configuration keys:
18 * - entity_type: The entity type of the destination field.
19 * - bundle: The bundle of the destination field.
20 * - field_name: The machine name of the destination name field.
21 * - title: (optional) Array of title values to recognize, overrides field
22 *   settings.
23 * - generational: (optional) Array of generational values to recognize,
24 *   overrides field settings.
25 * - credentials: (optional) Array of credential values to recognize by word
26 *   match. Credentials are always detected by delimiter (commas, parentheses,
27 *   slashes, dashes) regardless of this setting.
28 *
29 * If entity_type, bundle, and field_name are provided, the plugin retrieves
30 * title_options and generational_options from the field configuration. Direct
31 * configuration of title, generational, or credentials arrays takes precedence
32 * over field settings.
33 *
34 * @MigrateProcessPlugin(
35 *   id = "name_field"
36 * )
37 */
38class NameField extends ProcessPluginBase implements ContainerFactoryPluginInterface {
39
40  /**
41   * Constructs a NameField process plugin.
42   *
43   * @param array $configuration
44   *   The plugin configuration.
45   * @param string $plugin_id
46   *   The plugin ID.
47   * @param mixed $plugin_definition
48   *   The plugin definition.
49   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager
50   *   The entity_field.manager service.
51   */
52  public function __construct(
53    array $configuration,
54    $plugin_id,
55    $plugin_definition,
56    protected EntityFieldManagerInterface $entity_field_manager,
57  ) {
58    parent::__construct($configuration, $plugin_id, $plugin_definition);
59  }
60
61  /**
62   * {@inheritdoc}
63   */
64  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) : static {
65    return new static(
66      $configuration,
67      $plugin_id,
68      $plugin_definition,
69      $container->get('entity_field.manager'),
70    );
71  }
72
73  /**
74   * Gets the recognized values for a name component.
75   *
76   * @param string $component
77   *   The component name (title, generational, or credentials).
78   *
79   * @return array
80   *   The list of recognized values for this component.
81   */
82  protected function getComponentOptions(string $component): array {
83    $plugin_options = $this->configuredComponentList($component);
84    if ($plugin_options !== NULL) {
85      return $plugin_options;
86    }
87    return $this->fieldSettingsComponentList($component);
88  }
89
90  /**
91   * {@inheritdoc}
92   */
93  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property): array {
94    $normalized = $this->normalizeWhitespace((string) $value);
95    $family_only = $this->familyOnlyComponentsIfSingleWord($normalized);
96    if ($family_only !== NULL) {
97      return $family_only;
98    }
99
100    $credential_lexicon = $this->getComponentOptions('credentials');
101    $generational_lexicon = $this->getComponentOptions('generational');
102    [$credential_parsed, $remainder] = $this->extractCredentialOrCommaSuffix(
103      $normalized,
104      $credential_lexicon,
105      $generational_lexicon,
106    );
107
108    return $this->mergeStructuredNameOntoParsed(
109      $credential_parsed,
110      $remainder,
111      $this->getComponentOptions('title'),
112      $generational_lexicon,
113    );
114  }
115
116  /**
117   * Trims outer whitespace and collapses repeated internal spaces.
118   */
119  protected function normalizeWhitespace(string $value): string {
120    $trimmed = trim($value);
121    $single_spaced = preg_replace('/  +/', ' ', $trimmed);
122    return is_string($single_spaced) ? $single_spaced : $trimmed;
123  }
124
125  /**
126   * Maps a single-token value to a family-only component list.
127   */
128  protected function familyOnlyComponentsIfSingleWord(string $normalized): ?array {
129    $tokens = explode(' ', $normalized);
130    if (count($tokens) === 1) {
131      return ['family' => $normalized];
132    }
133    return NULL;
134  }
135
136  /**
137   * Applies credential and comma-suffix rules in fixed priority order.
138   *
139   * @return array
140   *   A two-element list: partial component values, then the remainder string.
141   */
142  protected function extractCredentialOrCommaSuffix(
143    string $value,
144    array $credential_lexicon,
145    array $generational_lexicon,
146  ): array {
147    $split = $this->tryParentheticalCredentials($value)
148      ?? $this->tryCommaThenSlashTrailingCredentials($value)
149      ?? $this->tryLeadingCredentialWord($value, $credential_lexicon)
150      ?? $this->trySlashTrailingCredentials($value)
151      ?? $this->trySpacedDashTrailingCredentials($value)
152      ?? $this->tryCommaGenerationalOrCredentialsSuffix($value, $generational_lexicon)
153      ?? $this->tryTrailingCredentialWord($value, $credential_lexicon);
154
155    if ($split === NULL) {
156      return [[], $value];
157    }
158
159    return [$split['parsed'], $split['remainder']];
160  }
161
162  /**
163   * Tries to read credentials inside balanced parentheses.
164   *
165   * @return array|null
166   *   Keys `parsed` and `remainder`, or NULL when the pattern does not match.
167   */
168  protected function tryParentheticalCredentials(string $value): ?array {
169    $open_parenthesis = strpos($value, '(');
170    $close_parenthesis = strpos($value, ')');
171    $parentheses_are_invalid = (
172      $open_parenthesis === FALSE
173      || $close_parenthesis === FALSE
174      || $close_parenthesis <= $open_parenthesis
175    );
176    if ($parentheses_are_invalid) {
177      return NULL;
178    }
179    return [
180      'parsed' => [
181        'credentials' => trim(substr($value, $open_parenthesis + 1, $close_parenthesis - $open_parenthesis - 1)),
182      ],
183      'remainder' => substr($value, 0, $open_parenthesis),
184    ];
185  }
186
187  /**
188   * Tries comma-suffix credentials that include a slash after the comma.
189   *
190   * @return array|null
191   *   Keys `parsed` and `remainder`, or NULL when the pattern does not match.
192   */
193  protected function tryCommaThenSlashTrailingCredentials(string $value): ?array {
194    $comma_position = strpos($value, ',');
195    $slash_position = strpos($value, '/');
196    $comma_slash_is_invalid = (
197      $comma_position === FALSE
198      || $slash_position === FALSE
199      || $slash_position <= $comma_position
200    );
201    if ($comma_slash_is_invalid) {
202      return NULL;
203    }
204    return [
205      'parsed' => [
206        'credentials' => trim(substr($value, $comma_position + 1)),
207      ],
208      'remainder' => substr($value, 0, $comma_position),
209    ];
210  }
211
212  /**
213   * Tries a leading word that matches the configured credential list.
214   *
215   * @return array|null
216   *   Keys `parsed` and `remainder`, or NULL when the pattern does not match.
217   */
218  protected function tryLeadingCredentialWord(string $value, array $credential_lexicon): ?array {
219    if ($credential_lexicon === []) {
220      return NULL;
221    }
222    $tokens = explode(' ', $value);
223    $first_token = trim($tokens[0] ?? '');
224    if (!in_array($first_token, $credential_lexicon, FALSE)) {
225      return NULL;
226    }
227    $first_space = strpos($value, ' ');
228    if ($first_space === FALSE) {
229      return NULL;
230    }
231    return [
232      'parsed' => ['credentials' => trim($tokens[0])],
233      'remainder' => substr($value, $first_space),
234    ];
235  }
236
237  /**
238   * Tries slash-delimited trailing credentials.
239   *
240   * @return array|null
241   *   Keys `parsed` and `remainder`, or NULL when the pattern does not match.
242   */
243  protected function trySlashTrailingCredentials(string $value): ?array {
244    $slash_position = strpos($value, '/');
245    if ($slash_position === FALSE) {
246      return NULL;
247    }
248    return [
249      'parsed' => [
250        'credentials' => trim(substr($value, $slash_position + 1)),
251      ],
252      'remainder' => substr($value, 0, $slash_position),
253    ];
254  }
255
256  /**
257   * Tries dash-delimited trailing credentials using spaced dash markers.
258   *
259   * @return array|null
260   *   Keys `parsed` and `remainder`, or NULL when the pattern does not match.
261   */
262  protected function trySpacedDashTrailingCredentials(string $value): ?array {
263    $dash_after_space = strpos($value, ' -');
264    $dash_before_space = strpos($value, '- ');
265    $dash_position = ($dash_after_space !== FALSE) ? $dash_after_space : $dash_before_space;
266    if ($dash_position === FALSE) {
267      return NULL;
268    }
269    return [
270      'parsed' => [
271        'credentials' => trim(substr($value, $dash_position + 2)),
272      ],
273      'remainder' => substr($value, 0, $dash_position),
274    ];
275  }
276
277  /**
278   * Tries a comma suffix as generational text or as credentials.
279   *
280   * @return array|null
281   *   Keys `parsed` and `remainder`, or NULL when the pattern does not match.
282   */
283  protected function tryCommaGenerationalOrCredentialsSuffix(string $value, array $generational_lexicon): ?array {
284    $comma_position = strpos($value, ',');
285    if ($comma_position === FALSE) {
286      return NULL;
287    }
288    $after_comma = trim(substr($value, $comma_position + 1));
289    $parsed = in_array($after_comma, $generational_lexicon, FALSE)
290      ? ['generational' => $after_comma]
291      : ['credentials' => $after_comma];
292    return [
293      'parsed' => $parsed,
294      'remainder' => substr($value, 0, $comma_position),
295    ];
296  }
297
298  /**
299   * Tries a trailing word that matches the configured credential list.
300   *
301   * @return array|null
302   *   Keys `parsed` and `remainder`, or NULL when the pattern does not match.
303   */
304  protected function tryTrailingCredentialWord(string $value, array $credential_lexicon): ?array {
305    if ($credential_lexicon === []) {
306      return NULL;
307    }
308    $tokens = explode(' ', $value);
309    $last_token = trim((string) end($tokens));
310    if (!in_array($last_token, $credential_lexicon, FALSE)) {
311      return NULL;
312    }
313    $credential_token = array_pop($tokens);
314    $last_space = strrpos($value, ' ');
315    if ($last_space === FALSE) {
316      return NULL;
317    }
318    return [
319      'parsed' => ['credentials' => $credential_token],
320      'remainder' => substr($value, 0, $last_space),
321    ];
322  }
323
324  /**
325   * Merges structured name fields into the parsed component list.
326   */
327  protected function mergeStructuredNameOntoParsed(
328    array $parsed,
329    string $remainder,
330    array $title_options,
331    array $generational_options,
332  ): array {
333    $words = explode(' ', trim($remainder, " \t,/()-"));
334    if (in_array(trim($words[0] ?? ''), $title_options, FALSE)) {
335      $parsed['title'] = trim(array_shift($words));
336    }
337    if (in_array(trim((string) end($words)), $generational_options, FALSE)) {
338      $parsed['generational'] = trim(array_pop($words));
339    }
340    $parsed['given'] = trim(array_shift($words));
341    if (count($words) > 1) {
342      $parsed['middle'] = trim(array_shift($words));
343    }
344    $parsed['family'] = trim(implode(' ', $words));
345    return $parsed;
346  }
347
348  /**
349   * Returns a component list supplied directly on the plugin configuration.
350   *
351   * @return array|null
352   *   The configured list, or NULL when the plugin did not supply values.
353   */
354  protected function configuredComponentList(string $component): ?array {
355    if (empty($this->configuration[$component])) {
356      return NULL;
357    }
358    return (array) $this->configuration[$component];
359  }
360
361  /**
362   * Loads a component list from the destination field definition when present.
363   */
364  protected function fieldSettingsComponentList(string $component): array {
365    $entity_type_id = $this->configuration['entity_type'] ?? NULL;
366    $bundle_id = $this->configuration['bundle'] ?? NULL;
367    $field_name = $this->configuration['field_name'] ?? NULL;
368    $field_context_is_incomplete = (
369      !$entity_type_id || !$bundle_id || !$field_name
370    );
371    if ($field_context_is_incomplete) {
372      return [];
373    }
374    $field_definitions = $this->entity_field_manager->getFieldDefinitions($entity_type_id, $bundle_id);
375    if (!isset($field_definitions[$field_name])) {
376      return [];
377    }
378    $settings = $field_definitions[$field_name]->getSettings();
379    $options_key = $component . '_options';
380    if (empty($settings[$options_key])) {
381      return [];
382    }
383    $as_strings = array_map(static fn($item): string => (string) $item, $settings[$options_key]);
384    return $this->optionListWithoutEmptyPlaceholders($as_strings);
385  }
386
387  /**
388   * Removes placeholder markers such as empty select options from a list.
389   *
390   * @param array $options
391   *   Raw option strings, typically from field storage settings.
392   *
393   * @return array
394   *   The filtered list with placeholder entries removed.
395   */
396  protected function optionListWithoutEmptyPlaceholders(array $options): array {
397    return array_values(array_filter($options, static fn($option): bool => !str_starts_with(trim((string) $option), '--')));
398  }
399
400}