Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/syntax/clause/draw.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -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 <aesthetic/property>` (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)

Expand Down
40 changes: 40 additions & 0 deletions src/execute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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));
}
}
}
Expand Down Expand Up @@ -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"
);
}
}
18 changes: 15 additions & 3 deletions src/parser/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,11 @@ fn parse_literal_value(node: &Node, source: &SourceTree) -> Result<AestheticValu
let child = node.child(0).unwrap();
let value = parse_value_node(&child, source, "literal")?;

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

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

#[test]
fn test_parse_null_literal_value() {
// Test null literal (used to remove global mappings)
let source = make_source("VISUALISE DRAW point MAPPING null AS fill");
let root = source.root();

let literal_node = source.find_node(&root, "(literal_value) @lit").unwrap();
let parsed = parse_literal_value(&literal_node, &source).unwrap();
assert!(matches!(parsed, AestheticValue::Literal(ParameterValue::Null)));
}

// ========================================
// Coordinate System Inference Tests
// ========================================
Expand Down
3 changes: 2 additions & 1 deletion tree-sitter-ggsql/grammar.js
Original file line number Diff line number Diff line change
Expand Up @@ -668,7 +668,8 @@ module.exports = grammar({
literal_value: $ => choice(
$.string,
$.number,
$.boolean
$.boolean,
$.null_literal
),

// SCALE clause - SCALE [TYPE] aesthetic [FROM ...] [TO ...] [VIA ...] [SETTING ...] [RENAMING ...]
Expand Down
Loading