ggplot_rs/aes/mapping.rs
1use crate::aes::{
2 validated_data_attr_internal_name, Aes, Aesthetic, MappingStage, INTERNAL_PREFIX,
3 TOOLTIP_INTERNAL_NAME,
4};
5use crate::data::DataFrame;
6
7/// The internal column name a mapping should be stored under, or `None` if
8/// it's invalid/reserved (in which case a warning was already pushed).
9/// Shared by [`resolve_mappings`] and [`apply_after_stat`] so `DataAttr` and
10/// `Tooltip` get exactly the same validation and routing regardless of
11/// which build stage (before/after stat) created the mapping.
12fn target_column(aesthetic: &Aesthetic, warnings: &mut Vec<String>) -> Option<String> {
13 match aesthetic {
14 Aesthetic::DataAttr(target) => match validated_data_attr_internal_name(target) {
15 Some(internal) => Some(internal),
16 None => {
17 warnings.push(format!(
18 "aes: data_attr target {target:?} is invalid or reserved; skipped"
19 ));
20 None
21 }
22 },
23 // A `Tooltip` mapping is stored under its own internal name, not the
24 // plain `"tooltip"` an unrelated raw source column could also be
25 // named — the same class of bug `DataAttr`'s internal prefix fixes.
26 Aesthetic::Tooltip => Some(TOOLTIP_INTERNAL_NAME.to_string()),
27 other => Some(other.col_name().to_string()),
28 }
29}
30
31/// Evaluate aesthetic mappings: extract columns from source data
32/// and rename them to canonical aesthetic names (x, y, color, etc.).
33/// Only resolves BeforeStat mappings.
34///
35/// A `DataAttr`/`Tooltip` mapping's target is re-validated and routed to an
36/// internal, non-colliding column name here (see [`target_column`]),
37/// regardless of how the mapping was built. `geom::set_row_extras` only
38/// reads those internal names, so it never mistakes an incidental raw
39/// column of the same plain name (`"tooltip"`, or `"data-<name>"`) for a
40/// validated one — and a raw source column is never carried through under
41/// an internal name either: any column whose name contains a NUL byte (or,
42/// redundantly, the internal prefix itself — the NUL check already covers
43/// it, since the prefix starts with one, but a future internal scheme might
44/// not) is dropped outright by the carry-over loop below, rather than
45/// copied through unchanged. Without that, a caller could still *forge* an
46/// internal name directly (`GGPlot::new` accepts any `String` column name,
47/// NUL byte included) and have it misread as a validated attribute.
48pub fn resolve_mappings(data: &DataFrame, mapping: &Aes, warnings: &mut Vec<String>) -> DataFrame {
49 let mut result = DataFrame::new();
50 let nrows = data.nrows();
51
52 if nrows == 0 {
53 return result;
54 }
55
56 for m in &mapping.mappings {
57 if m.stage != MappingStage::BeforeStat {
58 continue;
59 }
60 let Some(col_name) = target_column(&m.aesthetic, warnings) else {
61 continue;
62 };
63 if let Some(values) = data.column(&m.column) {
64 result.add_column(col_name, values.to_vec());
65 } else if let Some(values) = super::expr::eval_expression(&m.column, data) {
66 // Computed aesthetic — `m.column` is an expression like "a / b" or
67 // "log(gdp)" rather than a bare column name.
68 result.add_column(col_name, values);
69 }
70 }
71
72 // Also carry over any columns that already have canonical names and
73 // aren't mapped — except one that could forge an internal name (see
74 // this function's doc).
75 for name in data.column_names() {
76 if is_forged_internal_name(name) {
77 continue;
78 }
79 if !result.has_column(name) {
80 // Keep original columns available for stats that need them
81 if let Some(values) = data.column(name) {
82 if result.nrows() == 0 || values.len() == result.nrows() {
83 result.add_column(name.to_string(), values.to_vec());
84 }
85 }
86 }
87 }
88
89 result
90}
91
92/// A raw column name that could be mistaken for — or collide with — an
93/// internal column written by `target_column` (NUL byte, or the internal
94/// prefix text itself, which always contains a NUL but is checked
95/// explicitly too in case that ever changes).
96fn is_forged_internal_name(name: &str) -> bool {
97 name.contains('\0') || name.contains(INTERNAL_PREFIX)
98}
99
100/// Apply after_stat mappings: rename stat-computed columns to canonical
101/// aesthetic names. Called after the stat step in the build pipeline.
102/// `DataAttr`/`Tooltip` targets are validated and routed the same way as in
103/// [`resolve_mappings`] (built directly and pushed with
104/// `MappingStage::AfterStat` is obscure but not impossible, so it gets the
105/// same gate).
106pub fn apply_after_stat(data: &mut DataFrame, mapping: &Aes, warnings: &mut Vec<String>) {
107 for m in &mapping.mappings {
108 if m.stage != MappingStage::AfterStat {
109 continue;
110 }
111 let Some(target) = target_column(&m.aesthetic, warnings) else {
112 continue;
113 };
114 let source = &m.column;
115
116 // If the stat produced the source column, rename it to the target
117 // aesthetic; otherwise evaluate `source` as an expression over the
118 // stat output (e.g. after_stat_y("count / sum(count)") for proportions).
119 let values = if let Some(values) = data.column(source) {
120 Some(values.to_vec())
121 } else {
122 super::expr::eval_expression(source, data)
123 };
124 if let Some(values) = values {
125 // Remove existing target column if any, then add the new mapping
126 if data.has_column(&target) {
127 if let Some(col) = data.column_mut(&target) {
128 *col = values;
129 }
130 } else {
131 data.add_column(target, values);
132 }
133 }
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use crate::aes::AesMapping;
141 use crate::data::Value;
142
143 /// An invalid/reserved `DataAttr` target — bypassing `Aes::data_attr`'s
144 /// own validation by pushing the mapping directly — is dropped with a
145 /// warning in `resolve_mappings` (the single authoritative gate),
146 /// rather than silently resolved.
147 #[test]
148 fn resolve_mappings_drops_an_invalid_data_attr_target_with_a_warning() {
149 let mut data = DataFrame::new();
150 data.add_column("x".to_string(), vec![Value::Float(1.0)]);
151 data.add_column("v".to_string(), vec![Value::Str("payload".into())]);
152
153 let mut aes = Aes::new();
154 aes.mappings.push(AesMapping {
155 column: "v".to_string(),
156 aesthetic: Aesthetic::DataAttr("data-part".to_string()), // reserved
157 stage: MappingStage::BeforeStat,
158 });
159
160 let mut warnings = Vec::new();
161 let result = resolve_mappings(&data, &aes, &mut warnings);
162
163 assert_eq!(warnings.len(), 1);
164 assert!(warnings[0].contains("reserved"));
165 // No internal data-attr column was created for it.
166 assert!(!result
167 .column_names()
168 .iter()
169 .any(|n| n.starts_with(crate::aes::DATA_ATTR_INTERNAL_PREFIX)));
170 }
171
172 /// Same gate, reached via `apply_after_stat` (a `DataAttr` mapping
173 /// pushed with `MappingStage::AfterStat` directly).
174 #[test]
175 fn apply_after_stat_drops_an_invalid_data_attr_target_with_a_warning() {
176 let mut data = DataFrame::new();
177 data.add_column("count".to_string(), vec![Value::Float(5.0)]);
178
179 let mut aes = Aes::new();
180 aes.mappings.push(AesMapping {
181 column: "count".to_string(),
182 aesthetic: Aesthetic::DataAttr("data-Bad Name".to_string()),
183 stage: MappingStage::AfterStat,
184 });
185
186 let mut warnings = Vec::new();
187 apply_after_stat(&mut data, &aes, &mut warnings);
188
189 assert_eq!(warnings.len(), 1);
190 assert!(!data
191 .column_names()
192 .iter()
193 .any(|n| n.starts_with(crate::aes::DATA_ATTR_INTERNAL_PREFIX)));
194 }
195}