[BUGFIX] Copying a record with translation and IRRE relation fails
[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 if (!BackendUtility::isTableLocalizable($tableName)) {
571 return [];
572 }
573
574 $fieldNames = [
575 'uid' => 'uid',
576 'l10n_state' => 'l10n_state',
577 'language' => $GLOBALS['TCA'][$tableName]['ctrl']['languageField'],
578 'parent' => $GLOBALS['TCA'][$tableName]['ctrl']['transOrigPointerField'],
579 ];
580 if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['translationSource'])) {
581 $fieldNames['source'] = $GLOBALS['TCA'][$tableName]['ctrl']['translationSource'];
582 }
583
584 $dependentElements = $this->fetchDependentElements($tableName, $ids, $fieldNames);
585
586 $dependencyMap = [];
587 foreach ($dependentElements as $dependentElement) {
588 $dependentItem = DataMapItem::build(
589 $tableName,
590 $dependentElement['uid'],
591 [],
592 $dependentElement,
593 $fieldNames
594 );
595
596 if ($dependentItem->isDirectChildType()) {
597 $dependencyMap[$dependentItem->getParent()][State::STATE_PARENT][] = $dependentItem;
598 }
599 if ($dependentItem->isGrandChildType()) {
600 $dependencyMap[$dependentItem->getSource()][State::STATE_SOURCE][] = $dependentItem;
601 }
602 }
603 return $dependencyMap;
604 }
605
606 /**
607 * Fetch dependent records that depend on given record id's in their parent or source field and
608 * create an id map as further lookup array
609 *
610 * @param string $tableName
611 * @param array $ids
612 * @return array
613 */
614 protected function fetchDependentIdMap(string $tableName, array $ids)
615 {
616 if ($tableName === 'pages') {
617 $tableName = 'pages_language_overlay';
618 }
619
620 if (!BackendUtility::isTableLocalizable($tableName)) {
621 return [];
622 }
623
624 $fieldNames = [
625 'uid' => 'uid',
626 'l10n_state' => 'l10n_state',
627 'language' => $GLOBALS['TCA'][$tableName]['ctrl']['languageField'],
628 'parent' => $GLOBALS['TCA'][$tableName]['ctrl']['transOrigPointerField'],
629 ];
630 if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['translationSource'])) {
631 $fieldNames['source'] = $GLOBALS['TCA'][$tableName]['ctrl']['translationSource'];
632 }
633
634 $dependentElements = $this->fetchDependentElements($tableName, $ids, $fieldNames);
635
636 $dependentIdMap = [];
637 foreach ($dependentElements as $dependentElement) {
638 // implicit: having source value different to parent value, use source pointer
639 if (
640 !empty($fieldNames['source'])
641 && $dependentElement[$fieldNames['source']] !== $dependentElement[$fieldNames['parent']]
642 ) {
643 $dependentIdMap[$dependentElement[$fieldNames['source']]] = $dependentElement['uid'];
644 // implicit: otherwise, use parent pointer
645 } else {
646 $dependentIdMap[$dependentElement[$fieldNames['parent']]] = $dependentElement['uid'];
647 }
648 }
649 return $dependentIdMap;
650 }
651
652 /**
653 * Fetch all elements that depend on given record id's in their parent or source field
654 *
655 * @param string $tableName
656 * @param array $ids
657 * @param array|null $fieldNames
658 * @return array
659 */
660 protected function fetchDependentElements(string $tableName, array $ids, array $fieldNames)
661 {
662 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
663 ->getQueryBuilderForTable($tableName);
664 $queryBuilder->getRestrictions()
665 ->removeAll()
666 ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
667
668 $predicates = [
669 $queryBuilder->expr()->in(
670 $fieldNames['parent'],
671 $queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY)
672 )
673 ];
674
675 if (!empty($fieldNames['source'])) {
676 $predicates = [
677 $queryBuilder->expr()->in(
678 $fieldNames['source'],
679 $queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY)
680 )
681 ];
682 }
683
684 $statement = $queryBuilder
685 ->select(...array_values($fieldNames))
686 ->from($tableName)
687 ->andWhere(
688 // must be any kind of localization
689 $queryBuilder->expr()->gt(
690 $fieldNames['language'],
691 $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
692 ),
693 // must be in connected mode
694 $queryBuilder->expr()->gt(
695 $fieldNames['parent'],
696 $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
697 ),
698 // any parent or source pointers
699 $queryBuilder->expr()->orX(...$predicates)
700 )
701 ->execute();
702
703 $dependentElements = [];
704 foreach ($statement as $record) {
705 $dependentElements[] = $record;
706 }
707 return $dependentElements;
708 }
709
710 /**
711 * Return array of data map items that are of given type
712 *
713 * @param string $type
714 * @return DataMapItem[]
715 */
716 protected function filterItemsByType(string $type)
717 {
718 return array_filter(
719 $this->items,
720 function (DataMapItem $item) use ($type) {
721 return $item->getType() === $type;
722 }
723 );
724 }
725
726 /**
727 * Return only id's that are integer - so no NEW...
728 *
729 * @param array $ids
730 * @param bool $numeric
731 * @return array
732 */
733 protected function filterNumericIds(array $ids, bool $numeric = true)
734 {
735 return array_filter(
736 $ids,
737 function ($id) use ($numeric) {
738 return MathUtility::canBeInterpretedAsInteger($id) === $numeric;
739 }
740 );
741 }
742
743 /**
744 * Flatten array
745 *
746 * @param array $relationItems
747 * @return string[]
748 */
749 protected function mapRelationItemId(array $relationItems)
750 {
751 return array_map(
752 function (array $relationItem) {
753 return (string)$relationItem['id'];
754 },
755 $relationItems
756 );
757 }
758
759 /**
760 * See if an items is in item list and return it
761 *
762 * @param string $tableName
763 * @param string|int $id
764 * @return null|DataMapItem
765 */
766 protected function findItem(string $tableName, $id)
767 {
768 return $this->items[$tableName . ':' . $id] ?? null;
769 }
770
771 /**
772 * Field names we have to deal with
773 *
774 * @param DataMapItem $item
775 * @param string $scope
776 * @param null|bool $modified
777 * @return string[]
778 */
779 protected function getFieldNamesForItemScope(
780 DataMapItem $item,
781 string $scope,
782 bool $modified
783 ) {
784 if (
785 $scope === DataMapItem::SCOPE_PARENT
786 || $scope === DataMapItem::SCOPE_SOURCE
787 ) {
788 if (!State::isApplicable($item->getTableName())) {
789 return [];
790 }
791 return $item->getState()->filterFieldNames($scope, $modified);
792 }
793 if ($scope === DataMapItem::SCOPE_EXCLUDE) {
794 return $this->getLocalizationModeExcludeFieldNames(
795 $item->getTableName()
796 );
797 }
798 return [];
799 }
800
801 /**
802 * Field names of TCA table with columns having l10n_mode=exclude
803 *
804 * @param string $tableName
805 * @return string[]
806 */
807 protected function getLocalizationModeExcludeFieldNames(string $tableName)
808 {
809 $localizationExcludeFieldNames = [];
810 if (empty($GLOBALS['TCA'][$tableName]['columns'])) {
811 return $localizationExcludeFieldNames;
812 }
813
814 foreach ($GLOBALS['TCA'][$tableName]['columns'] as $fieldName => $configuration) {
815 if (($configuration['l10n_mode'] ?? null) === 'exclude') {
816 $localizationExcludeFieldNames[] = $fieldName;
817 }
818 }
819
820 return $localizationExcludeFieldNames;
821 }
822
823 /**
824 * True if we're dealing with a field that has foreign db relations
825 *
826 * @param string $tableName
827 * @param string $fieldName
828 * @return bool True if field is type=group with internalType === db or select with foreign_table
829 */
830 protected function isRelationField(string $tableName, string $fieldName): bool
831 {
832 if (empty($GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config']['type'])) {
833 return false;
834 }
835
836 $configuration = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
837
838 return
839 $configuration['type'] === 'group'
840 && ($configuration['internal_type'] ?? null) === 'db'
841 && !empty($configuration['allowed'])
842 || $configuration['type'] === 'select'
843 && (
844 !empty($configuration['foreign_table'])
845 && !empty($GLOBALS['TCA'][$configuration['foreign_table']])
846 || ($configuration['special'] ?? null) === 'languages'
847 )
848 || $this->isInlineRelationField($tableName, $fieldName)
849 ;
850 }
851
852 /**
853 * True if we're dealing with an inline field
854 *
855 * @param string $tableName
856 * @param string $fieldName
857 * @return bool TRUE if field is of type inline with foreign_table set
858 */
859 protected function isInlineRelationField(string $tableName, string $fieldName): bool
860 {
861 if (empty($GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config']['type'])) {
862 return false;
863 }
864
865 $configuration = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
866
867 return
868 $configuration['type'] === 'inline'
869 && !empty($configuration['foreign_table'])
870 && !empty($GLOBALS['TCA'][$configuration['foreign_table']])
871 ;
872 }
873
874 /**
875 * Determines whether the table can be localized and either has fields
876 * with allowLanguageSynchronization enabled or l10n_mode set to exclude.
877 *
878 * @param string $tableName
879 * @return bool
880 */
881 protected function isApplicable(string $tableName): bool
882 {
883 return
884 State::isApplicable($tableName)
885 || BackendUtility::isTableLocalizable($tableName)
886 && count($this->getLocalizationModeExcludeFieldNames($tableName)) > 0
887 ;
888 }
889
890 /**
891 * @return RelationHandler
892 */
893 protected function createRelationHandler()
894 {
895 $relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
896 $relationHandler->setWorkspaceId($this->backendUser->workspace);
897 return $relationHandler;
898 }
899 }