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