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"];
38pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Load {
68 Auto,
70 Inline,
71 TempTable,
72}
73
74#[derive(Debug, Clone)]
75pub struct Lookup {
76 pub name: String,
77 pub file: PathBuf,
79 pub types: BTreeMap<String, ColType>,
80 pub sheet: Option<String>,
81 pub load: Load,
82 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#[derive(Debug, Clone)]
98pub struct Table {
99 pub columns: Vec<String>,
100 pub types: Vec<ColType>,
101 pub rows: Vec<Vec<Cell>>,
102}
103
104pub 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 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 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
291type Raw = (Vec<String>, Vec<(String, Vec<Option<String>>)>);
293
294pub 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
441fn 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 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 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
601fn 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 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}