Skip to content

Instantly share code, notes, and snippets.

@WinterSilence
Last active October 10, 2021 03:53
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save WinterSilence/b1884de728d3023591bf6e0c53181742 to your computer and use it in GitHub Desktop.
Save WinterSilence/b1884de728d3023591bf6e0c53181742 to your computer and use it in GitHub Desktop.
Yii2 Collection
<?php
namespace app\core;
use ArrayAccess;
use Closure;
use Countable;
use Iterator;
use IteratorAggregate;
use yii\base\ArrayAccessTrait;
use yii\base\Component;
use yii\base\InvalidCallException;
use yii\data\Pagination;
use yii\helpers\ArrayHelper;
use function array_filter;
use function array_flip;
use function array_keys;
use function array_map;
use function array_merge;
use function array_reduce;
use function array_reverse;
use function array_slice;
use function array_values;
use function arsort;
use function asort;
use function call_user_func_array;
use function is_object;
use function iterator_apply;
use function iterator_to_array;
use function krsort;
use function ksort;
use function max;
use function natcasesort;
use function natsort;
use const ARRAY_FILTER_USE_BOTH;
use const SORT_ASC;
use const SORT_REGULAR;
/**
* Collection is a container for a set of data.
*
* It provides methods for transforming and filtering the data as well as sorting methods, which can be applied
* using a chained interface. All these operations will return a new collection containing the modified data
* keeping the original collection as it was as long as containing objects state is not changed.
*
* ```php
* $collection = new Collection([1, 2, 3]);
* echo $collection->map(function($i) { // [2, 3, 4]
* return $i + 1;
* })->filter(function($i) { // [2, 3]
* return $i < 4;
* })->sum(); // 5
* ```
*
* The collection implements [[ArrayAccessTrait]], so you can access it in the same way you use a PHP array.
* A collection however is read-only, you can not manipulate single data.
*
* ```php
* $collection = new Collection([1, 2, 3]);
* echo $collection[1]; // 2
* foreach($collection as $item) {
* echo $item . ' ';
* } // will print 1 2 3
* ```
*
* Note: The original collection will not be changed, a new collection with modified data is returned.
*
* @property-read array $data The data contained in this collection.
*/
class Collection extends Component implements ArrayAccess, Countable, IteratorAggregate
{
use ArrayAccessTrait;
/**
* @var array The data contained in this collection.
*/
private array $data;
/**
* Create new instance.
*
* @param array $data the collection data
* @param array $config collection configuration
*/
public function __construct(array $data, array $config = [])
{
$this->setData($data);
parent::__construct($config);
}
/**
* Returns by reference data contained in collection.
*
* @return array
*/
public function &getData(): array
{
return $this->data;
}
/**
* Sets new collection data.
*
* @param array $data collection data
* @return void
* @internal
*/
protected function setData(array $data): void
{
$this->data = $data;
}
/**
* Whether the collection is empty.
*
* @return bool
*/
public function isEmpty(): bool
{
return $this->count() === 0;
}
/**
* Apply reduce operation to data from the collection.
*
* @param callable $callable the callback to compute the reduce value, syntax:
* `function (mixed $carry, object $value): mixed`
* @param mixed $initialValue initial value to pass to the callback on first item.
* @return mixed the result of the reduce operation.
*/
public function reduce(callable $callable, $initialValue = null)
{
return array_reduce($this->getData(), $callable, $initialValue);
}
/**
* Merges all sub arrays into one array.
*
* ```php
* $collection = new Collection([[1,2], [3,4], [5,6]]);
* $collapsed = $collection->collapse(); // [1,2,3,4,5,6];
* ```
*
* @return static a new collection containing the collapsed array result.
*/
public function collapse(): self
{
$clone = clone $this;
$clone->setData($clone->reduce('array_merge', []));
return $clone;
}
/**
* Apply callback to all data in the collection.
*
* @param callable $callable the callback function to apply, syntax: `function(object $value): mixed`.
* @return static a new collection with data returned from the callback.
*/
public function map(callable $callable): self
{
$clone = clone $this;
$clone->setData(array_map($callable, $clone->getData()));
return $clone;
}
/**
* Apply callback to all data in the collection and return a new collection containing all data returned by
* the callback.
*
* @param callable $callable the callback function to apply, syntax: `function (object $value): array`
* @return static a new collection with data returned from the callback.
*/
public function flatMap(callable $callable): self
{
return $this->map($callable)->collapse();
}
/**
* Filter data from the collection.
*
* @param callable $callable the callback to decide which data to remove, syntax:
* `function (object $value, string|int $key): bool`
* @return static a new collection containing the filtered data.
*/
public function filter(callable $callable): self
{
$clone = clone $this;
$clone->setData(array_filter($clone->getData(), $callable, ARRAY_FILTER_USE_BOTH));
return $clone;
}
/**
* Calculate the sum of a field of the data in the collection.
*
* @param string|string[]|Closure|null $field the name of the field to calculate.
* @return mixed the calculated sum.
*/
public function sum($field = null)
{
return $this->reduce(
/**
* @param mixed $carry
* @param mixed $value
* @return mixed
* @throws \Exception
*/
static function ($carry, $value) use ($field) {
return $carry + (empty($field) ? $value : ArrayHelper::getValue($value, $field, 0));
},
0
);
}
/**
* Calculate the maximum value of a field of the data in the collection.
*
* @param string|string[]|Closure|null $field the name of the field to calculate.
* @return mixed the calculated maximum value. 0 if the collection is empty.
*/
public function max($field = null)
{
return $this->reduce(
/**
* @param mixed $carry
* @param mixed $value
* @return mixed
* @throws \Exception
*/
static function ($carry, $value) use ($field) {
$value = empty($field) ? $value : ArrayHelper::getValue($value, $field, 0);
return max($value, $carry);
}
);
}
/**
* Calculate the minimum value of a field of the data in the collection.
*
* @param string|string[]|Closure|null $field the name of the field to calculate.
* @return mixed the calculated minimum value. 0 if the collection is empty.
*/
public function min($field = null)
{
return $this->reduce(
/**
* @param mixed $carry
* @param mixed $value
* @return mixed
* @throws \Exception
*/
static function ($carry, $value) use ($field) {
$value = empty($field) ? $value : ArrayHelper::getValue($value, $field, 0);
return ($carry === null || $value < $carry) ? $value : $carry;
}
);
}
/**
* Sort collection data by value.
*
* If the collection values are not scalar types, use [[sortBy()]] instead.
*
* @param bool $ascendingOrder if true, sort in ascending order, else in descending order.
* @param int $sortFlag type of comparison, either `SORT_REGULAR`, `SORT_NUMERIC`, `SORT_STRING`,
* `SORT_LOCALE_STRING`, `SORT_NATURAL` or `SORT_FLAG_CASE`.
* @return static a new collection containing the sorted data.
* @see https://www.php.net/manual/en/function.sort#refsect1-function.sort-parameters
*/
public function sort(bool $ascendingOrder = true, int $sortFlag = SORT_REGULAR): self
{
$clone = clone $this;
if ($ascendingOrder) {
asort($clone->getData(), $sortFlag);
} else {
arsort($clone->getData(), $sortFlag);
}
return $clone;
}
/**
* Sort collection data by key.
*
* @param bool $ascendingOrder if true, sort in ascending order, else in descending order.
* @param int $sortFlag type of comparison, either `SORT_REGULAR`, `SORT_NUMERIC`, `SORT_STRING`,
* `SORT_LOCALE_STRING`, `SORT_NATURAL` or `SORT_FLAG_CASE`.
* @return static a new collection containing the sorted data.
* @see https://www.php.net/manual/en/function.sort#refsect1-function.sort-parameters
*/
public function sortByKey(bool $ascendingOrder = true, int $sortFlag = SORT_REGULAR): self
{
$clone = clone $this;
if ($ascendingOrder) {
ksort($clone->getData(), $sortFlag);
} else {
krsort($clone->getData(), $sortFlag);
}
return $clone;
}
/**
* Sort collection data by value using natural sort comparsion.
*
* If the collection values are not scalar types, use [[sortBy()]] instead.
*
* @param bool $caseSensitive whether comparison should be done in a case-sensitive manner. Defaults to `false`.
* @return static a new collection containing the sorted data.
*/
public function sortNatural(bool $caseSensitive = false): self
{
$clone = clone $this;
if ($caseSensitive) {
natsort($clone->getData());
} else {
natcasesort($clone->getData());
}
return $clone;
}
/**
* Sort collection data by one or multiple values.
*
* Note that keys will not be preserved by this method.
*
* @param string|int|Closure|array $key the key(s) to be sorted by. This refers to a key name of the sub-array
* elements, a property name of the objects, or an anonymous function returning the values for comparison
* purpose. The anonymous function signature should be: `function ($item)`. To sort by multiple keys, provide
* an array of keys here.
* @param int|int[] $direction the sorting direction. It can be either `SORT_ASC` or `SORT_DESC`. When sorting by
* multiple keys with different sorting directions, use an array of sorting directions.
* @param int|int[] $sortFlag the PHP sort flag. Valid values include `SORT_REGULAR`, `SORT_NUMERIC`,
* `SORT_STRING`, `SORT_LOCALE_STRING`, `SORT_NATURAL` and `SORT_FLAG_CASE`. When sorting by multiple keys with
* different sort flags, use an array of sort flags.
* @return static a new collection containing the sorted data.
* @throws \yii\base\InvalidArgumentException if the `$direction` or $sortFlag parameters do not have correct number of
* elements as that of `$key`.
* @see [[ArrayHelper::multisort()]]
*/
public function sortBy($key, $direction = SORT_ASC, $sortFlag = SORT_REGULAR): self
{
$clone = clone $this;
ArrayHelper::multisort($clone->getData(), $key, $direction, $sortFlag);
return $clone;
}
/**
* Reverse the order of data.
*
* @param bool $preserveKeys if true, numeric keys are preserved. Non-numeric keys are not affected by this setting
* and will always be preserved.
* @return static a new collection containing the data in reverse order.
*/
public function reverse(bool $preserveKeys = true): self
{
$clone = clone $this;
$clone->setData(array_reverse($this->getData(), $preserveKeys));
return $clone;
}
/**
* Return data without keys.
*
* @return static a new collection containing the values of this collections data.
*/
public function values(): self
{
$clone = clone $this;
$clone->setData(array_values($this->getData()));
return $clone;
}
/**
* Return keys of all collection data.
*
* @return array
*/
public function keys(): array
{
return array_keys($this->getData());
}
/**
* Flip keys and values of all collection data.
*
* @return static a new collection containing the data of this collections flipped by key and value.
*/
public function flip(): self
{
$clone = clone $this;
$clone->setData(array_flip($this->getData()));
return $clone;
}
/**
* Merge two collections or this collection with an array.
*
* @param array|\Traversable $collection the collection or array to merge with.
* @return static a new collection containing the merged data.
*/
public function merge($collection): self
{
if (is_object($collection)) {
$collection = $collection instanceof self ? $collection->getData() : iterator_to_array($collection);
}
$clone = clone $this;
$clone->setData(array_merge($clone->getData(), $collection));
return $clone;
}
/**
* Convert collection data by selecting a new key and a new value for each item.
*
* Builds a map (key-value pairs) from a multidimensional array or an array of objects.
* The `$from` and `$to` parameters specify the key names or property names to set up the map.
*
* @param string|Closure $from the field of the item to use as the key of the created map. This can be a closure
* that returns such a value.
* @param string|Closure $to the field of the item to use as the value of the created map. This can be a closure
* that returns such a value.
* @return static a new collection containing the mapped data.
*/
public function remap($from, $to): self
{
$clone = clone $this;
$clone->setData(ArrayHelper::map($clone->getData(), $from, $to));
return $clone;
}
/**
* Assign a new key to each item in the collection.
*
* @param string|Closure $key the field of the item to use as the new key. This can be a closure that returns such
* a value.
* @return static a new collection containing the newly index data.
*/
public function indexBy($key): self
{
return $this->remap(
$key,
static function ($value) {
return $value;
}
);
}
/**
* Group data by a specified value.
*
* @param string|Closure $groupField the field of the item to use as the group value. This can be a closure that
* returns such a value.
* @param bool $preserveKeys whether to preserve item keys in the groups. Defaults to `true`.
* @return static a new collection containing the grouped data.
* @throws \Exception
* @see [[ArrayHelper::getValue()]]
*/
public function groupBy($groupField, bool $preserveKeys = true): self
{
$clone = clone $this;
$data = [];
if ($preserveKeys) {
foreach ($clone->getData() as $key => $value) {
$data[ArrayHelper::getValue($value, $groupField)][$key] = $value;
}
} else {
foreach ($clone->getData() as $value) {
$data[ArrayHelper::getValue($value, $groupField)][] = $value;
}
}
$clone->setData($data);
return $clone;
}
/**
* Check whether the collection contains a specific item.
*
* @param mixed $item the item to search for. You may also pass a closure that returns a boolean. The closure will
* be called on each item and in case it returns `true`, the item will be considered to be found. In case a
* closure is passed, `$strict` parameter has no effect.
* @param bool $strict whether comparison should be compared strict (`===`) or not (`==`). Defaults to `false`.
* @return bool `true` if the collection contains at least one item that matches, `false` if not.
*/
public function contains($item, bool $strict = false): bool
{
if ($item instanceof Closure) {
foreach ($this->getData() as $i) {
if ($item($i)) {
return true;
}
}
} else {
foreach ($this->getData() as $i) {
if ($strict ? $i === $item : $i == $item) {
return true;
}
}
}
return false;
}
/**
* Remove a specific item from the collection.
*
* @param mixed|Closure $item the item to search for. You may also pass a closure that returns a boolean.
* The closure will be called on each item and in case it returns `true`, the item will be removed. In case
* a closure is passed, `$strict` parameter has no effect.
* @param bool $strict whether comparison should be compared strict (`===`) or not (`==`). Defaults to `false`.
* @return static a new collection containing the filtered data.
* @see [[filter()]]
*/
public function remove($item, bool $strict = false): self
{
if ($item instanceof Closure) {
$func = static function ($i) use ($item) {
return !$item($i);
};
} elseif ($strict) {
$func = static function ($i) use ($item) {
return $i !== $item;
};
} else {
$func = static function ($i) use ($item) {
return $i != $item;
};
}
return $this->filter($func);
}
/**
* Replace a specific item in the collection with another one.
*
* @param mixed $item the item to search for.
* @param mixed $replacement the replacement to insert instead of the item.
* @param bool $strict whether comparison should be compared strict (`===`) or not (`==`). Defaults to `false`.
* @return static a new collection containing the new set of data.
* @see [[map()]]
*/
public function replace($item, $replacement, bool $strict = false): self
{
return $this->map(static function ($i) use ($item, $replacement, $strict) {
if ($strict ? $i === $item : $i == $item) {
return $replacement;
}
return $i;
});
}
/**
* Slice the set of elements by an offset and number of data to return.
*
* @param int|string $offset starting offset for the slice.
* @param int|null $limit the number of elements to return at maximum.
* @param bool $preserveKeys whether to preserve item keys.
* @return static a new collection containing the new set of data.
*/
public function slice($offset, int $limit = null, bool $preserveKeys = true): self
{
$clone = clone $this;
$clone->setData(array_slice($this->getData(), $offset, $limit, $preserveKeys));
return $clone;
}
/**
* Apply `Pagination` to the collection.
*
* ```php
* $collection = new Collection($models);
* $pagination = new Pagination(['totalCount' => $collection->count(), 'pageSize' => 3]);
* // the current page will be determined from request parameters
* $pageData = $collection->paginate($pagination)->getData();
* ```
*
* @param Pagination $pagination the pagination object to retrieve page information from.
* @return static a new collection containing the data for the current page.
* @see Pagination
*/
public function paginate(Pagination $pagination, bool $preserveKeys = false): self
{
return $this->slice($pagination->getOffset(), $pagination->getLimit() ?: null, $preserveKeys);
}
/**
* Calls given method for current object in an iterator.
*
* @param \Iterator $iterator the `Iterator` instance to iterate over
* @param string $method the method called on each object
* @param array $arguments an array of arguments, each element is passed to method as separate argument
* @param array $results the execution results
* @return bool
* @see [[apply()]]
*/
protected function applyCallback(Iterator $iterator, string $method, array $arguments, array &$results): bool
{
$results[$iterator->key()] = call_user_func_array([$iterator->current(), $method], $arguments);
return true;
}
/**
* Calls given method for each objects in an iterator.
*
* Note: this method works only with object collection!
*
* @param string $method the method called on each object
* @param array $arguments an array of arguments, each element is passed to method as separate argument
* @param int $iterations the iteration count returning by reference
* @return array the execution results
*/
public function apply(string $method, array $arguments = [], int &$iterations = 0): array
{
$results = [];
$clone = clone $this;
$iterator = $clone->getIterator();
$iterations = iterator_apply(
$iterator,
[$this, 'applyCallback'],
[$iterator, $method, $arguments, &$results]
);
return $results;
}
/**
* @inheritdoc
* @throws InvalidCallException Read only collection
*/
public function offsetSet($offset, $item): void
{
throw new InvalidCallException('Read only collection');
}
/**
* Clones collection objects.
*
* @return void
*/
public function __clone()
{
parent::__clone();
foreach ($this->getData() as &$value) {
if (is_object($value)) {
$value = clone $value;
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment