Skip to content

Add MySQL REGEXP_LIKE(), REGEXP_REPLACE(), REGEXP_SUBSTR(), REGEXP_INSTR() UDFs#368

Draft
JanJakes wants to merge 5 commits into
trunkfrom
regex
Draft

Add MySQL REGEXP_LIKE(), REGEXP_REPLACE(), REGEXP_SUBSTR(), REGEXP_INSTR() UDFs#368
JanJakes wants to merge 5 commits into
trunkfrom
regex

Conversation

@JanJakes

Copy link
Copy Markdown
Member

Summary

Implements the four MySQL 8.0 regular-expression functions on top of SQLite by registering PHP UDFs that translate MySQL/ICU semantics onto PCRE.

  1. REGEXP_LIKE(expr, pattern [, match_type]) — boolean matching. Introduces a shared regexp_compile() that maps MySQL match-type flags (c/i/m/n/u) onto PCRE modifiers with UTF-8 mode always on, plus regexp_run() / regexp_fail() that distinguish compile errors from backtrack-limit and invalid-UTF-8 failures.
  2. REGEXP_REPLACE(expr, pattern, replacement [, pos [, occurrence [, match_type]]]) — uses a walker that calls preg_match with its offset argument so lookbehind assertions retain context across pos. Implements MySQL/ICU replacement grammar: $N backreferences (with $0 = full match and longest-valid-prefix wins), \X drops the backslash, ${N} is rejected, trailing \ is dropped. occurrence = 0 replaces all; negatives clamp to 1.
  3. REGEXP_SUBSTR(expr, pattern [, pos [, occurrence [, match_type]]]) — returns the Nth match from pos. Accepts pos = char_count + 1 (returns NULL); clamps occurrence <= 0 to 1.
  4. REGEXP_INSTR(expr, pattern [, pos [, occurrence [, return_option [, match_type]]]]) — returns a 1-based character position. return_option strictly 0 or 1; rejects pos > char_count even where SUBSTR/REPLACE allow it.

MySQL fidelity

Behavior was verified against MySQL 8.0.45 in Docker across ~100 SQL cases. Error texts mirror MySQL's ICU wording where practical:

  • Illegal argument to a regular expression. (empty pattern, ERROR 3685)
  • A capture group has an invalid name. (invalid `$` in replacement, ERROR 3887)
  • Index out of bounds in regular expression search. (ERROR 3686)
  • Incorrect arguments to regexp_instr: return_option must be 1 or 0.

Known limitations

Documented in `regexp_compile()`:

  • The UDF has no access to the session collation, so case-sensitivity defaults to insensitive (correct for `utf8mb4_0900_ai_ci`, wrong for `_bin`/`_cs`; callers on those collations should pass an explicit `c` match_type).
  • The `/u` PCRE modifier is always applied. Binary data with invalid UTF-8 that would match under the legacy `REGEXP` operator raises `Invalid UTF-8 data in regular expression input.` when routed through the new UDFs.

Test plan

  • `cd packages/mysql-on-sqlite && composer run test` passes (756 tests on my machine)
  • `composer run check-cs` clean on both changed files
  • Review: four new public methods at `packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php` and seven new private helpers
  • Spot-check a few queries against MySQL 8 for any corner cases you care about

Addresses #47.

@JanJakes JanJakes force-pushed the regex branch 2 times, most recently from ea33064 to 39a70af Compare July 10, 2026 10:42
JanJakes added 4 commits July 10, 2026 13:27
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.
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.
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.
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant