From 268c32bb588162dfa5553f13304bf9b0418f09d3 Mon Sep 17 00:00:00 2001 From: AmirHossein Roozbahani Date: Sun, 26 Jul 2026 17:42:02 +0200 Subject: [PATCH 1/3] chore(backlog): TASK-47 Done; TASK-48 wave 2 (LIKE + alias + comma-join) created In Progress Co-Authored-By: Claude Fable 5 --- ...workload-builtins-and-predicates-wave-1.md | 4 +-- ...QL-support-LIKE-alias-comma-join-wave-2.md | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 backlog/tasks/task-48 - Specializer-SQL-support-LIKE-alias-comma-join-wave-2.md diff --git a/backlog/tasks/task-47 - Specializer-SQL-support-workload-builtins-and-predicates-wave-1.md b/backlog/tasks/task-47 - Specializer-SQL-support-workload-builtins-and-predicates-wave-1.md index bfeb34e..59df9c3 100644 --- a/backlog/tasks/task-47 - Specializer-SQL-support-workload-builtins-and-predicates-wave-1.md +++ b/backlog/tasks/task-47 - Specializer-SQL-support-workload-builtins-and-predicates-wave-1.md @@ -1,10 +1,10 @@ --- id: TASK-47 title: 'Specializer SQL support: workload builtins & predicates wave 1' -status: In Progress +status: Done assignee: [] created_date: '2026-07-26 11:42' -updated_date: '2026-07-26 12:20' +updated_date: '2026-07-26 15:05' labels: [] milestone: m-7 dependencies: diff --git a/backlog/tasks/task-48 - Specializer-SQL-support-LIKE-alias-comma-join-wave-2.md b/backlog/tasks/task-48 - Specializer-SQL-support-LIKE-alias-comma-join-wave-2.md new file mode 100644 index 0000000..3ff6484 --- /dev/null +++ b/backlog/tasks/task-48 - Specializer-SQL-support-LIKE-alias-comma-join-wave-2.md @@ -0,0 +1,32 @@ +--- +id: TASK-48 +title: 'Specializer SQL support: LIKE, dynamic-table alias, comma-join rewrite (wave 2)' +status: In Progress +assignee: [] +created_date: '2026-07-26 15:05' +updated_date: '2026-07-26 15:05' +labels: [] +milestone: m-7 +dependencies: + - TASK-47 +documentation: + - docs/superpowers/specs/2026-07-25-sql-specializer-design.md + - docs/superpowers/specs/2026-07-26-wave1-builtin-pins.md +type: feature +ordinal: 42000 +--- + +## Description + + +The dual-axis rung plus two structural cheap wins, ~120 corpus first-blockers in reach. (1) LIKE / NOT LIKE with % and _ wildcards and the ESCAPE clause — DuckDB semantics pinned FIRST (case sensitivity, codepoint vs byte matching for _, escape edge cases, NULL propagation, degenerate patterns); a compiled-pattern op on both backends via one shared matcher; SIMILAR TO / regexp reject by name unless the pins show LIKE-only covers the corpus head. Closes the workload ladder's last gap (title extraction, device normalization patterns). (2) Alias on the dynamic table — scope plumbing in the frontend binder (30 cases). (3) Comma-join rewrite: FROM t, dim WHERE rewrites to the INNER equi-join the engine already serves, with non-equi/cross shapes rejecting cleanly by name (up to 50 join-form cases share this first blocker). + + +## Acceptance Criteria + +- [ ] #1 LIKE semantics pinned by measurement (duck_check tests recorded before implementation: wildcards, ESCAPE, unicode, NULL, degenerate patterns) and both backends agree via a shared matcher (differential extended) +- [ ] #2 Dynamic-table alias binds (qualified refs via alias, original name behavior measured and mirrored) +- [ ] #3 Comma-join with equi-WHERE rewrites to the served INNER join; non-equi shapes reject by name +- [ ] #4 Corpus replay: wave-2 first-blocker cases flip to match or a named deeper blocker; zero FAILs; tally recorded here +- [ ] #5 mise gate-specializer green + From 3387a6212557acf294768887723b66bb41453078 Mon Sep 17 00:00:00 2001 From: AmirHossein Roozbahani Date: Sun, 26 Jul 2026 17:45:40 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(specializer):=20dynamic-table=20alias?= =?UTF-8?q?=20=E2=80=94=20the=20alias=20replaces=20the=20original=20name?= =?UTF-8?q?=20(TASK-48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured: DuckDB makes qualified refs through the ORIGINAL name binder errors once aliased; making the alias the binder's this_name reproduces that scoping for qualified binds, star expansion, and messages with a three-line change. AS/bare identical; column-renaming alias t(a,b) rejects by name. Census note in the ticket: the comma-join rewrite was DROPPED from wave scope — the corpus cases are self-joins of the dynamic table, so the planned rewrite would flip ~0 cases. Co-Authored-By: Claude Fable 5 --- ...QL-support-LIKE-alias-comma-join-wave-2.md | 8 ++++- src/specializer/frontend.rs | 13 +++++--- tests/test_duckdb_interpreter.py | 30 +++++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/backlog/tasks/task-48 - Specializer-SQL-support-LIKE-alias-comma-join-wave-2.md b/backlog/tasks/task-48 - Specializer-SQL-support-LIKE-alias-comma-join-wave-2.md index 3ff6484..e5d2cf5 100644 --- a/backlog/tasks/task-48 - Specializer-SQL-support-LIKE-alias-comma-join-wave-2.md +++ b/backlog/tasks/task-48 - Specializer-SQL-support-LIKE-alias-comma-join-wave-2.md @@ -26,7 +26,13 @@ The dual-axis rung plus two structural cheap wins, ~120 corpus first-blockers in - [ ] #1 LIKE semantics pinned by measurement (duck_check tests recorded before implementation: wildcards, ESCAPE, unicode, NULL, degenerate patterns) and both backends agree via a shared matcher (differential extended) - [ ] #2 Dynamic-table alias binds (qualified refs via alias, original name behavior measured and mirrored) -- [ ] #3 Comma-join with equi-WHERE rewrites to the served INNER join; non-equi shapes reject by name +- [x] #3 RE-SCOPED by census (recorded below): the corpus comma-join cases are SELF-joins of the dynamic table (FROM integers i1, integers i2 ...), not dynamic-x-static shapes — a rewrite would flip ~0 cases. Comma-join stays cleanly unsupported; dynamic self-join is a distinct future feature (needs row-table-as-static materialization). - [ ] #4 Corpus replay: wave-2 first-blocker cases flip to match or a named deeper blocker; zero FAILs; tally recorded here - [ ] #5 mise gate-specializer green + +## Implementation Notes + + +Census before code (2026-07-26): the 30 alias-first-blocker cases are mostly alias + a DEEPER blocker (self-joins, USING, rowid, column-renaming aliases); the ~30 comma-join cases are nearly all SELF cross-products of the dynamic table — the planned equi-WHERE rewrite would flip approximately zero, so it was dropped from scope honestly rather than shipped as dead code. Alias landed: the alias REPLACES the original name (measured: qualified refs through the original are DuckDB binder errors) — implemented by making the alias the binder's this_name, which yields exactly that scoping for qualified refs, star expansion, and error messages; AS/bare forms identical; column-renaming alias t(a,b) rejects by name. LIKE pins fleet running; matcher lands next. + diff --git a/src/specializer/frontend.rs b/src/specializer/frontend.rs index 7378f67..e16887a 100644 --- a/src/specializer/frontend.rs +++ b/src/specializer/frontend.rs @@ -204,16 +204,21 @@ fn bind_from<'a>( }; let dyn_name = match &table.relation { TableFactor::Table { name, alias, .. } => { - if alias.is_some() { - return Err(unsup("alias on the dynamic table")); - } let n = name.to_string(); if !n.eq_ignore_ascii_case(this_name) { return Err(unsup(format!( "table '{n}' as the driving relation (must be the dynamic table '{this_name}')" ))); } - n + match alias { + // Measured: an alias REPLACES the original name entirely + // (qualified refs through the original are binder errors in + // DuckDB) — making the alias the binder's this_name gives + // exactly that scoping. + Some(a) if a.columns.is_empty() => a.name.value.clone(), + Some(_) => return Err(unsup("column-renaming table alias t(a, b, ...)")), + None => n, + } } other => return Err(unsup(format!("FROM {other}"))), }; diff --git a/tests/test_duckdb_interpreter.py b/tests/test_duckdb_interpreter.py index 4679810..a721ef4 100644 --- a/tests/test_duckdb_interpreter.py +++ b/tests/test_duckdb_interpreter.py @@ -1428,3 +1428,33 @@ def test_round_family_int64_identity_and_negative_n(): {"i": "int?"}, [{"i": v} for v in vals], ) + + +# --------------------------------------------------------- TASK-48: +# dynamic-table alias — the alias REPLACES the original name (measured). + + +def test_dynamic_table_alias(): + for sql in [ + "SELECT t.a AS x, a AS y FROM __THIS__ t", + "SELECT t.a AS x FROM __THIS__ AS t", + "SELECT t.a AS x, dim.name AS nm FROM __THIS__ t JOIN dim ON t.a = dim.id", + "SELECT t.*, upper(t.s) AS u FROM __THIS__ t", + ]: + duck_check(sql, {"a": "int", "s": "str"}, [{"a": 1, "s": "x"}], {"dim": DIM}) + + +def test_alias_shadows_original_name(): + # Mirrors DuckDB's 'Referenced table not found' as our bind error. + with pytest.raises(ValueError, match="unknown table"): + DuckDBInferFn( + "SELECT __THIS__.a FROM __THIS__ t", + row_tables={"__THIS__": _row_model({"a": "int"})}, + static_tables={}, + ) + with pytest.raises(ValueError, match="column-renaming"): + DuckDBInferFn( + "SELECT y FROM __THIS__ t(y)", + row_tables={"__THIS__": _row_model({"a": "int"})}, + static_tables={}, + ) From a1fe9f80845ff4e9773c15ce90d5c2bc90a65589 Mon Sep 17 00:00:00 2001 From: AmirHossein Roozbahani Date: Sun, 26 Jul 2026 18:23:43 +0200 Subject: [PATCH 3/3] feat(specializer): LIKE / NOT LIKE / ILIKE with full ESCAPE semantics (TASK-48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-based matcher with codepoint _, leftmost-first two-pointer backtracking: identical booleans AND identical error rows to DuckDB — including the data-dependent dangling-escape (plain false when the string is exhausted, per-row SyntaxException only when the matcher examines it with bytes remaining) — at O(n*m) where DuckDB's own column-pattern path is naive-recursive (measured 23s on one pathological row). ESCAPE: any single byte, doubled = literal, the escape char never matches itself unescaped, '' = none, NULL = NULL, multi-byte operand traps with DuckDB's message. ILIKE folds both sides with the measured simple casemap (== DuckDB lower(), exhaustive sweep). The corpus replay caught a genuine oracle divergence: ILIKE on NUL-containing rows is COLUMN-STATISTICS-dependent in DuckDB (sibling rows change the result) — irreproducible row-locally, now the repo's first documented divergence with the measurement attached. SIMILAR TO rejects by name (binds to regexp_full_match in DuckDB). Corpus: 240 -> 265 of 678, zero FAILs; gate green. Co-Authored-By: Claude Fable 5 --- ...QL-support-LIKE-alias-comma-join-wave-2.md | 16 +- .../specs/2026-07-26-wave1-builtin-pins.md | 21 + .../specs/pins-wave1/pins_like.json | 425 ++++++++++++++++++ src/specializer/exec/cranelift.rs | 63 +++ src/specializer/exec/interp.rs | 128 ++++++ src/specializer/fold.rs | 11 + src/specializer/frontend.rs | 66 ++- src/specializer/ir/gen.rs | 14 + src/specializer/ir/mod.rs | 20 + src/specializer/ir/parse.rs | 19 + src/specializer/ir/print.rs | 7 + src/specializer/ir/verify.rs | 8 + src/specializer/lower.rs | 43 ++ src/specializer/plan.rs | 7 + tests/test_corpus_replay.py | 15 + tests/test_duckdb_interpreter.py | 242 ++++++++++ 16 files changed, 1099 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/specs/pins-wave1/pins_like.json diff --git a/backlog/tasks/task-48 - Specializer-SQL-support-LIKE-alias-comma-join-wave-2.md b/backlog/tasks/task-48 - Specializer-SQL-support-LIKE-alias-comma-join-wave-2.md index e5d2cf5..262931c 100644 --- a/backlog/tasks/task-48 - Specializer-SQL-support-LIKE-alias-comma-join-wave-2.md +++ b/backlog/tasks/task-48 - Specializer-SQL-support-LIKE-alias-comma-join-wave-2.md @@ -24,15 +24,21 @@ The dual-axis rung plus two structural cheap wins, ~120 corpus first-blockers in ## Acceptance Criteria -- [ ] #1 LIKE semantics pinned by measurement (duck_check tests recorded before implementation: wildcards, ESCAPE, unicode, NULL, degenerate patterns) and both backends agree via a shared matcher (differential extended) -- [ ] #2 Dynamic-table alias binds (qualified refs via alias, original name behavior measured and mirrored) +- [x] #1 LIKE semantics pinned by measurement (duck_check tests recorded before implementation: wildcards, ESCAPE, unicode, NULL, degenerate patterns) and both backends agree via a shared matcher (differential extended) +- [x] #2 Dynamic-table alias binds (qualified refs via alias, original name behavior measured and mirrored) - [x] #3 RE-SCOPED by census (recorded below): the corpus comma-join cases are SELF-joins of the dynamic table (FROM integers i1, integers i2 ...), not dynamic-x-static shapes — a rewrite would flip ~0 cases. Comma-join stays cleanly unsupported; dynamic self-join is a distinct future feature (needs row-table-as-static materialization). -- [ ] #4 Corpus replay: wave-2 first-blocker cases flip to match or a named deeper blocker; zero FAILs; tally recorded here -- [ ] #5 mise gate-specializer green +- [x] #4 Corpus replay: wave-2 first-blocker cases flip to match or a named deeper blocker; zero FAILs; tally recorded here +- [x] #5 mise gate-specializer green ## Implementation Notes -Census before code (2026-07-26): the 30 alias-first-blocker cases are mostly alias + a DEEPER blocker (self-joins, USING, rowid, column-renaming aliases); the ~30 comma-join cases are nearly all SELF cross-products of the dynamic table — the planned equi-WHERE rewrite would flip approximately zero, so it was dropped from scope honestly rather than shipped as dead code. Alias landed: the alias REPLACES the original name (measured: qualified refs through the original are DuckDB binder errors) — implemented by making the alias the binder's this_name, which yields exactly that scoping for qualified refs, star expansion, and error messages; AS/bare forms identical; column-renaming alias t(a,b) rejects by name. LIKE pins fleet running; matcher lands next. +Census before code (2026-07-26): the 30 alias-first-blocker cases are mostly alias + a DEEPER blocker (self-joins, USING, rowid, column-renaming aliases); the ~30 comma-join cases are nearly all SELF cross-products of the dynamic table — the planned equi-WHERE rewrite would flip approximately zero, so it was dropped from scope honestly rather than shipped as dead code. Alias landed: the alias REPLACES the original name (measured: qualified refs through the original are DuckDB binder errors) — implemented by making the alias the binder's this_name, which yields exactly that scoping for qualified refs, star expansion, and error messages; AS/bare forms identical; column-renaming alias t(a,b) rejects by name. LIKE landed: byte-based O(n*m) two-pointer matcher with codepoint _, leftmost-first backtracking reproducing the DATA-DEPENDENT dangling-escape error rows, full ESCAPE semantics (any single byte, doubled = literal, never self-matching, '' = none, NULL = NULL), NOT LIKE, and ILIKE via double simple-casemap fold (exhaustively verified vs DuckDB lower()). The corpus caught a live divergence during replay: DuckDB's ILIKE result for NUL-containing rows depends on SIBLING rows (stats-selected kernels; the generic one NUL-truncates) — irreproducible row-locally, recorded as the repo's first documented oracle divergence (_KNOWN_DIVERGENT_SOURCES) with the measurement in the pins. SIMILAR TO rejects by name (DuckDB binds it to regexp_full_match). CORPUS: 240 -> 265 match of 678, zero FAILs, gate green. + +## Final Summary + + +Wave 2 complete: LIKE/NOT LIKE/ILIKE with full ESCAPE semantics on both backends (pins-first; the matcher reproduces even DuckDB's data-dependent error rows while avoiding its measured O(n^k) blowup), dynamic-table alias (alias replaces the original name), comma-join honestly descoped by census (corpus cases are self-joins). Corpus 240 -> 265/678, zero FAILs; first documented oracle divergence recorded with proof of row-local irreproducibility (stats-dependent ILIKE kernels). + diff --git a/docs/superpowers/specs/2026-07-26-wave1-builtin-pins.md b/docs/superpowers/specs/2026-07-26-wave1-builtin-pins.md index 814c58b..a19f132 100644 --- a/docs/superpowers/specs/2026-07-26-wave1-builtin-pins.md +++ b/docs/superpowers/specs/2026-07-26-wave1-builtin-pins.md @@ -112,3 +112,24 @@ differs. DOUBLE column path, as with substr). - Repo doc drift found during verification: interp.rs header still says fcmp is IEEE-with-NaN-false; it is duck_fcmp. Fix in this wave. + +## Wave-2 addendum: LIKE (pins-wave1/pins_like.json) + +Byte-based matcher, codepoint `_`, no normalization; backslash is a LITERAL +(no implicit escape — unlike Postgres); ESCAPE takes any single BYTE (a +2-byte codepoint errors), doubled = literal, the escape char never matches +itself unescaped, ESCAPE '' = none, ESCAPE NULL = NULL. The dangling-escape +error is DATA-DEPENDENT per row (string exhausted -> plain false; bytes +remaining when the matcher examines it -> SyntaxException) — reproduced by +leftmost-first backtracking. ILIKE = fold both sides with the measured +simple casemap then LIKE (exhaustive all-codepoint sweep, zero exceptions); +DuckDB's oracle-side caveats: (1) a stats-dependent ASCII kernel whose only +divergence surface is U+212A/U+0130 in patterns, and (2) the generic +kernel's fold NUL-truncates inconsistently, making a NUL-containing row's +result depend on SIBLING rows — statistics-dependent semantics are +irreproducible row-locally, so the engine is NUL-transparent and the one +corpus case is a documented divergence (test_corpus_replay +_KNOWN_DIVERGENT_SOURCES). SIMILAR TO rejects by name: DuckDB binds it to +regexp_full_match (RE2), not SQL wildcards. DuckDB's own column-pattern +LIKE is naive-recursive (measured 23s on one pathological row); ours is the +O(n*m) two-pointer restart with identical booleans and error rows. diff --git a/docs/superpowers/specs/pins-wave1/pins_like.json b/docs/superpowers/specs/pins-wave1/pins_like.json new file mode 100644 index 0000000..234cb23 --- /dev/null +++ b/docs/superpowers/specs/pins-wave1/pins_like.json @@ -0,0 +1,425 @@ +{ + "pins": [ + { + "expr": "s LIKE '%' / '%%' / '%%%'", + "input_desc": "s='abc' from VARCHAR column", + "result": "true", + "note": "runs of % collapse; any all-% pattern matches any non-NULL string" + }, + { + "expr": "s LIKE '%' and s LIKE '%%'", + "input_desc": "s='' (empty string)", + "result": "true", + "note": "% matches empty" + }, + { + "expr": "s LIKE 'a%' / '%c' / '%b%'", + "input_desc": "s='abc'", + "result": "true", + "note": "prefix/suffix/contains shapes" + }, + { + "expr": "s LIKE 'a%c'", + "input_desc": "s='ac'", + "result": "true", + "note": "% matches zero chars" + }, + { + "expr": "s LIKE 'a%b'", + "input_desc": "s='abc'", + "result": "false", + "note": "pattern must consume the whole string (anchored both ends)" + }, + { + "expr": "s LIKE '%abcd%'", + "input_desc": "s='abc'", + "result": "false", + "note": "needle longer than string" + }, + { + "expr": "s LIKE 'abc'", + "input_desc": "s='ab'", + "result": "false", + "note": "no-wildcard pattern = exact equality; 'abc' LIKE 'ab' also false" + }, + { + "expr": "s LIKE 'a%a'", + "input_desc": "s='aa'", + "result": "true", + "note": "s='a' gives false \u2014 the two literal a's cannot overlap" + }, + { + "expr": "s LIKE 'a%c%f'", + "input_desc": "s='abcdef'", + "result": "true", + "note": "multiple % segments; '%g%' on same s is false" + }, + { + "expr": "s LIKE ''", + "input_desc": "s=''", + "result": "true", + "note": "empty pattern matches only empty string; s='a' -> false" + }, + { + "expr": "s LIKE '_'", + "input_desc": "s=''", + "result": "false", + "note": "_ requires exactly one codepoint; s='a' true, s='ab' false" + }, + { + "expr": "s LIKE '_%' / '%_' / '%_%'", + "input_desc": "s=''", + "result": "false", + "note": "_ needs >=1 codepoint even adjacent to %; all three true for s='abc'" + }, + { + "expr": "s LIKE '_'", + "input_desc": "s='\u00e9' (e-acute NFC, 1 codepoint, 2 UTF-8 bytes)", + "result": "true", + "note": "_ consumes ONE CODEPOINT, not one byte: s LIKE '__' is false" + }, + { + "expr": "s LIKE '_'", + "input_desc": "s='e\u0301' (NFD, 2 codepoints, 3 bytes)", + "result": "false", + "note": "'__' true, '___' false: codepoints, NOT graphemes, NOT bytes" + }, + { + "expr": "s LIKE '_'", + "input_desc": "s='\\U0001F600' (1 codepoint, 4 bytes, 2 UTF-16 units)", + "result": "true", + "note": "'__' false and '____' false \u2014 rules out UTF-16 units and bytes" + }, + { + "expr": "s LIKE '_____'", + "input_desc": "s=man-ZWJ-woman-ZWJ-girl family emoji (5 codepoints, 18 bytes, 1 grapheme)", + "result": "true", + "note": "'_' false, 18x'_' false \u2014 ZWJ sequence counts 5 codepoints" + }, + { + "expr": "s LIKE 'a_c' and s LIKE '%\u00e9%'", + "input_desc": "s='a\u00e9c'", + "result": "true", + "note": "multibyte literals in pattern compare byte-wise" + }, + { + "expr": "s LIKE 'e\u0301'", + "input_desc": "s='\u00e9' (NFC)", + "result": "false", + "note": "and NFD LIKE NFC false: byte-wise, ZERO unicode normalization" + }, + { + "expr": "s LIKE 'a'", + "input_desc": "s='A'", + "result": "false", + "note": "case-sensitive both directions ('a' LIKE 'A' false)" + }, + { + "expr": "s LIKE 'a.c'", + "input_desc": "s='a.c'", + "result": "true", + "note": "s='abc' false \u2014 regex specials . [ ] ( ) * + ? ^ $ are plain literals" + }, + { + "expr": "s LIKE 'a\\bc' (backslash-b)", + "input_desc": "s='abc'", + "result": "false", + "note": "backslash is a LITERAL char, NOT an implicit escape (unlike Postgres); s='a\\bc'-with-real-backslash would match" + }, + { + "expr": "s LIKE 'a\\%b'", + "input_desc": "s='a%b'", + "result": "false", + "note": "'\\' literal + % wildcard: matches 'a\\xb' and 'a\\b' instead" + }, + { + "expr": "s LIKE '\\%'", + "input_desc": "s='\\\\' (single backslash)", + "result": "true", + "note": "s='%' false \u2014 no default escape char exists" + }, + { + "expr": "s LIKE 'a'||chr(10)||'b'", + "input_desc": "s='a\\nb'", + "result": "true", + "note": "newline is an ordinary byte in both string and pattern" + }, + { + "expr": "s LIKE 'a_b'", + "input_desc": "s='a\\nb'", + "result": "true", + "note": "_ matches newline; also matches tab; s LIKE 'a%b' true \u2014 % crosses newlines" + }, + { + "expr": "s LIKE 'a#%b' ESCAPE '#'", + "input_desc": "s='a%b'", + "result": "true", + "note": "escaped % is literal; s='axb' false" + }, + { + "expr": "s LIKE 'a#_b' ESCAPE '#'", + "input_desc": "s='a_b'", + "result": "true", + "note": "escaped _ is literal; s='axb' false" + }, + { + "expr": "s LIKE 'a##b' ESCAPE '#'", + "input_desc": "s='a#b'", + "result": "true", + "note": "doubled escape = literal escape char; 'a##' matches 'a#'" + }, + { + "expr": "s LIKE 'a#b' ESCAPE '#'", + "input_desc": "s='ab'", + "result": "true", + "note": "escaping a NON-special char is legal and yields that char (no error)" + }, + { + "expr": "s LIKE 'aeb' ESCAPE 'e'", + "input_desc": "s='aeb'", + "result": "false", + "note": "the escape char is ALWAYS an escape intro; it never matches itself unescaped ('aeb' pattern = 'a'+literal'b')" + }, + { + "expr": "s LIKE 'aeeb' ESCAPE 'e'", + "input_desc": "s='aeb'", + "result": "true", + "note": "ee = literal e" + }, + { + "expr": "s LIKE 'a%%b' ESCAPE '%'", + "input_desc": "s='a%b'", + "result": "true", + "note": "the wildcard itself can be the escape char; then no unescaped % wildcard exists" + }, + { + "expr": "s LIKE 'a%b' ESCAPE '%'", + "input_desc": "s='ab'", + "result": "true", + "note": "'%b' = escaped literal b" + }, + { + "expr": "s LIKE 'a__b' ESCAPE '_'", + "input_desc": "s='a_b'", + "result": "true", + "note": "same for _ as escape" + }, + { + "expr": "s LIKE 'a#%b' ESCAPE ''", + "input_desc": "s='a#xb'", + "result": "true", + "note": "ESCAPE '' means NO escape (# literal, % wildcard); s='a%b' false" + }, + { + "expr": "s LIKE 'a#%b' ESCAPE NULL", + "input_desc": "s='a%b'", + "result": "NULL", + "note": "NULL escape -> NULL result, no error" + }, + { + "expr": "s LIKE 'ab#' ESCAPE '#' (dangling escape)", + "input_desc": "s='ab'", + "result": "false", + "note": "DATA-DEPENDENT: string exhausted when the trailing escape is reached -> plain false; s='ab#' or 'abX' -> SyntaxException at runtime" + }, + { + "expr": "s LIKE 'a#' ESCAPE '#'", + "input_desc": "s='a' or s='' or s='ba'", + "result": "false", + "note": "s='ax' errors \u2014 error raised only when matcher examines the dangling escape with string chars remaining" + }, + { + "expr": "s LIKE 'a%#' ESCAPE '#'", + "input_desc": "s='a'", + "result": "false", + "note": "s='ab' or 'axy' errors: the % branch tries positions with chars left and hits the dangling escape" + }, + { + "expr": "s LIKE 'ab#%' ESCAPE '#'", + "input_desc": "s='ab'", + "result": "false", + "note": "#% = escaped literal %, NOT dangling; no error possible" + }, + { + "expr": "s LIKE p ESCAPE 'xy'", + "input_desc": "row (NULL, 'ab') or empty table", + "result": "NULL", + "note": "even the escape-length validation is per-row after NULL check: NULL rows and empty tables never raise" + }, + { + "expr": "s LIKE p ESCAPE e (e from a column)", + "input_desc": "row ('a%b','a#%b','#')", + "result": "true", + "note": "ESCAPE does NOT need to be constant" + }, + { + "expr": "NULL LIKE 'a' / 'a' LIKE NULL / NULL LIKE NULL / NULL LIKE '%'", + "input_desc": "NULLs via nullable column", + "result": "NULL", + "note": "NOT LIKE identical: all NULL (never true/false)" + }, + { + "expr": "s NOT LIKE 'a%'", + "input_desc": "s='abc'", + "result": "false", + "note": "plain negation; s='xbc' true; '' NOT LIKE '%' false" + }, + { + "expr": "s ILIKE 'a' / s ILIKE 'A'", + "input_desc": "s='A' / s='a'", + "result": "true", + "note": "ASCII case-insensitive both directions; wildcards work: 'ABC' ILIKE 'a%'/'_b_' true" + }, + { + "expr": "s ILIKE 'stra\u00dfe'", + "input_desc": "s='STRASSE'", + "result": "false", + "note": "both directions false: SIMPLE per-codepoint fold, never full casefold (ss never equals \u00df)" + }, + { + "expr": "s ILIKE '\u00df'", + "input_desc": "s='\u1e9e' (capital sharp s)", + "result": "true", + "note": "singleton simple map \u1e9e->\u00df; reverse direction also true" + }, + { + "expr": "s ILIKE 'k'", + "input_desc": "s='\u212a' (KELVIN SIGN) in a column", + "result": "true", + "note": "string-side fold = DuckDB lower(): maps \u212a->k, \u0130->i" + }, + { + "expr": "s ILIKE '\u212a'", + "input_desc": "s='k', s-column contains ONLY ASCII values", + "result": "false", + "note": "THE STATS PIN: with pure-ASCII column statistics DuckDB swaps in an ASCII-only ILIKE; pattern codepoints \u212a/\u0130 do not fold. Same for 'i' ILIKE '\u0130' and 'K' ILIKE '\u212a'" + }, + { + "expr": "s ILIKE '\u212a'", + "input_desc": "s='k', but a SIBLING ROW in the column contains '\u00e9'", + "result": "true", + "note": "identical row value flips with column contents (table stats); constant-folded literals also give true. Exhaustive sweep: \u0130 and \u212a are the ONLY non-ASCII codepoints with pure-ASCII lower(), so this is the entire divergence surface" + }, + { + "expr": "vectorized ILIKE vs lower(s) LIKE lower(p)", + "input_desc": "all 1112062 codepoints as 1-char strings/patterns, non-ASCII column stats", + "result": "identical (zero exceptions)", + "note": "generic ILIKE == fold both sides with DuckDB lower() then LIKE; lower() is per-codepoint and NEVER changes codepoint count (swept); upper('\u00df')='\u1e9e', no SS expansion" + }, + { + "expr": "s ILIKE '\u03c2' / '\u03c3' / '\u0131'-vs-'I'", + "input_desc": "s='\u03a3' / s='\u03c2' / s='I'", + "result": "false", + "note": "no transitive folding: lower(\u03a3)=\u03c3 != \u03c2; lower(\u03c2)=\u03c2 != \u03c3; lower('I')='i' != '\u0131'; 23 such lower-vs-upper asymmetric codepoints exist (\u00b5 \u0131 \u017f \u03c2 etc.)" + }, + { + "expr": "s ILIKE '_'", + "input_desc": "s='\u0130'", + "result": "true", + "note": "fold is length-preserving so _ counts codepoints of the folded string" + }, + { + "expr": "s ILIKE 'a#%b' ESCAPE '#'", + "input_desc": "s='A%B'", + "result": "true", + "note": "ILIKE supports ESCAPE; s='AxB' false; dangling-escape error identical to LIKE" + }, + { + "expr": "'abc' SIMILAR TO 'a%' / 'a_c'", + "input_desc": "constants and columns (identical)", + "result": "false", + "note": "DuckDB SIMILAR TO is NOT SQL-standard: %/_ are NOT wildcards" + }, + { + "expr": "'abc' SIMILAR TO 'a.c' / 'a.*' / '(a|x)bc' / 'a[bc]c'", + "input_desc": "constants and columns", + "result": "true", + "note": "raw RE2 regex, implicitly anchored full-match ('abc' SIMILAR TO 'a' false); plan shows regexp_full_match(s, pattern)" + }, + { + "expr": "like('a','a')", + "input_desc": "function-call form", + "result": "error", + "note": "no like() scalar function; only like_escape/not_like_escape/ilike_escape/not_ilike_escape (exactly 3 args) plus operators ~~ !~~ ~~* !~~*" + }, + { + "expr": "ilike_escape(s, p, '')", + "input_desc": "row ('k', '\u212a') in ASCII-only columns", + "result": "true", + "note": "the FUNCTION always uses the full fold \u2014 it diverges from the ILIKE OPERATOR (false here) on ASCII-stats columns" + }, + { + "expr": "s LIKE 'abc%' vs prefix(s,'abc') vs starts_with(s,'abc')", + "input_desc": "15-value corpus incl NULL, '', unicode, near-misses", + "result": "bitwise identical", + "note": "same for '%abc'==suffix==ends_with, '%abc%'==contains, 'abc'== equality; NULL propagates identically. DuckDB itself rewrites these constant shapes (EXPLAIN: prefix/contains/suffix/=)" + }, + { + "expr": "s LIKE '%'", + "input_desc": "s=NULL", + "result": "NULL", + "note": "NOT equivalent to IS NOT NULL (which is false)" + }, + { + "expr": "s LIKE p, p='%a%a%a%a%a' from column", + "input_desc": "single row, s='a'*n+'b'", + "result": "false; 26ms/0.69s/23.1s at n=64/128/256", + "note": "~32x per doubling = O(n^5) for 5 percents: DuckDB's column-pattern matcher is naive-recursive backtracking; DO NOT copy \u2014 engine needs the O(n*m) two-pointer algorithm" + }, + { + "expr": "s LIKE '%a%a%a%a%a' (constant pattern)", + "input_desc": "single row, s='a'*65536+'b'", + "result": "false in 0.2ms, flat across sizes", + "note": "bind-time-compiled segment matcher, linear; booleans never diverge from the naive path (verified on 22 adversarial %/_ cases + full pin corpus)" + } + ], + "errors": [ + { + "expr": "s LIKE p ESCAPE '#' with row s='ax', p='a#' (dangling escape reached with string chars left)", + "error_head": "Syntax Error: Like pattern must not end with escape character!", + "kind": "runtime (SyntaxException), DATA-DEPENDENT per row: raised only when the matcher examines the trailing escape while string chars remain; s='a' same pattern returns false; NULL rows and empty tables never raise; constant folding surfaces it at bind for all-constant expressions" + }, + { + "expr": "s LIKE p ESCAPE 'ab' (or ESCAPE '\u00e9' \u2014 one codepoint, two bytes)", + "error_head": "Syntax Error: Invalid escape string. Escape string must be empty or one character.", + "kind": "runtime (SyntaxException), per-row after NULL checks (NULL operand rows skip it); the limit is ONE BYTE \u2014 a single 2-byte codepoint is rejected" + }, + { + "expr": "s SIMILAR TO 'a#.c' ESCAPE '#'", + "error_head": "Not implemented Error: Custom escape in SIMILAR TO", + "kind": "binder (NotImplementedException, raised even over an empty table)" + }, + { + "expr": "s SIMILAR TO 'a(' (constant bad regex)", + "error_head": "Invalid Input Error: missing ): a(", + "kind": "binder for constant patterns (InvalidInputException, raised even over an empty table)" + }, + { + "expr": "SELECT like('a','a')", + "error_head": "Catalog Error: Scalar Function with name like does not exist!", + "kind": "binder (CatalogException)" + }, + { + "expr": "SELECT like_escape('a','a') (2 args)", + "error_head": "Binder Error: No function matches the given name and argument types 'like_escape(STRING_LITERAL, STRING_LITERAL)'. You might need to add explicit type casts.", + "kind": "binder (BinderException); like_escape is 3-arg only" + } + ], + "duck_check_tests": "\n# LIKE / NOT LIKE / ILIKE pins via duck_check (engine vs live DuckDB 1.5.5).\n# Every SQL below was validated against a native-table DuckDB oracle on 2026-07-26.\n# NOT expressible here: the error pins (dangling escape with chars left, multi-byte\n# ESCAPE string) \u2014 those raise SyntaxException per-row; cover them with pytest.raises.\n\ndef test_like_wildcards_and_empty():\n duck_check(\n \"SELECT s LIKE '%' AS c01, s LIKE '%%' AS c02, s LIKE 'a%' AS c03, s LIKE '%c' AS c04, \"\n \"s LIKE '%b%' AS c05, s LIKE 'a%c' AS c06, s LIKE 'a%b' AS c07, s LIKE '%abc' AS c08, \"\n \"s LIKE 'abc%' AS c09, s LIKE '%abcd%' AS c10, s LIKE 'abc' AS c11, s LIKE '' AS c12, \"\n \"s LIKE '_' AS c13, s LIKE '___' AS c14, s LIKE '_%' AS c15, s LIKE '%_' AS c16, \"\n \"s LIKE '%_%' AS c17, s LIKE 'a%a' AS c18, s LIKE 'a%c%f' AS c19, s LIKE '%%c' AS c20, \"\n \"s NOT LIKE 'a%' AS n01, s NOT LIKE '_' AS n02 FROM __THIS__\",\n {\"s\": \"str?\"},\n [{\"s\": v} for v in [\"abc\", \"\", \"a\", \"ab\", \"ac\", \"aa\", \"aaa\", \"abcdef\",\n \"a%b\", \"a_b\", \"%\", \"_\", \"xabcx\", \"A\", None]],\n )\n\ndef test_like_underscore_is_one_codepoint():\n # NFC 1cp/2B, NFD 2cp/3B, emoji 1cp/4B, ZWJ family 5cp/18B: '_' = one CODEPOINT\n duck_check(\n \"SELECT s LIKE '_' AS u1, s LIKE '__' AS u2, s LIKE '___' AS u3, s LIKE '_____' AS u4, \"\n \"s LIKE 'a_c' AS u5, s LIKE '%\u00e9%' AS u6, s LIKE '\u00e9' AS u7, \"\n \"s LIKE 'e\u0301' AS u8, s LIKE '_x' AS u9 FROM __THIS__\",\n {\"s\": \"str?\"},\n [{\"s\": v} for v in [\"\u00e9\", \"e\u0301\", \"\\U0001F600\",\n \"\\U0001F468\u200d\\U0001F469\u200d\\U0001F467\",\n \"a\u00e9c\", \"\u00e9x\", None]],\n )\n\ndef test_like_literals_case_and_backslash():\n # regex specials are literals; backslash is literal (NO implicit escape); case-sensitive\n duck_check(\n \"SELECT s LIKE 'a' AS l1, s LIKE 'A' AS l2, s LIKE 'a.c' AS l3, s LIKE 'a[b]c' AS l4, \"\n \"s LIKE 'a(b)c' AS l5, s LIKE 'a*' AS l6, s LIKE 'a+?' AS l7, s LIKE 'a^$c' AS l8, \"\n \"s LIKE 'a\\\\c' AS l9, s LIKE 'a\\\\bc' AS l10, s LIKE 'a\\\\%b' AS l11, s LIKE '\\\\' AS l12, \"\n \"s LIKE '\\\\%' AS l13 FROM __THIS__\",\n {\"s\": \"str?\"},\n [{\"s\": v} for v in [\"A\", \"a\", \"ABC\", \"a.c\", \"abc\", \"a[b]c\", \"a(b)c\", \"a*\",\n \"a+?\", \"a^$c\", \"a\\\\c\", \"a\\\\xb\", \"a\\\\b\", \"\\\\\", \"%\", \"a%b\"]],\n )\n\ndef test_like_control_chars():\n duck_check(\n \"SELECT s LIKE 'a' || chr(10) || 'b' AS t1, s LIKE 'a_b' AS t2, s LIKE 'a%b' AS t3, \"\n \"s LIKE '%' || chr(10) || 'b' AS t4, s LIKE 'a' || chr(9) || '%' AS t5, \"\n \"s LIKE '_' || chr(10) || 'b' AS t6 FROM __THIS__\",\n {\"s\": \"str?\"},\n [{\"s\": v} for v in [\"a\\nb\", \"a\\tb\", \"ab\"]],\n )\n\ndef test_like_escape_clause():\n duck_check(\n \"SELECT s LIKE 'a#%b' ESCAPE '#' AS e1, s LIKE '%#%' ESCAPE '#' AS e2, \"\n \"s LIKE 'a#_b' ESCAPE '#' AS e3, s LIKE 'a##b' ESCAPE '#' AS e4, \"\n \"s LIKE 'a##' ESCAPE '#' AS e5, s LIKE 'a#b' ESCAPE '#' AS e6, \"\n \"s LIKE 'abc' ESCAPE '#' AS e7, s LIKE 'a%%b' ESCAPE '%' AS e8, \"\n \"s LIKE 'a%b' ESCAPE '%' AS e9, s LIKE 'a__b' ESCAPE '_' AS e10, \"\n \"s LIKE 'aeb' ESCAPE 'e' AS e11, s LIKE 'aeeb' ESCAPE 'e' AS e12, \"\n \"s LIKE 'a#%b' ESCAPE '' AS e13, s ILIKE 'A#%B' ESCAPE '#' AS e14, \"\n \"s LIKE 'a#%b' ESCAPE NULL AS e15 FROM __THIS__\",\n {\"s\": \"str?\"},\n [{\"s\": v} for v in [\"a%b\", \"axb\", \"a%\", \"a_b\", \"a#b\", \"a#\", \"ab\", \"abc\",\n \"aeb\", \"a#xb\", None]],\n )\n\ndef test_like_dangling_escape_false_rows():\n # Dangling escape is DATA-dependent: these rows return false (string exhausted\n # before the trailing escape is consumed). Rows with chars remaining at the\n # escape raise SyntaxException instead \u2014 engine must reproduce BOTH.\n duck_check(\n \"SELECT s LIKE 'ab#' ESCAPE '#' AS d1, s LIKE 'ab#%' ESCAPE '#' AS d2 FROM __THIS__\",\n {\"s\": \"str?\"}, [{\"s\": v} for v in [\"ab\", \"a\", \"\", None]],\n )\n duck_check(\"SELECT s LIKE 'a#' ESCAPE '#' AS d3 FROM __THIS__\",\n {\"s\": \"str?\"}, [{\"s\": v} for v in [\"a\", \"\", \"ba\", None]])\n duck_check(\"SELECT s LIKE '#' ESCAPE '#' AS d4 FROM __THIS__\",\n {\"s\": \"str?\"}, [{\"s\": v} for v in [\"\", None]])\n\ndef test_ilike_ascii_stats_column():\n # Column stats are pure-ASCII -> DuckDB uses the ASCII-only ILIKE variant:\n # pattern codepoints U+212A / U+0130 do NOT fold (i7, i8 false for k/K/i).\n duck_check(\n \"SELECT s ILIKE 'a' AS i1, s ILIKE 'a%' AS i2, s ILIKE '_b_' AS i3, \"\n \"s ILIKE 'AbC' AS i4, s ILIKE 'stra\u00dfe' AS i5, s ILIKE '\u0131' AS i6, \"\n \"s ILIKE '\u212a' AS i7, s ILIKE '\u0130' AS i8, s NOT ILIKE 'a%' AS i9 FROM __THIS__\",\n {\"s\": \"str?\"},\n [{\"s\": v} for v in [\"A\", \"a\", \"ABC\", \"aBc\", \"STRASSE\", \"I\", \"k\", \"K\", \"i\", None]],\n )\n\ndef test_ilike_unicode_stats_column():\n # Non-ASCII values present in the column -> generic ILIKE == lower(s) LIKE lower(p):\n # now 'k' ILIKE U+212A and 'i' ILIKE U+0130 are TRUE (same rows as above!).\n duck_check(\n \"SELECT s ILIKE '\u212a' AS j1, s ILIKE '\u0130' AS j2, s ILIKE 'k' AS j3, \"\n \"s ILIKE 'i' AS j4, s ILIKE '\u00e9' AS j5, s ILIKE 'STRASSE' AS j6, \"\n \"s ILIKE '\u00df' AS j7, s ILIKE '\u03c3' AS j8, s ILIKE '\u03c2' AS j9, \"\n \"s ILIKE 'I' AS j10, s ILIKE '\u00e9_' AS j11 FROM __THIS__\",\n {\"s\": \"str?\"},\n [{\"s\": v} for v in [\"k\", \"K\", \"i\", \"\u212a\", \"\u0130\", \"\u00c9\", \"\u00e9\",\n \"stra\u00dfe\", \"\u1e9e\", \"\u00df\", \"\u03a3\", \"\u03c2\",\n \"\u03c3\", \"\u0131\", \"\u00c9X\", None]],\n )\n\ndef test_like_pattern_from_column():\n # vec_sp path: pattern (and NULLs) flowing through a column\n duck_check(\n \"SELECT s LIKE p AS v1, s NOT LIKE p AS v2, s LIKE p ESCAPE '#' AS v3 FROM __THIS__\",\n {\"s\": \"str?\", \"p\": \"str?\"},\n [{\"s\": s, \"p\": p} for s, p in [\n (\"abc\", \"a%c\"), (\"abc\", \"a_c\"), (\"abc\", \"x%\"), (\"a%b\", \"a#%b\"),\n (\"axb\", \"a#%b\"), (\"ab\", \"ab#\"), (\"aeb\", \"aeeb\"), (\"\u00e9\", \"_\"),\n (\"\u00e9\", \"__\"), (None, \"%\"), (\"abc\", None), (None, None)]],\n )\n\ndef test_like_degenerate_equivalences():\n # LIKE 'abc%'==prefix==starts_with, '%abc'==suffix==ends_with, '%abc%'==contains,\n # bitwise incl. NULL propagation (s LIKE '%' is NULL for NULL s, not false)\n duck_check(\n \"SELECT s LIKE 'abc%' AS g1, prefix(s,'abc') AS g2, s LIKE '%abc' AS g3, \"\n \"suffix(s,'abc') AS g4, s LIKE '%abc%' AS g5, contains(s,'abc') AS g6, \"\n \"s LIKE 'abc' AS g7, starts_with(s,'abc') AS g8, ends_with(s,'abc') AS g9, \"\n \"s LIKE '%' AS g10 FROM __THIS__\",\n {\"s\": \"str?\"},\n [{\"s\": v} for v in [None, \"\", \"a\", \"ab\", \"abc\", \"abcx\", \"xabc\", \"xabcx\",\n \"ABC\", \"a%c\", \"\u00e9abc\u00e9\", \"\\U0001F600abc\",\n \"a\\nb\", \"abca\", \"aabc\"]],\n )\n", + "matcher_spec": "\n// LIKE matcher \u2014 reproduces every pin bit-exactly against DuckDB 1.5.5 native tables.\n// UNIT: literal chars and '%' operate on BYTES (patterns are valid UTF-8, so multibyte\n// literals match byte-by-byte and '%' cut points cannot mis-align lead bytes);\n// '_' consumes exactly ONE UTF-8 CODEPOINT of the string (1-4 bytes by lead byte).\n// No normalization anywhere (NFC != NFD), no graphemes, no UTF-16 units.\n// NULL: s NULL || p NULL || escape NULL => NULL, BEFORE any validation\n// (a NULL row never raises, even with a dangling escape or 2-byte ESCAPE string).\n// ESCAPE string: validated per evaluated row: byte_len(esc_str) > 1 => Err(InvalidEscape)\n// (\"Invalid escape string. Escape string must be empty or one character.\" \u2014 note: one\n// BYTE; a single 2-byte codepoint is rejected). Empty => no escape. Escape may come\n// from a column. NOT LIKE = !like(...) with NULL passthrough.\n// ILIKE: fold BOTH sides per-codepoint with DuckDB lower()'s casemap (length-preserving,\n// includes singletons U+212A->k, U+0130->i, U+1E9E->df) and run the same matcher on the\n// folded strings ('_' then counts folded codepoints). This is DuckDB's generic path,\n// exhaustively verified over all codepoints. CAVEAT (measured): when the string\n// column's stats are pure-ASCII DuckDB silently switches to an ASCII-only fold, whose\n// ONLY observable difference is patterns containing U+212A or U+0130 \u2014 see ilike_verdict.\n// (Unmeasured corner: ILIKE with an UPPERCASE-LETTER escape char; restrict or measure.)\n\nenum LikeErr { DanglingEscape, InvalidEscape } // both map to DuckDB SyntaxException\n\nfn like(s: &[u8], p: &[u8], esc: Option) -> Result {\n like_at(s, 0, p, 0, esc)\n}\n\nfn like_at(s: &[u8], mut si: usize, p: &[u8], mut pi: usize, esc: Option)\n -> Result\n{\n while pi < p.len() {\n let pc = p[pi];\n if Some(pc) == esc {\n // Escape check FIRST \u2014 it beats '%' and '_' (ESCAPE '%' turns '%%' into a\n // literal percent; an unescaped escape char NEVER matches itself:\n // 'aeb' LIKE 'aeb' ESCAPE 'e' == false).\n if si == s.len() { return Ok(false); } // exhausted string: plain false\n // ('ab' LIKE 'ab#' ESC '#')\n pi += 1;\n if pi == p.len() { return Err(LikeErr::DanglingEscape); }\n // ^ raised ONLY here: i.e. only when string chars remain \u2014 data-dependent,\n // exactly DuckDB ('ax' LIKE 'a#' ESC '#' errors, 'a' LIKE 'a#' is false).\n if s[si] != p[pi] { return Ok(false); } // escaped char (special or\n si += 1; pi += 1; // not) is a literal byte\n } else if pc == b'%' {\n while pi < p.len() && p[pi] == b'%' { pi += 1; } // collapse bare-% runs\n if pi == p.len() { return Ok(true); }\n // Semantics: try suffix starts left-to-right; errors propagate from the\n // FIRST tried position (leftmost si), preserving DuckDB's error rows.\n // Implement with the iterative two-pointer restart (remember last '%' pos\n // and restart si+1) => worst case O(len(s)*len(p)). DuckDB's own\n // column-pattern path is naive-recursive O(n^k) (measured 23 s for one\n // 256-char row vs '%a%a%a%a%a') \u2014 booleans identical, never copy the blowup.\n for start in si..=s.len() {\n match like_at(s, start, p, pi, esc)? {\n true => return Ok(true),\n false => {}\n }\n }\n return Ok(false);\n } else if pc == b'_' {\n if si == s.len() { return Ok(false); }\n si += utf8_len(s[si]); // ONE codepoint: 1,2,3,4 by\n pi += 1; // lead byte (0xxx/110/1110/11110)\n } else {\n if si == s.len() || s[si] != pc { return Ok(false); }\n si += 1; pi += 1; // raw byte compare, case-\n } // sensitive, no specials\n }\n Ok(si == s.len()) // '' LIKE '' == true\n}\n", + "ilike_verdict": "IMPLEMENT \u2014 it is cheap, with one mandatory carve-out. Vectorized ILIKE over native tables is exactly LIKE(fold(s), fold(p)) where fold = DuckDB lower()'s per-codepoint, length-preserving casemap (the repo's measured simple-casemap can be reused verbatim; exhaustive all-codepoint sweep found ZERO exceptions and lower() never changes codepoint count). BUT the oracle is column-statistics-dependent: when every value in the string column is pure ASCII, DuckDB swaps in an ASCII-only variant, and the SAME row ('k' ILIKE U+212A) returns false in an all-ASCII column but true once any sibling row contains a non-ASCII byte (measured, T1/T2), and true again when constant-folded (literal-fold divergence). The complete divergence surface was measured exhaustively: U+0130 and U+212A are the only non-ASCII codepoints whose lower() is pure ASCII, so the two behaviors differ ONLY for patterns containing those two codepoints. Recommendation: implement fold-both-sides with the measured casemap, and at compile time reject constant patterns containing U+0130 or U+212A (and reject column-valued ILIKE patterns, or per-row-check them for those two codepoints); that yields bit-parity with the DuckDB oracle everywhere else without emulating table statistics. Also note: ilike_escape() the FUNCTION always uses the full fold even where the ILIKE operator uses the ASCII variant \u2014 do not use one to test the other.", + "similar_to_verdict": "REJECT BY NAME. DuckDB's SIMILAR TO is not SQL-standard SIMILAR TO at all: it binds to regexp_full_match(s, pattern) with the raw pattern as an anchored RE2 regex \u2014 '%' and '_' have NO wildcard meaning ('abc' SIMILAR TO 'a%' is false, 'a_c' is false) while '.', '.*', '(a|x)', '[bc]', '\\\\.', '[[:alpha:]]' all work. Supporting it means shipping a full regex engine \u2014 a different complexity class from the O(n*m) LIKE matcher \u2014 for an operator whose DuckDB semantics already surprise SQL users. Its ESCAPE clause is unimplemented in DuckDB itself (NotImplementedException 'Custom escape in SIMILAR TO', bind-time), and constant bad regexes fail at bind ('Invalid Input Error: missing ): a('). Frontend rejection is one match arm: sqlparser 0.62 parses it as a distinct Expr::SimilarTo AST variant (Expr::Like / Expr::ILike / Expr::RLike are separate), verified in the vendored crate source.", + "surprises": [ + "ILIKE results depend on COLUMN STATISTICS, not just the two values: 'k' ILIKE U+212A over a native table is false when the string column is all-ASCII, but true when any sibling row contains a non-ASCII byte (DuckDB swaps in an ASCII-only ILIKE variant off table stats). Exhaustively swept: patterns containing U+0130 or U+212A are the ONLY affected inputs. Constant-folded ILIKE always uses the full fold \u2014 a real literal-fold divergence.", + "The dangling-escape error is DATA-DEPENDENT per row: 'ab' LIKE 'ab#' ESCAPE '#' returns false (string exhausted first) while 'abX' LIKE 'ab#' ESCAPE '#' raises SyntaxException; NULL rows and empty tables never raise. Error class is SyntaxException even at runtime.", + "ESCAPE strings are limited to one BYTE, not one character: ESCAPE 'e-acute' (single codepoint, 2 bytes) is rejected with 'Escape string must be empty or one character.' \u2014 and even that validation runs per-row after NULL checks.", + "Column-pattern LIKE is naive-recursive backtracking: ONE 256-char row against '%a%a%a%a%a' took 23.1 s (~O(n^5)); the same query with a constant pattern runs in 0.2 ms at 65536 chars via a bind-time-compiled matcher. Booleans never diverge; the engine must use the O(n*m) two-pointer algorithm.", + "DuckDB SIMILAR TO is raw RE2 full-match: '%' and '_' are NOT wildcards there ('abc' SIMILAR TO 'a%' is false). It lowers to regexp_full_match.", + "'_' consumes one CODEPOINT (not byte, grapheme, or UTF-16 unit): e-acute NFC matches '_', NFD needs '__', the ZWJ family emoji needs exactly '_____'. Literal comparison is byte-wise with zero normalization \u2014 consistent with the repo's byte-wise-comparison learning while positions/lengths elsewhere are codepoint-based.", + "Backslash is a LITERAL in DuckDB LIKE \u2014 no implicit escape (unlike Postgres): 'abc' LIKE 'a\\bc' is false; there is no default escape character.", + "The escape char is ALWAYS an escape intro once declared \u2014 an unescaped occurrence never matches itself: 'aeb' LIKE 'aeb' ESCAPE 'e' is FALSE; escaping a non-special char is legal and yields the char ('ab' LIKE 'a#b' ESCAPE '#' is true); ESCAPE '' means no escape; ESCAPE NULL yields NULL; ESCAPE may be a column value.", + "ilike_escape() the function and the ILIKE operator are DIFFERENT implementations: over an all-ASCII column ilike_escape('k', kelvin, '') is true while s ILIKE p on the same row is false.", + "like() does not exist as a function (like_escape is 3-arg only); operators ~~ / !~~ / ~~* / !~~* exist. prefix/suffix/contains/starts_with/ends_with are bitwise-identical (incl. NULL propagation) to the degenerate LIKE shapes, and DuckDB itself rewrites constant 'abc%'/'%abc'/'%abc%'/'abc' to prefix/suffix/contains/=.", + "upper('sharp-s')='capital-sharp-s' and lower() never changes codepoint count (all-codepoint sweep) \u2014 DuckDB casing is strictly per-codepoint simple mappings, so ILIKE can reuse the repo's measured casemap directly." + ] +} \ No newline at end of file diff --git a/src/specializer/exec/cranelift.rs b/src/specializer/exec/cranelift.rs index 2aaaffa..98f095c 100644 --- a/src/specializer/exec/cranelift.rs +++ b/src/specializer/exec/cranelift.rs @@ -212,6 +212,43 @@ math1_h!(h_fsin, interp::duck_sin); math1_h!(h_fcos, interp::duck_cos); math1_h!(h_ftan, interp::duck_tan); +extern "C" fn h_slike( + cxp: *mut Cx, + ci: i64, + so: i64, + sl_: i64, + po: i64, + pl: i64, + has_esc: i64, + eo: i64, + el: i64, +) -> u8 { + let c = unsafe { cx(cxp) }; + let arena = unsafe { &mut *c.arena }; + let r = (|| -> Result { + let e = if has_esc != 0 { + interp::like_escape_of(arena.get(span(eo, el)))? + } else { + None + }; + let (mut sr, mut pr) = (span(so, sl_), span(po, pl)); + if ci != 0 { + sr = arena.case_map(sr, casemap::simple_lower); + pr = arena.case_map(pr, casemap::simple_lower); + } + let sv = arena.get(sr).as_bytes(); + let pv = arena.get(pr).as_bytes(); + interp::like_match(sv, pv, e) + })(); + match r { + Ok(v) => v as u8, + Err(t) => { + c.set_trap(t.0); + 0 + } + } +} + extern "C" fn h_sfind(p: *mut Cx, ao: i64, al: i64, bo: i64, bl: i64) -> i64 { let c = unsafe { cx(p) }; let arena = unsafe { &*c.arena }; @@ -1093,6 +1130,30 @@ fn translate_inst( let v = call_h(b, module, "h_round2i", &[x, nv, tv]).unwrap(); vals.insert(dst.0, V::S(v)); } + Inst::Slike { ci, dst, a, p, esc } => { + let (so, sl_) = vals[&a.0].str2(); + let (po, pl) = vals[&p.0].str2(); + let civ = icon(b, *ci as i64); + let (he, eo, el) = match esc { + Some(e) => { + let (eo, el) = vals[&e.0].str2(); + (icon(b, 1), eo, el) + } + None => { + let z = icon(b, 0); + (z, z, z) + } + }; + let v = call_h( + b, + module, + "h_slike", + &[cxp, civ, so, sl_, po, pl, he, eo, el], + ) + .unwrap(); + trap_check(b); + vals.insert(dst.0, V::S(v)); + } Inst::Str2 { op, dst, a, b: rhs } => { let (ao, al) = vals[&a.0].str2(); let (bo, bl) = vals[&rhs.0].str2(); @@ -1484,6 +1545,7 @@ const HELPERS: &[(&str, *const u8)] = &[ ("h_slen", h_slen as *const u8), ("h_round2f", h_round2f as *const u8), ("h_round2i", h_round2i as *const u8), + ("h_slike", h_slike as *const u8), ]; fn helper_sig(name: &str, sig: &mut cranelift_codegen::ir::Signature, ptr: types::Type) { @@ -1507,6 +1569,7 @@ fn helper_sig(name: &str, sig: &mut cranelift_codegen::ir::Signature, ptr: types "h_slen" => (&[ptr, I64, I64, I64], Some(I64)), "h_round2f" => (&[F64, I64, I64], Some(F64)), "h_round2i" => (&[I64, I64, I64], Some(I64)), + "h_slike" => (&[ptr, I64, I64, I64, I64, I64, I64, I64, I64], Some(I8)), "h_iabs" => (&[ptr, I64], Some(I64)), "h_fcmp" => (&[F64, F64, I64], Some(I8)), "h_scmp" => (&[ptr, I64, I64, I64, I64, I64], Some(I8)), diff --git a/src/specializer/exec/interp.rs b/src/specializer/exec/interp.rs index b7b52f6..8e97b01 100644 --- a/src/specializer/exec/interp.rs +++ b/src/specializer/exec/interp.rs @@ -833,6 +833,101 @@ pub(super) fn trunc_prec_i64(x: i64, n: i64) -> i64 { (x / power) * power } +// ------------------------------------------------------ LIKE (wave 2) -- +// Byte-based matcher with codepoint `_`, reproducing every DuckDB 1.5.5 +// pin including the DATA-DEPENDENT dangling-escape error (raised only +// when the matcher examines a trailing escape while string bytes remain; +// plain false when the string is exhausted). Iterative two-pointer +// restart: identical booleans and identical error rows to the leftmost- +// first recursive semantics, never DuckDB's own O(n^k) blowup (measured +// 23s/row there on pathological patterns; ours is O(n*m)). +// Pins: docs/superpowers/specs/pins-wave1/pins_like.json. + +fn utf8_width(b: u8) -> usize { + match b { + 0x00..=0x7f => 1, + 0xc0..=0xdf => 2, + 0xe0..=0xef => 3, + 0xf0..=0xf7 => 4, + // Continuation/invalid lead: advance one byte (spans are valid + // UTF-8; this arm is reachable only from a % restart landing on a + // continuation byte, where the subsequent literal compare fails + // anyway — pinned by the multibyte %_ duck_checks). + _ => 1, + } +} + +pub(super) fn like_match(s: &[u8], p: &[u8], esc: Option) -> Result { + let (mut si, mut pi) = (0usize, 0usize); + // Backtrack state: pattern index just past the last %, and the string + // index its current attempt started at. + let (mut star_p, mut star_s): (Option, usize) = (None, 0); + loop { + if pi < p.len() { + let pc = p[pi]; + if Some(pc) == esc { + // Escape intro beats % and _ (ESCAPE '%' de-wildcards it). + if si == s.len() { + return Ok(false); // exhausted string: plain false + } + if pi + 1 == p.len() { + return Err(Trap( + "Like pattern must not end with escape character!".into(), + )); + } + if s[si] == p[pi + 1] { + si += 1; + pi += 2; + continue; + } + } else if pc == b'%' { + while pi < p.len() && p[pi] == b'%' && Some(b'%') != esc { + pi += 1; + } + if pi == p.len() { + return Ok(true); + } + star_p = Some(pi); + star_s = si; + continue; + } else if pc == b'_' { + if si < s.len() { + si += utf8_width(s[si]); + pi += 1; + continue; + } + } else if si < s.len() && s[si] == pc { + si += 1; + pi += 1; + continue; + } + } else if si == s.len() { + return Ok(true); + } + // Mismatch: restart at the last % with one more byte consumed. + match star_p { + Some(sp) if star_s < s.len() => { + star_s += 1; + si = star_s; + pi = sp; + } + _ => return Ok(false), + } + } +} + +/// Validate an ESCAPE operand per row (AFTER NULL handling): empty means +/// no escape; the limit is one BYTE (a single 2-byte codepoint errors). +pub(super) fn like_escape_of(esc: &str) -> Result, Trap> { + match esc.len() { + 0 => Ok(None), + 1 => Ok(Some(esc.as_bytes()[0])), + _ => Err(Trap( + "Invalid escape string. Escape string must be empty or one character.".into(), + )), + } +} + /// Wave-1 string search (pins: 1-based CODEPOINT positions, empty needle /// matches everything, byte-wise comparison, zero unicode intelligence). pub(super) fn str_find(s: &str, n: &str) -> i64 { @@ -1097,6 +1192,39 @@ fn compile_inst(p: &Program, inst: &Inst, slots: &HashMap) -> InstFn { Ok(()) }) } + Inst::Slike { ci, dst, a, p, esc } => { + let (dst, a, p) = (sl(slots, dst), sl(slots, a), sl(slots, p)); + let esc = esc.map(|e| sl(slots, e)); + Box::new(move |ctx| { + let e = match esc { + None => None, + Some(e) => like_escape_of(ctx.arena.get(as_str(ctx.regs[e])))?, + }; + let (sr, pr) = (as_str(ctx.regs[a]), as_str(ctx.regs[p])); + let (sr, pr) = if ci { + // ILIKE: fold BOTH sides with the measured simple + // casemap (== DuckDB lower(); generic vectorized path. + // Known divergence, documented in the pins: DuckDB + // swaps an ASCII-only fold when column STATS are pure + // ASCII — K/U+0130 in the pattern are the entire + // observable surface). + ( + ctx.arena.case_map(sr, super::casemap::simple_lower), + ctx.arena.case_map(pr, super::casemap::simple_lower), + ) + } else { + (sr, pr) + }; + // Two immutable reads after any folding appends. + let ok = { + let sv = ctx.arena.get(sr).as_bytes(); + let pv = ctx.arena.get(pr).as_bytes(); + like_match(sv, pv, e)? + }; + ctx.regs[dst] = RegVal::I1(ok); + Ok(()) + }) + } Inst::Str2 { op, dst, a, b } => { let (dst, a, b) = (sl(slots, dst), sl(slots, a), sl(slots, b)); match op { diff --git a/src/specializer/fold.rs b/src/specializer/fold.rs index 71ff635..8b422b9 100644 --- a/src/specializer/fold.rs +++ b/src/specializer/fold.rs @@ -80,6 +80,17 @@ pub fn fold(e: SExpr) -> SExpr { a: Box::new(a), }) } + SKind::Like { ci, a, p, esc } => { + let a = fold(*a); + let p = fold(*p); + let esc = esc.map(|e| Box::new(fold(*e))); + e(SKind::Like { + ci, + a: Box::new(a), + p: Box::new(p), + esc, + }) + } SKind::Round2 { trunc, a, n } => { let a = fold(*a); let n = fold(*n); diff --git a/src/specializer/frontend.rs b/src/specializer/frontend.rs index e16887a..4c64861 100644 --- a/src/specializer/frontend.rs +++ b/src/specializer/frontend.rs @@ -819,7 +819,71 @@ impl Binder<'_> { let chain = chain.ok_or_else(|| unsup("empty IN list"))?; self.bind(&ast_not_if(*negated, chain)) } - SqlExpr::Like { .. } => Err(unsup("LIKE")), + SqlExpr::Like { + negated, + any, + expr, + pattern, + escape_char, + } + | SqlExpr::ILike { + negated, + any, + expr, + pattern, + escape_char, + } => { + let ci = matches!(e, SqlExpr::ILike { .. }); + if *any { + return Err(unsup("LIKE ANY")); + } + let (ba, bp) = (self.expr_or_null(expr)?, self.expr_or_null(pattern)?); + let (Some(ba), Some(bp)) = (ba, bp) else { + // NULL on either side is NULL before any validation + // (even a bad ESCAPE never raises on NULL rows). + return Ok(null_of(Ty::I1)); + }; + for side in [&ba, &bp] { + if side.ty != Ty::Str { + return Err(PrepareError::Bind(format!( + "no function matches {}({})", + if ci { "ilike" } else { "like" }, + side.ty.name() + ))); + } + } + let esc = match escape_char { + None => None, + Some(v) => match &v.value { + SqlValue::SingleQuotedString(s) => Some(Box::new(lit_str(s))), + SqlValue::Null => return Ok(null_of(Ty::I1)), + other => return Err(unsup(format!("ESCAPE {other} (non-string escape)"))), + }, + }; + let nullable = ba.nullable || bp.nullable; + let like = SExpr { + kind: SKind::Like { + ci, + a: Box::new(ba), + p: Box::new(bp), + esc, + }, + ty: Ty::I1, + nullable, + }; + Ok(if *negated { + SExpr { + kind: SKind::Not(Box::new(like)), + ty: Ty::I1, + nullable, + } + } else { + like + }) + } + SqlExpr::SimilarTo { .. } => Err(unsup( + "SIMILAR TO (DuckDB binds it to regexp_full_match, not SQL wildcards)", + )), other => Err(unsup(format!("expression: {other}"))), } } diff --git a/src/specializer/ir/gen.rs b/src/specializer/ir/gen.rs index 7c4a4b0..b10b25b 100644 --- a/src/specializer/ir/gen.rs +++ b/src/specializer/ir/gen.rs @@ -421,6 +421,20 @@ fn compute(rng: &mut Rng, b: &mut Builder, scope: &mut Scope, insts: &mut Vec { + // LIKE without ESCAPE is total (no trap conditions). + let a = ensure(rng, b, scope, insts, Ty::Str); + let pat = ensure(rng, b, scope, insts, Ty::Str); + let dst = b.fresh(); + insts.push(Inst::Slike { + ci: rng.chance(30), + dst, + a, + p: pat, + esc: None, + }); + scope.add(dst, Ty::I1); + } 12 => { let a = ensure(rng, b, scope, insts, Ty::Str); if rng.chance(30) { diff --git a/src/specializer/ir/mod.rs b/src/specializer/ir/mod.rs index bdef9db..829b45f 100644 --- a/src/specializer/ir/mod.rs +++ b/src/specializer/ir/mod.rs @@ -517,6 +517,17 @@ pub enum Inst { dst: Value, a: Value, }, + /// LIKE/ILIKE: `a LIKE p [ESCAPE esc]`. Byte-based matcher with + /// codepoint `_`; TRAPS on dangling-escape (data-dependent) and on a + /// multi-byte ESCAPE operand. `ci` folds both sides with the measured + /// simple casemap first (ILIKE's generic path). + Slike { + ci: bool, + dst: Value, + a: Value, + p: Value, + esc: Option, + }, /// round/trunc with digits on f64 — DuckDB's scale-then-round with the /// oracle-extracted pow10 table; TOTAL (non-finite fallbacks differ /// between round and trunc by measurement). `n` is an i64 register. @@ -667,6 +678,7 @@ impl Inst { | Inst::SLen { dst, .. } | Inst::Round2f { dst, .. } | Inst::Round2i { dst, .. } + | Inst::Slike { dst, .. } | Inst::Strim { dst, .. } | Inst::Ssubstr { dst, .. } | Inst::Num1 { dst, .. } @@ -737,6 +749,14 @@ impl Inst { *a = m(*a); *b = m(*b); } + Inst::Slike { dst, a, p, esc, .. } => { + *dst = m(*dst); + *a = m(*a); + *p = m(*p); + if let Some(e) = esc { + *e = m(*e); + } + } Inst::Ssubstr { dst, a, start, len } => { *dst = m(*dst); *a = m(*a); diff --git a/src/specializer/ir/parse.rs b/src/specializer/ir/parse.rs index ae743ef..ab2dff5 100644 --- a/src/specializer/ir/parse.rs +++ b/src/specializer/ir/parse.rs @@ -1012,6 +1012,25 @@ impl Parser { } } } + "slike" | "silike" => { + want_dsts(1, self)?; + let a = self.use_value()?; + self.expect(Tok::Comma)?; + let p = self.use_value()?; + let esc = if *self.peek() == Tok::Comma { + self.expect(Tok::Comma)?; + Some(self.use_value()?) + } else { + None + }; + Inst::Slike { + ci: opcode == "silike", + dst: def!(0), + a, + p, + esc, + } + } "slenc" | "slenb" => { want_dsts(1, self)?; let a = self.use_value()?; diff --git a/src/specializer/ir/print.rs b/src/specializer/ir/print.rs index e78d257..780a453 100644 --- a/src/specializer/ir/print.rs +++ b/src/specializer/ir/print.rs @@ -135,6 +135,13 @@ fn print_inst(s: &mut String, p: &Program, inst: &Inst) { let op = if *trunc { "itrunc2" } else { "iround2" }; let _ = write!(s, "{} {}, {}", op, val(*a), val(*n)); } + Inst::Slike { ci, a, p, esc, .. } => { + let op = if *ci { "silike" } else { "slike" }; + let _ = write!(s, "{} {}, {}", op, val(*a), val(*p)); + if let Some(e) = esc { + let _ = write!(s, ", {}", val(*e)); + } + } Inst::SLen { bytes, a, .. } => { let _ = write!(s, "{} {}", if *bytes { "slenb" } else { "slenc" }, val(*a)); } diff --git a/src/specializer/ir/verify.rs b/src/specializer/ir/verify.rs index dde51bd..8d2bb9a 100644 --- a/src/specializer/ir/verify.rs +++ b/src/specializer/ir/verify.rs @@ -187,6 +187,7 @@ fn dst_types(p: &Program, inst: &Inst) -> Vec<(Value, Ty)> { Inst::Num1 { op, dst, .. } => vec![(*dst, op.sig())], Inst::Str2 { op, dst, .. } => vec![(*dst, op.result_ty())], Inst::SLen { dst, .. } => vec![(*dst, Ty::I64)], + Inst::Slike { dst, .. } => vec![(*dst, Ty::I1)], Inst::Round2f { dst, .. } => vec![(*dst, Ty::F64)], Inst::Round2i { dst, .. } => vec![(*dst, Ty::I64)], Inst::StoiOpt { flag, dst, .. } => vec![(*flag, Ty::I1), (*dst, Ty::I64)], @@ -366,6 +367,13 @@ fn check_block( Inst::Str1 { a, .. } | Inst::SLen { a, .. } => { want(&in_scope, def_types, *a, Ty::Str, "operand", bi, i, errs) } + Inst::Slike { a, p, esc, .. } => { + want(&in_scope, def_types, *a, Ty::Str, "operand", bi, i, errs); + want(&in_scope, def_types, *p, Ty::Str, "operand", bi, i, errs); + if let Some(e) = esc { + want(&in_scope, def_types, *e, Ty::Str, "operand", bi, i, errs); + } + } Inst::Round2f { a, n, .. } => { want(&in_scope, def_types, *a, Ty::F64, "operand", bi, i, errs); want(&in_scope, def_types, *n, Ty::I64, "operand", bi, i, errs); diff --git a/src/specializer/lower.rs b/src/specializer/lower.rs index 4b5d063..0d57b77 100644 --- a/src/specializer/lower.rs +++ b/src/specializer/lower.rs @@ -580,6 +580,49 @@ impl<'a> FB<'a> { val: dst, }) } + SKind::Like { ci, a, p, esc } => { + let la = self.emit(a, live)?; + live.push((la, Ty::Str)); + let lp = self.emit(p, live)?; + live.push((lp, Ty::Str)); + let le = match esc { + Some(e) => Some(self.emit(e, live)?), + None => None, + }; + let (lp, _) = { + let x = live.pop().expect("pushed above"); + let _ = &x; + x + }; + let (la, _) = live.pop().expect("pushed above"); + // Trapping op: mask every nullable payload to "" — the + // empty string/pattern/escape are all outside the trap + // domain (like("", "", no-escape) is a clean false/true). + let empty = self.const_lit(Lit::Str(String::new())); + let mask = |fbb: &mut Self, l: Lane| match l.flag { + None => l.val, + Some(f) => fbb.select_of(f, l.val, empty), + }; + let av = mask(self, la); + let pv = mask(self, lp); + let (ev, eflag) = match le { + Some(l) => (Some(mask(self, l)), l.flag), + None => (None, None), + }; + let dst = self.fresh(); + self.inst(Inst::Slike { + ci: *ci, + dst, + a: av, + p: pv, + esc: ev, + }); + let f1 = self.combine_flags(la.flag, lp.flag); + Ok(Lane { + flag: self.combine_flags(f1, eflag), + val: dst, + }) + } SKind::Round2 { trunc, a, n } => { let la = self.emit(a, live)?; live.push((la, a.ty)); diff --git a/src/specializer/plan.rs b/src/specializer/plan.rs index 2728c40..1010cc3 100644 --- a/src/specializer/plan.rs +++ b/src/specializer/plan.rs @@ -171,6 +171,13 @@ pub enum SKind { bytes: bool, a: Box, }, + /// LIKE/ILIKE (negation handled by a Not wrapper at bind). + Like { + ci: bool, + a: Box, + p: Box, + esc: Option>, + }, /// round/trunc with digits — result type == subject type (I64 or F64); /// total, NULL-propagating. Round2 { diff --git a/tests/test_corpus_replay.py b/tests/test_corpus_replay.py index c98d1cf..296de99 100644 --- a/tests/test_corpus_replay.py +++ b/tests/test_corpus_replay.py @@ -37,6 +37,19 @@ # Build-time errors that are documented v0 contract limits, not bugs. _CLEAN = ("unsupported:", "parse error:", "duplicate map key", "NULL in value column") +# Documented oracle divergences (clean, not FAILs). Each entry must cite a +# measured reason the divergence is IRREPRODUCIBLE row-locally. +_KNOWN_DIVERGENT_SOURCES = { + # DuckDB's ILIKE result for a NUL-containing row DEPENDS ON SIBLING + # ROWS: pure-ASCII column stats select a NUL-safe ASCII kernel (row + # matches itself -> TRUE), while any non-ASCII sibling selects the + # generic kernel whose fold NUL-truncates (same row -> FALSE); measured + # 2026-07-26, pins-wave1/pins_like.json. Statistics-dependent semantics + # cannot be reproduced by a row-at-a-time engine even in principle; the + # engine is NUL-transparent (the ASCII-kernel behavior). + "test/sql/function/string/test_ilike_embedded_null.test", +} + _FROM_RE = re.compile(r"\bFROM\s+([A-Za-z_][A-Za-z0-9_]*)", re.IGNORECASE) _PY_OF_ARROW = [ @@ -116,6 +129,8 @@ def _replay(case: dict) -> tuple[str, str]: return "unsupported", msg return "FAIL", f"build error: {type(e).__name__}: {msg}" + if case.get("source") in _KNOWN_DIVERGENT_SOURCES: + return "unsupported", "known oracle divergence (see _KNOWN_DIVERGENT_SOURCES)" try: rows_in = [model(**r) for r in arrow[driving].to_pylist()] got = [list(r.model_dump().values()) for r in fn.infer({driving: rows_in})] diff --git a/tests/test_duckdb_interpreter.py b/tests/test_duckdb_interpreter.py index a721ef4..768af35 100644 --- a/tests/test_duckdb_interpreter.py +++ b/tests/test_duckdb_interpreter.py @@ -1458,3 +1458,245 @@ def test_alias_shadows_original_name(): row_tables={"__THIS__": _row_model({"a": "int"})}, static_tables={}, ) + + +# --------------------------------------------------------- TASK-48: +# LIKE / NOT LIKE / ILIKE - measured DuckDB 1.5.5 pins as oracle tests. +# LIKE / NOT LIKE / ILIKE pins via duck_check (engine vs live DuckDB 1.5.5). +# Every SQL below was validated against a native-table DuckDB oracle on 2026-07-26. +# NOT expressible here: the error pins (dangling escape with chars left, multi-byte +# ESCAPE string) — those raise SyntaxException per-row; cover them with pytest.raises. + + +def test_like_wildcards_and_empty(): + duck_check( + "SELECT s LIKE '%' AS c01, s LIKE '%%' AS c02, " + "s LIKE 'a%' AS c03, s LIKE '%c' AS c04, " + "s LIKE '%b%' AS c05, s LIKE 'a%c' AS c06, " + "s LIKE 'a%b' AS c07, s LIKE '%abc' AS c08, " + "s LIKE 'abc%' AS c09, s LIKE '%abcd%' AS c10, " + "s LIKE 'abc' AS c11, s LIKE '' AS c12, " + "s LIKE '_' AS c13, s LIKE '___' AS c14, s LIKE '_%' AS c15, " + "s LIKE '%_' AS c16, " + "s LIKE '%_%' AS c17, s LIKE 'a%a' AS c18, " + "s LIKE 'a%c%f' AS c19, s LIKE '%%c' AS c20, " + "s NOT LIKE 'a%' AS n01, s NOT LIKE '_' AS n02 FROM __THIS__", + {"s": "str?"}, + [ + {"s": v} + for v in [ + "abc", + "", + "a", + "ab", + "ac", + "aa", + "aaa", + "abcdef", + "a%b", + "a_b", + "%", + "_", + "xabcx", + "A", + None, + ] + ], + ) + + +def test_like_underscore_is_one_codepoint(): + # NFC 1cp/2B, NFD 2cp/3B, emoji 1cp/4B, ZWJ family 5cp/18B: '_' = one CODEPOINT + duck_check( + "SELECT s LIKE '_' AS u1, s LIKE '__' AS u2, " + "s LIKE '___' AS u3, s LIKE '_____' AS u4, " + "s LIKE 'a_c' AS u5, s LIKE '%é%' AS u6, s LIKE 'é' AS u7, " + "s LIKE 'é' AS u8, s LIKE '_x' AS u9 FROM __THIS__", + {"s": "str?"}, + [ + {"s": v} + for v in [ + "é", + "é", + "\U0001f600", + "\U0001f468‍\U0001f469‍\U0001f467", + "aéc", + "éx", + None, + ] + ], + ) + + +def test_like_literals_case_and_backslash(): + # regex specials are literals; backslash is literal (NO implicit + # escape); case-sensitive + duck_check( + "SELECT s LIKE 'a' AS l1, s LIKE 'A' AS l2, " + "s LIKE 'a.c' AS l3, s LIKE 'a[b]c' AS l4, " + "s LIKE 'a(b)c' AS l5, s LIKE 'a*' AS l6, " + "s LIKE 'a+?' AS l7, s LIKE 'a^$c' AS l8, " + "s LIKE 'a\\c' AS l9, s LIKE 'a\\bc' AS l10, " + "s LIKE 'a\\%b' AS l11, s LIKE '\\' AS l12, " + "s LIKE '\\%' AS l13 FROM __THIS__", + {"s": "str?"}, + [ + {"s": v} + for v in [ + "A", + "a", + "ABC", + "a.c", + "abc", + "a[b]c", + "a(b)c", + "a*", + "a+?", + "a^$c", + "a\\c", + "a\\xb", + "a\\b", + "\\", + "%", + "a%b", + ] + ], + ) + + +def test_like_control_chars(): + # chr() is not in the catalogue; embed the control chars directly in + # the SQL literals (legal in both engines). + duck_check( + "SELECT s LIKE 'a\nb' AS t1, s LIKE 'a_b' AS t2, s LIKE 'a%b' AS t3, " + "s LIKE '%\nb' AS t4, s LIKE 'a\t%' AS t5, " + "s LIKE '_\nb' AS t6 FROM __THIS__", + {"s": "str?"}, + [{"s": v} for v in ["a\nb", "a\tb", "ab"]], + ) + + +def test_like_dangling_escape_false_rows(): + # Dangling escape is DATA-dependent: these rows return false (string exhausted + # before the trailing escape is consumed). Rows with chars remaining at the + # escape raise SyntaxException instead — engine must reproduce BOTH. + duck_check( + "SELECT s LIKE 'ab#' ESCAPE '#' AS d1," + " s LIKE 'ab#%' ESCAPE '#' AS d2 FROM __THIS__", + {"s": "str?"}, + [{"s": v} for v in ["ab", "a", "", None]], + ) + duck_check( + "SELECT s LIKE 'a#' ESCAPE '#' AS d3 FROM __THIS__", + {"s": "str?"}, + [{"s": v} for v in ["a", "", "ba", None]], + ) + duck_check( + "SELECT s LIKE '#' ESCAPE '#' AS d4 FROM __THIS__", + {"s": "str?"}, + [{"s": v} for v in ["", None]], + ) + + +def test_like_pattern_from_column(): + # vec_sp path: pattern (and NULLs) flowing through a column + duck_check( + "SELECT s LIKE p AS v1, s NOT LIKE p AS v2," + " s LIKE p ESCAPE '#' AS v3 FROM __THIS__", + {"s": "str?", "p": "str?"}, + [ + {"s": s, "p": p} + for s, p in [ + ("abc", "a%c"), + ("abc", "a_c"), + ("abc", "x%"), + ("a%b", "a#%b"), + ("axb", "a#%b"), + ("ab", "ab#"), + ("aeb", "aeeb"), + ("é", "_"), + ("é", "__"), + (None, "%"), + ("abc", None), + (None, None), + ] + ], + ) + + +def test_like_degenerate_equivalences(): + # LIKE 'abc%'==prefix==starts_with, '%abc'==suffix==ends_with, '%abc%'==contains, + # bitwise incl. NULL propagation (s LIKE '%' is NULL for NULL s, not false) + duck_check( + "SELECT s LIKE 'abc%' AS g1, prefix(s,'abc') AS g2, s LIKE '%abc' AS g3, " + "suffix(s,'abc') AS g4, s LIKE '%abc%' AS g5, contains(s,'abc') AS g6, " + "s LIKE 'abc' AS g7, starts_with(s,'abc') AS g8, ends_with(s,'abc') AS g9, " + "s LIKE '%' AS g10 FROM __THIS__", + {"s": "str?"}, + [ + {"s": v} + for v in [ + None, + "", + "a", + "ab", + "abc", + "abcx", + "xabc", + "xabcx", + "ABC", + "a%c", + "éabcé", + "\U0001f600abc", + "a\nb", + "abca", + "aabc", + ] + ], + ) + + +def test_like_escape_clause(): + duck_check( + "SELECT s LIKE 'a#%b' ESCAPE '#' AS e1, s LIKE '%#%' ESCAPE '#' AS e2, " + "s LIKE 'a#_b' ESCAPE '#' AS e3, s LIKE 'a##b' ESCAPE '#' AS e4, " + "s LIKE 'a##' ESCAPE '#' AS e5, s LIKE 'a#b' ESCAPE '#' AS e6, " + "s LIKE 'abc' ESCAPE '#' AS e7, s LIKE 'a%%b' ESCAPE '%' AS e8, " + "s LIKE 'a%b' ESCAPE '%' AS e9, s LIKE 'a__b' ESCAPE '_' AS e10, " + "s LIKE 'aeb' ESCAPE 'e' AS e11, s LIKE 'aeeb' ESCAPE 'e' AS e12, " + "s LIKE 'a#%b' ESCAPE '' AS e13, s ILIKE 'A#%B' ESCAPE '#' AS e14, " + "s LIKE 'a#%b' ESCAPE NULL AS e15 FROM __THIS__", + {"s": "str?"}, + [ + {"s": v} + for v in [ + "a%b", + "axb", + "a%", + "a_b", + "a#b", + "a#", + "ab", + "abc", + "aeb", + "a#xb", + None, + ] + ], + ) + + +def test_like_dangling_escape_is_data_dependent(): + # Exhausted string -> plain false; chars remaining -> per-row trap. + duck_check( + "SELECT s LIKE 'ab#' ESCAPE '#' AS d FROM __THIS__", + {"s": "str"}, + [{"s": "ab"}], + ) + with pytest.raises(ValueError, match="must not end with escape"): + fn = DuckDBInferFn( + "SELECT s LIKE 'a#' ESCAPE '#' AS d FROM __THIS__", + row_tables={"__THIS__": _row_model({"s": "str"})}, + static_tables={}, + ) + fn.infer_rows([{"s": "ax"}])