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        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}