use minigraf::{Minigraf, OpenOptions, QueryResult};
fn in_memory_db() -> Minigraf {
OpenOptions::new().open_memory().unwrap()
}
fn result_count(r: &QueryResult) -> usize {
match r {
QueryResult::QueryResults { results, .. } => results.len(),
_ => panic!("expected QueryResults"),
}
}
#[test]
fn test_not_join_basic_inner_var_excluded() {
let db = in_memory_db();
db.execute(
r#"(transact [[:alice :name "Alice"]
[:alice :has-dep :dep1]
[:dep1 :blocked true]
[:bob :name "Bob"]])"#,
)
.unwrap();
let result = db
.execute(
r#"(query [:find ?x
:where [?x :name ?_n]
(not-join [?x]
[?x :has-dep ?d]
[?d :blocked true])])"#,
)
.unwrap();
assert_eq!(
result_count(&result),
1,
"only bob must pass not-join; alice is excluded"
);
}
#[test]
fn test_not_join_multiple_join_vars() {
let db = in_memory_db();
db.execute(
r#"(transact [[:u1 :has-role :r1]
[:u2 :has-role :r2]
[:r1 :is-restricted true]])"#,
)
.unwrap();
let result = db
.execute(
r#"(query [:find ?u
:where [?u :has-role ?r]
(not-join [?u ?r]
[?r :is-restricted true])])"#,
)
.unwrap();
assert_eq!(
result_count(&result),
1,
"u2 with unrestricted role must appear; u1 with restricted role excluded"
);
}
#[test]
fn test_not_join_multi_clause_body() {
let db = in_memory_db();
db.execute(
r#"(transact [[:e1 :tag :sensitive]
[:e1 :tag :critical]
[:e2 :tag :sensitive]
[:e3 :data true]])"#,
)
.unwrap();
let result = db
.execute(
r#"(query [:find ?e
:where [?e :data true]
(not-join [?e]
[?e :tag :sensitive])])"#,
)
.unwrap();
assert_eq!(
result_count(&result),
1,
"only e3 (data=true, no sensitive tag) must appear"
);
}
#[test]
fn test_not_join_in_rule_body() {
let db = in_memory_db();
db.execute(
r#"(transact [[:alice :applied true]
[:alice :dep :dep1]
[:dep1 :status :rejected]
[:bob :applied true]])"#,
)
.unwrap();
db.execute(
r#"(rule [(eligible ?x)
[?x :applied true]
(not-join [?x]
[?x :dep ?d]
[?d :status :rejected])])"#,
)
.unwrap();
let result = db
.execute("(query [:find ?x :where (eligible ?x)])")
.unwrap();
assert_eq!(
result_count(&result),
1,
"bob must be eligible; alice has a rejected dep"
);
}
#[test]
fn test_not_join_multi_stratum_chain() {
let db = in_memory_db();
db.execute(
r#"(transact [[:alice :applied true]
[:alice :dep :dep1]
[:dep1 :blocked true]
[:bob :applied true]
[:bob :on-hold true]
[:charlie :applied true]])"#,
)
.unwrap();
db.execute(
r#"(rule [(stage1-ok ?x)
[?x :applied true]
(not-join [?x] [?x :dep ?d] [?d :blocked true])])"#,
)
.unwrap();
db.execute(
r#"(rule [(stage2-ok ?x)
[?x :applied true]
(not-join [?x] [?x :dep ?d] [?d :blocked true])
(not-join [?x] [?x :on-hold true])])"#,
)
.unwrap();
let r1 = db
.execute("(query [:find ?x :where (stage1-ok ?x)])")
.unwrap();
assert_eq!(
result_count(&r1),
2,
"stage1-ok: bob and charlie (alice excluded by blocked dep)"
);
let r2 = db
.execute("(query [:find ?x :where (stage2-ok ?x)])")
.unwrap();
assert_eq!(
result_count(&r2),
1,
"stage2-ok: only charlie; alice excluded by dep, bob excluded by on-hold"
);
}
#[test]
fn test_not_join_allows_inner_var_not_would_reject() {
let db = in_memory_db();
db.execute(
r#"(transact [[:alice :dep :dep1]
[:dep1 :blocked true]
[:bob :dep :dep2]])"#,
)
.unwrap();
let result = db
.execute(
r#"(query [:find ?x
:where [?x :dep ?_d2]
(not-join [?x]
[?x :dep ?d]
[?d :blocked true])])"#,
)
.unwrap();
assert_eq!(
result_count(&result),
1,
"bob must appear (dep not blocked); alice excluded"
);
}
#[test]
fn test_not_join_with_as_of() {
let db = in_memory_db();
db.execute("(transact [[:alice :applied true]])").unwrap(); db.execute(
r#"(transact [[:dep1 :blocked true]
[:alice :dep :dep1]])"#,
)
.unwrap();
let result_tx1 = db
.execute(
r#"(query [:find ?x
:as-of 1
:where [?x :applied true]
(not-join [?x]
[?x :dep ?d]
[?d :blocked true])])"#,
)
.unwrap();
let result_tx2 = db
.execute(
r#"(query [:find ?x
:as-of 2
:where [?x :applied true]
(not-join [?x]
[?x :dep ?d]
[?d :blocked true])])"#,
)
.unwrap();
assert_eq!(
result_count(&result_tx1),
1,
"at tx 1 alice has no dep yet, must pass not-join"
);
assert_eq!(
result_count(&result_tx2),
0,
"at tx 2 alice has a blocked dep, must be excluded"
);
}
#[test]
fn test_not_join_unbound_join_var_parse_error() {
let db = in_memory_db();
db.execute(r#"(transact [[:e1 :name "test"]])"#).unwrap();
let result = db.execute(
r#"(query [:find ?e
:where [?e :name ?n]
(not-join [?unbound] [?e :dep ?unbound])])"#,
);
assert!(
result.is_err(),
"join var ?unbound not bound by outer clause must produce a parse error"
);
}
#[test]
fn test_not_join_nested_inside_not_rejected() {
let db = in_memory_db();
db.execute("(transact [[:e1 :data true]])").unwrap();
let result = db.execute(
r#"(query [:find ?e
:where [?e :data true]
(not (not-join [?e] [?e :flag true]))])"#,
);
assert!(
result.is_err(),
"not-join nested inside not must be a parse error"
);
}
#[test]
fn test_not_join_body_with_rule_invocation_end_to_end() {
let db = in_memory_db();
db.execute(
r#"(transact [[:alice :applied true]
[:alice :status :banned]
[:bob :applied true]])"#,
)
.unwrap();
db.execute(r#"(rule [(banned ?x) [?x :status :banned]])"#)
.unwrap();
db.execute(
r#"(rule [(eligible ?x)
[?x :applied true]
(not-join [?x] (banned ?x))])"#,
)
.unwrap();
let result = db
.execute("(query [:find ?x :where (eligible ?x)])")
.unwrap();
assert_eq!(
result_count(&result),
1,
"bob must be eligible; alice excluded via banned rule in not-join body"
);
}
#[test]
fn test_not_join_body_no_matches_all_survive() {
let db = in_memory_db();
db.execute("(transact [[:e1 :active true] [:e2 :active true] [:e3 :active true]])")
.unwrap();
let result = db
.execute("(query [:find ?x :where [?x :active true] (not-join [?x] [?x :banned true])])")
.unwrap();
assert_eq!(
result_count(&result),
3,
"all three entities must survive when not-join body matches nothing"
);
}
#[test]
fn test_not_join_with_valid_at() {
let db = in_memory_db();
db.execute(
"(transact {:valid-from \"2023-01-01\" :valid-to \"2024-01-01\"} [[:alice :active true] [:bob :active true]])",
)
.unwrap();
db.execute("(transact {:valid-from \"2024-01-01\"} [[:alice :restricted true]])")
.unwrap();
let result_2023 = db
.execute("(query [:find ?x :valid-at \"2023-06-01\" :where [?x :active true] (not-join [?x] [?x :restricted true])])")
.unwrap();
assert_eq!(
result_count(&result_2023),
2,
"both alice and bob must pass not-join at 2023-06-01 (neither is restricted then)"
);
}
#[test]
fn test_not_join_negative_cycle_at_registration_rejected() {
let db = in_memory_db();
let r1 = db.execute("(rule [(p ?x) (not-join [?x] (q ?x))])");
let r2 = db.execute("(rule [(q ?x) (not-join [?x] (p ?x))])");
assert!(
r1.is_err() || r2.is_err(),
"negative cycle via not-join must be rejected at rule registration"
);
}
#[test]
fn test_not_join_coexists_with_not_in_query() {
let db = in_memory_db();
db.execute("(transact [[:alice :active true] [:alice :blocked true] [:bob :active true] [:bob :dep :dep1] [:dep1 :severity :high] [:charlie :active true]])").unwrap();
let result = db
.execute("(query [:find ?x :where [?x :active true] (not [?x :blocked true]) (not-join [?x] [?x :dep ?d] [?d :severity :high])])")
.unwrap();
assert_eq!(
result_count(&result),
1,
"only charlie must pass: alice excluded by (not), bob excluded by (not-join)"
);
}