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
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 3, 2026 16:33
7e6d76f to
8a633ee
Compare
zzylol
force-pushed
the
stack/528-03-graph
branch
from
October 3, 2026 17:02
c30977a to
d02dd8e
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 3, 2026 17:02
8a633ee to
06eb517
Compare
zzylol
force-pushed
the
stack/528-03-graph
branch
from
October 3, 2026 17:11
d02dd8e to
725db8e
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 3, 2026 17:11
06eb517 to
69e4337
Compare
zzylol
force-pushed
the
stack/528-03-graph
branch
from
October 3, 2026 17:23
725db8e to
7be9e1a
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 3, 2026 17:23
69e4337 to
543314d
Compare
zzylol
force-pushed
the
stack/528-03-graph
branch
from
October 3, 2026 17:29
7be9e1a to
2d5b627
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 3, 2026 17:29
543314d to
0af6d2e
Compare
zzylol
force-pushed
the
stack/528-03-graph
branch
from
October 3, 2026 17:40
2d5b627 to
2cfb683
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 3, 2026 17:40
0af6d2e to
1f36004
Compare
zzylol
force-pushed
the
stack/528-03-graph
branch
from
October 3, 2026 19:32
2cfb683 to
ec425f9
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
2 times, most recently
from
October 3, 2026 19:45
2f7c76f to
e633197
Compare
zzylol
force-pushed
the
stack/528-03-graph
branch
from
October 3, 2026 19:45
ec425f9 to
4280239
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 3, 2026 19:47
e633197 to
230b940
Compare
zzylol
marked this pull request as draft
October 3, 2026 20:02
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 3, 2026 20:09
230b940 to
545ecc7
Compare
zzylol
force-pushed
the
stack/528-03-graph
branch
from
October 3, 2026 20:48
cbc4cf0 to
0f6f539
Compare
zzylol
force-pushed
the
stack/528-04-sql
branch
from
October 3, 2026 20:48
545ecc7 to
c2c329b
Compare
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.
Problem: no frontend produces the #511
OperatorNode/ScalarExprIR#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".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)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.What it leaves out: PromQL lowering (§3.3, #540; this PR only adds the resolver arms PromQL will use), planner cutover (callers stay on the legacy API), and the §3.4 gaps (correlated
IN/ scalar subqueries, GROUPS frames,DISTINCT ON), which are rejected.Proposed method
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, runsApplyFunctionRewritesand then DataFusion'sTypeCoercionanalyzer, so implicit conversions become explicitCasts. It rejects temporal subtraction that would produce aDurationtype. 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 literal fetch →n: None.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.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_planningis changed slightly), 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.Examples
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_filterOut of scope
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.Stack and validation
Revised logical foundation 3/5 · Previous: #537 · Next: #540 · Tracker: #528. Part of #528, #511, #509.
Review order: #567 → #560 → #537 → #539 → #540 → #561. This restack follows #560 and phase-free #537. SQL imports follow #536's
scalar_type_rulesnaming.Validation: the common-resolver tests pass, and both the existing and the unified SQL lowering suites pass, including 109 unified SQL tests. Workspace compilation, formatting, and workspace/all-target/all-feature Clippy pass. The full first-five tip passes 1,961 tests/doctests and workspace Clippy.
🤖 Generated with Claude Code