From 9e641f0a78793c71714cd6ebd1a5501c51b3e041 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Thu, 12 Mar 2026 08:33:15 +0100 Subject: [PATCH] Allow to map null --- doc/syntax/clause/draw.qmd | 1 + src/execute/mod.rs | 40 ++++++++++++++++++++++++++++++++++++ src/parser/builder.rs | 18 +++++++++++++--- tree-sitter-ggsql/grammar.js | 3 ++- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/doc/syntax/clause/draw.qmd b/doc/syntax/clause/draw.qmd index 6aa0593a..0e4737af 100644 --- a/doc/syntax/clause/draw.qmd +++ b/doc/syntax/clause/draw.qmd @@ -37,6 +37,7 @@ The `mapping` can take one of three forms and all three can be mixed in the same * *Column name*: If you provide the name of a column in the layer data (or global data in the absence of layer data) then the values in that column are mapped to the aesthetic or property. If the name of the column is the same as the aesthetic or property you can provide it without the following `AS ` (implicit mapping). * *Constant*: If you provide a constant like a string, number, or boolean then this value is repeated for every record in the data and mapped to the given aesthetic or property. When mapping a constant you must use the explicit form since the aesthetic/property cannot be derived. +* `null`: If you map `null` to an aesthetic you prevent that aesthetic from being inherited from the global mapping without mapping any data to it. `null` can only be used with explicit mappings. If an asterisk is given (wildcard mapping) it indicate that every column in the layer data with a name matching a supported aesthetic or property are implicitly mapped to said aesthetic or property. If the aesthetic or property has been mapped elsewhere then that gains precedence (i.e. if writing `MAPPING *, revenue AS y` then y will take on the data in the revenue column even if a y column exist in the data) diff --git a/src/execute/mod.rs b/src/execute/mod.rs index 470a55cd..5d7a5608 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -142,6 +142,11 @@ fn validate(layers: &[Layer], layer_schemas: &[Schema]) -> Result<()> { // Global Mapping & Color Splitting // ============================================================================= +/// Check if an aesthetic value is a null sentinel (explicit removal marker) +fn is_null_sentinel(value: &AestheticValue) -> bool { + matches!(value, AestheticValue::Literal(crate::plot::ParameterValue::Null)) +} + /// Merge global mappings into layer aesthetics and expand wildcards /// /// This function performs smart wildcard expansion with schema awareness: @@ -191,6 +196,12 @@ fn merge_global_mappings_into_layers(specs: &mut [Plot], layer_schemas: &[Schema // Clear wildcard flag since it's been resolved layer.mappings.wildcard = false; + + // Remove null sentinel mappings (explicit "don't inherit" markers) + layer + .mappings + .aesthetics + .retain(|_, value| !is_null_sentinel(value)); } } } @@ -2220,4 +2231,33 @@ mod tests { "line layer with facet column should not be expanded" ); } + + #[cfg(feature = "duckdb")] + #[test] + fn test_null_mapping_removes_global_aesthetic() { + // Global mapping sets fill=region, but second layer uses null AS fill to opt out + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT 1 as x, 2 as y, 'A' as region + VISUALISE x, y, region AS fill + DRAW point + DRAW line MAPPING null AS fill + "#; + + let result = prepare_data_with_reader(query, &reader).unwrap(); + + // Point layer (first) should have fill inherited from global + let point_layer = &result.specs[0].layers[0]; + assert!( + point_layer.mappings.aesthetics.contains_key("fill"), + "point layer should inherit fill from global mapping" + ); + + // Line layer (second) should NOT have fill because of null AS fill + let line_layer = &result.specs[0].layers[1]; + assert!( + !line_layer.mappings.aesthetics.contains_key("fill"), + "line layer should not have fill due to null AS fill" + ); + } } diff --git a/src/parser/builder.rs b/src/parser/builder.rs index e475f613..a36002d4 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -166,10 +166,11 @@ fn parse_literal_value(node: &Node, source: &SourceTree) -> Result choice( $.string, $.number, - $.boolean + $.boolean, + $.null_literal ), // SCALE clause - SCALE [TYPE] aesthetic [FROM ...] [TO ...] [VIA ...] [SETTING ...] [RENAMING ...]