From e4d46cf589dea9d06733cdd977c05f82c20fdb25 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Sun, 25 Oct 2026 02:11:23 +0530 Subject: [PATCH 1/2] feat: set values (#316) Shim synced with the binding template: MiniGrafValue.Set both ways, a nested set is API-021, execute() returns a set as an array. A Kotlin or Java Set 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 +- consumer-test/src/test/java/ContractTest.java | 14 +++++ java/README.md | 7 +++ .../project_minigraf/minigraf/Minigraf.kt | 18 +++++- .../project_minigraf/minigraf/PreparedTest.kt | 44 ++++++++++++++ src/lib.rs | 59 ++++++++++++++++++- 6 files changed, 139 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ee69e3a..ff07eb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ path = "src/uniffi_bindgen.rs" [dependencies] # Pin to the exact released version. The release workflow updates this line. # 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/consumer-test/src/test/java/ContractTest.java b/consumer-test/src/test/java/ContractTest.java index e3980c1..6fc338d 100644 --- a/consumer-test/src/test/java/ContractTest.java +++ b/consumer-test/src/test/java/ContractTest.java @@ -11,6 +11,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.Test; class ContractTest { @@ -53,6 +54,19 @@ void preparedQueryRunsWithPlainAndTypedValues() throws Exception { } } + @Test + void setValuesComeBackAndBind() throws Exception { + try (MiniGrafDb db = Minigraf.openInMemory()) { + db.execute("(transact [[:alice :tags #{1 \"x\"}]])"); + String tags = db.execute("(query [:find ?t :where [?e :tags ?t]])"); + assertTrue(tags.contains("[[[\"x\",1]]]"), "a set value comes back as an array"); + MiniGrafPreparedQuery pq = + db.prepare("(query [:find ?t :where [?e :tags ?t] [(in? 1 $s)]])"); + String found = Minigraf.execute(pq, Map.of("s", Set.of(1, 2))); + assertTrue(found.contains("[[[\"x\",1]]]"), "a set binds"); + } + } + @Test void invalidDatalogThrowsMiniGrafException() throws Exception { try (MiniGrafDb db = Minigraf.openInMemory()) { diff --git a/java/README.md b/java/README.md index 7f841d6..4dadbba 100644 --- a/java/README.md +++ b/java/README.md @@ -152,6 +152,13 @@ or `Minigraf.entity(uuid)`, `Minigraf.txCount(n)` / `Minigraf.timestamp(ms)` for `IllegalArgumentException`. A missing slot is `MiniGrafException.Api` with `[API-022]`, a slot in an attribute position `[API-023]`, and a value of the wrong kind `[API-024]`. +A `Set` binds a set value, for `in?` and the other set predicates or a set-valued +attribute: `pq.execute("ids" to setOf(1, 2, 3))`, or `Set.of(1, 2, 3)` from Java. Its +elements are the plain values above or a `MiniGrafValue` (`MiniGrafValue.Keyword(":role/admin")`), +and a `UUID` element is a ref. A set inside a set is `MiniGrafException.Api` with +`[API-021]`; another kind of element, or a `List`, throws `IllegalArgumentException`. Set +values come back as JSON arrays from `execute` and as `MiniGrafValue.Set` in fact records. + Errors are `MiniGrafException.Parse`, `.Query`, `.Storage`, `.Api` (a bad argument, a closed object, a bind mistake) or `.Other`. diff --git a/java/src/main/kotlin/io/github/project_minigraf/minigraf/Minigraf.kt b/java/src/main/kotlin/io/github/project_minigraf/minigraf/Minigraf.kt index 3c48f92..e90136a 100644 --- a/java/src/main/kotlin/io/github/project_minigraf/minigraf/Minigraf.kt +++ b/java/src/main/kotlin/io/github/project_minigraf/minigraf/Minigraf.kt @@ -106,8 +106,10 @@ object Minigraf { * [v] as a bind value. `Boolean`; `Byte`, `Short`, `Int` and `Long` as integers; * `Float` and `Double` as floats; `String`; `null`; and `java.util.UUID` (an * entity, which also fits value and rule-argument slots) bind as they are, as do - * a [MiniGrafValue] and a [MiniGrafBindValue]. Any other type is - * [IllegalArgumentException]. + * a [MiniGrafValue] and a [MiniGrafBindValue]. A `Set` binds a set value + * (minigraf#316): its elements are any of those plain values or a [MiniGrafValue], + * and a `UUID` element is a ref. Any other type, or a set element that is not a + * value, is [IllegalArgumentException]. */ fun bindValue(name: String, v: Any?): MiniGrafBindValue = when (v) { null -> MiniGrafBindValue.Value(MiniGrafValue.Null) @@ -118,12 +120,22 @@ fun bindValue(name: String, v: Any?): MiniGrafBindValue = when (v) { is Float, is Double -> MiniGrafBindValue.Value(MiniGrafValue.Float64((v as Number).toDouble())) is String -> MiniGrafBindValue.Value(MiniGrafValue.Text(v)) is UUID -> MiniGrafBindValue.Entity(v.toString()) + is kotlin.collections.Set<*> -> MiniGrafBindValue.Value(MiniGrafValue.Set(v.map { setElement(name, it) })) else -> throw IllegalArgumentException( "slot \$$name: cannot bind a ${v::class.java.name}; use Boolean, an integer or " + - "floating type, String, null, UUID, a MiniGrafValue or a MiniGrafBindValue", + "floating type, String, null, UUID, Set, a MiniGrafValue or a MiniGrafBindValue", ) } +/** An element of a `Set` bind value. A set element is passed on, for core's API-021. */ +private fun setElement(name: String, e: Any?): MiniGrafValue = when (e) { + is UUID -> MiniGrafValue.Ref(e.toString()) + else -> when (val b = bindValue(name, e)) { + is MiniGrafBindValue.Value -> b.value + else -> throw IllegalArgumentException("slot \$$name: a set element must be a value") + } +} + private fun bindValues(bindings: Map): Map = bindings.mapValues { (name, v) -> bindValue(name, v) } diff --git a/java/src/test/kotlin/io/github/project_minigraf/minigraf/PreparedTest.kt b/java/src/test/kotlin/io/github/project_minigraf/minigraf/PreparedTest.kt index 7ee3f56..96b68ae 100644 --- a/java/src/test/kotlin/io/github/project_minigraf/minigraf/PreparedTest.kt +++ b/java/src/test/kotlin/io/github/project_minigraf/minigraf/PreparedTest.kt @@ -119,4 +119,48 @@ class PreparedTest { db.execute("(transact [[:d :n 4]])") assertEquals(1, rows(pq.execute("n" to 4)).size) } + + // ─── Sets (minigraf#316) ──────────────────────────────────────────────── + + @Test + fun kotlinSetsBindSetValues() { + val pq = db().prepare("(query [:find ?e :where [?e :n ?n] [(in? ?n \$s)]])") + assertEquals(2, rows(pq.execute("s" to setOf(1, 3))).size) + assertEquals(1, rows(pq.execute("s" to setOf(2L, 2L))).size) + assertEquals(0, rows(pq.execute("s" to emptySet())).size) + val typed = MiniGrafValue.Set(listOf(MiniGrafValue.Int64(1), MiniGrafValue.Int64(1))) + assertEquals(1, rows(pq.execute("s" to typed)).size) + } + + @Test + fun setValuesComeBackTypedAndAsArrays() { + val db = db() + db.execute("(transact [[:a :tags #{1 :k \"x\"}]])") + val equal = db.prepare("(query [:find ?x :where [?e :tags \$s] [?e :n ?x]])") + val tags = setOf(1, "x", MiniGrafValue.Keyword(":k")) + assertEquals(listOf(listOf(1)), rows(equal.execute("s" to tags))) + assertEquals(0, rows(equal.execute("s" to setOf(1, "x", ":k"))).size, "a string is no keyword") + val out = db.prepare("(query [:find ?t :where [:a :tags ?t]])") + assertEquals(listOf(listOf(listOf("x", 1, ":k"))), rows(out.execute())) + val record = db.factLog(FactFilter(attributes = listOf(":tags"))).use { it.records(10).first() } + assertEquals( + MiniGrafValue.Set(listOf(MiniGrafValue.Text("x"), MiniGrafValue.Int64(1), MiniGrafValue.Keyword(":k"))), + record.value, + ) + } + + @Test + fun setElementForms() { + val db = db() + val a = UUID.fromString(entityA(db)) + val pq = db.prepare("(query [:find ?x :where [\$e :n ?x] [(in? \$e \$s)]])") + assertEquals(listOf(listOf(1)), rows(pq.execute("e" to a, "s" to setOf(a))), "a UUID element is a ref") + fun code(e: MiniGrafException.Api) = e.text.take(9) + assertEquals( + "[API-021]", + code(assertThrows { pq.execute("e" to a, "s" to setOf(setOf(1))) }), + ) + assertThrows { pq.execute("e" to a, "s" to setOf(MiniGrafBindValue.TxCount(1))) } + assertThrows { pq.execute("e" to a, "s" to setOf(listOf(1))) } + } } 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 { From e5d67e70fc0dac4ede6ab237a3513ecbd970b957 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Sun, 25 Oct 2026 02:16:40 +0530 Subject: [PATCH 2/2] 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 a77813e..9054e7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -440,7 +440,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[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",