packages/sql-catalog/tests/Unit/Extension/Laravel/GrammarTest.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\QueryState;
24
25#[CoversClass(Grammar::class)]
26#[UsesClass(Domain::class)]
27#[UsesClass(ArrayTerm::class)]
28#[UsesClass(ArrayEntry::class)]
29#[UsesClass(ObjectTerm::class)]
30#[UsesClass(LiteralTerm::class)]
31#[UsesClass(OpaqueTerm::class)]
32#[UsesClass(PatternTerm::class)]
33#[UsesClass(TextPattern::class)]
34#[UsesClass(TextHole::class)]
35#[UsesClass(LiteralText::class)]
36#[UsesClass(TextGeneralization::class)]
37#[UsesClass(TypeShape::class)]
38#[UsesClass(QueryState::class)]
39final class GrammarTest extends TestCase
40{
41 public function testWrapQuotesIdentifiersAliasesAndEmbeddedQuotes(): void
42 {
43 self::assertSame('`users`.`id`', (new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('mysql')))->wrap(Domain::literal('users.id'))->soleLiteral()?->value);
44 self::assertSame('"users" as "u"', (new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('pgsql')))->wrap(Domain::literal('users AS u'))->soleLiteral()?->value);
45 self::assertSame('"odd""name"', (new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite')))->wrap(Domain::literal('odd"name'))->soleLiteral()?->value);
46 self::assertSame('"users".*', (new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite')))->wrap(Domain::literal('users.*'))->soleLiteral()?->value);
47 self::assertFalse((new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find(null)))->wrap(Domain::literal('users'))->isExact());
48 self::assertFalse((new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite')))->wrap(Domain::unknown())->isExact());
49 self::assertFalse((new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite')))->wrap(Domain::literal('data->name'))->isExact());
50 }
51
52 public function testJoinPreservesOrderAndGaps(): void
53 {
54 $grammar = new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite'));
55 self::assertSame('a / b', $grammar->join([Domain::literal('a'), Domain::literal('b')], ' / ')->soleLiteral()?->value);
56 self::assertFalse($grammar->join([Domain::literal('a'), Domain::unknown()])->isExact());
57 self::assertSame('', $grammar->join([])->soleLiteral()?->value);
58 }
59
60 public function testParameterOnlySplicesExplicitExpressions(): void
61 {
62 $grammar = new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite'));
63 $raw = Domain::of(new ObjectTerm('Illuminate\Database\Query\Expression', state: (new QueryState(['sql' => Domain::literal('count(*)')]))->array()));
64 self::assertSame('count(*)', $grammar->parameter($raw)->soleLiteral()?->value);
65 self::assertSame('count(*)', $grammar->wrap($raw)->soleLiteral()?->value);
66 self::assertSame('?', $grammar->parameter(Domain::literal('untrusted'))->soleLiteral()?->value);
67 }
68
69 public function testBindingsExcludeExpressionsWithoutChangingValueOrder(): void
70 {
71 $a = Domain::literal(1);
72 $b = Domain::literal(null);
73 $raw = Domain::of(new ObjectTerm('Illuminate\Database\Query\Expression'));
74 self::assertSame([$a, $b], (new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('mysql')))->bindings([$a, $raw, $b]));
75 }
76 public function testWrapTreatsAnAliasAsOneIdentifierEvenWhenItContainsDots(): void
77 {
78 self::assertSame('"users"."id" as "user.key"', (new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite')))->wrap(Domain::literal('users.id as user.key'))->soleLiteral()?->value);
79 self::assertSame('`users`.`id` as `odd``alias`', (new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('mysql')))->wrap(Domain::literal('users.id as odd`alias'))->soleLiteral()?->value);
80 }
81
82
83 public function testWrapKeepsEachAlternativeAndTheOriginOfUnresolvedNames(): void
84 {
85 $grammar = new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('mysql'));
86 $either = $grammar->wrap(Domain::literal('a')->union(Domain::literal('b')));
87 self::assertSame(['`a`', '`b`'], array_map(static fn (\SqlCatalog\Core\Evaluation\Term $term): mixed => $term instanceof LiteralTerm ? $term->value : null, $either->terms));
88 $parameter = $grammar->wrap(Domain::opaque(TypeShape::of(['string']), \SqlCatalog\Core\Text\Origin::Parameter, '$column'));
89 self::assertSame(\SqlCatalog\Core\Text\Origin::Parameter, $parameter->patterns()[0]->holes()[0]->origin);
90 self::assertFalse($grammar->wrap(Domain::literal(1))->isExact());
91 }
92
93 public function testWrapNameQuotesQualifiedNamesAndLeavesJsonSelectorsOpen(): void
94 {
95 $grammar = new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('pgsql'));
96 self::assertSame('"users"."id" as "uid"', $grammar->wrapName('users.id as uid')->soleLiteral()?->value);
97 self::assertFalse($grammar->wrapName('data->name')->isExact());
98 self::assertFalse((new Grammar(null))->wrapName('users')->isExact());
99 }
100
101 public function testOpenedKeepsNonLiteralTermsAndRejectsNonStringLiterals(): void
102 {
103 $grammar = new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite'));
104 $opaque = new OpaqueTerm(TypeShape::of(['string']), \SqlCatalog\Core\Text\Origin::External, '$_GET');
105 self::assertSame([$opaque], $grammar->opened($opaque)->terms);
106 self::assertSame(\SqlCatalog\Core\Text\Origin::Call, $grammar->opened(new LiteralTerm(7))->patterns()[0]->holes()[0]->origin);
107 }
108
109 public function testPlaceholdersStandForAListOfUnknownLengthFromTheSameOrigin(): void
110 {
111 $grammar = new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite'));
112 $hole = $grammar->placeholders(Domain::of(new OpaqueTerm(TypeShape::of(['array']), \SqlCatalog\Core\Text\Origin::External, '$_GET', 'ids')))->patterns()[0]->holes()[0];
113 self::assertSame(\SqlCatalog\Core\Text\Origin::External, $hole->origin);
114 self::assertSame('ids', $hole->variable);
115 self::assertSame(\SqlCatalog\Core\Text\Origin::Unresolved, $grammar->placeholders(Domain::of(new ArrayTerm([], false)))->patterns()[0]->holes()[0]->origin);
116 self::assertSame(\SqlCatalog\Core\Text\Origin::Branch, $grammar->placeholders(Domain::literal('a')->union(Domain::literal('b')))->patterns()[0]->holes()[0]->origin);
117 }
118
119 public function testElementIsOneUnknownValueFromTheListsOrigin(): void
120 {
121 $grammar = new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite'));
122 $element = $grammar->element(Domain::opaque(TypeShape::of(['array']), \SqlCatalog\Core\Text\Origin::Parameter, '$values'));
123 self::assertSame(\SqlCatalog\Core\Text\Origin::Parameter, $element->patterns()[0]->holes()[0]->origin);
124 self::assertTrue($element->type()->isUnknown());
125 self::assertSame(\SqlCatalog\Core\Text\Origin::Unresolved, $grammar->element(Domain::of(new ArrayTerm([], false)))->patterns()[0]->holes()[0]->origin);
126 }
127
128 public function testRawBindingsCountPlaceholdersWhenTheArrayIsUnknown(): void
129 {
130 $grammar = new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite'));
131 $known = $grammar->rawBindings(Domain::literal('a = ?'), QueryState::list([Domain::literal(1)]));
132 self::assertSame([1], array_map(static fn (Domain $v): mixed => $v->soleLiteral()?->value, $known ?? []));
133 self::assertCount(2, $grammar->rawBindings(Domain::literal('a = ? or b = ?'), Domain::unknown()) ?? []);
134 self::assertSame([], $grammar->rawBindings(Domain::literal('a = 1'), Domain::of(new ArrayTerm([], false))));
135 self::assertNull($grammar->rawBindings(Domain::unknown(), Domain::unknown()));
136 self::assertNull($grammar->rawBindings(Domain::literal('a = ?'), Domain::of(new ObjectTerm('Closure'))));
137 }
138}
139