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    /// Columns that uniquely identify rows in this table.
17    #[serde(default, rename = "primaryKey", skip_serializing_if = "Vec::is_empty")]
18    pub primary_key: Vec<String>,
19    /// Foreign-key relationships from this table to other tables.
20    #[serde(default, rename = "foreignKeys", skip_serializing_if = "Vec::is_empty")]
21    pub foreign_keys: Vec<ForeignKeySchema>,
22    /// Ordered table columns.
23    pub columns: Vec<TableColumnSchema>,
24}
25
26impl TableSchema {
27    /// Parse a table schema from a package entry.
28    pub fn from_package(package: &McdPackage, path: &str) -> Result<Self> {
29        let bytes = package.read(path).map_err(|_| {
30            McdError::from_diagnostic(
31                Diagnostic::error(
32                    "schema.file.missing",
33                    format!("Declared table schema file '{path}' is missing."),
34                )
35                .with_source(path.to_owned()),
36            )
37        })?;
38        let schema = serde_json::from_slice::<Self>(bytes)?;
39        schema.validate(path)?;
40        Ok(schema)
41    }
42
43    /// Validate schema-level constraints.
44    pub fn validate(&self, source: &str) -> Result<()> {
45        if self.id.trim().is_empty() {
46            return Err(schema_error(
47                "schema.id.empty",
48                "Table schema id cannot be empty.",
49                source,
50            ));
51        }
52        if self.columns.is_empty() {
53            return Err(schema_error(
54                "schema.columns.empty",
55                "Table schema must declare at least one column.",
56                source,
57            ));
58        }
59
60        let mut names = std::collections::HashSet::new();
61        for column in &self.columns {
62            if column.name.trim().is_empty() {
63                return Err(schema_error(
64                    "schema.column.name.empty",
65                    "Table schema column name cannot be empty.",
66                    source,
67                ));
68            }
69            if !names.insert(column.name.clone()) {
70                return Err(schema_error(
71                    "schema.column.name.duplicate",
72                    format!("Duplicate schema column '{}'.", column.name),
73                    source,
74                ));
75            }
76            if column.value_type == ColumnType::Enum && column.enum_values.is_empty() {
77                return Err(schema_error(
78                    "schema.enum.values.missing",
79                    format!("Enum column '{}' must declare enum values.", column.name),
80                    source,
81                ));
82            }
83            if let Some(unit) = &column.unit {
84                if !column.value_type.is_numeric() {
85                    return Err(schema_error(
86                        "schema.unit.type.incompatible",
87                        format!(
88                            "Unit for column '{}' requires an integer or decimal type.",
89                            column.name
90                        ),
91                        source,
92                    ));
93                }
94                unit.validate(&column.name, source)?;
95            }
96        }
97
98        if has_duplicates(&self.primary_key) {
99            return Err(schema_error(
100                "schema.primary_key.column.duplicate",
101                "Primary key columns must be unique.",
102                source,
103            ));
104        }
105        for key_column in &self.primary_key {
106            let Some(column) = self.column(key_column) else {
107                return Err(schema_error(
108                    "schema.primary_key.column.unknown",
109                    format!("Primary key references unknown column '{key_column}'."),
110                    source,
111                ));
112            };
113            if column.nullable {
114                return Err(schema_error(
115                    "schema.primary_key.column.nullable",
116                    format!("Primary key column '{key_column}' cannot be nullable."),
117                    source,
118                ));
119            }
120        }
121
122        for foreign_key in &self.foreign_keys {
123            foreign_key.validate(self, source)?;
124        }
125
126        Ok(())
127    }
128
129    /// Return a map keyed by column name.
130    #[must_use]
131    pub fn columns_by_name(&self) -> IndexMap<&str, &TableColumnSchema> {
132        self.columns
133            .iter()
134            .map(|column| (column.name.as_str(), column))
135            .collect()
136    }
137
138    /// Return true when the schema contains a column.
139    #[must_use]
140    pub fn has_column(&self, name: &str) -> bool {
141        self.columns.iter().any(|column| column.name == name)
142    }
143
144    /// Find a column by name.
145    #[must_use]
146    pub fn column(&self, name: &str) -> Option<&TableColumnSchema> {
147        self.columns.iter().find(|column| column.name == name)
148    }
149}
150
151/// A foreign-key relationship from this table to another table.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct ForeignKeySchema {
155    /// Local columns in this table.
156    pub columns: Vec<String>,
157    /// Referenced table and columns.
158    pub references: ForeignKeyReference,
159}
160
161impl ForeignKeySchema {
162    fn validate(&self, schema: &TableSchema, source: &str) -> Result<()> {
163        if self.columns.is_empty() {
164            return Err(schema_error(
165                "schema.foreign_key.columns.empty",
166                "Foreign keys must reference at least one local column.",
167                source,
168            ));
169        }
170        if self.references.columns.is_empty() {
171            return Err(schema_error(
172                "schema.foreign_key.references.columns.empty",
173                "Foreign keys must reference at least one target column.",
174                source,
175            ));
176        }
177        if self.columns.len() != self.references.columns.len() {
178            return Err(schema_error(
179                "schema.foreign_key.column_count.mismatch",
180                "Foreign key local and referenced column counts must match.",
181                source,
182            ));
183        }
184        if has_duplicates(&self.columns) {
185            return Err(schema_error(
186                "schema.foreign_key.column.duplicate",
187                "Foreign key local columns must be unique.",
188                source,
189            ));
190        }
191        if has_duplicates(&self.references.columns) {
192            return Err(schema_error(
193                "schema.foreign_key.references.column.duplicate",
194                "Foreign key referenced columns must be unique.",
195                source,
196            ));
197        }
198        for column in &self.columns {
199            if !schema.has_column(column) {
200                return Err(schema_error(
201                    "schema.foreign_key.column.unknown",
202                    format!("Foreign key references unknown local column '{column}'."),
203                    source,
204                ));
205            }
206        }
207        if self.references.table.trim().is_empty() {
208            return Err(schema_error(
209                "schema.foreign_key.references.table.empty",
210                "Foreign key referenced table cannot be empty.",
211                source,
212            ));
213        }
214        Ok(())
215    }
216}
217
218/// Foreign-key target table and columns.
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub struct ForeignKeyReference {
222    /// Referenced manifest table id.
223    pub table: String,
224    /// Referenced columns in the target table.
225    pub columns: Vec<String>,
226}
227
228/// One table column schema.
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase")]
231pub struct TableColumnSchema {
232    /// CSV header and stable column id.
233    pub name: String,
234    /// Primitive MCD column type.
235    #[serde(rename = "type")]
236    pub value_type: ColumnType,
237    /// Human-readable label.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub label: Option<String>,
240    /// Optional semantic unit for numeric measured values.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub unit: Option<SemanticUnit>,
243    /// Whether empty CSV cells are allowed.
244    #[serde(default)]
245    pub nullable: bool,
246    /// Allowed values for enum columns.
247    #[serde(default, alias = "values", alias = "enumValues")]
248    pub enum_values: Vec<String>,
249}
250
251/// Simple semantic unit metadata for table columns.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct SemanticUnit {
255    /// Stable unit code for known units, for example `kg`, `m`, `GBP`, or `percent`.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub code: Option<String>,
258    /// Human-facing unit label. Required for custom units.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub label: Option<String>,
261    /// Whether this is an explicit non-convertible custom unit.
262    #[serde(default)]
263    pub custom: bool,
264}
265
266impl SemanticUnit {
267    fn validate(&self, column_name: &str, source: &str) -> Result<()> {
268        if self.custom {
269            if self.code.is_some() {
270                return Err(schema_error(
271                    "schema.unit.custom.code",
272                    format!("Custom unit for column '{column_name}' cannot declare a code."),
273                    source,
274                ));
275            }
276            if self.label.as_deref().is_none_or(str::is_empty) {
277                return Err(schema_error(
278                    "schema.unit.custom.label.missing",
279                    format!("Custom unit for column '{column_name}' must declare a label."),
280                    source,
281                ));
282            }
283        } else if self.code.as_deref().is_none_or(str::is_empty) {
284            return Err(schema_error(
285                "schema.unit.code.missing",
286                format!("Unit for column '{column_name}' must declare a code."),
287                source,
288            ));
289        }
290
291        if self.label.as_deref().is_some_and(str::is_empty) {
292            return Err(schema_error(
293                "schema.unit.label.empty",
294                format!("Unit label for column '{column_name}' cannot be empty."),
295                source,
296            ));
297        }
298
299        Ok(())
300    }
301
302    /// Return the best human-readable display label for this unit.
303    #[must_use]
304    pub fn display_label(&self) -> Option<&str> {
305        self.label.as_deref().or(self.code.as_deref())
306    }
307}
308
309/// Supported primitive table types.
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "lowercase")]
312pub enum ColumnType {
313    /// UTF-8 string value.
314    String,
315    /// Signed 64-bit integer value.
316    Integer,
317    /// Decimal value.
318    Decimal,
319    /// Boolean value.
320    Boolean,
321    /// ISO date value.
322    Date,
323    /// ISO datetime value.
324    Datetime,
325    /// ISO time value.
326    Time,
327    /// String value constrained to declared members.
328    Enum,
329}
330
331impl ColumnType {
332    /// Return true for numeric types.
333    #[must_use]
334    pub fn is_numeric(self) -> bool {
335        matches!(self, Self::Integer | Self::Decimal)
336    }
337
338    /// Return true for temporal types.
339    #[must_use]
340    pub fn is_temporal(self) -> bool {
341        matches!(self, Self::Date | Self::Datetime | Self::Time)
342    }
343}
344
345fn schema_error(code: impl Into<String>, message: impl Into<String>, source: &str) -> McdError {
346    McdError::from_diagnostic(Diagnostic::error(code, message).with_source(source.to_owned()))
347}
348
349fn has_duplicates(values: &[String]) -> bool {
350    let mut seen = std::collections::HashSet::new();
351    values.iter().any(|value| !seen.insert(value))
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn parses_schema_columns() {
360        let schema = serde_json::from_str::<TableSchema>(
361            r#"{
362                "id": "revenue",
363                "primaryKey": ["quarter"],
364                "columns": [
365                    {"name": "quarter", "type": "string"},
366                    {"name": "amount", "type": "decimal", "nullable": true}
367                ]
368            }"#,
369        )
370        .expect("schema parses");
371
372        assert_eq!(schema.columns[1].value_type, ColumnType::Decimal);
373        assert!(schema.columns[1].nullable);
374        assert_eq!(schema.primary_key, ["quarter"]);
375    }
376
377    #[test]
378    fn parses_semantic_units() {
379        let schema = serde_json::from_str::<TableSchema>(
380            r#"{
381                "id": "measurements",
382                "columns": [
383                    {"name": "mass", "type": "decimal", "unit": {"code": "kg", "label": "kg"}},
384                    {"name": "score", "type": "decimal", "unit": {"custom": true, "label": "index points"}}
385                ]
386            }"#,
387        )
388        .expect("schema parses");
389
390        schema
391            .validate("tables/measurements.schema.json")
392            .expect("valid units");
393        assert_eq!(
394            schema.columns[0]
395                .unit
396                .as_ref()
397                .and_then(SemanticUnit::display_label),
398            Some("kg")
399        );
400        assert_eq!(
401            schema.columns[1]
402                .unit
403                .as_ref()
404                .and_then(SemanticUnit::display_label),
405            Some("index points")
406        );
407    }
408
409    #[test]
410    fn rejects_bad_semantic_units() {
411        let cases = [
412            (
413                r#"{"id":"t","columns":[{"name":"name","type":"string","unit":{"code":"kg"}}]}"#,
414                "schema.unit.type.incompatible",
415            ),
416            (
417                r#"{"id":"t","columns":[{"name":"value","type":"decimal","unit":{"custom":true}}]}"#,
418                "schema.unit.custom.label.missing",
419            ),
420            (
421                r#"{"id":"t","columns":[{"name":"value","type":"decimal","unit":{"label":"kg"}}]}"#,
422                "schema.unit.code.missing",
423            ),
424        ];
425
426        for (raw, code) in cases {
427            let schema = serde_json::from_str::<TableSchema>(raw).expect("schema parses");
428            let err = schema
429                .validate("tables/t.schema.json")
430                .expect_err("invalid");
431            assert_eq!(err.diagnostic().map(|d| d.code.as_str()), Some(code));
432        }
433    }
434
435    #[test]
436    fn enum_columns_require_values() {
437        let schema = serde_json::from_str::<TableSchema>(
438            r#"{"id":"survey","columns":[{"name":"rating","type":"enum"}]}"#,
439        )
440        .expect("schema parses");
441        let err = schema
442            .validate("tables/survey.schema.json")
443            .expect_err("invalid");
444
445        assert_eq!(
446            err.diagnostic().map(|d| d.code.as_str()),
447            Some("schema.enum.values.missing")
448        );
449    }
450
451    #[test]
452    fn primary_key_columns_must_exist_and_be_nonnullable() {
453        let missing = serde_json::from_str::<TableSchema>(
454            r#"{"id":"revenue","primaryKey":["missing"],"columns":[{"name":"quarter","type":"string"}]}"#,
455        )
456        .expect("schema parses");
457        let err = missing
458            .validate("tables/revenue.schema.json")
459            .expect_err("invalid");
460        assert_eq!(
461            err.diagnostic().map(|d| d.code.as_str()),
462            Some("schema.primary_key.column.unknown")
463        );
464
465        let nullable = serde_json::from_str::<TableSchema>(
466            r#"{"id":"revenue","primaryKey":["quarter"],"columns":[{"name":"quarter","type":"string","nullable":true}]}"#,
467        )
468        .expect("schema parses");
469        let err = nullable
470            .validate("tables/revenue.schema.json")
471            .expect_err("invalid");
472        assert_eq!(
473            err.diagnostic().map(|d| d.code.as_str()),
474            Some("schema.primary_key.column.nullable")
475        );
476    }
477
478    #[test]
479    fn foreign_key_columns_must_be_well_formed() {
480        let schema = serde_json::from_str::<TableSchema>(
481            r#"{
482                "id":"orders",
483                "foreignKeys":[{
484                    "columns":["missing"],
485                    "references":{"table":"customers","columns":["customer_id"]}
486                }],
487                "columns":[{"name":"customer_id","type":"string"}]
488            }"#,
489        )
490        .expect("schema parses");
491        let err = schema
492            .validate("tables/orders.schema.json")
493            .expect_err("invalid");
494        assert_eq!(
495            err.diagnostic().map(|d| d.code.as_str()),
496            Some("schema.foreign_key.column.unknown")
497        );
498    }
499
500    #[test]
501    fn key_columns_must_be_unique() {
502        let duplicate_primary_key = serde_json::from_str::<TableSchema>(
503            r#"{"id":"revenue","primaryKey":["quarter","quarter"],"columns":[{"name":"quarter","type":"string"}]}"#,
504        )
505        .expect("schema parses");
506        let err = duplicate_primary_key
507            .validate("tables/revenue.schema.json")
508            .expect_err("invalid");
509        assert_eq!(
510            err.diagnostic().map(|d| d.code.as_str()),
511            Some("schema.primary_key.column.duplicate")
512        );
513
514        let duplicate_foreign_key = serde_json::from_str::<TableSchema>(
515            r#"{
516                "id":"orders",
517                "foreignKeys":[{
518                    "columns":["customer_id","customer_id"],
519                    "references":{"table":"customers","columns":["customer_id","other_id"]}
520                }],
521                "columns":[{"name":"customer_id","type":"string"},{"name":"other_id","type":"string"}]
522            }"#,
523        )
524        .expect("schema parses");
525        let err = duplicate_foreign_key
526            .validate("tables/orders.schema.json")
527            .expect_err("invalid");
528        assert_eq!(
529            err.diagnostic().map(|d| d.code.as_str()),
530            Some("schema.foreign_key.column.duplicate")
531        );
532    }
533}