diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index bef1bedeb..0e954a7ab 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php @@ -4431,12 +4431,17 @@ private function translate( $node ): ?string { return $this->translate_bit_expr( $node ); case 'simpleExprBody': return $this->translate_simple_expr_body( $node ); + case 'predicate': + $operations = $node->get_first_child_node( 'predicateOperations' ); + $token = null === $operations ? null : $operations->get_first_child_token(); + if ( null !== $token && WP_MySQL_Lexer::REGEXP_SYMBOL === $token->id ) { + return $this->translate_regexp_predicate( $node, $operations ); + } + return $this->translate_sequence( $node->get_children() ); case 'predicateOperations': $token = $node->get_first_child_token(); if ( WP_MySQL_Lexer::LIKE_SYMBOL === $token->id ) { return $this->translate_like( $node ); - } elseif ( WP_MySQL_Lexer::REGEXP_SYMBOL === $token->id ) { - return $this->translate_regexp_functions( $node ); } return $this->translate_sequence( $node->get_children() ); case 'runtimeFunctionCall': @@ -5154,31 +5159,28 @@ private function translate_like( WP_Parser_Node $node ): string { /** * Translate MySQL REGEXP expression to SQLite. * - * @param WP_Parser_Node $node The "predicateOperations" AST node. + * @param WP_Parser_Node $node The "predicate" AST node. + * @param WP_Parser_Node $operations The "predicateOperations" AST node. * @return string The translated value. * @throws WP_MySQL_On_SQLite_Exception When the translation fails. */ - private function translate_regexp_functions( WP_Parser_Node $node ): string { - $tokens = $node->get_descendant_tokens(); + private function translate_regexp_predicate( WP_Parser_Node $node, WP_Parser_Node $operations ): string { + $tokens = $operations->get_descendant_tokens(); $is_binary = isset( $tokens[1] ) && WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[1]->id; + $subject = $this->translate( $node->get_first_child_node() ); + $pattern = $this->translate( $operations->get_first_child_node() ); + $not = null === $node->get_first_child_node( 'notRule' ) ? '' : 'NOT '; /* - * If the query says REGEXP BINARY, the comparison is byte-by-byte - * and letter casing matters – lowercase and uppercase letters are - * represented using different byte codes. + * Call the REGEXP function directly, so that REGEXP BINARY can pass + * a flag for byte-by-byte, case-sensitive matching. * - * The REGEXP function can't be easily made to accept two - * parameters, so we'll have to use a hack to get around this. + * The parentheses keep the MySQL precedence. In SQLite, REGEXP binds + * as loosely as "=", so "1 = 'a' REGEXP 'a'" would be "(1 = 'a') REGEXP 'a'". * - * If the first character of the pattern is a null byte, we'll - * remove it and make the comparison case-sensitive. This should - * be reasonably safe since PHP does not allow null bytes in - * regular expressions anyway. + * PDO narrows integer UDF arguments to 32 bits, so pass the operands as text. */ - if ( true === $is_binary ) { - return 'REGEXP CHAR(0) || ' . $this->translate( $node->get_first_child_node() ); - } - return 'REGEXP ' . $this->translate( $node->get_first_child_node() ); + return sprintf( '(%sREGEXP(CAST(%s AS TEXT), CAST(%s AS TEXT)%s))', $not, $pattern, $subject, $is_binary ? ', 1' : '' ); } /** @@ -5393,6 +5395,17 @@ private function translate_function_call( WP_Parser_Node $node ): string { } case 'VERSION': return $this->quote_sqlite_value( $this->get_mysql_server_version_string() ); + case 'REGEXP_LIKE': + case 'REGEXP_SUBSTR': + case 'REGEXP_INSTR': + case 'REGEXP_REPLACE': + // PDO narrows integer UDF arguments to 32 bits, so pass the subject, the + // pattern, and REGEXP_REPLACE's replacement as text. + $text_args = min( count( $args ), 'REGEXP_REPLACE' === $name ? 3 : 2 ); + for ( $i = 0; $i < $text_args; $i++ ) { + $args[ $i ] = sprintf( 'CAST(%s AS TEXT)', $args[ $i ] ); + } + return sprintf( '%s(%s)', $name, implode( ', ', $args ) ); default: return $this->translate_sequence( $node->get_children() ); } diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php index 7b8828e7b..a8aeb18b8 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php @@ -22,10 +22,13 @@ class WP_SQLite_PDO_User_Defined_Functions { public static function register_for( $pdo ): self { $instance = new self(); foreach ( $instance->functions as $f => $t ) { - if ( $pdo instanceof Pdo\Sqlite ) { - $pdo->createFunction( $f, array( $instance, $t ) ); - } else { - $pdo->sqliteCreateFunction( $f, array( $instance, $t ) ); + $arities = $instance->function_arities[ $f ] ?? array( -1 ); + foreach ( $arities as $arity ) { + if ( $pdo instanceof Pdo\Sqlite ) { + $pdo->createFunction( $f, array( $instance, $t ), $arity ); + } else { + $pdo->sqliteCreateFunction( $f, array( $instance, $t ), $arity ); + } } } return $instance; @@ -61,6 +64,10 @@ public static function register_for( $pdo ): self { 'localtimestamp' => 'now', 'isnull' => 'isnull', 'regexp' => 'regexp', + 'regexp_like' => 'regexp_like', + 'regexp_replace' => 'regexp_replace', + 'regexp_substr' => 'regexp_substr', + 'regexp_instr' => 'regexp_instr', 'field' => 'field', 'log' => 'log', 'least' => 'least', @@ -86,6 +93,27 @@ public static function register_for( $pdo ): self { '_helper_like_to_glob_pattern' => '_helper_like_to_glob_pattern', ); + /** + * Exact argument counts for functions with optional arguments. + * + * Functions absent from this array are registered as variadic. + * + * @var array + */ + private $function_arities = array( + 'regexp' => array( 2, 3 ), + 'regexp_like' => array( 2, 3 ), + 'regexp_replace' => array( 3, 4, 5, 6 ), + 'regexp_substr' => array( 2, 3, 4, 5 ), + 'regexp_instr' => array( 2, 3, 4, 5, 6 ), + ); + + /** @var array|null Pattern, match type, and byte mode of the last compiled regex. */ + private $regexp_cached_key = null; + + /** @var string|null Last compiled PCRE pattern. */ + private $regexp_cached_compiled = null; + /** * First element of the RAND(N) LCG state (the value the output is derived from). * @@ -565,39 +593,210 @@ public function isnull( $field ) { } /** - * Method to emulate MySQL REGEXP() function. + * Method to emulate the MySQL REGEXP operator. * - * @param string $pattern Regular expression to match. - * @param string $field Haystack. + * @param string|null $pattern Regular expression to match. + * @param string|null $field Haystack. + * @param int $binary 1 for REGEXP BINARY, which matches bytes + * case-sensitively; 0 otherwise. * - * @return integer 1 if matched, 0 if not matched. + * @throws Exception If the pattern is not a valid regular expression. + * @return int|null 1 if matched, 0 if not matched, or NULL for NULL input. */ - public function regexp( $pattern, $field ) { + public function regexp( $pattern, $field, $binary = 0 ) { + if ( null === $pattern || null === $field ) { + return null; + } + /* - * If the original query says REGEXP BINARY - * the comparison is byte-by-byte and letter casing now - * matters since lower- and upper-case letters have different - * byte codes. - * - * The REGEXP function can't be easily made to accept two - * parameters, so we'll have to use a hack to get around this. - * - * If the first character of the pattern is a null byte, we'll - * remove it and make the comparison case-sensitive. This should - * be reasonably safe since PHP does not allow null bytes in - * regular expressions anyway. + * Data that is not valid UTF-8 can only be a binary string in MySQL, + * and MySQL matches binary strings byte by byte and case-sensitively. */ - if ( "\x00" === $pattern[0] ) { - $pattern = substr( $pattern, 1 ); - $flags = ''; - } else { - // Otherwise, the search is case-insensitive. - $flags = 'i'; + $byte_mode = 1 === $binary || ! $this->regexp_is_utf8( $pattern ) || ! $this->regexp_is_utf8( $field ); + $compiled = $this->regexp_compile( $pattern, $byte_mode ? 'c' : '', $byte_mode ); + return $this->regexp_match( $compiled, $pattern, $field ); + } + + /** + * Method to emulate MySQL REGEXP_LIKE() function. + * + * @param string|null $expr The subject string. + * @param string|null $pattern The regex pattern. + * @param string|null $match_type Optional MySQL match_type flags. + * + * @throws Exception If the pattern is not a valid regular expression. + * @return int|null 1 on match, 0 on no match, NULL if any argument is NULL. + */ + public function regexp_like( $expr, $pattern, $match_type = '' ) { + if ( null === $expr || null === $pattern || null === $match_type ) { + return null; + } + $compiled = $this->regexp_compile( $pattern, $match_type ); + return $this->regexp_match( $compiled, $pattern, $expr ); + } + + /** + * Method to emulate MySQL REGEXP_REPLACE() function. + * + * Uses MySQL/ICU replacement grammar: "$N" backreferences ("$0" is the + * full match), "${name}" named references, and "\X" emits X (drops the + * backslash). + * Negative `occurrence` is clamped to 1; `pos = char_count + 1` is + * accepted and can match a zero-width pattern at the end of the subject. + * + * @param string|null $expr Subject string. + * @param string|null $pattern Regex pattern. + * @param string|null $replacement Replacement string (supports $N backreferences). + * @param int|float|string|null $pos 1-based character position to start matching. + * @param int|float|string|null $occurrence Nth match to replace; 0 = all matches. + * @param string|null $match_type MySQL match_type flags. + * + * @throws Exception If the pattern is not a valid regular expression, or pos is out of range. + * @return string|null The replaced string, or NULL if any argument is NULL. + */ + public function regexp_replace( $expr, $pattern, $replacement, $pos = 1, $occurrence = 0, $match_type = '' ) { + if ( + null === $expr || null === $pattern || null === $replacement + || null === $pos || null === $occurrence || null === $match_type + ) { + return null; + } + $compiled = $this->regexp_compile( $pattern, $match_type ); + + $byte_start = $this->regexp_char_to_byte_offset( $expr, (int) $pos, true ); + + // 0 means replace all; negative occurrences are clamped to 1 (MySQL behavior). + $n = (int) $occurrence; + if ( $n < 0 ) { + $n = 1; + } + if ( '' === $expr ) { + return $expr; + } + + /* + * List the capture group keys, by index and name. preg_match_all() + * reports every group even when nothing matches, while preg_match() + * omits trailing unmatched groups before PHP 7.4. + */ + preg_match_all( $compiled, '', $groups ); + $group_keys = array_flip( array_keys( $groups ) ); + + // Rebuild the subject while streaming through the matches. With + // a positive occurrence, replace only that match. + $out = substr( $expr, 0, $byte_start ); + $cur = $byte_start; + $index = 0; + foreach ( $this->regexp_matches( $compiled, $pattern, $expr, $byte_start ) as $match ) { + if ( ++$index < $n ) { + continue; + } + $match_start = $match[0][1]; + $match_length = strlen( $match[0][0] ); + $out .= substr( $expr, $cur, $match_start - $cur ); + $out .= $this->regexp_expand_replacement( $replacement, $match, $group_keys ); + $cur = $match_start + $match_length; + if ( $n > 0 ) { + break; + } + } + $out .= substr( $expr, $cur ); + + return $out; + } + + /** + * Method to emulate MySQL REGEXP_SUBSTR() function. + * + * Values of `occurrence` less than 1 are clamped to 1, matching MySQL. + * `pos = char_count + 1` is accepted and can return a zero-width match. + * + * @param string|null $expr Subject string. + * @param string|null $pattern Regex pattern. + * @param int|float|string|null $pos 1-based character position to start matching. + * @param int|float|string|null $occurrence Which match to return (1-based; <= 0 clamps to 1). + * @param string|null $match_type MySQL match_type flags. + * + * @throws Exception If the pattern is not a valid regular expression, or pos is out of range. + * @return string|null The matched substring, NULL if no match or any argument is NULL. + */ + public function regexp_substr( $expr, $pattern, $pos = 1, $occurrence = 1, $match_type = '' ) { + if ( + null === $expr || null === $pattern + || null === $pos || null === $occurrence || null === $match_type + ) { + return null; } - $pattern = str_replace( '/', '\/', $pattern ); - $pattern = '/' . $pattern . '/' . $flags; + $compiled = $this->regexp_compile( $pattern, $match_type ); + + // MySQL clamps occurrence <= 0 to 1. + $n = max( 1, (int) $occurrence ); + + $byte_start = $this->regexp_char_to_byte_offset( $expr, (int) $pos, true ); - return preg_match( $pattern, $field ); + $match = $this->regexp_find_nth_match( $compiled, $pattern, $expr, $byte_start, $n ); + if ( null === $match ) { + return null; + } + return $match[0][0]; + } + + /** + * Method to emulate MySQL REGEXP_INSTR() function. + * + * Values of `occurrence` less than 1 are clamped to 1, matching MySQL. + * `pos` greater than char_count is rejected (unlike SUBSTR and REPLACE). + * Like MySQL, `pos` is not checked for an empty subject, where a match is + * reported at `pos`. + * + * @param string|null $expr Subject string. + * @param string|null $pattern Regex pattern. + * @param int|float|string|null $pos 1-based character position to start matching. + * @param int|float|string|null $occurrence Which match to locate (1-based; <= 0 clamps to 1). + * @param int|float|string|null $return_option 0 = start of match (default), 1 = one past end. + * @param string|null $match_type MySQL match_type flags. + * + * @throws Exception If the pattern is invalid, pos is out of range, or + * return_option is not 0 or 1. + * @return int|null 1-based character position, 0 if no match, NULL on NULL input. + */ + public function regexp_instr( $expr, $pattern, $pos = 1, $occurrence = 1, $return_option = 0, $match_type = '' ) { + if ( + null === $expr || null === $pattern || null === $pos + || null === $occurrence || null === $return_option || null === $match_type + ) { + return null; + } + $ret = (int) $return_option; + if ( 0 !== $ret && 1 !== $ret ) { + throw new Exception( 'Incorrect arguments to regexp_instr: return_option must be 1 or 0.' ); + } + $compiled = $this->regexp_compile( $pattern, $match_type ); + + $position = (int) $pos; + + // MySQL clamps occurrence <= 0 to 1. + $n = max( 1, (int) $occurrence ); + + $byte_start = '' === $expr ? 0 : $this->regexp_char_to_byte_offset( $expr, $position ); + $region = substr( $expr, $byte_start ); + + $match = $this->regexp_find_nth_match( $compiled, $pattern, $region, 0, $n ); + if ( null === $match ) { + return 0; + } + if ( '' === $expr ) { + return $position; + } + + list( $matched_text, $matched_byte_offset ) = $match[0]; + $target_byte = $byte_start + $matched_byte_offset; + if ( 1 === $ret ) { + $target_byte += strlen( $matched_text ); + } + + // Every byte except a UTF-8 continuation byte starts a character. + return $target_byte - preg_match_all( '/[\x80-\xBF]/', substr( $expr, 0, $target_byte ) ) + 1; } /** @@ -981,4 +1180,477 @@ public function _helper_like_to_glob_pattern( $pattern ) { return $pattern; } + + /** + * Compile a MySQL-style regex into a PCRE pattern string. + * + * Translates MySQL match_type flags (c/i/m/n/u) to PCRE modifiers. The u + * (UTF-8) modifier is applied unless byte-oriented matching is requested. + * Case-insensitive is the default. + * + * MySQL's native engine is ICU; we use PHP's PCRE. The two diverge in a + * few corners: + * + * - Some Unicode property shorthands and POSIX class spellings differ. + * - PCRE accepts both `(?...)` and `(?P...)`; MySQL accepts + * only the former and errors on the latter. + * - ICU supports multi-code-point case folds such as "ß" matching "ss"; + * PCRE's case-insensitive mode does not. + * - ICU-only syntax, such as `\uXXXX` and `\N{name}` in patterns, is an error. + * - An unknown escape, such as `\y`, is an error. ICU reads it as a literal. + * - ICU reads CRLF as one line break, and PCRE as two characters. `$` also + * matches between them, and `.` with the `n` flag matches only one of them. + * - ICU's nested sets and set operations don't exist in PCRE. `[a[b]]` and + * `[a&&b]` silently match something else, and `[a--b]` is usually an error. + * - A hyphen after a POSIX class or a property, as in `[[:alpha:]-z]` or + * `[\p{L}-x]`, is an error. ICU reads it literally. + * - A POSIX class outside of a set, as in `[:alpha:]`, is usually an error. ICU + * reads it like `[[:alpha:]]`. + * + * Known limitations of this emulation: + * + * - The default (case-insensitive) is correct for the usual + * `utf8mb4_0900_ai_ci` collation; callers that rely on a `_bin` or + * `_cs` collation must pass an explicit `c` match_type because this + * helper has no access to the session collation. + * - The `u` (UTF-8) PCRE modifier is applied to REGEXP_* functions, so + * they reject data that is not valid UTF-8 from the start position on. + * The REGEXP operators match such data, and REGEXP BINARY, byte by byte, + * like MySQL binary strings. Binary values that are valid UTF-8 match as + * text, so case sensitivity can differ between rows. + * - SQLite converts float and decimal operands to text, in a different format + * from MySQL: `20e0` reads "20.0" (MySQL: "20"), `0.000001` reads "1.0e-06", + * and `1.50` reads "1.5". Integers above the BIGINT range read as floats. + * - `pos`, `occurrence`, and `return_option` use PHP's integer conversion, so + * `2.6` means 2 (MySQL: 3), and `'2e1'` means 20 (MySQL: 2). + * - Inline case flags don't reach [:upper:] and [:lower:], or \p{Lu} and + * \p{Ll} on PCRE 10.44 and older. They stay case-sensitive in + * `(?i)[[:upper:]]` with the `c` flag, and in the whole pattern when it + * contains "(?-i)". + * - A repeated group, such as `(a|b)*`, can exceed PCRE's limits on a subject + * of about 50,000 characters, or 2,000 on PHP 7.2. MySQL allows more. + * + * @param string $pattern The MySQL regex pattern. + * @param string $match_type MySQL match_type flag string. + * @param bool $binary_mode Whether to match bytes instead of UTF-8 characters. + * + * @throws Exception If the pattern is empty or the match_type string + * contains an unrecognized flag. + * @return string PCRE-ready pattern with delimiter and modifiers. + */ + private function regexp_compile( $pattern, $match_type, $binary_mode = false ) { + $match_type = (string) $match_type; + $pattern = (string) $pattern; + if ( '' === $pattern ) { + throw new Exception( 'Illegal argument to a regular expression.' ); + } + $cache_key = array( $pattern, $match_type, $binary_mode ); + if ( $cache_key === $this->regexp_cached_key ) { + return $this->regexp_cached_compiled; + } + + $case_sensitive = false; + $multiline = false; + $dotall = false; + $unix_lines = false; + $len = strlen( $match_type ); + for ( $i = 0; $i < $len; $i++ ) { + $flag = $match_type[ $i ]; + if ( 'c' === $flag ) { + $case_sensitive = true; + } elseif ( 'i' === $flag ) { + $case_sensitive = false; + } elseif ( 'm' === $flag ) { + $multiline = true; + } elseif ( 'n' === $flag ) { + $dotall = true; + } elseif ( 'u' === $flag ) { + $unix_lines = true; + } else { + throw new Exception( "Invalid match_type flag: $flag." ); + } + } + + /* + * PHP 7 can read an unknown escape, such as "\y" or "\u0041", as literal text. + * The "X" modifier makes it an error, like in PHP 8. + */ + $modifiers = $binary_mode ? 'X' : 'uX'; + if ( ! $case_sensitive ) { + $modifiers .= 'i'; + } + if ( $multiline ) { + $modifiers .= 'm'; + } + if ( $dotall ) { + $modifiers .= 's'; + } + + $newline = $unix_lines ? '(*LF)' : '(*ANY)'; + $compiled = '/' . $newline . $this->regexp_translate_pattern( $pattern, ! $case_sensitive ) . '/' . $modifiers; + + /* + * Compile errors are the only preg failures that emit PHP warnings. + * Silence them, and report the failure as a MySQL error instead. + */ + set_error_handler( static function () {} ); + try { + $valid = preg_match( $compiled, '' ); + } finally { + restore_error_handler(); + } + if ( false === $valid ) { + $this->regexp_fail( $pattern ); + } + $this->regexp_cached_key = $cache_key; + $this->regexp_cached_compiled = $compiled; + return $compiled; + } + + /** + * Translate a MySQL regex pattern to PCRE syntax. + * + * Escapes pattern delimiters and NUL bytes that are not already escaped. PHP + * before 8.2 rejects NUL bytes in patterns, so they become "\x00". POSIX + * classes that PCRE reads differently from ICU become Unicode properties, and + * a hyphen after a shorthand class is escaped. + * + * @param string $pattern The regex pattern. + * @param bool $case_insensitive Whether the pattern ignores case by default. + * + * @return string Pattern safe to wrap in slash delimiters. + */ + private function regexp_translate_pattern( $pattern, $case_insensitive ) { + $special = array( + '/' => '\\/', + "\0" => '\\x00', + ); + + // PCRE's [:punct:] also holds ASCII symbols like "+", where ICU's doesn't. + $classes = array( 'punct' => '{P}' ); + + /* + * With the "u" modifier, PCRE reads [:upper:] and [:lower:] case-sensitively + * even when the pattern ignores case, where ICU matches both cases. PCRE 10.44 + * and older read \p{Lu} and \p{Ll} the same way. \p{L&} doesn't follow inline + * flags, so a pattern with "(?-i)" keeps PCRE's reading. + */ + $match_both_cases = $case_insensitive && ! preg_match( '/\(\?[a-zA-Z]*-[a-zA-Z]*i/', $pattern ); + if ( $match_both_cases ) { + $classes['upper'] = '{L&}'; + $classes['lower'] = '{L&}'; + } + + $escaped = ''; + $quoted = false; + $length = strlen( $pattern ); + for ( $i = 0; $i < $length; ++$i ) { + $character = $pattern[ $i ]; + if ( $quoted ) { + if ( '\\' === $character && $i + 1 < $length && 'E' === $pattern[ $i + 1 ] ) { + $escaped .= '\\E'; + $quoted = false; + ++$i; + } elseif ( isset( $special[ $character ] ) ) { + $escaped .= '\\E' . $special[ $character ] . '\\Q'; + } else { + $escaped .= $character; + } + continue; + } + if ( + $match_both_cases + && '\\' === $character + && preg_match( '/\G\\\\([pP])\{L[lu]\}/', $pattern, $property, 0, $i ) + ) { + $escaped .= '\\' . $property[1] . '{L&}'; + $i += strlen( $property[0] ) - 1; + } elseif ( '\\' === $character && $i + 1 < $length ) { + $next = $pattern[ $i + 1 ]; + $escaped .= isset( $special[ $next ] ) ? $special[ $next ] : $character . $next; + $quoted = 'Q' === $next; + ++$i; + + /* + * PCRE rejects a hyphen after a shorthand class, as in "[\w-.]", where + * ICU reads it literally. Escaping it is also safe outside of a set. + * ICU reads "--" as a set difference, so that is left as it is. + */ + if ( + false !== strpos( 'dDhHsSvVwW', $next ) + && '-' === substr( $pattern, $i + 1, 1 ) + && '-' !== substr( $pattern, $i + 2, 1 ) + ) { + $escaped .= '\\-'; + ++$i; + } + } elseif ( isset( $special[ $character ] ) ) { + $escaped .= $special[ $character ]; + } elseif ( + '[' === $character + && preg_match( '/\G\[:(\^?)(\w+):\]/', $pattern, $class, 0, $i ) + && isset( $classes[ $class[2] ] ) + ) { + $escaped .= ( '' === $class[1] ? '\\p' : '\\P' ) . $classes[ $class[2] ]; + $i += strlen( $class[0] ) - 1; + } else { + $escaped .= $character; + } + } + return $escaped; + } + + /** + * Check whether a string is valid UTF-8. + * + * @param string $value The string. + * + * @return bool Whether the string is valid UTF-8. + */ + private function regexp_is_utf8( $value ) { + return 1 === preg_match( '//u', $value ); + } + + /** + * Convert a 1-based character position into a byte offset into the UTF-8 string. + * + * @param string $s UTF-8 string. + * @param int $char_pos 1-based character position. + * @param bool $allow_past_end Whether to accept char_pos == char_count + 1 + * (returns strlen($s)). MySQL allows this for + * REGEXP_REPLACE and REGEXP_SUBSTR but not for + * REGEXP_INSTR. + * + * @throws Exception If $char_pos is out of range. + * @return int Byte offset into $s. + */ + private function regexp_char_to_byte_offset( $s, $char_pos, $allow_past_end = false ) { + if ( $char_pos < 1 ) { + throw new Exception( 'Index out of bounds in regular expression search.' ); + } + if ( 1 === $char_pos ) { + return 0; + } + $byte_len = strlen( $s ); + $chars = 1; + for ( $i = 0; $i < $byte_len; $i++ ) { + // Count every byte that isn't a UTF-8 continuation byte. + if ( ( ord( $s[ $i ] ) & 0xC0 ) !== 0x80 ) { + if ( $chars === $char_pos ) { + return $i; + } + ++$chars; + } + } + if ( $allow_past_end && $chars === $char_pos ) { + return $byte_len; + } + throw new Exception( 'Index out of bounds in regular expression search.' ); + } + + /** + * Expand a MySQL/ICU-style replacement template for one match. + * + * Rules (from ICU, used by MySQL REGEXP_REPLACE): + * - "\X" for any X: emit X, drop the backslash (also applies to "\\" -> "\"). + * Unlike ICU, "\uXXXX" and "\UXXXXXXXX" are not expanded. + * - Trailing lone backslash: dropped. + * - "$N" (N is one or more digits): emit the Nth capture group. Consumes + * the longest digit run that forms a valid group index; any trailing + * digits become literal text. + * - "${name}": emit the named capture group. Unknown names, and names + * with characters other than letters and the digits 1-9, are an error + * (ERROR 3887). So is "${N}", as only named groups use braces. + * - "$" not followed by a digit or "{": error (ERROR 3887). + * - "$N" where N is larger than any existing group: error (ERROR 3686). + * + * Unmatched groups expand to an empty string. + * + * @param string $replacement The replacement template. + * @param array $match_data The match in PREG_OFFSET_CAPTURE format. + * @param array $group_keys The pattern's capture group indexes and names, as array keys. + * + * @throws Exception On an invalid "$..." reference. + * @return string The expanded replacement. + */ + private function regexp_expand_replacement( $replacement, $match_data, $group_keys ) { + $out = ''; + $len = strlen( $replacement ); + $i = 0; + while ( $i < $len ) { + $c = $replacement[ $i ]; + if ( '\\' === $c ) { + if ( $i + 1 < $len ) { + $out .= $replacement[ $i + 1 ]; + $i += 2; + } else { + ++$i; + } + continue; + } + if ( '$' === $c ) { + if ( $i + 1 < $len && '{' === $replacement[ $i + 1 ] ) { + /* + * Like ICU, accept only letters and the digits 1-9 in a group name. + * A name must start with a letter, or "${1}" would match group 1's + * numeric key. + */ + if ( + ! preg_match( '/\G\{([A-Za-z][A-Za-z1-9]*)\}/', $replacement, $reference, 0, $i + 1 ) + || ! isset( $group_keys[ $reference[1] ] ) + ) { + throw new Exception( 'A capture group has an invalid name.' ); + } + $out .= $match_data[ $reference[1] ][0] ?? ''; + $i += 1 + strlen( $reference[0] ); + continue; + } + $digits = substr( $replacement, $i + 1, strspn( $replacement, '0123456789', $i + 1 ) ); + if ( '' === $digits ) { + throw new Exception( 'A capture group has an invalid name.' ); + } + // Longest digit prefix that refers to an existing group wins; + // remaining digits are emitted literally. + $idx = null; + $consumed = 0; + for ( $k = strlen( $digits ); $k > 0; --$k ) { + $cand = (int) substr( $digits, 0, $k ); + if ( isset( $group_keys[ $cand ] ) ) { + $idx = $cand; + $consumed = $k; + break; + } + } + if ( null === $idx ) { + throw new Exception( 'Index out of bounds in regular expression search.' ); + } + $out .= $match_data[ $idx ][0] ?? ''; + $i += 1 + $consumed; + continue; + } + $out .= $c; + ++$i; + } + return $out; + } + + /** + * Find one numbered match without retaining preceding matches. + * + * @param string $compiled PCRE-wrapped pattern. + * @param string $pattern The original MySQL regex pattern, for error messages. + * @param string $subject Full subject string. + * @param int $offset Byte offset at which matching begins. + * @param int $occurrence 1-based match number. + * + * @throws Exception On a preg failure. + * @return array|null Match array, or NULL if absent. + */ + private function regexp_find_nth_match( $compiled, $pattern, $subject, $offset, $occurrence ) { + $index = 0; + foreach ( $this->regexp_matches( $compiled, $pattern, $subject, $offset ) as $match ) { + if ( ++$index === $occurrence ) { + return $match; + } + } + return null; + } + + /** + * Iterate over matches from an offset without accumulating them in memory. + * + * Uses preg_match's offset argument rather than slicing the subject so + * lookbehind assertions can inspect bytes preceding the search position. + * + * @param string $compiled PCRE-wrapped pattern. + * @param string $pattern The original MySQL regex pattern, for error messages. + * @param string $subject Full subject string. + * @param int $offset Initial byte offset. + * + * @throws Exception On a preg failure. + * @return Generator Matches in PREG_OFFSET_CAPTURE format. + */ + private function regexp_matches( $compiled, $pattern, $subject, $offset ) { + $len = strlen( $subject ); + while ( $offset <= $len ) { + $r = $this->regexp_match( + $compiled, + $pattern, + $subject, + $m, + PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL, + $offset + ); + if ( 0 === $r ) { + return; + } + yield $m; + + $match_start = $m[0][1]; + $match_length = strlen( $m[0][0] ); + $offset = $match_start + $match_length; + if ( 0 === $match_length ) { + // Advance past a zero-width match to avoid looping on the same offset. + // Skip any UTF-8 continuation bytes so the next match starts on a code point boundary. + ++$offset; + while ( $offset < $len && ( ord( $subject[ $offset ] ) & 0xC0 ) === 0x80 ) { + ++$offset; + } + } + } + } + + /** + * Run preg_match(), and report a failure as a MySQL error. + * + * PHP's JIT stack is small, so a repeated group, such as "(a|b)*", can fail on + * a subject of a few thousand characters. Matching without JIT goes further. + * PHP 7.2 has no "(*NO_JIT)", and matching without JIT can crash it. + * + * @param string $compiled PCRE-wrapped pattern. + * @param string $pattern The original MySQL regex pattern, for error messages. + * @param string $subject The subject string. + * @param array $matches Receives the match, like in preg_match(). + * @param int $flags The preg_match() flags. + * @param int $offset Byte offset at which matching begins. + * + * @throws Exception On a preg failure. + * @return int 1 on match, 0 on no match. + */ + private function regexp_match( $compiled, $pattern, $subject, &$matches = null, $flags = 0, $offset = 0 ) { + $result = preg_match( $compiled, $subject, $matches, $flags, $offset ); + if ( false === $result && PREG_JIT_STACKLIMIT_ERROR === preg_last_error() && PHP_VERSION_ID >= 70300 ) { + $result = preg_match( '/(*NO_JIT)' . substr( $compiled, 1 ), $subject, $matches, $flags, $offset ); + } + if ( false === $result ) { + $this->regexp_fail( $pattern ); + } + return $result; + } + + /** + * Translate a preg_* failure into a caller-friendly exception message. + * + * Uses preg_last_error() to distinguish invalid patterns from runtime + * limit failures and invalid-UTF-8 input. + * + * @param string $pattern The original MySQL regex pattern. + * + * @throws Exception Always. + * @return void + */ + private function regexp_fail( $pattern ) { + $err = preg_last_error(); + if ( + PREG_BACKTRACK_LIMIT_ERROR === $err + || PREG_RECURSION_LIMIT_ERROR === $err + || PREG_JIT_STACKLIMIT_ERROR === $err + ) { + throw new Exception( 'Regular expression evaluation exceeded internal limits.' ); + } + if ( PREG_BAD_UTF8_ERROR === $err ) { + throw new Exception( 'Invalid UTF-8 data in regular expression input.' ); + } + throw new Exception( 'Invalid regular expression: ' . $pattern . '.' ); + } } diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php index 4f87a3a6f..b3aef8f6b 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php @@ -266,6 +266,1118 @@ public static function regexpOperators() { ); } + public function testRegexpFunctionsWithTableColumns() { + $this->assertQuery( + "INSERT INTO _options (option_name, option_value) VALUES + ('test-ignore', 'unchanged'), + ('test-remove', 'unchanged'), + ('keep', 'unchanged');" + ); + + $this->assertQuery( + "SELECT + option_name, + REGEXP_REPLACE(option_name, '(-ignore|-remove)\$', '') AS replacement_result, + REGEXP_SUBSTR(option_name, '[^-]+\$') AS substring_result, + REGEXP_INSTR(option_name, '-') AS position_result + FROM _options + WHERE REGEXP_LIKE(option_name, '^test-') + ORDER BY option_name" + ); + $this->assertEquals( + array( + (object) array( + 'option_name' => 'test-ignore', + 'replacement_result' => 'test', + 'substring_result' => 'ignore', + 'position_result' => '5', + ), + (object) array( + 'option_name' => 'test-remove', + 'replacement_result' => 'test', + 'substring_result' => 'remove', + 'position_result' => '5', + ), + ), + $this->last_result + ); + + $this->assertQuery( + "UPDATE _options + SET option_value = REGEXP_REPLACE(option_name, '^test-', '') + WHERE REGEXP_LIKE(option_name, '^test-')" + ); + $this->assertQuery( "SELECT option_name, option_value FROM _options WHERE option_value != 'unchanged' ORDER BY option_name" ); + $this->assertEquals( + array( + (object) array( + 'option_name' => 'test-ignore', + 'option_value' => 'ignore', + ), + (object) array( + 'option_name' => 'test-remove', + 'option_value' => 'remove', + ), + ), + $this->last_result + ); + } + + /** + * @dataProvider regexpLikeCases + */ + public function testRegexpLike( $expr, $pattern, $match_type, $expected ) { + $expr_sql = null === $expr ? 'NULL' : "'" . addslashes( $expr ) . "'"; + $pattern_sql = null === $pattern ? 'NULL' : "'" . addslashes( $pattern ) . "'"; + $args = $expr_sql . ', ' . $pattern_sql; + if ( null !== $match_type ) { + $args .= ", '" . addslashes( $match_type ) . "'"; + } + $this->assertQuery( "SELECT REGEXP_LIKE($args) AS r" ); + $this->assertSame( $expected, $this->last_result[0]->r ); + } + + public static function regexpLikeCases() { + return array( + // Basic matching. + 'match' => array( 'abc', 'abc', null, '1' ), + 'no match' => array( 'xbc', 'abc', null, '0' ), + 'quantifier match' => array( 'abbbbc', 'ab*bc', null, '1' ), + + // Default is case-insensitive (matches existing REGEXP operator behavior). + 'default i' => array( 'ABC', 'abc', null, '1' ), + + // Explicit flags. + 'explicit c' => array( 'ABC', 'abc', 'c', '0' ), + 'explicit i' => array( 'ABC', 'abc', 'i', '1' ), + + // Later flag wins. + 'ci -> c' => array( 'ABC', 'abc', 'ci', '1' ), + 'ic -> i' => array( 'ABC', 'abc', 'ic', '0' ), + + // Multiline. + 'm off: ^ anchored' => array( "abc\ndef", '^def', null, '0' ), + 'm on: ^ per line' => array( "abc\ndef", '^def', 'm', '1' ), + + // Dot matches newline. + "n off: . no \\n" => array( "a\nb", 'a.b', null, '0' ), + "n on: . matches \\n" => array( "a\nb", 'a.b', 'n', '1' ), + + // NULL propagation. + 'null expr' => array( null, 'abc', null, null ), + 'null pattern' => array( 'abc', null, null, null ), + ); + } + + public function testRegexpLikeNullMatchType() { + // A NULL match_type returns NULL, even for an invalid pattern. + foreach ( array( "'abc', 'abc', NULL", "NULL, '(abc', NULL" ) as $args ) { + $this->assertQuery( "SELECT REGEXP_LIKE($args) AS r" ); + $this->assertNull( $this->last_result[0]->r, $args ); + } + } + + /** + * @dataProvider regexpLikeErrors + */ + public function testRegexpLikeErrors( $sql, $message ) { + $this->assertQueryError( "SELECT $sql", $message ); + } + + public static function regexpLikeErrors() { + return array( + 'invalid pattern' => array( "REGEXP_LIKE('abc', '(abc')", 'Invalid regular expression: (abc.' ), + 'invalid flag' => array( "REGEXP_LIKE('abc', 'a', 'x')", 'Invalid match_type flag: x.' ), + 'unknown escape' => array( "REGEXP_LIKE('y', '\\\\y')", 'Invalid regular expression: \\y.' ), + 'ICU escape' => array( "REGEXP_LIKE('A', '\\\\u0041')", 'Invalid regular expression: \\u0041.' ), + 'bad property' => array( "REGEXP_LIKE('a', '\\\\p{Foo}')", 'Invalid regular expression: \\p{Foo}.' ), + 'operator escape' => array( "'y' REGEXP '\\\\y'", 'Invalid regular expression: \\y.' ), + 'binary escape' => array( "X'FF' REGEXP '\\\\y'", 'Invalid regular expression: \\y.' ), + 'extra argument' => array( + "REGEXP_LIKE('abc', 'abc', 'c', 'extra')", + 'SQLSTATE[HY000]: General error: 1 wrong number of arguments to function REGEXP_LIKE()', + ), + ); + } + + public function testRegexpMatchTypeMultipleFlags() { + // Later-wins across a four-character match_type. 'cimn' ends in 'n', + // so case-insensitive (last of c/i) + multiline + dotall apply. + $this->assertQuery( "SELECT REGEXP_LIKE('ABC', 'abc', 'cimn') AS r" ); + $this->assertSame( '1', $this->last_result[0]->r ); + } + + public function testRegexpInvalidUtf8() { + // Raw 0xFF is never valid UTF-8; /u rejects it, which regexp_fail + // translates to a dedicated error. + $this->assertQueryError( + "SELECT REGEXP_LIKE(CAST(X'FF' AS CHAR), 'a')", + 'Invalid UTF-8 data in regular expression input.' + ); + } + + public function testRegexpBacktrackLimit() { + // Classic exponential-backtracking pattern that exceeds PCRE's default + // backtrack limit; exercises the PREG_BACKTRACK_LIMIT_ERROR branch of + // regexp_fail(). + $subject = str_repeat( 'a', 30 ); + $this->assertQueryError( + "SELECT REGEXP_LIKE('$subject', '^(a?){30}a{30}\$')", + 'Regular expression evaluation exceeded internal limits.' + ); + } + + public function testRegexpJitStackLimit() { + // A repeated group exhausts PHP's JIT stack after a few thousand characters. + $subject = str_repeat( 'a', 20000 ); + if ( PHP_VERSION_ID < 70300 ) { + // PHP 7.2 can't retry without JIT. + $this->assertQueryError( + "SELECT REGEXP_LIKE('$subject', '^(a|b)*\$')", + 'Regular expression evaluation exceeded internal limits.' + ); + return; + } + $this->assertQuery( + "SELECT + '$subject' REGEXP '^(a|b)*\$' AS operator_result, + REGEXP_LIKE('$subject', '^(a|b)*\$') AS like_result, + LENGTH(REGEXP_SUBSTR('$subject', '(a|b)+')) AS substr_length, + REGEXP_INSTR('$subject', '(a|b)+\$', 1, 1, 1) AS instr_result" + ); + $this->assertEquals( + array( + (object) array( + 'operator_result' => '1', + 'like_result' => '1', + 'substr_length' => '20000', + 'instr_result' => '20001', + ), + ), + $this->last_result + ); + } + + public function testRegexpLimitWithoutJit() { + // Matching without JIT has a limit, too. + $subject = str_repeat( 'a', 200000 ); + $this->assertQueryError( + "SELECT REGEXP_LIKE('$subject', '^(a|b)*\$')", + 'Regular expression evaluation exceeded internal limits.' + ); + } + + public function testRegexpLegacyOperatorRegression() { + // The legacy REGEXP operator must keep working alongside REGEXP_LIKE. + $this->assertQuery( "SELECT 'abc' REGEXP 'ABC' AS r" ); + $this->assertSame( '1', $this->last_result[0]->r ); + $this->assertQuery( "SELECT 'abc' REGEXP 'xyz' AS r" ); + $this->assertSame( '0', $this->last_result[0]->r ); + } + + public function testRegexpLegacyOperatorNullPropagation() { + $this->assertQuery( "SELECT NULL REGEXP 'a' AS r" ); + $this->assertNull( $this->last_result[0]->r ); + $this->assertQuery( "SELECT 'a' RLIKE NULL AS r" ); + $this->assertNull( $this->last_result[0]->r ); + } + + public function testRegexpLegacyOperatorInvalidPattern() { + $this->assertQueryError( + "SELECT 'abc' REGEXP '(abc'", + 'Invalid regular expression: (abc.' + ); + $this->assertQueryError( + "SELECT 'abc' RLIKE ''", + 'Illegal argument to a regular expression.' + ); + } + + public function testRegexpLegacyOperatorEscapedDelimiter() { + $this->assertQuery( "SELECT '/' REGEXP '\\\\/' AS r" ); + $this->assertSame( '1', $this->last_result[0]->r ); + $this->assertQuery( "SELECT '/' REGEXP '\\\\Q/\\\\E' AS r" ); + $this->assertSame( '1', $this->last_result[0]->r ); + } + + public function testRegexpLegacyOperatorNumericOperands() { + $this->assertQuery( "SELECT 123 REGEXP '^12' AS r" ); + $this->assertSame( '1', $this->last_result[0]->r ); + $this->assertQuery( "SELECT 'x123' NOT REGEXP 123 AS r" ); + $this->assertSame( '0', $this->last_result[0]->r ); + } + + public function testRegexpLegacyOperatorUtf8Handling() { + $this->assertQuery( "SELECT 'é' REGEXP '^.\$' AS r" ); + $this->assertSame( '1', $this->last_result[0]->r ); + $this->assertQuery( "SELECT CAST(X'FF' AS CHAR) REGEXP BINARY CAST(X'FF' AS CHAR) AS r" ); + $this->assertSame( '1', $this->last_result[0]->r ); + } + + public function testRegexpNulBytePattern() { + // A pattern starting with a NUL byte is an ordinary case-insensitive pattern. + $this->assertQuery( 'CREATE TABLE nul_patterns (id INT PRIMARY KEY, s TEXT, p TEXT)' ); + $this->assertQuery( + "INSERT INTO nul_patterns VALUES (1, X'610062', X'0042'), (2, 'aBc', X'0062'), (3, 'abc', 'B')" + ); + $this->assertQuery( + 'SELECT + s REGEXP p AS matched, + s NOT REGEXP p AS not_matched, + s REGEXP BINARY p AS binary_matched, + REGEXP_LIKE(s, p) AS like_matched, + REGEXP_INSTR(s, p) AS position + FROM nul_patterns ORDER BY id' + ); + $this->assertEquals( + array( + (object) array( + 'matched' => '1', + 'not_matched' => '0', + 'binary_matched' => '0', + 'like_matched' => '1', + 'position' => '2', + ), + (object) array( + 'matched' => '0', + 'not_matched' => '1', + 'binary_matched' => '0', + 'like_matched' => '0', + 'position' => '0', + ), + (object) array( + 'matched' => '1', + 'not_matched' => '0', + 'binary_matched' => '0', + 'like_matched' => '1', + 'position' => '2', + ), + ), + $this->last_result + ); + } + + public function testRegexpLegacyOperatorPrecedence() { + // REGEXP binds tighter than comparison operators. + $this->assertQuery( + "SELECT + 1 = 'a' REGEXP 'a' AS a, + 0 = 'a' REGEXP 'b' AS b, + 'a' REGEXP 'a' = 1 AS c, + 1 = 'a' NOT REGEXP 'b' AS d, + 1 + 1 REGEXP '2' AS e, + NOT 'a' REGEXP 'b' AS f" + ); + $this->assertEquals( + array( + (object) array( + 'a' => '1', + 'b' => '1', + 'c' => '1', + 'd' => '1', + 'e' => '1', + 'f' => '1', + ), + ), + $this->last_result + ); + } + + public function testRegexpLegacyOperatorMatchesBinaryDataByBytes() { + // Non-UTF-8 data is matched like MySQL binary strings: byte by byte and case-sensitively. + $this->assertQuery( 'CREATE TABLE blobs (id INT PRIMARY KEY, b BLOB)' ); + $this->assertQuery( "INSERT INTO blobs VALUES (1, X'61FF62'), (2, NULL)" ); + $this->assertQuery( + "SELECT + b REGEXP 'a.b' AS any_byte, + b REGEXP '^a.b\$' AS anchored, + b REGEXP 'A' AS other_case, + b NOT REGEXP 'zzz' AS negated, + b REGEXP X'FF62' AS binary_pattern, + b REGEXP X'FE' AS other_byte + FROM blobs ORDER BY id" + ); + $this->assertEquals( + array( + (object) array( + 'any_byte' => '1', + 'anchored' => '1', + 'other_case' => '0', + 'negated' => '1', + 'binary_pattern' => '1', + 'other_byte' => '0', + ), + (object) array( + 'any_byte' => null, + 'anchored' => null, + 'other_case' => null, + 'negated' => null, + 'binary_pattern' => null, + 'other_byte' => null, + ), + ), + $this->last_result + ); + } + + public function testRegexpLikeEscapedDelimiter() { + $this->assertQuery( "SELECT REGEXP_LIKE('/', '\\\\/') AS r" ); + $this->assertSame( '1', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_LIKE('/', '\\\\Q/\\\\E', 'c') AS r" ); + $this->assertSame( '1', $this->last_result[0]->r ); + } + + public function testRegexpLikeNumericOperands() { + $this->assertQuery( 'SELECT REGEXP_LIKE(123, 2) AS r' ); + $this->assertSame( '1', $this->last_result[0]->r ); + } + + public function testRegexpUnicodeNewlineHandling() { + $this->assertQuery( "SELECT REGEXP_LIKE(CAST(X'610D62' AS CHAR), 'a.b', 'c') AS r" ); + $this->assertSame( '0', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_LIKE(CAST(X'610D62' AS CHAR), '^b', 'cm') AS r" ); + $this->assertSame( '1', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_LIKE(CAST(X'610D62' AS CHAR), 'a.b', 'cu') AS r" ); + $this->assertSame( '1', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_LIKE(CAST(X'61E280A862' AS CHAR), '^b', 'cm') AS r" ); + $this->assertSame( '1', $this->last_result[0]->r ); + } + + /** + * @dataProvider regexpReplaceBasicCases + */ + public function testRegexpReplaceBasic( $expr, $pattern, $replacement, $expected ) { + $this->assertQuery( + sprintf( + "SELECT REGEXP_REPLACE('%s', '%s', '%s') AS r", + addslashes( $expr ), + addslashes( $pattern ), + addslashes( $replacement ) + ) + ); + $this->assertSame( $expected, $this->last_result[0]->r ); + } + + public static function regexpReplaceBasicCases() { + return array( + 'simple' => array( 'abcabc', 'b', 'X', 'aXcaXc' ), + 'no match' => array( 'abc', 'z', 'X', 'abc' ), + 'quantifier' => array( 'aabbcc', 'b+', 'B', 'aaBcc' ), + 'groups' => array( 'John Doe', '(\\w+) (\\w+)', '$2 $1', 'Doe John' ), + 'case-insensitive' => array( 'ABC', 'abc', 'x', 'x' ), + ); + } + + /** + * @dataProvider regexpReplaceErrors + */ + public function testRegexpReplaceErrors( $sql, $message ) { + $this->assertQueryError( "SELECT $sql", $message ); + } + + public static function regexpReplaceErrors() { + return array( + 'pos zero' => array( "REGEXP_REPLACE('abc', 'a', 'X', 0)", 'Index out of bounds in regular expression search.' ), + 'pos two past end' => array( "REGEXP_REPLACE('abc', 'a', 'X', 5)", 'Index out of bounds in regular expression search.' ), + 'pos far past end' => array( "REGEXP_REPLACE('abc', 'a', 'X', 10)", 'Index out of bounds in regular expression search.' ), + 'invalid UTF-8 subject' => array( "REGEXP_REPLACE(CAST(X'FF' AS CHAR), 'a', 'X')", 'Invalid UTF-8 data in regular expression input.' ), + 'extra argument' => array( + "REGEXP_REPLACE('abc', 'a', 'x', 1, 0, 'c', 'extra')", + 'SQLSTATE[HY000]: General error: 1 wrong number of arguments to function REGEXP_REPLACE()', + ), + ); + } + + /** + * @dataProvider regexpReplaceFullCases + */ + public function testRegexpReplaceFull( $sql, $expected ) { + $this->assertQuery( "SELECT $sql AS r" ); + $this->assertSame( $expected, $this->last_result[0]->r ); + } + + public static function regexpReplaceFullCases() { + return array( + // pos: only replace from position 3 onward (1-based, character). + 'pos=3' => array( "REGEXP_REPLACE('abcabc', 'b', 'X', 3)", 'abcaXc' ), + // occurrence=1: replace only the first match after pos. + 'occurrence=1 from start' => array( "REGEXP_REPLACE('abcabc', 'b', 'X', 1, 1)", 'aXcabc' ), + // occurrence=2 from start. + 'occurrence=2 from start' => array( "REGEXP_REPLACE('abcabc', 'b', 'X', 1, 2)", 'abcaXc' ), + // occurrence=0 means all matches from pos. + 'occurrence=0 from pos 3' => array( "REGEXP_REPLACE('abcabc', 'b', 'X', 3, 0)", 'abcaXc' ), + // match_type c with default pos/occurrence. + 'match_type c' => array( "REGEXP_REPLACE('ABC', 'abc', 'x', 1, 0, 'c')", 'ABC' ), + // match_type i. + 'match_type i' => array( "REGEXP_REPLACE('ABC', 'abc', 'x', 1, 0, 'i')", 'x' ), + // Multi-byte pos: skip the first character, replace only in the rest. + 'multibyte pos' => array( "REGEXP_REPLACE('éabc', 'a', 'X', 2)", 'éXbc' ), + // MySQL allows pos = char_count + 1. + 'pos one past end' => array( "REGEXP_REPLACE('abc', 'a', 'X', 4)", 'abc' ), + 'zero-width at end' => array( "REGEXP_REPLACE('abc', '\$', 'X', 4)", 'abcX' ), + 'null expr' => array( "REGEXP_REPLACE(NULL, 'a', 'b')", null ), + 'null pattern' => array( "REGEXP_REPLACE('abc', NULL, 'b')", null ), + 'null replacement' => array( "REGEXP_REPLACE('abc', 'a', NULL)", null ), + // A NULL match_type returns NULL before the pattern and pos are checked. + 'null match_type first' => array( "REGEXP_REPLACE('abc', '(abc', 'x', 0, 0, NULL)", null ), + ); + } + + public function testRegexpReplaceNumericOperands() { + $this->assertQuery( "SELECT REGEXP_REPLACE(123, '2', 9) AS r" ); + $this->assertSame( '193', $this->last_result[0]->r ); + } + + public function testRegexpReplaceOccurrenceBeyondMatches() { + // MySQL: if occurrence exceeds the number of matches, return subject unchanged. + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', 'a', 'X', 1, 5) AS r" ); + $this->assertSame( 'abc', $this->last_result[0]->r ); + } + + public function testRegexpReplaceNegativeOccurrenceClamped() { + // MySQL clamps negative occurrence to 1; 0 still means "replace all". + $this->assertQuery( "SELECT REGEXP_REPLACE('abcabc', 'b', 'X', 1, -100) AS r" ); + $this->assertSame( 'aXcabc', $this->last_result[0]->r ); + } + + public function testRegexpReplaceOccurrenceBackreferenceForms() { + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', '(a)(b)(c)', '\$2\$1', 1, 1) AS r" ); + $this->assertSame( 'ba', $this->last_result[0]->r ); + } + + public function testRegexpReplaceBraceBackrefIsInvalid() { + // MySQL/ICU rejects "${N}"; only named groups are referenced with braces. + $this->assertQueryError( + "SELECT REGEXP_REPLACE('abc', '(a)(b)(c)', '\${2}\${1}', 1, 1)", + 'A capture group has an invalid name.' + ); + } + + public function testRegexpReplaceNamedGroupReference() { + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', '(?b)', '[\${x}]') AS r" ); + $this->assertSame( 'a[b]c', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', '(?b)(?c)', '[\${y}\${x}\$1]') AS r" ); + $this->assertSame( 'a[cbb]', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', '(?b)', '[\${x1}]') AS r" ); + $this->assertSame( 'a[b]c', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', '(?b)', '[\${B}]') AS r" ); + $this->assertSame( 'a[b]c', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', '(?b)', '[\${x}1]') AS r" ); + $this->assertSame( 'a[b1]c', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', '(?b)', '\\\\\${x}') AS r" ); + $this->assertSame( 'a${x}c', $this->last_result[0]->r ); + + // Unmatched named groups expand to an empty string. + $this->assertQuery( "SELECT REGEXP_REPLACE('ac', '(?b)?c', '[\${x}]') AS r" ); + $this->assertSame( 'a[]', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('a', 'a(?b)?', '[\${x}]') AS r" ); + $this->assertSame( '[]', $this->last_result[0]->r ); + } + + public function testRegexpReplaceInvalidNamedGroupReference() { + $cases = array( + array( '(?b)', '[${x]' ), + array( '(?b)', '[${}]' ), + array( '(?b)', '[${y}]' ), + array( '(?b)', '[${ x}]' ), + array( '(?b)', '[$x]' ), + array( '(?b)', '[${a}]' ), + array( '(?b)', '[${b}]' ), + // ICU names in "${name}" exclude the digit 0. + array( '(?b)', '[${x0}]' ), + ); + foreach ( $cases as $case ) { + list( $pattern, $replacement ) = $case; + $this->assertQueryError( + "SELECT REGEXP_REPLACE('abc', '$pattern', '$replacement')", + 'A capture group has an invalid name.' + ); + } + } + + public function testRegexpReplaceBackslashDigitIsLiteral() { + // MySQL strips backslash before any character; "\2\1" becomes "21". + $this->assertQuery( "SELECT REGEXP_REPLACE('xyzabc', '(a)(b)(c)', '\\\\2\\\\1', 1, 1) AS r" ); + $this->assertSame( 'xyz21', $this->last_result[0]->r ); + } + + public function testRegexpReplaceFullMatchBackref() { + // "$0" is the whole match. + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', 'b', '[\$0]') AS r" ); + $this->assertSame( 'a[b]c', $this->last_result[0]->r ); + } + + public function testRegexpReplaceBackslashZeroIsLiteral() { + // "\0" is literal "0", not the full match. + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', 'b', '[\\\\0]') AS r" ); + $this->assertSame( 'a[0]c', $this->last_result[0]->r ); + } + + public function testRegexpReplaceDollarDigitGreedyFallback() { + // "$10" with a single capture group is "$1" followed by literal "0". + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', '(b)', '[\$10]') AS r" ); + $this->assertSame( 'a[b0]c', $this->last_result[0]->r ); + } + + public function testRegexpReplaceDollarWithoutDigitErrors() { + $this->assertQueryError( + "SELECT REGEXP_REPLACE('abc', 'b', 'x\$y')", + 'A capture group has an invalid name.' + ); + } + + public function testRegexpReplaceDollarOutOfBoundsErrors() { + $this->assertQueryError( + "SELECT REGEXP_REPLACE('abc', '(b)', '[\$9]')", + 'Index out of bounds in regular expression search.' + ); + } + + public function testRegexpReplacePreservesNumericCaptureIndexes() { + $this->assertQuery( "SELECT REGEXP_REPLACE('a', '(?a)(?b)?', '[\$2]') AS r" ); + $this->assertSame( '[]', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('a', '(a)|(b)', '[\$2]') AS r" ); + $this->assertSame( '[]', $this->last_result[0]->r ); + } + + public function testRegexpReplaceTrailingUnmatchedGroups() { + // Unmatched groups at the end of the pattern still count as groups. + $this->assertQuery( "SELECT REGEXP_REPLACE('a', 'a(b)?', '[\$1]') AS r" ); + $this->assertSame( '[]', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('aa', 'a(b)?', '[\$1]') AS r" ); + $this->assertSame( '[][]', $this->last_result[0]->r ); + + // "$10" refers to the tenth group even when groups 2-10 did not match. + $pattern = '(a)(b)?(c)?(d)?(e)?(f)?(g)?(h)?(i)?(j)?'; + $this->assertQuery( "SELECT REGEXP_REPLACE('ab', '$pattern', '[\$10]') AS r" ); + $this->assertSame( '[]', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('ab', '$pattern', '[\$11]') AS r" ); + $this->assertSame( '[a1]', $this->last_result[0]->r ); + } + + public function testRegexpReplaceTrailingBackslashDropped() { + // Trailing lone backslash is dropped (matches MySQL). + $this->assertQuery( "SELECT REGEXP_REPLACE('a', 'a', 'x\\\\') AS r" ); + $this->assertSame( 'x', $this->last_result[0]->r ); + } + + public function testRegexpReplaceBackslashLetterIsLiteral() { + // "\q" -> "q" (backslash stripped before any char, including letters). + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', 'b', '[\\\\q]') AS r" ); + $this->assertSame( 'a[q]c', $this->last_result[0]->r ); + } + + public function testRegexpReplaceOccurrenceLookbehind() { + // Lookbehind depends on pattern context; previously a context-less + // preg_replace on the matched text silently dropped the replacement. + $this->assertQuery( "SELECT REGEXP_REPLACE('abcabc', '(?<=a)b', 'X', 1, 1) AS r" ); + $this->assertSame( 'aXcabc', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('abcabc', '(?<=a)b', 'X', 1, 2) AS r" ); + $this->assertSame( 'abcaXc', $this->last_result[0]->r ); + } + + public function testRegexpReplaceLookbehindAcrossPos() { + // The lookbehind sees bytes before pos because the full subject is kept. + $this->assertQuery( "SELECT REGEXP_REPLACE('ab', '(?<=a)b', 'X', 2) AS r" ); + $this->assertSame( 'aX', $this->last_result[0]->r ); + } + + public function testRegexpReplaceOccurrenceZeroWidth() { + // Zero-width match at a word boundary. + $this->assertQuery( "SELECT REGEXP_REPLACE('abc def', '\\\\b', '|', 1, 1) AS r" ); + $this->assertSame( '|abc def', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('abc def', '\\\\b', '|', 1, 2) AS r" ); + $this->assertSame( 'abc| def', $this->last_result[0]->r ); + } + + public function testRegexpReplaceOccurrenceLiteralEscapes() { + // \\ -> literal backslash in the replacement. SQL string literal tricks: + // PHP source "\\\\\\\\" is 4 backslashes, which SQL parses as 2 backslashes + // received by the function; the replacement grammar expander folds those + // to a single literal backslash. + $this->assertQuery( "SELECT REGEXP_REPLACE('a', 'a', '\\\\\\\\', 1, 1) AS r" ); + $this->assertSame( '\\', $this->last_result[0]->r ); + } + + public function testRegexpReplaceEmptyReplacement() { + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', 'b', '') AS r" ); + $this->assertSame( 'ac', $this->last_result[0]->r ); + } + + public function testRegexpReplaceLeavesEmptySubjectUnchanged() { + $this->assertQuery( "SELECT REGEXP_REPLACE('', '\$', 'X') AS r" ); + $this->assertSame( '', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('', '\$', '\$x') AS r" ); + $this->assertSame( '', $this->last_result[0]->r ); + } + + /** + * @dataProvider regexpSubstrCases + */ + public function testRegexpSubstr( $sql, $expected ) { + $this->assertQuery( "SELECT $sql AS r" ); + $this->assertSame( $expected, $this->last_result[0]->r ); + } + + public static function regexpSubstrCases() { + return array( + 'basic match' => array( "REGEXP_SUBSTR('abc123def', '[0-9]+')", '123' ), + 'no match' => array( "REGEXP_SUBSTR('abcdef', '[0-9]+')", null ), + 'pos' => array( "REGEXP_SUBSTR('abc123def456', '[0-9]+', 5)", '23' ), + 'pos with occurrence=2' => array( "REGEXP_SUBSTR('abc123def456', '[0-9]+', 5, 2)", '456' ), + 'occurrence' => array( "REGEXP_SUBSTR('a1 b2 c3', '[a-z][0-9]', 1, 2)", 'b2' ), + 'occurrence too high' => array( "REGEXP_SUBSTR('a1 b2', '[a-z][0-9]', 1, 5)", null ), + 'match_type c' => array( "REGEXP_SUBSTR('ABC', 'abc', 1, 1, 'c')", null ), + 'multibyte match' => array( "REGEXP_SUBSTR('café', 'é')", 'é' ), + // MySQL allows pos = char_count + 1. + 'pos one past end' => array( "REGEXP_SUBSTR('abc', 'a', 4)", null ), + 'zero-width at end' => array( "REGEXP_SUBSTR('abc', '\$', 4)", '' ), + 'null expr' => array( 'REGEXP_SUBSTR(NULL, \'abc\')', null ), + 'null pattern' => array( "REGEXP_SUBSTR('abc', NULL)", null ), + 'null pos' => array( "REGEXP_SUBSTR('abc', 'a', NULL)", null ), + 'null occurrence' => array( "REGEXP_SUBSTR('abc', 'a', 1, NULL)", null ), + 'null match_type' => array( "REGEXP_SUBSTR('abc', 'a', 1, 1, NULL)", null ), + // A NULL match_type returns NULL before the pattern and pos are checked. + 'null match_type first' => array( "REGEXP_SUBSTR('abc', '(abc', 0, 1, NULL)", null ), + ); + } + + /** + * @dataProvider regexpSubstrErrors + */ + public function testRegexpSubstrErrors( $sql, $message ) { + $this->assertQueryError( "SELECT $sql", $message ); + } + + public static function regexpSubstrErrors() { + return array( + 'invalid pattern' => array( "REGEXP_SUBSTR('abc', '(abc')", 'Invalid regular expression: (abc.' ), + 'invalid flag' => array( "REGEXP_SUBSTR('abc', 'a', 1, 1, 'x')", 'Invalid match_type flag: x.' ), + 'pos zero' => array( "REGEXP_SUBSTR('abc', 'a', 0)", 'Index out of bounds in regular expression search.' ), + 'pos two past end' => array( "REGEXP_SUBSTR('abc', 'a', 5)", 'Index out of bounds in regular expression search.' ), + 'pos far past end' => array( "REGEXP_SUBSTR('abc', 'a', 10)", 'Index out of bounds in regular expression search.' ), + 'invalid UTF-8 subject' => array( "REGEXP_SUBSTR(CAST(X'FF' AS CHAR), 'a')", 'Invalid UTF-8 data in regular expression input.' ), + 'extra argument' => array( + "REGEXP_SUBSTR('abc', 'a', 1, 1, 'c', 'extra')", + 'SQLSTATE[HY000]: General error: 1 wrong number of arguments to function REGEXP_SUBSTR()', + ), + ); + } + + public function testRegexpSubstrOccurrenceClampedToOne() { + // MySQL clamps occurrence <= 0 to 1. + $this->assertQuery( "SELECT REGEXP_SUBSTR('abcabc', 'b', 1, 0) AS r" ); + $this->assertSame( 'b', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_SUBSTR('abcabc', 'b', 1, -5) AS r" ); + $this->assertSame( 'b', $this->last_result[0]->r ); + } + + public function testRegexpSubstrLookbehindAcrossPos() { + // The lookbehind sees bytes before pos because the full subject is kept. + $this->assertQuery( "SELECT REGEXP_SUBSTR('ab', '(?<=a)b', 2) AS r" ); + $this->assertSame( 'b', $this->last_result[0]->r ); + } + + public function testRegexpSubstrNumericSubject() { + $this->assertQuery( "SELECT REGEXP_SUBSTR(123, '2') AS r" ); + $this->assertSame( '2', $this->last_result[0]->r ); + } + + /** + * @dataProvider regexpInstrCases + */ + public function testRegexpInstr( $sql, $expected ) { + $this->assertQuery( "SELECT $sql AS r" ); + $this->assertSame( $expected, $this->last_result[0]->r ); + } + + public static function regexpInstrCases() { + return array( + 'basic' => array( "REGEXP_INSTR('dog cat dog', 'dog')", '1' ), + 'no match' => array( "REGEXP_INSTR('abc', 'xyz')", '0' ), + 'second match' => array( "REGEXP_INSTR('dog cat dog', 'dog', 1, 2)", '9' ), + 'pos skips first match' => array( "REGEXP_INSTR('dog cat dog', 'dog', 5)", '9' ), + 'return_option=1 (end)' => array( "REGEXP_INSTR('dog cat dog', 'dog', 1, 1, 1)", '4' ), + 'match_type c miss' => array( "REGEXP_INSTR('DOG', 'dog', 1, 1, 0, 'c')", '0' ), + 'multibyte position' => array( "REGEXP_INSTR('café123', '[0-9]+')", '5' ), + 'null expr' => array( "REGEXP_INSTR(NULL, 'abc')", null ), + 'null pattern' => array( "REGEXP_INSTR('abc', NULL)", null ), + // A NULL match_type returns NULL before the pattern and pos are checked. + 'null match_type first' => array( "REGEXP_INSTR('abc', '(abc', 0, 1, 0, NULL)", null ), + // NULL arguments return NULL before pos is checked. + 'null expr, pos 0' => array( "REGEXP_INSTR(NULL, 'a', 0)", null ), + 'null expr, pos -1' => array( "REGEXP_INSTR(NULL, 'a', -1)", null ), + 'null expr, pos 5' => array( "REGEXP_INSTR(NULL, 'a', 5)", null ), + 'null pattern, pos 0' => array( "REGEXP_INSTR('abc', NULL, 0)", null ), + 'null occurrence, pos 0' => array( "REGEXP_INSTR('abc', 'a', 0, NULL)", null ), + ); + } + + /** + * @dataProvider regexpInstrErrors + */ + public function testRegexpInstrErrors( $sql, $message ) { + $this->assertQueryError( "SELECT $sql", $message ); + } + + public static function regexpInstrErrors() { + $return_option = 'Incorrect arguments to regexp_instr: return_option must be 1 or 0.'; + return array( + 'invalid pattern' => array( "REGEXP_INSTR('abc', '(abc')", 'Invalid regular expression: (abc.' ), + 'invalid flag' => array( "REGEXP_INSTR('abc', 'a', 1, 1, 0, 'x')", 'Invalid match_type flag: x.' ), + 'pos zero' => array( "REGEXP_INSTR('abc', 'a', 0)", 'Index out of bounds in regular expression search.' ), + 'pos far past end' => array( "REGEXP_INSTR('abc', 'a', 10)", 'Index out of bounds in regular expression search.' ), + 'invalid return_option' => array( "REGEXP_INSTR('abc', 'a', 1, 1, 2)", $return_option ), + 'extra argument' => array( + "REGEXP_INSTR('abc', 'a', 1, 1, 0, 'c', 'extra')", + 'SQLSTATE[HY000]: General error: 1 wrong number of arguments to function REGEXP_INSTR()', + ), + ); + } + + public function testRegexpInstrEmptySubjectSkipsPosCheck() { + // MySQL does not check pos for an empty subject and reports a match at pos. + $cases = array( + "'a', 2" => '0', + "'^', 5" => '5', + "'^', -3" => '-3', + ); + foreach ( $cases as $args => $expected ) { + $this->assertQuery( "SELECT REGEXP_INSTR('', $args) AS r" ); + $this->assertSame( $expected, $this->last_result[0]->r, $args ); + } + + $this->assertQueryError( + "SELECT REGEXP_INSTR('', '(', 2)", + 'Invalid regular expression: (.' + ); + } + + public function testRegexpInstrOccurrenceClampedToOne() { + // MySQL clamps occurrence <= 0 to 1. + $this->assertQuery( "SELECT REGEXP_INSTR('abcabc', 'b', 1, 0) AS r" ); + $this->assertSame( '2', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_INSTR('abcabc', 'b', 1, -5) AS r" ); + $this->assertSame( '2', $this->last_result[0]->r ); + } + + public function testRegexpInstrStraddlingMatch() { + // A match that starts before pos is not returned; the next match at or + // after pos is returned instead. + $this->assertQuery( "SELECT REGEXP_INSTR('abc123def', '[0-9]+', 5) AS r" ); + $this->assertSame( '5', $this->last_result[0]->r ); + } + + public function testRegexpInstrMultibyteReturnOptionEnd() { + // Multibyte match ('é' is 2 bytes) with return_option=1 (one past end). + // 'aéb' char positions: a=1, é=2, b=3. 'é' matches at char 2, end char position = 3. + $this->assertQuery( "SELECT REGEXP_INSTR('aéb', 'é', 1, 1, 1) AS r" ); + $this->assertSame( '3', $this->last_result[0]->r ); + } + + public function testRegexpInstrPatternIsRelativeToPos() { + $this->assertQuery( "SELECT REGEXP_INSTR('abc', '^b', 2) AS r" ); + $this->assertSame( '2', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_INSTR('ab', '(?<=a)b', 2) AS r" ); + $this->assertSame( '0', $this->last_result[0]->r ); + } + + public function testRegexpInstrNumericSubject() { + $this->assertQuery( "SELECT REGEXP_INSTR(123, '2') AS r" ); + $this->assertSame( '2', $this->last_result[0]->r ); + } + + public function testRegexpEmptyPatternRejected() { + // MySQL rejects the empty pattern with ERROR 3685. + $this->assertQueryError( + "SELECT REGEXP_LIKE('abc', '')", + 'Illegal argument to a regular expression.' + ); + $this->assertQueryError( + "SELECT REGEXP_REPLACE('abc', '', 'x')", + 'Illegal argument to a regular expression.' + ); + $this->assertQueryError( + "SELECT REGEXP_SUBSTR('abc', '')", + 'Illegal argument to a regular expression.' + ); + $this->assertQueryError( + "SELECT REGEXP_INSTR('abc', '')", + 'Illegal argument to a regular expression.' + ); + } + + public function testRegexpEmptySubject() { + // A pattern that matches empty string still matches against an empty subject. + $this->assertQuery( "SELECT REGEXP_LIKE('', 'a*') AS r" ); + $this->assertSame( '1', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_SUBSTR('', 'a*') AS r" ); + $this->assertSame( '', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_INSTR('', 'a*') AS r" ); + $this->assertSame( '1', $this->last_result[0]->r ); + } + + public function testRegexpZeroWidthAnchors() { + // ^ matches at position 1 (length 0). + $this->assertQuery( "SELECT REGEXP_INSTR('abc', '^') AS r" ); + $this->assertSame( '1', $this->last_result[0]->r ); + // $ matches one past the last character. + $this->assertQuery( "SELECT REGEXP_INSTR('abc', '\$') AS r" ); + $this->assertSame( '4', $this->last_result[0]->r ); + // SUBSTR of a zero-width anchor is the empty string, not NULL. + $this->assertQuery( "SELECT REGEXP_SUBSTR('abc', '^') AS r" ); + $this->assertSame( '', $this->last_result[0]->r ); + } + + public function testRegexpAstralPlaneCharacter() { + // 4-byte UTF-8 encodes as one code point; char offsets should reflect that. + // "x😀y" has three characters (x at 1, 😀 at 2, y at 3). + $this->assertQuery( "SELECT REGEXP_SUBSTR('x😀y', '.', 2) AS r" ); + $this->assertSame( '😀', $this->last_result[0]->r ); + $this->assertQuery( "SELECT REGEXP_INSTR('😀z', 'z', 1, 1, 1) AS r" ); + $this->assertSame( '3', $this->last_result[0]->r ); + } + + /** + * @dataProvider regexpPosixCaseClassCases + */ + public function testRegexpPosixCaseClasses( $sql, $expected ) { + $this->assertQuery( "SELECT $sql AS r" ); + $this->assertSame( $expected, $this->last_result[0]->r ); + } + + public static function regexpPosixCaseClassCases() { + return array( + // Without the "c" flag, the classes ignore case, like in MySQL. + 'upper, lowercase' => array( "REGEXP_LIKE('a', '[[:upper:]]')", '1' ), + 'lower, uppercase' => array( "REGEXP_LIKE('A', '[[:lower:]]')", '1' ), + 'upper, non-ASCII' => array( "REGEXP_LIKE('é', '[[:upper:]]')", '1' ), + 'lower, non-ASCII' => array( "REGEXP_LIKE('É', '[[:lower:]]')", '1' ), + 'upper, digit' => array( "REGEXP_LIKE('1', '[[:upper:]]')", '0' ), + 'negated class' => array( "REGEXP_LIKE('a', '[[:^upper:]]')", '0' ), + 'negated class, digit' => array( "REGEXP_LIKE('1', '[[:^upper:]]')", '1' ), + 'negated set' => array( "REGEXP_LIKE('a', '[^[:upper:]]')", '0' ), + 'with other set members' => array( "REGEXP_LIKE('a', '^[[:upper:]0-9]\$')", '1' ), + 'outside of a set' => array( "REGEXP_LIKE('a', '[:upper:]')", '1' ), + 'REGEXP_REPLACE' => array( "REGEXP_REPLACE('abC', '[[:lower:]]', 'x')", 'xxx' ), + 'REGEXP_SUBSTR' => array( "REGEXP_SUBSTR('abc1', '[[:upper:]]+')", 'abc' ), + 'REGEXP_INSTR' => array( "REGEXP_INSTR('1a', '[[:upper:]]')", '2' ), + 'operator' => array( "'a' REGEXP '[[:upper:]]'", '1' ), + 'negated operator' => array( "'a' NOT REGEXP '[[:upper:]]'", '0' ), + + // So do the matching Unicode properties. + '\\p{Lu}, lowercase' => array( "REGEXP_LIKE('a', '\\\\p{Lu}')", '1' ), + '\\p{Ll}, uppercase' => array( "REGEXP_LIKE('A', '\\\\p{Ll}')", '1' ), + '\\p{Lu}, non-ASCII' => array( "REGEXP_LIKE('é', '\\\\p{Lu}')", '1' ), + '\\p{Lu}, digit' => array( "REGEXP_LIKE('1', '\\\\p{Lu}')", '0' ), + '\\P{Lu}, lowercase' => array( "REGEXP_LIKE('a', '\\\\P{Lu}')", '0' ), + '\\P{Lu}, digit' => array( "REGEXP_LIKE('1', '\\\\P{Lu}')", '1' ), + '\\p{Lu} in a set' => array( "REGEXP_LIKE('a', '[\\\\p{Lu}]')", '1' ), + '\\p{Lu} in a negated set' => array( "REGEXP_LIKE('a', '[^\\\\p{Lu}]')", '0' ), + '\\p{Lu}, REGEXP_REPLACE' => array( "REGEXP_REPLACE('aB1', '\\\\p{Lu}', 'x')", 'xx1' ), + '\\p{Ll}, operator' => array( "'A' REGEXP '\\\\p{Ll}'", '1' ), + '\\p{Lu}, c flag' => array( "REGEXP_LIKE('a', '\\\\p{Lu}', 'c')", '0' ), + '\\p{Lu}, inline flag' => array( "REGEXP_LIKE('a', '(?-i)\\\\p{Lu}')", '0' ), + 'quoted \\p{Lu}' => array( "REGEXP_LIKE('a', '\\\\Q\\\\p{Lu}\\\\E')", '0' ), + 'escaped backslash' => array( "REGEXP_LIKE('\\\\p{Lu}', '^\\\\\\\\p\\\\{Lu\\\\}\$')", '1' ), + + // With the "c" flag, the classes keep their case. + 'c flag, upper' => array( "REGEXP_LIKE('A', '[[:upper:]]', 'c')", '1' ), + 'c flag, other case' => array( "REGEXP_LIKE('a', '[[:upper:]]', 'c')", '0' ), + 'c flag, lower other case' => array( "REGEXP_LIKE('A', '[[:lower:]]', 'c')", '0' ), + + // So they do with an inline flag, the operators' way to match case. + 'inline flag, upper' => array( "REGEXP_LIKE('A', '(?-i)[[:upper:]]')", '1' ), + 'inline flag, other case' => array( "REGEXP_LIKE('a', '(?-i)[[:upper:]]')", '0' ), + 'inline flag in a group' => array( "REGEXP_LIKE('a', '(?-i:[[:upper:]])')", '0' ), + 'inline flag among others' => array( "REGEXP_LIKE('a', '(?s-i)[[:upper:]]')", '0' ), + 'inline flag, operator' => array( "'a' REGEXP '(?-i)[[:upper:]]'", '0' ), + + // Escaped and quoted text is not a class. + 'escaped bracket' => array( "REGEXP_LIKE('[:upper:]', '^\\\\[:upper:]\$')", '1' ), + 'escaped bracket, letter' => array( "REGEXP_LIKE('a', '^\\\\[:upper:]\$')", '0' ), + 'quoted class' => array( "REGEXP_LIKE('[:upper:]', '^\\\\Q[:upper:]\\\\E\$')", '1' ), + 'quoted class, letter' => array( "REGEXP_LIKE('a', '\\\\Q[:upper:]\\\\E')", '0' ), + ); + } + + /** + * @dataProvider regexpPosixPunctCases + */ + public function testRegexpPosixPunct( $sql, $expected ) { + $this->assertQuery( "SELECT $sql AS r" ); + $this->assertSame( $expected, $this->last_result[0]->r ); + } + + public static function regexpPosixPunctCases() { + return array( + // ICU's class holds Unicode punctuation only, without ASCII symbols. + 'ASCII punctuation' => array( "REGEXP_LIKE('.', '[[:punct:]]')", '1' ), + 'plus' => array( "REGEXP_LIKE('+', '[[:punct:]]')", '0' ), + 'dollar' => array( "REGEXP_LIKE('\$', '[[:punct:]]')", '0' ), + 'equals' => array( "REGEXP_LIKE('=', '[[:punct:]]')", '0' ), + 'tilde' => array( "REGEXP_LIKE('~', '[[:punct:]]')", '0' ), + 'non-ASCII punctuation' => array( "REGEXP_LIKE('«', '[[:punct:]]')", '1' ), + 'non-ASCII symbol' => array( "REGEXP_LIKE('€', '[[:punct:]]')", '0' ), + 'c flag' => array( "REGEXP_LIKE('+', '[[:punct:]]', 'c')", '0' ), + 'negated class' => array( "REGEXP_LIKE('.', '[[:^punct:]]')", '0' ), + 'negated class, symbol' => array( "REGEXP_LIKE('+', '[[:^punct:]]')", '1' ), + 'with other set members' => array( "REGEXP_LIKE('+', '^[[:punct:]+]\$')", '1' ), + 'outside of a set' => array( "REGEXP_LIKE('.', '[:punct:]')", '1' ), + 'REGEXP_REPLACE' => array( "REGEXP_REPLACE('a+b.c\$d,e=f!g', '[[:punct:]]', '')", 'a+bc$de=fg' ), + 'operator, punctuation' => array( "'.' REGEXP '[[:punct:]]'", '1' ), + 'operator, symbol' => array( "'+' REGEXP '[[:punct:]]'", '0' ), + 'quoted class' => array( "REGEXP_LIKE('.', '\\\\Q[:punct:]\\\\E')", '0' ), + ); + } + + public function testRegexpPosixPunctOnBinaryData() { + // Bytes that are not valid UTF-8 are read as Latin-1, where 0xAB is "«". + $this->assertQuery( 'CREATE TABLE blobs (id INT PRIMARY KEY, b BLOB)' ); + $this->assertQuery( "INSERT INTO blobs VALUES (1, X'2BFF'), (2, X'2EFF'), (3, X'AB')" ); + $this->assertQuery( "SELECT b REGEXP '[[:punct:]]' AS r FROM blobs ORDER BY id" ); + $this->assertSame( array( '0', '1', '1' ), array_column( $this->last_result, 'r' ) ); + } + + /** + * @dataProvider regexpHyphenAfterShorthandCases + */ + public function testRegexpHyphenAfterShorthand( $sql, $expected ) { + $this->assertQuery( "SELECT $sql AS r" ); + $this->assertSame( $expected, $this->last_result[0]->r ); + } + + public static function regexpHyphenAfterShorthandCases() { + return array( + // Common patterns. + 'email pattern, operator' => array( "'a@b.co' REGEXP '^[\\\\w-\\\\.]+@'", '1' ), + 'email pattern' => array( "REGEXP_LIKE('foo-bar@example.com', '^[\\\\w-.]+@[\\\\w-]+\\\\.\\\\w+\$')", '1' ), + 'slug pattern' => array( "REGEXP_LIKE('a1', '^[a-z\\\\d-_]+\$')", '1' ), + + // The hyphen is a literal, not a range. + '\\d, hyphen' => array( "REGEXP_LIKE('-', '^[\\\\d-x]\$')", '1' ), + '\\d, shorthand' => array( "REGEXP_LIKE('5', '^[\\\\d-x]\$')", '1' ), + '\\d, last character' => array( "REGEXP_LIKE('x', '^[\\\\d-x]\$')", '1' ), + '\\d, past last character' => array( "REGEXP_LIKE('y', '^[\\\\d-x]\$')", '0' ), + '\\D, hyphen' => array( "REGEXP_LIKE('-', '^[\\\\D-1]\$')", '1' ), + '\\D, last character' => array( "REGEXP_LIKE('1', '^[\\\\D-1]\$')", '1' ), + '\\D, other digit' => array( "REGEXP_LIKE('2', '^[\\\\D-1]\$')", '0' ), + '\\s, hyphen' => array( "REGEXP_LIKE('-', '^[\\\\s-x]\$')", '1' ), + '\\s, other letter' => array( "REGEXP_LIKE('a', '^[\\\\s-x]\$')", '0' ), + '\\S, hyphen' => array( "REGEXP_LIKE('-', '^[\\\\S-x]\$')", '1' ), + '\\S, space' => array( "REGEXP_LIKE(' ', '^[\\\\S-x]\$')", '0' ), + '\\w, hyphen' => array( "REGEXP_LIKE('-', '^[\\\\w-.]\$')", '1' ), + '\\w, dot' => array( "REGEXP_LIKE('.', '^[\\\\w-.]\$')", '1' ), + '\\W, hyphen' => array( "REGEXP_LIKE('-', '^[\\\\W-x]\$')", '1' ), + '\\W, other letter' => array( "REGEXP_LIKE('y', '^[\\\\W-x]\$')", '0' ), + '\\h, hyphen' => array( "REGEXP_LIKE('-', '^[\\\\h-x]\$')", '1' ), + '\\H, hyphen' => array( "REGEXP_LIKE('-', '^[\\\\H-x]\$')", '1' ), + '\\v, hyphen' => array( "REGEXP_LIKE('-', '^[\\\\v-x]\$')", '1' ), + '\\V, hyphen' => array( "REGEXP_LIKE('-', '^[\\\\V-x]\$')", '1' ), + 'last in the set' => array( "REGEXP_LIKE('-', '^[\\\\w-]\$')", '1' ), + 'before a shorthand' => array( "REGEXP_LIKE('-', '^[\\\\w-\\\\d]\$')", '1' ), + 'before a range, hyphen' => array( "REGEXP_LIKE('-', '^[\\\\d-a-c]\$')", '1' ), + 'before a range, in range' => array( "REGEXP_LIKE('b', '^[\\\\d-a-c]\$')", '1' ), + 'before a range, past it' => array( "REGEXP_LIKE('e', '^[\\\\d-a-c]\$')", '0' ), + + // A hyphen outside of a set keeps its meaning. + 'outside of a set' => array( "REGEXP_LIKE('1-2', '^\\\\d-\\\\d\$')", '1' ), + 'with a quantifier' => array( "REGEXP_LIKE('1--', '^\\\\d-{2}\$')", '1' ), + + // These are not shorthand classes, so ranges still work. + 'escaped backslash, range' => array( "REGEXP_LIKE('a', '^[\\\\\\\\-a]\$')", '1' ), + 'escaped backslash, past' => array( "REGEXP_LIKE('Z', '^[\\\\\\\\-a]\$')", '0' ), + 'literal letter after it' => array( "REGEXP_LIKE('\\\\d-x', '^\\\\\\\\d-x\$')", '1' ), + 'quoted shorthand' => array( "REGEXP_LIKE('\\\\w-', '^\\\\Q\\\\w-\\\\E\$')", '1' ), + ); + } + + /** + * @dataProvider regexpBigIntegerCases + */ + public function testRegexpBigIntegerOperands( $sql, $expected ) { + $this->assertQuery( "SELECT $sql AS r" ); + $this->assertSame( $expected, $this->last_result[0]->r ); + } + + public static function regexpBigIntegerCases() { + return array( + // The smallest values that don't fit in 32 bits. + '2^31, subject' => array( "REGEXP_SUBSTR(2147483648, '.*')", '2147483648' ), + '-2^31 - 1, subject' => array( "REGEXP_SUBSTR(-2147483649, '.*')", '-2147483649' ), + 'largest BIGINT' => array( "REGEXP_SUBSTR(9223372036854775807, '.*')", '9223372036854775807' ), + + 'REGEXP_LIKE subject' => array( "REGEXP_LIKE(9999999999, '^9')", '1' ), + 'REGEXP_LIKE pattern' => array( "REGEXP_LIKE('9999999999', 9999999999)", '1' ), + 'REGEXP_SUBSTR' => array( "REGEXP_SUBSTR(9999999999, '9+')", '9999999999' ), + 'REGEXP_SUBSTR regex' => array( "REGEXP_SUBSTR('9999999999', 9999999999)", '9999999999' ), + 'REGEXP_INSTR' => array( "REGEXP_INSTR(9999999999, '99', 1, 2)", '3' ), + 'REGEXP_INSTR pattern' => array( "REGEXP_INSTR('x9999999999', 9999999999)", '2' ), + 'REGEXP_REPLACE' => array( "REGEXP_REPLACE(9999999999, '9', '1')", '1111111111' ), + 'REGEXP_REPLACE regex' => array( "REGEXP_REPLACE('9999999999', 9999999999, 'x')", 'x' ), + 'replacement' => array( "REGEXP_REPLACE('a', 'a', 9999999999)", '9999999999' ), + 'operator subject' => array( "9999999999 REGEXP '^9'", '1' ), + 'operator pattern' => array( "'9999999999' REGEXP 9999999999", '1' ), + 'negated operator' => array( "9999999999 NOT REGEXP '^9'", '0' ), + 'REGEXP BINARY' => array( "9999999999 REGEXP BINARY '^9'", '1' ), + ); + } + + public function testRegexpBigIntegerColumn() { + $this->assertQuery( 'CREATE TABLE phones (id INT PRIMARY KEY, phone BIGINT)' ); + $this->assertQuery( 'INSERT INTO phones VALUES (1, 15551234567), (2, 42), (3, NULL)' ); + $this->assertQuery( + "SELECT + phone REGEXP '^1' AS operator_result, + REGEXP_LIKE(phone, '^1') AS like_result, + REGEXP_REPLACE(phone, '^1', '') AS replace_result, + REGEXP_SUBSTR(phone, '^[0-9]{3}') AS substr_result, + REGEXP_INSTR(phone, '4') AS instr_result + FROM phones ORDER BY id" + ); + $this->assertEquals( + array( + (object) array( + 'operator_result' => '1', + 'like_result' => '1', + 'replace_result' => '5551234567', + 'substr_result' => '155', + 'instr_result' => '8', + ), + (object) array( + 'operator_result' => '0', + 'like_result' => '0', + 'replace_result' => '42', + 'substr_result' => null, + 'instr_result' => '1', + ), + (object) array( + 'operator_result' => null, + 'like_result' => null, + 'replace_result' => null, + 'substr_result' => null, + 'instr_result' => null, + ), + ), + $this->last_result + ); + } + + public function testRegexpSetDifferenceAfterShorthand() { + // ICU reads "--" as a set difference, which PCRE can't express. Escaping the + // first hyphen would turn PCRE's error into a range from "-" to "x", matching ".". + if ( PHP_VERSION_ID < 70300 ) { + // PCRE 8 reads it as that range by itself. + $this->assertQuery( "SELECT REGEXP_LIKE('.', '^[\\\\w--x]\$') AS r" ); + $this->assertSame( '1', $this->last_result[0]->r ); + return; + } + $this->assertQueryError( + "SELECT REGEXP_LIKE('.', '^[\\\\w--x]\$')", + 'Invalid regular expression: ^[\\w--x]$.' + ); + } + public function testInsertDateNow() { $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('first', now());"