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, index, maintenance, materialized_view, oidc_provider, retention, rls,
8    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    // Conflict policy must be checked before the generic collection parser
63    // so "SET ON CONFLICT" does not fall through to the raw-SQL path.
64    try_family!(conflict_policy::try_parse(&upper, &parts, trimmed));
65    try_family!(collection::try_parse(&upper, &parts, trimmed));
66    try_family!(index::try_parse(&upper, &parts, trimmed));
67    try_family!(trigger::try_parse(&upper, &parts, trimmed));
68    try_family!(schedule::try_parse(&upper, &parts, trimmed));
69    try_family!(sequence::try_parse(&upper, &parts, trimmed));
70    try_family!(alert::try_parse(&upper, &parts, trimmed));
71    try_family!(retention::try_parse(&upper, &parts, trimmed));
72    try_family!(cluster_admin::try_parse(&upper, &parts, trimmed));
73    try_family!(maintenance::try_parse(&upper, &parts, trimmed));
74    try_family!(backup::try_parse(&upper, &parts, trimmed));
75    // COPY FROM file-path form — must come after backup so STDIN forms fall through.
76    try_family!(copy_from::try_parse(&upper, &parts, trimmed));
77    // COPY TO file-path form — table and query forms.
78    try_family!(copy_to::try_parse(&upper, trimmed));
79    try_family!(user_auth::try_parse(&upper, &parts, trimmed));
80    try_family!(oidc_provider::try_parse(&upper, &parts, trimmed));
81    try_family!(change_stream::try_parse(&upper, &parts, trimmed));
82    try_family!(rls::try_parse(&upper, &parts, trimmed));
83    try_family!(materialized_view::try_parse(&upper, &parts, trimmed));
84    try_family!(synonym_group::try_parse(&upper, &parts, trimmed));
85    try_family!(custom_type::try_parse(&upper, &parts, trimmed));
86    try_family!(database::try_parse(&upper, &parts, trimmed));
87    try_family!(tenant::try_parse(&upper, &parts, trimmed));
88    None
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::error::SqlError;
95
96    /// Parse and double-unwrap — panics if `None` or `Err`.
97    fn ok(sql: &str) -> NodedbStatement {
98        parse(sql)
99            .expect("expected Some, got None")
100            .expect("expected Ok, got Err")
101    }
102
103    /// Assert `parse` returns `Some(Err(SqlError::ReservedIdentifier { .. }))`.
104    fn assert_reserved(sql: &str) {
105        match parse(sql) {
106            Some(Err(SqlError::ReservedIdentifier { .. })) => {}
107            other => panic!("expected Some(Err(ReservedIdentifier)), got {other:?}"),
108        }
109    }
110
111    #[test]
112    fn parse_create_collection() {
113        let stmt = ok("CREATE COLLECTION users (id INT, name TEXT)");
114        match stmt {
115            NodedbStatement::CreateCollection {
116                name,
117                if_not_exists,
118                ..
119            } => {
120                assert_eq!(name, "users");
121                assert!(!if_not_exists);
122            }
123            other => panic!("expected CreateCollection, got {other:?}"),
124        }
125    }
126
127    #[test]
128    fn parse_create_collection_if_not_exists() {
129        let stmt = ok("CREATE COLLECTION IF NOT EXISTS users");
130        match stmt {
131            NodedbStatement::CreateCollection {
132                name,
133                if_not_exists,
134                ..
135            } => {
136                assert_eq!(name, "users");
137                assert!(if_not_exists);
138            }
139            other => panic!("expected CreateCollection, got {other:?}"),
140        }
141    }
142
143    #[test]
144    fn parse_drop_collection() {
145        let stmt = ok("DROP COLLECTION users");
146        assert_eq!(
147            stmt,
148            NodedbStatement::DropCollection {
149                name: "users".into(),
150                if_exists: false,
151                purge: false,
152                cascade: false,
153                cascade_force: false,
154            }
155        );
156    }
157
158    #[test]
159    fn parse_drop_collection_if_exists() {
160        let stmt = ok("DROP COLLECTION IF EXISTS users");
161        assert_eq!(
162            stmt,
163            NodedbStatement::DropCollection {
164                name: "users".into(),
165                if_exists: true,
166                purge: false,
167                cascade: false,
168                cascade_force: false,
169            }
170        );
171    }
172
173    #[test]
174    fn parse_drop_collection_purge() {
175        let stmt = ok("DROP COLLECTION users PURGE");
176        assert_eq!(
177            stmt,
178            NodedbStatement::DropCollection {
179                name: "users".into(),
180                if_exists: false,
181                purge: true,
182                cascade: false,
183                cascade_force: false,
184            }
185        );
186    }
187
188    #[test]
189    fn parse_drop_collection_cascade() {
190        let stmt = ok("DROP COLLECTION users CASCADE");
191        assert_eq!(
192            stmt,
193            NodedbStatement::DropCollection {
194                name: "users".into(),
195                if_exists: false,
196                purge: false,
197                cascade: true,
198                cascade_force: false,
199            }
200        );
201    }
202
203    #[test]
204    fn parse_drop_collection_purge_cascade() {
205        let stmt = ok("DROP COLLECTION users PURGE CASCADE");
206        assert_eq!(
207            stmt,
208            NodedbStatement::DropCollection {
209                name: "users".into(),
210                if_exists: false,
211                purge: true,
212                cascade: true,
213                cascade_force: false,
214            }
215        );
216    }
217
218    #[test]
219    fn parse_drop_collection_cascade_force() {
220        let stmt = ok("DROP COLLECTION users CASCADE FORCE");
221        assert_eq!(
222            stmt,
223            NodedbStatement::DropCollection {
224                name: "users".into(),
225                if_exists: false,
226                purge: false,
227                cascade: true,
228                cascade_force: true,
229            }
230        );
231    }
232
233    #[test]
234    fn parse_undrop_collection() {
235        let stmt = ok("UNDROP COLLECTION users");
236        assert_eq!(
237            stmt,
238            NodedbStatement::UndropCollection {
239                name: "users".into()
240            }
241        );
242    }
243
244    #[test]
245    fn parse_undrop_table_alias() {
246        let stmt = ok("UNDROP TABLE users");
247        assert_eq!(
248            stmt,
249            NodedbStatement::UndropCollection {
250                name: "users".into()
251            }
252        );
253    }
254
255    #[test]
256    fn parse_show_nodes() {
257        assert_eq!(parse("SHOW NODES"), Some(Ok(NodedbStatement::ShowNodes)));
258    }
259
260    #[test]
261    fn parse_show_cluster() {
262        assert_eq!(
263            parse("SHOW CLUSTER"),
264            Some(Ok(NodedbStatement::ShowCluster))
265        );
266    }
267
268    #[test]
269    fn parse_create_trigger() {
270        let stmt = ok(
271            "CREATE OR REPLACE SYNC TRIGGER on_insert AFTER INSERT ON orders FOR EACH ROW BEGIN RETURN; END",
272        );
273        match stmt {
274            NodedbStatement::CreateTrigger {
275                or_replace,
276                execution_mode,
277                timing,
278                ..
279            } => {
280                assert!(or_replace);
281                assert_eq!(execution_mode, "SYNC");
282                assert_eq!(timing, "AFTER");
283            }
284            other => panic!("expected CreateTrigger, got {other:?}"),
285        }
286    }
287
288    #[test]
289    fn parse_drop_index_if_exists() {
290        let stmt = ok("DROP INDEX IF EXISTS idx_name");
291        match stmt {
292            NodedbStatement::DropIndex {
293                name, if_exists, ..
294            } => {
295                assert_eq!(name, "idx_name");
296                assert!(if_exists);
297            }
298            other => panic!("expected DropIndex, got {other:?}"),
299        }
300    }
301
302    #[test]
303    fn parse_analyze() {
304        assert_eq!(
305            parse("ANALYZE users"),
306            Some(Ok(NodedbStatement::Analyze {
307                collection: Some("users".into()),
308            }))
309        );
310        assert_eq!(
311            parse("ANALYZE"),
312            Some(Ok(NodedbStatement::Analyze { collection: None }))
313        );
314    }
315
316    #[test]
317    fn parse_create_table_plain() {
318        let stmt = ok("CREATE TABLE foo (id INT, name TEXT)");
319        match stmt {
320            NodedbStatement::CreateTable {
321                name,
322                if_not_exists,
323                ..
324            } => {
325                assert_eq!(name, "foo");
326                assert!(!if_not_exists);
327            }
328            other => panic!("expected CreateTable, got {other:?}"),
329        }
330    }
331
332    #[test]
333    fn parse_create_table_if_not_exists() {
334        let stmt = ok("CREATE TABLE IF NOT EXISTS orders (id INT)");
335        match stmt {
336            NodedbStatement::CreateTable {
337                name,
338                if_not_exists,
339                ..
340            } => {
341                assert_eq!(name, "orders");
342                assert!(if_not_exists);
343            }
344            other => panic!("expected CreateTable, got {other:?}"),
345        }
346    }
347
348    #[test]
349    fn create_collection_is_not_create_table() {
350        let stmt = ok("CREATE COLLECTION foo");
351        assert!(matches!(stmt, NodedbStatement::CreateCollection { .. }));
352    }
353
354    #[test]
355    fn non_ddl_returns_none() {
356        assert!(parse("SELECT * FROM users").is_none());
357        assert!(parse("INSERT INTO users VALUES (1)").is_none());
358    }
359
360    // ── graph dispatch (token-aware) ────────────────────────────────────────
361
362    /// `MATCH` as first real token routes to the graph parser.
363    #[test]
364    fn graph_dispatch_match_plain() {
365        let _ = parse("MATCH (a)-[]->(b) RETURN a");
366    }
367
368    /// `GRAPH` as first real token routes to the graph parser.
369    #[test]
370    fn graph_dispatch_graph_keyword() {
371        let _ = parse("GRAPH something");
372    }
373
374    /// A leading block comment before `MATCH` must still route to graph.
375    #[test]
376    fn graph_dispatch_block_comment_before_match() {
377        let _ = parse("/* hint */ MATCH (a) RETURN a");
378    }
379
380    /// `OPTIONAL MATCH` routes to the graph parser.
381    #[test]
382    fn graph_dispatch_optional_match() {
383        let _ = parse("OPTIONAL MATCH (a) RETURN a");
384    }
385
386    /// `OPTIONAL` followed by something other than `MATCH` must NOT route to
387    /// the graph parser (falls through to DDL families, which return None).
388    #[test]
389    fn graph_dispatch_optional_non_match_does_not_route() {
390        assert!(parse("OPTIONAL FOO").is_none());
391    }
392
393    #[test]
394    fn graph_dispatch_select_with_match_in_string() {
395        assert!(parse("SELECT * FROM t WHERE name = 'MATCH'").is_none());
396    }
397
398    #[test]
399    fn graph_dispatch_select_with_graph_in_string() {
400        assert!(parse("SELECT * FROM t WHERE name = 'GRAPH'").is_none());
401    }
402
403    #[test]
404    fn graph_dispatch_with_cte_does_not_route() {
405        assert!(parse("WITH cte AS (SELECT 1) SELECT * FROM cte").is_none());
406    }
407
408    #[test]
409    fn graph_dispatch_line_comment_match_then_select() {
410        assert!(parse("-- MATCH (a)\nSELECT 1").is_none());
411    }
412
413    // ── MatchQuery.body field ─────────────────────────────────────────────────
414
415    #[test]
416    fn match_query_uses_body_field() {
417        let stmt = ok("MATCH (x)-[:l]->(y) RETURN x, y");
418        match stmt {
419            NodedbStatement::MatchQuery { body } => {
420                assert!(body.starts_with("MATCH"), "body must hold the original SQL");
421            }
422            other => panic!("expected MatchQuery, got {other:?}"),
423        }
424    }
425
426    // ── AddMaterializedSum typed parsing ─────────────────────────────────────
427
428    #[test]
429    fn parse_add_materialized_sum_typed() {
430        // Representative input: ALTER COLLECTION <target> ADD COLUMN <col> DECIMAL
431        // AS MATERIALIZED_SUM SOURCE <src> ON <src>.join_col = <target>.id VALUE <src>.amount
432        let stmt = ok(
433            "ALTER COLLECTION accounts ADD COLUMN balance DECIMAL AS MATERIALIZED_SUM \
434             SOURCE orders ON orders.account_id = accounts.id VALUE orders.amount",
435        );
436        match stmt {
437            NodedbStatement::AlterCollection { name, operation } => {
438                assert_eq!(name, "accounts");
439                match operation {
440                    crate::ddl_ast::AlterCollectionOp::AddMaterializedSum {
441                        target_collection,
442                        target_column,
443                        source_collection,
444                        join_column,
445                        value_expr,
446                    } => {
447                        assert_eq!(target_collection, "accounts");
448                        assert_eq!(target_column, "balance");
449                        assert_eq!(source_collection, "orders");
450                        assert_eq!(join_column, "account_id");
451                        assert_eq!(value_expr, "amount");
452                    }
453                    other => panic!("expected AddMaterializedSum, got {other:?}"),
454                }
455            }
456            other => panic!("expected AlterCollection, got {other:?}"),
457        }
458    }
459
460    #[test]
461    fn parse_grant_role() {
462        let stmt = ok("GRANT ROLE admin TO alice");
463        match stmt {
464            NodedbStatement::GrantRole { role, username } => {
465                assert_eq!(role, "admin");
466                assert_eq!(username, "alice");
467            }
468            other => panic!("expected GrantRole, got {other:?}"),
469        }
470    }
471
472    #[test]
473    fn parse_create_sequence_if_not_exists() {
474        let stmt = ok("CREATE SEQUENCE IF NOT EXISTS my_seq START 1");
475        match stmt {
476            NodedbStatement::CreateSequence {
477                name,
478                if_not_exists,
479                ..
480            } => {
481                assert_eq!(name, "my_seq");
482                assert!(if_not_exists);
483            }
484            other => panic!("expected CreateSequence, got {other:?}"),
485        }
486    }
487
488    #[test]
489    fn parse_restore_dry_run() {
490        let stmt = ok("RESTORE TENANT 1 FROM '/tmp/backup' DRY RUN");
491        match stmt {
492            NodedbStatement::RestoreTenant { dry_run, tenant_id } => {
493                assert!(dry_run);
494                assert_eq!(tenant_id, "1");
495            }
496            other => panic!("expected RestoreTenant, got {other:?}"),
497        }
498    }
499
500    // ── reserved identifier tests ─────────────────────────────────────────────
501
502    #[test]
503    fn create_table_reserved_name_is_err() {
504        assert_reserved("CREATE TABLE match (id INT)");
505    }
506
507    #[test]
508    fn create_table_quoted_reserved_name_is_ok() {
509        let stmt = ok(r#"CREATE TABLE "match" (id INT)"#);
510        match stmt {
511            NodedbStatement::CreateTable { name, .. } => assert_eq!(name, "match"),
512            other => panic!("expected CreateTable, got {other:?}"),
513        }
514    }
515
516    #[test]
517    fn create_collection_reserved_name_is_err() {
518        assert_reserved("CREATE COLLECTION upsert (id INT)");
519    }
520
521    #[test]
522    fn create_table_reserved_column_is_err() {
523        assert_reserved("CREATE TABLE foo (graph INT)");
524    }
525
526    #[test]
527    fn create_table_quoted_reserved_column_is_ok() {
528        let stmt = ok(r#"CREATE TABLE foo ("graph" INT)"#);
529        match stmt {
530            NodedbStatement::CreateTable { columns, .. } => {
531                assert_eq!(columns[0].0, "graph");
532            }
533            other => panic!("expected CreateTable, got {other:?}"),
534        }
535    }
536
537    // One test per reserved word: rejected bare, accepted quoted.
538
539    #[test]
540    fn reserved_graph() {
541        assert_reserved("CREATE TABLE graph (id INT)");
542        let stmt = ok(r#"CREATE TABLE "graph" (id INT)"#);
543        assert!(matches!(stmt, NodedbStatement::CreateTable { .. }));
544    }
545
546    #[test]
547    fn reserved_match() {
548        assert_reserved("CREATE TABLE match (id INT)");
549        let stmt = ok(r#"CREATE TABLE "match" (id INT)"#);
550        assert!(matches!(stmt, NodedbStatement::CreateTable { .. }));
551    }
552
553    #[test]
554    fn reserved_optional() {
555        assert_reserved("CREATE TABLE optional (id INT)");
556        let stmt = ok(r#"CREATE TABLE "optional" (id INT)"#);
557        assert!(matches!(stmt, NodedbStatement::CreateTable { .. }));
558    }
559
560    #[test]
561    fn reserved_upsert() {
562        assert_reserved("CREATE COLLECTION upsert (id INT)");
563        let stmt = ok(r#"CREATE COLLECTION "upsert" (id INT)"#);
564        assert!(matches!(stmt, NodedbStatement::CreateCollection { .. }));
565    }
566
567    #[test]
568    fn reserved_undrop() {
569        assert_reserved("CREATE TABLE undrop (id INT)");
570        let stmt = ok(r#"CREATE TABLE "undrop" (id INT)"#);
571        assert!(matches!(stmt, NodedbStatement::CreateTable { .. }));
572    }
573
574    #[test]
575    fn reserved_purge() {
576        assert_reserved("CREATE TABLE purge (id INT)");
577        let stmt = ok(r#"CREATE TABLE "purge" (id INT)"#);
578        assert!(matches!(stmt, NodedbStatement::CreateTable { .. }));
579    }
580
581    #[test]
582    fn reserved_cascade() {
583        assert_reserved("CREATE TABLE cascade (id INT)");
584        let stmt = ok(r#"CREATE TABLE "cascade" (id INT)"#);
585        assert!(matches!(stmt, NodedbStatement::CreateTable { .. }));
586    }
587
588    #[test]
589    fn reserved_search() {
590        assert_reserved("CREATE TABLE search (id INT)");
591        let stmt = ok(r#"CREATE TABLE "search" (id INT)"#);
592        assert!(matches!(stmt, NodedbStatement::CreateTable { .. }));
593    }
594
595    #[test]
596    fn reserved_crdt() {
597        assert_reserved("CREATE TABLE crdt (id INT)");
598        let stmt = ok(r#"CREATE TABLE "crdt" (id INT)"#);
599        assert!(matches!(stmt, NodedbStatement::CreateTable { .. }));
600    }
601}