Skip to main content

ursa_sql/
property_graph.rs

1//! The table clauses of `CREATE PROPERTY GRAPH`: the subset of SQL/PGQ Ursa
2//! supports, and clear errors for the rest.
3//!
4//! ```text
5//! CREATE [OR REPLACE] PROPERTY GRAPH [IF NOT EXISTS] name
6//!   [VERTEX TABLES ( table KEY (column) )]
7//!   EDGE TABLES (
8//!     table SOURCE KEY (column) [REFERENCES vertex_table (key)]
9//!           DESTINATION KEY (column) [REFERENCES vertex_table (key)]
10//!   )
11//! ```
12//!
13//! SQL/PGQ allows more: several vertex and edge tables, `AS alias`, `LABEL`,
14//! `DEFAULT LABEL`, `PROPERTIES`, `NO PROPERTIES`, an edge table `KEY`,
15//! `SOURCE vertex_table` without `KEY`, and multi-column keys. Each is
16//! recognised and rejected as not supported yet, never parsed as something
17//! else. `NODE TABLES` and `RELATIONSHIP TABLES`, SQL/PGQ's synonyms, are
18//! accepted.
19
20use std::fmt;
21
22use datafusion::error::{DataFusionError, Result};
23use datafusion::sql::sqlparser::ast::{Ident, ObjectName};
24use datafusion::sql::sqlparser::parser::Parser;
25use datafusion::sql::sqlparser::tokenizer::Token;
26
27/// `VERTEX TABLES (table KEY (key))`.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct VertexTableDef {
30    pub table: ObjectName,
31    pub key: Ident,
32}
33
34/// `EDGE TABLES (table SOURCE … DESTINATION …)`.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct EdgeTableDef {
37    pub table: ObjectName,
38    pub source: EndpointDef,
39    pub destination: EndpointDef,
40}
41
42/// `KEY (key) [REFERENCES table (column)]`, after `SOURCE` or `DESTINATION`.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct EndpointDef {
45    pub key: Ident,
46    pub references: Option<References>,
47}
48
49/// `REFERENCES table (column)`.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct References {
52    pub table: ObjectName,
53    pub column: Ident,
54}
55
56impl fmt::Display for VertexTableDef {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        write!(f, "VERTEX TABLES ({} KEY ({}))", self.table, self.key)
59    }
60}
61
62impl fmt::Display for EdgeTableDef {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        write!(
65            f,
66            "EDGE TABLES ({} SOURCE {} DESTINATION {})",
67            self.table, self.source, self.destination
68        )
69    }
70}
71
72impl fmt::Display for EndpointDef {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        write!(f, "KEY ({})", self.key)?;
75        if let Some(r) = &self.references {
76            write!(f, " REFERENCES {} ({})", r.table, r.column)?;
77        }
78        Ok(())
79    }
80}
81
82/// Why everything beyond the subset is rejected.
83const ONE_TABLE_EACH: &str = "Ursa graphs have one edge table and at most one vertex table";
84
85/// Parse the table clauses, after the graph's name.
86pub(crate) fn parse_tables(p: &mut Parser) -> Result<(Option<VertexTableDef>, EdgeTableDef)> {
87    let vertices =
88        if consume_words(p, &["VERTEX", "TABLES"]) || consume_words(p, &["NODE", "TABLES"]) {
89            p.expect_token(&Token::LParen)?;
90            let vertices = parse_vertex_table(p)?;
91            if p.peek_token_ref().token == Token::Comma {
92                return unsupported(p, "several vertex tables are");
93            }
94            p.expect_token(&Token::RParen)?;
95            Some(vertices)
96        } else {
97            None
98        };
99    if !(consume_words(p, &["EDGE", "TABLES"]) || consume_words(p, &["RELATIONSHIP", "TABLES"])) {
100        let expected = if vertices.is_some() {
101            "EDGE TABLES"
102        } else {
103            "VERTEX TABLES or EDGE TABLES"
104        };
105        return Ok(p.expected_ref(expected, p.peek_token_ref())?);
106    }
107    p.expect_token(&Token::LParen)?;
108    let edges = parse_edge_table(p)?;
109    if p.peek_token_ref().token == Token::Comma {
110        return unsupported(p, "several edge tables are");
111    }
112    p.expect_token(&Token::RParen)?;
113    if word(p, 0, "VERTEX") || word(p, 0, "NODE") {
114        return unsupported(p, "VERTEX TABLES after EDGE TABLES is");
115    }
116    if word(p, 0, "EDGE") || word(p, 0, "RELATIONSHIP") {
117        return unsupported(p, "several EDGE TABLES clauses are");
118    }
119    Ok((vertices, edges))
120}
121
122fn parse_vertex_table(p: &mut Parser) -> Result<VertexTableDef> {
123    let table = p.parse_object_name(false)?;
124    reject_element_options(p)?;
125    if !consume_words(p, &["KEY"]) {
126        if matches!(p.peek_token_ref().token, Token::RParen | Token::Comma) {
127            return unsupported(
128                p,
129                "a vertex table without KEY is (name its key column with KEY (column))",
130            );
131        }
132        return Ok(p.expected_ref("KEY", p.peek_token_ref())?);
133    }
134    let key = parse_key(p)?;
135    reject_element_options(p)?;
136    Ok(VertexTableDef { table, key })
137}
138
139fn parse_edge_table(p: &mut Parser) -> Result<EdgeTableDef> {
140    let table = p.parse_object_name(false)?;
141    reject_element_options(p)?;
142    if word(p, 0, "KEY") {
143        return unsupported(p, "an edge table KEY is");
144    }
145    expect_word(p, "SOURCE")?;
146    let source = parse_endpoint(p, "SOURCE")?;
147    expect_word(p, "DESTINATION")?;
148    let destination = parse_endpoint(p, "DESTINATION")?;
149    reject_element_options(p)?;
150    Ok(EdgeTableDef {
151        table,
152        source,
153        destination,
154    })
155}
156
157fn parse_endpoint(p: &mut Parser, side: &str) -> Result<EndpointDef> {
158    if !consume_words(p, &["KEY"]) {
159        if matches!(p.peek_token_ref().token, Token::Word(_)) {
160            return unsupported(
161                p,
162                &format!(
163                    "{side} without KEY is (name the column with {side} KEY (column) \
164                     REFERENCES vertex_table (key))"
165                ),
166            );
167        }
168        return Ok(p.expected_ref("KEY", p.peek_token_ref())?);
169    }
170    let key = parse_key(p)?;
171    let references = if consume_words(p, &["REFERENCES"]) {
172        let table = p.parse_object_name(false)?;
173        let column = parse_key(p)?;
174        Some(References { table, column })
175    } else {
176        None
177    };
178    Ok(EndpointDef { key, references })
179}
180
181/// `(column)`: a key of exactly one column.
182fn parse_key(p: &mut Parser) -> Result<Ident> {
183    p.expect_token(&Token::LParen)?;
184    let column = p.parse_identifier()?;
185    if p.peek_token_ref().token == Token::Comma {
186        return unsupported(
187            p,
188            "multi-column keys are (KEY, SOURCE KEY, DESTINATION KEY and REFERENCES take one column)",
189        );
190    }
191    p.expect_token(&Token::RParen)?;
192    Ok(column)
193}
194
195/// Reject SQL/PGQ's element table options: `AS alias`, `LABEL`, `DEFAULT
196/// LABEL`, `PROPERTIES`, `NO PROPERTIES`.
197fn reject_element_options(p: &mut Parser) -> Result<()> {
198    if word(p, 0, "AS") {
199        return unsupported(p, "table aliases (AS) are");
200    }
201    if word(p, 0, "LABEL") || (word(p, 0, "DEFAULT") && word(p, 1, "LABEL")) {
202        return unsupported(p, "labels (LABEL, DEFAULT LABEL) are");
203    }
204    if word(p, 0, "PROPERTIES") || (word(p, 0, "NO") && word(p, 1, "PROPERTIES")) {
205        return unsupported(
206            p,
207            "PROPERTIES clauses are (every column of the vertex and edge tables is a property)",
208        );
209    }
210    Ok(())
211}
212
213/// The error for a SQL/PGQ feature beyond Ursa's subset, at the next token.
214fn unsupported<T>(p: &Parser, what: &str) -> Result<T> {
215    Err(DataFusionError::NotImplemented(format!(
216        "{what} not supported yet: {ONE_TABLE_EACH}{}",
217        p.peek_token_ref().span.start
218    )))
219}
220
221/// Whether the `n`th token ahead is the unquoted word `word`, in any case.
222pub(crate) fn word(p: &Parser, n: usize, word: &str) -> bool {
223    match &p.peek_nth_token_ref(n).token {
224        Token::Word(w) => w.quote_style.is_none() && w.value.eq_ignore_ascii_case(word),
225        _ => false,
226    }
227}
228
229/// Consume `words` if they are next.
230pub(crate) fn consume_words(p: &mut Parser, words: &[&str]) -> bool {
231    let found = words.iter().enumerate().all(|(i, w)| word(p, i, w));
232    if found {
233        for _ in words {
234            p.next_token();
235        }
236    }
237    found
238}
239
240fn expect_word(p: &mut Parser, expected: &str) -> Result<()> {
241    if consume_words(p, &[expected]) {
242        Ok(())
243    } else {
244        Ok(p.expected_ref(expected, p.peek_token_ref())?)
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use datafusion::sql::sqlparser::ast::ObjectNamePart;
251
252    use super::*;
253    use crate::{parse_sql, CreatePropertyGraph, UrsaStatement};
254
255    fn create(sql: &str) -> CreatePropertyGraph {
256        let mut statements = parse_sql(sql).unwrap_or_else(|e| panic!("{sql}: {e}"));
257        assert_eq!(statements.len(), 1, "{sql}");
258        match statements.pop().unwrap() {
259            UrsaStatement::CreatePropertyGraph(g) => *g,
260            other => panic!("{sql} parsed as {other:?}"),
261        }
262    }
263
264    fn error(sql: &str) -> String {
265        match parse_sql(sql) {
266            Ok(statements) => panic!("{sql} should not parse; got {statements:?}"),
267            Err(e) => ursa_engine::user_message(&e),
268        }
269    }
270
271    fn name(parts: &[&str]) -> ObjectName {
272        ObjectName(
273            parts
274                .iter()
275                .map(|p| ObjectNamePart::Identifier(Ident::new(*p)))
276                .collect(),
277        )
278    }
279
280    fn endpoint(key: &str, references: Option<(&str, &str)>) -> EndpointDef {
281        EndpointDef {
282            key: Ident::new(key),
283            references: references.map(|(table, column)| References {
284                table: name(&[table]),
285                column: Ident::new(column),
286            }),
287        }
288    }
289
290    const GETTING_STARTED: &str = "CREATE PROPERTY GRAPH karate
291  VERTEX TABLES (members KEY (id))
292  EDGE TABLES (
293    ties SOURCE KEY (src) REFERENCES members (id)
294         DESTINATION KEY (dst) REFERENCES members (id)
295  )";
296
297    #[test]
298    fn the_getting_started_graph() {
299        assert_eq!(
300            create(GETTING_STARTED),
301            CreatePropertyGraph {
302                name: name(&["karate"]),
303                or_replace: false,
304                if_not_exists: false,
305                vertices: Some(VertexTableDef {
306                    table: name(&["members"]),
307                    key: Ident::new("id"),
308                }),
309                edges: EdgeTableDef {
310                    table: name(&["ties"]),
311                    source: endpoint("src", Some(("members", "id"))),
312                    destination: endpoint("dst", Some(("members", "id"))),
313                },
314            }
315        );
316    }
317
318    #[test]
319    fn accepted_forms() {
320        let g = create(
321            "CREATE PROPERTY GRAPH karate_edges_only
322               EDGE TABLES (ties SOURCE KEY (src) DESTINATION KEY (dst))",
323        );
324        assert_eq!(g.vertices, None);
325        assert_eq!(g.edges.source, endpoint("src", None));
326        assert_eq!(g.edges.destination, endpoint("dst", None));
327
328        // Keywords in any case; qualified and quoted names; REFERENCES on one side.
329        let g = create(
330            r#"create or replace property graph if not exists "Social"
331                 node tables (db.public."People" key ("Id"))
332                 relationship tables (db.follows source key (a) references db.public."People" ("Id")
333                                                  destination key (b))"#,
334        );
335        assert!(g.or_replace && g.if_not_exists);
336        assert_eq!(g.name.to_string(), r#""Social""#);
337        let vertices = g.vertices.unwrap();
338        assert_eq!(vertices.table.to_string(), r#"db.public."People""#);
339        assert_eq!(vertices.key, Ident::with_quote('"', "Id"));
340        assert_eq!(g.edges.table.to_string(), "db.follows");
341        assert_eq!(
342            g.edges.source.references.unwrap().table.to_string(),
343            r#"db.public."People""#
344        );
345        assert_eq!(g.edges.destination.references, None);
346    }
347
348    #[test]
349    fn display_round_trips() {
350        for sql in [
351            GETTING_STARTED,
352            "CREATE PROPERTY GRAPH g EDGE TABLES (e SOURCE KEY (a) DESTINATION KEY (b))",
353            r#"CREATE OR REPLACE PROPERTY GRAPH IF NOT EXISTS "G" VERTEX TABLES (s.v KEY ("K"))
354               EDGE TABLES (s.e SOURCE KEY (a) DESTINATION KEY (b) REFERENCES s.v ("K"))"#,
355        ] {
356            let parsed = UrsaStatement::CreatePropertyGraph(Box::new(create(sql)));
357            let printed = parsed.to_string();
358            let mut again = parse_sql(&printed).unwrap();
359            assert_eq!(again.pop().unwrap(), parsed, "{printed}");
360        }
361        assert_eq!(
362            UrsaStatement::CreatePropertyGraph(Box::new(create(GETTING_STARTED))).to_string(),
363            "CREATE PROPERTY GRAPH karate VERTEX TABLES (members KEY (id)) EDGE TABLES (ties \
364             SOURCE KEY (src) REFERENCES members (id) DESTINATION KEY (dst) REFERENCES members (id))"
365        );
366    }
367
368    #[test]
369    fn the_statement_after_a_graph_still_parses() {
370        let sql =
371            "CREATE PROPERTY GRAPH g EDGE TABLES (e SOURCE KEY (a) DESTINATION KEY (b)); SELECT 1";
372        assert_eq!(parse_sql(sql).unwrap().len(), 2);
373    }
374
375    #[test]
376    fn sql_pgq_beyond_the_subset_is_rejected() {
377        let not_supported = "not supported yet: Ursa graphs have one edge table and at most one \
378                             vertex table";
379        let properties =
380            "PROPERTIES clauses are (every column of the vertex and edge tables is a property)";
381        let multi_column =
382            "multi-column keys are (KEY, SOURCE KEY, DESTINATION KEY and REFERENCES take one column)";
383        for (sql, what) in [
384            (
385                "CREATE PROPERTY GRAPH g VERTEX TABLES (a KEY (id), b KEY (id))
386                   EDGE TABLES (e SOURCE KEY (s) DESTINATION KEY (d))",
387                "several vertex tables are",
388            ),
389            (
390                "CREATE PROPERTY GRAPH g EDGE TABLES (e SOURCE KEY (s) DESTINATION KEY (d),
391                   f SOURCE KEY (s) DESTINATION KEY (d))",
392                "several edge tables are",
393            ),
394            (
395                "CREATE PROPERTY GRAPH g EDGE TABLES (e SOURCE KEY (s) DESTINATION KEY (d))
396                   EDGE TABLES (f SOURCE KEY (s) DESTINATION KEY (d))",
397                "several EDGE TABLES clauses are",
398            ),
399            (
400                "CREATE PROPERTY GRAPH g EDGE TABLES (e SOURCE KEY (s) DESTINATION KEY (d))
401                   VERTEX TABLES (v KEY (id))",
402                "VERTEX TABLES after EDGE TABLES is",
403            ),
404            (
405                "CREATE PROPERTY GRAPH g VERTEX TABLES (members AS m KEY (id))
406                   EDGE TABLES (e SOURCE KEY (s) DESTINATION KEY (d))",
407                "table aliases (AS) are",
408            ),
409            (
410                "CREATE PROPERTY GRAPH g EDGE TABLES (e AS ties SOURCE KEY (s) DESTINATION KEY (d))",
411                "table aliases (AS) are",
412            ),
413            (
414                "CREATE PROPERTY GRAPH g VERTEX TABLES (v KEY (id) LABEL person)
415                   EDGE TABLES (e SOURCE KEY (s) DESTINATION KEY (d))",
416                "labels (LABEL, DEFAULT LABEL) are",
417            ),
418            (
419                "CREATE PROPERTY GRAPH g EDGE TABLES (e SOURCE KEY (s) DESTINATION KEY (d) DEFAULT LABEL)",
420                "labels (LABEL, DEFAULT LABEL) are",
421            ),
422            (
423                "CREATE PROPERTY GRAPH g VERTEX TABLES (v KEY (id) PROPERTIES (name))
424                   EDGE TABLES (e SOURCE KEY (s) DESTINATION KEY (d))",
425                properties,
426            ),
427            (
428                "CREATE PROPERTY GRAPH g VERTEX TABLES (v KEY (id) PROPERTIES ARE ALL COLUMNS)
429                   EDGE TABLES (e SOURCE KEY (s) DESTINATION KEY (d))",
430                properties,
431            ),
432            (
433                "CREATE PROPERTY GRAPH g EDGE TABLES (e SOURCE KEY (s) DESTINATION KEY (d) NO PROPERTIES)",
434                properties,
435            ),
436            (
437                "CREATE PROPERTY GRAPH g VERTEX TABLES (v KEY (a, b))
438                   EDGE TABLES (e SOURCE KEY (s) DESTINATION KEY (d))",
439                multi_column,
440            ),
441            (
442                "CREATE PROPERTY GRAPH g EDGE TABLES (e SOURCE KEY (s1, s2) DESTINATION KEY (d))",
443                multi_column,
444            ),
445            (
446                "CREATE PROPERTY GRAPH g VERTEX TABLES (v KEY (id))
447                   EDGE TABLES (e SOURCE KEY (s) REFERENCES v (id, x) DESTINATION KEY (d))",
448                multi_column,
449            ),
450            (
451                "CREATE PROPERTY GRAPH g EDGE TABLES (e KEY (id) SOURCE KEY (s) DESTINATION KEY (d))",
452                "an edge table KEY is",
453            ),
454            (
455                "CREATE PROPERTY GRAPH g VERTEX TABLES (v KEY (id))
456                   EDGE TABLES (e SOURCE v DESTINATION v)",
457                "SOURCE without KEY is (name the column with SOURCE KEY (column) REFERENCES \
458                 vertex_table (key))",
459            ),
460            (
461                "CREATE PROPERTY GRAPH g VERTEX TABLES (v)
462                   EDGE TABLES (e SOURCE KEY (s) DESTINATION KEY (d))",
463                "a vertex table without KEY is (name its key column with KEY (column))",
464            ),
465        ] {
466            let message = error(sql);
467            assert!(
468                message.starts_with(&format!("{what} {not_supported} at Line: ")),
469                "{sql}\n{message}"
470            );
471        }
472    }
473
474    #[test]
475    fn syntax_errors_read_like_datafusions() {
476        for (sql, expected) in [
477            (
478                "CREATE PROPERTY GRAPH g",
479                "syntax error: Expected: VERTEX TABLES or EDGE TABLES, found: EOF",
480            ),
481            (
482                "CREATE PROPERTY GRAPH g VERTEX TABLES (v KEY (id))",
483                "syntax error: Expected: EDGE TABLES, found: EOF",
484            ),
485            (
486                "CREATE PROPERTY GRAPH g EDGE TABLES e SOURCE KEY (s) DESTINATION KEY (d)",
487                "syntax error: Expected: (, found: e at Line: 1, Column: 37",
488            ),
489            (
490                "CREATE PROPERTY GRAPH g EDGE TABLES (e SOURCE KEY (s))",
491                "syntax error: Expected: DESTINATION, found: ) at Line: 1, Column: 54",
492            ),
493            (
494                "CREATE PROPERTY GRAPH g EDGE TABLES (e DESTINATION KEY (d) SOURCE KEY (s))",
495                "syntax error: Expected: SOURCE, found: DESTINATION at Line: 1, Column: 40",
496            ),
497            (
498                "CREATE PROPERTY GRAPH g EDGE TABLES (e SOURCE KEY s DESTINATION KEY (d))",
499                "syntax error: Expected: (, found: s at Line: 1, Column: 51",
500            ),
501            (
502                "CREATE PROPERTY GRAPH g EDGE TABLES (e SOURCE KEY (s) DESTINATION KEY (d)",
503                "syntax error: Expected: ), found: EOF",
504            ),
505            (
506                "CREATE PROPERTY GRAPH g VERTEX TABLES (v ID (id)) EDGE TABLES (e SOURCE KEY (s) DESTINATION KEY (d))",
507                "syntax error: Expected: KEY, found: ID at Line: 1, Column: 42",
508            ),
509        ] {
510            assert_eq!(error(sql), expected, "{sql}");
511        }
512    }
513}