mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-09 01:35:18 +02:00
Extract compiler test modules to sibling files
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) <noreply@anthropic.com>
This commit is contained in:
parent
789c0633e8
commit
94849a50b4
6 changed files with 3050 additions and 3050 deletions
|
|
@ -800,938 +800,5 @@ fn parse_nearest_ordering(pair: pest::iterators::Pair<Rule>) -> Result<Expr> {
|
|||
}
|
||||
|
||||
#[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;
|
||||
|
|
|
|||
933
crates/omnigraph-compiler/src/query/parser_tests.rs
Normal file
933
crates/omnigraph-compiler/src/query/parser_tests.rs
Normal file
|
|
@ -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());
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
1156
crates/omnigraph-compiler/src/query/typecheck_tests.rs
Normal file
1156
crates/omnigraph-compiler/src/query/typecheck_tests.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
|
|||
955
crates/omnigraph-compiler/src/schema/parser_tests.rs
Normal file
955
crates/omnigraph-compiler/src/schema/parser_tests.rs
Normal file
|
|
@ -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());
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue