ew_base/Classes/Services/QueryBuilderHelper.php
2024-05-25 16:24:24 +02:00

313 lines
10 KiB
PHP

<?php
declare(strict_types=1);
/*
* This file is developed by evoWeb.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*/
namespace Evoweb\EwBase\Services;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
/**
* Utility class that parses sql statements with regard to types and parameters.
*/
class QueryBuilderHelper
{
public function getStatement(QueryBuilder $queryBuilder): string
{
[$sql, $parameters, $types] = $this->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<int>
*/
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<int,string>
*/
private function getNamedPlaceholderPositions(string $statement): array
{
return $this->collectPlaceholders(
$statement,
':',
'(?<!:):[a-zA-Z_][a-zA-Z0-9_]*',
function (
string $placeholder,
int $placeholderPosition,
int $fragmentPosition,
array &$carry
): void {
$carry[$placeholderPosition + $fragmentPosition] = substr($placeholder, 1);
}
);
}
private function collectPlaceholders(
string $statement,
string $match,
string $token,
callable $collector
): array {
if (!str_contains($statement, $match)) {
return [];
}
$carry = [];
foreach ($this->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 = "(?:'(?:\\\\)+'|'(?:[^'\\\\]|\\\\'?|'')*')"
. '|' . '(?:"(?:\\\\)+"|"(?:[^"\\\\]|\\\\"?)*")'
. '|' . '(?:`(?:\\\\)+`|`(?:[^`\\\\]|\\\\`?)*`)'
. '|' . '(?<!\b(?i:ARRAY))\[(?:[^\]])*\]';
$expression = sprintf('/((.+(?i:ARRAY)\\[.+\\])|([^\'"`\\[]+))(?:%s)?/s', $literal);
preg_match_all($expression, $statement, $fragments, PREG_OFFSET_CAPTURE);
return $fragments[1];
}
private function extractParam(
string $paramName,
array $paramsOrTypes,
bool $isParam,
mixed $defaultValue = null
): mixed {
if (array_key_exists($paramName, $paramsOrTypes)) {
return $paramsOrTypes[$paramName];
}
if (array_key_exists(':' . $paramName, $paramsOrTypes)) {
// Hash keys can be prefixed with a colon for compatibility
return $paramsOrTypes[':' . $paramName];
}
if ($defaultValue !== null) {
return $defaultValue;
}
if ($isParam) {
throw new \Exception($paramName);
}
throw new \Exception($paramName);
}
}