Skip to main content

appcore_filemaker/
table.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: table.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Defines bounded table contracts and behavior for this crate.
12
13use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17use crate::{
18    DataValue, ErrorCode, Expression, ExpressionBudget, FileMakerError, Length, Result, Style,
19};
20
21/// One deterministically ordered tabular row.
22pub type DataRow = BTreeMap<String, DataValue>;
23
24/// Restartable bounded dataset contract.
25pub trait Dataset: Send + Sync {
26    /// Optional exact row count.
27    fn row_count_hint(&self) -> Option<u64>;
28    /// Visits rows in stable order until the visitor returns `false`.
29    fn visit_rows_until(
30        &self,
31        visitor: &mut dyn FnMut(u64, &DataRow) -> Result<bool>,
32    ) -> Result<()>;
33
34    /// Visits every row without requiring full materialization.
35    fn visit_rows(&self, visitor: &mut dyn FnMut(u64, &DataRow) -> Result<()>) -> Result<()> {
36        self.visit_rows_until(&mut |index, row| {
37            visitor(index, row)?;
38            Ok(true)
39        })
40    }
41}
42
43/// In-memory dataset for small bounded inputs.
44#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
45pub struct InMemoryDataset {
46    /// Rows in source order.
47    pub rows: Vec<DataRow>,
48}
49
50/// Borrowed dataset avoiding a duplicate row allocation for existing slices.
51pub struct BorrowedDataset<'a> {
52    rows: &'a [DataRow],
53}
54
55impl<'a> BorrowedDataset<'a> {
56    /// Borrows rows in their existing stable source order.
57    #[must_use]
58    pub const fn new(rows: &'a [DataRow]) -> Self {
59        Self { rows }
60    }
61}
62
63impl Dataset for BorrowedDataset<'_> {
64    fn row_count_hint(&self) -> Option<u64> {
65        u64::try_from(self.rows.len()).ok()
66    }
67
68    fn visit_rows_until(
69        &self,
70        visitor: &mut dyn FnMut(u64, &DataRow) -> Result<bool>,
71    ) -> Result<()> {
72        visit_slice(self.rows, visitor)
73    }
74}
75
76impl Dataset for InMemoryDataset {
77    fn row_count_hint(&self) -> Option<u64> {
78        u64::try_from(self.rows.len()).ok()
79    }
80
81    fn visit_rows_until(
82        &self,
83        visitor: &mut dyn FnMut(u64, &DataRow) -> Result<bool>,
84    ) -> Result<()> {
85        visit_slice(&self.rows, visitor)
86    }
87}
88
89fn visit_slice(
90    rows: &[DataRow],
91    visitor: &mut dyn FnMut(u64, &DataRow) -> Result<bool>,
92) -> Result<()> {
93    for (index, row) in rows.iter().enumerate() {
94        let index = u64::try_from(index).map_err(|_| table_error("row index overflow"))?;
95        if !visitor(index, row)? {
96            break;
97        }
98    }
99    Ok(())
100}
101
102/// Factory-backed dataset enabling restartable streaming.
103pub struct StreamingDataset<F> {
104    factory: F,
105    row_count_hint: Option<u64>,
106}
107
108impl<F> StreamingDataset<F> {
109    /// Creates a dataset from a factory returning a fresh iterator per visit.
110    #[must_use]
111    pub const fn new(factory: F, row_count_hint: Option<u64>) -> Self {
112        Self {
113            factory,
114            row_count_hint,
115        }
116    }
117}
118
119impl<F, I> Dataset for StreamingDataset<F>
120where
121    F: Fn() -> I + Send + Sync,
122    I: Iterator<Item = Result<DataRow>>,
123{
124    fn row_count_hint(&self) -> Option<u64> {
125        self.row_count_hint
126    }
127
128    fn visit_rows_until(
129        &self,
130        visitor: &mut dyn FnMut(u64, &DataRow) -> Result<bool>,
131    ) -> Result<()> {
132        for (index, row) in (self.factory)().enumerate() {
133            let row = row?;
134            if !visitor(
135                u64::try_from(index).map_err(|_| table_error("row index overflow"))?,
136                &row,
137            )? {
138                break;
139            }
140        }
141        Ok(())
142    }
143}
144
145/// Table column sizing strategy.
146#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
147#[serde(tag = "mode", content = "value", rename_all = "snake_case")]
148pub enum ColumnWidth {
149    /// Exact width.
150    Fixed(Length),
151    /// Share of remaining width.
152    Flex(u32),
153    /// Width measured from bounded row samples.
154    Auto,
155}
156
157/// One first-class table column.
158#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
159pub struct TableColumn {
160    /// Stable field name.
161    pub field: String,
162    /// Header text.
163    pub header: String,
164    /// Sizing strategy.
165    pub width: ColumnWidth,
166}
167
168/// Conditional data-row style evaluated without IO or exporter state.
169#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct TableStyleRule {
172    /// Deterministic expression evaluated against the row object.
173    pub when: String,
174    /// Partial style applied when the expression is truthy.
175    pub style: Style,
176}
177
178/// Pagination and grouping contract for a table.
179#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
180pub struct TableSpec {
181    /// Columns in visual order.
182    pub columns: Vec<TableColumn>,
183    /// Repeat header after pagination.
184    pub repeat_header: bool,
185    /// Optional grouping field.
186    pub group_by: Option<String>,
187    /// Fields totaled as exact numeric values.
188    pub total_fields: Vec<String>,
189    /// Conditional row styles in stable declaration order.
190    pub conditional_styles: Vec<TableStyleRule>,
191    /// Per-row expression step budget shared by conditional rules.
192    pub style_expression_steps: usize,
193    /// Maximum rows sampled for automatic sizing.
194    pub auto_sample_rows: usize,
195    /// Maximum rows accepted from the dataset.
196    pub max_rows: u64,
197    /// Maximum fields accepted in one streamed row.
198    pub max_row_fields: usize,
199    /// Maximum displayed bytes accepted in one cell.
200    pub max_cell_bytes: usize,
201}
202
203/// One bounded table page delivered to a sink.
204#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
205pub struct TablePage {
206    /// Zero-based table page index.
207    pub index: usize,
208    /// Whether the header must be rendered on this page.
209    pub header: bool,
210    /// Rows bounded by the computed per-page capacity.
211    pub rows: Vec<DataRow>,
212    /// Measured height corresponding one-to-one with `rows`.
213    pub row_heights: Vec<crate::Unit>,
214    /// Computed conditional style corresponding one-to-one with `rows`.
215    pub row_styles: Vec<Style>,
216    /// Group key when each row begins a new group.
217    pub group_starts: Vec<Option<String>>,
218    /// Group key active at the first row, when configured.
219    pub starting_group: Option<String>,
220    /// Exact totals emitted only on the final table page.
221    pub totals: BTreeMap<String, DataValue>,
222}
223
224/// Streaming page consumer.
225pub trait TablePageSink {
226    /// Accepts one complete bounded page.
227    fn page(&mut self, page: TablePage) -> Result<()>;
228}
229
230/// Fixed-point bounded table pagination engine.
231pub struct TablePaginator {
232    /// Height available on each continuation page.
233    pub available_height: crate::Unit,
234    /// Header height.
235    pub header_height: crate::Unit,
236    /// Fixed row height after external text measurement.
237    pub row_height: crate::Unit,
238    /// Maximum generated pages.
239    pub max_pages: usize,
240}
241
242impl TablePaginator {
243    /// Streams paginated rows without retaining the entire dataset.
244    pub fn paginate(
245        &self,
246        spec: &TableSpec,
247        dataset: &dyn Dataset,
248        sink: &mut dyn TablePageSink,
249    ) -> Result<()> {
250        if self.row_height <= crate::Unit::ZERO {
251            return Err(table_error("fixed table row height must be positive"));
252        }
253        self.paginate_measured(spec, dataset, &mut |_| Ok(self.row_height), sink)
254    }
255
256    /// Streams rows using a deterministic externally measured height per row.
257    pub fn paginate_measured(
258        &self,
259        spec: &TableSpec,
260        dataset: &dyn Dataset,
261        measure: &mut dyn FnMut(&DataRow) -> Result<crate::Unit>,
262        sink: &mut dyn TablePageSink,
263    ) -> Result<()> {
264        spec.validate()?;
265        if self.available_height <= crate::Unit::ZERO
266            || self.header_height < crate::Unit::ZERO
267            || self.max_pages == 0
268        {
269            return Err(table_error("table pagination dimensions are invalid"));
270        }
271        let mut state = PaginationState::default();
272        spec.visit_bounded(dataset, &mut |_, row| {
273            let height = measure(row)?;
274            let mut content_height = self.content_height(spec, state.page_index)?;
275            if height <= crate::Unit::ZERO || height > content_height {
276                return Err(table_error("measured table row does not fit on a page"));
277            }
278            if !state.rows.is_empty() && state.used_height.checked_add(height)? > content_height {
279                state.flush(spec, sink, self.max_pages, false)?;
280                content_height = self.content_height(spec, state.page_index)?;
281                if height > content_height {
282                    return Err(table_error("measured table row does not fit on a page"));
283                }
284            }
285            state.push(spec, row, height)
286        })?;
287        if !state.rows.is_empty() {
288            state.flush(spec, sink, self.max_pages, true)?;
289        }
290        Ok(())
291    }
292
293    fn content_height(&self, spec: &TableSpec, page_index: usize) -> Result<crate::Unit> {
294        if page_index == 0 || spec.repeat_header {
295            self.available_height.checked_sub(self.header_height)
296        } else {
297            Ok(self.available_height)
298        }
299    }
300}
301
302#[derive(Default)]
303struct PaginationState {
304    page_index: usize,
305    rows: Vec<DataRow>,
306    row_heights: Vec<crate::Unit>,
307    row_styles: Vec<Style>,
308    group_starts: Vec<Option<String>>,
309    starting_group: Option<String>,
310    last_group: Option<String>,
311    used_height: crate::Unit,
312    totals: BTreeMap<String, DataValue>,
313}
314
315impl PaginationState {
316    fn push(&mut self, spec: &TableSpec, row: &DataRow, height: crate::Unit) -> Result<()> {
317        let group = spec
318            .group_by
319            .as_ref()
320            .and_then(|field| row.get(field))
321            .map(DataValue::display);
322        if self.rows.is_empty() {
323            self.starting_group.clone_from(&group);
324        }
325        self.group_starts
326            .push((group != self.last_group).then(|| group.clone()).flatten());
327        self.last_group = group;
328        accumulate_totals(&mut self.totals, &spec.total_fields, row)?;
329        self.row_styles.push(spec.style_for(row)?);
330        self.row_heights.push(height);
331        self.rows.push(row.clone());
332        self.used_height = self.used_height.checked_add(height)?;
333        Ok(())
334    }
335
336    fn flush(
337        &mut self,
338        spec: &TableSpec,
339        sink: &mut dyn TablePageSink,
340        max_pages: usize,
341        final_page: bool,
342    ) -> Result<()> {
343        if self.page_index >= max_pages {
344            return Err(FileMakerError::new(
345                ErrorCode::LimitExceeded,
346                "table page limit exceeded",
347            ));
348        }
349        sink.page(TablePage {
350            index: self.page_index,
351            header: self.page_index == 0 || spec.repeat_header,
352            rows: std::mem::take(&mut self.rows),
353            row_heights: std::mem::take(&mut self.row_heights),
354            row_styles: std::mem::take(&mut self.row_styles),
355            group_starts: std::mem::take(&mut self.group_starts),
356            starting_group: self.starting_group.take(),
357            totals: if final_page {
358                std::mem::take(&mut self.totals)
359            } else {
360                BTreeMap::new()
361            },
362        })?;
363        self.page_index += 1;
364        self.used_height = crate::Unit::ZERO;
365        Ok(())
366    }
367}
368
369fn accumulate_totals(
370    totals: &mut BTreeMap<String, DataValue>,
371    fields: &[String],
372    row: &DataRow,
373) -> Result<()> {
374    for field in fields {
375        let Some(value) = row.get(field) else {
376            continue;
377        };
378        if matches!(value, DataValue::Null) {
379            continue;
380        }
381        let next = add_total(totals.get(field), value)?;
382        totals.insert(field.clone(), next);
383    }
384    Ok(())
385}
386
387fn add_total(current: Option<&DataValue>, value: &DataValue) -> Result<DataValue> {
388    match (current, value) {
389        (None, DataValue::Integer(value)) => Ok(DataValue::Integer(*value)),
390        (None, DataValue::Decimal(value)) => Ok(DataValue::Decimal(*value)),
391        (None, DataValue::Currency(value)) => Ok(DataValue::Currency(value.clone())),
392        (Some(DataValue::Integer(left)), DataValue::Integer(right)) => left
393            .checked_add(*right)
394            .map(DataValue::Integer)
395            .ok_or_else(|| table_error("integer table total overflow")),
396        (Some(DataValue::Decimal(left)), DataValue::Decimal(right)) => left
397            .checked_add(*right)
398            .map(DataValue::Decimal)
399            .ok_or_else(|| table_error("decimal table total overflow")),
400        (Some(DataValue::Integer(left)), DataValue::Decimal(right)) => {
401            rust_decimal::Decimal::from(*left)
402                .checked_add(*right)
403                .map(DataValue::Decimal)
404                .ok_or_else(|| table_error("decimal table total overflow"))
405        }
406        (Some(DataValue::Decimal(left)), DataValue::Integer(right)) => left
407            .checked_add(rust_decimal::Decimal::from(*right))
408            .map(DataValue::Decimal)
409            .ok_or_else(|| table_error("decimal table total overflow")),
410        (Some(DataValue::Currency(left)), DataValue::Currency(right))
411            if left.code == right.code =>
412        {
413            left.amount
414                .checked_add(right.amount)
415                .map(|amount| {
416                    DataValue::Currency(crate::CurrencyValue {
417                        code: left.code.clone(),
418                        amount,
419                    })
420                })
421                .ok_or_else(|| table_error("currency table total overflow"))
422        }
423        _ => Err(table_error(
424            "table total field contains incompatible or non-numeric values",
425        )),
426    }
427}
428
429impl TableSpec {
430    /// Validates bounded table structure.
431    pub fn validate(&self) -> Result<()> {
432        let fields: std::collections::BTreeSet<_> =
433            self.columns.iter().map(|column| &column.field).collect();
434        let total_fields: std::collections::BTreeSet<_> = self.total_fields.iter().collect();
435        if self.columns.is_empty()
436            || self.columns.len() > 1_024
437            || self.auto_sample_rows == 0
438            || self.max_rows == 0
439            || self.style_expression_steps == 0
440            || self.max_row_fields == 0
441            || self.max_cell_bytes == 0
442            || self.columns.len() > self.max_row_fields
443            || self.columns.iter().any(|column| column.field.is_empty())
444            || fields.len() != self.columns.len()
445            || self
446                .group_by
447                .as_ref()
448                .is_some_and(|field| !fields.contains(field))
449            || self
450                .total_fields
451                .iter()
452                .any(|field| !fields.contains(field))
453            || total_fields.len() != self.total_fields.len()
454            || self.conditional_styles.len() > 1_024
455            || self
456                .conditional_styles
457                .iter()
458                .any(|rule| rule.when.is_empty())
459            || self
460                .columns
461                .iter()
462                .any(|column| matches!(column.width, ColumnWidth::Flex(0)))
463        {
464            return Err(table_error("table specification is invalid"));
465        }
466        for rule in &self.conditional_styles {
467            Expression::parse(&rule.when)?;
468            rule.style.validate()?;
469        }
470        Ok(())
471    }
472
473    /// Computes the ordered conditional style layers for one row.
474    pub fn style_for(&self, row: &DataRow) -> Result<Style> {
475        let root = DataValue::Object(row.clone());
476        let mut computed = Style::default();
477        let mut budget = ExpressionBudget::new(self.style_expression_steps)?;
478        for rule in &self.conditional_styles {
479            if Expression::parse(&rule.when)?
480                .evaluate(&root, &mut budget)?
481                .is_truthy()
482            {
483                rule.style.validate()?;
484                computed.overlay(&rule.style);
485            }
486        }
487        Ok(computed)
488    }
489
490    /// Streams rows with an enforced hard maximum.
491    pub fn visit_bounded(
492        &self,
493        dataset: &dyn Dataset,
494        visitor: &mut dyn FnMut(u64, &DataRow) -> Result<()>,
495    ) -> Result<()> {
496        self.visit_bounded_until(dataset, &mut |index, row| {
497            visitor(index, row)?;
498            Ok(true)
499        })
500    }
501
502    /// Streams rows until a bounded visitor asks to stop.
503    pub fn visit_bounded_until(
504        &self,
505        dataset: &dyn Dataset,
506        visitor: &mut dyn FnMut(u64, &DataRow) -> Result<bool>,
507    ) -> Result<()> {
508        self.validate()?;
509        dataset.visit_rows_until(&mut |index, row| {
510            if index >= self.max_rows {
511                return Err(FileMakerError::new(
512                    ErrorCode::LimitExceeded,
513                    "dataset row limit exceeded",
514                ));
515            }
516            if row.len() > self.max_row_fields
517                || row.iter().any(|(field, value)| {
518                    field.len() > self.max_cell_bytes
519                        || value.display().len() > self.max_cell_bytes
520                        || matches!(value, DataValue::Array(_) | DataValue::Object(_))
521                })
522            {
523                return Err(FileMakerError::new(
524                    ErrorCode::LimitExceeded,
525                    "dataset row exceeds its field, cell, or scalar-value limit",
526                ));
527            }
528            visitor(index, row)
529        })
530    }
531}
532
533fn table_error(message: impl Into<String>) -> FileMakerError {
534    FileMakerError::new(ErrorCode::DataType, message)
535}