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 #[serde(default, rename = "primaryKey", skip_serializing_if = "Vec::is_empty")]
18 pub primary_key: Vec<String>,
19 #[serde(default, rename = "foreignKeys", skip_serializing_if = "Vec::is_empty")]
21 pub foreign_keys: Vec<ForeignKeySchema>,
22 pub columns: Vec<TableColumnSchema>,
24}
25
26impl TableSchema {
27 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 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 #[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 #[must_use]
140 pub fn has_column(&self, name: &str) -> bool {
141 self.columns.iter().any(|column| column.name == name)
142 }
143
144 #[must_use]
146 pub fn column(&self, name: &str) -> Option<&TableColumnSchema> {
147 self.columns.iter().find(|column| column.name == name)
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct ForeignKeySchema {
155 pub columns: Vec<String>,
157 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub struct ForeignKeyReference {
222 pub table: String,
224 pub columns: Vec<String>,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase")]
231pub struct TableColumnSchema {
232 pub name: String,
234 #[serde(rename = "type")]
236 pub value_type: ColumnType,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub label: Option<String>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub unit: Option<SemanticUnit>,
243 #[serde(default)]
245 pub nullable: bool,
246 #[serde(default, alias = "values", alias = "enumValues")]
248 pub enum_values: Vec<String>,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct SemanticUnit {
255 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub code: Option<String>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub label: Option<String>,
261 #[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 #[must_use]
304 pub fn display_label(&self) -> Option<&str> {
305 self.label.as_deref().or(self.code.as_deref())
306 }
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "lowercase")]
312pub enum ColumnType {
313 String,
315 Integer,
317 Decimal,
319 Boolean,
321 Date,
323 Datetime,
325 Time,
327 Enum,
329}
330
331impl ColumnType {
332 #[must_use]
334 pub fn is_numeric(self) -> bool {
335 matches!(self, Self::Integer | Self::Decimal)
336 }
337
338 #[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}