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
519/// How an inlined lookup spells a string holding `'` or `\`, as the source plugin's `describe`
520/// reply asks (`string_literal`).
521#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
522pub enum StringLiteral {
523    /// `chr(39)`/`chr(92)` joined with `||`: the default, read the same by DuckDB, Postgres and
524    /// Databricks.
525    #[default]
526    Chr,
527    /// `\'` and `\\` inside the quotes: for a database without `chr()` (ClickHouse).
528    Backslash,
529}
530
531impl StringLiteral {
532    /// The style a `describe` reply's `string_literal` names; absent is [`StringLiteral::Chr`].
533    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    /// `(select * from (values ...) as t(cols))`, with text spelled as the source asks.
546    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    /// The rows as one Arrow batch, for loading into a temp table.
569    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
647/// A string literal. By default quotes and backslashes are spelled `chr(39)`/`chr(92)`, which
648/// DuckDB, Postgres and Databricks read the same way: doubling a quote works in Postgres and
649/// DuckDB but Spark SQL reads `'a''b'` as two adjacent literals, and Spark treats backslash as an
650/// escape where Postgres doesn't. A source without `chr()` asks for backslash escapes.
651fn 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        // ClickHouse has no chr(); it reads \' and \\ inside a literal.
687        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        // Portable on DuckDB, Postgres and Databricks: no '' and no \'.
702        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}