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.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    /// Unit label for numeric values.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub unit: Option<String>,
141    /// Whether the value is a percentage.
142    #[serde(default)]
143    pub percent: bool,
144}
145
146/// Constrained chart specification.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct ChartSpec {
150    /// Chart type in the alpha subset.
151    #[serde(rename = "type")]
152    pub chart_type: ChartType,
153    /// X-axis encoding.
154    pub x: ChartEncoding,
155    /// Y-axis encoding.
156    pub y: ChartEncoding,
157    /// Optional series encoding.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub series: Option<ChartEncoding>,
160    /// Optional grouping encoding.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub grouping: Option<ChartEncoding>,
163    /// Optional mark label declaration.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub mark_labels: Option<MarkLabels>,
166}
167
168impl ChartSpec {
169    fn validate(&self, schema: &TableSchema, source: &str) -> Result<()> {
170        self.x.validate_known_column(schema, source)?;
171        self.y.validate_known_column(schema, source)?;
172        if let Some(series) = &self.series {
173            series.validate_known_column(schema, source)?;
174        }
175        if let Some(grouping) = &self.grouping {
176            grouping.validate_known_column(schema, source)?;
177        }
178        if let Some(mark_labels) = &self.mark_labels {
179            mark_labels.validate(schema, source)?;
180        }
181
182        let y_type = column_type(schema, &self.y.column, source)?;
183        if !y_type.is_numeric() {
184            return Err(view_error(
185                "chart.column.type.incompatible",
186                format!(
187                    "Chart y column '{}' must be integer or decimal.",
188                    self.y.column
189                ),
190                source,
191            ));
192        }
193        self.y.validate_format(schema, source)?;
194        self.x.validate_format(schema, source)?;
195
196        if self.chart_type == ChartType::Scatter {
197            let x_type = column_type(schema, &self.x.column, source)?;
198            if !x_type.is_numeric() && !x_type.is_temporal() {
199                return Err(view_error(
200                    "chart.column.type.incompatible",
201                    format!(
202                        "Scatter chart x column '{}' must be numeric or temporal.",
203                        self.x.column
204                    ),
205                    source,
206                ));
207            }
208        }
209
210        Ok(())
211    }
212}
213
214/// Supported alpha chart types.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "lowercase")]
217pub enum ChartType {
218    /// Bar chart.
219    Bar,
220    /// Line chart.
221    Line,
222    /// Area chart.
223    Area,
224    /// Scatter chart.
225    Scatter,
226}
227
228/// A chart encoding.
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase")]
231pub struct ChartEncoding {
232    /// Referenced schema column.
233    pub column: String,
234    /// Optional display label.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub label: Option<String>,
237    /// Optional value format.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub format: Option<String>,
240    /// Currency code for currency-formatted numeric values.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub currency: Option<String>,
243    /// Unit label.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub unit: Option<String>,
246    /// Whether the value is a percentage.
247    #[serde(default)]
248    pub percent: bool,
249}
250
251impl ChartEncoding {
252    fn validate_known_column(&self, schema: &TableSchema, source: &str) -> Result<()> {
253        if schema.has_column(&self.column) {
254            Ok(())
255        } else {
256            Err(view_error(
257                "chart.column.unknown",
258                format!("Chart references unknown schema column '{}'.", self.column),
259                source,
260            ))
261        }
262    }
263
264    fn validate_format(&self, schema: &TableSchema, source: &str) -> Result<()> {
265        validate_format_declarations(
266            self.format.as_deref(),
267            self.currency.as_deref(),
268            self.unit.as_deref(),
269            self.percent,
270            schema.column(&self.column).map(|column| column.value_type),
271            source,
272        )
273    }
274}
275
276/// Mark label display and formatting declaration.
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct MarkLabels {
280    /// Whether labels are shown.
281    #[serde(default)]
282    pub show: bool,
283    /// Optional label source column. Defaults to the chart y column when absent.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub column: Option<String>,
286    /// Optional value format.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub format: Option<String>,
289    /// Currency code for currency-formatted numeric values.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub currency: Option<String>,
292    /// Unit label.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub unit: Option<String>,
295    /// Whether the value is a percentage.
296    #[serde(default)]
297    pub percent: bool,
298}
299
300impl MarkLabels {
301    fn validate(&self, schema: &TableSchema, source: &str) -> Result<()> {
302        if let Some(column) = &self.column {
303            if !schema.has_column(column) {
304                return Err(view_error(
305                    "chart.column.unknown",
306                    format!("Chart mark labels reference unknown schema column '{column}'."),
307                    source,
308                ));
309            }
310            validate_format_declarations(
311                self.format.as_deref(),
312                self.currency.as_deref(),
313                self.unit.as_deref(),
314                self.percent,
315                schema
316                    .column(column)
317                    .map(|schema_column| schema_column.value_type),
318                source,
319            )?;
320        }
321        Ok(())
322    }
323}
324
325fn validate_format_declarations(
326    format: Option<&str>,
327    currency: Option<&str>,
328    _unit: Option<&str>,
329    percent: bool,
330    column_type: Option<ColumnType>,
331    source: &str,
332) -> Result<()> {
333    if currency.is_some() && format != Some("currency") {
334        return Err(view_error(
335            "view.format.currency.inconsistent",
336            "Currency declarations require format: currency.",
337            source,
338        ));
339    }
340    if percent && format.is_some_and(|format| format != "percent") {
341        return Err(view_error(
342            "view.format.percent.inconsistent",
343            "Percent declarations must use format: percent when a format is declared.",
344            source,
345        ));
346    }
347
348    if matches!(format, Some("currency" | "number" | "percent")) {
349        let Some(column_type) = column_type else {
350            return Ok(());
351        };
352        if !column_type.is_numeric() {
353            return Err(view_error(
354                "view.format.type.incompatible",
355                "Numeric, currency, and percent formats require integer or decimal columns.",
356                source,
357            ));
358        }
359    }
360
361    if matches!(format, Some("date" | "datetime" | "time")) {
362        let Some(column_type) = column_type else {
363            return Ok(());
364        };
365        if !column_type.is_temporal() {
366            return Err(view_error(
367                "view.format.type.incompatible",
368                "Date, datetime, and time formats require temporal columns.",
369                source,
370            ));
371        }
372    }
373
374    Ok(())
375}
376
377fn column_type(schema: &TableSchema, column: &str, source: &str) -> Result<ColumnType> {
378    schema
379        .column(column)
380        .map(|column| column.value_type)
381        .ok_or_else(|| {
382            view_error(
383                "chart.column.unknown",
384                format!("Chart references unknown schema column '{column}'."),
385                source,
386            )
387        })
388}
389
390fn view_error(code: impl Into<String>, message: impl Into<String>, source: &str) -> McdError {
391    McdError::from_diagnostic(Diagnostic::error(code, message).with_source(source.to_owned()))
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::schema::{ColumnType, TableColumnSchema};
398
399    fn schema() -> TableSchema {
400        TableSchema {
401            id: "revenue".to_owned(),
402            columns: vec![
403                TableColumnSchema {
404                    name: "quarter".to_owned(),
405                    value_type: ColumnType::String,
406                    label: None,
407                    nullable: false,
408                    enum_values: Vec::new(),
409                },
410                TableColumnSchema {
411                    name: "amount".to_owned(),
412                    value_type: ColumnType::Decimal,
413                    label: None,
414                    nullable: false,
415                    enum_values: Vec::new(),
416                },
417            ],
418        }
419    }
420
421    #[test]
422    fn validates_chart_columns() {
423        let view = serde_json::from_str::<TableView>(
424            r#"{
425                "id":"chart",
426                "table":"revenue",
427                "display":"chart",
428                "chart":{
429                    "type":"bar",
430                    "x":{"column":"quarter"},
431                    "y":{"column":"amount","format":"currency","currency":"GBP"}
432                }
433            }"#,
434        )
435        .expect("view parses");
436
437        view.validate("chart", "revenue", &schema(), "tables/chart.view.json")
438            .expect("valid chart view");
439    }
440
441    #[test]
442    fn rejects_unknown_view_column() {
443        let view = serde_json::from_str::<TableView>(
444            r#"{"id":"default","table":"revenue","columns":[{"name":"missing"}]}"#,
445        )
446        .expect("view parses");
447        let err = view
448            .validate("default", "revenue", &schema(), "tables/view.json")
449            .expect_err("invalid");
450
451        assert_eq!(
452            err.diagnostic().map(|d| d.code.as_str()),
453            Some("view.column.unknown")
454        );
455    }
456}