8000 fix(query): a pseudo-attribute reads the nearest earlier pattern with its entity (#594) by adityamukho · Pull Request #602 · 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
4 changes: 3 additions & 1 deletion CHANGELOG.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ cargo run < demos/demo_negation.txt
- `persistent_facts.rs`: `PersistentFactStorage` — v8 save/load, auto-migration v7→v8

3. **`src/query/datalog/`** — Datalog engine
- `parser.rs`: EDN/Datalog parser — `transact`, `retract`, `query`, `rule`, `:as-of`, `:valid-at`, `not`, `not-join`
- `parser.rs`: EDN/Datalog parser — `transact`, `retract`, `query`, `rule`, `:as-of`, `:valid-at`, `not`, `not-join`; `resolve_pseudo_sources` names each per-fact pseudo-attribute's source (nearest earlier same-entity pattern, lexical scope) and its hidden metadata variable `?#<n>.<attr>`, else PRS-081 (#594)
- `executor.rs`: Query executor — temporal filter (tx-time → net-assert → valid-time), not/not-join post-filter
- `matcher.rs`: Pattern matching with variable unification; `edn_to_value`, `edn_to_entity_id`
- `evaluator.rs`: `RecursiveEvaluator` (semi-naive), `StratifiedEvaluator`, `evaluate_not_join`
Expand All @@ -139,7 +139,7 @@ cargo run < demos/demo_negation.txt
- `prepared.rs`: `BindValue`, `PreparedQuery` — parse-once/execute-many with named `$slot` bind slots; `prepare_query`, `substitute`
- `magic_sets.rs`: Magic-sets rewriting for demand-driven recursive rule evaluation (not applied to rules with `not`/`not-join`)

3a. **`src/query/engine/`** — Streaming query engine (#473), beside the executor until #551: `plan.rs` (which queries it runs: patterns and expression clauses in `optimizer::plan` order; a pseudo-attribute pattern reads the metadata column of the last earlier pattern that matched its entity, chosen per row, or joins as `[e _ _]` when none runs before it, as `:db/valid-at` always does; plain `:find` variables, today), `scan.rs` (one pattern over committed key ranges via `CommittedReader::key_source`/`live_keys`, AVET ranges for cross-kind constants, then the pending range by whole runs; one entity per probe for a join), `join.rs` (`IndexNestedLoopJoin` when the pattern's entity is bound, `HashJoin` otherwise; `JoinKey`: floats by IEEE `==`, a keyword equals a ref to the entity it names), `filter.rs` (`Filter`/`Bind` and pseudo-attribute steps over batches; a `Bind` on a bound slot keeps the rows where the result is join-equal, #596), `cell.rs`/`batch.rs` (id cells in columnar batches of 1,024), `ids.rs` (DICT and provisional ids, `keyword_eid`), `project.rs` (ids → values for returned rows only), `oracle.rs` (with `cfg(test)` or the `test-oracle` feature, every engine query also runs on the executor and must agree). `mod.rs`: `Operator` (`next_batch`/`close`/`counters`), `Stream` (the operator tree over an owned `Snapshot`, pulled one batch at a time). `DatalogExecutor::open` / `execute_query` try it first: a cursor gets the `Stream` (`Answer::Stream`), `execute` drains it.
3a. **`src/query/engine/`** — Streaming query engine (#473), beside the executor until #551: `plan.rs` (which queries it runs: patterns and expression clauses in `optimizer::plan` order; a per-fact pseudo-attribute pattern reads the metadata column its source's scan fills (the hidden variable the parser named, #594), and `:db/valid-at` joins as `[e _ _]`; plain `:find` variables, today), `scan.rs` (one pattern over committed key ranges via `CommittedReader::key_source`/`live_keys`, AVET ranges for cross-kind constants, then the pending range by whole runs; one entity per probe for a join), `join.rs` (`IndexNestedLoopJoin` when the pattern's entity is bound, `HashJoin` otherwise; `JoinKey`: floats by IEEE `==`, a keyword equals a ref to the entity it names), `filter.rs` (`Filter`/`Bind` and pseudo-attribute steps over batches; a `Bind` on a bound slot keeps the rows where the result is join-equal, #596), `cell.rs`/`batch.rs` (id cells in columnar batches of 1,024), `ids.rs` (DICT and provisional ids, `keyword_eid`), `project.rs` (ids → values for returned rows only), `oracle.rs` (with `cfg(test)` or the `test-oracle` feature, every engine query also runs on the executor and must agree). `mod.rs`: `Operator` (`next_batch`/`close`/`counters`), `Stream` (the operator tree over an owned `Snapshot`, pulled one batch at a time). `DatalogExecutor::open` / `execute_query` try it first: a cursor gets the `Stream` (`Answer::Stream`), `execute` drains it.

4. **`src/temporal.rs`** — UTC-only timestamp parsing (avoids chrono CVE GHSA-wcg3-cvx6-7396)

Expand Down Expand Up @@ -204,7 +204,7 @@ Auto-migrates v7 → v8 on open (spec §9, with a backup meta page). v1–v6 are

## Test Coverage

**1514 tests** (1504 passing, 10 ignored; unit + integration + doc).
**1532 tests** (1522 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 1514 tests
cargo test # run 1532 tests
cargo run < demos/demo_recursive.txt # recursive rules demo
```

Expand Down
17 changes: 17 additions & 0 deletions docs/ERROR_REFERENCE.md
8000
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ with no `CodedError` anywhere in its chain.
| PRS-078 | Query rejects unexpected trailing argument(s) | Parser |
| PRS-079 | Rule rejects unexpected trailing argument(s) | Parser |
| PRS-080 | :find variable not bound in :where | Parser |
| PRS-081 | Pseudo-attribute has no source pattern | Parser |
| QRY-001 | Invalid entity | Query Execution |
| QRY-002 | Attribute must be a keyword | Query Execution |
| QRY-003 | Cannot transact a pseudo-attribute | Query Execution |
Expand Down Expand Up @@ -1579,6 +1580,22 @@ $<4097-char-slot-name>
```
*`?n` is never bound; use `?name`*

### PRS-081 Pseudo-attribute has no source pattern

**Error text**: `{} on {} has no pattern before it, with the same entity, that reads a stored attribute; add one (e.g. [{} ?a _]) before it`

**Cause**: A per-fact pseudo-attribute pattern (`:db/valid-from`, `:db/valid-to`, `:db/tx-count`, `:db/tx-id`) reads the metadata of one fact: the fact matched by its *source*, the nearest pattern before it in the query text with the same entity term and a stored attribute (a keyword, or a variable such as `[?e ?a ?v]`). The search starts in the pseudo-attribute's own clause list and continues outwards, before each enclosing `not`, `or`, `not-join` or `or-join`; it crosses a `not-join` or `or-join` only for an entity variable listed in its join variables, and it stops at a rule body, which never reads its caller's patterns. A pattern after the pseudo-attribute never counts, a sibling `or` branch is never searched, and a `_` entity never has a source. Entity terms are the same when they are the same variable, the same `$slot`, or constants naming the same entity (`:alice` and its UUID). Before v3.0.0 such a pattern matched every visible fact of the entity instead, and which fact a pseudo-attribute read could depend on the plan.

**Resolution**:
- Put a pattern with the same entity term before the pseudo-attribute pattern.
- To read the metadata of every fact of an entity, write `[?e ?a _]` before it.

**Example**:
```datalog
(query [:find ?t :any-valid-time :where [?e :db/tx-count ?t] [?e :person/name "Alice"]])
```
*Move `[?e :person/name "Alice"]` before `[?e :db/tx-count ?t]`*

---

## QRY — Query Execution Errors
Expand Down
3 changes: 2 additions & 1 deletion docs/TEST_COVERAGE.md

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ pub(crate) enum ErrorCode {
Prs078,
Prs079,
Prs080,
Prs081,
Qry001,
Qry002,
Qry003,
Expand Down Expand Up @@ -765,6 +766,12 @@ pub(crate) const REGISTRY: &[(ErrorCode, &str, &str, ErrorCategory)] = &[
":find variable {} not bound in :where",
ErrorCategory::Parser,
),
(
ErrorCode::Prs081,
"PRS-081",
"{} on {} has no pattern before it, with the same entity, that reads a stored attribute; add one (e.g. [{} ?a _]) before it",
ErrorCategory::Parser,
),
(
ErrorCode::Qry001,
"QRY-001",
Expand Down Expand Up @@ -1984,6 +1991,7 @@ mod tests {
| ErrorCode::Prs078
| ErrorCode::Prs079
| ErrorCode::Prs080
| ErrorCode::Prs081
| ErrorCode::Qry001
| ErrorCode::Qry002
| ErrorCode::Qry003
Expand Down Expand Up @@ -2210,6 +2218,7 @@ mod tests {
ErrorCode::Prs078,
ErrorCode::Prs079,
ErrorCode::Prs080,
ErrorCode::Prs081,
ErrorCode::Qry001,
ErrorCode::Qry002,
ErrorCode::Qry003,
Expand Down
7 changes: 5 additions & 2 deletions src/query/datalog/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -442,8 +442,11 @@ pub fn substitute_pattern(pattern: &Pattern, binding: &Bindings) -> Pattern {
entity: substitute_value(&pattern.entity, binding),
attribute,
value: substitute_value(&pattern.value, binding),
valid_from: pattern.valid_from,
valid_to: pattern.valid_to,
pseudo_source: pattern
.pseudo_source
.as_ref()
.map(|v| substitute_value(v, binding)),
..pattern.clone()
}
}

Expand Down
76 changes: 71 additions & 5 deletions src/query/datalog/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1143,9 +1143,12 @@ fn apply_not_clause(
valid_at_value: Value,
registry: &FunctionRegistry,
) -> Vec<Binding> {
// Expressions, and pseudo-attributes that may read an outer source
// (#594), need the outer row: no exclusion set matched without it.
let has_expr = not_body
.iter()
.any(|c| matches!(c, WhereClause::Expr { .. }));
.any(|c| matches!(c, WhereClause::Expr { .. }))
|| reads_metadata(not_body);
let patterns: Vec<Pattern> = not_body
.iter()
.filter_map(|c| match c {
Expand Down Expand Up @@ -1175,8 +1178,7 @@ fn apply_not_clause(
let exclusion_set: std::collections::HashSet<Vec<(String, Value)>> = matcher
.match_patterns(&patterns)
.into_iter()
.map(|mut b| {
b.retain(|k, _| !k.starts_with("__f"));
.map(|b| {
let mut kv: Vec<(String, Value)> = b
.into_iter()
.map(|(k, v)| (k, normalize_value(&v)))
Expand Down Expand Up @@ -1232,9 +1234,12 @@ fn apply_not_join_clause(
valid_at_value: Value,
registry: &FunctionRegistry,
) -> Vec<Binding> {
// Expressions, and pseudo-attributes that may read an outer source
// (#594), need the outer row: no exclusion set matched without it.
let has_expr = nj_clauses
.iter()
.any(|c| matches!(c, WhereClause::Expr { .. }));
.any(|c| matches!(c, WhereClause::Expr { .. }))
|| reads_metadata(nj_clauses);
let patterns: Vec<Pattern> = nj_clauses
.iter()
.filter_map(|c| match c {
Expand Down Expand Up @@ -1421,6 +1426,20 @@ fn apply_not_clause_derived(
.collect()
}

/// Whether `clauses`, or any clause list inside them, holds a per-fact
/// pseudo-attribute pattern (#594), which reads metadata a source bound,
/// possibly outside `clauses`.
pub(crate) fn reads_metadata(clauses: &[WhereClause]) -> bool {
clauses.iter().any(|c| match c {
WhereClause::Pattern(p) => p.pseudo_source.is_some(),
WhereClause::Not(body) | WhereClause::NotJoin { clauses: body, .. } => reads_metadata(body),
WhereClause::Or(branches) | WhereClause::OrJoin { branches, .. } => {
branches.iter().any(|b| reads_metadata(b))
}
WhereClause::Expr { .. } | WhereClause::RuleInvocation { .. } => false,
})
}

/// Filter `bindings` through a single `(not-join [...] ...)` body for the rules query
/// path — no exclusion-set fast path here (matches the pre-#248 post-filter, which
/// never had one for this path either).
Expand Down Expand Up @@ -2076,9 +2095,12 @@ pub(crate) fn apply_or_clauses(
// If any branch contains Not/NotJoin clauses (which need bound variables
// from the outer scope to evaluate correctly), fall back to the classic
// seeded-branch evaluation to preserve correctness.
// So does a branch whose pseudo-attribute may read an outer
// source's metadata (#594).
let any_branch_has_not = sorted_or_branches.iter().any(|b| {
b.iter()
.any(|c| matches! 10654 (c, WhereClause::Not(_) | WhereClause::NotJoin { .. }))
|| reads_metadata(b)
});

if any_branch_has_not {
Expand Down Expand Up @@ -2309,6 +2331,50 @@ pub(crate) fn apply_or_clauses(
}
}

// A branch whose pseudo-attribute may read an outer source's
// metadata (#594) needs it from the outer row: evaluate the
// branches per row, seeded with its join variables only (the
// others are fresh inside), and keep the distinct rows some
// branch matches, as the joined path below does.
if sorted_oj_branches.iter().any(|b| reads_metadata(b)) {
let mut seen: std::collections::HashSet<Vec<(String, Value)>> =
std::collections::HashSet::new();
let mut result: Vec<Binding> = Vec::new();
for incoming in &bindings {
let seed: Binding = join_vars
.iter()
.filter_map(|v| incoming.get(v).map(|val| (v.clone(), val.clone())))
.collect();
let mut matched = false;
for branch in &sorted_oj_branches {
if !evaluate_branch(
branch,
vec![seed.clone()],
storage.clone(),
rules,
as_of.clone(),
valid_at.clone(),
registry,
)?
.is_empty()
{
matched = true;
break;
}
}
let mut key: Vec<_> = incoming
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
key.sort_unstable_by(|a, b| a.0.cmp(&b.0));
if matched && seen.insert(key) {
result.push(incoming.clone());
}
}
bindings = result;
continue;
}

let empty_seed: Vec<Binding> = vec![HashMap::new()];
let mut seen_proj: std::collections::HashSet<Vec<(String, Value)>> =
std::collections::HashSet::new();
Expand Down Expand Up @@ -5560,7 +5626,7 @@ mod expr_eval_tests {
// Lines 348-350: hard-error guard in execute_query_with_rules
// Per-fact pseudo-attr without :any-valid-time in a rules query
let err_cmd = parse_datalog_command(
"(query [:find ?e ?vf :where (tagged ?e) [?e :db/valid-from ?vf]])",
"(query [:find ?e ?vf :where (tagged ?e) [?e ?a _] [?e :db/valid-from ?vf]])",
)
.expect("err query parse failed");
let err_result = executor.execute(err_cmd);
Expand Down
Loading
Loading
0