From 94849a50b41f27e990097f0de9008fcca3ce4492 Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 20 Apr 2026 14:50:18 +0300 Subject: [PATCH] Extract compiler test modules to sibling files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typecheck.rs, schema/parser.rs, and query/parser.rs each had ~1000-line inline `mod tests` blocks that overshadowed the production code in the file. Move each to a sibling `*_tests.rs` using `#[path = "..."] mod tests;`. - typecheck.rs: 2865 → 1708 lines; typecheck_tests.rs: 1156 lines - schema/parser.rs: 1950 → 994 lines; parser_tests.rs: 955 lines - query/parser.rs: 1737 → 803 lines; parser_tests.rs: 933 lines No visibility change — the sibling module still has `use super::*` access to crate-privates. No semantic edits beyond de-indenting by 4 spaces (mechanical). All 229 compiler tests green, identical count to before. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/omnigraph-compiler/src/query/parser.rs | 937 +------------ .../src/query/parser_tests.rs | 933 +++++++++++++ .../omnigraph-compiler/src/query/typecheck.rs | 1160 +---------------- .../src/query/typecheck_tests.rs | 1156 ++++++++++++++++ .../omnigraph-compiler/src/schema/parser.rs | 959 +------------- .../src/schema/parser_tests.rs | 955 ++++++++++++++ 6 files changed, 3050 insertions(+), 3050 deletions(-) create mode 100644 crates/omnigraph-compiler/src/query/parser_tests.rs create mode 100644 crates/omnigraph-compiler/src/query/typecheck_tests.rs create mode 100644 crates/omnigraph-compiler/src/schema/parser_tests.rs diff --git a/crates/omnigraph-compiler/src/query/parser.rs b/crates/omnigraph-compiler/src/query/parser.rs index 466c567..20fedb8 100644 --- a/crates/omnigraph-compiler/src/query/parser.rs +++ b/crates/omnigraph-compiler/src/query/parser.rs @@ -800,938 +800,5 @@ fn parse_nearest_ordering(pair: pest::iterators::Pair) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_basic_query() { - let input = r#" -query get_person($name: String) { - match { - $p: Person { name: $name } - } - return { $p.name, $p.age } -} -"#; - let qf = parse_query(input).unwrap(); - assert_eq!(qf.queries.len(), 1); - let q = &qf.queries[0]; - assert_eq!(q.name, "get_person"); - assert_eq!(q.params.len(), 1); - assert_eq!(q.params[0].name, "name"); - assert_eq!(q.match_clause.len(), 1); - assert_eq!(q.return_clause.len(), 2); - } - - #[test] - fn test_parse_query_metadata_annotations() { - let input = r#" -query semantic_search($q: String) - @description("Find semantically similar documents.") - @instruction("Use for conceptual search; prefer keyword_search for exact terms.") -{ - match { - $d: Doc - } - return { $d.slug } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!( - q.description.as_deref(), - Some("Find semantically similar documents.") - ); - assert_eq!( - q.instruction.as_deref(), - Some("Use for conceptual search; prefer keyword_search for exact terms.") - ); - } - - #[test] - fn test_duplicate_query_description_is_rejected() { - let input = r#" -query q() - @description("one") - @description("two") -{ - match { - $p: Person - } - return { $p.name } -} -"#; - let err = parse_query(input).unwrap_err(); - assert!(err.to_string().contains("duplicate @description")); - } - - #[test] - fn test_parse_no_params() { - let input = r#" -query adults() { - match { - $p: Person - $p.age > 30 - } - return { $p.name, $p.age } - order { $p.age desc } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.name, "adults"); - assert!(q.params.is_empty()); - assert_eq!(q.match_clause.len(), 2); - assert_eq!(q.order_clause.len(), 1); - assert!(q.order_clause[0].descending); - } - - #[test] - fn test_parse_traversal() { - let input = r#" -query friends_of($name: String) { - match { - $p: Person { name: $name } - $p knows $f - } - return { $f.name, $f.age } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.match_clause.len(), 2); - match &q.match_clause[1] { - Clause::Traversal(t) => { - assert_eq!(t.src, "p"); - assert_eq!(t.edge_name, "knows"); - assert_eq!(t.dst, "f"); - assert_eq!(t.min_hops, 1); - assert_eq!(t.max_hops, Some(1)); - } - _ => panic!("expected Traversal"), - } - } - - #[test] - fn test_parse_negation() { - let input = r#" -query unemployed() { - match { - $p: Person - not { $p worksAt $_ } - } - return { $p.name } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.match_clause.len(), 2); - match &q.match_clause[1] { - Clause::Negation(clauses) => { - assert_eq!(clauses.len(), 1); - match &clauses[0] { - Clause::Traversal(t) => { - assert_eq!(t.src, "p"); - assert_eq!(t.edge_name, "worksAt"); - assert_eq!(t.dst, "_"); - assert_eq!(t.min_hops, 1); - assert_eq!(t.max_hops, Some(1)); - } - _ => panic!("expected Traversal inside negation"), - } - } - _ => panic!("expected Negation"), - } - } - - #[test] - fn test_parse_aggregation() { - let input = r#" -query friend_counts() { - match { - $p: Person - $p knows $f - } - return { - $p.name - count($f) as friends - } - order { friends desc } - limit 20 -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.return_clause.len(), 2); - match &q.return_clause[1].expr { - Expr::Aggregate { func, .. } => { - assert_eq!(*func, AggFunc::Count); - } - _ => panic!("expected Aggregate"), - } - assert_eq!(q.return_clause[1].alias.as_deref(), Some("friends")); - assert_eq!(q.limit, Some(20)); - } - - #[test] - fn test_parse_two_hop() { - let input = r#" -query friends_of_friends($name: String) { - match { - $p: Person { name: $name } - $p knows $mid - $mid knows $fof - } - return { $fof.name } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.match_clause.len(), 3); - } - - #[test] - fn test_parse_reverse_traversal() { - let input = r#" -query employees_of($company: String) { - match { - $c: Company { name: $company } - $p worksAt $c - } - return { $p.name } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.match_clause.len(), 2); - match &q.match_clause[1] { - Clause::Traversal(t) => { - assert_eq!(t.src, "p"); - assert_eq!(t.edge_name, "worksAt"); - assert_eq!(t.dst, "c"); - assert_eq!(t.min_hops, 1); - assert_eq!(t.max_hops, Some(1)); - } - _ => panic!("expected Traversal"), - } - } - - #[test] - fn test_parse_bounded_traversal() { - let input = r#" -query q() { - match { - $a: Person - $a knows{1,3} $b - } - return { $b.name } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - match &q.match_clause[1] { - Clause::Traversal(t) => { - assert_eq!(t.min_hops, 1); - assert_eq!(t.max_hops, Some(3)); - } - _ => panic!("expected Traversal"), - } - } - - #[test] - fn test_parse_unbounded_traversal() { - let input = r#" -query q() { - match { - $a: Person - $a knows{1,} $b - } - return { $b.name } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - match &q.match_clause[1] { - Clause::Traversal(t) => { - assert_eq!(t.min_hops, 1); - assert_eq!(t.max_hops, None); - } - _ => panic!("expected Traversal"), - } - } - - #[test] - fn test_parse_multi_query_file() { - let input = r#" -query q1() { - match { $p: Person } - return { $p.name } -} -query q2() { - match { $c: Company } - return { $c.name } -} -"#; - let qf = parse_query(input).unwrap(); - assert_eq!(qf.queries.len(), 2); - } - - #[test] - fn test_parse_complex_negation() { - let input = r#" -query knows_alice_not_bob() { - match { - $a: Person { name: "Alice" } - $b: Person { name: "Bob" } - $p: Person - $p knows $a - not { $p knows $b } - } - return { $p.name } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.match_clause.len(), 5); - } - - #[test] - fn test_parse_filter_string() { - let input = r#" -query test() { - match { - $p: Person - $p.name != "Bob" - } - return { $p.name } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - match &q.match_clause[1] { - Clause::Filter(f) => { - assert_eq!(f.op, CompOp::Ne); - } - _ => panic!("expected Filter"), - } - } - - #[test] - fn test_parse_filter_string_decodes_escapes() { - let input = r#" -query test() { - match { - $p: Person - $p.name = "Bob\n\"Builder\"\t\\" - } - return { $p.name } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - match &q.match_clause[1] { - Clause::Filter(f) => match &f.right { - Expr::Literal(Literal::String(value)) => { - assert_eq!(value, "Bob\n\"Builder\"\t\\"); - } - other => panic!("expected string literal, got {:?}", other), - }, - _ => panic!("expected Filter"), - } - } - - #[test] - fn test_parse_string_literal_rejects_unknown_escape() { - let input = r#" -query test() { - match { - $p: Person - $p.name = "Bob\q" - } - return { $p.name } -} -"#; - let err = parse_query(input).unwrap_err(); - assert!(err.to_string().contains("unsupported escape sequence")); - } - - #[test] - fn test_parse_bool_literals() { - let input = r#" -query flags() { - match { - $p: Person - $p.active = true - $p.active != false - } - return { $p.name } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - match &q.match_clause[1] { - Clause::Filter(f) => match &f.right { - Expr::Literal(Literal::Bool(value)) => assert!(*value), - other => panic!("expected bool literal, got {:?}", other), - }, - _ => panic!("expected Filter"), - } - match &q.match_clause[2] { - Clause::Filter(f) => match &f.right { - Expr::Literal(Literal::Bool(value)) => assert!(!*value), - other => panic!("expected bool literal, got {:?}", other), - }, - _ => panic!("expected Filter"), - } - } - - #[test] - fn test_parse_contains_filter() { - let input = r#" -query tagged($tag: String) { - match { - $p: Person - $p.tags contains $tag - } - return { $p.name } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - match &q.match_clause[1] { - Clause::Filter(f) => { - assert_eq!(f.op, CompOp::Contains); - assert!(matches!( - &f.left, - Expr::PropAccess { variable, property } if variable == "p" && property == "tags" - )); - assert!(matches!(&f.right, Expr::Variable(v) if v == "tag")); - } - _ => panic!("expected Filter"), - } - } - - #[test] - fn test_parse_contains_is_rejected_in_mutation_predicate() { - let input = r#" -query drop_person($tag: String) { - delete Person where tags contains $tag -} -"#; - assert!(parse_query(input).is_err()); - } - - #[test] - fn test_parse_triangle() { - let input = r#" -query triangles($name: String) { - match { - $a: Person { name: $name } - $a knows $b - $b knows $c - $c knows $a - } - return { $b.name, $c.name } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.match_clause.len(), 4); - } - - #[test] - fn test_parse_avg_aggregation() { - let input = r#" -query avg_age_by_company() { - match { - $p: Person - $p worksAt $c - } - return { - $c.name - avg($p.age) as avg_age - count($p) as headcount - } - order { headcount desc } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.return_clause.len(), 3); - } - - #[test] - fn test_parse_insert_mutation() { - let input = r#" -query add_person($name: String, $age: I32) { - insert Person { - name: $name - age: $age - } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - match q.mutations.first().expect("expected mutation") { - Mutation::Insert(ins) => { - assert_eq!(ins.type_name, "Person"); - assert_eq!(ins.assignments.len(), 2); - } - _ => panic!("expected Insert mutation"), - } - } - - #[test] - fn test_parse_update_mutation() { - let input = r#" -query set_age($name: String, $age: I32) { - update Person set { - age: $age - } where name = $name -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - match q.mutations.first().expect("expected mutation") { - Mutation::Update(upd) => { - assert_eq!(upd.type_name, "Person"); - assert_eq!(upd.assignments.len(), 1); - assert_eq!(upd.predicate.property, "name"); - assert_eq!(upd.predicate.op, CompOp::Eq); - } - _ => panic!("expected Update mutation"), - } - } - - #[test] - fn test_parse_delete_mutation() { - let input = r#" -query drop_person($name: String) { - delete Person where name = $name -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - match q.mutations.first().expect("expected mutation") { - Mutation::Delete(del) => { - assert_eq!(del.type_name, "Person"); - assert_eq!(del.predicate.property, "name"); - assert_eq!(del.predicate.op, CompOp::Eq); - } - _ => panic!("expected Delete mutation"), - } - } - - #[test] - fn test_parse_date_and_datetime_literals() { - let input = r#" -query dated() { - match { - $e: Event - $e.on = date("2026-02-14") - $e.at >= datetime("2026-02-14T10:00:00Z") - } - return { $e.id } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - match &q.match_clause[1] { - Clause::Filter(f) => match &f.right { - Expr::Literal(Literal::Date(v)) => assert_eq!(v, "2026-02-14"), - other => panic!("expected date literal, got {:?}", other), - }, - _ => panic!("expected Filter"), - } - match &q.match_clause[2] { - Clause::Filter(f) => match &f.right { - Expr::Literal(Literal::DateTime(v)) => assert_eq!(v, "2026-02-14T10:00:00Z"), - other => panic!("expected datetime literal, got {:?}", other), - }, - _ => panic!("expected Filter"), - } - } - - #[test] - fn test_parse_now_expression_and_mutation_value() { - let input = r#" -query clock() { - match { - $e: Event - $e.at <= now() - } - return { now() as ts } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - match &q.match_clause[1] { - Clause::Filter(f) => assert!(matches!(f.right, Expr::Now)), - _ => panic!("expected Filter"), - } - assert!(matches!(q.return_clause[0].expr, Expr::Now)); - - let mutation = parse_query( - r#" -query stamp() { - update Event set { updated_at: now() } where created_at <= now() -} -"#, - ) - .unwrap(); - match mutation.queries[0].mutations.first().unwrap() { - Mutation::Update(update) => { - assert!(matches!(update.assignments[0].value, MatchValue::Now)); - assert!(matches!(update.predicate.value, MatchValue::Now)); - } - _ => panic!("expected update mutation"), - } - } - - #[test] - fn test_parse_multi_mutation() { - let input = r#" -query add_and_link($name: String, $age: I32, $friend: String) { - insert Person { name: $name, age: $age } - insert Knows { from: $name, to: $friend } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.mutations.len(), 2); - assert!(matches!(&q.mutations[0], Mutation::Insert(ins) if ins.type_name == "Person")); - assert!(matches!(&q.mutations[1], Mutation::Insert(ins) if ins.type_name == "Knows")); - } - - #[test] - fn test_parse_multi_mutation_mixed_ops() { - let input = r#" -query create_and_clean($name: String, $age: I32, $old: String) { - insert Person { name: $name, age: $age } - delete Person where name = $old -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.mutations.len(), 2); - assert!(matches!(&q.mutations[0], Mutation::Insert(_))); - assert!(matches!(&q.mutations[1], Mutation::Delete(_))); - } - - #[test] - fn test_parse_single_mutation_backward_compat() { - let input = r#" -query add($name: String, $age: I32) { - insert Person { name: $name, age: $age } -} -"#; - let qf = parse_query(input).unwrap(); - assert_eq!(qf.queries[0].mutations.len(), 1); - } - - #[test] - fn test_parse_list_literal() { - let input = r#" -query listy() { - match { $p: Person { tags: ["rust", "db"] } } - return { $p.tags } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - match &q.match_clause[0] { - Clause::Binding(b) => match &b.prop_matches[0].value { - MatchValue::Literal(Literal::List(items)) => { - assert_eq!(items.len(), 2); - } - other => panic!("expected list literal, got {:?}", other), - }, - _ => panic!("expected Binding"), - } - } - - #[test] - fn test_parse_nearest_ordering_and_vector_param_type() { - let input = r#" -query similar($q: Vector(3)) { - match { $d: Doc } - return { $d.id } - order { nearest($d.embedding, $q) } - limit 5 -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.params[0].type_name, "Vector(3)"); - assert_eq!(q.order_clause.len(), 1); - assert!(!q.order_clause[0].descending); - match &q.order_clause[0].expr { - Expr::Nearest { - variable, - property, - query, - } => { - assert_eq!(variable, "d"); - assert_eq!(property, "embedding"); - assert!(matches!(query.as_ref(), Expr::Variable(v) if v == "q")); - } - other => panic!("expected nearest ordering, got {:?}", other), - } - } - - #[test] - fn test_parse_nearest_with_spaced_vector_param_type() { - let input = r#" -query similar($q: Vector( 3 ) ?) { - match { $d: Doc } - return { $d.id } - order { nearest($d.embedding, $q) } - limit 5 -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.params[0].type_name, "Vector(3)"); - assert!(q.params[0].nullable); - } - - #[test] - fn test_parse_list_and_datetime_param_types() { - let input = r#" -query tasks($tags: [String], $days: [Date]?, $due_at: DateTime) { - match { $t: Task } - return { $t.slug } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.params[0].type_name, "[String]"); - assert!(!q.params[0].nullable); - assert_eq!(q.params[1].type_name, "[Date]"); - assert!(q.params[1].nullable); - assert_eq!(q.params[2].type_name, "DateTime"); - } - - #[test] - fn test_parse_nearest_rejects_direction_modifier() { - let input = r#" -query similar($q: Vector(3)) { - match { $d: Doc } - return { $d.id } - order { nearest($d.embedding, $q) desc } - limit 5 -} -"#; - assert!(parse_query(input).is_err()); - } - - #[test] - fn test_parse_nearest_expression_in_return_projection() { - let input = r#" -query similar($q: Vector(3)) { - match { $d: Doc } - return { $d.id, nearest($d.embedding, $q) as score } - order { nearest($d.embedding, $q) } - limit 5 -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.return_clause.len(), 2); - match &q.return_clause[1].expr { - Expr::Nearest { - variable, - property, - query, - } => { - assert_eq!(variable, "d"); - assert_eq!(property, "embedding"); - assert!(matches!(query.as_ref(), Expr::Variable(v) if v == "q")); - } - other => panic!( - "expected nearest expression in return projection, got {:?}", - other - ), - } - assert_eq!(q.return_clause[1].alias.as_deref(), Some("score")); - } - - #[test] - fn test_parse_search_clause_sugar() { - let input = r#" -query q($q: String) { - match { - $s: Signal - search($s.summary, $q) - } - return { $s.slug } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.match_clause.len(), 2); - match &q.match_clause[1] { - Clause::Filter(Filter { left, op, right }) => { - assert_eq!(*op, CompOp::Eq); - assert!(matches!(right, Expr::Literal(Literal::Bool(true)))); - match left { - Expr::Search { field, query } => { - assert!(matches!( - field.as_ref(), - Expr::PropAccess { variable, property } if variable == "s" && property == "summary" - )); - assert!(matches!(query.as_ref(), Expr::Variable(v) if v == "q")); - } - other => panic!("expected search expression, got {:?}", other), - } - } - other => panic!("expected filter clause, got {:?}", other), - } - } - - #[test] - fn test_parse_fuzzy_clause_with_max_edits() { - let input = r#" -query q($q: String) { - match { - $s: Signal - fuzzy($s.summary, $q, 2) - } - return { $s.slug } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.match_clause.len(), 2); - match &q.match_clause[1] { - Clause::Filter(Filter { left, op, right }) => { - assert_eq!(*op, CompOp::Eq); - assert!(matches!(right, Expr::Literal(Literal::Bool(true)))); - match left { - Expr::Fuzzy { - field, - query, - max_edits, - } => { - assert!(matches!( - field.as_ref(), - Expr::PropAccess { variable, property } if variable == "s" && property == "summary" - )); - assert!(matches!(query.as_ref(), Expr::Variable(v) if v == "q")); - assert!(matches!( - max_edits.as_deref(), - Some(Expr::Literal(Literal::Integer(2))) - )); - } - other => panic!("expected fuzzy expression, got {:?}", other), - } - } - other => panic!("expected filter clause, got {:?}", other), - } - } - - #[test] - fn test_parse_match_text_clause_sugar() { - let input = r#" -query q($q: String) { - match { - $s: Signal - match_text($s.summary, $q) - } - return { $s.slug } -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.match_clause.len(), 2); - match &q.match_clause[1] { - Clause::Filter(Filter { left, op, right }) => { - assert_eq!(*op, CompOp::Eq); - assert!(matches!(right, Expr::Literal(Literal::Bool(true)))); - match left { - Expr::MatchText { field, query } => { - assert!(matches!( - field.as_ref(), - Expr::PropAccess { variable, property } if variable == "s" && property == "summary" - )); - assert!(matches!(query.as_ref(), Expr::Variable(v) if v == "q")); - } - other => panic!("expected match_text expression, got {:?}", other), - } - } - other => panic!("expected filter clause, got {:?}", other), - } - } - - #[test] - fn test_parse_bm25_expression_in_order() { - let input = r#" -query q($q: String) { - match { $s: Signal } - return { $s.slug, bm25($s.summary, $q) as score } - order { bm25($s.summary, $q) desc } - limit 5 -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.return_clause.len(), 2); - match &q.return_clause[1].expr { - Expr::Bm25 { field, query } => { - assert!(matches!( - field.as_ref(), - Expr::PropAccess { variable, property } if variable == "s" && property == "summary" - )); - assert!(matches!(query.as_ref(), Expr::Variable(v) if v == "q")); - } - other => panic!("expected bm25 expression, got {:?}", other), - } - assert_eq!(q.order_clause.len(), 1); - assert!(q.order_clause[0].descending); - } - - #[test] - fn test_parse_rrf_ordering_with_nearest_and_bm25() { - let input = r#" -query q($vq: Vector(3), $tq: String) { - match { $s: Signal } - return { $s.slug } - order { rrf(nearest($s.embedding, $vq), bm25($s.summary, $tq), 60) desc } - limit 5 -} -"#; - let qf = parse_query(input).unwrap(); - let q = &qf.queries[0]; - assert_eq!(q.order_clause.len(), 1); - assert!(q.order_clause[0].descending); - match &q.order_clause[0].expr { - Expr::Rrf { - primary, - secondary, - k, - } => { - assert!(matches!(primary.as_ref(), Expr::Nearest { .. })); - assert!(matches!(secondary.as_ref(), Expr::Bm25 { .. })); - assert!(matches!( - k.as_deref(), - Some(Expr::Literal(Literal::Integer(60))) - )); - } - other => panic!("expected rrf expression, got {:?}", other), - } - } - - #[test] - fn test_parse_error_diagnostic_has_span() { - let input = r#" -query q() { - match { - $p: Person - } - return { $p.name -} -"#; - let err = parse_query_diagnostic(input).unwrap_err(); - assert!(err.span.is_some()); - } -} +#[path = "parser_tests.rs"] +mod tests; diff --git a/crates/omnigraph-compiler/src/query/parser_tests.rs b/crates/omnigraph-compiler/src/query/parser_tests.rs new file mode 100644 index 0000000..5267a36 --- /dev/null +++ b/crates/omnigraph-compiler/src/query/parser_tests.rs @@ -0,0 +1,933 @@ +use super::*; + +#[test] +fn test_parse_basic_query() { + let input = r#" +query get_person($name: String) { +match { + $p: Person { name: $name } +} +return { $p.name, $p.age } +} +"#; + let qf = parse_query(input).unwrap(); + assert_eq!(qf.queries.len(), 1); + let q = &qf.queries[0]; + assert_eq!(q.name, "get_person"); + assert_eq!(q.params.len(), 1); + assert_eq!(q.params[0].name, "name"); + assert_eq!(q.match_clause.len(), 1); + assert_eq!(q.return_clause.len(), 2); +} + +#[test] +fn test_parse_query_metadata_annotations() { + let input = r#" +query semantic_search($q: String) +@description("Find semantically similar documents.") +@instruction("Use for conceptual search; prefer keyword_search for exact terms.") +{ +match { + $d: Doc +} +return { $d.slug } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!( + q.description.as_deref(), + Some("Find semantically similar documents.") + ); + assert_eq!( + q.instruction.as_deref(), + Some("Use for conceptual search; prefer keyword_search for exact terms.") + ); +} + +#[test] +fn test_duplicate_query_description_is_rejected() { + let input = r#" +query q() +@description("one") +@description("two") +{ +match { + $p: Person +} +return { $p.name } +} +"#; + let err = parse_query(input).unwrap_err(); + assert!(err.to_string().contains("duplicate @description")); +} + +#[test] +fn test_parse_no_params() { + let input = r#" +query adults() { +match { + $p: Person + $p.age > 30 +} +return { $p.name, $p.age } +order { $p.age desc } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.name, "adults"); + assert!(q.params.is_empty()); + assert_eq!(q.match_clause.len(), 2); + assert_eq!(q.order_clause.len(), 1); + assert!(q.order_clause[0].descending); +} + +#[test] +fn test_parse_traversal() { + let input = r#" +query friends_of($name: String) { +match { + $p: Person { name: $name } + $p knows $f +} +return { $f.name, $f.age } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.match_clause.len(), 2); + match &q.match_clause[1] { + Clause::Traversal(t) => { + assert_eq!(t.src, "p"); + assert_eq!(t.edge_name, "knows"); + assert_eq!(t.dst, "f"); + assert_eq!(t.min_hops, 1); + assert_eq!(t.max_hops, Some(1)); + } + _ => panic!("expected Traversal"), + } +} + +#[test] +fn test_parse_negation() { + let input = r#" +query unemployed() { +match { + $p: Person + not { $p worksAt $_ } +} +return { $p.name } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.match_clause.len(), 2); + match &q.match_clause[1] { + Clause::Negation(clauses) => { + assert_eq!(clauses.len(), 1); + match &clauses[0] { + Clause::Traversal(t) => { + assert_eq!(t.src, "p"); + assert_eq!(t.edge_name, "worksAt"); + assert_eq!(t.dst, "_"); + assert_eq!(t.min_hops, 1); + assert_eq!(t.max_hops, Some(1)); + } + _ => panic!("expected Traversal inside negation"), + } + } + _ => panic!("expected Negation"), + } +} + +#[test] +fn test_parse_aggregation() { + let input = r#" +query friend_counts() { +match { + $p: Person + $p knows $f +} +return { + $p.name + count($f) as friends +} +order { friends desc } +limit 20 +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.return_clause.len(), 2); + match &q.return_clause[1].expr { + Expr::Aggregate { func, .. } => { + assert_eq!(*func, AggFunc::Count); + } + _ => panic!("expected Aggregate"), + } + assert_eq!(q.return_clause[1].alias.as_deref(), Some("friends")); + assert_eq!(q.limit, Some(20)); +} + +#[test] +fn test_parse_two_hop() { + let input = r#" +query friends_of_friends($name: String) { +match { + $p: Person { name: $name } + $p knows $mid + $mid knows $fof +} +return { $fof.name } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.match_clause.len(), 3); +} + +#[test] +fn test_parse_reverse_traversal() { + let input = r#" +query employees_of($company: String) { +match { + $c: Company { name: $company } + $p worksAt $c +} +return { $p.name } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.match_clause.len(), 2); + match &q.match_clause[1] { + Clause::Traversal(t) => { + assert_eq!(t.src, "p"); + assert_eq!(t.edge_name, "worksAt"); + assert_eq!(t.dst, "c"); + assert_eq!(t.min_hops, 1); + assert_eq!(t.max_hops, Some(1)); + } + _ => panic!("expected Traversal"), + } +} + +#[test] +fn test_parse_bounded_traversal() { + let input = r#" +query q() { +match { + $a: Person + $a knows{1,3} $b +} +return { $b.name } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + match &q.match_clause[1] { + Clause::Traversal(t) => { + assert_eq!(t.min_hops, 1); + assert_eq!(t.max_hops, Some(3)); + } + _ => panic!("expected Traversal"), + } +} + +#[test] +fn test_parse_unbounded_traversal() { + let input = r#" +query q() { +match { + $a: Person + $a knows{1,} $b +} +return { $b.name } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + match &q.match_clause[1] { + Clause::Traversal(t) => { + assert_eq!(t.min_hops, 1); + assert_eq!(t.max_hops, None); + } + _ => panic!("expected Traversal"), + } +} + +#[test] +fn test_parse_multi_query_file() { + let input = r#" +query q1() { +match { $p: Person } +return { $p.name } +} +query q2() { +match { $c: Company } +return { $c.name } +} +"#; + let qf = parse_query(input).unwrap(); + assert_eq!(qf.queries.len(), 2); +} + +#[test] +fn test_parse_complex_negation() { + let input = r#" +query knows_alice_not_bob() { +match { + $a: Person { name: "Alice" } + $b: Person { name: "Bob" } + $p: Person + $p knows $a + not { $p knows $b } +} +return { $p.name } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.match_clause.len(), 5); +} + +#[test] +fn test_parse_filter_string() { + let input = r#" +query test() { +match { + $p: Person + $p.name != "Bob" +} +return { $p.name } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + match &q.match_clause[1] { + Clause::Filter(f) => { + assert_eq!(f.op, CompOp::Ne); + } + _ => panic!("expected Filter"), + } +} + +#[test] +fn test_parse_filter_string_decodes_escapes() { + let input = r#" +query test() { +match { + $p: Person + $p.name = "Bob\n\"Builder\"\t\\" +} +return { $p.name } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + match &q.match_clause[1] { + Clause::Filter(f) => match &f.right { + Expr::Literal(Literal::String(value)) => { + assert_eq!(value, "Bob\n\"Builder\"\t\\"); + } + other => panic!("expected string literal, got {:?}", other), + }, + _ => panic!("expected Filter"), + } +} + +#[test] +fn test_parse_string_literal_rejects_unknown_escape() { + let input = r#" +query test() { +match { + $p: Person + $p.name = "Bob\q" +} +return { $p.name } +} +"#; + let err = parse_query(input).unwrap_err(); + assert!(err.to_string().contains("unsupported escape sequence")); +} + +#[test] +fn test_parse_bool_literals() { + let input = r#" +query flags() { +match { + $p: Person + $p.active = true + $p.active != false +} +return { $p.name } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + match &q.match_clause[1] { + Clause::Filter(f) => match &f.right { + Expr::Literal(Literal::Bool(value)) => assert!(*value), + other => panic!("expected bool literal, got {:?}", other), + }, + _ => panic!("expected Filter"), + } + match &q.match_clause[2] { + Clause::Filter(f) => match &f.right { + Expr::Literal(Literal::Bool(value)) => assert!(!*value), + other => panic!("expected bool literal, got {:?}", other), + }, + _ => panic!("expected Filter"), + } +} + +#[test] +fn test_parse_contains_filter() { + let input = r#" +query tagged($tag: String) { +match { + $p: Person + $p.tags contains $tag +} +return { $p.name } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + match &q.match_clause[1] { + Clause::Filter(f) => { + assert_eq!(f.op, CompOp::Contains); + assert!(matches!( + &f.left, + Expr::PropAccess { variable, property } if variable == "p" && property == "tags" + )); + assert!(matches!(&f.right, Expr::Variable(v) if v == "tag")); + } + _ => panic!("expected Filter"), + } +} + +#[test] +fn test_parse_contains_is_rejected_in_mutation_predicate() { + let input = r#" +query drop_person($tag: String) { +delete Person where tags contains $tag +} +"#; + assert!(parse_query(input).is_err()); +} + +#[test] +fn test_parse_triangle() { + let input = r#" +query triangles($name: String) { +match { + $a: Person { name: $name } + $a knows $b + $b knows $c + $c knows $a +} +return { $b.name, $c.name } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.match_clause.len(), 4); +} + +#[test] +fn test_parse_avg_aggregation() { + let input = r#" +query avg_age_by_company() { +match { + $p: Person + $p worksAt $c +} +return { + $c.name + avg($p.age) as avg_age + count($p) as headcount +} +order { headcount desc } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.return_clause.len(), 3); +} + +#[test] +fn test_parse_insert_mutation() { + let input = r#" +query add_person($name: String, $age: I32) { +insert Person { + name: $name + age: $age +} +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + match q.mutations.first().expect("expected mutation") { + Mutation::Insert(ins) => { + assert_eq!(ins.type_name, "Person"); + assert_eq!(ins.assignments.len(), 2); + } + _ => panic!("expected Insert mutation"), + } +} + +#[test] +fn test_parse_update_mutation() { + let input = r#" +query set_age($name: String, $age: I32) { +update Person set { + age: $age +} where name = $name +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + match q.mutations.first().expect("expected mutation") { + Mutation::Update(upd) => { + assert_eq!(upd.type_name, "Person"); + assert_eq!(upd.assignments.len(), 1); + assert_eq!(upd.predicate.property, "name"); + assert_eq!(upd.predicate.op, CompOp::Eq); + } + _ => panic!("expected Update mutation"), + } +} + +#[test] +fn test_parse_delete_mutation() { + let input = r#" +query drop_person($name: String) { +delete Person where name = $name +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + match q.mutations.first().expect("expected mutation") { + Mutation::Delete(del) => { + assert_eq!(del.type_name, "Person"); + assert_eq!(del.predicate.property, "name"); + assert_eq!(del.predicate.op, CompOp::Eq); + } + _ => panic!("expected Delete mutation"), + } +} + +#[test] +fn test_parse_date_and_datetime_literals() { + let input = r#" +query dated() { +match { + $e: Event + $e.on = date("2026-02-14") + $e.at >= datetime("2026-02-14T10:00:00Z") +} +return { $e.id } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + match &q.match_clause[1] { + Clause::Filter(f) => match &f.right { + Expr::Literal(Literal::Date(v)) => assert_eq!(v, "2026-02-14"), + other => panic!("expected date literal, got {:?}", other), + }, + _ => panic!("expected Filter"), + } + match &q.match_clause[2] { + Clause::Filter(f) => match &f.right { + Expr::Literal(Literal::DateTime(v)) => assert_eq!(v, "2026-02-14T10:00:00Z"), + other => panic!("expected datetime literal, got {:?}", other), + }, + _ => panic!("expected Filter"), + } +} + +#[test] +fn test_parse_now_expression_and_mutation_value() { + let input = r#" +query clock() { +match { + $e: Event + $e.at <= now() +} +return { now() as ts } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + match &q.match_clause[1] { + Clause::Filter(f) => assert!(matches!(f.right, Expr::Now)), + _ => panic!("expected Filter"), + } + assert!(matches!(q.return_clause[0].expr, Expr::Now)); + + let mutation = parse_query( + r#" +query stamp() { +update Event set { updated_at: now() } where created_at <= now() +} +"#, + ) + .unwrap(); + match mutation.queries[0].mutations.first().unwrap() { + Mutation::Update(update) => { + assert!(matches!(update.assignments[0].value, MatchValue::Now)); + assert!(matches!(update.predicate.value, MatchValue::Now)); + } + _ => panic!("expected update mutation"), + } +} + +#[test] +fn test_parse_multi_mutation() { + let input = r#" +query add_and_link($name: String, $age: I32, $friend: String) { +insert Person { name: $name, age: $age } +insert Knows { from: $name, to: $friend } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.mutations.len(), 2); + assert!(matches!(&q.mutations[0], Mutation::Insert(ins) if ins.type_name == "Person")); + assert!(matches!(&q.mutations[1], Mutation::Insert(ins) if ins.type_name == "Knows")); +} + +#[test] +fn test_parse_multi_mutation_mixed_ops() { + let input = r#" +query create_and_clean($name: String, $age: I32, $old: String) { +insert Person { name: $name, age: $age } +delete Person where name = $old +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.mutations.len(), 2); + assert!(matches!(&q.mutations[0], Mutation::Insert(_))); + assert!(matches!(&q.mutations[1], Mutation::Delete(_))); +} + +#[test] +fn test_parse_single_mutation_backward_compat() { + let input = r#" +query add($name: String, $age: I32) { +insert Person { name: $name, age: $age } +} +"#; + let qf = parse_query(input).unwrap(); + assert_eq!(qf.queries[0].mutations.len(), 1); +} + +#[test] +fn test_parse_list_literal() { + let input = r#" +query listy() { +match { $p: Person { tags: ["rust", "db"] } } +return { $p.tags } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + match &q.match_clause[0] { + Clause::Binding(b) => match &b.prop_matches[0].value { + MatchValue::Literal(Literal::List(items)) => { + assert_eq!(items.len(), 2); + } + other => panic!("expected list literal, got {:?}", other), + }, + _ => panic!("expected Binding"), + } +} + +#[test] +fn test_parse_nearest_ordering_and_vector_param_type() { + let input = r#" +query similar($q: Vector(3)) { +match { $d: Doc } +return { $d.id } +order { nearest($d.embedding, $q) } +limit 5 +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.params[0].type_name, "Vector(3)"); + assert_eq!(q.order_clause.len(), 1); + assert!(!q.order_clause[0].descending); + match &q.order_clause[0].expr { + Expr::Nearest { + variable, + property, + query, + } => { + assert_eq!(variable, "d"); + assert_eq!(property, "embedding"); + assert!(matches!(query.as_ref(), Expr::Variable(v) if v == "q")); + } + other => panic!("expected nearest ordering, got {:?}", other), + } +} + +#[test] +fn test_parse_nearest_with_spaced_vector_param_type() { + let input = r#" +query similar($q: Vector( 3 ) ?) { +match { $d: Doc } +return { $d.id } +order { nearest($d.embedding, $q) } +limit 5 +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.params[0].type_name, "Vector(3)"); + assert!(q.params[0].nullable); +} + +#[test] +fn test_parse_list_and_datetime_param_types() { + let input = r#" +query tasks($tags: [String], $days: [Date]?, $due_at: DateTime) { +match { $t: Task } +return { $t.slug } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.params[0].type_name, "[String]"); + assert!(!q.params[0].nullable); + assert_eq!(q.params[1].type_name, "[Date]"); + assert!(q.params[1].nullable); + assert_eq!(q.params[2].type_name, "DateTime"); +} + +#[test] +fn test_parse_nearest_rejects_direction_modifier() { + let input = r#" +query similar($q: Vector(3)) { +match { $d: Doc } +return { $d.id } +order { nearest($d.embedding, $q) desc } +limit 5 +} +"#; + assert!(parse_query(input).is_err()); +} + +#[test] +fn test_parse_nearest_expression_in_return_projection() { + let input = r#" +query similar($q: Vector(3)) { +match { $d: Doc } +return { $d.id, nearest($d.embedding, $q) as score } +order { nearest($d.embedding, $q) } +limit 5 +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.return_clause.len(), 2); + match &q.return_clause[1].expr { + Expr::Nearest { + variable, + property, + query, + } => { + assert_eq!(variable, "d"); + assert_eq!(property, "embedding"); + assert!(matches!(query.as_ref(), Expr::Variable(v) if v == "q")); + } + other => panic!( + "expected nearest expression in return projection, got {:?}", + other + ), + } + assert_eq!(q.return_clause[1].alias.as_deref(), Some("score")); +} + +#[test] +fn test_parse_search_clause_sugar() { + let input = r#" +query q($q: String) { +match { + $s: Signal + search($s.summary, $q) +} +return { $s.slug } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.match_clause.len(), 2); + match &q.match_clause[1] { + Clause::Filter(Filter { left, op, right }) => { + assert_eq!(*op, CompOp::Eq); + assert!(matches!(right, Expr::Literal(Literal::Bool(true)))); + match left { + Expr::Search { field, query } => { + assert!(matches!( + field.as_ref(), + Expr::PropAccess { variable, property } if variable == "s" && property == "summary" + )); + assert!(matches!(query.as_ref(), Expr::Variable(v) if v == "q")); + } + other => panic!("expected search expression, got {:?}", other), + } + } + other => panic!("expected filter clause, got {:?}", other), + } +} + +#[test] +fn test_parse_fuzzy_clause_with_max_edits() { + let input = r#" +query q($q: String) { +match { + $s: Signal + fuzzy($s.summary, $q, 2) +} +return { $s.slug } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.match_clause.len(), 2); + match &q.match_clause[1] { + Clause::Filter(Filter { left, op, right }) => { + assert_eq!(*op, CompOp::Eq); + assert!(matches!(right, Expr::Literal(Literal::Bool(true)))); + match left { + Expr::Fuzzy { + field, + query, + max_edits, + } => { + assert!(matches!( + field.as_ref(), + Expr::PropAccess { variable, property } if variable == "s" && property == "summary" + )); + assert!(matches!(query.as_ref(), Expr::Variable(v) if v == "q")); + assert!(matches!( + max_edits.as_deref(), + Some(Expr::Literal(Literal::Integer(2))) + )); + } + other => panic!("expected fuzzy expression, got {:?}", other), + } + } + other => panic!("expected filter clause, got {:?}", other), + } +} + +#[test] +fn test_parse_match_text_clause_sugar() { + let input = r#" +query q($q: String) { +match { + $s: Signal + match_text($s.summary, $q) +} +return { $s.slug } +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.match_clause.len(), 2); + match &q.match_clause[1] { + Clause::Filter(Filter { left, op, right }) => { + assert_eq!(*op, CompOp::Eq); + assert!(matches!(right, Expr::Literal(Literal::Bool(true)))); + match left { + Expr::MatchText { field, query } => { + assert!(matches!( + field.as_ref(), + Expr::PropAccess { variable, property } if variable == "s" && property == "summary" + )); + assert!(matches!(query.as_ref(), Expr::Variable(v) if v == "q")); + } + other => panic!("expected match_text expression, got {:?}", other), + } + } + other => panic!("expected filter clause, got {:?}", other), + } +} + +#[test] +fn test_parse_bm25_expression_in_order() { + let input = r#" +query q($q: String) { +match { $s: Signal } +return { $s.slug, bm25($s.summary, $q) as score } +order { bm25($s.summary, $q) desc } +limit 5 +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.return_clause.len(), 2); + match &q.return_clause[1].expr { + Expr::Bm25 { field, query } => { + assert!(matches!( + field.as_ref(), + Expr::PropAccess { variable, property } if variable == "s" && property == "summary" + )); + assert!(matches!(query.as_ref(), Expr::Variable(v) if v == "q")); + } + other => panic!("expected bm25 expression, got {:?}", other), + } + assert_eq!(q.order_clause.len(), 1); + assert!(q.order_clause[0].descending); +} + +#[test] +fn test_parse_rrf_ordering_with_nearest_and_bm25() { + let input = r#" +query q($vq: Vector(3), $tq: String) { +match { $s: Signal } +return { $s.slug } +order { rrf(nearest($s.embedding, $vq), bm25($s.summary, $tq), 60) desc } +limit 5 +} +"#; + let qf = parse_query(input).unwrap(); + let q = &qf.queries[0]; + assert_eq!(q.order_clause.len(), 1); + assert!(q.order_clause[0].descending); + match &q.order_clause[0].expr { + Expr::Rrf { + primary, + secondary, + k, + } => { + assert!(matches!(primary.as_ref(), Expr::Nearest { .. })); + assert!(matches!(secondary.as_ref(), Expr::Bm25 { .. })); + assert!(matches!( + k.as_deref(), + Some(Expr::Literal(Literal::Integer(60))) + )); + } + other => panic!("expected rrf expression, got {:?}", other), + } +} + +#[test] +fn test_parse_error_diagnostic_has_span() { + let input = r#" +query q() { +match { + $p: Person +} +return { $p.name +} +"#; + let err = parse_query_diagnostic(input).unwrap_err(); + assert!(err.span.is_some()); +} diff --git a/crates/omnigraph-compiler/src/query/typecheck.rs b/crates/omnigraph-compiler/src/query/typecheck.rs index 5eb71bd..658f083 100644 --- a/crates/omnigraph-compiler/src/query/typecheck.rs +++ b/crates/omnigraph-compiler/src/query/typecheck.rs @@ -1705,1161 +1705,5 @@ fn expr_contains_rrf_inner( } #[cfg(test)] -mod tests { - use super::*; - use crate::catalog::build_catalog; - use crate::query::parser::parse_query; - use crate::schema::parser::parse_schema; - - fn setup() -> Catalog { - let schema = parse_schema( - r#" -node Person { - name: String - age: I32? -} -node Company { - name: String -} -edge Knows: Person -> Person { - since: Date? -} -edge WorksAt: Person -> Company { - title: String? -} -"#, - ) - .unwrap(); - build_catalog(&schema).unwrap() - } - - fn setup_vector() -> Catalog { - let schema = parse_schema( - r#" -node Doc { - id_str: String - embedding: Vector(3) -} -"#, - ) - .unwrap(); - build_catalog(&schema).unwrap() - } - - fn setup_list() -> Catalog { - let schema = parse_schema( - r#" -node Person { - name: String - tags: [String]? -} -"#, - ) - .unwrap(); - build_catalog(&schema).unwrap() - } - - fn setup_embed_vector() -> Catalog { - let schema = parse_schema( - r#" -node Doc { - slug: String - body: String? - embedding: Vector(3) @embed(body) -} -"#, - ) - .unwrap(); - build_catalog(&schema).unwrap() - } - - #[test] - fn test_basic_binding() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { $p: Person } - return { $p.name } -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert!(ctx.bindings.contains_key("p")); - } - - #[test] - fn test_t1_unknown_type() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { $p: Foo } - return { $p.name } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T1")); - } - - #[test] - fn test_t2_unknown_property_match() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { $p: Person { salary: 100 } } - return { $p.name } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T2")); - } - - #[test] - fn test_t3_wrong_type_in_match() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { $p: Person { age: "old" } } - return { $p.name } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T3")); - } - - #[test] - fn test_list_membership_match_accepts_scalar_literal() { - let catalog = setup_list(); - let qf = parse_query( - r#" -query q() { - match { $p: Person { tags: "rust" } } - return { $p.name } -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert!(ctx.bindings.contains_key("p")); - } - - #[test] - fn test_list_membership_match_accepts_scalar_param() { - let catalog = setup_list(); - let qf = parse_query( - r#" -query q($tag: String) { - match { $p: Person { tags: $tag } } - return { $p.name } -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert!(ctx.bindings.contains_key("p")); - } - - #[test] - fn test_list_equality_match_is_rejected() { - let catalog = setup_list(); - let qf = parse_query( - r#" -query q() { - match { $p: Person { tags: ["rust"] } } - return { $p.name } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("list equality is not supported")); - assert!(msg.contains("membership")); - } - - #[test] - fn test_contains_filter_accepts_list_membership() { - let catalog = setup_list(); - let qf = parse_query( - r#" -query q($tag: String) { - match { - $p: Person - $p.tags contains $tag - } - return { $p.name } -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert!(ctx.bindings.contains_key("p")); - } - - #[test] - fn test_declared_list_params_typecheck() { - let catalog = setup_list(); - let qf = parse_query( - r#" -query q($tags: [String], $days: [Date]?) { - match { - $p: Person - $p.tags contains "friend" - } - return { $p.tags, $tags, $days } -} -"#, - ) - .unwrap(); - assert!(typecheck_query(&catalog, &qf.queries[0]).is_ok()); - } - - #[test] - fn test_contains_filter_requires_list_left_operand() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - $p.name contains "Al" - } - return { $p.name } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!( - err.to_string() - .contains("contains requires a list property on the left") - ); - } - - #[test] - fn test_contains_filter_rejects_list_right_operand() { - let catalog = setup_list(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - $p.tags contains ["rust"] - } - return { $p.name } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!( - err.to_string() - .contains("contains requires a scalar right operand") - ); - } - - #[test] - fn test_t4_unknown_edge() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - $p likes $f - } - return { $p.name } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T4")); - } - - #[test] - fn test_t5_bad_endpoints() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $c: Company - $c knows $f - } - return { $c.name } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T5")); - } - - #[test] - fn test_t6_bad_property() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - $p.salary > 100 - } - return { $p.name } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T6")); - } - - #[test] - fn test_t7_bad_comparison() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - $p.age > "old" - } - return { $p.name } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T7")); - } - - #[test] - fn test_t7_rejects_non_scalar_comparison() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - $p != 5 - } - return { $p.name } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("scalar operands")); - } - - #[test] - fn test_nearest_requires_limit() { - let catalog = setup_vector(); - let qf = parse_query( - r#" -query q($q: Vector(3)) { - match { $d: Doc } - return { $d.id_str } - order { nearest($d.embedding, $q) } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T17")); - } - - #[test] - fn test_nearest_vector_dim_mismatch() { - let catalog = setup_vector(); - let qf = parse_query( - r#" -query q($q: Vector(2)) { - match { $d: Doc } - return { $d.id_str } - order { nearest($d.embedding, $q) } - limit 3 -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T15")); - } - - #[test] - fn test_nearest_vector_param_ok() { - let catalog = setup_vector(); - let qf = parse_query( - r#" -query q($q: Vector(3)) { - match { $d: Doc } - return { $d.id_str } - order { nearest($d.embedding, $q) } - limit 3 -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert!(ctx.bindings.contains_key("d")); - } - - #[test] - fn test_nearest_string_param_ok() { - let catalog = setup_vector(); - let qf = parse_query( - r#" -query q($q: String) { - match { $d: Doc } - return { $d.id_str } - order { nearest($d.embedding, $q) } - limit 3 -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert!(ctx.bindings.contains_key("d")); - } - - #[test] - fn test_search_string_param_ok() { - let catalog = setup(); - let qf = parse_query( - r#" -query q($q: String) { - match { - $p: Person - search($p.name, $q) - } - return { $p.name } -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert!(ctx.bindings.contains_key("p")); - } - - #[test] - fn test_fuzzy_max_edits_param_ok() { - let catalog = setup(); - let qf = parse_query( - r#" -query q($q: String, $m: I64) { - match { - $p: Person - fuzzy($p.name, $q, $m) - } - return { $p.name } -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert!(ctx.bindings.contains_key("p")); - } - - #[test] - fn test_fuzzy_rejects_non_integer_max_edits() { - let catalog = setup(); - let qf = parse_query( - r#" -query q($q: String, $m: F64) { - match { - $p: Person - fuzzy($p.name, $q, $m) - } - return { $p.name } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T19")); - } - - #[test] - fn test_match_text_string_param_ok() { - let catalog = setup(); - let qf = parse_query( - r#" -query q($q: String) { - match { - $p: Person - match_text($p.name, $q) - } - return { $p.name } -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert!(ctx.bindings.contains_key("p")); - } - - #[test] - fn test_bm25_string_param_ok() { - let catalog = setup(); - let qf = parse_query( - r#" -query q($q: String) { - match { $p: Person } - return { $p.name, bm25($p.name, $q) as score } - order { bm25($p.name, $q) desc } -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert!(ctx.bindings.contains_key("p")); - } - - #[test] - fn test_bm25_rejects_non_string_query() { - let catalog = setup(); - let qf = parse_query( - r#" -query q($q: I64) { - match { $p: Person } - return { bm25($p.name, $q) as score } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T20")); - } - - #[test] - fn test_rrf_requires_limit_in_order() { - let catalog = setup_vector(); - let qf = parse_query( - r#" -query q($vq: Vector(3), $tq: String) { - match { $d: Doc } - return { $d.id_str } - order { rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) desc } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T21")); - } - - #[test] - fn test_rrf_ordering_ok_with_limit() { - let catalog = setup_vector(); - let qf = parse_query( - r#" -query q($vq: Vector(3), $tq: String) { - match { $d: Doc } - return { $d.id_str } - order { rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) desc } - limit 5 -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert!(ctx.bindings.contains_key("d")); - } - - #[test] - fn test_rrf_ordering_ok_with_string_nearest_limit() { - let catalog = setup_vector(); - let qf = parse_query( - r#" -query q($vq: String, $tq: String) { - match { $d: Doc } - return { $d.id_str } - order { rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) desc } - limit 5 -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert!(ctx.bindings.contains_key("d")); - } - - #[test] - fn test_rrf_with_nearest_allows_alias_ordering() { - let catalog = setup_vector(); - let qf = parse_query( - r#" -query q($vq: Vector(3), $tq: String) { - match { $d: Doc } - return { - $d.id_str, - rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) as score - } - order { - rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) desc, - score desc - } - limit 5 -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert!(ctx.bindings.contains_key("d")); - } - - #[test] - fn test_rrf_alias_ordering_requires_limit() { - let catalog = setup_vector(); - let qf = parse_query( - r#" -query q($vq: Vector(3), $tq: String) { - match { $d: Doc } - return { - $d.id_str, - rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) as score - } - order { score desc } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T21")); - } - - #[test] - fn test_rrf_alias_ordering_with_limit_is_valid() { - let catalog = setup_vector(); - let qf = parse_query( - r#" -query q($vq: Vector(3), $tq: String) { - match { $d: Doc } - return { - $d.id_str, - rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) as score - } - order { score desc } - limit 5 -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert!(ctx.bindings.contains_key("d")); - } - - #[test] - fn test_standalone_nearest_with_alias_ordering_still_rejected() { - let catalog = setup_vector(); - let qf = parse_query( - r#" -query q($vq: Vector(3)) { - match { $d: Doc } - return { - $d.id_str as score - } - order { - nearest($d.embedding, $vq), - score desc - } - limit 5 -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T18")); - } - - #[test] - fn test_rrf_rejects_non_rank_expression_argument() { - let parse = parse_query( - r#" -query q($q: String) { - match { $d: Doc } - return { $d.id_str } - order { rrf(bm25($d.id_str, $q), search($d.id_str, $q), 60) desc } - limit 5 -} -"#, - ); - assert!(parse.is_err()); - } - - #[test] - fn test_rrf_rejects_non_positive_k_literal() { - let catalog = setup_vector(); - let qf = parse_query( - r#" -query q($vq: Vector(3), $tq: String) { - match { $d: Doc } - return { $d.id_str } - order { rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 0) desc } - limit 5 -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T21")); - } - - #[test] - fn test_t8_sum_on_string() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { $p: Person } - return { sum($p.name) as s } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T8")); - } - - #[test] - fn test_traversal_direction_out() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person { name: "Alice" } - $p knows $f - } - return { $f.name } -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert_eq!(ctx.traversals[0].direction, Direction::Out); - assert_eq!(ctx.bindings["f"].type_name, "Person"); - } - - #[test] - fn test_traversal_direction_in() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $c: Company { name: "Acme" } - $p worksAt $c - } - return { $p.name } -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - // $c is Company (to_type), $p is src — direction should be Out - // because $p (Person=from_type) worksAt $c (Company=to_type) is forward - assert_eq!(ctx.traversals[0].direction, Direction::Out); - } - - #[test] - fn test_bounded_traversal_typecheck() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - $p knows{1,3} $f - } - return { $f.name } -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert_eq!(ctx.traversals[0].min_hops, 1); - assert_eq!(ctx.traversals[0].max_hops, Some(3)); - } - - #[test] - fn test_bounded_traversal_invalid_bounds() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - $p knows{3,1} $f - } - return { $f.name } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T15")); - } - - #[test] - fn test_unbounded_traversal_is_disabled() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - $p knows{1,} $f - } - return { $f.name } -} -"#, - ) - .unwrap(); - let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("unbounded traversal is disabled")); - } - - #[test] - fn test_negation_typecheck() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - not { $p worksAt $_ } - } - return { $p.name } -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert!(ctx.bindings.contains_key("p")); - } - - #[test] - fn test_aggregation_typecheck() { - let catalog = setup(); - let qf = parse_query( - r#" -query q() { - match { - $p: Person - $p knows $f - } - return { - $p.name - count($f) as friends - } -} -"#, - ) - .unwrap(); - typecheck_query(&catalog, &qf.queries[0]).unwrap(); - } - - #[test] - fn test_valid_two_hop() { - let catalog = setup(); - let qf = parse_query( - r#" -query q($name: String) { - match { - $p: Person { name: $name } - $p knows $mid - $mid knows $fof - } - return { $fof.name } -} -"#, - ) - .unwrap(); - let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); - assert!(ctx.bindings.contains_key("mid")); - assert!(ctx.bindings.contains_key("fof")); - } - - #[test] - fn test_mutation_insert_typecheck_ok() { - let catalog = setup(); - let qf = parse_query( - r#" -query add_person($name: String, $age: I32) { - insert Person { - name: $name - age: $age - } -} -"#, - ) - .unwrap(); - let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); - match checked { - CheckedQuery::Mutation(ctx) => assert_eq!(ctx.target_types[0], "Person"), - _ => panic!("expected mutation typecheck result"), - } - } - - #[test] - fn test_mutation_insert_missing_required_property() { - let catalog = setup(); - let qf = parse_query( - r#" -query add_person($age: I32) { - insert Person { age: $age } -} -"#, - ) - .unwrap(); - let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T12")); - } - - #[test] - fn test_mutation_insert_allows_embed_target_omission_when_source_present() { - let catalog = setup_embed_vector(); - let qf = parse_query( - r#" -query add_doc($slug: String, $body: String) { - insert Doc { - slug: $slug - body: $body - } -} -"#, - ) - .unwrap(); - let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); - match checked { - CheckedQuery::Mutation(ctx) => assert_eq!(ctx.target_types[0], "Doc"), - _ => panic!("expected mutation typecheck result"), - } - } - - #[test] - fn test_mutation_insert_requires_embed_source_when_target_omitted() { - let catalog = setup_embed_vector(); - let qf = parse_query( - r#" -query add_doc($slug: String) { - insert Doc { - slug: $slug - } -} -"#, - ) - .unwrap(); - let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("T12")); - assert!(msg.contains("embedding")); - assert!(msg.contains("body")); - } - - #[test] - fn test_mutation_update_bad_property() { - let catalog = setup(); - let qf = parse_query( - r#" -query update_person($name: String) { - update Person set { salary: 100 } where name = $name -} -"#, - ) - .unwrap(); - let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T11")); - } - - #[test] - fn test_mutation_delete_bad_type() { - let catalog = setup(); - let qf = parse_query( - r#" -query del($name: String) { - delete Unknown where name = $name -} -"#, - ) - .unwrap(); - let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T10")); - } - - #[test] - fn test_mutation_insert_edge_typecheck_ok() { - let catalog = setup(); - let qf = parse_query( - r#" -query add_knows($from: String, $to: String) { - insert Knows { - from: $from - to: $to - } -} -"#, - ) - .unwrap(); - let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); - match checked { - CheckedQuery::Mutation(ctx) => assert_eq!(ctx.target_types[0], "Knows"), - _ => panic!("expected mutation typecheck result"), - } - } - - #[test] - fn test_mutation_insert_edge_requires_from_and_to() { - let catalog = setup(); - let qf = parse_query( - r#" -query add_knows($from: String) { - insert Knows { - from: $from - } -} -"#, - ) - .unwrap(); - let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T12")); - } - - #[test] - fn test_mutation_delete_edge_typecheck_ok() { - let catalog = setup(); - let qf = parse_query( - r#" -query del_knows($from: String) { - delete Knows where from = $from -} -"#, - ) - .unwrap(); - let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); - match checked { - CheckedQuery::Mutation(ctx) => assert_eq!(ctx.target_types[0], "Knows"), - _ => panic!("expected mutation typecheck result"), - } - } - - #[test] - fn test_mutation_update_edge_not_supported() { - let catalog = setup(); - let qf = parse_query( - r#" -query upd_knows($from: String) { - update Knows set { since: 2000 } where from = $from -} -"#, - ) - .unwrap(); - let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T16")); - } - - #[test] - fn test_mutation_multi_insert_typecheck_ok() { - let catalog = setup(); - let qf = parse_query( - r#" -query add_and_link($name: String, $age: I32, $friend: String) { - insert Person { name: $name, age: $age } - insert Knows { from: $name, to: $friend } -} -"#, - ) - .unwrap(); - let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); - match checked { - CheckedQuery::Mutation(ctx) => { - assert_eq!(ctx.target_types, vec!["Person", "Knows"]); - } - _ => panic!("expected mutation typecheck result"), - } - } - - #[test] - fn test_mutation_multi_second_stmt_error() { - let catalog = setup(); - let qf = parse_query( - r#" -query bad($name: String, $age: I32) { - insert Person { name: $name, age: $age } - insert Unknown { foo: $name } -} -"#, - ) - .unwrap(); - let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("T10")); - } - - #[test] - fn test_now_expression_typechecks_as_datetime() { - let schema = parse_schema( - r#" -node Event { - slug: String @key - at: DateTime -} -"#, - ) - .unwrap(); - let catalog = build_catalog(&schema).unwrap(); - let qf = parse_query( - r#" -query due() { - match { - $e: Event - $e.at <= now() - } - return { now() as ts } -} -"#, - ) - .unwrap(); - - let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); - assert!(matches!(checked, CheckedQuery::Read(_))); - } - - #[test] - fn test_now_is_rejected_for_non_datetime_mutation_property() { - let schema = parse_schema( - r#" -node Event { - slug: String @key - on: Date -} -"#, - ) - .unwrap(); - let catalog = build_catalog(&schema).unwrap(); - let qf = parse_query( - r#" -query stamp() { - update Event set { on: now() } where slug = "launch" -} -"#, - ) - .unwrap(); - - let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err(); - assert!(err.to_string().contains("DateTime")); - assert!(err.to_string().contains("property `on`")); - } -} +#[path = "typecheck_tests.rs"] +mod tests; diff --git a/crates/omnigraph-compiler/src/query/typecheck_tests.rs b/crates/omnigraph-compiler/src/query/typecheck_tests.rs new file mode 100644 index 0000000..7fa8f94 --- /dev/null +++ b/crates/omnigraph-compiler/src/query/typecheck_tests.rs @@ -0,0 +1,1156 @@ +use super::*; +use crate::catalog::build_catalog; +use crate::query::parser::parse_query; +use crate::schema::parser::parse_schema; + +fn setup() -> Catalog { + let schema = parse_schema( + r#" +node Person { +name: String +age: I32? +} +node Company { +name: String +} +edge Knows: Person -> Person { +since: Date? +} +edge WorksAt: Person -> Company { +title: String? +} +"#, + ) + .unwrap(); + build_catalog(&schema).unwrap() +} + +fn setup_vector() -> Catalog { + let schema = parse_schema( + r#" +node Doc { +id_str: String +embedding: Vector(3) +} +"#, + ) + .unwrap(); + build_catalog(&schema).unwrap() +} + +fn setup_list() -> Catalog { + let schema = parse_schema( + r#" +node Person { +name: String +tags: [String]? +} +"#, + ) + .unwrap(); + build_catalog(&schema).unwrap() +} + +fn setup_embed_vector() -> Catalog { + let schema = parse_schema( + r#" +node Doc { +slug: String +body: String? +embedding: Vector(3) @embed(body) +} +"#, + ) + .unwrap(); + build_catalog(&schema).unwrap() +} + +#[test] +fn test_basic_binding() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { $p: Person } +return { $p.name } +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert!(ctx.bindings.contains_key("p")); +} + +#[test] +fn test_t1_unknown_type() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { $p: Foo } +return { $p.name } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T1")); +} + +#[test] +fn test_t2_unknown_property_match() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { $p: Person { salary: 100 } } +return { $p.name } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T2")); +} + +#[test] +fn test_t3_wrong_type_in_match() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { $p: Person { age: "old" } } +return { $p.name } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T3")); +} + +#[test] +fn test_list_membership_match_accepts_scalar_literal() { + let catalog = setup_list(); + let qf = parse_query( + r#" +query q() { +match { $p: Person { tags: "rust" } } +return { $p.name } +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert!(ctx.bindings.contains_key("p")); +} + +#[test] +fn test_list_membership_match_accepts_scalar_param() { + let catalog = setup_list(); + let qf = parse_query( + r#" +query q($tag: String) { +match { $p: Person { tags: $tag } } +return { $p.name } +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert!(ctx.bindings.contains_key("p")); +} + +#[test] +fn test_list_equality_match_is_rejected() { + let catalog = setup_list(); + let qf = parse_query( + r#" +query q() { +match { $p: Person { tags: ["rust"] } } +return { $p.name } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("list equality is not supported")); + assert!(msg.contains("membership")); +} + +#[test] +fn test_contains_filter_accepts_list_membership() { + let catalog = setup_list(); + let qf = parse_query( + r#" +query q($tag: String) { +match { + $p: Person + $p.tags contains $tag +} +return { $p.name } +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert!(ctx.bindings.contains_key("p")); +} + +#[test] +fn test_declared_list_params_typecheck() { + let catalog = setup_list(); + let qf = parse_query( + r#" +query q($tags: [String], $days: [Date]?) { +match { + $p: Person + $p.tags contains "friend" +} +return { $p.tags, $tags, $days } +} +"#, + ) + .unwrap(); + assert!(typecheck_query(&catalog, &qf.queries[0]).is_ok()); +} + +#[test] +fn test_contains_filter_requires_list_left_operand() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + $p.name contains "Al" +} +return { $p.name } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!( + err.to_string() + .contains("contains requires a list property on the left") + ); +} + +#[test] +fn test_contains_filter_rejects_list_right_operand() { + let catalog = setup_list(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + $p.tags contains ["rust"] +} +return { $p.name } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!( + err.to_string() + .contains("contains requires a scalar right operand") + ); +} + +#[test] +fn test_t4_unknown_edge() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + $p likes $f +} +return { $p.name } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T4")); +} + +#[test] +fn test_t5_bad_endpoints() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $c: Company + $c knows $f +} +return { $c.name } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T5")); +} + +#[test] +fn test_t6_bad_property() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + $p.salary > 100 +} +return { $p.name } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T6")); +} + +#[test] +fn test_t7_bad_comparison() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + $p.age > "old" +} +return { $p.name } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T7")); +} + +#[test] +fn test_t7_rejects_non_scalar_comparison() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + $p != 5 +} +return { $p.name } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("scalar operands")); +} + +#[test] +fn test_nearest_requires_limit() { + let catalog = setup_vector(); + let qf = parse_query( + r#" +query q($q: Vector(3)) { +match { $d: Doc } +return { $d.id_str } +order { nearest($d.embedding, $q) } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T17")); +} + +#[test] +fn test_nearest_vector_dim_mismatch() { + let catalog = setup_vector(); + let qf = parse_query( + r#" +query q($q: Vector(2)) { +match { $d: Doc } +return { $d.id_str } +order { nearest($d.embedding, $q) } +limit 3 +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T15")); +} + +#[test] +fn test_nearest_vector_param_ok() { + let catalog = setup_vector(); + let qf = parse_query( + r#" +query q($q: Vector(3)) { +match { $d: Doc } +return { $d.id_str } +order { nearest($d.embedding, $q) } +limit 3 +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert!(ctx.bindings.contains_key("d")); +} + +#[test] +fn test_nearest_string_param_ok() { + let catalog = setup_vector(); + let qf = parse_query( + r#" +query q($q: String) { +match { $d: Doc } +return { $d.id_str } +order { nearest($d.embedding, $q) } +limit 3 +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert!(ctx.bindings.contains_key("d")); +} + +#[test] +fn test_search_string_param_ok() { + let catalog = setup(); + let qf = parse_query( + r#" +query q($q: String) { +match { + $p: Person + search($p.name, $q) +} +return { $p.name } +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert!(ctx.bindings.contains_key("p")); +} + +#[test] +fn test_fuzzy_max_edits_param_ok() { + let catalog = setup(); + let qf = parse_query( + r#" +query q($q: String, $m: I64) { +match { + $p: Person + fuzzy($p.name, $q, $m) +} +return { $p.name } +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert!(ctx.bindings.contains_key("p")); +} + +#[test] +fn test_fuzzy_rejects_non_integer_max_edits() { + let catalog = setup(); + let qf = parse_query( + r#" +query q($q: String, $m: F64) { +match { + $p: Person + fuzzy($p.name, $q, $m) +} +return { $p.name } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T19")); +} + +#[test] +fn test_match_text_string_param_ok() { + let catalog = setup(); + let qf = parse_query( + r#" +query q($q: String) { +match { + $p: Person + match_text($p.name, $q) +} +return { $p.name } +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert!(ctx.bindings.contains_key("p")); +} + +#[test] +fn test_bm25_string_param_ok() { + let catalog = setup(); + let qf = parse_query( + r#" +query q($q: String) { +match { $p: Person } +return { $p.name, bm25($p.name, $q) as score } +order { bm25($p.name, $q) desc } +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert!(ctx.bindings.contains_key("p")); +} + +#[test] +fn test_bm25_rejects_non_string_query() { + let catalog = setup(); + let qf = parse_query( + r#" +query q($q: I64) { +match { $p: Person } +return { bm25($p.name, $q) as score } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T20")); +} + +#[test] +fn test_rrf_requires_limit_in_order() { + let catalog = setup_vector(); + let qf = parse_query( + r#" +query q($vq: Vector(3), $tq: String) { +match { $d: Doc } +return { $d.id_str } +order { rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) desc } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T21")); +} + +#[test] +fn test_rrf_ordering_ok_with_limit() { + let catalog = setup_vector(); + let qf = parse_query( + r#" +query q($vq: Vector(3), $tq: String) { +match { $d: Doc } +return { $d.id_str } +order { rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) desc } +limit 5 +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert!(ctx.bindings.contains_key("d")); +} + +#[test] +fn test_rrf_ordering_ok_with_string_nearest_limit() { + let catalog = setup_vector(); + let qf = parse_query( + r#" +query q($vq: String, $tq: String) { +match { $d: Doc } +return { $d.id_str } +order { rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) desc } +limit 5 +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert!(ctx.bindings.contains_key("d")); +} + +#[test] +fn test_rrf_with_nearest_allows_alias_ordering() { + let catalog = setup_vector(); + let qf = parse_query( + r#" +query q($vq: Vector(3), $tq: String) { +match { $d: Doc } +return { + $d.id_str, + rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) as score +} +order { + rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) desc, + score desc +} +limit 5 +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert!(ctx.bindings.contains_key("d")); +} + +#[test] +fn test_rrf_alias_ordering_requires_limit() { + let catalog = setup_vector(); + let qf = parse_query( + r#" +query q($vq: Vector(3), $tq: String) { +match { $d: Doc } +return { + $d.id_str, + rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) as score +} +order { score desc } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T21")); +} + +#[test] +fn test_rrf_alias_ordering_with_limit_is_valid() { + let catalog = setup_vector(); + let qf = parse_query( + r#" +query q($vq: Vector(3), $tq: String) { +match { $d: Doc } +return { + $d.id_str, + rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) as score +} +order { score desc } +limit 5 +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert!(ctx.bindings.contains_key("d")); +} + +#[test] +fn test_standalone_nearest_with_alias_ordering_still_rejected() { + let catalog = setup_vector(); + let qf = parse_query( + r#" +query q($vq: Vector(3)) { +match { $d: Doc } +return { + $d.id_str as score +} +order { + nearest($d.embedding, $vq), + score desc +} +limit 5 +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T18")); +} + +#[test] +fn test_rrf_rejects_non_rank_expression_argument() { + let parse = parse_query( + r#" +query q($q: String) { +match { $d: Doc } +return { $d.id_str } +order { rrf(bm25($d.id_str, $q), search($d.id_str, $q), 60) desc } +limit 5 +} +"#, + ); + assert!(parse.is_err()); +} + +#[test] +fn test_rrf_rejects_non_positive_k_literal() { + let catalog = setup_vector(); + let qf = parse_query( + r#" +query q($vq: Vector(3), $tq: String) { +match { $d: Doc } +return { $d.id_str } +order { rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 0) desc } +limit 5 +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T21")); +} + +#[test] +fn test_t8_sum_on_string() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { $p: Person } +return { sum($p.name) as s } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T8")); +} + +#[test] +fn test_traversal_direction_out() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person { name: "Alice" } + $p knows $f +} +return { $f.name } +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert_eq!(ctx.traversals[0].direction, Direction::Out); + assert_eq!(ctx.bindings["f"].type_name, "Person"); +} + +#[test] +fn test_traversal_direction_in() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $c: Company { name: "Acme" } + $p worksAt $c +} +return { $p.name } +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + // $c is Company (to_type), $p is src — direction should be Out + // because $p (Person=from_type) worksAt $c (Company=to_type) is forward + assert_eq!(ctx.traversals[0].direction, Direction::Out); +} + +#[test] +fn test_bounded_traversal_typecheck() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + $p knows{1,3} $f +} +return { $f.name } +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert_eq!(ctx.traversals[0].min_hops, 1); + assert_eq!(ctx.traversals[0].max_hops, Some(3)); +} + +#[test] +fn test_bounded_traversal_invalid_bounds() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + $p knows{3,1} $f +} +return { $f.name } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T15")); +} + +#[test] +fn test_unbounded_traversal_is_disabled() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + $p knows{1,} $f +} +return { $f.name } +} +"#, + ) + .unwrap(); + let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("unbounded traversal is disabled")); +} + +#[test] +fn test_negation_typecheck() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + not { $p worksAt $_ } +} +return { $p.name } +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert!(ctx.bindings.contains_key("p")); +} + +#[test] +fn test_aggregation_typecheck() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + $p knows $f +} +return { + $p.name + count($f) as friends +} +} +"#, + ) + .unwrap(); + typecheck_query(&catalog, &qf.queries[0]).unwrap(); +} + +#[test] +fn test_valid_two_hop() { + let catalog = setup(); + let qf = parse_query( + r#" +query q($name: String) { +match { + $p: Person { name: $name } + $p knows $mid + $mid knows $fof +} +return { $fof.name } +} +"#, + ) + .unwrap(); + let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + assert!(ctx.bindings.contains_key("mid")); + assert!(ctx.bindings.contains_key("fof")); +} + +#[test] +fn test_mutation_insert_typecheck_ok() { + let catalog = setup(); + let qf = parse_query( + r#" +query add_person($name: String, $age: I32) { +insert Person { + name: $name + age: $age +} +} +"#, + ) + .unwrap(); + let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); + match checked { + CheckedQuery::Mutation(ctx) => assert_eq!(ctx.target_types[0], "Person"), + _ => panic!("expected mutation typecheck result"), + } +} + +#[test] +fn test_mutation_insert_missing_required_property() { + let catalog = setup(); + let qf = parse_query( + r#" +query add_person($age: I32) { +insert Person { age: $age } +} +"#, + ) + .unwrap(); + let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T12")); +} + +#[test] +fn test_mutation_insert_allows_embed_target_omission_when_source_present() { + let catalog = setup_embed_vector(); + let qf = parse_query( + r#" +query add_doc($slug: String, $body: String) { +insert Doc { + slug: $slug + body: $body +} +} +"#, + ) + .unwrap(); + let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); + match checked { + CheckedQuery::Mutation(ctx) => assert_eq!(ctx.target_types[0], "Doc"), + _ => panic!("expected mutation typecheck result"), + } +} + +#[test] +fn test_mutation_insert_requires_embed_source_when_target_omitted() { + let catalog = setup_embed_vector(); + let qf = parse_query( + r#" +query add_doc($slug: String) { +insert Doc { + slug: $slug +} +} +"#, + ) + .unwrap(); + let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("T12")); + assert!(msg.contains("embedding")); + assert!(msg.contains("body")); +} + +#[test] +fn test_mutation_update_bad_property() { + let catalog = setup(); + let qf = parse_query( + r#" +query update_person($name: String) { +update Person set { salary: 100 } where name = $name +} +"#, + ) + .unwrap(); + let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T11")); +} + +#[test] +fn test_mutation_delete_bad_type() { + let catalog = setup(); + let qf = parse_query( + r#" +query del($name: String) { +delete Unknown where name = $name +} +"#, + ) + .unwrap(); + let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T10")); +} + +#[test] +fn test_mutation_insert_edge_typecheck_ok() { + let catalog = setup(); + let qf = parse_query( + r#" +query add_knows($from: String, $to: String) { +insert Knows { + from: $from + to: $to +} +} +"#, + ) + .unwrap(); + let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); + match checked { + CheckedQuery::Mutation(ctx) => assert_eq!(ctx.target_types[0], "Knows"), + _ => panic!("expected mutation typecheck result"), + } +} + +#[test] +fn test_mutation_insert_edge_requires_from_and_to() { + let catalog = setup(); + let qf = parse_query( + r#" +query add_knows($from: String) { +insert Knows { + from: $from +} +} +"#, + ) + .unwrap(); + let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T12")); +} + +#[test] +fn test_mutation_delete_edge_typecheck_ok() { + let catalog = setup(); + let qf = parse_query( + r#" +query del_knows($from: String) { +delete Knows where from = $from +} +"#, + ) + .unwrap(); + let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); + match checked { + CheckedQuery::Mutation(ctx) => assert_eq!(ctx.target_types[0], "Knows"), + _ => panic!("expected mutation typecheck result"), + } +} + +#[test] +fn test_mutation_update_edge_not_supported() { + let catalog = setup(); + let qf = parse_query( + r#" +query upd_knows($from: String) { +update Knows set { since: 2000 } where from = $from +} +"#, + ) + .unwrap(); + let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T16")); +} + +#[test] +fn test_mutation_multi_insert_typecheck_ok() { + let catalog = setup(); + let qf = parse_query( + r#" +query add_and_link($name: String, $age: I32, $friend: String) { +insert Person { name: $name, age: $age } +insert Knows { from: $name, to: $friend } +} +"#, + ) + .unwrap(); + let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); + match checked { + CheckedQuery::Mutation(ctx) => { + assert_eq!(ctx.target_types, vec!["Person", "Knows"]); + } + _ => panic!("expected mutation typecheck result"), + } +} + +#[test] +fn test_mutation_multi_second_stmt_error() { + let catalog = setup(); + let qf = parse_query( + r#" +query bad($name: String, $age: I32) { +insert Person { name: $name, age: $age } +insert Unknown { foo: $name } +} +"#, + ) + .unwrap(); + let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("T10")); +} + +#[test] +fn test_now_expression_typechecks_as_datetime() { + let schema = parse_schema( + r#" +node Event { +slug: String @key +at: DateTime +} +"#, + ) + .unwrap(); + let catalog = build_catalog(&schema).unwrap(); + let qf = parse_query( + r#" +query due() { +match { + $e: Event + $e.at <= now() +} +return { now() as ts } +} +"#, + ) + .unwrap(); + + let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap(); + assert!(matches!(checked, CheckedQuery::Read(_))); +} + +#[test] +fn test_now_is_rejected_for_non_datetime_mutation_property() { + let schema = parse_schema( + r#" +node Event { +slug: String @key +on: Date +} +"#, + ) + .unwrap(); + let catalog = build_catalog(&schema).unwrap(); + let qf = parse_query( + r#" +query stamp() { +update Event set { on: now() } where slug = "launch" +} +"#, + ) + .unwrap(); + + let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err(); + assert!(err.to_string().contains("DateTime")); + assert!(err.to_string().contains("property `on`")); +} diff --git a/crates/omnigraph-compiler/src/schema/parser.rs b/crates/omnigraph-compiler/src/schema/parser.rs index 975d5a0..43e11ed 100644 --- a/crates/omnigraph-compiler/src/schema/parser.rs +++ b/crates/omnigraph-compiler/src/schema/parser.rs @@ -991,960 +991,5 @@ fn validate_type_constraints( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_basic_schema() { - let input = r#" -node Person { - name: String - age: I32? -} - -node Company { - name: String -} - -edge Knows: Person -> Person { - since: Date? -} - -edge WorksAt: Person -> Company { - title: String? -} -"#; - let schema = parse_schema(input).unwrap(); - assert_eq!(schema.declarations.len(), 4); - - match &schema.declarations[0] { - SchemaDecl::Node(n) => { - assert_eq!(n.name, "Person"); - assert!(n.annotations.is_empty()); - assert!(n.implements.is_empty()); - assert_eq!(n.properties.len(), 2); - assert_eq!(n.properties[0].name, "name"); - assert!(!n.properties[0].prop_type.nullable); - assert_eq!(n.properties[1].name, "age"); - assert!(n.properties[1].prop_type.nullable); - } - _ => panic!("expected Node"), - } - - match &schema.declarations[2] { - SchemaDecl::Edge(e) => { - assert_eq!(e.name, "Knows"); - assert_eq!(e.from_type, "Person"); - assert_eq!(e.to_type, "Person"); - assert!(e.annotations.is_empty()); - assert_eq!(e.properties.len(), 1); - assert!(e.cardinality.is_default()); - } - _ => panic!("expected Edge"), - } - } - - #[test] - fn test_parse_interface_basic() { - let input = r#" -interface Named { - name: String -} -node Person implements Named { - age: I32? -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Interface(i) => { - assert_eq!(i.name, "Named"); - assert_eq!(i.properties.len(), 1); - assert_eq!(i.properties[0].name, "name"); - } - _ => panic!("expected Interface"), - } - match &schema.declarations[1] { - SchemaDecl::Node(n) => { - assert_eq!(n.name, "Person"); - assert_eq!(n.implements, vec!["Named"]); - // "name" injected from interface + "age" declared locally - assert_eq!(n.properties.len(), 2); - } - _ => panic!("expected Node"), - } - } - - #[test] - fn test_parse_implements_multiple() { - let input = r#" -interface Slugged { - slug: String @key -} -interface Described { - title: String - description: String? -} -node Signal implements Slugged, Described { - strength: F64 -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[2] { - SchemaDecl::Node(n) => { - assert_eq!(n.name, "Signal"); - assert_eq!(n.implements, vec!["Slugged", "Described"]); - // slug + title + description + strength - assert_eq!(n.properties.len(), 4); - // @key from Slugged should be desugared into constraints - assert!( - n.constraints - .iter() - .any(|c| matches!(c, Constraint::Key(v) if v == &["slug"])) - ); - } - _ => panic!("expected Node"), - } - } - - #[test] - fn test_reject_implements_unknown_interface() { - let input = r#" -node Person implements Unknown { - name: String -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!(err.to_string().contains("unknown interface")); - } - - #[test] - fn test_reject_interface_property_type_conflict() { - let input = r#" -interface Named { - name: I32 -} -node Person implements Named { - name: String -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!(err.to_string().contains("type") || err.to_string().contains("interface")); - } - - #[test] - fn test_parse_annotation() { - let input = r#" -node Person { - name: String @unique - id: U64 @key - handle: String @index -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Node(n) => { - assert_eq!(n.properties[0].annotations.len(), 1); - assert_eq!(n.properties[0].annotations[0].name, "unique"); - assert_eq!(n.properties[1].annotations[0].name, "key"); - assert_eq!(n.properties[2].annotations[0].name, "index"); - // Annotations are desugared into constraints - assert!( - n.constraints - .iter() - .any(|c| matches!(c, Constraint::Unique(_))) - ); - assert!( - n.constraints - .iter() - .any(|c| matches!(c, Constraint::Key(_))) - ); - assert!( - n.constraints - .iter() - .any(|c| matches!(c, Constraint::Index(_))) - ); - } - _ => panic!("expected Node"), - } - } - - #[test] - fn test_property_level_key_desugars_to_constraint() { - let input = r#" -node Person { - name: String @key -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Node(n) => { - assert!( - n.constraints - .iter() - .any(|c| matches!(c, Constraint::Key(v) if v == &["name"])) - ); - } - _ => panic!("expected Node"), - } - } - - #[test] - fn test_parse_body_constraint_key() { - let input = r#" -node Person { - name: String - @key(name) -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Node(n) => { - assert!( - n.constraints - .iter() - .any(|c| matches!(c, Constraint::Key(v) if v == &["name"])) - ); - } - _ => panic!("expected Node"), - } - } - - #[test] - fn test_parse_body_constraint_unique_composite() { - let input = r#" -node Person { - first: String - last: String - @unique(first, last) -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Node(n) => { - assert!( - n.constraints - .iter() - .any(|c| matches!(c, Constraint::Unique(v) if v == &["first", "last"])) - ); - } - _ => panic!("expected Node"), - } - } - - #[test] - fn test_parse_body_constraint_index_composite() { - let input = r#" -node Event { - category: String - date: Date - @index(category, date) -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Node(n) => { - assert!( - n.constraints - .iter() - .any(|c| matches!(c, Constraint::Index(v) if v == &["category", "date"])) - ); - } - _ => panic!("expected Node"), - } - } - - #[test] - fn test_parse_body_constraint_range() { - let input = r#" -node Person { - age: I32? - @range(age, 0..200) -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Node(n) => { - assert!( - n.constraints.iter().any( - |c| matches!(c, Constraint::Range { property, .. } if property == "age") - ) - ); - } - _ => panic!("expected Node"), - } - } - - #[test] - fn test_parse_range_float_bounds() { - let input = r#" -node Measurement { - name: String @key - temperature: F64? - @range(temperature, 0.0..100.0) -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Node(n) => { - assert!(n.constraints.iter().any(|c| matches!( - c, - Constraint::Range { property, min, max } - if property == "temperature" - && matches!(min, Some(ConstraintBound::Float(f)) if *f == 0.0) - && matches!(max, Some(ConstraintBound::Float(f)) if *f == 100.0) - ))); - } - _ => panic!("expected Node"), - } - } - - #[test] - fn test_parse_range_negative_float_bounds() { - let input = r#" -node Measurement { - name: String @key - temperature: F64? - @range(temperature, -40.0..60.0) -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Node(n) => { - assert!(n.constraints.iter().any(|c| matches!( - c, - Constraint::Range { property, min, max } - if property == "temperature" - && matches!(min, Some(ConstraintBound::Float(f)) if *f == -40.0) - && matches!(max, Some(ConstraintBound::Float(f)) if *f == 60.0) - ))); - } - _ => panic!("expected Node"), - } - } - - #[test] - fn test_parse_range_negative_integer_bounds() { - let input = r#" -node Account { - name: String @key - balance: I64? - @range(balance, -1000..1000) -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Node(n) => { - assert!(n.constraints.iter().any(|c| matches!( - c, - Constraint::Range { property, min, max } - if property == "balance" - && matches!(min, Some(ConstraintBound::Integer(n)) if *n == -1000) - && matches!(max, Some(ConstraintBound::Integer(n)) if *n == 1000) - ))); - } - _ => panic!("expected Node"), - } - } - - #[test] - fn test_parse_body_constraint_check() { - let input = r#" -node Order { - code: String - @check(code, "[A-Z]{3}-[0-9]+") -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Node(n) => { - assert!(n.constraints.iter().any(|c| matches!(c, Constraint::Check { property, pattern } if property == "code" && pattern == "[A-Z]{3}-[0-9]+"))); - } - _ => panic!("expected Node"), - } - } - - #[test] - fn test_reject_range_on_string() { - let input = r#" -node Person { - name: String - @range(name, 0..100) -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!(err.to_string().contains("numeric")); - } - - #[test] - fn test_reject_check_on_integer() { - let input = r#" -node Person { - age: I32 - @check(age, "[0-9]+") -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!(err.to_string().contains("String")); - } - - #[test] - fn test_parse_edge_cardinality() { - let input = r#" -node Person { name: String } -node Company { name: String } -edge WorksAt: Person -> Company @card(0..1) -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[2] { - SchemaDecl::Edge(e) => { - assert_eq!(e.cardinality.min, 0); - assert_eq!(e.cardinality.max, Some(1)); - } - _ => panic!("expected Edge"), - } - } - - #[test] - fn test_parse_edge_cardinality_unbounded() { - let input = r#" -node Person { name: String } -node Paper { title: String } -edge Authored: Person -> Paper @card(1..) -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[2] { - SchemaDecl::Edge(e) => { - assert_eq!(e.cardinality.min, 1); - assert_eq!(e.cardinality.max, None); - } - _ => panic!("expected Edge"), - } - } - - #[test] - fn test_parse_edge_default_cardinality() { - let input = r#" -node Person { name: String } -edge Knows: Person -> Person -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[1] { - SchemaDecl::Edge(e) => { - assert!(e.cardinality.is_default()); - } - _ => panic!("expected Edge"), - } - } - - #[test] - fn test_parse_edge_unique_src_dst() { - let input = r#" -node Person { name: String } -edge Knows: Person -> Person { - @unique(src, dst) -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[1] { - SchemaDecl::Edge(e) => { - assert!( - e.constraints - .iter() - .any(|c| matches!(c, Constraint::Unique(v) if v == &["src", "dst"])) - ); - } - _ => panic!("expected Edge"), - } - } - - #[test] - fn test_parse_edge_property_index() { - let input = r#" -node Person { name: String } -node Company { name: String } -edge WorksAt: Person -> Company { - since: Date? @index -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[2] { - SchemaDecl::Edge(e) => { - // @index on since is desugared to Constraint::Index - assert!( - e.constraints - .iter() - .any(|c| matches!(c, Constraint::Index(v) if v == &["since"])) - ); - } - _ => panic!("expected Edge"), - } - } - - #[test] - fn test_parse_embed_annotation_identifier_arg() { - let input = r#" -node Doc { - title: String - embedding: Vector(3) @embed(title) -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Node(n) => { - assert_eq!(n.properties[1].annotations.len(), 1); - assert_eq!(n.properties[1].annotations[0].name, "embed"); - assert_eq!( - n.properties[1].annotations[0].value.as_deref(), - Some("title") - ); - } - _ => panic!("expected Node"), - } - } - - #[test] - fn test_parse_edge_no_body() { - let input = "edge WorksAt: Person -> Company\n"; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Edge(e) => { - assert_eq!(e.name, "WorksAt"); - assert!(e.annotations.is_empty()); - assert!(e.properties.is_empty()); - } - _ => panic!("expected Edge"), - } - } - - #[test] - fn test_parse_type_rename_annotation() { - let input = r#" -node Account @rename_from("User") { - full_name: String @rename_from("name") -} - -edge ConnectedTo: Account -> Account @rename_from("Knows") -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Node(n) => { - assert_eq!(n.name, "Account"); - assert_eq!(n.annotations.len(), 1); - assert_eq!(n.annotations[0].name, "rename_from"); - assert_eq!(n.annotations[0].value.as_deref(), Some("User")); - assert_eq!(n.properties[0].annotations[0].name, "rename_from"); - assert_eq!( - n.properties[0].annotations[0].value.as_deref(), - Some("name") - ); - } - _ => panic!("expected Node"), - } - match &schema.declarations[1] { - SchemaDecl::Edge(e) => { - assert_eq!(e.name, "ConnectedTo"); - assert_eq!(e.annotations.len(), 1); - assert_eq!(e.annotations[0].name, "rename_from"); - assert_eq!(e.annotations[0].value.as_deref(), Some("Knows")); - } - _ => panic!("expected Edge"), - } - } - - #[test] - fn test_reject_multiple_node_keys() { - let input = r#" -node Person { - id: U64 @key - ext_id: String @key -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!(err.to_string().contains("multiple @key")); - } - - #[test] - fn test_reject_unique_with_value() { - // @unique("x") is now a parse error — the grammar parses it as a body_constraint - // which expects ident args, not string literals as the sole argument - let input = r#" -node Person { - email: String @unique("x") -} -"#; - assert!(parse_schema(input).is_err()); - } - - #[test] - fn test_reject_index_with_value() { - // @index("x") is now a parse error — same reason as above - let input = r#" -node Person { - email: String @index("x") -} -"#; - assert!(parse_schema(input).is_err()); - } - - #[test] - fn test_reject_unique_on_node_annotation() { - let input = r#" -node Person @unique { - email: String -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!( - err.to_string() - .contains("only supported on node properties") - ); - } - - #[test] - fn test_reject_index_on_node_annotation() { - let input = r#" -node Person @index { - email: String -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!( - err.to_string() - .contains("only supported on node properties") - ); - } - - #[test] - fn test_allow_unique_on_edge_property() { - let input = r#" -node Person { name: String } -edge Knows: Person -> Person { - weight: I32 @unique -} -"#; - // Should now succeed (edge property @unique is allowed) - let schema = parse_schema(input).unwrap(); - match &schema.declarations[1] { - SchemaDecl::Edge(e) => { - assert!( - e.constraints - .iter() - .any(|c| matches!(c, Constraint::Unique(v) if v == &["weight"])) - ); - } - _ => panic!("expected Edge"), - } - } - - #[test] - fn test_allow_index_on_edge_property() { - let input = r#" -node Person { name: String } -edge Knows: Person -> Person { - weight: I32 @index -} -"#; - // Should now succeed (edge property @index is allowed) - let schema = parse_schema(input).unwrap(); - match &schema.declarations[1] { - SchemaDecl::Edge(e) => { - assert!( - e.constraints - .iter() - .any(|c| matches!(c, Constraint::Index(v) if v == &["weight"])) - ); - } - _ => panic!("expected Edge"), - } - } - - #[test] - fn test_reject_embed_without_source_property() { - let input = r#" -node Doc { - title: String - embedding: Vector(3) @embed -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!(err.to_string().contains("requires a source property name")); - } - - #[test] - fn test_reject_embed_on_non_vector_property() { - let input = r#" -node Doc { - title: String @embed(title) -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!( - err.to_string() - .contains("only supported on vector properties") - ); - } - - #[test] - fn test_reject_embed_unknown_source_property() { - let input = r#" -node Doc { - title: String - embedding: Vector(3) @embed(body) -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!( - err.to_string() - .contains("references unknown source property") - ); - } - - #[test] - fn test_reject_embed_source_not_string() { - let input = r#" -node Doc { - body: I32 - embedding: Vector(3) @embed(body) -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!(err.to_string().contains("must be String")); - } - - #[test] - fn test_reject_embed_on_edge_property() { - let input = r#" -node Doc { title: String } -edge Linked: Doc -> Doc { - embedding: Vector(3) @embed(title) -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!(err.to_string().contains("edge properties")); - } - - #[test] - fn test_parse_enum_and_list_types() { - let input = r#" -node Ticket { - status: enum(open, closed, blocked) - tags: [String] -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Node(n) => { - let status = &n.properties[0].prop_type; - assert!(status.is_enum()); - assert!(!status.list); - assert_eq!( - status.enum_values.as_ref().unwrap(), - &vec![ - "blocked".to_string(), - "closed".to_string(), - "open".to_string() - ] - ); - - let tags = &n.properties[1].prop_type; - assert!(tags.list); - assert!(!tags.is_enum()); - assert_eq!(tags.scalar, ScalarType::String); - } - _ => panic!("expected Node"), - } - } - - #[test] - fn test_reject_duplicate_enum_values() { - let input = r#" -node Ticket { - status: enum(open, closed, open) -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!(err.to_string().contains("duplicate values")); - } - - #[test] - fn test_parse_description_and_instruction_annotations() { - let input = r#" -node Task @description("Tracked work item") @instruction("Prefer querying by slug") { - slug: String @key @description("Stable external identifier") -} -edge DependsOn: Task -> Task @description("Hard dependency") @instruction("Use only for blockers") -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Node(node) => { - assert_eq!( - node.annotations - .iter() - .find(|ann| ann.name == "description") - .and_then(|ann| ann.value.as_deref()), - Some("Tracked work item") - ); - assert_eq!( - node.annotations - .iter() - .find(|ann| ann.name == "instruction") - .and_then(|ann| ann.value.as_deref()), - Some("Prefer querying by slug") - ); - assert_eq!( - node.properties[0] - .annotations - .iter() - .find(|ann| ann.name == "description") - .and_then(|ann| ann.value.as_deref()), - Some("Stable external identifier") - ); - } - _ => panic!("expected node"), - } - match &schema.declarations[1] { - SchemaDecl::Edge(edge) => { - assert_eq!( - edge.annotations - .iter() - .find(|ann| ann.name == "description") - .and_then(|ann| ann.value.as_deref()), - Some("Hard dependency") - ); - assert_eq!( - edge.annotations - .iter() - .find(|ann| ann.name == "instruction") - .and_then(|ann| ann.value.as_deref()), - Some("Use only for blockers") - ); - } - _ => panic!("expected edge"), - } - } - - #[test] - fn test_parse_annotation_decodes_escapes() { - let input = r#" -node Task @description("Tracked\n\"work\"\\item") { - slug: String @key @description("Stable\tidentifier") -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Node(node) => { - assert_eq!( - node.annotations[0].value.as_deref(), - Some("Tracked\n\"work\"\\item") - ); - assert_eq!( - node.properties[0].annotations[1].value.as_deref(), - Some("Stable\tidentifier") - ); - } - _ => panic!("expected node"), - } - } - - #[test] - fn test_parse_annotation_rejects_unknown_escape() { - let input = r#" -node Task @description("Tracked\q") { - slug: String @key -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!(err.to_string().contains("unsupported escape sequence")); - } - - #[test] - fn test_reject_duplicate_description_annotations() { - let input = r#" -node Task @description("a") @description("b") { - slug: String @key -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!( - err.to_string() - .contains("declares @description multiple times") - ); - } - - #[test] - fn test_reject_instruction_on_property() { - let input = r#" -node Task { - slug: String @instruction("bad") -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!( - err.to_string() - .contains("@instruction is only supported on node and edge types") - ); - } - - #[test] - fn test_reject_key_on_list_property() { - let input = r#" -node Ticket { - tags: [String] @key -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!(err.to_string().contains("list property")); - } - - #[test] - fn test_parse_vector_type() { - let input = r#" -node Doc { - embedding: Vector(3) -} -"#; - let schema = parse_schema(input).unwrap(); - match &schema.declarations[0] { - SchemaDecl::Node(n) => match n.properties[0].prop_type.scalar { - ScalarType::Vector(dim) => assert_eq!(dim, 3), - other => panic!("expected vector type, got {:?}", other), - }, - _ => panic!("expected node"), - } - } - - #[test] - fn test_reject_zero_vector_dimension() { - let input = r#" -node Doc { - embedding: Vector(0) -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!(err.to_string().contains("Vector dimension")); - } - - #[test] - fn test_reject_vector_dimension_larger_than_arrow_bound() { - let input = r#" -node Doc { - embedding: Vector(2147483648) -} -"#; - let err = parse_schema(input).unwrap_err(); - assert!(err.to_string().contains("exceeds maximum supported")); - } - - #[test] - fn test_parse_error() { - let input = "node { }"; // missing type name - assert!(parse_schema(input).is_err()); - } - - #[test] - fn test_parse_error_diagnostic_has_span() { - let input = "node { }"; - let err = parse_schema_diagnostic(input).unwrap_err(); - assert!(err.span.is_some()); - } -} +#[path = "parser_tests.rs"] +mod tests; diff --git a/crates/omnigraph-compiler/src/schema/parser_tests.rs b/crates/omnigraph-compiler/src/schema/parser_tests.rs new file mode 100644 index 0000000..9b96a4e --- /dev/null +++ b/crates/omnigraph-compiler/src/schema/parser_tests.rs @@ -0,0 +1,955 @@ +use super::*; + +#[test] +fn test_parse_basic_schema() { + let input = r#" +node Person { +name: String +age: I32? +} + +node Company { +name: String +} + +edge Knows: Person -> Person { +since: Date? +} + +edge WorksAt: Person -> Company { +title: String? +} +"#; + let schema = parse_schema(input).unwrap(); + assert_eq!(schema.declarations.len(), 4); + + match &schema.declarations[0] { + SchemaDecl::Node(n) => { + assert_eq!(n.name, "Person"); + assert!(n.annotations.is_empty()); + assert!(n.implements.is_empty()); + assert_eq!(n.properties.len(), 2); + assert_eq!(n.properties[0].name, "name"); + assert!(!n.properties[0].prop_type.nullable); + assert_eq!(n.properties[1].name, "age"); + assert!(n.properties[1].prop_type.nullable); + } + _ => panic!("expected Node"), + } + + match &schema.declarations[2] { + SchemaDecl::Edge(e) => { + assert_eq!(e.name, "Knows"); + assert_eq!(e.from_type, "Person"); + assert_eq!(e.to_type, "Person"); + assert!(e.annotations.is_empty()); + assert_eq!(e.properties.len(), 1); + assert!(e.cardinality.is_default()); + } + _ => panic!("expected Edge"), + } +} + +#[test] +fn test_parse_interface_basic() { + let input = r#" +interface Named { +name: String +} +node Person implements Named { +age: I32? +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Interface(i) => { + assert_eq!(i.name, "Named"); + assert_eq!(i.properties.len(), 1); + assert_eq!(i.properties[0].name, "name"); + } + _ => panic!("expected Interface"), + } + match &schema.declarations[1] { + SchemaDecl::Node(n) => { + assert_eq!(n.name, "Person"); + assert_eq!(n.implements, vec!["Named"]); + // "name" injected from interface + "age" declared locally + assert_eq!(n.properties.len(), 2); + } + _ => panic!("expected Node"), + } +} + +#[test] +fn test_parse_implements_multiple() { + let input = r#" +interface Slugged { +slug: String @key +} +interface Described { +title: String +description: String? +} +node Signal implements Slugged, Described { +strength: F64 +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[2] { + SchemaDecl::Node(n) => { + assert_eq!(n.name, "Signal"); + assert_eq!(n.implements, vec!["Slugged", "Described"]); + // slug + title + description + strength + assert_eq!(n.properties.len(), 4); + // @key from Slugged should be desugared into constraints + assert!( + n.constraints + .iter() + .any(|c| matches!(c, Constraint::Key(v) if v == &["slug"])) + ); + } + _ => panic!("expected Node"), + } +} + +#[test] +fn test_reject_implements_unknown_interface() { + let input = r#" +node Person implements Unknown { +name: String +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!(err.to_string().contains("unknown interface")); +} + +#[test] +fn test_reject_interface_property_type_conflict() { + let input = r#" +interface Named { +name: I32 +} +node Person implements Named { +name: String +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!(err.to_string().contains("type") || err.to_string().contains("interface")); +} + +#[test] +fn test_parse_annotation() { + let input = r#" +node Person { +name: String @unique +id: U64 @key +handle: String @index +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Node(n) => { + assert_eq!(n.properties[0].annotations.len(), 1); + assert_eq!(n.properties[0].annotations[0].name, "unique"); + assert_eq!(n.properties[1].annotations[0].name, "key"); + assert_eq!(n.properties[2].annotations[0].name, "index"); + // Annotations are desugared into constraints + assert!( + n.constraints + .iter() + .any(|c| matches!(c, Constraint::Unique(_))) + ); + assert!( + n.constraints + .iter() + .any(|c| matches!(c, Constraint::Key(_))) + ); + assert!( + n.constraints + .iter() + .any(|c| matches!(c, Constraint::Index(_))) + ); + } + _ => panic!("expected Node"), + } +} + +#[test] +fn test_property_level_key_desugars_to_constraint() { + let input = r#" +node Person { +name: String @key +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Node(n) => { + assert!( + n.constraints + .iter() + .any(|c| matches!(c, Constraint::Key(v) if v == &["name"])) + ); + } + _ => panic!("expected Node"), + } +} + +#[test] +fn test_parse_body_constraint_key() { + let input = r#" +node Person { +name: String +@key(name) +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Node(n) => { + assert!( + n.constraints + .iter() + .any(|c| matches!(c, Constraint::Key(v) if v == &["name"])) + ); + } + _ => panic!("expected Node"), + } +} + +#[test] +fn test_parse_body_constraint_unique_composite() { + let input = r#" +node Person { +first: String +last: String +@unique(first, last) +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Node(n) => { + assert!( + n.constraints + .iter() + .any(|c| matches!(c, Constraint::Unique(v) if v == &["first", "last"])) + ); + } + _ => panic!("expected Node"), + } +} + +#[test] +fn test_parse_body_constraint_index_composite() { + let input = r#" +node Event { +category: String +date: Date +@index(category, date) +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Node(n) => { + assert!( + n.constraints + .iter() + .any(|c| matches!(c, Constraint::Index(v) if v == &["category", "date"])) + ); + } + _ => panic!("expected Node"), + } +} + +#[test] +fn test_parse_body_constraint_range() { + let input = r#" +node Person { +age: I32? +@range(age, 0..200) +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Node(n) => { + assert!( + n.constraints.iter().any( + |c| matches!(c, Constraint::Range { property, .. } if property == "age") + ) + ); + } + _ => panic!("expected Node"), + } +} + +#[test] +fn test_parse_range_float_bounds() { + let input = r#" +node Measurement { +name: String @key +temperature: F64? +@range(temperature, 0.0..100.0) +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Node(n) => { + assert!(n.constraints.iter().any(|c| matches!( + c, + Constraint::Range { property, min, max } + if property == "temperature" + && matches!(min, Some(ConstraintBound::Float(f)) if *f == 0.0) + && matches!(max, Some(ConstraintBound::Float(f)) if *f == 100.0) + ))); + } + _ => panic!("expected Node"), + } +} + +#[test] +fn test_parse_range_negative_float_bounds() { + let input = r#" +node Measurement { +name: String @key +temperature: F64? +@range(temperature, -40.0..60.0) +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Node(n) => { + assert!(n.constraints.iter().any(|c| matches!( + c, + Constraint::Range { property, min, max } + if property == "temperature" + && matches!(min, Some(ConstraintBound::Float(f)) if *f == -40.0) + && matches!(max, Some(ConstraintBound::Float(f)) if *f == 60.0) + ))); + } + _ => panic!("expected Node"), + } +} + +#[test] +fn test_parse_range_negative_integer_bounds() { + let input = r#" +node Account { +name: String @key +balance: I64? +@range(balance, -1000..1000) +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Node(n) => { + assert!(n.constraints.iter().any(|c| matches!( + c, + Constraint::Range { property, min, max } + if property == "balance" + && matches!(min, Some(ConstraintBound::Integer(n)) if *n == -1000) + && matches!(max, Some(ConstraintBound::Integer(n)) if *n == 1000) + ))); + } + _ => panic!("expected Node"), + } +} + +#[test] +fn test_parse_body_constraint_check() { + let input = r#" +node Order { +code: String +@check(code, "[A-Z]{3}-[0-9]+") +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Node(n) => { + assert!(n.constraints.iter().any(|c| matches!(c, Constraint::Check { property, pattern } if property == "code" && pattern == "[A-Z]{3}-[0-9]+"))); + } + _ => panic!("expected Node"), + } +} + +#[test] +fn test_reject_range_on_string() { + let input = r#" +node Person { +name: String +@range(name, 0..100) +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!(err.to_string().contains("numeric")); +} + +#[test] +fn test_reject_check_on_integer() { + let input = r#" +node Person { +age: I32 +@check(age, "[0-9]+") +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!(err.to_string().contains("String")); +} + +#[test] +fn test_parse_edge_cardinality() { + let input = r#" +node Person { name: String } +node Company { name: String } +edge WorksAt: Person -> Company @card(0..1) +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[2] { + SchemaDecl::Edge(e) => { + assert_eq!(e.cardinality.min, 0); + assert_eq!(e.cardinality.max, Some(1)); + } + _ => panic!("expected Edge"), + } +} + +#[test] +fn test_parse_edge_cardinality_unbounded() { + let input = r#" +node Person { name: String } +node Paper { title: String } +edge Authored: Person -> Paper @card(1..) +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[2] { + SchemaDecl::Edge(e) => { + assert_eq!(e.cardinality.min, 1); + assert_eq!(e.cardinality.max, None); + } + _ => panic!("expected Edge"), + } +} + +#[test] +fn test_parse_edge_default_cardinality() { + let input = r#" +node Person { name: String } +edge Knows: Person -> Person +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[1] { + SchemaDecl::Edge(e) => { + assert!(e.cardinality.is_default()); + } + _ => panic!("expected Edge"), + } +} + +#[test] +fn test_parse_edge_unique_src_dst() { + let input = r#" +node Person { name: String } +edge Knows: Person -> Person { +@unique(src, dst) +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[1] { + SchemaDecl::Edge(e) => { + assert!( + e.constraints + .iter() + .any(|c| matches!(c, Constraint::Unique(v) if v == &["src", "dst"])) + ); + } + _ => panic!("expected Edge"), + } +} + +#[test] +fn test_parse_edge_property_index() { + let input = r#" +node Person { name: String } +node Company { name: String } +edge WorksAt: Person -> Company { +since: Date? @index +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[2] { + SchemaDecl::Edge(e) => { + // @index on since is desugared to Constraint::Index + assert!( + e.constraints + .iter() + .any(|c| matches!(c, Constraint::Index(v) if v == &["since"])) + ); + } + _ => panic!("expected Edge"), + } +} + +#[test] +fn test_parse_embed_annotation_identifier_arg() { + let input = r#" +node Doc { +title: String +embedding: Vector(3) @embed(title) +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Node(n) => { + assert_eq!(n.properties[1].annotations.len(), 1); + assert_eq!(n.properties[1].annotations[0].name, "embed"); + assert_eq!( + n.properties[1].annotations[0].value.as_deref(), + Some("title") + ); + } + _ => panic!("expected Node"), + } +} + +#[test] +fn test_parse_edge_no_body() { + let input = "edge WorksAt: Person -> Company\n"; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Edge(e) => { + assert_eq!(e.name, "WorksAt"); + assert!(e.annotations.is_empty()); + assert!(e.properties.is_empty()); + } + _ => panic!("expected Edge"), + } +} + +#[test] +fn test_parse_type_rename_annotation() { + let input = r#" +node Account @rename_from("User") { +full_name: String @rename_from("name") +} + +edge ConnectedTo: Account -> Account @rename_from("Knows") +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Node(n) => { + assert_eq!(n.name, "Account"); + assert_eq!(n.annotations.len(), 1); + assert_eq!(n.annotations[0].name, "rename_from"); + assert_eq!(n.annotations[0].value.as_deref(), Some("User")); + assert_eq!(n.properties[0].annotations[0].name, "rename_from"); + assert_eq!( + n.properties[0].annotations[0].value.as_deref(), + Some("name") + ); + } + _ => panic!("expected Node"), + } + match &schema.declarations[1] { + SchemaDecl::Edge(e) => { + assert_eq!(e.name, "ConnectedTo"); + assert_eq!(e.annotations.len(), 1); + assert_eq!(e.annotations[0].name, "rename_from"); + assert_eq!(e.annotations[0].value.as_deref(), Some("Knows")); + } + _ => panic!("expected Edge"), + } +} + +#[test] +fn test_reject_multiple_node_keys() { + let input = r#" +node Person { +id: U64 @key +ext_id: String @key +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!(err.to_string().contains("multiple @key")); +} + +#[test] +fn test_reject_unique_with_value() { + // @unique("x") is now a parse error — the grammar parses it as a body_constraint + // which expects ident args, not string literals as the sole argument + let input = r#" +node Person { +email: String @unique("x") +} +"#; + assert!(parse_schema(input).is_err()); +} + +#[test] +fn test_reject_index_with_value() { + // @index("x") is now a parse error — same reason as above + let input = r#" +node Person { +email: String @index("x") +} +"#; + assert!(parse_schema(input).is_err()); +} + +#[test] +fn test_reject_unique_on_node_annotation() { + let input = r#" +node Person @unique { +email: String +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!( + err.to_string() + .contains("only supported on node properties") + ); +} + +#[test] +fn test_reject_index_on_node_annotation() { + let input = r#" +node Person @index { +email: String +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!( + err.to_string() + .contains("only supported on node properties") + ); +} + +#[test] +fn test_allow_unique_on_edge_property() { + let input = r#" +node Person { name: String } +edge Knows: Person -> Person { +weight: I32 @unique +} +"#; + // Should now succeed (edge property @unique is allowed) + let schema = parse_schema(input).unwrap(); + match &schema.declarations[1] { + SchemaDecl::Edge(e) => { + assert!( + e.constraints + .iter() + .any(|c| matches!(c, Constraint::Unique(v) if v == &["weight"])) + ); + } + _ => panic!("expected Edge"), + } +} + +#[test] +fn test_allow_index_on_edge_property() { + let input = r#" +node Person { name: String } +edge Knows: Person -> Person { +weight: I32 @index +} +"#; + // Should now succeed (edge property @index is allowed) + let schema = parse_schema(input).unwrap(); + match &schema.declarations[1] { + SchemaDecl::Edge(e) => { + assert!( + e.constraints + .iter() + .any(|c| matches!(c, Constraint::Index(v) if v == &["weight"])) + ); + } + _ => panic!("expected Edge"), + } +} + +#[test] +fn test_reject_embed_without_source_property() { + let input = r#" +node Doc { +title: String +embedding: Vector(3) @embed +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!(err.to_string().contains("requires a source property name")); +} + +#[test] +fn test_reject_embed_on_non_vector_property() { + let input = r#" +node Doc { +title: String @embed(title) +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!( + err.to_string() + .contains("only supported on vector properties") + ); +} + +#[test] +fn test_reject_embed_unknown_source_property() { + let input = r#" +node Doc { +title: String +embedding: Vector(3) @embed(body) +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!( + err.to_string() + .contains("references unknown source property") + ); +} + +#[test] +fn test_reject_embed_source_not_string() { + let input = r#" +node Doc { +body: I32 +embedding: Vector(3) @embed(body) +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!(err.to_string().contains("must be String")); +} + +#[test] +fn test_reject_embed_on_edge_property() { + let input = r#" +node Doc { title: String } +edge Linked: Doc -> Doc { +embedding: Vector(3) @embed(title) +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!(err.to_string().contains("edge properties")); +} + +#[test] +fn test_parse_enum_and_list_types() { + let input = r#" +node Ticket { +status: enum(open, closed, blocked) +tags: [String] +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Node(n) => { + let status = &n.properties[0].prop_type; + assert!(status.is_enum()); + assert!(!status.list); + assert_eq!( + status.enum_values.as_ref().unwrap(), + &vec![ + "blocked".to_string(), + "closed".to_string(), + "open".to_string() + ] + ); + + let tags = &n.properties[1].prop_type; + assert!(tags.list); + assert!(!tags.is_enum()); + assert_eq!(tags.scalar, ScalarType::String); + } + _ => panic!("expected Node"), + } +} + +#[test] +fn test_reject_duplicate_enum_values() { + let input = r#" +node Ticket { +status: enum(open, closed, open) +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!(err.to_string().contains("duplicate values")); +} + +#[test] +fn test_parse_description_and_instruction_annotations() { + let input = r#" +node Task @description("Tracked work item") @instruction("Prefer querying by slug") { +slug: String @key @description("Stable external identifier") +} +edge DependsOn: Task -> Task @description("Hard dependency") @instruction("Use only for blockers") +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Node(node) => { + assert_eq!( + node.annotations + .iter() + .find(|ann| ann.name == "description") + .and_then(|ann| ann.value.as_deref()), + Some("Tracked work item") + ); + assert_eq!( + node.annotations + .iter() + .find(|ann| ann.name == "instruction") + .and_then(|ann| ann.value.as_deref()), + Some("Prefer querying by slug") + ); + assert_eq!( + node.properties[0] + .annotations + .iter() + .find(|ann| ann.name == "description") + .and_then(|ann| ann.value.as_deref()), + Some("Stable external identifier") + ); + } + _ => panic!("expected node"), + } + match &schema.declarations[1] { + SchemaDecl::Edge(edge) => { + assert_eq!( + edge.annotations + .iter() + .find(|ann| ann.name == "description") + .and_then(|ann| ann.value.as_deref()), + Some("Hard dependency") + ); + assert_eq!( + edge.annotations + .iter() + .find(|ann| ann.name == "instruction") + .and_then(|ann| ann.value.as_deref()), + Some("Use only for blockers") + ); + } + _ => panic!("expected edge"), + } +} + +#[test] +fn test_parse_annotation_decodes_escapes() { + let input = r#" +node Task @description("Tracked\n\"work\"\\item") { +slug: String @key @description("Stable\tidentifier") +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Node(node) => { + assert_eq!( + node.annotations[0].value.as_deref(), + Some("Tracked\n\"work\"\\item") + ); + assert_eq!( + node.properties[0].annotations[1].value.as_deref(), + Some("Stable\tidentifier") + ); + } + _ => panic!("expected node"), + } +} + +#[test] +fn test_parse_annotation_rejects_unknown_escape() { + let input = r#" +node Task @description("Tracked\q") { +slug: String @key +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!(err.to_string().contains("unsupported escape sequence")); +} + +#[test] +fn test_reject_duplicate_description_annotations() { + let input = r#" +node Task @description("a") @description("b") { +slug: String @key +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!( + err.to_string() + .contains("declares @description multiple times") + ); +} + +#[test] +fn test_reject_instruction_on_property() { + let input = r#" +node Task { +slug: String @instruction("bad") +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!( + err.to_string() + .contains("@instruction is only supported on node and edge types") + ); +} + +#[test] +fn test_reject_key_on_list_property() { + let input = r#" +node Ticket { +tags: [String] @key +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!(err.to_string().contains("list property")); +} + +#[test] +fn test_parse_vector_type() { + let input = r#" +node Doc { +embedding: Vector(3) +} +"#; + let schema = parse_schema(input).unwrap(); + match &schema.declarations[0] { + SchemaDecl::Node(n) => match n.properties[0].prop_type.scalar { + ScalarType::Vector(dim) => assert_eq!(dim, 3), + other => panic!("expected vector type, got {:?}", other), + }, + _ => panic!("expected node"), + } +} + +#[test] +fn test_reject_zero_vector_dimension() { + let input = r#" +node Doc { +embedding: Vector(0) +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!(err.to_string().contains("Vector dimension")); +} + +#[test] +fn test_reject_vector_dimension_larger_than_arrow_bound() { + let input = r#" +node Doc { +embedding: Vector(2147483648) +} +"#; + let err = parse_schema(input).unwrap_err(); + assert!(err.to_string().contains("exceeds maximum supported")); +} + +#[test] +fn test_parse_error() { + let input = "node { }"; // missing type name + assert!(parse_schema(input).is_err()); +} + +#[test] +fn test_parse_error_diagnostic_has_span() { + let input = "node { }"; + let err = parse_schema_diagnostic(input).unwrap_err(); + assert!(err.span.is_some()); +}