• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

martin-georgiev / postgresql-for-doctrine / d68a334a99805fffa994b295a98cbab4ea71761f

28 Feb 2026 10:20PM UTC coverage: 97.731%. First build
d68a334a99805fffa994b295a98cbab4ea71761f

push

github

web-flow
feat: extend support for PostgreSQL full-text search functions (#558)

144 of 147 new or added lines in 12 files covered. (97.96%)

3532 of 3614 relevant lines covered (97.73%)

36.93 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.37
/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/BaseVariadicFunction.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions;
6

7
use Doctrine\ORM\Query\AST\Node;
8
use Doctrine\ORM\Query\Lexer;
9
use Doctrine\ORM\Query\Parser;
10
use Doctrine\ORM\Query\SqlWalker;
11
use Doctrine\ORM\Query\TokenType;
12
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\InvalidArgumentForVariadicFunctionException;
13
use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Exception\ParserException;
14
use MartinGeorgiev\Utils\DoctrineLexer;
15
use MartinGeorgiev\Utils\DoctrineOrm;
16

17
/**
18
 * @author Martin Georgiev <martin.georgiev@gmail.com>
19
 */
20
abstract class BaseVariadicFunction extends BaseFunction
21
{
22
    protected function customizeFunction(): void
626✔
23
    {
24
        $this->setFunctionPrototype(\sprintf('%s(%%s)', $this->getFunctionName()));
626✔
25
    }
26

27
    abstract protected function getFunctionName(): string;
28

29
    /**
30
     * @return array<string>
31
     */
32
    abstract protected function getNodeMappingPattern(): array;
33

34
    abstract protected function getMinArgumentCount(): int;
35

36
    abstract protected function getMaxArgumentCount(): int;
37

38
    protected function feedParserWithNodes(Parser $parser): void
642✔
39
    {
40
        $patterns = $this->getNodeMappingPattern();
642✔
41

42
        if (\count($patterns) > 1) {
642✔
43
            $resolved = $this->resolvePatternByTokenAnalysis($parser->getLexer(), $patterns);
150✔
44
            if ($resolved !== null) {
150✔
45
                $this->feedParserWithNodesForNodeMappingPattern($parser, $resolved);
58✔
46

47
                return;
43✔
48
            }
49
        }
50

51
        foreach ($patterns as $pattern) {
614✔
52
            try {
53
                $this->feedParserWithNodesForNodeMappingPattern($parser, $pattern);
614✔
54

55
                break;
503✔
56
            } catch (ParserException) {
111✔
57
                // swallow and continue with next pattern
58
            }
59
        }
60
    }
61

62
    /**
63
     * Peeks at tokens ahead of parsing to select the correct pattern when multiple
64
     * patterns share the same prefix but diverge on argument types.
65
     *
66
     * @param array<string> $patterns
67
     */
68
    private function resolvePatternByTokenAnalysis(Lexer $lexer, array $patterns): ?string
150✔
69
    {
70
        $argumentTokenTypes = $this->peekArgumentTokenTypes($lexer);
150✔
71
        $argumentCount = \count($argumentTokenTypes);
150✔
72

73
        if ($argumentCount === 0) {
150✔
NEW
74
            return null;
×
75
        }
76

77
        $candidates = [];
150✔
78
        foreach ($patterns as $pattern) {
150✔
79
            $nodeMapping = \explode(',', $pattern);
150✔
80
            $patternNodeCount = \count($nodeMapping);
150✔
81

82
            if ($patternNodeCount < $argumentCount) {
150✔
83
                continue;
73✔
84
            }
85

86
            $compatible = true;
139✔
87
            for ($i = 0; $i < $argumentCount; $i++) {
139✔
88
                if (!$this->isTokenCompatibleWithNodeType($argumentTokenTypes[$i], $nodeMapping[$i])) {
139✔
89
                    $compatible = false;
11✔
90

91
                    break;
11✔
92
                }
93
            }
94

95
            if ($compatible) {
139✔
96
                $candidates[] = $pattern;
139✔
97
            }
98
        }
99

100
        if (\count($candidates) === 1) {
150✔
101
            return $candidates[0];
58✔
102
        }
103

104
        return null;
112✔
105
    }
106

107
    /**
108
     * Peeks at tokens to determine the first token type of each function argument.
109
     * Tracks parenthesis depth to correctly handle nested function calls.
110
     *
111
     * @return list<mixed>
112
     */
113
    private function peekArgumentTokenTypes(Lexer $lexer): array
150✔
114
    {
115
        $firstArgumentType = DoctrineLexer::getLookaheadType($lexer);
150✔
116
        if ($firstArgumentType === null) {
150✔
NEW
117
            return [];
×
118
        }
119

120
        $types = [$firstArgumentType];
150✔
121
        $depth = 0;
150✔
122

123
        $shouldUseLexer = DoctrineOrm::isPre219();
150✔
124
        $commaType = $shouldUseLexer ? Lexer::T_COMMA : TokenType::T_COMMA;
150✔
125
        $openParenthesisType = $shouldUseLexer ? Lexer::T_OPEN_PARENTHESIS : TokenType::T_OPEN_PARENTHESIS;
150✔
126
        $closeParenthesisType = $shouldUseLexer ? Lexer::T_CLOSE_PARENTHESIS : TokenType::T_CLOSE_PARENTHESIS;
150✔
127

128
        while (true) {
150✔
129
            $token = $lexer->peek();
150✔
130
            if ($token === null) {
150✔
NEW
131
                break;
×
132
            }
133

134
            $tokenType = \is_array($token) ? $token['type'] : $token->type; // @phpstan-ignore-line
150✔
135

136
            if ($tokenType === $openParenthesisType) {
150✔
137
                $depth++;
33✔
138
            } elseif ($tokenType === $closeParenthesisType) {
150✔
139
                if ($depth === 0) {
150✔
140
                    break;
150✔
141
                }
142

143
                $depth--;
33✔
144
            } elseif ($tokenType === $commaType && $depth === 0) {
150✔
145
                $nextToken = $lexer->peek();
136✔
146
                if ($nextToken !== null) {
136✔
147
                    $types[] = \is_array($nextToken) ? $nextToken['type'] : $nextToken->type; // @phpstan-ignore-line
136✔
148
                }
149
            }
150
        }
151

152
        $lexer->resetPeek();
150✔
153

154
        return $types;
150✔
155
    }
156

157
    /**
158
     * Determines if a token type is compatible with a node mapping type.
159
     *
160
     * Numeric literals (T_INTEGER/T_FLOAT) are only compatible with ArithmeticPrimary.
161
     * String literals (T_STRING) are only compatible with StringPrimary.
162
     * Identifiers, parameters, and function calls are compatible with all node types.
163
     */
164
    private function isTokenCompatibleWithNodeType(mixed $tokenType, string $nodeType): bool
139✔
165
    {
166
        $shouldUseLexer = DoctrineOrm::isPre219();
139✔
167
        $integerType = $shouldUseLexer ? Lexer::T_INTEGER : TokenType::T_INTEGER;
139✔
168
        $floatType = $shouldUseLexer ? Lexer::T_FLOAT : TokenType::T_FLOAT;
139✔
169
        $stringLiteralType = $shouldUseLexer ? Lexer::T_STRING : TokenType::T_STRING;
139✔
170

171
        $isNumericToken = ($tokenType === $integerType || $tokenType === $floatType);
139✔
172
        $isStringLiteralToken = ($tokenType === $stringLiteralType);
139✔
173

174
        if ($isNumericToken && $nodeType === 'StringPrimary') {
139✔
175
            return false;
7✔
176
        }
177

178
        return !($isStringLiteralToken && \in_array($nodeType, ['ArithmeticPrimary', 'SimpleArithmeticExpression'], true));
139✔
179
    }
180

181
    /**
182
     * @throws InvalidArgumentForVariadicFunctionException
183
     * @throws ParserException
184
     */
185
    private function feedParserWithNodesForNodeMappingPattern(Parser $parser, string $nodeMappingPattern): void
682✔
186
    {
187
        $nodeMapping = \explode(',', $nodeMappingPattern);
682✔
188
        $lexer = $parser->getLexer();
682✔
189

190
        try {
191
            $lookaheadType = DoctrineLexer::getLookaheadType($lexer);
682✔
192
            if ($lookaheadType === null) {
682✔
193
                throw InvalidArgumentForVariadicFunctionException::atLeast($this->getFunctionName(), $this->getMinArgumentCount());
×
194
            }
195

196
            $this->nodes[] = $parser->{$nodeMapping[0]}(); // @phpstan-ignore-line
682✔
197
        } catch (\Throwable $throwable) {
46✔
198
            throw ParserException::withThrowable($throwable);
46✔
199
        }
200

201
        $shouldUseLexer = DoctrineOrm::isPre219();
636✔
202
        $isNodeMappingASimplePattern = \count($nodeMapping) === 1;
636✔
203
        $nodeIndex = 1;
636✔
204
        while (($shouldUseLexer ? Lexer::T_CLOSE_PARENTHESIS : TokenType::T_CLOSE_PARENTHESIS) !== $lookaheadType) {
636✔
205
            if (($shouldUseLexer ? Lexer::T_COMMA : TokenType::T_COMMA) === $lookaheadType) {
636✔
206
                $parser->match($shouldUseLexer ? Lexer::T_COMMA : TokenType::T_COMMA);
462✔
207

208
                // Check if we're about to exceed the maximum number of arguments
209
                // nodeIndex starts at 1 and counts up for each argument after the first
210
                // So when nodeIndex=1, we're about to add the 2nd argument (total: 2)
211
                // When nodeIndex=2, we're about to add the 3rd argument (total: 3)
212
                $foundMoreNodesThanMappingExpected = ($nodeIndex + 1) > $this->getMaxArgumentCount();
462✔
213
                if ($foundMoreNodesThanMappingExpected) {
462✔
214
                    if ($this->getMinArgumentCount() === $this->getMaxArgumentCount()) {
37✔
215
                        throw InvalidArgumentForVariadicFunctionException::exactCount($this->getFunctionName(), $this->getMinArgumentCount());
1✔
216
                    }
217

218
                    throw InvalidArgumentForVariadicFunctionException::between($this->getFunctionName(), $this->getMinArgumentCount(), $this->getMaxArgumentCount());
36✔
219
                }
220

221
                $expectedNodeIndex = $isNodeMappingASimplePattern ? 0 : $nodeIndex;
462✔
222
                $argumentCountExceedsMappingPatternExpectation = !\array_key_exists($expectedNodeIndex, $nodeMapping);
462✔
223
                if ($argumentCountExceedsMappingPatternExpectation) {
462✔
224
                    throw InvalidArgumentForVariadicFunctionException::unsupportedCombination(
×
225
                        $this->getFunctionName(),
×
226
                        \count($this->nodes) + 1,
×
227
                        'implementation defines fewer node mappings than the actually provided argument count'
×
228
                    );
×
229
                }
230

231
                $this->nodes[] = $parser->{$nodeMapping[$expectedNodeIndex]}(); // @phpstan-ignore-line
462✔
232
                $nodeIndex++;
462✔
233
            }
234

235
            $lookaheadType = DoctrineLexer::getLookaheadType($lexer);
636✔
236
        }
237

238
        // Final validation ensures all arguments meet requirements, including any special rules in subclass implementations
239
        $this->validateArguments(...$this->nodes); // @phpstan-ignore-line
599✔
240
    }
241

242
    /**
243
     * @throws InvalidArgumentForVariadicFunctionException
244
     */
245
    protected function validateArguments(Node ...$arguments): void
799✔
246
    {
247
        $minArgumentCount = $this->getMinArgumentCount();
799✔
248
        $maxArgumentCount = $this->getMaxArgumentCount();
799✔
249
        $argumentCount = \count($arguments);
799✔
250

251
        if ($minArgumentCount === $maxArgumentCount && $argumentCount !== $minArgumentCount) {
799✔
252
            throw InvalidArgumentForVariadicFunctionException::exactCount($this->getFunctionName(), $this->getMinArgumentCount());
83✔
253
        }
254

255
        if ($argumentCount < $minArgumentCount) {
716✔
256
            throw InvalidArgumentForVariadicFunctionException::atLeast($this->getFunctionName(), $this->getMinArgumentCount());
106✔
257
        }
258

259
        if ($argumentCount > $maxArgumentCount) {
610✔
260
            throw InvalidArgumentForVariadicFunctionException::between($this->getFunctionName(), $this->getMinArgumentCount(), $this->getMaxArgumentCount());
40✔
261
        }
262
    }
263

264
    public function getSql(SqlWalker $sqlWalker): string
522✔
265
    {
266
        $dispatched = [];
522✔
267
        foreach ($this->nodes as $node) {
522✔
268
            $dispatched[] = $node instanceof Node ? $node->dispatch($sqlWalker) : 'null';
516✔
269
        }
270

271
        return \sprintf($this->functionPrototype, \implode(', ', $dispatched));
522✔
272
    }
273
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc