Skip to content

Instantly share code, notes, and snippets.

@kmuenkel
Last active May 24, 2023 06:00
Show Gist options
  • Save kmuenkel/c063511e572fc07deb713c67d1185c13 to your computer and use it in GitHub Desktop.
Save kmuenkel/c063511e572fc07deb713c67d1185c13 to your computer and use it in GitHub Desktop.
Dot-delimited recursive array data extraction with wildcard and nested expression support
<?php
namespace App\Helpers;
use stdClass;
class NodePath
{
protected static string $escaped = '\\\\';
protected static string $unescaped = '(?<!\\\\)';
protected int $recursionLevel = -1;
protected array $items;
protected string $notFound;
public function __construct(array $items)
{
$this->items = $items;
$this->notFound = uniqid();
}
protected function getOperators(): array
{
return [
'~=' => fn ($value, string $expression) => fnmatch("*$expression", $value),
'=~' => fn ($value, string $expression) => fnmatch("$expression*", $value),
'<=' => fn ($value, string $expression) => $value <= $expression,
'>=' => fn ($value, string $expression) => $value >= $expression,
'=' => fn ($value, string $expression) => $expression == $value,
'<' => fn ($value, string $expression) => $value < $expression,
'>' => fn ($value, string $expression) => $value > $expression,
'~' => fn ($value, string $expression) => preg_match($expression, $value)
];
}
public static function get(array $items, string $index, $default = null)
{
return (new static($items))->getPath($index, $default);
}
public function getPath(string $index, $default = null)
{
$this->recursionLevel = -1;
$indexes = array_map(
fn (string $index): string => preg_replace('~' . static::$escaped . '\.~', '.', $index),
preg_split('~'.static::$unescaped.'\.~', $index)
);
return $this->getNodes($indexes, $this->items, $default);
}
protected function getNodes(array $indexes, array $items, $default = null)
{
$this->recursionLevel++;
while (!is_null($index = array_shift($indexes))) {
$isCollectable = is_array($items) || $items instanceof stdClass;
$hasNested = preg_match('/^('.static::$unescaped.'\[)(.+)('.static::$unescaped.'])$/', $index, $checks);
$hasWildcard = preg_match('~'.static::$unescaped.'\*~', $index);
if (!$isCollectable) {
$items = $default;
break;
}
if ($hasNested) {
$items = $this->nestedExpression($items, $checks);
continue;
}
if ($hasWildcard) {
$items = $this->wildcardExpression($indexes, $index, $items, $default);
break;
}
$items = ($items[$this->unescape($index)] ?? $this->notFound);
if ($items === $this->notFound) {
$items = $default;
break;
}
}
$this->recursionLevel--;
return $items;
}
protected function unescape(string $index): string
{
$index = preg_replace('~' . static::$escaped . '\*~', '*', $index);
$index = preg_replace('~' . static::$escaped . '\[~', '[', $index);
return preg_replace('~' . static::$escaped . ']~', ']', $index);
}
protected function wildcardExpression(array $indexes, string $index, array $items, $default = null)
{
$arrayIsNumeric = ($keys = array_keys($items)) !== array_keys($keys);
$rootStringKeys = !$this->recursionLevel && $arrayIsNumeric;
$items = array_filter($items, fn ($key): bool => fnmatch($index, (string)$key), ARRAY_FILTER_USE_KEY);
$items = array_map(fn ($subset) => $this->getNodes($indexes, $subset), $items);
$items = array_filter($items, fn ($item) => $item !== $this->notFound);
return ($rootStringKeys ? $items : array_values($items)) ?: $default;
}
protected function nestedExpression(array $items, array $checks): array
{
$items = $this->findMatches($items, $checks);
$arrayIsNumeric = ($keys = array_keys($items)) !== array_keys($keys);
$rootStringKeys = !$this->recursionLevel && $arrayIsNumeric;
return $rootStringKeys ? $items : array_values($items);
}
protected function findMatches(array $items, array $checks): array
{
$operatorPatterns = array_map('preg_quote', array_keys($this->getOperators()));
$byOperator = '/^(.+)(' . implode('|', $operatorPatterns) . ')(.+)$/';
$flags = PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE;
$defaults = ['*', '~=', ''];
[$field, $operator, $expression] = preg_split($byOperator, $checks[2], 3, $flags) + $defaults;
$findMatches = function ($element) use ($field, $operator, $expression): bool {
$value = (new static((array)$element))->getPath($field);
$isMet = fn ($value) => ($this->getOperators()[$operator])($value, $expression);
$check = fn ($value) => is_array($value) ? (bool)array_filter($value, $isMet) : $isMet($value);
return $value !== $this->notFound && $check($value);
};
return array_filter($items, $findMatches);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment