From 1044668dccf11305c9ef01976e2b31cdcaf808c5 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:03:38 +0700 Subject: [PATCH] Java SDK v1: the API, the Panama provider, tests and benchmarks Two artifacts. dev.zudb:zudb is the API, compiled to release 17, with no native code in it and no FFM type anywhere in its public surface, so it is the thing a caller on any supported JDK compiles against. dev.zudb:zudb-ffm is the provider, compiled to release 25, and it is the only place that calls libzu. A ServiceLoader picks between providers at run time and application code never names one. The downcall handles are written by hand against zu.h rather than generated with jextract. The ABI here is around seventy functions with a stable shape, and the decisions worth making are the ones a generator does not make: which calls are Linker.Option.critical because they are short pure accessors, where the out-parameter space comes from so that a query does not allocate, and how a zu_error becomes a typed Java exception exactly once. Three things in the binding are worth reading before the rest: Scratch is a per-thread off-heap block that every call writes its out-parameters into, reset with a bump pointer at the top of each call rather than allocated per call. An Arena.ofConfined per call is a malloc and a free on a path that is otherwise a handful of instructions, and no binding method calls another, so there is nothing for a reset to invalidate. zu_config is filled in by this client rather than by zu_config_init. The struct is versioned by a struct_size the caller sets, and asking a newer library to initialise a buffer sized by our header is asking it to write past the end of it. A column comes back as a read-only java.nio buffer over the engine's own memory, in native byte order, because asByteBuffer hands back a big-endian view and a wrong byte order is a wrong number rather than a failure. java.nio rather than MemorySegment so that a Java 17 caller can name the type and so that the JNI provider can return the same thing. Native access is granted rather than assumed. The jar carries Enable-Native-Access: ALL-UNNAMED for the class path case, the module path case passes --enable-native-access=dev.zudb.ffm, and the provider checks Module::isNativeAccessEnabled before the first downcall so that a caller who has neither gets an exception naming the flag instead of a JVM warning three frames from any of our code. The FFM artifact targets 25 rather than the 22 that finalised the API, because 22 has been out of support since September 2024. 111 tests, green against libzu built from the engine at HEAD. The suite skips rather than fails when there is no library to find, so a checkout with no engine beside it is still green. The benchmarks say what the columnar surface is for. Summing one integer column of a hundred thousand rows costs 0.45 ns a row through r.longs(0), 4.1 ns a row a chunk at a time, 45 ns a row through the Row iterator and 67 ns a row through the Stream. A row at a time is a boundary crossing a cell, and a hundred of those cost about what one borrowed buffer costs. CI builds the API artifact on 17, 21, 25 and 26, and runs the whole suite against the engine at its own HEAD on Linux and macOS, once plainly and once with assertions on everywhere. One step checks that the ABI version written down in Zu.ABI_VERSION is the one the engine's zu.h declares, because ZU_ABI_VERSION is a header macro rather than a symbol and a binding with no C compile step has nowhere to read it from. The README no longer opens with a CREATE NODE TABLE the engine cannot run. --- .github/workflows/ci.yml | 123 +++ .gitignore | 2 + README.md | 85 +- pom.xml | 188 ++++ zudb-bench/pom.xml | 117 +++ .../main/java/dev/zudb/bench/QueryBench.java | 122 +++ .../main/java/dev/zudb/bench/ReadBench.java | 158 ++++ zudb-ffm/pom.xml | 76 ++ zudb-ffm/src/main/java/dev/zudb/ffm/Abi.java | 271 ++++++ .../main/java/dev/zudb/ffm/FfmBinding.java | 871 ++++++++++++++++++ .../main/java/dev/zudb/ffm/FfmProvider.java | 54 ++ .../src/main/java/dev/zudb/ffm/Scratch.java | 124 +++ zudb-ffm/src/main/java/module-info.java | 15 + .../META-INF/services/dev.zudb.spi.ZuProvider | 1 + .../test/java/dev/zudb/ffm/ColumnarTest.java | 199 ++++ .../test/java/dev/zudb/ffm/DatabaseTest.java | 108 +++ .../src/test/java/dev/zudb/ffm/ErrorTest.java | 102 ++ .../src/test/java/dev/zudb/ffm/Libzu.java | 58 ++ .../src/test/java/dev/zudb/ffm/QueryTest.java | 198 ++++ .../test/java/dev/zudb/ffm/StatementTest.java | 194 ++++ .../java/dev/zudb/ffm/TransactionTest.java | 118 +++ .../src/test/java/dev/zudb/ffm/ValueTest.java | 141 +++ .../src/test/java/dev/zudb/ffm/ZuTest.java | 48 + zudb/pom.xml | 34 + zudb/src/main/java/dev/zudb/Chunk.java | 132 +++ zudb/src/main/java/dev/zudb/Config.java | 81 ++ zudb/src/main/java/dev/zudb/Connection.java | 251 +++++ zudb/src/main/java/dev/zudb/Database.java | 203 ++++ zudb/src/main/java/dev/zudb/Diagnostic.java | 150 +++ zudb/src/main/java/dev/zudb/Library.java | 149 +++ zudb/src/main/java/dev/zudb/Result.java | 456 +++++++++ zudb/src/main/java/dev/zudb/Row.java | 305 ++++++ zudb/src/main/java/dev/zudb/Severity.java | 46 + zudb/src/main/java/dev/zudb/Statement.java | 277 ++++++ zudb/src/main/java/dev/zudb/Status.java | 79 ++ zudb/src/main/java/dev/zudb/Type.java | 79 ++ zudb/src/main/java/dev/zudb/Value.java | 327 +++++++ zudb/src/main/java/dev/zudb/Zu.java | 200 ++++ .../main/java/dev/zudb/ZuClosedException.java | 19 + .../java/dev/zudb/ZuConcurrentException.java | 23 + .../java/dev/zudb/ZuConnectionException.java | 18 + .../main/java/dev/zudb/ZuDataException.java | 19 + zudb/src/main/java/dev/zudb/ZuException.java | 175 ++++ .../java/dev/zudb/ZuInternalException.java | 17 + .../java/dev/zudb/ZuInterruptedException.java | 24 + .../java/dev/zudb/ZuProgrammingException.java | 20 + .../main/java/dev/zudb/ZuSyntaxException.java | 17 + .../java/dev/zudb/ZuTransactionException.java | 19 + zudb/src/main/java/dev/zudb/package-info.java | 35 + .../spi/ProviderUnavailableException.java | 37 + .../src/main/java/dev/zudb/spi/ZuBinding.java | 581 ++++++++++++ .../main/java/dev/zudb/spi/ZuProvider.java | 54 ++ .../main/java/dev/zudb/spi/package-info.java | 13 + zudb/src/main/java/module-info.java | 15 + zudb/src/test/java/dev/zudb/ConfigTest.java | 39 + .../test/java/dev/zudb/DiagnosticTest.java | 127 +++ zudb/src/test/java/dev/zudb/LibraryTest.java | 60 ++ zudb/src/test/java/dev/zudb/TemporalTest.java | 120 +++ 58 files changed, 7566 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 pom.xml create mode 100644 zudb-bench/pom.xml create mode 100644 zudb-bench/src/main/java/dev/zudb/bench/QueryBench.java create mode 100644 zudb-bench/src/main/java/dev/zudb/bench/ReadBench.java create mode 100644 zudb-ffm/pom.xml create mode 100644 zudb-ffm/src/main/java/dev/zudb/ffm/Abi.java create mode 100644 zudb-ffm/src/main/java/dev/zudb/ffm/FfmBinding.java create mode 100644 zudb-ffm/src/main/java/dev/zudb/ffm/FfmProvider.java create mode 100644 zudb-ffm/src/main/java/dev/zudb/ffm/Scratch.java create mode 100644 zudb-ffm/src/main/java/module-info.java create mode 100644 zudb-ffm/src/main/resources/META-INF/services/dev.zudb.spi.ZuProvider create mode 100644 zudb-ffm/src/test/java/dev/zudb/ffm/ColumnarTest.java create mode 100644 zudb-ffm/src/test/java/dev/zudb/ffm/DatabaseTest.java create mode 100644 zudb-ffm/src/test/java/dev/zudb/ffm/ErrorTest.java create mode 100644 zudb-ffm/src/test/java/dev/zudb/ffm/Libzu.java create mode 100644 zudb-ffm/src/test/java/dev/zudb/ffm/QueryTest.java create mode 100644 zudb-ffm/src/test/java/dev/zudb/ffm/StatementTest.java create mode 100644 zudb-ffm/src/test/java/dev/zudb/ffm/TransactionTest.java create mode 100644 zudb-ffm/src/test/java/dev/zudb/ffm/ValueTest.java create mode 100644 zudb-ffm/src/test/java/dev/zudb/ffm/ZuTest.java create mode 100644 zudb/pom.xml create mode 100644 zudb/src/main/java/dev/zudb/Chunk.java create mode 100644 zudb/src/main/java/dev/zudb/Config.java create mode 100644 zudb/src/main/java/dev/zudb/Connection.java create mode 100644 zudb/src/main/java/dev/zudb/Database.java create mode 100644 zudb/src/main/java/dev/zudb/Diagnostic.java create mode 100644 zudb/src/main/java/dev/zudb/Library.java create mode 100644 zudb/src/main/java/dev/zudb/Result.java create mode 100644 zudb/src/main/java/dev/zudb/Row.java create mode 100644 zudb/src/main/java/dev/zudb/Severity.java create mode 100644 zudb/src/main/java/dev/zudb/Statement.java create mode 100644 zudb/src/main/java/dev/zudb/Status.java create mode 100644 zudb/src/main/java/dev/zudb/Type.java create mode 100644 zudb/src/main/java/dev/zudb/Value.java create mode 100644 zudb/src/main/java/dev/zudb/Zu.java create mode 100644 zudb/src/main/java/dev/zudb/ZuClosedException.java create mode 100644 zudb/src/main/java/dev/zudb/ZuConcurrentException.java create mode 100644 zudb/src/main/java/dev/zudb/ZuConnectionException.java create mode 100644 zudb/src/main/java/dev/zudb/ZuDataException.java create mode 100644 zudb/src/main/java/dev/zudb/ZuException.java create mode 100644 zudb/src/main/java/dev/zudb/ZuInternalException.java create mode 100644 zudb/src/main/java/dev/zudb/ZuInterruptedException.java create mode 100644 zudb/src/main/java/dev/zudb/ZuProgrammingException.java create mode 100644 zudb/src/main/java/dev/zudb/ZuSyntaxException.java create mode 100644 zudb/src/main/java/dev/zudb/ZuTransactionException.java create mode 100644 zudb/src/main/java/dev/zudb/package-info.java create mode 100644 zudb/src/main/java/dev/zudb/spi/ProviderUnavailableException.java create mode 100644 zudb/src/main/java/dev/zudb/spi/ZuBinding.java create mode 100644 zudb/src/main/java/dev/zudb/spi/ZuProvider.java create mode 100644 zudb/src/main/java/dev/zudb/spi/package-info.java create mode 100644 zudb/src/main/java/module-info.java create mode 100644 zudb/src/test/java/dev/zudb/ConfigTest.java create mode 100644 zudb/src/test/java/dev/zudb/DiagnosticTest.java create mode 100644 zudb/src/test/java/dev/zudb/LibraryTest.java create mode 100644 zudb/src/test/java/dev/zudb/TemporalTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..18beaac --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,123 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + MAVEN_ARGS: -B -ntp + +jobs: + # The API artifact is what a caller compiles against, it has no native + # code in it, and it is the one thing that has to build on every JDK + # this client claims to support. It needs no engine, so it answers in + # under a minute and it answers first. + api: + strategy: + fail-fast: false + matrix: + java: ["17", "21", "25", "26-ea"] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: ${{ matrix.java }} + cache: maven + + # Only the API module, because the FFM provider compiles to release + # 25 and a JDK 17 compiler cannot be asked for that. A caller on 17 + # gets exactly this artifact and the JNI provider beside it. + - run: mvn $MAVEN_ARGS -pl zudb -am test + + # The whole client against the engine at its own HEAD, which is what + # makes a red job here mean the binding is wrong about the ABI rather + # than that a checked-in copy of something is stale. + engine: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + java: ["25", "26-ea"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + + - uses: actions/checkout@v5 + with: + repository: tamnd/zu + path: engine + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: ${{ matrix.java }} + cache: maven + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: engine + + # ZU_ABI_VERSION is a header macro rather than a symbol, so a + # binding with no C compile step has nowhere to read it from and + # has to write it down. This is the step that stops the written + # down copy from drifting. + - name: The ABI this client speaks is the ABI the engine offers + run: | + set -eu + engine_abi="$(sed -n 's/^#define ZU_ABI_VERSION "\(.*\)"$/\1/p' \ + engine/crates/zu-capi/include/zu.h)" + client_abi="$(sed -n 's/.*ABI_VERSION = "\(.*\)";.*/\1/p' \ + zudb/src/main/java/dev/zudb/Zu.java)" + test -n "$engine_abi" + test -n "$client_abi" + echo "engine $engine_abi, client $client_abi" + test "$engine_abi" = "$client_abi" + + - name: Build libzu + working-directory: engine + run: cargo build --release -p zu-capi + + - name: Where the library landed + run: | + set -eu + lib="$(ls engine/target/release/libzu.dylib engine/target/release/libzu.so 2>/dev/null | head -1)" + test -n "$lib" + echo "ZU_LIBRARY=$GITHUB_WORKSPACE/$lib" >> "$GITHUB_ENV" + + - run: mvn $MAVEN_ARGS test + + # The suite again with assertions on everywhere, including the ones + # in the JDK itself. The bounds checks a MemorySegment does are the + # difference between a wrong offset failing and a wrong offset + # reading somebody else's memory. + - run: mvn $MAVEN_ARGS test -Dzu.test.args="-ea -esa" + + # Not for the numbers, which mean nothing on a shared runner, but + # because a benchmark is code that nothing else compiles and + # nothing else runs. One iteration is enough to say it still works. + - run: mvn $MAVEN_ARGS -DskipTests package + + - run: java -jar zudb-bench/target/benchmarks.jar -f 1 -wi 1 -i 1 -r 1s -w 1s + + # What Maven Central will run over the artifacts, run here instead so + # that a release is not the first time anyone sees it. + javadoc: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "25" + cache: maven + + - run: mvn $MAVEN_ARGS -P release -DskipTests package diff --git a/.gitignore b/.gitignore index e43b0f9..240c72a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .DS_Store +target/ + diff --git a/README.md b/README.md index 864c2b6..258127d 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,6 @@ import dev.zudb.*; try (Database db = Database.open("social.zu1"); Connection conn = db.connect()) { - conn.execute("CREATE NODE TABLE Person(id INT64 PRIMARY KEY, name STRING)"); - conn.loadCsv("Person", Path.of("people.csv")); - try (Result result = conn.query(""" MATCH (p:Person)-[:Follows]->(f) RETURN p.name AS name, count(*) AS n ORDER BY n DESC LIMIT 5 @@ -28,26 +25,98 @@ try (Database db = Database.open("social.zu1"); zudb ${zu.version} + + dev.zudb + zudb-ffm + ${zu.version} + runtime + ``` Text blocks for queries, try-with-resources for every handle, `Stream` for iteration. Nothing here should surprise a Java developer, which is the whole goal. +## Reading a column without reading a row + +A row at a time is the shape most callers want, and it is not the shape that makes an embedded database worth embedding. Every column of a result is also readable as one borrowed buffer over the engine's own memory, with no copy and no per-row call: + +```java +try (Result r = conn.query("MATCH (p:Person) RETURN p.age")) { + LongBuffer ages = r.longs(0); + ByteBuffer valid = r.valid(0); + + long total = 0; + for (int i = 0; i < ages.remaining(); i++) { + if (valid.get(i) != 0) { + total += ages.get(i); + } + } +} +``` + +The buffers are read-only views in native byte order, and they are valid until the `Result` closes. A result larger than one chunk is readable a chunk at a time through `r.chunks()`, which is the path that does not need the whole column resident. `java.nio` rather than `MemorySegment` on purpose: a Java 17 caller can name a `LongBuffer`, and both providers can hand one back without copying. + +What it is worth, summing one integer column of a hundred thousand rows on an M-series laptop, JDK 25: + +| How | Per row | +|---|---| +| `r.longs(0)` and a loop over the buffer | 0.45 ns | +| the same a chunk at a time | 4.1 ns | +| `for (Row row : r) row.getLong(0)` | 45 ns | +| `r.stream().mapToLong(...)` | 67 ns | + +A row at a time is a boundary crossing a cell, and a hundred crossings cost about what one borrowed buffer costs. Both surfaces are there because both are the right answer to a different question, but a loop over a million rows should be reading a column. + ## How it binds -The Foreign Function and Memory API (Panama) is the primary path, with `jextract` generating the bindings from `zu.h` and `MemorySegment` giving genuinely zero-copy column access. There is no hand-written JNI shim on that path and no native code beyond `libzu` itself. +The Foreign Function and Memory API is the primary path. The downcall handles are written by hand against `zu.h` rather than generated with `jextract`, because the C ABI here is around seventy functions with a stable shape, and a hand-written layer is where the interesting decisions live: which calls are `Linker.Option.critical` because they are short pure accessors, where the out-parameter scratch space comes from so that a query does not allocate, and how a `zu_error` becomes a typed Java exception exactly once. There is no native code in this repository beyond `libzu` itself. An SDK that requires a recent JDK in 2026 excludes a large part of the enterprise ecosystem, so there is a JNI provider too: | Artifact | Baseline | Role | |---|---|---| | `dev.zudb:zudb` | Java 17 | the API, no native code, no FFM types in the public surface | -| `dev.zudb:zudb-ffm` | Java 22+ | the FFM provider, selected automatically | -| `dev.zudb:zudb-jni` | Java 17+ | the fallback provider | +| `dev.zudb:zudb-ffm` | Java 25 | the FFM provider, selected automatically | +| `dev.zudb:zudb-jni` | Java 17 | the fallback provider | | `dev.zudb:zudb-native-{platform}` | | the `libzu` binaries | -A `ServiceLoader` picks the provider at runtime and logs the choice once at debug level. Application code never names one. Baseline for the modern artifact is **Java 25 LTS**, CI runs 17, 21, 25, and 26. +A `ServiceLoader` picks the provider at run time and application code never names one. The FFM artifact targets Java 25 rather than the Java 22 that finalised the API, because 22 has been out of support since September 2024 and shipping against an unsupported release only moves the problem. CI runs 17, 21, 25, and 26. + +One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the class path case, the module path case wants `--enable-native-access=dev.zudb.ffm`, and the provider checks `Module::isNativeAccessEnabled` before the first downcall so that the failure is an exception naming the flag rather than a JVM warning on stderr three frames from any of our code. + +## Errors + +Every failure is a `ZuException`, and the subclass is chosen from the GQLSTATUS class rather than from the message: `ZuSyntaxException` for 42, `ZuDataException` for 22, `ZuTransactionException` for 25 and 40, and so on down. The exception carries the whole diagnostic, so a caller reads fields instead of parsing prose: + +```java +catch (ZuSyntaxException e) { + e.code(); // the GQLSTATUS, for example 42001 + e.condition(); // its standard text + e.position(); // line, column and byte offset, when there is one + e.caret().ifPresent(System.err::println); + e.retryable(); // whether running it again could work +} +``` + +## What works today + +The engine has no DDL yet, so there is no `CREATE NODE TABLE` and nothing in this client writes a schema. What runs against a fresh database is the expression and projection surface: `RETURN`, `UNWIND`, parameters, lists, records, and the temporal types. The example at the top of this file describes the intended shape and needs a graph that some other tool built. + +## Building + +```sh +mvn test -Dzu.library=/path/to/libzu.dylib +``` + +The provider looks at `-Dzu.library`, then `ZU_LIBRARY`, then the platform library path. The tests skip rather than fail when no `libzu` is reachable, so a checkout with no engine build beside it is still green. + +The benchmarks are JMH and are not published: + +```sh +mvn package -DskipTests +ZU_LIBRARY=/path/to/libzu.dylib java -jar zudb-bench/target/benchmarks.jar +``` -One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the classpath case, the docs give the exact `--enable-native-access=dev.zudb` flag for the module path, and the binding detects the ungranted state at `Database.open` and throws a message containing the flag you need. A JVM warning on stderr three frames from any of our code is not a diagnosis anyone can act on. +`ZU_LIBRARY` rather than `-Dzu.library` there, because JMH forks a JVM of its own and a fork inherits the environment rather than the system properties. ## Beyond Java diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..7e566a5 --- /dev/null +++ b/pom.xml @@ -0,0 +1,188 @@ + + + + 4.0.0 + + dev.zudb + zudb-parent + 0.11.0-SNAPSHOT + pom + + zu for the JVM + The Java client for zu, an embedded property-graph database. + https://github.com/tamnd/zu-java + + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + + tamnd + Tam Nguyen Duc + https://github.com/tamnd + + + + + scm:git:https://github.com/tamnd/zu-java.git + scm:git:ssh://git@github.com/tamnd/zu-java.git + https://github.com/tamnd/zu-java + + + + GitHub + https://github.com/tamnd/zu-java/issues + + + + zudb + zudb-ffm + zudb-bench + + + + UTF-8 + UTF-8 + + 2026-01-01T00:00:00Z + + + 17 + + 25 + + + + + 6.1.3 + 3.15.0 + 3.5.6 + 3.5.1 + 3.4.0 + 3.12.0 + 3.6.2 + 3.2.8 + 0.11.0 + + + + + + dev.zudb + zudb + ${project.version} + + + org.junit + junit-bom + ${junit.version} + pom + import + + + + + + + org.junit.jupiter + junit-jupiter + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.plugin.version} + + + + -Xlint:all,-requires-automatic,-requires-transitive-automatic + -Werror + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven.surefire.plugin.version} + + + org.apache.maven.plugins + maven-jar-plugin + ${maven.jar.plugin.version} + + + org.apache.maven.plugins + maven-source-plugin + ${maven.source.plugin.version} + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven.javadoc.plugin.version} + + + + + + + + + release + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + jar-no-fork + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + attach-javadocs + jar + + + + + + + + diff --git a/zudb-bench/pom.xml b/zudb-bench/pom.xml new file mode 100644 index 0000000..71197b2 --- /dev/null +++ b/zudb-bench/pom.xml @@ -0,0 +1,117 @@ + + + + 4.0.0 + + + dev.zudb + zudb-parent + 0.11.0-SNAPSHOT + + + zudb-bench + zu for the JVM: benchmarks + JMH benchmarks for the zu JVM client. Not published. + + + 1.37 + true + true + + + + + dev.zudb + zudb + + + dev.zudb + zudb-ffm + ${project.version} + runtime + + + org.openjdk.jmh + jmh-core + ${jmh.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + provided + + + + + benchmarks + + + org.apache.maven.plugins + maven-compiler-plugin + + ${zu.release.ffm} + + full + + + + + + org.apache.maven.plugins + maven-shade-plugin + ${maven.shade.plugin.version} + + + package + shade + + + false + + + org.openjdk.jmh.Main + + ALL-UNNAMED + + + + + + + *:* + + + module-info.class + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + diff --git a/zudb-bench/src/main/java/dev/zudb/bench/QueryBench.java b/zudb-bench/src/main/java/dev/zudb/bench/QueryBench.java new file mode 100644 index 0000000..da9ff4e --- /dev/null +++ b/zudb-bench/src/main/java/dev/zudb/bench/QueryBench.java @@ -0,0 +1,122 @@ +package dev.zudb.bench; + +import dev.zudb.Connection; +import dev.zudb.Database; +import dev.zudb.Result; +import dev.zudb.Statement; +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.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; +import org.openjdk.jmh.infra.Blackhole; + +/** + * What a whole statement costs, from the call to the closed result. + * + *

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 @@ + + + + 4.0.0 + + + dev.zudb + zudb-parent + 0.11.0-SNAPSHOT + + + zudb-ffm + zu for the JVM: Panama provider + The zu provider over the Foreign Function and Memory API, for JDK 25 and later. + + + + dev.zudb + zudb + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${zu.release.ffm} + + + + org.apache.maven.plugins + maven-jar-plugin + + + + + ALL-UNNAMED + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + --enable-native-access=dev.zudb.ffm ${zu.test.args} + + + + + diff --git a/zudb-ffm/src/main/java/dev/zudb/ffm/Abi.java b/zudb-ffm/src/main/java/dev/zudb/ffm/Abi.java new file mode 100644 index 0000000..787473d --- /dev/null +++ b/zudb-ffm/src/main/java/dev/zudb/ffm/Abi.java @@ -0,0 +1,271 @@ +package dev.zudb.ffm; + +import static java.lang.foreign.ValueLayout.ADDRESS; +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.spi.ProviderUnavailableException; +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemoryLayout; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandle; +import java.nio.file.Path; + +/** + * Every function of the C ABI, looked up once and bound to a method handle. + * + *

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 LOCAL = ThreadLocal.withInitial(Scratch::new); + + private final Arena arena = Arena.ofAuto(); + private final MemorySegment slots = arena.allocate(SLOTS, 8); + private final MemorySegment config = arena.allocate(CONFIG, 8); + private MemorySegment block = arena.allocate(512, 8); + private long used; + + private Scratch() {} + + /** + * This thread's scratch, with its bump pointer wound back. + * + * @return the scratch, whose previous contents are now free space + */ + static Scratch get() { + Scratch s = LOCAL.get(); + s.used = 0; + return s; + } + + /** + * The fixed block the out-parameters live in, at the offsets named above. + * + * @return the block, whose contents are whatever the last call left + */ + MemorySegment slots() { + return slots; + } + + /** + * The {@code zu_config} this thread fills in and passes by pointer. + * + * @return the block, zeroed and with its size field set + */ + MemorySegment config() { + config.fill((byte) 0); + return config; + } + + /** + * A string as UTF-8, without a terminator, since every call that takes one + * takes its length beside it. + * + * @param s the string + * @return a segment holding exactly its bytes + */ + MemorySegment utf8(String s) { + byte[] bytes = s.getBytes(StandardCharsets.UTF_8); + MemorySegment out = alloc(bytes.length); + MemorySegment.copy(bytes, 0, out, java.lang.foreign.ValueLayout.JAVA_BYTE, 0, bytes.length); + return out; + } + + private MemorySegment alloc(long bytes) { + if (used + bytes > block.byteSize()) { + // The old block stays alive as long as anything handed out of it is + // reachable, so growing mid-call cannot pull the ground out from under + // a segment already passed to a native function. + long size = Math.max(block.byteSize() * 2, bytes); + block = arena.allocate(size, 8); + used = 0; + } + MemorySegment out = block.asSlice(used, bytes); + used += bytes; + return out; + } +} diff --git a/zudb-ffm/src/main/java/module-info.java b/zudb-ffm/src/main/java/module-info.java new file mode 100644 index 0000000..7f58d03 --- /dev/null +++ b/zudb-ffm/src/main/java/module-info.java @@ -0,0 +1,15 @@ +/** + * The zu provider over the Foreign Function and Memory API. + * + *

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 seen = new ArrayList<>(); + long expectedOffset = 0; + for (Chunk c : r.chunks().toList()) { + assertEquals(expectedOffset, c.offset()); + LongBuffer b = c.longs(0); + for (int i = 0; i < c.rows(); i++) { + seen.add(b.get(i)); + } + expectedOffset += c.rows(); + } + assertEquals(3000, seen.size()); + assertEquals(1L, seen.get(0)); + assertEquals(3000L, seen.get(2999)); + assertEquals(3000, expectedOffset); + } + } + + @Test + void aChunkRowIsTheSameValueAsTheRowItsOffsetNames() { + try (Result r = conn.query(unwind(100))) { + Chunk first = r.chunk(0); + assertEquals(first.longs(0).get(0), first.row(0).getLong(0)); + assertEquals(r.row(first.offset()).getLong(0), first.longs(0).get(0)); + } + } + + @Test + void aChunkOffTheEndIsRefused() { + try (Result r = conn.query("UNWIND [1] AS v RETURN v")) { + assertThrows(ZuProgrammingException.class, () -> r.chunk(r.chunkCount())); + assertThrows(ZuProgrammingException.class, () -> r.chunk(-1)); + } + } + + @Test + void aWholeColumnAndAChunkOfItAgree() { + try (Result r = conn.query(unwind(500))) { + LongBuffer whole = r.longs(0); + Chunk c = r.chunk(0); + LongBuffer part = c.longs(0); + for (int i = 0; i < c.rows(); i++) { + assertEquals(whole.get((int) c.offset() + i), part.get(i)); + } + } + } + + /** A statement that answers with the numbers one to n, one a row. */ + private static String unwind(int n) { + StringBuilder sb = new StringBuilder("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/src/test/java/dev/zudb/ffm/DatabaseTest.java b/zudb-ffm/src/test/java/dev/zudb/ffm/DatabaseTest.java new file mode 100644 index 0000000..0198cac --- /dev/null +++ b/zudb-ffm/src/test/java/dev/zudb/ffm/DatabaseTest.java @@ -0,0 +1,108 @@ +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.zudb.Config; +import dev.zudb.Connection; +import dev.zudb.Database; +import dev.zudb.Result; +import dev.zudb.ZuClosedException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Opening, connecting, and closing, in that order and the reverse. */ +class DatabaseTest { + + @BeforeAll + static void engine() { + Libzu.require(); + } + + @Test + void aDatabaseInMemoryKnowsItIsOne() { + try (Database db = Database.memory()) { + assertTrue(db.isMemory()); + assertNotNull(db.path()); + assertFalse(db.isClosed()); + } + } + + @Test + void aDatabaseOnDiskKnowsItIsNot(@TempDir Path dir) { + Path file = dir.resolve("graph.zu"); + try (Database db = Database.create(file)) { + assertFalse(db.isMemory()); + assertEquals(file.toString(), db.path()); + } + assertTrue(Files.isRegularFile(file)); + try (Database again = Database.open(file)) { + assertFalse(again.isMemory()); + } + } + + @Test + void aConfigurationCrossesTheBoundary() { + try (Database db = Database.memory(Config.defaults().withThreads(1).withMemoryLimit(1 << 20)); + Connection conn = db.connect(); + Result r = conn.query("RETURN 1 AS one")) { + assertEquals(1, r.rows()); + } + } + + @Test + void closingTwiceIsNotAFailure() { + Database db = Database.memory(); + db.close(); + db.close(); + assertTrue(db.isClosed()); + } + + @Test + void usingAClosedDatabaseSaysSoRatherThanCrashing() { + Database db = Database.memory(); + db.close(); + assertThrows(ZuClosedException.class, db::connect); + assertThrows(ZuClosedException.class, db::isMemory); + } + + @Test + void usingAClosedConnectionSaysSoRatherThanCrashing() { + try (Database db = Database.memory()) { + Connection conn = db.connect(); + conn.close(); + assertThrows(ZuClosedException.class, () -> conn.query("RETURN 1 AS one")); + assertTrue(conn.isClosed()); + } + } + + @Test + void aDuplicateIsASecondConnectionOnTheSameGraph() { + try (Database db = Database.memory(); + Connection first = db.connect(); + Connection second = first.duplicate()) { + assertFalse(second.isClosed()); + try (Result r = second.query("RETURN 2 AS two")) { + assertEquals(2, r.row(0).getLong("two")); + } + } + } + + @Test + void aConnectionCountsTheRowsItHasRead() { + try (Database db = Database.memory(); + Connection conn = db.connect()) { + long before = conn.rowsRead(); + try (Result r = conn.query("UNWIND [1, 2, 3, 4] AS v RETURN v")) { + assertEquals(4, r.rows()); + } + assertTrue(conn.rowsRead() >= before); + } + } +} diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/ErrorTest.java b/zudb-ffm/src/test/java/dev/zudb/ffm/ErrorTest.java new file mode 100644 index 0000000..1f82c5c --- /dev/null +++ b/zudb-ffm/src/test/java/dev/zudb/ffm/ErrorTest.java @@ -0,0 +1,102 @@ +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.assertNotNull; +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.Severity; +import dev.zudb.ZuException; +import dev.zudb.ZuSyntaxException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * What a failure carries across the boundary. + * + *

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 names; + try (Result r = conn.query("UNWIND ['ada', 'grace'] AS v RETURN v")) { + names = r.stream().map(row -> row.getString(0)).collect(Collectors.toList()); + } + // The strings were copied out on the way, so this is still readable. + assertEquals(List.of("ada", "grace"), names); + } +} diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/StatementTest.java b/zudb-ffm/src/test/java/dev/zudb/ffm/StatementTest.java new file mode 100644 index 0000000..1f4449e --- /dev/null +++ b/zudb-ffm/src/test/java/dev/zudb/ffm/StatementTest.java @@ -0,0 +1,194 @@ +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.Connection; +import dev.zudb.Database; +import dev.zudb.Result; +import dev.zudb.Statement; +import dev.zudb.Value; +import dev.zudb.ZuClosedException; +import dev.zudb.ZuProgrammingException; +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.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** Preparing once and running many times, which is what a loop wants. */ +class StatementTest { + + 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 aParameterCrossesAndComesBack() { + try (Statement stmt = conn.prepare("RETURN $v AS v"); + Result r = stmt.bind("v", 42L).execute()) { + assertEquals(42, r.row(0).getLong("v")); + } + } + + @Test + void everyScalarKindOfParameter() { + try (Statement stmt = conn.prepare("RETURN $v AS v")) { + try (Result r = stmt.bind("v", 1.5).execute()) { + assertEquals(1.5, r.row(0).getDouble("v")); + } + try (Result r = stmt.bind("v", true).execute()) { + assertTrue(r.row(0).getBoolean("v")); + } + try (Result r = stmt.bind("v", "ada").execute()) { + assertEquals("ada", r.row(0).getString("v")); + } + try (Result r = stmt.bindNull("v").execute()) { + assertTrue(r.row(0).isNull("v")); + } + } + } + + @Test + void aBindingSurvivesAnExecuteAndRebindingReplacesIt() { + List seen = new ArrayList<>(); + try (Statement stmt = conn.prepare("RETURN $v AS v")) { + for (long v : new long[] {1, 2, 3}) { + try (Result r = stmt.bind("v", v).execute()) { + seen.add(r.row(0).getLong("v")); + } + } + // Nothing was rebound, so the last value is still there. + try (Result r = stmt.execute()) { + seen.add(r.row(0).getLong("v")); + } + } + assertEquals(List.of(1L, 2L, 3L, 3L), seen); + } + + @Test + void bindsChain() { + try (Statement stmt = conn.prepare("RETURN $a AS a, $b AS b"); + Result r = stmt.bind("a", 1L).bind("b", "two").execute()) { + assertEquals(1, r.row(0).getLong("a")); + assertEquals("two", r.row(0).getString("b")); + } + } + + @Test + void aStringParameterOfNullSaysWhichCallToUse() { + try (Statement stmt = conn.prepare("RETURN $v AS v")) { + ZuProgrammingException e = + assertThrows(ZuProgrammingException.class, () -> stmt.bind("v", (String) null)); + assertTrue(e.getMessage().contains("bindNull"), e.getMessage()); + } + } + + @Test + void aDateGoesOutAndComesBack() { + LocalDate date = LocalDate.of(2026, 8, 20); + try (Statement stmt = conn.prepare("RETURN $v AS v"); + Result r = stmt.bind("v", date).execute()) { + assertEquals(date, r.row(0).getTemporal("v").toLocalDate()); + } + } + + @Test + void everyTemporalKindGoesOutAndComesBack() { + LocalTime time = LocalTime.of(13, 45, 30, 123_456_789); + OffsetTime zonedTime = OffsetTime.of(LocalTime.of(9, 30), ZoneOffset.ofHours(2)); + LocalDateTime datetime = LocalDateTime.of(2026, 8, 20, 11, 22, 33); + OffsetDateTime zoned = + OffsetDateTime.of(LocalDateTime.of(2026, 8, 20, 11, 0), ZoneOffset.ofHours(-5)); + + try (Statement stmt = conn.prepare("RETURN $v AS v")) { + try (Result r = stmt.bind("v", time).execute()) { + assertEquals(time, r.row(0).getTemporal("v").toLocalTime()); + } + try (Result r = stmt.bind("v", zonedTime).execute()) { + assertEquals(zonedTime, r.row(0).getTemporal("v").toOffsetTime()); + } + try (Result r = stmt.bind("v", datetime).execute()) { + assertEquals(datetime, r.row(0).getTemporal("v").toLocalDateTime()); + } + try (Result r = stmt.bind("v", zoned).execute()) { + assertEquals(zoned.toInstant(), r.row(0).getTemporal("v").toOffsetDateTime().toInstant()); + } + try (Result r = stmt.bind("v", Period.ofMonths(14)).execute()) { + assertEquals(Period.of(1, 2, 0), r.row(0).getTemporal("v").toPeriod()); + } + try (Result r = stmt.bind("v", Duration.ofHours(25)).execute()) { + assertEquals(Duration.ofHours(25), r.row(0).getTemporal("v").toDuration()); + } + } + } + + @Test + void aPeriodWithDaysIsRefusedRatherThanRounded() { + try (Statement stmt = conn.prepare("RETURN $v AS v")) { + ZuProgrammingException e = + assertThrows( + ZuProgrammingException.class, () -> stmt.bind("v", Period.of(0, 1, 1))); + assertTrue(e.getMessage().contains("year-month"), e.getMessage()); + } + } + + @Test + void aTemporalReadOutOfOneResultBindsIntoTheNext() { + try (Statement stmt = conn.prepare("RETURN $v AS v")) { + Value.Temporal out; + try (Result r = stmt.bind("v", LocalDate.of(1999, 12, 31)).execute()) { + out = r.row(0).getTemporal("v"); + } + try (Result r = stmt.bind("v", out).execute()) { + assertEquals(LocalDate.of(1999, 12, 31), r.row(0).getTemporal("v").toLocalDate()); + } + } + } + + @Test + void aClosedStatementSaysSoAndIsStillSafeToClose() { + Statement stmt = conn.prepare("RETURN $v AS v"); + stmt.close(); + assertTrue(stmt.isClosed()); + assertThrows(ZuClosedException.class, () -> stmt.bind("v", 1L)); + assertThrows(ZuClosedException.class, stmt::execute); + stmt.close(); + } + + @Test + void aResultOutlivesTheStatementThatMadeIt() { + Result r; + try (Statement stmt = conn.prepare("RETURN $v AS v")) { + r = stmt.bind("v", 7L).execute(); + } + try (Result open = r) { + assertEquals(7, open.row(0).getLong("v")); + } + } +} diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/TransactionTest.java b/zudb-ffm/src/test/java/dev/zudb/ffm/TransactionTest.java new file mode 100644 index 0000000..6f3c1de --- /dev/null +++ b/zudb-ffm/src/test/java/dev/zudb/ffm/TransactionTest.java @@ -0,0 +1,118 @@ +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.zudb.Connection; +import dev.zudb.Database; +import dev.zudb.Result; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** Beginning, committing and rolling back, and the block that does all three. */ +class TransactionTest { + + @BeforeAll + static void engine() { + Libzu.require(); + } + + @Test + void aConnectionKnowsWhetherItIsInOne() { + try (Database db = Database.memory(); + Connection conn = db.connect()) { + assertFalse(conn.inTransaction()); + conn.begin(); + assertTrue(conn.inTransaction()); + conn.commit(); + assertFalse(conn.inTransaction()); + } + } + + @Test + void aRollbackEndsItToo() { + try (Database db = Database.memory(); + Connection conn = db.connect()) { + conn.begin(); + conn.rollback(); + assertFalse(conn.inTransaction()); + } + } + + @Test + void aReadOnlyTransactionIsStillATransaction() { + try (Database db = Database.memory(); + Connection conn = db.connect()) { + conn.beginReadOnly(); + assertTrue(conn.inTransaction()); + try (Result r = conn.query("RETURN 1 AS one")) { + assertEquals(1, r.rows()); + } + conn.commit(); + } + } + + @Test + void aStatementRunsInsideAnOpenTransaction() { + try (Database db = Database.memory(); + Connection conn = db.connect()) { + conn.begin(); + try (Result r = conn.query("UNWIND [1, 2] AS v RETURN v")) { + assertEquals(2, r.rows()); + } + conn.commit(); + } + } + + @Test + void theBlockCommitsWhenTheBodyReturns() { + try (Database db = Database.memory(); + Connection conn = db.connect()) { + long answer = conn.transaction(() -> 7L); + assertEquals(7, answer); + assertFalse(conn.inTransaction()); + } + } + + @Test + void theBlockRollsBackWhenTheBodyThrowsAndTheThrowIsTheOneYouGet() { + try (Database db = Database.memory(); + Connection conn = db.connect()) { + IllegalStateException e = + assertThrows( + IllegalStateException.class, + () -> + conn.transaction( + () -> { + throw new IllegalStateException("no"); + })); + assertEquals("no", e.getMessage()); + assertFalse(conn.inTransaction()); + } + } + + @Test + void theBlockWithNoAnswerRunsTheSameWay() { + try (Database db = Database.memory(); + Connection conn = db.connect()) { + StringBuilder ran = new StringBuilder(); + conn.transaction(() -> ran.append("yes")); + assertEquals("yes", ran.toString()); + assertFalse(conn.inTransaction()); + } + } + + @Test + void twoConnectionsOnOneDatabaseHaveTransactionsOfTheirOwn() { + try (Database db = Database.memory(); + Connection first = db.connect(); + Connection second = db.connect()) { + first.begin(); + assertTrue(first.inTransaction()); + assertFalse(second.inTransaction()); + first.rollback(); + } + } +} diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/ValueTest.java b/zudb-ffm/src/test/java/dev/zudb/ffm/ValueTest.java new file mode 100644 index 0000000..12ae8a1 --- /dev/null +++ b/zudb-ffm/src/test/java/dev/zudb/ffm/ValueTest.java @@ -0,0 +1,141 @@ +package dev.zudb.ffm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.zudb.Connection; +import dev.zudb.Database; +import dev.zudb.Result; +import dev.zudb.Type; +import dev.zudb.Value; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * The values that have no column to be read into: lists, records and the + * trees they make. + */ +class ValueTest { + + 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 aListIsAListOfValues() { + try (Result r = conn.query("RETURN [1, 2, 3] AS v")) { + assertEquals(Type.LIST, r.row(0).type("v")); + Value.List list = assertInstanceOf(Value.List.class, r.row(0).get("v")); + assertEquals(3, list.items().size()); + assertEquals(new Value.Int(1), list.items().get(0)); + assertEquals(new Value.Int(3), list.items().get(2)); + } + } + + @Test + void aListWithAHoleInItKeepsTheHole() { + try (Result r = conn.query("RETURN [1, null, 3] AS v")) { + Value.List list = assertInstanceOf(Value.List.class, r.row(0).get("v")); + assertEquals(Value.Null.instance(), list.items().get(1)); + } + } + + @Test + void aListOfListsRecurses() { + try (Result r = conn.query("RETURN [[1, 2], [3]] AS v")) { + Value.List outer = assertInstanceOf(Value.List.class, r.row(0).get("v")); + Value.List inner = assertInstanceOf(Value.List.class, outer.items().get(0)); + assertEquals(2, inner.items().size()); + assertEquals(new Value.Int(2), inner.items().get(1)); + } + } + + @Test + void aRecordCarriesItsFieldNames() { + try (Result r = conn.query("RETURN {a: 1, b: 'x'} AS v")) { + assertEquals(Type.RECORD, r.row(0).type("v")); + Value.Record rec = assertInstanceOf(Value.Record.class, r.row(0).get("v")); + assertEquals(2, rec.fields().size()); + // Fields come in name order, which is what makes two records written + // in different orders one value. + assertEquals("a", rec.fields().get(0).name()); + assertEquals(new Value.Int(1), rec.fields().get(0).value()); + assertEquals("b", rec.fields().get(1).name()); + assertEquals(new Value.Str("x"), rec.fields().get(1).value()); + } + } + + @Test + void aRecordOfAListOfARecord() { + try (Result r = conn.query("RETURN {a: [{b: 1}]} AS v")) { + Value.Record outer = assertInstanceOf(Value.Record.class, r.row(0).get("v")); + Value.List list = assertInstanceOf(Value.List.class, outer.fields().get(0).value()); + Value.Record inner = assertInstanceOf(Value.Record.class, list.items().get(0)); + assertEquals("b", inner.fields().get(0).name()); + assertEquals(new Value.Int(1), inner.fields().get(0).value()); + } + } + + @Test + void aStringInATreeIsCopiedOutAndOutlivesTheResult() { + Value value; + try (Result r = conn.query("RETURN ['ada', 'grace'] AS v")) { + value = r.row(0).get("v"); + } + Value.List list = assertInstanceOf(Value.List.class, value); + assertEquals(new Value.Str("ada"), list.items().get(0)); + assertEquals(new Value.Str("grace"), list.items().get(1)); + } + + @Test + void twoValuesWithTheSameContentsAreOneValue() { + // Records all the way down, so equality is structural and a test can + // write the value it expects rather than walking it. + try (Result a = conn.query("RETURN [1, 'x'] AS v"); + Result b = conn.query("RETURN [1, 'x'] AS v")) { + assertEquals(a.row(0).get("v"), b.row(0).get("v")); + } + } + + @Test + void everyValueIsOneOfTheSealedArms() { + try (Result r = conn.query("RETURN 1 AS i, 1.5 AS f, 'x' AS s, true AS b, null AS n")) { + for (int c = 0; c < r.columns(); c++) { + Value v = r.row(0).get(c); + assertTrue(v instanceof Value.Int + || v instanceof Value.Float + || v instanceof Value.Str + || v instanceof Value.Bool + || v instanceof Value.Null, + "unexpected arm: " + v.getClass()); + } + } + } + + @Test + void aTypeThisClientKnowsComesBackForEveryCell() { + try (Result r = conn.query("RETURN 1 AS i, [1] AS l, {a: 1} AS m")) { + assertEquals(Type.INT, r.cellType(0, 0)); + assertEquals(Type.LIST, r.cellType(0, 1)); + assertEquals(Type.RECORD, r.cellType(0, 2)); + } + } +} diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/ZuTest.java b/zudb-ffm/src/test/java/dev/zudb/ffm/ZuTest.java new file mode 100644 index 0000000..a2551d3 --- /dev/null +++ b/zudb-ffm/src/test/java/dev/zudb/ffm/ZuTest.java @@ -0,0 +1,48 @@ +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.assertTrue; + +import dev.zudb.Zu; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** What loaded, and what it says it is. */ +class ZuTest { + + @BeforeAll + static void engine() { + Libzu.require(); + } + + @Test + void theProviderIsTheOneThisArtifactShips() { + assertEquals("ffm", Zu.availableProvider().orElseThrow()); + assertEquals("ffm", Zu.provider()); + } + + @Test + void theEngineSaysWhatVersionItIs() { + // The release version of the engine, which moves on its own and is not + // the ABI version. A client checks the ABI version, a bug report quotes + // this one. + String version = Zu.version(); + assertFalse(version.isBlank()); + assertTrue(version.matches("\\d+\\.\\d+\\.\\d+.*"), version); + } + + @Test + void theClientSaysWhichAbiItSpeaks() { + // Hard-coded rather than read out of the library, because the ABI + // version is a header macro and a binding with no C compile step has + // nowhere to read it from. CI checks it against the engine's zu.h. + assertTrue(Zu.ABI_VERSION.matches("\\d+\\.\\d+"), Zu.ABI_VERSION); + } + + @Test + void theLoadedFileIsNamed() { + // The first question a bug report has to answer. + assertTrue(Zu.library().toString().contains("zu")); + } +} diff --git a/zudb/pom.xml b/zudb/pom.xml new file mode 100644 index 0000000..9dba4bd --- /dev/null +++ b/zudb/pom.xml @@ -0,0 +1,34 @@ + + + + 4.0.0 + + + dev.zudb + zudb-parent + 0.11.0-SNAPSHOT + + + zudb + zu for the JVM: API + The zu API for Java 17 and later. A provider module binds it to libzu. + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${zu.release.api} + + + + + diff --git a/zudb/src/main/java/dev/zudb/Chunk.java b/zudb/src/main/java/dev/zudb/Chunk.java new file mode 100644 index 0000000..2e0f1db --- /dev/null +++ b/zudb/src/main/java/dev/zudb/Chunk.java @@ -0,0 +1,132 @@ +package dev.zudb; + +import java.nio.ByteBuffer; +import java.nio.DoubleBuffer; +import java.nio.LongBuffer; + +/** + * A run of rows out of a {@link Result}, and the columns of it read where + * they lie. + * + *

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. + * + *

{@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. + * + *

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 what the block answers + * @param body what to run + * @return what the body returned + */ + public T transaction(Supplier body) { + begin(); + T value; + try { + value = body.get(); + } catch (RuntimeException | Error e) { + if (inTransaction()) { + try { + rollback(); + } catch (RuntimeException suppressed) { + e.addSuppressed(suppressed); + } + } + throw e; + } + if (inTransaction()) { + commit(); + } + return value; + } + + /** + * Whether this connection has been closed. + * + * @return true once {@link #close()} has run + */ + public boolean isClosed() { + return handle.get() == 0; + } + + /** + * Closes the connection, rolling back a transaction still running, which is + * what a program that failed halfway and dropped everything wants and the + * only answer that does not depend on a finalizer. + * + *

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. + * + *

{@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. + * + *

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 looked = new ArrayList<>(); + + String named = System.getProperty(PROPERTY); + if (named != null && !named.isBlank()) { + Path p = Paths.get(named); + if (!Files.isRegularFile(p)) { + throw new ZuProgrammingException( + Diagnostic.misuse( + Status.MISUSE, "-D" + PROPERTY + "=" + named + " names no file")); + } + return new Found(p, "-D" + PROPERTY); + } + looked.add("-D" + PROPERTY); + + String env = System.getenv(ENVIRONMENT); + if (env != null && !env.isBlank()) { + Path p = Paths.get(env); + if (!Files.isRegularFile(p)) { + throw new ZuProgrammingException( + Diagnostic.misuse(Status.MISUSE, ENVIRONMENT + "=" + env + " names no file")); + } + return new Found(p, ENVIRONMENT); + } + looked.add(ENVIRONMENT); + + String resource = "dev/zudb/native/" + platform() + "/" + System.mapLibraryName("zu"); + Path unpacked = unpack(resource); + if (unpacked != null) { + return new Found(unpacked, "the zudb-native-" + platform() + " artifact"); + } + looked.add("a zudb-native-" + platform() + " artifact on the classpath"); + + // A bare name, which is the platform being asked to search: + // java.library.path, and then whatever the loader does after that. + return new Found(Paths.get(System.mapLibraryName("zu")), "the platform library path"); + } + + /** + * The name this client gives the operating system and the instruction set, + * which is Go's spelling of both, because that is what the library + * artifacts in every other client of this engine are named after. + * + * @return for example {@code darwin-arm64} + */ + static String platform() { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + String arch = System.getProperty("os.arch", "").toLowerCase(Locale.ROOT); + + String goos; + if (os.startsWith("mac") || os.startsWith("darwin")) { + goos = "darwin"; + } else if (os.startsWith("win")) { + goos = "windows"; + } else if (os.startsWith("linux")) { + goos = "linux"; + } else { + goos = os.split("\\s")[0]; + } + + String goarch; + if (arch.equals("x86_64") || arch.equals("amd64")) { + goarch = "amd64"; + } else if (arch.equals("aarch64") || arch.equals("arm64")) { + goarch = "arm64"; + } else { + goarch = arch; + } + + return goos + "-" + goarch; + } + + /** + * Copies a library out of the classpath, because a library inside a jar is + * not a file and every loader on every platform wants a file. + * + * @param resource where it is + * @return the copy, or null if there is no such resource + */ + private static Path unpack(String resource) { + ClassLoader loader = Library.class.getClassLoader(); + try (InputStream in = + loader == null + ? ClassLoader.getSystemResourceAsStream(resource) + : loader.getResourceAsStream(resource)) { + if (in == null) { + return null; + } + Path dir = Files.createTempDirectory("zudb"); + Path file = dir.resolve(System.mapLibraryName("zu")); + Files.copy(in, file, StandardCopyOption.REPLACE_EXISTING); + // Best effort, and it fails on Windows for a library still mapped + // into the process. A file in the temp directory is what the + // operating system already cleans up after. + file.toFile().deleteOnExit(); + dir.toFile().deleteOnExit(); + return file; + } catch (IOException e) { + throw new UncheckedIOException("could not unpack " + resource, e); + } + } +} diff --git a/zudb/src/main/java/dev/zudb/Result.java b/zudb/src/main/java/dev/zudb/Result.java new file mode 100644 index 0000000..4646701 --- /dev/null +++ b/zudb/src/main/java/dev/zudb/Result.java @@ -0,0 +1,456 @@ +package dev.zudb; + +import dev.zudb.spi.ZuBinding; +import java.nio.ByteBuffer; +import java.nio.DoubleBuffer; +import java.nio.LongBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +/** + * Everything a statement answered. + * + *

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. + * + *

{@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 { + + private final ZuBinding zu; + private final AtomicLong handle; + private final long rows; + private final int columns; + private final List names; + private final Map byName; + + Result(ZuBinding zu, long handle) { + this.zu = zu; + this.handle = new AtomicLong(handle); + this.rows = zu.resultRows(handle); + this.columns = zu.resultCols(handle); + List found = new ArrayList<>(columns); + Map index = new HashMap<>(columns * 2); + for (int c = 0; c < columns; c++) { + String name = zu.resultColName(handle, c); + found.add(name); + // A statement may name two columns the same thing, and the first + // is the one a name resolves to, which is what every other client + // of this engine does and what an index makes unambiguous. + index.putIfAbsent(name, c); + } + this.names = Collections.unmodifiableList(found); + this.byName = Collections.unmodifiableMap(index); + } + + /** + * How many rows. + * + * @return the count, 0 for a statement that answered with none + */ + public long rows() { + return rows; + } + + /** + * How many columns. + * + * @return the count + */ + public int columns() { + return columns; + } + + /** + * What the columns are called, in order. + * + * @return the names, unmodifiable + */ + public List columnNames() { + return names; + } + + /** + * What one column is called. + * + * @param column the column, counting from zero + * @return the name + */ + public String columnName(int column) { + checkColumn(column); + return names.get(column); + } + + /** + * Which column a name is. + * + * @param name what the statement called it + * @return the column, counting from zero + * @throws ZuProgrammingException if the result has no such column, naming + * the ones it does have, because the answer is almost always a typo or + * a missing {@code AS} + */ + public int columnIndex(String name) { + Integer c = byName.get(name); + if (c == null) { + throw new ZuProgrammingException( + Diagnostic.misuse( + Status.MISUSE, + "this result has no column called " + name + "; it has " + String.join(", ", names))); + } + return c; + } + + /** + * One row. + * + * @param index the row, counting from zero + * @return the row, which is good until this result closes + */ + public Row row(long index) { + if (index < 0 || index >= rows) { + throw new ZuProgrammingException( + Diagnostic.misuse( + Status.MISUSE, "row " + index + " of a result with " + rows + " of them")); + } + return new Row(this, index); + } + + /** + * Every row, in order. + * + *

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 stream() { + return StreamSupport.stream(spliterator(), false); + } + + @Override + public Iterator iterator() { + return new Iterator<>() { + private long next; + + @Override + public boolean hasNext() { + return next < rows; + } + + @Override + public Row next() { + if (next >= rows) { + throw new NoSuchElementException(); + } + return new Row(Result.this, next++); + } + }; + } + + @Override + public Spliterator spliterator() { + return Spliterators.spliterator( + iterator(), rows, Spliterator.ORDERED | Spliterator.NONNULL | Spliterator.IMMUTABLE); + } + + /** + * What a cell holds, without reading it. + * + * @param row the row, counting from zero + * @param column the column, counting from zero + * @return the type + */ + public Type cellType(long row, int column) { + return Type.of(zu.resultCellType(open(), row, column)); + } + + /** + * The completion condition of the statement: {@code "00000"} for one that + * answered with columns, {@code "00001"}, successful completion with the + * result omitted, for one that had none to give back. + * + *

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 notices() { + long h = open(); + int count = zu.resultNotices(h); + if (count == 0) { + return List.of(); + } + List out = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + Diagnostic d = zu.resultNotice(h, i); + if (d == null) { + break; + } + out.add(d); + } + return Collections.unmodifiableList(out); + } + + // ---- whole columns ---- + + /** + * A whole column of integers, borrowed from the engine rather than copied. + * + *

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 chunks() { + long count = chunkCount(); + return java.util.stream.LongStream.range(0, count).mapToObj(this::chunk); + } + + /** + * Whether this result has been closed. + * + * @return true once {@link #close()} has run + */ + public boolean isClosed() { + return handle.get() == 0; + } + + /** + * Releases the rows and everything borrowed from them: every buffer, every + * string, every {@link Value}. Closing twice does nothing the second time. + */ + @Override + public void close() { + long h = handle.getAndSet(0); + if (h != 0) { + zu.resultFree(h); + } + } + + // ---- the parts a Row and a Chunk use ---- + + ZuBinding zu() { + return zu; + } + + long open() { + long h = handle.get(); + if (h == 0) { + throw new ZuClosedException(Diagnostic.misuse(Status.MISUSE_CLOSED, "this result is closed")); + } + return h; + } + + void checkColumn(int column) { + if (column < 0 || column >= columns) { + throw new ZuProgrammingException( + Diagnostic.misuse( + Status.MISUSE, + "column " + column + " of a result with " + columns + " of them: " + + String.join(", ", names))); + } + } + + /** + * The value tree under one cell, built once and owned by the caller. + * + *

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 fields = new ArrayList<>((int) length); + for (long i = 0; i < length; i++) { + fields.add(new Value.Field(zu.valueField(value, i), read(zu.valueAt(value, i)))); + } + return new Value.Record(Collections.unmodifiableList(fields)); + } + case TEMPORAL: { + long[] t = zu.valueTemporal(value); + return new Value.Temporal(Value.Temporal.Kind.of((int) t[0]), t[1], (int) t[2]); + } + case GRAPH: + return new Value.Graph(); + case BINDING_TABLE: + default: + return new Value.BindingTable(); + } + } + + private List items(long value) { + long length = zu.valueLength(value); + List out = new ArrayList<>((int) length); + for (long i = 0; i < length; i++) { + out.add(read(zu.valueAt(value, i))); + } + return Collections.unmodifiableList(out); + } +} diff --git a/zudb/src/main/java/dev/zudb/Row.java b/zudb/src/main/java/dev/zudb/Row.java new file mode 100644 index 0000000..cc4f49b --- /dev/null +++ b/zudb/src/main/java/dev/zudb/Row.java @@ -0,0 +1,305 @@ +package dev.zudb; + +/** + * One row of a {@link Result}, by column number or by name. + * + *

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. + * + *

{@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()) {
+ *             ...
+ *         }
+ *     }
+ * }
+ * }
+ * + *

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: + * + *

{@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();
+ *         ...
+ *     };
+ * }
+ * }
+ * + *

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 items) implements Value {} + + /** + * A path, as the nodes and relationships along it in order. + * + * @param items what is on it + */ + record Path(java.util.List items) implements Value {} + + /** + * A record, whose fields are in name order and whose names appear once, + * which is what makes two records written in different orders one value. + * + * @param fields what is in it + */ + record Record(java.util.List fields) implements Value {} + + /** + * One field of a {@link Value.Record}. + * + * @param name what it is called + * @param value what it holds + */ + record Field(String name, Value value) {} + + /** + * A date, a time, a datetime or a duration, as one count and the unit that + * count is in. + * + *

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 providers = providers(); + if (wanted != null && !wanted.isBlank()) { + providers.removeIf(p -> !p.name().equals(wanted)); + if (providers.isEmpty()) { + throw new ZuProgrammingException( + Diagnostic.misuse( + Status.MISUSE, + "-D" + + PROVIDER_PROPERTY + + "=" + + wanted + + " names no provider on this classpath; " + + "the two this client ships are ffm and jni")); + } + } + providers.sort(Comparator.comparingInt(ZuProvider::priority).reversed()); + + List refused = new ArrayList<>(); + for (ZuProvider p : providers) { + try { + ZuBinding binding = p.load(found.path()); + LOG.log( + Level.DEBUG, + () -> + "zu " + + binding.version() + + " through the " + + p.name() + + " provider, from " + + found.path() + + " by way of " + + found.source()); + return new Bound(binding, p.name(), found.path(), found.source()); + } catch (ProviderUnavailableException e) { + refused.add(p.name() + ": " + e.getMessage()); + } + } + + throw new ZuProgrammingException( + Diagnostic.misuse(Status.MISUSE, unavailable(found, refused))); + } + + private static String unavailable(Library.Found found, List refused) { + StringBuilder sb = new StringBuilder(); + sb.append("no provider could bind libzu at ") + .append(found.path()) + .append(", found through ") + .append(found.source()) + .append(". "); + if (refused.isEmpty()) { + sb.append( + "There is no provider on the classpath at all: add dev.zudb:zudb-ffm for JDK 22 " + + "and later, or dev.zudb:zudb-jni for 17 and later. The zudb artifact is the " + + "API and binds nothing on its own."); + } else { + sb.append("Every provider refused. ").append(String.join("; ", refused)); + } + sb.append(" Set -D").append(Library.PROPERTY).append(" to point at a libzu of your own."); + return sb.toString(); + } + + /** + * Every provider on the classpath that this JVM can load. + * + *

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 providers() { + List found = new ArrayList<>(); + load(Zu.class.getClassLoader(), found); + if (found.isEmpty()) { + load(Thread.currentThread().getContextClassLoader(), found); + } + return found; + } + + private static void load(ClassLoader loader, List into) { + if (loader == null) { + return; + } + for (ServiceLoader.Provider p : + ServiceLoader.load(ZuProvider.class, loader).stream().toList()) { + try { + into.add(p.get()); + } catch (ServiceConfigurationError e) { + LOG.log(Level.TRACE, () -> "a zu provider did not load on this JVM: " + e.getMessage()); + } + } + } + + /** + * The provider that would be used, without loading the library. + * + *

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 availableProvider() { + return providers().stream().max(Comparator.comparingInt(ZuProvider::priority)) + .map(ZuProvider::name); + } +} diff --git a/zudb/src/main/java/dev/zudb/ZuClosedException.java b/zudb/src/main/java/dev/zudb/ZuClosedException.java new file mode 100644 index 0000000..028f04d --- /dev/null +++ b/zudb/src/main/java/dev/zudb/ZuClosedException.java @@ -0,0 +1,19 @@ +package dev.zudb; + +/** + * A handle was used after the thing it belongs to closed. + * + *

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 code() { + return Optional.ofNullable(diagnostic.code()); + } + + /** + * The standard's own words for the condition, never paraphrased, for + * example {@code "syntax error or access rule violation, invalid syntax"}. + * This is what a conformance harness grades. + * + * @return the condition text, if there is one + */ + public Optional condition() { + return Optional.ofNullable(diagnostic.condition()); + } + + /** + * How bad it is. + * + * @return the severity, never null + */ + public Severity severity() { + return diagnostic.severity(); + } + + /** + * Where in the statement it happened, both counted from one, the column in + * characters so a line of multi-byte text does not read as wider than it + * looks. Empty when the condition happened somewhere the text cannot name. + * + * @return the position, if there is one + */ + public Optional position() { + return diagnostic.line() < 0 + ? Optional.empty() + : Optional.of(new Position(diagnostic.line(), diagnostic.column(), diagnostic.offset())); + } + + /** + * The whole line the position is on, quoted out of the statement, for the + * caller who has the failure and no longer has the text. Empty when there + * is no position, when the line is empty, and when the line is longer than + * anyone would read under a caret, since a line cut to fit would put the + * column somewhere it is not. + * + * @return the excerpt, if there is one + */ + public Optional excerpt() { + return Optional.ofNullable(diagnostic.excerpt()); + } + + /** + * The page that documents this condition, so a program hands a reader a + * page rather than five characters to search for. + * + * @return the URL, if there is one + */ + public Optional docUrl() { + return Optional.ofNullable(diagnostic.docUrl()); + } + + /** + * Whether running the same statement again could succeed. True for a write + * that lost to a concurrent one, since nothing of it was applied. False for + * text that will not parse, and false for a statement the caller + * interrupted, which did not fail so much as stop. + * + *

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 caret() { + String excerpt = diagnostic.excerpt(); + int column = diagnostic.column(); + if (excerpt == null || column < 1) { + return Optional.empty(); + } + return Optional.of(excerpt + System.lineSeparator() + " ".repeat(column - 1) + "^"); + } + + /** + * Where in a statement a condition was raised. + * + * @param line the line, counting from one + * @param column the column on that line in characters, counting from one, and a + * valid index into {@link ZuException#excerpt()} after subtracting that one + * @param offset bytes into the statement, counting from zero, for a caller that + * slices the text rather than printing it, always on a character boundary + */ + public record Position(int line, int column, int offset) {} +} diff --git a/zudb/src/main/java/dev/zudb/ZuInternalException.java b/zudb/src/main/java/dev/zudb/ZuInternalException.java new file mode 100644 index 0000000..cb31a72 --- /dev/null +++ b/zudb/src/main/java/dev/zudb/ZuInternalException.java @@ -0,0 +1,17 @@ +package dev.zudb; + +/** + * A failure the engine could not describe as a condition: a corrupt file, an + * assumption that did not hold, a call this build does not implement. + * + *

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: + * + *

{@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"));
+ *     }
+ * }
+ * }
+ * + *

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. + * + *

Failures

+ * + *

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. + * + *

Lifetimes

+ * + *

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. + * + *

Stability

+ * + *

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()); + } +}