diff --git a/Cargo.lock b/Cargo.lock index caf11f4..a77813e 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=949813ce27273d0c2820690df844e76951cb7996#949813ce27273d0c2820690df844e76951cb7996" +source = "git+https://github.com/project-minigraf/minigraf?rev=5f2ba46e0c5a9954d90e52f60daaad2128d91973#5f2ba46e0c5a9954d90e52f60daaad2128d91973" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 0e2276d..ee69e3a 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 = "949813ce27273d0c2820690df844e76951cb7996" } +minigraf = { git = "https://github.com/project-minigraf/minigraf", rev = "5f2ba46e0c5a9954d90e52f60daaad2128d91973" } 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 a2d765b..e3980c1 100644 --- a/consumer-test/src/test/java/ContractTest.java +++ b/consumer-test/src/test/java/ContractTest.java @@ -5,9 +5,12 @@ import io.github.project_minigraf.minigraf.MiniGrafDb; import io.github.project_minigraf.minigraf.MiniGrafException; +import io.github.project_minigraf.minigraf.MiniGrafPreparedQuery; +import io.github.project_minigraf.minigraf.MiniGrafValue; import io.github.project_minigraf.minigraf.Minigraf; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Map; import org.junit.jupiter.api.Test; class ContractTest { @@ -35,6 +38,21 @@ void fileBackedSurvivesReopen() throws Exception { } } + @Test + void preparedQueryRunsWithPlainAndTypedValues() throws Exception { + try (MiniGrafDb db = Minigraf.openInMemory()) { + db.execute("(transact [[:alice :name \"Alice\"] [:bob :name \"Bob\"]])"); + MiniGrafPreparedQuery pq = + db.prepare("(query [:find ?n :where [?e :name $name] [?e :name ?n]])"); + for (String name : new String[] {"Alice", "Bob"}) { + String result = Minigraf.execute(pq, Map.of("name", name)); + assertTrue(result.contains("[[\"" + name + "\"]]"), "prepared query finds " + name); + } + String typed = Minigraf.execute(pq, Map.of("name", new MiniGrafValue.Text("Alice"))); + assertTrue(typed.contains("[[\"Alice\"]]"), "a typed value binds"); + } + } + @Test void invalidDatalogThrowsMiniGrafException() throws Exception { try (MiniGrafDb db = Minigraf.openInMemory()) { diff --git a/java/README.md b/java/README.md index bcad8b4..7f841d6 100644 --- a/java/README.md +++ b/java/README.md @@ -127,6 +127,34 @@ A record's value is a `MiniGrafValue` (`Text`, `Int64`, `Float64`, `Bool`, `Ref` is the `validTo` of a fact valid forever. Errors are `MiniGrafException`; `Minigraf.errorMessage(e)` gives the text starting with its code, such as `[API-015]`. +## Prepared queries + +`db.prepare(text)` parses a query once; run it with its `$slots` bound (names without +the `$`). Each run reads the database as it is then, and a prepared query keeps its +database open until both are closed. + +```java +MiniGrafPreparedQuery pq = db.prepare("(query [:find ?n :where [?e :age $age] [?e :name ?n]])"); +String thirty = Minigraf.execute(pq, Map.of("age", 30)); +try (MiniGrafCursor cursor = Minigraf.query(pq, Map.of("age", 40))) { /* batches */ } +``` + +```kotlin +val thirty = pq.execute("age" to 30) +pq.query("age" to 40).use { cursor -> cursor.batches().forEach { /* rows */ } } +``` + +`Boolean`, `Byte`/`Short`/`Int`/`Long`, `Float`/`Double`, `String`, `null` and +`java.util.UUID` (an entity, which also fits value slots as a ref) bind as they are. +For the other kinds pass a `MiniGrafValue` (`Keyword`, `Ref`, a whole-number `Float64`), +or `Minigraf.entity(uuid)`, `Minigraf.txCount(n)` / `Minigraf.timestamp(ms)` for +`:as-of`, and `Minigraf.ANY_VALID_TIME` for `:valid-at`. Any other type throws +`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]`. + +Errors are `MiniGrafException.Parse`, `.Query`, `.Storage`, `.Api` (a bad argument, a +closed object, a bind mistake) or `.Other`. + ## Links - [Full Java/JVM integration guide](https://github.com/project-minigraf/minigraf/wiki/Use-Cases#java--jvm) 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 c1a48f1..3c48f92 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 @@ -1,5 +1,7 @@ package io.github.project_minigraf.minigraf +import java.util.UUID + /** * Static entry points for Java callers. * @@ -67,8 +69,72 @@ object Minigraf { /** The log's records, read [batchSize] at a time. */ @JvmStatic fun records(log: MiniGrafFactLog, batchSize: Int): Iterable = log.records(batchSize) + + /** + * Runs [prepared] with [bindings] (slot names without the `$`) and returns its + * result as JSON. Plain values bind as they are; see [bindValue]. + */ + @JvmStatic + @Throws(MiniGrafException::class) + fun execute(prepared: MiniGrafPreparedQuery, bindings: Map): String = + prepared.execute(bindValues(bindings)) + + /** Runs [prepared] with [bindings], as [execute] does, and returns a cursor. */ + @JvmStatic + @Throws(MiniGrafException::class) + fun query(prepared: MiniGrafPreparedQuery, bindings: Map): MiniGrafCursor = + prepared.query(bindValues(bindings)) + + /** Binds an entity slot (it also fits a value slot as a ref). */ + @JvmStatic + fun entity(uuid: UUID): MiniGrafBindValue = MiniGrafBindValue.Entity(uuid.toString()) + + /** Binds `:as-of $slot` to the transaction counter [n]. */ + @JvmStatic + fun txCount(n: Long): MiniGrafBindValue = MiniGrafBindValue.TxCount(n) + + /** Binds `:as-of $slot` or `:valid-at $slot` to Unix milliseconds. */ + @JvmStatic + fun timestamp(ms: Long): MiniGrafBindValue = MiniGrafBindValue.Timestamp(ms) + + /** Binds `:valid-at $slot` to every valid time, like `:any-valid-time`. */ + @JvmField + val ANY_VALID_TIME: MiniGrafBindValue = MiniGrafBindValue.AnyValidTime } +/** + * [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]. + */ +fun bindValue(name: String, v: Any?): MiniGrafBindValue = when (v) { + null -> MiniGrafBindValue.Value(MiniGrafValue.Null) + is MiniGrafBindValue -> v + is MiniGrafValue -> MiniGrafBindValue.Value(v) + is Boolean -> MiniGrafBindValue.Value(MiniGrafValue.Bool(v)) + is Byte, is Short, is Int, is Long -> MiniGrafBindValue.Value(MiniGrafValue.Int64((v as Number).toLong())) + 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()) + 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", + ) +} + +private fun bindValues(bindings: Map): Map = + bindings.mapValues { (name, v) -> bindValue(name, v) } + +/** Runs the query with [bindings] (`"slot" to value`, no `$`) and returns its JSON. */ +fun MiniGrafPreparedQuery.execute(vararg bindings: Pair): String = + execute(bindValues(bindings.toMap())) + +/** Runs the query with [bindings] and returns a cursor over its rows. */ +fun MiniGrafPreparedQuery.query(vararg bindings: Pair): MiniGrafCursor = + query(bindValues(bindings.toMap())) + /** Builds [OpenOptions] fluently, for Java. */ class OpenOptionsBuilder internal constructor() { private var options = OpenOptions() @@ -126,4 +192,5 @@ val MiniGrafException.text: String is MiniGrafException.Query -> msg is MiniGrafException.Parse -> msg is MiniGrafException.Other -> msg + is MiniGrafException.Api -> msg } diff --git a/java/src/test/java/io/github/project_minigraf/minigraf/JavaInteropTest.java b/java/src/test/java/io/github/project_minigraf/minigraf/JavaInteropTest.java index 9ed4562..87d1671 100644 --- a/java/src/test/java/io/github/project_minigraf/minigraf/JavaInteropTest.java +++ b/java/src/test/java/io/github/project_minigraf/minigraf/JavaInteropTest.java @@ -9,6 +9,8 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -73,4 +75,32 @@ void cursorFactLogAndLogWriterFromJava() throws Exception { assertFalse(new File(abandoned).exists()); assertFalse(new File(abandoned + ".partial").exists()); } + + @Test + void preparedQueryFromJava() throws Exception { + MiniGrafDb db = Minigraf.openInMemory(); + db.execute("(transact [[:a :n 1] [:b :n 2] [:a :s \"x\"]])"); + MiniGrafPreparedQuery pq = db.prepare("(query [:find ?x :where [?e :n $n] [?e :n ?x]])"); + for (long n = 1; n <= 2; n++) { + String result = Minigraf.execute(pq, Map.of("n", n)); + assertTrue(result.contains("\"results\":[[" + n + "]]"), result); + } + assertTrue(Minigraf.execute(pq, Map.of("n", 9)).contains("\"results\":[]")); + + try (MiniGrafCursor cursor = Minigraf.query(pq, Map.of("n", 1))) { + int batches = 0; + for (String batch : Minigraf.batches(cursor, 10)) { + batches++; + } + assertEquals(1, batches); + } + + MiniGrafPreparedQuery asOf = db.prepare("(query [:find ?x :as-of $t :where [?e :n ?x]])"); + assertTrue(Minigraf.execute(asOf, Map.of("t", Minigraf.txCount(1))).contains("[1]")); + + MiniGrafException e = assertThrows(MiniGrafException.class, () -> Minigraf.execute(pq, Map.of())); + assertTrue(e instanceof MiniGrafException.Api); + assertTrue(Minigraf.errorMessage(e).startsWith("[API-022]")); + assertThrows(IllegalArgumentException.class, () -> Minigraf.execute(pq, Map.of("n", new Object()))); + } } 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 new file mode 100644 index 0000000..7ee3f56 --- /dev/null +++ b/java/src/test/kotlin/io/github/project_minigraf/minigraf/PreparedTest.kt @@ -0,0 +1,122 @@ +package io.github.project_minigraf.minigraf + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private val json = jacksonObjectMapper() + +/** Prepared queries (minigraf#181). */ +class PreparedTest { + + private fun rows(s: String): List> = json.readValue>(s)["results"] + .let { @Suppress("UNCHECKED_CAST") (it as List>) } + + private fun entityA(db: MiniGrafDb): String = + rows(db.execute("(query [:find ?e :where [?e :s \"x\"]])"))[0][0] as String + + private fun db(): MiniGrafDb { + val db = MiniGrafDb.openInMemory() + db.execute( + "(transact [[:a :n 1] [:b :n 2] [:c :n 3] [:a :s \"x\"] [:a :f 2.5] [:a :w 2.0]" + + " [:a :t true] [:a :k :kw] [:a :z nil]])", + ) + return db + } + + @Test + fun preparedQueryRunsAgainWithNewBindings() { + val pq = db().prepare("(query [:find ?x :where [?e :n \$n] [?e :n ?x]])") + for (n in 1..3) assertEquals(listOf(listOf(n)), rows(pq.execute("n" to n))) + assertEquals(0, rows(pq.execute("n" to 9L)).size) + } + + @Test + fun everyPlainValueBinds() { + val db = db() + for ((attr, value) in listOf( + ":n" to 1, ":n" to 1L, ":n" to 1.toShort(), ":n" to 1.toByte(), + ":s" to "x", ":f" to 2.5, ":f" to 2.5f, ":w" to 2.0, ":t" to true, ":z" to null, + )) { + val pq = db.prepare("(query [:find ?e :where [?e $attr \$v]])") + assertEquals(1, rows(pq.execute("v" to value)).size, "$attr $value") + } + } + + @Test + fun uuidBindsAnEntityAnywhere() { + val db = db() + val a = UUID.fromString(entityA(db)) + assertEquals(1, rows(db.prepare("(query [:find ?x :where [\$e :n ?x]])").execute("e" to a)).size) + db.execute("(transact [[:b :friend #uuid \"$a\"]])") + db.execute("(rule [(friend-of ?x ?f) [?x :friend ?f]])") + for (q in listOf("(query [:find ?x :where [?x :friend \$f]])", "(query [:find ?x :where (friend-of ?x \$f)])")) { + assertEquals(1, rows(db.prepare(q).execute("f" to a)).size, q) + } + } + + @Test + fun everyTypedValueBinds() { + val db = db() + val a = entityA(db) + for ((q, value) in listOf( + "(query [:find ?e :where [?e :k \$v]])" to MiniGrafValue.Keyword(":kw"), + "(query [:find ?x :where [\$v :n ?x]])" to MiniGrafValue.Ref(a), + "(query [:find ?x :where [\$v :n ?x]])" to MiniGrafBindValue.Entity(a), + "(query [:find ?x :as-of \$v :where [?e :n ?x]])" to MiniGrafBindValue.TxCount(1), + "(query [:find ?x :as-of \$v :where [?e :n ?x]])" to MiniGrafBindValue.Timestamp(Long.MAX_VALUE), + "(query [:find ?x :valid-at \$v :where [:a :n ?x]])" to MiniGrafBindValue.AnyValidTime, + )) { + assertTrue(rows(db.prepare(q).execute("v" to value)).isNotEmpty(), q) + } + } + + @Test + fun preparedCursorMatchesExecute() { + val pq = db().prepare("(query [:find ?e ?n :where [?e :n ?n] [(>= ?n \$min)]])") + val expected = rows(pq.execute("min" to 2)) + val seen = pq.query("min" to 2).use { c -> c.batches(1).sumOf { rows("{\"results\":$it}").size } } + assertEquals(2, expected.size) + assertEquals(expected.size, seen) + } + + @Test + fun unsupportedPlainValueIsIllegalArgument() { + val pq = db().prepare("(query [:find ?e :where [?e :n \$n]])") + assertThrows { pq.execute("n" to listOf(1)) } + } + + @Test + fun bindMistakesAreApiErrors() { + val db = db() + val pq = db.prepare("(query [:find ?e :where [?e :n \$n]])") + fun code(e: MiniGrafException.Api) = e.text.take(9) + assertEquals("[API-022]", code(assertThrows { pq.execute() })) + assertEquals("[API-022]", code(assertThrows { pq.query() })) + assertEquals( + "[API-024]", + code(assertThrows { pq.execute("n" to MiniGrafBindValue.TxCount(1)) }), + ) + assertEquals( + "[API-023]", + code(assertThrows { db.prepare("(query [:find ?v :where [?e \$a ?v]])") }), + ) + assertEquals( + "[API-017]", + code(assertThrows { pq.execute("n" to MiniGrafBindValue.TxCount(-1)) }), + ) + } + + @Test + fun preparedQuerySeesLaterWrites() { + val db = db() + val pq = db.prepare("(query [:find ?e :where [?e :n \$n]])") + assertEquals(0, rows(pq.execute("n" to 4)).size) + db.execute("(transact [[:d :n 4]])") + assertEquals(1, rows(pq.execute("n" to 4)).size) + } +} diff --git a/src/lib.rs b/src/lib.rs index 944f09d..e1a2119 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ )] use minigraf::{QueryResult, Value}; +use std::collections::HashMap; use std::sync::{Arc, Mutex, MutexGuard}; uniffi::setup_scaffolding!(); @@ -28,6 +29,10 @@ pub enum MiniGrafError { Parse { msg: String }, #[error("unknown error: {msg}")] Other { msg: String }, + /// A call the API refuses: a bad argument, a closed object, a bind + /// mistake. The message starts with its `API-` code. + #[error("API error: {msg}")] + Api { msg: String }, } // ─── minigraf::MinigrafError → MiniGrafError conversion ────────────────────── @@ -41,9 +46,8 @@ impl From for MiniGrafError { MiniGrafError::Storage { msg } } minigraf::ErrorCategory::Query => MiniGrafError::Query { msg }, - minigraf::ErrorCategory::Api | minigraf::ErrorCategory::Internal | _ => { - MiniGrafError::Other { msg } - } + minigraf::ErrorCategory::Api => MiniGrafError::Api { msg }, + minigraf::ErrorCategory::Internal | _ => MiniGrafError::Other { msg }, } } } @@ -57,6 +61,7 @@ impl MiniGrafError { MiniGrafError::Query { msg } => MiniGrafError::Query { msg: suffix(msg) }, MiniGrafError::Parse { msg } => MiniGrafError::Parse { msg: suffix(msg) }, MiniGrafError::Other { msg } => MiniGrafError::Other { msg: suffix(msg) }, + MiniGrafError::Api { msg } => MiniGrafError::Api { msg: suffix(msg) }, } } } @@ -354,6 +359,61 @@ impl FactFilter { } } +// ─── Bind values ───────────────────────────────────────────────────────────── + +/// The value of one `$slot` of a prepared query. +/// +/// `Value` fills an entity position with a `Ref`, and a value, expression or +/// rule-argument position. `Entity` fills an entity position, and the others +/// as a `Ref`. `TxCount` and `Timestamp` fill `:as-of`; `Timestamp` and +/// `AnyValidTime` fill `:valid-at`. A bind value of the wrong kind for its +/// slot is API-024. +#[derive(Debug, Clone, PartialEq, uniffi::Enum)] +pub enum MiniGrafBindValue { + Value { + value: MiniGrafValue, + }, + /// An entity UUID string. + Entity { + uuid: String, + }, + /// The counter `:as-of N` compares against; negative is API-017. + TxCount { + value: i64, + }, + /// Unix milliseconds. + Timestamp { + value: i64, + }, + AnyValidTime, +} + +impl MiniGrafBindValue { + fn to_core(&self) -> Result { + Ok(match self { + MiniGrafBindValue::Value { value } => minigraf::BindValue::Val(value.to_core()?), + MiniGrafBindValue::Entity { uuid } => { + minigraf::BindValue::Entity(parse_uuid(uuid, "entity")?) + } + MiniGrafBindValue::TxCount { value } => { + minigraf::BindValue::TxCount(to_u64(*value, "tx count")?) + } + MiniGrafBindValue::Timestamp { value } => minigraf::BindValue::Timestamp(*value), + MiniGrafBindValue::AnyValidTime => minigraf::BindValue::AnyValidTime, + }) + } +} + +/// `bindings` as core bind values, named without their `$`. +fn core_bindings( + bindings: &HashMap, +) -> Result, MiniGrafError> { + bindings + .iter() + .map(|(name, value)| Ok((name.as_str(), value.to_core()?))) + .collect() +} + // ─── MiniGrafDb ────────────────────────────────────────────────────────────── #[derive(uniffi::Object)] @@ -423,6 +483,17 @@ impl MiniGrafDb { })) } + /// Parse and validate a `(query ...)` once, for running many times with + /// different bind values in its `$name` slots. A transact, retract or + /// rule is API-005/006/007; a slot in an attribute position is API-023. + pub fn prepare(&self, datalog: String) -> Result, MiniGrafError> { + let inner = lock(&self.inner)?.prepare(&datalog)?; + Ok(Arc::new(MiniGrafPreparedQuery { + _db: self.inner.clone(), + inner, + })) + } + /// The transaction counter that `:as-of N` compares against. pub fn current_tx_count(&self) -> Result { Ok(from_u64(lock(&self.inner)?.current_tx_count())) @@ -439,6 +510,46 @@ impl MiniGrafDb { } } +// ─── MiniGrafPreparedQuery ─────────────────────────────────────────────────── + +/// A query parsed once by `MiniGrafDb.prepare`, run with bind values. It +/// keeps its database open until both are freed, and each run reads the +/// database as it is then. +#[derive(uniffi::Object)] +pub struct MiniGrafPreparedQuery { + /// Keeps the database (and its close-time checkpoint) alive. + _db: Arc>, + inner: minigraf::PreparedQuery, +} + +#[uniffi::export] +impl MiniGrafPreparedQuery { + /// Run the query with `bindings` (slot name without its `$` to bind + /// value) and return its result as JSON, encoded like + /// `MiniGrafDb.execute`. Names the query does not use are ignored. A + /// missing slot is API-022. + pub fn execute( + &self, + bindings: HashMap, + ) -> Result { + let result = self.inner.execute(&core_bindings(&bindings)?)?; + Ok(query_result_to_json(result)) + } + + /// Run the query with `bindings` and return a cursor over its rows. The + /// answer is fixed when the cursor opens. + pub fn query( + &self, + bindings: HashMap, + ) -> Result, MiniGrafError> { + let cursor = self.inner.query(&core_bindings(&bindings)?)?; + Ok(Arc::new(MiniGrafCursor { + vars: cursor.vars().to_vec(), + inner: Mutex::new(Some(cursor)), + })) + } +} + // ─── MiniGrafCursor ────────────────────────────────────────────────────────── /// The rows of one query answer, in batches. @@ -844,6 +955,7 @@ mod tests { MiniGrafError::Storage { msg } | MiniGrafError::Query { msg } | MiniGrafError::Parse { msg } + | MiniGrafError::Api { msg } | MiniGrafError::Other { msg } => msg, } } @@ -1051,4 +1163,214 @@ mod tests { let db = MiniGrafDb::open_in_memory().expect("open"); db.checkpoint().expect("checkpoint on in-memory db"); } + + // ─── Prepared queries (#181) ───────────────────────────────────────── + + fn binds(pairs: &[(&str, MiniGrafBindValue)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect() + } + + fn int(n: i64) -> MiniGrafBindValue { + MiniGrafBindValue::Value { + value: MiniGrafValue::Int64 { value: n }, + } + } + + fn result_rows(json: &str) -> usize { + let v: serde_json::Value = serde_json::from_str(json).expect("json"); + v["results"].as_array().expect("results").len() + } + + fn prepared_db() -> Arc { + let db = MiniGrafDb::open_in_memory().expect("open"); + db.execute( + r#"(transact [[:a :n 1] [:b :n 2] [:c :n 3] + [:a :s "x"] [:a :f 2.5] [:a :t true] [:a :k :kw] [:a :z nil]])"# + .into(), + ) + .expect("transact"); + db + } + + #[test] + fn prepared_query_runs_again_with_new_bindings() { + let db = prepared_db(); + let pq = db + .prepare("(query [:find ?e :where [?e :n $n]])".into()) + .expect("prepare"); + for n in 1..=3 { + let r = pq.execute(binds(&[("n", int(n))])).expect("execute"); + assert_eq!(result_rows(&r), 1, "one entity per value"); + } + let r = pq.execute(binds(&[("n", int(9))])).expect("execute"); + assert_eq!(result_rows(&r), 0, "no entity has 9"); + } + + #[test] + fn every_bind_value_variant_binds() { + let db = prepared_db(); + let a = minigraf::EntityId::new_v5(&minigraf::EntityId::NAMESPACE_OID, b":a").to_string(); + let value = |value| MiniGrafBindValue::Value { value }; + for (q, bind) in [ + ("(query [:find ?e :where [?e :n $v]])", int(1)), + ( + "(query [:find ?e :where [?e :s $v]])", + value(MiniGrafValue::Text { value: "x".into() }), + ), + ( + "(query [:find ?e :where [?e :f $v]])", + value(MiniGrafValue::Float64 { value: 2.5 }), + ), + ( + "(query [:find ?e :where [?e :t $v]])", + value(MiniGrafValue::Bool { value: true }), + ), + ( + "(query [:find ?e :where [?e :k $v]])", + value(MiniGrafValue::Keyword { + value: ":kw".into(), + }), + ), + ( + "(query [:find ?e :where [?e :z $v]])", + value(MiniGrafValue::Null), + ), + ( + "(query [:find ?x :where [$v :n ?x]])", + value(MiniGrafValue::Ref { value: a.clone() }), + ), + ( + "(query [:find ?x :where [$v :n ?x]])", + MiniGrafBindValue::Entity { uuid: a.clone() }, + ), + ( + "(query [:find ?x :as-of $v :where [?e :n ?x]])", + MiniGrafBindValue::TxCount { value: 1 }, + ), + ( + "(query [:find ?x :as-of $v :where [?e :n ?x]])", + MiniGrafBindValue::Timestamp { value: i64::MAX }, + ), + ( + "(query [:find ?x :valid-at $v :where [:a :n ?x]])", + MiniGrafBindValue::AnyValidTime, + ), + ] { + let pq = db.prepare(q.into()).expect("prepare"); + let r = pq.execute(binds(&[("v", bind)])).expect("execute"); + assert!(result_rows(&r) > 0, "the bind value matches"); + } + } + + #[test] + fn prepared_cursor_matches_execute() { + let db = prepared_db(); + let pq = db + .prepare("(query [:find ?e ?n :where [?e :n ?n] [(>= ?n $min)]])".into()) + .expect("prepare"); + let all = result_rows(&pq.execute(binds(&[("min", int(2))])).expect("execute")); + let cursor = pq.query(binds(&[("min", int(2))])).expect("query"); + assert_eq!(cursor.vars(), vec!["?e".to_string(), "?n".to_string()]); + let mut seen = 0; + while let Some(batch) = cursor.next_batch(1).expect("batch") { + seen += serde_json::from_str::>(&batch) + .expect("rows") + .len(); + } + assert_eq!(seen, all); + assert_eq!(seen, 2); + } + + #[test] + fn prepared_bind_mistakes_are_api_errors() { + let db = prepared_db(); + let api = |e: MiniGrafError, code: &str| { + assert!(matches!(e, MiniGrafError::Api { .. }), "an API error"); + assert!(msg(e).starts_with(code), "the expected code"); + }; + let pq = db + .prepare("(query [:find ?e :where [?e :n $n]])".into()) + .expect("prepare"); + api(err(pq.execute(binds(&[]))), "[API-022]"); + api(err(pq.query(binds(&[]))), "[API-022]"); + for wrong in [ + pq.execute(binds(&[("n", MiniGrafBindValue::TxCount { value: 1 })])) + .map(|_| ()), + pq.query(binds(&[("n", MiniGrafBindValue::TxCount { value: 1 })])) + .map(|_| ()), + ] { + api(err(wrong), "[API-024]"); + } + api( + err(pq.query(binds(&[( + "n", + MiniGrafBindValue::Value { + value: MiniGrafValue::Ref { + value: "not-a-uuid".into(), + }, + }, + )]))), + "[API-017]", + ); + api( + err(db.prepare("(query [:find ?v :where [?e $a ?v]])".into())), + "[API-023]", + ); + api( + err(db.prepare("(transact [[:a :n 1]])".into())), + "[API-005]", + ); + + let entity = db + .prepare("(query [:find ?x :where [$e :n ?x]])".into()) + .expect("prepare"); + api( + err(entity.execute(binds(&[( + "e", + MiniGrafBindValue::Entity { + uuid: "not-a-uuid".into(), + }, + )]))), + "[API-017]", + ); + let as_of = db + .prepare("(query [:find ?x :as-of $t :where [?e :n ?x]])".into()) + .expect("prepare"); + api( + err(as_of.execute(binds(&[("t", MiniGrafBindValue::TxCount { value: -1 })]))), + "[API-017]", + ); + } + + #[test] + fn prepared_query_keeps_its_database_alive() { + let db = prepared_db(); + let pq = db + .prepare("(query [:find ?e :where [?e :n $n]])".into()) + .expect("prepare"); + drop(db); + let r = pq.execute(binds(&[("n", int(1))])).expect("execute"); + assert_eq!(result_rows(&r), 1); + } + + #[test] + fn prepared_query_sees_later_writes() { + let db = prepared_db(); + let pq = db + .prepare("(query [:find ?e :where [?e :n $n]])".into()) + .expect("prepare"); + assert_eq!( + result_rows(&pq.execute(binds(&[("n", int(4))])).expect("execute")), + 0 + ); + db.execute("(transact [[:d :n 4]])".into()) + .expect("transact"); + assert_eq!( + result_rows(&pq.execute(binds(&[("n", int(4))])).expect("execute")), + 1 + ); + } }