Skip to main content

nodedb_sql/ddl_ast/parse/
dispatch.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Dispatcher: try each DDL family's `try_parse` in turn.
4
5use super::{
6    alert, backup, change_stream, cluster_admin, collection, conflict_policy, copy_from, copy_to,
7    custom_type, database, grant, graph_stats, index, maintenance, materialized_view,
8    oidc_provider, retention, rls, schedule, sequence, synonym_group, tenant, trigger, user_auth,
9};
10use crate::ddl_ast::graph_parse;
11use crate::ddl_ast::statement::NodedbStatement;
12use crate::error::SqlError;
13use crate::parser::preprocess::lex;
14
15/// Try to parse a DDL statement from raw SQL.
16///
17/// Returns `None` for non-DDL queries (SELECT, INSERT, etc.) that should
18/// flow through the normal planner. Returns `Some(Err(...))` when the SQL
19/// is structurally a DDL command but contains a reserved identifier that
20/// would be misrouted by the dispatcher.
21pub fn parse(sql: &str) -> Option<Result<NodedbStatement, SqlError>> {
22    let trimmed = sql.trim();
23    if trimmed.is_empty() {
24        return None;
25    }
26    let upper = trimmed.to_uppercase();
27    let parts: Vec<&str> = trimmed.split_whitespace().collect();
28    if parts.is_empty() {
29        return None;
30    }
31
32    // Graph DSL (`GRAPH ...`, `MATCH ...`, `OPTIONAL MATCH ...`) has its own
33    // tokenising parser — delegate early using token-aware dispatch so that
34    // leading block/line comments and quoted values containing DSL keywords
35    // are never mistakenly matched.
36    let first = lex::first_sql_word(trimmed).map(|w| w.to_uppercase());
37    let is_graph = match first.as_deref() {
38        Some("GRAPH") | Some("MATCH") => true,
39        Some("OPTIONAL") => lex::second_sql_word(trimmed)
40            .map(|w| w.eq_ignore_ascii_case("MATCH"))
41            .unwrap_or(false),
42        _ => false,
43    };
44    if is_graph {
45        return graph_parse::try_parse(trimmed).map(Ok);
46    }
47
48    // Dispatch by family. Order matters only where prefixes overlap
49    // (e.g. DESCRIBE vs DESCRIBE SEQUENCE — handled inside each
50    // family's `try_parse`). A `Some(Err(...))` from any family
51    // short-circuits the chain — reserved-identifier errors must not
52    // be silently swallowed by the next family's `None` path.
53    macro_rules! try_family {
54        ($result:expr) => {{
55            let r = $result;
56            if r.is_some() {
57                return r;
58            }
59        }};
60    }
61
62    // `SHOW GRAPH STATS` must be checked before the generic collection parser
63    // so its `SHOW` prefix is not consumed by `SHOW COLLECTIONS`/etc.
64    try_family!(graph_stats::try_parse(&upper, &parts, trimmed));
65    // Conflict policy must be checked before the generic collection parser
66    // so "SET ON CONFLICT" does not fall through to the raw-SQL path.
67    try_family!(conflict_policy::try_parse(&upper, &parts, trimmed));
68    try_family!(collection::try_parse(&upper, &parts, trimmed));
69    try_family!(index::try_parse(&upper, &parts, trimmed));
70    try_family!(trigger::try_parse(&upper, &parts, trimmed));
71    try_family!(schedule::try_parse(&upper, &parts, trimmed));
72    try_family!(sequence::try_parse(&upper, &parts, trimmed));
73    try_family!(alert::try_parse(&upper, &parts, trimmed));
74    try_family!(retention::try_parse(&upper, &parts, trimmed));
75    try_family!(cluster_admin::try_parse(&upper, &parts, trimmed));
76    try_family!(maintenance::try_parse(&upper, &parts, trimmed));
77    try_family!(backup::try_parse(&upper, &parts, trimmed));
78    // COPY FROM file-path form — must come after backup so STDIN forms fall through.
79    try_family!(copy_from::try_parse(&upper, &parts, trimmed));
80    // COPY TO file-path form — table and query forms.
81    try_family!(copy_to::try_parse(&upper, trimmed));
82    try_family!(grant::try_parse(&upper, &parts, trimmed));
83    try_family!(user_auth::try_parse(&upper, &parts, trimmed));
84    try_family!(oidc_provider::try_parse(&upper, &parts, trimmed));
85    try_family!(change_stream::try_parse(&upper, &parts, trimmed));
86    try_family!(rls::try_parse(&upper, &parts, trimmed));
87    try_family!(materialized_view::try_parse(&upper, &parts, trimmed));
88    try_family!(synonym_group::try_parse(&upper, &parts, trimmed));
89    try_family!(custom_type::try_parse(&upper, &parts, trimmed));
90    try_family!(database::try_parse(&upper, &parts, trimmed));
91    try_family!(tenant::try_parse(&upper, &parts, trimmed));
92    None
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::ddl_ast::statement::{
99        AuthStmt, AutomationStmt, ClusterStmt, CollectionStmt, DatabaseStmt, GraphStmt,
100    };
101    use crate::error::SqlError;
102
103    /// Parse and double-unwrap — panics if `None` or `Err`.
104    fn ok(sql: &str) -> NodedbStatement {
105        parse(sql)
106            .expect("expected Some, got None")
107            .expect("expected Ok, got Err")
108    }
109
110    /// Assert `parse` returns `Some(Err(SqlError::ReservedIdentifier { .. }))`.
111    fn assert_reserved(sql: &str) {
112        match parse(sql) {
113            Some(Err(SqlError::ReservedIdentifier { .. })) => {}
114            other => panic!("expected Some(Err(ReservedIdentifier)), got {other:?}"),
115        }
116    }
117
118    #[test]
119    fn parse_create_collection() {
120        let stmt = ok("CREATE COLLECTION users (id INT, name TEXT)");
121        match stmt {
122            NodedbStatement::Collection(CollectionStmt::CreateCollection {
123                name,
124                if_not_exists,
125                ..
126            }) => {
127                assert_eq!(name, "users");
128                assert!(!if_not_exists);
129            }
130            other => panic!("expected CreateCollection, got {other:?}"),
131        }
132    }
133
134    #[test]
135    fn parse_create_collection_if_not_exists() {
136        let stmt = ok("CREATE COLLECTION IF NOT EXISTS users");
137        match stmt {
138            NodedbStatement::Collection(CollectionStmt::CreateCollection {
139                name,
140                if_not_exists,
141                ..
142            }) => {
143                assert_eq!(name, "users");
144                assert!(if_not_exists);
145            }
146            other => panic!("expected CreateCollection, got {other:?}"),
147        }
148    }
149
150    #[test]
151    fn parse_drop_collection() {
152        let stmt = ok("DROP COLLECTION users");
153        assert_eq!(
154            stmt,
155            NodedbStatement::Collection(CollectionStmt::DropCollection {
156                name: "users".into(),
157                if_exists: false,
158                purge: false,
159                cascade: false,
160                cascade_force: false,
161            })
162        );
163    }
164
165    #[test]
166    fn parse_drop_collection_if_exists() {
167        let stmt = ok("DROP COLLECTION IF EXISTS users");
168        assert_eq!(
169            stmt,
170            NodedbStatement::Collection(CollectionStmt::DropCollection {
171                name: "users".into(),
172                if_exists: true,
173                purge: false,
174                cascade: false,
175                cascade_force: false,
176            })
177        );
178    }
179
180    #[test]
181    fn parse_drop_collection_purge() {
182        let stmt = ok("DROP COLLECTION users PURGE");
183        assert_eq!(
184            stmt,
185            NodedbStatement::Collection(CollectionStmt::DropCollection {
186                name: "users".into(),
187                if_exists: false,
188                purge: true,
189                cascade: false,
190                cascade_force: false,
191            })
192        );
193    }
194
195    #[test]
196    fn parse_drop_collection_cascade() {
197        let stmt = ok("DROP COLLECTION users CASCADE");
198        assert_eq!(
199            stmt,
200            NodedbStatement::Collection(CollectionStmt::DropCollection {
201                name: "users".into(),
202                if_exists: false,
203                purge: false,
204                cascade: true,
205                cascade_force: false,
206            })
207        );
208    }
209
210    #[test]
211    fn parse_drop_collection_purge_cascade() {
212        let stmt = ok("DROP COLLECTION users PURGE CASCADE");
213        assert_eq!(
214            stmt,
215            NodedbStatement::Collection(CollectionStmt::DropCollection {
216                name: "users".into(),
217                if_exists: false,
218                purge: true,
219                cascade: true,
220                cascade_force: false,
221            })
222        );
223    }
224
225    #[test]
226    fn parse_drop_collection_cascade_force() {
227        let stmt = ok("DROP COLLECTION users CASCADE FORCE");
228        assert_eq!(
229            stmt,
230            NodedbStatement::Collection(CollectionStmt::DropCollection {
231                name: "users".into(),
232                if_exists: false,
233                purge: false,
234                cascade: true,
235                cascade_force: true,
236            })
237        );
238    }
239
240    #[test]
241    fn parse_undrop_collection() {
242        let stmt = ok("UNDROP COLLECTION users");
243        assert_eq!(
244            stmt,
245            NodedbStatement::Collection(CollectionStmt::UndropCollection {
246                name: "users".into()
247            })
248        );
249    }
250
251    #[test]
252    fn parse_undrop_table_alias() {
253        let stmt = ok("UNDROP TABLE users");
254        assert_eq!(
255            stmt,
256            NodedbStatement::Collection(CollectionStmt::UndropCollection {
257                name: "users".into()
258            })
259        );
260    }
261
262    #[test]
263    fn parse_show_nodes() {
264        assert_eq!(
265            parse("SHOW NODES"),
266            Some(Ok(NodedbStatement::Cluster(ClusterStmt::ShowNodes)))
267        );
268    }
269
270    #[test]
271    fn parse_show_cluster() {
272        assert_eq!(
273            parse("SHOW CLUSTER"),
274            Some(Ok(NodedbStatement::Cluster(ClusterStmt::ShowCluster)))
275        );
276    }
277
278    #[test]
279    fn parse_create_trigger() {
280        let stmt = ok(
281            "CREATE OR REPLACE SYNC TRIGGER on_insert AFTER INSERT ON orders FOR EACH ROW BEGIN RETURN; END",
282        );
283        match stmt {
284            NodedbStatement::Automation(AutomationStmt::CreateTrigger {
285                or_replace,
286                execution_mode,
287                timing,
288                ..
289            }) => {
290                assert!(or_replace);
291                assert_eq!(execution_mode, "SYNC");
292                assert_eq!(timing, "AFTER");
293            }
294            other => panic!("expected CreateTrigger, got {other:?}"),
295        }
296    }
297
298    #[test]
299    fn parse_drop_index_if_exists() {
300        let stmt = ok("DROP INDEX IF EXISTS idx_name");
301        match stmt {
302            NodedbStatement::Collection(CollectionStmt::DropIndex {
303                name, if_exists, ..
304            }) => {
305                assert_eq!(name, "idx_name");
306                assert!(if_exists);
307            }
308            other => panic!("expected DropIndex, got {other:?}"),
309        }
310    }
311
312    #[test]
313    fn parse_analyze() {
314        assert_eq!(
315            parse("ANALYZE users"),
316            Some(Ok(NodedbStatement::Cluster(ClusterStmt::Analyze {
317                collection: Some("users".into()),
318            })))
319        );
320        assert_eq!(
321            parse("ANALYZE"),
322            Some(Ok(NodedbStatement::Cluster(ClusterStmt::Analyze {
323                collection: None
324            })))
325        );
326    }
327
328    #[test]
329    fn parse_create_table_plain() {
330        let stmt = ok("CREATE TABLE foo (id INT, name TEXT)");
331        match stmt {
332            NodedbStatement::Collection(CollectionStmt::CreateTable {
333                name,
334                if_not_exists,
335                ..
336            }) => {
337                assert_eq!(name, "foo");
338                assert!(!if_not_exists);
339            }
340            other => panic!("expected CreateTable, got {other:?}"),
341        }
342    }
343
344    #[test]
345    fn parse_create_table_if_not_exists() {
346        let stmt = ok("CREATE TABLE IF NOT EXISTS orders (id INT)");
347        match stmt {
348            NodedbStatement::Collection(CollectionStmt::CreateTable {
349                name,
350                if_not_exists,
351                ..
352            }) => {
353                assert_eq!(name, "orders");
354                assert!(if_not_exists);
355            }
356            other => panic!("expected CreateTable, got {other:?}"),
357        }
358    }
359
360    #[test]
361    fn create_collection_is_not_create_table() {
362        let stmt = ok("CREATE COLLECTION foo");
363        assert!(matches!(
364            stmt,
365            NodedbStatement::Collection(CollectionStmt::CreateCollection { .. })
366        ));
367    }
368
369    #[test]
370    fn non_ddl_returns_none() {
371        assert!(parse("SELECT * FROM users").is_none());
372        assert!(parse("INSERT INTO users VALUES (1)").is_none());
373    }
374
375    #[test]
376    fn create_function_returns_none() {
377        // CREATE FUNCTION and CREATE PROCEDURE are handled by the text-based
378        // function router, not the DDL AST dispatcher.
379        assert!(
380            parse("CREATE OR REPLACE FUNCTION double_int(x INT) RETURNS INT AS SELECT x * 2")
381                .is_none(),
382            "expected None for CREATE FUNCTION"
383        );
384        assert!(
385            parse("CREATE FUNCTION foo(x INT) RETURNS INT AS SELECT x").is_none(),
386            "expected None for CREATE FUNCTION"
387        );
388        assert!(
389            parse("CREATE OR REPLACE PROCEDURE noop_proc() AS BEGIN END").is_none(),
390            "expected None for CREATE PROCEDURE"
391        );
392    }
393
394    // ── graph dispatch (token-aware) ────────────────────────────────────────
395
396    /// `MATCH` as first real token routes to the graph parser.
397    #[test]
398    fn graph_dispatch_match_plain() {
399        let _ = parse("MATCH (a)-[]->(b) RETURN a");
400    }
401
402    /// `GRAPH` as first real token routes to the graph parser.
403    #[test]
404    fn graph_dispatch_graph_keyword() {
405        let _ = parse("GRAPH something");
406    }
407
408    /// A leading block comment before `MATCH` must still route to graph.
409    #[test]
410    fn graph_dispatch_block_comment_before_match() {
411        let _ = parse("/* hint */ MATCH (a) RETURN a");
412    }
413
414    /// `OPTIONAL MATCH` routes to the graph parser.
415    #[test]
416    fn graph_dispatch_optional_match() {
417        let _ = parse("OPTIONAL MATCH (a) RETURN a");
418    }
419
420    /// `OPTIONAL` followed by something other than `MATCH` must NOT route to
421    /// the graph parser (falls through to DDL families, which return None).
422    #[test]
423    fn graph_dispatch_optional_non_match_does_not_route() {
424        assert!(parse("OPTIONAL FOO").is_none());
425    }
426
427    #[test]
428    fn graph_dispatch_select_with_match_in_string() {
429        assert!(parse("SELECT * FROM t WHERE name = 'MATCH'").is_none());
430    }
431
432    #[test]
433    fn graph_dispatch_select_with_graph_in_string() {
434        assert!(parse("SELECT * FROM t WHERE name = 'GRAPH'").is_none());
435    }
436
437    #[test]
438    fn graph_dispatch_with_cte_does_not_route() {
439        assert!(parse("WITH cte AS (SELECT 1) SELECT * FROM cte").is_none());
440    }
441
442    #[test]
443    fn graph_dispatch_line_comment_match_then_select() {
444        assert!(parse("-- MATCH (a)\nSELECT 1").is_none());
445    }
446
447    // ── MatchQuery.body field ─────────────────────────────────────────────────
448
449    #[test]
450    fn match_query_uses_body_field() {
451        let stmt = ok("MATCH (x)-[:l]->(y) RETURN x, y");
452        match stmt {
453            NodedbStatement::Graph(GraphStmt::MatchQuery { body }) => {
454                assert!(body.starts_with("MATCH"), "body must hold the original SQL");
455            }
456            other => panic!("expected MatchQuery, got {other:?}"),
457        }
458    }
459
460    // ── AddMaterializedSum typed parsing ─────────────────────────────────────
461
462    #[test]
463    fn parse_add_materialized_sum_typed() {
464        // Representative input: ALTER COLLECTION <target> ADD COLUMN <col> DECIMAL
465        // AS MATERIALIZED_SUM SOURCE <src> ON <src>.join_col = <target>.id VALUE <src>.amount
466        let stmt = ok(
467            "ALTER COLLECTION accounts ADD COLUMN balance DECIMAL AS MATERIALIZED_SUM \
468             SOURCE orders ON orders.account_id = accounts.id VALUE orders.amount",
469        );
470        match stmt {
471            NodedbStatement::Collection(CollectionStmt::AlterCollection { name, operation }) => {
472                assert_eq!(name, "accounts");
473                match operation {
474                    crate::ddl_ast::AlterCollectionOp::AddMaterializedSum {
475                        target_collection,
476                        target_column,
477                        source_collection,
478                        join_column,
479                        value_expr,
480                    } => {
481                        assert_eq!(target_collection, "accounts");
482                        assert_eq!(target_column, "balance");
483                        assert_eq!(source_collection, "orders");
484                        assert_eq!(join_column, "account_id");
485                        assert_eq!(value_expr, "amount");
486                    }
487                    other => panic!("expected AddMaterializedSum, got {other:?}"),
488                }
489            }
490            other => panic!("expected AlterCollection, got {other:?}"),
491        }
492    }
493
494    #[test]
495    fn parse_grant_role() {
496        let stmt = ok("GRANT ROLE admin TO alice");
497        match stmt {
498            NodedbStatement::Auth(AuthStmt::GrantRole { roles, grantee }) => {
499                assert_eq!(roles, vec!["admin"]);
500                assert_eq!(grantee, "alice");
501            }
502            other => panic!("expected GrantRole, got {other:?}"),
503        }
504    }
505
506    #[test]
507    fn parse_create_sequence_if_not_exists() {
508        let stmt = ok("CREATE SEQUENCE IF NOT EXISTS my_seq START 1");
509        match stmt {
510            NodedbStatement::Collection(CollectionStmt::CreateSequence {
511                name,
512                if_not_exists,
513                ..
514            }) => {
515                assert_eq!(name, "my_seq");
516                assert!(if_not_exists);
517            }
518            other => panic!("expected CreateSequence, got {other:?}"),
519        }
520    }
521
522    #[test]
523    fn parse_restore_dry_run() {
524        let stmt = ok("RESTORE TENANT 1 FROM '/tmp/backup' DRY RUN");
525        match stmt {
526            NodedbStatement::Database(DatabaseStmt::RestoreTenant {
527                dry_run,
528                force,
529                tenant_id,
530            }) => {
531                assert!(dry_run);
532                assert!(!force);
533                assert_eq!(tenant_id, "1");
534            }
535            other => panic!("expected RestoreTenant, got {other:?}"),
536        }
537    }
538
539    #[test]
540    fn parse_restore_force() {
541        let stmt = ok("RESTORE TENANT 1 FROM '/tmp/backup' FORCE");
542        match stmt {
543            NodedbStatement::Database(DatabaseStmt::RestoreTenant {
544                dry_run,
545                force,
546                tenant_id,
547            }) => {
548                assert!(!dry_run);
549                assert!(force);
550                assert_eq!(tenant_id, "1");
551            }
552            other => panic!("expected RestoreTenant, got {other:?}"),
553        }
554    }
555
556    // ── reserved identifier tests ─────────────────────────────────────────────
557
558    #[test]
559    fn create_table_reserved_name_is_err() {
560        assert_reserved("CREATE TABLE match (id INT)");
561    }
562
563    #[test]
564    fn create_table_quoted_reserved_name_is_ok() {
565        let stmt = ok(r#"CREATE TABLE "match" (id INT)"#);
566        match stmt {
567            NodedbStatement::Collection(CollectionStmt::CreateTable { name, .. }) => {
568                assert_eq!(name, "match")
569            }
570            other => panic!("expected CreateTable, got {other:?}"),
571        }
572    }
573
574    #[test]
575    fn create_collection_reserved_name_is_err() {
576        assert_reserved("CREATE COLLECTION upsert (id INT)");
577    }
578
579    #[test]
580    fn create_table_reserved_column_is_err() {
581        assert_reserved("CREATE TABLE foo (graph INT)");
582    }
583
584    #[test]
585    fn create_table_quoted_reserved_column_is_ok() {
586        let stmt = ok(r#"CREATE TABLE foo ("graph" INT)"#);
587        match stmt {
588            NodedbStatement::Collection(CollectionStmt::CreateTable { columns, .. }) => {
589                assert_eq!(columns[0].0, "graph");
590            }
591            other => panic!("expected CreateTable, got {other:?}"),
592        }
593    }
594
595    // One test per reserved word: rejected bare, accepted quoted.
596
597    #[test]
598    fn reserved_graph() {
599        assert_reserved("CREATE TABLE graph (id INT)");
600        let stmt = ok(r#"CREATE TABLE "graph" (id INT)"#);
601        assert!(matches!(
602            stmt,
603            NodedbStatement::Collection(CollectionStmt::CreateTable { .. })
604        ));
605    }
606
607    #[test]
608    fn reserved_match() {
609        assert_reserved("CREATE TABLE match (id INT)");
610        let stmt = ok(r#"CREATE TABLE "match" (id INT)"#);
611        assert!(matches!(
612            stmt,
613            NodedbStatement::Collection(CollectionStmt::CreateTable { .. })
614        ));
615    }
616
617    #[test]
618    fn reserved_optional() {
619        assert_reserved("CREATE TABLE optional (id INT)");
620        let stmt = ok(r#"CREATE TABLE "optional" (id INT)"#);
621        assert!(matches!(
622            stmt,
623            NodedbStatement::Collection(CollectionStmt::CreateTable { .. })
624        ));
625    }
626
627    #[test]
628    fn reserved_upsert() {
629        assert_reserved("CREATE COLLECTION upsert (id INT)");
630        let stmt = ok(r#"CREATE COLLECTION "upsert" (id INT)"#);
631        assert!(matches!(
632            stmt,
633            NodedbStatement::Collection(CollectionStmt::CreateCollection { .. })
634        ));
635    }
636
637    #[test]
638    fn reserved_undrop() {
639        assert_reserved("CREATE TABLE undrop (id INT)");
640        let stmt = ok(r#"CREATE TABLE "undrop" (id INT)"#);
641        assert!(matches!(
642            stmt,
643            NodedbStatement::Collection(CollectionStmt::CreateTable { .. })
644        ));
645    }
646
647    #[test]
648    fn reserved_purge() {
649        assert_reserved("CREATE TABLE purge (id INT)");
650        let stmt = ok(r#"CREATE TABLE "purge" (id INT)"#);
651        assert!(matches!(
652            stmt,
653            NodedbStatement::Collection(CollectionStmt::CreateTable { .. })
654        ));
655    }
656
657    #[test]
658    fn reserved_cascade() {
659        assert_reserved("CREATE TABLE cascade (id INT)");
660        let stmt = ok(r#"CREATE TABLE "cascade" (id INT)"#);
661        assert!(matches!(
662            stmt,
663            NodedbStatement::Collection(CollectionStmt::CreateTable { .. })
664        ));
665    }
666
667    #[test]
668    fn reserved_search() {
669        assert_reserved("CREATE TABLE search (id INT)");
670        let stmt = ok(r#"CREATE TABLE "search" (id INT)"#);
671        assert!(matches!(
672            stmt,
673            NodedbStatement::Collection(CollectionStmt::CreateTable { .. })
674        ));
675    }
676
677    #[test]
678    fn reserved_crdt() {
679        assert_reserved("CREATE TABLE crdt (id INT)");
680        let stmt = ok(r#"CREATE TABLE "crdt" (id INT)"#);
681        assert!(matches!(
682            stmt,
683            NodedbStatement::Collection(CollectionStmt::CreateTable { .. })
684        ));
685    }
686}