Initial commit
This commit is contained in:
commit
efa5624dab
687 changed files with 162710 additions and 0 deletions
37
kirby/src/Query/AST/ArgumentListNode.php
Normal file
37
kirby/src/Query/AST/ArgumentListNode.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\AST;
|
||||
|
||||
use Kirby\Query\Visitors\Visitor;
|
||||
|
||||
/**
|
||||
* Represents a list of (method) arguments in the AST
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>,
|
||||
* Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class ArgumentListNode extends Node
|
||||
{
|
||||
public function __construct(
|
||||
public array $arguments = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(Visitor $visitor): array|string
|
||||
{
|
||||
// Resolve each argument
|
||||
$arguments = array_map(
|
||||
fn ($argument) => $argument->resolve($visitor),
|
||||
$this->arguments
|
||||
);
|
||||
|
||||
// Keep as array or convert to string
|
||||
// depending on the visitor type
|
||||
return $visitor->arguments($arguments);
|
||||
}
|
||||
}
|
||||
34
kirby/src/Query/AST/ArithmeticNode.php
Normal file
34
kirby/src/Query/AST/ArithmeticNode.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\AST;
|
||||
|
||||
use Kirby\Query\Visitors\Visitor;
|
||||
|
||||
/**
|
||||
* Represents an arithmetic operation between two values in the AST
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class ArithmeticNode extends Node
|
||||
{
|
||||
public function __construct(
|
||||
public Node $left,
|
||||
public string $operator,
|
||||
public Node $right
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(Visitor $visitor): mixed
|
||||
{
|
||||
return $visitor->arithmetic(
|
||||
left: $this->left->resolve($visitor),
|
||||
operator: $this->operator,
|
||||
right: $this->right->resolve($visitor)
|
||||
);
|
||||
}
|
||||
}
|
||||
37
kirby/src/Query/AST/ArrayListNode.php
Normal file
37
kirby/src/Query/AST/ArrayListNode.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\AST;
|
||||
|
||||
use Kirby\Query\Visitors\Visitor;
|
||||
|
||||
/**
|
||||
* Represents a (array) list of elements in the AST
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>,
|
||||
* Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class ArrayListNode extends Node
|
||||
{
|
||||
public function __construct(
|
||||
public array $elements,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(Visitor $visitor): array|string
|
||||
{
|
||||
// Resolve each array element
|
||||
$elements = array_map(
|
||||
fn ($element) => $element->resolve($visitor),
|
||||
$this->elements
|
||||
);
|
||||
|
||||
// Keep as array or convert to string
|
||||
// depending on the visitor type
|
||||
return $visitor->arrayList($elements);
|
||||
}
|
||||
}
|
||||
33
kirby/src/Query/AST/ClosureNode.php
Normal file
33
kirby/src/Query/AST/ClosureNode.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\AST;
|
||||
|
||||
use Kirby\Query\Visitors\Visitor;
|
||||
|
||||
/**
|
||||
* Represents a closure in the AST
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>,
|
||||
* Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class ClosureNode extends Node
|
||||
{
|
||||
/**
|
||||
* @param string[] $arguments The arguments names
|
||||
*/
|
||||
public function __construct(
|
||||
public array $arguments,
|
||||
public Node $body,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(Visitor $visitor): mixed
|
||||
{
|
||||
return $visitor->closure($this);
|
||||
}
|
||||
}
|
||||
33
kirby/src/Query/AST/CoalesceNode.php
Normal file
33
kirby/src/Query/AST/CoalesceNode.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\AST;
|
||||
|
||||
use Kirby\Query\Visitors\Visitor;
|
||||
|
||||
/**
|
||||
* Represents a coalesce operation in the AST
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>,
|
||||
* Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class CoalesceNode extends Node
|
||||
{
|
||||
public function __construct(
|
||||
public Node $left,
|
||||
public Node $right,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(Visitor $visitor): mixed
|
||||
{
|
||||
return $visitor->coalescence(
|
||||
left: $this->left->resolve($visitor),
|
||||
right: $this->right->resolve($visitor)
|
||||
);
|
||||
}
|
||||
}
|
||||
34
kirby/src/Query/AST/ComparisonNode.php
Normal file
34
kirby/src/Query/AST/ComparisonNode.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\AST;
|
||||
|
||||
use Kirby\Query\Visitors\Visitor;
|
||||
|
||||
/**
|
||||
* Represents a comparison operation between two values in the AST
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class ComparisonNode extends Node
|
||||
{
|
||||
public function __construct(
|
||||
public Node $left,
|
||||
public string $operator,
|
||||
public Node $right
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(Visitor $visitor): bool|string
|
||||
{
|
||||
return $visitor->comparison(
|
||||
left: $this->left->resolve($visitor),
|
||||
operator: $this->operator,
|
||||
right: $this->right->resolve($visitor)
|
||||
);
|
||||
}
|
||||
}
|
||||
33
kirby/src/Query/AST/GlobalFunctionNode.php
Normal file
33
kirby/src/Query/AST/GlobalFunctionNode.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\AST;
|
||||
|
||||
use Kirby\Query\Visitors\Visitor;
|
||||
|
||||
/**
|
||||
* Represents a global function call in the AST
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>,
|
||||
* Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class GlobalFunctionNode extends Node
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public ArgumentListNode $arguments,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(Visitor $visitor): mixed
|
||||
{
|
||||
return $visitor->function(
|
||||
name: $this->name,
|
||||
arguments: $this->arguments->resolve($visitor)
|
||||
);
|
||||
}
|
||||
}
|
||||
29
kirby/src/Query/AST/LiteralNode.php
Normal file
29
kirby/src/Query/AST/LiteralNode.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\AST;
|
||||
|
||||
use Kirby\Query\Visitors\Visitor;
|
||||
|
||||
/**
|
||||
* Represents literal values (e.g. string, int, bool) in the AST
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>,
|
||||
* Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class LiteralNode extends Node
|
||||
{
|
||||
public function __construct(
|
||||
public mixed $value,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(Visitor $visitor): mixed
|
||||
{
|
||||
return $visitor->literal($this->value);
|
||||
}
|
||||
}
|
||||
34
kirby/src/Query/AST/LogicalNode.php
Normal file
34
kirby/src/Query/AST/LogicalNode.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\AST;
|
||||
|
||||
use Kirby\Query\Visitors\Visitor;
|
||||
|
||||
/**
|
||||
* Represents a logical operation between two values in the AST
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class LogicalNode extends Node
|
||||
{
|
||||
public function __construct(
|
||||
public Node $left,
|
||||
public string $operator,
|
||||
public Node $right
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(Visitor $visitor): bool|string
|
||||
{
|
||||
return $visitor->logical(
|
||||
left: $this->left->resolve($visitor),
|
||||
operator: $this->operator,
|
||||
right: $this->right->resolve($visitor)
|
||||
);
|
||||
}
|
||||
}
|
||||
37
kirby/src/Query/AST/MemberAccessNode.php
Normal file
37
kirby/src/Query/AST/MemberAccessNode.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\AST;
|
||||
|
||||
use Kirby\Query\Visitors\Visitor;
|
||||
|
||||
/**
|
||||
* Represents the access (e.g. method call) on a node in the AST
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>,
|
||||
* Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class MemberAccessNode extends Node
|
||||
{
|
||||
public function __construct(
|
||||
public Node $object,
|
||||
public Node $member,
|
||||
public ArgumentListNode|null $arguments = null,
|
||||
public bool $nullSafe = false,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(Visitor $visitor): mixed
|
||||
{
|
||||
return $visitor->memberAccess(
|
||||
object: $this->object->resolve($visitor),
|
||||
member: $this->member->resolve($visitor),
|
||||
arguments: $this->arguments?->resolve($visitor),
|
||||
nullSafe: $this->nullSafe
|
||||
);
|
||||
}
|
||||
}
|
||||
23
kirby/src/Query/AST/Node.php
Normal file
23
kirby/src/Query/AST/Node.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\AST;
|
||||
|
||||
use Kirby\Query\Visitors\Visitor;
|
||||
|
||||
/**
|
||||
* Basic node representation in the query AST
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>,
|
||||
* Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
abstract class Node
|
||||
{
|
||||
abstract public function resolve(Visitor $visitor);
|
||||
}
|
||||
36
kirby/src/Query/AST/TernaryNode.php
Normal file
36
kirby/src/Query/AST/TernaryNode.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\AST;
|
||||
|
||||
use Kirby\Query\Visitors\Visitor;
|
||||
|
||||
/**
|
||||
* Represents a ternary condition in the AST,
|
||||
* with a value for when the condition is true
|
||||
* and another value for when the condition is false
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class TernaryNode extends Node
|
||||
{
|
||||
public function __construct(
|
||||
public Node $condition,
|
||||
public Node $false,
|
||||
public Node|null $true = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(Visitor $visitor): mixed
|
||||
{
|
||||
return $visitor->ternary(
|
||||
condition: $this->condition->resolve($visitor),
|
||||
true: $this->true?->resolve($visitor),
|
||||
false: $this->false->resolve($visitor)
|
||||
);
|
||||
}
|
||||
}
|
||||
29
kirby/src/Query/AST/VariableNode.php
Normal file
29
kirby/src/Query/AST/VariableNode.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\AST;
|
||||
|
||||
use Kirby\Query\Visitors\Visitor;
|
||||
|
||||
/**
|
||||
* Represents a variable (e.g. an object) in the AST
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>,
|
||||
* Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class VariableNode extends Node
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(Visitor $visitor): mixed
|
||||
{
|
||||
return $visitor->variable($this->name);
|
||||
}
|
||||
}
|
||||
119
kirby/src/Query/Argument.php
Normal file
119
kirby/src/Query/Argument.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* The Argument class represents a single
|
||||
* parameter passed to a method in a chained query
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*
|
||||
* @todo Deprecate in v6
|
||||
*/
|
||||
class Argument
|
||||
{
|
||||
public function __construct(
|
||||
public mixed $value
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes argument string into actual
|
||||
* PHP type/object as new Argument instance
|
||||
*/
|
||||
public static function factory(string $argument): static
|
||||
{
|
||||
$argument = trim($argument);
|
||||
|
||||
// remove grouping parantheses
|
||||
if (
|
||||
Str::startsWith($argument, '(') &&
|
||||
Str::endsWith($argument, ')')
|
||||
) {
|
||||
$argument = trim(substr($argument, 1, -1));
|
||||
}
|
||||
|
||||
// string with single quotes
|
||||
if (
|
||||
Str::startsWith($argument, "'") &&
|
||||
Str::endsWith($argument, "'")
|
||||
) {
|
||||
$string = substr($argument, 1, -1);
|
||||
$string = str_replace("\'", "'", $string);
|
||||
return new static($string);
|
||||
}
|
||||
|
||||
// string with double quotes
|
||||
if (
|
||||
Str::startsWith($argument, '"') &&
|
||||
Str::endsWith($argument, '"')
|
||||
) {
|
||||
$string = substr($argument, 1, -1);
|
||||
$string = str_replace('\"', '"', $string);
|
||||
return new static($string);
|
||||
}
|
||||
|
||||
// array: split and recursive sanitizing
|
||||
if (
|
||||
Str::startsWith($argument, '[') &&
|
||||
Str::endsWith($argument, ']')
|
||||
) {
|
||||
$array = substr($argument, 1, -1);
|
||||
$array = Arguments::factory($array);
|
||||
return new static($array);
|
||||
}
|
||||
|
||||
// numeric
|
||||
if (is_numeric($argument) === true) {
|
||||
if (str_contains($argument, '.') === false) {
|
||||
return new static((int)$argument);
|
||||
}
|
||||
|
||||
return new static((float)$argument);
|
||||
}
|
||||
|
||||
// Closure
|
||||
if (Str::startsWith($argument, '() =>')) {
|
||||
$query = Str::after($argument, '() =>');
|
||||
$query = trim($query);
|
||||
return new static(fn () => $query);
|
||||
}
|
||||
|
||||
return new static(match ($argument) {
|
||||
'null' => null,
|
||||
'true' => true,
|
||||
'false' => false,
|
||||
|
||||
// resolve parameter for objects and methods itself
|
||||
default => new Query($argument)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the argument value and
|
||||
* resolves nested objects to scaler types
|
||||
*/
|
||||
public function resolve(array|object $data = []): mixed
|
||||
{
|
||||
// don't resolve the Closure immediately, instead
|
||||
// resolve it to the sub-query and create a new Closure
|
||||
// that resolves the sub-query with the same data set once called
|
||||
if ($this->value instanceof Closure) {
|
||||
$query = ($this->value)();
|
||||
return fn () => static::factory($query)->resolve($data);
|
||||
}
|
||||
|
||||
if (is_object($this->value) === true) {
|
||||
return $this->value->resolve($data);
|
||||
}
|
||||
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
63
kirby/src/Query/Arguments.php
Normal file
63
kirby/src/Query/Arguments.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query;
|
||||
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Collection;
|
||||
|
||||
/**
|
||||
* The Arguments class helps splitting a
|
||||
* parameter string into processable arguments
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*
|
||||
* @todo Deprecate in v6
|
||||
*
|
||||
* @extends \Kirby\Toolkit\Collection<\Kirby\Query\Argument>
|
||||
*/
|
||||
class Arguments extends Collection
|
||||
{
|
||||
// skip all matches inside of parantheses
|
||||
public const NO_PNTH = '\([^)]+\)(*SKIP)(*FAIL)';
|
||||
// skip all matches inside of square brackets
|
||||
public const NO_SQBR = '\[[^]]+\](*SKIP)(*FAIL)';
|
||||
// skip all matches inside of double quotes
|
||||
public const NO_DLQU = '\"(?:[^"\\\\]|\\\\.)*\"(*SKIP)(*FAIL)';
|
||||
// skip all matches inside of single quotes
|
||||
public const NO_SLQU = '\'(?:[^\'\\\\]|\\\\.)*\'(*SKIP)(*FAIL)';
|
||||
// skip all matches inside of any of the above skip groups
|
||||
public const OUTSIDE =
|
||||
self::NO_PNTH . '|' . self::NO_SQBR . '|' .
|
||||
self::NO_DLQU . '|' . self::NO_SLQU;
|
||||
|
||||
/**
|
||||
* Splits list of arguments into individual
|
||||
* Argument instances while respecting skip groups
|
||||
*/
|
||||
public static function factory(string $arguments): static
|
||||
{
|
||||
$arguments = A::map(
|
||||
// split by comma, but not inside skip groups
|
||||
preg_split('!,|' . self::OUTSIDE . '!', $arguments),
|
||||
fn ($argument) => Argument::factory($argument)
|
||||
);
|
||||
|
||||
return new static($arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve each argument, so that they can
|
||||
* passed together to the actual method call
|
||||
*/
|
||||
public function resolve(array|object $data = []): array
|
||||
{
|
||||
return A::map(
|
||||
$this->data,
|
||||
fn ($argument) => $argument->resolve($data)
|
||||
);
|
||||
}
|
||||
}
|
||||
123
kirby/src/Query/Expression.php
Normal file
123
kirby/src/Query/Expression.php
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query;
|
||||
|
||||
use Kirby\Exception\LogicException;
|
||||
use Kirby\Toolkit\A;
|
||||
|
||||
/**
|
||||
* The Expression class adds support for simple shorthand
|
||||
* comparisons (`a ? b : c`, `a ?: c` and `a ?? b`)
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*
|
||||
* @todo Deprecate in v6
|
||||
*/
|
||||
class Expression
|
||||
{
|
||||
public function __construct(
|
||||
public array $parts
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an expression string into its parts
|
||||
*/
|
||||
public static function factory(string $expression, Query|null $parent = null): static|Segments
|
||||
{
|
||||
// split into different expression parts and operators
|
||||
$parts = static::parse($expression);
|
||||
|
||||
// shortcut: if expression has only one part, directly
|
||||
// continue with the segments chain
|
||||
if (count($parts) === 1) {
|
||||
return Segments::factory(query: $parts[0], parent: $parent);
|
||||
}
|
||||
|
||||
// turn all non-operator parts into an Argument
|
||||
// which takes care of converting string, arrays booleans etc.
|
||||
// into actual types and treats all other parts as their own queries
|
||||
$parts = A::map(
|
||||
$parts,
|
||||
fn ($part) => match ($part) {
|
||||
'?', ':', '?:', '??' => $part,
|
||||
default => Argument::factory($part)
|
||||
}
|
||||
);
|
||||
|
||||
return new static(parts: $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a comparison string into an array
|
||||
* of expressions and operators
|
||||
* @unstable
|
||||
*/
|
||||
public static function parse(string $string): array
|
||||
{
|
||||
// split by multiples of `?` and `:`, but not inside skip groups
|
||||
// (parantheses, quotes etc.)
|
||||
return preg_split(
|
||||
'/\s+([\?\:]+)\s+|' . Arguments::OUTSIDE . '/',
|
||||
trim($string),
|
||||
flags: PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the expression by evaluating
|
||||
* the supported comparisons and consecutively
|
||||
* resolving the resulting query/argument
|
||||
*/
|
||||
public function resolve(array|object $data = []): mixed
|
||||
{
|
||||
$base = null;
|
||||
|
||||
foreach ($this->parts as $index => $part) {
|
||||
// `a ?? b`
|
||||
// if the base/previous (e.g. `a`) isn't null,
|
||||
// stop the expression chain and return `a`
|
||||
if ($part === '??') {
|
||||
if ($base !== null) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// `a ?: b`
|
||||
// if `a` isn't false, return `a`, otherwise `b`
|
||||
if ($part === '?:') {
|
||||
if ($base != false) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
return $this->parts[$index + 1]->resolve($data);
|
||||
}
|
||||
|
||||
// `a ? b : c`
|
||||
// if `a` isn't false, return `b`, otherwise `c`
|
||||
if ($part === '?') {
|
||||
if (($this->parts[$index + 2] ?? null) !== ':') {
|
||||
throw new LogicException(
|
||||
message: 'Query: Incomplete ternary operator (missing matching `? :`)'
|
||||
);
|
||||
}
|
||||
|
||||
if ($base != false) {
|
||||
return $this->parts[$index + 1]->resolve($data);
|
||||
}
|
||||
|
||||
return $this->parts[$index + 3]->resolve($data);
|
||||
}
|
||||
|
||||
$base = $part->resolve($data);
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
}
|
||||
476
kirby/src/Query/Parser/Parser.php
Normal file
476
kirby/src/Query/Parser/Parser.php
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\Parser;
|
||||
|
||||
use Exception;
|
||||
use Iterator;
|
||||
use Kirby\Query\AST\ArgumentListNode;
|
||||
use Kirby\Query\AST\ArithmeticNode;
|
||||
use Kirby\Query\AST\ArrayListNode;
|
||||
use Kirby\Query\AST\ClosureNode;
|
||||
use Kirby\Query\AST\CoalesceNode;
|
||||
use Kirby\Query\AST\ComparisonNode;
|
||||
use Kirby\Query\AST\GlobalFunctionNode;
|
||||
use Kirby\Query\AST\LiteralNode;
|
||||
use Kirby\Query\AST\LogicalNode;
|
||||
use Kirby\Query\AST\MemberAccessNode;
|
||||
use Kirby\Query\AST\Node;
|
||||
use Kirby\Query\AST\TernaryNode;
|
||||
use Kirby\Query\AST\VariableNode;
|
||||
|
||||
/**
|
||||
* Parses query string by first splitting it into tokens
|
||||
* and then matching and consuming tokens to create
|
||||
* an abstract syntax tree (AST) of matching nodes
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>,
|
||||
* Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class Parser
|
||||
{
|
||||
protected Token $current;
|
||||
protected Token|null $previous = null;
|
||||
|
||||
/**
|
||||
* @var Iterator<Token>
|
||||
*/
|
||||
protected Iterator $tokens;
|
||||
|
||||
public function __construct(string|Iterator $query)
|
||||
{
|
||||
if (is_string($query) === true) {
|
||||
$tokenizer = new Tokenizer($query);
|
||||
$query = $tokenizer->tokens();
|
||||
}
|
||||
|
||||
$this->tokens = $query;
|
||||
$this->current = $this->tokens->current();
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to the next token
|
||||
*/
|
||||
protected function advance(): Token|null
|
||||
{
|
||||
if ($this->isAtEnd() === false) {
|
||||
$this->previous = $this->current;
|
||||
$this->tokens->next();
|
||||
$this->current = $this->tokens->current();
|
||||
}
|
||||
|
||||
return $this->previous;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an array
|
||||
*/
|
||||
private function array(): ArrayListNode|null
|
||||
{
|
||||
if ($this->consume(TokenType::T_OPEN_BRACKET)) {
|
||||
return new ArrayListNode(
|
||||
elements: $this->consumeList(TokenType::T_CLOSE_BRACKET)
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a list of arguments
|
||||
*/
|
||||
private function argumentList(): ArgumentListNode
|
||||
{
|
||||
return new ArgumentListNode(
|
||||
arguments: $this->consumeList(TokenType::T_CLOSE_PAREN)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for and parses several atomic expressions
|
||||
*/
|
||||
private function atomic(): Node
|
||||
{
|
||||
$token = $this->scalar();
|
||||
$token ??= $this->array();
|
||||
$token ??= $this->identifier();
|
||||
$token ??= $this->grouping();
|
||||
|
||||
if ($token === null) {
|
||||
throw new Exception('Expect expression'); // @codeCoverageIgnore
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for and parses a coalesce expression
|
||||
*/
|
||||
private function coalesce(): Node
|
||||
{
|
||||
$node = $this->logical();
|
||||
|
||||
while ($this->consume(TokenType::T_COALESCE)) {
|
||||
$node = new CoalesceNode(
|
||||
left: $node,
|
||||
right: $this->logical()
|
||||
);
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the next token of a type
|
||||
*
|
||||
* @throws \Exception when next token is not of specified type
|
||||
*/
|
||||
protected function consume(
|
||||
TokenType $type,
|
||||
string|false $error = false
|
||||
): Token|false {
|
||||
if ($this->is($type) === true) {
|
||||
return $this->advance();
|
||||
}
|
||||
|
||||
if (is_string($error) === true) {
|
||||
throw new Exception($error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to next token if of any specific type
|
||||
*/
|
||||
protected function consumeAny(array $types): Token|false
|
||||
{
|
||||
foreach ($types as $type) {
|
||||
if ($this->is($type) === true) {
|
||||
return $this->advance();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all list element until closing token
|
||||
*/
|
||||
private function consumeList(TokenType $until): array
|
||||
{
|
||||
$elements = [];
|
||||
|
||||
while (
|
||||
$this->isAtEnd() === false &&
|
||||
$this->is($until) === false
|
||||
) {
|
||||
$elements[] = $this->expression();
|
||||
|
||||
if ($this->consume(TokenType::T_COMMA) === false) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// consume the closing token
|
||||
$this->consume($until, 'Expect closing bracket after list');
|
||||
|
||||
return $elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current token
|
||||
*/
|
||||
public function current(): Token
|
||||
{
|
||||
return $this->current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a full query expression into a node
|
||||
*/
|
||||
private function expression(): Node
|
||||
{
|
||||
// Top-level expression should be ternary
|
||||
return $this->ternary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses comparison expressions with proper precedence
|
||||
*/
|
||||
private function comparison(): Node
|
||||
{
|
||||
$left = $this->arithmetic();
|
||||
|
||||
while ($token = $this->consumeAny([
|
||||
TokenType::T_EQUAL,
|
||||
TokenType::T_IDENTICAL,
|
||||
TokenType::T_NOT_EQUAL,
|
||||
TokenType::T_NOT_IDENTICAL,
|
||||
TokenType::T_LESS_THAN,
|
||||
TokenType::T_LESS_EQUAL,
|
||||
TokenType::T_GREATER_THAN,
|
||||
TokenType::T_GREATER_EQUAL
|
||||
])) {
|
||||
$left = new ComparisonNode(
|
||||
left: $left,
|
||||
operator: $token->lexeme,
|
||||
right: $this->arithmetic()
|
||||
);
|
||||
}
|
||||
|
||||
return $left;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a grouping (e.g. closure)
|
||||
*/
|
||||
private function grouping(): ClosureNode|Node|null
|
||||
{
|
||||
if ($this->consume(TokenType::T_OPEN_PAREN)) {
|
||||
$list = $this->consumeList(TokenType::T_CLOSE_PAREN);
|
||||
|
||||
if ($this->consume(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: $arguments,
|
||||
body: $expression
|
||||
);
|
||||
}
|
||||
|
||||
if (count($list) > 1) {
|
||||
throw new Exception('Expecting "=>" after closure argument list');
|
||||
}
|
||||
|
||||
// this is just a grouping
|
||||
return $list[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an identifier (global functions or variables)
|
||||
*/
|
||||
private function identifier(): GlobalFunctionNode|VariableNode|null
|
||||
{
|
||||
if ($token = $this->consume(TokenType::T_IDENTIFIER)) {
|
||||
if ($this->consume(TokenType::T_OPEN_PAREN)) {
|
||||
return new GlobalFunctionNode(
|
||||
name: $token->lexeme,
|
||||
arguments: $this->argumentList()
|
||||
);
|
||||
}
|
||||
|
||||
return new VariableNode(name: $token->lexeme);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current token is of a specific type
|
||||
*/
|
||||
protected function is(TokenType $type): bool
|
||||
{
|
||||
if ($this->isAtEnd() === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->current->is($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the parser has reached the end of the query
|
||||
*/
|
||||
protected function isAtEnd(): bool
|
||||
{
|
||||
return $this->current->is(TokenType::T_EOF);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for and parses a member access expression
|
||||
*/
|
||||
private function memberAccess(): Node
|
||||
{
|
||||
$object = $this->atomic();
|
||||
|
||||
while ($token = $this->consumeAny([
|
||||
TokenType::T_DOT,
|
||||
TokenType::T_NULLSAFE,
|
||||
TokenType::T_OPEN_BRACKET
|
||||
])) {
|
||||
if ($token->is(TokenType::T_OPEN_BRACKET) === true) {
|
||||
// For subscript notation, parse the inside as expression…
|
||||
$member = $this->expression();
|
||||
|
||||
// …and ensure consuming the closing bracket
|
||||
$this->consume(
|
||||
TokenType::T_CLOSE_BRACKET,
|
||||
'Expect subscript closing bracket'
|
||||
);
|
||||
} elseif ($member = $this->consume(TokenType::T_IDENTIFIER)) {
|
||||
$member = new LiteralNode($member->lexeme);
|
||||
} elseif ($member = $this->consume(TokenType::T_INTEGER)) {
|
||||
$member = new LiteralNode($member->literal);
|
||||
} else {
|
||||
throw new Exception('Expect property name after "."');
|
||||
}
|
||||
|
||||
$object = new MemberAccessNode(
|
||||
object: $object,
|
||||
member: $member,
|
||||
arguments: match ($this->consume(TokenType::T_OPEN_PAREN)) {
|
||||
false => null,
|
||||
default => $this->argumentList(),
|
||||
},
|
||||
nullSafe: $token->is(TokenType::T_NULLSAFE)
|
||||
);
|
||||
}
|
||||
|
||||
return $object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses arithmetic expressions with proper precedence
|
||||
*/
|
||||
private function arithmetic(): Node
|
||||
{
|
||||
$left = $this->term();
|
||||
|
||||
while ($token = $this->consumeAny([
|
||||
TokenType::T_PLUS,
|
||||
TokenType::T_MINUS
|
||||
])) {
|
||||
$left = new ArithmeticNode(
|
||||
left: $left,
|
||||
operator: $token->lexeme,
|
||||
right: $this->term()
|
||||
);
|
||||
}
|
||||
|
||||
return $left;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses multiplication, division, and modulo expressions
|
||||
*/
|
||||
private function term(): Node
|
||||
{
|
||||
$left = $this->memberAccess();
|
||||
|
||||
while ($token = $this->consumeAny([
|
||||
TokenType::T_MULTIPLY,
|
||||
TokenType::T_DIVIDE,
|
||||
TokenType::T_MODULO
|
||||
])) {
|
||||
$left = new ArithmeticNode(
|
||||
left: $left,
|
||||
operator: $token->lexeme,
|
||||
right: $this->memberAccess()
|
||||
);
|
||||
}
|
||||
|
||||
return $left;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses logical expressions with proper precedence
|
||||
*/
|
||||
private function logical(): Node
|
||||
{
|
||||
$left = $this->comparison();
|
||||
|
||||
while ($token = $this->consumeAny([
|
||||
TokenType::T_AND,
|
||||
TokenType::T_OR
|
||||
])) {
|
||||
$left = new LogicalNode(
|
||||
left: $left,
|
||||
operator: $token->lexeme,
|
||||
right: $this->comparison()
|
||||
);
|
||||
}
|
||||
|
||||
return $left;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the tokenized query into AST node tree
|
||||
*/
|
||||
public function parse(): Node
|
||||
{
|
||||
// Start parsing chain
|
||||
$expression = $this->expression();
|
||||
|
||||
// Ensure that we consumed all tokens
|
||||
if ($this->isAtEnd() === false) {
|
||||
$this->consume(TokenType::T_EOF, 'Expect end of expression'); // @codeCoverageIgnore
|
||||
}
|
||||
|
||||
return $expression;
|
||||
}
|
||||
|
||||
private function scalar(): LiteralNode|null
|
||||
{
|
||||
if ($token = $this->consumeAny([
|
||||
TokenType::T_TRUE,
|
||||
TokenType::T_FALSE,
|
||||
TokenType::T_NULL,
|
||||
TokenType::T_STRING,
|
||||
TokenType::T_INTEGER,
|
||||
TokenType::T_FLOAT,
|
||||
])) {
|
||||
return new LiteralNode(value: $token->literal);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for and parses a ternary expression
|
||||
* (full `a ? b : c` or elvis shorthand `a ?: c`)
|
||||
*/
|
||||
private function ternary(): Node
|
||||
{
|
||||
$condition = $this->coalesce();
|
||||
|
||||
if ($token = $this->consumeAny([
|
||||
TokenType::T_QUESTION_MARK,
|
||||
TokenType::T_TERNARY_DEFAULT
|
||||
])) {
|
||||
if ($token->is(TokenType::T_TERNARY_DEFAULT) === false) {
|
||||
$true = $this->expression();
|
||||
$this->consume(
|
||||
type: TokenType::T_COLON,
|
||||
error: 'Expect ":" after true branch'
|
||||
);
|
||||
}
|
||||
|
||||
return new TernaryNode(
|
||||
condition: $condition,
|
||||
true: $true ?? null,
|
||||
false: $this->expression()
|
||||
);
|
||||
}
|
||||
|
||||
return $condition;
|
||||
}
|
||||
}
|
||||
30
kirby/src/Query/Parser/Token.php
Normal file
30
kirby/src/Query/Parser/Token.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\Parser;
|
||||
|
||||
/**
|
||||
* Represents a single token of a particular type
|
||||
* within a query
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>,
|
||||
* Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class Token
|
||||
{
|
||||
public function __construct(
|
||||
public TokenType $type,
|
||||
public string $lexeme,
|
||||
public mixed $literal = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function is(TokenType $type): bool
|
||||
{
|
||||
return $this->type === $type;
|
||||
}
|
||||
}
|
||||
61
kirby/src/Query/Parser/TokenType.php
Normal file
61
kirby/src/Query/Parser/TokenType.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\Parser;
|
||||
|
||||
/**
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>,
|
||||
* Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
enum TokenType
|
||||
{
|
||||
case T_DOT;
|
||||
case T_COLON;
|
||||
case T_QUESTION_MARK;
|
||||
case T_OPEN_PAREN;
|
||||
case T_CLOSE_PAREN;
|
||||
case T_OPEN_BRACKET;
|
||||
case T_CLOSE_BRACKET;
|
||||
case T_TERNARY_DEFAULT; // ?:
|
||||
case T_NULLSAFE; // ?.
|
||||
case T_COALESCE; // ??
|
||||
case T_COMMA;
|
||||
case T_ARROW;
|
||||
case T_WHITESPACE;
|
||||
case T_EOF;
|
||||
|
||||
// Comparison operators
|
||||
case T_EQUAL; // ==
|
||||
case T_IDENTICAL; // ===
|
||||
case T_NOT_EQUAL; // !=
|
||||
case T_NOT_IDENTICAL; // !==
|
||||
case T_LESS_THAN; // <
|
||||
case T_LESS_EQUAL; // <=
|
||||
case T_GREATER_THAN; // >
|
||||
case T_GREATER_EQUAL; // >=
|
||||
|
||||
// Math operators
|
||||
case T_PLUS; // +
|
||||
case T_MINUS; // -
|
||||
case T_MULTIPLY; // *
|
||||
case T_DIVIDE; // /
|
||||
case T_MODULO; // %
|
||||
|
||||
// Logical operators
|
||||
case T_AND; // AND or &&
|
||||
case T_OR; // OR or ||
|
||||
|
||||
// Literals
|
||||
case T_STRING;
|
||||
case T_INTEGER;
|
||||
case T_FLOAT;
|
||||
case T_TRUE;
|
||||
case T_FALSE;
|
||||
case T_NULL;
|
||||
|
||||
case T_IDENTIFIER;
|
||||
}
|
||||
256
kirby/src/Query/Parser/Tokenizer.php
Normal file
256
kirby/src/Query/Parser/Tokenizer.php
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\Parser;
|
||||
|
||||
use Exception;
|
||||
use Generator;
|
||||
|
||||
/**
|
||||
* Parses a query string into its individual tokens
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>,
|
||||
* Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class Tokenizer
|
||||
{
|
||||
private int $length = 0;
|
||||
|
||||
/**
|
||||
* The more complex regexes are written here in nowdoc format
|
||||
* so we don't need to double or triple escape backslashes
|
||||
* (that becomes ridiculous rather fast).
|
||||
*
|
||||
* Identifiers can contain letters, numbers and underscores.
|
||||
* They can't start with a number.
|
||||
* For more complex identifier strings, subscript member access
|
||||
* should be used. With `this` to access the global context.
|
||||
*/
|
||||
private const IDENTIFIER_REGEX = <<<'REGEX'
|
||||
(?:[\p{L}\p{N}_])*
|
||||
REGEX;
|
||||
|
||||
private const SINGLEQUOTE_STRING_REGEX = <<<'REGEX'
|
||||
'([^'\\]*(?:\\.[^'\\]*)*)'
|
||||
REGEX;
|
||||
|
||||
private const DOUBLEQUOTE_STRING_REGEX = <<<'REGEX'
|
||||
"([^"\\]*(?:\\.[^"\\]*)*)"
|
||||
REGEX;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $query,
|
||||
) {
|
||||
$this->length = mb_strlen($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a regex pattern at the current position in the query string.
|
||||
* The matched lexeme will be stored in the $lexeme variable.
|
||||
*
|
||||
* @param int $offset Current position in the query string
|
||||
* @param string $regex Regex pattern without delimiters/flags
|
||||
*/
|
||||
public static function match(
|
||||
string $query,
|
||||
int $offset,
|
||||
string $regex,
|
||||
bool $caseInsensitive = false
|
||||
): string|null {
|
||||
// Add delimiters and flags to the regex
|
||||
$regex = '/\G' . $regex . '/u';
|
||||
|
||||
if ($caseInsensitive === true) {
|
||||
$regex .= 'i';
|
||||
}
|
||||
|
||||
if (preg_match($regex, $query, $matches, 0, $offset) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the source string for a next token
|
||||
* starting from the given position
|
||||
*
|
||||
* @param int $current The current position in the source string
|
||||
*
|
||||
* @throws \Exception If an unexpected character is encountered
|
||||
*/
|
||||
public static function token(string $query, int $current): Token
|
||||
{
|
||||
$char = $query[$current];
|
||||
|
||||
// Multi character tokens (check these first):
|
||||
// Whitespace
|
||||
if ($lex = static::match($query, $current, '\s+')) {
|
||||
return new Token(TokenType::T_WHITESPACE, $lex);
|
||||
}
|
||||
|
||||
// true
|
||||
if ($lex = static::match($query, $current, 'true', true)) {
|
||||
return new Token(TokenType::T_TRUE, $lex, true);
|
||||
}
|
||||
|
||||
// false
|
||||
if ($lex = static::match($query, $current, 'false', true)) {
|
||||
return new Token(TokenType::T_FALSE, $lex, false);
|
||||
}
|
||||
|
||||
// null
|
||||
if ($lex = static::match($query, $current, 'null', true)) {
|
||||
return new Token(TokenType::T_NULL, $lex, null);
|
||||
}
|
||||
|
||||
// "string"
|
||||
if ($lex = static::match($query, $current, static::DOUBLEQUOTE_STRING_REGEX)) {
|
||||
return new Token(
|
||||
TokenType::T_STRING,
|
||||
$lex,
|
||||
stripcslashes(substr($lex, 1, -1))
|
||||
);
|
||||
}
|
||||
|
||||
// 'string'
|
||||
if ($lex = static::match($query, $current, static::SINGLEQUOTE_STRING_REGEX)) {
|
||||
return new Token(
|
||||
TokenType::T_STRING,
|
||||
$lex,
|
||||
stripcslashes(substr($lex, 1, -1))
|
||||
);
|
||||
}
|
||||
|
||||
// float (check before single character tokens)
|
||||
$lex = static::match($query, $current, '-?\d+\.\d+\b');
|
||||
if ($lex !== null) {
|
||||
return new Token(TokenType::T_FLOAT, $lex, (float)$lex);
|
||||
}
|
||||
|
||||
// int (check before single character tokens)
|
||||
$lex = static::match($query, $current, '-?\d+\b');
|
||||
if ($lex !== null) {
|
||||
return new Token(TokenType::T_INTEGER, $lex, (int)$lex);
|
||||
}
|
||||
|
||||
// Two character tokens:
|
||||
// ??
|
||||
if ($lex = static::match($query, $current, '\?\?')) {
|
||||
return new Token(TokenType::T_COALESCE, $lex);
|
||||
}
|
||||
|
||||
// ?.
|
||||
if ($lex = static::match($query, $current, '\?\s*\.')) {
|
||||
return new Token(TokenType::T_NULLSAFE, $lex);
|
||||
}
|
||||
|
||||
// ?:
|
||||
if ($lex = static::match($query, $current, '\?\s*:')) {
|
||||
return new Token(TokenType::T_TERNARY_DEFAULT, $lex);
|
||||
}
|
||||
|
||||
// =>
|
||||
if ($lex = static::match($query, $current, '=>')) {
|
||||
return new Token(TokenType::T_ARROW, $lex);
|
||||
}
|
||||
|
||||
// Logical operators (check before comparison operators)
|
||||
if ($lex = static::match($query, $current, '&&|AND')) {
|
||||
return new Token(TokenType::T_AND, $lex);
|
||||
}
|
||||
|
||||
if ($lex = static::match($query, $current, '\|\||OR')) {
|
||||
return new Token(TokenType::T_OR, $lex);
|
||||
}
|
||||
|
||||
// Comparison operators (three characters first, then two, then one)
|
||||
// === (must come before ==)
|
||||
if ($lex = static::match($query, $current, '===')) {
|
||||
return new Token(TokenType::T_IDENTICAL, $lex);
|
||||
}
|
||||
|
||||
// !== (must come before !=)
|
||||
if ($lex = static::match($query, $current, '!==')) {
|
||||
return new Token(TokenType::T_NOT_IDENTICAL, $lex);
|
||||
}
|
||||
|
||||
// <= (must come before <)
|
||||
if ($lex = static::match($query, $current, '<=')) {
|
||||
return new Token(TokenType::T_LESS_EQUAL, $lex);
|
||||
}
|
||||
|
||||
// >= (must come before >)
|
||||
if ($lex = static::match($query, $current, '>=')) {
|
||||
return new Token(TokenType::T_GREATER_EQUAL, $lex);
|
||||
}
|
||||
|
||||
// ==
|
||||
if ($lex = static::match($query, $current, '==')) {
|
||||
return new Token(TokenType::T_EQUAL, $lex);
|
||||
}
|
||||
|
||||
// !=
|
||||
if ($lex = static::match($query, $current, '!=')) {
|
||||
return new Token(TokenType::T_NOT_EQUAL, $lex);
|
||||
}
|
||||
|
||||
// Single character tokens (check these last):
|
||||
$token = match ($char) {
|
||||
'.' => new Token(TokenType::T_DOT, '.'),
|
||||
'(' => new Token(TokenType::T_OPEN_PAREN, '('),
|
||||
')' => new Token(TokenType::T_CLOSE_PAREN, ')'),
|
||||
'[' => new Token(TokenType::T_OPEN_BRACKET, '['),
|
||||
']' => new Token(TokenType::T_CLOSE_BRACKET, ']'),
|
||||
',' => new Token(TokenType::T_COMMA, ','),
|
||||
':' => new Token(TokenType::T_COLON, ':'),
|
||||
'+' => new Token(TokenType::T_PLUS, '+'),
|
||||
'-' => new Token(TokenType::T_MINUS, '-'),
|
||||
'*' => new Token(TokenType::T_MULTIPLY, '*'),
|
||||
'/' => new Token(TokenType::T_DIVIDE, '/'),
|
||||
'%' => new Token(TokenType::T_MODULO, '%'),
|
||||
'?' => new Token(TokenType::T_QUESTION_MARK, '?'),
|
||||
'<' => new Token(TokenType::T_LESS_THAN, '<'),
|
||||
'>' => new Token(TokenType::T_GREATER_THAN, '>'),
|
||||
default => null
|
||||
};
|
||||
|
||||
if ($token !== null) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
// Identifier
|
||||
if ($lex = static::match($query, $current, static::IDENTIFIER_REGEX)) {
|
||||
return new Token(TokenType::T_IDENTIFIER, $lex);
|
||||
}
|
||||
|
||||
// Unknown token
|
||||
throw new Exception('Invalid character in query: ' . $query[$current]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenizes the query string and returns a generator of tokens.
|
||||
* @return Generator<Token>
|
||||
*/
|
||||
public function tokens(): Generator
|
||||
{
|
||||
$current = 0;
|
||||
|
||||
while ($current < $this->length) {
|
||||
$token = static::token($this->query, $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);
|
||||
}
|
||||
}
|
||||
171
kirby/src/Query/Query.php
Normal file
171
kirby/src/Query/Query.php
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Collection;
|
||||
use Kirby\Cms\File;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\Site;
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Cms\Users;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Image\QrCode;
|
||||
use Kirby\Query\Runners\Runner;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* The Query class can be used to run expressions on arrays and objects,
|
||||
* including their methods with a very simple string-based syntax
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>,
|
||||
* Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class Query
|
||||
{
|
||||
public static array $cache = [];
|
||||
public static array $entries = [];
|
||||
|
||||
public Runner|string $runner;
|
||||
|
||||
/**
|
||||
* Creates a new Query object
|
||||
*/
|
||||
public function __construct(
|
||||
public string|null $query = null
|
||||
) {
|
||||
if ($query !== null) {
|
||||
$this->query = trim($query);
|
||||
}
|
||||
|
||||
$this->runner = App::instance()->option('query.runner', 'legacy');
|
||||
|
||||
if ($this->runner !== 'legacy') {
|
||||
|
||||
if (is_subclass_of($this->runner, Runner::class) === false) {
|
||||
throw new InvalidArgumentException("Query runner $this->runner must extend " . Runner::class);
|
||||
}
|
||||
|
||||
$this->runner = $this->runner::for($this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Query object
|
||||
*/
|
||||
public static function factory(string|null $query): static
|
||||
{
|
||||
return new static(query: $query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to help classes that extend Query
|
||||
* to intercept a segment's result.
|
||||
*/
|
||||
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
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If an invalid query runner is set in the config option
|
||||
*/
|
||||
public function resolve(array|object $data = []): mixed
|
||||
{
|
||||
if (empty($this->query) === true) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
// TODO: switch to 'interpreted' as default in v6
|
||||
// TODO: remove in v7
|
||||
// @codeCoverageIgnoreStart
|
||||
|
||||
if ($this->runner === 'legacy') {
|
||||
return $this->resolveLegacy($data);
|
||||
}
|
||||
// @codeCoverageIgnoreEnd
|
||||
|
||||
return $this->runner->run($this->query, (array)$data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 5.1.0
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
private function resolveLegacy(array|object $data = []): mixed
|
||||
{
|
||||
// merge data with default entries
|
||||
if (is_array($data) === true) {
|
||||
$data = [...static::$entries, ...$data];
|
||||
}
|
||||
|
||||
// direct data array access via key
|
||||
if (
|
||||
is_array($data) === true &&
|
||||
array_key_exists($this->query, $data) === true
|
||||
) {
|
||||
$value = $data[$this->query];
|
||||
|
||||
if ($value instanceof Closure) {
|
||||
$value = $value();
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
// loop through all segments to resolve query
|
||||
return Expression::factory($this->query, $this)->resolve($data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default entries/functions
|
||||
*/
|
||||
Query::$entries['kirby'] = function (): App {
|
||||
return App::instance();
|
||||
};
|
||||
|
||||
Query::$entries['collection'] = function (string $name): Collection|null {
|
||||
return App::instance()->collection($name);
|
||||
};
|
||||
|
||||
Query::$entries['file'] = function (string $id): File|null {
|
||||
return App::instance()->file($id);
|
||||
};
|
||||
|
||||
Query::$entries['page'] = function (string $id): Page|null {
|
||||
return App::instance()->page($id);
|
||||
};
|
||||
|
||||
Query::$entries['qr'] = function (string $data): QrCode {
|
||||
return new QrCode($data);
|
||||
};
|
||||
|
||||
Query::$entries['site'] = function (): Site {
|
||||
return App::instance()->site();
|
||||
};
|
||||
|
||||
Query::$entries['t'] = function (
|
||||
string $key,
|
||||
string|array|null $fallback = null,
|
||||
string|null $locale = null
|
||||
): string|null {
|
||||
return I18n::translate($key, $fallback, $locale);
|
||||
};
|
||||
|
||||
Query::$entries['user'] = function (string|null $id = null): User|null {
|
||||
return App::instance()->user($id);
|
||||
};
|
||||
|
||||
Query::$entries['users'] = function (): Users {
|
||||
return App::instance()->users();
|
||||
};
|
||||
69
kirby/src/Query/Runners/DefaultRunner.php
Normal file
69
kirby/src/Query/Runners/DefaultRunner.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\Runners;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Query\Parser\Parser;
|
||||
use Kirby\Query\Query;
|
||||
use Kirby\Query\Visitors\DefaultVisitor;
|
||||
|
||||
/**
|
||||
* Runner that caches the AST in memory
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class DefaultRunner extends Runner
|
||||
{
|
||||
/**
|
||||
* Creates a runner for the Query
|
||||
*/
|
||||
public static function for(Query $query): static
|
||||
{
|
||||
return new static(
|
||||
global: $query::$entries,
|
||||
interceptor: $query->intercept(...),
|
||||
cache: $query::$cache
|
||||
);
|
||||
}
|
||||
|
||||
protected function resolver(string $query): Closure
|
||||
{
|
||||
// Load closure from cache
|
||||
if (isset($this->cache[$query]) === true) {
|
||||
return $this->cache[$query];
|
||||
}
|
||||
|
||||
// Parse query as AST
|
||||
$parser = new Parser($query);
|
||||
$ast = $parser->parse();
|
||||
|
||||
// Cache closure to resolve same query
|
||||
return $this->cache[$query] = fn (array $context) => $ast->resolve(
|
||||
new DefaultVisitor($this->global, $context, $this->interceptor)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a query within a given data context
|
||||
*
|
||||
* @param array $context Optional variables to be passed to the query
|
||||
*
|
||||
* @throws \Exception when query is invalid or executor not callable
|
||||
*/
|
||||
public function run(string $query, array $context = []): mixed
|
||||
{
|
||||
// Try resolving query directly from data context or global functions
|
||||
$entry = Scope::get($query, $context, $this->global, false);
|
||||
|
||||
if ($entry !== false) {
|
||||
return $entry;
|
||||
}
|
||||
|
||||
return $this->resolver($query)($context);
|
||||
}
|
||||
}
|
||||
43
kirby/src/Query/Runners/Runner.php
Normal file
43
kirby/src/Query/Runners/Runner.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\Runners;
|
||||
|
||||
use ArrayAccess;
|
||||
use Closure;
|
||||
use Kirby\Query\Query;
|
||||
|
||||
/**
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>,
|
||||
* Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
abstract class Runner
|
||||
{
|
||||
/**
|
||||
* @param array $global Allowed global function closures
|
||||
*/
|
||||
public function __construct(
|
||||
public array $global = [],
|
||||
protected Closure|null $interceptor = null,
|
||||
protected ArrayAccess|array &$cache = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a runner instance for the Query
|
||||
*/
|
||||
abstract public static function for(Query $query): static;
|
||||
|
||||
/**
|
||||
* Executes a query within a given data context
|
||||
*
|
||||
* @param array $context Optional variables to be passed to the query
|
||||
*
|
||||
* @throws \Exception when query is invalid or executor not callable
|
||||
*/
|
||||
abstract public function run(string $query, array $context = []): mixed;
|
||||
}
|
||||
94
kirby/src/Query/Runners/Scope.php
Normal file
94
kirby/src/Query/Runners/Scope.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\Runners;
|
||||
|
||||
use Closure;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Helper class to execute logic during runtime
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class Scope
|
||||
{
|
||||
/**
|
||||
* Access the key on the object/array during runtime
|
||||
*/
|
||||
public static function access(
|
||||
array|object|null $object,
|
||||
string|int $key,
|
||||
bool $nullSafe = false,
|
||||
...$arguments
|
||||
): mixed {
|
||||
if ($object === null && $nullSafe === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($object) === true) {
|
||||
if ($item = $object[$key] ?? $object[(string)$key] ?? null) {
|
||||
if ($arguments) {
|
||||
return $item(...$arguments);
|
||||
}
|
||||
|
||||
if ($item instanceof Closure) {
|
||||
return $item();
|
||||
}
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
if (is_object($object) === true) {
|
||||
$key = (string)$key;
|
||||
|
||||
if (
|
||||
method_exists($object, $key) === true ||
|
||||
method_exists($object, '__call') === true
|
||||
) {
|
||||
return $object->$key(...$arguments);
|
||||
}
|
||||
|
||||
return $object->$key ?? null;
|
||||
}
|
||||
|
||||
throw new Exception("Cannot access \"$key\" on " . gettype($object));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a mapping from global context or functions during runtime
|
||||
*/
|
||||
public static function get(
|
||||
string $name,
|
||||
array $context = [],
|
||||
array $global = [],
|
||||
false|null $fallback = null
|
||||
): 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
|
||||
if (isset($context[$name]) === true) {
|
||||
if ($context[$name] instanceof Closure) {
|
||||
return $context[$name]();
|
||||
}
|
||||
|
||||
return $context[$name];
|
||||
}
|
||||
|
||||
if (isset($global[$name]) === true) {
|
||||
return $global[$name]();
|
||||
}
|
||||
|
||||
// Alias to access the global context
|
||||
if ($name === 'this') {
|
||||
return $context;
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
186
kirby/src/Query/Segment.php
Normal file
186
kirby/src/Query/Segment.php
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Exception\BadMethodCallException;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* The Segment class represents a single
|
||||
* part of a chained query
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*
|
||||
* @todo Deprecate in v6
|
||||
*/
|
||||
class Segment
|
||||
{
|
||||
public function __construct(
|
||||
public string $method,
|
||||
public int $position,
|
||||
public Arguments|null $arguments = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an exception for an access to an invalid method
|
||||
* @unstable
|
||||
*
|
||||
* @param mixed $data Variable on which the access was tried
|
||||
* @param string $name Name of the method/property that was accessed
|
||||
* @param string $label Type of the name (`method`, `property` or `method/property`)
|
||||
*
|
||||
* @throws \Kirby\Exception\BadMethodCallException
|
||||
*/
|
||||
public static function error(mixed $data, string $name, string $label): void
|
||||
{
|
||||
$type = strtolower(gettype($data));
|
||||
|
||||
if ($type === 'double') {
|
||||
$type = 'float';
|
||||
}
|
||||
|
||||
$nonExisting = in_array($type, ['array', 'object'], true) ? 'non-existing ' : '';
|
||||
|
||||
$error = 'Access to ' . $nonExisting . $label . ' "' . $name . '" on ' . $type;
|
||||
|
||||
throw new BadMethodCallException($error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a segment into the property/method name and its arguments
|
||||
*
|
||||
* @param int $position String position of the segment inside the full query
|
||||
*/
|
||||
public static function factory(
|
||||
string $segment,
|
||||
int $position = 0
|
||||
): static {
|
||||
if (Str::endsWith($segment, ')') === false) {
|
||||
return new static(method: $segment, position: $position);
|
||||
}
|
||||
|
||||
// the args are everything inside the *outer* parentheses
|
||||
$args = Str::substr($segment, Str::position($segment, '(') + 1, -1);
|
||||
|
||||
return new static(
|
||||
method: Str::before($segment, '('),
|
||||
position: $position,
|
||||
arguments: Arguments::factory($args)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically resolves the segment depending on the
|
||||
* segment position and the type of the base
|
||||
*
|
||||
* @param mixed $base Current value of the query chain
|
||||
*/
|
||||
public function resolve(mixed $base = null, array|object $data = []): mixed
|
||||
{
|
||||
// resolve arguments to array
|
||||
$args = $this->arguments?->resolve($data) ?? [];
|
||||
|
||||
// 1st segment, use $data as base
|
||||
if ($this->position === 0) {
|
||||
$base = $data;
|
||||
}
|
||||
|
||||
if (is_array($base) === true) {
|
||||
return $this->resolveArray($base, $args);
|
||||
}
|
||||
|
||||
if (is_object($base) === true) {
|
||||
return $this->resolveObject($base, $args);
|
||||
}
|
||||
|
||||
// trying to access further segments on a scalar/null value
|
||||
static::error($base, $this->method, 'method/property');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves segment by calling the corresponding array key
|
||||
*/
|
||||
protected function resolveArray(array $array, array $args): mixed
|
||||
{
|
||||
// the directly provided array takes precedence
|
||||
// to look up a matching entry
|
||||
if (array_key_exists($this->method, $array) === true) {
|
||||
$value = $array[$this->method];
|
||||
|
||||
// if this is a Closure we can directly use it, as
|
||||
// Closures from the $array should always have priority
|
||||
// over the Query::$entries Closures
|
||||
if ($value instanceof Closure) {
|
||||
return $value(...$args);
|
||||
}
|
||||
|
||||
// if we have no arguments to pass, we also can directly
|
||||
// use the value from the $array as it must not be different
|
||||
// to the one from Query::$entries with the same name
|
||||
if ($args === []) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
// fallback time: only if we are handling the first segment,
|
||||
// we can also try to resolve the segment with an entry from the
|
||||
// default Query::$entries
|
||||
if ($this->position === 0) {
|
||||
if (array_key_exists($this->method, Query::$entries) === true) {
|
||||
return Query::$entries[$this->method](...$args);
|
||||
}
|
||||
}
|
||||
|
||||
// if we have not been able to return anything so far,
|
||||
// we just need to differntiate between two different error messages
|
||||
|
||||
// this one is in case the original array contained the key,
|
||||
// but was not a Closure while the segment had arguments
|
||||
if (
|
||||
array_key_exists($this->method, $array) &&
|
||||
$args !== []
|
||||
) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Cannot access array element "' . $this->method . '" with arguments'
|
||||
);
|
||||
}
|
||||
|
||||
// last, the standard error for trying to access something
|
||||
// that does not exist
|
||||
static::error($array, $this->method, 'property');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves segment by calling the method/
|
||||
* accessing the property on the base object
|
||||
*/
|
||||
protected function resolveObject(object $object, array $args): mixed
|
||||
{
|
||||
if (
|
||||
method_exists($object, $this->method) === true ||
|
||||
method_exists($object, '__call') === true
|
||||
) {
|
||||
return $object->{$this->method}(...$args);
|
||||
}
|
||||
|
||||
if (
|
||||
$args === [] &&
|
||||
(
|
||||
property_exists($object, $this->method) === true ||
|
||||
method_exists($object, '__get') === true
|
||||
)
|
||||
) {
|
||||
return $object->{$this->method};
|
||||
}
|
||||
|
||||
$label = ($args === []) ? 'method/property' : 'method';
|
||||
static::error($object, $this->method, $label);
|
||||
}
|
||||
}
|
||||
104
kirby/src/Query/Segments.php
Normal file
104
kirby/src/Query/Segments.php
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query;
|
||||
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Collection;
|
||||
|
||||
/**
|
||||
* The Segments class helps splitting a
|
||||
* query string into processable segments
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*
|
||||
* @todo Deprecate in v6
|
||||
*
|
||||
* @extends \Kirby\Toolkit\Collection<\Kirby\Query\Segment>
|
||||
*/
|
||||
class Segments extends Collection
|
||||
{
|
||||
public function __construct(
|
||||
array $data = [],
|
||||
protected Query|null $parent = null,
|
||||
) {
|
||||
parent::__construct($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split query string into segments by dot
|
||||
* but not inside (nested) parens
|
||||
*/
|
||||
public static function factory(string $query, Query|null $parent = null): static
|
||||
{
|
||||
$segments = static::parse($query);
|
||||
$position = 0;
|
||||
|
||||
$segments = A::map(
|
||||
$segments,
|
||||
function ($segment) use (&$position) {
|
||||
// leave connectors as they are
|
||||
if (in_array($segment, ['.', '?.'], true) === true) {
|
||||
return $segment;
|
||||
}
|
||||
|
||||
// turn all other parts into Segment objects
|
||||
// and pass their position in the chain (ignoring connectors)
|
||||
$position++;
|
||||
return Segment::factory($segment, $position - 1);
|
||||
}
|
||||
);
|
||||
|
||||
return new static($segments, $parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the string of a segment chaing into an
|
||||
* array of segments as well as conenctors (`.` or `?.`)
|
||||
* @unstable
|
||||
*/
|
||||
public static function parse(string $string): array
|
||||
{
|
||||
return preg_split(
|
||||
'/(\??\.)|(\(([^()]+|(?2))*+\))(*SKIP)(*FAIL)/',
|
||||
trim($string),
|
||||
flags: PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the segments chain by looping through
|
||||
* each segment call to be applied to the value of
|
||||
* all previous segment calls, returning gracefully at
|
||||
* `?.` when current value is `null`
|
||||
*/
|
||||
public function resolve(array|object $data = [])
|
||||
{
|
||||
$value = null;
|
||||
|
||||
foreach ($this->data as $segment) {
|
||||
// optional chaining: stop if current value is null
|
||||
if ($segment === '?.' && $value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// for regular connectors and optional chaining on non-null,
|
||||
// just skip this connecting segment
|
||||
if ($segment === '.' || $segment === '?.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// offer possibility to intercept on objects
|
||||
if ($value !== null) {
|
||||
$value = $this->parent?->intercept($value) ?? $value;
|
||||
}
|
||||
|
||||
$value = $segment->resolve($value, $data);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
188
kirby/src/Query/Visitors/DefaultVisitor.php
Normal file
188
kirby/src/Query/Visitors/DefaultVisitor.php
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\Visitors;
|
||||
|
||||
use Closure;
|
||||
use Exception;
|
||||
use Kirby\Query\AST\ClosureNode;
|
||||
use Kirby\Query\Runners\Scope;
|
||||
|
||||
/**
|
||||
* Processes a query AST
|
||||
*
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>,
|
||||
* Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*/
|
||||
class DefaultVisitor extends Visitor
|
||||
{
|
||||
/**
|
||||
* Processes list of arguments
|
||||
*/
|
||||
public function arguments(array $arguments): array
|
||||
{
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes arithmetic operation
|
||||
*/
|
||||
public function arithmetic(
|
||||
int|float $left,
|
||||
string $operator,
|
||||
int|float $right
|
||||
): mixed {
|
||||
return match ($operator) {
|
||||
'+' => $left + $right,
|
||||
'-' => $left - $right,
|
||||
'*' => $left * $right,
|
||||
'/' => $left / $right,
|
||||
'%' => $left % $right,
|
||||
default => throw new Exception("Unknown arithmetic operator: $operator")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes array
|
||||
*/
|
||||
public function arrayList(array $elements): array
|
||||
{
|
||||
return $elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes node into actual closure
|
||||
*/
|
||||
public function closure(ClosureNode $node): Closure
|
||||
{
|
||||
$self = $this;
|
||||
|
||||
return function (...$params) use ($self, $node) {
|
||||
// [key1, key2] + [value1, value2] =>
|
||||
// [key1 => value1, key2 => value2]
|
||||
$arguments = array_combine(
|
||||
$node->arguments,
|
||||
$params
|
||||
);
|
||||
|
||||
// Create new nested visitor with combined
|
||||
// data context for resolving the closure body
|
||||
$visitor = new static(
|
||||
global: $self->global,
|
||||
context: [...$self->context, ...$arguments],
|
||||
interceptor: $self->interceptor
|
||||
);
|
||||
|
||||
return $node->body->resolve($visitor);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes coalescence operator
|
||||
*/
|
||||
public function coalescence(mixed $left, mixed $right): mixed
|
||||
{
|
||||
return $left ?? $right;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes comparison operation
|
||||
*/
|
||||
public function comparison(
|
||||
mixed $left,
|
||||
string $operator,
|
||||
mixed $right
|
||||
): bool {
|
||||
return match ($operator) {
|
||||
'==' => $left == $right,
|
||||
'===' => $left === $right,
|
||||
'!=' => $left != $right,
|
||||
'!==' => $left !== $right,
|
||||
'<' => $left < $right,
|
||||
'<=' => $left <= $right,
|
||||
'>' => $left > $right,
|
||||
'>=' => $left >= $right,
|
||||
default => throw new Exception("Unknown comparison operator: $operator")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes global function
|
||||
*/
|
||||
public function function(string $name, array $arguments = []): mixed
|
||||
{
|
||||
$function = $this->global[$name] ?? null;
|
||||
|
||||
if ($function === null) {
|
||||
throw new Exception("Invalid global function in query: $name");
|
||||
}
|
||||
|
||||
return $function(...$arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes literals
|
||||
*/
|
||||
public function literal(mixed $value): mixed
|
||||
{
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes logical operation
|
||||
*/
|
||||
public function logical(
|
||||
mixed $left,
|
||||
string $operator,
|
||||
mixed $right
|
||||
): bool {
|
||||
return match ($operator) {
|
||||
'&&', 'AND' => $left && $right,
|
||||
'||', 'OR' => $left || $right,
|
||||
default => throw new Exception("Unknown logical operator: $operator")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes member access
|
||||
*/
|
||||
public function memberAccess(
|
||||
mixed $object,
|
||||
string|int $member,
|
||||
array|null $arguments = null,
|
||||
bool $nullSafe = false
|
||||
): mixed {
|
||||
if ($this->interceptor !== null) {
|
||||
$object = ($this->interceptor)($object);
|
||||
}
|
||||
|
||||
return Scope::access($object, $member, $nullSafe, ...$arguments ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes ternary operator
|
||||
*/
|
||||
public function ternary(
|
||||
mixed $condition,
|
||||
mixed $true,
|
||||
mixed $false
|
||||
): mixed {
|
||||
if ($true === null) {
|
||||
return $condition ?: $false;
|
||||
}
|
||||
|
||||
return $condition ? $true : $false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variable from context or global function
|
||||
*/
|
||||
public function variable(string $name): mixed
|
||||
{
|
||||
return Scope::get($name, $this->context, $this->global);
|
||||
}
|
||||
}
|
||||
46
kirby/src/Query/Visitors/Visitor.php
Normal file
46
kirby/src/Query/Visitors/Visitor.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Query\Visitors;
|
||||
|
||||
use Closure;
|
||||
|
||||
/**
|
||||
* @package Kirby Query
|
||||
* @author Roman Steiner <roman@toastlab.ch>,
|
||||
* Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
* @since 5.1.0
|
||||
* @unstable
|
||||
*
|
||||
* Every visitor class must implement the following methods.
|
||||
* As PHP won't allow increasing the typing specificity, we
|
||||
* aren't actually adding them here in the abstract class, so that
|
||||
* the actual visitor classes can work with much more specific type hints.
|
||||
*
|
||||
* @method mixed arguments(array $arguments)
|
||||
* @method mixed arithmetic(mixed $left, string $operator, mixed $right)
|
||||
* @method mixed arrayList(array $elements)
|
||||
* @method mixed closure($ClosureNode $node))
|
||||
* @method mixed coalescence($left, $right)
|
||||
* @method mixed comparison(mixed $left, string $operator, mixed $right)
|
||||
* @method mixed function($name, $arguments)
|
||||
* @method mixed literal($value)
|
||||
* @method mixed logical(mixed $left, string $operator, mixed $right)
|
||||
* @method mixed memberAccess($object, string|int $member, $arguments, bool $nullSafe = false)
|
||||
* @method mixed ternary($condition, $true, $false)
|
||||
* @method mixed variable(string $name)
|
||||
*/
|
||||
abstract class Visitor
|
||||
{
|
||||
/**
|
||||
* @param array<string,Closure> $global valid global function closures
|
||||
* @param array<string,mixed> $context data bindings for the query
|
||||
*/
|
||||
public function __construct(
|
||||
public array $global = [],
|
||||
public array $context = [],
|
||||
protected Closure|null $interceptor = null
|
||||
) {
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue