Repository navigation
feat(sql): lower SQL to unified operator and scalar IR - #539
Merged
Merged
Conversation
This was referenced Oct 2, 2026
zzylol
force-pushed
the
stack/528-03-graph
branch
from
October 2, 2026 18:32
9369217 to
9870df6
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 2, 2026 18:32
e979d69 to
bffe654
Compare
zzylol
force-pushed
the
stack/528-03-graph
branch
from
October 2, 2026 19:40
9870df6 to
88c2931
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 2, 2026 19:40
bffe654 to
74cb049
Compare
zzylol
force-pushed
the
stack/528-03-graph
branch
from
October 2, 2026 21:14
88c2931 to
c0638f6
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 2, 2026 21:14
74cb049 to
49d558c
Compare
zzylol
force-pushed
the
stack/528-03-graph
branch
from
October 2, 2026 21:22
c0638f6 to
d414efd
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 2, 2026 21:22
49d558c to
34a7fad
Compare
zzylol
force-pushed
the
stack/528-03-graph
branch
from
October 2, 2026 21:25
d414efd to
ec0417b
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
2 times, most recently
from
October 2, 2026 21:56
7357d5b to
eec2e77
Compare
zzylol
force-pushed
the
stack/528-03-graph
branch
2 times, most recently
from
October 3, 2026 02:31
b4db7fb to
00a3d27
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 3, 2026 02:31
eec2e77 to
268f020
Compare
zzylol
force-pushed
the
stack/528-03-graph
branch
from
October 3, 2026 02:39
00a3d27 to
467baad
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 3, 2026 02:39
268f020 to
b310b0e
Compare
zzylol
force-pushed
the
stack/528-03-graph
branch
from
October 3, 2026 14:57
467baad to
aecd114
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 3, 2026 14:57
b310b0e to
82ea050
Compare
This was referenced Oct 3, 2026
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 3, 2026 16:07
82ea050 to
7adc4e9
Compare
zzylol
force-pushed
the
stack/528-03-graph
branch
2 times, most recently
from
October 3, 2026 16:08
131a18e to
76b23bc
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 3, 2026 16:08
7adc4e9 to
7e6d76f
Compare
zzylol
force-pushed
the
stack/528-03-graph
branch
from
October 3, 2026 16:33
76b23bc to
c30977a
Compare
This was referenced Oct 6, 2026
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 6, 2026 20:48
6ee8a19 to
d24fa84
Compare
zzylol
force-pushed
the
stack/528-02c-coverage-derivation
branch
2 times, most recently
from
October 6, 2026 21:49
b727827 to
34c43f0
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 6, 2026 22:16
d24fa84 to
9a0ade0
Compare
zzylol
force-pushed
the
stack/528-02c-coverage-derivation
branch
2 times, most recently
from
October 7, 2026 23:07
3721101 to
ebd2a93
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 7, 2026 23:41
9a0ade0 to
d58225d
Compare
zzylol
force-pushed
the
stack/528-02c-coverage-derivation
branch
from
October 9, 2026 15:58
ebd2a93 to
1bbd2a0
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 10, 2026 14:32
d58225d to
c47eee4
Compare
Port DF 54's API changes (Expr::Literal metadata, Cast/TryCast field, ScalarUDFImpl return_field_from_args/invoke_with_args, catalog path, dialect name) and the planner behaviour changes (SELECT * keeps its projection, GenericDialect parses aggregate FILTER) from main's crates/frontend-sql/src/sql onto the unified copy and its tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Type coercion turns a literal compared with a column of another type into a cast: `latency < 100` on a DOUBLE column became `latency < CAST(100 AS DOUBLE)`, which summary coverage cannot read as a constant. Fold a cast of a literal to an integer or float type with DataFusion's `ScalarValue::cast_to`, the evaluation its constant folding uses; the predicate lowers as `latency < 100.0`. A cast that fails to evaluate is kept. DataFusion's whole SimplifyExpressions rule is not run: it also folds timestamp casts into timestamp literals, which the IR cannot write yet, and rewrites REGEXP_LIKE into the `~` operator (#650). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 11, 2026 17:11
c47eee4 to
1b69820
Compare
zzylol
marked this pull request as ready for review
October 11, 2026 18:12
This was referenced Oct 11, 2026
zzylol
commented
Oct 12, 2026
zzylol
commented
Oct 12, 2026
- Column resolution: a qualified reference falls back to the bare name only when exactly one column has it, so `ORDER BY b.k` after a join no longer binds `a.k`; an ambiguous name is an error. A bare name must be unique too. - Reducer arguments keep their qualifier, so `SUM(b.x)` over a join reads `b.x`, not `a.x`. - `WITHIN GROUP (ORDER BY x DESC)` percentiles use `1 - q`, as DataFusion. - `CAST(NULL AS ...)` is not folded, so a typed NULL keeps its type. - Non-literal `LIMIT`/`OFFSET` expressions are rejected instead of dropped. - A cast in a reducer argument that can change values or the result type (`SUM(CAST(i AS DOUBLE))`) is computed as a derived column; only widening casts that leave the result unchanged read the bare column. - Window `FILTER`, `DISTINCT` and `IGNORE NULLS` are rejected. - `LIKE ... ESCAPE` is rejected. - DataFusion 54: an integer cast to `Decimal128(_, 0)` (how DataFusion compares unsigned with signed integers, e.g. `ROW_NUMBER() ... <= 5`) is dropped after type coercion; any other decimal cast, such as a user's `CAST(1.5 AS DECIMAL(10, 0))`, is rejected. Type coercion runs after the duration check, so a temporal subtraction inside a CASE branch is still reported as an unsupported duration. - A reducer argument is read from the bare column only when the cast is directly over a column: coercion wraps `avg(CAST(f AS BIGINT))` as `CAST(CAST(f AS Int64) AS Float64)`, and the inner cast truncates. - A CAST / TRY_CAST of a non-literal to INT, SMALLINT, TINYINT or REAL is rejected: the canonical types are 64-bit, so its range check or 32-bit rounding would be lost. - `CAST(x AS VARCHAR)` (DataFusion 54's `Utf8View`) lowers to Utf8. - A `Concat` column is nullable if any child's is, so a ROLLUP / CUBE / GROUPING SETS key is nullable in the combined output. The last five were found by running 573 queries through DataFusion and through this lowering. Each has a regression test in unified_sql_lowering.rs that fails without the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
Contributor
Author
Review findings and fixesTwo review rounds and a differential check against DataFusion 54 (573 queries run through DataFusion and through this lowering, with the #541 runtime or a reference interpreter of the IR). All fixes are in a34ab7f (
Join, |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
#511 (
decoupling_op_and_expr.md, §2.1 Operator nodes) says:§3.2 (SQL semantic mapping) then lists how each SQL construct must map onto those nodes, for example "
FROM t,WHERE x > 1,SELECT x * 2→Scan,Filter,Project; scalarCompare,Arithmetic", "VALUES (1), (2);SELECT 1→Values;Projectover one empty row", "ORDER BY,LIMIT, offset-only queries →Sort,Limit…n = Nonemeans no fetch limit", and "Uncorrelated scalar subquery,EXISTS,IN/NOT IN (SELECT ...)→ explicit scalar plan-reading variants". #509 §0 (Language-specific frontends) adds that the frontend preserves source-language behavior and rejects "a construct that cannot be represented faithfully".What
What this PR covers: the SQL part of #511 §2.1/§2.2/§3.2, plus the shared name resolver that §2.1 leaves to the frontend. It adds a common frontend crate with a name-based tree and a resolver, and a complete second SQL lowering under
asap_frontend_sql::unifiedthat emits it. Callers are unchanged: the planner keeps the legacyasap_frontend_sql::lower_sqluntil the cutover (#542).How
All of this is frontend (#509 stage 0). Nothing runs in Pass 1/Pass 2, physical planning, selection or runtime.
Name-based tree (
asap-frontend-common::unresolved).UnresolvedOp/UnresolvedScalarmirrorNonASAPOp/ScalarExprvariant for variant. There are three differences: everyColumnIdis a name-basedColumnRef,Scan.schemaisOption<Schema>(known for a catalog SQL table,Nonefor a schemaless PromQL leaf), and children areRc<UnresolvedOp>because no schema exists yet. Two extra variants (PromqlMap,PromqlScalarOp) exist only here; the resolver expands them intoProject/Filter.SQL → tree (
frontend-sql::unified::sql).SqlLowerer::lowerparses (DataFusion dialect with aggregateFILTER, or ClickHouse), plans with DataFusion, runsApplyFunctionRewrites, rejects temporal subtraction that would produce aDurationtype, and then runs DataFusion'sTypeCoercionanalyzer, so implicit conversions become explicitCasts. (The duration check runs first so that a subtraction hidden in aCASEbranch is reported as a duration, not as a coercion error.) An integer cast toDecimal128(_, 0), which DataFusion 54 inserts to compare unsigned with signed integers (ROW_NUMBER() … <= 5), is dropped after coercion; any other decimal cast is rejected. A cast of a non-literal toINT/SMALLINT/TINYINT/REALis rejected, because the canonical types are 64-bit and the cast's range check or 32-bit rounding would be lost. A cast of a literal to an integer or float type is folded with DataFusion'sScalarValue::cast_to, as its constant folding does:latency < 100on aDOUBLEcolumn lowers tolatency < 100.0, notlatency < CAST(100 AS DOUBLE). DataFusion's wholeSimplifyExpressionsrule is not run yet, since it also produces timestamp literals and the~regex operator, which lowering does not support (SQL lowering: run DataFusion's SimplifyExpressions (needs IR timestamp literals and regex operators) #650). It then walks the unoptimizedLogicalPlan:TableScan→Scan { schema: Some(catalog schema) }; aWHEREdirectly over a bare scan folds intoScan.predicates(filter_or_fold).EmptyRelation→Valueswith one empty row (or zero rows);Values→Valuestyped from DataFusion's schema.Compare/Arithmetic/NegativecarriesExprSemantics::Sql.BETWEENbecomes>= AND <=(NOT BETWEENbecomes< OR >).NOW()/CURRENT_TIMESTAMPbecomeCurrentTimestamp.count(x)over a nullablexkeeps SQL NULL skipping as a per-measure filterx IS NOT NULL.ROLLUP/CUBE/ grouping sets expand to oneAggregateper level under aConcat.LIMITwith no fetch →n: None; a non-literalLIMIT/OFFSETis rejected.SUM(b.x)over a join readsb.x. A cast in a reducer argument is read from the bare column only when it is directly over a column and leaves the value and the result type unchanged (the int-to-float cast underAVG); any other cast (SUM(CAST(i AS DOUBLE)),CAST(f AS BIGINT)) is computed as a derived column.WITHIN GROUP (ORDER BY x DESC)percentiles use1 - q. WindowFILTER/DISTINCT/IGNORE NULLSandLIKE … ESCAPEare rejected rather than dropped.ScalarSubquery/EXISTS/INbecomes the matchingUnresolvedScalarvariant, and its plan is lowered as a separate root. Each subquery conjunct of aWHEREbecomes its ownFilterabove the ordinary predicate. Only a correlatedEXISTSis still lowered to a semi/anti join in the frontend, because its correlation predicate needs both sides. Other correlated subqueries are rejected.Resolve (
asap-frontend-common::resolve).resolve_rootwalks the tree bottom-up. Each child becomes anOperatorNodefirst, and its derivedschemais the scope the parent'sColumnRefs resolve against. Scope rules:Aggregate.havingresolves against the aggregate's output schema.Join/SetOpsides and plan-reading scalars (ScalarSubquery,Exists,InSubquery,PromqlScalarFromVector) each resolve as a root in their own scope. AJoinpredicate seesleft ++ right.BinaryOpside is its own root but inherits label names that the enclosing scope references (issue L3: sum by (__name__) fails column resolution (metric-name label not in usage-derived schema) #52).Scanwithschema: Nonebinds to the usage-derived schema fromSchemaResolver:ts,value, plus one nullableUtf8field per referenced name,closed: false.Name lookup: a bare name must match exactly one column; a qualified
t.kprefers the(t, k)column and otherwise falls back to a unique barek. Anything else is an error (ResolveError::Ambiguous), so after a joinknever silently binds to the first side.The result then goes through the existing
canonicalizepass (which turnsFilter(Exists | IN)conjuncts into semi/anti joins and recognizes count-ranked top-k) andvalidate_structure.Entry points.
unified::lower_sql/lower_sql_dialect/lower_sql_batchmirror the legacy root API but returnRc<OperatorNode>. The legacyasap_frontend_sql::{lower_sql, …}is untouched. No bridge operators and no conversion between the two graphs are added.Key code interfaces
Name-based tree —
crates/frontend-common/src/unresolved.rs:Resolver —
crates/frontend-common/src/resolve.rs,schema_resolver.rs:SQL frontend —
crates/frontend-sql/src/unified/mod.rs,unified/sql/mod.rs,unified/sql/types.rs,unified/error.rs:Series identity —
crates/types/src/ir/schema_support.rs:Usage (from the tests):
Other new items, not public API:
collection_planning,dialect,clickhouse_ast(copied from the legacy SQL module;collection_planningbuildsScalarExprinstead ofQueryExpr), and thepre_asap::SchemaDerivationErrorre-export.Fields
Tree wrappers
UnresolvedPredicate.0UnresolvedScalarir::Predicate.UnresolvedProjectItem.aliasOption<String>Nonekeeps the name derived from the expression.UnresolvedProjectItem.exprUnresolvedScalarUnresolvedSortKey.expr/ascending/nulls_firstbool/boolir::SortKey.UnresolvedScalar(the mirrored variants have the meaning of the sameScalarExprvariant)Column(ColumnRef)Named,Qualified { table, name },SampleValueorWildcard. SQL keeps DataFusion's relation qualifier, soa.kandb.kacross a join resolve to the right side. Resolved toScalarExpr::Column(ColumnId).PromqlScalarFromVector(op)scalar(v).opresolves as its own root. Not emitted by SQL.ScalarSubquery(op)(SELECT …)in a value position.opresolves as its own root. The IR contract: zero rows gives typed NULL, more than one row is an error.Exists { subquery, negated }[NOT] EXISTS (subquery).negated = trueforNOT EXISTS.InSubquery { expr, subquery, negated }expr [NOT] IN (subquery).exprresolves in the outer scope,subqueryin its own scope. The SQL frontend requires the subquery to have exactly one column.negated = trueisNOT IN, kept with SQL NULL rules rather than lowered to an anti-join.UnresolvedOp(only the fields that differ fromNonASAPOp, plus frontend-only variants)Scan.sourceSourceTable { table_ref }for SQL,TimeSeries { metric }for PromQL.Scan.predicatesVec<UnresolvedPredicate>Scan.schemaOption<Schema>Some= catalog-backed SQL table (closed schema).None= bind to theSchemaResolverusage-derived schema.Values.rowsVec<Vec<UnresolvedScalar>>vec![vec![]]is the one empty row ofSELECT 1.vec![]is an empty relation.Values.schemaSchemaclosed: true, no time index or keys.Limit.nOption<usize>None= offset-only, no limit (wasusize::MAXin the legacy tree).Limit.offsetusize0when there is noOFFSET.Limit.partition_byGroupKeys<ColumnRef>GroupKeys::none().Limit.childRc<UnresolvedOp>PromqlMap.child/sample/drop_metric_nameboolsampleand keep every other field. The resolver first adds the full series identity (if the child schema is open), then emits aProject.drop_metric_nameremoves__name__. Used by PromQL (#540).PromqlScalarOp.child/scalar/op/scalar_left/return_boolBinaryOpKind/bool/boolscalarresolves in an empty scope.scalar_leftgives the operand order. Arithmetic becomes aProject. A comparison becomes aFilter, or aProjectofCase(cmp, 1.0, 0.0)whenreturn_boolis set oropisCompareBool.Setops are rejected. Used by PromQL (#540).Helper methods:
childrenreturns the direct scalar sub-expressions (not the operators they read).columns_referencedreturns everyColumnRefin this expression, not inside the operators it reads.operator_refsreturns the operators the expression reads, transitively.concatbuilds aConcatwith no unique-key claim.concat_with_discriminatorbuilds aConcatwith a caller-proven(discriminator, inner_key)unique key; nothing verifies the claim.scalar_exprsreturns every scalar the operator owns.Resolver
resolve_root(tree)canonicalizeandvalidate_structure. Returns the resolved root.resolve_expr(expr, schema)schema. Operators it reads become their own roots and inheritschema's label names (all fields excepttsandvalue).resolve_scalar_root(tree)ResolveDAGError::ResolveColumnRefdid not resolve against its scope (missing or ambiguous name).ResolveDAGError::SchemaSchemaCatalog::columns_for(source)None= unknown, so fall back to usage.UsageDerivedCatalogNone.SchemaResolver.catalog(private)Scansource. Set bynew()(usage-derived) orwith_catalog(c).resolve_schema(tree)(ts: Timestamp, value: Float64), plus one nullableUtf8field per referenced name.time_index= position ofts, no unique keys,closed: false.resolve_schema_with_inherited(tree, inherited)jobinsum by (job)(a or b), issue #52).collect_referenced_columns(tree)SQL frontend
lower_sql(query, catalog, accuracy)lower_sql_dialectwithSqlDialect::DataFusionSQL.lower_sql_dialect(…, dialect, …)SqlLowerer::with_dialect(…).lower(…), thenresolve_root.ClickhouseSQLparses with sqlparser's ClickHouse dialect.lower_sql_batch(workload, catalog)Resultperquery_batchentry; errors do not stop the batch. Each entry uses its ownrequirements.accuracy.target(). Non-SQL language →WrongLanguagefor every entry.ElasticSQL→UnsupportedDialect. Empty batch →vec![].SqlLowerer.catalog(private)MemTables; their schemas becomeScan.schema.SqlLowerer.dialect(private)newusesDataFusionSQL;with_dialectsets it.SqlLowerer::lower(sql, accuracy)UnresolvedOptree.accuracyis attached to every approximate intent (Count,Quantile,Cardinality). Exactly one statement per query.SqlCatalog.tablesSchema.with_tableadds one entry.SqlError::DataFusionSqlError::TableNotFoundSqlError::UnsupportedAggregateSqlError::InvalidExpressionIN (subquery)).SqlError::UnsupportedDialectElasticSQL.SqlError::UnsupportedFeatureIN/ scalar subquery,DISTINCT ON, GROUPS frames, unsupported join types, more than one statement.SqlError::WrongLanguageSqlError::Convertresolve_rootfailed (ResolveDAGError).with_promql_series_identity(root)rebuilds the DAG (memoized by node pointer) so that every openTimeSeriesscan gets a non-nullUtf8series-identity field and becomesclosed. Errors: a closed scan without it, awithoutrankingSort, or an operator with no rule for it.guaranteeandtimingare copied to the rebuilt nodes. The resolver calls it forPromqlMap/PromqlScalarOpover an open child.Before this PR
Before this PR, #537 has the resolved IR (
OperatorNode,NonASAPOp,ScalarExpr) inasap_types::ir, but no crate outsidecrates/typesbuilds it. The SQL frontend still emits the legacy mixed graph:UnresolvedQueryExpr(QueryExpr<ColumnRef>), resolved byasap_types::pre_asap::resolve_rootintoQueryExpr<ColumnId>, where a scalar is just anotherQueryExprnode:Concrete gaps in the legacy SQL path that §3.2 calls out:
... WHERE service NOT IN (SELECT service FROM hosts)InSubquery { negated: true }with SQLNOT INNULL rulesSELECT (SELECT max(latency) FROM metrics) FROM hostsScalarSubquery(Rc<OperatorNode>)SELECT 1,VALUES (1,'a'),(2,'b')EmptyRelation/ValuesarmProjectoverValueswith one empty row;Values... OFFSET 5with noLIMITn: usize::MAXLimit { n: None }WHERE ts < NOW()TypeCoercionpass, so the implicit conversion is not recordedCast(CurrentTimestamp → Timestamp)After this PR
Every row of the gap table above lowers as §3.2 requires, through
asap_frontend_sql::unified::{lower_sql, lower_sql_dialect, lower_sql_batch}, which return a validatedRc<OperatorNode>. A developer can lower any supported SQL query to the #511 IR and inspect it; constructs that cannot be represented faithfully fail with an explicit error instead of being approximated. The PromQL frontend (#540) reuses the same unresolved tree and resolver.Evidence
Execution examples from the tests. Screenshots and measurements: not applicable (no visual output, no performance claim).
End to end:
SELECT service FROM metrics WHERE service NOT IN (SELECT service FROM hosts)(testnot_in_subquery_is_rejected_rather_than_mislowered_as_an_anti_join; the test name is from the legacy behavior, but it now asserts the query is accepted). Catalog:metrics(ts, service, latency, bytes),hosts(service, region).The positive
INform goes through the same steps, andcanonicalizeturns it intoJoin(Semi)whose predicate isColumn(1) = Column(4). Column 4 is the subquery key, bound by position, not by the shared nameservice(in_subquery_lowers_to_a_semi_join).Accepted and rejected cases (all in
crates/frontend-sql/tests/unified_sql_lowering.rs):SELECT * FROM metrics WHERE service = 'api'Scanwith 1 predicate, closed schemaselect_star_with_where_folds_predicate_onto_scanSELECT bytes * 8 FROM metrics WHERE latency > 1.5Arithmetic/ComparewithExprSemantics::Sqlsql_comparisons_and_arithmetic_carry_sql_semanticsSELECT 1Project[Literal(Int64(1))]overValues { rows: [[]] }select_without_from_projects_over_one_empty_rowSELECT * FROM (VALUES (1,'a'),(2,'b')) AS v(n, s)Valueswith 2 rows,Int64/Utf8; outputn, svalues_lowers_to_one_row_per_values_rowSELECT * FROM metrics WHERE ts < NOW()Compare(ts, Cast(CurrentTimestamp → Timestamp))now_in_predicate_lowers_to_current_timestampSELECT (SELECT max(latency) FROM metrics) FROM hostsProject[ScalarSubquery(..)]overScan hostsscalar_subquery_in_projection_lowers_to_a_scalar_subquery_item… WHERE bytes > (SELECT AVG(bytes) FROM metrics)Filter(Compare(.., ScalarSubquery))overScanscalar_subquery_in_predicate_lowers_through_a_cross_join… WHERE EXISTS (SELECT 1 FROM hosts)Filter(Exists), thenJoin(Semi, true)after canonicalizeexists_and_in_subqueries_lower_to_scalar_filter_conjuncts,an_uncorrelated_exists_is_an_unconditional_semi_joinNOT EXISTS (… h.service = m.service)Join(Anti)built in the frontendnot_exists_lowers_to_an_anti_joincount(nullable_value)Countwith measure filterIsNotNullcount_null_semantics_become_a_measure_filterSUM(bytes * 2), MIN(bytes * 2)a_shared_expression_is_materialized_onceIN (SELECT service, region FROM hosts)a_multi_column_in_subquery_is_rejectedIN (… WHERE h.region = m.service)a_correlated_in_subquery_is_rejected… OVER (… GROUPS BETWEEN 2 PRECEDING AND CURRENT ROW)groups_frame_is_rejectedcount(nullable_value) … GROUP BY ROLLUP(value)UnsupportedFeature(no measure filter inside multi-level grouping)count_null_semantics_become_a_measure_filterVerification
crates/frontend-commonresolver tests;crates/frontend-sql/tests/unified_sql_lowering.rs(accepted and rejected cases above, pluscoerced_literal_is_folded:latency < 100on aDOUBLEcolumn lowers tolatency < 100.0).i64::MIN/MAX, run through DataFusion and through this lowering (with the feat(runtime): export timed plans as PhysicalASAPDAG and compile them #541 runtime where it supports the shape, otherwise a reference interpreter of the IR, which agreed with the runtime on all 90 queries both could run). Joins,NOT IN,EXISTS, NULL ordering,LIMIT/OFFSET, window frames and integer division matched. The five lowering differences it found are fixed, each with a regression test (decimal casts, nested casts in reducers, narrow casts, ROLLUP key nullability,CAST(… AS VARCHAR)).unified_sql_lowering.rsthat fails without the fix (qualified ORDER BY / reducer arguments over a self-join, DESC percentile, typed NULL cast, non-literal LIMIT, value- and type-changing casts in reducers, window modifiers,LIKE … ESCAPE, the DataFusion 54 decimal comparison and duration check).cargo fmt --check, workspace all-target Clippy with-D warnings, andcargo test --workspace(1,795 tests, 0 failures) on the PR head.Architectural decisions
unifiedmodule, no bridge. The new lowering lives beside the legacy one and is promoted at the cutover (refactor(planner): plan the workload on the unified operator IR #542); the legacy path is removed in refactor(ir): remove legacy scaffolding and finish tooling migration #543. Rejected: converting the legacyQueryExprgraph intoOperatorNode, which would carry over its scalar-as-operator shapes.asap-frontend-common. SQL and PromQL resolve names toColumnIdthe same way, as docs: define unified operators and SQL/PromQL scalar boundaries #511 §2.1 leaves to the frontend. Rejected: resolving inside each frontend.TypeCoercionplus a numeric literal-cast fold, not the wholeSimplifyExpressionsrule. The full rule also produces timestamp literals and the~regex operator, which lowering does not support yet (SQL lowering: run DataFusion's SimplifyExpressions (needs IR timestamp literals and regex operators) #650).EXISTSis lowered to a semi/anti join in the frontend; other correlated subqueries are rejected rather than approximated.Limitations and follow-up
unifiedto the root API (planner cutover) and deleting the legacy SQL path (cleanup PR).EXISTS,DISTINCT ON, GROUPS frames and the other docs: define unified operators and SQL/PromQL scalar boundaries #511 §3.4 gaps. These are rejected.ORDER BY b.kafter a join whose sides share a bare name is now rejected; lowering it correctly is SQL: lower ORDER BY on a qualified column after a join (ambiguous names are rejected now) #652./andcountIfstill follow DataFusion: SQL: ClickHouse dialect NULL ordering, division and countIf follow DataFusion #655.Stack: tracker #528 · base: main · next: #540.
Human review — do not complete with an agent
🤖 Generated with Claude Code
https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW