diff --git a/src/Query/Query.php b/src/Query/Query.php index dc6fca8f4d..d271c40d08 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -3,6 +3,7 @@ namespace Kirby\Query; use Closure; +use Exception; use Kirby\Cms\App; use Kirby\Cms\Collection; use Kirby\Cms\File; @@ -11,6 +12,8 @@ use Kirby\Cms\User; use Kirby\Image\QrCode; use Kirby\Toolkit\I18n; +use Kirby\Toolkit\Query\Runners\Interpreted; +use Kirby\Toolkit\Query\Runners\Transpiled; /** * The Query class can be used to query arrays and objects, @@ -66,18 +69,8 @@ public function intercept(mixed $result): mixed return $result; } - /** - * Returns the query result if anything - * can be found, otherwise returns null - * - * @throws \Kirby\Exception\BadMethodCallException If an invalid method is accessed by the query - */ - public function resolve(array|object $data = []): mixed + private function resolve_legacy(array|object $data = []): mixed { - if (empty($this->query) === true) { - return $data; - } - // merge data with default entries if (is_array($data) === true) { $data = [...static::$entries, ...$data]; @@ -99,6 +92,35 @@ public function resolve(array|object $data = []): mixed // loop through all segments to resolve query return Expression::factory($this->query, $this)->resolve($data); + + } + + /** + * Returns the query result if anything + * can be found, otherwise returns null + * + * @throws \Kirby\Exception\BadMethodCallException If an invalid method is accessed by the query + */ + public function resolve(array|object $data = []): mixed + { + if (empty($this->query) === true) { + return $data; + } + + $mode = App::instance()->option('query.runner', 'interpreted'); + + if ($mode === 'legacy') { + return $this->resolve_legacy($data); + } + + $runnerClass = match($mode) { + 'transpiled' => Transpiled::class, + 'interpreted' => Interpreted::class, + default => throw new Exception('Invalid query runner') + }; + + $runner = new $runnerClass(static::$entries, $this->intercept(...)); + return $runner->run($this->query, (array)$data); } } diff --git a/src/Toolkit/Query/AST/ArgumentListNode.php b/src/Toolkit/Query/AST/ArgumentListNode.php new file mode 100644 index 0000000000..b6d9d3bf83 --- /dev/null +++ b/src/Toolkit/Query/AST/ArgumentListNode.php @@ -0,0 +1,11 @@ +name); + } +} diff --git a/src/Toolkit/Query/AST/IdentifierNode.php b/src/Toolkit/Query/AST/IdentifierNode.php new file mode 100644 index 0000000000..5c07dcd325 --- /dev/null +++ b/src/Toolkit/Query/AST/IdentifierNode.php @@ -0,0 +1,14 @@ +member)) { + return self::unescape($this->member); + } + return $this->member; + + } +} diff --git a/src/Toolkit/Query/AST/Node.php b/src/Toolkit/Query/AST/Node.php new file mode 100644 index 0000000000..3da71795da --- /dev/null +++ b/src/Toolkit/Query/AST/Node.php @@ -0,0 +1,13 @@ +visitNode($this); + } +} diff --git a/src/Toolkit/Query/AST/TernaryNode.php b/src/Toolkit/Query/AST/TernaryNode.php new file mode 100644 index 0000000000..33aa203175 --- /dev/null +++ b/src/Toolkit/Query/AST/TernaryNode.php @@ -0,0 +1,14 @@ +name); + } +} diff --git a/src/Toolkit/Query/BaseParser.php b/src/Toolkit/Query/BaseParser.php new file mode 100644 index 0000000000..7e3fc459c4 --- /dev/null +++ b/src/Toolkit/Query/BaseParser.php @@ -0,0 +1,90 @@ + + */ + protected Iterator $tokens; + + + public function __construct( + Tokenizer|Iterator $source, + ) { + if ($source instanceof Tokenizer) { + $source = $source->tokenize(); + } + + $this->tokens = $source; + $first = $this->tokens->current(); + + if ($first === null) { + throw new Exception('No tokens found.'); + } + + $this->current = $first; + } + + protected function consume(TokenType $type, string $message): Token + { + if ($this->check($type) === true) { + return $this->advance(); + } + + throw new Exception($message); + } + + protected function check(TokenType $type): bool + { + if ($this->isAtEnd() === true) { + return false; + } + + return $this->current->type === $type; + } + + protected function advance(): Token|null + { + if ($this->isAtEnd() === false) { + $this->previous = $this->current; + $this->tokens->next(); + $this->current = $this->tokens->current(); + } + + return $this->previous; + } + + protected function isAtEnd(): bool + { + return $this->current->type === TokenType::T_EOF; + } + + + protected function match(TokenType $type): Token|false + { + if ($this->check($type) === true) { + return $this->advance(); + } + + return false; + } + + protected function matchAny(array $types): Token|false + { + foreach ($types as $type) { + if ($this->check($type) === true) { + return $this->advance(); + } + } + + return false; + } +} diff --git a/src/Toolkit/Query/Parser.php b/src/Toolkit/Query/Parser.php new file mode 100644 index 0000000000..f5b4801120 --- /dev/null +++ b/src/Toolkit/Query/Parser.php @@ -0,0 +1,200 @@ +expression(); + + // ensure that we consumed all tokens + if ($this->isAtEnd() === false) { + $this->consume(TokenType::T_EOF, 'Expect end of expression.'); + } + + return $expression; + } + + private function expression(): Node + { + return $this->coalesce(); + } + + private function coalesce(): Node + { + $left = $this->ternary(); + + while ($this->match(TokenType::T_COALESCE)) { + $right = $this->ternary(); + $left = new CoalesceNode($left, $right); + } + + return $left; + } + + private function ternary(): Node + { + $left = $this->memberAccess(); + + if ($tok = $this->matchAny([TokenType::T_QUESTION_MARK, TokenType::T_TERNARY_DEFAULT])) { + if ($tok->type === TokenType::T_TERNARY_DEFAULT) { + $trueIsDefault = true; + $trueBranch = null; + } else { + $trueIsDefault = false; + $trueBranch = $this->expression(); + $this->consume(TokenType::T_COLON, 'Expect ":" after true branch.'); + } + + $falseBranch = $this->expression(); + + return new TernaryNode( + $left, + $trueBranch, + $falseBranch, + $trueIsDefault + ); + } + + return $left; + } + + private function memberAccess(): Node + { + $left = $this->atomic(); + + while ($tok = $this->matchAny([TokenType::T_DOT, TokenType::T_NULLSAFE])) { + $nullSafe = $tok->type === TokenType::T_NULLSAFE; + + if ($right = $this->match(TokenType::T_IDENTIFIER)) { + $right = $right->lexeme; + } elseif ($right = $this->match(TokenType::T_INTEGER)) { + $right = $right->literal; + } else { + throw new Exception('Expect property name after ".".'); + } + + if ($this->match(TokenType::T_OPEN_PAREN)) { + $arguments = $this->argumentList(); + } + + $left = new MemberAccessNode( + $left, + $right, + $arguments ?? null, + $nullSafe + ); + } + + return $left; + } + + private function listUntil(TokenType $until): array + { + $elements = []; + + while ($this->isAtEnd() === false && $this->check($until) === false) { + $elements[] = $this->expression(); + + if ($this->match(TokenType::T_COMMA) == false) { + break; + } + } + + // consume the closing token + $this->consume($until, 'Expect closing bracket after list.'); + + return $elements; + } + + private function argumentList(): Node + { + $list = $this->listUntil(TokenType::T_CLOSE_PAREN); + + return new ArgumentListNode($list); + } + + private function atomic(): Node + { + // float numbers + if ($integer = $this->match(TokenType::T_INTEGER)) { + if ($this->match(TokenType::T_DOT)) { + $fractional = $this->match(TokenType::T_INTEGER); + return new LiteralNode((float)($integer->literal . '.' . $fractional->literal)); + } + + return new LiteralNode($integer->literal); + } + + // primitives + if ($token = $this->matchAny([ + TokenType::T_TRUE, + TokenType::T_FALSE, + TokenType::T_NULL, + TokenType::T_STRING, + ])) { + return new LiteralNode($token->literal); + } + + // array literals + if ($token = $this->match(TokenType::T_OPEN_BRACKET)) { + $arrayItems = $this->listUntil(TokenType::T_CLOSE_BRACKET); + + return new ArrayListNode($arrayItems); + } + + // global functions and variables + if ($token = $this->match(TokenType::T_IDENTIFIER)) { + if ($this->match(TokenType::T_OPEN_PAREN)) { + $arguments = $this->argumentList(); + return new GlobalFunctionNode($token->lexeme, $arguments); + } + + return new VariableNode($token->lexeme); + } + + // grouping and closure argument lists + if ($token = $this->match(TokenType::T_OPEN_PAREN)) { + $list = $this->listUntil(TokenType::T_CLOSE_PAREN); + + if ($this->match(TokenType::T_ARROW)) { + $expression = $this->expression(); + + /** + * Assert that all elements are VariableNodes + * @var VariableNode[] $list + */ + foreach($list as $element) { + if ($element instanceof VariableNode === false) { + throw new Exception('Expecting only variables in closure argument list.'); + } + } + + $arguments = array_map(fn ($element) => $element->name, $list); + return new ClosureNode($arguments, $expression); + } + + if (count($list) > 1) { + throw new Exception('Expecting \"=>\" after closure argument list.'); + } + + // this is just a grouping + return $list[0]; + } + + throw new Exception('Expect expression.'); + } +} diff --git a/src/Toolkit/Query/Runner.php b/src/Toolkit/Query/Runner.php new file mode 100644 index 0000000000..d01b295660 --- /dev/null +++ b/src/Toolkit/Query/Runner.php @@ -0,0 +1,31 @@ +parse(); + + $self = $this; + + return self::$cache[$query] = function (array $binding) use ($node, $self) { + $interpreter = new Interpreter($self->allowedFunctions, $binding); + + if ($self->interceptor !== null) { + $interpreter->setInterceptor($self->interceptor); + } + + return $node->accept($interpreter); + }; + } + + public function run(string $query, array $context = []): mixed + { + $resolver = $this->getResolver($query); + return $resolver($context); + } +} diff --git a/src/Toolkit/Query/Runners/Transpiled.php b/src/Toolkit/Query/Runners/Transpiled.php new file mode 100644 index 0000000000..09a3d5fbfd --- /dev/null +++ b/src/Toolkit/Query/Runners/Transpiled.php @@ -0,0 +1,107 @@ +parse(); + $codeGen = new CodeGen($this->allowedFunctions); + + $functionBody = $node->accept($codeGen); + + $mappings = array_map( + fn ($k, $v) => "$k = $v;", + array_keys($codeGen->mappings), + $codeGen->mappings + ); + $mappings = join("\n", $mappings) . "\n"; + + $comment = array_map(fn ($l) => "// $l", explode("\n", $query)); + $comment = join("\n", $comment); + + $uses = array_map(fn ($k) => "use $k;", array_keys($codeGen->uses)); + $uses = join("\n", $uses) . "\n"; + + $function = "getResolver($query); + + if (is_callable($function) === false) { + throw new Exception('Query is not valid'); + } + + return $function( + $context, + $this->allowedFunctions, + $this->interceptor ?? fn ($v) => $v + ); + } +} diff --git a/src/Toolkit/Query/Runners/Visitors/CodeGen.php b/src/Toolkit/Query/Runners/Visitors/CodeGen.php new file mode 100644 index 0000000000..d28a766779 --- /dev/null +++ b/src/Toolkit/Query/Runners/Visitors/CodeGen.php @@ -0,0 +1,185 @@ + + */ + public array $uses = []; + + /** + * @var array + */ + public array $mappings = []; + + + /** + * Variable names in Query Language are different from PHP variable names, + * they can start with a number and may contain escaped dots. + * + * This method returns a sanitized PHP variable name. + */ + private static function phpName(string $name): string + { + return '$_' . crc32($name); + } + + /** + * CodeGen constructor. + * + * @param array $validGlobalFunctions An array of valid global function closures. + */ + public function __construct( + public array $validGlobalFunctions = [], + public array $directAccessFor = [] + ) { + } + + private function intercept(string $value): string + { + return "(\$intercept($value))"; + } + + public function visitArgumentList(ArgumentListNode $node): string + { + $arguments = array_map( + fn ($argument) => $argument->accept($this), + $node->arguments + ); + + return join(', ', $arguments); + } + + public function visitArrayList(ArrayListNode $node): string + { + $elements = array_map( + fn ($element) => $element->accept($this), + $node->elements + ); + + return '[' . join(', ', $elements) . ']'; + } + + public function visitCoalesce(CoalesceNode $node): string + { + $left = $node->left->accept($this); + $right = $node->right->accept($this); + return "($left ?? $right)"; + } + + public function visitLiteral(LiteralNode $node): string + { + return '$intercept(' . var_export($node->value, true) . ')'; + } + + public function visitMemberAccess(MemberAccessNode $node): string + { + $object = $node->object->accept($this); + $member = $node->member; + + $this->uses[Runtime::class] = true; + $memberStr = var_export($member, true); + $nullSafe = $node->nullSafe ? 'true' : 'false'; + + if ($node->arguments) { + $arguments = $node->arguments->accept($this); + $member = var_export($member, true); + + return $this->intercept( + "Runtime::access($object, $memberStr, $nullSafe, $arguments)" + ); + } + + return $this->intercept( + "Runtime::access($object, $memberStr, $nullSafe)" + ); + } + + public function visitTernary(TernaryNode $node): string + { + $left = $node->condition->accept($this); + $falseBranch = $node->falseBranch->accept($this); + + if ($node->trueBranchIsDefault === true) { + return "($left ?: $falseBranch)"; + } + + $trueBranch = $node->trueBranch->accept($this); + return "($left ? $trueBranch : $falseBranch)"; + + } + + public function visitVariable(VariableNode $node): string + { + $name = $node->name(); + $namestr = var_export($name, true); + $key = static::phpName($name); + + if (isset($this->directAccessFor[$name])) { + return $this->intercept($key); + } + + if (isset($this->mappings[$key]) === false) { + $this->mappings[$key] = $this->intercept("match(true) { isset(\$context[$namestr]) && \$context[$namestr] instanceof Closure => \$context[$namestr](), isset(\$context[$namestr]) => \$context[$namestr], isset(\$functions[$namestr]) => \$functions[$namestr](), default => null }"); + } + + return $key; + } + + /** + * Generates code like `$functions['function']($arguments)` from a global function node. + */ + public function visitGlobalFunction(GlobalFunctionNode $node): string + { + $name = $node->name(); + + if (isset($this->validGlobalFunctions[$name])) { + throw new Exception("Invalid global function $name"); + } + + $arguments = $node->arguments->accept($this); + $name = var_export($name, true); + + return $this->intercept($this->intercept("\$functions[$name]") . "($arguments)"); + } + + public function visitClosure(ClosureNode $node): mixed + { + $this->uses[Runtime::class] = true; + + $args = array_map(static::phpName(...), $node->arguments); + $args = join(', ', $args); + + $newDirectAccessFor = [ + ...$this->directAccessFor, + ...array_fill_keys($node->arguments, true) + ]; + + return "fn($args) => " . $node->body->accept( + new static($this->validGlobalFunctions, $newDirectAccessFor) + ); + } +} diff --git a/src/Toolkit/Query/Runners/Visitors/Interpreter.php b/src/Toolkit/Query/Runners/Visitors/Interpreter.php new file mode 100644 index 0000000000..01c1e74507 --- /dev/null +++ b/src/Toolkit/Query/Runners/Visitors/Interpreter.php @@ -0,0 +1,169 @@ + $validGlobalFunctions An array of valid global function closures. + * @param array $context The data bindings for the query. + */ + public function __construct( + public array $validGlobalFunctions = [], + public array $context = [] + ) { + } + + public function visitArgumentList(ArgumentListNode $node): array + { + return array_map( + fn ($argument) => $argument->accept($this), + $node->arguments + ); + } + + public function visitArrayList(ArrayListNode $node): mixed + { + return array_map( + fn ($element) => $element->accept($this), + $node->elements + ); + } + + public function visitCoalesce(CoalesceNode $node): mixed + { + return $node->left->accept($this) ?? $node->right->accept($this); + } + + public function visitLiteral(LiteralNode $node): mixed + { + $val = $node->value; + + if ($this->interceptor !== null) { + $val = ($this->interceptor)($val); + } + + return $val; + } + + public function visitMemberAccess(MemberAccessNode $node): mixed + { + $left = $node->object->accept($this); + $item = null; + + if ($node->arguments !== null) { + $item = Runtime::access( + $left, + $node->member, + $node->nullSafe, + ...$node->arguments->accept($this) + ); + } else { + $item = Runtime::access($left, $node->member, $node->nullSafe); + } + + if ($this->interceptor !== null) { + $item = ($this->interceptor)($item); + } + + return $item; + } + + public function visitTernary(TernaryNode $node): mixed + { + if ($node->trueBranchIsDefault === true) { + return + $node->condition->accept($this) ?: + $node->trueBranch->accept($this); + } + + return + $node->condition->accept($this) ? + $node->trueBranch->accept($this) : + $node->falseBranch->accept($this); + } + + public function visitVariable(VariableNode $node): mixed + { + // what looks like a variable might actually be a global function + // but if there is a variable with the same name, the variable takes precedence + + $name = $node->name(); + + $item = match (true) { + isset($this->context[$name]) => $this->context[$name] instanceof Closure ? $this->context[$name]() : $this->context[$name], + isset($this->validGlobalFunctions[$name]) => $this->validGlobalFunctions[$name](), + default => null, + }; + + if ($this->interceptor !== null) { + $item = ($this->interceptor)($item); + } + + return $item; + } + + public function visitGlobalFunction(GlobalFunctionNode $node): mixed + { + $name = $node->name(); + + if (isset($this->validGlobalFunctions[$name]) === false) { + throw new Exception("Invalid global function $name"); + } + + $function = $this->validGlobalFunctions[$name]; + + if ($this->interceptor !== null) { + $function = ($this->interceptor)($function); + } + + $result = $function(...$node->arguments->accept($this)); + + if ($this->interceptor !== null) { + $result = ($this->interceptor)($result); + } + + return $result; + } + + public function visitClosure(ClosureNode $node): mixed + { + $self = $this; + + return function (...$params) use ($self, $node) { + $context = $self->context; + $functions = $self->validGlobalFunctions; + + // [key1, key2] + [value1, value2] => [key1 => value1, key2 => value2] + $arguments = array_combine( + $node->arguments, + $params + ); + + $visitor = new static($functions, [...$context, ...$arguments]); + + if ($self->interceptor !== null) { + $visitor->setInterceptor($self->interceptor); + } + + return $node->body->accept($visitor); + }; + } +} diff --git a/src/Toolkit/Query/Runtime.php b/src/Toolkit/Query/Runtime.php new file mode 100644 index 0000000000..66be6a59cf --- /dev/null +++ b/src/Toolkit/Query/Runtime.php @@ -0,0 +1,51 @@ +$key(...$arguments); + } + + return $object->$key ?? null; + } + + throw new Exception("Cannot access \"$key\" on " . gettype($object)); + } +} diff --git a/src/Toolkit/Query/Token.php b/src/Toolkit/Query/Token.php new file mode 100644 index 0000000000..99c0a405a7 --- /dev/null +++ b/src/Toolkit/Query/Token.php @@ -0,0 +1,13 @@ +length = mb_strlen($source); + } + + /** + * Tokenizes the source string and returns a generator of tokens. + * @return Generator + */ + public function tokenize(): Generator + { + $current = 0; + + while ($current < $this->length) { + $token = static::scanToken($this->source, $current); + + // don't yield whitespace tokens (ignore them) + if ($token->type !== TokenType::T_WHITESPACE) { + yield $token; + } + + $current += mb_strlen($token->lexeme); + } + + yield new Token(TokenType::T_EOF, '', null); + } + + /** + * Scans the source string for a token starting at the given position. + * @param string $source The source string + * @param int $current The current position in the source string + * + * @return Token The scanned token + * @throws Exception If an unexpected character is encountered + */ + protected static function scanToken(string $source, int $current): Token + { + $lex = ''; + $char = $source[$current]; + + return match(true) { + // single character tokens + $char === '.' => new Token(TokenType::T_DOT, '.'), + $char === '(' => new Token(TokenType::T_OPEN_PAREN, '('), + $char === ')' => new Token(TokenType::T_CLOSE_PAREN, ')'), + $char === '[' => new Token(TokenType::T_OPEN_BRACKET, '['), + $char === ']' => new Token(TokenType::T_CLOSE_BRACKET, ']'), + $char === ',' => new Token(TokenType::T_COMMA, ','), + $char === ':' => new Token(TokenType::T_COLON, ':'), + + // two character tokens + static::match($source, $current, '\?\?', $lex) + => new Token(TokenType::T_COALESCE, $lex), + static::match($source, $current, '\?\s*\.', $lex) + => new Token(TokenType::T_NULLSAFE, $lex), + static::match($source, $current, '\?\s*:', $lex) + => new Token(TokenType::T_TERNARY_DEFAULT, $lex), + static::match($source, $current, '=>', $lex) + => new Token(TokenType::T_ARROW, $lex), + + // make sure this check comes after the two above + // that check for '?' in the beginning + $char === '?' => new Token(TokenType::T_QUESTION_MARK, '?'), + + // multi character tokens + static::match($source, $current, '\s+', $lex) + => new Token(TokenType::T_WHITESPACE, $lex), + static::match($source, $current, 'true', $lex, true) + => new Token(TokenType::T_TRUE, $lex, true), + static::match($source, $current, 'false', $lex, true) + => new Token(TokenType::T_FALSE, $lex, false), + static::match($source, $current, 'null', $lex, true) + => new Token(TokenType::T_NULL, $lex, null), + static::match($source, $current, static::DOUBLEQUOTE_STRING_REGEX, $lex) + => new Token(TokenType::T_STRING, $lex, stripcslashes(substr($lex, 1, -1))), + static::match($source, $current, static::SINGLEQUOTE_STRING_REGEX, $lex) + => new Token(TokenType::T_STRING, $lex, stripcslashes(substr($lex, 1, -1))), + static::match($source, $current, '\d+\b', $lex) + => new Token(TokenType::T_INTEGER, $lex, (int)$lex), + static::match($source, $current, static::IDENTIFIER_REGEX, $lex) + => new Token(TokenType::T_IDENTIFIER, $lex), + + // unknown token + default => throw new Exception("Unexpected character: {$source[$current]}"), + }; + } + + /** + * Matches a regex pattern at the current position in the source string. + * The matched lexeme will be stored in the $lexeme variable. + * + * @param string $source The source string + * @param int $current The current position in the source string (used as offset for the regex) + * @param string $regex The regex pattern to match (without delimiters / flags) + * @param string $lexeme The matched lexeme will be stored in this variable + * @param bool $caseIgnore Whether to ignore case while matching + * @return bool Whether the regex pattern was matched + */ + protected static function match( + string $source, + int $current, + string $regex, + string &$lexeme, + bool $caseIgnore = false + ): bool { + $regex = '/\G' . $regex . '/u'; + + if ($caseIgnore) { + $regex .= 'i'; + } + + $matches = []; + preg_match($regex, $source, $matches, 0, $current); + + if (empty($matches[0])) { + return false; + } + + $lexeme = $matches[0]; + return true; + } +} diff --git a/src/Toolkit/Query/Visitor.php b/src/Toolkit/Query/Visitor.php new file mode 100644 index 0000000000..fc58257799 --- /dev/null +++ b/src/Toolkit/Query/Visitor.php @@ -0,0 +1,55 @@ +getShortName(); + + // remove the "Node" suffix + $shortName = substr($shortName, 0, -4); + $method = 'visit' . $shortName; + + if (method_exists($this, $method)) { + return $this->$method($node); + } + + throw new Exception('No visitor method for ' . $node::class); + } + + abstract public function visitArgumentList(ArgumentListNode $node): mixed; + abstract public function visitArrayList(ArrayListNode $node): mixed; + abstract public function visitCoalesce(CoalesceNode $node): mixed; + abstract public function visitLiteral(LiteralNode $node): mixed; + abstract public function visitMemberAccess(MemberAccessNode $node): mixed; + abstract public function visitTernary(TernaryNode $node): mixed; + abstract public function visitVariable(VariableNode $node): mixed; + abstract public function visitGlobalFunction(GlobalFunctionNode $node): mixed; + abstract public function visitClosure(ClosureNode $node): mixed; + + /** + * Sets and activates an interceptor closure that is called for each resolved value. + */ + public function setInterceptor(Closure $interceptor): void + { + $this->interceptor = $interceptor; + } +} diff --git a/tests/Query/QueryTest.php b/tests/Query/QueryTest.php index c51d961f48..486d4ddb2f 100644 --- a/tests/Query/QueryTest.php +++ b/tests/Query/QueryTest.php @@ -67,11 +67,24 @@ public function testResolveWithExactArrayMatch() $query = new Query('user'); $this->assertSame('homer', $query->resolve(['user' => 'homer'])); - $query = new Query('user.username'); + $query = new Query('user\.username'); $this->assertSame('homer', $query->resolve(['user.username' => 'homer'])); - $query = new Query('user.callback'); + $query = new Query('user\.callback'); $this->assertSame('homer', $query->resolve(['user.callback' => fn () => 'homer'])); + + // in the query, the first slash escapes the second, the third escapes the dot + $query = <<<'TXT' + user\\\.username + TXT; + + // this is actually the array key + $key = <<<'TXT' + user\.username + TXT; + + $query = new Query($query); + $this->assertSame('homer', $query->resolve([$key => 'homer'])); } /** @@ -89,7 +102,50 @@ public function testResolveWithClosureArgument() $bar = $query->resolve($data); $this->assertInstanceOf(Closure::class, $bar); + $bar = $bar(); $this->assertSame('simpson', $bar); } + + /** + * @covers ::resolve + */ + public function testResolveWithClosureWithArgument() + { + $query = new Query('(foo) => foo.homer'); + $data = []; + + $bar = $query->resolve($data); + $this->assertInstanceOf(Closure::class, $bar); + + $bar = $bar(['homer' => 'simpson']); + $this->assertSame('simpson', $bar); + } + + /** + * @covers ::intercept + */ + public function testResolveWithInterceptor() + { + $query = new class ('foo.getObj.name') extends Query { + public function intercept($result): mixed + { + if(is_object($result) === true) { + $result = clone $result; + $result->name .= ' simpson'; + } + + return $result; + } + }; + + $data = [ + 'foo' => [ + 'getObj' => fn () => (object)['name' => 'homer'] + ] + ]; + + $bar = $query->resolve($data); + $this->assertSame('homer simpson', $bar); + } }