Skip to content

Commit 622f652

Browse files
committed
feature: explain which parameters are missing
closes #346
1 parent b2f0758 commit 622f652

6 files changed

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

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)