1use 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
21pub const DEFAULT_RECURSION_LIMIT: usize = 50;
23
24#[derive(Debug, Clone, PartialEq)]
26pub enum UrsaStatement {
27 DataFusion(Box<DFStatement>),
29 CreatePropertyGraph(Box<CreatePropertyGraph>),
31 DropPropertyGraph(DropPropertyGraph),
33 DescribeGraph(DescribeGraph),
35 ShowGraphs,
37 Attach(Attach),
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct CreatePropertyGraph {
45 pub name: ObjectName,
46 pub or_replace: bool,
47 pub if_not_exists: bool,
48 pub vertices: Vec<VertexTableDef>,
50 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 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
121pub fn parse_sql(sql: &str) -> Result<Vec<UrsaStatement>> {
123 UrsaParser::new(sql, &GenericDialect {}, DEFAULT_RECURSION_LIMIT)?.parse_statements()
124}
125
126pub struct UrsaParser<'a> {
128 df: DFParser<'a>,
129 dialect: &'a dyn Dialect,
130 sql: String,
132 map: SourceMap,
135}
136
137impl<'a> UrsaParser<'a> {
138 pub fn new(sql: &str, dialect: &'a dyn Dialect, recursion_limit: usize) -> Result<Self> {
139 let mut map = SourceMap::new(sql);
142 map.push(crate::graph_table::rewrite_graph_tables_mapped(
143 sql, dialect,
144 ));
145 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 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 pub fn parse_statements_with_text(&mut self) -> Result<Vec<(UrsaStatement, String)>> {
178 let statements = self.parse_all().map_err(|e| match e {
179 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 if self.uses_tablesample() {
189 return Err(DataFusionError::NotImplemented(TABLESAMPLE.to_string()));
190 }
191 Ok(statements)
192 }
193
194 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 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 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 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 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 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 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 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(); 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
387const 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
423const 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
427const USING_SAMPLE: &str =
429 "USING SAMPLE is not supported; use ORDER BY random() LIMIT n for a sample of n rows";
430
431fn 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
439fn 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
462fn 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 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
529pub(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 #[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 #[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 #[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 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 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 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}