8000 fix(not-join): :db/valid-at binds the query's valid time on the per-row path (#615) by adityamukho · Pull Request #616 · 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 @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- **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.
- **An `or` that shares no variable with the earlier clauses returns its cross product** (#610). Such an `or` — `[?x :edge ?z] (or [?p :edge :b] [?p :edge :c])` — returned no rows when no branch had `not`, `not-join` or a pseudo-attribute, because the incoming rows were replaced by the branch results. Each incoming row now joins every branch row, in queries and rule bodies. `or-join` still refuses a join variable no earlier clause binds (INT-018): it filters, it does not bind.
- **A prepared query keeps a literal `:as-of`, `:valid-at` or `:any-valid-time`** (#603). Binding a prepared query's slots dropped any `:as-of` or `:valid-at` that was not itself a slot, so `PreparedQuery::execute` and `PreparedQuery::query` ran at the current transaction time and valid now: `(query [:find ?v :as-of 1 :where [:a :n ?v]])` prepared returned the current rows, not those at transaction 1, and a prepared per-fact pseudo-attribute query with `:any-valid-time` failed with `INT-026`. Slot forms (`:as-of $tx`, `:valid-at $t`) were not affected. Present since 2026-04-04; fixed on v2.x too.
- **The wasm32 builds are tested again, and the browser layer no longer panics or drops pages.** The repo split (#231) moved the browser and WASI CI jobs to minigraf-wasm, which only builds the package, so core's own wasm tests stopped running and the wasm32 test build broke unnoticed. A new `wasm.yml` workflow runs the `wasm-bindgen` tests in headless Firefox and Chrome, runs the unit and integration tests on `wasm32-wasip1` under Wasmtime, and lints both targets with `clippy -D warnings`. Fixes found on the way: `BrowserDb` skipped any dirty page it could not read when flushing to IndexedDB and still reported success, and now returns the error (also fixed in v2.0.4, #470). The IndexedDB upgrade callback, the IndexedDB backend handle and the page-buffer lock no longer `unwrap()` (on wasm32 a panic aborts the whole instance). IndexedDB keys that are not whole page numbers are rejected instead of truncated. The nine `BrowserBufferBackend` tests used `#[test]`, which the wasm test runner never runs, and now run. File-only modules (`wal`, `storage::verify`, `storage::dir_sync`) are no longer compiled for wasm32, so the wasm32 builds have no warnings.
Expand Down
8 changes: 6 additions & 2 deletions src/query/datalog/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -507,13 +507,15 @@ pub(crate) fn rule_invocation_to_pattern(predicate: &str, args: &[EdnValue]) ->
/// - Pattern → substitute join_vars via substitute_pattern.
/// - RuleInvocation → convert to Pattern via rule_invocation_to_pattern, then substitute.
/// Rule-derived facts are already present in `storage` (accumulated) from lower strata.
/// 3. Run PatternMatcher::match_patterns on all resulting patterns against `storage`.
/// 3. Run PatternMatcher::match_patterns on all resulting patterns against `storage`,
/// with `valid_at` as the value a `:db/valid-at` pattern binds (#615).
/// 4. Any complete match → body is satisfiable → return true (reject outer binding).
pub fn evaluate_not_join(
join_vars: &[String],
clauses: &[WhereClause],
binding: &Bindings,
storage: Arc<[Fact]>,
valid_at: Value,
functions: &FunctionRegistry,
) -> bool {
// Build a partial binding containing only the join variables
Expand Down Expand Up @@ -542,7 +544,7 @@ pub fn evaluate_not_join(
.filter(|c| matches!(c, WhereClause::Expr { .. }))
.collect();

let matcher = PatternMatcher::from_slice(storage.clone());
let matcher = PatternMatcher::from_slice_with_valid_at(storage.clone(), valid_at);
let mut not_bindings: Vec<Bindings> = if substituted.is_empty() {
// Expr-only not-join body: seed with the partial binding so variables resolve.
vec![partial.clone()]
Expand Down Expand Up @@ -985,6 +987,7 @@ impl StratifiedEvaluator {
nj_clauses,
&binding,
accumulated_facts.clone(),
Value::Null,
&fn_guard,
) {
continue 'binding;
Expand Down Expand Up @@ -1411,6 +1414,7 @@ mod tests {
&[expr_clause],
&binding,
facts,
Value::Null,
&FunctionRegistry::with_builtins(),
);
assert!(
Expand Down
34 changes: 30 additions & 4 deletions src/query/datalog/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,7 @@ impl DatalogExecutor {
&join_vars,
&clauses,
derived_facts.clone(),
valid_at_value.clone(),
&registry,
);
}
Expand Down Expand Up @@ -998,6 +999,7 @@ impl DatalogExecutor {
&join_vars,
&clauses,
derived_facts.clone(),
valid_at_value.clone(),
&registry,
),
_ => bindings,
Expand Down Expand Up @@ -1252,7 +1254,14 @@ fn apply_not_join_clause(
return bindings
.into_iter()
.filter(|b| {
!evaluate_not_join(join_vars, nj_clauses, b, filtered_facts.clone(), registry)
!evaluate_not_join(
join_vars,
nj_clauses,
b,
filtered_facts.clone(),
valid_at_value.clone(),
registry,
)
})
.collect();
}
Expand Down Expand Up @@ -1307,6 +1316,7 @@ fn apply_not_join_clause(
nj_clauses,
binding,
filtered_facts.clone(),
valid_at_value.clone(),
registry,
)
})
Expand Down Expand Up @@ -1448,11 +1458,21 @@ fn apply_not_join_clause_derived(
join_vars: &[String],
nj_clauses: &[WhereClause],
derived_facts: Arc<[Fact]>,
valid_at_value: Value,
registry: &FunctionRegistry,
) -> Vec<Binding> {
bindings
.into_iter()
.filter(|b| !evaluate_not_join(join_vars, nj_clauses, b, derived_facts.clone(), registry))
.filter(|b| {
!evaluate_not_join(
join_vars,
nj_clauses,
b,
derived_facts.clone(),
valid_at_value.clone(),
registry,
)
})
.collect()
}

Expand Down Expand Up @@ -2056,8 +2076,14 @@ pub(crate) fn evaluate_branch(
}
}
for (join_vars, nj_clauses) in &not_join_clauses {
if evaluate_not_join(join_vars, nj_clauses, binding, storage.clone(), registry)
{
if evaluate_not_join(
join_vars,
nj_clauses,
binding,
storage.clone(),
branch_valid_at_value.clone(),
registry,
) {
return false;
}
}
Expand Down
108 changes: 108 additions & 0 deletions tests/not_join_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,3 +446,111 @@ fn test_not_join_coexists_with_not_in_query() {
"only charlie must pass: alice excluded by (not), bob excluded by (not-join)"
);
}

/// #615: a `:db/valid-at` pattern in a `not-join` body binds the query's valid
/// time on every path, not `nil` on the per-row one (body with an expression).
#[test]
fn test_not_join_valid_at_with_expr_binds_query_valid_time() {
let db = in_memory_db();
db.execute("(transact [[:a :n 1] [:b :n 2]])").unwrap();

let plain = db
.execute(
r#"(query [:find ?e :valid-at "2030-01-01"
:where [?e :n _] (not-join [?e] [?e :db/valid-at ?t])])"#,
)
.unwrap();
assert_eq!(result_count(&plain), 0, "exclusion-set path excludes both");

let with_expr = db
.execute(
r#"(query [:find ?e :valid-at "2030-01-01"
:where [?e :n _] (not-join [?e] [?e :db/valid-at ?t] [(> ?t 0)])])"#,
)
.unwrap();
assert_eq!(result_count(&with_expr), 0, "per-row path excludes both");
}< 8BD0 /td>

/// #615: the same inside an `or` branch, where `not-join` always runs per row.
#[test]
fn test_not_join_valid_at_in_or_branch_binds_query_valid_time() {
let db = in_memory_db();
db.execute("(transact [[:a :n 1] [:b :n 2]])").unwrap();

let result = db
.execute(
r#"(query [:find ?e :valid-at "2030-01-01"
:where [?e :n _]
(or (and [?e :n 1] (not-join [?e] [?e :db/valid-at ?t] [(> ?t 0)]))
(and [?e :n 2] (not-join [?e] [?e :db/valid-at ?t] [(> ?t 0)])))])"#,
)
.unwrap();
assert_eq!(result_count(&result), 0, "or-branch not-join excludes both");
}

/// #615: the same in a query that invokes a rule (rules query path).
#[test]
fn test_not_join_valid_at_in_rule_query_binds_query_valid_time() {
let db = in_memory_db();
db.execute("(transact [[:a :n 1] [:b :n 2]])").unwrap();
db.execute("(rule [(numbered ?e) [?e :n _]])").unwrap();

let result = db
.execute(
r#"(query [:find ?e :valid-at "2030-01-01"
:where (numbered ?e) (not-join [?e] [?e :db/valid-at ?t])])"#,
)
.unwrap();
assert_eq!(
result_count(&result),
0,
"rule-query not-join excludes both"
);

let with_expr = db
.execute(
r#"(query [:find ?e :valid-at "2030-01-01"
:where (numbered ?e) (not-join [?e] [?e :db/valid-at ?t] [(> ?t 0)])])"#,
)
.unwrap();
assert_eq!(
result_count(&with_expr),
0,
"rule-query not-join with expr excludes both"
);
}

/// #615: a `not-join` whose `?e` only an `or` binds runs after the `or`, on
/// the rules query path's deferred pass, and binds the query's valid time too.
#[test]
fn test_not_join_valid_at_deferred_in_rule_query_binds_query_valid_time() {
let db = in_memory_db();
db.execute("(transact [[:a :n 1] [:b :n 2]])").unwrap();
db.execute("(rule [(numbered ?e) [?e :n _]])").unwrap();

let result = db
.execute(
r#"(query [:find ?x ?e :valid-at "2030-01-01"
:where (numbered ?x)
(or [?e :n 1] [?e :n 2])
707F (not-join [?e] [?e :db/valid-at ?t] [(> ?t 0)])])"#,
)
.unwrap();
assert_eq!(
result_count(&result),
0,
"deferred not-join excludes every ?e"
);
}

/// A `not-join` body holding only an expression filters on the join variables.
#[test]
fn test_not_join_expression_only_body() {
let db = in_memory_db();
db.execute("(transact [[:a :n 1] [:b :n 2]])").unwrap();

let result = db
.execute("(query [:find ?e :where [?e :n ?v] (not-join [?v] [(> ?v 1)])])")
.unwrap();
assert_eq!(result_count(&result), 1, "only :a has ?v <= 1");
}
Loading
0