RelationHandler::isPurged()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 0
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
16
namespace TYPO3\CMS\Core\Database;
17
18
use TYPO3\CMS\Backend\Utility\BackendUtility;
19
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
20
use TYPO3\CMS\Core\Configuration\Features;
21
use TYPO3\CMS\Core\Database\Platform\PlatformInformation;
22
use TYPO3\CMS\Core\Database\Query\QueryHelper;
23
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
24
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
25
use TYPO3\CMS\Core\DataHandling\PlainDataResolver;
26
use TYPO3\CMS\Core\DataHandling\ReferenceIndexUpdater;
27
use TYPO3\CMS\Core\Utility\GeneralUtility;
28
use TYPO3\CMS\Core\Utility\MathUtility;
29
use 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
 */
36
class 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)) {
0 ignored issues
show
introduced by
The condition is_array($this->tableArray) is always true.
Loading history...
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;
0 ignored issues
show
Deprecated Code introduced by
The property TYPO3\CMS\Core\Database\...::$updateReferenceIndex has been deprecated: since v11, will be removed in v12 ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

361
        /** @scrutinizer ignore-deprecated */ $this->updateReferenceIndex = (bool)$updateReferenceIndex;

This property has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.

Loading history...
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->execute();
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->execute();
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->execute();
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->execute();
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->execute();
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->execute();
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->execute();
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) {
0 ignored issues
show
Deprecated Code introduced by
The property TYPO3\CMS\Core\Database\...::$updateReferenceIndex has been deprecated: since v11, will be removed in v12 ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

1313
        if (!/** @scrutinizer ignore-deprecated */ $this->updateReferenceIndex) {

This property has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.

Loading history...
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
                ->execute();
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
                ->execute();
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
                ->execute();
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