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