use crate::plot::aesthetic::{self, AestheticContext};
use crate::plot::layer::is_transposed;
use crate::plot::layer::orientation::{flip_position_aesthetics, resolve_orientation};
use crate::plot::{
AestheticValue, DefaultAestheticValue, Layer, ParameterValue, Scale, Schema, StatResult,
};
use crate::reader::SqlDialect;
use crate::{naming, DataFrame, GgsqlError, Result};
use arrow::datatypes::DataType;
use std::collections::{HashMap, HashSet};
use super::casting::TypeRequirement;
use super::schema::build_aesthetic_schema;
pub fn layer_source_query(
layer: &mut Layer,
materialized_ctes: &HashSet<String>,
has_global: bool,
dialect: &dyn SqlDialect,
) -> Result<String> {
match &layer.source {
Some(crate::DataSource::Annotation) => {
process_annotation_layer(layer, dialect)
}
Some(crate::DataSource::Identifier(name)) => {
let source = if materialized_ctes.contains(name) {
naming::cte_table(name)
} else {
name.clone()
};
Ok(format!("SELECT * FROM {}", source))
}
Some(crate::DataSource::FilePath(path)) => {
Ok(format!("SELECT * FROM '{}'", path))
}
None => {
debug_assert!(has_global, "Layer has no source and no global data");
Ok(format!(
"SELECT * FROM {}",
naming::quote_ident(&naming::global_table())
))
}
}
}
pub fn build_layer_select_list(
layer: &Layer,
type_requirements: &[TypeRequirement],
dialect: &dyn SqlDialect,
) -> Vec<String> {
let mut select_exprs = Vec::new();
select_exprs.push("*".to_string());
let cast_map: HashMap<&str, &TypeRequirement> = type_requirements
.iter()
.map(|r| (r.column.as_str(), r))
.collect();
for (aesthetic, value) in &layer.mappings.aesthetics {
let aes_col_name = naming::aesthetic_column(aesthetic);
let select_expr = match value {
AestheticValue::Column { name, .. } | AestheticValue::AnnotationColumn { name } => {
if let Some(req) = cast_map.get(name.as_str()) {
format!(
"CAST({} AS {}) AS {}",
naming::quote_ident(name),
req.sql_type_name,
naming::quote_ident(&aes_col_name)
)
} else {
format!(
"{} AS {}",
naming::quote_ident(name),
naming::quote_ident(&aes_col_name)
)
}
}
AestheticValue::Literal(lit) => {
format!(
"{} AS {}",
lit.to_sql(dialect),
naming::quote_ident(&aes_col_name)
)
}
};
select_exprs.push(select_expr);
}
select_exprs
}
pub fn apply_remappings_post_query(df: DataFrame, layer: &Layer) -> Result<DataFrame> {
let mut df = df;
let row_count = df.height();
for (target_aesthetic, value) in &layer.remappings.aesthetics {
let target_col_name = naming::aesthetic_column(target_aesthetic);
match value {
AestheticValue::Column { name, .. } | AestheticValue::AnnotationColumn { name } => {
if df.column(name).is_ok() {
df = df.rename(name, &target_col_name).map_err(|e| {
GgsqlError::InternalError(format!(
"Failed to rename stat column '{}' to '{}': {}",
name, target_aesthetic, e
))
})?;
}
}
AestheticValue::Literal(lit) => {
let array = literal_to_array(lit, row_count);
df = df.with_column(&target_col_name, array).map_err(|e| {
GgsqlError::InternalError(format!(
"Failed to add literal column '{}': {}",
target_col_name, e
))
})?;
}
}
}
let stat_cols: Vec<String> = df
.get_column_names()
.into_iter()
.filter(|name| {
naming::is_stat_column(name) && !layer.partition_by.contains(&name.to_string())
})
.collect();
if !stat_cols.is_empty() {
df = df.drop_many(&stat_cols)?;
}
Ok(df)
}
pub fn literal_to_array(lit: &ParameterValue, len: usize) -> arrow::array::ArrayRef {
use crate::array_util::{cast_array, new_constant_bool, new_constant_f64, new_constant_str};
use crate::plot::ArrayElement;
use arrow::datatypes::{DataType, TimeUnit};
use std::sync::Arc;
match lit {
ParameterValue::Number(n) => new_constant_f64(*n, len),
ParameterValue::String(s) => {
match ArrayElement::String(s.clone()).try_as_temporal() {
ArrayElement::DateTime(micros) => {
let arr: arrow::array::ArrayRef =
Arc::new(arrow::array::Int64Array::from(vec![micros; len]));
cast_array(&arr, &DataType::Timestamp(TimeUnit::Microsecond, None))
.expect("DateTime cast should not fail")
}
ArrayElement::Date(days) => {
let arr: arrow::array::ArrayRef =
Arc::new(arrow::array::Int32Array::from(vec![days; len]));
cast_array(&arr, &DataType::Date32).expect("Date cast should not fail")
}
ArrayElement::Time(nanos) => {
let arr: arrow::array::ArrayRef =
Arc::new(arrow::array::Int64Array::from(vec![nanos; len]));
cast_array(&arr, &DataType::Time64(TimeUnit::Nanosecond))
.expect("Time cast should not fail")
}
ArrayElement::String(_) => {
new_constant_str(s, len)
}
_ => unreachable!("try_as_temporal only returns String or temporal types"),
}
}
ParameterValue::Boolean(b) => new_constant_bool(*b, len),
ParameterValue::Array(_) | ParameterValue::Null => {
unreachable!("Arrays are never moved to mappings; NULL is filtered in process_annotation_layers()")
}
}
}
pub fn apply_pre_stat_transform(
query: &str,
layer: &Layer,
full_schema: &Schema,
aesthetic_schema: &Schema,
scales: &[Scale],
dialect: &dyn SqlDialect,
aesthetic_ctx: &AestheticContext,
) -> String {
let mut transform_exprs: Vec<(String, String)> = vec![];
let mut transformed_columns: HashSet<String> = HashSet::new();
let agg_buckets = crate::plot::layer::geom::stat_aggregate::aggregated_aesthetics(
&layer.parameters,
&layer.mappings,
aesthetic_schema,
aesthetic_ctx,
layer.geom.aggregate_domain_aesthetics().unwrap_or(&[]),
);
for (aesthetic, value) in &layer.mappings.aesthetics {
let aes_col_name = naming::aesthetic_column(aesthetic);
if transformed_columns.contains(&aes_col_name) {
continue;
}
if value.column_name().is_none() && !value.is_literal() {
continue;
}
let col_dtype = aesthetic_schema
.iter()
.find(|c| c.name == aes_col_name)
.map(|c| c.dtype.clone())
.unwrap_or(DataType::Utf8);
if let Some(scale) = scales.iter().find(|s| s.aesthetic == *aesthetic) {
if let Some(ref scale_type) = scale.scale_type {
if let Some((ref targeted, ref aggregated)) = agg_buckets {
use crate::plot::scale::ScaleTypeKind;
let kind = scale_type.scale_type_kind();
let defer = match kind {
ScaleTypeKind::Binned => targeted.contains(aesthetic),
ScaleTypeKind::Continuous
| ScaleTypeKind::Discrete
| ScaleTypeKind::Ordinal => aggregated.contains(aesthetic),
ScaleTypeKind::Identity => false,
};
if defer {
continue;
}
}
if let Some(sql) =
scale_type.pre_stat_transform_sql(&aes_col_name, &col_dtype, scale, dialect)
{
transformed_columns.insert(aes_col_name.clone());
transform_exprs.push((aes_col_name, sql));
}
}
}
}
if transform_exprs.is_empty() {
return query.to_string();
}
let mut seen: HashSet<&str> = HashSet::new();
let combined_cols = full_schema.iter().chain(aesthetic_schema.iter());
let select_exprs: Vec<String> = combined_cols
.filter(|col| seen.insert(&col.name))
.map(|col| {
if let Some((_, sql)) = transform_exprs.iter().find(|(c, _)| c == &col.name) {
format!("{} AS {}", sql, naming::quote_ident(&col.name))
} else {
naming::quote_ident(&col.name)
}
})
.collect();
format!(
"SELECT {} FROM ({}) AS \"__ggsql_pre__\"",
select_exprs.join(", "),
query
)
}
pub fn build_layer_base_query(
layer: &Layer,
source_query: &str,
type_requirements: &[TypeRequirement],
dialect: &dyn SqlDialect,
) -> String {
let select_exprs = build_layer_select_list(layer, type_requirements, dialect);
let select_clause = if select_exprs.is_empty() {
"*".to_string()
} else {
select_exprs.join(", ")
};
if let Some(ref f) = layer.filter {
format!(
"SELECT {} FROM ({}) AS \"__ggsql_src__\" WHERE {}",
select_clause,
source_query,
f.as_str()
)
} else {
format!(
"SELECT {} FROM ({}) AS \"__ggsql_src__\"",
select_clause, source_query
)
}
}
pub fn apply_layer_transforms<F>(
layer: &mut Layer,
base_query: &str,
schema: &Schema,
scales: &[Scale],
dialect: &dyn SqlDialect,
execute_query: &F,
aesthetic_ctx: &AestheticContext,
) -> Result<String>
where
F: Fn(&str) -> Result<DataFrame>,
{
use crate::plot::layer::orientation::flip_position_aesthetics;
let order_by = layer.order_by.clone();
let needs_flip = is_transposed(layer);
let aesthetic_schema: Schema = build_aesthetic_schema(layer, schema);
let literal_columns: Vec<String> = layer
.mappings
.aesthetics
.iter()
.filter_map(|(aesthetic, value)| {
if value.is_literal() {
Some(naming::aesthetic_column(aesthetic))
} else {
None
}
})
.collect();
layer.update_mappings_for_aesthetic_columns();
let query = apply_pre_stat_transform(
base_query,
layer,
schema,
&aesthetic_schema,
scales,
dialect,
aesthetic_ctx,
);
let group_by = crate::util::set_union(layer.partition_by.clone(), &literal_columns);
let stat_result = layer.geom.apply_stat_transform(
&query,
&aesthetic_schema,
&layer.mappings,
&group_by,
&layer.parameters,
execute_query,
dialect,
aesthetic_ctx,
)?;
if needs_flip {
flip_position_aesthetics(&mut layer.remappings.aesthetics);
}
let implicit_remappings = layer.geom.implicit_default_remappings();
for (aesthetic, default_value) in &implicit_remappings {
if !matches!(default_value, DefaultAestheticValue::Column(_)) {
if !layer.remappings.aesthetics.contains_key(*aesthetic)
&& !layer.mappings.aesthetics.contains_key(*aesthetic)
{
layer
.remappings
.insert(aesthetic.to_string(), default_value.to_aesthetic_value());
}
}
}
let final_query = match stat_result {
StatResult::Transformed {
query: transformed_query,
stat_columns,
dummy_columns,
consumed_aesthetics,
} => {
let mut final_remappings: HashMap<String, String> = HashMap::new();
for (aesthetic, default_value) in &implicit_remappings {
if let DefaultAestheticValue::Column(stat_col) = default_value {
final_remappings.insert(stat_col.to_string(), aesthetic.to_string());
}
}
for (aesthetic, value) in &layer.remappings.aesthetics {
if let Some(stat_name) = value.column_name() {
final_remappings.retain(|_, aes| aes != aesthetic);
final_remappings.insert(stat_name.to_string(), aesthetic.clone());
}
}
let mut consumed_original_names: HashMap<String, String> = HashMap::new();
for aes in &consumed_aesthetics {
if let Some(value) = layer.mappings.get(aes) {
if let Some(label) = value.label_name() {
consumed_original_names.insert(aes.clone(), label.to_string());
}
}
}
for aes in &consumed_aesthetics {
layer.mappings.aesthetics.remove(aes);
}
for stat in &stat_columns {
if final_remappings.contains_key(stat) {
continue;
}
if consumed_aesthetics.contains(stat) {
final_remappings.insert(stat.clone(), stat.clone());
}
}
if stat_columns.iter().any(|s| s == "aggregate") {
let partition_col = match final_remappings.get("aggregate") {
Some(aes) => naming::aesthetic_column(aes),
None => naming::stat_column("aggregate"),
};
if !layer.partition_by.contains(&partition_col) {
layer.partition_by.push(partition_col);
}
}
for stat in &stat_columns {
if let Some(aesthetic) = final_remappings.get(stat) {
let is_dummy = dummy_columns.contains(stat);
let prefixed_name = naming::aesthetic_column(aesthetic);
let original_name = consumed_original_names
.get(aesthetic)
.cloned()
.or_else(|| {
aesthetic::parse_position(aesthetic).and_then(|(slot, suffix)| {
if !suffix.is_empty() {
let primary = format!("pos{}", slot);
consumed_original_names.get(&primary).cloned()
} else {
None
}
})
})
.or_else(|| Some(stat.clone()));
let value = AestheticValue::Column {
name: prefixed_name,
original_name,
is_dummy,
};
layer.mappings.insert(aesthetic.clone(), value);
}
}
let stat_rename_exprs: Vec<String> = stat_columns
.iter()
.filter_map(|stat| {
final_remappings.get(stat).map(|aes| {
let stat_col = naming::stat_column(stat);
let prefixed_aes = naming::aesthetic_column(aes);
format!(
"{} AS {}",
naming::quote_ident(&stat_col),
naming::quote_ident(&prefixed_aes)
)
})
})
.collect();
if stat_rename_exprs.is_empty() {
transformed_query
} else {
format!(
"SELECT *, {} FROM ({}) AS \"__ggsql_stat__\"",
stat_rename_exprs.join(", "),
transformed_query
)
}
}
StatResult::Identity => query,
};
if needs_flip {
flip_position_aesthetics(&mut layer.mappings.aesthetics);
normalize_mapping_column_names(layer);
}
let final_query = if let Some(ref o) = order_by {
format!("{} ORDER BY {}", final_query, o.as_str())
} else {
final_query
};
Ok(final_query)
}
fn process_annotation_layer(layer: &mut Layer, dialect: &dyn SqlDialect) -> Result<String> {
use crate::plot::ArrayElement;
let required_aesthetics = layer.geom.aesthetics().required();
let supported_aesthetics = layer.geom.aesthetics().supported();
let param_keys: Vec<String> = layer.parameters.keys().cloned().collect();
let mut annotation_params: Vec<(String, ParameterValue)> = Vec::new();
for param_name in param_keys {
if layer.mappings.contains_key(¶m_name) {
continue;
}
let Some(value) = layer.parameters.get(¶m_name) else {
continue;
};
if value.is_null() {
continue;
}
let is_position = crate::plot::aesthetic::is_position_aesthetic(¶m_name);
let is_required = required_aesthetics.contains(¶m_name.as_str());
let is_array = matches!(value, ParameterValue::Array(_))
&& supported_aesthetics.contains(¶m_name.as_str());
if is_position || is_required || is_array {
annotation_params.push((param_name.clone(), value.clone()));
}
}
if annotation_params.is_empty() {
annotation_params.push(("__ggsql_dummy__".to_string(), ParameterValue::Number(1.0)));
}
let mut max_length = 1;
for (aesthetic, value) in &annotation_params {
let ParameterValue::Array(arr) = value else {
continue;
};
let len = arr.len();
if len <= 1 {
continue;
}
if max_length > 1 && len != max_length {
return Err(GgsqlError::ValidationError(format!(
"PLACE annotation layer has mismatched array lengths: '{}' has length {}, but another has length {}",
aesthetic, len, max_length
)));
}
if len > max_length {
max_length = len;
}
}
let mut columns: Vec<Vec<ArrayElement>> = Vec::new();
let mut column_names = Vec::new();
for (aesthetic, param) in &annotation_params {
let mut column_values = match param.clone().rep(max_length)? {
ParameterValue::Array(arr) => arr,
_ => unreachable!("rep() always returns Array variant"),
};
column_values = column_values
.into_iter()
.map(|elem| elem.try_as_temporal())
.collect();
columns.push(column_values);
column_names.push(aesthetic.clone());
if aesthetic == "__ggsql_dummy__" {
continue;
}
let is_position = crate::plot::aesthetic::is_position_aesthetic(aesthetic);
let mapping_value = if is_position {
AestheticValue::Column {
name: aesthetic.clone(), original_name: None,
is_dummy: false,
}
} else {
AestheticValue::AnnotationColumn {
name: aesthetic.clone(), }
};
layer.mappings.insert(aesthetic.clone(), mapping_value);
layer.parameters.remove(aesthetic);
}
let values_clause = (0..max_length)
.map(|i| {
let row: Vec<String> = columns.iter().map(|col| col[i].to_sql(dialect)).collect();
format!("({})", row.join(", "))
})
.collect::<Vec<_>>()
.join(", ");
let column_list = column_names
.iter()
.map(|c| naming::quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"WITH __ggsql_values__({}) AS (VALUES {}) SELECT * FROM __ggsql_values__",
column_list, values_clause
);
Ok(sql)
}
fn normalize_mapping_column_names(layer: &mut Layer) {
let aesthetics_to_update: Vec<String> = layer
.mappings
.aesthetics
.keys()
.filter(|aes| crate::plot::aesthetic::is_position_aesthetic(aes))
.cloned()
.collect();
for aesthetic in aesthetics_to_update {
if let Some(AestheticValue::Column { name, .. }) =
layer.mappings.aesthetics.get_mut(&aesthetic)
{
*name = naming::aesthetic_column(&aesthetic);
}
}
}
pub fn resolve_orientations(
layers: &mut [Layer],
scales: &[Scale],
layer_type_info: &mut [Vec<super::schema::TypeInfo>],
aesthetic_ctx: &AestheticContext,
) {
for (layer_idx, layer) in layers.iter_mut().enumerate() {
let orientation = resolve_orientation(layer, scales);
layer.parameters.insert(
"orientation".to_string(),
ParameterValue::String(orientation.to_string()),
);
if is_transposed(layer) {
flip_position_aesthetics(&mut layer.mappings.aesthetics);
if layer_idx < layer_type_info.len() {
for (name, _, _) in &mut layer_type_info[layer_idx] {
if let Some(aesthetic) = naming::extract_aesthetic_name(name) {
let flipped = aesthetic_ctx.flip_position(aesthetic);
if flipped != aesthetic {
*name = naming::aesthetic_column(&flipped);
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plot::{ArrayElement, DataSource, Geom, Layer, ParameterValue};
use crate::reader::AnsiDialect;
#[test]
fn test_annotation_single_scalar() {
let mut layer = Layer::new(Geom::text());
layer.source = Some(DataSource::Annotation);
layer
.parameters
.insert("pos1".to_string(), ParameterValue::Number(5.0));
layer
.parameters
.insert("pos2".to_string(), ParameterValue::Number(10.0));
layer.parameters.insert(
"label".to_string(),
ParameterValue::String("Test".to_string()),
);
let result = process_annotation_layer(&mut layer, &AnsiDialect).unwrap();
assert!(result.contains("VALUES"));
assert!(result.contains("5"));
assert!(result.contains("10"));
assert!(result.contains("'Test'"));
assert!(result.contains("\"pos1\""));
assert!(result.contains("\"pos2\""));
assert!(result.contains("\"label\""));
assert!(layer.mappings.contains_key("pos1"));
assert!(layer.mappings.contains_key("pos2"));
assert!(layer.mappings.contains_key("label"));
}
#[test]
fn test_annotation_array_recycling() {
let mut layer = Layer::new(Geom::text());
layer.source = Some(DataSource::Annotation);
layer.parameters.insert(
"pos1".to_string(),
ParameterValue::Array(vec![
ArrayElement::Number(1.0),
ArrayElement::Number(2.0),
ArrayElement::Number(3.0),
]),
);
layer
.parameters
.insert("pos2".to_string(), ParameterValue::Number(10.0));
layer.parameters.insert(
"label".to_string(),
ParameterValue::String("Same".to_string()),
);
let result = process_annotation_layer(&mut layer, &AnsiDialect).unwrap();
assert!(result.contains("VALUES"));
assert!(result.contains("1") && result.contains("2") && result.contains("3"));
assert!(result.contains("10"));
assert!(result.contains("'Same'"));
assert_eq!(result.matches("), (").count() + 1, 3, "Should have 3 rows");
}
#[test]
fn test_annotation_mismatched_arrays() {
let mut layer = Layer::new(Geom::text());
layer.source = Some(DataSource::Annotation);
layer.parameters.insert(
"pos1".to_string(),
ParameterValue::Array(vec![
ArrayElement::Number(1.0),
ArrayElement::Number(2.0),
ArrayElement::Number(3.0),
]),
);
layer.parameters.insert(
"pos2".to_string(),
ParameterValue::Array(vec![ArrayElement::Number(10.0), ArrayElement::Number(20.0)]),
);
let result = process_annotation_layer(&mut layer, &AnsiDialect);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("mismatched array lengths"),
"Error message should mention mismatched arrays"
);
assert!(
err_msg.contains("pos1") || err_msg.contains("pos2"),
"Error message should mention at least one aesthetic"
);
}
#[test]
fn test_annotation_multiple_arrays_same_length() {
let mut layer = Layer::new(Geom::text());
layer.source = Some(DataSource::Annotation);
layer.parameters.insert(
"pos1".to_string(),
ParameterValue::Array(vec![ArrayElement::Number(1.0), ArrayElement::Number(2.0)]),
);
layer.parameters.insert(
"pos2".to_string(),
ParameterValue::Array(vec![ArrayElement::Number(10.0), ArrayElement::Number(20.0)]),
);
let result = process_annotation_layer(&mut layer, &AnsiDialect).unwrap();
assert!(result.contains("VALUES"));
assert!(result.contains("1") && result.contains("2"));
assert!(result.contains("10") && result.contains("20"));
assert_eq!(result.matches("), (").count() + 1, 2, "Should have 2 rows");
}
#[test]
fn test_annotation_mixed_types() {
let mut layer = Layer::new(Geom::text());
layer.source = Some(DataSource::Annotation);
layer
.parameters
.insert("pos1".to_string(), ParameterValue::Number(5.0));
layer
.parameters
.insert("pos2".to_string(), ParameterValue::Number(10.0));
layer.parameters.insert(
"label".to_string(),
ParameterValue::String("Text".to_string()),
);
let result = process_annotation_layer(&mut layer, &AnsiDialect).unwrap();
assert!(result.contains("VALUES"));
assert!(result.contains("5"));
assert!(result.contains("10"));
assert!(result.contains("'Text'"));
}
#[test]
fn test_literal_to_array_date_parsing() {
use arrow::array::Array;
use arrow::datatypes::DataType;
let array = literal_to_array(&ParameterValue::String("1973-06-01".to_string()), 5);
assert_eq!(
array.data_type(),
&DataType::Date32,
"Date string should parse to Date32 type"
);
assert_eq!(array.len(), 5);
}
#[test]
fn test_literal_to_array_datetime_parsing() {
use arrow::array::Array;
use arrow::datatypes::{DataType, TimeUnit};
let array = literal_to_array(
&ParameterValue::String("2024-03-17T14:30:00".to_string()),
3,
);
assert!(
matches!(
array.data_type(),
DataType::Timestamp(TimeUnit::Microsecond, None)
),
"DateTime string should parse to Timestamp type"
);
assert_eq!(array.len(), 3);
}
#[test]
fn test_literal_to_array_time_parsing() {
use arrow::array::Array;
use arrow::datatypes::{DataType, TimeUnit};
let array = literal_to_array(&ParameterValue::String("14:30:00".to_string()), 4);
assert_eq!(
array.data_type(),
&DataType::Time64(TimeUnit::Nanosecond),
"Time string should parse to Time64 type"
);
assert_eq!(array.len(), 4);
}
#[test]
fn test_literal_to_array_string_fallback() {
use arrow::array::Array;
use arrow::datatypes::DataType;
let array = literal_to_array(&ParameterValue::String("not a date".to_string()), 2);
assert_eq!(
array.data_type(),
&DataType::Utf8,
"Non-temporal string should remain Utf8 type"
);
assert_eq!(array.len(), 2);
}
#[test]
fn test_annotation_no_required_aesthetics() {
let mut layer = Layer::new(Geom::rule());
layer.source = Some(DataSource::Annotation);
layer.parameters.insert(
"stroke".to_string(),
ParameterValue::String("red".to_string()),
);
layer
.parameters
.insert("linewidth".to_string(), ParameterValue::Number(2.0));
let result = process_annotation_layer(&mut layer, &AnsiDialect);
match result {
Ok(sql) => {
assert!(
!sql.contains("(VALUES ) AS t()"),
"Should not generate empty VALUES clause"
);
assert!(
sql.contains("VALUES") && sql.contains("WITH __ggsql_values__"),
"Should have VALUES with at least one column"
);
assert!(
sql.contains("__ggsql_dummy__"),
"Should have dummy column when no data columns exist"
);
}
Err(e) => {
panic!("Unexpected error: {}", e);
}
}
assert!(
layer.parameters.contains_key("stroke"),
"Non-positional, non-required parameters should stay in parameters"
);
assert!(
layer.parameters.contains_key("linewidth"),
"Non-positional, non-required parameters should stay in parameters"
);
assert!(
!layer.mappings.contains_key("__ggsql_dummy__"),
"Dummy column should not be added to mappings"
);
}
}