Skip to main content

mcd_core/
schema.rs

1//! MCD table schema parsing and column type definitions.
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    errors::{Diagnostic, McdError, Result},
8    package::McdPackage,
9};
10
11/// Parsed table schema JSON.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct TableSchema {
14    /// Stable schema/table id.
15    pub id: String,
16    /// Ordered table columns.
17    pub columns: Vec<TableColumnSchema>,
18}
19
20impl TableSchema {
21    /// Parse a table schema from a package entry.
22    pub fn from_package(package: &McdPackage, path: &str) -> Result<Self> {
23        let bytes = package.read(path).map_err(|_| {
24            McdError::from_diagnostic(
25                Diagnostic::error(
26                    "schema.file.missing",
27                    format!("Declared table schema file '{path}' is missing."),
28                )
29                .with_source(path.to_owned()),
30            )
31        })?;
32        let schema = serde_json::from_slice::<Self>(bytes)?;
33        schema.validate(path)?;
34        Ok(schema)
35    }
36
37    /// Validate schema-level constraints.
38    pub fn validate(&self, source: &str) -> Result<()> {
39        if self.id.trim().is_empty() {
40            return Err(schema_error(
41                "schema.id.empty",
42                "Table schema id cannot be empty.",
43                source,
44            ));
45        }
46        if self.columns.is_empty() {
47            return Err(schema_error(
48                "schema.columns.empty",
49                "Table schema must declare at least one column.",
50                source,
51            ));
52        }
53
54        let mut names = std::collections::HashSet::new();
55        for column in &self.columns {
56            if column.name.trim().is_empty() {
57                return Err(schema_error(
58                    "schema.column.name.empty",
59                    "Table schema column name cannot be empty.",
60                    source,
61                ));
62            }
63            if !names.insert(column.name.clone()) {
64                return Err(schema_error(
65                    "schema.column.name.duplicate",
66                    format!("Duplicate schema column '{}'.", column.name),
67                    source,
68                ));
69            }
70            if column.value_type == ColumnType::Enum && column.enum_values.is_empty() {
71                return Err(schema_error(
72                    "schema.enum.values.missing",
73                    format!("Enum column '{}' must declare enum values.", column.name),
74                    source,
75                ));
76            }
77        }
78
79        Ok(())
80    }
81
82    /// Return a map keyed by column name.
83    #[must_use]
84    pub fn columns_by_name(&self) -> IndexMap<&str, &TableColumnSchema> {
85        self.columns
86            .iter()
87            .map(|column| (column.name.as_str(), column))
88            .collect()
89    }
90
91    /// Return true when the schema contains a column.
92    #[must_use]
93    pub fn has_column(&self, name: &str) -> bool {
94        self.columns.iter().any(|column| column.name == name)
95    }
96
97    /// Find a column by name.
98    #[must_use]
99    pub fn column(&self, name: &str) -> Option<&TableColumnSchema> {
100        self.columns.iter().find(|column| column.name == name)
101    }
102}
103
104/// One table column schema.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase")]
107pub struct TableColumnSchema {
108    /// CSV header and stable column id.
109    pub name: String,
110    /// Primitive MCD column type.
111    #[serde(rename = "type")]
112    pub value_type: ColumnType,
113    /// Human-readable label.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub label: Option<String>,
116    /// Whether empty CSV cells are allowed.
117    #[serde(default)]
118    pub nullable: bool,
119    /// Allowed values for enum columns.
120    #[serde(default, alias = "values", alias = "enumValues")]
121    pub enum_values: Vec<String>,
122}
123
124/// Supported primitive table types.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "lowercase")]
127pub enum ColumnType {
128    /// UTF-8 string value.
129    String,
130    /// Signed 64-bit integer value.
131    Integer,
132    /// Decimal value.
133    Decimal,
134    /// Boolean value.
135    Boolean,
136    /// ISO date value.
137    Date,
138    /// ISO datetime value.
139    Datetime,
140    /// ISO time value.
141    Time,
142    /// String value constrained to declared members.
143    Enum,
144}
145
146impl ColumnType {
147    /// Return true for numeric types.
148    #[must_use]
149    pub fn is_numeric(self) -> bool {
150        matches!(self, Self::Integer | Self::Decimal)
151    }
152
153    /// Return true for temporal types.
154    #[must_use]
155    pub fn is_temporal(self) -> bool {
156        matches!(self, Self::Date | Self::Datetime | Self::Time)
157    }
158}
159
160fn schema_error(code: impl Into<String>, message: impl Into<String>, source: &str) -> McdError {
161    McdError::from_diagnostic(Diagnostic::error(code, message).with_source(source.to_owned()))
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn parses_schema_columns() {
170        let schema = serde_json::from_str::<TableSchema>(
171            r#"{
172                "id": "revenue",
173                "columns": [
174                    {"name": "quarter", "type": "string"},
175                    {"name": "amount", "type": "decimal", "nullable": true}
176                ]
177            }"#,
178        )
179        .expect("schema parses");
180
181        assert_eq!(schema.columns[1].value_type, ColumnType::Decimal);
182        assert!(schema.columns[1].nullable);
183    }
184
185    #[test]
186    fn enum_columns_require_values() {
187        let schema = serde_json::from_str::<TableSchema>(
188            r#"{"id":"survey","columns":[{"name":"rating","type":"enum"}]}"#,
189        )
190        .expect("schema parses");
191        let err = schema
192            .validate("tables/survey.schema.json")
193            .expect_err("invalid");
194
195        assert_eq!(
196            err.diagnostic().map(|d| d.code.as_str()),
197            Some("schema.enum.values.missing")
198        );
199    }
200}