From b467129e02396f0565e630619b722e44bbc023c7 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 19 Feb 2026 10:59:00 -0600 Subject: [PATCH] Fix case-sensitive VISUALISE column reference validation The VISUALISE parser preserves the exact casing from the user's query (e.g., `VISUALISE ROOM_TYPE AS x` stores "ROOM_TYPE"). However, DuckDB lowercases unquoted identifiers in query results, so the schema column is "room_type". Since ggsql quotes column names in generated SQL (making them case-sensitive in DuckDB), this mismatch caused validation errors like: "aesthetic 'x' references non-existent column 'ROOM_TYPE'" Add a normalize_column_names() step early in the prepare_data pipeline that resolves VISUALISE column references to match the actual schema column names using case-insensitive matching. This is reader-agnostic: it normalizes to whatever the reader returns, not specifically to lowercase. Co-Authored-By: Claude Opus 4.6 --- src/execute/mod.rs | 139 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/src/execute/mod.rs b/src/execute/mod.rs index cdd394ce..99a6806a 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -33,6 +33,71 @@ use crate::reader::Reader; #[cfg(all(feature = "duckdb", test))] use crate::reader::DuckDBReader; +// ============================================================================= +// Column Name Normalization +// ============================================================================= + +/// Normalize aesthetic column references to match the actual schema column names. +/// +/// DuckDB lowercases unquoted identifiers, but users may write column names in +/// any case in VISUALISE/MAPPING clauses. Since ggsql quotes column names in +/// generated SQL (making them case-sensitive), we must normalize references to +/// match the actual column names returned by the database. +/// +/// This function walks all aesthetic mappings (global and per-layer) and replaces +/// column names with the matching schema column name (matched case-insensitively). +fn normalize_column_names(specs: &mut [Plot], layer_schemas: &[Schema]) { + for spec in specs { + // Normalize global mappings using the first layer's schema (global mappings + // are merged into all layers, so any layer's schema suffices for normalization) + if let Some(schema) = layer_schemas.first() { + let schema_names: Vec<&str> = schema.iter().map(|c| c.name.as_str()).collect(); + for value in spec.global_mappings.aesthetics.values_mut() { + normalize_aesthetic_value(value, &schema_names); + } + } + + // Normalize per-layer mappings and partition_by using each layer's own schema + for (layer, schema) in spec.layers.iter_mut().zip(layer_schemas.iter()) { + let schema_names: Vec<&str> = schema.iter().map(|c| c.name.as_str()).collect(); + for value in layer.mappings.aesthetics.values_mut() { + normalize_aesthetic_value(value, &schema_names); + } + for col in &mut layer.partition_by { + normalize_column_ref(col, &schema_names); + } + } + } +} + +/// Resolve a column name to match actual schema casing (case-insensitive). +/// +/// Only normalizes when there is no exact match and exactly one case-insensitive +/// match exists. This preserves correct behavior when the schema contains columns +/// that differ only by case (e.g., via quoted identifiers like `"Foo"` and `"foo"`). +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 matches: Vec<&&str> = schema_names + .iter() + .filter(|s| s.to_lowercase() == name_lower) + .collect(); + + if matches.len() == 1 { + *name = matches[0].to_string(); + } +} + +/// Normalize a single aesthetic value's column name to match schema casing. +fn normalize_aesthetic_value(value: &mut AestheticValue, schema_names: &[&str]) { + if let AestheticValue::Column { name, .. } = value { + normalize_column_ref(name, schema_names); + } +} + // ============================================================================= // Validation // ============================================================================= @@ -570,6 +635,12 @@ pub fn prepare_data_with_reader(query: &str, reader: &R) -> Result