From 62770020f427fd9e7a65c3faf4f982d3018d20de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Thu, 24 Sep 2026 23:16:35 +0300 Subject: [PATCH 1/2] feat: share one row record per table across full-row queries A query whose only projection is a single-source SELECT * or SELECT .*, or whose RETURNING clause is exactly *, now returns the repository's nested Row record instead of its own Result. Each table gets one row record, in schema column order, and one positional row mapper, generated after the constructor in first-use order and shared by every such query. Explicit column lists, a wildcard combined with another item, wildcards in joins, and RETURNING column lists keep their query-specific records. A row mapper yields to a query's own mapper name with a numeric suffix, and two tables whose row types are equal ignoring case are rejected before any file is written. Parameter and binding order are unchanged. --- .../dev/sqlcj/analysis/QueryAnalyzer.java | 60 ++- .../java/dev/sqlcj/analysis/QueryModel.java | 7 +- .../sqlcj/generator/JavaCodeGenerator.java | 70 +++- .../java/dev/sqlcj/generator/JavaNames.java | 169 +++++++- .../dev/sqlcj/analysis/QueryAnalyzerTest.java | 97 +++++ .../compiler/PostgresIntegrationTest.java | 58 +++ .../SqlcjCompilerIntegrationTest.java | 26 +- .../JavaCodeGeneratorNamingTest.java | 9 +- .../generator/JavaCodeGeneratorTest.java | 364 ++++++++++++++++-- .../dev/sqlcj/generator/JavaNamesTest.java | 3 +- 10 files changed, 778 insertions(+), 85 deletions(-) diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java index 5a3e9f1..15afd87 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -106,7 +106,8 @@ private QueryModel analyzeSelect(Query query, ParsedSql parsedSql, Select select parsedSql, table.getUnquotedName(), columns, - bindingParameters + bindingParameters, + resolveSelectRowTable(plainSelect, sources) ); } @@ -119,11 +120,13 @@ private QueryModel analyzeInsert(Query query, ParsedSql parsedSql, Insert insert Source source = toSource(table, schema); List columns = List.of(); + String rowTable = null; if (returningClause != null) { requireSupportedReturningInsert(insert); columns = resolveReturningColumns(returningClause, source); + rowTable = resolveReturningRowTable(returningClause, source); } return toQueryModel( @@ -131,7 +134,8 @@ private QueryModel analyzeInsert(Query query, ParsedSql parsedSql, Insert insert parsedSql, table.getUnquotedName(), columns, - resolveInsertParameters(insert, source.table()) + resolveInsertParameters(insert, source.table()), + rowTable ); } @@ -144,11 +148,13 @@ private QueryModel analyzeUpdate(Query query, ParsedSql parsedSql, Update update Source source = toSource(table, schema); List columns = List.of(); + String rowTable = null; if (returningClause != null) { requireSupportedReturningUpdate(update); columns = resolveReturningColumns(returningClause, source); + rowTable = resolveReturningRowTable(returningClause, source); } List bindingParameters = resolveUpdateSetParameters(update, source.table()); @@ -162,7 +168,8 @@ private QueryModel analyzeUpdate(Query query, ParsedSql parsedSql, Update update parsedSql, table.getUnquotedName(), columns, - bindingParameters + bindingParameters, + rowTable ); } @@ -175,11 +182,13 @@ private QueryModel analyzeDelete(Query query, ParsedSql parsedSql, Delete delete Source source = toSource(table, schema); List columns = List.of(); + String rowTable = null; if (returningClause != null) { requireSupportedReturningDelete(delete); columns = resolveReturningColumns(returningClause, source); + rowTable = resolveReturningRowTable(returningClause, source); } List bindingParameters = new ArrayList<>(); @@ -193,7 +202,8 @@ private QueryModel analyzeDelete(Query query, ParsedSql parsedSql, Delete delete parsedSql, table.getUnquotedName(), columns, - bindingParameters + bindingParameters, + rowTable ); } @@ -259,6 +269,23 @@ private List resolveReturningColumns(ReturningClause returningClaus return List.copyOf(columns); } + /** + * Reports the table whose complete row a returning write returns, which is + * the write target when the clause is exactly a bare {@code *}, and + * {@code null} for a returned column list. The name is the schema's own + * spelling of the table, so every query returning that row shares one row + * identity. + */ + private String resolveReturningRowTable(ReturningClause returningClause, Source source) { + if (returningClause.size() != 1) { + return null; + } + + return returningClause.getFirst().getExpression() instanceof AllColumns + ? source.table().name() + : null; + } + /** * Resolves one returned item, which is either a bare {@code *} or a direct * column of the write target. A computed item has no schema type to @@ -443,7 +470,8 @@ private QueryModel toQueryModel( ParsedSql parsedSql, String tableName, List columns, - List occurrences + List occurrences, + String rowTable ) { List bindingParameterIndexes = requireAccountedOccurrences(parsedSql, occurrences); @@ -454,7 +482,8 @@ private QueryModel toQueryModel( parsedSql.parameters().executableSql(), bindingParameterIndexes, columns, - toParameters(occurrences) + toParameters(occurrences), + rowTable ); } @@ -965,6 +994,25 @@ private List resolveColumns(PlainSelect plainSelect, List s return columns; } + /** + * Reports the table whose complete row a read returns, which is the single + * query source when the projection is exactly {@code *} or + * {@code qualifier.*}, and {@code null} for every other projection. The + * name is the schema's own spelling of the table, so a query-side alias or + * spelling never splits one row identity. + */ + private String resolveSelectRowTable(PlainSelect plainSelect, List sources) { + List> selectItems = plainSelect.getSelectItems(); + + if (sources.size() != 1 || selectItems.size() != 1) { + return null; + } + + return selectItems.getFirst().getExpression() instanceof AllColumns + ? sources.getFirst().table().name() + : null; + } + /** * Names a selected direct column after its explicit alias when the * projection declares one, so the alias reaches Java naming. The column's diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryModel.java b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryModel.java index d450a31..4d908f2 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryModel.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryModel.java @@ -13,6 +13,10 @@ * {@code ?} positions in {@link #executableSql()} * @param parameters one query parameter per placeholder index, in * logical placeholder-index order + * @param rowTable the schema's declared name of the table whose + * complete row this query returns, or + * {@code null} when the result is specific to + * this query */ public record QueryModel( String name, @@ -21,6 +25,7 @@ public record QueryModel( String executableSql, List bindingParameterIndexes, List columns, - List parameters + List parameters, + String rowTable ) { } 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 407755f..e172ef8 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java @@ -131,6 +131,14 @@ private boolean hasResultType(QueryModel query) { return !isExec(query); } + /** + * A query that returns one complete table row uses the repository's shared + * row record and row mapper instead of generating its own. + */ + private boolean returnsSharedRow(QueryModel query) { + return query.rowTable() != null; + } + private String resolveImport(ColumnType type) { return switch (type) { case DATE -> "java.time.LocalDate"; @@ -174,16 +182,35 @@ private String generateClass(QueryGroupModel group, JavaNames names) { members.add(generateExecutorField()); 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)); + } + 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 (hasResultType(query) && !returnsSharedRow(query)) { + members.add( + generateRecord( + queryNames.resultTypeName(), + query.columns(), + queryNames.componentNames() + ) + ); } - if (hasResult(query)) { - members.add(generateRowMapper(query, queryNames)); + if (hasResult(query) && !returnsSharedRow(query)) { + members.add( + generateRowMapper( + queryNames.resultTypeName(), + queryNames.rowMapperName(), + query.columns() + ) + ); } members.add(generateJavaDoc(query) + generateMethod(query, queryNames)); @@ -208,7 +235,12 @@ private String indent(String text) { return text.indent(4).stripTrailing(); } - private String generateResultType(QueryModel query, JavaNames.QueryNames names) { + /** Generates one nested result or row record in selected-column order. */ + private String generateRecord( + String typeName, + List columns, + List componentNames + ) { return """ public record %s( %s @@ -216,17 +248,17 @@ public record %s( } """ .formatted( - names.resultTypeName(), - generateResultComponents(query, names) + typeName, + generateResultComponents(columns, componentNames) ); } - private String generateResultComponents(QueryModel query, JavaNames.QueryNames names) { - return IntStream.range(0, query.columns().size()) + private String generateResultComponents(List columns, List componentNames) { + return IntStream.range(0, columns.size()) .mapToObj( index -> generateResultComponent( - query.columns().get(index), - names.componentNames().get(index) + columns.get(index), + componentNames.get(index) ) ) .collect(Collectors.joining(",\n")); @@ -424,7 +456,7 @@ private String generateReturnType(QueryModel query, JavaNames.QueryNames names) }; } - private String generateRowMapper(QueryModel query, JavaNames.QueryNames names) { + private String generateRowMapper(String typeName, String mapperName, List columns) { return """ private static final RowMapper<%s> %s = resultSet -> new %s( @@ -432,10 +464,10 @@ private String generateRowMapper(QueryModel query, JavaNames.QueryNames names) { ); """ .formatted( - names.resultTypeName(), - names.rowMapperName(), - names.resultTypeName(), - generateResultMappings(query) + typeName, + mapperName, + typeName, + generateResultMappings(columns) ); } @@ -443,11 +475,11 @@ private String generateRowMapper(QueryModel query, JavaNames.QueryNames names) { * Reads each result column by its one-based projection position so that * identically named columns from different sources stay distinct. */ - private String generateResultMappings(QueryModel query) { - return IntStream.range(0, query.columns().size()) + private String generateResultMappings(List columns) { + return IntStream.range(0, columns.size()) .mapToObj( index -> generateResultMapping( - query.columns().get(index), + columns.get(index), index + 1 ) ) 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 f0db719..f0fb99b 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaNames.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaNames.java @@ -4,11 +4,14 @@ import dev.sqlcj.analysis.QueryGroupModel; import dev.sqlcj.analysis.QueryModel; import dev.sqlcj.analysis.QueryParameter; +import dev.sqlcj.parser.QueryType; import javax.lang.model.SourceVersion; import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -31,11 +34,16 @@ *

Names that collide inside the generated parameter list or result record are * 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. + * *

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. + * case-insensitive filesystem. Two tables whose row types are equal ignoring + * case are rejected on the same grounds. */ final class JavaNames { @@ -45,7 +53,11 @@ final class JavaNames { private static final String RESULT_SUFFIX = "Result"; - private static final String ROW_MAPPER_SUFFIX = "RowMapper"; + private static final String ROW_SUFFIX = "Row"; + + private static final String MAPPER_SUFFIX = "Mapper"; + + private static final String ROW_MAPPER_SUFFIX = ROW_SUFFIX + MAPPER_SUFFIX; /** * Inherited {@link Object} method names that a generated method or record @@ -66,10 +78,12 @@ final class JavaNames { private static final String EXECUTOR_NAME = "executor"; private final String repositoryClassName; + private final List rows; private final List queries; - private JavaNames(String repositoryClassName, List queries) { + private JavaNames(String repositoryClassName, List rows, List queries) { this.repositoryClassName = repositoryClassName; + this.rows = rows; this.queries = queries; } @@ -85,19 +99,25 @@ static JavaNames of(QueryGroupModel group) { rejectDuplicateMethodNames(group, methodNames); rejectResultTypeNamesDifferingOnlyByCase(group, resultTypeNames); - Set reservedParameterNames = reservedParameterNames(methodNames); + Map rowsByTable = resolveRows(group, methodNames); + + Set reservedParameterNames = reservedParameterNames( + methodNames, + rowsByTable.values() + ); 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); + RowNames row = rowsByTable.get(query.rowTable()); queries.add( new QueryNames( - resultTypeNames.get(index), + row == null ? resultTypeNames.get(index) : row.typeName(), methodName, - methodName + ROW_MAPPER_SUFFIX, + row == null ? methodName + ROW_MAPPER_SUFFIX : row.mapperName(), resolveNames( query.parameters().stream() .map(QueryParameter::name) @@ -118,6 +138,7 @@ static JavaNames of(QueryGroupModel group) { return new JavaNames( upperCamelCase(group.name(), null) + REPOSITORY_SUFFIX, + List.copyOf(rowsByTable.values()), List.copyOf(queries) ); } @@ -126,11 +147,23 @@ String repositoryClassName() { return repositoryClassName; } + /** + * The shared row records of this repository, in the order the queries first + * use them. + */ + List rows() { + return rows; + } + List queries() { return queries; } - /** The resolved Java identifiers of one generated repository method. */ + /** + * The resolved Java identifiers of one generated repository method. A query + * that returns one complete table row names the repository's shared row + * record and row mapper instead of a record of its own. + */ record QueryNames( String resultTypeName, String methodName, @@ -140,6 +173,119 @@ record QueryNames( ) { } + /** + * The resolved Java identifiers of one shared row record. + * + * @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 typeName, + String mapperName, + List componentNames, + int queryIndex + ) { + } + + /** + * Resolves one row record per table whose complete row the group returns, + * keyed by the schema table name and ordered by first use. + */ + private static Map resolveRows(QueryGroupModel group, List methodNames) { + Set queryRowMapperNames = queryRowMapperNames(group, methodNames); + Set usedRowMapperNames = new HashSet<>(); + Map rowTypesByPortabilityKey = new HashMap<>(); + Map rowsByTable = new LinkedHashMap<>(); + + for (int index = 0; index < group.queries().size(); index++) { + QueryModel query = group.queries().get(index); + String table = query.rowTable(); + + if (table == null || rowsByTable.containsKey(table)) { + continue; + } + + String typeName = upperCamelCase(table, query.name()) + ROW_SUFFIX; + + rejectRowTypeNameEqualIgnoringCase(rowTypesByPortabilityKey, table, typeName); + + rowsByTable.put( + table, + new RowNames( + typeName, + resolveName( + decapitalize(typeName) + MAPPER_SUFFIX, + false, + queryRowMapperNames, + usedRowMapperNames + ), + resolveNames( + query.columns().stream() + .map(QueryColumn::name) + .toList(), + RESERVED_MEMBER_NAMES, + query.name() + ), + index + ) + ); + } + + return rowsByTable; + } + + /** + * Rejects two tables of one group whose row types are equal ignoring 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 rejectRowTypeNameEqualIgnoringCase( + Map rowTypesByPortabilityKey, + String tableName, + String typeName + ) { + GeneratedRowType previous = rowTypesByPortabilityKey.putIfAbsent( + typeName.toLowerCase(Locale.ROOT), + new GeneratedRowType(tableName, typeName) + ); + + if (previous != null) { + throw new IllegalArgumentException( + "Tables '%s' and '%s' generate row types that are equal ignoring case: %s and %s" + .formatted( + previous.tableName(), + tableName, + previous.typeName(), + typeName + ) + ); + } + } + + /** One generated nested row type and the table that generated it. */ + private record GeneratedRowType(String tableName, String typeName) { + } + + /** The row mapper names of the queries that still generate their own. */ + private static Set queryRowMapperNames(QueryGroupModel group, List methodNames) { + Set names = new LinkedHashSet<>(); + + for (int index = 0; index < group.queries().size(); index++) { + QueryModel query = group.queries().get(index); + + if (query.rowTable() == null && generatesRowMapper(query)) { + names.add(methodNames.get(index) + ROW_MAPPER_SUFFIX); + } + } + + return names; + } + + private static boolean generatesRowMapper(QueryModel query) { + return query.type() == QueryType.ONE || query.type() == QueryType.MANY; + } + /** * Rejects two queries of one group that generate the same method, naming * both queries so the query source can be corrected. @@ -203,7 +349,10 @@ 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) { + private static Set reservedParameterNames( + List methodNames, + Collection rows + ) { Set reserved = new LinkedHashSet<>(); reserved.add(EXECUTOR_NAME); @@ -212,6 +361,10 @@ private static Set reservedParameterNames(List methodNames) { .map(methodName -> methodName + ROW_MAPPER_SUFFIX) .forEach(reserved::add); + rows.stream() + .map(RowNames::mapperName) + .forEach(reserved::add); + return reserved; } diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java index 44f1cdf..d9eb05f 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -16,6 +16,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -1241,6 +1242,102 @@ void shouldExpandQualifiedAllColumnsForOneSource() { ); } + /** + * A query whose result is one complete table row carries the schema's own + * spelling of that table, so every such query shares one row identity + * regardless of the alias or spelling it used. + */ + @ParameterizedTest + @ValueSource( + strings = { + "SELECT * FROM users", + "SELECT * FROM Users", + "SELECT u.* FROM users u", + "SELECT u.* FROM USERS u" + } + ) + void shouldResolveRowTableForFullRowSelect(String sql) { + QueryModel model = analyzer.analyze( + new Query("ListUsers", QueryType.MANY, sql), + parser.parse(sql), + schema + ); + + assertEquals("users", model.rowTable()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "INSERT INTO users (id) VALUES ($1) RETURNING *", + "UPDATE Users SET name = $2 WHERE id = $1 RETURNING *", + "DELETE FROM users WHERE id = $1 RETURNING *" + } + ) + void shouldResolveRowTableForReturningAllColumns(String sql) { + QueryModel model = analyzer.analyze( + new Query("WriteUser", QueryType.ONE, sql), + parser.parse(sql), + schema + ); + + assertEquals("users", model.rowTable()); + } + + /** + * Every other result shape stays specific to its query, including an + * explicit list of every column and a wildcard combined with another item. + */ + @ParameterizedTest + @ValueSource( + strings = { + "SELECT id, name, active FROM users", + "SELECT *, id FROM users", + "SELECT id, * FROM users", + "INSERT INTO users (id) VALUES ($1) RETURNING id, name, active", + "DELETE FROM users WHERE id = $1 RETURNING id" + } + ) + void shouldNotResolveRowTableForQuerySpecificResult(String sql) { + QueryModel model = analyzer.analyze( + new Query("ReadUsers", QueryType.MANY, sql), + parser.parse(sql), + schema + ); + + assertNull(model.rowTable()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "SELECT * FROM users u JOIN profiles p ON p.user_id = u.id", + "SELECT u.* FROM users u JOIN profiles p ON p.user_id = u.id" + } + ) + void shouldNotResolveRowTableForJoinedWildcard(String sql) { + QueryModel model = analyzer.analyze( + new Query("ListUserProfiles", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertNull(model.rowTable()); + } + + @Test + void shouldNotResolveRowTableForExecWrite() { + String sql = "DELETE FROM users WHERE id = $1"; + + QueryModel model = analyzer.analyze( + new Query("DeleteUser", QueryType.EXEC, sql), + parser.parse(sql), + schema + ); + + assertNull(model.rowTable()); + } + @Test void shouldResolveUniqueUnqualifiedColumnInJoinedQuery() { String sql = """ 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 5d147fe..bf7ab49 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -45,6 +45,7 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Executes the compiler pipeline end to end against a real PostgreSQL @@ -691,6 +692,63 @@ INSERT INTO users (id, code, name, bio) } } + /** + * Covers the shared row record: a {@code RETURNING *} write, a + * {@code SELECT *} read, and a qualified {@code :many} full-row read of one + * table all execute into instances of the repository's single row type. + */ + @Test + void shouldExecuteGeneratedFullRowQueriesIntoOneSharedRowTypeAgainstPostgres() throws Exception { + Path classesDirectory = generateAndCompile(""" + -- name: InsertUserRow :one + INSERT INTO users (id, code, name) + VALUES ($1, $2, $3) + RETURNING *; + + -- name: GetUserRow :one + SELECT * + FROM users + WHERE id = $1; + + -- name: ListUserRows :many + SELECT u.* + FROM users u + ORDER BY u.id; + """); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object repository = newRepository(classLoader); + + Object inserted = repository + .getClass() + .getMethod("insertUserRow", Long.class, Integer.class, String.class) + .invoke(repository, 1L, 42, "Alice"); + + Object read = repository + .getClass() + .getMethod("getUserRow", Long.class) + .invoke(repository, 1L); + + List listed = assertInstanceOf( + List.class, + repository.getClass().getMethod("listUserRows").invoke(repository) + ); + + assertNotNull(inserted); + assertNotNull(read); + assertEquals(1, listed.size()); + + Class rowType = inserted.getClass(); + + assertTrue(rowType.getSimpleName().endsWith("Row"), rowType.getSimpleName()); + assertEquals(rowType, read.getClass()); + assertEquals(rowType, listed.getFirst().getClass()); + + assertEquals("Alice", component(read, "name")); + assertEquals(42, component(listed.getFirst(), "code")); + } + } + /** * Covers a returning delete: every deleted row is returned to the * {@code :many} result, and a delete that matches no row returns an empty 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 b551e7c..df47ba2 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -151,13 +151,27 @@ WHERE id IN ($1, $2) .count() ); - assertTrue(repository.contains("public GetUserResult getUser(LocalDateTime createdAt)")); + assertTrue(repository.contains("public UsersRow getUser(LocalDateTime createdAt)")); + assertFalse(repository.contains("GetUserResult")); assertTrue(repository.contains("Long id")); assertTrue(repository.contains("String name")); assertTrue(repository.contains("LocalDate birthDate")); assertTrue(repository.contains("LocalDateTime createdAt")); assertTrue(repository.contains("BigDecimal balance")); - assertTrue(repository.contains("private static final RowMapper getUserRowMapper")); + + assertEquals( + List.of( + "Long id", + "String name", + "Boolean active", + "LocalDate birthDate", + "LocalDateTime createdAt", + "BigDecimal balance" + ), + recordComponents(repository, "UsersRow") + ); + + assertTrue(repository.contains("private static final RowMapper usersRowMapper")); assertTrue(repository.contains("resultSet.getObject(1, Long.class)")); assertTrue(repository.contains("resultSet.getObject(2, String.class)")); assertTrue(repository.contains("resultSet.getObject(3, Boolean.class)")); @@ -186,7 +200,7 @@ WHERE id IN ($1, $2) assertTrue(repository.contains("private static final RowMapper findUsersRowMapper")); assertTrue( - repository.indexOf("public GetUserResult getUser(") < repository + repository.indexOf("public UsersRow getUser(") < repository .indexOf("public List listUsers(") ); @@ -1738,11 +1752,13 @@ INSERT INTO users (id, name) String repository = Files.readString(repositoryFile); - assertTrue(repository.contains("public record InsertUserResult(")); + assertTrue(repository.contains("public record UsersRow(")); + assertFalse(repository.contains("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("public UsersRow insertUser(Long id, String name)")); + assertTrue(repository.contains("private static final RowMapper usersRowMapper")); assertTrue(repository.contains("return executor.query(")); assertTrue(repository.contains("VALUES (?, ?)")); assertTrue(repository.contains("RETURNING *")); 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 2f5d5b2..a49bc02 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java @@ -217,7 +217,8 @@ void shouldExecuteExactSqlForQuotedIdentifierWithBackslash() throws Exception { sql, List.of(1), List.of(new QueryColumn("a\\q", ColumnType.VARCHAR, true)), - List.of(new QueryParameter(1, "a\\q", ColumnType.VARCHAR)) + List.of(new QueryParameter(1, "a\\q", ColumnType.VARCHAR)), + null ); GeneratedFile file = generate(query); @@ -241,7 +242,8 @@ void shouldExecuteExactSqlForTextBlockDelimiterAndTrailingWhitespace() throws Ex sql, List.of(), List.of(new QueryColumn("id", ColumnType.BIGINT, false)), - List.of() + List.of(), + null ); GeneratedFile file = generate(query); @@ -331,7 +333,8 @@ private QueryModel query(String name, List columns, List columns = List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("name", ColumnType.VARCHAR, true), + new QueryColumn("created_at", ColumnType.TIMESTAMP, true) + ); + + QueryModel createUser = new QueryModel( + "CreateUser", + QueryType.ONE, + "users", + "INSERT INTO users (name) VALUES (?) RETURNING *", + List.of(1), + columns, + List.of(new QueryParameter(1, "name", ColumnType.VARCHAR)), + "users" + ); + + QueryModel getUser = new QueryModel( + "GetUser", + QueryType.ONE, + "users", + "SELECT * FROM users WHERE id = ?", + List.of(1), + columns, + List.of(new QueryParameter(1, "id", ColumnType.BIGINT)), + "users" + ); + + QueryModel listUsers = new QueryModel( + "ListUsers", + QueryType.MANY, + "users", + "SELECT u.* FROM users u", + List.of(), + columns, + List.of(), + "users" + ); + + GeneratedFile file = codeGenerator.generate( + new QueryGroupModel( + GROUP, + List.of(createUser, getUser, listUsers) + ) + ); + + String source = file.content(); + + assertEquals( + 1, + source.lines() + .filter(line -> line.equals(" public record UsersRow(")) + .count() + ); + + assertEquals( + 1, + source.lines() + .filter(line -> line.contains("private static final RowMapper usersRowMapper")) + .count() + ); + + assertFalse(source.contains("Result")); + + assertTrue(source.contains("public UsersRow createUser(String name)")); + assertTrue(source.contains("public UsersRow getUser(Long id)")); + assertTrue(source.contains("public List listUsers()")); + + assertEquals( + 3, + source.lines() + .filter(line -> line.strip().equals("usersRowMapper")) + .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(") + ); + + assertTrue( + source.indexOf("public record UsersRow(") < source.indexOf("public UsersRow createUser(") + ); + + assertCompiles(file); + } + + @Test + void shouldGenerateOneRowRecordPerRowTable() throws IOException { + QueryModel getUser = new QueryModel( + "GetUser", + QueryType.ONE, + "users", + "SELECT * FROM users WHERE id = ?", + List.of(1), + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + List.of(new QueryParameter(1, "id", ColumnType.BIGINT)), + "users" + ); + + QueryModel getOrder = new QueryModel( + "GetOrder", + QueryType.ONE, + "orders", + "SELECT * FROM orders WHERE id = ?", + List.of(1), + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("user_id", ColumnType.BIGINT, false) + ), + List.of(new QueryParameter(1, "id", ColumnType.BIGINT)), + "orders" + ); + + GeneratedFile file = codeGenerator.generate( + new QueryGroupModel( + GROUP, + List.of(getUser, getOrder) + ) + ); + + String source = file.content(); + + assertTrue(source.contains("public record UsersRow(")); + assertTrue(source.contains("public record OrdersRow(")); + 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(")); + + assertCompiles(file); + } + + /** + * A row mapper yields to the mapper of a query that still generates its + * own, so the two fields stay distinct. + */ + @Test + void shouldDisambiguateRowMapperFromQueryRowMapper() throws IOException { + QueryModel authors = new QueryModel( + "Authors", + QueryType.MANY, + "authors", + "SELECT id, name FROM authors", + List.of(), + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("name", ColumnType.VARCHAR, true) + ), + List.of(), + null + ); + + QueryModel getAuthor = new QueryModel( + "GetAuthor", + QueryType.ONE, + "authors", + "SELECT * FROM authors WHERE id = ?", + List.of(1), + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("name", ColumnType.VARCHAR, true), + new QueryColumn("bio", ColumnType.TEXT, true) + ), + List.of(new QueryParameter(1, "id", ColumnType.BIGINT)), + "authors" + ); + + GeneratedFile file = codeGenerator.generate( + new QueryGroupModel( + GROUP, + List.of(authors, getAuthor) + ) + ); + + String source = file.content(); + + assertTrue(source.contains("private static final RowMapper authorsRowMapper =")); + assertTrue(source.contains("private static final RowMapper authorsRowMapper1 =")); + assertTrue(source.contains("public List authors()")); + assertTrue(source.contains("public AuthorsRow getAuthor(Long id)")); + + assertCompiles(file); + } + + @Test + void shouldRejectRowTypesThatAreEqualIgnoringCase() { + 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" + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> codeGenerator.generate( + new QueryGroupModel( + GROUP, + List.of(getUserData, fetchUserdata) + ) + ) + ); + + assertEquals( + "Tables 'user_data' and 'userdata' generate row types that are equal ignoring case: " + + "UserDataRow and UserdataRow", + exception.getMessage() + ); + } + private GeneratedFile generate(QueryModel query) { return generate(codeGenerator, query); } @@ -1607,7 +1886,8 @@ private QueryModel query(String name, QueryType type, List param .map(QueryParameter::index) .toList(), List.of(), - parameters + parameters, + null ); } } 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 f06ed72..9a6942a 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaNamesTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaNamesTest.java @@ -347,7 +347,8 @@ private QueryModel query( .map(QueryParameter::index) .toList(), columns, - parameters + parameters, + null ); } } From fdf2fb543955e85665221a86fb9ed6f14aa559e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Thu, 24 Sep 2026 23:16:50 +0300 Subject: [PATCH 2/2] docs: return the shared authors row from the sample and documentation --- README.md | 4 +- docs/configuration.md | 26 ++++++-- docs/queries.md | 63 ++++++++++++++----- docs/quickstart.md | 34 +++++----- examples/maven-postgresql/sql/queries.sql | 6 +- .../src/main/java/com/example/app/App.java | 18 +++--- 6 files changed, 102 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index b4a0391..b909868 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ sql: ```sql -- name: GetAuthor :one -SELECT id, name, bio +SELECT * FROM authors WHERE id = $1; ``` @@ -29,7 +29,7 @@ WHERE id = $1; ```java AuthorRepository authors = new AuthorRepository(executor); -AuthorRepository.GetAuthorResult author = authors.getAuthor(1L); +AuthorRepository.AuthorsRow author = authors.getAuthor(1L); ``` Every query of `sql/queries.sql` becomes a method of that one diff --git a/docs/configuration.md b/docs/configuration.md index 7dc4a76..5c2a038 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -146,7 +146,8 @@ resolved inside its own repository. ## Generated Java Names The configured `sql[].name` and the SQL names inside the entry — query names, -column names, and projection aliases — become conventional Java identifiers. +table names, column names, and projection aliases — become conventional Java +identifiers. SQL identifier delimiters are removed before a name is converted, so the quoted column `"user id"` has the JDBC label `user id` and generates the component `userId`, while executable SQL keeps the query exactly as written. @@ -187,9 +188,14 @@ The rules are applied as follows: `Repository`, so `author_admin` generates `AuthorAdminRepository`, - a result record is the upper camel form of the query name followed by `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, - 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`, +- a row-mapper field is the method name followed by `RowMapper`, and the mapper + of a row record is the lower camel form of the row type followed by `Mapper`, + so `AuthorsRow` generates `authorsRowMapper`, - record components and method parameters are the lower camel form of the column name or projection alias, so `created_at` generates `createdAt`. @@ -202,8 +208,13 @@ Generated names also avoid names that Java or the generated source already uses: - a method name also avoids the inherited `Object` method names, so a query named `ToString` generates the method `toString_`, - record components avoid inherited `Object` method names, -- method parameters avoid the generator-owned name `executor` and the row-mapper - field names of the repository. +- method parameters avoid the generator-owned name `executor` and every + row-mapper field name of the repository, including the mappers of its row + records, +- a row record's mapper field yields to the mapper of a query that generates its + own, using the same numeric suffixes, so a query named `Authors` keeps + `authorsRowMapper` while the row record of the table `authors` uses + `authorsRowMapper1`. Method parameters and record components are disambiguated inside their own generated method or record, in logical parameter order and selected-column @@ -240,6 +251,13 @@ 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: + +```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 +``` + ### Generated path collisions Two entries whose repository files resolve to the same path, or to paths that diff --git a/docs/queries.md b/docs/queries.md index 535fc9d..324fed7 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -126,7 +126,7 @@ A write targets exactly one table of its entry's schema. -- name: CreateAuthor :one INSERT INTO authors (name, bio) VALUES ($1, $2) -RETURNING id, name, bio; +RETURNING *; -- name: UpdateAuthorBio :exec UPDATE authors @@ -148,9 +148,11 @@ with a `RETURNING` clause that lists either: or - a bare `*`, which expands in the target table's schema column order. -A returning write produces the same generated result record, positional row -mapper, and cardinality behavior as a read, so a database-generated `SERIAL` or -`BIGSERIAL` value is read back with its declared type. +A returning write produces the same generated record, positional row mapper, and +cardinality behavior as a read, so a database-generated `SERIAL` or `BIGSERIAL` +value is read back with its declared type. A clause that is exactly `*` returns +the target table's shared `Row` record, while a `RETURNING` column +list keeps the query's own `Result` record. An aliased or computed `RETURNING` item, a qualified `table.*`, an unknown column, and `RETURNING` on `:exec` are rejected. `ON CONFLICT`, multi-row @@ -213,11 +215,19 @@ that repository; sqlcj never generates a class per query. constructor taking that executor. - Methods appear in query-source order. A method name is the lower camel form of the query name, as in `get_author` and `GetAuthor` to `getAuthor`. -- A `:one` or `:many` query also generates a nested `public record` named +- A `:one` 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. +- Every other `:one` 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 column name, plus a private `RowMapper` field that reads each column by its - one-based position. + one-based position. That includes an explicit column list, even one naming + every column, a wildcard combined with another projection item, a wildcard in + a join, and a `RETURNING` column list. - 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 @@ -234,6 +244,7 @@ package com.example.app.db; import dev.sqlcj.runtime.QueryExecutor; import dev.sqlcj.runtime.RowMapper; import java.util.List; +import java.time.LocalDateTime; /** * Generated by sqlcj. @@ -248,18 +259,20 @@ public final class AuthorRepository { this.executor = executor; } - public record CreateAuthorResult( + public record AuthorsRow( Long id, String name, - String bio + String bio, + LocalDateTime createdAt ) { } - private static final RowMapper createAuthorRowMapper = - resultSet -> new CreateAuthorResult( + private static final RowMapper authorsRowMapper = + resultSet -> new AuthorsRow( resultSet.getObject(1, Long.class), resultSet.getObject(2, String.class), - resultSet.getObject(3, String.class) + resultSet.getObject(3, String.class), + resultSet.getObject(4, LocalDateTime.class) ); /** @@ -267,14 +280,14 @@ public final class AuthorRepository { * Table: authors * Type: ONE */ - public CreateAuthorResult createAuthor(String name, String bio) { + public AuthorsRow createAuthor(String name, String bio) { return executor.query( """ INSERT INTO authors (name, bio) VALUES (?, ?) - RETURNING id, name, bio;""", + RETURNING *;""", java.util.Arrays.asList(name, bio), - createAuthorRowMapper + authorsRowMapper ); } @@ -282,12 +295,17 @@ public final class AuthorRepository { } ``` +`CreateAuthor`, `GetAuthor`, and `ListAuthors` each return one complete +`authors` row, so all three use the one `AuthorsRow` record and the one +`authorsRowMapper` field. `getAuthor` returns `AuthorsRow` and `listAuthors` +returns `List`. + The application constructs the repository once per execution context: ```java AuthorRepository authors = new AuthorRepository(new JdbcQueryExecutor(dataSource)); -AuthorRepository.GetAuthorResult author = authors.getAuthor(1L); +AuthorRepository.AuthorsRow author = authors.getAuthor(1L); ``` A query name, projection alias, column name, and parameter name becomes a @@ -403,6 +421,11 @@ Reads: - `PostgresIntegrationTest.shouldExecuteGeneratedOneQueryAgainstPostgres` and `PostgresIntegrationTest.shouldExecuteGeneratedManyQueryAgainstPostgres` execute an ordered list read against PostgreSQL 16. +- `QueryAnalyzerTest.shouldResolveRowTableForFullRowSelect`, + `QueryAnalyzerTest.shouldNotResolveRowTableForQuerySpecificResult`, + `QueryAnalyzerTest.shouldNotResolveRowTableForJoinedWildcard`, and + `QueryAnalyzerTest.shouldNotResolveRowTableForExecWrite` cover which reads + return one complete table row. Writes and `RETURNING`: @@ -415,6 +438,8 @@ Writes and `RETURNING`: `QueryAnalyzerTest.shouldRejectUnknownReturningColumn`, and `QueryAnalyzerTest.shouldRejectExcludedReturningWriteForm` cover the write shapes. +- `QueryAnalyzerTest.shouldResolveRowTableForReturningAllColumns` covers the + returning writes that produce a complete table row. - `PostgresIntegrationTest.shouldExecuteGeneratedWriteAgainstPostgres`, `PostgresIntegrationTest.shouldExecuteGeneratedInsertReturningAgainstPostgres`, `PostgresIntegrationTest.shouldExecuteGeneratedUpdateReturningAgainstPostgres`, @@ -452,3 +477,11 @@ Generated Java: - `SqlcjCompilerIntegrationTest.shouldGenerateCompilableJavaFiles` and `SqlcjCompilerIntegrationTest.shouldGenerateCompilableJavaForReturningWrites` compile the generated output of the supported query shapes. +- `JavaCodeGeneratorTest.shouldShareOneRowRecordAcrossFullRowQueriesOfOneTable`, + `JavaCodeGeneratorTest.shouldGenerateOneRowRecordPerRowTable`, + `JavaCodeGeneratorTest.shouldDisambiguateRowMapperFromQueryRowMapper`, and + `JavaCodeGeneratorTest.shouldRejectRowTypesThatAreEqualIgnoringCase` cover the + shared row records, their mapper fields, and their collision. +- `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 f7fcf43..ff78d58 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -180,15 +180,15 @@ The accepted column types and `CREATE TABLE` constructs are listed in -- name: CreateAuthor :one INSERT INTO authors (name, bio) VALUES ($1, $2) -RETURNING id, name, bio; +RETURNING *; -- name: GetAuthor :one -SELECT id, name, bio +SELECT * FROM authors WHERE id = $1; -- name: ListAuthors :many -SELECT id, name +SELECT * FROM authors ORDER BY id; @@ -205,9 +205,12 @@ WHERE id = $1; 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). +`deleteAuthor`. `CreateAuthor`, `GetAuthor`, and `ListAuthors` each return one +complete `authors` row, so all three 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. The full query contract is documented in [Queries](queries.md). ## 8. `src/main/java/com/example/app/App.java` @@ -232,12 +235,11 @@ public final class App { AuthorRepository authors = new AuthorRepository(executor); - AuthorRepository.CreateAuthorResult created = - authors.createAuthor("Ada Lovelace", "First programmer"); + AuthorRepository.AuthorsRow created = authors.createAuthor("Ada Lovelace", "First programmer"); System.out.println("created: " + created.id() + " " + created.name()); - AuthorRepository.GetAuthorResult read = authors.getAuthor(created.id()); + AuthorRepository.AuthorsRow read = authors.getAuthor(created.id()); System.out.println("read: " + read.name() + " / " + read.bio()); @@ -245,7 +247,7 @@ public final class App { System.out.println("updated rows: " + updatedRows); - for (AuthorRepository.ListAuthorsResult author : authors.listAuthors()) { + for (AuthorRepository.AuthorsRow author : authors.listAuthors()) { System.out.println("listed: " + author.id() + " " + author.name()); } @@ -254,11 +256,9 @@ public final class App { try (Connection connection = dataSource.getConnection()) { connection.setAutoCommit(false); - AuthorRepository transactionalAuthors = - new AuthorRepository(new JdbcQueryExecutor(connection)); + AuthorRepository transactionalAuthors = new AuthorRepository(new JdbcQueryExecutor(connection)); - AuthorRepository.CreateAuthorResult committed = - transactionalAuthors.createAuthor("Grace Hopper", null); + AuthorRepository.AuthorsRow committed = transactionalAuthors.createAuthor("Grace Hopper", null); transactionalAuthors.updateAuthorBio(committed.id(), "Compiler pioneer"); @@ -270,11 +270,9 @@ public final class App { try (Connection connection = dataSource.getConnection()) { connection.setAutoCommit(false); - AuthorRepository transactionalAuthors = - new AuthorRepository(new JdbcQueryExecutor(connection)); + AuthorRepository transactionalAuthors = new AuthorRepository(new JdbcQueryExecutor(connection)); - AuthorRepository.CreateAuthorResult discarded = - transactionalAuthors.createAuthor("Temporary Author", null); + AuthorRepository.AuthorsRow discarded = transactionalAuthors.createAuthor("Temporary Author", null); transactionalAuthors.updateAuthorBio(discarded.id(), "never stored"); diff --git a/examples/maven-postgresql/sql/queries.sql b/examples/maven-postgresql/sql/queries.sql index 80652e6..80ae52e 100644 --- a/examples/maven-postgresql/sql/queries.sql +++ b/examples/maven-postgresql/sql/queries.sql @@ -1,15 +1,15 @@ -- name: CreateAuthor :one INSERT INTO authors (name, bio) VALUES ($1, $2) -RETURNING id, name, bio; +RETURNING *; -- name: GetAuthor :one -SELECT id, name, bio +SELECT * FROM authors WHERE id = $1; -- name: ListAuthors :many -SELECT id, name +SELECT * FROM authors ORDER BY id; 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 f708938..85f6e37 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 @@ -21,6 +21,10 @@ * context: once from the {@code DataSource}-backed executor, and once more per * transaction from a caller-owned connection. * + *

Create, read, and list each return one complete {@code authors} row, so + * all three share the repository's single + * {@link AuthorRepository.AuthorsRow} record. + * *

Every step is checked, so the process exits non-zero as soon as one * generated operation returns an unexpected result. * @@ -39,7 +43,7 @@ public static void main(String[] args) throws SQLException { AuthorRepository authors = new AuthorRepository(executor); - AuthorRepository.CreateAuthorResult created = authors.createAuthor("Ada Lovelace", "First programmer"); + AuthorRepository.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"); @@ -48,7 +52,7 @@ public static void main(String[] args) throws SQLException { System.out.println("created: " + created.id() + " " + created.name()); - AuthorRepository.GetAuthorResult read = authors.getAuthor(created.id()); + AuthorRepository.AuthorsRow read = authors.getAuthor(created.id()); check(read != null, "GetAuthor returned no row for the created author"); checkEquals(created.id(), read.id(), "GetAuthor id"); @@ -57,7 +61,7 @@ public static void main(String[] args) throws SQLException { System.out.println("read: " + read.name() + " / " + read.bio()); - 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"); @@ -78,7 +82,7 @@ public static void main(String[] args) throws SQLException { Long committedId = writeAndCommit(dataSource); - AuthorRepository.GetAuthorResult committed = authors.getAuthor(committedId); + AuthorRepository.AuthorsRow committed = authors.getAuthor(committedId); check(committed != null, "the committed author is not readable after commit"); checkEquals("Grace Hopper", committed.name(), "committed name"); @@ -99,7 +103,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"); @@ -117,7 +121,7 @@ private static Long writeAndCommit(DataSource dataSource) throws SQLException { AuthorRepository transactionalAuthors = new AuthorRepository(new JdbcQueryExecutor(connection)); - AuthorRepository.CreateAuthorResult author = transactionalAuthors.createAuthor("Grace Hopper", null); + AuthorRepository.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"); @@ -142,7 +146,7 @@ private static Long writeAndRollback(DataSource dataSource) throws SQLException AuthorRepository transactionalAuthors = new AuthorRepository(new JdbcQueryExecutor(connection)); - AuthorRepository.CreateAuthorResult author = transactionalAuthors.createAuthor("Temporary Author", null); + AuthorRepository.AuthorsRow author = transactionalAuthors.createAuthor("Temporary Author", null); check(author != null, "CreateAuthor returned no row inside the rolled back transaction");