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