Skip to main content

visi_core/core/
table.rs

1//! Excel Tables (ListObjects): named sub-ranges of a worksheet.
2//!
3//! Not to be confused with a [`Sheet`], which this codebase informally calls a
4//! "table" in places. An [`ExcelTable`] lives *on* a sheet and may cover only
5//! part of it.
6
7use serde::{Deserialize, Serialize};
8
9use crate::core::engine::{Sheet, generate_unique_id};
10
11/// A named, rectangular range within a single worksheet, mirroring an Excel
12/// Table (a.k.a. `ListObject`): a header row, a body of data rows, and an
13/// optional totals row, all with stable per-column names that formulas can
14/// reference via structured references (e.g. `Sales[Amount]`).
15///
16/// This is a distinct concept from a `Sheet`: elsewhere in this codebase a
17/// `Sheet` is informally called a "table" (see `Sheet::new`'s default name
18/// `"table_1"`), but an `ExcelTable` is a sub-range that lives *on* a sheet,
19/// exactly like a real Excel Table can occupy only part of a worksheet.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub struct ExcelTable {
22    /// Workbook-unique identifier, stable across renames.
23    pub id: u64,
24    /// The table's name, as a structured reference spells it. Unique
25    /// workbook-wide and matched case-insensitively.
26    pub name: String,
27    /// The sheet this table occupies part of.
28    pub sheet_id: u64,
29    /// Topmost row of the range, 0-based -- the header row when there is one.
30    pub start_row: usize,
31    /// Leftmost column of the range, 0-based.
32    pub start_col: usize,
33    /// Bottommost row of the range, 0-based and inclusive -- the totals row
34    /// when there is one.
35    pub end_row: usize,
36    /// Rightmost column of the range, 0-based and inclusive.
37    pub end_col: usize,
38    /// Whether the first row is a header rather than data.
39    pub has_header_row: bool,
40    /// Whether the last row is a totals row rather than data.
41    pub has_totals_row: bool,
42    /// Column names, in sheet-column order, one per column in
43    /// `start_col..=end_col`. Kept in sync with the header row's cell text
44    /// (when `has_header_row` is true) by the CRUD methods in this file.
45    pub columns: Vec<String>,
46    /// Visual style theme name (e.g. "TableStyleMedium9", "TableStyleLight1", or custom theme)
47    #[serde(default)]
48    pub style_name: Option<String>,
49    /// Whether the last row of the range is Excel's *insert row* placeholder
50    /// rather than data -- i.e. the table has **zero data rows**.
51    ///
52    /// This cannot be inferred from the extent, which is the surprise:
53    /// deleting a one-data-row table's only row leaves `ref` at `A1:C2` and
54    /// sets `insertRow="1"` in `xl/tables/tableN.xml`, so a zero-row table
55    /// and a table with one *blank* data row have identical bounds. Excel
56    /// tells them apart by this flag and so must we -- `ListObject`'s
57    /// `.DataBodyRange` is `Nothing` and `.ListRows.Count` is 0 for the
58    /// former and a real range and 1 for the latter. Measured with
59    /// `fuzz/vba_table_probe.py --empty`, which is issue #11's shape.
60    #[serde(default)]
61    pub has_insert_row: bool,
62}
63
64impl ExcelTable {
65    /// Sets the table's visual style, or clears it with `None`.
66    pub fn set_style_name(&mut self, style_name: Option<String>) {
67        self.style_name = style_name;
68    }
69
70    /// Total rows in the range, header and totals rows included.
71    pub fn row_count(&self) -> usize {
72        self.end_row - self.start_row + 1
73    }
74
75    /// Columns in the range.
76    pub fn col_count(&self) -> usize {
77        self.end_col - self.start_col + 1
78    }
79
80    /// First row of the table's actual data body (excludes the header row).
81    pub fn data_start_row(&self) -> usize {
82        self.start_row + usize::from(self.has_header_row)
83    }
84
85    /// Last row of the table's actual data body, excluding the totals row
86    /// and Excel's insert-row placeholder.
87    ///
88    /// May be less than `data_start_row()` for a table with no data rows, so
89    /// callers building a range from the pair must handle the empty case
90    /// rather than assuming `start..=end` is non-empty. See
91    /// [`ExcelTable::data_row_count`].
92    pub fn data_end_row(&self) -> usize {
93        self.end_row
94            .saturating_sub(usize::from(self.has_totals_row))
95            .saturating_sub(usize::from(self.has_insert_row))
96    }
97
98    /// How many data rows the table actually has, which is 0 for a table
99    /// sitting on its insert-row placeholder.
100    ///
101    /// Use this rather than comparing `data_start_row()` with
102    /// `data_end_row()`: an empty table's end is *below* its start, so the
103    /// subtraction underflows.
104    pub fn data_row_count(&self) -> usize {
105        (self.data_end_row() + 1).saturating_sub(self.data_start_row())
106    }
107
108    /// The header row's sheet-row index, or `None` if the table has no
109    /// header.
110    pub fn header_row(&self) -> Option<usize> {
111        self.has_header_row.then_some(self.start_row)
112    }
113
114    /// The totals row's sheet-row index, or `None` if the table has no
115    /// totals row.
116    pub fn totals_row(&self) -> Option<usize> {
117        self.has_totals_row.then_some(self.end_row)
118    }
119
120    /// Index (0-based, relative to the table's own columns) of the column
121    /// with the given name, matched case-insensitively as Excel does for
122    /// structured references.
123    pub fn local_column_index(&self, name: &str) -> Option<usize> {
124        self.columns
125            .iter()
126            .position(|c| c.eq_ignore_ascii_case(name))
127    }
128
129    /// Whether this table's range overlaps the given rectangular range.
130    pub fn overlaps(
131        &self,
132        start_row: usize,
133        start_col: usize,
134        end_row: usize,
135        end_col: usize,
136    ) -> bool {
137        self.start_row <= end_row
138            && start_row <= self.end_row
139            && self.start_col <= end_col
140            && start_col <= self.end_col
141    }
142}
143
144fn validate_table_name(name: &str) -> Result<(), String> {
145    let trimmed = name.trim();
146    if trimmed.is_empty() {
147        return Err("Table name cannot be empty".to_string());
148    }
149    let first = trimmed.chars().next().unwrap();
150    if !(first.is_alphabetic() || first == '_') {
151        return Err(format!(
152            "Table name '{}' must start with a letter or underscore",
153            name
154        ));
155    }
156    if !trimmed
157        .chars()
158        .all(|c| c.is_alphanumeric() || c == '_' || c == '.')
159    {
160        return Err(format!(
161            "Table name '{}' may only contain letters, digits, underscores, and periods",
162            name
163        ));
164    }
165    Ok(())
166}
167
168fn check_duplicate_column_names(columns: &[String]) -> Result<(), String> {
169    let mut seen = std::collections::HashSet::new();
170    for c in columns {
171        if !seen.insert(c.to_ascii_lowercase()) {
172            return Err(format!("Duplicate column name '{}' in table header row", c));
173        }
174    }
175    Ok(())
176}
177
178impl Sheet {
179    /// Finds a table on this sheet by name, matched case-insensitively as
180    /// Excel does.
181    pub fn find_table(&self, name: &str) -> Option<&ExcelTable> {
182        self.tables
183            .iter()
184            .find(|t| t.name.eq_ignore_ascii_case(name))
185    }
186
187    /// [`Sheet::find_table`], mutably.
188    pub fn find_table_mut(&mut self, name: &str) -> Option<&mut ExcelTable> {
189        self.tables
190            .iter_mut()
191            .find(|t| t.name.eq_ignore_ascii_case(name))
192    }
193
194    /// Reads the header text for sheet column `col_idx` at `header_row`, if
195    /// non-blank; otherwise falls back to a default "ColumnN" name (N is
196    /// 1-based within the table).
197    fn table_column_header(&self, header_row: usize, col_idx: usize, local_idx: usize) -> String {
198        // Prefer the cell's computed value (what a user actually sees) over
199        // its raw source text, in case a header cell happens to hold a
200        // formula rather than plain text; fall back to raw source for
201        // cells that haven't been committed/evaluated yet.
202        let computed = self
203            .columns
204            .get(col_idx)
205            .and_then(|c| c.data.get(header_row))
206            .map(|d| d.to_string())
207            .filter(|s| !s.is_empty());
208        computed
209            .or_else(|| {
210                self.columns
211                    .get(col_idx)
212                    .and_then(|c| c.src.get(header_row))
213                    .map(|s| s.trim().to_string())
214                    .filter(|s| !s.is_empty())
215            })
216            .unwrap_or_else(|| format!("Column{}", local_idx + 1))
217    }
218
219    /// Defines a new Excel Table over the rectangular range
220    /// `start_row..=end_row` x `start_col..=end_col` (0-based, inclusive)
221    /// on this sheet. Column names are read from the header row's existing
222    /// cell text when `has_header_row` is true, falling back to "ColumnN"
223    /// for blank cells; otherwise every column gets a default "ColumnN"
224    /// name.
225    #[allow(clippy::too_many_arguments)]
226    pub fn add_table(
227        &mut self,
228        name: String,
229        start_row: usize,
230        start_col: usize,
231        end_row: usize,
232        end_col: usize,
233        has_header_row: bool,
234        has_totals_row: bool,
235    ) -> Result<u64, String> {
236        validate_table_name(&name)?;
237        if self.find_table(&name).is_some() {
238            return Err(format!(
239                "Table '{}' already exists on sheet '{}'",
240                name, self.name
241            ));
242        }
243        if end_row < start_row || end_col < start_col {
244            return Err("Table range end must not precede its start".to_string());
245        }
246        let (row_count, col_count) = (self.row_count(), self.col_count());
247        if end_row >= row_count || end_col >= col_count {
248            return Err(format!(
249                "Table range exceeds sheet bounds ({} rows x {} cols)",
250                row_count, col_count
251            ));
252        }
253        if let Some(existing) = self
254            .tables
255            .iter()
256            .find(|t| t.overlaps(start_row, start_col, end_row, end_col))
257        {
258            return Err(format!(
259                "Table range overlaps existing table '{}' on sheet '{}'",
260                existing.name, self.name
261            ));
262        }
263
264        let columns: Vec<String> = (start_col..=end_col)
265            .enumerate()
266            .map(|(local_idx, col_idx)| {
267                if has_header_row {
268                    self.table_column_header(start_row, col_idx, local_idx)
269                } else {
270                    format!("Column{}", local_idx + 1)
271                }
272            })
273            .collect();
274        check_duplicate_column_names(&columns)?;
275
276        let id = generate_unique_id();
277        self.tables.push(ExcelTable {
278            id,
279            name,
280            sheet_id: self.id,
281            start_row,
282            start_col,
283            end_row,
284            end_col,
285            has_header_row,
286            has_totals_row,
287            columns,
288            style_name: None,
289            has_insert_row: false,
290        });
291        Ok(id)
292    }
293
294    /// Removes a table definition from this sheet, leaving the cells it
295    /// covered untouched.
296    ///
297    /// # Errors
298    ///
299    /// Returns a message if no table on this sheet has that name.
300    pub fn delete_table_by_name(&mut self, name: &str) -> Result<(), String> {
301        if let Some(pos) = self
302            .tables
303            .iter()
304            .position(|t| t.name.eq_ignore_ascii_case(name))
305        {
306            self.tables.remove(pos);
307            Ok(())
308        } else {
309            Err(format!(
310                "Table '{}' not found on sheet '{}'",
311                name, self.name
312            ))
313        }
314    }
315
316    /// Renames a table on this sheet.
317    ///
318    /// Renaming here does *not* rewrite the formulas that reference the table
319    /// -- that cascade is `WorkbookManager::rename_table`'s job, and it is
320    /// what keeps `Sales[Amount]` pointing at the renamed table. Prefer that
321    /// entry point unless you are rewriting the references yourself.
322    ///
323    /// # Errors
324    ///
325    /// Returns a message if the new name is not a valid table name, if
326    /// another table on this sheet already has it, or if no table on this
327    /// sheet has `old_name`.
328    pub fn rename_table(&mut self, old_name: &str, new_name: &str) -> Result<(), String> {
329        validate_table_name(new_name)?;
330        if self.tables.iter().any(|t| {
331            !t.name.eq_ignore_ascii_case(old_name) && t.name.eq_ignore_ascii_case(new_name)
332        }) {
333            return Err(format!("Table name '{}' is already taken", new_name));
334        }
335        let sheet_name = self.name.clone();
336        let table = self
337            .find_table_mut(old_name)
338            .ok_or_else(|| format!("Table '{}' not found on sheet '{}'", old_name, sheet_name))?;
339        table.name = new_name.to_string();
340        Ok(())
341    }
342
343    /// Extends or shrinks a table's range by moving its bottom-right corner
344    /// to `new_end_row`/`new_end_col` (the top-left corner never moves).
345    /// Column names for any newly-included columns are read from the
346    /// header row (or default to "ColumnN"); names for columns that
347    /// already existed are preserved by position.
348    pub fn resize_table(
349        &mut self,
350        name: &str,
351        new_end_row: usize,
352        new_end_col: usize,
353    ) -> Result<(), String> {
354        let (id, start_row, start_col, has_header_row, old_columns) = {
355            let table = self
356                .find_table(name)
357                .ok_or_else(|| format!("Table '{}' not found on sheet '{}'", name, self.name))?;
358            (
359                table.id,
360                table.start_row,
361                table.start_col,
362                table.has_header_row,
363                table.columns.clone(),
364            )
365        };
366
367        if new_end_row < start_row || new_end_col < start_col {
368            return Err("Table range end must not precede its start".to_string());
369        }
370        let (row_count, col_count) = (self.row_count(), self.col_count());
371        if new_end_row >= row_count || new_end_col >= col_count {
372            return Err(format!(
373                "Table range exceeds sheet bounds ({} rows x {} cols)",
374                row_count, col_count
375            ));
376        }
377        if let Some(existing) = self
378            .tables
379            .iter()
380            .find(|t| t.id != id && t.overlaps(start_row, start_col, new_end_row, new_end_col))
381        {
382            return Err(format!(
383                "Resized range would overlap existing table '{}' on sheet '{}'",
384                existing.name, self.name
385            ));
386        }
387
388        let new_columns: Vec<String> = (start_col..=new_end_col)
389            .enumerate()
390            .map(|(local_idx, col_idx)| {
391                old_columns.get(local_idx).cloned().unwrap_or_else(|| {
392                    if has_header_row {
393                        self.table_column_header(start_row, col_idx, local_idx)
394                    } else {
395                        format!("Column{}", local_idx + 1)
396                    }
397                })
398            })
399            .collect();
400        check_duplicate_column_names(&new_columns)?;
401
402        let table = self.find_table_mut(name).unwrap();
403        table.end_row = new_end_row;
404        table.end_col = new_end_col;
405        table.columns = new_columns;
406        Ok(())
407    }
408
409    /// Renames one column (0-based, relative to the table) of a table,
410    /// updating both its stored name and the header row's cell text (if
411    /// the table has one).
412    pub fn rename_table_column(
413        &mut self,
414        table_name: &str,
415        col_index: usize,
416        new_name: &str,
417    ) -> Result<(), String> {
418        let trimmed = new_name.trim();
419        if trimmed.is_empty() {
420            return Err("Column name cannot be empty".to_string());
421        }
422
423        let (sheet_col, header_row, has_header_row) = {
424            let table = self.find_table(table_name).ok_or_else(|| {
425                format!("Table '{}' not found on sheet '{}'", table_name, self.name)
426            })?;
427            if col_index >= table.columns.len() {
428                return Err(format!(
429                    "Column index {} out of bounds (table '{}' has {} columns)",
430                    col_index,
431                    table_name,
432                    table.columns.len()
433                ));
434            }
435            if table
436                .columns
437                .iter()
438                .enumerate()
439                .any(|(i, c)| i != col_index && c.eq_ignore_ascii_case(trimmed))
440            {
441                return Err(format!(
442                    "Table '{}' already has a column named '{}'",
443                    table_name, trimmed
444                ));
445            }
446            (
447                table.start_col + col_index,
448                table.start_row,
449                table.has_header_row,
450            )
451        };
452
453        if let Some(table) = self.find_table_mut(table_name) {
454            table.columns[col_index] = trimmed.to_string();
455        }
456        if has_header_row {
457            self.set_cell_src(header_row, sheet_col, trimmed.to_string());
458        }
459        Ok(())
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use crate::core::engine::SheetInit;
467
468    fn sheet_with_data() -> Sheet {
469        let mut sheet = Sheet::new(SheetInit {
470            name: Some("Sheet1".to_string()),
471            rows: 6,
472            cols: 3,
473            ..Default::default()
474        });
475        // Row 0: headers, rows 1-4: data, row 5: totals.
476        let header = ["Name", "Amount", "Qty"];
477        let data = [
478            ["Widget", "10", "2"],
479            ["Gadget", "20", "3"],
480            ["Gizmo", "30", "4"],
481            ["Doohickey", "40", "5"],
482        ];
483        for (c, h) in header.iter().enumerate() {
484            sheet.set_cell_src(0, c, h.to_string());
485        }
486        for (r, row) in data.iter().enumerate() {
487            for (c, v) in row.iter().enumerate() {
488                sheet.set_cell_src(r + 1, c, v.to_string());
489            }
490        }
491        sheet.set_cell_src(5, 1, "=SUM(B2:B5)".to_string());
492        sheet.commit(None).unwrap();
493        sheet
494    }
495
496    #[test]
497    fn test_add_table_reads_headers_and_bounds() {
498        let mut sheet = sheet_with_data();
499        let id = sheet
500            .add_table("Sales".to_string(), 0, 0, 5, 2, true, true)
501            .unwrap();
502        let table = sheet.find_table("Sales").unwrap();
503        assert_eq!(table.id, id);
504        assert_eq!(table.columns, vec!["Name", "Amount", "Qty"]);
505        assert_eq!(table.data_start_row(), 1);
506        assert_eq!(table.data_end_row(), 4);
507        assert_eq!(table.header_row(), Some(0));
508        assert_eq!(table.totals_row(), Some(5));
509    }
510
511    #[test]
512    fn test_add_table_no_header_row_uses_default_names() {
513        let mut sheet = sheet_with_data();
514        sheet
515            .add_table("Raw".to_string(), 1, 0, 4, 2, false, false)
516            .unwrap();
517        let table = sheet.find_table("Raw").unwrap();
518        assert_eq!(table.columns, vec!["Column1", "Column2", "Column3"]);
519        assert_eq!(table.data_start_row(), 1);
520        assert_eq!(table.data_end_row(), 4);
521        assert_eq!(table.totals_row(), None);
522    }
523
524    #[test]
525    fn test_add_table_rejects_duplicate_name() {
526        let mut sheet = sheet_with_data();
527        sheet
528            .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
529            .unwrap();
530        let err = sheet
531            .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
532            .unwrap_err();
533        assert!(err.contains("already exists"));
534    }
535
536    #[test]
537    fn test_add_table_rejects_invalid_name() {
538        let mut sheet = sheet_with_data();
539        let err = sheet
540            .add_table("1Sales".to_string(), 0, 0, 4, 2, true, false)
541            .unwrap_err();
542        assert!(err.contains("must start with"));
543
544        let err2 = sheet
545            .add_table("Sales Report".to_string(), 0, 0, 4, 2, true, false)
546            .unwrap_err();
547        assert!(err2.contains("letters, digits"));
548    }
549
550    #[test]
551    fn test_add_table_rejects_out_of_bounds_range() {
552        let mut sheet = sheet_with_data();
553        let err = sheet
554            .add_table("Sales".to_string(), 0, 0, 10, 2, true, false)
555            .unwrap_err();
556        assert!(err.contains("exceeds sheet bounds"));
557    }
558
559    #[test]
560    fn test_add_table_rejects_overlap() {
561        let mut sheet = sheet_with_data();
562        sheet
563            .add_table("Sales".to_string(), 0, 0, 4, 1, true, false)
564            .unwrap();
565        let err = sheet
566            .add_table("Other".to_string(), 0, 1, 4, 2, true, false)
567            .unwrap_err();
568        assert!(err.contains("overlaps"));
569    }
570
571    #[test]
572    fn test_delete_and_rename_table() {
573        let mut sheet = sheet_with_data();
574        sheet
575            .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
576            .unwrap();
577
578        sheet.rename_table("Sales", "Revenue").unwrap();
579        assert!(sheet.find_table("Sales").is_none());
580        assert!(sheet.find_table("Revenue").is_some());
581
582        sheet.delete_table_by_name("Revenue").unwrap();
583        assert!(sheet.find_table("Revenue").is_none());
584
585        let err = sheet.delete_table_by_name("Revenue").unwrap_err();
586        assert!(err.contains("not found"));
587    }
588
589    #[test]
590    fn test_resize_table_grows_and_shrinks() {
591        let mut sheet = sheet_with_data();
592        sheet
593            .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
594            .unwrap();
595        assert_eq!(sheet.find_table("Sales").unwrap().columns.len(), 2);
596
597        // Grow to include the Qty column and one more row.
598        sheet.resize_table("Sales", 4, 2).unwrap();
599        let table = sheet.find_table("Sales").unwrap();
600        assert_eq!(table.end_row, 4);
601        assert_eq!(table.end_col, 2);
602        assert_eq!(table.columns, vec!["Name", "Amount", "Qty"]);
603
604        // Shrink back down; existing column names are preserved by position.
605        sheet.resize_table("Sales", 3, 0).unwrap();
606        let table = sheet.find_table("Sales").unwrap();
607        assert_eq!(table.end_row, 3);
608        assert_eq!(table.end_col, 0);
609        assert_eq!(table.columns, vec!["Name"]);
610    }
611
612    #[test]
613    fn test_rename_table_column_updates_header_cell() {
614        let mut sheet = sheet_with_data();
615        sheet
616            .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
617            .unwrap();
618
619        sheet.rename_table_column("Sales", 1, "Total").unwrap();
620        assert_eq!(
621            sheet.find_table("Sales").unwrap().columns,
622            vec!["Name", "Total", "Qty"]
623        );
624        // The header row's actual cell text is kept in sync.
625        assert_eq!(sheet.columns[1].src[0], "Total");
626    }
627
628    #[test]
629    fn test_rename_table_column_rejects_duplicate() {
630        let mut sheet = sheet_with_data();
631        sheet
632            .add_table("Sales".to_string(), 0, 0, 4, 2, true, false)
633            .unwrap();
634        let err = sheet.rename_table_column("Sales", 1, "Name").unwrap_err();
635        assert!(err.contains("already has a column"));
636    }
637
638    #[test]
639    fn an_insert_row_placeholder_means_zero_data_rows() {
640        // Excel's own shape for an emptied table, measured with
641        // `fuzz/vba_table_probe.py --empty`: deleting the only data row of an
642        // `A1:C2` table leaves the extent at `A1:C2` and sets `insertRow="1"`,
643        // so the flag is the *only* thing distinguishing this from a table
644        // with one blank data row. `ListObject.DataBodyRange` is `Nothing`
645        // for the former and `$A$2:$C$2` for the latter.
646        let mut table = ExcelTable {
647            id: 1,
648            name: "Hollow".to_string(),
649            sheet_id: 1,
650            start_row: 0,
651            start_col: 0,
652            end_row: 1,
653            end_col: 2,
654            has_header_row: true,
655            has_totals_row: false,
656            columns: vec!["Region".into(), "Product".into(), "Amount".into()],
657            style_name: None,
658            has_insert_row: false,
659        };
660        // One blank data row.
661        assert_eq!(table.data_row_count(), 1);
662        assert_eq!(table.data_start_row(), 1);
663        assert_eq!(table.data_end_row(), 1);
664
665        // The same extent, sitting on its insert row: zero data rows.
666        table.has_insert_row = true;
667        assert_eq!(table.data_row_count(), 0);
668        // The end is now *below* the start, which is why `data_row_count`
669        // exists rather than callers subtracting the two.
670        assert!(table.data_end_row() < table.data_start_row());
671    }
672
673    #[test]
674    fn data_row_count_does_not_underflow_on_a_header_only_table() {
675        let table = ExcelTable {
676            id: 1,
677            name: "T".to_string(),
678            sheet_id: 1,
679            start_row: 0,
680            start_col: 0,
681            end_row: 0,
682            end_col: 1,
683            has_header_row: true,
684            has_totals_row: false,
685            columns: vec!["A".into(), "B".into()],
686            style_name: None,
687            has_insert_row: false,
688        };
689        assert_eq!(table.data_row_count(), 0);
690    }
691}