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