1use 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
18pub 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
25pub 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
59pub 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
65pub 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
73pub struct QueryResult {
74 pub columns: Vec<String>,
76 pub rows: Vec<Vec<QueryValue>>,
78}
79
80impl QueryResult {
81 #[must_use]
83 pub fn row_count(&self) -> usize {
84 self.rows.len()
85 }
86
87 #[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 #[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 pub fn to_json_pretty(&self) -> Result<String> {
116 Ok(serde_json::to_string_pretty(&self.as_json())?)
117 }
118
119 #[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 #[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
166#[serde(tag = "type", content = "value", rename_all = "snake_case")]
167pub enum QueryValue {
168 Null,
170 Integer(i64),
172 Real(f64),
174 Text(String),
176 Blob(Vec<u8>),
178}
179
180impl QueryValue {
181 #[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 #[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}