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