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'"); + }); });