Skip to main content

ursa_engine/
read_functions.rs

1//! `read_parquet(path)`, `read_csv(path, header => false)` and
2//! `read_json(path)`: files as tables, as in DuckDB.
3//!
4//! Each is a thin wrapper over the path: `FROM read_csv('edges.csv')` reads
5//! the same table as `FROM 'edges.csv'` ([`crate::file_table`]), so a relative
6//! path, a glob, a directory or a URL behaves the same, and a graph's index
7//! cache knows the files by the same identity. What the function adds is the
8//! format, whatever the extension, and a few options:
9//!
10//! - `read_csv`: `header`, `delim` (or `sep` or `delimiter`), `quote`,
11//!   `compression`;
12//! - `read_json`: `compression`, and `format => 'array'` for a file holding
13//!   one JSON array of objects rather than one object per line;
14//! - `read_parquet`: none.
15//!
16//! The first argument is a path or a list of paths (`['a.csv', 'b.csv']`).
17//! Options are named, `name => value` as Ursa's graph functions take them,
18//! or `name = value` as DuckDB writes them. `read_csv_auto`, `read_json_auto`
19//! and `read_ndjson` are DuckDB's other names for the same functions.
20//!
21//! ## Opened before planning
22//!
23//! Opening files is async (it lists them and infers a schema), and a relation
24//! planner is not. So [`open_reads`] walks a statement before it is planned,
25//! opens every `read_*` call in it, and keeps each table under the call's
26//! text in the session's [`ReadTables`]; [`ReadFunctions`], the relation
27//! planner, then finds the table there by the same text. Ursa's SQL layer
28//! (`ursa_sql::execute`) does this for every statement, so a call in a view,
29//! a subquery, a CTE, a `COPY` or an `EXPLAIN` is opened too.
30
31use std::collections::HashMap;
32use std::ops::ControlFlow;
33use std::sync::Arc;
34
35use datafusion::catalog::default_table_source::provider_as_source;
36use datafusion::catalog::TableProvider;
37use datafusion::common::parsers::CompressionTypeVariant;
38use datafusion::error::{DataFusionError, Result};
39use datafusion::execution::context::SessionState;
40use datafusion::logical_expr::planner::{
41    PlannedRelation, RelationPlanner, RelationPlannerContext, RelationPlanning,
42};
43use datafusion::logical_expr::LogicalPlanBuilder;
44use datafusion::prelude::SessionConfig;
45use datafusion::sql::parser::{CopyToSource, Statement as DFStatement};
46use datafusion::sql::sqlparser::ast::{
47    BinaryOperator, Expr, FunctionArg, FunctionArgExpr, Ident, ObjectName, TableFactor,
48    TableFunctionArgs, Value, Visit, Visitor,
49};
50use parking_lot::Mutex;
51
52use crate::file_table::{open_files, FileFormat, FileOptions};
53use crate::graph::closest_name;
54
55/// The functions, by name: what each reads, and what it fixes.
56const FUNCTIONS: [(&str, Function); 6] = [
57    ("read_parquet", Function::Parquet),
58    ("read_csv", Function::Csv),
59    ("read_csv_auto", Function::Csv),
60    ("read_json", Function::Json),
61    ("read_json_auto", Function::Json),
62    ("read_ndjson", Function::NdJson),
63];
64
65/// The names of the `read_*` functions.
66pub fn names() -> impl Iterator<Item = &'static str> {
67    FUNCTIONS.iter().map(|(name, _)| *name)
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71enum Function {
72    Parquet,
73    Csv,
74    Json,
75    /// `read_ndjson`: JSON, one object per line.
76    NdJson,
77}
78
79impl Function {
80    fn lookup(name: &str) -> Option<Self> {
81        FUNCTIONS.iter().find(|(n, _)| *n == name).map(|(_, f)| *f)
82    }
83
84    /// The options the function takes.
85    fn options(self) -> &'static [&'static str] {
86        match self {
87            Function::Parquet => &[],
88            Function::Csv => &[
89                "header",
90                "delim",
91                "sep",
92                "delimiter",
93                "quote",
94                "compression",
95            ],
96            Function::Json => &["compression", "format"],
97            Function::NdJson => &["compression"],
98        }
99    }
100
101    fn format(self) -> FileFormat {
102        match self {
103            Function::Parquet => FileFormat::Parquet,
104            Function::Csv => FileFormat::Csv,
105            Function::Json | Function::NdJson => FileFormat::Json,
106        }
107    }
108}
109
110/// One `read_*` call: the locations it reads and how.
111#[derive(Debug, Clone, PartialEq, Eq)]
112struct ReadCall {
113    locations: Vec<String>,
114    options: FileOptions,
115}
116
117/// The tables of the `read_*` calls in the statements being planned, by the
118/// call's text, with how many statements are using each.
119#[derive(Debug, Default)]
120pub struct ReadTables {
121    tables: Mutex<HashMap<String, Shared>>,
122}
123
124/// A call's table, and how many statements being planned use it.
125type Shared = (Arc<dyn TableProvider>, usize);
126
127/// `config` with a place for the tables of `read_*` calls.
128pub(crate) fn with_read_tables(config: SessionConfig) -> SessionConfig {
129    config.with_extension(Arc::new(ReadTables::default()))
130}
131
132fn read_tables(config: &SessionConfig) -> Result<Arc<ReadTables>> {
133    config
134        .get_extension::<ReadTables>()
135        .ok_or_else(|| DataFusionError::Internal("the session has no read_* tables".into()))
136}
137
138/// The tables [`open_reads`] opened for one statement. Dropping it forgets
139/// them, once the statement is planned.
140#[must_use = "the tables are forgotten when this is dropped"]
141pub struct OpenReads {
142    tables: Option<Arc<ReadTables>>,
143    keys: Vec<String>,
144}
145
146impl Drop for OpenReads {
147    fn drop(&mut self) {
148        let Some(tables) = &self.tables else {
149            return;
150        };
151        let mut tables = tables.tables.lock();
152        for key in &self.keys {
153            if let Some((_, users)) = tables.get_mut(key) {
154                *users -= 1;
155                if *users == 0 {
156                    tables.remove(key);
157                }
158            }
159        }
160    }
161}
162
163/// Open the table of every `read_*` call in `statement`, for [`ReadFunctions`]
164/// to plan. Keep the result until the statement is planned.
165pub async fn open_reads(state: &SessionState, statement: &DFStatement) -> Result<OpenReads> {
166    let mut calls = Calls::default();
167    collect(statement, &mut calls)?;
168    if calls.calls.is_empty() {
169        return Ok(OpenReads {
170            tables: None,
171            keys: Vec::new(),
172        });
173    }
174    let tables = read_tables(state.config())?;
175    let mut open = OpenReads {
176        tables: Some(Arc::clone(&tables)),
177        keys: Vec::new(),
178    };
179    for (key, call) in calls.calls {
180        let table = open_files(state, &call.locations, &call.options).await?;
181        let mut tables = tables.tables.lock();
182        tables
183            .entry(key.clone())
184            .and_modify(|(_, users)| *users += 1)
185            .or_insert((table, 1));
186        open.keys.push(key);
187    }
188    Ok(open)
189}
190
191/// The `read_*` calls in a statement, by their text.
192#[derive(Default)]
193struct Calls {
194    calls: Vec<(String, ReadCall)>,
195}
196
197impl Visitor for Calls {
198    type Break = DataFusionError;
199
200    fn pre_visit_table_factor(&mut self, factor: &TableFactor) -> ControlFlow<Self::Break> {
201        let TableFactor::Table {
202            name,
203            args: Some(args),
204            ..
205        } = factor
206        else {
207            return ControlFlow::Continue(());
208        };
209        let Some(function) = function_name(name, |ident| default_normalize(ident.clone())) else {
210            return ControlFlow::Continue(());
211        };
212        let Some(f) = Function::lookup(&function) else {
213            return ControlFlow::Continue(());
214        };
215        let key = call_key(name, args);
216        if self.calls.iter().any(|(k, _)| *k == key) {
217            return ControlFlow::Continue(());
218        }
219        match parse_call(&function, f, args) {
220            Ok(call) => {
221                self.calls.push((key, call));
222                ControlFlow::Continue(())
223            }
224            Err(e) => ControlFlow::Break(e),
225        }
226    }
227}
228
229fn collect(statement: &DFStatement, calls: &mut Calls) -> Result<()> {
230    let flow = match statement {
231        DFStatement::Statement(s) => s.visit(calls),
232        DFStatement::CopyTo(copy) => match &copy.source {
233            CopyToSource::Query(query) => query.visit(calls),
234            CopyToSource::Relation(_) => ControlFlow::Continue(()),
235        },
236        DFStatement::Explain(explain) => return collect(&explain.statement, calls),
237        _ => ControlFlow::Continue(()),
238    };
239    match flow {
240        ControlFlow::Continue(()) => Ok(()),
241        ControlFlow::Break(e) => Err(e),
242    }
243}
244
245/// Unquoted identifiers are lowercase, as DataFusion normalizes them by default.
246fn default_normalize(ident: Ident) -> String {
247    match ident.quote_style {
248        Some(_) => ident.value,
249        None => ident.value.to_lowercase(),
250    }
251}
252
253/// The function name of a one-part `name`, normalized.
254fn function_name(name: &ObjectName, normalize: impl Fn(&Ident) -> String) -> Option<String> {
255    let [part] = name.0.as_slice() else {
256        return None;
257    };
258    part.as_ident().map(normalize)
259}
260
261/// The paths `factor` reads, if it is a `read_*` call: the strings of its
262/// first argument, for a database to rewrite relative to its directory.
263pub fn paths_mut(factor: &mut TableFactor) -> Vec<&mut String> {
264    let TableFactor::Table {
265        name,
266        args: Some(args),
267        ..
268    } = factor
269    else {
270        return Vec::new();
271    };
272    let is_read = function_name(name, |ident| default_normalize(ident.clone()))
273        .is_some_and(|f| Function::lookup(&f).is_some());
274    let Some(FunctionArg::Unnamed(FunctionArgExpr::Expr(first))) = args.args.first_mut() else {
275        return Vec::new();
276    };
277    if !is_read {
278        return Vec::new();
279    }
280    fn string_mut(expr: &mut Expr) -> Option<&mut String> {
281        match expr {
282            Expr::Value(v) => match &mut v.value {
283                Value::SingleQuotedString(s) => Some(s),
284                _ => None,
285            },
286            Expr::Nested(inner) => string_mut(inner),
287            _ => None,
288        }
289    }
290    match first {
291        Expr::Array(array) => array.elem.iter_mut().filter_map(string_mut).collect(),
292        expr => string_mut(expr).into_iter().collect(),
293    }
294}
295
296/// What a call's table is kept under: its text.
297fn call_key(name: &ObjectName, args: &TableFunctionArgs) -> String {
298    let args: Vec<String> = args.args.iter().map(ToString::to_string).collect();
299    format!("{name}({})", args.join(", "))
300}
301
302fn plan_error(message: String) -> DataFusionError {
303    DataFusionError::Plan(message)
304}
305
306/// Read a `read_*` call's arguments.
307fn parse_call(name: &str, function: Function, args: &TableFunctionArgs) -> Result<ReadCall> {
308    let usage = || match function {
309        Function::Parquet => format!("{name}('edges.parquet')"),
310        Function::Csv => format!("{name}('edges.csv', header => true)"),
311        Function::Json | Function::NdJson => format!("{name}('edges.jsonl')"),
312    };
313    if args.settings.is_some() {
314        return Err(plan_error(format!(
315            "{name}() takes no SETTINGS; write {}",
316            usage()
317        )));
318    }
319    let mut args = args.args.iter();
320    let locations = match args.next() {
321        Some(FunctionArg::Unnamed(FunctionArgExpr::Expr(expr))) => locations(expr),
322        _ => None,
323    }
324    .ok_or_else(|| {
325        plan_error(format!(
326            "{name}() takes a path, or a list of paths, as its first argument; write {}",
327            usage()
328        ))
329    })?;
330    let mut options = FileOptions {
331        format: Some(function.format()),
332        ..FileOptions::default()
333    };
334    if function == Function::NdJson {
335        options.newline_delimited = Some(true);
336    }
337    let mut seen: Vec<String> = Vec::new();
338    for arg in args {
339        let (option, value) = match arg {
340            FunctionArg::Named {
341                name: option,
342                arg: FunctionArgExpr::Expr(value),
343                ..
344            } => (default_normalize(option.clone()), value),
345            // DuckDB's `header = true`.
346            FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::BinaryOp {
347                left,
348                op: BinaryOperator::Eq,
349                right,
350            })) if matches!(left.as_ref(), Expr::Identifier(_)) => {
351                let Expr::Identifier(option) = left.as_ref() else {
352                    unreachable!("matched above")
353                };
354                (default_normalize(option.clone()), right.as_ref())
355            }
356            other => {
357                return Err(plan_error(format!(
358                    "{name}() takes its options by name, as name => value; got {other}. \
359                     Write {}",
360                    usage()
361                )))
362            }
363        };
364        let known = function.options();
365        if !known.contains(&option.as_str()) {
366            let hint = closest_name(&option, known.iter().copied())
367                .map(|c| format!("; did you mean '{c}'?"))
368                .unwrap_or_default();
369            let takes = match known {
370                [] => "it takes none".to_string(),
371                known => format!("it takes {}", known.join(", ")),
372            };
373            return Err(plan_error(format!(
374                "{name}() has no option '{option}'{hint} ({takes})"
375            )));
376        }
377        let canonical = match option.as_str() {
378            "sep" | "delimiter" => "delim",
379            option => option,
380        };
381        if seen.iter().any(|s| s == canonical) {
382            return Err(plan_error(format!(
383                "{name}() was given '{option}' more than once"
384            )));
385        }
386        seen.push(canonical.to_string());
387        let invalid =
388            |what: &str| plan_error(format!("{name}({option} =>) must be {what}; got {value}"));
389        match canonical {
390            "header" => {
391                options.has_header = Some(boolean(value).ok_or_else(|| invalid("true or false"))?)
392            }
393            "delim" | "quote" => {
394                let byte = string(value)
395                    .and_then(|s| single_byte(&s))
396                    .ok_or_else(|| invalid("a string of one character, such as '|' or '\\t'"))?;
397                if canonical == "delim" {
398                    options.delimiter = Some(byte);
399                } else {
400                    options.quote = Some(byte);
401                }
402            }
403            "compression" => {
404                options.compression = compression(value)
405                    .ok_or_else(|| invalid("'auto', 'none', 'gzip', 'bzip2', 'xz' or 'zstd'"))?
406            }
407            "format" => {
408                let format = string(value).map(|s| s.to_ascii_lowercase());
409                options.newline_delimited = match format.as_deref() {
410                    Some("newline_delimited") => Some(true),
411                    Some("array") => Some(false),
412                    _ => return Err(invalid("'newline_delimited' or 'array'")),
413                }
414            }
415            _ => unreachable!("checked against the function's options"),
416        }
417    }
418    Ok(ReadCall { locations, options })
419}
420
421/// A path, or a list of paths: plain strings, which [`paths_mut`] rewrites.
422fn locations(expr: &Expr) -> Option<Vec<String>> {
423    fn path(expr: &Expr) -> Option<String> {
424        match expr {
425            Expr::Value(v) => match &v.value {
426                Value::SingleQuotedString(s) => Some(s.clone()),
427                _ => None,
428            },
429            Expr::Nested(inner) => path(inner),
430            _ => None,
431        }
432    }
433    match expr {
434        Expr::Array(array) => {
435            let paths: Option<Vec<String>> = array.elem.iter().map(path).collect();
436            paths.filter(|p| !p.is_empty())
437        }
438        expr => path(expr).map(|s| vec![s]),
439    }
440}
441
442fn string(expr: &Expr) -> Option<String> {
443    match expr {
444        Expr::Value(v) => match &v.value {
445            Value::SingleQuotedString(s) | Value::EscapedStringLiteral(s) => Some(s.clone()),
446            _ => None,
447        },
448        Expr::Nested(inner) => string(inner),
449        _ => None,
450    }
451}
452
453fn boolean(expr: &Expr) -> Option<bool> {
454    match expr {
455        Expr::Value(v) => match &v.value {
456            Value::Boolean(b) => Some(*b),
457            _ => None,
458        },
459        _ => None,
460    }
461}
462
463/// One ASCII character; `\t` (as DuckDB writes a tab in a plain string) is a tab.
464fn single_byte(s: &str) -> Option<u8> {
465    match s {
466        "\\t" => Some(b'\t'),
467        s if s.len() == 1 && s.is_ascii() => Some(s.as_bytes()[0]),
468        _ => None,
469    }
470}
471
472/// A compression option: `Some(None)` for `'auto'` (from the extension).
473fn compression(expr: &Expr) -> Option<Option<CompressionTypeVariant>> {
474    let value = string(expr)?.to_ascii_lowercase();
475    match value.as_str() {
476        "auto" => Some(None),
477        "none" | "uncompressed" => Some(Some(CompressionTypeVariant::UNCOMPRESSED)),
478        other => other.parse().ok().map(Some),
479    }
480}
481
482/// Plans `read_*` calls in `FROM` as scans of the tables [`open_reads`]
483/// opened.
484#[derive(Debug)]
485pub struct ReadFunctions {
486    tables: Arc<ReadTables>,
487}
488
489impl ReadFunctions {
490    pub(crate) fn new(config: &SessionConfig) -> Result<Self> {
491        Ok(Self {
492            tables: read_tables(config)?,
493        })
494    }
495}
496
497impl RelationPlanner for ReadFunctions {
498    fn plan_relation(
499        &self,
500        relation: TableFactor,
501        context: &mut dyn RelationPlannerContext,
502    ) -> Result<RelationPlanning> {
503        let TableFactor::Table {
504            name,
505            args: Some(args),
506            ..
507        } = &relation
508        else {
509            return Ok(RelationPlanning::Original(Box::new(relation)));
510        };
511        let Some(function) = function_name(name, |ident| context.normalize_ident(ident.clone()))
512        else {
513            return Ok(RelationPlanning::Original(Box::new(relation)));
514        };
515        let Some(f) = Function::lookup(&function) else {
516            // A misspelt `read_*` call: a name close to one, given a path.
517            let given_a_path = matches!(
518                args.args.first(),
519                Some(FunctionArg::Unnamed(FunctionArgExpr::Expr(expr))) if locations(expr).is_some()
520            );
521            if given_a_path {
522                if let Some(close) = closest_name(&function, names()) {
523                    return Err(plan_error(format!(
524                        "there is no table function '{function}'; did you mean '{close}'?"
525                    )));
526                }
527            }
528            return Ok(RelationPlanning::Original(Box::new(relation)));
529        };
530        let key = call_key(name, args);
531        let table = self
532            .tables
533            .tables
534            .lock()
535            .get(&key)
536            .map(|(t, _)| Arc::clone(t));
537        let Some(table) = table else {
538            // Not opened: the statement did not come through `open_reads`.
539            // Read the arguments anyway, so a bad one is reported as such.
540            parse_call(&function, f, args)?;
541            return Err(DataFusionError::Internal(format!(
542                "{function}() was not opened before planning; run the statement with \
543                 ursa_sql::execute"
544            )));
545        };
546        let TableFactor::Table {
547            alias,
548            with_hints,
549            version,
550            with_ordinality,
551            partitions,
552            json_path,
553            sample,
554            index_hints,
555            ..
556        } = relation
557        else {
558            unreachable!("matched above")
559        };
560        if !with_hints.is_empty()
561            || version.is_some()
562            || with_ordinality
563            || !partitions.is_empty()
564            || json_path.is_some()
565            || sample.is_some()
566            || !index_hints.is_empty()
567        {
568            return Err(plan_error(format!("{function}() takes no table modifiers")));
569        }
570        let plan = LogicalPlanBuilder::scan(function.as_str(), provider_as_source(table), None)?
571            .build()?;
572        Ok(RelationPlanning::Planned(Box::new(PlannedRelation::new(
573            plan, alias,
574        ))))
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use datafusion::sql::sqlparser::dialect::GenericDialect;
582    use datafusion::sql::sqlparser::parser::Parser;
583
584    fn call(sql: &str) -> Result<ReadCall> {
585        let query = format!("SELECT * FROM {sql}");
586        let statement = Parser::parse_sql(&GenericDialect {}, &query)
587            .unwrap()
588            .remove(0);
589        let mut calls = Calls::default();
590        match statement.visit(&mut calls) {
591            ControlFlow::Break(e) => Err(e),
592            ControlFlow::Continue(()) => Ok(calls.calls.remove(0).1),
593        }
594    }
595
596    fn message(sql: &str) -> String {
597        crate::user_message(&call(sql).unwrap_err())
598    }
599
600    #[test]
601    fn reads_paths_and_options() {
602        let c = call("read_csv('a.csv', header => false, delim = '\\t', compression => 'gzip')")
603            .unwrap();
604        assert_eq!(c.locations, ["a.csv"]);
605        assert_eq!(c.options.format, Some(FileFormat::Csv));
606        assert_eq!(c.options.has_header, Some(false));
607        assert_eq!(c.options.delimiter, Some(b'\t'));
608        assert_eq!(c.options.compression, Some(CompressionTypeVariant::GZIP));
609
610        let c = call("read_parquet(['a.parquet', 'b.parquet'])").unwrap();
611        assert_eq!(c.locations, ["a.parquet", "b.parquet"]);
612
613        let c = call("read_json('a.json', format => 'array')").unwrap();
614        assert_eq!(c.options.newline_delimited, Some(false));
615        let c = call("read_ndjson('a.txt', compression => 'auto')").unwrap();
616        assert_eq!(c.options.newline_delimited, Some(true));
617        assert_eq!(c.options.compression, None);
618    }
619
620    #[test]
621    fn reports_bad_arguments() {
622        assert_eq!(
623            message("read_csv('a.csv', hedaer => true)"),
624            "read_csv() has no option 'hedaer'; did you mean 'header'? (it takes header, \
625             delim, sep, delimiter, quote, compression)"
626        );
627        assert_eq!(
628            message("read_parquet('a.parquet', header => true)"),
629            "read_parquet() has no option 'header' (it takes none)"
630        );
631        assert_eq!(
632            message("read_csv(1)"),
633            "read_csv() takes a path, or a list of paths, as its first argument; write \
634             read_csv('edges.csv', header => true)"
635        );
636        assert_eq!(
637            message("read_csv('a.csv', delim => ';;')"),
638            "read_csv(delim =>) must be a string of one character, such as '|' or '\\t'; \
639             got ';;'"
640        );
641        assert_eq!(
642            message("read_csv('a.csv', sep => ';', delim => ',')"),
643            "read_csv() was given 'delim' more than once"
644        );
645    }
646}