Skip to content

Negated conditions and ILIKE; operators that stream - #10

Merged
jessestimpson merged 3 commits into
mainfrom
claude/streaming-and-negation
Sep 27, 2026
Merged

jessestimpson merged 3 commits into
mainfrom
claude/streaming-and-negation

Conversation

@jessestimpson

Copy link
Copy Markdown
Contributor

Two independent changes, one commit each.

1. NOT, <>, NOT IN, NOT BETWEEN, ILIKE, NOT ILIKE

These already parsed, but the translator refused them. Now they work:

where status <> 'paid'                      -- or !=
where status not in ('paid', 'refunded')
where total not between 10 and 20
where name ilike 'al%'                      -- also not ilike
where not (status = 'paid' or total > 100)
  • NOT is pushed into the condition it negates, and SQL's NULL rules keep each opposite exact:
    • not (a < 1) is a >= 1 (both false when a is NULL), and not (a = 1) is a <> 1;
    • IN, LIKE, ILIKE, BETWEEN and IS NULL flip, and not not cancels;
    • not (a or b) is not a and not b;
    • not (a and b) would need OR, so it gets OR's "not supported" message.
  • New predicate shapes: {:cmp, :!=, …}, {:not_in, …}, {:not_range, …}, {:ilike, …} and {:not_ilike, …}. An index can't serve any of them, so they're checked on the rows read (Predicate.pushdown/1 is :filter). The rest of the WHERE still narrows what's read.
  • NULLs: a NULL field matches none of them, as in SQL. A NULL status is neither = 'paid' nor <> 'paid'.
  • Rewrite: a single-value NOT IN becomes <>.
  • Primary key _: a condition an index can't serve, like _ <> 'x', now says what _ does support (=, <, <=, >, >=, BETWEEN, IN). Before, it said "cannot be combined with this query shape".

The README, TUI help and completion cover the new forms.

2. Operators that process rows as they arrive (Efsql.Pipeline)

Operators used to take the whole list of rows. A batched cross-tenant query (\set tenant_batch N) concatenated every batch before the first operator ran, so it held every row it read, and a LIMIT still read every batch.

Efsql.Pipeline runs a plan's operators a chunk at a time, and each operator keeps only what it needs:

  • LIMIT without ORDER BY: stops reading once it has its rows. Unread batches are skipped, and ones already running are cancelled.
  • ORDER BY … LIMIT n: keeps only the best n rows so far.
  • GROUP BY and aggregates: keep a running total per group. Efsql.Aggregate is now new/2, add/2 and finish/1, and run/3 is unchanged.
  • Filter and project: keep nothing.
  • A sort with no limit: still keeps every row, as it has to.

The executor feeds every plan through a pipeline; a normal read is one chunk. Batches become a stream (Executor.stream_concurrently/3, replacing map_concurrently/3), so a halt stops fetching.

One behaviour change: sum/avg switch to Decimal at the first Decimal value, instead of converting every value when any is a Decimal. That only matters if one field mixes floats and Decimals, which an Ecto field can't.

Tests

  • Property test: random rows, random operator chains shaped like the planner's, and random chunking must give exactly the same result as running the operators over all rows at once, early halts included. 500 cases per run, passing on 11 seeds.
  • Unit tests:
    • every NOT translation;
    • evaluating the new conditions (NULL handling, case-insensitive and non-ASCII ILIKE) and the new rewrite;
    • pipeline halting, top-n memory and running aggregates;
    • a halted stream starting no further work.
  • FoundationDB tests:
    • the negations and ILIKE on the seeded users;
    • a plan check that an index still serves >= while <> is filtered afterwards;
    • the primary-key message;
    • ORDER BY … LIMIT and LIMIT across real batches.

All 308 tests that don't need FoundationDB pass locally on the combined branch, with no new compiler warnings. This CI run is the first time the FoundationDB tests run on either change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PFBZUrerge6bbWVCHpS5Ko


Generated by Claude Code

These parsed but were refused by the translator. Now:

- `<>` / `!=` is {:cmp, :!=, ...}. NOT IN, NOT BETWEEN, ILIKE and NOT ILIKE
  are {:not_in, ...}, {:not_range, ...}, {:ilike, ...} and
  {:not_ilike, ...}. They're checked on the rows read (Predicate.pushdown/1
  is :filter), since no index can serve them. A NULL field matches none of
  them, as in SQL, so a NULL status is neither = nor <> 'paid'.
- NOT is pushed into the condition it negates, where SQL's NULL rules
  keep each opposite exact: NOT (a < 1) is a >= 1, NOT (a = 1) is a <> 1,
  NOT IN/LIKE/ILIKE/BETWEEN/IS NULL flip, NOT NOT cancels, and
  NOT (a OR b) is NOT a AND NOT b. NOT (a AND b) would need OR, so it's
  refused with OR's message.
- Rewrite turns a single-value NOT IN into <>.
- A filter-only condition on the primary key '_' now says what '_'
  supports (=, <, <=, >, >=, BETWEEN, IN) instead of "cannot be combined
  with this query shape".

The README, the TUI help and completion cover the new forms. There are
unit tests for the translation (every NOT case), evaluation (NULL
semantics, case-insensitive and non-ASCII ILIKE) and the rewrite, plus
FoundationDB tests on the seeded users.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFBZUrerge6bbWVCHpS5Ko
Operators used to take the whole list of rows, and a batched query
across tenants concatenated every batch before the first one ran. So
memory held every row read, and a LIMIT still read every batch.

Efsql.Pipeline runs a plan's operators a chunk at a time (new/1,
feed/2, finish/1), and each operator keeps only what it has to:
- filter and project keep nothing;
- limit counts, and once it has its rows feed/2 says :halt, so the
  source stops reading;
- a sort directly followed by a limit is fused into a running top-n;
- a sort alone keeps every row, since it has to;
- aggregate keeps a running total per group. Efsql.Aggregate is now
  new/2, add/2 and finish/1, with run/3 unchanged.

The executor feeds every plan through a pipeline. A single read is one
chunk. Batches are a stream (Executor.stream_concurrently/3, replacing
map_concurrently/3), so each batch is processed as it arrives, and a
halt stops the batches not yet read, cancelling any in flight.

sum and avg now switch to Decimal at the first Decimal value instead of
converting every value when any is one. The results differ only if one
field mixes floats with Decimals.

A property test checks that for random rows, random planner-shaped
operator chains and random chunking, the pipeline's result equals the
operators applied to all the rows at once, early halts included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFBZUrerge6bbWVCHpS5Ko
SQLGen.mangle/2 inserts invalid UTF-8 on purpose (a lone 0xC3). The next
mangling round then split the string with String.graphemes/1, which
raises on invalid UTF-8 under OTP 28's unicode_util, so the "mangled
queries never crash" property test failed on some seeds. The string is
now cut into codepoints, with each invalid byte a unit of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFBZUrerge6bbWVCHpS5Ko
@jessestimpson
jessestimpson merged commit d0e625f into main Sep 27, 2026
1 check passed
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