use crate::plot::aesthetic::parse_position;
use crate::{naming, plot::types::DefaultAestheticValue, Mappings};
pub use crate::plot::types::{DefaultParamValue, ParamConstraint, ParamDefinition};
pub const POSITION_VALUES: &[&str] = &["identity", "stack", "dodge", "jitter"];
pub const CLOSED_VALUES: &[&str] = &["left", "right"];
pub const SIDE_VALUES: &[&str] = &["both", "left", "top", "right", "bottom"];
pub const AESTHETIC_ALIASES: &[(&str, &[&str])] = &[("color", &["stroke", "fill"])];
pub const AGGREGATE_PARAM: ParamDefinition = ParamDefinition {
name: "aggregate",
default: DefaultParamValue::Null,
constraint: ParamConstraint::string_or_string_array_unconstrained(),
};
#[derive(Debug, Clone, Copy)]
pub struct DefaultAesthetics {
pub defaults: &'static [(&'static str, DefaultAestheticValue)],
}
impl DefaultAesthetics {
pub fn names(&self) -> Vec<&'static str> {
let mut result: Vec<&'static str> = self.defaults.iter().map(|(name, _)| *name).collect();
for &(alias, targets) in AESTHETIC_ALIASES {
if targets.iter().any(|t| result.contains(t)) {
result.push(alias);
}
}
result
}
pub fn supported(&self) -> Vec<&'static str> {
let mut result: Vec<&'static str> = self
.defaults
.iter()
.filter(|(_, value)| !matches!(value, DefaultAestheticValue::Delayed))
.map(|(name, _)| *name)
.collect();
for &(alias, targets) in AESTHETIC_ALIASES {
if targets.iter().any(|t| result.contains(t)) {
result.push(alias);
}
}
result
}
pub fn required(&self) -> Vec<&'static str> {
self.defaults
.iter()
.filter_map(|(name, value)| {
if matches!(value, DefaultAestheticValue::Required) {
Some(*name)
} else {
None
}
})
.collect()
}
pub fn is_supported(&self, name: &str) -> bool {
let direct_match = self
.defaults
.iter()
.any(|(n, value)| !matches!(value, DefaultAestheticValue::Delayed) && *n == name);
if direct_match {
return true;
}
if let Some((slot, suffix)) = parse_position(name) {
let other_slot = if slot == 1 { 2 } else { 1 };
let equivalent = format!("pos{}{}", other_slot, suffix);
return self.defaults.iter().any(|(n, value)| {
!matches!(value, DefaultAestheticValue::Delayed) && *n == equivalent
});
}
for &(alias, targets) in AESTHETIC_ALIASES {
if alias == name {
return targets.iter().any(|t| self.is_supported(t));
}
}
false
}
pub fn contains(&self, name: &str) -> bool {
self.defaults.iter().any(|(n, _)| *n == name)
}
pub fn is_required(&self, name: &str) -> bool {
self.defaults
.iter()
.any(|(n, value)| *n == name && matches!(value, DefaultAestheticValue::Required))
}
pub fn get(&self, name: &str) -> Option<&'static DefaultAestheticValue> {
self.defaults
.iter()
.find(|(n, _)| *n == name)
.map(|(_, value)| value)
}
pub fn dummy_axes(&self) -> Vec<&'static str> {
self.defaults
.iter()
.filter_map(|(name, value)| {
if matches!(value, DefaultAestheticValue::Dummy) {
Some(*name)
} else {
None
}
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum StatResult {
Identity,
Transformed {
query: String,
stat_columns: Vec<String>,
dummy_columns: Vec<String>,
consumed_aesthetics: Vec<String>,
},
}
pub use crate::plot::types::ColumnInfo;
pub use crate::plot::types::Schema;
pub fn wrap_with_order_by(input_query: &str, result: StatResult, aesthetic: &str) -> StatResult {
let order_col = naming::aesthetic_column(aesthetic);
let order_quoted = naming::quote_ident(&order_col);
match result {
StatResult::Identity => StatResult::Transformed {
query: format!("{} ORDER BY {}", input_query, order_quoted),
stat_columns: vec![],
dummy_columns: vec![],
consumed_aesthetics: vec![],
},
StatResult::Transformed {
query,
stat_columns,
dummy_columns,
consumed_aesthetics,
} => StatResult::Transformed {
query: format!(
"SELECT * FROM ({}) AS \"__ggsql_ord__\" ORDER BY {}",
query, order_quoted
),
stat_columns,
dummy_columns,
consumed_aesthetics,
},
}
}
pub fn wrap_with_dummy_axis(query: &str, axis: &str) -> String {
let stat_col = naming::stat_column(axis);
let dummy_v = naming::stat_column("dummy");
format!(
"SELECT '{val}' AS {col}, * FROM ({q}) AS \"__ggsql_dummy_src__\"",
val = dummy_v,
col = naming::quote_ident(&stat_col),
q = query,
)
}
pub fn wrap_stat_with_dummy_axis(input_query: &str, result: StatResult, axis: &str) -> StatResult {
match result {
StatResult::Identity => StatResult::Transformed {
query: wrap_with_dummy_axis(input_query, axis),
stat_columns: vec![axis.to_string()],
dummy_columns: vec![axis.to_string()],
consumed_aesthetics: vec![],
},
StatResult::Transformed {
query,
mut stat_columns,
mut dummy_columns,
consumed_aesthetics,
} => {
let already_dummied = dummy_columns.iter().any(|s| s == axis);
let wrapped = if already_dummied {
query
} else {
wrap_with_dummy_axis(&query, axis)
};
if !stat_columns.iter().any(|s| s == axis) {
stat_columns.push(axis.to_string());
}
if !already_dummied {
dummy_columns.push(axis.to_string());
}
StatResult::Transformed {
query: wrapped,
stat_columns,
dummy_columns,
consumed_aesthetics,
}
}
}
}
pub fn wrap_stat_with_dummy_pos1(input_query: &str, result: StatResult) -> StatResult {
wrap_stat_with_dummy_axis(input_query, result, "pos1")
}
pub fn axis_family_has_mapping(aesthetics: &Mappings, axis: &str) -> bool {
use crate::plot::aesthetic::parse_position;
let Some((target_slot, _)) = parse_position(axis) else {
return false;
};
aesthetics
.aesthetics
.iter()
.any(|(name, value)| match parse_position(name) {
Some((slot, _)) => slot == target_slot && value.column_name().is_some(),
None => false,
})
}
pub fn get_column_name(aesthetics: &Mappings, aesthetic: &str) -> Option<String> {
use crate::AestheticValue;
aesthetics.get(aesthetic).and_then(|v| match v {
AestheticValue::Column { name, .. } => Some(name.clone()),
_ => None,
})
}
pub fn get_quoted_column_name(aesthetics: &Mappings, aesthetic: &str) -> Option<String> {
get_column_name(aesthetics, aesthetic).map(|n| naming::quote_ident(&n))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_aesthetics_methods() {
let aes = DefaultAesthetics {
defaults: &[
("x", DefaultAestheticValue::Required),
("y", DefaultAestheticValue::Required),
("size", DefaultAestheticValue::Number(3.0)),
("stroke", DefaultAestheticValue::String("black")),
("fill", DefaultAestheticValue::Null),
("yend", DefaultAestheticValue::Delayed),
],
};
assert_eq!(aes.get("x"), Some(&DefaultAestheticValue::Required));
assert_eq!(aes.get("size"), Some(&DefaultAestheticValue::Number(3.0)));
assert_eq!(
aes.get("stroke"),
Some(&DefaultAestheticValue::String("black"))
);
assert_eq!(aes.get("fill"), Some(&DefaultAestheticValue::Null));
assert_eq!(aes.get("yend"), Some(&DefaultAestheticValue::Delayed));
assert_eq!(aes.get("nonexistent"), None);
let names = aes.names();
assert_eq!(names.len(), 7); assert!(names.contains(&"x"));
assert!(names.contains(&"yend"));
assert!(names.contains(&"color"));
let supported = aes.supported();
assert_eq!(supported.len(), 6); assert!(supported.contains(&"x"));
assert!(supported.contains(&"size"));
assert!(supported.contains(&"fill"));
assert!(supported.contains(&"color")); assert!(!supported.contains(&"yend"));
let required = aes.required();
assert_eq!(required.len(), 2);
assert!(required.contains(&"x"));
assert!(required.contains(&"y"));
assert!(!required.contains(&"size"));
assert!(aes.is_supported("x"));
assert!(aes.is_supported("size"));
assert!(aes.is_supported("color")); assert!(!aes.is_supported("yend")); assert!(!aes.is_supported("nonexistent"));
assert!(aes.contains("x"));
assert!(aes.contains("yend")); assert!(!aes.contains("nonexistent"));
assert!(aes.is_required("x"));
assert!(aes.is_required("y"));
assert!(!aes.is_required("size"));
assert!(!aes.is_required("yend"));
}
#[test]
fn wrap_with_order_by_identity_appends_order() {
let result = wrap_with_order_by("SELECT * FROM t", StatResult::Identity, "pos1");
match result {
StatResult::Transformed {
query,
stat_columns,
dummy_columns,
consumed_aesthetics,
} => {
assert_eq!(query, "SELECT * FROM t ORDER BY \"__ggsql_aes_pos1__\"");
assert!(stat_columns.is_empty());
assert!(dummy_columns.is_empty());
assert!(consumed_aesthetics.is_empty());
}
_ => panic!("expected Transformed"),
}
}
#[test]
fn wrap_with_order_by_transformed_wraps_query_and_preserves_metadata() {
let inner = StatResult::Transformed {
query: "SELECT * FROM grouped".to_string(),
stat_columns: vec!["pos2".to_string(), "aggregate".to_string()],
dummy_columns: vec!["pos1".to_string()],
consumed_aesthetics: vec!["pos2".to_string()],
};
let result = wrap_with_order_by("SELECT * FROM raw", inner, "pos1");
match result {
StatResult::Transformed {
query,
stat_columns,
dummy_columns,
consumed_aesthetics,
} => {
assert_eq!(
query,
"SELECT * FROM (SELECT * FROM grouped) AS \"__ggsql_ord__\" ORDER BY \"__ggsql_aes_pos1__\""
);
assert_eq!(
stat_columns,
vec!["pos2".to_string(), "aggregate".to_string()]
);
assert_eq!(dummy_columns, vec!["pos1".to_string()]);
assert_eq!(consumed_aesthetics, vec!["pos2".to_string()]);
}
_ => panic!("expected Transformed"),
}
}
#[test]
fn wrap_with_dummy_pos1_produces_expected_sql() {
let wrapped = wrap_with_dummy_axis("SELECT * FROM t", "pos1");
assert_eq!(
wrapped,
"SELECT '__ggsql_stat_dummy' AS \"__ggsql_stat_pos1\", * FROM (SELECT * FROM t) AS \"__ggsql_dummy_src__\""
);
}
#[test]
fn wrap_stat_with_dummy_pos1_promotes_identity() {
let result = wrap_stat_with_dummy_pos1("SELECT * FROM raw", StatResult::Identity);
match result {
StatResult::Transformed {
query,
stat_columns,
dummy_columns,
consumed_aesthetics,
} => {
assert!(query.contains("__ggsql_stat_dummy"));
assert!(query.contains("__ggsql_stat_pos1"));
assert!(query.contains("SELECT * FROM raw"));
assert_eq!(stat_columns, vec!["pos1".to_string()]);
assert_eq!(dummy_columns, vec!["pos1".to_string()]);
assert!(consumed_aesthetics.is_empty());
}
_ => panic!("expected Transformed"),
}
}
#[test]
fn wrap_stat_with_dummy_pos1_extends_transformed_metadata() {
let inner = StatResult::Transformed {
query: "SELECT 1 AS x".to_string(),
stat_columns: vec!["count".to_string()],
dummy_columns: vec![],
consumed_aesthetics: vec!["weight".to_string()],
};
let result = wrap_stat_with_dummy_pos1("SELECT * FROM raw", inner);
match result {
StatResult::Transformed {
query,
stat_columns,
dummy_columns,
consumed_aesthetics,
} => {
assert!(query.contains("__ggsql_stat_dummy"));
assert!(query.contains("__ggsql_stat_pos1"));
assert!(query.contains("SELECT 1 AS x"));
assert_eq!(stat_columns, vec!["count".to_string(), "pos1".to_string()]);
assert_eq!(dummy_columns, vec!["pos1".to_string()]);
assert_eq!(consumed_aesthetics, vec!["weight".to_string()]);
}
_ => panic!("expected Transformed"),
}
}
#[test]
fn wrap_stat_with_dummy_pos1_idempotent_on_pos1() {
let inner = StatResult::Transformed {
query: "SELECT 1".to_string(),
stat_columns: vec!["pos1".to_string()],
dummy_columns: vec!["pos1".to_string()],
consumed_aesthetics: vec![],
};
let result = wrap_stat_with_dummy_pos1("SELECT *", inner);
match result {
StatResult::Transformed {
stat_columns,
dummy_columns,
..
} => {
assert_eq!(stat_columns, vec!["pos1".to_string()]);
assert_eq!(dummy_columns, vec!["pos1".to_string()]);
}
_ => panic!("expected Transformed"),
}
}
#[test]
fn test_color_alias_requires_stroke_or_fill() {
let aes = DefaultAesthetics {
defaults: &[
("pos1", DefaultAestheticValue::Required),
("pos2", DefaultAestheticValue::Required),
("size", DefaultAestheticValue::Number(3.0)),
],
};
assert!(!aes.is_supported("color"));
assert!(!aes.supported().contains(&"color"));
assert!(!aes.names().contains(&"color"));
let aes_stroke = DefaultAesthetics {
defaults: &[
("pos1", DefaultAestheticValue::Required),
("stroke", DefaultAestheticValue::String("black")),
],
};
assert!(aes_stroke.is_supported("color"));
assert!(aes_stroke.supported().contains(&"color"));
let aes_fill = DefaultAesthetics {
defaults: &[
("pos1", DefaultAestheticValue::Required),
("fill", DefaultAestheticValue::String("black")),
],
};
assert!(aes_fill.is_supported("color"));
assert!(aes_fill.supported().contains(&"color"));
}
}