8000 feat: prepared queries with plain Kotlin/Java bind values (minigraf#181) by adityamukho · Pull Request #18 · project-minigraf/minigraf-java · 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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions consumer-test/src/test/java/ContractTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()) {
Expand Down
28 changes: 28 additions & 0 deletions java/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package io.github.project_minigraf.minigraf

import java.util.UUID

/**
* Static entry points for Java callers.
*
Expand Down Expand Up @@ -67,8 +69,72 @@ object Minigraf {
/** The log's records, read [batchSize] at a time. */
@JvmStatic
fun records(log: MiniGrafFactLog, batchSize: Int): Iterable<FactRecord> = 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, Any?>): 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<String, Any?>): 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<String, Any?>): Map<String, MiniGrafBindValue> =
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, Any?>): String =
execute(bindValues(bindings.toMap()))

/** Runs the query with [bindings] and returns a cursor over its rows. */
fun MiniGrafPreparedQuery.query(vararg bindings: Pair<String, Any?>): MiniGrafCursor =
query(bindValues(bindings.toMap()))

/** Builds [OpenOptions] fluently, for Java. */
class OpenOptionsBuilder internal constructor() {
private var options = OpenOptions()
Expand Down Expand Up @@ -126,4 +192,5 @@ val MiniGrafException.text: String
is MiniGrafException.Query -> msg
is MiniGrafException.Parse -> msg
is MiniGrafException.Other -> msg
is MiniGrafException.Api -> msg
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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())));
}
}
Original file line number Diff line number Diff line change
@@ -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<List<Any?>> = json.readValue<Map<String, Any>>(s)["results"]
.let { @Suppress("UNCHECKED_CAST") (it as List<List<Any?>>) }

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<Any?>(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<IllegalArgumentException> { 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<MiniGrafException.Api> { pq.execute() }))
assertEquals("[API-022]", code(assertThrows<MiniGrafException.Api> { pq.query() }))
assertEquals(
"[API-024]",
code(assertThrows<MiniGrafException.Api> { pq.execute("n" to MiniGrafBindValue.TxCount(1)) }),
)
assertEquals(
"[API-023]",
code(assertThrows<MiniGrafException.Api> { db.prepare("(query [:find ?v :where [?e \$a ?v]])") }),
)
assertEquals(
"[API-017]",
code(assertThrows<MiniGrafException.Api> { 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)
}
}
Loading
Loading
0