Skip to content

Commit 1388952

Browse files
authored
Allow to map null (#191)
1 parent d79eb95 commit 1388952

4 files changed

Lines changed: 58 additions & 4 deletions

File tree

doc/syntax/clause/draw.qmd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ The `mapping` can take one of three forms and all three can be mixed in the same
3737

3838
* *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 <aesthetic/property>` (implicit mapping).
3939
* *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.
40+
* `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.
4041

4142
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)
4243

src/execute/mod.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@ fn validate(layers: &[Layer], layer_schemas: &[Schema]) -> Result<()> {
142142
// Global Mapping & Color Splitting
143143
// =============================================================================
144144

145+
/// Check if an aesthetic value is a null sentinel (explicit removal marker)
146+
fn is_null_sentinel(value: &AestheticValue) -> bool {
147+
matches!(value, AestheticValue::Literal(crate::plot::ParameterValue::Null))
148+
}
149+
145150
/// Merge global mappings into layer aesthetics and expand wildcards
146151
///
147152
/// 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
191196

192197
// Clear wildcard flag since it's been resolved
193198
layer.mappings.wildcard = false;
199+
200+
// Remove null sentinel mappings (explicit "don't inherit" markers)
201+
layer
202+
.mappings
203+
.aesthetics
204+
.retain(|_, value| !is_null_sentinel(value));
194205
}
195206
}
196207
}
@@ -2250,4 +2261,33 @@ mod tests {
22502261
"line layer with facet column should not be expanded"
22512262
);
22522263
}
2264+
2265+
#[cfg(feature = "duckdb")]
2266+
#[test]
2267+
fn test_null_mapping_removes_global_aesthetic() {
2268+
// Global mapping sets fill=region, but second layer uses null AS fill to opt out
2269+
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
2270+
let query = r#"
2271+
SELECT 1 as x, 2 as y, 'A' as region
2272+
VISUALISE x, y, region AS fill
2273+
DRAW point
2274+
DRAW line MAPPING null AS fill
2275+
"#;
2276+
2277+
let result = prepare_data_with_reader(query, &reader).unwrap();
2278+
2279+
// Point layer (first) should have fill inherited from global
2280+
let point_layer = &result.specs[0].layers[0];
2281+
assert!(
2282+
point_layer.mappings.aesthetics.contains_key("fill"),
2283+
"point layer should inherit fill from global mapping"
2284+
);
2285+
2286+
// Line layer (second) should NOT have fill because of null AS fill
2287+
let line_layer = &result.specs[0].layers[1];
2288+
assert!(
2289+
!line_layer.mappings.aesthetics.contains_key("fill"),
2290+
"line layer should not have fill due to null AS fill"
2291+
);
2292+
}
22532293
}

src/parser/builder.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,11 @@ fn parse_literal_value(node: &Node, source: &SourceTree) -> Result<AestheticValu
166166
let child = node.child(0).unwrap();
167167
let value = parse_value_node(&child, source, "literal")?;
168168

169-
// Grammar ensures literals can't be arrays or nulls, but add safety check
170-
if matches!(value, ParameterValue::Array(_) | ParameterValue::Null) {
169+
// Arrays cannot be used as literal values in aesthetic mappings
170+
// (null is allowed as a sentinel to remove global mappings)
171+
if matches!(value, ParameterValue::Array(_)) {
171172
return Err(GgsqlError::ParseError(
172-
"Arrays and null cannot be used as literal values in aesthetic mappings".to_string(),
173+
"Arrays cannot be used as literal values in aesthetic mappings".to_string(),
173174
));
174175
}
175176

@@ -3341,6 +3342,17 @@ mod tests {
33413342
assert!(matches!(parsed2, AestheticValue::Literal(ParameterValue::Number(n)) if n == 42.0));
33423343
}
33433344

3345+
#[test]
3346+
fn test_parse_null_literal_value() {
3347+
// Test null literal (used to remove global mappings)
3348+
let source = make_source("VISUALISE DRAW point MAPPING null AS fill");
3349+
let root = source.root();
3350+
3351+
let literal_node = source.find_node(&root, "(literal_value) @lit").unwrap();
3352+
let parsed = parse_literal_value(&literal_node, &source).unwrap();
3353+
assert!(matches!(parsed, AestheticValue::Literal(ParameterValue::Null)));
3354+
}
3355+
33443356
// ========================================
33453357
// Coordinate System Inference Tests
33463358
// ========================================

tree-sitter-ggsql/grammar.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -675,7 +675,8 @@ module.exports = grammar({
675675
literal_value: $ => choice(
676676
$.string,
677677
$.number,
678-
$.boolean
678+
$.boolean,
679+
$.null_literal
679680
),
680681

681682
// SCALE clause - SCALE [TYPE] aesthetic [FROM ...] [TO ...] [VIA ...] [SETTING ...] [RENAMING ...]

0 commit comments

Comments
 (0)