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(); + } +}