Files

978 lines
42 KiB
PHP

<?php
declare(strict_types=1);
/*
* 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\Extbase\Persistence\Generic\Mapper;
use Doctrine\Instantiator\InstantiatorInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\LanguageAspect;
use TYPO3\CMS\Core\Country\Country;
use TYPO3\CMS\Core\Country\CountryProvider;
use TYPO3\CMS\Core\Database\Query\QueryHelper;
use TYPO3\CMS\Core\Database\RelationHandler;
use TYPO3\CMS\Core\DataHandling\TableColumnType;
use TYPO3\CMS\Core\Domain\DateTimeFactory;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\DomainObject\AbstractDomainObject;
use TYPO3\CMS\Extbase\DomainObject\DomainObjectInterface;
use TYPO3\CMS\Extbase\Event\Persistence\AfterObjectThawedEvent;
use TYPO3\CMS\Extbase\Persistence;
use TYPO3\CMS\Extbase\Persistence\Generic\Exception;
use TYPO3\CMS\Extbase\Persistence\Generic\Exception\InvalidClassException;
use TYPO3\CMS\Extbase\Persistence\Generic\Exception\UnexpectedTypeException;
use TYPO3\CMS\Extbase\Persistence\Generic\LazyLoadingProxy;
use TYPO3\CMS\Extbase\Persistence\Generic\LazyObjectStorage;
use TYPO3\CMS\Extbase\Persistence\Generic\LoadingStrategyInterface;
use TYPO3\CMS\Extbase\Persistence\Generic\Mapper\ColumnMap\Relation;
use TYPO3\CMS\Extbase\Persistence\Generic\Mapper\Exception\NonExistentPropertyException;
use TYPO3\CMS\Extbase\Persistence\Generic\Mapper\Exception\UnknownPropertyTypeException;
use TYPO3\CMS\Extbase\Persistence\Generic\Qom\JoinInterface;
use TYPO3\CMS\Extbase\Persistence\Generic\Qom\QueryObjectModelFactory;
use TYPO3\CMS\Extbase\Persistence\Generic\Qom\SourceInterface;
use TYPO3\CMS\Extbase\Persistence\Generic\Query;
use TYPO3\CMS\Extbase\Persistence\Generic\QueryFactoryInterface;
use TYPO3\CMS\Extbase\Persistence\Generic\Session;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
use TYPO3\CMS\Extbase\Persistence\QueryInterface;
use TYPO3\CMS\Extbase\Persistence\QueryResultInterface;
use TYPO3\CMS\Extbase\Reflection\ClassSchema\Exception\NoPropertyTypesException;
use TYPO3\CMS\Extbase\Reflection\ClassSchema\Exception\NoSuchPropertyException;
use TYPO3\CMS\Extbase\Reflection\ClassSchema\Property;
use TYPO3\CMS\Extbase\Reflection\ReflectionService;
use TYPO3\CMS\Extbase\Utility\TypeHandlingUtility;
/**
* A mapper to map database tables configured in $TCA on domain objects.
* @internal only to be used within Extbase, not part of TYPO3 Core API.
*/
#[Autoconfigure(public: true, shared: false)]
class DataMapper
{
/**
* @var QueryInterface|null
*/
protected $query;
public function __construct(
private readonly ReflectionService $reflectionService,
private readonly QueryObjectModelFactory $qomFactory,
private readonly Session $persistenceSession,
private readonly DataMapFactory $dataMapFactory,
private readonly QueryFactoryInterface $queryFactory,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly InstantiatorInterface $instantiator,
private readonly TcaSchemaFactory $tcaSchemaFactory,
private readonly CountryProvider $countryProvider,
) {}
public function setQuery(QueryInterface $query): void
{
$this->query = $query;
}
/**
* Maps the given rows on objects
*
* @param string $className The name of the class
* @param array $rows An array of arrays with field_name => value pairs
* @return array An array of objects of the given class
* @template T of DomainObjectInterface
* @phpstan-param class-string<T> $className
* @phpstan-return list<T>
*/
public function map($className, array $rows)
{
$objects = [];
foreach ($rows as $row) {
$objects[] = $this->mapSingleRow($this->getTargetType($className, $row), $row);
}
return $objects;
}
/**
* Returns the target type for the given row.
*
* @param string $className The name of the class
* @param array $row A single array with field_name => value pairs
* @return string The target type (a class name)
* @phpstan-param class-string $className
* @phpstan-return class-string
*/
public function getTargetType($className, array $row)
{
$dataMap = $this->getDataMap($className);
$targetType = $className;
if ($dataMap->recordTypeColumnName !== null) {
foreach ($dataMap->subclasses as $subclassName) {
$recordSubtype = $this->getDataMap($subclassName)->recordType;
if ((string)$row[$dataMap->recordTypeColumnName] === (string)$recordSubtype) {
$targetType = $subclassName;
break;
}
}
}
return $targetType;
}
/**
* Maps a single row on an object of the given class
*
* @param string $className The name of the target class
* @param array $row A single array with field_name => value pairs
* @return object An object of the given class
* @template T of DomainObjectInterface
* @phpstan-param class-string<T> $className
* @phpstan-return T
*/
protected function mapSingleRow($className, array $row)
{
$identifier = $this->buildIdentifier($row);
if ($this->persistenceSession->hasIdentifier($identifier, $className)) {
$object = $this->persistenceSession->getObjectByIdentifier($identifier, $className);
} else {
$object = $this->createEmptyObject($className);
$this->persistenceSession->registerObject($object, $identifier);
$this->thawProperties($object, $row);
$event = new AfterObjectThawedEvent($object, $row);
$this->eventDispatcher->dispatch($event);
$object->_memorizeCleanState();
$this->persistenceSession->registerReconstitutedEntity($object);
}
return $object;
}
/**
* Build a language-aware identifier for the identity map.
*
* The identifier includes the UID, localized UID (if present), and the
* language content identifier to ensure objects loaded with different
* language configurations are cached separately.
*
* @param array $row A single array with field_name => value pairs
* @return non-empty-string The identifier for the identity map
*/
protected function buildIdentifier(array $row): string
{
return $this->persistenceSession->buildIdentifier($row, $this->getEffectiveLanguageAspect());
}
/**
* Get the effective LanguageAspect for the current mapping context.
*
* Returns the LanguageAspect from the current query if available,
* otherwise returns a default LanguageAspect for default language.
*/
protected function getEffectiveLanguageAspect(): LanguageAspect
{
return $this->query?->getQuerySettings()->getLanguageAspect() ?? new LanguageAspect();
}
/**
* Creates a skeleton of the specified object. This is
* designed to *not* call class constructor when hydrating,
* but *do call* initializeObject() if exists and obey
* eventually registered implementation overrides ("xclass").
*
* @param class-string $className Name of the class to create a skeleton for
* @throws InvalidClassException
* @template T of DomainObjectInterface
* @phpstan-param class-string<T> $className
* @phpstan-return T
*/
protected function createEmptyObject(string $className): DomainObjectInterface
{
// Note: The class_implements() function also invokes autoload to assure that the interfaces
// and the class are loaded. Would end up with __PHP_Incomplete_Class without it.
if (!in_array(DomainObjectInterface::class, class_implements($className) ?: [])) {
throw new InvalidClassException('Cannot create empty instance of the class "' . $className
. '" because it does not implement the TYPO3\\CMS\\Extbase\\DomainObject\\DomainObjectInterface.', 1234386924);
}
// Use GU::getClassName() to obey class implementation overrides.
$object = $this->instantiator->instantiate(GeneralUtility::getClassName($className));
if (is_callable($callable = [$object, 'initializeObject'])) {
$callable();
}
return $object;
}
/**
* Sets the given properties on the object.
*
* @param DomainObjectInterface $object The object to set properties on
* @throws NonExistentPropertyException
* @throws UnknownPropertyTypeException
*/
protected function thawProperties(DomainObjectInterface $object, array $row)
{
$className = get_class($object);
$classSchema = $this->reflectionService->getClassSchema($className);
$dataMap = $this->getDataMap($className);
$object->_setProperty(AbstractDomainObject::PROPERTY_UID, (int)$row['uid']);
$object->_setProperty(AbstractDomainObject::PROPERTY_PID, (int)($row['pid'] ?? 0));
$object->_setProperty(AbstractDomainObject::PROPERTY_LOCALIZED_UID, (int)$row['uid']);
$object->_setProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID, (int)$row['uid']);
if ($dataMap->languageIdColumnName !== null) {
$object->_setProperty(AbstractDomainObject::PROPERTY_LANGUAGE_UID, (int)($row[$dataMap->languageIdColumnName] ?? 0));
if (isset($row['_LOCALIZED_UID'])) {
$object->_setProperty(AbstractDomainObject::PROPERTY_LOCALIZED_UID, (int)$row['_LOCALIZED_UID']);
}
}
if (!empty($row['_ORIG_uid']) && $this->tcaSchemaFactory->get($dataMap->tableName)->isWorkspaceAware()) {
$object->_setProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID, (int)$row['_ORIG_uid']);
}
foreach ($classSchema->getDomainObjectProperties() as $property) {
$propertyName = $property->getName();
if (!$dataMap->isPersistableProperty($propertyName)) {
continue;
}
$columnMap = $dataMap->getColumnMap($propertyName);
if ($columnMap === null) {
continue;
}
if (!isset($row[$columnMap->columnName])) {
continue;
}
$propertyValue = $row[$columnMap->columnName];
$nonProxyPropertyTypes = $property->getFilteredTypes([$property, 'filterLazyLoadingProxyAndLazyObjectStorage']);
if ($nonProxyPropertyTypes === []) {
throw new UnknownPropertyTypeException(
'The type of property ' . $className . '::' . $propertyName . ' could not be identified, therefore the desired value ('
. var_export($propertyValue, true) . ') cannot be mapped onto it. The type of a class property is usually defined via property types or php doc blocks. '
. 'Make sure the property has a property type or valid @var tag set which defines the type.',
1579965021
);
}
if (count($nonProxyPropertyTypes) > 1) {
throw new UnknownPropertyTypeException(
'The type of property ' . $className . '::' . $propertyName . ' could not be identified because the property is defined as union or intersection type, therefore the desired value ('
. var_export($propertyValue, true) . ') cannot be mapped onto it. Make sure to use only a single type.',
1660215701
);
}
$primaryType = $nonProxyPropertyTypes[0];
$propertyType = $primaryType->getBuiltinType();
$propertyClassName = $primaryType->getClassName();
$propertyValue = match ($propertyType) {
'int', 'integer' => (int)$propertyValue,
'bool', 'boolean' => (bool)$propertyValue,
'float' => (float)$propertyValue,
'string' => (string)$propertyValue,
'array' => null, // $this->mapArray($propertyValue); // Not supported, yet!
'object' => $this->thawObjectProperty($property, $columnMap, $object, $propertyName, $propertyValue, $propertyClassName),
default => null,
};
if ($propertyValue !== null || $property->isNullable()) {
$object->_setProperty($propertyName, $propertyValue);
}
}
}
/**
* @param non-empty-string $propertyName
* @param class-string|null $targetClassName
*/
private function thawObjectProperty(
Property $propertySchema,
ColumnMap $columnMap,
DomainObjectInterface $parent,
string $propertyName,
mixed $propertyValue,
?string $targetClassName
): ?object {
if ($targetClassName === null) {
return null;
}
if (is_subclass_of($targetClassName, \BackedEnum::class)) {
return $propertySchema->isNullable()
? $targetClassName::tryFrom($propertyValue)
: $targetClassName::from($propertyValue);
}
if (in_array($targetClassName, [\SplObjectStorage::class, ObjectStorage::class], true)) {
return $this->mapResultToPropertyValue(
$parent,
$propertyName,
$this->fetchRelated($parent, $propertyName, $propertyValue)
);
}
if (is_subclass_of($targetClassName, \DateTimeInterface::class)) {
return $this->mapDateTime(
$propertyValue,
$columnMap->dateTimeFormat,
$columnMap->dateTimeStorageFormat,
$columnMap->isNullable,
$targetClassName
);
}
if ($targetClassName === Country::class || is_subclass_of($targetClassName, Country::class)) {
// @todo Check if this can be abstracted in a better way (for future TCA types)
// @todo does alpha2 need to be configurable? All storage currently seems to depend on alpha2 in TCA FormEngine
return $this->countryProvider->getByAlpha2IsoCode($propertyValue);
}
if (TypeHandlingUtility::isCoreType($targetClassName)) {
return $this->mapCoreType($targetClassName, $propertyValue);
}
return $this->mapObjectToClassProperty(
$parent,
$propertyName,
$propertyValue
);
}
/**
* Map value to a core type
*
* @param string $type
* @param mixed $value
* @return \TYPO3\CMS\Core\Type\TypeInterface
*/
protected function mapCoreType($type, $value)
{
return new $type($value);
}
/**
* Creates a DateTime from a unix timestamp or date/datetime/time value.
* If the input is empty, NULL is returned.
*
* @param int|string $value Unix timestamp or date/datetime/datetimesec value or seconds for time/timesec
* @param string|null $format Output format (date/datetime/time/timesec/datetimesec)
* @param string|null $storageFormat Storage format for native date/datetime/time/datetimesec fields
* @param string $targetType The object class name to be created
* @return \DateTimeInterface|null
*/
protected function mapDateTime(
$value,
$format = null,
$storageFormat = null,
$isNullable = true,
$targetType = \DateTime::class
) {
$dateTime = DateTimeFactory::createFromDatabaseValueAndTCAConfig(
$value,
// Reconstruct TCA from our ColumnMap
[
'type' => 'datetime',
'format' => $format,
'dbType' => $storageFormat,
'nullable' => $isNullable,
]
);
return $dateTime === null ? null : match ($targetType) {
\DateTimeImmutable::class => $dateTime,
\DateTime::class => \DateTime::createFromImmutable($dateTime),
default => GeneralUtility::makeInstance($targetType, $dateTime->format('Y-m-d H:i:s.v e')),
};
}
/**
* Fetches a collection of objects related to a property of a parent object
*
* @param DomainObjectInterface $parentObject The object instance this proxy is part of
* @param string $propertyName The name of the proxied property in it's parent
* @param mixed $fieldValue The raw field value.
* @param bool $enableLazyLoading A flag indication if the related objects should be lazy loaded
* @return \TYPO3\CMS\Extbase\Persistence\Generic\LazyObjectStorage|Persistence\QueryResultInterface The result
*/
public function fetchRelated(DomainObjectInterface $parentObject, $propertyName, $fieldValue = '', $enableLazyLoading = true)
{
$property = $this->reflectionService->getClassSchema(get_class($parentObject))->getProperty($propertyName);
if ($enableLazyLoading && $property->isLazy()) {
if ($property->isObjectStorageType()) {
$result = GeneralUtility::makeInstance(LazyObjectStorage::class, $parentObject, $propertyName, $fieldValue, $this);
} elseif (empty($fieldValue)) {
$result = null;
} else {
$result = GeneralUtility::makeInstance(LazyLoadingProxy::class, $parentObject, $propertyName, $fieldValue, $this);
}
} else {
$result = $this->fetchRelatedEager($parentObject, $propertyName, $fieldValue);
}
return $result;
}
/**
* Fetches the related objects from the storage backend.
*
* @param DomainObjectInterface $parentObject The object instance this proxy is part of
* @param string $propertyName The name of the proxied property in it's parent
* @param mixed $fieldValue The raw field value.
* @return mixed
*/
protected function fetchRelatedEager(DomainObjectInterface $parentObject, $propertyName, $fieldValue = '')
{
return $fieldValue === '' ? $this->getEmptyRelationValue($parentObject, $propertyName) : $this->getNonEmptyRelationValue($parentObject, $propertyName, $fieldValue);
}
/**
* @param string $propertyName
* @return array|null
*/
protected function getEmptyRelationValue(DomainObjectInterface $parentObject, $propertyName)
{
$columnMap = $this->getDataMap(get_class($parentObject))->getColumnMap($propertyName);
$relatesToOne = $columnMap->typeOfRelation == Relation::HAS_ONE;
return $relatesToOne ? null : [];
}
/**
* @param string $propertyName
* @param string $fieldValue
* @return Persistence\QueryResultInterface
*/
protected function getNonEmptyRelationValue(DomainObjectInterface $parentObject, $propertyName, $fieldValue)
{
$query = $this->getPreparedQuery($parentObject, $propertyName, $fieldValue);
return $query->execute();
}
/**
* Builds and returns the prepared query, ready to be executed.
*
* @param string $propertyName
* @param string $fieldValue
* @return Persistence\QueryInterface
*/
protected function getPreparedQuery(DomainObjectInterface $parentObject, $propertyName, $fieldValue = '')
{
$dataMap = $this->getDataMap(get_class($parentObject));
$columnMap = $dataMap->getColumnMap($propertyName);
$type = $this->getType(get_class($parentObject), $propertyName);
$query = $this->queryFactory->create($type);
if ($this->query && $query instanceof Query) {
$query->setParentQuery($this->query);
}
$query->getQuerySettings()->setRespectStoragePage(false);
$query->getQuerySettings()->setRespectSysLanguage(false);
$languageAspect = $query->getQuerySettings()->getLanguageAspect();
$languageUid = $languageAspect->getContentId();
if ($this->query) {
$languageAspect = $this->query->getQuerySettings()->getLanguageAspect();
$languageUid = $languageAspect->getContentId();
if ($dataMap->languageIdColumnName !== null && !$this->query->getQuerySettings()->getRespectSysLanguage()) {
//pass language of parent record to child objects, so they can be overlaid correctly in case
//e.g. findByUid is used.
//the languageUid is used for getRecordOverlay later on, despite RespectSysLanguage being false
$parentLanguageUid = (int)$parentObject->_getProperty(AbstractDomainObject::PROPERTY_LANGUAGE_UID);
// do not override the language when the parent language uid is set to all languages (-1)
if ($parentLanguageUid !== -1) {
$languageUid = $parentLanguageUid;
}
}
}
// we always want to overlay relations as most of the time they are stored in db using default language uids
$languageAspect = new LanguageAspect(
$languageUid,
$languageUid,
$languageAspect->getOverlayType() === LanguageAspect::OVERLAYS_OFF ? LanguageAspect::OVERLAYS_MIXED : $languageAspect->getOverlayType(),
$languageAspect->getFallbackChain()
);
$query->getQuerySettings()->setLanguageAspect($languageAspect);
if ($columnMap->typeOfRelation === Relation::HAS_MANY) {
if (null !== $orderings = $this->getOrderingsForColumnMap($columnMap)) {
$query->setOrderings($orderings);
}
} elseif ($columnMap->typeOfRelation === Relation::HAS_AND_BELONGS_TO_MANY) {
$query->setSource($this->getSource($parentObject, $propertyName));
if ($columnMap->childSortByFieldName !== null) {
$query->setOrderings([$columnMap->childSortByFieldName => QueryInterface::ORDER_ASCENDING]);
}
}
$query->matching($this->getConstraint($query, $parentObject, $propertyName, $fieldValue, $columnMap->relationTableMatchFields));
return $query;
}
/**
* Get orderings array for extbase query by columnMap
*
* @phpstan-return array<non-empty-string, QueryInterface::ORDER_*>|null
* @return array<string, string>|null
*/
public function getOrderingsForColumnMap(ColumnMap $columnMap): ?array
{
if ($columnMap->childSortByFieldName !== null) {
return [$columnMap->childSortByFieldName => QueryInterface::ORDER_ASCENDING];
}
if ($columnMap->childTableDefaultSortings === null) {
return null;
}
$orderings = [];
$fields = QueryHelper::parseOrderBy($columnMap->childTableDefaultSortings);
foreach ($fields as $field) {
$fieldName = $field[0] ?? null;
if ($fieldName === null) {
continue;
}
if (($fieldOrdering = $field[1] ?? null) === null) {
$orderings[$fieldName] = QueryInterface::ORDER_ASCENDING;
continue;
}
$fieldOrdering = strtoupper($fieldOrdering);
if (!in_array($fieldOrdering, [QueryInterface::ORDER_ASCENDING, QueryInterface::ORDER_DESCENDING], true)) {
$orderings[$fieldName] = QueryInterface::ORDER_ASCENDING;
continue;
}
$orderings[$fieldName] = $fieldOrdering;
}
return $orderings !== [] ? $orderings : null;
}
/**
* Builds and returns the constraint for multi value properties.
*
* @param string $propertyName
* @param string $fieldValue
* @param array $relationTableMatchFields
* @return \TYPO3\CMS\Extbase\Persistence\Generic\Qom\ConstraintInterface $constraint
*/
protected function getConstraint(QueryInterface $query, DomainObjectInterface $parentObject, $propertyName, $fieldValue = '', $relationTableMatchFields = [])
{
$dataMap = $this->getDataMap(get_class($parentObject));
$columnMap = $dataMap->getColumnMap($propertyName);
$workspaceId = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('workspace', 'id');
$parentId = $this->resolveParentId($parentObject, $workspaceId, $columnMap);
if ($columnMap && $workspaceId > 0) {
$resolvedRelationIds = $this->resolveRelationValuesOfField($dataMap, $columnMap, $parentId, $fieldValue, $workspaceId);
} else {
$resolvedRelationIds = [];
}
// Work with the UIDs directly in a workspace
if (!empty($resolvedRelationIds)) {
$source = $query->getSource();
if ($source instanceof JoinInterface) {
$constraint = $query->in($source->getJoinCondition()->getProperty1Name(), $resolvedRelationIds);
// When querying MM relations directly, Typo3DbQueryParser uses enableFields and thus, filters
// out versioned records by default. However, we directly query versioned UIDs here, so we want
// to include the versioned records explicitly.
if ($columnMap->typeOfRelation === Relation::HAS_AND_BELONGS_TO_MANY) {
$query->getQuerySettings()->setEnableFieldsToBeIgnored(['pid']);
$query->getQuerySettings()->setIgnoreEnableFields(true);
}
// Also, we still need to restrict the MM on the foreign side
if ($columnMap->getParentKeyFieldName() !== null) {
$constraint = $query->logicalAnd(
$constraint,
$query->equals($columnMap->getParentKeyFieldName(), $parentId)
);
}
} else {
$constraint = $query->in('uid', $resolvedRelationIds);
}
if ($columnMap->parentTableFieldName !== null) {
$constraint = $query->logicalAnd(
$constraint,
$query->equals($columnMap->parentTableFieldName, $dataMap->tableName)
);
}
} elseif ($columnMap->parentKeyFieldName !== null) {
$value = $parentObject;
// If this a MM relation, and MM relations do not know about workspaces, the MM relations always point to the
// versioned record, so this must be taken into account here and the versioned record's UID must be used.
if ($columnMap->typeOfRelation === Relation::HAS_AND_BELONGS_TO_MANY) {
// The versioned UID is used ideally the version ID of a translated record, so this takes precedence over the localized UID
if ($value->_hasProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID) && $value->_getProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID) > 0 && $value->_getProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID) !== $value->getUid()) {
$value = (int)$value->_getProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID);
}
}
$constraint = $query->equals($columnMap->parentKeyFieldName, $value);
if ($columnMap->parentTableFieldName !== null) {
$constraint = $query->logicalAnd(
$constraint,
$query->equals($columnMap->parentTableFieldName, $dataMap->tableName)
);
}
} else {
// Note: $fieldValue is annotated as a string, but this cannot be trusted as the callers do not ensure this.
$constraint = $query->in('uid', GeneralUtility::intExplode(',', (string)$fieldValue));
}
if (!empty($relationTableMatchFields)) {
foreach ($relationTableMatchFields as $relationTableMatchFieldName => $relationTableMatchFieldValue) {
$constraint = $query->logicalAnd($constraint, $query->equals($relationTableMatchFieldName, $relationTableMatchFieldValue));
}
}
return $constraint;
}
/**
* Fetch the actual "uid" which we need to query to fetch relations to this UID.
*/
protected function resolveParentId(DomainObjectInterface $parentObject, int $workspaceId, ?ColumnMap $columnMap): ?int
{
$parentId = $parentObject->getUid();
if ($columnMap && $workspaceId > 0) {
// versionedUid in a multi-language setup is the overlaid versioned AND translated ID
if ($parentObject->_hasProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID) && $parentObject->_getProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID) > 0 && $parentObject->_getProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID) !== $parentId) {
$parentId = $parentObject->_getProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID);
} elseif ($parentObject->_hasProperty(AbstractDomainObject::PROPERTY_LANGUAGE_UID) && $parentObject->_getProperty(AbstractDomainObject::PROPERTY_LANGUAGE_UID) > 0) {
$parentId = $parentObject->_getProperty(AbstractDomainObject::PROPERTY_LOCALIZED_UID);
}
}
return $parentId;
}
/**
* This resolves relations via RelationHandler and returns their UIDs respectively, and works for MM/ForeignField/CSV in IRRE + Select + Group.
*
* Note: This only happens for resolving properties for models. When limiting a parentQuery, the Typo3DbQueryParser is taking care of it.
*
* By using the RelationHandler, the localized, deleted and moved records turn out to be properly resolved
* without having to build intermediate queries.
*
* This is currently only used in workspaces' context, as it is 1 additional DB query needed.
*
* @param DataMap $dataMap
* @param ColumnMap $columnMap
* @param int|null $parentId
* @param string $fieldValue
* @param int $workspaceId
* @return array|false|mixed
*/
protected function resolveRelationValuesOfField(DataMap $dataMap, ColumnMap $columnMap, ?int $parentId, $fieldValue, int $workspaceId)
{
$relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
$relationHandler->setWorkspaceId($workspaceId);
$relationHandler->setUseLiveReferenceIds(true);
$relationHandler->setUseLiveParentIds(true);
$tableName = $dataMap->tableName;
$fieldName = $columnMap->columnName;
if (!$this->tcaSchemaFactory->get($tableName)->hasField($fieldName)) {
return [];
}
$fieldConfiguration = $this->tcaSchemaFactory->get($tableName)->getField($fieldName)->getConfiguration();
$relationHandler->start(
$fieldValue,
$fieldConfiguration['allowed'] ?? $fieldConfiguration['foreign_table'] ?? '',
$fieldConfiguration['MM'] ?? '',
$parentId,
$tableName,
$fieldConfiguration
);
$relationHandler->processDeletePlaceholder();
$relatedUids = [];
if (!empty($relationHandler->tableArray)) {
$relatedUids = reset($relationHandler->tableArray);
}
return $relatedUids;
}
/**
* Builds and returns the source to build a join for a m:n relation.
*
* @param string $propertyName
*/
protected function getSource(DomainObjectInterface $parentObject, $propertyName): SourceInterface
{
$columnMap = $this->getDataMap(get_class($parentObject))->getColumnMap($propertyName);
$left = $this->qomFactory->selector(null, $columnMap->relationTableName);
$childClassName = $this->getType(get_class($parentObject), $propertyName);
$right = $this->qomFactory->selector($childClassName, $columnMap->childTableName);
$joinCondition = $this->qomFactory->equiJoinCondition($columnMap->relationTableName, $columnMap->childKeyFieldName, $columnMap->childTableName, 'uid');
return $this->qomFactory->join($left, $right, Query::JCR_JOIN_TYPE_INNER, $joinCondition);
}
/**
* Returns the mapped classProperty from the identityMap or
* mapResultToPropertyValue()
*
* If the field value is empty and the column map has no parent key field name,
* the relation will be empty. If the persistence session has a registered object of
* the correct type and identity (fieldValue), this function returns that object.
* Otherwise, it proceeds with mapResultToPropertyValue().
*
* @param mixed $fieldValue the raw field value
* @see mapResultToPropertyValue()
*/
protected function mapObjectToClassProperty(DomainObjectInterface $parentObject, string $propertyName, $fieldValue)
{
if ($this->propertyMapsByForeignKey($parentObject, $propertyName)) {
$result = $this->fetchRelated($parentObject, $propertyName, $fieldValue);
return $this->mapResultToPropertyValue($parentObject, $propertyName, $result);
}
if (empty($fieldValue)) {
return $this->getEmptyRelationValue($parentObject, $propertyName);
}
$primaryType = $this->reflectionService
->getClassSchema(get_class($parentObject))
->getProperty($propertyName)
->getPrimaryType();
if ($primaryType === null) {
throw NoPropertyTypesException::create($parentObject::class, $propertyName);
}
$className = $primaryType->getClassName();
if ($className === null) {
throw new \LogicException(
sprintf('Evaluated type of class property %s::%s is not a class name. Check the type declaration of the property to use a valid class name.', $parentObject::class, $propertyName),
1660217846
);
}
$identifier = $this->persistenceSession->buildIdentifier((string)$fieldValue, $this->getEffectiveLanguageAspect());
if ($this->persistenceSession->hasIdentifier($identifier, $className)) {
return $this->persistenceSession->getObjectByIdentifier($identifier, $className);
}
$result = $this->fetchRelated($parentObject, $propertyName, $fieldValue);
return $this->mapResultToPropertyValue($parentObject, $propertyName, $result);
}
/**
* Checks if the relation is based on a foreign key.
*
* @param string $propertyName
* @return bool TRUE if the property is mapped
*/
protected function propertyMapsByForeignKey(DomainObjectInterface $parentObject, $propertyName)
{
$columnMap = $this->getDataMap(get_class($parentObject))->getColumnMap($propertyName);
return $columnMap->parentKeyFieldName !== null;
}
/**
* Returns the given result as property value of the specified property type.
*
* @param string $propertyName
* @param mixed $result The result
* @return mixed
*/
public function mapResultToPropertyValue(DomainObjectInterface $parentObject, $propertyName, $result)
{
$propertyValue = null;
if ($result instanceof LoadingStrategyInterface) {
$propertyValue = $result;
} else {
$property = $this->reflectionService->getClassSchema(get_class($parentObject))->getProperty($propertyName);
$primaryType = $property->getPrimaryType();
if ($primaryType === null) {
throw NoPropertyTypesException::create($parentObject::class, $propertyName);
}
if ($primaryType->getBuiltinType() === 'array' || in_array($primaryType->getClassName(), [\ArrayObject::class, \SplObjectStorage::class, ObjectStorage::class], true)) {
$objects = [];
foreach ($result as $value) {
$objects[] = $value;
}
if ($primaryType->getClassName() === \ArrayObject::class) {
$propertyValue = new \ArrayObject($objects);
} elseif ($primaryType->getClassName() === ObjectStorage::class) {
$propertyValue = new ObjectStorage();
foreach ($objects as $object) {
$propertyValue->attach($object);
}
$propertyValue->_memorizeCleanState();
} else {
$propertyValue = $objects;
}
} elseif (strpbrk((string)$primaryType->getClassName(), '_\\') !== false) {
// @todo: check the strpbrk function call. Seems to be a check for Tx_Foo_Bar style class names
if ($result instanceof QueryResultInterface) {
$propertyValue = $result->getFirst();
} else {
$propertyValue = $result;
}
}
}
return $propertyValue;
}
/**
* Counts the number of related objects assigned to a property of a parent object
*
* @param DomainObjectInterface $parentObject The object instance this proxy is part of
* @param string $propertyName The name of the proxied property in it's parent
* @param mixed $fieldValue The raw field value.
* @return int
*/
public function countRelated(DomainObjectInterface $parentObject, $propertyName, $fieldValue = '')
{
$query = $this->getPreparedQuery($parentObject, $propertyName, $fieldValue);
return $query->execute()->count();
}
/**
* Returns a data map for a given class name
*
* @param string $className The class name you want to fetch the Data Map for
* @throws Persistence\Generic\Exception
*/
public function getDataMap($className): DataMap
{
if (!is_string($className) || $className === '') {
throw new Exception('No class name was given to retrieve the Data Map for.', 1251315965);
}
return $this->dataMapFactory->buildDataMap($className);
}
/**
* Returns the selector (table) name for a given class name.
*
* @param string $className
* @return string The selector name
*/
public function convertClassNameToTableName($className)
{
return $this->getDataMap($className)->tableName;
}
/**
* Returns the column name for a given property name of the specified class.
*
* @param string $propertyName
* @param string $className
* @return string The column name
*/
public function convertPropertyNameToColumnName($propertyName, $className = null)
{
if (!empty($className)) {
$dataMap = $this->getDataMap($className);
$columnMap = $dataMap->getColumnMap($propertyName);
if ($columnMap !== null) {
return $columnMap->columnName;
}
}
return GeneralUtility::camelCaseToLowerCaseUnderscored($propertyName);
}
/**
* Returns the type of a child object.
*
* @param string $parentClassName The class name of the object this proxy is part of
* @param string $propertyName The name of the proxied property in it's parent
* @throws UnexpectedTypeException
* @return string The class name of the child object
*/
public function getType($parentClassName, $propertyName)
{
try {
$primaryType = $this->reflectionService
->getClassSchema($parentClassName)
->getProperty($propertyName)
->getPrimaryType();
if ($primaryType === null) {
throw NoPropertyTypesException::create($parentClassName, $propertyName);
}
if ($primaryType->isCollection() && $primaryType->getCollectionValueTypes() !== []) {
$primaryCollectionValueType = $primaryType->getCollectionValueTypes()[0];
return $primaryCollectionValueType->getClassName()
?? $primaryCollectionValueType->getBuiltinType();
}
return $primaryType->getClassName()
?? $primaryType->getBuiltinType();
} catch (NoSuchPropertyException|NoPropertyTypesException $e) {
}
throw new UnexpectedTypeException('Could not determine the child object type.', 1251315967);
}
/**
* Returns a plain value, i.e. objects are flattened out if possible.
* Multi value objects or arrays will be converted to a comma-separated list for use in "IN" SQL queries.
* Caution: We do not return "null" values yet, if so, we need to adapt all places to handle null (see git history of this line)
*
* @param mixed $input The value that will be converted.
* @param ColumnMap|null $columnMap Optional column map for retrieving the date storage format.
*/
public function getPlainValue(mixed $input, ?ColumnMap $columnMap = null): int|string
{
if ($input instanceof \DateTimeInterface || ($input === null && $columnMap?->type === TableColumnType::DATETIME)) {
return QueryHelper::transformDateTimeToDatabaseValue(
$input,
$columnMap->isNullable ?? false,
$columnMap->dateTimeFormat ?? 'datetime',
$columnMap?->dateTimeStorageFormat
) ?? 'NULL';
}
if ($input === null) {
return 'NULL';
}
if ($input instanceof \BackedEnum) {
return $input->value;
}
if ($input instanceof LazyLoadingProxy) {
$input = $input->_loadRealInstance();
}
if (is_bool($input)) {
return (int)$input;
}
if (is_int($input)) {
return $input;
}
if ($input instanceof Country) {
// @todo Check if this can be abstracted in a better way (for future TCA types)
return $input->getAlpha2IsoCode();
}
if ($input instanceof DomainObjectInterface) {
return (int)$input->getUid();
}
if (TypeHandlingUtility::isValidTypeForMultiValueComparison($input)) {
$plainValueArray = [];
foreach ($input as $inputElement) {
$plainValueArray[] = $this->getPlainValue($inputElement, $columnMap);
}
return implode(',', $plainValueArray);
}
if (is_object($input)) {
if (TypeHandlingUtility::isCoreType($input) || $input instanceof \Stringable) {
return (string)$input;
}
throw new UnexpectedTypeException('An object of class "' . get_class($input) . '" could not be converted to a plain value.', 1274799934);
}
return (string)$input;
}
}