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/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..2a48b1d5 --- /dev/null +++ b/Tests/Sqlsrv/SqlsrvPreparedStatementTest.php @@ -0,0 +1,169 @@ +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 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. + * + * @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/src/Mysqli/MysqliStatement.php b/src/Mysqli/MysqliStatement.php index f5eb05c7..e31dfd53 100644 --- a/src/Mysqli/MysqliStatement.php +++ b/src/Mysqli/MysqliStatement.php @@ -197,8 +197,14 @@ 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]]]; + $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 +377,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) { 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. 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; }