Skip to main content

sqlly_datatable/
data.rs

1//! Core data model for the grid: cell values, columns, and the rectangular
2//! [`GridData`] container.
3//!
4//! `GridData` is intentionally simple — a column list paired with a `Vec` of
5//! rectangular rows of [`CellValue`]. It carries no rendering, sorting, or
6//! filtering state: those live on [`crate::grid::GridState`]. Keeping the data
7//! layer pure makes it reusable from outside the widget (export pipelines,
8//! server-side previews, test fixtures).
9//!
10//! [`CellValue`] does not implement [`Eq`]/[`Ord`] because [`CellValue::Decimal`]
11//! holds an `f64`. Use [`compare_cells`] when you need a deterministic total
12//! ordering that handles `NaN` and mixed numeric kinds deliberately rather
13//! than collapsing to `Equal`.
14
15use std::cmp::Ordering;
16
17/// A single cell value.
18///
19/// Decimal values are stored as `f64`; for very large integers that exceed
20/// `2^53`, route them through [`CellValue::Text`] instead.
21#[derive(Clone, Debug, PartialEq)]
22pub enum CellValue {
23    /// Free-form text. The grid will case-fold, truncate, and align it per
24    /// [`crate::config::StringFormat`].
25    Text(String),
26    /// 64-bit signed integer.
27    Integer(i64),
28    /// 64-bit floating point. `NaN` is permitted; [`compare_cells`] places it
29    /// after all finite numbers so sorting remains stable.
30    Decimal(f64),
31    /// Unix timestamp in seconds. Formatting is driven by
32    /// [`crate::config::DateFormat`].
33    Date(i64),
34    /// Boolean value rendered with [`crate::config::BooleanFormat`].
35    Boolean(bool),
36    /// Explicit "no value" — distinct from empty string and zero. Sorts before
37    /// every other variant.
38    None,
39}
40
41/// Declared column kind. Drives the default [`crate::config::ResolvedColumnFormat`]
42/// when no [`crate::config::ColumnOverride`] is supplied.
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
44pub enum ColumnKind {
45    /// Text columns (`StringFormat`).
46    Text,
47    /// Integer columns (`NumberFormat`, default decimals = 0).
48    Integer,
49    /// Decimal columns (`NumberFormat`, default decimals = 2).
50    Decimal,
51    /// Date columns (`DateFormat`).
52    Date,
53    /// Boolean columns (`BooleanFormat`).
54    Boolean,
55    /// Unknown / un-inferred kind. Falls back to [`StringFormat`] for display.
56    None,
57}
58
59/// A single column declaration.
60#[derive(Clone, Debug, PartialEq)]
61pub struct Column {
62    /// Human-readable column name. Rendered as the header label.
63    pub name: String,
64    /// Inferred kind driving default formatting.
65    pub kind: ColumnKind,
66    /// Initial column width in logical pixels. Resizable by the user at runtime.
67    pub width: f32,
68}
69
70impl Column {
71    /// Convenience constructor.
72    #[must_use]
73    pub fn new(name: impl Into<String>, kind: ColumnKind, width: f32) -> Self {
74        Self {
75            name: name.into(),
76            kind,
77            width,
78        }
79    }
80}
81
82/// Rectangular grid data: `rows.len()` rows each of length `columns.len()`.
83///
84/// The library does not silently fix ragged rows; use [`GridData::new`] or
85/// [`GridData::validate`] to detect and reject them.
86#[derive(Clone, Debug)]
87pub struct GridData {
88    /// Column metadata. `columns.len()` is the row width for every row.
89    pub columns: Vec<Column>,
90    /// Row contents. Every row must have exactly `columns.len()` cells.
91    pub rows: Vec<Vec<CellValue>>,
92}
93
94/// Error returned when [`GridData`] cannot be constructed or validated because
95/// at least one row's length disagrees with the column count.
96#[derive(Clone, Debug, PartialEq, Eq)]
97pub enum GridDataError {
98    /// A row had a different number of cells than `columns.len()`.
99    RaggedRow {
100        /// Index of the offending row.
101        row_index: usize,
102        /// Expected number of cells (always `columns.len()`).
103        expected: usize,
104        /// Actual number of cells found in the row.
105        actual: usize,
106    },
107}
108
109impl std::fmt::Display for GridDataError {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        match self {
112            GridDataError::RaggedRow {
113                row_index,
114                expected,
115                actual,
116            } => write!(
117                f,
118                "row {row_index} has {actual} cells but {expected} were expected"
119            ),
120        }
121    }
122}
123
124impl std::error::Error for GridDataError {}
125
126impl GridData {
127    /// Construct a new `GridData`, validating that every row has exactly
128    /// `columns.len()` cells.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`GridDataError::RaggedRow`] pointing at the first mis-sized row.
133    pub fn new(columns: Vec<Column>, rows: Vec<Vec<CellValue>>) -> Result<Self, GridDataError> {
134        let data = Self { columns, rows };
135        data.validate()?;
136        Ok(data)
137    }
138
139    /// Validate the rectangular invariant. Cheap; called by [`GridData::new`]
140    /// and by debug assertions in the paint/copy hot paths.
141    ///
142    /// # Errors
143    ///
144    /// Returns [`GridDataError::RaggedRow`] pointing at the first mis-sized row.
145    pub fn validate(&self) -> Result<(), GridDataError> {
146        let expected = self.columns.len();
147        for (row_index, row) in self.rows.iter().enumerate() {
148            if row.len() != expected {
149                return Err(GridDataError::RaggedRow {
150                    row_index,
151                    expected,
152                    actual: row.len(),
153                });
154            }
155        }
156        Ok(())
157    }
158
159    /// Safe accessor for cell `(row, col)`. Returns `None` if either index is
160    /// out of bounds.
161    #[must_use]
162    pub fn cell(&self, row: usize, col: usize) -> Option<&CellValue> {
163        self.rows.get(row).and_then(|r| r.get(col))
164    }
165
166    /// Number of rows (after sort/filter this reflects the live `display_indices`
167    /// length, not `rows.len()`).
168    #[must_use]
169    pub fn row_count(&self) -> usize {
170        self.rows.len()
171    }
172
173    /// Number of columns. Always equal to any row's length if [`Self::validate`]
174    /// succeeded.
175    #[must_use]
176    pub fn column_count(&self) -> usize {
177        self.columns.len()
178    }
179}
180
181impl From<&str> for CellValue {
182    fn from(s: &str) -> Self {
183        CellValue::Text(s.to_owned())
184    }
185}
186
187impl From<String> for CellValue {
188    fn from(s: String) -> Self {
189        CellValue::Text(s)
190    }
191}
192
193impl From<i64> for CellValue {
194    fn from(v: i64) -> Self {
195        CellValue::Integer(v)
196    }
197}
198
199impl From<i32> for CellValue {
200    fn from(v: i32) -> Self {
201        CellValue::Integer(v.into())
202    }
203}
204
205impl From<f64> for CellValue {
206    fn from(v: f64) -> Self {
207        CellValue::Decimal(v)
208    }
209}
210
211impl From<bool> for CellValue {
212    fn from(v: bool) -> Self {
213        CellValue::Boolean(v)
214    }
215}
216
217impl From<Option<CellValue>> for CellValue {
218    fn from(v: Option<CellValue>) -> Self {
219        v.unwrap_or(CellValue::None)
220    }
221}
222
223/// Total deterministic ordering for `CellValue`.
224///
225/// Behavior:
226///
227/// * Same-kind numeric values compare numerically; decimals use
228///   [`f64::total_cmp`] so `NaN` is ordered consistently (after all finite
229///   values, and `0.0` before `-0.0` is reversed by `total_cmp` semantics —
230///   we keep that contract).
231/// * Mixed `Integer` / `Decimal` pairs compare numerically.
232/// * `None` always sorts before every other variant.
233/// * Cross-type non-numeric pairs fall back to a stable type-rank order so
234///   the return value is never `Equal` for genuinely different values.
235///
236/// Use [`std::cmp::Ordering`] directly via `slice::sort_by`; do not rely on
237/// whatever a future `PartialOrd` derive might produce.
238#[must_use]
239pub fn compare_cells(a: &CellValue, b: &CellValue) -> Ordering {
240    match (a, b) {
241        (CellValue::None, CellValue::None) => Ordering::Equal,
242        (CellValue::None, _) => Ordering::Less,
243        (_, CellValue::None) => Ordering::Greater,
244
245        (CellValue::Integer(x), CellValue::Integer(y)) => x.cmp(y),
246        (CellValue::Decimal(x), CellValue::Decimal(y)) => x.total_cmp(y),
247        (CellValue::Integer(x), CellValue::Decimal(y)) => (*x as f64).total_cmp(y),
248        (CellValue::Decimal(x), CellValue::Integer(y)) => x.total_cmp(&(*y as f64)),
249
250        (CellValue::Text(x), CellValue::Text(y)) => x.cmp(y),
251        (CellValue::Date(x), CellValue::Date(y)) => x.cmp(y),
252        (CellValue::Boolean(x), CellValue::Boolean(y)) => x.cmp(y),
253
254        (left, right) => type_rank(left).cmp(&type_rank(right)),
255    }
256}
257
258/// Stable rank used as a tie-breaker when two cells have different, non-numeric
259/// kinds. Sort order is `None < Boolean < Integer == Decimal < Date < Text`.
260fn type_rank(value: &CellValue) -> u8 {
261    match value {
262        CellValue::None => 0,
263        CellValue::Boolean(_) => 1,
264        CellValue::Integer(_) | CellValue::Decimal(_) => 2,
265        CellValue::Date(_) => 3,
266        CellValue::Text(_) => 4,
267    }
268}
269
270/// A handful of synthetic ledger-style rows for examples and the sample
271/// application. Kept here so examples have a known shape without pulling in a
272/// separate data file. Production code should construct [`GridData`] directly.
273#[must_use]
274pub fn sample_data() -> GridData {
275    use CellValue::{Boolean as B, Decimal as D, Integer as I, None as N, Text as T};
276    use ColumnKind::*;
277
278    let columns = vec![
279        Column::new("JournalLineId", Integer, 120.0),
280        Column::new("TenantId", Integer, 100.0),
281        Column::new("JournalId", Integer, 110.0),
282        Column::new("FinancialAccountingKeyId", Integer, 200.0),
283        Column::new("ExtendedFinancialAccountingKeyId", Integer, 240.0),
284        Column::new("TransactionCurrencyAmount", Decimal, 200.0),
285        Column::new("JurisdictionalCurrencyAmount", Decimal, 200.0),
286        Column::new("ReportingCurrencyAmount", Decimal, 200.0),
287        Column::new("Sequence", Integer, 100.0),
288        Column::new("TransPart", Boolean, 110.0),
289        Column::new("ReferenceTypeId", Integer, 140.0), // Nullable
290        Column::new("ReferenceEntityId", Integer, 150.0), // Nullable
291        Column::new("InternalReference", Text, 160.0),
292        Column::new("CounterPartyReference", Text, 180.0),
293        Column::new("Narrative", Text, 270.0),
294        Column::new("CurrencyId", Integer, 110.0),
295        Column::new("IsCleared", Boolean, 110.0),
296    ];
297
298    let row = |id: i64,
299               ta: i64,
300               ja: i64,
301               fa: i64,
302               ea: i64,
303               tx: i64,
304               jx: i64,
305               rx: i64,
306               sq: i64,
307               pa: bool,
308               rt: Option<i64>,
309               re: Option<i64>,
310               ir: &str,
311               cr: Option<&str>,
312               na: &str,
313               ci: i64,
314               cl: bool| {
315        vec![
316            I(id),
317            I(ta),
318            I(ja),
319            I(fa),
320            I(ea),
321            D(tx as f64),
322            D(jx as f64),
323            D(rx as f64),
324            I(sq),
325            B(pa),
326            rt.map(I).unwrap_or(N),
327            re.map(I).unwrap_or(N),
328            T(ir.into()),
329            cr.map(|s| T(s.into())).unwrap_or(N),
330            T(na.into()),
331            I(ci),
332            B(cl),
333        ]
334    };
335
336    let rows = vec![
337        row(
338            1096,
339            1,
340            148,
341            33,
342            528,
343            17968,
344            17968,
345            485,
346            0,
347            false,
348            Option::None,
349            Option::None,
350            "tomar 1",
351            Option::None,
352            "saldo de apertura de carga",
353            1,
354            false,
355        ),
356        row(
357            1097,
358            1,
359            148,
360            33,
361            530,
362            717,
363            717,
364            19,
365            1,
366            false,
367            Option::None,
368            Option::None,
369            "tomar 1",
370            Option::None,
371            "saldo de apertura de carga",
372            1,
373            false,
374        ),
375        row(
376            1098,
377            1,
378            148,
379            33,
380            532,
381            768,
382            768,
383            20,
384            2,
385            false,
386            Option::None,
387            Option::None,
388            "tomar 1",
389            Option::None,
390            "saldo de apertura de carga",
391            1,
392            false,
393        ),
394        row(
395            1099,
396            1,
397            148,
398            33,
399            533,
400            1141,
401            1141,
402            30,
403            3,
404            false,
405            Option::None,
406            Option::None,
407            "tomar 1",
408            Option::None,
409            "saldo de apertura de carga",
410            1,
411            false,
412        ),
413        row(
414            1100,
415            1,
416            148,
417            33,
418            536,
419            1937,
420            1937,
421            52,
422            4,
423            false,
424            Option::None,
425            Option::None,
426            "tomar 1",
427            Option::None,
428            "saldo de apertura de carga",
429            1,
430            false,
431        ),
432        row(
433            1101,
434            1,
435            148,
436            33,
437            538,
438            1018,
439            1018,
440            27,
441            5,
442            false,
443            Option::None,
444            Option::None,
445            "tomar 1",
446            Option::None,
447            "saldo de apertura de carga",
448            1,
449            false,
450        ),
451        row(
452            1102,
453            1,
454            148,
455            33,
456            542,
457            3172,
458            3172,
459            85,
460            6,
461            false,
462            Option::None,
463            Option::None,
464            "tomar 1",
465            Option::None,
466            "saldo de apertura de carga",
467            1,
468            false,
469        ),
470        row(
471            1103,
472            1,
473            148,
474            33,
475            544,
476            1640,
477            1640,
478            44,
479            7,
480            false,
481            Option::None,
482            Option::None,
483            "tomar 1",
484            Option::None,
485            "saldo de apertura de carga",
486            1,
487            false,
488        ),
489        row(
490            1104,
491            1,
492            148,
493            33,
494            546,
495            809,
496            809,
497            21,
498            8,
499            false,
500            Option::None,
501            Option::None,
502            "tomar 1",
503            Option::None,
504            "saldo de apertura de carga",
505            1,
506            false,
507        ),
508        row(
509            1105,
510            1,
511            148,
512            33,
513            573,
514            67,
515            67,
516            1,
517            9,
518            false,
519            Option::None,
520            Option::None,
521            "tomar 1",
522            Option::None,
523            "saldo de apertura de carga",
524            1,
525            false,
526        ),
527        row(
528            1106,
529            1,
530            148,
531            33,
532            574,
533            20,
534            20,
535            0,
536            10,
537            false,
538            Option::None,
539            Option::None,
540            "tomar 1",
541            Option::None,
542            "saldo de apertura de carga",
543            1,
544            false,
545        ),
546        row(
547            1107,
548            1,
549            148,
550            33,
551            575,
552            70,
553            70,
554            1,
555            11,
556            false,
557            Option::None,
558            Option::None,
559            "tomar 1",
560            Option::None,
561            "saldo de apertura de carga",
562            1,
563            false,
564        ),
565        row(
566            1108,
567            1,
568            148,
569            33,
570            576,
571            29,
572            29,
573            0,
574            12,
575            false,
576            Option::None,
577            Option::None,
578            "tomar 1",
579            Option::None,
580            "saldo de apertura de carga",
581            1,
582            false,
583        ),
584        row(
585            1109,
586            1,
587            148,
588            33,
589            577,
590            35,
591            35,
592            0,
593            13,
594            false,
595            Option::None,
596            Option::None,
597            "tomar 1",
598            Option::None,
599            "saldo de apertura de carga",
600            1,
601            false,
602        ),
603        row(
604            1110,
605            1,
606            148,
607            33,
608            578,
609            283,
610            283,
611            7,
612            14,
613            false,
614            Option::None,
615            Option::None,
616            "tomar 1",
617            Option::None,
618            "saldo de apertura de carga",
619            1,
620            false,
621        ),
622        row(
623            1111,
624            1,
625            148,
626            33,
627            579,
628            200,
629            200,
630            5,
631            15,
632            false,
633            Option::None,
634            Option::None,
635            "tomar 1",
636            Option::None,
637            "saldo de apertura de carga",
638            1,
639            false,
640        ),
641        row(
642            1112,
643            1,
644            148,
645            33,
646            580,
647            1140,
648            1140,
649            30,
650            16,
651            false,
652            Option::None,
653            Option::None,
654            "tomar 1",
655            Option::None,
656            "saldo de apertura de carga",
657            1,
658            false,
659        ),
660        row(
661            1113,
662            1,
663            148,
664            33,
665            581,
666            117,
667            117,
668            3,
669            17,
670            false,
671            Option::None,
672            Option::None,
673            "tomar 1",
674            Option::None,
675            "saldo de apertura de carga",
676            1,
677            false,
678        ),
679        row(
680            1114,
681            1,
682            148,
683            33,
684            582,
685            366,
686            366,
687            9,
688            18,
689            false,
690            Option::None,
691            Option::None,
692            "tomar 1",
693            Option::None,
694            "saldo de apertura de carga",
695            1,
696            false,
697        ),
698        row(
699            1115,
700            1,
701            148,
702            33,
703            603,
704            241,
705            241,
706            6,
707            19,
708            false,
709            Option::None,
710            Option::None,
711            "tomar 1",
712            Option::None,
713            "saldo de apertura de carga",
714            1,
715            false,
716        ),
717        row(
718            1116,
719            1,
720            148,
721            33,
722            604,
723            458,
724            458,
725            12,
726            20,
727            false,
728            Option::None,
729            Option::None,
730            "tomar 1",
731            Option::None,
732            "saldo de apertura de carga",
733            1,
734            false,
735        ),
736        row(
737            1117,
738            1,
739            148,
740            33,
741            605,
742            2640,
743            2640,
744            71,
745            21,
746            false,
747            Option::None,
748            Option::None,
749            "tomar 1",
750            Option::None,
751            "saldo de apertura de carga",
752            1,
753            false,
754        ),
755        row(
756            1118,
757            1,
758            148,
759            33,
760            606,
761            104,
762            104,
763            2,
764            22,
765            false,
766            Option::None,
767            Option::None,
768            "tomar 1",
769            Option::None,
770            "saldo de apertura de carga",
771            1,
772            false,
773        ),
774        row(
775            1119,
776            1,
777            148,
778            33,
779            607,
780            236,
781            236,
782            6,
783            23,
784            false,
785            Option::None,
786            Option::None,
787            "tomar 1",
788            Option::None,
789            "saldo de apertura de carga",
790            1,
791            false,
792        ),
793        row(
794            1120,
795            1,
796            148,
797            33,
798            608,
799            356,
800            356,
801            9,
802            24,
803            false,
804            Option::None,
805            Option::None,
806            "tomar 1",
807            Option::None,
808            "saldo de apertura de carga",
809            1,
810            false,
811        ),
812        row(
813            1121,
814            1,
815            148,
816            33,
817            609,
818            323,
819            323,
820            8,
821            25,
822            false,
823            Option::None,
824            Option::None,
825            "tomar 1",
826            Option::None,
827            "saldo de apertura de carga",
828            1,
829            false,
830        ),
831    ];
832
833    GridData { columns, rows }
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839
840    #[test]
841    fn compare_same_kind_numeric() {
842        assert_eq!(
843            compare_cells(&CellValue::Integer(1), &CellValue::Integer(2)),
844            Ordering::Less
845        );
846        assert_eq!(
847            compare_cells(&CellValue::Integer(2), &CellValue::Integer(1)),
848            Ordering::Greater
849        );
850        assert_eq!(
851            compare_cells(&CellValue::Integer(7), &CellValue::Integer(7)),
852            Ordering::Equal
853        );
854        assert_eq!(
855            compare_cells(&CellValue::Decimal(1.5), &CellValue::Decimal(2.5)),
856            Ordering::Less
857        );
858    }
859
860    #[test]
861    fn compare_decimal_handles_nan_deterministically() {
862        let nan = CellValue::Decimal(f64::NAN);
863        let one = CellValue::Decimal(1.0);
864        // `NaN` should not collapse to Equal: it sits at a defined slot in total_cmp.
865        assert_ne!(compare_cells(&nan, &one), Ordering::Equal);
866        // Two `NaN` should be equal under total_cmp.
867        assert_eq!(
868            compare_cells(&nan, &CellValue::Decimal(f64::NAN)),
869            Ordering::Equal
870        );
871    }
872
873    #[test]
874    fn compare_mixed_numeric_via_total_cmp() {
875        assert_eq!(
876            compare_cells(&CellValue::Integer(5), &CellValue::Decimal(5.5)),
877            Ordering::Less,
878        );
879        assert_eq!(
880            compare_cells(&CellValue::Decimal(5.5), &CellValue::Integer(5)),
881            Ordering::Greater,
882        );
883        assert_eq!(
884            compare_cells(&CellValue::Integer(5), &CellValue::Decimal(5.0)),
885            Ordering::Equal,
886        );
887    }
888
889    #[test]
890    fn compare_null_is_always_less_than_other() {
891        assert_eq!(
892            compare_cells(&CellValue::None, &CellValue::Integer(0)),
893            Ordering::Less
894        );
895        assert_eq!(
896            compare_cells(&CellValue::Integer(0), &CellValue::None),
897            Ordering::Greater
898        );
899        assert_eq!(
900            compare_cells(&CellValue::None, &CellValue::None),
901            Ordering::Equal
902        );
903        assert_eq!(
904            compare_cells(&CellValue::None, &CellValue::Text("z".into())),
905            Ordering::Less
906        );
907    }
908
909    #[test]
910    fn compare_cross_type_non_numeric_is_deterministic_non_equal() {
911        // Different kinds, neither numeric, both non-null -> type-rank, Equal only by rank.
912        assert_ne!(
913            compare_cells(&CellValue::Boolean(true), &CellValue::Text("x".into())),
914            Ordering::Equal,
915        );
916        assert_eq!(
917            compare_cells(&CellValue::Boolean(true), &CellValue::Boolean(true)),
918            Ordering::Equal,
919        );
920    }
921
922    #[test]
923    fn grid_data_construction_validates_rows() {
924        let cols = vec![
925            Column::new("a", ColumnKind::Integer, 80.0),
926            Column::new("b", ColumnKind::Integer, 80.0),
927        ];
928        // Good.
929        let ok = GridData::new(
930            cols.clone(),
931            vec![vec![CellValue::Integer(1), CellValue::Integer(2)]],
932        );
933        assert!(ok.is_ok());
934
935        // Ragged row.
936        let bad = GridData::new(
937            cols,
938            vec![vec![
939                CellValue::Integer(1),
940                CellValue::Integer(2),
941                CellValue::Integer(3),
942            ]],
943        );
944        assert_eq!(
945            bad.err(),
946            Some(GridDataError::RaggedRow {
947                row_index: 0,
948                expected: 2,
949                actual: 3
950            }),
951        );
952    }
953
954    #[test]
955    #[allow(clippy::unwrap_used, clippy::expect_used)]
956    fn grid_data_cell_safe_access() {
957        let data = GridData::new(
958            vec![Column::new("a", ColumnKind::Integer, 80.0)],
959            vec![vec![CellValue::Integer(9)]],
960        )
961        .expect("row width matches columns");
962        assert_eq!(data.cell(0, 0), Some(&CellValue::Integer(9)));
963        assert_eq!(data.cell(1, 0), Option::None);
964        assert_eq!(data.cell(0, 1), Option::None);
965    }
966
967    #[test]
968    fn from_conversions_match_variant() {
969        assert_eq!(
970            CellValue::from(String::from("x")),
971            CellValue::Text("x".into())
972        );
973        assert_eq!(CellValue::from(42_i64), CellValue::Integer(42));
974        assert_eq!(CellValue::from(7_i32), CellValue::Integer(7));
975        assert_eq!(CellValue::from(0.5_f64), CellValue::Decimal(0.5));
976        assert_eq!(CellValue::from(true), CellValue::Boolean(true));
977        assert_eq!(
978            CellValue::from(Some(CellValue::Integer(3))),
979            CellValue::Integer(3),
980        );
981        assert_eq!(CellValue::from(Option::None::<CellValue>), CellValue::None);
982    }
983
984    #[test]
985    fn sample_data_is_rectangular() {
986        let sample = sample_data();
987        assert!(
988            sample.validate().is_ok(),
989            "sample rows should be rectangular"
990        );
991        assert!(sample.row_count() > 0);
992    }
993}