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 @@ zudb zudb-tck zudb-ffm + zudb-corpus zudb-jni zudb-arrow zudb-bench diff --git a/zudb-corpus/pom.xml b/zudb-corpus/pom.xml new file mode 100644 index 0000000..f916f45 --- /dev/null +++ b/zudb-corpus/pom.xml @@ -0,0 +1,84 @@ + + + + 4.0.0 + + + dev.zudb + zudb-parent + 0.11.0-SNAPSHOT + + + zudb-corpus + zu for the JVM: the shared corpus + The conformance corpus the engine and every other client run, read and run here. + + + + dev.zudb + zudb + + + + dev.zudb + zudb-ffm + ${project.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${zu.release.ffm} + + -Xlint:all,-requires-automatic,-requires-transitive-automatic + -Werror + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + false + --enable-native-access=ALL-UNNAMED ${zu.test.args} + + + + + diff --git a/zudb-corpus/src/main/java/dev/zudb/corpus/Arrow.java b/zudb-corpus/src/main/java/dev/zudb/corpus/Arrow.java new file mode 100644 index 0000000..c87108c --- /dev/null +++ b/zudb-corpus/src/main/java/dev/zudb/corpus/Arrow.java @@ -0,0 +1,449 @@ +package dev.zudb.corpus; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_INT; +import static java.lang.foreign.ValueLayout.JAVA_LONG; + +import dev.zudb.Result; +import dev.zudb.ZuException; +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemoryLayout; +import java.lang.foreign.MemoryLayout.PathElement; +import java.lang.foreign.MemorySegment; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.VarHandle; +import java.util.ArrayList; +import java.util.List; + +/** + * What a result looks like on the way out through Arrow. + * + *

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 children) { + + /** + * One field with nothing under it. + * + * @param name the field's name + * @param format the format string + */ + public Field(String name, String format) { + this(name, format, List.of()); + } + } + + /** + * What a case says about the way out through Arrow: the columns it gives, + * or that Arrow has no type for one of them. + * + *

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 fields) {} + + /** + * What an export gave: the columns and how many rows came out. + * + * @param fields the fields under the stream's top level struct, which is + * one per column + * @param rows how many rows the batches held between them + */ + public record Exported(List fields, long rows) {} + + // The C Data Interface, laid out here rather than taken from anywhere: it + // is nine fields that have not changed since Arrow 0.17 and every producer + // in the world writes exactly this, which is what makes it an ABI. + // Copying it is how every consumer of it starts. + private static final MemoryLayout SCHEMA = MemoryLayout.structLayout( + ADDRESS.withName("format"), + ADDRESS.withName("name"), + ADDRESS.withName("metadata"), + JAVA_LONG.withName("flags"), + JAVA_LONG.withName("n_children"), + ADDRESS.withName("children"), + ADDRESS.withName("dictionary"), + ADDRESS.withName("release"), + ADDRESS.withName("private_data")); + + // Only the length is read off an array: the values a case cares about it + // already asserts as rows. + private static final MemoryLayout ARRAY = MemoryLayout.structLayout( + JAVA_LONG.withName("length"), + JAVA_LONG.withName("null_count"), + JAVA_LONG.withName("offset"), + JAVA_LONG.withName("n_buffers"), + JAVA_LONG.withName("n_children"), + ADDRESS.withName("buffers"), + ADDRESS.withName("children"), + ADDRESS.withName("dictionary"), + ADDRESS.withName("release"), + ADDRESS.withName("private_data")); + + private static final MemoryLayout STREAM = MemoryLayout.structLayout( + ADDRESS.withName("get_schema"), + ADDRESS.withName("get_next"), + ADDRESS.withName("get_last_error"), + ADDRESS.withName("release"), + ADDRESS.withName("private_data")); + + private static final VarHandle SCHEMA_FORMAT = at(SCHEMA, "format"); + private static final VarHandle SCHEMA_NAME = at(SCHEMA, "name"); + private static final VarHandle SCHEMA_CHILDREN = at(SCHEMA, "children"); + private static final VarHandle SCHEMA_RELEASE = at(SCHEMA, "release"); + private static final VarHandle SCHEMA_KIDS = at(SCHEMA, "n_children"); + + private static final VarHandle ARRAY_LENGTH = at(ARRAY, "length"); + private static final VarHandle ARRAY_RELEASE = at(ARRAY, "release"); + + private static final VarHandle STREAM_GET_SCHEMA = at(STREAM, "get_schema"); + private static final VarHandle STREAM_GET_NEXT = at(STREAM, "get_next"); + private static final VarHandle STREAM_LAST_ERROR = at(STREAM, "get_last_error"); + private static final VarHandle STREAM_RELEASE = at(STREAM, "release"); + + private static final Linker LINKER = Linker.nativeLinker(); + // int (*)(struct*, struct*), which both get_schema and get_next are. + private static final FunctionDescriptor TAKES = FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS); + // const char* (*)(struct*), which get_last_error is. + private static final FunctionDescriptor SAYS = FunctionDescriptor.of(ADDRESS, ADDRESS); + // void (*)(struct*), which every release is. + private static final FunctionDescriptor FREES = FunctionDescriptor.ofVoid(ADDRESS); + + private Arrow() {} + + /** + * Reads the {@code arrow:} of a case. + * + * @param node what was written under the key + * @return what the case says the export gives + * @throws CorpusException if it is not a shape an export has + */ + public static Export parseExport(Node node) { + String text = node.text(); + if (text != null) { + if (text.equals("refused")) { + return new Export(true, List.of()); + } + throw Text.refuse("line %d: `arrow:` is the columns the export gives, or `refused` for a " + + "result Arrow has no type for, and this is %s", node.line(), Text.quote(text)); + } + return new Export(false, exportFields(node)); + } + + private static List exportFields(Node node) { + List items = node.seq(); + if (items == null) { + throw Text.refuse("line %d: `arrow:` is a sequence of fields, and this is %s", + node.line(), node.what()); + } + List out = new ArrayList<>(items.size()); + for (Node item : items) { + out.add(exportField(item)); + } + return List.copyOf(out); + } + + private static Field exportField(Node node) { + int at = node.line(); + if (node.map() == null) { + throw Text.refuse("line %d: an Arrow field is a mapping of `name` and `format`, and this " + + "is %s", at, node.what()); + } + List unknown = node.unknown("name", "format", "children"); + if (!unknown.isEmpty()) { + throw Text.refuse("line %d: an Arrow field has no key %s", at, Text.quote(unknown.get(0))); + } + String name = spelled(node, "name", at); + String format = spelled(node, "format", at); + if (format.isEmpty()) { + throw Text.refuse("line %d: an empty format string is not a type Arrow has", at); + } + List children = List.of(); + Node under = node.get("children"); + if (under != null) { + children = exportFields(under); + } + // A nested format is the one thing about a format string this reader + // knows, and it is worth knowing here: a case that wrote the fields of a + // struct under a "u" would be asserting something the export cannot + // produce, and finding that out at load time says so with a line number + // rather than as a failure in a report. + boolean nested = format.charAt(0) == '+'; + if (nested && children.isEmpty()) { + throw Text.refuse("line %d: %s is a nested type and the fields under it are part of it", + at, Text.quote(format)); + } + if (!nested && !children.isEmpty()) { + throw Text.refuse("line %d: %s holds no fields, so nothing goes under it", + at, Text.quote(format)); + } + return new Field(name, format, children); + } + + private static String spelled(Node node, String key, int at) { + Node value = node.get(key); + String text = value == null ? null : value.text(); + if (text == null) { + throw Text.refuse("line %d: an Arrow field has a `%s:`", at, key); + } + return text; + } + + /** + * The columns a result gives through Arrow and how many rows came out of + * the stream. + * + *

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 children = new ArrayList<>((int) kids); + if (kids > 0) { + MemorySegment under = ((MemorySegment) SCHEMA_CHILDREN.get(one, 0L)) + .reinterpret(kids * ADDRESS.byteSize()); + for (long i = 0; i < kids; i++) { + children.add(walked(under.getAtIndex(ADDRESS, i).reinterpret(SCHEMA.byteSize()))); + } + } + return new Field(name, format, List.copyOf(children)); + } + + /** + * What the export gave that the case did not want, or the empty string + * when the two agree. + * + *

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 got, List want) { + return fieldsUnder("", got, want); + } + + // The fields under one place, where the place is the dotted path of the + // field they are under and the empty one is the result itself. + private static String fieldsUnder(String prefix, List got, List want) { + String place = prefix.isEmpty() ? THE_RESULT : Text.quote(prefix); + if (got.size() != want.size()) { + return "arrow gives " + got.size() + " fields in " + place + + " where the case wants " + want.size(); + } + for (int i = 0; i < got.size(); i++) { + Field mine = got.get(i); + Field theirs = want.get(i); + if (!mine.name().equals(theirs.name())) { + return "arrow field " + (i + 1) + " in " + place + " is named " + + Text.quote(mine.name()) + " where the case wants " + Text.quote(theirs.name()); + } + // The path is the case's own names joined with dots, which is how a + // field inside a path inside a column is pointed at without printing + // the whole schema at somebody. + String path = prefix.isEmpty() ? theirs.name() : prefix + "." + theirs.name(); + if (!mine.format().equals(theirs.format())) { + return "arrow field " + Text.quote(path) + " is " + Text.quote(mine.format()) + + " where the case wants " + Text.quote(theirs.format()); + } + String why = fieldsUnder(path, mine.children(), theirs.children()); + if (!why.isEmpty()) { + return why; + } + } + return ""; + } + + // ---- the interface itself ---- + + private static VarHandle at(MemoryLayout layout, String field) { + return layout.varHandle(PathElement.groupElement(field)); + } + + private static boolean empty(MemorySegment segment) { + return segment == null || segment.address() == 0; + } + + @SuppressWarnings("restricted") + private static String text(MemorySegment segment) { + return empty(segment) ? "" : segment.reinterpret(Long.MAX_VALUE).getString(0); + } + + // A function pointer read out of the struct, called through the linker, + // which is what the Go runner needs a static shim for and what the Python + // runner writes a ctypes prototype for. + @SuppressWarnings("restricted") + private static int call(VarHandle which, MemorySegment stream, MemorySegment out) { + MemorySegment fn = (MemorySegment) which.get(stream, 0L); + if (empty(fn)) { + throw new ArrowException("the stream has no callback where the interface requires one"); + } + MethodHandle handle = LINKER.downcallHandle(fn, TAKES); + try { + return (int) handle.invokeExact(stream, out); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable e) { + throw new ArrowException(String.valueOf(e.getMessage())); + } + } + + @SuppressWarnings("restricted") + private static void release(VarHandle which, MemorySegment struct) { + MemorySegment fn = (MemorySegment) which.get(struct, 0L); + if (empty(fn)) { + return; + } + MethodHandle handle = LINKER.downcallHandle(fn, FREES); + try { + handle.invokeExact(struct); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable e) { + throw new ArrowException(String.valueOf(e.getMessage())); + } + } + + @SuppressWarnings("restricted") + private static String said(MemorySegment stream, int code) { + MemorySegment fn = (MemorySegment) STREAM_LAST_ERROR.get(stream, 0L); + if (!empty(fn)) { + MethodHandle handle = LINKER.downcallHandle(fn, SAYS); + try { + MemorySegment said = (MemorySegment) handle.invokeExact(stream); + String message = text(said); + if (!message.isEmpty()) { + return message; + } + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable e) { + // Fall through to the errno, which is still an answer. + } + } + return "errno " + code; + } +} diff --git a/zudb-corpus/src/main/java/dev/zudb/corpus/ArrowException.java b/zudb-corpus/src/main/java/dev/zudb/corpus/ArrowException.java new file mode 100644 index 0000000..6d329ed --- /dev/null +++ b/zudb-corpus/src/main/java/dev/zudb/corpus/ArrowException.java @@ -0,0 +1,26 @@ +package dev.zudb.corpus; + +/** + * The export saying no, with what it said. + * + *

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 items) implements Cell {} + + /** + * A node, as a case names it. + * + * @param table the node table's name + * @param offset the row's number within that table, counted from zero in + * the order the load wrote it + */ + record Node(String table, long offset) implements Cell {} + + /** + * An edge, as a case names it. + * + * @param table the rel table's name + * @param source the row the edge runs from + * @param target the row it runs to + */ + record Edge(String table, long source, long target) implements Cell {} + + /** + * A path, which is the walk a case compares rather than the edges the + * engine happened to hand back: two walks that cross the same pairs are + * the same walk whichever copy of a parallel edge was taken. + * + * @param items a {@link Node} at each end and an {@link Edge} between + * every two of them + */ + record Path(java.util.List items) implements Cell {} + + /** + * A record. + * + * @param fields the names and what is under them, compared by name and + * printed in the order a sort puts them in, so that a report of one + * reads the same twice + */ + record Record(Map fields) implements Cell {} + + /** + * A value the corpus has no spelling for, which is a graph or a binding + * table. + * + *

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 named) { + return switch (value) { + case Value.Null _ -> NULL; + case Value.Bool v -> new Bool(v.value()); + case Value.Int v -> new Int(v.value()); + case Value.Float v -> new Float(v.value()); + case Value.Str v -> new Str(v.value()); + case Value.Bytes v -> new Bytes(v.value()); + case Value.Temporal v -> new Time(v); + case Value.Node v -> new Node(named.apply(v.table()), v.offset()); + case Value.Rel v -> new Edge(named.apply(v.table()), v.source(), v.target()); + case Value.List v -> new List(all(v.items(), named)); + case Value.Path v -> new Path(all(v.items(), named)); + case Value.Record v -> record(v, named); + // A graph and a binding table, which are the two the corpus has no + // spelling for and which no case can write. + default -> new Other(value); + }; + } + + private static java.util.List all(java.util.List items, + java.util.function.IntFunction named) { + java.util.List out = new java.util.ArrayList<>(items.size()); + for (Value item : items) { + out.add(of(item, named)); + } + return java.util.List.copyOf(out); + } + + private static Cell record(Value.Record value, + java.util.function.IntFunction named) { + Map out = new java.util.LinkedHashMap<>(); + for (Value.Field field : value.fields()) { + out.put(field.name(), of(field.value(), named)); + } + return new Record(Map.copyOf(out)); + } +} diff --git a/zudb-corpus/src/main/java/dev/zudb/corpus/CorpusException.java b/zudb-corpus/src/main/java/dev/zudb/corpus/CorpusException.java new file mode 100644 index 0000000..cb6a5d2 --- /dev/null +++ b/zudb-corpus/src/main/java/dev/zudb/corpus/CorpusException.java @@ -0,0 +1,29 @@ +package dev.zudb.corpus; + +/** + * A corpus this reader will not read, with the line it gave up on. + * + *

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

{@code
+ * java -cp ... dev.zudb.corpus.Main ../zu/conformance/cases
+ * }
+ * + *

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 rest = new ArrayList<>(); + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + switch (arg) { + case "-strict", "--strict" -> strict = true; + case "-quiet", "--quiet" -> quiet = true; + case "-work", "--work" -> { + if (i + 1 == args.length) { + usage(err); + return 2; + } + work = args[++i]; + } + default -> { + if (arg.startsWith("-work=") || arg.startsWith("--work=")) { + work = arg.substring(arg.indexOf('=') + 1); + } else if (arg.startsWith("-")) { + usage(err); + return 2; + } else { + rest.add(arg); + } + } + } + } + if (rest.size() != 1) { + usage(err); + return 2; + } + + List suites; + try { + suites = Suite.readDir(Path.of(rest.get(0))); + } catch (CorpusException e) { + // One rather than two, because the reference runner exits one for a + // corpus it cannot read and a report that is compared line for line + // is worth less if the two disagree about what the run came to. + err.println("zu corpus: " + e.getMessage()); + return 1; + } + + Path directory; + boolean keep = !work.isEmpty(); + try { + if (keep) { + directory = Files.createDirectories(Path.of(work)); + } else { + // Removed when the run ends, and each case removes its own as it + // finishes, so what is left in here at the end is the databases of + // the cases that failed. A run with -work keeps them. + directory = Files.createTempDirectory("zu-corpus-"); + } + } catch (IOException e) { + err.println("zu corpus: " + e); + return 1; + } + + Runner.Report report; + try { + report = Runner.run(suites, directory); + } finally { + if (!keep) { + removeAll(directory); + } + } + if (!quiet) { + for (Runner.Ran ran : report.ran()) { + if (ran.outcome() != Runner.Outcome.PASSED) { + out.println(ran); + } + } + } + out.println(report.summary()); + if (report.count(Runner.Outcome.FAILED) > 0) { + return 1; + } + if (strict && report.count(Runner.Outcome.UNSUPPORTED) > 0) { + return 1; + } + return 0; + } + + private static void usage(PrintStream err) { + err.println("usage: corpus [flags]

"); + err.println("run the shared corpus cases against this client"); + err.println(" -strict"); + err.println(" an unsupported case fails the run, which is what a release branch wants"); + err.println(" -quiet"); + err.println(" print the summary and nothing else"); + err.println(" -work string"); + err.println(" a directory to make the case databases under, kept rather than removed"); + } + + /** + * Takes the temporary directory back down, deepest first. + * + *

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 walk = Files.walk(directory)) { + walk.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + // Left behind in the temporary directory, which is where the + // operating system will get to it eventually. + } + }); + } catch (IOException | UncheckedIOException e) { + // The same. + } + } +} diff --git a/zudb-corpus/src/main/java/dev/zudb/corpus/Node.java b/zudb-corpus/src/main/java/dev/zudb/corpus/Node.java new file mode 100644 index 0000000..08a7905 --- /dev/null +++ b/zudb-corpus/src/main/java/dev/zudb/corpus/Node.java @@ -0,0 +1,213 @@ +package dev.zudb.corpus; + +import java.util.List; + +/** + * One node of a corpus document, with the line it started on. + * + *

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 items; + private final List pairs; + + private Node(Kind kind, int line, String text, boolean quoted, List items, + List pairs) { + this.kind = kind; + this.line = line; + this.text = text; + this.quoted = quoted; + this.items = items; + this.pairs = pairs; + } + + static Node scalar(int line, String text, boolean quoted) { + return new Node(Kind.SCALAR, line, text, quoted, null, null); + } + + static Node seq(int line, List items) { + return new Node(Kind.SEQ, line, null, false, List.copyOf(items), null); + } + + static Node map(int line, List pairs) { + return new Node(Kind.MAP, line, null, false, null, List.copyOf(pairs)); + } + + static Node empty(int line) { + return new Node(Kind.EMPTY, line, null, false, null, null); + } + + /** + * Which of the four shapes this node is. + * + * @return the kind, never null + */ + public Kind kind() { + return kind; + } + + /** + * The line the node started on, which is what a refusal cites. + * + * @return the line, counting from one + */ + public int line() { + return line; + } + + /** + * What kind of node this is in words, for a refusal that has to say what + * it found instead of what it wanted. + * + * @return one of "a scalar", "a sequence", "a mapping" and "nothing" + */ + public String what() { + switch (kind) { + case SCALAR: + return "a scalar"; + case SEQ: + return "a sequence"; + case MAP: + return "a mapping"; + default: + return "nothing"; + } + } + + /** + * The text of a scalar. + * + * @return the text, or null when this is not a scalar + */ + public String text() { + return kind == Kind.SCALAR ? text : null; + } + + /** + * Whether a scalar was written in quotes, which the value encoding turns + * on: an INT64 written bare is a number some reader in some language will + * round, and refusing it is the whole point of the encoding. + * + * @return whether it was quoted, and false for anything that is not a + * scalar + */ + public boolean quoted() { + return kind == Kind.SCALAR && quoted; + } + + /** + * The items of a sequence. + * + * @return the items, or null when this is not a sequence + */ + public List seq() { + return kind == Kind.SEQ ? items : null; + } + + /** + * The items of a sequence, counting a key with nothing under it as the + * empty one. + * + *

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 seqOrEmpty() { + return kind == Kind.EMPTY ? List.of() : seq(); + } + + /** + * The entries of a mapping, in the order they were written. + * + * @return the entries, or null when this is not a mapping + */ + public List map() { + return kind == Kind.MAP ? pairs : null; + } + + /** + * The value under one key. + * + * @param key the name to look for + * @return the value, or null when this is not a mapping or the key is not + * in it + */ + public Node get(String key) { + if (kind != Kind.MAP) { + return null; + } + for (Pair p : pairs) { + if (p.key().equals(key)) { + return p.value(); + } + } + return null; + } + + /** + * The keys that are not among the ones named, so a caller can refuse a + * typo rather than drop the field on the floor. + * + * @param known the keys this caller reads + * @return the keys it does not, in the order they were written, and empty + * when this is not a mapping + */ + public List unknown(String... known) { + if (kind != Kind.MAP) { + return List.of(); + } + List out = new java.util.ArrayList<>(); + for (Pair p : pairs) { + boolean found = false; + for (String k : known) { + found = found || k.equals(p.key()); + } + if (!found) { + out.add(p.key()); + } + } + return out; + } +} diff --git a/zudb-corpus/src/main/java/dev/zudb/corpus/Runner.java b/zudb-corpus/src/main/java/dev/zudb/corpus/Runner.java new file mode 100644 index 0000000..76af4e7 --- /dev/null +++ b/zudb-corpus/src/main/java/dev/zudb/corpus/Runner.java @@ -0,0 +1,656 @@ +package dev.zudb.corpus; + +import dev.zudb.Connection; +import dev.zudb.Database; +import dev.zudb.Loader; +import dev.zudb.Result; +import dev.zudb.Row; +import dev.zudb.Statement; +import dev.zudb.Value; +import dev.zudb.ZuException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.IntFunction; + +/** + * Running the corpus through this client, and saying what happened in the + * form the other eight runners are compared against. + * + *

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 ran) { + + /** + * How many cases came to one outcome. + * + * @param outcome the one to count + * @return how many + */ + public int count(Outcome outcome) { + int n = 0; + for (Ran one : ran) { + if (one.outcome() == outcome) { + n++; + } + } + return n; + } + + /** + * One line saying what the run came to, which is what a CI log keeps + * and what two runs are compared by. + * + * @return the line + */ + public String summary() { + return ran.size() + " cases, " + count(Outcome.PASSED) + " passed, " + + count(Outcome.FAILED) + " failed, " + count(Outcome.UNSUPPORTED) + " unsupported"; + } + } + + /** + * Runs every case of every suite, in the order they were written. + * + * @param suites what to run + * @param directory one the runner may make databases under. Each case + * gets its own file in it, named after the case, so that a failure + * leaves something to open + * @return what every case did + */ + public static Report run(List suites, Path directory) { + List out = new ArrayList<>(); + for (Suite suite : suites) { + for (Suite.Case one : suite.cases()) { + Ran ran = runCase(suite, one, directory); + // A failure leaves its database behind, which is the one thing + // somebody reading the report will want to open. Everything else + // goes as it finishes, because a corpus of fourteen hundred cases + // is fourteen hundred files and holding them all until the run ends + // is gigabytes of a disk that has other work to do. The Rust and C + // runners do the same. + if (ran.outcome() != Outcome.FAILED) { + Path path = casePath(directory, suite.name(), one.name()); + remove(path); + // The WAL sidecar goes with it. A database is and its log is + // .wal, and a log left beside a name the next run creates + // again is a log that run would adopt. + remove(path.resolveSibling(path.getFileName() + ".wal")); + } + out.add(ran); + } + } + return new Report(List.copyOf(out)); + } + + private static void remove(Path path) { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + // A file that would not go is a full disk or a permission, and + // neither is a thing to say about the case that just ran. + } + } + + static Path casePath(Path directory, String suite, String name) { + return directory.resolve(suite + "-" + name + ".zu"); + } + + private static Ran runCase(Suite suite, Suite.Case one, Path directory) { + Path path = casePath(directory, suite.name(), one.name()); + + // The load goes in before the connection opens, because it is bulk load + // and bulk load is the path that builds the file rather than one that + // goes through a statement. Every case of the suite gets its own copy + // of it for the same reason every case gets its own database. + // + // A loader makes the file, so the two halves of this are the two ways a + // database comes into being in this client and a case has exactly one + // of them. + if (suite.load() != null) { + try { + applyLoad(suite.load(), path); + } catch (ZuException | CorpusException e) { + return ran(suite, one, Outcome.FAILED, "the suite's load: " + errorText(e)); + } + } else { + try { + Database.create(path).close(); + } catch (ZuException e) { + return ran(suite, one, Outcome.FAILED, "creating " + path + ": " + errorText(e)); + } + } + + Database db; + try { + db = Database.open(path); + } catch (ZuException e) { + return ran(suite, one, Outcome.FAILED, "opening " + path + ": " + errorText(e)); + } + try { + Connection main; + try { + main = db.connect(); + } catch (ZuException e) { + return ran(suite, one, Outcome.FAILED, "opening " + path + ": " + errorText(e)); + } + List open = new ArrayList<>(); + open.add(new Named(Suite.MAIN, main)); + try { + return statement(suite, one, open); + } finally { + // In reverse, so that the connection the case was opened with is + // the last one to go, which is the order the ones after it were + // made from it in. + for (int i = open.size() - 1; i >= 0; i--) { + open.get(i).conn().close(); + } + } + } finally { + db.close(); + } + } + + private static Ran statement(Suite suite, Suite.Case one, List open) { + for (int i = 0; i < one.setup().size(); i++) { + Suite.Step step = one.setup().get(i); + Connection on; + try { + on = connection(open, step.on()); + } catch (ZuException e) { + return ran(suite, one, Outcome.FAILED, + "connecting as " + Text.quote(step.on()) + ": " + errorText(e)); + } + try { + on.execute(step.query()); + } catch (ZuException e) { + // A setup that fails is not a result about the statement under + // test, so it is never a pass and never a quiet skip. + if (unsupported(e)) { + return ran(suite, one, Outcome.UNSUPPORTED, "setup " + (i + 1) + ": " + errorText(e)); + } + return ran(suite, one, Outcome.FAILED, + "setup " + (i + 1) + " failed: " + errorText(e)); + } + } + + Connection on; + try { + on = connection(open, one.on()); + } catch (ZuException e) { + return ran(suite, one, Outcome.FAILED, + "connecting as " + Text.quote(one.on()) + ": " + errorText(e)); + } + + Statement prepared = null; + Result result; + try { + if (one.params().isEmpty()) { + result = on.query(one.query()); + } else { + prepared = on.prepare(one.query()); + for (Suite.Param param : one.params()) { + bind(prepared, param); + } + result = prepared.execute(); + } + } catch (ZuException | CorpusException e) { + if (prepared != null) { + prepared.close(); + } + if (!one.raises().isEmpty()) { + String code = statusCode(e); + if (code.isEmpty()) { + return ran(suite, one, Outcome.FAILED, "failed with no GQLSTATUS where the case wants " + + one.raises() + ": " + errorText(e)); + } + if (code.equals(one.raises())) { + return ran(suite, one, Outcome.PASSED, ""); + } + return ran(suite, one, Outcome.FAILED, "raised " + code + " where the case wants " + + one.raises() + ": " + errorText(e)); + } + if (unsupported(e)) { + return ran(suite, one, Outcome.UNSUPPORTED, errorText(e)); + } + return ran(suite, one, Outcome.FAILED, errorText(e)); + } + try { + if (!one.raises().isEmpty()) { + return ran(suite, one, Outcome.FAILED, + "returned rows where the case wants " + one.raises()); + } + // The catalog after the statement rather than before it, because a + // statement may have made the table the rows it returns are rows of. + IntFunction tables = table -> tableName(on, table); + List columns = result.columnNames(); + List> 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. + * + *

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 open, String name) { + for (Named had : open) { + if (had.name().equals(name)) { + return had.conn(); + } + } + Connection made = open.get(0).conn().duplicate(); + open.add(new Named(name, made)); + return made; + } + + /** + * One parameter, handed to the binding call that takes its type. + * + *

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> readAll(Result result, IntFunction tables) { + int columns = result.columns(); + long count = result.rows(); + List> out = new ArrayList<>((int) count); + for (long i = 0; i < count; i++) { + Row row = result.row(i); + List values = new ArrayList<>(columns); + for (int j = 0; j < columns; j++) { + // Into the corpus's own shape here rather than at the comparison, + // because the engine's edge carries a field the corpus does not + // write and its node carries an id where a case writes a name. + values.add(Cell.of(row.get(j), tables)); + } + out.add(List.copyOf(values)); + } + return List.copyOf(out); + } + + /** + * Puts the suite's load in through this client's own bulk load path, + * which is the strongest form of the corpus question: the value crosses + * the boundary twice and by two different mechanisms. + */ + private static void applyLoad(Suite.Load load, Path path) { + try (Loader loader = Loader.create(path)) { + loader.table(load.nodes(), load.edges(), load.count()); + for (Suite.Column column : load.columns()) { + loadColumn(loader, column); + } + if (!load.pairs().isEmpty()) { + int[] from = new int[load.pairs().size()]; + int[] to = new int[load.pairs().size()]; + for (int i = 0; i < load.pairs().size(); i++) { + from[i] = load.pairs().get(i).from(); + to[i] = load.pairs().get(i).to(); + } + loader.edges(from, to); + } + loader.finish(); + } + } + + /** + * Hands one column to the method that takes its type. + * + *

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 values = column.values(); + switch (column.type()) { + case "STRING" -> loader.column(name, strings(values)); + case "BOOL" -> loader.column(name, bools(values)); + case "FLOAT32", "FLOAT64" -> loader.column(name, doubles(values)); + case "DATE" -> loader.temporalColumn(name, Value.Temporal.Kind.DATE, counts(values)); + case "LOCALTIME" -> + loader.temporalColumn(name, Value.Temporal.Kind.LOCAL_TIME, counts(values)); + case "LOCALDATETIME" -> + loader.temporalColumn(name, Value.Temporal.Kind.LOCAL_DATETIME, counts(values)); + case "DURATION" -> + // The two duration kinds are two columns as far as the loader is + // concerned, and a column is one or the other, so which one it is + // comes off the first value. + loader.temporalColumn(name, kind(values.get(0)), counts(values)); + default -> { + if (Values.integer(column.type())) { + loader.column(name, longs(values)); + } else { + throw Text.refuse("a load column of %s, which this client has no loader method for", + column.type()); + } + } + } + } + + // A column of decoded values as the array one loader method takes. A + // value of the wrong shape cannot happen: every value in a column went + // through the same type's parser. + + private static String[] strings(List values) { + String[] out = new String[values.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = ((Cell.Str) values.get(i)).value(); + } + return out; + } + + private static boolean[] bools(List values) { + boolean[] out = new boolean[values.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = ((Cell.Bool) values.get(i)).value(); + } + return out; + } + + private static double[] doubles(List values) { + double[] out = new double[values.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = ((Cell.Float) values.get(i)).value(); + } + return out; + } + + private static long[] longs(List values) { + long[] out = new long[values.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = ((Cell.Int) values.get(i)).value(); + } + return out; + } + + private static long[] counts(List values) { + long[] out = new long[values.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = ((Cell.Time) values.get(i)).value().count(); + } + return out; + } + + private static Value.Temporal.Kind kind(Cell value) { + return ((Cell.Time) value).value().kind(); + } + + /** + * What the export gave that the case did not want, or the empty string + * when the case says nothing about it and when the two agree. + * + *

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 wantColumns, List> wantRows, + List gotColumns, 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 want = wantRows.get(i); + List got = gotRows.get(i); + for (int j = 0; j < want.size() && j < got.size(); j++) { + if (want.get(j).equals(got.get(j))) { + continue; + } + String name = j < wantColumns.size() ? wantColumns.get(j) : "?"; + return "row " + (i + 1) + " column " + name + " is " + Values.show(got.get(j)) + + " where the case wants " + Values.show(want.get(j)); + } + } + if (wantRows.size() != gotRows.size()) { + return gotRows.size() + " rows where the case wants " + wantRows.size(); + } + return ""; + } + + /** A list of column names the way Rust's {@code {:?}} writes one. */ + static String names(List columns) { + StringBuilder out = new StringBuilder("["); + String between = ""; + for (String name : columns) { + out.append(between).append('"').append(name).append('"'); + between = ", "; + } + return out.append(']').toString(); + } +} diff --git a/zudb-corpus/src/main/java/dev/zudb/corpus/Suite.java b/zudb-corpus/src/main/java/dev/zudb/corpus/Suite.java new file mode 100644 index 0000000..491e342 --- /dev/null +++ b/zudb-corpus/src/main/java/dev/zudb/corpus/Suite.java @@ -0,0 +1,673 @@ +package dev.zudb.corpus; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +/** + * One file of cases, and how a file of them is read. + * + *

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 cases) { + + /** + * The schema version a file declares. + * + *

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 setup, String on, + List params, boolean hasColumns, List columns, 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 values) {} + + /** + * One edge of a load, as the two row numbers it runs between. + * + * @param from the row the edge runs from + * @param to the row it runs to + */ + public record Pair(int from, int to) {} + + /** + * One node table, its columns, and the edges between its rows. + * + *

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 columns, + List pairs) {} + + /** + * Every suite in a directory, in the order a sorted listing gives, which + * is the order the reference runner walks them in. + * + * @param directory where the corpus is + * @return the suites + * @throws CorpusException if the directory holds no cases, or one of the + * files in it is not a suite + */ + public static List readDir(Path directory) { + List paths; + try (Stream listing = Files.list(directory)) { + paths = listing + .filter(path -> path.getFileName().toString().endsWith(".yaml")) + // Sorted, because a listing's order is the filesystem's and a + // report that is diffed against another runner's has to walk them + // the same way. + .sorted(Comparator.comparing(path -> path.getFileName().toString())) + .toList(); + } catch (IOException e) { + throw Text.refuse("%s: %s", directory, e); + } + List suites = new ArrayList<>(paths.size()); + for (Path path : paths) { + String text; + try { + text = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } catch (IOException e) { + throw Text.refuse("%s: %s", path, e); + } + Suite suite; + try { + suite = read(text); + } catch (CorpusException e) { + throw Text.refuse("%s: %s", path, e.getMessage()); + } + String stem = path.getFileName().toString(); + stem = stem.substring(0, stem.length() - ".yaml".length()); + if (!suite.name().equals(stem)) { + throw Text.refuse("%s: the suite calls itself %s and the file calls it %s", + path, Text.quote(suite.name()), Text.quote(stem)); + } + suites.add(suite); + } + if (suites.isEmpty()) { + throw Text.refuse("%s: no case files", directory); + } + return List.copyOf(suites); + } + + /** + * A suite, or the first thing in the file that is not one. + * + * @param text the file + * @return the suite it holds + * @throws CorpusException if it is not one + */ + public static Suite read(String text) { + Node doc = Yaml.parse(text); + List unknown = doc.unknown(SUITE_KEYS); + if (!unknown.isEmpty()) { + throw Text.refuse("line %d: a suite has no key %s", doc.line(), + Text.quote(unknown.get(0))); + } + Node schemaNode = doc.get("schema"); + String schema = schemaNode == null ? null : schemaNode.text(); + if (schema == null) { + throw Text.refuse("the file does not open with `schema:`"); + } + int version; + try { + version = Integer.parseInt(schema); + } catch (NumberFormatException e) { + throw Text.refuse("%s is not a schema version", Text.quote(schema)); + } + if (version != SCHEMA) { + throw Text.refuse("this is schema %d and the runner reads schema %d", version, SCHEMA); + } + + String name = field(doc, "suite"); + String docText = field(doc, "doc"); + Load load = null; + Node loadNode = doc.get("load"); + if (loadNode != null) { + load = readLoad(loadNode); + } + + Node casesNode = doc.get("cases"); + if (casesNode == null) { + throw Text.refuse("a suite with no `cases:`"); + } + List items = casesNode.seq(); + if (items == null) { + throw Text.refuse("`cases:` is a sequence"); + } + if (items.isEmpty()) { + throw Text.refuse("a suite with no cases in it"); + } + List cases = new ArrayList<>(items.size()); + // Names are 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. + Set seen = new HashSet<>(); + for (Node item : items) { + Case one = readCase(item); + if (!seen.add(one.name())) { + throw Text.refuse("two cases are called %s", Text.quote(one.name())); + } + cases.add(one); + } + return new Suite(name, docText, load, List.copyOf(cases)); + } + + private static String field(Node node, String key) { + Node value = node.get(key); + if (value == null) { + throw Text.refuse("line %d: no `%s:`", node.line(), key); + } + String text = value.text(); + if (text == null) { + throw Text.refuse("line %d: `%s:` is one line of text", node.line(), key); + } + return text; + } + + private static Case readCase(Node node) { + int at = node.line(); + if (node.map() == null) { + throw Text.refuse("line %d: a case is a mapping, and this is %s", at, node.what()); + } + List unknown = node.unknown(CASE_KEYS); + if (!unknown.isEmpty()) { + throw Text.refuse("line %d: a case has no key %s", at, Text.quote(unknown.get(0))); + } + + String name = field(node, "name"); + if (!dashedWords(name)) { + throw Text.refuse("line %d: %s is a case name, which is lower case words joined by dashes", + at, Text.quote(name)); + } + String doc = field(node, "doc"); + String query = field(node, "query"); + + List setup = new ArrayList<>(); + Node setupNode = node.get("setup"); + if (setupNode != null) { + List items = setupNode.seq(); + if (items == null) { + throw Text.refuse("line %d: `setup:` is a sequence of statements", at); + } + for (Node item : items) { + setup.add(readStep(item)); + } + } + + String on = MAIN; + Node onNode = node.get("on"); + if (onNode != null) { + on = connectionName(onNode); + } + + List params = readParams(node); + + Arrow.Export export = null; + Node arrowNode = node.get("arrow"); + if (arrowNode != null) { + export = Arrow.parseExport(arrowNode); + } + + Node raisesNode = node.get("raises"); + Node columnsNode = node.get("columns"); + if (raisesNode != null && columnsNode != null) { + throw Text.refuse("line %d: a case that raises has no rows, and one that returns rows does " + + "not raise", at); + } + if (raisesNode != null) { + String code = raisesNode.text(); + if (code == null) { + throw Text.refuse("line %d: `raises:` is a GQLSTATUS code", at); + } + if (!gqlstatusShaped(code)) { + throw Text.refuse("line %d: %s is not the shape of a GQLSTATUS, which is five characters " + + "of digits and capitals", raisesNode.line(), Text.quote(code)); + } + return new Case(name, doc, query, at, List.copyOf(setup), on, params, false, List.of(), + List.of(), code, export); + } + if (columnsNode == null) { + throw Text.refuse("line %d: a case says what it produces, with `columns:` and `rows:` or " + + "with `raises:`", at); + } + // Empty counts, because FINISH is a query that answers no columns at + // all, which is not the same as a query whose columns held no rows, and + // the corpus writes it as a `columns:` with nothing under it. + List names = columnsNode.seqOrEmpty(); + if (names == null) { + throw Text.refuse("line %d: `columns:` is a sequence of names", at); + } + List columns = new ArrayList<>(names.size()); + for (Node item : names) { + String text = item.text(); + if (text == null) { + throw Text.refuse("line %d: a column name is one word", item.line()); + } + columns.add(text); + } + List> rows = readRows(node); + for (List row : rows) { + if (row.size() != columns.size()) { + throw Text.refuse("line %d: a row of %d against %d columns", + at, row.size(), columns.size()); + } + } + return new Case(name, doc, query, at, List.copyOf(setup), on, params, true, + List.copyOf(columns), rows, "", export); + } + + // One setup statement, which is a line of its own or a line and the + // connection it runs on. + private static Step readStep(Node node) { + String text = node.text(); + if (text != null) { + return new Step(MAIN, text); + } + if (node.map() == null) { + throw Text.refuse("line %d: a setup statement is one line, or `on:` and `query:`, and this " + + "is %s", node.line(), node.what()); + } + List unknown = node.unknown("on", "query"); + if (!unknown.isEmpty()) { + throw Text.refuse("line %d: a setup statement has no key %s", node.line(), + Text.quote(unknown.get(0))); + } + Node onNode = node.get("on"); + if (onNode == null) { + throw Text.refuse("line %d: a setup statement written as a mapping names the connection it " + + "runs on", node.line()); + } + return new Step(connectionName(onNode), field(node, "query")); + } + + // The name of a connection, spelled the way a case name is, because a + // report cites it and a name a reader has to guess at is a report that + // says less than it looks like it does. + private static String connectionName(Node node) { + String name = node.text(); + if (name == null) { + throw Text.refuse("line %d: `on:` is the name of a connection", node.line()); + } + if (!dashedWords(name)) { + throw Text.refuse("line %d: %s is a connection name, which is lower case words joined by " + + "dashes", node.line(), Text.quote(name)); + } + return name; + } + + // The parameters a case binds, which is the value encoding with a name + // beside it. + // + // A name is what the statement spells after the $, so it is checked + // against what a statement may spell: a case whose name is "n one" is one + // no client can bind. + private static List readParams(Node node) { + Node paramsNode = node.get("params"); + if (paramsNode == null) { + return List.of(); + } + List items = paramsNode.seq(); + if (items == null) { + throw Text.refuse("line %d: `params:` is a sequence", paramsNode.line()); + } + List out = new ArrayList<>(items.size()); + for (Node item : items) { + int at = item.line(); + if (item.map() == null) { + throw Text.refuse("line %d: a parameter is a mapping of `name`, `type` and `value`, and " + + "this is %s", at, item.what()); + } + List unknown = item.unknown("name", "type", "value"); + if (!unknown.isEmpty()) { + throw Text.refuse("line %d: a parameter has no key %s", at, Text.quote(unknown.get(0))); + } + String name = field(item, "name"); + if (!wordOrUnderscore(name)) { + throw Text.refuse("line %d: %s is a parameter name, which is what a statement writes " + + "after the `$`", at, Text.quote(name)); + } + for (Param held : out) { + if (held.name().equals(name)) { + throw Text.refuse("line %d: two parameters are called %s", at, Text.quote(name)); + } + } + out.add(new Param(name, Values.typed(item))); + } + return List.copyOf(out); + } + + private static 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 items = rowsNode.seqOrEmpty(); + if (items == null) { + throw Text.refuse("line %d: `rows:` is a sequence of rows", rowsNode.line()); + } + List> out = new ArrayList<>(items.size()); + for (Node item : items) { + List unknown = item.unknown("values"); + if (!unknown.isEmpty()) { + throw Text.refuse("line %d: a row has no key %s", item.line(), + Text.quote(unknown.get(0))); + } + Node cellsNode = item.get("values"); + if (cellsNode == null) { + throw Text.refuse("line %d: a row is a `values:` and the values under it", item.line()); + } + List cells = cellsNode.seqOrEmpty(); + if (cells == null) { + throw Text.refuse("line %d: `values:` is a sequence of values", cellsNode.line()); + } + List row = new ArrayList<>(cells.size()); + for (Node cell : cells) { + row.add(Values.decode(cell)); + } + out.add(List.copyOf(row)); + } + return List.copyOf(out); + } + + private static Load readLoad(Node node) { + int at = node.line(); + if (node.map() == null) { + throw Text.refuse("line %d: a load is a mapping, and this is %s", at, node.what()); + } + List unknown = node.unknown(LOAD_KEYS); + if (!unknown.isEmpty()) { + throw Text.refuse("line %d: a load has no key %s", at, Text.quote(unknown.get(0))); + } + String nodes = tableName(node, "nodes"); + String edges = tableName(node, "edges"); + Node countNode = node.get("count"); + String countText = countNode == null ? null : countNode.text(); + if (countText == null) { + throw Text.refuse("line %d: a load says how many rows it has, with `count:`", at); + } + int count; + try { + count = Integer.parseInt(countText); + } catch (NumberFormatException e) { + throw Text.refuse("line %d: `count:` is a number of rows", at); + } + if (count == 0) { + throw Text.refuse("line %d: a load of no rows is a load nothing can be read back from", at); + } + + Node columnsNode = node.get("columns"); + if (columnsNode == null) { + throw Text.refuse("line %d: a load has `columns:`", at); + } + List items = columnsNode.seq(); + if (items == null) { + throw Text.refuse("line %d: `columns:` is a sequence", at); + } + List columns = new ArrayList<>(items.size()); + Set seen = new HashSet<>(); + for (Node item : items) { + Column column = readColumn(item, count); + if (!seen.add(column.name())) { + throw Text.refuse("line %d: two columns are called %s", at, Text.quote(column.name())); + } + columns.add(column); + } + if (columns.isEmpty()) { + throw Text.refuse("line %d: a load with no columns holds no values", at); + } + + List pairs = new ArrayList<>(); + Node pairsNode = node.get("pairs"); + if (pairsNode != null) { + List written = pairsNode.seqOrEmpty(); + if (written == null) { + throw Text.refuse("line %d: `pairs:` is a sequence of edges", at); + } + for (Node item : written) { + pairs.add(readEdge(item, count)); + } + } + return new Load(nodes, edges, count, List.copyOf(columns), List.copyOf(pairs)); + } + + private static String tableName(Node node, String key) { + String text = field(node, key); + if (!wordOrUnderscore(text)) { + throw Text.refuse("line %d: %s is not a table name", node.line(), Text.quote(text)); + } + return text; + } + + private static Column readColumn(Node node, int count) { + int at = node.line(); + List unknown = node.unknown("name", "type", "values"); + if (!unknown.isEmpty()) { + throw Text.refuse("line %d: a column has no key %s", at, Text.quote(unknown.get(0))); + } + String name = tableName(node, "name"); + String ty = field(node, "type"); + if (Values.form(ty) == null) { + throw Text.refuse("line %d: %s is not a type this encoding knows", at, ty); + } + Node valuesNode = node.get("values"); + List items = valuesNode == null ? null : valuesNode.seq(); + if (items == null) { + throw Text.refuse("line %d: a column holds `values:` in row order", at); + } + if (items.size() != count) { + throw Text.refuse("line %d: column %s holds %d values against the %d rows the load " + + "declares", at, Text.quote(name), items.size(), count); + } + List values = new ArrayList<>(items.size()); + for (Node item : items) { + values.add(Values.payload(ty, item)); + } + return new Column(name, ty, List.copyOf(values)); + } + + private static Pair readEdge(Node node, int count) { + int at = node.line(); + List unknown = node.unknown("from", "to"); + if (!unknown.isEmpty()) { + throw Text.refuse("line %d: an edge has no key %s", at, Text.quote(unknown.get(0))); + } + int[] ends = new int[2]; + String[] keys = {"from", "to"}; + for (int i = 0; i < keys.length; i++) { + Node value = node.get(keys[i]); + String text = value == null ? null : value.text(); + if (text == null) { + throw Text.refuse("line %d: an edge has a `%s:` row number", at, keys[i]); + } + int end; + try { + end = Integer.parseInt(text); + } catch (NumberFormatException e) { + throw Text.refuse("line %d: `%s:` is a row number", at, keys[i]); + } + if (end < 0 || end >= count) { + throw Text.refuse("line %d: `%s: %d` against a table of %d rows, which are numbered 0 " + + "to %d", at, keys[i], end, count, count - 1); + } + ends[i] = end; + } + return new Pair(ends[0], ends[1]); + } + + // Whether text is lower case ASCII words joined by dashes, which is how a + // case and a connection are named. + static boolean dashedWords(String text) { + if (text.isEmpty()) { + return false; + } + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') { + continue; + } + return false; + } + return true; + } + + // Whether text is ASCII letters, digits and underscores, which is what a + // statement may write after a $ and what a table may be called. + static boolean wordOrUnderscore(String text) { + if (text.isEmpty()) { + return false; + } + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') { + continue; + } + return false; + } + return true; + } + + // Whether a code is the shape of a GQLSTATUS, which is five characters of + // digits and capitals. The shape and not the list: a corpus that had to be + // told about every code the standard defines would be one nobody could add + // a case to. + private static boolean gqlstatusShaped(String code) { + if (code.length() != 5) { + return false; + } + for (int i = 0; i < code.length(); i++) { + char c = code.charAt(i); + if ((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z')) { + continue; + } + return false; + } + return true; + } +} diff --git a/zudb-corpus/src/main/java/dev/zudb/corpus/Temporals.java b/zudb-corpus/src/main/java/dev/zudb/corpus/Temporals.java new file mode 100644 index 0000000..c553992 --- /dev/null +++ b/zudb-corpus/src/main/java/dev/zudb/corpus/Temporals.java @@ -0,0 +1,544 @@ +package dev.zudb.corpus; + +import dev.zudb.Value; +import java.time.DateTimeException; +import java.time.LocalDate; + +/** + * The temporal half of the encoding, written out rather than handed to + * {@code java.time.format}. + * + *

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 pieces(String text) { + java.util.List out = new java.util.ArrayList<>(); + int start = 0; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if ((c >= '0' && c <= '9') || c == '.') { + continue; + } + String run = text.substring(start, i); + int dot = run.indexOf('.'); + String head = dot < 0 ? run : run.substring(0, dot); + String frac = dot < 0 ? null : run.substring(dot + 1); + boolean ok = true; + Long whole = number(head); + if (whole == null) { + ok = false; + whole = 0L; + } + long scaled = 0; + if (frac != null) { + if (frac.isEmpty() || frac.length() > 9) { + ok = false; + } else { + Long part = number(frac); + if (part == null) { + ok = false; + } else { + scaled = part; + for (int n = frac.length(); n < 9; n++) { + scaled *= 10; + } + } + } + } + out.add(new Piece(whole, scaled, c, ok)); + start = i + 1; + } + // Digits with no unit after them, which is the one thing left over that + // a caller has to hear about. + if (start != text.length()) { + out.add(new Piece(0, 0, '\0', false)); + } + return out; + } + + /** A date the way the engine prints one. */ + static String showDate(long days) { + LocalDate when = LocalDate.ofEpochDay(days); + return pad(when.getYear(), 4) + "-" + pad(when.getMonthValue(), 2) + "-" + + pad(when.getDayOfMonth(), 2); + } + + /** + * A count of nanoseconds since midnight the way the engine prints one, + * which is seconds always and a fraction of nine digits when there is + * one. + * + *

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 QUOTED_FORM = quotedForm(); + + private static Map quotedForm() { + Map out = new LinkedHashMap<>(); + out.put("NULL", false); + out.put("BOOL", false); + out.put("INT8", false); + out.put("INT16", false); + out.put("INT32", false); + out.put("INT64", true); + out.put("UINT8", false); + out.put("UINT16", false); + out.put("UINT32", false); + out.put("UINT64", true); + out.put("FLOAT32", true); + out.put("FLOAT64", true); + out.put("STRING", false); + // A byte string is written in quotes because its hexits are digits as + // often as not: a bare 0041 is a number with a leading zero in one + // reader and the string it looks like in another, and neither of them + // is the two octets the case meant. + out.put("BYTES", true); + out.put("DATE", true); + out.put("LOCALTIME", true); + out.put("ZONEDTIME", true); + out.put("LOCALDATETIME", true); + out.put("ZONEDDATETIME", true); + out.put("DURATION", true); + out.put("LIST", false); + // A node and an edge are written in quotes because what a case spells + // is a name and two numbers with punctuation between them, which is + // text in every reader and a number in none. + out.put("NODE", true); + out.put("EDGE", true); + // A path is a sequence, like a list, because that is what it is: the + // nodes and edges of a walk, in the order they were walked. + out.put("PATH", false); + return Map.copyOf(out); + } + + /** + * The types the encoding reserves a name for and the engine has no + * runtime value for yet, kept apart from an outright typo so that the + * refusal says which of the two it is. + */ + private static final List RESERVED = List.of("DECIMAL"); + + /** + * The range each integer width holds, so that a case writing a value its + * own type cannot carry is refused rather than stored wider than it says. + * + *

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 BOUNDS = Map.of( + "INT8", new long[] {Byte.MIN_VALUE, Byte.MAX_VALUE}, + "INT16", new long[] {Short.MIN_VALUE, Short.MAX_VALUE}, + "INT32", new long[] {Integer.MIN_VALUE, Integer.MAX_VALUE}, + "INT64", new long[] {Long.MIN_VALUE, Long.MAX_VALUE}, + "UINT8", new long[] {0, 0xFF}, + "UINT16", new long[] {0, 0xFFFF}, + "UINT32", new long[] {0, 0xFFFF_FFFFL}, + "UINT64", new long[] {0, Long.MAX_VALUE}); + + /** + * Whether a type is written quoted, and whether it is a type at all. + * + * @param ty the name a case wrote + * @return true when the payload is quoted, false when it is bare, and + * null when this is not a type this encoding knows + */ + public static Boolean form(String ty) { + return QUOTED_FORM.get(ty); + } + + /** + * Whether a type is one of the eight integer widths, which is what the + * runner asks before it hands a load column to the method that takes + * whole numbers. + * + * @param ty the name a case wrote + * @return true when it is + */ + static boolean integer(String ty) { + return BOUNDS.containsKey(ty); + } + + private static String unknownType(String ty) { + if (RESERVED.contains(ty)) { + return ty + " is a type the encoding reserves and the engine has no value for"; + } + return ty + " is not a type this encoding knows"; + } + + /** + * The value a {@code {type, value}} mapping describes. + * + * @param node the mapping + * @return the value + * @throws CorpusException if it is not one + */ + public static Cell decode(Node node) { + if (node.map() == null) { + throw Text.refuse("line %d: a value is a mapping of `type` and `value`, and this is %s", + node.line(), node.what()); + } + List unknown = node.unknown("type", "value"); + if (!unknown.isEmpty()) { + throw Text.refuse("line %d: a value has no key %s", node.line(), Text.quote(unknown.get(0))); + } + return typed(node); + } + + /** + * The type and value of a mapping that carries more than those two, which + * is a parameter: it is a value with a name, and the name belongs to the + * case rather than to the encoding. + * + * @param node the mapping + * @return the value + * @throws CorpusException if the type or the payload is not one + */ + public static Cell typed(Node node) { + int at = node.line(); + Node tyNode = node.get("type"); + if (tyNode == null) { + throw Text.refuse("line %d: a value with no `type`", at); + } + String ty = tyNode.text(); + if (ty == null) { + throw Text.refuse("line %d: a `type` that is not a name", at); + } + + // Checked here as well as in payload, because a value whose type is not + // a type and which also has no `value` under it should be told about + // the type first: that is the mistake, and the missing payload is a + // consequence of it. + if (form(ty) == null) { + throw Text.refuse("line %d: %s", at, unknownType(ty)); + } + + if (ty.equals("NULL")) { + if (node.get("value") != null) { + throw Text.refuse("line %d: NULL carries no `value`", at); + } + return Cell.NULL; + } + Node value = node.get("value"); + if (value == null) { + throw Text.refuse("line %d: a %s with no `value`", at, ty); + } + return payload(ty, value); + } + + /** + * The value a payload spells under a type that has already been read. + * + *

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 items = value.seqOrEmpty(); + if (items == null) { + throw Text.refuse("line %d: a %s holds a sequence of values, and this is %s", + value.line(), ty, value.what()); + } + List decoded = new ArrayList<>(items.size()); + for (Node item : items) { + decoded.add(decode(item)); + } + if (ty.equals("LIST")) { + return new Cell.List(List.copyOf(decoded)); + } + return walk(decoded, value.line()); + } + + String text = value.text(); + if (text == null) { + throw Text.refuse("line %d: a %s holds one scalar, and this is %s", + value.line(), ty, value.what()); + } + boolean wasQuoted = value.quoted(); + int at = value.line(); + // The one rule the whole encoding exists for, checked before the text + // is looked at, because a value that parses is exactly the case where a + // silent misread would survive review. + if (quoted && !wasQuoted) { + // A node and an edge are quoted for a different reason from the + // numbers, so they are told a different reason. Both reasons are the + // same rule: a payload is quoted where a bare one would read as + // something else in some reader of this file. + if (ty.equals("NODE") || ty.equals("EDGE")) { + throw Text.refuse("line %d: %s is written in quotes, because %s is a name and two " + + "numbers and no reader has a scalar for that", at, ty, text); + } + throw Text.refuse("line %d: %s is written in quotes, because a bare %s is a number and " + + "some reader of this file will round it", at, ty, text); + } + if (!quoted && wasQuoted && !ty.equals("STRING")) { + throw Text.refuse("line %d: %s is written without quotes, so that a reader cannot take it " + + "for a string", at, ty); + } + + Cell out = switch (ty) { + case "NODE" -> nodeAt(text); + case "EDGE" -> edgeAt(text); + default -> scalar(ty, text); + }; + if (out == null) { + throw Text.refuse("line %d: %s is not a %s", at, Text.quote(text), ty); + } + return out; + } + + /** + * The nodes and edges of a walk, or what is wrong with the sequence + * somebody wrote. + * + *

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 items, int at) { + if (items.size() % 2 == 0) { + throw Text.refuse("line %d: a PATH is a node, then an edge and a node for each hop, so it " + + "holds an odd number of values and this holds %d", at, items.size()); + } + for (int i = 0; i < items.size(); i++) { + boolean wantNode = i % 2 == 0; + Cell item = items.get(i); + boolean ok; + String was; + if (item instanceof Cell.Node) { + ok = wantNode; + was = "a NODE"; + } else if (item instanceof Cell.Edge) { + ok = !wantNode; + was = "an EDGE"; + } else { + ok = false; + was = "neither a NODE nor an EDGE"; + } + if (!ok) { + throw Text.refuse("line %d: a PATH alternates, so value %d is %s where it should be %s", + at, i + 1, was, wantNode ? "a NODE" : "an EDGE"); + } + } + return new Cell.Path(List.copyOf(items)); + } + + /** + * A node, written as its table and the offset of its row: {@code + * person#1}. + * + *

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 field : new TreeMap<>(value.fields()).entrySet()) { + out.append(between).append(field.getKey()).append(": ").append(show(field.getValue())); + between = ", "; + } + return out.append('}').toString(); + } + + private static String showAll(List items) { + StringBuilder out = new StringBuilder(); + String between = ""; + for (Cell item : items) { + out.append(between).append(show(item)); + between = ", "; + } + return out.toString(); + } + + /** + * A float the way Rust's {@code {:?}} writes one, which is the shortest + * text that reads back as the same double and always carries a point or + * an exponent. + * + *

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 lines = lex(text); + if (lines.isEmpty()) { + throw refuse("the file has nothing in it"); + } + if (lines.get(0).indent() != 0) { + throw refuse("line %d: the first line is indented", lines.get(0).no()); + } + Cursor at = new Cursor(lines); + Node node = parseNode(at, 0); + if (at.i < lines.size()) { + throw refuse("line %d: this belongs to nothing above it", lines.get(at.i).no()); + } + return node; + } + + /** + * One meaningful line: its indent, whether a {@code "- "} opened it, what + * is left after that, and where it was. + */ + private record Line(int indent, boolean dash, String text, int no) {} + + /** Where the parser is, which the recursive calls share. */ + private static final class Cursor { + private final List lines; + private int i; + + Cursor(List lines) { + this.lines = lines; + } + + /** + * The line the cursor is on plus an offset, or null past the end. + */ + Line at(int offset) { + int j = i + offset; + return j >= lines.size() ? null : lines.get(j); + } + } + + /** + * Lines, with blanks and comments dropped and every {@code "- "} split + * into the item it opens and the content that followed it on the same + * line. + * + *

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 lex(String text) { + List out = new ArrayList<>(); + String[] raws = text.split("\n", -1); + for (int n = 0; n < raws.length; n++) { + String raw = raws[n]; + int no = n + 1; + int tab = raw.indexOf('\t'); + if (tab >= 0) { + throw refuse("line %d: a tab at column %d, and indentation here is spaces", no, tab + 1); + } + String content = trimRight(stripComment(raw), TRAILING); + String rest = trimLeft(content); + int indent = content.length() - rest.length(); + if (rest.isEmpty()) { + continue; + } + if (rest.equals("---") || rest.equals("...")) { + throw refuse("line %d: %s opens or closes a document, and a file here holds one", + no, quote(rest)); + } + if (indent % 2 != 0) { + throw refuse("line %d: indented %d, and indentation here goes two spaces at a time", + no, indent); + } + + if (!rest.equals("-") && !rest.startsWith("- ")) { + out.add(new Line(indent, false, rest, no)); + continue; + } + rest = rest.substring(1); + if (rest.startsWith(" ")) { + throw refuse("line %d: a `- ` takes exactly one space, so that what follows it " + + "lines up with the lines under it", no); + } + rest = trimLeft(rest); + if (rest.startsWith("- ")) { + throw refuse("line %d: a sequence opening straight into another one, which " + + "nothing here needs", no); + } + out.add(new Line(indent, true, null, no)); + if (!rest.isEmpty()) { + out.add(new Line(indent + 2, false, rest, no)); + } + } + return out; + } + + /** + * Everything from an unquoted {@code " #"} on, dropped. + * + *

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 items = new ArrayList<>(); + while (true) { + Line here = at.at(0); + if (here == null || !here.dash() || here.indent() != indent) { + break; + } + int opened = here.no(); + at.i++; + Line next = at.at(0); + if (next != null && next.indent() == indent + 2) { + items.add(parseNode(at, indent + 2)); + } else if (next != null && next.indent() > indent) { + throw refuse("line %d: indented %d, where an item of the sequence on line %d " + + "is indented %d", next.no(), next.indent(), opened, indent + 2); + } else { + throw refuse("line %d: a `-` with nothing after it", opened); + } + } + return Node.seq(start, items); + } + + private static Node parseMap(Cursor at, int indent) { + int start = at.at(0).no(); + List pairs = new ArrayList<>(); + while (true) { + Line here = at.at(0); + if (here == null || here.dash() || here.indent() != indent) { + break; + } + Key split = splitKey(here.text()); + if (split == null) { + break; + } + int opened = here.no(); + at.i++; + + Node value; + Line next = at.at(0); + if (!split.rest().isEmpty()) { + value = parseScalar(split.rest(), opened); + } else if (next != null && next.indent() == indent + 2) { + value = parseNode(at, indent + 2); + } else if (next != null && next.indent() > indent) { + throw refuse("line %d: indented %d, where what is under `%s:` on line %d is indented %d", + next.no(), next.indent(), split.key(), opened, indent + 2); + } else { + value = Node.empty(opened); + } + for (Node.Pair p : pairs) { + if (p.key().equals(split.key())) { + throw refuse("line %d: %s is set twice in one mapping", opened, split.key()); + } + } + pairs.add(new Node.Pair(split.key(), value)); + } + return Node.map(start, pairs); + } + + /** A key and the rest of the line that followed it. */ + private record Key(String key, String rest) {} + + /** + * The key and the rest of the line, when the line opens a mapping entry, + * or null when it does not. + * + *

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 suites = Suite.readDir(Path.of(CASES)); + int total = 0; + for (Suite suite : suites) { + total += suite.cases().size(); + } + assertNotEquals(0, total, suites.size() + " suites and no cases in any of them"); + System.out.println(suites.size() + " suites, " + total + " cases"); + } + + /** + * The run, which is the whole point of the module. + * + *

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 suites = Suite.readDir(Path.of(CASES)); + Runner.Report report = Runner.run(suites, work); + StringBuilder failed = new StringBuilder(); + for (Runner.Ran ran : report.ran()) { + if (ran.outcome() == Runner.Outcome.FAILED) { + failed.append(ran).append('\n'); + } + } + System.out.println(report.summary()); + // The ones ahead of the engine are listed rather than counted, so that + // a release branch has something to read and so that a case quietly + // becoming unsupported is visible in the log. The corpus is run once + // and read twice, because running it is minutes. + for (Runner.Ran ran : report.ran()) { + if (ran.outcome() == Runner.Outcome.UNSUPPORTED) { + System.out.println(" " + ran); + } + } + assertTrue(failed.isEmpty(), failed.toString()); + // A run where nothing passed is a run that did not happen, which is + // what a corpus read from the wrong directory or a library that + // answers nothing looks like from here. + assertNotEquals(0, report.count(Runner.Outcome.PASSED), + "no case passed, and a run where nothing passes is a run that did not happen"); + } +} diff --git a/zudb-corpus/src/test/java/dev/zudb/corpus/ReaderTest.java b/zudb-corpus/src/test/java/dev/zudb/corpus/ReaderTest.java new file mode 100644 index 0000000..a71c322 --- /dev/null +++ b/zudb-corpus/src/test/java/dev/zudb/corpus/ReaderTest.java @@ -0,0 +1,283 @@ +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.util.List; +import org.junit.jupiter.api.Test; + +/** + * The reader, tested against the subset it claims to read and against the + * constructs it claims to refuse. + * + *

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 pairs = doc.map(); + assertNotNull(pairs, "the document should be a mapping"); + assertEquals(List.of("suite", "doc", "schema"), pairs.stream().map(Node.Pair::key).toList()); + assertEquals("what a string does", doc.get("doc").text()); + assertNull(doc.get("load"), "get answered a key that is not in the mapping"); + } + + @Test + void aSequenceOfMappingsIsOneNodePerItem() { + Node doc = Yaml.parse(String.join("\n", + "cases:", + " - name: one", + " query: RETURN 1", + " - name: two", + " query: RETURN 2", + "")); + List items = doc.get("cases").seq(); + assertNotNull(items, "`cases:` should be a sequence"); + assertEquals(2, items.size()); + assertEquals("one", items.get(0).get("name").text()); + assertEquals("two", items.get(1).get("name").text()); + // The line a refusal would cite is the line the item opened on and not + // the line the sequence did, which is the whole reason a node carries + // one. + assertEquals(4, items.get(1).line()); + } + + @Test + void aDashAndItsFirstKeyMayShareALine() { + // A `- ` and the key it opens are one line in the file and two lines + // by the time the parser sees them, and the split is what lets an item + // written on one line and an item written under its dash be the same + // shape. Both spellings are in the corpus. + Node together = Yaml.parse("cases:\n - name: one\n query: RETURN 1\n"); + Node apart = Yaml.parse("cases:\n -\n name: one\n query: RETURN 1\n"); + for (Node doc : List.of(together, apart)) { + List items = doc.get("cases").seq(); + assertNotNull(items); + assertEquals(1, items.size()); + assertEquals("one", items.get(0).get("name").text()); + } + } + + @Test + void aScalarRemembersWhetherItWasQuoted() { + Node doc = Yaml.parse("bare: 42\nsingle: '42'\ndouble: \"42\"\n"); + assertEquals("42", doc.get("bare").text()); + assertEquals("42", doc.get("single").text()); + assertEquals("42", doc.get("double").text()); + assertEquals(false, doc.get("bare").quoted()); + assertTrue(doc.get("single").quoted()); + assertTrue(doc.get("double").quoted()); + } + + @Test + void aSingleQuotedRunEscapesOnlyByDoublingTheQuote() { + Node doc = Yaml.parse("query: 'RETURN ''it''''s'' AS s'\n"); + assertTrue(doc.get("query").quoted()); + assertEquals("RETURN 'it''s' AS s", doc.get("query").text()); + // A backslash inside a single quoted run is a backslash, which is what + // lets a case write a regular expression without doubling every one of + // them. + Node back = Yaml.parse("query: 'a\\nb'\n"); + assertEquals("a\\nb", back.get("query").text()); + } + + @Test + void aDoubleQuotedRunTakesTheEscapesTheCorpusUses() { + Node doc = Yaml.parse("text: \"a\\nb\\tc\\\\d\\\"e\\r\\0f\\bg\"\n"); + assertEquals("a\nb\tc\\d\"e\r\0f\bg", doc.get("text").text()); + } + + @Test + void aCommentGoesAndAHashInsideAValueStays() { + // A comment is dropped, and the three rules that keep the dropping + // from eating content are each worth a line: a # inside a word is part + // of the word, a quote inside a word is part of the word, and a quote + // that opens nothing that closes was not a run. + record Case(String text, String key, String want) {} + List cases = List.of( + new Case("a: 1 # why\n", "a", "1"), + new Case("# whole line\nb: 2\n", "b", "2"), + new Case("c: person#1\n", "c", "person#1"), + new Case("d: 'a # b'\n", "d", "a # b"), + new Case("e: it's a plain scalar # and a comment\n", "e", "it's a plain scalar"), + new Case("f: cast(' 42 ' AS INT64)\n", "f", "cast(' 42 ' AS INT64)"), + new Case("g: RETURN 'a' AS a # a comment after a run that closed\n", "g", + "RETURN 'a' AS a")); + for (Case c : cases) { + assertEquals(c.want(), Yaml.parse(c.text()).get(c.key()).text(), Text.quote(c.text())); + } + } + + @Test + void aKeyWithNothingUnderItIsAnEmptyNode() { + // A key with nothing under it is a node rather than a refusal, because + // a case that expects no rows writes "rows:" and stops. Every accessor + // says no to it, so a "name:" somebody left blank is still caught. + Node doc = Yaml.parse("rows:\nname: after\n"); + Node empty = doc.get("rows"); + assertEquals(Node.Kind.EMPTY, empty.kind()); + assertEquals("nothing", empty.what()); + assertNull(empty.text(), "text answered an empty node"); + assertNull(empty.seq(), "seq answered an empty node"); + assertNull(empty.map(), "map answered an empty node"); + assertEquals(List.of(), empty.seqOrEmpty(), "empty is the answer seqOrEmpty is for"); + // The key after it is still read, so an empty value ends at its own + // line rather than swallowing what follows. + assertEquals("after", doc.get("name").text()); + } + + @Test + void unknownNamesTheKeysThatAreNotExpected() { + Node doc = Yaml.parse("name: one\nquery: RETURN 1\nqeury: RETURN 2\nrows:\n"); + assertEquals(List.of("qeury"), doc.unknown("name", "query", "rows")); + assertEquals(List.of(), doc.unknown("name", "query", "qeury", "rows")); + // A scalar has no keys and is not a mapping, so it has no unknown ones + // either rather than being a refusal at this level. + assertEquals(List.of(), Yaml.parse("just a scalar\n").unknown("name")); + } + + @Test + void whatSaysWhichShapeANodeIs() { + assertEquals("a scalar", Yaml.parse("a plain scalar\n").what()); + assertEquals("a sequence", Yaml.parse("- one\n- two\n").what()); + assertEquals("a mapping", Yaml.parse("key: value\n").what()); + } + + @Test + void theConstructsThisReaderDoesNotRead() { + // Every one of these is real YAML that a general reader would take, + // and every one of them would mean a case says one thing to a reviewer + // and another to the runner. + record Case(String what, String text, String want) {} + List cases = List.of( + new Case("a tab", + "cases:\n\t- name: one\n", + "line 2: a tab at column 1, and indentation here is spaces"), + new Case("a document marker", + "---\nschema: 4\n", + "line 1: \"---\" opens or closes a document, and a file here holds one"), + new Case("a document terminator", + "schema: 4\n...\n", + "line 2: \"...\" opens or closes a document, and a file here holds one"), + new Case("an odd indent", + "cases:\n - name: one\n", + "line 2: indented 3, and indentation here goes two spaces at a time"), + new Case("a dash with two spaces after it", + "cases:\n - name: one\n", + "line 2: a `- ` takes exactly one space, so that what follows it lines up with the " + + "lines under it"), + new Case("a sequence opening into a sequence", + "cases:\n - - one\n", + "line 2: a sequence opening straight into another one, which nothing here needs"), + new Case("a dash with nothing after it", + "cases:\n -\n", + "line 2: a `-` with nothing after it"), + new Case("a flow sequence", + "columns: [a, b]\n", + "line 1: a plain scalar opening with '[', which is a construct this reader does not " + + "read"), + new Case("a flow mapping", + "value: {type: INT64}\n", + "line 1: a plain scalar opening with '{', which is a construct this reader does not " + + "read"), + new Case("an anchor", + "row: &base one\n", + "line 1: a plain scalar opening with '&', which is a construct this reader does not " + + "read"), + new Case("an alias", + "row: *base\n", + "line 1: a plain scalar opening with '*', which is a construct this reader does not " + + "read"), + new Case("a tag", + "count: !!int 4\n", + "line 1: a plain scalar opening with '!', which is a construct this reader does not " + + "read"), + new Case("a literal block scalar", + "doc: |\n one\n", + "line 1: a plain scalar opening with '|', which is a construct this reader does not " + + "read"), + new Case("a folded block scalar", + "doc: >\n one\n", + "line 1: a plain scalar opening with '>', which is a construct this reader does not " + + "read"), + new Case("a directive", + "query: %YAML 1.2\n", + "line 1: a plain scalar opening with '%', which is a construct this reader does not " + + "read"), + new Case("a run that does not close", + "query: \"RETURN 1\n", + "line 1: a \" that opens and does not close on its line"), + new Case("two runs on one line", + "query: \"a\" and \"b\"\n", + "line 1: \" and \\\"b\\\"\" after the scalar ends"), + // The backslash escapes the quote that would have closed the run, + // so this is reported as a run left open rather than as a scalar + // ending in a backslash. Both messages are in the reader and this + // is the one that is reachable, since a backslash before the + // closing quote always takes the quote with it. + new Case("a double quoted run whose last character escapes its quote", + "query: \"a\\\"\n", + "line 1: a \" that opens and does not close on its line"), + new Case("an escape this reader has no rule for", + "query: \"a\\x41b\"\n", + "line 1: \\x is not an escape"), + new Case("a key set twice", + "name: one\nname: two\n", + "line 2: name is set twice in one mapping"), + new Case("an indent under a key that is not two", + "load:\n nodes: person\n", + "line 2: indented 4, where what is under `load:` on line 1 is indented 2"), + new Case("an indent under a dash that is not two", + "cases:\n -\n name: one\n", + "line 3: indented 6, where an item of the sequence on line 2 is indented 4"), + new Case("a first line that is indented", + " schema: 4\n", + "line 1: the first line is indented"), + new Case("a file with nothing in it", + "# only a comment\n\n", + "the file has nothing in it"), + new Case("a line belonging to nothing above it", + "just a scalar\nand another\n", + "line 2: this belongs to nothing above it")); + for (Case c : cases) { + assertEquals(c.want(), refused(c.text()), c.what()); + } + } + + @Test + void quoteWritesAStringTheWayTheOtherRunnersDo() { + // Rust's {:?} and not Java's own escaping, because a refusal written + // in five languages and diffed across them cannot have one of them + // escaping a character the others print. + assertEquals("\"plain\"", Text.quote("plain")); + assertEquals("\"a \\\"quoted\\\" word\"", Text.quote("a \"quoted\" word")); + assertEquals("\"a\\\\backslash\"", Text.quote("a\\backslash")); + assertEquals("\"a\\nb\"", Text.quote("a\nb")); + assertEquals("\"a\\rb\"", Text.quote("a\rb")); + assertEquals("\"a\\tb\"", Text.quote("a\tb")); + // The one a general escaper would touch and Rust would not. + assertEquals("\"héllo → 世界\"", Text.quote("héllo → 世界")); + assertEquals("\"\"", Text.quote("")); + } +} diff --git a/zudb-corpus/src/test/java/dev/zudb/corpus/RunnerTest.java b/zudb-corpus/src/test/java/dev/zudb/corpus/RunnerTest.java new file mode 100644 index 0000000..606c402 --- /dev/null +++ b/zudb-corpus/src/test/java/dev/zudb/corpus/RunnerTest.java @@ -0,0 +1,490 @@ +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.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.zudb.Connection; +import dev.zudb.ZuException; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The runner, on cases written here rather than on the corpus. + * + *

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 ranHere(String text) { + return Runner.run(List.of(Suite.read(text)), fresh()).ran(); + } + + /** A suite of one case, with body under {@code cases:}. */ + private static String caseText(String body) { + return "schema: 4\nsuite: inline\ndoc: cases written for the runner's own tests\ncases:\n" + + body; + } + + /** The one case a suite of one came to. */ + private Runner.Ran only(String body) { + List all = ranHere(caseText(body)); + assertEquals(1, all.size(), all.size() + " cases ran, and the suite writes one"); + return all.get(0); + } + + @Test + void aCaseThatSaysWhatItProducesPasses() { + Runner.Ran got = only(""" + - name: a-statement-returns-what-it-names + doc: d + query: UNWIND [1, 2] AS n RETURN n, n * 2 AS twice + columns: + - n + - twice + rows: + - values: + - type: INT64 + value: "1" + - type: INT64 + value: "2" + - values: + - type: INT64 + value: "2" + - type: INT64 + value: "4" + """); + assertEquals(Runner.Outcome.PASSED, got.outcome(), + "came to " + got.outcome().mark() + ": " + got.detail()); + assertEquals("", got.detail(), "a case that passed carries a detail"); + assertEquals("inline", got.suite()); + assertEquals("a-statement-returns-what-it-names", got.name()); + } + + @Test + void aCaseThatWantsAConditionPassesOnThatCode() { + Runner.Ran got = only(""" + - name: an-empty-statement-is-a-condition + doc: d + query: "" + raises: "42001" + """); + assertEquals(Runner.Outcome.PASSED, got.outcome(), + "came to " + got.outcome().mark() + ": " + got.detail()); + } + + /** + * The failures, which are what the runner is for and what the corpus has + * no examples of. + */ + @Test + void everyWayACaseCanFailIsReportedInFull() { + record Wrong(String what, String body, String want) {} + for (Wrong one : List.of( + new Wrong("the wrong columns", """ + - name: one + doc: d + query: RETURN 1 AS n + columns: + - m + rows: + - values: + - type: INT64 + value: "1" + """, "columns [\"n\"] where the case wants [\"m\"]"), + new Wrong("the wrong value", """ + - name: one + doc: d + query: RETURN 1 AS n + columns: + - n + rows: + - values: + - type: INT64 + value: "2" + """, "row 1 column n is INT64 \"1\" where the case wants INT64 \"2\""), + new Wrong("the wrong type in the right column", """ + - name: one + doc: d + query: RETURN 1 AS n + columns: + - n + rows: + - values: + - type: STRING + value: '1' + """, "row 1 column n is INT64 \"1\" where the case wants STRING \"1\""), + new Wrong("too few rows", """ + - name: one + doc: d + query: UNWIND [1] AS n RETURN n + columns: + - n + rows: + - values: + - type: INT64 + value: "1" + - values: + - type: INT64 + value: "2" + """, "1 rows where the case wants 2"), + new Wrong("too many rows", """ + - name: one + doc: d + query: UNWIND [1, 2] AS n RETURN n + columns: + - n + rows: + - values: + - type: INT64 + value: "1" + """, "2 rows where the case wants 1"), + new Wrong("rows where the case wants a condition", """ + - name: one + doc: d + query: RETURN 1 AS n + raises: "22003" + """, "returned rows where the case wants 22003"), + new Wrong("a condition where the case wants another one", """ + - name: one + doc: d + query: "" + raises: "22003" + """, "raised 42001 where the case wants 22003"), + new Wrong("a setup that will not run", """ + - name: one + doc: d + setup: + - INSERT (:nowhere) + query: RETURN 1 AS n + columns: + - n + rows: + - values: + - type: INT64 + value: "1" + """, "setup 1"))) { + // A setup that will not run is a failure or a case ahead of the + // engine depending on what the engine says about the statement, and + // either way it is never a pass. Everything else here is a failure + // and nothing else. + boolean wantsFailed = !one.want().startsWith("setup"); + Runner.Ran got = only(one.body()); + assertNotEquals(Runner.Outcome.PASSED, got.outcome(), one.what() + " passed"); + if (wantsFailed) { + assertEquals(Runner.Outcome.FAILED, got.outcome(), + one.what() + " came to " + got.outcome().mark() + ": " + got.detail()); + } + assertTrue(got.detail().startsWith(one.want()), one.what() + " was reported as\n " + + Text.quote(got.detail()) + "\nand it should open with\n " + Text.quote(one.want())); + } + } + + /** + * A case the engine has not caught up to is unsupported and not a + * failure, which is what lets the corpus be the contract and the engine + * catch up to it. The two classes that say so are 42 and 0A. + */ + @Test + void aCaseAheadOfTheEngineIsUnsupportedAndNotAFailure() { + Runner.Ran got = only(""" + - name: one + doc: d + query: SELECT 1 + columns: + - n + rows: + """); + assertEquals(Runner.Outcome.UNSUPPORTED, got.outcome(), + "came to " + got.outcome().mark() + ": " + got.detail()); + assertTrue(got.detail().startsWith("42001"), + "the detail is " + Text.quote(got.detail()) + ", and it should open with the code"); + } + + /** + * Two connections over one file share the write side, so each sees what + * the other has committed, which is what a case about a session means. + */ + @Test + void aCaseMayNameTheConnectionEachStatementRunsOn() { + Runner.Ran got = only(""" + - name: a-second-connection-sees-what-the-first-committed + doc: d + setup: + - on: writer + query: INSERT (:person {name: 'a'}) + on: reader + query: MATCH (p:person) RETURN count(*) AS c + columns: + - c + rows: + - values: + - type: INT64 + value: "1" + """); + assertNotEquals(Runner.Outcome.FAILED, got.outcome(), + "came to " + got.outcome().mark() + ": " + got.detail()); + } + + @Test + void aCaseMayBindParameters() { + Runner.Ran got = only(""" + - name: a-parameter-is-bound-by-name + doc: d + query: RETURN $n AS n + params: + - name: n + type: INT64 + value: "7" + columns: + - n + rows: + - values: + - type: INT64 + value: "7" + """); + assertEquals(Runner.Outcome.PASSED, got.outcome(), + "came to " + got.outcome().mark() + ": " + got.detail()); + } + + /** + * The load is the other half of the corpus: an expression says what a + * value means on the way out and nothing about how it got in. + */ + @Test + void aSuiteWithALoadPutsItInThroughTheLoader() { + List all = ranHere(""" + schema: 4 + suite: inline + doc: d + load: + nodes: person + edges: knows + count: 3 + columns: + - name: name + type: STRING + values: + - a + - b + - c + pairs: + - from: 0 + to: 1 + - from: 1 + to: 2 + cases: + - name: the-rows-that-went-in-come-back + doc: d + query: MATCH (p:person) RETURN count(*) AS c + columns: + - c + rows: + - values: + - type: INT64 + value: "3" + - name: the-edges-that-went-in-come-back + doc: d + query: MATCH (:person)-[:knows]->(:person) RETURN count(*) AS c + columns: + - c + rows: + - values: + - type: INT64 + value: "2" + """); + for (Runner.Ran ran : all) { + assertNotEquals(Runner.Outcome.FAILED, ran.outcome(), + ran.name() + " came to " + ran.outcome().mark() + ": " + ran.detail()); + } + // Each case of the suite gets its own copy of the load, so the second + // one does not see what the first one did. + assertEquals(2, all.size(), all.size() + " cases ran"); + } + + /** + * A failure leaves its database behind, which is the one thing somebody + * reading the report will want to open. Everything else goes as it + * finishes, because fourteen hundred cases are fourteen hundred files. + */ + @Test + void onlyAFailedCaseLeavesItsDatabaseBehind() { + Path directory = fresh(); + Runner.run(List.of(Suite.read(caseText(""" + - name: one-that-passes + doc: d + query: RETURN 1 AS n + columns: + - n + rows: + - values: + - type: INT64 + value: "1" + - name: one-that-fails + doc: d + query: RETURN 1 AS n + columns: + - n + rows: + - values: + - type: INT64 + value: "2" + """))), directory); + assertFalse(Files.exists(Runner.casePath(directory, "inline", "one-that-passes")), + "a case that passed left its database behind"); + assertTrue(Files.exists(Runner.casePath(directory, "inline", "one-that-fails")), + "a case that failed left nothing to open"); + } + + @Test + void aReportSaysWhatEachCaseDidAndWhatTheRunCameTo() { + Runner.Report report = new Runner.Report(List.of( + new Runner.Ran("string", "one", 12, Runner.Outcome.PASSED, ""), + new Runner.Ran("string", "two", 20, Runner.Outcome.FAILED, + "1 rows where the case wants 2"), + new Runner.Ran("select", "three", 38, Runner.Outcome.UNSUPPORTED, "42001: no"))); + List want = List.of( + "string/one line 12 ok", + "string/two line 20 FAILED: 1 rows where the case wants 2", + "select/three line 38 unsupported: 42001: no"); + for (int i = 0; i < want.size(); i++) { + assertEquals(want.get(i), report.ran().get(i).toString(), "line " + i); + } + assertEquals("3 cases, 1 passed, 1 failed, 1 unsupported", report.summary()); + assertEquals(1, report.count(Runner.Outcome.PASSED)); + assertEquals(1, report.count(Runner.Outcome.FAILED)); + assertEquals(1, report.count(Runner.Outcome.UNSUPPORTED)); + assertEquals("0 cases, 0 passed, 0 failed, 0 unsupported", + new Runner.Report(List.of()).summary()); + } + + /** + * compare reports the first difference rather than all of them, because + * the first is nearly always the cause of the rest, and the order the + * checks run in is the reference runner's. + */ + @Test + void compareReportsTheFirstDifferenceAndNothingAfterIt() { + record Case(String what, List wantColumns, List> wantRows, + List gotColumns, List> gotRows, String detail) {} + List n = List.of("n"); + 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. + * + *

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 suites = Suite.readDir(dir); + assertEquals(List.of("alpha", "zebra"), suites.stream().map(Suite::name).toList()); + + // A suite whose name and file disagree, which is a file somebody copied + // and half renamed, and which would otherwise run under a name no report + // could be diffed on. + write(dir, "beta.yaml", "gamma"); + String why = assertThrows(CorpusException.class, () -> Suite.readDir(dir), + "a suite named apart from its file was read").getMessage(); + assertTrue(why.contains("the suite calls itself \"gamma\" and the file calls it \"beta\""), + () -> "refused with " + why); + + // And a directory with nothing in it, which is almost always a path that + // was wrong rather than a corpus that is empty. + String empty = assertThrows(CorpusException.class, () -> Suite.readDir(bare), + "an empty directory read as a corpus").getMessage(); + assertTrue(empty.endsWith("no case files"), () -> "refused with " + empty); + } + + private static void write(Path dir, String name, String suite) throws IOException { + String text = "schema: 4\nsuite: " + suite + "\ndoc: d\ncases:\n - name: one\n doc: d\n" + + " query: RETURN 1\n raises: \"42001\"\n"; + Files.write(dir.resolve(name), text.getBytes(StandardCharsets.UTF_8)); + } + + @Test + @DisplayName("a name is checked against what writes it") + void aNameIsCheckedAgainstWhatWritesIt() { + record Case(String text, boolean dashed, boolean word) {} + for (Case c : List.of( + new Case("one", true, true), + new Case("a-name", true, false), + new Case("a-3-hop", true, false), + new Case("a_name", false, true), + new Case("Name", false, true), + new Case("n1", true, true), + new Case("a name", false, false), + new Case("", false, false), + new Case("héllo", false, false))) { + assertEquals(c.dashed(), Suite.dashedWords(c.text()), + () -> "dashedWords of " + c.text()); + assertEquals(c.word(), Suite.wordOrUnderscore(c.text()), + () -> "wordOrUnderscore of " + c.text()); + } + } +} diff --git a/zudb-corpus/src/test/java/dev/zudb/corpus/TemporalsTest.java b/zudb-corpus/src/test/java/dev/zudb/corpus/TemporalsTest.java new file mode 100644 index 0000000..dcd5e21 --- /dev/null +++ b/zudb-corpus/src/test/java/dev/zudb/corpus/TemporalsTest.java @@ -0,0 +1,339 @@ +package dev.zudb.corpus; + +import static dev.zudb.corpus.Temporals.NANOS_PER_DAY; +import static dev.zudb.corpus.Temporals.NANOS_PER_HOUR; +import static dev.zudb.corpus.Temporals.NANOS_PER_MINUTE; +import static dev.zudb.corpus.Temporals.NANOS_PER_SECOND; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.zudb.Value; +import dev.zudb.Value.Temporal.Kind; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The temporal half of the encoding. + * + *

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 bare = List.of("NULL", "BOOL", "INT8", "INT16", "INT32", "UINT8", "UINT16", + "UINT32", "STRING", "LIST", "PATH"); + List quoted = List.of("INT64", "UINT64", "FLOAT32", "FLOAT64", "BYTES", "DATE", + "LOCALTIME", "ZONEDTIME", "LOCALDATETIME", "ZONEDDATETIME", "DURATION", "NODE", "EDGE"); + for (String ty : bare) { + assertEquals(Boolean.FALSE, Values.form(ty), ty + " is written bare"); + } + for (String ty : quoted) { + assertEquals(Boolean.TRUE, Values.form(ty), ty + " is written in quotes"); + } + assertEquals(bare.size() + quoted.size(), Values.QUOTED_FORM.size(), + "the encoding has as many types as this test names"); + // DECIMAL has a name and no value behind it, and is told apart from a + // typo so that the message says which of the two happened. + assertNull(Values.form("DECIMAL"), "the engine has no value for a DECIMAL"); + } + + @Test + @DisplayName("a payload is read as the type beside it") + void aPayloadIsReadAsTheTypeBesideIt() { + record Case(String text, Cell want) {} + for (Case c : List.of( + new Case("type: NULL\n", Cell.NULL), + new Case("type: BOOL\nvalue: true\n", new Cell.Bool(true)), + new Case("type: BOOL\nvalue: false\n", new Cell.Bool(false)), + new Case("type: INT8\nvalue: -128\n", integer(-128)), + new Case("type: INT16\nvalue: 32767\n", integer(32767)), + new Case("type: INT32\nvalue: -2147483648\n", integer(-2147483648L)), + new Case("type: INT64\nvalue: \"9223372036854775807\"\n", integer(Long.MAX_VALUE)), + new Case("type: UINT8\nvalue: 255\n", integer(255)), + new Case("type: UINT16\nvalue: 65535\n", integer(65535)), + new Case("type: UINT32\nvalue: 4294967295\n", integer(4294967295L)), + new Case("type: UINT64\nvalue: \"0\"\n", integer(0)), + new Case("type: FLOAT64\nvalue: \"1.5\"\n", new Cell.Float(1.5)), + new Case("type: FLOAT64\nvalue: \"-0.0\"\n", new Cell.Float(-0.0)), + new Case("type: FLOAT64\nvalue: \"inf\"\n", + new Cell.Float(Double.POSITIVE_INFINITY)), + new Case("type: FLOAT64\nvalue: \"-inf\"\n", + new Cell.Float(Double.NEGATIVE_INFINITY)), + // A FLOAT32 is held as the double the single rounds to, since that + // is what comes back out of a column of them. + new Case("type: FLOAT32\nvalue: \"0.1\"\n", new Cell.Float(0.1f)), + new Case("type: STRING\nvalue: plain\n", new Cell.Str("plain")), + new Case("type: STRING\nvalue: ''\n", new Cell.Str("")), + new Case("type: BYTES\nvalue: \"00AB00\"\n", + new Cell.Bytes(new byte[] {0, (byte) 0xAB, 0})), + new Case("type: BYTES\nvalue: \"\"\n", new Cell.Bytes(new byte[0])), + new Case("type: NODE\nvalue: \"person#1\"\n", new Cell.Node("person", 1)), + new Case("type: EDGE\nvalue: \"knows#0->2\"\n", new Cell.Edge("knows", 0, 2)))) { + assertEquals(c.want(), value(c.text()), + () -> Text.quote(c.text().replace("\n", " ")) + " reads that way"); + } + } + + /** + * A STRING is the one type that reads either way, because a string is + * what a plain scalar already is and a case quotes one only when it has + * to. Everything else is written one way and refused the other. + */ + @Test + @DisplayName("a string reads quoted or bare") + void aStringReadsQuotedOrBare() { + assertEquals(new Cell.Str("42"), value("type: STRING\nvalue: 42\n")); + assertEquals(new Cell.Str("42"), value("type: STRING\nvalue: \"42\"\n")); + } + + @Test + @DisplayName("an integer is refused outside the range its type holds") + void anIntegerIsRefusedOutsideTheRangeItsTypeHolds() { + record Case(String ty, String text) {} + for (Case c : List.of( + new Case("INT8", "128"), + new Case("INT8", "-129"), + new Case("INT16", "32768"), + new Case("INT32", "2147483648"), + new Case("UINT8", "256"), + new Case("UINT8", "-1"), + new Case("UINT16", "65536"), + new Case("UINT32", "4294967296"))) { + assertEquals("line 2: " + Text.quote(c.text()) + " is not a " + c.ty(), + declined("type: " + c.ty() + "\nvalue: " + c.text() + "\n")); + } + // UINT64 stops at the signed maximum, because the engine's integer is + // signed and wrapping the top half into a negative would be a case that + // passes while meaning the opposite of what it says. + assertEquals("line 2: \"9223372036854775808\" is not a UINT64", + declined("type: UINT64\nvalue: \"9223372036854775808\"\n")); + } + + /** + * The rule the whole encoding exists for. A bare INT64 is a number some + * reader in some language rounds, and a bare NODE is a name and two + * numbers no reader has a scalar for, so the two are told apart. + */ + @Test + @DisplayName("a quoted type written bare is refused and says why") + void aQuotedTypeWrittenBareIsRefusedAndSaysWhy() { + record Case(String text, String want) {} + for (Case c : List.of( + new Case("type: INT64\nvalue: 1\n", + "line 2: INT64 is written in quotes, because a bare 1 is a number and some reader of " + + "this file will round it"), + new Case("type: FLOAT64\nvalue: 1.5\n", + "line 2: FLOAT64 is written in quotes, because a bare 1.5 is a number and some reader " + + "of this file will round it"), + new Case("type: NODE\nvalue: person#1\n", + "line 2: NODE is written in quotes, because person#1 is a name and two numbers and no " + + "reader has a scalar for that"), + new Case("type: EDGE\nvalue: knows#0->1\n", + "line 2: EDGE is written in quotes, because knows#0->1 is a name and two numbers and " + + "no reader has a scalar for that"))) { + assertEquals(c.want(), declined(c.text())); + } + } + + @Test + @DisplayName("a bare type written in quotes is refused") + void aBareTypeWrittenInQuotesIsRefused() { + assertEquals( + "line 2: INT8 is written without quotes, so that a reader cannot take it for a string", + declined("type: INT8\nvalue: \"1\"\n")); + } + + @Test + @DisplayName("a value says what is wrong with it in the order that helps") + void aValueSaysWhatIsWrongWithItInTheOrderThatHelps() { + record Case(String what, String text, String want) {} + for (Case c : List.of( + new Case("a type nothing knows", + "type: INTEGER\nvalue: 1\n", + "line 1: INTEGER is not a type this encoding knows"), + new Case("a type the encoding holds a name for", + "type: DECIMAL\nvalue: \"1.0\"\n", + "line 1: DECIMAL is a type the encoding reserves and the engine has no value for"), + // The type is the mistake and the missing payload is a consequence + // of it, so the type is what the message names. + new Case("a type nothing knows and no payload either", + "type: INTEGER\n", + "line 1: INTEGER is not a type this encoding knows"), + new Case("no type at all", + "value: 1\n", + "line 1: a value with no `type`"), + new Case("a type that is not a name", + "type:\n - INT8\nvalue: 1\n", + "line 1: a `type` that is not a name"), + new Case("no payload", + "type: INT8\n", + "line 1: a INT8 with no `value`"), + new Case("a payload under NULL", + "type: NULL\nvalue: 1\n", + "line 1: NULL carries no `value`"), + new Case("a key the encoding has no room for", + "type: INT8\nvalue: 1\nname: n\n", + "line 1: a value has no key \"name\""), + new Case("a sequence where a value belongs", + "- type: INT8\n", + "line 1: a value is a mapping of `type` and `value`, and this is a sequence"), + new Case("a scalar where a value belongs", + "just a scalar\n", + "line 1: a value is a mapping of `type` and `value`, and this is a scalar"), + new Case("a sequence under a scalar type", + "type: INT8\nvalue:\n - 1\n", + "line 3: a INT8 holds one scalar, and this is a sequence"), + new Case("a scalar under LIST", + "type: LIST\nvalue: 1\n", + "line 2: a LIST holds a sequence of values, and this is a scalar"))) { + assertEquals(c.want(), declined(c.text()), c.what()); + } + } + + @Test + @DisplayName("a list holds values and the empty one has a spelling") + void aListHoldsValuesAndTheEmptyOneHasASpelling() { + Cell got = value(""" + type: LIST + value: + - type: INT8 + value: 1 + - type: NULL + - type: STRING + value: two + """); + assertEquals(new Cell.List(List.of(integer(1), Cell.NULL, new Cell.Str("two"))), got); + // A "value:" with nothing under it, which is the empty list and a value + // a case asserts. + assertEquals(new Cell.List(List.of()), value("type: LIST\nvalue:\n")); + } + + /** + * A path alternates and ends at both ends with a node, so a sequence that + * does not is refused where it is written rather than at the comparison, + * which is the difference between a message naming a line and a report + * saying the row differs. + */ + @Test + @DisplayName("a path alternates node and edge or is refused") + void aPathAlternatesNodeAndEdgeOrIsRefused() { + Cell got = value(""" + type: PATH + value: + - type: NODE + value: "person#0" + - type: EDGE + value: "knows#0->1" + - type: NODE + value: "person#1" + """); + assertEquals(new Cell.Path(List.of( + new Cell.Node("person", 0), + new Cell.Edge("knows", 0, 1), + new Cell.Node("person", 1))), got); + + record Case(String what, String text, String want) {} + for (Case c : List.of( + new Case("an even number of values", + "type: PATH\nvalue:\n - type: NODE\n value: \"person#0\"\n" + + " - type: EDGE\n value: \"knows#0->1\"\n", + "line 3: a PATH is a node, then an edge and a node for each hop, so it holds an odd " + + "number of values and this holds 2"), + new Case("an edge where the walk starts", + "type: PATH\nvalue:\n - type: EDGE\n value: \"knows#0->1\"\n", + "line 3: a PATH alternates, so value 1 is an EDGE where it should be a NODE"), + new Case("a node in the hop position", + "type: PATH\nvalue:\n - type: NODE\n value: \"person#0\"\n" + + " - type: NODE\n value: \"person#1\"\n - type: NODE\n" + + " value: \"person#2\"\n", + "line 3: a PATH alternates, so value 2 is a NODE where it should be an EDGE"), + new Case("something that is neither", + "type: PATH\nvalue:\n - type: INT8\n value: 1\n", + "line 3: a PATH alternates, so value 1 is neither a NODE nor an EDGE where it should " + + "be a NODE"))) { + assertEquals(c.want(), declined(c.text()), c.what()); + } + // The empty path is refused too, since zero is an even number and a walk + // with no nodes in it is not a walk. + assertTrue(declined("type: PATH\nvalue:\n").contains("odd number"), + "the empty path is not a walk"); + } + + @Test + @DisplayName("a node and an edge are a table name and row numbers") + void aNodeAndAnEdgeAreATableNameAndRowNumbers() { + // Split from the right, so a table whose name holds a # still reads. + assertEquals(new Cell.Node("od#d", 7), value("type: NODE\nvalue: \"od#d#7\"\n")); + for (String text : List.of( + "person", // no offset + "#1", // no table + "person#", // no digits + "person#-1", // a sign, which an unsigned parser would take + "person#1_0", // a grouping mark, which a parser elsewhere would take too + "person#a", // not a number + "person#1->2")) { // an edge under a node's type + assertEquals("line 2: " + Text.quote(text) + " is not a NODE", + declined("type: NODE\nvalue: " + Text.quote(text) + "\n")); + } + for (String text : List.of( + "knows#0", // one row rather than two + "knows#0->", // no second row + "knows#->1", // no first row + "knows#0-1", // the wrong arrow + "#0->1", // no table + "knows#0->-1")) { // a sign + assertEquals("line 2: " + Text.quote(text) + " is not a EDGE", + declined("type: EDGE\nvalue: " + Text.quote(text) + "\n")); + } + } + + /** + * An integer is written back out and compared, so that a spelling a + * parser would take and no other reader would is refused. + */ + @Test + @DisplayName("an integer is refused when it is spelt unusually") + void anIntegerIsRefusedWhenItIsSpeltUnusually() { + for (String text : List.of("+1", "01", "1_0", "0x10")) { + assertEquals("line 2: " + Text.quote(text) + " is not a INT8", + declined("type: INT8\nvalue: " + text + "\n")); + } + // Space around a bare payload never reaches here, because the reader + // takes it off along with the space after the colon. This is asserted + // rather than left implied, since it is the reason the list above has no + // padded spelling in it. + assertEquals(integer(1), value("type: INT8\nvalue: 1 \n")); + } + + /** + * A float is exact here: {@code 1} is an integer somebody meant to write + * as {@code 1.0}, and {@code 1e400} is {@code inf} under another name. + * The JVM's own parser takes four more spellings the other runners do + * not. + */ + @Test + @DisplayName("a float is refused when it is spelt unusually") + void aFloatIsRefusedWhenItIsSpeltUnusually() { + for (String text : List.of("1", "-1", "1e400", "-1e400", "Infinity", "infinity", "nan", + "0x1p-2", "1_0.0", "1.0f", "1.0d", "")) { + assertEquals("line 2: " + Text.quote(text) + " is not a FLOAT64", + declined("type: FLOAT64\nvalue: " + Text.quote(text) + "\n")); + } + for (String text : List.of("1.0", "-1.5", "1e10", "1E10", "1.5e-3", "NaN", "inf", "-inf")) { + assertInstanceOf(Cell.Float.class, value("type: FLOAT64\nvalue: " + Text.quote(text) + "\n"), + text + " is a float"); + } + } + + /** + * Space anywhere in a byte string is dropped, which is what the + * standard's production allows and what lets a long literal be written in + * groups. Half a byte is refused. + */ + @Test + @DisplayName("a byte string is hexits in either case and space is dropped") + void aByteStringIsHexitsInEitherCaseAndSpaceIsDropped() { + record Case(String text, byte[] want) {} + for (Case c : List.of( + new Case("00AB00", new byte[] {0, (byte) 0xAB, 0}), + new Case("00ab00", new byte[] {0, (byte) 0xAB, 0}), + new Case("00 AB 00", new byte[] {0, (byte) 0xAB, 0}), + new Case("", new byte[0]), + new Case("FF", new byte[] {(byte) 0xFF}))) { + assertEquals(new Cell.Bytes(c.want()), + value("type: BYTES\nvalue: " + Text.quote(c.text()) + "\n"), + c.text() + " reads as those octets"); + } + for (String text : List.of("0", "ABC", "GG", "0x41", "00-AB")) { + assertEquals("line 2: " + Text.quote(text) + " is not a BYTES", + declined("type: BYTES\nvalue: " + Text.quote(text) + "\n")); + } + } + + /** + * Two values are the same value when their records are equal, which is + * the whole reason {@link Cell} is records. + * + *

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 named = table -> table == 1 ? "person" : "knows"; + record Case(Value from, Cell want) {} + for (Case c : List.of( + new Case(new Value.Node(1, 4), new Cell.Node("person", 4)), + new Case(new Value.Rel(2, 0, 1), new Cell.Edge("knows", 0, 1)), + new Case(new Value.Path(List.of(new Value.Node(1, 0))), + new Cell.Path(List.of(new Cell.Node("person", 0)))), + new Case(new Value.List(List.of(new Value.Node(1, 2))), + new Cell.List(List.of(new Cell.Node("person", 2)))), + new Case(new Value.Record(List.of(new Value.Field("n", new Value.Node(1, 5)))), + new Cell.Record(Map.of("n", new Cell.Node("person", 5)))), + // Everything else comes through as it is. + new Case(new Value.Int(1), integer(1)), + new Case(Value.Null.instance(), Cell.NULL), + new Case(new Value.Str("text"), new Cell.Str("text")))) { + assertEquals(c.want(), Cell.of(c.from(), named)); + } + // A list nested inside a list is walked all the way down, since a node + // can be anywhere a value can. + assertEquals( + new Cell.List(List.of(new Cell.List(List.of(new Cell.Node("person", 9))))), + Cell.of(new Value.List(List.of(new Value.List(List.of(new Value.Node(1, 9))))), named)); + // A graph is the one the corpus has no spelling for, and it is kept + // rather than dropped so that a report can say what came back. + Cell other = Cell.of(new Value.Graph(), named); + assertInstanceOf(Cell.Other.class, other); + assertNotNull(other); + assertFalse(other.equals(Cell.NULL), "nothing a case can write is equal to it"); + } +}