Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
60 / 60
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
NodeReference
100.00% covered (success)
100.00%
60 / 60
100.00% covered (success)
100.00%
9 / 9
24
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 normalize
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isNegated
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 field
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 indexCondition
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 containsCondition
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 containment
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 uids
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Finder\Output;
6
7use Cosray\Context;
8use Cosray\Exception\ParserOutputException;
9use Cosray\Field\Field;
10use Cosray\Finder\Input\Token;
11use Cosray\Finder\Input\TokenType;
12
13/**
14 * Filters nodes by the nodes they point at.
15 *
16 * `references = '<uid>'` asks whether the node references that target
17 * anywhere in its content and reads the `node_references` index, so it also
18 * covers richtext links. `references.<field> = '<uid>'` narrows the question
19 * to one `Reference` field and compiles to a jsonb containment test, which
20 * stays on the content GIN index.
21 */
22final readonly class NodeReference extends Expression implements Output
23{
24    /** Only nodes carry content; menu items are indexed as their own owner kind. */
25    private const string OWNER_TYPE = 'node';
26
27    public function __construct(
28        public Token $left,
29        public Token $operator,
30        public Token $right,
31        private Context $context,
32    ) {}
33
34    public function get(): string
35    {
36        [$referenceToken, $valueToken] = $this->normalize();
37        $negated = $this->isNegated();
38        $field = $this->field($referenceToken);
39        $condition = $field === null
40            ? $this->indexCondition($valueToken)
41            : $this->containsCondition($field, $valueToken);
42
43        return $negated ? "NOT ({$condition})" : $condition;
44    }
45
46    /** @return array{0: Token, 1: Token} */
47    private function normalize(): array
48    {
49        if ($this->left->type === TokenType::Reference) {
50            return [$this->left, $this->right];
51        }
52
53        return [$this->right, $this->left];
54    }
55
56    private function isNegated(): bool
57    {
58        return match ($this->operator->type) {
59            TokenType::Equal, TokenType::In => false,
60            TokenType::Unequal, TokenType::NotIn => true,
61            default => throw new ParserOutputException(
62                $this->operator,
63                'Reference expressions support the =, !=, @ and !@ operators only.',
64            ),
65        };
66    }
67
68    /** The referencing field, or null when the whole node is meant. */
69    private function field(#[\SensitiveParameter] Token $token): ?string
70    {
71        $parts = explode('.', $token->lexeme);
72
73        if (count($parts) === 1) {
74            return null;
75        }
76
77        if (count($parts) !== 2 || preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $parts[1]) !== 1) {
78            throw new ParserOutputException(
79                $token,
80                'Invalid reference selector. Use references or references.<field>.',
81            );
82        }
83
84        return $parts[1];
85    }
86
87    private function indexCondition(#[\SensitiveParameter] Token $valueToken): string
88    {
89        // Safe SQL fragment: table() validates the hardcoded identifier and configured prefix.
90        $table = $this->context->config->db->table('node_references', $this->context->db->getPdoDriver());
91        $uids = $this->uids($valueToken);
92        $targets = count($uids) === 1
93            ? '= ' . $this->context->db->quote($uids[0])
94            : 'IN (' . implode(', ', array_map($this->context->db->quote(...), $uids)) . ')';
95
96        return sprintf(
97            'EXISTS (SELECT 1 FROM %s r WHERE r.owner_type = %s AND r.owner_uid = n.uid AND r.target_uid %s)',
98            $table,
99            $this->context->db->quote(self::OWNER_TYPE),
100            $targets,
101        );
102    }
103
104    private function containsCondition(string $field, #[\SensitiveParameter] Token $valueToken): string
105    {
106        $conditions = array_map(
107            fn(string $uid): string => 'n.content @> '
108            . $this->context->db->quote($this->containment($field, $uid)),
109            $this->uids($valueToken),
110        );
111
112        return count($conditions) === 1 ? $conditions[0] : '(' . implode(' OR ', $conditions) . ')';
113    }
114
115    /** Reference targets are stored language-neutrally as an ordered `{uid}` list. */
116    private function containment(string $field, #[\SensitiveParameter] string $uid): string
117    {
118        return json_encode(
119            [$field => ['value' => [Field::NEUTRAL_LOCALE => [['uid' => $uid]]]]],
120            JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
121        );
122    }
123
124    /** @return non-empty-list<string> */
125    private function uids(#[\SensitiveParameter] Token $token): array
126    {
127        $uids = match ($token->type) {
128            TokenType::String, TokenType::Number => [$token->lexeme],
129            TokenType::List => $token->items,
130            default => throw new ParserOutputException(
131                $token,
132                'Reference comparisons only support uid literals.',
133            ),
134        };
135
136        if ($uids === []) {
137            throw new ParserOutputException($token, 'Reference comparisons need at least one uid.');
138        }
139
140        return $uids;
141    }
142}