PluginProbe
SQLite Database Integration / 3.1.0
SQLite Database Integration v3.1.0
3.1.0 3.0.2 3.0.1 trunk 2.1.13 2.1.14 2.1.15 2.1.16 2.2.0 2.2.1 2.2.10 2.2.11 2.2.12 2.2.13 2.2.14 2.2.15 2.2.16 2.2.17 2.2.18 2.2.19 2.2.2 2.2.20 2.2.21 2.2.22 2.2.23 All 33 releases
← 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