diff --git a/app/helpers.php b/app/helpers.php index 1ca6760..c081fe2 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -9,216 +9,1521 @@ declare(strict_types=1); use App\Core\IniFile; use App\Core\Kernel; +use Carbon\Carbon; +use Random\RandomException; use Slim\App; -/** - * Returns path to root application directory - * - * @param string $path - * @return string - */ -function root_path(string $path = ''): string -{ - return rtrim(sprintf('%s/%s', dirname($_SERVER['DOCUMENT_ROOT']), $path), '/'); -} +if (!function_exists('root_path')) { + /** + * Возвращает абсолютный путь к корневой директории приложения. + * + * @param string $path Относительный путь для добавления к корневому. + * @return string Абсолютный путь. + */ + function root_path(string $path = ''): string + { + $documentRoot = $_SERVER['DOCUMENT_ROOT'] ?? dirname(__DIR__); -/** - * Return path to application configuration directory - * - * @param string $path - * @return string - */ -function config_path(string $path = ''): string -{ - return root_path("config/$path"); -} - -/** - * Returns path to app cache - * - * @param string $path - * @return string - */ -function cache_path(string $path = ''): string -{ - return root_path("cache/$path"); -} - -/** - * Returns base URL - * - * @param string $route - * @return string - */ -function base_url(string $route = ''): string -{ - return rtrim(sprintf('%s/%s', config('app.base_url'), $route), '/'); -} - -/** - * Returns mirror URL - * - * @param string $route - * @return string - */ -function mirror_url(string $route = ''): string -{ - return rtrim(sprintf('%s/%s', config('app.mirror_url'), $route), '/'); -} - -/** - * Returns value of environment var - * - * @param string $key - * @param mixed|null $default - * @return mixed - */ -function env(string $key, mixed $default = null): mixed -{ - return $_ENV[$key] ?? $_SERVER[$key] ?? $default; -} - -/** - * Returns kernel object - * - * @return Kernel - */ -function kernel(): Kernel -{ - return Kernel::instance(); -} - -/** - * Returns app object - * - * @return App - */ -function app(): App -{ - return Kernel::instance()->app(); -} - -/** - * Get config values - * - * @param string $key - * @param mixed|null $default - * @return mixed - */ -function config(string $key, mixed $default = null): mixed -{ - return Kernel::instance()->config($key, $default); -} - -/** - * Get Redis instance - * - * @return Redis - */ -function redis(): Redis -{ - return Kernel::instance()->redis(); -} - -/** - * Returns any value as boolean - * - * @param mixed $value - * @return bool - */ -function bool(mixed $value): bool -{ - is_string($value) && $value = strtolower(trim($value)); - - $positives = [true, 1, '1', '+', 'yes', 'on', 'true', 'enable', 'enabled']; - if (in_array($value, $positives, true)) { - return true; + return rtrim(sprintf('%s/%s', dirname($documentRoot), $path), '/'); } +} + +if (!function_exists('config_path')) { + /** + * Возвращает абсолютный путь к директории конфигурации приложения. + * + * @param string $path Относительный путь для добавления к директории конфигурации. + * @return string Абсолютный путь. + */ + function config_path(string $path = ''): string + { + return root_path("config/$path"); + } +} + +if (!function_exists('cache_path')) { + /** + * Возвращает абсолютный путь к директории кэша приложения. + * + * @param string $path Относительный путь для добавления к директории кэша. + * @return string Абсолютный путь. + */ + function cache_path(string $path = ''): string + { + return root_path("cache/$path"); + } +} + +if (!function_exists('base_url')) { + /** + * Возвращает базовый URL приложения. + * + * @param string $route Дополнительный маршрут, который будет добавлен к базовому URL. + * @return string Полный URL. + */ + function base_url(string $route = ''): string + { + return rtrim(sprintf('%s/%s', config('app.base_url'), $route), '/'); + } +} + +if (!function_exists('kernel')) { + /** + * Возвращает синглтон-экземпляр ядра приложения. + * + * @return Kernel Экземпляр ядра приложения. + */ + function kernel(): Kernel + { + return Kernel::instance(); + } +} + +if (!function_exists('app')) { + /** + * Возвращает синглтон-экземпляр Slim-приложения. + * + * @return App Экземпляр Slim-приложения. + */ + function app(): App + { + return Kernel::instance()->app(); + } +} + +if (!function_exists('config')) { + /** + * Возвращает значение из конфигурации приложения. + * + * @param string $key Ключ конфигурации. + * @param mixed $default Значение по умолчанию, если ключ не найден. + * @return mixed Значение конфигурации или значение по умолчанию. + */ + function config(string $key, mixed $default = null): mixed + { + return Kernel::instance()->config($key, $default); + } +} + +if (!function_exists('redis')) { + /** + * Возвращает синглтон-экземпляр Redis-клиента. + * + * @return Redis Экземпляр Redis-клиента. + */ + function redis(): Redis + { + return Kernel::instance()->redis(); + } +} + +if (!function_exists('ini')) { + /** + * Возвращает синглтон-экземпляр IniFile. + * + * @return IniFile Экземпляр IniFile. + */ + function ini(): IniFile + { + return Kernel::instance()->ini(); + } +} + +// ------------------ +// Common purpose +// ------------------ + +if (!function_exists('int')) { + /** + * Приводит значение к целому числу или возвращает null + * + * @param mixed $value Входное значение + * @param bool $strict Строгая валидация (только целые числа) + * @return int|null Целое число или null + * @throws InvalidArgumentException Если значение не соответствует ограничениям + */ + function int(mixed $value, bool $strict = false): ?int + { + if (is_blank($value)) { + return null; + } + + if (is_int($value) && !$strict) { + return $value; + } + + return filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + } +} + +if (!function_exists('float')) { + /** + * Приводит значение к числу с плавающей точкой или возвращает null + * + * @param mixed $value Входное значение + * @return float|null Число с плавающей точкой или null + */ + function float(mixed $value): ?float + { + if (is_blank($value)) { + return null; + } + + $filtered = filter_var($value, FILTER_VALIDATE_FLOAT); + + return $filtered === false ? (float) $value : $filtered; + } +} + +if (!function_exists('bool')) { + /** + * Приводит любое значение к булевому типу + * + * @param mixed $value Входное значение + * @return bool Булево представление значения + */ + function bool(mixed $value): bool + { + if ($value === null || $value === '') { + return false; + } + + if (is_bool($value)) { + return $value; + } + + if (is_numeric($value)) { + return (float) $value !== 0.0; + } + + if (is_string($value)) { + $value = strtolower(trim($value)); + + $positives = ['1', '+', 'yes', 'on', 'true', 'enable', 'enabled', 'да', 'включено', 'истина']; + $negatives = ['0', '-', 'no', 'off', 'false', 'disable', 'disabled', 'нет', 'выключено', 'ложь']; + + if (in_array($value, $positives, true)) { + return true; + } + + if (in_array($value, $negatives, true)) { + return false; + } + } + + if (is_array($value) || is_object($value)) { + return !empty($value); + } + + return (bool) $value; + } +} + +if (!function_exists('string')) { + /** + * Приводит любое значение к строке + * + * @param mixed $value Входное значение + * @return string Строковое представление значения + */ + function string(mixed $value): string + { + if (is_object($value)) { + return match (true) { + $value instanceof Stringable => (string) $value, + $value instanceof JsonSerializable => json_encode($value), + default => json_encode(arr($value)), + }; + } + + return match (true) { + is_resource($value) => stream_get_contents($value), + is_iterable($value) => json_encode(iterator_to_array($value)), + default => (string) $value, + }; + } +} + +if (!function_exists('arr')) { + /** + * Рекурсивно приводит объект или массив объектов к массиву + * + * @param mixed $value Входное значение + * @return mixed Массив + */ + function arr(mixed $value): mixed + { + if (is_object($value)) { + $value = match (true) { + $value instanceof JsonSerializable => $value->jsonSerialize(), + $value instanceof Iterator => iterator_to_array($value), + default => (array) $value, + }; + } + + if (is_array($value)) { + return array_map(static fn ($value) => arr($value), $value); + } + + return $value; + } +} + +if (!function_exists('env')) { + /** + * Возвращает значение переменной окружения. + * + * @param string $key Ключ переменной окружения. + * @param mixed $default Значение по умолчанию, если переменная не найдена. + * @param bool $required Бросать исключение, если переменная обязательна и не найдена. + * @return mixed Значение переменной окружения или значение по умолчанию. + * @throws InvalidArgumentException Если переменная обязательна и не найдена. + */ + function env(string $key, mixed $default = null, bool $required = false): mixed + { + $value = $_ENV[$key] ?? null; + + if ($value === null && isset($_SERVER[$key])) { + $value = $_SERVER[$key]; + } + + if ($value !== null) { + return $value; + } + + if ($required) { + throw new InvalidArgumentException( + sprintf( + 'Обязательная переменная окружения "%s" не найдена', + $key, + ), + ); + } + + return $default; + } +} + +if (!function_exists('here')) { + /** + * Возвращает координаты вызывавшего метода + * + * @param bool $asArray массив или строка в формате `:():` + * @return string|array + */ + function here(bool $asArray = false): string|array + { + $trace = debug_backtrace(!DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, 2); + + return $asArray + ? [ + 'from' => $trace[1]['class'] ?? $trace[0]['file'], + 'function' => $trace[1]['function'], + 'line' => $trace[0]['line'], + ] + : sprintf( + '%s%s%s():%s', + $trace[1]['class'] ?? $trace[0]['file'], + $trace[1]['type'] ?? '::', + $trace[1]['function'], + $trace[0]['line'], + ); + } +} + +if (!function_exists('rslash')) { + /** + * Добавляет завершающий слеш к пути + * + * @param string $path Путь для обработки + * @return string Путь с завершающим слешем + */ + function rslash(string $path): string + { + return rtrim($path, '/') . '/'; + } +} + +if (!function_exists('camel2snake')) { + /** + * Переводит строку из camelCase в snake_case + * + * @param string $input Строка в camelCase + * @return string Строка в snake_case + */ + function camel2snake(string $input): string + { + if (!$input) { + return $input; + } + + $input = (string) preg_replace('/(? 1024 && $i < count($units) - 1; $i++) { + $bytes /= 1024; + } + + return round($bytes, $precision) . ' ' . $units[$i]; + } +} + +if (!function_exists('time_ago')) { + /** + * Возвращает читаемое представление времени в прошедшем времени + * + * @param int|string $time Время в формате Unix timestamp или строка времени + * @return string Относительное время + */ + function time_ago(int|string $time): string + { + $time = is_string($time) ? strtotime($time) : $time; + + if ($time === false || $time <= 0) { + return 'неизвестно'; + } + + $diff = time() - $time; + + if ($diff < 60) { + return 'только что'; + } + + if ($diff < 60 * 60) { // 1 час + $minutes = (int) floor($diff / 60); + + return $minutes . ' ' . get_noun_form($minutes, 'минуту', 'минуты', 'минут') . ' назад'; + } + + if ($diff < 24 * 60 * 60) { // 1 день + $hours = (int) floor($diff / (60 * 60)); + + return $hours . ' ' . get_noun_form($hours, 'час', 'часа', 'часов') . ' назад'; + } + + if ($diff < 7 * 24 * 60 * 60) { // 1 неделя + $days = (int) floor($diff / (24 * 60 * 60)); + + return $days . ' ' . get_noun_form($days, 'день', 'дня', 'дней') . ' назад'; + } + + if ($diff < 30 * 24 * 60 * 60) { // 1 месяц + $weeks = (int) floor($diff / (7 * 24 * 60 * 60)); + + return $weeks . ' ' . get_noun_form($weeks, 'неделю', 'недели', 'недель') . ' назад'; + } + + if ($diff < 365 * 24 * 60 * 60) { // 1 год + $months = (int) floor($diff / (30 * 24 * 60 * 60)); + + return $months . ' ' . get_noun_form($months, 'месяц', 'месяца', 'месяцев') . ' назад'; + } + + $years = (int) floor($diff / (365 * 24 * 60 * 60)); + + return $years . ' ' . get_noun_form($years, 'год', 'года', 'лет') . ' назад'; + } +} + +if (!function_exists('get_noun_form')) { + /** + * Возвращает правильную форму существительного для числительного + * + * @param int $number Числительное + * @param string $form1 Форма для 1 + * @param string $form2 Форма для 2-4 + * @param string $form5 Форма для 5+ + * @return string Правильная форма существительного + */ + function get_noun_form(int $number, string $form1, string $form2, string $form5): string + { + $number = abs($number); + $lastDigit = $number % 10; + $lastTwoDigits = $number % 100; + + if ($lastTwoDigits >= 11 && $lastTwoDigits <= 14) { + return $form5; + } + + if ($lastDigit === 1) { + return $form1; + } elseif ($lastDigit >= 2 && $lastDigit <= 4) { + return $form2; + } else { + return $form5; + } + } +} + +if (!function_exists('random_string')) { + /** + * Генерирует случайную строку заданной длины + * + * @param int $length Длина строки + * @param string $chars Набор символов для генерации + * @return string Случайная строка + * @throws RandomException + */ + function random_string( + int $length = 32, + string $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + ): string { + $result = ''; + $max = strlen($chars) - 1; + + for ($i = 0; $i < $length; $i++) { + $result .= $chars[random_int(0, $max)]; + } + + return $result; + } +} + +if (!function_exists('secure_token')) { + /** + * Генерирует безопасный случайный токен + * + * @param int $length Длина токена в байтах + * @return string Шестнадцатеричное представление токена + * @throws RandomException + */ + function secure_token(int $length = 32): string + { + return bin2hex(random_bytes($length)); + } +} + +if (!function_exists('decode_jwt')) { + /** + * Возвращает полезную нагрузку токена без проверки подписи + * + * @param string $token JWT токен + * @return array Полезная нагрузка токена + * @throws Exception + */ + function decode_jwt(string $token): array + { + $exploded = explode('.', $token); + count($exploded) !== 3 && throw new InvalidArgumentException('JWT string expected'); + $json = base64_decode($exploded[1]); + + return json_decode($json, true); + } +} + +if (!function_exists('nullable')) { + /** + * Возвращает значение, если оно непустое, иначе null + * + * @param mixed $value Значение, которое надо проверить на пустоту + * @param string|callable|null $type Тип, в который следует привести непустое значение + * @return mixed Значение в нужном $type либо null + */ + function nullable(mixed $value, callable|string|null $type = null): mixed + { + if (!is_bool($value) && empty($value)) { + return null; + } + + if (empty($type)) { + return $value; + } + + if (is_callable($type)) { + return $type($value); + } + + settype($value, $type); + + return $value; + } +} + +if (!function_exists('carbon')) { + /** + * Возвращает объект Carbon для значения переменной или null + * + * @param DateTimeInterface|string|null $value Строка/объект даты или null + * @param string $format Формат + * @return Carbon|null + */ + function carbon(DateTimeInterface|string|null $value = null, string $format = 'd.m.Y H:i:s'): ?Carbon + { + return Carbon::make($value)?->settings([ + 'toStringFormat' => $format, + 'toJsonFormat' => $format, + ]); + } +} + +if (!function_exists('recast')) { + /** + * Преобразует stdClass объект в объект указанного типа + * + * @param string $className Имя класса для приведения + * @param stdClass $object Объект для приведения + * @return mixed Новый объект указанного типа + * @throws InvalidArgumentException + */ + function recast(string $className, stdClass &$object): mixed + { + if (!class_exists($className)) { + throw new InvalidArgumentException("Class not found: $className"); + } + + $new = new $className(); + + foreach ($object as $property => &$value) { // @phpstan-ignore-line + $new->{$property} = &$value; + unset($object->{$property}); + } + unset($value, $object); + + return $new; + } +} + +if (!function_exists('truncate')) { + /** + * Обрезает текст до указанной длины и добавляет эллипсис + * + * @param string $text Исходный текст + * @param int $length Максимальная длина + * @param string $suffix Строка для добавления в конце + * @return string Обрезанный текст + */ + function truncate(string $text, int $length = 100, string $suffix = '...'): string + { + if (mb_strlen($text) <= $length) { + return $text; + } + + $truncatedLength = $length - mb_strlen($suffix); + + return mb_substr($text, 0, $truncatedLength) . $suffix; + } +} + +if (!function_exists('tail')) { + /** + * Возвращает последние символы строки с префиксом + * + * @param string $string Исходная строка + * @param int $count Количество символов для возврата + * @param string $prefix Префикс для добавления + * @return string Результат с префиксом + */ + function tail(string $string, int $count = 15, string $prefix = ''): string + { + return empty($string = trim($string)) + ? '' + : sprintf('%s%s', $prefix, mb_substr($string, -abs($count), mb_strlen($string))); + } +} + +if (!function_exists('mb_count_chars')) { + /** + * Подсчитывает количество вхождений каждого символа в строке + * + * @param string $string Строка для анализа + * @return array Массив с количеством вхождений каждого символа + */ + function mb_count_chars(string $string): array + { + $length = mb_strlen($string, 'UTF-8'); + $unique = []; + for ($i = 0; $i < $length; ++$i) { + $char = mb_substr($string, $i, 1, 'UTF-8'); + !array_key_exists($char, $unique) && $unique[$char] = 0; + $unique[$char]++; + } + + return $unique; + } +} + +if (!function_exists('query')) { + /** + * Возвращает ассоциативный массив GET-параметров из URL + * + * @param string $url URL для парсинга + * @return array Ассоциативный массив GET-параметров + */ + function query(string $url): array + { + $result = []; + $parsed = parse_url($url); + if (empty($parsed['query'])) { + return $result; + } + $params = explode('&', urldecode($parsed['query'])); + foreach ($params as $url) { + [$key, $value] = explode('=', $url); + if (in_array($value, ['true', 'false'])) { + $result[$key] = bool($value) === true; + } else { + $result[$key] = empty($value) ? null : $value; + } + } + + return $result; + } +} + +if (!function_exists('array_first')) { + /** + * Возвращает первый элемент массива без перемотки указателя + * + * @param array $array Входной массив + * @param callable|null $callback Замыкание для предварительной фильтрации массива + * @param mixed $default Значение по умолчанию, если массив пуст + * @return mixed Первый элемент массива или значение по умолчанию + */ + function array_first(array $array, ?callable $callback = null, mixed $default = null): mixed + { + if (empty($array)) { + return $default; + } + + foreach ($array as $key => $item) { + if ($callback === null || $callback($item, $key)) { + return $item; + } + } + + $firstKey = array_key_first($array); + + return $array[$firstKey] ?? $default; + } +} + +if (!function_exists('array_last')) { + /** + * Возвращает последний элемент массива без перемотки указателя + * + * @param array $array Входной массив + * @param callable|null $callback Замыкание для предварительной фильтрации массива + * @param mixed $default Значение по умолчанию, если массив пуст + * @return mixed Последний элемент массива или значение по умолчанию + */ + function array_last(array $array, ?callable $callback = null, mixed $default = null): mixed + { + $keys = array_keys($array); + $lastKey = end($keys); + + if ($lastKey === false) { + return $default; + } + + if ($callback === null || $callback($array[$lastKey], $lastKey)) { + return $array[$lastKey]; + } + + for ($i = count($keys) - 1; $i >= 0; $i--) { + $key = $keys[$i]; + if ($callback($array[$key], $key)) { + return $array[$key]; + } + } + + return $default; + } +} + +if (!function_exists('array_any')) { + /** + * Проверяет, удовлетворяет ли хотя бы один элемент массива условию + * + * @param array $array Массив для проверки + * @param callable $callback Функция проверки для каждого элемента + * @return bool True, если хотя бы один элемент удовлетворяет условию + */ + function array_any(array $array, callable $callback): bool + { + /** @noinspection PhpLoopCanBeConvertedToArrayAnyInspection */ + foreach ($array as $item) { + if ($callback($item)) { + return true; + } + } - $negatives = [false, 0, '0', '-', 'no', 'off', 'false', 'disable', 'disabled']; - if (in_array($value, $negatives, true)) { return false; } - - return (bool)$value; } -/** - * Проверяет значениен на пустоту - * - * @param $value - * @return bool - */ -function is_blank($value): bool -{ - if (is_null($value)) { +if (!function_exists('array_all')) { + /** + * Проверяет, удовлетворяют ли все элементы массива условию + * + * @param array $array Массив для проверки + * @param callable $callback Функция проверки для каждого элемента + * @return bool True, если все элементы удовлетворяют условию + */ + function array_all(array $array, callable $callback): bool + { + /** @noinspection PhpLoopCanBeConvertedToArrayAllInspection */ + /** @noinspection PhpLoopCanBeConvertedToArrayAnyInspection */ + foreach ($array as $item) { + if (!$callback($item)) { + return false; + } + } + return true; } +} - if (is_string($value)) { - return trim($value) === ''; +if (!function_exists('array_get')) { + /** + * Безопасно получает значение из массива по ключу + * + * @param array $array Исходный массив + * @param string|int $key Ключ для поиска + * @param mixed $default Значение по умолчанию + * @return mixed Значение из массива или значение по умолчанию + */ + function array_get(array $array, string|int $key, mixed $default = null): mixed + { + return $array[$key] ?? $default; } +} - if (is_numeric($value) || is_bool($value)) { - return false; +if (!function_exists('array_is_assoc')) { + /** + * Проверяет, является ли массив ассоциативным + * + * @param array $array Проверяемый массив + * @return bool True, если массив ассоциативный + */ + function array_is_assoc(array $array): bool + { + if ($array === []) { + return false; + } + + return array_keys($array) !== range(0, count($array) - 1); } +} - if ($value instanceof Countable) { - return count($value) === 0; +if (!function_exists('array_except')) { + /** + * Безопасно удаляет элементы из массива по ключам + * + * @param array $array Исходный массив + * @param array|string $keys Ключи для удаления + * @return array Массив с удаленными элементами + */ + function array_except(array $array, array|string $keys): array + { + $keys = is_array($keys) ? $keys : [$keys]; + + foreach ($keys as $key) { + unset($array[$key]); + } + + return $array; } - - return empty($value); } -/** - * Возвращает натуральное представление значения переменной или null - * - * @param mixed $value - * @return int|null - */ -function int(mixed $value): ?int -{ - if (is_blank($value)) { - return null; +if (!function_exists('array_only')) { + /** + * Возвращает только указанные ключи из массива + * + * @param array $array Исходный массив + * @param array $keys Ключи для сохранения + * @return array Массив только с указанными ключами + */ + function array_only(array $array, array $keys): array + { + return array_intersect_key($array, array_flip($keys)); } - $filtered = filter_var($value, FILTER_VALIDATE_INT); - return $filtered === false ? (int)$value : $filtered; } -/** - * Возвращает первый элемент массива без перемотки указателя - * - * @param array $array Входной массив - * @param callable|null $callback Замыкание для предварительной фильтрации вх. массива - * @return mixed - */ -function array_first(array $array, ?callable $callback = null): mixed -{ - is_null($callback) || $array = array_filter($array, $callback); - - return $array[array_key_first($array)] ?? null; +if (!function_exists('array_is_empty')) { + /** + * Проверяет, что все значения массива пусты + * + * @param array $array Проверяемый массив + * @return bool True, если все значения массива пусты + */ + function array_is_empty(array $array): bool + { + return empty($array) || empty(array_filter(array_values($array), static fn ($value) => is_filled($value))); + } } -/** - * Возвращает последний элемент массива без перемотки указателя - * - * @param array $array Входной массив - * @param callable|null $callback Замыкание для предварительной фильтрации вх. массива - * @return mixed - */ -function array_last(array $array, ?callable $callback = null): mixed -{ - is_null($callback) || $array = array_filter($array, $callback); +if (!function_exists('array_to_csv')) { + /** + * Преобразует массив в CSV строку + * + * @param array $array Массив для преобразования + * @param string $delimiter Разделитель полей + * @param string $enclosure Ограничитель строк + * @return string CSV строка + */ + function array_to_csv(array $array, string $delimiter = ',', string $enclosure = '"'): string + { + $output = fopen('php://temp', 'r+'); - return $array[array_key_last($array)] ?? null; + try { + foreach ($array as $row) { + fputcsv($output, $row, $delimiter, $enclosure); + } + + rewind($output); + $csv = stream_get_contents($output); + } finally { + fclose($output); + } + + return rtrim($csv, "\n"); + } } -/** - * Get ini-file instance - * - * @return IniFile - */ -function ini(): IniFile -{ - return Kernel::instance()->ini(); +if (!function_exists('array_is_multidim')) { + /** + * Проверяет, является ли массив многомерным + * + * @param array $array Проверяемый массив + * @return bool True, если массив многомерный + */ + function array_is_multidim(array $array): bool + { + if ($array === []) { + return false; + } + + return count($array) !== count($array, COUNT_RECURSIVE); + } +} + +if (!function_exists('array_merge_recursive_distinct')) { + /** + * Рекурсивно объединяет массивы + * + * @param array ...$arrays Массивы для объединения + * @return array Объединенный массив + */ + function array_merge_recursive_distinct(array ...$arrays): array + { + $result = []; + + foreach ($arrays as $array) { + foreach ($array as $key => $value) { + if (is_string($key)) { + if (is_array($value) && isset($result[$key]) && is_array($result[$key])) { + $result[$key] = array_merge_recursive_distinct($result[$key], $value); + } else { + $result[$key] = $value; + } + } else { + $result[] = $value; + } + } + } + + return $result; + } +} + +if (!function_exists('array_random')) { + /** + * Возвращает случайный элемент из массива + * + * @param array $array Массив для выбора + * @param int $count Количество элементов для возврата + * @return mixed|mixed[] Случайный элемент или массив элементов + */ + function array_random(array $array, int $count = 1): mixed + { + if ($count === 1) { + return $array[array_rand($array)]; + } + + $keys = array_rand($array, $count); + $result = []; + foreach ($keys as $key) { + $result[] = $array[$key]; + } + + return $result; + } +} + +if (!function_exists('array_clean')) { + /** + * Чистит пустые и повторяющиеся значения массива + * + * @param mixed[] $array Входной массив + * @param callable|null $callback Функция для фильтрации + * @return array Очищенный массив + */ + function array_clean(array $array, ?callable $callback = null): array + { + return array_filter(array_unique($array), $callback); + } +} + +if (!function_exists('array_recursive_diff')) { + /** + * Корректно вычисляет diff между массивами, включая мультиразмерные + * + * @param array $a Первый массив + * @param array $b Второй массив + * @return array Разность массивов + */ + function array_recursive_diff(array $a, array $b): array + { + $aReturn = []; + + foreach ($a as $key => $value) { + if (array_key_exists($key, $b)) { + if (is_array($value)) { + $aRecursiveDiff = array_recursive_diff($value, $b[$key]); + if (count($aRecursiveDiff)) { + $aReturn[$key] = $aRecursiveDiff; + } + } else { + if ($value != $b[$key]) { + $aReturn[$key] = $value; + } + } + } else { + $aReturn[$key] = $value; + } + } + + return $aReturn; + } +} + +if (!function_exists('array_group')) { + /** + * Группирует массив по ключу + * + * @param array $array Входной массив + * @param callable|string $callback Функция для определения ключа группировки + * @return array Сгруппированный массив + * @throws Exception + */ + function array_group(array $array, callable|string $callback): array + { + if (is_string($callback)) { + $callback = static fn ($item) => $item[$callback] ?? null; + } + + $result = []; + foreach ($array as $key => $value) { + $callbackRes = $callback($value, $key); + if (is_array($callbackRes)) { + $keys = array_keys($callbackRes); + if (count($keys) !== 1) { + throw new Exception('Возращаемый массив может содержать только один ключ'); + } + + $resultKey = $keys[0]; + $resultValue = $callbackRes[$resultKey]; + } else { + $resultKey = $callbackRes; + $resultValue = $value; + } + + $value = $result[$resultKey] ?? []; + $value[] = $resultValue; + $result[$resultKey] = $value; + } + + return $result; + } +} + +if (!function_exists('array_assoc')) { + /** + * Преобразует массив в ассоциативный массив по ключу + * + * @param array $array Входной массив + * @param callable|string $callback Функция для определения ключа + * @return array Ассоциативный массив + * @throws Exception + */ + function array_assoc(array $array, callable|string $callback): array + { + if (is_string($callback)) { + $callback = static fn ($item) => $item[$callback] ?? null; + } + + $result = []; + foreach ($array as $key => $value) { + $callbackRes = $callback($value, $key); + if (is_array($callbackRes)) { + $keys = array_keys($callbackRes); + if (count($keys) !== 1) { + throw new Exception('Возращаемый массив может содержать только один ключ'); + } + + $resultKey = $keys[0]; + $result[$resultKey] = $callbackRes[$resultKey]; + } else { + $result[$callbackRes] = $value; + } + } + + return $result; + } +} + +if (!function_exists('array_sort_by')) { + /** + * Сортирует массив по ключу + * + * @param array &$array Массив для сортировки + * @param callable|string $callback Функция для определения ключа сортировки + * @param int $sortType Тип сортировки (SORT_ASC или SORT_DESC) + * @return array Отсортированный массив + */ + function array_sort_by(array &$array, callable|string $callback, int $sortType = SORT_ASC): array + { + if (is_string($callback)) { + $callback = static fn ($item) => $item[$callback] ?? null; + } + + $keys = array_map($callback, $array); + array_multisort($keys, $sortType, $array); + + return $array; + } +} + +if (!function_exists('array_avg')) { + /** + * Вычисляет среднее значение элементов массива + * + * @param array $array Массив для вычисления среднего + * @param callable|string $callback Функция для получения значения + * @return float Среднее значение + */ + function array_avg(array $array, callable|string $callback): float + { + if (is_string($callback)) { + $callback = static fn ($item) => $item[$callback] ?? null; + } + + $values = array_map($callback, $array); + $values = array_filter($values, static fn ($item) => !is_null($item)); + + $count = count($values); + if ($count === 0) { + return 0; + } + + return ((float) array_sum($values)) / $count; + } +} + +if (!function_exists('array_single')) { + /** + * Возвращает единственный элемент массива + * + * @param array $array Массив с одним элементом + * @return mixed Единственный элемент массива + * @throws Exception Если массив содержит не один элемент + */ + function array_single(array $array): mixed + { + if (count($array) !== 1) { + throw new Exception('Expected 1 element, got ' . count($array)); + } + + return array_values($array)[0]; + } +} + +if (!function_exists('array_partition')) { + /** + * Разделяет массив на два по условию + * + * @param array $array Входной массив + * @param callable|string $callback Функция для проверки условия + * @return array Массив с двумя элементами: успешные и неуспешные + */ + function array_partition(array $array, callable|string $callback): array + { + if (is_string($callback)) { + $callback = static fn ($item) => $item[$callback] ?? null; + } + + $successList = []; + $failList = []; + foreach ($array as $key => $value) { + if ($callback($value, $key)) { + $successList[$key] = $value; + } else { + $failList[$key] = $value; + } + } + + return [$successList, $failList]; + } +} + +if (!function_exists('array_multidim_unique')) { + /** + * Удаляет дубликаты из многомерного массива по ключу + * + * @param array $arr Многомерный массив + * @param string $key Ключ для определения уникальности + * @return array Массив без дубликатов + */ + function array_multidim_unique(array $arr, string $key): array + { + $tempArray = []; + $keyArray = []; + $i = 0; + + foreach ($arr as $idx => $item) { + $subValue = $item[$key]; + + if (!in_array($subValue, $keyArray)) { + $keyArray[$i] = $subValue; + $tempArray[$idx] = $item; + } + + ++$i; + } + + return $tempArray; + } +} + +if (!function_exists('hashmap')) { + /** + * Возвращает хеш-таблицу, состоящую из ключей => значений указанных полей массива + * + * @param mixed[] $array Массив + * @param int|string|null $keyField Имя поля, значения коего станут ключами + * @param int|string|null $valueField Имя поля, значения коего станут значениями + * @return array Хеш-таблица + */ + function hashmap(array $array, int|string|null $keyField, int|string|null $valueField): array + { + $keys = array_column($array, $keyField) ?: []; + + if (empty($keys)) { + return []; + } + + $values = array_column($array, $valueField) ?: array_fill(0, count($keys), null); + + return array_combine($keys, $values); + } +} + +if (!function_exists('key_by')) { + /** + * Возвращает исходный массив, индексированный по указанному полю + * + * @param mixed[] $array Массив + * @param int|string|null $keyField Имя поля, значения коего станут ключами + * @return array Индексированный массив + */ + function key_by(array $array, int|string|null $keyField): array + { + return array_combine(array_column($array, $keyField), $array); + } +} + +if (!function_exists('tree')) { + /** + * Строит дерево из плоского списка без рекурсии + * + * @param array $flatlist Список всех нод, из которых нужно построить дерево + * @param string $fk Поле потомка, которое ссылается на ключевое родителя + * @param string $pk Имя ключевого поля родителя + * @return array Дерево + */ + function tree( + array $flatlist, + string $fk = 'hid', + string $pk = 'id', + ): array { + $tree = []; + foreach ($flatlist as &$node) { + $references[$node[$pk]] = &$node; + if (empty($node[$fk])) { + $tree[$node[$pk]] = &$node; + } else { + $references[$node[$fk]]['children'][] = &$node; + } + } + + return array_values($tree); + } +} + +if (!function_exists('flatten')) { + /** + * Разворачивает дерево в плоский список без рекурсии + * + * @param array $tree Дерево (например, результат функции tree()) + * @param string $branching Ключ ноды, под которым находится массив с дочерними нодами + * @param null|string $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем + * @return array Плоский список + */ + function flatten( + array $tree, + string $branching = 'children', + ?string $leafProperty = 'is_leaf', + ): array { + $result = []; + while (count($tree)) { + $node = array_shift($tree); + $needToSkip = !empty($leafProperty) + && isset($node[$leafProperty]) + && $node[$leafProperty] === false; + if (!empty($node[$branching])) { + $tree = array_merge($tree, $node[$branching]); + unset($node[$branching]); + } elseif ($needToSkip) { + continue; + } + $result[] = $node; + } + + return $result; + } +} + +if (!function_exists('hierarchy')) { + /** + * Восстанавливает иерархию ноды в плоском списке + * + * @param array $flatlist Список всех нод, среди коих есть нужная нода и её родители + * @param array $node Нода, родителей коей необходимо найти в общем списке + * @param string $fk Поле потомка, которое ссылается на ключевое родителя + * @param string $pk Имя ключевого поля родителя + * @return array Плоский ассоциированный список с потомком и его родителями + */ + function hierarchy( + array $flatlist, + array $node, + string $fk = 'hid', + string $pk = 'id', + ): array { + $stack[$node[$pk]] = $node; + while (isset($flatlist[$node[$fk]])) { + $node = $flatlist[$node[$fk]]; + $stack[$node[$pk]] = $node; + } + + return $stack; + } +} + +if (!function_exists('clear_tree')) { + /** + * Рекурсивно чистит дерево от пустых родителей + * + * @param array $node Нода, которая должна быть обработана + * @param string $branching Ключ ноды, под которым находится массив с дочерними нодами + * @param null|string $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем + * @return null|array Обработанная нода с хотя бы одним потомком либо null + */ + function clear_tree( + array $node, + string $branching = 'children', + ?string $leafProperty = 'is_leaf', + ): ?array { + if (empty($node[$leafProperty]) && empty($node[$branching])) { + unset($node); + + return null; + } + if (isset($node[$branching])) { + foreach ($node[$branching] as $key => $child) { + $value = clear_tree($child); + if (is_null($value)) { + unset($node[$branching][$key]); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + if (empty($node[$branching])) { + unset($node); + + return null; + } + } else { + $node[$branching][$key] = $value; + } + } + } + + return $node; + } +} + +if (!function_exists('descendants')) { + /** + * Восстанавливает всех потомков ноды в плоском списке + * + * @param array $flatlist Список всех нод, среди коих есть нужная нода и её потомки + * @param int $id Индекс в списке ноды, потомков которой необходимо найти + * @param string $fk ссылка, которое ссылается на ключевое родителя + * @param string $pk Имя ключевого поля родителя + * @return array Плоский ассоциированный список с потомком и его родителями + */ + function descendants( + array $flatlist, + int $id, + string $fk = 'hid', + string $pk = 'id', + ): array { + $descendants = []; + $stack = [$id]; + + while (!empty($stack)) { + $currentId = array_pop($stack); + + foreach ($flatlist as $item) { + if ($item[$fk] === $currentId) { + $descendants[$item[$pk]] = $item; + $stack[] = $item[$pk]; + } + } + } + $descendants[$id] = $flatlist[$id]; + + return $descendants; + } }