8000 fix(query): expression = / != equate a keyword with a ref to its entity (#744) — backport by adityamukho · Pull Request #749 · 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 @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **An expression's `=` and `!=` equate a keyword with a ref to the entity it names** (#744), backported from v3.0.0. Patterns and joins treat the keyword `:bob` as equal to a ref to the entity `:bob` names, but `[(= ?g :bob)]` compared the two values as they are, so with `?g` bound to that ref it kept no row and `[(!= ?g :bob)]` kept the row: the same question answered differently by how the query was written, with no error. `=` and `!=` now compare as joins do, in queries, rule bodies, `not` clauses and binding expressions, against a keyword constant, a keyword value or a bound `Val(Ref)`. A keyword still equals only the same keyword, and `<`, `>`, `<=` and `>=` on a keyword or a ref are still a type mismatch (no row).
- **`checkpoint` inside a `WriteTransaction` returns `INT-001` instead of deadlocking** (#734), backported from v3.0.0. Called on a thread that holds a `WriteTransaction`, `checkpoint` waited on the write lock that the transaction already held, so the thread hung. It now returns `INT-001`, as `execute` and `begin_write` already did, and the transaction stays usable. `prepare` and `PreparedQuery::execute` make the same check: they could not deadlock, but they ran inside the transaction while `execute` refused. Present since `WriteTransaction` was added.

## v2.0.10 — 2026-10-23
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md 8000
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ Auto-migrates v1/v2/v3/v4/v5/v6 → v7 on open/checkpoint.

## Test Coverage

**1330 tests** (1321 passing, 9 ignored; unit + integration + doc).
**1334 tests** (1325 passing, 9 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
9 changes: 6 additions & 3 deletions docs/TEST_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

| | |
|---|---|
| **Verified** | 2026-10-23 with `cargo test` |
| **Passing** | 1,317 |
| **Verified** | 2026-10-24 with `cargo test` |
| **Passing** | 1,325 |
| **Ignored** | 9 (see [Ignored Tests](#ignored-tests)) |
| **Total** | 1,326, including the golden-file corpus (#391) |
| **Total** | 1,334, including the golden-file corpus (#391) |

Not counted above: the `BrowserDb` tests, which run only on wasm32 with `wasm-bindgen-test` in a browser (see [Browser](#browser)).

Expand Down Expand Up @@ -100,6 +100,9 @@ These tests compare the database against an independent answer rather than a sin
- **Float constants** (`tests/float_constant_test.rs`, 2 tests; #706)
- A float constant matches equal floats only: `1e-17` does not match `0.0`; `0.0` matches `-0.0`.
- Checked in a pattern, bound to a `$slot`, in a rule body, in a `not` body and in an `=` predicate; in memory, on file before and after a checkpoint, and after reopen.
- **Keyword or ref in `=` / `!=`** (`tests/keyword_ref_expression_test.rs`, 4 tests; #744)
- An expression's `=` and `!=` equate a keyword with a ref to the entity it names, as the pattern constant and the join do: either operand order, a keyword constant or value, a bound `Val(Ref)`, a binding expression, a rule body, a `not` clause, an `or` branch and a keyword whose name is a UUID; a keyword still equals the same keyword only.
- In memory, on file after a checkpoint, and after reopen.
- **`or` with no shared variable** (`tests/disjunction_test.rs`, #610)
- Joins as a cross product, before or after the earlier clauses, through the seeded path (a branch with `not`) and in a rule body.
- Keeps the earlier clauses' variables and fact metadata (a pseudo-attribute).
Expand Down
17 changes: 14 additions & 3 deletions src/query/datalog/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2311,11 +2311,22 @@ fn to_float_pair(l: &Value, r: &Value) -> Result<(f64, f64), ()> {
Ok((lf, rf))
}

/// Whether `l` and `r` are equal as a join compares them: structurally,
/// except that a keyword equals a ref to the entity it names (#744).
fn expr_eq(l: &Value, r: &Value) -> bool {
match (l, r) {
(Value::Keyword(k), Value::Ref(u)) | (Value::Ref(u), Value::Keyword(k)) => {
edn_to_entity_id(&EdnValue::Keyword(k.clone())).is_ok_and(|e| e == *u)
}
_ => l == r,
}
}

fn eval_binop(op: &BinOp, l: Value, r: Value) -> Result<Value, ()> {
match op {
// Structural equality — works for all Value variants; no type mismatch error.
BinOp::Eq => return Ok(Value::Boolean(l == r)),
BinOp::Neq => return Ok(Value::Boolean(l != r)),
// Equality — works for all Value variants; no type mismatch error.
BinOp::Eq => return Ok(Value::Boolean(expr_eq(&l, &r))),
BinOp::Neq => return Ok(Value::Boolean(!expr_eq(&l, &r))),
_ => {}
}

Expand Down
131 changes: 131 additions & 0 deletions tests/keyword_ref_expression_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//! An expression's `=` and `!=` equate a keyword with a ref to the entity it
//! names, as pattern constants and joins do (#744). Before, they compared
//! the two values as they are, so `[(= ?g :bob)]` kept no row where the
//! pattern `[?x :ref/friend :bob]` matched one.

use minigraf::{BindValue, Minigraf, QueryResult, Value};

fn rows(r: QueryResult) -> Vec<Vec<Value>> {
match r {
QueryResult::QueryResults { results, .. } => results,
_ => panic!("expected QueryResults"),
}
}

fn query(db: &Minigraf, q: &str) -> Vec<Vec<Value>> {
rows(db.execute(&format!("(query {q})")).expect("query"))
}

/// The entity a keyword names, as `edn_to_entity_id` derives it.
fn named(k: &str) -> uuid::Uuid {
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, k.as_bytes())
}
/// `:alice` holds a ref to `:bob`'s entity, `:carol` the keyword `:bob`.
fn ref_and_keyword_db() -> Minigraf {
let db = Minigraf::in_memory().unwrap();
db.execute(&format!(
r#"(transact [[:alice :ref/friend #uuid "{}"] [:carol :kw/friend :bob] [:dan :kw/friend :eve]])"#,
named(":bob")
))
.unwrap();
db
}

/// An expression's `=` and `!=` equate a keyword with a ref to the entity
/// it names, as patterns and joins do (#744).
fn assert_expression_equality_matches_joins(db: &Minigraf) {
for (q, want) in [
// The pattern constant and the join the expressions must agree with.
("[:find ?x :where [?x :ref/friend :bob]]", 1),
(
"[:find ?x :where [?y :ref/friend ?g] [?x :kw/friend ?g]]",
1,
),
// A ref against a keyword constant, either side.
("[:find ?x :where [?x :ref/friend ?g] [(= ?g :bob)]]", 1),
("[:find ?x :where [?x :ref/friend ?g] [(= :bob ?g)]]", 1),
("[:find ?x :where [?x :ref/friend ?g] [(!= ?g :bob)]]", 0),
("[:find ?x :where [?x :ref/friend ?g] [(= ?g :eve)]]", 0),
("[:find ?x :where [?x :ref/friend ?g] [(!= ?g :eve)]]", 1),
// A ref against a keyword value.
(
"[:find ?x :where [?y :ref/friend ?g] [?x :kw/friend ?k] [(= ?g ?k)]]",
1,
),
(
"[:find ?x :where [?y :ref/friend ?g] [?x :kw/friend ?k] [(!= ?k ?g)]]",
1,
),
// A keyword still equals the same keyword only.
("[:find ?x :where [?x :kw/friend ?k] [(= ?k :bob)]]", 1),
("[:find ?x :where [?x :kw/friend ?k] [(!= ?k :bob)]]", 1),
] {
assert_eq!(query(db, q).len(), want, "{q}");
}
// A binding expression's value is the comparison.
let r = query(db, "[:find ?b :where [?x :ref/friend ?g] [(= ?g :bob) ?b]]");
assert!(matches!(r[..], [ref row] if row[0] == Value::Boolean(true)));
// A keyword value against a bound ref.
let p = db
.prepare("(query [:find ?x :where [?x :kw/friend ?k] [(= ?k $r)]])")
.unwrap();
let r = rows(
p.execute(&[("r", BindValue::Val(Value::Ref(named(":bob"))))])
.unwrap(),
);
assert_eq!(r.len(), 1, "bound ref");
}

#[test]
fn expression_equality_equates_keyword_and_ref() {
assert_expression_equality_matches_joins(&ref_and_keyword_db());
}

#[test]
fn expression_equality_in_rule_bodies_and_negation() {
let db = ref_and_keyword_db();
db.execute("(rule [(bobs-friend ?x) [?x :ref/friend ?g] [(= ?g :bob)]])")
.unwrap();
assert_eq!(query(&db, "[:find ?x :where (bobs-friend ?x)]").len(), 1);
let r = query(
&db,
"[:find ?x :where [?x :ref/friend ?g] (not [(= ?g :bob)])]",
);
assert!(r.is_empty(), "not of an equal pair");
}

#[cfg(not(target_arch = "wasm32"))]
#[test]
fn expression_equality_equates_keyword_and_ref_after_checkpoint() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("kw-ref.graph");
let db = Minigraf::open(&path).unwrap();
db.execute(&format!(
r#"(transact [[:alice :ref/friend #uuid "{}"] [:carol :kw/friend :bob] [:dan :kw/friend :eve]])"#,
named(":bob")
))
.unwrap();
db.checkpoint().unwrap();
assert_expression_equality_matches_joins(&db);
drop(db);
assert_expression_equality_matches_joins(&Minigraf::open(&path).unwrap());
}

#[test]
fn expression_equality_in_or_branches_and_on_uuid_keywords() {
let db = ref_and_keyword_db();
let r = query(
&db,
"[:find ?x :where [?x :ref/friend ?g] (or [(= ?g :bob)] [(= ?g :eve)])]",
);
assert_eq!(r.len(), 1, "or branch");
// A keyword whose name is a UUID names the entity with that UUID.
let r = query(
&db,
&format!(
"[:find ?x :where [?x :ref/friend ?g] [(= ?g :{})]]",
named(":bob")
),
);
assert_eq!(r.len(), 1, "UUID keyword");
}
Loading
0