SQLParserUtils.php 9.2 KB
Newer Older
1 2
<?php

Michael Moravec's avatar
Michael Moravec committed
3 4
declare(strict_types=1);

5 6
namespace Doctrine\DBAL;

7 8
use Doctrine\DBAL\Exception\MissingArrayParameter;
use Doctrine\DBAL\Exception\MissingArrayParameterType;
9
use function array_fill;
10
use function array_fill_keys;
11
use function array_key_exists;
12
use function array_keys;
13 14 15 16 17 18 19 20 21
use function array_merge;
use function array_slice;
use function array_values;
use function count;
use function implode;
use function is_int;
use function key;
use function ksort;
use function preg_match_all;
22
use function sprintf;
23 24 25
use function strlen;
use function strpos;
use function substr;
26
use const PREG_OFFSET_CAPTURE;
27

28 29 30
/**
 * Utility class that parses sql statements with regard to types and parameters.
 */
31 32
class SQLParserUtils
{
33 34 35
    private const POSITIONAL_TOKEN = '\?';
    private const NAMED_TOKEN      = '(?<!:):[a-zA-Z_][a-zA-Z0-9_]*';

Sergei Morozov's avatar
Sergei Morozov committed
36
    /**#@+
37
     * Quote characters within string literals can be preceded by a backslash.
Sergei Morozov's avatar
Sergei Morozov committed
38
     */
39 40 41 42
    private const ESCAPED_SINGLE_QUOTED_TEXT   = "(?:'(?:\\\\)+'|'(?:[^'\\\\]|\\\\'?|'')*')";
    private const ESCAPED_DOUBLE_QUOTED_TEXT   = '(?:"(?:\\\\)+"|"(?:[^"\\\\]|\\\\"?)*")';
    private const ESCAPED_BACKTICK_QUOTED_TEXT = '(?:`(?:\\\\)+`|`(?:[^`\\\\]|\\\\`?)*`)';
    private const ESCAPED_BRACKET_QUOTED_TEXT  = '(?<!\b(?i:ARRAY))\[(?:[^\]])*\]';
43 44
    /**#@-*/

Sergei Morozov's avatar
Sergei Morozov committed
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
    /**
     * Returns a zero-indexed list of placeholder position.
     *
     * @return int[]
     */
    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 string[]
     */
    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
    {
84
        if (strpos($statement, $match) === false) {
85
            return [];
86
        }
87

Sergei Morozov's avatar
Sergei Morozov committed
88
        $carry = [];
89 90

        foreach (self::getUnquotedStatementFragments($statement) as $fragment) {
91
            preg_match_all('/' . $token . '/', $fragment[0], $matches, PREG_OFFSET_CAPTURE);
92
            foreach ($matches[0] as $placeholder) {
Sergei Morozov's avatar
Sergei Morozov committed
93
                $collector($placeholder[0], $placeholder[1], $fragment[1], $carry);
94 95 96
            }
        }

Sergei Morozov's avatar
Sergei Morozov committed
97
        return $carry;
98
    }
99

100
    /**
101
     * For a positional query this method can rewrite the sql statement with regard to array parameters.
102
     *
103 104 105
     * @param string         $query  The SQL query to execute.
     * @param mixed[]        $params The parameters to bind to the query.
     * @param int[]|string[] $types  The types the previous parameters are in.
106
     *
107
     * @return mixed[]
Benjamin Morel's avatar
Benjamin Morel committed
108 109
     *
     * @throws SQLParserUtilsException
110
     */
111
    public static function expandListParameters(string $query, array $params, array $types) : array
112
    {
Fabio B. Silva's avatar
Fabio B. Silva committed
113
        $isPositional   = is_int(key($params));
114
        $arrayPositions = [];
Fabio B. Silva's avatar
Fabio B. Silva committed
115 116
        $bindIndex      = -1;

117
        if ($isPositional) {
118 119 120 121
            // 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);

122 123 124 125
            ksort($params);
            ksort($types);
        }

126
        foreach ($types as $name => $type) {
127
            ++$bindIndex;
128

Fabio B. Silva's avatar
Fabio B. Silva committed
129 130 131 132 133 134
            if ($type !== Connection::PARAM_INT_ARRAY && $type !== Connection::PARAM_STR_ARRAY) {
                continue;
            }

            if ($isPositional) {
                $name = $bindIndex;
135
            }
Fabio B. Silva's avatar
Fabio B. Silva committed
136 137

            $arrayPositions[$name] = false;
138
        }
139

140
        if ($isPositional && count($arrayPositions) === 0) {
141
            return [$query, $params, $types];
142
        }
143

144 145 146
        if ($isPositional) {
            $paramOffset = 0;
            $queryOffset = 0;
147 148
            $params      = array_values($params);
            $types       = array_values($types);
Fabio B. Silva's avatar
Fabio B. Silva committed
149

Sergei Morozov's avatar
Sergei Morozov committed
150 151
            $paramPos = self::getPositionalPlaceholderPositions($query);

152
            foreach ($paramPos as $needle => $needlePos) {
153
                if (! isset($arrayPositions[$needle])) {
154 155
                    continue;
                }
156

Fabio B. Silva's avatar
Fabio B. Silva committed
157
                $needle    += $paramOffset;
158
                $needlePos += $queryOffset;
Fabio B. Silva's avatar
Fabio B. Silva committed
159
                $count      = count($params[$needle]);
160

161
                $params = array_merge(
162
                    array_slice($params, 0, $needle),
163
                    $params[$needle],
164
                    array_slice($params, $needle + 1)
165
                );
166

167
                $types = array_merge(
168
                    array_slice($types, 0, $needle),
169
                    $count > 0 ?
170 171 172
                        // 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) :
173
                        [],
174
                    array_slice($types, $needle + 1)
175
                );
176

177
                $expandStr = $count > 0 ? implode(', ', array_fill(0, $count, '?')) : 'NULL';
178
                $query     = substr($query, 0, $needlePos) . $expandStr . substr($query, $needlePos + 1);
179

180 181
                $paramOffset += $count - 1; // Grows larger by number of parameters minus the replaced needle.
                $queryOffset += strlen($expandStr) - 1;
182
            }
183

184
            return [$query, $params, $types];
Fabio B. Silva's avatar
Fabio B. Silva committed
185
        }
186

Fabio B. Silva's avatar
Fabio B. Silva committed
187
        $queryOffset = 0;
188 189
        $typesOrd    = [];
        $paramsOrd   = [];
Fabio B. Silva's avatar
Fabio B. Silva committed
190

Sergei Morozov's avatar
Sergei Morozov committed
191 192
        $paramPos = self::getNamedPlaceholderPositions($query);

Fabio B. Silva's avatar
Fabio B. Silva committed
193
        foreach ($paramPos as $pos => $paramName) {
194 195
            $paramLen = strlen($paramName) + 1;
            $value    = static::extractParam($paramName, $params, true);
Fabio B. Silva's avatar
Fabio B. Silva committed
196

197
            if (! isset($arrayPositions[$paramName]) && ! isset($arrayPositions[':' . $paramName])) {
Fabio B. Silva's avatar
Fabio B. Silva committed
198
                $pos         += $queryOffset;
199
                $queryOffset -= $paramLen - 1;
Fabio B. Silva's avatar
Fabio B. Silva committed
200
                $paramsOrd[]  = $value;
201
                $typesOrd[]   = static::extractParam($paramName, $types, false, ParameterType::STRING);
202
                $query        = substr($query, 0, $pos) . '?' . substr($query, $pos + $paramLen);
203

Fabio B. Silva's avatar
Fabio B. Silva committed
204 205 206
                continue;
            }

207 208
            $count     = count($value);
            $expandStr = $count > 0 ? implode(', ', array_fill(0, $count, '?')) : 'NULL';
Fabio B. Silva's avatar
Fabio B. Silva committed
209 210 211

            foreach ($value as $val) {
                $paramsOrd[] = $val;
212
                $typesOrd[]  = static::extractParam($paramName, $types, false) - Connection::ARRAY_PARAM_OFFSET;
213
            }
214

Fabio B. Silva's avatar
Fabio B. Silva committed
215
            $pos         += $queryOffset;
216 217
            $queryOffset += strlen($expandStr) - $paramLen;
            $query        = substr($query, 0, $pos) . $expandStr . substr($query, $pos + $paramLen);
218
        }
219

220
        return [$query, $paramsOrd, $typesOrd];
221
    }
222 223 224 225 226 227 228 229 230

    /**
     * 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
     *
231
     * @return mixed[][]
232
     */
233
    private static function getUnquotedStatementFragments(string $statement) : array
234
    {
235 236 237 238 239 240 241
        $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);
242 243 244

        return $fragments[1];
    }
245 246

    /**
247
     * @param string $paramName     The name of the parameter (without a colon in front)
248
     * @param mixed  $paramsOrTypes A hash of parameters or types
249
     * @param mixed  $defaultValue  An optional default value. If omitted, an exception is thrown
250 251
     *
     * @return mixed
252 253
     *
     * @throws SQLParserUtilsException
254
     */
255
    private static function extractParam(string $paramName, $paramsOrTypes, bool $isParam, $defaultValue = null)
256
    {
257
        if (array_key_exists($paramName, $paramsOrTypes)) {
258 259 260 261
            return $paramsOrTypes[$paramName];
        }

        // Hash keys can be prefixed with a colon for compatibility
262
        if (array_key_exists(':' . $paramName, $paramsOrTypes)) {
263 264 265
            return $paramsOrTypes[':' . $paramName];
        }

266
        if ($defaultValue !== null) {
267 268 269 270
            return $defaultValue;
        }

        if ($isParam) {
271
            throw MissingArrayParameter::new($paramName);
272
        }
Benjamin Eberlei's avatar
Benjamin Eberlei committed
273

274
        throw MissingArrayParameterType::new($paramName);
275
    }
276
}