diff --git a/src/Database.php b/src/Database.php index c937361..e4bf77d 100644 --- a/src/Database.php +++ b/src/Database.php @@ -114,6 +114,7 @@ public function executeSql( } try { + PlaceholderValidator::validate($query, $bindings); $statement->execute($bindings); } catch(PDOException $exception) { diff --git a/src/MissingParameterException.php b/src/MissingParameterException.php new file mode 100644 index 0000000..65b257d --- /dev/null +++ b/src/MissingParameterException.php @@ -0,0 +1,4 @@ +|array $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} */ + 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|array $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 $expectedPlaceholders + * @param array|array $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 $expectedPlaceholders + * @param array|array $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|array $bindings */ + private static function bindingsEmptyOrNonAssociative(array $bindings):bool { + return $bindings === [] + || array_keys($bindings) === range( + 0, + count($bindings) - 1 + ); + } +} diff --git a/src/Query/Query.php b/src/Query/Query.php index bfc9769..1aba62b 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -3,6 +3,7 @@ use DateTimeInterface; use GT\Database\Connection\Connection; +use GT\Database\PlaceholderValidator; use GT\Database\Result\ResultSet; use PDO; use PDOException; @@ -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); diff --git a/test/phpunit/DatabaseTest.php b/test/phpunit/DatabaseTest.php index c70e2cf..d0716e5 100644 --- a/test/phpunit/DatabaseTest.php +++ b/test/phpunit/DatabaseTest.php @@ -4,6 +4,7 @@ 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; @@ -11,6 +12,10 @@ 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; @@ -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 = <<db->executeSql( + $sql, + ["id" => 1] + ); + } + + public function testExecuteSqlMissingNamedParametersThrowsHelpfulExceptionMoreBoundParameters():void { + $this->expectException(MissingParameterException::class); + $this->expectExceptionMessage("Too few parameters were bound - missing `name`"); + + $sql = <<db->executeSql( + $sql, + [ + "id" => 1, + "number" => 105, + ] + ); + } + private function settingsSingleton():Settings { if(is_null($this->settings)) { $this->settings = new Settings( diff --git a/test/phpunit/PlaceholderValidatorTest.php b/test/phpunit/PlaceholderValidatorTest.php new file mode 100644 index 0000000..77f3213 --- /dev/null +++ b/test/phpunit/PlaceholderValidatorTest.php @@ -0,0 +1,140 @@ + "one"] + ); + + self::assertTrue(true); + } + + public function testValidateAllowsSequentialBindingsForNamedPlaceholders():void { + PlaceholderValidator::validate( + "select * from test_table where name = :name and created_at > :date", + ["one", "2026-04-15 00:00:00"] + ); + + self::assertTrue(true); + } + + public function testValidateReportsMissingSequentialNamedBindings():void { + $this->expectException(MissingParameterException::class); + $this->expectExceptionMessage( + "Too few parameters were bound - missing `date`, `status`" + ); + + PlaceholderValidator::validate( + "select * from test_table where name = :name and created_at > :date and status = :status", + ["one"] + ); + } + + public function testValidateReportsMissingIndexedBindings():void { + $this->expectException(MissingParameterException::class); + $this->expectExceptionMessage( + "Too few parameters were bound - expected 3, received 1" + ); + + PlaceholderValidator::validate( + "select * from test_table where id = ? and name = ? and created_at > ?", + [1] + ); + } + + public function testValidateAllowsCompleteIndexedBindings():void { + PlaceholderValidator::validate( + "select * from test_table where id = ? and name = ? and created_at > ?", + [1, "one", "2026-04-15 00:00:00"] + ); + + self::assertTrue(true); + } + + public function testValidateReportsMissingAssociativeNamedBindings():void { + $this->expectException(MissingParameterException::class); + $this->expectExceptionMessage( + "Too few parameters were bound - missing `date`, `status`" + ); + + PlaceholderValidator::validate( + "select * from test_table where name = :name and created_at > :date and status = :status", + ["name" => "one"] + ); + } + + public function testValidateIgnoresQuotedStringsCommentsAndCastSyntax():void { + PlaceholderValidator::validate( + implode("\n", [ + "select ':ignored', \":ignoredToo\", `:ignoredThree`, column::text", + "from test_table", + "# :ignoredFour", + "where name = :name -- :ignoredFive", + "and flag = 1 /* :ignoredSix */", + ]), + ["name" => "one"] + ); + + self::assertTrue(true); + } + + public function testValidateIgnoresEscapedQuotedStrings():void { + PlaceholderValidator::validate( + "select 'it''s still a string :ignored', \"say \"\"hi :stillIgnored\"\"\" from test_table where name = :name", + ["name" => "one"] + ); + + self::assertTrue(true); + } + + public function testValidateIgnoresInvalidPlaceholderSyntax():void { + PlaceholderValidator::validate( + 'select 1:2 as ratio, :validPlaceholder as ok, ::text as casted, :$nope as nope', + ["validPlaceholder" => "ok"] + ); + + self::assertTrue(true); + } + + public function testValidateHandlesUnterminatedQuotedString():void { + PlaceholderValidator::validate( + "select ':ignored", + [] + ); + + self::assertTrue(true); + } + + public function testValidateHandlesUnterminatedHashComment():void { + PlaceholderValidator::validate( + "select 1 # :ignored", + [] + ); + + self::assertTrue(true); + } + + public function testValidateHandlesUnterminatedBlockComment():void { + PlaceholderValidator::validate( + "select 1 /* :ignored", + [] + ); + + self::assertTrue(true); + } +} diff --git a/test/phpunit/Query/SqlQueryTest.php b/test/phpunit/Query/SqlQueryTest.php index a2b5885..7cd659d 100644 --- a/test/phpunit/Query/SqlQueryTest.php +++ b/test/phpunit/Query/SqlQueryTest.php @@ -3,6 +3,7 @@ use GT\Database\Connection\Driver; use GT\Database\Connection\Settings; +use GT\Database\MissingParameterException; use GT\Database\Query\PreparedStatementException; use GT\Database\Query\QueryNotFoundException; use GT\Database\Query\SqlQuery; @@ -217,6 +218,68 @@ public function testPlaceholderReplacementSubsequentCalls() { } } + /** @dataProvider \GT\Database\Test\Helper\Helper::queryPathExistsProvider */ + public function testMissingIndexedParametersThrowsHelpfulException( + string $queryName, + string $queryCollectionPath, + string $queryPath + ):void { + file_put_contents( + $queryPath, + "select * from test_table where id = ? and name = ? and timestamp = ?" + ); + $query = new SqlQuery($queryPath, $this->driverSingleton()); + + $this->expectException(MissingParameterException::class); + $this->expectExceptionMessage( + "Too few parameters were bound - expected 3, received 2" + ); + $query->execute([1, "one"]); + } + + /** @dataProvider \GT\Database\Test\Helper\Helper::queryPathExistsProvider */ + public function testMissingNamedParametersThrowsHelpfulException( + string $queryName, + string $queryCollectionPath, + string $queryPath + ):void { + file_put_contents( + $queryPath, + "select * from test_table where name = :name and timestamp > :date" + ); + $query = new SqlQuery($queryPath, $this->driverSingleton()); + + $this->expectException(MissingParameterException::class); + $this->expectExceptionMessage( + "Too few parameters were bound - missing `date`" + ); + $query->execute([ + "name" => "one", + ]); + } + + /** @dataProvider \GT\Database\Test\Helper\Helper::queryPathExistsProvider */ + public function testMissingNamedParametersIgnoreCommentsAndStrings( + string $queryName, + string $queryCollectionPath, + string $queryPath + ):void { + file_put_contents( + $queryPath, + "select ':ignored' as literal from test_table -- :comment\n" + . "where name = :name and timestamp > :date" + ); + $query = new SqlQuery($queryPath, $this->driverSingleton()); + + $this->expectException(MissingParameterException::class); + $this->expectExceptionMessage( + "Too few parameters were bound - missing `date`" + ); + $query->execute([ + "name" => "one", + ]); + } + /** * @dataProvider \GT\Database\Test\Helper\Helper::queryPathNotExistsProvider() */