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