Skip to content

fix(sql): AND binds tighter than OR in every clause (ANDOR) - #407

Merged
fupelaqu merged 1 commit into
mainfrom
feature/ANDOR
Oct 1, 2026
Merged

fupelaqu merged 1 commit into
mainfrom
feature/ANDOR

Conversation

@fupelaqu

Copy link
Copy Markdown
Contributor

Summary

A condition that mixes AND and OR without parentheses is now evaluated with SQL precedence (AND binds tighter than OR) in every clause. Before, A OR B AND C was read as (A OR B) AND C, and the Elasticsearch bool query made the OR branch optional beside a filter: every unparenthesised mix in WHERE returned wrong rows, on every major.

  • Parsing: the condition reducer applies SQL precedence. A written group, including NESTED(…) / CHILD(…) / PARENT(…) bodies, stays one operand.
  • WHERE (and DELETE / UPDATE, which share it): a child bool shares its parent only when it has the same operator, so an OR branch is never optional beside a filter. A MATCH-bearing OR group written under an AND stays one required clause.
  • HAVING:
    • the bucket_selector script parenthesises an OR under an AND;
    • the script no longer strips the text 1 == 1 from the whole script (it ate params.max_c1 == 1, so HAVING MAX(c1) = 1 failed);
    • a one-key mix such as HAVING k = 'v1' OR k = 'v2' AND NOT k = 'v3' is accepted exactly when the include/exclude lists give SQL's answer.
  • NOT at the head of an AND run folds into its condition (ISNULL ↔ ISNOTNULL included). On MATCH it is refused by name, with a rewrite that parses.
  • Rendered SQL keeps the parentheses precedence needs, so a stored definition re-parses to the same meaning.
  • Docs: the precedence and NOT sections, plus 31 examples that did not parse, corrected.

Verification

  • Release rule (no statement answered correctly before may be refused now), over a 612,748-statement parse differential against main: 0 violations.
    • The 1,566 newly refused statements were all answered wrongly on main.
    • The 32 newly accepted statements return the right rows at run time.
  • HAVING answer differential (140,768 statements): 0 go from right to wrong or refused; 3,487 go from wrong to right.
  • Trees: 0 of 7,212 generated conditions keep a non-SQL grouping. Every render re-parses to the same tree.
  • Suites: sql/test 1,749 · bridges 263 / 256 · core/test 1,147 · macrosTests/test 30.
  • Integration on Elasticsearch 8.18.3: the new truth-table rows, NOT ISNULL against main (same rows where main was right), HAVING MAX(c1) = 1, and the one-key HAVING rows. The other majors run in CI.
  • Nested census (364 statements): 0 go from right to wrong, 0 new crashes.
  • Mutations: 22 rows go red.
  • Parse cost: no signal (ratios 0.96–1.01 against a control).

Release notes

  • Unparenthesised AND/OR mixes follow SQL precedence in WHERE, CASE, HAVING, DELETE and UPDATE. DELETE and UPDATE used to change the wrong documents; that damage cannot be recovered automatically.
  • Stored artefacts built before this change are not rebuilt automatically:
    • a materialized view whose stored definition renders the same must be dropped and re-created (CREATE OR REPLACE skips an unchanged render);
    • computed columns need their DDL re-run and a reindex;
    • INSERT … SELECT, CTAS and COPY results need re-running.
  • NOT ISNULL(x) / NOT ISNOTNULL(x) after AND / OR now mean ISNOTNULL(x) / ISNULL(x). a OR NOT ISNULL(x) used to run as AND NOT, and a CASE WHEN with NOT ISNULL negated the whole condition. a AND NOT ISNULL(x) returns the same rows with a different query.
  • A run-head NOT on MATCH is refused by name, with a rewrite.
  • x AND MATCH(a, b) AGAINST(…) is now required (it was optional). A relevance-ordered result can change, since a MATCH may move between filter and scoring clauses.
  • A HAVING over a metric whose script contained 1 == 1 (MAX(c1) = 1, = 10, IN (1, 2)) now works. A view built over such a HAVING may need re-creating.
  • One-key HAVING mixes are accepted exactly when Elasticsearch's lists give SQL's answer; the others are refused.
  • Rendered SQL changes: NOT c = 1 instead of c NOT = 1, and parentheses on rebuilt trees. Chains of four or more conditions with one operator regroup: same query, different CASE script text, so ingest pipelines are re-created on the next ALTER.
  • API: additive (selectorScript); no downstream rebuild.

Known limitations (not changed here)

  • A NOT before LIKE / IN / BETWEEN / IS parses only as the second item of a pair (a AND b AND NOT c LIKE 'x%' is rejected). Documented.
  • HAVING ISNULL(MAX(x)) never holds for a group without x, and a MATCH over an aggregate in HAVING is dropped from the script: pre-existing, to be fixed separately.

🤖 Generated with Claude Code

Story ANDOR: a condition that mixes AND and OR is evaluated with SQL
precedence.

- The condition reducer applies SQL precedence (AND before OR); a written
  group, NESTED/CHILD/PARENT bodies included, stays one operand.
- WHERE (and DELETE / UPDATE, which share it): a child bool shares its
  parent only on the same operator, so an OR branch is no longer made
  optional beside a filter.
- HAVING: the bucket_selector script parenthesises an OR under an AND;
  the selector no longer strips "1 == 1" from the whole script (it ate
  params.max_c1 == 1); a one-key HAVING mix is accepted exactly when the
  include/exclude lists give SQL's answer.
- NOT at the head of an AND run folds into its condition
  (ISNULL <-> ISNOTNULL included); on MATCH it is refused by name with a
  rewrite. A MATCH-bearing OR group under an AND stays a required clause.
- The rendered SQL keeps the parentheses precedence needs.
- Docs: precedence sections, NOT examples and 31 examples corrected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@fupelaqu
fupelaqu marked this pull request as ready for review October 1, 2026 03:19
@fupelaqu
fupelaqu merged commit 12e4d4b into main Oct 1, 2026
4 checks 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.

1 participant