From dc9476d159e74e442053c72dbbbc4f7f6316aadb Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Sat, 24 Oct 2026 16:18:22 +0530 Subject: [PATCH 1/3] docs(plan): detail #316 PR 2 (set predicates) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TcpoiFyFeQQQELL872LfQF --- .../plans/2026-10-24-316-set-values.md | 66 +++++++++++++++---- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/plans/2026-10-24-316-set-values.md b/docs/superpowers/plans/2026-10-24-316-set-values.md index 81b05ec1a..6e9b69cb0 100644 --- a/docs/superpowers/plans/2026-10-24-316-set-values.md +++ b/docs/superpowers/plans/2026-10-24-316-set-values.md @@ -190,17 +190,61 @@ machine, binary size reported (gate is report-only until #551's PR 2). Codex rev (`codex exec -s read-only … < /dev/null`), findings checked against the code. PR body without a closing keyword; monitor CI until green. -## PR 2 — predicates (outline; detailed after PR 1 merges) - -13. `types.rs` `BinOp::{In, NotIn, AnyIn, AllIn, NoneIn}`; parser arm beside `contains?` - (`parser.rs:1297`, `1340`); `functions.rs` sentinel names (`functions.rs:93`) so UDFs cannot - take them. -14. `eval_binop`: spec §6 table and type mismatches (`Err(())`), `not-in?` with a non-set is - `Err(())`. -15. Prepared: `BindValue::Val(Value::Set)` in an operand slot and a value slot - (`prepared.rs` `substitute`), reused across executes. -16. Tests (`tests/predicate_expr_test.rs`, `prepared_statements_test.rs`, grammar corpus): the - spec §12 predicate list; docs share (`CHANGELOG`, README); review and PR. +## PR 2 — predicates + +#306's implementation has not merged (only its spec, #729), so the `:order-by` over a set column +test moves to whichever of #306 PR 1 and this PR lands second. + +### Step 13. Operators and parser + +- `types.rs`: `BinOp::{In, NotIn, AnyIn, AllIn, NoneIn}` after `Contains`, documented as "set + predicates — return Boolean"; `test_binop_variants_exist` names them. +- `parser.rs` `parse_expr`: `"in?" | "not-in?" | "any-in?" | "all-in?" | "none-in?"` join the + binary arm (arity `PRS-065` as for `contains?`), mapped in the `op` match; `Contains` stops being + the `_` arm (an explicit `"contains?"`, `_` unreachable as in `eval_binop`). +- `functions.rs`: the five names join the built-in sentinels, so `register_predicate` refuses them + as it refuses `contains?`. +- `grammar.pest` `binary_op`: the five names, longest first where one is a prefix of another (none + are); `docs/GRAMMAR.md` regenerated (`UPDATE_GRAMMAR_DOC=1 cargo test --test grammar_doc`); + `tests/grammar/valid/query_expr_set_predicates.edn` (filter and binding forms, each op). + `TOKENS` is unchanged: the string predicates are not tokens either, so the verifier's cost stays. + +### Step 14. Evaluation + +`executor.rs` `eval_binop`, the arm after `Matches` (the engine's `Filter`/`Bind` call +`eval_expr`, so both forms follow): + +| op | `(l, r)` accepted | result | +|---|---|---| +| `In` / `NotIn` | `l` not a set, `r` a set | `r.contains(&l)` / its negation | +| `AnyIn` / `NoneIn` | both sets | some element of `l` in `r` / its negation | +| `AllIn` | both sets | every element of `l` in `r` | + +Every other shape is `Err(())`. `ValueSet::contains` is the `BTreeSet` lookup under `Value`'s `Ord`, +which is the spec's set equality (§2), so NaN, `-0.0`/`0.0` and keyword ≠ ref follow with no special +case. Iterate the smaller set for `AnyIn`/`NoneIn`; `AllIn` returns false at once when `l` is larger. +Unit tests in `executor.rs` next to `test_eval_contains_true`: each op true and false, every +empty-set case of spec §6, each wrong shape. + +### Step 15. Prepared sets + +No code: `substitute_expr` puts a bound `Value` into the `Expr` (`resolve_val_slot`) and +`resolve_value_slot` turns a `Value::Set` into `EdnValue::Set` (PR 1). Tests only: +`BindValue::Val(Value::Set)` as an `in?` operand slot and as a pattern value slot, one prepared query +executed with two different sets. + +### Step 16. Tests, docs, PR + +- `tests/set_predicates_test.rs` (file-backed parts behind `#![cfg(not(target_arch = "wasm32"))]`): + spec §6 table per scalar type (string, integer, float, boolean, nil, keyword, ref), NaN and `-0.0`, + keyword vs ref of the same entity, every empty-set case, each wrong shape in filter and binding + form, `not-in?` with a non-set drops the row, stored-attribute operands (pending and after + `checkpoint`), `=`/`!=` between sets, `contains?` on a set is still a type mismatch, arity + `PRS-065`, `register_predicate("in?")` refused. `collect-set` operands come with PR 3. +- `tests/prepared_statements_test.rs`: step 15. +- `CHANGELOG.md` (Unreleased, beside PR 1's entry), README predicate list, `CLAUDE.md` and + `docs/TEST_COVERAGE.md` counts. +- Review as step 12; PR into `v3` without a closing keyword. ## PR 3 — `collect-set` (outline) From fe4f14924be4e6815ab89893187908b0b0adc881 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Sat, 24 Oct 2026 18:05:27 +0530 Subject: [PATCH 2/3] feat(query): set-membership predicates in?, not-in?, any-in?, all-in?, none-in? (#316) Five BinOps over set values: filters and Boolean bindings, membership by Value's Ord (one NaN, -0.0 = 0.0, keyword != ref). A wrong operand shape is a type mismatch, so not-in? with a non-set drops the row. The names are built-in predicate sentinels. Grammar, GRAMMAR.md and a corpus fixture; prepared set binds tested in operand and value slots. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TcpoiFyFeQQQELL872LfQF --- CHANGELOG.md | 1 + docs/GRAMMAR.md | 3 +- docs/TEST_COVERAGE.md | 1 + src/query/datalog/executor.rs | 68 ++++ src/query/datalog/functions.rs | 5 + src/query/datalog/parser.rs | 10 +- src/query/datalog/types.rs | 11 + tests/grammar/grammar.pest | 1 + .../valid/query_expr_set_predicates.edn | 8 + tests/prepared_statements_test.rs | 69 ++++ tests/set_predicates_test.rs | 317 ++++++++++++++++++ 11 files changed, 491 insertions(+), 3 deletions(-) create mode 100644 tests/grammar/valid/query_expr_set_predicates.edn create mode 100644 tests/set_predicates_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 98fe8e2ea..5d6a3418b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Set values: `#{...}`** (#316, PR 1 of 3). A fact's value can be a finite set of scalars, `[:alice :user/scopes #{:scope/read :scope/write}]`, written in query text, returned as `Value::Set(ValueSet)`, transacted, retracted and queried by exact value (`[?e :user/scopes #{:scope/write :scope/read}]`), joined on and grouped by. Duplicates are removed and element order does not matter. Two elements are the same when `Value`'s order says so: `0.0` and `-0.0` are one element (`-0.0` is kept), every NaN is one element, a keyword never equals a ref and `1` never equals `1.0`. A set cannot hold a set (`ValueSet::new` fails with the new `API-021`; in query text, `PRS-119`); its elements are literals only (`PRS-120`), and an unclosed `#{` is `PRS-121`. In format v8 a set has value tag `0x08`, after refs; a set whose encoded elements take at most 64 bytes is stored in its index keys, a longer one once in a value page, as a long string is. On a file database a stored set may take up to 4,068 bytes, counting each keyword element as 6 bytes and each ref as 10 (`WAL-003`). The REPL prints sets as `#{...}` and `BrowserDb` returns them as JSON arrays, in `Value`'s order. +- **Set-membership predicates** (#316, PR 2 of 3). `[(in? ?role #{:role/admin :role/owner})]` keeps rows whose value is in a set, and `not-in?`, `any-in?` (the two sets share an element), `all-in?` (every element of the first is in the second) and `none-in?` (they share none) complete the five. Each works as a filter or binds a Boolean, `[(any-in? ?scopes #{:scope/write}) ?can-write]`, and takes literal sets, set-valued attributes and bind slots (`BindValue::Val(Value::Set(..))`). Membership is set equality, as for set values: `0.0` is in `#{-0.0}`, NaN is in a set holding NaN, a keyword is not a ref. `in?` of an empty set is false and `not-in?` true; `all-in?` and `none-in?` of an empty first set are true and `any-in?` false. A set where a scalar is needed, or a scalar where a set is needed, is a type mismatch: the filter drops the row and the binding binds nothing, so `not-in?` with a non-set is never true. `contains?` stays string-only. The five names cannot be registered as functions. - **Every query runs on a streaming engine** (#473: #544, #545, #546, #547, #548, #549, #551). The executor, which turned every live fact of each pattern's attribute into a `Fact` before matching, held each row's bindings in a map and copied the database into a `FactStorage` for every rule query, no longer runs queries: its query path, `PatternMatcher` and the old rule evaluators are gone (#551), and the release binary is 1,153 KiB instead of 1,286 KiB (CI's size check). A query is now a tree of operators that pass batches of up to 1,024 rows of ids, read from the index keys, and turn ids into values only for the rows they return (#530). A pattern reads the narrowest key range its constants allow (`[?e :a C]` reads the AVET range of `C`, #518); a later pattern is an `IndexNestedLoopJoin` when its entity is bound and a `HashJoin` otherwise, built on the side with the smaller estimate (with no shared variable, the smaller product, #575); per-fact pseudo-attributes read their source's metadata column (#594). `not` / `not-join` is an `AntiJoin` and `or` / `or-join` a `Union` and `Distinct`: a body or branch with the same rows for every outer row runs once into a hash table, any other runs per outer row from a one-row seed. Aggregates keep one entry per group (one set of accumulators when ungrouped), windows hold their rows, and rule queries derive each stratum's relations semi-naively, with the magic-set rewrite, then read them as patterns. `:as-of` with a time bounds the transaction counter when transaction times rise with it, and otherwise keeps the versions of transactions at or before the time. Cursors (`Minigraf::query`, `PreparedQuery::query`, `BrowserDb.query`) pull one batch at a time, and closing or dropping one stops the work; a cursor still answers from the database as it was when it opened. Every operator that keeps state counts it against `OpenOptions::query_memory_limit` (#549, below). Answers are the executor's, as a bag, except for the changes under Breaking changes: until #551 every query in the test suite was also run by the executor and the answers had to agree, and the answers of the suites that relied on that are recorded in `tests/answers/`. Measured with the #394 envelope at 1M facts, executor → engine, p50 and peak RSS (`docs/BENCHMARKS.md` has every shape at 100K, 1M and 10M): `[?e :name "…"]` 288 ms → 22 µs; an attribute scan of 166,666 rows drained with `next_batch(1000)` 58 → 7 MiB (458 → 7 MiB at 10M); `[:p :follows ?f] [?f :name ?n]` 796 ms → 50 µs; an entity-bound `not` 760 → 27 ms; `[:find (count ?v) :where [?e ?a ?v]]` 3.01 s → 457 ms and 728 → 7 MiB (6.6 GiB → 7 MiB at 10M); an `or-join` of two hashed branches 1.44 s → 80 ms; `(boss :p ?b)` 43.9 s → 2.14 ms and 2.6 GiB → 4 MiB; the whole transitive closure of `:manager` 51.0 s → 3.21 s and 4.7 GiB → 318 MiB; `:as-of` with a time, a point query, 1.65 s → 0.8 ms and 435 → 4 MiB (17.2 s and 3.6 GiB → 0.9 ms and 5 MiB at 10M). - **Transactions of any size on a file database, in bounded memory** (#473, part 8: #550). `transact` and `retract` are read one fact at a time: the whole command is never tokenized, parsed or materialised at once, and a statement reports the same error code as before whatever its size. A transaction becomes *large* when its facts would pass the new `OpenOptions::transaction_chunk_facts` (default 65,536, kept between 1 and 1,000,000): `Minigraf::execute` of a larger statement, or the `WriteTransaction` statement that takes its staged facts past it. A large transaction holds at most one chunk of facts in memory. It writes them a chunk at a time into copy-on-write trees that become the next generation, and commits as a checkpoint: no WAL entry, and the uncheckpointed facts of earlier transactions go into the same checkpoint. What a large transaction stores is what a small one stores, with two differences you can see: its `tx_id` is the time it became large, not its commit time (a default `:valid-from` takes that time too), and errors from different chunks of one statement come in chunk order. In a large `WriteTransaction` a rejected statement still leaves the transaction usable and the earlier statements' facts as they were; a fact too large to store is rejected at its statement (`WAL-003`) rather than at `commit`; a handle that has failed is refused when the transaction becomes large (`STG-046`) rather than at `commit`; and inside the transaction `:as-of` its own transaction number sees every finished statement, since they share one number (staged statements of a small transaction each have their own until `commit`). A crash at any point before a large transaction's commit leaves the database as it was; the WAL file exists, header only, from before its first page write, so a new file reopens too. The first checkpoint after a WAL is found at open, or after a large transaction is rolled back, now writes a meta page before it deletes the WAL. Measured at 1M facts against v3 before this change (one `execute`, or a `WriteTransaction` of 10 statements): peak memory above the input text goes from 1.3–2.4 GiB to at most 55 MiB at the default (under 10 MiB at 16,384); with sequential entities it is faster (3.9 s from 9.1 s for short values), with random entities about as fast for short values (9.5 s from 11.4 s in one `execute`) and slower for long ones over 10 statements (34 s from 22 s; 16,384 takes up to twice as long as the default with random entities), and a transaction of several statements over random entities can leave the file up to 1.8× larger, since each statement keeps the pages its base references until it ends; later checkpoints reuse that space. - **Queries inside a `WriteTransaction` read through a snapshot** (#550). They no longer copy every committed and pending fact into a new list: they read the committed indexes and the pending facts, with the transaction's staged facts read with the pending ones (or, in a large transaction, its working trees), and run on the streaming engine where it plans them, counted by `query_memory_limit`. diff --git a/docs/GRAMMAR.md b/docs/GRAMMAR.md index a530f5554..5d4cdad59 100644 --- a/docs/GRAMMAR.md +++ b/docs/GRAMMAR.md @@ -108,7 +108,8 @@ unary-form ::= (unary-op | op-sym | plain-sym) expr-arg binary-form ::= binary-op expr-arg expr-arg unary-op ::= "string?" | "integer?" | "float?" | "boolean?" | "nil?" (* token *) binary-op ::= "<=" | ">=" | "!=" | "starts-with?" | "ends-with?" | "contains?" - | "matches?" | "<" | ">" | "=" | "+" | "-" | "*" | "/" (* token *) + | "matches?" | "not-in?" | "any-in?" | "all-in?" | "none-in?" | "in?" + | "<" | ">" | "=" | "+" | "-" | "*" | "/" (* token *) expr-arg ::= expr | set-lit | boolean-lit | nil-lit | float-lit | integer-lit | string-lit | bind-slot | keyword | variable diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index 57715f445..ec69b096e 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -96,6 +96,7 @@ These tests compare the database against an independent answer rather than a sin - **Multi-value and index-key correctness** (`tests/multi_value_test.rs`, 10 tests; #287, #371): same-transaction multi-valued facts surviving query-time dedup, retracting several values of one attribute in one call, valid-time stints (facts differing only in valid time), maximum-value-size boundaries, and format v8 index keys (value bytes + assert/retract flag). - **Set values** (`tests/set_values_test.rs`, 8 tests, 10 with `test-hooks`; #316): `#{...}` transacted, queried by exact value, joined on and grouped by, in memory, pending, committed, after WAL replay and reopen, in a large transaction, through the fact log and the log writer; exact retraction; the stored-size limit (`WAL-003`); literal errors (`PRS-119`–`121`); with `test-hooks`, the same answers with reordering on and off, and `#{0.0}` equal to `#{-0.0}` through a constant and a join, committed or pending. Unit tests cover `ValueSet` (dedup, `-0.0` kept, one NaN, `API-021`, `Ord`/`Hash`/`Eq` property test, serde), v8 set keys (inline and long, the 64-byte boundary, decoding, `set_bytes_bound`), long-set dedup and a set record under a string's hash, the pending encoding, `value_order`, parser errors and REPL/JSON output; the model-based test generates sets. +- **Set predicates** (`tests/set_predicates_test.rs`, 12 tests; #316): `in?`/`not-in?` per scalar type (string, integer, float, boolean, nil, keyword, ref), `any-in?`/`all-in?`/`none-in?`, filter and binding forms agreeing, every empty-set case, `-0.0`/`0.0` and NaN (through bind slots) as one element, a keyword not a ref to its entity, each wrong operand shape (filter drops, binding binds nothing; `not-in?` with a non-set too), stored operands pending, committed and reopened, `=`/`!=` between sets, `contains?` still string-only, arity `PRS-065`, the names refused by `register_predicate`. `tests/prepared_statements_test.rs` binds sets to an operand slot and a value slot across executes; an `executor.rs` unit test covers `eval_binop`'s arms. - **B+tree cursor** (v8 storage format PR 1, #374): every scan walks the tree, not the leaf `next_leaf` chain. - Full scans and start positions match the input. - `seek` from the current position lands where a fresh descent from the root would: randomized, plus within a leaf, to a sibling, across subtrees, past the end, into the gap after a leaf, and backwards as a no-op. diff --git a/src/query/datalog/executor.rs b/src/query/datalog/executor.rs index 9e7f06def..ebcb60c0f 100644 --- a/src/query/datalog/executor.rs +++ b/src/query/datalog/executor.rs @@ -755,6 +755,29 @@ fn eval_binop(op: &BinOp, l: Value, r: Value) -> Result { _ => Err(()), }, + // Set predicates (#316): membership is Value's Ord, so NaN, -0.0/0.0 and + // keyword != ref need no case here. A scalar where a set is needed, or a set + // as in?'s element, is a type mismatch; not-in? then drops the row too. + BinOp::In | BinOp::NotIn => match (&l, &r) { + (Value::Set(_), _) => Err(()), + (x, Value::Set(s)) => Ok(Value::Boolean(s.contains(x) == matches!(op, BinOp::In))), + _ => Err(()), + }, + BinOp::AnyIn | BinOp::NoneIn => match (&l, &r) { + (Value::Set(a), Value::Set(b)) => { + let (small, large) = if a.len() <= b.len() { (a, b) } else { (b, a) }; + let any = small.iter().any(|x| large.contains(x)); + Ok(Value::Boolean(any == matches!(op, BinOp::AnyIn))) + } + _ => Err(()), + }, + BinOp::AllIn => match (&l, &r) { + (Value::Set(a), Value::Set(b)) => Ok(Value::Boolean( + a.len() <= b.len() && a.iter().all(|x| b.contains(x)), + )), + _ => Err(()), + }, + // Eq/Neq handled above BinOp::Eq | BinOp::Neq => unreachable!(), } @@ -2251,6 +2274,51 @@ mod expr_eval_tests { ); } + #[test] + fn test_eval_set_predicates() { + let set = |v: &[i64]| { + Value::Set( + crate::graph::types::ValueSet::new(v.iter().map(|i| Value::Integer(*i))).unwrap(), + ) + }; + let b = |x: bool| Ok(Value::Boolean(x)); + assert_eq!( + eval_binop(&BinOp::In, Value::Integer(1), set(&[1, 2])), + b(true) + ); + assert_eq!( + eval_binop(&BinOp::In, Value::Integer(3), set(&[1, 2])), + b(false) + ); + assert_eq!( + eval_binop(&BinOp::NotIn, Value::Integer(3), set(&[1, 2])), + b(true) + ); + assert_eq!( + eval_binop(&BinOp::NotIn, Value::Integer(1), set(&[])), + b(true) + ); + assert_eq!(eval_binop(&BinOp::AnyIn, set(&[1, 2]), set(&[2])), b(true)); + assert_eq!(eval_binop(&BinOp::AnyIn, set(&[2]), set(&[1, 3])), b(false)); + assert_eq!(eval_binop(&BinOp::AllIn, set(&[]), set(&[])), b(true)); + assert_eq!(eval_binop(&BinOp::AllIn, set(&[1, 2]), set(&[1])), b(false)); + assert_eq!(eval_binop(&BinOp::NoneIn, set(&[1, 2]), set(&[3])), b(true)); + assert_eq!( + eval_binop(&BinOp::NoneIn, set(&[3]), set(&[1, 2, 3])), + b(false) + ); + // Wrong shapes. + assert_eq!(eval_binop(&BinOp::In, set(&[1]), set(&[1])), Err(())); + assert_eq!( + eval_binop(&BinOp::NotIn, Value::Integer(1), Value::Integer(1)), + Err(()) + ); + for op in [BinOp::AnyIn, BinOp::AllIn, BinOp::NoneIn] { + assert_eq!(eval_binop(&op, Value::Integer(1), set(&[1])), Err(())); + assert_eq!(eval_binop(&op, set(&[1]), Value::Integer(1)), Err(())); + } + } + #[test] fn test_eval_matches_true() { let re = regex_lite::Regex::new("^[^@]+@[^@]+$").unwrap(); diff --git a/src/query/datalog/functions.rs b/src/query/datalog/functions.rs index c62fb9f2a..840312cbb 100644 --- a/src/query/datalog/functions.rs +++ b/src/query/datalog/functions.rs @@ -94,6 +94,11 @@ impl FunctionRegistry { "ends-with?", "contains?", "matches?", + "in?", + "not-in?", + "any-in?", + "all-in?", + "none-in?", ] { reg.predicates.insert( name.to_string(), diff --git a/src/query/datalog/parser.rs b/src/query/datalog/parser.rs index c2b24ac7f..fd5d7759d 100644 --- a/src/query/datalog/parser.rs +++ b/src/query/datalog/parser.rs @@ -1339,7 +1339,8 @@ fn parse_expr(list: &[EdnValue]) -> Result { // Binary operators "<" | ">" | "<=" | ">=" | "=" | "!=" | "+" | "-" | "*" | "/" | "starts-with?" - | "ends-with?" | "contains?" | "matches?" => { + | "ends-with?" | "contains?" | "matches?" | "in?" | "not-in?" | "any-in?" | "all-in?" + | "none-in?" => { if list.len() != 3 { bail_coded!(ErrorCode::Prs065, head); } @@ -1383,8 +1384,13 @@ fn parse_expr(list: &[EdnValue]) -> Result { "/" => BinOp::Div, "starts-with?" => BinOp::StartsWith, "ends-with?" => BinOp::EndsWith, + "contains?" => BinOp::Contains, + "in?" => BinOp::In, + "not-in?" => BinOp::NotIn, + "any-in?" => BinOp::AnyIn, + "all-in?" => BinOp::AllIn, // SAFETY: the match arm above covers all non-matches? variants - _ => BinOp::Contains, + _ => BinOp::NoneIn, }; // SAFETY: len == 3 check above guarantees indices 1 and 2 exist #[allow(clippy::indexing_slicing)] diff --git a/src/query/datalog/types.rs b/src/query/datalog/types.rs index 045037de2..53e0b1292 100644 --- a/src/query/datalog/types.rs +++ b/src/query/datalog/types.rs @@ -179,6 +179,12 @@ pub enum BinOp { StartsWith, EndsWith, Contains, + // Set predicates — return Boolean (#316) + In, + NotIn, + AnyIn, + AllIn, + NoneIn, /// Pattern must be a string literal validated at parse time via regex-lite. /// The compiled `regex_lite::Regex` is stored alongside the original string pattern. Matches { @@ -1173,6 +1179,11 @@ mod tests { let _ = BinOp::StartsWith; let _ = BinOp::EndsWith; let _ = BinOp::Contains; + let _ = BinOp::In; + let _ = BinOp::NotIn; + let _ = BinOp::AnyIn; + let _ = BinOp::AllIn; + let _ = BinOp::NoneIn; } #[test] diff --git a/tests/grammar/grammar.pest b/tests/grammar/grammar.pest index 6022dd7a2..8e0241b82 100644 --- a/tests/grammar/grammar.pest +++ b/tests/grammar/grammar.pest @@ -188,6 +188,7 @@ binary_form = { binary_op ~ expr_arg ~ expr_arg } unary_op = @{ "string?" | "integer?" | "float?" | "boolean?" | "nil?" } binary_op = @{ "<=" | ">=" | "!=" | "starts-with?" | "ends-with?" | "contains?" | "matches?" | + "not-in?" | "any-in?" | "all-in?" | "none-in?" | "in?" | "<" | ">" | "=" | "+" | "-" | "*" | "/" } expr_arg = { diff --git a/tests/grammar/valid/query_expr_set_predicates.edn b/tests/grammar/valid/query_expr_set_predicates.edn new file mode 100644 index 000000000..d33968263 --- /dev/null +++ b/tests/grammar/valid/query_expr_set_predicates.edn @@ -0,0 +1,8 @@ +(query [:find ?e ?any ?all ?none + :where [?e :user/role ?r] + [?e :user/scopes ?s] + [(in? ?r #{:role/admin :role/owner})] + [(not-in? ?r #{})] + [(any-in? ?s #{:scope/read}) ?any] + [(all-in? ?s #{:scope/read :scope/write}) ?all] + [(none-in? ?s $banned) ?none]]) diff --git a/tests/prepared_statements_test.rs b/tests/prepared_statements_test.rs index a8aa61f09..72e17bb60 100644 --- a/tests/prepared_statements_test.rs +++ b/tests/prepared_statements_test.rs @@ -666,3 +666,72 @@ fn entity_and_ref_bind_values_fit_each_others_slots() { let err = bind_error(&pq, &[("f", BindValue::Val(Value::Integer(1)))]); assert_eq!(err.code(), "API-024", "an integer is not an entity"); } + +// ─── Set binds (#316) ───────────────────────────────────────────────────────── + +fn scope_set(scopes: &[&str]) -> Value { + Value::Set( + minigraf::ValueSet::new(scopes.iter().map(|s| Value::Keyword(s.to_string()))).unwrap(), + ) +} + +fn result_len(r: QueryResult) -> usize { + match r { + QueryResult::QueryResults { results, .. } => results.len(), + _ => panic!("expected QueryResults"), + } +} + +#[test] +fn set_in_operand_slot_reused_across_executions() { + let db = Minigraf::in_memory().unwrap(); + db.execute( + "(transact [[:alice :user/role :role/admin] [:bob :user/role :role/guest] + [:carol :user/role :role/owner]])", + ) + .unwrap(); + let prepared = db + .prepare("(query [:find ?e :where [?e :user/role ?r] [(in? ?r $roles)]])") + .unwrap(); + let run = |roles: &[&str]| { + result_len( + prepared + .execute(&[("roles", BindValue::Val(scope_set(roles)))]) + .unwrap(), + ) + }; + assert_eq!(run(&[":role/admin", ":role/owner"]), 2); + assert_eq!(run(&[":role/guest"]), 1); + assert_eq!(run(&[]), 0, "empty set"); + // A scalar where the set is needed drops every row. + let r = prepared + .execute(&[( + "roles", + BindValue::Val(Value::Keyword(":role/admin".into())), + )]) + .unwrap(); + assert_eq!(result_len(r), 0, "scalar bound to a set operand"); +} + +#[test] +fn set_in_value_slot_reused_across_executions() { + let db = Minigraf::in_memory().unwrap(); + db.execute( + "(transact [[:alice :user/scopes #{:scope/read :scope/write}] + [:bob :user/scopes #{:scope/read}]])", + ) + .unwrap(); + let prepared = db + .prepare("(query [:find ?e :where [?e :user/scopes $s]])") + .unwrap(); + let run = |s: &[&str]| { + result_len( + prepared + .execute(&[("s", BindValue::Val(scope_set(s)))]) + .unwrap(), + ) + }; + assert_eq!(run(&[":scope/write", ":scope/read"]), 1, "alice's set"); + assert_eq!(run(&[":scope/read"]), 1, "bob's set"); + assert_eq!(run(&[":scope/admin"]), 0, "no such set"); +} diff --git a/tests/set_predicates_test.rs b/tests/set_predicates_test.rs new file mode 100644 index 000000000..589a22522 --- /dev/null +++ b/tests/set_predicates_test.rs @@ -0,0 +1,317 @@ +//! Set-membership predicates (#316): `in?`, `not-in?`, `any-in?`, `all-in?`, +//! `none-in?` as filters and as Boolean bindings, over literal, stored and +//! bind-slot operands. +#![cfg(not(target_arch = "wasm32"))] + +use minigraf::{BindValue, Minigraf, QueryResult, Value, ValueSet}; +use uuid::Uuid; + +fn rows(db: &Minigraf, q: &str) -> Vec> { + match db.execute(q).unwrap() { + QueryResult::QueryResults { results, .. } => results, + _ => panic!("expected query results"), + } +} + +fn set(values: Vec) -> Value { + Value::Set(ValueSet::new(values).unwrap()) +} + +/// A database with one entity, for ground expressions. +fn one() -> Minigraf { + let db = Minigraf::in_memory().unwrap(); + db.execute("(transact [[:one :t/k 1]])").unwrap(); + db +} + +/// `expr` as a filter: whether the one row is kept. +fn keeps(db: &Minigraf, expr: &str) -> bool { + let q = format!("(query [:find ?x :where [?x :t/k _] [{expr}]])"); + match rows(db, &q).len() { + 0 => false, + 1 => true, + _ => panic!("one entity, at most one row"), + } +} + +/// `expr` as a binding: the bound Boolean, or `None` when no row is bound. +fn binds(db: &Minigraf, expr: &str) -> Option { + let q = format!("(query [:find ?b :where [?x :t/k _] [{expr} ?b]])"); + let r = rows(db, &q); + match r.len() { + 0 => None, + 1 => match &r[0][0] { + Value::Boolean(b) => Some(*b), + _ => panic!("a set predicate binds a Boolean"), + }, + _ => panic!("one entity, at most one row"), + } +} + +/// Filter and binding forms agree, and both equal `expected`. +fn check(db: &Minigraf, expr: &str, expected: bool) { + assert_eq!(keeps(db, expr), expected, "filter form of {expr}"); + assert_eq!(binds(db, expr), Some(expected), "binding form of {expr}"); +} + +/// A type mismatch: the filter drops the row, the binding binds nothing. +fn mismatch(db: &Minigraf, expr: &str) { + assert!(!keeps(db, expr), "filter form of {expr} drops the row"); + assert_eq!( + binds(db, expr), + None, + "binding form of {expr} binds nothing" + ); +} + +/// Refs come from a stored attribute: `#uuid` is not an expression argument. +#[test] +fn in_and_not_in_per_scalar_type() { + let db = one(); + let cases = [ + ("\"a\"", "#{\"a\" \"b\"}", "#{\"b\" \"c\"}"), + ("1", "#{1 2}", "#{2 3}"), + ("1.5", "#{1.5 2.5}", "#{2.5}"), + ("true", "#{true}", "#{false}"), + ("nil", "#{nil 1}", "#{1}"), + (":k/a", "#{:k/a :k/b}", "#{:k/b}"), + ]; + for (x, with, without) in cases { + check(&db, &format!("(in? {x} {with})"), true); + check(&db, &format!("(in? {x} {without})"), false); + check(&db, &format!("(not-in? {x} {with})"), false); + check(&db, &format!("(not-in? {x} {without})"), true); + } +} + +#[test] +fn membership_is_set_equality() { + let db = one(); + // -0.0 and 0.0 are one element. + check(&db, "(in? 0.0 #{-0.0})", true); + check(&db, "(in? -0.0 #{0.0})", true); + // An integer is not a float, a string is not a keyword. + check(&db, "(in? 1 #{1.0})", false); + check(&db, "(in? \"k/a\" #{:k/a})", false); + check(&db, "(any-in? #{0.0} #{-0.0 5})", true); + check(&db, "(all-in? #{-0.0} #{0.0})", true); +} + +#[test] +fn nan_is_one_element() { + let db = one(); + let p = db + .prepare("(query [:find ?x :where [?x :t/k _] [(in? $x $s)]])") + .unwrap(); + let run = |x: f64, s: Vec| match p + .execute(&[ + ("x", BindValue::Val(Value::Float(x))), + ("s", BindValue::Val(set(s))), + ]) + .unwrap() + { + QueryResult::QueryResults { results, .. } => results.len(), + _ => panic!("expected query results"), + }; + assert_eq!( + run(f64::NAN, vec![Value::Float(f64::NAN)]), + 1, + "NaN in #{{NaN}}" + ); + assert_eq!( + run(f64::NAN, vec![Value::Float(1.0)]), + 0, + "NaN not in #{{1.0}}" + ); + assert_eq!( + run(1.0, vec![Value::Float(f64::NAN)]), + 0, + "1.0 not in #{{NaN}}" + ); +} + +#[test] +fn keyword_is_not_a_ref_to_its_entity() { + let db = Minigraf::in_memory().unwrap(); + db.execute("(transact [[:alice :person/name \"Alice\"] [:erin :user/friends #{:alice}]])") + .unwrap(); + // ?a is a ref to the entity :alice names; the set holds the keyword. + let q = |op: &str| { + format!( + "(query [:find ?a :where [?a :person/name \"Alice\"] [:erin :user/friends ?fs] [({op} ?a ?fs)]])" + ) + }; + assert_eq!(rows(&db, &q("in?")).len(), 0, "ref not in a keyword set"); + assert_eq!( + rows(&db, &q("not-in?")).len(), + 1, + "ref not-in a keyword set" + ); + // A ref element matches the ref. + let alice_u = Uuid::new_v5(&Uuid::NAMESPACE_OID, b":alice"); + let other = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; + let refq = |op: &str, s: &str| { + format!("(query [:find ?a :where [?a :person/name \"Alice\"] [({op} ?a {s})]])") + }; + let with = format!("#{{#uuid \"{alice_u}\" #uuid \"{other}\"}}"); + let without = format!("#{{#uuid \"{other}\"}}"); + assert_eq!(rows(&db, &refq("in?", &with)).len(), 1, "ref in a ref set"); + assert_eq!( + rows(&db, &refq("in?", &without)).len(), + 0, + "ref not in another ref set" + ); + assert_eq!( + rows(&db, &refq("not-in?", &without)).len(), + 1, + "ref not-in another ref set" + ); + let alice = Uuid::new_v5(&Uuid::NAMESPACE_OID, b":alice"); + let p = db + .prepare("(query [:find ?fs :where [:erin :user/friends ?fs] [(in? $a ?fs)]])") + .unwrap(); + match p.execute(&[("a", BindValue::Entity(alice))]).unwrap() { + QueryResult::QueryResults { results, .. } => { + assert_eq!(results.len(), 0, "entity bind is a ref, not the keyword") + } + _ => panic!("expected query results"), + } +} + +#[test] +fn set_against_set() { + let db = one(); + check(&db, "(any-in? #{1 2} #{2 3})", true); + check(&db, "(any-in? #{1 2} #{3 4})", false); + check(&db, "(all-in? #{1 2} #{1 2 3})", true); + check(&db, "(all-in? #{1 2 3} #{1 2})", false); + check(&db, "(all-in? #{1 4} #{1 2 3})", false); + check(&db, "(all-in? #{1 2} #{2 1})", true); + check(&db, "(none-in? #{1 2} #{3 4})", true); + check(&db, "(none-in? #{1 2} #{2 3})", false); + // The larger set first. + check(&db, "(any-in? #{1 2 3 4} #{4})", true); + check(&db, "(none-in? #{1 2 3 4} #{5})", true); +} + +#[test] +fn every_empty_set_case() { + let db = one(); + check(&db, "(in? 1 #{})", false); + check(&db, "(not-in? 1 #{})", true); + check(&db, "(all-in? #{} #{1})", true); + check(&db, "(all-in? #{} #{})", true); + check(&db, "(none-in? #{} #{1})", true); + check(&db, "(none-in? #{} #{})", true); + check(&db, "(any-in? #{} #{1})", false); + check(&db, "(any-in? #{} #{})", false); + check(&db, "(any-in? #{1} #{})", false); + check(&db, "(all-in? #{1} #{})", false); + check(&db, "(none-in? #{1} #{})", true); +} + +#[test] +fn wrong_operand_shapes_are_type_mismatches() { + let db = one(); + // A set as in?'s element. + mismatch(&db, "(in? #{1} #{1 2})"); + mismatch(&db, "(not-in? #{1} #{1 2})"); + // A scalar where a set is needed: not-in? is not true then. + mismatch(&db, "(in? 1 1)"); + mismatch(&db, "(not-in? 1 2)"); + mismatch(&db, "(not-in? 1 \"abc\")"); + for op in ["any-in?", "all-in?", "none-in?"] { + mismatch(&db, &format!("({op} 1 #{{1}})")); + mismatch(&db, &format!("({op} #{{1}} 1)")); + mismatch(&db, &format!("({op} nil nil)")); + } +} + +#[test] +fn stored_operands_pending_and_committed() { + let path = tempfile::tempdir().unwrap(); + let file = path.path().join("p.graph"); + let db = Minigraf::open(&file).unwrap(); + db.execute( + "(transact [[:alice :user/role :role/admin] [:alice :user/scopes #{:scope/read :scope/write}] + [:bob :user/role :role/guest] [:bob :user/scopes #{}] + [:carol :user/role :role/owner] [:carol :user/scopes #{:scope/read}]])", + ) + .unwrap(); + let check_all = |db: &Minigraf, phase: &str| { + let staff = rows( + db, + "(query [:find ?e :where [?e :user/role ?r] [(in? ?r #{:role/admin :role/owner})]])", + ); + assert_eq!(staff.len(), 2, "{phase}: admin and owner"); + let readers = rows( + db, + "(query [:find ?e :where [?e :user/scopes ?s] [(any-in? ?s #{:scope/read})]])", + ); + assert_eq!(readers.len(), 2, "{phase}: alice and carol read"); + let within = rows( + db, + "(query [:find ?e :where [?e :user/scopes ?s] [(all-in? ?s #{:scope/read})]])", + ); + assert_eq!(within.len(), 2, "{phase}: bob's empty set and carol's"); + let none = rows( + db, + "(query [:find ?e ?n :where [?e :user/scopes ?s] [(none-in? ?s #{:scope/write}) ?n]])", + ); + assert_eq!(none.len(), 3, "{phase}: a Boolean per entity"); + let trues = none.iter().filter(|r| r[1] == Value::Boolean(true)).count(); + assert_eq!(trues, 2, "{phase}: bob and carol write nothing"); + // A role is a keyword, not a set: all-in? over it binds nothing. + let bad = rows( + db, + "(query [:find ?e :where [?e :user/role ?r] [(all-in? ?r #{:role/admin}) ?b]])", + ); + assert_eq!(bad.len(), 0, "{phase}: scalar where a set is needed"); + }; + check_all(&db, "pending"); + db.checkpoint().unwrap(); + check_all(&db, "committed"); + drop(db); + let db = Minigraf::open(&file).unwrap(); + check_all(&db, "reopened"); +} + +#[test] +fn equality_between_sets() { + let db = one(); + check(&db, "(= #{1 2} #{2 1})", true); + check(&db, "(= #{1 2} #{1})", false); + check(&db, "(!= #{1 2} #{1})", true); + check(&db, "(= #{1} 1)", false); +} + +#[test] +fn contains_is_still_string_only() { + let db = one(); + check(&db, "(contains? \"abc\" \"b\")", true); + mismatch(&db, "(contains? #{\"b\"} \"b\")"); + mismatch(&db, "(contains? \"b\" #{\"b\"})"); +} + +#[test] +fn wrong_arity_is_prs_065() { + let db = one(); + for op in ["in?", "not-in?", "any-in?", "all-in?", "none-in?"] { + for args in ["1", "1 #{1} #{2}"] { + let q = format!("(query [:find ?x :where [?x :t/k _] [({op} {args})]])"); + let err = db.execute(&q).err().expect("arity error"); + assert_eq!(err.code(), "PRS-065", "{op} with {args}"); + } + } +} + +#[test] +fn names_cannot_be_registered() { + let db = one(); + for op in ["in?", "not-in?", "any-in?", "all-in?", "none-in?"] { + assert!( + db.register_predicate(op, |_| true).is_err(), + "{op} is built in" + ); + } +} From 08cbfe487d074193169a4997e55880594f63d3e6 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Sat, 24 Oct 2026 18:10:41 +0530 Subject: [PATCH 3/3] docs: test counts for #316 PR 2 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TcpoiFyFeQQQELL872LfQF --- CLAUDE.md | 2 +- docs/TEST_COVERAGE.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b004bf6be..64935739b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -215,7 +215,7 @@ Auto-migrates v7 → v8 on open (spec §9, with a backup meta page). v1–v6 are ## Test Coverage -**1760 tests** (1747 passing, 13 ignored; unit + integration + doc). Engine answers once checked by the executor are frozen in `tests/answers/` (#551). +**1775 tests** (1762 passing, 13 ignored; unit + integration + doc). Engine answers once checked by the executor are frozen in `tests/answers/` (#551). See `docs/TEST_COVERAGE.md` for the full per-file breakdown. **Testing conventions** — see the Testing Conventions section below before writing any tests. diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index ec69b096e..40d080ad2 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -4,9 +4,9 @@ | | Default features | With `--features test-hooks` | |---|---|---| -| **Passing** | 1,747 | 1,765 | +| **Passing** | 1,762 | 1,780 | | **Ignored** | 13 | 14 | -| **Total** | 1,760 | 1,779 | +| **Total** | 1,775 | 1,794 | **Verified**: 2026-10-24 with `cargo test --quiet`.