Skip to main content

ursa_sql/
parser.rs

1//! Ursa's statement parser.
2//!
3//! It wraps DataFusion's [`DFParser`]: before each statement it peeks at the
4//! leading words, parses Ursa's own statements with sqlparser's public helpers,
5//! and hands everything else to `DFParser::parse_statement`. sqlparser knows no
6//! SQL/PGQ, so `PROPERTY`, `GRAPH` and `GRAPHS` are not keywords; they are
7//! matched as unquoted words, case-insensitively.
8
9use std::fmt;
10
11use datafusion::error::{DataFusionError, Result};
12use datafusion::sql::parser::{DFParser, DFParserBuilder, Statement as DFStatement};
13use datafusion::sql::sqlparser::ast::{Ident, ObjectName};
14use datafusion::sql::sqlparser::dialect::{Dialect, GenericDialect};
15use datafusion::sql::sqlparser::parser::ParserError;
16use datafusion::sql::sqlparser::tokenizer::{Location, Token, Tokenizer};
17
18use crate::property_graph::{parse_tables, write_tables, EdgeTableDef, VertexTableDef};
19use crate::source_map::SourceMap;
20
21/// Same as sqlparser's and DataFusion's default.
22pub const DEFAULT_RECURSION_LIMIT: usize = 50;
23
24/// One statement of Ursa SQL.
25#[derive(Debug, Clone, PartialEq)]
26pub enum UrsaStatement {
27    /// Anything DataFusion parses: queries, DDL, `COPY … TO`, `SET`, `EXPLAIN`.
28    DataFusion(Box<DFStatement>),
29    /// `CREATE [OR REPLACE] PROPERTY GRAPH [IF NOT EXISTS] name …` (SQL/PGQ).
30    CreatePropertyGraph(Box<CreatePropertyGraph>),
31    /// `DROP PROPERTY GRAPH [IF EXISTS] name`.
32    DropPropertyGraph(DropPropertyGraph),
33    /// `DESCRIBE GRAPH name` (or `DESC GRAPH name`).
34    DescribeGraph(DescribeGraph),
35    /// `SHOW GRAPHS`.
36    ShowGraphs,
37    /// `ATTACH [DATABASE] 'file.ursa' [AS name]`.
38    Attach(Attach),
39}
40
41/// `CREATE PROPERTY GRAPH`: Ursa's subset of SQL/PGQ (see
42/// [`crate::property_graph`]).
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct CreatePropertyGraph {
45    pub name: ObjectName,
46    pub or_replace: bool,
47    pub if_not_exists: bool,
48    /// `VERTEX TABLES (…)`: empty when the clause is left out.
49    pub vertices: Vec<VertexTableDef>,
50    /// `EDGE TABLES (…)`: empty when the clause is left out.
51    pub edges: Vec<EdgeTableDef>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct DropPropertyGraph {
56    pub name: ObjectName,
57    pub if_exists: bool,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct DescribeGraph {
62    pub name: ObjectName,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Attach {
67    pub path: String,
68    pub alias: Option<Ident>,
69}
70
71impl UrsaStatement {
72    /// The statement's leading words, as an error message names it.
73    pub fn kind(&self) -> &'static str {
74        match self {
75            UrsaStatement::DataFusion(_) => "SQL statement",
76            UrsaStatement::CreatePropertyGraph(_) => "CREATE PROPERTY GRAPH",
77            UrsaStatement::DropPropertyGraph(_) => "DROP PROPERTY GRAPH",
78            UrsaStatement::DescribeGraph(_) => "DESCRIBE GRAPH",
79            UrsaStatement::ShowGraphs => "SHOW GRAPHS",
80            UrsaStatement::Attach(_) => "ATTACH",
81        }
82    }
83}
84
85impl fmt::Display for UrsaStatement {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            UrsaStatement::DataFusion(s) => write!(f, "{s}"),
89            UrsaStatement::CreatePropertyGraph(s) => {
90                write!(f, "CREATE ")?;
91                if s.or_replace {
92                    write!(f, "OR REPLACE ")?;
93                }
94                write!(f, "PROPERTY GRAPH ")?;
95                if s.if_not_exists {
96                    write!(f, "IF NOT EXISTS ")?;
97                }
98                write!(f, "{}", s.name)?;
99                write_tables(f, &s.vertices, &s.edges)
100            }
101            UrsaStatement::DropPropertyGraph(s) => {
102                write!(f, "DROP PROPERTY GRAPH ")?;
103                if s.if_exists {
104                    write!(f, "IF EXISTS ")?;
105                }
106                write!(f, "{}", s.name)
107            }
108            UrsaStatement::DescribeGraph(s) => write!(f, "DESCRIBE GRAPH {}", s.name),
109            UrsaStatement::ShowGraphs => write!(f, "SHOW GRAPHS"),
110            UrsaStatement::Attach(s) => {
111                write!(f, "ATTACH '{}'", s.path.replace('\'', "''"))?;
112                if let Some(alias) = &s.alias {
113                    write!(f, " AS {alias}")?;
114                }
115                Ok(())
116            }
117        }
118    }
119}
120
121/// Parse `sql` (one or more statements separated by `;`) with the generic dialect.
122pub fn parse_sql(sql: &str) -> Result<Vec<UrsaStatement>> {
123    UrsaParser::new(sql, &GenericDialect {}, DEFAULT_RECURSION_LIMIT)?.parse_statements()
124}
125
126/// Ursa's parser: DataFusion's, with Ursa's statements recognised first.
127pub struct UrsaParser<'a> {
128    df: DFParser<'a>,
129    dialect: &'a dyn Dialect,
130    /// The text being parsed, for [`Self::parse_statements_with_text`].
131    sql: String,
132    /// How `sql` was rewritten from the user's text, so a syntax error names
133    /// a place in what the user wrote.
134    map: SourceMap,
135}
136
137impl<'a> UrsaParser<'a> {
138    pub fn new(sql: &str, dialect: &'a dyn Dialect, recursion_limit: usize) -> Result<Self> {
139        // SQL/PGQ's GRAPH_TABLE ( … ), which sqlparser cannot parse, becomes
140        // the table function graph_table('…') first (see `graph_table`).
141        let mut map = SourceMap::new(sql);
142        map.push(crate::graph_table::rewrite_graph_tables_mapped(
143            sql, dialect,
144        ));
145        // DuckDB's `FROM t [WHERE …]` becomes `SELECT * FROM t [WHERE …]`.
146        map.push(crate::from_first::rewrite_from_first_mapped(
147            map.text(),
148            dialect,
149        ));
150        let sql = map.text().to_string();
151        let df = DFParserBuilder::new(sql.as_str())
152            .with_dialect(dialect)
153            .with_recursion_limit(recursion_limit)
154            .build()
155            .map_err(|e| in_user_text(e, &map, dialect))?;
156        Ok(Self {
157            df,
158            dialect,
159            sql,
160            map,
161        })
162    }
163
164    /// Parse every statement, separated by `;`. Empty statements are skipped.
165    /// (The loop is `DFParser::parse_statements`, calling our `parse_statement`.)
166    pub fn parse_statements(&mut self) -> Result<Vec<UrsaStatement>> {
167        Ok(self
168            .parse_statements_with_text()?
169            .into_iter()
170            .map(|(statement, _)| statement)
171            .collect())
172    }
173
174    /// [`Self::parse_statements`], with each statement's text as written
175    /// (from its first token to its last, without the `;`). A database file
176    /// stores this text when a statement's `Display` does not round-trip.
177    pub fn parse_statements_with_text(&mut self) -> Result<Vec<(UrsaStatement, String)>> {
178        let statements = self.parse_all().map_err(|e| match e {
179            // A syntax error in a statement that uses DuckDB's `USING SAMPLE`
180            // says what to write instead.
181            DataFusionError::SQL(..) if self.uses_sample_clause() => {
182                DataFusionError::NotImplemented(USING_SAMPLE.to_string())
183            }
184            e => in_user_text(e, &self.map, self.dialect),
185        })?;
186        // sqlparser accepts TABLESAMPLE, but DataFusion ignores it and reads
187        // every row: a silently wrong answer. Refuse it instead.
188        if self.uses_tablesample() {
189            return Err(DataFusionError::NotImplemented(TABLESAMPLE.to_string()));
190        }
191        Ok(statements)
192    }
193
194    /// Whether the text holds the word `TABLESAMPLE`, outside quotes and
195    /// comments. Tokenizes only when the text contains it at all.
196    fn uses_tablesample(&self) -> bool {
197        if !self.sql.to_ascii_uppercase().contains("TABLESAMPLE") {
198            return false;
199        }
200        let Ok(tokens) = Tokenizer::new(self.dialect, &self.sql).tokenize() else {
201            return false;
202        };
203        tokens.iter().any(|t| {
204            matches!(t, Token::Word(w) if w.quote_style.is_none()
205                && w.value.eq_ignore_ascii_case("TABLESAMPLE"))
206        })
207    }
208
209    fn parse_all(&mut self) -> Result<Vec<(UrsaStatement, String)>> {
210        let mut statements = Vec::new();
211        let mut expecting_delimiter = false;
212        loop {
213            while self.df.parser.consume_token(&Token::SemiColon) {
214                expecting_delimiter = false;
215            }
216            let next = self.df.parser.peek_token();
217            if next.token == Token::EOF {
218                break;
219            }
220            if expecting_delimiter {
221                return Err(ParserError::ParserError(format!(
222                    "Expected: end of statement, found: {next}{}",
223                    next.span.start
224                ))
225                .into());
226            }
227            let start = next.span.start;
228            let statement = self.parse_statement()?;
229            let end = self.df.parser.get_current_token().span.end;
230            let text = source_text(&self.sql, start, end).unwrap_or_else(|| statement.to_string());
231            statements.push((statement, text));
232            expecting_delimiter = true;
233        }
234        Ok(statements)
235    }
236
237    /// Parse one statement.
238    pub fn parse_statement(&mut self) -> Result<UrsaStatement> {
239        if let Some(message) = self.duckdb_statement() {
240            return Err(DataFusionError::NotImplemented(message.to_string()));
241        }
242        if self.word(0, "CREATE") {
243            let at = if self.word(1, "OR") && self.word(2, "REPLACE") {
244                3
245            } else {
246                1
247            };
248            if self.word(at, "PROPERTY") && self.word(at + 1, "GRAPH") {
249                return self.parse_create_property_graph();
250            }
251        } else if self.word(0, "DROP") && self.word(1, "PROPERTY") && self.word(2, "GRAPH") {
252            return Ok(self.parse_drop_property_graph()?);
253        } else if (self.word(0, "DESCRIBE") || self.word(0, "DESC"))
254            && self.word(1, "GRAPH")
255            && matches!(self.df.parser.peek_nth_token_ref(2).token, Token::Word(_))
256        {
257            // `DESCRIBE graph` alone still describes a table named `graph`.
258            for _ in 0..2 {
259                self.df.parser.next_token();
260            }
261            let name = self.df.parser.parse_object_name(false)?;
262            return Ok(UrsaStatement::DescribeGraph(DescribeGraph { name }));
263        } else if (self.word(0, "DESCRIBE") || self.word(0, "DESC"))
264            && self.word(1, "PROPERTY")
265            && self.word(2, "GRAPH")
266        {
267            // DuckPGQ's spelling of `DESCRIBE GRAPH`.
268            for _ in 0..3 {
269                self.df.parser.next_token();
270            }
271            let name = self.df.parser.parse_object_name(false)?;
272            return Ok(UrsaStatement::DescribeGraph(DescribeGraph { name }));
273        } else if self.word(0, "SHOW") && self.word(1, "GRAPHS") {
274            for _ in 0..2 {
275                self.df.parser.next_token();
276            }
277            return Ok(UrsaStatement::ShowGraphs);
278        } else if self.word(0, "ATTACH") {
279            return Ok(self.parse_attach()?);
280        }
281        Ok(UrsaStatement::DataFusion(Box::new(
282            self.df.parse_statement()?,
283        )))
284    }
285
286    /// The refusal for a DuckDB statement Ursa does not have, if the next
287    /// statement is one (see [`DUCKDB_STATEMENTS`]).
288    fn duckdb_statement(&self) -> Option<&'static str> {
289        DUCKDB_STATEMENTS
290            .iter()
291            .find(|(word, _)| self.word(0, word))
292            .filter(|(word, _)| !(*word == "LOAD" && self.word(1, "DATA")))
293            .map(|(_, message)| *message)
294    }
295
296    /// Whether the text holds `USING SAMPLE`, outside quotes and comments.
297    /// Only called once parsing has failed, so a statement that parses pays
298    /// nothing for it.
299    fn uses_sample_clause(&self) -> bool {
300        let Ok(tokens) = Tokenizer::new(self.dialect, &self.sql).tokenize() else {
301            return false;
302        };
303        let is = |token: &Token, word: &str| {
304            matches!(token, Token::Word(w) if w.quote_style.is_none()
305                && w.value.eq_ignore_ascii_case(word))
306        };
307        let mut words = tokens
308            .iter()
309            .filter(|t| !matches!(t, Token::Whitespace(_)))
310            .peekable();
311        while let Some(token) = words.next() {
312            if is(token, "USING") && words.peek().is_some_and(|next| is(next, "SAMPLE")) {
313                return true;
314            }
315        }
316        false
317    }
318
319    /// Whether the `n`th token ahead is the unquoted word `word`, in any case.
320    fn word(&self, n: usize, word: &str) -> bool {
321        match &self.df.parser.peek_nth_token_ref(n).token {
322            Token::Word(w) => w.quote_style.is_none() && w.value.eq_ignore_ascii_case(word),
323            _ => false,
324        }
325    }
326
327    /// Consume the next token if it is the unquoted word `word`.
328    fn consume_word(&mut self, word: &str) -> bool {
329        let found = self.word(0, word);
330        if found {
331            self.df.parser.next_token();
332        }
333        found
334    }
335
336    fn consume_words(&mut self, words: &[&str]) -> bool {
337        let found = words.iter().enumerate().all(|(i, w)| self.word(i, w));
338        if found {
339            for _ in words {
340                self.df.parser.next_token();
341            }
342        }
343        found
344    }
345
346    fn parse_create_property_graph(&mut self) -> Result<UrsaStatement> {
347        self.df.parser.next_token(); // CREATE
348        let or_replace = self.consume_words(&["OR", "REPLACE"]);
349        self.consume_words(&["PROPERTY", "GRAPH"]);
350        let if_not_exists = self.consume_words(&["IF", "NOT", "EXISTS"]);
351        let name = self.df.parser.parse_object_name(false)?;
352        let (vertices, edges) = parse_tables(&mut self.df.parser)?;
353        Ok(UrsaStatement::CreatePropertyGraph(Box::new(
354            CreatePropertyGraph {
355                name,
356                or_replace,
357                if_not_exists,
358                vertices,
359                edges,
360            },
361        )))
362    }
363
364    fn parse_drop_property_graph(&mut self) -> Result<UrsaStatement, ParserError> {
365        self.consume_words(&["DROP", "PROPERTY", "GRAPH"]);
366        let if_exists = self.consume_words(&["IF", "EXISTS"]);
367        let name = self.df.parser.parse_object_name(false)?;
368        Ok(UrsaStatement::DropPropertyGraph(DropPropertyGraph {
369            name,
370            if_exists,
371        }))
372    }
373
374    fn parse_attach(&mut self) -> Result<UrsaStatement, ParserError> {
375        self.consume_word("ATTACH");
376        self.consume_word("DATABASE");
377        let path = self.df.parser.parse_literal_string()?;
378        let alias = if self.consume_word("AS") {
379            Some(self.df.parser.parse_identifier()?)
380        } else {
381            None
382        };
383        Ok(UrsaStatement::Attach(Attach { path, alias }))
384    }
385}
386
387/// DuckDB statements Ursa does not have, by their first word, with what to
388/// write instead. Without these, sqlparser's own error ("Expected: an SQL
389/// statement, found: SUMMARIZE") or DataFusion's ("Unsupported SQL
390/// statement") would be all a user, or an agent that knows DuckDB, sees.
391const DUCKDB_STATEMENTS: &[(&str, &str)] = &[
392    (
393        "SUMMARIZE",
394        "SUMMARIZE is not supported; use DESCRIBE t for the columns and their types, and \
395         SELECT count(*), min(c), max(c), avg(c) FROM t for a column's statistics",
396    ),
397    (
398        "PIVOT",
399        "PIVOT is not supported; use GROUP BY with one aggregate per column, as \
400         sum(x) FILTER (WHERE k = 'a') AS a",
401    ),
402    (
403        "UNPIVOT",
404        "UNPIVOT is not supported; use UNION ALL of one SELECT per column",
405    ),
406    (
407        "INSTALL",
408        "INSTALL is not supported: Ursa has no extensions; Parquet, CSV, JSON and object \
409         storage are built in",
410    ),
411    (
412        "LOAD",
413        "LOAD is not supported: Ursa has no extensions; Parquet, CSV, JSON and object \
414         storage are built in",
415    ),
416    (
417        "PRAGMA",
418        "PRAGMA is not supported; use SHOW ALL, SHOW name or SET name = value for settings, \
419         and SELECT * FROM ursa_functions() for the graph functions",
420    ),
421];
422
423/// The refusal for `TABLESAMPLE`, which DataFusion parses but ignores.
424const TABLESAMPLE: &str = "TABLESAMPLE is not supported (it would be ignored and every row read); \
425     use ORDER BY random() LIMIT n for a sample of n rows";
426
427/// The refusal for DuckDB's `USING SAMPLE` clause.
428const USING_SAMPLE: &str =
429    "USING SAMPLE is not supported; use ORDER BY random() LIMIT n for a sample of n rows";
430
431/// The text of `sql` between two token locations, or `None` if a location is
432/// unknown (line 0) or outside the text.
433fn source_text(sql: &str, start: Location, end: Location) -> Option<String> {
434    let from = byte_offset(sql, start)?;
435    let to = byte_offset(sql, end)?;
436    (from < to).then(|| sql[from..to].trim().to_string())
437}
438
439/// `e` with the locations in a syntax error moved from the rewritten text to
440/// the user's (see [`SourceMap`]), and a hint when a reserved word seems to
441/// be used as a name there ([`reserved_word_hint`]).
442fn in_user_text(e: DataFusionError, map: &SourceMap, dialect: &dyn Dialect) -> DataFusionError {
443    match e {
444        DataFusionError::SQL(e, backtrace) => {
445            let e = match *e {
446                ParserError::ParserError(m) => {
447                    let mut m = map.user_message(&m);
448                    if let Some(hint) = reserved_word_hint(&m, map.original(), dialect) {
449                        m.push_str(&hint);
450                    }
451                    ParserError::ParserError(m)
452                }
453                ParserError::TokenizerError(m) => ParserError::TokenizerError(map.user_message(&m)),
454                other => other,
455            };
456            DataFusionError::SQL(Box::new(e), backtrace)
457        }
458        other => other,
459    }
460}
461
462/// When a syntax error at "Line: L, Column: C" of `sql` looks like a reserved
463/// word used as a name, a hint to quote it: the word is next to the error's
464/// token, inside a list (after `(` or `,`, or before `,` or `)`), or after
465/// `SELECT`. `SELECT from, to FROM t` gives "`from` is a reserved word; to
466/// use it as a name, quote it: "from"".
467fn reserved_word_hint(message: &str, sql: &str, dialect: &dyn Dialect) -> Option<String> {
468    use datafusion::sql::sqlparser::keywords::{Keyword, RESERVED_FOR_COLUMN_ALIAS};
469
470    let at = message.rfind("Line: ")?;
471    let (line, column) = message[at + "Line: ".len()..].split_once(", Column: ")?;
472    let line: u64 = line.parse().ok()?;
473    let column: u64 = column
474        .chars()
475        .take_while(char::is_ascii_digit)
476        .collect::<String>()
477        .parse()
478        .ok()?;
479    let tokens = Tokenizer::new(dialect, sql).tokenize_with_location().ok()?;
480    let tokens: Vec<_> = tokens
481        .into_iter()
482        .filter(|t| !matches!(t.token, Token::Whitespace(_)))
483        .collect();
484    let here = tokens
485        .iter()
486        .position(|t| t.span.start.line == line && t.span.start.column == column)
487        .unwrap_or(tokens.len());
488    let reserved = |i: usize| -> Option<&str> {
489        match &tokens.get(i)?.token {
490            Token::Word(w)
491                if w.quote_style.is_none()
492                    && w.keyword != Keyword::NoKeyword
493                    && RESERVED_FOR_COLUMN_ALIAS.contains(&w.keyword) =>
494            {
495                Some(w.value.as_str())
496            }
497            _ => None,
498        }
499    };
500    let is = |i: Option<usize>, kinds: &[Token]| {
501        i.and_then(|i| tokens.get(i))
502            .is_some_and(|t| kinds.contains(&t.token))
503    };
504    let is_select = |i: Option<usize>| {
505        i.and_then(|i| tokens.get(i))
506            .is_some_and(|t| matches!(&t.token, Token::Word(w) if w.keyword == Keyword::SELECT))
507    };
508    let list_end = [Token::Comma, Token::RParen, Token::EOF, Token::SemiColon];
509    let list_start = [Token::Comma, Token::LParen];
510    // The word before the error's token, ending a list item; or the error's
511    // token itself, starting one.
512    let word = here
513        .checked_sub(1)
514        .filter(|&before| {
515            (here >= tokens.len() || is(Some(here), &list_end))
516                && (is(before.checked_sub(1), &list_start) || is_select(before.checked_sub(1)))
517        })
518        .and_then(reserved)
519        .or_else(|| {
520            (is(here.checked_sub(1), &list_start) || is_select(here.checked_sub(1)))
521                .then(|| reserved(here))
522                .flatten()
523        })?;
524    Some(format!(
525        ". `{word}` is a reserved word; to use it as a name, quote it: \"{word}\""
526    ))
527}
528
529/// The byte offset of a tokenizer location (1-based line and column, columns
530/// counted in characters).
531pub(crate) fn byte_offset(sql: &str, at: Location) -> Option<usize> {
532    if at.line == 0 || at.column == 0 {
533        return None;
534    }
535    let mut line_start = 0;
536    for _ in 1..at.line {
537        line_start += sql[line_start..].find('\n')? + 1;
538    }
539    let line = &sql[line_start..];
540    let column = usize::try_from(at.column - 1).ok()?;
541    match line.char_indices().nth(column) {
542        Some((offset, _)) => Some(line_start + offset),
543        None if line.chars().count() == column => Some(sql.len()),
544        None => None,
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    fn one(sql: &str) -> UrsaStatement {
553        let mut statements = parse_sql(sql).unwrap();
554        assert_eq!(statements.len(), 1, "{sql}");
555        statements.pop().unwrap()
556    }
557
558    fn syntax_error(sql: &str) -> String {
559        ursa_engine::user_message(&parse_sql(sql).unwrap_err())
560    }
561
562    /// #328: a key column named `from` is a column, not a FROM-first query.
563    #[test]
564    fn a_key_column_may_be_named_from() {
565        let UrsaStatement::CreatePropertyGraph(g) =
566            one("CREATE PROPERTY GRAPH g EDGE TABLES (e SOURCE KEY (from) DESTINATION KEY (to))")
567        else {
568            panic!("not a CREATE PROPERTY GRAPH")
569        };
570        assert_eq!(g.edges[0].source.key.value, "from");
571        assert_eq!(g.edges[0].destination.key.value, "to");
572    }
573
574    /// #328: syntax errors name a place in the user's text, not in the text
575    /// the GRAPH_TABLE and FROM-first rewrites made of it.
576    #[test]
577    fn syntax_errors_point_at_the_users_text() {
578        let sql =
579            "FROM GRAPH_TABLE (g MATCH (a WHERE a.name = 'x') COLUMNS (a.id AS s)) t WHERE s = = 1";
580        let column = sql.rfind("= =").unwrap() + 3;
581        assert_eq!(
582            syntax_error(sql),
583            format!("syntax error: Expected: an expression, found: = at Line: 1, Column: {column}")
584        );
585        assert_eq!(
586            syntax_error("FROM t\nWHERE s = = 1"),
587            "syntax error: Expected: an expression, found: = at Line: 2, Column: 11"
588        );
589    }
590
591    /// #328: a reserved word used as a name says to quote it.
592    #[test]
593    fn a_reserved_word_as_a_name_says_to_quote_it() {
594        assert_eq!(
595            syntax_error("SELECT from, to FROM t"),
596            "syntax error: Expected: identifier, found: , at Line: 1, Column: 12. `from` is a \
597             reserved word; to use it as a name, quote it: \"from\""
598        );
599        // Not for other syntax errors.
600        assert!(!syntax_error("SELECT a FROM t WHERE s = = 1").contains("reserved"));
601    }
602
603    #[test]
604    fn create_property_graph() {
605        let sql = "CREATE PROPERTY GRAPH karate
606              VERTEX TABLES (members KEY (id))
607              EDGE TABLES (
608                ties SOURCE KEY (src) REFERENCES members (id)
609                     DESTINATION KEY (dst) REFERENCES members (id)
610              )";
611        let UrsaStatement::CreatePropertyGraph(g) = one(sql) else {
612            panic!("not a CREATE PROPERTY GRAPH")
613        };
614        assert_eq!(g.name.to_string(), "karate");
615        assert!(!g.or_replace && !g.if_not_exists);
616
617        let s = one("create or replace property graph if not exists db.social edge tables (e source key (a) destination key (b))");
618        let UrsaStatement::CreatePropertyGraph(g) = s else {
619            panic!("not a CREATE PROPERTY GRAPH")
620        };
621        assert_eq!(g.name.to_string(), "db.social");
622        assert!(g.or_replace && g.if_not_exists);
623    }
624
625    #[test]
626    fn drop_describe_show_attach() {
627        assert_eq!(
628            one("DROP PROPERTY GRAPH IF EXISTS karate"),
629            UrsaStatement::DropPropertyGraph(DropPropertyGraph {
630                name: ObjectName::from(vec![Ident::new("karate")]),
631                if_exists: true,
632            })
633        );
634        assert_eq!(
635            one("describe graph karate").to_string(),
636            "DESCRIBE GRAPH karate"
637        );
638        assert_eq!(one("DESC GRAPH karate").kind(), "DESCRIBE GRAPH");
639        assert_eq!(one("SHOW GRAPHS"), UrsaStatement::ShowGraphs);
640        assert_eq!(
641            one("ATTACH 'club.ursa' AS club"),
642            UrsaStatement::Attach(Attach {
643                path: "club.ursa".into(),
644                alias: Some(Ident::new("club")),
645            })
646        );
647        assert_eq!(
648            one("attach database 'club.ursa'").to_string(),
649            "ATTACH 'club.ursa'"
650        );
651    }
652
653    #[test]
654    fn everything_else_is_datafusion() {
655        for sql in [
656            "SELECT * FROM 'edges.csv' LIMIT 3",
657            "COPY (SELECT 1) TO 'out.parquet'",
658            "CREATE VIEW ties AS SELECT * FROM 'karate_edges.csv'",
659            "DESCRIBE graph",
660            "SHOW TABLES",
661            "CREATE TABLE property (graph INT)",
662        ] {
663            assert!(
664                matches!(one(sql), UrsaStatement::DataFusion(_)),
665                "{sql} should go to DataFusion"
666            );
667        }
668    }
669
670    #[test]
671    fn statements_split_on_semicolons() {
672        let statements = parse_sql(
673            "SELECT 1; ; CREATE PROPERTY GRAPH g EDGE TABLES (e SOURCE KEY (a) DESTINATION KEY (b));
674             SHOW GRAPHS; SELECT ';' AS semi;",
675        )
676        .unwrap();
677        let kinds: Vec<_> = statements.iter().map(UrsaStatement::kind).collect();
678        assert_eq!(
679            kinds,
680            [
681                "SQL statement",
682                "CREATE PROPERTY GRAPH",
683                "SHOW GRAPHS",
684                "SQL statement"
685            ]
686        );
687    }
688
689    #[test]
690    fn statements_keep_their_text() {
691        let sql =
692            "SELECT 1;\n  CREATE EXTERNAL TABLE t (a INT)\n  STORED AS CSV LOCATION 'é.csv'  ;\
693                   CREATE PROPERTY GRAPH g EDGE TABLES (e SOURCE KEY (a) DESTINATION KEY (b))";
694        let statements = UrsaParser::new(sql, &GenericDialect {}, DEFAULT_RECURSION_LIMIT)
695            .unwrap()
696            .parse_statements_with_text()
697            .unwrap();
698        let texts: Vec<_> = statements.iter().map(|(_, t)| t.as_str()).collect();
699        assert_eq!(
700            texts,
701            [
702                "SELECT 1",
703                "CREATE EXTERNAL TABLE t (a INT)\n  STORED AS CSV LOCATION 'é.csv'",
704                "CREATE PROPERTY GRAPH g EDGE TABLES (e SOURCE KEY (a) DESTINATION KEY (b))",
705            ]
706        );
707    }
708
709    #[test]
710    fn missing_delimiter_is_a_syntax_error() {
711        let err = parse_sql("SHOW GRAPHS SELECT 1").unwrap_err();
712        assert!(err.to_string().contains("end of statement"), "{err}");
713    }
714
715    #[test]
716    fn tablesample_is_refused_not_ignored() {
717        for sql in [
718            "SELECT * FROM t TABLESAMPLE SYSTEM (10)",
719            "select * from t tablesample bernoulli (5 percent)",
720        ] {
721            let err = parse_sql(sql).unwrap_err().to_string();
722            assert!(err.contains("TABLESAMPLE is not supported"), "{sql}: {err}");
723        }
724        // The word in a string or a quoted name is not the clause.
725        parse_sql("SELECT 'tablesample' AS x").unwrap();
726        parse_sql("SELECT \"TABLESAMPLE\" FROM t").unwrap();
727    }
728
729    #[test]
730    fn duckdb_statements_say_what_to_write_instead() {
731        let message = |sql: &str| parse_sql(sql).unwrap_err().to_string();
732        for (sql, start) in [
733            ("SUMMARIZE t", "SUMMARIZE is not supported; use DESCRIBE t"),
734            ("summarize SELECT 1", "SUMMARIZE is not supported;"),
735            (
736                "PIVOT t ON k USING sum(x)",
737                "PIVOT is not supported; use GROUP BY",
738            ),
739            ("UNPIVOT t ON a, b", "UNPIVOT is not supported;"),
740            (
741                "INSTALL httpfs",
742                "INSTALL is not supported: Ursa has no extensions",
743            ),
744            (
745                "LOAD httpfs",
746                "LOAD is not supported: Ursa has no extensions",
747            ),
748            ("PRAGMA version", "PRAGMA is not supported; use SHOW ALL"),
749            (
750                "SELECT * FROM t USING SAMPLE 10",
751                "USING SAMPLE is not supported;",
752            ),
753            (
754                "SELECT * FROM t USING SAMPLE 10%",
755                "USING SAMPLE is not supported;",
756            ),
757            (
758                "SELECT 1; SELECT * FROM (SELECT * FROM t USING SAMPLE 5 ROWS)",
759                "USING SAMPLE is not supported; use ORDER BY random() LIMIT n",
760            ),
761        ] {
762            let message = message(sql);
763            assert!(message.contains(start), "{sql}\n{message}");
764        }
765        // Not when the words are data, or when the error is elsewhere.
766        assert!(parse_sql("SELECT 'USING SAMPLE' AS summarize").is_ok());
767        let message = message("SELEC 'USING SAMPLE'");
768        assert!(message.contains("Expected: an SQL statement"), "{message}");
769    }
770}