From bca12b6f8658afc13269132b3b851e60abeb6fd0 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 1/5] Add REGEXP_LIKE() UDF Implement MySQL REGEXP_LIKE(expr, pattern [, match_type]) with explicit supported arities and shared helpers for UTF-8 validation, PCRE compilation, and MySQL-style error translation. Translate the c, i, m, n, and u flags with MySQL newline semantics; preserve numeric SQL operands; and validate arguments in MySQL order. Document the remaining ICU multi-code-point case-folding limitation. Cover arity and error precedence, flags and newlines, delimiter and quoted-literal escaping, invalid UTF-8, numeric operands, resource errors, and coexistence with the legacy REGEXP operator. --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 81 +++++- ...s-wp-sqlite-pdo-user-defined-functions.php | 268 +++++++++++++++++- .../tests/WP_SQLite_Driver_Tests.php | 169 +++++++++++ 3 files changed, 509 insertions(+), 9 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php index cdb8698ee..3d575dcc6 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php @@ -4570,11 +4570,10 @@ private function translate_function_call( WP_Parser_Node $node ): string { $this->unquote_sqlite_identifier( $this->translate( $nodes[0] ) ) ); - $args = array(); - if ( isset( $nodes[1] ) ) { - foreach ( $nodes[1]->get_child_nodes() as $child ) { - $args[] = $this->translate( $child ); - } + $arg_nodes = isset( $nodes[1] ) ? $nodes[1]->get_child_nodes() : array(); + $args = array(); + foreach ( $arg_nodes as $child ) { + $args[] = $this->translate( $child ); } switch ( $name ) { @@ -4668,11 +4667,83 @@ private function translate_function_call( WP_Parser_Node $node ): string { substr( $version, 3, 2 ) ); return $this->quote_sqlite_value( $value ); + case 'REGEXP_LIKE': + case 'REGEXP_REPLACE': + case 'REGEXP_SUBSTR': + case 'REGEXP_INSTR': + return sprintf( + '%s(%s)', + $name, + implode( ', ', $this->translate_regexp_function_args( $name, $arg_nodes ) ) + ); default: return $this->translate_sequence( $node->get_children() ); } } + /** + * Translate REGEXP function arguments while preserving numeric literal text. + * + * @param string $name Function name. + * @param array $arg_nodes Function argument nodes. + * + * @return array Translated arguments. + */ + private function translate_regexp_function_args( string $name, array $arg_nodes ): array { + $string_arg_indexes = array( + 'REGEXP_LIKE' => array( 0, 1, 2 ), + 'REGEXP_REPLACE' => array( 0, 1, 2, 5 ), + 'REGEXP_SUBSTR' => array( 0, 1, 4 ), + 'REGEXP_INSTR' => array( 0, 1, 5 ), + ); + + $args = array(); + foreach ( $arg_nodes as $index => $arg_node ) { + $numeric_literal = null; + if ( in_array( $index, $string_arg_indexes[ $name ], true ) ) { + $numeric_literal = $this->get_regexp_numeric_literal_string( $arg_node ); + } + $args[] = null === $numeric_literal + ? $this->translate( $arg_node ) + : $this->quote_sqlite_value( $numeric_literal ); + } + return $args; + } + + /** + * Read an exact decimal or floating-point literal from an expression node. + * + * @param WP_Parser_Node $node Expression node. + * + * @return string|null Literal text, or NULL for a non-literal expression. + */ + private function get_regexp_numeric_literal_string( WP_Parser_Node $node ): ?string { + $number = null; + $sign = ''; + foreach ( $node->get_descendant_tokens() as $token ) { + $value = $token->get_value(); + if ( '(' === $value || ')' === $value ) { + continue; + } + if ( ( '+' === $value || '-' === $value ) && null === $number && '' === $sign ) { + $sign = '-' === $value ? '-' : ''; + continue; + } + if ( + null === $number + && ( + WP_MySQL_Lexer::DECIMAL_NUMBER === $token->id + || WP_MySQL_Lexer::FLOAT_NUMBER === $token->id + ) + ) { + $number = $value; + continue; + } + return null; + } + return null === $number ? null : $sign . $number; + } + /** * Translate a MySQL datetime literal to SQLite. * 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 970c1f925..16b2e674e 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 @@ -31,10 +31,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; @@ -71,6 +74,7 @@ public static function register_for( $pdo ): self { 'isnull' => 'isnull', 'if' => '_if', 'regexp' => 'regexp', + 'regexp_like' => 'regexp_like', 'field' => 'field', 'log' => 'log', 'least' => 'least', @@ -96,6 +100,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). * @@ -625,6 +649,37 @@ 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 === $match_type ) { + return null; + } + $compiled = $this->regexp_compile( $pattern, $match_type ); + if ( null === $expr || null === $pattern ) { + return null; + } + $expr = $this->regexp_string_arg( $expr ); + $pattern = $this->regexp_string_arg( $pattern ); + $result = $this->regexp_run( + function () use ( $compiled, $expr ) { + return preg_match( $compiled, $expr ); + } + ); + if ( false === $result ) { + $this->regexp_fail( $pattern ); + } + return $result; + } + /** * Method to emulate MySQL FIELD() function. * @@ -1001,4 +1056,209 @@ 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|null $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|null PCRE-ready pattern with delimiter and modifiers, or + * NULL when the pattern is NULL. + */ + private function regexp_compile( $pattern, $match_type ) { + $match_type = $this->regexp_string_arg( $match_type ); + if ( null !== $pattern ) { + $pattern = $this->regexp_string_arg( $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'; + } + if ( null === $pattern ) { + return null; + } + + $newline = $unix_lines ? '(*LF)' : '(*ANY)'; + $compiled = '/' . $newline . $this->regexp_escape_delimiter( $pattern ) . '/' . $modifiers; + $valid = $this->regexp_run( + function () use ( $compiled ) { + return preg_match( $compiled, '' ); + } + ); + 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; + } + + /** + * Convert a regex string-domain argument to its MySQL-style text form. + * + * @param mixed $value Argument value. + * + * @return string String representation. + */ + private function regexp_string_arg( $value ) { + $is_float = is_float( $value ); + $value = (string) $value; + if ( $is_float && false !== strpos( $value, 'E' ) ) { + $value = str_replace( 'E', 'e', $value ); + $value = str_replace( 'e+', 'e', $value ); + } + return $value; + } + + /** + * Run a preg_* callable with PHP warnings suppressed. + * + * PHPUnit's strict error handler turns preg_* warnings into ErrorExceptions + * before we can translate them into a MySQL-style error. This wrapper + * suppresses those warnings so the caller can check preg_match's false + * result and throw a clean exception. + * + * @param callable $op Preg operation. Must be self-contained. + * + * @return mixed Return value of the callable. + */ + private function regexp_run( $op ) { + set_error_handler( static function () {} ); + try { + return $op(); + } finally { + restore_error_handler(); + } + } + + /** + * 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 + || ( defined( 'PREG_JIT_STACKLIMIT_ERROR' ) && 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_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 942d30100..b27217677 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -104,6 +104,175 @@ 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->engine->get_query_results()[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() { + $this->assertQuery( "SELECT REGEXP_LIKE('abc', 'abc', NULL) AS r" ); + $this->assertNull( $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpLikeValidatesBeforeNullPropagation() { + $this->assertQueryError( + "SELECT REGEXP_LIKE(NULL, '(abc')", + 'Invalid regular expression: (abc.' + ); + $this->assertQueryError( + "SELECT REGEXP_LIKE(NULL, 'abc', 'x')", + 'Invalid match_type flag: x.' + ); + $this->assertQuery( "SELECT REGEXP_LIKE(NULL, '(abc', NULL) AS r" ); + $this->assertNull( $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpLikeInvalidFlag() { + $this->assertQueryError( + "SELECT REGEXP_LIKE('abc', 'a', 'x')", + 'Invalid match_type flag: x.' + ); + } + + public function testRegexpLikeInvalidPattern() { + $this->assertQueryError( + "SELECT REGEXP_LIKE('abc', '(abc')", + 'Invalid regular expression: (abc.' + ); + } + + 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->engine->get_query_results()[0]->r ); + } + + public function testRegexpMatchTypeUnixFlagNoOp() { + // The 'u' flag is accepted for source compatibility but has no effect + // (PCRE's default already matches MySQL's 'u' semantics). + $this->assertQuery( "SELECT REGEXP_LIKE('abc', 'abc', 'u') AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpMatchTypeEmpty() { + // Empty match_type behaves like the default (case-insensitive). + $this->assertQuery( "SELECT REGEXP_LIKE('ABC', 'abc', '') AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[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->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT 'abc' REGEXP 'xyz' AS r" ); + $this->assertSame( '0', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpLikeEscapedDelimiter() { + $this->assertQuery( "SELECT REGEXP_LIKE('/', '\\\\/') AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_LIKE('/', '\\\\Q/\\\\E', 'c') AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpLikeNumericOperands() { + $this->assertQuery( 'SELECT REGEXP_LIKE(123, 2) AS r' ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_LIKE(1.2300, '00\$', 'c') AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_LIKE('1.2300', 1.2300, 'c') AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_LIKE(CAST(1.2e20 AS DOUBLE), 'e20\$', 'c') AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpUnicodeNewlineHandling() { + $this->assertQuery( "SELECT REGEXP_LIKE(CAST(X'610D62' AS CHAR), 'a.b', 'c') AS r" ); + $this->assertSame( '0', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_LIKE(CAST(X'610D62' AS CHAR), '^b', 'cm') AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_LIKE(CAST(X'610D62' AS CHAR), 'a.b', 'cu') AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_LIKE(CAST(X'61E280A862' AS CHAR), '^b', 'cm') AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpPcreCaseFoldingLimitation() { + // ICU matches "ß" with "ss" case-insensitively; PCRE does not support + // this multi-code-point fold. + $this->assertQuery( "SELECT REGEXP_LIKE('ß', 'ss', 'i') AS r" ); + $this->assertSame( '0', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpLikeRejectsUnsupportedArgumentCount() { + $this->assertQueryError( + "SELECT REGEXP_LIKE('abc', 'abc', 'c', 'extra')", + 'SQLSTATE[HY000]: General error: 1 wrong number of arguments to function REGEXP_LIKE()' + ); + } + public function testInsertDateNow() { $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('first', now());" From f6e3e8866d4d41ce5489cde463bd2cd5f332b903 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 2/5] Add REGEXP_REPLACE() UDF Implement MySQL REGEXP_REPLACE(expr, pattern, replacement [, pos [, occurrence [, match_type]]]) with character-based positions, MySQL replacement-template expansion, and exact argument-count validation. Stream matches instead of retaining the complete match list, preserve numeric capture indexes and unmatched groups, and handle empty subjects, zero-width matches, lookbehind, full-subject UTF-8 validation, and MySQL numeric coercion and 32-bit narrowing. Cover replacement grammar and errors, named and optional captures, numeric operands, NULL and validation precedence, multibyte offsets, invalid prefixes, position boundaries, and empty-subject behavior. --- ...s-wp-sqlite-pdo-user-defined-functions.php | 348 +++++++++++++++++- .../tests/WP_SQLite_Driver_Tests.php | 259 +++++++++++++ 2 files changed, 606 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 16b2e674e..d81e18134 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 @@ -75,6 +75,7 @@ public static function register_for( $pdo ): self { 'if' => '_if', 'regexp' => 'regexp', 'regexp_like' => 'regexp_like', + 'regexp_replace' => 'regexp_replace', 'field' => 'field', 'log' => 'log', 'least' => 'least', @@ -108,7 +109,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. */ @@ -680,6 +682,94 @@ function () use ( $compiled, $expr ) { return $result; } + /** + * Method to emulate MySQL REGEXP_REPLACE() function. + * + * Uses MySQL/ICU replacement grammar: "$N" backreferences ("$0" is the + * full match), "\X" emits X (drops the backslash), "${N}" is rejected. + * 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 === $match_type ) { + return null; + } + $compiled = $this->regexp_compile( $pattern, $match_type ); + $position = null === $pos ? null : $this->regexp_int_arg( $pos ); + $n = null === $occurrence ? null : $this->regexp_int_arg( $occurrence ); + if ( null !== $position && $position < 1 ) { + throw new Exception( 'Index out of bounds in regular expression search.' ); + } + if ( + null === $expr || null === $pattern || null === $replacement + || null === $pos || null === $occurrence + ) { + return null; + } + + $expr = $this->regexp_string_arg( $expr ); + $pattern = $this->regexp_string_arg( $pattern ); + $replacement = $this->regexp_string_arg( $replacement ); + $this->regexp_validate_subject( $expr, $pattern ); + $byte_start = $this->regexp_char_to_byte_offset( $expr, $position, true ); + + // 0 means replace all; negative occurrences are clamped to 1 (MySQL behavior). + if ( $n < 0 ) { + $n = 1; + } + if ( '' === $expr ) { + return $expr; + } + + if ( $n > 0 ) { + $match = $this->regexp_find_nth_match( $compiled, $expr, $byte_start, $n ); + if ( false === $match ) { + $this->regexp_fail( $pattern ); + } + if ( null === $match ) { + return $expr; + } + $match_start = $match[0][1]; + $match_length = strlen( $match[0][0] ); + return substr( $expr, 0, $match_start ) + . $this->regexp_expand_match_replacement( $replacement, $match ) + . substr( $expr, $match_start + $match_length ); + } + + // Rebuild the subject while streaming through every match. + $out = substr( $expr, 0, $byte_start ); + $cur = $byte_start; + $ok = $this->regexp_walk_matches( + $compiled, + $expr, + $byte_start, + function ( $match_data ) use ( $expr, $replacement, &$out, &$cur ) { + $match_start = $match_data[0][1]; + $match_length = strlen( $match_data[0][0] ); + $out .= substr( $expr, $cur, $match_start - $cur ); + $out .= $this->regexp_expand_match_replacement( $replacement, $match_data ); + $cur = $match_start + $match_length; + return true; + } + ); + if ( false === $ok ) { + $this->regexp_fail( $pattern ); + } + $out .= substr( $expr, $cur ); + + return $out; + } + /** * Method to emulate MySQL FIELD() function. * @@ -1236,6 +1326,262 @@ private function regexp_run( $op ) { } } + /** + * Convert a numeric-valued integer argument using MySQL rounding. + * + * MySQL rounds numeric values, truncates numeric strings, and narrows these + * REGEXP parameters to a signed 32-bit integer. + * + * @param int|float|string $value Numeric value. + * + * @return int Rounded integer. + */ + private function regexp_int_arg( $value ) { + if ( is_float( $value ) ) { + $value = round( $value ); + if ( ! is_finite( $value ) || $value >= PHP_INT_MAX ) { + $value = $value < 0 ? PHP_INT_MIN : PHP_INT_MAX; + } elseif ( $value <= PHP_INT_MIN ) { + $value = PHP_INT_MIN; + } else { + $value = (int) $value; + } + } else { + $value = (int) $value; + } + + $value &= 0xFFFFFFFF; + return $value >= 0x80000000 ? $value - 0x100000000 : $value; + } + + /** + * Validate the complete UTF-8 subject before matching from a byte offset. + * + * @param string $subject Subject string. + * @param string $pattern Original regex pattern, used in error reporting. + * + * @throws Exception If the subject contains invalid UTF-8. + * @return void + */ + private function regexp_validate_subject( $subject, $pattern ) { + $valid = $this->regexp_run( + function () use ( $subject ) { + return preg_match( '//u', $subject ); + } + ); + if ( false === $valid ) { + $this->regexp_fail( $pattern ); + } + } + + /** + * 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 replacement template using one preg_match result. + * + * @param string $replacement Replacement template. + * @param array $match_data Match in PREG_OFFSET_CAPTURE format. + * + * @return string Expanded replacement. + */ + private function regexp_expand_match_replacement( $replacement, $match_data ) { + $groups = array(); + foreach ( $match_data as $index => $group ) { + if ( is_int( $index ) ) { + $groups[ $index ] = null === $group[0] ? '' : $group[0]; + } + } + return $this->regexp_expand_replacement( $replacement, $groups ); + } + + /** + * Expand a MySQL/ICU-style replacement template. + * + * 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. + * - "$" not followed by a digit: error (matches MySQL ERROR 3887). + * - "$N" where N is larger than any existing group: error (ERROR 3686). + * - "${N}" is NOT supported and raises the same error as a bare "$". + * + * @param string $replacement The replacement template. + * @param array $groups Capture-group texts, with index 0 = full match. + * + * @throws Exception On an invalid "$..." reference. + * @return string The expanded replacement. + */ + private function regexp_expand_replacement( $replacement, $groups ) { + $max_group = count( $groups ) - 1; + $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 || ! ctype_digit( $replacement[ $i + 1 ] ) ) { + throw new Exception( 'A capture group has an invalid name.' ); + } + $j = $i + 1; + while ( $j < $len && ctype_digit( $replacement[ $j ] ) ) { + ++$j; + } + // Longest digit prefix that refers to an existing group wins; + // remaining digits are emitted literally. + $digits = substr( $replacement, $i + 1, $j - $i - 1 ); + $idx = null; + $consumed = 0; + for ( $k = strlen( $digits ); $k > 0; --$k ) { + $cand = (int) substr( $digits, 0, $k ); + if ( $cand <= $max_group ) { + $idx = $cand; + $consumed = $k; + break; + } + } + if ( null === $idx ) { + throw new Exception( 'Index out of bounds in regular expression search.' ); + } + $out .= $groups[ $idx ]; + $i += 1 + $consumed; + continue; + } + $out .= $c; + ++$i; + } + return $out; + } + + /** + * Find one numbered match without retaining preceding matches. + * + * @param string $compiled PCRE-wrapped pattern. + * @param string $subject Full subject string. + * @param int $offset Byte offset at which matching begins. + * @param int $occurrence 1-based match number. + * + * @return array|false|null Match array, false on preg error, or NULL if absent. + */ + private function regexp_find_nth_match( $compiled, $subject, $offset, $occurrence ) { + $index = 0; + $match = null; + $ok = $this->regexp_walk_matches( + $compiled, + $subject, + $offset, + function ( $candidate ) use ( $occurrence, &$index, &$match ) { + ++$index; + if ( $index === $occurrence ) { + $match = $candidate; + return false; + } + return true; + } + ); + return $ok ? $match : false; + } + + /** + * Walk 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. + * Returning false from the callback stops iteration successfully. + * + * @param string $compiled PCRE-wrapped pattern. + * @param string $subject Full subject string. + * @param int $offset Initial byte offset. + * @param callable $callback Invoked for each match. + * + * @return bool False on preg error, true otherwise. + */ + private function regexp_walk_matches( $compiled, $subject, $offset, $callback ) { + return $this->regexp_run( + function () use ( $compiled, $subject, $offset, $callback ) { + $len = strlen( $subject ); + while ( true ) { + $r = preg_match( + $compiled, + $subject, + $m, + PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL, + $offset + ); + if ( false === $r ) { + return false; + } + if ( 0 === $r ) { + return true; + } + if ( false === $callback( $m ) ) { + return true; + } + $match_start = $m[0][1]; + $match_length = strlen( $m[0][0] ); + $next = $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. + ++$next; + while ( $next < $len && ( ord( $subject[ $next ] ) & 0xC0 ) === 0x80 ) { + ++$next; + } + } + if ( $next > $len ) { + return true; + } + $offset = $next; + } + } + ); + } + /** * Translate a preg_* failure into a caller-friendly exception message. * diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index b27217677..92ee8162b 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -273,6 +273,265 @@ public function testRegexpLikeRejectsUnsupportedArgumentCount() { ); } + /** + * @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->engine->get_query_results()[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' ), + ); + } + + public function testRegexpReplaceNullPropagation() { + $this->assertQuery( "SELECT REGEXP_REPLACE(NULL, 'a', 'b') AS r" ); + $this->assertNull( $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', NULL, 'b') AS r" ); + $this->assertNull( $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', 'a', NULL) AS r" ); + $this->assertNull( $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpReplaceValidatesBeforeNullPropagation() { + $this->assertQueryError( + "SELECT REGEXP_REPLACE(NULL, '(abc', 'x')", + 'Invalid regular expression: (abc.' + ); + $this->assertQueryError( + "SELECT REGEXP_REPLACE(NULL, 'a', 'x', 0)", + 'Index out of bounds in regular expression search.' + ); + $this->assertQueryError( + "SELECT REGEXP_REPLACE(NULL, 'a', 'x', 1, 0, 'x')", + 'Invalid match_type flag: x.' + ); + } + + /** + * @dataProvider regexpReplaceFullCases + */ + public function testRegexpReplaceFull( $sql, $expected ) { + $this->assertQuery( "SELECT $sql AS r" ); + $this->assertSame( $expected, $this->engine->get_query_results()[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' ), + ); + } + + public function testRegexpReplaceRoundsNumericPositionArguments() { + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', '.', 'X', 2.9, 1.9) AS r" ); + $this->assertSame( 'abc', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', '.', 'X', '2.9', '1.9') AS r" ); + $this->assertSame( 'aXc', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpReplaceNumericOperands() { + $this->assertQuery( "SELECT REGEXP_REPLACE(123, '2', 9) AS r" ); + $this->assertSame( '193', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('a', 'a', 1.2300) AS r" ); + $this->assertSame( '1.2300', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpReplacePosOutOfRange() { + $this->assertQueryError( + "SELECT REGEXP_REPLACE('abc', 'a', 'X', 10)", + 'Index out of bounds in regular expression search.' + ); + } + + public function testRegexpReplacePosAtEnd() { + // MySQL allows pos = char_count + 1 for REPLACE. + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', 'a', 'X', 4) AS r" ); + $this->assertSame( 'abc', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', '\$', 'X', 4) AS r" ); + $this->assertSame( 'abcX', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpReplacePosBeyondEnd() { + $this->assertQueryError( + "SELECT REGEXP_REPLACE('abc', 'a', 'X', 5)", + 'Index out of bounds in regular expression search.' + ); + } + + public function testRegexpReplacePosZero() { + $this->assertQueryError( + "SELECT REGEXP_REPLACE('abc', 'a', 'X', 0)", + 'Index out of bounds in regular expression search.' + ); + } + + 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->engine->get_query_results()[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->engine->get_query_results()[0]->r ); + } + + public function testRegexpReplaceOccurrenceBackreferenceForms() { + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', '(a)(b)(c)', '\$2\$1', 1, 1) AS r" ); + $this->assertSame( 'ba', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpReplaceBraceBackrefIsInvalid() { + // MySQL/ICU rejects "${N}"; "$" must be followed by a digit. + $this->assertQueryError( + "SELECT REGEXP_REPLACE('abc', '(a)(b)(c)', '\${2}\${1}', 1, 1)", + '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->engine->get_query_results()[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->engine->get_query_results()[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->engine->get_query_results()[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->engine->get_query_results()[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->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('a', '(a)|(b)', '[\$2]') AS r" ); + $this->assertSame( '[]', $this->engine->get_query_results()[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->engine->get_query_results()[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->engine->get_query_results()[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->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('abcabc', '(?<=a)b', 'X', 1, 2) AS r" ); + $this->assertSame( 'abcaXc', $this->engine->get_query_results()[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->engine->get_query_results()[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->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('abc def', '\\\\b', '|', 1, 2) AS r" ); + $this->assertSame( 'abc| def', $this->engine->get_query_results()[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->engine->get_query_results()[0]->r ); + } + + public function testRegexpReplaceEmptyReplacement() { + $this->assertQuery( "SELECT REGEXP_REPLACE('abc', 'b', '') AS r" ); + $this->assertSame( 'ac', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpReplaceLeavesEmptySubjectUnchanged() { + $this->assertQuery( "SELECT REGEXP_REPLACE('', '\$', 'X') AS r" ); + $this->assertSame( '', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_REPLACE('', '\$', '\$x') AS r" ); + $this->assertSame( '', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpReplaceValidatesUtf8BeforePos() { + $this->assertQueryError( + "SELECT REGEXP_REPLACE(CAST(X'FF61' AS CHAR), 'a', 'X', 2)", + 'Invalid UTF-8 data in regular expression input.' + ); + } + + public function testRegexpReplaceRejectsUnsupportedArgumentCount() { + $this->assertQueryError( + "SELECT REGEXP_REPLACE('abc', 'a', 'x', 1, 0, 'c', 'extra')", + 'SQLSTATE[HY000]: General error: 1 wrong number of arguments to function REGEXP_REPLACE()' + ); + } + public function testInsertDateNow() { $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('first', now());" From cdd5133d50f4d2e73094e33c7f83b076ccf30ce8 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 3/5] Add REGEXP_SUBSTR() UDF Implement MySQL REGEXP_SUBSTR(expr, pattern [, pos [, occurrence [, match_type]]]) by returning the requested streamed match at a character position, or NULL when no such match exists. Match MySQL argument validation and numeric coercion, validate the complete UTF-8 subject before applying a nonzero position, preserve lookbehind context, and allow a zero-width match when pos is one character past the end. Cover NULL and error precedence, occurrence clamping, numeric and multibyte inputs, invalid UTF-8 prefixes, position boundaries, anchors, zero-width matches, and lookbehind across pos. --- ...s-wp-sqlite-pdo-user-defined-functions.php | 53 +++++++ .../tests/WP_SQLite_Driver_Tests.php | 142 ++++++++++++++++++ 2 files changed, 195 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 d81e18134..507c9353e 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 @@ -76,6 +76,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', @@ -111,6 +112,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. */ @@ -770,6 +772,57 @@ function ( $match_data ) use ( $expr, $replacement, &$out, &$cur ) { return $out; } + /** + * Method to emulate MySQL REGEXP_SUBSTR() function. + * + * Values of `occurrence` less than 1 are clamped to 1, matching MySQL. + * `pos = char_count + 1` is accepted and can return a zero-width match. + * + * @param string|null $expr Subject string. + * @param string|null $pattern Regex pattern. + * @param int|float|string|null $pos 1-based character position to start matching. + * @param int|float|string|null $occurrence Which match to return (1-based; <= 0 clamps to 1). + * @param string|null $match_type MySQL match_type flags. + * + * @throws Exception If the pattern is not a valid regular expression, or pos is out of range. + * @return string|null The matched substring, NULL if no match or any argument is NULL. + */ + public function regexp_substr( $expr, $pattern, $pos = 1, $occurrence = 1, $match_type = '' ) { + if ( null === $match_type ) { + return null; + } + $compiled = $this->regexp_compile( $pattern, $match_type ); + $position = null === $pos ? null : $this->regexp_int_arg( $pos ); + $n = null === $occurrence ? null : $this->regexp_int_arg( $occurrence ); + if ( null !== $position && $position < 1 ) { + throw new Exception( 'Index out of bounds in regular expression search.' ); + } + if ( + null === $expr || null === $pattern + || null === $pos || null === $occurrence + ) { + return null; + } + + $expr = $this->regexp_string_arg( $expr ); + $pattern = $this->regexp_string_arg( $pattern ); + $this->regexp_validate_subject( $expr, $pattern ); + + // MySQL clamps occurrence <= 0 to 1. + $n = max( 1, $n ); + + $byte_start = $this->regexp_char_to_byte_offset( $expr, $position, true ); + + $match = $this->regexp_find_nth_match( $compiled, $expr, $byte_start, $n ); + if ( false === $match ) { + $this->regexp_fail( $pattern ); + } + if ( null === $match ) { + return null; + } + return $match[0][0]; + } + /** * Method to emulate MySQL FIELD() function. * diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 92ee8162b..59ca68b5f 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -532,6 +532,148 @@ public function testRegexpReplaceRejectsUnsupportedArgumentCount() { ); } + /** + * @dataProvider regexpSubstrCases + */ + public function testRegexpSubstr( $sql, $expected ) { + $this->assertQuery( "SELECT $sql AS r" ); + $this->assertSame( $expected, $this->engine->get_query_results()[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é', 'é')", 'é' ), + 'null expr' => array( 'REGEXP_SUBSTR(NULL, \'abc\')', null ), + 'null pattern' => array( "REGEXP_SUBSTR('abc', NULL)", null ), + ); + } + + public function testRegexpSubstrNullPos() { + $this->assertQuery( "SELECT REGEXP_SUBSTR('abc', 'a', NULL) AS r" ); + $this->assertNull( $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpSubstrNullOccurrence() { + $this->assertQuery( "SELECT REGEXP_SUBSTR('abc', 'a', 1, NULL) AS r" ); + $this->assertNull( $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpSubstrNullMatchType() { + $this->assertQuery( "SELECT REGEXP_SUBSTR('abc', 'a', 1, 1, NULL) AS r" ); + $this->assertNull( $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_SUBSTR('abc', '(abc', 0, 1, NULL) AS r" ); + $this->assertNull( $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpSubstrValidatesBeforeNullPropagation() { + $this->assertQueryError( + "SELECT REGEXP_SUBSTR(NULL, '(abc')", + 'Invalid regular expression: (abc.' + ); + $this->assertQueryError( + "SELECT REGEXP_SUBSTR(NULL, 'a', 0)", + 'Index out of bounds in regular expression search.' + ); + $this->assertQueryError( + "SELECT REGEXP_SUBSTR(NULL, 'a', 1, 1, 'x')", + 'Invalid match_type flag: x.' + ); + } + + public function testRegexpSubstrOccurrenceClampedToOne() { + // MySQL clamps occurrence <= 0 to 1. + $this->assertQuery( "SELECT REGEXP_SUBSTR('abcabc', 'b', 1, 0) AS r" ); + $this->assertSame( 'b', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_SUBSTR('abcabc', 'b', 1, -5) AS r" ); + $this->assertSame( 'b', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpSubstrPosOutOfRange() { + $this->assertQueryError( + "SELECT REGEXP_SUBSTR('abc', 'a', 10)", + 'Index out of bounds in regular expression search.' + ); + } + + public function testRegexpSubstrPosAtEnd() { + // MySQL allows pos = char_count + 1 for SUBSTR. + $this->assertQuery( "SELECT REGEXP_SUBSTR('abc', 'a', 4) AS r" ); + $this->assertNull( $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_SUBSTR('abc', '\$', 4) AS r" ); + $this->assertSame( '', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpSubstrPosBeyondEnd() { + $this->assertQueryError( + "SELECT REGEXP_SUBSTR('abc', 'a', 5)", + 'Index out of bounds in regular expression search.' + ); + } + + public function testRegexpSubstrPosZero() { + $this->assertQueryError( + "SELECT REGEXP_SUBSTR('abc', 'a', 0)", + 'Index out of bounds in regular expression search.' + ); + } + + public function testRegexpSubstrInvalidPattern() { + $this->assertQueryError( + "SELECT REGEXP_SUBSTR('abc', '(abc')", + 'Invalid regular expression: (abc.' + ); + } + + public function testRegexpSubstrInvalidFlag() { + $this->assertQueryError( + "SELECT REGEXP_SUBSTR('abc', 'a', 1, 1, 'x')", + 'Invalid match_type flag: x.' + ); + } + + 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->engine->get_query_results()[0]->r ); + } + + public function testRegexpSubstrRoundsNumericPositionArguments() { + $this->assertQuery( "SELECT REGEXP_SUBSTR('a1b2c3', '[0-9]', 2.9, 1.9) AS r" ); + $this->assertSame( '3', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_SUBSTR('a1b2c3', '[0-9]', '2.9', '1.9') AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpSubstrNumericSubject() { + $this->assertQuery( "SELECT REGEXP_SUBSTR(123, '2') AS r" ); + $this->assertSame( '2', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_SUBSTR(1.2300, '.*') AS r" ); + $this->assertSame( '1.2300', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_SUBSTR(-1.2300, '.*') AS r" ); + $this->assertSame( '-1.2300', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpSubstrValidatesUtf8BeforePos() { + $this->assertQueryError( + "SELECT REGEXP_SUBSTR(CAST(X'FF61' AS CHAR), 'a', 2)", + 'Invalid UTF-8 data in regular expression input.' + ); + } + + public function testRegexpSubstrRejectsUnsupportedArgumentCount() { + $this->assertQueryError( + "SELECT REGEXP_SUBSTR('abc', 'a', 1, 1, 'c', 'extra')", + 'SQLSTATE[HY000]: General error: 1 wrong number of arguments to function REGEXP_SUBSTR()' + ); + } + public function testInsertDateNow() { $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('first', now());" From 963e1ae1d25e4e1fd7fcdb03bdd01714b703cf3e 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 4/5] Add REGEXP_INSTR() UDF Implement MySQL REGEXP_INSTR(expr, pattern [, pos [, occurrence [, return_option [, match_type]]]]) with 1-based character positions and start-or-end result selection. Evaluate anchors and lookbehind against the region beginning at pos, matching MySQL semantics. Validate return_option before nullable pattern and flag arguments, apply MySQL numeric coercion, and reject positions beyond the subject. Cover validation precedence, rounded and wrapped numeric controls, multibyte results, invalid UTF-8 prefixes, occurrence clamping, region-relative anchors and lookbehind, and return-option errors. --- ...s-wp-sqlite-pdo-user-defined-functions.php | 91 +++++++++ .../tests/WP_SQLite_Driver_Tests.php | 173 ++++++++++++++++++ 2 files changed, 264 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 507c9353e..bdbb1a9dd 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 @@ -77,6 +77,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', @@ -113,6 +114,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. */ @@ -823,6 +825,72 @@ 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). + * + * @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 = '' ) { + $ret = null === $return_option ? null : $this->regexp_int_arg( $return_option ); + if ( null !== $ret && 0 !== $ret && 1 !== $ret ) { + throw new Exception( 'Incorrect arguments to regexp_instr: return_option must be 1 or 0.' ); + } + if ( null === $match_type ) { + return null; + } + $compiled = $this->regexp_compile( $pattern, $match_type ); + $position = null === $pos ? null : $this->regexp_int_arg( $pos ); + $n = null === $occurrence ? null : $this->regexp_int_arg( $occurrence ); + if ( null !== $position && $position < 1 ) { + throw new Exception( 'Index out of bounds in regular expression search.' ); + } + if ( + null === $expr || null === $pattern + || null === $pos || null === $occurrence + || null === $return_option + ) { + return null; + } + + $expr = $this->regexp_string_arg( $expr ); + $pattern = $this->regexp_string_arg( $pattern ); + $this->regexp_validate_subject( $expr, $pattern ); + + // MySQL clamps occurrence <= 0 to 1. + $n = max( 1, $n ); + + $byte_start = $this->regexp_char_to_byte_offset( $expr, $position ); + $region = substr( $expr, $byte_start ); + + $match = $this->regexp_find_nth_match( $compiled, $region, 0, $n ); + if ( false === $match ) { + $this->regexp_fail( $pattern ); + } + if ( null === $match ) { + return 0; + } + + list( $matched_text, $matched_byte_offset ) = $match[0]; + $target_byte = $byte_start + $matched_byte_offset; + if ( 1 === $ret ) { + $target_byte += strlen( $matched_text ); + } + + return $this->regexp_byte_offset_to_char_index( $expr, $target_byte ) + 1; + } + /** * Method to emulate MySQL FIELD() function. * @@ -1464,6 +1532,29 @@ private function regexp_char_to_byte_offset( $s, $char_pos, $allow_past_end = fa throw new Exception( 'Index out of bounds in regular expression search.' ); } + /** + * Convert a byte offset within a UTF-8 string into the 0-based character index. + * + * The byte offset is expected to fall on a UTF-8 code point boundary, as is + * the case for offsets returned by PCRE. Offsets greater than the string + * length are clamped to the string length as a defensive measure. + * + * @param string $s UTF-8 string. + * @param int $byte_offset Byte offset on a code point boundary. + * + * @return int 0-based character index. + */ + private function regexp_byte_offset_to_char_index( $s, $byte_offset ) { + $byte_offset = min( $byte_offset, strlen( $s ) ); + $chars = 0; + for ( $i = 0; $i < $byte_offset; $i++ ) { + if ( ( ord( $s[ $i ] ) & 0xC0 ) !== 0x80 ) { + ++$chars; + } + } + return $chars; + } + /** * Expand a replacement template using one preg_match result. * diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 59ca68b5f..73d33d0f2 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -674,6 +674,179 @@ public function testRegexpSubstrRejectsUnsupportedArgumentCount() { ); } + /** + * @dataProvider regexpInstrCases + */ + public function testRegexpInstr( $sql, $expected ) { + $this->assertQuery( "SELECT $sql AS r" ); + $this->assertSame( $expected, $this->engine->get_query_results()[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' ), + ); + } + + public function testRegexpInstrNullExpr() { + $this->assertQuery( "SELECT REGEXP_INSTR(NULL, 'abc') AS r" ); + $this->assertNull( $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpInstrNullPattern() { + $this->assertQuery( "SELECT REGEXP_INSTR('abc', NULL) AS r" ); + $this->assertNull( $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpInstrNullMatchType() { + $this->assertQuery( "SELECT REGEXP_INSTR('abc', '(abc', 0, 1, 0, NULL) AS r" ); + $this->assertNull( $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpInstrValidatesBeforeNullPropagation() { + $this->assertQueryError( + "SELECT REGEXP_INSTR(NULL, '(abc')", + 'Invalid regular expression: (abc.' + ); + $this->assertQueryError( + "SELECT REGEXP_INSTR(NULL, 'a', 0)", + 'Index out of bounds in regular expression search.' + ); + $this->assertQueryError( + "SELECT REGEXP_INSTR(NULL, 'a', 1, 1, 2)", + 'Incorrect arguments to regexp_instr: return_option must be 1 or 0.' + ); + $this->assertQueryError( + "SELECT REGEXP_INSTR(NULL, 'a', 1, 1, 0, 'x')", + 'Invalid match_type flag: x.' + ); + } + + public function testRegexpInstrPosZero() { + $this->assertQueryError( + "SELECT REGEXP_INSTR('abc', 'a', 0)", + 'Index out of bounds in regular expression search.' + ); + } + + public function testRegexpInstrPosOutOfRange() { + $this->assertQueryError( + "SELECT REGEXP_INSTR('abc', 'a', 10)", + 'Index out of bounds in regular expression search.' + ); + } + + public function testRegexpInstrInvalidReturnOption() { + $this->assertQueryError( + "SELECT REGEXP_INSTR('abc', 'a', 1, 1, 2)", + 'Incorrect arguments to regexp_instr: return_option must be 1 or 0.' + ); + $this->assertQueryError( + "SELECT REGEXP_INSTR('abc', '(abc', 1, 1, 2)", + 'Incorrect arguments to regexp_instr: return_option must be 1 or 0.' + ); + $this->assertQueryError( + "SELECT REGEXP_INSTR('abc', 'a', 1, 1, 1e100)", + 'Incorrect arguments to regexp_instr: return_option must be 1 or 0.' + ); + $this->assertQuery( "SELECT REGEXP_INSTR('abc', 'a', 1, 1, 4294967296) AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_INSTR('abc', 'a', 1, 1, 4294967297) AS r" ); + $this->assertSame( '2', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpInstrInvalidReturnOptionWithOccurrenceZero() { + // return_option must be validated before occurrence is clamped, so an + // invalid return_option consistently errors regardless of occurrence. + $this->assertQueryError( + "SELECT REGEXP_INSTR('abc', 'a', 1, 0, 99)", + 'Incorrect arguments to regexp_instr: return_option must be 1 or 0.' + ); + } + + public function testRegexpInstrInvalidPattern() { + $this->assertQueryError( + "SELECT REGEXP_INSTR('abc', '(abc')", + 'Invalid regular expression: (abc.' + ); + } + + public function testRegexpInstrInvalidFlag() { + $this->assertQueryError( + "SELECT REGEXP_INSTR('abc', 'a', 1, 1, 0, 'x')", + 'Invalid match_type flag: x.' + ); + } + + public function testRegexpInstrOccurrenceClampedToOne() { + // MySQL clamps occurrence <= 0 to 1. + $this->assertQuery( "SELECT REGEXP_INSTR('abcabc', 'b', 1, 0) AS r" ); + $this->assertSame( '2', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_INSTR('abcabc', 'b', 1, -5) AS r" ); + $this->assertSame( '2', $this->engine->get_query_results()[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->engine->get_query_results()[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->engine->get_query_results()[0]->r ); + } + + public function testRegexpInstrPatternIsRelativeToPos() { + $this->assertQuery( "SELECT REGEXP_INSTR('abc', '^b', 2) AS r" ); + $this->assertSame( '2', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_INSTR('ab', '(?<=a)b', 2) AS r" ); + $this->assertSame( '0', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpInstrRoundsNumericPositionArguments() { + $this->assertQuery( "SELECT REGEXP_INSTR('abc', '.', 2.9) AS r" ); + $this->assertSame( '3', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_INSTR('abc', '.', '2.9') AS r" ); + $this->assertSame( '2', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_INSTR('abc', '.', 1, 1.9) AS r" ); + $this->assertSame( '2', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_INSTR('abc', '.', 1, '1.9') AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_INSTR('abc', 'a', 1, 1, 0.9) AS r" ); + $this->assertSame( '2', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_INSTR('abc', 'a', 1, 1, '0.9') AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpInstrNumericSubject() { + $this->assertQuery( "SELECT REGEXP_INSTR(123, '2') AS r" ); + $this->assertSame( '2', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpInstrValidatesUtf8BeforePos() { + $this->assertQueryError( + "SELECT REGEXP_INSTR(CAST(X'FF61' AS CHAR), 'a', 2)", + 'Invalid UTF-8 data in regular expression input.' + ); + } + + public function testRegexpInstrRejectsUnsupportedArgumentCount() { + $this->assertQueryError( + "SELECT REGEXP_INSTR('abc', 'a', 1, 1, 0, 'c', 'extra')", + 'SQLSTATE[HY000]: General error: 1 wrong number of arguments to function REGEXP_INSTR()' + ); + } + public function testInsertDateNow() { $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('first', now());" From 36d6b56398f5a70d9e65cad56e8d25c4f250064d 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 5/5] Pin down REGEXP_* edge cases shared across functions Adds a final layer of tests that exercise behaviors which involve more than one of REGEXP_LIKE / _REPLACE / _SUBSTR / _INSTR at once and only become testable once all four functions are available: - Empty pattern raises "Illegal argument to a regular expression." uniformly (MySQL ERROR 3685). - Empty subject with a zero-width-matching pattern still produces a match (LIKE = 1, SUBSTR = "", INSTR = 1). - Zero-width anchors ^ / $ report sensible 1-based positions and an empty-string match for SUBSTR rather than NULL. - Astral-plane (4-byte UTF-8) characters are counted as one code point by both SUBSTR and INSTR. - Negative pos rejects consistently across REPLACE / SUBSTR / INSTR. --- .../tests/WP_SQLite_Driver_Tests.php | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 73d33d0f2..adb7e5379 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -104,6 +104,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->engine->get_query_results() + ); + + $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->engine->get_query_results() + ); + } + /** * @dataProvider regexpLikeCases */ @@ -847,6 +904,73 @@ public function testRegexpInstrRejectsUnsupportedArgumentCount() { ); } + 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->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_SUBSTR('', 'a*') AS r" ); + $this->assertSame( '', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_INSTR('', 'a*') AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpZeroWidthAnchors() { + // ^ matches at position 1 (length 0). + $this->assertQuery( "SELECT REGEXP_INSTR('abc', '^') AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + // $ matches one past the last character. + $this->assertQuery( "SELECT REGEXP_INSTR('abc', '\$') AS r" ); + $this->assertSame( '4', $this->engine->get_query_results()[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->engine->get_query_results()[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->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT REGEXP_INSTR('😀z', 'z', 1, 1, 1) AS r" ); + $this->assertSame( '3', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpNegativePosErrors() { + // REGEXP_LIKE has no pos argument. The other three reject negative pos. + $this->assertQueryError( + "SELECT REGEXP_REPLACE('abc', 'a', 'X', -1)", + 'Index out of bounds in regular expression search.' + ); + $this->assertQueryError( + "SELECT REGEXP_SUBSTR('abc', 'a', -1)", + 'Index out of bounds in regular expression search.' + ); + $this->assertQueryError( + "SELECT REGEXP_INSTR('abc', 'a', -1)", + 'Index out of bounds in regular expression search.' + ); + } + public function testInsertDateNow() { $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('first', now());"