Skip to content

Commit f9d2485

Browse files
committed
Merge commit 'd6f6765a705689fe4106f7fac9e35e5884a9d142'
2 parents 75cc028 + d6f6765 commit f9d2485

12 files changed

Lines changed: 518 additions & 66 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,7 @@ pub struct Theme {
449449
- `Layer::with_parameter(parameter, value)` - Add a geom parameter (builder pattern)
450450
- `Layer::get_column(aesthetic)` - Get column name for an aesthetic (if mapped to column)
451451
- `Layer::get_literal(aesthetic)` - Get literal value for an aesthetic (if literal)
452-
- `Layer::validate_required_aesthetics()` - Validate that required aesthetics are present for the geom type
452+
- `Layer::validate_mapping()` - Validate that required aesthetics are present for the geom type
453453

454454
**Type conversions:**
455455

src/execute/mod.rs

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,18 @@ use crate::reader::DuckDBReader;
4949
/// - Partition_by columns exist in schema
5050
/// - Remapping target aesthetics are supported by geom
5151
/// - Remapping source columns are valid stat columns for geom
52-
fn validate(layers: &[Layer], layer_schemas: &[Schema]) -> Result<()> {
52+
fn validate(
53+
layers: &[Layer],
54+
layer_schemas: &[Schema],
55+
aesthetic_context: &Option<AestheticContext>,
56+
) -> Result<()> {
5357
for (idx, (layer, schema)) in layers.iter().zip(layer_schemas.iter()).enumerate() {
5458
let schema_columns: HashSet<&str> = schema.iter().map(|c| c.name.as_str()).collect();
5559
let supported = layer.geom.aesthetics().supported();
5660

5761
// Validate required aesthetics for this geom
5862
layer
59-
.validate_required_aesthetics()
63+
.validate_mapping(aesthetic_context, false)
6064
.map_err(|e| GgsqlError::ValidationError(format!("Layer {}: {}", idx + 1, e)))?;
6165

6266
// Validate SETTING parameters are valid for this geom
@@ -1029,7 +1033,11 @@ pub fn prepare_data_with_reader<R: Reader>(query: &str, reader: &R) -> Result<Pr
10291033

10301034
// Validate all layers against their schemas
10311035
// This must happen BEFORE build_layer_query because stat transforms remove consumed aesthetics
1032-
validate(&specs[0].layers, &layer_schemas)?;
1036+
validate(
1037+
&specs[0].layers,
1038+
&layer_schemas,
1039+
&specs[0].aesthetic_context,
1040+
)?;
10331041

10341042
// Create scales for all mapped aesthetics that don't have explicit SCALE clauses
10351043
scale::create_missing_scales(&mut specs[0]);
@@ -2503,8 +2511,8 @@ mod tests {
25032511
match result {
25042512
Err(GgsqlError::ValidationError(msg)) => {
25052513
assert!(
2506-
msg.contains("pos2"),
2507-
"Error should mention missing pos2 aesthetic: {}",
2514+
msg.contains("y"),
2515+
"Error should mention missing y aesthetic: {}",
25082516
msg
25092517
);
25102518
}
@@ -2645,4 +2653,88 @@ mod tests {
26452653
"line layer should not have fill due to null AS fill"
26462654
);
26472655
}
2656+
2657+
// ========================================================================
2658+
// Validation Error Message Tests (User-facing aesthetic names)
2659+
// ========================================================================
2660+
2661+
#[cfg(feature = "duckdb")]
2662+
#[test]
2663+
fn test_validation_error_shows_user_facing_names_for_missing_aesthetics() {
2664+
// Test that validation errors show user-facing names (x, y) instead of internal (pos1, pos2)
2665+
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
2666+
2667+
reader
2668+
.connection()
2669+
.execute(
2670+
"CREATE TABLE test_data AS SELECT * FROM (VALUES (1, 2)) AS t(a, b)",
2671+
duckdb::params![],
2672+
)
2673+
.unwrap();
2674+
2675+
// Query missing required aesthetic 'y' - should show 'y' not 'pos2'
2676+
let query = r#"
2677+
SELECT * FROM test_data
2678+
VISUALISE
2679+
DRAW point MAPPING a AS x
2680+
"#;
2681+
2682+
let result = prepare_data_with_reader(query, &reader);
2683+
assert!(result.is_err(), "Expected validation error");
2684+
2685+
let err_msg = match result {
2686+
Err(e) => e.to_string(),
2687+
Ok(_) => panic!("Expected error"),
2688+
};
2689+
assert!(
2690+
err_msg.contains("`y`"),
2691+
"Error should mention user-facing name 'y', got: {}",
2692+
err_msg
2693+
);
2694+
assert!(
2695+
!err_msg.contains("pos2"),
2696+
"Error should not mention internal name 'pos2', got: {}",
2697+
err_msg
2698+
);
2699+
}
2700+
2701+
#[cfg(feature = "duckdb")]
2702+
#[test]
2703+
fn test_validation_error_shows_user_facing_names_for_unsupported_aesthetics() {
2704+
// Test that validation errors show user-facing names for unsupported aesthetics
2705+
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
2706+
2707+
reader
2708+
.connection()
2709+
.execute(
2710+
"CREATE TABLE test_data AS SELECT * FROM (VALUES (1, 2, 3)) AS t(a, b, c)",
2711+
duckdb::params![],
2712+
)
2713+
.unwrap();
2714+
2715+
// Query with unsupported aesthetic 'xmin' for point - should show 'xmin' not 'pos1min'
2716+
let query = r#"
2717+
SELECT * FROM test_data
2718+
VISUALISE
2719+
DRAW point MAPPING a AS x, b AS y, c AS xmin
2720+
"#;
2721+
2722+
let result = prepare_data_with_reader(query, &reader);
2723+
assert!(result.is_err(), "Expected validation error");
2724+
2725+
let err_msg = match result {
2726+
Err(e) => e.to_string(),
2727+
Ok(_) => panic!("Expected error"),
2728+
};
2729+
assert!(
2730+
err_msg.contains("`xmin`"),
2731+
"Error should mention user-facing name 'xmin', got: {}",
2732+
err_msg
2733+
);
2734+
assert!(
2735+
!err_msg.contains("pos1min"),
2736+
"Error should not mention internal name 'pos1min', got: {}",
2737+
err_msg
2738+
);
2739+
}
26482740
}

src/plot/aesthetic.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,36 @@ impl AestheticContext {
240240
}
241241
}
242242

243+
/// Map internal aesthetic to user-facing name (reverse of map_user_to_internal).
244+
///
245+
/// Positional: "pos1" → "x", "pos2min" → "ymin", "pos1" → "theta" (for polar)
246+
/// Facet: "facet1" → "panel" (wrap), "facet1" → "row" (grid), "facet2" → "column" (grid)
247+
/// Non-positional: "color" → "color" (unchanged)
248+
///
249+
/// Returns None if the internal aesthetic is not recognized.
250+
pub fn map_internal_to_user(&self, internal_aesthetic: &str) -> String {
251+
// Check internal facet (facet1, facet2)
252+
if let Some(idx) = self
253+
.internal_facet
254+
.iter()
255+
.position(|i| i == internal_aesthetic)
256+
{
257+
return self.user_facet[idx].to_string();
258+
}
259+
260+
// Check internal positional (pos1, pos1min, pos2, etc.)
261+
// Iterate through user_to_internal to find reverse mapping
262+
for (user, internal) in &self.user_to_internal {
263+
if internal == internal_aesthetic {
264+
return user.to_string();
265+
}
266+
}
267+
268+
// Non-positional aesthetics (color, size, etc.)
269+
// Internal is the same as external
270+
internal_aesthetic.to_string()
271+
}
272+
243273
// === Checking (O(1) HashMap lookups) ===
244274

245275
/// Check if internal aesthetic is primary positional (pos1, pos2, ...)
@@ -633,6 +663,70 @@ mod tests {
633663
assert_eq!(ctx.primary_internal_positional("color"), Some("color"));
634664
}
635665

666+
#[test]
667+
fn test_aesthetic_context_internal_to_user_cartesian() {
668+
let ctx = AestheticContext::from_static(&["x", "y"], &[]);
669+
670+
// Primary aesthetics
671+
assert_eq!(ctx.map_internal_to_user("pos1"), "x");
672+
assert_eq!(ctx.map_internal_to_user("pos2"), "y");
673+
674+
// Variants
675+
assert_eq!(ctx.map_internal_to_user("pos1min"), "xmin");
676+
assert_eq!(ctx.map_internal_to_user("pos1max"), "xmax");
677+
assert_eq!(ctx.map_internal_to_user("pos1end"), "xend");
678+
assert_eq!(ctx.map_internal_to_user("pos2min"), "ymin");
679+
assert_eq!(ctx.map_internal_to_user("pos2max"), "ymax");
680+
assert_eq!(ctx.map_internal_to_user("pos2end"), "yend");
681+
682+
// Non-positional aesthetics remain unchanged
683+
assert_eq!(ctx.map_internal_to_user("color"), "color");
684+
assert_eq!(ctx.map_internal_to_user("size"), "size");
685+
assert_eq!(ctx.map_internal_to_user("fill"), "fill");
686+
}
687+
688+
#[test]
689+
fn test_aesthetic_context_internal_to_user_polar() {
690+
let ctx = AestheticContext::from_static(&["theta", "radius"], &[]);
691+
692+
// Primary aesthetics map to polar names
693+
assert_eq!(ctx.map_internal_to_user("pos1"), "theta");
694+
assert_eq!(ctx.map_internal_to_user("pos2"), "radius");
695+
696+
// Variants
697+
assert_eq!(ctx.map_internal_to_user("pos1end"), "thetaend");
698+
assert_eq!(ctx.map_internal_to_user("pos2min"), "radiusmin");
699+
assert_eq!(ctx.map_internal_to_user("pos2max"), "radiusmax");
700+
}
701+
702+
#[test]
703+
fn test_aesthetic_context_internal_to_user_facets() {
704+
// Wrap facet (panel)
705+
let ctx_wrap = AestheticContext::from_static(&["x", "y"], &["panel"]);
706+
assert_eq!(ctx_wrap.map_internal_to_user("facet1"), "panel");
707+
708+
// Grid facet (row, column)
709+
let ctx_grid = AestheticContext::from_static(&["x", "y"], &["row", "column"]);
710+
assert_eq!(ctx_grid.map_internal_to_user("facet1"), "row");
711+
assert_eq!(ctx_grid.map_internal_to_user("facet2"), "column");
712+
}
713+
714+
#[test]
715+
fn test_aesthetic_context_roundtrip() {
716+
// Test that user -> internal -> user roundtrips correctly
717+
let ctx = AestheticContext::from_static(&["x", "y"], &["panel"]);
718+
719+
// Positional
720+
let internal = ctx.map_user_to_internal("x").unwrap();
721+
assert_eq!(ctx.map_internal_to_user(internal), "x");
722+
723+
let internal = ctx.map_user_to_internal("ymin").unwrap();
724+
assert_eq!(ctx.map_internal_to_user(internal), "ymin");
725+
726+
// Facet
727+
let internal = ctx.map_user_to_internal("panel").unwrap();
728+
assert_eq!(ctx.map_internal_to_user(internal), "panel");
729+
}
636730
#[test]
637731
fn test_parse_positional() {
638732
// Primary positional

src/plot/layer/geom/area.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ impl GeomTrait for Area {
2727
("opacity", DefaultAestheticValue::Number(0.8)),
2828
("linewidth", DefaultAestheticValue::Number(1.0)),
2929
("linetype", DefaultAestheticValue::String("solid")),
30+
("pos2end", DefaultAestheticValue::Delayed),
3031
],
3132
}
3233
}

src/plot/layer/geom/bar.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ impl GeomTrait for Bar {
3636
defaults: &[
3737
("pos1", DefaultAestheticValue::Null), // Optional - stat may provide
3838
("pos2", DefaultAestheticValue::Null), // Optional - stat may compute
39+
("pos2end", DefaultAestheticValue::Delayed),
3940
("weight", DefaultAestheticValue::Null),
4041
("fill", DefaultAestheticValue::String("black")),
4142
("stroke", DefaultAestheticValue::String("black")),

src/plot/layer/geom/density.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ impl GeomTrait for Density {
4949
("linewidth", DefaultAestheticValue::Number(1.0)),
5050
("linetype", DefaultAestheticValue::String("solid")),
5151
("pos2", DefaultAestheticValue::Delayed), // Computed by stat
52+
("pos2end", DefaultAestheticValue::Delayed),
5253
],
5354
}
5455
}

src/plot/layer/geom/histogram.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ impl GeomTrait for Histogram {
3434
// pos2 and pos1end are produced by stat_histogram but not valid for manual MAPPING
3535
("pos2", DefaultAestheticValue::Delayed),
3636
("pos1end", DefaultAestheticValue::Delayed),
37+
("pos2end", DefaultAestheticValue::Delayed), // baseline value
3738
],
3839
}
3940
}

src/plot/layer/geom/mod.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,4 +529,77 @@ mod tests {
529529
let deserialized: Geom = serde_json::from_str(&json).unwrap();
530530
assert_eq!(deserialized.geom_type(), GeomType::Point);
531531
}
532+
533+
#[test]
534+
fn test_default_remappings_are_in_aesthetics() {
535+
// Test that every aesthetic in default_remappings() exists in aesthetics().defaults
536+
// This ensures that remapped aesthetics are properly declared (usually as Delayed)
537+
538+
let all_geom_types = [
539+
GeomType::Point,
540+
GeomType::Line,
541+
GeomType::Path,
542+
GeomType::Bar,
543+
GeomType::Area,
544+
GeomType::Rect,
545+
GeomType::Polygon,
546+
GeomType::Ribbon,
547+
GeomType::Histogram,
548+
GeomType::Density,
549+
GeomType::Smooth,
550+
GeomType::Boxplot,
551+
GeomType::Violin,
552+
GeomType::Text,
553+
GeomType::Segment,
554+
GeomType::Arrow,
555+
GeomType::Rule,
556+
GeomType::Linear,
557+
GeomType::ErrorBar,
558+
];
559+
560+
// This test is rigged to trigger a compiler error when new variants are added.
561+
// Add the new layer to both the array above and as match arm below.
562+
let _exhaustive_check = |t: GeomType| match t {
563+
GeomType::Point
564+
| GeomType::Line
565+
| GeomType::Path
566+
| GeomType::Bar
567+
| GeomType::Area
568+
| GeomType::Rect
569+
| GeomType::Polygon
570+
| GeomType::Ribbon
571+
| GeomType::Histogram
572+
| GeomType::Density
573+
| GeomType::Smooth
574+
| GeomType::Boxplot
575+
| GeomType::Violin
576+
| GeomType::Text
577+
| GeomType::Segment
578+
| GeomType::Arrow
579+
| GeomType::Rule
580+
| GeomType::Linear
581+
| GeomType::ErrorBar => {}
582+
};
583+
584+
for geom_type in all_geom_types {
585+
let geom = Geom::from_type(geom_type);
586+
let remappings = geom.default_remappings();
587+
let aesthetics = geom.aesthetics();
588+
589+
// Collect all aesthetic names from aesthetics().defaults
590+
let aesthetic_names: std::collections::HashSet<&str> =
591+
aesthetics.defaults.iter().map(|(name, _)| *name).collect();
592+
593+
// Check each remapping name exists in aesthetics
594+
for (name, _) in remappings.defaults {
595+
assert!(
596+
aesthetic_names.contains(name),
597+
"Geom '{}' has '{}' in default_remappings() but not in aesthetics().defaults. \
598+
Add it as DefaultAestheticValue::Delayed if it's a stat-produced aesthetic.",
599+
geom_type,
600+
name
601+
);
602+
}
603+
}
604+
}
532605
}

0 commit comments

Comments
 (0)