src/Core/Framework/DataAbstractionLayer/Dbal/EntityReader.php line 545
<?php declare(strict_types=1);
namespace Shopware\Core\Framework\DataAbstractionLayer\Dbal;
use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\Dbal\Exception\ParentAssociationCanNotBeFetched;
use Shopware\Core\Framework\DataAbstractionLayer\Entity;
use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection;
use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\AssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ChildrenAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Field;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\CascadeDelete;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Extension;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Inherited;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Runtime;
use Shopware\Core\Framework\DataAbstractionLayer\Field\JsonField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToManyAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ParentAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\StorageAware;
use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslatedField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Shopware\Core\Framework\DataAbstractionLayer\Read\EntityReaderInterface;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsAnyFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Parser\SqlQueryParser;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Struct\ArrayEntity;
use Shopware\Core\Framework\Struct\ArrayStruct;
use Shopware\Core\Framework\Uuid\Uuid;
use function array_filter;
/**
* @internal
*/
#[Package('core')]
class EntityReader implements EntityReaderInterface
{
final public const INTERNAL_MAPPING_STORAGE = 'internal_mapping_storage';
final public const FOREIGN_KEYS = 'foreignKeys';
final public const MANY_TO_MANY_LIMIT_QUERY = 'many_to_many_limit_query';
public function __construct(
private readonly Connection $connection,
private readonly EntityHydrator $hydrator,
private readonly EntityDefinitionQueryHelper $queryHelper,
private readonly SqlQueryParser $parser,
private readonly CriteriaQueryBuilder $criteriaQueryBuilder,
private readonly LoggerInterface $logger,
private readonly CriteriaFieldsResolver $criteriaFieldsResolver
) {
}
/**
* @return EntityCollection<Entity>
*/
public function read(EntityDefinition $definition, Criteria $criteria, Context $context): EntityCollection
{
$criteria->resetSorting();
$criteria->resetQueries();
/** @var EntityCollection<Entity> $collectionClass */
$collectionClass = $definition->getCollectionClass();
$fields = $this->criteriaFieldsResolver->resolve($criteria, $definition);
return $this->_read(
$criteria,
$definition,
$context,
new $collectionClass(),
$definition->getFields()->getBasicFields(),
true,
$fields
);
}
protected function getParser(): SqlQueryParser
{
return $this->parser;
}
/**
* @param EntityCollection<Entity> $collection
* @param array<string,mixed> $partial
*
* @return EntityCollection<Entity>
*/
private function _read(
Criteria $criteria,
EntityDefinition $definition,
Context $context,
EntityCollection $collection,
FieldCollection $fields,
bool $performEmptySearch = false,
array $partial = []
): EntityCollection {
$hasFilters = !empty($criteria->getFilters()) || !empty($criteria->getPostFilters());
$hasIds = !empty($criteria->getIds());
if (!$performEmptySearch && !$hasFilters && !$hasIds) {
return $collection;
}
if ($partial !== []) {
$fields = $definition->getFields()->filter(function (Field $field) use (&$partial) {
if ($field->getFlag(PrimaryKey::class)) {
$partial[$field->getPropertyName()] = [];
return true;
}
return isset($partial[$field->getPropertyName()]);
});
}
// always add the criteria fields to the collection, otherwise we have conflicts between criteria.fields and criteria.association logic
$fields = $this->addAssociationFieldsToCriteria($criteria, $definition, $fields);
if ($definition->isInheritanceAware() && $criteria->hasAssociation('parent')) {
throw new ParentAssociationCanNotBeFetched();
}
$rows = $this->fetch($criteria, $definition, $context, $fields, $partial);
$collection = $this->hydrator->hydrate($collection, $definition->getEntityClass(), $definition, $rows, $definition->getEntityName(), $context, $partial);
$collection = $this->fetchAssociations($criteria, $definition, $context, $collection, $fields, $partial);
$hasIds = !empty($criteria->getIds());
if ($hasIds && empty($criteria->getSorting())) {
$collection->sortByIdArray($criteria->getIds());
}
return $collection;
}
/**
* @param array<string,mixed> $partial
*/
private function joinBasic(
EntityDefinition $definition,
Context $context,
string $root,
QueryBuilder $query,
FieldCollection $fields,
?Criteria $criteria = null,
array $partial = []
): void {
$isPartial = $partial !== [];
$filtered = $fields->filter(static function (Field $field) use ($isPartial, $partial) {
if ($field->is(Runtime::class)) {
return false;
}
if (!$isPartial || $field->getFlag(PrimaryKey::class)) {
return true;
}
return isset($partial[$field->getPropertyName()]);
});
$parentAssociation = null;
if ($definition->isInheritanceAware() && $context->considerInheritance()) {
$parentAssociation = $definition->getFields()->get('parent');
if ($parentAssociation !== null) {
$this->queryHelper->resolveField($parentAssociation, $definition, $root, $query, $context);
}
}
$addTranslation = false;
/** @var Field $field */
foreach ($filtered as $field) {
//translated fields are handled after loop all together
if ($field instanceof TranslatedField) {
$this->queryHelper->resolveField($field, $definition, $root, $query, $context);
$addTranslation = true;
continue;
}
//self references can not be resolved if set to autoload, otherwise we get an endless loop
if (!$field instanceof ParentAssociationField && $field instanceof AssociationField && $field->getAutoload() && $field->getReferenceDefinition() === $definition) {
continue;
}
//many to one associations can be directly fetched in same query
if ($field instanceof ManyToOneAssociationField || $field instanceof OneToOneAssociationField) {
$reference = $field->getReferenceDefinition();
$basics = $reference->getFields()->getBasicFields();
$this->queryHelper->resolveField($field, $definition, $root, $query, $context);
$alias = $root . '.' . $field->getPropertyName();
$joinCriteria = null;
if ($criteria && $criteria->hasAssociation($field->getPropertyName())) {
$joinCriteria = $criteria->getAssociation($field->getPropertyName());
$basics = $this->addAssociationFieldsToCriteria($joinCriteria, $reference, $basics);
}
$this->joinBasic($reference, $context, $alias, $query, $basics, $joinCriteria, $partial[$field->getPropertyName()] ?? []);
continue;
}
//add sub select for many to many field
if ($field instanceof ManyToManyAssociationField) {
if ($this->isAssociationRestricted($criteria, $field->getPropertyName())) {
continue;
}
//requested a paginated, filtered or sorted list
$this->addManyToManySelect($definition, $root, $field, $query, $context);
continue;
}
//other associations like OneToManyAssociationField fetched lazy by additional query
if ($field instanceof AssociationField) {
continue;
}
if ($parentAssociation !== null
&& $field instanceof StorageAware
&& $field->is(Inherited::class)
&& $context->considerInheritance()
) {
$parentAlias = $root . '.' . $parentAssociation->getPropertyName();
//contains the field accessor for the child value (eg. `product.name`.`name`)
$childAccessor = EntityDefinitionQueryHelper::escape($root) . '.'
. EntityDefinitionQueryHelper::escape($field->getStorageName());
//contains the field accessor for the parent value (eg. `product.parent`.`name`)
$parentAccessor = EntityDefinitionQueryHelper::escape($parentAlias) . '.'
. EntityDefinitionQueryHelper::escape($field->getStorageName());
//contains the alias for the resolved field (eg. `product.name`)
$fieldAlias = EntityDefinitionQueryHelper::escape($root . '.' . $field->getPropertyName());
if ($field instanceof JsonField) {
// merged in hydrator
$parentFieldAlias = EntityDefinitionQueryHelper::escape($root . '.' . $field->getPropertyName() . '.inherited');
$query->addSelect(sprintf('%s as %s', $parentAccessor, $parentFieldAlias));
}
//add selection for resolved parent-child inheritance field
$query->addSelect(sprintf('COALESCE(%s, %s) as %s', $childAccessor, $parentAccessor, $fieldAlias));
continue;
}
//all other StorageAware fields are stored inside the main entity
if ($field instanceof StorageAware) {
$query->addSelect(
EntityDefinitionQueryHelper::escape($root) . '.'
. EntityDefinitionQueryHelper::escape($field->getStorageName()) . ' as '
. EntityDefinitionQueryHelper::escape($root . '.' . $field->getPropertyName())
);
}
}
if ($addTranslation) {
$this->queryHelper->addTranslationSelect($root, $definition, $query, $context, $partial);
}
}
/**
* @param array<string,mixed> $partial
*
* @return list<array<string,mixed>>
*/
private function fetch(Criteria $criteria, EntityDefinition $definition, Context $context, FieldCollection $fields, array $partial = []): array
{
$table = $definition->getEntityName();
$query = $this->criteriaQueryBuilder->build(
new QueryBuilder($this->connection),
$definition,
$criteria,
$context
);
$this->joinBasic($definition, $context, $table, $query, $fields, $criteria, $partial);
if (!empty($criteria->getIds())) {
$this->queryHelper->addIdCondition($criteria, $definition, $query);
}
if ($criteria->getTitle()) {
$query->setTitle($criteria->getTitle() . '::read');
}
return $query->executeQuery()->fetchAllAssociative();
}
/**
* @param EntityCollection<Entity> $collection
* @param array<string,mixed> $partial
*/
private function loadManyToMany(
Criteria $criteria,
ManyToManyAssociationField $association,
Context $context,
EntityCollection $collection,
array $partial
): void {
$associationCriteria = $criteria->getAssociation($association->getPropertyName());
if (!$associationCriteria->getTitle() && $criteria->getTitle()) {
$associationCriteria->setTitle(
$criteria->getTitle() . '::association::' . $association->getPropertyName()
);
}
//check if the requested criteria is restricted (limit, offset, sorting, filtering)
if ($this->isAssociationRestricted($criteria, $association->getPropertyName())) {
//if restricted load paginated list of many to many
$this->loadManyToManyWithCriteria($associationCriteria, $association, $context, $collection, $partial);
return;
}
//otherwise the association is loaded in the root query of the entity as sub select which contains all ids
//the ids are extracted in the entity hydrator (see: \Shopware\Core\Framework\DataAbstractionLayer\Dbal\EntityHydrator::extractManyToManyIds)
$this->loadManyToManyOverExtension($associationCriteria, $association, $context, $collection, $partial);
}
private function addManyToManySelect(
EntityDefinition $definition,
string $root,
ManyToManyAssociationField $field,
QueryBuilder $query,
Context $context
): void {
$mapping = $field->getMappingDefinition();
$versionCondition = '';
if ($mapping->isVersionAware() && $definition->isVersionAware() && $field->is(CascadeDelete::class)) {
$versionField = $definition->getEntityName() . '_version_id';
$versionCondition = ' AND #alias#.' . $versionField . ' = #root#.version_id';
}
$source = EntityDefinitionQueryHelper::escape($root) . '.' . EntityDefinitionQueryHelper::escape($field->getLocalField());
if ($field->is(Inherited::class) && $context->considerInheritance()) {
$source = EntityDefinitionQueryHelper::escape($root) . '.' . EntityDefinitionQueryHelper::escape($field->getPropertyName());
}
$parameters = [
'#alias#' => EntityDefinitionQueryHelper::escape($root . '.' . $field->getPropertyName() . '.mapping'),
'#mapping_reference_column#' => EntityDefinitionQueryHelper::escape($field->getMappingReferenceColumn()),
'#mapping_table#' => EntityDefinitionQueryHelper::escape($mapping->getEntityName()),
'#mapping_local_column#' => EntityDefinitionQueryHelper::escape($field->getMappingLocalColumn()),
'#root#' => EntityDefinitionQueryHelper::escape($root),
'#source#' => $source,
'#property#' => EntityDefinitionQueryHelper::escape($root . '.' . $field->getPropertyName() . '.id_mapping'),
];
$query->addSelect(
str_replace(
array_keys($parameters),
array_values($parameters),
'(SELECT GROUP_CONCAT(HEX(#alias#.#mapping_reference_column#) SEPARATOR \'||\')
FROM #mapping_table# #alias#
WHERE #alias#.#mapping_local_column# = #source#'
. $versionCondition
. ' ) as #property#'
)
);
}
/**
* @param EntityCollection<Entity> $collection
*
* @return array<string>
*/
private function collectManyToManyIds(EntityCollection $collection, AssociationField $association): array
{
$ids = [];
$property = $association->getPropertyName();
/** @var Entity $struct */
foreach ($collection as $struct) {
/** @var ArrayStruct<string, mixed> $ext */
$ext = $struct->getExtension(self::INTERNAL_MAPPING_STORAGE);
/** @var array<string> $tmp */
$tmp = $ext->get($property);
foreach ($tmp as $id) {
$ids[] = $id;
}
}
return $ids;
}
/**
* @param EntityCollection<Entity> $collection
* @param array<string,mixed> $partial
*/
private function loadOneToMany(
Criteria $criteria,
EntityDefinition $definition,
OneToManyAssociationField $association,
Context $context,
EntityCollection $collection,
array $partial
): void {
$fieldCriteria = new Criteria();
if ($criteria->hasAssociation($association->getPropertyName())) {
$fieldCriteria = $criteria->getAssociation($association->getPropertyName());
}
if (!$fieldCriteria->getTitle() && $criteria->getTitle()) {
$fieldCriteria->setTitle(
$criteria->getTitle() . '::association::' . $association->getPropertyName()
);
}
//association should not be paginated > load data over foreign key condition
if ($fieldCriteria->getLimit() === null) {
$this->loadOneToManyWithoutPagination($definition, $association, $context, $collection, $fieldCriteria, $partial);
return;
}
//load association paginated > use internal counter loops
$this->loadOneToManyWithPagination($definition, $association, $context, $collection, $fieldCriteria, $partial);
}
/**
* @param EntityCollection<Entity> $collection
* @param array<string,mixed> $partial
*/
private function loadOneToManyWithoutPagination(
EntityDefinition $definition,
OneToManyAssociationField $association,
Context $context,
EntityCollection $collection,
Criteria $fieldCriteria,
array $partial
): void {
$ref = $association->getReferenceDefinition()->getFields()->getByStorageName(
$association->getReferenceField()
);
\assert($ref instanceof Field);
$propertyName = $ref->getPropertyName();
if ($association instanceof ChildrenAssociationField) {
$propertyName = 'parentId';
}
//build orm property accessor to add field sortings and conditions `customer_address.customerId`
$propertyAccessor = $association->getReferenceDefinition()->getEntityName() . '.' . $propertyName;
$ids = array_values($collection->getIds());
$isInheritanceAware = $definition->isInheritanceAware() && $context->considerInheritance();
if ($isInheritanceAware) {
$parentIds = array_values(array_filter($collection->map(fn (Entity $entity) => $entity->get('parentId'))));
$ids = array_unique([...$ids, ...$parentIds]);
}
$fieldCriteria->addFilter(new EqualsAnyFilter($propertyAccessor, $ids));
$referenceClass = $association->getReferenceDefinition();
/** @var EntityCollection<Entity> $collectionClass */
$collectionClass = $referenceClass->getCollectionClass();
if ($partial !== []) {
// Make sure our collection index will be loaded
$partial[$propertyName] = [];
$collectionClass = EntityCollection::class;
}
$data = $this->_read(
$fieldCriteria,
$referenceClass,
$context,
new $collectionClass(),
$referenceClass->getFields()->getBasicFields(),
false,
$partial
);
$grouped = [];
foreach ($data as $entity) {
$fk = $entity->get($propertyName);
$grouped[$fk][] = $entity;
}
//assign loaded data to root entities
foreach ($collection as $entity) {
$structData = new $collectionClass();
if (isset($grouped[$entity->getUniqueIdentifier()])) {
$structData->fill($grouped[$entity->getUniqueIdentifier()]);
}
//assign data of child immediately
if ($association->is(Extension::class)) {
$entity->addExtension($association->getPropertyName(), $structData);
} else {
//otherwise the data will be assigned directly as properties
$entity->assign([$association->getPropertyName() => $structData]);
}
if (!$association->is(Inherited::class) || $structData->count() > 0 || !$context->considerInheritance()) {
continue;
}
//if association can be inherited by the parent and the struct data is empty, filter again for the parent id
$structData = new $collectionClass();
if (isset($grouped[$entity->get('parentId')])) {
$structData->fill($grouped[$entity->get('parentId')]);
}
if ($association->is(Extension::class)) {
$entity->addExtension($association->getPropertyName(), $structData);
continue;
}
$entity->assign([$association->getPropertyName() => $structData]);
}
}
/**
* @param EntityCollection<Entity> $collection
* @param array<string,mixed> $partial
*/
private function loadOneToManyWithPagination(
EntityDefinition $definition,
OneToManyAssociationField $association,
Context $context,
EntityCollection $collection,
Criteria $fieldCriteria,
array $partial
): void {
$isPartial = $partial !== [];
$propertyAccessor = $this->buildOneToManyPropertyAccessor($definition, $association);
// inject sorting for foreign key, otherwise the internal counter wouldn't work `order by customer_address.customer_id, other_sortings`
$sorting = array_merge(
[new FieldSorting($propertyAccessor, FieldSorting::ASCENDING)],
$fieldCriteria->getSorting()
);
$fieldCriteria->resetSorting();
$fieldCriteria->addSorting(...$sorting);
$ids = array_values($collection->getIds());
if ($isPartial) {
// Make sure our collection index will be loaded
$partial[$association->getPropertyName()] = [];
}
$isInheritanceAware = $definition->isInheritanceAware() && $context->considerInheritance();
if ($isInheritanceAware) {
$parentIds = array_values(array_filter($collection->map(fn (Entity $entity) => $entity->get('parentId'))));
$ids = array_unique([...$ids, ...$parentIds]);
}
$fieldCriteria->addFilter(new EqualsAnyFilter($propertyAccessor, $ids));
$mapping = $this->fetchPaginatedOneToManyMapping($definition, $association, $context, $collection, $fieldCriteria);
$ids = [];
foreach ($mapping as $associationIds) {
foreach ($associationIds as $associationId) {
$ids[] = $associationId;
}
}
$fieldCriteria->setIds(array_filter($ids));
$fieldCriteria->resetSorting();
$fieldCriteria->resetFilters();
$fieldCriteria->resetPostFilters();
$referenceClass = $association->getReferenceDefinition();
/** @var EntityCollection<Entity> $collectionClass */
$collectionClass = $referenceClass->getCollectionClass();
$data = $this->_read(
$fieldCriteria,
$referenceClass,
$context,
new $collectionClass(),
$referenceClass->getFields()->getBasicFields(),
false,
$partial
);
//assign loaded reference collections to root entities
/** @var Entity $entity */
foreach ($collection as $entity) {
//extract mapping ids for the current entity
$mappingIds = $mapping[$entity->getUniqueIdentifier()];
$structData = $data->getList($mappingIds);
//assign data of child immediately
if ($association->is(Extension::class)) {
$entity->addExtension($association->getPropertyName(), $structData);
} else {
$entity->assign([$association->getPropertyName() => $structData]);
}
if (!$association->is(Inherited::class) || $structData->count() > 0 || !$context->considerInheritance()) {
continue;
}
$parentId = $entity->get('parentId');
if ($parentId === null) {
continue;
}
//extract mapping ids for the current entity
$mappingIds = $mapping[$parentId];
$structData = $data->getList($mappingIds);
//assign data of child immediately
if ($association->is(Extension::class)) {
$entity->addExtension($association->getPropertyName(), $structData);
} else {
$entity->assign([$association->getPropertyName() => $structData]);
}
}
}
/**
* @param EntityCollection<Entity> $collection
* @param array<string,mixed> $partial
*/
private function loadManyToManyOverExtension(
Criteria $criteria,
ManyToManyAssociationField $association,
Context $context,
EntityCollection $collection,
array $partial
): void {
//collect all ids of many to many association which already stored inside the struct instances
$ids = $this->collectManyToManyIds($collection, $association);
$criteria->setIds($ids);
$referenceClass = $association->getToManyReferenceDefinition();
/** @var EntityCollection<Entity> $collectionClass */
$collectionClass = $referenceClass->getCollectionClass();
$data = $this->_read(
$criteria,
$referenceClass,
$context,
new $collectionClass(),
$referenceClass->getFields()->getBasicFields(),
false,
$partial
);
/** @var Entity $struct */
foreach ($collection as $struct) {
/** @var ArrayEntity $extension */
$extension = $struct->getExtension(self::INTERNAL_MAPPING_STORAGE);
//use assign function to avoid setter name building
$structData = $data->getList(
$extension->get($association->getPropertyName())
);
//if the association is added as extension (for plugins), we have to add the data as extension
if ($association->is(Extension::class)) {
$struct->addExtension($association->getPropertyName(), $structData);
} else {
$struct->assign([$association->getPropertyName() => $structData]);
}
}
}
/**
* @param EntityCollection<Entity> $collection
* @param array<string,mixed> $partial
*/
private function loadManyToManyWithCriteria(
Criteria $fieldCriteria,
ManyToManyAssociationField $association,
Context $context,
EntityCollection $collection,
array $partial
): void {
$fields = $association->getToManyReferenceDefinition()->getFields();
$reference = null;
foreach ($fields as $field) {
if (!$field instanceof ManyToManyAssociationField) {
continue;
}
if ($field->getReferenceDefinition() !== $association->getReferenceDefinition()) {
continue;
}
$reference = $field;
break;
}
if (!$reference) {
throw new \RuntimeException(
sprintf(
'No inverse many to many association found, for association %s',
$association->getPropertyName()
)
);
}
//build inverse accessor `product.categories.id`
$accessor = $association->getToManyReferenceDefinition()->getEntityName() . '.' . $reference->getPropertyName() . '.id';
$fieldCriteria->addFilter(new EqualsAnyFilter($accessor, $collection->getIds()));
$root = EntityDefinitionQueryHelper::escape(
$association->getToManyReferenceDefinition()->getEntityName() . '.' . $reference->getPropertyName() . '.mapping'
);
$query = new QueryBuilder($this->connection);
// to many selects results in a `group by` clause. In this case the order by parts will be executed with MIN/MAX aggregation
// but at this point the order by will be moved to an sub select where we don't have a group state, the `state` prevents this behavior
$query->addState(self::MANY_TO_MANY_LIMIT_QUERY);
$query = $this->criteriaQueryBuilder->build(
$query,
$association->getToManyReferenceDefinition(),
$fieldCriteria,
$context
);
$localColumn = EntityDefinitionQueryHelper::escape($association->getMappingLocalColumn());
$referenceColumn = EntityDefinitionQueryHelper::escape($association->getMappingReferenceColumn());
$orderBy = '';
$parts = $query->getQueryPart('orderBy');
if (!empty($parts)) {
$orderBy = ' ORDER BY ' . implode(', ', $parts);
$query->resetQueryPart('orderBy');
}
// order by is handled in group_concat
$fieldCriteria->resetSorting();
$query->select([
'LOWER(HEX(' . $root . '.' . $localColumn . ')) as `key`',
'GROUP_CONCAT(LOWER(HEX(' . $root . '.' . $referenceColumn . ')) ' . $orderBy . ') as `value`',
]);
$query->addGroupBy($root . '.' . $localColumn);
if ($fieldCriteria->getLimit() !== null) {
$limitQuery = $this->buildManyToManyLimitQuery($association);
$params = [
'#source_column#' => EntityDefinitionQueryHelper::escape($association->getMappingLocalColumn()),
'#reference_column#' => EntityDefinitionQueryHelper::escape($association->getMappingReferenceColumn()),
'#table#' => $root,
];
$query->innerJoin(
$root,
'(' . $limitQuery . ')',
'counter_table',
str_replace(
array_keys($params),
array_values($params),
'counter_table.#source_column# = #table#.#source_column# AND
counter_table.#reference_column# = #table#.#reference_column# AND
counter_table.id_count <= :limit'
)
);
$query->setParameter('limit', $fieldCriteria->getLimit());
$this->connection->executeQuery('SET @n = 0; SET @c = null;');
}
$mapping = $query->executeQuery()->fetchAllKeyValue();
$ids = [];
foreach ($mapping as &$row) {
$row = array_filter(explode(',', (string) $row));
foreach ($row as $id) {
$ids[] = $id;
}
}
unset($row);
$fieldCriteria->setIds($ids);
$referenceClass = $association->getToManyReferenceDefinition();
/** @var EntityCollection<Entity> $collectionClass */
$collectionClass = $referenceClass->getCollectionClass();
$data = $this->_read(
$fieldCriteria,
$referenceClass,
$context,
new $collectionClass(),
$referenceClass->getFields()->getBasicFields(),
false,
$partial
);
/** @var Entity $struct */
foreach ($collection as $struct) {
$structData = new $collectionClass();
$id = $struct->getUniqueIdentifier();
$parentId = $struct->has('parentId') ? $struct->get('parentId') : '';
if (\array_key_exists($struct->getUniqueIdentifier(), $mapping)) {
//filter mapping list of whole data array
$structData = $data->getList($mapping[$id]);
//sort list by ids if the criteria contained a sorting
$structData->sortByIdArray($mapping[$id]);
} elseif (\array_key_exists($parentId, $mapping) && $association->is(Inherited::class) && $context->considerInheritance()) {
//filter mapping for the inherited parent association
$structData = $data->getList($mapping[$parentId]);
//sort list by ids if the criteria contained a sorting
$structData->sortByIdArray($mapping[$parentId]);
}
//if the association is added as extension (for plugins), we have to add the data as extension
if ($association->is(Extension::class)) {
$struct->addExtension($association->getPropertyName(), $structData);
} else {
$struct->assign([$association->getPropertyName() => $structData]);
}
}
}
/**
* @param EntityCollection<Entity> $collection
*
* @return array<string,string[]>
*/
private function fetchPaginatedOneToManyMapping(
EntityDefinition $definition,
OneToManyAssociationField $association,
Context $context,
EntityCollection $collection,
Criteria $fieldCriteria
): array {
$sortings = $fieldCriteria->getSorting();
// Remove first entry
array_shift($sortings);
//build query based on provided association criteria (sortings, search, filter)
$query = $this->criteriaQueryBuilder->build(
new QueryBuilder($this->connection),
$association->getReferenceDefinition(),
$fieldCriteria,
$context
);
$foreignKey = $association->getReferenceField();
if (!$association->getReferenceDefinition()->getField('id')) {
throw new \RuntimeException(
sprintf(
'Paginated to many association must have an id field. No id field found for association %s.%s',
$definition->getEntityName(),
$association->getPropertyName()
)
);
}
//build sql accessor for foreign key field in reference table `customer_address.customer_id`
$sqlAccessor = EntityDefinitionQueryHelper::escape($association->getReferenceDefinition()->getEntityName()) . '.'
. EntityDefinitionQueryHelper::escape($foreignKey);
$query->select(
[
//build select with an internal counter loop, the counter loop will be reset if the foreign key changed (this is the reason for the sorting inject above)
'@n:=IF(@c=' . $sqlAccessor . ', @n+1, IF(@c:=' . $sqlAccessor . ',1,1)) as id_count',
//add select for foreign key for join condition
$sqlAccessor,
//add primary key select to group concat them
EntityDefinitionQueryHelper::escape($association->getReferenceDefinition()->getEntityName()) . '.id',
]
);
foreach ($query->getQueryPart('orderBy') as $i => $sorting) {
// The first order is the primary key
if ($i === 0) {
continue;
}
--$i;
// Strip the ASC/DESC at the end of the sort
$query->addSelect(\sprintf('%s as sort_%s', substr((string) $sorting, 0, -4), $i));
}
$root = EntityDefinitionQueryHelper::escape($definition->getEntityName());
//create a wrapper query which select the root primary key and the grouped reference ids
$wrapper = $this->connection->createQueryBuilder();
$wrapper->select(
[
'LOWER(HEX(' . $root . '.id)) as id',
'LOWER(HEX(child.id)) as child_id',
]
);
foreach ($sortings as $i => $sorting) {
$wrapper->addOrderBy(sprintf('sort_%s', $i), $sorting->getDirection());
}
$wrapper->from($root, $root);
//wrap query into a sub select to restrict the association count from the outer query
$wrapper->leftJoin(
$root,
'(' . $query->getSQL() . ')',
'child',
'child.' . $foreignKey . ' = ' . $root . '.id AND id_count >= :offset AND id_count <= :limit'
);
//filter result to loaded root entities
$wrapper->andWhere($root . '.id IN (:rootIds)');
$bytes = $collection->map(
fn (Entity $entity) => Uuid::fromHexToBytes($entity->getUniqueIdentifier())
);
if ($definition->isInheritanceAware() && $context->considerInheritance()) {
/** @var Entity $entity */
foreach ($collection->getElements() as $entity) {
if ($entity->get('parentId')) {
$bytes[$entity->get('parentId')] = Uuid::fromHexToBytes($entity->get('parentId'));
}
}
}
$wrapper->setParameter('rootIds', $bytes, Connection::PARAM_STR_ARRAY);
$limit = $fieldCriteria->getOffset() + $fieldCriteria->getLimit();
$offset = $fieldCriteria->getOffset() + 1;
$wrapper->setParameter('limit', $limit);
$wrapper->setParameter('offset', $offset);
foreach ($query->getParameters() as $key => $value) {
$type = $query->getParameterType($key);
$wrapper->setParameter($key, $value, $type);
}
//initials the cursor and loop counter, pdo do not allow to execute SET and SELECT in one statement
$this->connection->executeQuery('SET @n = 0; SET @c = null;');
$rows = $wrapper->executeQuery()->fetchAllAssociative();
$grouped = [];
foreach ($rows as $row) {
$id = (string) $row['id'];
if (!isset($grouped[$id])) {
$grouped[$id] = [];
}
if (empty($row['child_id'])) {
continue;
}
$grouped[$id][] = (string) $row['child_id'];
}
return $grouped;
}
private function buildManyToManyLimitQuery(ManyToManyAssociationField $association): QueryBuilder
{
$table = EntityDefinitionQueryHelper::escape($association->getMappingDefinition()->getEntityName());
$sourceColumn = EntityDefinitionQueryHelper::escape($association->getMappingLocalColumn());
$referenceColumn = EntityDefinitionQueryHelper::escape($association->getMappingReferenceColumn());
$params = [
'#table#' => $table,
'#source_column#' => $sourceColumn,
];
$query = new QueryBuilder($this->connection);
$query->select([
str_replace(
array_keys($params),
array_values($params),
'@n:=IF(@c=#table#.#source_column#, @n+1, IF(@c:=#table#.#source_column#,1,1)) as id_count'
),
$table . '.' . $referenceColumn,
$table . '.' . $sourceColumn,
]);
$query->from($table, $table);
$query->orderBy($table . '.' . $sourceColumn);
return $query;
}
private function buildOneToManyPropertyAccessor(EntityDefinition $definition, OneToManyAssociationField $association): string
{
$reference = $association->getReferenceDefinition();
if ($association instanceof ChildrenAssociationField) {
return $reference->getEntityName() . '.parentId';
}
$ref = $reference->getFields()->getByStorageName(
$association->getReferenceField()
);
if (!$ref) {
throw new \RuntimeException(
sprintf(
'Reference field %s not found in definition %s for definition %s',
$association->getReferenceField(),
$reference->getEntityName(),
$definition->getEntityName()
)
);
}
return $reference->getEntityName() . '.' . $ref->getPropertyName();
}
private function isAssociationRestricted(?Criteria $criteria, string $accessor): bool
{
if ($criteria === null) {
return false;
}
if (!$criteria->hasAssociation($accessor)) {
return false;
}
$fieldCriteria = $criteria->getAssociation($accessor);
return $fieldCriteria->getOffset() !== null
|| $fieldCriteria->getLimit() !== null
|| !empty($fieldCriteria->getSorting())
|| !empty($fieldCriteria->getFilters())
|| !empty($fieldCriteria->getPostFilters())
;
}
private function addAssociationFieldsToCriteria(
Criteria $criteria,
EntityDefinition $definition,
FieldCollection $fields
): FieldCollection {
foreach ($criteria->getAssociations() as $fieldName => $_fieldCriteria) {
$field = $definition->getFields()->get($fieldName);
if (!$field) {
$this->logger->warning(
sprintf('Criteria association "%s" could not be resolved. Double check your Criteria!', $fieldName)
);
continue;
}
$fields->add($field);
}
return $fields;
}
/**
* @param EntityCollection<Entity> $collection
* @param array<string,mixed> $partial
*/
private function loadToOne(
AssociationField $association,
Context $context,
EntityCollection $collection,
Criteria $criteria,
array $partial
): void {
if (!$association instanceof OneToOneAssociationField && !$association instanceof ManyToOneAssociationField) {
return;
}
if (!$criteria->hasAssociation($association->getPropertyName())) {
return;
}
$associationCriteria = $criteria->getAssociation($association->getPropertyName());
if (!$associationCriteria->getAssociations()) {
return;
}
if (!$associationCriteria->getTitle() && $criteria->getTitle()) {
$associationCriteria->setTitle(
$criteria->getTitle() . '::association::' . $association->getPropertyName()
);
}
$related = array_filter($collection->map(function (Entity $entity) use ($association) {
if ($association->is(Extension::class)) {
return $entity->getExtension($association->getPropertyName());
}
return $entity->get($association->getPropertyName());
}));
$referenceDefinition = $association->getReferenceDefinition();
$collectionClass = $referenceDefinition->getCollectionClass();
if ($partial !== []) {
$collectionClass = EntityCollection::class;
}
$fields = $referenceDefinition->getFields()->getBasicFields();
$fields = $this->addAssociationFieldsToCriteria($associationCriteria, $referenceDefinition, $fields);
// This line removes duplicate entries, so after fetchAssociations the association must be reassigned
$relatedCollection = new $collectionClass();
if (!$relatedCollection instanceof EntityCollection) {
throw new \RuntimeException(sprintf('Collection class %s has to be an instance of EntityCollection', $collectionClass));
}
$relatedCollection->fill($related);
$this->fetchAssociations($associationCriteria, $referenceDefinition, $context, $relatedCollection, $fields, $partial);
/** @var Entity $entity */
foreach ($collection as $entity) {
if ($association->is(Extension::class)) {
$item = $entity->getExtension($association->getPropertyName());
} else {
$item = $entity->get($association->getPropertyName());
}
/** @var Entity|null $item */
if ($item === null) {
continue;
}
if ($association->is(Extension::class)) {
$entity->addExtension($association->getPropertyName(), $relatedCollection->get($item->getUniqueIdentifier()));
continue;
}
$entity->assign([
$association->getPropertyName() => $relatedCollection->get($item->getUniqueIdentifier()),
]);
}
}
/**
* @param EntityCollection<Entity> $collection
* @param array<string,mixed> $partial
*
* @return EntityCollection<Entity>
*/
private function fetchAssociations(
Criteria $criteria,
EntityDefinition $definition,
Context $context,
EntityCollection $collection,
FieldCollection $fields,
array $partial
): EntityCollection {
if ($collection->count() <= 0) {
return $collection;
}
foreach ($fields as $association) {
if (!$association instanceof AssociationField) {
continue;
}
if ($association instanceof OneToOneAssociationField || $association instanceof ManyToOneAssociationField) {
$this->loadToOne($association, $context, $collection, $criteria, $partial[$association->getPropertyName()] ?? []);
continue;
}
if ($association instanceof OneToManyAssociationField) {
$this->loadOneToMany($criteria, $definition, $association, $context, $collection, $partial[$association->getPropertyName()] ?? []);
continue;
}
if ($association instanceof ManyToManyAssociationField) {
$this->loadManyToMany($criteria, $association, $context, $collection, $partial[$association->getPropertyName()] ?? []);
}
}
foreach ($collection as $struct) {
$struct->removeExtension(self::INTERNAL_MAPPING_STORAGE);
}
return $collection;
}
}