These numbers are the engine's work plus the boundary's, and the + * engine's dominates. What they are good for is the shape of the fixed cost + * a caller pays per statement, which is what decides whether a loop should + * prepare once or ask twice. The cost of reading rows out of a result is + * measured on its own in {@link ReadBench}, where the parse is not in the + * way of it. + */ +@State(Scope.Benchmark) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Warmup(iterations = 3, time = 2) +@Measurement(iterations = 5, time = 2) +// JMH forks a JVM of its own with a command line it writes, so the manifest +// attribute on this jar never reaches the process that runs the benchmark +// and the grant has to be here. For the same reason the library is found +// through ZU_LIBRARY rather than -Dzu.library: a fork inherits the +// environment and does not inherit system properties. +@Fork(value = 1, jvmArgs = {"--enable-native-access=ALL-UNNAMED"}) +public class QueryBench { + + private Database db; + private Connection conn; + private Statement prepared; + private Statement preparedConstant; + + @Setup + public void open() { + db = Database.memory(); + conn = db.connect(); + prepared = conn.prepare("RETURN $v AS v"); + preparedConstant = conn.prepare("RETURN 1 AS v"); + } + + @TearDown + public void close() { + prepared.close(); + preparedConstant.close(); + conn.close(); + db.close(); + } + + /** Parse, plan, run, one row out. The floor for anything at all. */ + @Benchmark + public long oneRowOneColumn() { + try (Result r = conn.query("RETURN 1 AS v")) { + return r.row(0).getLong("v"); + } + } + + /** The same without the parse, which is what a loop should be doing. */ + @Benchmark + public long preparedNoParameters() { + try (Result r = preparedConstant.execute()) { + return r.row(0).getLong("v"); + } + } + + /** The same again with a value crossing in, which is what a loop needs. */ + @Benchmark + public long preparedOneParameter() { + try (Result r = prepared.bind("v", 1L).execute()) { + return r.row(0).getLong("v"); + } + } + + /** The bind on its own, so the line above splits into its two halves. */ + @Benchmark + public Statement bindOnly() { + return prepared.bind("v", 1L); + } + + /** + * A string literal end to end. Against {@link #oneRowOneColumn} this is + * what a string costs over an integer, and most of it is the engine + * making the value rather than this client copying it out. + */ + @Benchmark + public String oneStringCell() { + try (Result r = conn.query("RETURN 'ada lovelace' AS v")) { + return r.row(0).getString("v"); + } + } + + /** The same statement with nothing read, which is the half above it. */ + @Benchmark + public void oneStringCellUnread(Blackhole hole) { + try (Result r = conn.query("RETURN 'ada lovelace' AS v")) { + hole.consume(r.rows()); + } + } + + /** What a connection costs, for anyone thinking about pooling one. */ + @Benchmark + public void connectAndClose(Blackhole hole) { + try (Connection c = db.connect()) { + hole.consume(c); + } + } +} diff --git a/zudb-bench/src/main/java/dev/zudb/bench/ReadBench.java b/zudb-bench/src/main/java/dev/zudb/bench/ReadBench.java new file mode 100644 index 0000000..bc0bdf3 --- /dev/null +++ b/zudb-bench/src/main/java/dev/zudb/bench/ReadBench.java @@ -0,0 +1,158 @@ +package dev.zudb.bench; + +import dev.zudb.Chunk; +import dev.zudb.Connection; +import dev.zudb.Database; +import dev.zudb.Result; +import dev.zudb.Row; +import java.nio.ByteBuffer; +import java.nio.LongBuffer; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OperationsPerInvocation; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +/** + * What reading rows out of a result costs, with the statement already run. + * + *
This is the number the columnar surface exists for. The result is + * executed once in setup and read over and over, so what is measured is the + * read and nothing else, and the score is per row rather than per call. + * + *
Holding one result open across the whole run is the point rather than
+ * a shortcut: a borrowed buffer is valid until its result closes, and the
+ * shape a caller should copy is exactly this one.
+ */
+@State(Scope.Benchmark)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Warmup(iterations = 3, time = 2)
+@Measurement(iterations = 5, time = 2)
+@Fork(value = 1, jvmArgs = {"--enable-native-access=ALL-UNNAMED"})
+public class ReadBench {
+
+ /**
+ * How many rows each invocation reads. A constant rather than a
+ * parameter because the per-row score below is scaled by it, and JMH
+ * wants that scale as a literal in an annotation.
+ */
+ private static final int ROWS = 100_000;
+
+ private Database db;
+ private Connection conn;
+ private Result result;
+
+ @Setup
+ public void open() {
+ db = Database.memory();
+ conn = db.connect();
+ result = conn.query(unwind(ROWS));
+ if (result.rows() != ROWS) {
+ throw new IllegalStateException("wanted " + ROWS + " rows and got " + result.rows());
+ }
+ }
+
+ @TearDown
+ public void close() {
+ result.close();
+ conn.close();
+ db.close();
+ }
+
+ /** A cell at a time, which is one boundary crossing a cell. */
+ @Benchmark
+ @OperationsPerInvocation(ROWS)
+ public long cellAtATime() {
+ long total = 0;
+ for (long i = 0, n = result.rows(); i < n; i++) {
+ total += result.row(i).getLong(0);
+ }
+ return total;
+ }
+
+ /** The same through the iterator, which is what a for loop compiles to. */
+ @Benchmark
+ @OperationsPerInvocation(ROWS)
+ public long rowAtATime() {
+ long total = 0;
+ for (Row row : result) {
+ total += row.getLong(0);
+ }
+ return total;
+ }
+
+ /** The same through the Stream, which is what most callers write. */
+ @Benchmark
+ @OperationsPerInvocation(ROWS)
+ public long throughTheStream() {
+ return result.stream().mapToLong(row -> row.getLong(0)).sum();
+ }
+
+ /** The same sum over one borrowed buffer, which is one crossing in total. */
+ @Benchmark
+ @OperationsPerInvocation(ROWS)
+ public long columnAtATime() {
+ LongBuffer b = result.longs(0);
+ long total = 0;
+ for (int i = 0, n = b.remaining(); i < n; i++) {
+ total += b.get(i);
+ }
+ return total;
+ }
+
+ /** The same with the nulls skipped rather than counted, which is the real loop. */
+ @Benchmark
+ @OperationsPerInvocation(ROWS)
+ public long columnAtATimeWithValidity() {
+ LongBuffer b = result.longs(0);
+ ByteBuffer valid = result.valid(0);
+ long total = 0;
+ for (int i = 0, n = b.remaining(); i < n; i++) {
+ if (valid.get(i) != 0) {
+ total += b.get(i);
+ }
+ }
+ return total;
+ }
+
+ /** A chunk at a time, which is what does not need the whole column resident. */
+ @Benchmark
+ @OperationsPerInvocation(ROWS)
+ public long chunkAtATime() {
+ long total = 0;
+ for (Chunk c : result.chunks().toList()) {
+ LongBuffer b = c.longs(0);
+ for (int i = 0, n = (int) c.rows(); i < n; i++) {
+ total += b.get(i);
+ }
+ }
+ return total;
+ }
+
+ /** Borrowing the buffer and reading nothing, so the loops above split in two. */
+ @Benchmark
+ public LongBuffer borrowOnly() {
+ return result.longs(0);
+ }
+
+ /** A statement that answers with the numbers one to n, one a row. */
+ private static String unwind(int n) {
+ StringBuilder sb = new StringBuilder(n * 7 + 32).append("UNWIND [");
+ for (int i = 1; i <= n; i++) {
+ if (i > 1) {
+ sb.append(", ");
+ }
+ sb.append(i);
+ }
+ return sb.append("] AS v RETURN v").toString();
+ }
+}
diff --git a/zudb-ffm/pom.xml b/zudb-ffm/pom.xml
new file mode 100644
index 0000000..d2bce5d
--- /dev/null
+++ b/zudb-ffm/pom.xml
@@ -0,0 +1,76 @@
+
+
+
The lookup is where a library that is not the one this client was written + * against is caught. {@code ZU_ABI_VERSION} is a macro in {@code zu.h} rather + * than a symbol in the library, so there is nothing to ask at run time and no + * point pretending otherwise. What there is instead is this: every symbol the + * client will ever call is resolved here, at load, and a missing one is named + * in the failure. A library too old to have {@code zu_result_chunk_col_i64} + * says so on the first line of the stack trace rather than on the call that + * needed it, three hours into a load. + * + *
The tiny accessors that only read a field of a struct the engine already + * has in hand are bound {@linkplain Linker.Option#critical critical}, which + * skips the thread state transition a downcall usually pays for. They qualify + * because they are bounded, they do not block and they never call back into + * Java. {@code zu_query} is none of those things and is bound normally. + */ +final class Abi { + + /** {@code size_t}, which is what the linker says it is on this platform. */ + static final MemoryLayout SIZE_T = Linker.nativeLinker().canonicalLayouts().get("size_t"); + + private final SymbolLookup lookup; + private final Linker linker = Linker.nativeLinker(); + private final Path library; + + final MethodHandle version; + + final MethodHandle errorStatus; + final MethodHandle errorMessage; + final MethodHandle errorCode; + final MethodHandle errorStandardText; + final MethodHandle errorDocUrl; + final MethodHandle errorSeverity; + final MethodHandle errorRetryable; + final MethodHandle errorPosition; + final MethodHandle errorOffset; + final MethodHandle errorExcerpt; + final MethodHandle errorFree; + + final MethodHandle databaseOpen; + final MethodHandle databaseCreate; + final MethodHandle databaseMemory; + final MethodHandle databaseIsMemory; + final MethodHandle databasePath; + final MethodHandle databaseClose; + + final MethodHandle connect; + final MethodHandle connDuplicate; + final MethodHandle connClose; + final MethodHandle connInterrupt; + final MethodHandle connRowsRead; + final MethodHandle connInTransaction; + final MethodHandle begin; + final MethodHandle commit; + final MethodHandle rollback; + + final MethodHandle query; + final MethodHandle prepare; + final MethodHandle bindI64; + final MethodHandle bindF64; + final MethodHandle bindBool; + final MethodHandle bindStr; + final MethodHandle bindTemporal; + final MethodHandle bindNull; + final MethodHandle execute; + final MethodHandle stmtClose; + + final MethodHandle resultRows; + final MethodHandle resultCols; + final MethodHandle resultColName; + final MethodHandle resultCellType; + final MethodHandle resultCellStr; + final MethodHandle resultCell; + final MethodHandle resultFree; + final MethodHandle resultGqlstatus; + final MethodHandle resultNotices; + final MethodHandle resultNotice; + + final MethodHandle colI64; + final MethodHandle colF64; + final MethodHandle colNodeOffset; + final MethodHandle colValid; + + final MethodHandle chunkCount; + final MethodHandle chunk; + final MethodHandle chunkColI64; + final MethodHandle chunkColF64; + final MethodHandle chunkColNodeOffset; + final MethodHandle chunkColValid; + + final MethodHandle valueType; + final MethodHandle valueBool; + final MethodHandle valueI64; + final MethodHandle valueF64; + final MethodHandle valueStr; + final MethodHandle valueTemporal; + final MethodHandle valueNode; + final MethodHandle valueRel; + final MethodHandle valueLen; + final MethodHandle valueAt; + final MethodHandle valueField; + + @SuppressWarnings("restricted") + Abi(Path library, Arena arena) { + this.library = library; + try { + this.lookup = + library == null + ? SymbolLookup.libraryLookup(System.mapLibraryName("zu"), arena) + : SymbolLookup.libraryLookup(library, arena); + } catch (IllegalArgumentException e) { + throw new ProviderUnavailableException( + "cannot load " + (library == null ? System.mapLibraryName("zu") : library) + ": " + e.getMessage(), + e); + } + + version = h("zu_version", FunctionDescriptor.of(ADDRESS)); + + errorStatus = critical("zu_error_status", FunctionDescriptor.of(JAVA_INT, ADDRESS)); + errorMessage = h("zu_error_message", FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS)); + errorCode = h("zu_error_code", FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS)); + errorStandardText = h("zu_error_standard_text", FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS)); + errorDocUrl = h("zu_error_doc_url", FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS)); + errorSeverity = critical("zu_error_severity", FunctionDescriptor.of(JAVA_INT, ADDRESS)); + errorRetryable = critical("zu_error_retryable", FunctionDescriptor.of(JAVA_INT, ADDRESS)); + errorPosition = h("zu_error_position", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + errorOffset = h("zu_error_offset", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + errorExcerpt = h("zu_error_excerpt", FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS)); + errorFree = h("zu_error_free", FunctionDescriptor.ofVoid(ADDRESS)); + + databaseOpen = + h("zu_database_open", FunctionDescriptor.of(JAVA_INT, ADDRESS, SIZE_T, ADDRESS, ADDRESS, ADDRESS)); + databaseCreate = + h("zu_database_create", FunctionDescriptor.of(JAVA_INT, ADDRESS, SIZE_T, ADDRESS, ADDRESS, ADDRESS)); + databaseMemory = h("zu_database_memory", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + databaseIsMemory = critical("zu_database_is_memory", FunctionDescriptor.of(JAVA_INT, ADDRESS)); + databasePath = h("zu_database_path", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + databaseClose = h("zu_database_close", FunctionDescriptor.ofVoid(ADDRESS)); + + connect = h("zu_connect", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + connDuplicate = h("zu_conn_duplicate", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + connClose = h("zu_conn_close", FunctionDescriptor.ofVoid(ADDRESS)); + connInterrupt = h("zu_conn_interrupt", FunctionDescriptor.of(JAVA_INT, ADDRESS)); + connRowsRead = h("zu_conn_rows_read", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + connInTransaction = h("zu_conn_in_transaction", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + begin = h("zu_begin", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT, ADDRESS)); + commit = h("zu_commit", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + rollback = h("zu_rollback", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + + query = h("zu_query", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, SIZE_T, ADDRESS, ADDRESS)); + prepare = h("zu_prepare", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, SIZE_T, ADDRESS, ADDRESS)); + bindI64 = h("zu_bind_i64", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, SIZE_T, JAVA_LONG)); + bindF64 = h("zu_bind_f64", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, SIZE_T, JAVA_DOUBLE)); + bindBool = h("zu_bind_bool", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, SIZE_T, JAVA_INT)); + bindStr = + h("zu_bind_str", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, SIZE_T, ADDRESS, SIZE_T)); + bindTemporal = + h( + "zu_bind_temporal", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, SIZE_T, JAVA_INT, JAVA_LONG, JAVA_INT)); + bindNull = h("zu_bind_null", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, SIZE_T)); + execute = h("zu_execute", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + stmtClose = h("zu_stmt_close", FunctionDescriptor.ofVoid(ADDRESS)); + + resultRows = critical("zu_result_rows", FunctionDescriptor.of(JAVA_LONG, ADDRESS)); + resultCols = critical("zu_result_cols", FunctionDescriptor.of(JAVA_INT, ADDRESS)); + resultColName = + h("zu_result_col_name", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT, ADDRESS, ADDRESS)); + resultCellType = + h("zu_result_cell_type", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, JAVA_INT, ADDRESS)); + resultCellStr = + h( + "zu_result_cell_str", + FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, JAVA_INT, ADDRESS, ADDRESS)); + resultCell = + h("zu_result_cell", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, JAVA_INT, ADDRESS)); + resultFree = h("zu_result_free", FunctionDescriptor.ofVoid(ADDRESS)); + resultGqlstatus = h("zu_result_gqlstatus", FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS)); + resultNotices = h("zu_result_notices", FunctionDescriptor.of(JAVA_INT, ADDRESS)); + resultNotice = h("zu_result_notice", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT, ADDRESS)); + + colI64 = h("zu_result_col_i64", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT, ADDRESS)); + colF64 = h("zu_result_col_f64", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT, ADDRESS)); + colNodeOffset = + h("zu_result_col_node_offset", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT, ADDRESS)); + colValid = h("zu_result_col_valid", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT, ADDRESS)); + + chunkCount = critical("zu_result_chunk_count", FunctionDescriptor.of(JAVA_LONG, ADDRESS)); + chunk = + h("zu_result_chunk", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, ADDRESS, ADDRESS)); + chunkColI64 = + h( + "zu_result_chunk_col_i64", + FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, JAVA_INT, ADDRESS)); + chunkColF64 = + h( + "zu_result_chunk_col_f64", + FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, JAVA_INT, ADDRESS)); + chunkColNodeOffset = + h( + "zu_result_chunk_col_node_offset", + FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, JAVA_INT, ADDRESS)); + chunkColValid = + h( + "zu_result_chunk_col_valid", + FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, JAVA_INT, ADDRESS)); + + valueType = critical("zu_value_type", FunctionDescriptor.of(JAVA_INT, ADDRESS)); + valueBool = h("zu_value_bool", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + valueI64 = h("zu_value_i64", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + valueF64 = h("zu_value_f64", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + valueStr = h("zu_value_str", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + valueTemporal = + h("zu_value_temporal", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS, ADDRESS)); + valueNode = h("zu_value_node", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + valueRel = + h("zu_value_rel", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS, ADDRESS)); + valueLen = critical("zu_value_len", FunctionDescriptor.of(JAVA_LONG, ADDRESS)); + valueAt = h("zu_value_at", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, ADDRESS)); + valueField = + h("zu_value_field", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, ADDRESS, ADDRESS)); + } + + /** Where this came from, for the message the loader prints. */ + Path library() { + return library; + } + + @SuppressWarnings("restricted") + private MethodHandle h(String name, FunctionDescriptor descriptor) { + return linker.downcallHandle(find(name), descriptor); + } + + @SuppressWarnings("restricted") + private MethodHandle critical(String name, FunctionDescriptor descriptor) { + return linker.downcallHandle(find(name), descriptor, Linker.Option.critical(false)); + } + + private MemorySegment find(String name) { + return lookup + .find(name) + .orElseThrow( + () -> + new ProviderUnavailableException( + (library == null ? "the libzu on the library path" : library.toString()) + + " has no " + + name + + ", so it is not a libzu this client can drive: this client speaks ABI " + + dev.zudb.Zu.ABI_VERSION + + " and the library is older")); + } +} diff --git a/zudb-ffm/src/main/java/dev/zudb/ffm/FfmBinding.java b/zudb-ffm/src/main/java/dev/zudb/ffm/FfmBinding.java new file mode 100644 index 0000000..7e5a7a5 --- /dev/null +++ b/zudb-ffm/src/main/java/dev/zudb/ffm/FfmBinding.java @@ -0,0 +1,871 @@ +package dev.zudb.ffm; + +import static dev.zudb.ffm.Scratch.A; +import static dev.zudb.ffm.Scratch.B; +import static dev.zudb.ffm.Scratch.C; +import static dev.zudb.ffm.Scratch.ERR; +import static dev.zudb.ffm.Scratch.LEN; +import static dev.zudb.ffm.Scratch.OUT; +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_BYTE; +import static java.lang.foreign.ValueLayout.JAVA_DOUBLE; +import static java.lang.foreign.ValueLayout.JAVA_INT; +import static java.lang.foreign.ValueLayout.JAVA_LONG; + +import dev.zudb.Diagnostic; +import dev.zudb.Status; +import dev.zudb.spi.ZuBinding; +import java.lang.foreign.MemorySegment; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.DoubleBuffer; +import java.nio.LongBuffer; +import java.nio.charset.StandardCharsets; + +/** + * The C ABI, called through the Foreign Function and Memory API. + * + *
Handles cross this class as {@code long} and become + * {@link MemorySegment#ofAddress} on the way out. That is deliberate. A + * provider that handed {@code MemorySegment} up to the API would put an FFM + * type in a surface a Java 17 program has to name, and there would be no JNI + * provider possible behind the same interface. + * + *
Nothing here allocates per call. The out-parameters and the encoded + * strings come out of a per-thread {@link Scratch} block that is wound back at + * the top of each call, so a bind in a loop costs the downcall and nothing + * else. + */ +final class FfmBinding implements ZuBinding { + + private static final int ZU_OK = 0; + private static final int ZU_DONE = 2; + + private final Abi abi; + + FfmBinding(Abi abi) { + this.abi = abi; + } + + @Override + public String version() { + try { + MemorySegment s = (MemorySegment) abi.version.invokeExact(); + return cstring(s.address()); + } catch (Throwable t) { + throw fail("zu_version", t); + } + } + + @Override + public long databaseOpen(String path, long memoryLimit, long threads, boolean readOnly) { + return openOrCreate(abi.databaseOpen, "zu_database_open", path, memoryLimit, threads, readOnly); + } + + @Override + public long databaseCreate(String path, long memoryLimit, long threads, boolean readOnly) { + return openOrCreate( + abi.databaseCreate, "zu_database_create", path, memoryLimit, threads, readOnly); + } + + @Override + public long databaseMemory(long memoryLimit, long threads, boolean readOnly) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + MemorySegment cfg = config(s, memoryLimit, threads, readOnly); + clear(sl); + try { + int st = + (int) + abi.databaseMemory.invokeExact(cfg, sl.asSlice(OUT, 8), sl.asSlice(ERR, 8)); + check("zu_database_memory", st, sl); + return sl.get(ADDRESS, OUT).address(); + } catch (Throwable t) { + throw fail("zu_database_memory", t); + } + } + + @Override + public boolean databaseIsMemory(long db) { + try { + return (int) abi.databaseIsMemory.invokeExact(ptr(db)) == ZU_OK; + } catch (Throwable t) { + throw fail("zu_database_is_memory", t); + } + } + + @Override + public String databasePath(long db) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = + (int) abi.databasePath.invokeExact(ptr(db), sl.asSlice(OUT, 8), sl.asSlice(LEN, 8)); + if (st == ZU_DONE) { + return null; + } + check("zu_database_path", st, null); + return utf8(sl.get(ADDRESS, OUT).address(), sl.get(JAVA_LONG, LEN)); + } catch (Throwable t) { + throw fail("zu_database_path", t); + } + } + + @Override + public void databaseClose(long db) { + try { + abi.databaseClose.invokeExact(ptr(db)); + } catch (Throwable t) { + throw fail("zu_database_close", t); + } + } + + @Override + public long connect(long db) { + return handle(abi.connect, "zu_connect", db); + } + + @Override + public long connDuplicate(long conn) { + return handle(abi.connDuplicate, "zu_conn_duplicate", conn); + } + + @Override + public void connClose(long conn) { + try { + abi.connClose.invokeExact(ptr(conn)); + } catch (Throwable t) { + throw fail("zu_conn_close", t); + } + } + + @Override + public void connInterrupt(long conn) { + try { + check("zu_conn_interrupt", (int) abi.connInterrupt.invokeExact(ptr(conn)), null); + } catch (Throwable t) { + throw fail("zu_conn_interrupt", t); + } + } + + @Override + public long connRowsRead(long conn) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = (int) abi.connRowsRead.invokeExact(ptr(conn), sl.asSlice(OUT, 8)); + check("zu_conn_rows_read", st, null); + return sl.get(JAVA_LONG, OUT); + } catch (Throwable t) { + throw fail("zu_conn_rows_read", t); + } + } + + @Override + public boolean connInTransaction(long conn) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = (int) abi.connInTransaction.invokeExact(ptr(conn), sl.asSlice(OUT, 8)); + check("zu_conn_in_transaction", st, null); + return sl.get(JAVA_INT, OUT) != 0; + } catch (Throwable t) { + throw fail("zu_conn_in_transaction", t); + } + } + + @Override + public void begin(long conn, boolean readOnly) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + clear(sl); + try { + int st = (int) abi.begin.invokeExact(ptr(conn), readOnly ? 1 : 0, sl.asSlice(ERR, 8)); + check("zu_begin", st, sl); + } catch (Throwable t) { + throw fail("zu_begin", t); + } + } + + @Override + public void commit(long conn) { + endTransaction(abi.commit, "zu_commit", conn); + } + + @Override + public void rollback(long conn) { + endTransaction(abi.rollback, "zu_rollback", conn); + } + + @Override + public long query(long conn, String statement) { + return run(abi.query, "zu_query", conn, statement); + } + + @Override + public long prepare(long conn, String statement) { + return run(abi.prepare, "zu_prepare", conn, statement); + } + + @Override + public void bindLong(long stmt, String name, long value) { + Scratch s = Scratch.get(); + MemorySegment n = s.utf8(name); + try { + int st = (int) abi.bindI64.invokeExact(ptr(stmt), n, n.byteSize(), value); + check("zu_bind_i64", st, null); + } catch (Throwable t) { + throw fail("zu_bind_i64", t); + } + } + + @Override + public void bindDouble(long stmt, String name, double value) { + Scratch s = Scratch.get(); + MemorySegment n = s.utf8(name); + try { + int st = (int) abi.bindF64.invokeExact(ptr(stmt), n, n.byteSize(), value); + check("zu_bind_f64", st, null); + } catch (Throwable t) { + throw fail("zu_bind_f64", t); + } + } + + @Override + public void bindBoolean(long stmt, String name, boolean value) { + Scratch s = Scratch.get(); + MemorySegment n = s.utf8(name); + try { + int st = (int) abi.bindBool.invokeExact(ptr(stmt), n, n.byteSize(), value ? 1 : 0); + check("zu_bind_bool", st, null); + } catch (Throwable t) { + throw fail("zu_bind_bool", t); + } + } + + @Override + public void bindString(long stmt, String name, String value) { + Scratch s = Scratch.get(); + MemorySegment n = s.utf8(name); + MemorySegment v = s.utf8(value); + try { + int st = (int) abi.bindStr.invokeExact(ptr(stmt), n, n.byteSize(), v, v.byteSize()); + check("zu_bind_str", st, null); + } catch (Throwable t) { + throw fail("zu_bind_str", t); + } + } + + @Override + public void bindTemporal(long stmt, String name, int kind, long count, int offsetMinutes) { + Scratch s = Scratch.get(); + MemorySegment n = s.utf8(name); + try { + int st = + (int) + abi.bindTemporal.invokeExact(ptr(stmt), n, n.byteSize(), kind, count, offsetMinutes); + check("zu_bind_temporal", st, null); + } catch (Throwable t) { + throw fail("zu_bind_temporal", t); + } + } + + @Override + public void bindNull(long stmt, String name) { + Scratch s = Scratch.get(); + MemorySegment n = s.utf8(name); + try { + int st = (int) abi.bindNull.invokeExact(ptr(stmt), n, n.byteSize()); + check("zu_bind_null", st, null); + } catch (Throwable t) { + throw fail("zu_bind_null", t); + } + } + + @Override + public long execute(long stmt) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + clear(sl); + try { + int st = (int) abi.execute.invokeExact(ptr(stmt), sl.asSlice(OUT, 8), sl.asSlice(ERR, 8)); + check("zu_execute", st, sl); + return sl.get(ADDRESS, OUT).address(); + } catch (Throwable t) { + throw fail("zu_execute", t); + } + } + + @Override + public void stmtClose(long stmt) { + try { + abi.stmtClose.invokeExact(ptr(stmt)); + } catch (Throwable t) { + throw fail("zu_stmt_close", t); + } + } + + @Override + public long resultRows(long result) { + try { + return (long) abi.resultRows.invokeExact(ptr(result)); + } catch (Throwable t) { + throw fail("zu_result_rows", t); + } + } + + @Override + public int resultCols(long result) { + try { + return (int) abi.resultCols.invokeExact(ptr(result)); + } catch (Throwable t) { + throw fail("zu_result_cols", t); + } + } + + @Override + public String resultColName(long result, int col) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = + (int) + abi.resultColName.invokeExact(ptr(result), col, sl.asSlice(OUT, 8), sl.asSlice(LEN, 8)); + check("zu_result_col_name", st, null); + return utf8(sl.get(ADDRESS, OUT).address(), sl.get(JAVA_LONG, LEN)); + } catch (Throwable t) { + throw fail("zu_result_col_name", t); + } + } + + @Override + public int resultCellType(long result, long row, int col) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = (int) abi.resultCellType.invokeExact(ptr(result), row, col, sl.asSlice(OUT, 8)); + check("zu_result_cell_type", st, null); + return sl.get(JAVA_INT, OUT); + } catch (Throwable t) { + throw fail("zu_result_cell_type", t); + } + } + + @Override + public String resultCellString(long result, long row, int col) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = + (int) + abi.resultCellStr.invokeExact( + ptr(result), row, col, sl.asSlice(OUT, 8), sl.asSlice(LEN, 8)); + check("zu_result_cell_str", st, null); + return utf8(sl.get(ADDRESS, OUT).address(), sl.get(JAVA_LONG, LEN)); + } catch (Throwable t) { + throw fail("zu_result_cell_str", t); + } + } + + @Override + public String resultGqlstatus(long result) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + MemorySegment out = + (MemorySegment) abi.resultGqlstatus.invokeExact(ptr(result), sl.asSlice(LEN, 8)); + return utf8(out.address(), sl.get(JAVA_LONG, LEN)); + } catch (Throwable t) { + throw fail("zu_result_gqlstatus", t); + } + } + + @Override + public int resultNotices(long result) { + try { + return (int) abi.resultNotices.invokeExact(ptr(result)); + } catch (Throwable t) { + throw fail("zu_result_notices", t); + } + } + + @Override + public Diagnostic resultNotice(long result, int index) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + clear(sl); + try { + int st = (int) abi.resultNotice.invokeExact(ptr(result), index, sl.asSlice(ERR, 8)); + if (st == ZU_DONE) { + return null; + } + check("zu_result_notice", st, null); + long err = sl.get(ADDRESS, ERR).address(); + return err == 0 ? null : diagnostic(err); + } catch (Throwable t) { + throw fail("zu_result_notice", t); + } + } + + @Override + public void resultFree(long result) { + try { + abi.resultFree.invokeExact(ptr(result)); + } catch (Throwable t) { + throw fail("zu_result_free", t); + } + } + + @Override + public LongBuffer colLongs(long result, int col, long rows) { + long p = column(abi.colI64, "zu_result_col_i64", result, col); + return p == 0 ? null : buffer(p, rows, 8).asLongBuffer(); + } + + @Override + public DoubleBuffer colDoubles(long result, int col, long rows) { + long p = column(abi.colF64, "zu_result_col_f64", result, col); + return p == 0 ? null : buffer(p, rows, 8).asDoubleBuffer(); + } + + @Override + public LongBuffer colNodeOffsets(long result, int col, long rows) { + long p = column(abi.colNodeOffset, "zu_result_col_node_offset", result, col); + return p == 0 ? null : buffer(p, rows, 8).asLongBuffer(); + } + + @Override + public ByteBuffer colValid(long result, int col, long rows) { + long p = column(abi.colValid, "zu_result_col_valid", result, col); + return p == 0 ? null : buffer(p, rows, 1); + } + + @Override + public long chunkCount(long result) { + try { + return (long) abi.chunkCount.invokeExact(ptr(result)); + } catch (Throwable t) { + throw fail("zu_result_chunk_count", t); + } + } + + @Override + public long[] chunk(long result, long chunk) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = + (int) abi.chunk.invokeExact(ptr(result), chunk, sl.asSlice(OUT, 8), sl.asSlice(A, 8)); + check("zu_result_chunk", st, null); + return new long[] {sl.get(JAVA_LONG, OUT), sl.get(JAVA_LONG, A)}; + } catch (Throwable t) { + throw fail("zu_result_chunk", t); + } + } + + @Override + public LongBuffer chunkLongs(long result, long chunk, int col, long rows) { + long p = chunkColumn(abi.chunkColI64, "zu_result_chunk_col_i64", result, chunk, col); + return p == 0 ? null : buffer(p, rows, 8).asLongBuffer(); + } + + @Override + public DoubleBuffer chunkDoubles(long result, long chunk, int col, long rows) { + long p = chunkColumn(abi.chunkColF64, "zu_result_chunk_col_f64", result, chunk, col); + return p == 0 ? null : buffer(p, rows, 8).asDoubleBuffer(); + } + + @Override + public LongBuffer chunkNodeOffsets(long result, long chunk, int col, long rows) { + long p = + chunkColumn(abi.chunkColNodeOffset, "zu_result_chunk_col_node_offset", result, chunk, col); + return p == 0 ? null : buffer(p, rows, 8).asLongBuffer(); + } + + @Override + public ByteBuffer chunkValid(long result, long chunk, int col, long rows) { + long p = chunkColumn(abi.chunkColValid, "zu_result_chunk_col_valid", result, chunk, col); + return p == 0 ? null : buffer(p, rows, 1); + } + + @Override + public long resultCell(long result, long row, int col) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = (int) abi.resultCell.invokeExact(ptr(result), row, col, sl.asSlice(OUT, 8)); + check("zu_result_cell", st, null); + return sl.get(ADDRESS, OUT).address(); + } catch (Throwable t) { + throw fail("zu_result_cell", t); + } + } + + @Override + public int valueType(long value) { + try { + return (int) abi.valueType.invokeExact(ptr(value)); + } catch (Throwable t) { + throw fail("zu_value_type", t); + } + } + + @Override + public boolean valueBoolean(long value) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = (int) abi.valueBool.invokeExact(ptr(value), sl.asSlice(OUT, 8)); + check("zu_value_bool", st, null); + return sl.get(JAVA_INT, OUT) != 0; + } catch (Throwable t) { + throw fail("zu_value_bool", t); + } + } + + @Override + public long valueLong(long value) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = (int) abi.valueI64.invokeExact(ptr(value), sl.asSlice(OUT, 8)); + check("zu_value_i64", st, null); + return sl.get(JAVA_LONG, OUT); + } catch (Throwable t) { + throw fail("zu_value_i64", t); + } + } + + @Override + public double valueDouble(long value) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = (int) abi.valueF64.invokeExact(ptr(value), sl.asSlice(OUT, 8)); + check("zu_value_f64", st, null); + return sl.get(JAVA_DOUBLE, OUT); + } catch (Throwable t) { + throw fail("zu_value_f64", t); + } + } + + @Override + public String valueString(long value) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = (int) abi.valueStr.invokeExact(ptr(value), sl.asSlice(OUT, 8), sl.asSlice(LEN, 8)); + check("zu_value_str", st, null); + return utf8(sl.get(ADDRESS, OUT).address(), sl.get(JAVA_LONG, LEN)); + } catch (Throwable t) { + throw fail("zu_value_str", t); + } + } + + @Override + public long[] valueTemporal(long value) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = + (int) + abi.valueTemporal.invokeExact( + ptr(value), sl.asSlice(A, 8), sl.asSlice(OUT, 8), sl.asSlice(B, 8)); + check("zu_value_temporal", st, null); + return new long[] {sl.get(JAVA_INT, A), sl.get(JAVA_LONG, OUT), sl.get(JAVA_INT, B)}; + } catch (Throwable t) { + throw fail("zu_value_temporal", t); + } + } + + @Override + public long[] valueNode(long value) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = (int) abi.valueNode.invokeExact(ptr(value), sl.asSlice(A, 8), sl.asSlice(OUT, 8)); + check("zu_value_node", st, null); + return new long[] {Integer.toUnsignedLong(sl.get(JAVA_INT, A)), sl.get(JAVA_LONG, OUT)}; + } catch (Throwable t) { + throw fail("zu_value_node", t); + } + } + + @Override + public long[] valueRel(long value) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = + (int) + abi.valueRel.invokeExact( + ptr(value), sl.asSlice(A, 8), sl.asSlice(OUT, 8), sl.asSlice(C, 8)); + check("zu_value_rel", st, null); + return new long[] { + Integer.toUnsignedLong(sl.get(JAVA_INT, A)), sl.get(JAVA_LONG, OUT), sl.get(JAVA_LONG, C) + }; + } catch (Throwable t) { + throw fail("zu_value_rel", t); + } + } + + @Override + public long valueLength(long value) { + try { + return (long) abi.valueLen.invokeExact(ptr(value)); + } catch (Throwable t) { + throw fail("zu_value_len", t); + } + } + + @Override + public long valueAt(long value, long index) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = (int) abi.valueAt.invokeExact(ptr(value), index, sl.asSlice(OUT, 8)); + check("zu_value_at", st, null); + return sl.get(ADDRESS, OUT).address(); + } catch (Throwable t) { + throw fail("zu_value_at", t); + } + } + + @Override + public String valueField(long value, long index) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = + (int) abi.valueField.invokeExact(ptr(value), index, sl.asSlice(OUT, 8), sl.asSlice(LEN, 8)); + check("zu_value_field", st, null); + return utf8(sl.get(ADDRESS, OUT).address(), sl.get(JAVA_LONG, LEN)); + } catch (Throwable t) { + throw fail("zu_value_field", t); + } + } + + private long openOrCreate( + java.lang.invoke.MethodHandle mh, + String what, + String path, + long memoryLimit, + long threads, + boolean readOnly) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + MemorySegment p = s.utf8(path); + MemorySegment cfg = config(s, memoryLimit, threads, readOnly); + clear(sl); + try { + int st = (int) mh.invokeExact(p, p.byteSize(), cfg, sl.asSlice(OUT, 8), sl.asSlice(ERR, 8)); + check(what, st, sl); + return sl.get(ADDRESS, OUT).address(); + } catch (Throwable t) { + throw fail(what, t); + } + } + + private long handle(java.lang.invoke.MethodHandle mh, String what, long in) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + clear(sl); + try { + int st = (int) mh.invokeExact(ptr(in), sl.asSlice(OUT, 8), sl.asSlice(ERR, 8)); + check(what, st, sl); + return sl.get(ADDRESS, OUT).address(); + } catch (Throwable t) { + throw fail(what, t); + } + } + + private long run(java.lang.invoke.MethodHandle mh, String what, long conn, String statement) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + MemorySegment q = s.utf8(statement); + clear(sl); + try { + int st = + (int) mh.invokeExact(ptr(conn), q, q.byteSize(), sl.asSlice(OUT, 8), sl.asSlice(ERR, 8)); + check(what, st, sl); + return sl.get(ADDRESS, OUT).address(); + } catch (Throwable t) { + throw fail(what, t); + } + } + + private void endTransaction(java.lang.invoke.MethodHandle mh, String what, long conn) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + clear(sl); + try { + int st = (int) mh.invokeExact(ptr(conn), sl.asSlice(ERR, 8)); + check(what, st, sl); + } catch (Throwable t) { + throw fail(what, t); + } + } + + private long column(java.lang.invoke.MethodHandle mh, String what, long result, int col) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = (int) mh.invokeExact(ptr(result), col, sl.asSlice(OUT, 8)); + if (st == ZU_DONE) { + return 0; + } + check(what, st, null); + return sl.get(ADDRESS, OUT).address(); + } catch (Throwable t) { + throw fail(what, t); + } + } + + private long chunkColumn( + java.lang.invoke.MethodHandle mh, String what, long result, long chunk, int col) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + try { + int st = (int) mh.invokeExact(ptr(result), chunk, col, sl.asSlice(OUT, 8)); + if (st == ZU_DONE) { + return 0; + } + check(what, st, null); + return sl.get(ADDRESS, OUT).address(); + } catch (Throwable t) { + throw fail(what, t); + } + } + + private static MemorySegment config(Scratch s, long memoryLimit, long threads, boolean readOnly) { + MemorySegment cfg = s.config(); + cfg.set(JAVA_LONG, 0, Scratch.CONFIG); + cfg.set(JAVA_LONG, 8, memoryLimit); + cfg.set(JAVA_LONG, 16, threads); + cfg.set(JAVA_INT, 24, readOnly ? 1 : 0); + return cfg; + } + + private static MemorySegment ptr(long handle) { + return handle == 0 ? MemorySegment.NULL : MemorySegment.ofAddress(handle); + } + + private static void clear(MemorySegment slots) { + slots.set(ADDRESS, ERR, MemorySegment.NULL); + } + + /** + * Turns a status that is not {@code ZU_OK} into the exception it names. + * + * @param what the C function, for the message a status with no error carries + * @param status what it answered + * @param slots the block whose error slot the call may have written, or null + * for a call that takes no error out-parameter + */ + private void check(String what, int status, MemorySegment slots) { + if (status == ZU_OK) { + return; + } + long err = slots == null ? 0 : slots.get(ADDRESS, ERR).address(); + if (err != 0) { + throw diagnostic(err).toException(); + } + throw Diagnostic.misuse(Status.of(status), what + " answered " + Status.of(status)) + .toException(); + } + + /** Reads a {@code zu_error} into a record and frees it. */ + private Diagnostic diagnostic(long err) { + MemorySegment e = ptr(err); + try { + int status = (int) abi.errorStatus.invokeExact(e); + int severity = (int) abi.errorSeverity.invokeExact(e); + int retryable = (int) abi.errorRetryable.invokeExact(e); + String message = text(abi.errorMessage, e); + String code = text(abi.errorCode, e); + String condition = text(abi.errorStandardText, e); + String docUrl = text(abi.errorDocUrl, e); + String excerpt = text(abi.errorExcerpt, e); + int line = -1; + int column = -1; + int offset = -1; + MemorySegment sl = Scratch.get().slots(); + if ((int) abi.errorPosition.invokeExact(e, sl.asSlice(A, 4), sl.asSlice(B, 4)) == ZU_OK) { + line = sl.get(JAVA_INT, A); + column = sl.get(JAVA_INT, B); + } + if ((int) abi.errorOffset.invokeExact(e, sl.asSlice(C, 4)) == ZU_OK) { + offset = sl.get(JAVA_INT, C); + } + return Diagnostic.of( + status, message, code, condition, severity, line, column, offset, excerpt, docUrl, + retryable == 1); + } catch (Throwable t) { + throw fail("zu_error", t); + } finally { + try { + abi.errorFree.invokeExact(e); + } catch (Throwable t) { + throw fail("zu_error_free", t); + } + } + } + + /** One of the {@code const char *} accessors on a {@code zu_error}. */ + private static String text(java.lang.invoke.MethodHandle mh, MemorySegment e) throws Throwable { + MemorySegment sl = Scratch.get().slots(); + MemorySegment out = (MemorySegment) mh.invokeExact(e, sl.asSlice(LEN, 8)); + return utf8(out.address(), sl.get(JAVA_LONG, LEN)); + } + + private static String utf8(long address, long length) { + if (address == 0) { + return null; + } + if (length == 0) { + return ""; + } + byte[] bytes = new byte[(int) length]; + MemorySegment.copy(reinterpret(address, length), JAVA_BYTE, 0, bytes, 0, bytes.length); + return new String(bytes, StandardCharsets.UTF_8); + } + + @SuppressWarnings("restricted") + private static String cstring(long address) { + return address == 0 ? null : MemorySegment.ofAddress(address).reinterpret(Long.MAX_VALUE).getString(0); + } + + @SuppressWarnings("restricted") + private static MemorySegment reinterpret(long address, long size) { + return MemorySegment.ofAddress(address).reinterpret(size); + } + + /** A native array of {@code count} items of {@code width} bytes, as a buffer over it. */ + private static ByteBuffer buffer(long address, long count, long width) { + long size = count * width; + if (size > Integer.MAX_VALUE) { + throw Diagnostic.misuse( + Status.UNSUPPORTED, + "this column is " + + size + + " bytes, and a java.nio buffer addresses at most " + + Integer.MAX_VALUE + + ": read it a chunk at a time") + .toException(); + } + return reinterpret(address, size) + .asByteBuffer() + .asReadOnlyBuffer() + .order(ByteOrder.nativeOrder()); + } + + private static RuntimeException fail(String what, Throwable t) { + if (t instanceof Error e) { + throw e; + } + if (t instanceof RuntimeException e) { + return e; + } + return Diagnostic.misuse(Status.UNKNOWN, what + " did not complete: " + t).toException(); + } +} diff --git a/zudb-ffm/src/main/java/dev/zudb/ffm/FfmProvider.java b/zudb-ffm/src/main/java/dev/zudb/ffm/FfmProvider.java new file mode 100644 index 0000000..c3a735d --- /dev/null +++ b/zudb-ffm/src/main/java/dev/zudb/ffm/FfmProvider.java @@ -0,0 +1,54 @@ +package dev.zudb.ffm; + +import dev.zudb.spi.ProviderUnavailableException; +import dev.zudb.spi.ZuBinding; +import dev.zudb.spi.ZuProvider; +import java.lang.foreign.Arena; +import java.nio.file.Path; + +/** + * The provider that binds zu through the Foreign Function and Memory API. + * + *
This is the one to have. There is no C compilation step, no second + * artifact per platform, and no JNI stub between the call and the library: + * a downcall handle is a direct call once it has been compiled. It needs + * JDK 25, which is what this artifact is built for. + */ +public final class FfmProvider implements ZuProvider { + + /** + * What the service loader calls. + * + *
Public and taking nothing because {@link java.util.ServiceLoader} says + * so. Nothing else has a reason to make one. + */ + public FfmProvider() {} + + @Override + public String name() { + return "ffm"; + } + + @Override + public int priority() { + return 100; + } + + @Override + public ZuBinding load(Path library) { + Module module = FfmProvider.class.getModule(); + if (!module.isNativeAccessEnabled()) { + throw new ProviderUnavailableException( + "this JVM has not granted native access to " + + (module.isNamed() ? module.getName() : "the class path") + + ", so it cannot call libzu: start it with --enable-native-access=" + + (module.isNamed() ? module.getName() : "ALL-UNNAMED") + + ", or put Enable-Native-Access: ALL-UNNAMED in the manifest of the jar" + + " that starts the process"); + } + // The arena is global on purpose. A library unloaded while a database is + // still open is a segfault, not an exception, and nothing in this API + // hands out a moment at which every handle is known to be gone. + return new FfmBinding(new Abi(library, Arena.global())); + } +} diff --git a/zudb-ffm/src/main/java/dev/zudb/ffm/Scratch.java b/zudb-ffm/src/main/java/dev/zudb/ffm/Scratch.java new file mode 100644 index 0000000..c067536 --- /dev/null +++ b/zudb-ffm/src/main/java/dev/zudb/ffm/Scratch.java @@ -0,0 +1,124 @@ +package dev.zudb.ffm; + +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.nio.charset.StandardCharsets; + +/** + * The off-heap space one thread needs for the length of one call, reused. + * + *
Every call across this boundary needs somewhere for the library to write + * its out-parameters, and most of them need somewhere to put a string in the + * encoding C reads. The obvious way to get that is a confined {@link Arena} + * per call, and the obvious way costs a malloc and a free on a path that is + * otherwise a handful of instructions, which shows up the moment anybody binds + * a parameter in a loop. + * + *
So: one block per thread, allocated once, handed out by bumping a pointer + * and reclaimed by setting that pointer back to nought at the top of the next + * call. Nothing here outlives the call that asked for it, which is what makes + * that safe, and the out-parameter slots are read before the call returns. + * The block belongs to an automatic arena, so a thread that goes away takes + * its block with it without anybody having to close anything. + */ +final class Scratch { + + /** Where the first out-parameter goes. */ + static final long OUT = 0; + + /** Where a length out-parameter goes. */ + static final long LEN = 8; + + /** Where an error out-parameter goes. */ + static final long ERR = 16; + + /** Three more slots, for the calls that write a triple. */ + static final long A = 24; + + static final long B = 32; + + static final long C = 40; + + private static final long SLOTS = 64; + + /** + * {@code sizeof(zu_config)} as this client's header declares it: three + * {@code size_t} and an {@code int32_t}, rounded up to the alignment. + * + *
This is written into the struct's first field rather than read back out
+ * of {@code zu_config_init}, and on purpose. The struct is versioned so that
+ * a library newer than the caller reads only the fields the caller says it
+ * has, and letting the library tell us how long our own buffer is would
+ * invert that: a library that grew the struct would write a size past the
+ * end of what we allocated and then read there.
+ */
+ static final long CONFIG = 32;
+
+ private static final ThreadLocal Nothing here is exported. A program depends on this module to have it,
+ * not to name it, and what it gets is a service {@code dev.zudb} finds on its
+ * own. That also keeps every {@code java.lang.foreign} type out of anything a
+ * user writes, which is what lets the same user code run on the JNI provider
+ * on JDK 17.
+ */
+module dev.zudb.ffm {
+ requires dev.zudb;
+
+ provides dev.zudb.spi.ZuProvider with
+ dev.zudb.ffm.FfmProvider;
+}
diff --git a/zudb-ffm/src/main/resources/META-INF/services/dev.zudb.spi.ZuProvider b/zudb-ffm/src/main/resources/META-INF/services/dev.zudb.spi.ZuProvider
new file mode 100644
index 0000000..92d141b
--- /dev/null
+++ b/zudb-ffm/src/main/resources/META-INF/services/dev.zudb.spi.ZuProvider
@@ -0,0 +1 @@
+dev.zudb.ffm.FfmProvider
diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/ColumnarTest.java b/zudb-ffm/src/test/java/dev/zudb/ffm/ColumnarTest.java
new file mode 100644
index 0000000..2241c82
--- /dev/null
+++ b/zudb-ffm/src/test/java/dev/zudb/ffm/ColumnarTest.java
@@ -0,0 +1,199 @@
+package dev.zudb.ffm;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import dev.zudb.Chunk;
+import dev.zudb.Connection;
+import dev.zudb.Database;
+import dev.zudb.Result;
+import dev.zudb.ZuProgrammingException;
+import java.nio.ByteBuffer;
+import java.nio.DoubleBuffer;
+import java.nio.LongBuffer;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The columnar reads, which are the reason to use this client over a socket.
+ *
+ * Every buffer here is a view over the engine's own memory. What these
+ * tests are for is that it is the right memory, in the right order, and that
+ * it is read-only so that nobody writes into the result by accident.
+ */
+class ColumnarTest {
+
+ private static Database db;
+ private static Connection conn;
+
+ @BeforeAll
+ static void engine() {
+ Libzu.require();
+ db = Database.memory();
+ conn = db.connect();
+ }
+
+ @AfterAll
+ static void done() {
+ if (conn != null) {
+ conn.close();
+ }
+ if (db != null) {
+ db.close();
+ }
+ }
+
+ @Test
+ void aWholeColumnOfIntegersInOneCall() {
+ try (Result r = conn.query("UNWIND [1, 2, 3, 4, 5] AS v RETURN v")) {
+ LongBuffer b = r.longs(0);
+ assertEquals(5, b.remaining());
+ long total = 0;
+ for (int i = 0; i < b.remaining(); i++) {
+ total += b.get(i);
+ }
+ assertEquals(15, total);
+ }
+ }
+
+ @Test
+ void aWholeColumnOfFloats() {
+ try (Result r = conn.query("UNWIND [1.5, 2.5] AS v RETURN v")) {
+ DoubleBuffer b = r.doubles(0);
+ assertEquals(2, b.remaining());
+ assertEquals(1.5, b.get(0));
+ assertEquals(2.5, b.get(1));
+ }
+ }
+
+ @Test
+ void integersReadAsFloatsAndBooleansReadAsIntegers() {
+ try (Result r = conn.query("UNWIND [1, 2] AS v RETURN v")) {
+ assertEquals(1.0, r.doubles(0).get(0));
+ }
+ try (Result r = conn.query("UNWIND [true, false] AS v RETURN v")) {
+ assertEquals(1, r.longs(0).get(0));
+ assertEquals(0, r.longs(0).get(1));
+ }
+ }
+
+ @Test
+ void nullsReadAsZeroAndValidityTellsThemApart() {
+ try (Result r = conn.query("UNWIND [1, null, 3] AS v RETURN v")) {
+ LongBuffer values = r.longs(0);
+ ByteBuffer valid = r.valid(0);
+ assertEquals(3, values.remaining());
+ assertEquals(3, valid.remaining());
+ assertEquals(1, values.get(0));
+ assertEquals(0, values.get(1));
+ assertEquals(3, values.get(2));
+ assertTrue(valid.get(0) != 0);
+ assertEquals(0, valid.get(1));
+ assertTrue(valid.get(2) != 0);
+ }
+ }
+
+ @Test
+ void aBorrowedBufferIsReadOnlySoNobodyWritesIntoTheResult() {
+ try (Result r = conn.query("UNWIND [1, 2] AS v RETURN v")) {
+ LongBuffer b = r.longs(0);
+ assertTrue(b.isReadOnly());
+ assertThrows(java.nio.ReadOnlyBufferException.class, () -> b.put(0, 99));
+ assertTrue(r.valid(0).isReadOnly());
+ }
+ }
+
+ @Test
+ void aColumnThatDoesNotHoldWhatTheAccessorReadsIsRefused() {
+ try (Result r = conn.query("UNWIND ['a', 'b'] AS v RETURN v")) {
+ // A string column is not an integer column, and reading it as one
+ // would hand back a pointer as a number.
+ assertThrows(RuntimeException.class, () -> r.longs(0));
+ }
+ }
+
+ @Test
+ void aColumnOffTheEndIsRefusedBeforeTheCall() {
+ try (Result r = conn.query("UNWIND [1] AS v RETURN v")) {
+ assertThrows(ZuProgrammingException.class, () -> r.longs(1));
+ assertThrows(ZuProgrammingException.class, () -> r.valid(-1));
+ }
+ }
+
+ @Test
+ void anEmptyResultBorrowsNothingAndSaysSoAsAnEmptyBuffer() {
+ try (Result r = conn.query("UNWIND [] AS v RETURN v")) {
+ assertEquals(0, r.longs(0).remaining());
+ assertEquals(0, r.doubles(0).remaining());
+ assertEquals(0, r.valid(0).remaining());
+ assertEquals(0, r.chunkCount());
+ assertEquals(0, r.chunks().count());
+ }
+ }
+
+ @Test
+ void theChunksCoverEveryRowExactlyOnce() {
+ try (Result r = conn.query(unwind(3000))) {
+ assertEquals(3000, r.rows());
+ List The whole point of the error model is that a caller reads fields rather
+ * than a message. These tests are what says the fields actually arrive.
+ */
+class ErrorTest {
+
+ private static Database db;
+ private static Connection conn;
+
+ @BeforeAll
+ static void engine() {
+ Libzu.require();
+ db = Database.memory();
+ conn = db.connect();
+ }
+
+ @AfterAll
+ static void done() {
+ if (conn != null) {
+ conn.close();
+ }
+ if (db != null) {
+ db.close();
+ }
+ }
+
+ @Test
+ void textThatWillNotParseIsASyntaxError() {
+ ZuSyntaxException e =
+ assertThrows(ZuSyntaxException.class, () -> conn.query("RETURN RETURN"));
+ assertTrue(e.code().orElseThrow().startsWith("42"), e.code().orElseThrow());
+ assertEquals(Severity.EXCEPTION, e.severity());
+ assertFalse(e.retryable());
+ assertNotNull(e.getMessage());
+ assertFalse(e.getMessage().isBlank());
+ }
+
+ @Test
+ void aFailureThatHasAPlaceCarriesIt() {
+ ZuException e = assertThrows(ZuException.class, () -> conn.query("RETURN RETURN"));
+ e.position()
+ .ifPresent(
+ p -> {
+ assertTrue(p.line() >= 1, "line " + p.line());
+ assertTrue(p.column() >= 1, "column " + p.column());
+ assertTrue(p.offset() >= 0, "offset " + p.offset());
+ });
+ // An excerpt and a column together are a caret, and the caret is the one
+ // piece of formatting this client does.
+ e.caret().ifPresent(c -> assertTrue(c.contains("^"), c));
+ }
+
+ @Test
+ void everyFailureIsCatchableAsOneType() {
+ assertThrows(ZuException.class, () -> conn.query("this is not a statement"));
+ assertThrows(ZuException.class, () -> conn.prepare("MATCH ("));
+ }
+
+ @Test
+ void theDiagnosticIsTheWholeRecord() {
+ ZuException e = assertThrows(ZuException.class, () -> conn.query("RETURN RETURN"));
+ assertNotNull(e.diagnostic());
+ assertEquals(e.getMessage(), e.diagnostic().message());
+ assertEquals(e.status(), e.diagnostic().status());
+ }
+
+ @Test
+ void aFailureLeavesTheConnectionUsable() {
+ assertThrows(ZuException.class, () -> conn.query("RETURN RETURN"));
+ conn.execute("RETURN 1 AS one");
+ assertFalse(conn.isClosed());
+ }
+
+ @Test
+ void athousandFailuresLeakNothing() {
+ // Every one of these allocates a zu_error on the far side. If the
+ // binding forgot to free them this is where it would show.
+ for (int i = 0; i < 1000; i++) {
+ assertThrows(ZuException.class, () -> conn.query("RETURN RETURN"));
+ }
+ conn.execute("RETURN 1 AS one");
+ }
+}
diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/Libzu.java b/zudb-ffm/src/test/java/dev/zudb/ffm/Libzu.java
new file mode 100644
index 0000000..ad86b89
--- /dev/null
+++ b/zudb-ffm/src/test/java/dev/zudb/ffm/Libzu.java
@@ -0,0 +1,58 @@
+package dev.zudb.ffm;
+
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+/**
+ * Whether there is a libzu to test against, and where.
+ *
+ * These tests link against a real engine, so they are skipped rather than
+ * failed when there is not one to link against. A checkout with no build of
+ * the engine beside it is an ordinary state for this repository to be in, and
+ * a red suite for it would train everybody to ignore a red suite.
+ *
+ * Point them at one with {@code -Dzu.library=/path/to/libzu.dylib}, or set
+ * {@code ZU_LIBRARY}. A sibling checkout of the engine with a release build in
+ * it is found on its own.
+ */
+final class Libzu {
+
+ private Libzu() {}
+
+ private static final Path FOUND = locate();
+
+ /** Skips the calling test when there is no engine to call. */
+ static void require() {
+ assumeTrue(FOUND != null, "no libzu: set -Dzu.library to run these");
+ if (System.getProperty("zu.library") == null) {
+ System.setProperty("zu.library", FOUND.toString());
+ }
+ }
+
+ private static Path locate() {
+ String named = System.getProperty("zu.library");
+ if (named == null || named.isBlank()) {
+ named = System.getenv("ZU_LIBRARY");
+ }
+ if (named != null && !named.isBlank()) {
+ Path p = Paths.get(named);
+ return Files.isRegularFile(p) ? p : null;
+ }
+ String name = System.mapLibraryName("zu");
+ // Up out of zudb-ffm, out of the repository, and into whichever
+ // checkout of the engine is beside it.
+ Path here = Paths.get("").toAbsolutePath();
+ for (Path root = here; root != null; root = root.getParent()) {
+ for (String sibling : new String[] {"zu", "zu-dx", "zu-g0"}) {
+ Path candidate = root.resolveSibling(sibling).resolve("target/release").resolve(name);
+ if (Files.isRegularFile(candidate)) {
+ return candidate;
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/QueryTest.java b/zudb-ffm/src/test/java/dev/zudb/ffm/QueryTest.java
new file mode 100644
index 0000000..050c207
--- /dev/null
+++ b/zudb-ffm/src/test/java/dev/zudb/ffm/QueryTest.java
@@ -0,0 +1,198 @@
+package dev.zudb.ffm;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import dev.zudb.Connection;
+import dev.zudb.Database;
+import dev.zudb.Result;
+import dev.zudb.Row;
+import dev.zudb.Type;
+import dev.zudb.Value;
+import dev.zudb.ZuClosedException;
+import dev.zudb.ZuProgrammingException;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/** Reading rows, which is what almost every program does with this client. */
+class QueryTest {
+
+ private static Database db;
+ private static Connection conn;
+
+ @BeforeAll
+ static void engine() {
+ Libzu.require();
+ db = Database.memory();
+ conn = db.connect();
+ }
+
+ @AfterAll
+ static void done() {
+ if (conn != null) {
+ conn.close();
+ }
+ if (db != null) {
+ db.close();
+ }
+ }
+
+ @Test
+ void oneRowOneColumn() {
+ try (Result r = conn.query("RETURN 1 AS one")) {
+ assertEquals(1, r.rows());
+ assertEquals(1, r.columns());
+ assertEquals(List.of("one"), r.columnNames());
+ assertEquals(1, r.row(0).getLong(0));
+ assertEquals(1, r.row(0).getLong("one"));
+ }
+ }
+
+ @Test
+ void everyScalarComesBackAsTheTypeItIs() {
+ try (Result r =
+ conn.query("RETURN 1 AS i, 1.5 AS f, 'ada' AS s, true AS b, null AS n")) {
+ Row row = r.row(0);
+ assertEquals(Type.INT, row.type("i"));
+ assertEquals(Type.FLOAT, row.type("f"));
+ assertEquals(Type.STR, row.type("s"));
+ assertEquals(Type.BOOL, row.type("b"));
+ assertEquals(Type.NULL, row.type("n"));
+
+ assertEquals(1, row.getLong("i"));
+ assertEquals(1.5, row.getDouble("f"));
+ assertEquals("ada", row.getString("s"));
+ assertTrue(row.getBoolean("b"));
+ assertTrue(row.isNull("n"));
+ }
+ }
+
+ @Test
+ void anIntegerWidensToAFloatAndNothingElseConverts() {
+ try (Result r = conn.query("RETURN 7 AS i")) {
+ assertEquals(7.0, r.row(0).getDouble("i"));
+ assertThrows(ZuProgrammingException.class, () -> r.row(0).getString("i"));
+ assertThrows(ZuProgrammingException.class, () -> r.row(0).getBoolean("i"));
+ }
+ }
+
+ @Test
+ void aNullStringIsNullRatherThanAThrow() {
+ try (Result r = conn.query("RETURN null AS s")) {
+ assertNull(r.row(0).getString("s"));
+ assertEquals(Value.Null.instance(), r.row(0).get("s"));
+ }
+ }
+
+ @Test
+ void aNullIntegerIsAThrowBecauseALongCannotSayNothing() {
+ try (Result r = conn.query("RETURN null AS i")) {
+ ZuProgrammingException e =
+ assertThrows(ZuProgrammingException.class, () -> r.row(0).getLong("i"));
+ assertTrue(e.getMessage().contains("nothing"), e.getMessage());
+ }
+ }
+
+ @Test
+ void manyRowsInOrder() {
+ try (Result r = conn.query("UNWIND [10, 20, 30] AS v RETURN v")) {
+ assertEquals(3, r.rows());
+ assertEquals(List.of(10L, 20L, 30L), r.stream().map(row -> row.getLong(0)).toList());
+ }
+ }
+
+ @Test
+ void theIterableIsTheSameRowsAsTheStream() {
+ try (Result r = conn.query("UNWIND ['a', 'b'] AS v RETURN v")) {
+ StringBuilder sb = new StringBuilder();
+ for (Row row : r) {
+ sb.append(row.getString(0));
+ }
+ assertEquals("ab", sb.toString());
+ }
+ }
+
+ @Test
+ void aStatementWithNoRowsIsAnEmptyResultRatherThanAFailure() {
+ try (Result r = conn.query("UNWIND [] AS v RETURN v")) {
+ assertEquals(0, r.rows());
+ assertEquals(0, r.stream().count());
+ assertEquals(0, r.longs(0).remaining());
+ }
+ }
+
+ @Test
+ void aColumnNobodyNamedIsAFailureThatListsTheOnesThereAre() {
+ try (Result r = conn.query("RETURN 1 AS one")) {
+ ZuProgrammingException e =
+ assertThrows(ZuProgrammingException.class, () -> r.row(0).getLong("won"));
+ assertTrue(e.getMessage().contains("one"), e.getMessage());
+ }
+ }
+
+ @Test
+ void aColumnOffTheEndIsAFailureRatherThanAReadOfSomethingElse() {
+ try (Result r = conn.query("RETURN 1 AS one")) {
+ assertThrows(ZuProgrammingException.class, () -> r.row(0).get(1));
+ assertThrows(ZuProgrammingException.class, () -> r.row(0).get(-1));
+ assertThrows(ZuProgrammingException.class, () -> r.row(1));
+ }
+ }
+
+ @Test
+ void twoColumnsOfOneNameResolveToTheFirst() {
+ try (Result r = conn.query("RETURN 1 AS v, 2 AS w")) {
+ assertEquals(0, r.columnIndex("v"));
+ assertEquals(1, r.columnIndex("w"));
+ }
+ }
+
+ @Test
+ void aClosedResultSaysSoRatherThanReadingFreedMemory() {
+ Result r = conn.query("RETURN 1 AS one");
+ Row row = r.row(0);
+ r.close();
+ assertTrue(r.isClosed());
+ assertThrows(ZuClosedException.class, () -> row.getLong(0));
+ r.close();
+ }
+
+ @Test
+ void aStatementSaysHowItCompleted() {
+ try (Result r = conn.query("RETURN 1 AS one")) {
+ assertEquals("00000", r.gqlstatus());
+ assertTrue(r.notices().isEmpty());
+ }
+ }
+
+ @Test
+ void aRowPrintsItselfWithItsColumnNames() {
+ try (Result r = conn.query("RETURN 1 AS one, 'x' AS two")) {
+ String text = r.row(0).toString();
+ assertTrue(text.contains("one="), text);
+ assertTrue(text.contains("two="), text);
+ }
+ }
+
+ @Test
+ void executeRunsAStatementAndKeepsNothing() {
+ conn.execute("RETURN 1 AS one");
+ assertFalse(conn.isClosed());
+ }
+
+ @Test
+ void theRowsOfAResultOutliveNothingButTheResult() {
+ List The trade against reading a whole column is a lifetime. A chunk's buffer
+ * is valid until the next call for the same column and the same accessor,
+ * which replaces its contents, or until the result closes. A program that
+ * needs one chunk to outlive the next copies it, which is the copy it was
+ * making anyway on the way into an array of its own.
+ *
+ * Ask a chunk its size rather than multiplying. Chunks are the same size
+ * today except the last, and will stop being once a chunk is what the
+ * executor produced rather than a slice of what it materialised.
+ *
+ * Columns are independent of each other, so reading a chunk's values and
+ * its validity together costs no reconversion.
+ *
+ * @param column the column
+ * @return a read-only view where a nonzero byte is a value
+ */
+ public ByteBuffer valid(int column) {
+ result.checkColumn(column);
+ return result.zu().chunkValid(result.open(), index, column, rows);
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/Config.java b/zudb/src/main/java/dev/zudb/Config.java
new file mode 100644
index 0000000..e6890a2
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/Config.java
@@ -0,0 +1,81 @@
+package dev.zudb;
+
+/**
+ * How a database is opened. Zero means the default in every field, so
+ * {@link #defaults()} opens the same database as passing nothing.
+ *
+ * A record with {@code with} methods rather than a builder, because there
+ * are three fields and a builder for three fields is a class to read before
+ * you can open a file.
+ *
+ * @param memoryLimit bytes the caches may hold, 0 for the default. A suffix
+ * such as {@code MB} is deliberately not parsed anywhere in this client:
+ * its two readings differ by 4.9%, and the place to decide which one a
+ * user meant is where the user typed it
+ * @param threads query workers, 0 to let the executor pick and 1 for
+ * sequential, which is what a benchmark that wants a number it can
+ * compare asks for
+ * @param readOnly whether to open a descriptor this process cannot write
+ * through, which is enforced by the operating system and not by a check
+ */
+public record Config(long memoryLimit, long threads, boolean readOnly) {
+
+ private static final Config DEFAULTS = new Config(0, 0, false);
+
+ /**
+ * Refuses a count that cannot be one.
+ *
+ * @param memoryLimit bytes, which cannot be negative
+ * @param threads workers, which cannot be negative
+ * @param readOnly whether writes are refused
+ */
+ public Config {
+ if (memoryLimit < 0) {
+ throw new ZuProgrammingException(
+ Diagnostic.misuse(Status.MISUSE, "a memory limit of " + memoryLimit + " bytes"));
+ }
+ if (threads < 0) {
+ throw new ZuProgrammingException(
+ Diagnostic.misuse(Status.MISUSE, "a thread count of " + threads));
+ }
+ }
+
+ /**
+ * Everything left to the engine.
+ *
+ * @return the default configuration
+ */
+ public static Config defaults() {
+ return DEFAULTS;
+ }
+
+ /**
+ * The same, with a cache budget.
+ *
+ * @param bytes what the caches may hold, 0 for the default
+ * @return a new configuration
+ */
+ public Config withMemoryLimit(long bytes) {
+ return new Config(bytes, threads, readOnly);
+ }
+
+ /**
+ * The same, with a worker count.
+ *
+ * @param count query workers, 0 to let the executor pick, 1 for sequential
+ * @return a new configuration
+ */
+ public Config withThreads(long count) {
+ return new Config(memoryLimit, count, readOnly);
+ }
+
+ /**
+ * The same, refusing writes.
+ *
+ * @param value whether the descriptor cannot be written through
+ * @return a new configuration
+ */
+ public Config withReadOnly(boolean value) {
+ return new Config(memoryLimit, threads, value);
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/Connection.java b/zudb/src/main/java/dev/zudb/Connection.java
new file mode 100644
index 0000000..81f15ef
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/Connection.java
@@ -0,0 +1,251 @@
+package dev.zudb;
+
+import dev.zudb.spi.ZuBinding;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Supplier;
+
+/**
+ * The state that cannot be shared: a file handle, the caches, and the plans
+ * compiled against a catalog.
+ *
+ * A connection may move between threads but must not be used from two at
+ * once. A call that finds one already in use raises
+ * {@link ZuConcurrentException} rather than corrupting a cache, so a program
+ * that shares one fails under load and passes every test. A program that
+ * queries from four threads opens one {@link Database} and calls
+ * {@link Database#connect()} four times, or {@link #duplicate()} where it no
+ * longer has the database.
+ *
+ * {@link #interrupt()} and {@link #rowsRead()} are the exception and the
+ * point of it. Both are meant to be called from another thread while a
+ * statement is running, and neither raises {@link ZuConcurrentException}: a
+ * cancellation that had to wait for the connection to be free could only
+ * arrive after the statement it was meant to stop.
+ */
+public final class Connection implements AutoCloseable {
+
+ private final ZuBinding zu;
+ private final AtomicLong handle;
+
+ Connection(ZuBinding zu, long handle) {
+ this.zu = zu;
+ this.handle = new AtomicLong(handle);
+ }
+
+ /**
+ * Runs one statement and hands back everything it answered.
+ *
+ * The result owns its rows outright, so it stays readable after this
+ * connection has gone back to a pool. What it does not outlive is its own
+ * {@link Result#close()}.
+ *
+ * @param statement the text
+ * @return the result, which the caller closes
+ */
+ public Result query(String statement) {
+ return new Result(zu, zu.query(open(), statement));
+ }
+
+ /**
+ * Runs one statement and throws away what it answered, for the statement
+ * there is nothing to read from.
+ *
+ * @param statement the text
+ */
+ public void execute(String statement) {
+ query(statement).close();
+ }
+
+ /**
+ * Prepares a statement, which is parsed and planned once and run as often
+ * as you like.
+ *
+ * Bindings live on the statement and survive an execute, so a loop
+ * rebinds only what changed.
+ *
+ * @param statement the text, with its parameters named
+ * @return the statement, which the caller closes
+ */
+ public Statement prepare(String statement) {
+ return new Statement(zu, zu.prepare(open(), statement));
+ }
+
+ /**
+ * A second connection on the database this one is already on, made without
+ * a path.
+ *
+ * This is what a pool calls once it has handed the database back, and it
+ * is the only way to a second connection on a database in memory, which has
+ * no path to reopen. The switches and the read-only setting come across;
+ * the plan cache, the block caches, the interrupt and the transaction do
+ * not, because those are what make it a connection of its own.
+ *
+ * @return a new connection
+ */
+ public Connection duplicate() {
+ return new Connection(zu, zu.connDuplicate(open()));
+ }
+
+ /**
+ * Stops whatever is running on this connection, from another thread.
+ *
+ * The statement stops at the next boundary the executor checks, which is
+ * a chunk of rows rather than the end of the query, and raises
+ * {@link ZuInterruptedException}. Nothing failed: the connection keeps its
+ * plans and its warm caches and runs the next statement normally, which is
+ * the difference between this and closing it.
+ *
+ * An ask raised while nothing is running is dropped when the next
+ * statement starts, so a Ctrl-C at a prompt cannot end whatever the user
+ * types next.
+ *
+ * What is safe from another thread is a statement running. Closing the
+ * connection underneath this call is not, and no amount of locking here
+ * could make it so: the program has to know that the connection is still
+ * there.
+ */
+ public void interrupt() {
+ zu.connInterrupt(open());
+ }
+
+ /**
+ * How many rows the running statement has read out of storage, counted from
+ * zero at each statement and left at its final value once one ends.
+ *
+ * Rows read rather than rows answered, because the statement a user is
+ * waiting on is exactly the one reading a hundred million rows to answer
+ * one. Safe from another thread, which is what a progress bar needs.
+ *
+ * @return the count
+ */
+ public long rowsRead() {
+ return zu.connRowsRead(open());
+ }
+
+ /**
+ * Starts a transaction.
+ *
+ * Every statement outside one is already a transaction of its own, so
+ * this does not turn transactions on. What it does is make several
+ * statements one: what they wrote is kept by {@link #commit()} or unmade by
+ * {@link #rollback()}, and nothing between the two is visible to another
+ * connection until the commit publishes it.
+ */
+ public void begin() {
+ zu.begin(open(), false);
+ }
+
+ /**
+ * Starts a transaction that refuses writes, which is enforced rather than
+ * advisory: a write inside one is refused at the statement that wrote, not
+ * at the commit.
+ */
+ public void beginReadOnly() {
+ zu.begin(open(), true);
+ }
+
+ /**
+ * Keeps what the transaction wrote. The log frame is on the disk before
+ * this returns.
+ */
+ public void commit() {
+ zu.commit(open());
+ }
+
+ /** Unmakes what the transaction wrote. */
+ public void rollback() {
+ zu.rollback(open());
+ }
+
+ /**
+ * Whether a transaction is running.
+ *
+ * This is the one thing about a transaction that no statement answers,
+ * and every block that ends one needs it: the cleanup path has to know
+ * whether the body already did.
+ *
+ * @return true inside a transaction
+ */
+ public boolean inTransaction() {
+ return zu.connInTransaction(open());
+ }
+
+ /**
+ * Runs a block inside a transaction, committing if it returns and rolling
+ * back if it throws.
+ *
+ * A body that commits or rolls back for itself is left alone rather than
+ * committed twice, which is why {@link #inTransaction()} exists.
+ *
+ * @param body what to run
+ */
+ public void transaction(Runnable body) {
+ transaction(
+ () -> {
+ body.run();
+ return null;
+ });
+ }
+
+ /**
+ * The same, for a block that has an answer.
+ *
+ * @param Closing is itself a use of the connection and obeys the same rule as
+ * every other one. Closing twice does nothing the second time.
+ */
+ @Override
+ public void close() {
+ long h = handle.getAndSet(0);
+ if (h != 0) {
+ zu.connClose(h);
+ }
+ }
+
+ private long open() {
+ long h = handle.get();
+ if (h == 0) {
+ throw new ZuClosedException(
+ Diagnostic.misuse(Status.MISUSE_CLOSED, "this connection is closed"));
+ }
+ return h;
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/Database.java b/zudb/src/main/java/dev/zudb/Database.java
new file mode 100644
index 0000000..d2324ca
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/Database.java
@@ -0,0 +1,203 @@
+package dev.zudb;
+
+import dev.zudb.spi.ZuBinding;
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * A path and a configuration that have been checked against a real file.
+ *
+ * It holds no descriptor and no cache, so it is safe to share between
+ * threads and cheap to keep. What cannot be shared is a {@link Connection}: a
+ * program that queries from four threads opens one of these and connects four
+ * times.
+ *
+ * The file is opened once here and closed again, so a path that is not a
+ * zu database fails at {@link #open} rather than on the first query. Closing
+ * a database does not close the connections opened from it, because each one
+ * holds its own file handle; this releases the path and the configuration and
+ * nothing else.
+ *
+ * The path must not exist. A create that opened what it found there
+ * would be the call that quietly writes into somebody else's data, and a
+ * program that wants either one has {@link #open} to fall back to and a
+ * decision to make about which.
+ *
+ * @param path the file to make
+ * @return the database
+ */
+ public static Database create(Path path) {
+ return create(path, Config.defaults());
+ }
+
+ /**
+ * Creates a database and opens it.
+ *
+ * @param path the file to make, which must not exist
+ * @param config the caches and the workers
+ * @return the database
+ */
+ public static Database create(Path path, Config config) {
+ ZuBinding zu = Zu.binding();
+ return new Database(
+ zu,
+ zu.databaseCreate(
+ path.toString(), config.memoryLimit(), config.threads(), config.readOnly()));
+ }
+
+ /**
+ * A database that never touches the filesystem, with the default
+ * configuration.
+ *
+ * Every call makes one of its own. Two connections on one of these are
+ * two views of one graph; two of these share nothing, and nothing survives
+ * the process.
+ *
+ * @return the database
+ */
+ public static Database memory() {
+ return memory(Config.defaults());
+ }
+
+ /**
+ * A database that never touches the filesystem.
+ *
+ * @param config the caches and the workers
+ * @return the database
+ */
+ public static Database memory(Config config) {
+ ZuBinding zu = Zu.binding();
+ return new Database(
+ zu, zu.databaseMemory(config.memoryLimit(), config.threads(), config.readOnly()));
+ }
+
+ /**
+ * A connection, which keeps the catalog, the statistics, the plan cache and
+ * the block caches resident, so queries after the first run without
+ * touching the catalog on disk.
+ *
+ * That is also why it is per connection rather than per database, and
+ * why a pool calls this once per worker instead of sharing one.
+ *
+ * @return a new connection
+ */
+ public Connection connect() {
+ return new Connection(zu, zu.connect(open()));
+ }
+
+ /**
+ * What this process calls the database.
+ *
+ * For one in memory this is a name and not a path: it is what an error
+ * message needs, and not something to open.
+ *
+ * @return the name
+ */
+ public String path() {
+ return zu.databasePath(open());
+ }
+
+ /**
+ * Whether this database is in memory, which is the way to ask rather than
+ * to read {@link #path()} and guess.
+ *
+ * @return true for a database in memory
+ */
+ public boolean isMemory() {
+ return zu.databaseIsMemory(open());
+ }
+
+ /**
+ * Whether this database has been closed.
+ *
+ * @return true once {@link #close()} has run
+ */
+ public boolean isClosed() {
+ return handle.get() == 0;
+ }
+
+ /** Releases the path and the configuration. Closing twice does nothing the second time. */
+ @Override
+ public void close() {
+ long h = handle.getAndSet(0);
+ if (h != 0) {
+ zu.databaseClose(h);
+ }
+ }
+
+ private long open() {
+ long h = handle.get();
+ if (h == 0) {
+ throw new ZuClosedException(
+ Diagnostic.misuse(Status.MISUSE_CLOSED, "this database is closed"));
+ }
+ return h;
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/Diagnostic.java b/zudb/src/main/java/dev/zudb/Diagnostic.java
new file mode 100644
index 0000000..5f5be19
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/Diagnostic.java
@@ -0,0 +1,150 @@
+package dev.zudb;
+
+/**
+ * One diagnostic record, read off the C ABI and not yet decided about.
+ *
+ * A record is a record whether it ends up thrown or handed back through
+ * {@link Result#notices()}. The code, its standard text, the severity, the
+ * place, the line and the documentation page are the same fields either way,
+ * and the severity is what tells them apart. This is the one shape, and
+ * {@link #toException()} is where it becomes the other.
+ *
+ * Providers build these. Nothing else has any reason to.
+ *
+ * @param status what the call that produced this answered, {@link Status#OK}
+ * for a notice, since that is what the call returned
+ * @param message zu's own account of the failure, naming the table, the token
+ * or the value, and complete on its own, so printing it alone is still a
+ * whole report
+ * @param code the five-character GQLSTATUS code, or null for a condition the
+ * standard has no code for
+ * @param condition the standard's words for the condition class and subclass,
+ * or null
+ * @param severity how bad it is, never null
+ * @param line the 1-based line the condition was raised at, or -1 for a
+ * failure with no position
+ * @param column the 1-based column in characters, or -1
+ * @param offset the 0-based byte index into the statement, or -1
+ * @param excerpt the line the position is on without its newline, or null
+ * @param docUrl where this condition is written up, or null
+ * @param retryable whether running the same statement again could succeed
+ */
+public record Diagnostic(
+ Status status,
+ String message,
+ String code,
+ String condition,
+ Severity severity,
+ int line,
+ int column,
+ int offset,
+ String excerpt,
+ String docUrl,
+ boolean retryable) {
+
+ /**
+ * The exception this record is, of the class its condition names.
+ *
+ * The class comes from the two characters that open the GQLSTATUS code,
+ * which is what a condition class is, and from the status when there is no
+ * code. That is what lets a caller catch every one of the forty-two
+ * conditions in class 22 by naming {@link ZuDataException} once.
+ *
+ * @return a new exception, never null
+ */
+ public ZuException toException() {
+ String cls = code == null || code.length() < 2 ? "" : code.substring(0, 2);
+ switch (cls) {
+ case "08":
+ return new ZuConnectionException(this);
+ case "22":
+ return new ZuDataException(this);
+ case "25":
+ case "2D":
+ case "40":
+ return new ZuTransactionException(this);
+ case "42":
+ return new ZuSyntaxException(this);
+ default:
+ break;
+ }
+ switch (status) {
+ case MISUSE:
+ return new ZuProgrammingException(this);
+ case MISUSE_CONCURRENT:
+ return new ZuConcurrentException(this);
+ case MISUSE_CLOSED:
+ return new ZuClosedException(this);
+ case INTERRUPTED:
+ return new ZuInterruptedException(this);
+ case CONFLICT:
+ return new ZuTransactionException(this);
+ case IO:
+ return new ZuConnectionException(this);
+ default:
+ return new ZuInternalException(this);
+ }
+ }
+
+ /**
+ * A record built out of what the C ABI answered.
+ *
+ * This is how a provider makes one. It takes the status and the severity
+ * as the numbers the library gave it, so that the mapping from those numbers
+ * to the two enums happens here and once, rather than in every provider with
+ * its own idea of what an unknown number means.
+ *
+ * @param status what {@code zu_error_status} answered
+ * @param message what {@code zu_error_message} answered
+ * @param code the GQLSTATUS code, or null
+ * @param condition the standard's words for it, or null
+ * @param severity what {@code zu_error_severity} answered
+ * @param line the 1-based line, or -1
+ * @param column the 1-based column, or -1
+ * @param offset the 0-based byte index, or -1
+ * @param excerpt the line the position is on, or null
+ * @param docUrl where this condition is written up, or null
+ * @param retryable whether running the same statement again could succeed
+ * @return the record
+ */
+ public static Diagnostic of(
+ int status,
+ String message,
+ String code,
+ String condition,
+ int severity,
+ int line,
+ int column,
+ int offset,
+ String excerpt,
+ String docUrl,
+ boolean retryable) {
+ return new Diagnostic(
+ Status.of(status),
+ message,
+ code,
+ condition,
+ Severity.of(severity),
+ line,
+ column,
+ offset,
+ excerpt,
+ docUrl,
+ retryable);
+ }
+
+ /**
+ * A record for a failure that never reached the engine, which is every
+ * mistake a caller makes in Java: a handle used after it closed, a column
+ * index off the end, a parameter of a type zu has no place for.
+ *
+ * @param status what to call it, which is one of the misuse statuses
+ * @param message what the caller did
+ * @return a record with no code and no position, because a call that was
+ * never made raised no condition
+ */
+ public static Diagnostic misuse(Status status, String message) {
+ return new Diagnostic(
+ status, message, null, null, Severity.EXCEPTION, -1, -1, -1, null, null, false);
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/Library.java b/zudb/src/main/java/dev/zudb/Library.java
new file mode 100644
index 0000000..8b4781b
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/Library.java
@@ -0,0 +1,149 @@
+package dev.zudb;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Where libzu is.
+ *
+ * Four places, in this order, and the first that answers wins. A path
+ * somebody named is first because a bisect and a bug report both start by
+ * pointing this at a build; the artifact that ships with the client is next
+ * because it is what a user who installed nothing has; and the platform's own
+ * search is last because it is the one that can pick up a library from
+ * somewhere nobody in this process chose.
+ *
+ * Finding it happens here rather than in a provider, so that the two
+ * providers cannot disagree about which library they loaded and so that the
+ * answer can be printed.
+ */
+final class Library {
+
+ /** A path to the library itself, which wins over everything else. */
+ static final String PROPERTY = "zu.library";
+
+ /** The same, for a process that cannot pass a system property. */
+ static final String ENVIRONMENT = "ZU_LIBRARY";
+
+ private Library() {}
+
+ /**
+ * The library, and how it was found.
+ *
+ * @param path the file, or a bare name meaning that the platform is to
+ * search for it
+ * @param source a phrase naming where the path came from, for the log line
+ * and for a failure
+ */
+ record Found(Path path, String source) {}
+
+ static Found find() {
+ List A result owns its rows outright, so it stays readable after the
+ * connection that produced it has gone back to a pool. What it does not
+ * outlive is {@link #close()}, and that includes every buffer the columnar
+ * readers handed back and every string that came out of a row.
+ *
+ * There are three ways to read it, in the order you reach for them.
+ * {@link #stream()} for a row at a time, which is what most code wants.
+ * {@link #longs(int)} and the three beside it for a whole column, borrowed
+ * from the engine rather than copied, which is what a million rows wants.
+ * {@link #chunks()} for a whole column read a chunk at a time, which is what
+ * a million rows wants when you are not going to read all of them.
+ *
+ * The stream is lazy and reads out of the result as it goes, so it has
+ * to be consumed before {@link #close()}. Collecting it inside the
+ * try-with-resources and using the list afterwards is the shape that always
+ * works.
+ *
+ * @return the rows
+ */
+ public Stream This is the half of the GQLSTATUS envelope a program reading rows and
+ * failures could not see. The status a call returned says whether it
+ * worked; this says which way, in the standard's own terms, and it is the
+ * value a conformance harness grades.
+ *
+ * @return the code, never null
+ */
+ public String gqlstatus() {
+ return zu.resultGqlstatus(open());
+ }
+
+ /**
+ * The conditions the statement raised and carried on through.
+ *
+ * An exception replaces a result and arrives as a throw; a warning rides
+ * along with one, because a statement that dropped a null out of an
+ * aggregate still has rows to give you and the standard still wants you
+ * told. Almost every statement raises none.
+ *
+ * @return the records, in the order they were raised, unmodifiable and
+ * usually empty
+ */
+ public List Reading a million of them costs one call and no allocation. What it
+ * costs is a lifetime: the buffer is valid until {@link #close()} and not
+ * one statement longer.
+ *
+ * Nulls read as zero, which {@link #valid(int)} tells apart. Booleans
+ * read as 0 and 1. A node does not read here at all: an internal row number
+ * is not an identity, and {@link #nodeOffsets(int)} is the call that says
+ * so out loud.
+ *
+ * @param column the column, which must hold integers or booleans
+ * @return a read-only view, empty when the result has no rows
+ */
+ public LongBuffer longs(int column) {
+ checkColumn(column);
+ LongBuffer b = zu.colLongs(open(), column, rows);
+ return b == null ? LongBuffer.allocate(0).asReadOnlyBuffer() : b;
+ }
+
+ /**
+ * A whole column of floats, borrowed rather than copied.
+ *
+ * @param column the column, which must hold floats or integers
+ * @return a read-only view, empty when the result has no rows
+ */
+ public DoubleBuffer doubles(int column) {
+ checkColumn(column);
+ DoubleBuffer b = zu.colDoubles(open(), column, rows);
+ return b == null ? DoubleBuffer.allocate(0).asReadOnlyBuffer() : b;
+ }
+
+ /**
+ * A whole column of node row offsets, borrowed rather than copied.
+ *
+ * The row offset is what identifies a node inside its table, and it
+ * takes the table to make an identity, which {@link Row#get(int)} hands
+ * over as a {@link Value.Node}. This is the bulk path for a column of nodes
+ * that are all of one table.
+ *
+ * @param column the column, which must hold nodes
+ * @return a read-only view, empty when the result has no rows
+ */
+ public LongBuffer nodeOffsets(int column) {
+ checkColumn(column);
+ LongBuffer b = zu.colNodeOffsets(open(), column, rows);
+ return b == null ? LongBuffer.allocate(0).asReadOnlyBuffer() : b;
+ }
+
+ /**
+ * Which values of a column are not null, one byte a row, borrowed rather
+ * than copied.
+ *
+ * @param column the column
+ * @return a read-only view where a nonzero byte is a value, empty when the
+ * result has no rows
+ */
+ public ByteBuffer valid(int column) {
+ checkColumn(column);
+ ByteBuffer b = zu.colValid(open(), column, rows);
+ return b == null ? ByteBuffer.allocate(0).asReadOnlyBuffer() : b;
+ }
+
+ // ---- chunks ----
+
+ /**
+ * How many chunks this result has, which is the loop bound.
+ *
+ * @return the count, 0 for a result with no rows
+ */
+ public long chunkCount() {
+ return zu.chunkCount(open());
+ }
+
+ /**
+ * One chunk.
+ *
+ * @param index the chunk, counting from zero
+ * @return the chunk
+ */
+ public Chunk chunk(long index) {
+ long h = open();
+ long count = zu.chunkCount(h);
+ if (index < 0 || index >= count) {
+ throw new ZuProgrammingException(
+ Diagnostic.misuse(
+ Status.MISUSE, "chunk " + index + " of a result with " + count + " of them"));
+ }
+ long[] shape = zu.chunk(h, index);
+ return new Chunk(this, index, shape[0], shape[1]);
+ }
+
+ /**
+ * Every chunk, in order.
+ *
+ * Which of these to use is a question of size. A point read wants a
+ * whole column, because the answer is small and one call beats a loop.
+ * Every large answer wants chunks, because the whole-column call converts
+ * all of it before returning any of it and keeps the conversion until the
+ * result is freed: reading the first hundred rows of a million-row column
+ * and stopping pays for the other 999,900.
+ *
+ * @return the chunks
+ */
+ public Stream Every string in it is copied out on the way, so the tree outlives
+ * nothing that the result does not, and a caller holding one after the
+ * result closed is holding Java objects rather than freed memory. That is
+ * the one place this client copies on purpose: a tree of records is not a
+ * column, and the alternative is a lifetime rule with no way to enforce it.
+ */
+ Value read(long value) {
+ Type type = Type.of(zu.valueType(value));
+ switch (type) {
+ case NULL:
+ return Value.Null.instance();
+ case BOOL:
+ return new Value.Bool(zu.valueBoolean(value));
+ case INT:
+ return new Value.Int(zu.valueLong(value));
+ case FLOAT:
+ return new Value.Float(zu.valueDouble(value));
+ case STR:
+ return new Value.Str(zu.valueString(value));
+ case NODE: {
+ long[] n = zu.valueNode(value);
+ return new Value.Node((int) n[0], n[1]);
+ }
+ case REL: {
+ long[] r = zu.valueRel(value);
+ return new Value.Rel((int) r[0], r[1], r[2]);
+ }
+ case LIST:
+ return new Value.List(items(value));
+ case PATH:
+ return new Value.Path(items(value));
+ case RECORD: {
+ long length = zu.valueLength(value);
+ List A row is a position rather than a copy. It holds no values of its own
+ * and reads them out of the result as it is asked, so it is good exactly as
+ * long as the result is and no longer.
+ *
+ * The typed accessors are the short way to read a cell whose type you
+ * know, and they refuse a cell that is null rather than answering zero, since
+ * a {@code long} has no way to say that there was nothing there.
+ * {@link #isNull(int)} is how to ask, and {@link #get(int)} is how to read a
+ * cell whose type you do not know or whose null you want as a value.
+ */
+public final class Row {
+
+ private final Result result;
+ private final long index;
+
+ Row(Result result, long index) {
+ this.result = result;
+ this.index = index;
+ }
+
+ /**
+ * Which row of the result this is.
+ *
+ * @return the index, counting from zero
+ */
+ public long index() {
+ return index;
+ }
+
+ /**
+ * The result this row is part of.
+ *
+ * @return the result
+ */
+ public Result result() {
+ return result;
+ }
+
+ /**
+ * Whether a cell is null.
+ *
+ * @param column the column, counting from zero
+ * @return true if there is no value there
+ */
+ public boolean isNull(int column) {
+ return result.cellType(index, column) == Type.NULL;
+ }
+
+ /**
+ * Whether a cell is null.
+ *
+ * @param column what the statement called it
+ * @return true if there is no value there
+ */
+ public boolean isNull(String column) {
+ return isNull(result.columnIndex(column));
+ }
+
+ /**
+ * What a cell holds, without reading it.
+ *
+ * @param column the column, counting from zero
+ * @return the type
+ */
+ public Type type(int column) {
+ return result.cellType(index, column);
+ }
+
+ /**
+ * What a cell holds, without reading it.
+ *
+ * @param column what the statement called it
+ * @return the type
+ */
+ public Type type(String column) {
+ return type(result.columnIndex(column));
+ }
+
+ /**
+ * One cell, as the type it actually is.
+ *
+ * Strings in the tree are copied out on the way, so what comes back is
+ * Java objects and outlives nothing the result does not.
+ *
+ * @param column the column, counting from zero
+ * @return the value, {@link Value.Null} for a cell with nothing in it
+ */
+ public Value get(int column) {
+ result.checkColumn(column);
+ return result.read(result.zu().resultCell(result.open(), index, column));
+ }
+
+ /**
+ * One cell, as the type it actually is.
+ *
+ * @param column what the statement called it
+ * @return the value
+ */
+ public Value get(String column) {
+ return get(result.columnIndex(column));
+ }
+
+ /**
+ * A cell as an integer.
+ *
+ * @param column the column, counting from zero
+ * @return the integer
+ * @throws ZuProgrammingException if the cell is null or holds something else
+ */
+ public long getLong(int column) {
+ Value v = get(column);
+ if (v instanceof Value.Int i) {
+ return i.value();
+ }
+ throw wrong(column, v, "an integer");
+ }
+
+ /**
+ * A cell as an integer.
+ *
+ * @param column what the statement called it
+ * @return the integer
+ */
+ public long getLong(String column) {
+ return getLong(result.columnIndex(column));
+ }
+
+ /**
+ * A cell as a float, widening an integer on the way, which is the one
+ * conversion this client makes without being asked and is the one every
+ * arithmetic in Java makes too.
+ *
+ * @param column the column, counting from zero
+ * @return the double
+ * @throws ZuProgrammingException if the cell is null or holds something else
+ */
+ public double getDouble(int column) {
+ Value v = get(column);
+ if (v instanceof Value.Float f) {
+ return f.value();
+ }
+ if (v instanceof Value.Int i) {
+ return i.value();
+ }
+ throw wrong(column, v, "a float");
+ }
+
+ /**
+ * A cell as a float.
+ *
+ * @param column what the statement called it
+ * @return the double
+ */
+ public double getDouble(String column) {
+ return getDouble(result.columnIndex(column));
+ }
+
+ /**
+ * A cell as a boolean.
+ *
+ * @param column the column, counting from zero
+ * @return the boolean
+ * @throws ZuProgrammingException if the cell is null or holds something else
+ */
+ public boolean getBoolean(int column) {
+ Value v = get(column);
+ if (v instanceof Value.Bool b) {
+ return b.value();
+ }
+ throw wrong(column, v, "a boolean");
+ }
+
+ /**
+ * A cell as a boolean.
+ *
+ * @param column what the statement called it
+ * @return the boolean
+ */
+ public boolean getBoolean(String column) {
+ return getBoolean(result.columnIndex(column));
+ }
+
+ /**
+ * A cell as a string.
+ *
+ * Null rather than a throw for a cell with nothing in it, because a
+ * {@code String} can say that and a {@code long} cannot, and because a
+ * column of names with a gap in it is an ordinary thing to read.
+ *
+ * @param column the column, counting from zero
+ * @return the string, or null if the cell is null
+ * @throws ZuProgrammingException if the cell holds something that is not a
+ * string
+ */
+ public String getString(int column) {
+ result.checkColumn(column);
+ Type type = result.cellType(index, column);
+ if (type == Type.NULL) {
+ return null;
+ }
+ if (type != Type.STR) {
+ throw new ZuProgrammingException(
+ Diagnostic.misuse(
+ Status.MISUSE,
+ "column " + result.columnName(column) + " of row " + index + " holds " + type
+ + " and was read as a string"));
+ }
+ return result.zu().resultCellString(result.open(), index, column);
+ }
+
+ /**
+ * A cell as a string.
+ *
+ * @param column what the statement called it
+ * @return the string, or null if the cell is null
+ */
+ public String getString(String column) {
+ return getString(result.columnIndex(column));
+ }
+
+ /**
+ * A cell as a date, a time, a datetime or a duration.
+ *
+ * @param column the column, counting from zero
+ * @return the temporal, which knows which of the seven it is
+ * @throws ZuProgrammingException if the cell is null or holds something else
+ */
+ public Value.Temporal getTemporal(int column) {
+ Value v = get(column);
+ if (v instanceof Value.Temporal t) {
+ return t;
+ }
+ throw wrong(column, v, "a temporal");
+ }
+
+ /**
+ * A cell as a temporal.
+ *
+ * @param column what the statement called it
+ * @return the temporal
+ */
+ public Value.Temporal getTemporal(String column) {
+ return getTemporal(result.columnIndex(column));
+ }
+
+ /**
+ * A cell as a node.
+ *
+ * @param column the column, counting from zero
+ * @return the node, which is a table and a row of it
+ * @throws ZuProgrammingException if the cell is null or holds something else
+ */
+ public Value.Node getNode(int column) {
+ Value v = get(column);
+ if (v instanceof Value.Node n) {
+ return n;
+ }
+ throw wrong(column, v, "a node");
+ }
+
+ /**
+ * A cell as a node.
+ *
+ * @param column what the statement called it
+ * @return the node
+ */
+ public Value.Node getNode(String column) {
+ return getNode(result.columnIndex(column));
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder("row ").append(index).append(" {");
+ for (int c = 0; c < result.columns(); c++) {
+ if (c > 0) {
+ sb.append(", ");
+ }
+ sb.append(result.columnName(c)).append('=').append(get(c));
+ }
+ return sb.append('}').toString();
+ }
+
+ private ZuProgrammingException wrong(int column, Value value, String wanted) {
+ String held =
+ value instanceof Value.Null
+ ? "nothing"
+ : value.getClass().getSimpleName().toLowerCase(java.util.Locale.ROOT);
+ return new ZuProgrammingException(
+ Diagnostic.misuse(
+ Status.MISUSE,
+ "column "
+ + result.columnName(column)
+ + " of row "
+ + index
+ + " holds "
+ + held
+ + " and was read as "
+ + wanted));
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/Severity.java b/zudb/src/main/java/dev/zudb/Severity.java
new file mode 100644
index 0000000..7b88b75
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/Severity.java
@@ -0,0 +1,46 @@
+package dev.zudb;
+
+/**
+ * How bad a diagnostic record is, which is what decides whether a binding
+ * raises at all.
+ *
+ * An exception replaces a result and arrives as a throw. A warning rides
+ * along with one and arrives through {@link Result#notices()}, because a
+ * statement that dropped a null out of an aggregate still has rows to give
+ * you and the standard still wants you told.
+ */
+public enum Severity {
+ /** The statement did what it was asked. */
+ SUCCESS,
+ /** Successful completion, with the result omitted. */
+ NO_DATA,
+ /** The statement answered, and raised a condition on the way. */
+ WARNING,
+ /** Something worth telling the caller that is neither of the above. */
+ INFORMATIONAL,
+ /** The statement was refused or could not finish. */
+ EXCEPTION;
+
+ /**
+ * The severity a {@code zu_error_severity} value names.
+ *
+ * @param value what the C ABI returned
+ * @return the severity, and {@link #EXCEPTION} for a value this release has
+ * no name for, since treating an unknown severity as harmless is the
+ * one reading that loses data
+ */
+ static Severity of(int value) {
+ switch (value) {
+ case 0:
+ return SUCCESS;
+ case 1:
+ return NO_DATA;
+ case 2:
+ return WARNING;
+ case 3:
+ return INFORMATIONAL;
+ default:
+ return EXCEPTION;
+ }
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/Statement.java b/zudb/src/main/java/dev/zudb/Statement.java
new file mode 100644
index 0000000..2d47cda
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/Statement.java
@@ -0,0 +1,277 @@
+package dev.zudb;
+
+import dev.zudb.spi.ZuBinding;
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.Period;
+import java.time.ZoneOffset;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * A statement parsed and planned once and run as often as you like.
+ *
+ * Bindings live on the statement and survive {@link #execute()}, so a loop
+ * rebinds only what changed. Binding a name again replaces its value.
+ *
+ * A statement belongs to the connection it was prepared on. Using one
+ * after that connection closes raises {@link ZuClosedException} rather than
+ * following a dangling pointer, and the statement is still safe to close.
+ */
+public final class Statement implements AutoCloseable {
+
+ private static final long NANOS = 1_000_000_000L;
+
+ private final ZuBinding zu;
+ private final AtomicLong handle;
+
+ Statement(ZuBinding zu, long handle) {
+ this.zu = zu;
+ this.handle = new AtomicLong(handle);
+ }
+
+ /**
+ * Binds an integer.
+ *
+ * @param name the parameter, written without its marker
+ * @param value what to bind
+ * @return this statement, so binds chain
+ */
+ public Statement bind(String name, long value) {
+ zu.bindLong(open(), name, value);
+ return this;
+ }
+
+ /**
+ * Binds a float.
+ *
+ * @param name the parameter
+ * @param value what to bind
+ * @return this statement
+ */
+ public Statement bind(String name, double value) {
+ zu.bindDouble(open(), name, value);
+ return this;
+ }
+
+ /**
+ * Binds a boolean.
+ *
+ * @param name the parameter
+ * @param value what to bind
+ * @return this statement
+ */
+ public Statement bind(String name, boolean value) {
+ zu.bindBoolean(open(), name, value);
+ return this;
+ }
+
+ /**
+ * Binds a string.
+ *
+ * @param name the parameter
+ * @param value what to bind, which may not be null: {@link #bindNull} is
+ * how to say that, so that a variable that turned out to be null is a
+ * failure at the bind rather than a query that quietly matched nothing
+ * @return this statement
+ */
+ public Statement bind(String name, String value) {
+ if (value == null) {
+ throw new ZuProgrammingException(
+ Diagnostic.misuse(
+ Status.MISUSE, "bind(" + name + ", null): call bindNull to bind no value"));
+ }
+ zu.bindString(open(), name, value);
+ return this;
+ }
+
+ /**
+ * Binds a date.
+ *
+ * @param name the parameter
+ * @param value what to bind
+ * @return this statement
+ */
+ public Statement bind(String name, LocalDate value) {
+ return bind(name, Value.Temporal.Kind.DATE, value.toEpochDay(), 0);
+ }
+
+ /**
+ * Binds a time of day.
+ *
+ * @param name the parameter
+ * @param value what to bind
+ * @return this statement
+ */
+ public Statement bind(String name, LocalTime value) {
+ return bind(name, Value.Temporal.Kind.LOCAL_TIME, value.toNanoOfDay(), 0);
+ }
+
+ /**
+ * Binds a time of day with an offset.
+ *
+ * @param name the parameter
+ * @param value what to bind
+ * @return this statement
+ */
+ public Statement bind(String name, OffsetTime value) {
+ return bind(
+ name,
+ Value.Temporal.Kind.ZONED_TIME,
+ value.toLocalTime().toNanoOfDay(),
+ value.getOffset().getTotalSeconds() / 60);
+ }
+
+ /**
+ * Binds a datetime.
+ *
+ * @param name the parameter
+ * @param value what to bind
+ * @return this statement
+ */
+ public Statement bind(String name, LocalDateTime value) {
+ return bind(name, Value.Temporal.Kind.LOCAL_DATETIME, nanos(value.toEpochSecond(ZoneOffset.UTC), value.getNano()), 0);
+ }
+
+ /**
+ * Binds a datetime with an offset.
+ *
+ * @param name the parameter
+ * @param value what to bind
+ * @return this statement
+ */
+ public Statement bind(String name, OffsetDateTime value) {
+ return bind(
+ name,
+ Value.Temporal.Kind.ZONED_DATETIME,
+ nanos(value.toEpochSecond(), value.getNano()),
+ value.getOffset().getTotalSeconds() / 60);
+ }
+
+ /**
+ * Binds a span of months.
+ *
+ * @param name the parameter
+ * @param value what to bind, whose days are refused rather than turned into
+ * a length of time they do not have: a period of one month and one day
+ * is two spans and the standard keeps them apart
+ * @return this statement
+ */
+ public Statement bind(String name, Period value) {
+ if (value.getDays() != 0) {
+ throw new ZuProgrammingException(
+ Diagnostic.misuse(
+ Status.MISUSE,
+ "bind("
+ + name
+ + ", "
+ + value
+ + "): a year-month duration holds months, and days are a duration of their own"));
+ }
+ return bind(name, Value.Temporal.Kind.DURATION_YEAR_MONTH, value.toTotalMonths(), 0);
+ }
+
+ /**
+ * Binds a span of time.
+ *
+ * @param name the parameter
+ * @param value what to bind
+ * @return this statement
+ */
+ public Statement bind(String name, Duration value) {
+ return bind(name, Value.Temporal.Kind.DURATION_DAY_TIME, value.toNanos(), 0);
+ }
+
+ /**
+ * Binds a temporal read out of another result, unchanged.
+ *
+ * @param name the parameter
+ * @param value what to bind
+ * @return this statement
+ */
+ public Statement bind(String name, Value.Temporal value) {
+ return bind(name, value.kind(), value.count(), value.offsetMinutes());
+ }
+
+ /**
+ * Binds a temporal as a kind and the count in the unit that kind implies,
+ * for the caller who has both and no {@code java.time} value to make out of
+ * them.
+ *
+ * @param name the parameter
+ * @param kind which of the seven
+ * @param count days for a date, months for a year-month duration,
+ * nanoseconds for the other five
+ * @param offsetMinutes minutes east of UTC, ignored by every kind but the
+ * two zoned ones
+ * @return this statement
+ */
+ public Statement bind(String name, Value.Temporal.Kind kind, long count, int offsetMinutes) {
+ zu.bindTemporal(open(), name, kind.value(), count, offsetMinutes);
+ return this;
+ }
+
+ /**
+ * Binds no value.
+ *
+ * @param name the parameter
+ * @return this statement
+ */
+ public Statement bindNull(String name) {
+ zu.bindNull(open(), name);
+ return this;
+ }
+
+ /**
+ * Runs the statement with what is bound to it.
+ *
+ * @return the result, which the caller closes
+ */
+ public Result execute() {
+ return new Result(zu, zu.execute(open()));
+ }
+
+ /**
+ * Whether this statement has been closed.
+ *
+ * @return true once {@link #close()} has run
+ */
+ public boolean isClosed() {
+ return handle.get() == 0;
+ }
+
+ /** Releases the statement. Closing twice does nothing the second time. */
+ @Override
+ public void close() {
+ long h = handle.getAndSet(0);
+ if (h != 0) {
+ zu.stmtClose(h);
+ }
+ }
+
+ private static long nanos(long seconds, int nano) {
+ return Math.addExact(Math.multiplyExact(seconds, NANOS), nano);
+ }
+
+ private long open() {
+ long h = handle.get();
+ if (h == 0) {
+ throw new ZuClosedException(
+ Diagnostic.misuse(Status.MISUSE_CLOSED, "this statement is closed"));
+ }
+ return h;
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/Status.java b/zudb/src/main/java/dev/zudb/Status.java
new file mode 100644
index 0000000..9465357
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/Status.java
@@ -0,0 +1,79 @@
+package dev.zudb;
+
+/**
+ * What a call into libzu answered, which is a different question from which
+ * condition it raised.
+ *
+ * The GQLSTATUS code a user reads is on the failure, not here, which is
+ * what keeps this from growing a value per condition. What this says is the
+ * shape of the answer: whether the caller broke a contract, whether the
+ * engine refused the work, or whether the work simply stopped.
+ */
+public enum Status {
+ /** The call did what it was asked. */
+ OK(0),
+ /** Well formed, and there is nothing to read: a column of a result with no rows. */
+ DONE(2),
+ /** The engine refused the work, and the failure says why. */
+ ERROR(3),
+ /**
+ * The caller broke the contract: a closed handle, an index out of range, an
+ * accessor asked for a column that does not hold what it reads. Nothing was
+ * done, and nothing is wrong with the database.
+ */
+ MISUSE(4),
+ /** Two threads used one connection at once. Nothing was done. Connect again rather than share. */
+ MISUSE_CONCURRENT(5),
+ /** A statement was used after its connection closed. Nothing was done. */
+ MISUSE_CLOSED(6),
+ /**
+ * The caller stopped the statement while it was running. Nothing is wrong
+ * with the connection and the next statement on it runs normally.
+ */
+ INTERRUPTED(7),
+ /** A write lost to a concurrent one. */
+ CONFLICT(8),
+ /** The file says something that cannot be true. */
+ CORRUPT(9),
+ /** Not implemented in this build, as against declined. */
+ UNSUPPORTED(10),
+ /** The operating system refused a read or a write. */
+ IO(11),
+ /**
+ * A value this release of the client has no name for, which is what a
+ * client older than the library it loaded sees. Nothing succeeded, since
+ * success is the one value that will never move.
+ */
+ UNKNOWN(-1);
+
+ private final int value;
+
+ Status(int value) {
+ this.value = value;
+ }
+
+ /**
+ * The number this status is in the C ABI.
+ *
+ * @return the {@code zu_status} value, and -1 for {@link #UNKNOWN}
+ */
+ public int value() {
+ return value;
+ }
+
+ /**
+ * The status a {@code zu_status} value names.
+ *
+ * @param value what the C ABI returned
+ * @return the status, and {@link #UNKNOWN} for a value this release has no
+ * name for
+ */
+ public static Status of(int value) {
+ for (Status s : values()) {
+ if (s.value == value && s != UNKNOWN) {
+ return s;
+ }
+ }
+ return UNKNOWN;
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/Type.java b/zudb/src/main/java/dev/zudb/Type.java
new file mode 100644
index 0000000..eded06a
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/Type.java
@@ -0,0 +1,79 @@
+package dev.zudb;
+
+/**
+ * What a cell holds, without reading it.
+ *
+ * For the program that has to branch before it knows which accessor to
+ * call. A program that is going to read the value anyway asks
+ * {@link Row#get(int)} for a {@link Value} and switches over that, which says
+ * the same thing and hands over the contents with it.
+ */
+public enum Type {
+ /** No value. */
+ NULL(0),
+ /** A boolean. */
+ BOOL(1),
+ /** A 64-bit signed integer. */
+ INT(2),
+ /** A double. */
+ FLOAT(3),
+ /** A string. */
+ STR(4),
+ /** A node, which is a table and a row of it. */
+ NODE(5),
+ /** A relationship. */
+ REL(6),
+ /** A list, which recurses. */
+ LIST(7),
+ /** A path. */
+ PATH(8),
+ /** A date, a time, a datetime or a duration. */
+ TEMPORAL(9),
+ /** A record, whose fields are in name order. */
+ RECORD(10),
+ /** A graph, one of the two reference values, which has no contents to read. */
+ GRAPH(11),
+ /** A binding table, the other reference value. */
+ BINDING_TABLE(12);
+
+ private final int value;
+
+ Type(int value) {
+ this.value = value;
+ }
+
+ /**
+ * The number this type is in the C ABI.
+ *
+ * @return the {@code ZU_TYPE_} value
+ */
+ public int value() {
+ return value;
+ }
+
+ /**
+ * The type a {@code ZU_TYPE_} value names.
+ *
+ * @param value what the C ABI returned
+ * @return the type
+ * @throws ZuProgrammingException if it is not one of them, which is what a
+ * client older than the library it loaded sees, and is worth saying
+ * plainly rather than reading as some other type
+ */
+ public static Type of(int value) {
+ for (Type t : values()) {
+ if (t.value == value) {
+ return t;
+ }
+ }
+ throw new ZuProgrammingException(
+ Diagnostic.misuse(
+ Status.MISUSE,
+ "this libzu answered "
+ + value
+ + " for the type of a cell, and this client knows no such type: "
+ + "it was written against ABI "
+ + Zu.ABI_VERSION
+ + " and the library is newer"));
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/Value.java b/zudb/src/main/java/dev/zudb/Value.java
new file mode 100644
index 0000000..6737c8f
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/Value.java
@@ -0,0 +1,327 @@
+package dev.zudb;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.Period;
+import java.time.ZoneOffset;
+
+/**
+ * One value out of a result, as the type it actually is.
+ *
+ * Sealed, so a switch over it is exhaustive and the compiler is the thing
+ * that tells you a case is missing when zu grows a type:
+ *
+ * This is the path a value takes that a column cannot express. A temporal
+ * is a count and a unit, a list recurses, a node is a table and a row of it,
+ * and none of the three fits a {@code long[]}. For a column of integers or
+ * floats the columnar readers on {@link Result} are the path, and they hand
+ * back the engine's own memory rather than building any of these.
+ *
+ * Three of the names here are also names in {@code java.lang} or
+ * {@code java.util}: {@link Value.List}, {@link Value.Record} and
+ * {@link Value.Path}. Write them qualified, as {@code Value.List}, which is
+ * how they read in a switch anyway. Importing the nested one is what would
+ * hurt.
+ */
+public sealed interface Value {
+
+ /** No value. Not an empty string and not a zero. */
+ record Null() implements Value {
+ private static final Null INSTANCE = new Null();
+
+ /**
+ * The one of these there is, since a null has nothing to tell two of
+ * them apart by.
+ *
+ * @return the instance
+ */
+ public static Null instance() {
+ return INSTANCE;
+ }
+ }
+
+ /**
+ * A boolean.
+ *
+ * @param value what it is
+ */
+ record Bool(boolean value) implements Value {}
+
+ /**
+ * A 64-bit signed integer.
+ *
+ * @param value what it is
+ */
+ record Int(long value) implements Value {}
+
+ /**
+ * A double.
+ *
+ * @param value what it is
+ */
+ record Float(double value) implements Value {}
+
+ /**
+ * A string.
+ *
+ * @param value what it is
+ */
+ record Str(String value) implements Value {}
+
+ /**
+ * A node, which is a table and a row of it, because neither identifies a
+ * node on its own: two tables number their rows from zero.
+ *
+ * @param table which table, as the number the engine keeps it under. The C
+ * ABI has no call that turns that number into a name, so this client
+ * hands over the number rather than a guess
+ * @param offset which row of it
+ */
+ record Node(int table, long offset) implements Value {}
+
+ /**
+ * A relationship, as the table it is in and the two rows it runs between.
+ *
+ * @param table which table
+ * @param source the row it starts at
+ * @param target the row it ends at
+ */
+ record Rel(int table, long source, long target) implements Value {}
+
+ /**
+ * A list, which recurses.
+ *
+ * @param items what is in it, in order
+ */
+ record List(java.util.List One shape rather than seven, because a program that reads temporals
+ * reads all of them and a switch over seven kinds is what it wants. The
+ * {@code to} methods below turn one into the {@code java.time} type it is,
+ * and each refuses a kind that is not its own rather than reinterpreting
+ * the count.
+ *
+ * @param kind which of the seven it is
+ * @param count days for a date, months for a year-month duration,
+ * nanoseconds for the other five
+ * @param offsetMinutes minutes east of UTC, and 0 for the five kinds that
+ * carry none
+ */
+ record Temporal(Kind kind, long count, int offsetMinutes) implements Value {
+
+ /** Which temporal a temporal is. The unit follows the kind. */
+ public enum Kind {
+ /** Days since 1970-01-01. */
+ DATE(0),
+ /** Nanoseconds since midnight. */
+ LOCAL_TIME(1),
+ /** Nanoseconds since midnight, with an offset. */
+ ZONED_TIME(2),
+ /** Nanoseconds since 1970-01-01T00:00. */
+ LOCAL_DATETIME(3),
+ /** Nanoseconds since the epoch, with an offset. */
+ ZONED_DATETIME(4),
+ /** Months. */
+ DURATION_YEAR_MONTH(5),
+ /** Nanoseconds. */
+ DURATION_DAY_TIME(6);
+
+ private final int value;
+
+ Kind(int value) {
+ this.value = value;
+ }
+
+ /**
+ * The number this kind is in the C ABI.
+ *
+ * @return the {@code ZU_TEMPORAL_} value
+ */
+ public int value() {
+ return value;
+ }
+
+ /**
+ * The kind a {@code ZU_TEMPORAL_} value names.
+ *
+ * @param value what the C ABI returned
+ * @return the kind
+ * @throws ZuProgrammingException if it is not one of the seven
+ */
+ public static Kind of(int value) {
+ for (Kind k : values()) {
+ if (k.value == value) {
+ return k;
+ }
+ }
+ throw new ZuProgrammingException(
+ Diagnostic.misuse(Status.MISUSE, "no temporal kind is " + value));
+ }
+ }
+
+ /**
+ * This as a date.
+ *
+ * @return the date
+ * @throws ZuProgrammingException unless the kind is {@link Kind#DATE}
+ */
+ public LocalDate toLocalDate() {
+ expect(Kind.DATE);
+ return LocalDate.ofEpochDay(count);
+ }
+
+ /**
+ * This as a time of day.
+ *
+ * @return the time
+ * @throws ZuProgrammingException unless the kind is {@link Kind#LOCAL_TIME}
+ */
+ public LocalTime toLocalTime() {
+ expect(Kind.LOCAL_TIME);
+ return LocalTime.ofNanoOfDay(count);
+ }
+
+ /**
+ * This as a time of day with an offset.
+ *
+ * @return the time
+ * @throws ZuProgrammingException unless the kind is {@link Kind#ZONED_TIME}
+ */
+ public OffsetTime toOffsetTime() {
+ expect(Kind.ZONED_TIME);
+ return OffsetTime.of(LocalTime.ofNanoOfDay(count), offset());
+ }
+
+ /**
+ * This as a datetime.
+ *
+ * @return the datetime
+ * @throws ZuProgrammingException unless the kind is {@link Kind#LOCAL_DATETIME}
+ */
+ public LocalDateTime toLocalDateTime() {
+ expect(Kind.LOCAL_DATETIME);
+ return LocalDateTime.ofEpochSecond(
+ Math.floorDiv(count, 1_000_000_000L),
+ (int) Math.floorMod(count, 1_000_000_000L),
+ ZoneOffset.UTC);
+ }
+
+ /**
+ * This as a datetime with an offset.
+ *
+ * @return the datetime
+ * @throws ZuProgrammingException unless the kind is {@link Kind#ZONED_DATETIME}
+ */
+ public OffsetDateTime toOffsetDateTime() {
+ expect(Kind.ZONED_DATETIME);
+ return OffsetDateTime.of(
+ LocalDateTime.ofEpochSecond(
+ Math.floorDiv(count, 1_000_000_000L),
+ (int) Math.floorMod(count, 1_000_000_000L),
+ ZoneOffset.UTC),
+ ZoneOffset.UTC)
+ .withOffsetSameInstant(offset());
+ }
+
+ /**
+ * This as a span of months.
+ *
+ * A {@link Period} and not a {@link Duration}, because months are the
+ * unit whose length depends on when you start counting, which is the
+ * whole reason the standard keeps the two durations apart.
+ *
+ * @return the period, normalised into years and months
+ * @throws ZuProgrammingException unless the kind is {@link Kind#DURATION_YEAR_MONTH}
+ */
+ public Period toPeriod() {
+ expect(Kind.DURATION_YEAR_MONTH);
+ return Period.ofMonths(Math.toIntExact(count)).normalized();
+ }
+
+ /**
+ * This as a span of time.
+ *
+ * @return the duration
+ * @throws ZuProgrammingException unless the kind is {@link Kind#DURATION_DAY_TIME}
+ */
+ public Duration toDuration() {
+ expect(Kind.DURATION_DAY_TIME);
+ return Duration.ofNanos(count);
+ }
+
+ /**
+ * The offset as {@code java.time} spells it.
+ *
+ * @return the offset, which is {@link ZoneOffset#UTC} for the five kinds
+ * that carry none
+ */
+ public ZoneOffset offset() {
+ return ZoneOffset.ofTotalSeconds(offsetMinutes * 60);
+ }
+
+ private void expect(Kind wanted) {
+ if (kind != wanted) {
+ throw new ZuProgrammingException(
+ Diagnostic.misuse(Status.MISUSE, "this temporal is a " + kind + " and not a " + wanted));
+ }
+ }
+ }
+
+ /**
+ * A graph, one of the two reference values the standard names.
+ *
+ * It has no contents to hand over: a handle is a handle, and the tag is
+ * the whole of what a binding can say about the cell.
+ */
+ record Graph() implements Value {}
+
+ /**
+ * A binding table, the other reference value, and empty for the same
+ * reason.
+ */
+ record BindingTable() implements Value {}
+}
diff --git a/zudb/src/main/java/dev/zudb/Zu.java b/zudb/src/main/java/dev/zudb/Zu.java
new file mode 100644
index 0000000..82f0948
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/Zu.java
@@ -0,0 +1,200 @@
+package dev.zudb;
+
+import dev.zudb.spi.ProviderUnavailableException;
+import dev.zudb.spi.ZuBinding;
+import dev.zudb.spi.ZuProvider;
+import java.lang.System.Logger;
+import java.lang.System.Logger.Level;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import java.util.ServiceConfigurationError;
+import java.util.ServiceLoader;
+
+/**
+ * The library itself: which provider bound it, which libzu it bound, and what
+ * both of them call themselves.
+ *
+ * Nothing here has to be called to use the client. {@link Database#open}
+ * loads the library on its own, once, the first time anything needs it. This
+ * is for the program that logs what it linked against, and for the bug report
+ * that has to say.
+ */
+public final class Zu {
+
+ /**
+ * The revision of the C ABI this client was written against.
+ *
+ * It is a constant rather than something read out of the library,
+ * because the C ABI publishes its revision as a header macro and a macro is
+ * not a symbol: a binding that never sees a C compiler has nothing to read
+ * it from. What checks the two agree is a step in this repository's CI that
+ * reads the macro out of the engine's own {@code zu.h}.
+ *
+ * What the client does check at run time is that the library it loaded
+ * has every symbol this client calls, which is the mismatch that actually
+ * bites, and it names the missing one.
+ */
+ public static final String ABI_VERSION = "0.11";
+
+ private static final Logger LOG = System.getLogger("dev.zudb");
+
+ /** Names a provider to use rather than taking the highest priority that loads. */
+ static final String PROVIDER_PROPERTY = "zu.provider";
+
+ private Zu() {}
+
+ /** Loaded on the first call that needs libzu, and not before. */
+ private static final class Holder {
+ static final Bound BOUND = bind();
+ }
+
+ record Bound(ZuBinding binding, String provider, Path library, String source) {}
+
+ static ZuBinding binding() {
+ return Holder.BOUND.binding();
+ }
+
+ /**
+ * What the loaded libzu calls itself.
+ *
+ * @return the engine version, for example {@code "0.11.0"}
+ */
+ public static String version() {
+ return Holder.BOUND.binding().version();
+ }
+
+ /**
+ * Which provider bound the library.
+ *
+ * @return {@code "ffm"} or {@code "jni"}
+ */
+ public static String provider() {
+ return Holder.BOUND.provider();
+ }
+
+ /**
+ * Which file was loaded, which is the first question a bug report has to
+ * answer.
+ *
+ * @return the path, which is a bare name when the platform was left to
+ * search for it
+ */
+ public static Path library() {
+ return Holder.BOUND.library();
+ }
+
+ private static Bound bind() {
+ Library.Found found = Library.find();
+ String wanted = System.getProperty(PROVIDER_PROPERTY);
+
+ List A provider compiled for a newer release than this JVM is a
+ * {@link ServiceConfigurationError} at the moment it is instantiated, and
+ * that is the ordinary case rather than a broken one: the Panama provider
+ * is compiled for 25 and both artifacts are on the classpath of a program
+ * running on 17. So each is resolved on its own and a failure to load one
+ * is a provider that is not there, which is exactly what it is.
+ */
+ private static List For a program that wants to say what it is about to do, and for a test
+ * that has to know whether the Panama path is even on this JVM.
+ *
+ * @return the name of the highest-priority provider on the classpath, or
+ * empty if there is none
+ */
+ public static Optional Nothing was done, and nothing is wrong with the database. Statements
+ * belong to the connection they were prepared on, so closing a connection
+ * ends every statement of it; a result does not, because a result owns its
+ * rows outright and stays readable after its connection has gone back to a
+ * pool.
+ */
+public class ZuClosedException extends ZuProgrammingException {
+
+ private static final long serialVersionUID = 1L;
+
+ ZuClosedException(Diagnostic diagnostic) {
+ super(diagnostic);
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/ZuConcurrentException.java b/zudb/src/main/java/dev/zudb/ZuConcurrentException.java
new file mode 100644
index 0000000..c2db206
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/ZuConcurrentException.java
@@ -0,0 +1,23 @@
+package dev.zudb;
+
+/**
+ * Two threads used one connection at once.
+ *
+ * A connection is exactly the state that cannot be shared: a file handle,
+ * the caches, and the plans compiled against a catalog. The second thread is
+ * refused rather than raced, and nothing was done. A program that queries
+ * from four threads opens one database and connects four times, which is
+ * {@link Database#connect()} or {@link Connection#duplicate()}.
+ *
+ * {@link Connection#interrupt()} and {@link Connection#rowsRead()} are the
+ * exception and the point of it: both are meant to be called from another
+ * thread while a statement runs, and neither raises this.
+ */
+public class ZuConcurrentException extends ZuProgrammingException {
+
+ private static final long serialVersionUID = 1L;
+
+ ZuConcurrentException(Diagnostic diagnostic) {
+ super(diagnostic);
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/ZuConnectionException.java b/zudb/src/main/java/dev/zudb/ZuConnectionException.java
new file mode 100644
index 0000000..650f55a
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/ZuConnectionException.java
@@ -0,0 +1,18 @@
+package dev.zudb;
+
+/**
+ * Class 08, and the failures the operating system reported: the database
+ * could not be reached, could not be opened, or could not be read.
+ *
+ * Nothing here is about the statement. A path that is not a zu database, a
+ * file the process may not open, a disk that answered an error: the text was
+ * never the problem and rewriting it will not help.
+ */
+public class ZuConnectionException extends ZuException {
+
+ private static final long serialVersionUID = 1L;
+
+ ZuConnectionException(Diagnostic diagnostic) {
+ super(diagnostic);
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/ZuDataException.java b/zudb/src/main/java/dev/zudb/ZuDataException.java
new file mode 100644
index 0000000..b07b033
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/ZuDataException.java
@@ -0,0 +1,19 @@
+package dev.zudb;
+
+/**
+ * Class 22: a value was wrong. Division by zero, a cast that could not be
+ * made, a number that did not fit, a string that is not the shape the
+ * function wanted.
+ *
+ * Most of these happen while the statement runs rather than while it is
+ * parsed, so most of them carry a code and no position: by then there is no
+ * token left to point at.
+ */
+public class ZuDataException extends ZuException {
+
+ private static final long serialVersionUID = 1L;
+
+ ZuDataException(Diagnostic diagnostic) {
+ super(diagnostic);
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/ZuException.java b/zudb/src/main/java/dev/zudb/ZuException.java
new file mode 100644
index 0000000..e7ddec0
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/ZuException.java
@@ -0,0 +1,175 @@
+package dev.zudb;
+
+import java.util.Optional;
+
+/**
+ * What every zu failure is, and the class to catch to catch them all.
+ *
+ * Every failure the engine reports is a GQLSTATUS condition: a
+ * five-character code from ISO/IEC 39075, a severity, and often the place in
+ * the statement that raised it. All of that arrives here as fields, so a
+ * caller reads {@link #code()} and never a regular expression over
+ * {@link #getMessage()}.
+ *
+ * There is one subclass per condition class, which is what the two
+ * characters that open a code are for. Catching {@link ZuDataException}
+ * catches every one of the forty-two conditions in class 22 without listing
+ * them, and a condition zu adds to that class later is caught by the same
+ * {@code catch}.
+ *
+ * The fields are empty when the condition has no answer for them, rather
+ * than filled with a guess. A division by zero happens while the statement
+ * runs and has no token to point at, so it carries a code and no position; a
+ * statement that failed to parse carries both.
+ *
+ * These are unchecked, and that is a decision rather than an oversight.
+ * There is nothing a caller can do about a syntax error at the call site; the
+ * one failure worth handling is a conflict, and a retry goes around a block
+ * rather than around a statement; and a checked exception would put a
+ * {@code throws} on every method of every program that reads a row. What a
+ * caller does want is {@link #retryable()}, which is a field and not a class.
+ */
+public class ZuException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final transient Diagnostic diagnostic;
+
+ ZuException(Diagnostic diagnostic) {
+ super(diagnostic.message());
+ this.diagnostic = diagnostic;
+ }
+
+ /**
+ * The whole record this failure was built from, for a caller that would
+ * rather pass one value around than nine.
+ *
+ * @return the record, never null
+ */
+ public Diagnostic diagnostic() {
+ return diagnostic;
+ }
+
+ /**
+ * What the call that failed answered, which is the shape of the failure as
+ * against the condition it raised.
+ *
+ * @return the status, never null
+ */
+ public Status status() {
+ return diagnostic.status();
+ }
+
+ /**
+ * The five-character GQLSTATUS code, {@code "42001"} for a syntax error.
+ * Empty for the few failures the standard has no condition for, such as a
+ * statement that was interrupted.
+ *
+ * @return the code, if there is one
+ */
+ public Optional A retry loop reads this rather than carrying a list of codes, which is
+ * the sort of list that is right in one binding and stale in the other
+ * five.
+ *
+ * @return whether a retry is worth it
+ */
+ public boolean retryable() {
+ return diagnostic.retryable();
+ }
+
+ /**
+ * The excerpt with a caret under the column, ready to print. Empty when
+ * there is no excerpt to point at.
+ *
+ * This is the one piece of formatting the library does, because every
+ * caller that prints a failure writes it otherwise and half of them count
+ * the column wrong.
+ *
+ * @return the two lines, if there is an excerpt and a column
+ */
+ public Optional Worth reporting at the
+ * engine's issue tracker with the statement that produced it.
+ */
+public class ZuInternalException extends ZuException {
+
+ private static final long serialVersionUID = 1L;
+
+ ZuInternalException(Diagnostic diagnostic) {
+ super(diagnostic);
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/ZuInterruptedException.java b/zudb/src/main/java/dev/zudb/ZuInterruptedException.java
new file mode 100644
index 0000000..b8243d4
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/ZuInterruptedException.java
@@ -0,0 +1,24 @@
+package dev.zudb;
+
+/**
+ * The caller stopped the statement while it was running, through
+ * {@link Connection#interrupt()} or by returning false from a progress
+ * callback.
+ *
+ * Nothing failed. The connection keeps its plans and its warm caches and
+ * runs the next statement normally, which is the difference between this and
+ * closing it. {@link ZuException#retryable()} is false, because a statement
+ * the caller stopped on purpose is not one to run again on its behalf.
+ *
+ * The name is spelled out rather than shortened, because
+ * {@code InterruptedException} is a class in {@code java.lang} that means
+ * something else and an import of the wrong one would compile.
+ */
+public class ZuInterruptedException extends ZuException {
+
+ private static final long serialVersionUID = 1L;
+
+ ZuInterruptedException(Diagnostic diagnostic) {
+ super(diagnostic);
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/ZuProgrammingException.java b/zudb/src/main/java/dev/zudb/ZuProgrammingException.java
new file mode 100644
index 0000000..d8922cf
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/ZuProgrammingException.java
@@ -0,0 +1,20 @@
+package dev.zudb;
+
+/**
+ * The caller broke the contract, in Java or in the C ABI underneath it: a
+ * handle used after it closed, a column index off the end, an accessor asked
+ * for a column that does not hold what it reads, a parameter of a type zu has
+ * no place for.
+ *
+ * Nothing reached the engine, so nothing happened to the database. This is
+ * a bug in the program rather than a condition of the data, and it is the one
+ * class here that a passing test suite should never see.
+ */
+public class ZuProgrammingException extends ZuException {
+
+ private static final long serialVersionUID = 1L;
+
+ ZuProgrammingException(Diagnostic diagnostic) {
+ super(diagnostic);
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/ZuSyntaxException.java b/zudb/src/main/java/dev/zudb/ZuSyntaxException.java
new file mode 100644
index 0000000..256ba4f
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/ZuSyntaxException.java
@@ -0,0 +1,17 @@
+package dev.zudb;
+
+/**
+ * Class 42: the statement could not be parsed, or it named something that is
+ * not there.
+ *
+ * This is the failure that always carries a position, which is what
+ * {@link ZuException#caret()} is for.
+ */
+public class ZuSyntaxException extends ZuException {
+
+ private static final long serialVersionUID = 1L;
+
+ ZuSyntaxException(Diagnostic diagnostic) {
+ super(diagnostic);
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/ZuTransactionException.java b/zudb/src/main/java/dev/zudb/ZuTransactionException.java
new file mode 100644
index 0000000..1098fca
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/ZuTransactionException.java
@@ -0,0 +1,19 @@
+package dev.zudb;
+
+/**
+ * Classes 25, 2D and 40, and a write that lost a race: the transaction rather
+ * than the statement is what went wrong.
+ *
+ * Check {@link ZuException#retryable()} before running it again. A write
+ * that lost to a concurrent one can be retried, because nothing of it was
+ * applied. A statement whose completion is unknown cannot, because a retry
+ * could do the work twice.
+ */
+public class ZuTransactionException extends ZuException {
+
+ private static final long serialVersionUID = 1L;
+
+ ZuTransactionException(Diagnostic diagnostic) {
+ super(diagnostic);
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/package-info.java b/zudb/src/main/java/dev/zudb/package-info.java
new file mode 100644
index 0000000..26f3fb8
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/package-info.java
@@ -0,0 +1,35 @@
+/**
+ * An embedded property graph database, in this process.
+ *
+ * Open a database, take a connection, run a statement, read the rows:
+ *
+ * Everything that holds something native is {@link java.lang.AutoCloseable}
+ * and is closed in the order it was opened, which a single try-with-resources
+ * does for you. Nothing here is a finalizer and nothing waits for a collector.
+ *
+ * Results are columnar underneath, and a program that is summing rather
+ * than printing reads a column at a time through {@link dev.zudb.Result#longs}
+ * and its neighbours, which hand back a {@link java.nio.Buffer} over the
+ * engine's own memory with no copy on the way.
+ *
+ * Failures are {@link dev.zudb.ZuException} and its subclasses, unchecked,
+ * each carrying the {@link dev.zudb.Diagnostic} the engine produced, which is
+ * a GQLSTATUS code, a condition, a position in the statement and the line it
+ * came from.
+ *
+ * Threads: a {@link dev.zudb.Database} is safe to share, a {@link
+ * dev.zudb.Connection} is not. Give each thread its own through {@link
+ * dev.zudb.Database#connect()} or {@link dev.zudb.Connection#duplicate()}, and
+ * they will share the one database underneath.
+ */
+package dev.zudb;
diff --git a/zudb/src/main/java/dev/zudb/spi/ProviderUnavailableException.java b/zudb/src/main/java/dev/zudb/spi/ProviderUnavailableException.java
new file mode 100644
index 0000000..d8309d8
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/spi/ProviderUnavailableException.java
@@ -0,0 +1,37 @@
+package dev.zudb.spi;
+
+/**
+ * A provider cannot run on this JVM, which is a fact about the JVM rather
+ * than a failure of the program.
+ *
+ * A JDK too old for the API a provider needs, native access not granted, a
+ * shim that is not on the library path, a libzu missing a symbol this client
+ * calls. The API module catches this, tries the next provider, and puts every
+ * reason it collected into one message if there is none left, because the
+ * user who has to fix it wants to see all of them at once and not the first
+ * one over and over.
+ */
+public class ProviderUnavailableException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Says why, in a sentence a user can act on.
+ *
+ * @param message what is missing and, where there is one, the flag or the
+ * property that supplies it
+ */
+ public ProviderUnavailableException(String message) {
+ super(message);
+ }
+
+ /**
+ * The same, keeping what went wrong underneath.
+ *
+ * @param message what is missing
+ * @param cause what the JVM said
+ */
+ public ProviderUnavailableException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/spi/ZuBinding.java b/zudb/src/main/java/dev/zudb/spi/ZuBinding.java
new file mode 100644
index 0000000..32b5e8d
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/spi/ZuBinding.java
@@ -0,0 +1,581 @@
+package dev.zudb.spi;
+
+import dev.zudb.Diagnostic;
+import java.nio.ByteBuffer;
+import java.nio.DoubleBuffer;
+import java.nio.LongBuffer;
+
+/**
+ * The C ABI, as Java. One method per call in {@code zu.h}, named for it, and
+ * nothing above it.
+ *
+ * This is the whole of what a provider has to write. Everything a user
+ * touches, {@code Database} through {@code Row}, is built on this once in the
+ * API module rather than once per provider, which is what keeps two providers
+ * from being two bindings that behave differently.
+ *
+ * Handles are the C pointers, as {@code long}, and zero is null. That they
+ * are numbers rather than objects is deliberate: it is the one representation
+ * both a Panama downcall and a JNI call can pass without either of them
+ * paying for the other's idea of a pointer, and the API module never sees one
+ * it did not get from here.
+ *
+ * An implementation throws rather than returning a status. Every call that
+ * can fail turns what the ABI answered into a {@link Diagnostic} and throws
+ * {@link Diagnostic#toException()}, so the mapping from a status and a
+ * GQLSTATUS code to an exception class happens once, here, in the API module.
+ * The calls that can answer {@code ZU_DONE} say so in their own words below,
+ * and none of them treats it as a failure.
+ *
+ * Every buffer and every string an accessor returns belongs to the result
+ * that produced it and is good until {@link #resultFree(long)}. A buffer is a
+ * view of the engine's own memory and is not a copy: reading a column of a
+ * million integers allocates nothing. The API module is what holds callers to
+ * that rule; an implementation only has to return the view.
+ *
+ * This is a service provider interface and not part of the supported
+ * surface. It moves with the C ABI, and a method is added to it whenever the
+ * ABI grows one. Implement it if you are writing a provider; call it if you
+ * are writing something this client has no room for. Do not build an
+ * application on it.
+ */
+public interface ZuBinding {
+
+ /**
+ * What the loaded library calls itself, from {@code zu_version}.
+ *
+ * @return the engine version, never null
+ */
+ String version();
+
+ // ---- databases ----
+
+ /**
+ * Opens an existing database.
+ *
+ * @param path the file
+ * @param memoryLimit bytes the caches may hold, 0 for the default
+ * @param threads query workers, 0 to let the executor pick, 1 for sequential
+ * @param readOnly whether to open a descriptor this process cannot write through
+ * @return the database handle
+ */
+ long databaseOpen(String path, long memoryLimit, long threads, boolean readOnly);
+
+ /**
+ * Creates a database and opens it. The path must not exist.
+ *
+ * @param path the file to make
+ * @param memoryLimit bytes the caches may hold, 0 for the default
+ * @param threads query workers, 0 to let the executor pick
+ * @param readOnly whether to open a descriptor this process cannot write through
+ * @return the database handle
+ */
+ long databaseCreate(String path, long memoryLimit, long threads, boolean readOnly);
+
+ /**
+ * Creates a database that never touches the filesystem.
+ *
+ * @param memoryLimit bytes the caches may hold, 0 for the default
+ * @param threads query workers, 0 to let the executor pick
+ * @param readOnly whether to refuse writes
+ * @return the database handle
+ */
+ long databaseMemory(long memoryLimit, long threads, boolean readOnly);
+
+ /**
+ * Whether a database is in memory.
+ *
+ * @param db the database
+ * @return true for a database in memory, false for one on disk
+ */
+ boolean databaseIsMemory(long db);
+
+ /**
+ * What this process calls the database, which for one in memory is a name
+ * and not a path.
+ *
+ * @param db the database
+ * @return the name, never null
+ */
+ String databasePath(long db);
+
+ /**
+ * Releases the path and the configuration. Connections opened from it are
+ * not closed: each holds its own file handle.
+ *
+ * @param db the database, or zero
+ */
+ void databaseClose(long db);
+
+ // ---- connections ----
+
+ /**
+ * A connection on a database.
+ *
+ * @param db the database
+ * @return the connection handle
+ */
+ long connect(long db);
+
+ /**
+ * A second connection on the database a connection is already on, made
+ * without a path.
+ *
+ * @param conn the connection
+ * @return the new connection handle
+ */
+ long connDuplicate(long conn);
+
+ /**
+ * Closes a connection, rolling back a transaction still running.
+ *
+ * @param conn the connection, or zero
+ */
+ void connClose(long conn);
+
+ /**
+ * Stops whatever is running. The one call here meant to be made from
+ * another thread while the connection is in use.
+ *
+ * @param conn the connection
+ */
+ void connInterrupt(long conn);
+
+ /**
+ * How many rows the statement has read out of storage, counted from zero at
+ * each statement. Also safe from another thread.
+ *
+ * @param conn the connection
+ * @return the count
+ */
+ long connRowsRead(long conn);
+
+ /**
+ * Whether a transaction is running, which no statement answers and every
+ * host offering a block needs.
+ *
+ * @param conn the connection
+ * @return true inside a transaction
+ */
+ boolean connInTransaction(long conn);
+
+ /**
+ * Starts a transaction.
+ *
+ * @param conn the connection
+ * @param readOnly whether a write inside it is refused where it is written
+ */
+ void begin(long conn, boolean readOnly);
+
+ /**
+ * Keeps what the transaction wrote. Durable when this returns.
+ *
+ * @param conn the connection
+ */
+ void commit(long conn);
+
+ /**
+ * Unmakes what the transaction wrote.
+ *
+ * @param conn the connection
+ */
+ void rollback(long conn);
+
+ // ---- statements ----
+
+ /**
+ * Runs one statement with no parameters.
+ *
+ * @param conn the connection
+ * @param statement the text
+ * @return the result handle
+ */
+ long query(long conn, String statement);
+
+ /**
+ * Prepares a statement. Bindings live on it and survive an execute, so a
+ * loop rebinds only what changed.
+ *
+ * @param conn the connection
+ * @param statement the text
+ * @return the statement handle
+ */
+ long prepare(long conn, String statement);
+
+ /**
+ * Binds an integer.
+ *
+ * @param stmt the statement
+ * @param name the parameter name, without its marker
+ * @param value the value
+ */
+ void bindLong(long stmt, String name, long value);
+
+ /**
+ * Binds a float.
+ *
+ * @param stmt the statement
+ * @param name the parameter name
+ * @param value the value
+ */
+ void bindDouble(long stmt, String name, double value);
+
+ /**
+ * Binds a boolean.
+ *
+ * @param stmt the statement
+ * @param name the parameter name
+ * @param value the value
+ */
+ void bindBoolean(long stmt, String name, boolean value);
+
+ /**
+ * Binds a string.
+ *
+ * @param stmt the statement
+ * @param name the parameter name
+ * @param value the value
+ */
+ void bindString(long stmt, String name, String value);
+
+ /**
+ * Binds a temporal, as a kind and the count in the unit that kind implies.
+ *
+ * @param stmt the statement
+ * @param name the parameter name
+ * @param kind one of the {@code ZU_TEMPORAL_} values
+ * @param count the count, in days for a date, months for a year-month
+ * duration, nanoseconds for the other five
+ * @param offsetMinutes minutes east of UTC, ignored by every kind but the
+ * two zoned ones
+ */
+ void bindTemporal(long stmt, String name, int kind, long count, int offsetMinutes);
+
+ /**
+ * Binds null.
+ *
+ * @param stmt the statement
+ * @param name the parameter name
+ */
+ void bindNull(long stmt, String name);
+
+ /**
+ * Runs a prepared statement with what is bound to it.
+ *
+ * @param stmt the statement
+ * @return the result handle
+ */
+ long execute(long stmt);
+
+ /**
+ * Releases a statement. Safe after its connection closed.
+ *
+ * @param stmt the statement, or zero
+ */
+ void stmtClose(long stmt);
+
+ // ---- result shape ----
+
+ /**
+ * How many rows.
+ *
+ * @param result the result
+ * @return the count, 0 for a statement that answered with none
+ */
+ long resultRows(long result);
+
+ /**
+ * How many columns.
+ *
+ * @param result the result
+ * @return the count
+ */
+ int resultCols(long result);
+
+ /**
+ * What a column is called.
+ *
+ * @param result the result
+ * @param col the column, counting from zero
+ * @return the name, never null
+ */
+ String resultColName(long result, int col);
+
+ /**
+ * The type tag of one cell.
+ *
+ * @param result the result
+ * @param row the row, counting from zero
+ * @param col the column, counting from zero
+ * @return one of the {@code ZU_TYPE_} values
+ */
+ int resultCellType(long result, long row, int col);
+
+ /**
+ * One string cell.
+ *
+ * @param result the result
+ * @param row the row
+ * @param col the column, which must hold strings
+ * @return the string, never null
+ */
+ String resultCellString(long result, long row, int col);
+
+ /**
+ * The completion condition of a statement that worked: {@code "00000"} for
+ * one that answered with columns, {@code "00001"} for one that had none to
+ * give back.
+ *
+ * @param result the result
+ * @return the code, never null
+ */
+ String resultGqlstatus(long result);
+
+ /**
+ * How many conditions the statement raised and carried on through.
+ *
+ * @param result the result
+ * @return the count, almost always zero
+ */
+ int resultNotices(long result);
+
+ /**
+ * One of those conditions.
+ *
+ * @param result the result
+ * @param index the notice, counting from zero
+ * @return the record, or null past the end
+ */
+ Diagnostic resultNotice(long result, int index);
+
+ /**
+ * Releases a result and everything borrowed from it.
+ *
+ * @param result the result, or zero
+ */
+ void resultFree(long result);
+
+ // ---- columns ----
+
+ /**
+ * A whole column of integers, read where it lies.
+ *
+ * @param result the result
+ * @param col the column, which must hold integers or booleans
+ * @param rows how many values, which is {@link #resultRows(long)}
+ * @return a read-only view of the engine's own memory, or null when the
+ * result has no rows
+ */
+ LongBuffer colLongs(long result, int col, long rows);
+
+ /**
+ * A whole column of floats, read where it lies.
+ *
+ * @param result the result
+ * @param col the column, which must hold floats or integers
+ * @param rows how many values
+ * @return a read-only view, or null when the result has no rows
+ */
+ DoubleBuffer colDoubles(long result, int col, long rows);
+
+ /**
+ * A whole column of node row offsets, read where it lies.
+ *
+ * @param result the result
+ * @param col the column, which must hold nodes
+ * @param rows how many values
+ * @return a read-only view, or null when the result has no rows
+ */
+ LongBuffer colNodeOffsets(long result, int col, long rows);
+
+ /**
+ * Which values of a column are not null, one byte a row.
+ *
+ * @param result the result
+ * @param col the column
+ * @param rows how many values
+ * @return a read-only view, or null when the result has no rows
+ */
+ ByteBuffer colValid(long result, int col, long rows);
+
+ // ---- chunks ----
+
+ /**
+ * How many chunks a result has, which is the loop bound.
+ *
+ * @param result the result
+ * @return the count, 0 for a result with no rows
+ */
+ long chunkCount(long result);
+
+ /**
+ * Where a chunk starts and how long it is.
+ *
+ * @param result the result
+ * @param chunk the chunk, counting from zero
+ * @return two values, the row this chunk starts at and how many rows it
+ * holds, never null
+ */
+ long[] chunk(long result, long chunk);
+
+ /**
+ * One chunk of a column of integers.
+ *
+ * @param result the result
+ * @param chunk the chunk
+ * @param col the column
+ * @param rows how many values the chunk holds
+ * @return a read-only view, good until the next call for the same column
+ * and the same accessor
+ */
+ LongBuffer chunkLongs(long result, long chunk, int col, long rows);
+
+ /**
+ * One chunk of a column of floats.
+ *
+ * @param result the result
+ * @param chunk the chunk
+ * @param col the column
+ * @param rows how many values the chunk holds
+ * @return a read-only view, good until the next call for the same column
+ * and the same accessor
+ */
+ DoubleBuffer chunkDoubles(long result, long chunk, int col, long rows);
+
+ /**
+ * One chunk of a column of node row offsets.
+ *
+ * @param result the result
+ * @param chunk the chunk
+ * @param col the column
+ * @param rows how many values the chunk holds
+ * @return a read-only view, good until the next call for the same column
+ * and the same accessor
+ */
+ LongBuffer chunkNodeOffsets(long result, long chunk, int col, long rows);
+
+ /**
+ * One chunk of a column's validity.
+ *
+ * @param result the result
+ * @param chunk the chunk
+ * @param col the column
+ * @param rows how many values the chunk holds
+ * @return a read-only view, good until the next call for the same column
+ * and the same accessor
+ */
+ ByteBuffer chunkValid(long result, long chunk, int col, long rows);
+
+ // ---- values ----
+
+ /**
+ * One cell, as a value that can be read as the type it is. Points into the
+ * result's own rows and is nothing to free.
+ *
+ * @param result the result
+ * @param row the row
+ * @param col the column
+ * @return the value handle
+ */
+ long resultCell(long result, long row, int col);
+
+ /**
+ * What a value holds.
+ *
+ * @param value the value
+ * @return one of the {@code ZU_TYPE_} values
+ */
+ int valueType(long value);
+
+ /**
+ * A value as a boolean.
+ *
+ * @param value the value, which must be a boolean
+ * @return the boolean
+ */
+ boolean valueBoolean(long value);
+
+ /**
+ * A value as an integer.
+ *
+ * @param value the value, which must be an integer
+ * @return the integer
+ */
+ long valueLong(long value);
+
+ /**
+ * A value as a float.
+ *
+ * @param value the value, which must be a float
+ * @return the float
+ */
+ double valueDouble(long value);
+
+ /**
+ * A value as a string.
+ *
+ * @param value the value, which must be a string
+ * @return the string, never null
+ */
+ String valueString(long value);
+
+ /**
+ * A value as a temporal.
+ *
+ * @param value the value, which must be a temporal
+ * @return three values, the {@code ZU_TEMPORAL_} kind, the count in the
+ * unit that kind implies, and minutes east of UTC
+ */
+ long[] valueTemporal(long value);
+
+ /**
+ * A value as a node, which is a table and a row of it, because neither
+ * identifies a node on its own.
+ *
+ * @param value the value, which must be a node
+ * @return two values, the table and the row offset
+ */
+ long[] valueNode(long value);
+
+ /**
+ * A value as a relationship.
+ *
+ * @param value the value, which must be a relationship
+ * @return three values, the table, the row it starts at and the row it ends at
+ */
+ long[] valueRel(long value);
+
+ /**
+ * How many elements a list, a path or a record has.
+ *
+ * @param value the value
+ * @return the count, 0 for anything that is not one of the three
+ */
+ long valueLength(long value);
+
+ /**
+ * One element of a list, a path or a record.
+ *
+ * @param value the value
+ * @param index the element, counting from zero
+ * @return the element's value handle
+ */
+ long valueAt(long value, long index);
+
+ /**
+ * What one field of a record is called. Fields are in name order and a name
+ * appears once, which is what makes two records written in different orders
+ * one value.
+ *
+ * @param value the value, which must be a record
+ * @param index the field, counting from zero
+ * @return the name, never null
+ */
+ String valueField(long value, long index);
+}
diff --git a/zudb/src/main/java/dev/zudb/spi/ZuProvider.java b/zudb/src/main/java/dev/zudb/spi/ZuProvider.java
new file mode 100644
index 0000000..cd2ac96
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/spi/ZuProvider.java
@@ -0,0 +1,54 @@
+package dev.zudb.spi;
+
+import java.nio.file.Path;
+
+/**
+ * How a {@link ZuBinding} gets made, and the service the API module looks up
+ * with {@link java.util.ServiceLoader}.
+ *
+ * There are two in this repository. The Panama one binds through the
+ * Foreign Function and Memory API and needs a recent JDK; the JNI one binds
+ * through a small native shim and runs on 17. A user names neither: the API
+ * module takes the highest {@link #priority()} that loads, and says which it
+ * took once, at debug level.
+ *
+ * Finding the library is not a provider's job. The API module resolves one
+ * path, from a system property, an environment variable, a native artifact on
+ * the classpath, or the platform's own search, and hands the same path to
+ * whichever provider it tries.
+ */
+public interface ZuProvider {
+
+ /**
+ * What to call this provider in a log line and in a failure.
+ *
+ * @return a short name, {@code "ffm"} or {@code "jni"}
+ */
+ String name();
+
+ /**
+ * Which provider wins when more than one loads. Higher goes first.
+ *
+ * The two in this repository are 100 for Panama and 50 for JNI, spaced
+ * so that something else can be put between them without either moving.
+ *
+ * @return the priority
+ */
+ int priority();
+
+ /**
+ * Loads the library and binds every call in it.
+ *
+ * This is where a provider decides it cannot run: a JVM too old for the
+ * API it needs, native access not granted, a library that is missing a
+ * symbol this client calls. All three are
+ * {@link ProviderUnavailableException}, which is not a failure of the
+ * program but a fact about this JVM, and the API module tries the next
+ * provider and reports every reason if none is left.
+ *
+ * @param library the library to load
+ * @return a binding over it, never null
+ * @throws ProviderUnavailableException if this provider cannot run here
+ */
+ ZuBinding load(Path library);
+}
diff --git a/zudb/src/main/java/dev/zudb/spi/package-info.java b/zudb/src/main/java/dev/zudb/spi/package-info.java
new file mode 100644
index 0000000..384c1fb
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/spi/package-info.java
@@ -0,0 +1,13 @@
+/**
+ * What a binding to the native library has to implement.
+ *
+ * Nothing in here is for the program that is querying a database. It is for
+ * the two artifacts that make the calls, {@code zudb-ffm} over Panama and
+ * {@code zudb-jni} over JNI, and for anyone who wants a third.
+ *
+ * The shape is one interface, {@link dev.zudb.spi.ZuBinding}, over {@code
+ * long} handles, so that a provider owns the calls and nothing else. Turning a
+ * status into an exception, reading a value tree, caching column names: all of
+ * that happens once in {@code dev.zudb} and cannot drift between providers.
+ */
+package dev.zudb.spi;
diff --git a/zudb/src/main/java/module-info.java b/zudb/src/main/java/module-info.java
new file mode 100644
index 0000000..f42fef0
--- /dev/null
+++ b/zudb/src/main/java/module-info.java
@@ -0,0 +1,15 @@
+/**
+ * The zu client for Java.
+ *
+ * This module is the whole API and none of the native access. What talks to
+ * the library is a provider, found at run time through {@link
+ * dev.zudb.spi.ZuProvider}, so that a program on JDK 25 gets the Panama one and
+ * a program on 17 gets the JNI one without either of them being on the
+ * compile-time path of the other.
+ */
+module dev.zudb {
+ exports dev.zudb;
+ exports dev.zudb.spi;
+
+ uses dev.zudb.spi.ZuProvider;
+}
diff --git a/zudb/src/test/java/dev/zudb/ConfigTest.java b/zudb/src/test/java/dev/zudb/ConfigTest.java
new file mode 100644
index 0000000..3b48313
--- /dev/null
+++ b/zudb/src/test/java/dev/zudb/ConfigTest.java
@@ -0,0 +1,39 @@
+package dev.zudb;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+/** The three knobs a database is opened with. */
+class ConfigTest {
+
+ @Test
+ void theDefaultsAreZeroesAndZeroMeansTheEngineDecides() {
+ Config c = Config.defaults();
+ assertEquals(0, c.memoryLimit());
+ assertEquals(0, c.threads());
+ assertFalse(c.readOnly());
+ }
+
+ @Test
+ void theWithersChangeOneFieldEach() {
+ Config c = Config.defaults().withMemoryLimit(1 << 20).withThreads(1).withReadOnly(true);
+ assertEquals(1 << 20, c.memoryLimit());
+ assertEquals(1, c.threads());
+ assertTrue(c.readOnly());
+ }
+
+ @Test
+ void aNegativeCountIsRefusedWhereItIsWrittenRatherThanAtTheOpen() {
+ assertThrows(ZuProgrammingException.class, () -> Config.defaults().withMemoryLimit(-1));
+ assertThrows(ZuProgrammingException.class, () -> Config.defaults().withThreads(-1));
+ }
+
+ @Test
+ void twoConfigurationsWithTheSameFieldsAreOneValue() {
+ assertEquals(Config.defaults().withThreads(4), new Config(0, 4, false));
+ }
+}
diff --git a/zudb/src/test/java/dev/zudb/DiagnosticTest.java b/zudb/src/test/java/dev/zudb/DiagnosticTest.java
new file mode 100644
index 0000000..5a9465a
--- /dev/null
+++ b/zudb/src/test/java/dev/zudb/DiagnosticTest.java
@@ -0,0 +1,127 @@
+package dev.zudb;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+/** The mapping from a diagnostic record to the exception a caller catches. */
+class DiagnosticTest {
+
+ @ParameterizedTest
+ @CsvSource({
+ "08000, dev.zudb.ZuConnectionException",
+ "08007, dev.zudb.ZuConnectionException",
+ "22003, dev.zudb.ZuDataException",
+ "22G03, dev.zudb.ZuDataException",
+ "25000, dev.zudb.ZuTransactionException",
+ "2D000, dev.zudb.ZuTransactionException",
+ "40000, dev.zudb.ZuTransactionException",
+ "42001, dev.zudb.ZuSyntaxException",
+ "42N51, dev.zudb.ZuSyntaxException",
+ })
+ void theCodeClassPicksTheException(String code, String expected) throws Exception {
+ Diagnostic d = diagnostic(Status.ERROR, code);
+ assertInstanceOf(Class.forName(expected), d.toException());
+ }
+
+ @Test
+ void aConditionClassIsCatchableInOneCatch() {
+ // The point of mapping on the class rather than the whole code: all
+ // forty-two conditions in class 22 arrive as one type.
+ for (String code : new String[] {"22000", "22001", "22003", "22G0B", "22N63"}) {
+ assertInstanceOf(ZuDataException.class, diagnostic(Status.ERROR, code).toException());
+ }
+ }
+
+ @Test
+ void aStatusWithNoCodePicksTheException() {
+ assertInstanceOf(
+ ZuProgrammingException.class, diagnostic(Status.MISUSE, null).toException());
+ assertInstanceOf(
+ ZuConcurrentException.class, diagnostic(Status.MISUSE_CONCURRENT, null).toException());
+ assertInstanceOf(ZuClosedException.class, diagnostic(Status.MISUSE_CLOSED, null).toException());
+ assertInstanceOf(
+ ZuInterruptedException.class, diagnostic(Status.INTERRUPTED, null).toException());
+ assertInstanceOf(ZuTransactionException.class, diagnostic(Status.CONFLICT, null).toException());
+ assertInstanceOf(ZuConnectionException.class, diagnostic(Status.IO, null).toException());
+ assertInstanceOf(ZuInternalException.class, diagnostic(Status.CORRUPT, null).toException());
+ }
+
+ @Test
+ void theConcurrentAndClosedMistakesAreProgrammingMistakes() {
+ // A pool that catches ZuProgrammingException catches both of these,
+ // which is the point of them being subclasses of it.
+ assertTrue(
+ ZuProgrammingException.class.isAssignableFrom(ZuConcurrentException.class));
+ assertTrue(ZuProgrammingException.class.isAssignableFrom(ZuClosedException.class));
+ }
+
+ @Test
+ void anExceptionCarriesTheWholeRecord() {
+ Diagnostic d =
+ new Diagnostic(
+ Status.ERROR,
+ "no such table: persn",
+ "42N51",
+ "syntax error or access rule violation",
+ Severity.EXCEPTION,
+ 2,
+ 9,
+ 17,
+ "MATCH (p:persn)",
+ "https://zudb.dev/errors/42N51",
+ false);
+ ZuException e = d.toException();
+ assertEquals("no such table: persn", e.getMessage());
+ assertEquals(Status.ERROR, e.status());
+ assertEquals("42N51", e.code().orElseThrow());
+ assertEquals(Severity.EXCEPTION, e.severity());
+ assertEquals(new ZuException.Position(2, 9, 17), e.position().orElseThrow());
+ assertEquals("MATCH (p:persn)", e.excerpt().orElseThrow());
+ assertFalse(e.retryable());
+ }
+
+ @Test
+ void aCaretUnderlinesTheColumnTheExcerptCounts() {
+ Diagnostic d =
+ new Diagnostic(
+ Status.ERROR, "bad", "42001", null, Severity.EXCEPTION, 1, 8, 7, "RETURN ?", null,
+ false);
+ String caret = d.toException().caret().orElseThrow();
+ assertEquals("RETURN ?", caret.lines().findFirst().orElseThrow());
+ assertTrue(caret.endsWith("^"));
+ // Seven characters of lead-in, then the caret under the eighth.
+ assertEquals(7, caret.lines().skip(1).findFirst().orElseThrow().indexOf('^'));
+ }
+
+ @Test
+ void aRecordWithNoPositionHasNoCaret() {
+ assertTrue(diagnostic(Status.ERROR, "22012").toException().caret().isEmpty());
+ }
+
+ @Test
+ void theRawFactoryMapsBothNumbers() {
+ Diagnostic d =
+ Diagnostic.of(3, "boom", "22012", "data exception", 4, 1, 1, 0, null, null, true);
+ assertEquals(Status.ERROR, d.status());
+ assertEquals(Severity.EXCEPTION, d.severity());
+ assertTrue(d.retryable());
+ }
+
+ @Test
+ void aStatusThisClientDoesNotKnowIsUnknownRatherThanAThrow() {
+ // A library newer than this client is a thing that happens, and the
+ // right answer is a failure that says so, not a crash in the mapping.
+ assertEquals(Status.UNKNOWN, Status.of(9999));
+ }
+
+ private static Diagnostic diagnostic(Status status, String code) {
+ return new Diagnostic(
+ status, "boom", code, null, Severity.EXCEPTION, -1, -1, -1, null, null, false);
+ }
+}
diff --git a/zudb/src/test/java/dev/zudb/LibraryTest.java b/zudb/src/test/java/dev/zudb/LibraryTest.java
new file mode 100644
index 0000000..86157dd
--- /dev/null
+++ b/zudb/src/test/java/dev/zudb/LibraryTest.java
@@ -0,0 +1,60 @@
+package dev.zudb;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/** Where the library is looked for, and in which order. */
+class LibraryTest {
+
+ @Test
+ void aNamedPathWins(@TempDir Path dir) throws Exception {
+ Path fake = Files.createFile(dir.resolve(System.mapLibraryName("zu")));
+ String before = System.getProperty(Library.PROPERTY);
+ System.setProperty(Library.PROPERTY, fake.toString());
+ try {
+ Library.Found found = Library.find();
+ assertEquals(fake, found.path());
+ assertEquals("-Dzu.library", found.source());
+ } finally {
+ restore(before);
+ }
+ }
+
+ @Test
+ void aNamedPathThatIsNotThereFailsWhereItWasWrittenDown() {
+ String before = System.getProperty(Library.PROPERTY);
+ System.setProperty(Library.PROPERTY, "/no/such/libzu.dylib");
+ try {
+ ZuProgrammingException e = assertThrows(ZuProgrammingException.class, Library::find);
+ assertTrue(e.getMessage().contains("/no/such/libzu.dylib"));
+ } finally {
+ restore(before);
+ }
+ }
+
+ @Test
+ void thePlatformIsSpelledTheWayTheArtifactsAre() {
+ // Go's spelling, because every library artifact this engine publishes is
+ // named after it, and two spellings of one platform is how a client ends
+ // up unable to find its own jar.
+ String platform = Library.platform();
+ assertTrue(
+ platform.matches("(darwin|linux|windows|[a-z0-9]+)-(amd64|arm64|[a-z0-9_]+)"),
+ platform + " is not a goos-goarch pair");
+ assertEquals(2, platform.split("-").length);
+ }
+
+ private static void restore(String before) {
+ if (before == null) {
+ System.clearProperty(Library.PROPERTY);
+ } else {
+ System.setProperty(Library.PROPERTY, before);
+ }
+ }
+}
diff --git a/zudb/src/test/java/dev/zudb/TemporalTest.java b/zudb/src/test/java/dev/zudb/TemporalTest.java
new file mode 100644
index 0000000..0f04589
--- /dev/null
+++ b/zudb/src/test/java/dev/zudb/TemporalTest.java
@@ -0,0 +1,120 @@
+package dev.zudb;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.Period;
+import java.time.ZoneOffset;
+import org.junit.jupiter.api.Test;
+
+/** The seven temporals, and the java.time value each of them is. */
+class TemporalTest {
+
+ @Test
+ void aDateIsDaysSinceTheEpoch() {
+ assertEquals(
+ LocalDate.of(2026, 8, 20),
+ new Value.Temporal(Value.Temporal.Kind.DATE, LocalDate.of(2026, 8, 20).toEpochDay(), 0)
+ .toLocalDate());
+ }
+
+ @Test
+ void aDateBeforeTheEpochCountsBackwards() {
+ assertEquals(
+ LocalDate.of(1969, 12, 31),
+ new Value.Temporal(Value.Temporal.Kind.DATE, -1, 0).toLocalDate());
+ }
+
+ @Test
+ void aLocalTimeIsNanosecondsSinceMidnight() {
+ LocalTime t = LocalTime.of(13, 45, 30, 123_456_789);
+ assertEquals(
+ t,
+ new Value.Temporal(Value.Temporal.Kind.LOCAL_TIME, t.toNanoOfDay(), 0).toLocalTime());
+ }
+
+ @Test
+ void aZonedTimeCarriesItsOffset() {
+ OffsetTime t = OffsetTime.of(LocalTime.of(9, 30), ZoneOffset.ofHoursMinutes(5, 30));
+ assertEquals(
+ t,
+ new Value.Temporal(
+ Value.Temporal.Kind.ZONED_TIME, t.toLocalTime().toNanoOfDay(), 5 * 60 + 30)
+ .toOffsetTime());
+ }
+
+ @Test
+ void aLocalDatetimeIsNanosecondsSinceTheEpoch() {
+ LocalDateTime d = LocalDateTime.of(2026, 8, 20, 11, 22, 33, 444_000_000);
+ long nanos = d.toEpochSecond(ZoneOffset.UTC) * 1_000_000_000L + d.getNano();
+ assertEquals(
+ d, new Value.Temporal(Value.Temporal.Kind.LOCAL_DATETIME, nanos, 0).toLocalDateTime());
+ }
+
+ @Test
+ void aLocalDatetimeBeforeTheEpochRoundsTheRightWay() {
+ // Integer division truncates towards zero and this has to floor, which
+ // is the whole reason the conversion uses Math.floorDiv.
+ LocalDateTime d = LocalDateTime.of(1960, 1, 1, 0, 0, 0, 1);
+ long nanos = d.toEpochSecond(ZoneOffset.UTC) * 1_000_000_000L + d.getNano();
+ assertEquals(
+ d, new Value.Temporal(Value.Temporal.Kind.LOCAL_DATETIME, nanos, 0).toLocalDateTime());
+ }
+
+ @Test
+ void aZonedDatetimeIsTheSameInstantInItsOwnOffset() {
+ OffsetDateTime d =
+ OffsetDateTime.of(LocalDateTime.of(2026, 8, 20, 11, 0), ZoneOffset.ofHours(-5));
+ long nanos = d.toEpochSecond() * 1_000_000_000L + d.getNano();
+ OffsetDateTime read =
+ new Value.Temporal(Value.Temporal.Kind.ZONED_DATETIME, nanos, -5 * 60).toOffsetDateTime();
+ assertEquals(d, read);
+ assertEquals(ZoneOffset.ofHours(-5), read.getOffset());
+ }
+
+ @Test
+ void aYearMonthDurationIsMonthsAndNormalises() {
+ assertEquals(
+ Period.of(1, 2, 0),
+ new Value.Temporal(Value.Temporal.Kind.DURATION_YEAR_MONTH, 14, 0).toPeriod());
+ }
+
+ @Test
+ void aDayTimeDurationIsNanoseconds() {
+ assertEquals(
+ Duration.ofHours(25).plusNanos(7),
+ new Value.Temporal(
+ Value.Temporal.Kind.DURATION_DAY_TIME, Duration.ofHours(25).toNanos() + 7, 0)
+ .toDuration());
+ }
+
+ @Test
+ void readingOneKindAsAnotherIsRefusedAndSaysWhichIsWhich() {
+ Value.Temporal date = new Value.Temporal(Value.Temporal.Kind.DATE, 0, 0);
+ ZuProgrammingException e = assertThrows(ZuProgrammingException.class, date::toDuration);
+ assertEquals("this temporal is a DATE and not a DURATION_DAY_TIME", e.getMessage());
+ }
+
+ @Test
+ void everyKindHasItsAbiNumberAndBackAgain() {
+ for (Value.Temporal.Kind k : Value.Temporal.Kind.values()) {
+ assertEquals(k, Value.Temporal.Kind.of(k.value()));
+ }
+ }
+
+ @Test
+ void aKindThisClientDoesNotKnowIsRefusedRatherThanGuessed() {
+ assertThrows(ZuProgrammingException.class, () -> Value.Temporal.Kind.of(7));
+ }
+
+ @Test
+ void theFiveKindsWithNoOffsetAnswerUtc() {
+ assertEquals(ZoneOffset.UTC, new Value.Temporal(Value.Temporal.Kind.DATE, 0, 0).offset());
+ }
+}
{@code
+ * long total = 0;
+ * for (Chunk c : result.chunks().toList()) {
+ * LongBuffer ids = c.longs(0);
+ * for (int i = 0; i < c.rows(); i++) {
+ * total += ids.get(i);
+ * }
+ * }
+ * }
+ */
+public final class Chunk {
+
+ private final Result result;
+ private final long index;
+ private final long offset;
+ private final long rows;
+
+ Chunk(Result result, long index, long offset, long rows) {
+ this.result = result;
+ this.index = index;
+ this.offset = offset;
+ this.rows = rows;
+ }
+
+ /**
+ * Which chunk this is.
+ *
+ * @return the index, counting from zero
+ */
+ public long index() {
+ return index;
+ }
+
+ /**
+ * Which row of the result this chunk starts at, which is how a value read
+ * here is matched to a cell accessor that takes a row number.
+ *
+ * @return the row, counting from zero
+ */
+ public long offset() {
+ return offset;
+ }
+
+ /**
+ * How many rows this chunk holds.
+ *
+ * @return the count
+ */
+ public long rows() {
+ return rows;
+ }
+
+ /**
+ * One row of the result, as a {@link Row}.
+ *
+ * @param row the row within this chunk, counting from zero
+ * @return the row
+ */
+ public Row row(long row) {
+ return result.row(offset + row);
+ }
+
+ /**
+ * This chunk of a column of integers.
+ *
+ * @param column the column, which must hold integers or booleans
+ * @return a read-only view, good until the next call for the same column
+ * and the same accessor
+ */
+ public LongBuffer longs(int column) {
+ result.checkColumn(column);
+ return result.zu().chunkLongs(result.open(), index, column, rows);
+ }
+
+ /**
+ * This chunk of a column of floats.
+ *
+ * @param column the column, which must hold floats or integers
+ * @return a read-only view, good until the next call for the same column
+ * and the same accessor
+ */
+ public DoubleBuffer doubles(int column) {
+ result.checkColumn(column);
+ return result.zu().chunkDoubles(result.open(), index, column, rows);
+ }
+
+ /**
+ * This chunk of a column of node row offsets.
+ *
+ * @param column the column, which must hold nodes
+ * @return a read-only view, good until the next call for the same column
+ * and the same accessor
+ */
+ public LongBuffer nodeOffsets(int column) {
+ result.checkColumn(column);
+ return result.zu().chunkNodeOffsets(result.open(), index, column, rows);
+ }
+
+ /**
+ * Which values of this chunk of a column are not null, one byte a row.
+ *
+ * {@code
+ * try (Database db = Database.open(Path.of("social.zu1"));
+ * Connection conn = db.connect()) {
+ * ...
+ * }
+ * }
+ */
+public final class Database implements AutoCloseable {
+
+ private final ZuBinding zu;
+ private final AtomicLong handle;
+
+ private Database(ZuBinding zu, long handle) {
+ this.zu = zu;
+ this.handle = new AtomicLong(handle);
+ }
+
+ /**
+ * Opens an existing database with the default configuration.
+ *
+ * @param path the file
+ * @return the database
+ */
+ public static Database open(Path path) {
+ return open(path, Config.defaults());
+ }
+
+ /**
+ * Opens an existing database.
+ *
+ * @param path the file
+ * @param config the caches, the workers and whether writes are refused
+ * @return the database
+ */
+ public static Database open(Path path, Config config) {
+ ZuBinding zu = Zu.binding();
+ return new Database(
+ zu,
+ zu.databaseOpen(
+ path.toString(), config.memoryLimit(), config.threads(), config.readOnly()));
+ }
+
+ /**
+ * Opens an existing database named by a string, for the caller who has one
+ * and does not want to write {@code Path.of} around it.
+ *
+ * @param path the file
+ * @return the database
+ */
+ public static Database open(String path) {
+ return open(Path.of(path), Config.defaults());
+ }
+
+ /**
+ * Opens an existing database named by a string.
+ *
+ * @param path the file
+ * @param config the caches, the workers and whether writes are refused
+ * @return the database
+ */
+ public static Database open(String path, Config config) {
+ return open(Path.of(path), config);
+ }
+
+ /**
+ * Creates a database and opens it.
+ *
+ * {@code
+ * try (Result r = conn.query("MATCH (p:Person) RETURN p.name AS name")) {
+ * r.stream().map(row -> row.getString("name")).forEach(System.out::println);
+ * }
+ * }
+ */
+public final class Result implements AutoCloseable, Iterable{@code
+ * try (Statement stmt = conn.prepare("MATCH (p:Person) WHERE p.age > $age RETURN p.name AS name")) {
+ * for (int age : new int[] {20, 30, 40}) {
+ * try (Result r = stmt.bind("age", age).execute()) {
+ * ...
+ * }
+ * }
+ * }
+ * }
+ *
+ * {@code
+ * String show(Value v) {
+ * return switch (v) {
+ * case Value.Null ignored -> "null";
+ * case Value.Int i -> Long.toString(i.value());
+ * case Value.Str s -> s.value();
+ * case Value.Node n -> "node " + n.table() + ":" + n.offset();
+ * case Value.List l -> l.items().toString();
+ * ...
+ * };
+ * }
+ * }
+ *
+ * {@code
+ * try (Database db = Database.open("graph.zu");
+ * Connection conn = db.connect();
+ * Result result = conn.query("MATCH (p:Person) RETURN p.name AS name, p.age AS age")) {
+ * for (Row row : result) {
+ * System.out.println(row.getString("name") + " is " + row.getLong("age"));
+ * }
+ * }
+ * }
+ *
+ * Failures
+ *
+ * Lifetimes
+ *
+ * Stability
+ *
+ *