diff --git a/CHANGELOG.md b/CHANGELOG.md index d26b31639..31808fbe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/query/datalog/evaluator.rs b/src/query/datalog/evaluator.rs index 6f0aae6fc..43da454de 100644 --- a/src/query/datalog/evaluator.rs +++ b/src/query/datalog/evaluator.rs @@ -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 @@ -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 = if substituted.is_empty() { // Expr-only not-join body: seed with the partial binding so variables resolve. vec![partial.clone()] @@ -985,6 +987,7 @@ impl StratifiedEvaluator { nj_clauses, &binding, accumulated_facts.clone(), + Value::Null, &fn_guard, ) { continue 'binding; @@ -1411,6 +1414,7 @@ mod tests { &[expr_clause], &binding, facts, + Value::Null, &FunctionRegistry::with_builtins(), ); assert!( diff --git a/src/query/datalog/executor.rs b/src/query/datalog/executor.rs index 16eaeb5b3..062c5abca 100644 --- a/src/query/datalog/executor.rs +++ b/src/query/datalog/executor.rs @@ -953,6 +953,7 @@ impl DatalogExecutor { &join_vars, &clauses, derived_facts.clone(), + valid_at_value.clone(), ®istry, ); } @@ -998,6 +999,7 @@ impl DatalogExecutor { &join_vars, &clauses, derived_facts.clone(), + valid_at_value.clone(), ®istry, ), _ => bindings, @@ -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(); } @@ -1307,6 +1316,7 @@ fn apply_not_join_clause( nj_clauses, binding, filtered_facts.clone(), + valid_at_value.clone(), registry, ) }) @@ -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 { 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() } @@ -2056,8 +2076,14 @@ pub(crate) fn evaluate_branch( } } for (join_vars, nj_clauses) in ¬_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; } } diff --git a/tests/not_join_test.rs b/tests/not_join_test.rs index 07f339430..62c0810d9 100644 --- a/tests/not_join_test.rs +++ b/tests/not_join_test.rs @@ -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"); +} + +/// #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]) + (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"); +}