@@ -489,24 +489,8 @@ fn parse_query(elements: &[EdnValue]) -> Result<DatalogCommand, String> {
489489 let pattern = Pattern :: from_edn ( pattern_vec) ?;
490490 where_clauses. push ( WhereClause :: Pattern ( pattern) ) ;
491491 } else if let Some ( rule_list) = query_vector[ i] . as_list ( ) {
492- // This is a rule invocation: (predicate ?arg1 ?arg2)
493- if rule_list. is_empty ( ) {
494- return Err ( "Rule invocation cannot be empty" . to_string ( ) ) ;
495- }
496-
497- // First element should be the predicate name (symbol)
498- let predicate = match & rule_list[ 0 ] {
499- EdnValue :: Symbol ( s) => s. clone ( ) ,
500- _ => {
501- return Err ( "Rule invocation must start with predicate name (symbol)"
502- . to_string ( ) ) ;
503- }
504- } ;
505-
506- // Rest are arguments
507- let args = rule_list[ 1 ..] . to_vec ( ) ;
508-
509- where_clauses. push ( WhereClause :: RuleInvocation { predicate, args } ) ;
492+ let clause = parse_list_as_where_clause ( rule_list, true ) ?;
493+ where_clauses. push ( clause) ;
510494 } else {
511495 return Err ( format ! (
512496 "Expected pattern vector or rule invocation in :where clause, got {:?}" ,
@@ -525,6 +509,13 @@ fn parse_query(elements: &[EdnValue]) -> Result<DatalogCommand, String> {
525509 i += 1 ;
526510 }
527511
512+ // Safety check: all variables in (not ...) must be bound by outer clauses
513+ let outer_bound: std:: collections:: HashSet < String > = where_clauses
514+ . iter ( )
515+ . flat_map ( outer_vars_from_clause)
516+ . collect ( ) ;
517+ check_not_safety ( & where_clauses, & outer_bound) ?;
518+
528519 let mut query = DatalogQuery :: new ( find_vars, where_clauses) ;
529520 query. as_of = query_as_of;
530521 query. valid_at = query_valid_at;
@@ -672,6 +663,118 @@ fn parse_retract(elements: &[EdnValue]) -> Result<DatalogCommand, String> {
672663 Ok ( DatalogCommand :: Retract ( Transaction :: new ( patterns) ) )
673664}
674665
666+ /// Parse a list item (EDN List) appearing in a :where clause or rule body.
667+ /// Returns Err if the list is empty, has an unknown form, or contains nested `not`.
668+ fn parse_list_as_where_clause (
669+ list : & [ EdnValue ] ,
670+ allow_not : bool ,
671+ ) -> Result < WhereClause , String > {
672+ if list. is_empty ( ) {
673+ return Err ( "Empty list in :where clause" . to_string ( ) ) ;
674+ }
675+ match & list[ 0 ] {
676+ EdnValue :: Symbol ( s) if s == "not" => {
677+ if !allow_not {
678+ return Err (
679+ "(not ...) cannot appear inside another (not ...)" . to_string ( ) ,
680+ ) ;
681+ }
682+ if list. len ( ) < 2 {
683+ return Err ( "(not) requires at least one clause" . to_string ( ) ) ;
684+ }
685+ let mut inner = Vec :: new ( ) ;
686+ for item in & list[ 1 ..] {
687+ if let Some ( vec) = item. as_vector ( ) {
688+ let pattern = Pattern :: from_edn ( vec) ?;
689+ inner. push ( WhereClause :: Pattern ( pattern) ) ;
690+ } else if let Some ( inner_list) = item. as_list ( ) {
691+ // Recurse with allow_not=false to reject nested not
692+ let clause = parse_list_as_where_clause ( inner_list, false ) ?;
693+ inner. push ( clause) ;
694+ } else {
695+ return Err ( format ! (
696+ "expected pattern or rule invocation inside (not), got {:?}" ,
697+ item
698+ ) ) ;
699+ }
700+ }
701+ Ok ( WhereClause :: Not ( inner) )
702+ }
703+ EdnValue :: Symbol ( predicate) => {
704+ let args = list[ 1 ..] . to_vec ( ) ;
705+ Ok ( WhereClause :: RuleInvocation {
706+ predicate : predicate. clone ( ) ,
707+ args,
708+ } )
709+ }
710+ _ => Err ( format ! (
711+ "Rule invocation must start with predicate name (symbol), got {:?}" ,
712+ list[ 0 ]
713+ ) ) ,
714+ }
715+ }
716+
717+ /// Collect all variable names that appear in a where clause (non-recursively into Not).
718+ fn outer_vars_from_clause ( clause : & WhereClause ) -> Vec < String > {
719+ match clause {
720+ WhereClause :: Pattern ( p) => {
721+ let mut vars = Vec :: new ( ) ;
722+ for v in [ & p. entity , & p. attribute , & p. value ] {
723+ if let Some ( name) = v. as_variable ( )
724+ && !name. starts_with ( "?_" )
725+ {
726+ vars. push ( name. to_string ( ) ) ;
727+ }
728+ }
729+ vars
730+ }
731+ WhereClause :: RuleInvocation { args, .. } => args
732+ . iter ( )
733+ . filter_map ( |a| {
734+ a. as_variable ( ) . and_then ( |s| {
735+ if !s. starts_with ( "?_" ) {
736+ Some ( s. to_string ( ) )
737+ } else {
738+ None
739+ }
740+ } )
741+ } )
742+ . collect ( ) ,
743+ WhereClause :: Not ( _) => vec ! [ ] , // not counted as "outer"
744+ }
745+ }
746+
747+ /// Collect all variable names that appear inside a Not clause.
748+ fn vars_in_not ( clause : & WhereClause ) -> Vec < String > {
749+ match clause {
750+ WhereClause :: Not ( inner) => inner
751+ . iter ( )
752+ . flat_map ( outer_vars_from_clause)
753+ . collect ( ) ,
754+ _ => vec ! [ ] ,
755+ }
756+ }
757+
758+ /// Validate safety: every variable in a (not ...) body must be bound by an outer clause.
759+ fn check_not_safety (
760+ clauses : & [ WhereClause ] ,
761+ outer_bound : & std:: collections:: HashSet < String > ,
762+ ) -> Result < ( ) , String > {
763+ for clause in clauses {
764+ if let WhereClause :: Not ( _) = clause {
765+ for var in vars_in_not ( clause) {
766+ if !outer_bound. contains ( & var) {
767+ return Err ( format ! (
768+ "variable {} in (not ...) is not bound by any outer clause" ,
769+ var
770+ ) ) ;
771+ }
772+ }
773+ }
774+ }
775+ Ok ( ( ) )
776+ }
777+
675778fn parse_rule ( elements : & [ EdnValue ] ) -> Result < DatalogCommand , String > {
676779 // Rule syntax: (rule [(predicate ?args) [pattern1] [pattern2] ...])
677780 // elements[0] = Vector with head (list) + body (patterns/rule calls)
@@ -704,13 +807,43 @@ fn parse_rule(elements: &[EdnValue]) -> Result<DatalogCommand, String> {
704807 _ => return Err ( "Rule head must start with a symbol (predicate name)" . to_string ( ) ) ,
705808 }
706809
707- // Rest of body_vec are patterns or rule invocations
708- let body_clauses = body_vec[ 1 ..] . to_vec ( ) ;
810+ // Rest of body_vec are patterns, rule invocations, or (not ...) clauses
811+ let mut body_clauses: Vec < WhereClause > = Vec :: new ( ) ;
812+ for item in & body_vec[ 1 ..] {
813+ if let Some ( vec) = item. as_vector ( ) {
814+ let pattern = Pattern :: from_edn ( vec) ?;
815+ body_clauses. push ( WhereClause :: Pattern ( pattern) ) ;
816+ } else if let Some ( list) = item. as_list ( ) {
817+ let clause = parse_list_as_where_clause ( list, true ) ?;
818+ body_clauses. push ( clause) ;
819+ } else {
820+ return Err ( format ! (
821+ "Rule body clause must be a vector (pattern) or list (rule invocation / not), got {:?}" ,
822+ item
823+ ) ) ;
824+ }
825+ }
709826
710827 if body_clauses. is_empty ( ) {
711828 return Err ( "Rule must have at least one pattern or rule invocation in body" . to_string ( ) ) ;
712829 }
713830
831+ // Safety check: variables in (not ...) must be bound by the rule head or outer body clauses
832+ let mut outer_bound: std:: collections:: HashSet < String > = std:: collections:: HashSet :: new ( ) ;
833+ // Head args count as binding sites
834+ for v in & head_list[ 1 ..] {
835+ if let Some ( name) = v. as_variable ( ) {
836+ outer_bound. insert ( name. to_string ( ) ) ;
837+ }
838+ }
839+ // Non-not body clauses
840+ for clause in & body_clauses {
841+ for var in outer_vars_from_clause ( clause) {
842+ outer_bound. insert ( var) ;
843+ }
844+ }
845+ check_not_safety ( & body_clauses, & outer_bound) ?;
846+
714847 Ok ( DatalogCommand :: Rule ( Rule {
715848 head : head_list. clone ( ) ,
716849 body : body_clauses,
@@ -894,11 +1027,11 @@ mod tests {
8941027 // Verify body has two clauses: pattern + rule invocation
8951028 assert_eq ! ( rule. body. len( ) , 2 ) ;
8961029
897- // First clause should be a vector (pattern)
898- assert ! ( rule. body[ 0 ] . as_vector ( ) . is_some ( ) ) ;
1030+ // First clause should be a Pattern
1031+ assert ! ( matches! ( rule. body[ 0 ] , WhereClause :: Pattern ( _ ) ) ) ;
8991032
900- // Second clause should be a list (rule invocation)
901- assert ! ( rule. body[ 1 ] . as_list ( ) . is_some ( ) ) ;
1033+ // Second clause should be a RuleInvocation
1034+ assert ! ( matches! ( rule. body[ 1 ] , WhereClause :: RuleInvocation { .. } ) ) ;
9021035 }
9031036 _ => panic ! ( "Expected Rule command" ) ,
9041037 }
@@ -914,8 +1047,8 @@ mod tests {
9141047 assert_eq ! ( rule. head[ 0 ] , EdnValue :: Symbol ( "ancestor" . to_string( ) ) ) ;
9151048 // Two patterns in body
9161049 assert_eq ! ( rule. body. len( ) , 2 ) ;
917- assert ! ( rule. body[ 0 ] . as_vector ( ) . is_some ( ) ) ;
918- assert ! ( rule. body[ 1 ] . as_vector ( ) . is_some ( ) ) ;
1050+ assert ! ( matches! ( rule. body[ 0 ] , WhereClause :: Pattern ( _ ) ) ) ;
1051+ assert ! ( matches! ( rule. body[ 1 ] , WhereClause :: Pattern ( _ ) ) ) ;
9191052 }
9201053 _ => panic ! ( "Expected Rule command" ) ,
9211054 }
@@ -1169,4 +1302,113 @@ mod tests {
11691302 assert ! ( matches!( query. as_of, Some ( AsOf :: Counter ( 100 ) ) ) ) ;
11701303 assert ! ( matches!( query. valid_at, Some ( ValidAt :: Timestamp ( _) ) ) ) ;
11711304 }
1305+
1306+ #[ test]
1307+ fn test_parse_not_with_pattern_in_query ( ) {
1308+ let input = r#"(query [:find ?person :where [?person :name ?n] (not [?person :banned true])])"# ;
1309+ let cmd = parse_datalog_command ( input) . unwrap ( ) ;
1310+ match cmd {
1311+ DatalogCommand :: Query ( q) => {
1312+ assert_eq ! ( q. where_clauses. len( ) , 2 ) ;
1313+ assert ! ( matches!( q. where_clauses[ 0 ] , WhereClause :: Pattern ( _) ) ) ;
1314+ match & q. where_clauses [ 1 ] {
1315+ WhereClause :: Not ( inner) => {
1316+ assert_eq ! ( inner. len( ) , 1 ) ;
1317+ assert ! ( matches!( inner[ 0 ] , WhereClause :: Pattern ( _) ) ) ;
1318+ }
1319+ other => panic ! ( "Expected Not, got {:?}" , other) ,
1320+ }
1321+ }
1322+ _ => panic ! ( "Expected Query" ) ,
1323+ }
1324+ }
1325+
1326+ #[ test]
1327+ fn test_parse_not_with_rule_invocation_in_query ( ) {
1328+ let input = r#"(query [:find ?person :where [?person :name ?n] (not (blocked ?person))])"# ;
1329+ let cmd = parse_datalog_command ( input) . unwrap ( ) ;
1330+ match cmd {
1331+ DatalogCommand :: Query ( q) => {
1332+ match & q. where_clauses [ 1 ] {
1333+ WhereClause :: Not ( inner) => {
1334+ assert ! ( matches!( inner[ 0 ] , WhereClause :: RuleInvocation { .. } ) ) ;
1335+ }
1336+ other => panic ! ( "Expected Not, got {:?}" , other) ,
1337+ }
1338+ }
1339+ _ => panic ! ( "Expected Query" ) ,
1340+ }
1341+ }
1342+
1343+ #[ test]
1344+ fn test_parse_not_in_rule_body ( ) {
1345+ let input = r#"(rule [(eligible ?x) [?x :applied true] (not (rejected ?x))])"# ;
1346+ let cmd = parse_datalog_command ( input) . unwrap ( ) ;
1347+ match cmd {
1348+ DatalogCommand :: Rule ( rule) => {
1349+ assert_eq ! ( rule. body. len( ) , 2 ) ;
1350+ assert ! ( matches!( rule. body[ 0 ] , WhereClause :: Pattern ( _) ) ) ;
1351+ assert ! ( matches!( rule. body[ 1 ] , WhereClause :: Not ( _) ) ) ;
1352+ }
1353+ _ => panic ! ( "Expected Rule" ) ,
1354+ }
1355+ }
1356+
1357+ #[ test]
1358+ fn test_parse_not_empty_body_is_error ( ) {
1359+ let input = r#"(query [:find ?x :where [?x :a ?v] (not)])"# ;
1360+ let result = parse_datalog_command ( input) ;
1361+ assert ! ( result. is_err( ) ) ;
1362+ let msg = result. unwrap_err ( ) ;
1363+ assert ! ( msg. contains( "requires at least one clause" ) , "got: {msg}" ) ;
1364+ }
1365+
1366+ #[ test]
1367+ fn test_parse_nested_not_is_error ( ) {
1368+ let input = r#"(query [:find ?x :where [?x :a ?v] (not (not [?x :banned true]))])"# ;
1369+ let result = parse_datalog_command ( input) ;
1370+ assert ! ( result. is_err( ) ) ;
1371+ let msg = result. unwrap_err ( ) ;
1372+ assert ! ( msg. contains( "cannot appear inside another" ) , "got: {msg}" ) ;
1373+ }
1374+
1375+ #[ test]
1376+ fn test_parse_not_unbound_variable_is_error ( ) {
1377+ // ?y is only in the not body, not in any outer clause
1378+ let input = r#"(query [:find ?x :where [?x :a ?v] (not [?y :banned true])])"# ;
1379+ let result = parse_datalog_command ( input) ;
1380+ assert ! ( result. is_err( ) ) ;
1381+ let msg = result. unwrap_err ( ) ;
1382+ assert ! ( msg. contains( "not bound" ) , "got: {msg}" ) ;
1383+ }
1384+
1385+ #[ test]
1386+ fn test_parse_not_unbound_variable_in_rule_body_is_error ( ) {
1387+ // ?y only in not, not in head or non-not body
1388+ let input = r#"(rule [(eligible ?x) [?x :applied true] (not [?y :banned true])])"# ;
1389+ let result = parse_datalog_command ( input) ;
1390+ assert ! ( result. is_err( ) ) ;
1391+ let msg = result. unwrap_err ( ) ;
1392+ assert ! ( msg. contains( "not bound" ) , "got: {msg}" ) ;
1393+ }
1394+
1395+ #[ test]
1396+ fn test_parse_not_with_multiple_clauses ( ) {
1397+ // (not [?person :role :admin] [?person :active false])
1398+ let input = r#"(query [:find ?person :where [?person :name ?n] (not [?person :role :admin] [?person :active false])])"# ;
1399+ let cmd = parse_datalog_command ( input) . unwrap ( ) ;
1400+ match cmd {
1401+ DatalogCommand :: Query ( q) => {
1402+ match & q. where_clauses [ 1 ] {
1403+ WhereClause :: Not ( inner) => {
1404+ assert_eq ! ( inner. len( ) , 2 ) ;
1405+ assert ! ( matches!( inner[ 0 ] , WhereClause :: Pattern ( _) ) ) ;
1406+ assert ! ( matches!( inner[ 1 ] , WhereClause :: Pattern ( _) ) ) ;
1407+ }
1408+ other => panic ! ( "Expected Not with 2 clauses, got {:?}" , other) ,
1409+ }
1410+ }
1411+ _ => panic ! ( "Expected Query" ) ,
1412+ }
1413+ }
11721414}
0 commit comments