From ddff050f2d7396f16877634e1d9b9357a468af30 Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:36:24 +0700 Subject: [PATCH 1/8] A reader for the corpus files The corpus is a directory of YAML files versioned with the engine and shipped to every client in the family. Java is the one client with no runner for it, which is the gap that keeps the differential suite from being green across all five. This is the first piece: the reader. It is a new module rather than sources under an existing one, and it holds main sources for the same reason zudb-tck does, because a test source set is not something another module can depend on without a test jar and the trouble a test jar brings on the module path. It ends up both a command a person runs against a corpus directory and a fixture a test can call. It compiles to 25 rather than to the 17 the API targets. The Arrow half of the corpus wants a result read through the C Data Interface, and doing that without taking a dependency on arrow-java wants java.lang.foreign. The reader is the fifth implementation of the same small subset of YAML, after the ones in the engine, in the C client, in zu-python and in zu-go. Writing a fifth rather than reaching for a YAML library is deliberate and the tests are where it shows: a library would read these files and would read a great deal more besides, and what the corpus needs is a reader that refuses. A case using a flow sequence, an anchor, a tag or a block scalar has to come back as a refusal with a line number rather than as something the author did not write, because a construct one implementation quietly accepts is a case that passes in one repository and fails in four. So the refusals are checked in full rather than by substring. They are diffed against the reference runner's output and a wording that drifted would be a difference in the report that is not a difference in the answer. Twenty five refused constructs and eleven accepted shapes, in twelve tests. Two things Go spells and Java does not needed care. Java has no escape for a vertical tab, so the one in the whitespace set is written as its code point. And the quoting of a value into a message follows Rust's {:?} rather than anything on the JVM, since Java and Rust disagree about which characters are worth escaping and the messages are compared across languages. --- pom.xml | 1 + zudb-corpus/pom.xml | 58 +++ .../java/dev/zudb/corpus/CorpusException.java | 29 ++ .../src/main/java/dev/zudb/corpus/Node.java | 213 +++++++++ .../src/main/java/dev/zudb/corpus/Text.java | 56 +++ .../src/main/java/dev/zudb/corpus/Yaml.java | 431 ++++++++++++++++++ .../java/dev/zudb/corpus/package-info.java | 16 + .../test/java/dev/zudb/corpus/ReaderTest.java | 283 ++++++++++++ 8 files changed, 1087 insertions(+) create mode 100644 zudb-corpus/pom.xml create mode 100644 zudb-corpus/src/main/java/dev/zudb/corpus/CorpusException.java create mode 100644 zudb-corpus/src/main/java/dev/zudb/corpus/Node.java create mode 100644 zudb-corpus/src/main/java/dev/zudb/corpus/Text.java create mode 100644 zudb-corpus/src/main/java/dev/zudb/corpus/Yaml.java create mode 100644 zudb-corpus/src/main/java/dev/zudb/corpus/package-info.java create mode 100644 zudb-corpus/src/test/java/dev/zudb/corpus/ReaderTest.java diff --git a/pom.xml b/pom.xml index 747313f..8bcd5fe 100644 --- a/pom.xml +++ b/pom.xml @@ -50,6 +50,7 @@ zudb zudb-tck + zudb-corpus zudb-ffm zudb-jni zudb-arrow diff --git a/zudb-corpus/pom.xml b/zudb-corpus/pom.xml new file mode 100644 index 0000000..d2dceac --- /dev/null +++ b/zudb-corpus/pom.xml @@ -0,0 +1,58 @@ + + + + 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 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${zu.release.ffm} + + -Xlint:all,-requires-automatic,-requires-transitive-automatic + -Werror + + + + + + 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/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/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/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/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("")); + } +} From 2129a932ff1cf5a41218ec86197bdcfa4e69b785 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:49:14 +0700 Subject: [PATCH 2/8] The temporal half of the corpus encoding The four spellings a case writes a date, a time, a datetime and a duration in, read by hand rather than handed to a formatter from the library. A corpus reader is a second opinion about the text, and a second opinion that calls the same code 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 Z under some patterns and not others, and none of that is visible at the call site. Writing the spellings out says exactly what is accepted, which is the extended ISO 8601 form and nothing else. The seven types come back as dev.zudb.Value.Temporal rather than as seven java.time types, because that is the one shape the client hands back and a comparison has to be against it. The two conventions that shape carries are kept: a zoned datetime holds the instant in UTC with the offset beside it, so two texts an hour apart in zones an hour apart hold one count, and a zoned time holds the clock as written, so 12:00:00+07:00 and 05:00:00Z are two values rather than one. A duration is months or it is nanoseconds and never both. That leaves one text the fields decide and the numbers cannot, a duration of nothing, and P0M and PT0S stay two values here where the Python runner has to call them one. Nine tests, ported case for case from the Go runner's, and most of them are 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, a fraction of a year. --- .../main/java/dev/zudb/corpus/Temporals.java | 544 ++++++++++++++++++ .../java/dev/zudb/corpus/TemporalsTest.java | 339 +++++++++++ 2 files changed, 883 insertions(+) create mode 100644 zudb-corpus/src/main/java/dev/zudb/corpus/Temporals.java create mode 100644 zudb-corpus/src/test/java/dev/zudb/corpus/TemporalsTest.java 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/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"); + } + } +} From d26585ac397186c90f320d6f41449924276b3e34 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:50:27 +0700 Subject: [PATCH 3/8] The value half of the corpus encoding Every value a case can write, in both directions: the text a case wrote becomes a value to bind or compare against, and a value the engine handed back becomes the text a report prints. The shape is a sealed Cell rather than dev.zudb.Value, for two reasons that are the same reason twice. Value is sealed by the client, so a corpus that needs a shape the client does not have could not add one. And the two disagree about what a node is: the engine's node carries a table id where a case writes a table name, and its edge carries a field the corpus has no spelling for. Converting at the boundary means a comparison is a comparison and not a walk through two shapes at once. A type is written quoted or bare and never both, which is the rule that lets a reader tell an integer a case meant from an integer YAML would have handed it. Anything wider than what a double holds exactly is written quoted, so INT64 and the two 64 bit unsigned widths are quoted types and INT32 is not. DECIMAL is reserved rather than unknown, since the encoding has a spelling for it and the engine has no value, and saying so is more use than saying it is not a type. The float printer is the one place the JVM had to be talked out of its own answer. Double.toString has given the shortest run that reads back since release 19, except where a single digit would do: its specification then asks for the closest decimal of one digit or two, and for the smallest subnormal the two digit one is closer. So Java alone would print 4.9e-324 where the reference runner and every other client print 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 rather than a table of special cases. Seventeen tests, ported case for case from the Go runner's, and the float ones carry the values that catch a printer written the easy way: the smallest subnormal, the largest finite, a half way case, and the three that have no numeric spelling at all. --- .../src/main/java/dev/zudb/corpus/Cell.java | 230 ++++++ .../src/main/java/dev/zudb/corpus/Values.java | 739 ++++++++++++++++++ .../test/java/dev/zudb/corpus/ValuesTest.java | 601 ++++++++++++++ 3 files changed, 1570 insertions(+) create mode 100644 zudb-corpus/src/main/java/dev/zudb/corpus/Cell.java create mode 100644 zudb-corpus/src/main/java/dev/zudb/corpus/Values.java create mode 100644 zudb-corpus/src/test/java/dev/zudb/corpus/ValuesTest.java 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/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/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"); + } +} From c412de2e2e11f29c27da6920485cb875a1be6d1a Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:50:49 +0700 Subject: [PATCH 4/8] Reading a result as Arrow through the C Data Interface A case may say what the export of its result looks like, which is a list of column names and Arrow format strings, or that the export is refused. This is the half that reads one. It goes through java.lang.foreign rather than arrow-java, and that is the reason this module compiles to 25 while the API targets 17. The alternative was a dependency on arrow-java for the sole purpose of reading a schema the client already hands over the C Data Interface, which would put a large library in the path of a test whose whole point is that no copy happens. The three structs are laid out here as they are in the interface, the three callbacks are called through downcall handles, and nothing on the path allocates a Java object per value. The schema is read off the stream rather than reconstructed from what the client knows about its own columns. That is what makes the answer worth comparing: the Java, Go and C runners all report the format strings the engine wrote, so a case that says a duration column is tDn is a case about the engine and not about three clients that each decided what to call it. A refusal is a type of its own. Arrow has no type for some of what the engine can return, and a case saying `arrow: refused` wants the stream to fail to open, which has to be told apart from a schema that opened and did not match. The refusal messages are the reference runner's word for word, including the one about a nested format that is not nested and the one about a nesting that is. The restricted method warnings are suppressed on the five methods that call one, which is what zudb-ffm already does in three files: release 24 put the restricted lint inside -Xlint:all, and this module builds with -Werror. --- .../src/main/java/dev/zudb/corpus/Arrow.java | 449 ++++++++++++++++++ .../java/dev/zudb/corpus/ArrowException.java | 26 + 2 files changed, 475 insertions(+) create mode 100644 zudb-corpus/src/main/java/dev/zudb/corpus/Arrow.java create mode 100644 zudb-corpus/src/main/java/dev/zudb/corpus/ArrowException.java 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); + } +} From eeb31fae4a0ed2ee81648f844ccfc2f7ed0235b7 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:50:49 +0700 Subject: [PATCH 5/8] The case model and what a corpus file may say A suite is a header and a list of cases. A case is a statement, the connection to run it on, the parameters to bind, and exactly one account of what it produces: rows, or a GQLSTATUS, and never both and never neither. The reading is strict in the same way the YAML reader under it is strict, and for the same reason. A key nobody recognises is a refusal rather than a key that is ignored, because a case with a misspelled `raises` would otherwise be a case that says nothing and passes. A name is lower case words joined by dashes, checked against the file rather than trusted, because the name is what a report prints and what two runners are diffed by. Two cases in a suite may not share a name for the same reason. The schema version is checked before anything else, so that a corpus written for a later shape says so rather than failing somewhere in the middle with a message about a key. Go's [2]int for an edge is a Pair record here, so that two edges compare equal when they are equal. The rest of the model is records nested inside the Suite record, which keeps the port one file the way the original is one file while reading as Suite.read, Suite.Case and Suite.Load at the call sites. Fifteen tests, ported case for case from the Go runner's, and most of them are files that are nearly right: a case with rows and a raises, a row with the wrong number of values, a name with an underscore in it, a load whose edge points past its own row count. --- .../src/main/java/dev/zudb/corpus/Suite.java | 673 ++++++++++++++++++ .../test/java/dev/zudb/corpus/SuiteTest.java | 501 +++++++++++++ 2 files changed, 1174 insertions(+) create mode 100644 zudb-corpus/src/main/java/dev/zudb/corpus/Suite.java create mode 100644 zudb-corpus/src/test/java/dev/zudb/corpus/SuiteTest.java 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/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()); + } + } +} From 48eb24a6f302bd652860abcb22654031d0a0ddba Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:51:01 +0700 Subject: [PATCH 6/8] The runner and the command that runs the corpus The last piece: the thing that opens a database per case, puts the suite's load in, binds the parameters, runs the statement and says what came back. The report is the reference runner's line for line, down to the word order of every failure, because the value of a shared corpus is that a disagreement between two clients is a diff. An outcome is one of three things and not two: unsupported is the third, and it is what a client says about a statement the engine does not implement yet, which the corpus allows on purpose since the cases are the contract and the engine catches up to them. A load goes in through the bulk loader rather than through statements, which is the strongest form of the corpus question: the value crosses the boundary twice and by two different mechanisms, once as a column of a loader and once as a row of a result. A second connection is a duplicate of the case's own rather than a second open of the file, since the two share the write side and a case about a transaction means the first and not the second. Against the engine at d9f6b5d this reads 1399 cases and passes 1377, with 20 unsupported and 2 failed. The Go runner on the same corpus reports the same 20 unsupported, line for line and in the same order, and the same 2 failures plus 4 more that are its own pinned library being four months of reserved words behind. The two shared failures are the engine accepting a pre-reserved word where a name belongs, which is a gap in the engine and not in a client. One thing the corpus can write and this client cannot bind is a BYTES parameter, since Statement has no overload for a byte array. No case binds one today, so the runner says so by name rather than putting the value somewhere it does not belong, and the gap is filed rather than worked around. --- .../src/main/java/dev/zudb/corpus/Main.java | 165 +++++ .../src/main/java/dev/zudb/corpus/Runner.java | 634 ++++++++++++++++++ 2 files changed, 799 insertions(+) create mode 100644 zudb-corpus/src/main/java/dev/zudb/corpus/Main.java create mode 100644 zudb-corpus/src/main/java/dev/zudb/corpus/Runner.java 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/Runner.java b/zudb-corpus/src/main/java/dev/zudb/corpus/Runner.java new file mode 100644 index 0000000..b255266 --- /dev/null +++ b/zudb-corpus/src/main/java/dev/zudb/corpus/Runner.java @@ -0,0 +1,634 @@ +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. + } + } + + private 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 = e instanceof ZuException zu ? zu.code().orElse("") : ""; + 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 (e instanceof ZuException zu && unsupported(zu)) { + 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. + */ + private static boolean unsupported(ZuException e) { + String code = e.code().orElse(""); + return code.startsWith("42") || code.startsWith("0A"); + } + + /** + * 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. */ + private 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(); + } +} From 2effe7e9cae12cede050cbc97ba0145a7121d194 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:41:13 +0700 Subject: [PATCH 7/8] The tests for the runner, and the corpus itself The runner's own tests are the reference runner's, case for case: a case that says what it produces, a case that wants a condition, the eight ways one can fail, a case ahead of the engine, a named connection, bound parameters, a suite with a load, the database a failure leaves behind, what a report comes to, where compare stops, and the two shapes the report prints. Ported rather than written afresh, because a port that tests something else is a port nobody can diff. Three things the port needed that the Go tests get for free. A fresh directory per run, since a case that fails leaves its database behind on purpose and eight sub-cases all called `one` would collide where Go's t.TempDir gives each one its own. A handful of the runner's helpers made package private, because what the tests check is the status code, the unsupported rule, the column list and the case path, and a test that reaches them through the whole run checks the run instead. And a real ZuException rather than a made one, since the constructor is package private in dev.zudb and an empty statement is a better source of one anyway. The corpus test is gated on ZU_CASES and skips without it, the way zu-go and zu-python gate theirs. The cases live in the engine's repository and a client whose suite cannot run without a second repository beside it is one nobody clones to fix a typo. The module needs a provider and a grant to run at all, so it takes zudb-ffm at test scope and runs surefire off the module path with --enable-native-access=ALL-UNNAMED, which is what zudb-arrow does and what the README tells a person to pass. That puts zudb-corpus after zudb-ffm in the build order. --- pom.xml | 2 +- zudb-corpus/pom.xml | 26 + .../src/main/java/dev/zudb/corpus/Runner.java | 34 +- .../test/java/dev/zudb/corpus/CorpusTest.java | 87 ++++ .../test/java/dev/zudb/corpus/RunnerTest.java | 490 ++++++++++++++++++ 5 files changed, 632 insertions(+), 7 deletions(-) create mode 100644 zudb-corpus/src/test/java/dev/zudb/corpus/CorpusTest.java create mode 100644 zudb-corpus/src/test/java/dev/zudb/corpus/RunnerTest.java diff --git a/pom.xml b/pom.xml index 8bcd5fe..0a8ce41 100644 --- a/pom.xml +++ b/pom.xml @@ -50,8 +50,8 @@ zudb zudb-tck - zudb-corpus zudb-ffm + zudb-corpus zudb-jni zudb-arrow zudb-bench diff --git a/zudb-corpus/pom.xml b/zudb-corpus/pom.xml index d2dceac..f916f45 100644 --- a/zudb-corpus/pom.xml +++ b/zudb-corpus/pom.xml @@ -38,6 +38,19 @@ dev.zudb zudb + + + dev.zudb + zudb-ffm + ${project.version} + test + @@ -53,6 +66,19 @@ + + 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/Runner.java b/zudb-corpus/src/main/java/dev/zudb/corpus/Runner.java index b255266..76af4e7 100644 --- a/zudb-corpus/src/main/java/dev/zudb/corpus/Runner.java +++ b/zudb-corpus/src/main/java/dev/zudb/corpus/Runner.java @@ -164,7 +164,7 @@ private static void remove(Path path) { } } - private static Path casePath(Path directory, String suite, String name) { + static Path casePath(Path directory, String suite, String name) { return directory.resolve(suite + "-" + name + ".zu"); } @@ -271,7 +271,7 @@ private static Ran statement(Suite suite, Suite.Case one, List open) { prepared.close(); } if (!one.raises().isEmpty()) { - String code = e instanceof ZuException zu ? zu.code().orElse("") : ""; + 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)); @@ -282,7 +282,7 @@ private static Ran statement(Suite suite, Suite.Case one, List open) { return ran(suite, one, Outcome.FAILED, "raised " + code + " where the case wants " + one.raises() + ": " + errorText(e)); } - if (e instanceof ZuException zu && unsupported(zu)) { + if (unsupported(e)) { return ran(suite, one, Outcome.UNSUPPORTED, errorText(e)); } return ran(suite, one, Outcome.FAILED, errorText(e)); @@ -568,11 +568,33 @@ private static String exported(Arrow.Export want, Result result, int count) { * case ahead of the engine, which the corpus allows on purpose: the cases * are the contract and the engine catches up to them. */ - private static boolean unsupported(ZuException e) { - String code = e.code().orElse(""); + 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. @@ -622,7 +644,7 @@ static String compare(List wantColumns, List> wantRows, } /** A list of column names the way Rust's {@code {:?}} writes one. */ - private static String names(List columns) { + static String names(List columns) { StringBuilder out = new StringBuilder("["); String between = ""; for (String name : columns) { 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/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"))); + } +} From 9d17c847727b6f0a167367237bec7080d11cd456 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:41:26 +0700 Subject: [PATCH 8/8] Run the corpus in CI, and say how to run it by hand A job of its own rather than a step in the engine job, because the run is fourteen hundred databases and that job runs its suite three times over. The job builds the engine and reads the cases out of the same checkout. That pairing is the whole point: 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. zu-go pins a revision because it ships an archive of the library; this client builds from source, so there is no revision to pin. The README gets the command a person runs against a corpus directory, the flags it takes, and the note that the same run happens under mvn test when ZU_CASES points at the cases and skips when it does not. --- .github/workflows/ci.yml | 53 ++++++++++++++++++++++++++++++++++++++++ README.md | 12 +++++++++ 2 files changed, 65 insertions(+) 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` |