Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
60 / 60 |
|
100.00% |
9 / 9 |
CRAP | |
100.00% |
1 / 1 |
| NodeReference | |
100.00% |
60 / 60 |
|
100.00% |
9 / 9 |
24 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| normalize | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| isNegated | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| field | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| indexCondition | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
| containsCondition | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| containment | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| uids | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\Finder\Output; |
| 6 | |
| 7 | use Cosray\Context; |
| 8 | use Cosray\Exception\ParserOutputException; |
| 9 | use Cosray\Field\Field; |
| 10 | use Cosray\Finder\Input\Token; |
| 11 | use 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 | */ |
| 22 | final 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 | } |