1use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22use std::sync::{Arc, LazyLock};
23
24use arrow::array::{ArrayRef, BooleanArray, Date32Array, Float64Array, Int64Array, StringArray};
25use arrow::datatypes::{DataType, Field, Schema};
26use arrow::record_batch::RecordBatch;
27use chrono::NaiveDate;
28use regex::Regex;
29use serde_json::Value as Json;
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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Load {
67 Auto,
69 Inline,
70 TempTable,
71}
72
73#[derive(Debug, Clone)]
74pub struct Lookup {
75 pub name: String,
76 pub file: PathBuf,
78 pub types: BTreeMap<String, ColType>,
79 pub sheet: Option<String>,
80 pub load: Load,
81 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#[derive(Debug, Clone)]
97pub struct Table {
98 pub columns: Vec<String>,
99 pub types: Vec<ColType>,
100 pub rows: Vec<Vec<Cell>>,
101}
102
103pub 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 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 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
290type Raw = (Vec<String>, Vec<(String, Vec<Option<String>>)>);
292
293pub 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
440fn 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 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 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
600fn 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 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}