Skip to main content

mcd_core/
tables.rs

1//! CSV table loading and typed value coercion.
2
3use indexmap::IndexMap;
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use time::{
7    Date, OffsetDateTime, PrimitiveDateTime, Time, format_description::well_known::Rfc3339,
8};
9
10use crate::{
11    errors::{Diagnostic, McdError, Result},
12    manifest::{Manifest, TableManifestEntry},
13    package::McdPackage,
14    schema::{ColumnType, TableColumnSchema, TableSchema},
15};
16
17/// Load all manifest-declared tables in manifest order.
18pub fn load_manifest_tables(
19    package: &McdPackage,
20    manifest: &Manifest,
21) -> Result<IndexMap<String, DataTable>> {
22    let mut tables = IndexMap::new();
23    for entry in &manifest.tables {
24        let table = DataTable::from_manifest_entry(package, entry)?;
25        tables.insert(entry.id.clone(), table);
26    }
27    Ok(tables)
28}
29
30/// A loaded CSV-backed table.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct DataTable {
34    /// Stable table id.
35    pub id: String,
36    /// Package CSV path.
37    pub source: String,
38    /// Parsed schema.
39    pub schema: TableSchema,
40    /// Ordered typed rows.
41    pub rows: Vec<TableRow>,
42}
43
44impl DataTable {
45    /// Load and validate a manifest-declared table.
46    pub fn from_manifest_entry(package: &McdPackage, entry: &TableManifestEntry) -> Result<Self> {
47        if !package.contains(&entry.data) {
48            return Err(McdError::from_diagnostic(
49                Diagnostic::error(
50                    "table.data.missing",
51                    format!("Declared table data file '{}' is missing.", entry.data),
52                )
53                .with_source(entry.data.clone()),
54            ));
55        }
56
57        let schema = TableSchema::from_package(package, &entry.schema)?;
58        if schema.id != entry.id {
59            return Err(McdError::from_diagnostic(
60                Diagnostic::error(
61                    "schema.table.mismatch",
62                    format!(
63                        "Table schema id '{}' does not match manifest table id '{}'.",
64                        schema.id, entry.id
65                    ),
66                )
67                .with_source(entry.schema.clone()),
68            ));
69        }
70
71        let bytes = package.read(&entry.data)?;
72        let rows = load_csv_rows(&entry.id, &entry.data, &entry.schema, bytes, &schema)?;
73
74        Ok(Self {
75            id: entry.id.clone(),
76            source: entry.data.clone(),
77            schema,
78            rows,
79        })
80    }
81}
82
83/// A typed table row, keyed by schema column name.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct TableRow {
86    /// Typed cells.
87    #[serde(flatten)]
88    pub cells: IndexMap<String, TypedValue>,
89}
90
91/// A typed table cell value.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(tag = "type", content = "value", rename_all = "snake_case")]
94pub enum TypedValue {
95    /// Null value from an empty nullable cell.
96    Null,
97    /// String value.
98    String(String),
99    /// Integer value.
100    Integer(i64),
101    /// Decimal value, serialized as a decimal string for stability.
102    Decimal(String),
103    /// Boolean value.
104    Boolean(bool),
105    /// Date value.
106    Date(String),
107    /// Datetime value.
108    Datetime(String),
109    /// Time value.
110    Time(String),
111    /// Enum member.
112    Enum(String),
113}
114
115fn load_csv_rows(
116    table_id: &str,
117    source: &str,
118    schema_source: &str,
119    bytes: &[u8],
120    schema: &TableSchema,
121) -> Result<Vec<TableRow>> {
122    let mut reader = csv::ReaderBuilder::new()
123        .has_headers(true)
124        .flexible(false)
125        .from_reader(bytes);
126
127    let headers = reader.headers().map_err(|err| {
128        McdError::from_diagnostic(
129            Diagnostic::error(
130                "csv.header.missing",
131                format!("CSV table '{table_id}' must include a header row: {err}."),
132            )
133            .with_source(format!("{source}:1")),
134        )
135    })?;
136    if headers.is_empty() {
137        return Err(McdError::from_diagnostic(
138            Diagnostic::error(
139                "csv.header.missing",
140                format!("CSV table '{table_id}' must include a header row."),
141            )
142            .with_source(format!("{source}:1")),
143        ));
144    }
145
146    let actual_headers = headers.iter().map(ToOwned::to_owned).collect::<Vec<_>>();
147    let expected_headers = schema
148        .columns
149        .iter()
150        .map(|column| column.name.clone())
151        .collect::<Vec<_>>();
152    if actual_headers != expected_headers {
153        return Err(McdError::from_diagnostic(
154            Diagnostic::error(
155                "csv.header.mismatch",
156                format!(
157                    "CSV header does not match table schema for table '{}'. Expected [{}], got [{}].",
158                    table_id,
159                    expected_headers.join(", "),
160                    actual_headers.join(", ")
161                ),
162            )
163            .with_source(format!("{source}:1"))
164            .with_related(schema_source.to_owned()),
165        ));
166    }
167
168    let mut rows = Vec::new();
169    for (record_index, record) in reader.records().enumerate() {
170        let record = record.map_err(|err| {
171            McdError::from_diagnostic(
172                Diagnostic::error("csv.row.invalid", format!("Invalid CSV row: {err}."))
173                    .with_source(format!("{source}:{}", record_index + 2)),
174            )
175        })?;
176
177        let mut cells = IndexMap::new();
178        for (column_index, column) in schema.columns.iter().enumerate() {
179            let raw = record.get(column_index).unwrap_or_default();
180            let value = coerce_cell(raw, column, source, record_index + 2)?;
181            cells.insert(column.name.clone(), value);
182        }
183        rows.push(TableRow { cells });
184    }
185
186    Ok(rows)
187}
188
189/// Coerce one CSV cell into its schema type.
190pub fn coerce_cell(
191    raw: &str,
192    column: &TableColumnSchema,
193    source: &str,
194    row_number: usize,
195) -> Result<TypedValue> {
196    let value = raw.trim();
197    if value.is_empty() {
198        if column.nullable {
199            return Ok(TypedValue::Null);
200        }
201        return Err(McdError::from_diagnostic(
202            Diagnostic::error(
203                "csv.cell.empty.nonnullable",
204                format!("Column '{}' does not allow empty cells.", column.name),
205            )
206            .with_source(format!("{source}:{row_number}")),
207        ));
208    }
209
210    match column.value_type {
211        ColumnType::String => Ok(TypedValue::String(raw.to_owned())),
212        ColumnType::Integer => value
213            .parse::<i64>()
214            .map(TypedValue::Integer)
215            .map_err(|_| cell_type_error("csv.cell.integer.invalid", column, source, row_number)),
216        ColumnType::Decimal => value
217            .parse::<Decimal>()
218            .map(|decimal| TypedValue::Decimal(decimal.normalize().to_string()))
219            .map_err(|_| cell_type_error("csv.cell.decimal.invalid", column, source, row_number)),
220        ColumnType::Boolean => parse_bool(value)
221            .map(TypedValue::Boolean)
222            .ok_or_else(|| cell_type_error("csv.cell.boolean.invalid", column, source, row_number)),
223        ColumnType::Date => Date::parse(
224            value,
225            &time::macros::format_description!("[year]-[month]-[day]"),
226        )
227        .map(|date| TypedValue::Date(date.to_string()))
228        .map_err(|_| cell_type_error("csv.cell.date.invalid", column, source, row_number)),
229        ColumnType::Datetime => parse_datetime(value)
230            .map(TypedValue::Datetime)
231            .ok_or_else(|| {
232                cell_type_error("csv.cell.datetime.invalid", column, source, row_number)
233            }),
234        ColumnType::Time => Time::parse(
235            value,
236            &time::macros::format_description!("[hour]:[minute]:[second]"),
237        )
238        .or_else(|_| Time::parse(value, &time::macros::format_description!("[hour]:[minute]")))
239        .map(|time| time.to_string())
240        .map(TypedValue::Time)
241        .map_err(|_| cell_type_error("csv.cell.time.invalid", column, source, row_number)),
242        ColumnType::Enum => {
243            if column.enum_values.iter().any(|allowed| allowed == value) {
244                Ok(TypedValue::Enum(value.to_owned()))
245            } else {
246                Err(McdError::from_diagnostic(
247                    Diagnostic::error(
248                        "csv.cell.enum.invalid",
249                        format!(
250                            "Value '{}' is not a member of enum column '{}'.",
251                            value, column.name
252                        ),
253                    )
254                    .with_source(format!("{source}:{row_number}")),
255                ))
256            }
257        }
258    }
259}
260
261fn parse_bool(value: &str) -> Option<bool> {
262    match value {
263        "true" | "TRUE" | "True" => Some(true),
264        "false" | "FALSE" | "False" => Some(false),
265        _ => None,
266    }
267}
268
269fn parse_datetime(value: &str) -> Option<String> {
270    OffsetDateTime::parse(value, &Rfc3339)
271        .map(|datetime| datetime.to_string())
272        .or_else(|_| {
273            PrimitiveDateTime::parse(
274                value,
275                &time::macros::format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]"),
276            )
277            .map(|datetime| datetime.to_string())
278        })
279        .ok()
280}
281
282fn cell_type_error(
283    code: &'static str,
284    column: &TableColumnSchema,
285    source: &str,
286    row_number: usize,
287) -> McdError {
288    McdError::from_diagnostic(
289        Diagnostic::error(
290            code,
291            format!(
292                "Cell in column '{}' is not a valid {:?}.",
293                column.name, column.value_type
294            ),
295        )
296        .with_source(format!("{source}:{row_number}")),
297    )
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use proptest::prelude::*;
304
305    fn column(value_type: ColumnType, nullable: bool) -> TableColumnSchema {
306        TableColumnSchema {
307            name: "value".to_owned(),
308            value_type,
309            label: None,
310            nullable,
311            enum_values: Vec::new(),
312        }
313    }
314
315    #[test]
316    fn coerces_decimal() {
317        let value = coerce_cell("12.3400", &column(ColumnType::Decimal, false), "t.csv", 2)
318            .expect("decimal");
319        assert_eq!(value, TypedValue::Decimal("12.34".to_owned()));
320    }
321
322    #[test]
323    fn rejects_empty_nonnullable_cell() {
324        let err =
325            coerce_cell("", &column(ColumnType::String, false), "t.csv", 2).expect_err("invalid");
326        assert_eq!(
327            err.diagnostic().map(|d| d.code.as_str()),
328            Some("csv.cell.empty.nonnullable")
329        );
330    }
331
332    #[test]
333    fn rejects_bad_date() {
334        let err = coerce_cell("2026-99-99", &column(ColumnType::Date, false), "t.csv", 2)
335            .expect_err("invalid");
336        assert_eq!(
337            err.diagnostic().map(|d| d.code.as_str()),
338            Some("csv.cell.date.invalid")
339        );
340    }
341
342    #[test]
343    fn rejects_bad_decimal_datetime_time_boolean_and_enum() {
344        let cases = [
345            (
346                "not-money",
347                TableColumnSchema {
348                    name: "value".to_owned(),
349                    value_type: ColumnType::Decimal,
350                    label: None,
351                    nullable: false,
352                    enum_values: Vec::new(),
353                },
354                "csv.cell.decimal.invalid",
355            ),
356            (
357                "2026-05-18 12:00:00",
358                TableColumnSchema {
359                    name: "value".to_owned(),
360                    value_type: ColumnType::Datetime,
361                    label: None,
362                    nullable: false,
363                    enum_values: Vec::new(),
364                },
365                "csv.cell.datetime.invalid",
366            ),
367            (
368                "25:00",
369                TableColumnSchema {
370                    name: "value".to_owned(),
371                    value_type: ColumnType::Time,
372                    label: None,
373                    nullable: false,
374                    enum_values: Vec::new(),
375                },
376                "csv.cell.time.invalid",
377            ),
378            (
379                "yes",
380                TableColumnSchema {
381                    name: "value".to_owned(),
382                    value_type: ColumnType::Boolean,
383                    label: None,
384                    nullable: false,
385                    enum_values: Vec::new(),
386                },
387                "csv.cell.boolean.invalid",
388            ),
389            (
390                "bronze",
391                TableColumnSchema {
392                    name: "value".to_owned(),
393                    value_type: ColumnType::Enum,
394                    label: None,
395                    nullable: false,
396                    enum_values: vec!["silver".to_owned(), "gold".to_owned()],
397                },
398                "csv.cell.enum.invalid",
399            ),
400        ];
401
402        for (raw, column, code) in cases {
403            let err = coerce_cell(raw, &column, "t.csv", 2).expect_err("invalid");
404            assert_eq!(err.diagnostic().map(|d| d.code.as_str()), Some(code));
405        }
406    }
407
408    proptest! {
409        #[test]
410        fn coerces_generated_integers(raw in any::<i64>()) {
411            let value = coerce_cell(
412                &raw.to_string(),
413                &column(ColumnType::Integer, false),
414                "t.csv",
415                2,
416            )
417            .expect("integer");
418            prop_assert_eq!(value, TypedValue::Integer(raw));
419        }
420    }
421}