fix(executor): honor the negate flag on LIKE and ILIKE [CLAUDE] - #8139
Merged
Conversation
`x NOT LIKE 'p'` parses as a single exp.Like carrying negate=True rather
than an exp.Not wrapping an exp.Like. Neither Like nor ILike was in
PythonGenerator.TRANSFORMS, so both fell through to _rename, which does not
read the flag -- LIKE and NOT LIKE generated byte-identical code:
>>> from sqlglot.executor import execute
>>> t = {"t": [{"s": "Bump version"}, {"s": "Add feature"}]}
>>> execute("SELECT s FROM t WHERE s NOT LIKE 'Bump%'", tables=t).rows
[('Bump version',)] # every row it asked to exclude, and none it asked for
It fails silently, with a full result that is quietly the inverse of the
query. `NOT (x LIKE 'p')` spelled out has always worked, which is what makes
it easy to miss.
exp.Is already reads negate in TRANSFORMS; Like and ILike are the only other
nodes that carry it. Route theirs through NOT, so the generated code is
exactly what the `NOT (x LIKE 'p')` spelling produces and the three-valued
logic comes from the same SQL_NOT -- a NULL operand matches neither form.
Note ILIKE is separately absent from the executor's ENV, so both ILIKE and
NOT ILIKE still raise "name 'ILIKE' is not defined". That is a different
gap; this change means the flag will be honored once it is filled.
jbylund
added a commit
to jbylund/pg_mimic
that referenced
this pull request
Aug 12, 2026
Adds `tests/test_sqlglot_workarounds.py`: thirteen strict-xfail tripwires, one per sqlglot executor bug pg_mimic works around. Asked for in the discussion on #50. ## Why A workaround has no expiry date. Two of ours silently outlived their cause, and both were caught by re-reading comments rather than by any test: - `TableSession` refuses a `FULL OUTER JOIN` for a bug sqlglot fixed in **v30.15.0** (#50) - `examples/git_sql.py`'s INTERVAL patch reads as stale (it turns out not to be — see below) ## How Each test asserts what **Postgres** answers, runs against sqlglot directly rather than through pg_mimic, and is marked `xfail(strict=True)`. So it fails today — the expected outcome — and the moment sqlglot fixes the bug it XPASSes, which `strict` turns into a build failure naming the workaround to delete. Verified end to end against local sqlglot branches carrying the two fixes in flight: ``` $ PYTHONPATH=../sqlglot pytest tests/test_sqlglot_workarounds.py -q FAILED tests/test_sqlglot_workarounds.py::test_not_like_excludes_what_like_matches [XPASS(strict)] sqlglot fixed this -- remove the workaround named in the test 1 failed, 1 passed, 12 xfailed ``` A failure here is never a pg_mimic regression — it means good news upstream. ## Coverage | tripwire | workaround it justifies | | --- | --- | | `OFFSET` | `_take_row_window` / `rows_sliced_here` | | `ORDER BY` on a set operation | `_take_result_order` / `_sorted_rows` | | `NOT IN (subquery)` | `_rewrite_not_in` | | NULL ordering, descending | `_rewrite_null_ordering` | | same-table plan collision | `canonicalize_table_aliases=True` | | parenthesized `LIMIT` | `_flatten_parenthesized` | | `TABLESAMPLE` | refused in `_reject_silently_ignored` | | `LIKE` anchoring / escaping | ENV patching; #38 | | `NOT LIKE` | #44 — flips when [sqlglot#8139](tobymao/sqlglot#8139) merges | | `ILIKE` | none, it raises; #38 | | `LENGTH` / `\|\|` | none, both raise; #38 | | `INTERVAL` years and months | `_interval_delta` in examples/git_sql.py | | typed division | #48 — flips when [sqlglot#8138](tobymao/sqlglot#8138) merges | `FULL OUTER JOIN` is deliberately **not** xfail: it already works, so it is a plain assertion that also catches an upstream regression. ## One subtlety worth recording The INTERVAL tripwire needs a **column** operand. With a literal, the optimizer constant-folds the arithmetic before the executor runs, so `interval '1 year'` looks fine: ```sql SELECT CAST('2024-03-15' AS TIMESTAMP) - INTERVAL '1 year' -- 2023-03-15, folded SELECT ts - INTERVAL '1 year' FROM t -- "'years' is an invalid keyword argument" ``` Only a column reaches `ENV["INTERVAL"]`, which is `timedelta(**{unit: n})` and has no years or months. Probing with the literal is what led me to wrongly call that workaround stale in the first version of #50. ## Note on the version floor These tests describe whatever sqlglot is installed. `pyproject.toml` pins `sqlglot>=25.0.0`, so on an older install the `FULL OUTER JOIN` assertion legitimately fails — the same version question #50 has to settle before its guard can be removed. Full suite: 483 passed, 13 xfailed. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
jbylund
added a commit
to jbylund/sqlglot
that referenced
this pull request
Aug 12, 2026
ILIKE was absent from the executor's ENV entirely, so both it and NOT ILIKE
raised `name 'ILIKE' is not defined` rather than answering:
>>> execute("SELECT s FROM t WHERE s ILIKE 'bump%'", tables=t).rows
ExecuteError: Step 'Join: t (...)' failed: name 'ILIKE' is not defined
The loose end from tobymao#8139, which taught the generator to honor negate on both
Like and ILike -- the flag now reaches an ILIKE the ENV still could not
resolve.
Implemented as LIKE with re.IGNORECASE, sharing one helper so the two cannot
drift: the escaping and anchoring semantics landed in 44b73a0 are exactly
what ILIKE needs too, and that expression moves into the helper unchanged.
Tests mirror test_like_semantics, negated form included, with a row carrying
a literal dot so the "." patterns tell a literal apart from the "_" wildcard
rather than only asserting a non-match.
jbylund
added a commit
to jbylund/sqlglot
that referenced
this pull request
Aug 12, 2026
ILIKE was absent from the executor's ENV entirely, so both it and NOT ILIKE
raised `name 'ILIKE' is not defined` rather than answering:
>>> execute("SELECT s FROM t WHERE s ILIKE 'bump%'", tables=t).rows
ExecuteError: Step 'Join: t (...)' failed: name 'ILIKE' is not defined
The loose end from tobymao#8139, which taught the generator to honor negate on both
Like and ILike -- the flag now reaches an ILIKE the ENV still could not
resolve.
Implemented as LIKE with re.IGNORECASE, sharing one helper so the two cannot
drift: the escaping and anchoring semantics landed in 44b73a0 are exactly
what ILIKE needs too, and that expression moves into the helper unchanged.
Tests mirror test_like_semantics, negated form included, with a row carrying
a literal dot so the "." patterns tell a literal apart from the "_" wildcard
rather than only asserting a non-match.
jbylund
added a commit
to jbylund/sqlglot
that referenced
this pull request
Aug 12, 2026
ILIKE was absent from the executor's ENV entirely, so both it and NOT ILIKE
raised `name 'ILIKE' is not defined` rather than answering:
>>> execute("SELECT s FROM t WHERE s ILIKE 'bump%'", tables=t).rows
ExecuteError: Step 'Join: t (...)' failed: name 'ILIKE' is not defined
The loose end from tobymao#8139, which taught the generator to honor negate on both
Like and ILike -- the flag now reaches an ILIKE the ENV could not resolve.
Implemented as LIKE with re.IGNORECASE, sharing one helper so the two cannot
drift: the escaping and anchoring semantics landed in 44b73a0 are exactly
what ILIKE needs too, and that expression moves into the helper unchanged.
Each pattern is run in both cases and expected to give the same rows, which
is the property that distinguishes ILIKE from LIKE. Hand-cased patterns only
assert it incidentally, and would still pass if IGNORECASE were dropped for
whichever spelling happened to match. A row carrying a literal dot keeps the
"." patterns telling a literal apart from the "_" wildcard.
jbylund
added a commit
to jbylund/sqlglot
that referenced
this pull request
Aug 12, 2026
ILIKE was absent from the executor's ENV entirely, so both it and NOT ILIKE
raised `name 'ILIKE' is not defined` rather than answering:
>>> execute("SELECT s FROM t WHERE s ILIKE 'bump%'", tables=t).rows
ExecuteError: Step 'Join: t (...)' failed: name 'ILIKE' is not defined
The loose end from tobymao#8139, which taught the generator to honor negate on both
Like and ILike -- the flag now reaches an ILIKE the ENV could not resolve.
Implemented as LIKE with re.IGNORECASE, sharing one helper so the two cannot
drift: the escaping and anchoring semantics landed in 44b73a0 are exactly
what ILIKE needs too, and that expression moves into the helper unchanged.
Tests mirror test_like_semantics, with each pattern run in both cases and
expected to give the same rows -- the property that distinguishes ILIKE from
LIKE, and one that hand-cased patterns assert only incidentally. A row
carrying a literal dot keeps the "." patterns telling a literal apart from
the "_" wildcard.
georgesittas
pushed a commit
that referenced
this pull request
Aug 12, 2026
ILIKE was absent from the executor's ENV entirely, so both it and NOT ILIKE
raised `name 'ILIKE' is not defined` rather than answering:
>>> execute("SELECT s FROM t WHERE s ILIKE 'bump%'", tables=t).rows
ExecuteError: Step 'Join: t (...)' failed: name 'ILIKE' is not defined
The loose end from #8139, which taught the generator to honor negate on both
Like and ILike -- the flag now reaches an ILIKE the ENV could not resolve.
Implemented as LIKE with re.IGNORECASE, sharing one helper so the two cannot
drift: the escaping and anchoring semantics landed in 44b73a0 are exactly
what ILIKE needs too, and that expression moves into the helper unchanged.
Tests mirror test_like_semantics, with each pattern run in both cases and
expected to give the same rows -- the property that distinguishes ILIKE from
LIKE, and one that hand-cased patterns assert only incidentally. A row
carrying a literal dot keeps the "." patterns telling a literal apart from
the "_" wildcard.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
x NOT LIKE 'p'parses as a singleexp.Likecarryingnegate=True, not anexp.Notwrapping anexp.Like. NeitherLikenorILikewas inPythonGenerator.TRANSFORMS, so both fell through to_rename, which does not read the flag.LIKEandNOT LIKEgenerated byte-identical code:so the predicate returned exactly the rows the query asked to exclude:
No exception -- a full result that is quietly the inverse of the query. Spelling it
NOT (x LIKE 'p')has always worked, which is what makes it easy to miss.The fix
exp.Isalready readsnegateinTRANSFORMS, andIs/Like/ILikeare the only three nodes whosearg_typescarry the flag -- so this completes the set.The negation is routed through
NOTrather than inverting in Python, so the generated code is exactly what theNOT (x LIKE 'p')spelling already produces and the three-valued logic comes from the sameSQL_NOT. A NULL operand therefore matches neither form, which the test asserts.Note, out of scope
ILIKEis separately absent from the executor'sENV, so bothILIKEandNOT ILIKEstill raisename 'ILIKE' is not defined. That is a different gap -- this change means the flag will be honored once it is filled.Tests
test_negated_likeassertsNOT LIKEreturns the complement and agrees exactly withNOT (x LIKE ...), NULL row included.test_py_dialectgains the two codegen assertions.Full suite passes:
SKIP_INTEGRATION=1 python -m unittest-> 1314 tests, OK.