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