Skip to content

Commit

Permalink
Queries: subscript member access
Browse files Browse the repository at this point in the history
  • Loading branch information
distantnative committed Jan 19, 2025
1 parent c717f87 commit fd7a2c4
Show file tree
Hide file tree
Showing 12 changed files with 78 additions and 138 deletions.
13 changes: 2 additions & 11 deletions src/Query/AST/GlobalFunctionNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,17 @@
* @license https://opensource.org/licenses/MIT
* @since 6.0.0
*/
class GlobalFunctionNode extends IdentifierNode
class GlobalFunctionNode extends Node
{
public function __construct(
public string $name,
public ArgumentListNode $arguments,
) {
}

/**
* Replace escaped dots with real dots
*/
public function name(): string
{
return str_replace('\.', '.', $this->name);
}

public function resolve(Visitor $visitor): mixed
{
$name = $this->name();
$arguments = $this->arguments->resolve($visitor);
return $visitor->function($name, $arguments);
return $visitor->function($this->name, $arguments);
}
}
25 changes: 0 additions & 25 deletions src/Query/AST/IdentifierNode.php

This file was deleted.

23 changes: 7 additions & 16 deletions src/Query/AST/MemberAccessNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,37 +15,28 @@
* @license https://opensource.org/licenses/MIT
* @since 6.0.0
*/
class MemberAccessNode extends IdentifierNode
class MemberAccessNode extends Node
{
public function __construct(
public Node $object,
public string|int $member,
public Node|string|int $member,
public ArgumentListNode|null $arguments = null,
public bool $nullSafe = false,
) {
}

/**
* Returns the member name and replaces escaped dots
* with real dots if it's a string
*/
public function member(): string|int
{
if (is_string($this->member) === true) {
return self::unescape($this->member);
}

return $this->member;
}

public function resolve(Visitor $visitor): mixed
{
$object = $this->object->resolve($visitor);
$arguments = $this->arguments?->resolve($visitor);
$member = match (true) {
$this->member instanceof Node => $this->member->resolve($visitor),
default => $this->member
};

return $visitor->memberAccess(
$object,
$this->member,
$member,
$arguments,
$this->nullSafe
);
Expand Down
13 changes: 2 additions & 11 deletions src/Query/AST/VariableNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,15 @@
* @license https://opensource.org/licenses/MIT
* @since 6.0.0
*/
class VariableNode extends IdentifierNode
class VariableNode extends Node
{
public function __construct(
public string $name,
) {
}

/**
* Replaces escaped dots with real dots
*/
public function name(): string
{
return self::unescape($this->name);
}

public function resolve(Visitor $visitor): mixed
{
$name = $this->name();
return $visitor->variable($name);
return $visitor->variable($this->name);
}
}
18 changes: 15 additions & 3 deletions src/Query/Parser/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -284,9 +284,18 @@ private function memberAccess(): Node

while ($token = $this->consumeAny([
TokenType::T_DOT,
TokenType::T_NULLSAFE
TokenType::T_NULLSAFE,
TokenType::T_OPEN_BRACKET
])) {
if ($member = $this->consume(TokenType::T_IDENTIFIER)) {
if ($token->is(TokenType::T_OPEN_BRACKET) === true) {
// for subscript notation, parse the inside as full 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 = $member->lexeme;
} elseif ($member = $this->consume(TokenType::T_INTEGER)) {
$member = $member->literal;
Expand Down Expand Up @@ -354,7 +363,10 @@ private function ternary(): Node
])) {
if ($token->is(TokenType::T_TERNARY_DEFAULT) === false) {
$true = $this->expression();
$this->consume(TokenType::T_COLON, 'Expect ":" after true branch');
$this->consume(
TokenType::T_COLON,
'Expect ":" after true branch'
);
}

return new TernaryNode(
Expand Down
9 changes: 4 additions & 5 deletions src/Query/Parser/Tokenizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,13 @@ class Tokenizer
* so we don't need to double or triple escape backslashes
* (that becomes ridiculous rather fast).
*
* Identifiers can contain letters, numbers, underscores and escaped dots.
* Identifiers can contain letters, numbers and underscores.
* They can't start with a number.
*
* To match an array key like "foo.bar" we write the query as `foo\.bar`,
* to match an array key like "foo\.bar" we write the query as `foo\\.bar`
* 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}_]|\\\.|\\\\)*
(?:[\p{L}\p{N}_])*
REGEX;

private const SINGLEQUOTE_STRING_REGEX = <<<'REGEX'
Expand Down
5 changes: 5 additions & 0 deletions src/Query/Runners/Runtime.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ public static function get(
return $function();
}

// alias to access the global context
if ($name === 'this') {
return $context;
}

return null;
}
}
12 changes: 0 additions & 12 deletions tests/Query/AST/GlobalFunctionNodeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,6 @@
*/
class GlobalFunctionNodeTest extends TestCase
{
/**
* @covers ::name
*/
public function testName(): void
{
$node = new GlobalFunctionNode('a', new ArgumentListNode());
$this->assertSame('a', $node->name());

$node = new GlobalFunctionNode('a\.b', new ArgumentListNode());
$this->assertSame('a.b', $node->name());
}

/**
* @covers ::resolve
*/
Expand Down
27 changes: 0 additions & 27 deletions tests/Query/AST/MemberAccessNodeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,33 +12,6 @@
*/
class MemberAccessNodeTest extends TestCase
{
/**
* @covers ::member
*/
public function testMember(): void
{
$node = new MemberAccessNode(
new VariableNode('user'),
'name'
);

$this->assertSame('name', $node->member());

$node = new MemberAccessNode(
new VariableNode('user'),
'my\.name'
);

$this->assertSame('my.name', $node->member());

$node = new MemberAccessNode(
new VariableNode('user'),
1
);

$this->assertSame(1, $node->member());
}

/**
* @covers ::resolve
*/
Expand Down
12 changes: 0 additions & 12 deletions tests/Query/AST/VariableNodeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,6 @@
*/
class VariableNodeTest extends TestCase
{
/**
* @covers ::name
*/
public function testName(): void
{
$node = new VariableNode('a');
$this->assertSame('a', $node->name());

$node = new VariableNode('a\.b');
$this->assertSame('a.b', $node->name());
}

/**
* @covers ::resolve
*/
Expand Down
40 changes: 40 additions & 0 deletions tests/Query/Parser/ParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,46 @@ public function testMemberAccessIntegerIndex(): void
);
}

/**
* @covers ::memberAccess
*/
public function testMemberAccessSubscriptString(): void
{
$parser = new Parser('user["This.is the key"](2)');
$ast = $parser->parse();

$this->assertEquals(
$ast,
new MemberAccessNode(
new VariableNode('user'),
new LiteralNode('This.is the key'),
arguments: new ArgumentListNode([
new LiteralNode(2)
])
)
);
}

/**
* @covers ::memberAccess
*/
public function testMemberAccessSubscriptExpression(): void
{
$parser = new Parser('user[page.id]');
$ast = $parser->parse();

$this->assertEquals(
$ast,
new MemberAccessNode(
new VariableNode('user'),
new MemberAccessNode(
new VariableNode('page'),
'id'
)
)
);
}

/**
* @covers ::memberAccess
*/
Expand Down
19 changes: 3 additions & 16 deletions tests/Query/QueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,24 +76,11 @@ public function testResolveWithExactArrayMatch(): void
$query = new Query('user');
$this->assertSame('homer', $query->resolve(['user' => 'homer']));

$query = new Query('user\.username');
$query = new Query('this["user.username"]');
$this->assertSame('homer', $query->resolve(['user.username' => 'homer']));

$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']));
$query = new Query('this["user callback"]');
$this->assertSame('homer', $query->resolve(['user callback' => fn () => 'homer']));
}

/**
Expand Down

0 comments on commit fd7a2c4

Please sign in to comment.