diff --git a/docs/configuration.md b/docs/configuration.md index b67dc12..7dc4a76 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,8 +68,10 @@ 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`. +`sql[].name` is required and names the generated repository type: the entry is +converted to upper camel case and followed by `Repository`, so `name: Author` +generates `AuthorRepository` and `name: author_admin` generates +`AuthorAdminRepository`. It must be a single valid, non-blank Java identifier. A Java keyword, the literals `true`, `false`, and `null`, the identifier `_`, and a restricted @@ -143,47 +145,77 @@ resolved inside its own repository. ## Generated Java Names -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 query or column name that is already a valid, non-reserved Java identifier -keeps its spelling: - -- 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 result record - `Get_UserResult`, -- a leading `_` is added when the first character cannot start an identifier, so - `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: - -- 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 record `ClassResult` and the method `class_`, +The configured `sql[].name` and the SQL names inside the entry — query names, +column names, and projection aliases — become conventional Java identifiers. +SQL identifier delimiters are removed before a name is converted, so the quoted +column `"user id"` has the JDBC label `user id` and generates the component +`userId`, while executable SQL keeps the query exactly as written. + +Every name is derived from its SQL spelling by one deterministic rule set that +uses no locale-dependent case mapping and no configuration: + +1. The SQL name is split into words at every character that is not a letter or + a digit, so `get_author`, `Get-User`, and `user id` have two words each. + There is no split inside a word, so `GetAuthor` and `HTTPStatus` are one + word. +2. A word written without a lower-case letter is lower-cased, so the acronyms + `ID` and `URL` become the words `id` and `url`. Every other word keeps its + spelling. +3. A type name is upper camel case: the first character of each word is + upper-cased and the words are joined. `authors` becomes `Authors`, + `get_author` and `GetAuthor` both become `GetAuthor`, and `user_ID` becomes + `UserId`. +4. A method, record component, or parameter name is lower camel case: the upper + camel form's leading run of upper-case letters is lower-cased. A run of two + or more letters that is followed by a lower-case letter keeps its last letter + upper-case, because that letter starts the next word. So `created_at` becomes + `createdAt`, `GetAuthor` becomes `getAuthor`, `HTTPStatus` becomes + `httpStatus`, `HTTP2Status` becomes `http2Status`, and `GetHTTPStatus` + becomes `getHTTPStatus`. +5. A name that would start with a digit is prefixed with `_`, so the query + `1st_query` generates the result record `_1stQueryResult`. +6. A SQL name with no letter and no digit at all, such as `_`, `***`, or `$`, + has no Java name and ends the run: + + ```text + sqlcj: Invalid query group 'User' in /home/dev/project/sql/queries.sql: SQL name '***' of query 'ListUsers' has no letter or digit to generate a Java name from + ``` + +The rules are applied as follows: + +- the repository type is the upper camel form of `sql[].name` followed by + `Repository`, so `author_admin` generates `AuthorAdminRepository`, +- a result record is the upper camel form of the query name followed by + `Result`, so `get_author` generates `GetAuthorResult`, +- a method is the lower camel form of the query name, so `get_author` generates + `getAuthor`, +- a row-mapper field is the method name followed by `RowMapper`, +- record components and method parameters are the lower camel form of the column + name or projection alias, so `created_at` generates `createdAt`. + +Generated names also avoid names that Java or the generated source already uses: + +- a method, record component, or parameter that would be a Java keyword or one + of the literals `true`, `false`, and `null` is suffixed with `_`, so a query + named `Class` generates the record `ClassResult` and the method `class_`, and + a column named `class` generates the component `class_`, +- a method name also avoids the inherited `Object` method names, so a query + named `ToString` generates the method `toString_`, - record components avoid inherited `Object` method names, - method parameters avoid the generator-owned name `executor` and the row-mapper - field names of the repository, which are the method name followed by - `RowMapper`. + field names of the repository. Method parameters and record components are disambiguated inside their own 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`. +column `id` become `id1` and `id2`; the columns `user id` and `user-id` both +convert to `userId` and become the components `userId1` and `userId2`; and a +component that would be an inherited `Object` method name, such as `hashCode`, +becomes `hashCode1`. + +A projection alias is the name of its result component, so +`SELECT u.id AS user_id, p.id AS profile_id` generates the components `userId` +and `profileId` instead of two disambiguated `id` components. 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 @@ -197,7 +229,7 @@ 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' +sqlcj: Invalid query group 'User' in /home/dev/project/sql/queries.sql: Queries 'get_author' and 'GetAuthor' generate the same repository method 'getAuthor' ``` Two queries of one entry whose nested result types differ only by case are @@ -205,18 +237,20 @@ 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 +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 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: +of the run is written, so existing output is not overwritten. Two entry names +that differ only by the case of their first character, such as `User` and +`user`, generate one repository name and collide as the same path: ```text -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 +sqlcj: Duplicate generated file for repositories 'User' and 'user': generated/UserRepository.java +sqlcj: Generated file paths for repositories 'UserData' and 'Userdata' differ only by case: generated/UserDataRepository.java and generated/UserdataRepository.java ``` ## Diagnostics diff --git a/docs/queries.md b/docs/queries.md index b927ca8..535fc9d 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -87,8 +87,10 @@ configuration entry. unknown column is rejected. - A result expression that is not a direct column or a wildcard, such as a function call, an arithmetic expression, or a literal, is rejected. -- Result aliases are not supported. `SELECT id AS author_id` is accepted, but - the alias is ignored: the result component is still named after the column. +- An explicit alias names the result column, so `SELECT id AS author_id` + generates the record component `authorId` while the component's type and + nullability still come from the column. sqlcj itself resolves an alias only in + the projection; every other clause reaches the database as written. ### Predicates @@ -209,11 +211,13 @@ that repository; sqlcj never generates a class per query. - 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. +- Methods appear in query-source order. A method name is the lower camel form + of the query name, as in `get_author` and `GetAuthor` to `getAuthor`. - A `:one` or `:many` query also generates a nested `public record` named - `Result` whose components follow the selected-column order, plus a - private `RowMapper` field that reads each column by its one-based position. + `Result` in upper camel case, whose components follow the + selected-column order and are named after each column's projection alias or + column name, plus a private `RowMapper` field that reads each column by its + one-based position. - 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 @@ -286,11 +290,12 @@ 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 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 +A query name, projection alias, column name, and parameter name becomes a +conventional Java name by one deterministic camel-case rule set, and names that +would collide inside one generated repository are disambiguated in their SQL +order. Those rules, the rejection of two queries of one entry that generate the +same method, and the rejection of two entries whose repository files would +collide, are documented in [Generated Java Names](configuration.md#generated-java-names). The generated repositories are executed through @@ -337,7 +342,6 @@ models nor rejects them, so a statement that uses one may still compile while the generated Java describes only the part sqlcj did analyze. Do not rely on them: -- result aliases, which are ignored, - `DISTINCT`, `GROUP BY`, and `HAVING`, - predicate forms other than the listed comparisons, `AND`/`OR`, and fixed `IN` lists, such as `IS NULL`, `LIKE` with a literal, or `IN` with a subquery, @@ -380,6 +384,8 @@ Reads: - `QueryAnalyzerTest.shouldAnalyzeSelectWithExplicitColumns`, `QueryAnalyzerTest.shouldResolveAllColumns`, `QueryAnalyzerTest.shouldAnalyzeAliasedSingleTableSelect`, + `QueryAnalyzerTest.shouldNameSelectedColumnAfterItsAlias`, + `QueryAnalyzerTest.shouldNameJoinedSelectedColumnsAfterTheirAliases`, `QueryAnalyzerTest.shouldAnalyzeSingleInnerJoin`, `QueryAnalyzerTest.shouldExpandAllColumnsAcrossJoinedSourcesInOrder`, `QueryAnalyzerTest.shouldExpandQualifiedAllColumnsForOneSource`, @@ -391,6 +397,7 @@ Reads: the accepted read shapes. - `SqlcjCompilerIntegrationTest.shouldExecuteGeneratedAliasedQualifiedQuery`, `SqlcjCompilerIntegrationTest.shouldExecuteGeneratedJoinQueryWithDuplicateColumnNames`, + `SqlcjCompilerIntegrationTest.shouldExecuteGeneratedJoinQueryWithProjectionAliases`, and `SqlcjCompilerIntegrationTest.shouldExecuteGeneratedMultipleJoinQuery` compile and execute them. - `PostgresIntegrationTest.shouldExecuteGeneratedOneQueryAgainstPostgres` and @@ -436,6 +443,9 @@ Parameters: Generated Java: +- `JavaNamesTest` covers the camel-case naming rules, the acronym, quoted-name, + keyword, and digit-initial cases, the disambiguation suffixes, and the + rejection of a SQL name without a letter or digit. - `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. diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java index e887024..5a3e9f1 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -6,6 +6,7 @@ import dev.sqlcj.sql.ParsedSql; import dev.sqlcj.type.DefaultTypeResolver; import dev.sqlcj.type.TypeResolver; +import net.sf.jsqlparser.expression.Alias; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.JdbcNamedParameter; import net.sf.jsqlparser.expression.JdbcParameter; @@ -946,7 +947,7 @@ private List resolveColumns(PlainSelect plainSelect, List s columns.add( new QueryColumn( - schemaColumn.name(), + selectedColumnName(selectItem.getAlias(), schemaColumn), schemaColumn.type(), schemaColumn.nullable() ) @@ -964,6 +965,17 @@ private List resolveColumns(PlainSelect plainSelect, List s return columns; } + /** + * Names a selected direct column after its explicit alias when the + * projection declares one, so the alias reaches Java naming. The column's + * type and nullability still come from the schema column. + */ + private String selectedColumnName(Alias alias, dev.sqlcj.schema.Column schemaColumn) { + return alias == null + ? schemaColumn.name() + : alias.getUnquotedName(); + } + /** * Resolves one column reference against the ordered query sources. A * qualified reference resolves through the exposed source name, and an 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 124a742..f0db719 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaNames.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaNames.java @@ -20,12 +20,16 @@ /** * Resolves the Java identifiers of one generated repository. * - *

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. + *

Every generated name is derived from its SQL spelling — the configured + * group name, a query name, a column name or projection alias, or a parameter's + * column name — with one deterministic, locale-independent rule set: the SQL + * name is split into words at every character that is not a letter or a digit, + * a word written without a lower-case letter is lower-cased so that {@code ID} + * becomes {@code Id}, and the words are joined as upper camel case for a type + * and lower camel case for a method, record component, or parameter. + * + *

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 @@ -71,11 +75,11 @@ private JavaNames(String repositoryClassName, List queries) { static JavaNames of(QueryGroupModel group) { List resultTypeNames = group.queries().stream() - .map(query -> className(query.name()) + RESULT_SUFFIX) + .map(query -> upperCamelCase(query.name(), null) + RESULT_SUFFIX) .toList(); List methodNames = group.queries().stream() - .map(query -> methodName(className(query.name()))) + .map(query -> methodName(query.name())) .toList(); rejectDuplicateMethodNames(group, methodNames); @@ -98,20 +102,22 @@ static JavaNames of(QueryGroupModel group) { query.parameters().stream() .map(QueryParameter::name) .toList(), - reservedParameterNames + reservedParameterNames, + query.name() ), resolveNames( query.columns().stream() .map(QueryColumn::name) .toList(), - RESERVED_MEMBER_NAMES + RESERVED_MEMBER_NAMES, + query.name() ) ) ); } return new JavaNames( - group.name() + REPOSITORY_SUFFIX, + upperCamelCase(group.name(), null) + REPOSITORY_SUFFIX, List.copyOf(queries) ); } @@ -209,12 +215,8 @@ private static Set reservedParameterNames(List methodNames) { return reserved; } - private static String className(String queryName) { - return normalize(queryName); - } - - private static String methodName(String className) { - String name = decapitalize(className); + private static String methodName(String queryName) { + String name = lowerCamelCase(queryName, null); while (isKeyword(name) || RESERVED_MEMBER_NAMES.contains(name)) { name += "_"; @@ -223,23 +225,18 @@ private static String methodName(String className) { return name; } - private static String decapitalize(String className) { - int codePoint = className.codePointAt(0); - - return new StringBuilder() - .appendCodePoint(Character.toLowerCase(codePoint)) - .append(className.substring(Character.charCount(codePoint))) - .toString(); - } - /** * Resolves one generated namespace, keeping the declared order and using * stable {@code name1}, {@code name2}, ... suffixes for names that repeat * or are reserved. */ - private static List resolveNames(List sqlNames, Set reserved) { + private static List resolveNames( + List sqlNames, + Set reserved, + String queryName + ) { List bases = sqlNames.stream() - .map(JavaNames::normalize) + .map(sqlName -> memberName(sqlName, queryName)) .toList(); Map occurrences = bases.stream() @@ -292,46 +289,134 @@ private static String resolveName( } /** - * Turns a SQL name into a valid Java identifier, preserving names that are - * already valid and non-reserved. + * Names one record component or method parameter after its SQL name, + * keeping a Java keyword or literal usable by suffixing {@code _}. */ - private static String normalize(String sqlName) { - if (SourceVersion.isIdentifier(sqlName) && !isKeyword(sqlName)) { - return sqlName; - } + private static String memberName(String sqlName, String queryName) { + String name = lowerCamelCase(sqlName, queryName); + + return isKeyword(name) ? name + "_" : name; + } + /** + * Joins the words of a SQL name in upper camel case, as in + * {@code get_author} to {@code GetAuthor} and {@code user_ID} to + * {@code UserId}. + */ + private static String upperCamelCase(String sqlName, String queryName) { StringBuilder builder = new StringBuilder(); - boolean invalidRun = false; + + for (String word : words(sqlName, queryName)) { + int first = word.codePointAt(0); + + builder + .appendCodePoint(Character.toUpperCase(first)) + .append(word.substring(Character.charCount(first))); + } + + return startIdentifier(builder.toString()); + } + + /** + * Joins the words of a SQL name in lower camel case, as in + * {@code created_at} to {@code createdAt} and {@code GetAuthor} to + * {@code getAuthor}. + */ + private static String lowerCamelCase(String sqlName, String queryName) { + return decapitalize(upperCamelCase(sqlName, queryName)); + } + + /** + * Lower-cases the leading upper-case run of an upper-camel-case name. A run + * of two or more letters that is followed by a lower-case letter keeps its + * last letter upper-case, because that letter starts the next word: + * {@code HTTPStatus} becomes {@code httpStatus} while {@code HTTP2Status} + * becomes {@code http2Status}. + */ + private static String decapitalize(String name) { + int end = 0; + int letters = 0; + + while (end < name.length() && Character.isUpperCase(name.codePointAt(end))) { + end += Character.charCount(name.codePointAt(end)); + letters++; + } + + if (letters == 0) { + return name; + } + + boolean startsNextWord = letters > 1 + && end < name.length() + && Character.isLowerCase(name.codePointAt(end)); + + int lowerCaseEnd = startsNextWord + ? name.offsetByCodePoints(end, -1) + : end; + + return name.substring(0, lowerCaseEnd).toLowerCase(Locale.ROOT) + + name.substring(lowerCaseEnd); + } + + /** + * Splits a SQL name into the words every generated name is built from. A + * word ends at every character that is not a letter or a digit, and a word + * written without a lower-case letter is lower-cased so that an acronym + * such as {@code ID} or {@code URL} becomes one ordinary word. + */ + private static List words(String sqlName, String queryName) { + List words = new ArrayList<>(); + StringBuilder word = new StringBuilder(); int index = 0; while (index < sqlName.length()) { int codePoint = sqlName.codePointAt(index); index += Character.charCount(codePoint); - if (Character.isJavaIdentifierPart(codePoint)) { - if (invalidRun) { - builder.append('_'); - invalidRun = false; - } - - builder.appendCodePoint(codePoint); + if (Character.isLetterOrDigit(codePoint)) { + word.appendCodePoint(codePoint); continue; } - invalidRun = true; + addWord(words, word); } - if (invalidRun) { - builder.append('_'); + addWord(words, word); + + if (words.isEmpty()) { + throw new IllegalArgumentException( + queryName == null + ? "SQL name '%s' has no letter or digit to generate a Java name from" + .formatted(sqlName) + : "SQL name '%s' of query '%s' has no letter or digit to generate a Java name from" + .formatted(sqlName, queryName) + ); } - if (builder.isEmpty() || !Character.isJavaIdentifierStart(builder.codePointAt(0))) { - builder.insert(0, '_'); + return words; + } + + private static void addWord(List words, StringBuilder word) { + if (word.isEmpty()) { + return; } - String name = builder.toString(); + String value = word.toString(); - return isKeyword(name) ? name + "_" : name; + words.add( + value.codePoints().anyMatch(Character::isLowerCase) + ? value + : value.toLowerCase(Locale.ROOT) + ); + + word.setLength(0); + } + + /** A name built from a digit-initial SQL name cannot start an identifier. */ + private static String startIdentifier(String name) { + return Character.isDigit(name.codePointAt(0)) + ? "_" + name + : name; } private static boolean isKeyword(String name) { diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java index 280a6fa..44f1cdf 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -1067,6 +1067,56 @@ void shouldAnalyzeAliasedSingleTableSelect() { ); } + /** + * An explicit projection alias names the result column, while its type and + * nullability still come from the schema column. + */ + @Test + void shouldNameSelectedColumnAfterItsAlias() { + String sql = """ + SELECT id AS user_id, name AS "user name" + FROM users + """; + + QueryModel model = analyzer.analyze( + new Query("ListUsers", QueryType.MANY, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of( + new QueryColumn("user_id", ColumnType.BIGINT, false), + new QueryColumn("user name", ColumnType.VARCHAR, true) + ), + model.columns() + ); + } + + @Test + void shouldNameJoinedSelectedColumnsAfterTheirAliases() { + String sql = """ + SELECT u.id AS user_id, p.id AS profile_id, p.nickname + FROM users u + JOIN profiles p ON p.user_id = u.id + """; + + QueryModel model = analyzer.analyze( + new Query("ListUserProfiles", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals( + List.of( + new QueryColumn("user_id", ColumnType.BIGINT, false), + new QueryColumn("profile_id", ColumnType.BIGINT, false), + new QueryColumn("nickname", ColumnType.VARCHAR, true) + ), + model.columns() + ); + } + @Test void shouldRejectTableNameHiddenByAlias() { String sql = """ diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java index 31de188..5d147fe 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -245,8 +245,8 @@ void shouldExecuteGeneratedOneQueryAgainstPostgres() throws Exception { "name", "bio", "active", - "birth_date", - "created_at", + "birthDate", + "createdAt", "balance" ), recordComponentNames(result) @@ -258,10 +258,10 @@ void shouldExecuteGeneratedOneQueryAgainstPostgres() throws Exception { assertEquals("Alice", component(result, "name")); assertEquals("first user", component(result, "bio")); assertEquals(true, component(result, "active")); - assertEquals(LocalDate.of(1990, 1, 15), component(result, "birth_date")); + assertEquals(LocalDate.of(1990, 1, 15), component(result, "birthDate")); assertEquals( LocalDateTime.of(2026, 1, 1, 10, 0), - component(result, "created_at") + component(result, "createdAt") ); assertEquals( new BigDecimal("100.50"), @@ -363,10 +363,10 @@ INSERT INTO users (id, code, name, birth_date, created_at, balance) assertEquals(5L, component(result, "id")); assertEquals(42, component(result, "code")); assertEquals("Alice", component(result, "name")); - assertEquals(LocalDate.of(1990, 1, 15), component(result, "birth_date")); + assertEquals(LocalDate.of(1990, 1, 15), component(result, "birthDate")); assertEquals( LocalDateTime.of(2026, 1, 1, 10, 0), - component(result, "created_at") + component(result, "createdAt") ); assertEquals(new BigDecimal("100.50"), component(result, "balance")); } @@ -441,11 +441,11 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception { assertNull(component(result, "name")); assertNull(component(result, "bio")); assertNull(component(result, "active")); - assertNull(component(result, "birth_date")); - assertNull(component(result, "created_at")); + assertNull(component(result, "birthDate")); + assertNull(component(result, "createdAt")); assertNull(component(result, "balance")); - assertNull(component(result, "external_id")); - assertNull(component(result, "updated_at")); + assertNull(component(result, "externalId")); + assertNull(component(result, "updatedAt")); } } @@ -502,18 +502,18 @@ INSERT INTO users (id, code, serial_id, revision, external_id, updated_at) assertNotNull(result); assertEquals( - List.of("id", "serial_id", "revision", "external_id", "updated_at"), + List.of("id", "serialId", "revision", "externalId", "updatedAt"), recordComponentNames(result) ); assertEquals(7L, component(result, "id")); - assertEquals(101, component(result, "serial_id")); + assertEquals(101, component(result, "serialId")); assertEquals(202L, component(result, "revision")); - assertEquals(EXTERNAL_ID, component(result, "external_id")); + assertEquals(EXTERNAL_ID, component(result, "externalId")); OffsetDateTime updatedAt = assertInstanceOf( OffsetDateTime.class, - component(result, "updated_at") + component(result, "updatedAt") ); assertEquals(UPDATED_AT.toInstant(), updatedAt.toInstant()); @@ -553,12 +553,12 @@ void shouldExecuteGeneratedQueryForSnapshotWithIgnoredTableConstraints() throws assertNotNull(result); assertEquals( - List.of("id", "customer_id", "quantity", "status"), + List.of("id", "customerId", "quantity", "status"), recordComponentNames(result) ); assertEquals(10L, component(result, "id")); - assertEquals(1L, component(result, "customer_id")); + assertEquals(1L, component(result, "customerId")); assertEquals(1, component(result, "quantity")); assertEquals("new", component(result, "status")); } @@ -597,8 +597,8 @@ INSERT INTO users (id, code, name) assertNotNull(serialIdResult); - assertEquals(List.of("serial_id"), recordComponentNames(serialIdResult)); - assertEquals(1, component(serialIdResult, "serial_id")); + assertEquals(List.of("serialId"), recordComponentNames(serialIdResult)); + assertEquals(1, component(serialIdResult, "serialId")); Method rowMethod = repository.getClass().getMethod( "insertUserReturningRow", @@ -619,13 +619,13 @@ INSERT INTO users (id, code, name) "name", "bio", "active", - "birth_date", - "created_at", + "birthDate", + "createdAt", "balance", - "serial_id", + "serialId", "revision", - "external_id", - "updated_at" + "externalId", + "updatedAt" ), recordComponentNames(row) ); @@ -633,11 +633,11 @@ INSERT INTO users (id, code, name) assertEquals(2L, component(row, "id")); assertEquals(43, component(row, "code")); assertEquals("Bob", component(row, "name")); - assertEquals(2, component(row, "serial_id")); + assertEquals(2, component(row, "serialId")); assertEquals(2L, component(row, "revision")); assertNull(component(row, "score")); assertNull(component(row, "bio")); - assertNull(component(row, "external_id")); + assertNull(component(row, "externalId")); } } 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 5734410..b551e7c 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -151,11 +151,11 @@ WHERE id IN ($1, $2) .count() ); - assertTrue(repository.contains("public GetUserResult getUser(LocalDateTime created_at)")); + assertTrue(repository.contains("public GetUserResult getUser(LocalDateTime createdAt)")); 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("LocalDate birthDate")); + assertTrue(repository.contains("LocalDateTime createdAt")); assertTrue(repository.contains("BigDecimal balance")); assertTrue(repository.contains("private static final RowMapper getUserRowMapper")); assertTrue(repository.contains("resultSet.getObject(1, Long.class)")); @@ -165,9 +165,19 @@ WHERE id IN ($1, $2) 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("public List listUsers(LocalDateTime createdAt)")); assertTrue(repository.contains("private static final RowMapper listUsersRowMapper")); + assertEquals( + List.of( + "Long id", + "LocalDate birthDate", + "LocalDateTime createdAt", + "BigDecimal balance" + ), + recordComponents(repository, "ListUsersResult") + ); + assertTrue( repository .contains("public List findUsers(Long id1, Long id2, Boolean active, String name)") @@ -1000,6 +1010,70 @@ void shouldExecuteGeneratedJoinQueryWithDuplicateColumnNames() throws Exception } } + /** + * An explicit projection alias names the generated record component, while + * the row mapper keeps reading each column by its projection position. + */ + @Test + void shouldExecuteGeneratedJoinQueryWithProjectionAliases() throws Exception { + Path classesDirectory = generateAndCompile( + JOIN_SCHEMA, + """ + -- name: ListUserProfiles :many + SELECT u.id AS user_id, p.id AS profile_id, p.nickname + FROM users u + JOIN profiles p ON p.user_id = u.id + WHERE u.id = $1; + """ + ); + + String source = Files.readString(tempDir.resolve("generated/generated/UsersRepository.java")); + + assertTrue( + source.contains( + "public List listUserProfiles(Long id)" + ) + ); + + assertEquals( + List.of("Long userId", "Long profileId", "String nickname"), + recordComponents(source, "ListUserProfilesResult") + ); + + assertTrue(source.contains("resultSet.getObject(1, Long.class)")); + assertTrue(source.contains("resultSet.getObject(2, Long.class)")); + assertTrue(source.contains("resultSet.getObject(3, String.class)")); + + QueryExecutor executor = new JdbcQueryExecutor(joinDataSource()); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Class generatedClass = Class.forName( + "generated.UsersRepository", + true, + classLoader + ); + + Object results = generatedClass + .getMethod("listUserProfiles", Long.class) + .invoke( + generatedClass + .getConstructor(QueryExecutor.class) + .newInstance(executor), + 1L + ); + + List rows = assertInstanceOf(List.class, results); + + assertEquals(1, rows.size()); + + Object row = rows.getFirst(); + + assertEquals(1L, getRecordComponent(row, "userId")); + assertEquals(10L, getRecordComponent(row, "profileId")); + assertEquals("ali", getRecordComponent(row, "nickname")); + } + } + @Test void shouldExecuteGeneratedMultipleJoinQuery() throws Exception { Path classesDirectory = generateAndCompile( @@ -1130,7 +1204,7 @@ total DECIMAL(10, 2), 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("public List listOrders(LocalDateTime createdAt)")); assertTrue(orders.contains("BigDecimal total")); Files.createDirectories(classesDirectory); @@ -1418,7 +1492,7 @@ void shouldRejectRepeatedRepositoryMethodBeforeWriting() throws IOException { assertEquals( "Invalid query group 'Users' in %s: ".formatted(tempDir.resolve("queries.sql")) - + "Queries 'Get.User' and 'Get-User' generate the same repository method 'get_User'", + + "Queries 'Get.User' and 'Get-User' generate the same repository method 'getUser'", exception.getMessage() ); @@ -1454,7 +1528,7 @@ void shouldRejectRepositoryResultTypesThatDifferOnlyByCaseBeforeWriting() throws assertEquals( "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", + + "GetUserResult and GetuserResult", exception.getMessage() ); @@ -1479,8 +1553,12 @@ void shouldRejectDuplicateRepositoryBeforeWriting() throws IOException { assertFalse(Files.exists(generatedDirectory)); } + /** + * Two group names that differ only by the case of their first character + * generate one repository name, so they collide as a duplicate path. + */ @Test - void shouldRejectRepositoryPathsThatDifferOnlyByCaseBeforeWriting() throws IOException { + void shouldRejectRepositoryNamesThatDifferOnlyByTheirFirstCharacterCase() throws IOException { Path generatedDirectory = tempDir.resolve("generated"); CompilationException exception = assertThrows( @@ -1489,10 +1567,28 @@ void shouldRejectRepositoryPathsThatDifferOnlyByCaseBeforeWriting() throws IOExc ); assertEquals( - "Generated file paths for repositories 'Users' and 'users' differ only by case: " - + Path.of("generated", "UsersRepository.java") + "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("UserData", "Userdata", generatedDirectory) + ); + + assertEquals( + "Generated file paths for repositories 'UserData' and 'Userdata' differ only by case: " + + Path.of("generated", "UserDataRepository.java") + " and " - + Path.of("generated", "usersRepository.java"), + + Path.of("generated", "UserdataRepository.java"), exception.getMessage() ); @@ -1549,7 +1645,7 @@ void shouldGenerateCompilableJavaForQuotedSqlIdentifiers() throws IOException { String source = Files.readString(generatedFile); - assertTrue(source.contains("Long user_id")); + assertTrue(source.contains("Long userId")); assertTrue(source.contains("String class_")); assertTrue(source.contains("resultSet.getObject(1, Long.class)")); assertTrue(source.contains("resultSet.getObject(2, String.class)")); @@ -1919,6 +2015,25 @@ INSERT INTO orders (id, user_id, total) return dataSource; } + /** The declared components of one generated record, in declared order. */ + private List recordComponents(String source, String recordName) { + int start = source.indexOf("public record " + recordName + "("); + + assertTrue(start >= 0); + + return source.substring(start, source.indexOf(") {", start)) + .lines() + .skip(1) + .map(String::strip) + .filter(component -> !component.isEmpty()) + .map( + component -> component.endsWith(",") + ? component.substring(0, component.length() - 1) + : component + ) + .toList(); + } + private Object getRecordComponent(Object record, String componentName) throws Exception { return record .getClass() 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 53bc698..2f5d5b2 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java @@ -76,8 +76,8 @@ void shouldNormalizeUnsafeQueryNameAndEscapeJavadoc() throws IOException { 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("public record GetUserResult(")); + assertTrue(source.contains("public List getUser()")); assertTrue(source.contains("Query: Get*/User")); assertFalse(source.contains("Query: Get*/User")); @@ -121,8 +121,8 @@ void shouldGenerateResultTypeForQueryNamedLikeAnImportedUuidType() throws IOExce assertEquals(Path.of("generated", "UsersRepository.java"), file.path()); assertTrue(source.contains("import java.util.UUID;")); assertTrue(source.contains("public final class UsersRepository {")); - assertTrue(source.contains("public record UUIDResult(")); - assertTrue(source.contains("UUID external_id")); + assertTrue(source.contains("public record UuidResult(")); + assertTrue(source.contains("UUID externalId")); assertTrue(source.contains("resultSet.getObject(1, UUID.class)")); assertCompiles(file); @@ -147,8 +147,8 @@ void shouldReadRenamedResultComponentsByProjectionPosition() throws IOException assertTrue(source.contains("Long class_")); assertTrue(source.contains("String hashCode1")); - assertTrue(source.contains("String user_id1")); - assertTrue(source.contains("String user_id2")); + assertTrue(source.contains("String userId1")); + assertTrue(source.contains("String userId2")); assertTrue(source.contains("resultSet.getObject(1, Long.class)")); assertTrue(source.contains("resultSet.getObject(2, String.class)")); @@ -170,7 +170,7 @@ void shouldReadQuotedSqlColumnByProjectionPosition() throws IOException { String source = file.content(); - assertTrue(source.contains("String user_id")); + assertTrue(source.contains("String userId")); assertTrue(source.contains("resultSet.getObject(1, String.class)")); assertCompiles(file); @@ -194,11 +194,11 @@ void shouldUseResolvedParameterNamesInSignatureAndArguments() throws IOException assertTrue( source.contains( - "public List findUsers(Long executor1, String user_id1, String user_id2)" + "public List findUsers(Long executor1, String userId1, String userId2)" ) ); - assertTrue(source.contains("java.util.Arrays.asList(executor1, user_id1, user_id2)")); + assertTrue(source.contains("java.util.Arrays.asList(executor1, userId1, userId2)")); assertCompiles(file); } @@ -223,7 +223,7 @@ void shouldExecuteExactSqlForQuotedIdentifierWithBackslash() throws Exception { GeneratedFile file = generate(query); assertTrue(file.content().contains("SELECT \"a\\\\q\"")); - assertTrue(file.content().contains("String a_q")); + assertTrue(file.content().contains("String aq")); assertCompiles(file); 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 37ef932..62bfdc8 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java @@ -579,8 +579,8 @@ void shouldGenerateImportsForUuidAndTimestampWithTimeZone() throws IOException { ) ); - assertTrue(source.contains("UUID external_id")); - assertTrue(source.contains("OffsetDateTime created_at")); + assertTrue(source.contains("UUID externalId")); + assertTrue(source.contains("OffsetDateTime createdAt")); assertTrue(source.contains("resultSet.getObject(1, UUID.class)")); assertTrue(source.contains("resultSet.getObject(2, OffsetDateTime.class)")); 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 73d8dd6..f06ed72 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaNamesTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaNamesTest.java @@ -21,15 +21,21 @@ class JavaNamesTest { private static final String SQL = "SELECT 1"; - @Test - void shouldNameRepositoryAfterConfiguredGroupName() { - JavaNames names = names("Author", "GetAuthor"); + @ParameterizedTest + @CsvSource( + { + "Author, AuthorRepository", + "author_admin, AuthorAdminRepository" + } + ) + void shouldNameRepositoryAfterConfiguredGroupName(String groupName, String expectedClassName) { + JavaNames names = names(groupName, "GetAuthor"); - assertEquals("AuthorRepository", names.repositoryClassName()); + assertEquals(expectedClassName, names.repositoryClassName()); } @Test - void shouldKeepSafeQueryNameAsResultTypeAndMethodName() { + void shouldNameResultTypeAndRowMapperAfterQueryName() { JavaNames.QueryNames names = queryNames("GetUser"); assertEquals("GetUserResult", names.resultTypeName()); @@ -37,14 +43,6 @@ void shouldKeepSafeQueryNameAsResultTypeAndMethodName() { assertEquals("getUserRowMapper", names.rowMapperName()); } - @Test - void shouldKeepSafeLowercaseQueryNameAsMethodName() { - JavaNames.QueryNames names = queryNames("getUser"); - - assertEquals("getUserResult", names.resultTypeName()); - assertEquals("getUser", names.methodName()); - } - @Test void shouldKeepKeywordMethodNameSafe() { JavaNames.QueryNames names = queryNames("Class"); @@ -56,21 +54,97 @@ void shouldKeepKeywordMethodNameSafe() { @ParameterizedTest @CsvSource( { - "Get-User, Get_UserResult", - "get user, get_userResult", - "'Get**User', Get_UserResult", - "'get user ', get_user_Result", + "authors, AuthorsResult", + "get_author, GetAuthorResult", + "GetAuthor, GetAuthorResult", + "user_ID, UserIdResult", + "URL, UrlResult", + "Get-User, GetUserResult", + "get user, GetUserResult", + "'Get**User', GetUserResult", + "'get user ', GetUserResult", + "1st_query, _1stQueryResult", "1stQuery, _1stQueryResult", - "default, default_Result", - "int, int_Result", - "'true', true_Result", - "_, __Result" + "default, DefaultResult", + "int, IntResult", + "'true', TrueResult" } ) - void shouldNormalizeUnsafeQueryName(String queryName, String expectedResultTypeName) { + void shouldNameResultTypeInUpperCamelCase(String queryName, String expectedResultTypeName) { assertEquals(expectedResultTypeName, queryNames(queryName).resultTypeName()); } + @ParameterizedTest + @CsvSource( + { + "get_author, getAuthor", + "GetAuthor, getAuthor", + "getAuthor, getAuthor", + "HTTPStatus, httpStatus", + "HTTP2Status, http2Status", + "GetHTTPStatus, getHTTPStatus", + "created_at, createdAt", + "user_ID, userId", + "1st_query, _1stQuery", + "default, default_", + "'true', true_" + } + ) + void shouldNameMethodInLowerCamelCase(String queryName, String expectedMethodName) { + assertEquals(expectedMethodName, queryNames(queryName).methodName()); + } + + @ParameterizedTest + @CsvSource( + { + "created_at, createdAt", + "'user id', userId", + "user_ID, userId", + "ID, id", + "URL, url", + "HTTPStatus, httpStatus", + "class, class_" + } + ) + void shouldNameResultComponentInLowerCamelCase(String columnName, String expectedComponentName) { + JavaNames.QueryNames names = queryNames("ListUsers", List.of(), List.of(columnName)); + + assertEquals(List.of(expectedComponentName), names.componentNames()); + } + + @ParameterizedTest + @CsvSource( + { + "_", + "'***'", + "$" + } + ) + void shouldRejectQueryNameWithoutAWord(String queryName) { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> queryNames(queryName) + ); + + assertEquals( + "SQL name '%s' has no letter or digit to generate a Java name from".formatted(queryName), + exception.getMessage() + ); + } + + @Test + void shouldRejectColumnNameWithoutAWord() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> queryNames("ListUsers", List.of(), List.of("***")) + ); + + assertEquals( + "SQL name '***' of query 'ListUsers' has no letter or digit to generate a Java name from", + exception.getMessage() + ); + } + @ParameterizedTest @CsvSource( { @@ -88,11 +162,11 @@ void shouldRenameMethodConflictingWithInheritedObjectMethod(String queryName, St void shouldRejectQueriesOfOneGroupThatGenerateTheSameMethod() { IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, - () -> names("Author", "Get.User", "Get-User") + () -> names("Author", "get_author", "GetAuthor") ); assertEquals( - "Queries 'Get.User' and 'Get-User' generate the same repository method 'get_User'", + "Queries 'get_author' and 'GetAuthor' generate the same repository method 'getAuthor'", exception.getMessage() ); } @@ -110,7 +184,7 @@ void shouldRejectQueriesOfOneGroupWhoseResultTypesDifferOnlyByCase() { assertEquals( "Queries 'GetUser' and 'getuser' generate result types that differ only by case: " - + "GetUserResult and getuserResult", + + "GetUserResult and GetuserResult", exception.getMessage() ); } @@ -129,10 +203,10 @@ void shouldKeepTheSameQueryNameInTwoRepositoriesIndependent() { } @Test - void shouldKeepSafeColumnNamesAsResultComponents() { + void shouldKeepSelectedColumnOrderOfResultComponents() { JavaNames.QueryNames names = queryNames("ListUsers", List.of(), List.of("id", "created_at")); - assertEquals(List.of("id", "created_at"), names.componentNames()); + assertEquals(List.of("id", "createdAt"), names.componentNames()); } @Test @@ -144,7 +218,7 @@ void shouldNormalizeAndDisambiguateResultComponents() { ); assertEquals( - List.of("user_id1", "user_id2", "class_", "hashCode1"), + List.of("userId1", "userId2", "class_", "hashCode1"), names.componentNames() ); }