diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcc9e7d..8eb3779 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,12 @@ jobs: echo "engine $engine_abi, client $client_abi" test "$engine_abi" = "$client_abi" + # The shim is written against this header and opens a libzu at run + # time, so a copy that has drifted compiles clean and then reads a + # struct that moved. This is the step that stops it drifting. + - name: The vendored header is the engine's header + run: diff -u engine/crates/zu-capi/include/zu.h zudb-jni/src/main/c/zu.h + - name: Build libzu working-directory: engine run: cargo build --release -p zu-capi @@ -100,6 +106,12 @@ jobs: test -n "$lib" echo "ZU_LIBRARY=$GITHUB_WORKSPACE/$lib" >> "$GITHUB_ENV" + # One platform, this runner's, which is all a runner can build and + # all the suite here needs. The other six are built in the release, + # on runners of their own. + - name: Build the JNI shim + run: ./scripts/build-shim.sh + - run: mvn $MAVEN_ARGS test # The suite again with assertions on everywhere, including the ones @@ -115,6 +127,66 @@ jobs: - run: java -jar zudb-bench/target/benchmarks.jar -f 1 -wi 1 -i 1 -r 1s -w 1s + # The JNI provider on the JDKs it exists for. Panama is not there on + # 17 or 21, so on those two this is the only way to call the engine at + # all, and a client that claims 17 and is only ever tested on 25 is a + # client that claims 17. + # + # It runs the same cases the Panama provider runs, out of the same + # artifact, so a difference between the two providers is a red job here + # rather than something a user finds. + jni: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + java: ["17", "21"] + 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 + + - name: The vendored header is the engine's header + run: diff -u engine/crates/zu-capi/include/zu.h zudb-jni/src/main/c/zu.h + + - 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" + + - name: Build the JNI shim + run: ./scripts/build-shim.sh + + # Named modules rather than the whole reactor, because the Panama + # provider compiles to release 25 and this JDK cannot be asked for + # that. What is left is exactly what a caller on 17 gets. + - run: mvn $MAVEN_ARGS -pl zudb,zudb-tck,zudb-jni -am test + + # Again with every assertion on, including the JDK's own. A wrong + # length handed to NewDirectByteBuffer is the difference between a + # test that fails and a test that reads somebody else's memory. + - run: mvn $MAVEN_ARGS -pl zudb,zudb-tck,zudb-jni -am test -Dzu.test.args="-ea -esa" + # The claim the zudb-native artifact makes is that a user who added a # dependency and installed nothing has an engine. Nothing in the test # suite can check that, because the suite is told where the library is diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a032e0..0d82ec0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,82 @@ env: MAVEN_ARGS: -B -ntp jobs: + # The JNI shim, one build a platform, because a C toolchain is a thing + # a runner is rather than a thing a runner can be asked for. Seven + # jobs of about a minute each, and the release job below collects what + # they made into one jar. + # + # The shim links against nothing: it opens libzu at run time and + # resolves what it calls, so none of this needs Rust and none of it + # needs the engine. + shims: + strategy: + fail-fast: false + matrix: + include: + - platform: linux-amd64 + runs-on: ubuntu-latest + - platform: linux-arm64 + runs-on: ubuntu-24.04-arm + - platform: linux-amd64-musl + runs-on: ubuntu-latest + container: alpine:3.21 + - platform: linux-arm64-musl + runs-on: ubuntu-24.04-arm + container: alpine:3.21 + - platform: darwin-amd64 + runs-on: macos-latest + # An arm runner building for intel, because the shim is one + # file with no dependencies and clang takes an -arch. + cflags: -arch x86_64 + - platform: darwin-arm64 + runs-on: macos-latest + - platform: windows-amd64 + runs-on: windows-latest + runs-on: ${{ matrix.runs-on }} + container: ${{ matrix.container }} + steps: + - uses: actions/checkout@v5 + + # The musl builds run in a container that has neither a compiler + # nor a JDK, and setup-java has no musl build to give it, so both + # come from the distribution's own packages. + - name: A compiler and the JDK headers + if: matrix.container + run: apk add --no-cache bash build-base openjdk21 + + - uses: actions/setup-java@v5 + if: ${{ !matrix.container }} + with: + distribution: temurin + java-version: "21" + + - name: Build the shim + shell: bash + env: + CFLAGS: ${{ matrix.cflags }} + run: | + set -eu + if [ -n "${{ matrix.container }}" ]; then + export JAVA_HOME=/usr/lib/jvm/java-21-openjdk + fi + ./scripts/build-shim.sh + # The script names the directory after the machine it ran on, + # which is right everywhere but the one cross build, so that + # one is put where it belongs. + if [ ! -d "zudb-jni/shim/${{ matrix.platform }}" ]; then + mv zudb-jni/shim/* "zudb-jni/shim/${{ matrix.platform }}" + fi + ls -l "zudb-jni/shim/${{ matrix.platform }}" + + - uses: actions/upload-artifact@v4 + with: + name: shim-${{ matrix.platform }} + path: zudb-jni/shim/${{ matrix.platform }} + if-no-files-found: error + release: + needs: shims runs-on: ubuntu-latest permissions: contents: read @@ -75,6 +150,23 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: ./scripts/stage-natives.sh "$TAG" + # The seven shims the jobs above built, laid out the way + # build-shim.sh would have laid them out if one machine could have + # built them all. + - uses: actions/download-artifact@v4 + with: + pattern: shim-* + path: zudb-jni/shim + + - name: The shims are seven and are named right + run: | + set -eu + for dir in zudb-jni/shim/shim-*; do + mv "$dir" "zudb-jni/shim/$(basename "$dir" | sed 's/^shim-//')" + done + chmod -R a+rX zudb-jni/shim + ls -lR zudb-jni/shim + # The suite needs an engine, and one of the seven that was just # staged is the platform this runner is, so it runs against the # library that is about to be published rather than against a build @@ -84,7 +176,10 @@ jobs: set -eu echo "ZU_LIBRARY=$PWD/zudb-native/lib/linux-amd64/libzu.so" >> "$GITHUB_ENV" - - run: mvn $MAVEN_ARGS -Pnatives test + # The shim for this runner is one of the seven already downloaded, + # so the suite runs against the same file that is about to be + # published rather than against one built here. + - run: mvn $MAVEN_ARGS -Pnatives,shims test - name: Build, sign and upload if: github.event_name == 'push' || inputs.publish @@ -92,14 +187,14 @@ jobs: CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - run: mvn $MAVEN_ARGS -Prelease,natives -DskipTests deploy + run: mvn $MAVEN_ARGS -Prelease,natives,shims -DskipTests deploy # What a dispatch with publish off is for: everything up to the # upload, so that a change to the packaging can be checked without # a version being spent on it. - name: Build what would have been uploaded if: github.event_name != 'push' && !inputs.publish - run: mvn $MAVEN_ARGS -Prelease,natives -DskipTests -Dgpg.skip=true package + run: mvn $MAVEN_ARGS -Prelease,natives,shims -DskipTests -Dgpg.skip=true package - uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index 478b4d5..1c9ba5a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ target/ # What scripts/stage-natives.sh downloads. A build of the engine is a # thing a release fetches, not a thing a git history carries. zudb-native/lib/ + +# What scripts/build-shim.sh compiles. One platform a run, so a checked +# in copy would be a copy of whichever machine ran it last. +zudb-jni/shim/ diff --git a/README.md b/README.md index 739b235..b603422 100644 --- a/README.md +++ b/README.md @@ -261,21 +261,22 @@ On the module path the artifact needs `--add-modules dev.zudb.natives`. Nothing ## How it binds -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. +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. + +An SDK that requires a recent JDK in 2026 excludes a large part of the enterprise ecosystem, so there is a JNI provider too. It is a small C shim, one build a platform, and it is the only native code in this repository that is ours. Four decisions in it are worth knowing about. It does not link against `libzu`, it opens one at run time and resolves what it calls, so building it needs a C compiler and the JDK headers and nothing else, and a shim built today opens a `libzu` built next year at the same ABI. Every string crosses as a `byte[]` rather than as a `jstring`, because JNI's own conversions speak modified UTF-8 and the engine validates the real thing, so an emoji handed to `NewStringUTF` is a byte sequence the engine refuses. One symbol is exported under the name JNI derives, and it registers the other hundred and two from a static table, because `JNI_OnLoad` cannot find a class that a module path or an application server's class loader holds. And a failure is built on the Java side, so the two providers cannot come to disagree about which exception a GQLSTATUS class names. -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 25 | the FFM provider, selected automatically | -| `dev.zudb:zudb-jni` | Java 17 | the fallback provider | +| `dev.zudb:zudb-jni` | Java 17 | the JNI provider, with the shim for all seven platforms | | `dev.zudb:zudb-arrow` | Java 17 | the Arrow reader, the only artifact that names arrow-java | | `dev.zudb:zudb-native` | | the `libzu` binaries, all platforms or one by classifier | -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. +A `ServiceLoader` picks the provider at run time and application code never names one, and the same suite of cases runs against both every build, so a difference between the two is a red job rather than something you find. Add `zudb-jni` beside `zudb` on 17 through 21, add `zudb-ffm` on 25 and later, or add both and let the loader pick. 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. +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` or `--enable-native-access=dev.zudb.jni` for whichever provider is in play, and the FFM 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 @@ -298,9 +299,12 @@ The engine has no DDL yet, so there is no `CREATE NODE TABLE` and no statement i ## Building ```sh +scripts/build-shim.sh mvn test -Dzu.library=/path/to/libzu.dylib ``` +The first line builds the JNI shim for this machine, and it is needed once rather than once a build, since the output lives outside `target/`. It needs a C compiler and a `JAVA_HOME` with headers in it. A checkout with no engine build beside it is still green, because the engine is a different repository and its absence is an ordinary state to be in, but a checkout with no shim in it is red, because the shim is ours and a suite that quietly skipped half its providers would be worth nothing. + The library is looked for in four places, in order: `-Dzu.library`, then `ZU_LIBRARY`, then a `zudb-native` artifact on the class path, then the platform's own search. A named path is first because a bisect and a bug report both start by pointing this at a build, and the platform's search is last because it is the one that can pick up a library nobody in the process chose. `Zu.library()` and `Zu.source()` say which file was loaded and which of the four it came from, and a failure to bind lists what was ruled out on the way. The tests skip rather than fail when no `libzu` is reachable, so a checkout with no engine build beside it is still green. The `zudb-native` module is not built unless it is asked for, because what it packages is downloaded rather than compiled: diff --git a/pom.xml b/pom.xml index 288138a..d42b48e 100644 --- a/pom.xml +++ b/pom.xml @@ -49,7 +49,9 @@ zudb + zudb-tck zudb-ffm + zudb-jni zudb-arrow zudb-bench @@ -103,6 +105,11 @@ zudb ${project.version} + + dev.zudb + zudb-tck + ${project.version} + org.apache.arrow arrow-bom @@ -223,6 +230,7 @@ validated zudb-bench + zudb-tck diff --git a/scripts/build-shim.sh b/scripts/build-shim.sh new file mode 100755 index 0000000..60a0b1e --- /dev/null +++ b/scripts/build-shim.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Build the JNI shim for the machine this runs on. +# +# Usage: scripts/build-shim.sh [output directory] +# +# The shim is one C file and it links against nothing: it opens libzu at +# run time and resolves what it calls, so all this needs is a C compiler +# and the JDK headers. That is what makes seven platform builds cheap +# enough to do on seven runners, and it is why a shim built today opens +# a libzu built next year at the same ABI. +# +# One platform a run, the host's, because a C cross compiler for six +# other platforms is not something a laptop has and not something worth +# pretending about. CI runs this on each runner of its matrix and +# collects the results into the tree that zudb-jni packages. +set -euo pipefail + +here="$(cd "$(dirname "$0")/.." && pwd)" +out="${1:-$here/zudb-jni/shim}" +src="$here/zudb-jni/src/main/c/zudb_jni.c" + +if [ -z "${JAVA_HOME:-}" ]; then + echo "JAVA_HOME is not set, and the shim is built against the JDK headers" >&2 + exit 1 +fi +if [ ! -f "$JAVA_HOME/include/jni.h" ]; then + echo "$JAVA_HOME has no include/jni.h, so it is a JRE rather than a JDK" >&2 + exit 1 +fi + +# The same two names every other client of this engine uses, so that the +# resource path a jar carries and the directory a release archive holds +# are spelled the same on all of them. +case "$(uname -s)" in + Darwin) goos=darwin ;; + Linux) goos=linux ;; + MINGW*|MSYS*|CYGWIN*) goos=windows ;; + *) echo "no rule for $(uname -s)" >&2; exit 1 ;; +esac +case "$(uname -m)" in + x86_64|amd64) goarch=amd64 ;; + arm64|aarch64) goarch=arm64 ;; + *) echo "no rule for $(uname -m)" >&2; exit 1 ;; +esac + +platform="$goos-$goarch" +# Alpine is not a smaller Linux, it is a different one, and the shim +# built against glibc does not load there. Asked of the loader rather +# than of /etc/os-release, which a container can be missing. +if [ "$goos" = linux ] && ldd /bin/sh 2>&1 | grep -q musl; then + platform="$platform-musl" +fi + +mkdir -p "$out/$platform" + +case "$goos" in + darwin) + library="libzudb_jni.dylib" + # CFLAGS unquoted on purpose: it is how the release passes an + # -arch, and one runner building for both of this platform's + # instruction sets is cheaper than two runners. + # shellcheck disable=SC2086 + "${CC:-cc}" -O2 -fPIC -shared -fvisibility=hidden ${CFLAGS:-} \ + -Wall -Wextra -Werror \ + -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/darwin" \ + -o "$out/$platform/$library" "$src" + ;; + linux) + library="libzudb_jni.so" + # -ldl for the glibc older than 2.34 that still has dlopen in a + # library of its own, and harmless on the ones that do not. + # shellcheck disable=SC2086 + "${CC:-cc}" -O2 -fPIC -shared -fvisibility=hidden ${CFLAGS:-} \ + -Wall -Wextra -Werror \ + -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/linux" \ + -o "$out/$platform/$library" "$src" -ldl + ;; + windows) + library="zudb_jni.dll" + if command -v cl >/dev/null 2>&1; then + # cl writes its output beside its input unless told, and it + # is told in the compiler's spelling rather than the shell's. + cl //nologo //O2 //W3 //WX //LD \ + //I"$JAVA_HOME/include" //I"$JAVA_HOME/include/win32" \ + "$src" //Fe:"$out/$platform/$library" //Fo:"$out/$platform/" + else + "${CC:-x86_64-w64-mingw32-gcc}" -O2 -shared \ + -Wall -Wextra -Werror \ + -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/win32" \ + -o "$out/$platform/$library" "$src" + fi + ;; +esac + +# A library a loader has to be able to map, whatever the umask was. +chmod 0755 "$out/$platform/$library" +echo "$platform $(du -h "$out/$platform/$library" | cut -f1)" diff --git a/zudb-arrow/pom.xml b/zudb-arrow/pom.xml index 34bde75..508944a 100644 --- a/zudb-arrow/pom.xml +++ b/zudb-arrow/pom.xml @@ -41,15 +41,23 @@ arrow-memory-core - + dev.zudb zudb-ffm ${project.version} test + + dev.zudb + zudb-jni + ${project.version} + test + org.apache.arrow arrow-memory-unsafe @@ -96,6 +104,31 @@ false --enable-native-access=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --sun-misc-unsafe-memory-access=allow ${zu.test.args} + + + + default-test + + + ffm + + + + + the same cases over JNI + test + test + + + jni + + ${project.build.directory}/surefire-reports-jni + + + diff --git a/zudb-ffm/pom.xml b/zudb-ffm/pom.xml index d2bce5d..cfcc017 100644 --- a/zudb-ffm/pom.xml +++ b/zudb-ffm/pom.xml @@ -29,6 +29,12 @@ dev.zudb zudb + + + dev.zudb + zudb-tck + test + @@ -69,6 +75,15 @@ This is the same flag the README tells a user on the module path to pass. --> --enable-native-access=dev.zudb.ffm ${zu.test.args} + + + ffm + + + dev.zudb:zudb-tck + diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/ReachabilityTest.java b/zudb-ffm/src/test/java/dev/zudb/ffm/ReachabilityTest.java index 06eee67..9cfc92a 100644 --- a/zudb-ffm/src/test/java/dev/zudb/ffm/ReachabilityTest.java +++ b/zudb-ffm/src/test/java/dev/zudb/ffm/ReachabilityTest.java @@ -5,6 +5,7 @@ import dev.zudb.Connection; import dev.zudb.Frame; import dev.zudb.Result; +import dev.zudb.tck.Libzu; import java.lang.foreign.AddressLayout; import java.lang.foreign.FunctionDescriptor; import java.lang.foreign.MemoryLayout; diff --git a/zudb-jni/pom.xml b/zudb-jni/pom.xml new file mode 100644 index 0000000..5fc5d9a --- /dev/null +++ b/zudb-jni/pom.xml @@ -0,0 +1,175 @@ + + + + 4.0.0 + + + dev.zudb + zudb-parent + 0.11.0-SNAPSHOT + + + zudb-jni + zu for the JVM: JNI provider + The zu provider over JNI, for JDK 17 and later. + + + + ${project.basedir}/shim + + + + + + + dev.zudb + zudb + + + + dev.zudb + zudb-tck + test + + + + + + + ${project.basedir}/src/main/resources + + + + ${zu.shim.dir} + dev/zudb/jni + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${zu.release.api} + + + + org.apache.maven.plugins + maven-jar-plugin + + + + + ALL-UNNAMED + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + ${zu.jni.access} ${zu.test.args} + + + jni + + + dev.zudb:zudb-tck + + + + + + + + + + native-access + + [22,) + + + --enable-native-access=dev.zudb.jni + + + + + + shims + + + + org.apache.maven.plugins + maven-enforcer-plugin + ${maven.enforcer.plugin.version} + + + the shims are built + enforce + + + + + ${zu.shim.dir}/linux-amd64/libzudb_jni.so + ${zu.shim.dir}/linux-arm64/libzudb_jni.so + ${zu.shim.dir}/linux-amd64-musl/libzudb_jni.so + ${zu.shim.dir}/linux-arm64-musl/libzudb_jni.so + ${zu.shim.dir}/darwin-amd64/libzudb_jni.dylib + ${zu.shim.dir}/darwin-arm64/libzudb_jni.dylib + ${zu.shim.dir}/windows-amd64/zudb_jni.dll + + + shim/ is not complete: every platform is built by + scripts/build-shim.sh on a runner of its own + + + + + + + + + + + + diff --git a/zudb-jni/src/main/c/zu.h b/zudb-jni/src/main/c/zu.h new file mode 100644 index 0000000..93e66ad --- /dev/null +++ b/zudb-jni/src/main/c/zu.h @@ -0,0 +1,1032 @@ +/* libzu: C API for the zu embedded property-graph database. + * + * A zu_database is a path and a configuration that have been checked + * against a real file. It holds no descriptor and no cache, so it is + * thread-safe and shareable. A zu_conn is the state that cannot be + * shared: a file handle, the caches, and the plans compiled against a + * catalog. A host that queries from four threads opens one database and + * connects four times. + * + * A connection may move between threads but must not be used from two + * at once; a call that finds one already in use answers + * ZU_MISUSE_CONCURRENT rather than corrupting a cache. Statements + * belong to the connection they were prepared on, and using one after + * that connection closes answers ZU_MISUSE_CLOSED rather than following + * a dangling pointer. Results own their rows outright, so a result + * stays readable after its connection has gone back to a pool. + * + * Every pointer an accessor returns (column names, column buffers, cell + * strings) stays valid exactly until zu_result_free on the result that + * produced it, or until zu_result_arrow, which is the one call that + * spends a result rather than reading it and hands those same bytes to + * an Arrow consumer. Every *_free and *_close call here is a no-op on + * NULL. + * + * Every fallible call returns a zu_status and writes what it produced + * through an out-parameter, because one returned pointer cannot say + * both "this failed" and "this succeeded and there is nothing here". + * The out-parameter is written on every path, NULL when there is + * nothing to point at, so a caller who ignores the status is never + * left holding a pointer from the call before. + * + * What a user reads comes back separately. The calls that can fail for + * a reason the engine has something to say about take a zu_error ** as + * their last parameter; on anything but ZU_OK they write a handle + * there, which the caller reads through the zu_error_* accessors and + * releases with zu_error_free. Passing NULL for that parameter + * discards the error and keeps the status. The accessors below take no + * error handle: their failures are structural, and the status names + * each one exactly. + * + * Strings cross this boundary as a pointer and a length, since most + * source languages have counted strings and a NUL-terminated parameter + * makes every one of them copy a string that already knew how long it + * was. Each of those calls has a _z variant for a caller who genuinely + * has a C string. A NULL pointer with a zero length is the empty + * string, not an error. + */ +#ifndef ZU_H +#define ZU_H + +#include +#include + +/* The revision of this ABI (dx/02 section 8), which is what a build + * system tests when it has to compile one way against 0.12 and another + * against what comes next. The two numbers are counts and not decimals, + * so 0.12 is the revision after 0.11 and a caller comparing them + * compares each on its own. `cargo xtask package` holds it to the + * constant the rest of the workspace reports, `zu version` included, + * so a header and a binary that disagree is a failed check rather than + * a caller's afternoon. */ +#define ZU_ABI_VERSION "0.12" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct zu_database zu_database; +typedef struct zu_conn zu_conn; +typedef struct zu_stmt zu_stmt; +typedef struct zu_result zu_result; +typedef struct zu_error zu_error; +/* One cell of a result, borrowed from it. Not a handle: nothing to + * free, and it lives exactly as long as the result does. */ +typedef struct zu_value zu_value; +/* A database being built. See the bulk load section at the end. */ +typedef struct zu_loader zu_loader; +/* Rows on their way into a table that already exists. See the appending + * section at the end. */ +typedef struct zu_appender zu_appender; +/* Columns of the host's own memory, named as a table of a connection + * and read where they lie. See the frames section at the end. */ +typedef struct zu_frame zu_frame; + +/* The name a connection had before the database was split out of it. + * Kept for one release, along with zu_close below, so that code written + * against v0 still compiles; both go at the freeze. */ +typedef zu_conn zu_session; + +/* What a call answers. The GQLSTATUS condition a user reads is on the + * error handle, not here, which is what keeps this from growing a + * value per condition. Values are fixed; new ones are appended, never + * inserted. + * + * The gaps that remain are held for the rest of the set dx/02 §6 names + * and nothing produces yet: 1 for ZU_ROW and 12 for ZU_OOM. Reserving + * the numbers is free, and it is what lets those land beside the value + * they belong with instead of at the end because the end was where + * there was room. */ +typedef enum zu_status { + /* The call did what it was asked and wrote its out-parameter. */ + ZU_OK = 0, + /* Well formed, and there is nothing to read: a column of a result + * with no rows. The out-parameter is NULL. This is the case a + * returned NULL could not tell apart from failure. */ + ZU_DONE = 2, + /* The engine refused the work; the error handle says why. */ + ZU_ERROR = 3, + /* The caller broke the contract in this header: a NULL handle, an + * index out of range, an accessor asked for a column that does not + * hold what it reads, or a string that is not UTF-8. Nothing was + * done, and nothing is wrong with the database. */ + ZU_MISUSE = 4, + /* Two threads used one connection at once. Nothing was done. Connect + * again rather than share. */ + ZU_MISUSE_CONCURRENT = 5, + /* A statement was used after its connection closed. Nothing was + * done; the statement handle is still safe to close. */ + ZU_MISUSE_CLOSED = 6, + /* The caller stopped the statement while it was running. Nothing is + * wrong with the connection and the next call on it runs normally. */ + ZU_INTERRUPTED = 7, + /* A write lost to a concurrent one. */ + ZU_CONFLICT = 8, + /* The file says something that cannot be true. */ + ZU_CORRUPT = 9, + /* Not implemented in this build, as against declined. */ + ZU_UNSUPPORTED = 10, + /* The operating system refused a read or a write. */ + ZU_IO = 11 +} zu_status; + +/* Severity, from zu_error_severity. */ +#define ZU_SEVERITY_SUCCESS 0 +#define ZU_SEVERITY_NO_DATA 1 +#define ZU_SEVERITY_WARNING 2 +#define ZU_SEVERITY_INFORMATIONAL 3 +#define ZU_SEVERITY_EXCEPTION 4 + +/* Cell type tags from zu_result_cell_type. */ +#define ZU_TYPE_NULL 0 +#define ZU_TYPE_BOOL 1 +#define ZU_TYPE_INT 2 +#define ZU_TYPE_FLOAT 3 +#define ZU_TYPE_STR 4 +#define ZU_TYPE_NODE 5 +#define ZU_TYPE_REL 6 +#define ZU_TYPE_LIST 7 +#define ZU_TYPE_PATH 8 +#define ZU_TYPE_TEMPORAL 9 +#define ZU_TYPE_RECORD 10 +/* GV60 and GV61, the two reference values. Neither reads through an + accessor: a handle has no contents to hand over, so the tag is the + whole of what a binding can say about the cell. */ +#define ZU_TYPE_GRAPH 11 +#define ZU_TYPE_BINDING_TABLE 12 + +/* Which temporal a temporal cell is, from zu_value_temporal. The unit + * follows the kind: days for a date, months for a year-month duration, + * nanoseconds for the other five. One tag rather than a type per arm, + * because a host that reads temporals reads all of them and a switch + * over seven is the shape it wants. */ +#define ZU_TEMPORAL_DATE 0 +#define ZU_TEMPORAL_LOCAL_TIME 1 +#define ZU_TEMPORAL_ZONED_TIME 2 +#define ZU_TEMPORAL_LOCAL_DATETIME 3 +#define ZU_TEMPORAL_ZONED_DATETIME 4 +#define ZU_TEMPORAL_DURATION_YEAR_MONTH 5 +#define ZU_TEMPORAL_DURATION_DAY_TIME 6 + +/* What zu_frame_col_int is told when a column of integers is a column + * of integers and nothing else. Negative so that the kinds above, which + * count up from nought and are the rest of what that parameter takes, + * keep their numbering. */ +#define ZU_FRAME_PLAIN (-1) + +/* Static version string; do not free. */ +const char *zu_version(void); + +/* Errors. An error carries the status its call returned, the GQLSTATUS + * code, the standard's name for it, the severity, whether it is worth + * retrying, where in the statement it happened, the line that place is + * on, and the message, as fields rather than as one string to parse: + * the code picks which exception class a binding raises, the severity + * decides whether it raises at all, and neither survives being + * formatted into prose and parsed back out. + * + * The strings live until zu_error_free, and each len out-parameter may + * be NULL. A string a failure does not carry is NULL rather than + * empty, since no condition and an empty condition are different + * facts: zu_error_code, zu_error_standard_text and zu_error_doc_url + * are all NULL for an engine-internal failure, which has no code + * rather than one that would be a guess. + * + * zu_error_message is zu's own account, naming the table, the token or + * the value. zu_error_standard_text is the standard's words for the + * condition class and subclass, which is what a conformance harness + * grades. zu_error_doc_url is where that condition is written up, so a + * binding hands a reader a page rather than five characters to search + * for. + * + * zu_error_retryable answers 1 when running the same statement again + * could succeed, 0 when it could not, -1 for a NULL error. A write + * that lost to a concurrent one is the 1: nothing of it was applied. + * Text that will not parse is the 0, and so is 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. + * + * zu_error_position writes the line and column the condition was + * raised at, both 1-based, the column counted in characters so a line + * of multi-byte text does not read as wider than it looks, and + * zu_error_offset writes the same place as a 0-based byte index into + * the statement, for a caller that slices the text rather than + * printing it. Both answer ZU_OK and write when there is a position, + * ZU_DONE and write nothing when there is not, and ZU_MISUSE for a + * NULL error. Not every failure has one: a division by zero happens + * while the statement runs and has no token to point at, and an io + * error has no statement at all. Every out-parameter may be NULL. The + * offset is always on a character boundary, so slicing at it cannot + * split a character in half. + * + * zu_error_excerpt is the line that position is on, without its + * newline, which the column counts characters into: a caller has both + * halves of a caret without having kept the statement text. It is NULL + * 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. + * + * The message says all of this in words and keeps saying it, so + * printing it alone is still a complete report. The fields are for the + * caller that would rather underline the token than read the numbers + * back out of the sentence. */ +zu_status zu_error_status(const zu_error *e); +const char *zu_error_message(const zu_error *e, size_t *len); +const char *zu_error_code(const zu_error *e, size_t *len); +const char *zu_error_standard_text(const zu_error *e, size_t *len); +const char *zu_error_doc_url(const zu_error *e, size_t *len); +int32_t zu_error_severity(const zu_error *e); /* -1 for a NULL error */ +int32_t zu_error_retryable(const zu_error *e); /* -1 for a NULL error */ +zu_status zu_error_position(const zu_error *e, uint32_t *line, uint32_t *column); +zu_status zu_error_offset(const zu_error *e, uint32_t *offset); +const char *zu_error_excerpt(const zu_error *e, size_t *len); +void zu_error_free(zu_error *e); + +/* How a database is opened. The only struct that crosses this boundary + * by value, and it does so because it is versioned: struct_size comes + * first, the caller sets it with zu_config_init, and every field after + * it is read only when that size says the caller's struct is long + * enough to hold it. A field appended later is therefore invisible to a + * binding compiled against this header, rather than fatal to it. + * + * Zero means the default in every field, so a zeroed struct with + * struct_size set opens the same database as a NULL config. */ +typedef struct zu_config { + size_t struct_size; /* sizeof(zu_config); set this */ + size_t memory_limit; /* bytes the caches may hold; 0 for the default */ + size_t threads; /* query workers; 0 to let the executor pick, 1 for sequential */ + int32_t read_only; /* nonzero opens a descriptor this process cannot write through */ +} zu_config; + +zu_status zu_config_init(zu_config *cfg); + +/* Sets one option by name, so a binding can forward a user's option map + * without this ABI growing a setter per option and without the binding + * hard-coding a layout it would have to keep in step. Keys are + * memory_limit, threads, and read_only. The first two take a decimal + * count; a suffix such as MB is deliberately not parsed here, because + * its two readings differ by 4.9% and the language the user typed it in + * is a better place to decide which they meant. read_only takes true, + * false, 1, or 0. An unrecognized key is refused and named. */ +zu_status zu_config_set(zu_config *cfg, const char *key, size_t key_len, const char *value, + size_t value_len, zu_error **err); +zu_status zu_config_set_z(zu_config *cfg, const char *key, const char *value, zu_error **err); + +/* Database lifecycle. cfg may be NULL for the defaults. The file is + * opened once here and closed again, so a path that is not a zu1 file + * fails now rather than on the first connection. Closing a database + * does not close the connections opened from it: each holds its own + * file handle, and this releases only the path and the configuration. */ +zu_status zu_database_open(const char *path, size_t path_len, const zu_config *cfg, + zu_database **out, zu_error **err); +zu_status zu_database_open_z(const char *path, const zu_config *cfg, zu_database **out, + zu_error **err); +/* 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 host that wants either one has + * zu_database_open to fall back to and a decision to make about which. + * + * What it makes is a valid database with nothing in it, which is what a + * host has to start from to run any statement at all. Bulk load below + * makes a database with a table in it, and until this call there was no + * other way for a C host to have one. */ +zu_status zu_database_create(const char *path, size_t path_len, const zu_config *cfg, + zu_database **out, zu_error **err); +zu_status zu_database_create_z(const char *path, const zu_config *cfg, zu_database **out, + zu_error **err); +/* Creates a database that never touches the filesystem. The blocks a + * file would hold are held in memory instead, and the log beside it + * too, so everything above this point runs unchanged and nothing + * survives the process. + * + * Every call makes a database of its own. Two connections on one handle + * are two views of one graph; two handles share nothing. + * + * zu_database_path still answers, with a name that is not a path: it is + * what this process calls the database, which is what an error message + * needs and not something to open. zu_database_is_memory is the way to + * ask rather than to parse that name, and returns ZU_OK for a database + * in memory and ZU_DONE for one on disk. */ +zu_status zu_database_memory(const zu_config *cfg, zu_database **out, zu_error **err); +zu_status zu_database_is_memory(const zu_database *db); +zu_status zu_database_path(const zu_database *db, const char **out, size_t *len); +void zu_database_close(zu_database *db); + +/* Connection lifecycle. A connection keeps the catalog, statistics, + * plan cache, and 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 zu_connect + * once per worker instead of sharing one. + * + * zu_open is the convenience for a host that wants exactly one: it + * opens a database with the default configuration, connects once, and + * returns the connection. Nothing outlives the database it discards, + * since the connection carries its own file handle. + * + * Closing is itself a use of the connection and obeys the same rule as + * every other one. */ +zu_status zu_connect(zu_database *db, zu_conn **out, zu_error **err); +zu_status zu_open(const char *path, size_t path_len, zu_conn **out, zu_error **err); +zu_status zu_open_z(const char *path, zu_conn **out, zu_error **err); +/* The same convenience over a database that is not there yet: + * zu_database_create and one connection on it. */ +zu_status zu_create(const char *path, size_t path_len, zu_conn **out, zu_error **err); +zu_status zu_create_z(const char *path, zu_conn **out, zu_error **err); +/* And the same over a database that is nowhere: one scratch graph and + * one connection on it, which go together when the connection closes. */ +zu_status zu_memory(zu_conn **out, zu_error **err); +/* A second connection on the database a connection is already on, made + * without a path. This is what a pool calls once it has handed the + * database handle 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 makes it a connection of its own. */ +zu_status zu_conn_duplicate(zu_conn *conn, zu_conn **out, zu_error **err); +void zu_conn_close(zu_conn *conn); +void zu_close(zu_conn *conn); /* the old name; goes at the freeze */ + +/* Cancellation and progress. + * + * zu_conn_interrupt is the one call here meant to be made from another + * thread while a connection is in use, and it does not answer + * ZU_MISUSE_CONCURRENT for it: a cancellation that had to wait for the + * connection to be free could only arrive after the statement it was + * meant to stop. The statement stops at the next boundary the executor + * checks, which is a chunk of rows rather than the end of the query, + * and answers ZU_INTERRUPTED. 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. + * + * zu_conn_rows_read is the same watch by polling: how many rows the + * 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. + * + * zu_conn_set_progress asks to be called back every interval_ms while a + * statement runs, with the rows read and the milliseconds since it + * started; returning 0 from the callback stops the statement exactly as + * zu_conn_interrupt would. A NULL callback takes the arrangement back + * and ignores interval_ms; a callback with an interval of zero is + * ZU_MISUSE, since a period of nothing is not a period. The + * arrangement belongs to the connection and covers every statement + * after it, and a statement already running keeps the one it started + * with. + * + * The callback runs on a thread of this library's, one per statement, + * never two at once and never after the call it belongs to has + * returned. It is not called on the thread that asked for the + * statement, because that thread is inside the executor; what follows + * from that is that user_data has to be usable from another thread, and + * that a callback must not call back into this library on the + * connection it is reporting on. */ +zu_status zu_conn_interrupt(zu_conn *conn); +zu_status zu_conn_rows_read(zu_conn *conn, uint64_t *out); +typedef int (*zu_progress_fn)(void *user_data, uint64_t rows, uint64_t ms); +zu_status zu_conn_set_progress(zu_conn *conn, zu_progress_fn cb, void *user_data, + uint64_t interval_ms); + +/* One-shot statement without parameters. */ +zu_status zu_query(zu_conn *conn, const char *q, size_t q_len, zu_result **out, zu_error **err); +zu_status zu_query_z(zu_conn *conn, const char *q, zu_result **out, zu_error **err); + +/* Prepared statements. Bindings live on the statement and survive + * zu_execute, so a loop rebinds only what changed. Binding a name + * again replaces its value. The bind calls return ZU_MISUSE for a NULL + * statement or a name that is not UTF-8, ZU_MISUSE_CLOSED once the + * connection has closed, and take no error handle because that is all + * they can say. */ +zu_status zu_prepare(zu_conn *conn, const char *q, size_t q_len, zu_stmt **out, zu_error **err); +zu_status zu_prepare_z(zu_conn *conn, const char *q, zu_stmt **out, zu_error **err); +zu_status zu_bind_i64(zu_stmt *stmt, const char *name, size_t name_len, int64_t v); +zu_status zu_bind_i64_z(zu_stmt *stmt, const char *name, int64_t v); +zu_status zu_bind_f64(zu_stmt *stmt, const char *name, size_t name_len, double v); +zu_status zu_bind_f64_z(zu_stmt *stmt, const char *name, double v); +/* A boolean, as an int: nought is false and anything else is true. */ +zu_status zu_bind_bool(zu_stmt *stmt, const char *name, size_t name_len, int v); +zu_status zu_bind_bool_z(zu_stmt *stmt, const char *name, int v); +zu_status zu_bind_str(zu_stmt *stmt, const char *name, size_t name_len, const char *v, + size_t v_len); +zu_status zu_bind_str_z(zu_stmt *stmt, const char *name, const char *v); +/* A temporal parameter, as one ZU_TEMPORAL_ kind and the count in the + * unit that kind implies, which is zu_value_temporal read backwards. + * The offset is minutes east of UTC and is ignored by every kind but + * the two zoned ones. A kind that is not one of the seven, or an + * offset or a count the kind cannot hold, is ZU_MISUSE. */ +zu_status zu_bind_temporal(zu_stmt *stmt, const char *name, size_t name_len, int32_t kind, + int64_t count, int32_t offset); +zu_status zu_bind_temporal_z(zu_stmt *stmt, const char *name, int32_t kind, int64_t count, + int32_t offset); +zu_status zu_bind_null(zu_stmt *stmt, const char *name, size_t name_len); +zu_status zu_bind_null_z(zu_stmt *stmt, const char *name); +zu_status zu_execute(zu_stmt *stmt, zu_result **out, zu_error **err); +void zu_stmt_close(zu_stmt *stmt); + +/* Transactions. + * + * Every statement outside one is already a transaction of its own, so + * these do not turn transactions on. What they do is make several + * statements one: what they wrote is kept by zu_commit or unmade by + * zu_rollback, and nothing between the two is visible to another + * connection until the commit publishes it. + * + * They are the statements START TRANSACTION, COMMIT and ROLLBACK, + * called rather than written, which is deliberate on both counts. A + * host that sends the text gets the same behaviour, because these run + * that text; and a host that would rather call gets a signature instead + * of a string to build, which is what a binding wrapping this in a + * block or a decorator actually needs. + * + * zu_begin with a nonzero read_only starts a READ ONLY transaction, + * which is enforced rather than advisory: a write inside one is 25G03 + * at the statement that wrote, not at the commit. Beginning inside a + * transaction is 25G01 rather than a nested one, and committing or + * rolling back with nothing running is 2D000 rather than a call that + * quietly did nothing, since a host that rolls back in an error path + * wants to hear that the transaction it meant to undo was not the one + * it thought. + * + * A commit that answers ZU_OK is durable: the log frame is on the disk + * before the call returns. Closing a connection with a transaction + * still running rolls it back, which is what a host that failed + * halfway and dropped everything wants and the only answer that does + * not depend on a destructor running. + * + * zu_conn_in_transaction is the one thing about a transaction that no + * statement answers, and every host offering a block, a `using` or a + * `defer` needs it: the cleanup path has to know whether the body + * already ended the transaction before it tries to. */ +zu_status zu_begin(zu_conn *conn, int32_t read_only, zu_error **err); +zu_status zu_commit(zu_conn *conn, zu_error **err); +zu_status zu_rollback(zu_conn *conn, zu_error **err); +zu_status zu_conn_in_transaction(zu_conn *conn, int32_t *out); + +/* Result shape. The two counts are 0 for a NULL result, which is the + * same answer as an empty one and needs no status. */ +uint64_t zu_result_rows(const zu_result *result); +uint32_t zu_result_cols(const zu_result *result); +zu_status zu_result_col_name(const zu_result *result, uint32_t col, const char **out, + size_t *len); +/* The ZU_TYPE_* tag of one cell, or -1 out of range: every tag is a + * type a cell can hold, so the failure has to be a value that is not + * one of them. */ +zu_status zu_result_cell_type(const zu_result *result, uint64_t row, uint32_t col, int32_t *out); + +/* Columnar reads: the whole column in one call, contiguous, owned by + * the result and valid until zu_result_free. ZU_DONE with *out NULL + * when the result has no rows, ZU_MISUSE when the column is out of + * range or holds something the accessor does not read. + * + * col_i64 reads ints and bools, col_f64 reads floats and ints, and + * col_node_offset reads the row offset that identifies a node. Nulls + * read 0 in all three, which col_valid tells apart. A node is not an + * integer here: reading one as its offset is what col_node_offset is + * for, and doing it quietly through col_i64 is how a binding ends up + * handing an internal row number to a user who asked for an identity. + * + * Where the engine filled the column itself, which is every plan whose + * projection is a scan of stored values, the pointer is into the + * engine's own buffer and the call costs a bounds check rather than a + * pass over the rows. Where it did not, a sort or a computed + * expression among them, the column is converted on the first call + * that asks for it and kept until zu_result_free. Nothing a caller + * writes depends on which happened. */ +zu_status zu_result_col_i64(zu_result *result, uint32_t col, const int64_t **out); +zu_status zu_result_col_f64(zu_result *result, uint32_t col, const double **out); +zu_status zu_result_col_node_offset(zu_result *result, uint32_t col, const uint64_t **out); +zu_status zu_result_col_valid(zu_result *result, uint32_t col, const uint8_t **out); + +/* Chunked reads: the same columns, a chunk of rows at a time. + * + * Which one to use is a question of size, and only for the columns the + * engine did not fill: on those, the whole-column call converts all of + * the column before returning any of it and keeps the conversion until + * the result is freed, so a million-row int column is eight megabytes + * of buffer beyond the rows and reading the first hundred rows and + * stopping pays for the other 999,900. A chunked read converts the + * chunk asked for, into a buffer of a fixed size that the next chunk + * reuses. On a column the engine did fill, both calls are pointers + * into the buffer it wrote and neither converts anything, so the + * choice is about the shape of the reading loop and nothing else. + * + * That is the trade: a chunk pointer is valid until the next call for + * the same column and the same accessor, which may replace its + * contents, or until zu_result_free. A host that needs one chunk to outlive the + * next copies it, which is the copy it was making anyway on the way + * into a host array. Columns are independent of each other, so reading + * a chunk's values and its validity together costs no reconversion. + * + * zu_result_chunk_count is the loop bound, and it is 0 for a result + * with no rows, which is why nothing here answers ZU_DONE. Ask each + * 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 materialized. The + * offset turns a chunk row back into the row number the cell accessors + * take, which is how a string column is read beside a chunked one. + * + * ZU_MISUSE when the chunk or the column is out of range, or the + * column holds something the accessor does not read. */ +uint64_t zu_result_chunk_count(const zu_result *result); +zu_status zu_result_chunk(const zu_result *result, uint64_t chunk, uint64_t *offset, + uint64_t *rows); +zu_status zu_result_chunk_col_i64(zu_result *result, uint64_t chunk, uint32_t col, + const int64_t **out); +zu_status zu_result_chunk_col_f64(zu_result *result, uint64_t chunk, uint32_t col, + const double **out); +zu_status zu_result_chunk_col_node_offset(zu_result *result, uint64_t chunk, uint32_t col, + const uint64_t **out); +zu_status zu_result_chunk_col_valid(zu_result *result, uint64_t chunk, uint32_t col, + const uint8_t **out); + +/* One string cell, NUL-terminated, with its byte length through len + * when that is non-NULL. ZU_MISUSE when the cell is out of range or is + * not a string. */ +zu_status zu_result_cell_str(zu_result *result, uint64_t row, uint32_t col, const char **out, + size_t *len); + +/* Cells one at a time, for the values that have no column to be read + * into. A temporal is a count and a unit, a list recurses, a node is a + * table and an offset, and none of the three fits an int64_t *. The + * columnar accessors above stay the path a bulk read takes; this is the + * path a value takes that they cannot express. + * + * zu_result_cell hands back a pointer into the result's own rows, so it + * allocates nothing and stays valid until zu_result_free, exactly like + * every other pointer here. There is no zu_value_free. + * + * These read a value as the type it is and nothing else, which is where + * they differ from the columns: zu_result_col_i64 reads bools and nulls + * too, because a column is one host array and something has to go in + * every slot, while zu_value_i64 on a bool answers ZU_MISUSE. Each + * writes its out-parameters on every path, so a caller that ignores the + * status reads a zero rather than the call before. + * + * zu_value_type returns the tag directly, and -1 for a NULL pointer: + * every tag is a type a cell can hold, so the failure has to be a value + * that is not one of them. zu_value_len is 0 for anything that is not a + * list, a path or a record, an empty list included, which is the same + * answer zu_result_rows gives and needs no status for the same reason. + * + * zu_value_str and zu_value_field point into the result's bytes and are + * NOT NUL-terminated; the length is the whole of the answer, and their + * len parameter may not be NULL. That is the price of not copying, and + * a string inside a list has no row and column to be cached under. + * zu_result_cell_str above is the NUL-terminated form, for a top-level + * cell, and it keeps the copy it makes. */ +zu_status zu_result_cell(const zu_result *result, uint64_t row, uint32_t col, + const zu_value **out); +int32_t zu_value_type(const zu_value *v); +zu_status zu_value_bool(const zu_value *v, int32_t *out); +zu_status zu_value_i64(const zu_value *v, int64_t *out); +zu_status zu_value_f64(const zu_value *v, double *out); +zu_status zu_value_str(const zu_value *v, const char **out, size_t *len); +/* kind and count are required; offset may be NULL for a host with no + * zoned type, and is minutes east of UTC, 0 for the five kinds that + * carry none. */ +zu_status zu_value_temporal(const zu_value *v, int32_t *kind, int64_t *count, int32_t *offset); +/* Both parts, because neither identifies a node on its own: two tables + * number their rows from zero. Either out-parameter may be NULL. */ +zu_status zu_value_node(const zu_value *v, uint32_t *table, uint64_t *offset); +zu_status zu_value_rel(const zu_value *v, uint32_t *table, uint64_t *src, uint64_t *dst); +uint64_t zu_value_len(const zu_value *v); +zu_status zu_value_at(const zu_value *v, uint64_t i, const zu_value **out); +/* A record's fields are in name order and a name appears once, which is + * what makes two records written in different orders one value. */ +zu_status zu_value_field(const zu_value *v, uint64_t i, const char **out, size_t *len); + +void zu_result_free(zu_result *result); + +/* ---- arrow ---- + * + * The other way a result ends. Every call above reads it and leaves it + * whole; this one hands its buffers to an Arrow consumer and gives the + * result up, which is what makes it free. + * + * The three structs below are Apache Arrow's C Data Interface, copied + * from the specification and guarded by the macro names the + * specification tells everybody to guard them by, so a translation unit + * that already has them from arrow/c/abi.h or from another library gets + * one definition and not two. Nothing here depends on Arrow being + * installed. */ + +#ifndef ARROW_C_DATA_INTERFACE +#define ARROW_C_DATA_INTERFACE + +#define ARROW_FLAG_DICTIONARY_ORDERED 1 +#define ARROW_FLAG_NULLABLE 2 +#define ARROW_FLAG_MAP_KEYS_SORTED 4 + +struct ArrowSchema { + const char *format; + const char *name; + const char *metadata; + int64_t flags; + int64_t n_children; + struct ArrowSchema **children; + struct ArrowSchema *dictionary; + void (*release)(struct ArrowSchema *); + void *private_data; +}; + +struct ArrowArray { + int64_t length; + int64_t null_count; + int64_t offset; + int64_t n_buffers; + int64_t n_children; + const void **buffers; + struct ArrowArray **children; + struct ArrowArray *dictionary; + void (*release)(struct ArrowArray *); + void *private_data; +}; + +#endif /* ARROW_C_DATA_INTERFACE */ + +#ifndef ARROW_C_STREAM_INTERFACE +#define ARROW_C_STREAM_INTERFACE + +struct ArrowArrayStream { + int (*get_schema)(struct ArrowArrayStream *, struct ArrowSchema *out); + int (*get_next)(struct ArrowArrayStream *, struct ArrowArray *out); + const char *(*get_last_error)(struct ArrowArrayStream *); + void (*release)(struct ArrowArrayStream *); + void *private_data; +}; + +#endif /* ARROW_C_STREAM_INTERFACE */ + +/* The whole result as a stream of Arrow record batches, moving the + * buffers the executor filled rather than copying them. + * + * This is the only call that spends a result. It takes the handle + * through a pointer to it and writes NULL back on every path, the + * failing ones included, because the buffers were on their way out + * before anything could refuse: after this the result holds nothing to + * read a second time. Do not call zu_result_free on it, and do not keep + * a column pointer or a cell string taken before the call, since those + * bytes belong to the stream now. + * + * That is the point of it. A result that stayed readable would have to + * be copied on the way out, and the copy is the whole answer, so a + * caller exporting a hundred million rows would move eight hundred + * megabytes to hand over eight hundred megabytes it already had. + * + * conn is where a node column's table name comes from, since a node + * carries the id of its table and the catalog is what turns that into a + * name. It may be NULL, and then the table is named "#7" after the id, + * which is what a caller who closed the connection and kept the result + * can still be given. When it is not NULL it must be the connection the + * result was produced on, and it is claimed for the length of the call. + * + * rows_per_batch is what the consumer sees per batch, and 0 asks for + * this library's own, which is 65536. The batches are slices of arrays + * that are already in memory, so this is about what a consumer likes to + * work in and not about what gets allocated. + * + * out points at an ArrowArrayStream the caller owns, uninitialised on + * the way in, and released through its own release callback on the way + * out rather than by any function of this library. It is written only + * on ZU_OK. + * + * ZU_UNSUPPORTED means the library was built without the arrow feature, + * which is the one answer about the build rather than the call; the + * symbol is here either way so that a caller loading this library by + * name learns what it can do from a status and not from a lookup that + * failed. ZU_MISUSE names the column when one holds something Arrow has + * no type for, which is a time with an offset and the two handle + * types. */ +zu_status zu_result_arrow(zu_conn *conn, zu_result **result, uint64_t rows_per_batch, + struct ArrowArrayStream *out, zu_error **err); + +/* ---- diagnostics ---- + * + * What a statement that worked has to say for itself, which is the half + * of the GQLSTATUS envelope a host reading rows and errors could not + * see. The status a call returns says whether it worked; this says + * which way, in the standard's own terms. + * + * zu_result_gqlstatus is the completion condition: "00000" for a + * statement that answered with columns, and "00001", successful + * completion with the result omitted, for one that had none to give + * back. It is the value the JSON Lines protocol already writes into + * every record and the value a conformance harness grades. It is never + * NULL for a result that is not NULL, and it belongs to the result + * rather than to the caller, so it is good until zu_result_free and is + * not freed on its own. + * + * The notices are the conditions the statement raised and carried on + * through. An exception replaces a result and arrives as an error; 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, so a host that + * asks and finds nought has paid for one call. + * + * A notice comes back as a zu_error, which is one shape rather than + * two: a diagnostic record is a diagnostic record, the code, its + * standard text, the severity, the place, the line and the doc page are + * the same accessors either way, and a binding that already turns one + * of these into an exception gets its warning class for the cost of + * reading zu_error_severity. That is what tells them apart, along with + * zu_error_status, which is ZU_OK here because that is what the call + * that produced it returned. It is a copy rather than a borrow, so the + * rule for every zu_error a host is handed stays the one rule: free it. + * The result keeps its own and can be asked again. */ +/* len may be NULL. */ +const char *zu_result_gqlstatus(zu_result *result, size_t *len); +uint32_t zu_result_notices(zu_result *result); +/* ZU_DONE with *out NULL past the end, which is what a host walking + * them gets at the end of the walk rather than a failure. */ +zu_status zu_result_notice(zu_result *result, uint32_t ix, zu_error **out); + +/* ---- bulk load ---- + * + * How values get into a database that does not exist yet. CREATE and + * INSERT need a table and no statement makes one, so a host holding + * data and an empty file has nowhere else to go. A database that does + * exist is what the appending section below is for. This is also the + * entry point the Rust appender and `zu copy` are built on, not a + * second mechanism beside them. + * + * A loader is columnar for the same reason a result is. One call per + * column, not one per cell. + * + * The order is fixed: create, then table, then columns and edges in any + * order and as many calls as you like, then finish. A column call + * before zu_loader_table is ZU_MISUSE, and so is a column whose count + * disagrees with the row count the table was given, checked at the call + * that passed it rather than at finish so the error names the column + * while you still know which one you were building. + * + * Nothing reaches the file until zu_loader_finish, so a load either + * happened or did not. The loader copies every array it is given, which + * means a caller may free or reuse its own buffers as soon as a call + * returns; the alternative is a lifetime rule this comment could not + * state safely. + * + * A loader is used from one thread, like a connection, and the same + * check applies: a second thread in a call answers ZU_MISUSE_CONCURRENT + * rather than corrupting the columns. After finish, and after a finish + * that failed, every call answers ZU_MISUSE_CLOSED and only + * zu_loader_free is left. + * + * zu_loader_create fails if the path exists, which is what `zu copy` + * does: a bulk load builds a database rather than adding to one. A + * loader freed before finish wrote nothing and leaves the empty file it + * created for the caller to remove. */ +zu_status zu_loader_create(const char *path, size_t path_len, zu_loader **out, zu_error **err); +zu_status zu_loader_create_z(const char *path, zu_loader **out, zu_error **err); +/* rows is given rather than counted from the first column, so a column + * with a value missing is an error and not a shorter table. One table + * per loader. */ +zu_status zu_loader_table(zu_loader *l, const char *nodes, size_t nodes_len, const char *edges, + size_t edges_len, uint64_t rows, zu_error **err); +zu_status zu_loader_table_z(zu_loader *l, const char *nodes, const char *edges, uint64_t rows, + zu_error **err); +/* Edges as the row each starts at and the row it ends at, two arrays so + * a host that has them in columns passes what it has. Appends, so call + * it as often as you like; the loader sorts and deduplicates at finish. + */ +zu_status zu_loader_edges(zu_loader *l, const uint32_t *from, const uint32_t *to, uint64_t count, + zu_error **err); +zu_status zu_loader_col_i64(zu_loader *l, const char *name, size_t name_len, const int64_t *values, + uint64_t count, zu_error **err); +zu_status zu_loader_col_f64(zu_loader *l, const char *name, size_t name_len, const double *values, + uint64_t count, zu_error **err); +/* Any nonzero value is true. int32_t rather than _Bool, because this + * header is C89-safe and because zu_value_bool writes one out. */ +zu_status zu_loader_col_bool(zu_loader *l, const char *name, size_t name_len, const int32_t *values, + uint64_t count, zu_error **err); +/* Lengths are a separate array so a caller whose strings are not + * NUL-terminated passes what it has. Every string is checked for UTF-8 + * here rather than read back later as something no query could return. + */ +zu_status zu_loader_col_str(zu_loader *l, const char *name, size_t name_len, + const char *const *values, const size_t *lens, uint64_t count, + zu_error **err); +zu_status zu_loader_col_str_z(zu_loader *l, const char *name, const char *const *values, + uint64_t count, zu_error **err); +/* zu_value_temporal read backwards: one ZU_TEMPORAL_ kind and the count + * each row holds in the unit that kind implies, so a value read out as + * 19782 days goes back in as 19782 days. ZU_TEMPORAL_ZONED_TIME and + * ZU_TEMPORAL_ZONED_DATETIME answer ZU_UNSUPPORTED: a stored column has + * nowhere to keep the offset that makes those two what they are. */ +zu_status zu_loader_col_temporal(zu_loader *l, const char *name, size_t name_len, int32_t kind, + const int64_t *values, uint64_t count, zu_error **err); +/* Writes it all. The database is on disk when this returns ZU_OK, and + * zu_open on the same path reads it. */ +zu_status zu_loader_finish(zu_loader *l, zu_error **err); +void zu_loader_free(zu_loader *l); + +/* ---- appending ---- + * + * How values get into a table that already exists. A statement is the + * wrong shape for it: every row is parsed, bound, planned and + * committed, and the commit is the expensive part, so a million rows is + * a million commits and the load is dominated by durability work nobody + * asked for. An appender buffers rows and pays one commit per flush. + * + * A row is written a value at a time, in the order the table declares + * its columns, and ended by zu_append_end_row. A column is a position + * rather than a name, because naming one per value would cost a lookup + * on the one path where per-value cost is the whole story and a loader + * knows its own column order. The columns are there to be read back: + * zu_appender_cols is how many values a row carries and + * zu_appender_col_name is what each one is called. + * + * A refused value ends the row it was in, whether the column would not + * take it or it was no value at all. The values that row had already + * written come back off, the error names the column and says what it + * holds, and the next value starts a new row. A row of the wrong width + * is refused the same way at zu_append_end_row. Nothing of a refused row + * is kept, so an appender is still usable once the loop is fixed. A row + * that was never ended is not a row: a flush takes it back off rather + * than writing a short one. + * + * A rel table has no property columns. A row of one is the two ends of + * an edge, as offsets into the tables it runs between, so those are its + * two columns and they are named for what they are. A negative offset + * is refused where it was appended, since it is no row of anything. An + * edge to a row that is not there is refused at the flush, before + * anything is written, and the file is left as it was. + * + * A flush is one commit. When it returns the rows are durable and every + * later statement sees them, and before it returns nothing sees + * anything. A flush with nothing buffered touches no file, so a host + * can flush on a timer without writing empty commits, and a flush that + * failed keeps its rows so what did not go in is still there to look + * at. Opening the appender is where a table nothing declares, a table + * that stores no properties, a column that holds a null and a read-only + * connection are refused, rather than at the first flush a million rows + * later. + * + * An appender is used from one thread, like the connection it writes + * through, and it takes that connection's claim for every call: a + * second thread in a call, or a statement running on the same + * connection, answers ZU_MISUSE_CONCURRENT rather than tearing a + * buffer. After close, every call but close answers ZU_MISUSE_CLOSED + * and only zu_appender_free is left. Closing twice writes nothing the + * second time, so a cleanup path may close what the load already did. + * + * zu_appender_free writes what is still buffered, because rows that + * were appended are rows the host meant to write; what it cannot do is + * say that the write failed, which is what close is for. A host that + * wants the rows gone calls zu_appender_discard and gets exactly + * that. */ +zu_status zu_appender_open(zu_conn *conn, const char *table, size_t table_len, zu_appender **out, + zu_error **err); +zu_status zu_appender_open_z(zu_conn *conn, const char *table, zu_appender **out, zu_error **err); +/* Any nonzero value is true, as everywhere else in this header. */ +zu_status zu_append_bool(zu_appender *app, int32_t v, zu_error **err); +zu_status zu_append_i64(zu_appender *app, int64_t v, zu_error **err); +zu_status zu_append_f64(zu_appender *app, double v, zu_error **err); +zu_status zu_append_str(zu_appender *app, const char *v, size_t v_len, zu_error **err); +zu_status zu_append_str_z(zu_appender *app, const char *v, zu_error **err); +zu_status zu_append_bytes(zu_appender *app, const uint8_t *v, size_t v_len, zu_error **err); +/* One ZU_TEMPORAL_ kind and the count in the unit that kind implies, + * which is zu_value_temporal read backwards. ZU_TEMPORAL_ZONED_TIME and + * ZU_TEMPORAL_ZONED_DATETIME answer ZU_UNSUPPORTED for the reason + * zu_loader_col_temporal gives. */ +zu_status zu_append_temporal(zu_appender *app, int32_t kind, int64_t count, zu_error **err); +/* Ends the row being written, which is what makes it a row. */ +zu_status zu_append_end_row(zu_appender *app, zu_error **err); +zu_status zu_appender_flush(zu_appender *app, zu_error **err); +/* Rows ended and not yet written, and rows committed across every + * flush. Both write nought before anything can fail. */ +zu_status zu_appender_buffered(zu_appender *app, uint64_t *out); +zu_status zu_appender_committed(zu_appender *app, uint64_t *out); +zu_status zu_appender_cols(zu_appender *app, uint32_t *out); +/* The column's name, borrowed from the appender and valid until it is + * freed, or NULL out of range. len may be NULL. */ +const char *zu_appender_col_name(zu_appender *app, uint32_t col, size_t *len); +/* Throws away what is buffered and says how many rows that was. Rows an + * earlier flush committed are committed and this does not reach them. + * out may be NULL. */ +zu_status zu_appender_discard(zu_appender *app, uint64_t *out); +/* Flushes what is left and spends the appender, writing the rows it + * committed in all through out, which may be NULL. */ +zu_status zu_appender_close(zu_appender *app, uint64_t *out, zu_error **err); +void zu_appender_free(zu_appender *app); + +/* ---- frames ---- + * + * How values get queried without getting in at all. A host holding + * columns in memory names them as a table of one connection and runs + * statements over them where they lie. Nothing is copied, at + * registration or at read: a scan builds vectors that point straight at + * the host's buffers wherever the layouts agree, and widens a value at + * a time into a scratch arena where they do not, so a host with a + * hundred columns pays for the one the statement named and a frame of + * ten million rows registers in the time it takes to walk its columns. + * + * The layouts that agree are the ones Arrow and this engine both keep: + * 64-bit signed integers, doubles, one bit a row for a boolean, and + * characters end to end with offsets cutting them up. A narrower + * integer, an unsigned one, a single-precision float and a count of + * microseconds against the nanoseconds this engine keeps time in are + * all widened as the statement reaches them. Strings never copy their + * characters either way: what a scan builds is the sixteen-byte view a + * row of the string lane is, and that view points back into the host's + * data buffer. + * + * The order is create, then a column call per column, then register. + * Every column call takes the count of values it is passing and a + * mismatch with the frame's row count is refused at that call, where + * the caller still knows which column it was describing. Everything + * else that can fail is settled at zu_conn_register: alignment, an + * unsigned value too large for the signed lane, a scale that would + * overflow, an offset that leaves its buffer. A read of a registered + * frame cannot fail, which is what lets a scan be a loop. + * + * The buffers stay the host's and this library never writes one. What + * it asks for is that each stays where it is, unwritten and unfreed, + * until the release callback runs. That callback is how a host learns + * the engine is finished: it runs once, on a thread of this library's, + * after the last statement reading the frame ends, which is not the + * unregister that preceded it and not necessarily the free either. A + * host that has to take a lock, or a runtime's interpreter lock, to let + * go of what it passed takes it inside the callback. Both owner and + * release may be NULL for a host whose buffers outlive the process. + * + * A frame is described once and registered as often as you like. The + * handle stays the caller's on every path, so registering it on two + * connections registers the same memory twice, and zu_frame_free is + * what ends it either way. + * + * A frame is read only and has no edges. A statement that would insert + * into, set on or delete from a registered name is refused with 25G03 + * and the reason. A name a stored table already holds is refused; + * a name another frame holds replaces that frame. Registering inside a + * transaction is 25G01, since a table appearing halfway through one is + * not a thing the transaction could then be rolled back over. + * + * A frame is used from one thread, like the connection it registers on, + * and a second thread in a call answers ZU_MISUSE_CONCURRENT. */ +zu_status zu_frame_new(const char *name, size_t name_len, uint64_t rows, void *owner, + void (*release)(void *), zu_frame **out, zu_error **err); +zu_status zu_frame_new_z(const char *name, uint64_t rows, void *owner, void (*release)(void *), + zu_frame **out, zu_error **err); +/* bits is 8, 16, 32 or 64. scale is what one value is multiplied by to + * reach the unit its meaning counts in: 1 for an integer and a date, + * 1000 for the microseconds Arrow keeps a time or a timestamp in. + * temporal is that meaning, ZU_FRAME_PLAIN for a column of numbers or + * one of the ZU_TEMPORAL_ kinds otherwise, with the two zoned kinds + * answering ZU_UNSUPPORTED for the reason zu_loader_col_temporal gives. + * Sixty-four signed bits at scale 1 is the lane this engine reads + * natively and is the column that costs nothing at all. */ +zu_status zu_frame_col_int(zu_frame *f, const char *name, size_t name_len, const void *values, + uint64_t count, int32_t bits, int32_t is_signed, int64_t scale, + int32_t temporal, zu_error **err); +/* bits is 32 or 64, and 64 is the lane. */ +zu_status zu_frame_col_float(zu_frame *f, const char *name, size_t name_len, const void *values, + uint64_t count, int32_t bits, zu_error **err); +/* One bit a row, low bit of the first byte first, which is Arrow's + * bitmap and this engine's alike. A host holding a slice with a bit + * offset of its own owes the shift before it gets here: a bitmap that + * starts partway into a byte is not a thing a pointer can say. */ +zu_status zu_frame_col_bool(zu_frame *f, const char *name, size_t name_len, const void *bitmap, + uint64_t count, zu_error **err); +/* Arrow's Utf8 when wide is nought and its LargeUtf8 when it is not, + * which is 32-bit and 64-bit offsets. There are count + 1 of them and + * the last is how much of data is used. */ +zu_status zu_frame_col_str(zu_frame *f, const char *name, size_t name_len, const void *offsets, + int32_t wide, const void *data, size_t data_len, uint64_t count, + zu_error **err); +/* Arrow's Utf8View: sixteen bytes a row at views, over buffers data + * buffers named by the two arrays data and data_lens. A short string in + * that layout is already this engine's own view, byte for byte. */ +zu_status zu_frame_col_view(zu_frame *f, const char *name, size_t name_len, const void *views, + const void *const *data, const size_t *data_lens, size_t buffers, + uint64_t count, zu_error **err); +void zu_frame_free(zu_frame *f); +/* Registers the frame as a table of this connection under the name it + * carries. Does not spend the handle. */ +zu_status zu_conn_register(zu_conn *conn, zu_frame *f, zu_error **err); +/* Drops one, writing through out, which may be NULL, whether there was + * one under that name. A statement already running keeps the frame it + * started with, and the release callback waits for it. */ +zu_status zu_conn_unregister(zu_conn *conn, const char *name, size_t name_len, int32_t *out, + zu_error **err); +zu_status zu_conn_unregister_z(zu_conn *conn, const char *name, int32_t *out, zu_error **err); +/* How many are registered, and the call that refreshes the names the + * accessor below hands out. The two are separate so that every pointer + * a host took while walking the list is still good when it reaches the + * end. */ +zu_status zu_conn_registered_count(zu_conn *conn, uint64_t *out); +/* One name, in the sorted order the count call last read them in, or + * NULL out of range. Borrowed from the connection and valid until the + * next zu_conn_registered_count on it or until it closes. Not + * NUL-terminated, which is what len is for; len may be NULL. */ +const char *zu_conn_registered_name(zu_conn *conn, uint64_t index, size_t *len); + +#ifdef __cplusplus +} +#endif + +#endif /* ZU_H */ diff --git a/zudb-jni/src/main/c/zudb_jni.c b/zudb-jni/src/main/c/zudb_jni.c new file mode 100644 index 0000000..1146fa5 --- /dev/null +++ b/zudb-jni/src/main/c/zudb_jni.c @@ -0,0 +1,1903 @@ +/* + * The zu C ABI, as JNI, for the JDKs that have no Panama. + * + * This file is the whole of the native code in this repository. It is + * about a hundred one-line functions over zu.h and four things worth + * reading first. + * + * It does not link against libzu. The API module decides which library + * to load, from a property, an environment variable, an artifact on the + * classpath or the platform's own search, and hands one path to + * whichever provider it tries. So this shim is built once per platform + * against nothing but the JDK and the C library, and opens libzu itself + * in n_load. That is also what makes the seven builds cheap: none of + * them needs a Rust toolchain, and a shim built in 2026 opens a libzu + * built tomorrow as long as the ABI revision still matches. + * + * Strings cross as byte arrays and never as jstring. JNI's + * GetStringUTFChars answers modified UTF-8, which spells a character + * outside the basic plane as a surrogate pair in six bytes and a NUL as + * two. The engine validates real UTF-8 and would refuse the first + * emoji anybody stored. NewStringUTF has the same fault in the other + * direction. So the Java side encodes and decodes, and this side sees + * bytes and a length, which is what the ABI wants anyway. + * + * There is one exported symbol with a mangled name, n_register, and + * everything else is bound by RegisterNatives from the table at the + * bottom. That is not tidiness: JNI_OnLoad cannot FindClass anything a + * custom loader or the module path holds, and the class handed to a + * static native is exactly the right one to register against. + * + * A failure is thrown from here, built by the Java helpers, so that the + * mapping from a status and a GQLSTATUS code to an exception class + * stays in the one place both providers share. + */ + +#include +#include +#include +#include +#include + +#include "zu.h" + +#ifdef _WIN32 +#include +#else +#include +#endif + +/* ------------------------------------------------------------------ */ +/* the library */ +/* ------------------------------------------------------------------ */ + +/* + * Every call this client makes, as a pointer resolved once. The list is + * the whole of what the shim depends on, so a library missing any of it + * is refused by name at load rather than found by a segfault later. + */ +#define ZU_SYMBOLS(X) \ + X(version, const char *, (void)) \ + X(error_status, zu_status, (const zu_error *)) \ + X(error_message, const char *, (const zu_error *, size_t *)) \ + X(error_code, const char *, (const zu_error *, size_t *)) \ + X(error_standard_text, const char *, (const zu_error *, size_t *)) \ + X(error_doc_url, const char *, (const zu_error *, size_t *)) \ + X(error_severity, int32_t, (const zu_error *)) \ + X(error_retryable, int32_t, (const zu_error *)) \ + X(error_position, zu_status, (const zu_error *, uint32_t *, uint32_t *)) \ + X(error_offset, zu_status, (const zu_error *, uint32_t *)) \ + X(error_excerpt, const char *, (const zu_error *, size_t *)) \ + X(error_free, void, (zu_error *)) \ + X(config_set, zu_status, \ + (zu_config *, const char *, size_t, const char *, size_t, zu_error **)) \ + X(database_open, zu_status, \ + (const char *, size_t, const zu_config *, zu_database **, zu_error **)) \ + X(database_create, zu_status, \ + (const char *, size_t, const zu_config *, zu_database **, zu_error **)) \ + X(database_memory, zu_status, (const zu_config *, zu_database **, zu_error **)) \ + X(database_is_memory, zu_status, (const zu_database *)) \ + X(database_path, zu_status, (const zu_database *, const char **, size_t *)) \ + X(database_close, void, (zu_database *)) \ + X(connect, zu_status, (zu_database *, zu_conn **, zu_error **)) \ + X(open, zu_status, (const char *, size_t, zu_conn **, zu_error **)) \ + X(create, zu_status, (const char *, size_t, zu_conn **, zu_error **)) \ + X(memory, zu_status, (zu_conn **, zu_error **)) \ + X(conn_duplicate, zu_status, (zu_conn *, zu_conn **, zu_error **)) \ + X(conn_close, void, (zu_conn *)) \ + X(conn_interrupt, zu_status, (zu_conn *)) \ + X(conn_rows_read, zu_status, (zu_conn *, uint64_t *)) \ + X(conn_set_progress, zu_status, (zu_conn *, zu_progress_fn, void *, uint64_t)) \ + X(query, zu_status, (zu_conn *, const char *, size_t, zu_result **, zu_error **)) \ + X(prepare, zu_status, (zu_conn *, const char *, size_t, zu_stmt **, zu_error **)) \ + X(bind_i64, zu_status, (zu_stmt *, const char *, size_t, int64_t)) \ + X(bind_f64, zu_status, (zu_stmt *, const char *, size_t, double)) \ + X(bind_bool, zu_status, (zu_stmt *, const char *, size_t, int)) \ + X(bind_str, zu_status, (zu_stmt *, const char *, size_t, const char *, size_t)) \ + X(bind_temporal, zu_status, (zu_stmt *, const char *, size_t, int32_t, int64_t, int32_t)) \ + X(bind_null, zu_status, (zu_stmt *, const char *, size_t)) \ + X(execute, zu_status, (zu_stmt *, zu_result **, zu_error **)) \ + X(stmt_close, void, (zu_stmt *)) \ + X(begin, zu_status, (zu_conn *, int32_t, zu_error **)) \ + X(commit, zu_status, (zu_conn *, zu_error **)) \ + X(rollback, zu_status, (zu_conn *, zu_error **)) \ + X(conn_in_transaction, zu_status, (zu_conn *, int32_t *)) \ + X(result_rows, uint64_t, (const zu_result *)) \ + X(result_cols, uint32_t, (const zu_result *)) \ + X(result_col_name, zu_status, (const zu_result *, uint32_t, const char **, size_t *)) \ + X(result_cell_type, zu_status, (const zu_result *, uint64_t, uint32_t, int32_t *)) \ + X(result_col_i64, zu_status, (zu_result *, uint32_t, const int64_t **)) \ + X(result_col_f64, zu_status, (zu_result *, uint32_t, const double **)) \ + X(result_col_node_offset, zu_status, (zu_result *, uint32_t, const uint64_t **)) \ + X(result_col_valid, zu_status, (zu_result *, uint32_t, const uint8_t **)) \ + X(result_chunk_count, uint64_t, (const zu_result *)) \ + X(result_chunk, zu_status, (const zu_result *, uint64_t, uint64_t *, uint64_t *)) \ + X(result_chunk_col_i64, zu_status, (zu_result *, uint64_t, uint32_t, const int64_t **)) \ + X(result_chunk_col_f64, zu_status, (zu_result *, uint64_t, uint32_t, const double **)) \ + X(result_chunk_col_node_offset, zu_status, \ + (zu_result *, uint64_t, uint32_t, const uint64_t **)) \ + X(result_chunk_col_valid, zu_status, (zu_result *, uint64_t, uint32_t, const uint8_t **)) \ + X(result_cell_str, zu_status, (zu_result *, uint64_t, uint32_t, const char **, size_t *)) \ + X(result_cell, zu_status, (const zu_result *, uint64_t, uint32_t, const zu_value **)) \ + X(result_free, void, (zu_result *)) \ + X(result_arrow, zu_status, \ + (zu_conn *, zu_result **, uint64_t, struct ArrowArrayStream *, zu_error **)) \ + X(result_gqlstatus, const char *, (zu_result *, size_t *)) \ + X(result_notices, uint32_t, (zu_result *)) \ + X(result_notice, zu_status, (zu_result *, uint32_t, zu_error **)) \ + X(value_type, int32_t, (const zu_value *)) \ + X(value_bool, zu_status, (const zu_value *, int32_t *)) \ + X(value_i64, zu_status, (const zu_value *, int64_t *)) \ + X(value_f64, zu_status, (const zu_value *, double *)) \ + X(value_str, zu_status, (const zu_value *, const char **, size_t *)) \ + X(value_temporal, zu_status, (const zu_value *, int32_t *, int64_t *, int32_t *)) \ + X(value_node, zu_status, (const zu_value *, uint32_t *, uint64_t *)) \ + X(value_rel, zu_status, (const zu_value *, uint32_t *, uint64_t *, uint64_t *)) \ + X(value_len, uint64_t, (const zu_value *)) \ + X(value_at, zu_status, (const zu_value *, uint64_t, const zu_value **)) \ + X(value_field, zu_status, (const zu_value *, uint64_t, const char **, size_t *)) \ + X(loader_create, zu_status, (const char *, size_t, zu_loader **, zu_error **)) \ + X(loader_table, zu_status, \ + (zu_loader *, const char *, size_t, const char *, size_t, uint64_t, zu_error **)) \ + X(loader_edges, zu_status, \ + (zu_loader *, const uint32_t *, const uint32_t *, uint64_t, zu_error **)) \ + X(loader_col_i64, zu_status, \ + (zu_loader *, const char *, size_t, const int64_t *, uint64_t, zu_error **)) \ + X(loader_col_f64, zu_status, \ + (zu_loader *, const char *, size_t, const double *, uint64_t, zu_error **)) \ + X(loader_col_bool, zu_status, \ + (zu_loader *, const char *, size_t, const int32_t *, uint64_t, zu_error **)) \ + X(loader_col_str, zu_status, \ + (zu_loader *, const char *, size_t, const char *const *, const size_t *, uint64_t, \ + zu_error **)) \ + X(loader_col_temporal, zu_status, \ + (zu_loader *, const char *, size_t, int32_t, const int64_t *, uint64_t, zu_error **)) \ + X(loader_finish, zu_status, (zu_loader *, zu_error **)) \ + X(loader_free, void, (zu_loader *)) \ + X(appender_open, zu_status, \ + (zu_conn *, const char *, size_t, zu_appender **, zu_error **)) \ + X(append_bool, zu_status, (zu_appender *, int32_t, zu_error **)) \ + X(append_i64, zu_status, (zu_appender *, int64_t, zu_error **)) \ + X(append_f64, zu_status, (zu_appender *, double, zu_error **)) \ + X(append_str, zu_status, (zu_appender *, const char *, size_t, zu_error **)) \ + X(append_bytes, zu_status, (zu_appender *, const uint8_t *, size_t, zu_error **)) \ + X(append_temporal, zu_status, (zu_appender *, int32_t, int64_t, zu_error **)) \ + X(append_end_row, zu_status, (zu_appender *, zu_error **)) \ + X(appender_flush, zu_status, (zu_appender *, zu_error **)) \ + X(appender_buffered, zu_status, (zu_appender *, uint64_t *)) \ + X(appender_committed, zu_status, (zu_appender *, uint64_t *)) \ + X(appender_cols, zu_status, (zu_appender *, uint32_t *)) \ + X(appender_col_name, const char *, (zu_appender *, uint32_t, size_t *)) \ + X(appender_discard, zu_status, (zu_appender *, uint64_t *)) \ + X(appender_close, zu_status, (zu_appender *, uint64_t *, zu_error **)) \ + X(appender_free, void, (zu_appender *)) \ + X(frame_new, zu_status, \ + (const char *, size_t, uint64_t, void *, void (*)(void *), zu_frame **, zu_error **)) \ + X(frame_col_int, zu_status, \ + (zu_frame *, const char *, size_t, const void *, uint64_t, int32_t, int32_t, int64_t, \ + int32_t, zu_error **)) \ + X(frame_col_float, zu_status, \ + (zu_frame *, const char *, size_t, const void *, uint64_t, int32_t, zu_error **)) \ + X(frame_col_bool, zu_status, \ + (zu_frame *, const char *, size_t, const void *, uint64_t, zu_error **)) \ + X(frame_col_str, zu_status, \ + (zu_frame *, const char *, size_t, const void *, int32_t, const void *, size_t, \ + uint64_t, zu_error **)) \ + X(frame_col_view, zu_status, \ + (zu_frame *, const char *, size_t, const void *, const void *const *, const size_t *, \ + size_t, uint64_t, zu_error **)) \ + X(frame_free, void, (zu_frame *)) \ + X(conn_register, zu_status, (zu_conn *, zu_frame *, zu_error **)) \ + X(conn_unregister, zu_status, (zu_conn *, const char *, size_t, int32_t *, zu_error **)) \ + X(conn_registered_count, zu_status, (zu_conn *, uint64_t *)) \ + X(conn_registered_name, const char *, (zu_conn *, uint64_t, size_t *)) + +#define ZU_DECLARE(name, ret, params) static ret (*p_##name) params; +ZU_SYMBOLS(ZU_DECLARE) +#undef ZU_DECLARE + +/* ------------------------------------------------------------------ */ +/* what this side remembers */ +/* ------------------------------------------------------------------ */ + +static JavaVM *vm; +static jclass c_binding; /* global ref, for the two static helpers */ +static jmethodID m_diagnostic; +static jmethodID m_misuse; +static jmethodID m_to_exception; +static jmethodID m_progress_at; +static jmethodID m_runnable_run; + +#define H(type, handle) ((type *)(intptr_t)(handle)) +#define A(handle) ((jlong)(intptr_t)(handle)) + +/* ------------------------------------------------------------------ */ +/* threads */ +/* ------------------------------------------------------------------ */ + +/* + * A callback arrives on a thread of the engine's, which the JVM has + * never seen. Attaching for the call and detaching after it is not the + * cheapest arrangement, but it is the one that is correct when the + * engine's pool retires a worker: a thread that exits while still + * attached takes the process with it, and nothing here is told when a + * worker goes. + */ +static JNIEnv *attach(int *attached) { + void *env = NULL; + *attached = 0; + if ((*vm)->GetEnv(vm, &env, JNI_VERSION_1_8) == JNI_OK) { + return (JNIEnv *)env; + } + if ((*vm)->AttachCurrentThreadAsDaemon(vm, &env, NULL) != JNI_OK) { + return NULL; + } + *attached = 1; + return (JNIEnv *)env; +} + +static void detach(int attached) { + if (attached) { + (*vm)->DetachCurrentThread(vm); + } +} + +/* ------------------------------------------------------------------ */ +/* strings and arrays */ +/* ------------------------------------------------------------------ */ + +/* A byte array borrowed for the length of one call. */ +typedef struct { + jbyteArray array; + jbyte *data; + jsize length; +} borrowed; + +static int borrow(JNIEnv *env, jbyteArray array, borrowed *out) { + out->array = array; + out->data = NULL; + out->length = 0; + if (array == NULL) { + return 1; + } + out->data = (*env)->GetByteArrayElements(env, array, NULL); + if (out->data == NULL) { + return 0; /* the out of memory error is already pending */ + } + out->length = (*env)->GetArrayLength(env, array); + return 1; +} + +static void giveback(JNIEnv *env, borrowed *b) { + if (b->data != NULL) { + (*env)->ReleaseByteArrayElements(env, b->array, b->data, JNI_ABORT); + } +} + +#define S(b) ((const char *)(b).data) +#define L(b) ((size_t)(b).length) + +/* A run of bytes as a Java array, or null for a pointer that is null. */ +static jbyteArray bytes(JNIEnv *env, const char *p, size_t len) { + jbyteArray a; + if (p == NULL) { + return NULL; + } + if (len > (size_t)INT32_MAX) { + len = (size_t)INT32_MAX; + } + a = (*env)->NewByteArray(env, (jsize)len); + if (a == NULL) { + return NULL; + } + if (len > 0) { + (*env)->SetByteArrayRegion(env, a, 0, (jsize)len, (const jbyte *)p); + } + return a; +} + +/* The same for a NUL terminated string, which is what zu_version is. */ +static jbyteArray cstring(JNIEnv *env, const char *p) { + return p == NULL ? NULL : bytes(env, p, strlen(p)); +} + +static jlongArray longs(JNIEnv *env, const jlong *values, jsize count) { + jlongArray a = (*env)->NewLongArray(env, count); + if (a != NULL) { + (*env)->SetLongArrayRegion(env, a, 0, count, values); + } + return a; +} + +/* + * The address of a direct buffer, which is where the Java side has + * already put whatever the engine is about to read. A buffer that is + * not direct never reaches here: the Java side either copies it into + * one, for a call that reads and is done, or refuses it, for a frame + * that keeps the pointer. + */ +static void *address(JNIEnv *env, jobject buffer) { + return buffer == NULL ? NULL : (*env)->GetDirectBufferAddress(env, buffer); +} + +/* ------------------------------------------------------------------ */ +/* failures */ +/* ------------------------------------------------------------------ */ + +/* One of the const char * accessors on a zu_error, as a Java array. */ +static jbyteArray field(JNIEnv *env, const zu_error *e, + const char *(*get)(const zu_error *, size_t *)) { + size_t len = 0; + const char *p = get(e, &len); + return bytes(env, p, len); +} + +/* + * Turns what the ABI answered into the exception it names and throws + * it, then frees the error. The record is built in Java because that is + * where the mapping from a GQLSTATUS class to an exception class lives, + * and it lives in one place so that the two providers cannot come to + * differ about it. + */ +static void raise(JNIEnv *env, zu_status status, zu_error *e, const char *what) { + jobject record; + jobject thrown; + uint32_t line = 0; + uint32_t column = 0; + uint32_t offset = 0; + jint jline = -1; + jint jcolumn = -1; + jint joffset = -1; + + if (e == NULL) { + jstring name = (*env)->NewStringUTF(env, what); + if (name == NULL) { + return; + } + thrown = (*env)->CallStaticObjectMethod(env, c_binding, m_misuse, (jint)status, name); + (*env)->DeleteLocalRef(env, name); + if (thrown != NULL) { + (*env)->Throw(env, (jthrowable)thrown); + } + return; + } + + if (p_error_position(e, &line, &column) == ZU_OK) { + jline = (jint)line; + jcolumn = (jint)column; + } + if (p_error_offset(e, &offset) == ZU_OK) { + joffset = (jint)offset; + } + + record = (*env)->CallStaticObjectMethod( + env, c_binding, m_diagnostic, (jint)p_error_status(e), field(env, e, p_error_message), + field(env, e, p_error_code), field(env, e, p_error_standard_text), + (jint)p_error_severity(e), jline, jcolumn, joffset, field(env, e, p_error_excerpt), + field(env, e, p_error_doc_url), p_error_retryable(e) == 1 ? JNI_TRUE : JNI_FALSE); + p_error_free(e); + if (record == NULL) { + return; /* something is already pending, and it is the truer failure */ + } + thrown = (*env)->CallObjectMethod(env, record, m_to_exception); + if (thrown != NULL) { + (*env)->Throw(env, (jthrowable)thrown); + } +} + +/* The record a notice is, which is the same reading without the throw. */ +static jobject record(JNIEnv *env, zu_error *e) { + jobject out; + uint32_t line = 0; + uint32_t column = 0; + uint32_t offset = 0; + jint jline = -1; + jint jcolumn = -1; + jint joffset = -1; + + if (p_error_position(e, &line, &column) == ZU_OK) { + jline = (jint)line; + jcolumn = (jint)column; + } + if (p_error_offset(e, &offset) == ZU_OK) { + joffset = (jint)offset; + } + out = (*env)->CallStaticObjectMethod( + env, c_binding, m_diagnostic, (jint)p_error_status(e), field(env, e, p_error_message), + field(env, e, p_error_code), field(env, e, p_error_standard_text), + (jint)p_error_severity(e), jline, jcolumn, joffset, field(env, e, p_error_excerpt), + field(env, e, p_error_doc_url), p_error_retryable(e) == 1 ? JNI_TRUE : JNI_FALSE); + p_error_free(e); + return out; +} + +/* A status that is not OK and carries no error of its own. */ +#define FAIL_IF(env, st, err, what) \ + do { \ + if ((st) != ZU_OK) { \ + raise((env), (st), (err), (what)); \ + return; \ + } \ + } while (0) + +#define FAIL_IF_V(env, st, err, what, value) \ + do { \ + if ((st) != ZU_OK) { \ + raise((env), (st), (err), (what)); \ + return (value); \ + } \ + } while (0) + +/* ------------------------------------------------------------------ */ +/* loading */ +/* ------------------------------------------------------------------ */ + +#ifdef _WIN32 +typedef HMODULE handle; +static handle lib_open(const char *path) { + int wide = MultiByteToWideChar(CP_UTF8, 0, path, -1, NULL, 0); + wchar_t *w; + handle h; + if (wide <= 0) { + return NULL; + } + w = (wchar_t *)calloc((size_t)wide, sizeof(wchar_t)); + if (w == NULL) { + return NULL; + } + MultiByteToWideChar(CP_UTF8, 0, path, -1, w, wide); + h = LoadLibraryW(w); + free(w); + return h; +} +static void *lib_sym(handle h, const char *name) { + return (void *)(intptr_t)GetProcAddress(h, name); +} +#else +typedef void *handle; +static handle lib_open(const char *path) { return dlopen(path, RTLD_NOW | RTLD_LOCAL); } +static void *lib_sym(handle h, const char *name) { return dlsym(h, name); } +#endif + +static handle library; + +/* + * Opens libzu and binds every call in it. + * + * Answers null for a library that opened and had everything, and + * otherwise a sentence saying which of the two went wrong. A provider + * turns that into ProviderUnavailableException, which is not a failure + * of the program but a fact about this machine. + */ +static jbyteArray n_load(JNIEnv *env, jclass self, jbyteArray path) { + borrowed p; + char *copy; + handle h; + (void)self; + + if (library != NULL) { + return NULL; /* already bound, and a second library would be a second engine */ + } + if (!borrow(env, path, &p)) { + return NULL; + } + copy = (char *)malloc(L(p) + 1); + if (copy == NULL) { + giveback(env, &p); + return cstring(env, "out of memory"); + } + memcpy(copy, S(p), L(p)); + copy[L(p)] = '\0'; + giveback(env, &p); + + h = lib_open(copy); + if (h == NULL) { + char message[1024]; +#ifdef _WIN32 + snprintf(message, sizeof message, "%s did not load (error %lu)", copy, + (unsigned long)GetLastError()); +#else + const char *why = dlerror(); + snprintf(message, sizeof message, "%s did not load: %s", copy, why == NULL ? "" : why); +#endif + free(copy); + return cstring(env, message); + } + free(copy); + +#define ZU_BIND(name, ret, params) \ + p_##name = (ret(*) params)lib_sym(h, "zu_" #name); \ + if (p_##name == NULL) { \ + return cstring(env, "this library has no zu_" #name \ + ", so it is not a zu of this ABI"); \ + } + ZU_SYMBOLS(ZU_BIND) +#undef ZU_BIND + + library = h; + return NULL; +} + +/* ------------------------------------------------------------------ */ +/* the calls */ +/* ------------------------------------------------------------------ */ + +static jbyteArray n_version(JNIEnv *env, jclass self) { + (void)self; + return cstring(env, p_version()); +} + +static void config(zu_config *cfg, jlong memory, jlong threads, jboolean read_only) { + memset(cfg, 0, sizeof *cfg); + cfg->struct_size = sizeof *cfg; + cfg->memory_limit = (size_t)memory; + cfg->threads = (size_t)threads; + cfg->read_only = read_only == JNI_TRUE ? 1 : 0; +} + +static jlongArray n_config_set(JNIEnv *env, jclass self, jlong memory, jlong threads, + jboolean read_only, jbyteArray key, jbyteArray value) { + zu_config cfg; + zu_error *e = NULL; + zu_status st; + borrowed k; + borrowed v; + jlong out[3]; + (void)self; + + if (!borrow(env, key, &k)) { + return NULL; + } + if (!borrow(env, value, &v)) { + giveback(env, &k); + return NULL; + } + config(&cfg, memory, threads, read_only); + st = p_config_set(&cfg, S(k), L(k), S(v), L(v), &e); + giveback(env, &k); + giveback(env, &v); + FAIL_IF_V(env, st, e, "zu_config_set", NULL); + out[0] = (jlong)cfg.memory_limit; + out[1] = (jlong)cfg.threads; + out[2] = (jlong)cfg.read_only; + return longs(env, out, 3); +} + +static jlong open_or_create(JNIEnv *env, jbyteArray path, jlong memory, jlong threads, + jboolean read_only, int creating) { + zu_config cfg; + zu_database *db = NULL; + zu_error *e = NULL; + zu_status st; + borrowed p; + + if (!borrow(env, path, &p)) { + return 0; + } + config(&cfg, memory, threads, read_only); + st = creating ? p_database_create(S(p), L(p), &cfg, &db, &e) + : p_database_open(S(p), L(p), &cfg, &db, &e); + giveback(env, &p); + FAIL_IF_V(env, st, e, creating ? "zu_database_create" : "zu_database_open", 0); + return A(db); +} + +static jlong n_database_open(JNIEnv *env, jclass self, jbyteArray path, jlong memory, + jlong threads, jboolean read_only) { + (void)self; + return open_or_create(env, path, memory, threads, read_only, 0); +} + +static jlong n_database_create(JNIEnv *env, jclass self, jbyteArray path, jlong memory, + jlong threads, jboolean read_only) { + (void)self; + return open_or_create(env, path, memory, threads, read_only, 1); +} + +static jlong n_database_memory(JNIEnv *env, jclass self, jlong memory, jlong threads, + jboolean read_only) { + zu_config cfg; + zu_database *db = NULL; + zu_error *e = NULL; + zu_status st; + (void)self; + + config(&cfg, memory, threads, read_only); + st = p_database_memory(&cfg, &db, &e); + FAIL_IF_V(env, st, e, "zu_database_memory", 0); + return A(db); +} + +static jboolean n_database_is_memory(JNIEnv *env, jclass self, jlong db) { + (void)env; + (void)self; + return p_database_is_memory(H(zu_database, db)) == ZU_OK ? JNI_TRUE : JNI_FALSE; +} + +static jbyteArray n_database_path(JNIEnv *env, jclass self, jlong db) { + const char *p = NULL; + size_t len = 0; + zu_status st = p_database_path(H(zu_database, db), &p, &len); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_database_path", NULL); + return bytes(env, p, len); +} + +static void n_database_close(JNIEnv *env, jclass self, jlong db) { + (void)env; + (void)self; + p_database_close(H(zu_database, db)); +} + +static jlong n_connect(JNIEnv *env, jclass self, jlong db) { + zu_conn *conn = NULL; + zu_error *e = NULL; + zu_status st = p_connect(H(zu_database, db), &conn, &e); + (void)self; + FAIL_IF_V(env, st, e, "zu_connect", 0); + return A(conn); +} + +static jlong path_conn(JNIEnv *env, jbyteArray path, int creating) { + zu_conn *conn = NULL; + zu_error *e = NULL; + zu_status st; + borrowed p; + + if (!borrow(env, path, &p)) { + return 0; + } + st = creating ? p_create(S(p), L(p), &conn, &e) : p_open(S(p), L(p), &conn, &e); + giveback(env, &p); + FAIL_IF_V(env, st, e, creating ? "zu_create" : "zu_open", 0); + return A(conn); +} + +static jlong n_open(JNIEnv *env, jclass self, jbyteArray path) { + (void)self; + return path_conn(env, path, 0); +} + +static jlong n_create(JNIEnv *env, jclass self, jbyteArray path) { + (void)self; + return path_conn(env, path, 1); +} + +static jlong n_memory(JNIEnv *env, jclass self) { + zu_conn *conn = NULL; + zu_error *e = NULL; + zu_status st = p_memory(&conn, &e); + (void)self; + FAIL_IF_V(env, st, e, "zu_memory", 0); + return A(conn); +} + +static jlong n_conn_duplicate(JNIEnv *env, jclass self, jlong conn) { + zu_conn *out = NULL; + zu_error *e = NULL; + zu_status st = p_conn_duplicate(H(zu_conn, conn), &out, &e); + (void)self; + FAIL_IF_V(env, st, e, "zu_conn_duplicate", 0); + return A(out); +} + +static void n_conn_close(JNIEnv *env, jclass self, jlong conn) { + (void)env; + (void)self; + p_conn_close(H(zu_conn, conn)); +} + +static void n_conn_interrupt(JNIEnv *env, jclass self, jlong conn) { + zu_status st = p_conn_interrupt(H(zu_conn, conn)); + (void)self; + FAIL_IF(env, st, NULL, "zu_conn_interrupt"); +} + +static jlong n_conn_rows_read(JNIEnv *env, jclass self, jlong conn) { + uint64_t out = 0; + zu_status st = p_conn_rows_read(H(zu_conn, conn), &out); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_conn_rows_read", 0); + return (jlong)out; +} + +/* ---- the progress callback ---- */ + +/* + * What a watcher is on this side: one global reference, which the + * engine holds as its user_data and this shim frees when the connection + * stops watching or closes. It is a heap cell rather than the reference + * itself because a jobject is not a pointer on every JVM. + */ +typedef struct { + jobject watcher; +} watch; + +static int on_progress(void *user_data, uint64_t rows, uint64_t ms) { + watch *w = (watch *)user_data; + int attached = 0; + JNIEnv *env = attach(&attached); + jboolean go; + + if (env == NULL) { + return 0; /* nothing can be asked, so stop rather than run on unwatched */ + } + go = (*env)->CallBooleanMethod(env, w->watcher, m_progress_at, (jlong)rows, (jlong)ms); + /* + * Nothing may be thrown out of a callback the engine is inside of. A + * watcher that threw is a program that has stopped wanting the + * answer, so the exception is cleared and described, and the + * statement is stopped, which is the reading that throws least away. + */ + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionDescribe(env); + (*env)->ExceptionClear(env); + go = JNI_FALSE; + } + detach(attached); + return go == JNI_TRUE ? 1 : 0; +} + +static jlong n_conn_set_progress(JNIEnv *env, jclass self, jlong conn, jobject watcher, + jlong interval) { + watch *w = NULL; + zu_status st; + (void)self; + + if (watcher != NULL) { + w = (watch *)calloc(1, sizeof *w); + if (w == NULL) { + raise(env, ZU_ERROR, NULL, "zu_conn_set_progress"); + return 0; + } + w->watcher = (*env)->NewGlobalRef(env, watcher); + if (w->watcher == NULL) { + free(w); + return 0; + } + } + st = p_conn_set_progress(H(zu_conn, conn), w == NULL ? NULL : on_progress, w, + (uint64_t)interval); + if (st != ZU_OK) { + if (w != NULL) { + (*env)->DeleteGlobalRef(env, w->watcher); + free(w); + } + raise(env, st, NULL, "zu_conn_set_progress"); + return 0; + } + return A(w); +} + +/* Says nothing will call this watcher again, which is what taking the + arrangement back means. */ +static void n_watch_free(JNIEnv *env, jclass self, jlong cookie) { + watch *w = H(watch, cookie); + (void)self; + if (w != NULL) { + (*env)->DeleteGlobalRef(env, w->watcher); + free(w); + } +} + +static jboolean n_conn_in_transaction(JNIEnv *env, jclass self, jlong conn) { + int32_t out = 0; + zu_status st = p_conn_in_transaction(H(zu_conn, conn), &out); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_conn_in_transaction", JNI_FALSE); + return out != 0 ? JNI_TRUE : JNI_FALSE; +} + +static void n_begin(JNIEnv *env, jclass self, jlong conn, jboolean read_only) { + zu_error *e = NULL; + zu_status st = p_begin(H(zu_conn, conn), read_only == JNI_TRUE ? 1 : 0, &e); + (void)self; + FAIL_IF(env, st, e, "zu_begin"); +} + +static void n_commit(JNIEnv *env, jclass self, jlong conn) { + zu_error *e = NULL; + zu_status st = p_commit(H(zu_conn, conn), &e); + (void)self; + FAIL_IF(env, st, e, "zu_commit"); +} + +static void n_rollback(JNIEnv *env, jclass self, jlong conn) { + zu_error *e = NULL; + zu_status st = p_rollback(H(zu_conn, conn), &e); + (void)self; + FAIL_IF(env, st, e, "zu_rollback"); +} + +static jlong n_query(JNIEnv *env, jclass self, jlong conn, jbyteArray statement) { + zu_result *out = NULL; + zu_error *e = NULL; + zu_status st; + borrowed q; + (void)self; + + if (!borrow(env, statement, &q)) { + return 0; + } + st = p_query(H(zu_conn, conn), S(q), L(q), &out, &e); + giveback(env, &q); + FAIL_IF_V(env, st, e, "zu_query", 0); + return A(out); +} + +static jlong n_prepare(JNIEnv *env, jclass self, jlong conn, jbyteArray statement) { + zu_stmt *out = NULL; + zu_error *e = NULL; + zu_status st; + borrowed q; + (void)self; + + if (!borrow(env, statement, &q)) { + return 0; + } + st = p_prepare(H(zu_conn, conn), S(q), L(q), &out, &e); + giveback(env, &q); + FAIL_IF_V(env, st, e, "zu_prepare", 0); + return A(out); +} + +static void n_bind_long(JNIEnv *env, jclass self, jlong stmt, jbyteArray name, jlong value) { + borrowed n; + zu_status st; + (void)self; + + if (!borrow(env, name, &n)) { + return; + } + st = p_bind_i64(H(zu_stmt, stmt), S(n), L(n), (int64_t)value); + giveback(env, &n); + FAIL_IF(env, st, NULL, "zu_bind_i64"); +} + +static void n_bind_double(JNIEnv *env, jclass self, jlong stmt, jbyteArray name, jdouble value) { + borrowed n; + zu_status st; + (void)self; + + if (!borrow(env, name, &n)) { + return; + } + st = p_bind_f64(H(zu_stmt, stmt), S(n), L(n), (double)value); + giveback(env, &n); + FAIL_IF(env, st, NULL, "zu_bind_f64"); +} + +static void n_bind_boolean(JNIEnv *env, jclass self, jlong stmt, jbyteArray name, + jboolean value) { + borrowed n; + zu_status st; + (void)self; + + if (!borrow(env, name, &n)) { + return; + } + st = p_bind_bool(H(zu_stmt, stmt), S(n), L(n), value == JNI_TRUE ? 1 : 0); + giveback(env, &n); + FAIL_IF(env, st, NULL, "zu_bind_bool"); +} + +static void n_bind_string(JNIEnv *env, jclass self, jlong stmt, jbyteArray name, + jbyteArray value) { + borrowed n; + borrowed v; + zu_status st; + (void)self; + + if (!borrow(env, name, &n)) { + return; + } + if (!borrow(env, value, &v)) { + giveback(env, &n); + return; + } + st = p_bind_str(H(zu_stmt, stmt), S(n), L(n), S(v), L(v)); + giveback(env, &n); + giveback(env, &v); + FAIL_IF(env, st, NULL, "zu_bind_str"); +} + +static void n_bind_temporal(JNIEnv *env, jclass self, jlong stmt, jbyteArray name, jint kind, + jlong count, jint offset) { + borrowed n; + zu_status st; + (void)self; + + if (!borrow(env, name, &n)) { + return; + } + st = p_bind_temporal(H(zu_stmt, stmt), S(n), L(n), (int32_t)kind, (int64_t)count, + (int32_t)offset); + giveback(env, &n); + FAIL_IF(env, st, NULL, "zu_bind_temporal"); +} + +static void n_bind_null(JNIEnv *env, jclass self, jlong stmt, jbyteArray name) { + borrowed n; + zu_status st; + (void)self; + + if (!borrow(env, name, &n)) { + return; + } + st = p_bind_null(H(zu_stmt, stmt), S(n), L(n)); + giveback(env, &n); + FAIL_IF(env, st, NULL, "zu_bind_null"); +} + +static jlong n_execute(JNIEnv *env, jclass self, jlong stmt) { + zu_result *out = NULL; + zu_error *e = NULL; + zu_status st = p_execute(H(zu_stmt, stmt), &out, &e); + (void)self; + FAIL_IF_V(env, st, e, "zu_execute", 0); + return A(out); +} + +static void n_stmt_close(JNIEnv *env, jclass self, jlong stmt) { + (void)env; + (void)self; + p_stmt_close(H(zu_stmt, stmt)); +} + +/* ---- results ---- */ + +static jlong n_result_rows(JNIEnv *env, jclass self, jlong result) { + (void)env; + (void)self; + return (jlong)p_result_rows(H(zu_result, result)); +} + +static jint n_result_cols(JNIEnv *env, jclass self, jlong result) { + (void)env; + (void)self; + return (jint)p_result_cols(H(zu_result, result)); +} + +static jbyteArray n_result_col_name(JNIEnv *env, jclass self, jlong result, jint col) { + const char *p = NULL; + size_t len = 0; + zu_status st = p_result_col_name(H(zu_result, result), (uint32_t)col, &p, &len); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_result_col_name", NULL); + return bytes(env, p, len); +} + +static jint n_result_cell_type(JNIEnv *env, jclass self, jlong result, jlong row, jint col) { + int32_t out = 0; + zu_status st = + p_result_cell_type(H(zu_result, result), (uint64_t)row, (uint32_t)col, &out); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_result_cell_type", 0); + return (jint)out; +} + +static jbyteArray n_result_cell_string(JNIEnv *env, jclass self, jlong result, jlong row, + jint col) { + const char *p = NULL; + size_t len = 0; + zu_status st = + p_result_cell_str(H(zu_result, result), (uint64_t)row, (uint32_t)col, &p, &len); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_result_cell_str", NULL); + return bytes(env, p, len); +} + +static jbyteArray n_result_gqlstatus(JNIEnv *env, jclass self, jlong result) { + size_t len = 0; + const char *p = p_result_gqlstatus(H(zu_result, result), &len); + (void)self; + return bytes(env, p, len); +} + +static jint n_result_notices(JNIEnv *env, jclass self, jlong result) { + (void)env; + (void)self; + return (jint)p_result_notices(H(zu_result, result)); +} + +static jobject n_result_notice(JNIEnv *env, jclass self, jlong result, jint index) { + zu_error *e = NULL; + zu_status st = p_result_notice(H(zu_result, result), (uint32_t)index, &e); + (void)self; + if (st == ZU_DONE) { + return NULL; + } + FAIL_IF_V(env, st, NULL, "zu_result_notice", NULL); + return e == NULL ? NULL : record(env, e); +} + +static void n_result_free(JNIEnv *env, jclass self, jlong result) { + (void)env; + (void)self; + p_result_free(H(zu_result, result)); +} + +/* + * A column, as a buffer over the engine's own memory. Nothing is copied + * here and nothing is copied on the Java side either: what comes back + * is a window onto the result, good until zu_result_free, which is the + * rule the API module holds callers to. + * + * A null buffer is not a failure. It is the answer for a column the + * result built a row at a time, which has no run of values to point at, + * and the API module falls back to reading it cell by cell. + */ +static jobject window(JNIEnv *env, const void *p, jlong rows, jlong width) { + if (p == NULL) { + return NULL; + } + return (*env)->NewDirectByteBuffer(env, (void *)(intptr_t)p, rows * width); +} + +#define COLUMN(fn, sym, what, type, width) \ + static jobject fn(JNIEnv *env, jclass self, jlong result, jint col, jlong rows) { \ + const type *p = NULL; \ + zu_status st = sym(H(zu_result, result), (uint32_t)col, &p); \ + (void)self; \ + if (st == ZU_DONE) { \ + return NULL; \ + } \ + FAIL_IF_V(env, st, NULL, what, NULL); \ + return window(env, p, rows, width); \ + } + +COLUMN(n_col_longs, p_result_col_i64, "zu_result_col_i64", int64_t, 8) +COLUMN(n_col_doubles, p_result_col_f64, "zu_result_col_f64", double, 8) +COLUMN(n_col_node_offsets, p_result_col_node_offset, "zu_result_col_node_offset", uint64_t, 8) +COLUMN(n_col_valid, p_result_col_valid, "zu_result_col_valid", uint8_t, 1) + +#define CHUNK_COLUMN(fn, sym, what, type, width) \ + static jobject fn(JNIEnv *env, jclass self, jlong result, jlong chunk, jint col, \ + jlong rows) { \ + const type *p = NULL; \ + zu_status st = sym(H(zu_result, result), (uint64_t)chunk, (uint32_t)col, &p); \ + (void)self; \ + if (st == ZU_DONE) { \ + return NULL; \ + } \ + FAIL_IF_V(env, st, NULL, what, NULL); \ + return window(env, p, rows, width); \ + } + +CHUNK_COLUMN(n_chunk_longs, p_result_chunk_col_i64, "zu_result_chunk_col_i64", int64_t, 8) +CHUNK_COLUMN(n_chunk_doubles, p_result_chunk_col_f64, "zu_result_chunk_col_f64", double, 8) +CHUNK_COLUMN(n_chunk_node_offsets, p_result_chunk_col_node_offset, + "zu_result_chunk_col_node_offset", uint64_t, 8) +CHUNK_COLUMN(n_chunk_valid, p_result_chunk_col_valid, "zu_result_chunk_col_valid", uint8_t, 1) + +static jlong n_chunk_count(JNIEnv *env, jclass self, jlong result) { + (void)env; + (void)self; + return (jlong)p_result_chunk_count(H(zu_result, result)); +} + +static jlongArray n_chunk(JNIEnv *env, jclass self, jlong result, jlong chunk) { + uint64_t offset = 0; + uint64_t rows = 0; + jlong out[2]; + zu_status st = p_result_chunk(H(zu_result, result), (uint64_t)chunk, &offset, &rows); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_result_chunk", NULL); + out[0] = (jlong)offset; + out[1] = (jlong)rows; + return longs(env, out, 2); +} + +/* + * The whole result to Arrow, which is the one call that spends what it + * is given. The result is nulled by the engine on every path, including + * the refusal, so the Java side has already let go of the handle before + * this runs and there is nothing here to put back. + */ +static void n_result_arrow(JNIEnv *env, jclass self, jlong conn, jlong result, + jlong rows_per_batch, jlong stream) { + zu_result *r = H(zu_result, result); + zu_error *e = NULL; + zu_status st = p_result_arrow(H(zu_conn, conn), &r, (uint64_t)rows_per_batch, + (struct ArrowArrayStream *)(intptr_t)stream, &e); + (void)self; + FAIL_IF(env, st, e, "zu_result_arrow"); +} + +/* ---- values ---- */ + +static jlong n_result_cell(JNIEnv *env, jclass self, jlong result, jlong row, jint col) { + const zu_value *out = NULL; + zu_status st = p_result_cell(H(zu_result, result), (uint64_t)row, (uint32_t)col, &out); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_result_cell", 0); + return A(out); +} + +static jint n_value_type(JNIEnv *env, jclass self, jlong value) { + (void)env; + (void)self; + return (jint)p_value_type(H(zu_value, value)); +} + +static jboolean n_value_boolean(JNIEnv *env, jclass self, jlong value) { + int32_t out = 0; + zu_status st = p_value_bool(H(zu_value, value), &out); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_value_bool", JNI_FALSE); + return out != 0 ? JNI_TRUE : JNI_FALSE; +} + +static jlong n_value_long(JNIEnv *env, jclass self, jlong value) { + int64_t out = 0; + zu_status st = p_value_i64(H(zu_value, value), &out); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_value_i64", 0); + return (jlong)out; +} + +static jdouble n_value_double(JNIEnv *env, jclass self, jlong value) { + double out = 0; + zu_status st = p_value_f64(H(zu_value, value), &out); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_value_f64", 0); + return (jdouble)out; +} + +static jbyteArray n_value_string(JNIEnv *env, jclass self, jlong value) { + const char *p = NULL; + size_t len = 0; + zu_status st = p_value_str(H(zu_value, value), &p, &len); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_value_str", NULL); + return bytes(env, p, len); +} + +static jlongArray n_value_temporal(JNIEnv *env, jclass self, jlong value) { + int32_t kind = 0; + int64_t count = 0; + int32_t offset = 0; + jlong out[3]; + zu_status st = p_value_temporal(H(zu_value, value), &kind, &count, &offset); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_value_temporal", NULL); + out[0] = (jlong)kind; + out[1] = (jlong)count; + out[2] = (jlong)offset; + return longs(env, out, 3); +} + +static jlongArray n_value_node(JNIEnv *env, jclass self, jlong value) { + uint32_t table = 0; + uint64_t offset = 0; + jlong out[2]; + zu_status st = p_value_node(H(zu_value, value), &table, &offset); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_value_node", NULL); + out[0] = (jlong)table; + out[1] = (jlong)offset; + return longs(env, out, 2); +} + +static jlongArray n_value_rel(JNIEnv *env, jclass self, jlong value) { + uint32_t table = 0; + uint64_t src = 0; + uint64_t dst = 0; + jlong out[3]; + zu_status st = p_value_rel(H(zu_value, value), &table, &src, &dst); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_value_rel", NULL); + out[0] = (jlong)table; + out[1] = (jlong)src; + out[2] = (jlong)dst; + return longs(env, out, 3); +} + +static jlong n_value_length(JNIEnv *env, jclass self, jlong value) { + (void)env; + (void)self; + return (jlong)p_value_len(H(zu_value, value)); +} + +static jlong n_value_at(JNIEnv *env, jclass self, jlong value, jlong index) { + const zu_value *out = NULL; + zu_status st = p_value_at(H(zu_value, value), (uint64_t)index, &out); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_value_at", 0); + return A(out); +} + +static jbyteArray n_value_field(JNIEnv *env, jclass self, jlong value, jlong index) { + const char *p = NULL; + size_t len = 0; + zu_status st = p_value_field(H(zu_value, value), (uint64_t)index, &p, &len); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_value_field", NULL); + return bytes(env, p, len); +} + +/* ---- the loader ---- */ + +static jlong n_loader_create(JNIEnv *env, jclass self, jbyteArray path) { + zu_loader *out = NULL; + zu_error *e = NULL; + zu_status st; + borrowed p; + (void)self; + + if (!borrow(env, path, &p)) { + return 0; + } + st = p_loader_create(S(p), L(p), &out, &e); + giveback(env, &p); + FAIL_IF_V(env, st, e, "zu_loader_create", 0); + return A(out); +} + +static void n_loader_table(JNIEnv *env, jclass self, jlong loader, jbyteArray nodes, + jbyteArray edges, jlong rows) { + borrowed n; + borrowed g; + zu_error *e = NULL; + zu_status st; + (void)self; + + if (!borrow(env, nodes, &n)) { + return; + } + if (!borrow(env, edges, &g)) { + giveback(env, &n); + return; + } + st = p_loader_table(H(zu_loader, loader), S(n), L(n), S(g), L(g), (uint64_t)rows, &e); + giveback(env, &n); + giveback(env, &g); + FAIL_IF(env, st, e, "zu_loader_table"); +} + +static void n_loader_edges(JNIEnv *env, jclass self, jlong loader, jobject from, jobject to, + jlong count) { + zu_error *e = NULL; + zu_status st; + (void)self; + + st = p_loader_edges(H(zu_loader, loader), (const uint32_t *)address(env, from), + (const uint32_t *)address(env, to), (uint64_t)count, &e); + FAIL_IF(env, st, e, "zu_loader_edges"); +} + +#define LOADER_COLUMN(fn, sym, what, type) \ + static void fn(JNIEnv *env, jclass self, jlong loader, jbyteArray name, jobject values, \ + jlong count) { \ + borrowed n; \ + zu_error *e = NULL; \ + zu_status st; \ + (void)self; \ + if (!borrow(env, name, &n)) { \ + return; \ + } \ + st = sym(H(zu_loader, loader), S(n), L(n), (const type *)address(env, values), \ + (uint64_t)count, &e); \ + giveback(env, &n); \ + FAIL_IF(env, st, e, what); \ + } + +LOADER_COLUMN(n_loader_col_longs, p_loader_col_i64, "zu_loader_col_i64", int64_t) +LOADER_COLUMN(n_loader_col_doubles, p_loader_col_f64, "zu_loader_col_f64", double) +LOADER_COLUMN(n_loader_col_booleans, p_loader_col_bool, "zu_loader_col_bool", int32_t) + +static void n_loader_col_temporal(JNIEnv *env, jclass self, jlong loader, jbyteArray name, + jint kind, jobject values, jlong count) { + borrowed n; + zu_error *e = NULL; + zu_status st; + (void)self; + + if (!borrow(env, name, &n)) { + return; + } + st = p_loader_col_temporal(H(zu_loader, loader), S(n), L(n), (int32_t)kind, + (const int64_t *)address(env, values), (uint64_t)count, &e); + giveback(env, &n); + FAIL_IF(env, st, e, "zu_loader_col_temporal"); +} + +/* + * A column of strings, which is the one call that has to gather. The + * values arrive as one array of arrays, already encoded on the Java + * side, and are laid out here as the two parallel arrays the ABI takes. + * Everything is freed before this returns: the engine copies what it + * keeps. + */ +static void n_loader_col_strings(JNIEnv *env, jclass self, jlong loader, jbyteArray name, + jobjectArray values) { + borrowed n; + zu_error *e = NULL; + zu_status st; + jsize count = (*env)->GetArrayLength(env, values); + const char **pointers = NULL; + size_t *lengths = NULL; + jsize i; + jsize made = 0; + (void)self; + + if (!borrow(env, name, &n)) { + return; + } + pointers = (const char **)calloc((size_t)count + 1, sizeof *pointers); + lengths = (size_t *)calloc((size_t)count + 1, sizeof *lengths); + if (pointers == NULL || lengths == NULL) { + free(pointers); + free((void *)lengths); + giveback(env, &n); + raise(env, ZU_ERROR, NULL, "zu_loader_col_str"); + return; + } + for (i = 0; i < count; i++) { + jbyteArray one = (jbyteArray)(*env)->GetObjectArrayElement(env, values, i); + jsize len = (*env)->GetArrayLength(env, one); + char *copy = (char *)malloc((size_t)len + 1); + if (copy == NULL) { + break; + } + (*env)->GetByteArrayRegion(env, one, 0, len, (jbyte *)copy); + copy[len] = '\0'; + pointers[i] = copy; + lengths[i] = (size_t)len; + made++; + (*env)->DeleteLocalRef(env, one); + } + if (made == count) { + st = p_loader_col_str(H(zu_loader, loader), S(n), L(n), pointers, lengths, + (uint64_t)count, &e); + } else { + st = ZU_ERROR; + } + for (i = 0; i < made; i++) { + free((void *)pointers[i]); + } + free(pointers); + free(lengths); + giveback(env, &n); + FAIL_IF(env, st, e, "zu_loader_col_str"); +} + +static void n_loader_finish(JNIEnv *env, jclass self, jlong loader) { + zu_error *e = NULL; + zu_status st = p_loader_finish(H(zu_loader, loader), &e); + (void)self; + FAIL_IF(env, st, e, "zu_loader_finish"); +} + +static void n_loader_free(JNIEnv *env, jclass self, jlong loader) { + (void)env; + (void)self; + p_loader_free(H(zu_loader, loader)); +} + +/* ---- the appender ---- */ + +static jlong n_appender_open(JNIEnv *env, jclass self, jlong conn, jbyteArray table) { + zu_appender *out = NULL; + zu_error *e = NULL; + zu_status st; + borrowed t; + (void)self; + + if (!borrow(env, table, &t)) { + return 0; + } + st = p_appender_open(H(zu_conn, conn), S(t), L(t), &out, &e); + giveback(env, &t); + FAIL_IF_V(env, st, e, "zu_appender_open", 0); + return A(out); +} + +static void n_append_boolean(JNIEnv *env, jclass self, jlong appender, jboolean value) { + zu_error *e = NULL; + zu_status st = + p_append_bool(H(zu_appender, appender), value == JNI_TRUE ? 1 : 0, &e); + (void)self; + FAIL_IF(env, st, e, "zu_append_bool"); +} + +static void n_append_long(JNIEnv *env, jclass self, jlong appender, jlong value) { + zu_error *e = NULL; + zu_status st = p_append_i64(H(zu_appender, appender), (int64_t)value, &e); + (void)self; + FAIL_IF(env, st, e, "zu_append_i64"); +} + +static void n_append_double(JNIEnv *env, jclass self, jlong appender, jdouble value) { + zu_error *e = NULL; + zu_status st = p_append_f64(H(zu_appender, appender), (double)value, &e); + (void)self; + FAIL_IF(env, st, e, "zu_append_f64"); +} + +static void n_append_string(JNIEnv *env, jclass self, jlong appender, jbyteArray value) { + borrowed v; + zu_error *e = NULL; + zu_status st; + (void)self; + + if (!borrow(env, value, &v)) { + return; + } + st = p_append_str(H(zu_appender, appender), S(v), L(v), &e); + giveback(env, &v); + FAIL_IF(env, st, e, "zu_append_str"); +} + +static void n_append_bytes(JNIEnv *env, jclass self, jlong appender, jobject value, + jlong length) { + zu_error *e = NULL; + zu_status st = p_append_bytes(H(zu_appender, appender), + (const uint8_t *)address(env, value), (size_t)length, &e); + (void)self; + FAIL_IF(env, st, e, "zu_append_bytes"); +} + +static void n_append_temporal(JNIEnv *env, jclass self, jlong appender, jint kind, + jlong count) { + zu_error *e = NULL; + zu_status st = + p_append_temporal(H(zu_appender, appender), (int32_t)kind, (int64_t)count, &e); + (void)self; + FAIL_IF(env, st, e, "zu_append_temporal"); +} + +static void n_append_end_row(JNIEnv *env, jclass self, jlong appender) { + zu_error *e = NULL; + zu_status st = p_append_end_row(H(zu_appender, appender), &e); + (void)self; + FAIL_IF(env, st, e, "zu_append_end_row"); +} + +static void n_appender_flush(JNIEnv *env, jclass self, jlong appender) { + zu_error *e = NULL; + zu_status st = p_appender_flush(H(zu_appender, appender), &e); + (void)self; + FAIL_IF(env, st, e, "zu_appender_flush"); +} + +static jlong n_appender_buffered(JNIEnv *env, jclass self, jlong appender) { + uint64_t out = 0; + zu_status st = p_appender_buffered(H(zu_appender, appender), &out); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_appender_buffered", 0); + return (jlong)out; +} + +static jlong n_appender_committed(JNIEnv *env, jclass self, jlong appender) { + uint64_t out = 0; + zu_status st = p_appender_committed(H(zu_appender, appender), &out); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_appender_committed", 0); + return (jlong)out; +} + +static jint n_appender_columns(JNIEnv *env, jclass self, jlong appender) { + uint32_t out = 0; + zu_status st = p_appender_cols(H(zu_appender, appender), &out); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_appender_cols", 0); + return (jint)out; +} + +static jbyteArray n_appender_column_name(JNIEnv *env, jclass self, jlong appender, jint col) { + size_t len = 0; + const char *p = p_appender_col_name(H(zu_appender, appender), (uint32_t)col, &len); + (void)self; + return bytes(env, p, len); +} + +static jlong n_appender_discard(JNIEnv *env, jclass self, jlong appender) { + uint64_t out = 0; + zu_status st = p_appender_discard(H(zu_appender, appender), &out); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_appender_discard", 0); + return (jlong)out; +} + +static jlong n_appender_close(JNIEnv *env, jclass self, jlong appender) { + uint64_t out = 0; + zu_error *e = NULL; + zu_status st = p_appender_close(H(zu_appender, appender), &out, &e); + (void)self; + FAIL_IF_V(env, st, e, "zu_appender_close", 0); + return (jlong)out; +} + +static void n_appender_free(JNIEnv *env, jclass self, jlong appender) { + (void)env; + (void)self; + p_appender_free(H(zu_appender, appender)); +} + +/* ---- frames ---- */ + +/* + * The release callback, which is how a host learns the engine has + * finished with the buffers it lent. It runs once, on a thread of the + * library's, after the last statement reading the frame ends. + * + * Nothing may be thrown out of here either, and for the same reason. + */ +static void on_release(void *owner) { + jobject runnable = (jobject)owner; + int attached = 0; + JNIEnv *env; + + if (runnable == NULL) { + return; + } + env = attach(&attached); + if (env == NULL) { + return; + } + (*env)->CallVoidMethod(env, runnable, m_runnable_run); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionDescribe(env); + (*env)->ExceptionClear(env); + } + (*env)->DeleteGlobalRef(env, runnable); + detach(attached); +} + +static jlong n_frame_new(JNIEnv *env, jclass self, jbyteArray name, jlong rows, + jobject release) { + borrowed n; + zu_frame *out = NULL; + zu_error *e = NULL; + zu_status st; + jobject owner = NULL; + (void)self; + + if (!borrow(env, name, &n)) { + return 0; + } + if (release != NULL) { + owner = (*env)->NewGlobalRef(env, release); + if (owner == NULL) { + giveback(env, &n); + return 0; + } + } + st = p_frame_new(S(n), L(n), (uint64_t)rows, owner, owner == NULL ? NULL : on_release, &out, + &e); + giveback(env, &n); + if (st != ZU_OK) { + /* The frame was never made, so nothing will ever call the release, + and the reference this side took has to go back here. */ + if (owner != NULL) { + (*env)->DeleteGlobalRef(env, owner); + } + raise(env, st, e, "zu_frame_new"); + return 0; + } + return A(out); +} + +static void n_frame_col_int(JNIEnv *env, jclass self, jlong frame, jbyteArray name, + jobject values, jlong count, jint bits, jboolean is_signed, + jlong scale, jint temporal) { + borrowed n; + zu_error *e = NULL; + zu_status st; + (void)self; + + if (!borrow(env, name, &n)) { + return; + } + st = p_frame_col_int(H(zu_frame, frame), S(n), L(n), address(env, values), (uint64_t)count, + (int32_t)bits, is_signed == JNI_TRUE ? 1 : 0, (int64_t)scale, + (int32_t)temporal, &e); + giveback(env, &n); + FAIL_IF(env, st, e, "zu_frame_col_int"); +} + +static void n_frame_col_float(JNIEnv *env, jclass self, jlong frame, jbyteArray name, + jobject values, jlong count, jint bits) { + borrowed n; + zu_error *e = NULL; + zu_status st; + (void)self; + + if (!borrow(env, name, &n)) { + return; + } + st = p_frame_col_float(H(zu_frame, frame), S(n), L(n), address(env, values), + (uint64_t)count, (int32_t)bits, &e); + giveback(env, &n); + FAIL_IF(env, st, e, "zu_frame_col_float"); +} + +static void n_frame_col_bool(JNIEnv *env, jclass self, jlong frame, jbyteArray name, + jobject bitmap, jlong count) { + borrowed n; + zu_error *e = NULL; + zu_status st; + (void)self; + + if (!borrow(env, name, &n)) { + return; + } + st = p_frame_col_bool(H(zu_frame, frame), S(n), L(n), address(env, bitmap), (uint64_t)count, + &e); + giveback(env, &n); + FAIL_IF(env, st, e, "zu_frame_col_bool"); +} + +static void n_frame_col_str(JNIEnv *env, jclass self, jlong frame, jbyteArray name, + jobject offsets, jboolean wide, jobject data, jlong data_length, + jlong count) { + borrowed n; + zu_error *e = NULL; + zu_status st; + (void)self; + + if (!borrow(env, name, &n)) { + return; + } + st = p_frame_col_str(H(zu_frame, frame), S(n), L(n), address(env, offsets), + wide == JNI_TRUE ? 1 : 0, address(env, data), (size_t)data_length, + (uint64_t)count, &e); + giveback(env, &n); + FAIL_IF(env, st, e, "zu_frame_col_str"); +} + +static void n_frame_col_view(JNIEnv *env, jclass self, jlong frame, jbyteArray name, + jobject views, jobjectArray data, jlongArray data_lengths, + jlong count) { + borrowed n; + zu_error *e = NULL; + zu_status st; + jsize buffers = (*env)->GetArrayLength(env, data); + const void **pointers; + size_t *lengths; + jlong *given; + jsize i; + (void)self; + + if (!borrow(env, name, &n)) { + return; + } + pointers = (const void **)calloc((size_t)buffers + 1, sizeof *pointers); + lengths = (size_t *)calloc((size_t)buffers + 1, sizeof *lengths); + if (pointers == NULL || lengths == NULL) { + free(pointers); + free(lengths); + giveback(env, &n); + raise(env, ZU_ERROR, NULL, "zu_frame_col_view"); + return; + } + given = (*env)->GetLongArrayElements(env, data_lengths, NULL); + for (i = 0; i < buffers; i++) { + jobject one = (*env)->GetObjectArrayElement(env, data, i); + pointers[i] = address(env, one); + lengths[i] = given == NULL ? 0 : (size_t)given[i]; + (*env)->DeleteLocalRef(env, one); + } + if (given != NULL) { + (*env)->ReleaseLongArrayElements(env, data_lengths, given, JNI_ABORT); + } + st = p_frame_col_view(H(zu_frame, frame), S(n), L(n), address(env, views), pointers, + lengths, (size_t)buffers, (uint64_t)count, &e); + free(pointers); + free(lengths); + giveback(env, &n); + FAIL_IF(env, st, e, "zu_frame_col_view"); +} + +static void n_frame_free(JNIEnv *env, jclass self, jlong frame) { + (void)env; + (void)self; + p_frame_free(H(zu_frame, frame)); +} + +static void n_conn_register(JNIEnv *env, jclass self, jlong conn, jlong frame) { + zu_error *e = NULL; + zu_status st = p_conn_register(H(zu_conn, conn), H(zu_frame, frame), &e); + (void)self; + FAIL_IF(env, st, e, "zu_conn_register"); +} + +static jboolean n_conn_unregister(JNIEnv *env, jclass self, jlong conn, jbyteArray name) { + borrowed n; + int32_t out = 0; + zu_error *e = NULL; + zu_status st; + (void)self; + + if (!borrow(env, name, &n)) { + return JNI_FALSE; + } + st = p_conn_unregister(H(zu_conn, conn), S(n), L(n), &out, &e); + giveback(env, &n); + FAIL_IF_V(env, st, e, "zu_conn_unregister", JNI_FALSE); + return out != 0 ? JNI_TRUE : JNI_FALSE; +} + +static jlong n_conn_registered_count(JNIEnv *env, jclass self, jlong conn) { + uint64_t out = 0; + zu_status st = p_conn_registered_count(H(zu_conn, conn), &out); + (void)self; + FAIL_IF_V(env, st, NULL, "zu_conn_registered_count", 0); + return (jlong)out; +} + +static jbyteArray n_conn_registered_name(JNIEnv *env, jclass self, jlong conn, jlong index) { + size_t len = 0; + const char *p = p_conn_registered_name(H(zu_conn, conn), (uint64_t)index, &len); + (void)self; + return bytes(env, p, len); +} + +/* ------------------------------------------------------------------ */ +/* binding */ +/* ------------------------------------------------------------------ */ + +static const JNINativeMethod methods[] = { + {"nLoad", "([B)[B", (void *)n_load}, + {"nVersion", "()[B", (void *)n_version}, + {"nConfigSet", "(JJZ[B[B)[J", (void *)n_config_set}, + {"nDatabaseOpen", "([BJJZ)J", (void *)n_database_open}, + {"nDatabaseCreate", "([BJJZ)J", (void *)n_database_create}, + {"nDatabaseMemory", "(JJZ)J", (void *)n_database_memory}, + {"nDatabaseIsMemory", "(J)Z", (void *)n_database_is_memory}, + {"nDatabasePath", "(J)[B", (void *)n_database_path}, + {"nDatabaseClose", "(J)V", (void *)n_database_close}, + {"nConnect", "(J)J", (void *)n_connect}, + {"nOpen", "([B)J", (void *)n_open}, + {"nCreate", "([B)J", (void *)n_create}, + {"nMemory", "()J", (void *)n_memory}, + {"nConnDuplicate", "(J)J", (void *)n_conn_duplicate}, + {"nConnClose", "(J)V", (void *)n_conn_close}, + {"nConnInterrupt", "(J)V", (void *)n_conn_interrupt}, + {"nConnRowsRead", "(J)J", (void *)n_conn_rows_read}, + {"nConnSetProgress", "(JLdev/zudb/Progress;J)J", (void *)n_conn_set_progress}, + {"nWatchFree", "(J)V", (void *)n_watch_free}, + {"nConnInTransaction", "(J)Z", (void *)n_conn_in_transaction}, + {"nBegin", "(JZ)V", (void *)n_begin}, + {"nCommit", "(J)V", (void *)n_commit}, + {"nRollback", "(J)V", (void *)n_rollback}, + {"nQuery", "(J[B)J", (void *)n_query}, + {"nPrepare", "(J[B)J", (void *)n_prepare}, + {"nBindLong", "(J[BJ)V", (void *)n_bind_long}, + {"nBindDouble", "(J[BD)V", (void *)n_bind_double}, + {"nBindBoolean", "(J[BZ)V", (void *)n_bind_boolean}, + {"nBindString", "(J[B[B)V", (void *)n_bind_string}, + {"nBindTemporal", "(J[BIJI)V", (void *)n_bind_temporal}, + {"nBindNull", "(J[B)V", (void *)n_bind_null}, + {"nExecute", "(J)J", (void *)n_execute}, + {"nStmtClose", "(J)V", (void *)n_stmt_close}, + {"nResultRows", "(J)J", (void *)n_result_rows}, + {"nResultCols", "(J)I", (void *)n_result_cols}, + {"nResultColName", "(JI)[B", (void *)n_result_col_name}, + {"nResultCellType", "(JJI)I", (void *)n_result_cell_type}, + {"nResultCellString", "(JJI)[B", (void *)n_result_cell_string}, + {"nResultGqlstatus", "(J)[B", (void *)n_result_gqlstatus}, + {"nResultNotices", "(J)I", (void *)n_result_notices}, + {"nResultNotice", "(JI)Ldev/zudb/Diagnostic;", (void *)n_result_notice}, + {"nResultFree", "(J)V", (void *)n_result_free}, + {"nColLongs", "(JIJ)Ljava/nio/ByteBuffer;", (void *)n_col_longs}, + {"nColDoubles", "(JIJ)Ljava/nio/ByteBuffer;", (void *)n_col_doubles}, + {"nColNodeOffsets", "(JIJ)Ljava/nio/ByteBuffer;", (void *)n_col_node_offsets}, + {"nColValid", "(JIJ)Ljava/nio/ByteBuffer;", (void *)n_col_valid}, + {"nChunkCount", "(J)J", (void *)n_chunk_count}, + {"nChunk", "(JJ)[J", (void *)n_chunk}, + {"nChunkLongs", "(JJIJ)Ljava/nio/ByteBuffer;", (void *)n_chunk_longs}, + {"nChunkDoubles", "(JJIJ)Ljava/nio/ByteBuffer;", (void *)n_chunk_doubles}, + {"nChunkNodeOffsets", "(JJIJ)Ljava/nio/ByteBuffer;", (void *)n_chunk_node_offsets}, + {"nChunkValid", "(JJIJ)Ljava/nio/ByteBuffer;", (void *)n_chunk_valid}, + {"nResultArrow", "(JJJJ)V", (void *)n_result_arrow}, + {"nResultCell", "(JJI)J", (void *)n_result_cell}, + {"nValueType", "(J)I", (void *)n_value_type}, + {"nValueBoolean", "(J)Z", (void *)n_value_boolean}, + {"nValueLong", "(J)J", (void *)n_value_long}, + {"nValueDouble", "(J)D", (void *)n_value_double}, + {"nValueString", "(J)[B", (void *)n_value_string}, + {"nValueTemporal", "(J)[J", (void *)n_value_temporal}, + {"nValueNode", "(J)[J", (void *)n_value_node}, + {"nValueRel", "(J)[J", (void *)n_value_rel}, + {"nValueLength", "(J)J", (void *)n_value_length}, + {"nValueAt", "(JJ)J", (void *)n_value_at}, + {"nValueField", "(JJ)[B", (void *)n_value_field}, + {"nLoaderCreate", "([B)J", (void *)n_loader_create}, + {"nLoaderTable", "(J[B[BJ)V", (void *)n_loader_table}, + {"nLoaderEdges", "(JLjava/nio/Buffer;Ljava/nio/Buffer;J)V", (void *)n_loader_edges}, + {"nLoaderColLongs", "(J[BLjava/nio/Buffer;J)V", (void *)n_loader_col_longs}, + {"nLoaderColDoubles", "(J[BLjava/nio/Buffer;J)V", (void *)n_loader_col_doubles}, + {"nLoaderColBooleans", "(J[BLjava/nio/Buffer;J)V", (void *)n_loader_col_booleans}, + {"nLoaderColStrings", "(J[B[[B)V", (void *)n_loader_col_strings}, + {"nLoaderColTemporal", "(J[BILjava/nio/Buffer;J)V", (void *)n_loader_col_temporal}, + {"nLoaderFinish", "(J)V", (void *)n_loader_finish}, + {"nLoaderFree", "(J)V", (void *)n_loader_free}, + {"nAppenderOpen", "(J[B)J", (void *)n_appender_open}, + {"nAppendBoolean", "(JZ)V", (void *)n_append_boolean}, + {"nAppendLong", "(JJ)V", (void *)n_append_long}, + {"nAppendDouble", "(JD)V", (void *)n_append_double}, + {"nAppendString", "(J[B)V", (void *)n_append_string}, + {"nAppendBytes", "(JLjava/nio/Buffer;J)V", (void *)n_append_bytes}, + {"nAppendTemporal", "(JIJ)V", (void *)n_append_temporal}, + {"nAppendEndRow", "(J)V", (void *)n_append_end_row}, + {"nAppenderFlush", "(J)V", (void *)n_appender_flush}, + {"nAppenderBuffered", "(J)J", (void *)n_appender_buffered}, + {"nAppenderCommitted", "(J)J", (void *)n_appender_committed}, + {"nAppenderColumns", "(J)I", (void *)n_appender_columns}, + {"nAppenderColumnName", "(JI)[B", (void *)n_appender_column_name}, + {"nAppenderDiscard", "(J)J", (void *)n_appender_discard}, + {"nAppenderClose", "(J)J", (void *)n_appender_close}, + {"nAppenderFree", "(J)V", (void *)n_appender_free}, + {"nFrameNew", "([BJLjava/lang/Runnable;)J", (void *)n_frame_new}, + {"nFrameColInt", "(J[BLjava/nio/Buffer;JIZJI)V", (void *)n_frame_col_int}, + {"nFrameColFloat", "(J[BLjava/nio/Buffer;JI)V", (void *)n_frame_col_float}, + {"nFrameColBool", "(J[BLjava/nio/Buffer;J)V", (void *)n_frame_col_bool}, + {"nFrameColStr", "(J[BLjava/nio/Buffer;ZLjava/nio/Buffer;JJ)V", (void *)n_frame_col_str}, + {"nFrameColView", "(J[BLjava/nio/Buffer;[Ljava/nio/Buffer;[JJ)V", + (void *)n_frame_col_view}, + {"nFrameFree", "(J)V", (void *)n_frame_free}, + {"nConnRegister", "(JJ)V", (void *)n_conn_register}, + {"nConnUnregister", "(J[B)Z", (void *)n_conn_unregister}, + {"nConnRegisteredCount", "(J)J", (void *)n_conn_registered_count}, + {"nConnRegisteredName", "(JJ)[B", (void *)n_conn_registered_name}, +}; + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *jvm, void *reserved) { + (void)reserved; + vm = jvm; + return JNI_VERSION_1_8; +} + +/* + * The one method bound by name, which binds the rest. + * + * It is called from the static initialiser of JniBinding, so the class + * it is handed is the one to register against and the loader it can + * find dev.zudb classes through is the right one. Doing this in + * JNI_OnLoad instead would mean FindClass against the system loader, + * which does not see a client on the module path or under an + * application server's loader of its own. + */ +JNIEXPORT jboolean JNICALL Java_dev_zudb_jni_JniBinding_nRegister(JNIEnv *env, jclass self) { + jclass diagnostic; + jclass progress; + jclass runnable; + + diagnostic = (*env)->FindClass(env, "dev/zudb/Diagnostic"); + if (diagnostic == NULL) { + return JNI_FALSE; + } + progress = (*env)->FindClass(env, "dev/zudb/Progress"); + if (progress == NULL) { + return JNI_FALSE; + } + runnable = (*env)->FindClass(env, "java/lang/Runnable"); + if (runnable == NULL) { + return JNI_FALSE; + } + + m_diagnostic = (*env)->GetStaticMethodID(env, self, "diagnostic", + "(I[B[B[BIIII[B[BZ)Ldev/zudb/Diagnostic;"); + m_misuse = + (*env)->GetStaticMethodID(env, self, "misuse", "(ILjava/lang/String;)Ldev/zudb/ZuException;"); + m_to_exception = + (*env)->GetMethodID(env, diagnostic, "toException", "()Ldev/zudb/ZuException;"); + m_progress_at = (*env)->GetMethodID(env, progress, "at", "(JJ)Z"); + m_runnable_run = (*env)->GetMethodID(env, runnable, "run", "()V"); + if (m_diagnostic == NULL || m_misuse == NULL || m_to_exception == NULL || + m_progress_at == NULL || m_runnable_run == NULL) { + return JNI_FALSE; + } + + c_binding = (*env)->NewGlobalRef(env, self); + if (c_binding == NULL) { + return JNI_FALSE; + } + + return (*env)->RegisterNatives(env, self, methods, + (jint)(sizeof methods / sizeof methods[0])) == 0 + ? JNI_TRUE + : JNI_FALSE; +} diff --git a/zudb-jni/src/main/java/dev/zudb/jni/JniBinding.java b/zudb-jni/src/main/java/dev/zudb/jni/JniBinding.java new file mode 100644 index 0000000..875b322 --- /dev/null +++ b/zudb-jni/src/main/java/dev/zudb/jni/JniBinding.java @@ -0,0 +1,1061 @@ +package dev.zudb.jni; + +import dev.zudb.Diagnostic; +import dev.zudb.Progress; +import dev.zudb.Status; +import dev.zudb.ZuException; +import dev.zudb.spi.ZuBinding; +import java.nio.Buffer; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.CharBuffer; +import java.nio.DoubleBuffer; +import java.nio.FloatBuffer; +import java.nio.IntBuffer; +import java.nio.LongBuffer; +import java.nio.ShortBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The C ABI, called through JNI. + * + *

This is the provider for the JDKs that have no Panama: 17 through 21, + * which is still most of what is deployed. It costs a small native shim, one + * per platform, that the artifact carries and unpacks; everything it does is + * the same ABI with the same handles and the same rules, so a program that + * runs on one provider runs on the other without knowing which it got. + * + *

Strings

+ * + *

Every string crosses as a byte array rather than as a {@code String}. + * JNI's own conversions speak modified UTF-8, which spells a character outside + * the basic multilingual plane as a surrogate pair in six bytes and a NUL in + * two. The engine validates real UTF-8, so the first emoji anybody stored + * would be refused as a bad encoding and the first one anybody read back would + * come out mangled. Encoding here costs an array per call on the string paths, + * which is the price of being right, and no column path touches it. + * + *

Buffers

+ * + *

A column comes back as a direct buffer over the engine's own memory, + * made by {@code NewDirectByteBuffer}, so reading a column of a million + * integers allocates nothing here either. A buffer handed the other way has to + * have an address, so one on the heap is copied into a direct buffer for the + * calls that read and are done, and refused for a frame, which keeps the + * pointer. + */ +final class JniBinding implements ZuBinding { + + /** + * The watch cookie each connection is watching through, which is the one + * thing this binding has to remember about a connection. + * + *

It is the address of a cell in the shim holding a global reference to + * the watcher. Keyed by the handle, which is safe because the entry goes + * when the connection closes and not a moment later, so an address the + * allocator hands out again cannot find an old one. + */ + private final Map watches = new ConcurrentHashMap<>(); + + JniBinding() {} + + // ---- strings ---- + + private static byte[] u8(String s) { + return s == null ? null : s.getBytes(StandardCharsets.UTF_8); + } + + private static String str(byte[] b) { + return b == null ? null : new String(b, StandardCharsets.UTF_8); + } + + // ---- buffers ---- + + /** + * A window onto a run of the engine's memory, as the buffer the API module + * hands a caller. + * + *

The size is checked here rather than in the shim so that a column too + * big to address says what to do about it. A {@code java.nio} buffer counts + * in {@code int}, which runs out at two gigabytes, and the chunked accessors + * are exactly the way round that. + */ + private static ByteBuffer window(ByteBuffer raw) { + return raw == null ? null : raw.asReadOnlyBuffer().order(ByteOrder.nativeOrder()); + } + + private static void addressable(long rows, long width, String what) { + long size = rows * 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(); + } + if (rows < 0) { + throw Diagnostic.misuse(Status.MISUSE, what + " was asked for " + rows + " rows") + .toException(); + } + } + + /** How many bytes of a buffer are between its position and its limit. */ + private static long byteSize(Buffer b) { + int width; + if (b instanceof ByteBuffer) { + width = 1; + } else if (b instanceof ShortBuffer || b instanceof CharBuffer) { + width = 2; + } else if (b instanceof IntBuffer || b instanceof FloatBuffer) { + width = 4; + } else { + width = 8; + } + return (long) b.remaining() * width; + } + + /** + * A buffer the engine may keep rather than read once. + * + *

This is the one place a copy is refused instead of made. Everywhere + * else a heap buffer costs a memcpy and nothing else, because the call reads + * it and is done. A frame keeps the pointer for as long as it is registered, + * and a heap buffer has no address anything outside the JVM can keep, so a + * copy here would mean the engine reading a copy for the rest of the frame's + * life. That is a frame that is not a frame, and quietly making one is worse + * than saying so. + */ + private static Buffer lent(Buffer buffer, String name) { + if (buffer == null) { + throw Diagnostic.misuse(Status.MISUSE, "column " + name + " of a frame has no buffer") + .toException(); + } + if (!buffer.isDirect()) { + throw Diagnostic.misuse( + Status.MISUSE, + "column " + + name + + " of a frame is on the heap, and a frame is read where it lies rather than" + + " copied, so it wants a buffer from ByteBuffer.allocateDirect") + .toException(); + } + // The shim asks the JVM for the address of a buffer, and that answer + // is the start of the buffer rather than of what is left in it. A + // slice is the same memory from the position on, so the two agree. + return buffer.position() == 0 ? buffer : buffer.slice(); + } + + /** The same buffer if it is direct, and a direct copy of it if it is not. */ + private static LongBuffer pass(LongBuffer values) { + if (values.isDirect()) { + return values.position() == 0 ? values : values.slice(); + } + LongBuffer copy = + ByteBuffer.allocateDirect(values.remaining() * 8) + .order(ByteOrder.nativeOrder()) + .asLongBuffer(); + copy.put(values.duplicate()); + copy.flip(); + return copy; + } + + private static DoubleBuffer pass(DoubleBuffer values) { + if (values.isDirect()) { + return values.position() == 0 ? values : values.slice(); + } + DoubleBuffer copy = + ByteBuffer.allocateDirect(values.remaining() * 8) + .order(ByteOrder.nativeOrder()) + .asDoubleBuffer(); + copy.put(values.duplicate()); + copy.flip(); + return copy; + } + + private static IntBuffer pass(IntBuffer values) { + if (values.isDirect()) { + return values.position() == 0 ? values : values.slice(); + } + IntBuffer copy = + ByteBuffer.allocateDirect(values.remaining() * 4) + .order(ByteOrder.nativeOrder()) + .asIntBuffer(); + copy.put(values.duplicate()); + copy.flip(); + return copy; + } + + private static ByteBuffer pass(ByteBuffer values) { + if (values.isDirect()) { + return values.position() == 0 ? values : values.slice(); + } + ByteBuffer copy = ByteBuffer.allocateDirect(values.remaining()).order(ByteOrder.nativeOrder()); + copy.put(values.duplicate()); + copy.flip(); + return copy; + } + + // ---- what the shim calls back ---- + + /** + * A diagnostic, built here because the mapping from the ABI's numbers to + * this client's two enums belongs in one place and that place is the API + * module. + * + *

Called from the shim and from nowhere else, which is why it is private + * and why nothing in this file appears to use it. + */ + private static Diagnostic diagnostic( + int status, + byte[] message, + byte[] code, + byte[] condition, + int severity, + int line, + int column, + int offset, + byte[] excerpt, + byte[] docUrl, + boolean retryable) { + return Diagnostic.of( + status, + str(message), + str(code), + str(condition), + severity, + line, + column, + offset, + str(excerpt), + str(docUrl), + retryable); + } + + /** The exception a status that carries no error of its own is. */ + private static ZuException misuse(int status, String what) { + return Diagnostic.misuse(Status.of(status), what + " answered " + Status.of(status)) + .toException(); + } + + // ---- the interface ---- + + @Override + public String version() { + return str(nVersion()); + } + + @Override + public long[] configSet( + long memoryLimit, long threads, boolean readOnly, String key, String value) { + return nConfigSet(memoryLimit, threads, readOnly, u8(key), u8(value)); + } + + @Override + public long databaseOpen(String path, long memoryLimit, long threads, boolean readOnly) { + return nDatabaseOpen(u8(path), memoryLimit, threads, readOnly); + } + + @Override + public long databaseCreate(String path, long memoryLimit, long threads, boolean readOnly) { + return nDatabaseCreate(u8(path), memoryLimit, threads, readOnly); + } + + @Override + public long databaseMemory(long memoryLimit, long threads, boolean readOnly) { + return nDatabaseMemory(memoryLimit, threads, readOnly); + } + + @Override + public boolean databaseIsMemory(long db) { + return nDatabaseIsMemory(db); + } + + @Override + public String databasePath(long db) { + return str(nDatabasePath(db)); + } + + @Override + public void databaseClose(long db) { + nDatabaseClose(db); + } + + @Override + public long connect(long db) { + return nConnect(db); + } + + @Override + public long open(String path) { + return nOpen(u8(path)); + } + + @Override + public long create(String path) { + return nCreate(u8(path)); + } + + @Override + public long memory() { + return nMemory(); + } + + @Override + public long connDuplicate(long conn) { + return nConnDuplicate(conn); + } + + @Override + public void connClose(long conn) { + nConnClose(conn); + spend(watches.remove(conn)); + } + + @Override + public void connInterrupt(long conn) { + nConnInterrupt(conn); + } + + @Override + public long connRowsRead(long conn) { + return nConnRowsRead(conn); + } + + @Override + public void connSetProgress(long conn, Progress watcher, long intervalMillis) { + long cookie = nConnSetProgress(conn, watcher, intervalMillis); + // The old arrangement goes only once the engine has the new one, so + // there is no moment at which the reference the engine holds is one + // this side has already given back. + Long before = cookie == 0 ? watches.remove(conn) : watches.put(conn, cookie); + spend(before); + } + + private static void spend(Long cookie) { + if (cookie != null && cookie != 0) { + nWatchFree(cookie); + } + } + + @Override + public boolean connInTransaction(long conn) { + return nConnInTransaction(conn); + } + + @Override + public void begin(long conn, boolean readOnly) { + nBegin(conn, readOnly); + } + + @Override + public void commit(long conn) { + nCommit(conn); + } + + @Override + public void rollback(long conn) { + nRollback(conn); + } + + @Override + public long query(long conn, String statement) { + return nQuery(conn, u8(statement)); + } + + @Override + public long prepare(long conn, String statement) { + return nPrepare(conn, u8(statement)); + } + + @Override + public void bindLong(long stmt, String name, long value) { + nBindLong(stmt, u8(name), value); + } + + @Override + public void bindDouble(long stmt, String name, double value) { + nBindDouble(stmt, u8(name), value); + } + + @Override + public void bindBoolean(long stmt, String name, boolean value) { + nBindBoolean(stmt, u8(name), value); + } + + @Override + public void bindString(long stmt, String name, String value) { + nBindString(stmt, u8(name), u8(value)); + } + + @Override + public void bindTemporal(long stmt, String name, int kind, long count, int offsetMinutes) { + nBindTemporal(stmt, u8(name), kind, count, offsetMinutes); + } + + @Override + public void bindNull(long stmt, String name) { + nBindNull(stmt, u8(name)); + } + + @Override + public long execute(long stmt) { + return nExecute(stmt); + } + + @Override + public void stmtClose(long stmt) { + nStmtClose(stmt); + } + + @Override + public long resultRows(long result) { + return nResultRows(result); + } + + @Override + public int resultCols(long result) { + return nResultCols(result); + } + + @Override + public String resultColName(long result, int col) { + return str(nResultColName(result, col)); + } + + @Override + public int resultCellType(long result, long row, int col) { + return nResultCellType(result, row, col); + } + + @Override + public String resultCellString(long result, long row, int col) { + return str(nResultCellString(result, row, col)); + } + + @Override + public String resultGqlstatus(long result) { + return str(nResultGqlstatus(result)); + } + + @Override + public int resultNotices(long result) { + return nResultNotices(result); + } + + @Override + public Diagnostic resultNotice(long result, int index) { + return nResultNotice(result, index); + } + + @Override + public void resultFree(long result) { + nResultFree(result); + } + + @Override + public LongBuffer colLongs(long result, int col, long rows) { + addressable(rows, 8, "zu_result_col_i64"); + ByteBuffer raw = window(nColLongs(result, col, rows)); + return raw == null ? null : raw.asLongBuffer(); + } + + @Override + public DoubleBuffer colDoubles(long result, int col, long rows) { + addressable(rows, 8, "zu_result_col_f64"); + ByteBuffer raw = window(nColDoubles(result, col, rows)); + return raw == null ? null : raw.asDoubleBuffer(); + } + + @Override + public LongBuffer colNodeOffsets(long result, int col, long rows) { + addressable(rows, 8, "zu_result_col_node_offset"); + ByteBuffer raw = window(nColNodeOffsets(result, col, rows)); + return raw == null ? null : raw.asLongBuffer(); + } + + @Override + public ByteBuffer colValid(long result, int col, long rows) { + addressable(rows, 1, "zu_result_col_valid"); + return window(nColValid(result, col, rows)); + } + + @Override + public long chunkCount(long result) { + return nChunkCount(result); + } + + @Override + public long[] chunk(long result, long chunk) { + return nChunk(result, chunk); + } + + @Override + public LongBuffer chunkLongs(long result, long chunk, int col, long rows) { + addressable(rows, 8, "zu_result_chunk_col_i64"); + ByteBuffer raw = window(nChunkLongs(result, chunk, col, rows)); + return raw == null ? null : raw.asLongBuffer(); + } + + @Override + public DoubleBuffer chunkDoubles(long result, long chunk, int col, long rows) { + addressable(rows, 8, "zu_result_chunk_col_f64"); + ByteBuffer raw = window(nChunkDoubles(result, chunk, col, rows)); + return raw == null ? null : raw.asDoubleBuffer(); + } + + @Override + public LongBuffer chunkNodeOffsets(long result, long chunk, int col, long rows) { + addressable(rows, 8, "zu_result_chunk_col_node_offset"); + ByteBuffer raw = window(nChunkNodeOffsets(result, chunk, col, rows)); + return raw == null ? null : raw.asLongBuffer(); + } + + @Override + public ByteBuffer chunkValid(long result, long chunk, int col, long rows) { + addressable(rows, 1, "zu_result_chunk_col_valid"); + return window(nChunkValid(result, chunk, col, rows)); + } + + @Override + public void resultArrow(long conn, long result, long rowsPerBatch, long stream) { + nResultArrow(conn, result, rowsPerBatch, stream); + } + + @Override + public long resultCell(long result, long row, int col) { + return nResultCell(result, row, col); + } + + @Override + public int valueType(long value) { + return nValueType(value); + } + + @Override + public boolean valueBoolean(long value) { + return nValueBoolean(value); + } + + @Override + public long valueLong(long value) { + return nValueLong(value); + } + + @Override + public double valueDouble(long value) { + return nValueDouble(value); + } + + @Override + public String valueString(long value) { + return str(nValueString(value)); + } + + @Override + public long[] valueTemporal(long value) { + return nValueTemporal(value); + } + + @Override + public long[] valueNode(long value) { + return nValueNode(value); + } + + @Override + public long[] valueRel(long value) { + return nValueRel(value); + } + + @Override + public long valueLength(long value) { + return nValueLength(value); + } + + @Override + public long valueAt(long value, long index) { + return nValueAt(value, index); + } + + @Override + public String valueField(long value, long index) { + return str(nValueField(value, index)); + } + + @Override + public long loaderCreate(String path) { + return nLoaderCreate(u8(path)); + } + + @Override + public void loaderTable(long loader, String nodes, String edges, long rows) { + nLoaderTable(loader, u8(nodes), u8(edges), rows); + } + + @Override + public void loaderEdges(long loader, IntBuffer from, IntBuffer to) { + int count = from.remaining(); + if (to.remaining() != count) { + throw Diagnostic.misuse( + Status.MISUSE, + "an edge starts somewhere and ends somewhere, and there are " + + count + + " starts against " + + to.remaining() + + " ends") + .toException(); + } + nLoaderEdges(loader, pass(from), pass(to), count); + } + + @Override + public void loaderColumnLongs(long loader, String name, LongBuffer values) { + nLoaderColLongs(loader, u8(name), pass(values), values.remaining()); + } + + @Override + public void loaderColumnDoubles(long loader, String name, DoubleBuffer values) { + nLoaderColDoubles(loader, u8(name), pass(values), values.remaining()); + } + + @Override + public void loaderColumnBooleans(long loader, String name, IntBuffer values) { + nLoaderColBooleans(loader, u8(name), pass(values), values.remaining()); + } + + @Override + public void loaderColumnStrings(long loader, String name, List values) { + byte[][] encoded = new byte[values.size()][]; + for (int i = 0; i < encoded.length; i++) { + String v = values.get(i); + if (v == null) { + throw Diagnostic.misuse( + Status.MISUSE, + "row " + + i + + " of column " + + name + + " is no value at all, and a loaded column holds a value a row") + .toException(); + } + encoded[i] = u8(v); + } + nLoaderColStrings(loader, u8(name), encoded); + } + + @Override + public void loaderColumnTemporal(long loader, String name, int kind, LongBuffer values) { + nLoaderColTemporal(loader, u8(name), kind, pass(values), values.remaining()); + } + + @Override + public void loaderFinish(long loader) { + nLoaderFinish(loader); + } + + @Override + public void loaderFree(long loader) { + nLoaderFree(loader); + } + + @Override + public long appenderOpen(long conn, String table) { + return nAppenderOpen(conn, u8(table)); + } + + @Override + public void appendBoolean(long appender, boolean value) { + nAppendBoolean(appender, value); + } + + @Override + public void appendLong(long appender, long value) { + nAppendLong(appender, value); + } + + @Override + public void appendDouble(long appender, double value) { + nAppendDouble(appender, value); + } + + @Override + public void appendString(long appender, String value) { + nAppendString(appender, u8(value)); + } + + @Override + public void appendBytes(long appender, ByteBuffer value) { + nAppendBytes(appender, pass(value), value.remaining()); + } + + @Override + public void appendTemporal(long appender, int kind, long count) { + nAppendTemporal(appender, kind, count); + } + + @Override + public void appendEndRow(long appender) { + nAppendEndRow(appender); + } + + @Override + public void appenderFlush(long appender) { + nAppenderFlush(appender); + } + + @Override + public long appenderBuffered(long appender) { + return nAppenderBuffered(appender); + } + + @Override + public long appenderCommitted(long appender) { + return nAppenderCommitted(appender); + } + + @Override + public int appenderColumns(long appender) { + return nAppenderColumns(appender); + } + + @Override + public String appenderColumnName(long appender, int col) { + return str(nAppenderColumnName(appender, col)); + } + + @Override + public long appenderDiscard(long appender) { + return nAppenderDiscard(appender); + } + + @Override + public long appenderClose(long appender) { + return nAppenderClose(appender); + } + + @Override + public void appenderFree(long appender) { + nAppenderFree(appender); + } + + @Override + public long frameNew(String name, long rows, Runnable release) { + return nFrameNew(u8(name), rows, release); + } + + @Override + public void frameColumnInts( + long frame, + String name, + Buffer values, + long count, + int bits, + boolean signed, + long scale, + int temporal) { + nFrameColInt(frame, u8(name), lent(values, name), count, bits, signed, scale, temporal); + } + + @Override + public void frameColumnFloats(long frame, String name, Buffer values, long count, int bits) { + nFrameColFloat(frame, u8(name), lent(values, name), count, bits); + } + + @Override + public void frameColumnBooleans(long frame, String name, Buffer bitmap, long count) { + nFrameColBool(frame, u8(name), lent(bitmap, name), count); + } + + @Override + public void frameColumnStrings( + long frame, String name, Buffer offsets, boolean wide, Buffer data, long count) { + Buffer characters = lent(data, name); + nFrameColStr( + frame, u8(name), lent(offsets, name), wide, characters, byteSize(characters), count); + } + + @Override + public void frameColumnViews( + long frame, String name, Buffer views, List data, long count) { + Buffer[] buffers = new Buffer[data.size()]; + long[] lengths = new long[buffers.length]; + for (int i = 0; i < buffers.length; i++) { + buffers[i] = lent(data.get(i), name); + lengths[i] = byteSize(buffers[i]); + } + nFrameColView(frame, u8(name), lent(views, name), buffers, lengths, count); + } + + @Override + public void frameFree(long frame) { + nFrameFree(frame); + } + + @Override + public void connRegister(long conn, long frame) { + nConnRegister(conn, frame); + } + + @Override + public boolean connUnregister(long conn, String name) { + return nConnUnregister(conn, u8(name)); + } + + @Override + public long connRegisteredCount(long conn) { + return nConnRegisteredCount(conn); + } + + @Override + public String connRegisteredName(long conn, long index) { + return str(nConnRegisteredName(conn, index)); + } + + // ---- the shim ---- + + /** + * Binds every native below to the shim, and the shim to this class. + * + *

One method with a name JNI derives, which registers the rest and looks + * up what it has to call back into. It is done from here rather than from + * the shim's own load hook because the hook cannot find a class a module + * path or an application server's loader holds, and the class handed to a + * static native is the right one by construction. + * + * @return whether it bound + */ + static native boolean nRegister(); + + /** + * Opens libzu and resolves every call this client makes. + * + * @param path the library, as UTF-8 bytes + * @return null if it opened and had everything, and otherwise why not + */ + static native byte[] nLoad(byte[] path); + + private static native byte[] nVersion(); + + private static native long[] nConfigSet( + long memoryLimit, long threads, boolean readOnly, byte[] key, byte[] value); + + private static native long nDatabaseOpen( + byte[] path, long memoryLimit, long threads, boolean readOnly); + + private static native long nDatabaseCreate( + byte[] path, long memoryLimit, long threads, boolean readOnly); + + private static native long nDatabaseMemory(long memoryLimit, long threads, boolean readOnly); + + private static native boolean nDatabaseIsMemory(long db); + + private static native byte[] nDatabasePath(long db); + + private static native void nDatabaseClose(long db); + + private static native long nConnect(long db); + + private static native long nOpen(byte[] path); + + private static native long nCreate(byte[] path); + + private static native long nMemory(); + + private static native long nConnDuplicate(long conn); + + private static native void nConnClose(long conn); + + private static native void nConnInterrupt(long conn); + + private static native long nConnRowsRead(long conn); + + private static native long nConnSetProgress(long conn, Progress watcher, long intervalMillis); + + private static native void nWatchFree(long cookie); + + private static native boolean nConnInTransaction(long conn); + + private static native void nBegin(long conn, boolean readOnly); + + private static native void nCommit(long conn); + + private static native void nRollback(long conn); + + private static native long nQuery(long conn, byte[] statement); + + private static native long nPrepare(long conn, byte[] statement); + + private static native void nBindLong(long stmt, byte[] name, long value); + + private static native void nBindDouble(long stmt, byte[] name, double value); + + private static native void nBindBoolean(long stmt, byte[] name, boolean value); + + private static native void nBindString(long stmt, byte[] name, byte[] value); + + private static native void nBindTemporal( + long stmt, byte[] name, int kind, long count, int offsetMinutes); + + private static native void nBindNull(long stmt, byte[] name); + + private static native long nExecute(long stmt); + + private static native void nStmtClose(long stmt); + + private static native long nResultRows(long result); + + private static native int nResultCols(long result); + + private static native byte[] nResultColName(long result, int col); + + private static native int nResultCellType(long result, long row, int col); + + private static native byte[] nResultCellString(long result, long row, int col); + + private static native byte[] nResultGqlstatus(long result); + + private static native int nResultNotices(long result); + + private static native Diagnostic nResultNotice(long result, int index); + + private static native void nResultFree(long result); + + private static native ByteBuffer nColLongs(long result, int col, long rows); + + private static native ByteBuffer nColDoubles(long result, int col, long rows); + + private static native ByteBuffer nColNodeOffsets(long result, int col, long rows); + + private static native ByteBuffer nColValid(long result, int col, long rows); + + private static native long nChunkCount(long result); + + private static native long[] nChunk(long result, long chunk); + + private static native ByteBuffer nChunkLongs(long result, long chunk, int col, long rows); + + private static native ByteBuffer nChunkDoubles(long result, long chunk, int col, long rows); + + private static native ByteBuffer nChunkNodeOffsets(long result, long chunk, int col, long rows); + + private static native ByteBuffer nChunkValid(long result, long chunk, int col, long rows); + + private static native void nResultArrow(long conn, long result, long rowsPerBatch, long stream); + + private static native long nResultCell(long result, long row, int col); + + private static native int nValueType(long value); + + private static native boolean nValueBoolean(long value); + + private static native long nValueLong(long value); + + private static native double nValueDouble(long value); + + private static native byte[] nValueString(long value); + + private static native long[] nValueTemporal(long value); + + private static native long[] nValueNode(long value); + + private static native long[] nValueRel(long value); + + private static native long nValueLength(long value); + + private static native long nValueAt(long value, long index); + + private static native byte[] nValueField(long value, long index); + + private static native long nLoaderCreate(byte[] path); + + private static native void nLoaderTable(long loader, byte[] nodes, byte[] edges, long rows); + + private static native void nLoaderEdges(long loader, Buffer from, Buffer to, long count); + + private static native void nLoaderColLongs(long loader, byte[] name, Buffer values, long count); + + private static native void nLoaderColDoubles(long loader, byte[] name, Buffer values, long count); + + private static native void nLoaderColBooleans( + long loader, byte[] name, Buffer values, long count); + + private static native void nLoaderColStrings(long loader, byte[] name, byte[][] values); + + private static native void nLoaderColTemporal( + long loader, byte[] name, int kind, Buffer values, long count); + + private static native void nLoaderFinish(long loader); + + private static native void nLoaderFree(long loader); + + private static native long nAppenderOpen(long conn, byte[] table); + + private static native void nAppendBoolean(long appender, boolean value); + + private static native void nAppendLong(long appender, long value); + + private static native void nAppendDouble(long appender, double value); + + private static native void nAppendString(long appender, byte[] value); + + private static native void nAppendBytes(long appender, Buffer value, long length); + + private static native void nAppendTemporal(long appender, int kind, long count); + + private static native void nAppendEndRow(long appender); + + private static native void nAppenderFlush(long appender); + + private static native long nAppenderBuffered(long appender); + + private static native long nAppenderCommitted(long appender); + + private static native int nAppenderColumns(long appender); + + private static native byte[] nAppenderColumnName(long appender, int col); + + private static native long nAppenderDiscard(long appender); + + private static native long nAppenderClose(long appender); + + private static native void nAppenderFree(long appender); + + private static native long nFrameNew(byte[] name, long rows, Runnable release); + + private static native void nFrameColInt( + long frame, + byte[] name, + Buffer values, + long count, + int bits, + boolean signed, + long scale, + int temporal); + + private static native void nFrameColFloat( + long frame, byte[] name, Buffer values, long count, int bits); + + private static native void nFrameColBool(long frame, byte[] name, Buffer bitmap, long count); + + private static native void nFrameColStr( + long frame, byte[] name, Buffer offsets, boolean wide, Buffer data, long dataLength, + long count); + + private static native void nFrameColView( + long frame, byte[] name, Buffer views, Buffer[] data, long[] lengths, long count); + + private static native void nFrameFree(long frame); + + private static native void nConnRegister(long conn, long frame); + + private static native boolean nConnUnregister(long conn, byte[] name); + + private static native long nConnRegisteredCount(long conn); + + private static native byte[] nConnRegisteredName(long conn, long index); +} diff --git a/zudb-jni/src/main/java/dev/zudb/jni/JniProvider.java b/zudb-jni/src/main/java/dev/zudb/jni/JniProvider.java new file mode 100644 index 0000000..a580a92 --- /dev/null +++ b/zudb-jni/src/main/java/dev/zudb/jni/JniProvider.java @@ -0,0 +1,51 @@ +package dev.zudb.jni; + +import dev.zudb.spi.ProviderUnavailableException; +import dev.zudb.spi.ZuBinding; +import dev.zudb.spi.ZuProvider; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +/** + * The provider that binds zu through JNI. + * + *

This is the one for JDK 17 through 21, which have no Foreign Function + * and Memory API and are most of what is running in production. It costs a + * native shim per platform, which this artifact carries, and it is a little + * slower on the string paths than Panama is. Everything else about it is the + * same: the same handles, the same failures, the same buffers over the + * engine's own memory. + * + *

Its priority is below Panama's, so a JDK that has both takes Panama and + * this one is never loaded. Nothing chooses it by name. + */ +public final class JniProvider 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 JniProvider() {} + + @Override + public String name() { + return "jni"; + } + + @Override + public int priority() { + return 50; + } + + @Override + public ZuBinding load(Path library) { + Shim.ensure(); + byte[] why = JniBinding.nLoad(library.toAbsolutePath().toString().getBytes(StandardCharsets.UTF_8)); + if (why != null) { + throw new ProviderUnavailableException(new String(why, StandardCharsets.UTF_8)); + } + return new JniBinding(); + } +} diff --git a/zudb-jni/src/main/java/dev/zudb/jni/Shim.java b/zudb-jni/src/main/java/dev/zudb/jni/Shim.java new file mode 100644 index 0000000..52fd59f --- /dev/null +++ b/zudb-jni/src/main/java/dev/zudb/jni/Shim.java @@ -0,0 +1,110 @@ +package dev.zudb.jni; + +import dev.zudb.spi.Natives; +import dev.zudb.spi.ProviderUnavailableException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +/** + * Finds and loads the native shim, once. + * + *

The shim is the second library this provider needs, and it is not libzu. + * libzu is the engine, it is large, and the API module already knows four + * places to look for it. The shim is a few tens of kilobytes of C that turns + * a JNI call into a call through a function pointer, one build per platform, + * and it ships inside this artifact for the platforms that are tier one. + * + *

The order below is the API module's order for libzu, for the same + * reasons and with the same escape hatch first: somebody who built their own + * shim, or who is running one out of a build directory, names it and is + * believed. Only then does the jar's own copy get used, and only then the + * platform's search, which is what a distribution packaging this would want. + * + *

Loading is done here rather than in a static initialiser on the binding + * because a failure in a static initialiser is an + * {@link ExceptionInInitializerError} the first time and a bare + * {@link NoClassDefFoundError} every time after, and neither says which of + * four places was looked in. This throws + * {@link ProviderUnavailableException} with all four. + */ +final class Shim { + + private Shim() {} + + /** What the shim is called, before the platform decorates it. */ + private static final String NAME = "zudb_jni"; + + private static boolean loaded; + + /** + * Loads the shim and binds it to the binding, or says why it could not. + * + *

Called under the class lock, which is enough: the work is done once + * and everything after it is a read of a boolean that the same lock + * published. + * + * @throws ProviderUnavailableException if there is no shim for this machine + */ + static synchronized void ensure() { + if (loaded) { + return; + } + List looked = new ArrayList<>(); + Path file = named(System.getProperty("zu.jni.library"), "the zu.jni.library property", looked); + if (file == null) { + file = named(System.getenv("ZU_JNI_LIBRARY"), "the ZU_JNI_LIBRARY variable", looked); + } + + String library = System.mapLibraryName(NAME); + if (file == null) { + String resource = "dev/zudb/jni/" + Natives.flavour() + "/" + library; + file = Natives.unpack(resource, library, Shim.class); + if (file == null) { + looked.add("no " + resource + " on the classpath"); + } + } + + try { + if (file != null) { + System.load(file.toAbsolutePath().toString()); + } else { + // The last resort, and the one a distribution that packages this + // properly would rely on: LD_LIBRARY_PATH, /usr/local/lib, + // java.library.path, whatever this platform calls its search. + System.loadLibrary(NAME); + } + } catch (UnsatisfiedLinkError e) { + if (file == null) { + looked.add("no " + library + " on java.library.path: " + e.getMessage()); + } else { + looked.add(file + " did not load: " + e.getMessage()); + } + throw new ProviderUnavailableException( + "the JNI provider needs its native shim, and " + String.join(", and ", looked), e); + } + + if (!JniBinding.nRegister()) { + throw new ProviderUnavailableException( + "the JNI shim loaded and then could not bind itself to " + + JniBinding.class.getName() + + ", which means the shim and this jar are from different releases"); + } + loaded = true; + } + + /** A path somebody named, checked for being there so the reason is theirs. */ + private static Path named(String value, String where, List looked) { + if (value == null || value.isEmpty()) { + return null; + } + Path path = Paths.get(value); + if (Files.isRegularFile(path)) { + return path; + } + looked.add(where + " names " + value + ", which is not a file"); + return null; + } +} diff --git a/zudb-jni/src/main/java/module-info.java b/zudb-jni/src/main/java/module-info.java new file mode 100644 index 0000000..86572e2 --- /dev/null +++ b/zudb-jni/src/main/java/module-info.java @@ -0,0 +1,15 @@ +/** + * The zu provider over JNI, for the JDKs that have no Panama. + * + *

Nothing here is exported, the same as the Panama provider: 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. Which of the two providers a + * program ended up on is a log line rather than a compile-time fact, which is + * the point of both. + */ +module dev.zudb.jni { + requires dev.zudb; + + provides dev.zudb.spi.ZuProvider with + dev.zudb.jni.JniProvider; +} diff --git a/zudb-jni/src/main/resources/META-INF/services/dev.zudb.spi.ZuProvider b/zudb-jni/src/main/resources/META-INF/services/dev.zudb.spi.ZuProvider new file mode 100644 index 0000000..349ce86 --- /dev/null +++ b/zudb-jni/src/main/resources/META-INF/services/dev.zudb.spi.ZuProvider @@ -0,0 +1 @@ +dev.zudb.jni.JniProvider diff --git a/zudb-jni/src/test/java/dev/zudb/jni/JniTest.java b/zudb-jni/src/test/java/dev/zudb/jni/JniTest.java new file mode 100644 index 0000000..c97c4cd --- /dev/null +++ b/zudb-jni/src/test/java/dev/zudb/jni/JniTest.java @@ -0,0 +1,204 @@ +package dev.zudb.jni; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +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 static org.junit.jupiter.api.Assumptions.assumeTrue; + +import dev.zudb.Connection; +import dev.zudb.Database; +import dev.zudb.Frame; +import dev.zudb.Result; +import dev.zudb.Statement; +import dev.zudb.ZuException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.LongBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * The JNI provider, end to end. + * + *

The behaviour every provider owes is checked by the suite both of them + * run, and is not repeated here. What is here is the handful of things that + * are true of this provider and of no other: that a string survives a + * crossing JNI's own conversions would mangle, that a column is a window onto + * the engine's memory rather than a copy, that a callback arriving on a + * thread the JVM has never seen finds its way back into Java, and that a + * frame refuses a buffer it could not keep. + */ +class JniTest { + + private static Database db; + private static Connection conn; + + @BeforeAll + static void engine() { + Path library = locate(); + assumeTrue(library != null, "no libzu: set -Dzu.library to run these"); + System.setProperty("zu.library", library.toString()); + // Panama wins on priority wherever it runs, and this suite is about + // the other one, so it is named rather than left to the contest. + System.setProperty("zu.provider", "jni"); + db = Database.memory(); + conn = db.connect(); + } + + @AfterAll + static void done() { + if (conn != null) { + conn.close(); + } + if (db != null) { + db.close(); + } + } + + /** + * The reason every string in this provider is a byte array. + * + *

JNI's own {@code NewStringUTF} and {@code GetStringUTFChars} speak + * modified UTF-8, in which a character outside the basic multilingual plane + * is a surrogate pair in six bytes rather than the four the standard says. + * Hand one of those to an engine that validates real UTF-8 and it is + * refused; read one back through the same conversion and it comes out + * mangled. This is the test that would fail the day somebody decided a + * jstring would be tidier. + */ + @Test + void aStringOutsideTheBasicMultilingualPlaneCrossesIntact() { + String[] awkward = { + "ada", // plain + "héllo wörld", // two bytes a character + "日本語", // three + "🜛 alchemy 🝗", // four, which is where modified UTF-8 goes wrong + "👩‍💻", // a surrogate pair inside a longer sequence + }; + for (String s : awkward) { + try (Statement stmt = conn.prepare("RETURN $s AS echoed")) { + try (Result r = stmt.bind("s", s).execute()) { + assertEquals(s, r.row(0).getString(0), s); + } + } + } + } + + @Test + void aColumnIsAWindowOntoTheEnginesOwnMemory() { + try (Result r = conn.query("UNWIND [1, 2, 3, 4, 5] AS n RETURN n")) { + LongBuffer column = r.longs(0); + assertNotNull(column, "an integer column reads as a buffer"); + assertTrue(column.isDirect(), "and the buffer is the engine's, not a copy of it"); + assertEquals(5, column.remaining()); + long[] read = new long[5]; + column.get(read); + assertArrayEquals(new long[] {1, 2, 3, 4, 5}, read); + } + } + + /** + * A callback arrives on a thread of the engine's, which the JVM has never + * seen, so the shim attaches it for the call and detaches after. A thread + * that exits while attached takes the process with it, so a green run of + * this test is also the check that it does not. + */ + @Test + void progressArrivesFromAThreadTheJvmHasNeverSeen() { + long rows = 3000; + ByteBuffer bytes = ByteBuffer.allocateDirect((int) rows * 8).order(ByteOrder.nativeOrder()); + LongBuffer ids = bytes.asLongBuffer(); + for (long i = 0; i < rows; i++) { + ids.put((int) i, i); + } + AtomicLong seen = new AtomicLong(); + Set threads = ConcurrentHashMap.newKeySet(); + Thread asked = Thread.currentThread(); + try (Frame frame = Frame.of("Person", rows)) { + frame.column("id", ids); + conn.register(frame); + conn.onProgress( + Duration.ofMillis(1), + (read, millis) -> { + seen.incrementAndGet(); + threads.add(Thread.currentThread()); + return true; + }); + try (Result r = + conn.query("MATCH (a:Person), (b:Person) WHERE a.id < b.id RETURN count(*)")) { + assertTrue(r.row(0).getLong(0) > 0); + } finally { + conn.clearProgress(); + conn.unregister("Person"); + } + } + assertTrue(seen.get() > 0, "a cross product of nine million rows went by without a word"); + assertFalse(threads.contains(asked), "the callback ran on the thread that asked"); + } + + /** + * A frame keeps the pointer it is given for as long as it is registered, + * and a heap buffer has no pointer anything outside the JVM can keep. A + * copy would be a frame that is not a frame, so it is refused instead. + */ + @Test + void aFrameRefusesABufferOnTheHeap() { + try (Frame frame = Frame.of("Heaped", 2)) { + ZuException e = + assertThrows( + ZuException.class, + () -> frame.column("n", ByteBuffer.allocate(16).asLongBuffer())); + assertTrue(e.getMessage().contains("allocateDirect"), e.getMessage()); + } + } + + @Test + void aFrameOverDirectMemoryIsQueriedWhereItLies() { + ByteBuffer bytes = ByteBuffer.allocateDirect(24).order(ByteOrder.nativeOrder()); + LongBuffer values = bytes.asLongBuffer(); + values.put(new long[] {7, 8, 9}).flip(); + try (Frame frame = Frame.of("Lent", 3)) { + frame.column("n", values); + conn.register(frame); + try (Result r = conn.query("MATCH (l:Lent) RETURN l.n AS n ORDER BY n")) { + assertEquals(List.of(7L, 8L, 9L), r.stream().map(row -> row.getLong(0)).toList()); + } finally { + conn.unregister("Lent"); + } + } + } + + 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"); + 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-tck/pom.xml b/zudb-tck/pom.xml new file mode 100644 index 0000000..1122a4b --- /dev/null +++ b/zudb-tck/pom.xml @@ -0,0 +1,67 @@ + + + + 4.0.0 + + + dev.zudb + zudb-parent + 0.11.0-SNAPSHOT + + + zudb-tck + zu for the JVM: the cases every provider owes + One suite, run against both providers, so that neither can quietly differ from the other. + + + + dev.zudb + zudb + + + + org.junit.jupiter + junit-jupiter + compile + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${zu.release.api} + + + -Xlint:all,-requires-automatic,-requires-transitive-automatic,-missing-explicit-ctor + -Werror + + + + + + diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/AppenderTest.java b/zudb-tck/src/main/java/dev/zudb/tck/AppenderTest.java similarity index 99% rename from zudb-ffm/src/test/java/dev/zudb/ffm/AppenderTest.java rename to zudb-tck/src/main/java/dev/zudb/tck/AppenderTest.java index 9232e18..0bc8fa2 100644 --- a/zudb-ffm/src/test/java/dev/zudb/ffm/AppenderTest.java +++ b/zudb-tck/src/main/java/dev/zudb/tck/AppenderTest.java @@ -1,4 +1,4 @@ -package dev.zudb.ffm; +package dev.zudb.tck; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -27,7 +27,7 @@ * DDL and a bulk load is the only thing that makes a table for an appender to * append to. */ -class AppenderTest { +public class AppenderTest { @TempDir Path dir; diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/ColumnarTest.java b/zudb-tck/src/main/java/dev/zudb/tck/ColumnarTest.java similarity index 99% rename from zudb-ffm/src/test/java/dev/zudb/ffm/ColumnarTest.java rename to zudb-tck/src/main/java/dev/zudb/tck/ColumnarTest.java index 2241c82..66efa71 100644 --- a/zudb-ffm/src/test/java/dev/zudb/ffm/ColumnarTest.java +++ b/zudb-tck/src/main/java/dev/zudb/tck/ColumnarTest.java @@ -1,4 +1,4 @@ -package dev.zudb.ffm; +package dev.zudb.tck; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -25,7 +25,7 @@ * 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 { +public class ColumnarTest { private static Database db; private static Connection conn; diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/DatabaseTest.java b/zudb-tck/src/main/java/dev/zudb/tck/DatabaseTest.java similarity index 98% rename from zudb-ffm/src/test/java/dev/zudb/ffm/DatabaseTest.java rename to zudb-tck/src/main/java/dev/zudb/tck/DatabaseTest.java index 0198cac..f68ec8d 100644 --- a/zudb-ffm/src/test/java/dev/zudb/ffm/DatabaseTest.java +++ b/zudb-tck/src/main/java/dev/zudb/tck/DatabaseTest.java @@ -1,4 +1,4 @@ -package dev.zudb.ffm; +package dev.zudb.tck; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -18,7 +18,7 @@ import org.junit.jupiter.api.io.TempDir; /** Opening, connecting, and closing, in that order and the reverse. */ -class DatabaseTest { +public class DatabaseTest { @BeforeAll static void engine() { diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/ErrorTest.java b/zudb-tck/src/main/java/dev/zudb/tck/ErrorTest.java similarity index 98% rename from zudb-ffm/src/test/java/dev/zudb/ffm/ErrorTest.java rename to zudb-tck/src/main/java/dev/zudb/tck/ErrorTest.java index 1f82c5c..6d36ec3 100644 --- a/zudb-ffm/src/test/java/dev/zudb/ffm/ErrorTest.java +++ b/zudb-tck/src/main/java/dev/zudb/tck/ErrorTest.java @@ -1,4 +1,4 @@ -package dev.zudb.ffm; +package dev.zudb.tck; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -21,7 +21,7 @@ *

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 { +public class ErrorTest { private static Database db; private static Connection conn; diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/FrameTest.java b/zudb-tck/src/main/java/dev/zudb/tck/FrameTest.java similarity index 99% rename from zudb-ffm/src/test/java/dev/zudb/ffm/FrameTest.java rename to zudb-tck/src/main/java/dev/zudb/tck/FrameTest.java index 958cc18..bd8c150 100644 --- a/zudb-ffm/src/test/java/dev/zudb/ffm/FrameTest.java +++ b/zudb-tck/src/main/java/dev/zudb/tck/FrameTest.java @@ -1,4 +1,4 @@ -package dev.zudb.ffm; +package dev.zudb.tck; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -33,7 +33,7 @@ * Querying memory the test already holds, without any of it getting into a * database. */ -class FrameTest { +public class FrameTest { @TempDir Path dir; diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/Libzu.java b/zudb-tck/src/main/java/dev/zudb/tck/Libzu.java similarity index 78% rename from zudb-ffm/src/test/java/dev/zudb/ffm/Libzu.java rename to zudb-tck/src/main/java/dev/zudb/tck/Libzu.java index ad86b89..c9631f8 100644 --- a/zudb-ffm/src/test/java/dev/zudb/ffm/Libzu.java +++ b/zudb-tck/src/main/java/dev/zudb/tck/Libzu.java @@ -1,4 +1,4 @@ -package dev.zudb.ffm; +package dev.zudb.tck; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -17,15 +17,20 @@ *

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

Which provider the suite runs on is not decided here. Each provider + * module names its own with {@code -Dzu.provider}, so that the same cases are + * run twice and a difference between the two is a red test rather than + * something a user finds. */ -final class Libzu { +public 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() { + public 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()); @@ -42,8 +47,8 @@ private static Path locate() { 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. + // Up out of whichever provider module is running this, 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"}) { diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/LoaderTest.java b/zudb-tck/src/main/java/dev/zudb/tck/LoaderTest.java similarity index 99% rename from zudb-ffm/src/test/java/dev/zudb/ffm/LoaderTest.java rename to zudb-tck/src/main/java/dev/zudb/tck/LoaderTest.java index 671fb9f..a831492 100644 --- a/zudb-ffm/src/test/java/dev/zudb/ffm/LoaderTest.java +++ b/zudb-tck/src/main/java/dev/zudb/tck/LoaderTest.java @@ -1,4 +1,4 @@ -package dev.zudb.ffm; +package dev.zudb.tck; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -26,7 +26,7 @@ import org.junit.jupiter.api.io.TempDir; /** Building a database out of columns, which is the only way a table comes into being. */ -class LoaderTest { +public class LoaderTest { @TempDir Path dir; diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/ProgressTest.java b/zudb-tck/src/main/java/dev/zudb/tck/ProgressTest.java similarity index 99% rename from zudb-ffm/src/test/java/dev/zudb/ffm/ProgressTest.java rename to zudb-tck/src/main/java/dev/zudb/tck/ProgressTest.java index 6f62d6c..539b79f 100644 --- a/zudb-ffm/src/test/java/dev/zudb/ffm/ProgressTest.java +++ b/zudb-tck/src/main/java/dev/zudb/tck/ProgressTest.java @@ -1,4 +1,4 @@ -package dev.zudb.ffm; +package dev.zudb.tck; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -32,7 +32,7 @@ * thousand rows against themselves, which is a third of a second and is not * something the planner can fold into a count. */ -class ProgressTest { +public class ProgressTest { private static final int ROWS = 3_000; diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/QueryTest.java b/zudb-tck/src/main/java/dev/zudb/tck/QueryTest.java similarity index 99% rename from zudb-ffm/src/test/java/dev/zudb/ffm/QueryTest.java rename to zudb-tck/src/main/java/dev/zudb/tck/QueryTest.java index 050c207..34e6774 100644 --- a/zudb-ffm/src/test/java/dev/zudb/ffm/QueryTest.java +++ b/zudb-tck/src/main/java/dev/zudb/tck/QueryTest.java @@ -1,4 +1,4 @@ -package dev.zudb.ffm; +package dev.zudb.tck; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -21,7 +21,7 @@ import org.junit.jupiter.api.Test; /** Reading rows, which is what almost every program does with this client. */ -class QueryTest { +public class QueryTest { private static Database db; private static Connection conn; diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/ShorthandTest.java b/zudb-tck/src/main/java/dev/zudb/tck/ShorthandTest.java similarity index 98% rename from zudb-ffm/src/test/java/dev/zudb/ffm/ShorthandTest.java rename to zudb-tck/src/main/java/dev/zudb/tck/ShorthandTest.java index 64b5951..afcd671 100644 --- a/zudb-ffm/src/test/java/dev/zudb/ffm/ShorthandTest.java +++ b/zudb-tck/src/main/java/dev/zudb/tck/ShorthandTest.java @@ -1,4 +1,4 @@ -package dev.zudb.ffm; +package dev.zudb.tck; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -26,7 +26,7 @@ * objects, and a program reading its settings out of a file should not have to * know which of three fields a key lands in. */ -class ShorthandTest { +public class ShorthandTest { @BeforeAll static void engine() { diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/StatementTest.java b/zudb-tck/src/main/java/dev/zudb/tck/StatementTest.java similarity index 99% rename from zudb-ffm/src/test/java/dev/zudb/ffm/StatementTest.java rename to zudb-tck/src/main/java/dev/zudb/tck/StatementTest.java index 1f4449e..a9169e7 100644 --- a/zudb-ffm/src/test/java/dev/zudb/ffm/StatementTest.java +++ b/zudb-tck/src/main/java/dev/zudb/tck/StatementTest.java @@ -1,4 +1,4 @@ -package dev.zudb.ffm; +package dev.zudb.tck; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -26,7 +26,7 @@ import org.junit.jupiter.api.Test; /** Preparing once and running many times, which is what a loop wants. */ -class StatementTest { +public class StatementTest { private static Database db; private static Connection conn; diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/TransactionTest.java b/zudb-tck/src/main/java/dev/zudb/tck/TransactionTest.java similarity index 98% rename from zudb-ffm/src/test/java/dev/zudb/ffm/TransactionTest.java rename to zudb-tck/src/main/java/dev/zudb/tck/TransactionTest.java index 6f3c1de..7a81a6f 100644 --- a/zudb-ffm/src/test/java/dev/zudb/ffm/TransactionTest.java +++ b/zudb-tck/src/main/java/dev/zudb/tck/TransactionTest.java @@ -1,4 +1,4 @@ -package dev.zudb.ffm; +package dev.zudb.tck; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -12,7 +12,7 @@ import org.junit.jupiter.api.Test; /** Beginning, committing and rolling back, and the block that does all three. */ -class TransactionTest { +public class TransactionTest { @BeforeAll static void engine() { diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/ValueTest.java b/zudb-tck/src/main/java/dev/zudb/tck/ValueTest.java similarity index 98% rename from zudb-ffm/src/test/java/dev/zudb/ffm/ValueTest.java rename to zudb-tck/src/main/java/dev/zudb/tck/ValueTest.java index 12ae8a1..d108328 100644 --- a/zudb-ffm/src/test/java/dev/zudb/ffm/ValueTest.java +++ b/zudb-tck/src/main/java/dev/zudb/tck/ValueTest.java @@ -1,4 +1,4 @@ -package dev.zudb.ffm; +package dev.zudb.tck; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -17,7 +17,7 @@ * The values that have no column to be read into: lists, records and the * trees they make. */ -class ValueTest { +public class ValueTest { private static Database db; private static Connection conn; diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/ZuTest.java b/zudb-tck/src/main/java/dev/zudb/tck/ZuTest.java similarity index 70% rename from zudb-ffm/src/test/java/dev/zudb/ffm/ZuTest.java rename to zudb-tck/src/main/java/dev/zudb/tck/ZuTest.java index a2551d3..2f22602 100644 --- a/zudb-ffm/src/test/java/dev/zudb/ffm/ZuTest.java +++ b/zudb-tck/src/main/java/dev/zudb/tck/ZuTest.java @@ -1,4 +1,4 @@ -package dev.zudb.ffm; +package dev.zudb.tck; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -9,7 +9,7 @@ import org.junit.jupiter.api.Test; /** What loaded, and what it says it is. */ -class ZuTest { +public class ZuTest { @BeforeAll static void engine() { @@ -17,9 +17,14 @@ static void engine() { } @Test - void theProviderIsTheOneThisArtifactShips() { - assertEquals("ffm", Zu.availableProvider().orElseThrow()); - assertEquals("ffm", Zu.provider()); + void theProviderIsTheOneThisRunNamed() { + // These cases are run once per provider and the run says which, so + // this checks that the naming took rather than that a particular one + // won. A run in which it did not take would be a run of the same + // provider twice, which is the failure this catches. + String named = System.getProperty("zu.provider", "ffm"); + assertEquals(named, Zu.availableProvider().orElseThrow()); + assertEquals(named, Zu.provider()); } @Test diff --git a/zudb-tck/src/main/java/module-info.java b/zudb-tck/src/main/java/module-info.java new file mode 100644 index 0000000..778e9c5 --- /dev/null +++ b/zudb-tck/src/main/java/module-info.java @@ -0,0 +1,20 @@ +/** + * The cases every provider owes, and the fixture that finds an engine to run + * them against. + * + *

These are main sources rather than test sources because two other + * modules run them, and a test source set is not a thing another module can + * depend on without a test jar and the trouble that comes with one. + * + *

The module is open rather than exporting to a list, because the + * reflection that reaches a test method comes from the platform, from the + * engine and from an extension, and a list of three would be a list that goes + * stale. The package is exported as well so that a provider module can name + * the fixture in a test of its own. + */ +open module dev.zudb.tck { + requires dev.zudb; + requires transitive org.junit.jupiter.api; + + exports dev.zudb.tck; +} diff --git a/zudb/src/main/java/dev/zudb/Library.java b/zudb/src/main/java/dev/zudb/Library.java index bd8308c..8dcfc88 100644 --- a/zudb/src/main/java/dev/zudb/Library.java +++ b/zudb/src/main/java/dev/zudb/Library.java @@ -1,15 +1,11 @@ package dev.zudb; -import java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; +import dev.zudb.spi.Natives; 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. @@ -76,9 +72,10 @@ static Found find() { } looked.add(ENVIRONMENT); - String flavour = flavour(); - String resource = "dev/zudb/native/" + flavour + "/" + System.mapLibraryName("zu"); - Path unpacked = unpack(resource); + String flavour = Natives.flavour(); + String library = System.mapLibraryName("zu"); + String resource = "dev/zudb/native/" + flavour + "/" + library; + Path unpacked = Natives.unpack(resource, library, Library.class); if (unpacked != null) { return new Found(unpacked, "the zudb-native artifact, " + flavour, looked); } @@ -99,100 +96,4 @@ static Found find() { Paths.get(System.mapLibraryName("zu")), "the platform library path", looked); } - /** - * 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; - } - - /** - * The same, and which C library on the platform where there are two. - * - *

Alpine is not a smaller Linux, it is a different one: a shared - * object built against glibc does not load on musl and says so in a message - * about an interpreter rather than about a database. The two builds are two - * artifacts everywhere else this engine ships, so they are two here as well, - * and the choice is made by looking for musl's own loader, which is the one - * file whose path is fixed by the ABI rather than by a distribution. - * - *

Nowhere but Linux has a second answer, so nowhere but Linux is asked. - * - * @return for example {@code darwin-arm64} or {@code linux-amd64-musl} - */ - static String flavour() { - String platform = platform(); - if (!platform.startsWith("linux-")) { - return platform; - } - return musl() ? platform + "-musl" : platform; - } - - /** Whether this is a musl system, by its loader rather than by its name. */ - private static boolean musl() { - for (String loader : - new String[] {"/lib/ld-musl-x86_64.so.1", "/lib/ld-musl-aarch64.so.1"}) { - if (Files.exists(Paths.get(loader))) { - return true; - } - } - return false; - } - - /** - * 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 - */ - 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/spi/Natives.java b/zudb/src/main/java/dev/zudb/spi/Natives.java new file mode 100644 index 0000000..2a3944c --- /dev/null +++ b/zudb/src/main/java/dev/zudb/spi/Natives.java @@ -0,0 +1,129 @@ +package dev.zudb.spi; + +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.Locale; + +/** + * What a provider needs to know about the machine it is on. + * + *

Two of the three questions here are asked twice in this repository: the + * API module asks them to find libzu, and the JNI provider asks them again to + * find its own shim, which is a second library and a different file with the + * same seven platforms under it. They are answered here so that the two + * cannot come to disagree about what a platform is called, which would show up + * as a jar that has the library for the machine it is on and cannot find it. + * + *

Like everything else in this package, this is not part of the supported + * surface. + */ +public final class Natives { + + private Natives() {} + + /** + * 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} + */ + public 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; + } + + /** + * The same, and which C library on the platform where there are two. + * + *

Alpine is not a smaller Linux, it is a different one: a shared object + * built against glibc does not load on musl and says so in a message about + * an interpreter rather than about a database. The two builds are two + * artifacts everywhere else this engine ships, so they are two here as well, + * and the choice is made by looking for musl's own loader, which is the one + * file whose path is fixed by the ABI rather than by a distribution. + * + *

Nowhere but Linux has a second answer, so nowhere but Linux is asked. + * + * @return for example {@code darwin-arm64} or {@code linux-amd64-musl} + */ + public static String flavour() { + String platform = platform(); + if (!platform.startsWith("linux-")) { + return platform; + } + return musl() ? platform + "-musl" : platform; + } + + /** Whether this is a musl system, by its loader rather than by its name. */ + private static boolean musl() { + for (String loader : new String[] {"/lib/ld-musl-x86_64.so.1", "/lib/ld-musl-aarch64.so.1"}) { + if (Files.exists(Paths.get(loader))) { + return true; + } + } + return false; + } + + /** + * 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 + * @param name what to call the copy, which matters because a loader reports + * the file name it failed on and a temporary name would be no help + * @param near a class of the artifact the resource is in, whose loader is + * the one asked, because the API module and the JNI provider are two + * artifacts and on a module path they are two loaders as well + * @return the copy, or null if there is no such resource + */ + public static Path unpack(String resource, String name, Class near) { + ClassLoader loader = near.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(name); + 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/test/java/dev/zudb/LibraryTest.java b/zudb/src/test/java/dev/zudb/LibraryTest.java index 9057e1b..7ea2028 100644 --- a/zudb/src/test/java/dev/zudb/LibraryTest.java +++ b/zudb/src/test/java/dev/zudb/LibraryTest.java @@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import dev.zudb.spi.Natives; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -48,7 +49,7 @@ 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(); + String platform = Natives.platform(); assertTrue( platform.matches("(darwin|linux|windows|[a-z0-9]+)-(amd64|arm64|[a-z0-9_]+)"), platform + " is not a goos-goarch pair"); @@ -60,8 +61,8 @@ void theCLibraryIsPartOfTheAnswerOnLinuxAndNowhereElse() { // A shared object built against glibc does not load on musl, so the two // are two artifacts. Everywhere else there is one C library and nothing // to say about it. - String platform = Library.platform(); - String flavour = Library.flavour(); + String platform = Natives.platform(); + String flavour = Natives.flavour(); if (platform.startsWith("linux-")) { assertTrue( flavour.equals(platform) || flavour.equals(platform + "-musl"), @@ -78,7 +79,7 @@ void aLibraryInAJarBecomesAFileWithTheSameBytes() throws Exception { // a library, deliberately: what is being checked is the copy, and a real // one would only make the test slower and platform-specific. String resource = "dev/zudb/native/a-platform-that-is-not-one/libzu.stand-in"; - Path unpacked = Library.unpack(resource); + Path unpacked = Natives.unpack(resource, "libzu.stand-in", Library.class); assertTrue(unpacked != null, "the stand-in is not on the test classpath"); assertTrue(Files.isRegularFile(unpacked)); byte[] want; @@ -88,7 +89,7 @@ void aLibraryInAJarBecomesAFileWithTheSameBytes() throws Exception { assertArrayEquals(want, Files.readAllBytes(unpacked)); // A directory of its own each time, so two callers cannot land on one // file and so a copy cannot be made over a library already mapped. - Path again = Library.unpack(resource); + Path again = Natives.unpack(resource, "libzu.stand-in", Library.class); assertNotEquals(unpacked, again); } @@ -116,7 +117,7 @@ void theSearchSaysWhatItRuledOutOnTheWay() { void aPlatformWithNoArtifactIsNotAFailure() { // The classpath is the third of four places, so nothing there means the // search carries on to the platform's own rather than stopping. - assertNull(Library.unpack("dev/zudb/native/vax-11-780/libzu.so")); + assertNull(Natives.unpack("dev/zudb/native/vax-11-780/libzu.so", "libzu.so", Library.class)); } private static void restore(String before) {