From fe161135ee7085e64c83bd02405da4a607963b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Tue, 21 Apr 2026 17:00:16 +0200 Subject: [PATCH 01/14] Add REGEXP_LIKE() UDF Implement MySQL's REGEXP_LIKE(expr, pattern [, match_type]) on top of PCRE. A NULL argument returns NULL, and matching is case-insensitive by default. Add the helpers that the other REGEXP_* functions share: - regexp_compile() turns the c, i, m, n, and u flags into PCRE options, with MySQL's newline handling, and caches the last compiled pattern. - regexp_escape_delimiter() escapes slashes that are not escaped yet. - regexp_fail() reports invalid patterns, invalid UTF-8 data, and PCRE limits as separate errors. Register the function with its exact argument counts, so a wrong count is an error, like in MySQL. --- ...s-wp-sqlite-pdo-user-defined-functions.php | 221 +++++++++++++++++- .../tests/WP_MySQL_On_SQLite_Tests.php | 130 +++++++++++ 2 files changed, 347 insertions(+), 4 deletions(-) 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..ddf70dd8f 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,7 @@ public static function register_for( $pdo ): self { 'localtimestamp' => 'now', 'isnull' => 'isnull', 'regexp' => 'regexp', + 'regexp_like' => 'regexp_like', 'field' => 'field', 'log' => 'log', 'least' => 'least', @@ -86,6 +90,26 @@ 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_like' => array( 2, 3 ), + ); + + /** @var string|null Last validated regex pattern. */ + private $regexp_cached_pattern = null; + + /** @var string|null Match type used by the cached regex. */ + private $regexp_cached_match_type = 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). * @@ -600,6 +624,28 @@ public function regexp( $pattern, $field ) { return preg_match( $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 ); + $result = preg_match( $compiled, $expr ); + if ( false === $result ) { + $this->regexp_fail( $pattern ); + } + return $result; + } + /** * Method to emulate MySQL FIELD() function. * @@ -981,4 +1027,171 @@ 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 and always + * appends the u (UTF-8) modifier. Case-insensitive is the default, matching + * the existing REGEXP operator. + * + * 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. + * + * 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 always applied. Binary data with + * invalid UTF-8 bytes that matches fine under the legacy `REGEXP` + * operator raises "Invalid UTF-8 data in regular expression input." + * when routed through REGEXP_LIKE / _REPLACE / _SUBSTR / _INSTR. + * + * @param string $pattern The MySQL regex pattern. + * @param string $match_type MySQL match_type flag string. + * + * @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 ) { + $match_type = (string) $match_type; + $pattern = (string) $pattern; + if ( '' === $pattern ) { + throw new Exception( 'Illegal argument to a regular expression.' ); + } + if ( + null !== $pattern + && $pattern === $this->regexp_cached_pattern + && $match_type === $this->regexp_cached_match_type + ) { + 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." ); + } + } + + $modifiers = 'u'; + if ( ! $case_sensitive ) { + $modifiers .= 'i'; + } + if ( $multiline ) { + $modifiers .= 'm'; + } + if ( $dotall ) { + $modifiers .= 's'; + } + + $newline = $unix_lines ? '(*LF)' : '(*ANY)'; + $compiled = '/' . $newline . $this->regexp_escape_delimiter( $pattern ) . '/' . $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_pattern = $pattern; + $this->regexp_cached_match_type = $match_type; + $this->regexp_cached_compiled = $compiled; + return $compiled; + } + + /** + * Escape pattern delimiters that are not already escaped. + * + * @param string $pattern The regex pattern. + * + * @return string Pattern safe to wrap in slash delimiters. + */ + private function regexp_escape_delimiter( $pattern ) { + $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 ( '/' === $character ) { + $escaped .= '\\E\\/\\Q'; + } else { + $escaped .= $character; + } + continue; + } + if ( '\\' === $character && $i + 1 < $length ) { + $escaped .= $character . $pattern[ $i + 1 ]; + $quoted = 'Q' === $pattern[ $i + 1 ]; + ++$i; + } elseif ( '/' === $character ) { + $escaped .= '\\/'; + } else { + $escaped .= $character; + } + } + return $escaped; + } + + /** + * 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..b5e3122e1 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,136 @@ public static function regexpOperators() { ); } + /** + * @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.' ), + '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 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 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 ); + } + public function testInsertDateNow() { $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('first', now());" From e496f5468e2c5fad2c7ae264408cc1d7fa508a8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Tue, 21 Apr 2026 17:03:35 +0200 Subject: [PATCH 02/14] Add REGEXP_REPLACE() UDF Implement MySQL's REGEXP_REPLACE(expr, pattern, replacement [, pos [, occurrence [, match_type]]]). Positions count characters, not bytes. An occurrence of 0 replaces every match, and a negative one replaces the first. Like in MySQL, pos may be one past the end, and an empty subject is returned unchanged. The replacement follows MySQL's ICU syntax: "$N" and "${name}" insert capture groups, a backslash escapes the next character, and invalid references are errors. Matches are found one at a time with preg_match() offsets, so lookbehind sees the text before pos, and zero-width matches advance by one character, like in MySQL. --- ...s-wp-sqlite-pdo-user-defined-functions.php | 244 +++++++++++++++- .../tests/WP_MySQL_On_SQLite_Tests.php | 268 ++++++++++++++++++ 2 files changed, 511 insertions(+), 1 deletion(-) 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 ddf70dd8f..2aeaf28e3 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 @@ -65,6 +65,7 @@ public static function register_for( $pdo ): self { 'isnull' => 'isnull', 'regexp' => 'regexp', 'regexp_like' => 'regexp_like', + 'regexp_replace' => 'regexp_replace', 'field' => 'field', 'log' => 'log', 'least' => 'least', @@ -98,7 +99,8 @@ public static function register_for( $pdo ): self { * @var array */ private $function_arities = array( - 'regexp_like' => array( 2, 3 ), + 'regexp_like' => array( 2, 3 ), + 'regexp_replace' => array( 3, 4, 5, 6 ), ); /** @var string|null Last validated regex pattern. */ @@ -646,6 +648,78 @@ public function regexp_like( $expr, $pattern, $match_type = '' ) { return $result; } + /** + * 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 ); + + $expr = (string) $expr; + $replacement = (string) $replacement; + $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 FIELD() function. * @@ -1169,6 +1243,174 @@ private function regexp_escape_delimiter( $pattern ) { return $escaped; } + /** + * 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 "\\" -> "\"). + * - 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; + } + + /** + * 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 = preg_match( + $compiled, + $subject, + $m, + PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL, + $offset + ); + if ( false === $r ) { + $this->regexp_fail( $pattern ); + } + 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; + } + } + } + } + /** * Translate a preg_* failure into a caller-friendly exception message. * 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 b5e3122e1..8aa8aab96 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 @@ -396,6 +396,274 @@ public function testRegexpUnicodeNewlineHandling() { $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 ); + } + public function testInsertDateNow() { $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('first', now());" From ee0d740ec41b4ba2a4de508160f2436a605e20d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Tue, 21 Apr 2026 17:05:05 +0200 Subject: [PATCH 03/14] Add REGEXP_SUBSTR() UDF Implement MySQL's REGEXP_SUBSTR(expr, pattern [, pos [, occurrence [, match_type]]]), which returns the Nth match from pos, or NULL when there is none. Like in MySQL, an occurrence below 1 means the first match, and pos may be one past the end, where a zero-width match returns an empty string. --- ...s-wp-sqlite-pdo-user-defined-functions.php | 62 ++++++++++++++++ .../tests/WP_MySQL_On_SQLite_Tests.php | 72 +++++++++++++++++++ 2 files changed, 134 insertions(+) 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 2aeaf28e3..dbed52bce 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 @@ -66,6 +66,7 @@ public static function register_for( $pdo ): self { 'regexp' => 'regexp', 'regexp_like' => 'regexp_like', 'regexp_replace' => 'regexp_replace', + 'regexp_substr' => 'regexp_substr', 'field' => 'field', 'log' => 'log', 'least' => 'least', @@ -101,6 +102,7 @@ public static function register_for( $pdo ): self { private $function_arities = array( 'regexp_like' => array( 2, 3 ), 'regexp_replace' => array( 3, 4, 5, 6 ), + 'regexp_substr' => array( 2, 3, 4, 5 ), ); /** @var string|null Last validated regex pattern. */ @@ -720,6 +722,44 @@ public function regexp_replace( $expr, $pattern, $replacement, $pos = 1, $occurr 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; + } + $compiled = $this->regexp_compile( $pattern, $match_type ); + + $expr = (string) $expr; + + // MySQL clamps occurrence <= 0 to 1. + $n = max( 1, (int) $occurrence ); + + $byte_start = $this->regexp_char_to_byte_offset( $expr, (int) $pos, true ); + + $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 FIELD() function. * @@ -1365,6 +1405,28 @@ private function regexp_expand_replacement( $replacement, $match_data, $group_ke 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. * 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 8aa8aab96..b5165c8f1 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 @@ -664,6 +664,78 @@ public function testRegexpReplaceLeavesEmptySubjectUnchanged() { $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 ); + } + public function testInsertDateNow() { $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('first', now());" From d5ee1612d390c919daf5575279a354ad62cee94a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Tue, 21 Apr 2026 17:06:47 +0200 Subject: [PATCH 04/14] Add REGEXP_INSTR() UDF Implement MySQL's REGEXP_INSTR(expr, pattern [, pos [, occurrence [, return_option [, match_type]]]]), which returns the character position where the Nth match starts, or with return_option 1, where it ends. Any other return_option is an error. Unlike REGEXP_SUBSTR() and REGEXP_REPLACE(), and like in MySQL: - Anchors and lookbehind see only the text from pos on. - A pos past the end is an error, except for an empty subject, where pos is not checked at all. --- ...s-wp-sqlite-pdo-user-defined-functions.php | 61 ++++++++++ .../tests/WP_MySQL_On_SQLite_Tests.php | 104 ++++++++++++++++++ 2 files changed, 165 insertions(+) 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 dbed52bce..ec9b47f33 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 @@ -67,6 +67,7 @@ public static function register_for( $pdo ): self { 'regexp_like' => 'regexp_like', 'regexp_replace' => 'regexp_replace', 'regexp_substr' => 'regexp_substr', + 'regexp_instr' => 'regexp_instr', 'field' => 'field', 'log' => 'log', 'least' => 'least', @@ -103,6 +104,7 @@ public static function register_for( $pdo ): self { '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 string|null Last validated regex pattern. */ @@ -760,6 +762,65 @@ public function regexp_substr( $expr, $pattern, $pos = 1, $occurrence = 1, $matc 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 ); + + $expr = (string) $expr; + $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; + } + /** * Method to emulate MySQL FIELD() function. * 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 b5165c8f1..e38397c9b 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 @@ -736,6 +736,110 @@ public function testRegexpSubstrNumericSubject() { $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 testInsertDateNow() { $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('first', now());" From ffa993db6a417830b19b7bad790e1d84f59fe7c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Tue, 21 Apr 2026 17:07:52 +0200 Subject: [PATCH 05/14] Pin down REGEXP_* edge cases shared across functions Test behavior that all four REGEXP_* functions share: - They work on table columns in SELECT, WHERE, and UPDATE statements. - An empty pattern is an error. - A pattern that matches an empty string also matches an empty subject. - The ^ and $ anchors match at the start and the end of the subject. - A 4-byte UTF-8 character counts as one character. --- .../tests/WP_MySQL_On_SQLite_Tests.php | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) 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 e38397c9b..954beac28 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,63 @@ 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 */ @@ -840,6 +897,57 @@ public function testRegexpInstrNumericSubject() { $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 ); + } + public function testInsertDateNow() { $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('first', now());" From c5541176ce127fb767bdb68f3559cefa8e6339e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Tue, 28 Jul 2026 21:38:11 +0200 Subject: [PATCH 06/14] Align REGEXP operators with REGEXP functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the REGEXP and RLIKE operators use regexp_compile() and the same error handling as the REGEXP_* functions: - UTF-8 text is matched by character, so "." matches "é", and case-insensitive matching works beyond ASCII. - Data that is not valid UTF-8 is matched byte by byte and case-sensitively, like MySQL binary strings. - NULL operands return NULL. - Escaped slashes in patterns work, and invalid patterns raise an error instead of a PHP warning. Translate REGEXP on the "predicate" node, which has both operands, to prepare for calling the REGEXP function directly. --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 24 ++++-- ...s-wp-sqlite-pdo-user-defined-functions.php | 86 +++++++++++-------- .../tests/WP_MySQL_On_SQLite_Tests.php | 76 ++++++++++++++++ 3 files changed, 144 insertions(+), 42 deletions(-) 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..5b32351ef 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,13 +5159,16 @@ 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() ); /* * If the query says REGEXP BINARY, the comparison is byte-by-byte @@ -5176,9 +5184,11 @@ private function translate_regexp_functions( WP_Parser_Node $node ): string { * regular expressions anyway. */ if ( true === $is_binary ) { - return 'REGEXP CHAR(0) || ' . $this->translate( $node->get_first_child_node() ); + $pattern = 'CHAR(0) || ' . $pattern; } - return 'REGEXP ' . $this->translate( $node->get_first_child_node() ); + + $operator = null === $node->get_first_child_node( 'notRule' ) ? 'REGEXP' : 'NOT REGEXP'; + return "$subject $operator $pattern"; } /** 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 ec9b47f33..8eecd232f 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 @@ -107,11 +107,8 @@ public static function register_for( $pdo ): self { 'regexp_instr' => array( 2, 3, 4, 5, 6 ), ); - /** @var string|null Last validated regex pattern. */ - private $regexp_cached_pattern = null; - - /** @var string|null Match type used by the cached regex. */ - private $regexp_cached_match_type = null; + /** @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; @@ -597,12 +594,18 @@ public function isnull( $field ) { /** * Method to emulate MySQL REGEXP() function. * - * @param string $pattern Regular expression to match. - * @param string $field Haystack. + * @param string|null $pattern Regular expression to match. + * @param string|null $field Haystack. * - * @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 ) { + if ( null === $pattern || null === $field ) { + return null; + } + $pattern = (string) $pattern; + /* * If the original query says REGEXP BINARY * the comparison is byte-by-byte and letter casing now @@ -617,17 +620,22 @@ public function regexp( $pattern, $field ) { * be reasonably safe since PHP does not allow null bytes in * regular expressions anyway. */ - if ( "\x00" === $pattern[0] ) { + $is_binary = '' !== $pattern && "\x00" === $pattern[0]; + if ( $is_binary ) { $pattern = substr( $pattern, 1 ); - $flags = ''; - } else { - // Otherwise, the search is case-insensitive. - $flags = 'i'; } - $pattern = str_replace( '/', '\/', $pattern ); - $pattern = '/' . $pattern . '/' . $flags; - return preg_match( $pattern, $field ); + /* + * 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. + */ + $byte_mode = $is_binary || ! $this->regexp_is_utf8( $pattern ) || ! $this->regexp_is_utf8( $field ); + $compiled = $this->regexp_compile( $pattern, $byte_mode ? 'c' : '', $byte_mode ); + $result = preg_match( $compiled, $field ); + if ( false === $result ) { + $this->regexp_fail( $pattern ); + } + return $result; } /** @@ -1206,9 +1214,9 @@ public function _helper_like_to_glob_pattern( $pattern ) { /** * Compile a MySQL-style regex into a PCRE pattern string. * - * Translates MySQL match_type flags (c/i/m/n/u) to PCRE modifiers and always - * appends the u (UTF-8) modifier. Case-insensitive is the default, matching - * the existing REGEXP operator. + * 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: @@ -1225,29 +1233,27 @@ public function _helper_like_to_glob_pattern( $pattern ) { * `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 always applied. Binary data with - * invalid UTF-8 bytes that matches fine under the legacy `REGEXP` - * operator raises "Invalid UTF-8 data in regular expression input." - * when routed through REGEXP_LIKE / _REPLACE / _SUBSTR / _INSTR. + * - 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. * - * @param string $pattern The MySQL regex pattern. - * @param string $match_type MySQL match_type flag string. + * @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 ) { + 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.' ); } - if ( - null !== $pattern - && $pattern === $this->regexp_cached_pattern - && $match_type === $this->regexp_cached_match_type - ) { + $cache_key = array( $pattern, $match_type, $binary_mode ); + if ( $cache_key === $this->regexp_cached_key ) { return $this->regexp_cached_compiled; } @@ -1273,7 +1279,7 @@ private function regexp_compile( $pattern, $match_type ) { } } - $modifiers = 'u'; + $modifiers = $binary_mode ? '' : 'u'; if ( ! $case_sensitive ) { $modifiers .= 'i'; } @@ -1300,9 +1306,8 @@ private function regexp_compile( $pattern, $match_type ) { if ( false === $valid ) { $this->regexp_fail( $pattern ); } - $this->regexp_cached_pattern = $pattern; - $this->regexp_cached_match_type = $match_type; - $this->regexp_cached_compiled = $compiled; + $this->regexp_cached_key = $cache_key; + $this->regexp_cached_compiled = $compiled; return $compiled; } @@ -1344,6 +1349,17 @@ private function regexp_escape_delimiter( $pattern ) { 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. * 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 954beac28..f68f7477e 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 @@ -430,6 +430,82 @@ public function testRegexpLegacyOperatorRegression() { $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 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 ); From 0c6e9944fce2ee3c39c2a863ec35782996526c21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Tue, 3 Nov 2026 11:04:37 +0100 Subject: [PATCH 07/14] Pass REGEXP BINARY to the REGEXP function as a flag The operator translation marked REGEXP BINARY by prepending a NUL byte to the pattern. Any pattern value that started with a NUL byte was then silently matched as REGEXP BINARY: the byte was dropped and matching became case-sensitive. Call the REGEXP function directly with an explicit flag instead, and escape NUL bytes in patterns, which PHP rejects before 8.2. Wrapping the call in parentheses also keeps the MySQL precedence, where REGEXP binds tighter than comparison operators. SQLite read 1 = 'a' REGEXP 'a' as (1 = 'a') REGEXP 'a'. --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 22 ++---- ...s-wp-sqlite-pdo-user-defined-functions.php | 54 ++++++--------- .../tests/WP_MySQL_On_SQLite_Tests.php | 69 +++++++++++++++++++ 3 files changed, 97 insertions(+), 48 deletions(-) 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 5b32351ef..9bfa7bac9 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 @@ -5169,26 +5169,16 @@ private function translate_regexp_predicate( WP_Parser_Node $node, WP_Parser_Nod $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. - * - * 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. + * 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 ( true === $is_binary ) { - $pattern = 'CHAR(0) || ' . $pattern; - } - - $operator = null === $node->get_first_child_node( 'notRule' ) ? 'REGEXP' : 'NOT REGEXP'; - return "$subject $operator $pattern"; + return sprintf( '(%sREGEXP(%s, %s%s))', $not, $pattern, $subject, $is_binary ? ', 1' : '' ); } /** 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 8eecd232f..04d77175f 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 @@ -101,6 +101,7 @@ public static function register_for( $pdo ): self { * @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 ), @@ -592,44 +593,26 @@ public function isnull( $field ) { } /** - * Method to emulate MySQL REGEXP() function. + * Method to emulate the MySQL REGEXP operator. * * @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. * * @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; } - $pattern = (string) $pattern; - - /* - * 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. - */ - $is_binary = '' !== $pattern && "\x00" === $pattern[0]; - if ( $is_binary ) { - $pattern = substr( $pattern, 1 ); - } /* * 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. */ - $byte_mode = $is_binary || ! $this->regexp_is_utf8( $pattern ) || ! $this->regexp_is_utf8( $field ); + $byte_mode = 1 === $binary || ! $this->regexp_is_utf8( $pattern ) || ! $this->regexp_is_utf8( $field ); $compiled = $this->regexp_compile( $pattern, $byte_mode ? 'c' : '', $byte_mode ); $result = preg_match( $compiled, $field ); if ( false === $result ) { @@ -1291,7 +1274,7 @@ private function regexp_compile( $pattern, $match_type, $binary_mode = false ) { } $newline = $unix_lines ? '(*LF)' : '(*ANY)'; - $compiled = '/' . $newline . $this->regexp_escape_delimiter( $pattern ) . '/' . $modifiers; + $compiled = '/' . $newline . $this->regexp_escape_pattern( $pattern ) . '/' . $modifiers; /* * Compile errors are the only preg failures that emit PHP warnings. @@ -1312,13 +1295,19 @@ private function regexp_compile( $pattern, $match_type, $binary_mode = false ) { } /** - * Escape pattern delimiters that are not already escaped. + * Escape pattern delimiters and NUL bytes that are not already escaped. + * + * PHP before 8.2 rejects NUL bytes in patterns, so they become "\x00". * * @param string $pattern The regex pattern. * * @return string Pattern safe to wrap in slash delimiters. */ - private function regexp_escape_delimiter( $pattern ) { + private function regexp_escape_pattern( $pattern ) { + $special = array( + '/' => '\\/', + "\0" => '\\x00', + ); $escaped = ''; $quoted = false; $length = strlen( $pattern ); @@ -1329,19 +1318,20 @@ private function regexp_escape_delimiter( $pattern ) { $escaped .= '\\E'; $quoted = false; ++$i; - } elseif ( '/' === $character ) { - $escaped .= '\\E\\/\\Q'; + } elseif ( isset( $special[ $character ] ) ) { + $escaped .= '\\E' . $special[ $character ] . '\\Q'; } else { $escaped .= $character; } continue; } if ( '\\' === $character && $i + 1 < $length ) { - $escaped .= $character . $pattern[ $i + 1 ]; - $quoted = 'Q' === $pattern[ $i + 1 ]; + $next = $pattern[ $i + 1 ]; + $escaped .= isset( $special[ $next ] ) ? $special[ $next ] : $character . $next; + $quoted = 'Q' === $next; ++$i; - } elseif ( '/' === $character ) { - $escaped .= '\\/'; + } elseif ( isset( $special[ $character ] ) ) { + $escaped .= $special[ $character ]; } else { $escaped .= $character; } 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 f68f7477e..59772f050 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 @@ -469,6 +469,75 @@ public function testRegexpLegacyOperatorUtf8Handling() { $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)' ); From 401b375682fd00907be5c7f94f35f3da91042119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Thu, 5 Nov 2026 15:06:04 +0100 Subject: [PATCH 08/14] Ignore case in [:upper:] and [:lower:] by default With the u modifier, PCRE reads these classes case-sensitively even in a case-insensitive pattern. 'a' REGEXP '[[:upper:]]' returned 0, where MySQL and the operator on trunk return 1. Replace them with \p{L&} when the pattern ignores case. \p{L&} doesn't follow inline flags, so a pattern with "(?-i)" keeps PCRE's reading. Rename regexp_escape_pattern() to regexp_translate_pattern(), since it now does more than escape. --- ...s-wp-sqlite-pdo-user-defined-functions.php | 43 +++++++++++-- .../tests/WP_MySQL_On_SQLite_Tests.php | 63 +++++++++++++++++++ 2 files changed, 100 insertions(+), 6 deletions(-) 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 04d77175f..0ca84147c 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 @@ -1274,7 +1274,7 @@ private function regexp_compile( $pattern, $match_type, $binary_mode = false ) { } $newline = $unix_lines ? '(*LF)' : '(*ANY)'; - $compiled = '/' . $newline . $this->regexp_escape_pattern( $pattern ) . '/' . $modifiers; + $compiled = '/' . $newline . $this->regexp_translate_pattern( $pattern, ! $case_sensitive ) . '/' . $modifiers; /* * Compile errors are the only preg failures that emit PHP warnings. @@ -1295,19 +1295,36 @@ private function regexp_compile( $pattern, $match_type, $binary_mode = false ) { } /** - * Escape pattern delimiters and NUL bytes that are not already escaped. + * Translate a MySQL regex pattern to PCRE syntax. * - * PHP before 8.2 rejects NUL bytes in patterns, so they become "\x00". + * 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. * - * @param string $pattern The regex pattern. + * @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_escape_pattern( $pattern ) { + private function regexp_translate_pattern( $pattern, $case_insensitive ) { $special = array( '/' => '\\/', "\0" => '\\x00', ); + $classes = array(); + + /* + * 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 ); @@ -1325,13 +1342,27 @@ private function regexp_escape_pattern( $pattern ) { } continue; } - if ( '\\' === $character && $i + 1 < $length ) { + 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; } 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; } 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 59772f050..8ee11b6e9 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 @@ -1093,6 +1093,69 @@ public function testRegexpAstralPlaneCharacter() { $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' ), + ); + } + public function testInsertDateNow() { $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('first', now());" From c45cfc86fa1c194f3594ee56ff9b268a6d9b9cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Thu, 5 Nov 2026 15:07:01 +0100 Subject: [PATCH 09/14] Exclude ASCII symbols from [:punct:] PCRE's [:punct:] also holds ASCII symbols such as + $ = ~, where ICU's holds Unicode punctuation only. REGEXP_REPLACE('a+b.c$d', '[[:punct:]]', '') removed all of them, where MySQL keeps + and $. Replace the class with \p{P}. It also applies to byte-oriented matching, where MySQL reads bytes as Latin-1, so 0xAB counts as punctuation. --- ...s-wp-sqlite-pdo-user-defined-functions.php | 4 +- .../tests/WP_MySQL_On_SQLite_Tests.php | 38 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) 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 0ca84147c..e5a1a1e2f 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 @@ -1311,7 +1311,9 @@ private function regexp_translate_pattern( $pattern, $case_insensitive ) { '/' => '\\/', "\0" => '\\x00', ); - $classes = array(); + + // 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 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 8ee11b6e9..8b9e00dd7 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 @@ -1156,6 +1156,44 @@ public static function regexpPosixCaseClassCases() { ); } + /** + * @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' ) ); + } + public function testInsertDateNow() { $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('first', now());" From 7552cc6bed985e4e58b63d2b26804fb3915b0a2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Thu, 5 Nov 2026 15:08:23 +0100 Subject: [PATCH 10/14] Accept a hyphen after a shorthand class PCRE 10 rejects a hyphen after a shorthand class, as in "[\w-.]", where ICU reads it literally. Common patterns like '^[\w-\.]+@' failed with "Invalid regular expression", where MySQL matches them. Escape such a hyphen, which is also safe outside of a set. ICU reads "--" as a set difference, which PCRE can't express. Leave it alone, as escaping the first hyphen would create a range that matches other text. --- ...s-wp-sqlite-pdo-user-defined-functions.php | 17 ++++- .../tests/WP_MySQL_On_SQLite_Tests.php | 68 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) 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 e5a1a1e2f..5d93a9641 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 @@ -1299,7 +1299,8 @@ private function regexp_compile( $pattern, $match_type, $binary_mode = false ) { * * 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. + * 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. @@ -1356,6 +1357,20 @@ private function regexp_translate_pattern( $pattern, $case_insensitive ) { $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 ( 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 8b9e00dd7..622b0fdd7 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 @@ -1194,6 +1194,74 @@ public function testRegexpPosixPunctOnBinaryData() { $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' ), + ); + } + + 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());" From e19fe0cfbabdeb0febd490477a9dfc8863d88a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Thu, 5 Nov 2026 15:38:31 +0100 Subject: [PATCH 11/14] Pass REGEXP operands to the functions as text PDO narrows integer arguments of user-defined functions to 32 bits before PHP receives them. On a BIGINT column holding 15551234567, REGEXP_REPLACE(n, '^1', '') returned -1628634617, and n REGEXP '^1' returned 0. PHP can't recover the value, so cast the subject, the pattern, and the replacement to text in the translator. pos, occurrence, and return_option stay numbers, as MySQL also narrows them to 32 bits. SQLite now converts float operands to text instead of PHP. A whole number in a DOUBLE or FLOAT column reads as "20.0", where MySQL reads "20". --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 15 +++- ...s-wp-sqlite-pdo-user-defined-functions.php | 7 +- .../tests/WP_MySQL_On_SQLite_Tests.php | 71 +++++++++++++++++++ 3 files changed, 86 insertions(+), 7 deletions(-) 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 9bfa7bac9..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 @@ -5177,8 +5177,10 @@ private function translate_regexp_predicate( WP_Parser_Node $node, WP_Parser_Nod * * The parentheses keep the MySQL precedence. In SQLite, REGEXP binds * as loosely as "=", so "1 = 'a' REGEXP 'a'" would be "(1 = 'a') REGEXP 'a'". + * + * PDO narrows integer UDF arguments to 32 bits, so pass the operands as text. */ - return sprintf( '(%sREGEXP(%s, %s%s))', $not, $pattern, $subject, $is_binary ? ', 1' : '' ); + 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 5d93a9641..53c202536 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 @@ -671,9 +671,7 @@ public function regexp_replace( $expr, $pattern, $replacement, $pos = 1, $occurr } $compiled = $this->regexp_compile( $pattern, $match_type ); - $expr = (string) $expr; - $replacement = (string) $replacement; - $byte_start = $this->regexp_char_to_byte_offset( $expr, (int) $pos, true ); + $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; @@ -739,8 +737,6 @@ public function regexp_substr( $expr, $pattern, $pos = 1, $occurrence = 1, $matc } $compiled = $this->regexp_compile( $pattern, $match_type ); - $expr = (string) $expr; - // MySQL clamps occurrence <= 0 to 1. $n = max( 1, (int) $occurrence ); @@ -785,7 +781,6 @@ public function regexp_instr( $expr, $pattern, $pos = 1, $occurrence = 1, $retur } $compiled = $this->regexp_compile( $pattern, $match_type ); - $expr = (string) $expr; $position = (int) $pos; // MySQL clamps occurrence <= 0 to 1. 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 622b0fdd7..fb587cc5d 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 @@ -1247,6 +1247,77 @@ public static function regexpHyphenAfterShorthandCases() { ); } + /** + * @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 ".". From 7ed6ba1dd48df779013a82d6159981ac141ccd46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Thu, 5 Nov 2026 16:24:07 +0100 Subject: [PATCH 12/14] Document known REGEXP differences from MySQL List the differences that stay on purpose, because fixing them would take a pattern translator, or more code than such rare input is worth: ICU-only syntax, CRLF, ICU sets, hyphens after classes, numeric text and arguments, inline case flags, and \u escapes in replacements. --- ...s-wp-sqlite-pdo-user-defined-functions.php | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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 53c202536..5de0c6fdc 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 @@ -1204,6 +1204,15 @@ public function _helper_like_to_glob_pattern( $pattern ) { * 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. + * - 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: * @@ -1214,7 +1223,17 @@ public function _helper_like_to_glob_pattern( $pattern ) { * - 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. + * 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)". * * @param string $pattern The MySQL regex pattern. * @param string $match_type MySQL match_type flag string. @@ -1435,6 +1454,7 @@ private function regexp_char_to_byte_offset( $s, $char_pos, $allow_past_end = fa * * 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 From 463f26b471679880211532b9da2d2fdbd12c6439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Thu, 5 Nov 2026 18:03:35 +0100 Subject: [PATCH 13/14] Reject unknown escapes in REGEXP patterns on PHP 7 PHP 7.3 and 7.4 read unknown escapes as literal text. '\u0041' silently matched "u0041", and '\p{Foo}' matched nothing instead of failing. PHP 7.2 did the same for escapes like '\y'. PHP 8 reports them as errors, as the known differences say. The "X" modifier makes them errors on PHP 7, too, and does nothing on PHP 8. MySQL reads unknown escapes like '\y' as literals, so document that difference as well. --- .../sqlite/class-wp-sqlite-pdo-user-defined-functions.php | 7 ++++++- .../mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) 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 5de0c6fdc..bc11429e5 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 @@ -1205,6 +1205,7 @@ public function _helper_like_to_glob_pattern( $pattern ) { * - 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 @@ -1276,7 +1277,11 @@ private function regexp_compile( $pattern, $match_type, $binary_mode = false ) { } } - $modifiers = $binary_mode ? '' : 'u'; + /* + * 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'; } 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 fb587cc5d..d1503d86b 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 @@ -388,6 +388,11 @@ 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()', From 83cfc3baa139d225cec3d9874e85c9a5d9e9861e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Thu, 5 Nov 2026 18:06:02 +0100 Subject: [PATCH 14/14] Retry a REGEXP match without JIT when its stack runs out PHP's JIT stack is small, so a repeated group like '(a|b)*' failed with "exceeded internal limits" on a subject of about 6,000 characters. MySQL matches such subjects, and trunk silently treated them as not matching. On PHP 7.3+, retry such a match with "(*NO_JIT)", which raises the limit to about 50,000 characters. PHP 7.2 has no "(*NO_JIT)", and matching without JIT can crash it, so it keeps the JIT limit of about 2,000 characters. Document the remaining limit. --- ...s-wp-sqlite-pdo-user-defined-functions.php | 48 +++++++++++++------ .../tests/WP_MySQL_On_SQLite_Tests.php | 40 ++++++++++++++++ 2 files changed, 74 insertions(+), 14 deletions(-) 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 bc11429e5..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 @@ -614,11 +614,7 @@ public function regexp( $pattern, $field, $binary = 0 ) { */ $byte_mode = 1 === $binary || ! $this->regexp_is_utf8( $pattern ) || ! $this->regexp_is_utf8( $field ); $compiled = $this->regexp_compile( $pattern, $byte_mode ? 'c' : '', $byte_mode ); - $result = preg_match( $compiled, $field ); - if ( false === $result ) { - $this->regexp_fail( $pattern ); - } - return $result; + return $this->regexp_match( $compiled, $pattern, $field ); } /** @@ -636,11 +632,7 @@ public function regexp_like( $expr, $pattern, $match_type = '' ) { return null; } $compiled = $this->regexp_compile( $pattern, $match_type ); - $result = preg_match( $compiled, $expr ); - if ( false === $result ) { - $this->regexp_fail( $pattern ); - } - return $result; + return $this->regexp_match( $compiled, $pattern, $expr ); } /** @@ -1235,6 +1227,8 @@ public function _helper_like_to_glob_pattern( $pattern ) { * \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. @@ -1579,16 +1573,14 @@ private function regexp_find_nth_match( $compiled, $pattern, $subject, $offset, private function regexp_matches( $compiled, $pattern, $subject, $offset ) { $len = strlen( $subject ); while ( $offset <= $len ) { - $r = preg_match( + $r = $this->regexp_match( $compiled, + $pattern, $subject, $m, PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL, $offset ); - if ( false === $r ) { - $this->regexp_fail( $pattern ); - } if ( 0 === $r ) { return; } @@ -1608,6 +1600,34 @@ private function regexp_matches( $compiled, $pattern, $subject, $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. * 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 d1503d86b..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 @@ -427,6 +427,46 @@ public function testRegexpBacktrackLimit() { ); } + 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" );