From 47a6a8dff2c2babcb779fb2e4d194ec646a2574a Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:54:33 +0700 Subject: [PATCH] Run the front page rather than read it The quickstart was a fragment. No class, no main, and it opened a database the reader does not have, so a person who copied it got a compile error first and a missing file second. It is a whole program now: it builds the graph, reads it back, and prints the two lines the page says it prints. The test runs it as printed. The source is taken off the README character for character, written into an empty directory, and compiled and run by a JVM of its own with the client on its class path and nothing else, and what it prints is compared against the block that follows it. A block that declares a public class is a program and a block that does not is a fragment, which is a rule the page can be read against rather than a list kept in the test. Checked both ways. With the page as written the suite is green, and with one word changed in the output block it fails naming the line. The class path the child gets is written out by the build rather than worked out by the test, because a test that guessed at a sibling module's target directory would keep passing on the day the dependency graph changed under it. --- README.md | 46 ++-- pom.xml | 6 + zudb-ffm/pom.xml | 24 ++ .../test/java/dev/zudb/ffm/ReadmeTest.java | 205 ++++++++++++++++++ 4 files changed, 268 insertions(+), 13 deletions(-) create mode 100644 zudb-ffm/src/test/java/dev/zudb/ffm/ReadmeTest.java diff --git a/README.md b/README.md index b603422..f103d60 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,41 @@ The Java client for [zu](https://github.com/tamnd/zu), an embedded property-grap ```java import dev.zudb.*; +import java.nio.file.Path; + +public class Quickstart { + public static void main(String[] args) { + Path path = Path.of("social.zu1"); + + try (Loader loader = Loader.create(path)) { + loader.table("Person", "Follows", 3); + loader.column("id", 1L, 2L, 3L); + loader.column("name", "ada", "grace", "lynn"); + loader.edges(new int[] {0, 1}, new int[] {1, 2}); + loader.finish(); + } -try (Database db = Database.open("social.zu1"); - Connection conn = db.connect()) { - - try (Result result = conn.query(""" - MATCH (p:Person)-[:Follows]->(f) - RETURN p.name AS name, count(*) AS n ORDER BY n DESC LIMIT 5 - """)) { - result.stream() - .map(r -> r.getString("name") + ": " + r.getLong("n")) - .forEach(System.out::println); + try (Database db = Database.open(path); + Connection conn = db.connect(); + Result result = conn.query(""" + MATCH (p:Person)-[:Follows]->(f:Person) + RETURN p.name AS name, f.name AS follows ORDER BY p.id + """)) { + result.stream() + .map(row -> row.getString("name") + " follows " + row.getString("follows")) + .forEach(System.out::println); + } } } ``` +``` +ada follows grace +grace follows lynn +``` + +It writes `social.zu1` beside you and prints those two lines, and this repository's tests run it exactly as printed, which is the only way a first example stays true. Run it a second time and it fails: `Loader.create` refuses a path that is already there, and `Database.open` is the call for a database that exists. + ```xml dev.zudb @@ -33,7 +53,7 @@ try (Database db = Database.open("social.zu1"); ``` -Text blocks for queries, try-with-resources for every handle, `Stream` for iteration. Nothing here should surprise a Java developer, which is the whole goal. +Text blocks for queries, try-with-resources for every handle, `Stream` for iteration. Nothing here should surprise a Java developer, which is the whole goal. The loader in the middle of it is there because the engine has no DDL yet, so a table comes into being out of columns rather than out of a `CREATE`. ## Reading a column without reading a row @@ -114,7 +134,7 @@ Two ways, and which one you want follows from whether the database exists yet. T A loader builds one out of whole columns. It is the fastest way values get in and, while the engine has no DDL, it is the only way a table comes into being at all: ```java -try (Loader loader = Loader.create(Path.of("social.zu1"))) { +try (Loader loader = Loader.create(Path.of("people.zu1"))) { loader.table("Person", "Follows", 3); loader.column("id", 1L, 2L, 3L); loader.column("name", "ada", "grace", "alan"); @@ -294,7 +314,7 @@ catch (ZuSyntaxException e) { ## What works today -The engine has no DDL yet, so there is no `CREATE NODE TABLE` and no statement in this client writes a schema. A table comes into being through `Loader`, which is why the loader example above builds the graph the example at the top of this file reads. What runs against a fresh database with nothing in it is the expression and projection surface: `RETURN`, `UNWIND`, parameters, lists, records, and the temporal types. +The engine has no DDL yet, so there is no `CREATE NODE TABLE` and no statement in this client writes a schema. A table comes into being through `Loader`, which is why the quickstart at the top of this file builds its graph before it reads one. What runs against a fresh database with nothing in it is the expression and projection surface: `RETURN`, `UNWIND`, parameters, lists, records, and the temporal types. ## Building diff --git a/pom.xml b/pom.xml index d42b48e..747313f 100644 --- a/pom.xml +++ b/pom.xml @@ -90,6 +90,7 @@ 3.15.0 3.5.6 3.5.1 + 3.8.1 3.4.0 3.12.0 3.6.2 @@ -162,6 +163,11 @@ maven-jar-plugin ${maven.jar.plugin.version} + + org.apache.maven.plugins + maven-dependency-plugin + ${maven.dependency.plugin.version} + org.apache.maven.plugins maven-source-plugin diff --git a/zudb-ffm/pom.xml b/zudb-ffm/pom.xml index cfcc017..933ab03 100644 --- a/zudb-ffm/pom.xml +++ b/zudb-ffm/pom.xml @@ -66,6 +66,30 @@ + + + org.apache.maven.plugins + maven-dependency-plugin + + + readme-classpath + generate-test-resources + + build-classpath + + + runtime + ${project.build.directory}/readme-classpath.txt + + + + org.apache.maven.plugins maven-surefire-plugin diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/ReadmeTest.java b/zudb-ffm/src/test/java/dev/zudb/ffm/ReadmeTest.java new file mode 100644 index 0000000..c466d24 --- /dev/null +++ b/zudb-ffm/src/test/java/dev/zudb/ffm/ReadmeTest.java @@ -0,0 +1,205 @@ +package dev.zudb.ffm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.zudb.tck.Libzu; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.io.TempDir; + +/** + * The programs on the front page, run as printed. + * + *

A quickstart is the most read and the least executed code a client has. + * It is what somebody copies before they have opened the reference, and it + * goes wrong a rename at a time: a method loses an argument, a return type + * narrows, an example keeps compiling in a reader's head and nowhere else. + * The only fix that holds is to run the page. + * + *

Run means run. The source is taken off the README character for + * character, written into an empty directory somewhere else on the machine, + * and compiled and executed by a JVM of its own with the client on its class + * path and nothing else. Nothing about this repository's build reaches it: no + * test framework, no fixture, no working directory with a database already in + * it. What it prints is compared against what the page says it prints, which + * is the other half of the claim and the half that rots quietest. + * + *

A block that declares a public class is a whole program and a block that + * does not is a fragment, which is a rule the page can be read against rather + * than a list kept here. The fenced block after a whole program is its + * output. + */ +class ReadmeTest { + + /** The page. Surefire runs a module in its own directory, so this is the root of the repository. */ + private static final Path README = Paths.get("..", "README.md"); + + /** What makes a block a program rather than a fragment, and what names it. */ + private static final Pattern DECLARES = Pattern.compile("^public class (\\w+) \\{$"); + + @TempDir static Path dir; + + @BeforeAll + static void engine() { + Libzu.require(); + } + + /** A program on the page, the class it declares, and the lines it says it prints. */ + private record Program(String name, String source, String output) {} + + @Test + void thePageHasAProgramOnIt() throws IOException { + // A rule that quietly matches nothing leaves a green suite that runs no + // programs, which is worse than the page being wrong, because a suite + // that tests nothing is one nobody looks at again. + assertFalse(programs().isEmpty(), "no whole program on " + README.toAbsolutePath().normalize()); + } + + @TestFactory + List everyProgramOnThePageRunsAndPrintsWhatItSays() throws IOException { + List cases = new ArrayList<>(); + for (Program program : programs()) { + cases.add(DynamicTest.dynamicTest(program.name(), () -> assertEquals(program.output(), run(program)))); + } + return cases; + } + + /** + * Compiles and runs one program in a directory of its own. + * + *

Single file source mode, because that is the shortest way to run + * exactly the text on the page and because a reader with the jars and a JDK + * can do the same thing. The class path is the client as this build made + * it, so a signature that moved in this commit is a compile failure here + * rather than a surprise after a release. + */ + private static String run(Program program) throws Exception { + Path where = Files.createDirectories(dir.resolve(program.name())); + Path source = where.resolve(program.name() + ".java"); + Files.writeString(source, program.source(), StandardCharsets.UTF_8); + Path complaints = where.resolve("stderr.txt"); + + List command = new ArrayList<>(); + command.add(Paths.get(System.getProperty("java.home"), "bin", "java").toString()); + // The jars carry Enable-Native-Access in their manifest and a class path + // run off them needs no flag. This runs off a directory of class files, + // which has no manifest to carry it, so the grant is made here instead. + command.add("--enable-native-access=ALL-UNNAMED"); + command.add("-Dzu.library=" + System.getProperty("zu.library")); + command.add("-classpath"); + command.add(classpath()); + command.add(source.toString()); + + Process java = + new ProcessBuilder(command) + .directory(where.toFile()) + .redirectError(complaints.toFile()) + .start(); + String printed = new String(java.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(java.waitFor(5, TimeUnit.MINUTES), program.name() + " did not finish"); + assertEquals( + 0, + java.exitValue(), + program.name() + " exited " + java.exitValue() + ":\n" + Files.readString(complaints)); + return printed; + } + + /** + * What the child JVM is given: this provider, and the API it implements. + * + *

The dependency list is written out by the build rather than worked out + * here, because a test that guesses at a sibling module's target directory + * is a test that passes for the wrong reason the day the graph changes. + */ + private static String classpath() throws IOException { + Path written = Paths.get("target", "readme-classpath.txt"); + assertTrue( + Files.isRegularFile(written), + written.toAbsolutePath().normalize() + " is missing: the build writes it before the tests run"); + String provider = Paths.get("target", "classes").toAbsolutePath().toString(); + String rest = Files.readString(written, StandardCharsets.UTF_8).trim(); + return rest.isEmpty() ? provider : provider + File.pathSeparator + rest; + } + + /** Every whole program on the page, with the output block that follows it. */ + private static List programs() throws IOException { + List lines = Files.readAllLines(README, StandardCharsets.UTF_8); + List found = new ArrayList<>(); + for (int i = 0; i < lines.size(); i++) { + if (!lines.get(i).equals("```java")) { + continue; + } + int end = close(lines, i + 1, i); + String name = declared(lines.subList(i + 1, end)); + if (name == null) { + i = end; + continue; + } + int opens = fence(lines, end + 1); + assertTrue(opens >= 0, name + " has no output block after it"); + assertEquals("```", lines.get(opens), name + " is followed by a block that is not its output"); + int closes = close(lines, opens + 1, opens); + found.add( + new Program( + name, + join(lines.subList(i + 1, end)), + join(lines.subList(opens + 1, closes)))); + i = closes; + } + return found; + } + + /** Where the block opened at {@code opened} ends. */ + private static int close(List lines, int from, int opened) { + for (int i = from; i < lines.size(); i++) { + if (lines.get(i).equals("```")) { + return i; + } + } + throw new AssertionError("the block at line " + (opened + 1) + " is never closed"); + } + + /** The next fence at or after {@code from}, or -1 if the page ends first. */ + private static int fence(List lines, int from) { + for (int i = from; i < lines.size(); i++) { + if (lines.get(i).startsWith("```")) { + return i; + } + } + return -1; + } + + /** The class a block declares, or null when it declares none and is a fragment. */ + private static String declared(List block) { + for (String line : block) { + Matcher m = DECLARES.matcher(line); + if (m.matches()) { + return m.group(1); + } + } + return null; + } + + private static String join(List lines) { + StringBuilder text = new StringBuilder(); + for (String line : lines) { + text.append(line).append('\n'); + } + return text.toString(); + } +}