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