diff --git a/docs/en/reference/data-retrieval-and-manipulation.rst b/docs/en/reference/data-retrieval-and-manipulation.rst index 43f1da683aa..4c8575173aa 100644 --- a/docs/en/reference/data-retrieval-and-manipulation.rst +++ b/docs/en/reference/data-retrieval-and-manipulation.rst @@ -253,10 +253,12 @@ SQL injection possibilities if not handled carefully. Doctrine DBAL implements a very powerful parsing process that will make this kind of prepared statement possible natively in the binding type system. The parsing necessarily comes with a performance overhead, but only if you really use a list of parameters. -There are two special binding types that describe a list of integers or strings: +There are four special binding types that describe a list of integers, regular, ascii or binary strings: - ``\Doctrine\DBAL\ArrayParameterType::INTEGER`` - ``\Doctrine\DBAL\ArrayParameterType::STRING`` +- ``\Doctrine\DBAL\ArrayParameterType::ASCII`` +- ``\Doctrine\DBAL\ArrayParameterType::BINARY`` Using one of these constants as a type you can activate the SQLParser inside Doctrine that rewrites the SQL and flattens the specified values into the set of parameters. Consider our previous example: diff --git a/src/ArrayParameterType.php b/src/ArrayParameterType.php index 3fc977bf287..65e1a29c225 100644 --- a/src/ArrayParameterType.php +++ b/src/ArrayParameterType.php @@ -19,12 +19,17 @@ final class ArrayParameterType */ public const ASCII = ParameterType::ASCII + Connection::ARRAY_PARAM_OFFSET; + /** + * Represents an array of ascii strings to be expanded by Doctrine SQL parsing. + */ + public const BINARY = ParameterType::BINARY + Connection::ARRAY_PARAM_OFFSET; + /** * @internal * * @psalm-param self::* $type * - * @psalm-return ParameterType::INTEGER|ParameterType::STRING|ParameterType::ASCII + * @psalm-return ParameterType::INTEGER|ParameterType::STRING|ParameterType::ASCII|ParameterType::BINARY */ public static function toElementParameterType(int $type): int { diff --git a/src/Connection.php b/src/Connection.php index e8b3a226123..1e79c9ea355 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -1919,6 +1919,7 @@ private function needsArrayParameterConversion(array $params, array $types): boo $type === ArrayParameterType::INTEGER || $type === ArrayParameterType::STRING || $type === ArrayParameterType::ASCII + || $type === ArrayParameterType::BINARY ) { return true; } diff --git a/src/ExpandArrayParameters.php b/src/ExpandArrayParameters.php index 7645acb8666..01cccfcd46d 100644 --- a/src/ExpandArrayParameters.php +++ b/src/ExpandArrayParameters.php @@ -101,6 +101,7 @@ private function acceptParameter($key, $value): void $type !== ArrayParameterType::INTEGER && $type !== ArrayParameterType::STRING && $type !== ArrayParameterType::ASCII + && $type !== ArrayParameterType::BINARY ) { $this->appendTypedParameter([$value], $type); diff --git a/tests/Connection/ExpandArrayParametersTest.php b/tests/Connection/ExpandArrayParametersTest.php index cb2c6c38934..8ef6a3151ab 100644 --- a/tests/Connection/ExpandArrayParametersTest.php +++ b/tests/Connection/ExpandArrayParametersTest.php @@ -11,6 +11,8 @@ use Doctrine\DBAL\Types\Type; use PHPUnit\Framework\TestCase; +use function hex2bin; + class ExpandArrayParametersTest extends TestCase { /** @return mixed[][] */ @@ -94,16 +96,24 @@ public static function dataExpandListParameters(): iterable [1 => ParameterType::STRING, 2 => ParameterType::STRING], ], 'Positional: explicit keys for array params and array types' => [ - 'SELECT * FROM Foo WHERE foo IN (?) AND bar IN (?) AND baz = ? AND bax IN (?)', - [1 => ['bar1', 'bar2'], 2 => true, 0 => [1, 2, 3], ['bax1', 'bax2']], + 'SELECT * FROM Foo WHERE foo IN (?) AND bar IN (?) AND baz = ? AND bax IN (?) AND bay IN (?)', + [ + 1 => ['bar1', 'bar2'], + 2 => true, + 0 => [1, 2, 3], + ['bax1', 'bax2'], + 4 => [hex2bin('DEADBEEF'), hex2bin('C0DEF00D')], + ], [ + 4 => ArrayParameterType::BINARY, 3 => ArrayParameterType::ASCII, 2 => ParameterType::BOOLEAN, 1 => ArrayParameterType::STRING, 0 => ArrayParameterType::INTEGER, ], - 'SELECT * FROM Foo WHERE foo IN (?, ?, ?) AND bar IN (?, ?) AND baz = ? AND bax IN (?, ?)', - [1, 2, 3, 'bar1', 'bar2', true, 'bax1', 'bax2'], + 'SELECT * FROM Foo WHERE foo IN (?, ?, ?) AND bar IN (?, ?) AND baz = ? AND bax IN (?, ?) ' . + 'AND bay IN (?, ?)', + [1, 2, 3, 'bar1', 'bar2', true, 'bax1', 'bax2', hex2bin('DEADBEEF'), hex2bin('C0DEF00D')], [ ParameterType::INTEGER, ParameterType::INTEGER, @@ -113,6 +123,8 @@ public static function dataExpandListParameters(): iterable ParameterType::BOOLEAN, ParameterType::ASCII, ParameterType::ASCII, + ParameterType::BINARY, + ParameterType::BINARY, ], ], 'Named: Very simple with param int' => [ @@ -310,6 +322,22 @@ public static function dataExpandListParameters(): iterable ['foo', 'bar', 'baz'], [1 => ParameterType::STRING, ParameterType::STRING], ], + 'Named: Binary array with explicit types' => [ + 'SELECT * FROM Foo WHERE foo IN (:foo) OR bar IN (:bar)', + [ + 'foo' => [hex2bin('DEADBEEF'), hex2bin('C0DEF00D')], + 'bar' => [hex2bin('DEADBEEF'), hex2bin('C0DEF00D')], + ], + ['foo' => ArrayParameterType::BINARY, 'bar' => ArrayParameterType::BINARY], + 'SELECT * FROM Foo WHERE foo IN (?, ?) OR bar IN (?, ?)', + [hex2bin('DEADBEEF'), hex2bin('C0DEF00D'), hex2bin('DEADBEEF'), hex2bin('C0DEF00D')], + [ + ParameterType::BINARY, + ParameterType::BINARY, + ParameterType::BINARY, + ParameterType::BINARY, + ], + ], ]; } diff --git a/tests/Functional/BinaryDataAccessTest.php b/tests/Functional/BinaryDataAccessTest.php new file mode 100644 index 00000000000..50be983860f --- /dev/null +++ b/tests/Functional/BinaryDataAccessTest.php @@ -0,0 +1,354 @@ +addColumn('test_int', 'integer'); + $table->addColumn('test_binary', 'binary', ['notnull' => false, 'length' => 4]); + $table->setPrimaryKey(['test_int']); + + $this->dropAndCreateTable($table); + + $this->connection->insert('binary_fetch_table', [ + 'test_int' => 1, + 'test_binary' => hex2bin('C0DEF00D'), + ], [ + 'test_binary' => ParameterType::BINARY, + ]); + } + + public function testPrepareWithBindValue(): void + { + $sql = 'SELECT test_int, test_binary FROM binary_fetch_table WHERE test_int = ? AND test_binary = ?'; + $stmt = $this->connection->prepare($sql); + + $stmt->bindValue(1, 1); + $stmt->bindValue(2, hex2bin('C0DEF00D'), ParameterType::BINARY); + + $row = $stmt->executeQuery()->fetchAssociative(); + + self::assertIsArray($row); + $row = array_change_key_case($row, CASE_LOWER); + self::assertEquals(['test_int', 'test_binary'], array_keys($row)); + self::assertEquals(1, $row['test_int']); + + $binaryResult = $row['test_binary']; + if (is_resource($binaryResult)) { + $binaryResult = stream_get_contents($binaryResult); + } + + self::assertEquals(hex2bin('C0DEF00D'), $binaryResult); + } + + public function testPrepareWithFetchAllAssociative(): void + { + $paramInt = 1; + $paramBin = hex2bin('C0DEF00D'); + + $sql = 'SELECT test_int, test_binary FROM binary_fetch_table WHERE test_int = ? AND test_binary = ?'; + $stmt = $this->connection->prepare($sql); + + $stmt->bindValue(1, $paramInt); + $stmt->bindValue(2, $paramBin, ParameterType::BINARY); + + $rows = $stmt->executeQuery()->fetchAllAssociative(); + $rows[0] = array_change_key_case($rows[0], CASE_LOWER); + + self::assertEquals(['test_int', 'test_binary'], array_keys($rows[0])); + self::assertEquals(1, $rows[0]['test_int']); + + $binaryResult = $rows[0]['test_binary']; + if (is_resource($binaryResult)) { + $binaryResult = stream_get_contents($binaryResult); + } + + self::assertEquals(hex2bin('C0DEF00D'), $binaryResult); + } + + public function testPrepareWithFetchOne(): void + { + $paramInt = 1; + $paramBin = hex2bin('C0DEF00D'); + + $sql = 'SELECT test_int FROM binary_fetch_table WHERE test_int = ? AND test_binary = ?'; + $stmt = $this->connection->prepare($sql); + + $stmt->bindValue(1, $paramInt); + $stmt->bindValue(2, $paramBin, ParameterType::BINARY); + + $column = $stmt->executeQuery()->fetchOne(); + self::assertEquals(1, $column); + } + + public function testFetchAllAssociative(): void + { + $sql = 'SELECT test_int, test_binary FROM binary_fetch_table WHERE test_int = ? AND test_binary = ?'; + $data = $this->connection->fetchAllAssociative($sql, [1, hex2bin('C0DEF00D')], [1 => ParameterType::BINARY]); + + self::assertCount(1, $data); + + $row = $data[0]; + self::assertCount(2, $row); + + $row = array_change_key_case($row, CASE_LOWER); + self::assertEquals(1, $row['test_int']); + + $binaryResult = $row['test_binary']; + if (is_resource($binaryResult)) { + $binaryResult = stream_get_contents($binaryResult); + } + + self::assertEquals(hex2bin('C0DEF00D'), $binaryResult); + } + + public function testFetchAllWithTypes(): void + { + $sql = 'SELECT test_int, test_binary FROM binary_fetch_table WHERE test_int = ? AND test_binary = ?'; + $data = $this->connection->fetchAllAssociative( + $sql, + [1, hex2bin('C0DEF00D')], + [ParameterType::STRING, Types::BINARY], + ); + + self::assertCount(1, $data); + + $row = $data[0]; + self::assertCount(2, $row); + + $row = array_change_key_case($row, CASE_LOWER); + self::assertEquals(1, $row['test_int']); + + $binaryResult = $row['test_binary']; + if (is_resource($binaryResult)) { + $binaryResult = stream_get_contents($binaryResult); + } + + self::assertEquals(hex2bin('C0DEF00D'), $binaryResult); + } + + public function testFetchAssociative(): void + { + $sql = 'SELECT test_int, test_binary FROM binary_fetch_table WHERE test_int = ? AND test_binary = ?'; + $row = $this->connection->fetchAssociative($sql, [1, hex2bin('C0DEF00D')], [1 => ParameterType::BINARY]); + + self::assertNotFalse($row); + + $row = array_change_key_case($row, CASE_LOWER); + + self::assertEquals(1, $row['test_int']); + + $binaryResult = $row['test_binary']; + if (is_resource($binaryResult)) { + $binaryResult = stream_get_contents($binaryResult); + } + + self::assertEquals(hex2bin('C0DEF00D'), $binaryResult); + } + + public function testFetchAssocWithTypes(): void + { + $sql = 'SELECT test_int, test_binary FROM binary_fetch_table WHERE test_int = ? AND test_binary = ?'; + $row = $this->connection->fetchAssociative( + $sql, + [1, hex2bin('C0DEF00D')], + [ParameterType::STRING, Types::BINARY], + ); + + self::assertNotFalse($row); + + $row = array_change_key_case($row, CASE_LOWER); + + self::assertEquals(1, $row['test_int']); + + $binaryResult = $row['test_binary']; + if (is_resource($binaryResult)) { + $binaryResult = stream_get_contents($binaryResult); + } + + self::assertEquals(hex2bin('C0DEF00D'), $binaryResult); + } + + public function testFetchArray(): void + { + $sql = 'SELECT test_int, test_binary FROM binary_fetch_table WHERE test_int = ? AND test_binary = ?'; + $row = $this->connection->fetchNumeric($sql, [1, hex2bin('C0DEF00D')], [1 => ParameterType::BINARY]); + self::assertNotFalse($row); + + self::assertEquals(1, $row[0]); + + $binaryResult = $row[1]; + if (is_resource($binaryResult)) { + $binaryResult = stream_get_contents($binaryResult); + } + + self::assertEquals(hex2bin('C0DEF00D'), $binaryResult); + } + + public function testFetchArrayWithTypes(): void + { + $sql = 'SELECT test_int, test_binary FROM binary_fetch_table WHERE test_int = ? AND test_binary = ?'; + $row = $this->connection->fetchNumeric( + $sql, + [1, hex2bin('C0DEF00D')], + [ParameterType::STRING, Types::BINARY], + ); + + self::assertNotFalse($row); + + $row = array_change_key_case($row, CASE_LOWER); + + self::assertEquals(1, $row[0]); + + $binaryResult = $row[1]; + if (is_resource($binaryResult)) { + $binaryResult = stream_get_contents($binaryResult); + } + + self::assertEquals(hex2bin('C0DEF00D'), $binaryResult); + } + + public function testFetchColumn(): void + { + $sql = 'SELECT test_int FROM binary_fetch_table WHERE test_int = ? AND test_binary = ?'; + $testInt = $this->connection->fetchOne($sql, [1, hex2bin('C0DEF00D')], [1 => ParameterType::BINARY]); + + self::assertEquals(1, $testInt); + + $sql = 'SELECT test_binary FROM binary_fetch_table WHERE test_int = ? AND test_binary = ?'; + $testBinary = $this->connection->fetchOne($sql, [1, hex2bin('C0DEF00D')], [1 => ParameterType::BINARY]); + + if (is_resource($testBinary)) { + $testBinary = stream_get_contents($testBinary); + } + + self::assertEquals(hex2bin('C0DEF00D'), $testBinary); + } + + public function testFetchOneWithTypes(): void + { + $sql = 'SELECT test_binary FROM binary_fetch_table WHERE test_int = ? AND test_binary = ?'; + $column = $this->connection->fetchOne( + $sql, + [1, hex2bin('C0DEF00D')], + [ParameterType::STRING, Types::BINARY], + ); + + if (is_resource($column)) { + $column = stream_get_contents($column); + } + + self::assertIsString($column); + self::assertEquals(hex2bin('C0DEF00D'), $column); + } + + public function testNativeArrayListSupport(): void + { + $binaryValues = [ + hex2bin('A0AEFA'), + hex2bin('1F43BA'), + hex2bin('8C9D2A'), + hex2bin('72E8AA'), + hex2bin('5B6F9A'), + hex2bin('DAB24A'), + hex2bin('3E71CA'), + hex2bin('F0D6EA'), + hex2bin('6A8B5A'), + hex2bin('C582FA'), + ]; + + for ($i = 100; $i < 110; $i++) { + $this->connection->insert('binary_fetch_table', [ + 'test_int' => $i, + 'test_binary' => $binaryValues[$i - 100], + ], [ + 'test_binary' => ParameterType::BINARY, + ]); + } + + $result = $this->connection->executeQuery( + 'SELECT test_int FROM binary_fetch_table WHERE test_int IN (?)', + [[100, 101, 102, 103, 104]], + [ArrayParameterType::INTEGER], + ); + + $data = $result->fetchAllNumeric(); + self::assertCount(5, $data); + self::assertEquals([[100], [101], [102], [103], [104]], $data); + + $result = $this->connection->executeQuery( + 'SELECT test_int FROM binary_fetch_table WHERE test_binary IN (?)', + [ + [ + $binaryValues[0], + $binaryValues[1], + $binaryValues[2], + $binaryValues[3], + $binaryValues[4], + ], + ], + [ArrayParameterType::BINARY], + ); + + $data = $result->fetchAllNumeric(); + self::assertCount(5, $data); + self::assertEquals([[100], [101], [102], [103], [104]], $data); + + $result = $this->connection->executeQuery( + 'SELECT test_binary FROM binary_fetch_table WHERE test_binary IN (?)', + [ + [ + $binaryValues[0], + $binaryValues[1], + $binaryValues[2], + $binaryValues[3], + $binaryValues[4], + ], + ], + [ArrayParameterType::BINARY], + ); + + $data = $result->fetchFirstColumn(); + self::assertCount(5, $data); + + $data = array_map( + static fn ($binaryField) => is_resource($binaryField) + ? stream_get_contents($binaryField) + : $binaryField, + $data, + ); + + self::assertEquals([ + $binaryValues[0], + $binaryValues[1], + $binaryValues[2], + $binaryValues[3], + $binaryValues[4], + ], $data); + } +} diff --git a/tests/Query/QueryBuilderTest.php b/tests/Query/QueryBuilderTest.php index 8cbd8052dba..899a1cd8675 100644 --- a/tests/Query/QueryBuilderTest.php +++ b/tests/Query/QueryBuilderTest.php @@ -15,6 +15,8 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use function hex2bin; + class QueryBuilderTest extends TestCase { /** @var Connection&MockObject */ @@ -930,12 +932,17 @@ public function testArrayParameters(): void $qb->andWhere('name IN (:names)'); $qb->setParameter('names', ['john', 'jane'], ArrayParameterType::STRING); + $qb->andWhere('hash IN (:hashes)'); + $qb->setParameter('hashes', [hex2bin('DEADBEEF'), hex2bin('C0DEF00D')], ArrayParameterType::BINARY); + self::assertSame(ArrayParameterType::INTEGER, $qb->getParameterType('ids')); self::assertSame(ArrayParameterType::STRING, $qb->getParameterType('names')); + self::assertSame(ArrayParameterType::BINARY, $qb->getParameterType('hashes')); self::assertSame([ 'ids' => ArrayParameterType::INTEGER, 'names' => ArrayParameterType::STRING, + 'hashes' => ArrayParameterType::BINARY, ], $qb->getParameterTypes()); }