Skip to content

Commit 88a707b

Browse files
committed
fix aggregation of time-dependent layers
1 parent 564673c commit 88a707b

6 files changed

Lines changed: 111 additions & 1 deletion

File tree

src/plot/layer/geom/area.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ impl GeomTrait for Area {
5959
true
6060
}
6161

62+
fn aggregate_domain_aesthetics(&self) -> &'static [&'static str] {
63+
&["pos1"]
64+
}
65+
6266
fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool {
6367
true
6468
}
@@ -83,6 +87,7 @@ impl GeomTrait for Area {
8387
parameters,
8488
dialect,
8589
aesthetic_ctx,
90+
self.aggregate_domain_aesthetics(),
8691
)?
8792
} else {
8893
StatResult::Identity

src/plot/layer/geom/bar.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ impl GeomTrait for Bar {
108108
parameters,
109109
dialect,
110110
aesthetic_ctx,
111+
self.aggregate_domain_aesthetics(),
111112
);
112113
}
113114
stat_bar_count(query, schema, aesthetics, group_by)

src/plot/layer/geom/line.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ impl GeomTrait for Line {
4545
true
4646
}
4747

48+
fn aggregate_domain_aesthetics(&self) -> &'static [&'static str] {
49+
&["pos1"]
50+
}
51+
4852
fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool {
4953
true
5054
}
@@ -69,6 +73,7 @@ impl GeomTrait for Line {
6973
parameters,
7074
dialect,
7175
aesthetic_ctx,
76+
self.aggregate_domain_aesthetics(),
7277
)?
7378
} else {
7479
StatResult::Identity

src/plot/layer/geom/mod.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,17 @@ pub trait GeomTrait: std::fmt::Debug + std::fmt::Display + Send + Sync {
205205
false
206206
}
207207

208+
/// Aesthetics that the Aggregate stat must keep as group keys rather than
209+
/// aggregating, even if their bound column is continuous. This is for
210+
/// geoms like line/area/ribbon where one axis is the *domain* — the
211+
/// natural group identity of each row — and the user expects "summarise
212+
/// the other axis per domain value" without writing an explicit target.
213+
///
214+
/// Default empty; line/area/ribbon override to `&["pos1"]`.
215+
fn aggregate_domain_aesthetics(&self) -> &'static [&'static str] {
216+
&[]
217+
}
218+
208219
/// Apply statistical transformation to the layer query.
209220
///
210221
/// The default implementation dispatches to the Aggregate stat when
@@ -231,6 +242,7 @@ pub trait GeomTrait: std::fmt::Debug + std::fmt::Display + Send + Sync {
231242
parameters,
232243
dialect,
233244
aesthetic_ctx,
245+
self.aggregate_domain_aesthetics(),
234246
);
235247
}
236248
Ok(StatResult::Identity)

src/plot/layer/geom/ribbon.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ impl GeomTrait for Ribbon {
4444
true
4545
}
4646

47+
fn aggregate_domain_aesthetics(&self) -> &'static [&'static str] {
48+
&["pos1"]
49+
}
50+
4751
fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool {
4852
true
4953
}
@@ -68,6 +72,7 @@ impl GeomTrait for Ribbon {
6872
parameters,
6973
dialect,
7074
aesthetic_ctx,
75+
self.aggregate_domain_aesthetics(),
7176
)?
7277
} else {
7378
StatResult::Identity

src/plot/layer/geom/stat_aggregate.rs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,7 @@ pub fn apply(
602602
parameters: &HashMap<String, ParameterValue>,
603603
dialect: &dyn SqlDialect,
604604
aesthetic_ctx: &AestheticContext,
605+
domain_aesthetics: &[&'static str],
605606
) -> Result<StatResult> {
606607
let raw = match parameters.get("aggregate") {
607608
None | Some(ParameterValue::Null) => return Ok(StatResult::Identity),
@@ -656,6 +657,15 @@ pub fn apply(
656657
Some(c) => c.to_string(),
657658
None => continue, // literals & annotation columns pass through
658659
};
660+
// Geom-declared domain aesthetics (e.g. `pos1` for line/area/ribbon)
661+
// always become group keys — they identify each row, never get
662+
// aggregated, never get dropped.
663+
if domain_aesthetics.contains(&aes.as_str()) {
664+
if !kept_cols.contains(&col) {
665+
kept_cols.push(col);
666+
}
667+
continue;
668+
}
659669
let info = schema.iter().find(|c| c.name == col);
660670
let is_discrete = info.map(|c| c.is_discrete).unwrap_or(false);
661671
if is_discrete {
@@ -922,11 +932,31 @@ mod tests {
922932
schema: &Schema,
923933
group_by: &[String],
924934
dialect: &dyn SqlDialect,
935+
) -> Result<StatResult> {
936+
run_with_domain(params, aes, schema, group_by, dialect, &[])
937+
}
938+
939+
fn run_with_domain(
940+
params: ParameterValue,
941+
aes: &Mappings,
942+
schema: &Schema,
943+
group_by: &[String],
944+
dialect: &dyn SqlDialect,
945+
domain: &[&'static str],
925946
) -> Result<StatResult> {
926947
let mut p = HashMap::new();
927948
p.insert("aggregate".to_string(), params);
928949
let ctx = cartesian_ctx();
929-
apply("SELECT * FROM t", schema, aes, group_by, &p, dialect, &ctx)
950+
apply(
951+
"SELECT * FROM t",
952+
schema,
953+
aes,
954+
group_by,
955+
&p,
956+
dialect,
957+
&ctx,
958+
domain,
959+
)
930960
}
931961

932962
fn arr(items: &[&str]) -> ParameterValue {
@@ -1138,6 +1168,7 @@ mod tests {
11381168
&p,
11391169
&InlineQuantileDialect,
11401170
&ctx,
1171+
&[],
11411172
)
11421173
.unwrap();
11431174
assert_eq!(result, StatResult::Identity);
@@ -1451,6 +1482,57 @@ mod tests {
14511482
}
14521483
}
14531484

1485+
#[test]
1486+
fn domain_aesthetic_kept_as_group_key_even_when_continuous() {
1487+
// Regression test for the line/area/ribbon case: the user writes
1488+
// DRAW line ... SETTING aggregate => ('y:min', 'y:max')
1489+
// and expects pos1 (the continuous time-axis column) to be a group
1490+
// key, not a dropped numeric mapping. The geom declares pos1 as a
1491+
// domain aesthetic; the stat keeps it as a group column.
1492+
let mut aes = Mappings::new();
1493+
aes.insert("pos1", col("__ggsql_aes_pos1__"));
1494+
aes.insert("pos2", col("__ggsql_aes_pos2__"));
1495+
let schema = schema_for(&[
1496+
("__ggsql_aes_pos1__", false), // continuous, would be dropped without the domain hint
1497+
("__ggsql_aes_pos2__", false),
1498+
]);
1499+
let result = run_with_domain(
1500+
arr(&["y:min", "y:max"]),
1501+
&aes,
1502+
&schema,
1503+
&[],
1504+
&InlineQuantileDialect,
1505+
&["pos1"],
1506+
)
1507+
.unwrap();
1508+
match result {
1509+
StatResult::Transformed {
1510+
query,
1511+
stat_columns,
1512+
consumed_aesthetics,
1513+
..
1514+
} => {
1515+
// pos1 is in the GROUP BY, not aggregated.
1516+
assert!(
1517+
query.contains("GROUP BY \"__ggsql_aes_pos1__\""),
1518+
"{}",
1519+
query
1520+
);
1521+
assert!(!query.contains("MIN(\"__ggsql_aes_pos1__\")"));
1522+
assert!(!query.contains("MAX(\"__ggsql_aes_pos1__\")"));
1523+
// pos2 is exploded into MIN and MAX branches.
1524+
assert!(query.contains("MIN(\"__ggsql_aes_pos2__\")"));
1525+
assert!(query.contains("MAX(\"__ggsql_aes_pos2__\")"));
1526+
// pos1 is NOT consumed (kept), pos2 IS consumed.
1527+
assert!(!consumed_aesthetics.contains(&"pos1".to_string()));
1528+
assert!(consumed_aesthetics.contains(&"pos2".to_string()));
1529+
// synthetic aggregate column emitted in the explosion case.
1530+
assert!(stat_columns.contains(&"aggregate".to_string()));
1531+
}
1532+
_ => panic!("expected Transformed"),
1533+
}
1534+
}
1535+
14541536
#[test]
14551537
fn explosion_with_range_geom_two_defaults() {
14561538
// For ribbon: pos1 + pos2min (lower) + pos2max (upper).

0 commit comments

Comments
 (0)