1<?php
2
3/*
4 * This file is part of the TYPO3 CMS project.
5 *
6 * It is free software; you can redistribute it and/or modify it under
7 * the terms of the GNU General Public License, either version 2
8 * of the License, or any later version.
9 *
10 * For the full copyright and license information, please read the
11 * LICENSE.txt file that was distributed with this source code.
12 *
13 * The TYPO3 project - inspiring people to share!
14 */
15
16namespace TYPO3\CMS\Core\Database;
17
18use TYPO3\CMS\Backend\Utility\BackendUtility;
19use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
20use TYPO3\CMS\Core\Configuration\Features;
21use TYPO3\CMS\Core\Database\Platform\PlatformInformation;
22use TYPO3\CMS\Core\Database\Query\QueryHelper;
23use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
24use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
25use TYPO3\CMS\Core\DataHandling\PlainDataResolver;
26use TYPO3\CMS\Core\DataHandling\ReferenceIndexUpdater;
27use TYPO3\CMS\Core\Utility\GeneralUtility;
28use TYPO3\CMS\Core\Utility\MathUtility;
29use TYPO3\CMS\Core\Versioning\VersionState;
30
31/**
32 * Load database groups (relations)
33 * Used to process the relations created by the TCA element types "group" and "select" for database records.
34 * Manages MM-relations as well.
35 */
36class RelationHandler
37{
38    /**
39     * $fetchAllFields if false getFromDB() fetches only uid, pid, thumbnail and label fields (as defined in TCA)
40     *
41     * @var bool
42     */
43    protected $fetchAllFields = true;
44
45    /**
46     * If set, values that are not ids in tables are normally discarded. By this options they will be preserved.
47     *
48     * @var bool
49     */
50    public $registerNonTableValues = false;
51
52    /**
53     * Contains the table names as keys. The values are the id-values for each table.
54     * Should ONLY contain proper table names.
55     *
56     * @var array
57     */
58    public $tableArray = [];
59
60    /**
61     * Contains items in a numeric array (table/id for each). Tablenames here might be "_NO_TABLE". Keeps
62     * the sorting of thee retrieved items.
63     *
64     * @var array<int, array<string, mixed>>
65     */
66    public $itemArray = [];
67
68    /**
69     * Array for NON-table elements
70     *
71     * @var array
72     */
73    public $nonTableArray = [];
74
75    /**
76     * @var array
77     */
78    public $additionalWhere = [];
79
80    /**
81     * Deleted-column is added to additionalWhere... if this is set...
82     *
83     * @var bool
84     */
85    public $checkIfDeleted = true;
86
87    /**
88     * Will contain the first table name in the $tablelist (for positive ids)
89     *
90     * @var string
91     */
92    protected $firstTable = '';
93
94    /**
95     * If TRUE, uid_local and uid_foreign are switched, and the current table
96     * is inserted as tablename - this means you display a foreign relation "from the opposite side"
97     *
98     * @var bool
99     */
100    protected $MM_is_foreign = false;
101
102    /**
103     * Is empty by default; if MM_is_foreign is set and there is more than one table
104     * allowed (on the "local" side), then it contains the first table (as a fallback)
105     * @var string
106     */
107    protected $MM_isMultiTableRelationship = '';
108
109    /**
110     * Current table => Only needed for reverse relations
111     *
112     * @var string
113     */
114    protected $currentTable;
115
116    /**
117     * If a record should be undeleted
118     * (so do not use the $useDeleteClause on \TYPO3\CMS\Backend\Utility\BackendUtility)
119     *
120     * @var bool
121     */
122    public $undeleteRecord;
123
124    /**
125     * Array of fields value pairs that should match while SELECT
126     * and will be written into MM table if $MM_insert_fields is not set
127     *
128     * @var array
129     */
130    protected $MM_match_fields = [];
131
132    /**
133     * This is set to TRUE if the MM table has a UID field.
134     *
135     * @var bool
136     */
137    protected $MM_hasUidField;
138
139    /**
140     * Array of fields and value pairs used for insert in MM table
141     *
142     * @var array
143     */
144    protected $MM_insert_fields = [];
145
146    /**
147     * Extra MM table where
148     *
149     * @var string
150     */
151    protected $MM_table_where = '';
152
153    /**
154     * Usage of an MM field on the opposite relation.
155     *
156     * @var array
157     */
158    protected $MM_oppositeUsage;
159
160    /**
161     * If false, reference index is not updated.
162     *
163     * @var bool
164     * @deprecated since v11, will be removed in v12
165     */
166    protected $updateReferenceIndex = true;
167
168    /**
169     * @var ReferenceIndexUpdater|null
170     */
171    protected $referenceIndexUpdater;
172
173    /**
174     * @var bool
175     */
176    protected $useLiveParentIds = true;
177
178    /**
179     * @var bool
180     */
181    protected $useLiveReferenceIds = true;
182
183    /**
184     * @var int|null
185     */
186    protected $workspaceId;
187
188    /**
189     * @var bool
190     */
191    protected $purged = false;
192
193    /**
194     * This array will be filled by getFromDB().
195     *
196     * @var array
197     */
198    public $results = [];
199
200    /**
201     * Gets the current workspace id.
202     *
203     * @return int
204     */
205    protected function getWorkspaceId(): int
206    {
207        $backendUser = $GLOBALS['BE_USER'] ?? null;
208        if (!isset($this->workspaceId)) {
209            $this->workspaceId = $backendUser instanceof BackendUserAuthentication ? (int)($backendUser->workspace) : 0;
210        }
211        return $this->workspaceId;
212    }
213
214    /**
215     * Sets the current workspace id.
216     *
217     * @param int $workspaceId
218     */
219    public function setWorkspaceId($workspaceId): void
220    {
221        $this->workspaceId = (int)$workspaceId;
222    }
223
224    /**
225     * Setter to carry the 'deferred' reference index updater registry around.
226     *
227     * @param ReferenceIndexUpdater $updater
228     * @internal Used internally within DataHandler only
229     */
230    public function setReferenceIndexUpdater(ReferenceIndexUpdater $updater): void
231    {
232        $this->referenceIndexUpdater = $updater;
233    }
234
235    /**
236     * Whether item array has been purged in this instance.
237     *
238     * @return bool
239     */
240    public function isPurged()
241    {
242        return $this->purged;
243    }
244
245    /**
246     * Initialization of the class.
247     *
248     * @param string $itemlist List of group/select items
249     * @param string $tablelist Comma list of tables, first table takes priority if no table is set for an entry in the list.
250     * @param string $MMtable Name of a MM table.
251     * @param int|string $MMuid Local UID for MM lookup. May be a string for newly created elements.
252     * @param string $currentTable Current table name
253     * @param array $conf TCA configuration for current field
254     */
255    public function start($itemlist, $tablelist, $MMtable = '', $MMuid = 0, $currentTable = '', $conf = [])
256    {
257        $conf = (array)$conf;
258        // SECTION: MM reverse relations
259        $this->MM_is_foreign = (bool)($conf['MM_opposite_field'] ?? false);
260        $this->MM_table_where = $conf['MM_table_where'] ?? null;
261        $this->MM_hasUidField = $conf['MM_hasUidField'] ?? null;
262        $this->MM_match_fields = (isset($conf['MM_match_fields']) && is_array($conf['MM_match_fields'])) ? $conf['MM_match_fields'] : [];
263        $this->MM_insert_fields = (isset($conf['MM_insert_fields']) && is_array($conf['MM_insert_fields'])) ? $conf['MM_insert_fields'] : $this->MM_match_fields;
264        $this->currentTable = $currentTable;
265        if (!empty($conf['MM_oppositeUsage']) && is_array($conf['MM_oppositeUsage'])) {
266            $this->MM_oppositeUsage = $conf['MM_oppositeUsage'];
267        }
268        $mmOppositeTable = '';
269        if ($this->MM_is_foreign) {
270            $allowedTableList = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
271            // Normally, $conf['allowed'] can contain a list of tables,
272            // but as we are looking at a MM relation from the foreign side,
273            // it only makes sense to allow one table in $conf['allowed'].
274            [$mmOppositeTable] = GeneralUtility::trimExplode(',', $allowedTableList);
275            // Only add the current table name if there is more than one allowed
276            // field. We must be sure this has been done at least once before accessing
277            // the "columns" part of TCA for a table.
278            $mmOppositeAllowed = (string)($GLOBALS['TCA'][$mmOppositeTable]['columns'][$conf['MM_opposite_field'] ?? '']['config']['allowed'] ?? '');
279            if ($mmOppositeAllowed !== '') {
280                $mmOppositeAllowedTables = explode(',', $mmOppositeAllowed);
281                if ($mmOppositeAllowed === '*' || count($mmOppositeAllowedTables) > 1) {
282                    $this->MM_isMultiTableRelationship = $mmOppositeAllowedTables[0];
283                }
284            }
285        }
286        // SECTION:	normal MM relations
287        // If the table list is "*" then all tables are used in the list:
288        if (trim($tablelist) === '*') {
289            $tablelist = implode(',', array_keys($GLOBALS['TCA']));
290        }
291        // The tables are traversed and internal arrays are initialized:
292        $tempTableArray = GeneralUtility::trimExplode(',', $tablelist, true);
293        foreach ($tempTableArray as $val) {
294            $tName = trim($val);
295            $this->tableArray[$tName] = [];
296            $deleteField = $GLOBALS['TCA'][$tName]['ctrl']['delete'] ?? false;
297            if ($this->checkIfDeleted && $deleteField) {
298                $fieldN = $tName . '.' . $deleteField;
299                if (!isset($this->additionalWhere[$tName])) {
300                    $this->additionalWhere[$tName] = '';
301                }
302                $this->additionalWhere[$tName] .= ' AND ' . $fieldN . '=0';
303            }
304        }
305        if (is_array($this->tableArray)) {
306            reset($this->tableArray);
307        } else {
308            // No tables
309            return;
310        }
311        // Set first and second tables:
312        // Is the first table
313        $this->firstTable = (string)key($this->tableArray);
314        next($this->tableArray);
315        // Now, populate the internal itemArray and tableArray arrays:
316        // If MM, then call this function to do that:
317        if ($MMtable) {
318            if ($MMuid) {
319                $this->readMM($MMtable, $MMuid, $mmOppositeTable);
320                $this->purgeItemArray();
321            } else {
322                // Revert to readList() for new records in order to load possible default values from $itemlist
323                $this->readList($itemlist, $conf);
324                $this->purgeItemArray();
325            }
326        } elseif ($MMuid && ($conf['foreign_field'] ?? false)) {
327            // If not MM but foreign_field, the read the records by the foreign_field
328            $this->readForeignField($MMuid, $conf);
329        } else {
330            // If not MM, then explode the itemlist by "," and traverse the list:
331            $this->readList($itemlist, $conf);
332            // Do automatic default_sortby, if any
333            if (isset($conf['foreign_default_sortby']) && $conf['foreign_default_sortby']) {
334                $this->sortList($conf['foreign_default_sortby']);
335            }
336        }
337    }
338
339    /**
340     * Sets $fetchAllFields
341     *
342     * @param bool $allFields enables fetching of all fields in getFromDB()
343     */
344    public function setFetchAllFields($allFields)
345    {
346        $this->fetchAllFields = (bool)$allFields;
347    }
348
349    /**
350     * Sets whether the reference index shall be updated.
351     *
352     * @param bool $updateReferenceIndex Whether the reference index shall be updated
353     * @deprecated since v11, will be removed in v12
354     */
355    public function setUpdateReferenceIndex($updateReferenceIndex)
356    {
357        trigger_error(
358            'Calling RelationHandler->setUpdateReferenceIndex() is deprecated. Use setReferenceIndexUpdater() instead.',
359            E_USER_DEPRECATED
360        );
361        $this->updateReferenceIndex = (bool)$updateReferenceIndex;
362    }
363
364    /**
365     * @param bool $useLiveParentIds
366     */
367    public function setUseLiveParentIds($useLiveParentIds)
368    {
369        $this->useLiveParentIds = (bool)$useLiveParentIds;
370    }
371
372    /**
373     * @param bool $useLiveReferenceIds
374     */
375    public function setUseLiveReferenceIds($useLiveReferenceIds)
376    {
377        $this->useLiveReferenceIds = (bool)$useLiveReferenceIds;
378    }
379
380    /**
381     * Explodes the item list and stores the parts in the internal arrays itemArray and tableArray from MM records.
382     *
383     * @param string $itemlist Item list
384     * @param array $configuration Parent field configuration
385     */
386    protected function readList($itemlist, array $configuration)
387    {
388        if (trim((string)$itemlist) !== '') {
389            // Changed to trimExplode 31/3 04; HMENU special type "list" didn't work
390            // if there were spaces in the list... I suppose this is better overall...
391            $tempItemArray = GeneralUtility::trimExplode(',', $itemlist);
392            // If the second table is set and the ID number is less than zero (later)
393            // then the record is regarded to come from the second table...
394            $secondTable = (string)(key($this->tableArray) ?? '');
395            foreach ($tempItemArray as $key => $val) {
396                // Will be set to "true" if the entry was a real table/id
397                $isSet = false;
398                // Extract table name and id. This is in the formula [tablename]_[id]
399                // where table name MIGHT contain "_", hence the reversion of the string!
400                $val = strrev($val);
401                $parts = explode('_', $val, 2);
402                $theID = strrev($parts[0]);
403                // Check that the id IS an integer:
404                if (MathUtility::canBeInterpretedAsInteger($theID)) {
405                    // Get the table name: If a part of the exploded string, use that.
406                    // Otherwise if the id number is LESS than zero, use the second table, otherwise the first table
407                    $theTable = trim($parts[1] ?? '')
408                        ? strrev(trim($parts[1] ?? ''))
409                        : ($secondTable && $theID < 0 ? $secondTable : $this->firstTable);
410                    // If the ID is not blank and the table name is among the names in the inputted tableList
411                    if ((string)$theID != '' && $theID && $theTable && isset($this->tableArray[$theTable])) {
412                        // Get ID as the right value:
413                        $theID = $secondTable ? abs((int)$theID) : (int)$theID;
414                        // Register ID/table name in internal arrays:
415                        $this->itemArray[$key]['id'] = $theID;
416                        $this->itemArray[$key]['table'] = $theTable;
417                        $this->tableArray[$theTable][] = $theID;
418                        // Set update-flag
419                        $isSet = true;
420                    }
421                }
422                // If it turns out that the value from the list was NOT a valid reference to a table-record,
423                // then we might still set it as a NO_TABLE value:
424                if (!$isSet && $this->registerNonTableValues) {
425                    $this->itemArray[$key]['id'] = $tempItemArray[$key];
426                    $this->itemArray[$key]['table'] = '_NO_TABLE';
427                    $this->nonTableArray[] = $tempItemArray[$key];
428                }
429            }
430
431            // Skip if not dealing with IRRE in a CSV list on a workspace
432            if (!isset($configuration['type']) || $configuration['type'] !== 'inline'
433                || empty($configuration['foreign_table']) || !empty($configuration['foreign_field'])
434                || !empty($configuration['MM']) || count($this->tableArray) !== 1 || empty($this->tableArray[$configuration['foreign_table']])
435                || $this->getWorkspaceId() === 0 || !BackendUtility::isTableWorkspaceEnabled($configuration['foreign_table'])
436            ) {
437                return;
438            }
439
440            // Fetch live record data
441            if ($this->useLiveReferenceIds) {
442                foreach ($this->itemArray as &$item) {
443                    $item['id'] = $this->getLiveDefaultId($item['table'], $item['id']);
444                }
445            } else {
446                // Directly overlay workspace data
447                $this->itemArray = [];
448                $foreignTable = $configuration['foreign_table'];
449                $ids = $this->getResolver($foreignTable, $this->tableArray[$foreignTable])->get();
450                foreach ($ids as $id) {
451                    $this->itemArray[] = [
452                        'id' => $id,
453                        'table' => $foreignTable,
454                    ];
455                }
456            }
457        }
458    }
459
460    /**
461     * Does a sorting on $this->itemArray depending on a default sortby field.
462     * This is only used for automatic sorting of comma separated lists.
463     * This function is only relevant for data that is stored in comma separated lists!
464     *
465     * @param string $sortby The default_sortby field/command (e.g. 'price DESC')
466     */
467    protected function sortList($sortby)
468    {
469        // Sort directly without fetching additional data
470        if ($sortby === 'uid') {
471            usort(
472                $this->itemArray,
473                static function ($a, $b) {
474                    return $a['id'] < $b['id'] ? -1 : 1;
475                }
476            );
477        } elseif (count($this->tableArray) === 1) {
478            reset($this->tableArray);
479            $table = (string)key($this->tableArray);
480            $connection = $this->getConnectionForTableName($table);
481            $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform());
482
483            foreach (array_chunk(current($this->tableArray), $maxBindParameters - 10, true) as $chunk) {
484                if (empty($chunk)) {
485                    continue;
486                }
487                $this->itemArray = [];
488                $this->tableArray = [];
489                $queryBuilder = $connection->createQueryBuilder();
490                $queryBuilder->getRestrictions()->removeAll();
491                $queryBuilder->select('uid')
492                    ->from($table)
493                    ->where(
494                        $queryBuilder->expr()->in(
495                            'uid',
496                            $queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY)
497                        )
498                    );
499                foreach (QueryHelper::parseOrderBy((string)$sortby) as $orderPair) {
500                    [$fieldName, $order] = $orderPair;
501                    $queryBuilder->addOrderBy($fieldName, $order);
502                }
503                $statement = $queryBuilder->executeQuery();
504                while ($row = $statement->fetchAssociative()) {
505                    $this->itemArray[] = ['id' => $row['uid'], 'table' => $table];
506                    $this->tableArray[$table][] = $row['uid'];
507                }
508            }
509        }
510    }
511
512    /**
513     * Reads the record tablename/id into the internal arrays itemArray and tableArray from MM records.
514     *
515     * @todo: The source record is not checked for correct workspace. Say there is a category 5 in
516     *        workspace 1. setWorkspace(0) is called, after that readMM('sys_category_record_mm', 5 ...).
517     *        readMM will *still* return the list of records connected to this workspace 1 item,
518     *        even though workspace 0 has been set.
519     *
520     * @param string $tableName MM Tablename
521     * @param int|string $uid Local UID
522     * @param string $mmOppositeTable Opposite table name
523     */
524    protected function readMM($tableName, $uid, $mmOppositeTable)
525    {
526        $key = 0;
527        $theTable = null;
528        $queryBuilder = $this->getConnectionForTableName($tableName)
529            ->createQueryBuilder();
530        $queryBuilder->getRestrictions()->removeAll();
531        $queryBuilder->select('*')->from($tableName);
532        // In case of a reverse relation
533        if ($this->MM_is_foreign) {
534            $uidLocal_field = 'uid_foreign';
535            $uidForeign_field = 'uid_local';
536            $sorting_field = 'sorting_foreign';
537            if ($this->MM_isMultiTableRelationship) {
538                // Be backwards compatible! When allowing more than one table after
539                // having previously allowed only one table, this case applies.
540                if ($this->currentTable == $this->MM_isMultiTableRelationship) {
541                    $expression = $queryBuilder->expr()->orX(
542                        $queryBuilder->expr()->eq(
543                            'tablenames',
544                            $queryBuilder->createNamedParameter($this->currentTable, \PDO::PARAM_STR)
545                        ),
546                        $queryBuilder->expr()->eq(
547                            'tablenames',
548                            $queryBuilder->createNamedParameter('', \PDO::PARAM_STR)
549                        )
550                    );
551                } else {
552                    $expression = $queryBuilder->expr()->eq(
553                        'tablenames',
554                        $queryBuilder->createNamedParameter($this->currentTable, \PDO::PARAM_STR)
555                    );
556                }
557                $queryBuilder->andWhere($expression);
558            }
559            $theTable = $mmOppositeTable;
560        } else {
561            // Default
562            $uidLocal_field = 'uid_local';
563            $uidForeign_field = 'uid_foreign';
564            $sorting_field = 'sorting';
565        }
566        if ($this->MM_table_where) {
567            if (GeneralUtility::makeInstance(Features::class)->isFeatureEnabled('runtimeDbQuotingOfTcaConfiguration')) {
568                $queryBuilder->andWhere(
569                    QueryHelper::stripLogicalOperatorPrefix(str_replace('###THIS_UID###', (string)$uid, QueryHelper::quoteDatabaseIdentifiers($queryBuilder->getConnection(), $this->MM_table_where)))
570                );
571            } else {
572                $queryBuilder->andWhere(
573                    QueryHelper::stripLogicalOperatorPrefix(str_replace('###THIS_UID###', (string)$uid, $this->MM_table_where))
574                );
575            }
576        }
577        foreach ($this->MM_match_fields as $field => $value) {
578            $queryBuilder->andWhere(
579                $queryBuilder->expr()->eq($field, $queryBuilder->createNamedParameter($value, \PDO::PARAM_STR))
580            );
581        }
582        $queryBuilder->andWhere(
583            $queryBuilder->expr()->eq(
584                $uidLocal_field,
585                $queryBuilder->createNamedParameter((int)$uid, \PDO::PARAM_INT)
586            )
587        );
588        $queryBuilder->orderBy($sorting_field);
589        $queryBuilder->addOrderBy($uidForeign_field);
590        $statement = $queryBuilder->executeQuery();
591        while ($row = $statement->fetchAssociative()) {
592            // Default
593            if (!$this->MM_is_foreign) {
594                // If tablesnames columns exists and contain a name, then this value is the table, else it's the firstTable...
595                $theTable = !empty($row['tablenames']) ? $row['tablenames'] : $this->firstTable;
596            }
597            if (($row[$uidForeign_field] || $theTable === 'pages') && $theTable && isset($this->tableArray[$theTable])) {
598                $this->itemArray[$key]['id'] = $row[$uidForeign_field];
599                $this->itemArray[$key]['table'] = $theTable;
600                $this->tableArray[$theTable][] = $row[$uidForeign_field];
601            } elseif ($this->registerNonTableValues) {
602                $this->itemArray[$key]['id'] = $row[$uidForeign_field];
603                $this->itemArray[$key]['table'] = '_NO_TABLE';
604                $this->nonTableArray[] = $row[$uidForeign_field];
605            }
606            $key++;
607        }
608    }
609
610    /**
611     * Writes the internal itemArray to MM table:
612     *
613     * @param string $MM_tableName MM table name
614     * @param int $uid Local UID
615     * @param bool $prependTableName If set, then table names will always be written.
616     */
617    public function writeMM($MM_tableName, $uid, $prependTableName = false)
618    {
619        $connection = $this->getConnectionForTableName($MM_tableName);
620        $expressionBuilder = $connection->createQueryBuilder()->expr();
621
622        // In case of a reverse relation
623        if ($this->MM_is_foreign) {
624            $uidLocal_field = 'uid_foreign';
625            $uidForeign_field = 'uid_local';
626            $sorting_field = 'sorting_foreign';
627        } else {
628            // default
629            $uidLocal_field = 'uid_local';
630            $uidForeign_field = 'uid_foreign';
631            $sorting_field = 'sorting';
632        }
633        // If there are tables...
634        $tableC = count($this->tableArray);
635        if ($tableC) {
636            // Boolean: does the field "tablename" need to be filled?
637            $prep = $tableC > 1 || $prependTableName || $this->MM_isMultiTableRelationship;
638            $c = 0;
639            $additionalWhere_tablenames = '';
640            if ($this->MM_is_foreign && $prep) {
641                $additionalWhere_tablenames = $expressionBuilder->eq(
642                    'tablenames',
643                    $expressionBuilder->literal($this->currentTable)
644                );
645            }
646            $additionalWhere = $expressionBuilder->andX();
647            // Add WHERE clause if configured
648            if ($this->MM_table_where) {
649                $additionalWhere->add(
650                    QueryHelper::stripLogicalOperatorPrefix(
651                        str_replace('###THIS_UID###', (string)$uid, $this->MM_table_where)
652                    )
653                );
654            }
655            // Select, update or delete only those relations that match the configured fields
656            foreach ($this->MM_match_fields as $field => $value) {
657                $additionalWhere->add($expressionBuilder->eq($field, $expressionBuilder->literal($value)));
658            }
659
660            $queryBuilder = $connection->createQueryBuilder();
661            $queryBuilder->getRestrictions()->removeAll();
662            $queryBuilder->select($uidForeign_field)
663                ->from($MM_tableName)
664                ->where($queryBuilder->expr()->eq(
665                    $uidLocal_field,
666                    $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
667                ))
668                ->orderBy($sorting_field);
669
670            if ($prep) {
671                $queryBuilder->addSelect('tablenames');
672            }
673            if ($this->MM_hasUidField) {
674                $queryBuilder->addSelect('uid');
675            }
676            if ($additionalWhere_tablenames) {
677                $queryBuilder->andWhere($additionalWhere_tablenames);
678            }
679            if ($additionalWhere->count()) {
680                $queryBuilder->andWhere($additionalWhere);
681            }
682
683            $result = $queryBuilder->executeQuery();
684            $oldMMs = [];
685            // This array is similar to $oldMMs but also holds the uid of the MM-records, if any (configured by MM_hasUidField).
686            // If the UID is present it will be used to update sorting and delete MM-records.
687            // This is necessary if the "multiple" feature is used for the MM relations.
688            // $oldMMs is still needed for the in_array() search used to look if an item from $this->itemArray is in $oldMMs
689            $oldMMs_inclUid = [];
690            while ($row = $result->fetchAssociative()) {
691                if (!$this->MM_is_foreign && $prep) {
692                    $oldMMs[] = [$row['tablenames'], $row[$uidForeign_field]];
693                } else {
694                    $oldMMs[] = $row[$uidForeign_field];
695                }
696                $oldMMs_inclUid[] = (int)($row['uid'] ?? 0);
697            }
698            // For each item, insert it:
699            foreach ($this->itemArray as $val) {
700                $c++;
701                if ($prep || $val['table'] === '_NO_TABLE') {
702                    // Insert current table if needed
703                    if ($this->MM_is_foreign) {
704                        $tablename = $this->currentTable;
705                    } else {
706                        $tablename = $val['table'];
707                    }
708                } else {
709                    $tablename = '';
710                }
711                if (!$this->MM_is_foreign && $prep) {
712                    $item = [$val['table'], $val['id']];
713                } else {
714                    $item = $val['id'];
715                }
716                if (in_array($item, $oldMMs)) {
717                    $oldMMs_index = array_search($item, $oldMMs);
718                    // In principle, selecting on the UID is all we need to do
719                    // if a uid field is available since that is unique!
720                    // But as long as it "doesn't hurt" we just add it to the where clause. It should all match up.
721                    $queryBuilder = $connection->createQueryBuilder();
722                    $queryBuilder->update($MM_tableName)
723                        ->set($sorting_field, $c)
724                        ->where(
725                            $expressionBuilder->eq(
726                                $uidLocal_field,
727                                $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
728                            ),
729                            $expressionBuilder->eq(
730                                $uidForeign_field,
731                                $queryBuilder->createNamedParameter($val['id'], \PDO::PARAM_INT)
732                            )
733                        );
734
735                    if ($additionalWhere->count()) {
736                        $queryBuilder->andWhere($additionalWhere);
737                    }
738                    if ($this->MM_hasUidField) {
739                        $queryBuilder->andWhere(
740                            $expressionBuilder->eq(
741                                'uid',
742                                $queryBuilder->createNamedParameter($oldMMs_inclUid[$oldMMs_index], \PDO::PARAM_INT)
743                            )
744                        );
745                    }
746                    if ($tablename) {
747                        $queryBuilder->andWhere(
748                            $expressionBuilder->eq(
749                                'tablenames',
750                                $queryBuilder->createNamedParameter($tablename, \PDO::PARAM_STR)
751                            )
752                        );
753                    }
754
755                    $queryBuilder->executeStatement();
756                    // Remove the item from the $oldMMs array so after this
757                    // foreach loop only the ones that need to be deleted are in there.
758                    unset($oldMMs[$oldMMs_index]);
759                    // Remove the item from the $oldMMs_inclUid array so after this
760                    // foreach loop only the ones that need to be deleted are in there.
761                    unset($oldMMs_inclUid[$oldMMs_index]);
762                } else {
763                    $insertFields = $this->MM_insert_fields;
764                    $insertFields[$uidLocal_field] = $uid;
765                    $insertFields[$uidForeign_field] = $val['id'];
766                    $insertFields[$sorting_field] = $c;
767                    if ($tablename) {
768                        $insertFields['tablenames'] = $tablename;
769                        $insertFields = $this->completeOppositeUsageValues($tablename, $insertFields);
770                    }
771                    $connection->insert($MM_tableName, $insertFields);
772                    if ($this->MM_is_foreign) {
773                        $this->updateRefIndex($val['table'], $val['id']);
774                    }
775                }
776            }
777            // Delete all not-used relations:
778            if (is_array($oldMMs) && !empty($oldMMs)) {
779                $queryBuilder = $connection->createQueryBuilder();
780                $removeClauses = $queryBuilder->expr()->orX();
781                $updateRefIndex_records = [];
782                foreach ($oldMMs as $oldMM_key => $mmItem) {
783                    // If UID field is present, of course we need only use that for deleting.
784                    if ($this->MM_hasUidField) {
785                        $removeClauses->add($queryBuilder->expr()->eq(
786                            'uid',
787                            $queryBuilder->createNamedParameter($oldMMs_inclUid[$oldMM_key], \PDO::PARAM_INT)
788                        ));
789                    } else {
790                        if (is_array($mmItem)) {
791                            $removeClauses->add(
792                                $queryBuilder->expr()->andX(
793                                    $queryBuilder->expr()->eq(
794                                        'tablenames',
795                                        $queryBuilder->createNamedParameter($mmItem[0], \PDO::PARAM_STR)
796                                    ),
797                                    $queryBuilder->expr()->eq(
798                                        $uidForeign_field,
799                                        $queryBuilder->createNamedParameter($mmItem[1], \PDO::PARAM_INT)
800                                    )
801                                )
802                            );
803                        } else {
804                            $removeClauses->add(
805                                $queryBuilder->expr()->eq(
806                                    $uidForeign_field,
807                                    $queryBuilder->createNamedParameter($mmItem, \PDO::PARAM_INT)
808                                )
809                            );
810                        }
811                    }
812                    if ($this->MM_is_foreign) {
813                        if (is_array($mmItem)) {
814                            $updateRefIndex_records[] = [$mmItem[0], $mmItem[1]];
815                        } else {
816                            $updateRefIndex_records[] = [$this->firstTable, $mmItem];
817                        }
818                    }
819                }
820
821                $queryBuilder->delete($MM_tableName)
822                    ->where(
823                        $queryBuilder->expr()->eq(
824                            $uidLocal_field,
825                            $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
826                        ),
827                        $removeClauses
828                    );
829
830                if ($additionalWhere_tablenames) {
831                    $queryBuilder->andWhere($additionalWhere_tablenames);
832                }
833                if ($additionalWhere->count()) {
834                    $queryBuilder->andWhere($additionalWhere);
835                }
836
837                $queryBuilder->executeStatement();
838
839                // Update ref index:
840                foreach ($updateRefIndex_records as $pair) {
841                    $this->updateRefIndex($pair[0], $pair[1]);
842                }
843            }
844            // Update ref index; In DataHandler it is not certain that this will happen because
845            // if only the MM field is changed the record itself is not updated and so the ref-index is not either.
846            // This could also have been fixed in updateDB in DataHandler, however I decided to do it here ...
847            $this->updateRefIndex($this->currentTable, $uid);
848        }
849    }
850
851    /**
852     * Remaps MM table elements from one local uid to another
853     * Does NOT update the reference index for you, must be called subsequently to do that!
854     *
855     * @param string $MM_tableName MM table name
856     * @param int $uid Local, current UID
857     * @param int $newUid Local, new UID
858     * @param bool $prependTableName If set, then table names will always be written.
859     * @deprecated since v11, will be removed with v12.
860     */
861    public function remapMM($MM_tableName, $uid, $newUid, $prependTableName = false)
862    {
863        trigger_error(
864            'Method ' . __METHOD__ . ' of class ' . __CLASS__ . ' is deprecated since v11 and will be removed in v12.',
865            E_USER_DEPRECATED
866        );
867
868        // In case of a reverse relation
869        if ($this->MM_is_foreign) {
870            $uidLocal_field = 'uid_foreign';
871        } else {
872            // default
873            $uidLocal_field = 'uid_local';
874        }
875        // If there are tables...
876        $tableC = count($this->tableArray);
877        if ($tableC) {
878            $queryBuilder = $this->getConnectionForTableName($MM_tableName)
879                ->createQueryBuilder();
880            $queryBuilder->update($MM_tableName)
881                ->set($uidLocal_field, (int)$newUid)
882                ->where($queryBuilder->expr()->eq(
883                    $uidLocal_field,
884                    $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
885                ));
886            // Boolean: does the field "tablename" need to be filled?
887            $prep = $tableC > 1 || $prependTableName || $this->MM_isMultiTableRelationship;
888            if ($this->MM_is_foreign && $prep) {
889                $queryBuilder->andWhere(
890                    $queryBuilder->expr()->eq(
891                        'tablenames',
892                        $queryBuilder->createNamedParameter($this->currentTable, \PDO::PARAM_STR)
893                    )
894                );
895            }
896            // Add WHERE clause if configured
897            if ($this->MM_table_where) {
898                $queryBuilder->andWhere(
899                    QueryHelper::stripLogicalOperatorPrefix(str_replace('###THIS_UID###', (string)$uid, $this->MM_table_where))
900                );
901            }
902            // Select, update or delete only those relations that match the configured fields
903            foreach ($this->MM_match_fields as $field => $value) {
904                $queryBuilder->andWhere(
905                    $queryBuilder->expr()->eq($field, $queryBuilder->createNamedParameter($value, \PDO::PARAM_STR))
906                );
907            }
908            $queryBuilder->execute();
909        }
910    }
911
912    /**
913     * Reads items from a foreign_table, that has a foreign_field (uid of the parent record) and
914     * stores the parts in the internal array itemArray and tableArray.
915     *
916     * @param int|string $uid The uid of the parent record (this value is also on the foreign_table in the foreign_field)
917     * @param array $conf TCA configuration for current field
918     */
919    protected function readForeignField($uid, $conf)
920    {
921        if ($this->useLiveParentIds) {
922            $uid = $this->getLiveDefaultId($this->currentTable, $uid);
923        }
924
925        $key = 0;
926        $uid = (int)$uid;
927        // skip further processing if $uid does not
928        // point to a valid parent record
929        if ($uid === 0) {
930            return;
931        }
932
933        $foreign_table = $conf['foreign_table'];
934        $foreign_table_field = $conf['foreign_table_field'] ?? '';
935        $useDeleteClause = !$this->undeleteRecord;
936        $foreign_match_fields = is_array($conf['foreign_match_fields'] ?? false) ? $conf['foreign_match_fields'] : [];
937        $queryBuilder = $this->getConnectionForTableName($foreign_table)
938            ->createQueryBuilder();
939        $queryBuilder->getRestrictions()
940            ->removeAll();
941        // Use the deleteClause (e.g. "deleted=0") on this table
942        if ($useDeleteClause) {
943            $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(DeletedRestriction::class));
944        }
945
946        $queryBuilder->select('uid')
947            ->from($foreign_table);
948
949        // Search for $uid in foreign_field, and if we have symmetric relations, do this also on symmetric_field
950        if (!empty($conf['symmetric_field'])) {
951            $queryBuilder->where(
952                $queryBuilder->expr()->orX(
953                    $queryBuilder->expr()->eq(
954                        $conf['foreign_field'],
955                        $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
956                    ),
957                    $queryBuilder->expr()->eq(
958                        $conf['symmetric_field'],
959                        $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
960                    )
961                )
962            );
963        } else {
964            $queryBuilder->where($queryBuilder->expr()->eq(
965                $conf['foreign_field'],
966                $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
967            ));
968        }
969        // If it's requested to look for the parent uid AND the parent table,
970        // add an additional SQL-WHERE clause
971        if ($foreign_table_field && $this->currentTable) {
972            $queryBuilder->andWhere(
973                $queryBuilder->expr()->eq(
974                    $foreign_table_field,
975                    $queryBuilder->createNamedParameter($this->currentTable, \PDO::PARAM_STR)
976                )
977            );
978        }
979        // Add additional where clause if foreign_match_fields are defined
980        foreach ($foreign_match_fields as $field => $value) {
981            $queryBuilder->andWhere(
982                $queryBuilder->expr()->eq($field, $queryBuilder->createNamedParameter($value, \PDO::PARAM_STR))
983            );
984        }
985        // Select children from the live(!) workspace only
986        if (BackendUtility::isTableWorkspaceEnabled($foreign_table)) {
987            $queryBuilder->getRestrictions()->add(
988                GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->getWorkspaceId())
989            );
990        }
991        // Get the correct sorting field
992        // Specific manual sortby for data handled by this field
993        $sortby = '';
994        if (!empty($conf['foreign_sortby'])) {
995            if (!empty($conf['symmetric_sortby']) && !empty($conf['symmetric_field'])) {
996                // Sorting depends on, from which side of the relation we're looking at it
997                // This requires bypassing automatic quoting and setting of the default sort direction
998                // @TODO: Doctrine: generalize to standard SQL to guarantee database independency
999                $queryBuilder->add(
1000                    'orderBy',
1001                    'CASE
1002						WHEN ' . $queryBuilder->expr()->eq($conf['foreign_field'], $uid) . '
1003						THEN ' . $queryBuilder->quoteIdentifier($conf['foreign_sortby']) . '
1004						ELSE ' . $queryBuilder->quoteIdentifier($conf['symmetric_sortby']) . '
1005					END'
1006                );
1007            } else {
1008                // Regular single-side behaviour
1009                $sortby = $conf['foreign_sortby'];
1010            }
1011        } elseif (!empty($conf['foreign_default_sortby'])) {
1012            // Specific default sortby for data handled by this field
1013            $sortby = $conf['foreign_default_sortby'];
1014        } elseif (!empty($GLOBALS['TCA'][$foreign_table]['ctrl']['sortby'])) {
1015            // Manual sortby for all table records
1016            $sortby = $GLOBALS['TCA'][$foreign_table]['ctrl']['sortby'];
1017        } elseif (!empty($GLOBALS['TCA'][$foreign_table]['ctrl']['default_sortby'])) {
1018            // Default sortby for all table records
1019            $sortby = $GLOBALS['TCA'][$foreign_table]['ctrl']['default_sortby'];
1020        }
1021
1022        if (!empty($sortby)) {
1023            foreach (QueryHelper::parseOrderBy($sortby) as $orderPair) {
1024                [$fieldName, $sorting] = $orderPair;
1025                $queryBuilder->addOrderBy($fieldName, $sorting);
1026            }
1027        }
1028
1029        // Get the rows from storage
1030        $rows = [];
1031        $result = $queryBuilder->executeQuery();
1032        while ($row = $result->fetchAssociative()) {
1033            $rows[(int)$row['uid']] = $row;
1034        }
1035        if (!empty($rows)) {
1036            // Retrieve the parsed and prepared ORDER BY configuration for the resolver
1037            $sortby = $queryBuilder->getQueryPart('orderBy');
1038            $ids = $this->getResolver($foreign_table, array_keys($rows), $sortby)->get();
1039            foreach ($ids as $id) {
1040                $this->itemArray[$key]['id'] = $id;
1041                $this->itemArray[$key]['table'] = $foreign_table;
1042                $this->tableArray[$foreign_table][] = $id;
1043                $key++;
1044            }
1045        }
1046    }
1047
1048    /**
1049     * Write the sorting values to a foreign_table, that has a foreign_field (uid of the parent record)
1050     *
1051     * @param array $conf TCA configuration for current field
1052     * @param int $parentUid The uid of the parent record
1053     * @param int $updateToUid If this is larger than zero it will be used as foreign UID instead of the given $parentUid (on Copy)
1054     * @param bool $skipSorting @deprecated since v11, will be dropped with v12. Simplify the if below when removing argument.
1055     */
1056    public function writeForeignField($conf, $parentUid, $updateToUid = 0, $skipSorting = null)
1057    {
1058        // @deprecated since v11, will be removed with v12.
1059        if ($skipSorting !== null) {
1060            trigger_error(
1061                'Calling ' . __METHOD__ . ' with 4th argument $skipSorting is deprecated and will be removed in v12.',
1062                E_USER_DEPRECATED
1063            );
1064        }
1065        $skipSorting = (bool)$skipSorting;
1066
1067        if ($this->useLiveParentIds) {
1068            $parentUid = $this->getLiveDefaultId($this->currentTable, $parentUid);
1069            if (!empty($updateToUid)) {
1070                $updateToUid = $this->getLiveDefaultId($this->currentTable, $updateToUid);
1071            }
1072        }
1073
1074        // Ensure all values are set.
1075        $conf += [
1076            'foreign_table' => '',
1077            'foreign_field' => '',
1078            'symmetric_field' => '',
1079            'foreign_table_field' => '',
1080            'foreign_match_fields' => [],
1081        ];
1082
1083        $c = 0;
1084        $foreign_table = $conf['foreign_table'];
1085        $foreign_field = $conf['foreign_field'];
1086        $symmetric_field = $conf['symmetric_field'] ?? '';
1087        $foreign_table_field = $conf['foreign_table_field'];
1088        $foreign_match_fields = $conf['foreign_match_fields'];
1089        // If there are table items and we have a proper $parentUid
1090        if (MathUtility::canBeInterpretedAsInteger($parentUid) && !empty($this->tableArray)) {
1091            // If updateToUid is not a positive integer, set it to '0', so it will be ignored
1092            if (!(MathUtility::canBeInterpretedAsInteger($updateToUid) && $updateToUid > 0)) {
1093                $updateToUid = 0;
1094            }
1095            $considerWorkspaces = BackendUtility::isTableWorkspaceEnabled($foreign_table);
1096            $fields = 'uid,pid,' . $foreign_field;
1097            // Consider the symmetric field if defined:
1098            if ($symmetric_field) {
1099                $fields .= ',' . $symmetric_field;
1100            }
1101            // Consider workspaces if defined and currently used:
1102            if ($considerWorkspaces) {
1103                $fields .= ',t3ver_wsid,t3ver_state,t3ver_oid';
1104            }
1105            // Update all items
1106            foreach ($this->itemArray as $val) {
1107                $uid = $val['id'];
1108                $table = $val['table'];
1109                $row = [];
1110                // Fetch the current (not overwritten) relation record if we should handle symmetric relations
1111                if ($symmetric_field || $considerWorkspaces) {
1112                    $row = BackendUtility::getRecord($table, $uid, $fields, '', true);
1113                    if (empty($row)) {
1114                        continue;
1115                    }
1116                }
1117                $isOnSymmetricSide = false;
1118                if ($symmetric_field) {
1119                    $isOnSymmetricSide = self::isOnSymmetricSide((string)$parentUid, $conf, $row);
1120                }
1121                $updateValues = $foreign_match_fields;
1122                // No update to the uid is requested, so this is the normal behaviour
1123                // just update the fields and care about sorting
1124                if (!$updateToUid) {
1125                    // Always add the pointer to the parent uid
1126                    if ($isOnSymmetricSide) {
1127                        $updateValues[$symmetric_field] = $parentUid;
1128                    } else {
1129                        $updateValues[$foreign_field] = $parentUid;
1130                    }
1131                    // If it is configured in TCA also to store the parent table in the child record, just do it
1132                    if ($foreign_table_field && $this->currentTable) {
1133                        $updateValues[$foreign_table_field] = $this->currentTable;
1134                    }
1135                    // Update sorting columns if not to be skipped.
1136                    // @deprecated since v11, will be removed with v12. Drop if() below, assume $skipSorting false, keep body.
1137                    if (!$skipSorting) {
1138                        // Get the correct sorting field
1139                        // Specific manual sortby for data handled by this field
1140                        $sortby = '';
1141                        if ($conf['foreign_sortby'] ?? false) {
1142                            $sortby = $conf['foreign_sortby'];
1143                        } elseif ($GLOBALS['TCA'][$foreign_table]['ctrl']['sortby'] ?? false) {
1144                            // manual sortby for all table records
1145                            $sortby = $GLOBALS['TCA'][$foreign_table]['ctrl']['sortby'];
1146                        }
1147                        // Apply sorting on the symmetric side
1148                        // (it depends on who created the relation, so what uid is in the symmetric_field):
1149                        if ($isOnSymmetricSide && isset($conf['symmetric_sortby']) && $conf['symmetric_sortby']) {
1150                            $sortby = $conf['symmetric_sortby'];
1151                        } else {
1152                            $tempSortBy = [];
1153                            foreach (QueryHelper::parseOrderBy($sortby) as $orderPair) {
1154                                [$fieldName, $order] = $orderPair;
1155                                if ($order !== null) {
1156                                    $tempSortBy[] = implode(' ', $orderPair);
1157                                } else {
1158                                    $tempSortBy[] = $fieldName;
1159                                }
1160                            }
1161                            $sortby = implode(',', $tempSortBy);
1162                        }
1163                        if ($sortby) {
1164                            $updateValues[$sortby] = ++$c;
1165                        }
1166                    }
1167                } else {
1168                    if ($isOnSymmetricSide) {
1169                        $updateValues[$symmetric_field] = $updateToUid;
1170                    } else {
1171                        $updateValues[$foreign_field] = $updateToUid;
1172                    }
1173                }
1174                // Update accordant fields in the database:
1175                if (!empty($updateValues)) {
1176                    // Update tstamp if any foreign field value has changed
1177                    if (!empty($GLOBALS['TCA'][$table]['ctrl']['tstamp'])) {
1178                        $updateValues[$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = $GLOBALS['EXEC_TIME'];
1179                    }
1180                    $this->getConnectionForTableName($table)
1181                        ->update(
1182                            $table,
1183                            $updateValues,
1184                            ['uid' => (int)$uid]
1185                        );
1186                    $this->updateRefIndex($table, $uid);
1187                }
1188            }
1189        }
1190    }
1191
1192    /**
1193     * After initialization you can extract an array of the elements from the object. Use this function for that.
1194     *
1195     * @param bool $prependTableName If set, then table names will ALWAYS be prepended (unless its a _NO_TABLE value)
1196     * @return array A numeric array.
1197     */
1198    public function getValueArray($prependTableName = false)
1199    {
1200        // INIT:
1201        $valueArray = [];
1202        $tableC = count($this->tableArray);
1203        // If there are tables in the table array:
1204        if ($tableC) {
1205            // If there are more than ONE table in the table array, then always prepend table names:
1206            $prep = $tableC > 1 || $prependTableName;
1207            // Traverse the array of items:
1208            foreach ($this->itemArray as $val) {
1209                $valueArray[] = ($prep && $val['table'] !== '_NO_TABLE' ? $val['table'] . '_' : '') . $val['id'];
1210            }
1211        }
1212        // Return the array
1213        return $valueArray;
1214    }
1215
1216    /**
1217     * Reads all records from internal tableArray into the internal ->results array
1218     * where keys are table names and for each table, records are stored with uids as their keys.
1219     * If $this->fetchAllFields is false you can save a little memory
1220     * since only uid,pid and a few other fields are selected.
1221     *
1222     * @return array
1223     */
1224    public function getFromDB()
1225    {
1226        // Traverses the tables listed:
1227        foreach ($this->tableArray as $table => $ids) {
1228            if (is_array($ids) && !empty($ids)) {
1229                $connection = $this->getConnectionForTableName($table);
1230                $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform());
1231
1232                foreach (array_chunk($ids, $maxBindParameters - 10, true) as $chunk) {
1233                    $queryBuilder = $connection->createQueryBuilder();
1234                    $queryBuilder->getRestrictions()->removeAll();
1235                    $queryBuilder->select('*')
1236                        ->from($table)
1237                        ->where($queryBuilder->expr()->in(
1238                            'uid',
1239                            $queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY)
1240                        ));
1241                    if ($this->additionalWhere[$table] ?? false) {
1242                        $queryBuilder->andWhere(
1243                            QueryHelper::stripLogicalOperatorPrefix($this->additionalWhere[$table])
1244                        );
1245                    }
1246                    $statement = $queryBuilder->executeQuery();
1247                    while ($row = $statement->fetchAssociative()) {
1248                        $this->results[$table][$row['uid']] = $row;
1249                    }
1250                }
1251            }
1252        }
1253        return $this->results;
1254    }
1255
1256    /**
1257     * This method is typically called after getFromDB().
1258     * $this->results holds a list of resolved and valid relations,
1259     * $this->itemArray hold a list of "selected" relations from the incoming selection array.
1260     * The difference is that "itemArray" may hold a single table/uid combination multiple times,
1261     * for instance in a type=group relation having multiple=true, while "results" hold each
1262     * resolved relation only once.
1263     * The methods creates a sanitized "itemArray" from resolved "results" list, normalized
1264     * the return array to always contain both table name and uid, and keep incoming
1265     * "itemArray" sort order and keeps "multiple" selections.
1266     *
1267     * In addition, the item array contains the full record to be used later-on and save database queries.
1268     * This method keeps the ordering intact.
1269     *
1270     * @return array
1271     */
1272    public function getResolvedItemArray(): array
1273    {
1274        $itemArray = [];
1275        foreach ($this->itemArray as $item) {
1276            if (isset($this->results[$item['table']][$item['id']])) {
1277                $itemArray[] = [
1278                    'table' => $item['table'],
1279                    'uid' => $item['id'],
1280                    'record' => $this->results[$item['table']][$item['id']],
1281                ];
1282            }
1283        }
1284        return $itemArray;
1285    }
1286
1287    /**
1288     * Counts the items in $this->itemArray and puts this value in an array by default.
1289     *
1290     * @param bool $returnAsArray Whether to put the count value in an array
1291     * @return mixed The plain count as integer or the same inside an array
1292     */
1293    public function countItems($returnAsArray = true)
1294    {
1295        $count = count($this->itemArray);
1296        if ($returnAsArray) {
1297            $count = [$count];
1298        }
1299        return $count;
1300    }
1301
1302    /**
1303     * Update Reference Index (sys_refindex) for a record.
1304     * Should be called any almost any update to a record which could affect references inside the record.
1305     * If used from within DataHandler, only registers a row for update for later processing.
1306     *
1307     * @param string $table Table name
1308     * @param int $uid Record uid
1309     * @return array Result from ReferenceIndex->updateRefIndexTable() updated directly, else empty array
1310     */
1311    protected function updateRefIndex($table, $uid): array
1312    {
1313        if (!$this->updateReferenceIndex) {
1314            return [];
1315        }
1316        if ($this->referenceIndexUpdater) {
1317            // Add to update registry if given
1318            $this->referenceIndexUpdater->registerForUpdate((string)$table, (int)$uid, $this->getWorkspaceId());
1319            $statisticsArray = [];
1320        } else {
1321            // @deprecated else branch can be dropped when setUpdateReferenceIndex() is dropped.
1322            // Update reference index directly if enabled
1323            $referenceIndex = GeneralUtility::makeInstance(ReferenceIndex::class);
1324            if (BackendUtility::isTableWorkspaceEnabled($table)) {
1325                $referenceIndex->setWorkspaceId($this->getWorkspaceId());
1326            }
1327            $statisticsArray = $referenceIndex->updateRefIndexTable($table, $uid);
1328        }
1329        return $statisticsArray;
1330    }
1331
1332    /**
1333     * Converts elements in the local item array to use version ids instead of
1334     * live ids, if possible. The most common use case is, to call that prior
1335     * to processing with MM relations in a workspace context. For tha special
1336     * case, ids on both side of the MM relation must use version ids if
1337     * available.
1338     *
1339     * @return bool Whether items have been converted
1340     */
1341    public function convertItemArray()
1342    {
1343        // conversion is only required in a workspace context
1344        // (the case that version ids are submitted in a live context are rare)
1345        if ($this->getWorkspaceId() === 0) {
1346            return false;
1347        }
1348
1349        $hasBeenConverted = false;
1350        foreach ($this->tableArray as $tableName => $ids) {
1351            if (empty($ids) || !BackendUtility::isTableWorkspaceEnabled($tableName)) {
1352                continue;
1353            }
1354
1355            // convert live ids to version ids if available
1356            $convertedIds = $this->getResolver($tableName, $ids)
1357                ->setKeepDeletePlaceholder(false)
1358                ->setKeepMovePlaceholder(false)
1359                ->processVersionOverlays($ids);
1360            foreach ($this->itemArray as $index => $item) {
1361                if ($item['table'] !== $tableName) {
1362                    continue;
1363                }
1364                $currentItemId = $item['id'];
1365                if (
1366                    !isset($convertedIds[$currentItemId])
1367                    || $currentItemId === $convertedIds[$currentItemId]
1368                ) {
1369                    continue;
1370                }
1371                // adjust local item to use resolved version id
1372                $this->itemArray[$index]['id'] = $convertedIds[$currentItemId];
1373                $hasBeenConverted = true;
1374            }
1375            // update per-table reference for ids
1376            if ($hasBeenConverted) {
1377                $this->tableArray[$tableName] = array_values($convertedIds);
1378            }
1379        }
1380
1381        return $hasBeenConverted;
1382    }
1383
1384    /**
1385     * @todo: It *should* be possible to drop all three 'purge' methods by using
1386     *        a clever join within readMM - that sounds doable now with pid -1 and
1387     *        ws-pair records being gone since v11. It would resolve this indirect
1388     *        callback logic and would reduce some queries. The (workspace) mm tests
1389     *        should be complete enough now to verify if a change like that would do.
1390     *
1391     * @param int|null $workspaceId
1392     * @return bool Whether items have been purged
1393     * @internal
1394     */
1395    public function purgeItemArray($workspaceId = null)
1396    {
1397        if ($workspaceId === null) {
1398            $workspaceId = $this->getWorkspaceId();
1399        } else {
1400            $workspaceId = (int)$workspaceId;
1401        }
1402
1403        // Ensure, only live relations are in the items Array
1404        if ($workspaceId === 0) {
1405            $purgeCallback = 'purgeVersionedIds';
1406        } else {
1407            // Otherwise, ensure that live relations are purged if version exists
1408            $purgeCallback = 'purgeLiveVersionedIds';
1409        }
1410
1411        $itemArrayHasBeenPurged = $this->purgeItemArrayHandler($purgeCallback);
1412        $this->purged = ($this->purged || $itemArrayHasBeenPurged);
1413        return $itemArrayHasBeenPurged;
1414    }
1415
1416    /**
1417     * Removes items having a delete placeholder from $this->itemArray
1418     *
1419     * @return bool Whether items have been purged
1420     */
1421    public function processDeletePlaceholder()
1422    {
1423        if (!$this->useLiveReferenceIds || $this->getWorkspaceId() === 0) {
1424            return false;
1425        }
1426
1427        return $this->purgeItemArrayHandler('purgeDeletePlaceholder');
1428    }
1429
1430    /**
1431     * Handles a purge callback on $this->itemArray
1432     *
1433     * @param string $purgeCallback
1434     * @return bool Whether items have been purged
1435     */
1436    protected function purgeItemArrayHandler($purgeCallback)
1437    {
1438        $itemArrayHasBeenPurged = false;
1439
1440        foreach ($this->tableArray as $itemTableName => $itemIds) {
1441            if (empty($itemIds) || !BackendUtility::isTableWorkspaceEnabled($itemTableName)) {
1442                continue;
1443            }
1444
1445            $purgedItemIds = [];
1446            $callable =[$this, $purgeCallback];
1447            if (is_callable($callable)) {
1448                $purgedItemIds = $callable($itemTableName, $itemIds);
1449            }
1450
1451            $removedItemIds = array_diff($itemIds, $purgedItemIds);
1452            foreach ($removedItemIds as $removedItemId) {
1453                $this->removeFromItemArray($itemTableName, $removedItemId);
1454            }
1455            $this->tableArray[$itemTableName] = $purgedItemIds;
1456            if (!empty($removedItemIds)) {
1457                $itemArrayHasBeenPurged = true;
1458            }
1459        }
1460
1461        return $itemArrayHasBeenPurged;
1462    }
1463
1464    /**
1465     * Purges ids that are versioned.
1466     *
1467     * @param string $tableName
1468     * @param array $ids
1469     * @return array
1470     */
1471    protected function purgeVersionedIds($tableName, array $ids)
1472    {
1473        $ids = $this->sanitizeIds($ids);
1474        $ids = (array)array_combine($ids, $ids);
1475        $connection = $this->getConnectionForTableName($tableName);
1476        $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform());
1477
1478        foreach (array_chunk($ids, $maxBindParameters - 10, true) as $chunk) {
1479            $queryBuilder = $connection->createQueryBuilder();
1480            $queryBuilder->getRestrictions()->removeAll();
1481            $result = $queryBuilder->select('uid', 't3ver_oid', 't3ver_state')
1482                ->from($tableName)
1483                ->where(
1484                    $queryBuilder->expr()->in(
1485                        'uid',
1486                        $queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY)
1487                    ),
1488                    $queryBuilder->expr()->neq(
1489                        't3ver_wsid',
1490                        $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
1491                    )
1492                )
1493                ->orderBy('t3ver_state', 'DESC')
1494                ->executeQuery();
1495
1496            while ($version = $result->fetchAssociative()) {
1497                $versionId = $version['uid'];
1498                if (isset($ids[$versionId])) {
1499                    unset($ids[$versionId]);
1500                }
1501            }
1502        }
1503
1504        return array_values($ids);
1505    }
1506
1507    /**
1508     * Purges ids that are live but have an accordant version.
1509     *
1510     * @param string $tableName
1511     * @param array $ids
1512     * @return array
1513     */
1514    protected function purgeLiveVersionedIds($tableName, array $ids)
1515    {
1516        $ids = $this->sanitizeIds($ids);
1517        $ids = (array)array_combine($ids, $ids);
1518        $connection = $this->getConnectionForTableName($tableName);
1519        $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform());
1520
1521        foreach (array_chunk($ids, $maxBindParameters - 10, true) as $chunk) {
1522            $queryBuilder = $connection->createQueryBuilder();
1523            $queryBuilder->getRestrictions()->removeAll();
1524            $result = $queryBuilder->select('uid', 't3ver_oid', 't3ver_state')
1525                ->from($tableName)
1526                ->where(
1527                    $queryBuilder->expr()->in(
1528                        't3ver_oid',
1529                        $queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY)
1530                    ),
1531                    $queryBuilder->expr()->neq(
1532                        't3ver_wsid',
1533                        $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
1534                    )
1535                )
1536                ->orderBy('t3ver_state', 'DESC')
1537                ->executeQuery();
1538
1539            while ($version = $result->fetchAssociative()) {
1540                $versionId = $version['uid'];
1541                $liveId = $version['t3ver_oid'];
1542                if (isset($ids[$liveId]) && isset($ids[$versionId])) {
1543                    unset($ids[$liveId]);
1544                }
1545            }
1546        }
1547
1548        return array_values($ids);
1549    }
1550
1551    /**
1552     * Purges ids that have a delete placeholder
1553     *
1554     * @param string $tableName
1555     * @param array $ids
1556     * @return array
1557     */
1558    protected function purgeDeletePlaceholder($tableName, array $ids)
1559    {
1560        $ids = $this->sanitizeIds($ids);
1561        $ids = array_combine($ids, $ids) ?: [];
1562        $connection = $this->getConnectionForTableName($tableName);
1563        $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform());
1564
1565        foreach (array_chunk($ids, $maxBindParameters - 10, true) as $chunk) {
1566            $queryBuilder = $connection->createQueryBuilder();
1567            $queryBuilder->getRestrictions()->removeAll();
1568            $result = $queryBuilder->select('uid', 't3ver_oid', 't3ver_state')
1569                ->from($tableName)
1570                ->where(
1571                    $queryBuilder->expr()->in(
1572                        't3ver_oid',
1573                        $queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY)
1574                    ),
1575                    $queryBuilder->expr()->eq(
1576                        't3ver_wsid',
1577                        $queryBuilder->createNamedParameter(
1578                            $this->getWorkspaceId(),
1579                            \PDO::PARAM_INT
1580                        )
1581                    ),
1582                    $queryBuilder->expr()->eq(
1583                        't3ver_state',
1584                        $queryBuilder->createNamedParameter(
1585                            (string)VersionState::cast(VersionState::DELETE_PLACEHOLDER),
1586                            \PDO::PARAM_INT
1587                        )
1588                    )
1589                )
1590                ->executeQuery();
1591
1592            while ($version = $result->fetchAssociative()) {
1593                $liveId = $version['t3ver_oid'];
1594                if (isset($ids[$liveId])) {
1595                    unset($ids[$liveId]);
1596                }
1597            }
1598        }
1599
1600        return array_values($ids);
1601    }
1602
1603    protected function removeFromItemArray($tableName, $id)
1604    {
1605        foreach ($this->itemArray as $index => $item) {
1606            if ($item['table'] === $tableName && (string)$item['id'] === (string)$id) {
1607                unset($this->itemArray[$index]);
1608                return true;
1609            }
1610        }
1611        return false;
1612    }
1613
1614    /**
1615     * Checks, if we're looking from the "other" side, the symmetric side, to a symmetric relation.
1616     *
1617     * @param string $parentUid The uid of the parent record
1618     * @param array $parentConf The TCA configuration of the parent field embedding the child records
1619     * @param array $childRec The record row of the child record
1620     * @return bool Returns TRUE if looking from the symmetric ("other") side to the relation.
1621     */
1622    protected static function isOnSymmetricSide($parentUid, $parentConf, $childRec)
1623    {
1624        return MathUtility::canBeInterpretedAsInteger($childRec['uid'])
1625            && $parentConf['symmetric_field']
1626            && $parentUid == $childRec[$parentConf['symmetric_field']];
1627    }
1628
1629    /**
1630     * Completes MM values to be written by values from the opposite relation.
1631     * This method used MM insert field or MM match fields if defined.
1632     *
1633     * @param string $tableName Name of the opposite table
1634     * @param array $referenceValues Values to be written
1635     * @return array Values to be written, possibly modified
1636     */
1637    protected function completeOppositeUsageValues($tableName, array $referenceValues)
1638    {
1639        if (empty($this->MM_oppositeUsage[$tableName]) || count($this->MM_oppositeUsage[$tableName]) > 1) {
1640            // @todo: count($this->MM_oppositeUsage[$tableName]) > 1 is buggy.
1641            //        Scenario: Suppose a foreign table has two (!) fields that link to a sys_category. Relations can
1642            //        then be correctly set for both fields when editing the foreign records. But when editing a sys_category
1643            //        record (local side) and adding a relation to a table that has two category relation fields, the 'fieldname'
1644            //        entry in mm-table can not be decided and ends up empty. Neither of the foreign table fields then recognize
1645            //        the relation as being set.
1646            //        One simple solution is to either simply pick the *first* field, or set *both* relations, but this
1647            //        is a) guesswork and b) it may be that in practice only *one* field is actually shown due to record
1648            //        types "showitem".
1649            //        Brain melt increases with tt_content field 'selected_category' in combination with
1650            //        'category_field' for record types 'menu_categorized_pages' and 'menu_categorized_content' next
1651            //        to casual 'categories' field. However, 'selected_category' is a 'oneToMany' and not a 'manyToMany'.
1652            //        Hard nut ...
1653            return $referenceValues;
1654        }
1655
1656        $fieldName = $this->MM_oppositeUsage[$tableName][0];
1657        if (empty($GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'])) {
1658            return $referenceValues;
1659        }
1660
1661        $configuration = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
1662        if (!empty($configuration['MM_insert_fields'])) {
1663            // @todo: MM_insert_fields does not make sense and should be probably dropped altogether.
1664            //        No core usages, not even with sys_category. There is no point in having data fields that
1665            //        are filled with static content, especially since the mm table can't be edited directly.
1666            $referenceValues = array_merge($configuration['MM_insert_fields'], $referenceValues);
1667        } elseif (!empty($configuration['MM_match_fields'])) {
1668            // @todo: In the end, MM_match_fields does not make sense. The 'tablename' and 'fieldname' restriction
1669            //        in addition to uid_local and uid_foreign used when multiple 'foreign' tables and/or multiple fields
1670            //        of one table refer to a single 'local' table having an mm table with these four fields, is already
1671            //        clear when looking at 'MM_oppositeUsage' of the local table. 'MM_match_fields' should thus probably
1672            //        fall altogether. The only information carried here are the field names of 'tablename' and 'fieldname'
1673            //        within the mm table itself, which we should hard code. This is partially assumed in DefaultTcaSchema
1674            //        already.
1675            $referenceValues = array_merge($configuration['MM_match_fields'], $referenceValues);
1676        }
1677
1678        return $referenceValues;
1679    }
1680
1681    /**
1682     * Gets the record uid of the live default record. If already
1683     * pointing to the live record, the submitted record uid is returned.
1684     *
1685     * @param string $tableName
1686     * @param int|string $id
1687     * @return int
1688     */
1689    protected function getLiveDefaultId($tableName, $id)
1690    {
1691        $liveDefaultId = BackendUtility::getLiveVersionIdOfRecord($tableName, $id);
1692        if ($liveDefaultId === null) {
1693            $liveDefaultId = $id;
1694        }
1695        return (int)$liveDefaultId;
1696    }
1697
1698    /**
1699     * Removes empty values (null, '0', 0, false).
1700     *
1701     * @param int[] $ids
1702     * @return array
1703     */
1704    protected function sanitizeIds(array $ids): array
1705    {
1706        return array_filter($ids);
1707    }
1708
1709    /**
1710     * @param string $tableName
1711     * @param int[] $ids
1712     * @param array $sortingStatement
1713     * @return PlainDataResolver
1714     */
1715    protected function getResolver($tableName, array $ids, array $sortingStatement = null)
1716    {
1717        /** @var PlainDataResolver $resolver */
1718        $resolver = GeneralUtility::makeInstance(
1719            PlainDataResolver::class,
1720            $tableName,
1721            $ids,
1722            $sortingStatement
1723        );
1724        $resolver->setWorkspaceId($this->getWorkspaceId());
1725        $resolver->setKeepDeletePlaceholder(true);
1726        $resolver->setKeepLiveIds($this->useLiveReferenceIds);
1727        return $resolver;
1728    }
1729
1730    /**
1731     * @param string $tableName
1732     * @return Connection
1733     */
1734    protected function getConnectionForTableName(string $tableName)
1735    {
1736        return GeneralUtility::makeInstance(ConnectionPool::class)
1737            ->getConnectionForTable($tableName);
1738    }
1739}
1740