SQLParserUtils.php 9.94 KB
Newer Older
1 2 3 4
<?php

namespace Doctrine\DBAL;

5 6
use const PREG_OFFSET_CAPTURE;
use function array_fill;
7
use function array_fill_keys;
8
use function array_key_exists;
9
use function array_keys;
10 11 12 13 14 15 16 17 18
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;
19
use function sprintf;
20 21 22 23
use function strlen;
use function strpos;
use function substr;

24 25 26
/**
 * Utility class that parses sql statements with regard to types and parameters.
 */
27 28
class SQLParserUtils
{
Sergei Morozov's avatar
Sergei Morozov committed
29 30 31 32
    /**#@+
     *
     * @deprecated Will be removed as internal implementation details.
     */
33 34
    public const POSITIONAL_TOKEN = '\?';
    public const NAMED_TOKEN      = '(?<!:):[a-zA-Z_][a-zA-Z0-9_]*';
35
    // Quote characters within string literals can be preceded by a backslash.
36 37 38
    public const ESCAPED_SINGLE_QUOTED_TEXT   = "(?:'(?:\\\\)+'|'(?:[^'\\\\]|\\\\'?|'')*')";
    public const ESCAPED_DOUBLE_QUOTED_TEXT   = '(?:"(?:\\\\)+"|"(?:[^"\\\\]|\\\\"?)*")';
    public const ESCAPED_BACKTICK_QUOTED_TEXT = '(?:`(?:\\\\)+`|`(?:[^`\\\\]|\\\\`?)*`)';
39 40
    /**#@-*/

41
    private const ESCAPED_BRACKET_QUOTED_TEXT = '(?<!\b(?i:ARRAY))\[(?:[^\]])*\]';
42

43
    /**
Benjamin Morel's avatar
Benjamin Morel committed
44
     * Gets an array of the placeholders in an sql statements as keys and their positions in the query string.
45
     *
Sergei Morozov's avatar
Sergei Morozov committed
46 47
     * 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.
48
     *
Sergei Morozov's avatar
Sergei Morozov committed
49 50
     * @deprecated Will be removed as internal implementation detail.
     *
51 52
     * @param string $statement
     * @param bool   $isPositional
Benjamin Morel's avatar
Benjamin Morel committed
53
     *
Sergei Morozov's avatar
Sergei Morozov committed
54
     * @return int[]|string[]
55
     */
56
    public static function getPlaceholderPositions($statement, $isPositional = true)
57
    {
Sergei Morozov's avatar
Sergei Morozov committed
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 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101
        return $isPositional
            ? self::getPositionalPlaceholderPositions($statement)
            : self::getNamedPlaceholderPositions($statement);
    }

    /**
     * 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
    {
102
        if (strpos($statement, $match) === false) {
103
            return [];
104
        }
105

Sergei Morozov's avatar
Sergei Morozov committed
106
        $carry = [];
107 108

        foreach (self::getUnquotedStatementFragments($statement) as $fragment) {
109
            preg_match_all('/' . $token . '/', $fragment[0], $matches, PREG_OFFSET_CAPTURE);
110
            foreach ($matches[0] as $placeholder) {
Sergei Morozov's avatar
Sergei Morozov committed
111
                $collector($placeholder[0], $placeholder[1], $fragment[1], $carry);
112 113 114
            }
        }

Sergei Morozov's avatar
Sergei Morozov committed
115
        return $carry;
116
    }
117

118
    /**
119
     * For a positional query this method can rewrite the sql statement with regard to array parameters.
120
     *
121 122 123
     * @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.
124
     *
125
     * @return mixed[]
Benjamin Morel's avatar
Benjamin Morel committed
126 127
     *
     * @throws SQLParserUtilsException
128
     */
129
    public static function expandListParameters($query, $params, $types)
130
    {
Fabio B. Silva's avatar
Fabio B. Silva committed
131
        $isPositional   = is_int(key($params));
132
        $arrayPositions = [];
Fabio B. Silva's avatar
Fabio B. Silva committed
133 134
        $bindIndex      = -1;

135
        if ($isPositional) {
136 137 138 139
            // 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);

140 141 142 143
            ksort($params);
            ksort($types);
        }

144
        foreach ($types as $name => $type) {
145
            ++$bindIndex;
146

Fabio B. Silva's avatar
Fabio B. Silva committed
147 148 149 150 151 152
            if ($type !== Connection::PARAM_INT_ARRAY && $type !== Connection::PARAM_STR_ARRAY) {
                continue;
            }

            if ($isPositional) {
                $name = $bindIndex;
153
            }
Fabio B. Silva's avatar
Fabio B. Silva committed
154 155

            $arrayPositions[$name] = false;
156
        }
157

158
        if (( ! $arrayPositions && $isPositional)) {
159
            return [$query, $params, $types];
160
        }
161

162 163 164
        if ($isPositional) {
            $paramOffset = 0;
            $queryOffset = 0;
165 166
            $params      = array_values($params);
            $types       = array_values($types);
Fabio B. Silva's avatar
Fabio B. Silva committed
167

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

170
            foreach ($paramPos as $needle => $needlePos) {
171
                if (! isset($arrayPositions[$needle])) {
172 173
                    continue;
                }
174

Fabio B. Silva's avatar
Fabio B. Silva committed
175
                $needle    += $paramOffset;
176
                $needlePos += $queryOffset;
Fabio B. Silva's avatar
Fabio B. Silva committed
177
                $count      = count($params[$needle]);
178

179
                $params = array_merge(
180
                    array_slice($params, 0, $needle),
181
                    $params[$needle],
182
                    array_slice($params, $needle + 1)
183
                );
184

185
                $types = array_merge(
186
                    array_slice($types, 0, $needle),
187
                    $count ?
188 189 190
                        // 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) :
191
                        [],
192
                    array_slice($types, $needle + 1)
193
                );
194

195 196
                $expandStr = $count ? implode(', ', array_fill(0, $count, '?')) : 'NULL';
                $query     = substr($query, 0, $needlePos) . $expandStr . substr($query, $needlePos + 1);
197

Fabio B. Silva's avatar
Fabio B. Silva committed
198
                $paramOffset += ($count - 1); // Grows larger by number of parameters minus the replaced needle.
199 200
                $queryOffset += (strlen($expandStr) - 1);
            }
201

202
            return [$query, $params, $types];
Fabio B. Silva's avatar
Fabio B. Silva committed
203
        }
204

Fabio B. Silva's avatar
Fabio B. Silva committed
205
        $queryOffset = 0;
206 207
        $typesOrd    = [];
        $paramsOrd   = [];
Fabio B. Silva's avatar
Fabio B. Silva committed
208

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

Fabio B. Silva's avatar
Fabio B. Silva committed
211
        foreach ($paramPos as $pos => $paramName) {
212 213
            $paramLen = strlen($paramName) + 1;
            $value    = static::extractParam($paramName, $params, true);
Fabio B. Silva's avatar
Fabio B. Silva committed
214

215
            if (! isset($arrayPositions[$paramName]) && ! isset($arrayPositions[':' . $paramName])) {
Fabio B. Silva's avatar
Fabio B. Silva committed
216 217 218
                $pos         += $queryOffset;
                $queryOffset -= ($paramLen - 1);
                $paramsOrd[]  = $value;
219
                $typesOrd[]   = static::extractParam($paramName, $types, false, ParameterType::STRING);
Fabio B. Silva's avatar
Fabio B. Silva committed
220
                $query        = substr($query, 0, $pos) . '?' . substr($query, ($pos + $paramLen));
221

Fabio B. Silva's avatar
Fabio B. Silva committed
222 223 224
                continue;
            }

225 226
            $count     = count($value);
            $expandStr = $count > 0 ? implode(', ', array_fill(0, $count, '?')) : 'NULL';
Fabio B. Silva's avatar
Fabio B. Silva committed
227 228 229

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

Fabio B. Silva's avatar
Fabio B. Silva committed
233 234 235
            $pos         += $queryOffset;
            $queryOffset += (strlen($expandStr) - $paramLen);
            $query        = substr($query, 0, $pos) . $expandStr . substr($query, ($pos + $paramLen));
236
        }
237

238
        return [$query, $paramsOrd, $typesOrd];
239
    }
240 241 242 243 244 245 246 247 248 249

    /**
     * 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
250
     *
251
     * @return mixed[][]
252
     */
253
    private static function getUnquotedStatementFragments($statement)
254
    {
255 256 257 258 259 260 261
        $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);
262 263 264

        return $fragments[1];
    }
265 266

    /**
267
     * @param string $paramName     The name of the parameter (without a colon in front)
268
     * @param mixed  $paramsOrTypes A hash of parameters or types
269 270
     * @param bool   $isParam
     * @param mixed  $defaultValue  An optional default value. If omitted, an exception is thrown
271 272
     *
     * @return mixed
273 274
     *
     * @throws SQLParserUtilsException
275
     */
276
    private static function extractParam($paramName, $paramsOrTypes, $isParam, $defaultValue = null)
277
    {
278
        if (array_key_exists($paramName, $paramsOrTypes)) {
279 280 281 282
            return $paramsOrTypes[$paramName];
        }

        // Hash keys can be prefixed with a colon for compatibility
283
        if (array_key_exists(':' . $paramName, $paramsOrTypes)) {
284 285 286
            return $paramsOrTypes[':' . $paramName];
        }

287
        if ($defaultValue !== null) {
288 289 290 291 292 293
            return $defaultValue;
        }

        if ($isParam) {
            throw SQLParserUtilsException::missingParam($paramName);
        }
Benjamin Eberlei's avatar
Benjamin Eberlei committed
294 295

        throw SQLParserUtilsException::missingType($paramName);
296
    }
297
}