Skip to main content

mcd_query/
lib.rs

1//! Read-only SQL querying for MCD package tables.
2
3use std::path::Path;
4
5use anyhow::{Context, Result, bail};
6use mcd_core::{
7    Manifest, McdPackage,
8    schema::{ColumnType, TableColumnSchema},
9    tables::{DataTable, TypedValue, load_manifest_tables},
10};
11use rusqlite::{
12    Connection, params, params_from_iter,
13    types::{Value, ValueRef},
14};
15use serde::{Deserialize, Serialize};
16use serde_json::{Map, Value as JsonValue, json};
17
18/// Run a read-only SQL query against manifest-declared package tables.
19pub fn query_package(package: &McdPackage, sql: &str) -> Result<QueryResult> {
20    validate_read_only_sql(sql)?;
21    let connection = query_connection(package)?;
22    run_read_only_query(&connection, sql)
23}
24
25/// Run multiple read-only SQL queries against manifest-declared package tables.
26///
27/// The package tables are loaded into SQLite once, then each query is prepared
28/// and checked independently for read-only behavior.
29pub fn query_package_many(package: &McdPackage, queries: &[String]) -> Result<Vec<QueryResult>> {
30    validate_query_batch(queries)?;
31    let connection = query_connection(package)?;
32    queries
33        .iter()
34        .map(|sql| run_read_only_query(&connection, sql))
35        .collect()
36}
37
38fn query_connection(package: &McdPackage) -> Result<Connection> {
39    let manifest = package.manifest()?;
40    let tables = load_manifest_tables(package, &manifest)?;
41    let mut connection = Connection::open_in_memory()?;
42    connection.execute_batch("PRAGMA query_only = OFF;")?;
43    load_tables_into_sqlite(&mut connection, &manifest, tables.values())?;
44    connection.execute_batch("PRAGMA query_only = ON;")?;
45    Ok(connection)
46}
47
48fn run_read_only_query(connection: &Connection, sql: &str) -> Result<QueryResult> {
49    let mut statement = connection
50        .prepare(sql)
51        .with_context(|| "prepare SQL query")?;
52    if !statement.readonly() {
53        bail!("query must be read-only");
54    }
55
56    query_rows(&mut statement)
57}
58
59/// Open an MCD package from disk and run a read-only SQL query against its tables.
60pub fn query_path(path: impl AsRef<Path>, sql: &str) -> Result<QueryResult> {
61    let package = McdPackage::open_path(path)?;
62    query_package(&package, sql)
63}
64
65/// Open an MCD package from disk and run multiple read-only SQL queries.
66pub fn query_path_many(path: impl AsRef<Path>, queries: &[String]) -> Result<Vec<QueryResult>> {
67    let package = McdPackage::open_path(path)?;
68    query_package_many(&package, queries)
69}
70
71/// Structured result of an SQL query.
72#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
73pub struct QueryResult {
74    /// Result column names in query order.
75    pub columns: Vec<String>,
76    /// Result rows in column order.
77    pub rows: Vec<Vec<QueryValue>>,
78}
79
80impl QueryResult {
81    /// Number of returned rows.
82    #[must_use]
83    pub fn row_count(&self) -> usize {
84        self.rows.len()
85    }
86
87    /// Return result rows as JSON objects keyed by column name.
88    #[must_use]
89    pub fn rows_as_json(&self) -> JsonValue {
90        JsonValue::Array(
91            self.rows
92                .iter()
93                .map(|row| {
94                    let mut object = Map::new();
95                    for (index, value) in row.iter().enumerate() {
96                        object.insert(self.columns[index].clone(), value.as_json());
97                    }
98                    JsonValue::Object(object)
99                })
100                .collect(),
101        )
102    }
103
104    /// Return a JSON object with columns, rows, and row count.
105    #[must_use]
106    pub fn as_json(&self) -> JsonValue {
107        json!({
108            "columns": self.columns,
109            "rows": self.rows_as_json(),
110            "rowCount": self.row_count(),
111        })
112    }
113
114    /// Serialize the result as pretty JSON.
115    pub fn to_json_pretty(&self) -> Result<String> {
116        Ok(serde_json::to_string_pretty(&self.as_json())?)
117    }
118
119    /// Serialize the result as CSV.
120    #[must_use]
121    pub fn to_csv(&self) -> String {
122        let mut lines = vec![
123            self.columns
124                .iter()
125                .map(|cell| csv_escape(cell))
126                .collect::<Vec<_>>()
127                .join(","),
128        ];
129        for row in &self.rows {
130            lines.push(
131                row.iter()
132                    .map(|value| csv_escape(&value.display()))
133                    .collect::<Vec<_>>()
134                    .join(","),
135            );
136        }
137        lines.join("\n") + "\n"
138    }
139
140    /// Serialize the result as a simple ASCII table.
141    #[must_use]
142    pub fn to_table(&self) -> String {
143        let mut widths = self.columns.iter().map(String::len).collect::<Vec<_>>();
144        for row in &self.rows {
145            for (index, value) in row.iter().enumerate() {
146                widths[index] = widths[index].max(value.display().len());
147            }
148        }
149
150        let mut lines = Vec::new();
151        push_separator(&mut lines, &widths);
152        push_row(&mut lines, &self.columns, &widths);
153        push_separator(&mut lines, &widths);
154        for row in &self.rows {
155            let cells = row.iter().map(QueryValue::display).collect::<Vec<_>>();
156            push_row(&mut lines, &cells, &widths);
157        }
158        push_separator(&mut lines, &widths);
159        lines.push(format!("{} row(s)", self.rows.len()));
160        lines.join("\n") + "\n"
161    }
162}
163
164/// One SQL result cell value.
165#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
166#[serde(tag = "type", content = "value", rename_all = "snake_case")]
167pub enum QueryValue {
168    /// SQL NULL.
169    Null,
170    /// Signed integer value.
171    Integer(i64),
172    /// Floating point value.
173    Real(f64),
174    /// UTF-8 text value.
175    Text(String),
176    /// Binary blob value.
177    Blob(Vec<u8>),
178}
179
180impl QueryValue {
181    /// Convert this value to a plain JSON scalar.
182    #[must_use]
183    pub fn as_json(&self) -> JsonValue {
184        match self {
185            Self::Null => JsonValue::Null,
186            Self::Integer(value) => json!(value),
187            Self::Real(value) => json!(value),
188            Self::Text(value) => json!(value),
189            Self::Blob(value) => json!(value),
190        }
191    }
192
193    /// Return a display string suitable for table and CSV output.
194    #[must_use]
195    pub fn display(&self) -> String {
196        match self {
197            Self::Null => String::new(),
198            Self::Integer(value) => value.to_string(),
199            Self::Real(value) => value.to_string(),
200            Self::Text(value) => value.clone(),
201            Self::Blob(value) => format!("<{} bytes>", value.len()),
202        }
203    }
204}
205
206fn validate_read_only_sql(sql: &str) -> Result<()> {
207    let trimmed = sql.trim();
208    if trimmed.is_empty() {
209        bail!("SQL query cannot be empty");
210    }
211    let without_final_semicolon = trimmed.trim_end_matches(';').trim_end();
212    if without_final_semicolon.contains(';') {
213        bail!("query must contain exactly one SQL statement");
214    }
215
216    let lowercase = trimmed.to_ascii_lowercase();
217    if !(lowercase.starts_with("select") || lowercase.starts_with("with")) {
218        bail!("query must be a SELECT statement");
219    }
220    Ok(())
221}
222
223fn validate_query_batch(queries: &[String]) -> Result<()> {
224    if queries.is_empty() {
225        bail!("query batch cannot be empty");
226    }
227    for sql in queries {
228        validate_read_only_sql(sql)?;
229    }
230    Ok(())
231}
232
233const METADATA_TABLES: &[&str] = &[
234    "mcd_tables",
235    "mcd_columns",
236    "mcd_primary_keys",
237    "mcd_foreign_keys",
238    "mcd_units",
239];
240
241fn load_tables_into_sqlite<'a>(
242    connection: &mut Connection,
243    manifest: &Manifest,
244    tables: impl IntoIterator<Item = &'a mcd_core::tables::DataTable>,
245) -> Result<()> {
246    let tables = tables.into_iter().collect::<Vec<_>>();
247    reject_metadata_name_collisions(&tables)?;
248
249    connection.execute_batch("PRAGMA foreign_keys = ON;")?;
250    let transaction = connection.transaction()?;
251    create_metadata_tables(&transaction)?;
252    insert_metadata(&transaction, manifest, &tables)?;
253
254    for table in &tables {
255        create_data_table(&transaction, table)?;
256    }
257
258    for table in &tables {
259        let placeholders = std::iter::repeat_n("?", table.schema.columns.len())
260            .collect::<Vec<_>>()
261            .join(", ");
262        let insert_sql = format!(
263            "INSERT INTO {} VALUES ({placeholders})",
264            quote_identifier(&table.id)
265        );
266        let mut insert = transaction.prepare(&insert_sql)?;
267        for row in &table.rows {
268            let values = table
269                .schema
270                .columns
271                .iter()
272                .map(|column| {
273                    let value = row
274                        .cells
275                        .get(&column.name)
276                        .with_context(|| format!("missing cell '{}'", column.name))?;
277                    Ok(sqlite_value(value))
278                })
279                .collect::<Result<Vec<_>>>()?;
280            insert.execute(params_from_iter(values))?;
281        }
282    }
283    transaction.commit()?;
284    Ok(())
285}
286
287fn reject_metadata_name_collisions(tables: &[&DataTable]) -> Result<()> {
288    for table in tables {
289        if METADATA_TABLES.contains(&table.id.as_str()) {
290            bail!(
291                "table id '{}' is reserved for MCD SQL metadata introspection",
292                table.id
293            );
294        }
295    }
296    Ok(())
297}
298
299fn create_data_table(transaction: &rusqlite::Transaction<'_>, table: &DataTable) -> Result<()> {
300    let mut definitions = table
301        .schema
302        .columns
303        .iter()
304        .map(|column| {
305            format!(
306                "{} {}",
307                quote_identifier(&column.name),
308                sqlite_type(column.value_type)
309            )
310        })
311        .collect::<Vec<_>>();
312
313    if !table.schema.primary_key.is_empty() {
314        definitions.push(format!(
315            "PRIMARY KEY ({})",
316            quote_identifiers(&table.schema.primary_key)
317        ));
318    }
319
320    for foreign_key in &table.schema.foreign_keys {
321        definitions.push(format!(
322            "FOREIGN KEY ({}) REFERENCES {} ({})",
323            quote_identifiers(&foreign_key.columns),
324            quote_identifier(&foreign_key.references.table),
325            quote_identifiers(&foreign_key.references.columns)
326        ));
327    }
328
329    transaction.execute(
330        &format!(
331            "CREATE TABLE {} ({})",
332            quote_identifier(&table.id),
333            definitions.join(", ")
334        ),
335        [],
336    )?;
337    Ok(())
338}
339
340fn create_metadata_tables(transaction: &rusqlite::Transaction<'_>) -> Result<()> {
341    transaction.execute_batch(
342        r#"
343        CREATE TABLE mcd_tables (
344            table_id TEXT PRIMARY KEY,
345            data_path TEXT NOT NULL,
346            schema_path TEXT NOT NULL
347        );
348        CREATE TABLE mcd_columns (
349            table_id TEXT NOT NULL,
350            column_name TEXT NOT NULL,
351            ordinal INTEGER NOT NULL,
352            type TEXT NOT NULL,
353            label TEXT,
354            nullable INTEGER NOT NULL,
355            enum_values TEXT,
356            unit_code TEXT,
357            unit_label TEXT,
358            unit_custom INTEGER NOT NULL,
359            PRIMARY KEY (table_id, column_name)
360        );
361        CREATE TABLE mcd_primary_keys (
362            table_id TEXT NOT NULL,
363            column_name TEXT NOT NULL,
364            ordinal INTEGER NOT NULL,
365            PRIMARY KEY (table_id, ordinal)
366        );
367        CREATE TABLE mcd_foreign_keys (
368            table_id TEXT NOT NULL,
369            column_name TEXT NOT NULL,
370            ordinal INTEGER NOT NULL,
371            ref_table_id TEXT NOT NULL,
372            ref_column_name TEXT NOT NULL,
373            PRIMARY KEY (table_id, column_name, ref_table_id, ref_column_name)
374        );
375        CREATE TABLE mcd_units (
376            table_id TEXT NOT NULL,
377            column_name TEXT NOT NULL,
378            unit_code TEXT,
379            unit_label TEXT,
380            unit_custom INTEGER NOT NULL,
381            PRIMARY KEY (table_id, column_name)
382        );
383        "#,
384    )?;
385    Ok(())
386}
387
388fn insert_metadata(
389    transaction: &rusqlite::Transaction<'_>,
390    manifest: &Manifest,
391    tables: &[&DataTable],
392) -> Result<()> {
393    for table in tables {
394        let entry = manifest
395            .tables
396            .iter()
397            .find(|entry| entry.id == table.id)
398            .with_context(|| format!("missing manifest entry for table '{}'", table.id))?;
399        transaction.execute(
400            "INSERT INTO mcd_tables (table_id, data_path, schema_path) VALUES (?, ?, ?)",
401            params![entry.id, entry.data, entry.schema],
402        )?;
403
404        for (index, column) in table.schema.columns.iter().enumerate() {
405            insert_column_metadata(transaction, &table.id, index, column)?;
406        }
407
408        for (index, column) in table.schema.primary_key.iter().enumerate() {
409            transaction.execute(
410                "INSERT INTO mcd_primary_keys (table_id, column_name, ordinal) VALUES (?, ?, ?)",
411                params![table.id, column, index as i64 + 1],
412            )?;
413        }
414
415        for foreign_key in &table.schema.foreign_keys {
416            for (index, (column, referenced_column)) in foreign_key
417                .columns
418                .iter()
419                .zip(foreign_key.references.columns.iter())
420                .enumerate()
421            {
422                transaction.execute(
423                    "INSERT INTO mcd_foreign_keys (table_id, column_name, ordinal, ref_table_id, ref_column_name) VALUES (?, ?, ?, ?, ?)",
424                    params![
425                        table.id,
426                        column,
427                        index as i64 + 1,
428                        foreign_key.references.table,
429                        referenced_column
430                    ],
431                )?;
432            }
433        }
434    }
435    Ok(())
436}
437
438fn insert_column_metadata(
439    transaction: &rusqlite::Transaction<'_>,
440    table_id: &str,
441    index: usize,
442    column: &TableColumnSchema,
443) -> Result<()> {
444    let enum_values = if column.enum_values.is_empty() {
445        None
446    } else {
447        Some(serde_json::to_string(&column.enum_values)?)
448    };
449    let unit_code = column.unit.as_ref().and_then(|unit| unit.code.as_deref());
450    let unit_label = column.unit.as_ref().and_then(|unit| unit.label.as_deref());
451    let unit_custom = column.unit.as_ref().is_some_and(|unit| unit.custom);
452
453    transaction.execute(
454        "INSERT INTO mcd_columns (table_id, column_name, ordinal, type, label, nullable, enum_values, unit_code, unit_label, unit_custom) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
455        params![
456            table_id,
457            column.name,
458            index as i64 + 1,
459            column_type_name(column.value_type),
460            column.label,
461            i64::from(column.nullable),
462            enum_values,
463            unit_code,
464            unit_label,
465            i64::from(unit_custom),
466        ],
467    )?;
468
469    if column.unit.is_some() {
470        transaction.execute(
471            "INSERT INTO mcd_units (table_id, column_name, unit_code, unit_label, unit_custom) VALUES (?, ?, ?, ?, ?)",
472            params![table_id, column.name, unit_code, unit_label, i64::from(unit_custom)],
473        )?;
474    }
475    Ok(())
476}
477
478fn sqlite_type(column_type: ColumnType) -> &'static str {
479    match column_type {
480        ColumnType::Integer | ColumnType::Boolean => "INTEGER",
481        ColumnType::Decimal => "REAL",
482        ColumnType::String
483        | ColumnType::Date
484        | ColumnType::Datetime
485        | ColumnType::Time
486        | ColumnType::Enum => "TEXT",
487    }
488}
489
490fn column_type_name(column_type: ColumnType) -> &'static str {
491    match column_type {
492        ColumnType::String => "string",
493        ColumnType::Integer => "integer",
494        ColumnType::Decimal => "decimal",
495        ColumnType::Boolean => "boolean",
496        ColumnType::Date => "date",
497        ColumnType::Datetime => "datetime",
498        ColumnType::Time => "time",
499        ColumnType::Enum => "enum",
500    }
501}
502
503fn sqlite_value(value: &TypedValue) -> Value {
504    match value {
505        TypedValue::Null => Value::Null,
506        TypedValue::String(value)
507        | TypedValue::Decimal(value)
508        | TypedValue::Date(value)
509        | TypedValue::Datetime(value)
510        | TypedValue::Time(value)
511        | TypedValue::Enum(value) => Value::Text(value.clone()),
512        TypedValue::Integer(value) => Value::Integer(*value),
513        TypedValue::Boolean(value) => Value::Integer(i64::from(*value)),
514    }
515}
516
517fn quote_identifier(identifier: &str) -> String {
518    format!("\"{}\"", identifier.replace('"', "\"\""))
519}
520
521fn quote_identifiers(identifiers: &[String]) -> String {
522    identifiers
523        .iter()
524        .map(|identifier| quote_identifier(identifier))
525        .collect::<Vec<_>>()
526        .join(", ")
527}
528
529fn query_rows(statement: &mut rusqlite::Statement<'_>) -> Result<QueryResult> {
530    let columns = statement
531        .column_names()
532        .into_iter()
533        .map(str::to_owned)
534        .collect::<Vec<_>>();
535    let column_count = columns.len();
536    let mut rows = Vec::new();
537    let mut query = statement.query([])?;
538    while let Some(row) = query.next()? {
539        let mut values = Vec::with_capacity(column_count);
540        for index in 0..column_count {
541            values.push(query_value(row.get_ref(index)?));
542        }
543        rows.push(values);
544    }
545    Ok(QueryResult { columns, rows })
546}
547
548fn query_value(value: ValueRef<'_>) -> QueryValue {
549    match value {
550        ValueRef::Null => QueryValue::Null,
551        ValueRef::Integer(value) => QueryValue::Integer(value),
552        ValueRef::Real(value) => QueryValue::Real(value),
553        ValueRef::Text(value) => QueryValue::Text(String::from_utf8_lossy(value).into_owned()),
554        ValueRef::Blob(value) => QueryValue::Blob(value.to_vec()),
555    }
556}
557
558fn push_separator(lines: &mut Vec<String>, widths: &[usize]) {
559    let parts = widths
560        .iter()
561        .map(|width| "-".repeat(width + 2))
562        .collect::<Vec<_>>();
563    lines.push(format!("+{}+", parts.join("+")));
564}
565
566fn push_row(lines: &mut Vec<String>, cells: &[String], widths: &[usize]) {
567    let cells = cells
568        .iter()
569        .enumerate()
570        .map(|(index, cell)| format!(" {:width$} ", cell, width = widths[index]))
571        .collect::<Vec<_>>();
572    lines.push(format!("|{}|", cells.join("|")));
573}
574
575fn csv_escape(value: &str) -> String {
576    if value.contains([',', '"', '\n', '\r']) {
577        format!("\"{}\"", value.replace('"', "\"\""))
578    } else {
579        value.to_owned()
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use mcd_core::package::MCD_MIMETYPE;
586
587    use super::*;
588
589    #[test]
590    fn queries_aggregate_values() {
591        let package = package();
592        let result = query_package(
593            &package,
594            "select count(*) as rows, max(revenue_gbp) as max_revenue from revenue",
595        )
596        .expect("query succeeds");
597
598        assert_eq!(result.columns, ["rows", "max_revenue"]);
599        assert_eq!(result.row_count(), 1);
600        assert_eq!(result.rows[0][0], QueryValue::Integer(2));
601        assert_eq!(result.rows[0][1], QueryValue::Real(142500.0));
602    }
603
604    #[test]
605    fn runs_multiple_queries_against_one_package() {
606        let package = package();
607        let results = query_package_many(
608            &package,
609            &[
610                "select count(*) as rows from revenue".to_owned(),
611                "select quarter from revenue order by revenue_gbp desc limit 1".to_owned(),
612            ],
613        )
614        .expect("query batch succeeds");
615
616        assert_eq!(results.len(), 2);
617        assert_eq!(results[0].rows, vec![vec![QueryValue::Integer(2)]]);
618        assert_eq!(
619            results[1].rows,
620            vec![vec![QueryValue::Text("Q2".to_owned())]]
621        );
622    }
623
624    #[test]
625    fn rejects_writes() {
626        let package = package();
627        let err = query_package(&package, "delete from revenue").expect_err("write rejected");
628
629        assert!(err.to_string().contains("query must be a SELECT statement"));
630    }
631
632    #[test]
633    fn rejects_writes_in_query_batches() {
634        let package = package();
635        let err = query_package_many(
636            &package,
637            &[
638                "select count(*) as rows from revenue".to_owned(),
639                "delete from revenue".to_owned(),
640            ],
641        )
642        .expect_err("write rejected");
643
644        assert!(err.to_string().contains("query must be a SELECT statement"));
645    }
646
647    #[test]
648    fn exposes_mcd_schema_metadata_to_sql() {
649        let package = related_package();
650
651        let primary_keys = query_package(
652            &package,
653            "select table_id, column_name, ordinal from mcd_primary_keys order by table_id",
654        )
655        .expect("primary keys query succeeds");
656        assert_eq!(
657            primary_keys.rows,
658            vec![
659                vec![
660                    QueryValue::Text("customers".to_owned()),
661                    QueryValue::Text("customer_id".to_owned()),
662                    QueryValue::Integer(1),
663                ],
664                vec![
665                    QueryValue::Text("orders".to_owned()),
666                    QueryValue::Text("order_id".to_owned()),
667                    QueryValue::Integer(1),
668                ],
669            ]
670        );
671
672        let foreign_keys = query_package(
673            &package,
674            "select table_id, column_name, ref_table_id, ref_column_name from mcd_foreign_keys",
675        )
676        .expect("foreign keys query succeeds");
677        assert_eq!(
678            foreign_keys.rows,
679            vec![vec![
680                QueryValue::Text("orders".to_owned()),
681                QueryValue::Text("customer_id".to_owned()),
682                QueryValue::Text("customers".to_owned()),
683                QueryValue::Text("customer_id".to_owned()),
684            ]]
685        );
686
687        let units = query_package(
688            &package,
689            "select table_id, column_name, unit_code, unit_label from mcd_units",
690        )
691        .expect("units query succeeds");
692        assert_eq!(
693            units.rows,
694            vec![vec![
695                QueryValue::Text("orders".to_owned()),
696                QueryValue::Text("amount".to_owned()),
697                QueryValue::Text("GBP".to_owned()),
698                QueryValue::Text("GBP".to_owned()),
699            ]]
700        );
701    }
702
703    #[test]
704    fn creates_sqlite_key_constraints_for_pragma_introspection() {
705        let package = related_package();
706
707        let table_info = query_package(
708            &package,
709            "select name, pk from pragma_table_info('customers') where pk > 0",
710        )
711        .expect("pragma table_info query succeeds");
712        assert_eq!(
713            table_info.rows,
714            vec![vec![
715                QueryValue::Text("customer_id".to_owned()),
716                QueryValue::Integer(1),
717            ]]
718        );
719
720        let foreign_key_info = query_package(
721            &package,
722            "select [table], [from], [to] from pragma_foreign_key_list('orders')",
723        )
724        .expect("pragma foreign_key_list query succeeds");
725        assert_eq!(
726            foreign_key_info.rows,
727            vec![vec![
728                QueryValue::Text("customers".to_owned()),
729                QueryValue::Text("customer_id".to_owned()),
730                QueryValue::Text("customer_id".to_owned()),
731            ]]
732        );
733    }
734
735    fn package() -> McdPackage {
736        McdPackage::from_bytes(&zip_bytes(&[
737            ("mimetype", MCD_MIMETYPE),
738            (
739                "manifest.json",
740                r#"{"format":"MCD","version":"0.1","profile":"MCD-Core","entrypoint":"content/main.md","tables":[{"id":"revenue","data":"tables/revenue.csv","schema":"tables/revenue.schema.json"}]}"#,
741            ),
742            ("content/main.md", "# Report\n"),
743            (
744                "tables/revenue.schema.json",
745                r#"{"id":"revenue","columns":[{"name":"quarter","type":"string"},{"name":"revenue_gbp","type":"decimal"}]}"#,
746            ),
747            ("tables/revenue.csv", "quarter,revenue_gbp\nQ1,125000.00\nQ2,142500.00\n"),
748        ]))
749        .expect("package opens")
750    }
751
752    fn related_package() -> McdPackage {
753        McdPackage::from_bytes(&zip_bytes(&[
754            ("mimetype", MCD_MIMETYPE),
755            (
756                "manifest.json",
757                r#"{"format":"MCD","version":"0.1","profile":"MCD-Core","entrypoint":"content/main.md","tables":[
758                    {"id":"customers","data":"tables/customers.csv","schema":"tables/customers.schema.json"},
759                    {"id":"orders","data":"tables/orders.csv","schema":"tables/orders.schema.json"}
760                ]}"#,
761            ),
762            ("content/main.md", "# Orders\n"),
763            (
764                "tables/customers.schema.json",
765                r#"{"id":"customers","primaryKey":["customer_id"],"columns":[
766                    {"name":"customer_id","type":"string"},
767                    {"name":"name","type":"string"}
768                ]}"#,
769            ),
770            ("tables/customers.csv", "customer_id,name\nc1,Alice\n"),
771            (
772                "tables/orders.schema.json",
773                r#"{"id":"orders","primaryKey":["order_id"],"foreignKeys":[{
774                    "columns":["customer_id"],
775                    "references":{"table":"customers","columns":["customer_id"]}
776                }],"columns":[
777                    {"name":"order_id","type":"string"},
778                    {"name":"customer_id","type":"string"},
779                    {"name":"amount","type":"decimal","unit":{"code":"GBP","label":"GBP"}}
780                ]}"#,
781            ),
782            ("tables/orders.csv", "order_id,customer_id,amount\no1,c1,12.50\n"),
783        ]))
784        .expect("package opens")
785    }
786
787    fn zip_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
788        use std::io::{Cursor, Write};
789        use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
790
791        let cursor = Cursor::new(Vec::new());
792        let mut writer = ZipWriter::new(cursor);
793        let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
794
795        for (path, content) in entries {
796            writer.start_file(*path, options).expect("start file");
797            writer.write_all(content.as_bytes()).expect("write file");
798        }
799
800        writer.finish().expect("finish zip").into_inner()
801    }
802}