xiaowang/kirby/src/Toolkit/A.php

984 lines
22 KiB
PHP
Raw Normal View History

2021-10-29 18:05:46 +02:00
<?php
namespace Kirby\Toolkit;
2022-12-19 16:26:24 +01:00
use Closure;
2021-10-29 18:05:46 +02:00
use Exception;
2023-04-14 16:30:28 +02:00
use InvalidArgumentException;
2021-10-29 18:05:46 +02:00
/**
* The `A` class provides a set of handy methods
* to simplify array handling and make it more
* consistent. The class contains methods for
* fetching elements from arrays, merging and
* sorting or shuffling arrays.
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
2022-03-22 15:39:39 +01:00
* @copyright Bastian Allgeier
2021-10-29 18:05:46 +02:00
* @license https://opensource.org/licenses/MIT
*/
class A
{
2022-08-31 16:08:03 +02:00
/**
* Appends the given array
*/
public static function append(array $array, array $append): array
{
2022-12-19 16:26:24 +01:00
return static::merge($array, $append, A::MERGE_APPEND);
2022-08-31 16:08:03 +02:00
}
/**
* Recursively loops through the array and
* resolves any item defined as `Closure`,
* applying the passed parameters
* @since 3.5.6
*
* @param mixed ...$args Parameters to pass to the closures
*/
public static function apply(array $array, ...$args): array
{
array_walk_recursive($array, function (&$item) use ($args) {
2022-12-19 16:26:24 +01:00
if ($item instanceof Closure) {
2022-08-31 16:08:03 +02:00
$item = $item(...$args);
}
});
return $array;
}
2023-04-14 16:30:28 +02:00
/**
* Counts the number of elements in an array
*
* @param array $array
* @return int
*/
public static function count(array $array): int
{
return count($array);
}
2024-12-20 12:37:35 +01:00
/**
* Checks if every element in the array passes the test
*
* <code>
* $array = [1, 30, 39, 29, 10, 13];
*
* $isBelowThreshold = fn($value) => $value < 40;
* echo A::every($array, $isBelowThreshold) ? 'true' : 'false';
* // output: 'true'
*
* $isIntegerKey = fn($value, $key) => is_int($key);
* echo A::every($array, $isIntegerKey) ? 'true' : 'false';
* // output: 'true'
* </code>
*
* @since 3.9.8
* @param array $array
* @param callable(mixed $value, int|string $key, array $array):bool $test
* @return bool
*/
public static function every(array $array, callable $test): bool
{
foreach ($array as $key => $value) {
if (!$test($value, $key, $array)) {
return false;
}
}
return true;
}
/**
* Finds the first element matching the given callback
*
* <code>
* $array = [1, 30, 39, 29, 10, 13];
*
* $isAboveThreshold = fn($value) => $value > 30;
* echo A::find($array, $isAboveThreshold);
* // output: '39'
*
* $array = [
* 'cat' => 'miao',
* 'cow' => 'moo',
* 'colibri' => 'humm',
* 'dog' => 'wuff',
* 'chicken' => 'cluck',
* 'bird' => 'tweet'
* ];
*
* $keyNotStartingWithC = fn($value, $key) => $key[0] !== 'c';
* echo A::find($array, $keyNotStartingWithC);
* // output: 'wuff'
* </code>
*
* @since 3.9.8
* @param array $array
* @param callable(mixed $value, int|string $key, array $array):bool $callback
* @return mixed
*/
public static function find(array $array, callable $callback): mixed
{
foreach ($array as $key => $value) {
if ($callback($value, $key, $array)) {
return $value;
}
}
return null;
}
2022-08-31 16:08:03 +02:00
/**
* Gets an element of an array by key
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* echo A::get($array, 'cat');
* // output: 'miao'
*
* echo A::get($array, 'elephant', 'shut up');
* // output: 'shut up'
*
* $catAndDog = A::get($array, ['cat', 'dog']);
* // result: ['cat' => 'miao', 'dog' => 'wuff'];
* </code>
*
* @param array $array The source array
2022-12-19 16:26:24 +01:00
* @param string|int|array|null $key The key to look for
* @param mixed $default Optional default value, which
* should be returned if no element
* has been found
2022-08-31 16:08:03 +02:00
*/
2022-12-19 16:26:24 +01:00
public static function get(
$array,
string|int|array|null $key,
$default = null
) {
2022-08-31 16:08:03 +02:00
if (is_array($array) === false) {
return $array;
}
// return the entire array if the key is null
if ($key === null) {
return $array;
}
// get an array of keys
if (is_array($key) === true) {
$result = [];
foreach ($key as $k) {
$result[$k] = static::get($array, $k, $default);
}
return $result;
}
if (isset($array[$key]) === true) {
return $array[$key];
}
// extract data from nested array structures using the dot notation
if (strpos($key, '.') !== false) {
$keys = explode('.', $key);
$firstKey = array_shift($keys);
2024-12-20 12:37:35 +01:00
// if the input array also uses dot notation,
// try to find a subset of the $keys
2022-08-31 16:08:03 +02:00
if (isset($array[$firstKey]) === false) {
$currentKey = $firstKey;
while ($innerKey = array_shift($keys)) {
$currentKey .= '.' . $innerKey;
2024-12-20 12:37:35 +01:00
// the element needs to exist and also needs
// to be an array; otherwise we cannot find the
// remaining keys within it (invalid array structure)
2023-04-14 16:30:28 +02:00
if (
isset($array[$currentKey]) === true &&
is_array($array[$currentKey]) === true
) {
2024-12-20 12:37:35 +01:00
// $keys only holds the remaining keys
// that have not been shifted off yet
2023-04-14 16:30:28 +02:00
return static::get(
$array[$currentKey],
implode('.', $keys),
$default
);
2022-08-31 16:08:03 +02:00
}
}
// searching through the full chain of keys wasn't successful
return $default;
}
// if the input array uses a completely nested structure,
// recursively progress layer by layer
if (is_array($array[$firstKey]) === true) {
2023-04-14 16:30:28 +02:00
return static::get(
$array[$firstKey],
implode('.', $keys),
$default
);
2022-08-31 16:08:03 +02:00
}
// the $firstKey element was found, but isn't an array, so we cannot
// find the remaining keys within it (invalid array structure)
return $default;
}
return $default;
}
2023-04-14 16:30:28 +02:00
/**
* Checks if array has a value
*
* @param array $array
* @param mixed $value
* @param bool $strict
* @return bool
*/
public static function has(array $array, $value, bool $strict = false): bool
{
return in_array($value, $array, $strict);
}
2022-08-31 16:08:03 +02:00
/**
2022-12-19 16:26:24 +01:00
* Joins the elements of an array to a string
2022-08-31 16:08:03 +02:00
*/
2022-12-19 16:26:24 +01:00
public static function join(array|string $value, string $separator = ', '): string
2022-08-31 16:08:03 +02:00
{
if (is_string($value) === true) {
return $value;
}
2023-04-14 16:30:28 +02:00
2022-08-31 16:08:03 +02:00
return implode($separator, $value);
}
2023-04-14 16:30:28 +02:00
/**
* Takes an array and makes it associative by an argument.
* If the argument is a callable, it will be used to map the array.
* If it is a string, it will be used as a key to pluck from the array.
*
* <code>
* $array = [['id'=>1], ['id'=>2], ['id'=>3]];
* $keyed = A::keyBy($array, 'id');
*
* // Now you can access the array by the id
* </code>
*
* @param array $array
* @param string|callable $keyBy
* @return array
*/
public static function keyBy(array $array, string|callable $keyBy): array
{
2024-12-20 12:37:35 +01:00
$keys =
is_callable($keyBy) ?
static::map($array, $keyBy) :
static::pluck($array, $keyBy);
2023-04-14 16:30:28 +02:00
if (count($keys) !== count($array)) {
throw new InvalidArgumentException('The "key by" argument must be a valid key or a callable');
}
return array_combine($keys, $array);
}
2022-08-31 16:08:03 +02:00
public const MERGE_OVERWRITE = 0;
public const MERGE_APPEND = 1;
public const MERGE_REPLACE = 2;
/**
* Merges arrays recursively
*
2022-12-19 16:26:24 +01:00
* If last argument is an integer, it defines the
* behavior for elements with numeric keys;
* - A::MERGE_OVERWRITE: elements are overwritten, keys are preserved
* - A::MERGE_APPEND: elements are appended, keys are reset;
* - A::MERGE_REPLACE: non-associative arrays are completely replaced
2022-08-31 16:08:03 +02:00
*/
2022-12-19 16:26:24 +01:00
public static function merge(array|int ...$arrays): array
2022-08-31 16:08:03 +02:00
{
2022-12-19 16:26:24 +01:00
// get mode from parameters
$last = A::last($arrays);
$mode = is_int($last) ? array_pop($arrays) : A::MERGE_APPEND;
// get the first two arrays that should be merged
$merged = array_shift($arrays);
$join = array_shift($arrays);
if (
static::isAssociative($merged) === false &&
$mode === static::MERGE_REPLACE
) {
$merged = $join;
} else {
foreach ($join as $key => $value) {
// append to the merged array, don't overwrite numeric keys
if (
is_int($key) === true &&
$mode === static::MERGE_APPEND
) {
$merged[] = $value;
// recursively merge the two array values
} elseif (
is_array($value) === true &&
isset($merged[$key]) === true &&
is_array($merged[$key]) === true
) {
$merged[$key] = static::merge($merged[$key], $value, $mode);
// simply overwrite with the value from the second array
} else {
$merged[$key] = $value;
}
}
2022-08-31 16:08:03 +02:00
2022-12-19 16:26:24 +01:00
if ($mode === static::MERGE_APPEND) {
// the keys don't make sense anymore, reset them
// array_merge() is the simplest way to renumber
// arrays that have both numeric and string keys;
// besides the keys, nothing changes here
$merged = array_merge($merged, []);
2022-08-31 16:08:03 +02:00
}
}
2022-12-19 16:26:24 +01:00
// if more than two arrays need to be merged, add the result
// as first array and the mode to the end and call the method again
if (count($arrays) > 0) {
array_unshift($arrays, $merged);
array_push($arrays, $mode);
return static::merge(...$arrays);
2022-08-31 16:08:03 +02:00
}
return $merged;
}
/**
* Plucks a single column from an array
*
* <code>
* $array[] = [
* 'id' => 1,
* 'username' => 'homer',
* ];
*
* $array[] = [
* 'id' => 2,
* 'username' => 'marge',
* ];
*
* $array[] = [
* 'id' => 3,
* 'username' => 'lisa',
* ];
*
* var_dump(A::pluck($array, 'username'));
* // result: ['homer', 'marge', 'lisa'];
* </code>
*
* @param array $array The source array
* @param string $key The key name of the column to extract
* @return array The result array with all values
* from that column.
*/
2022-12-19 16:26:24 +01:00
public static function pluck(array $array, string $key): array
2022-08-31 16:08:03 +02:00
{
$output = [];
2023-04-14 16:30:28 +02:00
2022-08-31 16:08:03 +02:00
foreach ($array as $a) {
if (isset($a[$key]) === true) {
$output[] = $a[$key];
}
}
return $output;
}
/**
* Prepends the given array
*/
public static function prepend(array $array, array $prepend): array
{
return $prepend + $array;
}
2023-04-14 16:30:28 +02:00
/**
* Reduce an array to a single value
*
* @param array $array
* @param callable $callback
* @param mixed $initial
* @return mixed
*/
public static function reduce(array $array, callable $callback, $initial = null): mixed
{
return array_reduce($array, $callback, $initial);
}
2022-08-31 16:08:03 +02:00
/**
* Shuffles an array and keeps the keys
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $shuffled = A::shuffle($array);
* // output: [
* // 'dog' => 'wuff',
* // 'cat' => 'miao',
* // 'bird' => 'tweet'
* // ];
* </code>
*
* @param array $array The source array
* @return array The shuffled result array
*/
public static function shuffle(array $array): array
{
$keys = array_keys($array);
$new = [];
shuffle($keys);
// resort the array
foreach ($keys as $key) {
$new[$key] = $array[$key];
}
return $new;
}
2023-04-14 16:30:28 +02:00
/**
* Returns a slice of an array
*
* @param array $array
* @param int $offset
* @param int|null $length
* @param bool $preserveKeys
* @return array
*/
public static function slice(
array $array,
int $offset,
int $length = null,
bool $preserveKeys = false
): array {
return array_slice($array, $offset, $length, $preserveKeys);
}
2024-12-20 12:37:35 +01:00
/**
* Checks if at least one element in the array passes the test
*
* <code>
* $array = [1, 30, 39, 29, 10, 'foo' => 12, 13];
*
* $isAboveThreshold = fn($value) => $value > 30;
* echo A::some($array, $isAboveThreshold) ? 'true' : 'false';
* // output: 'true'
*
* $isStringKey = fn($value, $key) => is_string($key);
* echo A::some($array, $isStringKey) ? 'true' : 'false';
* // output: 'true'
* </code>
*
* @since 3.9.8
* @param array $array
* @param callable(mixed $value, int|string $key, array $array):bool $test
* @return bool
*/
public static function some(array $array, callable $test): bool
{
foreach ($array as $key => $value) {
if ($test($value, $key, $array)) {
return true;
}
}
return false;
}
2023-04-14 16:30:28 +02:00
/**
* Sums an array
*
* @param array $array
* @return int|float
*/
public static function sum(array $array): int|float
{
return array_sum($array);
}
2022-08-31 16:08:03 +02:00
/**
* Returns the first element of an array
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $first = A::first($array);
* // first: 'miao'
* </code>
*
* @param array $array The source array
* @return mixed The first element
*/
public static function first(array $array)
{
return array_shift($array);
}
/**
* Returns the last element of an array
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $last = A::last($array);
* // last: 'tweet'
* </code>
*
* @param array $array The source array
* @return mixed The last element
*/
public static function last(array $array)
{
return array_pop($array);
}
/**
* Returns a number of random elements from an array,
* either in original or shuffled order
*/
public static function random(array $array, int $count = 1, bool $shuffle = false): array
{
if ($shuffle) {
return array_slice(self::shuffle($array), 0, $count);
}
if ($count === 1) {
$key = array_rand($array);
return [$key => $array[$key]];
}
return self::get($array, array_rand($array, $count));
}
/**
* Fills an array up with additional elements to certain amount.
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $result = A::fill($array, 5, 'elephant');
*
* // result: [
* // 'cat',
* // 'dog',
* // 'bird',
* // 'elephant',
* // 'elephant',
* // ];
* </code>
*
* @param array $array The source array
* @param int $limit The number of elements the array should
* contain after filling it up.
* @param mixed $fill The element, which should be used to
2023-04-14 16:30:28 +02:00
* fill the array. If it's a callable, it
* will be called with the current index
2022-08-31 16:08:03 +02:00
* @return array The filled-up result array
*/
public static function fill(array $array, int $limit, $fill = 'placeholder'): array
{
2023-04-14 16:30:28 +02:00
for ($x = count($array); $x < $limit; $x++) {
$array[] = is_callable($fill) ? $fill($x) : $fill;
2022-08-31 16:08:03 +02:00
}
2023-04-14 16:30:28 +02:00
2022-08-31 16:08:03 +02:00
return $array;
}
/**
* A simple wrapper around array_map
* with a sane argument order
* @since 3.6.0
*/
public static function map(array $array, callable $map): array
{
return array_map($map, $array);
}
/**
* Move an array item to a new index
*/
public static function move(array $array, int $from, int $to): array
{
$total = count($array);
if ($from >= $total || $from < 0) {
throw new Exception('Invalid "from" index');
}
if ($to >= $total || $to < 0) {
throw new Exception('Invalid "to" index');
}
// remove the item from the array
$item = array_splice($array, $from, 1);
// inject it at the new position
array_splice($array, $to, 0, $item);
return $array;
}
/**
* Checks for missing elements in an array
*
* This is very handy to check for missing
* user values in a request for example.
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $required = ['cat', 'elephant'];
*
* $missing = A::missing($array, $required);
* // missing: [
* // 'elephant'
* // ];
* </code>
*
* @param array $array The source array
* @param array $required An array of required keys
* @return array An array of missing fields. If this
* is empty, nothing is missing.
*/
public static function missing(array $array, array $required = []): array
{
2023-04-14 16:30:28 +02:00
return array_values(array_diff($required, array_keys($array)));
2022-08-31 16:08:03 +02:00
}
/**
* Normalizes an array into a nested form by converting
* dot notation in keys to nested structures
*
* @param array $ignore List of keys in dot notation that should
* not be converted to a nested structure
*/
public static function nest(array $array, array $ignore = []): array
{
// convert a simple ignore list to a nested $key => true array
if (isset($ignore[0]) === true) {
$ignore = array_map(fn () => true, array_flip($ignore));
$ignore = A::nest($ignore);
}
$result = [];
foreach ($array as $fullKey => $value) {
// extract the first part of a multi-level key, keep the others
$subKeys = explode('.', $fullKey);
$key = array_shift($subKeys);
// skip the magic for ignored keys
2024-12-20 12:37:35 +01:00
if (($ignore[$key] ?? null) === true) {
2022-08-31 16:08:03 +02:00
$result[$fullKey] = $value;
continue;
}
// untangle elements where the key uses dot notation
if (count($subKeys) > 0) {
$value = static::nestByKeys($value, $subKeys);
}
// now recursively do the same for each array level if needed
if (is_array($value) === true) {
$value = static::nest($value, $ignore[$key] ?? []);
}
// merge arrays with previous results if necessary
// (needed when the same keys are used both with and without dot notation)
if (
2024-12-20 12:37:35 +01:00
is_array($result[$key] ?? null) === true &&
2022-08-31 16:08:03 +02:00
is_array($value) === true
) {
2022-12-19 16:26:24 +01:00
$value = array_replace_recursive($result[$key], $value);
2022-08-31 16:08:03 +02:00
}
2022-12-19 16:26:24 +01:00
$result[$key] = $value;
2022-08-31 16:08:03 +02:00
}
return $result;
}
/**
* Recursively creates a nested array from a set of keys
* with a key on each level
*
* @param mixed $value Arbitrary value that will end up at the bottom of the tree
* @param array $keys List of keys to use sorted from the topmost level
* @return array|mixed Nested array or (if `$keys` is empty) the input `$value`
*/
public static function nestByKeys($value, array $keys)
{
// shift off the first key from the list
$firstKey = array_shift($keys);
// stop further recursion if there are no more keys
if ($firstKey === null) {
return $value;
}
// return one level of the output tree, recurse further
return [
$firstKey => static::nestByKeys($value, $keys)
];
}
/**
* Sorts a multi-dimensional array by a certain column
*
* <code>
* $array[0] = [
* 'id' => 1,
* 'username' => 'mike',
* ];
*
* $array[1] = [
* 'id' => 2,
* 'username' => 'peter',
* ];
*
* $array[3] = [
* 'id' => 3,
* 'username' => 'john',
* ];
*
* $sorted = A::sort($array, 'username ASC');
* // Array
* // (
* // [0] => Array
* // (
* // [id] => 3
* // [username] => john
* // )
* // [1] => Array
* // (
* // [id] => 1
* // [username] => mike
* // )
* // [2] => Array
* // (
* // [id] => 2
* // [username] => peter
* // )
* // )
*
* </code>
*
* @param array $array The source array
* @param string $field The name of the column
* @param string $direction desc (descending) or asc (ascending)
* @param int $method A PHP sort method flag or 'natural' for
* natural sorting, which is not supported in
* PHP by sort flags
* @return array The sorted array
*/
2024-12-20 12:37:35 +01:00
public static function sort(
array $array,
string $field,
string $direction = 'desc',
$method = SORT_REGULAR
): array {
2022-08-31 16:08:03 +02:00
$direction = strtolower($direction) === 'desc' ? SORT_DESC : SORT_ASC;
$helper = [];
$result = [];
// build the helper array
foreach ($array as $key => $row) {
$helper[$key] = $row[$field];
}
// natural sorting
if ($direction === SORT_DESC) {
arsort($helper, $method);
} else {
asort($helper, $method);
}
// rebuild the original array
foreach ($helper as $key => $val) {
$result[$key] = $array[$key];
}
return $result;
}
/**
* Checks whether an array is associative or not
*
* <code>
* $array = ['a', 'b', 'c'];
*
* A::isAssociative($array);
* // returns: false
*
* $array = ['a' => 'a', 'b' => 'b', 'c' => 'c'];
*
* A::isAssociative($array);
* // returns: true
* </code>
*
* @param array $array The array to analyze
* @return bool true: The array is associative false: It's not
*/
public static function isAssociative(array $array): bool
{
return ctype_digit(implode('', array_keys($array))) === false;
}
/**
* Returns the average value of an array
*
* @param array $array The source array
* @param int $decimals The number of decimals to return
* @return float The average value
*/
2022-12-19 16:26:24 +01:00
public static function average(array $array, int $decimals = 0): float|null
2022-08-31 16:08:03 +02:00
{
2022-12-19 16:26:24 +01:00
if (empty($array) === true) {
return null;
}
2022-08-31 16:08:03 +02:00
return round((array_sum($array) / sizeof($array)), $decimals);
}
/**
* Merges arrays recursively
*
* <code>
* $defaults = [
* 'username' => 'admin',
* 'password' => 'admin',
* ];
*
* $options = A::extend($defaults, ['password' => 'super-secret']);
* // returns: [
* // 'username' => 'admin',
* // 'password' => 'super-secret'
* // ];
* </code>
*/
2022-12-19 16:26:24 +01:00
public static function extend(array ...$arrays): array
2022-08-31 16:08:03 +02:00
{
return array_merge_recursive(...$arrays);
}
/**
* Update an array with a second array
* The second array can contain callbacks as values,
* which will get the original values as argument
*
* <code>
* $user = [
* 'username' => 'homer',
* 'email' => 'homer@simpsons.com'
* ];
*
* // simple updates
* A::update($user, [
* 'username' => 'homer j. simpson'
* ]);
*
* // with callback
* A::update($user, [
* 'username' => function ($username) {
* return $username . ' j. simpson'
* }
* ]);
* </code>
*/
public static function update(array $array, array $update): array
{
foreach ($update as $key => $value) {
2022-12-19 16:26:24 +01:00
if ($value instanceof Closure) {
2024-12-20 12:37:35 +01:00
$value = $value(static::get($array, $key));
2022-08-31 16:08:03 +02:00
}
2022-12-19 16:26:24 +01:00
$array[$key] = $value;
2022-08-31 16:08:03 +02:00
}
return $array;
}
/**
* Wraps the given value in an array
* if it's not an array yet.
*/
public static function wrap($array = null): array
{
if ($array === null) {
return [];
2022-12-19 16:26:24 +01:00
}
if (is_array($array) === false) {
2022-08-31 16:08:03 +02:00
return [$array];
}
2022-12-19 16:26:24 +01:00
return $array;
2022-08-31 16:08:03 +02:00
}
/**
* Filter the array using the given callback
* using both value and key
* @since 3.6.5
*/
public static function filter(array $array, callable $callback): array
{
return array_filter($array, $callback, ARRAY_FILTER_USE_BOTH);
}
/**
* Remove key(s) from an array
* @since 3.6.5
*/
2022-12-19 16:26:24 +01:00
public static function without(array $array, int|string|array $keys): array
2022-08-31 16:08:03 +02:00
{
if (is_int($keys) || is_string($keys)) {
$keys = static::wrap($keys);
}
2022-12-19 16:26:24 +01:00
return static::filter(
$array,
fn ($value, $key) => in_array($key, $keys, true) === false
);
2022-08-31 16:08:03 +02:00
}
2021-10-29 18:05:46 +02:00
}