Skip to content

Commit c1de25b

Browse files
zzylolclaude
andauthored
feat(sql): extract function-name catalog, generalize uniqExact/countIf rewrite (#227)
Pulls the SQL aggregate-function name -> canonical AggIntent mapping out of frontend-sql::lower_agg_intent's hand-written match and into a new independent crate, asap-sql-function-catalog (issue #225), structured after tobilg/polyglot's polyglot-sql-function-catalogs: flat data (name, arity, canonical semantic), no dependency on the SQL front end or DataFusion. lower_agg_intent keeps only call-site logic that isn't a function of the name alone (the DISTINCT rule, the bare-column reducer-argument rule, phi extraction, the ambient AccuracyTarget). Also generalizes uniqExact's stub-UDAF + FunctionRewrite mechanism (#221) so a new ClickHouse-only builtin DataFusion doesn't know at all is a catalog data entry (name, arity, RewriteKind) rather than a new one-off Rust type and a new one-off stub-UDAF function. uniqExact and the new countIf entry both go through the single ClickHouseBuiltinRewrite + clickhouse_builtin_stub_udaf mechanism now. countIf(cond) rewrites to sum(CASE WHEN cond THEN 1 ELSE 0 END) rather than count(...) FILTER (WHERE cond), since AggIntent::Count never consults its argument (it always means "row count") and would silently drop the filter; summing a 0/1 indicator keeps cond observable through the existing Sum path, including the general non-column-argument materialization lower_aggregate already does (#110). uniqExact's own behavior is unchanged (same signature, same rewrite target) and its existing corpus tests (bgp_analytics, #221) pass unmodified. countIf support moves 13 bgp_jan2024_workload corpus queries out of Category::Plan (12 now lower end to end, one hits a second, pre-existing NotImplemented gap) -- the pinned tally is updated accordingly. New direct unit tests cover countIf's derived-column shape and composition with GROUP BY. Deliberately out of scope (per the issue): generating the catalog from a live ClickHouse/DataFusion introspection source (item 3 -- flagged in the issue as needing a decision on where such tooling would run). Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 07c99e3 commit c1de25b

9 files changed

Lines changed: 588 additions & 106 deletions

File tree

‎Cargo.lock‎

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
[workspace]
22
members = [
33
"crates/types",
4+
"crates/sql-function-catalog",
45
"crates/asap-aware-mapping",
56
"crates/frontend-promql",
67
"crates/frontend-sql",

‎crates/frontend-sql/Cargo.toml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ version = "0.1.0"
44
edition = "2021"
55

66
# SQL front end: L1 (parse + plan via DataFusion) → L2 relational, then the
7-
# shared L2→L3 converter — both in asap-types. Pulls DataFusion only — never promql-parser.
7+
# shared L2→L3 converter — both in asap-types. Pulls DataFusion, plus the
8+
# independent SQL function-name catalog (asap-sql-function-catalog, issue
9+
# #225) it consults when lowering an aggregate call — never promql-parser.
810
[dependencies]
911
asap-types = { path = "../types" }
12+
asap-sql-function-catalog = { path = "../sql-function-catalog" }
1013
datafusion = "43"
1114

1215
[dev-dependencies]

‎crates/frontend-sql/src/lib.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@ pub async fn lower_sql(
3636
/// Lower a single SQL query string under an explicit [`SqlDialect`].
3737
///
3838
/// `ClickhouseSQL` parses via sqlparser's vendored `ClickHouseDialect`
39-
/// (array-lambda syntax, `arr[-1]` indexing) — it does not teach DataFusion's
40-
/// planner any ClickHouse-only builtin functions, which still fail to plan.
39+
/// (array-lambda syntax, `arr[-1]` indexing). It also teaches DataFusion's
40+
/// planner the ClickHouse-only builtin functions listed in
41+
/// `asap_sql_function_catalog::CLICKHOUSE_BUILTINS` (`uniqExact`, `countIf`)
42+
/// — every other ClickHouse-only builtin still fails to plan.
4143
/// `ElasticSQL` has no vendored parser and always returns `UnsupportedDialect`.
4244
pub async fn lower_sql_dialect(
4345
query: &str,

‎crates/frontend-sql/src/sql/mod.rs‎

Lines changed: 157 additions & 98 deletions
Large diffs are not rendered by default.

‎crates/frontend-sql/tests/bgp_jan2024_workload/bgp_jan2024_workload.rs‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,15 +160,20 @@ async fn corpus_lowering_matches_the_pinned_aggregate_tally() {
160160
gap was added/removed: {tally:?}"
161161
);
162162
};
163-
expect(Category::Lowered, 85);
164-
expect(Category::Plan, 105);
163+
// `countIf` support (issue #225 -- `ClickHouseBuiltinRewrite`, catalog-
164+
// driven the same way `uniqExact` (#221) already was) clears the
165+
// "unknown function: countif" `Plan` failure for 13 queries: 12 now
166+
// lower end to end, and a 13th plans far enough to hit a second,
167+
// pre-existing gap (map/array index access, `NotImplemented`).
168+
expect(Category::Lowered, 97);
169+
expect(Category::Plan, 92);
165170
expect(Category::Schema, 0);
166171
expect(Category::Parse, 0);
167172
// One query that used to fail at `uniqExact` (`Plan`) now clears that
168-
// hurdle -- `UniqExactRewrite` in `sql/mod.rs` rewrites it to
173+
// hurdle -- `ClickHouseBuiltinRewrite` in `sql/mod.rs` rewrites it to
169174
// `COUNT(DISTINCT ...)` before `lower_plan` runs -- and plans far enough
170175
// to hit a second, pre-existing gap: map/array index access.
171-
expect(Category::NotImplemented, 4);
176+
expect(Category::NotImplemented, 5);
172177
expect(Category::UnsupportedFeature, 6);
173178
expect(Category::Other, 0);
174179
}

‎crates/frontend-sql/tests/sql_lowering.rs‎

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
//! the shared `resolve_root` produces the positional, resolved canonical
66
//! tree (the same resolver the PromQL path uses).
77
8-
use asap_frontend_sql::{lower_sql, SqlCatalog};
8+
use asap_frontend_sql::{lower_sql, lower_sql_dialect, SqlCatalog};
99
use asap_types::pre_asap::schema::{Column, DataType, Schema};
1010
use asap_types::pre_asap::{
1111
AggIntent, CompareOpKind, GroupKeys, JoinKind, QueryExpr, Reduction, ScalarValue, Source,
1212
WindowFuncKind,
1313
};
1414
use asap_types::types::AccuracyTarget;
15+
use asap_types::workload::SqlDialect;
1516

1617
fn col(name: &str, dtype: DataType) -> Column {
1718
Column::new(name, dtype, false)
@@ -1392,3 +1393,76 @@ async fn array_agg_is_deliberately_rejected() {
13921393
"expected a clean UnsupportedAggregate, got {err}"
13931394
);
13941395
}
1396+
1397+
// ── Issue #225: catalog-driven ClickHouse builtins (countIf, generalizing
1398+
// uniqExact from #221) ───────────────────────────────────────────────────
1399+
1400+
async fn lower_clickhouse(sql: &str) -> QueryExpr {
1401+
lower_sql_dialect(
1402+
sql,
1403+
&catalog(),
1404+
SqlDialect::ClickhouseSQL,
1405+
AccuracyTarget::Exact,
1406+
)
1407+
.await
1408+
.unwrap_or_else(|e| panic!("lower failed for {sql:?}: {e}"))
1409+
}
1410+
1411+
#[tokio::test]
1412+
async fn count_if_lowers_to_a_sum_over_a_derived_indicator_column() {
1413+
// ClickHouse's `countIf(cond)` has no DataFusion equivalent at all, so it
1414+
// goes through the same stub-UDAF + catalog-driven `FunctionRewrite`
1415+
// mechanism `uniqExact` (#221) does — rewritten, before `lower_agg_intent`
1416+
// ever runs, to `sum(CASE WHEN cond THEN 1 ELSE 0 END)`. Not a plain
1417+
// `count(...) FILTER (WHERE cond)`: `AggIntent::Count` never consults its
1418+
// argument (always a row count), so the filter would be silently dropped;
1419+
// summing a 0/1 indicator keeps `cond` observable through the ordinary
1420+
// `Sum` path instead.
1421+
let qe = lower_clickhouse("SELECT countIf(bytes > 100) AS big FROM metrics").await;
1422+
let (by, measures) = find_aggregate(&qe).expect("expected an Aggregate");
1423+
assert!(by.is_empty());
1424+
assert!(
1425+
matches!(measures.as_slice(), [AggIntent::Sum { col: Some(_) }]),
1426+
"expected a Sum bound to the derived indicator column, got {measures:?}"
1427+
);
1428+
let (_, materialized) = reducer_input_names(&qe);
1429+
assert!(
1430+
materialized,
1431+
"the indicator expression must be materialized in a Project beneath the Aggregate"
1432+
);
1433+
}
1434+
1435+
#[tokio::test]
1436+
async fn two_count_ifs_with_different_conditions_stay_distinct_reducers() {
1437+
// The corpus pattern (`countIf(operation = 'A'), countIf(operation = 'W')`
1438+
// in one GROUP BY) needs each call's own condition to survive as its own
1439+
// derived column, not collapse onto a shared one.
1440+
let qe = lower_clickhouse(
1441+
"SELECT service, countIf(bytes > 100) AS big, countIf(bytes <= 100) AS small \
1442+
FROM metrics GROUP BY service",
1443+
)
1444+
.await;
1445+
let (by, measures) = find_aggregate(&qe).expect("expected an Aggregate");
1446+
assert_eq!(*by, GroupKeys::by(vec![0]));
1447+
assert!(
1448+
matches!(
1449+
measures.as_slice(),
1450+
[
1451+
AggIntent::Sum { col: Some(a) },
1452+
AggIntent::Sum { col: Some(b) }
1453+
] if a != b
1454+
),
1455+
"expected two distinct Sum reducers, got {measures:?}"
1456+
);
1457+
}
1458+
1459+
#[tokio::test]
1460+
async fn count_if_composes_with_group_by() {
1461+
let qe = lower_clickhouse(
1462+
"SELECT service, countIf(bytes > 100) AS big FROM metrics GROUP BY service",
1463+
)
1464+
.await;
1465+
let (by, measures) = find_aggregate(&qe).expect("expected an Aggregate");
1466+
assert_eq!(*by, GroupKeys::by(vec![0]));
1467+
assert!(matches!(measures.as_slice(), [AggIntent::Sum { .. }]));
1468+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[package]
2+
name = "asap-sql-function-catalog"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
# The (SQL function name, arity) -> canonical AggIntent-shaped semantic
7+
# catalog `asap-frontend-sql` consults when lowering an aggregate call, plus
8+
# the ClickHouse-only-builtin -> native-DataFusion-shape rewrite table
9+
# (issue #225). No internal deps -- like `asap-types`, this is meant to be
10+
# consultable on its own (a future extraction tool populating it, a test
11+
# asserting a name is covered, ...) without pulling in DataFusion or the
12+
# front end that uses it. `asap-frontend-sql` depends on this crate, never
13+
# the other way around.
14+
[dependencies]

0 commit comments

Comments
 (0)