From 90195287e7759a2c167c69e617323ce06a3253e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Thu, 24 Sep 2026 20:35:44 +0300 Subject: [PATCH 1/2] feat: generate one repository per named query group Each sql entry now requires a Java-safe name, and all queries of the entry generate one final Repository with one QueryExecutor field, one constructor, and one method per query in declared order. Result records and row mappers are nested in the repository, and per-query classes are no longer generated. Query-name and method collisions, including nested result types that differ only by case, are checked per repository before any file is written, so two repositories may reuse a query name. Repository file collisions remain global within one output package. --- examples/maven-postgresql/sqlcj.yaml | 3 +- .../src/main/java/com/example/app/App.java | 51 +- scripts/verify-release.sh | 15 +- .../dev/sqlcj/analysis/QueryGroupModel.java | 15 + .../sqlcj/compiler/DefaultSourceLoader.java | 18 +- .../main/java/dev/sqlcj/compiler/Source.java | 3 + .../dev/sqlcj/compiler/SqlcjCompiler.java | 83 ++-- .../dev/sqlcj/config/ConfigValidator.java | 52 ++ .../main/java/dev/sqlcj/config/SqlConfig.java | 6 + .../dev/sqlcj/config/YamlConfigLoader.java | 1 + .../dev/sqlcj/generator/CodeGenerator.java | 4 +- .../sqlcj/generator/JavaCodeGenerator.java | 138 ++++-- .../java/dev/sqlcj/generator/JavaNames.java | 234 +++++---- .../cli/command/GenerateCommandTest.java | 23 +- .../compiler/DefaultSourceLoaderTest.java | 31 +- .../compiler/PostgresIntegrationTest.java | 113 +++-- .../SqlcjCompilerIntegrationTest.java | 451 +++++++++++------- .../sqlcj/config/YamlConfigLoaderTest.java | 141 +++++- .../JavaCodeGeneratorNamingTest.java | 66 ++- .../generator/JavaCodeGeneratorTest.java | 252 +++++++--- .../dev/sqlcj/generator/JavaNamesTest.java | 207 +++++--- sqlcj.yaml | 3 +- 22 files changed, 1280 insertions(+), 630 deletions(-) create mode 100644 sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryGroupModel.java diff --git a/examples/maven-postgresql/sqlcj.yaml b/examples/maven-postgresql/sqlcj.yaml index eebb0b9..0f9e92c 100644 --- a/examples/maven-postgresql/sqlcj.yaml +++ b/examples/maven-postgresql/sqlcj.yaml @@ -1,6 +1,7 @@ version: "1" sql: - - schema: sql/schema.sql + - name: Author + schema: sql/schema.sql queries: sql/queries.sql java: package: com.example.app.db diff --git a/examples/maven-postgresql/src/main/java/com/example/app/App.java b/examples/maven-postgresql/src/main/java/com/example/app/App.java index 35fa71d..f708938 100644 --- a/examples/maven-postgresql/src/main/java/com/example/app/App.java +++ b/examples/maven-postgresql/src/main/java/com/example/app/App.java @@ -1,10 +1,6 @@ package com.example.app; -import com.example.app.db.CreateAuthor; -import com.example.app.db.DeleteAuthor; -import com.example.app.db.GetAuthor; -import com.example.app.db.ListAuthors; -import com.example.app.db.UpdateAuthorBio; +import com.example.app.db.AuthorRepository; import dev.sqlcj.runtime.JdbcQueryExecutor; import dev.sqlcj.runtime.QueryExecutor; import org.postgresql.ds.PGSimpleDataSource; @@ -20,6 +16,11 @@ * code: create, read, list, update, a missing-row read, a committed * transaction, a rolled back transaction, and delete. * + *

All five named queries are methods of one generated + * {@link AuthorRepository}. The repository is constructed once per execution + * context: once from the {@code DataSource}-backed executor, and once more per + * transaction from a caller-owned connection. + * *

Every step is checked, so the process exits non-zero as soon as one * generated operation returns an unexpected result. * @@ -36,11 +37,9 @@ public static void main(String[] args) throws SQLException { QueryExecutor executor = new JdbcQueryExecutor(dataSource); - GetAuthor getAuthor = new GetAuthor(executor); - ListAuthors listAuthors = new ListAuthors(executor); + AuthorRepository authors = new AuthorRepository(executor); - CreateAuthor.CreateAuthorResult created = new CreateAuthor(executor) - .createAuthor("Ada Lovelace", "First programmer"); + AuthorRepository.CreateAuthorResult created = authors.createAuthor("Ada Lovelace", "First programmer"); check(created != null, "CreateAuthor returned no row"); check(created.id() != null, "CreateAuthor returned no database-generated id"); @@ -49,7 +48,7 @@ public static void main(String[] args) throws SQLException { System.out.println("created: " + created.id() + " " + created.name()); - GetAuthor.GetAuthorResult read = getAuthor.getAuthor(created.id()); + AuthorRepository.GetAuthorResult read = authors.getAuthor(created.id()); check(read != null, "GetAuthor returned no row for the created author"); checkEquals(created.id(), read.id(), "GetAuthor id"); @@ -58,7 +57,7 @@ public static void main(String[] args) throws SQLException { System.out.println("read: " + read.name() + " / " + read.bio()); - List listed = listAuthors.listAuthors(); + List listed = authors.listAuthors(); checkEquals(1, listed.size(), "ListAuthors row count after create"); checkEquals(created.id(), listed.get(0).id(), "ListAuthors id"); @@ -66,20 +65,20 @@ public static void main(String[] args) throws SQLException { System.out.println("listed: " + listed.get(0).id() + " " + listed.get(0).name()); - int updatedRows = new UpdateAuthorBio(executor).updateAuthorBio(created.id(), "Mathematician"); + int updatedRows = authors.updateAuthorBio(created.id(), "Mathematician"); checkEquals(1, updatedRows, "UpdateAuthorBio affected rows"); - checkEquals("Mathematician", getAuthor.getAuthor(created.id()).bio(), "bio after update"); + checkEquals("Mathematician", authors.getAuthor(created.id()).bio(), "bio after update"); System.out.println("updated rows: " + updatedRows); - check(getAuthor.getAuthor(-1L) == null, "GetAuthor must return null for a missing row"); + check(authors.getAuthor(-1L) == null, "GetAuthor must return null for a missing row"); System.out.println("missing row: null"); Long committedId = writeAndCommit(dataSource); - GetAuthor.GetAuthorResult committed = getAuthor.getAuthor(committedId); + AuthorRepository.GetAuthorResult committed = authors.getAuthor(committedId); check(committed != null, "the committed author is not readable after commit"); checkEquals("Grace Hopper", committed.name(), "committed name"); @@ -89,18 +88,18 @@ public static void main(String[] args) throws SQLException { Long discardedId = writeAndRollback(dataSource); - check(getAuthor.getAuthor(discardedId) == null, "the rolled back author must not be readable"); + check(authors.getAuthor(discardedId) == null, "the rolled back author must not be readable"); System.out.println("rolled back: null"); - int deletedRows = new DeleteAuthor(executor).deleteAuthor(created.id()); + int deletedRows = authors.deleteAuthor(created.id()); checkEquals(1, deletedRows, "DeleteAuthor affected rows"); - check(getAuthor.getAuthor(created.id()) == null, "the deleted author must not be readable"); + check(authors.getAuthor(created.id()) == null, "the deleted author must not be readable"); System.out.println("deleted rows: " + deletedRows); - List remaining = listAuthors.listAuthors(); + List remaining = authors.listAuthors(); checkEquals(1, remaining.size(), "ListAuthors row count after delete"); checkEquals(committedId, remaining.get(0).id(), "remaining author id"); @@ -116,15 +115,14 @@ private static Long writeAndCommit(DataSource dataSource) throws SQLException { try (Connection connection = dataSource.getConnection()) { connection.setAutoCommit(false); - QueryExecutor transactional = new JdbcQueryExecutor(connection); + AuthorRepository transactionalAuthors = new AuthorRepository(new JdbcQueryExecutor(connection)); - CreateAuthor.CreateAuthorResult author = new CreateAuthor(transactional) - .createAuthor("Grace Hopper", null); + AuthorRepository.CreateAuthorResult author = transactionalAuthors.createAuthor("Grace Hopper", null); check(author != null, "CreateAuthor returned no row inside the committed transaction"); checkEquals(null, author.bio(), "committed bio before update"); - int rows = new UpdateAuthorBio(transactional).updateAuthorBio(author.id(), "Compiler pioneer"); + int rows = transactionalAuthors.updateAuthorBio(author.id(), "Compiler pioneer"); checkEquals(1, rows, "UpdateAuthorBio affected rows inside the committed transaction"); @@ -142,14 +140,13 @@ private static Long writeAndRollback(DataSource dataSource) throws SQLException try (Connection connection = dataSource.getConnection()) { connection.setAutoCommit(false); - QueryExecutor transactional = new JdbcQueryExecutor(connection); + AuthorRepository transactionalAuthors = new AuthorRepository(new JdbcQueryExecutor(connection)); - CreateAuthor.CreateAuthorResult author = new CreateAuthor(transactional) - .createAuthor("Temporary Author", null); + AuthorRepository.CreateAuthorResult author = transactionalAuthors.createAuthor("Temporary Author", null); check(author != null, "CreateAuthor returned no row inside the rolled back transaction"); - int rows = new UpdateAuthorBio(transactional).updateAuthorBio(author.id(), "never stored"); + int rows = transactionalAuthors.updateAuthorBio(author.id(), "never stored"); checkEquals(1, rows, "UpdateAuthorBio affected rows inside the rolled back transaction"); diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index c5f5bc4..5efff6f 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -115,15 +115,18 @@ log "Generating sources with the packaged CLI" generated_root="${sample_dir}/target/generated-sources/sqlcj" generated_package="${generated_root}/com/example/app/db" -for generated in CreateAuthor DeleteAuthor GetAuthor ListAuthors UpdateAuthorBio; do - [ -f "${generated_package}/${generated}.java" ] \ - || fail "the expected generated source is missing: ${generated_package}/${generated}.java" -done +[ -f "${generated_package}/AuthorRepository.java" ] \ + || fail "the expected generated source is missing: ${generated_package}/AuthorRepository.java" generated_count=$(find "${generated_root}" -type f -name '*.java' | wc -l) -[ "${generated_count}" -eq 5 ] \ - || fail "expected 5 generated sources but found ${generated_count}" +[ "${generated_count}" -eq 1 ] \ + || fail "expected 1 generated source but found ${generated_count}" + +for method in createAuthor getAuthor listAuthors updateAuthorBio deleteAuthor; do + grep -q " ${method}(" "${generated_package}/AuthorRepository.java" \ + || fail "the generated repository is missing the ${method} method" +done find "${generated_root}" -type f -name '*.java' | sort diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryGroupModel.java b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryGroupModel.java new file mode 100644 index 0000000..78fc781 --- /dev/null +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryGroupModel.java @@ -0,0 +1,15 @@ +package dev.sqlcj.analysis; + +import java.util.List; + +/** + * One analyzed query group and the queries its generated repository exposes, + * in the order they were declared in the group's query source. + * + * @param name the configured group identity that names the generated repository + */ +public record QueryGroupModel( + String name, + List queries +) { +} diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/DefaultSourceLoader.java b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/DefaultSourceLoader.java index b084371..1b1193e 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/DefaultSourceLoader.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/DefaultSourceLoader.java @@ -11,19 +11,21 @@ import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Set; public final class DefaultSourceLoader implements SourceLoader { private final FileLoader fileLoader = new DefaultFileLoader(); private final QueryParser queryParser = new DefaultQueryParser(); + /** + * Loads every configured entry as its own query group. A query name is + * scoped to the group that declares it, so two groups may name the same + * query. + */ @Override public List load(Config config) { List sources = new ArrayList<>(); - Set queryNames = new HashSet<>(); for (SqlConfig sqlConfig : config.sql()) { Path schemaPath = Path.of(sqlConfig.schema()); @@ -32,17 +34,9 @@ public List load(Config config) { String schema = read(schemaPath, "schema"); List queries = parse(read(queriesPath, "queries"), queriesPath); - for (Query query : queries) { - if (!queryNames.add(query.name())) { - throw new CompilationException( - "Duplicate query name '%s' in query source: %s" - .formatted(query.name(), queriesPath) - ); - } - } - sources.add( new Source( + sqlConfig.name(), schemaPath, schema, queriesPath, diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/Source.java b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/Source.java index f4d80d6..bc05da6 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/Source.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/Source.java @@ -8,8 +8,11 @@ /** * One loaded configuration entry and the paths it was loaded from, which * identify a source in a compilation diagnostic. + * + * @param name the configured group identity that names the generated repository */ public record Source( + String name, Path schemaPath, String schema, Path queriesPath, diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java index 3cf4214..5de91bb 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java @@ -1,6 +1,7 @@ package dev.sqlcj.compiler; import dev.sqlcj.analysis.QueryAnalyzer; +import dev.sqlcj.analysis.QueryGroupModel; import dev.sqlcj.analysis.QueryModel; import dev.sqlcj.config.Config; import dev.sqlcj.generator.CodeGenerator; @@ -50,20 +51,23 @@ public void compile(Config config) { } } + /** Generates one repository per configured query group. */ private List generate(List sources, CodeGenerator codeGenerator) { List files = new ArrayList<>(); - Map generatedQueries = new HashMap<>(); + Map generatedRepositories = new HashMap<>(); for (Source source : sources) { Schema schema = parseSchema(source); - for (Query query : source.queries()) { - GeneratedFile file = compileQuery(source, query, schema, codeGenerator); + GeneratedFile file = generateRepository( + source, + analyze(source, schema), + codeGenerator + ); - checkGeneratedPath(query, file, generatedQueries); + checkGeneratedPath(source, file, generatedRepositories); - files.add(file); - } + files.add(file); } return files; @@ -80,21 +84,26 @@ private Schema parseSchema(Source source) { } } + /** Analyzes every query of one group against that group's schema. */ + private QueryGroupModel analyze(Source source, Schema schema) { + List queries = new ArrayList<>(source.queries().size()); + + for (Query query : source.queries()) { + queries.add(analyzeQuery(source, query, schema)); + } + + return new QueryGroupModel(source.name(), List.copyOf(queries)); + } + /** - * Compiles one query, reporting a parse, analysis, or generation failure - * with the source, query, and header line it belongs to. + * Analyzes one query, reporting a parse or analysis failure with the + * source, query, and header line it belongs to. */ - private GeneratedFile compileQuery( - Source source, - Query query, - Schema schema, - CodeGenerator codeGenerator - ) { + private QueryModel analyzeQuery(Source source, Query query, Schema schema) { try { ParsedSql parsedSql = sqlParser.parse(query.sql()); - QueryModel model = queryAnalyzer.analyze(query, parsedSql, schema); - return codeGenerator.generate(model); + return queryAnalyzer.analyze(query, parsedSql, schema); } catch (RuntimeException e) { throw new CompilationException( "Invalid query '%s' in %s at line %d: %s" @@ -109,6 +118,26 @@ private GeneratedFile compileQuery( } } + /** + * Generates one group, reporting a generation failure such as a repeated + * repository method with the group and its query source. + */ + private GeneratedFile generateRepository( + Source source, + QueryGroupModel group, + CodeGenerator codeGenerator + ) { + try { + return codeGenerator.generate(group); + } catch (RuntimeException e) { + throw new CompilationException( + "Invalid query group '%s' in %s: %s" + .formatted(group.name(), source.queriesPath(), reason(e)), + e + ); + } + } + /** Uses the first message line so a diagnostic stays focused. */ private String reason(RuntimeException e) { String message = e.getMessage(); @@ -128,20 +157,20 @@ private String reason(RuntimeException e) { * already generated path before any file is written. */ private void checkGeneratedPath( - Query query, + Source source, GeneratedFile file, - Map generatedQueries + Map generatedRepositories ) { String portabilityKey = file.path() .toString() .toLowerCase(Locale.ROOT); - GeneratedQuery generated = generatedQueries.get(portabilityKey); + GeneratedRepository generated = generatedRepositories.get(portabilityKey); if (generated == null) { - generatedQueries.put( + generatedRepositories.put( portabilityKey, - new GeneratedQuery(query.name(), file.path()) + new GeneratedRepository(source.name(), file.path()) ); return; @@ -149,16 +178,16 @@ private void checkGeneratedPath( if (generated.path().equals(file.path())) { throw new CompilationException( - "Duplicate generated file for queries '%s' and '%s': %s" - .formatted(generated.queryName(), query.name(), file.path()) + "Duplicate generated file for repositories '%s' and '%s': %s" + .formatted(generated.groupName(), source.name(), file.path()) ); } throw new CompilationException( - "Generated file paths for queries '%s' and '%s' differ only by case: %s and %s" + "Generated file paths for repositories '%s' and '%s' differ only by case: %s and %s" .formatted( - generated.queryName(), - query.name(), + generated.groupName(), + source.name(), generated.path(), file.path() ) @@ -173,6 +202,6 @@ private void write(GeneratedFile file, Path outputDirectory) { } } - private record GeneratedQuery(String queryName, Path path) { + private record GeneratedRepository(String groupName, Path path) { } } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/config/ConfigValidator.java b/sqlcj-cli/src/main/java/dev/sqlcj/config/ConfigValidator.java index cd66fee..46e0a6b 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/config/ConfigValidator.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/config/ConfigValidator.java @@ -3,6 +3,7 @@ import javax.lang.model.SourceVersion; import java.nio.file.Path; import java.util.List; +import java.util.Set; /** * Validates the structure and values of a loaded configuration against the @@ -10,6 +11,31 @@ */ final class ConfigValidator { + private static final SourceVersion SOURCE_VERSION = SourceVersion.RELEASE_21; + + /** + * Java 21 restricted identifiers. They are rejected as a group name so that + * the configured value can be used unchanged as a generated type-name + * prefix. + */ + private static final Set RESTRICTED_IDENTIFIERS = Set.of( + "exports", + "module", + "open", + "opens", + "permits", + "provides", + "record", + "requires", + "sealed", + "to", + "transitive", + "uses", + "var", + "with", + "yield" + ); + void validate(Config config, Path configFile) { validateVersion(config.version(), configFile); validateSql(config.sql(), configFile); @@ -44,11 +70,37 @@ private void validateSql(List sql, Path configFile) { throw invalid(configFile, "'sql[%d]' must not be null".formatted(i)); } + validateName(entry.name(), i, configFile); + requireValue(entry.schema(), "sql[%d].schema".formatted(i), configFile); requireValue(entry.queries(), "sql[%d].queries".formatted(i), configFile); } } + /** + * Requires a group name that can be used unchanged as the prefix of the + * generated repository type name. + */ + private void validateName(String name, int index, Path configFile) { + String field = "sql[%d].name".formatted(index); + + requireValue(name, field, configFile); + + if (!isGroupName(name)) { + throw invalid( + configFile, + "'%s' value '%s' is not a valid Java identifier for a generated repository name" + .formatted(field, name) + ); + } + } + + private boolean isGroupName(String name) { + return SourceVersion.isIdentifier(name) + && !SourceVersion.isKeyword(name, SOURCE_VERSION) + && !RESTRICTED_IDENTIFIERS.contains(name); + } + private void validateJava(JavaConfig java, Path configFile) { if (java == null) { throw invalid(configFile, "'java' is required"); diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/config/SqlConfig.java b/sqlcj-cli/src/main/java/dev/sqlcj/config/SqlConfig.java index e9c0a68..2f55625 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/config/SqlConfig.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/config/SqlConfig.java @@ -1,6 +1,12 @@ package dev.sqlcj.config; +/** + * One configured query group. + * + * @param name the required group identity that names the generated repository + */ public record SqlConfig( + String name, String schema, String queries ) { diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/config/YamlConfigLoader.java b/sqlcj-cli/src/main/java/dev/sqlcj/config/YamlConfigLoader.java index 96fadbf..5e76457 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/config/YamlConfigLoader.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/config/YamlConfigLoader.java @@ -185,6 +185,7 @@ private Config resolvePaths(Config config, Path baseDirectory) { List sql = config.sql().stream() .map( entry -> new SqlConfig( + entry.name(), resolvePath(entry.schema(), baseDirectory), resolvePath(entry.queries(), baseDirectory) ) diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/generator/CodeGenerator.java b/sqlcj-cli/src/main/java/dev/sqlcj/generator/CodeGenerator.java index 2b79aae..014a6ac 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/generator/CodeGenerator.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/generator/CodeGenerator.java @@ -1,8 +1,8 @@ package dev.sqlcj.generator; -import dev.sqlcj.analysis.QueryModel; +import dev.sqlcj.analysis.QueryGroupModel; public interface CodeGenerator { - GeneratedFile generate(QueryModel query); + GeneratedFile generate(QueryGroupModel group); } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java b/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java index 0ae43f8..407755f 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java @@ -1,6 +1,7 @@ package dev.sqlcj.generator; import dev.sqlcj.analysis.QueryColumn; +import dev.sqlcj.analysis.QueryGroupModel; import dev.sqlcj.analysis.QueryModel; import dev.sqlcj.analysis.QueryParameter; import dev.sqlcj.parser.QueryType; @@ -9,15 +10,16 @@ import dev.sqlcj.type.TypeResolver; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; -import java.util.stream.Stream; public final class JavaCodeGenerator implements CodeGenerator { @@ -34,18 +36,22 @@ public JavaCodeGenerator(String packageName) { this.packageName = packageName; } + /** + * Generates one repository holding every method of the group, in query + * order, with one executor field and one constructor. + */ @Override - public GeneratedFile generate(QueryModel query) { - JavaNames names = JavaNames.of(query); + public GeneratedFile generate(QueryGroupModel group) { + JavaNames names = JavaNames.of(group); return new GeneratedFile( buildPath(names), - generateSource(query, names) + generateSource(group, names) ); } private Path buildPath(JavaNames names) { - return packageDirectory().resolve(names.className() + ".java"); + return packageDirectory().resolve(names.repositoryClassName() + ".java"); } private Path packageDirectory() { @@ -57,7 +63,7 @@ private Path packageDirectory() { ); } - private String generateSource(QueryModel query, JavaNames names) { + private String generateSource(QueryGroupModel group, JavaNames names) { return """ %s @@ -69,9 +75,9 @@ private String generateSource(QueryModel query, JavaNames names) { """ .formatted( generatePackage(), - generateImports(query), - generateJavaDoc(query), - generateClass(query, names) + generateImports(group), + generateRepositoryJavaDoc(group).stripTrailing(), + generateClass(group, names) ); } @@ -79,30 +85,32 @@ private String generatePackage() { return "package " + packageName + ";"; } - private String generateImports(QueryModel query) { + private String generateImports(QueryGroupModel group) { Set imports = new LinkedHashSet<>(); imports.add("dev.sqlcj.runtime.QueryExecutor"); - if (hasResult(query)) { + if (group.queries().stream().anyMatch(this::hasResult)) { imports.add("dev.sqlcj.runtime.RowMapper"); } - if (hasResult(query) || isExec(query)) { + if (group.queries().stream().anyMatch(query -> hasResult(query) || isExec(query))) { imports.add("java.util.List"); } - query.columns().stream() - .map(QueryColumn::type) - .map(this::resolveImport) - .filter(Objects::nonNull) - .forEach(imports::add); - - query.parameters().stream() - .map(QueryParameter::type) - .map(this::resolveImport) - .filter(Objects::nonNull) - .forEach(imports::add); + for (QueryModel query : group.queries()) { + query.columns().stream() + .map(QueryColumn::type) + .map(this::resolveImport) + .filter(Objects::nonNull) + .forEach(imports::add); + + query.parameters().stream() + .map(QueryParameter::type) + .map(this::resolveImport) + .filter(Objects::nonNull) + .forEach(imports::add); + } return imports.stream() .map(type -> "import " + type + ";") @@ -134,11 +142,20 @@ private String resolveImport(ColumnType type) { }; } - private String generateJavaDoc(QueryModel query) { + private String generateRepositoryJavaDoc(QueryGroupModel group) { return """ /** * Generated by sqlcj. * + * Repository: %s + */ + """ + .formatted(escapeJavadoc(group.name())); + } + + private String generateJavaDoc(QueryModel query) { + return """ + /** * Query: %s * Table: %s * Type: %s @@ -151,17 +168,26 @@ private String generateJavaDoc(QueryModel query) { ); } - private String generateClass(QueryModel query, JavaNames names) { - String members = Stream.of( - generateExecutorField(), - generateConstructor(names), - hasResultType(query) ? generateResultType(query, names) : "", - hasResult(query) ? generateRowMapper(query, names) : "", - generateMethod(query, names) - ) - .filter(member -> !member.isBlank()) - .map(this::indent) - .collect(Collectors.joining("\n\n")); + private String generateClass(QueryGroupModel group, JavaNames names) { + List members = new ArrayList<>(); + + members.add(generateExecutorField()); + members.add(generateConstructor(names)); + + for (int index = 0; index < group.queries().size(); index++) { + QueryModel query = group.queries().get(index); + JavaNames.QueryNames queryNames = names.queries().get(index); + + if (hasResultType(query)) { + members.add(generateResultType(query, queryNames)); + } + + if (hasResult(query)) { + members.add(generateRowMapper(query, queryNames)); + } + + members.add(generateJavaDoc(query) + generateMethod(query, queryNames)); + } return """ public final class %s { @@ -170,8 +196,11 @@ public final class %s { } """ .formatted( - names.className(), - members + names.repositoryClassName(), + members.stream() + .filter(member -> !member.isBlank()) + .map(this::indent) + .collect(Collectors.joining("\n\n")) ); } @@ -179,7 +208,7 @@ private String indent(String text) { return text.indent(4).stripTrailing(); } - private String generateResultType(QueryModel query, JavaNames names) { + private String generateResultType(QueryModel query, JavaNames.QueryNames names) { return """ public record %s( %s @@ -192,7 +221,7 @@ public record %s( ); } - private String generateResultComponents(QueryModel query, JavaNames names) { + private String generateResultComponents(QueryModel query, JavaNames.QueryNames names) { return IntStream.range(0, query.columns().size()) .mapToObj( index -> generateResultComponent( @@ -213,7 +242,7 @@ private String generateResultComponent(QueryColumn column, String name) { .stripTrailing(); } - private String generateMethodParameters(QueryModel query, JavaNames names) { + private String generateMethodParameters(QueryModel query, JavaNames.QueryNames names) { return IntStream.range(0, query.parameters().size()) .mapToObj(index -> { QueryParameter parameter = query.parameters().get(index); @@ -225,7 +254,7 @@ private String generateMethodParameters(QueryModel query, JavaNames names) { .collect(Collectors.joining(", ")); } - private String generateMethod(QueryModel query, JavaNames names) { + private String generateMethod(QueryModel query, JavaNames.QueryNames names) { return switch (query.type()) { case ONE, MANY -> generateQueryMethod(query, names); case EXEC -> generateExecMethod(query, names); @@ -233,13 +262,13 @@ private String generateMethod(QueryModel query, JavaNames names) { }; } - private String generateQueryMethod(QueryModel query, JavaNames names) { + private String generateQueryMethod(QueryModel query, JavaNames.QueryNames names) { return """ public %s %s(%s) { return executor.%s( %s, %s, - ROW_MAPPER + %s ); } """ @@ -249,11 +278,12 @@ private String generateQueryMethod(QueryModel query, JavaNames names) { generateMethodParameters(query, names), generateExecutorMethod(query), generateSql(query), - generateParameterList(query, names) + generateParameterList(query, names), + names.rowMapperName() ); } - private String generateExecMethod(QueryModel query, JavaNames names) { + private String generateExecMethod(QueryModel query, JavaNames.QueryNames names) { return """ public %s %s(%s) { return executor.execute( @@ -271,7 +301,7 @@ private String generateExecMethod(QueryModel query, JavaNames names) { ); } - private String generateUnsupportedMethod(QueryModel query, JavaNames names) { + private String generateUnsupportedMethod(QueryModel query, JavaNames.QueryNames names) { return """ public %s %s(%s) { throw new UnsupportedOperationException("Not implemented"); @@ -358,7 +388,7 @@ private boolean isLineEnd(String sql, int index) { *

The argument list is built with {@code java.util.Arrays.asList} rather * than {@code List.of} so that a null argument can be bound. */ - private String generateParameterList(QueryModel query, JavaNames names) { + private String generateParameterList(QueryModel query, JavaNames.QueryNames names) { Map namesByIndex = generateParameterNamesByIndex(query, names); return query.bindingParameterIndexes().stream() @@ -366,7 +396,10 @@ private String generateParameterList(QueryModel query, JavaNames names) { .collect(Collectors.joining(", ", "java.util.Arrays.asList(", ")")); } - private Map generateParameterNamesByIndex(QueryModel query, JavaNames names) { + private Map generateParameterNamesByIndex( + QueryModel query, + JavaNames.QueryNames names + ) { Map namesByIndex = new LinkedHashMap<>(); for (int index = 0; index < query.parameters().size(); index++) { @@ -379,7 +412,7 @@ private Map generateParameterNamesByIndex(QueryModel query, Jav return namesByIndex; } - private String generateReturnType(QueryModel query, JavaNames names) { + private String generateReturnType(QueryModel query, JavaNames.QueryNames names) { return switch (query.type()) { case ONE -> names.resultTypeName(); case MANY -> "List<" + names.resultTypeName() + ">"; @@ -391,15 +424,16 @@ private String generateReturnType(QueryModel query, JavaNames names) { }; } - private String generateRowMapper(QueryModel query, JavaNames names) { + private String generateRowMapper(QueryModel query, JavaNames.QueryNames names) { return """ - private static final RowMapper<%s> ROW_MAPPER = + private static final RowMapper<%s> %s = resultSet -> new %s( %s ); """ .formatted( names.resultTypeName(), + names.rowMapperName(), names.resultTypeName(), generateResultMappings(query) ); @@ -441,7 +475,7 @@ private String generateConstructor(JavaNames names) { this.executor = executor; } """ - .formatted(names.className()); + .formatted(names.repositoryClassName()); } /** diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaNames.java b/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaNames.java index c127b22..124a742 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaNames.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaNames.java @@ -1,54 +1,47 @@ package dev.sqlcj.generator; import dev.sqlcj.analysis.QueryColumn; +import dev.sqlcj.analysis.QueryGroupModel; import dev.sqlcj.analysis.QueryModel; import dev.sqlcj.analysis.QueryParameter; import javax.lang.model.SourceVersion; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; /** - * Resolves the Java identifiers of one generated query class. + * Resolves the Java identifiers of one generated repository. * - *

SQL names that are already valid, non-reserved Java identifiers keep their - * spelling. Every other name is normalized deterministically, and names that - * collide inside the generated parameter list or result record are - * disambiguated in their existing SQL order. + *

The configured group name is used unchanged as the repository-name prefix, + * because configuration already requires it to be a Java identifier. SQL names + * that are already valid, non-reserved Java identifiers keep their spelling. + * Every other name is normalized deterministically, and names that collide + * inside the generated parameter list or result record are disambiguated in + * their existing SQL order. + * + *

Two queries of one group that would generate the same method are rejected + * instead of being disambiguated, because a repository method is a name the + * application calls. Two queries whose nested result types differ only by case + * are rejected for the same reason: their class files share one path on a + * case-insensitive filesystem. */ final class JavaNames { private static final SourceVersion SOURCE_VERSION = SourceVersion.RELEASE_21; - /** - * Simple type names referenced by generated source, plus the Java 21 - * identifiers that cannot name a type. - */ - private static final Set RESERVED_CLASS_NAMES = Set.of( - "QueryExecutor", - "RowMapper", - "List", - "BigDecimal", - "LocalDate", - "LocalDateTime", - "OffsetDateTime", - "UUID", - "Boolean", - "Integer", - "Long", - "Short", - "String", - "permits", - "record", - "sealed", - "var", - "yield" - ); + private static final String REPOSITORY_SUFFIX = "Repository"; + + private static final String RESULT_SUFFIX = "Result"; + + private static final String ROW_MAPPER_SUFFIX = "RowMapper"; /** * Inherited {@link Object} method names that a generated method or record @@ -65,78 +58,159 @@ final class JavaNames { "wait" ); - /** Generator-owned names referenced by the generated method body. */ - private static final Set RESERVED_PARAMETER_NAMES = Set.of( - "executor", - "ROW_MAPPER" - ); + /** Generator-owned name referenced by every generated method body. */ + private static final String EXECUTOR_NAME = "executor"; - private final String className; - private final String methodName; - private final List parameterNames; - private final List componentNames; + private final String repositoryClassName; + private final List queries; - private JavaNames( - String className, - String methodName, - List parameterNames, - List componentNames - ) { - this.className = className; - this.methodName = methodName; - this.parameterNames = parameterNames; - this.componentNames = componentNames; + private JavaNames(String repositoryClassName, List queries) { + this.repositoryClassName = repositoryClassName; + this.queries = queries; } - static JavaNames of(QueryModel query) { - String className = className(query.name()); + static JavaNames of(QueryGroupModel group) { + List resultTypeNames = group.queries().stream() + .map(query -> className(query.name()) + RESULT_SUFFIX) + .toList(); + + List methodNames = group.queries().stream() + .map(query -> methodName(className(query.name()))) + .toList(); + + rejectDuplicateMethodNames(group, methodNames); + rejectResultTypeNamesDifferingOnlyByCase(group, resultTypeNames); + + Set reservedParameterNames = reservedParameterNames(methodNames); + + List queries = new ArrayList<>(group.queries().size()); + + for (int index = 0; index < group.queries().size(); index++) { + QueryModel query = group.queries().get(index); + String methodName = methodNames.get(index); + + queries.add( + new QueryNames( + resultTypeNames.get(index), + methodName, + methodName + ROW_MAPPER_SUFFIX, + resolveNames( + query.parameters().stream() + .map(QueryParameter::name) + .toList(), + reservedParameterNames + ), + resolveNames( + query.columns().stream() + .map(QueryColumn::name) + .toList(), + RESERVED_MEMBER_NAMES + ) + ) + ); + } return new JavaNames( - className, - methodName(className), - resolveNames( - query.parameters().stream() - .map(QueryParameter::name) - .toList(), - RESERVED_PARAMETER_NAMES - ), - resolveNames( - query.columns().stream() - .map(QueryColumn::name) - .toList(), - RESERVED_MEMBER_NAMES - ) + group.name() + REPOSITORY_SUFFIX, + List.copyOf(queries) ); } - String className() { - return className; + String repositoryClassName() { + return repositoryClassName; } - String resultTypeName() { - return className + "Result"; + List queries() { + return queries; } - String methodName() { - return methodName; + /** The resolved Java identifiers of one generated repository method. */ + record QueryNames( + String resultTypeName, + String methodName, + String rowMapperName, + List parameterNames, + List componentNames + ) { } - List parameterNames() { - return parameterNames; + /** + * Rejects two queries of one group that generate the same method, naming + * both queries so the query source can be corrected. + */ + private static void rejectDuplicateMethodNames(QueryGroupModel group, List methodNames) { + Map queryNamesByMethodName = new HashMap<>(); + + for (int index = 0; index < methodNames.size(); index++) { + String methodName = methodNames.get(index); + String queryName = group.queries().get(index).name(); + String previous = queryNamesByMethodName.putIfAbsent(methodName, queryName); + + if (previous != null) { + throw new IllegalArgumentException( + "Queries '%s' and '%s' generate the same repository method '%s'" + .formatted(previous, queryName, methodName) + ); + } + } } - List componentNames() { - return componentNames; - } + /** + * Rejects two queries of one group whose nested result types differ only by + * case, because the compiled class files of those types are one path on a + * case-insensitive filesystem and would overwrite each other. + */ + private static void rejectResultTypeNamesDifferingOnlyByCase( + QueryGroupModel group, + List resultTypeNames + ) { + Map resultTypesByPortabilityKey = new HashMap<>(); - private static String className(String queryName) { - String name = normalize(queryName); + for (int index = 0; index < resultTypeNames.size(); index++) { + String resultTypeName = resultTypeNames.get(index); + String queryName = group.queries().get(index).name(); - while (RESERVED_CLASS_NAMES.contains(name)) { - name += "_"; + GeneratedResultType previous = resultTypesByPortabilityKey.putIfAbsent( + resultTypeName.toLowerCase(Locale.ROOT), + new GeneratedResultType(queryName, resultTypeName) + ); + + if (previous != null) { + throw new IllegalArgumentException( + "Queries '%s' and '%s' generate result types that differ only by case: %s and %s" + .formatted( + previous.queryName(), + queryName, + previous.resultTypeName(), + resultTypeName + ) + ); + } } + } - return name; + /** One generated nested result type and the query that generated it. */ + private record GeneratedResultType(String queryName, String resultTypeName) { + } + + /** + * A method parameter must not shadow a generated name the method body + * reads, which is the executor field and the repository's row mappers. + */ + private static Set reservedParameterNames(List methodNames) { + Set reserved = new LinkedHashSet<>(); + + reserved.add(EXECUTOR_NAME); + + methodNames.stream() + .map(methodName -> methodName + ROW_MAPPER_SUFFIX) + .forEach(reserved::add); + + return reserved; + } + + private static String className(String queryName) { + return normalize(queryName); } private static String methodName(String className) { diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/cli/command/GenerateCommandTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/cli/command/GenerateCommandTest.java index 5e02775..9477ccd 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/cli/command/GenerateCommandTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/cli/command/GenerateCommandTest.java @@ -45,7 +45,8 @@ void shouldGenerateFromConfigurationInWorkingDirectory() throws Exception { """ version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -57,14 +58,15 @@ void shouldGenerateFromConfigurationInWorkingDirectory() throws Exception { assertEquals(0, result.exitCode(), result.error()); - Path generated = workingDirectory.resolve("generated/dev/example/generated/GetUser.java"); + Path generated = workingDirectory.resolve("generated/dev/example/generated/UsersRepository.java"); assertTrue(Files.exists(generated), result.error()); - assertTrue( - Files.readString(generated) - .startsWith("package dev.example.generated;") - ); + String source = Files.readString(generated); + + assertTrue(source.startsWith("package dev.example.generated;")); + assertTrue(source.contains("public final class UsersRepository {")); + assertTrue(source.contains("public GetUserResult getUser(Long id)")); } @Test @@ -74,7 +76,8 @@ void shouldFailWithConciseDiagnosticForMalformedConfiguration() throws Exception """ version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -98,7 +101,8 @@ void shouldFailWithConciseDiagnosticForUnreadableSource() throws Exception { """ version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: missing.sql java: package: dev.example.generated @@ -141,7 +145,8 @@ void shouldFailWithConciseDiagnosticForInvalidQuery() throws Exception { """ version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/DefaultSourceLoaderTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/DefaultSourceLoaderTest.java index bc2d09c..ae5fdc9 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/DefaultSourceLoaderTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/DefaultSourceLoaderTest.java @@ -59,8 +59,8 @@ void shouldLoadEntriesInDeclaredOrder() throws IOException { List sources = sourceLoader.load( config( - new SqlConfig(userSchema.toString(), userQueries.toString()), - new SqlConfig(orderSchema.toString(), orderQueries.toString()) + new SqlConfig("Users", userSchema.toString(), userQueries.toString()), + new SqlConfig("Orders", orderSchema.toString(), orderQueries.toString()) ) ); @@ -81,7 +81,7 @@ void shouldLoadEntriesInDeclaredOrder() throws IOException { } @Test - void shouldRejectDuplicateQueryNameAcrossEntries() throws IOException { + void shouldLoadTheSameQueryNameInTwoEntries() throws IOException { Path schema = write("schema.sql", """ CREATE TABLE users ( @@ -102,20 +102,18 @@ void shouldRejectDuplicateQueryNameAcrossEntries() throws IOException { FROM users; """); - CompilationException exception = assertThrows( - CompilationException.class, - () -> sourceLoader.load( - config( - new SqlConfig(schema.toString(), firstQueries.toString()), - new SqlConfig(schema.toString(), secondQueries.toString()) - ) + List sources = sourceLoader.load( + config( + new SqlConfig("Users", schema.toString(), firstQueries.toString()), + new SqlConfig("Orders", schema.toString(), secondQueries.toString()) ) ); - assertEquals( - "Duplicate query name 'GetUser' in query source: " + secondQueries, - exception.getMessage() - ); + assertEquals(List.of("GetUser"), names(sources.getFirst())); + assertEquals(List.of("GetUser"), names(sources.get(1))); + + assertEquals("Users", sources.getFirst().name()); + assertEquals("Orders", sources.get(1).name()); } @Test @@ -134,6 +132,7 @@ void shouldRejectUnreadableQueriesSource() throws IOException { () -> sourceLoader.load( config( new SqlConfig( + "Users", schema.toString(), missingQueries.toString() ) @@ -163,6 +162,7 @@ void shouldRejectUnreadableSchemaSource() throws IOException { () -> sourceLoader.load( config( new SqlConfig( + "Users", missingSchema.toString(), queries.toString() ) @@ -201,6 +201,7 @@ void shouldReportInvalidQuerySourceWithItsPath() throws IOException { () -> sourceLoader.load( config( new SqlConfig( + "Users", schema.toString(), queries.toString() ) @@ -239,6 +240,7 @@ void shouldCarrySourcePathsAndQueryLines() throws IOException { List sources = sourceLoader.load( config( new SqlConfig( + "Users", schema.toString(), queries.toString() ) @@ -247,6 +249,7 @@ void shouldCarrySourcePathsAndQueryLines() throws IOException { Source source = sources.getFirst(); + assertEquals("Users", source.name()); assertEquals(schema, source.schemaPath()); assertEquals(queries, source.queriesPath()); assertEquals(1, source.queries().getFirst().line()); diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java index a7e993a..31de188 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -67,6 +67,9 @@ class PostgresIntegrationTest { */ private static final String REQUIRE_DOCKER_PROPERTY = "sqlcj.test.requireDocker"; + /** The configured query-group name, which generates {@code UsersRepository}. */ + private static final String GROUP = "Users"; + private static final String SCHEMA = """ CREATE TABLE users ( @@ -222,15 +225,15 @@ void shouldExecuteGeneratedOneQueryAgainstPostgres() throws Exception { """); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object query = newQuery(classLoader, "GetUser"); + Object repository = newRepository(classLoader); - Method method = query.getClass().getMethod( + Method method = repository.getClass().getMethod( "getUser", Long.class, Boolean.class ); - Object result = method.invoke(query, 1L, Boolean.TRUE); + Object result = method.invoke(repository, 1L, Boolean.TRUE); assertNotNull(result); @@ -287,14 +290,14 @@ INSERT INTO users (id, code, name, active) """); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object query = newQuery(classLoader, "ListUsers"); + Object repository = newRepository(classLoader); - Method method = query.getClass().getMethod( + Method method = repository.getClass().getMethod( "listUsers", Boolean.class ); - Object result = method.invoke(query, Boolean.TRUE); + Object result = method.invoke(repository, Boolean.TRUE); assertInstanceOf(List.class, result); @@ -327,9 +330,9 @@ INSERT INTO users (id, code, name, birth_date, created_at, balance) """); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object insert = newQuery(classLoader, "InsertUser"); + Object repository = newRepository(classLoader); - Method insertMethod = insert.getClass().getMethod( + Method insertMethod = repository.getClass().getMethod( "insertUser", Long.class, Integer.class, @@ -340,7 +343,7 @@ INSERT INTO users (id, code, name, birth_date, created_at, balance) ); Object affectedRows = insertMethod.invoke( - insert, + repository, 5L, 42, "Alice", @@ -351,11 +354,9 @@ INSERT INTO users (id, code, name, birth_date, created_at, balance) assertEquals(1, affectedRows); - Object query = newQuery(classLoader, "GetUser"); - - Method queryMethod = query.getClass().getMethod("getUser", Long.class); + Method queryMethod = repository.getClass().getMethod("getUser", Long.class); - Object result = queryMethod.invoke(query, 5L); + Object result = queryMethod.invoke(repository, 5L); assertNotNull(result); @@ -394,9 +395,9 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception { """); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object insert = newQuery(classLoader, "InsertUser"); + Object repository = newRepository(classLoader); - Method insertMethod = insert.getClass().getMethod( + Method insertMethod = repository.getClass().getMethod( "insertUser", Long.class, Integer.class, @@ -412,7 +413,7 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception { ); Object affectedRows = insertMethod.invoke( - insert, + repository, 6L, 42, null, @@ -428,11 +429,9 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception { assertEquals(1, affectedRows); - Object query = newQuery(classLoader, "GetUser"); - - Method queryMethod = query.getClass().getMethod("getUser", Long.class); + Method queryMethod = repository.getClass().getMethod("getUser", Long.class); - Object result = queryMethod.invoke(query, 6L); + Object result = queryMethod.invoke(repository, 6L); assertNotNull(result); @@ -472,9 +471,9 @@ INSERT INTO users (id, code, serial_id, revision, external_id, updated_at) """); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object insert = newQuery(classLoader, "InsertUser"); + Object repository = newRepository(classLoader); - Method insertMethod = insert.getClass().getMethod( + Method insertMethod = repository.getClass().getMethod( "insertUser", Long.class, Integer.class, @@ -485,7 +484,7 @@ INSERT INTO users (id, code, serial_id, revision, external_id, updated_at) ); Object affectedRows = insertMethod.invoke( - insert, + repository, 7L, 42, 101, @@ -496,11 +495,9 @@ INSERT INTO users (id, code, serial_id, revision, external_id, updated_at) assertEquals(1, affectedRows); - Object query = newQuery(classLoader, "GetUser"); + Method queryMethod = repository.getClass().getMethod("getUser", Long.class); - Method queryMethod = query.getClass().getMethod("getUser", Long.class); - - Object result = queryMethod.invoke(query, 7L); + Object result = queryMethod.invoke(repository, 7L); assertNotNull(result); @@ -547,11 +544,11 @@ void shouldExecuteGeneratedQueryForSnapshotWithIgnoredTableConstraints() throws execute("INSERT INTO customer_orders (id, customer_id) VALUES (10, 1)"); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object query = newQuery(classLoader, "GetCustomerOrder"); + Object repository = newRepository(classLoader); - Method method = query.getClass().getMethod("getCustomerOrder", Long.class); + Method method = repository.getClass().getMethod("getCustomerOrder", Long.class); - Object result = method.invoke(query, 10L); + Object result = method.invoke(repository, 10L); assertNotNull(result); @@ -587,32 +584,30 @@ INSERT INTO users (id, code, name) """); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object insertSerialId = newQuery(classLoader, "InsertUserReturningSerialId"); + Object repository = newRepository(classLoader); - Method serialIdMethod = insertSerialId.getClass().getMethod( + Method serialIdMethod = repository.getClass().getMethod( "insertUserReturningSerialId", Long.class, Integer.class, String.class ); - Object serialIdResult = serialIdMethod.invoke(insertSerialId, 1L, 42, "Alice"); + Object serialIdResult = serialIdMethod.invoke(repository, 1L, 42, "Alice"); assertNotNull(serialIdResult); assertEquals(List.of("serial_id"), recordComponentNames(serialIdResult)); assertEquals(1, component(serialIdResult, "serial_id")); - Object insertRow = newQuery(classLoader, "InsertUserReturningRow"); - - Method rowMethod = insertRow.getClass().getMethod( + Method rowMethod = repository.getClass().getMethod( "insertUserReturningRow", Long.class, Integer.class, String.class ); - Object row = rowMethod.invoke(insertRow, 2L, 43, "Bob"); + Object row = rowMethod.invoke(repository, 2L, 43, "Bob"); assertNotNull(row); @@ -669,16 +664,16 @@ INSERT INTO users (id, code, name, bio) """); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object update = newQuery(classLoader, "RenameUser"); + Object repository = newRepository(classLoader); - Method method = update.getClass().getMethod( + Method method = repository.getClass().getMethod( "renameUser", Long.class, String.class, Integer.class ); - Object result = method.invoke(update, 1L, "Renamed", 42); + Object result = method.invoke(repository, 1L, "Renamed", 42); assertNotNull(result); @@ -692,7 +687,7 @@ INSERT INTO users (id, code, name, bio) assertEquals("Renamed", component(result, "name")); assertNull(component(result, "score")); - assertNull(method.invoke(update, 404L, "Missing", 42)); + assertNull(method.invoke(repository, 404L, "Missing", 42)); } } @@ -720,16 +715,16 @@ INSERT INTO users (id, code, name, active) """); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object delete = newQuery(classLoader, "DeleteUsersByActive"); + Object repository = newRepository(classLoader); - Method method = delete.getClass().getMethod( + Method method = repository.getClass().getMethod( "deleteUsersByActive", Boolean.class ); List deleted = assertInstanceOf( List.class, - method.invoke(delete, Boolean.TRUE) + method.invoke(repository, Boolean.TRUE) ); assertEquals(2, deleted.size()); @@ -760,7 +755,7 @@ INSERT INTO users (id, code, name, active) assertEquals( List.of(), - method.invoke(delete, Boolean.TRUE) + method.invoke(repository, Boolean.TRUE) ); } } @@ -871,12 +866,12 @@ private Object insertUser( Integer code, String name ) throws Exception { - Object insert = newQuery(classLoader, "InsertUser", executor); + Object repository = newRepository(classLoader, executor); - return insert + return repository .getClass() .getMethod("insertUser", Long.class, Integer.class, String.class) - .invoke(insert, id, code, name); + .invoke(repository, id, code, name); } private Object insertUserReturningRow( @@ -886,12 +881,12 @@ private Object insertUserReturningRow( Integer code, String name ) throws Exception { - Object insert = newQuery(classLoader, "InsertUserReturningRow", executor); + Object repository = newRepository(classLoader, executor); - return insert + return repository .getClass() .getMethod("insertUserReturningRow", Long.class, Integer.class, String.class) - .invoke(insert, id, code, name); + .invoke(repository, id, code, name); } private List listUserNames(URLClassLoader classLoader) throws Exception { @@ -902,12 +897,12 @@ private List listUserNames( URLClassLoader classLoader, QueryExecutor executor ) throws Exception { - Object query = newQuery(classLoader, "ListUserNames", executor); + Object repository = newRepository(classLoader, executor); - List rows = (List) query + List rows = (List) repository .getClass() .getMethod("listUserNames") - .invoke(query); + .invoke(repository); List names = new ArrayList<>(); @@ -938,6 +933,7 @@ private Path generateAndCompile(String schema, String queries) throws Exception Config config = new Config( List.of( new SqlConfig( + GROUP, schemaFile.toString(), queriesFile.toString() ) @@ -991,17 +987,16 @@ private URLClassLoader classLoader(Path classesDirectory) throws Exception { ); } - private Object newQuery(URLClassLoader classLoader, String queryName) throws Exception { - return newQuery(classLoader, queryName, new JdbcQueryExecutor(dataSource)); + private Object newRepository(URLClassLoader classLoader) throws Exception { + return newRepository(classLoader, new JdbcQueryExecutor(dataSource)); } - private Object newQuery( + private Object newRepository( URLClassLoader classLoader, - String queryName, QueryExecutor executor ) throws Exception { Class generatedClass = Class.forName( - "generated." + queryName, + "generated." + GROUP + "Repository", true, classLoader ); diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java index 6da1de0..5734410 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -106,6 +106,7 @@ WHERE id IN ($1, $2) Config config = new Config( List.of( new SqlConfig( + "Users", schemaFile.toString(), queriesFile.toString() ) @@ -120,52 +121,69 @@ WHERE id IN ($1, $2) compiler.compile(config); - Path getUserFile = generatedDirectory.resolve("generated/GetUser.java"); - - Path listUsersFile = generatedDirectory.resolve("generated/ListUsers.java"); - - Path findUsersFile = generatedDirectory.resolve("generated/FindUsers.java"); - - assertTrue(Files.exists(getUserFile)); - assertTrue(Files.exists(listUsersFile)); - - String getUser = Files.readString(getUserFile); - assertTrue(getUser.contains("public final class GetUser")); - assertTrue(getUser.contains("import java.time.LocalDate;")); - assertTrue(getUser.contains("import java.time.LocalDateTime;")); - assertTrue(getUser.contains("import java.math.BigDecimal;")); - assertTrue(getUser.contains("public GetUserResult getUser(LocalDateTime created_at)")); - assertTrue(getUser.contains("Long id")); - assertTrue(getUser.contains("String name")); - assertTrue(getUser.contains("LocalDate birth_date")); - assertTrue(getUser.contains("LocalDateTime created_at")); - assertTrue(getUser.contains("BigDecimal balance")); - assertTrue(getUser.contains("private static final RowMapper ROW_MAPPER")); - assertTrue(getUser.contains("resultSet.getObject(1, Long.class)")); - assertTrue(getUser.contains("resultSet.getObject(2, String.class)")); - assertTrue(getUser.contains("resultSet.getObject(3, Boolean.class)")); - assertTrue(getUser.contains("resultSet.getObject(4, LocalDate.class)")); - assertTrue(getUser.contains("resultSet.getObject(5, LocalDateTime.class)")); - assertTrue(getUser.contains("resultSet.getObject(6, BigDecimal.class)")); - - String listUsers = Files.readString(listUsersFile); - assertTrue(listUsers.contains("public final class ListUsers")); - assertTrue(listUsers.contains("import java.util.List;")); - assertTrue(listUsers.contains("import java.time.LocalDate;")); - assertTrue(listUsers.contains("import java.time.LocalDateTime;")); - assertTrue(listUsers.contains("import java.math.BigDecimal;")); - assertTrue(listUsers.contains("public List listUsers(LocalDateTime created_at)")); - - String findUsers = Files.readString(findUsersFile); - assertTrue(findUsers.contains("public final class FindUsers")); - assertTrue(findUsers.contains("import java.util.List;")); + Path repositoryFile = generatedDirectory.resolve("generated/UsersRepository.java"); + + assertTrue(Files.exists(repositoryFile)); + + assertFalse(Files.exists(generatedDirectory.resolve("generated/GetUser.java"))); + assertFalse(Files.exists(generatedDirectory.resolve("generated/ListUsers.java"))); + assertFalse(Files.exists(generatedDirectory.resolve("generated/FindUsers.java"))); + + String repository = Files.readString(repositoryFile); + + assertTrue(repository.contains("public final class UsersRepository")); + assertTrue(repository.contains("import java.time.LocalDate;")); + assertTrue(repository.contains("import java.time.LocalDateTime;")); + assertTrue(repository.contains("import java.math.BigDecimal;")); + assertTrue(repository.contains("import java.util.List;")); + + assertEquals( + 1, + repository.lines() + .filter(line -> line.equals(" private final QueryExecutor executor;")) + .count() + ); + + assertEquals( + 1, + repository.lines() + .filter(line -> line.contains("public UsersRepository(QueryExecutor executor)")) + .count() + ); + + assertTrue(repository.contains("public GetUserResult getUser(LocalDateTime created_at)")); + assertTrue(repository.contains("Long id")); + assertTrue(repository.contains("String name")); + assertTrue(repository.contains("LocalDate birth_date")); + assertTrue(repository.contains("LocalDateTime created_at")); + assertTrue(repository.contains("BigDecimal balance")); + assertTrue(repository.contains("private static final RowMapper getUserRowMapper")); + assertTrue(repository.contains("resultSet.getObject(1, Long.class)")); + assertTrue(repository.contains("resultSet.getObject(2, String.class)")); + assertTrue(repository.contains("resultSet.getObject(3, Boolean.class)")); + assertTrue(repository.contains("resultSet.getObject(4, LocalDate.class)")); + assertTrue(repository.contains("resultSet.getObject(5, LocalDateTime.class)")); + assertTrue(repository.contains("resultSet.getObject(6, BigDecimal.class)")); + + assertTrue(repository.contains("public List listUsers(LocalDateTime created_at)")); + assertTrue(repository.contains("private static final RowMapper listUsersRowMapper")); + assertTrue( - findUsers + repository .contains("public List findUsers(Long id1, Long id2, Boolean active, String name)") ); - assertTrue(findUsers.contains("public record FindUsersResult(")); - assertTrue(findUsers.contains("Long id")); - assertTrue(findUsers.contains("String name")); + assertTrue(repository.contains("public record FindUsersResult(")); + assertTrue(repository.contains("private static final RowMapper findUsersRowMapper")); + + assertTrue( + repository.indexOf("public GetUserResult getUser(") < repository + .indexOf("public List listUsers(") + ); + + assertTrue( + repository.indexOf("public List listUsers(") < repository + .indexOf("public List findUsers(") + ); Files.createDirectories(classesDirectory); @@ -183,28 +201,14 @@ WHERE id IN ($1, $2) classpath, "-d", classesDirectory.toString(), - getUserFile.toString(), - listUsersFile.toString(), - findUsersFile.toString() + repositoryFile.toString() ); assertEquals(0, result); assertTrue( Files.exists( - classesDirectory.resolve("generated/GetUser.class") - ) - ); - - assertTrue( - Files.exists( - classesDirectory.resolve("generated/ListUsers.class") - ) - ); - - assertTrue( - Files.exists( - classesDirectory.resolve("generated/FindUsers.class") + classesDirectory.resolve("generated/UsersRepository.class") ) ); } @@ -244,6 +248,7 @@ balance DECIMAL(10, 2) Config config = new Config( List.of( new SqlConfig( + "Users", schemaFile.toString(), queriesFile.toString() ) @@ -258,9 +263,9 @@ balance DECIMAL(10, 2) compiler.compile(config); - Path getUserFile = generatedDirectory.resolve("generated/GetUser.java"); + Path repositoryFile = generatedDirectory.resolve("generated/UsersRepository.java"); - assertTrue(Files.exists(getUserFile)); + assertTrue(Files.exists(repositoryFile)); Files.createDirectories(classesDirectory); @@ -278,7 +283,7 @@ balance DECIMAL(10, 2) classpath, "-d", classesDirectory.toString(), - getUserFile.toString() + repositoryFile.toString() ); assertEquals(0, compilationResult); @@ -322,7 +327,7 @@ balance DECIMAL(10, 2) try (URLClassLoader classLoader = new URLClassLoader(classpathUrls, getClass().getClassLoader())) { Class generatedClass = Class.forName( - "generated.GetUser", + "generated.UsersRepository", true, classLoader ); @@ -404,6 +409,7 @@ balance DECIMAL(10, 2) Config config = new Config( List.of( new SqlConfig( + "Users", schemaFile.toString(), queriesFile.toString() ) @@ -418,9 +424,9 @@ balance DECIMAL(10, 2) compiler.compile(config); - Path listActiveUsersFile = generatedDirectory.resolve("generated/ListActiveUsers.java"); + Path repositoryFile = generatedDirectory.resolve("generated/UsersRepository.java"); - assertTrue(Files.exists(listActiveUsersFile)); + assertTrue(Files.exists(repositoryFile)); Files.createDirectories(classesDirectory); @@ -438,7 +444,7 @@ balance DECIMAL(10, 2) classpath, "-d", classesDirectory.toString(), - listActiveUsersFile.toString() + repositoryFile.toString() ); assertEquals(0, compilationResult); @@ -491,7 +497,7 @@ balance DECIMAL(10, 2) ) ) { Class generatedClass = Class.forName( - "generated.ListActiveUsers", + "generated.UsersRepository", true, classLoader ); @@ -563,11 +569,10 @@ void shouldExecuteGeneratedQueryWithOutOfOrderPlaceholders() throws Exception { FROM users WHERE active = $2 AND id = $1; - """, - "FindUser" + """ ); - String source = Files.readString(tempDir.resolve("generated/generated/FindUser.java")); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue( source.contains( @@ -581,7 +586,7 @@ void shouldExecuteGeneratedQueryWithOutOfOrderPlaceholders() throws Exception { try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.FindUser", + "generated.UsersRepository", true, classLoader ); @@ -615,11 +620,10 @@ void shouldExecuteGeneratedQueryWithRepeatedPlaceholder() throws Exception { FROM users WHERE name = $2 AND (id = $1 OR id = $1); - """, - "FindUser" + """ ); - String source = Files.readString(tempDir.resolve("generated/generated/FindUser.java")); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue( source.contains( @@ -634,7 +638,7 @@ void shouldExecuteGeneratedQueryWithRepeatedPlaceholder() throws Exception { try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.FindUser", + "generated.UsersRepository", true, classLoader ); @@ -670,11 +674,10 @@ void shouldExecuteGeneratedQueryWithProtectedPlaceholderText() throws Exception FROM users WHERE name <> '$1 literal' /* keeps $8 */ AND id = $1; - """, - "FindUser" + """ ); - String source = Files.readString(tempDir.resolve("generated/generated/FindUser.java")); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue(source.contains("-- Keeps $9 in a comment.")); assertTrue(source.contains("WHERE name <> '$1 literal' /* keeps $8 */")); @@ -690,7 +693,7 @@ void shouldExecuteGeneratedQueryWithProtectedPlaceholderText() throws Exception try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.FindUser", + "generated.UsersRepository", true, classLoader ); @@ -717,11 +720,10 @@ void shouldExecuteGeneratedQueryWithoutParameters() throws Exception { SELECT id, name FROM users ORDER BY id; - """, - "GetFirstUser" + """ ); - String source = Files.readString(tempDir.resolve("generated/generated/GetFirstUser.java")); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue( source.contains( @@ -735,7 +737,7 @@ void shouldExecuteGeneratedQueryWithoutParameters() throws Exception { try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.GetFirstUser", + "generated.UsersRepository", true, classLoader ); @@ -762,11 +764,10 @@ void shouldExecuteGeneratedInsert() throws Exception { -- name: InsertUser :exec INSERT INTO users (id, name, active) VALUES ($1, $2, $3); - """, - "InsertUser" + """ ); - String source = Files.readString(tempDir.resolve("generated/generated/InsertUser.java")); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue( source.contains( @@ -783,7 +784,7 @@ INSERT INTO users (id, name, active) try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.InsertUser", + "generated.UsersRepository", true, classLoader ); @@ -817,11 +818,10 @@ void shouldExecuteGeneratedUpdateWithOutOfOrderPlaceholders() throws Exception { UPDATE users SET name = $2 WHERE id = $1; - """, - "UpdateUserName" + """ ); - String source = Files.readString(tempDir.resolve("generated/generated/UpdateUserName.java")); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue( source.contains( @@ -835,7 +835,7 @@ void shouldExecuteGeneratedUpdateWithOutOfOrderPlaceholders() throws Exception { try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.UpdateUserName", + "generated.UsersRepository", true, classLoader ); @@ -868,11 +868,10 @@ void shouldExecuteGeneratedDelete() throws Exception { -- name: DeleteUser :exec DELETE FROM users WHERE id = $1; - """, - "DeleteUser" + """ ); - String source = Files.readString(tempDir.resolve("generated/generated/DeleteUser.java")); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue(source.contains("public int deleteUser(Long id)")); assertTrue(source.contains("java.util.Arrays.asList(id)")); @@ -881,7 +880,7 @@ void shouldExecuteGeneratedDelete() throws Exception { try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.DeleteUser", + "generated.UsersRepository", true, classLoader ); @@ -915,11 +914,10 @@ void shouldExecuteGeneratedAliasedQualifiedQuery() throws Exception { SELECT u.id, u.name FROM users u WHERE u.id = $1; - """, - "GetUser" + """ ); - String source = Files.readString(tempDir.resolve("generated/generated/GetUser.java")); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue(source.contains("public GetUserResult getUser(Long id)")); assertTrue(source.contains("resultSet.getObject(1, Long.class)")); @@ -928,7 +926,7 @@ void shouldExecuteGeneratedAliasedQualifiedQuery() throws Exception { QueryExecutor executor = new JdbcQueryExecutor(joinDataSource()); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Class generatedClass = Class.forName("generated.GetUser", true, classLoader); + Class generatedClass = Class.forName("generated.UsersRepository", true, classLoader); Object result = generatedClass .getMethod("getUser", Long.class) @@ -956,13 +954,10 @@ void shouldExecuteGeneratedJoinQueryWithDuplicateColumnNames() throws Exception JOIN profiles p ON p.user_id = u.id WHERE p.nickname = $2 AND u.id = $1; - """, - "ListUserProfiles" + """ ); - String source = Files.readString( - tempDir.resolve("generated/generated/ListUserProfiles.java") - ); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue( source.contains( @@ -978,7 +973,7 @@ void shouldExecuteGeneratedJoinQueryWithDuplicateColumnNames() throws Exception try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.ListUserProfiles", + "generated.UsersRepository", true, classLoader ); @@ -1016,15 +1011,14 @@ void shouldExecuteGeneratedMultipleJoinQuery() throws Exception { JOIN profiles p ON p.user_id = u.id JOIN orders o ON o.user_id = u.id WHERE u.id = $1; - """, - "GetUserOrder" + """ ); QueryExecutor executor = new JdbcQueryExecutor(joinDataSource()); try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.GetUserOrder", + "generated.UsersRepository", true, classLoader ); @@ -1101,10 +1095,12 @@ total DECIMAL(10, 2), Config config = new Config( List.of( new SqlConfig( + "Users", usersSchema.toString(), usersQueries.toString() ), new SqlConfig( + "Orders", ordersSchema.toString(), ordersQueries.toString() ) @@ -1117,23 +1113,25 @@ total DECIMAL(10, 2), new SqlcjCompiler().compile(config); - Path getUserFile = generatedDirectory.resolve("dev/example/generated/GetUser.java"); + Path usersFile = generatedDirectory.resolve("dev/example/generated/UsersRepository.java"); - Path listOrdersFile = generatedDirectory.resolve("dev/example/generated/ListOrders.java"); + Path ordersFile = generatedDirectory.resolve("dev/example/generated/OrdersRepository.java"); - assertTrue(Files.exists(getUserFile)); - assertTrue(Files.exists(listOrdersFile)); + assertTrue(Files.exists(usersFile)); + assertTrue(Files.exists(ordersFile)); - String getUser = Files.readString(getUserFile); + String users = Files.readString(usersFile); - assertTrue(getUser.startsWith("package dev.example.generated;")); - assertTrue(getUser.contains("public GetUserResult getUser(Long id)")); + assertTrue(users.startsWith("package dev.example.generated;")); + assertTrue(users.contains("public final class UsersRepository {")); + assertTrue(users.contains("public GetUserResult getUser(Long id)")); - String listOrders = Files.readString(listOrdersFile); + String orders = Files.readString(ordersFile); - assertTrue(listOrders.startsWith("package dev.example.generated;")); - assertTrue(listOrders.contains("public List listOrders(LocalDateTime created_at)")); - assertTrue(listOrders.contains("BigDecimal total")); + assertTrue(orders.startsWith("package dev.example.generated;")); + assertTrue(orders.contains("public final class OrdersRepository {")); + assertTrue(orders.contains("public List listOrders(LocalDateTime created_at)")); + assertTrue(orders.contains("BigDecimal total")); Files.createDirectories(classesDirectory); @@ -1149,8 +1147,8 @@ total DECIMAL(10, 2), System.getProperty("java.class.path"), "-d", classesDirectory.toString(), - getUserFile.toString(), - listOrdersFile.toString() + usersFile.toString(), + ordersFile.toString() ); assertEquals(0, compilationResult); @@ -1158,7 +1156,7 @@ total DECIMAL(10, 2), assertTrue( Files.exists( classesDirectory.resolve( - "dev/example/generated/GetUser.class" + "dev/example/generated/UsersRepository.class" ) ) ); @@ -1166,14 +1164,14 @@ total DECIMAL(10, 2), assertTrue( Files.exists( classesDirectory.resolve( - "dev/example/generated/ListOrders.class" + "dev/example/generated/OrdersRepository.class" ) ) ); } @Test - void shouldRejectDuplicateQueryNameAcrossEntriesBeforeWriting() throws IOException { + void shouldGenerateTheSameQueryNameInTwoRepositories() throws IOException { Path usersSchema = tempDir.resolve("users-schema.sql"); Path usersQueries = tempDir.resolve("users-queries.sql"); Path ordersSchema = tempDir.resolve("orders-schema.sql"); @@ -1223,10 +1221,12 @@ void shouldRejectDuplicateQueryNameAcrossEntriesBeforeWriting() throws IOExcepti Config config = new Config( List.of( new SqlConfig( + "Users", usersSchema.toString(), usersQueries.toString() ), new SqlConfig( + "Orders", ordersSchema.toString(), ordersQueries.toString() ) @@ -1237,20 +1237,21 @@ void shouldRejectDuplicateQueryNameAcrossEntriesBeforeWriting() throws IOExcepti ) ); - SqlcjCompiler compiler = new SqlcjCompiler(); + new SqlcjCompiler().compile(config); - CompilationException exception = assertThrows( - CompilationException.class, - () -> compiler.compile(config) - ); + Path usersRepository = generatedDirectory + .resolve("dev/example/generated") + .resolve("UsersRepository.java"); - assertEquals( - "Duplicate query name 'GetRecord' in query source: " - + ordersQueries, - exception.getMessage() - ); + Path ordersRepository = generatedDirectory + .resolve("dev/example/generated") + .resolve("OrdersRepository.java"); - assertFalse(Files.exists(generatedDirectory)); + assertTrue(Files.exists(usersRepository)); + assertTrue(Files.exists(ordersRepository)); + + assertTrue(Files.readString(usersRepository).contains("public GetRecordResult getRecord(Long id)")); + assertTrue(Files.readString(ordersRepository).contains("public GetRecordResult getRecord(Long id)")); } @Test @@ -1284,14 +1285,14 @@ name VARCHAR(255) new SqlcjCompiler().compile( new Config( - List.of(new SqlConfig(usersSchema.toString(), usersQueries.toString())), + List.of(new SqlConfig("Users", usersSchema.toString(), usersQueries.toString())), new JavaConfig(generatedDirectory.toString(), "dev.example.generated") ) ); Path generatedFile = generatedDirectory .resolve("dev/example/generated") - .resolve("GetUser.java"); + .resolve("UsersRepository.java"); String previous = Files.readString(generatedFile); @@ -1333,8 +1334,8 @@ name VARCHAR(255) Config config = new Config( List.of( - new SqlConfig(usersSchema.toString(), usersQueries.toString()), - new SqlConfig(ordersSchema.toString(), ordersQueries.toString()) + new SqlConfig("Users", usersSchema.toString(), usersQueries.toString()), + new SqlConfig("Orders", ordersSchema.toString(), ordersQueries.toString()) ), new JavaConfig(generatedDirectory.toString(), "dev.example.generated") ); @@ -1359,7 +1360,7 @@ name VARCHAR(255) Files.exists( generatedDirectory .resolve("dev/example/generated") - .resolve("ListOrders.java") + .resolve("OrdersRepository.java") ) ); } @@ -1394,7 +1395,7 @@ void shouldRejectAnonymousPlaceholderBeforeWriting() { } @Test - void shouldRejectNormalizedGeneratedPathCollisionBeforeWriting() throws IOException { + void shouldRejectRepeatedRepositoryMethodBeforeWriting() throws IOException { Path generatedDirectory = tempDir.resolve("generated"); CompilationException exception = assertThrows( @@ -1416,16 +1417,20 @@ void shouldRejectNormalizedGeneratedPathCollisionBeforeWriting() throws IOExcept ); assertEquals( - "Duplicate generated file for queries 'Get.User' and 'Get-User': " - + Path.of("generated", "Get_User.java"), + "Invalid query group 'Users' in %s: ".formatted(tempDir.resolve("queries.sql")) + + "Queries 'Get.User' and 'Get-User' generate the same repository method 'get_User'", exception.getMessage() ); assertFalse(Files.exists(generatedDirectory)); } + /** + * Nested result types that differ only by case compile to class files that + * share one path on a case-insensitive filesystem. + */ @Test - void shouldRejectGeneratedPathsThatDifferOnlyByCaseBeforeWriting() { + void shouldRejectRepositoryResultTypesThatDifferOnlyByCaseBeforeWriting() throws IOException { Path generatedDirectory = tempDir.resolve("generated"); CompilationException exception = assertThrows( @@ -1447,10 +1452,47 @@ void shouldRejectGeneratedPathsThatDifferOnlyByCaseBeforeWriting() { ); assertEquals( - "Generated file paths for queries 'GetUser' and 'getuser' differ only by case: " - + Path.of("generated", "GetUser.java") + "Invalid query group 'Users' in %s: ".formatted(tempDir.resolve("queries.sql")) + + "Queries 'GetUser' and 'getuser' generate result types that differ only by case: " + + "GetUserResult and getuserResult", + exception.getMessage() + ); + + assertFalse(Files.exists(generatedDirectory)); + } + + @Test + void shouldRejectDuplicateRepositoryBeforeWriting() throws IOException { + Path generatedDirectory = tempDir.resolve("generated"); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> compileUsersGroups("Users", "Users", generatedDirectory) + ); + + assertEquals( + "Duplicate generated file for repositories 'Users' and 'Users': " + + Path.of("generated", "UsersRepository.java"), + exception.getMessage() + ); + + assertFalse(Files.exists(generatedDirectory)); + } + + @Test + void shouldRejectRepositoryPathsThatDifferOnlyByCaseBeforeWriting() throws IOException { + Path generatedDirectory = tempDir.resolve("generated"); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> compileUsersGroups("Users", "users", generatedDirectory) + ); + + assertEquals( + "Generated file paths for repositories 'Users' and 'users' differ only by case: " + + Path.of("generated", "UsersRepository.java") + " and " - + Path.of("generated", "getuser.java"), + + Path.of("generated", "usersRepository.java"), exception.getMessage() ); @@ -1488,6 +1530,7 @@ void shouldGenerateCompilableJavaForQuotedSqlIdentifiers() throws IOException { Config config = new Config( List.of( new SqlConfig( + "Users", schemaFile.toString(), queriesFile.toString() ) @@ -1502,7 +1545,7 @@ void shouldGenerateCompilableJavaForQuotedSqlIdentifiers() throws IOException { Path generatedFile = generatedDirectory .resolve("generated") - .resolve("ListUserData.java"); + .resolve("UsersRepository.java"); String source = Files.readString(generatedFile); @@ -1582,6 +1625,7 @@ INSERT INTO users (id, name) Config config = new Config( List.of( new SqlConfig( + "Users", schemaFile.toString(), queriesFile.toString() ) @@ -1594,31 +1638,28 @@ INSERT INTO users (id, name) new SqlcjCompiler().compile(config); - Path insertUserFile = generatedDirectory.resolve("generated/InsertUser.java"); - Path updateUserFile = generatedDirectory.resolve("generated/UpdateUser.java"); - Path deleteUsersFile = generatedDirectory.resolve("generated/DeleteUsers.java"); - - String insertUser = Files.readString(insertUserFile); - assertTrue(insertUser.contains("public record InsertUserResult(")); - assertTrue(insertUser.contains("Long id")); - assertTrue(insertUser.contains("String name")); - assertTrue(insertUser.contains("Boolean active")); - assertTrue(insertUser.contains("public InsertUserResult insertUser(Long id, String name)")); - assertTrue(insertUser.contains("return executor.query(")); - assertTrue(insertUser.contains("VALUES (?, ?)")); - assertTrue(insertUser.contains("RETURNING *")); - - String updateUser = Files.readString(updateUserFile); - assertTrue(updateUser.contains("public UpdateUserResult updateUser(Long id, String name)")); - assertTrue(updateUser.contains("resultSet.getObject(1, String.class)")); - assertTrue(updateUser.contains("resultSet.getObject(2, Long.class)")); - assertTrue(updateUser.contains("java.util.Arrays.asList(name, id)")); - assertTrue(updateUser.contains("RETURNING name, id")); - - String deleteUsers = Files.readString(deleteUsersFile); - assertTrue(deleteUsers.contains("public List deleteUsers(Boolean active)")); - assertTrue(deleteUsers.contains("return executor.queryMany(")); - assertTrue(deleteUsers.contains("RETURNING id, name")); + Path repositoryFile = generatedDirectory.resolve("generated/UsersRepository.java"); + + String repository = Files.readString(repositoryFile); + + assertTrue(repository.contains("public record InsertUserResult(")); + assertTrue(repository.contains("Long id")); + assertTrue(repository.contains("String name")); + assertTrue(repository.contains("Boolean active")); + assertTrue(repository.contains("public InsertUserResult insertUser(Long id, String name)")); + assertTrue(repository.contains("return executor.query(")); + assertTrue(repository.contains("VALUES (?, ?)")); + assertTrue(repository.contains("RETURNING *")); + + assertTrue(repository.contains("public UpdateUserResult updateUser(Long id, String name)")); + assertTrue(repository.contains("resultSet.getObject(1, String.class)")); + assertTrue(repository.contains("resultSet.getObject(2, Long.class)")); + assertTrue(repository.contains("java.util.Arrays.asList(name, id)")); + assertTrue(repository.contains("RETURNING name, id")); + + assertTrue(repository.contains("public List deleteUsers(Boolean active)")); + assertTrue(repository.contains("return executor.queryMany(")); + assertTrue(repository.contains("RETURNING id, name")); Files.createDirectories(classesDirectory); @@ -1636,15 +1677,11 @@ INSERT INTO users (id, name) System.getProperty("java.class.path"), "-d", classesDirectory.toString(), - insertUserFile.toString(), - updateUserFile.toString(), - deleteUsersFile.toString() + repositoryFile.toString() ) ); - assertTrue(Files.exists(classesDirectory.resolve("generated/InsertUser.class"))); - assertTrue(Files.exists(classesDirectory.resolve("generated/UpdateUser.class"))); - assertTrue(Files.exists(classesDirectory.resolve("generated/DeleteUsers.class"))); + assertTrue(Files.exists(classesDirectory.resolve("generated/UsersRepository.class"))); } private void compileUsersQueries(String queries, Path generatedDirectory) throws IOException { @@ -1667,6 +1704,7 @@ name VARCHAR(255) Config config = new Config( List.of( new SqlConfig( + "Users", schemaFile.toString(), queriesFile.toString() ) @@ -1680,7 +1718,51 @@ name VARCHAR(255) new SqlcjCompiler().compile(config); } - private Path generateAndCompile(String queries, String queryName) throws IOException { + /** Compiles two configured groups that share one schema and query source. */ + private void compileUsersGroups( + String firstGroup, + String secondGroup, + Path generatedDirectory + ) throws IOException { + Path schemaFile = tempDir.resolve("schema.sql"); + Path queriesFile = tempDir.resolve("queries.sql"); + + Files.writeString( + schemaFile, + """ + CREATE TABLE users + ( + id BIGINT NOT NULL, + name VARCHAR(255) + ); + """ + ); + + Files.writeString( + queriesFile, + """ + -- name: GetUser :one + SELECT id, name + FROM users + WHERE id = $1; + """ + ); + + Config config = new Config( + List.of( + new SqlConfig(firstGroup, schemaFile.toString(), queriesFile.toString()), + new SqlConfig(secondGroup, schemaFile.toString(), queriesFile.toString()) + ), + new JavaConfig( + generatedDirectory.toString(), + "generated" + ) + ); + + new SqlcjCompiler().compile(config); + } + + private Path generateAndCompile(String queries) throws IOException { return generateAndCompile( """ CREATE TABLE users @@ -1690,12 +1772,12 @@ name VARCHAR(255), active BOOLEAN ); """, - queries, - queryName + queries ); } - private Path generateAndCompile(String schema, String queries, String queryName) throws IOException { + /** Generates and compiles the one repository of the {@code Users} group. */ + private Path generateAndCompile(String schema, String queries) throws IOException { Path schemaFile = tempDir.resolve("schema.sql"); Path queriesFile = tempDir.resolve("queries.sql"); Path generatedDirectory = tempDir.resolve("generated"); @@ -1708,6 +1790,7 @@ private Path generateAndCompile(String schema, String queries, String queryName) Config config = new Config( List.of( new SqlConfig( + "Users", schemaFile.toString(), queriesFile.toString() ) @@ -1720,7 +1803,7 @@ private Path generateAndCompile(String schema, String queries, String queryName) new SqlcjCompiler().compile(config); - Path generatedFile = generatedDirectory.resolve("generated").resolve(queryName + ".java"); + Path generatedFile = generatedDirectory.resolve("generated").resolve("UsersRepository.java"); assertTrue(Files.exists(generatedFile)); diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/config/YamlConfigLoaderTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/config/YamlConfigLoaderTest.java index 1eeaf00..6e95f6e 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/config/YamlConfigLoaderTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/config/YamlConfigLoaderTest.java @@ -2,6 +2,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.io.IOException; import java.nio.file.Files; @@ -24,7 +26,8 @@ void shouldMapVersionOneFields() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -35,6 +38,7 @@ void shouldMapVersionOneFields() throws IOException { assertEquals("1", config.version()); assertEquals(1, config.sql().size()); + assertEquals("Users", config.sql().getFirst().name()); assertEquals( tempDir.resolve("schema.sql").toString(), config.sql().getFirst().schema() @@ -58,9 +62,11 @@ void shouldPreserveSqlEntryOrder() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: users.sql + - name: Users + schema: users.sql queries: user-queries.sql - - schema: orders.sql + - name: Orders + schema: orders.sql queries: order-queries.sql java: package: dev.example.generated @@ -97,7 +103,8 @@ void shouldResolveRelativePathsAgainstConfigurationDirectory() throws IOExceptio Files.writeString(configFile, """ version: "1" sql: - - schema: ../sql/schema.sql + - name: Users + schema: ../sql/schema.sql queries: ./queries.sql java: package: dev.example.generated @@ -134,7 +141,8 @@ void shouldKeepAbsolutePathsAbsolute() throws IOException { """ version: "1" sql: - - schema: %s + - name: Users + schema: %s queries: %s java: package: dev.example.generated @@ -203,7 +211,8 @@ void shouldRejectUnknownField() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -235,7 +244,8 @@ void shouldRejectWrongTypedNestedField() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: 42 + - name: Users + schema: 42 queries: queries.sql java: package: dev.example.generated @@ -254,7 +264,8 @@ void shouldRejectWrongTypedJavaOut() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -273,7 +284,8 @@ void shouldRejectNumericVersion() throws IOException { Path configFile = write(""" version: 1 sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -291,7 +303,8 @@ void shouldRejectNumericVersion() throws IOException { void shouldRejectMissingVersion() throws IOException { Path configFile = write(""" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -306,7 +319,8 @@ void shouldRejectUnsupportedVersion() throws IOException { Path configFile = write(""" version: "2" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -349,7 +363,8 @@ void shouldRejectNullSqlEntry() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql - java: @@ -365,7 +380,8 @@ void shouldRejectBlankQueriesValue() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: " " java: package: dev.example.generated @@ -376,12 +392,103 @@ void shouldRejectBlankQueriesValue() throws IOException { } @Test - void shouldRejectMissingJavaSection() throws IOException { + void shouldRejectMissingSqlEntryName() throws IOException { Path configFile = write(""" version: "1" sql: - schema: schema.sql queries: queries.sql + java: + package: dev.example.generated + out: generated + """); + + assertInvalid(configFile, "'sql[0].name' is required"); + } + + @Test + void shouldRejectBlankSqlEntryName() throws IOException { + Path configFile = write(""" + version: "1" + sql: + - name: " " + schema: schema.sql + queries: queries.sql + java: + package: dev.example.generated + out: generated + """); + + assertInvalid(configFile, "'sql[0].name' must not be blank"); + } + + @ParameterizedTest + @ValueSource( + strings = { + "Author Repository", + "1Author", + "Author.Repository", + "class", + "true", + "null", + "_", + "var", + "record" + } + ) + void shouldRejectSqlEntryNameThatIsNotAJavaIdentifier(String name) throws IOException { + Path configFile = write( + """ + version: "1" + sql: + - name: "%s" + schema: schema.sql + queries: queries.sql + java: + package: dev.example.generated + out: generated + """ + .formatted(name) + ); + + assertInvalid( + configFile, + "'sql[0].name' value '%s' is not a valid Java identifier for a generated repository name" + .formatted(name) + ); + } + + @Test + void shouldPreserveSqlEntryNameWhileResolvingPaths() throws IOException { + Path configFile = write(""" + version: "1" + sql: + - name: Author + schema: sql/schema.sql + queries: sql/queries.sql + java: + package: dev.example.generated + out: generated + """); + + Config config = configLoader.load(configFile); + + assertEquals("Author", config.sql().getFirst().name()); + + assertEquals( + tempDir.resolve("sql/schema.sql").toString(), + config.sql().getFirst().schema() + ); + } + + @Test + void shouldRejectMissingJavaSection() throws IOException { + Path configFile = write(""" + version: "1" + sql: + - name: Users + schema: schema.sql + queries: queries.sql """); assertInvalid(configFile, "'java' is required"); @@ -392,7 +499,8 @@ void shouldRejectMissingJavaOut() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -406,7 +514,8 @@ void shouldRejectInvalidJavaPackage() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.class.generated diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java index b501c61..53bc698 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java @@ -1,6 +1,7 @@ package dev.sqlcj.generator; import dev.sqlcj.analysis.QueryColumn; +import dev.sqlcj.analysis.QueryGroupModel; import dev.sqlcj.analysis.QueryModel; import dev.sqlcj.analysis.QueryParameter; import dev.sqlcj.parser.QueryType; @@ -33,14 +34,16 @@ class JavaCodeGeneratorNamingTest { FROM users """; + private static final String GROUP = "Users"; + private final CodeGenerator codeGenerator = new JavaCodeGenerator(); @TempDir Path tempDir; @Test - void shouldGenerateKeywordSafeMethodForKeywordClassName() throws IOException { - GeneratedFile file = codeGenerator.generate( + void shouldGenerateKeywordSafeMethodForKeywordQueryName() throws IOException { + GeneratedFile file = generate( query( "Class", List.of(new QueryColumn("id", ColumnType.BIGINT, false)), @@ -50,9 +53,10 @@ void shouldGenerateKeywordSafeMethodForKeywordClassName() throws IOException { String source = file.content(); - assertEquals(Path.of("generated", "Class.java"), file.path()); - assertTrue(source.contains("public final class Class {")); - assertTrue(source.contains("public Class(QueryExecutor executor)")); + assertEquals(Path.of("generated", "UsersRepository.java"), file.path()); + assertTrue(source.contains("public final class UsersRepository {")); + assertTrue(source.contains("public UsersRepository(QueryExecutor executor)")); + assertTrue(source.contains("public record ClassResult(")); assertTrue(source.contains("public List class_()")); assertCompiles(file); @@ -60,7 +64,7 @@ void shouldGenerateKeywordSafeMethodForKeywordClassName() throws IOException { @Test void shouldNormalizeUnsafeQueryNameAndEscapeJavadoc() throws IOException { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "Get*/User", List.of(new QueryColumn("id", ColumnType.BIGINT, false)), @@ -70,9 +74,9 @@ void shouldNormalizeUnsafeQueryNameAndEscapeJavadoc() throws IOException { String source = file.content(); - assertEquals(Path.of("generated", "Get_User.java"), file.path()); - assertTrue(source.contains("public final class Get_User {")); - assertTrue(source.contains("public Get_User(QueryExecutor executor)")); + assertEquals(Path.of("generated", "UsersRepository.java"), file.path()); + assertTrue(source.contains("public final class UsersRepository {")); + assertTrue(source.contains("public record Get_UserResult(")); assertTrue(source.contains("public List get_User()")); assertTrue(source.contains("Query: Get*/User")); assertFalse(source.contains("Query: Get*/User")); @@ -81,8 +85,8 @@ void shouldNormalizeUnsafeQueryNameAndEscapeJavadoc() throws IOException { } @Test - void shouldRenameClassConflictingWithImportedType() throws IOException { - GeneratedFile file = codeGenerator.generate( + void shouldGenerateResultTypeForQueryNamedLikeAnImportedType() throws IOException { + GeneratedFile file = generate( query( "List", List.of(new QueryColumn("id", ColumnType.BIGINT, false)), @@ -92,19 +96,19 @@ void shouldRenameClassConflictingWithImportedType() throws IOException { String source = file.content(); - assertEquals(Path.of("generated", "List_.java"), file.path()); + assertEquals(Path.of("generated", "UsersRepository.java"), file.path()); assertTrue(source.contains("import java.util.List;")); - assertTrue(source.contains("public final class List_ {")); - assertTrue(source.contains("public record List_Result(")); - assertTrue(source.contains("private static final RowMapper ROW_MAPPER =")); - assertTrue(source.contains("public List list_()")); + assertTrue(source.contains("public final class UsersRepository {")); + assertTrue(source.contains("public record ListResult(")); + assertTrue(source.contains("private static final RowMapper listRowMapper =")); + assertTrue(source.contains("public List list()")); assertCompiles(file); } @Test - void shouldRenameClassConflictingWithImportedUuidType() throws IOException { - GeneratedFile file = codeGenerator.generate( + void shouldGenerateResultTypeForQueryNamedLikeAnImportedUuidType() throws IOException { + GeneratedFile file = generate( query( "UUID", List.of(new QueryColumn("external_id", ColumnType.UUID, true)), @@ -114,10 +118,10 @@ void shouldRenameClassConflictingWithImportedUuidType() throws IOException { String source = file.content(); - assertEquals(Path.of("generated", "UUID_.java"), file.path()); + assertEquals(Path.of("generated", "UsersRepository.java"), file.path()); assertTrue(source.contains("import java.util.UUID;")); - assertTrue(source.contains("public final class UUID_ {")); - assertTrue(source.contains("public record UUID_Result(")); + assertTrue(source.contains("public final class UsersRepository {")); + assertTrue(source.contains("public record UUIDResult(")); assertTrue(source.contains("UUID external_id")); assertTrue(source.contains("resultSet.getObject(1, UUID.class)")); @@ -126,7 +130,7 @@ void shouldRenameClassConflictingWithImportedUuidType() throws IOException { @Test void shouldReadRenamedResultComponentsByProjectionPosition() throws IOException { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "ListUsers", List.of( @@ -156,7 +160,7 @@ void shouldReadRenamedResultComponentsByProjectionPosition() throws IOException @Test void shouldReadQuotedSqlColumnByProjectionPosition() throws IOException { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "ListUsers", List.of(new QueryColumn("user\"id", ColumnType.VARCHAR, true)), @@ -174,7 +178,7 @@ void shouldReadQuotedSqlColumnByProjectionPosition() throws IOException { @Test void shouldUseResolvedParameterNamesInSignatureAndArguments() throws IOException { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "FindUsers", List.of(new QueryColumn("id", ColumnType.BIGINT, false)), @@ -216,7 +220,7 @@ void shouldExecuteExactSqlForQuotedIdentifierWithBackslash() throws Exception { List.of(new QueryParameter(1, "a\\q", ColumnType.VARCHAR)) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue(file.content().contains("SELECT \"a\\\\q\"")); assertTrue(file.content().contains("String a_q")); @@ -240,7 +244,7 @@ void shouldExecuteExactSqlForTextBlockDelimiterAndTrailingWhitespace() throws Ex List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertCompiles(file); @@ -307,6 +311,16 @@ public int execute(String sql, List parameters) { } } + /** Generates the repository of a single-query group. */ + private GeneratedFile generate(QueryModel query) { + return codeGenerator.generate( + new QueryGroupModel( + GROUP, + List.of(query) + ) + ); + } + private QueryModel query(String name, List columns, List parameters) { return new QueryModel( name, diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java index f8e14e6..37ef932 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java @@ -1,6 +1,7 @@ package dev.sqlcj.generator; import dev.sqlcj.analysis.QueryColumn; +import dev.sqlcj.analysis.QueryGroupModel; import dev.sqlcj.analysis.QueryModel; import dev.sqlcj.analysis.QueryParameter; import dev.sqlcj.parser.QueryType; @@ -26,6 +27,8 @@ class JavaCodeGeneratorTest { private static final String SQL = "SELECT 1"; + private static final String GROUP = "Users"; + private final CodeGenerator codeGenerator = new JavaCodeGenerator(); @TempDir @@ -45,10 +48,10 @@ void shouldGenerateFilePath() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertEquals( - Path.of("generated", "GetUser.java"), + Path.of("generated", "UsersRepository.java"), file.path() ); } @@ -65,9 +68,9 @@ void shouldGenerateClassName() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); - assertTrue(file.content().contains("public final class GetUser")); + assertTrue(file.content().contains("public final class UsersRepository")); } @Test @@ -82,14 +85,14 @@ void shouldGenerateMethodName() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue(file.content().contains("getUser(")); } @Test void shouldGenerateSingleParameter() { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "GetUser", QueryType.ONE, @@ -106,7 +109,7 @@ void shouldGenerateSingleParameter() { @Test void shouldGenerateMultipleParameters() { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "ListUsersByIdAndName", QueryType.MANY, @@ -126,7 +129,7 @@ void shouldGenerateMultipleParameters() { @Test void shouldGenerateMethodWithoutParameters() { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "ListUsers", QueryType.MANY, @@ -143,7 +146,7 @@ void shouldGenerateMethodWithoutParameters() { @Test void shouldGenerateJavaDoc() { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "GetUser", QueryType.ONE, @@ -162,7 +165,7 @@ void shouldGenerateJavaDoc() { @Test void shouldGeneratePackageDeclaration() { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "GetUser", QueryType.ONE, @@ -195,10 +198,10 @@ void shouldGenerateConfiguredPackageDeclarationAndPath() throws IOException { ) ); - GeneratedFile file = generator.generate(query); + GeneratedFile file = generate(generator, query); assertEquals( - Path.of("dev", "example", "generated", "GetUser.java"), + Path.of("dev", "example", "generated", "UsersRepository.java"), file.path() ); @@ -225,7 +228,7 @@ void shouldGenerateResultRecord() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -250,7 +253,7 @@ void shouldGenerateSingleResultReturnType() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -274,7 +277,7 @@ void shouldGenerateListResultReturnType() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue(file.content().contains("import java.util.List;")); @@ -303,7 +306,7 @@ void shouldGenerateTypedMethodParameters() { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -329,7 +332,7 @@ void shouldGenerateSingleResultWithTypedParameter() { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -352,7 +355,7 @@ void shouldGenerateListImportForSingleResultWithoutParameters() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -371,7 +374,7 @@ void shouldGenerateParameterNamesFromQueryParameters() { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -400,7 +403,7 @@ void shouldGenerateDistinctComponentsAndPositionalReadsForDuplicateColumns() thr List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -412,7 +415,7 @@ void shouldGenerateDistinctComponentsAndPositionalReadsForDuplicateColumns() thr assertTrue(source.contains("resultSet.getObject(2, Long.class)")); assertTrue(source.contains("resultSet.getObject(3, String.class)")); - assertEquals(0, compile(file, "ListUserProfiles.java")); + assertEquals(0, compile(file, "UsersRepository.java")); } @ParameterizedTest @@ -435,7 +438,7 @@ void shouldGenerateJavaTypeForQueryParameter( ColumnType columnType, String expectedJavaType ) { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "GetUser", QueryType.ONE, @@ -466,7 +469,7 @@ void shouldGenerateQuerySpecificResultRecord() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -493,7 +496,7 @@ void shouldGenerateImportForLocalDate() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -520,7 +523,7 @@ void shouldGenerateImportForBigDecimal() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -563,7 +566,7 @@ void shouldGenerateImportsForUuidAndTimestampWithTimeZone() throws IOException { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -609,7 +612,7 @@ void shouldGenerateImportForQueryParameterType() { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -642,7 +645,7 @@ void shouldGenerateEachImportOnlyOnce() { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -677,7 +680,7 @@ void shouldGenerateMultipleRequiredImports() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -710,11 +713,11 @@ void shouldGenerateQueryExecutionMethodBody() { ) ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); assertTrue(source.contains("return executor.query(")); assertTrue(source.contains("java.util.Arrays.asList(id)")); - assertTrue(source.contains("ROW_MAPPER")); + assertTrue(source.contains("getUserRowMapper")); assertFalse(source.contains("UnsupportedOperationException")); } @@ -757,9 +760,9 @@ void shouldGenerateCompilableJavaSource() throws IOException { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); - assertEquals(0, compile(file, "ListUsers.java")); + assertEquals(0, compile(file, "UsersRepository.java")); } /** Compiles one generated source file in an isolated temporary location. */ @@ -822,7 +825,7 @@ void shouldGenerateJavaTypeForResultColumn(ColumnType columnType, String expecte List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -849,7 +852,7 @@ void shouldGenerateWrapperTypeForNullableColumn() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains("Long id") @@ -874,7 +877,7 @@ void shouldGenerateWrapperTypeForNonNullableColumn() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains("Long id") @@ -898,7 +901,7 @@ void shouldGenerateUniqueParameterNamesForDuplicateColumns() { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -925,7 +928,7 @@ void shouldGenerateRowMapperForOneQuery() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -937,7 +940,7 @@ void shouldGenerateRowMapperForOneQuery() { assertTrue( source.contains( - "private static final RowMapper ROW_MAPPER" + "private static final RowMapper getUserRowMapper" ) ); @@ -987,7 +990,7 @@ void shouldPreserveResultColumnOrder() { List.of() ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); int nameIndex = source.indexOf("resultSet.getObject(1, String.class)"); @@ -1011,7 +1014,7 @@ void shouldGenerateRowMapperForManyQuery() { List.of() ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); assertTrue( source.contains( @@ -1021,7 +1024,7 @@ void shouldGenerateRowMapperForManyQuery() { assertTrue( source.contains( - "private static final RowMapper ROW_MAPPER" + "private static final RowMapper listUsersRowMapper" ) ); @@ -1050,7 +1053,7 @@ void shouldGenerateQueryExecutorImport() { List.of() ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); assertTrue( source.contains( @@ -1071,7 +1074,7 @@ void shouldGenerateQueryExecutorField() { List.of() ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); assertTrue( source.contains( @@ -1092,11 +1095,11 @@ void shouldGenerateQueryExecutorConstructor() { List.of() ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); assertTrue( source.contains( - "public GetUser(QueryExecutor executor)" + "public UsersRepository(QueryExecutor executor)" ) ); @@ -1140,7 +1143,7 @@ void shouldGenerateExecutionForOneQuery() { ) ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); assertTrue( source.contains(""" @@ -1162,7 +1165,7 @@ void shouldGenerateExecutionForOneQuery() { assertTrue( source.contains( - "ROW_MAPPER" + "getUserRowMapper" ) ); @@ -1212,7 +1215,7 @@ void shouldGenerateQueryManyExecutionForManyQuery() { ) ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); assertTrue( source.contains(""" @@ -1228,7 +1231,7 @@ void shouldGenerateQueryManyExecutionForManyQuery() { assertTrue( source.contains( - "ROW_MAPPER" + "listUsersRowMapper" ) ); @@ -1264,7 +1267,7 @@ void shouldPreserveParameterOrderWhenGeneratingExecution() { ) ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); assertTrue( source.contains( @@ -1296,7 +1299,7 @@ void shouldGenerateLogicalParametersWithTextualBindingOrder() throws IOException ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -1336,7 +1339,7 @@ void shouldRepeatArgumentForRepeatedPlaceholderIndex() throws IOException { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -1369,7 +1372,7 @@ void shouldGenerateEmptyArgumentListForQueryWithoutParameters() throws IOExcepti List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -1403,7 +1406,7 @@ INSERT INTO users (id, name) ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -1455,6 +1458,145 @@ private void assertCompiles(GeneratedFile file) throws IOException { assertEquals(0, result); } + /** + * One group holding every supported query kind becomes one repository with + * one executor field, one constructor, and one method per query. + */ + @Test + void shouldGenerateOneRepositoryForEveryQueryOfTheGroup() throws IOException { + QueryModel createUser = new QueryModel( + "CreateUser", + QueryType.ONE, + "users", + "INSERT INTO users (name) VALUES (?) RETURNING id, name", + List.of(1), + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("name", ColumnType.VARCHAR, true) + ), + List.of(new QueryParameter(1, "name", ColumnType.VARCHAR)) + ); + + QueryModel getUser = new QueryModel( + "GetUser", + QueryType.ONE, + "users", + "SELECT id FROM users WHERE id = ?", + List.of(1), + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + List.of(new QueryParameter(1, "id", ColumnType.BIGINT)) + ); + + QueryModel listUsers = new QueryModel( + "ListUsers", + QueryType.MANY, + "users", + "SELECT id, birth_date FROM users", + List.of(), + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("birth_date", ColumnType.DATE, true) + ), + List.of() + ); + + QueryModel deleteUser = new QueryModel( + "DeleteUser", + QueryType.EXEC, + "users", + "DELETE FROM users WHERE id = ?", + List.of(1), + List.of(), + List.of(new QueryParameter(1, "id", ColumnType.BIGINT)) + ); + + GeneratedFile file = codeGenerator.generate( + new QueryGroupModel( + GROUP, + List.of(createUser, getUser, listUsers, deleteUser) + ) + ); + + String source = file.content(); + + assertEquals(Path.of("generated", "UsersRepository.java"), file.path()); + + assertEquals( + 1, + source.lines() + .filter(line -> line.equals(" private final QueryExecutor executor;")) + .count() + ); + + assertEquals( + 1, + source.lines() + .filter(line -> line.contains("public UsersRepository(QueryExecutor executor)")) + .count() + ); + + assertTrue(source.contains("public CreateUserResult createUser(String name)")); + assertTrue(source.contains("public GetUserResult getUser(Long id)")); + assertTrue(source.contains("public List listUsers()")); + assertTrue(source.contains("public int deleteUser(Long id)")); + + assertTrue(source.contains("public record CreateUserResult(")); + assertTrue(source.contains("public record GetUserResult(")); + assertTrue(source.contains("public record ListUsersResult(")); + assertFalse(source.contains("public record DeleteUserResult(")); + + assertTrue(source.contains("private static final RowMapper createUserRowMapper")); + assertTrue(source.contains("private static final RowMapper getUserRowMapper")); + assertTrue(source.contains("private static final RowMapper listUsersRowMapper")); + + assertTrue(source.contains("import java.time.LocalDate;")); + + assertEquals( + 1, + source.lines() + .filter(line -> line.equals("import dev.sqlcj.runtime.RowMapper;")) + .count() + ); + + assertTrue(source.indexOf("createUser(") < source.indexOf("getUser(")); + assertTrue(source.indexOf("getUser(") < source.indexOf("listUsers(")); + assertTrue(source.indexOf("listUsers(") < source.indexOf("deleteUser(")); + + assertCompiles(file); + } + + @Test + void shouldGenerateRepositoryWithoutMethodsForEmptyGroup() throws IOException { + GeneratedFile file = codeGenerator.generate( + new QueryGroupModel(GROUP, List.of()) + ); + + String source = file.content(); + + assertEquals(Path.of("generated", "UsersRepository.java"), file.path()); + assertTrue(source.contains("public final class UsersRepository {")); + assertTrue(source.contains("private final QueryExecutor executor;")); + assertTrue(source.contains("public UsersRepository(QueryExecutor executor)")); + assertFalse(source.contains("RowMapper")); + assertFalse(source.contains("public record")); + + assertCompiles(file); + } + + private GeneratedFile generate(QueryModel query) { + return generate(codeGenerator, query); + } + + /** Generates the repository of a single-query group. */ + private GeneratedFile generate(CodeGenerator generator, QueryModel query) { + return generator.generate( + new QueryGroupModel( + GROUP, + List.of(query) + ) + ); + } + private QueryModel query(String name, QueryType type, List parameters) { return new QueryModel( name, diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaNamesTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaNamesTest.java index a32f16a..73d8dd6 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaNamesTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaNamesTest.java @@ -1,6 +1,7 @@ package dev.sqlcj.generator; import dev.sqlcj.analysis.QueryColumn; +import dev.sqlcj.analysis.QueryGroupModel; import dev.sqlcj.analysis.QueryModel; import dev.sqlcj.analysis.QueryParameter; import dev.sqlcj.parser.QueryType; @@ -9,71 +10,65 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import java.util.Arrays; import java.util.List; import java.util.stream.IntStream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; class JavaNamesTest { private static final String SQL = "SELECT 1"; @Test - void shouldKeepSafeQueryNameAsClassName() { - JavaNames names = names("GetUser"); + void shouldNameRepositoryAfterConfiguredGroupName() { + JavaNames names = names("Author", "GetAuthor"); + + assertEquals("AuthorRepository", names.repositoryClassName()); + } + + @Test + void shouldKeepSafeQueryNameAsResultTypeAndMethodName() { + JavaNames.QueryNames names = queryNames("GetUser"); - assertEquals("GetUser", names.className()); assertEquals("GetUserResult", names.resultTypeName()); assertEquals("getUser", names.methodName()); + assertEquals("getUserRowMapper", names.rowMapperName()); } @Test - void shouldKeepSafeLowercaseQueryNameAsClassName() { - JavaNames names = names("getUser"); + void shouldKeepSafeLowercaseQueryNameAsMethodName() { + JavaNames.QueryNames names = queryNames("getUser"); - assertEquals("getUser", names.className()); + assertEquals("getUserResult", names.resultTypeName()); assertEquals("getUser", names.methodName()); } @Test - void shouldKeepClassNameThatIsOnlyAKeywordAsMethodName() { - JavaNames names = names("Class"); + void shouldKeepKeywordMethodNameSafe() { + JavaNames.QueryNames names = queryNames("Class"); - assertEquals("Class", names.className()); + assertEquals("ClassResult", names.resultTypeName()); assertEquals("class_", names.methodName()); } @ParameterizedTest @CsvSource( { - "Get-User, Get_User", - "get user, get_user", - "'Get**User', Get_User", - "'get user ', get_user_", - "1stQuery, _1stQuery", - "default, default_", - "int, int_", - "'true', true_", - "_, __" - } - ) - void shouldNormalizeUnsafeQueryName(String queryName, String expectedClassName) { - assertEquals(expectedClassName, names(queryName).className()); - } - - @ParameterizedTest - @CsvSource( - { - "List, List_", - "String, String_", - "RowMapper, RowMapper_", - "QueryExecutor, QueryExecutor_", - "record, record_", - "var, var_" + "Get-User, Get_UserResult", + "get user, get_userResult", + "'Get**User', Get_UserResult", + "'get user ', get_user_Result", + "1stQuery, _1stQueryResult", + "default, default_Result", + "int, int_Result", + "'true', true_Result", + "_, __Result" } ) - void shouldRenameClassConflictingWithGeneratedOrImportedType(String queryName, String expectedClassName) { - assertEquals(expectedClassName, names(queryName).className()); + void shouldNormalizeUnsafeQueryName(String queryName, String expectedResultTypeName) { + assertEquals(expectedResultTypeName, queryNames(queryName).resultTypeName()); } @ParameterizedTest @@ -86,19 +81,63 @@ void shouldRenameClassConflictingWithGeneratedOrImportedType(String queryName, S } ) void shouldRenameMethodConflictingWithInheritedObjectMethod(String queryName, String expectedMethodName) { - assertEquals(expectedMethodName, names(queryName).methodName()); + assertEquals(expectedMethodName, queryNames(queryName).methodName()); + } + + @Test + void shouldRejectQueriesOfOneGroupThatGenerateTheSameMethod() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> names("Author", "Get.User", "Get-User") + ); + + assertEquals( + "Queries 'Get.User' and 'Get-User' generate the same repository method 'get_User'", + exception.getMessage() + ); + } + + /** + * The compiled class files of two nested result types that differ only by + * case are one path on a case-insensitive filesystem. + */ + @Test + void shouldRejectQueriesOfOneGroupWhoseResultTypesDifferOnlyByCase() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> names("Author", "GetUser", "getuser") + ); + + assertEquals( + "Queries 'GetUser' and 'getuser' generate result types that differ only by case: " + + "GetUserResult and getuserResult", + exception.getMessage() + ); + } + + @Test + void shouldKeepTheSameQueryNameInTwoRepositoriesIndependent() { + assertEquals( + "getUser", + names("Author", "GetUser").queries().getFirst().methodName() + ); + + assertEquals( + "getUser", + names("Book", "GetUser").queries().getFirst().methodName() + ); } @Test void shouldKeepSafeColumnNamesAsResultComponents() { - JavaNames names = names("ListUsers", List.of(), List.of("id", "created_at")); + JavaNames.QueryNames names = queryNames("ListUsers", List.of(), List.of("id", "created_at")); assertEquals(List.of("id", "created_at"), names.componentNames()); } @Test void shouldNormalizeAndDisambiguateResultComponents() { - JavaNames names = names( + JavaNames.QueryNames names = queryNames( "ListUsers", List.of(), List.of("user id", "user-id", "class", "hashCode") @@ -112,7 +151,7 @@ void shouldNormalizeAndDisambiguateResultComponents() { @Test void shouldSkipUsedCandidateWhenDisambiguatingResultComponents() { - JavaNames names = names( + JavaNames.QueryNames names = queryNames( "ListUsers", List.of(), List.of("id", "id", "id1") @@ -123,28 +162,50 @@ void shouldSkipUsedCandidateWhenDisambiguatingResultComponents() { @Test void shouldKeepExistingDuplicateParameterNaming() { - JavaNames names = names("FindUsers", List.of("id", "id"), List.of()); + JavaNames.QueryNames names = queryNames("FindUsers", List.of("id", "id"), List.of()); assertEquals(List.of("id1", "id2"), names.parameterNames()); } @Test void shouldResolveParameterNamesReservedByGeneratedCode() { - JavaNames names = names( + JavaNames.QueryNames names = queryNames( "FindUsers", - List.of("executor", "ROW_MAPPER", "name"), + List.of("executor", "findUsersRowMapper", "name"), List.of() ); assertEquals( - List.of("executor1", "ROW_MAPPER1", "name"), + List.of("executor1", "findUsersRowMapper1", "name"), names.parameterNames() ); } + /** + * A parameter must not shadow the row mapper of another method of the same + * repository either, because every mapper is a field of that repository. + */ + @Test + void shouldResolveParameterNameReservedByAnotherQueryRowMapper() { + JavaNames names = JavaNames.of( + new QueryGroupModel( + "Users", + List.of( + query("FindUsers", List.of("listUsersRowMapper"), List.of()), + query("ListUsers", List.of(), List.of("id")) + ) + ) + ); + + assertEquals( + List.of("listUsersRowMapper1"), + names.queries().getFirst().parameterNames() + ); + } + @Test void shouldResolveParameterAndComponentNamesIndependently() { - JavaNames names = names( + JavaNames.QueryNames names = queryNames( "FindUsers", List.of("id", "id"), List.of("id") @@ -154,11 +215,41 @@ void shouldResolveParameterAndComponentNamesIndependently() { assertEquals(List.of("id"), names.componentNames()); } - private JavaNames names(String queryName) { - return names(queryName, List.of(), List.of()); + private JavaNames names(String groupName, String... queryNames) { + return JavaNames.of( + new QueryGroupModel( + groupName, + Arrays.stream(queryNames) + .map(queryName -> query(queryName, List.of(), List.of())) + .toList() + ) + ); + } + + private JavaNames.QueryNames queryNames(String queryName) { + return queryNames(queryName, List.of(), List.of()); } - private JavaNames names(String queryName, List parameterNames, List columnNames) { + private JavaNames.QueryNames queryNames( + String queryName, + List parameterNames, + List columnNames + ) { + return JavaNames.of( + new QueryGroupModel( + "Users", + List.of(query(queryName, parameterNames, columnNames)) + ) + ) + .queries() + .getFirst(); + } + + private QueryModel query( + String queryName, + List parameterNames, + List columnNames + ) { List parameters = IntStream.range(0, parameterNames.size()) .mapToObj( index -> new QueryParameter( @@ -173,18 +264,16 @@ private JavaNames names(String queryName, List parameterNames, List new QueryColumn(name, ColumnType.BIGINT, false)) .toList(); - return JavaNames.of( - new QueryModel( - queryName, - QueryType.MANY, - "users", - SQL, - parameters.stream() - .map(QueryParameter::index) - .toList(), - columns, - parameters - ) + return new QueryModel( + queryName, + QueryType.MANY, + "users", + SQL, + parameters.stream() + .map(QueryParameter::index) + .toList(), + columns, + parameters ); } } diff --git a/sqlcj.yaml b/sqlcj.yaml index 4990b07..f7d1cec 100644 --- a/sqlcj.yaml +++ b/sqlcj.yaml @@ -1,7 +1,8 @@ version: "1" sql: - - schema: schema.sql + - name: User + schema: schema.sql queries: queries.sql java: From 85f2aa319b853bffa0c0802c467e74bd4690f2c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Thu, 24 Sep 2026 20:36:34 +0300 Subject: [PATCH 2/2] docs: document the generated repository API --- README.md | 23 +++++-- docs/configuration.md | 136 ++++++++++++++++++++++++++++-------------- docs/postgresql.md | 11 ++-- docs/queries.md | 86 ++++++++++++++++---------- docs/quickstart.md | 79 ++++++++++++------------ 5 files changed, 210 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index 2489344..b4a0391 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,21 @@ sqlcj is a SQL compiler and type-safe Java code generator for PostgreSQL, inspired by [sqlc](https://github.com/sqlc-dev/sqlc). You write a PostgreSQL schema snapshot and named SQL queries. sqlcj analyzes -them against the schema and generates readable Java classes with typed -parameters and typed result records, which execute through a small JDBC runtime: +them against the schema and generates one readable Java repository per named +query group, with typed parameters and typed result records, which executes +through a small JDBC runtime: ```text schema + named SQL -> sqlcj generate -> generated Java -> JDBC ``` +```yaml +sql: + - name: Author + schema: sql/schema.sql + queries: sql/queries.sql +``` + ```sql -- name: GetAuthor :one SELECT id, name, bio @@ -19,9 +27,14 @@ WHERE id = $1; ``` ```java -GetAuthor.GetAuthorResult author = new GetAuthor(executor).getAuthor(1L); +AuthorRepository authors = new AuthorRepository(executor); + +AuthorRepository.GetAuthorResult author = authors.getAuthor(1L); ``` +Every query of `sql/queries.sql` becomes a method of that one +`AuthorRepository`. + The SQL stays visible and owned by the application. sqlcj is not an ORM, a migration tool, or a query builder: it does not run migrations, inspect a live database, or build queries at runtime. @@ -75,8 +88,8 @@ operations, including an application-controlled commit and rollback. empty directory. - [Queries](docs/queries.md) — query annotations, the supported SQL shapes, parameter and binding order, the generated API, and what is not supported. -- [Configuration](docs/configuration.md) — the `sqlcj.yaml` format, path - resolution, and generated Java naming. +- [Configuration](docs/configuration.md) — the `sqlcj.yaml` format, query-group + names, path resolution, and generated Java naming. - [PostgreSQL Support](docs/postgresql.md) — the engine contract, accepted column types and `CREATE TABLE` constructs, null handling, and connection ownership. diff --git a/docs/configuration.md b/docs/configuration.md index 0ea9c95..b67dc12 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -28,7 +28,8 @@ blocking JDBC execution; there is no engine or dialect option. See ```yaml version: "1" sql: - - schema: schema.sql + - name: Author + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -41,6 +42,7 @@ java: | --- | --- | --- | | `version` | string | Configuration contract version. Must be `"1"`. | | `sql` | list | Non-empty, ordered list of source entries. | +| `sql[].name` | string | Identity of the query group. Names the generated repository. | | `sql[].schema` | string | Path to a file containing `CREATE TABLE` statements. | | `sql[].queries` | string | Path to a file containing named queries. | | `java` | mapping | Java generation settings. | @@ -49,17 +51,36 @@ java: ### `sql` entries -Each entry pairs one schema source with one named-query source. Entries are -loaded in declared order, each entry's queries are analyzed against that entry's -own schema, and query order inside a query file is preserved. +Each entry pairs one group name with one schema source and one named-query +source. Entries are loaded in declared order, each entry's queries are analyzed +against that entry's own schema, and query order inside a query file is +preserved. Schemas are not shared or merged between entries. A query can only use tables declared in the schema file of its own entry. -Query names must be unique across all configured query sources, because each -query name becomes a generated Java class name. See -[Generated Java Names](#generated-java-names) for how a query name becomes a -class name and when two query names collide. +One entry generates exactly one repository containing every query of its query +source, in declared query order. Query names are therefore scoped to their +entry: two entries may use the same query name, while two queries of one entry +that generate the same method name are rejected. See +[Generated Java Names](#generated-java-names) for the naming rules and the +collisions that end a run. + +### `sql[].name` + +`sql[].name` is required and is used unchanged as the prefix of the generated +repository type name, so the entry `name: Author` generates `AuthorRepository`. + +It must be a single valid, non-blank Java identifier. A Java keyword, the +literals `true`, `false`, and `null`, the identifier `_`, and a restricted +identifier such as `var` or `record` are rejected: + +```text +sqlcj: Invalid configuration in /home/dev/project/sqlcj.yaml: 'sql[0].name' value 'record' is not a valid Java identifier for a generated repository name +``` + +sqlcj does not derive the name from a table or a file name. A query file may +join or write several tables, so the group boundary is declared, not guessed. ### `java.package` @@ -67,11 +88,11 @@ class name and when two query names collide. identifiers, for example `dev.example.generated`. It is used both for the generated `package` declaration and for the generated file layout. -A query named `GetUser` with `java.package: dev.example.generated` and +An entry named `Author` with `java.package: dev.example.generated` and `java.out: generated` is written to: ```text -generated/dev/example/generated/GetUser.java +generated/dev/example/generated/AuthorRepository.java ``` ### Value rules @@ -80,8 +101,8 @@ generated/dev/example/generated/GetUser.java - Unknown fields are rejected. - Wrong-typed fields are rejected, including an unquoted numeric `version`. - A missing or unsupported `version`, a missing section, an empty `sql` list, a - null `sql` entry, a blank value, and an invalid `java.package` are all invalid - configuration. + null `sql` entry, a blank value, an invalid `sql[].name`, and an invalid + `java.package` are all invalid configuration. ## Path Resolution @@ -99,77 +120,103 @@ canonical location. ```yaml version: "1" sql: - - schema: sql/users/schema.sql + - name: User + schema: sql/users/schema.sql queries: sql/users/queries.sql - - schema: sql/orders/schema.sql + - name: Order + schema: sql/orders/schema.sql queries: sql/orders/queries.sql java: package: dev.example.generated out: target/generated-sources/sqlcj ``` -With the configuration above and queries `GetUser` and `ListOrders`, sqlcj -generates: +With the configuration above, sqlcj generates one repository per entry: ```text -target/generated-sources/sqlcj/dev/example/generated/GetUser.java -target/generated-sources/sqlcj/dev/example/generated/ListOrders.java +target/generated-sources/sqlcj/dev/example/generated/UserRepository.java +target/generated-sources/sqlcj/dev/example/generated/OrderRepository.java ``` +Both repositories may contain a query named `GetById`, because each name is +resolved inside its own repository. + ## Generated Java Names -Query names and SQL column names become Java identifiers. SQL identifier -delimiters are removed before a name is analyzed, so the quoted column -`"user id"` has the JDBC label `user id`, while executable SQL keeps the query -exactly as written. +The configured `sql[].name` and the SQL names inside the entry become Java +identifiers. SQL identifier delimiters are removed before a name is analyzed, so +the quoted column `"user id"` has the JDBC label `user id`, while executable SQL +keeps the query exactly as written. + +The repository name is the configured `sql[].name` followed by `Repository`, +without normalization, because configuration already requires a valid Java +identifier. -A name that is already a valid, non-reserved Java identifier keeps its spelling: +A query or column name that is already a valid, non-reserved Java identifier +keeps its spelling: -- a query named `GetUser` generates the class `GetUser` and the method `getUser`, -- a query named `getUser` generates the class `getUser` and the method `getUser`, +- a query named `GetUser` generates the result record `GetUserResult` and the + method `getUser`, - a column named `created_at` generates the record component `created_at`. Any other name is normalized deterministically: - each maximal run of characters that cannot appear in a Java identifier becomes - a single `_`, so `Get-User` and `Get*/User` both generate the class `Get_User`, + a single `_`, so `Get-User` and `Get*/User` both generate the result record + `Get_UserResult`, - a leading `_` is added when the first character cannot start an identifier, so - `1stQuery` generates the class `_1stQuery`, + `1stQuery` generates `_1stQueryResult`, - a trailing `_` is added to a Java keyword, to `true`, `false`, `null`, and to `_`, so a column named `class` generates the component `class_`. Generated names also avoid names that the generated source already uses: -- a class name never repeats an imported or generated type name such as `List`, - `String`, `RowMapper`, or `QueryExecutor`, and never uses a restricted type - identifier such as `record` or `var`; such a name gets a trailing `_`, so a - query named `List` generates the class `List_`, - the generated method name keeps the lower-initial rule and avoids Java keywords and inherited `Object` method names, so a query named `Class` - generates the class `Class` with the method `class_`, -- record components avoid inherited `Object` method names, and method parameters - avoid the generator-owned names `executor` and `ROW_MAPPER`. + generates the record `ClassResult` and the method `class_`, +- record components avoid inherited `Object` method names, +- method parameters avoid the generator-owned name `executor` and the row-mapper + field names of the repository, which are the method name followed by + `RowMapper`. Method parameters and record components are disambiguated inside their own -generated class, in logical parameter order and selected-column order, using the -suffixes `1`, `2`, and so on. Two parameters resolved from the column `id` -become `id1` and `id2`, and the columns `user id` and `user-id` become the -components `user_id1` and `user_id2`. +generated method or record, in logical parameter order and selected-column +order, using the suffixes `1`, `2`, and so on. Two parameters resolved from the +column `id` become `id1` and `id2`, and the columns `user id` and `user-id` +become the components `user_id1` and `user_id2`. The generated row mapper reads each result column by its one-based position in the selected-column list, so renaming a component never changes which column it reads, and identically named columns selected from different query sources stay distinct. +### Repository method collisions + +Two queries of one entry that generate the same method name are rejected instead +of being renamed, because a repository method is a name the application calls. +The diagnostic names both queries: + +```text +sqlcj: Invalid query group 'User' in /home/dev/project/sql/queries.sql: Queries 'Get.User' and 'Get-User' generate the same repository method 'get_User' +``` + +Two queries of one entry whose nested result types differ only by case are +rejected for the same reason, because those class files are one path on a +case-insensitive filesystem: + +```text +sqlcj: Invalid query group 'User' in /home/dev/project/sql/queries.sql: Queries 'GetUser' and 'getuser' generate result types that differ only by case: GetUserResult and getuserResult +``` + ### Generated path collisions -Two queries whose generated class names resolve to the same file path, or to -paths that differ only by case and are therefore not portable, are rejected -before any file of the run is written, so existing output is not overwritten: +Two entries whose repository files resolve to the same path, or to paths that +differ only by case and are therefore not portable, are rejected before any file +of the run is written, so existing output is not overwritten: ```text -sqlcj: Duplicate generated file for queries 'Get.User' and 'Get-User': generated/Get_User.java -sqlcj: Generated file paths for queries 'GetUser' and 'getuser' differ only by case: generated/GetUser.java and generated/getuser.java +sqlcj: Duplicate generated file for repositories 'User' and 'User': generated/UserRepository.java +sqlcj: Generated file paths for repositories 'User' and 'user' differ only by case: generated/UserRepository.java and generated/userRepository.java ``` ## Diagnostics @@ -198,6 +245,7 @@ from a previous run. sqlcj does not remove or roll back files it has already written. sqlcj also never deletes a generated file that the current run did not produce, -so a renamed or deleted query leaves its previous class behind. Generate into a +so a renamed or removed configuration entry leaves its previous repository +behind. Generate into a build-owned directory and let the build's clean step remove stale output, as the [Quickstart](quickstart.md) does. diff --git a/docs/postgresql.md b/docs/postgresql.md index 1e82dc1..4a79591 100644 --- a/docs/postgresql.md +++ b/docs/postgresql.md @@ -23,7 +23,7 @@ supplies the PostgreSQL driver. `JdbcQueryExecutor` has two construction paths. Both share the same positional parameter binding, row mapping, single-row and multi-row result handling, affected-row counting, and exception translation, and both accept the same -generated query classes without regeneration: +generated repositories without regeneration: | Construction | Connection ownership | | --- | --- | @@ -32,8 +32,9 @@ generated query classes without regeneration: The caller-owned connection path is how several generated operations take part in one application-controlled transaction: the application disables auto-commit, -runs generated reads and writes through one executor, and then calls `commit` or -`rollback` itself. sqlcj provides no transaction callback or template API, no +constructs another repository instance over an executor bound to that +connection, runs generated reads and writes through it, and then calls `commit` +or `rollback` itself. sqlcj provides no transaction callback or template API, no savepoints, and no isolation configuration. The [Quickstart](quickstart.md) runs that pattern end to end. @@ -52,7 +53,7 @@ connection inherits that connection's confinement to a single thread at a time. Behavior is verified against PostgreSQL 16. The pipeline is executed end to end against a `postgres:16-alpine` container: the schema snapshot is run as -PostgreSQL DDL, the generated Java is compiled, and the generated classes are +PostgreSQL DDL, the generated Java is compiled, and the generated repository is executed through the JDBC runtime. Those tests are skipped when Docker is unavailable. @@ -96,7 +97,7 @@ accepted and map exactly like their unparameterized spellings. Any spelling that is not listed above is rejected. -The generated class imports `java.time.LocalDate`, `java.time.LocalDateTime`, +The generated repository imports `java.time.LocalDate`, `java.time.LocalDateTime`, `java.time.OffsetDateTime`, `java.math.BigDecimal`, and `java.util.UUID` as needed; the remaining types need no import. Each result column is read with `resultSet.getObject(position, JavaType.class)` at its one-based position in the diff --git a/docs/queries.md b/docs/queries.md index 3e2fe6e..b927ca8 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -28,9 +28,11 @@ WHERE id = $1; the query annotation, separated by whitespace. - A query owns every following line until the next header or the end of the file. The statement's trailing `;` is part of the query. -- Query order inside a file is preserved. -- A query name must be unique across every configured query source, because it - becomes a generated Java class name. +- Query order inside a file is preserved, and it is the order of the methods of + the generated repository. +- A query name must be unique inside its own query source. Two configuration + entries may use the same query name, because each entry generates its own + repository. - An unparsable header, an unknown annotation, a duplicate name, and a header without SQL are all rejected. @@ -200,22 +202,27 @@ types differ are rejected. ## Generated Java -Each query generates one final class in the configured `java.package`, written -to the package directory under `java.out` and named after the query. +Each configuration entry generates one final repository class in the configured +`java.package`, written to the package directory under `java.out` and named +`Repository`. Every named query of that entry becomes one method of +that repository; sqlcj never generates a class per query. -- The class has a single constructor taking a - `dev.sqlcj.runtime.QueryExecutor`. -- The generated method name is the class name with a lower-case first character. +- The repository has one `dev.sqlcj.runtime.QueryExecutor` field and one + constructor taking that executor. +- Methods appear in query-source order. A method name is the query name with a + lower-case first character. - A `:one` or `:many` query also generates a nested `public record` named - `Result` whose components follow the selected-column order, plus a - private `RowMapper` that reads each column by its one-based position. + `Result` whose components follow the selected-column order, plus a + private `RowMapper` field that reads each column by its one-based position. - A `:exec` query generates no result record and returns `int`. - Generated source imports only `dev.sqlcj.runtime.QueryExecutor`, `dev.sqlcj.runtime.RowMapper`, `java.util.List`, and the JDK types of the mapped columns, so the runtime artifact is the only sqlcj dependency a consumer needs. -`CreateAuthor` above generates: +The `Author` entry of the [Quickstart](quickstart.md), which declares +`CreateAuthor`, `GetAuthor`, `ListAuthors`, `UpdateAuthorBio`, and +`DeleteAuthor`, generates one `AuthorRepository`: ```java package com.example.app.db; @@ -227,17 +234,13 @@ import java.util.List; /** * Generated by sqlcj. * - * Query: CreateAuthor - * Table: authors - * Type: ONE + * Repository: Author */ - - -public final class CreateAuthor { +public final class AuthorRepository { private final QueryExecutor executor; - public CreateAuthor(QueryExecutor executor) { + public AuthorRepository(QueryExecutor executor) { this.executor = executor; } @@ -248,36 +251,52 @@ public final class CreateAuthor { ) { } - private static final RowMapper ROW_MAPPER = + private static final RowMapper createAuthorRowMapper = resultSet -> new CreateAuthorResult( resultSet.getObject(1, Long.class), resultSet.getObject(2, String.class), resultSet.getObject(3, String.class) ); + /** + * Query: CreateAuthor + * Table: authors + * Type: ONE + */ public CreateAuthorResult createAuthor(String name, String bio) { return executor.query( - """ - INSERT INTO authors (name, bio) - VALUES (?, ?) - RETURNING id, name, bio; - """, - java.util.Arrays.asList(name, bio), - ROW_MAPPER + """ + INSERT INTO authors (name, bio) + VALUES (?, ?) + RETURNING id, name, bio;""", + java.util.Arrays.asList(name, bio), + createAuthorRowMapper ); } + + // getAuthor, listAuthors, updateAuthorBio, and deleteAuthor follow here. } ``` +The application constructs the repository once per execution context: + +```java +AuthorRepository authors = new AuthorRepository(new JdbcQueryExecutor(dataSource)); + +AuthorRepository.GetAuthorResult author = authors.getAuthor(1L); +``` + A query name, column name, or parameter name that is not already a valid Java identifier is normalized deterministically, and names that would collide inside -one generated class are disambiguated in their SQL order. Those rules, and the -rejection of two queries whose generated files would collide, are documented in +one generated repository are disambiguated in their SQL order. Those rules, the +rejection of two queries of one entry that generate the same method, and the +rejection of two entries whose repository files would collide, are documented in [Generated Java Names](configuration.md#generated-java-names). -The generated classes are executed through `dev.sqlcj.runtime.JdbcQueryExecutor` -on a `DataSource` or on a caller-owned `Connection`. Connection ownership, -transaction control, and exception translation are documented in +The generated repositories are executed through +`dev.sqlcj.runtime.JdbcQueryExecutor` on a `DataSource` or on a caller-owned +`Connection`. Connection ownership, transaction control, and exception +translation are documented in [Connection Ownership and Transactions](postgresql.md#connection-ownership-and-transactions). ## Unsupported Queries @@ -417,8 +436,9 @@ Parameters: Generated Java: -- `JavaCodeGeneratorTest` covers the generated class, constructor, result - record, row mapper, method signature, and compilation of the generated source. +- `JavaCodeGeneratorTest` covers the generated repository, its single executor + field and constructor, the nested result records and row mappers, method + signatures, and compilation of the generated source. - `SqlcjCompilerIntegrationTest.shouldGenerateCompilableJavaFiles` and `SqlcjCompilerIntegrationTest.shouldGenerateCompilableJavaForReturningWrites` compile the generated output of the supported query shapes. diff --git a/docs/quickstart.md b/docs/quickstart.md index ab50683..f7fcf43 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -139,13 +139,18 @@ Two details matter: ```yaml version: "1" sql: - - schema: sql/schema.sql + - name: Author + schema: sql/schema.sql queries: sql/queries.sql java: package: com.example.app.db out: target/generated-sources/sqlcj ``` +`sql[].name` is the identity of the query group. It names the generated +repository, so the entry above generates one `AuthorRepository` holding every +query of `sql/queries.sql`. + `sqlcj generate` reads `sqlcj.yaml` from the directory it is run in, and the relative paths above are resolved against the directory that contains the file. Generated output therefore belongs in the build directory, where `mvn clean` @@ -198,21 +203,18 @@ FROM authors WHERE id = $1; ``` -Each query generates one class named after it: `CreateAuthor`, `GetAuthor`, -`ListAuthors`, `UpdateAuthorBio`, and `DeleteAuthor`. A `:one` or `:many` query -also generates a nested result record such as `GetAuthor.GetAuthorResult`. The -full query contract is documented in [Queries](queries.md). +All five queries become methods of the one generated `AuthorRepository`: +`createAuthor`, `getAuthor`, `listAuthors`, `updateAuthorBio`, and +`deleteAuthor`. A `:one` or `:many` query also generates a nested result record +such as `AuthorRepository.GetAuthorResult`. The full query contract is +documented in [Queries](queries.md). ## 8. `src/main/java/com/example/app/App.java` ```java package com.example.app; -import com.example.app.db.CreateAuthor; -import com.example.app.db.DeleteAuthor; -import com.example.app.db.GetAuthor; -import com.example.app.db.ListAuthors; -import com.example.app.db.UpdateAuthorBio; +import com.example.app.db.AuthorRepository; import dev.sqlcj.runtime.JdbcQueryExecutor; import dev.sqlcj.runtime.QueryExecutor; import org.postgresql.ds.PGSimpleDataSource; @@ -228,61 +230,60 @@ public final class App { QueryExecutor executor = new JdbcQueryExecutor(dataSource); - CreateAuthor.CreateAuthorResult created = new CreateAuthor(executor) - .createAuthor("Ada Lovelace", "First programmer"); + AuthorRepository authors = new AuthorRepository(executor); + + AuthorRepository.CreateAuthorResult created = + authors.createAuthor("Ada Lovelace", "First programmer"); System.out.println("created: " + created.id() + " " + created.name()); - GetAuthor.GetAuthorResult read = new GetAuthor(executor).getAuthor(created.id()); + AuthorRepository.GetAuthorResult read = authors.getAuthor(created.id()); System.out.println("read: " + read.name() + " / " + read.bio()); - int updatedRows = new UpdateAuthorBio(executor) - .updateAuthorBio(created.id(), "Mathematician"); + int updatedRows = authors.updateAuthorBio(created.id(), "Mathematician"); System.out.println("updated rows: " + updatedRows); - for (ListAuthors.ListAuthorsResult author : new ListAuthors(executor).listAuthors()) { + for (AuthorRepository.ListAuthorsResult author : authors.listAuthors()) { System.out.println("listed: " + author.id() + " " + author.name()); } - System.out.println("missing row: " + new GetAuthor(executor).getAuthor(-1L)); + System.out.println("missing row: " + authors.getAuthor(-1L)); try (Connection connection = dataSource.getConnection()) { connection.setAutoCommit(false); - QueryExecutor transactional = new JdbcQueryExecutor(connection); + AuthorRepository transactionalAuthors = + new AuthorRepository(new JdbcQueryExecutor(connection)); - CreateAuthor.CreateAuthorResult committed = new CreateAuthor(transactional) - .createAuthor("Grace Hopper", null); + AuthorRepository.CreateAuthorResult committed = + transactionalAuthors.createAuthor("Grace Hopper", null); - new UpdateAuthorBio(transactional).updateAuthorBio(committed.id(), "Compiler pioneer"); + transactionalAuthors.updateAuthorBio(committed.id(), "Compiler pioneer"); connection.commit(); - System.out.println( - "committed: " + new GetAuthor(executor).getAuthor(committed.id()).bio() - ); + System.out.println("committed: " + authors.getAuthor(committed.id()).bio()); } try (Connection connection = dataSource.getConnection()) { connection.setAutoCommit(false); - QueryExecutor transactional = new JdbcQueryExecutor(connection); + AuthorRepository transactionalAuthors = + new AuthorRepository(new JdbcQueryExecutor(connection)); - CreateAuthor.CreateAuthorResult discarded = new CreateAuthor(transactional) - .createAuthor("Temporary Author", null); + AuthorRepository.CreateAuthorResult discarded = + transactionalAuthors.createAuthor("Temporary Author", null); - new UpdateAuthorBio(transactional).updateAuthorBio(discarded.id(), "never stored"); + transactionalAuthors.updateAuthorBio(discarded.id(), "never stored"); connection.rollback(); - System.out.println( - "rolled back: " + new GetAuthor(executor).getAuthor(discarded.id()) - ); + System.out.println("rolled back: " + authors.getAuthor(discarded.id())); } - System.out.println("deleted rows: " + new DeleteAuthor(executor).deleteAuthor(created.id())); + System.out.println("deleted rows: " + authors.deleteAuthor(created.id())); } private static DataSource dataSource() { @@ -297,6 +298,10 @@ public final class App { } ``` +One repository instance serves the whole `DataSource`-backed execution context, +and each transaction constructs another repository over its caller-owned +connection. No code constructs a type per query. + ## 9. Start PostgreSQL and apply the schema ```bash @@ -336,14 +341,10 @@ mvn exec:java - `mvn compile` then compiles `src/main/java` together with `target/generated-sources/sqlcj`. -Generation writes one file per query: +Generation writes one file per configured entry: ```text -target/generated-sources/sqlcj/com/example/app/db/CreateAuthor.java -target/generated-sources/sqlcj/com/example/app/db/DeleteAuthor.java -target/generated-sources/sqlcj/com/example/app/db/GetAuthor.java -target/generated-sources/sqlcj/com/example/app/db/ListAuthors.java -target/generated-sources/sqlcj/com/example/app/db/UpdateAuthorBio.java +target/generated-sources/sqlcj/com/example/app/db/AuthorRepository.java ``` `mvn exec:java` prints: @@ -361,6 +362,8 @@ deleted rows: 1 That output is the whole MVP contract in one run: +- One `AuthorRepository` instance answers every call, and each generated method + keeps the types and order of its named query. - `CreateAuthor` is a `:one` write whose `RETURNING` clause reads back the database-generated `BIGSERIAL` identifier as a typed `Long`. - `GetAuthor` is a `:one` read, and returns `null` when no row matches.