packages/sql-catalog/tests/Unit/Extension/Laravel/PredicatesTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Extension\Laravel;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use SqlCatalog\Core\Evaluation\ArrayEntry;
11use SqlCatalog\Core\Evaluation\ArrayTerm;
12use SqlCatalog\Core\Evaluation\Domain;
13use SqlCatalog\Core\Evaluation\LiteralTerm;
14use SqlCatalog\Core\Evaluation\ObjectTerm;
15use SqlCatalog\Core\Evaluation\OpaqueTerm;
16use SqlCatalog\Core\Evaluation\PatternTerm;
17use SqlCatalog\Core\Text\LiteralText;
18use SqlCatalog\Core\Text\TextGeneralization;
19use SqlCatalog\Core\Text\TextHole;
20use SqlCatalog\Core\Text\TextPattern;
21use SqlCatalog\Core\Type\TypeShape;
22use SqlCatalog\Extension\Laravel\Grammar;
23use SqlCatalog\Extension\Laravel\Predicates;
24use SqlCatalog\Extension\Laravel\QueryState;
25
26#[CoversClass(Predicates::class)]
27#[UsesClass(Domain::class)]
28#[UsesClass(ArrayTerm::class)]
29#[UsesClass(ArrayEntry::class)]
30#[UsesClass(ObjectTerm::class)]
31#[UsesClass(LiteralTerm::class)]
32#[UsesClass(OpaqueTerm::class)]
33#[UsesClass(PatternTerm::class)]
34#[UsesClass(TextPattern::class)]
35#[UsesClass(TextHole::class)]
36#[UsesClass(LiteralText::class)]
37#[UsesClass(TextGeneralization::class)]
38#[UsesClass(TypeShape::class)]
39#[UsesClass(QueryState::class)]
40#[UsesClass(Grammar::class)]
41final class PredicatesTest extends TestCase
42{
43    public function testApplyDispatchesOrPredicatesAndRejectsOtherOperations(): void
44    {
45        $predicates = new Predicates(new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite')));
46        $state = $predicates->basic(new QueryState(), [Domain::literal('id'), Domain::literal(1)], 'and');
47        $state = $predicates->apply($state, 'orwhere', [Domain::literal('id'), Domain::literal(2)]);
48        self::assertSame('or "id" = ?', $state?->items('where')[1]->soleLiteral()?->value);
49        self::assertNull($predicates->apply(new QueryState(), 'unknown', []));
50    }
51
52    public function testAddDoesNotPrefixTheFirstPredicateWithABoolean(): void
53    {
54        $p = new Predicates(new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite')));
55        $state = $p->add(new QueryState(), Domain::literal('a = ?'), [Domain::literal(1)], 'or');
56        $state = $p->add($state, Domain::literal('b = ?'), [Domain::literal(2)]);
57        self::assertSame(['a = ?', 'and b = ?'], array_map(static fn (Domain $v): mixed => $v->soleLiteral()?->value, $state->items('where')));
58        self::assertCount(2, $state->items('whereBindings'));
59    }
60
61    public function testBasicNormalizesNullAndPreservesComparisonBindings(): void
62    {
63        $p = new Predicates(new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite')));
64        $state = $p->basic(new QueryState(), [Domain::literal('id'), Domain::literal('>'), Domain::literal(2)], 'and');
65        self::assertSame('"id" > ?', $state->items('where')[0]->soleLiteral()?->value);
66        self::assertSame(2, $state->items('whereBindings')[0]->soleLiteral()?->value);
67        $null = $p->basic(new QueryState(), [Domain::literal('id'), Domain::literal('!='), Domain::literal(null)], 'and');
68        self::assertSame('"id" is not null', $null->items('where')[0]->soleLiteral()?->value);
69        self::assertSame([], $null->items('whereBindings'));
70        $open = $p->basic(new QueryState(), [Domain::literal('id'), Domain::unknown()], 'and');
71        self::assertSame('"id" = ?', $open->items('where')[0]->soleLiteral()?->value);
72        self::assertFalse($open->items('whereBindings')[0]->isExact());
73        self::assertArrayNotHasKey('problem', $open->fields);
74        self::assertFalse($p->basic(new QueryState(), [], 'and')->get('problem')->isExact());
75        self::assertFalse($p->basic(new QueryState(), [Domain::literal('id'), Domain::literal('bad'), Domain::literal(1)], 'and')->get('problem')->isExact());
76    }
77
78    public function testNullsSupportsMultipleColumnsWithoutBindings(): void
79    {
80        $p = new Predicates(new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite')));
81        $state = $p->nulls(new QueryState(), [QueryState::list([Domain::literal('a'), Domain::literal('b')])], 'or', false);
82        self::assertSame('"a" is null', $state->items('where')[0]->soleLiteral()?->value);
83        self::assertSame('or "b" is null', $state->items('where')[1]->soleLiteral()?->value);
84        self::assertFalse($p->nulls(new QueryState(), [], 'and', false)->get('problem')->isExact());
85    }
86
87    public function testInHandlesEmptySetsAndOrderedValues(): void
88    {
89        $p = new Predicates(new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite')));
90        $empty = [Domain::literal('id'), QueryState::list([])];
91        self::assertSame('0 = 1', $p->in(new QueryState(), $empty, 'and', false)->items('where')[0]->soleLiteral()?->value);
92        self::assertSame('1 = 1', $p->in(new QueryState(), $empty, 'and', true)->items('where')[0]->soleLiteral()?->value);
93        $state = $p->in(new QueryState(), [Domain::literal('id'), QueryState::list([Domain::literal(2), Domain::literal(1)])], 'and', true);
94        self::assertSame('"id" not in (?, ?)', $state->items('where')[0]->soleLiteral()?->value);
95        self::assertSame(2, $state->items('whereBindings')[0]->soleLiteral()?->value);
96        $open = $p->in(new QueryState(), [Domain::literal('id'), Domain::unknown()], 'and', false);
97        self::assertArrayNotHasKey('problem', $open->fields);
98        self::assertFalse($open->items('where')[0]->isExact());
99        self::assertSame('"id" in ({$})', $open->items('where')[0]->patterns()[0]->display());
100        self::assertSame([], $open->items('whereBindings'));
101        self::assertFalse($p->in(new QueryState(), [Domain::literal('id')], 'and', false)->get('problem')->isExact());
102    }
103
104    public function testBetweenKeepsBothBoundsInOrder(): void
105    {
106        $p = new Predicates(new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('mysql')));
107        $state = $p->between(new QueryState(), [Domain::literal('id'), QueryState::list([Domain::literal(2), Domain::literal(9)])], 'and', true);
108        self::assertSame('`id` not between ? and ?', $state->items('where')[0]->soleLiteral()?->value);
109        self::assertSame([2, 9], array_map(static fn (Domain $v): mixed => $v->soleLiteral()?->value, $state->items('whereBindings')));
110        self::assertFalse($p->between(new QueryState(), [], 'and', false)->get('problem')->isExact());
111        self::assertSame([1, 2], array_map(static fn (Domain $v): mixed => $v->soleLiteral()?->value, $p->between(new QueryState(), [Domain::literal('id'), QueryState::list([Domain::literal(1), Domain::literal(2), Domain::literal(3)])], 'and', false)->items('whereBindings')));
112        self::assertFalse($p->between(new QueryState(), [Domain::literal('id'), QueryState::list([Domain::literal(1)])], 'and', false)->get('problem')->isExact());
113    }
114
115    public function testColumnDoesNotTurnTheRightIdentifierIntoABinding(): void
116    {
117        $p = new Predicates(new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite')));
118        $state = $p->column(new QueryState(), [Domain::literal('a'), Domain::literal('<'), Domain::literal('b')], 'and');
119        self::assertSame('"a" < "b"', $state->items('where')[0]->soleLiteral()?->value);
120        self::assertSame([], $state->items('whereBindings'));
121        self::assertFalse($p->column(new QueryState(), [], 'and')->get('problem')->isExact());
122        self::assertFalse($p->column(new QueryState(), [Domain::literal('a'), Domain::unknown(), Domain::literal('b')], 'and')->get('problem')->isExact());
123    }
124
125    public function testRawKeepsBindingsAndLeavesIncompleteArraysOpen(): void
126    {
127        $p = new Predicates(new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite')));
128        $state = $p->raw(new QueryState(), [Domain::literal('id > ?'), QueryState::list([Domain::literal(2)])], 'and');
129        self::assertSame('id > ?', $state->items('where')[0]->soleLiteral()?->value);
130        self::assertSame(2, $state->items('whereBindings')[0]->soleLiteral()?->value);
131        $counted = $p->raw(new QueryState(), [Domain::literal('a = ? and b = ?'), Domain::of(new ArrayTerm([], false))], 'and');
132        self::assertArrayNotHasKey('problem', $counted->fields);
133        self::assertCount(2, $counted->items('whereBindings'));
134        self::assertFalse($p->raw(new QueryState(), [Domain::unknown(), Domain::of(new ArrayTerm([], false))], 'and')->get('problem')->isExact());
135        self::assertFalse($p->raw(new QueryState(), [], 'and')->get('problem')->isExact());
136    }
137
138    public function testInPreservesValuesFromAssociativeArraysAndRejectsNestedArrays(): void
139    {
140        $p = new Predicates(new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite')));
141        $values = Domain::of(new ArrayTerm([new ArrayEntry(Domain::literal('key'), Domain::literal(7))]));
142        $state = $p->in(new QueryState(), [Domain::literal('id'), $values], 'and', false);
143        self::assertSame('"id" in (?)', $state->items('where')[0]->soleLiteral()?->value);
144        self::assertSame(7, $state->items('whereBindings')[0]->soleLiteral()?->value);
145        self::assertFalse($p->in(new QueryState(), [Domain::literal('id'), QueryState::list([$values])], 'and', false)->get('problem')->isExact());
146    }
147
148    public function testNullsDoesNotDropUnknownColumnsOrAssociativeValues(): void
149    {
150        $p = new Predicates(new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite')));
151        self::assertFalse($p->nulls(new QueryState(), [Domain::of(new ArrayTerm([], false))], 'and', false)->get('problem')->isExact());
152        $state = $p->nulls(new QueryState(), [Domain::of(new ArrayTerm([new ArrayEntry(Domain::literal('key'), Domain::literal('id'))]))], 'and', false);
153        self::assertSame('"id" is null', $state->items('where')[0]->soleLiteral()?->value);
154    }
155
156    public function testGroupDisjunctionPreservesConjunctionsWithoutAddingParentheses(): void
157    {
158        $state = new QueryState(['where' => QueryState::list([Domain::literal('a = ?'), Domain::literal('and b = ?')])]);
159        self::assertSame($state, (new Predicates(new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite'))))->groupDisjunction($state));
160        $disjunction = $state->with('where', QueryState::list([Domain::literal('a = ?'), Domain::literal('or b = ?')]));
161        self::assertSame('(a = ? or b = ?)', (new Predicates(new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite'))))->groupDisjunction($disjunction)->items('where')[0]->soleLiteral()?->value);
162    }
163
164    /**
165     * @param list<Domain> $arguments
166     */
167    #[\PHPUnit\Framework\Attributes\DataProvider('providerPredicateDispatch')]
168    public function testApplyPreservesThePredicateAndBoolean(string $method, array $arguments, string $expected, int $bindings): void
169    {
170        $state = new QueryState(['where' => QueryState::list([Domain::literal('active = 1')])]);
171        $after = (new Predicates(new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite'))))->apply($state, $method, $arguments);
172        self::assertNotNull($after);
173        self::assertSame($expected, $after->items('where')[1]->soleLiteral()?->value);
174        self::assertCount($bindings, $after->items('whereBindings'));
175        self::assertArrayNotHasKey('problem', $after->fields);
176    }
177
178    /**
179     * @return iterable<array{string, list<Domain>, string, int}>
180     */
181    public static function providerPredicateDispatch(): iterable
182    {
183        foreach (['where' => [' = ?', 1], 'wherenull' => [' is null', 0], 'wherenotnull' => [' is not null', 0], 'wherein' => [' in (?, ?)', 2], 'wherenotin' => [' not in (?, ?)', 2], 'wherebetween' => [' between ? and ?', 2], 'wherenotbetween' => [' not between ? and ?', 2], 'wherecolumn' => [' = "other"', 0], 'whereraw' => [' > ?', 1]] as $method => [$suffix, $count]) {
184            $args = match ($method) {
185                'where' => [Domain::literal('id'), Domain::literal(1)],
186                'wherenull', 'wherenotnull' => [Domain::literal('id')],
187                'wherecolumn' => [Domain::literal('id'), Domain::literal('other')],
188                'whereraw' => [Domain::literal('"id" > ?'), QueryState::list([Domain::literal(1)])],
189                'wherein', 'wherenotin', 'wherebetween', 'wherenotbetween' => [Domain::literal('id'), QueryState::list([Domain::literal(1), Domain::literal(2)])],
190            };
191            yield [$method, $args, 'and "id"'.$suffix, $count];
192            yield ['or'.$method, $args, 'or "id"'.$suffix, $count];
193        }
194    }
195
196
197    public function testBasicCompilesUnresolvedAndNullableValuesAsPlaceholders(): void
198    {
199        $p = new Predicates(new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('mysql')));
200        $nullable = Domain::opaque(TypeShape::of(['int', 'null']), \SqlCatalog\Core\Text\Origin::Parameter, '$id');
201        $state = $p->basic(new QueryState(), [Domain::literal('id'), $nullable], 'and');
202        self::assertSame('`id` = ?', $state->items('where')[0]->soleLiteral()?->value);
203        self::assertSame('int|null', $state->items('whereBindings')[0]->type()->display());
204        $or = $p->basic($state, [Domain::literal('a'), Domain::literal('>'), Domain::literal(1), Domain::literal('OR')], 'and');
205        self::assertSame('or `a` > ?', $or->items('where')[1]->soleLiteral()?->value);
206        self::assertFalse($p->basic($state, [Domain::literal('a'), Domain::literal('>'), Domain::literal(1), Domain::unknown()], 'and')->get('problem')->isExact());
207        self::assertFalse($p->basic(new QueryState(), [Domain::literal('id'), QueryState::list([Domain::literal(1)])], 'and')->get('problem')->isExact());
208    }
209
210    public function testGroupNestsArrayEntriesInParenthesesWithTheOuterBoolean(): void
211    {
212        $p = new Predicates(new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite')));
213        $entries = new ArrayTerm([new ArrayEntry(Domain::literal('a'), Domain::literal(1)), new ArrayEntry(Domain::literal('b'), Domain::literal(null)), new ArrayEntry(null, QueryState::list([Domain::literal('c'), Domain::literal('>'), Domain::literal(2)]))]);
214        $state = $p->basic(new QueryState(), [Domain::literal('x'), Domain::literal(0)], 'and');
215        $state = $p->group($state, $entries, 'or');
216        self::assertSame('or ("a" = ? or "b" is null or "c" > ?)', $state->items('where')[1]->soleLiteral()?->value);
217        self::assertSame([0, 1, 2], array_map(static fn (Domain $v): mixed => $v->soleLiteral()?->value, $state->items('whereBindings')));
218        self::assertSame($state, $p->group($state, new ArrayTerm([]), 'and'));
219        self::assertFalse($p->group(new QueryState(), new ArrayTerm([], false), 'and')->get('problem')->isExact());
220        self::assertFalse($p->group(new QueryState(), new ArrayTerm([new ArrayEntry(Domain::literal('a'), QueryState::list([]))]), 'and')->get('problem')->isExact());
221    }
222
223    public function testEntryReadsKeyedComparisonsAndPositionalArgumentLists(): void
224    {
225        $p = new Predicates(new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite')));
226        self::assertSame('"a" = ?', $p->entry(new QueryState(), new ArrayEntry(Domain::literal('a'), Domain::literal(1)), 'and')->items('where')[0]->soleLiteral()?->value);
227        self::assertSame('"a" < ?', $p->entry(new QueryState(), new ArrayEntry(Domain::literal(0), QueryState::list([Domain::literal('a'), Domain::literal('<'), Domain::literal(1)])), 'and')->items('where')[0]->soleLiteral()?->value);
228        $row = QueryState::list([Domain::literal('a'), Domain::literal('<'), Domain::literal(1)]);
229        self::assertSame('or "a" < ?', $p->entry($p->basic(new QueryState(), [Domain::literal('x'), Domain::literal(0)], 'and'), new ArrayEntry(Domain::literal(0), $row), 'or')->items('where')[1]->soleLiteral()?->value);
230        self::assertFalse($p->entry(new QueryState(), new ArrayEntry(null, QueryState::list([Domain::literal('a'), Domain::literal('<'), Domain::literal(1), Domain::literal('or')])), 'and')->get('problem')->isExact());
231        self::assertFalse($p->entry(new QueryState(), new ArrayEntry(null, Domain::literal('a')), 'and')->get('problem')->isExact());
232        self::assertFalse($p->entry(new QueryState(), new ArrayEntry(null, Domain::of(new ArrayTerm([], false))), 'and')->get('problem')->isExact());
233        self::assertFalse($p->entry(new QueryState(), new ArrayEntry(Domain::unknown(), Domain::literal(1)), 'and')->get('problem')->isExact());
234    }
235
236    public function testComparisonBindsNullLiterallyForClausesWithoutNullNormalization(): void
237    {
238        $p = new Predicates(new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite')));
239        $comparison = $p->comparison([Domain::literal('total'), Domain::literal(null)]);
240        self::assertNotNull($comparison);
241        self::assertSame('"total" = ?', $comparison[0]->soleLiteral()?->value);
242        self::assertNull($comparison[1][0]->soleLiteral()?->value);
243        $like = $p->comparison([Domain::literal('a'), Domain::literal('LIKE'), Domain::literal('x%')]);
244        self::assertNotNull($like);
245        self::assertSame('"a" LIKE ?', $like[0]->soleLiteral()?->value);
246        self::assertNull($p->comparison([Domain::literal('a')]));
247        self::assertNull($p->comparison([Domain::literal('a'), Domain::literal('between'), Domain::literal(1)]));
248        self::assertNull($p->comparison([Domain::literal('a'), QueryState::list([])]));
249    }
250}
251