From 8d22e874ca2f89e48afcc3f27dc95b824a27f1fb Mon Sep 17 00:00:00 2001 From: "Nicholas K. Dionysopoulos" Date: Thu, 16 May 2024 11:14:21 +0300 Subject: [PATCH 1/4] Add support for duplicate named query parameters to the mysqli driver --- Tests/Mysql/MysqlPreparedStatementTest.php | 109 +++++++++++++ Tests/Mysqli/MysqliPreparedStatementTest.php | 157 +++++++++++++++++++ src/Mysqli/MysqliStatement.php | 24 ++- 3 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 Tests/Mysql/MysqlPreparedStatementTest.php create mode 100644 Tests/Mysqli/MysqliPreparedStatementTest.php diff --git a/Tests/Mysql/MysqlPreparedStatementTest.php b/Tests/Mysql/MysqlPreparedStatementTest.php new file mode 100644 index 00000000..cc0919e8 --- /dev/null +++ b/Tests/Mysql/MysqlPreparedStatementTest.php @@ -0,0 +1,109 @@ +getName() !== 'mysql') { + self::markTestSkipped('MySQL database not configured.'); + } + } + + /** + * Sets up the fixture. + * + * This method is called before a test is executed. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + try { + foreach (DatabaseDriver::splitSql(file_get_contents(dirname(__DIR__) . '/Stubs/Schema/mysql.sql')) as $query) { + static::$connection->setQuery($query) + ->execute(); + } + } catch (ExecutionFailureException $exception) { + $this->markTestSkipped( + \sprintf( + 'Could not load MySQL database: %s', + $exception->getMessage() + ) + ); + } + } + + /** + * Tears down the fixture. + * + * This method is called after a test is executed. + */ + protected function tearDown(): void + { + foreach (static::$connection->getTableList() as $table) { + static::$connection->dropTable($table); + } + } + + /** + * Make sure the mysqli driver correctly runs queries with named parameters appearing more than once. + * + * @doesNotPerformAssertions + */ + public function testPreparedStatementWithDuplicateKey() + { + $dummyValue = 'test'; + $query = static::$connection->getQuery(true); + $query->select('*') + ->from($query->quoteName('dbtest')) + ->where([ + $query->quoteName('title') . ' LIKE :search', + $query->quoteName('description') . ' LIKE :search', + ]) + ->bind(':search', $dummyValue); + + static::$connection->setQuery($query)->execute(); + } + + /** + * Regression test to ensure running queries with named parameters appearing once didn't break. + * + * @doesNotPerformAssertions + */ + public function testPreparedStatementWithSingleKey() + { + $dummyValue = 'test'; + $dummyValue2 = 'test'; + $query = static::$connection->getQuery(true); + $query->select('*') + ->from($query->quoteName('dbtest')) + ->where([ + $query->quoteName('title') . ' LIKE :search', + $query->quoteName('description') . ' LIKE :search2', + ]) + ->bind(':search', $dummyValue) + ->bind(':search2', $dummyValue2); + + static::$connection->setQuery($query)->execute(); + } +} diff --git a/Tests/Mysqli/MysqliPreparedStatementTest.php b/Tests/Mysqli/MysqliPreparedStatementTest.php new file mode 100644 index 00000000..3921b3dc --- /dev/null +++ b/Tests/Mysqli/MysqliPreparedStatementTest.php @@ -0,0 +1,157 @@ +getName() !== 'mysqli') { + self::markTestSkipped('MySQL database not configured.'); + } + } + + /** + * Sets up the fixture. + * + * This method is called before a test is executed. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + try { + foreach (DatabaseDriver::splitSql(file_get_contents(dirname(__DIR__) . '/Stubs/Schema/mysql.sql')) as $query) { + static::$connection->setQuery($query) + ->execute(); + } + } catch (ExecutionFailureException $exception) { + $this->markTestSkipped( + \sprintf( + 'Could not load MySQL database: %s', + $exception->getMessage() + ) + ); + } + } + + /** + * Tears down the fixture. + * + * This method is called after a test is executed. + */ + protected function tearDown(): void + { + foreach (static::$connection->getTableList() as $table) { + static::$connection->dropTable($table); + } + } + + + /** + * Make sure the mysqli driver correctly maps named query parameters appearing more than once. + */ + public function testPrepareParameterKeyMappingWithDuplicateKey() + { + $statement = 'SELECT * FROM dbtest WHERE `title` LIKE :search OR `description` LIKE :search'; + $mysqliStatementObject = new MysqliStatement(static::$connection->getConnection(), $statement); + $rawQuery = $mysqliStatementObject->prepareParameterKeyMapping($statement); + + $this->assertEquals( + "SELECT * FROM dbtest WHERE `title` LIKE ? OR `description` LIKE ?", + $rawQuery + ); + + $refObject = new \ReflectionObject($mysqliStatementObject); + $refMapping = $refObject->getProperty('parameterKeyMapping'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $refMapping->setAccessible(true); + $parameterKeyMapping = $refMapping->getValue($mysqliStatementObject); + + $this->assertEquals( + [ + ':search' => [0, 1], + ], + $parameterKeyMapping + ); + } + + /** + * Regression test to ensure mapping query parameters appearing once didn't break. + */ + public function testPrepareParameterKeyMappingWithSingleKey() + { + $statement = 'SELECT * FROM dbtest WHERE `title` LIKE :search OR `description` LIKE :search2'; + $mysqliStatementObject = new MysqliStatement(static::$connection->getConnection(), $statement); + $rawQuery = $mysqliStatementObject->prepareParameterKeyMapping($statement); + + $this->assertEquals( + "SELECT * FROM dbtest WHERE `title` LIKE ? OR `description` LIKE ?", + $rawQuery + ); + + $refObject = new \ReflectionObject($mysqliStatementObject); + $refMapping = $refObject->getProperty('parameterKeyMapping'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $refMapping->setAccessible(true); + $parameterKeyMapping = $refMapping->getValue($mysqliStatementObject); + + $this->assertEquals( + [ + ':search' => 0, + ':search2' => 1, + ], + $parameterKeyMapping + ); + } + + /** + * Make sure the mysqli driver correctly runs queries with named parameters appearing more than once. + * + * @doesNotPerformAssertions + */ + public function testPreparedStatementWithDuplicateKey() + { + $statement = 'SELECT * FROM dbtest WHERE `title` LIKE :search OR `description` LIKE :search'; + $mysqliStatementObject = new MysqliStatement(static::$connection->getConnection(), $statement); + $dummyValue = 'test'; + $mysqliStatementObject->bindParam(':search', $dummyValue); + + $mysqliStatementObject->execute(); + } + + /** + * Regression test to ensure running queries with named parameters appearing once didn't break. + * + * @doesNotPerformAssertions + */ + public function testPreparedStatementWithSingleKey() + { + $statement = 'SELECT * FROM dbtest WHERE `title` LIKE :search OR `description` LIKE :search2'; + $mysqliStatementObject = new MysqliStatement(static::$connection->getConnection(), $statement); + $dummyValue = 'test'; + $dummyValue2 = 'test'; + $mysqliStatementObject->bindParam(':search', $dummyValue); + $mysqliStatementObject->bindParam(':search2', $dummyValue); + + $mysqliStatementObject->execute(); + } +} diff --git a/src/Mysqli/MysqliStatement.php b/src/Mysqli/MysqliStatement.php index f5eb05c7..a7ec0ff2 100644 --- a/src/Mysqli/MysqliStatement.php +++ b/src/Mysqli/MysqliStatement.php @@ -197,8 +197,15 @@ public function prepareParameterKeyMapping($sql) $literal .= substr($substring, 0, $match[1]); } - $mapping[$match[0]] = \count($mapping); - $endOfPlaceholder = $match[1] + strlen($match[0]); + if (isset($mapping[$match[0]])) { + $mapping[$match[0]] = is_array($mapping[$match[0]]) ? $mapping[$match[0]] : [$mapping[$match[0]]]; + + array_push($mapping[$match[0]], \count($mapping)); + + } else { + $mapping[$match[0]] = \count($mapping); + } + $endOfPlaceholder = $match[1] + strlen($match[0]); $beginOfNextPlaceholder = $matches[0][$i + 1][1] ?? strlen($substring); $beginOfNextPlaceholder -= $endOfPlaceholder; $literal .= '?' . substr($substring, $endOfPlaceholder, $beginOfNextPlaceholder); @@ -371,8 +378,17 @@ public function execute(?array $parameters = null) if (!empty($this->parameterKeyMapping)) { foreach ($this->bindedValues as $key => &$value) { - $params[$this->parameterKeyMapping[$key]] =& $value; - $types[$this->parameterKeyMapping[$key]] = $this->typesKeyMapping[$key]; + $paramKey = $this->parameterKeyMapping[$key]; + + if (is_scalar($this->parameterKeyMapping[$key])) { + $params[$paramKey] =& $value; + $types[$paramKey] = $this->typesKeyMapping[$key]; + } else { + foreach ($paramKey as $currentKey) { + $params[$currentKey] =& $value; + $types[$currentKey] = $this->typesKeyMapping[$key]; + } + } } } else { foreach ($this->bindedValues as $key => &$value) { From 256bc7c91157a5530c591c8a7ded1aea5c01831a Mon Sep 17 00:00:00 2001 From: "Nicholas K. Dionysopoulos" Date: Thu, 16 May 2024 13:22:34 +0300 Subject: [PATCH 2/4] Add prepared statement tests for other DB servers * PostgreSQL * SQLite * Microsoft SQL Server --- Tests/Pgsql/PgsqlPreparedStatementTest.php | 111 +++++++++++++++++ Tests/Sqlite/SqlitePreparedStatementTest.php | 118 +++++++++++++++++++ Tests/Sqlsrv/SqlsrvPreparedStatementTest.php | 111 +++++++++++++++++ 3 files changed, 340 insertions(+) create mode 100644 Tests/Pgsql/PgsqlPreparedStatementTest.php create mode 100644 Tests/Sqlite/SqlitePreparedStatementTest.php create mode 100644 Tests/Sqlsrv/SqlsrvPreparedStatementTest.php diff --git a/Tests/Pgsql/PgsqlPreparedStatementTest.php b/Tests/Pgsql/PgsqlPreparedStatementTest.php new file mode 100644 index 00000000..f2177f17 --- /dev/null +++ b/Tests/Pgsql/PgsqlPreparedStatementTest.php @@ -0,0 +1,111 @@ +getConnection(); + $manager->dropDatabase(); + $manager->createDatabase(); + $connection->select($manager->getDbName()); + + static::$connection = $connection; + } + + /** + * Sets up the fixture. + * + * This method is called before a test is executed. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + try { + foreach (DatabaseDriver::splitSql(file_get_contents(dirname(__DIR__) . '/Stubs/Schema/pgsql.sql')) as $query) { + static::$connection->setQuery($query) + ->execute(); + } + } catch (ExecutionFailureException $exception) { + $this->markTestSkipped( + \sprintf( + 'Could not load PostgreSQL database: %s', + $exception->getMessage() + ) + ); + } + } + + /** + * Tears down the fixture. + * + * This method is called after a test is executed. + */ + protected function tearDown(): void + { + foreach (static::$connection->getTableList() as $table) { + static::$connection->dropTable($table); + } + } + + /** + * Make sure the mysqli driver correctly runs queries with named parameters appearing more than once. + * + * @doesNotPerformAssertions + */ + public function testPreparedStatementWithDuplicateKey() + { + $dummyValue = 'test'; + $query = static::$connection->getQuery(true); + $query->select('*') + ->from($query->quoteName('dbtest')) + ->where([ + $query->quoteName('title') . ' LIKE :search', + $query->quoteName('description') . ' LIKE :search', + ], 'OR') + ->bind(':search', $dummyValue); + + static::$connection->setQuery($query)->execute(); + } + + /** + * Regression test to ensure running queries with named parameters appearing once didn't break. + * + * @doesNotPerformAssertions + */ + public function testPreparedStatementWithSingleKey() + { + $dummyValue = 'test'; + $dummyValue2 = 'test'; + $query = static::$connection->getQuery(true); + $query->select('*') + ->from($query->quoteName('dbtest')) + ->where([ + $query->quoteName('title') . ' LIKE :search', + $query->quoteName('description') . ' LIKE :search2', + ]) + ->bind(':search', $dummyValue) + ->bind(':search2', $dummyValue2); + + static::$connection->setQuery($query)->execute(); + } +} diff --git a/Tests/Sqlite/SqlitePreparedStatementTest.php b/Tests/Sqlite/SqlitePreparedStatementTest.php new file mode 100644 index 00000000..e3ffd2a8 --- /dev/null +++ b/Tests/Sqlite/SqlitePreparedStatementTest.php @@ -0,0 +1,118 @@ +getConnection(); + $manager->dropDatabase(); + $manager->createDatabase(); + $connection->select($manager->getDbName()); + + static::$connection = $connection; + } + + /** + * Sets up the fixture. + * + * This method is called before a test is executed. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + try { + foreach (DatabaseDriver::splitSql(file_get_contents(dirname(__DIR__) . '/Stubs/Schema/sqlite.sql')) as $query) { + static::$connection->setQuery($query) + ->execute(); + } + } catch (ExecutionFailureException $exception) { + $this->markTestSkipped( + \sprintf( + 'Could not load SQLite database: %s', + $exception->getMessage() + ) + ); + } + } + + /** + * Tears down the fixture. + * + * This method is called after a test is executed. + */ + protected function tearDown(): void + { + $tables = array_filter( + static::$connection->getTableList(), + function (string $table): bool { + return $table !== 'sqlite_sequence'; + } + ); + + foreach ($tables as $table) { + static::$connection->dropTable($table); + } + } + + /** + * Make sure the mysqli driver correctly runs queries with named parameters appearing more than once. + * + * @doesNotPerformAssertions + */ + public function testPreparedStatementWithDuplicateKey() + { + $dummyValue = 'test'; + $query = static::$connection->getQuery(true); + $query->select('*') + ->from($query->quoteName('dbtest')) + ->where([ + $query->quoteName('title') . ' LIKE :search', + $query->quoteName('description') . ' LIKE :search', + ], 'OR') + ->bind(':search', $dummyValue); + + static::$connection->setQuery($query)->execute(); + } + + /** + * Regression test to ensure running queries with named parameters appearing once didn't break. + * + * @doesNotPerformAssertions + */ + public function testPreparedStatementWithSingleKey() + { + $dummyValue = 'test'; + $dummyValue2 = 'test'; + $query = static::$connection->getQuery(true); + $query->select('*') + ->from($query->quoteName('dbtest')) + ->where([ + $query->quoteName('title') . ' LIKE :search', + $query->quoteName('description') . ' LIKE :search2', + ]) + ->bind(':search', $dummyValue) + ->bind(':search2', $dummyValue2); + + static::$connection->setQuery($query)->execute(); + } +} diff --git a/Tests/Sqlsrv/SqlsrvPreparedStatementTest.php b/Tests/Sqlsrv/SqlsrvPreparedStatementTest.php new file mode 100644 index 00000000..4af10856 --- /dev/null +++ b/Tests/Sqlsrv/SqlsrvPreparedStatementTest.php @@ -0,0 +1,111 @@ +getConnection(); + $manager->dropDatabase(); + $manager->createDatabase(); + $connection->select($manager->getDbName()); + + static::$connection = $connection; + } + + /** + * Sets up the fixture. + * + * This method is called before a test is executed. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + try { + foreach (DatabaseDriver::splitSql(file_get_contents(dirname(__DIR__) . '/Stubs/Schema/sqlsrv.sql')) as $query) { + static::$connection->setQuery($query) + ->execute(); + } + } catch (ExecutionFailureException $exception) { + $this->markTestSkipped( + \sprintf( + 'Could not load MS SQL Server database: %s', + $exception->getMessage() + ) + ); + } + } + + /** + * Tears down the fixture. + * + * This method is called after a test is executed. + */ + protected function tearDown(): void + { + foreach (static::$connection->getTableList() as $table) { + static::$connection->dropTable($table); + } + } + + /** + * Make sure the mysqli driver correctly runs queries with named parameters appearing more than once. + * + * @doesNotPerformAssertions + */ + public function testPreparedStatementWithDuplicateKey() + { + $dummyValue = 'test'; + $query = static::$connection->getQuery(true); + $query->select('*') + ->from($query->quoteName('dbtest')) + ->where([ + $query->quoteName('title') . ' LIKE :search', + $query->quoteName('description') . ' LIKE :search', + ], 'OR') + ->bind(':search', $dummyValue); + + static::$connection->setQuery($query)->execute(); + } + + /** + * Regression test to ensure running queries with named parameters appearing once didn't break. + * + * @doesNotPerformAssertions + */ + public function testPreparedStatementWithSingleKey() + { + $dummyValue = 'test'; + $dummyValue2 = 'test'; + $query = static::$connection->getQuery(true); + $query->select('*') + ->from($query->quoteName('dbtest')) + ->where([ + $query->quoteName('title') . ' LIKE :search', + $query->quoteName('description') . ' LIKE :search2', + ]) + ->bind(':search', $dummyValue) + ->bind(':search2', $dummyValue2); + + static::$connection->setQuery($query)->execute(); + } +} From 16d3d220d6308c10d11b39af7608cbb9a82042bb Mon Sep 17 00:00:00 2001 From: "Nicholas K. Dionysopoulos" Date: Thu, 16 May 2024 14:09:21 +0300 Subject: [PATCH 3/4] Add support for duplicate named query parameters to the sqlsrv driver --- Tests/Sqlsrv/SqlsrvPreparedStatementTest.php | 58 ++++++++++++++++++++ src/Mysqli/MysqliStatement.php | 3 +- src/Sqlsrv/SqlsrvStatement.php | 19 ++++++- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/Tests/Sqlsrv/SqlsrvPreparedStatementTest.php b/Tests/Sqlsrv/SqlsrvPreparedStatementTest.php index 4af10856..2a48b1d5 100644 --- a/Tests/Sqlsrv/SqlsrvPreparedStatementTest.php +++ b/Tests/Sqlsrv/SqlsrvPreparedStatementTest.php @@ -8,6 +8,7 @@ use Joomla\Database\DatabaseDriver; use Joomla\Database\Exception\ExecutionFailureException; +use Joomla\Database\Sqlsrv\SqlsrvStatement; use Joomla\Test\DatabaseTestCase; class SqlsrvPreparedStatementTest extends DatabaseTestCase @@ -67,6 +68,63 @@ protected function tearDown(): void } } + /** + * Make sure the mysqli driver correctly maps named query parameters appearing more than once. + */ + public function testPrepareParameterKeyMappingWithDuplicateKey() + { + $statement = 'SELECT * FROM dbtest WHERE title LIKE :search OR description LIKE :search'; + $sqlsrvStatement = new SqlsrvStatement(static::$connection->getConnection(), $statement); + $rawQuery = $sqlsrvStatement->prepareParameterKeyMapping($statement); + + $this->assertEquals( + "SELECT * FROM dbtest WHERE title LIKE ? OR description LIKE ?", + $rawQuery + ); + + $refObject = new \ReflectionObject($sqlsrvStatement); + $refMapping = $refObject->getProperty('parameterKeyMapping'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $refMapping->setAccessible(true); + $parameterKeyMapping = $refMapping->getValue($sqlsrvStatement); + + $this->assertEquals( + [ + ':search' => [0, 1], + ], + $parameterKeyMapping + ); + } + + /** + * Regression test to ensure mapping query parameters appearing once didn't break. + */ + public function testPrepareParameterKeyMappingWithSingleKey() + { + $statement = 'SELECT * FROM dbtest WHERE title LIKE :search OR description LIKE :search2'; + $sqlsrvStatement = new SqlsrvStatement(static::$connection->getConnection(), $statement); + $rawQuery = $sqlsrvStatement->prepareParameterKeyMapping($statement); + + $this->assertEquals( + "SELECT * FROM dbtest WHERE title LIKE ? OR description LIKE ?", + $rawQuery + ); + + $refObject = new \ReflectionObject($sqlsrvStatement); + $refMapping = $refObject->getProperty('parameterKeyMapping'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $refMapping->setAccessible(true); + $parameterKeyMapping = $refMapping->getValue($sqlsrvStatement); + + $this->assertEquals( + [ + ':search' => 0, + ':search2' => 1, + ], + $parameterKeyMapping + ); + } + /** * Make sure the mysqli driver correctly runs queries with named parameters appearing more than once. * diff --git a/src/Mysqli/MysqliStatement.php b/src/Mysqli/MysqliStatement.php index a7ec0ff2..e31dfd53 100644 --- a/src/Mysqli/MysqliStatement.php +++ b/src/Mysqli/MysqliStatement.php @@ -199,8 +199,7 @@ public function prepareParameterKeyMapping($sql) if (isset($mapping[$match[0]])) { $mapping[$match[0]] = is_array($mapping[$match[0]]) ? $mapping[$match[0]] : [$mapping[$match[0]]]; - - array_push($mapping[$match[0]], \count($mapping)); + $mapping[$match[0]][] = \count($mapping); } else { $mapping[$match[0]] = \count($mapping); diff --git a/src/Sqlsrv/SqlsrvStatement.php b/src/Sqlsrv/SqlsrvStatement.php index bb1fa716..01b73e43 100644 --- a/src/Sqlsrv/SqlsrvStatement.php +++ b/src/Sqlsrv/SqlsrvStatement.php @@ -202,7 +202,14 @@ public function prepareParameterKeyMapping($sql) $literal .= substr($substring, 0, $match[1]); } - $mapping[$match[0]] = \count($mapping); + if (isset($mapping[$match[0]])) { + $mapping[$match[0]] = is_array($mapping[$match[0]]) ? $mapping[$match[0]] : [$mapping[$match[0]]]; + $mapping[$match[0]][] = \count($mapping); + + } else { + $mapping[$match[0]] = \count($mapping); + } + $endOfPlaceholder = $match[1] + strlen($match[0]); $beginOfNextPlaceholder = $matches[0][$i + 1][1] ?? strlen($substring); $beginOfNextPlaceholder -= $endOfPlaceholder; @@ -484,7 +491,15 @@ private function prepare() } if (isset($this->parameterKeyMapping[$key])) { - $params[$this->parameterKeyMapping[$key]] = $variable; + $paramKey = $this->parameterKeyMapping[$key]; + + if (is_scalar($this->parameterKeyMapping[$key])) { + $params[$paramKey] = $variable; + } else { + foreach ($paramKey as $currentKey) { + $params[$currentKey] = $variable; + } + } } else { $params[] = $variable; } From 5c456c62ece338827d478959ad3a3ba04a73efd5 Mon Sep 17 00:00:00 2001 From: "Nicholas K. Dionysopoulos" Date: Thu, 16 May 2024 14:10:31 +0300 Subject: [PATCH 4/4] Add encryption option to the sqlsrv driver This is necessary if you want to use the driver with Microsoft SQL Server for Linux, i.e. what you get when using Docker (even under Windows). --- src/Sqlsrv/SqlsrvDriver.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Sqlsrv/SqlsrvDriver.php b/src/Sqlsrv/SqlsrvDriver.php index 43a98874..087941d4 100644 --- a/src/Sqlsrv/SqlsrvDriver.php +++ b/src/Sqlsrv/SqlsrvDriver.php @@ -88,6 +88,7 @@ public function __construct(array $options) $options['password'] = $options['password'] ?? ''; $options['database'] = $options['database'] ?? ''; $options['select'] = isset($options['select']) ? (bool) $options['select'] : true; + $options['encrypt'] = isset($options['encrypt']) ? (bool) $options['encrypt'] : true; // Finalize initialisation parent::__construct($options); @@ -119,6 +120,7 @@ public function connect() 'pwd' => $this->options['password'], 'CharacterSet' => 'UTF-8', 'ReturnDatesAsStrings' => true, + 'Encrypt' => $this->options['encrypt'] ]; // Attempt to connect to the server.