Skip to main content

mcd_core/
table_view.rs

1//! Table and chart view parsing and validation.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    directives::TableDisplay,
7    errors::{Diagnostic, McdError, Result},
8    package::McdPackage,
9    schema::{ColumnType, TableSchema},
10};
11
12/// Parsed table view JSON.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct TableView {
16    /// Stable view id.
17    pub id: String,
18    /// Referenced table id.
19    pub table: String,
20    /// View display type.
21    #[serde(default)]
22    pub display: TableDisplay,
23    /// Columns included in a table display.
24    #[serde(default)]
25    pub columns: Vec<ViewColumn>,
26    /// Chart specification for chart displays.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub chart: Option<ChartSpec>,
29    /// Optional style metadata.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub style: Option<serde_json::Value>,
32}
33
34impl TableView {
35    /// Parse a table view from a package entry.
36    pub fn from_package(package: &McdPackage, path: &str) -> Result<Self> {
37        let bytes = package.read(path).map_err(|_| {
38            McdError::from_diagnostic(
39                Diagnostic::error(
40                    "view.file.missing",
41                    format!("Declared table view file '{path}' is missing."),
42                )
43                .with_source(path.to_owned()),
44            )
45        })?;
46        serde_json::from_slice::<Self>(bytes).map_err(McdError::from)
47    }
48
49    /// Validate this view against its table schema.
50    pub fn validate(
51        &self,
52        expected_id: &str,
53        table_id: &str,
54        schema: &TableSchema,
55        source: &str,
56    ) -> Result<()> {
57        if self.id != expected_id {
58            return Err(view_error(
59                "view.id.mismatch",
60                format!(
61                    "View id '{}' does not match manifest view id '{}'.",
62                    self.id, expected_id
63                ),
64                source,
65            ));
66        }
67        if self.table != table_id {
68            return Err(view_error(
69                "view.table.mismatch",
70                format!(
71                    "View '{}' references table '{}', but manifest attaches it to '{}'.",
72                    self.id, self.table, table_id
73                ),
74                source,
75            ));
76        }
77
78        match self.display {
79            TableDisplay::Table => self.validate_table_columns(schema, source),
80            TableDisplay::Chart => self.validate_chart(schema, source),
81        }
82    }
83
84    fn validate_table_columns(&self, schema: &TableSchema, source: &str) -> Result<()> {
85        for column in &self.columns {
86            if !schema.has_column(&column.name) {
87                return Err(view_error(
88                    "view.column.unknown",
89                    format!(
90                        "View '{}' references unknown schema column '{}'.",
91                        self.id, column.name
92                    ),
93                    source,
94                ));
95            }
96            validate_format_declarations(
97                column.format.as_deref(),
98                column.currency.as_deref(),
99                column.unit_label.as_deref(),
100                column.percent,
101                schema
102                    .column(&column.name)
103                    .map(|schema_column| schema_column.value_type),
104                source,
105            )?;
106        }
107        Ok(())
108    }
109
110    fn validate_chart(&self, schema: &TableSchema, source: &str) -> Result<()> {
111        let Some(chart) = &self.chart else {
112            return Err(view_error(
113                "chart.spec.missing",
114                format!("Chart view '{}' must include a chart object.", self.id),
115                source,
116            ));
117        };
118        chart.validate(schema, source)?;
119        Ok(())
120    }
121}
122
123/// One table view column.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct ViewColumn {
127    /// Referenced schema column name.
128    pub name: String,
129    /// Optional display label.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub label: Option<String>,
132    /// Optional format declaration.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub format: Option<String>,
135    /// Currency code for currency-formatted numeric values.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub currency: Option<String>,
138    /// Free-form display unit label for numeric values.
139    #[serde(
140        default,
141        rename = "unitLabel",
142        alias = "unit",
143        skip_serializing_if = "Option::is_none"
144    )]
145    pub unit_label: Option<String>,
146    /// Whether the value is a percentage.
147    #[serde(default)]
148    pub percent: bool,
149}
150
151/// Constrained chart specification.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct ChartSpec {
155    /// Chart type in the alpha subset.
156    #[serde(rename = "type")]
157    pub chart_type: ChartType,
158    /// X-axis encoding.
159    pub x: ChartEncoding,
160    /// Y-axis encoding.
161    pub y: ChartEncoding,
162    /// Optional series encoding.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub series: Option<ChartEncoding>,
165    /// Optional grouping encoding.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub grouping: Option<ChartEncoding>,
168    /// Optional mark label declaration.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub mark_labels: Option<MarkLabels>,
171}
172
173impl ChartSpec {
174    fn validate(&self, schema: &TableSchema, source: &str) -> Result<()> {
175        self.x.validate_known_column(schema, source)?;
176        self.y.validate_known_column(schema, source)?;
177        if let Some(series) = &self.series {
178            series.validate_known_column(schema, source)?;
179        }
180        if let Some(grouping) = &self.grouping {
181            grouping.validate_known_column(schema, source)?;
182        }
183        if let Some(mark_labels) = &self.mark_labels {
184            mark_labels.validate(schema, source)?;
185        }
186
187        let y_type = column_type(schema, &self.y.column, source)?;
188        if !y_type.is_numeric() {
189            return Err(view_error(
190                "chart.column.type.incompatible",
191                format!(
192                    "Chart y column '{}' must be integer or decimal.",
193                    self.y.column
194                ),
195                source,
196            ));
197        }
198        self.y.validate_format(schema, source)?;
199        self.x.validate_format(schema, source)?;
200
201        if self.chart_type == ChartType::Scatter {
202            let x_type = column_type(schema, &self.x.column, source)?;
203            if !x_type.is_numeric() && !x_type.is_temporal() {
204                return Err(view_error(
205                    "chart.column.type.incompatible",
206                    format!(
207                        "Scatter chart x column '{}' must be numeric or temporal.",
208                        self.x.column
209                    ),
210                    source,
211                ));
212            }
213        }
214
215        Ok(())
216    }
217}
218
219/// Supported alpha chart types.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "lowercase")]
222pub enum ChartType {
223    /// Bar chart.
224    Bar,
225    /// Line chart.
226    Line,
227    /// Area chart.
228    Area,
229    /// Scatter chart.
230    Scatter,
231}
232
233/// A chart encoding.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct ChartEncoding {
237    /// Referenced schema column.
238    pub column: String,
239    /// Optional display label.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub label: Option<String>,
242    /// Optional value format.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub format: Option<String>,
245    /// Currency code for currency-formatted numeric values.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub currency: Option<String>,
248    /// Free-form display unit label.
249    #[serde(
250        default,
251        rename = "unitLabel",
252        alias = "unit",
253        skip_serializing_if = "Option::is_none"
254    )]
255    pub unit_label: Option<String>,
256    /// Whether the value is a percentage.
257    #[serde(default)]
258    pub percent: bool,
259}
260
261impl ChartEncoding {
262    fn validate_known_column(&self, schema: &TableSchema, source: &str) -> Result<()> {
263        if schema.has_column(&self.column) {
264            Ok(())
265        } else {
266            Err(view_error(
267                "chart.column.unknown",
268                format!("Chart references unknown schema column '{}'.", self.column),
269                source,
270            ))
271        }
272    }
273
274    fn validate_format(&self, schema: &TableSchema, source: &str) -> Result<()> {
275        validate_format_declarations(
276            self.format.as_deref(),
277            self.currency.as_deref(),
278            self.unit_label.as_deref(),
279            self.percent,
280            schema.column(&self.column).map(|column| column.value_type),
281            source,
282        )
283    }
284}
285
286/// Mark label display and formatting declaration.
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(rename_all = "camelCase")]
289pub struct MarkLabels {
290    /// Whether labels are shown.
291    #[serde(default)]
292    pub show: bool,
293    /// Optional label source column. Defaults to the chart y column when absent.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub column: Option<String>,
296    /// Optional value format.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub format: Option<String>,
299    /// Currency code for currency-formatted numeric values.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub currency: Option<String>,
302    /// Free-form display unit label.
303    #[serde(
304        default,
305        rename = "unitLabel",
306        alias = "unit",
307        skip_serializing_if = "Option::is_none"
308    )]
309    pub unit_label: Option<String>,
310    /// Whether the value is a percentage.
311    #[serde(default)]
312    pub percent: bool,
313}
314
315impl MarkLabels {
316    fn validate(&self, schema: &TableSchema, source: &str) -> Result<()> {
317        if let Some(column) = &self.column {
318            if !schema.has_column(column) {
319                return Err(view_error(
320                    "chart.column.unknown",
321                    format!("Chart mark labels reference unknown schema column '{column}'."),
322                    source,
323                ));
324            }
325            validate_format_declarations(
326                self.format.as_deref(),
327                self.currency.as_deref(),
328                self.unit_label.as_deref(),
329                self.percent,
330                schema
331                    .column(column)
332                    .map(|schema_column| schema_column.value_type),
333                source,
334            )?;
335        }
336        Ok(())
337    }
338}
339
340fn validate_format_declarations(
341    format: Option<&str>,
342    currency: Option<&str>,
343    _unit: Option<&str>,
344    percent: bool,
345    column_type: Option<ColumnType>,
346    source: &str,
347) -> Result<()> {
348    if currency.is_some() && format != Some("currency") {
349        return Err(view_error(
350            "view.format.currency.inconsistent",
351            "Currency declarations require format: currency.",
352            source,
353        ));
354    }
355    if percent && format.is_some_and(|format| format != "percent") {
356        return Err(view_error(
357            "view.format.percent.inconsistent",
358            "Percent declarations must use format: percent when a format is declared.",
359            source,
360        ));
361    }
362
363    if matches!(format, Some("currency" | "number" | "percent")) {
364        let Some(column_type) = column_type else {
365            return Ok(());
366        };
367        if !column_type.is_numeric() {
368            return Err(view_error(
369                "view.format.type.incompatible",
370                "Numeric, currency, and percent formats require integer or decimal columns.",
371                source,
372            ));
373        }
374    }
375
376    if matches!(format, Some("date" | "datetime" | "time")) {
377        let Some(column_type) = column_type else {
378            return Ok(());
379        };
380        if !column_type.is_temporal() {
381            return Err(view_error(
382                "view.format.type.incompatible",
383                "Date, datetime, and time formats require temporal columns.",
384                source,
385            ));
386        }
387    }
388
389    Ok(())
390}
391
392fn column_type(schema: &TableSchema, column: &str, source: &str) -> Result<ColumnType> {
393    schema
394        .column(column)
395        .map(|column| column.value_type)
396        .ok_or_else(|| {
397            view_error(
398                "chart.column.unknown",
399                format!("Chart references unknown schema column '{column}'."),
400                source,
401            )
402        })
403}
404
405fn view_error(code: impl Into<String>, message: impl Into<String>, source: &str) -> McdError {
406    McdError::from_diagnostic(Diagnostic::error(code, message).with_source(source.to_owned()))
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::schema::{ColumnType, TableColumnSchema};
413
414    fn schema() -> TableSchema {
415        TableSchema {
416            id: "revenue".to_owned(),
417            primary_key: Vec::new(),
418            foreign_keys: Vec::new(),
419            columns: vec![
420                TableColumnSchema {
421                    name: "quarter".to_owned(),
422                    value_type: ColumnType::String,
423                    label: None,
424                    unit: None,
425                    nullable: false,
426                    enum_values: Vec::new(),
427                },
428                TableColumnSchema {
429                    name: "amount".to_owned(),
430                    value_type: ColumnType::Decimal,
431                    label: None,
432                    unit: None,
433                    nullable: false,
434                    enum_values: Vec::new(),
435                },
436            ],
437        }
438    }
439
440    #[test]
441    fn validates_chart_columns() {
442        let view = serde_json::from_str::<TableView>(
443            r#"{
444                "id":"chart",
445                "table":"revenue",
446                "display":"chart",
447                "chart":{
448                    "type":"bar",
449                    "x":{"column":"quarter"},
450                    "y":{"column":"amount","format":"currency","currency":"GBP"}
451                }
452            }"#,
453        )
454        .expect("view parses");
455
456        view.validate("chart", "revenue", &schema(), "tables/chart.view.json")
457            .expect("valid chart view");
458    }
459
460    #[test]
461    fn accepts_unit_label_and_legacy_unit_display_fields() {
462        let unit_label = serde_json::from_str::<TableView>(
463            r#"{
464                "id":"default",
465                "table":"revenue",
466                "columns":[{"name":"amount","unitLabel":"kg"}]
467            }"#,
468        )
469        .expect("view parses");
470        assert_eq!(unit_label.columns[0].unit_label.as_deref(), Some("kg"));
471
472        let legacy_unit = serde_json::from_str::<TableView>(
473            r#"{
474                "id":"default",
475                "table":"revenue",
476                "columns":[{"name":"amount","unit":"kg"}]
477            }"#,
478        )
479        .expect("view parses");
480        assert_eq!(legacy_unit.columns[0].unit_label.as_deref(), Some("kg"));
481    }
482
483    #[test]
484    fn rejects_unknown_view_column() {
485        let view = serde_json::from_str::<TableView>(
486            r#"{"id":"default","table":"revenue","columns":[{"name":"missing"}]}"#,
487        )
488        .expect("view parses");
489        let err = view
490            .validate("default", "revenue", &schema(), "tables/view.json")
491            .expect_err("invalid");
492
493        assert_eq!(
494            err.diagnostic().map(|d| d.code.as_str()),
495            Some("view.column.unknown")
496        );
497    }
498}