Skip to main content

nodedb_sql/ddl_ast/
parse.rs

1//! Parse raw SQL into a [`NodedbStatement`].
2
3use super::graph_parse;
4use super::statement::NodedbStatement;
5
6/// Try to parse a DDL statement from raw SQL. Returns `None` for
7/// non-DDL queries (SELECT, INSERT, etc.) that should flow through
8/// the normal planner.
9pub fn parse(sql: &str) -> Option<NodedbStatement> {
10    let trimmed = sql.trim();
11    if trimmed.is_empty() {
12        return None;
13    }
14    let upper = trimmed.to_uppercase();
15    let parts: Vec<&str> = trimmed.split_whitespace().collect();
16    if parts.is_empty() {
17        return None;
18    }
19
20    // Graph DSL (`GRAPH ...`, `MATCH ...`) has its own tokenising
21    // parser — delegate early so the string-prefix branches below
22    // cannot accidentally shadow quoted values that contain DSL
23    // keywords.
24    if upper.starts_with("GRAPH ")
25        || upper.starts_with("MATCH ")
26        || upper.starts_with("OPTIONAL MATCH ")
27    {
28        return graph_parse::try_parse(trimmed);
29    }
30
31    // ── Collection lifecycle ─────────────────────────────────────
32    if upper.starts_with("CREATE COLLECTION ") || upper.starts_with("CREATE TABLE ") {
33        let if_not_exists = upper.contains("IF NOT EXISTS");
34        let name = extract_name_after_keyword(&parts, "COLLECTION")
35            .or_else(|| extract_name_after_keyword(&parts, "TABLE"))?;
36        return Some(NodedbStatement::CreateCollection {
37            name,
38            if_not_exists,
39            raw_sql: trimmed.to_string(),
40        });
41    }
42    if upper.starts_with("DROP COLLECTION ") || upper.starts_with("DROP TABLE ") {
43        let if_exists = upper.contains("IF EXISTS");
44        let name = extract_name_after_if_exists(&parts, "COLLECTION")
45            .or_else(|| extract_name_after_if_exists(&parts, "TABLE"))?;
46        return Some(NodedbStatement::DropCollection { name, if_exists });
47    }
48    if upper.starts_with("ALTER COLLECTION ") || upper.starts_with("ALTER TABLE ") {
49        let name = extract_name_after_keyword(&parts, "COLLECTION")
50            .or_else(|| extract_name_after_keyword(&parts, "TABLE"))?;
51        return Some(NodedbStatement::AlterCollection {
52            name,
53            raw_sql: trimmed.to_string(),
54        });
55    }
56    if upper.starts_with("DESCRIBE ") && !upper.starts_with("DESCRIBE SEQUENCE") {
57        let name = parts.get(1)?.to_string();
58        return Some(NodedbStatement::DescribeCollection { name });
59    }
60    if upper == "\\D" || upper == "SHOW COLLECTIONS" || upper.starts_with("SHOW COLLECTIONS") {
61        return Some(NodedbStatement::ShowCollections);
62    }
63
64    // ── Index ────────────────────────────────────────────────────
65    if upper.starts_with("CREATE UNIQUE INDEX ") || upper.starts_with("CREATE UNIQUE IND") {
66        return Some(NodedbStatement::CreateIndex {
67            unique: true,
68            raw_sql: trimmed.to_string(),
69        });
70    }
71    if upper.starts_with("CREATE INDEX ") {
72        return Some(NodedbStatement::CreateIndex {
73            unique: false,
74            raw_sql: trimmed.to_string(),
75        });
76    }
77    if upper.starts_with("DROP INDEX ") {
78        let if_exists = upper.contains("IF EXISTS");
79        let name = extract_name_after_if_exists(&parts, "INDEX")?;
80        return Some(NodedbStatement::DropIndex {
81            name,
82            collection: None,
83            if_exists,
84        });
85    }
86    if upper.starts_with("SHOW INDEX") {
87        let collection = parts.get(2).map(|s| s.to_string());
88        return Some(NodedbStatement::ShowIndexes { collection });
89    }
90    if upper.starts_with("REINDEX ") {
91        let collection = parts.get(1)?.to_string();
92        return Some(NodedbStatement::Reindex { collection });
93    }
94
95    // ── Trigger ──────────────────────────────────────────────────
96    if upper.starts_with("CREATE ") && upper.contains("TRIGGER ") {
97        let or_replace = upper.contains("OR REPLACE");
98        let deferred = upper.contains("DEFERRED");
99        let sync = upper.contains("SYNC");
100        return Some(NodedbStatement::CreateTrigger {
101            or_replace,
102            deferred,
103            sync,
104            raw_sql: trimmed.to_string(),
105        });
106    }
107    if upper.starts_with("DROP TRIGGER ") {
108        let if_exists = upper.contains("IF EXISTS");
109        let name = extract_name_after_if_exists(&parts, "TRIGGER")?;
110        let collection = extract_after_keyword(&parts, "ON").unwrap_or_default();
111        return Some(NodedbStatement::DropTrigger {
112            name,
113            collection,
114            if_exists,
115        });
116    }
117    if upper.starts_with("ALTER TRIGGER ") {
118        return Some(NodedbStatement::AlterTrigger {
119            raw_sql: trimmed.to_string(),
120        });
121    }
122    if upper.starts_with("SHOW TRIGGERS") {
123        let collection = if upper.starts_with("SHOW TRIGGERS ON ") {
124            parts.get(3).map(|s| s.to_string())
125        } else {
126            None
127        };
128        return Some(NodedbStatement::ShowTriggers { collection });
129    }
130
131    // ── Schedule ─────────────────────────────────────────────────
132    if upper.starts_with("CREATE SCHEDULE ") {
133        return Some(NodedbStatement::CreateSchedule {
134            raw_sql: trimmed.to_string(),
135        });
136    }
137    if upper.starts_with("DROP SCHEDULE ") {
138        let if_exists = upper.contains("IF EXISTS");
139        let name = extract_name_after_if_exists(&parts, "SCHEDULE")?;
140        return Some(NodedbStatement::DropSchedule { name, if_exists });
141    }
142    if upper.starts_with("ALTER SCHEDULE ") {
143        return Some(NodedbStatement::AlterSchedule {
144            raw_sql: trimmed.to_string(),
145        });
146    }
147    if upper.starts_with("SHOW SCHEDULE HISTORY ") {
148        let name = parts.get(3)?.to_string();
149        return Some(NodedbStatement::ShowScheduleHistory { name });
150    }
151    if upper == "SHOW SCHEDULES" || upper.starts_with("SHOW SCHEDULES") {
152        return Some(NodedbStatement::ShowSchedules);
153    }
154
155    // ── Sequence ─────────────────────────────────────────────────
156    if upper.starts_with("CREATE SEQUENCE ") {
157        let if_not_exists = upper.contains("IF NOT EXISTS");
158        let name = extract_name_after_if_exists(&parts, "SEQUENCE")?;
159        return Some(NodedbStatement::CreateSequence {
160            name,
161            if_not_exists,
162            raw_sql: trimmed.to_string(),
163        });
164    }
165    if upper.starts_with("DROP SEQUENCE ") {
166        let if_exists = upper.contains("IF EXISTS");
167        let name = extract_name_after_if_exists(&parts, "SEQUENCE")?;
168        return Some(NodedbStatement::DropSequence { name, if_exists });
169    }
170    if upper.starts_with("ALTER SEQUENCE ") {
171        return Some(NodedbStatement::AlterSequence {
172            raw_sql: trimmed.to_string(),
173        });
174    }
175    if upper.starts_with("DESCRIBE SEQUENCE ") {
176        let name = parts.get(2)?.to_string();
177        return Some(NodedbStatement::DescribeSequence { name });
178    }
179    if upper == "SHOW SEQUENCES" || upper.starts_with("SHOW SEQUENCES") {
180        return Some(NodedbStatement::ShowSequences);
181    }
182
183    // ── Alert ────────────────────────────────────────────────────
184    if upper.starts_with("CREATE ALERT ") {
185        return Some(NodedbStatement::CreateAlert {
186            raw_sql: trimmed.to_string(),
187        });
188    }
189    if upper.starts_with("DROP ALERT ") {
190        let if_exists = upper.contains("IF EXISTS");
191        let name = extract_name_after_if_exists(&parts, "ALERT")?;
192        return Some(NodedbStatement::DropAlert { name, if_exists });
193    }
194    if upper.starts_with("ALTER ALERT ") {
195        return Some(NodedbStatement::AlterAlert {
196            raw_sql: trimmed.to_string(),
197        });
198    }
199    if upper.starts_with("SHOW ALERT STATUS ") {
200        let name = parts.get(3)?.to_string();
201        return Some(NodedbStatement::ShowAlertStatus { name });
202    }
203    if upper.starts_with("SHOW ALERT") && !upper.starts_with("SHOW ALERT STATUS") {
204        return Some(NodedbStatement::ShowAlerts);
205    }
206
207    // ── Retention policy ─────────────────────────────────────────
208    if upper.starts_with("CREATE RETENTION POLICY ") {
209        return Some(NodedbStatement::CreateRetentionPolicy {
210            raw_sql: trimmed.to_string(),
211        });
212    }
213    if upper.starts_with("DROP RETENTION POLICY ") {
214        let if_exists = upper.contains("IF EXISTS");
215        let name = extract_name_after_if_exists(&parts, "POLICY")?;
216        return Some(NodedbStatement::DropRetentionPolicy { name, if_exists });
217    }
218    if upper.starts_with("ALTER RETENTION POLICY ") {
219        return Some(NodedbStatement::AlterRetentionPolicy {
220            raw_sql: trimmed.to_string(),
221        });
222    }
223    if upper.starts_with("SHOW RETENTION POLIC") {
224        return Some(NodedbStatement::ShowRetentionPolicies);
225    }
226
227    // ── Cluster admin ────────────────────────────────────────────
228    if upper.starts_with("SHOW CLUSTER") {
229        return Some(NodedbStatement::ShowCluster);
230    }
231    if upper.starts_with("SHOW MIGRATIONS") {
232        return Some(NodedbStatement::ShowMigrations);
233    }
234    if upper.starts_with("SHOW RANGES") {
235        return Some(NodedbStatement::ShowRanges);
236    }
237    if upper.starts_with("SHOW ROUTING") {
238        return Some(NodedbStatement::ShowRouting);
239    }
240    if upper.starts_with("SHOW SCHEMA VERSION") {
241        return Some(NodedbStatement::ShowSchemaVersion);
242    }
243    if upper.starts_with("SHOW PEER HEALTH") {
244        return Some(NodedbStatement::ShowPeerHealth);
245    }
246    if upper.starts_with("REBALANCE") {
247        return Some(NodedbStatement::Rebalance);
248    }
249    if upper.starts_with("SHOW RAFT GROUP ") {
250        let id = parts.get(3)?.to_string();
251        return Some(NodedbStatement::ShowRaftGroup { group_id: id });
252    }
253    if upper.starts_with("SHOW RAFT GROUPS") || upper.starts_with("SHOW RAFT") {
254        return Some(NodedbStatement::ShowRaftGroups);
255    }
256    if upper.starts_with("ALTER RAFT GROUP ") {
257        return Some(NodedbStatement::AlterRaftGroup {
258            raw_sql: trimmed.to_string(),
259        });
260    }
261    if upper.starts_with("REMOVE NODE ") {
262        let id = parts.get(2)?.to_string();
263        return Some(NodedbStatement::RemoveNode { node_id: id });
264    }
265    if upper.starts_with("SHOW NODE ") {
266        let id = parts.get(2)?.to_string();
267        return Some(NodedbStatement::ShowNode { node_id: id });
268    }
269    if upper.starts_with("SHOW NODES") {
270        return Some(NodedbStatement::ShowNodes);
271    }
272
273    // ── Maintenance ──────────────────────────────────────────────
274    if upper.starts_with("ANALYZE") {
275        let collection = parts.get(1).map(|s| s.to_string());
276        return Some(NodedbStatement::Analyze { collection });
277    }
278    if upper.starts_with("COMPACT ") {
279        let collection = parts.get(1)?.to_string();
280        return Some(NodedbStatement::Compact { collection });
281    }
282    if upper.starts_with("SHOW COMPACTION ST") {
283        return Some(NodedbStatement::ShowCompactionStatus);
284    }
285    if upper.starts_with("SHOW STORAGE") {
286        let collection = parts.get(2).map(|s| s.to_string());
287        return Some(NodedbStatement::ShowStorage { collection });
288    }
289
290    // ── Backup / restore ─────────────────────────────────────────
291    if upper.starts_with("BACKUP TENANT ") {
292        return Some(NodedbStatement::BackupTenant {
293            raw_sql: trimmed.to_string(),
294        });
295    }
296    if upper.starts_with("RESTORE TENANT ") {
297        let dry_run = upper.ends_with(" DRY RUN") || upper.ends_with(" DRYRUN");
298        return Some(NodedbStatement::RestoreTenant {
299            dry_run,
300            raw_sql: trimmed.to_string(),
301        });
302    }
303
304    // ── User / auth ──────────────────────────────────────────────
305    if upper.starts_with("CREATE USER ") {
306        return Some(NodedbStatement::CreateUser {
307            raw_sql: trimmed.to_string(),
308        });
309    }
310    if upper.starts_with("DROP USER ") {
311        let username = parts.get(2)?.to_string();
312        return Some(NodedbStatement::DropUser { username });
313    }
314    if upper.starts_with("ALTER USER ") {
315        return Some(NodedbStatement::AlterUser {
316            raw_sql: trimmed.to_string(),
317        });
318    }
319    if upper.starts_with("SHOW USERS") {
320        return Some(NodedbStatement::ShowUsers);
321    }
322    if upper.starts_with("GRANT ROLE ") {
323        return Some(NodedbStatement::GrantRole {
324            raw_sql: trimmed.to_string(),
325        });
326    }
327    if upper.starts_with("REVOKE ROLE ") {
328        return Some(NodedbStatement::RevokeRole {
329            raw_sql: trimmed.to_string(),
330        });
331    }
332    if upper.starts_with("GRANT ") {
333        return Some(NodedbStatement::GrantPermission {
334            raw_sql: trimmed.to_string(),
335        });
336    }
337    if upper.starts_with("REVOKE ") {
338        return Some(NodedbStatement::RevokePermission {
339            raw_sql: trimmed.to_string(),
340        });
341    }
342    if upper.starts_with("SHOW PERMISSIONS") {
343        let collection = parts.get(2).map(|s| s.to_string());
344        return Some(NodedbStatement::ShowPermissions { collection });
345    }
346    if upper.starts_with("SHOW GRANTS") {
347        let username = parts.get(2).map(|s| s.to_string());
348        return Some(NodedbStatement::ShowGrants { username });
349    }
350    if upper.starts_with("SHOW TENANTS") {
351        return Some(NodedbStatement::ShowTenants);
352    }
353    if upper.starts_with("SHOW AUDIT") {
354        return Some(NodedbStatement::ShowAuditLog);
355    }
356    if upper.starts_with("SHOW CONSTRAINTS ") {
357        let collection = parts.get(2)?.to_string();
358        return Some(NodedbStatement::ShowConstraints { collection });
359    }
360    if upper.starts_with("SHOW TYPEGUARD") {
361        let collection = parts.get(2)?.to_string();
362        return Some(NodedbStatement::ShowTypeGuards { collection });
363    }
364
365    // ── Change stream ────────────────────────────────────────────
366    if upper.starts_with("CREATE CHANGE STREAM ") {
367        return Some(NodedbStatement::CreateChangeStream {
368            raw_sql: trimmed.to_string(),
369        });
370    }
371    if upper.starts_with("DROP CHANGE STREAM ") {
372        let if_exists = upper.contains("IF EXISTS");
373        let name = extract_name_after_if_exists(&parts, "STREAM")?;
374        return Some(NodedbStatement::DropChangeStream { name, if_exists });
375    }
376
377    // ── RLS ──────────────────────────────────────────────────────
378    if upper.starts_with("CREATE RLS POLICY ") {
379        return Some(NodedbStatement::CreateRlsPolicy {
380            raw_sql: trimmed.to_string(),
381        });
382    }
383    if upper.starts_with("DROP RLS POLICY ") {
384        let if_exists = upper.contains("IF EXISTS");
385        let name = extract_name_after_if_exists(&parts, "POLICY")?;
386        let collection = extract_after_keyword(&parts, "ON").unwrap_or_default();
387        return Some(NodedbStatement::DropRlsPolicy {
388            name,
389            collection,
390            if_exists,
391        });
392    }
393    if upper.starts_with("SHOW RLS POLI") {
394        let collection = parts.get(3).map(|s| s.to_string());
395        return Some(NodedbStatement::ShowRlsPolicies { collection });
396    }
397
398    // ── Materialized view ────────────────────────────────────────
399    if upper.starts_with("CREATE MATERIALIZED VIEW ") {
400        return Some(NodedbStatement::CreateMaterializedView {
401            raw_sql: trimmed.to_string(),
402        });
403    }
404    if upper.starts_with("DROP MATERIALIZED VIEW ") {
405        let if_exists = upper.contains("IF EXISTS");
406        let name = extract_name_after_if_exists(&parts, "VIEW")?;
407        return Some(NodedbStatement::DropMaterializedView { name, if_exists });
408    }
409
410    // ── Continuous aggregate ─────────────────────────────────────
411    if upper.starts_with("CREATE CONTINUOUS AGGREGATE ") {
412        return Some(NodedbStatement::CreateContinuousAggregate {
413            raw_sql: trimmed.to_string(),
414        });
415    }
416    if upper.starts_with("DROP CONTINUOUS AGGREGATE ") {
417        let if_exists = upper.contains("IF EXISTS");
418        let name = extract_name_after_if_exists(&parts, "AGGREGATE")?;
419        return Some(NodedbStatement::DropContinuousAggregate { name, if_exists });
420    }
421
422    None
423}
424
425/// Extract the object name that follows a keyword (e.g. "COLLECTION"
426/// in "CREATE COLLECTION users ..."). Handles IF NOT EXISTS by
427/// skipping those tokens.
428fn extract_name_after_keyword(parts: &[&str], keyword: &str) -> Option<String> {
429    let kw_upper = keyword.to_uppercase();
430    let pos = parts.iter().position(|p| p.to_uppercase() == kw_upper)?;
431    let mut idx = pos + 1;
432    // Skip IF NOT EXISTS tokens.
433    if parts.get(idx).map(|s| s.to_uppercase()) == Some("IF".to_string()) {
434        idx += 1; // NOT
435        if parts.get(idx).map(|s| s.to_uppercase()) == Some("NOT".to_string()) {
436            idx += 1; // EXISTS
437        }
438        if parts.get(idx).map(|s| s.to_uppercase()) == Some("EXISTS".to_string()) {
439            idx += 1;
440        }
441    }
442    parts.get(idx).map(|s| s.to_string())
443}
444
445/// Extract the object name for DROP-style commands where IF EXISTS
446/// may appear between the keyword and the name.
447fn extract_name_after_if_exists(parts: &[&str], keyword: &str) -> Option<String> {
448    extract_name_after_keyword(parts, keyword)
449}
450
451/// Extract the token after a keyword like "ON" or "TO".
452fn extract_after_keyword(parts: &[&str], keyword: &str) -> Option<String> {
453    let kw_upper = keyword.to_uppercase();
454    let pos = parts.iter().position(|p| p.to_uppercase() == kw_upper)?;
455    parts.get(pos + 1).map(|s| s.to_string())
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[test]
463    fn parse_create_collection() {
464        let stmt = parse("CREATE COLLECTION users (id INT, name TEXT)").unwrap();
465        match stmt {
466            NodedbStatement::CreateCollection {
467                name,
468                if_not_exists,
469                ..
470            } => {
471                assert_eq!(name, "users");
472                assert!(!if_not_exists);
473            }
474            other => panic!("expected CreateCollection, got {other:?}"),
475        }
476    }
477
478    #[test]
479    fn parse_create_collection_if_not_exists() {
480        let stmt = parse("CREATE COLLECTION IF NOT EXISTS users").unwrap();
481        match stmt {
482            NodedbStatement::CreateCollection {
483                name,
484                if_not_exists,
485                ..
486            } => {
487                assert_eq!(name, "users");
488                assert!(if_not_exists);
489            }
490            other => panic!("expected CreateCollection, got {other:?}"),
491        }
492    }
493
494    #[test]
495    fn parse_drop_collection() {
496        let stmt = parse("DROP COLLECTION users").unwrap();
497        assert_eq!(
498            stmt,
499            NodedbStatement::DropCollection {
500                name: "users".into(),
501                if_exists: false,
502            }
503        );
504    }
505
506    #[test]
507    fn parse_drop_collection_if_exists() {
508        let stmt = parse("DROP COLLECTION IF EXISTS users").unwrap();
509        assert_eq!(
510            stmt,
511            NodedbStatement::DropCollection {
512                name: "users".into(),
513                if_exists: true,
514            }
515        );
516    }
517
518    #[test]
519    fn parse_show_nodes() {
520        assert_eq!(parse("SHOW NODES"), Some(NodedbStatement::ShowNodes));
521    }
522
523    #[test]
524    fn parse_show_cluster() {
525        assert_eq!(parse("SHOW CLUSTER"), Some(NodedbStatement::ShowCluster));
526    }
527
528    #[test]
529    fn parse_create_trigger() {
530        let stmt = parse("CREATE OR REPLACE SYNC TRIGGER on_insert ...").unwrap();
531        match stmt {
532            NodedbStatement::CreateTrigger {
533                or_replace,
534                sync,
535                deferred,
536                ..
537            } => {
538                assert!(or_replace);
539                assert!(sync);
540                assert!(!deferred);
541            }
542            other => panic!("expected CreateTrigger, got {other:?}"),
543        }
544    }
545
546    #[test]
547    fn parse_drop_index_if_exists() {
548        let stmt = parse("DROP INDEX IF EXISTS idx_name").unwrap();
549        match stmt {
550            NodedbStatement::DropIndex {
551                name, if_exists, ..
552            } => {
553                assert_eq!(name, "idx_name");
554                assert!(if_exists);
555            }
556            other => panic!("expected DropIndex, got {other:?}"),
557        }
558    }
559
560    #[test]
561    fn parse_analyze() {
562        assert_eq!(
563            parse("ANALYZE users"),
564            Some(NodedbStatement::Analyze {
565                collection: Some("users".into()),
566            })
567        );
568        assert_eq!(
569            parse("ANALYZE"),
570            Some(NodedbStatement::Analyze { collection: None })
571        );
572    }
573
574    #[test]
575    fn non_ddl_returns_none() {
576        assert!(parse("SELECT * FROM users").is_none());
577        assert!(parse("INSERT INTO users VALUES (1)").is_none());
578    }
579
580    #[test]
581    fn parse_grant_role() {
582        let stmt = parse("GRANT ROLE admin TO alice").unwrap();
583        match stmt {
584            NodedbStatement::GrantRole { raw_sql } => {
585                assert!(raw_sql.contains("admin"));
586            }
587            other => panic!("expected GrantRole, got {other:?}"),
588        }
589    }
590
591    #[test]
592    fn parse_create_sequence_if_not_exists() {
593        let stmt = parse("CREATE SEQUENCE IF NOT EXISTS my_seq START 1").unwrap();
594        match stmt {
595            NodedbStatement::CreateSequence {
596                name,
597                if_not_exists,
598                ..
599            } => {
600                assert_eq!(name, "my_seq");
601                assert!(if_not_exists);
602            }
603            other => panic!("expected CreateSequence, got {other:?}"),
604        }
605    }
606
607    #[test]
608    fn parse_restore_dry_run() {
609        let stmt = parse("RESTORE TENANT 1 FROM '/tmp/backup' DRY RUN").unwrap();
610        match stmt {
611            NodedbStatement::RestoreTenant { dry_run, .. } => {
612                assert!(dry_run);
613            }
614            other => panic!("expected RestoreTenant, got {other:?}"),
615        }
616    }
617}