minigraf 1.0.0

Zero-config, single-file, embedded graph database with bi-temporal Datalog queries
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
//! Integration tests for Phase 7.1b: not-join (existential negation).

use minigraf::{Minigraf, OpenOptions, QueryResult};

fn in_memory_db() -> Minigraf {
    OpenOptions::new().open_memory().unwrap()
}

/// Helper: extract result count from a QueryResult.
fn result_count(r: &QueryResult) -> usize {
    match r {
        QueryResult::QueryResults { results, .. } => results.len(),
        _ => panic!("expected QueryResults"),
    }
}

/// 1. Basic not-join: single join var, inner var existentially quantified.
///
/// alice has a blocked dep → excluded; bob has no deps → included.
#[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();

    // Only bob passes the not-join (alice has a blocked dep).
    assert_eq!(
        result_count(&result),
        1,
        "only bob must pass not-join; alice is excluded"
    );
}

/// 2. Multiple join vars.
///
/// u1 has restricted role r1; u2 has unrestricted role r2 → only u2 returned.
#[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();

    // u1's role r1 is restricted → excluded; u2's role r2 is not → included.
    assert_eq!(
        result_count(&result),
        1,
        "u2 with unrestricted role must appear; u1 with restricted role excluded"
    );
}

/// 3. Multi-clause not-join body (conjunction of patterns).
///
/// Only e3 has :data true and no :tag :sensitive.
#[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();

    // Only e3 has :data true and is not tagged :sensitive.
    assert_eq!(
        result_count(&result),
        1,
        "only e3 (data=true, no sensitive tag) must appear"
    );
}

/// 4. not-join in a rule body.
///
/// alice has a rejected dep → ineligible; bob has no deps → eligible.
#[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();

    // Only bob is eligible (alice's dep is rejected).
    assert_eq!(
        result_count(&result),
        1,
        "bob must be eligible; alice has a rejected dep"
    );
}

/// 5. not-join in a two-stage rule chain.
///
/// LIMITATION: When rule B positively invokes rule A (both in the same stratum)
/// and rule A contains a not-join, the StratifiedEvaluator processes all mixed
/// rules within a stratum in a single pass in declaration order.  Because
/// `approved` is declared before `eligible`, its positive-pattern match for
/// `[?x :eligible ?_rule_value]` finds no facts yet, producing an empty result.
///
/// A proper fix would require either:
///   (a) iterating mixed rules to a fixed-point within each stratum, or
///   (b) giving `approved` a higher stratum via a negative edge (which isn't
///       present here — only a positive dep exists).
///
/// What DOES work: two independent not-join rules that each operate directly on
/// base facts (no positive dependency between the two rules).
///
/// Stage-1 rule: (stage1-ok ?x) :- [?x :applied true],
///                                  (not-join [?x] [?x :dep ?d] [?d :blocked true])
/// Stage-2 rule: (stage2-ok ?x) :- [?x :applied true],
///                                  (not-join [?x] [?x :dep ?d] [?d :blocked true]),
///                                  (not-join [?x] [?x :on-hold true])
///
/// alice: has blocked dep → excluded at both stages
/// bob:   on-hold → excluded only at stage 2
/// charlie: neither → passes both
#[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();

    // stage1-ok: bob and charlie (alice has blocked dep)
    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)"
    );

    // stage2-ok: only charlie (alice excluded by dep, bob excluded by on-hold)
    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"
    );
}

/// 6. not-join allows inner vars not bound by outer clauses (unlike plain `not`).
///
/// alice's dep is blocked → excluded; bob's dep is not blocked → included.
#[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();

    // ?d is an inner (existentially quantified) variable inside not-join.
    // With plain (not ...) this would be a safety error; not-join allows it.
    let result = db
        .execute(
            r#"(query [:find ?x
                       :where [?x :dep ?_d2]
                              (not-join [?x]
                                [?x :dep ?d]
                                [?d :blocked true])])"#,
        )
        .unwrap();

    // bob's dep is not blocked → passes; alice's dep is blocked → excluded.
    assert_eq!(
        result_count(&result),
        1,
        "bob must appear (dep not blocked); alice excluded"
    );
}

/// 7. not-join combined with :as-of time travel.
///
/// At tx 1 alice has no dep yet → passes not-join.
/// At tx 2 alice's dep is present and blocked → excluded.
#[test]
fn test_not_join_with_as_of() {
    let db = in_memory_db();
    db.execute("(transact [[:alice :applied true]])").unwrap(); // tx 1
    db.execute(
        r#"(transact [[:dep1 :blocked true]
                      [:alice :dep :dep1]])"#,
    )
    .unwrap(); // tx 2

    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"
    );
}

/// 8. Unbound join variable rejected at parse time.
///
/// ?unbound appears in the join-vars list but is never bound by any outer clause.
#[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"
    );
}

/// 9. Nested not-join inside not is rejected at parse time.
#[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"
    );
}

/// 10. not-join body contains a RuleInvocation — derived rule facts correctly negated end-to-end.
///
/// Rule: (banned ?x) :- [?x :status :banned]
/// Rule: (eligible ?x) :- [?x :applied true], (not-join [?x] (banned ?x))
/// alice: applied + banned → NOT eligible; bob: applied only → eligible.
#[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();

    // Only bob is eligible (alice is banned via the rule).
    assert_eq!(
        result_count(&result),
        1,
        "bob must be eligible; alice excluded via banned rule in not-join body"
    );
}

/// 11. not-join where no entities match the body — all outer bindings survive.
#[test]
fn test_not_join_body_no_matches_all_survive() {
    let db = in_memory_db();
    // Three entities with :active true, but none have :banned true
    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();
    // All three survive because the not-join body matches nothing.
    // Entity IDs are UUIDs in the result, not keyword strings, so we count results.
    assert_eq!(
        result_count(&result),
        3,
        "all three entities must survive when not-join body matches nothing"
    );
}

/// 12. not-join combined with :valid-at valid-time query.
#[test]
fn test_not_join_with_valid_at() {
    let db = in_memory_db();
    // alice: active during 2023, restricted during 2024
    // bob: active during 2023, no restrictions
    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();
    // At 2023-06-01: both active, neither restricted -> both pass.
    // Entity IDs are UUIDs in the result, not keyword strings, so we count results.
    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)"
    );
}

/// 13. Negative cycle via not-join at rule registration is rejected.
#[test]
fn test_not_join_negative_cycle_at_registration_rejected() {
    let db = in_memory_db();
    // p :- (not-join [?x] (q ?x))
    // q :- (not-join [?x] (p ?x))
    // This is a negative cycle; the second rule registration should fail.
    let r1 = db.execute("(rule [(p ?x) (not-join [?x] (q ?x))])");
    let r2 = db.execute("(rule [(q ?x) (not-join [?x] (p ?x))])");
    // At least one of the registrations must fail with a stratification error
    assert!(
        r1.is_err() || r2.is_err(),
        "negative cycle via not-join must be rejected at rule registration"
    );
}

/// 14. not and not-join coexist in the same query body.
#[test]
fn test_not_join_coexists_with_not_in_query() {
    let db = in_memory_db();
    // Find active entities that are not globally-blocked AND have no blocked dependency
    // alice: active, globally-blocked -> excluded by (not)
    // bob: active, has blocked dep -> excluded by (not-join)
    // charlie: active, no restrictions -> passes both
    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();
    // Entity IDs are UUIDs in the result, not keyword strings, so we count results.
    // Only charlie passes both (not) and (not-join) filters.
    assert_eq!(
        result_count(&result),
        1,
        "only charlie must pass: alice excluded by (not), bob excluded by (not-join)"
    );
}