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    /// Lay multiple row fields out flat (tabular) instead of nested: one row
45    /// per innermost combination, each row field in its own row-header
46    /// column, with no group-header rows, indentation, or per-level
47    /// subtotals. Off by default (the nested/hierarchical layout). Has no
48    /// visible effect with zero or one row field.
49    pub flat_rows: bool,
50    /// Show subtotal values on expanded row group headers. Ignored while
51    /// [`Self::flat_rows`] is on (flat layout has no group headers).
52    pub show_row_subtotals: bool,
53    /// Append a "Total" column after each expanded column group.
54    pub show_column_subtotals: bool,
55    /// Show the grand-total row at the bottom.
56    pub show_row_grand_total: bool,
57    /// Show the grand-total column at the right.
58    pub show_column_grand_total: bool,
59    /// Label used when a grouping value is `CellValue::None`.
60    pub blank_label: String,
61    /// Optional number-format override for value cells. `None` falls back
62    /// to the value column's resolved format.
63    pub value_format: Option<NumberFormat>,
64    /// Optional per-field display overrides (source column index → format)
65    /// for Rows / Columns / Filters fields, edited from the sidebar's
66    /// double-click format dialog. Applied on top of the column's resolved
67    /// format when its group labels and filter values are formatted; the
68    /// override's `alignment` also drives that field's label alignment.
69    /// Value-cell display is governed by `value_format` instead.
70    pub field_formats: HashMap<usize, NumberFormat>,
71}
72
73impl Default for PivotConfig {
74    fn default() -> Self {
75        Self {
76            row_fields: vec![],
77            column_fields: vec![],
78            value_field: None,
79            aggregation: AggregationFn::default(),
80            filter_fields: vec![],
81            flat_rows: false,
82            show_row_subtotals: true,
83            show_column_subtotals: false,
84            show_row_grand_total: true,
85            show_column_grand_total: true,
86            blank_label: "(blank)".into(),
87            value_format: None,
88            field_formats: HashMap::new(),
89        }
90    }
91}
92
93impl PivotConfig {
94    /// `true` when there is enough configuration to compute a pivot: a value
95    /// field plus at least one axis field.
96    #[must_use]
97    pub fn is_ready(&self) -> bool {
98        self.value_field.is_some()
99            && (!self.row_fields.is_empty() || !self.column_fields.is_empty())
100    }
101
102    /// The zone `field` is currently assigned to, if any.
103    #[must_use]
104    pub fn zone_of(&self, field: usize) -> Option<PivotZone> {
105        if self.row_fields.contains(&field) {
106            Some(PivotZone::Rows)
107        } else if self.column_fields.contains(&field) {
108            Some(PivotZone::Columns)
109        } else if self.value_field == Some(field) {
110            Some(PivotZone::Values)
111        } else if self.filter_fields.contains(&field) {
112            Some(PivotZone::Filters)
113        } else {
114            None
115        }
116    }
117
118    /// Fields currently assigned to `zone`, in display order.
119    #[must_use]
120    pub fn fields_in(&self, zone: PivotZone) -> Vec<usize> {
121        match zone {
122            PivotZone::Rows => self.row_fields.clone(),
123            PivotZone::Columns => self.column_fields.clone(),
124            PivotZone::Values => self.value_field.into_iter().collect(),
125            PivotZone::Filters => self.filter_fields.clone(),
126        }
127    }
128
129    /// Assign `field` to `zone` at `index` (clamped; `None` appends).
130    ///
131    /// A field lives in at most one zone, so any previous assignment is
132    /// removed first. Dropping onto [`PivotZone::Values`] replaces the
133    /// current value field — the zone holds exactly one.
134    pub fn move_field(&mut self, field: usize, zone: PivotZone, index: Option<usize>) {
135        self.remove_field(field);
136        match zone {
137            PivotZone::Rows => insert_clamped(&mut self.row_fields, field, index),
138            PivotZone::Columns => insert_clamped(&mut self.column_fields, field, index),
139            PivotZone::Values => self.value_field = Some(field),
140            PivotZone::Filters => insert_clamped(&mut self.filter_fields, field, index),
141        }
142    }
143
144    /// Remove `field` from whatever zone holds it. No-op when unassigned.
145    pub fn remove_field(&mut self, field: usize) {
146        self.row_fields.retain(|&f| f != field);
147        self.column_fields.retain(|&f| f != field);
148        self.filter_fields.retain(|&f| f != field);
149        if self.value_field == Some(field) {
150            self.value_field = None;
151        }
152    }
153
154    /// Drop every assignment that refers to a column index outside
155    /// `0..column_count`. Call after the source schema changes.
156    pub fn clamp_to_columns(&mut self, column_count: usize) {
157        self.row_fields.retain(|&f| f < column_count);
158        self.column_fields.retain(|&f| f < column_count);
159        self.filter_fields.retain(|&f| f < column_count);
160        if self.value_field.is_some_and(|f| f >= column_count) {
161            self.value_field = None;
162        }
163        self.field_formats.retain(|&f, _| f < column_count);
164    }
165}
166
167fn insert_clamped(list: &mut Vec<usize>, field: usize, index: Option<usize>) {
168    let at = index.unwrap_or(list.len()).min(list.len());
169    list.insert(at, field);
170}
171
172#[cfg(test)]
173#[allow(clippy::field_reassign_with_default)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn default_is_not_ready() {
179        let cfg = PivotConfig::default();
180        assert!(!cfg.is_ready());
181        assert_eq!(cfg.zone_of(0), None);
182    }
183
184    #[test]
185    fn ready_needs_value_and_one_axis() {
186        let mut cfg = PivotConfig::default();
187        cfg.value_field = Some(2);
188        assert!(!cfg.is_ready(), "value alone is not enough");
189        cfg.row_fields = vec![0];
190        assert!(cfg.is_ready());
191        cfg.row_fields.clear();
192        cfg.column_fields = vec![1];
193        assert!(cfg.is_ready());
194    }
195
196    #[test]
197    fn move_field_between_zones_is_exclusive() {
198        let mut cfg = PivotConfig::default();
199        cfg.move_field(3, PivotZone::Rows, None);
200        assert_eq!(cfg.zone_of(3), Some(PivotZone::Rows));
201        cfg.move_field(3, PivotZone::Columns, None);
202        assert_eq!(cfg.zone_of(3), Some(PivotZone::Columns));
203        assert!(cfg.row_fields.is_empty());
204        cfg.move_field(3, PivotZone::Filters, None);
205        assert_eq!(cfg.zone_of(3), Some(PivotZone::Filters));
206        assert!(cfg.column_fields.is_empty());
207    }
208
209    #[test]
210    fn values_zone_holds_exactly_one() {
211        let mut cfg = PivotConfig::default();
212        cfg.move_field(1, PivotZone::Values, None);
213        cfg.move_field(2, PivotZone::Values, None);
214        assert_eq!(cfg.value_field, Some(2));
215        // The displaced field is unassigned, not relocated.
216        assert_eq!(cfg.zone_of(1), None);
217    }
218
219    #[test]
220    fn move_field_respects_insertion_index() {
221        let mut cfg = PivotConfig::default();
222        cfg.move_field(1, PivotZone::Rows, None);
223        cfg.move_field(2, PivotZone::Rows, None);
224        cfg.move_field(3, PivotZone::Rows, Some(0));
225        assert_eq!(cfg.row_fields, vec![3, 1, 2]);
226        // Out-of-range index clamps to append.
227        cfg.move_field(4, PivotZone::Rows, Some(99));
228        assert_eq!(cfg.row_fields, vec![3, 1, 2, 4]);
229    }
230
231    #[test]
232    fn reorder_within_same_zone() {
233        let mut cfg = PivotConfig::default();
234        cfg.row_fields = vec![1, 2, 3];
235        cfg.move_field(3, PivotZone::Rows, Some(0));
236        assert_eq!(cfg.row_fields, vec![3, 1, 2]);
237    }
238
239    #[test]
240    fn remove_field_clears_every_zone() {
241        let mut cfg = PivotConfig::default();
242        cfg.row_fields = vec![1];
243        cfg.value_field = Some(2);
244        cfg.remove_field(1);
245        cfg.remove_field(2);
246        assert!(cfg.row_fields.is_empty());
247        assert_eq!(cfg.value_field, None);
248    }
249
250    #[test]
251    fn clamp_to_columns_drops_out_of_range() {
252        let mut cfg = PivotConfig::default();
253        cfg.row_fields = vec![0, 5];
254        cfg.column_fields = vec![9];
255        cfg.filter_fields = vec![2, 7];
256        cfg.value_field = Some(6);
257        cfg.field_formats.insert(0, NumberFormat::default());
258        cfg.field_formats.insert(8, NumberFormat::default());
259        cfg.clamp_to_columns(4);
260        assert_eq!(cfg.row_fields, vec![0]);
261        assert!(cfg.column_fields.is_empty());
262        assert_eq!(cfg.filter_fields, vec![2]);
263        assert_eq!(cfg.value_field, None);
264        assert!(cfg.field_formats.contains_key(&0));
265        assert!(!cfg.field_formats.contains_key(&8));
266    }
267
268    #[test]
269    fn fields_in_reports_zone_contents() {
270        let mut cfg = PivotConfig::default();
271        cfg.row_fields = vec![4, 1];
272        cfg.value_field = Some(0);
273        assert_eq!(cfg.fields_in(PivotZone::Rows), vec![4, 1]);
274        assert_eq!(cfg.fields_in(PivotZone::Values), vec![0]);
275        assert!(cfg.fields_in(PivotZone::Columns).is_empty());
276    }
277}