Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 33 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<dependency>
<groupId>dev.zudb</groupId>
Expand All @@ -33,7 +53,7 @@ try (Database db = Database.open("social.zu1");
</dependency>
```

Text blocks for queries, try-with-resources for every handle, `Stream<Row>` 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<Row>` 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

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
<maven.compiler.plugin.version>3.15.0</maven.compiler.plugin.version>
<maven.surefire.plugin.version>3.5.6</maven.surefire.plugin.version>
<maven.jar.plugin.version>3.5.1</maven.jar.plugin.version>
<maven.dependency.plugin.version>3.8.1</maven.dependency.plugin.version>
<maven.source.plugin.version>3.4.0</maven.source.plugin.version>
<maven.javadoc.plugin.version>3.12.0</maven.javadoc.plugin.version>
<maven.shade.plugin.version>3.6.2</maven.shade.plugin.version>
Expand Down Expand Up @@ -162,6 +163,11 @@
<artifactId>maven-jar-plugin</artifactId>
<version>${maven.jar.plugin.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>${maven.dependency.plugin.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
Expand Down
24 changes: 24 additions & 0 deletions zudb-ffm/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,30 @@
</archive>
</configuration>
</plugin>
<!-- What ReadmeTest hands the JVM it starts. The programs on the
front page are run in a process of their own, off a class path
holding this provider and the API and nothing else, and the
list of what that comes to is written by the build rather than
guessed at by the test. A test that pointed at a sibling
module's target directory would keep passing on the day the
dependency graph changed under it. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>readme-classpath</id>
<phase>generate-test-resources</phase>
<goals>
<goal>build-classpath</goal>
</goals>
<configuration>
<includeScope>runtime</includeScope>
<outputFile>${project.build.directory}/readme-classpath.txt</outputFile>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
Expand Down
205 changes: 205 additions & 0 deletions zudb-ffm/src/test/java/dev/zudb/ffm/ReadmeTest.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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<DynamicTest> everyProgramOnThePageRunsAndPrintsWhatItSays() throws IOException {
List<DynamicTest> 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.
*
* <p>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<String> 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.
*
* <p>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<Program> programs() throws IOException {
List<String> lines = Files.readAllLines(README, StandardCharsets.UTF_8);
List<Program> 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<String> 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<String> 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<String> block) {
for (String line : block) {
Matcher m = DECLARES.matcher(line);
if (m.matches()) {
return m.group(1);
}
}
return null;
}

private static String join(List<String> lines) {
StringBuilder text = new StringBuilder();
for (String line : lines) {
text.append(line).append('\n');
}
return text.toString();
}
}
Loading