Skip to main content

dre_core/
lookups.rs

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