Skip to content

fix: comprehension SQL broken on SQLite, DuckDB, and MySQL - #27

Merged
richardwooding merged 1 commit into
mainfrom
fix/comprehension-iteration-variables
Aug 22, 2026
Merged

fix: comprehension SQL broken on SQLite, DuckDB, and MySQL#27
richardwooding merged 1 commit into
mainfrom
fix/comprehension-iteration-variables

Conversation

@richardwooding

Copy link
Copy Markdown
Contributor

Ports the two comprehension fixes from the Go cel2sql (SPANDigital/cel2sql#169 and SPANDigital/cel2sql#177) — and it turns out pycel2sql was worse off than Go was: three dialects generated comprehension SQL that parses but cannot run correctly.

The bugs

Dialect Symptom
SQLite EXISTS (SELECT 1 FROM json_each(arr) AS a WHERE a = ?)"no such column: a" (json_each is table-valued: rows of key, value, type, …)
DuckDB FROM UNNEST(arr) AS a WHERE a = 'x'cast error: a binds a STRUCT(unnest VARCHAR) row, not the value
MySQL same unknown-column problem as SQLite — and once that's fixed, MySQL 8.x's optimizer turns a correlated EXISTS into a semijoin, loses the JSON_TABLE correlation, and silently matches nothing (works from 9.x; verified against 8.4.11 and 9.7.1 locally)

The fix

Two new dialect hooks, mirroring the Go/Java/C# family design:

  • write_comprehension_source — binds the element value to the iteration variable: derived-table rename (SELECT value AS x FROM json_each(...)) AS _t for SQLite/MySQL (same idiom cel2sql4j already uses for SQLite), UNNEST(...) AS _t(x) for DuckDB
  • write_comprehension_exists / write_comprehension_not_exists — default EXISTS/NOT EXISTS; MySQL emits (SELECT COUNT(*) FROM ...) > 0 / = 0, which the 8.x optimizer never transforms

PostgreSQL/BigQuery/Spark output is unchanged.

Testing

  • New tests/integration/test_comprehensions.py executes exists/all/exists_one against PostgreSQL, DuckDB, SQLite, and MySQL (the missing coverage that let this ship)
  • SQLite + DuckDB verified locally in-process; MySQL verified locally against a real 8.4.11 server via pymysql; pg/mysql container legs run in CI
  • Unit suite, ruff (src/ tests/), and strict mypy all clean

Ports two fixes from the Go cel2sql (SPANDigital/cel2sql#169, #177),
where they were found by executing generated comprehension SQL against
real databases:

- SQLite json_each and MySQL JSON_TABLE are table-valued, so a bare
  reference to the iteration variable was 'no such column'; DuckDB's
  FROM UNNEST(arr) AS a binds a to a STRUCT row, making a = 'x' a cast
  error. A new Dialect.write_comprehension_source hook lets each
  dialect bind the value to the variable (derived-table rename for
  json_each/JSON_TABLE, AS _t(var) for DuckDB).

- MySQL 8.x transforms a correlated EXISTS into a semijoin and loses
  the correlation to JSON_TABLE, so exists()/all() executed without
  error and silently matched nothing (works from 9.x; verified against
  MySQL 8.4.11 and 9.7.1). New write_comprehension_exists /
  write_comprehension_not_exists hooks let MySQL emit COUNT
  comparisons, which are never transformed.

Adds integration tests that execute exists/all/exists_one against
PostgreSQL, DuckDB, SQLite, and MySQL.
@richardwooding
richardwooding merged commit c857b4a into main Aug 22, 2026
7 checks passed
@richardwooding
richardwooding deleted the fix/comprehension-iteration-variables branch August 22, 2026 22:29
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