Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ public function executeSql(
}

try {
PlaceholderValidator::validate($query, $bindings);
$statement->execute($bindings);
}
catch(PDOException $exception) {
Expand Down
4 changes: 4 additions & 0 deletions src/MissingParameterException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
namespace GT\Database;

class MissingParameterException extends DatabaseException {}
262 changes: 262 additions & 0 deletions src/PlaceholderValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
<?php
namespace GT\Database;

/**
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*/
class PlaceholderValidator {
/** @param array<string, mixed>|array<mixed> $bindings */
public static function validate(string $sql, array $bindings):void {
$placeholderData = self::parsePlaceholders($sql);
self::validateIndexedBindings(
$placeholderData["indexedCount"],
$bindings
);
self::validateNamedBindings(
$placeholderData["namedPlaceholders"],
$bindings
);
}

/** @return array{indexedCount:int, namedPlaceholders:array<string>} */
private static function parsePlaceholders(string $sql):array {
$indexedCount = 0;
$namedPlaceholders = [];
$length = strlen($sql);

for($i = 0; $i < $length; $i++) {
$character = $sql[$i];

if($character === "'"
|| $character === '"'
|| $character === "`") {
$i = self::skipQuotedString($sql, $i, $character);
continue;
}

if($character === "#") {
$i = self::skipLineComment($sql, $i);
continue;
}

if($character === "-"
&& $i + 1 < $length
&& $sql[$i + 1] === "-") {
$i = self::skipLineComment($sql, $i);
continue;
}

if($character === "/"
&& $i + 1 < $length
&& $sql[$i + 1] === "*") {
$i = self::skipBlockComment($sql, $i);
continue;
}

if($character === "?") {
$indexedCount++;
continue;
}

if($character === ":") {
if(($i > 0 && $sql[$i - 1] === ":")
|| $i + 1 >= $length
|| $sql[$i + 1] === ":") {
continue;
}

$placeholderName = self::parseNamedPlaceholder($sql, $i + 1);
if($placeholderName === null) {
continue;
}

$namedPlaceholders[] = $placeholderName;
$i += strlen($placeholderName);
}
}

return [
"indexedCount" => $indexedCount,
"namedPlaceholders" => array_values(array_unique($namedPlaceholders)),
];
}

private static function skipQuotedString(
string $sql,
int $offset,
string $quoteCharacter
):int {
$length = strlen($sql);

for($i = $offset + 1; $i < $length; $i++) {
if($sql[$i] !== $quoteCharacter) {
continue;
}

if($quoteCharacter !== "`"
&& $i + 1 < $length
&& $sql[$i + 1] === $quoteCharacter) {
$i++;
continue;
}

return $i;
}

return $length - 1;
}

private static function skipLineComment(string $sql, int $offset):int {
$length = strlen($sql);

for($i = $offset + 1; $i < $length; $i++) {
if($sql[$i] === "\n") {
return $i;
}
}

return $length - 1;
}

private static function skipBlockComment(string $sql, int $offset):int {
$length = strlen($sql);

for($i = $offset + 2; $i < $length; $i++) {
if($sql[$i] === "*"
&& $i + 1 < $length
&& $sql[$i + 1] === "/") {
return $i + 1;
}
}

return $length - 1;
}

private static function parseNamedPlaceholder(
string $sql,
int $offset
):?string {
$length = strlen($sql);
$placeholderName = "";

if($offset >= $length
|| !preg_match('/[a-zA-Z_]/', $sql[$offset])) {
return null;
}

for($i = $offset; $i < $length; $i++) {
if(!preg_match('/[a-zA-Z0-9_]/', $sql[$i])) {
break;
}

$placeholderName .= $sql[$i];
}

return $placeholderName;
}

/** @param array<string, mixed>|array<mixed> $bindings */
private static function validateIndexedBindings(
int $expectedCount,
array $bindings
):void {
if($expectedCount === 0) {
return;
}

$receivedCount = count($bindings);
if($receivedCount >= $expectedCount) {
return;
}

throw new MissingParameterException(
"Too few parameters were bound - expected "
. $expectedCount
. ", received "
. $receivedCount
);
}

/**
* @param array<string> $expectedPlaceholders
* @param array<string, mixed>|array<mixed> $bindings
*/
private static function validateNamedBindings(
array $expectedPlaceholders,
array $bindings
):void {
if($expectedPlaceholders === []) {
return;
}

if(self::bindingsEmptyOrNonAssociative($bindings)) {
self::validateSequentialBindingsAgainstNamedPlaceholders(
$expectedPlaceholders,
$bindings
);
return;
}

$bindingMap = [];
foreach(array_keys($bindings) as $key) {
$bindingMap[ltrim((string)$key, ":")] = true;
}

$missingPlaceholders = [];
foreach($expectedPlaceholders as $placeholderName) {
if(isset($bindingMap[$placeholderName])) {
continue;
}

$missingPlaceholders[] = "`$placeholderName`";
}

if($missingPlaceholders === []) {
return;
}

throw new MissingParameterException(
"Too few parameters were bound - missing "
. implode(", ", $missingPlaceholders)
);
}

/**
* @param array<string> $expectedPlaceholders
* @param array<string, mixed>|array<mixed> $bindings
*/
private static function validateSequentialBindingsAgainstNamedPlaceholders(
array $expectedPlaceholders,
array $bindings
):void {
$receivedCount = count($bindings);
$expectedCount = count($expectedPlaceholders);

if($receivedCount >= $expectedCount) {
return;
}

$missingPlaceholders = array_slice(
$expectedPlaceholders,
$receivedCount
);
$missingPlaceholders = array_map(
fn(string $placeholderName):string => "`$placeholderName`",
$missingPlaceholders
);

throw new MissingParameterException(
"Too few parameters were bound - missing "
. implode(", ", $missingPlaceholders)
);
}

/** @param array<string, mixed>|array<mixed> $bindings */
private static function bindingsEmptyOrNonAssociative(array $bindings):bool {
return $bindings === []
|| array_keys($bindings) === range(
0,
count($bindings) - 1
);
}
}
2 changes: 2 additions & 0 deletions src/Query/Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

use DateTimeInterface;
use GT\Database\Connection\Connection;
use GT\Database\PlaceholderValidator;
use GT\Database\Result\ResultSet;
use PDO;
use PDOException;
Expand Down Expand Up @@ -64,6 +65,7 @@ public function execute(array $bindings = []):ResultSet {
$preparedBindings = $this->prepareBindings($bindings);
$preparedBindings = $this->ensureParameterCharacter($preparedBindings);
$preparedBindings = $this->removeUnusedBindings($preparedBindings, $sql);
PlaceholderValidator::validate($sql, $preparedBindings);

try {
$statement->execute($preparedBindings);
Expand Down
58 changes: 58 additions & 0 deletions test/phpunit/DatabaseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@
use Exception;
use GT\Database\Connection\Settings;
use GT\Database\Database;
use GT\Database\MissingParameterException;
use GT\Database\Query\QueryCollection;
use GT\Database\Query\QueryCollectionClass;
use GT\Database\Query\QueryCollectionNotFoundException;
use GT\Database\Query\QueryOverrideConflictException;
use GT\Database\Test\Helper\Helper;
use PHPUnit\Framework\TestCase;

/**
* @noinspection SqlNoDataSourceInspection
* @noinspection SqlResolve
*/
class DatabaseTest extends TestCase {
private ?Settings $settings = null;
private string $queryBase;
Expand Down Expand Up @@ -305,6 +310,59 @@ public function groupedParity():SelectBuilder {
self::assertSame(2, $row1->getInt("number_sum"));
}

public function testExecuteSqlMissingNamedParametersThrowsHelpfulException():void {
$this->expectException(MissingParameterException::class);
$this->expectExceptionMessage("Too few parameters were bound - missing `name`, `number`");

$sql = <<<SQL
select
id,
name,
number
from
test_table
where
id = :id
and
name = :name
and
number = :number
SQL;

$this->db->executeSql(
$sql,
["id" => 1]
);
}

public function testExecuteSqlMissingNamedParametersThrowsHelpfulExceptionMoreBoundParameters():void {
$this->expectException(MissingParameterException::class);
$this->expectExceptionMessage("Too few parameters were bound - missing `name`");

$sql = <<<SQL
select
id,
name,
number
from
test_table
where
id = :id
and
name = :name
and
number = :number
SQL;

$this->db->executeSql(
$sql,
[
"id" => 1,
"number" => 105,
]
);
}

private function settingsSingleton():Settings {
if(is_null($this->settings)) {
$this->settings = new Settings(
Expand Down
Loading
Loading