From 932a5a028bf57c51988c83947e8f343a9a54656d Mon Sep 17 00:00:00 2001 From: Sebastian Fischer Date: Mon, 20 May 2024 20:02:50 +0200 Subject: [PATCH] Add query builder helper --- Classes/Services/QueryBuilderHelper.php | 311 ++++++++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 Classes/Services/QueryBuilderHelper.php diff --git a/Classes/Services/QueryBuilderHelper.php b/Classes/Services/QueryBuilderHelper.php new file mode 100644 index 0000000..efa5f70 --- /dev/null +++ b/Classes/Services/QueryBuilderHelper.php @@ -0,0 +1,311 @@ +expandListParameters( + $queryBuilder->getSQL(), + $queryBuilder->getParameters(), + $queryBuilder->getParameterTypes(), + ); + + array_walk($parameters, function ($value, $key) use (&$sql, $types) { + if ($types[$key] == ParameterType::STRING) { + $value = '\'' . $value . '\''; + } elseif ($types[$key] == ParameterType::INTEGER) { + $value = '' . $value; + } elseif ($types[$key] == ArrayParameterType::INTEGER) { + $value = implode(', ', $value); + } elseif ($types[$key] == ArrayParameterType::STRING) { + $value = $value ? '"' . implode('", "', $value) . '"' : '""'; + } + if (is_int($key)) { + $sql = substr_replace($sql, (string)$value, strpos($sql, '?'), 1); + } else { + $sql = str_replace(':' . $key, $value, $sql); + } + }); + + return $sql; + } + + /** + * Gets an array of the placeholders in a sql statements as keys and their positions in the query string. + * + * For a statement with positional parameters, returns a zero-indexed list of placeholder position. + * For a statement with named parameters, returns a map of placeholder positions to their parameter names. + */ + public function getPlaceholderPositions(string $statement, bool $isPositional = true): array + { + return $isPositional + ? $this->getPositionalPlaceholderPositions($statement) + : $this->getNamedPlaceholderPositions($statement); + } + + /** + * Returns a zero-indexed list of placeholder position. + * + * @return list + */ + private function getPositionalPlaceholderPositions(string $statement): array + { + return $this->collectPlaceholders( + $statement, + '?', + '\?', + 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 function getNamedPlaceholderPositions(string $statement): array + { + return $this->collectPlaceholders( + $statement, + ':', + '(?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. + * + * @throws \Exception + */ + public function expandListParameters(string $query, array $params, array $types): array + { + $isPositional = is_int(key($params)); + $bindIndex = -1; + $arrayPositions = []; + + 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 !== ArrayParameterType::INTEGER && $type !== ArrayParameterType::STRING) { + continue; + } + + if ($isPositional) { + $name = $bindIndex; + } + + $arrayPositions[$name] = false; + } + + // parameter are positional and no array parameter given + if ($isPositional && !$arrayPositions) { + return [$query, $params, $types]; + } + + return $isPositional + ? $this->preparePositionalParameters($query, $params, $types, $arrayPositions) + : $this->convertNamedParamsToPositionalParams($query, $params, $types, $arrayPositions); + } + + private function preparePositionalParameters( + string $query, + array $params, + array $types, + array $arrayPositions + ): array { + $paramOffset = 0; + $queryOffset = 0; + $params = array_values($params); + $types = array_values($types); + + $paramPos = $this->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 ? + // array needles are at {@link \Doctrine\DBAL\ArrayParameterType} constants + array_fill(0, $count, $types[$needle]) : + [], + array_slice($types, $needle + 1) + ); + + $expandStr = $count ? 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]; + } + + private function convertNamedParamsToPositionalParams( + string $query, + array $params, + array $types, + array $arrayPositions + ): array { + $queryOffset = 0; + $typesOrd = []; + $paramsOrd = []; + + $paramPos = $this->getNamedPlaceholderPositions($query); + + foreach ($paramPos as $pos => $paramName) { + $paramLen = strlen($paramName) + 1; + $value = $this->extractParam($paramName, $params, true); + + if (!isset($arrayPositions[$paramName]) && !isset($arrayPositions[':' . $paramName])) { + $pos += $queryOffset; + $queryOffset -= $paramLen - 1; + $paramsOrd[] = $value; + $typesOrd[] = $this->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; + $type = $this->extractParam($paramName, $types, false); + $typesOrd[] = match ($type) { + ArrayParameterType::INTEGER => ParameterType::INTEGER, + ArrayParameterType::STRING => ParameterType::STRING, + default => $type + }; + } + + $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 outside SQL of quoted literals. + * Each fragment is captured as a 2-element array: + * + * 0 => matched fragment string, + * 1 => offset of fragment in $statement + */ + private function getUnquotedStatementFragments(string $statement): array + { + $literal = "(?:'(?:\\\\)+'|'(?:[^'\\\\]|\\\\'?|'')*')" + . '|' . '(?:"(?:\\\\)+"|"(?:[^"\\\\]|\\\\"?)*")' + . '|' . '(?:`(?:\\\\)+`|`(?:[^`\\\\]|\\\\`?)*`)' + . '|' . '(?