From 2aca8afcd0c0ae46abf7dbc66b5c238fb2c6082b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Mon, 28 Sep 2026 20:22:12 +0300 Subject: [PATCH 1/3] feat(generator): generate one top-level row record per table feat(generator): generate one top-level row record per table Every repository of the package that returns the complete row of a table now returns one top-level Row record generated into its own file, instead of nesting its own copy. Each repository keeps its row mapper. Two entries that define a shared row from different columns, or whose row types are equal ignoring case, fail before any output is written. Row files are listed in the manifest and deleted once no entry returns them. --- .../dev/sqlcj/compiler/SqlcjCompiler.java | 10 +- .../dev/sqlcj/generator/CodeGenerator.java | 21 + .../sqlcj/generator/JavaCodeGenerator.java | 181 ++++++- .../java/dev/sqlcj/generator/JavaNames.java | 10 +- .../SqlcjCompilerIntegrationTest.java | 495 +++++++++++++----- .../generator/JavaCodeGeneratorTest.java | 265 ++++++++-- 6 files changed, 815 insertions(+), 167 deletions(-) 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 1ed8152..f968198 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java @@ -67,7 +67,10 @@ public void compile(Config config) { writeManifest(outputDirectory, generatedPaths); } - /** Generates one repository per configured query group. */ + /** + * Generates one repository per configured query group, followed by the row + * records those groups share. + */ private List generate(List sources, CodeGenerator codeGenerator) { List files = new ArrayList<>(); Map generatedRepositories = new HashMap<>(); @@ -86,6 +89,8 @@ private List generate(List sources, CodeGenerator codeGen files.add(file); } + files.addAll(codeGenerator.generateRows()); + return files; } @@ -151,7 +156,8 @@ private QueryModel analyzeQuery(Source source, Query query, Schema schema) { /** * Generates one group, reporting a generation failure such as a repeated - * repository method with the group and its query source. + * repository method, or a row record that conflicts with the row of an + * earlier group, with the group and its query source. */ private GeneratedFile generateRepository( Source source, 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 014a6ac..5ddf479 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/generator/CodeGenerator.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/generator/CodeGenerator.java @@ -2,7 +2,28 @@ import dev.sqlcj.analysis.QueryGroupModel; +import java.util.List; + +/** + * Generates the source files of one package from the query groups of that + * package. + * + *

A generator is used for one package: every group of the package is + * generated through the same generator, in configuration order, and + * {@link #generateRows()} then yields the row records those groups share. + */ public interface CodeGenerator { + /** + * Generates the repository of one query group and records the row records + * it returns, which the package generates once and every repository + * returning them shares. + */ GeneratedFile generate(QueryGroupModel group); + + /** + * Generates one row record per table whose complete row a generated group + * returns, in the order the groups first returned them. + */ + List generateRows(); } 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 ac4a0ae..45e1584 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java @@ -15,6 +15,7 @@ import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -30,6 +31,15 @@ public final class JavaCodeGenerator implements CodeGenerator { private final TypeResolver typeResolver = new DefaultTypeResolver(); private final String packageName; + /** + * The row records of the package, keyed by row identity and ordered by the + * group that first returned them. + */ + private final Map rows = new LinkedHashMap<>(); + + /** The rows of the package, keyed by the lower-case row type name. */ + private final Map rowsByPortabilityKey = new LinkedHashMap<>(); + public JavaCodeGenerator() { this(DEFAULT_PACKAGE); } @@ -40,18 +50,115 @@ public JavaCodeGenerator(String packageName) { /** * Generates one repository holding every method of the group, in query - * order, with one executor field and one constructor. + * order, with one executor field and one constructor, and records the row + * records the group returns. */ @Override public GeneratedFile generate(QueryGroupModel group) { JavaNames names = JavaNames.of(group); + recordRows(group, names); + return new GeneratedFile( buildPath(names), generateSource(group, names) ); } + @Override + public List generateRows() { + return rows.values().stream() + .map(this::generateRowFile) + .toList(); + } + + /** + * Records the row records of one group, rejecting a row that conflicts with + * a row an earlier group of the package already generated. + */ + private void recordRows(QueryGroupModel group, JavaNames names) { + for (JavaNames.RowNames row : names.rows()) { + recordRow( + new SharedRow( + group.name(), + row.tableName(), + row.typeName(), + group.queries().get(row.queryIndex()).columns(), + row.componentNames() + ) + ); + } + } + + /** + * Records one row record of the package. A row of a table already recorded + * under the same row type is the same record, so its columns must be the + * ones the earlier group defined; a row type equal ignoring case to the row + * type of another table is rejected, because the compiled class files of + * those types are one path on a case-insensitive filesystem. + */ + private void recordRow(SharedRow row) { + SharedRow recorded = rows.get(row.identity()); + + if (recorded != null) { + if (!recorded.columns().equals(row.columns())) { + throw new IllegalArgumentException( + "Table '%s' differs from its definition in query group '%s', which generates the same row type %s" + .formatted( + row.tableName(), + recorded.groupName(), + row.typeName() + ) + ); + } + + return; + } + + SharedRow previous = rowsByPortabilityKey.get(row.portabilityKey()); + + if (previous != null) { + throw new IllegalArgumentException( + "Tables '%s' and '%s' generate row types that are equal ignoring case: %s and %s" + .formatted( + previous.tableName(), + row.tableName(), + previous.typeName(), + row.typeName() + ) + ); + } + + rows.put(row.identity(), row); + rowsByPortabilityKey.put(row.portabilityKey(), row); + } + + /** + * One row record of the package, generated once from the group that first + * returned it. + */ + private record SharedRow( + String groupName, + String tableName, + String typeName, + List columns, + List componentNames + ) { + + /** + * Two rows are the same record when they generate the same row type for + * the same table, compared as a case-insensitive filesystem compares + * the table's generated file. + */ + String identity() { + return typeName + "\n" + tableName.toLowerCase(Locale.ROOT); + } + + String portabilityKey() { + return typeName.toLowerCase(Locale.ROOT); + } + } + private Path buildPath(JavaNames names) { return packageDirectory().resolve(names.repositoryClassName() + ".java"); } @@ -86,6 +193,61 @@ private String generateSource(QueryGroupModel group, JavaNames names) { ); } + /** Generates the file of one row record of the package. */ + private GeneratedFile generateRowFile(SharedRow row) { + return new GeneratedFile( + packageDirectory().resolve(row.typeName() + ".java"), + generateRowSource(row) + ); + } + + private String generateRowSource(SharedRow row) { + List sections = new ArrayList<>(); + + sections.add(GENERATED_NOTICE); + sections.add(generatePackage()); + + String imports = generateRowImports(row); + + if (!imports.isEmpty()) { + sections.add(imports); + } + + sections.add(generateRowJavaDoc(row).stripTrailing()); + + sections.add( + generateRecord( + row.typeName(), + row.columns(), + row.componentNames() + ) + ); + + return String.join("\n\n", sections); + } + + /** A row record depends on the JDK types of its components alone. */ + private String generateRowImports(SharedRow row) { + return row.columns().stream() + .map(QueryColumn::type) + .map(this::resolveImport) + .filter(Objects::nonNull) + .distinct() + .map(type -> "import " + type + ";") + .collect(Collectors.joining("\n")); + } + + private String generateRowJavaDoc(SharedRow row) { + return """ + /** + * Generated by sqlcj. + * + * Table: %s + */ + """ + .formatted(escapeJavadoc(row.tableName())); + } + private String generatePackage() { return "package " + packageName + ";"; } @@ -146,8 +308,8 @@ private boolean hasResultType(QueryModel query) { } /** - * A query that returns one complete table row uses the repository's shared - * row record and row mapper instead of generating its own. + * A query that returns one complete table row uses the package's row record + * and the repository's row mapper instead of generating its own. */ private boolean returnsSharedRow(QueryModel query) { return query.rowTable() != null; @@ -197,10 +359,13 @@ private String generateClass(QueryGroupModel group, JavaNames names) { members.add(generateConstructor(names)); for (JavaNames.RowNames row : names.rows()) { - List columns = group.queries().get(row.queryIndex()).columns(); - - members.add(generateRecord(row.typeName(), columns, row.componentNames())); - members.add(generateRowMapper(row.typeName(), row.mapperName(), columns)); + members.add( + generateRowMapper( + row.typeName(), + row.mapperName(), + group.queries().get(row.queryIndex()).columns() + ) + ); } for (int index = 0; index < group.queries().size(); index++) { @@ -252,7 +417,7 @@ private String indent(String text) { return text.indent(4).stripTrailing(); } - /** Generates one nested result or row record in selected-column order. */ + /** Generates one result or row record in selected-column order. */ private String generateRecord( String typeName, List columns, 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 cee21bc..f73c57e 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaNames.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaNames.java @@ -35,8 +35,8 @@ * disambiguated in their existing SQL order. * *

A query that returns one complete table row is named after that table - * instead of after itself, so every such query of one group shares one nested - * row record and one row mapper. + * instead of after itself, so every such query of one group shares one row + * record and one row mapper. * *

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 @@ -176,11 +176,14 @@ record QueryNames( /** * The resolved Java identifiers of one shared row record. * + * @param tableName the schema's declared name of the table the row belongs + * to * @param queryIndex the group-relative index of the query that first * returned this row, whose analyzed columns are the row's * columns */ record RowNames( + String tableName, String typeName, String mapperName, List componentNames, @@ -213,6 +216,7 @@ private static Map resolveRows(QueryGroupModel group, List usersRowMapper")); @@ -212,30 +227,17 @@ WHERE id IN ($1, $2) .indexOf("public List findUsers(") ); - Files.createDirectories(classesDirectory); + assertCompiles(generatedDirectory, classesDirectory); - JavaCompiler compilerApi = ToolProvider.getSystemJavaCompiler(); - - assertNotNull(compilerApi); - - String classpath = System.getProperty("java.class.path"); - - int result = compilerApi.run( - null, - null, - null, - "-classpath", - classpath, - "-d", - classesDirectory.toString(), - repositoryFile.toString() + assertTrue( + Files.exists( + classesDirectory.resolve("generated/UsersRepository.class") + ) ); - assertEquals(0, result); - assertTrue( Files.exists( - classesDirectory.resolve("generated/UsersRepository.class") + classesDirectory.resolve("generated/UsersRow.class") ) ); } @@ -294,26 +296,7 @@ balance DECIMAL(10, 2) assertTrue(Files.exists(repositoryFile)); - Files.createDirectories(classesDirectory); - - JavaCompiler compilerApi = ToolProvider.getSystemJavaCompiler(); - - assertNotNull(compilerApi); - - String classpath = System.getProperty("java.class.path"); - - int compilationResult = compilerApi.run( - null, - null, - null, - "-classpath", - classpath, - "-d", - classesDirectory.toString(), - repositoryFile.toString() - ); - - assertEquals(0, compilationResult); + assertCompiles(generatedDirectory, classesDirectory); JdbcDataSource dataSource = new JdbcDataSource(); @@ -455,26 +438,7 @@ balance DECIMAL(10, 2) assertTrue(Files.exists(repositoryFile)); - Files.createDirectories(classesDirectory); - - JavaCompiler compilerApi = ToolProvider.getSystemJavaCompiler(); - - assertNotNull(compilerApi); - - String classpath = System.getProperty("java.class.path"); - - int compilationResult = compilerApi.run( - null, - null, - null, - "-classpath", - classpath, - "-d", - classesDirectory.toString(), - repositoryFile.toString() - ); - - assertEquals(0, compilationResult); + assertCompiles(generatedDirectory, classesDirectory); JdbcDataSource dataSource = new JdbcDataSource(); @@ -1497,6 +1461,308 @@ name VARCHAR(255) ); } + /** + * Two entries over one migration directory that both return the complete + * row of one table share the single top-level row record the package + * generates for it. + */ + @Test + void shouldGenerateOneSharedRowRecordForTwoEntries() throws Exception { + Path migrations = authorMigrations(); + Path queriesFile = authorQueries(); + Path generatedDirectory = tempDir.resolve("generated"); + + new SqlcjCompiler().compile( + authorAndLibraryConfig( + migrations, + queriesFile, + migrations, + queriesFile, + generatedDirectory + ) + ); + + Path packageDirectory = generatedDirectory.resolve("dev/example/generated"); + + assertTrue(Files.exists(packageDirectory.resolve("AuthorRepository.java"))); + assertTrue(Files.exists(packageDirectory.resolve("LibraryRepository.java"))); + assertTrue(Files.exists(packageDirectory.resolve("AuthorsRow.java"))); + + try (Stream files = Files.list(packageDirectory)) { + assertEquals(3, files.count()); + } + + assertEquals( + "dev/example/generated/AuthorRepository.java\n" + + "dev/example/generated/AuthorsRow.java\n" + + "dev/example/generated/LibraryRepository.java\n", + Files.readString(generatedDirectory.resolve("sqlcj-manifest.txt")) + ); + + Path classesDirectory = tempDir.resolve("classes"); + + assertCompiles(generatedDirectory, classesDirectory); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Class row = Class.forName("dev.example.generated.AuthorsRow", true, classLoader); + + assertTrue(row.isRecord()); + assertNull(row.getEnclosingClass()); + + assertEquals( + List.of("id", "name", "createdAt"), + Arrays.stream(row.getRecordComponents()) + .map(RecordComponent::getName) + .toList() + ); + + for (String repositoryName : List.of("AuthorRepository", "LibraryRepository")) { + Class repository = Class.forName( + "dev.example.generated." + repositoryName, + true, + classLoader + ); + + assertEquals( + row, + repository.getMethod("getAuthor", Long.class).getReturnType() + ); + } + } + } + + /** A row record no entry returns any more is deleted on regeneration. */ + @Test + void shouldDeleteTheStaleRowRecordOfARemovedFullRowQuery() throws IOException { + Path migrations = authorMigrations(); + Path queriesFile = authorQueries(); + Path generatedDirectory = tempDir.resolve("generated"); + + Config config = new Config( + List.of(new SqlConfig("Author", migrations.toString(), queriesFile.toString())), + new JavaConfig(generatedDirectory.toString(), "dev.example.generated") + ); + + new SqlcjCompiler().compile(config); + + Path rowFile = generatedDirectory.resolve("dev/example/generated/AuthorsRow.java"); + + assertTrue(Files.exists(rowFile)); + + Files.writeString( + queriesFile, + """ + -- name: GetAuthor :one + SELECT id + FROM authors + WHERE id = $1; + """ + ); + + new SqlcjCompiler().compile(config); + + assertFalse(Files.exists(rowFile)); + + assertEquals( + "dev/example/generated/AuthorRepository.java\n", + Files.readString(generatedDirectory.resolve("sqlcj-manifest.txt")) + ); + } + + /** + * Two entries that return the full row of one table must define it from the + * same columns, because the package generates one record. + */ + @Test + void shouldReportTwoEntriesThatDefineOneRowDifferently() throws IOException { + Path authorSchema = tempDir.resolve("author-schema.sql"); + Path librarySchema = tempDir.resolve("library-schema.sql"); + Path queriesFile = authorQueries(); + Path generatedDirectory = tempDir.resolve("generated"); + + Files.writeString( + authorSchema, + """ + CREATE TABLE authors + ( + id BIGINT NOT NULL, + name VARCHAR(255), + created_at TIMESTAMP + ); + """ + ); + + Files.writeString( + librarySchema, + """ + CREATE TABLE authors + ( + id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMP + ); + """ + ); + + Config config = authorAndLibraryConfig( + authorSchema, + queriesFile, + librarySchema, + queriesFile, + generatedDirectory + ); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> new SqlcjCompiler().compile(config) + ); + + assertEquals( + "Invalid query group 'Library' in %s: ".formatted(queriesFile) + + "Table 'authors' differs from its definition in query group 'Author', " + + "which generates the same row type AuthorsRow", + exception.getMessage() + ); + + assertFalse(Files.exists(generatedDirectory)); + } + + /** + * Two entries whose row records are equal ignoring case are rejected across + * the package, because those class files are one path on a case-insensitive + * filesystem. + */ + @Test + void shouldReportRowRecordsOfTwoEntriesThatAreEqualIgnoringCase() throws IOException { + Path authorSchema = tempDir.resolve("author-schema.sql"); + Path librarySchema = tempDir.resolve("library-schema.sql"); + Path authorQueries = tempDir.resolve("author-queries.sql"); + Path libraryQueries = tempDir.resolve("library-queries.sql"); + Path generatedDirectory = tempDir.resolve("generated"); + + Files.writeString( + authorSchema, + """ + CREATE TABLE user_data + ( + id BIGINT NOT NULL + ); + """ + ); + + Files.writeString( + librarySchema, + """ + CREATE TABLE userdata + ( + id BIGINT NOT NULL + ); + """ + ); + + Files.writeString( + authorQueries, + """ + -- name: GetUserData :one + SELECT * + FROM user_data + WHERE id = $1; + """ + ); + + Files.writeString( + libraryQueries, + """ + -- name: FetchUserdata :one + SELECT * + FROM userdata + WHERE id = $1; + """ + ); + + Config config = authorAndLibraryConfig( + authorSchema, + authorQueries, + librarySchema, + libraryQueries, + generatedDirectory + ); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> new SqlcjCompiler().compile(config) + ); + + assertEquals( + "Invalid query group 'Library' in %s: ".formatted(libraryQueries) + + "Tables 'user_data' and 'userdata' generate row types that are equal ignoring case: " + + "UserDataRow and UserdataRow", + exception.getMessage() + ); + + assertFalse(Files.exists(generatedDirectory)); + } + + /** A migration directory that creates and then alters the table authors. */ + private Path authorMigrations() throws IOException { + Path migrations = Files.createDirectories(tempDir.resolve("migrations")); + + Files.writeString( + migrations.resolve("V1__authors.sql"), + """ + CREATE TABLE authors + ( + id BIGINT NOT NULL, + name VARCHAR(255) + ); + """ + ); + + Files.writeString( + migrations.resolve("V2__created_at.sql"), + "ALTER TABLE authors ADD COLUMN created_at TIMESTAMP;\n" + ); + + return migrations; + } + + /** Two queries returning the complete row of the table authors. */ + private Path authorQueries() throws IOException { + Path queriesFile = tempDir.resolve("queries.sql"); + + Files.writeString( + queriesFile, + """ + -- name: GetAuthor :one + SELECT * + FROM authors + WHERE id = $1; + + -- name: ListAuthors :many + SELECT * + FROM authors; + """ + ); + + return queriesFile; + } + + private Config authorAndLibraryConfig( + Path authorSchema, + Path authorQueries, + Path librarySchema, + Path libraryQueries, + Path generatedDirectory + ) { + return new Config( + List.of( + new SqlConfig("Author", authorSchema.toString(), authorQueries.toString()), + new SqlConfig("Library", librarySchema.toString(), libraryQueries.toString()) + ), + new JavaConfig(generatedDirectory.toString(), "dev.example.generated") + ); + } + @Test void shouldDeleteTheStaleRepositoriesOfRenamedAndRemovedGroups() throws IOException { Path generatedDirectory = tempDir.resolve("generated"); @@ -1981,7 +2247,12 @@ INSERT INTO users (id, name) String repository = Files.readString(repositoryFile); - assertTrue(repository.contains("public record UsersRow(")); + assertTrue( + Files.readString(generatedDirectory.resolve("generated/UsersRow.java")) + .contains("public record UsersRow(") + ); + + assertFalse(repository.contains("public record UsersRow(")); assertFalse(repository.contains("InsertUserResult")); assertTrue(repository.contains("Long id")); assertTrue(repository.contains("String name")); @@ -2002,25 +2273,7 @@ INSERT INTO users (id, name) assertTrue(repository.contains("return executor.queryMany(")); assertTrue(repository.contains("RETURNING id, name")); - Files.createDirectories(classesDirectory); - - JavaCompiler compilerApi = ToolProvider.getSystemJavaCompiler(); - - assertNotNull(compilerApi); - - assertEquals( - 0, - compilerApi.run( - null, - null, - null, - "-classpath", - System.getProperty("java.class.path"), - "-d", - classesDirectory.toString(), - repositoryFile.toString() - ) - ); + assertCompiles(generatedDirectory, classesDirectory); assertTrue(Files.exists(classesDirectory.resolve("generated/UsersRepository.class"))); } @@ -2236,26 +2489,9 @@ total DECIMAL(10, 2) Files.readAllBytes(fromDirectory) ); - Path classesDirectory = tempDir.resolve("classes"); - - Files.createDirectories(classesDirectory); - - JavaCompiler compilerApi = ToolProvider.getSystemJavaCompiler(); - - assertNotNull(compilerApi); - - assertEquals( - 0, - compilerApi.run( - null, - null, - null, - "-classpath", - System.getProperty("java.class.path"), - "-d", - classesDirectory.toString(), - fromDirectory.toString() - ) + assertCompiles( + tempDir.resolve("generated-directory"), + tempDir.resolve("classes") ); } @@ -2366,26 +2602,9 @@ total DECIMAL(10, 2) NOT NULL Files.readAllBytes(fromDirectory) ); - Path classesDirectory = tempDir.resolve("classes"); - - Files.createDirectories(classesDirectory); - - JavaCompiler compilerApi = ToolProvider.getSystemJavaCompiler(); - - assertNotNull(compilerApi); - - assertEquals( - 0, - compilerApi.run( - null, - null, - null, - "-classpath", - System.getProperty("java.class.path"), - "-d", - classesDirectory.toString(), - fromDirectory.toString() - ) + assertCompiles( + tempDir.resolve("generated-directory"), + tempDir.resolve("classes") ); } @@ -2731,6 +2950,42 @@ private Path generateAndCompile(String schema, String queries) throws IOExceptio return classesDirectory; } + /** Compiles every generated source of one output directory together. */ + private void assertCompiles(Path generatedDirectory, Path classesDirectory) throws IOException { + Files.createDirectories(classesDirectory); + + JavaCompiler compilerApi = ToolProvider.getSystemJavaCompiler(); + + assertNotNull(compilerApi); + + List arguments = new ArrayList<>( + List.of( + "-classpath", + System.getProperty("java.class.path"), + "-d", + classesDirectory.toString() + ) + ); + + try (Stream generatedFiles = Files.walk(generatedDirectory)) { + generatedFiles + .map(Path::toString) + .filter(path -> path.endsWith(".java")) + .sorted() + .forEach(arguments::add); + } + + assertEquals( + 0, + compilerApi.run( + null, + null, + null, + arguments.toArray(new String[0]) + ) + ); + } + private URLClassLoader classLoader(Path classesDirectory) throws IOException { return new URLClassLoader( new URL[] { classesDirectory.toUri().toURL() }, 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 0b8286e..b4dde17 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java @@ -16,6 +16,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -1500,18 +1501,32 @@ INSERT INTO users (id, name) assertCompiles(file); } - private void assertCompiles(GeneratedFile file) throws IOException { + /** Compiles the generated files of one package together. */ + private void assertCompiles(GeneratedFile... files) throws IOException { Path sourceDirectory = tempDir.resolve("generated"); Path outputDirectory = tempDir.resolve("classes"); Files.createDirectories(sourceDirectory); Files.createDirectories(outputDirectory); - Path sourceFile = sourceDirectory.resolve(file.path()); + List arguments = new ArrayList<>( + List.of( + "-classpath", + System.getProperty("java.class.path"), + "-d", + outputDirectory.toString() + ) + ); - Files.createDirectories(sourceFile.getParent()); + for (GeneratedFile file : files) { + Path sourceFile = sourceDirectory.resolve(file.path()); - Files.writeString(sourceFile, file.content()); + Files.createDirectories(sourceFile.getParent()); + + Files.writeString(sourceFile, file.content()); + + arguments.add(sourceFile.toString()); + } JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); @@ -1521,16 +1536,22 @@ private void assertCompiles(GeneratedFile file) throws IOException { null, null, null, - "-classpath", - System.getProperty("java.class.path"), - "-d", - outputDirectory.toString(), - sourceFile.toString() + arguments.toArray(new String[0]) ); assertEquals(0, result); } + /** Compiles one repository together with the row records it returns. */ + private void assertCompilesWithRows(GeneratedFile repository) throws IOException { + List files = new ArrayList<>(); + + files.add(repository); + files.addAll(codeGenerator.generateRows()); + + assertCompiles(files.toArray(new GeneratedFile[0])); + } + /** * One group holding every supported query kind becomes one repository with * one executor field, one constructor, and one method per query. @@ -1662,8 +1683,8 @@ void shouldGenerateRepositoryWithoutMethodsForEmptyGroup() throws IOException { /** * Every query that returns one complete row of a table returns the - * repository's single row record, generated once after the constructor with - * its own mapper. + * package's single row record, which the repository reads through one row + * mapper generated after the constructor. */ @Test void shouldShareOneRowRecordAcrossFullRowQueriesOfOneTable() throws IOException { @@ -1715,12 +1736,7 @@ void shouldShareOneRowRecordAcrossFullRowQueriesOfOneTable() throws IOException String source = file.content(); - assertEquals( - 1, - source.lines() - .filter(line -> line.equals(" public record UsersRow(")) - .count() - ); + assertFalse(source.contains("public record")); assertEquals( 1, @@ -1742,18 +1758,46 @@ void shouldShareOneRowRecordAcrossFullRowQueriesOfOneTable() throws IOException .count() ); - assertTrue(source.contains("LocalDateTime createdAt")); assertTrue(source.contains("resultSet.getObject(3, LocalDateTime.class)")); assertTrue( - source.indexOf("public UsersRepository(QueryExecutor executor)") < source.indexOf("public record UsersRow(") + source.indexOf("public UsersRepository(QueryExecutor executor)") < source + .indexOf("private static final RowMapper usersRowMapper") ); - assertTrue( - source.indexOf("public record UsersRow(") < source.indexOf("public UsersRow createUser(") + List rows = codeGenerator.generateRows(); + + assertEquals(1, rows.size()); + + GeneratedFile row = rows.getFirst(); + + assertEquals(Path.of("generated", "UsersRow.java"), row.path()); + + assertEquals( + """ + // Code generated by sqlcj. DO NOT EDIT. + + package generated; + + import java.time.LocalDateTime; + + /** + * Generated by sqlcj. + * + * Table: users + */ + + public record UsersRow( + Long id, + String name, + LocalDateTime createdAt + ) { + } + """, + row.content() ); - assertCompiles(file); + assertCompiles(file, row); } @Test @@ -1834,12 +1878,7 @@ void shouldShareOneRowRecordBetweenOptionalAndOtherFullRowQueries() throws IOExc String source = file.content(); - assertEquals( - 1, - source.lines() - .filter(line -> line.equals(" public record UsersRow(")) - .count() - ); + assertFalse(source.contains("public record")); assertEquals( 1, @@ -1851,7 +1890,12 @@ void shouldShareOneRowRecordBetweenOptionalAndOtherFullRowQueries() throws IOExc assertTrue(source.contains("public UsersRow getUser(Long id)")); assertTrue(source.contains("public Optional findUser(Long id)")); - assertCompiles(file); + assertEquals( + 1, + codeGenerator.generateRows().size() + ); + + assertCompilesWithRows(file); } @Test @@ -2022,16 +2066,26 @@ void shouldGenerateOneRowRecordPerRowTable() throws IOException { String source = file.content(); - assertTrue(source.contains("public record UsersRow(")); - assertTrue(source.contains("public record OrdersRow(")); + assertFalse(source.contains("public record")); assertTrue(source.contains("private static final RowMapper usersRowMapper")); assertTrue(source.contains("private static final RowMapper ordersRowMapper")); assertTrue(source.contains("public UsersRow getUser(Long id)")); assertTrue(source.contains("public OrdersRow getOrder(Long id)")); - assertTrue(source.indexOf("public record UsersRow(") < source.indexOf("public record OrdersRow(")); + List rows = codeGenerator.generateRows(); - assertCompiles(file); + assertEquals( + List.of( + Path.of("generated", "UsersRow.java"), + Path.of("generated", "OrdersRow.java") + ), + rows.stream().map(GeneratedFile::path).toList() + ); + + assertTrue(rows.getFirst().content().contains("public record UsersRow(")); + assertTrue(rows.get(1).content().contains("public record OrdersRow(")); + + assertCompilesWithRows(file); } /** @@ -2083,7 +2137,7 @@ void shouldDisambiguateRowMapperFromQueryRowMapper() throws IOException { assertTrue(source.contains("public List authors()")); assertTrue(source.contains("public AuthorsRow getAuthor(Long id)")); - assertCompiles(file); + assertCompilesWithRows(file); } @Test @@ -2127,6 +2181,149 @@ void shouldRejectRowTypesThatAreEqualIgnoringCase() { ); } + @Test + void shouldGenerateNoRowRecordWithoutAFullRowQuery() { + generate( + new QueryModel( + "GetUser", + QueryType.ONE, + "users", + SQL, + List.of(), + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + List.of(), + null + ) + ); + + assertEquals(List.of(), codeGenerator.generateRows()); + } + + /** + * Two groups of one package that return the full row of one table share the + * single row record the package generates for it. + */ + @Test + void shouldGenerateOneRowRecordForTwoGroupsReturningTheSameRow() throws IOException { + GeneratedFile users = codeGenerator.generate( + new QueryGroupModel(GROUP, List.of(getUsersRow("GetUser"))) + ); + + GeneratedFile admins = codeGenerator.generate( + new QueryGroupModel("Admins", List.of(getUsersRow("GetAdmin"))) + ); + + List rows = codeGenerator.generateRows(); + + assertEquals(1, rows.size()); + assertEquals(Path.of("generated", "UsersRow.java"), rows.getFirst().path()); + + assertTrue(users.content().contains("public UsersRow getUser(Long id)")); + assertTrue(admins.content().contains("public UsersRow getAdmin(Long id)")); + + assertFalse(users.content().contains("public record")); + assertFalse(admins.content().contains("public record")); + + assertCompiles(users, admins, rows.getFirst()); + } + + /** + * Two groups that return the same row type of the same table must define it + * from the same columns, because the package generates one record. + */ + @Test + void shouldRejectARowOfAnotherGroupDefinedFromDifferentColumns() { + codeGenerator.generate( + new QueryGroupModel(GROUP, List.of(getUsersRow("GetUser"))) + ); + + QueryModel getAdmin = new QueryModel( + "GetAdmin", + QueryType.ONE, + "users", + SQL, + List.of(), + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("name", ColumnType.VARCHAR, false) + ), + List.of(), + "users" + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> codeGenerator.generate( + new QueryGroupModel("Admins", List.of(getAdmin)) + ) + ); + + assertEquals( + "Table 'users' differs from its definition in query group 'Users', " + + "which generates the same row type UsersRow", + exception.getMessage() + ); + } + + @Test + void shouldRejectRowTypesOfTwoGroupsThatAreEqualIgnoringCase() { + QueryModel getUserData = new QueryModel( + "GetUserData", + QueryType.ONE, + "user_data", + SQL, + List.of(), + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + List.of(), + "user_data" + ); + + QueryModel fetchUserdata = new QueryModel( + "FetchUserdata", + QueryType.ONE, + "userdata", + SQL, + List.of(), + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + List.of(), + "userdata" + ); + + codeGenerator.generate( + new QueryGroupModel(GROUP, List.of(getUserData)) + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> codeGenerator.generate( + new QueryGroupModel("Admins", List.of(fetchUserdata)) + ) + ); + + assertEquals( + "Tables 'user_data' and 'userdata' generate row types that are equal ignoring case: " + + "UserDataRow and UserdataRow", + exception.getMessage() + ); + } + + /** A {@code :one} query returning the complete row of the table {@code users}. */ + private QueryModel getUsersRow(String queryName) { + return new QueryModel( + queryName, + QueryType.ONE, + "users", + "SELECT * FROM users WHERE id = ?", + List.of(1), + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("name", ColumnType.VARCHAR, true) + ), + List.of(new QueryParameter(1, "id", ColumnType.BIGINT)), + "users" + ); + } + private GeneratedFile generate(QueryModel query) { return generate(codeGenerator, query); } From 151fd7406b9246f3b3a276d7818e77ec0835ebe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Mon, 28 Sep 2026 20:22:35 +0300 Subject: [PATCH 2/3] docs: return the top-level AuthorsRow from the sample and documentation --- README.md | 5 +- docs/configuration.md | 39 +++++++--- docs/queries.md | 72 +++++++++++++------ docs/quickstart.md | 40 ++++++----- .../src/main/java/com/example/app/App.java | 35 ++++----- 5 files changed, 125 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 5f3d7da..4d3fd19 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,12 @@ WHERE id = $1; ```java AuthorRepository authors = new AuthorRepository(executor); -AuthorRepository.AuthorsRow author = authors.getAuthor(1L); +AuthorsRow author = authors.getAuthor(1L); ``` Every query of `sql/queries.sql` becomes a method of that one -`AuthorRepository`. +`AuthorRepository`, and the `authors` row it returns is the top-level +`AuthorsRow` record generated once for the configured package. 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 diff --git a/docs/configuration.md b/docs/configuration.md index d9c2e51..e05fe27 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -218,6 +218,10 @@ 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. +The entries share one generated package, so they also share its row records: a +table whose complete row both entries return generates one `Row` +file that both repositories return. + ## Generated Java Names The configured `sql[].name` and the SQL names inside the entry — query names, @@ -265,7 +269,9 @@ The rules are applied as follows: `Result`, so `get_author` generates `GetAuthorResult`, - a row record is the upper camel form of the table name followed by `Row`, so the table `authors` generates `AuthorsRow`. Row names are normalized, never - singularized, + singularized. A row record is a top-level type of `java.package`, generated + once per table into its own file and shared by every repository of the package + that returns that row, - a method is the lower camel form of the query name, so `get_author` generates `getAuthor`, - a row-mapper field is the method name followed by `RowMapper`, and the mapper @@ -326,13 +332,29 @@ case-insensitive filesystem: 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 ``` -Two tables of one entry whose row records are equal ignoring case are rejected -on the same grounds, naming both tables and both row types: +Two tables whose row records are equal ignoring case are rejected on the same +grounds, naming both tables and both row types. Because a row record belongs to +the package, the two tables may come from one entry or from two: ```text sqlcj: Invalid query group 'User' in /home/dev/project/sql/queries.sql: Tables 'user_data' and 'userdata' generate row types that are equal ignoring case: UserDataRow and UserdataRow ``` +### Row record collisions + +Two entries of one package that return the complete row of one table generate +one row record, so they must define that table alike: the analyzed row columns +must have the same names, types, and nullability, in the same order. Otherwise +the run ends, naming the entry that defined the table first: + +```text +sqlcj: Invalid query group 'Library' in /home/dev/project/sql/library.sql: Table 'authors' differs from its definition in query group 'Author', which generates the same row type AuthorsRow +``` + +A cross-entry failure is reported against the later entry in configuration +order and, like every generation failure, ends the run before any file is +written. + ### Generated path collisions Two entries whose repository files resolve to the same path, or to paths that @@ -385,11 +407,12 @@ output directory or generated file it could not write and exit status `1`. The files the run had already written stay in place. A successful run records what it generated in `sqlcj-manifest.txt` inside -`java.out`. The manifest is a UTF-8 text file listing every generated file as a -path relative to `java.out`, with `/` between its name elements, sorted, one per -line. The next successful run deletes the files the previous manifest listed -that it did not generate itself, so a renamed or removed configuration entry -leaves no stale repository behind. +`java.out`. The manifest is a UTF-8 text file listing every generated file, +repositories and row records alike, as a path relative to `java.out`, with `/` +between its name elements, sorted, one per line. The next successful run deletes +the files the previous manifest listed that it did not generate itself, so a +renamed or removed configuration entry leaves no stale repository behind, and a +row record no entry returns any more is deleted too. Cleanup is deliberately narrow: diff --git a/docs/queries.md b/docs/queries.md index e73a404..c93c2b7 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -318,9 +318,11 @@ types differ are rejected. 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. +that repository; sqlcj never generates a class per query. Beside the +repositories, the package holds one `Row` record file for each table +whose complete row an entry returns. -- Every generated repository file begins with the fixed notice +- Every generated file begins with the fixed notice `// Code generated by sqlcj. DO NOT EDIT.`, followed by one blank line and the `package` declaration. The notice holds no version, timestamp, or filesystem path. @@ -330,10 +332,11 @@ that repository; sqlcj never generates a class per query. of the query name, as in `get_author` and `GetAuthor` to `getAuthor`. - A `:one`, `:optional`, or `:many` query that returns one complete table row — a single-source `SELECT *` or `SELECT .*`, or a write whose `RETURNING` - clause is exactly `*` — returns the repository's nested - `Row` record. That record and its private `RowMapper` field are - generated once per table, after the constructor, in the order the queries - first use them, and every query returning that row shares them. + clause is exactly `*` — returns the `Row` record. That record is a + top-level `public record` in `java.package`, generated once per table into its + own file and shared by every repository of the package that returns that row. + Each repository keeps its own private `RowMapper` field for the row, generated + after the constructor in the order its queries first use it. - Every other `:one`, `:optional`, or `:many` query generates a nested `public record` named `Result` in upper camel case, whose components follow the selected-column order and are named after each column's projection alias or @@ -345,14 +348,39 @@ that repository; sqlcj never generates a class per query. - Every generated method passes the repository class name and the query name to the executor, as the first two arguments of its call, so a runtime failure names the query the application called. -- Generated source imports only `dev.sqlcj.runtime.QueryExecutor`, +- Generated repository source imports only `dev.sqlcj.runtime.QueryExecutor`, `dev.sqlcj.runtime.RowMapper`, `java.util.List`, `java.util.Optional` when the entry declares an `:optional` query, and the JDK types of the mapped columns, - so the runtime artifact is the only sqlcj dependency a consumer needs. + so the runtime artifact is the only sqlcj dependency a consumer needs. A row + record imports the JDK types of its components alone and depends on no sqlcj + type. The `Author` entry of the [Quickstart](quickstart.md), which declares `CreateAuthor`, `GetAuthor`, `FindAuthor`, `ListAuthors`, `UpdateAuthorBio`, and -`DeleteAuthor`, generates one `AuthorRepository`: +`DeleteAuthor`, generates the row record of the `authors` table: + +```java +// Code generated by sqlcj. DO NOT EDIT. + +package com.example.app.db; + +import java.time.LocalDateTime; + +/** + * Generated by sqlcj. + * + * Table: authors + */ +public record AuthorsRow( + Long id, + String name, + String bio, + LocalDateTime createdAt +) { +} +``` + +and one `AuthorRepository` that uses it: ```java // Code generated by sqlcj. DO NOT EDIT. @@ -378,14 +406,6 @@ public final class AuthorRepository { this.executor = executor; } - public record AuthorsRow( - Long id, - String name, - String bio, - LocalDateTime createdAt - ) { - } - private static final RowMapper authorsRowMapper = resultSet -> new AuthorsRow( resultSet.getObject(1, Long.class), @@ -439,23 +459,26 @@ public final class AuthorRepository { `CreateAuthor`, `GetAuthor`, `FindAuthor`, and `ListAuthors` each return one complete `authors` row, so all four use the one `AuthorsRow` record and the one `authorsRowMapper` field. `getAuthor` returns `AuthorsRow`, `findAuthor` returns -`Optional`, and `listAuthors` returns `List`. +`Optional`, and `listAuthors` returns `List`. A second +entry of the same package that returns the full `authors` row returns that same +`AuthorsRow` class. The application constructs the repository once per execution context: ```java AuthorRepository authors = new AuthorRepository(new JdbcQueryExecutor(dataSource)); -AuthorRepository.AuthorsRow author = authors.getAuthor(1L); +AuthorsRow author = authors.getAuthor(1L); -Optional found = authors.findAuthor(2L); +Optional found = authors.findAuthor(2L); ``` A query name, projection alias, column name, and parameter name becomes a conventional Java name by one deterministic camel-case rule set, and names that would collide inside 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 +same method, the rejection of two entries that define one row record +differently, and the rejection of two entries whose repository files would collide, are documented in [Generated Java Names](configuration.md#generated-java-names). @@ -714,6 +737,13 @@ Generated Java: `JavaCodeGeneratorTest.shouldDisambiguateRowMapperFromQueryRowMapper`, and `JavaCodeGeneratorTest.shouldRejectRowTypesThatAreEqualIgnoringCase` cover the shared row records, their mapper fields, and their collision. +- `SqlcjCompilerIntegrationTest.shouldGenerateOneSharedRowRecordForTwoEntries`, + `SqlcjCompilerIntegrationTest.shouldDeleteTheStaleRowRecordOfARemovedFullRowQuery`, + `SqlcjCompilerIntegrationTest.shouldReportTwoEntriesThatDefineOneRowDifferently`, + and + `SqlcjCompilerIntegrationTest.shouldReportRowRecordsOfTwoEntriesThatAreEqualIgnoringCase` + cover the one top-level row record two entries share, its manifest entry and + stale deletion, and the two cross-entry diagnostics. - `PostgresIntegrationTest.shouldExecuteGeneratedFullRowQueriesIntoOneSharedRowTypeAgainstPostgres` executes a returning write, a full-row read, and a qualified full-row list of one table into one row type against PostgreSQL 16. diff --git a/docs/quickstart.md b/docs/quickstart.md index 256ab33..5ae8c1f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -272,14 +272,15 @@ All eleven queries become methods of the one generated `AuthorRepository`: `deleteAuthor`, `searchAuthors`, `countAuthors`, `listAuthorPage`, `createBook`, and `listAuthorBooks`. `CreateAuthor`, `GetAuthor`, `FindAuthor`, `ListAuthors`, `SearchAuthors`, and `ListAuthorPage` each return one complete `authors` row, so -all six share the nested record `AuthorRepository.AuthorsRow`, generated once -from the schema's column order. A query with its own result shape, such as a -partial projection or a `RETURNING` column list, generates a nested -`AuthorRepository.Result` record instead: `CountAuthors` generates -`CountAuthorsResult` with the single non-null `Long` component `total`, and -`ListAuthorBooks` generates `ListAuthorBooksResult` with the components `name` -and `title`, where `title` is `null` for an author that the left-joined `books` -table does not match. +all six share the top-level record `AuthorsRow`, generated once for the package +`com.example.app.db` from the schema's column order. Another entry of the same +package that returns the full `authors` row would return that same record. A +query with its own result shape, such as a partial projection or a `RETURNING` +column list, generates a nested `AuthorRepository.Result` record +instead: `CountAuthors` generates `CountAuthorsResult` with the single non-null +`Long` component `total`, and `ListAuthorBooks` generates +`ListAuthorBooksResult` with the components `name` and `title`, where `title` is +`null` for an author that the left-joined `books` table does not match. `GetAuthor` and `FindAuthor` read the same row by the same key and differ only in cardinality: `getAuthor` returns `AuthorsRow` and requires exactly one row, @@ -292,6 +293,7 @@ query contract is documented in [Queries](queries.md). package com.example.app; import com.example.app.db.AuthorRepository; +import com.example.app.db.AuthorsRow; import dev.sqlcj.runtime.JdbcQueryExecutor; import dev.sqlcj.runtime.QueryCardinalityException; import dev.sqlcj.runtime.QueryExecutor; @@ -311,11 +313,11 @@ public final class App { AuthorRepository authors = new AuthorRepository(executor); - AuthorRepository.AuthorsRow created = authors.createAuthor("Ada Lovelace", "First programmer"); + AuthorsRow created = authors.createAuthor("Ada Lovelace", "First programmer"); System.out.println("created: " + created.id() + " " + created.name()); - AuthorRepository.AuthorsRow read = authors.getAuthor(created.id()); + AuthorsRow read = authors.getAuthor(created.id()); System.out.println("read: " + read.name() + " / " + read.bio()); @@ -323,11 +325,11 @@ public final class App { System.out.println("updated rows: " + updatedRows); - for (AuthorRepository.AuthorsRow author : authors.listAuthors()) { + for (AuthorsRow author : authors.listAuthors()) { System.out.println("listed: " + author.id() + " " + author.name()); } - Optional missing = authors.findAuthor(-1L); + Optional missing = authors.findAuthor(-1L); System.out.println("missing row: " + missing.isPresent()); @@ -344,7 +346,7 @@ public final class App { AuthorRepository transactionalAuthors = new AuthorRepository(new JdbcQueryExecutor(connection)); - AuthorRepository.AuthorsRow committed = transactionalAuthors.createAuthor("Grace Hopper", null); + AuthorsRow committed = transactionalAuthors.createAuthor("Grace Hopper", null); transactionalAuthors.updateAuthorBio(committed.id(), "Compiler pioneer"); @@ -360,7 +362,7 @@ public final class App { AuthorRepository transactionalAuthors = new AuthorRepository(new JdbcQueryExecutor(connection)); - AuthorRepository.AuthorsRow discarded = transactionalAuthors.createAuthor("Temporary Author", null); + AuthorsRow discarded = transactionalAuthors.createAuthor("Temporary Author", null); transactionalAuthors.updateAuthorBio(discarded.id(), "never stored"); @@ -369,13 +371,13 @@ public final class App { System.out.println("rolled back: " + authors.findAuthor(discarded.id()).isPresent()); } - for (AuthorRepository.AuthorsRow author : authors.searchAuthors("%lovelace%")) { + for (AuthorsRow author : authors.searchAuthors("%lovelace%")) { System.out.println("searched: " + author.id() + " " + author.name()); } System.out.println("count: " + authors.countAuthors().total()); - for (AuthorRepository.AuthorsRow author : authors.listAuthorPage(1, 1)) { + for (AuthorsRow author : authors.listAuthorPage(1, 1)) { System.out.println("page: " + author.id() + " " + author.name()); } @@ -455,11 +457,13 @@ mvn exec:java - `mvn compile` then compiles `src/main/java` together with `target/generated-sources/sqlcj`. -Generation writes one repository per configured entry, plus the manifest that -records what it wrote: +Generation writes one repository per configured entry, one row record per table +whose complete row an entry returns, plus the manifest that records what it +wrote: ```text target/generated-sources/sqlcj/com/example/app/db/AuthorRepository.java +target/generated-sources/sqlcj/com/example/app/db/AuthorsRow.java target/generated-sources/sqlcj/sqlcj-manifest.txt ``` 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 3cccefa..050c85b 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,6 +1,7 @@ package com.example.app; import com.example.app.db.AuthorRepository; +import com.example.app.db.AuthorsRow; import dev.sqlcj.runtime.JdbcQueryExecutor; import dev.sqlcj.runtime.QueryCardinalityException; import dev.sqlcj.runtime.QueryExecutor; @@ -25,13 +26,13 @@ * transaction from a caller-owned connection. * *

Create, read, optional read, list, search, and page each return one - * complete {@code authors} row, so all six share the repository's single - * {@link AuthorRepository.AuthorsRow} record. A query with its own result shape - * generates its own record: {@link AuthorRepository.CountAuthorsResult} carries - * the non-null {@code Long} count, and - * {@link AuthorRepository.ListAuthorBooksResult} carries an author name beside - * the title of the left-joined {@code books} row, which is {@code null} for an - * author without a book. + * complete {@code authors} row, so all six share the single top-level + * {@link AuthorsRow} record of the generated package. A query with its own + * result shape generates its own nested record: + * {@link AuthorRepository.CountAuthorsResult} carries the non-null + * {@code Long} count, and {@link AuthorRepository.ListAuthorBooksResult} + * carries an author name beside the title of the left-joined {@code books} row, + * which is {@code null} for an author without a book. * *

Row absence is expressed by the {@code :optional} {@code FindAuthor} * query, which returns an empty {@link Optional}, while the {@code :one} @@ -57,7 +58,7 @@ public static void main(String[] args) throws SQLException { AuthorRepository authors = new AuthorRepository(executor); - AuthorRepository.AuthorsRow created = authors.createAuthor("Ada Lovelace", "First programmer"); + AuthorsRow created = authors.createAuthor("Ada Lovelace", "First programmer"); check(created != null, "CreateAuthor returned no row"); check(created.id() != null, "CreateAuthor returned no database-generated id"); @@ -66,7 +67,7 @@ public static void main(String[] args) throws SQLException { System.out.println("created: " + created.id() + " " + created.name()); - AuthorRepository.AuthorsRow read = authors.getAuthor(created.id()); + AuthorsRow read = authors.getAuthor(created.id()); check(read != null, "GetAuthor returned no row for the created author"); checkEquals(created.id(), read.id(), "GetAuthor id"); @@ -75,14 +76,14 @@ public static void main(String[] args) throws SQLException { System.out.println("read: " + read.name() + " / " + read.bio()); - Optional found = authors.findAuthor(created.id()); + Optional found = authors.findAuthor(created.id()); check(found.isPresent(), "FindAuthor returned no row for the created author"); checkEquals("Ada Lovelace", found.get().name(), "FindAuthor name"); System.out.println("found: " + found.get().name()); - List listed = authors.listAuthors(); + List listed = authors.listAuthors(); checkEquals(1, listed.size(), "ListAuthors row count after create"); checkEquals(created.id(), listed.get(0).id(), "ListAuthors id"); @@ -114,7 +115,7 @@ public static void main(String[] args) throws SQLException { Long committedId = writeAndCommit(dataSource); - AuthorRepository.AuthorsRow committed = authors.getAuthor(committedId); + AuthorsRow committed = authors.getAuthor(committedId); check(committed != null, "the committed author is not readable after commit"); checkEquals("Grace Hopper", committed.name(), "committed name"); @@ -131,7 +132,7 @@ public static void main(String[] args) throws SQLException { System.out.println("rolled back: empty"); - List searched = authors.searchAuthors("%lovelace%"); + List searched = authors.searchAuthors("%lovelace%"); checkEquals(1, searched.size(), "SearchAuthors row count"); checkEquals(created.id(), searched.get(0).id(), "SearchAuthors id"); @@ -146,7 +147,7 @@ public static void main(String[] args) throws SQLException { System.out.println("count: " + count.total()); - List page = authors.listAuthorPage(1, 1); + List page = authors.listAuthorPage(1, 1); checkEquals(1, page.size(), "ListAuthorPage row count"); checkEquals(committedId, page.get(0).id(), "ListAuthorPage id"); @@ -185,7 +186,7 @@ public static void main(String[] args) throws SQLException { System.out.println("deleted rows: " + deletedRows); - List remaining = authors.listAuthors(); + List remaining = authors.listAuthors(); checkEquals(1, remaining.size(), "ListAuthors row count after delete"); checkEquals(committedId, remaining.get(0).id(), "remaining author id"); @@ -203,7 +204,7 @@ private static Long writeAndCommit(DataSource dataSource) throws SQLException { AuthorRepository transactionalAuthors = new AuthorRepository(new JdbcQueryExecutor(connection)); - AuthorRepository.AuthorsRow author = transactionalAuthors.createAuthor("Grace Hopper", null); + AuthorsRow 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"); @@ -228,7 +229,7 @@ private static Long writeAndRollback(DataSource dataSource) throws SQLException AuthorRepository transactionalAuthors = new AuthorRepository(new JdbcQueryExecutor(connection)); - AuthorRepository.AuthorsRow author = transactionalAuthors.createAuthor("Temporary Author", null); + AuthorsRow author = transactionalAuthors.createAuthor("Temporary Author", null); check(author != null, "CreateAuthor returned no row inside the rolled back transaction"); From 23ce1d8597683721bf175fa829db819927564a8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Mon, 28 Sep 2026 20:22:48 +0300 Subject: [PATCH 3/3] ci: verify the shared row record in the release script --- scripts/verify-release.sh | 46 ++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index d056329..d6f26ff 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -15,9 +15,11 @@ # manifest are byte-identical. Two scratch configurations outside the sample # then generate the query groups 'Author' and 'Library' and next only 'Writer', # proving that a renamed or removed entry leaves no stale repository and a -# manifest listing the surviving file alone. The final generation uses the -# sample's own 'sqlcj.yaml' from the sample directory and must restore the -# first generated output byte for byte before the sample is compiled and run. +# manifest listing the surviving files alone. Both entries share the one +# top-level 'AuthorsRow' record the package generates. The final generation +# uses the sample's own 'sqlcj.yaml' from the sample directory and must restore +# the first generated output byte for byte before the sample is compiled and +# run. # # Requirements: JDK 21, Maven, psql, and a reachable PostgreSQL server. # @@ -184,11 +186,16 @@ YAML (cd "${elsewhere_dir}" && java -jar "${cli_jar}" generate \ --config ../regeneration/author-and-library.yaml) -for repository in AuthorRepository LibraryRepository; do - [ -f "${generated_package}/${repository}.java" ] \ - || fail "the renamed-entry setup is missing ${generated_package}/${repository}.java" +for generated in AuthorRepository LibraryRepository AuthorsRow; do + [ -f "${generated_package}/${generated}.java" ] \ + || fail "the renamed-entry setup is missing ${generated_package}/${generated}.java" done +shared_row_count=$(find "${generated_root}" -type f -name 'AuthorsRow.java' | wc -l) + +[ "${shared_row_count}" -eq 1 ] \ + || fail "expected the two entries to share 1 row record but found ${shared_row_count}" + (cd "${elsewhere_dir}" && java -jar "${cli_jar}" generate \ --config ../regeneration/writer.yaml) @@ -200,29 +207,34 @@ done stale_count=$(find "${generated_root}" -type f -name '*.java' | wc -l) -[ "${stale_count}" -eq 1 ] \ - || fail "expected 1 generated source after the rename but found ${stale_count}" +[ "${stale_count}" -eq 2 ] \ + || fail "expected 2 generated sources after the rename but found ${stale_count}" -[ -f "${generated_package}/WriterRepository.java" ] \ - || fail "the surviving generated source is not ${generated_package}/WriterRepository.java" +for generated in WriterRepository AuthorsRow; do + [ -f "${generated_package}/${generated}.java" ] \ + || fail "the surviving generated sources are missing ${generated_package}/${generated}.java" +done expected_manifest="${workspace}/expected-manifest.txt" -printf 'com/example/app/db/WriterRepository.java\n' > "${expected_manifest}" +printf 'com/example/app/db/AuthorsRow.java\ncom/example/app/db/WriterRepository.java\n' \ + > "${expected_manifest}" cmp "${expected_manifest}" "${manifest_file}" \ - || fail "the manifest does not list WriterRepository.java alone: $(cat "${manifest_file}")" + || fail "the manifest does not list the surviving files alone: $(cat "${manifest_file}")" log "Generating sources with the packaged CLI" (cd "${sample_dir}" && java -jar "${cli_jar}" generate) -[ -f "${generated_package}/AuthorRepository.java" ] \ - || fail "the expected generated source is missing: ${generated_package}/AuthorRepository.java" +for generated in AuthorRepository AuthorsRow; do + [ -f "${generated_package}/${generated}.java" ] \ + || fail "the expected generated source is missing: ${generated_package}/${generated}.java" +done generated_count=$(find "${generated_root}" -type f -name '*.java' | wc -l) -[ "${generated_count}" -eq 1 ] \ - || fail "expected 1 generated source but found ${generated_count}" +[ "${generated_count}" -eq 2 ] \ + || fail "expected 2 generated sources but found ${generated_count}" for method in createAuthor getAuthor findAuthor listAuthors updateAuthorBio deleteAuthor \ searchAuthors countAuthors listAuthorPage createBook listAuthorBooks; do @@ -230,7 +242,7 @@ for method in createAuthor getAuthor findAuthor listAuthors updateAuthorBio dele || fail "the generated repository is missing the ${method} method" done -grep -q "LocalDateTime createdAt" "${generated_package}/AuthorRepository.java" \ +grep -q "LocalDateTime createdAt" "${generated_package}/AuthorsRow.java" \ || fail "the generated row is missing the column the second migration adds" diff -r "${first_generation}" "${generated_root}" \