Skip to content

Instantly share code, notes, and snippets.

@kmuenkel
Last active May 30, 2025 21:27
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 Tests;
class NodePath
{
private const string ESCAPED = '\\\\';
private const string UNESCAPED = '(?<!\\\\)';
private static string $notFound;
private function __construct()
{
self::$notFound ??= uniqid();
}
public static function get(array $items, string $index, mixed $default = null)
{
$indexes = array_map(
fn (string $index): string => preg_replace('~' . static::ESCAPED . '\.~', '.', $index),
preg_split('~' . static::UNESCAPED . '\.~', $index)
);
$indexes = array_reduce($indexes, function (array $indexes, string $index) {
if (str_starts_with(end($indexes), '[') && !str_ends_with(end($indexes), ']')) {
$indexes[key($indexes)] .= ".$index";
reset($indexes);
} else {
$indexes[] = $index;
}
return $indexes;
}, []);
$self = new static();
$nodes = $self->getNodes($indexes, $items, $default);
$expectsOneIndex = count($indexes) == 1;
$hasWildcards = array_reduce($indexes, fn (bool $hasWildcards, string $index) => $hasWildcards || $self->indexIsWildcard($index), true);
$hasOperations = array_reduce($indexes, fn (bool $hasOperations, string $index) => $hasOperations || $self->indexIsOperation($index), true);
return $expectsOneIndex && !$hasWildcards && !$hasOperations ? $nodes[0] ?? $default : $nodes;
}
private 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|bool|null $expression) => $expression === $value,
'<' => fn ($value, string $expression) => $value < $expression,
'>' => fn ($value, string $expression) => $value > $expression,
'~' => fn ($value, string $expression) => preg_match($expression, $value),
];
}
private function getNodes(array $indexes, array $items, mixed $default): array
{
while (null !== ($index = array_shift($indexes))) {
if (!is_array($items)) {
return (array) $default;
} elseif ($this->indexIsOperation($index, $checks)) {
$items = $this->findMatches($items, $checks[2], $default);
} elseif ($this->indexIsWildcard($index)) {
return $this->wildcardExpression($indexes, $index, $items, $default);
} elseif (!array_key_exists($this->unescape($index), $items)) {
return (array) $default;
} else {
$items = $items[$this->unescape($index)];
}
}
return (array) $items;
}
private function indexIsWildcard(string $index): bool
{
return preg_match('~' . self::UNESCAPED . '\*~', $index);
}
private function indexIsOperation(string $index, &$checks = null): bool
{
return preg_match('/^(' . self::UNESCAPED . '\[)(.+)(' . self::UNESCAPED . '])$/', $index, $checks);
}
private function unescape(string $index): string
{
$index = preg_replace('~' . self::ESCAPED . '\*~', '*', $index);
$index = preg_replace('~' . self::ESCAPED . '\[~', '[', $index);
return preg_replace('~' . self::ESCAPED . ']~', ']', $index);
}
private function wildcardExpression(array $indexes, string $index, array $items, mixed $default): array
{
$items = array_filter($items, fn ($key): bool => fnmatch($index, (string) $key), ARRAY_FILTER_USE_KEY);
$items = array_map(fn ($subset) => is_array($subset) ? $this->getNodes($indexes, $subset, $default) : self::$notFound, $items);
return array_filter($items, fn ($item) => $item !== self::$notFound) ?: (array) $default;
}
private function findMatches(array $items, string $check, mixed $default): array
{
$operatorPatterns = array_map(fn (string $operator) => preg_quote($operator, '/'), array_keys($this->getOperators()));
$byOperator = '/^(.+)(' . implode('|', $operatorPatterns) . ')(.+)$/';
[$field, $operator, $expression] = preg_split($byOperator, $check, 3, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE) + ['*', '~=', ''];
$expression = !strcasecmp($expression, 'true') ?: (strcasecmp($expression, 'false') ? (!strcasecmp($expression, 'null') ? null : $expression) : (false));
$expression = in_array($expression, [true, false, null]) ? $expression : preg_replace('~^(\'|")(.+)' . self::UNESCAPED . '(\'|")$~', '$2', $expression);
$value = (array) static::get($items, $field, $default);
$isMet = fn ($value) => ($this->getOperators()[$operator])($value, $expression) && $value !== self::$notFound;
return array_filter($value, $isMet) ? $items : (array) $default;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment