packages/sql-catalog/tests/Unit/Extension/Laravel/BuilderQueriesTest.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\Analysis\Derivation\FreeNames;
11use SqlCatalog\Core\Analysis\Derivation\ModifiedNames;
12use SqlCatalog\Core\Analysis\Derivation\Objects\CallbackEffects;
13use SqlCatalog\Core\Analysis\Derivation\Objects\ObjectEffects;
14use SqlCatalog\Core\Analysis\Derivation\Slice\BackwardSlicer;
15use SqlCatalog\Core\Analysis\Derivation\SliceExecutor;
16use SqlCatalog\Core\Analysis\Derivation\Solution;
17use SqlCatalog\Core\Analysis\Derivation\SourceTree;
18use SqlCatalog\Core\Analysis\EvaluationBudget;
19use SqlCatalog\Core\Analysis\FunctionScope;
20use SqlCatalog\Core\Analysis\Interpreter;
21use SqlCatalog\Core\Analysis\SinkFinder;
22use SqlCatalog\Core\Evaluation\ArrayEntry;
23use SqlCatalog\Core\Evaluation\ArrayTerm;
24use SqlCatalog\Core\Evaluation\Domain;
25use SqlCatalog\Core\Evaluation\Environment;
26use SqlCatalog\Core\Evaluation\LiteralTerm;
27use SqlCatalog\Core\Evaluation\ObjectMemory;
28use SqlCatalog\Core\Evaluation\ObjectTerm;
29use SqlCatalog\Core\Evaluation\OpaqueTerm;
30use SqlCatalog\Core\Evaluation\PatternTerm;
31use SqlCatalog\Core\Php\ParsedFile;
32use SqlCatalog\Core\Php\ProgramIndex;
33use SqlCatalog\Core\Php\ProgramIndexBuilder;
34use SqlCatalog\Core\Php\SourceParser;
35use SqlCatalog\Core\Text\LiteralText;
36use SqlCatalog\Core\Text\TextGeneralization;
37use SqlCatalog\Core\Text\TextHole;
38use SqlCatalog\Core\Text\TextPattern;
39use SqlCatalog\Core\Type\TypeShape;
40use SqlCatalog\Extension\Laravel\BuilderCalls;
41use SqlCatalog\Extension\Laravel\BuilderQueries;
42use SqlCatalog\Extension\Laravel\Clauses;
43use SqlCatalog\Extension\Laravel\Grammar;
44use SqlCatalog\Extension\Laravel\LaravelExtension;
45use SqlCatalog\Extension\Laravel\Predicates;
46use SqlCatalog\Extension\Laravel\QueryState;
47use SqlCatalog\Extension\Laravel\SelectCompiler;
48use SqlCatalog\Extension\Laravel\WriteCompiler;
49
50#[CoversClass(BuilderQueries::class)]
51#[UsesClass(Domain::class)]
52#[UsesClass(ArrayTerm::class)]
53#[UsesClass(ArrayEntry::class)]
54#[UsesClass(ObjectTerm::class)]
55#[UsesClass(LiteralTerm::class)]
56#[UsesClass(OpaqueTerm::class)]
57#[UsesClass(PatternTerm::class)]
58#[UsesClass(TextPattern::class)]
59#[UsesClass(TextHole::class)]
60#[UsesClass(LiteralText::class)]
61#[UsesClass(TextGeneralization::class)]
62#[UsesClass(TypeShape::class)]
63#[UsesClass(FreeNames::class)]
64#[UsesClass(ModifiedNames::class)]
65#[UsesClass(ObjectEffects::class)]
66#[UsesClass(CallbackEffects::class)]
67#[UsesClass(BackwardSlicer::class)]
68#[UsesClass(SourceTree::class)]
69#[UsesClass(SliceExecutor::class)]
70#[UsesClass(EvaluationBudget::class)]
71#[UsesClass(Interpreter::class)]
72#[UsesClass(FunctionScope::class)]
73#[UsesClass(Environment::class)]
74#[UsesClass(ObjectMemory::class)]
75#[UsesClass(SourceParser::class)]
76#[UsesClass(ParsedFile::class)]
77#[UsesClass(ProgramIndex::class)]
78#[UsesClass(ProgramIndexBuilder::class)]
79#[UsesClass(BuilderCalls::class)]
80#[UsesClass(QueryState::class)]
81#[UsesClass(LaravelExtension::class)]
82#[UsesClass(Solution::class)]
83#[UsesClass(SinkFinder::class)]
84#[UsesClass(SelectCompiler::class)]
85#[UsesClass(WriteCompiler::class)]
86#[UsesClass(Grammar::class)]
87#[UsesClass(Predicates::class)]
88#[UsesClass(Clauses::class)]
89#[UsesClass(\SqlCatalog\Core\Analysis\CallEvaluator::class)]
90#[UsesClass(\SqlCatalog\Core\Analysis\ConstantReader::class)]
91#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Binding::class)]
92#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CalleeReturns::class)]
93#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CallerIndex::class)]
94#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Callers::class)]
95#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Deriver::class)]
96#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\EntryBinder::class)]
97#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\PropertyWrites::class)]
98#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\Arrival::class)]
99#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\AssignmentSteps::class)]
100#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\LoopPasses::class)]
101#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\Pending::class)]
102#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\SliceStep::class)]
103#[UsesClass(\SqlCatalog\Core\Analysis\ExpressionEvaluator::class)]
104#[UsesClass(\SqlCatalog\Core\Analysis\ExternalInput::class)]
105#[UsesClass(\SqlCatalog\Extension\Laravel\CallbackModel::class)]
106#[UsesClass(\SqlCatalog\Core\Analysis\ReferenceEvaluator::class)]
107#[UsesClass(\SqlCatalog\Core\Analysis\SinkMatcher::class)]
108#[UsesClass(\SqlCatalog\Core\Extension\SinkSpec::class)]
109#[UsesClass(\SqlCatalog\Core\Php\DeclaredGlobals::class)]
110#[UsesClass(\SqlCatalog\Core\Php\NodeText::class)]
111#[UsesClass(\SqlCatalog\Core\Analysis\Model\ModelQueries::class)]
112#[UsesClass(\SqlCatalog\Extension\Laravel\CallModel::class)]
113#[UsesClass(\SqlCatalog\Core\Extension\Model\CallContext::class)]
114#[UsesClass(\SqlCatalog\Core\Extension\Model\ModelContext::class)]
115#[UsesClass(\SqlCatalog\Core\Extension\Model\ModelSet::class)]
116#[UsesClass(\SqlCatalog\Core\Extension\Model\QueryOutput::class)]
117#[UsesClass(\SqlCatalog\Core\Analysis\BuiltinCallModel::class)]
118#[UsesClass(\SqlCatalog\Core\Analysis\Effect\ReferenceEffects::class)]
119#[UsesClass(\SqlCatalog\Core\Analysis\Effect\WriteEffects::class)]
120#[UsesClass(\SqlCatalog\Core\Analysis\FunctionModel\Registry::class)]
121final class BuilderQueriesTest extends TestCase
122{
123 public function testStatementsCompilesReceiverAndBindingsDerivedFromTheSameBranch(): void
124 {
125 $file = (new SourceParser())->parse('query.php', '<?php use Illuminate\\Support\\Facades\\DB; $q = DB::table("users"); $q->where("id", 7)->get();');
126 $index = (new ProgramIndexBuilder())->build([$file]);
127 $calls = (new \PhpParser\NodeFinder())->findInstanceOf($file->statements, \PhpParser\Node\Expr\MethodCall::class);
128 $deriver = (new Interpreter($index, (new LaravelExtension(\SqlCatalog\Facade\Builtins::dialects()))->sinks(), dialect: 'sqlite', modelProviders: [new LaravelExtension(\SqlCatalog\Facade\Builtins::dialects())]))->deriverFor([$file]);
129 $solutions = (new \SqlCatalog\Core\Analysis\Model\ModelQueries())->solve($calls[0], new BuilderQueries($index, dialects: \SqlCatalog\Facade\Builtins::dialects()), $deriver);
130 self::assertCount(1, $solutions);
131 self::assertSame('select * from "users" where "id" = ?', $solutions[0]->values[0]->soleLiteral()?->value);
132 self::assertSame(7, $solutions[0]->values[1]->soleArray()?->positional()[0]->soleLiteral()?->value);
133 }
134
135 public function testCompileAppliesSoftDeletesOnlyAtExecution(): void
136 {
137 $state = new QueryState(['dialect' => Domain::literal('sqlite'), 'table' => Domain::literal('users'), 'softDeletes' => Domain::literal(true), 'deletedColumn' => Domain::literal('deleted_at')]);
138 $queries = new BuilderQueries(new ProgramIndex(), dialects: \SqlCatalog\Facade\Builtins::dialects());
139 self::assertSame('select * from "users" where "users"."deleted_at" is null', $queries->compile($state, 'get', [])[0]->soleLiteral()?->value);
140 self::assertSame('select * from "users"', $queries->compile($state->with('trashed', Domain::literal('withtrashed')), 'get', [])[0]->soleLiteral()?->value);
141 self::assertSame('select * from "users" where "users"."deleted_at" is not null', $queries->compile($state->with('trashed', Domain::literal('onlytrashed')), 'get', [])[0]->soleLiteral()?->value);
142 self::assertFalse($queries->compile($state, 'delete', [])[0]->isExact());
143 self::assertFalse($queries->compile($state->with('timestamps', Domain::literal(true)), 'update', [])[0]->isExact());
144 }
145
146 public function testCompileKeepsUnknownEffectsAndMissingDialectsOpen(): void
147 {
148 $queries = new BuilderQueries(new ProgramIndex(), dialects: \SqlCatalog\Facade\Builtins::dialects());
149 self::assertFalse($queries->compile(new QueryState(['table' => Domain::literal('users')]), 'get', [])[0]->isExact());
150 self::assertFalse($queries->compile((new QueryState())->reject('macro'), 'get', [])[0]->isExact());
151 }
152
153 public function testCompileGroupsDisjunctionsBeforeApplyingSoftDeletes(): void
154 {
155 $state = new QueryState(['dialect' => Domain::literal('sqlite'), 'table' => Domain::literal('users'), 'softDeletes' => Domain::literal(true), 'deletedColumn' => Domain::literal('deleted_at'), 'where' => QueryState::list([Domain::literal('"a" = ?'), Domain::literal('or "b" = ?')]), 'whereBindings' => QueryState::list([Domain::literal(1), Domain::literal(2)])]);
156 [$sql, $bindings] = (new BuilderQueries(new ProgramIndex(), dialects: \SqlCatalog\Facade\Builtins::dialects()))->compile($state, 'get', []);
157 self::assertSame('select * from "users" where ("a" = ? or "b" = ?) and "users"."deleted_at" is null', $sql->soleLiteral()?->value);
158 self::assertCount(2, $bindings->soleArray()->entries ?? []);
159 }
160 public function testCompileRequiresADialectEvenWhenEveryIdentifierIsRaw(): void
161 {
162 $raw = Domain::of(new ObjectTerm('Illuminate\Database\Query\Expression', state: (new QueryState(['sql' => Domain::literal('users')]))->array()));
163 $state = new QueryState(['table' => $raw, 'columns' => QueryState::list([Domain::literal('*')]), 'offset' => Domain::literal(1)]);
164 self::assertFalse((new BuilderQueries(new ProgramIndex(), dialects: \SqlCatalog\Facade\Builtins::dialects()))->compile($state, 'get', [])[0]->isExact());
165 }
166
167 public function testInputsUsesTheReceiverOrModelFactoryBeforeTheArguments(): void
168 {
169 $queries = new BuilderQueries(new ProgramIndex(), dialects: \SqlCatalog\Facade\Builtins::dialects());
170 $receiver = new \PhpParser\Node\Expr\Variable('builder');
171 $argument = new \PhpParser\Node\Scalar\Int_(7);
172 self::assertSame([$receiver, $argument], $queries->inputs(new \PhpParser\Node\Expr\MethodCall($receiver, 'find', [new \PhpParser\Node\Arg($argument)])));
173 $inputs = $queries->inputs(new \PhpParser\Node\Expr\StaticCall(new \PhpParser\Node\Name('User'), 'get'));
174 self::assertSame('User::query()', (new \SqlCatalog\Core\Php\NodeText())->render($inputs[0]));
175 self::assertSame([], $queries->inputs(new \PhpParser\Node\Expr\FuncCall(new \PhpParser\Node\Name('other'))));
176 }
177
178
179 public function testOutputsListsEveryStatementAPaginatedOrEagerReadIssues(): void
180 {
181 $queries = new BuilderQueries(new ProgramIndex(), \SqlCatalog\Facade\Builtins::dialects());
182 $state = new QueryState(['dialect' => Domain::literal('sqlite'), 'table' => Domain::literal('users'), 'where' => QueryState::list([Domain::literal('"a" = ?')]), 'whereBindings' => QueryState::list([Domain::literal(1)])]);
183 $outputs = $queries->outputs($state, 'paginate', [Domain::literal(10)]);
184 self::assertCount(2, $outputs);
185 self::assertSame('select count(*) as "aggregate" from "users" where "a" = ?', $outputs[0][0]->soleLiteral()?->value);
186 self::assertSame('select * from "users" where "a" = ? limit 10 offset {$}', $outputs[1][0]->patterns()[0]->display());
187 $eager = $queries->outputs($state->with('eager', Domain::literal(true)), 'get', []);
188 self::assertCount(2, $eager);
189 self::assertSame('select * from "users" where "a" = ?', $eager[0][0]->soleLiteral()?->value);
190 self::assertFalse($eager[1][0]->isExact());
191 self::assertCount(1, $queries->outputs($state->with('eager', Domain::literal(true)), 'delete', []));
192 $problem = $queries->outputs($state->reject('macro'), 'paginate', []);
193 self::assertCount(1, $problem);
194 self::assertFalse($problem[0][0]->isExact());
195 }
196
197 public function testPrepareAppliesScopesAndMarksUnmodelledWrites(): void
198 {
199 $queries = new BuilderQueries(new ProgramIndex(), \SqlCatalog\Facade\Builtins::dialects());
200 $state = new QueryState(['dialect' => Domain::literal('sqlite'), 'table' => Domain::literal('users'), 'softDeletes' => Domain::literal(true), 'deletedColumn' => Domain::literal('deleted_at'), 'timestamps' => Domain::literal(true)]);
201 self::assertSame('"users"."deleted_at" is null', $queries->prepare($state, 'get')->items('where')[0]->soleLiteral()?->value);
202 self::assertArrayNotHasKey('problem', $queries->prepare($state, 'get')->fields);
203 self::assertFalse($queries->prepare($state, 'increment')->get('problem')->isExact());
204 self::assertFalse($queries->prepare($state, 'delete')->get('problem')->isExact());
205 self::assertFalse($queries->prepare(new QueryState(['table' => Domain::literal('users')]), 'get')->get('problem')->isExact());
206 }
207
208 public function testDispatchRoutesReadsAndWritesToTheirCompilers(): void
209 {
210 $queries = new BuilderQueries(new ProgramIndex(), \SqlCatalog\Facade\Builtins::dialects());
211 $grammar = new Grammar(\SqlCatalog\Facade\Builtins::dialects()->find('sqlite'));
212 $state = new QueryState(['dialect' => Domain::literal('sqlite'), 'table' => Domain::literal('users')]);
213 self::assertSame('select * from "users"', $queries->dispatch($state, 'cursor', [], $grammar)[0]->soleLiteral()?->value);
214 self::assertSame('delete from "users"', $queries->dispatch($state, 'delete', [], $grammar)[0]->soleLiteral()?->value);
215 }
216}
217