1599 lines
69 KiB
PHP
1599 lines
69 KiB
PHP
<?php
|
|
|
|
/*
|
|
* This file is part of the TYPO3 CMS project.
|
|
*
|
|
* It is free software; you can redistribute it and/or modify it under
|
|
* the terms of the GNU General Public License, either version 2
|
|
* of the License, or any later version.
|
|
*
|
|
* For the full copyright and license information, please read the
|
|
* LICENSE.txt file that was distributed with this source code.
|
|
*
|
|
* The TYPO3 project - inspiring people to share!
|
|
*/
|
|
|
|
namespace TYPO3\CMS\Core\Database;
|
|
|
|
use TYPO3\CMS\Backend\Utility\BackendUtility;
|
|
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
|
|
use TYPO3\CMS\Core\Database\Platform\PlatformInformation;
|
|
use TYPO3\CMS\Core\Database\Query\QueryHelper;
|
|
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
|
|
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
|
|
use TYPO3\CMS\Core\DataHandling\PlainDataResolver;
|
|
use TYPO3\CMS\Core\DataHandling\ReferenceIndexUpdater;
|
|
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
|
|
use TYPO3\CMS\Core\Schema\Field\FieldTypeInterface;
|
|
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
use TYPO3\CMS\Core\Utility\MathUtility;
|
|
use TYPO3\CMS\Core\Versioning\VersionState;
|
|
|
|
/**
|
|
* Load database groups (relations)
|
|
* Used to process the relations created by the TCA element types
|
|
* - "group"
|
|
* - "select"
|
|
* - "inline"
|
|
* - "file"
|
|
* - "category"
|
|
* for database records. Manages MM-relations as well.
|
|
*/
|
|
class RelationHandler
|
|
{
|
|
/**
|
|
* If set, values that are not ids in tables are normally discarded. By this options they will be preserved.
|
|
*/
|
|
public bool $registerNonTableValues = false;
|
|
|
|
/**
|
|
* Contains the table names as keys. The values are the id-values for each table.
|
|
* Should ONLY contain proper table names.
|
|
*/
|
|
public array $tableArray = [];
|
|
|
|
/**
|
|
* Contains items in a numeric array (table/id for each). Tablenames here might be "_NO_TABLE". Keeps
|
|
* the sorting of thee retrieved items.
|
|
*
|
|
* @var array<int, array<string, mixed>>
|
|
*/
|
|
public array $itemArray = [];
|
|
|
|
/**
|
|
* Array for NON-table elements
|
|
*/
|
|
public array $nonTableArray = [];
|
|
|
|
public array $additionalWhere = [];
|
|
|
|
/**
|
|
* Deleted-column is added to additionalWhere... if this is set...
|
|
*/
|
|
public bool $checkIfDeleted = true;
|
|
|
|
/**
|
|
* Will contain the first table name in the $tablelist (for positive ids)
|
|
*/
|
|
protected string $firstTable = '';
|
|
|
|
/**
|
|
* If TRUE, uid_local and uid_foreign are switched, and the current table
|
|
* is inserted as tablename - this means you display a foreign relation "from the opposite side"
|
|
*/
|
|
protected bool $MM_is_foreign = false;
|
|
|
|
/**
|
|
* Is empty by default; if MM_is_foreign is set and there is more than one table
|
|
* allowed (on the "local" side), then it contains the first table (as a fallback)
|
|
*/
|
|
protected string $MM_isMultiTableRelationship = '';
|
|
|
|
/**
|
|
* Current table => Only needed for reverse relations
|
|
*/
|
|
protected string $currentTable = '';
|
|
|
|
/**
|
|
* If a record should be undeleted
|
|
* (so do not use the $useDeleteClause on \TYPO3\CMS\Backend\Utility\BackendUtility)
|
|
*/
|
|
public bool $undeleteRecord = false;
|
|
|
|
/**
|
|
* Array of fields value pairs that should match while SELECT.
|
|
*/
|
|
protected array $MM_match_fields = [];
|
|
|
|
/**
|
|
* When "multiple" is true, the MM table has the "uid" column as primary key. With
|
|
* "multiple", items can be selected more than once, combination "uid_local" and "uid_foreign"
|
|
* (plus "tablenames" and "fieldname" for "multi-foreign" setups) are not unique and can not
|
|
* be primary-keyed.
|
|
* Query results and insert/update operations are influenced by this a bit.
|
|
*/
|
|
protected bool $multiple = false;
|
|
|
|
/**
|
|
* Extra MM table where
|
|
*/
|
|
protected string $MM_table_where = '';
|
|
|
|
/**
|
|
* Usage of an MM field on the opposite relation.
|
|
*/
|
|
protected array $MM_oppositeUsage;
|
|
|
|
protected ?ReferenceIndexUpdater $referenceIndexUpdater = null;
|
|
|
|
protected bool $useLiveParentIds = true;
|
|
|
|
protected bool $useLiveReferenceIds = true;
|
|
|
|
protected ?int $workspaceId = null;
|
|
|
|
protected bool $purged = false;
|
|
|
|
/**
|
|
* This array will be filled by getFromDB().
|
|
*/
|
|
public array $results = [];
|
|
|
|
protected TcaSchemaFactory $tcaSchemaFactory;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->tcaSchemaFactory = GeneralUtility::makeInstance(TcaSchemaFactory::class);
|
|
}
|
|
|
|
/**
|
|
* Gets the current workspace id.
|
|
*/
|
|
protected function getWorkspaceId(): int
|
|
{
|
|
if ($this->workspaceId === null) {
|
|
$backendUser = $GLOBALS['BE_USER'] ?? null;
|
|
$this->workspaceId = $backendUser instanceof BackendUserAuthentication ? (int)($backendUser->workspace) : 0;
|
|
}
|
|
return $this->workspaceId;
|
|
}
|
|
|
|
/**
|
|
* Sets the current workspace id.
|
|
*
|
|
* @param int $workspaceId
|
|
*/
|
|
public function setWorkspaceId($workspaceId): void
|
|
{
|
|
$this->workspaceId = (int)$workspaceId;
|
|
}
|
|
|
|
/**
|
|
* Setter to carry the 'deferred' reference index updater registry around.
|
|
*
|
|
* @internal Used internally within DataHandler only
|
|
*/
|
|
public function setReferenceIndexUpdater(ReferenceIndexUpdater $updater): void
|
|
{
|
|
$this->referenceIndexUpdater = $updater;
|
|
}
|
|
|
|
/**
|
|
* Whether item array has been purged in this instance.
|
|
*/
|
|
public function isPurged(): bool
|
|
{
|
|
return $this->purged;
|
|
}
|
|
|
|
/**
|
|
* Use this method to find relations for a specific field / table of a record.
|
|
* Once the initializeForField() method was called, the resolved IDs can be used via
|
|
* ->getValueArray() et al.
|
|
*/
|
|
public function initializeForField(
|
|
string $tableName,
|
|
array|FieldTypeInterface $fieldConfiguration,
|
|
array|string|int|null $baseRecordOrUid,
|
|
string|int|float|array|null $currentValue = null
|
|
): void {
|
|
if ($fieldConfiguration instanceof FieldTypeInterface) {
|
|
$fieldConfiguration = $fieldConfiguration->getConfiguration();
|
|
}
|
|
|
|
$manyToManyConfiguration = $fieldConfiguration['MM'] ?? '';
|
|
$recordUid = $baseRecordOrUid;
|
|
if (is_array($baseRecordOrUid)) {
|
|
$recordUid = (int)($baseRecordOrUid['uid'] ?? 0);
|
|
// If not dealing with MM relations, use default live uid, not versioned uid for record relations
|
|
// MM relations point to their versioned ID
|
|
if (!$manyToManyConfiguration
|
|
&& $this->tcaSchemaFactory->has($tableName)
|
|
&& $this->tcaSchemaFactory->get($tableName)->hasCapability(TcaSchemaCapability::Workspace)
|
|
&& ($baseRecordOrUid['t3ver_oid'] ?? 0) > 0
|
|
) {
|
|
$recordUid = (int)$baseRecordOrUid['t3ver_oid'];
|
|
}
|
|
}
|
|
|
|
$this->registerNonTableValues = (bool)($fieldConfiguration['allowNonIdValues'] ?? false);
|
|
$foreignTable = $fieldConfiguration['allowed'] ?? $fieldConfiguration['foreign_table'] ?? '';
|
|
// type=file uses allowed AND foreign_table
|
|
if ($fieldConfiguration['type'] === 'file') {
|
|
$foreignTable = $fieldConfiguration['foreign_table'];
|
|
}
|
|
$this->start(
|
|
is_array($currentValue) ? implode(',', $currentValue) : (string)$currentValue,
|
|
$foreignTable,
|
|
$manyToManyConfiguration,
|
|
$recordUid,
|
|
$tableName,
|
|
$fieldConfiguration
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Initialization of the class.
|
|
*
|
|
* @param string $itemlist List of group/select items
|
|
* @param string $tablelist Comma list of tables, first table takes priority if no table is set for an entry in the list.
|
|
* @param string $MMtable Name of a MM table.
|
|
* @param int|string $MMuid Local UID for MM lookup. May be a string for newly created elements.
|
|
* @param string $currentTable Current table name
|
|
* @param array $conf TCA configuration for current field
|
|
*/
|
|
public function start($itemlist, $tablelist, $MMtable = '', $MMuid = 0, $currentTable = '', $conf = [])
|
|
{
|
|
$conf = (array)$conf;
|
|
// SECTION: MM reverse relations
|
|
$this->MM_is_foreign = (bool)($conf['MM_opposite_field'] ?? false);
|
|
$this->MM_table_where = (string)($conf['MM_table_where'] ?? '');
|
|
$this->multiple = (bool)($conf['multiple'] ?? false);
|
|
$this->MM_match_fields = (isset($conf['MM_match_fields']) && is_array($conf['MM_match_fields'])) ? $conf['MM_match_fields'] : [];
|
|
$this->currentTable = $currentTable;
|
|
if (!empty($conf['MM_oppositeUsage']) && is_array($conf['MM_oppositeUsage'])) {
|
|
$this->MM_oppositeUsage = $conf['MM_oppositeUsage'];
|
|
}
|
|
$mmOppositeTable = '';
|
|
if ($this->MM_is_foreign) {
|
|
$allowedTableList = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
|
|
// Normally, $conf['allowed'] can contain a list of tables,
|
|
// but as we are looking at an MM relation from the foreign side,
|
|
// it only makes sense to allow one table in $conf['allowed'].
|
|
[$mmOppositeTable] = GeneralUtility::trimExplode(',', $allowedTableList);
|
|
// Only add the current table name if there is more than one allowed
|
|
// field. We must be sure this has been done at least once before accessing
|
|
// the "columns" part of TCA for a table.
|
|
if ($this->tcaSchemaFactory->has($mmOppositeTable)) {
|
|
$oppositeSchema = $this->tcaSchemaFactory->get($mmOppositeTable);
|
|
$mmOppositeAllowed = $oppositeSchema->hasField($conf['MM_opposite_field']) ? ($oppositeSchema->getField($conf['MM_opposite_field'])->getConfiguration()['allowed'] ?? '') : '';
|
|
if ($mmOppositeAllowed !== '') {
|
|
$mmOppositeAllowedTables = explode(',', $mmOppositeAllowed);
|
|
if ($mmOppositeAllowed === '*' || count($mmOppositeAllowedTables) > 1) {
|
|
$this->MM_isMultiTableRelationship = $mmOppositeAllowedTables[0];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// SECTION: normal MM relations
|
|
// If the table list is "*" then all tables are used in the list:
|
|
if (trim($tablelist) === '*') {
|
|
$tables = $this->tcaSchemaFactory->all()->getNames();
|
|
} else {
|
|
$tables = GeneralUtility::trimExplode(',', $tablelist, true);
|
|
}
|
|
// The tables are traversed and internal arrays are initialized:
|
|
foreach ($tables as $tableName) {
|
|
// @todo: Loop could be restricted in MM local when MM_oppositeUsage is used.
|
|
if (!$this->tcaSchemaFactory->has($tableName)) {
|
|
continue;
|
|
}
|
|
$schema = $this->tcaSchemaFactory->get($tableName);
|
|
$this->tableArray[$tableName] = [];
|
|
if ($this->checkIfDeleted && $schema->hasCapability(TcaSchemaCapability::SoftDelete)) {
|
|
if (!isset($this->additionalWhere[$tableName])) {
|
|
$this->additionalWhere[$tableName] = '';
|
|
}
|
|
// @todo: Omit ' AND ' and QueryHelper::stripLogicalOperatorPrefix() in consumers
|
|
$this->additionalWhere[$tableName] .= ' AND ' . $tableName . '.' . $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName() . '=0';
|
|
}
|
|
}
|
|
if ($this->tableArray !== []) {
|
|
reset($this->tableArray);
|
|
} else {
|
|
// No tables
|
|
return;
|
|
}
|
|
// Set first and second tables:
|
|
// Is the first table
|
|
$this->firstTable = (string)key($this->tableArray);
|
|
next($this->tableArray);
|
|
// Now, populate the internal itemArray and tableArray arrays:
|
|
// If MM, then call this function to do that:
|
|
if ($MMtable) {
|
|
if ($MMuid) {
|
|
$this->readMM($MMtable, $MMuid, $mmOppositeTable);
|
|
$this->purgeItemArray();
|
|
} else {
|
|
// Revert to readList() for new records in order to load possible default values from $itemlist
|
|
$this->readList($itemlist, $conf);
|
|
$this->purgeItemArray();
|
|
}
|
|
} elseif ($MMuid && ($conf['foreign_field'] ?? false)) {
|
|
// If not MM but foreign_field, the read the records by the foreign_field
|
|
$this->readForeignField((int)$MMuid, $conf);
|
|
} else {
|
|
// If not MM, then explode the itemlist by "," and traverse the list:
|
|
$this->readList($itemlist, $conf);
|
|
// Do automatic default_sortby, if any
|
|
if (isset($conf['foreign_default_sortby']) && $conf['foreign_default_sortby']) {
|
|
$this->sortList($conf['foreign_default_sortby']);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param bool $useLiveParentIds
|
|
*/
|
|
public function setUseLiveParentIds($useLiveParentIds)
|
|
{
|
|
$this->useLiveParentIds = (bool)$useLiveParentIds;
|
|
}
|
|
|
|
/**
|
|
* @param bool $useLiveReferenceIds
|
|
*/
|
|
public function setUseLiveReferenceIds($useLiveReferenceIds)
|
|
{
|
|
$this->useLiveReferenceIds = (bool)$useLiveReferenceIds;
|
|
}
|
|
|
|
/**
|
|
* Explodes the item list and stores the parts in the internal arrays itemArray and tableArray from MM records.
|
|
*
|
|
* @param string $itemlist Item list
|
|
* @param array $configuration Parent field configuration
|
|
*/
|
|
protected function readList($itemlist, array $configuration)
|
|
{
|
|
if (trim((string)$itemlist) !== '') {
|
|
// Changed to trimExplode 31/3 04; HMENU special type "list" didn't work
|
|
// if there were spaces in the list... I suppose this is better overall...
|
|
$tempItemArray = GeneralUtility::trimExplode(',', $itemlist);
|
|
// If the second table is set and the ID number is less than zero (later)
|
|
// then the record is regarded to come from the second table...
|
|
$secondTable = (string)(key($this->tableArray) ?? '');
|
|
foreach ($tempItemArray as $key => $val) {
|
|
// Will be set to "true" if the entry was a real table/id
|
|
$isSet = false;
|
|
// Extract table name and id. This is in the formula [tablename]_[id]
|
|
// where table name MIGHT contain "_", hence the reversion of the string!
|
|
$val = strrev($val);
|
|
$parts = explode('_', $val, 2);
|
|
$theID = strrev($parts[0]);
|
|
// Check that the id IS an integer:
|
|
if (MathUtility::canBeInterpretedAsInteger($theID)) {
|
|
// Get the table name: If a part of the exploded string, use that.
|
|
// Otherwise if the id number is LESS than zero, use the second table, otherwise the first table
|
|
$theTable = trim($parts[1] ?? '')
|
|
? strrev(trim($parts[1] ?? ''))
|
|
: ($secondTable && $theID < 0 ? $secondTable : $this->firstTable);
|
|
// If the ID is not blank and the table name is among the names in the inputted tableList
|
|
if ((string)$theID != '' && $theID && $theTable && isset($this->tableArray[$theTable])) {
|
|
// Get ID as the right value:
|
|
$theID = $secondTable ? abs((int)$theID) : (int)$theID;
|
|
// Register ID/table name in internal arrays:
|
|
$this->itemArray[$key]['id'] = $theID;
|
|
$this->itemArray[$key]['table'] = $theTable;
|
|
$this->tableArray[$theTable][] = $theID;
|
|
// Set update-flag
|
|
$isSet = true;
|
|
}
|
|
}
|
|
// If it turns out that the value from the list was NOT a valid reference to a table-record,
|
|
// then we might still set it as a NO_TABLE value:
|
|
if (!$isSet && $this->registerNonTableValues) {
|
|
$this->itemArray[$key]['id'] = $tempItemArray[$key];
|
|
$this->itemArray[$key]['table'] = '_NO_TABLE';
|
|
$this->nonTableArray[] = $tempItemArray[$key];
|
|
}
|
|
}
|
|
|
|
// Skip if not dealing with IRRE in a CSV list on a workspace
|
|
if (!isset($configuration['type']) || ($configuration['type'] !== 'inline' && $configuration['type'] !== 'file')
|
|
|| empty($configuration['foreign_table']) || !empty($configuration['foreign_field'])
|
|
|| !empty($configuration['MM']) || count($this->tableArray) !== 1 || empty($this->tableArray[$configuration['foreign_table']])
|
|
|| $this->getWorkspaceId() === 0 || (!$this->tcaSchemaFactory->has($configuration['foreign_table']) || !$this->tcaSchemaFactory->get($configuration['foreign_table'])->hasCapability(TcaSchemaCapability::Workspace))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Fetch live record data
|
|
if ($this->useLiveReferenceIds) {
|
|
foreach ($this->itemArray as &$item) {
|
|
$item['id'] = $this->getLiveDefaultId($item['table'], $item['id']);
|
|
}
|
|
} else {
|
|
// Directly overlay workspace data
|
|
$this->itemArray = [];
|
|
$foreignTable = $configuration['foreign_table'];
|
|
$ids = $this->getResolver($foreignTable, $this->tableArray[$foreignTable])->get();
|
|
foreach ($ids as $id) {
|
|
$this->itemArray[] = [
|
|
'id' => $id,
|
|
'table' => $foreignTable,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Does a sorting on $this->itemArray depending on a default sortby field.
|
|
* This is only used for automatic sorting of comma separated lists.
|
|
* This function is only relevant for data that is stored in comma separated lists!
|
|
*
|
|
* @param string $sortby The default_sortby field/command (e.g. 'price DESC')
|
|
*/
|
|
protected function sortList($sortby)
|
|
{
|
|
// Sort directly without fetching additional data
|
|
if ($sortby === 'uid') {
|
|
usort(
|
|
$this->itemArray,
|
|
static function ($a, $b) {
|
|
return $a['id'] < $b['id'] ? -1 : 1;
|
|
}
|
|
);
|
|
} elseif (count($this->tableArray) === 1) {
|
|
reset($this->tableArray);
|
|
$table = (string)key($this->tableArray);
|
|
$connection = $this->getConnectionForTableName($table);
|
|
$maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform());
|
|
|
|
foreach (array_chunk(current($this->tableArray), $maxBindParameters - 10, true) as $chunk) {
|
|
if (empty($chunk)) {
|
|
continue;
|
|
}
|
|
$this->itemArray = [];
|
|
$this->tableArray = [];
|
|
$queryBuilder = $connection->createQueryBuilder();
|
|
$queryBuilder->getRestrictions()->removeAll();
|
|
$queryBuilder->select('uid')
|
|
->from($table)
|
|
->where(
|
|
$queryBuilder->expr()->in(
|
|
'uid',
|
|
$queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY)
|
|
)
|
|
);
|
|
foreach (QueryHelper::parseOrderBy((string)$sortby) as $orderPair) {
|
|
[$fieldName, $order] = $orderPair;
|
|
$queryBuilder->addOrderBy($fieldName, $order);
|
|
}
|
|
$statement = $queryBuilder->executeQuery();
|
|
while ($row = $statement->fetchAssociative()) {
|
|
$this->itemArray[] = ['id' => $row['uid'], 'table' => $table];
|
|
$this->tableArray[$table][] = $row['uid'];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reads the record tablename/id into the internal arrays itemArray and tableArray from MM records.
|
|
*
|
|
* @todo: The source record is not checked for correct workspace. Say there is a category 5 in
|
|
* workspace 1. setWorkspace(0) is called, after that readMM('sys_category_record_mm', 5 ...).
|
|
* readMM will *still* return the list of records connected to this workspace 1 item,
|
|
* even though workspace 0 has been set.
|
|
*
|
|
* @param string $tableName MM Tablename
|
|
* @param int|string $uid Local UID
|
|
* @param string $mmOppositeTable Opposite table name
|
|
*/
|
|
protected function readMM($tableName, $uid, $mmOppositeTable)
|
|
{
|
|
$theTable = null;
|
|
$queryBuilder = $this->getConnectionForTableName($tableName)->createQueryBuilder();
|
|
$queryBuilder->getRestrictions()->removeAll();
|
|
$queryBuilder->select('*')->from($tableName);
|
|
// Default
|
|
$uidLocal_field = 'uid_local';
|
|
$uidForeign_field = 'uid_foreign';
|
|
$sorting_field = 'sorting';
|
|
$sortingForeign_field = 'sorting_foreign';
|
|
if ($this->MM_is_foreign) {
|
|
// In case of a reverse relation
|
|
$uidLocal_field = 'uid_foreign';
|
|
$uidForeign_field = 'uid_local';
|
|
$sorting_field = 'sorting_foreign';
|
|
$sortingForeign_field = 'sorting';
|
|
if ($this->MM_isMultiTableRelationship) {
|
|
// Be backwards compatible! When allowing more than one table after
|
|
// having previously allowed only one table, this case applies.
|
|
if ($this->currentTable == $this->MM_isMultiTableRelationship) {
|
|
$expression = $queryBuilder->expr()->or(
|
|
$queryBuilder->expr()->eq(
|
|
'tablenames',
|
|
$queryBuilder->createNamedParameter($this->currentTable)
|
|
),
|
|
$queryBuilder->expr()->eq(
|
|
'tablenames',
|
|
$queryBuilder->createNamedParameter('')
|
|
)
|
|
);
|
|
} else {
|
|
$expression = $queryBuilder->expr()->eq(
|
|
'tablenames',
|
|
$queryBuilder->createNamedParameter($this->currentTable)
|
|
);
|
|
}
|
|
$queryBuilder->andWhere($expression);
|
|
}
|
|
$theTable = $mmOppositeTable;
|
|
}
|
|
if ($this->MM_table_where) {
|
|
$queryBuilder->andWhere(
|
|
QueryHelper::stripLogicalOperatorPrefix(str_replace('###THIS_UID###', (string)$uid, QueryHelper::quoteDatabaseIdentifiers($queryBuilder->getConnection(), $this->MM_table_where)))
|
|
);
|
|
}
|
|
foreach ($this->MM_match_fields as $field => $value) {
|
|
$queryBuilder->andWhere(
|
|
$queryBuilder->expr()->eq($field, $queryBuilder->createNamedParameter($value))
|
|
);
|
|
}
|
|
$queryBuilder->andWhere(
|
|
$queryBuilder->expr()->eq(
|
|
$uidLocal_field,
|
|
$queryBuilder->createNamedParameter((int)$uid, Connection::PARAM_INT)
|
|
)
|
|
);
|
|
$queryBuilder->orderBy($sorting_field);
|
|
$queryBuilder->addOrderBy($uidForeign_field);
|
|
// @todo: It would be more safe adding an order-by fieldname if field exists (MM_oppositeUsage set) to avoid
|
|
// arbitrary sorting if 2 mm rows to 2 different fields have same sorting and sorting_foreign values.
|
|
$statement = $queryBuilder->executeQuery();
|
|
$itemArray = [];
|
|
while ($row = $statement->fetchAssociative()) {
|
|
// Default
|
|
if (!$this->MM_is_foreign) {
|
|
// If tablenames columns exists and contain a name, then this value is the table, else it's the firstTable...
|
|
$theTable = !empty($row['tablenames']) ? $row['tablenames'] : $this->firstTable;
|
|
}
|
|
if (($row[$uidForeign_field] || $theTable === 'pages') && $theTable && isset($this->tableArray[$theTable])) {
|
|
$item = [
|
|
'id' => $row[$uidForeign_field],
|
|
'table' => $theTable,
|
|
];
|
|
if (!empty($row['fieldname'])) {
|
|
$item['fieldname'] = $row['fieldname'];
|
|
}
|
|
if (isset($row[$sorting_field])) {
|
|
$item[$sorting_field] = $row[$sorting_field];
|
|
}
|
|
if (isset($row[$sortingForeign_field])) {
|
|
$item[$sortingForeign_field] = $row[$sortingForeign_field];
|
|
}
|
|
$itemArray[] = $item;
|
|
$this->tableArray[$theTable][] = $row[$uidForeign_field];
|
|
}
|
|
}
|
|
$this->itemArray = $itemArray;
|
|
}
|
|
|
|
/**
|
|
* Writes the internal itemArray to MM table:
|
|
*
|
|
* @param string $MM_tableName MM table name
|
|
* @param int $uid Local UID
|
|
* @param bool $prependTableName If set, then table names will always be written.
|
|
*/
|
|
public function writeMM($MM_tableName, $uid, $prependTableName = false)
|
|
{
|
|
$connection = $this->getConnectionForTableName($MM_tableName);
|
|
$expressionBuilder = $connection->createQueryBuilder()->expr();
|
|
|
|
// In case of a reverse relation
|
|
if ($this->MM_is_foreign) {
|
|
$uidLocal_field = 'uid_foreign';
|
|
$uidForeign_field = 'uid_local';
|
|
$sorting_field = 'sorting_foreign';
|
|
} else {
|
|
// default
|
|
$uidLocal_field = 'uid_local';
|
|
$uidForeign_field = 'uid_foreign';
|
|
$sorting_field = 'sorting';
|
|
}
|
|
// If there are tables...
|
|
$tableC = count($this->tableArray);
|
|
if ($tableC) {
|
|
// Boolean: does the field "tablename" need to be filled?
|
|
$prep = $tableC > 1 || $prependTableName || $this->MM_isMultiTableRelationship;
|
|
$c = 0;
|
|
$additionalWhere_tablenames = '';
|
|
if ($this->MM_is_foreign && $prep) {
|
|
$additionalWhere_tablenames = $expressionBuilder->eq(
|
|
'tablenames',
|
|
$expressionBuilder->literal($this->currentTable)
|
|
);
|
|
}
|
|
$additionalWhere = $expressionBuilder->and();
|
|
// Add WHERE clause if configured
|
|
if ($this->MM_table_where) {
|
|
$additionalWhere = $additionalWhere->with(
|
|
QueryHelper::stripLogicalOperatorPrefix(
|
|
str_replace('###THIS_UID###', (string)$uid, $this->MM_table_where)
|
|
)
|
|
);
|
|
}
|
|
// Select, update or delete only those relations that match the configured fields
|
|
foreach ($this->MM_match_fields as $field => $value) {
|
|
$additionalWhere = $additionalWhere->with($expressionBuilder->eq($field, $expressionBuilder->literal((string)$value)));
|
|
}
|
|
|
|
$queryBuilder = $connection->createQueryBuilder();
|
|
$queryBuilder->getRestrictions()->removeAll();
|
|
$queryBuilder->select($uidForeign_field)
|
|
->from($MM_tableName)
|
|
->where($queryBuilder->expr()->eq(
|
|
$uidLocal_field,
|
|
$queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)
|
|
))
|
|
->orderBy($sorting_field);
|
|
|
|
if ($prep) {
|
|
$queryBuilder->addSelect('tablenames');
|
|
}
|
|
if ($this->multiple) {
|
|
$queryBuilder->addSelect('uid');
|
|
}
|
|
if ($additionalWhere_tablenames) {
|
|
$queryBuilder->andWhere($additionalWhere_tablenames);
|
|
}
|
|
if ($additionalWhere->count()) {
|
|
$queryBuilder->andWhere($additionalWhere);
|
|
}
|
|
|
|
$result = $queryBuilder->executeQuery();
|
|
$oldMMs = [];
|
|
// This array is similar to $oldMMs but also holds the uid of the MM-records if 'multiple' is true.
|
|
// If the UID is present it will be used to update sorting and delete MM-records.
|
|
// $oldMMs is still needed for the in_array() search used to look if an item from $this->itemArray is in $oldMMs.
|
|
$oldMMs_inclUid = [];
|
|
while ($row = $result->fetchAssociative()) {
|
|
if (!$this->MM_is_foreign && $prep) {
|
|
$oldMMs[] = [$row['tablenames'], $row[$uidForeign_field]];
|
|
} else {
|
|
$oldMMs[] = $row[$uidForeign_field];
|
|
}
|
|
$oldMMs_inclUid[] = (int)($row['uid'] ?? 0);
|
|
}
|
|
// For each item, insert it:
|
|
foreach ($this->itemArray as $val) {
|
|
$c++;
|
|
if ($prep || $val['table'] === '_NO_TABLE') {
|
|
// Insert current table if needed
|
|
if ($this->MM_is_foreign) {
|
|
$tablename = $this->currentTable;
|
|
} else {
|
|
$tablename = $val['table'];
|
|
}
|
|
} else {
|
|
$tablename = '';
|
|
}
|
|
if (!$this->MM_is_foreign && $prep) {
|
|
$item = [$val['table'], $val['id']];
|
|
} else {
|
|
$item = $val['id'];
|
|
}
|
|
if (in_array($item, $oldMMs)) {
|
|
$oldMMs_index = array_search($item, $oldMMs);
|
|
// In principle, selecting on the UID is all we need to do
|
|
// if a uid field is available since that is unique!
|
|
// But as long as it "doesn't hurt" we just add it to the where clause. It should all match up.
|
|
$queryBuilder = $connection->createQueryBuilder();
|
|
$queryBuilder->update($MM_tableName)
|
|
->set($sorting_field, $c)
|
|
->where(
|
|
$expressionBuilder->eq(
|
|
$uidLocal_field,
|
|
$queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)
|
|
),
|
|
$expressionBuilder->eq(
|
|
$uidForeign_field,
|
|
$queryBuilder->createNamedParameter($val['id'], Connection::PARAM_INT)
|
|
)
|
|
);
|
|
|
|
if ($additionalWhere->count()) {
|
|
$queryBuilder->andWhere($additionalWhere);
|
|
}
|
|
if ($this->multiple) {
|
|
$queryBuilder->andWhere(
|
|
$expressionBuilder->eq(
|
|
'uid',
|
|
$queryBuilder->createNamedParameter($oldMMs_inclUid[$oldMMs_index], Connection::PARAM_INT)
|
|
)
|
|
);
|
|
}
|
|
if ($tablename) {
|
|
$queryBuilder->andWhere(
|
|
$expressionBuilder->eq(
|
|
'tablenames',
|
|
$queryBuilder->createNamedParameter($tablename)
|
|
)
|
|
);
|
|
}
|
|
|
|
$queryBuilder->executeStatement();
|
|
// Remove the item from the $oldMMs array so after this
|
|
// foreach loop only the ones that need to be deleted are in there.
|
|
unset($oldMMs[$oldMMs_index]);
|
|
// Remove the item from the $oldMMs_inclUid array so after this
|
|
// foreach loop only the ones that need to be deleted are in there.
|
|
unset($oldMMs_inclUid[$oldMMs_index]);
|
|
} else {
|
|
$insertFields = $this->MM_match_fields;
|
|
$insertFields[$uidLocal_field] = $uid;
|
|
$insertFields[$uidForeign_field] = $val['id'];
|
|
$insertFields[$sorting_field] = $c;
|
|
if ($tablename) {
|
|
$insertFields['tablenames'] = $tablename;
|
|
$insertFields = $this->completeOppositeUsageValues($tablename, $insertFields);
|
|
}
|
|
$connection->insert($MM_tableName, $insertFields);
|
|
if ($this->MM_is_foreign) {
|
|
$this->referenceIndexUpdater?->registerForUpdate($val['table'], (int)$val['id'], $this->getWorkspaceId());
|
|
}
|
|
}
|
|
}
|
|
// Delete all not-used relations:
|
|
if ($oldMMs !== []) {
|
|
$queryBuilder = $connection->createQueryBuilder();
|
|
$removeClauses = $queryBuilder->expr()->or();
|
|
foreach ($oldMMs as $oldMM_key => $mmItem) {
|
|
// If UID field is present, of course we need only use that for deleting.
|
|
if ($this->multiple) {
|
|
$removeClauses = $removeClauses->with($queryBuilder->expr()->eq(
|
|
'uid',
|
|
$queryBuilder->createNamedParameter($oldMMs_inclUid[$oldMM_key], Connection::PARAM_INT)
|
|
));
|
|
} else {
|
|
if (is_array($mmItem)) {
|
|
$removeClauses = $removeClauses->with(
|
|
$queryBuilder->expr()->and(
|
|
$queryBuilder->expr()->eq(
|
|
'tablenames',
|
|
$queryBuilder->createNamedParameter($mmItem[0])
|
|
),
|
|
$queryBuilder->expr()->eq(
|
|
$uidForeign_field,
|
|
$queryBuilder->createNamedParameter($mmItem[1], Connection::PARAM_INT)
|
|
)
|
|
)
|
|
);
|
|
} else {
|
|
$removeClauses = $removeClauses->with(
|
|
$queryBuilder->expr()->eq(
|
|
$uidForeign_field,
|
|
$queryBuilder->createNamedParameter($mmItem, Connection::PARAM_INT)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
if ($this->MM_is_foreign) {
|
|
if (is_array($mmItem)) {
|
|
$this->referenceIndexUpdater?->registerForUpdate((string)$mmItem[0], (int)$mmItem[1], $this->getWorkspaceId());
|
|
} else {
|
|
$this->referenceIndexUpdater?->registerForUpdate($this->firstTable, (int)$mmItem, $this->getWorkspaceId());
|
|
}
|
|
}
|
|
}
|
|
|
|
$queryBuilder->delete($MM_tableName)
|
|
->where(
|
|
$queryBuilder->expr()->eq(
|
|
$uidLocal_field,
|
|
$queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)
|
|
),
|
|
$removeClauses
|
|
);
|
|
|
|
if ($additionalWhere_tablenames) {
|
|
$queryBuilder->andWhere($additionalWhere_tablenames);
|
|
}
|
|
if ($additionalWhere->count()) {
|
|
$queryBuilder->andWhere($additionalWhere);
|
|
}
|
|
|
|
$queryBuilder->executeStatement();
|
|
}
|
|
// Update ref index; In DataHandler it is not certain that this will happen because
|
|
// if only the MM field is changed the record itself is not updated and so the ref-index is not either.
|
|
// This could also have been fixed in updateDB in DataHandler, however I decided to do it here ...
|
|
$this->referenceIndexUpdater?->registerForUpdate($this->currentTable, (int)$uid, $this->getWorkspaceId());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reads items from a foreign_table, that has a foreign_field (uid of the parent record) and
|
|
* stores the parts in the internal array itemArray and tableArray.
|
|
*
|
|
* @param int $uid The uid of the parent record (this value is also on the foreign_table in the foreign_field)
|
|
* @param array $conf TCA configuration for current field
|
|
*/
|
|
protected function readForeignField(int $uid, array $conf): void
|
|
{
|
|
if ($this->useLiveParentIds) {
|
|
$uid = $this->getLiveDefaultId($this->currentTable, $uid);
|
|
}
|
|
|
|
if ($uid === 0) {
|
|
// Skip further processing if uid does not point to a valid parent record
|
|
return;
|
|
}
|
|
|
|
$foreign_table = $conf['foreign_table'];
|
|
$foreign_table_field = $conf['foreign_table_field'] ?? '';
|
|
$useDeleteClause = !$this->undeleteRecord;
|
|
$foreign_match_fields = is_array($conf['foreign_match_fields'] ?? false) ? $conf['foreign_match_fields'] : [];
|
|
$queryBuilder = $this->getConnectionForTableName($foreign_table)->createQueryBuilder();
|
|
$queryBuilder->getRestrictions()->removeAll();
|
|
$queryBuilder->select('uid')->from($foreign_table);
|
|
// Use the deleteClause (e.g. "deleted=0") on this table
|
|
if ($useDeleteClause) {
|
|
$queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(DeletedRestriction::class));
|
|
}
|
|
if (!$this->tcaSchemaFactory->has($foreign_table)) {
|
|
return;
|
|
}
|
|
$schema = $this->tcaSchemaFactory->get($foreign_table);
|
|
|
|
// Search for $uid in foreign_field, and if we have symmetric relations, do this also on symmetric_field
|
|
if (!empty($conf['symmetric_field'])) {
|
|
$queryBuilder->where(
|
|
$queryBuilder->expr()->or(
|
|
$queryBuilder->expr()->eq(
|
|
$conf['foreign_field'],
|
|
$queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)
|
|
),
|
|
$queryBuilder->expr()->eq(
|
|
$conf['symmetric_field'],
|
|
$queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)
|
|
)
|
|
)
|
|
);
|
|
} else {
|
|
$queryBuilder->where($queryBuilder->expr()->eq(
|
|
$conf['foreign_field'],
|
|
$queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)
|
|
));
|
|
}
|
|
// If it's requested to look for the parent uid AND the parent table, add a where clause
|
|
if ($foreign_table_field && $this->currentTable) {
|
|
$queryBuilder->andWhere(
|
|
$queryBuilder->expr()->eq(
|
|
$foreign_table_field,
|
|
$queryBuilder->createNamedParameter($this->currentTable)
|
|
)
|
|
);
|
|
}
|
|
// Add additional where clause if foreign_match_fields are defined
|
|
foreach ($foreign_match_fields as $field => $value) {
|
|
$queryBuilder->andWhere(
|
|
$queryBuilder->expr()->eq($field, $queryBuilder->createNamedParameter($value))
|
|
);
|
|
}
|
|
// Select children from the live(!) workspace only
|
|
if ($schema->isWorkspaceAware()) {
|
|
$queryBuilder->getRestrictions()->add(
|
|
GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getWorkspaceId())
|
|
);
|
|
}
|
|
|
|
// Set sorting criteria
|
|
$sortby = '';
|
|
if (!empty($conf['foreign_sortby'])) {
|
|
// Specific manual sortby for data handled by this field
|
|
if (!empty($conf['symmetric_sortby']) && !empty($conf['symmetric_field'])) {
|
|
// Sorting depends on, from which side of the relation we're looking at it
|
|
// This requires bypassing automatic quoting and setting of the default sort direction
|
|
// @todo Doctrine - generalize to standard SQL to guarantee database independence
|
|
$queryBuilder->getConcreteQueryBuilder()->orderBy(
|
|
'CASE
|
|
WHEN ' . $queryBuilder->expr()->eq($conf['foreign_field'], $uid) . '
|
|
THEN ' . $queryBuilder->quoteIdentifier($conf['foreign_sortby']) . '
|
|
ELSE ' . $queryBuilder->quoteIdentifier($conf['symmetric_sortby']) . '
|
|
END'
|
|
);
|
|
} else {
|
|
// Regular single-side behaviour
|
|
$sortby = $conf['foreign_sortby'];
|
|
}
|
|
} elseif (!empty($conf['foreign_default_sortby'])) {
|
|
// Specific default sortby for data handled by this field
|
|
$sortby = $conf['foreign_default_sortby'];
|
|
} elseif ($schema->hasCapability(TcaSchemaCapability::SortByField)) {
|
|
// Manual sortby for all table records
|
|
$sortby = $schema->getCapability(TcaSchemaCapability::SortByField)->getFieldName();
|
|
} elseif ($schema->hasCapability(TcaSchemaCapability::DefaultSorting)) {
|
|
// Default sortby for all table records
|
|
$sortby = (string)$schema->getCapability(TcaSchemaCapability::DefaultSorting)->getValue();
|
|
}
|
|
if (!empty($sortby)) {
|
|
foreach (QueryHelper::parseOrderBy($sortby) as $orderPair) {
|
|
[$fieldName, $sorting] = $orderPair;
|
|
$queryBuilder->addOrderBy($fieldName, $sorting);
|
|
}
|
|
}
|
|
|
|
$rows = [];
|
|
$result = $queryBuilder->executeQuery();
|
|
while ($row = $result->fetchAssociative()) {
|
|
$rows[(int)$row['uid']] = $row;
|
|
}
|
|
if (!empty($rows)) {
|
|
$sortby = $queryBuilder->getOrderBy();
|
|
$ids = $this->getResolver($foreign_table, array_keys($rows), $sortby)->get();
|
|
$itemArray = [];
|
|
foreach ($ids as $id) {
|
|
$item = [
|
|
'id' => $id,
|
|
'table' => $foreign_table,
|
|
];
|
|
$itemArray[] = $item;
|
|
$this->tableArray[$foreign_table][] = $id;
|
|
}
|
|
$this->itemArray = $itemArray;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write the sorting values to a foreign_table, that has a foreign_field (uid of the parent record)
|
|
*
|
|
* @param array $conf TCA configuration for current field
|
|
* @param int $parentUid The uid of the parent record
|
|
* @param int $updateToUid If this is larger than zero it will be used as foreign UID instead of the given $parentUid (on Copy)
|
|
*/
|
|
public function writeForeignField(array $conf, $parentUid, $updateToUid = 0): void
|
|
{
|
|
if ($this->useLiveParentIds) {
|
|
$parentUid = $this->getLiveDefaultId($this->currentTable, $parentUid);
|
|
if (!empty($updateToUid)) {
|
|
$updateToUid = $this->getLiveDefaultId($this->currentTable, $updateToUid);
|
|
}
|
|
}
|
|
|
|
// Ensure all values are set.
|
|
$conf += [
|
|
'foreign_table' => '',
|
|
'foreign_field' => '',
|
|
'symmetric_field' => '',
|
|
'foreign_table_field' => '',
|
|
'foreign_match_fields' => [],
|
|
];
|
|
|
|
$c = 0;
|
|
$foreign_table = $conf['foreign_table'];
|
|
$foreign_field = $conf['foreign_field'];
|
|
$symmetric_field = $conf['symmetric_field'] ?? '';
|
|
$foreign_table_field = $conf['foreign_table_field'];
|
|
$foreign_match_fields = $conf['foreign_match_fields'];
|
|
if (!$this->tcaSchemaFactory->has($foreign_table)) {
|
|
return;
|
|
}
|
|
$schema = $this->tcaSchemaFactory->get($foreign_table);
|
|
// If there are table items and we have a proper $parentUid
|
|
if (MathUtility::canBeInterpretedAsInteger($parentUid) && !empty($this->tableArray)) {
|
|
// If updateToUid is not a positive integer, set it to '0', so it will be ignored
|
|
if (!(MathUtility::canBeInterpretedAsInteger($updateToUid) && $updateToUid > 0)) {
|
|
$updateToUid = 0;
|
|
}
|
|
$fields = ['uid', 'pid', $foreign_field];
|
|
// Consider the symmetric field if defined:
|
|
if ($symmetric_field) {
|
|
$fields[] = $symmetric_field;
|
|
}
|
|
// Consider workspaces if defined and currently used:
|
|
if ($schema->isWorkspaceAware()) {
|
|
$fields = [...$fields, 't3ver_wsid', 't3ver_state', 't3ver_oid'];
|
|
}
|
|
// Update all items
|
|
foreach ($this->itemArray as $val) {
|
|
$uid = $val['id'];
|
|
$table = $val['table'];
|
|
$row = [];
|
|
// Fetch the current (not overwritten) relation record if we should handle symmetric relations
|
|
if ($symmetric_field || $schema->isWorkspaceAware()) {
|
|
$row = BackendUtility::getRecord($table, $uid, $fields, '', true);
|
|
if (empty($row)) {
|
|
continue;
|
|
}
|
|
}
|
|
$isOnSymmetricSide = false;
|
|
if ($symmetric_field) {
|
|
$isOnSymmetricSide = $this->isOnSymmetricSide((string)$parentUid, $conf, $row);
|
|
}
|
|
$updateValues = $foreign_match_fields;
|
|
// No update to the uid is requested, so this is the normal behaviour
|
|
// just update the fields and care about sorting
|
|
if (!$updateToUid) {
|
|
// Always add the pointer to the parent uid
|
|
if ($isOnSymmetricSide) {
|
|
$updateValues[$symmetric_field] = $parentUid;
|
|
} else {
|
|
$updateValues[$foreign_field] = $parentUid;
|
|
}
|
|
// If it is configured in TCA also to store the parent table in the child record, just do it
|
|
if ($foreign_table_field && $this->currentTable) {
|
|
$updateValues[$foreign_table_field] = $this->currentTable;
|
|
}
|
|
// Get the correct sorting field
|
|
// Specific manual sortby for data handled by this field
|
|
$sortby = '';
|
|
if ($conf['foreign_sortby'] ?? false) {
|
|
$sortby = $conf['foreign_sortby'];
|
|
} elseif ($schema->hasCapability(TcaSchemaCapability::SortByField)) {
|
|
// manual sortby for all table records
|
|
$sortby = $schema->getCapability(TcaSchemaCapability::SortByField)->getFieldName();
|
|
}
|
|
// Apply sorting on the symmetric side
|
|
// (it depends on who created the relation, so what uid is in the symmetric_field):
|
|
if ($isOnSymmetricSide && isset($conf['symmetric_sortby']) && $conf['symmetric_sortby']) {
|
|
$sortby = $conf['symmetric_sortby'];
|
|
} else {
|
|
$tempSortBy = [];
|
|
foreach (QueryHelper::parseOrderBy($sortby) as $orderPair) {
|
|
[$fieldName, $order] = $orderPair;
|
|
if ($order !== null) {
|
|
$tempSortBy[] = implode(' ', $orderPair);
|
|
} else {
|
|
$tempSortBy[] = $fieldName;
|
|
}
|
|
}
|
|
$sortby = implode(',', $tempSortBy);
|
|
}
|
|
if ($sortby) {
|
|
$updateValues[$sortby] = ++$c;
|
|
}
|
|
} else {
|
|
if ($isOnSymmetricSide) {
|
|
$updateValues[$symmetric_field] = $updateToUid;
|
|
} else {
|
|
$updateValues[$foreign_field] = $updateToUid;
|
|
}
|
|
}
|
|
// Update accordant fields in the database:
|
|
if (!empty($updateValues)) {
|
|
// Update tstamp if any foreign field value has changed
|
|
if ($schema->hasCapability(TcaSchemaCapability::UpdatedAt)) {
|
|
$updateValues[$schema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName()] = $GLOBALS['EXEC_TIME'];
|
|
}
|
|
$this->getConnectionForTableName($table)
|
|
->update(
|
|
$table,
|
|
$updateValues,
|
|
['uid' => (int)$uid]
|
|
);
|
|
$this->referenceIndexUpdater?->registerForUpdate($table, (int)$uid, $this->getWorkspaceId());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* After initialization, you can extract an array of the elements from the object. Use this function for that.
|
|
*
|
|
* @param bool $prependTableName If set, then table names will ALWAYS be prepended (unless its a _NO_TABLE value)
|
|
* @return array A numeric array.
|
|
*/
|
|
public function getValueArray(bool $prependTableName = false): array
|
|
{
|
|
$valueArray = [];
|
|
$tableC = count($this->tableArray);
|
|
// If there are tables in the table array:
|
|
if ($tableC) {
|
|
// If there are more than ONE table in the table array, then always prepend table names:
|
|
$prep = $tableC > 1 || $prependTableName;
|
|
// Traverse the array of items:
|
|
foreach ($this->itemArray as $val) {
|
|
$valueArray[] = ($prep && $val['table'] !== '_NO_TABLE' ? $val['table'] . '_' : '') . $val['id'];
|
|
}
|
|
}
|
|
// Return the array
|
|
return $valueArray;
|
|
}
|
|
|
|
/**
|
|
* Reads all records from internal tableArray into the internal ->results array
|
|
* where keys are table names and for each table, records are stored with uids as their keys.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getFromDB(): array
|
|
{
|
|
// Traverses the tables listed:
|
|
foreach ($this->tableArray as $table => $ids) {
|
|
if (is_array($ids) && !empty($ids)) {
|
|
$connection = $this->getConnectionForTableName($table);
|
|
$maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform());
|
|
|
|
foreach (array_chunk($ids, $maxBindParameters - 10, true) as $chunk) {
|
|
$queryBuilder = $connection->createQueryBuilder();
|
|
$queryBuilder->getRestrictions()->removeAll();
|
|
$queryBuilder->select('*')
|
|
->from($table)
|
|
->where($queryBuilder->expr()->in(
|
|
'uid',
|
|
$queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY)
|
|
));
|
|
if ($this->additionalWhere[$table] ?? false) {
|
|
$queryBuilder->andWhere(
|
|
QueryHelper::stripLogicalOperatorPrefix($this->additionalWhere[$table])
|
|
);
|
|
}
|
|
$statement = $queryBuilder->executeQuery();
|
|
while ($row = $statement->fetchAssociative()) {
|
|
$this->results[$table][$row['uid']] = $row;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return $this->results;
|
|
}
|
|
|
|
/**
|
|
* This method is typically called after getFromDB().
|
|
* $this->results holds a list of resolved and valid relations,
|
|
* $this->itemArray hold a list of "selected" relations from the incoming selection array.
|
|
* The difference is that "itemArray" may hold a single table/uid combination multiple times,
|
|
* for instance in a type=group relation having multiple=true, while "results" hold each
|
|
* resolved relation only once.
|
|
* The method creates a sanitized "itemArray" from resolved "results" list, normalized
|
|
* the return array to always contain both table name and uid, and keep incoming
|
|
* "itemArray" sort order and keeps "multiple" selections.
|
|
*
|
|
* In addition, the item array contains the full record to be used later-on and save database queries.
|
|
* This method keeps the ordering intact.
|
|
*/
|
|
public function getResolvedItemArray(): array
|
|
{
|
|
$itemArray = [];
|
|
foreach ($this->itemArray as $item) {
|
|
if (isset($this->results[$item['table']][$item['id']])) {
|
|
$itemArray[] = [
|
|
'table' => $item['table'],
|
|
'uid' => $item['id'],
|
|
'record' => $this->results[$item['table']][$item['id']],
|
|
];
|
|
}
|
|
}
|
|
return $itemArray;
|
|
}
|
|
|
|
/**
|
|
* Counts the items in $this->itemArray and puts this value in an array by default.
|
|
*
|
|
* @param bool $returnAsArray Whether to put the count value in an array
|
|
* @return mixed The plain count as integer or the same inside an array
|
|
*/
|
|
public function countItems(bool $returnAsArray = true)
|
|
{
|
|
$count = count($this->itemArray);
|
|
if ($returnAsArray) {
|
|
$count = [$count];
|
|
}
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Converts elements in the local item array to use version ids instead of
|
|
* live ids, if possible. The most common use case is, to call that prior
|
|
* to processing with MM relations in a workspace context. For tha special
|
|
* case, ids on both side of the MM relation must use version ids if
|
|
* available.
|
|
*
|
|
* @return bool Whether items have been converted
|
|
*/
|
|
public function convertItemArray(): bool
|
|
{
|
|
// conversion is only required in a workspace context
|
|
// (the case that version ids are submitted in a live context are rare)
|
|
if ($this->getWorkspaceId() === 0) {
|
|
return false;
|
|
}
|
|
|
|
$hasBeenConverted = false;
|
|
foreach ($this->tableArray as $tableName => $ids) {
|
|
if (empty($ids)) {
|
|
continue;
|
|
}
|
|
if (!$this->tcaSchemaFactory->has($tableName)) {
|
|
continue;
|
|
}
|
|
$schema = $this->tcaSchemaFactory->get($tableName);
|
|
if ($schema->isWorkspaceAware()) {
|
|
continue;
|
|
}
|
|
|
|
// convert live ids to version ids if available
|
|
$convertedIds = $this->getResolver($tableName, $ids)
|
|
->setKeepDeletePlaceholder(false)
|
|
->setKeepMovePlaceholder(false)
|
|
->processVersionOverlays($ids);
|
|
foreach ($this->itemArray as $index => $item) {
|
|
if ($item['table'] !== $tableName) {
|
|
continue;
|
|
}
|
|
$currentItemId = $item['id'];
|
|
if (
|
|
!isset($convertedIds[$currentItemId])
|
|
|| $currentItemId === $convertedIds[$currentItemId]
|
|
) {
|
|
continue;
|
|
}
|
|
// adjust local item to use resolved version id
|
|
$this->itemArray[$index]['id'] = $convertedIds[$currentItemId];
|
|
$hasBeenConverted = true;
|
|
}
|
|
// update per-table reference for ids
|
|
if ($hasBeenConverted) {
|
|
$this->tableArray[$tableName] = array_values($convertedIds);
|
|
}
|
|
}
|
|
|
|
return $hasBeenConverted;
|
|
}
|
|
|
|
/**
|
|
* @todo: It *should* be possible to drop all three 'purge' methods by using
|
|
* a clever join within readMM - that sounds doable now with pid -1 and
|
|
* ws-pair records being gone since v11. It would resolve this indirect
|
|
* callback logic and would reduce some queries. The (workspace) mm tests
|
|
* should be complete enough now to verify if a change like that would do.
|
|
*
|
|
* @return bool Whether items have been purged
|
|
* @internal
|
|
*/
|
|
public function purgeItemArray(?int $workspaceId = null): bool
|
|
{
|
|
if ($workspaceId === null) {
|
|
$workspaceId = $this->getWorkspaceId();
|
|
}
|
|
|
|
// Ensure, only live relations are in the items Array
|
|
if ($workspaceId === 0) {
|
|
$purgeCallback = 'purgeVersionedIds';
|
|
} else {
|
|
// Otherwise, ensure that live relations are purged if version exists
|
|
$purgeCallback = 'purgeLiveVersionedIds';
|
|
}
|
|
|
|
$itemArrayHasBeenPurged = $this->purgeItemArrayHandler($purgeCallback, $workspaceId);
|
|
$this->purged = ($this->purged || $itemArrayHasBeenPurged);
|
|
return $itemArrayHasBeenPurged;
|
|
}
|
|
|
|
/**
|
|
* Removes items having a delete placeholder from $this->itemArray
|
|
*
|
|
* @return bool Whether items have been purged
|
|
*/
|
|
public function processDeletePlaceholder(): bool
|
|
{
|
|
if (!$this->useLiveReferenceIds || $this->getWorkspaceId() === 0) {
|
|
return false;
|
|
}
|
|
|
|
return $this->purgeItemArrayHandler('purgeDeletePlaceholder', $this->getWorkspaceId());
|
|
}
|
|
|
|
/**
|
|
* Handles a purge callback on $this->itemArray
|
|
*
|
|
* @return bool Whether items have been purged
|
|
*/
|
|
protected function purgeItemArrayHandler(string $purgeCallback, int $workspaceId): bool
|
|
{
|
|
$itemArrayHasBeenPurged = false;
|
|
|
|
foreach ($this->tableArray as $itemTableName => $itemIds) {
|
|
if (empty($itemIds)
|
|
|| !$this->tcaSchemaFactory->has($itemTableName)
|
|
|| !$this->tcaSchemaFactory->get($itemTableName)->hasCapability(TcaSchemaCapability::Workspace)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
$purgedItemIds = [];
|
|
$callable = [$this, $purgeCallback];
|
|
if (is_callable($callable)) {
|
|
$purgedItemIds = $callable($itemTableName, $itemIds, $workspaceId);
|
|
}
|
|
|
|
$removedItemIds = array_diff($itemIds, $purgedItemIds);
|
|
foreach ($removedItemIds as $removedItemId) {
|
|
$this->removeFromItemArray($itemTableName, $removedItemId);
|
|
}
|
|
$this->tableArray[$itemTableName] = $purgedItemIds;
|
|
if (!empty($removedItemIds)) {
|
|
$itemArrayHasBeenPurged = true;
|
|
}
|
|
}
|
|
|
|
return $itemArrayHasBeenPurged;
|
|
}
|
|
|
|
/**
|
|
* Purges ids that are versioned.
|
|
*/
|
|
protected function purgeVersionedIds(string $tableName, array $ids): array
|
|
{
|
|
$ids = $this->sanitizeIds($ids);
|
|
$ids = array_combine($ids, $ids);
|
|
$connection = $this->getConnectionForTableName($tableName);
|
|
$maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform());
|
|
|
|
foreach (array_chunk($ids, $maxBindParameters - 10, true) as $chunk) {
|
|
$queryBuilder = $connection->createQueryBuilder();
|
|
$queryBuilder->getRestrictions()->removeAll();
|
|
$result = $queryBuilder->select('uid', 't3ver_oid', 't3ver_state')
|
|
->from($tableName)
|
|
->where(
|
|
$queryBuilder->expr()->in(
|
|
'uid',
|
|
$queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY)
|
|
),
|
|
$queryBuilder->expr()->neq(
|
|
't3ver_wsid',
|
|
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
|
|
)
|
|
)
|
|
->orderBy('t3ver_state', 'DESC')
|
|
->executeQuery();
|
|
|
|
while ($version = $result->fetchAssociative()) {
|
|
$versionId = $version['uid'];
|
|
if (isset($ids[$versionId])) {
|
|
unset($ids[$versionId]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_values($ids);
|
|
}
|
|
|
|
/**
|
|
* Clean up the list of incoming MM connection candidates.
|
|
* readMM() results in a uid list that contains:
|
|
* * uids of all live MM connections
|
|
* * uids of workspace connections of all workspaces
|
|
* The method filters this candidate list:
|
|
* * Remove candidates of different workspaces
|
|
* * Remove live candidates that do have a workspace overlay
|
|
*
|
|
* @todo: It should be possible to merge this method into main query of readMM()
|
|
* directly to avoid the chunked query. Note purgeVersionedIds() does a
|
|
* similar thing when requesting live, to throw away workspace connections.
|
|
* @todo: It would be possible to filter delete placeholder rows here as well,
|
|
* but this needs bigger refactoring of the class, since purgeDeletePlaceholder()
|
|
* is public and only called on demand.
|
|
*/
|
|
protected function purgeLiveVersionedIds(string $tableName, array $candidateUidList, int $targetWorkspaceUid): array
|
|
{
|
|
$candidateUidList = $this->sanitizeIds($candidateUidList);
|
|
$candidateUidList = array_combine($candidateUidList, $candidateUidList);
|
|
$connection = $this->getConnectionForTableName($tableName);
|
|
$maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform());
|
|
|
|
foreach (array_chunk($candidateUidList, $maxBindParameters - 10, true) as $chunk) {
|
|
$queryBuilder = $connection->createQueryBuilder();
|
|
$queryBuilder->getRestrictions()->removeAll();
|
|
$result = $queryBuilder->select('uid', 't3ver_oid', 't3ver_state', 't3ver_wsid')
|
|
->from($tableName)
|
|
->where(
|
|
$queryBuilder->expr()->in(
|
|
'uid',
|
|
$queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY)
|
|
),
|
|
$queryBuilder->expr()->neq(
|
|
't3ver_wsid',
|
|
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
|
|
)
|
|
)
|
|
->orderBy('t3ver_state', 'DESC')
|
|
->executeQuery();
|
|
while ($workspaceRow = $result->fetchAssociative()) {
|
|
$rowVersionUid = (int)$workspaceRow['uid'];
|
|
$rowLiveUid = (int)$workspaceRow['t3ver_oid'];
|
|
$rowWorkspaceUid = (int)$workspaceRow['t3ver_wsid'];
|
|
if ($rowWorkspaceUid !== $targetWorkspaceUid) {
|
|
// If this row t3ver_wsid does not match requested workspace,
|
|
// the row is a row of a different workspace and has to be
|
|
// removed from result set.
|
|
unset($candidateUidList[$rowVersionUid]);
|
|
continue;
|
|
}
|
|
if (isset($candidateUidList[$rowLiveUid]) && isset($candidateUidList[$rowVersionUid])) {
|
|
// This is a workspace row that overlays a live candidate,
|
|
// so live needs to be removed from the candidate list.
|
|
unset($candidateUidList[$rowLiveUid]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_values($candidateUidList);
|
|
}
|
|
|
|
/**
|
|
* Purges ids that have a delete placeholder
|
|
*/
|
|
protected function purgeDeletePlaceholder(string $tableName, array $ids): array
|
|
{
|
|
$ids = $this->sanitizeIds($ids);
|
|
$ids = array_combine($ids, $ids) ?: [];
|
|
$connection = $this->getConnectionForTableName($tableName);
|
|
$maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform());
|
|
|
|
foreach (array_chunk($ids, $maxBindParameters - 10, true) as $chunk) {
|
|
$queryBuilder = $connection->createQueryBuilder();
|
|
$queryBuilder->getRestrictions()->removeAll();
|
|
$result = $queryBuilder->select('uid', 't3ver_oid', 't3ver_state')
|
|
->from($tableName)
|
|
->where(
|
|
$queryBuilder->expr()->in(
|
|
't3ver_oid',
|
|
$queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY)
|
|
),
|
|
$queryBuilder->expr()->eq(
|
|
't3ver_wsid',
|
|
$queryBuilder->createNamedParameter(
|
|
$this->getWorkspaceId(),
|
|
Connection::PARAM_INT
|
|
)
|
|
),
|
|
$queryBuilder->expr()->eq(
|
|
't3ver_state',
|
|
$queryBuilder->createNamedParameter(
|
|
VersionState::DELETE_PLACEHOLDER->value,
|
|
Connection::PARAM_INT
|
|
)
|
|
)
|
|
)
|
|
->executeQuery();
|
|
|
|
while ($version = $result->fetchAssociative()) {
|
|
$liveId = $version['t3ver_oid'];
|
|
if (isset($ids[$liveId])) {
|
|
unset($ids[$liveId]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_values($ids);
|
|
}
|
|
|
|
protected function removeFromItemArray(string $tableName, $id): bool
|
|
{
|
|
foreach ($this->itemArray as $index => $item) {
|
|
if ($item['table'] === $tableName && (string)$item['id'] === (string)$id) {
|
|
unset($this->itemArray[$index]);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Checks, if we're looking from the "other" side, the symmetric side, to a symmetric relation.
|
|
*
|
|
* @param string $parentUid The uid of the parent record
|
|
* @param array $parentConf The TCA configuration of the parent field embedding the child records
|
|
* @param array $childRec The record row of the child record
|
|
* @return bool Returns TRUE if looking from the symmetric ("other") side to the relation.
|
|
*/
|
|
protected function isOnSymmetricSide(string $parentUid, array $parentConf, array $childRec): bool
|
|
{
|
|
return MathUtility::canBeInterpretedAsInteger($childRec['uid'])
|
|
&& $parentConf['symmetric_field']
|
|
&& $parentUid == $childRec[$parentConf['symmetric_field']];
|
|
}
|
|
|
|
/**
|
|
* Completes MM values to be written by values from the opposite relation.
|
|
* This method used MM insert field or MM match fields if defined.
|
|
*
|
|
* @param string $tableName Name of the opposite table
|
|
* @param array $referenceValues Values to be written
|
|
* @return array Values to be written, possibly modified
|
|
*/
|
|
protected function completeOppositeUsageValues(string $tableName, array $referenceValues): array
|
|
{
|
|
if (empty($this->MM_oppositeUsage[$tableName]) || count($this->MM_oppositeUsage[$tableName]) > 1) {
|
|
// @todo: count($this->MM_oppositeUsage[$tableName]) > 1 is buggy.
|
|
// Scenario: Suppose a foreign table has two (!) fields that link to a sys_category. Relations can
|
|
// then be correctly set for both fields when editing the foreign records. But when editing a sys_category
|
|
// record (local side) and adding a relation to a table that has two category relation fields, the 'fieldname'
|
|
// entry in mm-table can not be decided and ends up empty. Neither of the foreign table fields then recognize
|
|
// the relation as being set.
|
|
// One simple solution is to either simply pick the *first* field, or set *both* relations, but this
|
|
// is a) guesswork and b) it may be that in practice only *one* field is actually shown due to record
|
|
// types "showitem".
|
|
// Brain melt increases with tt_content field 'selected_category' in combination with
|
|
// 'category_field' for record types 'menu_categorized_pages' and 'menu_categorized_content' next
|
|
// to casual 'categories' field. However, 'selected_category' is a 'oneToMany' and not a 'manyToMany'.
|
|
// Hard nut ...
|
|
return $referenceValues;
|
|
}
|
|
|
|
$fieldName = $this->MM_oppositeUsage[$tableName][0];
|
|
$schema = $this->tcaSchemaFactory->get($tableName);
|
|
if (!$schema->hasField($fieldName)) {
|
|
return $referenceValues;
|
|
}
|
|
|
|
$configuration = $schema->getField($fieldName)->getConfiguration();
|
|
if (!empty($configuration['MM_match_fields'])) {
|
|
// @todo: In the end, MM_match_fields does not make sense. The 'tablename' and 'fieldname' restriction
|
|
// in addition to uid_local and uid_foreign used when multiple 'foreign' tables and/or multiple fields
|
|
// of one table refer to a single 'local' table having an mm table with these four fields, is already
|
|
// clear when looking at 'MM_oppositeUsage' of the local table. 'MM_match_fields' should thus probably
|
|
// fall altogether. The only information carried here are the field names of 'tablename' and 'fieldname'
|
|
// within the mm table itself, which we should hard code. This is partially assumed in DefaultTcaSchema
|
|
// already.
|
|
$referenceValues = array_merge($configuration['MM_match_fields'], $referenceValues);
|
|
}
|
|
|
|
return $referenceValues;
|
|
}
|
|
|
|
/**
|
|
* Gets the record uid of the live default record. If already
|
|
* pointing to the live record, the submitted record uid is returned.
|
|
*
|
|
* @param int|string $id
|
|
*/
|
|
protected function getLiveDefaultId(string $tableName, $id): int
|
|
{
|
|
$liveDefaultId = BackendUtility::getLiveVersionIdOfRecord($tableName, $id);
|
|
if ($liveDefaultId === null) {
|
|
$liveDefaultId = $id;
|
|
}
|
|
return (int)$liveDefaultId;
|
|
}
|
|
|
|
/**
|
|
* Removes empty values (null, '0', 0, false).
|
|
*
|
|
* @param int[] $ids
|
|
*/
|
|
protected function sanitizeIds(array $ids): array
|
|
{
|
|
return array_filter($ids);
|
|
}
|
|
|
|
/**
|
|
* @param int[] $ids
|
|
*/
|
|
protected function getResolver(string $tableName, array $ids, ?array $sortingStatement = null): PlainDataResolver
|
|
{
|
|
$resolver = GeneralUtility::makeInstance(
|
|
PlainDataResolver::class,
|
|
$tableName,
|
|
$ids,
|
|
$sortingStatement
|
|
);
|
|
$resolver->setWorkspaceId($this->getWorkspaceId());
|
|
$resolver->setKeepDeletePlaceholder(true);
|
|
$resolver->setKeepLiveIds($this->useLiveReferenceIds);
|
|
return $resolver;
|
|
}
|
|
|
|
protected function getConnectionForTableName(string $tableName): Connection
|
|
{
|
|
return GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($tableName);
|
|
}
|
|
}
|