1use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use crate::{
7 errors::{Diagnostic, McdError, Result},
8 package::McdPackage,
9};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct TableSchema {
14 pub id: String,
16 pub columns: Vec<TableColumnSchema>,
18}
19
20impl TableSchema {
21 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 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 #[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 #[must_use]
93 pub fn has_column(&self, name: &str) -> bool {
94 self.columns.iter().any(|column| column.name == name)
95 }
96
97 #[must_use]
99 pub fn column(&self, name: &str) -> Option<&TableColumnSchema> {
100 self.columns.iter().find(|column| column.name == name)
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase")]
107pub struct TableColumnSchema {
108 pub name: String,
110 #[serde(rename = "type")]
112 pub value_type: ColumnType,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub label: Option<String>,
116 #[serde(default)]
118 pub nullable: bool,
119 #[serde(default, alias = "values", alias = "enumValues")]
121 pub enum_values: Vec<String>,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "lowercase")]
127pub enum ColumnType {
128 String,
130 Integer,
132 Decimal,
134 Boolean,
136 Date,
138 Datetime,
140 Time,
142 Enum,
144}
145
146impl ColumnType {
147 #[must_use]
149 pub fn is_numeric(self) -> bool {
150 matches!(self, Self::Integer | Self::Decimal)
151 }
152
153 #[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}