diff --git a/psalm.xml b/psalm.xml index 2d8a65dc940..8ce2b9b9b11 100644 --- a/psalm.xml +++ b/psalm.xml @@ -75,14 +75,6 @@ - - - - - - diff --git a/src/ArrayParameters/Exception.php b/src/ArrayParameters/Exception.php new file mode 100644 index 00000000000..cce84b25bf0 --- /dev/null +++ b/src/ArrayParameters/Exception.php @@ -0,0 +1,12 @@ + 0) { - [$sql, $params, $types] = SQLParserUtils::expandListParameters($sql, $params, $types); + if ($this->needsArrayParameterConversion($params, $types)) { + [$sql, $params, $types] = $this->expandArrayParameters($sql, $params, $types); + } $stmt = $connection->prepare($sql); if (count($types) > 0) { @@ -1118,7 +1124,9 @@ public function executeStatement($sql, array $params = [], array $types = []) try { if (count($params) > 0) { - [$sql, $params, $types] = SQLParserUtils::expandListParameters($sql, $params, $types); + if ($this->needsArrayParameterConversion($params, $types)) { + [$sql, $params, $types] = $this->expandArrayParameters($sql, $params, $types); + } $stmt = $connection->prepare($sql); @@ -1581,13 +1589,11 @@ private function _bindTypedValues(DriverStatement $stmt, array $params, array $t { // Check whether parameters are positional or named. Mixing is not allowed. if (is_int(key($params))) { - // Positional parameters - $typeOffset = array_key_exists(0, $types) ? -1 : 0; - $bindIndex = 1; - foreach ($params as $value) { - $typeIndex = $bindIndex + $typeOffset; - if (isset($types[$typeIndex])) { - $type = $types[$typeIndex]; + $bindIndex = 1; + + foreach ($params as $key => $value) { + if (isset($types[$key])) { + $type = $types[$key]; [$value, $bindingType] = $this->getBindingInfo($value, $type); $stmt->bindValue($bindIndex, $value, $bindingType); } else { @@ -1669,6 +1675,48 @@ final public function convertException(Driver\Exception $e): DriverException return $this->handleDriverException($e, null); } + /** + * @param array|array $params + * @param array|array $types + * + * @return array{string, list, array} + */ + private function expandArrayParameters(string $sql, array $params, array $types): array + { + if ($this->parser === null) { + $this->parser = $this->getDatabasePlatform()->createSQLParser(); + } + + $visitor = new ExpandArrayParameters($params, $types); + + $this->parser->parse($sql, $visitor); + + return [ + $visitor->getSQL(), + $visitor->getParameters(), + $visitor->getTypes(), + ]; + } + + /** + * @param array|array $params + * @param array|array $types + */ + private function needsArrayParameterConversion(array $params, array $types): bool + { + if (is_string(key($params))) { + return true; + } + + foreach ($types as $type) { + if ($type === self::PARAM_INT_ARRAY || $type === self::PARAM_STR_ARRAY) { + return true; + } + } + + return false; + } + private function handleDriverException( Driver\Exception $driverException, ?Query $query diff --git a/src/Driver/OCI8/ConvertPositionalToNamedPlaceholders.php b/src/Driver/OCI8/ConvertPositionalToNamedPlaceholders.php index ba09397f329..483879d4726 100644 --- a/src/Driver/OCI8/ConvertPositionalToNamedPlaceholders.php +++ b/src/Driver/OCI8/ConvertPositionalToNamedPlaceholders.php @@ -1,164 +1,58 @@ ). * * Oracle does not support positional parameters, hence this method converts all - * positional parameters into artificially named parameters. Note that this conversion - * is not perfect. All question marks (?) in the original statement are treated as - * placeholders and converted to a named parameter. + * positional parameters into artificially named parameters. * * @internal This class is not covered by the backward compatibility promise */ -final class ConvertPositionalToNamedPlaceholders +final class ConvertPositionalToNamedPlaceholders implements Visitor { - /** - * @param string $statement The SQL statement to convert. - * - * @return mixed[] [0] => the statement value (string), [1] => the paramMap value (array). - * - * @throws Exception - */ - public function __invoke(string $statement): array - { - $fragmentOffset = $tokenOffset = 0; - $fragments = $paramMap = []; - $currentLiteralDelimiter = null; - - do { - if ($currentLiteralDelimiter === null) { - $result = $this->findPlaceholderOrOpeningQuote( - $statement, - $tokenOffset, - $fragmentOffset, - $fragments, - $currentLiteralDelimiter, - $paramMap - ); - } else { - $result = $this->findClosingQuote($statement, $tokenOffset, $currentLiteralDelimiter); - } - } while ($result); + /** @var list */ + private $buffer = []; - if ($currentLiteralDelimiter !== null) { - throw NonTerminatedStringLiteral::new($tokenOffset - 1); - } + /** @var array */ + private $parameterMap = []; - $fragments[] = substr($statement, $fragmentOffset); - $statement = implode('', $fragments); - - return [$statement, $paramMap]; + public function acceptOther(string $sql): void + { + $this->buffer[] = $sql; } - /** - * Finds next placeholder or opening quote. - * - * @param string $statement The SQL statement to parse - * @param int $tokenOffset The offset to start searching from - * @param int $fragmentOffset The offset to build the next fragment from - * @param string[] $fragments Fragments of the original statement not containing placeholders - * @param string|null $currentLiteralDelimiter The delimiter of the current string literal - * or NULL if not currently in a literal - * @param string[] $paramMap Mapping of the original parameter positions - * to their named replacements - * - * @return bool Whether the token was found - */ - private function findPlaceholderOrOpeningQuote( - string $statement, - int &$tokenOffset, - int &$fragmentOffset, - array &$fragments, - ?string &$currentLiteralDelimiter, - array &$paramMap - ): bool { - $token = $this->findToken($statement, $tokenOffset, '/[?\'"]/'); - - if ($token === null) { - return false; - } - - if ($token === '?') { - $position = count($paramMap) + 1; - $param = ':param' . $position; - $fragments[] = substr($statement, $fragmentOffset, $tokenOffset - $fragmentOffset); - $fragments[] = $param; - $paramMap[$position] = $param; - $tokenOffset += 1; - $fragmentOffset = $tokenOffset; - - return true; - } + public function acceptPositionalParameter(string $sql): void + { + $position = count($this->parameterMap) + 1; + $param = ':param' . $position; - $currentLiteralDelimiter = $token; - ++$tokenOffset; + $this->parameterMap[$position] = $param; - return true; + $this->buffer[] = $param; } - /** - * Finds closing quote - * - * @param string $statement The SQL statement to parse - * @param int $tokenOffset The offset to start searching from - * @param string $currentLiteralDelimiter The delimiter of the current string literal - * - * @return bool Whether the token was found - */ - private function findClosingQuote( - string $statement, - int &$tokenOffset, - string &$currentLiteralDelimiter - ): bool { - $token = $this->findToken( - $statement, - $tokenOffset, - '/' . preg_quote($currentLiteralDelimiter, '/') . '/' - ); - - if ($token === null) { - return false; - } - - $currentLiteralDelimiter = null; - ++$tokenOffset; + public function acceptNamedParameter(string $sql): void + { + $this->buffer[] = $sql; + } - return true; + public function getSQL(): string + { + return implode('', $this->buffer); } /** - * Finds the token described by regex starting from the given offset. Updates the offset with the position - * where the token was found. - * - * @param string $statement The SQL statement to parse - * @param int $offset The offset to start searching from - * @param string $regex The regex containing token pattern - * - * @return string|null Token or NULL if not found + * @return array */ - private function findToken(string $statement, int &$offset, string $regex): ?string + public function getParameterMap(): array { - if (preg_match($regex, $statement, $matches, PREG_OFFSET_CAPTURE, $offset) === 1) { - $offset = $matches[0][1]; - - return $matches[0][0]; - } - - return null; + return $this->parameterMap; } } diff --git a/src/Driver/OCI8/Statement.php b/src/Driver/OCI8/Statement.php index 5257eb5453e..a1621c7ed1a 100644 --- a/src/Driver/OCI8/Statement.php +++ b/src/Driver/OCI8/Statement.php @@ -8,6 +8,7 @@ use Doctrine\DBAL\Driver\Result as ResultInterface; use Doctrine\DBAL\Driver\Statement as StatementInterface; use Doctrine\DBAL\ParameterType; +use Doctrine\DBAL\SQL\Parser; use function assert; use function is_int; @@ -60,14 +61,17 @@ final class Statement implements StatementInterface */ public function __construct($dbh, $query, ExecutionMode $executionMode) { - [$query, $paramMap] = (new ConvertPositionalToNamedPlaceholders())($query); + $parser = new Parser(false); + $visitor = new ConvertPositionalToNamedPlaceholders(); - $stmt = oci_parse($dbh, $query); + $parser->parse($query, $visitor); + + $stmt = oci_parse($dbh, $visitor->getSQL()); assert(is_resource($stmt)); $this->_sth = $stmt; $this->_dbh = $dbh; - $this->_paramMap = $paramMap; + $this->_paramMap = $visitor->getParameterMap(); $this->executionMode = $executionMode; } diff --git a/src/ExpandArrayParameters.php b/src/ExpandArrayParameters.php new file mode 100644 index 00000000000..063fd3f916f --- /dev/null +++ b/src/ExpandArrayParameters.php @@ -0,0 +1,143 @@ +|array */ + private $originalParameters; + + /** @var array|array */ + private $originalTypes; + + /** @var int */ + private $originalParameterIndex = 0; + + /** @var list */ + private $convertedSQL = []; + + /** @var list */ + private $convertedParameteres = []; + + /** @var array */ + private $convertedTypes = []; + + /** + * @param array|array $parameters + * @param array|array $types + */ + public function __construct(array $parameters, array $types) + { + $this->originalParameters = $parameters; + $this->originalTypes = $types; + } + + public function acceptPositionalParameter(string $sql): void + { + $index = $this->originalParameterIndex; + + if (! array_key_exists($index, $this->originalParameters)) { + throw MissingPositionalParameter::new($index); + } + + $this->acceptParameter($index, $this->originalParameters[$index]); + + $this->originalParameterIndex++; + } + + public function acceptNamedParameter(string $sql): void + { + $name = substr($sql, 1); + + if (! array_key_exists($name, $this->originalParameters)) { + throw MissingNamedParameter::new($name); + } + + $this->acceptParameter($name, $this->originalParameters[$name]); + } + + public function acceptOther(string $sql): void + { + $this->convertedSQL[] = $sql; + } + + public function getSQL(): string + { + return implode('', $this->convertedSQL); + } + + /** + * @return list + */ + public function getParameters(): array + { + return $this->convertedParameteres; + } + + /** + * @param int|string $key + * @param mixed $value + */ + private function acceptParameter($key, $value): void + { + if (! isset($this->originalTypes[$key])) { + $this->convertedSQL[] = '?'; + $this->convertedParameteres[] = $value; + + return; + } + + $type = $this->originalTypes[$key]; + + if ($type !== Connection::PARAM_INT_ARRAY && $type !== Connection::PARAM_STR_ARRAY) { + $this->appendTypedParameter([$value], $type); + + return; + } + + if (count($value) === 0) { + $this->convertedSQL[] = 'NULL'; + + return; + } + + $this->appendTypedParameter($value, $type - Connection::ARRAY_PARAM_OFFSET); + } + + /** + * @return array + */ + public function getTypes(): array + { + return $this->convertedTypes; + } + + /** + * @param list $values + * @param Type|int|string|null $type + */ + private function appendTypedParameter(array $values, $type): void + { + $this->convertedSQL[] = implode(', ', array_fill(0, count($values), '?')); + + $index = count($this->convertedParameteres); + + foreach ($values as $value) { + $this->convertedParameteres[] = $value; + $this->convertedTypes[$index] = $type; + + $index++; + } + } +} diff --git a/src/Platforms/AbstractPlatform.php b/src/Platforms/AbstractPlatform.php index b0dcef25521..6ef2831958e 100644 --- a/src/Platforms/AbstractPlatform.php +++ b/src/Platforms/AbstractPlatform.php @@ -24,6 +24,7 @@ use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Schema\TableDiff; use Doctrine\DBAL\Schema\UniqueConstraint; +use Doctrine\DBAL\SQL\Parser; use Doctrine\DBAL\TransactionIsolationLevel; use Doctrine\DBAL\Types; use Doctrine\DBAL\Types\Type; @@ -3543,6 +3544,14 @@ final public function escapeStringForLike(string $inputString, string $escapeCha ); } + /** + * @internal + */ + public function createSQLParser(): Parser + { + return new Parser(false); + } + protected function getLikeWildcardCharacters(): string { return '%_'; diff --git a/src/Platforms/MySQL57Platform.php b/src/Platforms/MySQL57Platform.php index 669cac6e718..a3ec50441e4 100644 --- a/src/Platforms/MySQL57Platform.php +++ b/src/Platforms/MySQL57Platform.php @@ -4,6 +4,7 @@ use Doctrine\DBAL\Schema\Index; use Doctrine\DBAL\Schema\TableDiff; +use Doctrine\DBAL\SQL\Parser; use Doctrine\DBAL\Types\Types; /** @@ -27,6 +28,11 @@ public function getJsonTypeDeclarationSQL(array $column) return 'JSON'; } + public function createSQLParser(): Parser + { + return new Parser(true); + } + /** * {@inheritdoc} */ diff --git a/src/SQL/Parser.php b/src/SQL/Parser.php new file mode 100644 index 00000000000..32d306ca0f1 --- /dev/null +++ b/src/SQL/Parser.php @@ -0,0 +1,117 @@ +getMySQLStringLiteralPattern("'"), + $this->getMySQLStringLiteralPattern('"'), + ]; + } else { + $patterns = [ + $this->getAnsiSQLStringLiteralPattern("'"), + $this->getAnsiSQLStringLiteralPattern('"'), + ]; + } + + $patterns = array_merge($patterns, [ + self::BACKTICK_IDENTIFIER, + self::BRACKET_IDENTIFIER, + self::MULTICHAR, + self::ONE_LINE_COMMENT, + self::MULTI_LINE_COMMENT, + self::OTHER, + ]); + + $this->sqlPattern = sprintf('(%s)+', implode('|', $patterns)); + } + + /** + * Parses the given SQL statement + */ + public function parse(string $sql, Visitor $visitor): void + { + /** @var array $patterns */ + $patterns = [ + self::NAMED_PARAMETER => static function (string $sql) use ($visitor): void { + $visitor->acceptNamedParameter($sql); + }, + self::POSITIONAL_PARAMETER => static function (string $sql) use ($visitor): void { + $visitor->acceptPositionalParameter($sql); + }, + $this->sqlPattern => static function (string $sql) use ($visitor): void { + $visitor->acceptOther($sql); + }, + self::SPECIAL => static function (string $sql) use ($visitor): void { + $visitor->acceptOther($sql); + }, + ]; + + $offset = 0; + + while (($handler = current($patterns)) !== false) { + if (preg_match('~\G' . key($patterns) . '~s', $sql, $matches, 0, $offset) === 1) { + $handler($matches[0]); + reset($patterns); + + $offset += strlen($matches[0]); + } else { + next($patterns); + } + } + + assert($offset === strlen($sql)); + } + + private function getMySQLStringLiteralPattern(string $delimiter): string + { + return $delimiter . '((\\\\' . self::ANY . ')|(?![' . $delimiter . '\\\\])' . self::ANY . ')*' . $delimiter; + } + + private function getAnsiSQLStringLiteralPattern(string $delimiter): string + { + return $delimiter . '[^' . $delimiter . ']*' . $delimiter; + } +} diff --git a/src/SQL/Parser/Visitor.php b/src/SQL/Parser/Visitor.php new file mode 100644 index 00000000000..574ba1bf166 --- /dev/null +++ b/src/SQL/Parser/Visitor.php @@ -0,0 +1,26 @@ + - */ - private static function getPositionalPlaceholderPositions(string $statement): array - { - return self::collectPlaceholders( - $statement, - '?', - self::POSITIONAL_TOKEN, - static function (string $_, int $placeholderPosition, int $fragmentPosition, array &$carry): void { - $carry[] = $placeholderPosition + $fragmentPosition; - } - ); - } - - /** - * Returns a map of placeholder positions to their parameter names. - * - * @return array - */ - private static function getNamedPlaceholderPositions(string $statement): array - { - return self::collectPlaceholders( - $statement, - ':', - self::NAMED_TOKEN, - static function ( - string $placeholder, - int $placeholderPosition, - int $fragmentPosition, - array &$carry - ): void { - $carry[$placeholderPosition + $fragmentPosition] = substr($placeholder, 1); - } - ); - } - - /** - * @return mixed[] - */ - private static function collectPlaceholders( - string $statement, - string $match, - string $token, - callable $collector - ): array { - if (strpos($statement, $match) === false) { - return []; - } - - $carry = []; - - foreach (self::getUnquotedStatementFragments($statement) as $fragment) { - preg_match_all('/' . $token . '/', $fragment[0], $matches, PREG_OFFSET_CAPTURE); - foreach ($matches[0] as $placeholder) { - $collector($placeholder[0], $placeholder[1], $fragment[1], $carry); - } - } - - return $carry; - } - - /** - * For a positional query this method can rewrite the sql statement with regard to array parameters. - * - * @param string $query SQL query - * @param mixed[] $params Query parameters - * @param array|array $types Parameter types - * - * @return mixed[] - * - * @throws SQLParserUtilsException - */ - public static function expandListParameters($query, $params, $types) - { - $isPositional = is_int(key($params)); - $arrayPositions = []; - $bindIndex = -1; - - if ($isPositional) { - // make sure that $types has the same keys as $params - // to allow omitting parameters with unspecified types - $types += array_fill_keys(array_keys($params), null); - - ksort($params); - ksort($types); - } - - foreach ($types as $name => $type) { - ++$bindIndex; - - if ($type !== Connection::PARAM_INT_ARRAY && $type !== Connection::PARAM_STR_ARRAY) { - continue; - } - - if ($isPositional) { - $name = $bindIndex; - } - - $arrayPositions[$name] = false; - } - - if ($isPositional && count($arrayPositions) === 0) { - return [$query, $params, $types]; - } - - if ($isPositional) { - $paramOffset = 0; - $queryOffset = 0; - $params = array_values($params); - $types = array_values($types); - - $paramPos = self::getPositionalPlaceholderPositions($query); - - foreach ($paramPos as $needle => $needlePos) { - if (! isset($arrayPositions[$needle])) { - continue; - } - - $needle += $paramOffset; - $needlePos += $queryOffset; - $count = count($params[$needle]); - - $params = array_merge( - array_slice($params, 0, $needle), - $params[$needle], - array_slice($params, $needle + 1) - ); - - $types = array_merge( - array_slice($types, 0, $needle), - $count > 0 ? - // array needles are at {@link \Doctrine\DBAL\ParameterType} constants - // + {@link \Doctrine\DBAL\Connection::ARRAY_PARAM_OFFSET} - array_fill(0, $count, $types[$needle] - Connection::ARRAY_PARAM_OFFSET) : - [], - array_slice($types, $needle + 1) - ); - - $expandStr = $count > 0 ? implode(', ', array_fill(0, $count, '?')) : 'NULL'; - $query = substr($query, 0, $needlePos) . $expandStr . substr($query, $needlePos + 1); - - $paramOffset += $count - 1; // Grows larger by number of parameters minus the replaced needle. - $queryOffset += strlen($expandStr) - 1; - } - - return [$query, $params, $types]; - } - - $queryOffset = 0; - $typesOrd = []; - $paramsOrd = []; - - $paramPos = self::getNamedPlaceholderPositions($query); - - foreach ($paramPos as $pos => $paramName) { - $paramLen = strlen($paramName) + 1; - $value = static::extractParam($paramName, $params, true); - - if (! isset($arrayPositions[$paramName]) && ! isset($arrayPositions[':' . $paramName])) { - $pos += $queryOffset; - $queryOffset -= $paramLen - 1; - $paramsOrd[] = $value; - $typesOrd[] = static::extractParam($paramName, $types, false, ParameterType::STRING); - $query = substr($query, 0, $pos) . '?' . substr($query, $pos + $paramLen); - - continue; - } - - $count = count($value); - $expandStr = $count > 0 ? implode(', ', array_fill(0, $count, '?')) : 'NULL'; - - foreach ($value as $val) { - $paramsOrd[] = $val; - $typesOrd[] = static::extractParam($paramName, $types, false) - Connection::ARRAY_PARAM_OFFSET; - } - - $pos += $queryOffset; - $queryOffset += strlen($expandStr) - $paramLen; - $query = substr($query, 0, $pos) . $expandStr . substr($query, $pos + $paramLen); - } - - return [$query, $paramsOrd, $typesOrd]; - } - - /** - * Slice the SQL statement around pairs of quotes and - * return string fragments of SQL outside of quoted literals. - * Each fragment is captured as a 2-element array: - * - * 0 => matched fragment string, - * 1 => offset of fragment in $statement - * - * @param string $statement - * - * @return mixed[][] - */ - private static function getUnquotedStatementFragments($statement) - { - $literal = self::ESCAPED_SINGLE_QUOTED_TEXT . '|' . - self::ESCAPED_DOUBLE_QUOTED_TEXT . '|' . - self::ESCAPED_BACKTICK_QUOTED_TEXT . '|' . - self::ESCAPED_BRACKET_QUOTED_TEXT; - $expression = sprintf('/((.+(?i:ARRAY)\\[.+\\])|([^\'"`\\[]+))(?:%s)?/s', $literal); - - preg_match_all($expression, $statement, $fragments, PREG_OFFSET_CAPTURE); - - return $fragments[1]; - } - - /** - * @param string $paramName The name of the parameter (without a colon in front) - * @param mixed $paramsOrTypes A hash of parameters or types - * @param bool $isParam - * @param mixed $defaultValue An optional default value. If omitted, an exception is thrown - * - * @return mixed - * - * @throws SQLParserUtilsException - */ - private static function extractParam($paramName, $paramsOrTypes, $isParam, $defaultValue = null) - { - if (array_key_exists($paramName, $paramsOrTypes)) { - return $paramsOrTypes[$paramName]; - } - - if ($defaultValue !== null) { - return $defaultValue; - } - - if ($isParam) { - throw SQLParserUtilsException::missingParam($paramName); - } - - throw SQLParserUtilsException::missingType($paramName); - } -} diff --git a/src/SQLParserUtilsException.php b/src/SQLParserUtilsException.php deleted file mode 100644 index 297b0761b8d..00000000000 --- a/src/SQLParserUtilsException.php +++ /dev/null @@ -1,37 +0,0 @@ - 42, 0 => 30]], // explicit keys - ]; - } - - /** - * @return mixed[][] - */ - public function dataGetPlaceholderNamedPositions(): iterable - { - return [ - // none - ['SELECT * FROM Foo', []], - - // named - ['SELECT :foo FROM :bar', [7 => 'foo', 17 => 'bar']], - ['SELECT * FROM Foo WHERE bar IN (:name1, :name2)', [32 => 'name1', 40 => 'name2']], - ['SELECT ":foo" FROM Foo WHERE bar IN (:name1, :name2)', [37 => 'name1', 45 => 'name2']], - ["SELECT ':foo' FROM Foo WHERE bar IN (:name1, :name2)", [37 => 'name1', 45 => 'name2']], - ['SELECT :foo_id', [7 => 'foo_id']], // Ticket DBAL-231 - ['SELECT @rank := 1', []], // Ticket DBAL-398 - ['SELECT @rank := 1 AS rank, :foo AS foo FROM :bar', [27 => 'foo', 44 => 'bar']], // Ticket DBAL-398 - // Ticket GH-113 - [ - 'SELECT * FROM Foo WHERE bar > :start_date AND baz > :start_date', - [ - 30 => 'start_date', - 52 => 'start_date', - ], - ], - - // Ticket GH-259 - [ - 'SELECT foo::date as date FROM Foo WHERE bar > :start_date AND baz > :start_date', - [ - 46 => 'start_date', - 68 => 'start_date', - ], - ], - - // Ticket DBAL-552 - [ - 'SELECT `d.ns:col_name` FROM my_table d WHERE `d.date` >= :param1', - [57 => 'param1'], - ], - - // Ticket DBAL-552 - ['SELECT [d.ns:col_name] FROM my_table d WHERE [d.date] >= :param1', [57 => 'param1']], - ['SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, ARRAY[:foo])', [56 => 'foo']], // Ticket GH-2295 - ['SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, array[:foo])', [56 => 'foo']],[ - 'SELECT table.column1, ARRAY[\'3\'] FROM schema.table table WHERE table.f1 = :foo AND ARRAY[\'3\']', - [74 => 'foo'], - ], - [ - 'SELECT table.column1, ARRAY[\'3\']::integer[] FROM schema.table table' - . ' WHERE table.f1 = :foo AND ARRAY[\'3\']::integer[]', - [85 => 'foo'], - ], - [ - 'SELECT table.column1, ARRAY[:foo] FROM schema.table table WHERE table.f1 = :bar AND ARRAY[\'3\']', - [ - 28 => 'foo', - 75 => 'bar', - ], - ], - [ - 'SELECT table.column1, ARRAY[:foo]::integer[] FROM schema.table table' - . ' WHERE table.f1 = :bar AND ARRAY[\'3\']::integer[]', - [ - 28 => 'foo', - 86 => 'bar', - ], - ], - [ - <<<'SQLDATA' -SELECT * FROM foo WHERE -bar = ':not_a_param1 ''":not_a_param2"''' -OR bar=:a_param1 -OR bar=:a_param2||':not_a_param3' -OR bar=':not_a_param4 '':not_a_param5'' :not_a_param6' -OR bar='' -OR bar=:a_param3 -SQLDATA -, - [ - 74 => 'a_param1', - 91 => 'a_param2', - 190 => 'a_param3', - ], - ], - [ - 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data' - . " WHERE (data.description LIKE :condition_0 ESCAPE '\\\\')" - . " AND (data.description LIKE :condition_1 ESCAPE '\\\\') ORDER BY id ASC", - [ - 121 => 'condition_0', - 174 => 'condition_1', - ], - ], - [ - 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data' - . ' WHERE (data.description LIKE :condition_0 ESCAPE "\\\\")' - . ' AND (data.description LIKE :condition_1 ESCAPE "\\\\") ORDER BY id ASC', - [ - 121 => 'condition_0', - 174 => 'condition_1', - ], - ], - [ - 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data' - . ' WHERE (data.description LIKE :condition_0 ESCAPE "\\\\")' - . ' AND (data.description LIKE :condition_1 ESCAPE \'\\\\\') ORDER BY id ASC', - [ - 121 => 'condition_0', - 174 => 'condition_1', - ], - ], - [ - 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data' - . ' WHERE (data.description LIKE :condition_0 ESCAPE `\\\\`)' - . ' AND (data.description LIKE :condition_1 ESCAPE `\\\\`) ORDER BY id ASC', - [ - 121 => 'condition_0', - 174 => 'condition_1', - ], - ], - [ - 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data' - . ' WHERE (data.description LIKE :condition_0 ESCAPE \'\\\\\')' - . ' AND (data.description LIKE :condition_1 ESCAPE `\\\\`) ORDER BY id ASC', - [ - 121 => 'condition_0', - 174 => 'condition_1', - ], - ], - [ - "SELECT * FROM Foo WHERE (foo.bar LIKE :condition_0 ESCAPE '\')" - . " AND (foo.baz = :condition_1) AND (foo.bak LIKE :condition_2 ESCAPE '\')", - [ - 38 => 'condition_0', - 78 => 'condition_1', - 110 => 'condition_2', - ], - ], - ]; - } - - /** - * @param int[] $expectedParamPos - * - * @dataProvider dataGetPlaceholderPositionalPositions - */ - public function testGetPositionalPlaceholderPositions(string $query, array $expectedParamPos): void - { - $reflection = new ReflectionMethod(SQLParserUtils::class, 'getPositionalPlaceholderPositions'); - $reflection->setAccessible(true); - - self::assertEquals($expectedParamPos, $reflection->invoke(null, $query)); - } - - /** - * @param string[] $expectedParamPos - * - * @dataProvider dataGetPlaceholderNamedPositions - */ - public function testGetNamedPlaceholderPositions(string $query, array $expectedParamPos): void - { - $reflection = new ReflectionMethod(SQLParserUtils::class, 'getNamedPlaceholderPositions'); - $reflection->setAccessible(true); - - self::assertEquals($expectedParamPos, $reflection->invoke(null, $query)); - } - /** * @return mixed[][] */ @@ -301,7 +93,7 @@ public static function dataExpandListParameters(): iterable [2 => ParameterType::STRING, 1 => ParameterType::STRING], 'SELECT * FROM Foo WHERE foo = ? AND bar = ? AND baz = ?', [1 => 'bar', 0 => 1, 2 => 'baz'], - [1 => ParameterType::STRING, 2 => ParameterType::STRING, 0 => null], + [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 = ?', @@ -318,27 +110,6 @@ public static function dataExpandListParameters(): iterable ParameterType::BOOLEAN, ], ], - 'Positional starts from 1: One non-list before and one after list-needle' => [ - 'SELECT * FROM Foo WHERE foo = ? AND bar IN (?) AND baz = ? AND foo IN (?)', - [1 => 1, 2 => [1, 2, 3], 3 => 4, 4 => [5, 6]], - [ - 1 => ParameterType::INTEGER, - 2 => Connection::PARAM_INT_ARRAY, - 3 => ParameterType::INTEGER, - 4 => Connection::PARAM_INT_ARRAY, - ], - 'SELECT * FROM Foo WHERE foo = ? AND bar IN (?, ?, ?) AND baz = ? AND foo IN (?, ?)', - [1, 1, 2, 3, 4, 5, 6], - [ - ParameterType::INTEGER, - ParameterType::INTEGER, - ParameterType::INTEGER, - ParameterType::INTEGER, - ParameterType::INTEGER, - ParameterType::INTEGER, - ParameterType::INTEGER, - ], - ], 'Named: Very simple with param int' => [ 'SELECT * FROM Foo WHERE foo = :foo', ['foo' => 1], @@ -464,7 +235,11 @@ public static function dataExpandListParameters(): iterable ['foo' => Connection::PARAM_INT_ARRAY, 'baz' => 'string'], 'SELECT * FROM Foo WHERE foo IN (?, ?) OR bar = ? OR baz = ?', [1, 2, 'bar', 'baz'], - [ParameterType::INTEGER, ParameterType::INTEGER, ParameterType::STRING, 'string'], + [ + 0 => ParameterType::INTEGER, + 1 => ParameterType::INTEGER, + 3 => 'string', + ], ], [ 'SELECT * FROM Foo WHERE foo IN (:foo) OR bar = :bar', @@ -472,7 +247,7 @@ public static function dataExpandListParameters(): iterable ['foo' => Connection::PARAM_INT_ARRAY], 'SELECT * FROM Foo WHERE foo IN (?, ?) OR bar = ?', [1, 2, 'bar'], - [ParameterType::INTEGER, ParameterType::INTEGER, ParameterType::STRING], + [ParameterType::INTEGER, ParameterType::INTEGER], ], 'Named parameters and partially implicit types' => [ 'SELECT * FROM Foo WHERE foo = :foo OR bar = :bar', @@ -480,7 +255,7 @@ public static function dataExpandListParameters(): iterable ['foo' => ParameterType::INTEGER], 'SELECT * FROM Foo WHERE foo = ? OR bar = ?', ['foo', 'bar'], - [ParameterType::INTEGER, ParameterType::STRING], + [ParameterType::INTEGER], ], 'Named parameters and explicit types' => [ 'SELECT * FROM Foo WHERE foo = :foo OR bar = :bar', @@ -520,16 +295,16 @@ public static function dataExpandListParameters(): iterable [1 => Connection::PARAM_STR_ARRAY], 'SELECT NULL FROM dummy WHERE ? IN (?, ?)', ['foo', 'bar', 'baz'], - [null, ParameterType::STRING, ParameterType::STRING], + [1 => ParameterType::STRING, ParameterType::STRING], ], ]; } /** - * @param mixed[] $params - * @param mixed[] $types - * @param mixed[] $expectedParams - * @param mixed[] $expectedTypes + * @param array|array $params + * @param array|array $types + * @param list $expectedParams + * @param array $expectedTypes * * @dataProvider dataExpandListParameters */ @@ -541,7 +316,7 @@ public function testExpandListParameters( array $expectedParams, array $expectedTypes ): void { - [$query, $params, $types] = SQLParserUtils::expandListParameters($query, $params, $types); + [$query, $params, $types] = $this->expandArrayParameters($query, $params, $types); self::assertEquals($expectedQuery, $query, 'Query was not rewritten correctly.'); self::assertEquals($expectedParams, $params, 'Params dont match'); @@ -551,7 +326,7 @@ public function testExpandListParameters( /** * @return mixed[][] */ - public static function dataQueryWithMissingParameters(): iterable + public static function missingNamedParameterProvider(): iterable { return [ [ @@ -564,16 +339,6 @@ public static function dataQueryWithMissingParameters(): iterable [], [], ], - [ - 'SELECT * FROM foo WHERE bar = :param', - [], - ['param' => Connection::PARAM_INT_ARRAY], - ], - [ - 'SELECT * FROM foo WHERE bar = :param', - [], - [':param' => Connection::PARAM_INT_ARRAY], - ], [ 'SELECT * FROM foo WHERE bar = :param', [], @@ -588,16 +353,64 @@ public static function dataQueryWithMissingParameters(): iterable } /** - * @param mixed[] $params - * @param mixed[] $types + * @param array|array $params + * @param array|array $types + * + * @dataProvider missingNamedParameterProvider + */ + public function testMissingNamedParameter(string $query, array $params, array $types = []): void + { + $this->expectException(MissingNamedParameter::class); + + $this->expandArrayParameters($query, $params, $types); + } + + /** + * @param array|array $params * - * @dataProvider dataQueryWithMissingParameters + * @dataProvider missingPositionalParameterProvider */ - public function testExceptionIsThrownForMissingParam(string $query, array $params, array $types = []): void + public function testMissingPositionalParameter(string $query, array $params): void { - $this->expectException(SQLParserUtilsException::class); - $this->expectExceptionMessage('Value for :param not found in params array. Params array key should be "param"'); + $this->expectException(MissingPositionalParameter::class); - SQLParserUtils::expandListParameters($query, $params, $types); + $this->expandArrayParameters($query, $params, []); + } + + /** + * @return mixed[][] + */ + public static function missingPositionalParameterProvider(): iterable + { + return [ + 'No parameters' => [ + 'SELECT * FROM foo WHERE bar = ?', + [], + ], + 'Too few parameters' => [ + 'SELECT * FROM foo WHERE bar = ? AND baz = ?', + [1], + ], + ]; + } + + /** + * @param array|array $params + * @param array|array $types + * + * @return array{0: string, 1: list, 2: array} + */ + private function expandArrayParameters(string $sql, array $params, array $types): array + { + $parser = new Parser(true); + $visitor = new ExpandArrayParameters($params, $types); + + $parser->parse($sql, $visitor); + + return [ + $visitor->getSQL(), + $visitor->getParameters(), + $visitor->getTypes(), + ]; } } diff --git a/tests/Driver/OCI8/ConvertPositionalToNamedPlaceholdersTest.php b/tests/Driver/OCI8/ConvertPositionalToNamedPlaceholdersTest.php index 17046373a7e..d3779a6419a 100644 --- a/tests/Driver/OCI8/ConvertPositionalToNamedPlaceholdersTest.php +++ b/tests/Driver/OCI8/ConvertPositionalToNamedPlaceholdersTest.php @@ -5,19 +5,11 @@ namespace Doctrine\Tests\DBAL\Driver\OCI8; use Doctrine\DBAL\Driver\OCI8\ConvertPositionalToNamedPlaceholders; -use Doctrine\DBAL\Driver\OCI8\Exception\NonTerminatedStringLiteral; +use Doctrine\DBAL\SQL\Parser; use PHPUnit\Framework\TestCase; class ConvertPositionalToNamedPlaceholdersTest extends TestCase { - /** @var ConvertPositionalToNamedPlaceholders */ - private $convertPositionalToNamedPlaceholders; - - protected function setUp(): void - { - $this->convertPositionalToNamedPlaceholders = new ConvertPositionalToNamedPlaceholders(); - } - /** * @param mixed[] $expectedOutputParamsMap * @@ -28,10 +20,13 @@ public function testConvertPositionalToNamedParameters( string $expectedOutputSQL, array $expectedOutputParamsMap ): void { - [$statement, $params] = ($this->convertPositionalToNamedPlaceholders)($inputSQL); + $parser = new Parser(false); + $visitor = new ConvertPositionalToNamedPlaceholders(); + + $parser->parse($inputSQL, $visitor); - self::assertEquals($expectedOutputSQL, $statement); - self::assertEquals($expectedOutputParamsMap, $params); + self::assertEquals($expectedOutputSQL, $visitor->getSQL()); + self::assertEquals($expectedOutputParamsMap, $visitor->getParameterMap()); } /** @@ -92,35 +87,4 @@ public static function positionalToNamedPlaceholdersProvider(): iterable ], ]; } - - /** - * @dataProvider nonTerminatedLiteralProvider - */ - public function testConvertNonTerminatedLiteral(string $sql, string $expectedExceptionMessageRegExp): void - { - $this->expectException(NonTerminatedStringLiteral::class); - $this->expectExceptionMessageMatches($expectedExceptionMessageRegExp); - ($this->convertPositionalToNamedPlaceholders)($sql); - } - - /** - * @return array> - */ - public static function nonTerminatedLiteralProvider(): iterable - { - return [ - 'no-matching-quote' => [ - "SELECT 'literal FROM DUAL", - '/offset 7./', - ], - 'no-matching-double-quote' => [ - 'SELECT 1 "COL1 FROM DUAL', - '/offset 9./', - ], - 'incorrect-escaping-syntax' => [ - "SELECT 'quoted \\'string' FROM DUAL", - '/offset 23./', - ], - ]; - } } diff --git a/tests/Functional/DataAccessTest.php b/tests/Functional/DataAccessTest.php index db109d56513..4e942b792ce 100644 --- a/tests/Functional/DataAccessTest.php +++ b/tests/Functional/DataAccessTest.php @@ -271,8 +271,8 @@ public function testExecuteQueryBindDateTimeType(): void { $value = $this->connection->fetchOne( 'SELECT count(*) AS c FROM fetch_table WHERE test_datetime = ?', - [1 => new DateTime('2010-01-01 10:10:10')], - [1 => Types::DATETIME_MUTABLE] + [new DateTime('2010-01-01 10:10:10')], + [Types::DATETIME_MUTABLE] ); self::assertEquals(1, $value); @@ -284,20 +284,20 @@ public function testExecuteStatementBindDateTimeType(): void $sql = 'INSERT INTO fetch_table (test_int, test_string, test_datetime) VALUES (?, ?, ?)'; $affectedRows = $this->connection->executeStatement($sql, [ - 1 => 50, - 2 => 'foo', - 3 => $datetime, + 50, + 'foo', + $datetime, ], [ - 1 => ParameterType::INTEGER, - 2 => ParameterType::STRING, - 3 => Types::DATETIME_MUTABLE, + ParameterType::INTEGER, + ParameterType::STRING, + Types::DATETIME_MUTABLE, ]); self::assertEquals(1, $affectedRows); self::assertEquals(1, $this->connection->executeQuery( 'SELECT count(*) AS c FROM fetch_table WHERE test_datetime = ?', - [1 => $datetime], - [1 => Types::DATETIME_MUTABLE] + [$datetime], + [Types::DATETIME_MUTABLE] )->fetchOne()); } diff --git a/tests/SQL/ParserTest.php b/tests/SQL/ParserTest.php new file mode 100644 index 00000000000..d7ca6930bc5 --- /dev/null +++ b/tests/SQL/ParserTest.php @@ -0,0 +1,463 @@ + */ + private $result = []; + + /** + * @dataProvider statementsWithParametersProvider + */ + public function testStatementsWithParameters(bool $mySQLStringEscaping, string $sql, string $expected): void + { + $parser = new Parser($mySQLStringEscaping); + $parser->parse($sql, $this); + + $this->assertParsed($expected); + } + + /** + * @return iterable> + */ + public static function statementsWithParametersProvider(): iterable + { + foreach (self::getModes() as $mode => $mySQLStringEscaping) { + foreach (self::getStatementsWithParameters() as $item => $arguments) { + yield sprintf('%s: %s', $mode, $item) => array_merge([$mySQLStringEscaping], $arguments); + } + } + } + + /** + * @return iterable> + */ + private static function getStatementsWithParameters(): iterable + { + yield [ + 'SELECT ?', + 'SELECT {?}', + ]; + + yield [ + 'SELECT * FROM Foo WHERE bar IN (?, ?, ?)', + 'SELECT * FROM Foo WHERE bar IN ({?}, {?}, {?})', + ]; + + yield [ + 'SELECT ? FROM ?', + 'SELECT {?} FROM {?}', + ]; + + yield [ + 'SELECT "?" FROM foo WHERE bar = ?', + 'SELECT "?" FROM foo WHERE bar = {?}', + ]; + + yield [ + "SELECT '?' FROM foo WHERE bar = ?", + "SELECT '?' FROM foo WHERE bar = {?}", + ]; + + yield [ + 'SELECT `?` FROM foo WHERE bar = ?', + 'SELECT `?` FROM foo WHERE bar = {?}', + ]; + + yield [ + 'SELECT [?] FROM foo WHERE bar = ?', + 'SELECT [?] FROM foo WHERE bar = {?}', + ]; + + yield [ + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, ARRAY[?])', + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, ARRAY[{?}])', + ]; + + yield [ + "SELECT 'Doctrine\DBAL?' FROM foo WHERE bar = ?", + "SELECT 'Doctrine\DBAL?' FROM foo WHERE bar = {?}", + ]; + + yield [ + 'SELECT "Doctrine\DBAL?" FROM foo WHERE bar = ?', + 'SELECT "Doctrine\DBAL?" FROM foo WHERE bar = {?}', + ]; + + yield [ + 'SELECT `Doctrine\DBAL?` FROM foo WHERE bar = ?', + 'SELECT `Doctrine\DBAL?` FROM foo WHERE bar = {?}', + ]; + + yield [ + 'SELECT [Doctrine\DBAL?] FROM foo WHERE bar = ?', + 'SELECT [Doctrine\DBAL?] FROM foo WHERE bar = {?}', + ]; + + yield [ + 'SELECT :foo FROM :bar', + 'SELECT {:foo} FROM {:bar}', + ]; + + yield [ + 'SELECT * FROM Foo WHERE bar IN (:name1, :name2)', + 'SELECT * FROM Foo WHERE bar IN ({:name1}, {:name2})', + ]; + + yield [ + 'SELECT ":foo" FROM Foo WHERE bar IN (:name1, :name2)', + 'SELECT ":foo" FROM Foo WHERE bar IN ({:name1}, {:name2})', + ]; + + yield [ + "SELECT ':foo' FROM Foo WHERE bar IN (:name1, :name2)", + "SELECT ':foo' FROM Foo WHERE bar IN ({:name1}, {:name2})", + ]; + + yield [ + 'SELECT :foo_id', + 'SELECT {:foo_id}', + ]; + + yield [ + 'SELECT @rank := 1 AS rank, :foo AS foo FROM :bar', + 'SELECT @rank := 1 AS rank, {:foo} AS foo FROM {:bar}', + ]; + + yield [ + 'SELECT * FROM Foo WHERE bar > :start_date AND baz > :start_date', + 'SELECT * FROM Foo WHERE bar > {:start_date} AND baz > {:start_date}', + ]; + + yield [ + 'SELECT foo::date as date FROM Foo WHERE bar > :start_date AND baz > :start_date', + 'SELECT foo::date as date FROM Foo WHERE bar > {:start_date} AND baz > {:start_date}', + ]; + + yield [ + 'SELECT `d.ns:col_name` FROM my_table d WHERE `d.date` >= :param1', + 'SELECT `d.ns:col_name` FROM my_table d WHERE `d.date` >= {:param1}', + ]; + + yield [ + 'SELECT [d.ns:col_name] FROM my_table d WHERE [d.date] >= :param1', + 'SELECT [d.ns:col_name] FROM my_table d WHERE [d.date] >= {:param1}', + ]; + + yield [ + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, ARRAY[:foo])', + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, ARRAY[{:foo}])', + ]; + + yield [ + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, array[:foo])', + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, array[{:foo}])', + ]; + + yield [ + "SELECT table.column1, ARRAY['3'] FROM schema.table table WHERE table.f1 = :foo AND ARRAY['3']", + "SELECT table.column1, ARRAY['3'] FROM schema.table table WHERE table.f1 = {:foo} AND ARRAY['3']", + ]; + + yield [ + "SELECT table.column1, ARRAY['3']::integer[] FROM schema.table table" + . " WHERE table.f1 = :foo AND ARRAY['3']::integer[]", + "SELECT table.column1, ARRAY['3']::integer[] FROM schema.table table" + . " WHERE table.f1 = {:foo} AND ARRAY['3']::integer[]", + ]; + + yield [ + "SELECT table.column1, ARRAY[:foo] FROM schema.table table WHERE table.f1 = :bar AND ARRAY['3']", + "SELECT table.column1, ARRAY[{:foo}] FROM schema.table table WHERE table.f1 = {:bar} AND ARRAY['3']", + ]; + + yield [ + 'SELECT table.column1, ARRAY[:foo]::integer[] FROM schema.table table' + . " WHERE table.f1 = :bar AND ARRAY['3']::integer[]", + 'SELECT table.column1, ARRAY[{:foo}]::integer[] FROM schema.table table' + . " WHERE table.f1 = {:bar} AND ARRAY['3']::integer[]", + ]; + + yield 'Quotes inside literals escaped by doubling' => [ + <<<'SQL' +SELECT * FROM foo +WHERE bar = ':not_a_param1 ''":not_a_param2"''' + OR bar=:a_param1 + OR bar=:a_param2||':not_a_param3' + OR bar=':not_a_param4 '':not_a_param5'' :not_a_param6' + OR bar='' + OR bar=:a_param3 +SQL +, + <<<'SQL' +SELECT * FROM foo +WHERE bar = ':not_a_param1 ''":not_a_param2"''' + OR bar={:a_param1} + OR bar={:a_param2}||':not_a_param3' + OR bar=':not_a_param4 '':not_a_param5'' :not_a_param6' + OR bar='' + OR bar={:a_param3} +SQL +, + ]; + + yield [ + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data' + . " WHERE (data.description LIKE :condition_0 ESCAPE '\\\\')" + . " AND (data.description LIKE :condition_1 ESCAPE '\\\\') ORDER BY id ASC", + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data' + . " WHERE (data.description LIKE {:condition_0} ESCAPE '\\\\')" + . " AND (data.description LIKE {:condition_1} ESCAPE '\\\\') ORDER BY id ASC", + ]; + + yield [ + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data' + . ' WHERE (data.description LIKE :condition_0 ESCAPE "\\\\")' + . ' AND (data.description LIKE :condition_1 ESCAPE "\\\\") ORDER BY id ASC', + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data' + . ' WHERE (data.description LIKE {:condition_0} ESCAPE "\\\\")' + . ' AND (data.description LIKE {:condition_1} ESCAPE "\\\\") ORDER BY id ASC', + ]; + + yield 'Combined single and double quotes' => [ + <<<'SQL' +SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id + FROM test_data data + WHERE (data.description LIKE :condition_0 ESCAPE "\\") + AND (data.description LIKE :condition_1 ESCAPE '\\') ORDER BY id ASC +SQL +, + <<<'SQL' +SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id + FROM test_data data + WHERE (data.description LIKE {:condition_0} ESCAPE "\\") + AND (data.description LIKE {:condition_1} ESCAPE '\\') ORDER BY id ASC +SQL +, + ]; + + yield [ + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data' + . ' WHERE (data.description LIKE :condition_0 ESCAPE `\\\\`)' + . ' AND (data.description LIKE :condition_1 ESCAPE `\\\\`) ORDER BY id ASC', + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data' + . ' WHERE (data.description LIKE {:condition_0} ESCAPE `\\\\`)' + . ' AND (data.description LIKE {:condition_1} ESCAPE `\\\\`) ORDER BY id ASC', + ]; + + yield 'Combined single quotes and backticks' => [ + <<<'SQL' +SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id + FROM test_data data + WHERE (data.description LIKE :condition_0 ESCAPE '\\') + AND (data.description LIKE :condition_1 ESCAPE `\\`) ORDER BY id ASC +SQL +, + <<<'SQL' +SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id + FROM test_data data + WHERE (data.description LIKE {:condition_0} ESCAPE '\\') + AND (data.description LIKE {:condition_1} ESCAPE `\\`) ORDER BY id ASC +SQL +, + ]; + + yield 'Placeholders inside comments' => [ + <<<'SQL' +/* + * test placeholder ? + */ +SELECT dummy as "dummy?" + FROM DUAL + WHERE '?' = '?' +-- AND dummy <> ? + AND dummy = ? +SQL +, + <<<'SQL' +/* + * test placeholder ? + */ +SELECT dummy as "dummy?" + FROM DUAL + WHERE '?' = '?' +-- AND dummy <> ? + AND dummy = {?} +SQL +, + ]; + } + + /** + * @dataProvider statementsWithoutParametersProvider + */ + public function testStatementsWithoutParameters(bool $mySQLStringEscaping, string $sql): void + { + $parser = new Parser($mySQLStringEscaping); + $parser->parse($sql, $this); + + $this->assertParsed($sql); + } + + /** + * @return iterable> + */ + public static function statementsWithoutParametersProvider(): iterable + { + foreach (self::getModes() as $mode => $mySQLStringEscaping) { + foreach (self::getStatementsWithoutParameters() as $sql) { + yield sprintf('%s: %s', $mode, $sql) => [$mySQLStringEscaping, $sql]; + } + } + } + + /** + * @return iterable + */ + private static function getStatementsWithoutParameters(): iterable + { + yield 'SELECT * FROM Foo'; + yield "SELECT '?' FROM foo"; + yield 'SELECT "?" FROM foo'; + yield 'SELECT `?` FROM foo'; + yield 'SELECT [?] FROM foo'; + yield "SELECT 'Doctrine\DBAL?' FROM foo"; + yield 'SELECT "Doctrine\DBAL?" FROM foo'; + yield 'SELECT `Doctrine\DBAL?` FROM foo'; + yield 'SELECT [Doctrine\DBAL?] FROM foo'; + yield 'SELECT @rank := 1'; + } + + /** + * @dataProvider ansiParametersProvider + */ + public function testAnsiEscaping(string $sql, string $expected): void + { + $parser = new Parser(false); + $parser->parse($sql, $this); + + $this->assertParsed($expected); + } + + /** + * @return iterable> + */ + public static function ansiParametersProvider(): iterable + { + yield 'Quotes inside literals escaped by doubling' => [ + <<<'SQL' +SELECT * FROM FOO WHERE bar = 'it''s a trap? \' OR bar = ? +AND baz = """quote"" me on it? \" OR baz = ? +SQL +, + <<<'SQL' +SELECT * FROM FOO WHERE bar = 'it''s a trap? \' OR bar = {?} +AND baz = """quote"" me on it? \" OR baz = {?} +SQL +, + ]; + + yield 'Backslash inside literals does not need escaping' => [ + <<<'SQL' +SELECT * FROM Foo +WHERE (foo.bar LIKE :condition_0 ESCAPE '\') + AND (foo.baz = :condition_1) + AND (foo.bak LIKE :condition_2 ESCAPE '\') +SQL, + <<<'SQL' +SELECT * FROM Foo +WHERE (foo.bar LIKE {:condition_0} ESCAPE '\') + AND (foo.baz = {:condition_1}) + AND (foo.bak LIKE {:condition_2} ESCAPE '\') +SQL, + ]; + } + + /** + * @dataProvider mySQLParametersProvider + */ + public function testMySQLEscaping(string $sql, string $expected): void + { + $parser = new Parser(true); + $parser->parse($sql, $this); + + $this->assertParsed($expected); + } + + /** + * @return iterable> + */ + public static function mySQLParametersProvider(): iterable + { + yield 'Quotes inside literals escaped by backslash' => [ + <<<'SQL' +SELECT * FROM FOO + WHERE bar = 'it\'s a trap? \\' OR bar = ? + AND baz = "\"quote\" me on it? \\" OR baz = ? +SQL +, + <<<'SQL' +SELECT * FROM FOO + WHERE bar = 'it\'s a trap? \\' OR bar = {?} + AND baz = "\"quote\" me on it? \\" OR baz = {?} +SQL +, + ]; + + yield 'Backslash inside literals needs escaping' => [ + <<<'SQL' +SELECT * FROM Foo +WHERE (foo.bar LIKE :condition_0 ESCAPE '\\') + AND (foo.baz = :condition_1) + AND (foo.bak LIKE :condition_2 ESCAPE '\\') +SQL +, + <<<'SQL' +SELECT * FROM Foo +WHERE (foo.bar LIKE {:condition_0} ESCAPE '\\') + AND (foo.baz = {:condition_1}) + AND (foo.bak LIKE {:condition_2} ESCAPE '\\') +SQL +, + ]; + } + + public function acceptPositionalParameter(string $sql): void + { + $this->result[] = sprintf('{%s}', $sql); + } + + public function acceptNamedParameter(string $sql): void + { + $this->result[] = sprintf('{%s}', $sql); + } + + public function acceptOther(string $sql): void + { + $this->result[] = $sql; + } + + /** + * @return iterable + */ + private static function getModes(): iterable + { + yield 'ANSI' => false; + + yield 'MySQL' => true; + } + + private function assertParsed(string $expected): void + { + self::assertSame($expected, implode('', $this->result)); + } +}