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"); 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 ); } }