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