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 a78b8db..98b57a6 100644 --- a/.github/workflows/coverage-ci.yml +++ b/.github/workflows/coverage-ci.yml @@ -19,11 +19,5 @@ 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: Check coverage 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 fc8d6db..b8be1a8 100644 --- a/.github/workflows/test-ci.yml +++ b/.github/workflows/test-ci.yml @@ -19,12 +19,6 @@ 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: Build and test estore run: mvn -pl estore clean test jacoco:report 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 85c7373..439a117 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,22 @@ mvn -pl estore test verify The JaCoCo code coverage report is generated at `estore/target/site/jacoco/index.html`. +#### Run Tests with Compiled Queries + +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 +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..79cd663 100644 --- a/estore-maven-plugin/pom.xml +++ b/estore-maven-plugin/pom.xml @@ -37,14 +37,10 @@ 1.0.0 - org.antlr - antlr4-runtime - 4.13.1 - - - com.github.javaparser - javaparser-symbol-solver-core - 3.26.1 + org.junit.jupiter + junit-jupiter-engine + 5.9.2 + test org.apache.maven 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..31fb9a9 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,125 @@ 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.Comparator; 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.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. 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 { @Parameter(property = "sourceDir", defaultValue = "${project.build.testSourceDirectory}") - private File sourceDir; + 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") - 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(); + deleteOutputDir(); + int rewritten = 0; + for (Path file : javaFiles()) { + if (rewriteFile(file)) { + rewritten++; } - } catch (IOException e) { - throw new MojoExecutionException("Error processing files", e); } + project.addTestCompileSourceRoot(outputDir.getPath()); + getLog().info( + "Rewrote @CompileQuery methods in " + + rewritten + + " file(s) into " + + outputDir); 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 void deleteOutputDir() throws MojoExecutionException { + 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; + } + 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("Error processing file: " + file, e); + throw new MojoExecutionException("Cannot clear stale output in " + outputDir, 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 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("Cannot list Java files under " + sourceDir, 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(); - } - } + private boolean rewriteFile(Path file) throws MojoExecutionException { + 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 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 ""; + if (!rewritten.isPresent()) { + return false; } - } - - 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"); + 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); + } + getLog().info("Transformed " + file.getFileName() + " to " + target.getFileName()); + 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..a100ba5 --- /dev/null +++ b/estore-maven-plugin/src/test/java/org/estore/CodegenMojoTest.java @@ -0,0 +1,150 @@ +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.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 { + + @TempDir Path root; + + private Path srcDir; + private Path outDir; + private MavenProject project; + private String className; + + @BeforeEach + 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 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 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); + } + + private void runMojo() throws MojoExecutionException { + CodegenMojo mojo = new CodegenMojo(); + mojo.sourceDir = srcDir.toFile(); + mojo.outputDir = outDir.toFile(); + mojo.project = project; + mojo.profiling = false; + mojo.execute(); + } + + @Test + void writesTransformedFileIntoOutputPackage() throws Exception { + writeSource(" @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); + + runMojo(); + + 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/Transformed" + className + ".java"))); + } + + @Test + void registersOutputDirAsTestSourceRoot() throws Exception { + writeSource(" @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); + + runMojo(); + + assertTrue(project.getTestCompileSourceRoots().contains(outDir.toString())); + } + + @Test + void skipsFilesWithoutAnnotatedMethods() throws Exception { + Path original = writeSource(" void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); + String before = read(original); + + runMojo(); + + assertFalse(Files.exists(transformedPath())); + assertEquals(before, read(original)); + } + + @Test + void removesOutputForSourcesThatNoLongerExist() throws Exception { + Path source = + writeSource(" @CompileQuery void m() { db.query(\"MATCH (n) RETURN n\"); }\n"); + runMojo(); + assertTrue(Files.exists(transformedPath())); + Files.delete(source); + + runMojo(); + + assertFalse(Files.exists(transformedPath())); + } + + @Test + void failsBuildNamingFileForUnsupportedQuery() throws Exception { + Path file = + writeSource( + " @CompileQuery void m() {" + + " db.query(\"MATCH (n) WHERE n.x = 1 RETURN n\"); }\n"); + + 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(transformedPath())); + } + + @Test + void refusesOutputDirThatContainsSources() throws Exception { + Path source = + writeSource(" @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(); + + assertFalse(Files.exists(outDir)); + assertTrue(project.getTestCompileSourceRoots().isEmpty()); + } +} diff --git a/estore/pom.xml b/estore/pom.xml index 532a1d2..24ab409 100644 --- a/estore/pom.xml +++ b/estore/pom.xml @@ -90,19 +90,6 @@ - - org.apache.maven.plugins @@ -207,15 +194,6 @@ - - compile - - java - - - org.estore.compiler.ImplCodeGen - - main @@ -230,6 +208,25 @@ + + 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/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 {} 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 a7eac52..0000000 --- a/estore/src/main/java/org/estore/compiler/ImplCodeGen.java +++ /dev/null @@ -1,162 +0,0 @@ -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.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; - -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(); - } - - 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()); - } -} 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..83ee095 --- /dev/null +++ b/estore/src/main/java/org/estore/compiler/QueryRewriter.java @@ -0,0 +1,191 @@ +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.estore.antlr4.CypherLexer; +import org.estore.antlr4.CypherParser; + +/** + * 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 { + + 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 {@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. + * @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; + 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()) { + return Optional.empty(); + } + for (MethodDeclaration md : methods) { + md.getAnnotationByName(ANNOTATION).ifPresent(Node::remove); + 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)); + } + + /** + * 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); + 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 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"); + } + 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); + } + } + + 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 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..19c20cc --- /dev/null +++ b/estore/src/test/java/org/estore/compiler/CompiledQueryTest.java @@ -0,0 +1,165 @@ +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 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, check)), + rowCounts(interpret(estore, check)), + cypher + " then " + check); + } + } + + @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() { + 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() { + 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)")); + } + + @CompileQuery + @Test + void matchThenCreateRelation() { + 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)")); + } +} 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 new file mode 100644 index 0000000..a269a90 --- /dev/null +++ b/estore/src/test/java/org/estore/compiler/QueryRewriterTest.java @@ -0,0 +1,166 @@ +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()")); + } + + private static String rewriteError(String source) { + return assertThrows( + QueryCompileException.class, + () -> QueryRewriter.rewriteAnnotatedMethods(source)) + .getMessage(); + } + + @Test + void rejectsAnnotatedMethodWithoutQuery() { + assertTrue(rewriteError(source(annotatedMethodBody("int x = 1;"))).contains("method m")); + } + + @Test + void rejectsNonLiteralQueries() { + String variable = "String q = \"MATCH (n) RETURN n\"; db.query(q);"; + String mixed = "String s = \"n\"; db.query(\"MATCH (n) RETURN \" + s);"; + + assertTrue(rewriteError(source(annotatedMethodBody(variable))).contains("string literal")); + assertTrue(rewriteError(source(annotatedMethodBody(mixed))).contains("string literal")); + } + + @Test + void reportsLineAndQueryForUnsupportedCypher() { + String message = + rewriteError( + source( + annotatedMethodBody( + "db.query(\"MATCH (n) WHERE n.field1 = 1 RETURN n\");"))); + + 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 + 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() { + assertTrue(rewriteError("class {").contains("Cannot parse Java 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()); + })); + } +} 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"); - } -}