Skip to main content

sqlly_datatable/pivot/
config.rs

1//! [`PivotConfig`] — the complete, GPUI-free description of a pivot layout.
2//!
3//! The struct is plain data: reading it back *is* the "get current
4//! configuration" API, and constructing/mutating one is the programmatic
5//! preconfiguration API. The sidebar mutates the same struct through
6//! [`PivotConfig::move_field`] / [`PivotConfig::remove_field`] so drag-and-drop
7//! and code paths cannot diverge.
8
9use crate::config::NumberFormat;
10use crate::pivot::aggregation::AggregationFn;
11use std::collections::HashMap;
12
13/// The four sidebar drop zones a source column can be assigned to.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
15pub enum PivotZone {
16    /// Distinct values become the left-axis row groups.
17    Rows,
18    /// Distinct values become the top-axis column groups.
19    Columns,
20    /// The single measure aggregated at each intersection.
21    Values,
22    /// Source-row filters applied before pivoting.
23    Filters,
24}
25
26/// Field assignments plus display options for a pivot view.
27///
28/// All field references are **source column indices** into the grid's
29/// `GridData::columns`.
30#[derive(Clone, Debug, PartialEq)]
31pub struct PivotConfig {
32    /// Columns whose distinct values form the row axis, outermost first.
33    pub row_fields: Vec<usize>,
34    /// Columns whose distinct values form the column axis, outermost first.
35    pub column_fields: Vec<usize>,
36    /// The measure column. `None` renders an empty pivot prompting for
37    /// configuration.
38    pub value_field: Option<usize>,
39    /// How intersection values are combined.
40    pub aggregation: AggregationFn,
41    /// Columns available as source-row filters (the Filters zone). The
42    /// actual predicate state lives on the pivot state, not the config.
43    pub filter_fields: Vec<usize>,
44    /// Show subtotal values on expanded row group headers.
45    pub show_row_subtotals: bool,
46    /// Append a "Total" column after each expanded column group.
47    pub show_column_subtotals: bool,
48    /// Show the grand-total row at the bottom.
49    pub show_row_grand_total: bool,
50    /// Show the grand-total column at the right.
51    pub show_column_grand_total: bool,
52    /// Label used when a grouping value is `CellValue::None`.
53    pub blank_label: String,
54    /// Optional number-format override for value cells. `None` falls back
55    /// to the value column's resolved format.
56    pub value_format: Option<NumberFormat>,
57    /// Optional per-field display overrides (source column index → format)
58    /// for Rows / Columns / Filters fields, edited from the sidebar's
59    /// double-click format dialog. Applied on top of the column's resolved
60    /// format when its group labels and filter values are formatted; the
61    /// override's `alignment` also drives that field's label alignment.
62    /// Value-cell display is governed by `value_format` instead.
63    pub field_formats: HashMap<usize, NumberFormat>,
64}
65
66impl Default for PivotConfig {
67    fn default() -> Self {
68        Self {
69            row_fields: vec![],
70            column_fields: vec![],
71            value_field: None,
72            aggregation: AggregationFn::default(),
73            filter_fields: vec![],
74            show_row_subtotals: true,
75            show_column_subtotals: false,
76            show_row_grand_total: true,
77            show_column_grand_total: true,
78            blank_label: "(blank)".into(),
79            value_format: None,
80            field_formats: HashMap::new(),
81        }
82    }
83}
84
85impl PivotConfig {
86    /// `true` when there is enough configuration to compute a pivot: a value
87    /// field plus at least one axis field.
88    #[must_use]
89    pub fn is_ready(&self) -> bool {
90        self.value_field.is_some()
91            && (!self.row_fields.is_empty() || !self.column_fields.is_empty())
92    }
93
94    /// The zone `field` is currently assigned to, if any.
95    #[must_use]
96    pub fn zone_of(&self, field: usize) -> Option<PivotZone> {
97        if self.row_fields.contains(&field) {
98            Some(PivotZone::Rows)
99        } else if self.column_fields.contains(&field) {
100            Some(PivotZone::Columns)
101        } else if self.value_field == Some(field) {
102            Some(PivotZone::Values)
103        } else if self.filter_fields.contains(&field) {
104            Some(PivotZone::Filters)
105        } else {
106            None
107        }
108    }
109
110    /// Fields currently assigned to `zone`, in display order.
111    #[must_use]
112    pub fn fields_in(&self, zone: PivotZone) -> Vec<usize> {
113        match zone {
114            PivotZone::Rows => self.row_fields.clone(),
115            PivotZone::Columns => self.column_fields.clone(),
116            PivotZone::Values => self.value_field.into_iter().collect(),
117            PivotZone::Filters => self.filter_fields.clone(),
118        }
119    }
120
121    /// Assign `field` to `zone` at `index` (clamped; `None` appends).
122    ///
123    /// A field lives in at most one zone, so any previous assignment is
124    /// removed first. Dropping onto [`PivotZone::Values`] replaces the
125    /// current value field — the zone holds exactly one.
126    pub fn move_field(&mut self, field: usize, zone: PivotZone, index: Option<usize>) {
127        self.remove_field(field);
128        match zone {
129            PivotZone::Rows => insert_clamped(&mut self.row_fields, field, index),
130            PivotZone::Columns => insert_clamped(&mut self.column_fields, field, index),
131            PivotZone::Values => self.value_field = Some(field),
132            PivotZone::Filters => insert_clamped(&mut self.filter_fields, field, index),
133        }
134    }
135
136    /// Remove `field` from whatever zone holds it. No-op when unassigned.
137    pub fn remove_field(&mut self, field: usize) {
138        self.row_fields.retain(|&f| f != field);
139        self.column_fields.retain(|&f| f != field);
140        self.filter_fields.retain(|&f| f != field);
141        if self.value_field == Some(field) {
142            self.value_field = None;
143        }
144    }
145
146    /// Drop every assignment that refers to a column index outside
147    /// `0..column_count`. Call after the source schema changes.
148    pub fn clamp_to_columns(&mut self, column_count: usize) {
149        self.row_fields.retain(|&f| f < column_count);
150        self.column_fields.retain(|&f| f < column_count);
151        self.filter_fields.retain(|&f| f < column_count);
152        if self.value_field.is_some_and(|f| f >= column_count) {
153            self.value_field = None;
154        }
155        self.field_formats.retain(|&f, _| f < column_count);
156    }
157}
158
159fn insert_clamped(list: &mut Vec<usize>, field: usize, index: Option<usize>) {
160    let at = index.unwrap_or(list.len()).min(list.len());
161    list.insert(at, field);
162}
163
164#[cfg(test)]
165#[allow(clippy::field_reassign_with_default)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn default_is_not_ready() {
171        let cfg = PivotConfig::default();
172        assert!(!cfg.is_ready());
173        assert_eq!(cfg.zone_of(0), None);
174    }
175
176    #[test]
177    fn ready_needs_value_and_one_axis() {
178        let mut cfg = PivotConfig::default();
179        cfg.value_field = Some(2);
180        assert!(!cfg.is_ready(), "value alone is not enough");
181        cfg.row_fields = vec![0];
182        assert!(cfg.is_ready());
183        cfg.row_fields.clear();
184        cfg.column_fields = vec![1];
185        assert!(cfg.is_ready());
186    }
187
188    #[test]
189    fn move_field_between_zones_is_exclusive() {
190        let mut cfg = PivotConfig::default();
191        cfg.move_field(3, PivotZone::Rows, None);
192        assert_eq!(cfg.zone_of(3), Some(PivotZone::Rows));
193        cfg.move_field(3, PivotZone::Columns, None);
194        assert_eq!(cfg.zone_of(3), Some(PivotZone::Columns));
195        assert!(cfg.row_fields.is_empty());
196        cfg.move_field(3, PivotZone::Filters, None);
197        assert_eq!(cfg.zone_of(3), Some(PivotZone::Filters));
198        assert!(cfg.column_fields.is_empty());
199    }
200
201    #[test]
202    fn values_zone_holds_exactly_one() {
203        let mut cfg = PivotConfig::default();
204        cfg.move_field(1, PivotZone::Values, None);
205        cfg.move_field(2, PivotZone::Values, None);
206        assert_eq!(cfg.value_field, Some(2));
207        // The displaced field is unassigned, not relocated.
208        assert_eq!(cfg.zone_of(1), None);
209    }
210
211    #[test]
212    fn move_field_respects_insertion_index() {
213        let mut cfg = PivotConfig::default();
214        cfg.move_field(1, PivotZone::Rows, None);
215        cfg.move_field(2, PivotZone::Rows, None);
216        cfg.move_field(3, PivotZone::Rows, Some(0));
217        assert_eq!(cfg.row_fields, vec![3, 1, 2]);
218        // Out-of-range index clamps to append.
219        cfg.move_field(4, PivotZone::Rows, Some(99));
220        assert_eq!(cfg.row_fields, vec![3, 1, 2, 4]);
221    }
222
223    #[test]
224    fn reorder_within_same_zone() {
225        let mut cfg = PivotConfig::default();
226        cfg.row_fields = vec![1, 2, 3];
227        cfg.move_field(3, PivotZone::Rows, Some(0));
228        assert_eq!(cfg.row_fields, vec![3, 1, 2]);
229    }
230
231    #[test]
232    fn remove_field_clears_every_zone() {
233        let mut cfg = PivotConfig::default();
234        cfg.row_fields = vec![1];
235        cfg.value_field = Some(2);
236        cfg.remove_field(1);
237        cfg.remove_field(2);
238        assert!(cfg.row_fields.is_empty());
239        assert_eq!(cfg.value_field, None);
240    }
241
242    #[test]
243    fn clamp_to_columns_drops_out_of_range() {
244        let mut cfg = PivotConfig::default();
245        cfg.row_fields = vec![0, 5];
246        cfg.column_fields = vec![9];
247        cfg.filter_fields = vec![2, 7];
248        cfg.value_field = Some(6);
249        cfg.field_formats.insert(0, NumberFormat::default());
250        cfg.field_formats.insert(8, NumberFormat::default());
251        cfg.clamp_to_columns(4);
252        assert_eq!(cfg.row_fields, vec![0]);
253        assert!(cfg.column_fields.is_empty());
254        assert_eq!(cfg.filter_fields, vec![2]);
255        assert_eq!(cfg.value_field, None);
256        assert!(cfg.field_formats.contains_key(&0));
257        assert!(!cfg.field_formats.contains_key(&8));
258    }
259
260    #[test]
261    fn fields_in_reports_zone_contents() {
262        let mut cfg = PivotConfig::default();
263        cfg.row_fields = vec![4, 1];
264        cfg.value_field = Some(0);
265        assert_eq!(cfg.fields_in(PivotZone::Rows), vec![4, 1]);
266        assert_eq!(cfg.fields_in(PivotZone::Values), vec![0]);
267        assert!(cfg.fields_in(PivotZone::Columns).is_empty());
268    }
269}