From c782152f55238e07a6255b3f83e87ca641b4b392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D1=85=D1=82=D0=B8=D1=8F=D1=80=20=D0=9A=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=BC=D0=BE=D0=B2?= Date: Fri, 18 Sep 2026 14:24:08 +0600 Subject: [PATCH] fix: replace named bind standing at the end of query Pg::_replaceBind() required a trailing non-word character after the placeholder, so a bind standing at the very end of the query was never matched and was sent to PostgreSQL as is, failing with a syntax error at or near ":". Matching the end of the subject as an alternative to that character is enough: :id is still not replaced inside :idAccount, but a placeholder closing the query is replaced now, and so is a bind used twice whose second occurrence closes the query. Co-Authored-By: Claude Opus 5 --- src/Pg.php | 8 +++++++- tests/Pg/PgBindTest.php | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/Pg.php b/src/Pg.php index e8d7aff..7c0f5a6 100644 --- a/src/Pg.php +++ b/src/Pg.php @@ -460,6 +460,12 @@ protected function replaceBind(string &$preparedQuery, Bind $bind): void } /** + * Replaces named placeholder with its value. + * + * Trailing (\W|$) keeps :id from being replaced inside :idAccount, but, + * unlike a mandatory (\W), matches a placeholder standing at the very end + * of the query as well. + * * @param $name * @param $value * @param $subject @@ -467,7 +473,7 @@ protected function replaceBind(string &$preparedQuery, Bind $bind): void */ private function _replaceBind($name, $value, $subject) { - return preg_replace('~' . $name . '(::\w+)?(\W)~', sprintf("%s$1$2", $value), $subject); + return preg_replace('~' . $name . '(::\w+)?(\W|$)~', sprintf("%s$1$2", $value), $subject); } /** diff --git a/tests/Pg/PgBindTest.php b/tests/Pg/PgBindTest.php index 5eee331..1aa5168 100644 --- a/tests/Pg/PgBindTest.php +++ b/tests/Pg/PgBindTest.php @@ -98,4 +98,43 @@ public function testBind() self::assertSame('1.00011122', $row['sixes']); self::assertSame('{foo,bar,false,NULL}', $row['array_of_text']); } + + /** + * Placeholder standing at the very end of the query must be replaced too. + * + * @throws DBDException + * @throws Exception + */ + public function testBindAtTheEndOfQuery() + { + $sth = $this->db->prepare("SELECT :int AS num WHERE 'some string' = :string"); + $sth->bind(':int', 1, NumericPrimitives::Int16) + ->bind(':string', 'some string'); + + $sth->execute(); + $row = $sth->fetchRow(); + + self::assertIsArray($row); + self::assertEquals(1, $row['num']); + } + + /** + * Shorter bind name must not be replaced inside a longer one. + * + * @throws DBDException + * @throws Exception + */ + public function testBindNameIsPrefixOfAnother() + { + $sth = $this->db->prepare("SELECT :id AS first, :idAccount AS second"); + $sth->bind(':id', 'one') + ->bind(':idAccount', 'two'); + + $sth->execute(); + $row = $sth->fetchRow(); + + self::assertIsArray($row); + self::assertSame('one', $row['first']); + self::assertSame('two', $row['second']); + } }