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;
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"];
40/// Keys of a lookup config file.
41pub 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/// How `ref()` hands a lookup to SQL.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Load {
57    /// Inline up to `lookup_inline_max_rows` rows, load into a temp table above that.
58    Auto,
59    Inline,
60    TempTable,
61}
62
63#[derive(Debug, Clone)]
64pub struct Lookup {
65    pub name: String,
66    /// The data file, relative to the project root.
67    pub file: PathBuf,
68    pub types: BTreeMap<String, ColType>,
69    pub sheet: Option<String>,
70    pub load: Load,
71    /// A `.yml` lookup's rows, parsed with its config.
72    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/// A lookup's rows, typed.
86#[derive(Debug, Clone)]
87pub struct Table {
88    pub columns: Vec<String>,
89    pub types: Vec<ColType>,
90    pub rows: Vec<Vec<Cell>>,
91}
92
93/// Find every lookup among the files under `lookups/` (relative paths) and parse their config.
94pub 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            // A bare list of rows.
164            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        // A `.yml` lookup may be just a list of rows.
180        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                        // Not a type name at all: read as text.
245                        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
307/// Raw rows: column names, then text cells (`None` for empty), with each row's label for errors.
308type Raw = (Vec<String>, Vec<(String, Vec<Option<String>>)>);
309
310/// Read and type a lookup's rows.
311pub 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
457/// Rows given as objects (json, jsonl, yml): columns are every key, in first-seen order.
458fn 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    /// `(select * from (values ...) as t(cols))`: portable across DuckDB, Postgres and Databricks.
521    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    /// The rows as one Arrow batch, for loading into a temp table.
539    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
617/// A string literal every supported dialect reads the same way. Quotes and backslashes are
618/// spelled `chr(39)`/`chr(92)`: doubling a quote works in Postgres and DuckDB but Spark SQL reads
619/// `'a''b'` as two adjacent literals, and Spark treats backslash as an escape where Postgres
620/// doesn't.
621fn 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        // Portable on DuckDB, Postgres and Databricks: no '' and no \'.
654        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}