Skip to main content

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}