packages/sql-catalog/tests/Unit/Core/Analysis/ValueBinderTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Core\Analysis;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use SqlCatalog\Core\Analysis\QueryRecord;
11use SqlCatalog\Core\Analysis\StatementRecorder;
12use SqlCatalog\Core\Analysis\ValueBinder;
13use SqlCatalog\Core\Catalog\CallSite;
14use SqlCatalog\Core\Evaluation\ArrayEntry;
15use SqlCatalog\Core\Evaluation\ArrayTerm;
16use SqlCatalog\Core\Evaluation\Domain;
17use SqlCatalog\Core\Evaluation\LiteralTerm;
18use SqlCatalog\Core\Evaluation\ObjectTerm;
19use SqlCatalog\Core\Evaluation\OpaqueTerm;
20use SqlCatalog\Core\Extension\SinkCallKind;
21use SqlCatalog\Core\Extension\SinkRole;
22use SqlCatalog\Core\Extension\SinkSpec;
23use SqlCatalog\Core\Text\LiteralText;
24use SqlCatalog\Core\Text\TextHole;
25use SqlCatalog\Core\Text\TextPattern;
26use SqlCatalog\Core\Type\TypeShape;
27
28#[CoversClass(ValueBinder::class)]
29#[UsesClass(QueryRecord::class)]
30#[UsesClass(StatementRecorder::class)]
31#[UsesClass(CallSite::class)]
32#[UsesClass(ArrayEntry::class)]
33#[UsesClass(ArrayTerm::class)]
34#[UsesClass(Domain::class)]
35#[UsesClass(LiteralTerm::class)]
36#[UsesClass(ObjectTerm::class)]
37#[UsesClass(OpaqueTerm::class)]
38#[UsesClass(SinkSpec::class)]
39#[UsesClass(LiteralText::class)]
40#[UsesClass(TextHole::class)]
41#[UsesClass(TextPattern::class)]
42#[UsesClass(TypeShape::class)]
43final class ValueBinderTest extends TestCase
44{
45    public function testOpenRecordsFindsTheStatementsAHandleStandsFor(): void
46    {
47        $recorder = new StatementRecorder();
48        $record = $recorder->record(new CallSite('a.php', 1, 'f', 'pdo.prepare'), 'h', TextPattern::fromText('SELECT ?'));
49        $recorder->filePrepared('h', [$record]);
50
51        $binder = new ValueBinder($recorder);
52        self::assertSame([$record], $binder->openRecords(Domain::of(new ObjectTerm('PDOStatement', null, 'h'))));
53    }
54
55    public function testOpenRecordsIsEmptyForAReceiverWithoutAHandle(): void
56    {
57        self::assertSame([], (new ValueBinder(new StatementRecorder()))->openRecords(Domain::literal('a')));
58    }
59
60    public function testBindValuesReachesEveryStatement(): void
61    {
62        $site = new CallSite('a.php', 1, 'f', 'pdo.prepare');
63        $first = new QueryRecord($site, 'h', TextPattern::fromText('SELECT ?'));
64        $second = new QueryRecord($site, 'h', TextPattern::fromText('SELECT ?, ?'));
65        $sink = new SinkSpec('s', SinkCallKind::Method, 'PDOStatement', 'execute', SinkRole::Execute, valuesParameter: 0);
66        $values = Domain::of(new ArrayTerm([new ArrayEntry(null, Domain::literal(7))]));
67
68        (new ValueBinder(new StatementRecorder()))->bindValues([$first, $second], $sink, [$values]);
69
70        self::assertTrue($first->isBound());
71        self::assertTrue($second->isBound());
72    }
73
74    public function testBindOneRecordReadsAnArrayOfValues(): void
75    {
76        $record = new QueryRecord(new CallSite('a.php', 1, 'f', 's'), 'h', TextPattern::fromText('SELECT ?'));
77        $sink = new SinkSpec('s', SinkCallKind::Method, 'PDOStatement', 'execute', SinkRole::Execute, valuesParameter: 0);
78        $values = Domain::of(new ArrayTerm([
79            new ArrayEntry(null, Domain::literal(7)),
80            new ArrayEntry(Domain::literal(':id'), Domain::literal(9)),
81        ]));
82
83        (new ValueBinder(new StatementRecorder()))->bindOneRecord($record, $sink, [$values]);
84
85        self::assertSame(7, $record->positional()[0]->soleLiteral()?->value);
86        self::assertSame(9, $record->named()['id']->soleLiteral()?->value);
87    }
88
89    public function testBindOneRecordReadsVariadicValues(): void
90    {
91        $record = new QueryRecord(new CallSite('a.php', 1, 'f', 's'), 'h', TextPattern::fromText('SELECT ?'));
92        $sink = new SinkSpec('s', SinkCallKind::Method, 'mysqli_stmt', 'bind_param', SinkRole::Bind, valuesFrom: 1);
93
94        (new ValueBinder(new StatementRecorder()))->bindOneRecord($record, $sink, [Domain::literal('s'), Domain::literal('a')]);
95
96        self::assertSame('a', $record->positional()[0]->soleLiteral()?->value);
97    }
98
99    public function testBindOneRecordReadsOneNamedValue(): void
100    {
101        $record = new QueryRecord(new CallSite('a.php', 1, 'f', 's'), 'h', TextPattern::fromText('SELECT :id'));
102        $sink = new SinkSpec('s', SinkCallKind::Method, 'PDOStatement', 'bindValue', SinkRole::Bind, nameParameter: 0, valueParameter: 1);
103
104        (new ValueBinder(new StatementRecorder()))->bindOneRecord($record, $sink, [Domain::literal(':id'), Domain::literal(9)]);
105
106        self::assertSame(9, $record->named()['id']->soleLiteral()?->value);
107    }
108
109    public function testBindOneRecordDoesNothingWhenTheCallBindsNothing(): void
110    {
111        $record = new QueryRecord(new CallSite('a.php', 1, 'f', 's'), 'h', TextPattern::fromText('SELECT 1'));
112        $sink = new SinkSpec('s', SinkCallKind::Method, 'PDO', 'query', SinkRole::Query, sqlParameter: 0);
113
114        (new ValueBinder(new StatementRecorder()))->bindOneRecord($record, $sink, [Domain::literal('SELECT 1')]);
115
116        self::assertFalse($record->isBound());
117    }
118
119    public function testBindOneRecordIgnoresAValuesArgumentThatDidNotResolve(): void
120    {
121        $record = new QueryRecord(new CallSite('a.php', 1, 'f', 's'), 'h', TextPattern::fromText('SELECT ?'));
122        $sink = new SinkSpec('s', SinkCallKind::Method, 'PDOStatement', 'execute', SinkRole::Execute, valuesParameter: 0);
123
124        (new ValueBinder(new StatementRecorder()))->bindOneRecord($record, $sink, [Domain::unknown()]);
125
126        self::assertFalse($record->isBound());
127    }
128
129    public function testBindOneRecordLeavesAnArrayOfValuesThatDidNotResolveUnboundEvenWhenValuesCouldBeListed(): void
130    {
131        $record = new QueryRecord(new CallSite('a.php', 1, 'f', 's'), 'h', TextPattern::fromText('SELECT ?'));
132        $sink = new SinkSpec('s', SinkCallKind::Method, 'Db', 'run', SinkRole::Execute, valuesParameter: 0, valuesFrom: 1);
133
134        (new ValueBinder(new StatementRecorder()))->bindOneRecord($record, $sink, [Domain::unknown(), Domain::literal('a')]);
135
136        self::assertFalse($record->isBound());
137    }
138
139    public function testBindOneRecordReadsVariadicValuesWithoutAlsoReadingOneNamedValue(): void
140    {
141        $record = new QueryRecord(new CallSite('a.php', 1, 'f', 's'), 'h', TextPattern::fromText('SELECT ?'));
142        $sink = new SinkSpec('s', SinkCallKind::Method, 'Db', 'run', SinkRole::Bind, valuesFrom: 1, nameParameter: 0, valueParameter: 1);
143
144        (new ValueBinder(new StatementRecorder()))->bindOneRecord($record, $sink, [Domain::literal(':id'), Domain::literal('a')]);
145
146        self::assertSame('a', $record->positional()[0]->soleLiteral()?->value);
147        self::assertSame([], $record->named());
148    }
149
150    public function testBindOneRecordNeedsBothTheNameAndTheValueOfASingleBind(): void
151    {
152        $record = new QueryRecord(new CallSite('a.php', 1, 'f', 's'), 'h', TextPattern::fromText('SELECT :id'));
153        $nameOnly = new SinkSpec('s', SinkCallKind::Method, 'Db', 'name', SinkRole::Bind, nameParameter: 0);
154        $valueOnly = new SinkSpec('s', SinkCallKind::Method, 'Db', 'value', SinkRole::Bind, valueParameter: 1);
155        $binder = new ValueBinder(new StatementRecorder());
156
157        $binder->bindOneRecord($record, $nameOnly, [Domain::literal(':id'), Domain::literal(9)]);
158        $binder->bindOneRecord($record, $valueOnly, [Domain::literal(':id'), Domain::literal(9)]);
159
160        self::assertFalse($record->isBound());
161    }
162
163    public function testBindOneRecordAppendsAValueWhoseNameDidNotResolve(): void
164    {
165        $record = new QueryRecord(new CallSite('a.php', 1, 'f', 's'), 'h', TextPattern::fromText('SELECT ?'));
166        $sink = new SinkSpec('s', SinkCallKind::Method, 'PDOStatement', 'bindValue', SinkRole::Bind, nameParameter: 0, valueParameter: 1);
167
168        (new ValueBinder(new StatementRecorder()))->bindOneRecord($record, $sink, [Domain::unknown(), Domain::literal(9)]);
169
170        self::assertSame(9, $record->positional()[0]->soleLiteral()?->value);
171        self::assertSame([], $record->named());
172    }
173
174    public function testBindingKeyReadsOnlyScalarKeys(): void
175    {
176        $binder = new ValueBinder(new StatementRecorder());
177        self::assertSame(1, $binder->bindingKey(1));
178        self::assertSame(':id', $binder->bindingKey(':id'));
179        self::assertNull($binder->bindingKey(true));
180        self::assertNull($binder->bindingKey(null));
181    }
182}
183