1pub mod ast;
2mod grammar;
3pub mod locy_ast;
4pub mod plugin_aggregates;
5
6pub use grammar::{ParseError, parse, parse_expression, parse_locy};
7pub use plugin_aggregates::{is_known_plugin_aggregate, register_plugin_aggregate};
8
9#[cfg(test)]
10mod tests {
11 use super::*;
12
13 #[test]
14 fn test_comprehension_with_complex_where() {
15 let test_cases = vec![
16 (
18 "AND operator",
19 "RETURN [x IN range(1,100) WHERE x > 10 AND x < 50 | x * 2] AS result",
20 ),
21 (
22 "OR operator",
23 "RETURN [x IN nodes WHERE x.active OR x.admin | x.name] AS result",
24 ),
25 (
26 "XOR operator",
27 "RETURN [x IN items WHERE x.flag1 XOR x.flag2 | x.id] AS result",
28 ),
29 (
31 "Parenthesized OR with AND",
32 "RETURN [x IN list WHERE (x > 0 AND x < 10) OR x = 100 | x] AS result",
33 ),
34 (
35 "Complex nested",
36 "RETURN [x IN data WHERE (x.a AND x.b) OR (x.c AND NOT x.d) | x.value] AS result",
37 ),
38 (
39 "Triple nesting",
40 "RETURN [x IN items WHERE ((x.a OR x.b) AND x.c) OR (x.d AND NOT x.e) | x] AS result",
41 ),
42 (
44 "NOT with AND",
45 "RETURN [x IN list WHERE NOT x.deleted AND x.active | x] AS result",
46 ),
47 (
48 "NOT with OR",
49 "RETURN [x IN list WHERE NOT (x.a OR x.b) | x] AS result",
50 ),
51 (
52 "Multiple NOT",
53 "RETURN [x IN list WHERE NOT x.a AND NOT x.b | x] AS result",
54 ),
55 (
57 "Filter-only with AND",
58 "RETURN [x IN list WHERE x > 5 AND x < 10] AS filtered",
59 ),
60 (
61 "Filter-only with OR",
62 "RETURN [x IN list WHERE x < 0 OR x > 100] AS outliers",
63 ),
64 (
65 "Filter-only complex",
66 "RETURN [x IN data WHERE (x.status = 'active' AND x.verified) OR x.admin] AS users",
67 ),
68 (
70 "Pattern with AND",
71 "RETURN [(a)-[:KNOWS]->(b) WHERE b.age > 21 AND b.active | b.name] AS friends",
72 ),
73 (
74 "Pattern with OR",
75 "RETURN [(n)-[:LIKES|LOVES]->(m) WHERE m.public OR n.friend | m] AS items",
76 ),
77 (
78 "Pattern complex",
79 "RETURN [p = (a)-[r]->(b) WHERE (r.weight > 5 AND b.score > 10) OR a.vip | p] AS paths",
80 ),
81 (
83 "Multiple comparisons",
84 "RETURN [x IN items WHERE x.price > 10 AND x.price < 100 AND x.inStock | x] AS affordable",
85 ),
86 (
87 "String operators",
88 "RETURN [x IN names WHERE x STARTS WITH 'A' AND NOT x ENDS WITH 'z' | x] AS filtered",
89 ),
90 (
91 "IN with AND",
92 "RETURN [x IN numbers WHERE x IN [1,2,3] AND x % 2 = 0 | x * 10] AS even",
93 ),
94 (
96 "Nested properties",
97 "RETURN [x IN items WHERE x.meta.active AND (x.meta.score > 5 OR x.priority) | x.id] AS result",
98 ),
99 (
100 "Property with NULL",
101 "RETURN [x IN items WHERE x.prop IS NOT NULL AND x.prop > 0 | x] AS valid",
102 ),
103 (
105 "AND OR XOR mix",
106 "RETURN [x IN list WHERE (x.a AND x.b) OR (x.c XOR x.d) | x] AS result",
107 ),
108 (
109 "Complex mix",
110 "RETURN [x IN data WHERE (x.flag1 OR x.flag2) AND NOT (x.flag3 XOR x.flag4) | x.value] AS result",
111 ),
112 ];
113
114 println!("\n=== Testing Complex Comprehension WHERE Clauses ===\n");
115
116 for (name, query) in test_cases.iter() {
117 match parse(query) {
118 Ok(_) => println!("✅ {}: PASSED", name),
119 Err(e) => panic!("❌ {} FAILED: {:?}\nQuery: {}", name, e, query),
120 }
121 }
122
123 println!(
124 "\n✅ All {} complex comprehension tests passed!",
125 test_cases.len()
126 );
127 }
128
129 #[test]
130 fn test_parse_version_as_of() {
131 let q = parse("MATCH (n) RETURN n VERSION AS OF 'snap123'").unwrap();
132 match q {
133 ast::Query::TimeTravel { query, spec } => {
134 assert!(matches!(*query, ast::Query::Single(_)));
135 assert_eq!(spec, ast::TimeTravelSpec::Version("snap123".to_string()));
136 }
137 _ => panic!("Expected TimeTravel query, got {:?}", q),
138 }
139 }
140
141 #[test]
142 fn test_parse_timestamp_as_of() {
143 let q = parse("MATCH (n) RETURN n TIMESTAMP AS OF '2025-02-01T12:00:00Z'").unwrap();
144 match q {
145 ast::Query::TimeTravel { query, spec } => {
146 assert!(matches!(*query, ast::Query::Single(_)));
147 assert_eq!(
148 spec,
149 ast::TimeTravelSpec::Timestamp("2025-02-01T12:00:00Z".to_string())
150 );
151 }
152 _ => panic!("Expected TimeTravel query, got {:?}", q),
153 }
154 }
155
156 #[test]
157 fn test_parse_version_as_of_with_union() {
158 let q =
159 parse("MATCH (n:A) RETURN n UNION MATCH (m:B) RETURN m VERSION AS OF 'snap1'").unwrap();
160 match q {
161 ast::Query::TimeTravel { query, spec } => {
162 assert!(matches!(*query, ast::Query::Union { .. }));
163 assert_eq!(spec, ast::TimeTravelSpec::Version("snap1".to_string()));
164 }
165 _ => panic!("Expected TimeTravel query, got {:?}", q),
166 }
167 }
168
169 #[test]
170 fn test_parse_no_time_travel() {
171 let q = parse("MATCH (n) RETURN n").unwrap();
172 assert!(matches!(q, ast::Query::Single(_)));
173 }
174
175 #[test]
176 fn test_parse_or_relationship_types() {
177 let q = parse("MATCH (n)-[r:KNOWS|HATES]->(x) RETURN r").unwrap();
178 if let ast::Query::Single(single) = q
179 && let ast::Clause::Match(match_clause) = &single.clauses[0]
180 && let ast::PatternElement::Relationship(rel) =
181 &match_clause.pattern.paths[0].elements[1]
182 {
183 assert_eq!(
184 rel.types,
185 ast::LabelExpr::Disjunction(vec!["KNOWS".to_string(), "HATES".to_string()])
186 );
187 println!("Parsed types: {:?}", rel.types);
188 return;
189 }
190 panic!("Could not find relationship pattern with OR types");
191 }
192
193 #[test]
194 fn test_parse_vlp_relationship_variable() {
195 let q = parse("MATCH (a)-[r*1..1]->(b) RETURN r").unwrap();
197 if let ast::Query::Single(single) = q
198 && let ast::Clause::Match(match_clause) = &single.clauses[0]
199 && let ast::PatternElement::Relationship(rel) =
200 &match_clause.pattern.paths[0].elements[1]
201 {
202 assert_eq!(
203 rel.variable,
204 Some("r".to_string()),
205 "VLP should preserve relationship variable 'r'"
206 );
207 assert!(rel.range.is_some(), "VLP should have range");
208 let range = rel.range.as_ref().unwrap();
209 assert_eq!(range.min, Some(1));
210 assert_eq!(range.max, Some(1));
211 println!(
212 "VLP relationship: variable={:?}, range={:?}",
213 rel.variable, rel.range
214 );
215 return;
216 }
217 panic!("Could not find VLP relationship pattern");
218 }
219}
220
221#[cfg(test)]
222mod locy_tests {
223 use super::*;
224
225 #[test]
230 fn test_locy_cypher_passthrough_match_return() {
231 let program = parse_locy("MATCH (n) RETURN n").unwrap();
232 assert!(program.module.is_none());
233 assert!(program.uses.is_empty());
234 assert_eq!(program.statements.len(), 1);
235 assert!(
236 matches!(&program.statements[0], locy_ast::LocyStatement::Cypher(_)),
237 "Expected Cypher passthrough, got: {:?}",
238 program.statements[0]
239 );
240 }
241
242 #[test]
243 fn test_locy_cypher_passthrough_create() {
244 let program = parse_locy("CREATE (n:Person {name: 'Alice'})").unwrap();
245 assert_eq!(program.statements.len(), 1);
246 assert!(matches!(
247 &program.statements[0],
248 locy_ast::LocyStatement::Cypher(_)
249 ));
250 }
251
252 #[test]
253 fn test_locy_cypher_passthrough_union() {
254 let program = parse_locy("MATCH (n:A) RETURN n UNION MATCH (m:B) RETURN m").unwrap();
255 assert_eq!(program.statements.len(), 1);
256 if let locy_ast::LocyStatement::Cypher(q) = &program.statements[0] {
257 assert!(matches!(q, ast::Query::Union { .. }));
258 } else {
259 panic!("Expected Cypher union");
260 }
261 }
262
263 #[test]
264 fn test_locy_cypher_passthrough_multi_clause() {
265 let program = parse_locy("MATCH (n) WHERE n.age > 21 WITH n RETURN n.name").unwrap();
266 assert_eq!(program.statements.len(), 1);
267 if let locy_ast::LocyStatement::Cypher(ast::Query::Single(stmt)) = &program.statements[0] {
268 assert_eq!(stmt.clauses.len(), 3); } else {
270 panic!("Expected Cypher single query with 3 clauses");
271 }
272 }
273
274 #[test]
279 fn test_locy_create_rule_minimal() {
280 let program =
281 parse_locy("CREATE RULE reachable AS MATCH (a)-[:KNOWS]->(b) YIELD a, b").unwrap();
282 assert_eq!(program.statements.len(), 1);
283 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
284 assert_eq!(rule.name.parts, vec!["reachable"]);
285 assert!(rule.priority.is_none());
286 assert!(!rule.match_pattern.paths.is_empty());
287 assert!(rule.where_conditions.is_empty());
288 if let locy_ast::RuleOutput::Yield(yc) = &rule.output {
289 let items = &yc.items;
290 assert_eq!(items.len(), 2);
291 assert!(!items[0].is_key);
292 assert!(!items[1].is_key);
293 } else {
294 panic!("Expected Yield output");
295 }
296 } else {
297 panic!("Expected Rule statement");
298 }
299 }
300
301 #[test]
306 fn test_locy_rule_priority() {
307 let program =
308 parse_locy("CREATE RULE r PRIORITY 2 AS MATCH (a)-[:E]->(b) YIELD a").unwrap();
309 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
310 assert_eq!(rule.priority, Some(2));
311 } else {
312 panic!("Expected Rule");
313 }
314 }
315
316 #[test]
321 fn test_locy_is_reference_unary() {
322 let program =
323 parse_locy("CREATE RULE test AS MATCH (n) WHERE n IS suspicious YIELD n").unwrap();
324 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
325 assert_eq!(rule.where_conditions.len(), 1);
326 if let locy_ast::RuleCondition::IsReference(is_ref) = &rule.where_conditions[0] {
327 assert_eq!(is_ref.subjects, vec!["n"]);
328 assert_eq!(is_ref.rule_name.parts, vec!["suspicious"]);
329 assert!(is_ref.target.is_none());
330 assert!(!is_ref.negated);
331 } else {
332 panic!("Expected IsReference");
333 }
334 } else {
335 panic!("Expected Rule");
336 }
337 }
338
339 #[test]
344 fn test_locy_is_not_reference() {
345 let program =
346 parse_locy("CREATE RULE test AS MATCH (n) WHERE n IS NOT clean YIELD n").unwrap();
347 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
348 if let locy_ast::RuleCondition::IsReference(is_ref) = &rule.where_conditions[0] {
349 assert!(is_ref.negated);
350 assert_eq!(is_ref.rule_name.parts, vec!["clean"]);
351 } else {
352 panic!("Expected IsReference");
353 }
354 } else {
355 panic!("Expected Rule");
356 }
357 }
358
359 #[test]
360 fn test_locy_not_is_reference_prefix() {
361 let program =
362 parse_locy("CREATE RULE test AS MATCH (n) WHERE NOT n IS clean YIELD n").unwrap();
363 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
364 if let locy_ast::RuleCondition::IsReference(is_ref) = &rule.where_conditions[0] {
365 assert!(is_ref.negated);
366 assert_eq!(is_ref.rule_name.parts, vec!["clean"]);
367 } else {
368 panic!("Expected IsReference");
369 }
370 } else {
371 panic!("Expected Rule");
372 }
373 }
374
375 #[test]
380 fn test_locy_is_reference_binary() {
381 let program = parse_locy(
382 "CREATE RULE test AS MATCH (a)-[:E]->(b) WHERE a IS reachable TO b YIELD a, b",
383 )
384 .unwrap();
385 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
386 if let locy_ast::RuleCondition::IsReference(is_ref) = &rule.where_conditions[0] {
387 assert_eq!(is_ref.subjects, vec!["a"]);
388 assert_eq!(is_ref.rule_name.parts, vec!["reachable"]);
389 assert_eq!(is_ref.target, Some("b".to_string()));
390 assert!(!is_ref.negated);
391 } else {
392 panic!("Expected IsReference");
393 }
394 } else {
395 panic!("Expected Rule");
396 }
397 }
398
399 #[test]
404 fn test_locy_is_reference_tuple() {
405 let program = parse_locy(
406 "CREATE RULE test AS MATCH (x)-[:E]->(y) WHERE (x, y, cost) IS control YIELD x",
407 )
408 .unwrap();
409 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
410 if let locy_ast::RuleCondition::IsReference(is_ref) = &rule.where_conditions[0] {
411 assert_eq!(is_ref.subjects, vec!["x", "y", "cost"]);
412 assert_eq!(is_ref.rule_name.parts, vec!["control"]);
413 assert!(is_ref.target.is_none());
414 } else {
415 panic!("Expected IsReference");
416 }
417 } else {
418 panic!("Expected Rule");
419 }
420 }
421
422 #[test]
427 fn test_locy_mixed_where_conditions() {
428 let program =
429 parse_locy("CREATE RULE test AS MATCH (n) WHERE n IS reachable, n.age > 18 YIELD n")
430 .unwrap();
431 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
432 assert_eq!(rule.where_conditions.len(), 2);
433 assert!(matches!(
434 &rule.where_conditions[0],
435 locy_ast::RuleCondition::IsReference(_)
436 ));
437 assert!(matches!(
438 &rule.where_conditions[1],
439 locy_ast::RuleCondition::Expression(_)
440 ));
441 } else {
442 panic!("Expected Rule");
443 }
444 }
445
446 #[test]
451 fn test_locy_along_clause() {
452 let program = parse_locy(
453 "CREATE RULE test AS MATCH (a)-[:E]->(b) ALONG hops = prev.hops + 1 YIELD a, b, hops",
454 )
455 .unwrap();
456 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
457 assert_eq!(rule.along.len(), 1);
458 assert_eq!(rule.along[0].name, "hops");
459 if let locy_ast::LocyExpr::BinaryOp { left, op, right } = &rule.along[0].expr {
461 assert!(matches!(left.as_ref(), locy_ast::LocyExpr::PrevRef(f) if f == "hops"));
462 assert_eq!(*op, locy_ast::LocyBinaryOp::Add);
463 assert!(matches!(right.as_ref(), locy_ast::LocyExpr::Cypher(_)));
464 } else {
465 panic!("Expected BinaryOp, got: {:?}", rule.along[0].expr);
466 }
467 } else {
468 panic!("Expected Rule");
469 }
470 }
471
472 #[test]
477 fn test_locy_fold_clause() {
478 let program = parse_locy(
479 "CREATE RULE test AS MATCH (a)-[:E]->(b) FOLD total = SUM(s) YIELD a, total",
480 )
481 .unwrap();
482 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
483 assert_eq!(rule.fold.len(), 1);
484 assert_eq!(rule.fold[0].name, "total");
485 if let ast::Expr::FunctionCall { name, .. } = &rule.fold[0].aggregate {
486 assert_eq!(name.to_uppercase(), "SUM");
487 } else {
488 panic!("Expected FunctionCall, got: {:?}", rule.fold[0].aggregate);
489 }
490 } else {
491 panic!("Expected Rule");
492 }
493 }
494
495 #[test]
500 fn test_locy_best_by_clause() {
501 let program =
502 parse_locy("CREATE RULE test AS MATCH (a)-[:E]->(b) BEST BY cost ASC YIELD a, b")
503 .unwrap();
504 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
505 let best_by = rule.best_by.as_ref().unwrap();
506 assert_eq!(best_by.items.len(), 1);
507 assert!(best_by.items[0].ascending);
508 } else {
509 panic!("Expected Rule");
510 }
511 }
512
513 #[test]
514 fn test_locy_best_by_desc() {
515 let program =
516 parse_locy("CREATE RULE test AS MATCH (a)-[:E]->(b) BEST BY cost DESC YIELD a, b")
517 .unwrap();
518 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
519 let best_by = rule.best_by.as_ref().unwrap();
520 assert!(!best_by.items[0].ascending);
521 } else {
522 panic!("Expected Rule");
523 }
524 }
525
526 #[test]
531 fn test_locy_derive_forward() {
532 let program =
533 parse_locy("CREATE RULE test AS MATCH (a)-[:KNOWS]->(b) DERIVE (a)-[:FRIEND]->(b)")
534 .unwrap();
535 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
536 if let locy_ast::RuleOutput::Derive(locy_ast::DeriveClause::Patterns(pats)) =
537 &rule.output
538 {
539 assert_eq!(pats.len(), 1);
540 assert_eq!(pats[0].direction, ast::Direction::Outgoing);
541 } else {
542 panic!("Expected Derive Patterns terminal");
543 }
544 } else {
545 panic!("Expected Rule");
546 }
547 }
548
549 #[test]
554 fn test_locy_derive_merge() {
555 let program =
556 parse_locy("CREATE RULE test AS MATCH (a)-[:SAME]->(b) DERIVE MERGE a, b").unwrap();
557 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
558 if let locy_ast::RuleOutput::Derive(locy_ast::DeriveClause::Merge(a, b)) = &rule.output
559 {
560 assert_eq!(a, "a");
561 assert_eq!(b, "b");
562 } else {
563 panic!("Expected Derive Merge");
564 }
565 } else {
566 panic!("Expected Rule");
567 }
568 }
569
570 #[test]
575 fn test_locy_derive_new_backward() {
576 let program =
577 parse_locy("CREATE RULE test AS MATCH (c) DERIVE (NEW x:Country)<-[:IN]-(c)").unwrap();
578 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
579 if let locy_ast::RuleOutput::Derive(locy_ast::DeriveClause::Patterns(pats)) =
580 &rule.output
581 {
582 assert_eq!(pats[0].direction, ast::Direction::Incoming);
583 assert!(pats[0].source.is_new);
584 assert_eq!(pats[0].source.variable, "x");
585 assert_eq!(pats[0].source.labels, vec!["Country"]);
586 } else {
587 panic!("Expected Derive Patterns");
588 }
589 } else {
590 panic!("Expected Rule");
591 }
592 }
593
594 #[test]
599 fn test_locy_yield_with_key() {
600 let program =
601 parse_locy("CREATE RULE test AS MATCH (a)-[:E]->(b) YIELD KEY a, KEY b, cost").unwrap();
602 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
603 if let locy_ast::RuleOutput::Yield(yc) = &rule.output {
604 let items = &yc.items;
605 assert_eq!(items.len(), 3);
606 assert!(items[0].is_key);
607 assert!(items[1].is_key);
608 assert!(!items[2].is_key);
609 } else {
610 panic!("Expected Yield output");
611 }
612 } else {
613 panic!("Expected Rule");
614 }
615 }
616
617 #[test]
618 fn test_locy_yield_key_with_property() {
619 let program = parse_locy(
620 "CREATE RULE r AS MATCH (e:Event) YIELD KEY e.action, KEY e.outcome, n AS support",
621 )
622 .unwrap();
623 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
624 if let locy_ast::RuleOutput::Yield(yc) = &rule.output {
625 let items = &yc.items;
626 assert_eq!(items.len(), 3);
627 assert!(items[0].is_key);
628 assert_eq!(
629 items[0].expr,
630 ast::Expr::Property(
631 Box::new(ast::Expr::Variable("e".to_string())),
632 "action".to_string()
633 )
634 );
635 assert!(items[1].is_key);
636 assert_eq!(
637 items[1].expr,
638 ast::Expr::Property(
639 Box::new(ast::Expr::Variable("e".to_string())),
640 "outcome".to_string()
641 )
642 );
643 assert!(!items[2].is_key);
644 assert_eq!(items[2].alias, Some("support".to_string()));
645 } else {
646 panic!("Expected Yield output");
647 }
648 } else {
649 panic!("Expected Rule");
650 }
651 }
652
653 #[test]
654 fn test_locy_yield_key_with_alias() {
655 let program =
656 parse_locy("CREATE RULE r AS MATCH (e:Event) YIELD KEY e.action AS act").unwrap();
657 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
658 if let locy_ast::RuleOutput::Yield(yc) = &rule.output {
659 assert!(yc.items[0].is_key);
660 assert_eq!(yc.items[0].alias, Some("act".to_string()));
661 } else {
662 panic!("Expected Yield output");
663 }
664 } else {
665 panic!("Expected Rule");
666 }
667 }
668
669 #[test]
674 fn test_locy_goal_query() {
675 let program = parse_locy("QUERY reachable WHERE a.name = 'Alice' RETURN b").unwrap();
676 assert_eq!(program.statements.len(), 1);
677 if let locy_ast::LocyStatement::GoalQuery(gq) = &program.statements[0] {
678 assert_eq!(gq.rule_name.parts, vec!["reachable"]);
679 assert!(gq.return_clause.is_some());
680 } else {
681 panic!("Expected GoalQuery, got: {:?}", program.statements[0]);
682 }
683 }
684
685 #[test]
686 fn test_locy_goal_query_no_return() {
687 let program = parse_locy("QUERY reachable WHERE a.name = 'Alice'").unwrap();
688 if let locy_ast::LocyStatement::GoalQuery(gq) = &program.statements[0] {
689 assert!(gq.return_clause.is_none());
690 } else {
691 panic!("Expected GoalQuery");
692 }
693 }
694
695 #[test]
700 fn test_locy_assume_block() {
701 let program = parse_locy("ASSUME { CREATE (x:Temp) } THEN { MATCH (n) RETURN n }").unwrap();
702 assert_eq!(program.statements.len(), 1);
703 if let locy_ast::LocyStatement::AssumeBlock(ab) = &program.statements[0] {
704 assert_eq!(ab.mutations.len(), 1);
705 assert!(matches!(&ab.mutations[0], ast::Clause::Create(_)));
706 assert_eq!(ab.body.len(), 1);
707 assert!(matches!(&ab.body[0], locy_ast::LocyStatement::Cypher(_)));
708 } else {
709 panic!("Expected AssumeBlock, got: {:?}", program.statements[0]);
710 }
711 }
712
713 #[test]
718 fn test_locy_abduce_query() {
719 let program = parse_locy("ABDUCE NOT reachable WHERE a.name = 'Alice' RETURN b").unwrap();
720 assert_eq!(program.statements.len(), 1);
721 if let locy_ast::LocyStatement::AbduceQuery(aq) = &program.statements[0] {
722 assert!(aq.negated);
723 assert_eq!(aq.rule_name.parts, vec!["reachable"]);
724 assert!(aq.return_clause.is_some());
725 } else {
726 panic!("Expected AbduceQuery, got: {:?}", program.statements[0]);
727 }
728 }
729
730 #[test]
731 fn test_locy_abduce_query_positive() {
732 let program = parse_locy("ABDUCE reachable WHERE a.name = 'Bob'").unwrap();
733 if let locy_ast::LocyStatement::AbduceQuery(aq) = &program.statements[0] {
734 assert!(!aq.negated);
735 assert!(aq.return_clause.is_none());
736 } else {
737 panic!("Expected AbduceQuery");
738 }
739 }
740
741 #[test]
746 fn test_locy_explain_rule() {
747 let program = parse_locy("EXPLAIN RULE reachable WHERE a.name = 'Alice'").unwrap();
748 assert_eq!(program.statements.len(), 1);
749 if let locy_ast::LocyStatement::ExplainRule(eq) = &program.statements[0] {
750 assert_eq!(eq.rule_name.parts, vec!["reachable"]);
751 assert!(eq.return_clause.is_none());
752 } else {
753 panic!("Expected ExplainRule, got: {:?}", program.statements[0]);
754 }
755 }
756
757 #[test]
762 fn test_locy_module_use() {
763 let program =
764 parse_locy("MODULE acme.compliance\nUSE acme.common\nMATCH (n) RETURN n").unwrap();
765 assert!(program.module.is_some());
766 assert_eq!(
767 program.module.as_ref().unwrap().name.parts,
768 vec!["acme", "compliance"]
769 );
770 assert_eq!(program.uses.len(), 1);
771 assert_eq!(program.uses[0].name.parts, vec!["acme", "common"]);
772 assert_eq!(program.statements.len(), 1);
773 assert!(matches!(
774 &program.statements[0],
775 locy_ast::LocyStatement::Cypher(_)
776 ));
777 }
778
779 #[test]
780 fn test_locy_module_multiple_uses() {
781 let program =
782 parse_locy("MODULE mymod\nUSE dep1\nUSE dep2.sub\nMATCH (n) RETURN n").unwrap();
783 assert_eq!(program.uses.len(), 2);
784 assert_eq!(program.uses[0].name.parts, vec!["dep1"]);
785 assert_eq!(program.uses[1].name.parts, vec!["dep2", "sub"]);
786 }
787
788 #[test]
793 fn test_locy_complex_rule_all_clauses() {
794 let program = parse_locy(
795 "CREATE RULE shortest_path PRIORITY 1 AS \
796 MATCH (a)-[:EDGE {weight: w}]->(b) \
797 WHERE a IS reachable TO b, w > 0 \
798 ALONG dist = prev.dist + w \
799 FOLD total = SUM(dist) \
800 BEST BY dist ASC \
801 YIELD KEY a, KEY b, dist",
802 )
803 .unwrap();
804 if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
805 assert_eq!(rule.name.parts, vec!["shortest_path"]);
806 assert_eq!(rule.priority, Some(1));
807 assert_eq!(rule.where_conditions.len(), 2);
808 assert_eq!(rule.along.len(), 1);
809 assert_eq!(rule.fold.len(), 1);
810 let best_by = rule.best_by.as_ref().unwrap();
811 assert_eq!(best_by.items.len(), 1);
812 assert!(best_by.items[0].ascending);
813 if let locy_ast::RuleOutput::Yield(yc) = &rule.output {
814 let items = &yc.items;
815 assert_eq!(items.len(), 3);
816 assert!(items[0].is_key);
817 assert!(items[1].is_key);
818 assert!(!items[2].is_key);
819 } else {
820 panic!("Expected Yield");
821 }
822 } else {
823 panic!("Expected Rule");
824 }
825 }
826
827 #[test]
832 fn test_locy_derive_command() {
833 let program = parse_locy("DERIVE reachable WHERE a.name = 'Alice'").unwrap();
834 assert_eq!(program.statements.len(), 1);
835 if let locy_ast::LocyStatement::DeriveCommand(dc) = &program.statements[0] {
836 assert_eq!(dc.rule_name.parts, vec!["reachable"]);
837 assert!(dc.where_expr.is_some());
838 } else {
839 panic!("Expected DeriveCommand, got: {:?}", program.statements[0]);
840 }
841 }
842
843 #[test]
844 fn test_locy_derive_command_no_where() {
845 let program = parse_locy("DERIVE reachable").unwrap();
846 if let locy_ast::LocyStatement::DeriveCommand(dc) = &program.statements[0] {
847 assert!(dc.where_expr.is_none());
848 } else {
849 panic!("Expected DeriveCommand");
850 }
851 }
852
853 fn expect_model(stmt: &locy_ast::LocyStatement) -> &locy_ast::ModelDefinition {
858 match stmt {
859 locy_ast::LocyStatement::Model(m) => m,
860 other => panic!("expected Model, got {other:?}"),
861 }
862 }
863
864 #[test]
865 fn test_create_model_minimal() {
866 let program = parse_locy(
868 "CREATE MODEL flag AS \
869 INPUT (s) \
870 OUTPUT PROB risk \
871 USING xervo('classify/flag-v1')",
872 )
873 .unwrap();
874 let m = expect_model(&program.statements[0]);
875 assert_eq!(m.name.parts, vec!["flag"]);
876 assert_eq!(m.inputs.len(), 1);
877 assert_eq!(m.inputs[0].variable, "s");
878 assert!(m.inputs[0].label.is_none());
879 assert!(m.features.is_empty());
880 assert_eq!(m.output.output_type, locy_ast::OutputType::Prob);
881 assert_eq!(m.output.name, "risk");
882 assert_eq!(m.xervo_alias, "classify/flag-v1");
883 assert!(m.calibration.is_none());
884 assert!(m.version.is_none());
885 assert!(!m.annotations.independent);
886 }
887
888 #[test]
889 fn test_create_model_full_with_calibration_version() {
890 let program = parse_locy(
891 "CREATE MODEL supplier_risk_scorer AS \
892 INPUT (s:Supplier) \
893 FEATURES s.country, s.annual_revenue \
894 OUTPUT PROB risk \
895 USING xervo('classify/supplier-risk-v3') \
896 CALIBRATION platt_scaling \
897 VERSION '3.1.0'",
898 )
899 .unwrap();
900 let m = expect_model(&program.statements[0]);
901 assert_eq!(m.inputs.len(), 1);
902 assert_eq!(m.inputs[0].variable, "s");
903 assert_eq!(m.inputs[0].label.as_deref(), Some("Supplier"));
904 assert_eq!(m.features.len(), 2);
905 assert_eq!(
906 m.calibration,
907 Some(locy_ast::CalibrationMethod::PlattScaling)
908 );
909 assert_eq!(m.version.as_deref(), Some("3.1.0"));
910 }
911
912 #[test]
913 fn test_create_model_independent_annotation() {
914 let program = parse_locy(
915 "@independent CREATE MODEL m AS \
916 INPUT (s) OUTPUT PROB risk USING xervo('classify/m')",
917 )
918 .unwrap();
919 let m = expect_model(&program.statements[0]);
920 assert!(m.annotations.independent);
921 }
922
923 #[test]
924 fn test_create_model_multi_input() {
925 let program = parse_locy(
926 "CREATE MODEL pair AS \
927 INPUT (a:User), (b:Item) \
928 OUTPUT SCORE relevance \
929 USING xervo('rerank/pair')",
930 )
931 .unwrap();
932 let m = expect_model(&program.statements[0]);
933 assert_eq!(m.inputs.len(), 2);
934 assert_eq!(m.inputs[0].variable, "a");
935 assert_eq!(m.inputs[0].label.as_deref(), Some("User"));
936 assert_eq!(m.inputs[1].variable, "b");
937 assert_eq!(m.inputs[1].label.as_deref(), Some("Item"));
938 assert_eq!(m.output.output_type, locy_ast::OutputType::Score);
939 }
940
941 #[test]
942 fn test_create_model_all_output_types() {
943 for (kw, expected) in [
944 ("PROB", locy_ast::OutputType::Prob),
945 ("SCORE", locy_ast::OutputType::Score),
946 ("LABEL", locy_ast::OutputType::Label),
947 ("VECTOR", locy_ast::OutputType::Vector),
948 ] {
949 let src = format!("CREATE MODEL m AS INPUT (s) OUTPUT {kw} out USING xervo('test/m')");
950 let p = parse_locy(&src).unwrap();
951 let m = expect_model(&p.statements[0]);
952 assert_eq!(m.output.output_type, expected, "kw {kw}");
953 }
954 }
955}