decor-6-site/kirby/src/Cms/LazyCollection.php
2026-04-07 18:09:15 +02:00

551 lines
12 KiB
PHP

<?php
namespace Kirby\Cms;
use Closure;
use Iterator;
use Kirby\Exception\LogicException;
/**
* The LazyCollection class is a variant of the CMS
* Collection that is only initialized with keys for
* each collection element or without any data.
* Collection elements and their values (= objects)
* are loaded and initialized lazily when they are
* first used.
*
* You can use LazyCollection in two ways:
* 1. Initialize with keys only (values are `null`),
* define `hydrateElement` method that initializes
* an element dynamically.
* 2. Option 1, but also don't initialize any keys,
* set `$initialized` prop to `false` and define
* `initialize` method that defines which keys
* are available.
*
* @package Kirby Cms
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @template TValue
* @extends \Kirby\Cms\Collection<TValue>
*/
abstract class LazyCollection extends Collection
{
/**
* Flag that tells whether hydration has been
* completed for all collection elements;
* this is used to increase performance
*/
protected bool $hydrated = false;
/**
* Flag that tells whether all possible collection
* items have been loaded (only relevant in lazy
* initialization mode)
*/
protected bool $initialized = true;
/**
* Temporary auto-hydration whenever a collection
* method is called; some methods may not need raw
* access to all collection data, so performance
* will be improved if methods call initialization
* or hydration themselves only if they need it
* @deprecated
* @todo Remove this in v6
*/
public function __call(string $key, $arguments)
{
$this->hydrate();
return parent::__call($key, $arguments);
}
/**
* Low-level getter for elements
*
* @return TValue|null
*/
public function __get(string $key)
{
$element = parent::__get($key);
// `$element === null` could mean "element does not exist"
// or "element found but not hydrated"
if (
$element === null &&
(array_key_exists($key, $this->data) || $this->initialized === false)
) {
return $this->hydrateElement($key);
}
return $element;
}
/**
* Low-level element remover
*/
public function __unset(string $key)
{
// first initialize, otherwise a later initialization
// might bring back the element that was unset
$this->initialize();
return parent::__unset($key);
}
/**
* Creates chunks of the same size.
* The last chunk may be smaller
*
* @param int $size Number of elements per chunk
* @return static A new collection with an element for each chunk and
* a sub collection in each chunk
*/
public function chunk(int $size): static
{
// chunking at least requires the collection structure
$this->initialize();
return parent::chunk($size);
}
/**
* Counts all elements
*/
public function count(): int
{
$this->initialize();
return parent::count();
}
/**
* Returns the current element
* @deprecated
* @todo Remove in v6
*
* @return TValue
*/
public function current(): mixed
{
$current = parent::current();
// `$current === null` could mean "empty collection"
// or "element found but not hydrated"
if ($current === null && $key = $this->key()) {
return $this->hydrateElement($key);
}
return $current;
}
/**
* Clone and remove all elements from the collection
*/
public function empty(): static
{
$empty = parent::empty();
// prevent new collection from initializing its
// elements into the now empty collection
// (relevant when emptying a collection that
// has not been (fully) initialized yet)
$empty->initialized = true;
return $empty;
}
/**
* Find one or multiple elements by id
*
* @param string ...$keys
* @return TValue|static
*/
public function find(...$keys)
{
$result = parent::find(...$keys);
// when the result is a cloned collection (multiple keys),
// mark it as initialized to prevent it from initializing
// all of its elements again after we filtered it above
// (relevant when finding elements in a collection that
// has not been (fully) initialized yet)
if ($result instanceof static && $result !== $this) {
$result->initialized = true;
}
return $result;
}
/**
* Returns the elements in reverse order
*/
public function flip(): static
{
// flipping at least requires the collection structure
$this->initialize();
return parent::flip();
}
/**
* Filters elements by one of the
* predefined filter methods, by a
* custom filter function or an array of filters
*/
public function filter(string|array|Closure $field, ...$args): static
{
// to filter through values, we need all values present
$this->hydrate();
return parent::filter($field, ...$args);
}
/**
* Returns the first element
*
* @return TValue
*/
public function first()
{
// returning a specific offset requires the collection structure
$this->initialize();
$first = parent::first();
// `$first === null` could mean "empty collection"
// or "element found but not hydrated"
if ($first === null && $key = array_key_first($this->data)) {
return $this->hydrateElement($key);
}
return $first;
}
/**
* Returns an iterator for the elements
* @return \Iterator<TKey, TValue>
*/
public function getIterator(): Iterator
{
// ensure we are looping over all possible elements
$this->initialize();
foreach ($this->data as $key => $value) {
if ($value === null) {
$value = $this->hydrateElement($key);
}
yield $key => $value;
}
}
/**
* Checks by key if an element is included
* @param TKey $key
*/
public function has(mixed $key): bool
{
$this->initialize();
return parent::has($key);
}
/**
* Ensures that all collection elements are loaded,
* essentially converting the lazy collection into a
* normal collection
*/
public function hydrate(): void
{
// first ensure all keys are initialized
$this->initialize();
// skip another hydration loop if no longer needed
if ($this->hydrated === true) {
return;
}
foreach ($this->data as $key => $value) {
if ($value === null) {
$this->hydrateElement($key);
}
}
$this->hydrated = true;
}
/**
* Loads a collection element, sets it in `$this->data[$key]`
* and returns the hydrated object value (or `null` if the
* element does not exist in the collection); to be
* implemented in each specific collection
*/
abstract protected function hydrateElement(string $key): object|null;
/**
* Ensures that the keys for all valid collection elements
* are loaded in the `$data` array and sets `$initialized`
* to `true` afterwards; to be implemented in each collection
* that wants to use lazy initialization; be sure to keep
* existing `$data` values and not overwrite the entire array
*/
public function initialize(): void
{
if ($this->initialized === true) {
return;
}
throw new LogicException(static::class . ' class does not implement `initialize()` method that is required for lazy initialization'); // @codeCoverageIgnore
}
/**
* Returns an array of all keys
*/
public function keys(): array
{
// ensure we are returning all possible keys
$this->initialize();
return parent::keys();
}
/**
* Tries to find the key for the given element
*
* @param TValue $needle the element to search for
* @return int|string|false the name of the key or false
*/
public function keyOf(mixed $needle): int|string|false
{
// quick lookup without having to hydrate the collection
// (keys in CMS collections are the object IDs)
if (
is_object($needle) === true &&
method_exists($needle, 'id') === true
) {
return $needle->id();
}
$this->hydrate();
return parent::keyOf($needle);
}
/**
* Returns the last element
*
* @return TValue
*/
public function last()
{
// returning a specific offset requires the collection structure
$this->initialize();
$last = parent::last();
// `$last === null` could mean "empty collection"
// or "element found but not hydrated"
if ($last === null && $key = array_key_last($this->data)) {
return $this->hydrateElement($key);
}
return $last;
}
/**
* Map a function to each element
*
* @return $this
*/
public function map(callable $callback): static
{
// to map a function, we need all values present
$this->hydrate();
return parent::map($callback);
}
/**
* Moves the cursor to the next element
* and returns it
* @deprecated
* @todo Remove in v6
*
* @return TValue
*/
public function next(): mixed
{
$this->initialize();
$next = parent::next();
// `$next === null` could mean "empty collection"
// or "element found but not hydrated"
if ($next === null && $key = $this->key()) {
return $this->hydrateElement($key);
}
return $next;
}
/**
* Returns the nth element from the collection
*
* @return TValue|null
*/
public function nth(int $n)
{
// returning a specific offset requires the collection structure
$this->initialize();
$nth = parent::nth($n);
// `$nth === null` could mean "empty collection"
// or "element found but not hydrated"
if ($nth === null) {
$key = array_keys($this->data)[$n] ?? null;
if (is_string($key) === true) {
return $this->hydrateElement($key);
}
}
return $nth;
}
/**
* Prepends an element to the data array
*
* ```php
* $collection->prepend('key', $value);
* $collection->prepend($value);
* ```
*
* @param string|TValue ...$args
* @return $this
*/
public function prepend(...$args): static
{
// prepending to an uninitialized collection would
// destroy the order on later initialization
$this->initialize();
return parent::prepend(...$args);
}
/**
* Moves the cursor to the previous element
* and returns it
* @deprecated
* @todo Remove in v6
*
* @return TValue
*/
public function prev(): mixed
{
$this->initialize();
$prev = parent::prev();
// `$prev === null` could mean "empty collection"
// or "element found but not hydrated"
if ($prev === null && $key = $this->key()) {
return $this->hydrateElement($key);
}
return $prev;
}
/**
* Returns a new collection consisting of random elements,
* from the original collection, shuffled or ordered
*/
public function random(int $count = 1, bool $shuffle = false): static
{
// picking random elements at least requires the collection structure
$this->initialize();
return parent::random($count, $shuffle);
}
/**
* Shuffle all elements
*/
public function shuffle(): static
{
// shuffling at least requires the collection structure
$this->initialize();
return parent::shuffle();
}
/**
* Returns a slice of the object
*
* @param int $offset The optional index to start the slice from
* @param int|null $limit The optional number of elements to return
* @return $this|static
* @psalm-return ($offset is 0 && $limit is null ? $this : static)
*/
public function slice(
int $offset = 0,
int|null $limit = null
): static {
// returning a specific subset requires the collection structure
$this->initialize();
return parent::slice($offset, $limit);
}
/**
* Sorts the elements by any number of fields
*
* ```php
* $collection->sort('fieldName');
* $collection->sort('fieldName', 'desc');
* $collection->sort('fieldName', 'asc', SORT_REGULAR);
* $collection->sort(fn ($a) => ...);
* ```
*
* @param string|callable $field Field name or value callback to sort by
* @param string|null $direction asc or desc
* @param int|null $method The sort flag, SORT_REGULAR, SORT_NUMERIC etc.
* @return $this|static
*/
public function sort(...$args): static
{
// to sort through values, we need all values present
$this->hydrate();
return parent::sort(...$args);
}
/**
* Converts all objects in the collection
* to an array. This can also take a callback
* function to further modify the array result.
*/
public function toArray(Closure|null $map = null): array
{
// to export an array, we need all values present
$this->hydrate();
return parent::toArray($map);
}
/**
* Returns a non-associative array
* with all values. If a mapping Closure is passed,
* all values are processed by the Closure.
*/
public function values(Closure|null $map = null): array
{
// to export an array, we need all values present
$this->hydrate();
return parent::values($map);
}
}