diff --git a/README.md b/README.md index 2489344..b4a0391 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,21 @@ sqlcj is a SQL compiler and type-safe Java code generator for PostgreSQL, inspired by [sqlc](https://github.com/sqlc-dev/sqlc). You write a PostgreSQL schema snapshot and named SQL queries. sqlcj analyzes -them against the schema and generates readable Java classes with typed -parameters and typed result records, which execute through a small JDBC runtime: +them against the schema and generates one readable Java repository per named +query group, with typed parameters and typed result records, which executes +through a small JDBC runtime: ```text schema + named SQL -> sqlcj generate -> generated Java -> JDBC ``` +```yaml +sql: + - name: Author + schema: sql/schema.sql + queries: sql/queries.sql +``` + ```sql -- name: GetAuthor :one SELECT id, name, bio @@ -19,9 +27,14 @@ WHERE id = $1; ``` ```java -GetAuthor.GetAuthorResult author = new GetAuthor(executor).getAuthor(1L); +AuthorRepository authors = new AuthorRepository(executor); + +AuthorRepository.GetAuthorResult author = authors.getAuthor(1L); ``` +Every query of `sql/queries.sql` becomes a method of that one +`AuthorRepository`. + The SQL stays visible and owned by the application. sqlcj is not an ORM, a migration tool, or a query builder: it does not run migrations, inspect a live database, or build queries at runtime. @@ -75,8 +88,8 @@ operations, including an application-controlled commit and rollback. empty directory. - [Queries](docs/queries.md) — query annotations, the supported SQL shapes, parameter and binding order, the generated API, and what is not supported. -- [Configuration](docs/configuration.md) — the `sqlcj.yaml` format, path - resolution, and generated Java naming. +- [Configuration](docs/configuration.md) — the `sqlcj.yaml` format, query-group + names, path resolution, and generated Java naming. - [PostgreSQL Support](docs/postgresql.md) — the engine contract, accepted column types and `CREATE TABLE` constructs, null handling, and connection ownership. diff --git a/docs/configuration.md b/docs/configuration.md index 0ea9c95..b67dc12 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -28,7 +28,8 @@ blocking JDBC execution; there is no engine or dialect option. See ```yaml version: "1" sql: - - schema: schema.sql + - name: Author + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -41,6 +42,7 @@ java: | --- | --- | --- | | `version` | string | Configuration contract version. Must be `"1"`. | | `sql` | list | Non-empty, ordered list of source entries. | +| `sql[].name` | string | Identity of the query group. Names the generated repository. | | `sql[].schema` | string | Path to a file containing `CREATE TABLE` statements. | | `sql[].queries` | string | Path to a file containing named queries. | | `java` | mapping | Java generation settings. | @@ -49,17 +51,36 @@ java: ### `sql` entries -Each entry pairs one schema source with one named-query source. Entries are -loaded in declared order, each entry's queries are analyzed against that entry's -own schema, and query order inside a query file is preserved. +Each entry pairs one group name with one schema source and one named-query +source. Entries are loaded in declared order, each entry's queries are analyzed +against that entry's own schema, and query order inside a query file is +preserved. Schemas are not shared or merged between entries. A query can only use tables declared in the schema file of its own entry. -Query names must be unique across all configured query sources, because each -query name becomes a generated Java class name. See -[Generated Java Names](#generated-java-names) for how a query name becomes a -class name and when two query names collide. +One entry generates exactly one repository containing every query of its query +source, in declared query order. Query names are therefore scoped to their +entry: two entries may use the same query name, while two queries of one entry +that generate the same method name are rejected. See +[Generated Java Names](#generated-java-names) for the naming rules and the +collisions that end a run. + +### `sql[].name` + +`sql[].name` is required and is used unchanged as the prefix of the generated +repository type name, so the entry `name: Author` generates `AuthorRepository`. + +It must be a single valid, non-blank Java identifier. A Java keyword, the +literals `true`, `false`, and `null`, the identifier `_`, and a restricted +identifier such as `var` or `record` are rejected: + +```text +sqlcj: Invalid configuration in /home/dev/project/sqlcj.yaml: 'sql[0].name' value 'record' is not a valid Java identifier for a generated repository name +``` + +sqlcj does not derive the name from a table or a file name. A query file may +join or write several tables, so the group boundary is declared, not guessed. ### `java.package` @@ -67,11 +88,11 @@ class name and when two query names collide. identifiers, for example `dev.example.generated`. It is used both for the generated `package` declaration and for the generated file layout. -A query named `GetUser` with `java.package: dev.example.generated` and +An entry named `Author` with `java.package: dev.example.generated` and `java.out: generated` is written to: ```text -generated/dev/example/generated/GetUser.java +generated/dev/example/generated/AuthorRepository.java ``` ### Value rules @@ -80,8 +101,8 @@ generated/dev/example/generated/GetUser.java - Unknown fields are rejected. - Wrong-typed fields are rejected, including an unquoted numeric `version`. - A missing or unsupported `version`, a missing section, an empty `sql` list, a - null `sql` entry, a blank value, and an invalid `java.package` are all invalid - configuration. + null `sql` entry, a blank value, an invalid `sql[].name`, and an invalid + `java.package` are all invalid configuration. ## Path Resolution @@ -99,77 +120,103 @@ canonical location. ```yaml version: "1" sql: - - schema: sql/users/schema.sql + - name: User + schema: sql/users/schema.sql queries: sql/users/queries.sql - - schema: sql/orders/schema.sql + - name: Order + schema: sql/orders/schema.sql queries: sql/orders/queries.sql java: package: dev.example.generated out: target/generated-sources/sqlcj ``` -With the configuration above and queries `GetUser` and `ListOrders`, sqlcj -generates: +With the configuration above, sqlcj generates one repository per entry: ```text -target/generated-sources/sqlcj/dev/example/generated/GetUser.java -target/generated-sources/sqlcj/dev/example/generated/ListOrders.java +target/generated-sources/sqlcj/dev/example/generated/UserRepository.java +target/generated-sources/sqlcj/dev/example/generated/OrderRepository.java ``` +Both repositories may contain a query named `GetById`, because each name is +resolved inside its own repository. + ## Generated Java Names -Query names and SQL column names become Java identifiers. SQL identifier -delimiters are removed before a name is analyzed, so the quoted column -`"user id"` has the JDBC label `user id`, while executable SQL keeps the query -exactly as written. +The configured `sql[].name` and the SQL names inside the entry become Java +identifiers. SQL identifier delimiters are removed before a name is analyzed, so +the quoted column `"user id"` has the JDBC label `user id`, while executable SQL +keeps the query exactly as written. + +The repository name is the configured `sql[].name` followed by `Repository`, +without normalization, because configuration already requires a valid Java +identifier. -A name that is already a valid, non-reserved Java identifier keeps its spelling: +A query or column name that is already a valid, non-reserved Java identifier +keeps its spelling: -- a query named `GetUser` generates the class `GetUser` and the method `getUser`, -- a query named `getUser` generates the class `getUser` and the method `getUser`, +- a query named `GetUser` generates the result record `GetUserResult` and the + method `getUser`, - a column named `created_at` generates the record component `created_at`. Any other name is normalized deterministically: - each maximal run of characters that cannot appear in a Java identifier becomes - a single `_`, so `Get-User` and `Get*/User` both generate the class `Get_User`, + a single `_`, so `Get-User` and `Get*/User` both generate the result record + `Get_UserResult`, - a leading `_` is added when the first character cannot start an identifier, so - `1stQuery` generates the class `_1stQuery`, + `1stQuery` generates `_1stQueryResult`, - a trailing `_` is added to a Java keyword, to `true`, `false`, `null`, and to `_`, so a column named `class` generates the component `class_`. Generated names also avoid names that the generated source already uses: -- a class name never repeats an imported or generated type name such as `List`, - `String`, `RowMapper`, or `QueryExecutor`, and never uses a restricted type - identifier such as `record` or `var`; such a name gets a trailing `_`, so a - query named `List` generates the class `List_`, - the generated method name keeps the lower-initial rule and avoids Java keywords and inherited `Object` method names, so a query named `Class` - generates the class `Class` with the method `class_`, -- record components avoid inherited `Object` method names, and method parameters - avoid the generator-owned names `executor` and `ROW_MAPPER`. + generates the record `ClassResult` and the method `class_`, +- record components avoid inherited `Object` method names, +- method parameters avoid the generator-owned name `executor` and the row-mapper + field names of the repository, which are the method name followed by + `RowMapper`. Method parameters and record components are disambiguated inside their own -generated class, in logical parameter order and selected-column order, using the -suffixes `1`, `2`, and so on. Two parameters resolved from the column `id` -become `id1` and `id2`, and the columns `user id` and `user-id` become the -components `user_id1` and `user_id2`. +generated method or record, in logical parameter order and selected-column +order, using the suffixes `1`, `2`, and so on. Two parameters resolved from the +column `id` become `id1` and `id2`, and the columns `user id` and `user-id` +become the components `user_id1` and `user_id2`. The generated row mapper reads each result column by its one-based position in the selected-column list, so renaming a component never changes which column it reads, and identically named columns selected from different query sources stay distinct. +### Repository method collisions + +Two queries of one entry that generate the same method name are rejected instead +of being renamed, because a repository method is a name the application calls. +The diagnostic names both queries: + +```text +sqlcj: Invalid query group 'User' in /home/dev/project/sql/queries.sql: Queries 'Get.User' and 'Get-User' generate the same repository method 'get_User' +``` + +Two queries of one entry whose nested result types differ only by case are +rejected for the same reason, because those class files are one path on a +case-insensitive filesystem: + +```text +sqlcj: Invalid query group 'User' in /home/dev/project/sql/queries.sql: Queries 'GetUser' and 'getuser' generate result types that differ only by case: GetUserResult and getuserResult +``` + ### Generated path collisions -Two queries whose generated class names resolve to the same file path, or to -paths that differ only by case and are therefore not portable, are rejected -before any file of the run is written, so existing output is not overwritten: +Two entries whose repository files resolve to the same path, or to paths that +differ only by case and are therefore not portable, are rejected before any file +of the run is written, so existing output is not overwritten: ```text -sqlcj: Duplicate generated file for queries 'Get.User' and 'Get-User': generated/Get_User.java -sqlcj: Generated file paths for queries 'GetUser' and 'getuser' differ only by case: generated/GetUser.java and generated/getuser.java +sqlcj: Duplicate generated file for repositories 'User' and 'User': generated/UserRepository.java +sqlcj: Generated file paths for repositories 'User' and 'user' differ only by case: generated/UserRepository.java and generated/userRepository.java ``` ## Diagnostics @@ -198,6 +245,7 @@ from a previous run. sqlcj does not remove or roll back files it has already written. sqlcj also never deletes a generated file that the current run did not produce, -so a renamed or deleted query leaves its previous class behind. Generate into a +so a renamed or removed configuration entry leaves its previous repository +behind. Generate into a build-owned directory and let the build's clean step remove stale output, as the [Quickstart](quickstart.md) does. diff --git a/docs/postgresql.md b/docs/postgresql.md index 1e82dc1..4a79591 100644 --- a/docs/postgresql.md +++ b/docs/postgresql.md @@ -23,7 +23,7 @@ supplies the PostgreSQL driver. `JdbcQueryExecutor` has two construction paths. Both share the same positional parameter binding, row mapping, single-row and multi-row result handling, affected-row counting, and exception translation, and both accept the same -generated query classes without regeneration: +generated repositories without regeneration: | Construction | Connection ownership | | --- | --- | @@ -32,8 +32,9 @@ generated query classes without regeneration: The caller-owned connection path is how several generated operations take part in one application-controlled transaction: the application disables auto-commit, -runs generated reads and writes through one executor, and then calls `commit` or -`rollback` itself. sqlcj provides no transaction callback or template API, no +constructs another repository instance over an executor bound to that +connection, runs generated reads and writes through it, and then calls `commit` +or `rollback` itself. sqlcj provides no transaction callback or template API, no savepoints, and no isolation configuration. The [Quickstart](quickstart.md) runs that pattern end to end. @@ -52,7 +53,7 @@ connection inherits that connection's confinement to a single thread at a time. Behavior is verified against PostgreSQL 16. The pipeline is executed end to end against a `postgres:16-alpine` container: the schema snapshot is run as -PostgreSQL DDL, the generated Java is compiled, and the generated classes are +PostgreSQL DDL, the generated Java is compiled, and the generated repository is executed through the JDBC runtime. Those tests are skipped when Docker is unavailable. @@ -96,7 +97,7 @@ accepted and map exactly like their unparameterized spellings. Any spelling that is not listed above is rejected. -The generated class imports `java.time.LocalDate`, `java.time.LocalDateTime`, +The generated repository imports `java.time.LocalDate`, `java.time.LocalDateTime`, `java.time.OffsetDateTime`, `java.math.BigDecimal`, and `java.util.UUID` as needed; the remaining types need no import. Each result column is read with `resultSet.getObject(position, JavaType.class)` at its one-based position in the diff --git a/docs/queries.md b/docs/queries.md index 3e2fe6e..b927ca8 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -28,9 +28,11 @@ WHERE id = $1; the query annotation, separated by whitespace. - A query owns every following line until the next header or the end of the file. The statement's trailing `;` is part of the query. -- Query order inside a file is preserved. -- A query name must be unique across every configured query source, because it - becomes a generated Java class name. +- Query order inside a file is preserved, and it is the order of the methods of + the generated repository. +- A query name must be unique inside its own query source. Two configuration + entries may use the same query name, because each entry generates its own + repository. - An unparsable header, an unknown annotation, a duplicate name, and a header without SQL are all rejected. @@ -200,22 +202,27 @@ types differ are rejected. ## Generated Java -Each query generates one final class in the configured `java.package`, written -to the package directory under `java.out` and named after the query. +Each configuration entry generates one final repository class in the configured +`java.package`, written to the package directory under `java.out` and named +`Repository`. Every named query of that entry becomes one method of +that repository; sqlcj never generates a class per query. -- The class has a single constructor taking a - `dev.sqlcj.runtime.QueryExecutor`. -- The generated method name is the class name with a lower-case first character. +- The repository has one `dev.sqlcj.runtime.QueryExecutor` field and one + constructor taking that executor. +- Methods appear in query-source order. A method name is the query name with a + lower-case first character. - A `:one` or `:many` query also generates a nested `public record` named - `Result` whose components follow the selected-column order, plus a - private `RowMapper` that reads each column by its one-based position. + `Result` whose components follow the selected-column order, plus a + private `RowMapper` field that reads each column by its one-based position. - A `:exec` query generates no result record and returns `int`. - Generated source imports only `dev.sqlcj.runtime.QueryExecutor`, `dev.sqlcj.runtime.RowMapper`, `java.util.List`, and the JDK types of the mapped columns, so the runtime artifact is the only sqlcj dependency a consumer needs. -`CreateAuthor` above generates: +The `Author` entry of the [Quickstart](quickstart.md), which declares +`CreateAuthor`, `GetAuthor`, `ListAuthors`, `UpdateAuthorBio`, and +`DeleteAuthor`, generates one `AuthorRepository`: ```java package com.example.app.db; @@ -227,17 +234,13 @@ import java.util.List; /** * Generated by sqlcj. * - * Query: CreateAuthor - * Table: authors - * Type: ONE + * Repository: Author */ - - -public final class CreateAuthor { +public final class AuthorRepository { private final QueryExecutor executor; - public CreateAuthor(QueryExecutor executor) { + public AuthorRepository(QueryExecutor executor) { this.executor = executor; } @@ -248,36 +251,52 @@ public final class CreateAuthor { ) { } - private static final RowMapper ROW_MAPPER = + private static final RowMapper createAuthorRowMapper = resultSet -> new CreateAuthorResult( resultSet.getObject(1, Long.class), resultSet.getObject(2, String.class), resultSet.getObject(3, String.class) ); + /** + * Query: CreateAuthor + * Table: authors + * Type: ONE + */ public CreateAuthorResult createAuthor(String name, String bio) { return executor.query( - """ - INSERT INTO authors (name, bio) - VALUES (?, ?) - RETURNING id, name, bio; - """, - java.util.Arrays.asList(name, bio), - ROW_MAPPER + """ + INSERT INTO authors (name, bio) + VALUES (?, ?) + RETURNING id, name, bio;""", + java.util.Arrays.asList(name, bio), + createAuthorRowMapper ); } + + // getAuthor, listAuthors, updateAuthorBio, and deleteAuthor follow here. } ``` +The application constructs the repository once per execution context: + +```java +AuthorRepository authors = new AuthorRepository(new JdbcQueryExecutor(dataSource)); + +AuthorRepository.GetAuthorResult author = authors.getAuthor(1L); +``` + A query name, column name, or parameter name that is not already a valid Java identifier is normalized deterministically, and names that would collide inside -one generated class are disambiguated in their SQL order. Those rules, and the -rejection of two queries whose generated files would collide, are documented in +one generated repository are disambiguated in their SQL order. Those rules, the +rejection of two queries of one entry that generate the same method, and the +rejection of two entries whose repository files would collide, are documented in [Generated Java Names](configuration.md#generated-java-names). -The generated classes are executed through `dev.sqlcj.runtime.JdbcQueryExecutor` -on a `DataSource` or on a caller-owned `Connection`. Connection ownership, -transaction control, and exception translation are documented in +The generated repositories are executed through +`dev.sqlcj.runtime.JdbcQueryExecutor` on a `DataSource` or on a caller-owned +`Connection`. Connection ownership, transaction control, and exception +translation are documented in [Connection Ownership and Transactions](postgresql.md#connection-ownership-and-transactions). ## Unsupported Queries @@ -417,8 +436,9 @@ Parameters: Generated Java: -- `JavaCodeGeneratorTest` covers the generated class, constructor, result - record, row mapper, method signature, and compilation of the generated source. +- `JavaCodeGeneratorTest` covers the generated repository, its single executor + field and constructor, the nested result records and row mappers, method + signatures, and compilation of the generated source. - `SqlcjCompilerIntegrationTest.shouldGenerateCompilableJavaFiles` and `SqlcjCompilerIntegrationTest.shouldGenerateCompilableJavaForReturningWrites` compile the generated output of the supported query shapes. diff --git a/docs/quickstart.md b/docs/quickstart.md index ab50683..f7fcf43 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -139,13 +139,18 @@ Two details matter: ```yaml version: "1" sql: - - schema: sql/schema.sql + - name: Author + schema: sql/schema.sql queries: sql/queries.sql java: package: com.example.app.db out: target/generated-sources/sqlcj ``` +`sql[].name` is the identity of the query group. It names the generated +repository, so the entry above generates one `AuthorRepository` holding every +query of `sql/queries.sql`. + `sqlcj generate` reads `sqlcj.yaml` from the directory it is run in, and the relative paths above are resolved against the directory that contains the file. Generated output therefore belongs in the build directory, where `mvn clean` @@ -198,21 +203,18 @@ FROM authors WHERE id = $1; ``` -Each query generates one class named after it: `CreateAuthor`, `GetAuthor`, -`ListAuthors`, `UpdateAuthorBio`, and `DeleteAuthor`. A `:one` or `:many` query -also generates a nested result record such as `GetAuthor.GetAuthorResult`. The -full query contract is documented in [Queries](queries.md). +All five queries become methods of the one generated `AuthorRepository`: +`createAuthor`, `getAuthor`, `listAuthors`, `updateAuthorBio`, and +`deleteAuthor`. A `:one` or `:many` query also generates a nested result record +such as `AuthorRepository.GetAuthorResult`. The full query contract is +documented in [Queries](queries.md). ## 8. `src/main/java/com/example/app/App.java` ```java package com.example.app; -import com.example.app.db.CreateAuthor; -import com.example.app.db.DeleteAuthor; -import com.example.app.db.GetAuthor; -import com.example.app.db.ListAuthors; -import com.example.app.db.UpdateAuthorBio; +import com.example.app.db.AuthorRepository; import dev.sqlcj.runtime.JdbcQueryExecutor; import dev.sqlcj.runtime.QueryExecutor; import org.postgresql.ds.PGSimpleDataSource; @@ -228,61 +230,60 @@ public final class App { QueryExecutor executor = new JdbcQueryExecutor(dataSource); - CreateAuthor.CreateAuthorResult created = new CreateAuthor(executor) - .createAuthor("Ada Lovelace", "First programmer"); + AuthorRepository authors = new AuthorRepository(executor); + + AuthorRepository.CreateAuthorResult created = + authors.createAuthor("Ada Lovelace", "First programmer"); System.out.println("created: " + created.id() + " " + created.name()); - GetAuthor.GetAuthorResult read = new GetAuthor(executor).getAuthor(created.id()); + AuthorRepository.GetAuthorResult read = authors.getAuthor(created.id()); System.out.println("read: " + read.name() + " / " + read.bio()); - int updatedRows = new UpdateAuthorBio(executor) - .updateAuthorBio(created.id(), "Mathematician"); + int updatedRows = authors.updateAuthorBio(created.id(), "Mathematician"); System.out.println("updated rows: " + updatedRows); - for (ListAuthors.ListAuthorsResult author : new ListAuthors(executor).listAuthors()) { + for (AuthorRepository.ListAuthorsResult author : authors.listAuthors()) { System.out.println("listed: " + author.id() + " " + author.name()); } - System.out.println("missing row: " + new GetAuthor(executor).getAuthor(-1L)); + System.out.println("missing row: " + authors.getAuthor(-1L)); try (Connection connection = dataSource.getConnection()) { connection.setAutoCommit(false); - QueryExecutor transactional = new JdbcQueryExecutor(connection); + AuthorRepository transactionalAuthors = + new AuthorRepository(new JdbcQueryExecutor(connection)); - CreateAuthor.CreateAuthorResult committed = new CreateAuthor(transactional) - .createAuthor("Grace Hopper", null); + AuthorRepository.CreateAuthorResult committed = + transactionalAuthors.createAuthor("Grace Hopper", null); - new UpdateAuthorBio(transactional).updateAuthorBio(committed.id(), "Compiler pioneer"); + transactionalAuthors.updateAuthorBio(committed.id(), "Compiler pioneer"); connection.commit(); - System.out.println( - "committed: " + new GetAuthor(executor).getAuthor(committed.id()).bio() - ); + System.out.println("committed: " + authors.getAuthor(committed.id()).bio()); } try (Connection connection = dataSource.getConnection()) { connection.setAutoCommit(false); - QueryExecutor transactional = new JdbcQueryExecutor(connection); + AuthorRepository transactionalAuthors = + new AuthorRepository(new JdbcQueryExecutor(connection)); - CreateAuthor.CreateAuthorResult discarded = new CreateAuthor(transactional) - .createAuthor("Temporary Author", null); + AuthorRepository.CreateAuthorResult discarded = + transactionalAuthors.createAuthor("Temporary Author", null); - new UpdateAuthorBio(transactional).updateAuthorBio(discarded.id(), "never stored"); + transactionalAuthors.updateAuthorBio(discarded.id(), "never stored"); connection.rollback(); - System.out.println( - "rolled back: " + new GetAuthor(executor).getAuthor(discarded.id()) - ); + System.out.println("rolled back: " + authors.getAuthor(discarded.id())); } - System.out.println("deleted rows: " + new DeleteAuthor(executor).deleteAuthor(created.id())); + System.out.println("deleted rows: " + authors.deleteAuthor(created.id())); } private static DataSource dataSource() { @@ -297,6 +298,10 @@ public final class App { } ``` +One repository instance serves the whole `DataSource`-backed execution context, +and each transaction constructs another repository over its caller-owned +connection. No code constructs a type per query. + ## 9. Start PostgreSQL and apply the schema ```bash @@ -336,14 +341,10 @@ mvn exec:java - `mvn compile` then compiles `src/main/java` together with `target/generated-sources/sqlcj`. -Generation writes one file per query: +Generation writes one file per configured entry: ```text -target/generated-sources/sqlcj/com/example/app/db/CreateAuthor.java -target/generated-sources/sqlcj/com/example/app/db/DeleteAuthor.java -target/generated-sources/sqlcj/com/example/app/db/GetAuthor.java -target/generated-sources/sqlcj/com/example/app/db/ListAuthors.java -target/generated-sources/sqlcj/com/example/app/db/UpdateAuthorBio.java +target/generated-sources/sqlcj/com/example/app/db/AuthorRepository.java ``` `mvn exec:java` prints: @@ -361,6 +362,8 @@ deleted rows: 1 That output is the whole MVP contract in one run: +- One `AuthorRepository` instance answers every call, and each generated method + keeps the types and order of its named query. - `CreateAuthor` is a `:one` write whose `RETURNING` clause reads back the database-generated `BIGSERIAL` identifier as a typed `Long`. - `GetAuthor` is a `:one` read, and returns `null` when no row matches. diff --git a/examples/maven-postgresql/sqlcj.yaml b/examples/maven-postgresql/sqlcj.yaml index eebb0b9..0f9e92c 100644 --- a/examples/maven-postgresql/sqlcj.yaml +++ b/examples/maven-postgresql/sqlcj.yaml @@ -1,6 +1,7 @@ version: "1" sql: - - schema: sql/schema.sql + - name: Author + schema: sql/schema.sql queries: sql/queries.sql java: package: com.example.app.db diff --git a/examples/maven-postgresql/src/main/java/com/example/app/App.java b/examples/maven-postgresql/src/main/java/com/example/app/App.java index 35fa71d..f708938 100644 --- a/examples/maven-postgresql/src/main/java/com/example/app/App.java +++ b/examples/maven-postgresql/src/main/java/com/example/app/App.java @@ -1,10 +1,6 @@ package com.example.app; -import com.example.app.db.CreateAuthor; -import com.example.app.db.DeleteAuthor; -import com.example.app.db.GetAuthor; -import com.example.app.db.ListAuthors; -import com.example.app.db.UpdateAuthorBio; +import com.example.app.db.AuthorRepository; import dev.sqlcj.runtime.JdbcQueryExecutor; import dev.sqlcj.runtime.QueryExecutor; import org.postgresql.ds.PGSimpleDataSource; @@ -20,6 +16,11 @@ * code: create, read, list, update, a missing-row read, a committed * transaction, a rolled back transaction, and delete. * + *

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

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

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

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

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

Two queries of one group that would generate the same method are rejected + * instead of being disambiguated, because a repository method is a name the + * application calls. Two queries whose nested result types differ only by case + * are rejected for the same reason: their class files share one path on a + * case-insensitive filesystem. */ final class JavaNames { private static final SourceVersion SOURCE_VERSION = SourceVersion.RELEASE_21; - /** - * Simple type names referenced by generated source, plus the Java 21 - * identifiers that cannot name a type. - */ - private static final Set RESERVED_CLASS_NAMES = Set.of( - "QueryExecutor", - "RowMapper", - "List", - "BigDecimal", - "LocalDate", - "LocalDateTime", - "OffsetDateTime", - "UUID", - "Boolean", - "Integer", - "Long", - "Short", - "String", - "permits", - "record", - "sealed", - "var", - "yield" - ); + private static final String REPOSITORY_SUFFIX = "Repository"; + + private static final String RESULT_SUFFIX = "Result"; + + private static final String ROW_MAPPER_SUFFIX = "RowMapper"; /** * Inherited {@link Object} method names that a generated method or record @@ -65,78 +58,159 @@ final class JavaNames { "wait" ); - /** Generator-owned names referenced by the generated method body. */ - private static final Set RESERVED_PARAMETER_NAMES = Set.of( - "executor", - "ROW_MAPPER" - ); + /** Generator-owned name referenced by every generated method body. */ + private static final String EXECUTOR_NAME = "executor"; - private final String className; - private final String methodName; - private final List parameterNames; - private final List componentNames; + private final String repositoryClassName; + private final List queries; - private JavaNames( - String className, - String methodName, - List parameterNames, - List componentNames - ) { - this.className = className; - this.methodName = methodName; - this.parameterNames = parameterNames; - this.componentNames = componentNames; + private JavaNames(String repositoryClassName, List queries) { + this.repositoryClassName = repositoryClassName; + this.queries = queries; } - static JavaNames of(QueryModel query) { - String className = className(query.name()); + static JavaNames of(QueryGroupModel group) { + List resultTypeNames = group.queries().stream() + .map(query -> className(query.name()) + RESULT_SUFFIX) + .toList(); + + List methodNames = group.queries().stream() + .map(query -> methodName(className(query.name()))) + .toList(); + + rejectDuplicateMethodNames(group, methodNames); + rejectResultTypeNamesDifferingOnlyByCase(group, resultTypeNames); + + Set reservedParameterNames = reservedParameterNames(methodNames); + + List queries = new ArrayList<>(group.queries().size()); + + for (int index = 0; index < group.queries().size(); index++) { + QueryModel query = group.queries().get(index); + String methodName = methodNames.get(index); + + queries.add( + new QueryNames( + resultTypeNames.get(index), + methodName, + methodName + ROW_MAPPER_SUFFIX, + resolveNames( + query.parameters().stream() + .map(QueryParameter::name) + .toList(), + reservedParameterNames + ), + resolveNames( + query.columns().stream() + .map(QueryColumn::name) + .toList(), + RESERVED_MEMBER_NAMES + ) + ) + ); + } return new JavaNames( - className, - methodName(className), - resolveNames( - query.parameters().stream() - .map(QueryParameter::name) - .toList(), - RESERVED_PARAMETER_NAMES - ), - resolveNames( - query.columns().stream() - .map(QueryColumn::name) - .toList(), - RESERVED_MEMBER_NAMES - ) + group.name() + REPOSITORY_SUFFIX, + List.copyOf(queries) ); } - String className() { - return className; + String repositoryClassName() { + return repositoryClassName; } - String resultTypeName() { - return className + "Result"; + List queries() { + return queries; } - String methodName() { - return methodName; + /** The resolved Java identifiers of one generated repository method. */ + record QueryNames( + String resultTypeName, + String methodName, + String rowMapperName, + List parameterNames, + List componentNames + ) { } - List parameterNames() { - return parameterNames; + /** + * Rejects two queries of one group that generate the same method, naming + * both queries so the query source can be corrected. + */ + private static void rejectDuplicateMethodNames(QueryGroupModel group, List methodNames) { + Map queryNamesByMethodName = new HashMap<>(); + + for (int index = 0; index < methodNames.size(); index++) { + String methodName = methodNames.get(index); + String queryName = group.queries().get(index).name(); + String previous = queryNamesByMethodName.putIfAbsent(methodName, queryName); + + if (previous != null) { + throw new IllegalArgumentException( + "Queries '%s' and '%s' generate the same repository method '%s'" + .formatted(previous, queryName, methodName) + ); + } + } } - List componentNames() { - return componentNames; - } + /** + * Rejects two queries of one group whose nested result types differ only by + * case, because the compiled class files of those types are one path on a + * case-insensitive filesystem and would overwrite each other. + */ + private static void rejectResultTypeNamesDifferingOnlyByCase( + QueryGroupModel group, + List resultTypeNames + ) { + Map resultTypesByPortabilityKey = new HashMap<>(); - private static String className(String queryName) { - String name = normalize(queryName); + for (int index = 0; index < resultTypeNames.size(); index++) { + String resultTypeName = resultTypeNames.get(index); + String queryName = group.queries().get(index).name(); - while (RESERVED_CLASS_NAMES.contains(name)) { - name += "_"; + GeneratedResultType previous = resultTypesByPortabilityKey.putIfAbsent( + resultTypeName.toLowerCase(Locale.ROOT), + new GeneratedResultType(queryName, resultTypeName) + ); + + if (previous != null) { + throw new IllegalArgumentException( + "Queries '%s' and '%s' generate result types that differ only by case: %s and %s" + .formatted( + previous.queryName(), + queryName, + previous.resultTypeName(), + resultTypeName + ) + ); + } } + } - return name; + /** One generated nested result type and the query that generated it. */ + private record GeneratedResultType(String queryName, String resultTypeName) { + } + + /** + * A method parameter must not shadow a generated name the method body + * reads, which is the executor field and the repository's row mappers. + */ + private static Set reservedParameterNames(List methodNames) { + Set reserved = new LinkedHashSet<>(); + + reserved.add(EXECUTOR_NAME); + + methodNames.stream() + .map(methodName -> methodName + ROW_MAPPER_SUFFIX) + .forEach(reserved::add); + + return reserved; + } + + private static String className(String queryName) { + return normalize(queryName); } private static String methodName(String className) { diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/cli/command/GenerateCommandTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/cli/command/GenerateCommandTest.java index 5e02775..9477ccd 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/cli/command/GenerateCommandTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/cli/command/GenerateCommandTest.java @@ -45,7 +45,8 @@ void shouldGenerateFromConfigurationInWorkingDirectory() throws Exception { """ version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -57,14 +58,15 @@ void shouldGenerateFromConfigurationInWorkingDirectory() throws Exception { assertEquals(0, result.exitCode(), result.error()); - Path generated = workingDirectory.resolve("generated/dev/example/generated/GetUser.java"); + Path generated = workingDirectory.resolve("generated/dev/example/generated/UsersRepository.java"); assertTrue(Files.exists(generated), result.error()); - assertTrue( - Files.readString(generated) - .startsWith("package dev.example.generated;") - ); + String source = Files.readString(generated); + + assertTrue(source.startsWith("package dev.example.generated;")); + assertTrue(source.contains("public final class UsersRepository {")); + assertTrue(source.contains("public GetUserResult getUser(Long id)")); } @Test @@ -74,7 +76,8 @@ void shouldFailWithConciseDiagnosticForMalformedConfiguration() throws Exception """ version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -98,7 +101,8 @@ void shouldFailWithConciseDiagnosticForUnreadableSource() throws Exception { """ version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: missing.sql java: package: dev.example.generated @@ -141,7 +145,8 @@ void shouldFailWithConciseDiagnosticForInvalidQuery() throws Exception { """ version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/DefaultSourceLoaderTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/DefaultSourceLoaderTest.java index bc2d09c..ae5fdc9 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/DefaultSourceLoaderTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/DefaultSourceLoaderTest.java @@ -59,8 +59,8 @@ void shouldLoadEntriesInDeclaredOrder() throws IOException { List sources = sourceLoader.load( config( - new SqlConfig(userSchema.toString(), userQueries.toString()), - new SqlConfig(orderSchema.toString(), orderQueries.toString()) + new SqlConfig("Users", userSchema.toString(), userQueries.toString()), + new SqlConfig("Orders", orderSchema.toString(), orderQueries.toString()) ) ); @@ -81,7 +81,7 @@ void shouldLoadEntriesInDeclaredOrder() throws IOException { } @Test - void shouldRejectDuplicateQueryNameAcrossEntries() throws IOException { + void shouldLoadTheSameQueryNameInTwoEntries() throws IOException { Path schema = write("schema.sql", """ CREATE TABLE users ( @@ -102,20 +102,18 @@ void shouldRejectDuplicateQueryNameAcrossEntries() throws IOException { FROM users; """); - CompilationException exception = assertThrows( - CompilationException.class, - () -> sourceLoader.load( - config( - new SqlConfig(schema.toString(), firstQueries.toString()), - new SqlConfig(schema.toString(), secondQueries.toString()) - ) + List sources = sourceLoader.load( + config( + new SqlConfig("Users", schema.toString(), firstQueries.toString()), + new SqlConfig("Orders", schema.toString(), secondQueries.toString()) ) ); - assertEquals( - "Duplicate query name 'GetUser' in query source: " + secondQueries, - exception.getMessage() - ); + assertEquals(List.of("GetUser"), names(sources.getFirst())); + assertEquals(List.of("GetUser"), names(sources.get(1))); + + assertEquals("Users", sources.getFirst().name()); + assertEquals("Orders", sources.get(1).name()); } @Test @@ -134,6 +132,7 @@ void shouldRejectUnreadableQueriesSource() throws IOException { () -> sourceLoader.load( config( new SqlConfig( + "Users", schema.toString(), missingQueries.toString() ) @@ -163,6 +162,7 @@ void shouldRejectUnreadableSchemaSource() throws IOException { () -> sourceLoader.load( config( new SqlConfig( + "Users", missingSchema.toString(), queries.toString() ) @@ -201,6 +201,7 @@ void shouldReportInvalidQuerySourceWithItsPath() throws IOException { () -> sourceLoader.load( config( new SqlConfig( + "Users", schema.toString(), queries.toString() ) @@ -239,6 +240,7 @@ void shouldCarrySourcePathsAndQueryLines() throws IOException { List sources = sourceLoader.load( config( new SqlConfig( + "Users", schema.toString(), queries.toString() ) @@ -247,6 +249,7 @@ void shouldCarrySourcePathsAndQueryLines() throws IOException { Source source = sources.getFirst(); + assertEquals("Users", source.name()); assertEquals(schema, source.schemaPath()); assertEquals(queries, source.queriesPath()); assertEquals(1, source.queries().getFirst().line()); diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java index a7e993a..31de188 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -67,6 +67,9 @@ class PostgresIntegrationTest { */ private static final String REQUIRE_DOCKER_PROPERTY = "sqlcj.test.requireDocker"; + /** The configured query-group name, which generates {@code UsersRepository}. */ + private static final String GROUP = "Users"; + private static final String SCHEMA = """ CREATE TABLE users ( @@ -222,15 +225,15 @@ void shouldExecuteGeneratedOneQueryAgainstPostgres() throws Exception { """); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object query = newQuery(classLoader, "GetUser"); + Object repository = newRepository(classLoader); - Method method = query.getClass().getMethod( + Method method = repository.getClass().getMethod( "getUser", Long.class, Boolean.class ); - Object result = method.invoke(query, 1L, Boolean.TRUE); + Object result = method.invoke(repository, 1L, Boolean.TRUE); assertNotNull(result); @@ -287,14 +290,14 @@ INSERT INTO users (id, code, name, active) """); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object query = newQuery(classLoader, "ListUsers"); + Object repository = newRepository(classLoader); - Method method = query.getClass().getMethod( + Method method = repository.getClass().getMethod( "listUsers", Boolean.class ); - Object result = method.invoke(query, Boolean.TRUE); + Object result = method.invoke(repository, Boolean.TRUE); assertInstanceOf(List.class, result); @@ -327,9 +330,9 @@ INSERT INTO users (id, code, name, birth_date, created_at, balance) """); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object insert = newQuery(classLoader, "InsertUser"); + Object repository = newRepository(classLoader); - Method insertMethod = insert.getClass().getMethod( + Method insertMethod = repository.getClass().getMethod( "insertUser", Long.class, Integer.class, @@ -340,7 +343,7 @@ INSERT INTO users (id, code, name, birth_date, created_at, balance) ); Object affectedRows = insertMethod.invoke( - insert, + repository, 5L, 42, "Alice", @@ -351,11 +354,9 @@ INSERT INTO users (id, code, name, birth_date, created_at, balance) assertEquals(1, affectedRows); - Object query = newQuery(classLoader, "GetUser"); - - Method queryMethod = query.getClass().getMethod("getUser", Long.class); + Method queryMethod = repository.getClass().getMethod("getUser", Long.class); - Object result = queryMethod.invoke(query, 5L); + Object result = queryMethod.invoke(repository, 5L); assertNotNull(result); @@ -394,9 +395,9 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception { """); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object insert = newQuery(classLoader, "InsertUser"); + Object repository = newRepository(classLoader); - Method insertMethod = insert.getClass().getMethod( + Method insertMethod = repository.getClass().getMethod( "insertUser", Long.class, Integer.class, @@ -412,7 +413,7 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception { ); Object affectedRows = insertMethod.invoke( - insert, + repository, 6L, 42, null, @@ -428,11 +429,9 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception { assertEquals(1, affectedRows); - Object query = newQuery(classLoader, "GetUser"); - - Method queryMethod = query.getClass().getMethod("getUser", Long.class); + Method queryMethod = repository.getClass().getMethod("getUser", Long.class); - Object result = queryMethod.invoke(query, 6L); + Object result = queryMethod.invoke(repository, 6L); assertNotNull(result); @@ -472,9 +471,9 @@ INSERT INTO users (id, code, serial_id, revision, external_id, updated_at) """); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object insert = newQuery(classLoader, "InsertUser"); + Object repository = newRepository(classLoader); - Method insertMethod = insert.getClass().getMethod( + Method insertMethod = repository.getClass().getMethod( "insertUser", Long.class, Integer.class, @@ -485,7 +484,7 @@ INSERT INTO users (id, code, serial_id, revision, external_id, updated_at) ); Object affectedRows = insertMethod.invoke( - insert, + repository, 7L, 42, 101, @@ -496,11 +495,9 @@ INSERT INTO users (id, code, serial_id, revision, external_id, updated_at) assertEquals(1, affectedRows); - Object query = newQuery(classLoader, "GetUser"); + Method queryMethod = repository.getClass().getMethod("getUser", Long.class); - Method queryMethod = query.getClass().getMethod("getUser", Long.class); - - Object result = queryMethod.invoke(query, 7L); + Object result = queryMethod.invoke(repository, 7L); assertNotNull(result); @@ -547,11 +544,11 @@ void shouldExecuteGeneratedQueryForSnapshotWithIgnoredTableConstraints() throws execute("INSERT INTO customer_orders (id, customer_id) VALUES (10, 1)"); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object query = newQuery(classLoader, "GetCustomerOrder"); + Object repository = newRepository(classLoader); - Method method = query.getClass().getMethod("getCustomerOrder", Long.class); + Method method = repository.getClass().getMethod("getCustomerOrder", Long.class); - Object result = method.invoke(query, 10L); + Object result = method.invoke(repository, 10L); assertNotNull(result); @@ -587,32 +584,30 @@ INSERT INTO users (id, code, name) """); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object insertSerialId = newQuery(classLoader, "InsertUserReturningSerialId"); + Object repository = newRepository(classLoader); - Method serialIdMethod = insertSerialId.getClass().getMethod( + Method serialIdMethod = repository.getClass().getMethod( "insertUserReturningSerialId", Long.class, Integer.class, String.class ); - Object serialIdResult = serialIdMethod.invoke(insertSerialId, 1L, 42, "Alice"); + Object serialIdResult = serialIdMethod.invoke(repository, 1L, 42, "Alice"); assertNotNull(serialIdResult); assertEquals(List.of("serial_id"), recordComponentNames(serialIdResult)); assertEquals(1, component(serialIdResult, "serial_id")); - Object insertRow = newQuery(classLoader, "InsertUserReturningRow"); - - Method rowMethod = insertRow.getClass().getMethod( + Method rowMethod = repository.getClass().getMethod( "insertUserReturningRow", Long.class, Integer.class, String.class ); - Object row = rowMethod.invoke(insertRow, 2L, 43, "Bob"); + Object row = rowMethod.invoke(repository, 2L, 43, "Bob"); assertNotNull(row); @@ -669,16 +664,16 @@ INSERT INTO users (id, code, name, bio) """); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object update = newQuery(classLoader, "RenameUser"); + Object repository = newRepository(classLoader); - Method method = update.getClass().getMethod( + Method method = repository.getClass().getMethod( "renameUser", Long.class, String.class, Integer.class ); - Object result = method.invoke(update, 1L, "Renamed", 42); + Object result = method.invoke(repository, 1L, "Renamed", 42); assertNotNull(result); @@ -692,7 +687,7 @@ INSERT INTO users (id, code, name, bio) assertEquals("Renamed", component(result, "name")); assertNull(component(result, "score")); - assertNull(method.invoke(update, 404L, "Missing", 42)); + assertNull(method.invoke(repository, 404L, "Missing", 42)); } } @@ -720,16 +715,16 @@ INSERT INTO users (id, code, name, active) """); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Object delete = newQuery(classLoader, "DeleteUsersByActive"); + Object repository = newRepository(classLoader); - Method method = delete.getClass().getMethod( + Method method = repository.getClass().getMethod( "deleteUsersByActive", Boolean.class ); List deleted = assertInstanceOf( List.class, - method.invoke(delete, Boolean.TRUE) + method.invoke(repository, Boolean.TRUE) ); assertEquals(2, deleted.size()); @@ -760,7 +755,7 @@ INSERT INTO users (id, code, name, active) assertEquals( List.of(), - method.invoke(delete, Boolean.TRUE) + method.invoke(repository, Boolean.TRUE) ); } } @@ -871,12 +866,12 @@ private Object insertUser( Integer code, String name ) throws Exception { - Object insert = newQuery(classLoader, "InsertUser", executor); + Object repository = newRepository(classLoader, executor); - return insert + return repository .getClass() .getMethod("insertUser", Long.class, Integer.class, String.class) - .invoke(insert, id, code, name); + .invoke(repository, id, code, name); } private Object insertUserReturningRow( @@ -886,12 +881,12 @@ private Object insertUserReturningRow( Integer code, String name ) throws Exception { - Object insert = newQuery(classLoader, "InsertUserReturningRow", executor); + Object repository = newRepository(classLoader, executor); - return insert + return repository .getClass() .getMethod("insertUserReturningRow", Long.class, Integer.class, String.class) - .invoke(insert, id, code, name); + .invoke(repository, id, code, name); } private List listUserNames(URLClassLoader classLoader) throws Exception { @@ -902,12 +897,12 @@ private List listUserNames( URLClassLoader classLoader, QueryExecutor executor ) throws Exception { - Object query = newQuery(classLoader, "ListUserNames", executor); + Object repository = newRepository(classLoader, executor); - List rows = (List) query + List rows = (List) repository .getClass() .getMethod("listUserNames") - .invoke(query); + .invoke(repository); List names = new ArrayList<>(); @@ -938,6 +933,7 @@ private Path generateAndCompile(String schema, String queries) throws Exception Config config = new Config( List.of( new SqlConfig( + GROUP, schemaFile.toString(), queriesFile.toString() ) @@ -991,17 +987,16 @@ private URLClassLoader classLoader(Path classesDirectory) throws Exception { ); } - private Object newQuery(URLClassLoader classLoader, String queryName) throws Exception { - return newQuery(classLoader, queryName, new JdbcQueryExecutor(dataSource)); + private Object newRepository(URLClassLoader classLoader) throws Exception { + return newRepository(classLoader, new JdbcQueryExecutor(dataSource)); } - private Object newQuery( + private Object newRepository( URLClassLoader classLoader, - String queryName, QueryExecutor executor ) throws Exception { Class generatedClass = Class.forName( - "generated." + queryName, + "generated." + GROUP + "Repository", true, classLoader ); diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java index 6da1de0..5734410 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -106,6 +106,7 @@ WHERE id IN ($1, $2) Config config = new Config( List.of( new SqlConfig( + "Users", schemaFile.toString(), queriesFile.toString() ) @@ -120,52 +121,69 @@ WHERE id IN ($1, $2) compiler.compile(config); - Path getUserFile = generatedDirectory.resolve("generated/GetUser.java"); - - Path listUsersFile = generatedDirectory.resolve("generated/ListUsers.java"); - - Path findUsersFile = generatedDirectory.resolve("generated/FindUsers.java"); - - assertTrue(Files.exists(getUserFile)); - assertTrue(Files.exists(listUsersFile)); - - String getUser = Files.readString(getUserFile); - assertTrue(getUser.contains("public final class GetUser")); - assertTrue(getUser.contains("import java.time.LocalDate;")); - assertTrue(getUser.contains("import java.time.LocalDateTime;")); - assertTrue(getUser.contains("import java.math.BigDecimal;")); - assertTrue(getUser.contains("public GetUserResult getUser(LocalDateTime created_at)")); - assertTrue(getUser.contains("Long id")); - assertTrue(getUser.contains("String name")); - assertTrue(getUser.contains("LocalDate birth_date")); - assertTrue(getUser.contains("LocalDateTime created_at")); - assertTrue(getUser.contains("BigDecimal balance")); - assertTrue(getUser.contains("private static final RowMapper ROW_MAPPER")); - assertTrue(getUser.contains("resultSet.getObject(1, Long.class)")); - assertTrue(getUser.contains("resultSet.getObject(2, String.class)")); - assertTrue(getUser.contains("resultSet.getObject(3, Boolean.class)")); - assertTrue(getUser.contains("resultSet.getObject(4, LocalDate.class)")); - assertTrue(getUser.contains("resultSet.getObject(5, LocalDateTime.class)")); - assertTrue(getUser.contains("resultSet.getObject(6, BigDecimal.class)")); - - String listUsers = Files.readString(listUsersFile); - assertTrue(listUsers.contains("public final class ListUsers")); - assertTrue(listUsers.contains("import java.util.List;")); - assertTrue(listUsers.contains("import java.time.LocalDate;")); - assertTrue(listUsers.contains("import java.time.LocalDateTime;")); - assertTrue(listUsers.contains("import java.math.BigDecimal;")); - assertTrue(listUsers.contains("public List listUsers(LocalDateTime created_at)")); - - String findUsers = Files.readString(findUsersFile); - assertTrue(findUsers.contains("public final class FindUsers")); - assertTrue(findUsers.contains("import java.util.List;")); + Path repositoryFile = generatedDirectory.resolve("generated/UsersRepository.java"); + + assertTrue(Files.exists(repositoryFile)); + + assertFalse(Files.exists(generatedDirectory.resolve("generated/GetUser.java"))); + assertFalse(Files.exists(generatedDirectory.resolve("generated/ListUsers.java"))); + assertFalse(Files.exists(generatedDirectory.resolve("generated/FindUsers.java"))); + + String repository = Files.readString(repositoryFile); + + assertTrue(repository.contains("public final class UsersRepository")); + assertTrue(repository.contains("import java.time.LocalDate;")); + assertTrue(repository.contains("import java.time.LocalDateTime;")); + assertTrue(repository.contains("import java.math.BigDecimal;")); + assertTrue(repository.contains("import java.util.List;")); + + assertEquals( + 1, + repository.lines() + .filter(line -> line.equals(" private final QueryExecutor executor;")) + .count() + ); + + assertEquals( + 1, + repository.lines() + .filter(line -> line.contains("public UsersRepository(QueryExecutor executor)")) + .count() + ); + + assertTrue(repository.contains("public GetUserResult getUser(LocalDateTime created_at)")); + assertTrue(repository.contains("Long id")); + assertTrue(repository.contains("String name")); + assertTrue(repository.contains("LocalDate birth_date")); + assertTrue(repository.contains("LocalDateTime created_at")); + assertTrue(repository.contains("BigDecimal balance")); + assertTrue(repository.contains("private static final RowMapper getUserRowMapper")); + assertTrue(repository.contains("resultSet.getObject(1, Long.class)")); + assertTrue(repository.contains("resultSet.getObject(2, String.class)")); + assertTrue(repository.contains("resultSet.getObject(3, Boolean.class)")); + assertTrue(repository.contains("resultSet.getObject(4, LocalDate.class)")); + assertTrue(repository.contains("resultSet.getObject(5, LocalDateTime.class)")); + assertTrue(repository.contains("resultSet.getObject(6, BigDecimal.class)")); + + assertTrue(repository.contains("public List listUsers(LocalDateTime created_at)")); + assertTrue(repository.contains("private static final RowMapper listUsersRowMapper")); + assertTrue( - findUsers + repository .contains("public List findUsers(Long id1, Long id2, Boolean active, String name)") ); - assertTrue(findUsers.contains("public record FindUsersResult(")); - assertTrue(findUsers.contains("Long id")); - assertTrue(findUsers.contains("String name")); + assertTrue(repository.contains("public record FindUsersResult(")); + assertTrue(repository.contains("private static final RowMapper findUsersRowMapper")); + + assertTrue( + repository.indexOf("public GetUserResult getUser(") < repository + .indexOf("public List listUsers(") + ); + + assertTrue( + repository.indexOf("public List listUsers(") < repository + .indexOf("public List findUsers(") + ); Files.createDirectories(classesDirectory); @@ -183,28 +201,14 @@ WHERE id IN ($1, $2) classpath, "-d", classesDirectory.toString(), - getUserFile.toString(), - listUsersFile.toString(), - findUsersFile.toString() + repositoryFile.toString() ); assertEquals(0, result); assertTrue( Files.exists( - classesDirectory.resolve("generated/GetUser.class") - ) - ); - - assertTrue( - Files.exists( - classesDirectory.resolve("generated/ListUsers.class") - ) - ); - - assertTrue( - Files.exists( - classesDirectory.resolve("generated/FindUsers.class") + classesDirectory.resolve("generated/UsersRepository.class") ) ); } @@ -244,6 +248,7 @@ balance DECIMAL(10, 2) Config config = new Config( List.of( new SqlConfig( + "Users", schemaFile.toString(), queriesFile.toString() ) @@ -258,9 +263,9 @@ balance DECIMAL(10, 2) compiler.compile(config); - Path getUserFile = generatedDirectory.resolve("generated/GetUser.java"); + Path repositoryFile = generatedDirectory.resolve("generated/UsersRepository.java"); - assertTrue(Files.exists(getUserFile)); + assertTrue(Files.exists(repositoryFile)); Files.createDirectories(classesDirectory); @@ -278,7 +283,7 @@ balance DECIMAL(10, 2) classpath, "-d", classesDirectory.toString(), - getUserFile.toString() + repositoryFile.toString() ); assertEquals(0, compilationResult); @@ -322,7 +327,7 @@ balance DECIMAL(10, 2) try (URLClassLoader classLoader = new URLClassLoader(classpathUrls, getClass().getClassLoader())) { Class generatedClass = Class.forName( - "generated.GetUser", + "generated.UsersRepository", true, classLoader ); @@ -404,6 +409,7 @@ balance DECIMAL(10, 2) Config config = new Config( List.of( new SqlConfig( + "Users", schemaFile.toString(), queriesFile.toString() ) @@ -418,9 +424,9 @@ balance DECIMAL(10, 2) compiler.compile(config); - Path listActiveUsersFile = generatedDirectory.resolve("generated/ListActiveUsers.java"); + Path repositoryFile = generatedDirectory.resolve("generated/UsersRepository.java"); - assertTrue(Files.exists(listActiveUsersFile)); + assertTrue(Files.exists(repositoryFile)); Files.createDirectories(classesDirectory); @@ -438,7 +444,7 @@ balance DECIMAL(10, 2) classpath, "-d", classesDirectory.toString(), - listActiveUsersFile.toString() + repositoryFile.toString() ); assertEquals(0, compilationResult); @@ -491,7 +497,7 @@ balance DECIMAL(10, 2) ) ) { Class generatedClass = Class.forName( - "generated.ListActiveUsers", + "generated.UsersRepository", true, classLoader ); @@ -563,11 +569,10 @@ void shouldExecuteGeneratedQueryWithOutOfOrderPlaceholders() throws Exception { FROM users WHERE active = $2 AND id = $1; - """, - "FindUser" + """ ); - String source = Files.readString(tempDir.resolve("generated/generated/FindUser.java")); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue( source.contains( @@ -581,7 +586,7 @@ void shouldExecuteGeneratedQueryWithOutOfOrderPlaceholders() throws Exception { try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.FindUser", + "generated.UsersRepository", true, classLoader ); @@ -615,11 +620,10 @@ void shouldExecuteGeneratedQueryWithRepeatedPlaceholder() throws Exception { FROM users WHERE name = $2 AND (id = $1 OR id = $1); - """, - "FindUser" + """ ); - String source = Files.readString(tempDir.resolve("generated/generated/FindUser.java")); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue( source.contains( @@ -634,7 +638,7 @@ void shouldExecuteGeneratedQueryWithRepeatedPlaceholder() throws Exception { try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.FindUser", + "generated.UsersRepository", true, classLoader ); @@ -670,11 +674,10 @@ void shouldExecuteGeneratedQueryWithProtectedPlaceholderText() throws Exception FROM users WHERE name <> '$1 literal' /* keeps $8 */ AND id = $1; - """, - "FindUser" + """ ); - String source = Files.readString(tempDir.resolve("generated/generated/FindUser.java")); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue(source.contains("-- Keeps $9 in a comment.")); assertTrue(source.contains("WHERE name <> '$1 literal' /* keeps $8 */")); @@ -690,7 +693,7 @@ void shouldExecuteGeneratedQueryWithProtectedPlaceholderText() throws Exception try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.FindUser", + "generated.UsersRepository", true, classLoader ); @@ -717,11 +720,10 @@ void shouldExecuteGeneratedQueryWithoutParameters() throws Exception { SELECT id, name FROM users ORDER BY id; - """, - "GetFirstUser" + """ ); - String source = Files.readString(tempDir.resolve("generated/generated/GetFirstUser.java")); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue( source.contains( @@ -735,7 +737,7 @@ void shouldExecuteGeneratedQueryWithoutParameters() throws Exception { try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.GetFirstUser", + "generated.UsersRepository", true, classLoader ); @@ -762,11 +764,10 @@ void shouldExecuteGeneratedInsert() throws Exception { -- name: InsertUser :exec INSERT INTO users (id, name, active) VALUES ($1, $2, $3); - """, - "InsertUser" + """ ); - String source = Files.readString(tempDir.resolve("generated/generated/InsertUser.java")); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue( source.contains( @@ -783,7 +784,7 @@ INSERT INTO users (id, name, active) try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.InsertUser", + "generated.UsersRepository", true, classLoader ); @@ -817,11 +818,10 @@ void shouldExecuteGeneratedUpdateWithOutOfOrderPlaceholders() throws Exception { UPDATE users SET name = $2 WHERE id = $1; - """, - "UpdateUserName" + """ ); - String source = Files.readString(tempDir.resolve("generated/generated/UpdateUserName.java")); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue( source.contains( @@ -835,7 +835,7 @@ void shouldExecuteGeneratedUpdateWithOutOfOrderPlaceholders() throws Exception { try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.UpdateUserName", + "generated.UsersRepository", true, classLoader ); @@ -868,11 +868,10 @@ void shouldExecuteGeneratedDelete() throws Exception { -- name: DeleteUser :exec DELETE FROM users WHERE id = $1; - """, - "DeleteUser" + """ ); - String source = Files.readString(tempDir.resolve("generated/generated/DeleteUser.java")); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue(source.contains("public int deleteUser(Long id)")); assertTrue(source.contains("java.util.Arrays.asList(id)")); @@ -881,7 +880,7 @@ void shouldExecuteGeneratedDelete() throws Exception { try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.DeleteUser", + "generated.UsersRepository", true, classLoader ); @@ -915,11 +914,10 @@ void shouldExecuteGeneratedAliasedQualifiedQuery() throws Exception { SELECT u.id, u.name FROM users u WHERE u.id = $1; - """, - "GetUser" + """ ); - String source = Files.readString(tempDir.resolve("generated/generated/GetUser.java")); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue(source.contains("public GetUserResult getUser(Long id)")); assertTrue(source.contains("resultSet.getObject(1, Long.class)")); @@ -928,7 +926,7 @@ void shouldExecuteGeneratedAliasedQualifiedQuery() throws Exception { QueryExecutor executor = new JdbcQueryExecutor(joinDataSource()); try (URLClassLoader classLoader = classLoader(classesDirectory)) { - Class generatedClass = Class.forName("generated.GetUser", true, classLoader); + Class generatedClass = Class.forName("generated.UsersRepository", true, classLoader); Object result = generatedClass .getMethod("getUser", Long.class) @@ -956,13 +954,10 @@ void shouldExecuteGeneratedJoinQueryWithDuplicateColumnNames() throws Exception JOIN profiles p ON p.user_id = u.id WHERE p.nickname = $2 AND u.id = $1; - """, - "ListUserProfiles" + """ ); - String source = Files.readString( - tempDir.resolve("generated/generated/ListUserProfiles.java") - ); + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); assertTrue( source.contains( @@ -978,7 +973,7 @@ void shouldExecuteGeneratedJoinQueryWithDuplicateColumnNames() throws Exception try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.ListUserProfiles", + "generated.UsersRepository", true, classLoader ); @@ -1016,15 +1011,14 @@ void shouldExecuteGeneratedMultipleJoinQuery() throws Exception { JOIN profiles p ON p.user_id = u.id JOIN orders o ON o.user_id = u.id WHERE u.id = $1; - """, - "GetUserOrder" + """ ); QueryExecutor executor = new JdbcQueryExecutor(joinDataSource()); try (URLClassLoader classLoader = classLoader(classesDirectory)) { Class generatedClass = Class.forName( - "generated.GetUserOrder", + "generated.UsersRepository", true, classLoader ); @@ -1101,10 +1095,12 @@ total DECIMAL(10, 2), Config config = new Config( List.of( new SqlConfig( + "Users", usersSchema.toString(), usersQueries.toString() ), new SqlConfig( + "Orders", ordersSchema.toString(), ordersQueries.toString() ) @@ -1117,23 +1113,25 @@ total DECIMAL(10, 2), new SqlcjCompiler().compile(config); - Path getUserFile = generatedDirectory.resolve("dev/example/generated/GetUser.java"); + Path usersFile = generatedDirectory.resolve("dev/example/generated/UsersRepository.java"); - Path listOrdersFile = generatedDirectory.resolve("dev/example/generated/ListOrders.java"); + Path ordersFile = generatedDirectory.resolve("dev/example/generated/OrdersRepository.java"); - assertTrue(Files.exists(getUserFile)); - assertTrue(Files.exists(listOrdersFile)); + assertTrue(Files.exists(usersFile)); + assertTrue(Files.exists(ordersFile)); - String getUser = Files.readString(getUserFile); + String users = Files.readString(usersFile); - assertTrue(getUser.startsWith("package dev.example.generated;")); - assertTrue(getUser.contains("public GetUserResult getUser(Long id)")); + assertTrue(users.startsWith("package dev.example.generated;")); + assertTrue(users.contains("public final class UsersRepository {")); + assertTrue(users.contains("public GetUserResult getUser(Long id)")); - String listOrders = Files.readString(listOrdersFile); + String orders = Files.readString(ordersFile); - assertTrue(listOrders.startsWith("package dev.example.generated;")); - assertTrue(listOrders.contains("public List listOrders(LocalDateTime created_at)")); - assertTrue(listOrders.contains("BigDecimal total")); + assertTrue(orders.startsWith("package dev.example.generated;")); + assertTrue(orders.contains("public final class OrdersRepository {")); + assertTrue(orders.contains("public List listOrders(LocalDateTime created_at)")); + assertTrue(orders.contains("BigDecimal total")); Files.createDirectories(classesDirectory); @@ -1149,8 +1147,8 @@ total DECIMAL(10, 2), System.getProperty("java.class.path"), "-d", classesDirectory.toString(), - getUserFile.toString(), - listOrdersFile.toString() + usersFile.toString(), + ordersFile.toString() ); assertEquals(0, compilationResult); @@ -1158,7 +1156,7 @@ total DECIMAL(10, 2), assertTrue( Files.exists( classesDirectory.resolve( - "dev/example/generated/GetUser.class" + "dev/example/generated/UsersRepository.class" ) ) ); @@ -1166,14 +1164,14 @@ total DECIMAL(10, 2), assertTrue( Files.exists( classesDirectory.resolve( - "dev/example/generated/ListOrders.class" + "dev/example/generated/OrdersRepository.class" ) ) ); } @Test - void shouldRejectDuplicateQueryNameAcrossEntriesBeforeWriting() throws IOException { + void shouldGenerateTheSameQueryNameInTwoRepositories() throws IOException { Path usersSchema = tempDir.resolve("users-schema.sql"); Path usersQueries = tempDir.resolve("users-queries.sql"); Path ordersSchema = tempDir.resolve("orders-schema.sql"); @@ -1223,10 +1221,12 @@ void shouldRejectDuplicateQueryNameAcrossEntriesBeforeWriting() throws IOExcepti Config config = new Config( List.of( new SqlConfig( + "Users", usersSchema.toString(), usersQueries.toString() ), new SqlConfig( + "Orders", ordersSchema.toString(), ordersQueries.toString() ) @@ -1237,20 +1237,21 @@ void shouldRejectDuplicateQueryNameAcrossEntriesBeforeWriting() throws IOExcepti ) ); - SqlcjCompiler compiler = new SqlcjCompiler(); + new SqlcjCompiler().compile(config); - CompilationException exception = assertThrows( - CompilationException.class, - () -> compiler.compile(config) - ); + Path usersRepository = generatedDirectory + .resolve("dev/example/generated") + .resolve("UsersRepository.java"); - assertEquals( - "Duplicate query name 'GetRecord' in query source: " - + ordersQueries, - exception.getMessage() - ); + Path ordersRepository = generatedDirectory + .resolve("dev/example/generated") + .resolve("OrdersRepository.java"); - assertFalse(Files.exists(generatedDirectory)); + assertTrue(Files.exists(usersRepository)); + assertTrue(Files.exists(ordersRepository)); + + assertTrue(Files.readString(usersRepository).contains("public GetRecordResult getRecord(Long id)")); + assertTrue(Files.readString(ordersRepository).contains("public GetRecordResult getRecord(Long id)")); } @Test @@ -1284,14 +1285,14 @@ name VARCHAR(255) new SqlcjCompiler().compile( new Config( - List.of(new SqlConfig(usersSchema.toString(), usersQueries.toString())), + List.of(new SqlConfig("Users", usersSchema.toString(), usersQueries.toString())), new JavaConfig(generatedDirectory.toString(), "dev.example.generated") ) ); Path generatedFile = generatedDirectory .resolve("dev/example/generated") - .resolve("GetUser.java"); + .resolve("UsersRepository.java"); String previous = Files.readString(generatedFile); @@ -1333,8 +1334,8 @@ name VARCHAR(255) Config config = new Config( List.of( - new SqlConfig(usersSchema.toString(), usersQueries.toString()), - new SqlConfig(ordersSchema.toString(), ordersQueries.toString()) + new SqlConfig("Users", usersSchema.toString(), usersQueries.toString()), + new SqlConfig("Orders", ordersSchema.toString(), ordersQueries.toString()) ), new JavaConfig(generatedDirectory.toString(), "dev.example.generated") ); @@ -1359,7 +1360,7 @@ name VARCHAR(255) Files.exists( generatedDirectory .resolve("dev/example/generated") - .resolve("ListOrders.java") + .resolve("OrdersRepository.java") ) ); } @@ -1394,7 +1395,7 @@ void shouldRejectAnonymousPlaceholderBeforeWriting() { } @Test - void shouldRejectNormalizedGeneratedPathCollisionBeforeWriting() throws IOException { + void shouldRejectRepeatedRepositoryMethodBeforeWriting() throws IOException { Path generatedDirectory = tempDir.resolve("generated"); CompilationException exception = assertThrows( @@ -1416,16 +1417,20 @@ void shouldRejectNormalizedGeneratedPathCollisionBeforeWriting() throws IOExcept ); assertEquals( - "Duplicate generated file for queries 'Get.User' and 'Get-User': " - + Path.of("generated", "Get_User.java"), + "Invalid query group 'Users' in %s: ".formatted(tempDir.resolve("queries.sql")) + + "Queries 'Get.User' and 'Get-User' generate the same repository method 'get_User'", exception.getMessage() ); assertFalse(Files.exists(generatedDirectory)); } + /** + * Nested result types that differ only by case compile to class files that + * share one path on a case-insensitive filesystem. + */ @Test - void shouldRejectGeneratedPathsThatDifferOnlyByCaseBeforeWriting() { + void shouldRejectRepositoryResultTypesThatDifferOnlyByCaseBeforeWriting() throws IOException { Path generatedDirectory = tempDir.resolve("generated"); CompilationException exception = assertThrows( @@ -1447,10 +1452,47 @@ void shouldRejectGeneratedPathsThatDifferOnlyByCaseBeforeWriting() { ); assertEquals( - "Generated file paths for queries 'GetUser' and 'getuser' differ only by case: " - + Path.of("generated", "GetUser.java") + "Invalid query group 'Users' in %s: ".formatted(tempDir.resolve("queries.sql")) + + "Queries 'GetUser' and 'getuser' generate result types that differ only by case: " + + "GetUserResult and getuserResult", + exception.getMessage() + ); + + assertFalse(Files.exists(generatedDirectory)); + } + + @Test + void shouldRejectDuplicateRepositoryBeforeWriting() throws IOException { + Path generatedDirectory = tempDir.resolve("generated"); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> compileUsersGroups("Users", "Users", generatedDirectory) + ); + + assertEquals( + "Duplicate generated file for repositories 'Users' and 'Users': " + + Path.of("generated", "UsersRepository.java"), + exception.getMessage() + ); + + assertFalse(Files.exists(generatedDirectory)); + } + + @Test + void shouldRejectRepositoryPathsThatDifferOnlyByCaseBeforeWriting() throws IOException { + Path generatedDirectory = tempDir.resolve("generated"); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> compileUsersGroups("Users", "users", generatedDirectory) + ); + + assertEquals( + "Generated file paths for repositories 'Users' and 'users' differ only by case: " + + Path.of("generated", "UsersRepository.java") + " and " - + Path.of("generated", "getuser.java"), + + Path.of("generated", "usersRepository.java"), exception.getMessage() ); @@ -1488,6 +1530,7 @@ void shouldGenerateCompilableJavaForQuotedSqlIdentifiers() throws IOException { Config config = new Config( List.of( new SqlConfig( + "Users", schemaFile.toString(), queriesFile.toString() ) @@ -1502,7 +1545,7 @@ void shouldGenerateCompilableJavaForQuotedSqlIdentifiers() throws IOException { Path generatedFile = generatedDirectory .resolve("generated") - .resolve("ListUserData.java"); + .resolve("UsersRepository.java"); String source = Files.readString(generatedFile); @@ -1582,6 +1625,7 @@ INSERT INTO users (id, name) Config config = new Config( List.of( new SqlConfig( + "Users", schemaFile.toString(), queriesFile.toString() ) @@ -1594,31 +1638,28 @@ INSERT INTO users (id, name) new SqlcjCompiler().compile(config); - Path insertUserFile = generatedDirectory.resolve("generated/InsertUser.java"); - Path updateUserFile = generatedDirectory.resolve("generated/UpdateUser.java"); - Path deleteUsersFile = generatedDirectory.resolve("generated/DeleteUsers.java"); - - String insertUser = Files.readString(insertUserFile); - assertTrue(insertUser.contains("public record InsertUserResult(")); - assertTrue(insertUser.contains("Long id")); - assertTrue(insertUser.contains("String name")); - assertTrue(insertUser.contains("Boolean active")); - assertTrue(insertUser.contains("public InsertUserResult insertUser(Long id, String name)")); - assertTrue(insertUser.contains("return executor.query(")); - assertTrue(insertUser.contains("VALUES (?, ?)")); - assertTrue(insertUser.contains("RETURNING *")); - - String updateUser = Files.readString(updateUserFile); - assertTrue(updateUser.contains("public UpdateUserResult updateUser(Long id, String name)")); - assertTrue(updateUser.contains("resultSet.getObject(1, String.class)")); - assertTrue(updateUser.contains("resultSet.getObject(2, Long.class)")); - assertTrue(updateUser.contains("java.util.Arrays.asList(name, id)")); - assertTrue(updateUser.contains("RETURNING name, id")); - - String deleteUsers = Files.readString(deleteUsersFile); - assertTrue(deleteUsers.contains("public List deleteUsers(Boolean active)")); - assertTrue(deleteUsers.contains("return executor.queryMany(")); - assertTrue(deleteUsers.contains("RETURNING id, name")); + Path repositoryFile = generatedDirectory.resolve("generated/UsersRepository.java"); + + String repository = Files.readString(repositoryFile); + + assertTrue(repository.contains("public record InsertUserResult(")); + assertTrue(repository.contains("Long id")); + assertTrue(repository.contains("String name")); + assertTrue(repository.contains("Boolean active")); + assertTrue(repository.contains("public InsertUserResult insertUser(Long id, String name)")); + assertTrue(repository.contains("return executor.query(")); + assertTrue(repository.contains("VALUES (?, ?)")); + assertTrue(repository.contains("RETURNING *")); + + assertTrue(repository.contains("public UpdateUserResult updateUser(Long id, String name)")); + assertTrue(repository.contains("resultSet.getObject(1, String.class)")); + assertTrue(repository.contains("resultSet.getObject(2, Long.class)")); + assertTrue(repository.contains("java.util.Arrays.asList(name, id)")); + assertTrue(repository.contains("RETURNING name, id")); + + assertTrue(repository.contains("public List deleteUsers(Boolean active)")); + assertTrue(repository.contains("return executor.queryMany(")); + assertTrue(repository.contains("RETURNING id, name")); Files.createDirectories(classesDirectory); @@ -1636,15 +1677,11 @@ INSERT INTO users (id, name) System.getProperty("java.class.path"), "-d", classesDirectory.toString(), - insertUserFile.toString(), - updateUserFile.toString(), - deleteUsersFile.toString() + repositoryFile.toString() ) ); - assertTrue(Files.exists(classesDirectory.resolve("generated/InsertUser.class"))); - assertTrue(Files.exists(classesDirectory.resolve("generated/UpdateUser.class"))); - assertTrue(Files.exists(classesDirectory.resolve("generated/DeleteUsers.class"))); + assertTrue(Files.exists(classesDirectory.resolve("generated/UsersRepository.class"))); } private void compileUsersQueries(String queries, Path generatedDirectory) throws IOException { @@ -1667,6 +1704,7 @@ name VARCHAR(255) Config config = new Config( List.of( new SqlConfig( + "Users", schemaFile.toString(), queriesFile.toString() ) @@ -1680,7 +1718,51 @@ name VARCHAR(255) new SqlcjCompiler().compile(config); } - private Path generateAndCompile(String queries, String queryName) throws IOException { + /** Compiles two configured groups that share one schema and query source. */ + private void compileUsersGroups( + String firstGroup, + String secondGroup, + Path generatedDirectory + ) throws IOException { + Path schemaFile = tempDir.resolve("schema.sql"); + Path queriesFile = tempDir.resolve("queries.sql"); + + Files.writeString( + schemaFile, + """ + CREATE TABLE users + ( + id BIGINT NOT NULL, + name VARCHAR(255) + ); + """ + ); + + Files.writeString( + queriesFile, + """ + -- name: GetUser :one + SELECT id, name + FROM users + WHERE id = $1; + """ + ); + + Config config = new Config( + List.of( + new SqlConfig(firstGroup, schemaFile.toString(), queriesFile.toString()), + new SqlConfig(secondGroup, schemaFile.toString(), queriesFile.toString()) + ), + new JavaConfig( + generatedDirectory.toString(), + "generated" + ) + ); + + new SqlcjCompiler().compile(config); + } + + private Path generateAndCompile(String queries) throws IOException { return generateAndCompile( """ CREATE TABLE users @@ -1690,12 +1772,12 @@ name VARCHAR(255), active BOOLEAN ); """, - queries, - queryName + queries ); } - private Path generateAndCompile(String schema, String queries, String queryName) throws IOException { + /** Generates and compiles the one repository of the {@code Users} group. */ + private Path generateAndCompile(String schema, String queries) throws IOException { Path schemaFile = tempDir.resolve("schema.sql"); Path queriesFile = tempDir.resolve("queries.sql"); Path generatedDirectory = tempDir.resolve("generated"); @@ -1708,6 +1790,7 @@ private Path generateAndCompile(String schema, String queries, String queryName) Config config = new Config( List.of( new SqlConfig( + "Users", schemaFile.toString(), queriesFile.toString() ) @@ -1720,7 +1803,7 @@ private Path generateAndCompile(String schema, String queries, String queryName) new SqlcjCompiler().compile(config); - Path generatedFile = generatedDirectory.resolve("generated").resolve(queryName + ".java"); + Path generatedFile = generatedDirectory.resolve("generated").resolve("UsersRepository.java"); assertTrue(Files.exists(generatedFile)); diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/config/YamlConfigLoaderTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/config/YamlConfigLoaderTest.java index 1eeaf00..6e95f6e 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/config/YamlConfigLoaderTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/config/YamlConfigLoaderTest.java @@ -2,6 +2,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.io.IOException; import java.nio.file.Files; @@ -24,7 +26,8 @@ void shouldMapVersionOneFields() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -35,6 +38,7 @@ void shouldMapVersionOneFields() throws IOException { assertEquals("1", config.version()); assertEquals(1, config.sql().size()); + assertEquals("Users", config.sql().getFirst().name()); assertEquals( tempDir.resolve("schema.sql").toString(), config.sql().getFirst().schema() @@ -58,9 +62,11 @@ void shouldPreserveSqlEntryOrder() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: users.sql + - name: Users + schema: users.sql queries: user-queries.sql - - schema: orders.sql + - name: Orders + schema: orders.sql queries: order-queries.sql java: package: dev.example.generated @@ -97,7 +103,8 @@ void shouldResolveRelativePathsAgainstConfigurationDirectory() throws IOExceptio Files.writeString(configFile, """ version: "1" sql: - - schema: ../sql/schema.sql + - name: Users + schema: ../sql/schema.sql queries: ./queries.sql java: package: dev.example.generated @@ -134,7 +141,8 @@ void shouldKeepAbsolutePathsAbsolute() throws IOException { """ version: "1" sql: - - schema: %s + - name: Users + schema: %s queries: %s java: package: dev.example.generated @@ -203,7 +211,8 @@ void shouldRejectUnknownField() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -235,7 +244,8 @@ void shouldRejectWrongTypedNestedField() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: 42 + - name: Users + schema: 42 queries: queries.sql java: package: dev.example.generated @@ -254,7 +264,8 @@ void shouldRejectWrongTypedJavaOut() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -273,7 +284,8 @@ void shouldRejectNumericVersion() throws IOException { Path configFile = write(""" version: 1 sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -291,7 +303,8 @@ void shouldRejectNumericVersion() throws IOException { void shouldRejectMissingVersion() throws IOException { Path configFile = write(""" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -306,7 +319,8 @@ void shouldRejectUnsupportedVersion() throws IOException { Path configFile = write(""" version: "2" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -349,7 +363,8 @@ void shouldRejectNullSqlEntry() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql - java: @@ -365,7 +380,8 @@ void shouldRejectBlankQueriesValue() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: " " java: package: dev.example.generated @@ -376,12 +392,103 @@ void shouldRejectBlankQueriesValue() throws IOException { } @Test - void shouldRejectMissingJavaSection() throws IOException { + void shouldRejectMissingSqlEntryName() throws IOException { Path configFile = write(""" version: "1" sql: - schema: schema.sql queries: queries.sql + java: + package: dev.example.generated + out: generated + """); + + assertInvalid(configFile, "'sql[0].name' is required"); + } + + @Test + void shouldRejectBlankSqlEntryName() throws IOException { + Path configFile = write(""" + version: "1" + sql: + - name: " " + schema: schema.sql + queries: queries.sql + java: + package: dev.example.generated + out: generated + """); + + assertInvalid(configFile, "'sql[0].name' must not be blank"); + } + + @ParameterizedTest + @ValueSource( + strings = { + "Author Repository", + "1Author", + "Author.Repository", + "class", + "true", + "null", + "_", + "var", + "record" + } + ) + void shouldRejectSqlEntryNameThatIsNotAJavaIdentifier(String name) throws IOException { + Path configFile = write( + """ + version: "1" + sql: + - name: "%s" + schema: schema.sql + queries: queries.sql + java: + package: dev.example.generated + out: generated + """ + .formatted(name) + ); + + assertInvalid( + configFile, + "'sql[0].name' value '%s' is not a valid Java identifier for a generated repository name" + .formatted(name) + ); + } + + @Test + void shouldPreserveSqlEntryNameWhileResolvingPaths() throws IOException { + Path configFile = write(""" + version: "1" + sql: + - name: Author + schema: sql/schema.sql + queries: sql/queries.sql + java: + package: dev.example.generated + out: generated + """); + + Config config = configLoader.load(configFile); + + assertEquals("Author", config.sql().getFirst().name()); + + assertEquals( + tempDir.resolve("sql/schema.sql").toString(), + config.sql().getFirst().schema() + ); + } + + @Test + void shouldRejectMissingJavaSection() throws IOException { + Path configFile = write(""" + version: "1" + sql: + - name: Users + schema: schema.sql + queries: queries.sql """); assertInvalid(configFile, "'java' is required"); @@ -392,7 +499,8 @@ void shouldRejectMissingJavaOut() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.example.generated @@ -406,7 +514,8 @@ void shouldRejectInvalidJavaPackage() throws IOException { Path configFile = write(""" version: "1" sql: - - schema: schema.sql + - name: Users + schema: schema.sql queries: queries.sql java: package: dev.class.generated diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java index b501c61..53bc698 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java @@ -1,6 +1,7 @@ package dev.sqlcj.generator; import dev.sqlcj.analysis.QueryColumn; +import dev.sqlcj.analysis.QueryGroupModel; import dev.sqlcj.analysis.QueryModel; import dev.sqlcj.analysis.QueryParameter; import dev.sqlcj.parser.QueryType; @@ -33,14 +34,16 @@ class JavaCodeGeneratorNamingTest { FROM users """; + private static final String GROUP = "Users"; + private final CodeGenerator codeGenerator = new JavaCodeGenerator(); @TempDir Path tempDir; @Test - void shouldGenerateKeywordSafeMethodForKeywordClassName() throws IOException { - GeneratedFile file = codeGenerator.generate( + void shouldGenerateKeywordSafeMethodForKeywordQueryName() throws IOException { + GeneratedFile file = generate( query( "Class", List.of(new QueryColumn("id", ColumnType.BIGINT, false)), @@ -50,9 +53,10 @@ void shouldGenerateKeywordSafeMethodForKeywordClassName() throws IOException { String source = file.content(); - assertEquals(Path.of("generated", "Class.java"), file.path()); - assertTrue(source.contains("public final class Class {")); - assertTrue(source.contains("public Class(QueryExecutor executor)")); + assertEquals(Path.of("generated", "UsersRepository.java"), file.path()); + assertTrue(source.contains("public final class UsersRepository {")); + assertTrue(source.contains("public UsersRepository(QueryExecutor executor)")); + assertTrue(source.contains("public record ClassResult(")); assertTrue(source.contains("public List class_()")); assertCompiles(file); @@ -60,7 +64,7 @@ void shouldGenerateKeywordSafeMethodForKeywordClassName() throws IOException { @Test void shouldNormalizeUnsafeQueryNameAndEscapeJavadoc() throws IOException { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "Get*/User", List.of(new QueryColumn("id", ColumnType.BIGINT, false)), @@ -70,9 +74,9 @@ void shouldNormalizeUnsafeQueryNameAndEscapeJavadoc() throws IOException { String source = file.content(); - assertEquals(Path.of("generated", "Get_User.java"), file.path()); - assertTrue(source.contains("public final class Get_User {")); - assertTrue(source.contains("public Get_User(QueryExecutor executor)")); + assertEquals(Path.of("generated", "UsersRepository.java"), file.path()); + assertTrue(source.contains("public final class UsersRepository {")); + assertTrue(source.contains("public record Get_UserResult(")); assertTrue(source.contains("public List get_User()")); assertTrue(source.contains("Query: Get*/User")); assertFalse(source.contains("Query: Get*/User")); @@ -81,8 +85,8 @@ void shouldNormalizeUnsafeQueryNameAndEscapeJavadoc() throws IOException { } @Test - void shouldRenameClassConflictingWithImportedType() throws IOException { - GeneratedFile file = codeGenerator.generate( + void shouldGenerateResultTypeForQueryNamedLikeAnImportedType() throws IOException { + GeneratedFile file = generate( query( "List", List.of(new QueryColumn("id", ColumnType.BIGINT, false)), @@ -92,19 +96,19 @@ void shouldRenameClassConflictingWithImportedType() throws IOException { String source = file.content(); - assertEquals(Path.of("generated", "List_.java"), file.path()); + assertEquals(Path.of("generated", "UsersRepository.java"), file.path()); assertTrue(source.contains("import java.util.List;")); - assertTrue(source.contains("public final class List_ {")); - assertTrue(source.contains("public record List_Result(")); - assertTrue(source.contains("private static final RowMapper ROW_MAPPER =")); - assertTrue(source.contains("public List list_()")); + assertTrue(source.contains("public final class UsersRepository {")); + assertTrue(source.contains("public record ListResult(")); + assertTrue(source.contains("private static final RowMapper listRowMapper =")); + assertTrue(source.contains("public List list()")); assertCompiles(file); } @Test - void shouldRenameClassConflictingWithImportedUuidType() throws IOException { - GeneratedFile file = codeGenerator.generate( + void shouldGenerateResultTypeForQueryNamedLikeAnImportedUuidType() throws IOException { + GeneratedFile file = generate( query( "UUID", List.of(new QueryColumn("external_id", ColumnType.UUID, true)), @@ -114,10 +118,10 @@ void shouldRenameClassConflictingWithImportedUuidType() throws IOException { String source = file.content(); - assertEquals(Path.of("generated", "UUID_.java"), file.path()); + assertEquals(Path.of("generated", "UsersRepository.java"), file.path()); assertTrue(source.contains("import java.util.UUID;")); - assertTrue(source.contains("public final class UUID_ {")); - assertTrue(source.contains("public record UUID_Result(")); + assertTrue(source.contains("public final class UsersRepository {")); + assertTrue(source.contains("public record UUIDResult(")); assertTrue(source.contains("UUID external_id")); assertTrue(source.contains("resultSet.getObject(1, UUID.class)")); @@ -126,7 +130,7 @@ void shouldRenameClassConflictingWithImportedUuidType() throws IOException { @Test void shouldReadRenamedResultComponentsByProjectionPosition() throws IOException { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "ListUsers", List.of( @@ -156,7 +160,7 @@ void shouldReadRenamedResultComponentsByProjectionPosition() throws IOException @Test void shouldReadQuotedSqlColumnByProjectionPosition() throws IOException { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "ListUsers", List.of(new QueryColumn("user\"id", ColumnType.VARCHAR, true)), @@ -174,7 +178,7 @@ void shouldReadQuotedSqlColumnByProjectionPosition() throws IOException { @Test void shouldUseResolvedParameterNamesInSignatureAndArguments() throws IOException { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "FindUsers", List.of(new QueryColumn("id", ColumnType.BIGINT, false)), @@ -216,7 +220,7 @@ void shouldExecuteExactSqlForQuotedIdentifierWithBackslash() throws Exception { List.of(new QueryParameter(1, "a\\q", ColumnType.VARCHAR)) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue(file.content().contains("SELECT \"a\\\\q\"")); assertTrue(file.content().contains("String a_q")); @@ -240,7 +244,7 @@ void shouldExecuteExactSqlForTextBlockDelimiterAndTrailingWhitespace() throws Ex List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertCompiles(file); @@ -307,6 +311,16 @@ public int execute(String sql, List parameters) { } } + /** Generates the repository of a single-query group. */ + private GeneratedFile generate(QueryModel query) { + return codeGenerator.generate( + new QueryGroupModel( + GROUP, + List.of(query) + ) + ); + } + private QueryModel query(String name, List columns, List parameters) { return new QueryModel( name, diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java index f8e14e6..37ef932 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java @@ -1,6 +1,7 @@ package dev.sqlcj.generator; import dev.sqlcj.analysis.QueryColumn; +import dev.sqlcj.analysis.QueryGroupModel; import dev.sqlcj.analysis.QueryModel; import dev.sqlcj.analysis.QueryParameter; import dev.sqlcj.parser.QueryType; @@ -26,6 +27,8 @@ class JavaCodeGeneratorTest { private static final String SQL = "SELECT 1"; + private static final String GROUP = "Users"; + private final CodeGenerator codeGenerator = new JavaCodeGenerator(); @TempDir @@ -45,10 +48,10 @@ void shouldGenerateFilePath() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertEquals( - Path.of("generated", "GetUser.java"), + Path.of("generated", "UsersRepository.java"), file.path() ); } @@ -65,9 +68,9 @@ void shouldGenerateClassName() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); - assertTrue(file.content().contains("public final class GetUser")); + assertTrue(file.content().contains("public final class UsersRepository")); } @Test @@ -82,14 +85,14 @@ void shouldGenerateMethodName() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue(file.content().contains("getUser(")); } @Test void shouldGenerateSingleParameter() { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "GetUser", QueryType.ONE, @@ -106,7 +109,7 @@ void shouldGenerateSingleParameter() { @Test void shouldGenerateMultipleParameters() { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "ListUsersByIdAndName", QueryType.MANY, @@ -126,7 +129,7 @@ void shouldGenerateMultipleParameters() { @Test void shouldGenerateMethodWithoutParameters() { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "ListUsers", QueryType.MANY, @@ -143,7 +146,7 @@ void shouldGenerateMethodWithoutParameters() { @Test void shouldGenerateJavaDoc() { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "GetUser", QueryType.ONE, @@ -162,7 +165,7 @@ void shouldGenerateJavaDoc() { @Test void shouldGeneratePackageDeclaration() { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "GetUser", QueryType.ONE, @@ -195,10 +198,10 @@ void shouldGenerateConfiguredPackageDeclarationAndPath() throws IOException { ) ); - GeneratedFile file = generator.generate(query); + GeneratedFile file = generate(generator, query); assertEquals( - Path.of("dev", "example", "generated", "GetUser.java"), + Path.of("dev", "example", "generated", "UsersRepository.java"), file.path() ); @@ -225,7 +228,7 @@ void shouldGenerateResultRecord() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -250,7 +253,7 @@ void shouldGenerateSingleResultReturnType() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -274,7 +277,7 @@ void shouldGenerateListResultReturnType() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue(file.content().contains("import java.util.List;")); @@ -303,7 +306,7 @@ void shouldGenerateTypedMethodParameters() { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -329,7 +332,7 @@ void shouldGenerateSingleResultWithTypedParameter() { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -352,7 +355,7 @@ void shouldGenerateListImportForSingleResultWithoutParameters() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -371,7 +374,7 @@ void shouldGenerateParameterNamesFromQueryParameters() { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -400,7 +403,7 @@ void shouldGenerateDistinctComponentsAndPositionalReadsForDuplicateColumns() thr List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -412,7 +415,7 @@ void shouldGenerateDistinctComponentsAndPositionalReadsForDuplicateColumns() thr assertTrue(source.contains("resultSet.getObject(2, Long.class)")); assertTrue(source.contains("resultSet.getObject(3, String.class)")); - assertEquals(0, compile(file, "ListUserProfiles.java")); + assertEquals(0, compile(file, "UsersRepository.java")); } @ParameterizedTest @@ -435,7 +438,7 @@ void shouldGenerateJavaTypeForQueryParameter( ColumnType columnType, String expectedJavaType ) { - GeneratedFile file = codeGenerator.generate( + GeneratedFile file = generate( query( "GetUser", QueryType.ONE, @@ -466,7 +469,7 @@ void shouldGenerateQuerySpecificResultRecord() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -493,7 +496,7 @@ void shouldGenerateImportForLocalDate() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -520,7 +523,7 @@ void shouldGenerateImportForBigDecimal() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -563,7 +566,7 @@ void shouldGenerateImportsForUuidAndTimestampWithTimeZone() throws IOException { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -609,7 +612,7 @@ void shouldGenerateImportForQueryParameterType() { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -642,7 +645,7 @@ void shouldGenerateEachImportOnlyOnce() { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -677,7 +680,7 @@ void shouldGenerateMultipleRequiredImports() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -710,11 +713,11 @@ void shouldGenerateQueryExecutionMethodBody() { ) ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); assertTrue(source.contains("return executor.query(")); assertTrue(source.contains("java.util.Arrays.asList(id)")); - assertTrue(source.contains("ROW_MAPPER")); + assertTrue(source.contains("getUserRowMapper")); assertFalse(source.contains("UnsupportedOperationException")); } @@ -757,9 +760,9 @@ void shouldGenerateCompilableJavaSource() throws IOException { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); - assertEquals(0, compile(file, "ListUsers.java")); + assertEquals(0, compile(file, "UsersRepository.java")); } /** Compiles one generated source file in an isolated temporary location. */ @@ -822,7 +825,7 @@ void shouldGenerateJavaTypeForResultColumn(ColumnType columnType, String expecte List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -849,7 +852,7 @@ void shouldGenerateWrapperTypeForNullableColumn() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains("Long id") @@ -874,7 +877,7 @@ void shouldGenerateWrapperTypeForNonNullableColumn() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains("Long id") @@ -898,7 +901,7 @@ void shouldGenerateUniqueParameterNamesForDuplicateColumns() { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); assertTrue( file.content().contains( @@ -925,7 +928,7 @@ void shouldGenerateRowMapperForOneQuery() { List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -937,7 +940,7 @@ void shouldGenerateRowMapperForOneQuery() { assertTrue( source.contains( - "private static final RowMapper ROW_MAPPER" + "private static final RowMapper getUserRowMapper" ) ); @@ -987,7 +990,7 @@ void shouldPreserveResultColumnOrder() { List.of() ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); int nameIndex = source.indexOf("resultSet.getObject(1, String.class)"); @@ -1011,7 +1014,7 @@ void shouldGenerateRowMapperForManyQuery() { List.of() ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); assertTrue( source.contains( @@ -1021,7 +1024,7 @@ void shouldGenerateRowMapperForManyQuery() { assertTrue( source.contains( - "private static final RowMapper ROW_MAPPER" + "private static final RowMapper listUsersRowMapper" ) ); @@ -1050,7 +1053,7 @@ void shouldGenerateQueryExecutorImport() { List.of() ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); assertTrue( source.contains( @@ -1071,7 +1074,7 @@ void shouldGenerateQueryExecutorField() { List.of() ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); assertTrue( source.contains( @@ -1092,11 +1095,11 @@ void shouldGenerateQueryExecutorConstructor() { List.of() ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); assertTrue( source.contains( - "public GetUser(QueryExecutor executor)" + "public UsersRepository(QueryExecutor executor)" ) ); @@ -1140,7 +1143,7 @@ void shouldGenerateExecutionForOneQuery() { ) ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); assertTrue( source.contains(""" @@ -1162,7 +1165,7 @@ void shouldGenerateExecutionForOneQuery() { assertTrue( source.contains( - "ROW_MAPPER" + "getUserRowMapper" ) ); @@ -1212,7 +1215,7 @@ void shouldGenerateQueryManyExecutionForManyQuery() { ) ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); assertTrue( source.contains(""" @@ -1228,7 +1231,7 @@ void shouldGenerateQueryManyExecutionForManyQuery() { assertTrue( source.contains( - "ROW_MAPPER" + "listUsersRowMapper" ) ); @@ -1264,7 +1267,7 @@ void shouldPreserveParameterOrderWhenGeneratingExecution() { ) ); - String source = codeGenerator.generate(query).content(); + String source = generate(query).content(); assertTrue( source.contains( @@ -1296,7 +1299,7 @@ void shouldGenerateLogicalParametersWithTextualBindingOrder() throws IOException ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -1336,7 +1339,7 @@ void shouldRepeatArgumentForRepeatedPlaceholderIndex() throws IOException { ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -1369,7 +1372,7 @@ void shouldGenerateEmptyArgumentListForQueryWithoutParameters() throws IOExcepti List.of() ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -1403,7 +1406,7 @@ INSERT INTO users (id, name) ) ); - GeneratedFile file = codeGenerator.generate(query); + GeneratedFile file = generate(query); String source = file.content(); @@ -1455,6 +1458,145 @@ private void assertCompiles(GeneratedFile file) throws IOException { assertEquals(0, result); } + /** + * One group holding every supported query kind becomes one repository with + * one executor field, one constructor, and one method per query. + */ + @Test + void shouldGenerateOneRepositoryForEveryQueryOfTheGroup() throws IOException { + QueryModel createUser = new QueryModel( + "CreateUser", + QueryType.ONE, + "users", + "INSERT INTO users (name) VALUES (?) RETURNING id, name", + List.of(1), + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("name", ColumnType.VARCHAR, true) + ), + List.of(new QueryParameter(1, "name", ColumnType.VARCHAR)) + ); + + QueryModel getUser = new QueryModel( + "GetUser", + QueryType.ONE, + "users", + "SELECT id FROM users WHERE id = ?", + List.of(1), + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + List.of(new QueryParameter(1, "id", ColumnType.BIGINT)) + ); + + QueryModel listUsers = new QueryModel( + "ListUsers", + QueryType.MANY, + "users", + "SELECT id, birth_date FROM users", + List.of(), + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("birth_date", ColumnType.DATE, true) + ), + List.of() + ); + + QueryModel deleteUser = new QueryModel( + "DeleteUser", + QueryType.EXEC, + "users", + "DELETE FROM users WHERE id = ?", + List.of(1), + List.of(), + List.of(new QueryParameter(1, "id", ColumnType.BIGINT)) + ); + + GeneratedFile file = codeGenerator.generate( + new QueryGroupModel( + GROUP, + List.of(createUser, getUser, listUsers, deleteUser) + ) + ); + + String source = file.content(); + + assertEquals(Path.of("generated", "UsersRepository.java"), file.path()); + + assertEquals( + 1, + source.lines() + .filter(line -> line.equals(" private final QueryExecutor executor;")) + .count() + ); + + assertEquals( + 1, + source.lines() + .filter(line -> line.contains("public UsersRepository(QueryExecutor executor)")) + .count() + ); + + assertTrue(source.contains("public CreateUserResult createUser(String name)")); + assertTrue(source.contains("public GetUserResult getUser(Long id)")); + assertTrue(source.contains("public List listUsers()")); + assertTrue(source.contains("public int deleteUser(Long id)")); + + assertTrue(source.contains("public record CreateUserResult(")); + assertTrue(source.contains("public record GetUserResult(")); + assertTrue(source.contains("public record ListUsersResult(")); + assertFalse(source.contains("public record DeleteUserResult(")); + + assertTrue(source.contains("private static final RowMapper createUserRowMapper")); + assertTrue(source.contains("private static final RowMapper getUserRowMapper")); + assertTrue(source.contains("private static final RowMapper listUsersRowMapper")); + + assertTrue(source.contains("import java.time.LocalDate;")); + + assertEquals( + 1, + source.lines() + .filter(line -> line.equals("import dev.sqlcj.runtime.RowMapper;")) + .count() + ); + + assertTrue(source.indexOf("createUser(") < source.indexOf("getUser(")); + assertTrue(source.indexOf("getUser(") < source.indexOf("listUsers(")); + assertTrue(source.indexOf("listUsers(") < source.indexOf("deleteUser(")); + + assertCompiles(file); + } + + @Test + void shouldGenerateRepositoryWithoutMethodsForEmptyGroup() throws IOException { + GeneratedFile file = codeGenerator.generate( + new QueryGroupModel(GROUP, List.of()) + ); + + String source = file.content(); + + assertEquals(Path.of("generated", "UsersRepository.java"), file.path()); + assertTrue(source.contains("public final class UsersRepository {")); + assertTrue(source.contains("private final QueryExecutor executor;")); + assertTrue(source.contains("public UsersRepository(QueryExecutor executor)")); + assertFalse(source.contains("RowMapper")); + assertFalse(source.contains("public record")); + + assertCompiles(file); + } + + private GeneratedFile generate(QueryModel query) { + return generate(codeGenerator, query); + } + + /** Generates the repository of a single-query group. */ + private GeneratedFile generate(CodeGenerator generator, QueryModel query) { + return generator.generate( + new QueryGroupModel( + GROUP, + List.of(query) + ) + ); + } + private QueryModel query(String name, QueryType type, List parameters) { return new QueryModel( name, diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaNamesTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaNamesTest.java index a32f16a..73d8dd6 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaNamesTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaNamesTest.java @@ -1,6 +1,7 @@ package dev.sqlcj.generator; import dev.sqlcj.analysis.QueryColumn; +import dev.sqlcj.analysis.QueryGroupModel; import dev.sqlcj.analysis.QueryModel; import dev.sqlcj.analysis.QueryParameter; import dev.sqlcj.parser.QueryType; @@ -9,71 +10,65 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import java.util.Arrays; import java.util.List; import java.util.stream.IntStream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; class JavaNamesTest { private static final String SQL = "SELECT 1"; @Test - void shouldKeepSafeQueryNameAsClassName() { - JavaNames names = names("GetUser"); + void shouldNameRepositoryAfterConfiguredGroupName() { + JavaNames names = names("Author", "GetAuthor"); + + assertEquals("AuthorRepository", names.repositoryClassName()); + } + + @Test + void shouldKeepSafeQueryNameAsResultTypeAndMethodName() { + JavaNames.QueryNames names = queryNames("GetUser"); - assertEquals("GetUser", names.className()); assertEquals("GetUserResult", names.resultTypeName()); assertEquals("getUser", names.methodName()); + assertEquals("getUserRowMapper", names.rowMapperName()); } @Test - void shouldKeepSafeLowercaseQueryNameAsClassName() { - JavaNames names = names("getUser"); + void shouldKeepSafeLowercaseQueryNameAsMethodName() { + JavaNames.QueryNames names = queryNames("getUser"); - assertEquals("getUser", names.className()); + assertEquals("getUserResult", names.resultTypeName()); assertEquals("getUser", names.methodName()); } @Test - void shouldKeepClassNameThatIsOnlyAKeywordAsMethodName() { - JavaNames names = names("Class"); + void shouldKeepKeywordMethodNameSafe() { + JavaNames.QueryNames names = queryNames("Class"); - assertEquals("Class", names.className()); + assertEquals("ClassResult", names.resultTypeName()); assertEquals("class_", names.methodName()); } @ParameterizedTest @CsvSource( { - "Get-User, Get_User", - "get user, get_user", - "'Get**User', Get_User", - "'get user ', get_user_", - "1stQuery, _1stQuery", - "default, default_", - "int, int_", - "'true', true_", - "_, __" - } - ) - void shouldNormalizeUnsafeQueryName(String queryName, String expectedClassName) { - assertEquals(expectedClassName, names(queryName).className()); - } - - @ParameterizedTest - @CsvSource( - { - "List, List_", - "String, String_", - "RowMapper, RowMapper_", - "QueryExecutor, QueryExecutor_", - "record, record_", - "var, var_" + "Get-User, Get_UserResult", + "get user, get_userResult", + "'Get**User', Get_UserResult", + "'get user ', get_user_Result", + "1stQuery, _1stQueryResult", + "default, default_Result", + "int, int_Result", + "'true', true_Result", + "_, __Result" } ) - void shouldRenameClassConflictingWithGeneratedOrImportedType(String queryName, String expectedClassName) { - assertEquals(expectedClassName, names(queryName).className()); + void shouldNormalizeUnsafeQueryName(String queryName, String expectedResultTypeName) { + assertEquals(expectedResultTypeName, queryNames(queryName).resultTypeName()); } @ParameterizedTest @@ -86,19 +81,63 @@ void shouldRenameClassConflictingWithGeneratedOrImportedType(String queryName, S } ) void shouldRenameMethodConflictingWithInheritedObjectMethod(String queryName, String expectedMethodName) { - assertEquals(expectedMethodName, names(queryName).methodName()); + assertEquals(expectedMethodName, queryNames(queryName).methodName()); + } + + @Test + void shouldRejectQueriesOfOneGroupThatGenerateTheSameMethod() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> names("Author", "Get.User", "Get-User") + ); + + assertEquals( + "Queries 'Get.User' and 'Get-User' generate the same repository method 'get_User'", + exception.getMessage() + ); + } + + /** + * The compiled class files of two nested result types that differ only by + * case are one path on a case-insensitive filesystem. + */ + @Test + void shouldRejectQueriesOfOneGroupWhoseResultTypesDifferOnlyByCase() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> names("Author", "GetUser", "getuser") + ); + + assertEquals( + "Queries 'GetUser' and 'getuser' generate result types that differ only by case: " + + "GetUserResult and getuserResult", + exception.getMessage() + ); + } + + @Test + void shouldKeepTheSameQueryNameInTwoRepositoriesIndependent() { + assertEquals( + "getUser", + names("Author", "GetUser").queries().getFirst().methodName() + ); + + assertEquals( + "getUser", + names("Book", "GetUser").queries().getFirst().methodName() + ); } @Test void shouldKeepSafeColumnNamesAsResultComponents() { - JavaNames names = names("ListUsers", List.of(), List.of("id", "created_at")); + JavaNames.QueryNames names = queryNames("ListUsers", List.of(), List.of("id", "created_at")); assertEquals(List.of("id", "created_at"), names.componentNames()); } @Test void shouldNormalizeAndDisambiguateResultComponents() { - JavaNames names = names( + JavaNames.QueryNames names = queryNames( "ListUsers", List.of(), List.of("user id", "user-id", "class", "hashCode") @@ -112,7 +151,7 @@ void shouldNormalizeAndDisambiguateResultComponents() { @Test void shouldSkipUsedCandidateWhenDisambiguatingResultComponents() { - JavaNames names = names( + JavaNames.QueryNames names = queryNames( "ListUsers", List.of(), List.of("id", "id", "id1") @@ -123,28 +162,50 @@ void shouldSkipUsedCandidateWhenDisambiguatingResultComponents() { @Test void shouldKeepExistingDuplicateParameterNaming() { - JavaNames names = names("FindUsers", List.of("id", "id"), List.of()); + JavaNames.QueryNames names = queryNames("FindUsers", List.of("id", "id"), List.of()); assertEquals(List.of("id1", "id2"), names.parameterNames()); } @Test void shouldResolveParameterNamesReservedByGeneratedCode() { - JavaNames names = names( + JavaNames.QueryNames names = queryNames( "FindUsers", - List.of("executor", "ROW_MAPPER", "name"), + List.of("executor", "findUsersRowMapper", "name"), List.of() ); assertEquals( - List.of("executor1", "ROW_MAPPER1", "name"), + List.of("executor1", "findUsersRowMapper1", "name"), names.parameterNames() ); } + /** + * A parameter must not shadow the row mapper of another method of the same + * repository either, because every mapper is a field of that repository. + */ + @Test + void shouldResolveParameterNameReservedByAnotherQueryRowMapper() { + JavaNames names = JavaNames.of( + new QueryGroupModel( + "Users", + List.of( + query("FindUsers", List.of("listUsersRowMapper"), List.of()), + query("ListUsers", List.of(), List.of("id")) + ) + ) + ); + + assertEquals( + List.of("listUsersRowMapper1"), + names.queries().getFirst().parameterNames() + ); + } + @Test void shouldResolveParameterAndComponentNamesIndependently() { - JavaNames names = names( + JavaNames.QueryNames names = queryNames( "FindUsers", List.of("id", "id"), List.of("id") @@ -154,11 +215,41 @@ void shouldResolveParameterAndComponentNamesIndependently() { assertEquals(List.of("id"), names.componentNames()); } - private JavaNames names(String queryName) { - return names(queryName, List.of(), List.of()); + private JavaNames names(String groupName, String... queryNames) { + return JavaNames.of( + new QueryGroupModel( + groupName, + Arrays.stream(queryNames) + .map(queryName -> query(queryName, List.of(), List.of())) + .toList() + ) + ); + } + + private JavaNames.QueryNames queryNames(String queryName) { + return queryNames(queryName, List.of(), List.of()); } - private JavaNames names(String queryName, List parameterNames, List columnNames) { + private JavaNames.QueryNames queryNames( + String queryName, + List parameterNames, + List columnNames + ) { + return JavaNames.of( + new QueryGroupModel( + "Users", + List.of(query(queryName, parameterNames, columnNames)) + ) + ) + .queries() + .getFirst(); + } + + private QueryModel query( + String queryName, + List parameterNames, + List columnNames + ) { List parameters = IntStream.range(0, parameterNames.size()) .mapToObj( index -> new QueryParameter( @@ -173,18 +264,16 @@ private JavaNames names(String queryName, List parameterNames, List new QueryColumn(name, ColumnType.BIGINT, false)) .toList(); - return JavaNames.of( - new QueryModel( - queryName, - QueryType.MANY, - "users", - SQL, - parameters.stream() - .map(QueryParameter::index) - .toList(), - columns, - parameters - ) + return new QueryModel( + queryName, + QueryType.MANY, + "users", + SQL, + parameters.stream() + .map(QueryParameter::index) + .toList(), + columns, + parameters ); } } diff --git a/sqlcj.yaml b/sqlcj.yaml index 4990b07..f7d1cec 100644 --- a/sqlcj.yaml +++ b/sqlcj.yaml @@ -1,7 +1,8 @@ version: "1" sql: - - schema: schema.sql + - name: User + schema: schema.sql queries: queries.sql java: