Skip to content

fix(executor): honor the negate flag on LIKE and ILIKE [CLAUDE] - #8139

Merged
georgesittas merged 2 commits into
tobymao:mainfrom
jbylund:fix/executor-negated-like
Aug 12, 2026
Merged

fix(executor): honor the negate flag on LIKE and ILIKE [CLAUDE]#8139
georgesittas merged 2 commits into
tobymao:mainfrom
jbylund:fix/executor-negated-like

Conversation

@jbylund

@jbylund jbylund commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

The bug

x NOT LIKE 'p' parses as a single exp.Like carrying negate=True, not 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:

a NOT LIKE 'p'     ->  LIKE(scope[None]["a"], 'p')
a LIKE 'p'         ->  LIKE(scope[None]["a"], 'p')

so the predicate returned exactly the rows the query asked to exclude:

>>> 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 excluded, and none it asked for

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.Is already reads negate in TRANSFORMS, and Is/Like/ILike are the only three nodes whose arg_types carry the flag -- so this completes the set.

The negation is routed through NOT rather than inverting in Python, so the generated code is exactly what the NOT (x LIKE 'p') spelling already produces and the three-valued logic comes from the same SQL_NOT. A NULL operand therefore matches neither form, which the test asserts.

Note, out of scope

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.

Tests

test_negated_like asserts NOT LIKE returns the complement and agrees exactly with NOT (x LIKE ...), NULL row included. test_py_dialect gains the two codegen assertions.

Full suite passes: SKIP_INTEGRATION=1 python -m unittest -> 1314 tests, OK.

`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>
@georgesittas
georgesittas merged commit 7cab581 into tobymao:main Aug 12, 2026
7 checks passed
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.
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.

2 participants