Skip to main content

nodedb_sql/ddl_ast/graph_parse/
entry.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Graph DSL entry point.
4
5use super::super::statement::{GraphStmt, NodedbStatement};
6use super::{edge_predicate, tokenizer, variants};
7use crate::error::SqlError;
8
9/// Parse a graph DSL statement.
10///
11/// `None` means the input is not graph DSL and the caller should keep trying
12/// other statement families. `Some(Err(..))` means it *is* graph DSL and is
13/// malformed — the two must stay apart, because returning `None` for a
14/// malformed statement sends it to the general SQL parser, which can only
15/// report that `GRAPH` is not an SQL statement and says nothing about the
16/// clause actually at fault.
17pub fn try_parse(sql: &str) -> Option<Result<NodedbStatement, SqlError>> {
18    let trimmed = sql.trim();
19    let upper = trimmed.to_ascii_uppercase();
20
21    if upper.starts_with("MATCH ") || upper.starts_with("OPTIONAL MATCH ") {
22        return Some(Ok(NodedbStatement::Graph(GraphStmt::MatchQuery {
23            body: trimmed.to_string(),
24        })));
25    }
26
27    if !upper.starts_with("GRAPH ") {
28        return None;
29    }
30
31    Some(parse_graph(trimmed))
32}
33
34/// Parse a statement known to start with `GRAPH `. Comments are blanked
35/// first. A trailing `EDGE WHERE` predicate splits off next, so the clause
36/// text before it tokenizes alone.
37fn parse_graph(sql: &str) -> Result<NodedbStatement, SqlError> {
38    let blanked = tokenizer::blank_comments(sql);
39    let trimmed = blanked.trim();
40    let upper = trimmed.to_ascii_uppercase();
41    let upper = upper.as_str();
42    let (head, predicate_text) = edge_predicate::split_edge_where(trimmed)?;
43    let predicate = match predicate_text {
44        Some(text) => edge_predicate::parse_edge_predicate(text)?,
45        None => Vec::new(),
46    };
47    let toks = tokenizer::tokenize(head);
48
49    if upper.starts_with("GRAPH TRAVERSE ") {
50        return variants::parse_traverse(&toks, predicate);
51    }
52    if upper.starts_with("GRAPH PATH ") {
53        return variants::parse_path(&toks, predicate);
54    }
55    if predicate_text.is_some() {
56        return Err(SqlError::Parse {
57            detail: format!(
58                "{} does not accept EDGE WHERE: only GRAPH TRAVERSE and GRAPH PATH filter on \
59                 edge properties",
60                graph_command(upper)
61            ),
62        });
63    }
64
65    if upper.starts_with("GRAPH INSERT EDGE ") {
66        variants::parse_insert_edge(&toks)
67    } else if upper.starts_with("GRAPH DELETE EDGE ") {
68        variants::parse_delete_edge(&toks)
69    } else if upper.starts_with("GRAPH LABEL ") {
70        variants::parse_set_labels(&toks, false)
71    } else if upper.starts_with("GRAPH UNLABEL ") {
72        variants::parse_set_labels(&toks, true)
73    } else if upper.starts_with("GRAPH NEIGHBORS ") {
74        variants::parse_neighbors(&toks)
75    } else if upper.starts_with("GRAPH ALGO ") {
76        variants::parse_algo(&toks)
77    } else if upper.starts_with("GRAPH RAG FUSION ") {
78        variants::parse_rag_fusion(&toks)
79    } else {
80        // Starts with `GRAPH ` but names no known command. Still graph DSL,
81        // so report it here rather than letting the SQL parser guess.
82        Err(SqlError::Parse {
83            detail: "unrecognised GRAPH command".to_owned(),
84        })
85    }
86}
87
88/// The command words of a `GRAPH` statement, for an error message:
89/// `GRAPH NEIGHBORS`, `GRAPH INSERT EDGE`, `GRAPH RAG FUSION`.
90fn graph_command(upper: &str) -> String {
91    const MULTI_WORD: [&str; 3] = ["GRAPH INSERT EDGE", "GRAPH DELETE EDGE", "GRAPH RAG FUSION"];
92    if let Some(command) = MULTI_WORD.iter().find(|c| upper.starts_with(*c)) {
93        return (*command).to_owned();
94    }
95    upper
96        .split_whitespace()
97        .take(2)
98        .collect::<Vec<_>>()
99        .join(" ")
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::ddl_ast::statement::{GraphDirection, GraphProperties};
106
107    /// Parse a statement that must be both graph DSL and well-formed.
108    fn parsed(sql: &str) -> NodedbStatement {
109        try_parse(sql)
110            .expect("input is graph DSL")
111            .expect("statement is well-formed")
112    }
113
114    #[test]
115    fn parse_graph_insert_edge_keyword_shaped_ids() {
116        let stmt = parsed("GRAPH INSERT EDGE IN 'myedges' FROM 'TO' TO 'FROM' TYPE 'LABEL'");
117        match stmt {
118            NodedbStatement::Graph(GraphStmt::GraphInsertEdge {
119                collection,
120                src,
121                dst,
122                label,
123                properties,
124            }) => {
125                assert_eq!(collection, "myedges");
126                assert_eq!(src, "TO");
127                assert_eq!(dst, "FROM");
128                assert_eq!(label, "LABEL");
129                assert_eq!(properties, GraphProperties::None);
130            }
131            other => panic!("expected GraphInsertEdge, got {other:?}"),
132        }
133    }
134
135    #[test]
136    fn parse_graph_delete_edge_with_collection() {
137        let stmt = parsed("GRAPH DELETE EDGE IN 'myedges' FROM 'a' TO 'b' TYPE 'l'");
138        match stmt {
139            NodedbStatement::Graph(GraphStmt::GraphDeleteEdge {
140                collection,
141                src,
142                dst,
143                label,
144            }) => {
145                assert_eq!(collection, "myedges");
146                assert_eq!(src, "a");
147                assert_eq!(dst, "b");
148                assert_eq!(label, "l");
149            }
150            other => panic!("expected GraphDeleteEdge, got {other:?}"),
151        }
152    }
153
154    /// A missing required clause is a malformed graph statement, not a
155    /// non-graph one. `None` here would send it to the SQL parser, which
156    /// reports only that `GRAPH` is not SQL.
157    #[test]
158    fn parse_graph_insert_edge_missing_collection_names_the_clause() {
159        let error = try_parse("GRAPH INSERT EDGE FROM 'a' TO 'b' TYPE 'l'")
160            .expect("input is graph DSL")
161            .expect_err("missing IN <collection> must not produce a statement");
162        assert!(
163            error.to_string().contains("IN <collection>"),
164            "the error must name the missing clause: {error}"
165        );
166    }
167
168    #[test]
169    fn parse_graph_insert_edge_with_object_properties() {
170        let stmt = parsed(
171            "GRAPH INSERT EDGE IN 'edges' FROM 'a' TO 'b' TYPE 'l' PROPERTIES { note: '} DEPTH 999' }",
172        );
173        match stmt {
174            NodedbStatement::Graph(GraphStmt::GraphInsertEdge {
175                collection,
176                properties,
177                ..
178            }) => {
179                assert_eq!(collection, "edges");
180                match properties {
181                    GraphProperties::Object(s) => assert!(s.contains("} DEPTH 999")),
182                    other => panic!("expected Object properties, got {other:?}"),
183                }
184            }
185            other => panic!("expected GraphInsertEdge, got {other:?}"),
186        }
187    }
188
189    #[test]
190    fn parse_graph_traverse_keyword_substring_id() {
191        let stmt =
192            parsed("GRAPH TRAVERSE IN 'kw' FROM 'node_with_DEPTH_in_name' DEPTH 2 LABEL 'l'");
193        match stmt {
194            NodedbStatement::Graph(GraphStmt::GraphTraverse {
195                collection,
196                start,
197                depth,
198                ..
199            }) => {
200                assert_eq!(collection, "kw");
201                assert_eq!(start, "node_with_DEPTH_in_name");
202                assert_eq!(depth, 2);
203            }
204            other => panic!("expected GraphTraverse, got {other:?}"),
205        }
206    }
207
208    #[test]
209    fn parse_graph_path() {
210        let stmt = parsed("GRAPH PATH IN 'docs' FROM 'a' TO 'b' MAX_DEPTH 5 LABEL 'l'");
211        match stmt {
212            NodedbStatement::Graph(GraphStmt::GraphPath {
213                collection,
214                src,
215                dst,
216                max_depth,
217                edge_labels,
218                edge_predicate,
219            }) => {
220                assert!(edge_predicate.is_empty());
221                assert_eq!(collection, "docs");
222                assert_eq!(src, "a");
223                assert_eq!(dst, "b");
224                assert_eq!(max_depth, 5);
225                assert_eq!(edge_labels, vec!["l".to_string()]);
226            }
227            other => panic!("expected GraphPath, got {other:?}"),
228        }
229    }
230
231    fn labels_of(sql: &str) -> Vec<String> {
232        match parsed(sql) {
233            NodedbStatement::Graph(
234                GraphStmt::GraphTraverse { edge_labels, .. }
235                | GraphStmt::GraphNeighbors { edge_labels, .. }
236                | GraphStmt::GraphPath { edge_labels, .. },
237            ) => edge_labels,
238            other => panic!("expected a graph walk statement, got {other:?}"),
239        }
240    }
241
242    fn ab() -> Vec<String> {
243        vec!["a".to_string(), "b".to_string()]
244    }
245
246    #[test]
247    fn label_list_parses_on_every_walk_statement() {
248        assert_eq!(
249            labels_of("GRAPH TRAVERSE IN 'c' FROM 'x' DEPTH 2 LABEL 'a', 'b' DIRECTION out"),
250            ab()
251        );
252        assert_eq!(
253            labels_of("GRAPH NEIGHBORS IN 'c' OF 'x' LABEL 'a', 'b' DIRECTION both"),
254            ab()
255        );
256        assert_eq!(
257            labels_of("GRAPH PATH IN 'c' FROM 'x' TO 'y' MAX_DEPTH 3 LABEL 'a', 'b'"),
258            ab()
259        );
260    }
261
262    #[test]
263    fn parenthesised_label_list_parses() {
264        assert_eq!(
265            labels_of("GRAPH TRAVERSE IN 'c' FROM 'x' LABEL ('a','b')"),
266            ab()
267        );
268        assert_eq!(
269            labels_of("GRAPH NEIGHBORS IN 'c' OF 'x' LABEL ('a', 'b')"),
270            ab()
271        );
272        assert_eq!(
273            labels_of("GRAPH PATH IN 'c' FROM 'x' TO 'y' LABEL ('a','b')"),
274            ab()
275        );
276    }
277
278    #[test]
279    fn omitted_label_clause_is_an_empty_set() {
280        assert!(labels_of("GRAPH TRAVERSE IN 'c' FROM 'x' DEPTH 2").is_empty());
281        assert!(labels_of("GRAPH NEIGHBORS IN 'c' OF 'x'").is_empty());
282        assert!(labels_of("GRAPH PATH IN 'c' FROM 'x' TO 'y'").is_empty());
283    }
284
285    #[test]
286    fn label_clause_without_quoted_labels_is_refused() {
287        for sql in [
288            "GRAPH TRAVERSE IN 'c' FROM 'x' LABEL DIRECTION out",
289            "GRAPH TRAVERSE IN 'c' FROM 'x' LABEL knows",
290            "GRAPH NEIGHBORS IN 'c' OF 'x' LABEL",
291            "GRAPH PATH IN 'c' FROM 'x' TO 'y' LABEL knows",
292        ] {
293            let error = try_parse(sql)
294                .expect("graph DSL")
295                .expect_err("a LABEL clause with no quoted label must be refused");
296            assert!(
297                error.to_string().contains("LABEL"),
298                "the error must name the LABEL clause: {error}"
299            );
300        }
301    }
302
303    #[test]
304    fn quoted_keyword_label_does_not_shadow_the_keyword() {
305        let stmt = parsed("GRAPH TRAVERSE IN 'c' FROM 'x' LABEL 'DIRECTION', 'b' DIRECTION in");
306        match stmt {
307            NodedbStatement::Graph(GraphStmt::GraphTraverse {
308                edge_labels,
309                direction,
310                ..
311            }) => {
312                assert_eq!(edge_labels, vec!["DIRECTION".to_string(), "b".to_string()]);
313                assert_eq!(direction, GraphDirection::In);
314            }
315            other => panic!("expected GraphTraverse, got {other:?}"),
316        }
317    }
318
319    #[test]
320    fn parse_graph_labels_list() {
321        let stmt = parsed("GRAPH LABEL 'alice' AS 'Person', 'User'");
322        match stmt {
323            NodedbStatement::Graph(GraphStmt::GraphSetLabels {
324                node_id,
325                labels,
326                remove,
327            }) => {
328                assert_eq!(node_id, "alice");
329                assert_eq!(labels, vec!["Person".to_string(), "User".to_string()]);
330                assert!(!remove);
331            }
332            other => panic!("expected GraphSetLabels, got {other:?}"),
333        }
334    }
335
336    #[test]
337    fn parse_graph_algo_pagerank() {
338        let stmt = parsed("GRAPH ALGO PAGERANK ON users ITERATIONS 5 DAMPING 0.85");
339        match stmt {
340            NodedbStatement::Graph(GraphStmt::GraphAlgo {
341                algorithm,
342                collection,
343                damping,
344                max_iterations,
345                ..
346            }) => {
347                assert_eq!(algorithm, "PAGERANK");
348                assert_eq!(collection, "users");
349                assert_eq!(damping, Some(0.85));
350                assert_eq!(max_iterations, Some(5));
351            }
352            other => panic!("expected GraphAlgo, got {other:?}"),
353        }
354    }
355
356    #[test]
357    fn parse_graph_algo_personalization() {
358        let stmt = parsed(
359            r#"GRAPH ALGO PAGERANK ON 'users' DAMPING 0.9 PERSONALIZATION {"alice": 1.0, "bob": 0.5}"#,
360        );
361        match stmt {
362            NodedbStatement::Graph(GraphStmt::GraphAlgo {
363                algorithm,
364                collection,
365                damping,
366                personalization,
367                ..
368            }) => {
369                assert_eq!(algorithm, "PAGERANK");
370                assert_eq!(collection, "users");
371                assert_eq!(damping, Some(0.9));
372                let raw = personalization.expect("personalization object present");
373                assert!(raw.contains("alice"));
374                assert!(raw.contains("bob"));
375                // Round-trips as a JSON node→weight map.
376                let map: std::collections::HashMap<String, f64> = sonic_rs::from_str(&raw).unwrap();
377                assert_eq!(map.get("alice"), Some(&1.0));
378                assert_eq!(map.get("bob"), Some(&0.5));
379            }
380            other => panic!("expected GraphAlgo, got {other:?}"),
381        }
382    }
383
384    #[test]
385    fn parse_match_query_captures_raw() {
386        let stmt = parsed("MATCH (x)-[:l]->(y) RETURN x, y");
387        match stmt {
388            NodedbStatement::Graph(GraphStmt::MatchQuery { body }) => {
389                assert!(body.starts_with("MATCH"));
390            }
391            other => panic!("expected MatchQuery, got {other:?}"),
392        }
393    }
394
395    #[test]
396    fn non_graph_returns_none() {
397        assert!(try_parse("SELECT * FROM users").is_none());
398        assert!(try_parse("CREATE COLLECTION users").is_none());
399    }
400
401    // ── GraphRagFusion parser tests ──────────────────────────────────────
402
403    #[test]
404    fn parse_rag_fusion_full_syntax() {
405        let stmt = parsed(
406            "GRAPH RAG FUSION ON entities \
407             QUERY ARRAY[0.1, 0.2, 0.3] \
408             VECTOR_TOP_K 50 \
409             EXPANSION_DEPTH 2 \
410             EDGE_LABEL 'related_to' \
411             FINAL_TOP_K 10 \
412             RRF_K (60.0, 35.0)",
413        );
414        match stmt {
415            NodedbStatement::Graph(GraphStmt::GraphRagFusion { collection, params }) => {
416                assert_eq!(collection, "entities");
417                let v = params.query_vector.expect("QUERY ARRAY parsed");
418                assert_eq!(v.len(), 3);
419                assert!((v[0] - 0.1f32).abs() < 1e-5);
420                assert_eq!(params.vector_top_k, Some(50));
421                assert_eq!(params.expansion_depth, Some(2));
422                assert_eq!(params.edge_label.as_deref(), Some("related_to"));
423                assert_eq!(params.final_top_k, Some(10));
424                let (k1, k2) = params.rrf_k.unwrap();
425                assert!((k1 - 60.0).abs() < 1e-10);
426                assert!((k2 - 35.0).abs() < 1e-10);
427            }
428            other => panic!("expected GraphRagFusion, got {other:?}"),
429        }
430    }
431
432    #[test]
433    fn parse_rag_fusion_minimal_defaults_to_none() {
434        let stmt = parsed("GRAPH RAG FUSION ON mycol QUERY ARRAY[1.0, 0.0]");
435        match stmt {
436            NodedbStatement::Graph(GraphStmt::GraphRagFusion { collection, params }) => {
437                assert_eq!(collection, "mycol");
438                assert!(params.query_vector.is_some());
439                assert_eq!(params.vector_top_k, None);
440                assert_eq!(params.expansion_depth, None);
441                assert_eq!(params.edge_label, None);
442                assert_eq!(params.final_top_k, None);
443                assert_eq!(params.rrf_k, None);
444                assert_eq!(params.vector_field, None);
445                assert_eq!(params.direction, None);
446                assert_eq!(params.max_visited, None);
447            }
448            other => panic!("expected GraphRagFusion, got {other:?}"),
449        }
450    }
451
452    #[test]
453    fn parse_rag_fusion_direction_and_max_visited() {
454        let stmt =
455            parsed("GRAPH RAG FUSION ON col QUERY ARRAY[0.5] DIRECTION both MAX_VISITED 500");
456        match stmt {
457            NodedbStatement::Graph(GraphStmt::GraphRagFusion { params, .. }) => {
458                assert_eq!(params.direction, Some(GraphDirection::Both));
459                assert_eq!(params.max_visited, Some(500));
460            }
461            other => panic!("expected GraphRagFusion, got {other:?}"),
462        }
463    }
464
465    #[test]
466    fn parse_rag_fusion_vector_field_is_captured() {
467        let stmt = parsed("GRAPH RAG FUSION ON col QUERY ARRAY[0.5] VECTOR_FIELD 'embedding'");
468        match stmt {
469            NodedbStatement::Graph(GraphStmt::GraphRagFusion { params, .. }) => {
470                assert_eq!(params.vector_field.as_deref(), Some("embedding"));
471            }
472            other => panic!("expected GraphRagFusion, got {other:?}"),
473        }
474    }
475
476    #[test]
477    fn parse_rag_fusion_rrf_k_both_values_captured() {
478        let stmt = parsed("GRAPH RAG FUSION ON col QUERY ARRAY[0.5] RRF_K (1.0, 99.5)");
479        match stmt {
480            NodedbStatement::Graph(GraphStmt::GraphRagFusion { params, .. }) => {
481                let (k1, k2) = params.rrf_k.expect("RRF_K must be parsed");
482                assert!((k1 - 1.0).abs() < 1e-10, "vector_k must be 1.0, got {k1}");
483                assert!((k2 - 99.5).abs() < 1e-10, "graph_k must be 99.5, got {k2}");
484            }
485            other => panic!("expected GraphRagFusion, got {other:?}"),
486        }
487    }
488
489    #[test]
490    fn parse_rag_fusion_missing_collection_names_the_clause() {
491        let error = try_parse("GRAPH RAG FUSION QUERY ARRAY[0.1] VECTOR_TOP_K 5")
492            .expect("input is graph DSL")
493            .expect_err("missing ON <collection> must not produce a statement");
494        assert!(
495            error.to_string().contains("ON <collection>"),
496            "the error must name the missing clause: {error}"
497        );
498    }
499
500    /// The two states `try_parse` must never confuse.
501    #[test]
502    fn malformed_graph_is_distinguishable_from_non_graph() {
503        assert!(
504            try_parse("SELECT * FROM users").is_none(),
505            "non-graph input must stay None so other families get a turn"
506        );
507        assert!(
508            matches!(try_parse("GRAPH TRAVERSE FROM 'b'"), Some(Err(_))),
509            "malformed graph input must report its own error"
510        );
511    }
512
513    /// An unrecognised direction is refused; an omitted one still defaults.
514    #[test]
515    fn direction_vocabulary_is_closed() {
516        for word in ["IN", "OUT", "BOTH", "both"] {
517            let sql = format!("GRAPH TRAVERSE IN 'c' FROM 'a' DIRECTION {word}");
518            assert!(
519                try_parse(&sql).expect("graph DSL").is_ok(),
520                "'{word}' is documented vocabulary and must parse"
521            );
522        }
523        for word in ["INBOUND", "BANANA"] {
524            let sql = format!("GRAPH TRAVERSE IN 'c' FROM 'a' DIRECTION {word}");
525            let error = try_parse(&sql)
526                .expect("graph DSL")
527                .expect_err("'{word}' is outside the vocabulary");
528            assert!(
529                error.to_string().contains(word),
530                "the error must name the offending value: {error}"
531            );
532        }
533        assert!(
534            try_parse("GRAPH TRAVERSE IN 'c' FROM 'a'")
535                .expect("graph DSL")
536                .is_ok(),
537            "an omitted DIRECTION must keep defaulting"
538        );
539    }
540
541    // ── Unconsumed and misplaced tokens ───────────────────────────────────
542
543    fn parse_error(sql: &str) -> String {
544        try_parse(sql)
545            .expect("input is graph DSL")
546            .expect_err("statement must be refused")
547            .to_string()
548    }
549
550    #[test]
551    fn misspelled_clause_keyword_is_named() {
552        let error = parse_error("GRAPH TRAVERSE FROM 1 DEPTS 3 IN g");
553        assert!(
554            error.contains("unexpected token 'DEPTS' in GRAPH TRAVERSE"),
555            "{error}"
556        );
557        let error = parse_error("GRAPH NEIGHBORS OF 1 LABELS 'follows' IN g");
558        assert!(
559            error.contains("unexpected token 'LABELS' in GRAPH NEIGHBORS"),
560            "{error}"
561        );
562    }
563
564    #[test]
565    fn stray_tokens_are_refused() {
566        for (sql, token) in [
567            ("GRAPH TRAVERSE FROM 1 SIDEWAYS IN g", "SIDEWAYS"),
568            ("GRAPH TRAVERSE FROM 1 BANANA PHONE IN g", "BANANA"),
569            ("GRAPH INSERT EDGE IN 'e' FROM 'a' TO 'b' TYP 'x'", "TYP"),
570            (
571                "GRAPH DELETE EDGE IN 'e' FROM 'a' TO 'b' TYPE 'x' EXTRA",
572                "EXTRA",
573            ),
574            ("GRAPH PATH IN 'g' FROM 'a' TO 'b' MAXDEPTH 3", "MAXDEPTH"),
575            ("GRAPH LABEL 'a' AS 'P' EXTRA", "EXTRA"),
576            ("GRAPH ALGO PAGERANK ON g ITERATION 5", "ITERATION"),
577            (
578                "GRAPH RAG FUSION ON e QUERY ARRAY[0.1] VECTOR_TOPK 5",
579                "VECTOR_TOPK",
580            ),
581            ("GRAPH TRAVERSE IN 'g' FROM 'a' ]", "]"),
582        ] {
583            let error = parse_error(sql);
584            assert!(
585                error.contains(&format!("unexpected token '{token}'")),
586                "{sql}: {error}"
587            );
588        }
589    }
590
591    #[test]
592    fn stray_string_at_clause_position_is_refused() {
593        let error = parse_error("GRAPH TRAVERSE IN 'g' FROM 'a' 'b'");
594        assert!(error.contains("unexpected string 'b'"), "{error}");
595    }
596
597    #[test]
598    fn repeated_clause_is_refused() {
599        let error = parse_error("GRAPH TRAVERSE IN 'g' FROM 'a' DEPTH 1 DEPTH 3");
600        assert!(
601            error.contains("DEPTH given more than once in GRAPH TRAVERSE"),
602            "{error}"
603        );
604    }
605
606    #[test]
607    fn direction_value_in_does_not_shadow_in_clause() {
608        match parsed("GRAPH TRAVERSE FROM 'b' DIRECTION in IN 'g'") {
609            NodedbStatement::Graph(GraphStmt::GraphTraverse {
610                collection,
611                start,
612                direction,
613                ..
614            }) => {
615                assert_eq!(collection, "g");
616                assert_eq!(start, "b");
617                assert_eq!(direction, GraphDirection::In);
618            }
619            other => panic!("expected GraphTraverse, got {other:?}"),
620        }
621    }
622
623    #[test]
624    fn non_numeric_clause_values_are_refused() {
625        let error = parse_error("GRAPH ALGO PAGERANK ON g DAMPING abc");
626        assert!(error.contains("DAMPING must be a number"), "{error}");
627        let error = parse_error("GRAPH TRAVERSE IN 'g' FROM 'a' DEPTH '3'");
628        assert!(
629            error.contains("DEPTH must be a non-negative integer"),
630            "{error}"
631        );
632        let error = parse_error("GRAPH RAG FUSION ON e QUERY ARRAY[0.1, x]");
633        assert!(error.contains("'x'"), "{error}");
634    }
635
636    #[test]
637    fn comments_are_not_tokens() {
638        match parsed("GRAPH TRAVERSE IN 'g' /* note */ FROM 'a' -- trailing note") {
639            NodedbStatement::Graph(GraphStmt::GraphTraverse {
640                collection, start, ..
641            }) => {
642                assert_eq!(collection, "g");
643                assert_eq!(start, "a");
644            }
645            other => panic!("expected GraphTraverse, got {other:?}"),
646        }
647    }
648
649    #[test]
650    fn quoted_identifier_is_a_value() {
651        match parsed(r#"GRAPH NEIGHBORS IN "My Graph" OF 'a'"#) {
652            NodedbStatement::Graph(GraphStmt::GraphNeighbors { collection, .. }) => {
653                assert_eq!(collection, "My Graph");
654            }
655            other => panic!("expected GraphNeighbors, got {other:?}"),
656        }
657    }
658
659    #[test]
660    fn rag_fusion_bm25_field_is_the_on_after_bm25() {
661        let params_of = |sql: &str| match parsed(sql) {
662            NodedbStatement::Graph(GraphStmt::GraphRagFusion { collection, params }) => {
663                assert_eq!(collection, "entities");
664                params
665            }
666            other => panic!("expected GraphRagFusion, got {other:?}"),
667        };
668        let params = params_of(
669            "GRAPH RAG FUSION ON entities QUERY ARRAY[0.1] BM25 'q' ON 'body' \
670             RRF_K (1.0, 2.0, 3.0)",
671        );
672        assert_eq!(params.bm25_query.as_deref(), Some("q"));
673        assert_eq!(params.bm25_field.as_deref(), Some("body"));
674        assert_eq!(params.rrf_k_triple, Some((1.0, 2.0, 3.0)));
675        let params = params_of("GRAPH RAG FUSION ON entities QUERY ARRAY[0.1]");
676        assert_eq!(params.bm25_field, None);
677    }
678}