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