mod casting;
mod cte;
mod layer;
mod position;
mod scale;
mod schema;
pub use casting::TypeRequirement;
pub use cte::CteDefinition;
pub use schema::TypeInfo;
use crate::naming;
use crate::parser;
use crate::plot::aesthetic::{is_position_aesthetic, AestheticContext};
use crate::plot::facet::{resolve_properties as resolve_facet_properties, FacetDataContext};
use crate::plot::layer::is_transposed;
use crate::plot::projection::resolve_projection_properties;
use crate::plot::{AestheticValue, Layer, Scale, ScaleTypeKind, Schema};
use crate::{DataFrame, DataSource, GgsqlError, Plot, Result};
use std::collections::{HashMap, HashSet};
use crate::reader::Reader;
#[cfg(all(feature = "duckdb", test))]
use crate::reader::DuckDBReader;
fn validate(
layers: &[Layer],
layer_schemas: &[Schema],
aesthetic_context: &Option<AestheticContext>,
) -> Result<()> {
let translate = |aes: &str| -> String {
match aesthetic_context {
Some(ctx) => ctx.map_internal_to_user(aes),
None => aes.to_string(),
}
};
for (idx, (layer, schema)) in layers.iter().zip(layer_schemas.iter()).enumerate() {
let schema_columns: HashSet<&str> = schema.iter().map(|c| c.name.as_str()).collect();
let supported = layer.geom.aesthetics().supported();
layer
.validate_mapping(aesthetic_context, false)
.map_err(|e| GgsqlError::ValidationError(format!("Layer {}: {}", idx + 1, e)))?;
layer
.validate_settings()
.map_err(|e| GgsqlError::ValidationError(format!("Layer {}: {}", idx + 1, e)))?;
for (aesthetic, value) in &layer.mappings.aesthetics {
if !supported.contains(&aesthetic.as_str()) {
continue;
}
if let Some(col_name) = value.column_name() {
if naming::is_synthetic_column(col_name) {
continue;
}
if !schema_columns.contains(col_name) {
return Err(GgsqlError::ValidationError(format!(
"Layer {}: aesthetic '{}' references non-existent column '{}'",
idx + 1,
translate(aesthetic),
col_name
)));
}
}
}
for col in &layer.partition_by {
if !schema_columns.contains(col.as_str()) {
return Err(GgsqlError::ValidationError(format!(
"Layer {}: PARTITION BY references non-existent column '{}'",
idx + 1,
col
)));
}
}
let aesthetics_info = layer.geom.aesthetics();
for target_aesthetic in layer.remappings.aesthetics.keys() {
if !aesthetics_info.contains(target_aesthetic) {
return Err(GgsqlError::ValidationError(format!(
"Layer {}: REMAPPING targets unsupported aesthetic '{}' for geom '{}'",
idx + 1,
translate(target_aesthetic),
layer.geom
)));
}
}
let valid_stat_columns = layer.geom.implicit_valid_stat_columns();
let supports_aggregate = layer.geom.supports_aggregate();
for stat_value in layer.remappings.aesthetics.values() {
if let Some(stat_col) = stat_value.column_name() {
let is_aggregate_stat_col = supports_aggregate
&& (stat_col == "aggregate"
|| stat_col == "count"
|| crate::plot::aesthetic::is_position_aesthetic(stat_col));
if !valid_stat_columns.contains(&stat_col) && !is_aggregate_stat_col {
if valid_stat_columns.is_empty() && !supports_aggregate {
return Err(GgsqlError::ValidationError(format!(
"Layer {}: REMAPPING not supported for geom '{}' (no stat transform)",
idx + 1,
layer.geom
)));
} else {
let mut valid: Vec<String> =
valid_stat_columns.iter().map(|s| s.to_string()).collect();
if supports_aggregate {
valid.push("aggregate".to_string());
valid.push("count".to_string());
}
let valid_refs: Vec<&str> = valid.iter().map(|s| s.as_str()).collect();
return Err(GgsqlError::ValidationError(format!(
"Layer {}: REMAPPING references unknown stat column '{}'. Valid stat columns for geom '{}' are: {}",
idx + 1,
stat_col,
layer.geom,
crate::and_list(&valid_refs)
)));
}
}
}
}
}
Ok(())
}
fn normalize_column_ref(name: &mut String, schema_names: &[&str]) {
if schema_names.contains(&name.as_str()) {
return;
}
let name_lower = name.to_lowercase();
let mut match_iter = schema_names
.iter()
.filter(|s| s.to_lowercase() == name_lower);
if let Some(first) = match_iter.next() {
if match_iter.next().is_none() {
*name = (*first).to_string();
}
}
}
fn normalize_column_references(specs: &mut [Plot], layer_schemas: &[Schema]) {
for spec in specs {
for (layer, schema) in spec.layers.iter_mut().zip(layer_schemas.iter()) {
if matches!(layer.source, Some(DataSource::Annotation)) {
continue;
}
let names: Vec<&str> = schema.iter().map(|c| c.name.as_str()).collect();
for value in layer.mappings.aesthetics.values_mut() {
if let AestheticValue::Column { name, .. } = value {
normalize_column_ref(name, &names);
}
}
for col in &mut layer.partition_by {
normalize_column_ref(col, &names);
}
}
if let Some(facet) = spec.facet.as_mut() {
let normalize_var = |var: &mut String| {
for schema in layer_schemas {
let names: Vec<&str> = schema.iter().map(|c| c.name.as_str()).collect();
let before = var.clone();
normalize_column_ref(var, &names);
if *var != before || names.contains(&var.as_str()) {
break;
}
}
};
match &mut facet.layout {
crate::plot::FacetLayout::Wrap { variables } => {
variables.iter_mut().for_each(normalize_var);
}
crate::plot::FacetLayout::Grid { row, column } => {
row.iter_mut().for_each(normalize_var);
column.iter_mut().for_each(normalize_var);
}
}
}
}
}
fn is_null_sentinel(value: &AestheticValue) -> bool {
matches!(
value,
AestheticValue::Literal(crate::plot::ParameterValue::Null)
)
}
fn merge_global_mappings_into_layers(specs: &mut [Plot], layer_schemas: &[Schema]) {
for spec in specs {
let aesthetic_ctx = spec.get_aesthetic_context();
for (layer, schema) in spec.layers.iter_mut().zip(layer_schemas.iter()) {
if matches!(layer.source, Some(DataSource::Annotation)) {
continue;
}
let supported = layer.geom.aesthetics().supported();
let all_names = layer.geom.aesthetics().names();
let schema_columns: HashSet<&str> = schema.iter().map(|c| c.name.as_str()).collect();
for (aesthetic, value) in &spec.global_mappings.aesthetics {
let is_facet_aesthetic = crate::plot::scale::is_facet_aesthetic(aesthetic.as_str());
let flipped = aesthetic_ctx.flip_position(aesthetic);
if all_names.contains(&aesthetic.as_str())
|| all_names.contains(&flipped.as_str())
|| is_facet_aesthetic
{
layer
.mappings
.aesthetics
.entry(aesthetic.clone())
.or_insert(value.clone());
}
}
let has_wildcard = layer.mappings.wildcard || spec.global_mappings.wildcard;
if has_wildcard {
for aes in &supported {
let user_name = aesthetic_ctx.map_internal_to_user(aes);
if schema_columns.contains(user_name.as_str()) {
layer
.mappings
.aesthetics
.entry(crate::parser::builder::normalise_aes_name(aes))
.or_insert(AestheticValue::standard_column(&user_name));
}
}
}
layer.mappings.wildcard = false;
if layer.geom.aesthetics().contains("geometry")
&& !layer.mappings.aesthetics.contains_key("geometry")
{
if let Some(col) = detect_geometry_column(schema) {
layer
.mappings
.aesthetics
.entry("geometry".to_string())
.or_insert(AestheticValue::standard_column(&col));
}
}
layer
.mappings
.aesthetics
.retain(|_, value| !is_null_sentinel(value));
}
}
}
fn detect_geometry_column(schema: &Schema) -> Option<String> {
use arrow::datatypes::DataType;
fn looks_like_geometry(name: &str) -> bool {
matches!(
name.to_lowercase().as_str(),
"geom" | "geometry" | "wkb_geometry" | "the_geom" | "shape"
)
}
fn is_geometry_type(dtype: &DataType) -> bool {
matches!(
dtype,
DataType::Binary | DataType::LargeBinary | DataType::BinaryView
)
}
let candidates: Vec<_> = schema
.iter()
.filter(|c| looks_like_geometry(&c.name) && is_geometry_type(&c.dtype))
.collect();
if candidates.len() == 1 {
return Some(candidates[0].name.clone());
}
None
}
fn resolve_aesthetic_aliases(spec: &mut Plot) {
use crate::plot::layer::geom::types::AESTHETIC_ALIASES;
for &(alias, targets) in AESTHETIC_ALIASES {
if let Some(idx) = spec.scales.iter().position(|s| s.aesthetic == alias) {
let alias_scale = spec.scales[idx].clone();
for &target in targets {
if !spec.scales.iter().any(|s| s.aesthetic == target) {
let mut target_scale = alias_scale.clone();
target_scale.aesthetic = target.to_string();
spec.scales.push(target_scale);
}
}
spec.scales.remove(idx);
}
for layer in &mut spec.layers {
let aesthetics = layer.geom.aesthetics();
if let Some(value) = layer.mappings.aesthetics.get(alias).cloned() {
for &target in targets {
if aesthetics.is_supported(target) {
layer
.mappings
.aesthetics
.entry(target.to_string())
.or_insert(value.clone());
}
}
layer.mappings.aesthetics.remove(alias);
}
if let Some(value) = layer.parameters.get(alias).cloned() {
for &target in targets {
if aesthetics.is_supported(target) {
layer
.parameters
.entry(target.to_string())
.or_insert(value.clone());
}
}
layer.parameters.remove(alias);
}
}
}
}
fn add_facet_mappings_to_layers(
layers: &mut [Layer],
facet: &crate::plot::Facet,
layer_type_info: &[Vec<schema::TypeInfo>],
) {
for (layer_idx, layer) in layers.iter_mut().enumerate() {
if layer_idx >= layer_type_info.len() {
continue;
}
let type_info = &layer_type_info[layer_idx];
for (var, aesthetic) in facet.layout.get_internal_aesthetic_mappings() {
if layer.mappings.aesthetics.contains_key(&aesthetic) {
continue;
}
if type_info.iter().any(|(col, _, _)| col == var) {
layer.mappings.aesthetics.insert(
aesthetic,
AestheticValue::Column {
name: var.to_string(),
original_name: Some(var.to_string()),
is_dummy: false,
},
);
}
}
}
}
fn identify_layers_missing_facet_column(
layers: &[Layer],
facet: &crate::plot::Facet,
layer_type_info: &[Vec<schema::TypeInfo>],
) -> Vec<bool> {
let facet_variables = facet.get_variables();
if facet_variables.is_empty() {
return vec![false; layers.len()];
}
layers
.iter()
.enumerate()
.map(|(layer_idx, _layer)| {
if layer_idx >= layer_type_info.len() {
return false;
}
let type_info = &layer_type_info[layer_idx];
let schema_columns: std::collections::HashSet<&str> =
type_info.iter().map(|(name, _, _)| name.as_str()).collect();
facet_variables
.iter()
.any(|var| !schema_columns.contains(var.as_str()))
})
.collect()
}
fn get_unique_facet_values(
data_map: &HashMap<String, DataFrame>,
facet_aesthetic: &str,
layers: &[Layer],
layers_missing_facet: &[bool],
) -> Option<arrow::array::ArrayRef> {
let aes_col = naming::aesthetic_column(facet_aesthetic);
let mut all_arrays: Vec<arrow::array::ArrayRef> = Vec::new();
for (idx, layer) in layers.iter().enumerate() {
if idx < layers_missing_facet.len() && layers_missing_facet[idx] {
continue;
}
if let Some(ref data_key) = layer.data_key {
if let Some(df) = data_map.get(data_key) {
if let Ok(col) = df.column(&aes_col) {
all_arrays.push(col.clone());
}
}
}
}
if all_arrays.is_empty() {
return None;
}
let refs: Vec<&dyn arrow::array::Array> = all_arrays.iter().map(|a| a.as_ref()).collect();
let combined = arrow::compute::concat(&refs).ok()?;
use crate::array_util::value_to_string;
let mut seen = std::collections::HashSet::new();
let mut unique_indices = Vec::new();
for i in 0..combined.len() {
let key = value_to_string(&combined, i);
if seen.insert(key) {
unique_indices.push(i as u32);
}
}
let indices = arrow::array::UInt32Array::from(unique_indices);
arrow::compute::take(&*combined, &indices, None).ok()
}
fn cross_join_with_facet_values(
df: &DataFrame,
unique_values: &arrow::array::ArrayRef,
facet_aesthetic: &str,
) -> Result<DataFrame> {
use arrow::array::{Array, UInt32Array};
let aes_col = naming::aesthetic_column(facet_aesthetic);
let n_values = unique_values.len();
if n_values == 0 {
return Ok(df.clone());
}
let n_rows = df.height();
let repeat_indices: Vec<u32> = (0..n_rows)
.flat_map(|i| std::iter::repeat_n(i as u32, n_values))
.collect();
let repeat_idx = UInt32Array::from(repeat_indices);
let col_names = df.get_column_names();
let mut new_columns: Vec<(&str, arrow::array::ArrayRef)> = Vec::new();
for name in &col_names {
let col = df.column(name)?;
let repeated = arrow::compute::take(col.as_ref(), &repeat_idx, None).map_err(|e| {
GgsqlError::InternalError(format!("Failed to repeat column '{}': {}", name, e))
})?;
new_columns.push((name, repeated));
}
let facet_indices: Vec<u32> = (0..n_rows)
.flat_map(|_| (0..n_values).map(|j| j as u32))
.collect();
let facet_idx = UInt32Array::from(facet_indices);
let facet_col = arrow::compute::take(unique_values.as_ref(), &facet_idx, None)
.map_err(|e| GgsqlError::InternalError(format!("Failed to create facet column: {}", e)))?;
new_columns.push((&aes_col, facet_col));
DataFrame::new(new_columns)
}
fn handle_missing_facet_columns(
spec: &Plot,
data_map: &mut HashMap<String, DataFrame>,
layers_missing_facet: &[bool],
) -> Result<()> {
use crate::plot::ParameterValue;
let facet = match &spec.facet {
Some(f) => f,
None => return Ok(()),
};
let missing_setting = facet
.properties
.get("missing")
.and_then(|v| {
if let ParameterValue::String(s) = v {
Some(s.as_str())
} else {
None
}
})
.unwrap_or("repeat");
if missing_setting == "null" {
return Ok(());
}
let facet_aesthetics = facet.layout.internal_facet_names();
for facet_aesthetic in &facet_aesthetics {
let unique_values = match get_unique_facet_values(
data_map,
facet_aesthetic,
&spec.layers,
layers_missing_facet,
) {
Some(v) => v,
None => continue, };
for (idx, layer) in spec.layers.iter().enumerate() {
if idx >= layers_missing_facet.len() || !layers_missing_facet[idx] {
continue;
}
if let Some(ref data_key) = layer.data_key {
if let Some(df) = data_map.get(data_key) {
let aes_col = naming::aesthetic_column(facet_aesthetic);
if df.column(&aes_col).is_err() {
let expanded_df =
cross_join_with_facet_values(df, &unique_values, facet_aesthetic)?;
data_map.insert(data_key.clone(), expanded_df);
}
}
}
}
}
Ok(())
}
fn resolve_facet(
layers: &[crate::plot::Layer],
existing_facet: Option<crate::plot::Facet>,
) -> Result<Option<crate::plot::Facet>> {
use crate::plot::facet::FacetLayout;
use crate::plot::scale::is_facet_aesthetic;
let mut has_facet1 = false;
let mut has_facet2 = false;
for layer in layers {
for aesthetic in layer.mappings.aesthetics.keys() {
if is_facet_aesthetic(aesthetic) {
match aesthetic.as_str() {
"facet1" => has_facet1 = true,
"facet2" => has_facet2 = true,
_ => {}
}
}
}
}
if has_facet2 && !has_facet1 {
return Err(GgsqlError::ValidationError(
"Grid facet layout requires both 'row' and 'column' aesthetics. Missing: 'row'"
.to_string(),
));
}
let inferred_layout = if has_facet1 && has_facet2 {
Some(FacetLayout::Grid {
row: vec![], column: vec![], })
} else if has_facet1 {
Some(FacetLayout::Wrap {
variables: vec![], })
} else {
None
};
if inferred_layout.is_none() && existing_facet.is_none() {
return Ok(None);
}
if let Some(ref facet) = existing_facet {
let is_wrap = facet.is_wrap();
if is_wrap && has_facet2 {
return Err(GgsqlError::ValidationError(
"FACET clause uses Wrap layout, but layer mappings use 'row'/'column' (Grid layout). \
Remove FACET clause to infer Grid layout, or use 'panel' aesthetic instead.".to_string()
));
}
return Ok(Some(facet.clone()));
}
if let Some(layout) = inferred_layout {
return Ok(Some(crate::plot::Facet::new(layout)));
}
Ok(None)
}
fn add_discrete_columns_to_partition_by(
layers: &mut [Layer],
layer_schemas: &[Schema],
scales: &[Scale],
aesthetic_ctx: &AestheticContext,
) {
let scale_map: HashMap<&str, &Scale> =
scales.iter().map(|s| (s.aesthetic.as_str(), s)).collect();
for (layer, schema) in layers.iter_mut().zip(layer_schemas.iter()) {
let schema_columns: HashSet<&str> = schema.iter().map(|c| c.name.as_str()).collect();
let discrete_columns: HashSet<&str> = schema
.iter()
.filter(|c| c.is_discrete)
.map(|c| c.name.as_str())
.collect();
let consumed_aesthetics = layer.geom.stat_consumed_aesthetics();
let mut excluded_aesthetics: HashSet<&str> = consumed_aesthetics.iter().copied().collect();
if !crate::plot::layer::geom::has_aggregate_param(&layer.parameters) {
excluded_aesthetics.insert("label");
}
let agg_targeted: HashSet<String> =
crate::plot::layer::geom::stat_aggregate::aggregated_aesthetics(
&layer.parameters,
&layer.mappings,
schema,
aesthetic_ctx,
layer.geom.aggregate_domain_aesthetics().unwrap_or(&[]),
)
.map(|(t, _)| t)
.unwrap_or_default();
for (aesthetic, value) in &layer.mappings.aesthetics {
if is_position_aesthetic(aesthetic) {
continue;
}
if excluded_aesthetics.contains(aesthetic.as_str()) {
continue;
}
if let Some(col) = value.column_name() {
if !schema_columns.contains(col) {
continue;
}
let primary_aes = aesthetic_ctx
.primary_internal_position(aesthetic)
.unwrap_or(aesthetic);
let is_discrete = if let Some(scale) = scale_map.get(primary_aes) {
if let Some(ref scale_type) = scale.scale_type {
match scale_type.scale_type_kind() {
ScaleTypeKind::Discrete | ScaleTypeKind::Ordinal => true,
ScaleTypeKind::Binned => !agg_targeted.contains(aesthetic),
ScaleTypeKind::Continuous => false,
ScaleTypeKind::Identity => discrete_columns.contains(col),
}
} else {
discrete_columns.contains(col)
}
} else {
discrete_columns.contains(col)
};
if !is_discrete {
continue;
}
let aes_col_name = naming::aesthetic_column(aesthetic);
if layer.partition_by.contains(&aes_col_name) {
continue;
}
layer.partition_by.push(aes_col_name);
}
}
}
}
fn collect_layer_required_columns(layer: &Layer, spec: &Plot) -> HashSet<String> {
use crate::plot::layer::geom::GeomType;
let mut required = HashSet::new();
if let Some(ref facet) = spec.facet {
for aesthetic in facet.layout.internal_facet_names() {
required.insert(naming::aesthetic_column(&aesthetic));
}
}
for aesthetic in layer.mappings.aesthetics.keys() {
let aes_col = naming::aesthetic_column(aesthetic);
required.insert(aes_col.clone());
if let Some(scale) = spec.find_scale(aesthetic) {
if let Some(ref scale_type) = scale.scale_type {
if scale_type.scale_type_kind() == ScaleTypeKind::Binned {
required.insert(naming::bin_end_column(&aes_col));
}
}
}
}
for col in &layer.partition_by {
required.insert(col.clone());
}
if layer.geom.geom_type() == GeomType::Path {
required.insert(naming::ORDER_COLUMN.to_string());
}
if layer.position.creates_pos1offset() {
required.insert(naming::aesthetic_column("pos1offset"));
}
if layer.position.creates_pos2offset() {
required.insert(naming::aesthetic_column("pos2offset"));
}
required
}
fn prune_dataframe(df: &DataFrame, required: &HashSet<String>) -> Result<DataFrame> {
let columns_to_keep: Vec<String> = df
.get_column_names()
.into_iter()
.filter(|name| required.contains(name.as_str()))
.map(|name| name.to_string())
.collect();
if columns_to_keep.is_empty() {
let row_count = df.height();
if row_count > 0 {
let with_rows = crate::df! {
"__dummy__" => vec![0i32; row_count]
}?;
return with_rows.drop("__dummy__");
} else {
return Ok(DataFrame::empty());
}
}
let drop_cols: Vec<String> = df
.get_column_names()
.into_iter()
.filter(|name| !columns_to_keep.contains(name))
.collect();
df.drop_many(&drop_cols)
}
fn prune_dataframes_per_layer(
specs: &[Plot],
data_map: &mut HashMap<String, DataFrame>,
) -> Result<()> {
for spec in specs {
for layer in &spec.layers {
if let Some(ref data_key) = layer.data_key {
if let Some(df) = data_map.get(data_key) {
let required = collect_layer_required_columns(layer, spec);
let pruned = prune_dataframe(df, &required)?;
data_map.insert(data_key.clone(), pruned);
}
}
}
}
Ok(())
}
pub struct PreparedData {
pub data: HashMap<String, DataFrame>,
pub specs: Vec<Plot>,
pub sql: String,
pub visual: String,
}
pub fn prepare_data_with_reader(query: &str, reader: &dyn Reader) -> Result<PreparedData> {
let execute_query = |sql: &str| reader.execute_sql(sql);
let dialect = reader.dialect();
let source_tree = parser::SourceTree::new(query)?;
source_tree.validate()?;
let root = source_tree.root();
if source_tree
.find_node(&root, "(visualise_statement) @viz")
.is_none()
{
return Err(GgsqlError::ValidationError(
"No visualization specifications found".to_string(),
));
}
let mut specs = parser::build_ast(&source_tree)?;
if specs.is_empty() {
return Err(GgsqlError::ValidationError(
"No visualization specifications found".to_string(),
));
}
for stmt in source_tree.find_texts(&root, "(sql_statement (other_sql_statement) @stmt)") {
execute_query(&stmt)?;
}
let ctes = cte::extract_ctes(&source_tree);
let materialized_ctes = cte::materialize_ctes(&ctes, reader)?;
let mut data_map: HashMap<String, DataFrame> = HashMap::new();
let sql_part = source_tree.extract_sql();
let mut has_global_table = false;
if sql_part.is_some() {
let (side_effects, query) = cte::transform_global_sql(&source_tree, &materialized_ctes);
for stmt in &side_effects {
execute_query(stmt)?;
}
if let Some(query) = query {
let statements = reader.dialect().create_or_replace_temp_table_sql(
&naming::global_table(),
&[],
&query,
);
for stmt in &statements {
execute_query(stmt)?;
}
has_global_table = true;
}
}
for (idx, layer) in specs[0].layers.iter().enumerate() {
if layer.source.is_none() && !has_global_table {
return Err(GgsqlError::ValidationError(format!(
"Layer {} has no data source. Either provide a SQL query before VISUALISE or use FROM in the layer.",
idx + 1
)));
}
}
let layer_source_queries: Vec<String> = specs[0]
.layers
.iter_mut()
.map(|l| layer::layer_source_query(l, &materialized_ctes, has_global_table, dialect))
.collect::<Result<Vec<_>>>()?;
let mut layer_type_info: Vec<Vec<schema::TypeInfo>> = Vec::new();
for source_query in &layer_source_queries {
let type_info = schema::fetch_schema_types(source_query, &execute_query)?;
layer_type_info.push(type_info);
}
let mut layer_schemas: Vec<Schema> = layer_type_info
.iter()
.map(|ti| schema::type_info_to_schema(ti))
.collect();
merge_global_mappings_into_layers(&mut specs, &layer_schemas);
normalize_column_references(&mut specs, &layer_schemas);
for spec in &mut specs {
resolve_aesthetic_aliases(spec);
}
schema::add_literal_columns_to_type_info(&specs[0].layers, &mut layer_type_info);
layer_schemas = layer_type_info
.iter()
.map(|ti| schema::type_info_to_schema(ti))
.collect();
specs[0].facet = resolve_facet(&specs[0].layers, specs[0].facet.clone())?;
if let Some(facet) = specs[0].facet.clone() {
add_facet_mappings_to_layers(&mut specs[0].layers, &facet, &layer_type_info);
}
let layers_missing_facet = if let Some(ref facet) = specs[0].facet {
identify_layers_missing_facet_column(&specs[0].layers, facet, &layer_type_info)
} else {
vec![false; specs[0].layers.len()]
};
validate(
&specs[0].layers,
&layer_schemas,
&specs[0].aesthetic_context,
)?;
for spec in &mut specs {
for layer in &mut spec.layers {
layer
.geom
.setup_layer(&mut layer.mappings, &mut layer.parameters)?;
}
}
scale::create_missing_scales(&mut specs[0]);
scale::resolve_scale_types_and_transforms(&mut specs[0], &layer_type_info)?;
let type_requirements =
casting::determine_type_requirements(&specs[0], &layer_type_info, dialect);
for (layer_idx, requirements) in type_requirements.iter().enumerate() {
if layer_idx < layer_type_info.len() {
casting::update_type_info_for_casting(&mut layer_type_info[layer_idx], requirements);
}
}
let scales = specs[0].scales.clone();
let aesthetic_ctx = specs[0].get_aesthetic_context();
layer::resolve_orientations(
&mut specs[0].layers,
&scales,
&mut layer_type_info,
&aesthetic_ctx,
);
let layer_base_queries: Vec<String> = specs[0]
.layers
.iter()
.enumerate()
.map(|(idx, l)| {
layer::build_layer_base_query(
l,
&layer_source_queries[idx],
&type_requirements[idx],
dialect,
)
})
.collect();
for (idx, base_query) in layer_base_queries.iter().enumerate() {
layer_schemas[idx] =
schema::complete_schema_ranges(base_query, &layer_type_info[idx], &execute_query)?;
}
scale::apply_pre_stat_resolve(&mut specs[0], &layer_schemas)?;
let scales = specs[0].scales.clone();
let aesthetic_ctx = specs[0].get_aesthetic_context();
add_discrete_columns_to_partition_by(
&mut specs[0].layers,
&layer_schemas,
&scales,
&aesthetic_ctx,
);
let scales = specs[0].scales.clone();
let mut layer_queries: Vec<String> = Vec::new();
for (idx, l) in specs[0].layers.iter_mut().enumerate() {
if let Some(weight_value) = l.mappings.aesthetics.get("weight") {
if weight_value.is_literal() {
return Err(GgsqlError::ValidationError(
"Bar weight aesthetic must be a column, not a literal".to_string(),
));
}
}
l.apply_default_params();
let layer_query = layer::apply_layer_transforms(
l,
&layer_base_queries[idx],
&layer_schemas[idx],
&scales,
dialect,
&execute_query,
&aesthetic_ctx,
)?;
layer_queries.push(layer_query);
}
let mut project = specs[0]
.project
.take()
.unwrap_or_else(crate::plot::projection::Projection::cartesian);
if project.coord.coord_kind() == crate::plot::projection::coord::CoordKind::Map {
crate::plot::projection::coord::map::resolve_map_projection(
&mut project,
&specs[0].layers,
&layer_queries,
dialect,
&execute_query,
)?;
}
project.apply_projection_transforms(
&mut specs[0].layers,
&mut layer_queries,
dialect,
&execute_query,
)?;
specs[0].project = Some(project);
let mut query_to_result: HashMap<String, DataFrame> = HashMap::new();
for (idx, q) in layer_queries.iter().enumerate() {
if !query_to_result.contains_key(q) {
let df = execute_query(q).map_err(|e| {
GgsqlError::ReaderError(format!(
"Failed to fetch data for layer {}: {}",
idx + 1,
e
))
})?;
query_to_result.insert(q.clone(), df);
}
}
let mut config_to_key: HashMap<(String, String, bool), String> = HashMap::new();
for (idx, q) in layer_queries.iter().enumerate() {
let layer = &mut specs[0].layers[idx];
let remappings_key = serde_json::to_string(&layer.remappings).unwrap_or_default();
let needs_flip = is_transposed(layer);
let config_key = (q.clone(), remappings_key, needs_flip);
if let Some(existing_key) = config_to_key.get(&config_key) {
layer.data_key = Some(existing_key.clone());
} else {
let layer_key = naming::layer_key(idx);
let df = query_to_result.get(q).unwrap().clone();
data_map.insert(layer_key.clone(), df);
config_to_key.insert(config_key, layer_key.clone());
layer.data_key = Some(layer_key);
}
}
let mut processed_keys: HashSet<String> = HashSet::new();
for l in specs[0].layers.iter_mut() {
if let Some(ref key) = l.data_key {
if processed_keys.insert(key.clone()) {
if let Some(df) = data_map.remove(key) {
let df_with_remappings = layer::apply_remappings_post_query(df, l)?;
let df_post_processed =
l.geom.post_process(df_with_remappings, &l.parameters)?;
data_map.insert(key.clone(), df_post_processed);
}
}
if is_transposed(l) {
crate::plot::layer::orientation::flip_position_aesthetics(
&mut l.remappings.aesthetics,
);
}
l.update_mappings_for_remappings();
}
l.resolve_aesthetics();
}
let mut flipped_keys: HashSet<String> = HashSet::new();
for layer in specs[0].layers.iter() {
if is_transposed(layer) {
if let Some(ref key) = layer.data_key {
if flipped_keys.insert(key.clone()) {
if let Some(df) = data_map.remove(key) {
let flipped_df =
crate::plot::layer::orientation::flip_dataframe_position_columns(
df,
&aesthetic_ctx,
);
data_map.insert(key.clone(), flipped_df);
}
}
}
}
}
for spec in &mut specs {
scale::create_missing_scales_post_stat(spec, &data_map)?;
}
for spec in &mut specs {
position::apply_position_adjustments(spec, &mut data_map)?;
}
if data_map.is_empty() {
return Err(GgsqlError::ValidationError(
"No data sources found. Either provide a SQL query or use MAPPING FROM in layers."
.to_string(),
));
}
for spec in &mut specs {
spec.compute_aesthetic_labels();
}
for spec in &mut specs {
scale::resolve_scales(spec, &mut data_map)?;
}
for spec in &mut specs {
if let Some(ref mut project) = spec.project {
resolve_projection_properties(project, &spec.scales)?;
}
}
for spec in &mut specs {
let position_names: Vec<String> = spec.get_aesthetic_context().user_position().to_vec();
let position_refs: Vec<&str> = position_names.iter().map(|s| s.as_str()).collect();
if let Some(ref mut facet) = spec.facet {
let facet_df = data_map.get(&naming::layer_key(0)).ok_or_else(|| {
GgsqlError::InternalError("Missing layer 0 data for facet resolution".to_string())
})?;
let aesthetic_cols: Vec<String> = facet
.layout
.internal_facet_names()
.iter()
.map(|aes| naming::aesthetic_column(aes))
.collect();
let context = FacetDataContext::from_dataframe(facet_df, &aesthetic_cols);
resolve_facet_properties(facet, &context, &position_refs)
.map_err(|e| GgsqlError::ValidationError(format!("Facet: {}", e)))?;
}
}
for spec in &specs {
scale::apply_post_stat_binning(spec, &mut data_map)?;
}
for spec in &specs {
scale::apply_scale_oob(spec, &mut data_map)?;
}
for spec in &specs {
handle_missing_facet_columns(spec, &mut data_map, &layers_missing_facet)?;
}
prune_dataframes_per_layer(&specs, &mut data_map)?;
let visual_part = source_tree.extract_visualise().unwrap_or_default();
Ok(PreparedData {
data: data_map,
specs,
sql: sql_part.unwrap_or_default(),
visual: visual_part,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "duckdb")]
#[test]
fn test_prepare_data_global_only() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
let query = "SELECT 1 as x, 2 as y VISUALISE x, y DRAW point";
let result = prepare_data_with_reader(query, &reader).unwrap();
assert!(result.data.contains_key(&naming::layer_key(0)));
assert_eq!(result.specs.len(), 1);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_prepare_data_no_viz() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
let query = "SELECT 1 as x, 2 as y";
let result = prepare_data_with_reader(query, &reader);
assert!(result.is_err());
}
#[cfg(feature = "duckdb")]
#[test]
fn test_prepare_data_layer_source() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE test_data AS SELECT 1 as a, 2 as b",
duckdb::params![],
)
.unwrap();
let query = "VISUALISE DRAW point MAPPING a AS x, b AS y FROM test_data";
let result = prepare_data_with_reader(query, &reader).unwrap();
assert!(result.data.contains_key(&naming::layer_key(0)));
assert!(!result.data.contains_key(naming::GLOBAL_DATA_KEY));
}
#[cfg(feature = "duckdb")]
#[test]
fn test_prepare_data_with_filter_on_global() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE filter_test AS SELECT * FROM (VALUES
(1, 10, 'A'),
(2, 20, 'B'),
(3, 30, 'A'),
(4, 40, 'B')
) AS t(id, value, category)",
duckdb::params![],
)
.unwrap();
let query = "SELECT * FROM filter_test VISUALISE DRAW point MAPPING id AS x, value AS y FILTER category = 'A'";
let result = prepare_data_with_reader(query, &reader).unwrap();
assert!(!result.data.contains_key(naming::GLOBAL_DATA_KEY));
assert!(result.data.contains_key(&naming::layer_key(0)));
let layer_df = result.data.get(&naming::layer_key(0)).unwrap();
assert_eq!(layer_df.height(), 2);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_layer_references_cte_from_global() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
let query = r#"
WITH sales AS (
SELECT 1 as date, 100 as revenue, 'A' as region
UNION ALL
SELECT 2, 200, 'B'
),
targets AS (
SELECT 1 as date, 150 as goal
UNION ALL
SELECT 2, 180
)
SELECT * FROM sales
VISUALISE
DRAW line MAPPING date AS x, revenue AS y
DRAW point MAPPING date AS x, goal AS y FROM targets
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
assert!(result.data.contains_key(&naming::layer_key(0)));
assert!(result.data.contains_key(&naming::layer_key(1)));
let layer0_df = result.data.get(&naming::layer_key(0)).unwrap();
assert_eq!(layer0_df.height(), 2);
let layer1_df = result.data.get(&naming::layer_key(1)).unwrap();
assert_eq!(layer1_df.height(), 2);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_layer_references_cte_with_column_aliases() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
let query = r#"
WITH t(value, label) AS (
SELECT * FROM (VALUES
(70, 'Target'),
(80, 'Warning'),
(90, 'Critical')
)
)
SELECT 1 AS date, 75 AS temperature
VISUALISE
DRAW line MAPPING date AS x, temperature AS y
DRAW rule MAPPING value AS y, label AS colour FROM t
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
let layer0_df = result.data.get(&naming::layer_key(0)).unwrap();
assert_eq!(layer0_df.height(), 1);
let layer1_df = result.data.get(&naming::layer_key(1)).unwrap();
assert_eq!(layer1_df.height(), 3);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_histogram_stat_transform() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE hist_test AS SELECT RANDOM() * 100 as value FROM range(100)",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT * FROM hist_test
VISUALISE
DRAW histogram MAPPING value AS x
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
assert!(result.data.contains_key(&naming::layer_key(0)));
let layer_df = result.data.get(&naming::layer_key(0)).unwrap();
let col_names: Vec<String> = layer_df
.get_column_names()
.iter()
.map(|s| s.to_string())
.collect();
let x_col = naming::aesthetic_column("pos1");
let y_col = naming::aesthetic_column("pos2");
assert!(
col_names.contains(&x_col),
"Should have '{}' column: {:?}",
x_col,
col_names
);
assert!(
col_names.contains(&y_col),
"Should have '{}' column: {:?}",
y_col,
col_names
);
assert!(layer_df.height() < 100);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_bar_count_stat_transform() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE bar_test AS SELECT * FROM (VALUES ('A'), ('B'), ('A'), ('C'), ('A'), ('B')) AS t(category)",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT * FROM bar_test
VISUALISE
DRAW bar MAPPING category AS x
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
assert!(result.data.contains_key(&naming::layer_key(0)));
let layer_df = result.data.get(&naming::layer_key(0)).unwrap();
assert_eq!(layer_df.height(), 3);
let col_names: Vec<String> = layer_df
.get_column_names()
.iter()
.map(|s| s.to_string())
.collect();
let x_col = naming::aesthetic_column("pos1");
let y_col = naming::aesthetic_column("pos2");
assert!(
col_names.contains(&x_col),
"Expected '{}' in {:?}",
x_col,
col_names
);
assert!(
col_names.contains(&y_col),
"Expected '{}' in {:?}",
y_col,
col_names
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_bar_uses_y_when_mapped() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE bar_y_test AS SELECT * FROM (VALUES ('A', 10), ('B', 20), ('C', 30)) AS t(category, value)",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT * FROM bar_y_test
VISUALISE
DRAW bar MAPPING category AS x, value AS y
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
let layer_df = result.data.get(&naming::layer_key(0)).unwrap();
assert_eq!(layer_df.height(), 3);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_bar_adds_y2_zero_for_baseline() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE bar_y2_test AS SELECT * FROM (VALUES
('A', 10), ('B', 20), ('C', 30)
) AS t(category, value)",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT * FROM bar_y2_test
VISUALISE category AS x, value AS y
DRAW bar
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
let layer = &result.specs[0].layers[0];
assert!(
layer.mappings.aesthetics.contains_key("pos2end"),
"Bar should have pos2end mapping for baseline: {:?}",
layer.mappings.aesthetics.keys().collect::<Vec<_>>()
);
let layer_df = result.data.get(&naming::layer_key(0)).unwrap();
let yend_col = naming::aesthetic_column("pos2end");
assert!(
layer_df.column(¥d_col).is_ok(),
"DataFrame should have '{}' column: {:?}",
yend_col,
layer_df.get_column_names()
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_resolve_scales_numeric_to_continuous() {
use crate::plot::ScaleType;
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
let query = r#"
SELECT 1.0 as x, 2.0 as y FROM (VALUES (1))
VISUALISE x, y
DRAW point
SCALE x FROM [0, 100]
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
let spec = &result.specs[0];
let x_scale = spec.find_scale("pos1").expect("pos1 scale should exist");
assert_eq!(
x_scale.scale_type,
Some(ScaleType::continuous()),
"Numeric column should infer Continuous scale type"
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_resolve_scales_string_to_discrete() {
use crate::plot::ScaleType;
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
let query = r#"
SELECT 'A' as category, 100 as value FROM (VALUES (1))
VISUALISE category AS x, value AS y
DRAW bar
SCALE x FROM ['A', 'B', 'C']
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
let spec = &result.specs[0];
let x_scale = spec.find_scale("pos1").expect("pos1 scale should exist");
assert_eq!(
x_scale.scale_type,
Some(ScaleType::discrete()),
"String column should infer Discrete scale type"
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_visualise_from_cte() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
let query = r#"
WITH monthly AS (
SELECT 1 as month, 1000 as revenue
UNION ALL SELECT 2, 1200
UNION ALL SELECT 3, 1100
)
VISUALISE month AS x, revenue AS y FROM monthly
DRAW line
DRAW point
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
let layer0_key = result.specs[0].layers[0]
.data_key
.as_ref()
.expect("Layer 0 should have data_key");
let layer1_key = result.specs[0].layers[1]
.data_key
.as_ref()
.expect("Layer 1 should have data_key");
assert!(
result.data.contains_key(layer0_key),
"Should have layer 0 data"
);
assert!(
result.data.contains_key(layer1_key),
"Should have layer 1 data"
);
assert_eq!(result.data.get(layer0_key).unwrap().height(), 3);
assert_eq!(result.data.get(layer1_key).unwrap().height(), 3);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_visualise_from_after_create() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
let query = r#"
CREATE TEMP TABLE data(x, y) AS (VALUES
('A', 5),
('B', 2),
('C', 4),
('D', 7),
('E', 6)
)
VISUALISE x, y FROM data
DRAW area
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
let key = result.specs[0].layers[0]
.data_key
.as_ref()
.expect("Layer should have data_key");
assert_eq!(result.data.get(key).unwrap().height(), 5);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_visualise_from_after_create_and_insert() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
let query = r#"
CREATE TEMP TABLE data(x INTEGER, y INTEGER);
INSERT INTO data VALUES (1, 10), (2, 20), (3, 30);
VISUALISE x, y FROM data
DRAW point
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
let key = result.specs[0].layers[0]
.data_key
.as_ref()
.expect("Layer should have data_key");
assert_eq!(result.data.get(key).unwrap().height(), 3);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_select_after_create_and_insert() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
let query = r#"
CREATE TEMP TABLE data(x INTEGER, y INTEGER);
INSERT INTO data VALUES (1, 10), (2, 20), (3, 30);
SELECT * FROM data
VISUALISE x, y
DRAW point
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
let key = result.specs[0].layers[0]
.data_key
.as_ref()
.expect("Layer should have data_key");
assert_eq!(result.data.get(key).unwrap().height(), 3);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_histogram_with_literal_mapping() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE hist_literal_test AS SELECT RANDOM() * 100 as value FROM range(100)",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT * FROM hist_literal_test
VISUALISE value AS x
DRAW histogram MAPPING 'foo' AS stroke
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
assert!(result.data.contains_key(&naming::layer_key(0)));
let layer_df = result.data.get(&naming::layer_key(0)).unwrap();
let col_names: Vec<String> = layer_df
.get_column_names()
.iter()
.map(|s| s.to_string())
.collect();
let x_col = naming::aesthetic_column("pos1");
let y_col = naming::aesthetic_column("pos2");
let stroke_col = naming::aesthetic_column("stroke");
assert!(
col_names.contains(&x_col),
"Should have '{}' column: {:?}",
x_col,
col_names
);
assert!(
col_names.contains(&y_col),
"Should have '{}' column: {:?}",
y_col,
col_names
);
assert!(
col_names.contains(&stroke_col),
"Should have '{}' column (literal mapping should survive stat transform): {:?}",
stroke_col,
col_names
);
assert!(layer_df.height() < 100);
}
mod resolve_facet_tests {
use super::*;
use crate::plot::facet::FacetLayout;
use crate::plot::layer::geom::Geom;
use crate::plot::layer::Layer;
use crate::plot::Facet;
fn make_layer_with_mapping(aesthetic: &str, column: &str) -> Layer {
let mut layer = Layer::new(Geom::point());
layer.mappings.aesthetics.insert(
aesthetic.to_string(),
AestheticValue::standard_column(column),
);
layer
}
#[test]
fn test_resolve_facet_infers_wrap_from_layer_mapping() {
let layers = vec![make_layer_with_mapping("facet1", "region")];
let result = resolve_facet(&layers, None).unwrap();
assert!(result.is_some());
let facet = result.unwrap();
assert!(facet.is_wrap());
assert!(facet.get_variables().is_empty());
}
#[test]
fn test_resolve_facet_infers_grid_from_layer_mappings() {
let mut layer = Layer::new(Geom::point());
layer.mappings.aesthetics.insert(
"facet1".to_string(),
AestheticValue::standard_column("region"),
);
layer.mappings.aesthetics.insert(
"facet2".to_string(),
AestheticValue::standard_column("year"),
);
let layers = vec![layer];
let result = resolve_facet(&layers, None).unwrap();
assert!(result.is_some());
let facet = result.unwrap();
assert!(facet.is_grid());
assert!(facet.get_variables().is_empty());
}
#[test]
fn test_resolve_facet_error_incomplete_grid() {
let layers = vec![make_layer_with_mapping("facet2", "region")];
let result = resolve_facet(&layers, None);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("requires both"));
assert!(err.contains("row"));
}
#[test]
fn test_resolve_facet_uses_existing_facet_clause() {
let layers = vec![Layer::new(Geom::point())];
let existing_facet = Facet::new(FacetLayout::Wrap {
variables: vec!["region".to_string()],
});
let result = resolve_facet(&layers, Some(existing_facet.clone())).unwrap();
assert!(result.is_some());
let facet = result.unwrap();
assert!(facet.is_wrap());
assert_eq!(facet.get_variables(), vec!["region".to_string()]);
}
#[test]
fn test_resolve_facet_error_wrap_clause_with_grid_mapping() {
let mut layer = Layer::new(Geom::point());
layer.mappings.aesthetics.insert(
"facet1".to_string(),
AestheticValue::standard_column("category"),
);
layer.mappings.aesthetics.insert(
"facet2".to_string(),
AestheticValue::standard_column("year"),
);
let layers = vec![layer];
let existing_facet = Facet::new(FacetLayout::Wrap {
variables: vec!["region".to_string()],
});
let result = resolve_facet(&layers, Some(existing_facet));
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Wrap layout"));
assert!(err.contains("row")); }
#[test]
fn test_resolve_facet_no_mappings_no_clause() {
let layers = vec![Layer::new(Geom::point())];
let result = resolve_facet(&layers, None).unwrap();
assert!(result.is_none());
}
#[test]
fn test_resolve_facet_layer_override_compatible_with_clause() {
let layers = vec![make_layer_with_mapping("facet1", "category")];
let existing_facet = Facet::new(FacetLayout::Wrap {
variables: vec!["region".to_string()],
});
let result = resolve_facet(&layers, Some(existing_facet)).unwrap();
assert!(result.is_some());
assert!(result.unwrap().is_wrap());
}
}
#[cfg(feature = "duckdb")]
#[test]
fn test_facet_aesthetic_mapping_wrap() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE facet_test AS SELECT * FROM (VALUES
(1, 10, 'A'), (2, 20, 'A'), (3, 30, 'B'), (4, 40, 'B')
) AS t(x, y, region)",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT * FROM facet_test
VISUALISE
DRAW point MAPPING x AS x, y AS y, region AS panel
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
assert!(result.specs[0].facet.is_some());
let facet = result.specs[0].facet.as_ref().unwrap();
assert!(facet.is_wrap());
let layer_df = result.data.get(&naming::layer_key(0)).unwrap();
let facet_col = naming::aesthetic_column("facet1");
assert!(
layer_df.column(&facet_col).is_ok(),
"Should have '{}' column: {:?}",
facet_col,
layer_df.get_column_names()
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_facet_aesthetic_mapping_grid() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE grid_facet_test AS SELECT * FROM (VALUES
(1, 10, 'A', 2020), (2, 20, 'B', 2020),
(3, 30, 'A', 2021), (4, 40, 'B', 2021)
) AS t(x, y, region, year)",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT * FROM grid_facet_test
VISUALISE
DRAW point MAPPING x AS x, y AS y, region AS row, year AS column
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
assert!(result.specs[0].facet.is_some());
let facet = result.specs[0].facet.as_ref().unwrap();
assert!(facet.is_grid());
let layer_df = result.data.get(&naming::layer_key(0)).unwrap();
let row_col = naming::aesthetic_column("facet1");
let col_col = naming::aesthetic_column("facet2");
assert!(
layer_df.column(&row_col).is_ok(),
"Should have '{}' column",
row_col
);
assert!(
layer_df.column(&col_col).is_ok(),
"Should have '{}' column",
col_col
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_facet_global_mapping() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE global_facet_test AS SELECT * FROM (VALUES
(1, 10, 'A'), (2, 20, 'B')
) AS t(x, y, region)",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT * FROM global_facet_test
VISUALISE region AS panel
DRAW point MAPPING x AS x, y AS y
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
assert!(result.specs[0].facet.is_some());
assert!(result.specs[0].facet.as_ref().unwrap().is_wrap());
}
#[cfg(feature = "duckdb")]
#[test]
fn test_facet_layer_override_of_facet_clause() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE override_test AS SELECT * FROM (VALUES
(1, 10, 'A', 'X'), (2, 20, 'B', 'Y')
) AS t(x, y, region, category)",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT * FROM override_test
VISUALISE
FACET region
DRAW point MAPPING x AS x, y AS y, category AS panel
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
let layer = &result.specs[0].layers[0];
let facet_mapping = layer.mappings.aesthetics.get("facet1").unwrap();
assert_eq!(
facet_mapping.label_name(),
Some("category"),
"Layer should override FACET clause with category column"
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_facet_missing_repeat_broadcasts_layer() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE main_data AS SELECT * FROM (VALUES
(1, 10, 'A'), (2, 20, 'A'), (3, 30, 'B'), (4, 40, 'B')
) AS t(x, y, region)",
duckdb::params![],
)
.unwrap();
reader
.connection()
.execute(
"CREATE TABLE ref_data AS SELECT * FROM (VALUES
(0, 25)
) AS t(x, y)",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT * FROM main_data
VISUALISE
FACET region
DRAW point MAPPING x AS x, y AS y
DRAW point MAPPING x AS x, y AS y FROM ref_data
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
let ref_key = result.specs[0].layers[1]
.data_key
.as_ref()
.expect("ref layer should have data_key");
let ref_df = result.data.get(ref_key).unwrap();
assert_eq!(
ref_df.height(),
2,
"ref layer should be repeated for each facet panel (A and B)"
);
let facet_col = naming::aesthetic_column("facet1");
assert!(
ref_df.column(&facet_col).is_ok(),
"ref data should have facet column after broadcast: {:?}",
ref_df.get_column_names()
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_facet_missing_null_no_broadcast() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE main_data_null AS SELECT * FROM (VALUES
(1, 10, 'A'), (2, 20, 'A'), (3, 30, 'B'), (4, 40, 'B')
) AS t(x, y, region)",
duckdb::params![],
)
.unwrap();
reader
.connection()
.execute(
"CREATE TABLE ref_data_null AS SELECT * FROM (VALUES
(0, 25)
) AS t(x, y)",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT * FROM main_data_null
VISUALISE
FACET region SETTING missing => 'null'
DRAW point MAPPING x AS x, y AS y
DRAW point MAPPING x AS x, y AS y FROM ref_data_null
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
let ref_key = result.specs[0].layers[1]
.data_key
.as_ref()
.expect("ref layer should have data_key");
let ref_df = result.data.get(ref_key).unwrap();
assert_eq!(
ref_df.height(),
1,
"ref layer should NOT be repeated with missing => 'null'"
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_facet_missing_repeat_grid_layout() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE grid_main AS SELECT * FROM (VALUES
(1, 10, 'A', 2020), (2, 20, 'A', 2021),
(3, 30, 'B', 2020), (4, 40, 'B', 2021)
) AS t(x, y, region, year)",
duckdb::params![],
)
.unwrap();
reader
.connection()
.execute(
"CREATE TABLE grid_ref AS SELECT * FROM (VALUES
(0, 25)
) AS t(x, y)",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT * FROM grid_main
VISUALISE
FACET region BY year
DRAW point MAPPING x AS x, y AS y
DRAW point MAPPING x AS x, y AS y FROM grid_ref
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
let ref_key = result.specs[0].layers[1]
.data_key
.as_ref()
.expect("ref layer should have data_key");
let ref_df = result.data.get(ref_key).unwrap();
assert_eq!(
ref_df.height(),
4,
"ref layer should be repeated for each grid panel (2 regions x 2 years)"
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_facet_missing_layer_with_facet_column_unchanged() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE both_have_facet AS SELECT * FROM (VALUES
(1, 10, 'A'), (2, 20, 'B')
) AS t(x, y, region)",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT * FROM both_have_facet
VISUALISE
FACET region
DRAW point MAPPING x AS x, y AS y
DRAW line MAPPING x AS x, y AS y
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
let point_key = result.specs[0].layers[0].data_key.as_ref().unwrap();
let line_key = result.specs[0].layers[1].data_key.as_ref().unwrap();
let point_df = result.data.get(point_key).unwrap();
let line_df = result.data.get(line_key).unwrap();
assert_eq!(
point_df.height(),
2,
"point layer with facet column should not be expanded"
);
assert_eq!(
line_df.height(),
2,
"line layer with facet column should not be expanded"
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_place_annotation_layer() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE test_place AS SELECT * FROM (VALUES (1, 10), (2, 20), (3, 30)) AS t(x, y)",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT * FROM test_place
VISUALISE x, y
DRAW point
PLACE text SETTING x => 2, y => 25, label => 'Annotation', fontsize => 14
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
assert_eq!(result.specs.len(), 1);
assert_eq!(
result.specs[0].layers.len(),
2,
"Should have DRAW + PLACE layers"
);
let point_layer = &result.specs[0].layers[0];
assert_eq!(point_layer.geom, crate::Geom::point());
assert!(
point_layer.source.is_none(),
"DRAW layer should have no explicit source"
);
let annotation_layer = &result.specs[0].layers[1];
assert_eq!(annotation_layer.geom, crate::Geom::text());
assert!(
matches!(annotation_layer.source, Some(DataSource::Annotation)),
"PLACE layer should have Annotation source"
);
let annotation_key = annotation_layer.data_key.as_ref().unwrap();
let annotation_df = result.data.get(annotation_key).unwrap();
assert_eq!(
annotation_df.height(),
1,
"Annotation layer should have exactly 1 row"
);
assert!(
matches!(
annotation_layer.mappings.get("pos1"),
Some(AestheticValue::Column { name, .. }) if name == "__ggsql_aes_pos1__"
),
"x should be transformed to pos1, moved to mappings, and materialized as column"
);
assert!(
matches!(
annotation_layer.mappings.get("pos2"),
Some(AestheticValue::Column { name, .. }) if name == "__ggsql_aes_pos2__"
),
"y should be transformed to pos2, moved to mappings, and materialized as column"
);
assert!(
matches!(
annotation_layer.mappings.get("label"),
Some(AestheticValue::AnnotationColumn { name }) if name == "__ggsql_aes_label__"
),
"label (required) should be in mappings as AnnotationColumn with prefixed name"
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_place_annotation_with_stat_geom() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
let query = r#"
VISUALISE
PLACE histogram SETTING x => [1.2, 2.5, 3.1, 2.8, 1.9, 2.2, 3.5, 2.1, 1.8, 2.9], bins => 5
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
assert_eq!(result.specs.len(), 1);
assert_eq!(
result.specs[0].layers.len(),
1,
"Should have one PLACE layer"
);
let histogram_layer = &result.specs[0].layers[0];
assert_eq!(histogram_layer.geom, crate::Geom::histogram());
assert!(
matches!(histogram_layer.source, Some(DataSource::Annotation)),
"PLACE layer should have Annotation source"
);
assert!(
histogram_layer.mappings.contains_key("pos1"),
"Histogram should have pos1 aesthetic (bin start)"
);
assert!(
histogram_layer.mappings.contains_key("pos1end"),
"Histogram should have pos1end aesthetic (bin end)"
);
assert!(
histogram_layer.mappings.contains_key("pos2"),
"Histogram should have pos2 aesthetic (count)"
);
let histogram_key = histogram_layer.data_key.as_ref().unwrap();
let histogram_df = result.data.get(histogram_key).unwrap();
assert!(
histogram_df.height() > 0,
"Histogram should produce binned data"
);
assert!(
histogram_df.height() <= 5,
"Histogram with 5 bins should produce at most 5 rows"
);
assert!(
histogram_df.column("__ggsql_aes_pos1__").is_ok(),
"Should have bin start column"
);
assert!(
histogram_df.column("__ggsql_aes_pos1end__").is_ok(),
"Should have bin end column"
);
assert!(
histogram_df.column("__ggsql_aes_pos2__").is_ok(),
"Should have count column"
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_place_missing_required_aesthetic() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
let query = r#"
SELECT 1 AS x, 2 AS y
VISUALISE x, y
DRAW point
PLACE text SETTING x => 5, label => 'Missing y!'
"#;
let result = prepare_data_with_reader(query, &reader);
assert!(result.is_err(), "Should fail validation");
match result {
Err(GgsqlError::ValidationError(msg)) => {
assert!(
msg.contains("y"),
"Error should mention missing y aesthetic: {}",
msg
);
}
Err(e) => panic!("Expected ValidationError, got: {}", e),
Ok(_) => panic!("Expected error, got success"),
}
}
#[cfg(feature = "duckdb")]
#[test]
fn test_place_affects_scale_ranges() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
let query = r#"
SELECT 1 AS x, 10 AS y UNION ALL
SELECT 2 AS x, 20 AS y UNION ALL
SELECT 3 AS x, 30 AS y
VISUALISE x, y
DRAW point
PLACE text SETTING x => 10, y => 50, label => 'Extended'
"#;
let result = prepare_data_with_reader(query, &reader);
assert!(result.is_ok(), "Query should execute: {:?}", result.err());
let prep = result.unwrap();
let x_scale = prep.specs[0].find_scale("pos1");
assert!(x_scale.is_some(), "Should have x scale");
let scale = x_scale.unwrap();
if let Some(input_range) = &scale.input_range {
assert!(
input_range.len() >= 2,
"Scale input_range should have min/max values"
);
if let Some(max_val) = input_range.last() {
match max_val {
crate::plot::types::ArrayElement::Number(n) => {
assert!(
*n >= 10.0,
"Scale input_range max should include annotation point at x=10, got: {}",
n
);
}
_ => panic!("Expected numeric input_range value"),
}
}
} else {
panic!("Scale should have an input_range");
}
}
#[cfg(feature = "duckdb")]
#[test]
fn test_place_no_global_mapping_inheritance() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
let query = r#"
SELECT 1 AS x, 2 AS y, 'red' AS color
VISUALISE x, y, color
DRAW point
PLACE text SETTING x => 5, y => 10, label => 'Test'
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
let point_layer = &result.specs[0].layers[0];
assert!(
point_layer.mappings.contains_key("color")
|| point_layer.mappings.contains_key("fill")
|| point_layer.mappings.contains_key("stroke"),
"DRAW layer should inherit color from global mappings"
);
let annotation_layer = &result.specs[0].layers[1];
assert!(
!annotation_layer.mappings.contains_key("color"),
"PLACE layer should not inherit color from global mappings"
);
assert!(
annotation_layer.mappings.contains_key("fill"),
"PLACE layer should have default fill from text geom"
);
match annotation_layer.mappings.aesthetics.get("fill") {
Some(AestheticValue::Literal(crate::plot::types::ParameterValue::String(s)))
if s == "black" =>
{
}
Some(AestheticValue::Column { name, .. }) if name == "color" => {
panic!("PLACE layer incorrectly inherited global color mapping as fill");
}
other => {
panic!("Expected fill=Literal('black'), got: {:?}", other);
}
}
assert!(
!annotation_layer.mappings.contains_key("stroke"),
"PLACE layer should not have stroke (text geom default is null)"
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_place_array_parameter_not_recycled() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
let query = r#"
VISUALISE
PLACE text SETTING x => 5, y => 10, label => 'Test', offset => [0, 1]
"#;
let result = prepare_data_with_reader(query, &reader).unwrap();
assert_eq!(result.specs.len(), 1);
assert_eq!(
result.specs[0].layers.len(),
1,
"Should have one PLACE layer"
);
let text_layer = &result.specs[0].layers[0];
assert_eq!(text_layer.geom, crate::Geom::text());
assert!(
matches!(text_layer.source, Some(DataSource::Annotation)),
"PLACE layer should have Annotation source"
);
let annotation_key = text_layer.data_key.as_ref().unwrap();
let annotation_df = result.data.get(annotation_key).unwrap();
assert_eq!(
annotation_df.height(),
1,
"Annotation layer should have exactly 1 row (offset array should not be recycled)"
);
assert!(
text_layer.parameters.contains_key("offset"),
"offset should remain as a parameter"
);
assert!(
!text_layer.mappings.contains_key("offset"),
"offset should NOT be moved to aesthetics/mappings"
);
match text_layer.parameters.get("offset") {
Some(crate::plot::types::ParameterValue::Array(arr)) => {
assert_eq!(arr.len(), 2, "offset should have 2 elements");
assert!(
matches!(arr[0], crate::plot::types::ArrayElement::Number(n) if (n - 0.0).abs() < 1e-10),
"offset[0] should be 0"
);
assert!(
matches!(arr[1], crate::plot::types::ArrayElement::Number(n) if (n - 1.0).abs() < 1e-10),
"offset[1] should be 1"
);
}
other => panic!("Expected offset to be Array, got: {:?}", other),
}
}
#[cfg(feature = "duckdb")]
#[test]
fn test_null_mapping_removes_global_aesthetic() {
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();
let point_layer = &result.specs[0].layers[0];
assert!(
point_layer.mappings.aesthetics.contains_key("fill"),
"point layer should inherit fill from global mapping"
);
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"
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_validation_error_shows_user_facing_names_for_missing_aesthetics() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE test_data AS SELECT * FROM (VALUES (1, 2)) AS t(a, b)",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT * FROM test_data
VISUALISE
DRAW line MAPPING a AS x
"#;
let result = prepare_data_with_reader(query, &reader);
assert!(result.is_err(), "Expected validation error");
let err_msg = match result {
Err(e) => e.to_string(),
Ok(_) => panic!("Expected error"),
};
assert!(
err_msg.contains("`y`"),
"Error should mention user-facing name 'y', got: {}",
err_msg
);
assert!(
!err_msg.contains("pos2"),
"Error should not mention internal name 'pos2', got: {}",
err_msg
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_validation_error_shows_user_facing_names_for_unsupported_aesthetics() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE test_data AS SELECT * FROM (VALUES (1, 2, 3)) AS t(a, b, c)",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT * FROM test_data
VISUALISE
DRAW point MAPPING a AS x, b AS y, c AS xmin
"#;
let result = prepare_data_with_reader(query, &reader);
assert!(result.is_err(), "Expected validation error");
let err_msg = match result {
Err(e) => e.to_string(),
Ok(_) => panic!("Expected error"),
};
assert!(
err_msg.contains("`xmin`"),
"Error should mention user-facing name 'xmin', got: {}",
err_msg
);
assert!(
!err_msg.contains("pos1min"),
"Error should not mention internal name 'pos1min', got: {}",
err_msg
);
}
#[cfg(all(feature = "duckdb", feature = "spatial"))]
#[test]
fn test_spatial_native_geometry() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
let query = r#"
INSTALL spatial;
LOAD spatial;
SELECT
ST_GeomFromText('POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))') AS geom,
'A' AS name,
100 AS value
UNION ALL
SELECT
ST_GeomFromText('POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))') AS geom,
'B' AS name,
200 AS value
VISUALISE
DRAW spatial MAPPING value AS fill
"#;
let result = prepare_data_with_reader(query, &reader);
assert!(
result.is_ok(),
"Spatial with native GEOMETRY failed: {:?}",
result.err()
);
let prepared = result.unwrap();
let layer_key = prepared.specs[0].layers[0].data_key.as_ref().unwrap();
let df = prepared.data.get(layer_key).unwrap();
assert_eq!(df.height(), 2);
}
#[cfg(all(feature = "duckdb", feature = "spatial"))]
#[test]
fn test_spatial_auto_detect_geometry_column() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
let query = r#"
INSTALL spatial;
LOAD spatial;
SELECT
ST_GeomFromText('POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))') AS geom,
'A' AS name
UNION ALL
SELECT
ST_GeomFromText('POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))') AS geom,
'B' AS name
VISUALISE
DRAW spatial MAPPING name AS fill
"#;
let result = prepare_data_with_reader(query, &reader);
assert!(
result.is_ok(),
"Spatial auto-detect geometry failed: {:?}",
result.err()
);
let prepared = result.unwrap();
let layer_key = prepared.specs[0].layers[0].data_key.as_ref().unwrap();
let df = prepared.data.get(layer_key).unwrap();
assert_eq!(df.height(), 2);
assert!(df.column("__ggsql_aes_geometry__").is_ok());
}
#[cfg(all(feature = "duckdb", feature = "spatial", feature = "builtin-data"))]
#[test]
fn test_spatial_world_minimal() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader.execute_sql("INSTALL spatial").unwrap();
let query = r#"
VISUALISE FROM ggsql:world
DRAW spatial
"#;
let result = prepare_data_with_reader(query, &reader);
assert!(
result.is_ok(),
"ggsql:world DRAW spatial failed: {:?}",
result.err()
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_case_insensitive_visualise_refs() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE case_test AS SELECT 'A' AS category, 10 AS value \
UNION ALL SELECT 'B', 20",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT category, value FROM case_test
VISUALISE CATEGORY AS x, VALUE AS y
DRAW bar
"#;
let result = prepare_data_with_reader(query, &reader);
assert!(
result.is_ok(),
"Uppercase VISUALISE refs should resolve to lowercase schema: {:?}",
result.err()
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_mixed_case_visualise_refs() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE mixed AS SELECT 'A' AS category, 10 AS value \
UNION ALL SELECT 'B', 20",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT category, value FROM mixed
VISUALISE CaTeGoRy AS x, VaLuE AS y
DRAW bar
"#;
let result = prepare_data_with_reader(query, &reader);
assert!(
result.is_ok(),
"Mixed-case VISUALISE refs should normalize: {:?}",
result.err()
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_case_insensitive_partition_by() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE pb AS SELECT 1 AS x, 10 AS y, 'A' AS category \
UNION ALL SELECT 2, 20, 'B'",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT x, y, category FROM pb
VISUALISE x AS x, y AS y
DRAW line PARTITION BY CATEGORY
"#;
let result = prepare_data_with_reader(query, &reader);
assert!(
result.is_ok(),
"Uppercase PARTITION BY should resolve to lowercase schema: {:?}",
result.err()
);
}
#[cfg(all(feature = "duckdb", feature = "spatial"))]
#[test]
fn test_partition_by_preserved_through_map_projection() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute_batch("INSTALL spatial; LOAD spatial")
.unwrap();
reader
.connection()
.execute(
"CREATE TABLE routes AS \
SELECT 10.0 AS lon, 50.0 AS lat, 'A' AS direction, 1 AS grp \
UNION ALL SELECT 20.0, 55.0, 'A', 1 \
UNION ALL SELECT 30.0, 52.0, 'R' , 2",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT lon, lat, direction, grp FROM routes
VISUALISE lon AS x, lat AS y
DRAW path PARTITION BY direction, grp
PROJECT x, y TO orthographic
"#;
let result = prepare_data_with_reader(query, &reader);
assert!(
result.is_ok(),
"PARTITION BY columns should survive map projection: {:?}",
result.err()
);
let prepared = result.unwrap();
let layer = &prepared.specs[0].layers[0];
let data_key = layer.data_key.as_ref().unwrap();
let df = prepared.data.get(data_key).unwrap();
let col_names = df.get_column_names();
assert!(
col_names.iter().any(|c| c == "direction"),
"partition column 'direction' missing from output data; columns: {:?}",
col_names
);
assert!(
col_names.iter().any(|c| c == "grp"),
"partition column 'grp' missing from output data; columns: {:?}",
col_names
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_case_insensitive_facet() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE facet_case AS SELECT 1 AS x, 10 AS y, 'N' AS region \
UNION ALL SELECT 2, 20, 'S'",
duckdb::params![],
)
.unwrap();
let query = r#"
SELECT x, y, region FROM facet_case
VISUALISE x AS x, y AS y
DRAW point
FACET REGION
"#;
let result = prepare_data_with_reader(query, &reader);
assert!(
result.is_ok(),
"Uppercase FACET variable should resolve to lowercase schema: {:?}",
result.err()
);
}
#[cfg(feature = "duckdb")]
#[test]
fn test_multi_source_layers_case_insensitive() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
reader
.connection()
.execute(
"CREATE TABLE temps AS SELECT 1 AS date, 20.0 AS value \
UNION ALL SELECT 2, 21.0",
duckdb::params![],
)
.unwrap();
reader
.connection()
.execute(
"CREATE TABLE ozone AS SELECT 1 AS date, 0.05 AS value \
UNION ALL SELECT 2, 0.06",
duckdb::params![],
)
.unwrap();
let query = r#"
VISUALISE Date AS x, Value AS y
DRAW line MAPPING Date AS x, Value AS y FROM temps
DRAW line MAPPING DATE AS x, VALUE AS y FROM ozone
"#;
let result = prepare_data_with_reader(query, &reader);
assert!(
result.is_ok(),
"Per-layer normalization should work across multi-source layers: {:?}",
result.err()
);
}
#[test]
fn test_normalize_column_ref_exact_match_wins() {
let mut name = "foo".to_string();
normalize_column_ref(&mut name, &["Foo", "foo"]);
assert_eq!(name, "foo");
}
#[test]
fn test_normalize_column_ref_unique_case_match_rewrites() {
let mut name = "CATEGORY".to_string();
normalize_column_ref(&mut name, &["category", "value"]);
assert_eq!(name, "category");
}
#[test]
fn test_normalize_column_ref_ambiguous_left_alone() {
let mut name = "FOO".to_string();
normalize_column_ref(&mut name, &["Foo", "foo"]);
assert_eq!(name, "FOO", "ambiguous match must not be silently resolved");
}
#[test]
fn test_normalize_column_ref_no_match_left_alone() {
let mut name = "missing".to_string();
normalize_column_ref(&mut name, &["a", "b"]);
assert_eq!(name, "missing");
}
mod validate_translation_tests {
use super::*;
use crate::plot::layer::geom::Geom;
use crate::plot::layer::Layer;
use crate::plot::types::{AestheticValue, ColumnInfo};
use arrow::datatypes::DataType;
fn col(name: &str) -> ColumnInfo {
ColumnInfo {
name: name.to_string(),
dtype: DataType::Float64,
is_discrete: false,
min: None,
max: None,
}
}
fn point_layer_with_mapping(aesthetic: &str, column: &str) -> Layer {
let mut layer = Layer::new(Geom::point());
layer.mappings.aesthetics.insert(
"pos1".to_string(),
AestheticValue::standard_column("present_x"),
);
layer.mappings.aesthetics.insert(
"pos2".to_string(),
AestheticValue::standard_column("present_y"),
);
layer.mappings.aesthetics.insert(
aesthetic.to_string(),
AestheticValue::standard_column(column),
);
layer
}
#[test]
fn aesthetic_column_missing_translates_pos1_to_x_under_cartesian() {
let mut layer = Layer::new(Geom::point());
layer.mappings.aesthetics.insert(
"pos1".to_string(),
AestheticValue::standard_column("missing"),
);
layer.mappings.aesthetics.insert(
"pos2".to_string(),
AestheticValue::standard_column("present_y"),
);
let schema: Schema = vec![col("present_y")];
let ctx = Some(AestheticContext::from_static(&["x", "y"], &[]));
let err = validate(&[layer], &[schema], &ctx).unwrap_err().to_string();
assert_eq!(
err,
"Validation error: Layer 1: aesthetic 'x' references non-existent column 'missing'"
);
}
#[test]
fn aesthetic_column_missing_translates_pos1_to_angle_under_polar() {
let mut layer = Layer::new(Geom::point());
layer.mappings.aesthetics.insert(
"pos1".to_string(),
AestheticValue::standard_column("missing"),
);
layer.mappings.aesthetics.insert(
"pos2".to_string(),
AestheticValue::standard_column("present_radius"),
);
let schema: Schema = vec![col("present_radius")];
let ctx = Some(AestheticContext::from_static(&["angle", "radius"], &[]));
let err = validate(&[layer], &[schema], &ctx).unwrap_err().to_string();
assert_eq!(
err,
"Validation error: Layer 1: aesthetic 'angle' references non-existent column 'missing'"
);
}
#[test]
fn aesthetic_column_missing_translates_pos2_to_y_under_cartesian() {
let layer = point_layer_with_mapping("pos2", "missing");
let schema: Schema = vec![col("present_x"), col("present_y")];
let ctx = Some(AestheticContext::from_static(&["x", "y"], &[]));
let err = validate(&[layer], &[schema], &ctx).unwrap_err().to_string();
assert_eq!(
err,
"Validation error: Layer 1: aesthetic 'y' references non-existent column 'missing'"
);
}
#[test]
fn material_aesthetic_column_missing_keeps_color_name() {
let layer = point_layer_with_mapping("color", "missing");
let schema: Schema = vec![col("present_x"), col("present_y")];
let ctx = Some(AestheticContext::from_static(&["x", "y"], &[]));
let err = validate(&[layer], &[schema], &ctx).unwrap_err().to_string();
assert_eq!(
err,
"Validation error: Layer 1: aesthetic 'color' references non-existent column 'missing'"
);
}
#[test]
fn remapping_unsupported_target_translates_pos2_to_y_under_cartesian() {
let mut layer = Layer::new(Geom::histogram());
layer.mappings.aesthetics.insert(
"pos1".to_string(),
AestheticValue::standard_column("present_x"),
);
layer.remappings.aesthetics.insert(
"pos1max".to_string(),
AestheticValue::standard_column("count"),
);
let schema: Schema = vec![col("present_x")];
let ctx = Some(AestheticContext::from_static(&["x", "y"], &[]));
let err = validate(&[layer], &[schema], &ctx).unwrap_err().to_string();
assert_eq!(
err,
"Validation error: Layer 1: REMAPPING targets unsupported aesthetic 'xmax' for geom 'histogram'"
);
}
#[test]
fn remapping_unsupported_target_translates_pos1max_to_anglemax_under_polar() {
let mut layer = Layer::new(Geom::histogram());
layer.mappings.aesthetics.insert(
"pos1".to_string(),
AestheticValue::standard_column("present_x"),
);
layer.remappings.aesthetics.insert(
"pos1max".to_string(),
AestheticValue::standard_column("count"),
);
let schema: Schema = vec![col("present_x")];
let ctx = Some(AestheticContext::from_static(&["angle", "radius"], &[]));
let err = validate(&[layer], &[schema], &ctx).unwrap_err().to_string();
assert_eq!(
err,
"Validation error: Layer 1: REMAPPING targets unsupported aesthetic 'anglemax' for geom 'histogram'"
);
}
}
}