From e4380a1c7847cd7bdda79f816fbccb4986065beb Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Fri, 24 May 2024 13:43:34 -0700 Subject: [PATCH 1/4] Improve query & collection type devs w/ generics --- CHANGELOG-WIP.md | 1 + src/db/Query.php | 9 +++++++-- src/elements/ElementCollection.php | 6 +++--- src/elements/db/AddressQuery.php | 8 ++++---- src/elements/db/AssetQuery.php | 8 ++++---- src/elements/db/CategoryQuery.php | 8 ++++---- src/elements/db/ElementQuery.php | 22 +++++++++++++--------- src/elements/db/EntryQuery.php | 8 ++++---- src/elements/db/GlobalSetQuery.php | 9 +++++---- src/elements/db/MatrixBlockQuery.php | 8 ++++---- src/elements/db/TagQuery.php | 8 ++++---- src/elements/db/UserQuery.php | 8 ++++---- 12 files changed, 57 insertions(+), 46 deletions(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 067fbf45d99..234104946a3 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -10,6 +10,7 @@ - The web-based installer now displays the error message when installation fails. ### Extensibility +- Improved type definitions for `craft\db\Query`, element queries, and `craft\elements\ElementCollection`. - Added `craft\db\getBackupFormat()`. - Added `craft\db\getRestoreFormat()`. - Added `craft\db\setBackupFormat()`. diff --git a/src/db/Query.php b/src/db/Query.php index 7fda145a304..e8d7e002ce5 100644 --- a/src/db/Query.php +++ b/src/db/Query.php @@ -22,6 +22,10 @@ /** * Class Query * + * @template TKey of array-key + * @template TValue + * @implements ArrayAccess + * * @author Pixel & Tonic, Inc. * @since 3.0.0 */ @@ -240,6 +244,7 @@ public function pairs(?YiiConnection $db = null): array /** * @inheritdoc + * @return array */ public function all($db = null): array { @@ -255,7 +260,7 @@ public function all($db = null): array * * @param YiiConnection|null $db The database connection used to generate the SQL statement. * If this parameter is not given, the `db` application component will be used. - * @return Collection A collection of the resulting elements. + * @return Collection A collection of the resulting elements. * @since 4.0.0 */ public function collect(?YiiConnection $db = null): Collection @@ -265,7 +270,7 @@ public function collect(?YiiConnection $db = null): Collection /** * @inheritdoc - * @return array|null + * @return TValue|null */ public function one($db = null): mixed { diff --git a/src/elements/ElementCollection.php b/src/elements/ElementCollection.php index 2562672dedc..e928b294e50 100644 --- a/src/elements/ElementCollection.php +++ b/src/elements/ElementCollection.php @@ -15,10 +15,10 @@ * ElementCollection represents a collection of elements. * * @template TKey of array-key - * @template TValue of ElementInterface - * @extends Collection + * @template TElement of ElementInterface + * @extends Collection * - * @method TValue one(callable|null $callback, mixed $default) + * @method TElement one(callable|null $callback, mixed $default) * @author Pixel & Tonic, Inc. * @since 4.3.0 */ diff --git a/src/elements/db/AddressQuery.php b/src/elements/db/AddressQuery.php index 7495e00b1da..69cf798cd93 100644 --- a/src/elements/db/AddressQuery.php +++ b/src/elements/db/AddressQuery.php @@ -13,14 +13,14 @@ use craft\elements\Address; use craft\helpers\ArrayHelper; use yii\base\InvalidConfigException; -use yii\db\Connection; /** * AddressQuery represents a SELECT SQL statement for categories in a way that is independent of DBMS. * - * @method Address[]|array all($db = null) - * @method Address|array|null one($db = null) - * @method Address|array|null nth(int $n, ?Connection $db = null) + * @template TKey of array-key + * @template TElement of Address + * @extends ElementQuery + * * @author Pixel & Tonic, Inc. * @since 4.0.0 * @doc-path addresses.md diff --git a/src/elements/db/AssetQuery.php b/src/elements/db/AssetQuery.php index 9eb03902529..6eb3d711f6b 100644 --- a/src/elements/db/AssetQuery.php +++ b/src/elements/db/AssetQuery.php @@ -19,16 +19,16 @@ use craft\helpers\StringHelper; use craft\models\Volume; use yii\base\InvalidArgumentException; -use yii\db\Connection; use yii\db\Schema; /** * AssetQuery represents a SELECT SQL statement for assets in a way that is independent of DBMS. * + * @template TKey of array-key + * @template TElement of Asset + * @extends ElementQuery + * * @property-write string|string[]|Volume|null $volume The volume(s) that resulting assets must belong to - * @method Asset[]|array all($db = null) - * @method Asset|array|null one($db = null) - * @method Asset|array|null nth(int $n, ?Connection $db = null) * @author Pixel & Tonic, Inc. * @since 3.0.0 * @doc-path assets.md diff --git a/src/elements/db/CategoryQuery.php b/src/elements/db/CategoryQuery.php index ac46848430d..1cccbeb2999 100644 --- a/src/elements/db/CategoryQuery.php +++ b/src/elements/db/CategoryQuery.php @@ -16,15 +16,15 @@ use craft\helpers\Db; use craft\helpers\StringHelper; use craft\models\CategoryGroup; -use yii\db\Connection; /** * CategoryQuery represents a SELECT SQL statement for categories in a way that is independent of DBMS. * + * @template TKey of array-key + * @template TElement of Category + * @extends ElementQuery + * * @property-write string|string[]|CategoryGroup|null $group The category group(s) that resulting categories must belong to - * @method Category[]|array all($db = null) - * @method Category|array|null one($db = null) - * @method Category|array|null nth(int $n, ?Connection $db = null) * @author Pixel & Tonic, Inc. * @since 3.0.0 * @doc-path categories.md diff --git a/src/elements/db/ElementQuery.php b/src/elements/db/ElementQuery.php index 21107eb1a1b..82bc75dd7e5 100644 --- a/src/elements/db/ElementQuery.php +++ b/src/elements/db/ElementQuery.php @@ -50,6 +50,10 @@ /** * ElementQuery represents a SELECT SQL statement for elements in a way that is independent of DBMS. * + * @template TKey of array-key + * @template TElement of ElementInterface + * @extends Query + * * @property-write string|string[]|Site $site The site(s) that resulting elements must be returned in * @mixin CustomFieldBehavior * @author Pixel & Tonic, Inc. @@ -101,7 +105,7 @@ class ElementQuery extends Query implements ElementQueryInterface /** * @var string The name of the [[ElementInterface]] class. - * @phpstan-var class-string + * @phpstan-var class-string */ public string $elementType; @@ -523,7 +527,7 @@ class ElementQuery extends Query implements ElementQueryInterface * Constructor * * @param string $elementType The element type class associated with this query - * @phpstan-param class-string $elementType + * @phpstan-param class-string $elementType * @param array $config Configurations to be applied to the newly created query object */ public function __construct(string $elementType, array $config = []) @@ -1565,7 +1569,7 @@ public function prepare($builder): Query /** * @inheritdoc - * @return ElementInterface[]|array The resulting elements. + * @return TElement[]|array The resulting elements. */ public function populate($rows): array { @@ -1613,7 +1617,7 @@ public function count($q = '*', $db = null): bool|int|string|null /** * @inheritdoc - * @return ElementInterface[]|array + * @return TElement[]|array */ public function all($db = null): array { @@ -1630,7 +1634,7 @@ public function all($db = null): array /** * @param YiiConnection|null $db - * @return Collection + * @return ElementCollection */ public function collect(?YiiConnection $db = null): Collection { @@ -1639,7 +1643,7 @@ public function collect(?YiiConnection $db = null): Collection /** * @inheritdoc - * @return ElementInterface|array|null + * @return TElement|array|null */ public function one($db = null): mixed { @@ -1697,7 +1701,7 @@ public function exists($db = null): bool /** * @inheritdoc - * @return ElementInterface|array|null + * @return TElement|array|null */ public function nth(int $n, ?YiiConnection $db = null): mixed { @@ -1725,7 +1729,7 @@ public function ids(?YiiConnection $db = null): array /** * Returns the resulting elements set by [[setCachedResult()]], if the criteria params haven’t changed since then. * - * @return ElementInterface[]|null $elements The resulting elements, or null if setCachedResult() was never called or the criteria has changed + * @return TElement[]|null $elements The resulting elements, or null if setCachedResult() was never called or the criteria has changed * @see setCachedResult() */ public function getCachedResult(): ?array @@ -1749,7 +1753,7 @@ public function getCachedResult(): ?array * If this is called, [[all()]] will return these elements rather than initiating a new SQL query, * as long as none of the parameters have changed since setCachedResult() was called. * - * @param ElementInterface[] $elements The resulting elements. + * @param TElement[] $elements The resulting elements. * @see getCachedResult() */ public function setCachedResult(array $elements): void diff --git a/src/elements/db/EntryQuery.php b/src/elements/db/EntryQuery.php index 3001348b292..f38c915b093 100644 --- a/src/elements/db/EntryQuery.php +++ b/src/elements/db/EntryQuery.php @@ -21,17 +21,17 @@ use DateTime; use Illuminate\Support\Collection; use yii\base\InvalidConfigException; -use yii\db\Connection; /** * EntryQuery represents a SELECT SQL statement for entries in a way that is independent of DBMS. * + * @template TKey of array-key + * @template TElement of Entry + * @extends ElementQuery + * * @property-write string|string[]|EntryType|null $type The entry type(s) that resulting entries must have * @property-write string|string[]|Section|null $section The section(s) that resulting entries must belong to * @property-write string|string[]|UserGroup|null $authorGroup The user group(s) that resulting entries’ authors must belong to - * @method Entry[]|array all($db = null) - * @method Entry|array|null one($db = null) - * @method Entry|array|null nth(int $n, ?Connection $db = null) * @author Pixel & Tonic, Inc. * @since 3.0.0 * @doc-path entries.md diff --git a/src/elements/db/GlobalSetQuery.php b/src/elements/db/GlobalSetQuery.php index 139557d6318..2afd5e8e97f 100644 --- a/src/elements/db/GlobalSetQuery.php +++ b/src/elements/db/GlobalSetQuery.php @@ -8,18 +8,19 @@ namespace craft\elements\db; use Craft; +use craft\db\Query; use craft\db\QueryAbortedException; use craft\db\Table; use craft\elements\GlobalSet; use craft\helpers\Db; -use yii\db\Connection; /** * GlobalSetQuery represents a SELECT SQL statement for global sets in a way that is independent of DBMS. * - * @method GlobalSet[]|array all($db = null) - * @method GlobalSet|array|null one($db = null) - * @method GlobalSet|array|null nth(int $n, ?Connection $db = null) + * @template TKey of array-key + * @template TElement of GlobalSet + * @extends ElementQuery + * * @author Pixel & Tonic, Inc. * @since 3.0.0 * @doc-path globals.md diff --git a/src/elements/db/MatrixBlockQuery.php b/src/elements/db/MatrixBlockQuery.php index d3355e3bb02..4143a1deaf6 100644 --- a/src/elements/db/MatrixBlockQuery.php +++ b/src/elements/db/MatrixBlockQuery.php @@ -20,18 +20,18 @@ use craft\models\MatrixBlockType; use yii\base\InvalidArgumentException; use yii\base\InvalidConfigException; -use yii\db\Connection; /** * MatrixBlockQuery represents a SELECT SQL statement for global sets in a way that is independent of DBMS. * + * @template TKey of array-key + * @template TElement of MatrixBlock + * @extends ElementQuery + * * @property-write ElementInterface $owner The owner element the Matrix blocks must belong to * @property-write ElementInterface $primaryOwner The primary owner element the Matrix blocks must belong to * @property-write string|string[]|MatrixBlockType|null $type The block type(s) that resulting Matrix blocks must have * @property-write string|string[]|MatrixField|null $field The field the Matrix blocks must belong to - * @method MatrixBlock[]|array all($db = null) - * @method MatrixBlock|array|null one($db = null) - * @method MatrixBlock|array|null nth(int $n, ?Connection $db = null) * @author Pixel & Tonic, Inc. * @since 3.0.0 * @doc-path matrix-blocks.md diff --git a/src/elements/db/TagQuery.php b/src/elements/db/TagQuery.php index 2bf057910d5..4260e795064 100644 --- a/src/elements/db/TagQuery.php +++ b/src/elements/db/TagQuery.php @@ -15,15 +15,15 @@ use craft\helpers\ArrayHelper; use craft\helpers\Db; use craft\models\TagGroup; -use yii\db\Connection; /** * TagQuery represents a SELECT SQL statement for tags in a way that is independent of DBMS. * + * @template TKey of array-key + * @template TElement of Tag + * @extends ElementQuery + * * @property-write string|string[]|TagGroup|null $group The tag group(s) that resulting tags must belong to - * @method Tag[]|array all($db = null) - * @method Tag|array|null one($db = null) - * @method Tag|array|null nth(int $n, ?Connection $db = null) * @author Pixel & Tonic, Inc. * @since 3.0.0 * @doc-path tags.md diff --git a/src/elements/db/UserQuery.php b/src/elements/db/UserQuery.php index 19f96c64379..1632c642593 100644 --- a/src/elements/db/UserQuery.php +++ b/src/elements/db/UserQuery.php @@ -14,16 +14,16 @@ use craft\elements\User; use craft\helpers\Db; use craft\models\UserGroup; -use yii\db\Connection; use yii\db\Expression; /** * UserQuery represents a SELECT SQL statement for users in a way that is independent of DBMS. * + * @template TKey of array-key + * @template TElement of User + * @extends ElementQuery + * * @property-write string|string[]|UserGroup|null $group The user group(s) that resulting users must belong to - * @method User[]|array all($db = null) - * @method User|array|null one($db = null) - * @method User|array|null nth(int $n, ?Connection $db = null) * @author Pixel & Tonic, Inc. * @since 3.0.0 * @doc-path users.md From d0ff013b14f908df65dd2fd4d9e0978a2685f2dd Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Fri, 24 May 2024 13:43:59 -0700 Subject: [PATCH 2/4] Reorder ElementCollection methods --- src/elements/ElementCollection.php | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/elements/ElementCollection.php b/src/elements/ElementCollection.php index e928b294e50..d2dd6371e29 100644 --- a/src/elements/ElementCollection.php +++ b/src/elements/ElementCollection.php @@ -24,16 +24,6 @@ */ class ElementCollection extends Collection { - /** - * Returns a collection of the elements’ IDs. - * - * @return Collection - */ - public function ids(): Collection - { - return Collection::make(array_map(fn(ElementInterface $element): int => $element->id, $this->items)); - } - /** * Eager-loads related elements for the collected elements. * @@ -66,4 +56,14 @@ public function with(array|string $with): static } return $this; } + + /** + * Returns a collection of the elements’ IDs. + * + * @return Collection + */ + public function ids(): Collection + { + return Collection::make(array_map(fn(ElementInterface $element): int => $element->id, $this->items)); + } } From f0740d71e8d80c7ff516282724067aedb4ffb976 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Fri, 24 May 2024 13:45:19 -0700 Subject: [PATCH 3/4] Support multiple element types in ElementCollection::with() --- CHANGELOG-WIP.md | 1 + src/elements/ElementCollection.php | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 234104946a3..0f30c768c0a 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -19,6 +19,7 @@ - Added `craft\fields\BaseRelationField::existsQueryCondition()`. - Added `craft\helpers\StringHelper::indent()`. - Added `craft\queue\Queue::getJobId()`. +- `craft\elements\ElementCollection::with()` now supports collections made up of multiple element types. - Added the `reloadOnBroadcastSave` setting to `Craft.ElementEditor`. ([#14814](https://github.com/craftcms/cms/issues/14814)) ### System diff --git a/src/elements/ElementCollection.php b/src/elements/ElementCollection.php index d2dd6371e29..d16a8bcb9fc 100644 --- a/src/elements/ElementCollection.php +++ b/src/elements/ElementCollection.php @@ -50,9 +50,11 @@ class ElementCollection extends Collection */ public function with(array|string $with): static { - $first = $this->first(); - if ($first instanceof ElementInterface) { - Craft::$app->getElements()->eagerLoadElements(get_class($first), $this->items, $with); + /** @var array,TElement[]> $elementsByClass */ + $elementsByClass = $this->groupBy(fn(ElementInterface $element) => $element::class)->all(); + $elementsService = Craft::$app->getElements(); + foreach ($elementsByClass as $class => $classElements) { + $elementsService->eagerLoadElements($class, $this->items, $with); } return $this; } From 0fbc77bd4bf30087f5fe401c7cba20dddd6e24b7 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Fri, 24 May 2024 14:13:58 -0700 Subject: [PATCH 4/4] ElementCollection improvements Resolves #15023 --- CHANGELOG-WIP.md | 12 + src/elements/ElementCollection.php | 370 ++++++++++++++++++ tests/unit/elements/ElementCollectionTest.php | 198 ++++++++++ 3 files changed, 580 insertions(+) create mode 100644 tests/unit/elements/ElementCollectionTest.php diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 0f30c768c0a..c45c0035d74 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -9,6 +9,18 @@ - The `install` command and web-based installer now validate the existing project config files at the outset, and abort installation if there are any issues. - The web-based installer now displays the error message when installation fails. +### Development +- Added `craft\elements\ElementCollection::find()`, which can return an element or elements in the collection based on a given element or ID. ([#15023](https://github.com/craftcms/cms/discussions/15023)) +- Added `craft\elements\ElementCollection::fresh()`, which reloads each of the collection elements from the database. ([#15023](https://github.com/craftcms/cms/discussions/15023)) +- `craft\elements\ElementCollection::contains()` now returns `true` if an element is passed in and the collection contains an element with the same ID and site ID; or if an integer is passed in and the collection contains an element with the same ID. ([#15023](https://github.com/craftcms/cms/discussions/15023)) +- `craft\elements\ElementCollection::countBy()`, `collapse()`, `flatten()`, `keys()`, `pad()`, `pluck()`, and `zip()` now return an `Illuminate\Support\Collection` object. ([#15023](https://github.com/craftcms/cms/discussions/15023)) +- `craft\elements\ElementCollection::diff()` and `intersect()` now compare the passed-in elements to the collection elements by their IDs and site IDs. ([#15023](https://github.com/craftcms/cms/discussions/15023)) +- `craft\elements\ElementCollection::flip()` now throws an exception, as element objects can’t be used as array keys. ([#15023](https://github.com/craftcms/cms/discussions/15023)) +- `craft\elements\ElementCollection::map()` and `mapWithKeys()` now return an `Illuminate\Support\Collection` object, if any of the mapped values aren’t elements. ([#15023](https://github.com/craftcms/cms/discussions/15023)) +- `craft\elements\ElementCollection::merge()` now replaces any elements in the collection with passed-in elements, if their ID and site ID matches. ([#15023](https://github.com/craftcms/cms/discussions/15023)) +- `craft\elements\ElementCollection::only()` and `except()` now compare the passed-in values to the collection elements by their IDs, if an integer or array of integers is passed in. ([#15023](https://github.com/craftcms/cms/discussions/15023)) +- `craft\elements\ElementCollection::unique()` now returns all elements with unique IDs, if no key is passed in. ([#15023](https://github.com/craftcms/cms/discussions/15023)) + ### Extensibility - Improved type definitions for `craft\db\Query`, element queries, and `craft\elements\ElementCollection`. - Added `craft\db\getBackupFormat()`. diff --git a/src/elements/ElementCollection.php b/src/elements/ElementCollection.php index d16a8bcb9fc..f172b4502a8 100644 --- a/src/elements/ElementCollection.php +++ b/src/elements/ElementCollection.php @@ -9,7 +9,12 @@ use Craft; use craft\base\ElementInterface; +use craft\helpers\ArrayHelper; +use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Support\Arr; use Illuminate\Support\Collection; +use Illuminate\Support\Enumerable; +use yii\base\NotSupportedException; /** * ElementCollection represents a collection of elements. @@ -24,6 +29,46 @@ */ class ElementCollection extends Collection { + /** + * Finds an element in the collection. + * + * If `$key` is an element instance, the first element with the same ID and site ID. + * + * If `$key` is an integer, the first element with the same ID will be returned. + * + * @template TFindDefault + * + * @param int|TElement|Arrayable|iterable $key + * @param TFindDefault $default + * @return static|TElement|TFindDefault + * @since 4.10.0 + */ + public function find(mixed $key, mixed $default = null): mixed + { + if ($key instanceof ElementInterface) { + return Arr::first( + $this->items, + fn(ElementInterface $element) => $element->siteSettingsId === $key->siteSettingsId, + $default, + ); + } + + if ($key instanceof Arrayable) { + $key = $key->toArray(); + } + + if (is_array($key)) { + if ($this->isEmpty()) { + /** @phpstan-ignore-next-line */ + return new static(); + } + + return $this->whereIn('id', $key); + } + + return Arr::first($this->items, fn(ElementInterface $element) => $element->id === $key, $default); + } + /** * Eager-loads related elements for the collected elements. * @@ -59,6 +104,36 @@ public function with(array|string $with): static return $this; } + /** + * Returns whether an element exists within the collection. + * + * If `$key` is an element instance, `true` will be returned if the collection contains an element with the same ID + * and site ID. + * + * If `$key` is an integer, `true` will be returned in the collection contains an element with that ID. + * + * @param (callable(TElement,TKey):bool)|TElement|string|int $key + * @param mixed $operator + * @param mixed $value + * @return bool + */ + public function contains($key, $operator = null, $value = null) + { + if (func_num_args() > 1 || $this->useAsCallable($key)) { + return parent::contains(...func_get_args()); + } + + if ($key instanceof ElementInterface) { + return parent::contains(fn(ElementInterface $element) => $element->siteSettingsId === $key->siteSettingsId); + } + + if (is_int($key)) { + return parent::contains(fn(ElementInterface $element) => $element->id === $key); + } + + return false; + } + /** * Returns a collection of the elements’ IDs. * @@ -68,4 +143,299 @@ public function ids(): Collection { return Collection::make(array_map(fn(ElementInterface $element): int => $element->id, $this->items)); } + + /** + * Merge the collection with the given elements. + * + * Any elements with a matching ID and site ID will be replaced. + * + * @param iterable $items + * @return static + */ + public function merge($items) + { + $elements = $this->keyBy('siteSettingsId')->all(); + + foreach ($items as $element) { + $elements[$element->siteSettingsId] = $element; + } + + /** @phpstan-ignore-next-line */ + return new static(array_values($elements)); + } + + /** + * Runs a map over each of the items. + * + * @template TMapValue + * + * @param callable(TElement,TKey):TMapValue $callback + * @return Collection|static + */ + public function map(callable $callback) + { + $result = parent::map($callback); + return $result->contains(fn($item) => !$item instanceof ElementInterface) ? $result->toBase() : $result; + } + + /** + * Runs an associative map over each of the items. + * + * The callback should return an associative array with a single key/value pair. + * + * @template TMapWithKeysKey of array-key + * @template TMapWithKeysValue + * + * @param callable(TElement,TKey):array $callback + * @return Collection|static + */ + public function mapWithKeys(callable $callback) + { + $result = parent::mapWithKeys($callback); + return $result->contains(fn($item) => !$item instanceof ElementInterface) ? $result->toBase() : $result; + } + + /** + * Reloads fresh element instances from the database for all the elements. + * + * @return static + * @since 4.10.0 + */ + public function fresh(): static + { + if ($this->isEmpty()) { + /** @phpstan-ignore-next-line */ + return new static(); + } + + // Get all the elements' site settings IDs, grouped by element type + /** @var array,int[]> $idsByClass */ + $idsByClass = []; + foreach ($this->items as $element) { + /** @var TElement $element */ + $idsByClass[$element::class][] = $element->siteSettingsId; + } + + /** @var array,array> $idsByClass */ + $freshElements = []; + + foreach ($idsByClass as $class => $ids) { + /** @var string|TElement $class */ + $freshElements[$class] = $class::find() + ->site('*') + ->siteSettingsId($ids) + ->drafts(null) + ->provisionalDrafts(null) + ->revisions(null) + ->status(null) + ->indexBy('siteSettingsId') + ->all(); + } + + /** @phpstan-ignore-next-line */ + return $this + ->filter(fn(ElementInterface $element) => isset($freshElements[$element::class][$element->siteSettingsId])) + ->map(fn(ElementInterface $element) => $freshElements[$element::class][$element->siteSettingsId]); + } + + /** + * Returns a new collection with the elements that are not present in the given array. + */ + public function diff($items) + { + /** @phpstan-ignore-next-line */ + $diff = new static(); + $ids = array_flip(array_map(fn(ElementInterface $element) => $element->siteSettingsId, $items)); + + foreach ($this->items as $element) { + /** @var TElement $element */ + if (!isset($ids[$element->siteSettingsId])) { + $diff->add($element); + } + } + + return $diff; + } + + /** + * Returns a new collection with all the elements present in this collection and the provided array. + * + * @param array $items + * @return static + */ + public function intersect($items) + { + /** @phpstan-ignore-next-line */ + $intersect = new static(); + + if (empty($items)) { + return $intersect; + } + + $ids = array_flip(array_map(fn(ElementInterface $element) => $element->siteSettingsId, $items)); + + foreach ($this->items as $element) { + /** @var TElement $element */ + if (isset($ids[$element->siteSettingsId])) { + $intersect->add($element); + } + } + + return $intersect; + } + + /** + * Return only unique items from the collection. + * + * @param (callable(TElement,TKey):mixed)|string|null $key + * @param bool $strict + * @return static + */ + public function unique($key = null, $strict = false) + { + if ($key !== null) { + return parent::unique($key, $strict); + } + + /** @phpstan-ignore-next-line */ + return $this->keyBy('id')->values(); + } + + /** + * Returns a new collection with only the elements with the specified keys. + * + * If `$keys` is an integer or array of integers, a collection of elements with the same IDs will be returned. + * + * @param Enumerable|array|string|int|null $keys + * @return static + */ + public function only($keys) + { + if ($keys === null) { + /** @phpstan-ignore-next-line */ + return new static($this->items); + } + + if ($keys instanceof Enumerable) { + $keys = $keys->toArray(); + } elseif (is_scalar($keys)) { + $keys = [$keys]; + } + + if (!ArrayHelper::isNumeric($keys)) { + return parent::only($keys); + } + + $keys = array_flip($keys); + $elements = array_filter($this->items, fn(ElementInterface $element) => isset($keys[$element->id])); + /** @phpstan-ignore-next-line */ + return new static(array_values($elements)); + } + + /** + * Returns a new collection with all elements except those with the specified keys. + * + * If `$keys` is an integer or array of integers, a collection of elements without the same IDs will be returned. + * + * @param Enumerable|array|string|int|null $keys + * @return static + */ + public function except($keys) + { + if ($keys === null) { + /** @phpstan-ignore-next-line */ + return new static($this->items); + } + + if ($keys instanceof Enumerable) { + $keys = $keys->toArray(); + } elseif (is_scalar($keys)) { + $keys = [$keys]; + } + + if (!ArrayHelper::isNumeric($keys)) { + return parent::except($keys); + } + + $keys = array_flip($keys); + $elements = array_filter($this->items, fn(ElementInterface $element) => !isset($keys[$element->id])); + /** @phpstan-ignore-next-line */ + return new static(array_values($elements)); + } + + // The following methods are intercepted to always return base collections. + // ------------------------------------------------------------------------- + + /** + * @inheritdoc + * @return Collection + */ + public function countBy($countBy = null) + { + return $this->toBase()->countBy($countBy); + } + + /** + * @inheritdoc + * @return Collection + */ + public function collapse() + { + return $this->toBase()->collapse(); + } + + /** + * @inheritdoc + * @param int|float $depth + * @return Collection + */ + public function flatten($depth = INF) + { + return $this->toBase()->flatten($depth); + } + + /** + * @inheritdoc + * @throws NotSupportedException + */ + public function flip() + { + throw new NotSupportedException('Not possible to flip element collections.'); + } + + /** + * @inheritdoc + * @return Collection + */ + public function keys() + { + return $this->toBase()->keys(); + } + + /** + * @inheritdoc + * @return Collection + */ + public function pad($size, $value) + { + return $this->toBase()->pad($size, $value); + } + + /** + * @inheritdoc + * @return Collection + */ + public function pluck($value, $key = null) + { + return $this->toBase()->pluck($value, $key); + } + + /** + * @inheritdoc + * @return Collection + */ + public function zip($items) + { + return $this->toBase()->zip(...func_get_args()); + } } diff --git a/tests/unit/elements/ElementCollectionTest.php b/tests/unit/elements/ElementCollectionTest.php new file mode 100644 index 00000000000..58cf76ad9fb --- /dev/null +++ b/tests/unit/elements/ElementCollectionTest.php @@ -0,0 +1,198 @@ + + * @since 4.10.0 + */ +class ElementCollectionTest extends TestCase +{ + public function _fixtures(): array + { + return [ + 'entries' => [ + 'class' => EntryFixture::class, + ], + ]; + } + + public function testFind(): void + { + $collection = Entry::find()->collect(); + self::assertInstanceOf(ElementCollection::class, $collection); + $first = $collection->first(); + self::assertInstanceOf(Entry::class, $first); + self::assertSame($first, $collection->find($first)); + self::assertNull($collection->find(User::find()->one())); + self::assertSame([$first], $collection->find([$first->id])->all()); + self::assertTrue($collection->find([-1])->isEmpty()); + self::assertSame($first, $collection->find($first->id)); + self::assertNull($collection->find(-1)); + } + + public function testWith(): void + { + $collection = Entry::find()->limit(1)->collect(); + self::assertFalse($collection->first()->hasEagerLoadedElements('foo')); + $collection->with('foo'); + self::assertTrue($collection->first()->hasEagerLoadedElements('foo')); + } + + public function testContains(): void + { + $collection = Entry::find()->collect(); + self::assertInstanceOf(ElementCollection::class, $collection); + self::assertTrue($collection->contains('title', 'Theories of life')); + self::assertTrue($collection->contains(fn(Entry $entry) => $entry->title === 'Theories of life')); + self::assertFalse($collection->contains(fn(Entry $entry) => false)); + $first = $collection->first(); + self::assertInstanceOf(Entry::class, $first); + self::assertTrue($collection->contains($first)); + self::assertFalse($collection->contains(User::find()->one())); + self::assertTrue($collection->contains($first->id)); + self::assertFalse($collection->contains(-1)); + self::assertFalse($collection->contains('title')); + } + + public function testIds(): void + { + $collection = Entry::find()->collect(); + self::assertInstanceOf(ElementCollection::class, $collection); + $ids = $collection->map(fn(Entry $entry) => $entry->id)->all(); + self::assertSame($ids, $collection->ids()->all()); + } + + public function testMerge(): void + { + /** @var ElementCollection $collection */ + $collection = Entry::find()->collect(); + self::assertInstanceOf(ElementCollection::class, $collection); + $first = $collection->first(); + self::assertInstanceOf(Entry::class, $first); + $user = User::find()->one(); + self::assertInstanceOf(User::class, $user); + $merged = $collection->merge([$user]); + self::assertTrue($merged->contains($first)); + self::assertTrue($merged->contains($user)); + self::assertEquals($collection->count() + 1, $merged->count()); + } + + public function testMap(): void + { + $collection = Entry::find()->collect(); + self::assertInstanceOf(ElementCollection::class, $collection); + $mapped = $collection->map(fn(Entry $entry) => new Entry()); + self::assertInstanceOf(ElementCollection::class, $mapped); + $mapped = $collection->map(fn(Entry $entry) => $entry->id); + self::assertSame(Collection::class, $mapped::class); + } + + public function testMapWithKeys(): void + { + $collection = Entry::find()->collect(); + self::assertInstanceOf(ElementCollection::class, $collection); + $mapped = $collection->mapWithKeys(fn(Entry $entry, int|string $key) => [$entry->id => new Entry()]); + self::assertInstanceOf(ElementCollection::class, $mapped); + $mapped = $collection->mapWithKeys(fn(Entry $entry, int|string $key) => [$entry->id => $entry->id]); + self::assertInstanceOf(Collection::class, $mapped); + self::assertNotInstanceOf(ElementCollection::class, $mapped); + } + + public function testFresh(): void + { + $collection = Entry::find()->collect(); + self::assertInstanceOf(ElementCollection::class, $collection); + $collection->each(function(Entry $entry) { + $entry->title .= 'edit'; + }); + self::assertFalse($collection->contains(fn(Entry $entry) => !str_ends_with($entry->title, 'edit'))); + $fresh = $collection->fresh(); + self::assertSame($collection->count(), $fresh->count()); + self::assertTrue($fresh->contains(fn(Entry $entry) => !str_ends_with($entry->title, 'edit'))); + } + + public function testDiff(): void + { + $collection1 = Entry::find()->limit(4)->collect(); + self::assertInstanceOf(ElementCollection::class, $collection1); + self::assertSame(4, $collection1->count()); + $collection2 = Entry::find()->offset(3)->collect(); + self::assertInstanceOf(ElementCollection::class, $collection2); + self::assertTrue($collection2->isNotEmpty()); + $diff = $collection1->diff($collection2->all()); + self::assertSame(3, $diff->count()); + } + + public function testIntersect(): void + { + $collection1 = Entry::find()->limit(4)->collect(); + self::assertInstanceOf(ElementCollection::class, $collection1); + self::assertSame(4, $collection1->count()); + $collection2 = Entry::find()->offset(3)->collect(); + self::assertInstanceOf(ElementCollection::class, $collection2); + self::assertTrue($collection2->isNotEmpty()); + $intersect = $collection1->intersect($collection2->all()); + self::assertSame(1, $intersect->count()); + } + + public function testUnique(): void + { + $collection = Entry::find()->limit(4)->collect(); + self::assertInstanceOf(ElementCollection::class, $collection); + $count = $collection->count(); + $collection->push(...$collection->all()); + self::assertSame($count * 2, $collection->count()); + $unique = $collection->unique(); + self::assertSame($count, $unique->count()); + } + + public function testOnly(): void + { + $collection = Entry::find()->collect(); + self::assertInstanceOf(ElementCollection::class, $collection); + self::assertNotEquals(1, $collection->count()); + $first = $collection->first(); + self::assertInstanceOf(Entry::class, $first); + self::assertEquals(1, $collection->only([$first->id])->count()); + self::assertEquals(1, $collection->only($first->id)->count()); + } + + public function testExcept(): void + { + $collection = Entry::find()->collect(); + self::assertInstanceOf(ElementCollection::class, $collection); + $count = $collection->count(); + $first = $collection->first(); + self::assertInstanceOf(Entry::class, $first); + self::assertEquals($count - 1, $collection->except([$first->id])->count()); + self::assertEquals($count - 1, $collection->except($first->id)->count()); + } + + public function testBaseMethods(): void + { + $collection = Entry::find()->collect(); + self::assertInstanceOf(ElementCollection::class, $collection); + self::assertSame(Collection::class, get_class($collection->countBy(fn(Entry $entry) => $entry->sectionId))); + self::assertSame(Collection::class, get_class($collection->collapse())); + self::assertSame(Collection::class, get_class($collection->flatten(1))); + self::assertSame(Collection::class, get_class($collection->keys())); + self::assertSame(Collection::class, get_class($collection->pad(100, null))); + self::assertSame(Collection::class, get_class($collection->pluck('title'))); + self::assertSame(Collection::class, get_class($collection->zip($collection->ids()))); + } +}