Skip to content

Commit a08003f

Browse files
authored
Fix global mappings and aesthetic aliases in validation (#306)
* Merge global mappings during validation * Centralise aesthetic aliases * cargo fmt
1 parent 81c1363 commit a08003f

3 files changed

Lines changed: 211 additions & 75 deletions

File tree

src/execute/mod.rs

Lines changed: 48 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -178,17 +178,14 @@ fn merge_global_mappings_into_layers(specs: &mut [Plot], layer_schemas: &[Schema
178178
let schema_columns: HashSet<&str> = schema.iter().map(|c| c.name.as_str()).collect();
179179

180180
// 1. First merge explicit global aesthetics (layer overrides global)
181-
// Note: "color"/"colour" are accepted even though not in supported,
182-
// because split_color_aesthetic will convert them to fill/stroke later
183181
// Note: facet aesthetics (panel, row, column) are also accepted,
184182
// as they apply to all layers regardless of geom support
185183
// Note: Use all_names (not supported) so that Delayed aesthetics like
186184
// pos2 on histogram can be targeted by explicit global mappings, matching
187185
// the behavior of layer-level MAPPING
188186
for (aesthetic, value) in &spec.global_mappings.aesthetics {
189-
let is_color_alias = matches!(aesthetic.as_str(), "color" | "colour");
190187
let is_facet_aesthetic = crate::plot::scale::is_facet_aesthetic(aesthetic.as_str());
191-
if all_names.contains(&aesthetic.as_str()) || is_color_alias || is_facet_aesthetic {
188+
if all_names.contains(&aesthetic.as_str()) || is_facet_aesthetic {
192189
layer
193190
.mappings
194191
.aesthetics
@@ -226,69 +223,59 @@ fn merge_global_mappings_into_layers(specs: &mut [Plot], layer_schemas: &[Schema
226223
}
227224
}
228225

229-
/// Let 'color' aesthetics fill defaults for the 'stroke' and 'fill' aesthetics.
230-
/// Also splits 'color' scale to 'fill' and 'stroke' scales.
231-
/// Removes 'color' from both mappings and scales after splitting to avoid
232-
/// non-deterministic behavior from HashMap iteration order.
233-
fn split_color_aesthetic(spec: &mut Plot) {
234-
// 1. Split color SCALE to fill/stroke scales
235-
if let Some(color_scale_idx) = spec.scales.iter().position(|s| s.aesthetic == "color") {
236-
let color_scale = spec.scales[color_scale_idx].clone();
237-
238-
// Add fill scale if not already present
239-
if !spec.scales.iter().any(|s| s.aesthetic == "fill") {
240-
let mut fill_scale = color_scale.clone();
241-
fill_scale.aesthetic = "fill".to_string();
242-
spec.scales.push(fill_scale);
243-
}
244-
245-
// Add stroke scale if not already present
246-
if !spec.scales.iter().any(|s| s.aesthetic == "stroke") {
247-
let mut stroke_scale = color_scale.clone();
248-
stroke_scale.aesthetic = "stroke".to_string();
249-
spec.scales.push(stroke_scale);
250-
}
251-
252-
// Remove the color scale
253-
spec.scales.remove(color_scale_idx);
254-
}
255-
256-
// 2. Split color mapping to fill/stroke in layers, then remove color
257-
for layer in &mut spec.layers {
258-
if let Some(color_value) = layer.mappings.aesthetics.get("color").cloned() {
259-
let aesthetics = layer.geom.aesthetics();
260-
261-
for &aes in &["stroke", "fill"] {
262-
if aesthetics.is_supported(aes) {
263-
layer
264-
.mappings
265-
.aesthetics
266-
.entry(aes.to_string())
267-
.or_insert(color_value.clone());
226+
/// Resolve aesthetic aliases in a plot specification.
227+
///
228+
/// For each alias defined in [`AESTHETIC_ALIASES`], splits the alias in scales,
229+
/// layer mappings, and layer parameters into the concrete target aesthetics
230+
/// (only where the geom supports them). Removes the alias after splitting to
231+
/// avoid non-deterministic behavior from HashMap iteration order.
232+
fn resolve_aesthetic_aliases(spec: &mut Plot) {
233+
use crate::plot::layer::geom::types::AESTHETIC_ALIASES;
234+
235+
for &(alias, targets) in AESTHETIC_ALIASES {
236+
// 1. Split alias SCALE to target scales
237+
if let Some(idx) = spec.scales.iter().position(|s| s.aesthetic == alias) {
238+
let alias_scale = spec.scales[idx].clone();
239+
for &target in targets {
240+
if !spec.scales.iter().any(|s| s.aesthetic == target) {
241+
let mut target_scale = alias_scale.clone();
242+
target_scale.aesthetic = target.to_string();
243+
spec.scales.push(target_scale);
268244
}
269245
}
270-
271-
// Remove color after splitting
272-
layer.mappings.aesthetics.remove("color");
246+
spec.scales.remove(idx);
273247
}
274-
}
275248

276-
// 3. Split color parameter (SETTING) to fill/stroke in layers
277-
for layer in &mut spec.layers {
278-
if let Some(color_value) = layer.parameters.get("color").cloned() {
249+
// 2. Split alias mapping and parameters in each layer
250+
for layer in &mut spec.layers {
279251
let aesthetics = layer.geom.aesthetics();
280252

281-
for &aes in &["stroke", "fill"] {
282-
if aesthetics.is_supported(aes) {
283-
layer
284-
.parameters
285-
.entry(aes.to_string())
286-
.or_insert(color_value.clone());
253+
// Split mapping
254+
if let Some(value) = layer.mappings.aesthetics.get(alias).cloned() {
255+
for &target in targets {
256+
if aesthetics.is_supported(target) {
257+
layer
258+
.mappings
259+
.aesthetics
260+
.entry(target.to_string())
261+
.or_insert(value.clone());
262+
}
287263
}
264+
layer.mappings.aesthetics.remove(alias);
288265
}
289266

290-
// Remove color after splitting
291-
layer.parameters.remove("color");
267+
// Split parameter (SETTING)
268+
if let Some(value) = layer.parameters.get(alias).cloned() {
269+
for &target in targets {
270+
if aesthetics.is_supported(target) {
271+
layer
272+
.parameters
273+
.entry(target.to_string())
274+
.or_insert(value.clone());
275+
}
276+
}
277+
layer.parameters.remove(alias);
278+
}
292279
}
293280
}
294281
}
@@ -1024,10 +1011,10 @@ pub fn prepare_data_with_reader(query: &str, reader: &dyn Reader) -> Result<Prep
10241011
// because transformation happens in builder.rs right after parsing
10251012
merge_global_mappings_into_layers(&mut specs, &layer_schemas);
10261013

1027-
// Split 'color' aesthetic to 'fill' and 'stroke' early in the pipeline
1028-
// This must happen before validation so fill/stroke are validated (not color)
1014+
// Resolve aesthetic aliases (e.g., 'color' 'fill'/'stroke') early in the pipeline
1015+
// This must happen before validation so concrete aesthetics are validated
10291016
for spec in &mut specs {
1030-
split_color_aesthetic(spec);
1017+
resolve_aesthetic_aliases(spec);
10311018
}
10321019

10331020
// Add literal (constant) columns to type info programmatically

src/plot/layer/geom/types.rs

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ pub const POSITION_VALUES: &[&str] = &["identity", "stack", "dodge", "jitter"];
1818
/// Closed interval side values for binned data
1919
pub const CLOSED_VALUES: &[&str] = &["left", "right"];
2020

21+
/// Aesthetic aliases: user-facing names that resolve to concrete aesthetics.
22+
///
23+
/// An alias is considered supported if any of its target aesthetics are supported
24+
/// by a geom. For example, `color` resolves to `stroke` and/or `fill` — so any geom
25+
/// that supports either `stroke` or `fill` also accepts `color`.
26+
///
27+
/// Note: Spelling variants (`colour`/`col` → `color`) are handled separately at parse
28+
/// time by `normalise_aes_name()` in `parser/builder.rs`.
29+
pub const AESTHETIC_ALIASES: &[(&str, &[&str])] = &[("color", &["stroke", "fill"])];
30+
2131
/// Default aesthetic values for a geom type
2232
///
2333
/// This struct describes which aesthetics a geom supports, requires, and their default values.
@@ -32,21 +42,37 @@ pub struct DefaultAesthetics {
3242
}
3343

3444
impl DefaultAesthetics {
35-
/// Get all aesthetic names (including Delayed)
45+
/// Get all aesthetic names (including Delayed and aliases)
3646
pub fn names(&self) -> Vec<&'static str> {
37-
self.defaults.iter().map(|(name, _)| *name).collect()
47+
let mut result: Vec<&'static str> = self.defaults.iter().map(|(name, _)| *name).collect();
48+
// Include alias names if any of their targets are in the defaults
49+
for &(alias, targets) in AESTHETIC_ALIASES {
50+
if targets.iter().any(|t| result.contains(t)) {
51+
result.push(alias);
52+
}
53+
}
54+
result
3855
}
3956

4057
/// Get supported aesthetic names (excludes Delayed, for MAPPING validation)
4158
///
42-
/// Returns the literal names from defaults. For bidirectional position checking,
43-
/// use `is_supported()` which handles pos1/pos2 equivalence.
59+
/// Returns the literal names from defaults plus any aliases whose targets are
60+
/// supported. For bidirectional position checking, use `is_supported()` which
61+
/// handles pos1/pos2 equivalence.
4462
pub fn supported(&self) -> Vec<&'static str> {
45-
self.defaults
63+
let mut result: Vec<&'static str> = self
64+
.defaults
4665
.iter()
4766
.filter(|(_, value)| !matches!(value, DefaultAestheticValue::Delayed))
4867
.map(|(name, _)| *name)
49-
.collect()
68+
.collect();
69+
// Include alias names if any of their targets are supported
70+
for &(alias, targets) in AESTHETIC_ALIASES {
71+
if targets.iter().any(|t| result.contains(t)) {
72+
result.push(alias);
73+
}
74+
}
75+
result
5076
}
5177

5278
/// Get required aesthetic names (those marked as Required)
@@ -66,7 +92,8 @@ impl DefaultAesthetics {
6692
/// Check if an aesthetic is supported (not Delayed)
6793
///
6894
/// Position aesthetics are bidirectional: if pos1* is supported, pos2* is also
69-
/// considered supported (and vice versa).
95+
/// considered supported (and vice versa). Aliases (e.g., `color`) are supported
96+
/// if any of their target aesthetics are supported.
7097
pub fn is_supported(&self, name: &str) -> bool {
7198
// Check for direct match first
7299
let direct_match = self
@@ -86,6 +113,13 @@ impl DefaultAesthetics {
86113
});
87114
}
88115

116+
// Check if name is an alias that resolves to a supported aesthetic
117+
for &(alias, targets) in AESTHETIC_ALIASES {
118+
if alias == name {
119+
return targets.iter().any(|t| self.is_supported(t));
120+
}
121+
}
122+
89123
false
90124
}
91125

@@ -184,18 +218,20 @@ mod tests {
184218
assert_eq!(aes.get("yend"), Some(&DefaultAestheticValue::Delayed));
185219
assert_eq!(aes.get("nonexistent"), None);
186220

187-
// Test names() - includes all aesthetics
221+
// Test names() - includes all aesthetics + aliases
188222
let names = aes.names();
189-
assert_eq!(names.len(), 6);
223+
assert_eq!(names.len(), 7); // 6 defaults + "color" alias (has stroke+fill)
190224
assert!(names.contains(&"x"));
191225
assert!(names.contains(&"yend"));
226+
assert!(names.contains(&"color")); // alias resolved from stroke+fill
192227

193-
// Test supported() - excludes Delayed
228+
// Test supported() - excludes Delayed, includes aliases
194229
let supported = aes.supported();
195-
assert_eq!(supported.len(), 5);
230+
assert_eq!(supported.len(), 6); // 5 non-delayed + "color" alias
196231
assert!(supported.contains(&"x"));
197232
assert!(supported.contains(&"size"));
198233
assert!(supported.contains(&"fill"));
234+
assert!(supported.contains(&"color")); // alias
199235
assert!(!supported.contains(&"yend")); // Delayed excluded
200236

201237
// Test required() - only Required variants
@@ -208,6 +244,7 @@ mod tests {
208244
// Test is_supported() - efficient membership check
209245
assert!(aes.is_supported("x"));
210246
assert!(aes.is_supported("size"));
247+
assert!(aes.is_supported("color")); // alias: has stroke+fill
211248
assert!(!aes.is_supported("yend")); // Delayed not supported
212249
assert!(!aes.is_supported("nonexistent"));
213250

@@ -222,4 +259,42 @@ mod tests {
222259
assert!(!aes.is_required("size"));
223260
assert!(!aes.is_required("yend"));
224261
}
262+
263+
#[test]
264+
fn test_color_alias_requires_stroke_or_fill() {
265+
// Geom with neither stroke nor fill: color alias should NOT be supported
266+
let aes = DefaultAesthetics {
267+
defaults: &[
268+
("pos1", DefaultAestheticValue::Required),
269+
("pos2", DefaultAestheticValue::Required),
270+
("size", DefaultAestheticValue::Number(3.0)),
271+
],
272+
};
273+
274+
assert!(!aes.is_supported("color"));
275+
assert!(!aes.supported().contains(&"color"));
276+
assert!(!aes.names().contains(&"color"));
277+
278+
// Geom with only stroke: color alias should be supported
279+
let aes_stroke = DefaultAesthetics {
280+
defaults: &[
281+
("pos1", DefaultAestheticValue::Required),
282+
("stroke", DefaultAestheticValue::String("black")),
283+
],
284+
};
285+
286+
assert!(aes_stroke.is_supported("color"));
287+
assert!(aes_stroke.supported().contains(&"color"));
288+
289+
// Geom with only fill: color alias should be supported
290+
let aes_fill = DefaultAesthetics {
291+
defaults: &[
292+
("pos1", DefaultAestheticValue::Required),
293+
("fill", DefaultAestheticValue::String("black")),
294+
],
295+
};
296+
297+
assert!(aes_fill.is_supported("color"));
298+
assert!(aes_fill.supported().contains(&"color"));
299+
}
225300
}

src/validate.rs

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,9 +176,28 @@ pub fn validate(query: &str) -> Result<Validated> {
176176

177177
// Check required aesthetics
178178
// Note: Without schema data, we can only check if mappings exist,
179-
// not if the columns are valid. We skip this check for wildcards.
180-
if !layer.mappings.wildcard {
181-
if let Err(e) = layer.validate_mapping(&plot.aesthetic_context, false) {
179+
// not if the columns are valid. We skip this check for wildcards
180+
// (either layer or global).
181+
let is_annotation = matches!(
182+
layer.source,
183+
Some(crate::plot::types::DataSource::Annotation)
184+
);
185+
let has_wildcard =
186+
layer.mappings.wildcard || (!is_annotation && plot.global_mappings.wildcard);
187+
if !has_wildcard {
188+
// Merge global mappings into a temporary copy for validation
189+
// (mirrors execution-time merge, layer takes precedence)
190+
let mut merged = layer.clone();
191+
if !is_annotation {
192+
for (aesthetic, value) in &plot.global_mappings.aesthetics {
193+
merged
194+
.mappings
195+
.aesthetics
196+
.entry(aesthetic.clone())
197+
.or_insert(value.clone());
198+
}
199+
}
200+
if let Err(e) = merged.validate_mapping(&plot.aesthetic_context, false) {
182201
errors.push(ValidationError {
183202
message: format!("{}: {}", context, e),
184203
location: None,
@@ -282,4 +301,59 @@ mod tests {
282301
assert!(validated.valid());
283302
assert!(validated.errors().is_empty());
284303
}
304+
305+
#[test]
306+
fn test_validate_color_aesthetic_on_line() {
307+
// color should be valid on line geom (has stroke)
308+
let validated = validate(
309+
"SELECT 1 as x, 2 as y VISUALISE DRAW line MAPPING x AS x, y AS y, region AS color",
310+
)
311+
.unwrap();
312+
assert!(
313+
validated.valid(),
314+
"color should be accepted on line geom: {:?}",
315+
validated.errors()
316+
);
317+
}
318+
319+
#[test]
320+
fn test_validate_color_aesthetic_on_point() {
321+
// color should be valid on point geom (has stroke + fill)
322+
let validated = validate(
323+
"SELECT 1 as x, 2 as y VISUALISE DRAW point MAPPING x AS x, y AS y, cat AS color",
324+
)
325+
.unwrap();
326+
assert!(
327+
validated.valid(),
328+
"color should be accepted on point geom: {:?}",
329+
validated.errors()
330+
);
331+
}
332+
333+
#[test]
334+
fn test_validate_colour_spelling() {
335+
// British spelling 'colour' should work (normalized by parser to 'color')
336+
let validated = validate(
337+
"SELECT 1 as x, 2 as y VISUALISE DRAW line MAPPING x AS x, y AS y, region AS colour",
338+
)
339+
.unwrap();
340+
assert!(
341+
validated.valid(),
342+
"colour (British) should be accepted: {:?}",
343+
validated.errors()
344+
);
345+
}
346+
347+
#[test]
348+
fn test_validate_global_color_mapping() {
349+
// Global color mapping should validate correctly
350+
let validated =
351+
validate("SELECT 1 as x, 2 as y VISUALISE x AS x, y AS y, region AS color DRAW line")
352+
.unwrap();
353+
assert!(
354+
validated.valid(),
355+
"global color mapping should be accepted: {:?}",
356+
validated.errors()
357+
);
358+
}
285359
}

0 commit comments

Comments
 (0)