From 3addab7ffd05555a1da5cf68d2843fea9e25e5f7 Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Sun, 24 May 2026 12:15:07 -0400 Subject: [PATCH] fix(database-mysql): JSON-encode array bindings before passing to PDO PDO silently casts PHP arrays to the string "Array" when bound as query parameters. For json columns this either fails the JSON parse or stores the literal string "Array" depending on column type and SQL mode. Mirrors the database-pgsql fix from #72: pre-process bindings via a prepareBindings() helper that JSON-encodes any array values with JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE. JsonException is caught and converted to a domain ConnectionException::invalidArrayBinding() that names the offending parameter. Closes #81 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/Connection/MySqlConnection.php | 32 +++++++++++- .../src/Exceptions/ConnectionException.php | 13 +++++ .../tests/Connection/MySqlConnectionTest.php | 52 +++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/packages/database-mysql/src/Connection/MySqlConnection.php b/packages/database-mysql/src/Connection/MySqlConnection.php index c2e0cce2..0736fbed 100644 --- a/packages/database-mysql/src/Connection/MySqlConnection.php +++ b/packages/database-mysql/src/Connection/MySqlConnection.php @@ -4,6 +4,7 @@ namespace Marko\Database\MySql\Connection; +use JsonException; use Marko\Database\Config\DatabaseConfig; use Marko\Database\Connection\ConnectionInterface; use Marko\Database\Connection\StatementInterface; @@ -125,7 +126,7 @@ public function query( $this->ensureConnected(); $statement = $this->pdo->prepare($sql); - $statement->execute($bindings); + $statement->execute($this->prepareBindings($bindings)); return $statement->fetchAll(PDO::FETCH_ASSOC); } @@ -140,11 +141,38 @@ public function execute( $this->ensureConnected(); $statement = $this->pdo->prepare($sql); - $statement->execute($bindings); + $statement->execute($this->prepareBindings($bindings)); return $statement->rowCount(); } + /** + * JSON-encode any array values so PDO does not silently cast them to the literal string "Array". + * + * @param array $bindings + * + * @return array + * + * @throws ConnectionException + */ + private function prepareBindings( + array $bindings, + ): array { + foreach ($bindings as $key => $value) { + if (!is_array($value)) { + continue; + } + + try { + $bindings[$key] = json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + } catch (JsonException $e) { + throw ConnectionException::invalidArrayBinding($key, $e); + } + } + + return $bindings; + } + /** * @throws ConnectionException */ diff --git a/packages/database-mysql/src/Exceptions/ConnectionException.php b/packages/database-mysql/src/Exceptions/ConnectionException.php index cc3abf31..6d9aad2c 100644 --- a/packages/database-mysql/src/Exceptions/ConnectionException.php +++ b/packages/database-mysql/src/Exceptions/ConnectionException.php @@ -4,6 +4,7 @@ namespace Marko\Database\MySql\Exceptions; +use JsonException; use Marko\Core\Exceptions\MarkoException; use PDOException; @@ -22,4 +23,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-mysql/tests/Connection/MySqlConnectionTest.php b/packages/database-mysql/tests/Connection/MySqlConnectionTest.php index 3f9851fa..fc300557 100644 --- a/packages/database-mysql/tests/Connection/MySqlConnectionTest.php +++ b/packages/database-mysql/tests/Connection/MySqlConnectionTest.php @@ -667,4 +667,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 = createTestDatabaseConfig(); + $connection = new class ($config) extends MySqlConnection + { + protected function createPdo( + string $dsn, + string $username, + string $password, + array $options, + ): PDO { + $pdo = new PDO('sqlite::memory:', options: $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 = createTestDatabaseConfig(); + $connection = new class ($config) extends MySqlConnection + { + protected function createPdo( + string $dsn, + string $username, + string $password, + array $options, + ): PDO { + $pdo = new PDO('sqlite::memory:', options: $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 '0'"); + }); });