Skip to content

fix(render): keep un-aliased NULL unwrapped so NOT survives negation - #2

Open
toddyLee wants to merge 4 commits into
mindsdb:mainfrom
toddyLee:fix/sqlalchemy-render-not-is-null
Open

toddyLee wants to merge 4 commits into
mindsdb:mainfrom
toddyLee:fix/sqlalchemy-render-not-is-null

Conversation

@toddyLee

Copy link
Copy Markdown

Summary

SqlalchemyRender.to_expression() wrapped every ast.Constant — including NULL — in a .label(). In WHERE operands this turned x IS NULL into a bind-param comparison, which defeats SQLAlchemy's negate optimization, so a negated null predicate compiled to the identical expression and the NOT was silently dropped:

expr = col.operate(operators.is_, null_label)   # x IS :param_1
str(expr.__invert__().compile())                # x IS :param_1  <-- NOT lost
str(col.is_(None).__invert__().compile())       # x IS NOT NULL  <-- correct without the Label

Pushed-down queries therefore returned inverted results with no error. Verified on a live deployment (ClickHouse integration):

SELECT COUNT(*) FROM db.t WHERE code='UAV' AND TypeName IS NOT NULL;   -- 1 row (correct)
SELECT COUNT(*) FROM db.t WHERE code='UAV' AND NOT (TypeName IS NULL); -- 0 rows (wrong, should be 1)
SELECT COUNT(*) FROM db.t WHERE code='UAV' AND NOT (TypeName IS NOT NULL); -- 1 row (wrong, should be 0)

Because the renderer is shared, this affects every integration that pushes SQL through SqlalchemyRender (mysql, postgres, mssql, oracle, snowflake, duckdb, bigquery, databricks, and 30+ community handlers) and is dialect-independent — reproduced rendering with MySQL, PostgreSQL and SQLite dialects.

Fix

Return sa.null() without a label when t.value is None and not t.alias. Labeled behavior is kept for aliased SELECT-list constants (SELECT NULL AS x).

Tests

5 new regression cases in tests/unit/render/test_sqlalchemyrender.py (TestNullPredicateRendering): NOT (x IS NULL), NOT x IS NULL, NOT (x IS NOT NULL), positive-form invariance, and aliased-NULL label preservation. Full file passes 20/20.

Verification

Applied the same one-line fix to a live mindsdb/mindsdb:latest deployment: all probe queries returned correct results afterwards, no regressions on the positive forms.

Fixes mindsdb/mindshub#12491

SqlalchemyRender.to_expression() wrapped every ast.Constant — including
NULL — in a Label. In WHERE operands this turned `x IS NULL` into a
bind-param comparison, defeating SQLAlchemy's negate optimization, so
`NOT (x IS NULL)` compiled identical to `x IS NULL` and pushed-down
queries silently returned inverted results.

Return sa.null() without a label for un-aliased NULL constants; labeled
behavior is kept for aliased SELECT-list constants.

Fixes mindsdb/mindshub#12491
@github-actions

github-actions Bot commented Aug 29, 2026 •

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@toddyLee

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

1 similar comment
@todd-lee

todd-lee commented Sep 1, 2026

Copy link
Copy Markdown

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request Sep 1, 2026
@toddyLee

toddyLee commented Sep 1, 2026

Copy link
Copy Markdown
Author

recheck

@toddyLee
toddyLee force-pushed the fix/sqlalchemy-render-not-is-null branch from f4a009b to 9ba32cb Compare September 1, 2026 01:55
to_expression() returned sa.null() for every un-aliased NULL constant,
regardless of the operator it lands under:

- comparisons raise ArgumentError (only =/!=/is-family accept null), so
  queries like 'a > last' - whose placeholder is Constant(None) on the
  first pass - silently failback to str(ast) and produce the invalid
  'a > None', surfacing as a duckdb Binder Error (test_last)
- 'a = NULL' was auto-rewritten to 'a IS NULL', changing match semantics
- un-aliased 'SELECT NULL' lost its NULL column label

Restore the always-labeled form and unwrap to sa.null() only under
is / is not, the one place where SQLAlchemy's negate optimization needs
it to keep NOT (x IS NULL) from compiling as x IS NULL.
SQLAlchemy's __invert__ only produces a real negation when the operand
of IS / IS NOT is a dedicated NULL/TRUE/FALSE singleton. For any other
operand (bind param, label, column, function, subquery, UNKNOWN) the
inverted expression compiled identical to the original, silently
dropping the NOT and inverting pushed-down results.

Swapping IS <-> IS NOT is not a universal replacement either: PostgreSQL
row-valued predicates such as NOT (ROW(1, NULL) IS NULL) are not
equivalent to ROW(1, NULL) IS NOT NULL.

Comparison negation (NOT (a > b) -> a <= b) stays untouched. The
IS TRUE / IS FALSE keywords are preserved on SQLite, where they test
truthiness and differ from IS 1 / IS 0.

Tests: real LastQuery placeholder-to-injection roundtrip, duckdb
row-level assertions, differential testing against sqlite3 for arbitrary
IS operands and nested NOT, and cross-dialect NOT preservation.
@toddyLee

Copy link
Copy Markdown
Author

Following up from mindsdb/mindshub#12491 — @akshat-lakhera offered to help there, and the renderer fix is implemented on this branch awaiting review. (GitHub doesn't allow the fork-PR author to send a formal review request, so mentioning here instead.) Feedback welcome, especially on the explicit-unary-NOT approach in to_expression()'s UnaryOperation branch and the regression suite in tests/unit/render/test_sqlalchemyrender.py.

@nandyshirshak-cloud

Copy link
Copy Markdown

I tested PR #2 locally.

On the main branch, NOT (x IS NULL) was rendered incorrectly as:

SELECT *
FROM t
WHERE x IS NULL

After switching to this PR's branch, it is correctly rendered as:

SELECT *
FROM t
WHERE NOT (x IS NULL)

I also ran the SQLAlchemy render test suite successfully.

Thank you for the fix!

This branch has not been deployed

No deployments
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.

[Bug]: SqlalchemyRender silently drops NOT on IS [NOT] NULL predicates — wrong query results across all SqlalchemyRender-based integrations

3 participants