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
23 changes: 18 additions & 5 deletions src/query/datalog/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ impl DatalogExecutor {
/// selective, but multi-pattern joins still need attribute candidates for patterns that do not
/// bind an entity. If any pattern has neither a bound entity nor a bound attribute, or if the
/// distinct lookup count exceeds `threshold`, returns `None` to use a full scan. Otherwise
/// returns `Some(facts)`, deduplicated by `(entity, attribute, tx_count)`.
/// returns `Some(facts)`, deduplicated by `(entity, attribute, tx_count, asserted)`.
fn selective_fact_fetch(&self, patterns: &[Pattern], threshold: usize) -> Option<Vec<Fact>> {
use std::collections::HashSet;

Expand Down Expand Up @@ -405,15 +405,23 @@ impl DatalogExecutor {
return None;
}

// Dedup key: (entity uuid, attribute string, tx_count) — avoids Value debug formatting.
let mut seen: HashSet<(uuid::Uuid, String, u64)> = HashSet::new();
// Dedup key: (entity uuid, attribute string, tx_count, asserted) — avoids Value debug
// formatting. Including `asserted` ensures that a retraction and an assertion committed in
// the same WriteTransaction (same tx_count) are both retained, since they differ only by
// the `asserted` flag.
let mut seen: HashSet<(uuid::Uuid, String, u64, bool)> = HashSet::new();
let mut all_facts: Vec<Fact> = Vec::new();

for uid in &entity_ids {
match self.storage.get_facts_by_entity(uid) {
Ok(facts) => {
for fact in facts {
let key = (fact.entity, fact.attribute.clone(), fact.tx_count);
let key = (
fact.entity,
fact.attribute.clone(),
fact.tx_count,
fact.asserted,
);
if seen.insert(key) {
all_facts.push(fact);
}
Expand All @@ -427,7 +435,12 @@ impl DatalogExecutor {
match self.storage.get_facts_by_attribute(attr) {
Ok(facts) => {
for fact in facts {
let key = (fact.entity, fact.attribute.clone(), fact.tx_count);
let key = (
fact.entity,
fact.attribute.clone(),
fact.tx_count,
fact.asserted,
);
if seen.insert(key) {
all_facts.push(fact);
}
Expand Down
30 changes: 30 additions & 0 deletions tests/retraction_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,36 @@ fn test_retraction_rule_as_of_before_sees_fact() {
);
}

// ── Test 7: Atomic retract+transact in WriteTransaction (issue #285) ─────────
// When both a retraction and a new assertion share the same tx_count (because they
// are committed together), selective_fact_fetch must not drop the assertion due to
// dedup on (entity, attribute, tx_count).

#[test]
fn test_write_transaction_retract_and_transact_same_attr_query_sees_new_value() {
let db = Minigraf::in_memory().unwrap();

db.execute("(transact [[:order-42 :fsm/state :awaiting-payment]])")
.unwrap();

let mut tx = db.begin_write().unwrap();
tx.execute("(retract [[:order-42 :fsm/state :awaiting-payment]])")
.unwrap();
tx.execute("(transact [[:order-42 :fsm/state :paid]])")
.unwrap();
tx.commit().unwrap();

let result = db
.execute("(query [:find ?order ?state :where [?order :fsm/state ?state]])")
.unwrap();
match result {
minigraf::QueryResult::QueryResults { results, .. } => {
assert_eq!(results.len(), 1, "expected exactly one result row");
}
_ => panic!("expected QueryResults"),
}
}

// ── Test 6: Retraction + recursive rule — as-of after retraction ──────────────

#[test]
Expand Down
Loading