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 { dry_run, tenant_id }) => {
527                assert!(dry_run);
528                assert_eq!(tenant_id, "1");
529            }
530            other => panic!("expected RestoreTenant, got {other:?}"),
531        }
532    }
533
534    // ── reserved identifier tests ─────────────────────────────────────────────
535
536    #[test]
537    fn create_table_reserved_name_is_err() {
538        assert_reserved("CREATE TABLE match (id INT)");
539    }
540
541    #[test]
542    fn create_table_quoted_reserved_name_is_ok() {
543        let stmt = ok(r#"CREATE TABLE "match" (id INT)"#);
544        match stmt {
545            NodedbStatement::Collection(CollectionStmt::CreateTable { name, .. }) => {
546                assert_eq!(name, "match")
547            }
548            other => panic!("expected CreateTable, got {other:?}"),
549        }
550    }
551
552    #[test]
553    fn create_collection_reserved_name_is_err() {
554        assert_reserved("CREATE COLLECTION upsert (id INT)");
555    }
556
557    #[test]
558    fn create_table_reserved_column_is_err() {
559        assert_reserved("CREATE TABLE foo (graph INT)");
560    }
561
562    #[test]
563    fn create_table_quoted_reserved_column_is_ok() {
564        let stmt = ok(r#"CREATE TABLE foo ("graph" INT)"#);
565        match stmt {
566            NodedbStatement::Collection(CollectionStmt::CreateTable { columns, .. }) => {
567                assert_eq!(columns[0].0, "graph");
568            }
569            other => panic!("expected CreateTable, got {other:?}"),
570        }
571    }
572
573    // One test per reserved word: rejected bare, accepted quoted.
574
575    #[test]
576    fn reserved_graph() {
577        assert_reserved("CREATE TABLE graph (id INT)");
578        let stmt = ok(r#"CREATE TABLE "graph" (id INT)"#);
579        assert!(matches!(
580            stmt,
581            NodedbStatement::Collection(CollectionStmt::CreateTable { .. })
582        ));
583    }
584
585    #[test]
586    fn reserved_match() {
587        assert_reserved("CREATE TABLE match (id INT)");
588        let stmt = ok(r#"CREATE TABLE "match" (id INT)"#);
589        assert!(matches!(
590            stmt,
591            NodedbStatement::Collection(CollectionStmt::CreateTable { .. })
592        ));
593    }
594
595    #[test]
596    fn reserved_optional() {
597        assert_reserved("CREATE TABLE optional (id INT)");
598        let stmt = ok(r#"CREATE TABLE "optional" (id INT)"#);
599        assert!(matches!(
600            stmt,
601            NodedbStatement::Collection(CollectionStmt::CreateTable { .. })
602        ));
603    }
604
605    #[test]
606    fn reserved_upsert() {
607        assert_reserved("CREATE COLLECTION upsert (id INT)");
608        let stmt = ok(r#"CREATE COLLECTION "upsert" (id INT)"#);
609        assert!(matches!(
610            stmt,
611            NodedbStatement::Collection(CollectionStmt::CreateCollection { .. })
612        ));
613    }
614
615    #[test]
616    fn reserved_undrop() {
617        assert_reserved("CREATE TABLE undrop (id INT)");
618        let stmt = ok(r#"CREATE TABLE "undrop" (id INT)"#);
619        assert!(matches!(
620            stmt,
621            NodedbStatement::Collection(CollectionStmt::CreateTable { .. })
622        ));
623    }
624
625    #[test]
626    fn reserved_purge() {
627        assert_reserved("CREATE TABLE purge (id INT)");
628        let stmt = ok(r#"CREATE TABLE "purge" (id INT)"#);
629        assert!(matches!(
630            stmt,
631            NodedbStatement::Collection(CollectionStmt::CreateTable { .. })
632        ));
633    }
634
635    #[test]
636    fn reserved_cascade() {
637        assert_reserved("CREATE TABLE cascade (id INT)");
638        let stmt = ok(r#"CREATE TABLE "cascade" (id INT)"#);
639        assert!(matches!(
640            stmt,
641            NodedbStatement::Collection(CollectionStmt::CreateTable { .. })
642        ));
643    }
644
645    #[test]
646    fn reserved_search() {
647        assert_reserved("CREATE TABLE search (id INT)");
648        let stmt = ok(r#"CREATE TABLE "search" (id INT)"#);
649        assert!(matches!(
650            stmt,
651            NodedbStatement::Collection(CollectionStmt::CreateTable { .. })
652        ));
653    }
654
655    #[test]
656    fn reserved_crdt() {
657        assert_reserved("CREATE TABLE crdt (id INT)");
658        let stmt = ok(r#"CREATE TABLE "crdt" (id INT)"#);
659        assert!(matches!(
660            stmt,
661            NodedbStatement::Collection(CollectionStmt::CreateTable { .. })
662        ));
663    }
664}