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