Skip to main content

appcore_filemaker/
table_spec.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: table_spec.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/09/30 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/09/30 working-tree by dnettoRaw
8//      ###########      S: 0.1.0-beta.5
9// =============================================================================
10
11//! Table specification validation, styles, and bounded dataset visitation.
12
13use super::*;
14
15impl TableSpec {
16    /// Validates bounded table structure.
17    pub fn validate(&self) -> Result<()> {
18        let fields: std::collections::BTreeSet<_> =
19            self.columns.iter().map(|column| &column.field).collect();
20        let total_fields: std::collections::BTreeSet<_> = self.total_fields.iter().collect();
21        if self.columns.is_empty()
22            || self.columns.len() > 1_024
23            || self.auto_sample_rows == 0
24            || self.max_rows == 0
25            || self.style_expression_steps == 0
26            || self.max_row_fields == 0
27            || self.max_cell_bytes == 0
28            || self.columns.len() > self.max_row_fields
29            || self.columns.iter().any(|column| column.field.is_empty())
30            || self.columns.iter().any(|column| !column.padding.is_valid())
31            || fields.len() != self.columns.len()
32            || self
33                .group_by
34                .as_ref()
35                .is_some_and(|field| !fields.contains(field))
36            || self
37                .keep_together_by
38                .as_ref()
39                .is_some_and(|field| field.is_empty())
40            || self
41                .row_anchor_field
42                .as_ref()
43                .is_some_and(|field| field.is_empty())
44            || (self.row_anchor_field.is_none()
45                && self
46                    .conditional_styles
47                    .iter()
48                    .any(|rule| rule.reserve_after.is_some()))
49            || self
50                .total_fields
51                .iter()
52                .any(|field| !fields.contains(field))
53            || total_fields.len() != self.total_fields.len()
54            || self.conditional_styles.len() > 1_024
55            || self.conditional_styles.iter().any(|rule| {
56                rule.when.is_empty()
57                    || [
58                        rule.padding,
59                        rule.padding_first_page,
60                        rule.padding_continuation,
61                    ]
62                    .into_iter()
63                    .flatten()
64                    .any(|padding| !padding.is_valid())
65                    || rule
66                        .text_offset_y
67                        .into_iter()
68                        .chain(rule.text_offset_y_first_page)
69                        .chain(rule.text_offset_y_continuation)
70                        .any(|offset| matches!(offset, Length::Percent(_) | Length::Auto))
71                    || [
72                        rule.min_height,
73                        rule.min_height_first_page,
74                        rule.min_height_continuation,
75                    ]
76                    .into_iter()
77                    .flatten()
78                    .any(|height| match height {
79                        Length::Absolute(value) => value <= crate::Unit::ZERO,
80                        Length::Logical(value) => value <= 0,
81                        Length::Percent(value) => value <= 0,
82                        Length::Auto => true,
83                    })
84                    || rule.reserve_after.is_some_and(|reserve| {
85                        !matches!(reserve, Length::Absolute(value) if value > crate::Unit::ZERO)
86                    })
87                    || rule.padding.is_some_and(|padding| !padding.is_valid())
88            })
89            || self
90                .columns
91                .iter()
92                .any(|column| matches!(column.width, ColumnWidth::Flex(0)))
93        {
94            return Err(table_error("table specification is invalid"));
95        }
96        for rule in &self.conditional_styles {
97            Expression::parse(&rule.when)?;
98            rule.style.validate()?;
99        }
100        Ok(())
101    }
102
103    /// Computes the ordered conditional style layers for one row.
104    pub fn style_for(&self, row: &DataRow) -> Result<Style> {
105        let root = DataValue::Object(row.clone());
106        let mut computed = Style::default();
107        let mut budget = ExpressionBudget::new(self.style_expression_steps)?;
108        for rule in &self.conditional_styles {
109            if Expression::parse(&rule.when)?
110                .evaluate(&root, &mut budget)?
111                .is_truthy()
112            {
113                rule.style.validate()?;
114                computed.overlay(&rule.style);
115            }
116        }
117        Ok(computed)
118    }
119
120    /// Resolves the last matching conditional per-row cell padding.
121    pub(crate) fn padding_for(&self, row: &DataRow, page_index: usize) -> Result<CellPadding> {
122        let root = DataValue::Object(row.clone());
123        let mut padding = CellPadding::default();
124        let mut budget = ExpressionBudget::new(self.style_expression_steps)?;
125        for rule in &self.conditional_styles {
126            let page_padding = if page_index == 0 {
127                rule.padding_first_page.or(rule.padding)
128            } else {
129                rule.padding_continuation.or(rule.padding)
130            };
131            if page_padding.is_some()
132                && Expression::parse(&rule.when)?
133                    .evaluate(&root, &mut budget)?
134                    .is_truthy()
135            {
136                if let Some(next) = page_padding {
137                    padding = next;
138                }
139            }
140        }
141        Ok(padding)
142    }
143
144    /// Resolves the last matching paint-only vertical text offset.
145    pub(crate) fn text_offset_y_for(
146        &self,
147        row: &DataRow,
148        logical_unit: crate::Unit,
149        page_index: usize,
150    ) -> Result<crate::Unit> {
151        let root = DataValue::Object(row.clone());
152        let mut offset = crate::Unit::ZERO;
153        let mut budget = ExpressionBudget::new(self.style_expression_steps)?;
154        for rule in &self.conditional_styles {
155            let page_offset = if page_index == 0 {
156                rule.text_offset_y_first_page
157            } else {
158                rule.text_offset_y_continuation
159            };
160            if let Some(value) = page_offset.or(rule.text_offset_y) {
161                if Expression::parse(&rule.when)?
162                    .evaluate(&root, &mut budget)?
163                    .is_truthy()
164                {
165                    offset = match value {
166                        Length::Absolute(value) => value,
167                        Length::Logical(value) => logical_unit.checked_scale(value)?,
168                        Length::Percent(_) | Length::Auto => {
169                            return Err(table_error(
170                                "table text offset must be an absolute or logical length",
171                            ));
172                        }
173                    };
174                }
175            }
176        }
177        Ok(offset)
178    }
179
180    /// Computes the largest conditional minimum height for one data row.
181    pub(crate) fn minimum_row_height_for(
182        &self,
183        row: &DataRow,
184        page_index: usize,
185        reference: crate::Unit,
186        logical_unit: crate::Unit,
187    ) -> Result<Option<crate::Unit>> {
188        let root = DataValue::Object(row.clone());
189        let mut budget = ExpressionBudget::new(self.style_expression_steps)?;
190        let mut minimum = None;
191        for rule in &self.conditional_styles {
192            let page_minimum = if page_index == 0 {
193                rule.min_height_first_page
194            } else {
195                rule.min_height_continuation
196            };
197            if (rule.min_height.is_some() || page_minimum.is_some())
198                && Expression::parse(&rule.when)?
199                    .evaluate(&root, &mut budget)?
200                    .is_truthy()
201            {
202                for height in [rule.min_height, page_minimum].into_iter().flatten() {
203                    let Some(candidate) = height.resolve(reference, logical_unit)? else {
204                        return Err(table_error("conditional minimum row height cannot be auto"));
205                    };
206                    minimum = Some(
207                        minimum.map_or(candidate, |current: crate::Unit| current.max(candidate)),
208                    );
209                }
210            }
211        }
212        Ok(minimum)
213    }
214
215    /// Resolves page capacity reserved after a row that publishes a named anchor.
216    pub(crate) fn reserve_after_for(&self, row: &DataRow) -> Result<crate::Unit> {
217        let Some(field) = self.row_anchor_field.as_deref() else {
218            return Ok(crate::Unit::ZERO);
219        };
220        if !matches!(row.get(field), Some(DataValue::String(name)) if !name.is_empty()) {
221            return Ok(crate::Unit::ZERO);
222        }
223        let root = DataValue::Object(row.clone());
224        let mut budget = ExpressionBudget::new(self.style_expression_steps)?;
225        self.conditional_styles
226            .iter()
227            .filter_map(|rule| rule.reserve_after.map(|reserve| (&rule.when, reserve)))
228            .try_fold(crate::Unit::ZERO, |largest, (when, reserve)| {
229                if Expression::parse(when)?
230                    .evaluate(&root, &mut budget)?
231                    .is_truthy()
232                {
233                    let Length::Absolute(candidate) = reserve else {
234                        return Err(table_error(
235                            "row anchor reserve must be an absolute positive length",
236                        ));
237                    };
238                    Ok(largest.max(candidate))
239                } else {
240                    Ok(largest)
241                }
242            })
243    }
244
245    /// Streams rows with an enforced hard maximum.
246    pub fn visit_bounded(
247        &self,
248        dataset: &dyn Dataset,
249        visitor: &mut dyn FnMut(u64, &DataRow) -> Result<()>,
250    ) -> Result<()> {
251        self.visit_bounded_until(dataset, &mut |index, row| {
252            visitor(index, row)?;
253            Ok(true)
254        })
255    }
256
257    /// Streams rows until a bounded visitor asks to stop.
258    pub fn visit_bounded_until(
259        &self,
260        dataset: &dyn Dataset,
261        visitor: &mut dyn FnMut(u64, &DataRow) -> Result<bool>,
262    ) -> Result<()> {
263        self.validate()?;
264        let mut row_anchors = std::collections::BTreeSet::new();
265        dataset.visit_rows_until(&mut |index, row| {
266            if index >= self.max_rows {
267                return Err(FileMakerError::new(
268                    ErrorCode::LimitExceeded,
269                    "dataset row limit exceeded",
270                ));
271            }
272            if row.len() > self.max_row_fields
273                || row.iter().any(|(field, value)| {
274                    field.len() > self.max_cell_bytes
275                        || value.display().len() > self.max_cell_bytes
276                        || matches!(value, DataValue::Array(_) | DataValue::Object(_))
277                })
278            {
279                return Err(FileMakerError::new(
280                    ErrorCode::LimitExceeded,
281                    "dataset row exceeds its field, cell, or scalar-value limit",
282                ));
283            }
284            if let Some(field) = &self.row_anchor_field {
285                if let Some(value) = row.get(field) {
286                    match value {
287                        DataValue::Null => {}
288                        DataValue::String(name)
289                            if !name.is_empty()
290                                && name.len() <= self.max_cell_bytes
291                                && !name.contains('.')
292                                && !name.contains('+')
293                                && row_anchors.insert(name.clone()) => {}
294                        _ => {
295                            return Err(table_error(
296                                "row anchor names must be unique bounded strings without anchor delimiters",
297                            ));
298                        }
299                    }
300                }
301            }
302            visitor(index, row)
303        })
304    }
305}