diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 2128bbb8062..90f52a52d05 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -109,6 +109,7 @@ - Added `craft\web\View::registerIcon()`. - Added `craft\web\assets\codemirror\CodeMirrorAsset`. - `craft\base\Element::fieldLayoutFields()` now has an `editableOnly` argument. +- `craft\base\ElementInterface::eagerLoadingMap()` and `craft\base\EagerLoadingFieldInterface::eagerLoadingMap()` can now specify mappings for multiple target element types, or not specify the element types at all. ([#16972](https://github.com/craftcms/cms/pull/16972)) - `craft\cache\ElementQueryTagDependency` now merges cache tags provided by the element query with any tags already set on its `$tags` property. - `craft\elements\NestedElementManager::getCardsHtml()` and `getIndexHtml()` now accept `canPaste` config options, which can be set to `true`, `false`, or a JavaScript function. - `craft\services\Elements::duplicateElement()` now has a `checkAuthorization` argument. @@ -142,3 +143,4 @@ - Updated GraphiQL to 3.8.3. ([#16836](https://github.com/craftcms/cms/pull/16836)) - Fixed a bug where indicator icons within field layout element chips didn’t have alternative text. ([#16297](https://github.com/craftcms/cms/discussions/16297)) - Fixed a bug where slide pickers within selected field layout elements didn’t have a label. ([#16696](https://github.com/craftcms/cms/pull/16696)) +- Fixed a bug where nested elements’ `getOwner()` and `getPrimaryOwner()` methods weren’t working properly if they had been queried alongside other elements that didn’t share the same owner type. ([#16960](https://github.com/craftcms/cms/pull/16960)) diff --git a/src/base/EagerLoadingFieldInterface.php b/src/base/EagerLoadingFieldInterface.php index 38d5fb2d06a..ae53640ea76 100644 --- a/src/base/EagerLoadingFieldInterface.php +++ b/src/base/EagerLoadingFieldInterface.php @@ -10,6 +10,7 @@ /** * EagerLoadingFieldInterface defines the common interface to be implemented by field classes that support eager-loading. * + * @phpstan-import-type EagerLoadingMap from ElementInterface * @author Pixel & Tonic, Inc. * @since 3.0.0 */ @@ -20,13 +21,27 @@ interface EagerLoadingFieldInterface * * This method aids in the eager-loading of elements when performing an element query. The returned array should * contain the following keys: - * - `elementType` – the fully qualified class name of the element type that should be eager-loaded - * - `map` – an array of element ID mappings, where each element is a sub-array with `source` and `target` keys. - * - `criteria` *(optional)* – Any criteria parameters that should be applied to the element query when fetching the eager-loaded elements. + * - `map` – an array defining source-target element mappings + * - `elementType` *(optional)* – the fully qualified class name of the element type that should be eager-loaded, + * if each target element is of the same element type + * - `criteria` *(optional)* – any criteria parameters that should be applied to the element query when fetching the + * eager-loaded elements + * - `createElement` *(optional)* - an element factory function, which will be passed the element query, the current + * query result data, and the first source element that the result was eager-loaded for + * + * Each mapping listed in `map` should be an array with the following keys: + * - `source` – the source element ID + * - `target` – the target element ID + * - `elementType` *(optional)* – the target element type (only checked for if the top-level array doesn’t specify + * an `elementType` key) + * + * Alternatively, the method can return an array of multiple sets of mappings, each with their own nested `map`, + * `elementType`, `criteria`, and `createElement` keys. * * @param ElementInterface[] $sourceElements An array of the source elements - * @return array|null|false The eager-loading element ID mappings, false if no mappings exist, or null if the result + * @return EagerLoadingMap|EagerLoadingMap[]|null|false The eager-loading element ID mappings, false if no mappings exist, or null if the result * should be ignored. + * @see ElementInterface::eagerLoadingMap() */ public function getEagerLoadingMap(array $sourceElements): array|null|false; diff --git a/src/base/ElementInterface.php b/src/base/ElementInterface.php index 4db319b1056..2e50fd20dfb 100644 --- a/src/base/ElementInterface.php +++ b/src/base/ElementInterface.php @@ -30,6 +30,7 @@ * @mixin CustomFieldBehavior * @mixin Component * @phpstan-require-extends Element + * @phpstan-type EagerLoadingMap array{elementType?:class-string,map:array{elementType?:class-string,source:int,target:int}[],criteria?:array,createElement?:callable} * @author Pixel & Tonic, Inc. * @since 3.0.0 */ @@ -574,13 +575,20 @@ public static function attributePreviewHtml(array $attribute): mixed; * * This method aids in the eager-loading of elements when performing an element query. The returned array should * contain the following keys: - * - `elementType` – the fully qualified class name of the element type that should be eager-loaded - * - `map` – an array of element ID mappings, where each element is a sub-array with `source` and `target` keys + * - `map` – an array defining source-target element mappings + * - `elementType` *(optional)* – the fully qualified class name of the element type that should be eager-loaded, + * if each target element is of the same element type * - `criteria` *(optional)* – any criteria parameters that should be applied to the element query when fetching the * eager-loaded elements * - `createElement` *(optional)* - an element factory function, which will be passed the element query, the current * query result data, and the first source element that the result was eager-loaded for * + * Each mapping listed in `map` should be an array with the following keys: + * - `source` – the source element ID + * - `target` – the target element ID + * - `elementType` *(optional)* – the target element type (only checked for if the top-level array doesn’t specify + * an `elementType` key) + * * ```php * use craft\base\ElementInterface; * use craft\db\Query; @@ -616,10 +624,13 @@ public static function attributePreviewHtml(array $attribute): mixed; * } * ``` * + * Alternatively, the method can return an array of multiple sets of mappings, each with their own nested `map`, + * `elementType`, `criteria`, and `createElement` keys. + * * @param self[] $sourceElements An array of the source elements * @param string $handle The property handle used to identify which target elements should be included in the map - * @return array|null|false The eager-loading element ID mappings, false if no mappings exist, or null if the result - * should be ignored + * @return EagerLoadingMap|EagerLoadingMap[]|null|false The eager-loading element ID mappings, false if no mappings + * exist, or null if the result should be ignored */ public static function eagerLoadingMap(array $sourceElements, string $handle): array|null|false; diff --git a/src/base/NestedElementTrait.php b/src/base/NestedElementTrait.php index 1f2205379bf..cff15557417 100644 --- a/src/base/NestedElementTrait.php +++ b/src/base/NestedElementTrait.php @@ -37,14 +37,9 @@ public static function eagerLoadingMap(array $sourceElements, string $handle): a switch ($handle) { case 'owner': case 'primaryOwner': - /** @var array $sourceElements */ - $ownerType = $sourceElements[0]->ownerType(); - if (!$ownerType) { - return false; - } - + /** @phpstan-ignore-next-line */ return [ - 'elementType' => $ownerType, + /** @phpstan-ignore-next-line */ 'map' => array_filter(array_map(function(NestedElementInterface $element) use ($handle) { $ownerId = match ($handle) { 'owner' => $element->getOwnerId(), diff --git a/src/elements/Entry.php b/src/elements/Entry.php index ddd6d6e0de8..a25206e1ee6 100644 --- a/src/elements/Entry.php +++ b/src/elements/Entry.php @@ -740,6 +740,7 @@ public static function eagerLoadingMap(array $sourceElements, string $handle): a } } + /** @phpstan-ignore-next-line */ return [ 'elementType' => User::class, 'map' => $map, diff --git a/src/fields/BaseRelationField.php b/src/fields/BaseRelationField.php index d6d6cd2517e..acd7a693a0e 100644 --- a/src/fields/BaseRelationField.php +++ b/src/fields/BaseRelationField.php @@ -1046,6 +1046,7 @@ public function getEagerLoadingMap(array $sourceElements): array|null|false $criteria['orderBy'] = ['structureelements.lft' => SORT_ASC]; } + /** @phpstan-ignore-next-line */ return [ 'elementType' => static::elementType(), 'map' => $map, diff --git a/src/services/Elements.php b/src/services/Elements.php index a839bdd78fd..373894b348d 100644 --- a/src/services/Elements.php +++ b/src/services/Elements.php @@ -90,6 +90,7 @@ * * An instance of the service is available via [[\craft\base\ApplicationTrait::getElements()|`Craft::$app->getElements()`]]. * + * @phpstan-import-type EagerLoadingMap from ElementInterface * @author Pixel & Tonic, Inc. * @since 3.0.0 */ @@ -3255,197 +3256,302 @@ private function _eagerLoadElementsInternal(string $elementType, array $elements } // Get the eager-loading map from the source element type - $map = $elementType::eagerLoadingMap($filteredElements, $plan->handle); + $maps = $elementType::eagerLoadingMap($filteredElements, $plan->handle); - if ($map === null) { + if ($maps === null) { // Null means to skip eager-loading this segment continue; } - $targetElementIdsBySourceIds = null; - $query = null; - $offset = 0; - $limit = null; - - if (!empty($map['map'])) { - // Loop through the map to find: - // - unique target element IDs - // - target element IDs indexed by source element IDs - $uniqueTargetElementIds = []; - $targetElementIdsBySourceIds = []; - - foreach ($map['map'] as $mapping) { - if (!empty($mapping['target'])) { - $uniqueTargetElementIds[$mapping['target']] = true; - $targetElementIdsBySourceIds[$mapping['source']][$mapping['target']] = true; - } + // Set everything to empty results as a starting point + foreach ($filteredElements as $sourceElement) { + if ($plan->count) { + $sourceElement->setEagerLoadedElementCount($plan->alias, 0); } + if ($plan->all) { + $sourceElement->setEagerLoadedElements($plan->alias, [], $plan); + $sourceElement->setLazyEagerLoadedElements($plan->alias, $plan->lazy); + } + } - // Get the target elements - $query = $this->createElementQuery($map['elementType']); + $maps = $this->normalizeEagerLoadingMaps($maps); + + foreach ($maps as $map) { + $targetElementIdsBySourceIds = null; + $query = null; + $offset = 0; + $limit = null; + + if (!empty($map['map'])) { + // Loop through the map to find: + // - unique target element IDs + // - target element IDs indexed by source element IDs + $uniqueTargetElementIds = []; + $targetElementIdsBySourceIds = []; + + foreach ($map['map'] as $mapping) { + if (!empty($mapping['target'])) { + $uniqueTargetElementIds[$mapping['target']] = true; + $targetElementIdsBySourceIds[$mapping['source']][$mapping['target']] = true; + } + } - // Default to no order, offset, or limit, but allow the element type/path criteria to override - $query->orderBy = null; - $query->offset = null; - $query->limit = null; + // Get the target elements + $query = $this->createElementQuery($map['elementType']); - $criteria = array_merge( - $map['criteria'] ?? [], - $plan->criteria - ); + // Default to no order, offset, or limit, but allow the element type/path criteria to override + $query->orderBy = null; + $query->offset = null; + $query->limit = null; - // Save the offset & limit params for later - $offset = ArrayHelper::remove($criteria, 'offset', 0); - $limit = ArrayHelper::remove($criteria, 'limit'); + $criteria = array_merge( + $map['criteria'] ?? [], + $plan->criteria + ); - Craft::configure($query, $criteria); + // Save the offset & limit params for later + $offset = ArrayHelper::remove($criteria, 'offset', 0); + $limit = ArrayHelper::remove($criteria, 'limit'); - if (!$query->siteId) { - $query->siteId = $siteId; - } + Craft::configure($query, $criteria); - if (!$query->id) { - $query->id = array_keys($uniqueTargetElementIds); - } else { - $query->andWhere([ - 'elements.id' => array_keys($uniqueTargetElementIds), - ]); - } - } + if (!$query->siteId) { + $query->siteId = $siteId; + } - // Do we just need the count? - if ($plan->count && !$plan->all) { - // Just fetch the target elements’ IDs - $targetElementIdCounts = []; - if ($query) { - foreach ($query->ids() as $id) { - if (!isset($targetElementIdCounts[$id])) { - $targetElementIdCounts[$id] = 1; - } else { - $targetElementIdCounts[$id]++; - } + if (!$query->id) { + $query->id = array_keys($uniqueTargetElementIds); + } else { + $query->andWhere([ + 'elements.id' => array_keys($uniqueTargetElementIds), + ]); } } - // Loop through the source elements and count up their targets - foreach ($filteredElements as $sourceElement) { - $count = 0; - if (!empty($targetElementIdCounts) && isset($targetElementIdsBySourceIds[$sourceElement->id])) { - foreach (array_keys($targetElementIdsBySourceIds[$sourceElement->id]) as $targetElementId) { - if (isset($targetElementIdCounts[$targetElementId])) { - $count += $targetElementIdCounts[$targetElementId]; + // Do we just need the count? + if ($plan->count && !$plan->all) { + // Just fetch the target elements’ IDs + $targetElementIdCounts = []; + if ($query) { + foreach ($query->ids() as $id) { + if (!isset($targetElementIdCounts[$id])) { + $targetElementIdCounts[$id] = 1; + } else { + $targetElementIdCounts[$id]++; } } } - $sourceElement->setEagerLoadedElementCount($plan->alias, $count); - } - - continue; - } - - $targetElementData = $query ? ArrayHelper::index($query->asArray()->all(), null, ['id']) : []; - $targetElements = []; - // Tell the source elements about their eager-loaded elements - foreach ($filteredElements as $sourceElement) { - $targetElementIdsForSource = []; - $targetElementsForSource = []; - - if (isset($targetElementIdsBySourceIds[$sourceElement->id])) { - // Does the path mapping want a custom order? - if (!empty($criteria['orderBy']) || !empty($criteria['order'])) { - // Assign the elements in the order they were returned from the query - foreach (array_keys($targetElementData) as $targetElementId) { - if (isset($targetElementIdsBySourceIds[$sourceElement->id][$targetElementId])) { - $targetElementIdsForSource[] = $targetElementId; + // Loop through the source elements and count up their targets + foreach ($filteredElements as $sourceElement) { + if (!empty($targetElementIdCounts) && isset($targetElementIdsBySourceIds[$sourceElement->id])) { + $count = 0; + foreach (array_keys($targetElementIdsBySourceIds[$sourceElement->id]) as $targetElementId) { + if (isset($targetElementIdCounts[$targetElementId])) { + $count += $targetElementIdCounts[$targetElementId]; + } } - } - } else { - // Assign the elements in the order defined by the map - foreach (array_keys($targetElementIdsBySourceIds[$sourceElement->id]) as $targetElementId) { - if (isset($targetElementData[$targetElementId])) { - $targetElementIdsForSource[] = $targetElementId; + if ($count !== 0) { + $sourceElement->setEagerLoadedElementCount($plan->alias, $count); } } } - if (!empty($criteria['inReverse'])) { - $targetElementIdsForSource = array_reverse($targetElementIdsForSource); - } + continue; + } - // Create the elements - $currentOffset = 0; - $count = 0; - foreach ($targetElementIdsForSource as $elementId) { - foreach ($targetElementData[$elementId] as $result) { - if ($offset && $currentOffset < $offset) { - $currentOffset++; - continue; - } - $targetSiteId = $result['siteId']; - if (!isset($targetElements[$targetSiteId][$elementId])) { - if (isset($map['createElement'])) { - $targetElements[$targetSiteId][$elementId] = $map['createElement']($query, $result, $sourceElement); - } else { - $targetElements[$targetSiteId][$elementId] = $query->createElement($result); + $targetElementData = $query ? ArrayHelper::index($query->asArray()->all(), null, ['id']) : []; + $targetElements = []; + + // Tell the source elements about their eager-loaded elements + foreach ($filteredElements as $sourceElement) { + $targetElementIdsForSource = []; + $targetElementsForSource = []; + + if (isset($targetElementIdsBySourceIds[$sourceElement->id])) { + // Does the path mapping want a custom order? + if (!empty($criteria['orderBy']) || !empty($criteria['order'])) { + // Assign the elements in the order they were returned from the query + foreach (array_keys($targetElementData) as $targetElementId) { + if (isset($targetElementIdsBySourceIds[$sourceElement->id][$targetElementId])) { + $targetElementIdsForSource[] = $targetElementId; } } - $targetElementsForSource[] = $element = $targetElements[$targetSiteId][$elementId]; - - // If we're collecting cache info and the element is expirable, register its expiry date - if ( - $element instanceof ExpirableElementInterface && - $elementsService->getIsCollectingCacheInfo() && - ($expiryDate = $element->getExpiryDate()) !== null - ) { - $elementsService->setCacheExpiryDate($expiryDate); + } else { + // Assign the elements in the order defined by the map + foreach (array_keys($targetElementIdsBySourceIds[$sourceElement->id]) as $targetElementId) { + if (isset($targetElementData[$targetElementId])) { + $targetElementIdsForSource[] = $targetElementId; + } } + } + + if (!empty($criteria['inReverse'])) { + $targetElementIdsForSource = array_reverse($targetElementIdsForSource); + } + + // Create the elements + $currentOffset = 0; + $count = 0; + foreach ($targetElementIdsForSource as $elementId) { + foreach ($targetElementData[$elementId] as $result) { + if ($offset && $currentOffset < $offset) { + $currentOffset++; + continue; + } + $targetSiteId = $result['siteId']; + if (!isset($targetElements[$targetSiteId][$elementId])) { + if (isset($map['createElement'])) { + $targetElements[$targetSiteId][$elementId] = $map['createElement']($query, $result, $sourceElement); + } else { + $targetElements[$targetSiteId][$elementId] = $query->createElement($result); + } + } + $targetElementsForSource[] = $element = $targetElements[$targetSiteId][$elementId]; + + // If we're collecting cache info and the element is expirable, register its expiry date + if ( + $element instanceof ExpirableElementInterface && + $elementsService->getIsCollectingCacheInfo() && + ($expiryDate = $element->getExpiryDate()) !== null + ) { + $elementsService->setCacheExpiryDate($expiryDate); + } - if ($limit && ++$count == $limit) { - break 2; + if ($limit && ++$count == $limit) { + break 2; + } } } } - } - if (!empty($criteria['withProvisionalDrafts'])) { - ElementHelper::swapInProvisionalDrafts($targetElementsForSource); + if (!empty($targetElementsForSource)) { + if (!empty($criteria['withProvisionalDrafts'])) { + ElementHelper::swapInProvisionalDrafts($targetElementsForSource); + } + + $sourceElement->setEagerLoadedElements($plan->alias, $targetElementsForSource, $plan); + + if ($plan->count) { + $sourceElement->setEagerLoadedElementCount($plan->alias, count($targetElementsForSource)); + } + } } - $sourceElement->setEagerLoadedElements($plan->alias, $targetElementsForSource, $plan); - $sourceElement->setLazyEagerLoadedElements($plan->alias, $plan->lazy); + if (!empty($targetElements)) { + /** @var ElementInterface[] $flatTargetElements */ + $flatTargetElements = array_merge(...array_values($targetElements)); - if ($plan->count) { - $sourceElement->setEagerLoadedElementCount($plan->alias, count($targetElementsForSource)); + // Set the eager loading info on each of the target elements, + // in case it's needed for lazy eager loading + $eagerLoadResult = new EagerLoadInfo($plan, $filteredElements); + foreach ($flatTargetElements as $element) { + $element->eagerLoadInfo = $eagerLoadResult; + } + + // Pass the instantiated elements to afterPopulate() + $query->asArray = false; + $query->afterPopulate($flatTargetElements); } + + // Now eager-load any sub paths + if (!empty($map['map']) && !empty($plan->nested)) { + $this->_eagerLoadElementsInternal( + $map['elementType'], + array_map('array_values', $targetElements), + $plan->nested, + ); + } + } + } + } + } + + /** + * @param EagerLoadingMap|EagerLoadingMap[]|false $map + * @return EagerLoadingMap[]|false[] + */ + private function normalizeEagerLoadingMaps(array|false $map): array + { + if (isset($map['elementType']) || $map === false) { + // a normal, one-dimensional map + return [$map]; + } + + if (isset($map['map'])) { + // no single element type was provided, so split it up into multiple maps - one for each unique type + $maps = $this->groupMapsByElementType($map['map']); + if (isset($map['criteria']) || isset($map['createElement'])) { + foreach ($maps as &$m) { + $m['criteria'] ??= $map['criteria'] ?? []; + $m['createElement'] ??= $map['createElement'] ?? null; } + } + return $maps; + } - if (!empty($targetElements)) { - /** @var ElementInterface[] $flatTargetElements */ - $flatTargetElements = array_merge(...array_values($targetElements)); + // multiple maps were provided, so normalize and return each of them + $maps = []; + foreach ($map as $m) { + /** @phpstan-ignore-next-line */ + if (isset($m['map'])) { + $maps += $this->normalizeEagerLoadingMaps($m); + } + } + return $maps; + } - // Set the eager loading info on each of the target elements, - // in case it's needed for lazy eager loading - $eagerLoadResult = new EagerLoadInfo($plan, $filteredElements); - foreach ($flatTargetElements as $element) { - $element->eagerLoadInfo = $eagerLoadResult; - } + /** + * @param array{source:int,target:int,elementType?:class-string}[] $map + * @return EagerLoadingMap[] + */ + private function groupMapsByElementType(array $map): array + { + if (empty($map)) { + return []; + } - // Pass the instantiated elements to afterPopulate() - $query->asArray = false; - $query->afterPopulate($flatTargetElements); + $maps = []; + $untypedMaps = []; + $untypedTargetIds = []; + + foreach ($map as $m) { + if (isset($m['elementType'])) { + $elementType = $m['elementType']; + $maps[$elementType] ??= ['elementType' => $elementType]; + $maps[$elementType]['map'][] = $m; + } else { + $untypedMaps[] = $m; + $untypedTargetIds[] = $m['target']; + } + } + + if (!empty($untypedMaps)) { + $elementTypesById = []; + + foreach (array_chunk($untypedTargetIds, 100) as $ids) { + $types = (new Query()) + ->select(['id', 'type']) + ->from(Table::ELEMENTS) + ->where(['id' => $ids]) + ->pairs(); + // we need to preserve the numeric keys, so array_merge() won't work here + foreach ($types as $id => $type) { + $elementTypesById[$id] = $type; } + } - // Now eager-load any sub paths - if (!empty($map['map']) && !empty($plan->nested)) { - $this->_eagerLoadElementsInternal( - $map['elementType'], - array_map('array_values', $targetElements), - $plan->nested, - ); + foreach ($untypedMaps as $m) { + if (!isset($elementTypesById[$m['target']])) { + continue; } + $elementType = $elementTypesById[$m['target']]; + $maps[$elementType] ??= ['elementType' => $elementType]; + $maps[$elementType]['map'][] = $m; } } + + return array_values($maps); } /**