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