← All changes
|
wp-includes/database/mysql/class-wp-mysql-lexer.php
+206
-99
2.2.23
→
3.1.0
View file →
| @@ -15,8 +15,10 @@ | ||
| 15 | 15 | * See: |
| 16 | 16 | * https://github.com/mysql/mysql-workbench/blob/8.0.38/library/parsers/grammars/MySQLLexer.g4 |
| 17 | 17 | * https://github.com/mysql/mysql-workbench/blob/8.0.38/library/parsers/grammars/predefined.tokens |
| 18 | 18 | * https://github.com/mysql/mysql-workbench/blob/8.0.38/library/parsers/mysql/MySQLBaseLexer.cpp |
| 19 | + * | |
| 20 | + * @access private | |
| 19 | 21 | */ |
| 20 | 22 | class WP_MySQL_Lexer { |
| 21 | 23 | /** |
| 22 | 24 | * The SQL modes that affect the lexer behavior. |
| @@ -31,8 +33,9 @@ | ||
| 31 | 33 | const SQL_MODE_HIGH_NOT_PRECEDENCE = 1; |
| 32 | 34 | const SQL_MODE_PIPES_AS_CONCAT = 2; |
| 33 | 35 | const SQL_MODE_IGNORE_SPACE = 4; |
| 34 | 36 | const SQL_MODE_NO_BACKSLASH_ESCAPES = 8; |
| 37 | + const SQL_MODE_ANSI_QUOTES = 16; | |
| 35 | 38 | |
| 36 | 39 | /** |
| 37 | 40 | * Character masks for frequently used character classes. |
| 38 | 41 | * |
| @@ -38,9 +41,9 @@ | ||
| 38 | 41 | * |
| 39 | 42 | * These are intended to be used with "strspn()" and "strcspn()" functions |
| 40 | 43 | * for fast character class matching in the SQL payload. |
| 41 | 44 | */ |
| 42 | - const WHITESPACE_MASK = " \t\n\r\f"; | |
| 45 | + const WHITESPACE_MASK = " \t\n\r\f\v"; | |
| 43 | 46 | const DIGIT_MASK = '0123456789'; |
| 44 | 47 | const HEX_DIGIT_MASK = '0123456789abcdefABCDEF'; |
| 45 | 48 | |
| 46 | 49 | /** |
| @@ -2111,8 +2114,15 @@ | ||
| 2111 | 2114 | */ |
| 2112 | 2115 | private $sql; |
| 2113 | 2116 | |
| 2114 | 2117 | /** |
| 2118 | + * Byte length of the SQL payload. | |
| 2119 | + * | |
| 2120 | + * @var int | |
| 2121 | + */ | |
| 2122 | + private $sql_length; | |
| 2123 | + | |
| 2124 | + /** | |
| 2115 | 2125 | * The version of the MySQL server that the SQL payload is intended for. |
| 2116 | 2126 | * |
| 2117 | 2127 | * This is used to determine which tokens are valid for the given MySQL |
| 2118 | 2128 | * version, and how some tokens should be interpreted. |
| @@ -2188,8 +2198,9 @@ | ||
| 2188 | 2198 | int $mysql_version = 80038, |
| 2189 | 2199 | array $sql_modes = array() |
| 2190 | 2200 | ) { |
| 2191 | 2201 | $this->sql = $sql; |
| 2202 | + $this->sql_length = strlen( $sql ); | |
| 2192 | 2203 | $this->mysql_version = $mysql_version; |
| 2193 | 2204 | |
| 2194 | 2205 | foreach ( $sql_modes as $sql_mode ) { |
| 2195 | 2206 | $sql_mode = strtoupper( $sql_mode ); |
| @@ -2200,8 +2211,21 @@ | ||
| 2200 | 2211 | } elseif ( 'IGNORE_SPACE' === $sql_mode ) { |
| 2201 | 2212 | $this->sql_modes |= self::SQL_MODE_IGNORE_SPACE; |
| 2202 | 2213 | } elseif ( 'NO_BACKSLASH_ESCAPES' === $sql_mode ) { |
| 2203 | 2214 | $this->sql_modes |= self::SQL_MODE_NO_BACKSLASH_ESCAPES; |
| 2215 | + } elseif ( 'ANSI_QUOTES' === $sql_mode ) { | |
| 2216 | + $this->sql_modes |= self::SQL_MODE_ANSI_QUOTES; | |
| 2217 | + } elseif ( 'ANSI' === $sql_mode ) { | |
| 2218 | + /* | |
| 2219 | + * Expand the composite ANSI mode into its lexer-relevant components. | |
| 2220 | + * The ANSI mode also implies REAL_AS_FLOAT and ONLY_FULL_GROUP_BY, | |
| 2221 | + * which do not affect the lexer. | |
| 2222 | + * | |
| 2223 | + * See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi | |
| 2224 | + */ | |
| 2225 | + $this->sql_modes |= self::SQL_MODE_PIPES_AS_CONCAT | |
| 2226 | + | self::SQL_MODE_IGNORE_SPACE | |
| 2227 | + | self::SQL_MODE_ANSI_QUOTES; | |
| 2204 | 2228 | } |
| 2205 | 2229 | } |
| 2206 | 2230 | } |
| 2207 | 2231 | |
| @@ -2226,8 +2250,11 @@ | ||
| 2226 | 2250 | $this->token_type = null; |
| 2227 | 2251 | return false; |
| 2228 | 2252 | } |
| 2229 | 2253 | |
| 2254 | + // Skip leading whitespace inline for optimal performance. | |
| 2255 | + $this->bytes_already_read += strspn( $this->sql, self::WHITESPACE_MASK, $this->bytes_already_read ); | |
| 2256 | + | |
| 2230 | 2257 | do { |
| 2231 | 2258 | $this->token_starts_at = $this->bytes_already_read; |
| 2232 | 2259 | $this->token_type = $this->read_next_token(); |
| 2233 | 2260 | } while ( |
| @@ -2283,12 +2310,53 @@ | ||
| 2283 | 2310 | * |
| 2284 | 2311 | * @return WP_MySQL_Token[] An array of token objects representing the remaining tokens. |
| 2285 | 2312 | */ |
| 2286 | 2313 | public function remaining_tokens(): array { |
| 2287 | - $tokens = array(); | |
| 2288 | - while ( true === $this->next_token() ) { | |
| 2289 | - $token = $this->get_token(); | |
| 2290 | - $tokens[] = $token; | |
| 2314 | + $tokens = array(); | |
| 2315 | + $no_backslash_escapes_sql_mode_set = $this->is_sql_mode_active( | |
| 2316 | + self::SQL_MODE_NO_BACKSLASH_ESCAPES | |
| 2317 | + ); | |
| 2318 | + | |
| 2319 | + while ( true ) { | |
| 2320 | + // Bail on EOF, or on a null token type once at least one byte has | |
| 2321 | + // been consumed (read_next_token() hit invalid input mid-stream). | |
| 2322 | + if ( | |
| 2323 | + self::EOF === $this->token_type | |
| 2324 | + || ( null === $this->token_type && $this->bytes_already_read > 0 ) | |
| 2325 | + ) { | |
| 2326 | + $this->token_type = null; | |
| 2327 | + break; | |
| 2328 | + } | |
| 2329 | + | |
| 2330 | + // Skip leading whitespace inline for optimal performance. | |
| 2331 | + $this->bytes_already_read += strspn( $this->sql, self::WHITESPACE_MASK, $this->bytes_already_read ); | |
| 2332 | + | |
| 2333 | + do { | |
| 2334 | + $this->token_starts_at = $this->bytes_already_read; | |
| 2335 | + $this->token_type = $this->read_next_token(); | |
| 2336 | + } while ( | |
| 2337 | + self::WHITESPACE === $this->token_type | |
| 2338 | + || self::COMMENT === $this->token_type | |
| 2339 | + || self::MYSQL_COMMENT_START === $this->token_type | |
| 2340 | + || self::MYSQL_COMMENT_END === $this->token_type | |
| 2341 | + ); | |
| 2342 | + | |
| 2343 | + if ( null === $this->token_type ) { | |
| 2344 | + break; | |
| 2345 | + } | |
| 2346 | + | |
| 2347 | + $tokens[] = new WP_MySQL_Token( | |
| 2348 | + $this->token_type, | |
| 2349 | + $this->token_starts_at, | |
| 2350 | + $this->bytes_already_read - $this->token_starts_at, | |
| 2351 | + $this->sql, | |
| 2352 | + $no_backslash_escapes_sql_mode_set | |
| 2353 | + ); | |
| 2354 | + | |
| 2355 | + if ( self::EOF === $this->token_type ) { | |
| 2356 | + $this->token_type = null; | |
| 2357 | + break; | |
| 2358 | + } | |
| 2291 | 2359 | } |
| 2292 | 2360 | return $tokens; |
| 2293 | 2361 | } |
| 2294 | 2362 | |
| @@ -2353,9 +2421,60 @@ | ||
| 2353 | 2421 | private function read_next_token(): ?int { |
| 2354 | 2422 | $byte = $this->sql[ $this->bytes_already_read ] ?? null; |
| 2355 | 2423 | $next_byte = $this->sql[ $this->bytes_already_read + 1 ] ?? null; |
| 2356 | 2424 | |
| 2357 | - if ( "'" === $byte || '"' === $byte || '`' === $byte ) { | |
| 2425 | + // A map for a single-byte symbol fast path. | |
| 2426 | + static $single_byte_ops = array( | |
| 2427 | + '(' => self::OPEN_PAR_SYMBOL, | |
| 2428 | + ')' => self::CLOSE_PAR_SYMBOL, | |
| 2429 | + ',' => self::COMMA_SYMBOL, | |
| 2430 | + ';' => self::SEMICOLON_SYMBOL, | |
| 2431 | + '+' => self::PLUS_OPERATOR, | |
| 2432 | + '~' => self::BITWISE_NOT_OPERATOR, | |
| 2433 | + '%' => self::MOD_OPERATOR, | |
| 2434 | + '^' => self::BITWISE_XOR_OPERATOR, | |
| 2435 | + '?' => self::PARAM_MARKER, | |
| 2436 | + '{' => self::OPEN_CURLY_SYMBOL, | |
| 2437 | + '}' => self::CLOSE_CURLY_SYMBOL, | |
| 2438 | + '=' => self::EQUAL_OPERATOR, | |
| 2439 | + ); | |
| 2440 | + | |
| 2441 | + // Fast path for keywords and identifiers. | |
| 2442 | + // `$byte > "\x7F"` catches any non-ASCII byte (0x80-0xFF); read_identifier() | |
| 2443 | + // restricts the accepted identifier codepoints to U+0080-U+FFFF. | |
| 2444 | + // `"'" !== $next_byte` defers x'..', n'..' and similar special | |
| 2445 | + // literals to their dedicated branches below; only single quotes | |
| 2446 | + // form those, regardless of SQL mode. | |
| 2447 | + if ( | |
| 2448 | + ( | |
| 2449 | + ( $byte >= 'a' && $byte <= 'z' ) | |
| 2450 | + || ( $byte >= 'A' && $byte <= 'Z' ) | |
| 2451 | + || $byte > "\x7F" | |
| 2452 | + ) | |
| 2453 | + && "'" !== $next_byte | |
| 2454 | + ) { | |
| 2455 | + $started_at = $this->bytes_already_read; | |
| 2456 | + $type = $this->read_identifier(); | |
| 2457 | + if ( | |
| 2458 | + self::IDENTIFIER === $type | |
| 2459 | + // When preceded by a dot, it is always an identifier. | |
| 2460 | + && ! ( $started_at > 0 && '.' === $this->sql[ $started_at - 1 ] ) | |
| 2461 | + ) { | |
| 2462 | + // Inline the keyword lookup on the hot identifier path: most | |
| 2463 | + // identifiers are not keywords, so this avoids two method calls | |
| 2464 | + // (token-bytes extraction + keyword determination) per token. | |
| 2465 | + $keyword = self::TOKENS[ strtoupper( | |
| 2466 | + substr( $this->sql, $started_at, $this->bytes_already_read - $started_at ) | |
| 2467 | + ) ] ?? self::IDENTIFIER; | |
| 2468 | + if ( self::IDENTIFIER !== $keyword ) { | |
| 2469 | + $type = $this->resolve_keyword_type( $keyword ); | |
| 2470 | + } | |
| 2471 | + } | |
| 2472 | + } elseif ( null !== $byte && isset( $single_byte_ops[ $byte ] ) ) { | |
| 2473 | + // Fast path for single-byte symbols. | |
| 2474 | + $this->bytes_already_read += 1; | |
| 2475 | + $type = $single_byte_ops[ $byte ]; | |
| 2476 | + } elseif ( "'" === $byte || '"' === $byte || '`' === $byte ) { | |
| 2358 | 2477 | $type = $this->read_quoted_text(); |
| 2359 | 2478 | } elseif ( null !== $byte && strspn( $byte, self::DIGIT_MASK ) > 0 ) { |
| 2360 | 2479 | $type = $this->read_number(); |
| 2361 | 2480 | } elseif ( '.' === $byte ) { |
| @@ -2364,11 +2483,8 @@ | ||
| 2364 | 2483 | } else { |
| 2365 | 2484 | $this->bytes_already_read += 1; |
| 2366 | 2485 | $type = self::DOT_SYMBOL; |
| 2367 | 2486 | } |
| 2368 | - } elseif ( '=' === $byte ) { | |
| 2369 | - $this->bytes_already_read += 1; | |
| 2370 | - $type = self::EQUAL_OPERATOR; | |
| 2371 | 2487 | } elseif ( ':' === $byte ) { |
| 2372 | 2488 | $this->bytes_already_read += 1; // Consume the ':'. |
| 2373 | 2489 | if ( '=' === $next_byte ) { |
| 2374 | 2490 | $this->bytes_already_read += 1; // Consume the '='. |
| @@ -2413,15 +2529,12 @@ | ||
| 2413 | 2529 | $type = self::NOT_EQUAL_OPERATOR; |
| 2414 | 2530 | } else { |
| 2415 | 2531 | $type = self::LOGICAL_NOT_OPERATOR; |
| 2416 | 2532 | } |
| 2417 | - } elseif ( '+' === $byte ) { | |
| 2418 | - $this->bytes_already_read += 1; | |
| 2419 | - $type = self::PLUS_OPERATOR; | |
| 2420 | 2533 | } elseif ( '-' === $byte ) { |
| 2421 | 2534 | if ( |
| 2422 | 2535 | '-' === $next_byte |
| 2423 | - && $this->bytes_already_read + 2 < strlen( $this->sql ) | |
| 2536 | + && $this->bytes_already_read + 2 < $this->sql_length | |
| 2424 | 2537 | && strspn( $this->sql[ $this->bytes_already_read + 2 ], self::WHITESPACE_MASK ) > 0 |
| 2425 | 2538 | ) { |
| 2426 | 2539 | $type = $this->read_line_comment(); |
| 2427 | 2540 | } elseif ( '>' === $next_byte ) { |
| @@ -2465,11 +2578,8 @@ | ||
| 2465 | 2578 | } else { |
| 2466 | 2579 | $this->bytes_already_read += 1; |
| 2467 | 2580 | $type = self::DIV_OPERATOR; |
| 2468 | 2581 | } |
| 2469 | - } elseif ( '%' === $byte ) { | |
| 2470 | - $this->bytes_already_read += 1; | |
| 2471 | - $type = self::MOD_OPERATOR; | |
| 2472 | 2582 | } elseif ( '&' === $byte ) { |
| 2473 | 2583 | $this->bytes_already_read += 1; // Consume the '&'. |
| 2474 | 2584 | if ( '&' === $next_byte ) { |
| 2475 | 2585 | $this->bytes_already_read += 1; // Consume the '&'. |
| @@ -2476,11 +2586,8 @@ | ||
| 2476 | 2586 | $type = self::LOGICAL_AND_OPERATOR; |
| 2477 | 2587 | } else { |
| 2478 | 2588 | $type = self::BITWISE_AND_OPERATOR; |
| 2479 | 2589 | } |
| 2480 | - } elseif ( '^' === $byte ) { | |
| 2481 | - $this->bytes_already_read += 1; | |
| 2482 | - $type = self::BITWISE_XOR_OPERATOR; | |
| 2483 | 2590 | } elseif ( '|' === $byte ) { |
| 2484 | 2591 | $this->bytes_already_read += 1; // Consume the '|'. |
| 2485 | 2592 | if ( '|' === $next_byte ) { |
| 2486 | 2593 | $this->bytes_already_read += 1; // Consume the '|'. |
| @@ -2489,29 +2596,8 @@ | ||
| 2489 | 2596 | : self::LOGICAL_OR_OPERATOR; |
| 2490 | 2597 | } else { |
| 2491 | 2598 | $type = self::BITWISE_OR_OPERATOR; |
| 2492 | 2599 | } |
| 2493 | - } elseif ( '~' === $byte ) { | |
| 2494 | - $this->bytes_already_read += 1; | |
| 2495 | - $type = self::BITWISE_NOT_OPERATOR; | |
| 2496 | - } elseif ( ',' === $byte ) { | |
| 2497 | - $this->bytes_already_read += 1; | |
| 2498 | - $type = self::COMMA_SYMBOL; | |
| 2499 | - } elseif ( ';' === $byte ) { | |
| 2500 | - $this->bytes_already_read += 1; | |
| 2501 | - $type = self::SEMICOLON_SYMBOL; | |
| 2502 | - } elseif ( '(' === $byte ) { | |
| 2503 | - $this->bytes_already_read += 1; | |
| 2504 | - $type = self::OPEN_PAR_SYMBOL; | |
| 2505 | - } elseif ( ')' === $byte ) { | |
| 2506 | - $this->bytes_already_read += 1; | |
| 2507 | - $type = self::CLOSE_PAR_SYMBOL; | |
| 2508 | - } elseif ( '{' === $byte ) { | |
| 2509 | - $this->bytes_already_read += 1; | |
| 2510 | - $type = self::OPEN_CURLY_SYMBOL; | |
| 2511 | - } elseif ( '}' === $byte ) { | |
| 2512 | - $this->bytes_already_read += 1; | |
| 2513 | - $type = self::CLOSE_CURLY_SYMBOL; | |
| 2514 | 2600 | } elseif ( '@' === $byte ) { |
| 2515 | 2601 | $this->bytes_already_read += 1; // Consume the '@'. |
| 2516 | 2602 | |
| 2517 | 2603 | if ( '@' === $next_byte ) { |
| @@ -2533,11 +2619,8 @@ | ||
| 2533 | 2619 | } else { |
| 2534 | 2620 | $type = self::AT_SIGN_SYMBOL; |
| 2535 | 2621 | } |
| 2536 | 2622 | } |
| 2537 | - } elseif ( '?' === $byte ) { | |
| 2538 | - $this->bytes_already_read += 1; | |
| 2539 | - $type = self::PARAM_MARKER; | |
| 2540 | 2623 | } elseif ( '\\' === $byte ) { |
| 2541 | 2624 | $this->bytes_already_read += 1; // Consume the '\'. |
| 2542 | 2625 | if ( 'N' === $next_byte ) { |
| 2543 | 2626 | $this->bytes_already_read += 1; // Consume the 'N'. |
| @@ -2684,9 +2767,9 @@ | ||
| 2684 | 2767 | $this->bytes_already_read += 2; // Consume "0x" or "x'". |
| 2685 | 2768 | $this->bytes_already_read += strspn( $this->sql, self::HEX_DIGIT_MASK, $this->bytes_already_read ); |
| 2686 | 2769 | if ( $is_quoted ) { |
| 2687 | 2770 | if ( |
| 2688 | - $this->bytes_already_read >= strlen( $this->sql ) | |
| 2771 | + $this->bytes_already_read >= $this->sql_length | |
| 2689 | 2772 | || "'" !== $this->sql[ $this->bytes_already_read ] |
| 2690 | 2773 | ) { |
| 2691 | 2774 | return null; // Invalid input. |
| 2692 | 2775 | } |
| @@ -2707,9 +2790,9 @@ | ||
| 2707 | 2790 | $this->bytes_already_read += 2; // Consume "0b" or "b'". |
| 2708 | 2791 | $this->bytes_already_read += strspn( $this->sql, '01', $this->bytes_already_read ); |
| 2709 | 2792 | if ( $is_quoted ) { |
| 2710 | 2793 | if ( |
| 2711 | - $this->bytes_already_read >= strlen( $this->sql ) | |
| 2794 | + $this->bytes_already_read >= $this->sql_length | |
| 2712 | 2795 | || "'" !== $this->sql[ $this->bytes_already_read ] |
| 2713 | 2796 | ) { |
| 2714 | 2797 | return null; // Invalid input. |
| 2715 | 2798 | } |
| @@ -2739,9 +2822,9 @@ | ||
| 2739 | 2822 | && ( |
| 2740 | 2823 | strspn( $next_byte, self::DIGIT_MASK ) > 0 |
| 2741 | 2824 | || ( |
| 2742 | 2825 | ( '+' === $next_byte || '-' === $next_byte ) |
| 2743 | - && $this->bytes_already_read + 2 < strlen( $this->sql ) | |
| 2826 | + && $this->bytes_already_read + 2 < $this->sql_length | |
| 2744 | 2827 | && strspn( $this->sql[ $this->bytes_already_read + 2 ], self::DIGIT_MASK ) > 0 |
| 2745 | 2828 | ) |
| 2746 | 2829 | ); |
| 2747 | 2830 | if ( $has_exponent ) { |
| @@ -2823,37 +2906,62 @@ | ||
| 2823 | 2906 | * https://dev.mysql.com/doc/refman/8.4/en/identifiers.html |
| 2824 | 2907 | * |
| 2825 | 2908 | * Rules: |
| 2826 | 2909 | * 1. Quotes can be escaped by doubling them ('', "", ``). |
| 2827 | - * 2. Backslashes escape the next character, unless NO_BACKSLASH_ESCAPES is set. | |
| 2828 | - * | |
| 2829 | - * @param string $quote The quote character - ', ", or `. | |
| 2910 | + * 2. In string literals, backslashes escape the next character, | |
| 2911 | + * unless the NO_BACKSLASH_ESCAPES SQL mode is set. | |
| 2912 | + * 3. In identifiers, backslashes are always literal and never escape. | |
| 2830 | 2913 | */ |
| 2831 | 2914 | private function read_quoted_text(): ?int { |
| 2832 | 2915 | $quote = $this->sql[ $this->bytes_already_read ]; |
| 2833 | 2916 | $this->bytes_already_read += 1; // Consume the quote. |
| 2834 | 2917 | |
| 2835 | - $no_backslash_escapes = $this->is_sql_mode_active( | |
| 2836 | - self::SQL_MODE_NO_BACKSLASH_ESCAPES | |
| 2837 | - ); | |
| 2918 | + /* | |
| 2919 | + * Determine whether the quote opens an identifier or a string literal. | |
| 2920 | + * An identifier is quoted with a backtick or a double quote when the | |
| 2921 | + * ANSI_QUOTES SQL mode is active. Otherwise, it is a string literal. | |
| 2922 | + * | |
| 2923 | + * See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi_quotes | |
| 2924 | + */ | |
| 2925 | + $is_identifier_quote = '`' === $quote | |
| 2926 | + || ( '"' === $quote && $this->is_sql_mode_active( self::SQL_MODE_ANSI_QUOTES ) ); | |
| 2838 | 2927 | |
| 2928 | + /* | |
| 2929 | + * Backslash escapes apply only to string literals, and only when the | |
| 2930 | + * NO_BACKSLASH_ESCAPES SQL mode is not set. | |
| 2931 | + */ | |
| 2932 | + $backslash_is_escape = ! $is_identifier_quote | |
| 2933 | + && ! $this->is_sql_mode_active( self::SQL_MODE_NO_BACKSLASH_ESCAPES ); | |
| 2934 | + | |
| 2839 | 2935 | // We need to look for the closing quote in a loop, as it can be escaped, |
| 2840 | 2936 | // in which case the escape sequence is consumed and the loop continues. |
| 2841 | 2937 | $at = $this->bytes_already_read; |
| 2842 | 2938 | while ( true ) { |
| 2843 | - $at += strcspn( $this->sql, $quote, $at ); | |
| 2939 | + $quote_at = strpos( $this->sql, $quote, $at ); | |
| 2940 | + if ( false === $quote_at ) { | |
| 2941 | + return null; // Invalid input. | |
| 2942 | + } | |
| 2943 | + $at = $quote_at; | |
| 2844 | 2944 | |
| 2845 | 2945 | /* |
| 2846 | - * By default, quotes can be escaped with a "\". | |
| 2847 | - * When NO_BACKSLASH_ESCAPES SQL mode is active, the "\" treated as | |
| 2848 | - * a regular character. | |
| 2946 | + * In string literals, quotes can be escaped with a backslash. When | |
| 2947 | + * NO_BACKSLASH_ESCAPES SQL mode is active, the backslash is treated | |
| 2948 | + * as a regular character. Identifiers never use backslash escaping. | |
| 2849 | 2949 | * |
| 2850 | 2950 | * The quote is escaped only when the number of preceding backslashes |
| 2851 | 2951 | * is odd - "\" is an escape sequence, "\\" is an escaped backslash, |
| 2852 | 2952 | * "\\\" is an escaped backslash and an escape sequence, and so on. |
| 2953 | + * | |
| 2954 | + * The `($at - $i - 1) >= 0` guard prevents PHP's negative-string- | |
| 2955 | + * offset wraparound (PHP 7.1+) when the closing-quote candidate | |
| 2956 | + * sits at the very start of the input. The `?? null` covers | |
| 2957 | + * positive out-of-range indexes belt-and-suspenders. | |
| 2853 | 2958 | */ |
| 2854 | - if ( ! $no_backslash_escapes ) { | |
| 2855 | - for ($i = 0; '\\' === $this->sql[ $at - $i - 1 ]; $i += 1); | |
| 2959 | + if ( $backslash_is_escape ) { | |
| 2960 | + $i = 0; | |
| 2961 | + while ( ( $at - $i - 1 ) >= 0 && '\\' === ( $this->sql[ $at - $i - 1 ] ?? null ) ) { | |
| 2962 | + $i += 1; | |
| 2963 | + } | |
| 2856 | 2964 | if ( 1 === $i % 2 ) { |
| 2857 | 2965 | $at += 1; |
| 2858 | 2966 | continue; |
| 2859 | 2967 | } |
| @@ -2858,13 +2966,8 @@ | ||
| 2858 | 2966 | continue; |
| 2859 | 2967 | } |
| 2860 | 2968 | } |
| 2861 | 2969 | |
| 2862 | - // Unclosed string - unexpected EOF. | |
| 2863 | - if ( ( $this->sql[ $at ] ?? null ) !== $quote ) { | |
| 2864 | - return null; // Invalid input. | |
| 2865 | - } | |
| 2866 | - | |
| 2867 | 2970 | // Check if the quote is doubled. |
| 2868 | 2971 | if ( ( $this->sql[ $at + 1 ] ?? null ) === $quote ) { |
| 2869 | 2972 | $at += 2; |
| 2870 | 2973 | continue; |
| @@ -2875,15 +2978,12 @@ | ||
| 2875 | 2978 | $at += 1; |
| 2876 | 2979 | |
| 2877 | 2980 | $this->bytes_already_read = $at; |
| 2878 | 2981 | |
| 2879 | - if ( '`' === $quote ) { | |
| 2982 | + if ( $is_identifier_quote ) { | |
| 2880 | 2983 | return self::BACK_TICK_QUOTED_ID; |
| 2881 | - } elseif ( '"' === $quote ) { | |
| 2882 | - return self::DOUBLE_QUOTED_TEXT; | |
| 2883 | - } else { | |
| 2884 | - return self::SINGLE_QUOTED_TEXT; | |
| 2885 | 2984 | } |
| 2985 | + return '"' === $quote ? self::DOUBLE_QUOTED_TEXT : self::SINGLE_QUOTED_TEXT; | |
| 2886 | 2986 | } |
| 2887 | 2987 | |
| 2888 | 2988 | private function read_line_comment(): int { |
| 2889 | 2989 | $this->bytes_already_read += strcspn( $this->sql, "\r\n", $this->bytes_already_read ); |
| @@ -2892,24 +2992,29 @@ | ||
| 2892 | 2992 | |
| 2893 | 2993 | private function read_mysql_comment(): int { |
| 2894 | 2994 | // @TODO: Consider supporting optimizer hints (/*+ ... */) or document |
| 2895 | 2995 | // that they are not supported. |
| 2896 | - // @TODO: Implement six-digit version number support (from MySQL 8.4). | |
| 2897 | 2996 | |
| 2898 | - // MySQL-specific comment in one of the following forms: | |
| 2899 | - // 1. /*! ... */ - The content is treated as SQL. | |
| 2900 | - // 2. /*!12345 ... */ - The content is treated as SQL when "MySQL version >= 12345". | |
| 2997 | + // MySQL-specific comments in the following forms: | |
| 2998 | + // 1. /*! ... */ - The content is treated as SQL. | |
| 2999 | + // 2. /*!80400 ... */ - SQL when the server version is at least 8.4.0. | |
| 3000 | + // 3. /*!080400 ... */ - The same version in six-digit form (MySQL 8.1+). | |
| 3001 | + // | |
| 3002 | + // See: https://dev.mysql.com/doc/refman/8.4/en/comments.html | |
| 2901 | 3003 | $this->bytes_already_read += 3; // Consume the '/*!'. |
| 2902 | 3004 | |
| 2903 | - // Check if the next 5 characters are digits. | |
| 2904 | - $digit_count = strspn( $this->sql, self::DIGIT_MASK, $this->bytes_already_read, 5 ); | |
| 2905 | - $is_version_comment = 5 === $digit_count; | |
| 3005 | + // Version has 5 digits (Mmmrr) or 6 digits (MMmmrr + whitespace, MySQL 8.1+). | |
| 3006 | + $digit_count = strspn( $this->sql, self::DIGIT_MASK, $this->bytes_already_read, 6 ); | |
| 3007 | + $version_length = $digit_count < 5 ? 0 : 5; | |
| 3008 | + if ( | |
| 3009 | + 6 === $digit_count | |
| 3010 | + && $this->mysql_version >= 80100 | |
| 3011 | + && 1 === strspn( $this->sql, self::WHITESPACE_MASK, $this->bytes_already_read + 6, 1 ) | |
| 3012 | + ) { | |
| 3013 | + $version_length = 6; | |
| 3014 | + } | |
| 3015 | + $version = (int) substr( $this->sql, $this->bytes_already_read, $version_length ); | |
| 2906 | 3016 | |
| 2907 | - // For version comments, extract the version number. | |
| 2908 | - $version = $is_version_comment | |
| 2909 | - ? (int) substr( $this->sql, $this->bytes_already_read, $digit_count ) | |
| 2910 | - : 0; | |
| 2911 | - | |
| 2912 | 3017 | if ( $this->mysql_version < $version ) { |
| 2913 | 3018 | // Version not satisfied. Treat the content as a regular comment. |
| 2914 | 3019 | $this->read_comment_content(); |
| 2915 | 3020 | return self::COMMENT; |
| @@ -2914,9 +3019,9 @@ | ||
| 2914 | 3019 | $this->read_comment_content(); |
| 2915 | 3020 | return self::COMMENT; |
| 2916 | 3021 | } else { |
| 2917 | 3022 | // Version satisfied or not specified. Treat the content as SQL code. |
| 2918 | - $this->bytes_already_read += $digit_count; // Skip the version number. | |
| 3023 | + $this->bytes_already_read += $version_length; // Skip the version number. | |
| 2919 | 3024 | $this->in_mysql_comment = true; |
| 2920 | 3025 | return self::MYSQL_COMMENT_START; |
| 2921 | 3026 | } |
| 2922 | 3027 | } |
| @@ -2921,31 +3026,32 @@ | ||
| 2921 | 3026 | } |
| 2922 | 3027 | } |
| 2923 | 3028 | |
| 2924 | 3029 | private function read_comment_content(): void { |
| 2925 | - while ( true ) { | |
| 2926 | - $this->bytes_already_read += strcspn( $this->sql, '*', $this->bytes_already_read ); | |
| 2927 | - $this->bytes_already_read += 1; // Consume the '*'. | |
| 2928 | - $byte = $this->sql[ $this->bytes_already_read ] ?? null; | |
| 2929 | - if ( null === $byte ) { | |
| 2930 | - break; | |
| 2931 | - } | |
| 2932 | - if ( '/' === $byte ) { | |
| 2933 | - $this->bytes_already_read += 1; // Consume the '/'. | |
| 2934 | - break; | |
| 2935 | - } | |
| 3030 | + $comment_end = strpos( $this->sql, '*/', $this->bytes_already_read ); | |
| 3031 | + if ( false === $comment_end ) { | |
| 3032 | + $this->bytes_already_read = $this->sql_length; | |
| 3033 | + } else { | |
| 3034 | + $this->bytes_already_read = $comment_end + 2; | |
| 2936 | 3035 | } |
| 2937 | 3036 | } |
| 2938 | 3037 | |
| 2939 | 3038 | private function determine_identifier_or_keyword_type( string $value ): int { |
| 2940 | - $value = strtoupper( $value ); | |
| 2941 | - | |
| 2942 | - // Lookup the string in the token table. | |
| 2943 | - $type = self::TOKENS[ $value ] ?? self::IDENTIFIER; | |
| 3039 | + $type = self::TOKENS[ strtoupper( $value ) ] ?? self::IDENTIFIER; | |
| 2944 | 3040 | if ( self::IDENTIFIER === $type ) { |
| 2945 | 3041 | return self::IDENTIFIER; |
| 2946 | 3042 | } |
| 3043 | + return $this->resolve_keyword_type( $type ); | |
| 3044 | + } | |
| 2947 | 3045 | |
| 3046 | + /** | |
| 3047 | + * Resolve a keyword token id matched in self::TOKENS, applying version gating, | |
| 3048 | + * function-call lookahead, the SQL_MODE_HIGH_NOT_PRECEDENCE rule, and synonyms. | |
| 3049 | + * | |
| 3050 | + * @param int $type A token id already matched in self::TOKENS (never IDENTIFIER). | |
| 3051 | + */ | |
| 3052 | + private function resolve_keyword_type( int $type ): int { | |
| 3053 | + | |
| 2948 | 3054 | // Apply MySQL version specifics (positive number: >= <version>, negative number: < <version>). |
| 2949 | 3055 | if ( isset( self::VERSIONS[ $type ] ) ) { |
| 2950 | 3056 | $version = self::VERSIONS[ $type ]; |
| 2951 | 3057 | if ( $this->mysql_version < $version || -$version >= $this->mysql_version ) { |
| @@ -2976,13 +3082,14 @@ | ||
| 2976 | 3082 | } |
| 2977 | 3083 | |
| 2978 | 3084 | // Determine function calls. |
| 2979 | 3085 | if ( isset( self::FUNCTIONS[ $type ] ) ) { |
| 2980 | - // Skip any whitespace character if the SQL mode says they should be ignored. | |
| 3086 | + // Keep ignored whitespace outside the current token range. | |
| 3087 | + $peek = $this->bytes_already_read; | |
| 2981 | 3088 | if ( $this->is_sql_mode_active( self::SQL_MODE_IGNORE_SPACE ) ) { |
| 2982 | - $this->bytes_already_read += strspn( $this->sql, self::WHITESPACE_MASK, $this->bytes_already_read ); | |
| 3089 | + $peek += strspn( $this->sql, self::WHITESPACE_MASK, $peek ); | |
| 2983 | 3090 | } |
| 2984 | - if ( '(' !== ( $this->sql[ $this->bytes_already_read ] ?? null ) ) { | |
| 3091 | + if ( '(' !== ( $this->sql[ $peek ] ?? null ) ) { | |
| 2985 | 3092 | return self::IDENTIFIER; |
| 2986 | 3093 | } |
| 2987 | 3094 | } |
| 2988 | 3095 | |