1use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22use std::sync::{Arc, LazyLock};
23
24use arrow::array::{ArrayRef, BooleanArray, Date32Array, Float64Array, Int64Array, StringArray};
25use arrow::datatypes::{DataType, Field, Schema};
26use arrow::record_batch::RecordBatch;
27use chrono::NaiveDate;
28use regex::Regex;
29use serde_json::Value as Json;
30
31use crate::codes::Code;
32use crate::config::de::{self, Loose};
33use crate::config::lookup::{ColumnType, LoadName, LookupFile};
34use crate::diag::Diagnostics;
35use crate::yaml::YamlFile;
36
37pub const LOOKUPS_DIR: &str = "lookups";
38pub const DEFAULT_INLINE_MAX_ROWS: u64 = 200;
39const DATA_EXTS: &[&str] = &["csv", "xlsx", "xls", "json", "jsonl"];
40pub const CONFIG_KEYS: &[&str] = &["columns", "sheet", "load", "rows"];
42
43static IDENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").unwrap());
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ColType {
47 String,
48 Integer,
49 Number,
50 Boolean,
51 Date,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Load {
57 Auto,
59 Inline,
60 TempTable,
61}
62
63#[derive(Debug, Clone)]
64pub struct Lookup {
65 pub name: String,
66 pub file: PathBuf,
68 pub types: BTreeMap<String, ColType>,
69 pub sheet: Option<String>,
70 pub load: Load,
71 inline_rows: Option<Json>,
73}
74
75#[derive(Debug, Clone, PartialEq)]
76pub enum Cell {
77 Null,
78 Text(String),
79 Int(i64),
80 Num(f64),
81 Bool(bool),
82 Date(NaiveDate),
83}
84
85#[derive(Debug, Clone)]
87pub struct Table {
88 pub columns: Vec<String>,
89 pub types: Vec<ColType>,
90 pub rows: Vec<Vec<Cell>>,
91}
92
93pub fn discover(root: &Path, files: &[PathBuf], diags: &mut Diagnostics) -> BTreeMap<String, Lookup> {
95 let mut by_name: BTreeMap<String, Vec<&PathBuf>> = BTreeMap::new();
96 for f in files {
97 let stem = f.file_stem().unwrap_or_default().to_string_lossy().to_string();
98 by_name.entry(stem).or_default().push(f);
99 }
100 let mut out = BTreeMap::new();
101 for (name, paths) in by_name {
102 let ext = |p: &Path| {
103 p.extension()
104 .and_then(|e| e.to_str())
105 .unwrap_or("")
106 .to_ascii_lowercase()
107 };
108 let data: Vec<&&PathBuf> = paths
109 .iter()
110 .filter(|p| DATA_EXTS.contains(&ext(p).as_str()))
111 .collect();
112 let config: Vec<&&PathBuf> = paths
113 .iter()
114 .filter(|p| matches!(ext(p).as_str(), "yml" | "yaml"))
115 .collect();
116 if data.is_empty() && config.is_empty() {
117 continue;
118 }
119 if data.len() > 1 || config.len() > 1 {
120 diags.error(
121 Code::DuplicateLookup,
122 Some((*paths[1]).clone()),
123 None,
124 format!(
125 "lookup `{name}` is defined more than once: {}",
126 paths
127 .iter()
128 .map(|p| p.display().to_string())
129 .collect::<Vec<_>>()
130 .join(", ")
131 ),
132 );
133 continue;
134 }
135 if !IDENT.is_match(&name) {
136 diags.error(
137 Code::InvalidLookup,
138 Some((*paths[0]).clone()),
139 None,
140 format!("lookup name `{name}` must be letters, digits and `_`, not starting with a digit"),
141 );
142 continue;
143 }
144 let mut l = Lookup {
145 name: name.clone(),
146 file: data
147 .first()
148 .map_or_else(|| (**config[0]).clone(), |d| (***d).clone()),
149 types: BTreeMap::new(),
150 sheet: None,
151 load: Load::Auto,
152 inline_rows: None,
153 };
154 if let Some(cfg) = config.first() {
155 let Some(yf) = YamlFile::load(&root.join(cfg), (**cfg).clone(), diags) else {
156 continue;
157 };
158 if !parse_config(&yf, data.is_empty(), &mut l, diags) {
159 continue;
160 }
161 }
162 if data.is_empty() && l.inline_rows.is_none() {
163 let yf_rows = std::fs::read_to_string(root.join(&l.file))
165 .ok()
166 .and_then(|t| crate::config::node::parse(&t).ok())
167 .map(|n| n.to_json());
168 l.inline_rows = yf_rows;
169 }
170 out.insert(name, l);
171 }
172 out
173}
174
175fn parse_config(yf: &YamlFile, is_data: bool, l: &mut Lookup, diags: &mut Diagnostics) -> bool {
176 let file = Some(yf.display.clone());
177 let cfg = match de::from_node::<Loose<LookupFile>>(&yf.node) {
178 Ok(Loose::Ok(c)) => c,
179 Ok(Loose::Bad(f)) if is_data && f.kind == "a list" => return true,
181 _ => {
182 diags.error(
183 Code::InvalidLookup,
184 file,
185 None,
186 if is_data {
187 "a .yml lookup is a list of rows, or a map with `rows:` (and optional `columns`, `load`)"
188 } else {
189 "a lookup config must be a map (`columns`, `sheet`, `load`)"
190 },
191 );
192 return false;
193 }
194 };
195 let mut ok = true;
196 let mut unknown: Vec<(String, Option<usize>)> = cfg
197 .unknown
198 .0
199 .iter()
200 .map(|k| (k.name.clone(), Some(k.line)))
201 .collect();
202 if !is_data && let Some(r) = &cfg.rows {
203 unknown.push(("rows".into(), r.line()));
204 }
205 if is_data && let Some(s) = &cfg.sheet {
206 unknown.push(("sheet".into(), s.line()));
207 }
208 unknown.sort_by_key(|(_, line)| *line);
209 for (k, line) in unknown {
210 diags.error(
211 Code::InvalidLookup,
212 file.clone(),
213 line,
214 format!(
215 "unknown key `{k}` in lookup config (expected: columns, sheet, load{})",
216 if is_data { ", rows" } else { "" }
217 ),
218 );
219 ok = false;
220 }
221 if let Some(cols) = &cfg.columns {
222 match &cols.value {
223 Loose::Ok(cols) => {
224 for (c, t) in cols.iter() {
225 match t {
226 Loose::Ok(t) => {
227 l.types.insert(c.value.clone(), (*t).into());
228 }
229 Loose::Bad(f) if f.kind == "a string" => {
230 let t = yf
231 .node
232 .get("columns")
233 .and_then(|n| n.get(&c.value))
234 .and_then(|n| n.as_str())
235 .unwrap_or_default();
236 diags.error(
237 Code::InvalidLookup,
238 file.clone(),
239 c.line(),
240 format!("column `{}` has type `{t}`; use string, integer, number, boolean or date", c.value),
241 );
242 ok = false;
243 }
244 Loose::Bad(_) => {}
246 }
247 }
248 }
249 Loose::Bad(_) => {
250 diags.error(
251 Code::InvalidLookup,
252 file.clone(),
253 cols.line(),
254 "`columns` must map column names to types",
255 );
256 ok = false;
257 }
258 }
259 }
260 l.sheet = cfg.sheet.as_ref().and_then(|s| s.value.ok()).cloned();
261 if let Some(v) = &cfg.load {
262 l.load = match v.value {
263 Loose::Ok(LoadName::Auto) => Load::Auto,
264 Loose::Ok(LoadName::Inline) => Load::Inline,
265 Loose::Ok(LoadName::TempTable) => Load::TempTable,
266 Loose::Bad(_) => {
267 diags.error(
268 Code::InvalidLookup,
269 file.clone(),
270 v.line(),
271 "`load` must be auto, inline or temp_table",
272 );
273 ok = false;
274 Load::Auto
275 }
276 };
277 }
278 if is_data {
279 match cfg.rows {
280 Some(rows) => l.inline_rows = Some(rows.value),
281 None => {
282 diags.error(
283 Code::InvalidLookup,
284 file,
285 None,
286 "a .yml lookup written as a map needs `rows:`",
287 );
288 ok = false;
289 }
290 }
291 }
292 ok
293}
294
295impl From<ColumnType> for ColType {
296 fn from(t: ColumnType) -> ColType {
297 match t {
298 ColumnType::String => ColType::String,
299 ColumnType::Integer => ColType::Integer,
300 ColumnType::Number => ColType::Number,
301 ColumnType::Boolean => ColType::Boolean,
302 ColumnType::Date => ColType::Date,
303 }
304 }
305}
306
307type Raw = (Vec<String>, Vec<(String, Vec<Option<String>>)>);
309
310pub fn read(root: &Path, l: &Lookup) -> Result<Table, String> {
312 let path = root.join(&l.file);
313 let ext = l
314 .file
315 .extension()
316 .and_then(|e| e.to_str())
317 .unwrap_or("")
318 .to_ascii_lowercase();
319 let (columns, rows) = match ext.as_str() {
320 "csv" => read_csv(&path)?,
321 "xlsx" | "xls" => read_sheet(&path, l.sheet.as_deref())?,
322 "json" => {
323 let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
324 let v: Json = serde_json::from_str(&text).map_err(|e| format!("invalid JSON: {e}"))?;
325 read_objects(v.as_array().ok_or("a .json lookup must be an array of objects")?)?
326 }
327 "jsonl" => {
328 let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
329 let mut items = Vec::new();
330 for (i, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
331 items.push(
332 serde_json::from_str(line).map_err(|e| format!("line {}: invalid JSON: {e}", i + 1))?,
333 );
334 }
335 read_objects(&items)?
336 }
337 _ => match &l.inline_rows {
338 Some(Json::Array(items)) => read_objects(items)?,
339 _ => return Err("a .yml lookup's rows must be a list of maps".into()),
340 },
341 };
342 for c in &columns {
343 if !IDENT.is_match(c) {
344 return Err(format!(
345 "column `{c}`: names must be letters, digits and `_`, not starting with a digit, so SQL can use them unquoted"
346 ));
347 }
348 }
349 for c in l.types.keys() {
350 if !columns.contains(c) {
351 return Err(format!(
352 "the config types column `{c}`, which isn't in the data ({})",
353 columns.join(", ")
354 ));
355 }
356 }
357 let types: Vec<ColType> = columns
358 .iter()
359 .map(|c| l.types.get(c).copied().unwrap_or(ColType::String))
360 .collect();
361 let mut typed = Vec::with_capacity(rows.len());
362 for (label, row) in rows {
363 let mut cells = Vec::with_capacity(row.len());
364 for ((v, t), c) in row.into_iter().zip(&types).zip(&columns) {
365 cells.push(to_cell(v, *t).map_err(|e| format!("{label}, column `{c}`: {e}"))?);
366 }
367 typed.push(cells);
368 }
369 Ok(Table {
370 columns,
371 types,
372 rows: typed,
373 })
374}
375
376fn read_csv(path: &Path) -> Result<Raw, String> {
377 let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
378 let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
379 let mut r = csv::ReaderBuilder::new().from_reader(text.as_bytes());
380 let columns: Vec<String> = r
381 .headers()
382 .map_err(|e| e.to_string())?
383 .iter()
384 .map(|h| h.trim().to_string())
385 .collect();
386 let mut rows = Vec::new();
387 for rec in r.records() {
388 let rec = rec.map_err(|e| e.to_string())?;
389 let line = rec.position().map_or(0, |p| p.line());
390 rows.push((
391 format!("line {line}"),
392 rec.iter()
393 .map(|v| (!v.is_empty()).then(|| v.to_string()))
394 .collect(),
395 ));
396 }
397 Ok((columns, rows))
398}
399
400fn read_sheet(path: &Path, sheet: Option<&str>) -> Result<Raw, String> {
401 use calamine::{Data, Reader};
402 let mut wb = calamine::open_workbook_auto(path).map_err(|e| e.to_string())?;
403 let names = wb.sheet_names();
404 let name = match sheet {
405 Some(s) if names.iter().any(|n| n == s) => s.to_string(),
406 Some(s) => return Err(format!("no sheet `{s}` (sheets: {})", names.join(", "))),
407 None => names.first().cloned().ok_or("the workbook has no sheets")?,
408 };
409 let range = wb.worksheet_range(&name).map_err(|e| e.to_string())?;
410 let first_row = range.start().map_or(0, |(r, _)| r as usize);
411 let text = |d: &Data| -> Result<Option<String>, String> {
412 Ok(match d {
413 Data::Empty => None,
414 Data::String(s) if s.is_empty() => None,
415 Data::String(s) | Data::DateTimeIso(s) | Data::DurationIso(s) => Some(s.clone()),
416 Data::Int(i) => Some(i.to_string()),
417 Data::Float(f) if f.fract() == 0.0 && f.abs() < 1e15 => Some((*f as i64).to_string()),
418 Data::Float(f) => Some(f.to_string()),
419 Data::Bool(b) => Some(b.to_string()),
420 Data::DateTime(dt) => dt.as_datetime().map(|t| {
421 if t.time() == chrono::NaiveTime::MIN {
422 t.date().to_string()
423 } else {
424 t.format("%Y-%m-%d %H:%M:%S").to_string()
425 }
426 }),
427 Data::Error(e) => return Err(format!("cell error {e:?}")),
428 })
429 };
430 let mut it = range.rows().enumerate();
431 let Some((_, header)) = it.next() else {
432 return Ok((Vec::new(), Vec::new()));
433 };
434 let mut columns = Vec::new();
435 for d in header {
436 columns.push(text(d)?.unwrap_or_default().trim().to_string());
437 }
438 while columns.last().is_some_and(String::is_empty) {
439 columns.pop();
440 }
441 let mut rows = Vec::new();
442 for (i, r) in it {
443 let label = format!("row {}", first_row + i + 1);
444 let mut cells = Vec::new();
445 for d in r.iter().take(columns.len()) {
446 cells.push(text(d).map_err(|e| format!("{label}: {e}"))?);
447 }
448 cells.resize(columns.len(), None);
449 if cells.iter().all(Option::is_none) {
450 continue;
451 }
452 rows.push((label, cells));
453 }
454 Ok((columns, rows))
455}
456
457fn read_objects(items: &[Json]) -> Result<Raw, String> {
459 let mut columns: Vec<String> = Vec::new();
460 for (i, item) in items.iter().enumerate() {
461 let obj = item
462 .as_object()
463 .ok_or_else(|| format!("entry {}: each row must be an object", i + 1))?;
464 for k in obj.keys() {
465 if !columns.contains(k) {
466 columns.push(k.clone());
467 }
468 }
469 }
470 let mut rows = Vec::new();
471 for (i, item) in items.iter().enumerate() {
472 let obj = item.as_object().unwrap();
473 let label = format!("entry {}", i + 1);
474 let mut cells = Vec::new();
475 for c in &columns {
476 cells.push(match obj.get(c) {
477 None | Some(Json::Null) => None,
478 Some(Json::String(s)) if s.is_empty() => None,
479 Some(Json::String(s)) => Some(s.clone()),
480 Some(Json::Number(n)) => Some(n.to_string()),
481 Some(Json::Bool(b)) => Some(b.to_string()),
482 Some(_) => {
483 return Err(format!(
484 "{label}, column `{c}`: values must be plain, not lists or maps"
485 ));
486 }
487 });
488 }
489 rows.push((label, cells));
490 }
491 Ok((columns, rows))
492}
493
494fn to_cell(v: Option<String>, t: ColType) -> Result<Cell, String> {
495 let Some(v) = v else { return Ok(Cell::Null) };
496 let s = v.trim();
497 Ok(match t {
498 ColType::String => Cell::Text(v),
499 ColType::Integer => Cell::Int(s.parse().map_err(|_| format!("`{s}` isn't an integer"))?),
500 ColType::Number => {
501 let n: f64 = s.parse().map_err(|_| format!("`{s}` isn't a number"))?;
502 if !n.is_finite() {
503 return Err(format!("`{s}` isn't a finite number"));
504 }
505 Cell::Num(n)
506 }
507 ColType::Boolean => Cell::Bool(match s.to_ascii_lowercase().as_str() {
508 "true" | "yes" | "y" | "1" => true,
509 "false" | "no" | "n" | "0" => false,
510 _ => return Err(format!("`{s}` isn't a boolean (true/false)")),
511 }),
512 ColType::Date => Cell::Date(
513 NaiveDate::parse_from_str(s, "%Y-%m-%d")
514 .map_err(|_| format!("`{s}` isn't a date (YYYY-MM-DD)"))?,
515 ),
516 })
517}
518
519impl Table {
520 pub fn inline_sql(&self) -> String {
522 let cols = self.columns.join(", ");
523 if self.rows.is_empty() {
524 let nulls = vec!["NULL"; self.columns.len()].join(", ");
525 return format!("(select * from (values ({nulls})) as t({cols}) where 1 = 0)");
526 }
527 let rows: Vec<String> = self
528 .rows
529 .iter()
530 .map(|r| format!("({})", r.iter().map(literal).collect::<Vec<_>>().join(", ")))
531 .collect();
532 format!(
533 "(select * from (values\n {}\n) as t({cols}))",
534 rows.join(",\n ")
535 )
536 }
537
538 pub fn to_batch(&self) -> Result<RecordBatch, String> {
540 let mut fields = Vec::new();
541 let mut arrays: Vec<ArrayRef> = Vec::new();
542 for (i, (c, t)) in self.columns.iter().zip(&self.types).enumerate() {
543 let col = self.rows.iter().map(|r| &r[i]);
544 let (dt, a): (DataType, ArrayRef) = match t {
545 ColType::String => (
546 DataType::Utf8,
547 Arc::new(
548 col.map(|v| match v {
549 Cell::Text(s) => Some(s.as_str()),
550 _ => None,
551 })
552 .collect::<StringArray>(),
553 ),
554 ),
555 ColType::Integer => (
556 DataType::Int64,
557 Arc::new(
558 col.map(|v| match v {
559 Cell::Int(n) => Some(*n),
560 _ => None,
561 })
562 .collect::<Int64Array>(),
563 ),
564 ),
565 ColType::Number => (
566 DataType::Float64,
567 Arc::new(
568 col.map(|v| match v {
569 Cell::Num(n) => Some(*n),
570 _ => None,
571 })
572 .collect::<Float64Array>(),
573 ),
574 ),
575 ColType::Boolean => (
576 DataType::Boolean,
577 Arc::new(
578 col.map(|v| match v {
579 Cell::Bool(b) => Some(*b),
580 _ => None,
581 })
582 .collect::<BooleanArray>(),
583 ),
584 ),
585 ColType::Date => {
586 let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
587 (
588 DataType::Date32,
589 Arc::new(
590 col.map(|v| match v {
591 Cell::Date(d) => Some((*d - epoch).num_days() as i32),
592 _ => None,
593 })
594 .collect::<Date32Array>(),
595 ),
596 )
597 }
598 };
599 fields.push(Field::new(c, dt, true));
600 arrays.push(a);
601 }
602 RecordBatch::try_new(Arc::new(Schema::new(fields)), arrays).map_err(|e| e.to_string())
603 }
604}
605
606fn literal(c: &Cell) -> String {
607 match c {
608 Cell::Null => "NULL".into(),
609 Cell::Text(s) => text_literal(s),
610 Cell::Int(n) => n.to_string(),
611 Cell::Num(n) => format!("{n:?}"),
612 Cell::Bool(b) => b.to_string(),
613 Cell::Date(d) => format!("date '{d}'"),
614 }
615}
616
617fn text_literal(s: &str) -> String {
622 if !s.contains(['\'', '\\']) {
623 return format!("'{s}'");
624 }
625 let mut parts = Vec::new();
626 let mut run = String::new();
627 for ch in s.chars() {
628 let code = match ch {
629 '\'' => 39,
630 '\\' => 92,
631 _ => {
632 run.push(ch);
633 continue;
634 }
635 };
636 if !run.is_empty() {
637 parts.push(format!("'{}'", std::mem::take(&mut run)));
638 }
639 parts.push(format!("chr({code})"));
640 }
641 if !run.is_empty() {
642 parts.push(format!("'{run}'"));
643 }
644 format!("({})", parts.join(" || "))
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 #[test]
652 fn inlined_text_never_relies_on_a_dialects_escapes() {
653 assert_eq!(text_literal("plain"), "'plain'");
655 assert_eq!(
656 text_literal("a\\b'c"),
657 "('a' || chr(92) || 'b' || chr(39) || 'c')"
658 );
659 assert_eq!(text_literal("'"), "(chr(39))");
660 }
661
662 fn lookup(dir: &Path, file: &str, body: &str, cfg: Option<&str>) -> (Lookup, Diagnostics) {
663 std::fs::create_dir_all(dir.join("lookups")).unwrap();
664 std::fs::write(dir.join("lookups").join(file), body).unwrap();
665 let mut files = vec![PathBuf::from("lookups").join(file)];
666 if let Some(c) = cfg {
667 let stem = Path::new(file).file_stem().unwrap().to_string_lossy().to_string();
668 std::fs::write(dir.join("lookups").join(format!("{stem}.yml")), c).unwrap();
669 files.push(PathBuf::from("lookups").join(format!("{stem}.yml")));
670 }
671 let mut d = Diagnostics::default();
672 let mut found = discover(dir, &files, &mut d);
673 (found.pop_first().map(|(_, l)| l).expect("a lookup"), d)
674 }
675
676 #[test]
677 fn csv_is_all_text_unless_typed() {
678 let dir = tempfile::tempdir().unwrap();
679 let (l, _) = lookup(
680 dir.path(),
681 "c.csv",
682 "\u{feff}code,name,pop\nAU,\"O'Brien, Aus\",27\nNZ,,5\n",
683 Some("columns: {pop: integer}\n"),
684 );
685 let t = read(dir.path(), &l).unwrap();
686 assert_eq!(t.columns, ["code", "name", "pop"]);
687 assert_eq!(
688 t.inline_sql(),
689 "(select * from (values\n ('AU', ('O' || chr(39) || 'Brien, Aus'), 27),\n ('NZ', NULL, 5)\n) as t(code, name, pop))"
690 );
691 assert_eq!(t.to_batch().unwrap().num_rows(), 2);
692 }
693
694 #[test]
695 fn json_jsonl_and_yml_rows_are_objects() {
696 let dir = tempfile::tempdir().unwrap();
697 let (l, _) = lookup(
698 dir.path(),
699 "j.json",
700 r#"[{"a": 1, "b": true}, {"a": 2, "c": "x"}]"#,
701 None,
702 );
703 let t = read(dir.path(), &l).unwrap();
704 assert_eq!(t.columns, ["a", "b", "c"]);
705 assert_eq!(
706 t.rows[1],
707 [Cell::Text("2".into()), Cell::Null, Cell::Text("x".into())]
708 );
709
710 let (l, _) = lookup(dir.path(), "k.jsonl", "{\"a\": 1}\n\n{\"a\": 2}\n", None);
711 assert_eq!(read(dir.path(), &l).unwrap().rows.len(), 2);
712
713 let (l, d) = lookup(
714 dir.path(),
715 "y.yml",
716 "columns: {d: date, ok: boolean}\nrows:\n - {d: 2026-01-02, ok: yes}\n",
717 None,
718 );
719 assert!(!d.has_errors());
720 let t = read(dir.path(), &l).unwrap();
721 assert_eq!(
722 t.inline_sql(),
723 "(select * from (values\n (date '2026-01-02', true)\n) as t(d, ok))"
724 );
725 }
726
727 #[test]
728 fn problems_name_the_row_and_column() {
729 let dir = tempfile::tempdir().unwrap();
730 let (l, _) = lookup(
731 dir.path(),
732 "c.csv",
733 "code,pop\nAU,many\n",
734 Some("columns: {pop: integer}\n"),
735 );
736 assert_eq!(
737 read(dir.path(), &l).unwrap_err(),
738 "line 2, column `pop`: `many` isn't an integer"
739 );
740 let (l, _) = lookup(dir.path(), "d.csv", "country code\nAU\n", None);
741 assert!(
742 read(dir.path(), &l)
743 .unwrap_err()
744 .starts_with("column `country code`")
745 );
746 std::fs::write(dir.path().join("lookups/e.csv"), "a\n1\n").unwrap();
747 std::fs::write(
748 dir.path().join("lookups/e.yml"),
749 "columns: {a: int}\nlod: inline\n",
750 )
751 .unwrap();
752 let mut d = Diagnostics::default();
753 let files = [PathBuf::from("lookups/e.csv"), PathBuf::from("lookups/e.yml")];
754 assert!(discover(dir.path(), &files, &mut d).is_empty());
755 assert_eq!(d.error_count(), 2);
756 }
757
758 #[test]
759 fn an_empty_lookup_still_has_its_columns() {
760 let dir = tempfile::tempdir().unwrap();
761 let (l, _) = lookup(dir.path(), "c.csv", "a,b\n", None);
762 assert_eq!(
763 read(dir.path(), &l).unwrap().inline_sql(),
764 "(select * from (values (NULL, NULL)) as t(a, b) where 1 = 0)"
765 );
766 }
767}