From 8742e001c3ea3d82dd2e2c52d269f71351420baf Mon Sep 17 00:00:00 2001 From: zijian yi Date: Mon, 19 Oct 2026 11:02:51 -0500 Subject: [PATCH 1/5] Make @CompileQuery rewriting strict and part of the build - Merge the duplicated rewriters (CodegenMojo and ImplCodeGen) into org.estore.compiler.QueryRewriter; both entry points now delegate to it. - Fail instead of silently falling back: Cypher syntax errors, non-literal query arguments, annotated methods without a query, and unparseable generated code raise QueryCompileException, which the Mojo reports as a MojoExecutionException naming the file. - CodeGenBuilder rejects constructs it used to drop or crash on (WHERE, OPTIONAL MATCH, DISTINCT, ORDER BY/SKIP/LIMIT, WITH, UNION, UNWIND, SET, multi-variable DELETE) and replaces System.exit calls with exceptions. Remove the never-implemented compiler NodePropScan. - Add a compile-queries profile in estore/pom.xml and use it in CI instead of invoking the plugin goal by hand. - Tests: QueryRewriterTest, CodegenMojoTest (first plugin tests), and CompiledQueryTest, which checks compiled results against the interpreter for each supported query shape. - Scope junit-jupiter-engine to test in estore so it no longer leaks into consumers; drop unused plugin dependencies. --- .github/workflows/coverage-ci.yml | 11 +- .github/workflows/test-ci.yml | 11 +- README.md | 12 + estore-maven-plugin/pom.xml | 28 +- .../src/main/java/org/estore/CodegenMojo.java | 229 ++++------------- .../test/java/org/estore/CodegenMojoTest.java | 119 +++++++++ estore/pom.xml | 40 ++- .../org/estore/compiler/CodeGenBuilder.java | 64 +++-- .../java/org/estore/compiler/ImplCodeGen.java | 150 +---------- .../compiler/QueryCompileException.java | 13 + .../org/estore/compiler/QueryRewriter.java | 241 ++++++++++++++++++ .../estore/compiler/scan/NodePropScan.java | 66 ----- .../compiler/scan/VarLengthRelationScan.java | 7 +- .../estore/compiler/CompiledQueryTest.java | 179 +++++++++++++ .../estore/compiler/QueryRewriterTest.java | 207 +++++++++++++++ 15 files changed, 928 insertions(+), 449 deletions(-) create mode 100644 estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java create mode 100644 estore/src/main/java/org/estore/compiler/QueryCompileException.java create mode 100644 estore/src/main/java/org/estore/compiler/QueryRewriter.java delete mode 100644 estore/src/main/java/org/estore/compiler/scan/NodePropScan.java create mode 100644 estore/src/test/java/org/estore/compiler/CompiledQueryTest.java create mode 100644 estore/src/test/java/org/estore/compiler/QueryRewriterTest.java diff --git a/.github/workflows/coverage-ci.yml b/.github/workflows/coverage-ci.yml index a78b8db..81c31f2 100644 --- a/.github/workflows/coverage-ci.yml +++ b/.github/workflows/coverage-ci.yml @@ -19,11 +19,10 @@ jobs: - name: Install test-data tools run: sudo apt-get update && sudo apt-get install -y wget zstd - - name: Install codegen plugin - run: mvn -pl estore-maven-plugin -am install -DskipTests -Djacoco.skip=true - - - name: Rewrite annotated queries - run: mvn -pl estore org.estore:estore-maven-plugin:1.0-SNAPSHOT:codegen + - name: Install estore and test the codegen plugin + run: | + mvn -pl estore -am install -DskipTests -Djacoco.skip=true + mvn -pl estore-maven-plugin install - name: Check coverage - run: mvn -pl estore clean test jacoco:report jacoco:check + run: mvn -f estore -Pcompile-queries clean test jacoco:report jacoco:check diff --git a/.github/workflows/test-ci.yml b/.github/workflows/test-ci.yml index fc8d6db..04d29f4 100644 --- a/.github/workflows/test-ci.yml +++ b/.github/workflows/test-ci.yml @@ -19,14 +19,13 @@ jobs: - name: Install test-data tools run: sudo apt-get update && sudo apt-get install -y wget zstd - - name: Install codegen plugin - run: mvn -pl estore-maven-plugin -am install -DskipTests -Djacoco.skip=true - - - name: Rewrite annotated queries - run: mvn -pl estore org.estore:estore-maven-plugin:1.0-SNAPSHOT:codegen + - name: Install estore and test the codegen plugin + run: | + mvn -pl estore -am install -DskipTests -Djacoco.skip=true + mvn -pl estore-maven-plugin install - name: Build and test estore - run: mvn -pl estore clean test jacoco:report + run: mvn -f estore -Pcompile-queries clean test jacoco:report - name: Upload JaCoCo report if: always() diff --git a/README.md b/README.md index 85c7373..44fa8f1 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,18 @@ mvn -pl estore test verify The JaCoCo code coverage report is generated at `estore/target/site/jacoco/index.html`. +#### Run Tests with Compiled Queries + +Test methods annotated with `@CompileQuery` can also run with their Cypher queries compiled +to Java. The `compile-queries` profile writes a `TransformedTest.java` next to each such +test, and the build fails if a query cannot be compiled. The codegen plugin depends on `estore`, +so install both first, then build the `estore` POM directly: + +```bash +mvn -pl estore -am install -DskipTests && mvn -pl estore-maven-plugin install +mvn -f estore -Pcompile-queries test +``` + #### Run the Application Start the estore server and query it over the network: diff --git a/estore-maven-plugin/pom.xml b/estore-maven-plugin/pom.xml index 4e29ce5..1e1b31b 100644 --- a/estore-maven-plugin/pom.xml +++ b/estore-maven-plugin/pom.xml @@ -36,16 +36,6 @@ estore 1.0.0 - - org.antlr - antlr4-runtime - 4.13.1 - - - com.github.javaparser - javaparser-symbol-solver-core - 3.26.1 - org.apache.maven maven-plugin-api @@ -64,12 +54,6 @@ ${maven.version} provided - - org.apache.maven - maven-compat - ${maven.version} - test - org.apache.maven.plugin-tools maven-plugin-annotations @@ -77,15 +61,9 @@ provided - junit - junit - 4.12 - test - - - org.apache.maven.plugin-testing - maven-plugin-testing-harness - 3.3.0 + org.junit.jupiter + junit-jupiter-engine + 5.9.2 test diff --git a/estore-maven-plugin/src/main/java/org/estore/CodegenMojo.java b/estore-maven-plugin/src/main/java/org/estore/CodegenMojo.java index be80ef2..9a039ca 100644 --- a/estore-maven-plugin/src/main/java/org/estore/CodegenMojo.java +++ b/estore-maven-plugin/src/main/java/org/estore/CodegenMojo.java @@ -1,209 +1,92 @@ package org.estore; -import com.github.javaparser.StaticJavaParser; -import com.github.javaparser.ast.CompilationUnit; -import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; -import com.github.javaparser.ast.body.MethodDeclaration; -import com.github.javaparser.ast.expr.BinaryExpr; -import com.github.javaparser.ast.expr.Expression; -import com.github.javaparser.ast.expr.MethodCallExpr; -import com.github.javaparser.ast.visitor.VoidVisitorAdapter; import java.io.File; -import java.io.FileInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; -import org.antlr.v4.runtime.CharStreams; -import org.antlr.v4.runtime.CommonTokenStream; -import org.antlr.v4.runtime.tree.ParseTree; +import java.util.stream.Stream; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; -import org.estore.antlr4.CypherLexer; -import org.estore.antlr4.CypherParser; -import org.estore.compiler.CodeGenBuilder; - -@Mojo(name = "codegen", defaultPhase = LifecyclePhase.PROCESS_SOURCES) +import org.estore.compiler.QueryCompileException; +import org.estore.compiler.QueryRewriter; + +/** + * Rewrites the queries in every {@code @CompileQuery} method under {@code sourceDir} into Java + * code, writing each rewritten file next to its source as {@code Transformed.java}. + */ +@Mojo(name = "codegen", defaultPhase = LifecyclePhase.GENERATE_TEST_SOURCES) public class CodegenMojo extends AbstractMojo { @Parameter(property = "sourceDir", defaultValue = "${project.build.testSourceDirectory}") - private File sourceDir; + File sourceDir; @Parameter(property = "profiling", defaultValue = "true") - private Boolean profiling; + boolean profiling; + @Override public void execute() throws MojoExecutionException { - long t1 = 0; - if (profiling) { - t1 = System.currentTimeMillis(); + if (!sourceDir.isDirectory()) { + getLog().info("Skipping query rewriting: " + sourceDir + " does not exist"); + return; } - try { - List javaFiles = - Files.walk(sourceDir.toPath()) - .filter(p -> p.toString().endsWith(".java")) - .collect(Collectors.toList()); - for (Path fp : javaFiles) { - File f = fp.toFile(); - processJavaFile(f); + long start = System.currentTimeMillis(); + int rewritten = 0; + for (Path file : javaFiles()) { + if (rewriteFile(file)) { + rewritten++; } - } catch (IOException e) { - throw new MojoExecutionException("Error processing files", e); } + getLog().info("Rewrote @CompileQuery methods in " + rewritten + " file(s)"); if (profiling) { - long t2 = System.currentTimeMillis(); - System.out.println("Total Time taken: " + (t2 - t1) + " ms"); + getLog().info("Total time taken: " + (System.currentTimeMillis() - start) + " ms"); } } - private void processJavaFile(File file) throws MojoExecutionException { - try (FileInputStream in = new FileInputStream(file)) { - CompilationUnit cu = StaticJavaParser.parse(in); - boolean[] modified = {false}; - new AnnotationVisitor().visit(cu, modified); - if (modified[0]) { - // if there is a query being transformed - // add imports, rename the class, and add DFSNode class - addImports(cu); - cu.findAll(ClassOrInterfaceDeclaration.class).stream() - .filter( - c -> - c.getParentNode().isPresent() - && c.getParentNode().get() - instanceof CompilationUnit) - .forEach( - c -> { - c.setName("Transformed" + c.getNameAsString()); - c.addMember( - StaticJavaParser.parseBodyDeclaration( - createDFSNodeClass())); - }); - - String originalFilename = file.getName(); - String newFilename = "Transformed" + originalFilename; - File newFile = new File(file.getParent(), newFilename); - Files.write(newFile.toPath(), cu.toString().getBytes()); - - getLog().info("Transformed " + originalFilename + " to " + newFilename); - } + private List javaFiles() throws MojoExecutionException { + try (Stream paths = Files.walk(sourceDir.toPath())) { + return paths.filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList()); } catch (IOException e) { - throw new MojoExecutionException("Error processing file: " + file, e); + throw new MojoExecutionException("Cannot list Java files under " + sourceDir, e); } } - private class AnnotationVisitor extends VoidVisitorAdapter { - @Override - public void visit(MethodDeclaration md, boolean[] modified) { - super.visit(md, modified); - if (md.isAnnotationPresent("CompileQuery")) { - String mdName = md.getNameAsString(); - long t1 = 0; - if (profiling) { - t1 = System.currentTimeMillis(); - } - // remove the annotation to avoid re-processing - md.getAnnotations().removeIf(a -> a.getNameAsString().equals("CompileQuery")); - new QueryReplacer().visit(md, modified); - if (profiling) { - long t2 = System.currentTimeMillis(); - System.out.println( - "Time taken for method " + mdName + ": " + (t2 - t1) + " ms"); - } - } + private boolean rewriteFile(Path file) throws MojoExecutionException { + long start = System.currentTimeMillis(); + Optional rewritten; + try { + String source = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); + rewritten = QueryRewriter.rewriteAnnotatedMethods(source); + } catch (IOException e) { + throw new MojoExecutionException("Cannot read " + file, e); + } catch (QueryCompileException e) { + throw new MojoExecutionException( + "Cannot rewrite @CompileQuery queries in " + file + ": " + e.getMessage(), e); } - } - - private class QueryReplacer extends VoidVisitorAdapter { - - @Override - public void visit(MethodCallExpr n, boolean[] modified) { - super.visit(n, modified); - if (n.getNameAsString().equals("query")) { - modified[0] = true; - - String dbname = n.getScope().get().toString(); - - // get the query string - String queryString = ""; - Expression expr = n.getArgument(0); - if (expr.isStringLiteralExpr()) { - queryString = expr.asStringLiteralExpr().getValue(); - } else if (expr.isBinaryExpr()) { - queryString = resolveStringConcatenation(expr); - } else { - System.out.println("Unsupported query argument type"); - System.exit(1); - } - - CypherLexer lexer = new CypherLexer(CharStreams.fromString(queryString)); - CommonTokenStream tokenStream = new CommonTokenStream(lexer); - CypherParser parser = new CypherParser(tokenStream); - ParseTree tree = parser.oC_Cypher(); - - CodeGenBuilder builder = new CodeGenBuilder(dbname, null); - String transformedCode = (String) builder.visit(tree); - - try { - n.replace(StaticJavaParser.parseExpression(transformedCode)); - } catch (Exception e) { - System.out.println( - "Failed to parse transformed code for query: " + queryString); - e.printStackTrace(); - } - } + if (!rewritten.isPresent()) { + return false; } - } - - private String resolveStringConcatenation(Expression expr) { - if (expr.isStringLiteralExpr()) { - return expr.asStringLiteralExpr().getValue(); - } else if (expr.isBinaryExpr()) { - BinaryExpr binaryExpr = expr.asBinaryExpr(); - String left = resolveStringConcatenation(binaryExpr.getLeft()); - String right = resolveStringConcatenation(binaryExpr.getRight()); - return left + right; - } else { - // TODO: Handle other types of expressions (e.g., variable references) - // return expr.toString(); - System.out.println("Unsupported expression: " + expr); - System.exit(1); - return ""; + Path target = file.resolveSibling(QueryRewriter.TRANSFORMED_PREFIX + file.getFileName()); + try { + Files.write(target, rewritten.get().getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new MojoExecutionException("Cannot write " + target, e); } - } - - private static String createDFSNodeClass() { - return "private static class DFSNode {" - + "Object currentNode;" - + "int depth;" - + "DFSNode(Object currentNode, int depth) {" - + "this.currentNode = currentNode;" - + "this.depth = depth;}}"; - } - - private void addImports(CompilationUnit cu) { - cu.addImport("java.util.function.Supplier"); - cu.addImport("java.util.Arrays"); - cu.addImport("java.util.List"); - cu.addImport("java.util.ArrayList"); - cu.addImport("java.util.HashMap"); - cu.addImport("java.util.HashSet"); - cu.addImport("java.util.Map"); - cu.addImport("java.util.Set"); - cu.addImport("java.util.Stack"); - cu.addImport("java.lang.reflect.Array"); - cu.addImport("java.lang.reflect.Field"); - cu.addImport("org.estore.compiler.Util"); - cu.addImport("org.estore.EstoreEdge"); - cu.addImport("org.estore.planner.expressions.LogicalExpr"); - cu.addImport("org.estore.planner.expressions.PropertyLookupExpr"); - cu.addImport("org.estore.planner.expressions.function.CountFunctionExpr"); - cu.addImport("org.estore.planner.expressions.function.CountFunctionSingleExpr"); - cu.addImport("org.estore.planner.expressions.VarExpr"); - cu.addImport("org.estore.planner.util.ClassInfo"); - cu.addImport("org.estore.planner.util.NodeProperty"); - cu.addImport("org.estore.planner.util.ClassHelper"); + getLog().info("Transformed " + file.getFileName() + " to " + target.getFileName()); + if (profiling) { + getLog().info( + "Time taken for " + + file.getFileName() + + ": " + + (System.currentTimeMillis() - start) + + " ms"); + } + return true; } } diff --git a/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java b/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java new file mode 100644 index 0000000..614da62 --- /dev/null +++ b/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java @@ -0,0 +1,119 @@ +package org.estore; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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 org.apache.maven.plugin.MojoExecutionException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class CodegenMojoTest { + + @TempDir Path srcDir; + + private Path writeSource(String className, String body) throws IOException { + Path pkg = Files.createDirectories(srcDir.resolve("p")); + String source = + "package p;\n" + + "import org.estore.Estore;\n" + + "import org.estore.compiler.CompileQuery;\n" + + "public class " + + className + + " {\n" + + " Estore db;\n" + + body + + "}\n"; + Path file = pkg.resolve(className + ".java"); + Files.write(file, source.getBytes(StandardCharsets.UTF_8)); + return file; + } + + private static String read(Path file) throws IOException { + return new String(Files.readAllBytes(file), StandardCharsets.UTF_8); + } + + private void runMojo(Path sourceDir) throws MojoExecutionException { + CodegenMojo mojo = new CodegenMojo(); + mojo.sourceDir = sourceDir.toFile(); + mojo.profiling = false; + mojo.execute(); + } + + @Test + void writesTransformedFileForAnnotatedQueries() throws Exception { + writeSource("ATest", " @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); + + runMojo(srcDir); + + Path transformed = srcDir.resolve("p/TransformedATest.java"); + assertTrue(Files.exists(transformed)); + String code = read(transformed); + assertTrue(code.contains("class TransformedATest")); + assertFalse(code.contains("db.query(")); + } + + @Test + void skipsFilesWithoutAnnotatedMethods() throws Exception { + Path original = + writeSource("BTest", " void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); + String before = read(original); + + runMojo(srcDir); + + assertFalse(Files.exists(srcDir.resolve("p/TransformedBTest.java"))); + assertEquals(before, read(original)); + } + + @Test + void isIdempotentWhenRunTwice() throws Exception { + writeSource("CTest", " @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); + + runMojo(srcDir); + String first = read(srcDir.resolve("p/TransformedCTest.java")); + runMojo(srcDir); + + assertEquals(first, read(srcDir.resolve("p/TransformedCTest.java"))); + assertFalse(Files.exists(srcDir.resolve("p/TransformedTransformedCTest.java"))); + } + + @Test + void failsBuildNamingFileForUnsupportedQuery() throws Exception { + Path file = + writeSource( + "DTest", + " @CompileQuery void m() {" + + " db.query(\"MATCH (n) WHERE n.x = 1 RETURN n\"); }\n"); + + MojoExecutionException e = + assertThrows(MojoExecutionException.class, () -> runMojo(srcDir)); + + assertTrue(e.getMessage().contains(file.toString()), e.getMessage()); + assertTrue(e.getMessage().contains("WHERE is not supported"), e.getMessage()); + assertFalse(Files.exists(srcDir.resolve("p/TransformedDTest.java"))); + } + + @Test + void failsBuildForNonLiteralQuery() throws Exception { + writeSource("ETest", " @CompileQuery void m(String q) { db.query(q); }\n"); + + MojoExecutionException e = + assertThrows(MojoExecutionException.class, () -> runMojo(srcDir)); + + assertTrue(e.getMessage().contains("string literal"), e.getMessage()); + } + + @Test + void skipsMissingSourceDirectory() throws Exception { + Path missing = srcDir.resolve("missing"); + + runMojo(missing); + + assertFalse(Files.exists(missing)); + } +} diff --git a/estore/pom.xml b/estore/pom.xml index 532a1d2..52e8516 100644 --- a/estore/pom.xml +++ b/estore/pom.xml @@ -25,6 +25,7 @@ org.junit.jupiter junit-jupiter-engine 5.9.2 + test org.ow2.asm @@ -90,19 +91,6 @@ - - org.apache.maven.plugins @@ -230,6 +218,32 @@ + + + compile-queries + + + + org.estore + estore-maven-plugin + 1.0-SNAPSHOT + + + + codegen + + + + + + + java-9-plus-test-opens diff --git a/estore/src/main/java/org/estore/compiler/CodeGenBuilder.java b/estore/src/main/java/org/estore/compiler/CodeGenBuilder.java index 2cd9f8f..61cb4e9 100644 --- a/estore/src/main/java/org/estore/compiler/CodeGenBuilder.java +++ b/estore/src/main/java/org/estore/compiler/CodeGenBuilder.java @@ -13,7 +13,6 @@ import org.estore.compiler.scan.AllNodeScan; import org.estore.compiler.scan.NodeLabelPropScan; import org.estore.compiler.scan.NodeLabelScan; -import org.estore.compiler.scan.NodePropScan; import org.estore.compiler.scan.TwoNodeRelationScan; import org.estore.compiler.scan.VarLengthRelationScan; import org.estore.planner.LogicalPlanBuilder; @@ -62,6 +61,19 @@ public Object visitOC_Cypher(CypherParser.OC_CypherContext ctx) { return res; } + @Override + public Object visitOC_RegularQuery(CypherParser.OC_RegularQueryContext ctx) { + if (!ctx.oC_Union().isEmpty()) { + throw unsupported("UNION"); + } + return ctx.oC_SingleQuery().accept(this); + } + + @Override + public Object visitOC_MultiPartQuery(CypherParser.OC_MultiPartQueryContext ctx) { + throw unsupported("WITH"); + } + @Override public Object visitOC_SinglePartQuery(CypherParser.OC_SinglePartQueryContext ctx) { String res = ""; @@ -79,11 +91,10 @@ public Object visitOC_SinglePartQuery(CypherParser.OC_SinglePartQueryContext ctx @Override public Object visitOC_ReadingClause(CypherParser.OC_ReadingClauseContext ctx) { - String res = ""; - if (ctx.oC_Match() != null) { - res += (String) ctx.oC_Match().accept(this); + if (ctx.oC_Match() == null) { + throw unsupported("reading clauses other than MATCH"); } - return res; + return ctx.oC_Match().accept(this); } @Override @@ -93,18 +104,19 @@ public Object visitOC_UpdatingClause(CypherParser.OC_UpdatingClauseContext ctx) res += (String) ctx.oC_Create().accept(this); } else if (ctx.oC_Delete() != null) { res += (String) ctx.oC_Delete().accept(this); + } else { + throw unsupported("updating clauses other than CREATE and DELETE"); } return res; } @Override public Object visitOC_Delete(CypherParser.OC_DeleteContext ctx) { - String res = ""; - if (ctx.oC_Expression().size() == 1) { - VarExpr variable = (VarExpr) ctx.oC_Expression(0).accept(this); - res += DeleteRelation.codegen(dbname, assignId++, variable.evaluate(null)); + if (ctx.oC_Expression().size() != 1) { + throw unsupported("DELETE of more than one variable"); } - return res; + VarExpr variable = (VarExpr) ctx.oC_Expression(0).accept(this); + return DeleteRelation.codegen(dbname, assignId++, variable.evaluate(null)); } @Override @@ -121,15 +133,12 @@ public Object visitOC_Create(CypherParser.OC_CreateContext ctx) { && nodePattern.getProperties() != null) { res += CreateLabelPropNode.codegen(dbname, nodePattern); } else { - System.out.println("Not supported yet"); - System.exit(1); + throw unsupported("CREATE of a node without a label"); } } else if (pattern.isMultiLengthRelationPattern()) { - System.out.println("Not supported yet"); - System.exit(1); + throw unsupported("CREATE of a path with more than one relationship"); } else if (pattern.getRelationPattern(0).isVarLengthRelation()) { - System.out.println("Not supported yet"); - System.exit(1); + throw unsupported("CREATE of a variable-length relationship"); } else { NodePattern nodePattern1 = pattern.getNodePattern(0); NodePattern nodePattern2 = pattern.getNodePattern(1); @@ -156,6 +165,17 @@ public Object visitOC_Create(CypherParser.OC_CreateContext ctx) { return res; } + @Override + public Object visitOC_ProjectionBody(CypherParser.OC_ProjectionBodyContext ctx) { + if (ctx.DISTINCT() != null) { + throw unsupported("DISTINCT"); + } + if (ctx.oC_Order() != null || ctx.oC_Skip() != null || ctx.oC_Limit() != null) { + throw unsupported("ORDER BY, SKIP and LIMIT"); + } + return ctx.oC_ProjectionItems().accept(this); + } + @Override public Object visitOC_ProjectionItems(CypherParser.OC_ProjectionItemsContext ctx) { String res = ""; @@ -177,6 +197,12 @@ public Object visitOC_ProjectionItems(CypherParser.OC_ProjectionItemsContext ctx @Override public Object visitOC_Match(CypherParser.OC_MatchContext ctx) { + if (ctx.OPTIONAL() != null) { + throw unsupported("OPTIONAL MATCH"); + } + if (ctx.oC_Where() != null) { + throw unsupported("WHERE"); + } String res = ""; if (ctx.oC_Pattern() != null) { List patterns = (List) ctx.oC_Pattern().accept(this); @@ -191,7 +217,7 @@ public Object visitOC_Match(CypherParser.OC_MatchContext ctx) { res += NodeLabelScan.codegen(dbname, nodePattern); } else if (nodePattern.getLabel() == null && nodePattern.getProperties() != null) { - res += NodePropScan.codegen(dbname, nodePattern); + throw unsupported("MATCH on node properties without a label"); } else if (nodePattern.getLabel() != null && nodePattern.getProperties() != null) { res += NodeLabelPropScan.codegen(dbname, nodePattern); @@ -282,4 +308,8 @@ public Object visitOC_Match(CypherParser.OC_MatchContext ctx) { } return res; } + + private static QueryCompileException unsupported(String construct) { + return new QueryCompileException(construct + " is not supported by the query compiler"); + } } diff --git a/estore/src/main/java/org/estore/compiler/ImplCodeGen.java b/estore/src/main/java/org/estore/compiler/ImplCodeGen.java index a7eac52..7eeb520 100644 --- a/estore/src/main/java/org/estore/compiler/ImplCodeGen.java +++ b/estore/src/main/java/org/estore/compiler/ImplCodeGen.java @@ -1,162 +1,32 @@ package org.estore.compiler; -import com.github.javaparser.JavaParser; -import com.github.javaparser.StaticJavaParser; -import com.github.javaparser.ast.CompilationUnit; -import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; -import com.github.javaparser.ast.expr.BinaryExpr; -import com.github.javaparser.ast.expr.Expression; -import com.github.javaparser.ast.expr.MethodCallExpr; -import com.github.javaparser.ast.visitor.VoidVisitorAdapter; import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; -import org.antlr.v4.runtime.CharStreams; -import org.antlr.v4.runtime.CommonTokenStream; -import org.antlr.v4.runtime.tree.ParseTree; -import org.estore.antlr4.CypherLexer; -import org.estore.antlr4.CypherParser; -import org.estore.planner.LogicalPlan; -import org.estore.planner.LogicalPlanBuilder; +/** + * Command-line entry point that rewrites every literal query call in one Java file. Usage: {@code + * ImplCodeGen [input.java [output.java]]}. + */ public class ImplCodeGen { private static final String DEFAULT_INPUT_FILE_PATH = "src/test/java/org/estore/compiler/CodeGenTest.java"; - private static final JavaParser jparser = new JavaParser(); - - private static String createDFSNodeClass() { - return "private static class DFSNode {" - + "Object currentNode;" - + "int depth;" - + "DFSNode(Object currentNode, int depth) {" - + "this.currentNode = currentNode;" - + "this.depth = depth;}}"; - } - - private static class QueryReplacer extends VoidVisitorAdapter { - - private static final JavaParser jparser = new JavaParser(); - - @Override - public void visit(MethodCallExpr n, Void arg) { - super.visit(n, arg); - if (n.getNameAsString().equals("query")) { - // TODO: check the class of caller - // get the database name - String dbname = n.getScope().get().toString(); - - // get the query string - String queryString; - Expression expr = n.getArgument(0); - if (expr.isStringLiteralExpr()) { - queryString = expr.asStringLiteralExpr().getValue(); - } else if (expr.isBinaryExpr()) { - queryString = resolveStringConcatenation(expr); - if (queryString == null) { - return; - } - // System.out.println("resolved query string: " + queryString); - } else { - // System.out.println("Unsupported query argument type"); - return; - } - - CypherLexer lexer = new CypherLexer(CharStreams.fromString(queryString)); - CommonTokenStream tokenStream = new CommonTokenStream(lexer); - CypherParser parser = new CypherParser(tokenStream); - ParseTree tree = parser.oC_Cypher(); - - LogicalPlanBuilder lbuilder = new LogicalPlanBuilder(null); - LogicalPlan plan = (LogicalPlan) lbuilder.visit(tree); - // System.out.println("logical plan: \n" + plan.toString(0)); - - CodeGenBuilder builder = new CodeGenBuilder(dbname, null); - String transformedCode = (String) builder.visit(tree); - - try { - n.replace(jparser.parseExpression(transformedCode).getResult().get()); - } catch (Exception e) { - System.out.println( - "Failed to parse transformed code for query: " + queryString); - e.printStackTrace(); - } - } - } - - private String resolveStringConcatenation(Expression expr) { - if (expr.isStringLiteralExpr()) { - return expr.asStringLiteralExpr().getValue(); - } else if (expr.isBinaryExpr()) { - BinaryExpr binaryExpr = expr.asBinaryExpr(); - String left = resolveStringConcatenation(binaryExpr.getLeft()); - String right = resolveStringConcatenation(binaryExpr.getRight()); - if (left == null || right == null) { - return null; - } - return left + right; - } else { - // TODO: Handle other types of expressions (e.g., variable references) - return null; - } - } - } - - private static void addImports(CompilationUnit cu) { - cu.addImport("java.util.function.Supplier"); - cu.addImport("java.util.Arrays"); - cu.addImport("java.util.List"); - cu.addImport("java.util.ArrayList"); - cu.addImport("java.util.HashMap"); - cu.addImport("java.util.HashSet"); - cu.addImport("java.util.Map"); - cu.addImport("java.util.Set"); - cu.addImport("java.util.Stack"); - cu.addImport("java.lang.reflect.Array"); - cu.addImport("java.lang.reflect.Field"); - cu.addImport("org.estore.compiler.Util"); - cu.addImport("org.estore.EstoreEdge"); - cu.addImport("org.estore.planner.expressions.LogicalExpr"); - cu.addImport("org.estore.planner.expressions.PropertyLookupExpr"); - cu.addImport("org.estore.planner.expressions.function.CountFunctionExpr"); - cu.addImport("org.estore.planner.expressions.function.CountFunctionSingleExpr"); - cu.addImport("org.estore.planner.expressions.VarExpr"); - cu.addImport("org.estore.planner.util.ClassInfo"); - cu.addImport("org.estore.planner.util.NodeProperty"); - cu.addImport("org.estore.planner.util.ClassHelper"); - } - private static String getDefaultOutputFilePath(String inputFilePath) { File inputFile = new File(inputFilePath); String parentDir = inputFile.getParent(); String fileName = inputFile.getName(); - return Paths.get(parentDir, "Transformed" + fileName).toString(); + return Paths.get(parentDir, QueryRewriter.TRANSFORMED_PREFIX + fileName).toString(); } public static void main(String[] args) throws IOException { String inputFilePath = args.length > 0 ? args[0] : DEFAULT_INPUT_FILE_PATH; String outputFilePath = args.length > 1 ? args[1] : getDefaultOutputFilePath(inputFilePath); - CompilationUnit cu = StaticJavaParser.parse(Files.newInputStream(Paths.get(inputFilePath))); - - addImports(cu); - cu.accept(new QueryReplacer(), null); - - // change the class name of the generated file - cu.findAll(ClassOrInterfaceDeclaration.class).stream() - .filter( - c -> - c.getParentNode().isPresent() - && c.getParentNode().get() instanceof CompilationUnit) - .forEach( - c -> { - c.setName("Transformed" + c.getNameAsString()); - c.addMember( - jparser.parseBodyDeclaration(createDFSNodeClass()) - .getResult() - .get()); - }); - - Files.write(Paths.get(outputFilePath), cu.toString().getBytes()); + String source = + new String(Files.readAllBytes(Paths.get(inputFilePath)), StandardCharsets.UTF_8); + String rewritten = QueryRewriter.rewriteAllQueries(source); + Files.write(Paths.get(outputFilePath), rewritten.getBytes(StandardCharsets.UTF_8)); } } diff --git a/estore/src/main/java/org/estore/compiler/QueryCompileException.java b/estore/src/main/java/org/estore/compiler/QueryCompileException.java new file mode 100644 index 0000000..d3bc7ee --- /dev/null +++ b/estore/src/main/java/org/estore/compiler/QueryCompileException.java @@ -0,0 +1,13 @@ +package org.estore.compiler; + +/** Thrown when a Cypher query cannot be compiled into Java code. */ +public class QueryCompileException extends RuntimeException { + + public QueryCompileException(String message) { + super(message); + } + + public QueryCompileException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/estore/src/main/java/org/estore/compiler/QueryRewriter.java b/estore/src/main/java/org/estore/compiler/QueryRewriter.java new file mode 100644 index 0000000..5f4bf23 --- /dev/null +++ b/estore/src/main/java/org/estore/compiler/QueryRewriter.java @@ -0,0 +1,241 @@ +package org.estore.compiler; + +import com.github.javaparser.ParseProblemException; +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.Node; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.ast.expr.BinaryExpr; +import com.github.javaparser.ast.expr.Expression; +import com.github.javaparser.ast.expr.MethodCallExpr; +import java.util.List; +import java.util.Optional; +import org.antlr.v4.runtime.BaseErrorListener; +import org.antlr.v4.runtime.CharStreams; +import org.antlr.v4.runtime.CommonTokenStream; +import org.antlr.v4.runtime.RecognitionException; +import org.antlr.v4.runtime.Recognizer; +import org.antlr.v4.runtime.tree.ParseTree; +import org.estore.antlr4.CypherLexer; +import org.estore.antlr4.CypherParser; + +/** + * Rewrites {@code db.query("...")} calls in Java source into generated Java code that evaluates the + * Cypher query directly, and renames the rewritten top-level classes with {@link + * #TRANSFORMED_PREFIX}. + */ +public final class QueryRewriter { + + public static final String TRANSFORMED_PREFIX = "Transformed"; + + private static final String ANNOTATION = "CompileQuery"; + + private static final String[] IMPORTS = { + "java.util.function.Supplier", + "java.util.Arrays", + "java.util.List", + "java.util.ArrayList", + "java.util.HashMap", + "java.util.HashSet", + "java.util.Map", + "java.util.Set", + "java.util.Stack", + "java.lang.reflect.Array", + "java.lang.reflect.Field", + "org.estore.compiler.Util", + "org.estore.EstoreEdge", + "org.estore.planner.expressions.LogicalExpr", + "org.estore.planner.expressions.PropertyLookupExpr", + "org.estore.planner.expressions.function.CountFunctionExpr", + "org.estore.planner.expressions.function.CountFunctionSingleExpr", + "org.estore.planner.expressions.VarExpr", + "org.estore.planner.util.ClassInfo", + "org.estore.planner.util.NodeProperty", + "org.estore.planner.util.ClassHelper", + }; + + private static final String DFS_NODE_CLASS = + "private static class DFSNode {" + + "Object currentNode;" + + "int depth;" + + "DFSNode(Object currentNode, int depth) {" + + "this.currentNode = currentNode;" + + "this.depth = depth;}}"; + + private QueryRewriter() {} + + /** + * Rewrites every query call inside methods annotated with {@link CompileQuery}. + * + *

Every query in an annotated method must be compiled, so anything that cannot be is an + * error rather than being left to the interpreter. + * + * @param source Java source of a compilation unit. + * @return the rewritten source, or empty if the source has no annotated method. + * @throws QueryCompileException if the source does not parse, an annotated method has no query + * call, passes a query that is not a string literal, or contains a query the compiler does + * not support. + */ + public static Optional rewriteAnnotatedMethods(String source) { + CompilationUnit cu = parseSource(source); + List methods = + cu.findAll(MethodDeclaration.class, md -> md.isAnnotationPresent(ANNOTATION)); + if (methods.isEmpty()) { + return Optional.empty(); + } + for (MethodDeclaration md : methods) { + md.getAnnotationByName(ANNOTATION).ifPresent(Node::remove); + if (rewriteQueryCalls(md, true) == 0) { + throw new QueryCompileException( + "@CompileQuery method " + + md.getNameAsString() + + " contains no db.query(\"...\") call to compile"); + } + } + return Optional.of(finish(cu)); + } + + /** + * Rewrites every query call whose argument is a string literal, or a concatenation of string + * literals. Other query calls are left in place. + * + * @param source Java source of a compilation unit. + * @return the rewritten source. + * @throws QueryCompileException if the source does not parse or a literal query cannot be + * compiled. + */ + public static String rewriteAllQueries(String source) { + CompilationUnit cu = parseSource(source); + rewriteQueryCalls(cu, false); + return finish(cu); + } + + /** + * Compiles one Cypher query into a Java expression of type {@code Table}. + * + * @param dbName the Java expression that refers to the {@code Estore} instance. + * @param cypher the Cypher query. + * @return the generated Java expression. + * @throws QueryCompileException if the query has a syntax error or uses a construct the + * compiler does not support. + */ + public static String compileCypher(String dbName, String cypher) { + try { + CypherLexer lexer = new CypherLexer(CharStreams.fromString(cypher)); + CypherParser parser = new CypherParser(new CommonTokenStream(lexer)); + lexer.removeErrorListeners(); + lexer.addErrorListener(ThrowingErrorListener.INSTANCE); + parser.removeErrorListeners(); + parser.addErrorListener(ThrowingErrorListener.INSTANCE); + ParseTree tree = parser.oC_Cypher(); + Object code = new CodeGenBuilder(dbName, null).visit(tree); + if (!(code instanceof String)) { + throw new QueryCompileException("code generation produced no Java code"); + } + return (String) code; + } catch (RuntimeException e) { + throw new QueryCompileException( + "Cannot compile Cypher query `" + cypher + "`: " + e.getMessage(), e); + } + } + + private static CompilationUnit parseSource(String source) { + try { + return StaticJavaParser.parse(source); + } catch (ParseProblemException e) { + throw new QueryCompileException("Cannot parse Java source: " + e.getMessage(), e); + } + } + + private static int rewriteQueryCalls(Node root, boolean strict) { + int rewritten = 0; + for (MethodCallExpr call : + root.findAll(MethodCallExpr.class, c -> c.getNameAsString().equals("query"))) { + Optional cypher = literalQuery(call); + if (!cypher.isPresent()) { + if (strict) { + throw new QueryCompileException( + describe(call) + + " must have the form db.query(\"...\") with a string" + + " literal (or concatenation of literals) argument"); + } + continue; + } + String dbName = call.getScope().get().toString(); + String code; + try { + code = compileCypher(dbName, cypher.get()); + } catch (QueryCompileException e) { + throw new QueryCompileException(describe(call) + ": " + e.getMessage(), e); + } + call.replace(parseGeneratedExpression(call, code)); + rewritten++; + } + return rewritten; + } + + private static Optional literalQuery(MethodCallExpr call) { + if (!call.getScope().isPresent() || call.getArguments().size() != 1) { + return Optional.empty(); + } + return concatenatedLiteral(call.getArgument(0)); + } + + private static Optional concatenatedLiteral(Expression expr) { + if (expr.isStringLiteralExpr()) { + return Optional.of(expr.asStringLiteralExpr().asString()); + } + if (expr.isBinaryExpr() && expr.asBinaryExpr().getOperator() == BinaryExpr.Operator.PLUS) { + Optional left = concatenatedLiteral(expr.asBinaryExpr().getLeft()); + Optional right = concatenatedLiteral(expr.asBinaryExpr().getRight()); + if (left.isPresent() && right.isPresent()) { + return Optional.of(left.get() + right.get()); + } + } + return Optional.empty(); + } + + private static Expression parseGeneratedExpression(MethodCallExpr call, String code) { + try { + return StaticJavaParser.parseExpression(code); + } catch (ParseProblemException e) { + throw new QueryCompileException( + describe(call) + ": generated Java code does not parse: " + e.getMessage(), e); + } + } + + private static String describe(MethodCallExpr call) { + String line = call.getBegin().map(p -> " at line " + p.line).orElse(""); + return "query call" + line; + } + + private static String finish(CompilationUnit cu) { + for (String imp : IMPORTS) { + cu.addImport(imp); + } + for (ClassOrInterfaceDeclaration c : cu.findAll(ClassOrInterfaceDeclaration.class)) { + if (c.getParentNode().filter(p -> p instanceof CompilationUnit).isPresent()) { + c.setName(TRANSFORMED_PREFIX + c.getNameAsString()); + c.addMember(StaticJavaParser.parseBodyDeclaration(DFS_NODE_CLASS)); + } + } + return cu.toString(); + } + + private static final class ThrowingErrorListener extends BaseErrorListener { + static final ThrowingErrorListener INSTANCE = new ThrowingErrorListener(); + + @Override + public void syntaxError( + Recognizer recognizer, + Object offendingSymbol, + int line, + int charPositionInLine, + String msg, + RecognitionException e) { + throw new QueryCompileException( + "syntax error at " + line + ":" + charPositionInLine + ": " + msg); + } + } +} diff --git a/estore/src/main/java/org/estore/compiler/scan/NodePropScan.java b/estore/src/main/java/org/estore/compiler/scan/NodePropScan.java deleted file mode 100644 index 9951d53..0000000 --- a/estore/src/main/java/org/estore/compiler/scan/NodePropScan.java +++ /dev/null @@ -1,66 +0,0 @@ -package org.estore.compiler.scan; - -import java.util.List; -import org.estore.planner.pattern.NodePattern; -import org.estore.planner.util.NodeProperty; - -/** Code generation for node property scans. */ -public final class NodePropScan { - - private NodePropScan() {} - - /** - * Generates Java source for scanning nodes by property list. - * - * @param dbname database name expression - * @param nodePattern node pattern - * @return generated code fragment - */ - public static String codegen(final String dbname, final NodePattern nodePattern) { - String res = ""; - int id = nodePattern.getID(); - String variable = nodePattern.getVariable(); - List referrerProperties = nodePattern.getProperties(); - variable = variable == null ? "_" + "NodeLabelPropScan" + id : variable; - - if (referrerProperties != null) { - res += "referrerProperties = new ArrayList();\n"; - for (NodeProperty prop : referrerProperties) { - Object value = prop.getValue(); - if (value instanceof String) { - res += - "referrerProperties.add(new NodeProperty(String.class, \"" - + value - + "\", \"" - + prop.getName() - + "\"));\n"; - } else if (value instanceof Long) { - res += - "referrerProperties.add(new NodeProperty(Long.TYPE, " - + value - + "L, \"" - + prop.getName() - + "\"));\n"; - } else if (value instanceof Double) { - res += - "referrerProperties.add(new NodeProperty(Double.TYPE, " - + value - + ", \"" - + prop.getName() - + "\"));\n"; - } else if (value instanceof Boolean) { - res += - "referrerProperties.add(new NodeProperty(Boolean.TYPE, " - + value - + ", \"" - + prop.getName() - + "\"));\n"; - } - } - } - System.out.println("Not supported yet: NodePropScan"); - System.exit(1); - - return res; - } -} diff --git a/estore/src/main/java/org/estore/compiler/scan/VarLengthRelationScan.java b/estore/src/main/java/org/estore/compiler/scan/VarLengthRelationScan.java index 744002e..01104c9 100644 --- a/estore/src/main/java/org/estore/compiler/scan/VarLengthRelationScan.java +++ b/estore/src/main/java/org/estore/compiler/scan/VarLengthRelationScan.java @@ -1,6 +1,7 @@ package org.estore.compiler.scan; import java.util.List; +import org.estore.compiler.QueryCompileException; import org.estore.planner.pattern.NodePattern; import org.estore.planner.pattern.RelationPattern; import org.estore.planner.util.NodeProperty; @@ -163,9 +164,9 @@ public static String codegen( refereeLabel, pRange); } else { - // Non-DFS case not implemented yet - System.out.println("Non-DFS case VarLengthRelation not supported yet"); - System.exit(1); + throw new QueryCompileException( + "variable-length relationships from a node without a label are not supported" + + " by the query compiler"); } return res; diff --git a/estore/src/test/java/org/estore/compiler/CompiledQueryTest.java b/estore/src/test/java/org/estore/compiler/CompiledQueryTest.java new file mode 100644 index 0000000..17635cf --- /dev/null +++ b/estore/src/test/java/org/estore/compiler/CompiledQueryTest.java @@ -0,0 +1,179 @@ +package org.estore.compiler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; +import org.estore.Estore; +import org.estore.EstoreException; +import org.estore.EstoreOptions; +import org.estore.example.A; +import org.estore.planner.util.Table; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Checks that each query shape the compiler supports returns the same result when compiled as when + * interpreted. With {@code -Pcompile-queries}, the codegen plugin generates {@code + * TransformedCompiledQueryTest}, where every {@code estore.query(...)} call in a {@link + * CompileQuery} method runs as generated Java code; {@link #interpret} is not annotated, so it + * always runs the interpreter. + */ +public class CompiledQueryTest { + + private Estore estore; + private Estore reference; + + @BeforeEach + public void setUp() throws EstoreException { + EstoreOptions options = new EstoreOptions().profile(false); + estore = new Estore(CompiledQueryTest.class.getName(), options); + estore.insert(A.class); + reference = new Estore(CompiledQueryTest.class.getName() + ".reference", options); + reference.insert(A.class); + } + + private static Table interpret(Estore db, String cypher) { + return db.query(cypher); + } + + private static Map, Long> rowCounts(Table table) { + Map, Long> counts = new HashMap<>(); + for (int i = 0; i < table.getSize(); i++) { + counts.merge(table.getAtIndex(i), 1L, Long::sum); + } + return counts; + } + + private void assertSameAsInterpreted(String cypher, Table compiled) { + Table interpreted = interpret(estore, cypher); + assertTrue(interpreted.getSize() > 0, "interpreter returned no rows for " + cypher); + assertEquals(interpreted.keySet(), compiled.keySet(), cypher); + assertEquals(rowCounts(interpreted), rowCounts(compiled), cypher); + } + + private void assertSameGraphAsReference() { + for (String cypher : + new String[] {"MATCH (n) RETURN COUNT(n)", "MATCH (n)-[]->(m) RETURN COUNT(n)"}) { + assertEquals( + rowCounts(interpret(reference, cypher)), + rowCounts(interpret(estore, cypher)), + cypher); + } + } + + @CompileQuery + @Test + void allNodeScan() { + assertSameAsInterpreted("MATCH (n) RETURN n", estore.query("MATCH (n) RETURN n")); + } + + @CompileQuery + @Test + void nodeLabelScan() { + assertSameAsInterpreted( + "MATCH (n:`org.estore.example.B`) RETURN n", + estore.query("MATCH (n:`org.estore.example.B`) RETURN n")); + } + + @CompileQuery + @Test + void nodeLabelPropScan() { + assertSameAsInterpreted( + "MATCH (n:`org.estore.example.A` {field1:10}) RETURN n", + estore.query("MATCH (n:`org.estore.example.A` {field1:10}) RETURN n")); + } + + @CompileQuery + @Test + void twoNodeRelationScan() { + assertSameAsInterpreted( + "MATCH (n)-[]->(m) RETURN n, m", estore.query("MATCH (n)-[]->(m) RETURN n, m")); + } + + @CompileQuery + @Test + void multiHopJoin() { + assertSameAsInterpreted( + "MATCH (n:`org.estore.example.A`)-[]->()-[]->(l) RETURN n, l", + estore.query("MATCH (n:`org.estore.example.A`)-[]->()-[]->(l) RETURN n, l")); + } + + @CompileQuery + @Test + void varLengthRelationScan() { + assertSameAsInterpreted( + "MATCH (n:`org.estore.example.A`)-[*1..2]->(m) RETURN m", + estore.query("MATCH (n:`org.estore.example.A`)-[*1..2]->(m) RETURN m")); + } + + @CompileQuery + @Test + void varLengthMultiHopJoin() { + assertSameAsInterpreted( + "MATCH (n:`org.estore.example.B`)-[*1..2]->()-[]->(l) RETURN l", + estore.query("MATCH (n:`org.estore.example.B`)-[*1..2]->()-[]->(l) RETURN l")); + } + + @CompileQuery + @Test + void propertyProjectionWithAlias() { + assertSameAsInterpreted( + "MATCH (n) RETURN n.field1 AS f, n.field2 AS s", + estore.query("MATCH (n) RETURN n.field1 AS f, n.field2 AS s")); + } + + @CompileQuery + @Test + void countProjection() { + assertSameAsInterpreted( + "MATCH (n)-[]->(m) RETURN COUNT(m)", + estore.query("MATCH (n)-[]->(m) RETURN COUNT(m)")); + } + + @CompileQuery + @Test + void createLabelNode() { + Table compiled = estore.query("CREATE (n:`org.estore.example.D`) RETURN COUNT(n)"); + Table interpreted = + interpret(reference, "CREATE (n:`org.estore.example.D`) RETURN COUNT(n)"); + + assertEquals(rowCounts(interpreted), rowCounts(compiled)); + assertSameGraphAsReference(); + } + + @CompileQuery + @Test + void deleteRelation() { + Table compiled = + estore.query( + "MATCH (n:`org.estore.example.A`)-[r:b]->(m) DELETE r RETURN COUNT(n)"); + Table interpreted = + interpret( + reference, + "MATCH (n:`org.estore.example.A`)-[r:b]->(m) DELETE r RETURN COUNT(n)"); + + assertEquals(rowCounts(interpreted), rowCounts(compiled)); + assertSameGraphAsReference(); + } + + @CompileQuery + @Test + void matchThenCreateRelation() { + Table compiled = + estore.query( + "MATCH (c:`org.estore.example.C` {field1:10})," + + " (d:`org.estore.example.D` {field1:10})" + + " CREATE (c)-[r:d]->(d) RETURN COUNT(r)"); + Table interpreted = + interpret( + reference, + "MATCH (c:`org.estore.example.C` {field1:10})," + + " (d:`org.estore.example.D` {field1:10})" + + " CREATE (c)-[r:d]->(d) RETURN COUNT(r)"); + + assertEquals(rowCounts(interpreted), rowCounts(compiled)); + assertSameGraphAsReference(); + } +} diff --git a/estore/src/test/java/org/estore/compiler/QueryRewriterTest.java b/estore/src/test/java/org/estore/compiler/QueryRewriterTest.java new file mode 100644 index 0000000..023f048 --- /dev/null +++ b/estore/src/test/java/org/estore/compiler/QueryRewriterTest.java @@ -0,0 +1,207 @@ +package org.estore.compiler; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +public class QueryRewriterTest { + + private static String source(String... methods) { + return "package p;\n" + + "import org.estore.Estore;\n" + + "import org.estore.compiler.CompileQuery;\n" + + "public class Q {\n" + + " Estore db;\n" + + String.join("\n", methods) + + "}\n"; + } + + private static String rewriteAnnotated(String source) { + Optional rewritten = QueryRewriter.rewriteAnnotatedMethods(source); + assertTrue(rewritten.isPresent()); + return rewritten.get(); + } + + private static String annotatedMethodBody(String body) { + return " @CompileQuery\n void m() {\n " + body + "\n }\n"; + } + + @Test + void rewritesOnlyAnnotatedMethods() { + String rewritten = + rewriteAnnotated( + source( + annotatedMethodBody("db.query(\"MATCH (n) RETURN n\");"), + " void plain() { db.query(\"MATCH (m) RETURN m\"); }\n")); + + assertTrue(rewritten.contains("class TransformedQ")); + assertTrue(rewritten.contains("((Supplier)")); + assertTrue(rewritten.contains("db.query(\"MATCH (m) RETURN m\")")); + assertFalse(rewritten.contains("db.query(\"MATCH (n) RETURN n\")")); + assertFalse(rewritten.contains("@CompileQuery")); + } + + @Test + void returnsEmptyWithoutAnnotatedMethods() { + String source = source(" void plain() { db.query(\"MATCH (n) RETURN n\"); }\n"); + + assertFalse(QueryRewriter.rewriteAnnotatedMethods(source).isPresent()); + } + + @Test + void joinsConcatenatedLiterals() { + String rewritten = + rewriteAnnotated( + source( + annotatedMethodBody( + "db.query(\"MATCH (n:`org.estore.example.A`) \"" + + " + \"RETURN n\");"))); + + assertTrue(rewritten.contains("getLabelObjectMap().get(\"org.estore.example.A\")")); + } + + @Test + void usesQueryScopeAsDatabaseName() { + String rewritten = + rewriteAnnotated( + source( + " Estore other;\n", + annotatedMethodBody("other.query(\"MATCH (n) RETURN n\");"))); + + assertTrue(rewritten.contains("other.getDataStore()")); + assertFalse(rewritten.contains("db.getDataStore()")); + } + + @Test + void rejectsAnnotatedMethodWithoutQuery() { + QueryCompileException e = + assertThrows( + QueryCompileException.class, + () -> + QueryRewriter.rewriteAnnotatedMethods( + source(annotatedMethodBody("int x = 1;")))); + + assertTrue(e.getMessage().contains("method m")); + } + + @Test + void rejectsNonLiteralQueryInAnnotatedMethod() { + String body = "String q = \"MATCH (n) RETURN n\"; db.query(q);"; + + QueryCompileException e = + assertThrows( + QueryCompileException.class, + () -> + QueryRewriter.rewriteAnnotatedMethods( + source(annotatedMethodBody(body)))); + + assertTrue(e.getMessage().contains("string literal")); + } + + @Test + void rejectsMixedConcatenationInAnnotatedMethod() { + String body = "String s = \"n\"; db.query(\"MATCH (n) RETURN \" + s);"; + + assertThrows( + QueryCompileException.class, + () -> QueryRewriter.rewriteAnnotatedMethods(source(annotatedMethodBody(body)))); + } + + @Test + void reportsLineAndQueryForUnsupportedCypher() { + String body = "db.query(\"MATCH (n) WHERE n.field1 = 1 RETURN n\");"; + + QueryCompileException e = + assertThrows( + QueryCompileException.class, + () -> + QueryRewriter.rewriteAnnotatedMethods( + source(annotatedMethodBody(body)))); + + assertTrue(e.getMessage().contains("line 8"), e.getMessage()); + assertTrue(e.getMessage().contains("MATCH (n) WHERE n.field1 = 1 RETURN n")); + assertTrue(e.getMessage().contains("WHERE is not supported")); + } + + @Test + void rejectsCypherSyntaxError() { + QueryCompileException e = + assertThrows( + QueryCompileException.class, + () -> QueryRewriter.compileCypher("db", "MATCH (n RETURN n")); + + assertTrue(e.getMessage().contains("syntax error"), e.getMessage()); + } + + @Test + void rejectsUnparseableJavaSource() { + QueryCompileException e = + assertThrows( + QueryCompileException.class, + () -> QueryRewriter.rewriteAnnotatedMethods("class {")); + + assertTrue(e.getMessage().contains("Cannot parse Java source")); + } + + @Test + void rewriteAllLeavesNonLiteralQueriesInPlace() { + String rewritten = + QueryRewriter.rewriteAllQueries( + source( + " void a(String q) { db.query(q); }\n", + " void b() { db.query(\"MATCH (n) RETURN n\"); }\n")); + + assertTrue(rewritten.contains("db.query(q)")); + assertFalse(rewritten.contains("db.query(\"MATCH (n) RETURN n\")")); + } + + @Test + void rewriteAllRejectsUnsupportedLiteralQuery() { + String source = source(" void b() { db.query(\"MATCH (n) RETURN n LIMIT 1\"); }\n"); + + assertThrows(QueryCompileException.class, () -> QueryRewriter.rewriteAllQueries(source)); + } + + @Test + void codegenRejectsUnsupportedConstructs() { + String[][] cases = { + {"MATCH (n) WHERE n.field1 = 5 RETURN n", "WHERE"}, + {"OPTIONAL MATCH (n) RETURN n", "OPTIONAL MATCH"}, + {"MATCH (n) RETURN DISTINCT n", "DISTINCT"}, + {"MATCH (n) RETURN n ORDER BY n.field1", "ORDER BY"}, + {"MATCH (n) RETURN n SKIP 1", "SKIP"}, + {"MATCH (n) RETURN n LIMIT 1", "LIMIT"}, + {"MATCH (n) WITH n RETURN n", "WITH"}, + {"MATCH (n) RETURN n UNION MATCH (m) RETURN m", "UNION"}, + {"UNWIND [1, 2] AS x RETURN x", "reading clauses other than MATCH"}, + {"MATCH (n) SET n.field1 = 1 RETURN n", "updating clauses"}, + {"MATCH (n)-[r]->(m) DELETE r, n RETURN n", "DELETE of more than one"}, + {"MATCH (n {field1:10}) RETURN n", "MATCH on node properties without a label"}, + {"MATCH (n)-[*1..2]->(m) RETURN m", "variable-length relationships from a node"}, + {"CREATE (n) RETURN n", "CREATE of a node without a label"}, + {"CREATE (a:`A`)-[:e]->(b:`B`)-[:e]->(c:`C`) RETURN a", "more than one relationship"}, + {"CREATE (a:`A`)-[:e*1..2]->(b:`B`) RETURN a", "variable-length"}, + }; + assertAll( + Arrays.stream(cases) + .map( + c -> + () -> { + QueryCompileException e = + assertThrows( + QueryCompileException.class, + () -> + QueryRewriter.compileCypher( + "db", c[0]), + c[0]); + assertTrue( + e.getMessage().contains(c[1]), + c[0] + " -> " + e.getMessage()); + })); + } +} From 1378e8cbe9acc69b6f9679e8afec0a23689029ad Mon Sep 17 00:00:00 2001 From: zijian yi Date: Mon, 19 Oct 2026 13:23:53 -0500 Subject: [PATCH 2/5] Generate compiled-query tests under target/generated-test-sources The codegen Mojo now writes Transformed*Test.java into target/generated-test-sources/compiled-queries (same package layout) and registers that directory as a test source root, instead of writing next to the original sources in src/test. Stale output is cleared on each run, and mvn clean removes it. Drop the .gitignore rule for in-tree Transformed*Test.java so leftover copies from the old layout show up in git status rather than causing duplicate-class compile errors. --- .gitignore | 3 - README.md | 7 +- .../src/main/java/org/estore/CodegenMojo.java | 40 +++++++++- .../test/java/org/estore/CodegenMojoTest.java | 79 +++++++++++++------ estore/pom.xml | 7 +- 5 files changed, 102 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index c867e0b..fcbbe0f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,9 +16,6 @@ eval/libs/*.jar # data data/ -# Codegen files -Transformed*Test.java - # Local agent instructions (not committed) AGENTS.md .cursor/ diff --git a/README.md b/README.md index 44fa8f1..d2ffffd 100644 --- a/README.md +++ b/README.md @@ -163,9 +163,10 @@ The JaCoCo code coverage report is generated at `estore/target/site/jacoco/index #### Run Tests with Compiled Queries Test methods annotated with `@CompileQuery` can also run with their Cypher queries compiled -to Java. The `compile-queries` profile writes a `TransformedTest.java` next to each such -test, and the build fails if a query cannot be compiled. The codegen plugin depends on `estore`, -so install both first, then build the `estore` POM directly: +to Java. The `compile-queries` profile generates a `TransformedTest.java` for each such +test under `estore/target/generated-test-sources/compiled-queries`, and the build fails if a +query cannot be compiled. The codegen plugin depends on `estore`, so install both first, then +build the `estore` POM directly: ```bash mvn -pl estore -am install -DskipTests && mvn -pl estore-maven-plugin install diff --git a/estore-maven-plugin/src/main/java/org/estore/CodegenMojo.java b/estore-maven-plugin/src/main/java/org/estore/CodegenMojo.java index 9a039ca..6c981a6 100644 --- a/estore-maven-plugin/src/main/java/org/estore/CodegenMojo.java +++ b/estore-maven-plugin/src/main/java/org/estore/CodegenMojo.java @@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -14,12 +15,14 @@ import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.project.MavenProject; import org.estore.compiler.QueryCompileException; import org.estore.compiler.QueryRewriter; /** * Rewrites the queries in every {@code @CompileQuery} method under {@code sourceDir} into Java - * code, writing each rewritten file next to its source as {@code Transformed.java}. + * code. Each rewritten file is written to the same package under {@code outputDir} as {@code + * Transformed.java}, and {@code outputDir} is added as a test source root. */ @Mojo(name = "codegen", defaultPhase = LifecyclePhase.GENERATE_TEST_SOURCES) public class CodegenMojo extends AbstractMojo { @@ -27,6 +30,14 @@ public class CodegenMojo extends AbstractMojo { @Parameter(property = "sourceDir", defaultValue = "${project.build.testSourceDirectory}") File sourceDir; + @Parameter( + property = "outputDir", + defaultValue = "${project.build.directory}/generated-test-sources/compiled-queries") + File outputDir; + + @Parameter(defaultValue = "${project}", readonly = true, required = true) + MavenProject project; + @Parameter(property = "profiling", defaultValue = "true") boolean profiling; @@ -37,18 +48,38 @@ public void execute() throws MojoExecutionException { return; } long start = System.currentTimeMillis(); + deleteOutputDir(); int rewritten = 0; for (Path file : javaFiles()) { if (rewriteFile(file)) { rewritten++; } } - getLog().info("Rewrote @CompileQuery methods in " + rewritten + " file(s)"); + project.addTestCompileSourceRoot(outputDir.getPath()); + getLog().info( + "Rewrote @CompileQuery methods in " + + rewritten + + " file(s) into " + + outputDir); if (profiling) { getLog().info("Total time taken: " + (System.currentTimeMillis() - start) + " ms"); } } + private void deleteOutputDir() throws MojoExecutionException { + Path dir = outputDir.toPath(); + if (!Files.exists(dir)) { + return; + } + try (Stream paths = Files.walk(dir)) { + for (Path p : paths.sorted(Comparator.reverseOrder()).collect(Collectors.toList())) { + Files.delete(p); + } + } catch (IOException e) { + throw new MojoExecutionException("Cannot clear stale output in " + outputDir, e); + } + } + private List javaFiles() throws MojoExecutionException { try (Stream paths = Files.walk(sourceDir.toPath())) { return paths.filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList()); @@ -72,8 +103,11 @@ private boolean rewriteFile(Path file) throws MojoExecutionException { if (!rewritten.isPresent()) { return false; } - Path target = file.resolveSibling(QueryRewriter.TRANSFORMED_PREFIX + file.getFileName()); + Path packageDir = + outputDir.toPath().resolve(sourceDir.toPath().relativize(file.getParent())); + Path target = packageDir.resolve(QueryRewriter.TRANSFORMED_PREFIX + file.getFileName()); try { + Files.createDirectories(packageDir); Files.write(target, rewritten.get().getBytes(StandardCharsets.UTF_8)); } catch (IOException e) { throw new MojoExecutionException("Cannot write " + target, e); diff --git a/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java b/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java index 614da62..7e05a5f 100644 --- a/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java +++ b/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java @@ -10,12 +10,25 @@ import java.nio.file.Files; import java.nio.file.Path; import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; public class CodegenMojoTest { - @TempDir Path srcDir; + @TempDir Path root; + + private Path srcDir; + private Path outDir; + private MavenProject project; + + @BeforeEach + void setUp() { + srcDir = root.resolve("src"); + outDir = root.resolve("target/generated-test-sources/compiled-queries"); + project = new MavenProject(); + } private Path writeSource(String className, String body) throws IOException { Path pkg = Files.createDirectories(srcDir.resolve("p")); @@ -38,24 +51,36 @@ private static String read(Path file) throws IOException { return new String(Files.readAllBytes(file), StandardCharsets.UTF_8); } - private void runMojo(Path sourceDir) throws MojoExecutionException { + private void runMojo() throws MojoExecutionException { CodegenMojo mojo = new CodegenMojo(); - mojo.sourceDir = sourceDir.toFile(); + mojo.sourceDir = srcDir.toFile(); + mojo.outputDir = outDir.toFile(); + mojo.project = project; mojo.profiling = false; mojo.execute(); } @Test - void writesTransformedFileForAnnotatedQueries() throws Exception { + void writesTransformedFileIntoOutputPackage() throws Exception { writeSource("ATest", " @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); - runMojo(srcDir); + runMojo(); - Path transformed = srcDir.resolve("p/TransformedATest.java"); + Path transformed = outDir.resolve("p/TransformedATest.java"); assertTrue(Files.exists(transformed)); String code = read(transformed); assertTrue(code.contains("class TransformedATest")); assertFalse(code.contains("db.query(")); + assertFalse(Files.exists(srcDir.resolve("p/TransformedATest.java"))); + } + + @Test + void registersOutputDirAsTestSourceRoot() throws Exception { + writeSource("ATest", " @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); + + runMojo(); + + assertTrue(project.getTestCompileSourceRoots().contains(outDir.toString())); } @Test @@ -64,9 +89,9 @@ void skipsFilesWithoutAnnotatedMethods() throws Exception { writeSource("BTest", " void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); String before = read(original); - runMojo(srcDir); + runMojo(); - assertFalse(Files.exists(srcDir.resolve("p/TransformedBTest.java"))); + assertFalse(Files.exists(outDir.resolve("p/TransformedBTest.java"))); assertEquals(before, read(original)); } @@ -74,12 +99,25 @@ void skipsFilesWithoutAnnotatedMethods() throws Exception { void isIdempotentWhenRunTwice() throws Exception { writeSource("CTest", " @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); - runMojo(srcDir); - String first = read(srcDir.resolve("p/TransformedCTest.java")); - runMojo(srcDir); + runMojo(); + String first = read(outDir.resolve("p/TransformedCTest.java")); + runMojo(); + + assertEquals(first, read(outDir.resolve("p/TransformedCTest.java"))); + } + + @Test + void removesOutputForSourcesThatNoLongerExist() throws Exception { + Path source = + writeSource( + "FTest", + " @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); + runMojo(); + Files.delete(source); - assertEquals(first, read(srcDir.resolve("p/TransformedCTest.java"))); - assertFalse(Files.exists(srcDir.resolve("p/TransformedTransformedCTest.java"))); + runMojo(); + + assertFalse(Files.exists(outDir.resolve("p/TransformedFTest.java"))); } @Test @@ -90,30 +128,27 @@ void failsBuildNamingFileForUnsupportedQuery() throws Exception { " @CompileQuery void m() {" + " db.query(\"MATCH (n) WHERE n.x = 1 RETURN n\"); }\n"); - MojoExecutionException e = - assertThrows(MojoExecutionException.class, () -> runMojo(srcDir)); + MojoExecutionException e = assertThrows(MojoExecutionException.class, this::runMojo); assertTrue(e.getMessage().contains(file.toString()), e.getMessage()); assertTrue(e.getMessage().contains("WHERE is not supported"), e.getMessage()); - assertFalse(Files.exists(srcDir.resolve("p/TransformedDTest.java"))); + assertFalse(Files.exists(outDir.resolve("p/TransformedDTest.java"))); } @Test void failsBuildForNonLiteralQuery() throws Exception { writeSource("ETest", " @CompileQuery void m(String q) { db.query(q); }\n"); - MojoExecutionException e = - assertThrows(MojoExecutionException.class, () -> runMojo(srcDir)); + MojoExecutionException e = assertThrows(MojoExecutionException.class, this::runMojo); assertTrue(e.getMessage().contains("string literal"), e.getMessage()); } @Test void skipsMissingSourceDirectory() throws Exception { - Path missing = srcDir.resolve("missing"); - - runMojo(missing); + runMojo(); - assertFalse(Files.exists(missing)); + assertFalse(Files.exists(outDir)); + assertTrue(project.getTestCompileSourceRoots().isEmpty()); } } diff --git a/estore/pom.xml b/estore/pom.xml index 52e8516..522587c 100644 --- a/estore/pom.xml +++ b/estore/pom.xml @@ -219,9 +219,10 @@ From 5155853311420a93ea9bdb5bcf0885b87541c965 Mon Sep 17 00:00:00 2001 From: zijian yi Date: Mon, 19 Oct 2026 13:31:13 -0500 Subject: [PATCH 3/5] Remove ImplCodeGen and simplify query rewriting ImplCodeGen was the original standalone rewriter, reachable only through an exec:java@compile entry; the codegen plugin replaces it. Removing it lets QueryRewriter drop its lenient rewrite-everything mode, so it now has a single strict path with one place that adds error context. Also drop per-file timing from the Mojo, remove tests that duplicated coverage elsewhere, and share one helper across CompiledQueryTest's mutation cases. --- .../src/main/java/org/estore/CodegenMojo.java | 9 -- .../test/java/org/estore/CodegenMojoTest.java | 20 --- estore/pom.xml | 9 -- .../java/org/estore/compiler/ImplCodeGen.java | 32 ----- .../org/estore/compiler/QueryRewriter.java | 114 +++++------------- .../estore/compiler/CompiledQueryTest.java | 48 +++----- .../org/estore/compiler/ImplCodeGenTest.java | 102 ---------------- .../estore/compiler/QueryRewriterTest.java | 81 +++---------- .../estore/compiler/ConcatQuerySource.java | 12 -- .../estore/compiler/MinimalQuerySource.java | 12 -- .../compiler/MixedConcatQuerySource.java | 13 -- .../compiler/UnsupportedQuerySource.java | 13 -- .../compiler/WithNonQueryCallSource.java | 13 -- 13 files changed, 69 insertions(+), 409 deletions(-) delete mode 100644 estore/src/main/java/org/estore/compiler/ImplCodeGen.java delete mode 100644 estore/src/test/java/org/estore/compiler/ImplCodeGenTest.java delete mode 100644 estore/src/test/resources/org/estore/compiler/ConcatQuerySource.java delete mode 100644 estore/src/test/resources/org/estore/compiler/MinimalQuerySource.java delete mode 100644 estore/src/test/resources/org/estore/compiler/MixedConcatQuerySource.java delete mode 100644 estore/src/test/resources/org/estore/compiler/UnsupportedQuerySource.java delete mode 100644 estore/src/test/resources/org/estore/compiler/WithNonQueryCallSource.java diff --git a/estore-maven-plugin/src/main/java/org/estore/CodegenMojo.java b/estore-maven-plugin/src/main/java/org/estore/CodegenMojo.java index 6c981a6..878d1f1 100644 --- a/estore-maven-plugin/src/main/java/org/estore/CodegenMojo.java +++ b/estore-maven-plugin/src/main/java/org/estore/CodegenMojo.java @@ -89,7 +89,6 @@ private List javaFiles() throws MojoExecutionException { } private boolean rewriteFile(Path file) throws MojoExecutionException { - long start = System.currentTimeMillis(); Optional rewritten; try { String source = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); @@ -113,14 +112,6 @@ private boolean rewriteFile(Path file) throws MojoExecutionException { throw new MojoExecutionException("Cannot write " + target, e); } getLog().info("Transformed " + file.getFileName() + " to " + target.getFileName()); - if (profiling) { - getLog().info( - "Time taken for " - + file.getFileName() - + ": " - + (System.currentTimeMillis() - start) - + " ms"); - } return true; } } diff --git a/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java b/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java index 7e05a5f..2681c46 100644 --- a/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java +++ b/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java @@ -95,17 +95,6 @@ void skipsFilesWithoutAnnotatedMethods() throws Exception { assertEquals(before, read(original)); } - @Test - void isIdempotentWhenRunTwice() throws Exception { - writeSource("CTest", " @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); - - runMojo(); - String first = read(outDir.resolve("p/TransformedCTest.java")); - runMojo(); - - assertEquals(first, read(outDir.resolve("p/TransformedCTest.java"))); - } - @Test void removesOutputForSourcesThatNoLongerExist() throws Exception { Path source = @@ -135,15 +124,6 @@ void failsBuildNamingFileForUnsupportedQuery() throws Exception { assertFalse(Files.exists(outDir.resolve("p/TransformedDTest.java"))); } - @Test - void failsBuildForNonLiteralQuery() throws Exception { - writeSource("ETest", " @CompileQuery void m(String q) { db.query(q); }\n"); - - MojoExecutionException e = assertThrows(MojoExecutionException.class, this::runMojo); - - assertTrue(e.getMessage().contains("string literal"), e.getMessage()); - } - @Test void skipsMissingSourceDirectory() throws Exception { runMojo(); diff --git a/estore/pom.xml b/estore/pom.xml index 522587c..e2f2c38 100644 --- a/estore/pom.xml +++ b/estore/pom.xml @@ -195,15 +195,6 @@ - - compile - - java - - - org.estore.compiler.ImplCodeGen - - main diff --git a/estore/src/main/java/org/estore/compiler/ImplCodeGen.java b/estore/src/main/java/org/estore/compiler/ImplCodeGen.java deleted file mode 100644 index 7eeb520..0000000 --- a/estore/src/main/java/org/estore/compiler/ImplCodeGen.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.estore.compiler; - -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; - -/** - * Command-line entry point that rewrites every literal query call in one Java file. Usage: {@code - * ImplCodeGen [input.java [output.java]]}. - */ -public class ImplCodeGen { - private static final String DEFAULT_INPUT_FILE_PATH = - "src/test/java/org/estore/compiler/CodeGenTest.java"; - - private static String getDefaultOutputFilePath(String inputFilePath) { - File inputFile = new File(inputFilePath); - String parentDir = inputFile.getParent(); - String fileName = inputFile.getName(); - return Paths.get(parentDir, QueryRewriter.TRANSFORMED_PREFIX + fileName).toString(); - } - - public static void main(String[] args) throws IOException { - String inputFilePath = args.length > 0 ? args[0] : DEFAULT_INPUT_FILE_PATH; - String outputFilePath = args.length > 1 ? args[1] : getDefaultOutputFilePath(inputFilePath); - String source = - new String(Files.readAllBytes(Paths.get(inputFilePath)), StandardCharsets.UTF_8); - String rewritten = QueryRewriter.rewriteAllQueries(source); - Files.write(Paths.get(outputFilePath), rewritten.getBytes(StandardCharsets.UTF_8)); - } -} diff --git a/estore/src/main/java/org/estore/compiler/QueryRewriter.java b/estore/src/main/java/org/estore/compiler/QueryRewriter.java index 5f4bf23..83ee095 100644 --- a/estore/src/main/java/org/estore/compiler/QueryRewriter.java +++ b/estore/src/main/java/org/estore/compiler/QueryRewriter.java @@ -16,13 +16,12 @@ import org.antlr.v4.runtime.CommonTokenStream; import org.antlr.v4.runtime.RecognitionException; import org.antlr.v4.runtime.Recognizer; -import org.antlr.v4.runtime.tree.ParseTree; import org.estore.antlr4.CypherLexer; import org.estore.antlr4.CypherParser; /** - * Rewrites {@code db.query("...")} calls in Java source into generated Java code that evaluates the - * Cypher query directly, and renames the rewritten top-level classes with {@link + * Rewrites {@code db.query("...")} calls in {@link CompileQuery} methods into generated Java code + * that evaluates the Cypher query directly, and renames the rewritten top-level classes with {@link * #TRANSFORMED_PREFIX}. */ public final class QueryRewriter { @@ -66,10 +65,9 @@ public final class QueryRewriter { private QueryRewriter() {} /** - * Rewrites every query call inside methods annotated with {@link CompileQuery}. - * - *

Every query in an annotated method must be compiled, so anything that cannot be is an - * error rather than being left to the interpreter. + * Rewrites every {@code db.query("...")} call inside methods annotated with {@link + * CompileQuery}. Any query that cannot be compiled is an error rather than being left to the + * interpreter. * * @param source Java source of a compilation unit. * @return the rewritten source, or empty if the source has no annotated method. @@ -78,7 +76,12 @@ private QueryRewriter() {} * not support. */ public static Optional rewriteAnnotatedMethods(String source) { - CompilationUnit cu = parseSource(source); + CompilationUnit cu; + try { + cu = StaticJavaParser.parse(source); + } catch (ParseProblemException e) { + throw new QueryCompileException("Cannot parse Java source: " + e.getMessage(), e); + } List methods = cu.findAll(MethodDeclaration.class, md -> md.isAnnotationPresent(ANNOTATION)); if (methods.isEmpty()) { @@ -86,31 +89,19 @@ public static Optional rewriteAnnotatedMethods(String source) { } for (MethodDeclaration md : methods) { md.getAnnotationByName(ANNOTATION).ifPresent(Node::remove); - if (rewriteQueryCalls(md, true) == 0) { + List calls = + md.findAll(MethodCallExpr.class, c -> c.getNameAsString().equals("query")); + if (calls.isEmpty()) { throw new QueryCompileException( "@CompileQuery method " + md.getNameAsString() + " contains no db.query(\"...\") call to compile"); } + calls.forEach(QueryRewriter::rewriteQueryCall); } return Optional.of(finish(cu)); } - /** - * Rewrites every query call whose argument is a string literal, or a concatenation of string - * literals. Other query calls are left in place. - * - * @param source Java source of a compilation unit. - * @return the rewritten source. - * @throws QueryCompileException if the source does not parse or a literal query cannot be - * compiled. - */ - public static String rewriteAllQueries(String source) { - CompilationUnit cu = parseSource(source); - rewriteQueryCalls(cu, false); - return finish(cu); - } - /** * Compiles one Cypher query into a Java expression of type {@code Table}. * @@ -128,58 +119,31 @@ public static String compileCypher(String dbName, String cypher) { lexer.addErrorListener(ThrowingErrorListener.INSTANCE); parser.removeErrorListeners(); parser.addErrorListener(ThrowingErrorListener.INSTANCE); - ParseTree tree = parser.oC_Cypher(); - Object code = new CodeGenBuilder(dbName, null).visit(tree); - if (!(code instanceof String)) { - throw new QueryCompileException("code generation produced no Java code"); - } - return (String) code; + return (String) new CodeGenBuilder(dbName, null).visit(parser.oC_Cypher()); } catch (RuntimeException e) { throw new QueryCompileException( "Cannot compile Cypher query `" + cypher + "`: " + e.getMessage(), e); } } - private static CompilationUnit parseSource(String source) { - try { - return StaticJavaParser.parse(source); - } catch (ParseProblemException e) { - throw new QueryCompileException("Cannot parse Java source: " + e.getMessage(), e); - } - } - - private static int rewriteQueryCalls(Node root, boolean strict) { - int rewritten = 0; - for (MethodCallExpr call : - root.findAll(MethodCallExpr.class, c -> c.getNameAsString().equals("query"))) { - Optional cypher = literalQuery(call); - if (!cypher.isPresent()) { - if (strict) { - throw new QueryCompileException( - describe(call) - + " must have the form db.query(\"...\") with a string" - + " literal (or concatenation of literals) argument"); - } - continue; - } - String dbName = call.getScope().get().toString(); - String code; - try { - code = compileCypher(dbName, cypher.get()); - } catch (QueryCompileException e) { - throw new QueryCompileException(describe(call) + ": " + e.getMessage(), e); - } - call.replace(parseGeneratedExpression(call, code)); - rewritten++; + private static void rewriteQueryCall(MethodCallExpr call) { + String where = "query call at line " + call.getBegin().map(p -> p.line).orElse(-1); + Optional cypher = + call.getScope().isPresent() && call.getArguments().size() == 1 + ? concatenatedLiteral(call.getArgument(0)) + : Optional.empty(); + if (!cypher.isPresent()) { + throw new QueryCompileException( + where + + " must have the form db.query(\"...\") with a string literal" + + " (or concatenation of literals) argument"); } - return rewritten; - } - - private static Optional literalQuery(MethodCallExpr call) { - if (!call.getScope().isPresent() || call.getArguments().size() != 1) { - return Optional.empty(); + try { + String code = compileCypher(call.getScope().get().toString(), cypher.get()); + call.replace(StaticJavaParser.parseExpression(code)); + } catch (QueryCompileException | ParseProblemException e) { + throw new QueryCompileException(where + ": " + e.getMessage(), e); } - return concatenatedLiteral(call.getArgument(0)); } private static Optional concatenatedLiteral(Expression expr) { @@ -196,20 +160,6 @@ private static Optional concatenatedLiteral(Expression expr) { return Optional.empty(); } - private static Expression parseGeneratedExpression(MethodCallExpr call, String code) { - try { - return StaticJavaParser.parseExpression(code); - } catch (ParseProblemException e) { - throw new QueryCompileException( - describe(call) + ": generated Java code does not parse: " + e.getMessage(), e); - } - } - - private static String describe(MethodCallExpr call) { - String line = call.getBegin().map(p -> " at line " + p.line).orElse(""); - return "query call" + line; - } - private static String finish(CompilationUnit cu) { for (String imp : IMPORTS) { cu.addImport(imp); diff --git a/estore/src/test/java/org/estore/compiler/CompiledQueryTest.java b/estore/src/test/java/org/estore/compiler/CompiledQueryTest.java index 17635cf..19c20cc 100644 --- a/estore/src/test/java/org/estore/compiler/CompiledQueryTest.java +++ b/estore/src/test/java/org/estore/compiler/CompiledQueryTest.java @@ -53,13 +53,14 @@ private void assertSameAsInterpreted(String cypher, Table compiled) { assertEquals(rowCounts(interpreted), rowCounts(compiled), cypher); } - private void assertSameGraphAsReference() { - for (String cypher : + private void assertSameAsInterpretedOnReference(String cypher, Table compiled) { + assertEquals(rowCounts(interpret(reference, cypher)), rowCounts(compiled), cypher); + for (String check : new String[] {"MATCH (n) RETURN COUNT(n)", "MATCH (n)-[]->(m) RETURN COUNT(n)"}) { assertEquals( - rowCounts(interpret(reference, cypher)), - rowCounts(interpret(estore, cypher)), - cypher); + rowCounts(interpret(reference, check)), + rowCounts(interpret(estore, check)), + cypher + " then " + check); } } @@ -135,45 +136,30 @@ void countProjection() { @CompileQuery @Test void createLabelNode() { - Table compiled = estore.query("CREATE (n:`org.estore.example.D`) RETURN COUNT(n)"); - Table interpreted = - interpret(reference, "CREATE (n:`org.estore.example.D`) RETURN COUNT(n)"); - - assertEquals(rowCounts(interpreted), rowCounts(compiled)); - assertSameGraphAsReference(); + assertSameAsInterpretedOnReference( + "CREATE (n:`org.estore.example.D`) RETURN COUNT(n)", + estore.query("CREATE (n:`org.estore.example.D`) RETURN COUNT(n)")); } @CompileQuery @Test void deleteRelation() { - Table compiled = + assertSameAsInterpretedOnReference( + "MATCH (n:`org.estore.example.A`)-[r:b]->(m) DELETE r RETURN COUNT(n)", estore.query( - "MATCH (n:`org.estore.example.A`)-[r:b]->(m) DELETE r RETURN COUNT(n)"); - Table interpreted = - interpret( - reference, - "MATCH (n:`org.estore.example.A`)-[r:b]->(m) DELETE r RETURN COUNT(n)"); - - assertEquals(rowCounts(interpreted), rowCounts(compiled)); - assertSameGraphAsReference(); + "MATCH (n:`org.estore.example.A`)-[r:b]->(m) DELETE r RETURN COUNT(n)")); } @CompileQuery @Test void matchThenCreateRelation() { - Table compiled = + assertSameAsInterpretedOnReference( + "MATCH (c:`org.estore.example.C` {field1:10})," + + " (d:`org.estore.example.D` {field1:10})" + + " CREATE (c)-[r:d]->(d) RETURN COUNT(r)", estore.query( "MATCH (c:`org.estore.example.C` {field1:10})," + " (d:`org.estore.example.D` {field1:10})" - + " CREATE (c)-[r:d]->(d) RETURN COUNT(r)"); - Table interpreted = - interpret( - reference, - "MATCH (c:`org.estore.example.C` {field1:10})," - + " (d:`org.estore.example.D` {field1:10})" - + " CREATE (c)-[r:d]->(d) RETURN COUNT(r)"); - - assertEquals(rowCounts(interpreted), rowCounts(compiled)); - assertSameGraphAsReference(); + + " CREATE (c)-[r:d]->(d) RETURN COUNT(r)")); } } diff --git a/estore/src/test/java/org/estore/compiler/ImplCodeGenTest.java b/estore/src/test/java/org/estore/compiler/ImplCodeGenTest.java deleted file mode 100644 index a804f50..0000000 --- a/estore/src/test/java/org/estore/compiler/ImplCodeGenTest.java +++ /dev/null @@ -1,102 +0,0 @@ -package org.estore.compiler; - -import static org.junit.jupiter.api.Assertions.assertFalse; -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.nio.file.Paths; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -public class ImplCodeGenTest { - - private static final Path RESOURCES = Paths.get("src/test/resources/org/estore/compiler"); - - private void runTransform(Path fixture, Path input, Path output) throws IOException { - Files.copy(RESOURCES.resolve(fixture), input); - if (output != null) { - ImplCodeGen.main(new String[] {input.toString(), output.toString()}); - } else { - ImplCodeGen.main(new String[] {input.toString()}); - } - } - - private String readString(Path path) throws IOException { - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - } - - @Test - void transformsMinimalSourceWithCompiledQueryCode(@TempDir Path tempDir) throws IOException { - Path input = tempDir.resolve("MinimalQuerySource.java"); - Path output = tempDir.resolve("TransformedMinimalQuerySource.java"); - runTransform(Paths.get("MinimalQuerySource.java"), input, output); - - String generated = readString(output); - - assertTrue(generated.contains("class TransformedMinimalQuerySource")); - assertTrue(generated.contains("import java.util.function.Supplier;")); - assertTrue(generated.contains("private static class DFSNode")); - - assertFalse(generated.contains("estore.query(")); - assertTrue(generated.contains("((Supplier

)")); - assertTrue(generated.contains("estore.getDataStore().values()")); - assertTrue(generated.contains("res.put(\"n\"")); - } - - @Test - void transformsConcatenatedQueryString(@TempDir Path tempDir) throws IOException { - Path input = tempDir.resolve("ConcatQuerySource.java"); - Path output = tempDir.resolve("TransformedConcatQuerySource.java"); - runTransform(Paths.get("ConcatQuerySource.java"), input, output); - - String generated = readString(output); - assertFalse(generated.contains("estore.query(")); - assertTrue(generated.contains("((Supplier
)")); - } - - @Test - void leavesUnsupportedQueryArgumentInPlace(@TempDir Path tempDir) throws IOException { - Path input = tempDir.resolve("UnsupportedQuerySource.java"); - Path output = tempDir.resolve("TransformedUnsupportedQuerySource.java"); - runTransform(Paths.get("UnsupportedQuerySource.java"), input, output); - - String generated = readString(output); - assertTrue(generated.contains("estore.query(q)")); - assertTrue(generated.contains("class TransformedUnsupportedQuerySource")); - } - - @Test - void leavesNonLiteralConcatenationInPlace(@TempDir Path tempDir) throws IOException { - Path input = tempDir.resolve("MixedConcatQuerySource.java"); - Path output = tempDir.resolve("TransformedMixedConcatQuerySource.java"); - runTransform(Paths.get("MixedConcatQuerySource.java"), input, output); - - String generated = readString(output); - assertTrue(generated.contains("estore.query(\"MATCH \" + suffix)")); - assertTrue(generated.contains("class TransformedMixedConcatQuerySource")); - } - - @Test - void transformsFileThatAlsoHasNonQueryMethodCalls(@TempDir Path tempDir) throws IOException { - Path input = tempDir.resolve("WithNonQueryCallSource.java"); - Path output = tempDir.resolve("TransformedWithNonQueryCallSource.java"); - runTransform(Paths.get("WithNonQueryCallSource.java"), input, output); - - String generated = readString(output); - assertTrue(generated.contains("System.out.println")); - assertFalse(generated.contains("estore.query(")); - } - - @Test - void usesDefaultOutputPathWhenOnlyInputProvided(@TempDir Path tempDir) throws IOException { - Path input = tempDir.resolve("MinimalQuerySource.java"); - runTransform(Paths.get("MinimalQuerySource.java"), input, null); - - Path output = tempDir.resolve("TransformedMinimalQuerySource.java"); - assertTrue(Files.exists(output)); - assertTrue(readString(output).contains("class TransformedMinimalQuerySource")); - } -} diff --git a/estore/src/test/java/org/estore/compiler/QueryRewriterTest.java b/estore/src/test/java/org/estore/compiler/QueryRewriterTest.java index 023f048..a269a90 100644 --- a/estore/src/test/java/org/estore/compiler/QueryRewriterTest.java +++ b/estore/src/test/java/org/estore/compiler/QueryRewriterTest.java @@ -77,55 +77,38 @@ void usesQueryScopeAsDatabaseName() { assertFalse(rewritten.contains("db.getDataStore()")); } - @Test - void rejectsAnnotatedMethodWithoutQuery() { - QueryCompileException e = - assertThrows( + private static String rewriteError(String source) { + return assertThrows( QueryCompileException.class, - () -> - QueryRewriter.rewriteAnnotatedMethods( - source(annotatedMethodBody("int x = 1;")))); - - assertTrue(e.getMessage().contains("method m")); + () -> QueryRewriter.rewriteAnnotatedMethods(source)) + .getMessage(); } @Test - void rejectsNonLiteralQueryInAnnotatedMethod() { - String body = "String q = \"MATCH (n) RETURN n\"; db.query(q);"; - - QueryCompileException e = - assertThrows( - QueryCompileException.class, - () -> - QueryRewriter.rewriteAnnotatedMethods( - source(annotatedMethodBody(body)))); - - assertTrue(e.getMessage().contains("string literal")); + void rejectsAnnotatedMethodWithoutQuery() { + assertTrue(rewriteError(source(annotatedMethodBody("int x = 1;"))).contains("method m")); } @Test - void rejectsMixedConcatenationInAnnotatedMethod() { - String body = "String s = \"n\"; db.query(\"MATCH (n) RETURN \" + s);"; + void rejectsNonLiteralQueries() { + String variable = "String q = \"MATCH (n) RETURN n\"; db.query(q);"; + String mixed = "String s = \"n\"; db.query(\"MATCH (n) RETURN \" + s);"; - assertThrows( - QueryCompileException.class, - () -> QueryRewriter.rewriteAnnotatedMethods(source(annotatedMethodBody(body)))); + assertTrue(rewriteError(source(annotatedMethodBody(variable))).contains("string literal")); + assertTrue(rewriteError(source(annotatedMethodBody(mixed))).contains("string literal")); } @Test void reportsLineAndQueryForUnsupportedCypher() { - String body = "db.query(\"MATCH (n) WHERE n.field1 = 1 RETURN n\");"; - - QueryCompileException e = - assertThrows( - QueryCompileException.class, - () -> - QueryRewriter.rewriteAnnotatedMethods( - source(annotatedMethodBody(body)))); + String message = + rewriteError( + source( + annotatedMethodBody( + "db.query(\"MATCH (n) WHERE n.field1 = 1 RETURN n\");"))); - assertTrue(e.getMessage().contains("line 8"), e.getMessage()); - assertTrue(e.getMessage().contains("MATCH (n) WHERE n.field1 = 1 RETURN n")); - assertTrue(e.getMessage().contains("WHERE is not supported")); + assertTrue(message.contains("line 8"), message); + assertTrue(message.contains("MATCH (n) WHERE n.field1 = 1 RETURN n"), message); + assertTrue(message.contains("WHERE is not supported"), message); } @Test @@ -140,31 +123,7 @@ void rejectsCypherSyntaxError() { @Test void rejectsUnparseableJavaSource() { - QueryCompileException e = - assertThrows( - QueryCompileException.class, - () -> QueryRewriter.rewriteAnnotatedMethods("class {")); - - assertTrue(e.getMessage().contains("Cannot parse Java source")); - } - - @Test - void rewriteAllLeavesNonLiteralQueriesInPlace() { - String rewritten = - QueryRewriter.rewriteAllQueries( - source( - " void a(String q) { db.query(q); }\n", - " void b() { db.query(\"MATCH (n) RETURN n\"); }\n")); - - assertTrue(rewritten.contains("db.query(q)")); - assertFalse(rewritten.contains("db.query(\"MATCH (n) RETURN n\")")); - } - - @Test - void rewriteAllRejectsUnsupportedLiteralQuery() { - String source = source(" void b() { db.query(\"MATCH (n) RETURN n LIMIT 1\"); }\n"); - - assertThrows(QueryCompileException.class, () -> QueryRewriter.rewriteAllQueries(source)); + assertTrue(rewriteError("class {").contains("Cannot parse Java source")); } @Test diff --git a/estore/src/test/resources/org/estore/compiler/ConcatQuerySource.java b/estore/src/test/resources/org/estore/compiler/ConcatQuerySource.java deleted file mode 100644 index 0b6a4a4..0000000 --- a/estore/src/test/resources/org/estore/compiler/ConcatQuerySource.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.estore.compiler; - -import org.estore.Estore; -import org.estore.EstoreException; -import org.estore.planner.util.Table; - -public class ConcatQuerySource { - - void runQuery(Estore estore) throws EstoreException { - Table result = estore.query("MATCH " + "(n) RETURN n"); - } -} diff --git a/estore/src/test/resources/org/estore/compiler/MinimalQuerySource.java b/estore/src/test/resources/org/estore/compiler/MinimalQuerySource.java deleted file mode 100644 index 5024613..0000000 --- a/estore/src/test/resources/org/estore/compiler/MinimalQuerySource.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.estore.compiler; - -import org.estore.Estore; -import org.estore.EstoreException; -import org.estore.planner.util.Table; - -public class MinimalQuerySource { - - void runQuery(Estore estore) throws EstoreException { - Table result = estore.query("MATCH (n) RETURN n"); - } -} diff --git a/estore/src/test/resources/org/estore/compiler/MixedConcatQuerySource.java b/estore/src/test/resources/org/estore/compiler/MixedConcatQuerySource.java deleted file mode 100644 index 082bd03..0000000 --- a/estore/src/test/resources/org/estore/compiler/MixedConcatQuerySource.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.estore.compiler; - -import org.estore.Estore; -import org.estore.EstoreException; -import org.estore.planner.util.Table; - -public class MixedConcatQuerySource { - - void runQuery(Estore estore) throws EstoreException { - String suffix = "(n) RETURN n"; - Table result = estore.query("MATCH " + suffix); - } -} diff --git a/estore/src/test/resources/org/estore/compiler/UnsupportedQuerySource.java b/estore/src/test/resources/org/estore/compiler/UnsupportedQuerySource.java deleted file mode 100644 index 6766996..0000000 --- a/estore/src/test/resources/org/estore/compiler/UnsupportedQuerySource.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.estore.compiler; - -import org.estore.Estore; -import org.estore.EstoreException; -import org.estore.planner.util.Table; - -public class UnsupportedQuerySource { - - void runQuery(Estore estore) throws EstoreException { - String q = "MATCH (n) RETURN n"; - Table result = estore.query(q); - } -} diff --git a/estore/src/test/resources/org/estore/compiler/WithNonQueryCallSource.java b/estore/src/test/resources/org/estore/compiler/WithNonQueryCallSource.java deleted file mode 100644 index 8ceaf2b..0000000 --- a/estore/src/test/resources/org/estore/compiler/WithNonQueryCallSource.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.estore.compiler; - -import org.estore.Estore; -import org.estore.EstoreException; -import org.estore.planner.util.Table; - -public class WithNonQueryCallSource { - - void runQuery(Estore estore) throws EstoreException { - System.out.println("warmup"); - Table result = estore.query("MATCH (n) RETURN n"); - } -} From 0c889418496d9aa5ecce3de82f951ae1bb034eb5 Mon Sep 17 00:00:00 2001 From: zijian yi Date: Mon, 19 Oct 2026 13:38:05 -0500 Subject: [PATCH 4/5] Refuse to clear a codegen outputDir that contains the sources The Mojo deletes outputDir on every run, and outputDir can be overridden with -DoutputDir, so a mistaken path such as src/test/java would wipe the test sources. Fail with a clear message instead. --- .../src/main/java/org/estore/CodegenMojo.java | 10 +++++++++- .../src/test/java/org/estore/CodegenMojoTest.java | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/estore-maven-plugin/src/main/java/org/estore/CodegenMojo.java b/estore-maven-plugin/src/main/java/org/estore/CodegenMojo.java index 878d1f1..31fb9a9 100644 --- a/estore-maven-plugin/src/main/java/org/estore/CodegenMojo.java +++ b/estore-maven-plugin/src/main/java/org/estore/CodegenMojo.java @@ -67,7 +67,15 @@ public void execute() throws MojoExecutionException { } private void deleteOutputDir() throws MojoExecutionException { - Path dir = outputDir.toPath(); + Path dir = outputDir.toPath().toAbsolutePath().normalize(); + if (sourceDir.toPath().toAbsolutePath().normalize().startsWith(dir)) { + throw new MojoExecutionException( + "outputDir " + + outputDir + + " contains sourceDir " + + sourceDir + + "; it is cleared on every run, so it must be a separate directory"); + } if (!Files.exists(dir)) { return; } diff --git a/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java b/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java index 2681c46..2d5773a 100644 --- a/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java +++ b/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java @@ -124,6 +124,20 @@ void failsBuildNamingFileForUnsupportedQuery() throws Exception { assertFalse(Files.exists(outDir.resolve("p/TransformedDTest.java"))); } + @Test + void refusesOutputDirThatContainsSources() throws Exception { + Path source = + writeSource( + "GTest", + " @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); + outDir = root; + + MojoExecutionException e = assertThrows(MojoExecutionException.class, this::runMojo); + + assertTrue(e.getMessage().contains("must be a separate directory"), e.getMessage()); + assertTrue(Files.exists(source)); + } + @Test void skipsMissingSourceDirectory() throws Exception { runMojo(); From 03bf90b140851a5e38ca7efe0b544e0b87bdbdab Mon Sep 17 00:00:00 2001 From: zijian yi Date: Mon, 19 Oct 2026 21:52:12 -0500 Subject: [PATCH 5/5] Address review feedback on query rewriting - Move the dependency scoping fix to its own PR. This PR keeps only the plugin's test-scoped JUnit 5 for its new tests and drops antlr and javaparser, which the rewritten Mojo no longer imports. - Run the rewritten tests in a separate Compiled Queries CI workflow; restore test-ci and coverage-ci to their main versions. - State in the README and the @CompileQuery javadoc that the annotation is for test methods only. - Remove the build instructions from the estore POM; the README and the workflow cover them. - CodegenMojoTest: derive class names from each test's method name, and check that generated output exists before the source is deleted. --- .github/workflows/compiled-queries-ci.yml | 28 ++++++++++++ .github/workflows/coverage-ci.yml | 7 +-- .github/workflows/test-ci.yml | 7 +-- README.md | 13 +++--- estore-maven-plugin/pom.xml | 24 ++++++++-- .../test/java/org/estore/CodegenMojoTest.java | 44 ++++++++++--------- estore/pom.xml | 9 ---- .../org/estore/compiler/CompileQuery.java | 4 ++ 8 files changed, 86 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/compiled-queries-ci.yml diff --git a/.github/workflows/compiled-queries-ci.yml b/.github/workflows/compiled-queries-ci.yml new file mode 100644 index 0000000..30698fd --- /dev/null +++ b/.github/workflows/compiled-queries-ci.yml @@ -0,0 +1,28 @@ +name: Compiled Queries CI + +on: + push: + pull_request: + +jobs: + compiled-queries: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: oracle + java-version: "25" + cache: maven + + - name: Install test-data tools + run: sudo apt-get update && sudo apt-get install -y wget zstd + + - name: Install estore and test the codegen plugin + run: | + mvn -pl estore -am install -DskipTests -Djacoco.skip=true + mvn -pl estore-maven-plugin install + + - name: Rewrite @CompileQuery tests and run them + run: mvn -f estore -Pcompile-queries clean test -Djacoco.skip=true -Dtest='Transformed*' diff --git a/.github/workflows/coverage-ci.yml b/.github/workflows/coverage-ci.yml index 81c31f2..98b57a6 100644 --- a/.github/workflows/coverage-ci.yml +++ b/.github/workflows/coverage-ci.yml @@ -19,10 +19,5 @@ jobs: - name: Install test-data tools run: sudo apt-get update && sudo apt-get install -y wget zstd - - name: Install estore and test the codegen plugin - run: | - mvn -pl estore -am install -DskipTests -Djacoco.skip=true - mvn -pl estore-maven-plugin install - - name: Check coverage - run: mvn -f estore -Pcompile-queries clean test jacoco:report jacoco:check + run: mvn -pl estore clean test jacoco:report jacoco:check diff --git a/.github/workflows/test-ci.yml b/.github/workflows/test-ci.yml index 04d29f4..b8be1a8 100644 --- a/.github/workflows/test-ci.yml +++ b/.github/workflows/test-ci.yml @@ -19,13 +19,8 @@ jobs: - name: Install test-data tools run: sudo apt-get update && sudo apt-get install -y wget zstd - - name: Install estore and test the codegen plugin - run: | - mvn -pl estore -am install -DskipTests -Djacoco.skip=true - mvn -pl estore-maven-plugin install - - name: Build and test estore - run: mvn -f estore -Pcompile-queries clean test jacoco:report + run: mvn -pl estore clean test jacoco:report - name: Upload JaCoCo report if: always() diff --git a/README.md b/README.md index d2ffffd..439a117 100644 --- a/README.md +++ b/README.md @@ -162,11 +162,14 @@ The JaCoCo code coverage report is generated at `estore/target/site/jacoco/index #### Run Tests with Compiled Queries -Test methods annotated with `@CompileQuery` can also run with their Cypher queries compiled -to Java. The `compile-queries` profile generates a `TransformedTest.java` for each such -test under `estore/target/generated-test-sources/compiled-queries`, and the build fails if a -query cannot be compiled. The codegen plugin depends on `estore`, so install both first, then -build the `estore` POM directly: +For now, `@CompileQuery` is for test methods only: the codegen plugin scans the test sources +(`estore/src/test/java`) and ignores the annotation anywhere else. For each test class with +annotated methods, the `compile-queries` profile generates a `TransformedTest.java` under +`estore/target/generated-test-sources/compiled-queries`, where those methods' Cypher queries are +compiled to Java. The build fails if a query cannot be compiled. + +The codegen plugin depends on `estore`, so install both first, then build the `estore` POM +directly: ```bash mvn -pl estore -am install -DskipTests && mvn -pl estore-maven-plugin install diff --git a/estore-maven-plugin/pom.xml b/estore-maven-plugin/pom.xml index 1e1b31b..79cd663 100644 --- a/estore-maven-plugin/pom.xml +++ b/estore-maven-plugin/pom.xml @@ -36,6 +36,12 @@ estore 1.0.0 + + org.junit.jupiter + junit-jupiter-engine + 5.9.2 + test + org.apache.maven maven-plugin-api @@ -54,6 +60,12 @@ ${maven.version} provided + + org.apache.maven + maven-compat + ${maven.version} + test + org.apache.maven.plugin-tools maven-plugin-annotations @@ -61,9 +73,15 @@ provided - org.junit.jupiter - junit-jupiter-engine - 5.9.2 + junit + junit + 4.12 + test + + + org.apache.maven.plugin-testing + maven-plugin-testing-harness + 3.3.0 test diff --git a/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java b/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java index 2d5773a..a100ba5 100644 --- a/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java +++ b/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java @@ -13,6 +13,7 @@ import org.apache.maven.project.MavenProject; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; import org.junit.jupiter.api.io.TempDir; public class CodegenMojoTest { @@ -22,15 +23,18 @@ public class CodegenMojoTest { private Path srcDir; private Path outDir; private MavenProject project; + private String className; @BeforeEach - void setUp() { + void setUp(TestInfo testInfo) { + String method = testInfo.getTestMethod().get().getName(); + className = Character.toUpperCase(method.charAt(0)) + method.substring(1) + "Test"; srcDir = root.resolve("src"); outDir = root.resolve("target/generated-test-sources/compiled-queries"); project = new MavenProject(); } - private Path writeSource(String className, String body) throws IOException { + private Path writeSource(String body) throws IOException { Path pkg = Files.createDirectories(srcDir.resolve("p")); String source = "package p;\n" @@ -47,6 +51,10 @@ private Path writeSource(String className, String body) throws IOException { return file; } + private Path transformedPath() { + return outDir.resolve("p/Transformed" + className + ".java"); + } + private static String read(Path file) throws IOException { return new String(Files.readAllBytes(file), StandardCharsets.UTF_8); } @@ -62,21 +70,20 @@ private void runMojo() throws MojoExecutionException { @Test void writesTransformedFileIntoOutputPackage() throws Exception { - writeSource("ATest", " @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); + writeSource(" @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); runMojo(); - Path transformed = outDir.resolve("p/TransformedATest.java"); - assertTrue(Files.exists(transformed)); - String code = read(transformed); - assertTrue(code.contains("class TransformedATest")); + assertTrue(Files.exists(transformedPath())); + String code = read(transformedPath()); + assertTrue(code.contains("class Transformed" + className)); assertFalse(code.contains("db.query(")); - assertFalse(Files.exists(srcDir.resolve("p/TransformedATest.java"))); + assertFalse(Files.exists(srcDir.resolve("p/Transformed" + className + ".java"))); } @Test void registersOutputDirAsTestSourceRoot() throws Exception { - writeSource("ATest", " @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); + writeSource(" @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); runMojo(); @@ -85,35 +92,32 @@ void registersOutputDirAsTestSourceRoot() throws Exception { @Test void skipsFilesWithoutAnnotatedMethods() throws Exception { - Path original = - writeSource("BTest", " void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); + Path original = writeSource(" void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); String before = read(original); runMojo(); - assertFalse(Files.exists(outDir.resolve("p/TransformedBTest.java"))); + assertFalse(Files.exists(transformedPath())); assertEquals(before, read(original)); } @Test void removesOutputForSourcesThatNoLongerExist() throws Exception { Path source = - writeSource( - "FTest", - " @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); + writeSource(" @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); runMojo(); + assertTrue(Files.exists(transformedPath())); Files.delete(source); runMojo(); - assertFalse(Files.exists(outDir.resolve("p/TransformedFTest.java"))); + assertFalse(Files.exists(transformedPath())); } @Test void failsBuildNamingFileForUnsupportedQuery() throws Exception { Path file = writeSource( - "DTest", " @CompileQuery void m() {" + " db.query(\"MATCH (n) WHERE n.x = 1 RETURN n\"); }\n"); @@ -121,15 +125,13 @@ void failsBuildNamingFileForUnsupportedQuery() throws Exception { assertTrue(e.getMessage().contains(file.toString()), e.getMessage()); assertTrue(e.getMessage().contains("WHERE is not supported"), e.getMessage()); - assertFalse(Files.exists(outDir.resolve("p/TransformedDTest.java"))); + assertFalse(Files.exists(transformedPath())); } @Test void refusesOutputDirThatContainsSources() throws Exception { Path source = - writeSource( - "GTest", - " @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); + writeSource(" @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); outDir = root; MojoExecutionException e = assertThrows(MojoExecutionException.class, this::runMojo); diff --git a/estore/pom.xml b/estore/pom.xml index e2f2c38..24ab409 100644 --- a/estore/pom.xml +++ b/estore/pom.xml @@ -25,7 +25,6 @@ org.junit.jupiter junit-jupiter-engine 5.9.2 - test org.ow2.asm @@ -209,14 +208,6 @@ - compile-queries diff --git a/estore/src/main/java/org/estore/compiler/CompileQuery.java b/estore/src/main/java/org/estore/compiler/CompileQuery.java index 0943e35..e059065 100644 --- a/estore/src/main/java/org/estore/compiler/CompileQuery.java +++ b/estore/src/main/java/org/estore/compiler/CompileQuery.java @@ -5,6 +5,10 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Marks a test method whose {@code db.query("...")} calls are compiled to Java by the codegen + * plugin. Only methods in the test sources are processed; the annotation has no effect elsewhere. + */ @Retention(RetentionPolicy.SOURCE) @Target({ElementType.METHOD}) public @interface CompileQuery {}