diff --git a/packages/database-pgsql/src/Connection/PgSqlConnection.php b/packages/database-pgsql/src/Connection/PgSqlConnection.php index 2edb610d..5395527d 100644 --- a/packages/database-pgsql/src/Connection/PgSqlConnection.php +++ b/packages/database-pgsql/src/Connection/PgSqlConnection.php @@ -4,6 +4,7 @@ namespace Marko\Database\PgSql\Connection; +use JsonException; use Marko\Database\Config\DatabaseConfig; use Marko\Database\Connection\ConnectionInterface; use Marko\Database\Connection\StatementInterface; @@ -180,6 +181,8 @@ public function prepare( /** * @param array $bindings + * + * @throws ConnectionException */ private function bindValues( PDOStatement $statement, @@ -187,6 +190,19 @@ private function bindValues( ): void { foreach ($bindings as $key => $value) { $param = is_int($key) ? $key + 1 : $key; + + if (is_array($value)) { + try { + $encoded = json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + } catch (JsonException $e) { + throw ConnectionException::invalidArrayBinding($param, $e); + } + + $statement->bindValue($param, $encoded, PDO::PARAM_STR); + + continue; + } + $type = match (true) { is_bool($value) => PDO::PARAM_BOOL, is_null($value) => PDO::PARAM_NULL, diff --git a/packages/database-pgsql/src/Exceptions/ConnectionException.php b/packages/database-pgsql/src/Exceptions/ConnectionException.php index 94d7ab2d..8b025127 100644 --- a/packages/database-pgsql/src/Exceptions/ConnectionException.php +++ b/packages/database-pgsql/src/Exceptions/ConnectionException.php @@ -4,6 +4,7 @@ namespace Marko\Database\PgSql\Exceptions; +use JsonException; use Marko\Core\Exceptions\MarkoException; use PDOException; @@ -25,4 +26,16 @@ public static function connectionFailed( previous: $previous, ); } + + public static function invalidArrayBinding( + int|string $parameter, + JsonException $previous, + ): self { + return new self( + message: "Failed to JSON-encode array bound to parameter '$parameter'", + context: $previous->getMessage(), + suggestion: 'Ensure array values are JSON-encodable (no resources, recursive references, NAN, or INF)', + previous: $previous, + ); + } } diff --git a/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php b/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php index 3ab8c1cd..9316a6d8 100644 --- a/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php +++ b/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php @@ -685,4 +685,56 @@ protected function createPdo( expect(fn () => $connection->beginTransaction()) ->toThrow(TransactionException::class, 'Nested transactions are not supported'); }); + + it('JSON-encodes array bindings instead of casting them to the string "Array"', function (): void { + $config = createTestPgSqlConfig(); + $connection = new class ($config) extends PgSqlConnection + { + protected function createPdo( + string $dsn, + string $username, + string $password, + array $options, + ): PDO { + $pdo = createSqliteMockPdo($options); + $pdo->exec('CREATE TABLE items (id INTEGER PRIMARY KEY, metadata TEXT)'); + + return $pdo; + } + }; + + $connection->execute( + 'INSERT INTO items (metadata) VALUES (?)', + [['key' => 'value', 'nested' => [1, 2, 3]]], + ); + + $rows = $connection->query('SELECT metadata FROM items'); + + expect($rows[0]['metadata']) + ->toBe('{"key":"value","nested":[1,2,3]}') + ->not->toBe('Array'); + }); + + it('throws ConnectionException when an array binding is not JSON-encodable', function (): void { + $config = createTestPgSqlConfig(); + $connection = new class ($config) extends PgSqlConnection + { + protected function createPdo( + string $dsn, + string $username, + string $password, + array $options, + ): PDO { + $pdo = createSqliteMockPdo($options); + $pdo->exec('CREATE TABLE items (id INTEGER PRIMARY KEY, metadata TEXT)'); + + return $pdo; + } + }; + + expect(fn () => $connection->execute( + 'INSERT INTO items (metadata) VALUES (?)', + [[NAN]], + ))->toThrow(ConnectionException::class, "Failed to JSON-encode array bound to parameter '1'"); + }); });