use crate::naming;
use crate::plot::layer::geom::Geom;
use crate::plot::projection::resolve_coord;
use crate::plot::scale::{color_to_hex, is_color_aesthetic, is_user_facet_aesthetic, Transform};
use crate::plot::*;
use crate::{GgsqlError, Result};
use std::collections::HashMap;
use tree_sitter::Node;
use super::SourceTree;
fn extract_name_value_nodes<'a>(node: &'a Node<'a>, context: &str) -> Result<(Node<'a>, Node<'a>)> {
let name_node = node
.child_by_field_name("name")
.ok_or_else(|| GgsqlError::ParseError(format!("Missing 'name' field in {}", context)))?;
let value_node = node
.child_by_field_name("value")
.ok_or_else(|| GgsqlError::ParseError(format!("Missing 'value' field in {}", context)))?;
Ok((name_node, value_node))
}
fn parse_string_node(node: &Node, source: &SourceTree) -> String {
let text = source.get_text(node);
let unquoted = text.trim_matches(|c| c == '\'' || c == '"');
process_escape_sequences(unquoted)
}
fn process_escape_sequences(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => result.push('\n'),
Some('t') => result.push('\t'),
Some('r') => result.push('\r'),
Some('\\') => result.push('\\'),
Some('\'') => result.push('\''),
Some('"') => result.push('"'),
Some(other) => {
result.push('\\');
result.push(other);
}
None => result.push('\\'), }
} else {
result.push(c);
}
}
result
}
fn parse_number_node(node: &Node, source: &SourceTree) -> Result<f64> {
let text = source.get_text(node);
text.parse::<f64>()
.map_err(|e| GgsqlError::ParseError(format!("Failed to parse number '{}': {}", text, e)))
}
fn parse_boolean_node(node: &Node, source: &SourceTree) -> bool {
let text = source.get_text(node);
text == "true"
}
fn parse_infinity_node(node: &Node, source: &SourceTree) -> f64 {
let text = source.get_text(node);
if text.starts_with('-') {
f64::NEG_INFINITY
} else {
f64::INFINITY
}
}
fn parse_array_node(node: &Node, source: &SourceTree) -> Result<Vec<ArrayElement>> {
let mut values = Vec::new();
let query = "(array_element) @elem";
let array_elements = source.find_nodes(node, query);
for array_element in array_elements {
let elem_child = array_element.child(0).ok_or_else(|| {
GgsqlError::ParseError("Invalid array_element: missing child".to_string())
})?;
let value = match elem_child.kind() {
"string" => ArrayElement::String(parse_string_node(&elem_child, source)),
"number" => ArrayElement::Number(parse_number_node(&elem_child, source)?),
"boolean" => ArrayElement::Boolean(parse_boolean_node(&elem_child, source)),
"null_literal" => ArrayElement::Null,
"infinity" => ArrayElement::Number(parse_infinity_node(&elem_child, source)),
_ => {
return Err(GgsqlError::ParseError(format!(
"Invalid array element type: {}",
elem_child.kind()
)));
}
};
values.push(value);
}
Ok(values)
}
fn parse_value_node(node: &Node, source: &SourceTree, context: &str) -> Result<ParameterValue> {
match node.kind() {
"string" => {
let value = parse_string_node(node, source);
Ok(ParameterValue::String(value))
}
"number" => {
let num = parse_number_node(node, source)?;
Ok(ParameterValue::Number(num))
}
"boolean" => {
let bool_val = parse_boolean_node(node, source);
Ok(ParameterValue::Boolean(bool_val))
}
"array" => {
let values = parse_array_node(node, source)?;
Ok(ParameterValue::Array(values))
}
"null_literal" => Ok(ParameterValue::Null),
"infinity" => Ok(ParameterValue::Number(parse_infinity_node(node, source))),
_ => Err(GgsqlError::ParseError(format!(
"Unexpected {} value type: {}",
context,
node.kind()
))),
}
}
fn parse_data_source(node: &Node, source: &SourceTree) -> DataSource {
match node.kind() {
"string" => {
let path = parse_string_node(node, source);
DataSource::FilePath(path)
}
_ => {
let text = source.get_text(node);
DataSource::Identifier(text)
}
}
}
fn parse_literal_value(node: &Node, source: &SourceTree) -> Result<AestheticValue> {
let child = node.child(0).unwrap();
let value = parse_value_node(&child, source, "literal")?;
if matches!(value, ParameterValue::Array(_)) {
return Err(GgsqlError::ParseError(
"Arrays cannot be used as literal values in aesthetic mappings".to_string(),
));
}
Ok(AestheticValue::Literal(value))
}
pub fn build_ast(source: &SourceTree) -> Result<Vec<Plot>> {
let root = source.root();
if root.kind() != "query" {
return Err(GgsqlError::ParseError(format!(
"Expected 'query' root node, got '{}'",
root.kind()
)));
}
let query = "(sql_portion) @sql";
let sql_portion_node = source.find_node(&root, query);
let last_is_select = if let Some(sql_node) = sql_portion_node {
check_last_statement_is_select(&sql_node, source)
} else {
false
};
let query = "(visualise_statement) @viz";
let viz_nodes = source.find_nodes(&root, query);
let mut specs = Vec::new();
for viz_node in viz_nodes {
let spec = build_visualise_statement(&viz_node, source)?;
if spec.source.is_some() && last_is_select {
return Err(GgsqlError::ParseError(
"Cannot use VISUALISE FROM when the last SQL statement is SELECT. \
Use either 'SELECT ... VISUALISE' or remove the SELECT and use \
'VISUALISE FROM ...'."
.to_string(),
));
}
specs.push(spec);
}
if specs.is_empty() {
return Err(GgsqlError::ParseError(
"No VISUALISE statements found in query".to_string(),
));
}
Ok(specs)
}
fn build_visualise_statement(node: &Node, source: &SourceTree) -> Result<Plot> {
let mut spec = Plot::new();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"VISUALISE" | "VISUALIZE" | "FROM" => {
continue;
}
"global_mapping" => {
spec.global_mappings = parse_mapping(&child, source)?;
}
"wildcard_mapping" => {
spec.global_mappings.wildcard = true;
}
"from_clause" => {
let query = "(table_ref table: (_) @table)";
if let Some(table_node) = source.find_node(&child, query) {
spec.source = Some(parse_data_source(&table_node, source));
}
}
"viz_clause" => {
process_viz_clause(&child, source, &mut spec)?;
}
_ => {
continue;
}
}
}
let layer_mappings: Vec<&Mappings> = spec.layers.iter().map(|l| &l.mappings).collect();
let layer_geom_types: Vec<GeomType> = spec.layers.iter().map(|l| l.geom.geom_type()).collect();
if let Some(inferred) = resolve_coord(
spec.project.as_ref(),
&spec.global_mappings,
&layer_mappings,
&layer_geom_types,
)
.map_err(GgsqlError::ParseError)?
{
spec.project = Some(inferred);
}
spec.initialize_aesthetic_context();
spec.transform_aesthetics_to_internal();
Ok(spec)
}
fn process_viz_clause(node: &Node, source: &SourceTree, spec: &mut Plot) -> Result<()> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"draw_clause" => {
let layer = build_layer(&child, source)?;
spec.layers.push(layer);
}
"place_clause" => {
let layer = build_place_layer(&child, source)?;
spec.layers.push(layer);
}
"scale_clause" => {
let scale = build_scale(&child, source)?;
spec.scales.push(scale);
}
"facet_clause" => {
spec.facet = Some(build_facet(&child, source)?);
}
"project_clause" => {
spec.project = Some(build_project(&child, source)?);
}
"label_clause" => {
let new_labels = build_labels(&child, source)?;
if let Some(ref mut existing_labels) = spec.labels {
for (key, value) in new_labels.labels {
existing_labels.labels.insert(key, value);
}
} else {
spec.labels = Some(new_labels);
}
}
_ => {
continue;
}
}
}
Ok(())
}
fn parse_mapping(node: &Node, source: &SourceTree) -> Result<Mappings> {
let mut mappings = Mappings::new();
let query = "(mapping_element) @elem";
let mapping_nodes = source.find_nodes(node, query);
for mapping_node in mapping_nodes {
parse_mapping_element(&mapping_node, source, &mut mappings)?;
}
Ok(mappings)
}
fn parse_mapping_element(node: &Node, source: &SourceTree, mappings: &mut Mappings) -> Result<()> {
let child = node.child(0).ok_or_else(|| {
GgsqlError::ParseError("Invalid mapping_element: missing child".to_string())
})?;
match child.kind() {
"wildcard_mapping" => {
mappings.wildcard = true;
}
"explicit_mapping" => {
let (aesthetic, value) = parse_explicit_mapping(&child, source)?;
mappings.insert(normalise_aes_name(&aesthetic), value);
}
"implicit_mapping" | "identifier" => {
let name = naming::unquote_ident(&source.get_text(&child));
mappings.insert(
normalise_aes_name(&name),
AestheticValue::standard_column(&name),
);
}
_ => {
return Err(GgsqlError::ParseError(format!(
"Invalid mapping_element child type: {}",
child.kind()
)));
}
}
Ok(())
}
fn parse_explicit_mapping(node: &Node, source: &SourceTree) -> Result<(String, AestheticValue)> {
let (name_node, value_node) = extract_name_value_nodes(node, "explicit mapping")?;
let aesthetic = source.get_text(&name_node);
let value_child = value_node.child(0).ok_or_else(|| {
GgsqlError::ParseError("Invalid explicit mapping: missing value".to_string())
})?;
let value = match value_child.kind() {
"column_reference" => {
AestheticValue::standard_column(naming::unquote_ident(&source.get_text(&value_child)))
}
"literal_value" => parse_literal_value(&value_child, source)?,
_ => {
return Err(GgsqlError::ParseError(format!(
"Invalid explicit mapping value type: {}",
value_child.kind()
)));
}
};
Ok((aesthetic, value))
}
fn build_layer(node: &Node, source: &SourceTree) -> Result<Layer> {
let mut geom = Geom::point(); let mut aesthetics = Mappings::new();
let mut remappings = Mappings::new();
let mut parameters = Parameters::new();
let mut partition_by = Vec::new();
let mut filter = None;
let mut order_by = None;
let mut layer_source = None;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"geom_type" => {
let geom_text = source.get_text(&child);
geom = parse_geom_type(&geom_text)?;
}
"mapping_clause" => {
aesthetics = parse_mapping(&child, source)?;
layer_source = child
.child_by_field_name("layer_source")
.map(|src| parse_data_source(&src, source));
}
"remapping_clause" => {
remappings = parse_mapping(&child, source)?;
}
"setting_clause" => {
parameters = parse_setting_clause(&child, source)?;
}
"partition_clause" => {
partition_by = parse_partition_clause(&child, source)?;
}
"filter_clause" => {
filter = Some(parse_filter_clause(&child, source)?);
}
"order_clause" => {
order_by = Some(parse_order_clause(&child, source)?);
}
_ => {
continue;
}
}
}
let position = if let Some(ParameterValue::String(pos_str)) = parameters.remove("position") {
pos_str.parse().unwrap()
} else {
geom.default_params()
.iter()
.find(|p| p.name == "position")
.and_then(|p| p.to_parameter_value())
.and_then(|v| match v {
ParameterValue::String(s) => Some(s.parse().unwrap()),
_ => None,
})
.unwrap_or_default()
};
let mut layer = Layer::new(geom);
layer.position = position;
layer.mappings = aesthetics;
layer.remappings = remappings;
layer.parameters = parameters;
layer.partition_by = partition_by;
layer.filter = filter;
layer.order_by = order_by;
layer.source = layer_source;
Ok(layer)
}
fn build_place_layer(node: &Node, source: &SourceTree) -> Result<Layer> {
let mut layer = build_layer(node, source)?;
layer.source = Some(DataSource::Annotation);
Ok(layer)
}
fn parse_setting_clause(node: &Node, source: &SourceTree) -> Result<Parameters> {
let mut parameters = Parameters::new();
let query = "(parameter_assignment) @param";
let param_nodes = source.find_nodes(node, query);
for param_node in param_nodes {
let (param, mut value) = parse_parameter_assignment(¶m_node, source)?;
if is_color_aesthetic(¶m) {
if let ParameterValue::String(color) = value {
value =
ParameterValue::String(color_to_hex(&color).map_err(GgsqlError::ParseError)?);
}
}
parameters.insert(param, value);
}
Ok(parameters)
}
fn parse_parameter_assignment(
node: &Node,
source: &SourceTree,
) -> Result<(String, ParameterValue)> {
let (name_node, value_node) = extract_name_value_nodes(node, "parameter assignment")?;
let param_name = normalise_aes_name(&source.get_text(&name_node));
let param_value = if let Some(value_child) = value_node.child(0) {
parse_value_node(&value_child, source, "parameter")?
} else {
return Err(GgsqlError::ParseError(
"Invalid parameter assignment: empty parameter_value".to_string(),
));
};
Ok((param_name, param_value))
}
fn parse_partition_clause(node: &Node, source: &SourceTree) -> Result<Vec<String>> {
let query = r#"
(partition_columns
(identifier) @col)
"#;
Ok(source.find_texts(node, query))
}
fn parse_filter_clause(node: &Node, source: &SourceTree) -> Result<SqlExpression> {
let query = "(filter_expression) @expr";
if let Some(filter_text) = source.find_text(node, query) {
Ok(SqlExpression::new(filter_text.trim().to_string()))
} else {
Err(GgsqlError::ParseError(
"Could not find filter expression in filter clause".to_string(),
))
}
}
fn parse_order_clause(node: &Node, source: &SourceTree) -> Result<SqlExpression> {
let query = "(order_expression) @expr";
if let Some(order_text) = source.find_text(node, query) {
Ok(SqlExpression::new(order_text.trim().to_string()))
} else {
Err(GgsqlError::ParseError(
"Could not find order expression in order clause".to_string(),
))
}
}
fn parse_geom_type(text: &str) -> Result<Geom> {
match text.to_lowercase().as_str() {
"point" => Ok(Geom::point()),
"line" => Ok(Geom::line()),
"path" => Ok(Geom::path()),
"bar" => Ok(Geom::bar()),
"area" => Ok(Geom::area()),
"tile" => Ok(Geom::tile()),
"polygon" => Ok(Geom::polygon()),
"ribbon" => Ok(Geom::ribbon()),
"histogram" => Ok(Geom::histogram()),
"density" => Ok(Geom::density()),
"smooth" => Ok(Geom::smooth()),
"boxplot" => Ok(Geom::boxplot()),
"violin" => Ok(Geom::violin()),
"text" => Ok(Geom::text()),
"segment" => Ok(Geom::segment()),
"arrow" => Ok(Geom::arrow()),
"rule" => Ok(Geom::rule()),
"range" => Ok(Geom::range()),
"spatial" => Ok(Geom::spatial()),
_ => Err(GgsqlError::ParseError(format!(
"Unknown geom type: {}",
text
))),
}
}
fn build_scale(node: &Node, source: &SourceTree) -> Result<Scale> {
let mut aesthetic = String::new();
let mut scale_type: Option<ScaleType> = None;
let mut input_range: Option<Vec<ArrayElement>> = None;
let mut explicit_input_range = false;
let mut output_range: Option<OutputRange> = None;
let mut transform: Option<Transform> = None;
let mut explicit_transform = false;
let mut properties = Parameters::new();
let mut label_mapping: Option<HashMap<String, Option<String>>> = None;
let mut label_template = "{}".to_string();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"SCALE" | "SETTING" | "=>" | "," | "FROM" | "TO" | "VIA" | "RENAMING" => continue, "scale_type_identifier" => {
let type_text = source.get_text(&child);
scale_type = Some(parse_scale_type_identifier(&type_text)?);
}
"aesthetic_name" => {
aesthetic = normalise_aes_name(&source.get_text(&child));
}
"scale_from_clause" => {
input_range = Some(parse_scale_from_clause(&child, source)?);
explicit_input_range = true;
}
"scale_to_clause" => {
output_range = Some(parse_scale_to_clause(&child, source)?);
}
"scale_via_clause" => {
transform = Some(parse_scale_via_clause(&child, source)?);
explicit_transform = true;
}
"setting_clause" => {
properties = parse_setting_clause(&child, source)?;
}
"scale_renaming_clause" => {
let (mappings, template) = parse_scale_renaming_clause(&child, source)?;
if !mappings.is_empty() {
label_mapping = Some(mappings);
}
label_template = template;
}
_ => {}
}
}
if aesthetic.is_empty() {
return Err(GgsqlError::ParseError(
"Scale clause missing aesthetic name".to_string(),
));
}
if is_color_aesthetic(&aesthetic) {
if let Some(OutputRange::Array(ref elements)) = output_range {
let hex_codes: Vec<ArrayElement> = elements
.iter()
.map(|elem| {
if let ArrayElement::String(color) = elem {
color_to_hex(color).map(ArrayElement::String)
} else {
Ok(elem.clone())
}
})
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(GgsqlError::ParseError)?;
output_range = Some(OutputRange::Array(hex_codes));
}
}
if is_user_facet_aesthetic(&aesthetic) && output_range.is_some() {
return Err(GgsqlError::ValidationError(format!(
"SCALE {}: facet variables cannot have output ranges (TO clause)",
aesthetic
)));
}
Ok(Scale {
aesthetic,
scale_type,
input_range,
explicit_input_range,
output_range,
transform,
explicit_transform,
properties,
resolved: false,
label_mapping,
label_template,
})
}
fn parse_scale_type_identifier(text: &str) -> Result<ScaleType> {
match text.to_lowercase().as_str() {
"continuous" => Ok(ScaleType::continuous()),
"discrete" => Ok(ScaleType::discrete()),
"binned" => Ok(ScaleType::binned()),
"ordinal" => Ok(ScaleType::ordinal()),
"identity" => Ok(ScaleType::identity()),
_ => Err(GgsqlError::ParseError(format!(
"Unknown scale type: '{}'. Valid types: continuous, discrete, binned, ordinal, identity",
text
))),
}
}
fn parse_scale_from_clause(node: &Node, source: &SourceTree) -> Result<Vec<ArrayElement>> {
let query = "(array) @arr";
let array_node = source
.find_node(node, query)
.ok_or_else(|| GgsqlError::ParseError("FROM clause missing array".to_string()))?;
parse_array_node(&array_node, source)
}
fn parse_scale_to_clause(node: &Node, source: &SourceTree) -> Result<OutputRange> {
let array_query = "(array) @arr";
if let Some(array_node) = source.find_node(node, array_query) {
let elements = parse_array_node(&array_node, source)?;
return Ok(OutputRange::Array(elements));
}
let ident_query = "[(identifier) (bare_identifier) (quoted_identifier)] @id";
if let Some(ident_node) = source.find_node(node, ident_query) {
let palette_name = source.get_text(&ident_node);
return Ok(OutputRange::Palette(palette_name));
}
Err(GgsqlError::ParseError(
"TO clause must contain either an array or identifier".to_string(),
))
}
fn parse_scale_via_clause(node: &Node, source: &SourceTree) -> Result<Transform> {
let query = "[(identifier) (bare_identifier) (quoted_identifier)] @id";
let ident_node = source.find_node(node, query).ok_or_else(|| {
GgsqlError::ParseError("VIA clause missing transform identifier".to_string())
})?;
let transform_name = source.get_text(&ident_node);
Transform::from_name(&transform_name).ok_or_else(|| {
GgsqlError::ParseError(format!(
"Unknown transform: '{}'. Valid transforms are: {}",
transform_name,
crate::and_list_quoted(crate::plot::scale::ALL_TRANSFORM_NAMES, '\'')
))
})
}
fn parse_scale_renaming_clause(
node: &Node,
source: &SourceTree,
) -> Result<(HashMap<String, Option<String>>, String)> {
let mut mappings = HashMap::new();
let mut template = "{}".to_string();
let query = "(renaming_assignment) @assign";
let assignment_nodes = source.find_nodes(node, query);
for assignment_node in assignment_nodes {
let (name_node, value_node) = extract_name_value_nodes(&assignment_node, "scale renaming")?;
let is_wildcard = name_node.kind() == "*";
let from_value = match name_node.kind() {
"*" => "*".to_string(),
"string" => parse_string_node(&name_node, source),
"number" => source.get_text(&name_node),
"null_literal" => "null".to_string(), _ => {
return Err(GgsqlError::ParseError(format!(
"Invalid 'from' type in scale renaming: {}",
name_node.kind()
)));
}
};
let to_value: Option<String> = match value_node.kind() {
"string" => Some(parse_string_node(&value_node, source)),
"null_literal" => None, _ => {
return Err(GgsqlError::ParseError(format!(
"Invalid 'to' type in scale renaming: {}",
value_node.kind()
)));
}
};
if is_wildcard {
if let Some(tmpl) = to_value {
template = tmpl;
}
} else {
mappings.insert(from_value, to_value);
}
}
Ok((mappings, template))
}
fn build_facet(node: &Node, source: &SourceTree) -> Result<Facet> {
let mut row_vars = Vec::new();
let mut column_vars = Vec::new();
let mut properties = Parameters::new();
let mut cursor = node.walk();
let mut next_vars_are_cols = false;
for child in node.children(&mut cursor) {
match child.kind() {
"FACET" => continue,
"facet_by" => {
next_vars_are_cols = true;
}
"facet_vars" => {
let vars = parse_facet_vars(&child, source)?;
if next_vars_are_cols {
column_vars = vars;
} else {
row_vars = vars;
}
}
"setting_clause" => {
properties = parse_setting_clause(&child, source)?;
}
_ => {}
}
}
let layout = if column_vars.is_empty() {
FacetLayout::Wrap {
variables: row_vars,
}
} else {
FacetLayout::Grid {
row: row_vars,
column: column_vars,
}
};
Ok(Facet {
layout,
properties,
resolved: false,
})
}
fn parse_facet_vars(node: &Node, source: &SourceTree) -> Result<Vec<String>> {
let query = "(identifier) @var";
Ok(source.find_texts(node, query))
}
fn build_project(node: &Node, source: &SourceTree) -> Result<Projection> {
let mut coord_type_name: Option<String> = None;
let mut properties = Parameters::new();
let mut user_aesthetics: Option<Vec<String>> = None;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"PROJECT" | "SETTING" | "TO" | "=>" | "," => continue,
"project_aesthetics" => {
let query = "(identifier) @aes";
user_aesthetics = Some(source.find_texts(&child, query));
}
"project_type" => {
coord_type_name = Some(source.get_text(&child).to_lowercase());
}
"project_properties" => {
let query = "(project_property) @prop";
let prop_nodes = source.find_nodes(&child, query);
for prop_node in prop_nodes {
let (prop_name, prop_value) =
parse_single_project_property(&prop_node, source)?;
properties.insert(prop_name, prop_value);
}
}
_ => {}
}
}
let coord = parse_coord_system(coord_type_name.as_deref(), &properties)?;
let aesthetics = if let Some(aes) = user_aesthetics {
let expected = coord.position_aesthetic_names().len();
if aes.len() != expected {
return Err(GgsqlError::ParseError(format!(
"PROJECT {} requires {} aesthetics, got {}",
coord.name(),
expected,
aes.len()
)));
}
validate_position_aesthetic_names(&aes)?;
aes
} else {
coord
.position_aesthetic_names()
.iter()
.map(|s| s.to_string())
.collect()
};
validate_project_properties(&coord, coord_type_name.as_deref(), &properties)?;
Ok(Projection {
coord,
aesthetics,
properties,
computed: Parameters::new(),
})
}
fn validate_position_aesthetic_names(names: &[String]) -> Result<()> {
use crate::plot::aesthetic::{MATERIAL_AESTHETICS, USER_FACET_AESTHETICS};
for name in names {
if MATERIAL_AESTHETICS.contains(&name.as_str()) {
return Err(GgsqlError::ParseError(format!(
"PROJECT aesthetic '{}' conflicts with material aesthetic. \
Choose a different name.",
name
)));
}
if USER_FACET_AESTHETICS.contains(&name.as_str()) {
return Err(GgsqlError::ParseError(format!(
"PROJECT aesthetic '{}' conflicts with facet aesthetic. \
Choose a different name.",
name
)));
}
}
Ok(())
}
fn parse_single_project_property(
node: &Node,
source: &SourceTree,
) -> Result<(String, ParameterValue)> {
let (name_node, value_node) = extract_name_value_nodes(node, "project property")?;
let prop_name = source.get_text(&name_node);
let prop_value = match value_node.kind() {
"string" | "number" | "boolean" | "array" => {
parse_value_node(&value_node, source, "project property")?
}
"identifier" => {
ParameterValue::String(source.get_text(&value_node))
}
_ => {
return Err(GgsqlError::ParseError(format!(
"Invalid project property value type: {}",
value_node.kind()
)));
}
};
Ok((prop_name, prop_value))
}
fn validate_project_properties(
coord: &Coord,
coord_type_name: Option<&str>,
properties: &Parameters,
) -> Result<()> {
coord
.resolve_properties(coord_type_name, properties)
.map_err(GgsqlError::ParseError)?;
Ok(())
}
fn parse_coord_system(name: Option<&str>, properties: &Parameters) -> Result<Coord> {
use crate::plot::projection::coord::map_projections::NAMED_PROJECTIONS;
match name {
Some("cartesian") | None => Ok(Coord::cartesian()),
Some("polar") => Ok(Coord::polar()),
Some("crs") => Ok(Coord::map("crs", properties)),
Some(n) if NAMED_PROJECTIONS.contains(&n) => Ok(Coord::map(n, properties)),
Some(n) => Err(GgsqlError::ParseError(format!("Unknown coord type: {}", n))),
}
}
fn build_labels(node: &Node, source: &SourceTree) -> Result<Labels> {
let mut labels = HashMap::new();
let query = "(label_assignment) @label";
let label_nodes = source.find_nodes(node, query);
for label_node in label_nodes {
let (name_node, value_node) = extract_name_value_nodes(&label_node, "label assignment")?;
let label_type = source.get_text(&name_node);
let label_value = match value_node.kind() {
"string" => Some(parse_string_node(&value_node, source)),
"null_literal" => None,
_ => {
return Err(GgsqlError::ParseError(format!(
"Label '{}' must have a string or null value, got: {}",
label_type,
value_node.kind()
)));
}
};
labels.insert(label_type, label_value);
}
Ok(Labels { labels })
}
fn check_last_statement_is_select(sql_portion_node: &Node, source: &SourceTree) -> bool {
let query = "(sql_statement) @stmt";
let statements = source.find_nodes(sql_portion_node, query);
let last_statement = statements.last();
if let Some(stmt) = last_statement {
let mut stmt_cursor = stmt.walk();
for child in stmt.children(&mut stmt_cursor) {
if child.kind() == "select_statement" {
return true;
} else if child.kind() == "with_statement" {
return with_statement_has_trailing_select(&child);
}
}
}
false
}
fn with_statement_has_trailing_select(with_node: &Node) -> bool {
let mut cursor = with_node.walk();
let mut seen_cte_definition = false;
for child in with_node.children(&mut cursor) {
if child.kind() == "cte_definition" {
seen_cte_definition = true;
} else if child.kind() == "select_statement" && seen_cte_definition {
return true;
}
}
false
}
pub fn normalise_aes_name(name: &str) -> String {
match name {
"col" | "colour" => "color".to_string(),
_ => name.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_test_query(query: &str) -> Result<Vec<Plot>> {
let source = SourceTree::new(query)?;
source.validate()?;
build_ast(&source)
}
#[test]
fn test_project_polar_with_start() {
let query = r#"
VISUALISE
DRAW bar MAPPING category AS x, value AS y
PROJECT TO polar SETTING start => 90
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let project = specs[0].project.as_ref().unwrap();
assert_eq!(project.coord.coord_kind(), CoordKind::Polar);
assert!(project.properties.contains_key("start"));
assert_eq!(
project.properties.get("start"),
Some(&ParameterValue::Number(90.0))
);
}
#[test]
fn test_project_explicit_aesthetics() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y
PROJECT x, y TO cartesian
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let project = specs[0].project.as_ref().unwrap();
assert_eq!(project.coord.coord_kind(), CoordKind::Cartesian);
assert_eq!(project.aesthetics, vec!["x".to_string(), "y".to_string()]);
}
#[test]
fn test_project_custom_aesthetics() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y
PROJECT myX, myY TO cartesian
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let project = specs[0].project.as_ref().unwrap();
assert_eq!(
project.aesthetics,
vec!["myX".to_string(), "myY".to_string()]
);
}
#[test]
fn test_project_default_aesthetics_cartesian() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y
PROJECT TO cartesian
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let project = specs[0].project.as_ref().unwrap();
assert_eq!(project.aesthetics, vec!["x".to_string(), "y".to_string()]);
}
#[test]
fn test_project_default_aesthetics_polar() {
let query = r#"
VISUALISE
DRAW bar MAPPING category AS angle, value AS radius
PROJECT TO polar
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let project = specs[0].project.as_ref().unwrap();
assert_eq!(
project.aesthetics,
vec!["radius".to_string(), "angle".to_string()]
);
}
#[test]
fn test_project_wrong_aesthetic_count() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x
PROJECT x TO cartesian
"#;
let result = parse_test_query(query);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("requires 2 aesthetics, got 1"));
}
#[test]
fn test_project_conflicting_aesthetic_name() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y
PROJECT color, fill TO cartesian
"#;
let result = parse_test_query(query);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err
.to_string()
.contains("conflicts with material aesthetic"));
}
#[test]
fn test_project_crs_bare() {
let query = r#"
VISUALISE
DRAW point MAPPING lon AS lon, lat AS lat
PROJECT TO crs
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let project = specs[0].project.as_ref().unwrap();
assert_eq!(project.coord.coord_kind(), CoordKind::Map);
assert_eq!(
project.aesthetics,
vec!["lon".to_string(), "lat".to_string()]
);
assert!(!project.properties.contains_key("target"));
}
#[test]
fn test_project_mercator_shorthand() {
let query = r#"
VISUALISE
DRAW point MAPPING lon AS lon, lat AS lat
PROJECT TO mercator
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let project = specs[0].project.as_ref().unwrap();
assert_eq!(project.coord.coord_kind(), CoordKind::Map);
let mp = project.coord.as_map_projection().unwrap();
assert_eq!(mp.proj_code(), "merc");
assert_eq!(mp.to_proj_str(), "+proj=merc +lon_0=0");
}
#[test]
fn test_project_shorthand_target_override_rejected() {
let query = r#"
VISUALISE
DRAW point MAPPING lon AS lon, lat AS lat
PROJECT TO mercator SETTING target => '+proj=custom'
"#;
let result = parse_test_query(query);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Cannot combine a named projection"));
}
#[test]
fn test_case_insensitive_keywords_lowercase() {
let query = r#"
visualise
draw point MAPPING x AS x, y AS y
project to cartesian
label title => 'Test Chart'
"#;
let result = parse_test_query(query);
if let Err(ref e) = result {
eprintln!("Parse error: {:?}", e);
}
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(specs.len(), 1);
assert!(specs[0].global_mappings.is_empty());
assert_eq!(specs[0].layers.len(), 1);
assert!(specs[0].project.is_some());
assert!(specs[0].labels.is_some());
}
#[test]
fn test_case_insensitive_keywords_mixed() {
let query = r#"
ViSuAlIsE date AS x, revenue AS y
DrAw line
ScAlE x SeTtInG type => 'date'
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].layers.len(), 1);
assert_eq!(specs[0].scales.len(), 1);
}
#[test]
fn test_case_insensitive_american_spelling() {
let query = r#"
visualize category AS x, value AS y
draw bar
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(specs.len(), 1);
}
#[test]
fn test_visualise_from_cte() {
let query = r#"
WITH cte AS (SELECT * FROM x)
VISUALISE FROM cte
DRAW bar MAPPING a AS x, b AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(specs.len(), 1);
assert_eq!(
specs[0].source,
Some(DataSource::Identifier("cte".to_string()))
);
}
#[test]
fn test_visualise_from_table() {
let query = r#"
VISUALISE FROM mtcars
DRAW point MAPPING mpg AS x, hp AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(
specs[0].source,
Some(DataSource::Identifier("mtcars".to_string()))
);
}
#[test]
fn test_visualise_from_file_path() {
let query = r#"
VISUALISE FROM 'mtcars.csv'
DRAW point MAPPING hp AS x, mpg AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(
specs[0].source,
Some(DataSource::FilePath("mtcars.csv".to_string()))
);
}
#[test]
fn test_visualise_from_file_path_quote_parquet() {
let query = r#"
VISUALISE FROM 'data/sales.parquet'
DRAW bar MAPPING region AS x, total AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(
specs[0].source,
Some(DataSource::FilePath("data/sales.parquet".to_string()))
);
}
#[test]
fn test_visualise_from_file_path_double_quote_parquet() {
let query = r#"
VISUALISE FROM "data/sales.parquet"
DRAW bar MAPPING region AS x, total AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(
specs[0].source,
Some(DataSource::Identifier("\"data/sales.parquet\"".to_string()))
);
}
#[test]
fn test_error_select_with_from() {
let query = r#"
SELECT * FROM x
VISUALISE FROM y
"#;
let result = parse_test_query(query);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err
.to_string()
.contains("Cannot use VISUALISE FROM when the last SQL statement is SELECT"));
}
#[test]
fn test_allow_non_select_with_from() {
let query = r#"
CREATE TABLE x AS SELECT 1;
WITH cte AS (SELECT * FROM x)
VISUALISE FROM cte
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_backward_compat_select_visualise_as() {
let query = r#"
SELECT * FROM x
VISUALISE
DRAW bar MAPPING a AS x, b AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(specs[0].source, None); }
#[test]
fn test_with_select_visualise_as() {
let query = r#"
WITH cte AS (SELECT * FROM x)
SELECT * FROM cte
VISUALISE
DRAW point MAPPING a AS x, b AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(specs[0].source, None); }
#[test]
fn test_error_with_select_and_visualise_from() {
let query = r#"
WITH cte AS (SELECT * FROM x)
SELECT * FROM cte
VISUALISE FROM cte
"#;
let result = parse_test_query(query);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err
.to_string()
.contains("Cannot use VISUALISE FROM when the last SQL statement is SELECT"));
}
#[test]
fn test_deeply_nested_subqueries() {
let query = r#"
SELECT * FROM (SELECT * FROM (SELECT 1 as x, 2 as y))
VISUALISE
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_multiple_values_rows() {
let query = r#"
SELECT * FROM (VALUES (1, 2), (3, 4), (5, 6)) AS t(x, y)
VISUALISE
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_multiple_ctes_no_select_with_visualise_from() {
let query = r#"
WITH a AS (SELECT 1 as x), b AS (SELECT 2 as y), c AS (SELECT 3 as z)
VISUALISE FROM c
DRAW point MAPPING z AS x, 1 AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(
specs[0].source,
Some(DataSource::Identifier("c".to_string()))
);
}
#[test]
fn test_union_with_visualise_as() {
let query = r#"
SELECT x, y FROM a UNION SELECT x, y FROM b
VISUALISE
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_error_union_with_visualise_from() {
let query = r#"
SELECT x FROM a UNION SELECT x FROM b
VISUALISE FROM c
"#;
let result = parse_test_query(query);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Cannot use VISUALISE FROM"));
}
#[test]
fn test_subquery_in_where_clause() {
let query = r#"
SELECT * FROM data WHERE x IN (SELECT y FROM other)
VISUALISE
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_join_with_visualise_as() {
let query = r#"
SELECT a.x, b.y FROM a LEFT JOIN b ON a.id = b.id
VISUALISE
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_window_function_with_visualise_as() {
let query = r#"
SELECT x, y, ROW_NUMBER() OVER (ORDER BY x) as row_num FROM data
VISUALISE
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_cte_with_join_then_visualise_from() {
let query = r#"
WITH joined AS (
SELECT a.x, b.y FROM a JOIN b ON a.id = b.id
)
VISUALISE FROM joined
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_recursive_cte_with_visualise_from() {
let query = r#"
WITH RECURSIVE series AS (
SELECT 1 as n
UNION ALL
SELECT n + 1 FROM series WHERE n < 10
)
VISUALISE FROM series
DRAW line MAPPING n AS x, n AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_visualise_keyword_in_string_literal() {
let query = r#"
SELECT 'VISUALISE' as text, 1 as x, 2 as y
VISUALISE
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_group_by_having_with_visualise_as() {
let query = r#"
SELECT category, SUM(value) as total FROM data
GROUP BY category
HAVING SUM(value) > 100
VISUALISE
DRAW bar MAPPING category AS x, total AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_order_by_limit_with_visualise_as() {
let query = r#"
SELECT * FROM data
ORDER BY x DESC
LIMIT 100
VISUALISE
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_case_expression_with_visualise_as() {
let query = r#"
SELECT x,
CASE WHEN x > 0 THEN 'positive' ELSE 'negative' END as sign
FROM data
VISUALISE
DRAW point MAPPING x AS x, sign AS color
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_intersect_with_visualise_as() {
let query = r#"
SELECT x FROM a INTERSECT SELECT x FROM b
VISUALISE
DRAW histogram MAPPING x AS x
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_error_intersect_with_visualise_from() {
let query = r#"
SELECT x FROM a INTERSECT SELECT x FROM b
VISUALISE FROM c
"#;
let result = parse_test_query(query);
assert!(result.is_err());
}
#[test]
fn test_except_with_visualise_as() {
let query = r#"
SELECT x FROM a EXCEPT SELECT x FROM b
VISUALISE
DRAW histogram MAPPING x AS x
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_with_semicolon_between_cte_and_visualise_from() {
let query = r#"
WITH cte AS (SELECT 1 as x, 2 as y);
VISUALISE FROM cte
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_multiple_statements_with_semicolons_and_visualise_from() {
let query = r#"
CREATE TABLE temp AS SELECT 1 as x;
INSERT INTO temp VALUES (2);
WITH final AS (SELECT * FROM temp)
VISUALISE FROM final
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_subquery_with_aggregation() {
let query = r#"
SELECT * FROM (
SELECT category, AVG(value) as avg_value
FROM data
GROUP BY category
)
VISUALISE
DRAW bar MAPPING category AS x, avg_value AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_lateral_join_with_visualise_as() {
let query = r#"
SELECT a.*, b.*
FROM a, LATERAL (SELECT * FROM b WHERE b.id = a.id) AS b
VISUALISE
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_values_without_table_alias() {
let query = r#"
SELECT * FROM (VALUES (1, 2))
VISUALISE
DRAW point MAPPING column0 AS x, column1 AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_nested_ctes() {
let query = r#"
WITH outer_cte AS (
WITH inner_cte AS (SELECT 1 as x)
SELECT x, x * 2 as y FROM inner_cte
)
VISUALISE FROM outer_cte
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_cross_join_with_visualise_from() {
let query = r#"
WITH result AS (
SELECT a.x, b.y FROM a CROSS JOIN b
)
VISUALISE FROM result
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_distinct_with_visualise_as() {
let query = r#"
SELECT DISTINCT x, y FROM data
VISUALISE
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_all_with_visualise_as() {
let query = r#"
SELECT ALL x, y FROM data
VISUALISE
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_exists_subquery_with_visualise_as() {
let query = r#"
SELECT * FROM a WHERE EXISTS (SELECT 1 FROM b WHERE b.id = a.id)
VISUALISE
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_not_exists_subquery_with_visualise_as() {
let query = r#"
SELECT * FROM a WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.id = a.id)
VISUALISE
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_boolean_operators_in_function_args() {
let query = r#"
SELECT IFF(x = 'a' OR x = 'b', 1, 0) AS rate
FROM t
VISUALISE rate AS x
DRAW point
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_and_operator_in_function_args() {
let query = r#"
SELECT IFF(x > 0 AND x < 10, 1, 0) AS flag
FROM t
VISUALISE flag AS x
DRAW point
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_between_in_function_args() {
let query = r#"
SELECT IFF(x BETWEEN 1 AND 10, 1, 0) AS flag
FROM t
VISUALISE flag AS x
DRAW point
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_not_operator_in_function_args() {
let query = r#"
SELECT IFF(NOT x = 'a', 1, 0) AS flag
FROM t
VISUALISE flag AS x
DRAW point
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_in_predicate_in_function_args() {
let query = r#"
SELECT IFF(x IN ('a', 'b'), 1, 0) AS flag
FROM t
VISUALISE flag AS x
DRAW point
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_not_in_predicate_in_function_args() {
let query = r#"
SELECT IFF(x NOT IN ('a', 'b'), 1, 0) AS flag
FROM t
VISUALISE flag AS x
DRAW point
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_is_null_in_function_args() {
let query = r#"
SELECT IFF(x IS NULL, 1, 0) AS flag
FROM t
VISUALISE flag AS x
DRAW point
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_like_predicate_in_function_args() {
let query = r#"
SELECT IFF(x LIKE 'a%', 1, 0) AS flag
FROM t
VISUALISE flag AS x
DRAW point
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_ilike_predicate_in_function_args() {
let query = r#"
SELECT IFF(x ILIKE 'a%', 1, 0) AS flag
FROM t
VISUALISE flag AS x
DRAW point
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_extract_named_arg_predicate_value_spans_full_expression() {
let query = "SELECT FOO(flag => x = 'a' OR x = 'b') FROM t VISUALISE x AS x DRAW point";
let source = make_source(query);
let root = source.root();
let named_arg = source.find_node(&root, "(named_arg) @arg").unwrap();
let (_, value_node) = extract_name_value_nodes(&named_arg, "named_arg").unwrap();
assert_eq!(source.get_text(&value_node), "x = 'a' OR x = 'b'");
}
#[test]
fn test_named_arg_predicate_in_function_args() {
let query = r#"
SELECT FOO(flag => x = 'a' OR x = 'b')
FROM t
VISUALISE x AS x
DRAW point
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
}
#[test]
fn test_error_create_with_select_and_visualise_from() {
let query = r#"
CREATE TABLE x AS SELECT 1;
SELECT * FROM x
VISUALISE FROM y
"#;
let result = parse_test_query(query);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Cannot use VISUALISE FROM"));
}
#[test]
fn test_error_insert_with_select_and_visualise_from() {
let query = r#"
INSERT INTO x SELECT * FROM y;
SELECT * FROM x
VISUALISE FROM z
"#;
let result = parse_test_query(query);
assert!(result.is_err());
}
#[test]
fn test_error_subquery_select_with_visualise_from() {
let query = r#"
SELECT * FROM (SELECT * FROM data)
VISUALISE FROM other
"#;
let result = parse_test_query(query);
assert!(result.is_err());
}
#[test]
fn test_error_join_select_with_visualise_from() {
let query = r#"
SELECT a.* FROM a JOIN b ON a.id = b.id
VISUALISE FROM c
"#;
let result = parse_test_query(query);
assert!(result.is_err());
}
#[test]
fn test_filter_simple_comparison() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y FILTER value > 10
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].layers.len(), 1);
let filter = specs[0].layers[0].filter.as_ref().unwrap();
assert_eq!(filter.as_str(), "value > 10");
}
#[test]
fn test_filter_equality() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y FILTER category = 'A'
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let filter = specs[0].layers[0].filter.as_ref().unwrap();
assert_eq!(filter.as_str(), "category = 'A'");
}
#[test]
fn test_filter_not_equal() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x FILTER status != 'inactive'
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let filter = specs[0].layers[0].filter.as_ref().unwrap();
assert_eq!(filter.as_str(), "status != 'inactive'");
}
#[test]
fn test_filter_less_than_or_equal() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x FILTER score <= 100
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let filter = specs[0].layers[0].filter.as_ref().unwrap();
assert_eq!(filter.as_str(), "score <= 100");
}
#[test]
fn test_filter_greater_than_or_equal() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x FILTER year >= 2020
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let filter = specs[0].layers[0].filter.as_ref().unwrap();
assert_eq!(filter.as_str(), "year >= 2020");
}
#[test]
fn test_filter_and_expression() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x FILTER value > 10 AND value < 100
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let filter = specs[0].layers[0].filter.as_ref().unwrap();
assert_eq!(filter.as_str(), "value > 10 AND value < 100");
}
#[test]
fn test_filter_or_expression() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x FILTER category = 'A' OR category = 'B'
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let filter = specs[0].layers[0].filter.as_ref().unwrap();
assert_eq!(filter.as_str(), "category = 'A' OR category = 'B'");
}
#[test]
fn test_filter_with_mapping_and_setting() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y, category AS color SETTING size => 5 FILTER value > 50
DRAW point SETTING fill => 'Chartreuse'
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let layer = &specs[0].layers[0];
assert_eq!(layer.mappings.len(), 3);
assert!(layer.mappings.contains_key("pos1"));
assert!(layer.mappings.contains_key("pos2"));
assert!(layer.mappings.contains_key("color"));
assert_eq!(layer.parameters.len(), 1);
assert!(layer.parameters.contains_key("size"));
assert!(layer.filter.is_some());
let filter = layer.filter.as_ref().unwrap();
assert_eq!(filter.as_str(), "value > 50");
let layer = &specs[0].layers[1];
assert!(layer.parameters.contains_key("fill"));
if let ParameterValue::String(fill) = layer.parameters.get("fill").unwrap() {
assert_eq!(fill, "#7fff00")
} else {
panic!("Wrong type of 'fill' parameter")
}
}
#[test]
fn test_filter_boolean_value() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x FILTER active = true
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let filter = specs[0].layers[0].filter.as_ref().unwrap();
assert_eq!(filter.as_str(), "active = true");
}
#[test]
fn test_filter_negative_number() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x FILTER temperature > -10
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let filter = specs[0].layers[0].filter.as_ref().unwrap();
assert_eq!(filter.as_str(), "temperature > -10");
}
#[test]
fn test_no_filter() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert!(specs[0].layers[0].filter.is_none());
}
#[test]
fn test_multiple_layers_with_different_filters() {
let query = r#"
VISUALISE
DRAW line MAPPING x AS x, y AS y
DRAW point MAPPING x AS x, y AS y FILTER highlight = true
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert!(specs[0].layers[0].filter.is_none());
assert!(specs[0].layers[1].filter.is_some());
assert_eq!(
specs[0].layers[1].filter.as_ref().unwrap().as_str(),
"highlight = true"
);
}
#[test]
fn test_filter_column_comparison() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x FILTER start_date < end_date
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let filter = specs[0].layers[0].filter.as_ref().unwrap();
assert_eq!(filter.as_str(), "start_date < end_date");
}
#[test]
fn test_filter_complex_sql_expression() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x FILTER category IN ('A', 'B', 'C') AND value BETWEEN 10 AND 100
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let filter = specs[0].layers[0].filter.as_ref().unwrap();
assert!(filter.as_str().contains("IN"));
assert!(filter.as_str().contains("BETWEEN"));
}
#[test]
fn test_filter_like_expression() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x FILTER name LIKE '%test%'
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let filter = specs[0].layers[0].filter.as_ref().unwrap();
assert!(filter.as_str().contains("LIKE"));
}
#[test]
fn test_partition_by_single_column() {
let query = r#"
VISUALISE date AS x, value AS y
DRAW line PARTITION BY category
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(specs[0].layers[0].partition_by.len(), 1);
assert_eq!(specs[0].layers[0].partition_by[0], "category");
}
#[test]
fn test_partition_by_multiple_columns() {
let query = r#"
VISUALISE date AS x, value AS y
DRAW line PARTITION BY category, region
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(specs[0].layers[0].partition_by.len(), 2);
assert_eq!(specs[0].layers[0].partition_by[0], "category");
assert_eq!(specs[0].layers[0].partition_by[1], "region");
}
#[test]
fn test_partition_by_with_other_clauses() {
let query = r#"
VISUALISE
DRAW line MAPPING date AS x, value AS y SETTING opacity => 0.5 FILTER year > 2020 PARTITION BY category
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let layer = &specs[0].layers[0];
assert_eq!(layer.partition_by.len(), 1);
assert_eq!(layer.partition_by[0], "category");
assert!(layer.filter.is_some());
assert!(layer.parameters.contains_key("opacity"));
}
#[test]
fn test_no_partition_by() {
let query = r#"
VISUALISE date AS x, value AS y
DRAW line
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert!(specs[0].layers[0].partition_by.is_empty());
}
#[test]
fn test_partition_by_case_insensitive() {
let query = r#"
VISUALISE date AS x, value AS y
DRAW line partition by category
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(specs[0].layers[0].partition_by.len(), 1);
assert_eq!(specs[0].layers[0].partition_by[0], "category");
}
#[test]
fn test_order_by_single_column() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y ORDER BY x ASC
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let order_by = specs[0].layers[0].order_by.as_ref().unwrap();
assert_eq!(order_by.as_str(), "x ASC");
}
#[test]
fn test_order_by_multiple_columns() {
let query = r#"
VISUALISE
DRAW line MAPPING x AS x, y AS y ORDER BY category, date DESC
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let order_by = specs[0].layers[0].order_by.as_ref().unwrap();
assert_eq!(order_by.as_str(), "category, date DESC");
}
#[test]
fn test_order_by_with_nulls() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y ORDER BY date ASC NULLS FIRST
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let order_by = specs[0].layers[0].order_by.as_ref().unwrap();
assert!(order_by.as_str().contains("NULLS FIRST"));
}
#[test]
fn test_order_by_desc() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y ORDER BY value DESC
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let order_by = specs[0].layers[0].order_by.as_ref().unwrap();
assert_eq!(order_by.as_str(), "value DESC");
}
#[test]
fn test_order_by_with_filter() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y FILTER x > 0 ORDER BY x ASC
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let layer = &specs[0].layers[0];
assert!(layer.filter.is_some());
assert!(layer.order_by.is_some());
assert_eq!(layer.filter.as_ref().unwrap().as_str(), "x > 0");
assert_eq!(layer.order_by.as_ref().unwrap().as_str(), "x ASC");
}
#[test]
fn test_order_by_with_partition_by() {
let query = r#"
VISUALISE
DRAW line MAPPING x AS x, y AS y PARTITION BY category ORDER BY date ASC
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let layer = &specs[0].layers[0];
assert_eq!(layer.partition_by.len(), 1);
assert_eq!(layer.partition_by[0], "category");
assert!(layer.order_by.is_some());
assert_eq!(layer.order_by.as_ref().unwrap().as_str(), "date ASC");
}
#[test]
fn test_order_by_with_all_clauses() {
let query = r#"
VISUALISE
DRAW line MAPPING date AS x, value AS y SETTING opacity => 0.5 FILTER year > 2020 PARTITION BY region ORDER BY date ASC, value DESC
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let layer = &specs[0].layers[0];
assert!(layer.parameters.contains_key("opacity"));
assert!(layer.filter.is_some());
assert_eq!(layer.partition_by.len(), 1);
assert!(layer.order_by.is_some());
assert_eq!(
layer.order_by.as_ref().unwrap().as_str(),
"date ASC, value DESC"
);
}
#[test]
fn test_no_order_by() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert!(specs[0].layers[0].order_by.is_none());
}
#[test]
fn test_order_by_case_insensitive() {
let query = r#"
VISUALISE
DRAW line MAPPING x AS x, y AS y order by date asc
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert!(specs[0].layers[0].order_by.is_some());
}
#[test]
fn test_multiple_layers_different_order_by() {
let query = r#"
VISUALISE
DRAW line MAPPING x AS x, y AS y ORDER BY date ASC
DRAW point MAPPING x AS x, y AS y ORDER BY value DESC
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(
specs[0].layers[0].order_by.as_ref().unwrap().as_str(),
"date ASC"
);
assert_eq!(
specs[0].layers[1].order_by.as_ref().unwrap().as_str(),
"value DESC"
);
}
#[test]
fn test_global_mapping_parsing() {
let query = r#"
VISUALISE date AS x, revenue AS y
DRAW line
DRAW point MAPPING region AS color
"#;
let specs = parse_test_query(query).unwrap();
assert_eq!(specs[0].global_mappings.aesthetics.len(), 2);
assert!(specs[0].global_mappings.aesthetics.contains_key("pos1"));
assert!(specs[0].global_mappings.aesthetics.contains_key("pos2"));
assert!(!specs[0].global_mappings.wildcard);
assert_eq!(specs[0].layers[0].mappings.len(), 0);
assert_eq!(specs[0].layers[1].mappings.len(), 1);
assert!(specs[0].layers[1].mappings.contains_key("color"));
}
#[test]
fn test_implicit_global_mapping_parsing() {
let query = r#"
VISUALISE x, y
DRAW point
"#;
let specs = parse_test_query(query).unwrap();
assert_eq!(specs[0].global_mappings.aesthetics.len(), 2);
assert!(specs[0].global_mappings.aesthetics.contains_key("pos1"));
assert!(specs[0].global_mappings.aesthetics.contains_key("pos2"));
let x_val = specs[0].global_mappings.aesthetics.get("pos1").unwrap();
assert_eq!(x_val.column_name(), Some("x"));
let y_val = specs[0].global_mappings.aesthetics.get("pos2").unwrap();
assert_eq!(y_val.column_name(), Some("y"));
}
#[test]
fn test_wildcard_global_mapping_parsing() {
let query = r#"
VISUALISE *
DRAW point
"#;
let specs = parse_test_query(query).unwrap();
assert!(specs[0].global_mappings.wildcard);
assert!(specs[0].global_mappings.aesthetics.is_empty());
}
#[test]
fn test_wildcard_with_explicit_mapping_parsing() {
let query = r#"
VISUALISE *, category AS fill
DRAW bar
"#;
let specs = parse_test_query(query).unwrap();
assert!(specs[0].global_mappings.wildcard);
assert_eq!(specs[0].global_mappings.aesthetics.len(), 1);
assert!(specs[0].global_mappings.aesthetics.contains_key("fill"));
}
#[test]
fn test_layer_wildcard_mapping_parsing() {
let query = r#"
VISUALISE
DRAW point MAPPING *
"#;
let specs = parse_test_query(query).unwrap();
assert!(specs[0].global_mappings.is_empty());
assert!(specs[0].layers[0].mappings.wildcard);
}
#[test]
fn test_layer_wildcard_with_explicit_parsing() {
let query = r#"
VISUALISE
DRAW point MAPPING *, 'red' AS color
"#;
let specs = parse_test_query(query).unwrap();
assert!(specs[0].layers[0].mappings.wildcard);
assert_eq!(specs[0].layers[0].mappings.len(), 1);
assert!(specs[0].layers[0].mappings.contains_key("color"));
}
#[test]
fn test_layer_from_identifier() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y FROM my_cte
"#;
let specs = parse_test_query(query).unwrap();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].layers.len(), 1);
let layer = &specs[0].layers[0];
assert!(layer.source.is_some());
assert!(matches!(
layer.source.as_ref(),
Some(DataSource::Identifier(name)) if name == "my_cte"
));
}
#[test]
fn test_layer_from_file_path() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y FROM 'data.csv'
"#;
let specs = parse_test_query(query).unwrap();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].layers.len(), 1);
let layer = &specs[0].layers[0];
assert!(layer.source.is_some());
assert!(matches!(
layer.source.as_ref(),
Some(DataSource::FilePath(path)) if path == "data.csv"
));
}
#[test]
fn test_layer_from_empty_mapping() {
let query = r#"
VISUALISE x AS x, y AS y
DRAW point MAPPING FROM other_data
"#;
let specs = parse_test_query(query).unwrap();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].layers.len(), 1);
let layer = &specs[0].layers[0];
assert!(layer.source.is_some());
assert!(matches!(
layer.source.as_ref(),
Some(DataSource::Identifier(name)) if name == "other_data"
));
assert!(layer.mappings.is_empty());
}
#[test]
fn test_layer_without_from() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y
"#;
let specs = parse_test_query(query).unwrap();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].layers.len(), 1);
let layer = &specs[0].layers[0];
assert!(layer.source.is_none());
}
#[test]
fn test_mixed_layers_with_and_without_from() {
let query = r#"
SELECT * FROM baseline
VISUALISE
DRAW line MAPPING x AS x, y AS y
DRAW point MAPPING x AS x, y AS y FROM comparison
"#;
let specs = parse_test_query(query).unwrap();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].layers.len(), 2);
assert!(specs[0].layers[0].source.is_none());
assert!(specs[0].layers[1].source.is_some());
assert!(matches!(
specs[0].layers[1].source.as_ref(),
Some(DataSource::Identifier(name)) if name == "comparison"
));
}
#[test]
fn test_layer_from_with_cte() {
let query = r#"
WITH sales AS (SELECT date, revenue FROM transactions),
targets AS (SELECT date, goal FROM monthly_goals)
VISUALISE
DRAW line MAPPING date AS x, revenue AS y FROM sales
DRAW line MAPPING date AS x, goal AS y FROM targets
"#;
let specs = parse_test_query(query).unwrap();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].layers.len(), 2);
assert!(matches!(
specs[0].layers[0].source.as_ref(),
Some(DataSource::Identifier(name)) if name == "sales"
));
assert!(matches!(
specs[0].layers[1].source.as_ref(),
Some(DataSource::Identifier(name)) if name == "targets"
));
}
#[test]
fn test_colour_scale_hex_code_conversion() {
let query = r#"
VISUALISE foo AS x
SCALE color TO ['rgb(0, 0, 255)', 'green', '#FF0000']
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert_eq!(scales.len(), 1);
let output_range = &scales[0].output_range;
assert!(output_range.is_some());
let output_range = output_range.as_ref().unwrap();
let mut ok = false;
if let OutputRange::Array(elems) = output_range {
ok = matches!(&elems[0], ArrayElement::String(color) if color == "#0000ff");
ok = ok && matches!(&elems[1], ArrayElement::String(color) if color == "#008000");
ok = ok && matches!(&elems[2], ArrayElement::String(color) if color == "#ff0000");
}
assert!(ok);
eprintln!("{:?}", output_range);
}
#[test]
fn test_scale_from_with_null_max() {
let query = r#"
VISUALISE x, y
DRAW point
SCALE x FROM [0, null]
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert_eq!(scales.len(), 1);
assert_eq!(scales[0].aesthetic, "pos1");
let input_range = scales[0].input_range.as_ref().unwrap();
assert_eq!(input_range.len(), 2);
assert!(matches!(&input_range[0], ArrayElement::Number(n) if *n == 0.0));
assert!(matches!(&input_range[1], ArrayElement::Null));
}
#[test]
fn test_scale_from_with_null_min() {
let query = r#"
VISUALISE x, y
DRAW point
SCALE x FROM [null, 100]
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert_eq!(scales.len(), 1);
let input_range = scales[0].input_range.as_ref().unwrap();
assert_eq!(input_range.len(), 2);
assert!(matches!(&input_range[0], ArrayElement::Null));
assert!(matches!(&input_range[1], ArrayElement::Number(n) if *n == 100.0));
}
#[test]
fn test_scale_from_with_both_nulls() {
let query = r#"
VISUALISE x, y
DRAW point
SCALE x FROM [null, null]
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert_eq!(scales.len(), 1);
let input_range = scales[0].input_range.as_ref().unwrap();
assert_eq!(input_range.len(), 2);
assert!(matches!(&input_range[0], ArrayElement::Null));
assert!(matches!(&input_range[1], ArrayElement::Null));
}
#[test]
fn test_scale_from_with_null_case_insensitive() {
let query = r#"
VISUALISE x, y
DRAW point
SCALE x FROM [0, NULL]
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
let input_range = scales[0].input_range.as_ref().unwrap();
assert!(matches!(&input_range[1], ArrayElement::Null));
}
#[test]
fn test_scale_from_with_null() {
let query = r#"
VISUALISE date AS x, value AS y
DRAW line
SCALE x FROM ['2024-01-01', null]
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert_eq!(scales.len(), 1);
let input_range = scales[0].input_range.as_ref().unwrap();
assert_eq!(input_range.len(), 2);
assert!(matches!(&input_range[0], ArrayElement::String(s) if s == "2024-01-01"));
assert!(matches!(&input_range[1], ArrayElement::Null));
}
#[test]
fn test_scale_via_date_transform() {
let query = r#"
VISUALISE date AS x, value AS y
DRAW line
SCALE x VIA date
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert_eq!(scales.len(), 1);
assert_eq!(scales[0].aesthetic, "pos1");
assert!(scales[0].transform.is_some());
assert_eq!(scales[0].transform.as_ref().unwrap().name(), "date");
}
#[test]
fn test_scale_via_integer_transform() {
let query = r#"
VISUALISE val AS x, count AS y
DRAW point
SCALE x VIA integer
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert_eq!(scales.len(), 1);
assert_eq!(scales[0].aesthetic, "pos1");
assert!(scales[0].transform.is_some());
assert_eq!(scales[0].transform.as_ref().unwrap().name(), "integer");
}
#[test]
fn test_scale_via_int_alias() {
let query = r#"
VISUALISE val AS x, count AS y
DRAW point
SCALE x VIA int
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert_eq!(scales.len(), 1);
assert!(scales[0].transform.is_some());
assert_eq!(scales[0].transform.as_ref().unwrap().name(), "integer");
}
#[test]
fn test_scale_via_bigint_alias() {
let query = r#"
VISUALISE val AS x, count AS y
DRAW point
SCALE x VIA bigint
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert_eq!(scales.len(), 1);
assert!(scales[0].transform.is_some());
assert_eq!(scales[0].transform.as_ref().unwrap().name(), "integer");
}
#[test]
fn test_scale_renaming_basic() {
let query = r#"
VISUALISE x AS x, y AS y
DRAW bar
SCALE DISCRETE x RENAMING 'A' => 'Alpha', 'B' => 'Beta'
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert_eq!(scales.len(), 1);
assert_eq!(scales[0].aesthetic, "pos1");
let label_mapping = scales[0].label_mapping.as_ref().unwrap();
assert_eq!(label_mapping.len(), 2);
assert_eq!(label_mapping.get("A"), Some(&Some("Alpha".to_string())));
assert_eq!(label_mapping.get("B"), Some(&Some("Beta".to_string())));
}
#[test]
fn test_scale_renaming_with_null() {
let query = r#"
VISUALISE x AS x, y AS y
DRAW bar
SCALE DISCRETE x RENAMING 'internal' => NULL, 'visible' => 'Shown'
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
let label_mapping = scales[0].label_mapping.as_ref().unwrap();
assert_eq!(label_mapping.get("internal"), Some(&None)); assert_eq!(
label_mapping.get("visible"),
Some(&Some("Shown".to_string()))
);
}
#[test]
fn test_scale_renaming_with_numeric_keys() {
let query = r#"
VISUALISE temp AS x, count AS y
DRAW bar
SCALE BINNED x RENAMING 0 => '0-10', 10 => '10-20', 20 => '20-30'
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
let label_mapping = scales[0].label_mapping.as_ref().unwrap();
assert_eq!(label_mapping.len(), 3);
assert_eq!(label_mapping.get("0"), Some(&Some("0-10".to_string())));
assert_eq!(label_mapping.get("10"), Some(&Some("10-20".to_string())));
assert_eq!(label_mapping.get("20"), Some(&Some("20-30".to_string())));
}
#[test]
fn test_scale_renaming_for_color_legend() {
let query = r#"
VISUALISE x, y, category AS color
DRAW point
SCALE DISCRETE color RENAMING 'cat_a' => 'Category A', 'cat_b' => 'Category B'
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert_eq!(scales.len(), 1);
assert_eq!(scales[0].aesthetic, "color");
let label_mapping = scales[0].label_mapping.as_ref().unwrap();
assert_eq!(
label_mapping.get("cat_a"),
Some(&Some("Category A".to_string()))
);
assert_eq!(
label_mapping.get("cat_b"),
Some(&Some("Category B".to_string()))
);
}
#[test]
fn test_scale_renaming_with_setting() {
let query = r#"
VISUALISE x, y
DRAW bar
SCALE DISCRETE x SETTING reverse => true RENAMING 'A' => 'First', 'B' => 'Second'
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert_eq!(
scales[0].properties.get("reverse"),
Some(&ParameterValue::Boolean(true))
);
let label_mapping = scales[0].label_mapping.as_ref().unwrap();
assert_eq!(label_mapping.get("A"), Some(&Some("First".to_string())));
assert_eq!(label_mapping.get("B"), Some(&Some("Second".to_string())));
}
#[test]
fn test_scale_renaming_with_from_to() {
let query = r#"
VISUALISE x, y, cat AS color
DRAW point
SCALE DISCRETE color FROM ['A', 'B'] TO ['red', 'blue']
RENAMING 'A' => 'Option A', 'B' => 'Option B'
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
let input_range = scales[0].input_range.as_ref().unwrap();
assert_eq!(input_range.len(), 2);
assert!(scales[0].output_range.is_some());
let label_mapping = scales[0].label_mapping.as_ref().unwrap();
assert_eq!(label_mapping.get("A"), Some(&Some("Option A".to_string())));
}
#[test]
fn test_scale_renaming_wildcard_template() {
let query = r#"
VISUALISE x AS x, y AS y
DRAW point
SCALE CONTINUOUS x RENAMING * => '{} units'
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert!(scales[0].label_mapping.is_none()); assert_eq!(scales[0].label_template, "{} units");
}
#[test]
fn test_scale_renaming_wildcard_with_explicit() {
let query = r#"
VISUALISE x AS x, y AS y
DRAW point
SCALE DISCRETE x RENAMING 'A' => 'Alpha', * => 'Category {}'
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
let label_mapping = scales[0].label_mapping.as_ref().unwrap();
assert_eq!(label_mapping.get("A"), Some(&Some("Alpha".to_string())));
assert_eq!(scales[0].label_template, "Category {}");
}
#[test]
fn test_scale_renaming_wildcard_uppercase() {
let query = r#"
VISUALISE x AS x, y AS y
DRAW bar
SCALE DISCRETE x RENAMING * => '{:UPPER}'
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert_eq!(scales[0].label_template, "{:UPPER}");
}
#[test]
fn test_scale_renaming_wildcard_datetime() {
let query = r#"
VISUALISE date AS x, value AS y
DRAW line
SCALE CONTINUOUS x RENAMING * => '{:time %b %Y}'
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert_eq!(scales[0].label_template, "{:time %b %Y}");
}
#[test]
fn test_scale_ordinal_basic() {
let query = r#"
VISUALISE x AS x, y AS y, category AS fill
DRAW point
SCALE ORDINAL fill FROM ['low', 'medium', 'high'] TO viridis
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert_eq!(scales.len(), 1);
assert_eq!(scales[0].aesthetic, "fill");
assert!(scales[0].scale_type.is_some());
assert_eq!(
scales[0].scale_type.as_ref().unwrap().scale_type_kind(),
crate::plot::ScaleTypeKind::Ordinal
);
let input_range = scales[0].input_range.as_ref().unwrap();
assert_eq!(input_range.len(), 3);
assert!(scales[0].output_range.is_some());
}
#[test]
fn test_scale_ordinal_with_explicit_colors() {
let query = r#"
VISUALISE x AS x, y AS y, size_cat AS fill
DRAW point
SCALE ORDINAL fill FROM ['S', 'M', 'L'] TO ['#ff0000', '#00ff00', '#0000ff']
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert_eq!(
scales[0].scale_type.as_ref().unwrap().scale_type_kind(),
crate::plot::ScaleTypeKind::Ordinal
);
}
#[test]
fn test_scale_ordinal_case_insensitive() {
let query = r#"
VISUALISE x AS x, y AS y, cat AS color
DRAW point
SCALE ordinal color FROM ['a', 'b', 'c']
"#;
let specs = parse_test_query(query).unwrap();
let scales = &specs[0].scales;
assert_eq!(
scales[0].scale_type.as_ref().unwrap().scale_type_kind(),
crate::plot::ScaleTypeKind::Ordinal
);
}
fn make_source(query: &str) -> SourceTree<'_> {
SourceTree::new(query).unwrap()
}
#[test]
fn test_parse_string_node() {
let source = make_source("VISUALISE DRAW point LABEL title => 'hello world'");
let root = source.root();
let string_node = source.find_node(&root, "(string) @s").unwrap();
let parsed = parse_string_node(&string_node, &source);
assert_eq!(parsed, "hello world");
}
#[test]
fn test_parse_number_node() {
let source = make_source("VISUALISE DRAW point SCALE x FROM [0, 100]");
let root = source.root();
let numbers = source.find_nodes(&root, "(number) @n");
assert_eq!(numbers.len(), 2);
assert_eq!(parse_number_node(&numbers[0], &source).unwrap(), 0.0);
assert_eq!(parse_number_node(&numbers[1], &source).unwrap(), 100.0);
let source2 = make_source("VISUALISE DRAW point SCALE y FROM [-10.5, 20.75]");
let root2 = source2.root();
let numbers2 = source2.find_nodes(&root2, "(number) @n");
assert_eq!(parse_number_node(&numbers2[0], &source2).unwrap(), -10.5);
assert_eq!(parse_number_node(&numbers2[1], &source2).unwrap(), 20.75);
}
#[test]
fn test_parse_array_node() {
let source = make_source("VISUALISE DRAW point SCALE x FROM ['a', 'b', 'c']");
let root = source.root();
let array_node = source.find_node(&root, "(array) @arr").unwrap();
let parsed = parse_array_node(&array_node, &source).unwrap();
assert_eq!(parsed.len(), 3);
assert!(matches!(parsed[0], ArrayElement::String(ref s) if s == "a"));
assert!(matches!(parsed[1], ArrayElement::String(ref s) if s == "b"));
assert!(matches!(parsed[2], ArrayElement::String(ref s) if s == "c"));
let source2 = make_source("VISUALISE DRAW point SCALE x FROM [0, 50, 100]");
let root2 = source2.root();
let array_node2 = source2.find_node(&root2, "(array) @arr").unwrap();
let parsed2 = parse_array_node(&array_node2, &source2).unwrap();
assert_eq!(parsed2.len(), 3);
assert!(matches!(parsed2[0], ArrayElement::Number(n) if n == 0.0));
assert!(matches!(parsed2[1], ArrayElement::Number(n) if n == 50.0));
assert!(matches!(parsed2[2], ArrayElement::Number(n) if n == 100.0));
}
#[test]
fn test_parse_array_node_parenthesized() {
let source = make_source("VISUALISE DRAW point SCALE x FROM ('a', 'b', 'c')");
let root = source.root();
let array_node = source.find_node(&root, "(array) @arr").unwrap();
let parsed = parse_array_node(&array_node, &source).unwrap();
assert_eq!(parsed.len(), 3);
assert!(matches!(parsed[0], ArrayElement::String(ref s) if s == "a"));
assert!(matches!(parsed[1], ArrayElement::String(ref s) if s == "b"));
assert!(matches!(parsed[2], ArrayElement::String(ref s) if s == "c"));
let source2 = make_source("VISUALISE DRAW point SCALE x FROM (0, 50, 100)");
let root2 = source2.root();
let array_node2 = source2.find_node(&root2, "(array) @arr").unwrap();
let parsed2 = parse_array_node(&array_node2, &source2).unwrap();
assert_eq!(parsed2.len(), 3);
assert!(matches!(parsed2[0], ArrayElement::Number(n) if n == 0.0));
assert!(matches!(parsed2[1], ArrayElement::Number(n) if n == 50.0));
assert!(matches!(parsed2[2], ArrayElement::Number(n) if n == 100.0));
}
#[test]
fn test_parse_data_source() {
let source = make_source("VISUALISE FROM sales DRAW bar");
let root = source.root();
let from_node = source.find_node(&root, "(table_ref) @ref").unwrap();
let parsed = parse_data_source(&from_node, &source);
assert!(matches!(parsed, DataSource::Identifier(ref name) if name == "sales"));
let source2 = make_source("VISUALISE FROM 'data.csv' DRAW bar");
let root2 = source2.root();
let from_node2 = source2.find_node(&root2, "(table_ref) @ref").unwrap();
let string_node = source2.find_node(&from_node2, "(string) @s").unwrap();
let parsed2 = parse_data_source(&string_node, &source2);
assert!(matches!(parsed2, DataSource::FilePath(ref path) if path == "data.csv"));
}
#[test]
fn test_parse_literal_value() {
let source = make_source("VISUALISE DRAW point MAPPING 'red' AS color");
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::String(ref s)) if s == "red"
));
let source2 = make_source("VISUALISE DRAW point MAPPING 42 AS size");
let root2 = source2.root();
let literal_node2 = source2.find_node(&root2, "(literal_value) @lit").unwrap();
let parsed2 = parse_literal_value(&literal_node2, &source2).unwrap();
assert!(matches!(parsed2, AestheticValue::Literal(ParameterValue::Number(n)) if n == 42.0));
}
#[test]
fn test_parse_null_literal_value() {
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)
));
}
#[test]
fn test_infer_cartesian_from_x_y_mappings() {
let query = "VISUALISE DRAW point MAPPING date AS x, value AS y";
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let project = specs[0].project.as_ref().unwrap();
assert_eq!(project.coord.coord_kind(), CoordKind::Cartesian);
assert_eq!(project.aesthetics, vec!["x", "y"]);
}
#[test]
fn test_infer_polar_from_angle_radius_mappings() {
let query = "VISUALISE DRAW bar MAPPING cat AS angle, val AS radius";
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let project = specs[0].project.as_ref().unwrap();
assert_eq!(project.coord.coord_kind(), CoordKind::Polar);
assert_eq!(project.aesthetics, vec!["radius", "angle"]);
}
#[test]
fn test_explicit_project_overrides_inference() {
let query = r#"
VISUALISE
DRAW bar MAPPING cat AS angle, val AS radius
PROJECT TO cartesian
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let project = specs[0].project.as_ref().unwrap();
assert_eq!(project.coord.coord_kind(), CoordKind::Cartesian);
}
#[test]
fn test_conflicting_aesthetics_error() {
let query = "VISUALISE DRAW point MAPPING a AS x, b AS angle";
let result = parse_test_query(query);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Conflicting"));
}
#[test]
fn test_no_position_keeps_default() {
let query = "VISUALISE DRAW point MAPPING region AS color";
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert!(specs[0].project.is_none());
}
#[test]
fn test_infer_from_global_mappings() {
let query = "VISUALISE date AS x, value AS y DRAW point";
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let project = specs[0].project.as_ref().unwrap();
assert_eq!(project.coord.coord_kind(), CoordKind::Cartesian);
}
#[test]
fn test_infer_from_xmin_ymax_variants() {
let query = "VISUALISE DRAW ribbon MAPPING date AS x, lo AS ymin, hi AS ymax";
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
let project = specs[0].project.as_ref().unwrap();
assert_eq!(project.coord.coord_kind(), CoordKind::Cartesian);
}
#[test]
fn test_position_stack_from_setting() {
let query = r#"
VISUALISE
DRAW bar MAPPING cat AS x, val AS y, grp AS fill
SETTING position => 'stack'
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(specs[0].layers.len(), 1);
assert_eq!(specs[0].layers[0].position, Position::stack());
}
#[test]
fn test_position_dodge_from_setting() {
let query = r#"
VISUALISE
DRAW bar MAPPING cat AS x, val AS y, grp AS fill
SETTING position => 'dodge'
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(specs[0].layers.len(), 1);
assert_eq!(specs[0].layers[0].position, Position::dodge());
}
#[test]
fn test_position_jitter_from_setting() {
let query = r#"
VISUALISE
DRAW point MAPPING cat AS x, val AS y
SETTING position => 'jitter'
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(specs[0].layers.len(), 1);
assert_eq!(specs[0].layers[0].position, Position::jitter());
}
#[test]
fn test_position_geom_defaults() {
let query = r#"
VISUALISE
DRAW bar MAPPING cat AS x, val AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(specs[0].layers[0].position, Position::stack());
let query = r#"
VISUALISE
DRAW point MAPPING cat AS x, val AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(specs[0].layers[0].position, Position::identity());
let query = r#"
VISUALISE
DRAW boxplot MAPPING cat AS x, val AS y
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert_eq!(specs[0].layers[0].position, Position::dodge());
}
#[test]
fn test_parameter_colour_normalized_to_color() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y SETTING colour => 'red'
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert!(specs[0].layers[0].parameters.contains_key("color"));
assert!(!specs[0].layers[0].parameters.contains_key("colour"));
assert_eq!(
specs[0].layers[0].parameters.get("color"),
Some(&ParameterValue::String("#ff0000".to_string()))
);
}
#[test]
fn test_parameter_col_normalized_to_color() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y SETTING col => 'blue'
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert!(specs[0].layers[0].parameters.contains_key("color"));
assert!(!specs[0].layers[0].parameters.contains_key("col"));
assert_eq!(
specs[0].layers[0].parameters.get("color"),
Some(&ParameterValue::String("#0000ff".to_string()))
);
}
#[test]
fn test_parameter_mixed_with_colour() {
let query = r#"
VISUALISE
DRAW point MAPPING x AS x, y AS y SETTING colour => 'green', opacity => 0.5
"#;
let result = parse_test_query(query);
assert!(result.is_ok());
let specs = result.unwrap();
assert!(specs[0].layers[0].parameters.contains_key("color"));
assert!(specs[0].layers[0].parameters.contains_key("opacity"));
assert_eq!(
specs[0].layers[0].parameters.get("color"),
Some(&ParameterValue::String("#008000".to_string()))
);
assert_eq!(
specs[0].layers[0].parameters.get("opacity"),
Some(&ParameterValue::Number(0.5))
);
}
}