89a1d8ffbd6a7074c7e3448d170f2685b8f30926
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / DataHandling / Localization / DataMapProcessor.php
1 <?php
2 namespace TYPO3\CMS\Core\DataHandling\Localization;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Backend\Utility\BackendUtility;
18 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
19 use TYPO3\CMS\Core\Database\Connection;
20 use TYPO3\CMS\Core\Database\ConnectionPool;
21 use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
22 use TYPO3\CMS\Core\Database\RelationHandler;
23 use TYPO3\CMS\Core\DataHandling\DataHandler;
24 use TYPO3\CMS\Core\Utility\GeneralUtility;
25 use TYPO3\CMS\Core\Utility\MathUtility;
26 use TYPO3\CMS\Core\Utility\StringUtility;
27
28 /**
29 * This processor analyses the provided data-map before actually being process
30 * in the calling DataHandler instance. Field names that are configured to have
31 * "allowLanguageSynchronization" enabled are either synchronized from there
32 * relative parent records (could be a default language record, or a l10n_source
33 * record) or to their dependent records (in case a default language record or
34 * nested records pointing upwards with l10n_source).
35 *
36 * Except inline relational record editing, all modifications are applied to
37 * the data-map directly, which ensures proper history entries as a side-effect.
38 * For inline relational record editing, this processor either triggers the copy
39 * or localize actions by instantiation a new local DataHandler instance.
40 */
41 class DataMapProcessor
42 {
43 /**
44 * @var array
45 */
46 protected $dataMap = [];
47
48 /**
49 * @var BackendUserAuthentication
50 */
51 protected $backendUser;
52
53 /**
54 * @var DataMapItem[]
55 */
56 protected $items = [];
57
58 /**
59 * Class generator
60 *
61 * @param array $dataMap The submitted data-map to be worked on
62 * @param BackendUserAuthentication $backendUser Forwared backend-user scope
63 * @return DataMapProcessor
64 */
65 public static function instance(array $dataMap, BackendUserAuthentication $backendUser)
66 {
67 return GeneralUtility::makeInstance(
68 static::class,
69 $dataMap,
70 $backendUser
71 );
72 }
73
74 /**
75 * @param array $dataMap The submitted data-map to be worked on
76 * @param BackendUserAuthentication $backendUser Forwared backend-user scope
77 */
78 public function __construct(array $dataMap, BackendUserAuthentication $backendUser)
79 {
80 $this->dataMap = $dataMap;
81 $this->backendUser = $backendUser;
82 }
83
84 /**
85 * Processes the submitted data-map and returns the sanitized and enriched
86 * version depending on accordant localization states and dependencies.
87 *
88 * @return array
89 */
90 public function process()
91 {
92 foreach ($this->dataMap as $tableName => $idValues) {
93 $this->collectItems($tableName, $idValues);
94 }
95 $this->sanitize();
96 $this->enrich();
97 return $this->dataMap;
98 }
99
100 /**
101 * Create data map items of all affected rows
102 *
103 * @param string $tableName
104 * @param array $idValues
105 */
106 protected function collectItems(string $tableName, array $idValues)
107 {
108 if (!$this->isApplicable($tableName)) {
109 return;
110 }
111
112 $fieldNames = [
113 'uid' => 'uid',
114 'l10n_state' => 'l10n_state',
115 'language' => $GLOBALS['TCA'][$tableName]['ctrl']['languageField'],
116 'parent' => $GLOBALS['TCA'][$tableName]['ctrl']['transOrigPointerField'],
117 ];
118 if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['translationSource'])) {
119 $fieldNames['source'] = $GLOBALS['TCA'][$tableName]['ctrl']['translationSource'];
120 }
121
122 $translationValues = $this->fetchTranslationValues(
123 $tableName,
124 $fieldNames,
125 $this->filterNumericIds(array_keys($idValues))
126 );
127
128 $dependencies = $this->fetchDependencies(
129 $tableName,
130 $this->filterNumericIds(array_keys($idValues))
131 );
132
133 foreach ($idValues as $id => $values) {
134 $recordValues = $translationValues[$id] ?? [];
135 $item = DataMapItem::build(
136 $tableName,
137 $id,
138 $values,
139 $recordValues,
140 $fieldNames
141 );
142
143 // must be any kind of localization and in connected mode
144 if ($item->getLanguage() > 0 && empty($item->getParent())) {
145 unset($item);
146 continue;
147 }
148 // add dependencies
149 if (!empty($dependencies[$id])) {
150 $item->setDependencies($dependencies[$id]);
151 }
152 $this->items[$tableName . ':' . $id] = $item;
153 }
154 }
155
156 /**
157 * Sanitizes the submitted data-map and removes fields which are not
158 * defined as custom and thus rely on either parent or source values.
159 */
160 protected function sanitize()
161 {
162 foreach (['grandChild', 'directChild'] as $type) {
163 foreach ($this->filterItemsByType($type) as $item) {
164 $this->sanitizeTranslationItem($item);
165 }
166 }
167 }
168
169 /**
170 * Handle synchronization of an item list
171 */
172 protected function enrich()
173 {
174 foreach (['grandChild', 'directChild'] as $type) {
175 foreach ($this->filterItemsByType($type) as $item) {
176 foreach ($item->getApplicableScopes() as $scope) {
177 $fromId = $item->getIdForScope($scope);
178 $fieldNames = $this->getFieldNamesForItemScope($item, $scope, !$item->isNew());
179 $this->synchronizeTranslationItem($item, $fieldNames, $fromId);
180 }
181 $this->populateTranslationItem($item);
182 $this->finishTranslationItem($item);
183 }
184 }
185 foreach ($this->filterItemsByType('parent') as $item) {
186 $this->populateTranslationItem($item);
187 }
188 }
189
190 /**
191 * Sanitizes the submitted data-map for a particular item and removes
192 * fields which are not defined as custom and thus rely on either parent
193 * or source values.
194 *
195 * @param DataMapItem $item
196 */
197 protected function sanitizeTranslationItem(DataMapItem $item)
198 {
199 $fieldNames = array_merge(
200 $this->getFieldNamesForItemScope($item, DataMapItem::SCOPE_PARENT, !$item->isNew()),
201 $this->getFieldNamesForItemScope($item, DataMapItem::SCOPE_SOURCE, !$item->isNew())
202 );
203 // remove fields, that are submitted in data-map, but not defined as custom
204 $this->dataMap[$item->getTableName()][$item->getId()] = array_diff_key(
205 $this->dataMap[$item->getTableName()][$item->getId()],
206 array_combine($fieldNames, $fieldNames)
207 );
208 }
209
210 /**
211 * Synchronize a single item
212 *
213 * @param DataMapItem $item
214 * @param array $fieldNames
215 * @param int $fromId
216 */
217 protected function synchronizeTranslationItem(DataMapItem $item, array $fieldNames, int $fromId)
218 {
219 if (empty($fieldNames)) {
220 return;
221 }
222 $fieldNameList = 'uid,' . implode(',', $fieldNames);
223 $fromRecord = BackendUtility::getRecordWSOL(
224 $item->getFromTableName(),
225 $fromId,
226 $fieldNameList
227 );
228 $forRecord = [];
229 if (!$item->isNew()) {
230 $forRecord = BackendUtility::getRecordWSOL(
231 $item->getTableName(),
232 $item->getId(),
233 $fieldNameList
234 );
235 }
236 foreach ($fieldNames as $fieldName) {
237 $this->synchronizeFieldValues(
238 $item,
239 $fieldName,
240 $fromRecord,
241 $forRecord
242 );
243 }
244 }
245
246 /**
247 * Populates values downwards, either from a parent language item or
248 * a source language item to an accordant dependent translation item.
249 *
250 * @param DataMapItem $item
251 */
252 protected function populateTranslationItem(DataMapItem $item)
253 {
254 if ($item->isNew()) {
255 return;
256 }
257
258 foreach ([State::STATE_PARENT, State::STATE_SOURCE] as $scope) {
259 foreach ($item->findDependencies($scope) as $dependentItem) {
260 // use suggested item, if it was submitted in data-map
261 $suggestedDependentItem = $this->findItem(
262 $dependentItem->getTableName(),
263 $dependentItem->getId()
264 );
265 if ($suggestedDependentItem !== null) {
266 $dependentItem = $suggestedDependentItem;
267 }
268 $fieldNames = $this->getFieldNamesForItemScope(
269 $dependentItem,
270 $scope,
271 false
272 );
273 $this->synchronizeTranslationItem(
274 $dependentItem,
275 $fieldNames,
276 $item->getId()
277 );
278 }
279 }
280 }
281
282 /**
283 * Finishes a translation item by updating states to be persisted.
284 *
285 * @param DataMapItem $item
286 */
287 protected function finishTranslationItem(DataMapItem $item)
288 {
289 if (
290 $item->isParentType()
291 || !State::isApplicable($item->getTableName())
292 ) {
293 return;
294 }
295
296 $this->dataMap[$item->getTableName()][$item->getId()]['l10n_state'] = $item->getState()->export();
297 }
298
299 /**
300 * Synchronize simple values like text and similar
301 *
302 * @param DataMapItem $item
303 * @param string $fieldName
304 * @param array $fromRecord
305 * @param array $forRecord
306 */
307 protected function synchronizeFieldValues(DataMapItem $item, string $fieldName, array $fromRecord, array $forRecord)
308 {
309 // skip if this field has been processed already, assumed that proper sanitation happened
310 if (!empty($this->dataMap[$item->getTableName()][$item->getId()][$fieldName])) {
311 return;
312 }
313
314 $fromId = $fromRecord['uid'];
315 $fromValue = $this->dataMap[$item->getFromTableName()][$fromId][$fieldName] ?? $fromRecord[$fieldName];
316
317 // plain values
318 if (!$this->isRelationField($item->getFromTableName(), $fieldName)) {
319 $this->dataMap[$item->getTableName()][$item->getId()][$fieldName] = $fromValue;
320 // direct relational values
321 } elseif (!$this->isInlineRelationField($item->getFromTableName(), $fieldName)) {
322 $this->synchronizeDirectRelations($item, $fieldName, $fromRecord);
323 // inline relational values
324 } else {
325 $this->synchronizeInlineRelations($item, $fieldName, $fromRecord, $forRecord);
326 }
327 }
328
329 /**
330 * Synchronize select and group field localizations
331 *
332 * @param DataMapItem $item
333 * @param string $fieldName
334 * @param array $fromRecord
335 */
336 protected function synchronizeDirectRelations(DataMapItem $item, string $fieldName, array $fromRecord)
337 {
338 $fromId = $fromRecord['uid'];
339 $fromValue = $this->dataMap[$item->getFromTableName()][$fromId][$fieldName] ?? $fromRecord[$fieldName];
340 $configuration = $GLOBALS['TCA'][$item->getFromTableName()]['columns'][$fieldName];
341
342 // non-MM relations are stored as comma separated values, just use them
343 // if values are available in data-map already, just use them as well
344 if (
345 empty($configuration['config']['MM'])
346 || isset($this->dataMap[$item->getFromTableName()][$fromId][$fieldName])
347 || ($configuration['config']['special'] ?? null) === 'languages'
348 ) {
349 $this->dataMap[$item->getTableName()][$item->getId()][$fieldName] = $fromValue;
350 return;
351 }
352
353 // fetch MM relations from storage
354 $type = $configuration['config']['type'];
355 $manyToManyTable = $configuration['config']['MM'];
356 if ($type === 'group' && $configuration['config']['internal_type'] === 'db') {
357 $tableNames = trim($configuration['config']['allowed'] ?? '');
358 } elseif ($configuration['config']['type'] === 'select') {
359 $tableNames = ($configuration['foreign_table'] ?? '');
360 } else {
361 return;
362 }
363
364 $relationHandler = $this->createRelationHandler();
365 $relationHandler->start(
366 '',
367 $tableNames,
368 $manyToManyTable,
369 $fromId,
370 $item->getFromTableName(),
371 $configuration['config']
372 );
373
374 // provide list of relations, optionally prepended with table name
375 // e.g. "13,19,23" or "tt_content_27,tx_extension_items_28"
376 $this->dataMap[$item->getTableName()][$item->getId()][$fieldName] = implode(
377 ',',
378 $relationHandler->getValueArray()
379 );
380 }
381
382 /**
383 * Handle synchonization of inline relations
384 *
385 * @param DataMapItem $item
386 * @param string $fieldName
387 * @param array $fromRecord
388 * @param array $forRecord
389 */
390 protected function synchronizeInlineRelations(DataMapItem $item, string $fieldName, array $fromRecord, array $forRecord)
391 {
392 $fromId = $fromRecord['uid'];
393 $configuration = $GLOBALS['TCA'][$item->getFromTableName()]['columns'][$fieldName];
394 $foreignTableName = $configuration['config']['foreign_table'];
395 $manyToManyTable = ($configuration['config']['MM'] ?? '');
396
397 $languageFieldName = ($GLOBALS['TCA'][$foreignTableName]['ctrl']['languageField'] ?? null);
398 $parentFieldName = ($GLOBALS['TCA'][$foreignTableName]['ctrl']['transOrigPointerField'] ?? null);
399 $sourceFieldName = ($GLOBALS['TCA'][$foreignTableName]['ctrl']['translationSource'] ?? null);
400
401 // determine suggested elements of either translation parent or source record
402 // from data-map, in case the accordant language parent/source record was modified
403 if (isset($this->dataMap[$item->getFromTableName()][$fromId][$fieldName])) {
404 $suggestedAncestorIds = GeneralUtility::trimExplode(
405 ',',
406 $this->dataMap[$item->getFromTableName()][$fromId][$fieldName],
407 true
408 );
409 // determine suggested elements of either translation parent or source record from storage
410 } else {
411 $relationHandler = $this->createRelationHandler();
412 $relationHandler->start(
413 $fromRecord[$fieldName],
414 $foreignTableName,
415 $manyToManyTable,
416 $fromId,
417 $item->getFromTableName(),
418 $configuration['config']
419 );
420 $suggestedAncestorIds = $this->mapRelationItemId($relationHandler->itemArray);
421 }
422 // determine persisted elements for the current data-map item
423 $relationHandler = $this->createRelationHandler();
424 $relationHandler->start(
425 $forRecord[$fieldName] ?? '',
426 $foreignTableName,
427 $manyToManyTable,
428 $item->getId(),
429 $item->getTableName(),
430 $configuration['config']
431 );
432 $persistedIds = $this->mapRelationItemId($relationHandler->itemArray);
433 // The dependent ID map points from language parent/source record to
434 // localization, thus keys: parents/sources & values: localizations
435 $dependentIdMap = $this->fetchDependentIdMap($foreignTableName, $suggestedAncestorIds);
436 // filter incomplete structures - this is a drawback of DataHandler's remap stack, since
437 // just created IRRE translations still belong to the language parent - filter them out
438 $suggestedAncestorIds = array_diff($suggestedAncestorIds, array_values($dependentIdMap));
439 // compile element differences to be resolved
440 // remove elements that are persisted at the language translation, but not required anymore
441 $removeIds = array_diff($persistedIds, array_values($dependentIdMap));
442 // remove elements that are persisted at the language parent/source, but not required anymore
443 $removeAncestorIds = array_diff(array_keys($dependentIdMap), $suggestedAncestorIds);
444 // missing elements that are persisted at the language parent/source, but not translated yet
445 $missingAncestorIds = array_diff($suggestedAncestorIds, array_keys($dependentIdMap));
446 // persisted elements that should be copied or localized
447 $createAncestorIds = $this->filterNumericIds($missingAncestorIds, true);
448 // non-persisted elements that should be duplicated in data-map directly
449 $populateAncestorIds = $this->filterNumericIds($missingAncestorIds, false);
450 // this desired state map defines the final result of child elements of the translation
451 $desiredLocalizationIdMap = array_combine($suggestedAncestorIds, $suggestedAncestorIds);
452 // update existing translations in the desired state map
453 foreach ($dependentIdMap as $ancestorId => $translationId) {
454 if (isset($desiredLocalizationIdMap[$ancestorId])) {
455 $desiredLocalizationIdMap[$ancestorId] = $translationId;
456 }
457 }
458 // nothing to synchronize, but element order could have been changed
459 if (empty($removeAncestorIds) && empty($missingAncestorIds)) {
460 $this->dataMap[$item->getTableName()][$item->getId()][$fieldName] = implode(
461 ',',
462 array_values($desiredLocalizationIdMap)
463 );
464 return;
465 }
466
467 $localCommandMap = [];
468 foreach ($removeIds as $removeId) {
469 $localCommandMap[$foreignTableName][$removeId]['delete'] = true;
470 }
471 foreach ($removeAncestorIds as $removeAncestorId) {
472 $removeId = $dependentIdMap[$removeAncestorId];
473 $localCommandMap[$foreignTableName][$removeId]['delete'] = true;
474 }
475 foreach ($createAncestorIds as $createAncestorId) {
476 // if child table is not aware of localization, just copy
477 if (empty($languageFieldName) || empty($parentFieldName)) {
478 $localCommandMap[$foreignTableName][$createAncestorId]['copy'] = true;
479 // otherwise, trigger the localization process
480 } else {
481 $localCommandMap[$foreignTableName][$createAncestorId]['localize'] = $item->getLanguage();
482 }
483 }
484 // execute copy, localize and delete actions on persisted child records
485 if (!empty($localCommandMap)) {
486 $localDataHandler = GeneralUtility::makeInstance(DataHandler::class);
487 $localDataHandler->start([], $localCommandMap, $this->backendUser);
488 $localDataHandler->process_cmdmap();
489 // update copied or localized ids
490 foreach ($createAncestorIds as $createAncestorId) {
491 if (empty($localDataHandler->copyMappingArray[$foreignTableName][$createAncestorId])) {
492 throw new \RuntimeException('Child record was not processed', 1486233164);
493 }
494 $newLocalizationId = $localDataHandler->copyMappingArray[$foreignTableName][$createAncestorId];
495 $newLocalizationId = $localDataHandler->getAutoVersionId($foreignTableName, $newLocalizationId) ?? $newLocalizationId;
496 $desiredLocalizationIdMap[$createAncestorId] = $newLocalizationId;
497 }
498 }
499 // populate new child records in data-map
500 if (!empty($populateAncestorIds)) {
501 foreach ($populateAncestorIds as $populateId) {
502 $newLocalizationId = StringUtility::getUniqueId('NEW');
503 $desiredLocalizationIdMap[$populateId] = $newLocalizationId;
504 // @todo l10n_mode=prefixLangTitle is not applied to this "in-memory translation"
505 $this->dataMap[$foreignTableName][$newLocalizationId] = $this->dataMap[$foreignTableName][$populateId];
506 $this->dataMap[$foreignTableName][$newLocalizationId][$languageFieldName] = $item->getLanguage();
507 // @todo Only $populatedIs used in TCA type 'select' is resolved in DataHandler's remapStack
508 $this->dataMap[$foreignTableName][$newLocalizationId][$parentFieldName] = $populateId;
509 if ($sourceFieldName !== null) {
510 // @todo Not sure, whether $populateId is resolved in DataHandler's remapStack
511 $this->dataMap[$foreignTableName][$newLocalizationId][$sourceFieldName] = $populateId;
512 }
513 }
514 }
515 // update inline parent field references - required to update pointer fields
516 $this->dataMap[$item->getTableName()][$item->getId()][$fieldName] = implode(
517 ',',
518 array_values($desiredLocalizationIdMap)
519 );
520 }
521
522 /**
523 * Fetches translation related field values for the items submitted in
524 * the data-map. That's why further adjustment for the tables pages vs.
525 * pages_language_overlay is not required.
526 *
527 * @param string $tableName
528 * @param array $fieldNames
529 * @param array $ids
530 * @return array
531 */
532 protected function fetchTranslationValues(string $tableName, array $fieldNames, array $ids)
533 {
534 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
535 ->getQueryBuilderForTable($tableName);
536 $queryBuilder->getRestrictions()
537 ->removeAll()
538 ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
539 $statement = $queryBuilder
540 ->select(...array_values($fieldNames))
541 ->from($tableName)
542 ->where(
543 $queryBuilder->expr()->in(
544 'uid',
545 $queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY)
546 )
547 )
548 ->execute();
549
550 $translationValues = [];
551 foreach ($statement as $record) {
552 $translationValues[$record['uid']] = $record;
553 }
554 return $translationValues;
555 }
556
557 /**
558 * Create arary of dependent records
559 *
560 * @param string $tableName
561 * @param array $ids
562 * @return DataMapItem[][]
563 */
564 protected function fetchDependencies(string $tableName, array $ids)
565 {
566 if ($tableName === 'pages') {
567 $tableName = 'pages_language_overlay';
568 }
569
570 $fieldNames = [
571 'uid' => 'uid',
572 'l10n_state' => 'l10n_state',
573 'language' => $GLOBALS['TCA'][$tableName]['ctrl']['languageField'],
574 'parent' => $GLOBALS['TCA'][$tableName]['ctrl']['transOrigPointerField'],
575 ];
576 if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['translationSource'])) {
577 $fieldNames['source'] = $GLOBALS['TCA'][$tableName]['ctrl']['translationSource'];
578 }
579
580 $dependentElements = $this->fetchDependentElements($tableName, $ids, $fieldNames);
581
582 $dependencyMap = [];
583 foreach ($dependentElements as $dependentElement) {
584 $dependentItem = DataMapItem::build(
585 $tableName,
586 $dependentElement['uid'],
587 [],
588 $dependentElement,
589 $fieldNames
590 );
591
592 if ($dependentItem->isDirectChildType()) {
593 $dependencyMap[$dependentItem->getParent()][State::STATE_PARENT][] = $dependentItem;
594 }
595 if ($dependentItem->isGrandChildType()) {
596 $dependencyMap[$dependentItem->getSource()][State::STATE_SOURCE][] = $dependentItem;
597 }
598 }
599 return $dependencyMap;
600 }
601
602 /**
603 * Fetch dependent records that depend on given record id's in their parent or source field and
604 * create an id map as further lookup array
605 *
606 * @param string $tableName
607 * @param array $ids
608 * @return array
609 */
610 protected function fetchDependentIdMap(string $tableName, array $ids)
611 {
612 if ($tableName === 'pages') {
613 $tableName = 'pages_language_overlay';
614 }
615
616 $fieldNames = [
617 'uid' => 'uid',
618 'l10n_state' => 'l10n_state',
619 'language' => $GLOBALS['TCA'][$tableName]['ctrl']['languageField'],
620 'parent' => $GLOBALS['TCA'][$tableName]['ctrl']['transOrigPointerField'],
621 ];
622 if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['translationSource'])) {
623 $fieldNames['source'] = $GLOBALS['TCA'][$tableName]['ctrl']['translationSource'];
624 }
625
626 $dependentElements = $this->fetchDependentElements($tableName, $ids, $fieldNames);
627
628 $dependentIdMap = [];
629 foreach ($dependentElements as $dependentElement) {
630 // implicit: having source value different to parent value, use source pointer
631 if (
632 !empty($fieldNames['source'])
633 && $dependentElement[$fieldNames['source']] !== $dependentElement[$fieldNames['parent']]
634 ) {
635 $dependentIdMap[$dependentElement[$fieldNames['source']]] = $dependentElement['uid'];
636 // implicit: otherwise, use parent pointer
637 } else {
638 $dependentIdMap[$dependentElement[$fieldNames['parent']]] = $dependentElement['uid'];
639 }
640 }
641 return $dependentIdMap;
642 }
643
644 /**
645 * Fetch all elements that depend on given record id's in their parent or source field
646 *
647 * @param string $tableName
648 * @param array $ids
649 * @param array|null $fieldNames
650 * @return array
651 */
652 protected function fetchDependentElements(string $tableName, array $ids, array $fieldNames)
653 {
654 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
655 ->getQueryBuilderForTable($tableName);
656 $queryBuilder->getRestrictions()
657 ->removeAll()
658 ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
659
660 $predicates = [
661 $queryBuilder->expr()->in(
662 $fieldNames['parent'],
663 $queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY)
664 )
665 ];
666
667 if (!empty($fieldNames['source'])) {
668 $predicates = [
669 $queryBuilder->expr()->in(
670 $fieldNames['source'],
671 $queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY)
672 )
673 ];
674 }
675
676 $statement = $queryBuilder
677 ->select(...array_values($fieldNames))
678 ->from($tableName)
679 ->andWhere(
680 // must be any kind of localization
681 $queryBuilder->expr()->gt(
682 $fieldNames['language'],
683 $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
684 ),
685 // must be in connected mode
686 $queryBuilder->expr()->gt(
687 $fieldNames['parent'],
688 $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
689 ),
690 // any parent or source pointers
691 $queryBuilder->expr()->orX(...$predicates)
692 )
693 ->execute();
694
695 $dependentElements = [];
696 foreach ($statement as $record) {
697 $dependentElements[] = $record;
698 }
699 return $dependentElements;
700 }
701
702 /**
703 * Return array of data map items that are of given type
704 *
705 * @param string $type
706 * @return DataMapItem[]
707 */
708 protected function filterItemsByType(string $type)
709 {
710 return array_filter(
711 $this->items,
712 function (DataMapItem $item) use ($type) {
713 return $item->getType() === $type;
714 }
715 );
716 }
717
718 /**
719 * Return only id's that are integer - so no NEW...
720 *
721 * @param array $ids
722 * @param bool $numeric
723 * @return array
724 */
725 protected function filterNumericIds(array $ids, bool $numeric = true)
726 {
727 return array_filter(
728 $ids,
729 function ($id) use ($numeric) {
730 return MathUtility::canBeInterpretedAsInteger($id) === $numeric;
731 }
732 );
733 }
734
735 /**
736 * Flatten array
737 *
738 * @param array $relationItems
739 * @return string[]
740 */
741 protected function mapRelationItemId(array $relationItems)
742 {
743 return array_map(
744 function (array $relationItem) {
745 return (string)$relationItem['id'];
746 },
747 $relationItems
748 );
749 }
750
751 /**
752 * See if an items is in item list and return it
753 *
754 * @param string $tableName
755 * @param string|int $id
756 * @return null|DataMapItem
757 */
758 protected function findItem(string $tableName, $id)
759 {
760 return $this->items[$tableName . ':' . $id] ?? null;
761 }
762
763 /**
764 * Field names we have to deal with
765 *
766 * @param DataMapItem $item
767 * @param string $scope
768 * @param null|bool $modified
769 * @return string[]
770 */
771 protected function getFieldNamesForItemScope(
772 DataMapItem $item,
773 string $scope,
774 bool $modified
775 ) {
776 if (
777 $scope === DataMapItem::SCOPE_PARENT
778 || $scope === DataMapItem::SCOPE_SOURCE
779 ) {
780 if (!State::isApplicable($item->getTableName())) {
781 return [];
782 }
783 return $item->getState()->filterFieldNames($scope, $modified);
784 }
785 if ($scope === DataMapItem::SCOPE_EXCLUDE) {
786 return $this->getLocalizationModeExcludeFieldNames(
787 $item->getTableName()
788 );
789 }
790 return [];
791 }
792
793 /**
794 * Field names of TCA table with columns having l10n_mode=exclude
795 *
796 * @param string $tableName
797 * @return string[]
798 */
799 protected function getLocalizationModeExcludeFieldNames(string $tableName)
800 {
801 $localizationExcludeFieldNames = [];
802 if (empty($GLOBALS['TCA'][$tableName]['columns'])) {
803 return $localizationExcludeFieldNames;
804 }
805
806 foreach ($GLOBALS['TCA'][$tableName]['columns'] as $fieldName => $configuration) {
807 if (($configuration['l10n_mode'] ?? null) === 'exclude') {
808 $localizationExcludeFieldNames[] = $fieldName;
809 }
810 }
811
812 return $localizationExcludeFieldNames;
813 }
814
815 /**
816 * True if we're dealing with a field that has foreign db relations
817 *
818 * @param string $tableName
819 * @param string $fieldName
820 * @return bool True if field is type=group with internalType === db or select with foreign_table
821 */
822 protected function isRelationField(string $tableName, string $fieldName): bool
823 {
824 if (empty($GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config']['type'])) {
825 return false;
826 }
827
828 $configuration = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
829
830 return
831 $configuration['type'] === 'group'
832 && ($configuration['internal_type'] ?? null) === 'db'
833 && !empty($configuration['allowed'])
834 || $configuration['type'] === 'select'
835 && (
836 !empty($configuration['foreign_table'])
837 && !empty($GLOBALS['TCA'][$configuration['foreign_table']])
838 || ($configuration['special'] ?? null) === 'languages'
839 )
840 || $this->isInlineRelationField($tableName, $fieldName)
841 ;
842 }
843
844 /**
845 * True if we're dealing with an inline field
846 *
847 * @param string $tableName
848 * @param string $fieldName
849 * @return bool TRUE if field is of type inline with foreign_table set
850 */
851 protected function isInlineRelationField(string $tableName, string $fieldName): bool
852 {
853 if (empty($GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config']['type'])) {
854 return false;
855 }
856
857 $configuration = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
858
859 return
860 $configuration['type'] === 'inline'
861 && !empty($configuration['foreign_table'])
862 && !empty($GLOBALS['TCA'][$configuration['foreign_table']])
863 ;
864 }
865
866 /**
867 * Determines whether the table can be localized and either has fields
868 * with allowLanguageSynchronization enabled or l10n_mode set to exclude.
869 *
870 * @param string $tableName
871 * @return bool
872 */
873 protected function isApplicable(string $tableName): bool
874 {
875 return
876 State::isApplicable($tableName)
877 || BackendUtility::isTableLocalizable($tableName)
878 && count($this->getLocalizationModeExcludeFieldNames($tableName)) > 0
879 ;
880 }
881
882 /**
883 * @return RelationHandler
884 */
885 protected function createRelationHandler()
886 {
887 $relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
888 $relationHandler->setWorkspaceId($this->backendUser->workspace);
889 return $relationHandler;
890 }
891 }