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/7] 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/7] 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/7] 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/7] 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/7] 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());" From 7a7f948d7a7f5a09bdfad58778c1fab421538df4 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 6/7] Align REGEXP operators with REGEXP functions Use the shared regex compiler, coercion, and error handling for REGEXP and RLIKE while preserving byte-oriented REGEXP BINARY behavior. Preserve numeric literal text for operator operands and cover NULL, invalid patterns, delimiters, numeric coercion, and UTF-8 handling. --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 49 +++++++++++---- ...s-wp-sqlite-pdo-user-defined-functions.php | 60 ++++++++++++------- .../tests/WP_SQLite_Driver_Tests.php | 41 +++++++++++++ 3 files changed, 116 insertions(+), 34 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 3d575dcc6..ae8397b46 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 @@ -3837,12 +3837,17 @@ private function translate( $node ): ?string { return $this->translate_sequence( $node->get_children() ); 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': @@ -4478,13 +4483,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_SQLite_Driver_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_regexp_string_arg( $node->get_first_child_node() ); + $pattern = $this->translate_regexp_string_arg( $operations->get_first_child_node() ); /* * If the query says REGEXP BINARY, the comparison is byte-by-byte @@ -4500,9 +4508,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"; } /** @@ -4699,17 +4709,29 @@ private function translate_regexp_function_args( string $name, array $arg_nodes $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[] = $this->translate_regexp_string_arg( $arg_node ); + } else { + $args[] = $this->translate( $arg_node ); } - $args[] = null === $numeric_literal - ? $this->translate( $arg_node ) - : $this->quote_sqlite_value( $numeric_literal ); } return $args; } + /** + * Translate a REGEXP string-domain argument while preserving numeric literal text. + * + * @param WP_Parser_Node $node Function argument or operator operand. + * + * @return string Translated argument. + */ + private function translate_regexp_string_arg( WP_Parser_Node $node ): string { + $numeric_literal = $this->get_regexp_numeric_literal_string( $node ); + return null === $numeric_literal + ? $this->translate( $node ) + : $this->quote_sqlite_value( $numeric_literal ); + } + /** * Read an exact decimal or floating-point literal from an expression node. * @@ -4725,6 +4747,9 @@ private function get_regexp_numeric_literal_string( WP_Parser_Node $node ): ?str if ( '(' === $value || ')' === $value ) { continue; } + if ( WP_MySQL_Lexer::BINARY_SYMBOL === $token->id ) { + continue; + } if ( ( '+' === $value || '-' === $value ) && null === $number && '' === $sign ) { $sign = '-' === $value ? '-' : ''; continue; 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 bdbb1a9dd..bafc3c45e 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 @@ -123,6 +123,9 @@ public static function register_for( $pdo ): self { /** @var string|null Match type used by the cached regex. */ private $regexp_cached_match_type = null; + /** @var bool|null Whether the cached regex uses byte-oriented matching. */ + private $regexp_cached_binary = null; + /** @var string|null Last compiled PCRE pattern. */ private $regexp_cached_compiled = null; @@ -622,12 +625,20 @@ public function _if( $expression, $truthy, $falsy ) { /** * 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 = $this->regexp_string_arg( $pattern ); + $field = $this->regexp_string_arg( $field ); + /* * If the original query says REGEXP BINARY * the comparison is byte-by-byte and letter casing now @@ -642,17 +653,21 @@ 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 ); + $compiled = $this->regexp_compile( $pattern, $is_binary ? 'c' : '', $is_binary ); + $result = $this->regexp_run( + function () use ( $compiled, $field ) { + return preg_match( $compiled, $field ); + } + ); + if ( false === $result ) { + $this->regexp_fail( $pattern ); + } + return $result; } /** @@ -1271,9 +1286,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: @@ -1290,20 +1305,19 @@ 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 and + * nonbinary REGEXP operators. REGEXP BINARY remains byte-oriented. * - * @param string|null $pattern The MySQL regex pattern. - * @param string $match_type MySQL match_type flag string. + * @param string|null $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|null PCRE-ready pattern with delimiter and modifiers, or * NULL when the pattern is NULL. */ - private function regexp_compile( $pattern, $match_type ) { + private function regexp_compile( $pattern, $match_type, $binary_mode = false ) { $match_type = $this->regexp_string_arg( $match_type ); if ( null !== $pattern ) { $pattern = $this->regexp_string_arg( $pattern ); @@ -1315,6 +1329,7 @@ private function regexp_compile( $pattern, $match_type ) { null !== $pattern && $pattern === $this->regexp_cached_pattern && $match_type === $this->regexp_cached_match_type + && $binary_mode === $this->regexp_cached_binary ) { return $this->regexp_cached_compiled; } @@ -1341,7 +1356,7 @@ private function regexp_compile( $pattern, $match_type ) { } } - $modifiers = 'u'; + $modifiers = $binary_mode ? '' : 'u'; if ( ! $case_sensitive ) { $modifiers .= 'i'; } @@ -1367,6 +1382,7 @@ function () use ( $compiled ) { } $this->regexp_cached_pattern = $pattern; $this->regexp_cached_match_type = $match_type; + $this->regexp_cached_binary = $binary_mode; $this->regexp_cached_compiled = $compiled; return $compiled; } 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 adb7e5379..c3a5b04e0 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -287,6 +287,47 @@ public function testRegexpLegacyOperatorRegression() { $this->assertSame( '0', $this->engine->get_query_results()[0]->r ); } + public function testRegexpLegacyOperatorNullPropagation() { + $this->assertQuery( "SELECT NULL REGEXP 'a' AS r" ); + $this->assertNull( $this->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT 'a' RLIKE NULL AS r" ); + $this->assertNull( $this->engine->get_query_results()[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->engine->get_query_results()[0]->r ); + $this->assertQuery( "SELECT '/' REGEXP '\\\\Q/\\\\E' AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpLegacyOperatorNumericOperands() { + $this->assertQuery( "SELECT 1.2300 REGEXP '00\$' AS r" ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + $this->assertQuery( 'SELECT 1.23 NOT REGEXP 1.2300 AS r' ); + $this->assertSame( '1', $this->engine->get_query_results()[0]->r ); + } + + public function testRegexpLegacyOperatorUtf8Handling() { + $this->assertQueryError( + "SELECT CAST(X'FF' AS CHAR) REGEXP 'a'", + 'Invalid UTF-8 data in regular expression input.' + ); + $this->assertQuery( "SELECT CAST(X'FF' AS CHAR) REGEXP BINARY CAST(X'FF' AS CHAR) AS r" ); + $this->assertSame( '1', $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 ); From b35d758084bfb0c596ed700a38274b6348df04d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Tue, 28 Jul 2026 21:43:48 +0200 Subject: [PATCH 7/7] Enforce existing UDF argument counts Register explicit supported arities for every UDF, validate the minimum count for variadic functions, and allow WEEK() without an explicit mode. Shadow incompatible SQLite IF() and UNHEX() overloads so unsupported MySQL calls cannot fall through to built-in behavior. --- ...s-wp-sqlite-pdo-user-defined-functions.php | 126 ++++++++++++++++-- .../tests/WP_SQLite_Driver_Tests.php | 107 +++++++++++++++ 2 files changed, 219 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 bafc3c45e..84c8e0065 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 @@ -30,14 +30,30 @@ class WP_SQLite_PDO_User_Defined_Functions { */ public static function register_for( $pdo ): self { $instance = new self(); + $register = static function ( $function_name, $callback, $arity ) use ( $pdo ) { + if ( $pdo instanceof PDO\SQLite ) { + $pdo->createFunction( $function_name, $callback, $arity ); + } else { + $pdo->sqliteCreateFunction( $function_name, $callback, $arity ); + } + }; + foreach ( $instance->functions as $f => $t ) { - $arities = $instance->function_arities[ $f ] ?? array( -1 ); + foreach ( $instance->function_arities[ $f ] as $arity ) { + $register( $f, array( $instance, $t ), $arity ); + } + } + foreach ( $instance->unsupported_function_arities as $f => $arities ) { + $reject = static function () use ( $f ) { + throw new Exception( + sprintf( + "Incorrect parameter count in the call to native function '%s'", + strtoupper( $f ) + ) + ); + }; foreach ( $arities as $arity ) { - if ( $pdo instanceof PDO\SQLite ) { - $pdo->createFunction( $f, array( $instance, $t ), $arity ); - } else { - $pdo->sqliteCreateFunction( $f, array( $instance, $t ), $arity ); - } + $register( $f, $reject, $arity ); } } return $instance; @@ -104,17 +120,75 @@ public static function register_for( $pdo ): self { ); /** - * Exact argument counts for functions with optional arguments. + * Supported argument counts for each registered function. * - * Functions absent from this array are registered as variadic. + * An argument count of -1 registers a variadic function. * * @var array */ private $function_arities = array( - '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 ), + 'throw' => array( 1 ), + 'month' => array( 1 ), + 'monthnum' => array( 1 ), + 'year' => array( 1 ), + 'day' => array( 1 ), + 'hour' => array( 1 ), + 'minute' => array( 1 ), + 'second' => array( 1 ), + 'week' => array( 1, 2 ), + 'weekday' => array( 1 ), + 'dayofweek' => array( 1 ), + 'dayofmonth' => array( 1 ), + 'unix_timestamp' => array( 0, 1 ), + 'now' => array( 0, 1 ), + 'md5' => array( 1 ), + 'curdate' => array( 0 ), + 'rand' => array( 1 ), + 'from_unixtime' => array( 1, 2 ), + 'localtime' => array( 0, 1 ), + 'localtimestamp' => array( 0, 1 ), + 'isnull' => array( 1 ), + 'if' => array( 3 ), + 'regexp' => array( 2 ), + '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 ), + 'field' => array( -1 ), + 'log' => array( 1, 2 ), + 'least' => array( -1 ), + 'greatest' => array( -1 ), + 'get_lock' => array( 2 ), + 'release_lock' => array( 1 ), + 'ucase' => array( 1 ), + 'lcase' => array( 1 ), + 'unhex' => array( 1 ), + 'from_base64' => array( 1 ), + 'to_base64' => array( 1 ), + 'inet_ntoa' => array( 1 ), + 'inet_aton' => array( 1 ), + 'datediff' => array( 2 ), + 'locate' => array( 2, 3 ), + 'utc_date' => array( 0 ), + 'utc_time' => array( 0, 1 ), + 'utc_timestamp' => array( 0, 1 ), + 'version' => array( 0 ), + 'reverse' => array( 1 ), + '_helper_like_to_glob_pattern' => array( 1 ), + ); + + /** + * Variadic registrations that shadow incompatible SQLite built-in overloads. + * + * SQLite has variadic IF() and two-argument UNHEX() implementations. These + * catch-all registrations prevent unsupported MySQL calls from falling + * through to those built-ins. + * + * @var array + */ + private $unsupported_function_arities = array( + 'if' => array( -1 ), + 'unhex' => array( -1 ), ); /** @var string|null Last validated regex pattern. */ @@ -510,7 +584,7 @@ public function hour( $time ) { * @param string $field Representing the date. * @param int $mode The mode argument. */ - public function week( $field, $mode ) { + public function week( $field, $mode = 1 ) { /* * From https://www.php.net/manual/en/datetime.format.php: * @@ -917,7 +991,8 @@ public function regexp_instr( $expr, $pattern, $pos = 1, $occurrence = 1, $retur */ public function field() { $num_args = func_num_args(); - if ( $num_args < 2 || is_null( func_get_arg( 0 ) ) ) { + $this->require_minimum_argument_count( 'FIELD', $num_args, 2 ); + if ( is_null( func_get_arg( 0 ) ) ) { return 0; } $arg_list = func_get_args(); @@ -974,6 +1049,7 @@ public function log() { * @return mixed */ public function least() { + $this->require_minimum_argument_count( 'LEAST', func_num_args(), 2 ); $arg_list = func_get_args(); return min( $arg_list ); @@ -987,6 +1063,7 @@ public function least() { * @return mixed */ public function greatest() { + $this->require_minimum_argument_count( 'GREATEST', func_num_args(), 2 ); $arg_list = func_get_args(); return max( $arg_list ); @@ -1283,6 +1360,27 @@ public function _helper_like_to_glob_pattern( $pattern ) { return $pattern; } + /** + * Reject a variadic function call with too few arguments. + * + * @param string $function_name Function name used in the error message. + * @param int $actual Actual argument count. + * @param int $minimum Minimum supported argument count. + * + * @throws Exception If the argument count is too low. + * @return void + */ + private function require_minimum_argument_count( $function_name, $actual, $minimum ): void { + if ( $actual < $minimum ) { + throw new Exception( + sprintf( + "Incorrect parameter count in the call to native function '%s'", + $function_name + ) + ); + } + } + /** * Compile a MySQL-style regex into a PCRE pattern string. * 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 c3a5b04e0..1d2f3dddf 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -1012,6 +1012,113 @@ public function testRegexpNegativePosErrors() { ); } + /** + * @dataProvider existingUserDefinedFunctionUnsupportedArgumentCounts + */ + public function testExistingUserDefinedFunctionsRejectUnsupportedArgumentCounts( $function_name, $argument_count ) { + $args = implode( ', ', array_fill( 0, $argument_count, 'NULL' ) ); + $exception = null; + try { + $this->sqlite->query( "SELECT `$function_name`($args)" ); + } catch ( PDOException $e ) { + $exception = $e; + } + + $this->assertNotNull( $exception, 'An exception was expected, but none was thrown.' ); + $this->assertStringContainsString( + "wrong number of arguments to function $function_name()", + $exception->getMessage() + ); + } + + public static function existingUserDefinedFunctionUnsupportedArgumentCounts() { + return array( + 'throw' => array( 'throw', 2 ), + 'month' => array( 'month', 2 ), + 'monthnum' => array( 'monthnum', 2 ), + 'year' => array( 'year', 2 ), + 'day' => array( 'day', 2 ), + 'hour' => array( 'hour', 2 ), + 'minute' => array( 'minute', 2 ), + 'second' => array( 'second', 2 ), + 'week' => array( 'week', 3 ), + 'weekday' => array( 'weekday', 2 ), + 'dayofweek' => array( 'dayofweek', 2 ), + 'dayofmonth' => array( 'dayofmonth', 2 ), + 'unix_timestamp' => array( 'unix_timestamp', 2 ), + 'now' => array( 'now', 2 ), + 'md5' => array( 'md5', 2 ), + 'curdate' => array( 'curdate', 1 ), + 'rand' => array( 'rand', 2 ), + 'from_unixtime' => array( 'from_unixtime', 3 ), + 'localtime' => array( 'localtime', 2 ), + 'localtimestamp' => array( 'localtimestamp', 2 ), + 'isnull' => array( 'isnull', 2 ), + 'regexp' => array( 'regexp', 3 ), + 'log' => array( 'log', 3 ), + 'get_lock' => array( 'get_lock', 3 ), + 'release_lock' => array( 'release_lock', 2 ), + 'ucase' => array( 'ucase', 2 ), + 'lcase' => array( 'lcase', 2 ), + 'from_base64' => array( 'from_base64', 2 ), + 'to_base64' => array( 'to_base64', 2 ), + 'inet_ntoa' => array( 'inet_ntoa', 2 ), + 'inet_aton' => array( 'inet_aton', 2 ), + 'datediff' => array( 'datediff', 3 ), + 'locate' => array( 'locate', 4 ), + 'utc_date' => array( 'utc_date', 1 ), + 'utc_time' => array( 'utc_time', 2 ), + 'utc_timestamp' => array( 'utc_timestamp', 2 ), + 'version' => array( 'version', 1 ), + 'reverse' => array( 'reverse', 2 ), + '_helper_like_to_glob_pattern' => array( '_helper_like_to_glob_pattern', 2 ), + ); + } + + public function testSqliteBuiltInOverloadsDoNotBypassArgumentValidation() { + $this->assertQueryError( + "SELECT UNHEX('41', 'ignored')", + "Incorrect parameter count in the call to native function 'UNHEX'" + ); + + $exception = null; + try { + $this->sqlite->query( 'SELECT `if`(1, 2, 3, 4)' ); + } catch ( Exception $e ) { + $exception = $e; + } + $this->assertNotNull( $exception, 'An exception was expected, but none was thrown.' ); + $this->assertSame( + "Incorrect parameter count in the call to native function 'IF'", + $exception->getMessage() + ); + } + + public function testWeekSupportsOptionalModeArgument() { + $this->assertQuery( "SELECT WEEK('2000-05-27') AS implicit_mode, WEEK('2000-05-27', 1) AS explicit_mode" ); + $result = $this->engine->get_query_results()[0]; + $this->assertSame( '21', $result->implicit_mode ); + $this->assertSame( '21', $result->explicit_mode ); + } + + /** + * @dataProvider variadicUserDefinedFunctions + */ + public function testVariadicUserDefinedFunctionsRequireTwoArguments( $function_name ) { + $this->assertQueryError( + "SELECT $function_name('value')", + "Incorrect parameter count in the call to native function '$function_name'" + ); + } + + public static function variadicUserDefinedFunctions() { + return array( + 'field' => array( 'FIELD' ), + 'least' => array( 'LEAST' ), + 'greatest' => array( 'GREATEST' ), + ); + } + public function testInsertDateNow() { $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('first', now());"