8000 fix(rules): a pseudo-attribute in a rule body reads the stored fact (#661) by adityamukho · Pull Request #665 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **A pseudo-attribute in a rule body reads the stored fact** (#661). In a rule without `not`, `not-join`, `or` or `or-join`, `:db/valid-from`, `:db/valid-to`, `:db/tx-count` and `:db/tx-id` read the rule evaluator's working copy of the fact: valid-from was the time the query ran, valid-to forever, and the tx-count and tx-id those of the copy. With `[:a :edge :b]` valid from 2020-01-01 to 2021-01-01, `(rule [(vf ?x ?t) [?x :edge ?y] [?x :db/valid-from ?t]])` returned the query's time for `:a`; it now returns 2020-01-01, as the same pattern in a query body does. Stored data was never affected. Present since v0.15.0; fixed on v2.x too.
- **Rule queries with a literal argument return every row** (#608). Magic-sets rewriting restricts a rule to the facts its callers demand, but it took each rule's bound arguments from the first call it saw and assumed every other call matched. A call that did not — the same rule called with free arguments elsewhere in the query, a call inside `or` or `not`, a call from a rule the query calls with free arguments, or a rule-body call whose argument nothing bound yet — either failed with `INT-022` (`(chain ?x :c)` with `(chain ?x ?y) :- (link ?x ?m) (chain ?m ?y)`) or silently returned too few rows (`(link ?x :c) (link ?p ?q)` returned only the `:c` edges for `?p ?q`). A query with both arguments literal next to another rule call also lost its rows. The demand on each rule is now the one every call site supplies, and a rule some call site reads in full is evaluated in full. Present since v1.2.0.
- **A `:db/valid-at` pattern in a `not-join` body binds the query's valid time on every path** (#615). When the body also held an expression, sat in an `or` branch, or ran in a query that invokes a rule, the body bound `nil`, so `(not-join [?e] [?e :db/valid-at ?t] [(> ?t 0)])` never matched and excluded nothing; without the expression the same body excluded every entity. It now binds the query's `:valid-at` value (or the current time) as the other paths do. Present since v0.13.1; v2.x is affected too.
- **A `:db/valid-at` pattern in a rule definition binds the query's valid time** (#617). The rule evaluators never received it, so in a rule body the pattern bound `nil` and the query failed with `INT-022`; in a rule's `not` / `not-join` body it bound `nil`, so the negation never matched on it; and in a rule's `or` branch it bound the current time instead of the query's `:valid-at`. Rule bodies, their negation bodies and `or` branches now bind the query's `:valid-at` value, the current time without one, and `nil` under `:any-valid-time`, as patterns outside rules do. v2.x is affected too.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ Auto-migrates v7 → v8 on open (spec §9, with a backup meta page). v1–v6 are

## Test Coverage

**1680 tests** (1670 passing, 10 ignored; unit + integration + doc).
**1681 tests** (1671 passing, 10 ignored; unit + integration + doc).
See `docs/TEST_COVERAGE.md` for the full per-file breakdown.

**Testing conventions** — see the Testing Conventions section below before writing any tests.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ let r2 = pq.execute(&[("tx", BindValue::TxCount(2)), ("entity", BindValue::Entit

```bash
cargo run # interactive Datalog REPL
cargo test # run 1680 tests
cargo test # run 1681 tests
cargo run < demos/demo_recursive.txt # recursive rules demo
```

Expand Down
2 changes: 1 addition & 1 deletion docs/TEST_COVERAGE.md

Large diffs are not rendered by default.

11 changes: 4 additions & 7 deletions src/query/datalog/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,10 @@ impl RecursiveEvaluator {
// Create storage for derived facts
let derived = FactStorage::new();

// Add base facts to derived storage
for fact in &base_facts {
derived.transact(
vec![(fact.entity, fact.attribute.clone(), fact.value.clone())],
None,
)?;
}
// Add base facts to derived storage as stored: a pseudo-attribute in a
// rule body reads their valid time and transaction (#661).
derived.load_facts(base_facts.iter().cloned())?;
derived.restore_tx_counter()?;

// Track facts we've seen (for delta computation) using BTreeSet with canonical encoding.
let mut seen_facts: BTreeSet<(uuid::Uuid, String, Vec<u8>)> = base_facts
Expand Down
59 changes: 59 additions & 0 deletions tests/recursive_rules_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -622,3 +622,62 @@ fn test_rule_or_branch_valid_at_binds_query_valid_time() {
"one row per entity"
);
}

/// #661: a per-fact pseudo-attribute in a rule body reads the stored fact's
/// metadata, as it does in a query body. Positive rules used to read the
/// evaluator's working copy: valid-from was the time the query ran,
/// valid-to forever, and tx-count / tx-id the copy's.
#[test]
fn test_pseudo_attributes_in_rule_bodies_read_the_stored_fact() {
let db = Minigraf::in_memory().unwrap();
exec(
&db,
r#"(transact {:valid-from "2020-01-01T00:00:00Z" :valid-to "2021-01-01T00:00:00Z"} [[:a :edge :b]])"#,
);
exec(&db, "(transact [[:b :edge :c]])");
exec(&db, "(transact [[:c :edge :d]])");
let direct = |attr: &str| {
let mut rows = result_rows(exec(
&db,
&format!(
"(query [:find ?x ?m :valid-at :any-valid-time :where [?x :edge ?y] [?x {attr} ?m]])"
),
));
rows.sort();
rows
};
for (i, attr) in [
":db/valid-from",
":db/valid-to",
":db/tx-count",
":db/tx-id",
]
.into_iter()
.enumerate()
{
// A positive rule, and a recursive one whose later iterations read
// the base facts again.
exec(
&db,
&format!("(rule [(m{i} ?x ?m) [?x :edge ?y] [?x {attr} ?m]])"),
);
exec(
&db,
&format!("(rule [(r{i} ?x ?m) [?x :edge ?y] [?x {attr} ?m]])"),
);
exec(
&db,
&format!("(rule [(r{i} ?x ?m) [?x :edge ?y] [?x {attr} ?m] (r{i} ?y ?n)])"),
);
let expected = direct(attr);
assert_eq!(expected.len(), 3, "three :edge facts");
for rule in [format!("m{i}"), format!("r{i}")] {
let mut rows = result_rows(exec(
&db,
&format!("(query [:find ?x ?m :valid-at :any-valid-time :where ({rule} ?x ?m)])"),
));
rows.sort();
assert!(rows == expected, "a rule body reads the stored metadata");
}
}
}
Loading
0