diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3ed665f..437f710 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -127,6 +127,59 @@ jobs:
- run: java -jar zudb-bench/target/benchmarks.jar -f 1 -wi 1 -i 1 -r 1s -w 1s
+ # The cross client corpus, run against the engine this job builds.
+ # Every client answers the same fourteen hundred cases and a report is
+ # diffed line for line against the other four, so this is the job that
+ # says this client agrees with them rather than only with itself.
+ #
+ # The cases come from the same checkout the library was built from,
+ # which is the pairing that makes the report mean anything. A corpus
+ # ahead of the library reports the engine catching up to its own cases
+ # as this client failing, and a library ahead of the corpus reports
+ # nothing at all. This client builds the engine rather than shipping an
+ # archive of it, so both are the same checkout and there is no revision
+ # to pin.
+ #
+ # A job of its own rather than a step in the one above, because the run
+ # is fourteen hundred databases and the job above runs its suite three
+ # times.
+ corpus:
+ runs-on: ubuntu-latest
+ 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: "25"
+ cache: maven
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: engine
+
+ - 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.so)"
+ test -n "$lib"
+ echo "ZU_LIBRARY=$GITHUB_WORKSPACE/$lib" >> "$GITHUB_ENV"
+
+ # Verbose, because the run logs every case the engine has not
+ # caught up to and a release branch is read for exactly that.
+ - run: mvn $MAVEN_ARGS -pl zudb-corpus -am test -Dsurefire.useFile=false
+ env:
+ ZU_CASES: ${{ github.workspace }}/engine/conformance/cases
+
# 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
diff --git a/README.md b/README.md
index a9713f7..942e80f 100644
--- a/README.md
+++ b/README.md
@@ -360,6 +360,17 @@ ZU_LIBRARY=/path/to/libzu.dylib java -jar zudb-bench/target/benchmarks.jar
`ZU_LIBRARY` rather than `-Dzu.library` there, because JMH forks a JVM of its own and a fork inherits the environment rather than the system properties.
+The cross client corpus is a directory of cases in the engine's repository, versioned with it and answered by every client in the family. It is a command as well as a test:
+
+```sh
+mvn -pl zudb-corpus -am package -DskipTests
+ZU_LIBRARY=/path/to/libzu.dylib java --enable-native-access=ALL-UNNAMED \
+ -cp "zudb/target/classes:zudb-ffm/target/classes:zudb-corpus/target/classes" \
+ dev.zudb.corpus.Main /path/to/zu/conformance/cases
+```
+
+It prints a line per case that did not pass and then a summary, and the lines are the reference runner's word for word so that two clients disagreeing is a diff. `-strict` makes a case the engine has not caught up to fail the run, which is what a release branch wants, and `-work` keeps the databases rather than removing them. The same run happens under `mvn test` when `ZU_CASES` points at the cases, and skips when it does not, so a checkout of this repository alone is still green.
+
The leak run is a script rather than a test, because what reads the result is the allocator rather than an assertion:
```sh
@@ -404,6 +415,7 @@ Inside this repository:
| The Panama provider | `zudb-ffm` |
| The JNI provider, and the C shim it calls through | `zudb-jni` |
| The cases every provider owes, run by both of them | `zudb-tck` |
+| The cross client corpus, read and run against this client | `zudb-corpus` |
| Arrow, over the C Data Interface | `zudb-arrow` |
| JMH benchmarks | `zudb-bench` |
| The staged libraries, built by the release rather than by a clone | `zudb-native` |
diff --git a/pom.xml b/pom.xml
index 747313f..0a8ce41 100644
--- a/pom.xml
+++ b/pom.xml
@@ -51,6 +51,7 @@
A client that reads rows one at a time and a client that exports a + * million of them to a dataframe are the same client, and only one of those + * paths is covered by a case that asserts values. The other one has its own + * contract: a column of dates is a Date32 and not a string of digits, a + * year-month duration is a month-day-nano interval because that is the + * interval every reader implements, a node is a struct of the name of its + * table and the row it is, and a time with an offset is refused rather than + * quietly moved to UTC. None of that shows up in a row a case compares. + * + *
So a case may say what the export gives as well as what the rows are, + * and the runner checks both against one statement. What it checks is the + * schema, field by field and into the nested types, and how many rows came + * back through the stream. The schema is spelled in the C Data Interface's + * own format strings, {@code l} for an int64 and {@code +s} for a struct, + * because that is the one spelling every language sees the same. + * + *
The schema is read off the interface itself through + * {@link java.lang.foreign} rather than out of arrow-java, for two reasons. + * The first is that a checkout running the corpus should pull nothing, and + * arrow-java is a tree. The second is that the C Data Interface is what the + * C runner and the Go runner have at this point too, so all three read the + * same bytes and report them in the same words, which is the whole reason a + * format string is what the case writes down. Mapping arrow-java's own + * ArrowType back to a format string would be a third opinion about the + * spelling, and a third opinion is what this class exists to avoid. + * + *
Values are not read back here. A consumer that decoded every array by + * hand in each of nine languages would be nine new decoders under test, + * which is more of our own code and not more of the contract; the rows the + * case already asserts are the same values by another road. + */ +public final class Arrow { + + /** + * How a report names the whole result, which is the place the columns of + * an export are in. + */ + public static final String THE_RESULT = "the result"; + + /** + * One field of the schema an export gives, and the fields under it when it + * is a struct or a list. + * + *
A list has exactly one field under it, which Arrow names
+ * {@code item}, and a case writes that out rather than leaving it implied:
+ * a client that named it {@code element} would export something no reader
+ * lines up with what another client wrote.
+ *
+ * @param name the field's name, which for a column is the column's name
+ * and for the field under a list is {@code item}
+ * @param format the C Data Interface format string, {@code l} for an
+ * int64, {@code u} for a string, {@code tsn:} for a timestamp in
+ * nanoseconds with no zone
+ * @param children the fields under this one, empty for everything that is
+ * not a struct or a list
+ */
+ public record Field(String name, String format, List A refusal is a thing a statement can produce today, which is why it
+ * has a spelling of its own rather than being a bug.
+ *
+ * @param refused whether the case says the export says no, in which case
+ * there are no fields to compare
+ * @param fields the schema's fields, one per column, in order
+ */
+ public record Export(boolean refused, List The stream is taken once and both answers come out of that one
+ * taking, because a stream is consumed by reading it and a second export
+ * would be a second statement in all but name.
+ *
+ * A column Arrow cannot hold is found when the stream is asked for,
+ * before a row moves, and it comes back as an {@link ArrowException} so
+ * that the runner can tell it from a schema that came out wrong. The
+ * result is spent either way, which is what {@link Result#exportArrow}
+ * promises.
+ *
+ * @param result the result to export, which is closed by this call
+ * @return the fields and the row count
+ * @throws ArrowException if the export or the stream says no
+ */
+ public static Exported exported(Result result) {
+ // Confined, because nothing here crosses a thread, and the arena is what
+ // the C side calls the caller's own storage: the struct stays where it
+ // is until this returns, and the engine's stream keeps its state behind
+ // private_data rather than a pointer back to the struct.
+ try (Arena arena = Arena.ofConfined()) {
+ // Zeroed by the arena, which is what the interface asks of a caller.
+ MemorySegment stream = arena.allocate(STREAM);
+ try {
+ result.exportArrow(stream.address());
+ } catch (ZuException e) {
+ throw new ArrowException(e.getMessage());
+ }
+ try {
+ MemorySegment schema = arena.allocate(SCHEMA);
+ int code = call(STREAM_GET_SCHEMA, stream, schema);
+ if (code != 0) {
+ throw new ArrowException(said(stream, code));
+ }
+ // The stream's schema is a struct of the columns, so what the case
+ // is compared against is the fields under it.
+ Field top = walked(schema);
+ release(SCHEMA_RELEASE, schema);
+
+ long count = 0;
+ MemorySegment batch = arena.allocate(ARRAY);
+ while (true) {
+ batch.fill((byte) 0);
+ code = call(STREAM_GET_NEXT, stream, batch);
+ if (code != 0) {
+ throw new ArrowException(said(stream, code));
+ }
+ // A released array is how the interface says the stream is done.
+ if (empty((MemorySegment) ARRAY_RELEASE.get(batch, 0L))) {
+ break;
+ }
+ count += (long) ARRAY_LENGTH.get(batch, 0L);
+ release(ARRAY_RELEASE, batch);
+ }
+ return new Exported(top.children(), count);
+ } finally {
+ release(STREAM_RELEASE, stream);
+ }
+ }
+ }
+
+ /** One field of an exported schema, and everything under it. */
+ @SuppressWarnings("restricted")
+ private static Field walked(MemorySegment one) {
+ String name = text((MemorySegment) SCHEMA_NAME.get(one, 0L));
+ String format = text((MemorySegment) SCHEMA_FORMAT.get(one, 0L));
+ long kids = (long) SCHEMA_KIDS.get(one, 0L);
+ List The comparison walks the schema and the case's fields together and
+ * stops at the first difference, for the reason the row comparison does:
+ * the first is nearly always the cause of the rest.
+ *
+ * @param got what the export gave
+ * @param want what the case wants
+ * @return what is wrong, or the empty string
+ */
+ public static String schemaSays(List A type of its own, so that a refusal on the way out is told apart from
+ * a schema that came back different from the one the case wants. A case
+ * writing {@code arrow: refused} turns on exactly that difference: Arrow has
+ * a time and a timestamp and nothing in between, so a time with an offset
+ * has nowhere to go, and a client that quietly moved it to UTC would be
+ * moving the value.
+ */
+public class ArrowException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * The export saying no.
+ *
+ * @param message what it said, which is the whole of it and carries no
+ * prefix, for the reason {@link CorpusException} carries none
+ */
+ public ArrowException(String message) {
+ super(message);
+ }
+}
diff --git a/zudb-corpus/src/main/java/dev/zudb/corpus/Cell.java b/zudb-corpus/src/main/java/dev/zudb/corpus/Cell.java
new file mode 100644
index 0000000..b04ccf9
--- /dev/null
+++ b/zudb-corpus/src/main/java/dev/zudb/corpus/Cell.java
@@ -0,0 +1,230 @@
+package dev.zudb.corpus;
+
+import dev.zudb.Value;
+import java.util.Arrays;
+import java.util.Map;
+
+/**
+ * One value, in the shape the corpus compares values in.
+ *
+ * Not {@link dev.zudb.Value}, for two reasons. It is sealed, so this
+ * package could not add to it if it wanted to. And its node, rel and path
+ * carry the id of the table the row is in, where a case writes the table's
+ * name: an id is a number the file decided and every client builds its own
+ * file, so a case naming one would be asserting something about the order
+ * the tables went in. A node here is {@code person#1} and an edge is
+ * {@code knows#0->1}, which is what the case wrote.
+ *
+ * A rel also carries a fourth field the corpus does not write, which is
+ * where the edge's properties sit, and that is its place in the order the
+ * table was loaded in rather than anything a case chose. A pair may run
+ * more than once, and a case that has to tell two parallel edges apart
+ * asserts a property of them instead.
+ *
+ * Sealed, so a switch over the twelve is exhaustive and a thirteenth
+ * cannot be added without every reader of one being made to say what it
+ * does about it. That is the JVM's answer to the Go runner's type switch
+ * over {@code any}, and a stricter one: there, a shape nobody handled fell
+ * through to a default.
+ *
+ * The declared width is dropped on the way in, so a case that says
+ * {@code INT8} and one that says {@code INT64} both come to an {@link Int}.
+ * That is a fact about this engine rather than about the corpus, since its
+ * own value is one signed 64 bit integer either way.
+ *
+ * Two of these are the same value when they are {@link #equals}, which
+ * is the whole reason for records here. A float is the case that usually
+ * needs a comparison written by hand: NaN is not equal to itself and a case
+ * asserting NaN has to pass, and 0.0 equals -0.0 and a case asserting -0.0
+ * has to fail on 0.0, because the sign of zero is exactly the sort of thing
+ * that survives one binding and not another. A record's generated equality
+ * compares a double with {@code Double.compare}, which says yes to the
+ * first pair and no to the second, so it is already the comparison the
+ * corpus wants. {@link Bytes} is the one that is not, since an array
+ * compares by identity, and it is written out below.
+ */
+public sealed interface Cell {
+
+ /** The absence of a value, which is a value a case asserts. */
+ record Null() implements Cell {}
+
+ /** The one null, since they are all equal and there is no state to hold. */
+ Cell NULL = new Null();
+
+ /**
+ * A boolean.
+ *
+ * @param value what it is
+ */
+ record Bool(boolean value) implements Cell {}
+
+ /**
+ * An integer, of whatever width the case declared.
+ *
+ * @param value what it is
+ */
+ record Int(long value) implements Cell {}
+
+ /**
+ * A float. A {@code FLOAT32} arrives here already rounded through the
+ * narrower type, which is what the engine holds it as.
+ *
+ * @param value what it is
+ */
+ record Float(double value) implements Cell {}
+
+ /**
+ * A string.
+ *
+ * @param value what it is
+ */
+ record Str(String value) implements Cell {}
+
+ /**
+ * A byte string.
+ *
+ * @param value the octets, which are not copied on the way in or out
+ * because nothing in this package writes to one
+ */
+ record Bytes(byte[] value) implements Cell {
+
+ @Override
+ public boolean equals(Object other) {
+ return other instanceof Bytes b && Arrays.equals(value, b.value);
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(value);
+ }
+
+ @Override
+ public String toString() {
+ return "Bytes[" + Values.hexits(value) + "]";
+ }
+ }
+
+ /**
+ * A date, a time, a datetime or a duration, in the client's own shape,
+ * which is one count and the unit that count is in.
+ *
+ * @param value which of the seven, and the count
+ */
+ record Time(Value.Temporal value) implements Cell {}
+
+ /**
+ * A list.
+ *
+ * @param items what is in it, in order
+ */
+ record List(java.util.List This is here so that a report carrying one says so rather than
+ * dying, and it is deliberately not a shape a case can write: no
+ * {@code type} names it, so it can only arrive from the engine, and it
+ * can never be equal to anything a case asserts.
+ *
+ * @param value what came back
+ */
+ record Other(Value value) implements Cell {}
+
+ /**
+ * A value from the engine in the shape the corpus compares values in.
+ *
+ * Everything a table holds is spelled the same on both sides and comes
+ * through untouched. A graph value is not: the engine's node and edge
+ * carry the id of their table where a case writes its name, so they are
+ * put into the shapes above before anything is compared, which is what
+ * the Rust runner's {@code from_engine} does for the same reason.
+ *
+ * A table with no name is spelled {@code #7} after its id, which is
+ * what a node column of an Arrow export is named when there is no catalog
+ * to ask. It should not happen here, since the connection is right there,
+ * and it is a spelling rather than a failure because a report saying "the
+ * case wants person#1 and this is #7" is more use to whoever has to fix
+ * it than one that died.
+ *
+ * @param value what the statement gave back
+ * @param named what a table id is called, for the three shapes that carry
+ * one
+ * @return the same value, ready to compare
+ */
+ static Cell of(Value value, java.util.function.IntFunction It is a type of its own rather than a plain failure so that the
+ * command can tell a corpus it cannot read from a case that did not pass.
+ * Those are two different exits: the first is a broken file and the
+ * second is a client that disagrees with the engine.
+ *
+ * The message is the whole of the refusal. It opens with the line it
+ * happened on unless the file has no line to blame, and it carries no
+ * stack, because a reader that printed the shape of this package at
+ * somebody trying to fix a case would be answering a question they did
+ * not ask.
+ */
+public class CorpusException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * A refusal with the message it carries.
+ *
+ * @param message the whole of the refusal
+ */
+ public CorpusException(String message) {
+ super(message);
+ }
+}
diff --git a/zudb-corpus/src/main/java/dev/zudb/corpus/Main.java b/zudb-corpus/src/main/java/dev/zudb/corpus/Main.java
new file mode 100644
index 0000000..2f8338b
--- /dev/null
+++ b/zudb-corpus/src/main/java/dev/zudb/corpus/Main.java
@@ -0,0 +1,165 @@
+package dev.zudb.corpus;
+
+import java.io.IOException;
+import java.io.PrintStream;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Stream;
+
+/**
+ * Runs the shared cross-client corpus against this client and prints what
+ * happened.
+ *
+ * The report is the reference runner's, line for line, so a disagreement
+ * between two clients is a diff and not a reading exercise. It exits zero
+ * when nothing failed and one when something did or when the corpus will
+ * not read, which is also the reference runner's rule.
+ */
+public final class Main {
+
+ private Main() {}
+
+ /**
+ * The command.
+ *
+ * @param args the flags and the one directory to read
+ */
+ public static void main(String[] args) {
+ System.exit(run(args, System.out, System.err));
+ }
+
+ /**
+ * The command, with somewhere to write and a status rather than an exit,
+ * which is what a test can call.
+ *
+ * @param args the flags and the one directory to read
+ * @param out where the report goes
+ * @param err where a refusal goes
+ * @return what the command would exit with
+ */
+ static int run(String[] args, PrintStream out, PrintStream err) {
+ boolean strict = false;
+ boolean quiet = false;
+ String work = "";
+ List A file that will not go is not something to say about a run that has
+ * already printed what it came to, so nothing is said about it.
+ */
+ private static void removeAll(Path directory) {
+ try (Stream The accessors answer null for a node of the wrong shape rather than
+ * throwing, because every caller is a reader deciding what a case says
+ * and wanting to refuse it with a line number of its own. A reader that
+ * had to catch something to find out that {@code rows:} held a mapping
+ * would be writing the refusal twice.
+ */
+public final class Node {
+
+ /** Which of the four shapes a node is. */
+ public enum Kind {
+ /** A single value, quoted or plain. */
+ SCALAR,
+ /** A list of nodes. */
+ SEQ,
+ /** Keys and what is under them, in the order they were written. */
+ MAP,
+ /**
+ * A key with nothing under it.
+ *
+ * This is a node rather than a refusal because a case that expects
+ * no rows back writes {@code rows:} and stops, and that is a real
+ * expectation which needs a spelling. Every accessor says no to it, so
+ * a {@code name:} left blank is still caught by whoever wanted a name.
+ */
+ EMPTY
+ }
+
+ /**
+ * One entry of a mapping.
+ *
+ * Kept in the order it was written rather than in a hash map, because
+ * the order is what a report cites and what a parameter list means.
+ *
+ * @param key the name left of the colon
+ * @param value what is right of it, or what is indented under it
+ */
+ public record Pair(String key, Node value) {}
+
+ private final Kind kind;
+ private final int line;
+ private final String text;
+ private final boolean quoted;
+ private final List Only a caller for whom empty is a meaningful answer should reach for
+ * this. The rest want {@link #seq()}, so that a list somebody left
+ * unfinished is refused rather than read as none.
+ *
+ * @return the items, empty for a key with nothing under it, or null when
+ * this is neither
+ */
+ public List Each case gets a database of its own. Cases in a suite are written as
+ * if nothing came before them, and the cheapest way to keep that true is to
+ * make it true: a case that leaked a table into the next one would be a
+ * failure that moves when the file is reordered, which is the worst kind to
+ * be handed.
+ *
+ * An outcome is one of three things and not two. Passed and failed are
+ * obvious. Unsupported is the third, and it exists because the corpus is
+ * versioned with the engine and shipped to nine clients that will not all
+ * implement the same subset at the same time: a client that cannot yet
+ * parse a statement should say so, and a report should be able to tell that
+ * apart from an answer that came back wrong.
+ *
+ * What this prints is what the Rust runner prints, line for line, so
+ * that a disagreement between two clients is a diff and not a reading
+ * exercise.
+ */
+public final class Runner {
+
+ private Runner() {}
+
+ /** What one case came to. */
+ public enum Outcome {
+ /** The statement answered what the case wants. */
+ PASSED,
+ /** It answered something else. */
+ FAILED,
+ /**
+ * The engine does not implement the statement yet, which the corpus
+ * allows on purpose: the cases are the contract and the engine catches
+ * up to them.
+ */
+ UNSUPPORTED;
+
+ /**
+ * How a report spells an outcome, which is the reference runner's
+ * spelling and not a word of it different.
+ */
+ String mark() {
+ return switch (this) {
+ case PASSED -> "ok";
+ case FAILED -> "FAILED";
+ default -> "unsupported";
+ };
+ }
+ }
+
+ /**
+ * What one case did, with the account of why when it did not pass.
+ *
+ * @param suite the name of the suite the case is in
+ * @param name the case's name within that suite
+ * @param line where the case starts in its file
+ * @param outcome what it came to
+ * @param detail what went wrong, in enough detail to fix the case or the
+ * engine without running it again
+ */
+ public record Ran(String suite, String name, int line, Outcome outcome, String detail) {
+
+ /** The line a report prints for one case. */
+ @Override
+ public String toString() {
+ String head = suite + "/" + name + " line " + line + " " + outcome.mark();
+ return detail.isEmpty() ? head : head + ": " + detail;
+ }
+ }
+
+ /**
+ * What a whole run did.
+ *
+ * @param ran one entry per case, in the order the cases were run
+ */
+ public record Report(List A table with no name is spelled after its id, which is what a node
+ * column of an export is named when there is no catalog to ask. It should
+ * not happen with the connection right here, and it is a spelling rather
+ * than a stop because a report saying the case wants person#1 and this is
+ * #7 is more use to whoever has to fix it than one that gave up.
+ */
+ private static String tableName(Connection on, int table) {
+ try {
+ String name = on.tableName(table);
+ if (name != null && !name.isEmpty()) {
+ return name;
+ }
+ } catch (ZuException e) {
+ // The id below, which is still an answer.
+ }
+ return "#" + Integer.toUnsignedString(table);
+ }
+
+ /** One open connection and the name the case calls it by. */
+ private record Named(String name, Connection conn) {}
+
+ /**
+ * The connection a case named, made if this is the first mention of it.
+ *
+ * A new one is a duplicate of the case's own rather than a second open
+ * of the file, which is what a pool does: the two share the write side,
+ * so each sees what the other has committed. Opening the path twice would
+ * be two databases that happen to be the same file, which is a different
+ * thing and not what a case about a transaction means.
+ */
+ private static Connection connection(List A shape with no call here is one the corpus can write and this
+ * client cannot bind, and it says so by name rather than by putting the
+ * value somewhere it does not belong.
+ */
+ private static void bind(Statement prepared, Suite.Param param) {
+ String name = param.name();
+ switch (param.value()) {
+ case Cell.Null _ -> prepared.bindNull(name);
+ case Cell.Bool value -> prepared.bind(name, value.value());
+ case Cell.Int value -> prepared.bind(name, value.value());
+ case Cell.Float value -> prepared.bind(name, value.value());
+ case Cell.Str value -> prepared.bind(name, value.value());
+ case Cell.Time value -> prepared.bind(name, value.value());
+ default -> throw Text.refuse("a parameter of %s, which this client has no binding call for",
+ Values.show(param.value()));
+ }
+ }
+
+ /**
+ * Every row of a result, in the corpus's own shape.
+ *
+ * The whole result is read before anything is compared, because the
+ * engine hands back an array rather than a cursor and a comparison that
+ * stopped at the first difference would leave the rest unread anyway.
+ */
+ private static List The loader has a method per column type rather than one that takes
+ * an Object, which is what makes a load a real test of the encoding: a
+ * column of dates goes in as dates. A type with no method here is a
+ * column the corpus has never written, and it says so by name rather than
+ * by putting the values somewhere they do not belong.
+ */
+ private static void loadColumn(Loader loader, Suite.Column column) {
+ String name = column.name();
+ List A result Arrow has no type for is a refusal from the export rather
+ * than a condition from the statement, so a case saying {@code refused}
+ * is the case where the stream failing to open is the right answer.
+ */
+ private static String exported(Arrow.Export want, Result result, int count) {
+ if (want == null) {
+ return "";
+ }
+ Arrow.Exported got;
+ try {
+ got = Arrow.exported(result);
+ } catch (ArrowException e) {
+ return want.refused() ? "" : "arrow refused the result: " + errorText(e);
+ }
+ if (want.refused()) {
+ return "arrow exported the result where the case wants a refusal";
+ }
+ String detail = Arrow.schemaSays(got.fields(), want.fields());
+ if (!detail.isEmpty()) {
+ return detail;
+ }
+ if (got.rows() != count) {
+ return "arrow gives " + got.rows() + " rows where the case wants " + count;
+ }
+ return "";
+ }
+
+ /**
+ * Whether a condition means the engine does not implement the statement
+ * rather than that the statement is wrong.
+ *
+ * The two GQL classes that say so are 42, syntax error or access rule
+ * violation, and 0A, feature not supported. A case landing on either is a
+ * case ahead of the engine, which the corpus allows on purpose: the cases
+ * are the contract and the engine catches up to them.
+ */
+ static boolean unsupported(RuntimeException e) {
+ return unsupported(statusCode(e));
+ }
+
+ /**
+ * The same, of a code on its own.
+ *
+ * @param code the GQLSTATUS, or the empty string for a failure carrying
+ * none
+ * @return whether it says the engine has not caught up
+ */
+ static boolean unsupported(String code) {
+ return code.startsWith("42") || code.startsWith("0A");
+ }
+
+ /**
+ * The GQLSTATUS a failure carries, or the empty string for one that
+ * carries none, which is what the reader's own refusals and the export's
+ * carry.
+ *
+ * @param e what was thrown
+ * @return the code
+ */
+ static String statusCode(RuntimeException e) {
+ return e instanceof ZuException zu ? zu.code().orElse("") : "";
+ }
+
+ /**
+ * What the engine said, which is what the Rust runner prints for the same
+ * failure.
+ *
+ * The message rather than the exception, which is what a Java program
+ * logs: a class name in front of every failing line would differ from the
+ * report this one is diffed against. The message a condition carries
+ * already opens with its own code, which is why nothing is added here
+ * either.
+ */
+ static String errorText(RuntimeException e) {
+ String message = e.getMessage();
+ return message == null || message.isEmpty() ? e.toString() : message;
+ }
+
+ /**
+ * What differs between what a case wants and what came back, or the empty
+ * string if nothing does.
+ *
+ * It reports the first difference rather than all of them, because the
+ * first is nearly always the cause of the rest, and a report that prints
+ * a hundred rows is one nobody reads to the end. The order the checks run
+ * in is the reference runner's, so that two runners looking at the same
+ * wrong answer say the same thing about it.
+ */
+ static String compare(List A case is a statement and what running it must produce. That is
+ * deliberately the whole of it. Every client in every language can run a
+ * statement and look at the rows that come back, so a corpus written in
+ * those terms is one every client can run, and a corpus written in terms of
+ * a client's own API would be nine corpora.
+ *
+ * The expectation is either rows or a condition. A case expecting a
+ * condition names the GQLSTATUS code, not the message, because the code is
+ * the contract and the message is prose that will improve.
+ *
+ * A statement may take parameters, which is the other direction the same
+ * values travel: a case with {@code params:} writes a value in the encoding,
+ * hands it to this client's own binding call, and asserts what came back. A
+ * client that decodes a date correctly and encodes it a day early passes
+ * every case that has no parameters in it.
+ *
+ * A case may say which connection each of its statements runs on, which
+ * is how a case about a transaction is written: a transaction is only
+ * observable from outside it, so a case that has to say what a commit means
+ * needs a second connection to say it to. A case that says nothing runs
+ * everything on one connection called {@value #MAIN}, which is every case
+ * but a handful.
+ *
+ * @param name the suite's name, which is also its file's name without the
+ * extension
+ * @param doc what the suite is about
+ * @param load the data every case in the suite runs against, or null for a
+ * suite whose cases need none
+ * @param cases the cases, in the order the file writes them
+ */
+public record Suite(String name, String doc, Load load, List It exists so that a corpus unpacked from an old release tells a new
+ * runner what it is instead of failing in the middle.
+ */
+ public static final int SCHEMA = 4;
+
+ /**
+ * The connection a statement runs on when the case does not name one.
+ */
+ public static final String MAIN = "main";
+
+ private static final String[] SUITE_KEYS = {"schema", "suite", "doc", "load", "cases"};
+ private static final String[] CASE_KEYS = {"name", "doc", "setup", "on", "params", "query",
+ "columns", "rows", "raises", "arrow"};
+ private static final String[] LOAD_KEYS = {"nodes", "edges", "count", "columns", "pairs"};
+
+ /**
+ * A statement run before the one under test, and the connection it runs
+ * on.
+ *
+ * @param on the connection this statement runs on, which is {@value #MAIN}
+ * unless the step names another
+ * @param query the statement, written the way the case wrote it
+ */
+ public record Step(String on, String query) {}
+
+ /**
+ * One parameter a case binds: the name a statement writes after the
+ * {@code $}, and the value.
+ *
+ * @param name the name without the {@code $}, since that is what binding
+ * it takes
+ * @param value the value, read the way every other value in the corpus is
+ * read
+ */
+ public record Param(String name, Cell value) {}
+
+ /**
+ * One statement and what it owes.
+ *
+ * {@code columns} and {@code rows} are set together or neither is, and
+ * {@code raises} is set when neither is: a case says what it produces one
+ * way or the other. Columns being set and empty is a case of its own,
+ * since FINISH is a statement that answers no columns at all.
+ *
+ * @param name the case's name within its suite, which with the suite name
+ * is what a report is diffed on
+ * @param doc why the case is here, which is the part a reader of the
+ * corpus needs and no runner does
+ * @param query the statement under test
+ * @param line where the case starts in its file, so that a failure names
+ * somewhere to look
+ * @param setup the statements run before the one under test, in order
+ * @param on the connection the statement under test runs on, which is
+ * {@value #MAIN} unless the case says otherwise
+ * @param params what the statement binds, in the order the case wrote them
+ * @param hasColumns whether the case said {@code columns:} at all, which
+ * is what tells a case expecting no columns from one expecting a
+ * condition
+ * @param columns the column names the statement answers, in order
+ * @param rows the rows it answers, one value per column
+ * @param raises the GQLSTATUS the statement owes instead of an answer, and
+ * the empty string for a case that answers
+ * @param arrow what the same result looks like on the way out through
+ * Arrow, for a case that says, and null for one that does not. Most do
+ * not: the export gives one answer per column type and a handful of
+ * cases pin every one of them, so the rest would be repeating a type
+ * the corpus already covers
+ */
+ public record Case(String name, String doc, String query, int line, List Everything else in the corpus is an expression, and an expression
+ * says what a value means on the way out and nothing about how it got in.
+ * A load is the other half, and every runner puts it in through its own
+ * bulk load path, which for this client is {@link dev.zudb.Loader}.
+ *
+ * @param nodes the name of the node table the rows go into
+ * @param edges the name of the edge table the pairs go into
+ * @param count how many rows the load has, which every column is checked
+ * against so that a short column is a refusal here rather than a
+ * puzzle later
+ * @param columns the node table's columns, in the order they are written
+ * and loaded
+ * @param pairs the edges, each a from and a to row number within the node
+ * table
+ */
+ public record Load(String nodes, String edges, int count, List A corpus reader is a second opinion about the text, and a second
+ * opinion that calls the same library the client calls is not one. A
+ * formatter also takes a great deal this encoding does not: a pattern with
+ * a fractional second matches text with none, a numeric zone matches
+ * {@code Z} under some patterns and not others, and none of that is
+ * visible at the call site. Writing the four spellings out says exactly
+ * what is accepted.
+ *
+ * The spellings are the ones the engine prints, which is the extended
+ * ISO 8601 form and nothing else: {@code 2024-01-01} for a date, {@code
+ * 12:34:56} with an optional fraction of one to nine digits for a time,
+ * the two joined with a {@code T} for a datetime, and {@code Z} or {@code
+ * +07:00} for an offset. A basic-form {@code 20240101} is refused, because
+ * a case that writes one is a case the other runners would read
+ * differently or not at all.
+ *
+ * The JVM holds all of it exactly. A date is a count of days and
+ * everything else is a count of nanoseconds, which is the resolution the
+ * engine keeps, so unlike the Python runner this one has no notion of a
+ * value written too finely to hold.
+ */
+final class Temporals {
+
+ private Temporals() {}
+
+ static final long NANOS_PER_SECOND = 1_000_000_000L;
+ static final long NANOS_PER_MINUTE = 60 * NANOS_PER_SECOND;
+ static final long NANOS_PER_HOUR = 60 * NANOS_PER_MINUTE;
+ static final long NANOS_PER_DAY = 24 * NANOS_PER_HOUR;
+
+ /** A date, as the count of days from 1970-01-01 the client holds one as. */
+ static Value.Temporal parseDate(String text) {
+ Long days = dateDays(text);
+ return days == null ? null : new Value.Temporal(Value.Temporal.Kind.DATE, days, 0);
+ }
+
+ /** A time of day with no offset on the end. */
+ static Value.Temporal parseLocalTime(String text) {
+ Long nanos = clockNanos(text);
+ return nanos == null ? null : new Value.Temporal(Value.Temporal.Kind.LOCAL_TIME, nanos, 0);
+ }
+
+ /**
+ * A time of day with an offset, which it carries as written: the count of
+ * nanoseconds is midnight in the offset's own day rather than midnight
+ * UTC, which is what the client's zoned time holds and what makes
+ * {@code 12:00:00+07:00} and {@code 05:00:00Z} two values rather than
+ * one.
+ */
+ static Value.Temporal parseZonedTime(String text) {
+ Offset split = splitOffset(text);
+ if (split == null) {
+ return null;
+ }
+ Long nanos = clockNanos(split.rest());
+ return nanos == null
+ ? null
+ : new Value.Temporal(Value.Temporal.Kind.ZONED_TIME, nanos, split.minutes());
+ }
+
+ /**
+ * A date and a time with no offset, as the count of nanoseconds from
+ * 1970-01-01T00:00:00 read with no zone at all.
+ */
+ static Value.Temporal parseLocalDateTime(String text) {
+ Long nanos = stampNanos(text);
+ return nanos == null
+ ? null
+ : new Value.Temporal(Value.Temporal.Kind.LOCAL_DATETIME, nanos, 0);
+ }
+
+ /**
+ * An instant and the offset it was written with.
+ *
+ * The client holds the instant in UTC and the offset beside it, so the
+ * wall clock that was written is moved back by the offset to get there.
+ * Two texts an hour apart in zones an hour apart are the same instant and
+ * hold the same count, which is the point of keeping it that way.
+ */
+ static Value.Temporal parseZonedDateTime(String text) {
+ Offset split = splitOffset(text);
+ if (split == null) {
+ return null;
+ }
+ Long nanos = stampNanos(split.rest());
+ return nanos == null
+ ? null
+ : new Value.Temporal(Value.Temporal.Kind.ZONED_DATETIME,
+ nanos - split.minutes() * NANOS_PER_MINUTE, split.minutes());
+ }
+
+ /**
+ * {@code YYYY-MM-DD} as a count of days from the epoch, or null.
+ *
+ * The date is handed to the calendar rather than checked field by
+ * field, because that is the calendar answering the question about
+ * February rather than this file having an opinion about it.
+ */
+ private static Long dateDays(String text) {
+ if (text.length() != 10 || text.charAt(4) != '-' || text.charAt(7) != '-') {
+ return null;
+ }
+ Long year = number(text.substring(0, 4));
+ Long month = number(text.substring(5, 7));
+ Long day = number(text.substring(8, 10));
+ if (year == null || month == null || day == null) {
+ return null;
+ }
+ try {
+ return LocalDate.of((int) (long) year, (int) (long) month, (int) (long) day).toEpochDay();
+ } catch (DateTimeException e) {
+ // A date the calendar does not have, such as 2023-02-30.
+ return null;
+ }
+ }
+
+ /**
+ * {@code HH:MM:SS}, with a fraction of one to nine digits when there is
+ * one, as nanoseconds since midnight, or null.
+ */
+ private static Long clockNanos(String text) {
+ int dot = text.indexOf('.');
+ String head = dot < 0 ? text : text.substring(0, dot);
+ String frac = dot < 0 ? null : text.substring(dot + 1);
+ if (head.length() != 8 || head.charAt(2) != ':' || head.charAt(5) != ':') {
+ return null;
+ }
+ Long hours = number(head.substring(0, 2));
+ Long minutes = number(head.substring(3, 5));
+ Long seconds = number(head.substring(6, 8));
+ if (hours == null || minutes == null || seconds == null) {
+ return null;
+ }
+ // No leap second, because the engine has no value for one: a time is
+ // nanoseconds since midnight and 23:59:60 is a second the count does
+ // not have.
+ if (hours > 23 || minutes > 59 || seconds > 59) {
+ return null;
+ }
+ long nanos = hours * NANOS_PER_HOUR + minutes * NANOS_PER_MINUTE + seconds * NANOS_PER_SECOND;
+ if (frac == null) {
+ return nanos;
+ }
+ // A point with nothing after it is not a fraction, and ten digits is
+ // finer than the engine counts, so neither is read as the number it
+ // resembles.
+ if (frac.isEmpty() || frac.length() > 9) {
+ return null;
+ }
+ Long part = number(frac);
+ if (part == null) {
+ return null;
+ }
+ long scaled = part;
+ for (int i = frac.length(); i < 9; i++) {
+ scaled *= 10;
+ }
+ return nanos + scaled;
+ }
+
+ /**
+ * A date and a time joined with a {@code T}, as nanoseconds from
+ * 1970-01-01T00:00:00, or null.
+ */
+ private static Long stampNanos(String text) {
+ int at = text.indexOf('T');
+ if (at < 0) {
+ return null;
+ }
+ Long days = dateDays(text.substring(0, at));
+ Long nanos = clockNanos(text.substring(at + 1));
+ if (days == null || nanos == null) {
+ return null;
+ }
+ return days * NANOS_PER_DAY + nanos;
+ }
+
+ /** What came before an offset, and the offset in minutes east of UTC. */
+ record Offset(String rest, int minutes) {}
+
+ /**
+ * The offset taken off the end of a zoned value, or null when there is
+ * none to take.
+ *
+ * Zero is written {@code Z} rather than {@code +00:00}, which is what
+ * the engine prints, and both are read here because a case may assert
+ * either. Which one it was is not kept, since it is not part of the
+ * value: the engine holds an offset in minutes and prints zero as
+ * {@code Z} whichever way it went in.
+ */
+ static Offset splitOffset(String text) {
+ if (text.endsWith("Z")) {
+ return new Offset(text.substring(0, text.length() - 1), 0);
+ }
+ if (text.length() < 7) {
+ return null;
+ }
+ char mark = text.charAt(text.length() - 6);
+ if (mark != '+' && mark != '-') {
+ return null;
+ }
+ String zone = text.substring(text.length() - 6);
+ if (zone.charAt(3) != ':') {
+ return null;
+ }
+ Long hours = number(zone.substring(1, 3));
+ Long minutes = number(zone.substring(4, 6));
+ if (hours == null || minutes == null || minutes > 59) {
+ return null;
+ }
+ long total = hours * 60 + minutes;
+ // The standard's own limit, which is wider than any zone in use and is
+ // here so that a typo lands as a refusal rather than as a date a day
+ // away from the one that was meant.
+ if (total > 18 * 60) {
+ return null;
+ }
+ if (mark == '-') {
+ total = -total;
+ }
+ return new Offset(text.substring(0, text.length() - 6), (int) total);
+ }
+
+ /**
+ * A run of ASCII digits as the number it spells, or null.
+ *
+ * Not a parser from the library, which takes a sign and grouping,
+ * neither of which belongs inside a temporal field.
+ */
+ static Long number(String text) {
+ if (text.isEmpty()) {
+ return null;
+ }
+ long out = 0;
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (c < '0' || c > '9') {
+ return null;
+ }
+ out = out * 10 + (c - '0');
+ }
+ return out;
+ }
+
+ /**
+ * An ISO 8601 duration, in the two kinds the engine keeps apart.
+ *
+ * A duration is months or it is nanoseconds and never both, because a
+ * month is not a number of days: adding one to a date is a different
+ * operation from adding thirty of them, and a type that held both would
+ * have to say which happens first. The client has a kind for each, so
+ * which one this is is part of what the case asserts.
+ *
+ * The text says which. A duration whose fields are years and months is
+ * the month kind and everything else is the nanosecond kind, and a
+ * duration with a field of each is refused rather than guessed at. That
+ * leaves one text the fields decide and the numbers cannot, which is a
+ * duration of nothing: {@code P0M} is no months and {@code PT0S} is no
+ * nanoseconds, and they are two values here where the Python runner has
+ * to call them one.
+ */
+ static Value.Temporal parseDuration(String text) {
+ String rest = text;
+ boolean negative = rest.startsWith("-");
+ if (negative || rest.startsWith("+")) {
+ rest = rest.substring(1);
+ }
+ if (!rest.startsWith("P")) {
+ return null;
+ }
+ String body = rest.substring(1);
+ int at = body.indexOf('T');
+ boolean dated = at >= 0;
+ String day = dated ? body.substring(0, at) : body;
+ String clock = dated ? body.substring(at + 1) : "";
+ // A P with nothing under it is not a duration, and neither is a T with
+ // nothing after it.
+ if (day.isEmpty() && clock.isEmpty()) {
+ return null;
+ }
+ if (dated && clock.isEmpty()) {
+ return null;
+ }
+
+ long months = 0;
+ long nanos = 0;
+ boolean sawMonths = !dated;
+ for (Piece field : pieces(day)) {
+ if (!field.ok()) {
+ return null;
+ }
+ switch (field.unit()) {
+ case 'Y':
+ months += field.whole() * 12;
+ break;
+ case 'M':
+ months += field.whole();
+ break;
+ case 'W':
+ nanos += field.whole() * 7 * NANOS_PER_DAY;
+ sawMonths = false;
+ break;
+ case 'D':
+ nanos += field.whole() * NANOS_PER_DAY;
+ sawMonths = false;
+ break;
+ default:
+ return null;
+ }
+ // A fraction of a year, a month, a week or a day is a length that
+ // depends on which one it lands on, so it is refused here rather than
+ // turned into a number of nanoseconds that is right for some of them.
+ if (field.frac() != 0) {
+ return null;
+ }
+ }
+ for (Piece field : pieces(clock)) {
+ if (!field.ok()) {
+ return null;
+ }
+ switch (field.unit()) {
+ case 'H':
+ nanos += field.whole() * NANOS_PER_HOUR;
+ break;
+ case 'M':
+ nanos += field.whole() * NANOS_PER_MINUTE;
+ break;
+ case 'S':
+ nanos += field.whole() * NANOS_PER_SECOND + field.frac();
+ break;
+ default:
+ return null;
+ }
+ if (field.frac() != 0 && field.unit() != 'S') {
+ return null;
+ }
+ }
+ if (months != 0 && nanos != 0) {
+ return null;
+ }
+ if (negative) {
+ months = -months;
+ nanos = -nanos;
+ }
+ if (months != 0 || (nanos == 0 && sawMonths)) {
+ return new Value.Temporal(Value.Temporal.Kind.DURATION_YEAR_MONTH, months, 0);
+ }
+ return new Value.Temporal(Value.Temporal.Kind.DURATION_DAY_TIME, nanos, 0);
+ }
+
+ /**
+ * One number and the letter after it, which is what a duration is a run
+ * of. The fraction is of a second, in nanoseconds, for the one field that
+ * is allowed one.
+ */
+ private record Piece(long whole, long frac, char unit, boolean ok) {}
+
+ /**
+ * Half a duration split into its fields.
+ *
+ * A half that does not split gives back one field that is not ok, so
+ * that the caller refuses the text at the same place it refuses a unit it
+ * does not know.
+ */
+ private static java.util.List Nine and not the shortest that reads back: the report this goes into
+ * is diffed against the one the reference runner writes, and that one
+ * writes nine.
+ */
+ static String showClock(long nanos) {
+ long hours = nanos / NANOS_PER_HOUR;
+ long minutes = nanos % NANOS_PER_HOUR / NANOS_PER_MINUTE;
+ long seconds = nanos % NANOS_PER_MINUTE / NANOS_PER_SECOND;
+ long frac = nanos % NANOS_PER_SECOND;
+ String out = pad(hours, 2) + ":" + pad(minutes, 2) + ":" + pad(seconds, 2);
+ return frac == 0 ? out : out + "." + pad(frac, 9);
+ }
+
+ /**
+ * A count of nanoseconds from the epoch as a date and a time joined with
+ * a {@code T}.
+ */
+ static String showStamp(long nanos) {
+ long days = Math.floorDiv(nanos, NANOS_PER_DAY);
+ return showDate(days) + "T" + showClock(nanos - days * NANOS_PER_DAY);
+ }
+
+ /**
+ * An offset in minutes east of UTC, which is {@code Z} at zero rather
+ * than {@code +00:00}.
+ */
+ static String showOffset(int offset) {
+ if (offset == 0) {
+ return "Z";
+ }
+ String sign = "+";
+ int east = offset;
+ if (east < 0) {
+ sign = "-";
+ east = -east;
+ }
+ return sign + pad(east / 60, 2) + ":" + pad(east % 60, 2);
+ }
+
+ /**
+ * A month duration as the text that parses back to it: a field that is
+ * zero is left out, and a duration with nothing left in it is
+ * {@code P0M}, because {@code P} on its own is not a value.
+ */
+ static String showMonths(long count) {
+ String sign = "";
+ long left = count;
+ if (left < 0) {
+ sign = "-";
+ left = -left;
+ }
+ long years = left / 12;
+ long months = left % 12;
+ StringBuilder out = new StringBuilder(sign).append('P');
+ if (years != 0) {
+ out.append(years).append('Y');
+ }
+ if (months != 0 || years == 0) {
+ out.append(months).append('M');
+ }
+ return out.toString();
+ }
+
+ /**
+ * A nanosecond duration as the text that parses back to it, under the
+ * same rule, with {@code PT0S} for the one that is empty.
+ */
+ static String showNanos(long count) {
+ String sign = "";
+ long left = count;
+ if (left < 0) {
+ sign = "-";
+ left = -left;
+ }
+ long days = left / NANOS_PER_DAY;
+ long rest = left % NANOS_PER_DAY;
+ StringBuilder out = new StringBuilder(sign).append('P');
+ if (days != 0) {
+ out.append(days).append('D');
+ }
+ if (rest == 0 && days != 0) {
+ return out.toString();
+ }
+ out.append('T');
+ long hours = rest / NANOS_PER_HOUR;
+ long minutes = rest % NANOS_PER_HOUR / NANOS_PER_MINUTE;
+ long seconds = rest % NANOS_PER_MINUTE / NANOS_PER_SECOND;
+ long frac = rest % NANOS_PER_SECOND;
+ if (hours != 0) {
+ out.append(hours).append('H');
+ }
+ if (minutes != 0) {
+ out.append(minutes).append('M');
+ }
+ if (seconds != 0 || frac != 0 || (hours == 0 && minutes == 0)) {
+ out.append(seconds);
+ if (frac != 0) {
+ out.append('.').append(pad(frac, 9));
+ }
+ out.append('S');
+ }
+ return out.toString();
+ }
+
+ /** A number in at least width digits, zeroes in front of it. */
+ static String pad(long n, int width) {
+ StringBuilder text = new StringBuilder(Long.toString(n));
+ while (text.length() < width) {
+ text.insert(0, '0');
+ }
+ return text.toString();
+ }
+}
diff --git a/zudb-corpus/src/main/java/dev/zudb/corpus/Text.java b/zudb-corpus/src/main/java/dev/zudb/corpus/Text.java
new file mode 100644
index 0000000..2534c19
--- /dev/null
+++ b/zudb-corpus/src/main/java/dev/zudb/corpus/Text.java
@@ -0,0 +1,56 @@
+package dev.zudb.corpus;
+
+/** How this package writes a value into a message. */
+final class Text {
+
+ private Text() {}
+
+ /**
+ * A string the way Rust's {@code {:?}} writes one.
+ *
+ * Every refusal in the corpus is written in several languages and
+ * diffed across them, so a value quoted one way here and another way
+ * there would be a difference in the report that is not a difference in
+ * the answer. Java's own escaping and Rust's disagree about what is
+ * unprintable, so the quoting is written out rather than borrowed.
+ *
+ * @param text the string to write
+ * @return the string in quotes, with the four escapes Rust uses
+ */
+ static String quote(String text) {
+ StringBuilder out = new StringBuilder(text.length() + 2).append('"');
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ switch (c) {
+ case '"':
+ case '\\':
+ out.append('\\').append(c);
+ break;
+ case '\n':
+ out.append("\\n");
+ break;
+ case '\r':
+ out.append("\\r");
+ break;
+ case '\t':
+ out.append("\\t");
+ break;
+ default:
+ out.append(c);
+ break;
+ }
+ }
+ return out.append('"').toString();
+ }
+
+ /**
+ * A refusal, formatted.
+ *
+ * @param format the message, as {@link String#format} spells one
+ * @param args what goes in it
+ * @return the exception, for a caller to throw
+ */
+ static CorpusException refuse(String format, Object... args) {
+ return new CorpusException(String.format(format, args));
+ }
+}
diff --git a/zudb-corpus/src/main/java/dev/zudb/corpus/Values.java b/zudb-corpus/src/main/java/dev/zudb/corpus/Values.java
new file mode 100644
index 0000000..ae15c70
--- /dev/null
+++ b/zudb-corpus/src/main/java/dev/zudb/corpus/Values.java
@@ -0,0 +1,739 @@
+package dev.zudb.corpus;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * The {@code {type, value}} encoding a case writes its values in.
+ *
+ * Every value in the corpus is a mapping with a {@code type} naming the
+ * GQL type and a {@code value} holding the payload. The type is written
+ * down rather than inferred because the corpus is read by several
+ * languages and inference is where they differ: a bare {@code 1} is an
+ * integer in YAML, and which integer it becomes is a decision each host
+ * language makes on its own.
+ *
+ * The payload is a YAML scalar where a YAML scalar is exact, and a
+ * string where it is not. An integer wider than 53 bits is a string,
+ * because most YAML readers hand a number to a double. A float is a
+ * string, for that reason and for NaN, inf and -0.0. A temporal value is a
+ * string, because YAML has no type that keeps an offset.
+ *
+ * NODE, EDGE and PATH are the values a graph has and a table does not,
+ * and they are written as names rather than as the numbers the engine
+ * holds. A node is {@code person#1}, the table it is a row of and which row
+ * of it. An edge is {@code knows#0->1}, its table and the two rows it runs
+ * between. A path is a sequence, like a list, holding a node and then an
+ * edge and a node for each hop.
+ *
+ * Refusing the wrong form is half the point, and refusing it here is
+ * what makes this a second reader of the corpus rather than a consumer of
+ * it.
+ *
+ * One thing this client does not need and the Python one does. There, a
+ * temporal written finer than a microsecond is a value the host language
+ * cannot hold, and a case carrying one is reported unsupported rather than
+ * run. The JVM's day is not a datetime either: a date here is a count of
+ * days and everything else is a count of nanoseconds, which is the same
+ * resolution the engine keeps, so every temporal in the corpus is a value
+ * this client holds exactly.
+ */
+public final class Values {
+
+ private Values() {}
+
+ /** The vertical tab, which the JVM has no escape for. */
+ private static final char VTAB = 0x0B;
+
+ /**
+ * Whether a type's payload is written as a quoted string. False is a type
+ * a YAML scalar carries without loss, true is one it does not, and absent
+ * is not a type at all.
+ */
+ static final Map UINT64 stops at the signed maximum because the engine's integer is
+ * signed and 64 bits wide, and wrapping the top half into a negative
+ * would be a case that passes while meaning the opposite of what it says.
+ */
+ private static final Map A row of a case names its type beside every value. A column of a
+ * load names it once at the top and every value under it is a bare
+ * payload, which is the same encoding with the type factored out, so it
+ * is the same code reading it.
+ *
+ * @param ty the type, already known
+ * @param value the payload
+ * @return the value
+ * @throws CorpusException if the payload does not spell one
+ */
+ public static Cell payload(String ty, Node value) {
+ Boolean quoted = form(ty);
+ if (quoted == null) {
+ throw Text.refuse("line %d: %s", value.line(), unknownType(ty));
+ }
+
+ if (ty.equals("LIST") || ty.equals("PATH")) {
+ // The empty list is a value worth a case and needs a spelling, which
+ // is a "value:" with nothing under it.
+ List A path alternates and ends at both ends with a node, so a sequence
+ * that does not is a case that could never pass. Refusing it here rather
+ * than at the comparison is the difference between a message naming the
+ * line and a report saying the row differs.
+ */
+ private static Cell walk(List The table's name rather than its id, because the id is a number the
+ * file decided and every client builds its own file. Split from the
+ * right, so that a table whose name holds a {@code #} is still readable.
+ */
+ private static Cell nodeAt(String text) {
+ int hash = text.lastIndexOf('#');
+ if (hash <= 0) {
+ return null;
+ }
+ Long offset = row(text.substring(hash + 1));
+ return offset == null ? null : new Cell.Node(text.substring(0, hash), offset);
+ }
+
+ /**
+ * An edge, written as its table and the rows it runs between: {@code
+ * knows#0->1}.
+ */
+ private static Cell edgeAt(String text) {
+ int hash = text.lastIndexOf('#');
+ if (hash <= 0) {
+ return null;
+ }
+ String pair = text.substring(hash + 1);
+ int arrow = pair.indexOf("->");
+ if (arrow < 0) {
+ return null;
+ }
+ Long from = row(pair.substring(0, arrow));
+ Long to = row(pair.substring(arrow + 2));
+ if (from == null || to == null) {
+ return null;
+ }
+ return new Cell.Edge(text.substring(0, hash), from, to);
+ }
+
+ /**
+ * A row number, which is one or more ASCII digits and nothing else.
+ *
+ * Not {@code Long.parseLong}, which takes a leading sign, and not
+ * {@code Long.parseUnsignedLong}, which takes one too. Neither is a row
+ * number anybody meant to write.
+ */
+ private static Long row(String text) {
+ if (text.isEmpty()) {
+ return null;
+ }
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (c < '0' || c > '9') {
+ return null;
+ }
+ }
+ try {
+ return Long.parseUnsignedLong(text);
+ } catch (NumberFormatException e) {
+ // A row number wider than the count of rows any load writes.
+ return null;
+ }
+ }
+
+ /** The value a type's text spells, or null when it spells none. */
+ private static Cell scalar(String ty, String text) {
+ switch (ty) {
+ case "BOOL":
+ if (text.equals("true")) {
+ return new Cell.Bool(true);
+ }
+ if (text.equals("false")) {
+ return new Cell.Bool(false);
+ }
+ return null;
+ case "STRING":
+ return new Cell.Str(text);
+ case "BYTES":
+ return fromHexits(text);
+ case "FLOAT32":
+ case "FLOAT64": {
+ Double f = parseFloat(text);
+ if (f == null) {
+ return null;
+ }
+ return new Cell.Float(ty.equals("FLOAT32") ? (float) (double) f : f);
+ }
+ case "DATE":
+ return time(Temporals.parseDate(text));
+ case "LOCALTIME":
+ return time(Temporals.parseLocalTime(text));
+ case "ZONEDTIME":
+ return time(Temporals.parseZonedTime(text));
+ case "LOCALDATETIME":
+ return time(Temporals.parseLocalDateTime(text));
+ case "ZONEDDATETIME":
+ return time(Temporals.parseZonedDateTime(text));
+ case "DURATION":
+ return time(Temporals.parseDuration(text));
+ default:
+ break;
+ }
+ long[] range = BOUNDS.get(ty);
+ if (range == null) {
+ return null;
+ }
+ long n;
+ try {
+ n = Long.parseLong(text);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ // Written back out and compared, so that a leading plus, a leading zero
+ // and a grouping mark are all refused rather than read as the number
+ // they resemble.
+ if (!Long.toString(n).equals(text)) {
+ return null;
+ }
+ if (n < range[0] || n > range[1]) {
+ return null;
+ }
+ return new Cell.Int(n);
+ }
+
+ private static Cell time(dev.zudb.Value.Temporal value) {
+ return value == null ? null : new Cell.Time(value);
+ }
+
+ /**
+ * A float, including the three spellings YAML has no opinion about. They
+ * are spelled the way Rust prints them, because that is what the
+ * reference runner writes into a failure report and what a case is pasted
+ * from.
+ */
+ private static Double parseFloat(String text) {
+ switch (text) {
+ case "NaN":
+ return Double.NaN;
+ case "inf":
+ return Double.POSITIVE_INFINITY;
+ case "-inf":
+ return Double.NEGATIVE_INFINITY;
+ default:
+ break;
+ }
+ // A float is exact here, so `1` is not a FLOAT64 and neither is
+ // `1e400`. The first is an integer somebody meant to write as `1.0` and
+ // the second is `inf` under another name.
+ if (text.indexOf('.') < 0 && text.indexOf('e') < 0 && text.indexOf('E') < 0) {
+ return null;
+ }
+ // Double.parseDouble takes "Infinity", "0x1p-2", a trailing d or f and
+ // whitespace at either end, none of which the corpus writes and all of
+ // which would be a case that reads differently in the other runners.
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (c >= '0' && c <= '9') {
+ continue;
+ }
+ if (".eE+-".indexOf(c) < 0) {
+ return null;
+ }
+ }
+ double f;
+ try {
+ f = Double.parseDouble(text);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ return Double.isInfinite(f) || Double.isNaN(f) ? null : f;
+ }
+
+ /**
+ * How a value reads in a failure report, in the encoding's own spelling
+ * so that it can be pasted into a case, and line for line what the Rust
+ * runner prints so that two reports can be diffed.
+ *
+ * @param value the value
+ * @return the text
+ */
+ public static String show(Cell value) {
+ return switch (value) {
+ case Cell.Null _ -> "NULL";
+ case Cell.Bool v -> v.value() ? "BOOL true" : "BOOL false";
+ case Cell.Int v -> "INT64 \"" + v.value() + "\"";
+ case Cell.Float v -> "FLOAT64 \"" + showFloat(v.value()) + "\"";
+ case Cell.Str v -> "STRING " + Text.quote(v.value());
+ case Cell.Bytes v -> "BYTES \"" + hexits(v.value()) + "\"";
+ case Cell.Time v -> showTime(v.value());
+ case Cell.List v -> "LIST [" + showAll(v.items()) + "]";
+ case Cell.Path v -> "PATH [" + showAll(v.items()) + "]";
+ case Cell.Node v -> "NODE \"" + v.table() + "#" + Long.toUnsignedString(v.offset()) + "\"";
+ case Cell.Edge v -> "EDGE \"" + v.table() + "#" + Long.toUnsignedString(v.source())
+ + "->" + Long.toUnsignedString(v.target()) + "\"";
+ case Cell.Record v -> showRecord(v);
+ // A graph or a binding table, which is a value no case can write. It
+ // prints as itself, under a name that is not a type, so that a report
+ // carrying one cannot be mistaken for a case that could be pasted
+ // back into the corpus.
+ case Cell.Other v -> "(" + v.value().getClass().getSimpleName() + ") " + v.value();
+ };
+ }
+
+ private static String showTime(dev.zudb.Value.Temporal v) {
+ return switch (v.kind()) {
+ case DATE -> "DATE \"" + Temporals.showDate(v.count()) + "\"";
+ case LOCAL_TIME -> "LOCALTIME \"" + Temporals.showClock(v.count()) + "\"";
+ case ZONED_TIME -> "ZONEDTIME \"" + Temporals.showClock(v.count())
+ + Temporals.showOffset(v.offsetMinutes()) + "\"";
+ case LOCAL_DATETIME -> "LOCALDATETIME \"" + Temporals.showStamp(v.count()) + "\"";
+ case ZONED_DATETIME -> "ZONEDDATETIME \""
+ + Temporals.showStamp(v.count() + v.offsetMinutes() * Temporals.NANOS_PER_MINUTE)
+ + Temporals.showOffset(v.offsetMinutes()) + "\"";
+ case DURATION_YEAR_MONTH -> "DURATION \"" + Temporals.showMonths(v.count()) + "\"";
+ case DURATION_DAY_TIME -> "DURATION \"" + Temporals.showNanos(v.count()) + "\"";
+ };
+ }
+
+ /**
+ * The fields of a record, in order, so that a report of one reads the
+ * same twice. A hash map's order is not stable and a failure that
+ * reorders its own fields between runs is a failure nobody can diff.
+ */
+ private static String showRecord(Cell.Record value) {
+ StringBuilder out = new StringBuilder("RECORD {");
+ String between = "";
+ for (Map.Entry Written out rather than taken from {@code Double.toString}, which
+ * switches to an exponent at a different place, writes the exponent with
+ * a capital E and writes a mantissa of one digit as {@code 1.0}. All of
+ * those are a report that differs from the reference one without the
+ * answer differing, which is the thing this whole file exists to avoid.
+ *
+ * @param f the double
+ * @return the text
+ */
+ static String showFloat(double f) {
+ if (Double.isNaN(f)) {
+ return "NaN";
+ }
+ if (f == Double.POSITIVE_INFINITY) {
+ return "inf";
+ }
+ if (f == Double.NEGATIVE_INFINITY) {
+ return "-inf";
+ }
+ String sign = "";
+ double g = f;
+ // Not f < 0, which says nothing about the zero that is signed.
+ if (Double.doubleToRawLongBits(f) < 0) {
+ sign = "-";
+ g = -f;
+ }
+ // The shortest digits that read back as this double, and where the
+ // point goes in them. Double.toString has given the shortest since 19,
+ // so the digits are taken from it and only the layout is decided here.
+ Shortest s = shortest(g);
+ String run = s.run();
+ int exp = s.exp();
+ int point = exp + 1;
+ if (point <= -4 || point > 16) {
+ String head = run.substring(0, 1);
+ if (run.length() > 1) {
+ head += "." + run.substring(1);
+ }
+ return sign + head + "e" + exp;
+ }
+ if (point <= 0) {
+ return sign + "0." + "0".repeat(-point) + run;
+ }
+ if (point >= run.length()) {
+ return sign + run + "0".repeat(point - run.length()) + ".0";
+ }
+ return sign + run.substring(0, point) + "." + run.substring(point);
+ }
+
+ /**
+ * The digits that read back as a double, and the power of ten the first
+ * of them stands for, so that the value is {@code d.ddd} times ten to it.
+ *
+ * @param run the digits, with no zero on either end
+ * @param exp what the first digit stands for
+ */
+ private record Shortest(String run, int exp) {}
+
+ /**
+ * The shortest text that reads back as a positive double, split into its
+ * digits and its exponent.
+ *
+ * {@code Double.toString} is where the digits come from, since it has
+ * given the shortest run that reads back since release 19, give or take
+ * the one digit handled below. What it does not give is the exponent,
+ * because it writes one only outside a range of its own, so it is worked
+ * out here from where the point landed.
+ */
+ private static Shortest shortest(double g) {
+ String text = Double.toString(g);
+ int e = text.indexOf('E');
+ String head = e < 0 ? text : text.substring(0, e);
+ int shift = e < 0 ? 0 : Integer.parseInt(text.substring(e + 1));
+ int point = head.indexOf('.');
+ String all = head.replace(".", "");
+ int start = 0;
+ while (start < all.length() - 1 && all.charAt(start) == '0') {
+ start++;
+ }
+ int end = all.length();
+ while (end > start + 1 && all.charAt(end - 1) == '0') {
+ end--;
+ }
+ String run = all.substring(start, end);
+ int exp = point - start - 1 + shift;
+ // Double.toString gives the shortest run that reads back except in one
+ // place. Where a single digit would do, its specification asks for the
+ // closest decimal of two digits instead, so the smallest subnormal comes
+ // out as 4.9e-324 here where every other runner prints 5e-324. Two
+ // digits is the only length that can be one too many, so one attempt at
+ // shortening closes it, and the round trip is what decides.
+ if (run.length() == 2) {
+ Shortest one = rounded(run, exp);
+ if (Double.parseDouble(one.run() + "e" + one.exp()) == g) {
+ return one;
+ }
+ }
+ return new Shortest(run, exp);
+ }
+
+ /** Two digits rounded to one, carrying into the exponent at ten. */
+ private static Shortest rounded(String run, int exp) {
+ int first = run.charAt(0) - '0';
+ if (run.charAt(1) >= '5') {
+ first++;
+ }
+ return first == 10 ? new Shortest("1", exp + 1) : new Shortest(String.valueOf(first), exp);
+ }
+
+ /**
+ * A byte string the way the engine writes one: two hexits to a byte,
+ * upper case, no quotes and no X.
+ *
+ * Upper case because the standard writes the literal that way, and a
+ * reader comparing two of these is comparing text, so one case is one
+ * answer.
+ *
+ * @param raw the octets
+ * @return the hexits
+ */
+ static String hexits(byte[] raw) {
+ final String to = "0123456789ABCDEF";
+ StringBuilder out = new StringBuilder(raw.length * 2);
+ for (byte b : raw) {
+ out.append(to.charAt((b >> 4) & 0xF)).append(to.charAt(b & 0xF));
+ }
+ return out.toString();
+ }
+
+ /**
+ * The bytes a run of hexits names, and null for anything that is not a
+ * run of hexits or that names half a byte.
+ *
+ * Space is allowed anywhere and dropped, which is what the standard's
+ * production allows and what lets a long literal be written in groups.
+ * Either case reads, because a value that went in as {@code 00ab} and came
+ * back as {@code 00AB} is the same value.
+ */
+ private static Cell fromHexits(String text) {
+ byte[] nibbles = new byte[text.length()];
+ int n = 0;
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == VTAB || c == '\f') {
+ continue;
+ } else if (c >= '0' && c <= '9') {
+ nibbles[n++] = (byte) (c - '0');
+ } else if (c >= 'a' && c <= 'f') {
+ nibbles[n++] = (byte) (c - 'a' + 10);
+ } else if (c >= 'A' && c <= 'F') {
+ nibbles[n++] = (byte) (c - 'A' + 10);
+ } else {
+ return null;
+ }
+ }
+ if (n % 2 != 0) {
+ return null;
+ }
+ // The empty byte string is a value of its own and a case asserts it,
+ // which is why this is an array of no octets rather than nothing at
+ // all: X'' comes back from the engine as an empty one and the two
+ // should read alike.
+ byte[] out = new byte[n / 2];
+ for (int i = 0; i < n; i += 2) {
+ out[i / 2] = (byte) (nibbles[i] << 4 | nibbles[i + 1]);
+ }
+ return new Cell.Bytes(out);
+ }
+}
diff --git a/zudb-corpus/src/main/java/dev/zudb/corpus/Yaml.java b/zudb-corpus/src/main/java/dev/zudb/corpus/Yaml.java
new file mode 100644
index 0000000..579b2a6
--- /dev/null
+++ b/zudb-corpus/src/main/java/dev/zudb/corpus/Yaml.java
@@ -0,0 +1,431 @@
+package dev.zudb.corpus;
+
+import static dev.zudb.corpus.Text.quote;
+import static dev.zudb.corpus.Text.refuse;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The subset of YAML the corpus is written in.
+ *
+ * YAML is a large language and the corpus needs a small corner of it:
+ * block mappings, block sequences, and scalars. Everything else is refused
+ * with a line number. The files are hand written and are read by people in
+ * nine repositories who did not write them, so a construct a reader
+ * quietly reinterpreted would be a case that says one thing to a reviewer
+ * and another to the runner.
+ *
+ * So: two space indentation and no tabs, {@code "- "} with exactly one
+ * space, plain, single quoted and double quoted scalars on one line, and
+ * comments. No flow collections, no block scalars, no anchors, no aliases,
+ * no tags, no document markers, no multi document streams.
+ *
+ * This is the fifth implementation of that subset, after
+ * {@code crates/zu-corpus/src/yaml.rs} in the engine, {@code
+ * conformance/c/yaml.c} beside it, {@code conformance/reader.py} in
+ * zu-python and {@code corpus/reader.go} in zu-go. There are YAML
+ * libraries for the JVM that would read these files, and would read a good
+ * deal more besides: they would take a flow sequence, a block scalar and
+ * an anchor, none of which a case may use. What the corpus needs is a
+ * reader that refuses, and the cheapest way to have one is to write it.
+ *
+ * Whether a scalar was quoted survives parsing, because the value
+ * encoding turns on it.
+ */
+public final class Yaml {
+
+ private Yaml() {}
+
+ /** A vertical tab, which Java has no escape for and Go writes as \v. */
+ private static final char VTAB = 0x0B;
+
+ /** What comes off the end of a line, which is every space Go's TrimRight took. */
+ private static final String TRAILING = " \r" + VTAB + "\f";
+
+ /**
+ * A document, read.
+ *
+ * @param text the whole file
+ * @return the node the document is
+ * @throws CorpusException on the first thing in it this reader will not
+ * read
+ */
+ public static Node parse(String text) {
+ List Splitting here rather than in the parser is what lets
+ * {@code "- name: x"} and a {@code "name: x"} on its own line be the same
+ * shape by the time anything looks at them.
+ */
+ private static List Three rules keep this from eating content. A {@code #} starts a
+ * comment only with whitespace before it, because one inside a word is
+ * part of the word. A quote opens a quoted run only with whitespace
+ * before it, because a quote inside a word is part of the word too, which
+ * is what lets a {@code doc:} say "it's" without opening a run that never
+ * closes. And a quote that opens nothing that closes was not a run at
+ * all, which is what lets a {@code query:} hold
+ * {@code cast(' 42 ' AS INT64)}.
+ */
+ private static String stripComment(String text) {
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ boolean opens = i == 0 || space(text.charAt(i - 1));
+ if (c == '#' && opens) {
+ return text.substring(0, i);
+ }
+ if ((c == '"' || c == '\'') && opens) {
+ int end = closingQuote(text.substring(i + 1), c);
+ if (end >= 0) {
+ i += 1 + end;
+ }
+ }
+ }
+ return text;
+ }
+
+ /**
+ * Whether a character is one of the ones that can stand before a comment
+ * or a quote.
+ */
+ private static boolean space(char c) {
+ return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == VTAB || c == '\f';
+ }
+
+ /**
+ * The offset of the quote that closes a run whose opening quote has
+ * already been passed, or -1 when the line ends first.
+ *
+ * The two styles hide a quote differently: a double quoted run escapes
+ * with a backslash, and a single quoted run doubles the quote, which is
+ * the only escape it has.
+ */
+ private static int closingQuote(String rest, char mark) {
+ for (int i = 0; i < rest.length(); i++) {
+ if (rest.charAt(i) == '\\' && mark == '"') {
+ i++;
+ continue;
+ }
+ if (rest.charAt(i) == mark) {
+ if (mark == '\'' && i + 1 < rest.length() && rest.charAt(i + 1) == '\'') {
+ i++;
+ continue;
+ }
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * The node that starts where the cursor is and is indented {@code
+ * indent}, leaving the cursor on the first line that is not part of it.
+ */
+ private static Node parseNode(Cursor at, int indent) {
+ Line here = at.at(0);
+ if (here.dash()) {
+ return parseSeq(at, indent);
+ }
+ // A mapping key is a bare word and a ":". Anything else at this
+ // position is a scalar standing on its own, which is what the items of
+ // a sequence of scalars are.
+ if (splitKey(here.text()) != null) {
+ return parseMap(at, indent);
+ }
+ at.i++;
+ return parseScalar(here.text(), here.no());
+ }
+
+ private static Node parseSeq(Cursor at, int indent) {
+ int start = at.at(0).no();
+ List A key is a bare word, and the {@code ":"} after it ends the line or
+ * has a space after it, so that a plain scalar holding a colon is still a
+ * scalar.
+ */
+ private static Key splitKey(String text) {
+ String key;
+ String rest;
+ int cut = text.indexOf(": ");
+ if (cut >= 0) {
+ key = text.substring(0, cut);
+ rest = trimLeft(text.substring(cut + 2));
+ } else {
+ if (!text.endsWith(":")) {
+ return null;
+ }
+ key = text.substring(0, text.length() - 1);
+ rest = "";
+ }
+ if (key.isEmpty()) {
+ return null;
+ }
+ for (int i = 0; i < key.length(); i++) {
+ char c = key.charAt(i);
+ boolean bare = c == '_' || c == '-'
+ || ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || ('0' <= c && c <= '9');
+ if (!bare) {
+ return null;
+ }
+ }
+ return new Key(key, rest);
+ }
+
+ private static Node parseScalar(String text, int at) {
+ for (char mark : new char[] {'"', '\''}) {
+ if (text.isEmpty() || text.charAt(0) != mark) {
+ continue;
+ }
+ String body = text.substring(1);
+ // The closing quote is found by scanning rather than by taking the
+ // last one on the line, so that `"a" and "b"` is refused instead of
+ // read as one scalar with quotes in the middle.
+ int end = closingQuote(body, mark);
+ if (end < 0) {
+ throw refuse("line %d: a %c that opens and does not close on its line", at, mark);
+ }
+ if (end + 1 != body.length()) {
+ throw refuse("line %d: %s after the scalar ends", at, quote(body.substring(end + 1)));
+ }
+ String inner = body.substring(0, end);
+ if (mark == '\'') {
+ // A single quoted run has one escape, the doubled quote, and a
+ // backslash in it is a backslash.
+ return Node.scalar(at, inner.replace("''", "'"), true);
+ }
+ return Node.scalar(at, unescape(inner, at), true);
+ }
+ if (!text.isEmpty() && "[]{}&*!|>%@`".indexOf(text.charAt(0)) >= 0) {
+ throw refuse("line %d: a plain scalar opening with '%c', which is a construct this "
+ + "reader does not read", at, text.charAt(0));
+ }
+ return Node.scalar(at, text, false);
+ }
+
+ /**
+ * The escape a backslash and this character spell, or -1 for one the
+ * corpus does not use.
+ *
+ * The escapes that name a code point by its digits are not here,
+ * because the corpus writes those as the character itself and a case that
+ * wants the digits is testing the engine's own escapes inside a query
+ * rather than the file's.
+ */
+ private static int escape(char c) {
+ switch (c) {
+ case '"':
+ return '"';
+ case '\\':
+ return '\\';
+ case 'n':
+ return '\n';
+ case 'r':
+ return '\r';
+ case 't':
+ return '\t';
+ case '0':
+ return 0;
+ case 'b':
+ return '\b';
+ case 'f':
+ return '\f';
+ default:
+ return -1;
+ }
+ }
+
+ private static String unescape(String body, int at) {
+ StringBuilder out = new StringBuilder(body.length());
+ for (int i = 0; i < body.length(); i++) {
+ if (body.charAt(i) != '\\') {
+ out.append(body.charAt(i));
+ continue;
+ }
+ if (i + 1 >= body.length()) {
+ throw refuse("line %d: a scalar ending in a backslash", at);
+ }
+ char next = body.charAt(i + 1);
+ int c = escape(next);
+ if (c < 0) {
+ throw refuse("line %d: \\%c is not an escape", at, next);
+ }
+ out.append((char) c);
+ i++;
+ }
+ return out.toString();
+ }
+
+ /** The text with any of {@code cut} taken off the end. */
+ private static String trimRight(String text, String cut) {
+ int end = text.length();
+ while (end > 0 && cut.indexOf(text.charAt(end - 1)) >= 0) {
+ end--;
+ }
+ return text.substring(0, end);
+ }
+
+ /** The text with spaces taken off the front. */
+ private static String trimLeft(String text) {
+ int start = 0;
+ while (start < text.length() && text.charAt(start) == ' ') {
+ start++;
+ }
+ return text.substring(start);
+ }
+}
diff --git a/zudb-corpus/src/main/java/dev/zudb/corpus/package-info.java b/zudb-corpus/src/main/java/dev/zudb/corpus/package-info.java
new file mode 100644
index 0000000..d29d49e
--- /dev/null
+++ b/zudb-corpus/src/main/java/dev/zudb/corpus/package-info.java
@@ -0,0 +1,16 @@
+/**
+ * The shared conformance corpus, read and run against this client.
+ *
+ * The corpus is a directory of YAML files, versioned with the engine
+ * and shipped to every client in the family. A case is a statement and
+ * what running it must produce, which is deliberately the whole of it:
+ * every client in every language can run a statement and look at the rows
+ * that come back, so a corpus written in those terms is one every client
+ * can run, and a corpus written in terms of a client's own API would be
+ * nine corpora.
+ *
+ * What this prints is what the reference runner in Rust prints, line
+ * for line, so that a disagreement between two clients is a diff and not
+ * a reading exercise.
+ */
+package dev.zudb.corpus;
diff --git a/zudb-corpus/src/test/java/dev/zudb/corpus/CorpusTest.java b/zudb-corpus/src/test/java/dev/zudb/corpus/CorpusTest.java
new file mode 100644
index 0000000..6780f38
--- /dev/null
+++ b/zudb-corpus/src/test/java/dev/zudb/corpus/CorpusTest.java
@@ -0,0 +1,87 @@
+package dev.zudb.corpus;
+
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.nio.file.Path;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * The corpus itself, run against this client.
+ *
+ * The cases live in the engine's repository and are versioned with it,
+ * so this test says where they are with an environment variable and skips
+ * without one. That is what zu-go and zu-python do with ZU_CASES and what
+ * makes a checkout of this repository alone still {@code mvn test} green: a
+ * client whose test suite cannot run without a second repository beside it
+ * is one nobody clones to fix a typo.
+ *
+ * CI sets the variable, having checked the engine out at the revision the
+ * staged library was built from. Anything else compares a client against a
+ * corpus that is not the one it was built against, which reports the engine
+ * catching up to its own cases as this client failing.
+ */
+final class CorpusTest {
+
+ /** Where the case files are, or null when nobody said. */
+ private static final String CASES = System.getenv("ZU_CASES");
+
+ @TempDir Path work;
+
+ /** Skips a test that has no corpus to run. */
+ private static void needsCases() {
+ assumeTrue(CASES != null && !CASES.isBlank(), "ZU_CASES does not point at the case files");
+ }
+
+ @Test
+ void theCorpusReads() {
+ needsCases();
+ List A case the engine has not caught up to is unsupported and is not a
+ * failure, because the corpus is the contract and the engine catches up
+ * to it. A case that fails is this client answering a question wrongly,
+ * and there is no allowance for one.
+ */
+ @Test
+ void everyCaseInTheCorpusPassesOrIsAheadOfTheEngine() {
+ needsCases();
+ List Both halves matter and the second one is the reason this file is
+ * long. A reader that accepts everything the corpus writes is half a
+ * reader: the other half is that a case using a block scalar, an anchor or
+ * a flow sequence is refused with a line number rather than read as
+ * something the author did not write. Five implementations of this subset
+ * exist and a construct one of them quietly accepts is a case that passes
+ * in one repository and fails in four.
+ *
+ * The refusal messages are checked in full rather than by substring,
+ * because they are diffed against the reference runner's and a wording
+ * that drifted would be a difference in the report that is not a
+ * difference in the answer.
+ */
+class ReaderTest {
+
+ /** The message a document was refused with, and a failure when it was read. */
+ private static String refused(String text) {
+ return assertThrows(CorpusException.class, () -> Yaml.parse(text)).getMessage();
+ }
+
+ @Test
+ void aMappingKeepsItsKeysInTheOrderTheyWereWritten() {
+ Node doc = Yaml.parse("suite: string\ndoc: what a string does\nschema: 4\n");
+ List The corpus itself is run by the test beside this one, which needs the
+ * case files and is skipped without them. This file is the other half: a
+ * handful of cases written to make the runner do each of the things it
+ * does, including the ones the corpus has no case for because the corpus is
+ * a corpus of correct expectations. A case that wants the wrong number of
+ * rows has to be reported and not merely fail, and the only way to have one
+ * is to write one.
+ *
+ * The detail strings are checked in full, because they are what a report
+ * says and the report is diffed against the reference runner's.
+ */
+final class RunnerTest {
+
+ @TempDir Path work;
+
+ private int round;
+
+ /**
+ * A directory nothing has run in yet.
+ *
+ * One per run rather than one per test, because a case that fails
+ * leaves its database behind on purpose and the next run of a case with
+ * the same name would find the file already there. The tests here run
+ * several suites of a case called `one` and most of them fail, which is
+ * the whole point of them.
+ */
+ private Path fresh() {
+ try {
+ return Files.createDirectory(work.resolve("run-" + round++));
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * Runs a suite written inline and gives back what each case came to. The
+ * databases go under the test's own directory, which JUnit removes.
+ */
+ private List A case says what a statement produces one way or the other: columns
+ * and rows, or a GQLSTATUS. Most of what this file tests is the ways a case
+ * can say neither, or both, or something that looks like one and is not,
+ * because a corpus file is hand written and read by people in nine
+ * repositories who did not write it. A case that was quietly dropped for a
+ * typo in a key is a suite that runs green with less in it than anybody
+ * thinks.
+ */
+class SuiteTest {
+
+ /**
+ * A whole file with body as its list of cases, so that a test about one
+ * case does not have to write a header every time.
+ */
+ private static String suiteAround(String body) {
+ return "schema: 4\nsuite: test\ndoc: a suite for the tests\ncases:\n" + body;
+ }
+
+ /** The case a body comes to. */
+ private static Suite.Case oneCase(String body) {
+ Suite suite = Suite.read(suiteAround(body));
+ assertEquals(1, suite.cases().size(), "the body writes one case");
+ return suite.cases().get(0);
+ }
+
+ /** The message a file was refused with. */
+ private static String rejected(String text) {
+ return assertThrows(CorpusException.class, () -> Suite.read(text),
+ () -> "this should have been refused: " + text).getMessage();
+ }
+
+ private static String lines(String... written) {
+ return String.join("\n", written) + "\n";
+ }
+
+ @Test
+ @DisplayName("a suite is its header and its cases")
+ void aSuiteIsItsHeaderAndItsCases() {
+ Suite suite = Suite.read(lines(
+ "schema: 4",
+ "suite: string",
+ "doc: what a string does",
+ "cases:",
+ " - name: one",
+ " doc: the first",
+ " query: RETURN 1 AS n",
+ " columns:",
+ " - n",
+ " rows:",
+ " - values:",
+ " - type: INT64",
+ " value: \"1\""));
+ assertEquals("string", suite.name());
+ assertEquals("what a string does", suite.doc());
+ assertNull(suite.load(), "a suite with no `load:` came back with one");
+ Suite.Case one = suite.cases().get(0);
+ assertEquals("one", one.name());
+ assertEquals("RETURN 1 AS n", one.query());
+ assertEquals(5, one.line());
+ assertEquals(Suite.MAIN, one.on(), "a case that names no connection runs on main");
+ assertTrue(one.hasColumns());
+ assertEquals(List.of("n"), one.columns());
+ assertEquals(List.of(List.of(new Cell.Int(1))), one.rows());
+ }
+
+ // The version is checked before anything else, so that a corpus unpacked
+ // from an old release says what it is instead of failing somewhere in the
+ // middle with a message about a key.
+ @Test
+ @DisplayName("the schema version is checked before anything else")
+ void theSchemaVersionIsCheckedBeforeAnythingElse() {
+ record Case(String what, String text, String want) {}
+ for (Case c : List.of(
+ new Case("a version this runner does not read",
+ "schema: 3\nsuite: test\ndoc: d\ncases:\n - name: one\n",
+ "this is schema 3 and the runner reads schema 4"),
+ new Case("a version that is not a number",
+ "schema: four\nsuite: test\ndoc: d\n",
+ "\"four\" is not a schema version"),
+ new Case("no version at all",
+ "suite: test\ndoc: d\n",
+ "the file does not open with `schema:`"),
+ new Case("a version that is not one line",
+ "schema:\n - 4\nsuite: test\ndoc: d\n",
+ "the file does not open with `schema:`"))) {
+ assertEquals(c.want(), rejected(c.text()), c.what());
+ }
+ }
+
+ @Test
+ @DisplayName("a suite says what is wrong with its header")
+ void aSuiteSaysWhatIsWrongWithItsHeader() {
+ record Case(String what, String text, String want) {}
+ for (Case c : List.of(
+ new Case("a key a suite has no room for",
+ "schema: 4\nsuite: test\ndoc: d\nloadd:\ncases:\n",
+ "line 1: a suite has no key \"loadd\""),
+ new Case("no name",
+ "schema: 4\ndoc: d\ncases:\n",
+ "line 1: no `suite:`"),
+ new Case("no doc",
+ "schema: 4\nsuite: test\ncases:\n",
+ "line 1: no `doc:`"),
+ new Case("no cases",
+ "schema: 4\nsuite: test\ndoc: d\n",
+ "a suite with no `cases:`"),
+ new Case("cases that are not a sequence",
+ "schema: 4\nsuite: test\ndoc: d\ncases: one\n",
+ "`cases:` is a sequence"),
+ new Case("a `cases:` with nothing under it",
+ "schema: 4\nsuite: test\ndoc: d\ncases:\n",
+ "`cases:` is a sequence"))) {
+ assertEquals(c.want(), rejected(c.text()), c.what());
+ }
+ }
+
+ // A name is what a report cites and what a binding's skip list names, so
+ // two cases sharing one is a report that says less than it looks like it
+ // does.
+ @Test
+ @DisplayName("two cases may not share a name")
+ void twoCasesMayNotShareAName() {
+ String body = lines(
+ " - name: one",
+ " doc: d",
+ " query: RETURN 1",
+ " raises: \"42001\"",
+ " - name: one",
+ " doc: d",
+ " query: RETURN 2",
+ " raises: \"42001\"");
+ assertEquals("two cases are called \"one\"", rejected(suiteAround(body)));
+ }
+
+ @Test
+ @DisplayName("a case name is lower case words joined by dashes")
+ void aCaseNameIsLowerCaseWordsJoinedByDashes() {
+ for (String name : List.of("One", "a name", "a_name", "a.name", "\"\"")) {
+ String body = " - name: " + name + "\n doc: d\n query: RETURN 1\n";
+ assertTrue(rejected(suiteAround(body))
+ .contains("is a case name, which is lower case words joined by dashes"),
+ () -> name + " should have been refused for its spelling");
+ }
+ // A `name:` with nothing after it is a key with nothing under it rather
+ // than an empty name, so it is refused for its shape and not for what it
+ // spells. The two messages say different things and this is the one that
+ // helps.
+ assertEquals("line 5: `name:` is one line of text",
+ rejected(suiteAround(" - name:\n doc: d\n query: RETURN 1\n")));
+ // Digits and dashes are in, since a case is named after what it does and
+ // some of those have numbers in them.
+ Suite.Case one = oneCase(" - name: a-3-hop-walk\n doc: d\n query: RETURN 1\n"
+ + " raises: \"42001\"\n");
+ assertEquals("a-3-hop-walk", one.name());
+ }
+
+ // A case says what it produces one way or the other, and the reader
+ // refuses both ways at once and neither way at all.
+ @Test
+ @DisplayName("a case says what it produces exactly one way")
+ void aCaseSaysWhatItProducesExactlyOneWay() {
+ String both = lines(
+ " - name: one",
+ " doc: d",
+ " query: RETURN 1",
+ " raises: \"42001\"",
+ " columns:",
+ " - n");
+ assertEquals("line 5: a case that raises has no rows, and one that returns rows does not raise",
+ rejected(suiteAround(both)), "a case saying both");
+
+ String neither = " - name: one\n doc: d\n query: RETURN 1\n";
+ assertEquals("line 5: a case says what it produces, with `columns:` and `rows:` or with "
+ + "`raises:`", rejected(suiteAround(neither)), "a case saying neither");
+
+ // Columns with no rows is the one that looks finished and is not, so the
+ // message says how to write the case that was meant.
+ String noRows = " - name: one\n doc: d\n query: RETURN 1\n columns:\n - n\n";
+ assertEquals("line 5: `columns:` with no `rows:`. A case expecting nothing back writes `rows:` "
+ + "with an empty sequence under it.", rejected(suiteAround(noRows)));
+ }
+
+ // FINISH answers no columns at all, which is not the same as a query whose
+ // columns held no rows, so an empty `columns:` is a case and not an
+ // omission.
+ @Test
+ @DisplayName("no columns and no rows are two different expectations")
+ void noColumnsAndNoRowsAreTwoDifferentExpectations() {
+ Suite.Case empty = oneCase(" - name: one\n doc: d\n query: FINISH\n"
+ + " columns:\n rows:\n");
+ assertTrue(empty.hasColumns(),
+ "a `columns:` with nothing under it read as a case with no columns key");
+ assertEquals(List.of(), empty.columns());
+ assertEquals(List.of(), empty.rows());
+
+ Suite.Case noRows = oneCase(" - name: one\n doc: d\n query: RETURN 1 AS n\n"
+ + " columns:\n - n\n rows:\n");
+ assertEquals(List.of("n"), noRows.columns());
+ assertEquals(List.of(), noRows.rows());
+ }
+
+ @Test
+ @DisplayName("a row holds one value per column")
+ void aRowHoldsOneValuePerColumn() {
+ String body = lines(
+ " - name: one",
+ " doc: d",
+ " query: RETURN 1 AS a, 2 AS b",
+ " columns:",
+ " - a",
+ " - b",
+ " rows:",
+ " - values:",
+ " - type: INT8",
+ " value: 1");
+ assertEquals("line 5: a row of 1 against 2 columns", rejected(suiteAround(body)));
+ }
+
+ @Test
+ @DisplayName("a raises is the shape of a GQLSTATUS")
+ void aRaisesIsTheShapeOfAGqlstatus() {
+ assertEquals("42001",
+ oneCase(" - name: one\n doc: d\n query: RETURN\n raises: \"42001\"\n").raises());
+ // The shape and not the list, because a corpus that had to be told about
+ // every code the standard defines is one nobody could add a case to.
+ assertEquals("22G0Z",
+ oneCase(" - name: one\n doc: d\n query: RETURN\n raises: \"22G0Z\"\n").raises());
+ for (String code : List.of("4200", "420011", "42a01", "42-01", "")) {
+ String body = " - name: one\n doc: d\n query: RETURN\n raises: \"" + code + "\"\n";
+ assertEquals("line 8: \"" + code + "\" is not the shape of a GQLSTATUS, which is five "
+ + "characters of digits and capitals", rejected(suiteAround(body)));
+ }
+ }
+
+ // A setup statement is one line, or a line and the connection it runs on,
+ // which is what a case testing two sessions against one file needs.
+ @Test
+ @DisplayName("setup is a line or a line and a connection")
+ void setupIsALineOrALineAndAConnection() {
+ Suite.Case one = oneCase(lines(
+ " - name: one",
+ " doc: d",
+ " setup:",
+ " - INSERT (:person {name: 'a'})",
+ " - on: other",
+ " query: INSERT (:person {name: 'b'})",
+ " on: other",
+ " query: MATCH (p:person) RETURN count(*) AS c",
+ " raises: \"42001\""));
+ assertEquals(2, one.setup().size());
+ assertEquals(new Suite.Step(Suite.MAIN, "INSERT (:person {name: 'a'})"), one.setup().get(0));
+ assertEquals("other", one.setup().get(1).on());
+ assertEquals("other", one.on());
+
+ record Case(String what, String body, String want) {}
+ for (Case c : List.of(
+ new Case("setup that is not a sequence",
+ " - name: one\n doc: d\n setup: INSERT (:p)\n query: RETURN 1\n",
+ "line 5: `setup:` is a sequence of statements"),
+ new Case("a step written as a mapping with no connection",
+ " - name: one\n doc: d\n setup:\n - query: INSERT (:p)\n"
+ + " query: RETURN 1\n",
+ "line 8: a setup statement written as a mapping names the connection it runs on"),
+ new Case("a step with a key it has no room for",
+ " - name: one\n doc: d\n setup:\n - on: other\n params: x\n"
+ + " query: RETURN 1\n",
+ "line 8: a setup statement has no key \"params\""),
+ new Case("a connection name that is not one",
+ " - name: one\n doc: d\n on: Other\n query: RETURN 1\n",
+ "line 7: \"Other\" is a connection name, which is lower case words joined by dashes"))) {
+ assertEquals(c.want(), rejected(suiteAround(c.body())), c.what());
+ }
+ }
+
+ // A parameter is the value encoding with a name beside it, and the name is
+ // checked against what a statement may write after the $.
+ @Test
+ @DisplayName("a parameter is a value with a name")
+ void aParameterIsAValueWithAName() {
+ Suite.Case one = oneCase(lines(
+ " - name: one",
+ " doc: d",
+ " query: RETURN $n AS n",
+ " params:",
+ " - name: n",
+ " type: INT64",
+ " value: \"1\"",
+ " - name: nothing",
+ " type: NULL",
+ " raises: \"42001\""));
+ assertEquals(2, one.params().size());
+ assertEquals(new Suite.Param("n", new Cell.Int(1)), one.params().get(0));
+ assertEquals(new Suite.Param("nothing", Cell.NULL), one.params().get(1));
+
+ record Case(String what, String body, String want) {}
+ for (Case c : List.of(
+ new Case("params that are not a sequence",
+ " - name: one\n doc: d\n query: RETURN $n\n params: n\n",
+ "line 8: `params:` is a sequence"),
+ new Case("a parameter that is not a mapping",
+ " - name: one\n doc: d\n query: RETURN $n\n params:\n - n\n",
+ "line 9: a parameter is a mapping of `name`, `type` and `value`, and this is a scalar"),
+ new Case("a parameter with a key it has no room for",
+ " - name: one\n doc: d\n query: RETURN $n\n params:\n - name: n\n"
+ + " type: NULL\n on: other\n",
+ "line 9: a parameter has no key \"on\""),
+ new Case("a name no statement can write",
+ " - name: one\n doc: d\n query: RETURN $n\n params:\n - name: n one\n"
+ + " type: NULL\n",
+ "line 9: \"n one\" is a parameter name, which is what a statement writes after the `$`"),
+ new Case("two parameters with one name",
+ " - name: one\n doc: d\n query: RETURN $n\n params:\n - name: n\n"
+ + " type: NULL\n - name: n\n type: NULL\n",
+ "line 11: two parameters are called \"n\""))) {
+ assertEquals(c.want(), rejected(suiteAround(c.body())), c.what());
+ }
+ }
+
+ // A load is the other half of the corpus: everything else is an
+ // expression, which says what a value means on the way out and nothing
+ // about how it got in.
+ @Test
+ @DisplayName("a load is a table, its columns and the edges between its rows")
+ void aLoadIsATableItsColumnsAndTheEdgesBetweenItsRows() {
+ Suite suite = Suite.read(lines(
+ "schema: 4",
+ "suite: test",
+ "doc: d",
+ "load:",
+ " nodes: person",
+ " edges: knows",
+ " count: 3",
+ " columns:",
+ " - name: name",
+ " type: STRING",
+ " values:",
+ " - a",
+ " - b",
+ " - c",
+ " - name: age",
+ " type: INT64",
+ " values:",
+ " - \"1\"",
+ " - \"2\"",
+ " - \"3\"",
+ " pairs:",
+ " - from: 0",
+ " to: 1",
+ " - from: 1",
+ " to: 2",
+ "cases:",
+ " - name: one",
+ " doc: d",
+ " query: MATCH (p:person) RETURN count(*) AS c",
+ " raises: \"42001\""));
+ Suite.Load load = suite.load();
+ assertNotNull(load, "the suite came back with no load");
+ assertEquals("person", load.nodes());
+ assertEquals("knows", load.edges());
+ assertEquals(3, load.count());
+ assertEquals(2, load.columns().size());
+ assertEquals("INT64", load.columns().get(1).type());
+ assertEquals(new Cell.Int(2), load.columns().get(1).values().get(1));
+ assertEquals(List.of(new Suite.Pair(0, 1), new Suite.Pair(1, 2)), load.pairs());
+ }
+
+ @Test
+ @DisplayName("a load says what is wrong with it")
+ void aLoadSaysWhatIsWrongWithIt() {
+ // A load with the given body, and a case after it so that the file gets
+ // as far as the load before it runs out of suite.
+ String column = " columns:\n - name: name\n type: STRING\n values:\n - a\n";
+ record Case(String what, String body, String want) {}
+ for (Case c : List.of(
+ new Case("a key a load has no room for",
+ " nodes: person\n edges: knows\n count: 1\n rows:\n" + column,
+ "line 5: a load has no key \"rows\""),
+ new Case("a table name that is not one",
+ " nodes: a person\n edges: knows\n count: 1\n" + column,
+ "line 5: \"a person\" is not a table name"),
+ new Case("no count",
+ " nodes: person\n edges: knows\n" + column,
+ "line 5: a load says how many rows it has, with `count:`"),
+ new Case("a count that is not a number",
+ " nodes: person\n edges: knows\n count: three\n" + column,
+ "line 5: `count:` is a number of rows"),
+ new Case("a count of nothing",
+ " nodes: person\n edges: knows\n count: 0\n" + column,
+ "line 5: a load of no rows is a load nothing can be read back from"),
+ new Case("no columns",
+ " nodes: person\n edges: knows\n count: 1\n",
+ "line 5: a load has `columns:`"),
+ new Case("a column short of the rows the load declares",
+ " nodes: person\n edges: knows\n count: 2\n" + column,
+ "line 9: column \"name\" holds 1 values against the 2 rows the load declares"),
+ new Case("two columns with one name",
+ " nodes: person\n edges: knows\n count: 1\n" + column
+ + " - name: name\n type: STRING\n values:\n - b\n",
+ "line 5: two columns are called \"name\""),
+ new Case("a column type nothing knows",
+ " nodes: person\n edges: knows\n count: 1\n"
+ + " columns:\n - name: name\n type: TEXT\n values:\n - a\n",
+ "line 9: TEXT is not a type this encoding knows"),
+ new Case("an edge whose row is not in the table",
+ " nodes: person\n edges: knows\n count: 2\n"
+ + " columns:\n - name: name\n type: STRING\n values:\n - a\n"
+ + " - b\n pairs:\n - from: 0\n to: 2\n",
+ "line 15: `to: 2` against a table of 2 rows, which are numbered 0 to 1"),
+ new Case("an edge with a key it has no room for",
+ " nodes: person\n edges: knows\n count: 1\n" + column
+ + " pairs:\n - from: 0\n to: 0\n kind: friend\n",
+ "line 14: an edge has no key \"kind\""),
+ new Case("an edge missing an end",
+ " nodes: person\n edges: knows\n count: 1\n" + column
+ + " pairs:\n - from: 0\n",
+ "line 14: an edge has a `to:` row number"))) {
+ String text = "schema: 4\nsuite: test\ndoc: d\nload:\n" + c.body()
+ + "cases:\n - name: one\n doc: d\n query: RETURN 1\n raises: \"42001\"\n";
+ assertEquals(c.want(), rejected(text), c.what());
+ }
+ }
+
+ // readDir walks a directory in sorted order, because a listing's order is
+ // the filesystem's and a report diffed against another runner's has to
+ // walk them the same way.
+ @Test
+ @DisplayName("readDir walks the files in order and checks their names")
+ void readDirWalksTheFilesInOrderAndChecksTheirNames(@TempDir Path dir, @TempDir Path bare)
+ throws IOException {
+ write(dir, "zebra.yaml", "zebra");
+ write(dir, "alpha.yaml", "alpha");
+ // Not a case file, and not read.
+ Files.write(dir.resolve("README.md"), "hello".getBytes(StandardCharsets.UTF_8));
+
+ List These parsers exist so that the corpus reader is a second opinion
+ * about the text rather than a second call into the same library the
+ * client calls, and a second opinion is only worth having if it says no to
+ * the same things. So most of this file is texts that look like temporal
+ * values and are not: a basic-form date, a leap second, a fraction of ten
+ * digits, an offset past the standard's own limit.
+ *
+ * The other thing tested here is which of the two duration kinds a text
+ * comes to. A month is not a number of days, the client has a kind for
+ * each, and which one a case means is part of what the case asserts.
+ */
+class TemporalsTest {
+
+ private static Value.Temporal at(Kind kind, long count) {
+ return new Value.Temporal(kind, count, 0);
+ }
+
+ @Test
+ @DisplayName("a date is the extended form and the calendar answers for February")
+ void aDateIsTheExtendedFormAndTheCalendarAnswersForFebruary() {
+ record Case(String text, long days) {}
+ for (Case c : List.of(
+ new Case("1970-01-01", 0),
+ new Case("1970-01-02", 1),
+ new Case("1969-12-31", -1),
+ new Case("2024-02-29", 19782),
+ new Case("0001-01-01", -719162),
+ new Case("9999-12-31", 2932896))) {
+ assertEquals(at(Kind.DATE, c.days()), Temporals.parseDate(c.text()),
+ c.text() + " is " + c.days() + " days from the epoch");
+ // And back out again, which is what a failure report prints.
+ assertEquals(c.text(), Temporals.showDate(c.days()),
+ c.days() + " days should print as it was written");
+ }
+ for (String text : List.of(
+ "20240101", // the basic form, which the other runners would not read
+ "2024-1-1", // fields that are not padded
+ "2023-02-30", // a day February does not have
+ "2023-13-01", // a month the year does not have
+ "2023-00-01", // and the two that are zero
+ "2023-01-00",
+ "2024-02-29x", // something after the date
+ "+2024-01-01", // a sign, which the field reader does not take
+ "2024/01/01", // the wrong separator
+ "")) {
+ assertNull(Temporals.parseDate(text), Text.quote(text) + " is not a date");
+ }
+ }
+
+ @Test
+ @DisplayName("a time is seconds always and a fraction when there is one")
+ void aTimeIsSecondsAlwaysAndAFractionWhenThereIsOne() {
+ record Case(String text, long nanos) {}
+ for (Case c : List.of(
+ new Case("00:00:00", 0),
+ new Case("23:59:59", 86399 * NANOS_PER_SECOND),
+ new Case("12:34:56.789000000", 45296 * NANOS_PER_SECOND + 789000000),
+ new Case("12:34:56.789", 45296 * NANOS_PER_SECOND + 789000000),
+ new Case("12:34:56.1", 45296 * NANOS_PER_SECOND + 100000000),
+ new Case("23:59:59.999999999", 86400 * NANOS_PER_SECOND - 1))) {
+ assertEquals(at(Kind.LOCAL_TIME, c.nanos()), Temporals.parseLocalTime(c.text()),
+ c.text() + " is " + c.nanos() + " nanoseconds");
+ }
+ // Printed with nine digits when there is a fraction and none when
+ // there is not, which is what the reference runner writes and not what
+ // the client's own toString gives.
+ record Print(long nanos, String want) {}
+ for (Print c : List.of(
+ new Print(0, "00:00:00"),
+ new Print(100000000, "00:00:00.100000000"),
+ new Print(1, "00:00:00.000000001"),
+ new Print(86400 * NANOS_PER_SECOND - 1, "23:59:59.999999999"))) {
+ assertEquals(c.want(), Temporals.showClock(c.nanos()),
+ c.nanos() + " nanoseconds should print that way");
+ }
+ for (String text : List.of(
+ "123456", // the basic form
+ "1:02:03", // fields that are not padded
+ "24:00:00", // the hour a day does not have
+ "23:60:00", // and the minute
+ "23:59:60", // the leap second, which the count does not have
+ "12:34:56.", // a point with nothing after it
+ "12:34:56.1234567890", // ten digits, finer than the engine counts
+ "12:34:56.-1", // a sign inside the fraction
+ "12:34", // no seconds
+ "12:34:56Z", // an offset, which a local time does not carry
+ "")) {
+ assertNull(Temporals.parseLocalTime(text), Text.quote(text) + " is not a time");
+ }
+ }
+
+ /**
+ * A zoned time carries the clock as written rather than moved to UTC,
+ * which is what makes {@code 12:00:00+07:00} and {@code 05:00:00Z} two
+ * values here and not one.
+ */
+ @Test
+ @DisplayName("a zoned time keeps the clock it was written with")
+ void aZonedTimeKeepsTheClockItWasWrittenWith() {
+ Value.Temporal got = Temporals.parseZonedTime("12:00:00+07:00");
+ Value.Temporal want = new Value.Temporal(Kind.ZONED_TIME, 12 * NANOS_PER_HOUR, 7 * 60);
+ assertEquals(want, got, "the clock is kept as written");
+ assertNotEquals(Temporals.parseZonedTime("05:00:00Z"), got,
+ "05:00:00Z and 12:00:00+07:00 are two values");
+ assertEquals("12:00:00+07:00",
+ Temporals.showClock(want.count()) + Temporals.showOffset(want.offsetMinutes()));
+ }
+
+ /**
+ * A zoned datetime is held as the instant, so two texts an hour apart in
+ * zones an hour apart are one instant and hold one count.
+ */
+ @Test
+ @DisplayName("a zoned datetime is held as the instant and the offset beside it")
+ void aZonedDateTimeIsHeldAsTheInstantAndTheOffsetBesideIt() {
+ Value.Temporal east = Temporals.parseZonedDateTime("2024-01-01T07:00:00+07:00");
+ Value.Temporal utc = Temporals.parseZonedDateTime("2024-01-01T00:00:00Z");
+ assertEquals(utc.count(), east.count(),
+ "the same instant should hold the same count");
+ assertNotEquals(utc, east, "the offset is part of what a case asserts");
+ // Printed back into the zone it was written in, which is the wall
+ // clock a case reads.
+ assertEquals("2024-01-01T07:00:00+07:00", stamp(east));
+ // A zero offset prints as Z, whichever of the two spellings went in.
+ assertEquals("2024-01-01T00:00:00Z",
+ stamp(Temporals.parseZonedDateTime("2024-01-01T00:00:00+00:00")));
+ }
+
+ /** A zoned datetime back in the zone it was written in. */
+ private static String stamp(Value.Temporal v) {
+ return Temporals.showStamp(v.count() + v.offsetMinutes() * NANOS_PER_MINUTE)
+ + Temporals.showOffset(v.offsetMinutes());
+ }
+
+ @Test
+ @DisplayName("a local datetime counts from the epoch and reads back before it")
+ void aLocalDateTimeCountsFromTheEpochAndReadsBackBeforeIt() {
+ record Case(String text, long nanos) {}
+ for (Case c : List.of(
+ new Case("1970-01-01T00:00:00", 0),
+ new Case("1970-01-02T00:00:00", NANOS_PER_DAY),
+ new Case("1969-12-31T23:59:59", -NANOS_PER_SECOND),
+ new Case("2024-01-15T10:00:00", 19737 * NANOS_PER_DAY + 10 * NANOS_PER_HOUR))) {
+ assertEquals(at(Kind.LOCAL_DATETIME, c.nanos()), Temporals.parseLocalDateTime(c.text()),
+ c.text() + " is " + c.nanos() + " nanoseconds");
+ // The date of an instant before the epoch is the day it is on and not
+ // the day after it, which is what the floored division is for.
+ assertEquals(c.text(), Temporals.showStamp(c.nanos()),
+ c.nanos() + " nanoseconds should print as it was written");
+ }
+ for (String text : List.of(
+ "2024-01-15 10:00:00", // a space where the T goes
+ "2024-01-15", // no time
+ "10:00:00", // no date
+ "2024-01-15T10:00:00Z", // an offset, which a local datetime does not carry
+ "")) {
+ assertNull(Temporals.parseLocalDateTime(text), Text.quote(text) + " is not a datetime");
+ }
+ }
+
+ @Test
+ @DisplayName("an offset is Z or the extended form within the standard's limit")
+ void anOffsetIsZOrTheExtendedFormWithinTheStandardsLimit() {
+ record Case(String text, String rest, int minutes) {}
+ for (Case c : List.of(
+ new Case("12:00:00Z", "12:00:00", 0),
+ new Case("12:00:00+00:00", "12:00:00", 0),
+ new Case("12:00:00+07:00", "12:00:00", 420),
+ new Case("12:00:00-05:30", "12:00:00", -330),
+ new Case("12:00:00+18:00", "12:00:00", 1080),
+ new Case("12:00:00-18:00", "12:00:00", -1080))) {
+ assertEquals(new Temporals.Offset(c.rest(), c.minutes()), Temporals.splitOffset(c.text()),
+ c.text() + " splits that way");
+ }
+ for (String text : List.of(
+ "12:00:00+18:01", // past the standard's own limit
+ "12:00:00+19:00",
+ "12:00:00+0700", // the basic form
+ "12:00:00+07", // hours alone
+ "12:00:00+07:60", // a minute an hour does not have
+ "12:00:00", // no offset at all
+ "12:00:00 07:00", // no sign
+ "+07:00")) { // an offset and nothing before it, which is too short to split
+ Temporals.Offset got = Temporals.splitOffset(text);
+ assertTrue(got == null || got.rest().isEmpty(),
+ Text.quote(text) + " carries no offset this reader takes");
+ }
+ record Print(int minutes, String want) {}
+ for (Print c : List.of(
+ new Print(0, "Z"),
+ new Print(420, "+07:00"),
+ new Print(-330, "-05:30"),
+ new Print(1080, "+18:00"))) {
+ assertEquals(c.want(), Temporals.showOffset(c.minutes()),
+ c.minutes() + " should print that way");
+ }
+ }
+
+ /**
+ * A duration is months or it is nanoseconds and never both, because
+ * adding a month to a date is a different operation from adding thirty
+ * days and a kind holding both would have to say which happens first.
+ */
+ @Test
+ @DisplayName("a duration is one of the two kinds the engine keeps apart")
+ void aDurationIsOneOfTheTwoKindsTheEngineKeepsApart() {
+ record Case(String text, Value.Temporal want) {}
+ for (Case c : List.of(
+ new Case("P1Y", months(12)),
+ new Case("P1Y2M", months(14)),
+ new Case("P2M", months(2)),
+ new Case("-P1Y2M", months(-14)),
+ // The one text the fields decide and the numbers cannot: no months
+ // and no nanoseconds, told apart by what was written.
+ new Case("P0M", months(0)),
+ new Case("P1D", nanos(NANOS_PER_DAY)),
+ new Case("P1W", nanos(7 * NANOS_PER_DAY)),
+ new Case("PT1H", nanos(NANOS_PER_HOUR)),
+ new Case("PT1M", nanos(NANOS_PER_MINUTE)),
+ new Case("PT1S", nanos(NANOS_PER_SECOND)),
+ new Case("PT0S", nanos(0)),
+ new Case("PT0.250000000S", nanos(250 * 1_000_000L)),
+ new Case("PT0.5S", nanos(500 * 1_000_000L)),
+ new Case("P1DT2H3M4S", nanos(NANOS_PER_DAY + 2 * NANOS_PER_HOUR
+ + 3 * NANOS_PER_MINUTE + 4 * NANOS_PER_SECOND)),
+ new Case("-PT1H", nanos(-NANOS_PER_HOUR)),
+ new Case("+PT1H", nanos(NANOS_PER_HOUR)))) {
+ assertEquals(c.want(), Temporals.parseDuration(c.text()),
+ c.text() + " is that duration");
+ }
+ for (String text : List.of(
+ "P1Y1D", // a field of each kind, refused rather than guessed at
+ "P1M1S", // likewise, through the time part
+ "P", // a P with nothing under it
+ "PT", // a T with nothing after it
+ "P1DT", // and a T on the end of a date part
+ "1Y", // no P
+ "P1X", // a unit nothing here knows
+ "P1", // digits with no unit after them
+ "PT1", // likewise in the time part
+ "P0.5Y", // a fraction of a year, whose length depends on which one
+ "P0.5M", // and of a month
+ "P0.5D", // and of a day, which a leap second makes not quite exact
+ "PT0.5H", // a fraction anywhere but on the seconds
+ "PT0.5M",
+ "PT1.S", // a point with nothing after it
+ "PT0.1234567890S", // ten digits
+ "P1Y2M3W4DT5H6M7.8S", // every field at once, which is both kinds
+ "")) {
+ assertNull(Temporals.parseDuration(text),
+ Text.quote(text) + " is not a duration this reader takes");
+ }
+ }
+
+ /**
+ * A duration prints as the text that parses back to it, which is what
+ * lets a failure report be pasted into a case.
+ */
+ @Test
+ @DisplayName("a duration prints as the text that reads back to it")
+ void aDurationPrintsAsTheTextThatReadsBackToIt() {
+ record Case(Value.Temporal value, String want) {}
+ for (Case c : List.of(
+ new Case(months(0), "P0M"),
+ new Case(months(1), "P1M"),
+ new Case(months(12), "P1Y"),
+ new Case(months(14), "P1Y2M"),
+ new Case(months(-14), "-P1Y2M"),
+ new Case(nanos(0), "PT0S"),
+ new Case(nanos(NANOS_PER_SECOND), "PT1S"),
+ new Case(nanos(NANOS_PER_MINUTE), "PT1M"),
+ new Case(nanos(NANOS_PER_HOUR), "PT1H"),
+ new Case(nanos(NANOS_PER_DAY), "P1D"),
+ new Case(nanos(25 * NANOS_PER_HOUR), "P1DT1H"),
+ new Case(nanos(250 * 1_000_000L), "PT0.250000000S"),
+ new Case(nanos(NANOS_PER_SECOND + 1), "PT1.000000001S"),
+ new Case(nanos(-NANOS_PER_HOUR), "-PT1H"),
+ new Case(nanos(NANOS_PER_DAY + 2 * NANOS_PER_HOUR
+ + 3 * NANOS_PER_MINUTE + 4 * NANOS_PER_SECOND), "P1DT2H3M4S"),
+ new Case(nanos(NANOS_PER_HOUR + NANOS_PER_MINUTE), "PT1H1M"))) {
+ String got = c.value().kind() == Kind.DURATION_YEAR_MONTH
+ ? Temporals.showMonths(c.value().count())
+ : Temporals.showNanos(c.value().count());
+ assertEquals(c.want(), got, c.value() + " should print that way");
+ // And back, which is the half that says the spelling is the one this
+ // reader takes and not merely one that looks right.
+ assertEquals(c.value(), Temporals.parseDuration(c.want()),
+ Text.quote(c.want()) + " should read back to it");
+ }
+ }
+
+ private static Value.Temporal months(long count) {
+ return at(Kind.DURATION_YEAR_MONTH, count);
+ }
+
+ private static Value.Temporal nanos(long count) {
+ return at(Kind.DURATION_DAY_TIME, count);
+ }
+
+ /**
+ * The field reader every parser above goes through, which is not
+ * {@code Long.parseLong}: a sign or a grouping mark inside a temporal
+ * field is a text some other runner would refuse.
+ */
+ @Test
+ @DisplayName("a temporal field is digits and nothing else")
+ void aTemporalFieldIsDigitsAndNothingElse() {
+ record Case(String text, long want) {}
+ for (Case c : List.of(
+ new Case("0", 0),
+ new Case("07", 7),
+ new Case("2024", 2024))) {
+ assertEquals(c.want(), Temporals.number(c.text()), c.text() + " is that number");
+ }
+ for (String text : List.of("", "+1", "-1", "1_0", " 1", "1 ", "0x1", "١٢")) {
+ assertNull(Temporals.number(text), Text.quote(text) + " is not a field");
+ }
+ }
+}
diff --git a/zudb-corpus/src/test/java/dev/zudb/corpus/ValuesTest.java b/zudb-corpus/src/test/java/dev/zudb/corpus/ValuesTest.java
new file mode 100644
index 0000000..6d91521
--- /dev/null
+++ b/zudb-corpus/src/test/java/dev/zudb/corpus/ValuesTest.java
@@ -0,0 +1,601 @@
+package dev.zudb.corpus;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import dev.zudb.Value;
+import java.util.List;
+import java.util.Map;
+import java.util.function.IntFunction;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The value encoding, tested on both sides of it.
+ *
+ * A case says what a statement produces by naming a type and a payload,
+ * and the whole point of naming the type is that a payload cannot be
+ * misread. So the tests here are mostly refusals: an INT64 written bare is
+ * refused because some reader will round it, a STRING written where a LIST
+ * belongs is refused, a payload out of its type's range is refused. A round
+ * trip through a reader that accepted all three would still look green.
+ *
+ * The other half is {@link Values#show}, which is what a failure report
+ * prints. It is diffed against the Rust runner's line for line, so a float
+ * that switches to an exponent one power earlier here than there is a
+ * difference in the report that is not a difference in the answer.
+ */
+class ValuesTest {
+
+ /**
+ * The value a {@code type:}/{@code value:} mapping comes to, written the
+ * way a case writes it.
+ */
+ private static Cell value(String text) {
+ return Values.decode(Yaml.parse(text));
+ }
+
+ /**
+ * The message a {@code type:}/{@code value:} mapping was refused with,
+ * and a failure when it was read instead.
+ */
+ private static String declined(String text) {
+ return assertThrows(CorpusException.class, () -> Values.decode(Yaml.parse(text)),
+ () -> "this should have been refused: " + text).getMessage();
+ }
+
+ private static Cell integer(long n) {
+ return new Cell.Int(n);
+ }
+
+ @Test
+ @DisplayName("every type says whether its payload is quoted")
+ void everyTypeSaysWhetherItsPayloadIsQuoted() {
+ // The whole table, written out rather than iterated over, because the
+ // point of the test is that the table is this and not whatever the map
+ // happens to hold.
+ List A float is the one that usually needs a comparison written by hand:
+ * NaN is not equal to itself and a case asserting NaN has to pass, and 0.0
+ * equals -0.0 and a case asserting -0.0 has to fail on 0.0. A record
+ * compares a double with {@code Double.compare}, which is already both of
+ * those answers.
+ */
+ @Test
+ @DisplayName("two values are the same when equality says so, and a float is why that works")
+ void twoValuesAreTheSameWhenEqualitySaysSo() {
+ record Case(String what, Cell want, Cell got, boolean same) {}
+ Cell nan = new Cell.Float(Double.NaN);
+ for (Case c : List.of(
+ new Case("NaN against itself", nan, new Cell.Float(Double.NaN), true),
+ new Case("a negative zero against a positive one",
+ new Cell.Float(-0.0), new Cell.Float(0.0), false),
+ new Case("a positive zero against a negative one",
+ new Cell.Float(0.0), new Cell.Float(-0.0), false),
+ new Case("two ones", new Cell.Float(1.0), new Cell.Float(1.0), true),
+ new Case("an integer against a float", integer(1), new Cell.Float(1.0), false),
+ new Case("a boolean against an integer", new Cell.Bool(true), integer(1), false),
+ new Case("nothing against nothing", Cell.NULL, new Cell.Null(), true),
+ new Case("nothing against a value", Cell.NULL, integer(0), false),
+ new Case("two lists", new Cell.List(List.of(integer(1), Cell.NULL)),
+ new Cell.List(List.of(integer(1), Cell.NULL)), true),
+ new Case("lists of different lengths", new Cell.List(List.of(integer(1))),
+ new Cell.List(List.of(integer(1), Cell.NULL)), false),
+ new Case("a list against a scalar", new Cell.List(List.of(integer(1))), integer(1), false),
+ new Case("nested lists",
+ new Cell.List(List.of(new Cell.List(List.of(new Cell.Float(1.0))))),
+ new Cell.List(List.of(new Cell.List(List.of(new Cell.Float(1.0))))), true),
+ new Case("two walks", new Cell.Path(List.of(new Cell.Node("p", 0))),
+ new Cell.Path(List.of(new Cell.Node("p", 0))), true),
+ new Case("a walk against a list", new Cell.Path(List.of(new Cell.Node("p", 0))),
+ new Cell.List(List.of(new Cell.Node("p", 0))), false),
+ new Case("two records", new Cell.Record(Map.of("a", integer(1))),
+ new Cell.Record(Map.of("a", integer(1))), true),
+ new Case("records of different sizes", new Cell.Record(Map.of("a", integer(1))),
+ new Cell.Record(Map.of("a", integer(1), "b", Cell.NULL)), false),
+ new Case("records with different names", new Cell.Record(Map.of("a", integer(1))),
+ new Cell.Record(Map.of("b", integer(1))), false),
+ new Case("a record against a scalar", new Cell.Record(Map.of("a", integer(1))),
+ integer(1), false),
+ new Case("two byte strings", new Cell.Bytes(new byte[] {1, 2}),
+ new Cell.Bytes(new byte[] {1, 2}), true),
+ new Case("byte strings that differ", new Cell.Bytes(new byte[] {1, 2}),
+ new Cell.Bytes(new byte[] {1, 3}), false),
+ new Case("a byte string against a string", new Cell.Bytes(new byte[] {65}),
+ new Cell.Str("A"), false),
+ new Case("two dates", date(1), date(1), true),
+ new Case("dates that differ", date(1), date(2), false),
+ new Case("a year month against a day time",
+ new Cell.Time(new Value.Temporal(Value.Temporal.Kind.DURATION_YEAR_MONTH, 0, 0)),
+ new Cell.Time(new Value.Temporal(Value.Temporal.Kind.DURATION_DAY_TIME, 0, 0)),
+ false))) {
+ assertEquals(c.same(), c.want().equals(c.got()), c.what());
+ // And the hash, since a value that compares equal and hashes
+ // differently is one a set would hold twice.
+ if (c.same()) {
+ assertEquals(c.want().hashCode(), c.got().hashCode(), c.what() + ", hashed");
+ }
+ }
+ }
+
+ private static Cell date(long days) {
+ return new Cell.Time(new Value.Temporal(Value.Temporal.Kind.DATE, days, 0));
+ }
+
+ /**
+ * What a failure report prints, in the encoding's own spelling so that a
+ * line can be pasted back into a case.
+ */
+ @Test
+ @DisplayName("show writes a value the way a case would spell it")
+ void showWritesAValueTheWayACaseWouldSpellIt() {
+ record Case(Cell value, String want) {}
+ for (Case c : List.of(
+ new Case(Cell.NULL, "NULL"),
+ new Case(new Cell.Bool(true), "BOOL true"),
+ new Case(new Cell.Bool(false), "BOOL false"),
+ new Case(integer(-7), "INT64 \"-7\""),
+ new Case(new Cell.Float(1.5), "FLOAT64 \"1.5\""),
+ new Case(new Cell.Str("a string"), "STRING \"a string\""),
+ new Case(new Cell.Str("with \"quotes\""), "STRING \"with \\\"quotes\\\"\""),
+ new Case(new Cell.Bytes(new byte[] {0, (byte) 0xAB}), "BYTES \"00AB\""),
+ new Case(new Cell.Bytes(new byte[0]), "BYTES \"\""),
+ new Case(date(0), "DATE \"1970-01-01\""),
+ new Case(time(Value.Temporal.Kind.LOCAL_TIME, 0, 0), "LOCALTIME \"00:00:00\""),
+ new Case(time(Value.Temporal.Kind.LOCAL_TIME, 123456789, 0),
+ "LOCALTIME \"00:00:00.123456789\""),
+ new Case(time(Value.Temporal.Kind.ZONED_TIME, 0, 0), "ZONEDTIME \"00:00:00Z\""),
+ new Case(time(Value.Temporal.Kind.LOCAL_DATETIME, 0, 0),
+ "LOCALDATETIME \"1970-01-01T00:00:00\""),
+ // The instant is UTC and the offset is what the case wrote, so the
+ // clock printed beside it is the instant moved into that zone, which
+ // is the wall clock the case reads back.
+ new Case(time(Value.Temporal.Kind.ZONED_DATETIME, 0, 60),
+ "ZONEDDATETIME \"1970-01-01T01:00:00+01:00\""),
+ new Case(time(Value.Temporal.Kind.DURATION_YEAR_MONTH, 14, 0), "DURATION \"P1Y2M\""),
+ new Case(time(Value.Temporal.Kind.DURATION_DAY_TIME, 0, 0), "DURATION \"PT0S\""),
+ new Case(new Cell.List(List.of(integer(1), Cell.NULL)), "LIST [INT64 \"1\", NULL]"),
+ new Case(new Cell.List(List.of()), "LIST []"),
+ new Case(new Cell.Path(List.of(new Cell.Node("person", 0), new Cell.Edge("knows", 0, 1),
+ new Cell.Node("person", 1))),
+ "PATH [NODE \"person#0\", EDGE \"knows#0->1\", NODE \"person#1\"]"),
+ new Case(new Cell.Node("person", 3), "NODE \"person#3\""),
+ new Case(new Cell.Edge("knows", 3, 4), "EDGE \"knows#3->4\""),
+ new Case(new Cell.Record(Map.of("b", integer(2), "a", Cell.NULL)),
+ "RECORD {a: NULL, b: INT64 \"2\"}"),
+ new Case(new Cell.Record(Map.of()), "RECORD {}"),
+ // A value the corpus has no spelling for prints as itself, under a
+ // name that is not a type, so a report carrying one cannot be
+ // mistaken for a case that could be pasted back in.
+ new Case(new Cell.Other(new Value.Graph()), "(Graph) Graph[]"))) {
+ assertEquals(c.want(), Values.show(c.value()));
+ }
+ // A record's names are sorted, because a hash map's order is not stable
+ // and a failure that reorders its own fields between runs is a failure
+ // nobody can diff.
+ Cell record = new Cell.Record(Map.of("z", Cell.NULL, "a", Cell.NULL, "m", Cell.NULL));
+ for (int i = 0; i < 8; i++) {
+ assertEquals("RECORD {a: NULL, m: NULL, z: NULL}", Values.show(record),
+ "a record prints the same every time");
+ }
+ }
+
+ private static Cell time(Value.Temporal.Kind kind, long count, int offset) {
+ return new Cell.Time(new Value.Temporal(kind, count, offset));
+ }
+
+ /**
+ * A float is printed the way Rust's {@code {:?}} writes one: the shortest
+ * text that reads back as the same double, always with a point or an
+ * exponent, switching to an exponent where Rust switches and writing the
+ * exponent bare rather than with a sign and a padding zero.
+ */
+ @Test
+ @DisplayName("a float prints the way the reference runner prints it")
+ void aFloatPrintsTheWayTheReferenceRunnerPrintsIt() {
+ record Case(double value, String want) {}
+ for (Case c : List.of(
+ new Case(0, "0.0"),
+ new Case(-0.0, "-0.0"),
+ new Case(1, "1.0"),
+ new Case(-1, "-1.0"),
+ new Case(1.5, "1.5"),
+ new Case(0.1, "0.1"),
+ new Case(1.0 / 3.0, "0.3333333333333333"),
+ new Case(100, "100.0"),
+ new Case(1e15, "1000000000000000.0"),
+ // At ten to the sixteenth the digits go behind an exponent, which is
+ // where Rust switches and not where the JVM's own printer does.
+ new Case(1e16, "1e16"),
+ new Case(1e17, "1e17"),
+ new Case(1.5e17, "1.5e17"),
+ new Case(0.001, "0.001"),
+ new Case(0.0001, "0.0001"),
+ // And below a ten thousandth, likewise.
+ new Case(0.00001, "1e-5"),
+ new Case(1.5e-5, "1.5e-5"),
+ new Case(Double.MAX_VALUE, "1.7976931348623157e308"),
+ new Case(Double.MIN_VALUE, "5e-324"),
+ new Case(Double.NaN, "NaN"),
+ new Case(Double.POSITIVE_INFINITY, "inf"),
+ new Case(Double.NEGATIVE_INFINITY, "-inf"))) {
+ assertEquals(c.want(), Values.showFloat(c.value()));
+ }
+ }
+
+ /**
+ * Everything a table holds is spelled the same on both sides and comes
+ * through untouched. A graph value is not: the engine's node and edge
+ * carry the id of their table where a case writes its name.
+ */
+ @Test
+ @DisplayName("a cell turns the engine's graph values into the corpus spelling")
+ void aCellTurnsTheEnginesGraphValuesIntoTheCorpusSpelling() {
+ IntFunction{@code
+ * java -cp ... dev.zudb.corpus.Main ../zu/conformance/cases
+ * }
+ *
+ * > got;
+ try {
+ got = readAll(result, tables);
+ } catch (ZuException e) {
+ return ran(suite, one, Outcome.FAILED, errorText(e));
+ }
+ String detail = compare(one.columns(), one.rows(), columns, got);
+ if (!detail.isEmpty()) {
+ return ran(suite, one, Outcome.FAILED, detail);
+ }
+ // The export is checked on the result the rows were read from rather
+ // than on a second run of the statement, because it is the same
+ // result a client exports: one statement, two ways of reading what it
+ // gave back.
+ detail = exported(one.arrow(), result, got.size());
+ if (!detail.isEmpty()) {
+ return ran(suite, one, Outcome.FAILED, detail);
+ }
+ return ran(suite, one, Outcome.PASSED, "");
+ } finally {
+ // An export spends the result and closes it, and closing it again is
+ // the no-op it always was.
+ result.close();
+ if (prepared != null) {
+ prepared.close();
+ }
+ }
+ }
+
+ private static Ran ran(Suite suite, Suite.Case one, Outcome outcome, String detail) {
+ return new Ran(suite.name(), one.name(), one.line(), outcome, detail);
+ }
+
+ /**
+ * A table's name, or its id when there is no name to be had.
+ *
+ *
> readAll(Result result, IntFunction
> out = new ArrayList<>((int) count);
+ for (long i = 0; i < count; i++) {
+ Row row = result.row(i);
+ List
> wantRows,
+ List
> gotRows) {
+ if (!wantColumns.equals(gotColumns)) {
+ return "columns " + names(gotColumns) + " where the case wants " + names(wantColumns);
+ }
+ for (int i = 0; i < wantRows.size() && i < gotRows.size(); i++) {
+ List
> rows,
+ String raises, Arrow.Export arrow) {}
+
+ /**
+ * One column of a load: a name, the type every value in it has, and the
+ * values in row order.
+ *
+ * @param name the property name the column is loaded under
+ * @param type the type every value in the column has, named once here
+ * rather than beside each value
+ * @param values the column's values in row order, one per row the load
+ * declares
+ */
+ public record Column(String name, String type, List
> rows = readRows(node);
+ for (List
> readRows(Node node) {
+ Node rowsNode = node.get("rows");
+ if (rowsNode == null) {
+ // A statement that returns no rows is a case worth having, and writing
+ // it as an absent `rows:` would make it the same shape as one somebody
+ // forgot to finish.
+ throw Text.refuse("line %d: `columns:` with no `rows:`. A case expecting nothing back "
+ + "writes `rows:` with an empty sequence under it.", node.line());
+ }
+ List
> out = new ArrayList<>(items.size());
+ for (Node item : items) {
+ List
> wantRows,
+ List
> gotRows, String detail) {}
+ List
> one = List.of(List.of(new Cell.Int(1)));
+ for (Case c : List.of(
+ new Case("nothing wrong", n, one, n, one, ""),
+ new Case("the columns before the rows, since a wrong column makes every row wrong",
+ n, one, List.of("m"), List.of(List.of(new Cell.Int(2))),
+ "columns [\"m\"] where the case wants [\"n\"]"),
+ new Case("the first row that differs and not the second",
+ n, List.of(List.of(new Cell.Int(1)), List.of(new Cell.Int(2))),
+ n, List.of(List.of(new Cell.Int(9)), List.of(new Cell.Int(8))),
+ "row 1 column n is INT64 \"9\" where the case wants INT64 \"1\""),
+ new Case("a value before a count, since a row that differs says more than a total",
+ n, List.of(List.of(new Cell.Int(1)), List.of(new Cell.Int(2))),
+ n, List.of(List.of(new Cell.Int(9))),
+ "row 1 column n is INT64 \"9\" where the case wants INT64 \"1\""),
+ new Case("the count when every row that is there matches",
+ n, List.of(List.of(new Cell.Int(1)), List.of(new Cell.Int(2))), n, one,
+ "1 rows where the case wants 2"),
+ new Case("no columns against no columns, which FINISH answers",
+ List.of(), List.of(), List.of(), List.of(), ""),
+ new Case("no columns against some, which is still a difference",
+ List.of(), List.of(), n, List.of(),
+ "columns [\"n\"] where the case wants []"))) {
+ assertEquals(c.detail(),
+ Runner.compare(c.wantColumns(), c.wantRows(), c.gotColumns(), c.gotRows()), c.what());
+ }
+ }
+
+ /**
+ * The report prints the message rather than the exception, because a
+ * class name in front of every failing line would differ from the report
+ * this one is diffed against.
+ */
+ @Test
+ void theReportPrintsWhatTheEngineSaidAndNotWhatAJavaProgramWouldLog() {
+ ZuException engine = assertThrows(ZuException.class, () -> {
+ try (Connection conn = Connection.memory()) {
+ conn.execute("");
+ }
+ });
+ assertEquals("42001", Runner.statusCode(engine));
+ assertTrue(Runner.errorText(engine).startsWith("42001:"),
+ "an engine failure reads " + Text.quote(Runner.errorText(engine)));
+ assertFalse(Runner.errorText(engine).startsWith("zu: "),
+ "the report opens a line with what another client would log");
+ assertFalse(Runner.errorText(engine).contains("ZuException"),
+ "the report opens a line with a class name");
+ assertTrue(Runner.unsupported(engine),
+ "42001 is a case ahead of the engine and not a failure");
+
+ // The two classes that say a case is ahead of the engine, and nothing
+ // else.
+ record Code(String code, boolean ahead) {}
+ for (Code c : List.of(
+ new Code("42001", true),
+ new Code("42002", true),
+ new Code("0A000", true),
+ new Code("22003", false),
+ new Code("22G03", false),
+ new Code("00000", false),
+ new Code("", false))) {
+ assertEquals(c.ahead(), Runner.unsupported(c.code()), Text.quote(c.code()));
+ }
+
+ // The reader's own failures and the export's, which go into the same
+ // report and have no GQLSTATUS at all.
+ assertEquals("line 4: no `doc:`",
+ Runner.errorText(new CorpusException("line 4: no `doc:`")));
+ assertEquals("no export", Runner.errorText(new ArrowException("no export")));
+ assertEquals("something else", Runner.errorText(new IllegalStateException("something else")));
+ assertEquals("", Runner.statusCode(new IllegalStateException("something else")));
+ }
+
+ @Test
+ void namesWritesAColumnListTheWayTheReferenceRunnerDoes() {
+ assertEquals("[]", Runner.names(List.of()));
+ assertEquals("[\"n\"]", Runner.names(List.of("n")));
+ assertEquals("[\"n\", \"twice\"]", Runner.names(List.of("n", "twice")));
+ }
+}
diff --git a/zudb-corpus/src/test/java/dev/zudb/corpus/SuiteTest.java b/zudb-corpus/src/test/java/dev/zudb/corpus/SuiteTest.java
new file mode 100644
index 0000000..94c385a
--- /dev/null
+++ b/zudb-corpus/src/test/java/dev/zudb/corpus/SuiteTest.java
@@ -0,0 +1,501 @@
+package dev.zudb.corpus;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * The case reader, which is the layer between the YAML subset and the
+ * runner.
+ *
+ *