Skip to content

Commit b2f3ec3

Browse files
authored
Explain which parameters are missing (#399)
* feature: explain which parameters are missing closes #346 * test: improve coverage * test: improve coverage
1 parent b2f0758 commit b2f3ec3

7 files changed

Lines changed: 530 additions & 0 deletions

File tree

src/Database.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ public function executeSql(
114114
}
115115

116116
try {
117+
PlaceholderValidator::validate($query, $bindings);
117118
$statement->execute($bindings);
118119
}
119120
catch(PDOException $exception) {

src/MissingParameterException.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?php
2+
namespace GT\Database;
3+
4+
class MissingParameterException extends DatabaseException {}

src/PlaceholderValidator.php

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
<?php
2+
namespace GT\Database;
3+
4+
/**
5+
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
6+
*/
7+
class PlaceholderValidator {
8+
/** @param array<string, mixed>|array<mixed> $bindings */
9+
public static function validate(string $sql, array $bindings):void {
10+
$placeholderData = self::parsePlaceholders($sql);
11+
self::validateIndexedBindings(
12+
$placeholderData["indexedCount"],
13+
$bindings
14+
);
15+
self::validateNamedBindings(
16+
$placeholderData["namedPlaceholders"],
17+
$bindings
18+
);
19+
}
20+
21+
/** @return array{indexedCount:int, namedPlaceholders:array<string>} */
22+
private static function parsePlaceholders(string $sql):array {
23+
$indexedCount = 0;
24+
$namedPlaceholders = [];
25+
$length = strlen($sql);
26+
27+
for($i = 0; $i < $length; $i++) {
28+
$character = $sql[$i];
29+
30+
if($character === "'"
31+
|| $character === '"'
32+
|| $character === "`") {
33+
$i = self::skipQuotedString($sql, $i, $character);
34+
continue;
35+
}
36+
37+
if($character === "#") {
38+
$i = self::skipLineComment($sql, $i);
39+
continue;
40+
}
41+
42+
if($character === "-"
43+
&& $i + 1 < $length
44+
&& $sql[$i + 1] === "-") {
45+
$i = self::skipLineComment($sql, $i);
46+
continue;
47+
}
48+
49+
if($character === "/"
50+
&& $i + 1 < $length
51+
&& $sql[$i + 1] === "*") {
52+
$i = self::skipBlockComment($sql, $i);
53+
continue;
54+
}
55+
56+
if($character === "?") {
57+
$indexedCount++;
58+
continue;
59+
}
60+
61+
if($character === ":") {
62+
if(($i > 0 && $sql[$i - 1] === ":")
63+
|| $i + 1 >= $length
64+
|| $sql[$i + 1] === ":") {
65+
continue;
66+
}
67+
68+
$placeholderName = self::parseNamedPlaceholder($sql, $i + 1);
69+
if($placeholderName === null) {
70+
continue;
71+
}
72+
73+
$namedPlaceholders[] = $placeholderName;
74+
$i += strlen($placeholderName);
75+
}
76+
}
77+
78+
return [
79+
"indexedCount" => $indexedCount,
80+
"namedPlaceholders" => array_values(array_unique($namedPlaceholders)),
81+
];
82+
}
83+
84+
private static function skipQuotedString(
85+
string $sql,
86+
int $offset,
87+
string $quoteCharacter
88+
):int {
89+
$length = strlen($sql);
90+
91+
for($i = $offset + 1; $i < $length; $i++) {
92+
if($sql[$i] !== $quoteCharacter) {
93+
continue;
94+
}
95+
96+
if($quoteCharacter !== "`"
97+
&& $i + 1 < $length
98+
&& $sql[$i + 1] === $quoteCharacter) {
99+
$i++;
100+
continue;
101+
}
102+
103+
return $i;
104+
}
105+
106+
return $length - 1;
107+
}
108+
109+
private static function skipLineComment(string $sql, int $offset):int {
110+
$length = strlen($sql);
111+
112+
for($i = $offset + 1; $i < $length; $i++) {
113+
if($sql[$i] === "\n") {
114+
return $i;
115+
}
116+
}
117+
118+
return $length - 1;
119+
}
120+
121+
private static function skipBlockComment(string $sql, int $offset):int {
122+
$length = strlen($sql);
123+
124+
for($i = $offset + 2; $i < $length; $i++) {
125+
if($sql[$i] === "*"
126+
&& $i + 1 < $length
127+
&& $sql[$i + 1] === "/") {
128+
return $i + 1;
129+
}
130+
}
131+
132+
return $length - 1;
133+
}
134+
135+
private static function parseNamedPlaceholder(
136+
string $sql,
137+
int $offset
138+
):?string {
139+
$length = strlen($sql);
140+
$placeholderName = "";
141+
142+
if($offset >= $length
143+
|| !preg_match('/[a-zA-Z_]/', $sql[$offset])) {
144+
return null;
145+
}
146+
147+
for($i = $offset; $i < $length; $i++) {
148+
if(!preg_match('/[a-zA-Z0-9_]/', $sql[$i])) {
149+
break;
150+
}
151+
152+
$placeholderName .= $sql[$i];
153+
}
154+
155+
return $placeholderName;
156+
}
157+
158+
/** @param array<string, mixed>|array<mixed> $bindings */
159+
private static function validateIndexedBindings(
160+
int $expectedCount,
161+
array $bindings
162+
):void {
163+
if($expectedCount === 0) {
164+
return;
165+
}
166+
167+
$receivedCount = count($bindings);
168+
if($receivedCount >= $expectedCount) {
169+
return;
170+
}
171+
172+
throw new MissingParameterException(
173+
"Too few parameters were bound - expected "
174+
. $expectedCount
175+
. ", received "
176+
. $receivedCount
177+
);
178+
}
179+
180+
/**
181+
* @param array<string> $expectedPlaceholders
182+
* @param array<string, mixed>|array<mixed> $bindings
183+
*/
184+
private static function validateNamedBindings(
185+
array $expectedPlaceholders,
186+
array $bindings
187+
):void {
188+
if($expectedPlaceholders === []) {
189+
return;
190+
}
191+
192+
if(self::bindingsEmptyOrNonAssociative($bindings)) {
193+
self::validateSequentialBindingsAgainstNamedPlaceholders(
194+
$expectedPlaceholders,
195+
$bindings
196+
);
197+
return;
198+
}
199+
200+
$bindingMap = [];
201+
foreach(array_keys($bindings) as $key) {
202+
$bindingMap[ltrim((string)$key, ":")] = true;
203+
}
204+
205+
$missingPlaceholders = [];
206+
foreach($expectedPlaceholders as $placeholderName) {
207+
if(isset($bindingMap[$placeholderName])) {
208+
continue;
209+
}
210+
211+
$missingPlaceholders[] = "`$placeholderName`";
212+
}
213+
214+
if($missingPlaceholders === []) {
215+
return;
216+
}
217+
218+
throw new MissingParameterException(
219+
"Too few parameters were bound - missing "
220+
. implode(", ", $missingPlaceholders)
221+
);
222+
}
223+
224+
/**
225+
* @param array<string> $expectedPlaceholders
226+
* @param array<string, mixed>|array<mixed> $bindings
227+
*/
228+
private static function validateSequentialBindingsAgainstNamedPlaceholders(
229+
array $expectedPlaceholders,
230+
array $bindings
231+
):void {
232+
$receivedCount = count($bindings);
233+
$expectedCount = count($expectedPlaceholders);
234+
235+
if($receivedCount >= $expectedCount) {
236+
return;
237+
}
238+
239+
$missingPlaceholders = array_slice(
240+
$expectedPlaceholders,
241+
$receivedCount
242+
);
243+
$missingPlaceholders = array_map(
244+
fn(string $placeholderName):string => "`$placeholderName`",
245+
$missingPlaceholders
246+
);
247+
248+
throw new MissingParameterException(
249+
"Too few parameters were bound - missing "
250+
. implode(", ", $missingPlaceholders)
251+
);
252+
}
253+
254+
/** @param array<string, mixed>|array<mixed> $bindings */
255+
private static function bindingsEmptyOrNonAssociative(array $bindings):bool {
256+
return $bindings === []
257+
|| array_keys($bindings) === range(
258+
0,
259+
count($bindings) - 1
260+
);
261+
}
262+
}

src/Query/Query.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
use DateTimeInterface;
55
use GT\Database\Connection\Connection;
6+
use GT\Database\PlaceholderValidator;
67
use GT\Database\Result\ResultSet;
78
use PDO;
89
use PDOException;
@@ -64,6 +65,7 @@ public function execute(array $bindings = []):ResultSet {
6465
$preparedBindings = $this->prepareBindings($bindings);
6566
$preparedBindings = $this->ensureParameterCharacter($preparedBindings);
6667
$preparedBindings = $this->removeUnusedBindings($preparedBindings, $sql);
68+
PlaceholderValidator::validate($sql, $preparedBindings);
6769

6870
try {
6971
$statement->execute($preparedBindings);

test/phpunit/DatabaseTest.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@
44
use Exception;
55
use GT\Database\Connection\Settings;
66
use GT\Database\Database;
7+
use GT\Database\MissingParameterException;
78
use GT\Database\Query\QueryCollection;
89
use GT\Database\Query\QueryCollectionClass;
910
use GT\Database\Query\QueryCollectionNotFoundException;
1011
use GT\Database\Query\QueryOverrideConflictException;
1112
use GT\Database\Test\Helper\Helper;
1213
use PHPUnit\Framework\TestCase;
1314

15+
/**
16+
* @noinspection SqlNoDataSourceInspection
17+
* @noinspection SqlResolve
18+
*/
1419
class DatabaseTest extends TestCase {
1520
private ?Settings $settings = null;
1621
private string $queryBase;
@@ -305,6 +310,59 @@ public function groupedParity():SelectBuilder {
305310
self::assertSame(2, $row1->getInt("number_sum"));
306311
}
307312

313+
public function testExecuteSqlMissingNamedParametersThrowsHelpfulException():void {
314+
$this->expectException(MissingParameterException::class);
315+
$this->expectExceptionMessage("Too few parameters were bound - missing `name`, `number`");
316+
317+
$sql = <<<SQL
318+
select
319+
id,
320+
name,
321+
number
322+
from
323+
test_table
324+
where
325+
id = :id
326+
and
327+
name = :name
328+
and
329+
number = :number
330+
SQL;
331+
332+
$this->db->executeSql(
333+
$sql,
334+
["id" => 1]
335+
);
336+
}
337+
338+
public function testExecuteSqlMissingNamedParametersThrowsHelpfulExceptionMoreBoundParameters():void {
339+
$this->expectException(MissingParameterException::class);
340+
$this->expectExceptionMessage("Too few parameters were bound - missing `name`");
341+
342+
$sql = <<<SQL
343+
select
344+
id,
345+
name,
346+
number
347+
from
348+
test_table
349+
where
350+
id = :id
351+
and
352+
name = :name
353+
and
354+
number = :number
355+
SQL;
356+
357+
$this->db->executeSql(
358+
$sql,
359+
[
360+
"id" => 1,
361+
"number" => 105,
362+
]
363+
);
364+
}
365+
308366
private function settingsSingleton():Settings {
309367
if(is_null($this->settings)) {
310368
$this->settings = new Settings(

0 commit comments

Comments
 (0)