From ef529f6fd960b351b3423676195c1fe132f790c9 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Sun, 25 Oct 2026 02:07:18 +0530 Subject: [PATCH 1/3] feat: set values (#316) Shim synced with the binding template: Value.SET both ways, a nested set is API-021, execute() returns a set as an array. A Python set or frozenset binds a set value (UUID elements are refs). Pin core v3 at 7c8c5540. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TcpoiFyFeQQQELL872LfQF --- Cargo.toml | 2 +- README.md | 7 +++++ consumer-test/contract.py | 7 +++++ minigraf/__init__.py | 48 +++++++++++++++++++++++-------- src/lib.rs | 59 ++++++++++++++++++++++++++++++++++++++- tests/test_prepared.py | 43 ++++++++++++++++++++++++++++ 6 files changed, 153 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 58ae775..4e52891 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "src/uniffi_bindgen.rs" [dependencies] # Unreleased v3 core (format v8). Switches to minigraf = "=3.0.0" at the v3.0.0 release. -minigraf = { git = "https://github.com/project-minigraf/minigraf", rev = "5f2ba46e0c5a9954d90e52f60daaad2128d91973" } +minigraf = { git = "https://github.com/project-minigraf/minigraf", rev = "7c8c554096209ddc2bf1342b9a4eef3ef77c67c3" } uniffi = { version = "0.31.1", features = ["cli"] } thiserror = "2.0.18" serde_json = "1.0.149" diff --git a/README.md b/README.md index ae7b0dc..74d7707 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,13 @@ other Python type raises `TypeError`. A missing slot raises `MiniGrafError.Api` `[API-022]`, a slot in an attribute position `[API-023]`, and a value of the wrong kind for its slot `[API-024]`. +A `set` or `frozenset` binds a set value, for `in?` and the other set predicates or a +set-valued attribute: `pq.execute(ids={1, 2, 3})`. Its elements are `bool`, `int`, `float`, +`str`, `None` or `uuid.UUID` (a ref). `minigraf.Value` is not hashable, so a set with keyword +elements is written `minigraf.Value.SET([minigraf.Value.KEYWORD(":role/admin")])`. A set +inside a set raises `MiniGrafError.Api` with `[API-021]`; a `list` is not a set. Set values +come back as JSON arrays from `execute()` and as `Value.SET([...])` in fact records. + ## Fact log and log writer `db.fact_log(filter)` streams every fact version (assertions and retractions, with diff --git a/consumer-test/contract.py b/consumer-test/contract.py index 5630142..c0fd81e 100644 --- a/consumer-test/contract.py +++ b/consumer-test/contract.py @@ -31,6 +31,13 @@ def names(db): assert typed == [["Alice"]], "prepared query takes a typed value" print("ok: prepared query runs with plain and typed bind values") +mem.execute('(transact [[:alice :tags #{1 "x"}]])') +tags = json.loads(mem.execute("(query [:find ?t :where [?e :tags ?t]])"))["results"] +assert tags == [["x", 1]], "a set value comes back as an array" +member = mem.prepare("(query [:find ?t :where [?e :tags ?t] [(in? 1 $s)]])") +assert json.loads(member.execute(s={1, 2}))["results"] == [["x", 1]], "a set binds" +print("ok: set values come back and bind") + try: mem.execute("(query [:find") except MiniGrafError: diff --git a/minigraf/__init__.py b/minigraf/__init__.py index 40e55a3..2f693ae 100644 --- a/minigraf/__init__.py +++ b/minigraf/__init__.py @@ -91,32 +91,58 @@ def Timestamp(ms): _I64_MIN, _I64_MAX = -(2**63), 2**63 - 1 -def _bind_value(name, v): - """A plain Python value or a typed form as a ``MiniGrafBindValue``.""" - if isinstance(v, MiniGrafBindValue): - return v +def _plain_value(name, v): + """A plain Python value as a ``MiniGrafValue``, or ``None`` if it is none.""" if isinstance(v, MiniGrafValue): - return MiniGrafBindValue.VALUE(v) + return v # bool before int: True is an int in Python. if isinstance(v, bool): - return MiniGrafBindValue.VALUE(MiniGrafValue.BOOL(v)) + return MiniGrafValue.BOOL(v) if isinstance(v, int): if not _I64_MIN <= v <= _I64_MAX: raise MiniGrafError.Api( f"[API-017] invalid argument: slot ${name}: {v} is outside the 64-bit integer range" ) - return MiniGrafBindValue.VALUE(MiniGrafValue.INT64(v)) + return MiniGrafValue.INT64(v) if isinstance(v, float): - return MiniGrafBindValue.VALUE(MiniGrafValue.FLOAT64(v)) + return MiniGrafValue.FLOAT64(v) if isinstance(v, str): - return MiniGrafBindValue.VALUE(MiniGrafValue.TEXT(v)) + return MiniGrafValue.TEXT(v) if v is None: - return MiniGrafBindValue.VALUE(MiniGrafValue.NULL()) + return MiniGrafValue.NULL() + if isinstance(v, (set, frozenset)): + return MiniGrafValue.SET([_set_element(name, e) for e in v]) + return None + + +def _set_element(name, e): + """An element of a Python set as a ``MiniGrafValue``. A UUID is a ref. + ``minigraf.Value`` is not hashable, so keyword elements need + ``Value.SET([...])``.""" + value = _plain_value(name, e) + if value is not None: + return value + if isinstance(e, _uuid.UUID): + return MiniGrafValue.REF(str(e)) + raise TypeError( + f"slot ${name}: cannot put a {type(e).__name__} in a set; use bool, int, float, " + "str, None, uuid.UUID, or Value.SET([...]) for keywords" + ) + + +def _bind_value(name, v): + """A plain Python value or a typed form as a ``MiniGrafBindValue``.""" + if isinstance(v, MiniGrafBindValue): + return v + value = _plain_value(name, v) + if value is not None: + return MiniGrafBindValue.VALUE(value) if isinstance(v, _uuid.UUID): return MiniGrafBindValue.ENTITY(str(v)) raise TypeError( f"slot ${name}: cannot bind a {type(v).__name__}; use bool, int, float, str, " - "None, uuid.UUID, a minigraf.Value or minigraf.Entity/TxCount/Timestamp/ANY_VALID_TIME" + "None, set, frozenset, uuid.UUID, a minigraf.Value or " + "minigraf.Entity/TxCount/Timestamp/ANY_VALID_TIME" ) diff --git a/src/lib.rs b/src/lib.rs index e1a2119..462c5b9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -198,7 +198,8 @@ impl OpenOptions { // ─── Values and fact records ───────────────────────────────────────────────── /// A fact value with its type kept, so a record can be written back exactly. -/// `Ref` holds an entity UUID string. +/// `Ref` holds an entity UUID string. `Set` holds the elements of a set +/// value in canonical order; an element that is itself a `Set` is API-021. #[derive(Debug, Clone, PartialEq, uniffi::Enum)] pub enum MiniGrafValue { Text { value: String }, @@ -208,6 +209,7 @@ pub enum MiniGrafValue { Ref { value: String }, Keyword { value: String }, Null, + Set { value: Vec }, } impl From for MiniGrafValue { @@ -222,6 +224,14 @@ impl From for MiniGrafValue { }, Value::Keyword(value) => MiniGrafValue::Keyword { value }, Value::Null => MiniGrafValue::Null, + Value::Set(set) => MiniGrafValue::Set { + value: set.iter().cloned().map(MiniGrafValue::from).collect(), + }, + // A value kind newer than this binding: its debug form, until + // the binding learns it. + other => MiniGrafValue::Text { + value: format!("{other:?}"), + }, } } } @@ -236,6 +246,12 @@ impl MiniGrafValue { MiniGrafValue::Ref { value } => Value::Ref(parse_uuid(value, "ref value")?), MiniGrafValue::Keyword { value } => Value::Keyword(value.clone()), MiniGrafValue::Null => Value::Null, + MiniGrafValue::Set { value } => Value::Set(minigraf::ValueSet::new( + value + .iter() + .map(MiniGrafValue::to_core) + .collect::, _>>()?, + )?), }) } } @@ -738,6 +754,9 @@ fn value_to_json(v: &Value) -> serde_json::Value { Value::Ref(uuid) => JVal::String(uuid.to_string()), Value::Keyword(k) => JVal::String(k.clone()), Value::Null => JVal::Null, + Value::Set(set) => JVal::Array(set.iter().map(value_to_json).collect()), + // A value kind newer than this binding: its debug form. + other => JVal::String(format!("{other:?}")), } } @@ -971,12 +990,50 @@ mod tests { Value::Ref(id), Value::Keyword(":k/w".into()), Value::Null, + Value::Set( + minigraf::ValueSet::new([ + Value::Integer(1), + Value::Keyword(":k/w".into()), + Value::Ref(id), + ]) + .expect("set"), + ), + Value::Set(minigraf::ValueSet::default()), ] { let back = MiniGrafValue::from(v.clone()).to_core().expect("to_core"); assert!(back == v, "value changed in round trip"); } } + #[test] + fn set_elements_dedup_and_nesting_is_api_021() { + let one = || MiniGrafValue::Int64 { value: 1 }; + let back = MiniGrafValue::Set { + value: vec![one(), MiniGrafValue::Text { value: "a".into() }, one()], + } + .to_core() + .expect("to_core"); + assert_eq!( + MiniGrafValue::from(back), + MiniGrafValue::Set { + value: vec![MiniGrafValue::Text { value: "a".into() }, one()] + }, + "duplicates dropped, canonical order" + ); + let e = err(MiniGrafValue::Set { + value: vec![MiniGrafValue::Set { value: vec![] }], + } + .to_core()); + assert_eq!(e.code(), "API-021"); + } + + #[test] + fn set_is_a_json_array() { + let set = + minigraf::ValueSet::new([Value::Integer(2), Value::String("a".into())]).expect("set"); + assert_eq!(value_to_json(&Value::Set(set)), serde_json::json!(["a", 2])); + } + #[test] fn bad_uuid_is_api_017() { let e = err(MiniGrafValue::Ref { diff --git a/tests/test_prepared.py b/tests/test_prepared.py index 1bb121c..0d7e62b 100644 --- a/tests/test_prepared.py +++ b/tests/test_prepared.py @@ -147,3 +147,46 @@ def test_prepared_query_sees_later_writes(db): assert rows(pq.execute(n=4)) == [] db.execute("(transact [[:d :n 4]])") assert len(rows(pq.execute(n=4))) == 1 + + +# ─── Sets (#316) ───────────────────────────────────────────────────────────── + + +def test_python_sets_bind_set_values(db): + member = db.prepare("(query [:find ?e :where [?e :n ?n] [(in? ?n $s)]])") + assert len(rows(member.execute(s={1, 3}))) == 2 + assert len(rows(member.execute(s=frozenset([2])))) == 1 + assert rows(member.execute(s=set())) == [] + typed = Value.SET([Value.INT64(1), Value.INT64(1)]) + assert len(rows(member.execute(s=typed))) == 1 + + +def test_set_values_come_back_typed_and_as_arrays(db): + db.execute('(transact [[:a :tags #{1 :k "x"}]])') + equal = db.prepare("(query [:find ?x :where [?e :tags $s] [?e :n ?x]])") + tags = Value.SET([Value.INT64(1), Value.TEXT("x"), Value.KEYWORD(":k")]) + assert rows(equal.execute(s=tags)) == [[1]] + # A string is no keyword. + assert rows(equal.execute(s={1, "x", ":k"})) == [] + out = db.prepare("(query [:find ?t :where [:a :tags ?t]])") + assert rows(out.execute()) == [["x", 1, ":k"]] + records = [r for r in db.fact_log(minigraf.FactFilter(attributes=[":tags"]))] + assert records[0].value == Value.SET( + [Value.TEXT("x"), Value.INT64(1), Value.KEYWORD(":k")] + ) + + +def test_set_element_forms(db): + pq = db.prepare("(query [:find ?x :where [$e :n ?x] [(in? $e $s)]])") + # A UUID element is a ref. + hit = {uuid.UUID(A)} + assert rows(pq.execute(e=minigraf.Entity(A), s=hit)) == [[1]] + with pytest.raises(MiniGrafError.Api, match=r"^\[API-021\]"): + pq.execute(e=minigraf.Entity(A), s={frozenset([1])}) + with pytest.raises(MiniGrafError.Api, match=r"^\[API-021\]"): + pq.execute(e=minigraf.Entity(A), s=Value.SET([Value.SET([])])) + with pytest.raises(TypeError): + pq.execute(e=minigraf.Entity(A), s={(1, 2)}) + # A list is not a set. + with pytest.raises(TypeError): + pq.execute(e=minigraf.Entity(A), s=[1]) From 692205002aa232963b0c43f3651df1ce29edeb88 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Sun, 25 Oct 2026 02:08:52 +0530 Subject: [PATCH 2/3] test: set results are one cell holding an array Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TcpoiFyFeQQQELL872LfQF --- consumer-test/contract.py | 4 ++-- tests/test_prepared.py | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/consumer-test/contract.py b/consumer-test/contract.py index c0fd81e..7878091 100644 --- a/consumer-test/contract.py +++ b/consumer-test/contract.py @@ -33,9 +33,9 @@ def names(db): mem.execute('(transact [[:alice :tags #{1 "x"}]])') tags = json.loads(mem.execute("(query [:find ?t :where [?e :tags ?t]])"))["results"] -assert tags == [["x", 1]], "a set value comes back as an array" +assert tags == [[["x", 1]]], "a set value comes back as an array" member = mem.prepare("(query [:find ?t :where [?e :tags ?t] [(in? 1 $s)]])") -assert json.loads(member.execute(s={1, 2}))["results"] == [["x", 1]], "a set binds" +assert json.loads(member.execute(s={1, 2}))["results"] == [[["x", 1]]], "a set binds" print("ok: set values come back and bind") try: diff --git a/tests/test_prepared.py b/tests/test_prepared.py index 0d7e62b..9f2db9d 100644 --- a/tests/test_prepared.py +++ b/tests/test_prepared.py @@ -169,7 +169,7 @@ def test_set_values_come_back_typed_and_as_arrays(db): # A string is no keyword. assert rows(equal.execute(s={1, "x", ":k"})) == [] out = db.prepare("(query [:find ?t :where [:a :tags ?t]])") - assert rows(out.execute()) == [["x", 1, ":k"]] + assert rows(out.execute()) == [[["x", 1, ":k"]]] records = [r for r in db.fact_log(minigraf.FactFilter(attributes=[":tags"]))] assert records[0].value == Value.SET( [Value.TEXT("x"), Value.INT64(1), Value.KEYWORD(":k")] @@ -181,10 +181,12 @@ def test_set_element_forms(db): # A UUID element is a ref. hit = {uuid.UUID(A)} assert rows(pq.execute(e=minigraf.Entity(A), s=hit)) == [[1]] - with pytest.raises(MiniGrafError.Api, match=r"^\[API-021\]"): + with pytest.raises(MiniGrafError.Api) as e: pq.execute(e=minigraf.Entity(A), s={frozenset([1])}) - with pytest.raises(MiniGrafError.Api, match=r"^\[API-021\]"): + assert code(e) == "[API-021]" + with pytest.raises(MiniGrafError.Api) as e: pq.execute(e=minigraf.Entity(A), s=Value.SET([Value.SET([])])) + assert code(e) == "[API-021]" with pytest.raises(TypeError): pq.execute(e=minigraf.Entity(A), s={(1, 2)}) # A list is not a set. From 0503bb3b16717d8987b8da1ff6642d10ddd0dea6 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Sun, 25 Oct 2026 02:16:32 +0530 Subject: [PATCH 3/3] chore: Cargo.lock for the core pin at 7c8c5540 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TcpoiFyFeQQQELL872LfQF --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index eb2d922..1f4935c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -410,7 +410,7 @@ checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "minigraf" version = "2.0.5" -source = "git+https://github.com/project-minigraf/minigraf?rev=5f2ba46e0c5a9954d90e52f60daaad2128d91973#5f2ba46e0c5a9954d90e52f60daaad2128d91973" +source = "git+https://github.com/project-minigraf/minigraf?rev=7c8c554096209ddc2bf1342b9a4eef3ef77c67c3#7c8c554096209ddc2bf1342b9a4eef3ef77c67c3" dependencies = [ "anyhow", "chrono",