packages/sql-catalog/tests/Unit/Extension/Laravel/CallbackModelTest.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\SourceTree;
17use SqlCatalog\Core\Analysis\EvaluationBudget;
18use SqlCatalog\Core\Analysis\FunctionScope;
19use SqlCatalog\Core\Analysis\Interpreter;
20use SqlCatalog\Core\Evaluation\ArrayEntry;
21use SqlCatalog\Core\Evaluation\ArrayTerm;
22use SqlCatalog\Core\Evaluation\Domain;
23use SqlCatalog\Core\Evaluation\Environment;
24use SqlCatalog\Core\Evaluation\LiteralTerm;
25use SqlCatalog\Core\Evaluation\ObjectMemory;
26use SqlCatalog\Core\Evaluation\ObjectTerm;
27use SqlCatalog\Core\Evaluation\OpaqueTerm;
28use SqlCatalog\Core\Evaluation\PatternTerm;
29use SqlCatalog\Core\Php\ParsedFile;
30use SqlCatalog\Core\Php\ProgramIndex;
31use SqlCatalog\Core\Php\ProgramIndexBuilder;
32use SqlCatalog\Core\Php\SourceParser;
33use SqlCatalog\Core\Text\LiteralText;
34use SqlCatalog\Core\Text\TextGeneralization;
35use SqlCatalog\Core\Text\TextHole;
36use SqlCatalog\Core\Text\TextPattern;
37use SqlCatalog\Core\Type\TypeShape;
38use SqlCatalog\Extension\Laravel\BuilderCalls;
39use SqlCatalog\Extension\Laravel\CallbackModel;
40use SqlCatalog\Extension\Laravel\Grammar;
41use SqlCatalog\Extension\Laravel\LaravelExtension;
42use SqlCatalog\Extension\Laravel\Predicates;
43use SqlCatalog\Extension\Laravel\QueryState;
44
45#[CoversClass(CallbackModel::class)]
46#[UsesClass(Domain::class)]
47#[UsesClass(ArrayTerm::class)]
48#[UsesClass(ArrayEntry::class)]
49#[UsesClass(ObjectTerm::class)]
50#[UsesClass(LiteralTerm::class)]
51#[UsesClass(OpaqueTerm::class)]
52#[UsesClass(PatternTerm::class)]
53#[UsesClass(TextPattern::class)]
54#[UsesClass(TextHole::class)]
55#[UsesClass(LiteralText::class)]
56#[UsesClass(TextGeneralization::class)]
57#[UsesClass(TypeShape::class)]
58#[UsesClass(FreeNames::class)]
59#[UsesClass(ModifiedNames::class)]
60#[UsesClass(ObjectEffects::class)]
61#[UsesClass(CallbackEffects::class)]
62#[UsesClass(BackwardSlicer::class)]
63#[UsesClass(SourceTree::class)]
64#[UsesClass(SliceExecutor::class)]
65#[UsesClass(EvaluationBudget::class)]
66#[UsesClass(Interpreter::class)]
67#[UsesClass(FunctionScope::class)]
68#[UsesClass(Environment::class)]
69#[UsesClass(ObjectMemory::class)]
70#[UsesClass(SourceParser::class)]
71#[UsesClass(ParsedFile::class)]
72#[UsesClass(ProgramIndex::class)]
73#[UsesClass(ProgramIndexBuilder::class)]
74#[UsesClass(BuilderCalls::class)]
75#[UsesClass(QueryState::class)]
76#[UsesClass(LaravelExtension::class)]
77#[UsesClass(Grammar::class)]
78#[UsesClass(Predicates::class)]
79#[UsesClass(\SqlCatalog\Core\Analysis\CallEvaluator::class)]
80#[UsesClass(\SqlCatalog\Core\Analysis\ConstantReader::class)]
81#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CalleeReturns::class)]
82#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CallerIndex::class)]
83#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Callers::class)]
84#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Deriver::class)]
85#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\EntryBinder::class)]
86#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\PropertyWrites::class)]
87#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\AssignmentSteps::class)]
88#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\LoopPasses::class)]
89#[UsesClass(\SqlCatalog\Core\Analysis\ExpressionEvaluator::class)]
90#[UsesClass(\SqlCatalog\Core\Analysis\ExternalInput::class)]
91#[UsesClass(\SqlCatalog\Core\Analysis\ReferenceEvaluator::class)]
92#[UsesClass(\SqlCatalog\Core\Analysis\SinkFinder::class)]
93#[UsesClass(\SqlCatalog\Core\Analysis\SinkMatcher::class)]
94#[UsesClass(\SqlCatalog\Core\Extension\SinkSpec::class)]
95#[UsesClass(\SqlCatalog\Core\Php\DeclaredGlobals::class)]
96#[UsesClass(\SqlCatalog\Core\Php\NodeText::class)]
97#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\Pending::class)]
98#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\SliceStep::class)]
99#[UsesClass(\SqlCatalog\Core\Php\ClassShape::class)]
100#[UsesClass(\SqlCatalog\Core\Php\MethodShape::class)]
101#[UsesClass(\SqlCatalog\Core\Php\ParameterShape::class)]
102#[UsesClass(\SqlCatalog\Core\Php\TypeReader::class)]
103#[UsesClass(\SqlCatalog\Extension\Laravel\BuilderQueries::class)]
104#[UsesClass(\SqlCatalog\Extension\Laravel\CallModel::class)]
105#[UsesClass(\SqlCatalog\Core\Extension\Model\CallContext::class)]
106#[UsesClass(\SqlCatalog\Core\Extension\Model\ModelContext::class)]
107#[UsesClass(\SqlCatalog\Core\Extension\Model\ModelSet::class)]
108#[UsesClass(\SqlCatalog\Core\Analysis\BuiltinCallModel::class)]
109#[UsesClass(\SqlCatalog\Core\Analysis\Effect\ReferenceEffects::class)]
110#[UsesClass(\SqlCatalog\Core\Analysis\Effect\WriteEffects::class)]
111#[UsesClass(\SqlCatalog\Core\Analysis\FunctionModel\Registry::class)]
112final class CallbackModelTest extends TestCase
113{
114    public function testApplyRunsSourceDeclaredLocalScopes(): void
115    {
116
117        $budget = new EvaluationBudget();
118        $names = new FreeNames();
119        $modified = new ModifiedNames($names, new ObjectEffects());
120        $effects = new CallbackEffects(new BackwardSlicer(new SourceTree([]), $budget, $names, $modified), new SliceExecutor(modified: $modified, budget: $budget));
121
122        $file = (new SourceParser())->parse('model.php', '<?php class User extends \\Illuminate\\Database\\Eloquent\\Model { public function scopeActive($q) { return $q->where("active", 1); } }');
123        $index = (new ProgramIndexBuilder())->build([$file]);
124        $model = new CallbackModel($index, $effects, dialects: \SqlCatalog\Facade\Builtins::dialects());
125        $object = (new BuilderCalls($index, dialects: \SqlCatalog\Facade\Builtins::dialects()))->allocate('Illuminate\Database\Eloquent\Builder', new QueryState(['model' => Domain::literal('User'), 'dialect' => Domain::literal('sqlite')]));
126        $call = new \PhpParser\Node\Expr\MethodCall(new \PhpParser\Node\Expr\Variable('q'), 'active');
127        $result = $model->apply($call, $object, 'active', [], new Environment(), new FunctionScope('query.php'), (new Interpreter($index, (new LaravelExtension(\SqlCatalog\Facade\Builtins::dialects()))->sinks(), modelProviders: [new LaravelExtension(\SqlCatalog\Facade\Builtins::dialects())]))->evaluatorFor([$file]))?->soleObject();
128        self::assertNotNull($result);
129        self::assertSame('"active" = ?', QueryState::from($result)->items('where')[0]->soleLiteral()?->value);
130        self::assertNull($model->apply($call, $object, 'missing', [], new Environment(), new FunctionScope('query.php'), (new Interpreter($index, []))->evaluatorFor()));
131    }
132
133    public function testNestedRestoresOuterClausesAndGroupsOnlyNewPredicates(): void
134    {
135
136        $budget = new EvaluationBudget();
137        $names = new FreeNames();
138        $modified = new ModifiedNames($names, new ObjectEffects());
139        $effects = new CallbackEffects(new BackwardSlicer(new SourceTree([]), $budget, $names, $modified), new SliceExecutor(modified: $modified, budget: $budget));
140
141        $callback = new \PhpParser\Node\Expr\ArrowFunction(['params' => [new \PhpParser\Node\Param(new \PhpParser\Node\Expr\Variable('q'))], 'expr' => new \PhpParser\Node\Expr\MethodCall(new \PhpParser\Node\Expr\Variable('q'), 'where', [new \PhpParser\Node\Arg(new \PhpParser\Node\Scalar\String_('id')), new \PhpParser\Node\Arg(new \PhpParser\Node\Scalar\Int_(7))])]);
142        $index = new ProgramIndex();
143        $state = new QueryState(['dialect' => Domain::literal('sqlite'), 'limit' => Domain::literal(10), 'where' => QueryState::list([Domain::literal('active = 1')])]);
144        $object = (new BuilderCalls($index, dialects: \SqlCatalog\Facade\Builtins::dialects()))->allocate(BuilderCalls::QUERY, $state);
145        $result = (new CallbackModel($index, $effects, dialects: \SqlCatalog\Facade\Builtins::dialects()))->nested($callback, $object, 'orwhere', new Environment(), new FunctionScope('query.php'), (new Interpreter($index, (new LaravelExtension(\SqlCatalog\Facade\Builtins::dialects()))->sinks(), modelProviders: [new LaravelExtension(\SqlCatalog\Facade\Builtins::dialects())]))->evaluatorFor())->soleObject();
146        self::assertNotNull($result);
147        $after = QueryState::from($result);
148        self::assertSame('or ("id" = ?)', $after->items('where')[1]->soleLiteral()?->value);
149        self::assertSame(10, $after->get('limit')->soleLiteral()?->value);
150        self::assertSame($object->identity, $result->identity);
151    }
152
153    public function testCheckedPreservesAVisibleGapWhenTheCallbackExceedsItsBudget(): void
154    {
155
156        $budget = new EvaluationBudget();
157        $names = new FreeNames();
158        $modified = new ModifiedNames($names, new ObjectEffects());
159        $effects = new CallbackEffects(new BackwardSlicer(new SourceTree([]), $budget, $names, $modified), new SliceExecutor(modified: $modified, budget: $budget));
160
161        $object = new ObjectTerm(BuilderCalls::QUERY, identity: 'a', state: new ArrayTerm([]));
162        $model = new CallbackModel(new ProgramIndex(), $effects, dialects: \SqlCatalog\Facade\Builtins::dialects());
163        $result = $model->checked(Domain::fromTerms([$object], true), $object)->soleObject();
164        self::assertNotNull($result);
165        self::assertFalse(QueryState::from($result)->get('problem')->isExact());
166        self::assertSame($object, $model->checked(Domain::of($object), $object)->soleObject());
167    }
168
169
170    public function testConditionalKeepsBothOutcomesOfWhenAndUnless(): void
171    {
172
173        $budget = new EvaluationBudget();
174        $names = new FreeNames();
175        $modified = new ModifiedNames($names, new ObjectEffects());
176        $effects = new CallbackEffects(new BackwardSlicer(new SourceTree([]), $budget, $names, $modified), new SliceExecutor(modified: $modified, budget: $budget));
177
178        $file = (new SourceParser())->parse('query.php', '<?php $q->when($x, fn ($q, $v) => $q->where("a", $v)); $q->unless($x, function ($q) { $q->where("b", 1); }, fn ($q) => $q->where("c", 2)); $q->when($x, "not a closure");');
179        $calls = array_values(array_filter((new \PhpParser\NodeFinder())->findInstanceOf($file->statements, \PhpParser\Node\Expr\MethodCall::class), static fn (\PhpParser\Node\Expr\MethodCall $node): bool => $node->name instanceof \PhpParser\Node\Identifier && in_array($node->name->toString(), ['when', 'unless'], true)));
180        $index = new ProgramIndex();
181        $model = new CallbackModel($index, $effects, dialects: \SqlCatalog\Facade\Builtins::dialects());
182        $evaluator = (new Interpreter($index, (new LaravelExtension(\SqlCatalog\Facade\Builtins::dialects()))->sinks(), modelProviders: [new LaravelExtension(\SqlCatalog\Facade\Builtins::dialects())]))->evaluatorFor([$file]);
183        $object = (new BuilderCalls($index, dialects: \SqlCatalog\Facade\Builtins::dialects()))->allocate(BuilderCalls::QUERY, new QueryState(['dialect' => Domain::literal('sqlite')]));
184        $unknown = [Domain::unknown(), Domain::unknown(), Domain::unknown()];
185        $when = $model->apply($calls[0], $object, 'when', array_slice($unknown, 0, 2), new Environment(), new FunctionScope('query.php'), $evaluator);
186        self::assertNotNull($when);
187        self::assertSame(['"a" = ?', null], array_map(static fn (\SqlCatalog\Core\Evaluation\Term $term): mixed => $term instanceof ObjectTerm ? (QueryState::from($term)->items('where')[0] ?? null)?->soleLiteral()?->value : false, $when->terms));
188        $unless = $model->apply($calls[1], $object, 'unless', $unknown, new Environment(), new FunctionScope('query.php'), $evaluator);
189        self::assertNotNull($unless);
190        self::assertSame(['"b" = ?', '"c" = ?'], array_map(static fn (\SqlCatalog\Core\Evaluation\Term $term): mixed => $term instanceof ObjectTerm ? QueryState::from($term)->items('where')[0]->soleLiteral()?->value : false, $unless->terms));
191        $literal = $model->apply($calls[2], $object, 'when', array_slice($unknown, 0, 2), new Environment(), new FunctionScope('query.php'), $evaluator)?->soleObject();
192        self::assertNotNull($literal);
193        self::assertFalse(QueryState::from($literal)->get('problem')->isExact());
194    }
195
196    public function testClosedRejectsWidenedResultsAndForeignIdentities(): void
197    {
198
199        $budget = new EvaluationBudget();
200        $names = new FreeNames();
201        $modified = new ModifiedNames($names, new ObjectEffects());
202        $effects = new CallbackEffects(new BackwardSlicer(new SourceTree([]), $budget, $names, $modified), new SliceExecutor(modified: $modified, budget: $budget));
203
204        $object = new ObjectTerm(BuilderCalls::QUERY, identity: 'a', state: new ArrayTerm([]));
205        $model = new CallbackModel(new ProgramIndex(), $effects, dialects: \SqlCatalog\Facade\Builtins::dialects());
206        self::assertSame([$object], $model->closed(Domain::of($object), $object, 'lost')->terms);
207        $widened = $model->closed(Domain::fromTerms([$object], true), $object, 'lost')->soleObject();
208        $foreign = $model->closed(Domain::of(new ObjectTerm(BuilderCalls::QUERY, identity: 'b')), $object, 'lost')->soleObject();
209        $lost = $model->closed(Domain::unknown(), $object, 'lost')->soleObject();
210        self::assertSame(['a', 'a', 'a'], [$widened?->identity, $foreign?->identity, $lost?->identity]);
211        self::assertNotNull($widened);
212        self::assertNotNull($foreign);
213        self::assertNotNull($lost);
214        self::assertFalse(QueryState::from($widened)->get('problem')->isExact());
215        self::assertFalse(QueryState::from($foreign)->get('problem')->isExact());
216        self::assertFalse(QueryState::from($lost)->get('problem')->isExact());
217    }
218}
219