8000 fix(prepared): keep a literal :as-of / :valid-at when binding slots (#603) by adityamukho · Pull Request #605 · 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: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **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` 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 (v1.x and every v2.x release).

## v2.0.6 — 2026-10-14

Patch release on the v2.x line under the [support policy](PHILOSOPHY.md#support-policy): three data-integrity fixes for queries that run on another thread while a transaction commits or a checkpoint runs (#554, #555, #558). File format is unchanged (v7). No API changes. Stored data was never affected. Upgrading is recommended for every v2.x user who queries and writes from different threads.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ Auto-migrates v1/v2/v3/v4/v5/v6 → v7 on open/checkpoint.

## Test Coverage

**1251 tests** (1242 passing, 9 ignored; unit + integration + doc).
**1253 tests** (1244 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ let r2 = pq.execute(&[("tx", BindValue::TxCount(2)), ("entity", BindValue::Entit

```bash
cargo run # interactive Datalog REPL
cargo test # run 1251 tests
cargo test # run 1253 tests
cargo run < demos/demo_recursive.txt # recursive rules demo
```

Expand Down
2 changes: 1 addition & 1 deletion docs/TEST_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**Verified**: 2026-10-14 with `cargo test`

**Result**: 1,242 passing tests and 9 ignored tests (1,251 total), including the golden-file corpus (#391)
**Result**: 1,244 passing tests and 9 ignored tests (1,253 total), including the golden-file corpus (#391)

For v2.0.5, `tests/edge_cases_test.rs` checks that a file of 1 to 4,095 bytes is `STG-045`, unchanged and with no WAL, when it is a database cut below one page or foreign bytes, and opens as a new database that takes a write and reads it back after reopen when it is a prefix of the initial header or all zeros (#506).

Expand Down
27 changes: 23 additions & 4 deletions src/query/datalog/prepared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,12 +271,13 @@ fn substitute(
) -> Result<DatalogQuery> {
let mut query = template.clone();

if let Some(AsOf::Slot(name)) = query.as_of.take() {
query.as_of = Some(resolve_as_of_slot(&name, bindings)?);
// Only a slot is replaced; a literal `:as-of` / `:valid-at` is kept (#603).
if let Some(AsOf::Slot(name)) = &query.as_of {
query.as_of = Some(resolve_as_of_slot(name, bindings)?);
}

if let Some(ValidAt::Slot(name)) = query.valid_at.take() {
query.valid_at = Some(resolve_valid_at_slot(&name, bindings)?);
if let Some(ValidAt::Slot(name)) = &query.valid_at {
query.valid_at = Some(resolve_valid_at_slot(name, bindings)?);
}

for clause in &mut query.where_clauses {
Expand Down Expand Up @@ -687,6 +688,24 @@ mod tests {
));
}

#[test]
fn test_substitute_keeps_literal_as_of_and_valid_at() {
// #603: only a slot is replaced.
let mut q = make_query_entity_slot();
q.as_of = Some(AsOf::Counter(3));
q.valid_at = Some(ValidAt::AnyValidTime);
let bv = BindValue::Entity(uuid::Uuid::nil());
let bindings: HashMap<&str, &BindValue> = [("entity", &bv)].into();
let filled = substitute(&q, &bindings).unwrap();
assert!(matches!(filled.as_of, Some(AsOf::Counter(3))));
assert!(matches!(filled.valid_at, Some(ValidAt::AnyValidTime)));
q.as_of = Some(AsOf::Timestamp(5));
q.valid_at = Some(ValidAt::Timestamp(7));
let filled = substitute(&q, &bindings).unwrap();
assert!(matches!(filled.as_of, Some(AsOf::Timestamp(5))));
assert!(matches!(filled.valid_at, Some(ValidAt::Timestamp(7))));
}

#[test]
fn test_substitute_valid_at_any() {
let q = make_query_valid_at_slot();
Expand Down
60 changes: 60 additions & 0 deletions tests/prepared_statements_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,3 +439,63 @@ fn write_transaction_execute_rejects_bind_slots() {
assert_eq!(err.code(), "API-010");
tx.rollback();
}

// ─── Literal time travel in a prepared query (#603) ──────────────────────────

/// Rows of `q` through `execute` and a prepared `execute` with no bind
/// values; both must agree.
fn both_ways(db: &Minigraf, q: &str) -> usize {
let direct = match db.execute(q).unwrap() {
QueryResult::QueryResults { results, .. } => results.len(),
_ => panic!("expected QueryResults"),
};
let prepared = match db.prepare(q).unwrap().execute(&[]).unwrap() {
QueryResult::QueryResults { results, .. } => results.len(),
_ => panic!("expected QueryResults"),
};
assert_eq!(prepared, direct, "prepared execute agrees with execute");
direct
}

#[test]
fn prepared_literal_as_of_and_valid_at_are_kept() {
let db = Minigraf::in_memory().unwrap();
db.execute(r#"(transact {:valid-from "2020-01-01" :valid-to "2021-01-01"} [[:a :n 1]])"#)
.unwrap();
db.execute("(transact [[:a :n 2]])").unwrap();
assert_eq!(
both_ways(
&db,
"(query [:find ?v :as-of 1 :valid-at :any-valid-time :where [:a :n ?v]])"
),
1
);
assert_eq!(
both_ways(
&db,
r#"(query [:find ?v :as-of "2000-01-01T00:00:00Z" :any-valid-time :where [:a :n ?v]])"#
),
0
);
assert_eq!(
both_ways(
&db,
r#"(query [:find ?v :valid-at "2020-06-01T00:00:00Z" :where [:a :n ?v]])"#
),
1
);
assert_eq!(
both_ways(&db, "(query [:find ?v :any-valid-time :where [:a :n ?v]])"),
2
);
assert_eq!(both_ways(&db, "(query [:find ?v :where [:a :n ?v]])"), 1);
// A per-fact pseudo-attribute needs :any-valid-time; a prepared query
// keeps it (INT-026 before the fix).
assert_eq!(
both_ways(
&db,
"(query [:find ?v ?t :any-valid-time :where [:a :n ?v] [:a :db/tx-count ?t]])"
),
2
);
}
Loading
0