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
519#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
522pub enum StringLiteral {
523 #[default]
526 Chr,
527 Backslash,
529}
530
531impl StringLiteral {
532 pub fn from_describe(v: Option<&str>) -> Result<StringLiteral, String> {
534 match v {
535 None | Some("chr") => Ok(StringLiteral::Chr),
536 Some("backslash") => Ok(StringLiteral::Backslash),
537 Some(other) => Err(format!(
538 "the source plugin asks for string literals spelled `{other}`, which this DRE doesn't know; update DRE"
539 )),
540 }
541 }
542}
543
544impl Table {
545 pub fn inline_sql(&self, style: StringLiteral) -> String {
547 let cols = self.columns.join(", ");
548 if self.rows.is_empty() {
549 let nulls = vec!["NULL"; self.columns.len()].join(", ");
550 return format!("(select * from (values ({nulls})) as t({cols}) where 1 = 0)");
551 }
552 let rows: Vec<String> = self
553 .rows
554 .iter()
555 .map(|r| {
556 format!(
557 "({})",
558 r.iter().map(|c| literal(c, style)).collect::<Vec<_>>().join(", ")
559 )
560 })
561 .collect();
562 format!(
563 "(select * from (values\n {}\n) as t({cols}))",
564 rows.join(",\n ")
565 )
566 }
567
568 pub fn to_batch(&self) -> Result<RecordBatch, String> {
570 let mut fields = Vec::new();
571 let mut arrays: Vec<ArrayRef> = Vec::new();
572 for (i, (c, t)) in self.columns.iter().zip(&self.types).enumerate() {
573 let col = self.rows.iter().map(|r| &r[i]);
574 let (dt, a): (DataType, ArrayRef) = match t {
575 ColType::String => (
576 DataType::Utf8,
577 Arc::new(
578 col.map(|v| match v {
579 Cell::Text(s) => Some(s.as_str()),
580 _ => None,
581 })
582 .collect::<StringArray>(),
583 ),
584 ),
585 ColType::Integer => (
586 DataType::Int64,
587 Arc::new(
588 col.map(|v| match v {
589 Cell::Int(n) => Some(*n),
590 _ => None,
591 })
592 .collect::<Int64Array>(),
593 ),
594 ),
595 ColType::Number => (
596 DataType::Float64,
597 Arc::new(
598 col.map(|v| match v {
599 Cell::Num(n) => Some(*n),
600 _ => None,
601 })
602 .collect::<Float64Array>(),
603 ),
604 ),
605 ColType::Boolean => (
606 DataType::Boolean,
607 Arc::new(
608 col.map(|v| match v {
609 Cell::Bool(b) => Some(*b),
610 _ => None,
611 })
612 .collect::<BooleanArray>(),
613 ),
614 ),
615 ColType::Date => {
616 let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
617 (
618 DataType::Date32,
619 Arc::new(
620 col.map(|v| match v {
621 Cell::Date(d) => Some((*d - epoch).num_days() as i32),
622 _ => None,
623 })
624 .collect::<Date32Array>(),
625 ),
626 )
627 }
628 };
629 fields.push(Field::new(c, dt, true));
630 arrays.push(a);
631 }
632 RecordBatch::try_new(Arc::new(Schema::new(fields)), arrays).map_err(|e| e.to_string())
633 }
634}
635
636fn literal(c: &Cell, style: StringLiteral) -> String {
637 match c {
638 Cell::Null => "NULL".into(),
639 Cell::Text(s) => text_literal(s, style),
640 Cell::Int(n) => n.to_string(),
641 Cell::Num(n) => format!("{n:?}"),
642 Cell::Bool(b) => b.to_string(),
643 Cell::Date(d) => format!("date '{d}'"),
644 }
645}
646
647fn text_literal(s: &str, style: StringLiteral) -> String {
652 if !s.contains(['\'', '\\']) {
653 return format!("'{s}'");
654 }
655 if style == StringLiteral::Backslash {
656 return format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"));
657 }
658 let mut parts = Vec::new();
659 let mut run = String::new();
660 for ch in s.chars() {
661 let code = match ch {
662 '\'' => 39,
663 '\\' => 92,
664 _ => {
665 run.push(ch);
666 continue;
667 }
668 };
669 if !run.is_empty() {
670 parts.push(format!("'{}'", std::mem::take(&mut run)));
671 }
672 parts.push(format!("chr({code})"));
673 }
674 if !run.is_empty() {
675 parts.push(format!("'{run}'"));
676 }
677 format!("({})", parts.join(" || "))
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683
684 #[test]
685 fn a_source_that_asks_for_backslash_escapes_gets_them() {
686 let b = StringLiteral::Backslash;
688 assert_eq!(text_literal("plain", b), "'plain'");
689 assert_eq!(text_literal("a\\b'c", b), "'a\\\\b\\'c'");
690 assert_eq!(StringLiteral::from_describe(None).unwrap(), StringLiteral::Chr);
691 assert_eq!(
692 StringLiteral::from_describe(Some("backslash")).unwrap(),
693 StringLiteral::Backslash
694 );
695 let e = StringLiteral::from_describe(Some("hex")).unwrap_err();
696 assert!(e.contains("`hex`") && e.contains("update DRE"), "{e}");
697 }
698
699 #[test]
700 fn inlined_text_never_relies_on_a_dialects_escapes() {
701 let c = StringLiteral::Chr;
703 assert_eq!(text_literal("plain", c), "'plain'");
704 assert_eq!(
705 text_literal("a\\b'c", c),
706 "('a' || chr(92) || 'b' || chr(39) || 'c')"
707 );
708 assert_eq!(text_literal("'", c), "(chr(39))");
709 }
710
711 fn lookup(dir: &Path, file: &str, body: &str, cfg: Option<&str>) -> (Lookup, Diagnostics) {
712 std::fs::create_dir_all(dir.join("lookups")).unwrap();
713 std::fs::write(dir.join("lookups").join(file), body).unwrap();
714 let mut files = vec![PathBuf::from("lookups").join(file)];
715 if let Some(c) = cfg {
716 let stem = Path::new(file).file_stem().unwrap().to_string_lossy().to_string();
717 std::fs::write(dir.join("lookups").join(format!("{stem}.yml")), c).unwrap();
718 files.push(PathBuf::from("lookups").join(format!("{stem}.yml")));
719 }
720 let mut d = Diagnostics::default();
721 let mut found = discover(dir, &files, &mut d);
722 (found.pop_first().map(|(_, l)| l).expect("a lookup"), d)
723 }
724
725 #[test]
726 fn csv_is_all_text_unless_typed() {
727 let dir = tempfile::tempdir().unwrap();
728 let (l, _) = lookup(
729 dir.path(),
730 "c.csv",
731 "\u{feff}code,name,pop\nAU,\"O'Brien, Aus\",27\nNZ,,5\n",
732 Some("columns: {pop: integer}\n"),
733 );
734 let t = read(dir.path(), &l).unwrap();
735 assert_eq!(t.columns, ["code", "name", "pop"]);
736 assert_eq!(
737 t.inline_sql(StringLiteral::Chr),
738 "(select * from (values\n ('AU', ('O' || chr(39) || 'Brien, Aus'), 27),\n ('NZ', NULL, 5)\n) as t(code, name, pop))"
739 );
740 assert_eq!(t.to_batch().unwrap().num_rows(), 2);
741 }
742
743 #[test]
744 fn json_jsonl_and_yml_rows_are_objects() {
745 let dir = tempfile::tempdir().unwrap();
746 let (l, _) = lookup(
747 dir.path(),
748 "j.json",
749 r#"[{"a": 1, "b": true}, {"a": 2, "c": "x"}]"#,
750 None,
751 );
752 let t = read(dir.path(), &l).unwrap();
753 assert_eq!(t.columns, ["a", "b", "c"]);
754 assert_eq!(
755 t.rows[1],
756 [Cell::Text("2".into()), Cell::Null, Cell::Text("x".into())]
757 );
758
759 let (l, _) = lookup(dir.path(), "k.jsonl", "{\"a\": 1}\n\n{\"a\": 2}\n", None);
760 assert_eq!(read(dir.path(), &l).unwrap().rows.len(), 2);
761
762 let (l, d) = lookup(
763 dir.path(),
764 "y.yml",
765 "columns: {d: date, ok: boolean}\nrows:\n - {d: 2026-01-02, ok: yes}\n",
766 None,
767 );
768 assert!(!d.has_errors());
769 let t = read(dir.path(), &l).unwrap();
770 assert_eq!(
771 t.inline_sql(StringLiteral::Chr),
772 "(select * from (values\n (date '2026-01-02', true)\n) as t(d, ok))"
773 );
774 }
775
776 #[test]
777 fn problems_name_the_row_and_column() {
778 let dir = tempfile::tempdir().unwrap();
779 let (l, _) = lookup(
780 dir.path(),
781 "c.csv",
782 "code,pop\nAU,many\n",
783 Some("columns: {pop: integer}\n"),
784 );
785 assert_eq!(
786 read(dir.path(), &l).unwrap_err(),
787 "line 2, column `pop`: `many` isn't an integer"
788 );
789 let (l, _) = lookup(dir.path(), "d.csv", "country code\nAU\n", None);
790 assert!(
791 read(dir.path(), &l)
792 .unwrap_err()
793 .starts_with("column `country code`")
794 );
795 std::fs::write(dir.path().join("lookups/e.csv"), "a\n1\n").unwrap();
796 std::fs::write(
797 dir.path().join("lookups/e.yml"),
798 "columns: {a: int}\nlod: inline\n",
799 )
800 .unwrap();
801 let mut d = Diagnostics::default();
802 let files = [PathBuf::from("lookups/e.csv"), PathBuf::from("lookups/e.yml")];
803 assert!(discover(dir.path(), &files, &mut d).is_empty());
804 assert_eq!(d.error_count(), 2);
805 }
806
807 #[test]
808 fn an_empty_lookup_still_has_its_columns() {
809 let dir = tempfile::tempdir().unwrap();
810 let (l, _) = lookup(dir.path(), "c.csv", "a,b\n", None);
811 assert_eq!(
812 read(dir.path(), &l).unwrap().inline_sql(StringLiteral::Chr),
813 "(select * from (values (NULL, NULL)) as t(a, b) where 1 = 0)"
814 );
815 }
816}