Skip to content

Commit 9d6e9da

Browse files
authored
Merge pull request #82 from marko-php/feature/mysql-array-binding-json-encode
fix(database-mysql): JSON-encode array bindings before passing to PDO (closes #81)
2 parents 00b4a23 + 3addab7 commit 9d6e9da

3 files changed

Lines changed: 95 additions & 2 deletions

File tree

packages/database-mysql/src/Connection/MySqlConnection.php

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Marko\Database\MySql\Connection;
66

7+
use JsonException;
78
use Marko\Database\Config\DatabaseConfig;
89
use Marko\Database\Connection\ConnectionInterface;
910
use Marko\Database\Connection\StatementInterface;
@@ -125,7 +126,7 @@ public function query(
125126
$this->ensureConnected();
126127

127128
$statement = $this->pdo->prepare($sql);
128-
$statement->execute($bindings);
129+
$statement->execute($this->prepareBindings($bindings));
129130

130131
return $statement->fetchAll(PDO::FETCH_ASSOC);
131132
}
@@ -140,11 +141,38 @@ public function execute(
140141
$this->ensureConnected();
141142

142143
$statement = $this->pdo->prepare($sql);
143-
$statement->execute($bindings);
144+
$statement->execute($this->prepareBindings($bindings));
144145

145146
return $statement->rowCount();
146147
}
147148

149+
/**
150+
* JSON-encode any array values so PDO does not silently cast them to the literal string "Array".
151+
*
152+
* @param array<int|string, mixed> $bindings
153+
*
154+
* @return array<int|string, mixed>
155+
*
156+
* @throws ConnectionException
157+
*/
158+
private function prepareBindings(
159+
array $bindings,
160+
): array {
161+
foreach ($bindings as $key => $value) {
162+
if (!is_array($value)) {
163+
continue;
164+
}
165+
166+
try {
167+
$bindings[$key] = json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
168+
} catch (JsonException $e) {
169+
throw ConnectionException::invalidArrayBinding($key, $e);
170+
}
171+
}
172+
173+
return $bindings;
174+
}
175+
148176
/**
149177
* @throws ConnectionException
150178
*/

packages/database-mysql/src/Exceptions/ConnectionException.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Marko\Database\MySql\Exceptions;
66

7+
use JsonException;
78
use Marko\Core\Exceptions\MarkoException;
89
use PDOException;
910

@@ -22,4 +23,16 @@ public static function connectionFailed(
2223
previous: $previous,
2324
);
2425
}
26+
27+
public static function invalidArrayBinding(
28+
int|string $parameter,
29+
JsonException $previous,
30+
): self {
31+
return new self(
32+
message: "Failed to JSON-encode array bound to parameter '$parameter'",
33+
context: $previous->getMessage(),
34+
suggestion: 'Ensure array values are JSON-encodable (no resources, recursive references, NAN, or INF)',
35+
previous: $previous,
36+
);
37+
}
2538
}

packages/database-mysql/tests/Connection/MySqlConnectionTest.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,4 +667,56 @@ protected function createPdo(
667667
expect(fn () => $connection->beginTransaction())
668668
->toThrow(TransactionException::class, 'Nested transactions are not supported');
669669
});
670+
671+
it('JSON-encodes array bindings instead of casting them to the string "Array"', function (): void {
672+
$config = createTestDatabaseConfig();
673+
$connection = new class ($config) extends MySqlConnection
674+
{
675+
protected function createPdo(
676+
string $dsn,
677+
string $username,
678+
string $password,
679+
array $options,
680+
): PDO {
681+
$pdo = new PDO('sqlite::memory:', options: $options);
682+
$pdo->exec('CREATE TABLE items (id INTEGER PRIMARY KEY, metadata TEXT)');
683+
684+
return $pdo;
685+
}
686+
};
687+
688+
$connection->execute(
689+
'INSERT INTO items (metadata) VALUES (?)',
690+
[['key' => 'value', 'nested' => [1, 2, 3]]],
691+
);
692+
693+
$rows = $connection->query('SELECT metadata FROM items');
694+
695+
expect($rows[0]['metadata'])
696+
->toBe('{"key":"value","nested":[1,2,3]}')
697+
->not->toBe('Array');
698+
});
699+
700+
it('throws ConnectionException when an array binding is not JSON-encodable', function (): void {
701+
$config = createTestDatabaseConfig();
702+
$connection = new class ($config) extends MySqlConnection
703+
{
704+
protected function createPdo(
705+
string $dsn,
706+
string $username,
707+
string $password,
708+
array $options,
709+
): PDO {
710+
$pdo = new PDO('sqlite::memory:', options: $options);
711+
$pdo->exec('CREATE TABLE items (id INTEGER PRIMARY KEY, metadata TEXT)');
712+
713+
return $pdo;
714+
}
715+
};
716+
717+
expect(fn () => $connection->execute(
718+
'INSERT INTO items (metadata) VALUES (?)',
719+
[[NAN]],
720+
))->toThrow(ConnectionException::class, "Failed to JSON-encode array bound to parameter '0'");
721+
});
670722
});

0 commit comments

Comments
 (0)