diff --git a/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java b/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java index a836b11..16a26c5 100644 --- a/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -74,7 +74,7 @@ private QueryModel analyzeSelect(Query query, Select select, Schema schema) { return new QueryModel( query.name(), query.type(), - table.getName(), + table.getUnquotedName(), toExecutableSql(query.sql()), bindingParameterIndexes, columns, @@ -104,7 +104,7 @@ private List resolveBindingParameters(PlainSelect plainSelect, S return List.of(); } - dev.sqlcj.schema.Table schemaTable = findTable(schema, table.getName()); + dev.sqlcj.schema.Table schemaTable = findTable(schema, table.getUnquotedName()); List parameters = new ArrayList<>(); @@ -165,7 +165,7 @@ private void resolveInExpression(InExpression in, dev.sqlcj.schema.Table table, return; } - dev.sqlcj.schema.Column schemaColumn = findColumn(table, column.getColumnName()); + dev.sqlcj.schema.Column schemaColumn = findColumn(table, column.getUnquotedColumnName()); Expression rightExpression = in.getRightExpression(); @@ -261,7 +261,7 @@ private void resolveParameterComparison( ) { addParameter( parameter, - column.getColumnName(), + column.getUnquotedColumnName(), table, parameters ); @@ -274,7 +274,7 @@ private void resolveParameterComparison( ) { addParameter( parameter, - column.getColumnName(), + column.getUnquotedColumnName(), table, parameters ); @@ -313,7 +313,7 @@ private void addParameter( } private List resolveColumns(PlainSelect plainSelect, Schema schema, Table table) { - dev.sqlcj.schema.Table schemaTable = findTable(schema, table.getName()); + dev.sqlcj.schema.Table schemaTable = findTable(schema, table.getUnquotedName()); List columns = new ArrayList<>(); @@ -324,7 +324,7 @@ private List resolveColumns(PlainSelect plainSelect, Schema schema, } if (selectItem.getExpression() instanceof net.sf.jsqlparser.schema.Column column) { - dev.sqlcj.schema.Column schemaColumn = findColumn(schemaTable, column.getColumnName()); + dev.sqlcj.schema.Column schemaColumn = findColumn(schemaTable, column.getUnquotedColumnName()); columns.add( new QueryColumn( diff --git a/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java b/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java index ddc95bb..6fef50b 100644 --- a/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java +++ b/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java @@ -17,9 +17,10 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.Path; -import java.util.HashSet; +import java.util.HashMap; import java.util.List; -import java.util.Set; +import java.util.Locale; +import java.util.Map; public final class SqlcjCompiler { @@ -36,7 +37,7 @@ public void compile(Config config) { Path outputDirectory = Path.of(config.java().out()); - Set generatedPaths = new HashSet<>(); + Map generatedQueries = new HashMap<>(); for (Source source : sources) { Schema schema = schemaParser.parse(source.schema()); @@ -47,7 +48,7 @@ public void compile(Config config) { schema, codeGenerator, outputDirectory, - generatedPaths + generatedQueries ); } } @@ -58,19 +59,57 @@ private void compileQuery( Schema schema, CodeGenerator codeGenerator, Path outputDirectory, - Set generatedPaths + Map generatedQueries ) { Statement statement = sqlParser.parse(query.sql()); QueryModel model = queryAnalyzer.analyze(query, statement, schema); GeneratedFile file = codeGenerator.generate(model); - if (!generatedPaths.add(file.path())) { + checkGeneratedPath(query, file, generatedQueries); + + write(file, outputDirectory); + } + + /** + * Rejects a generated path that repeats, or differs only by case from, an + * already generated path before the earlier file can be overwritten. + */ + private void checkGeneratedPath( + Query query, + GeneratedFile file, + Map generatedQueries + ) { + String portabilityKey = file.path() + .toString() + .toLowerCase(Locale.ROOT); + + GeneratedQuery generated = generatedQueries.get(portabilityKey); + + if (generated == null) { + generatedQueries.put( + portabilityKey, + new GeneratedQuery(query.name(), file.path()) + ); + + return; + } + + if (generated.path().equals(file.path())) { throw new CompilationException( - "Duplicate generated file: " + file.path() + "Duplicate generated file for queries '%s' and '%s': %s" + .formatted(generated.queryName(), query.name(), file.path()) ); } - write(file, outputDirectory); + throw new CompilationException( + "Generated file paths for queries '%s' and '%s' differ only by case: %s and %s" + .formatted( + generated.queryName(), + query.name(), + generated.path(), + file.path() + ) + ); } private void write(GeneratedFile file, Path outputDirectory) { @@ -80,4 +119,7 @@ private void write(GeneratedFile file, Path outputDirectory) { throw new UncheckedIOException(e); } } + + private record GeneratedQuery(String queryName, Path path) { + } } diff --git a/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java b/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java index c61bfe5..7558a6d 100644 --- a/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java +++ b/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java @@ -10,10 +10,8 @@ import java.nio.file.Path; import java.util.Arrays; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; -import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -37,14 +35,16 @@ public JavaCodeGenerator(String packageName) { @Override public GeneratedFile generate(QueryModel query) { + JavaNames names = JavaNames.of(query); + return new GeneratedFile( - buildPath(query), - generateSource(query) + buildPath(names), + generateSource(query, names) ); } - private Path buildPath(QueryModel query) { - return packageDirectory().resolve(query.name() + ".java"); + private Path buildPath(JavaNames names) { + return packageDirectory().resolve(names.className() + ".java"); } private Path packageDirectory() { @@ -56,7 +56,7 @@ private Path packageDirectory() { ); } - private String generateSource(QueryModel query) { + private String generateSource(QueryModel query, JavaNames names) { return """ %s @@ -70,7 +70,7 @@ private String generateSource(QueryModel query) { generatePackage(), generateImports(query), generateJavaDoc(query), - generateClass(query) + generateClass(query, names) ); } @@ -130,15 +130,15 @@ private String generateJavaDoc(QueryModel query) { */ """ .formatted( - query.name(), - query.table(), + escapeJavadoc(query.name()), + escapeJavadoc(query.table()), query.type() ); } - private String generateClass(QueryModel query) { + private String generateClass(QueryModel query, JavaNames names) { String rowMapper = hasResult(query) - ? indent(generateRowMapper(query)) + ? indent(generateRowMapper(query, names)) : ""; return """ @@ -156,12 +156,12 @@ public final class %s { } """ .formatted( - query.name(), + names.className(), indent(generateExecutorField()), - indent(generateConstructor(query)), - indent(generateResultType(query)), + indent(generateConstructor(names)), + indent(generateResultType(query, names)), rowMapper, - indent(generateMethod(query)) + indent(generateMethod(query, names)) ); } @@ -169,7 +169,7 @@ private String indent(String text) { return text.indent(4).stripTrailing(); } - private String generateResultType(QueryModel query) { + private String generateResultType(QueryModel query, JavaNames names) { return """ public record %s( %s @@ -177,79 +177,52 @@ public record %s( } """ .formatted( - generateResultTypeName(query), - generateResultComponents(query) + names.resultTypeName(), + generateResultComponents(query, names) ); } - private String generateResultComponents(QueryModel query) { - return query.columns().stream() - .map(this::generateResultComponent) + private String generateResultComponents(QueryModel query, JavaNames names) { + return IntStream.range(0, query.columns().size()) + .mapToObj( + index -> generateResultComponent( + query.columns().get(index), + names.componentNames().get(index) + ) + ) .collect(Collectors.joining(",\n")); } - private String generateResultComponent(QueryColumn column) { + private String generateResultComponent(QueryColumn column, String name) { return "%s %s" .formatted( typeResolver.resolve(column.type()), - column.name() + name ) .indent(4) .stripTrailing(); } - private String generateMethodParameters(QueryModel query) { - List names = generateParameterNames(query); - + private String generateMethodParameters(QueryModel query, JavaNames names) { return IntStream.range(0, query.parameters().size()) - .mapToObj(i -> { - QueryParameter parameter = query.parameters().get(i); + .mapToObj(index -> { + QueryParameter parameter = query.parameters().get(index); return typeResolver.resolve(parameter.type()) + " " - + names.get(i); + + names.parameterNames().get(index); }) .collect(Collectors.joining(", ")); } - private List generateParameterNames(QueryModel query) { - Map occurrences = query.parameters().stream() - .collect( - Collectors.groupingBy( - QueryParameter::name, - Collectors.counting() - ) - ); - - Map indexes = new HashMap<>(); - - return query.parameters().stream() - .map(parameter -> { - String name = parameter.name(); - - if (occurrences.get(name) > 1) { - int occurrence = indexes.merge( - name, - 1, - Integer::sum - ); - - name += occurrence; - } - - return name; - }) - .toList(); - } - - private String generateMethod(QueryModel query) { + private String generateMethod(QueryModel query, JavaNames names) { return switch (query.type()) { - case ONE, MANY -> generateQueryMethod(query); - case EXEC, EXEC_RESULT, BATCH_EXEC, BATCH_MANY, BATCH_ONE -> generateUnsupportedMethod(query); + case ONE, MANY -> generateQueryMethod(query, names); + case EXEC, EXEC_RESULT, BATCH_EXEC, BATCH_MANY, BATCH_ONE -> generateUnsupportedMethod(query, names); }; } - private String generateQueryMethod(QueryModel query) { + private String generateQueryMethod(QueryModel query, JavaNames names) { return """ public %s %s(%s) { return executor.%s( @@ -260,25 +233,25 @@ private String generateQueryMethod(QueryModel query) { } """ .formatted( - generateReturnType(query), - generateMethodName(query), - generateMethodParameters(query), + generateReturnType(query, names), + names.methodName(), + generateMethodParameters(query, names), generateExecutorMethod(query), generateSql(query), - generateParameterList(query) + generateParameterList(query, names) ); } - private String generateUnsupportedMethod(QueryModel query) { + private String generateUnsupportedMethod(QueryModel query, JavaNames names) { return """ public %s %s(%s) { throw new UnsupportedOperationException("Not implemented"); } """ .formatted( - generateReturnType(query), - generateMethodName(query), - generateMethodParameters(query) + generateReturnType(query, names), + names.methodName(), + generateMethodParameters(query, names) ); } @@ -294,45 +267,90 @@ private String generateExecutorMethod(QueryModel query) { private String generateSql(QueryModel query) { return "\"\"\"\n" - + query.executableSql() + + escapeTextBlock(query.executableSql()) + "\"\"\""; } + /** + * Escapes the executable SQL rendered inside the generated text block so + * that the compiled string is exactly the analyzed SQL, including + * backslashes, embedded text-block delimiters, and line-trailing + * whitespace. + */ + private String escapeTextBlock(String sql) { + StringBuilder builder = new StringBuilder(); + int quotes = 0; + + for (int index = 0; index < sql.length(); index++) { + char character = sql.charAt(index); + boolean lineEnd = isLineEnd(sql, index); + + if (character == '"') { + quotes++; + + if (quotes == 3 || index + 1 == sql.length()) { + builder.append("\\\""); + quotes = 0; + continue; + } + + builder.append('"'); + continue; + } + + quotes = 0; + + switch (character) { + case '\\' -> builder.append("\\\\"); + case '\r' -> builder.append("\\r"); + case ' ' -> builder.append(lineEnd ? "\\s" : " "); + case '\t' -> builder.append(lineEnd ? "\\t" : "\t"); + default -> builder.append(character); + } + } + + return builder.toString(); + } + + private boolean isLineEnd(String sql, int index) { + if (index + 1 == sql.length()) { + return true; + } + + char next = sql.charAt(index + 1); + + return next == '\n' || next == '\r'; + } + /** * Renders the executor arguments in the textual order of the JDBC * {@code ?} positions, using the logically ordered method parameter names. */ - private String generateParameterList(QueryModel query) { - Map namesByIndex = generateParameterNamesByIndex(query); + private String generateParameterList(QueryModel query, JavaNames names) { + Map namesByIndex = generateParameterNamesByIndex(query, names); return query.bindingParameterIndexes().stream() .map(namesByIndex::get) .collect(Collectors.joining(", ", "List.of(", ")")); } - private Map generateParameterNamesByIndex(QueryModel query) { - List names = generateParameterNames(query); - + private Map generateParameterNamesByIndex(QueryModel query, JavaNames names) { Map namesByIndex = new LinkedHashMap<>(); - for (int i = 0; i < query.parameters().size(); i++) { + for (int index = 0; index < query.parameters().size(); index++) { namesByIndex.put( - query.parameters().get(i).index(), - names.get(i) + query.parameters().get(index).index(), + names.parameterNames().get(index) ); } return namesByIndex; } - private String generateMethodName(QueryModel query) { - return Character.toLowerCase(query.name().charAt(0)) + query.name().substring(1); - } - - private String generateReturnType(QueryModel query) { + private String generateReturnType(QueryModel query, JavaNames names) { return switch (query.type()) { - case ONE -> generateResultTypeName(query); - case MANY -> "List<" + generateResultTypeName(query) + ">"; + case ONE -> names.resultTypeName(); + case MANY -> "List<" + names.resultTypeName() + ">"; case EXEC -> "void"; case EXEC_RESULT -> "void"; case BATCH_EXEC -> "void"; @@ -341,11 +359,7 @@ private String generateReturnType(QueryModel query) { }; } - private String generateResultTypeName(QueryModel query) { - return query.name() + "Result"; - } - - private String generateRowMapper(QueryModel query) { + private String generateRowMapper(QueryModel query, JavaNames names) { return """ private static final RowMapper<%s> ROW_MAPPER = resultSet -> new %s( @@ -353,8 +367,8 @@ private String generateRowMapper(QueryModel query) { ); """ .formatted( - generateResultTypeName(query), - generateResultTypeName(query), + names.resultTypeName(), + names.resultTypeName(), generateResultMappings(query) ); } @@ -370,7 +384,7 @@ private String generateResultMapping(QueryColumn column) { return "resultSet.getObject(\"%s\", %s.class)" .formatted( - column.name(), + escapeStringLiteral(column.name()), javaType ) .indent(8) @@ -383,12 +397,48 @@ private String generateExecutorField() { """; } - private String generateConstructor(QueryModel query) { + private String generateConstructor(JavaNames names) { return """ public %s(QueryExecutor executor) { this.executor = executor; } """ - .formatted(query.name()); + .formatted(names.className()); + } + + /** + * Escapes a SQL-derived value rendered inside a generated Java string + * literal, such as a JDBC column label. + */ + private String escapeStringLiteral(String value) { + StringBuilder builder = new StringBuilder(); + + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + + switch (character) { + case '\\' -> builder.append("\\\\"); + case '"' -> builder.append("\\\""); + case '\b' -> builder.append("\\b"); + case '\f' -> builder.append("\\f"); + case '\n' -> builder.append("\\n"); + case '\r' -> builder.append("\\r"); + case '\t' -> builder.append("\\t"); + default -> builder.append(character); + } + } + + return builder.toString(); + } + + /** + * Escapes a SQL-derived value rendered inside the generated Javadoc block. + */ + private String escapeJavadoc(String value) { + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("*/", "*/"); } } diff --git a/src/main/java/dev/sqlcj/generator/JavaNames.java b/src/main/java/dev/sqlcj/generator/JavaNames.java new file mode 100644 index 0000000..fe59350 --- /dev/null +++ b/src/main/java/dev/sqlcj/generator/JavaNames.java @@ -0,0 +1,264 @@ +package dev.sqlcj.generator; + +import dev.sqlcj.analysis.QueryColumn; +import dev.sqlcj.analysis.QueryModel; +import dev.sqlcj.analysis.QueryParameter; + +import javax.lang.model.SourceVersion; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +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. + * + *

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. + */ +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", + "Boolean", + "Integer", + "Long", + "Short", + "String", + "permits", + "record", + "sealed", + "var", + "yield" + ); + + /** + * Inherited {@link Object} method names that a generated method or record + * component cannot use. + */ + private static final Set RESERVED_MEMBER_NAMES = Set.of( + "clone", + "finalize", + "getClass", + "hashCode", + "notify", + "notifyAll", + "toString", + "wait" + ); + + /** Generator-owned names referenced by the generated method body. */ + private static final Set RESERVED_PARAMETER_NAMES = Set.of( + "executor", + "ROW_MAPPER" + ); + + private final String className; + private final String methodName; + private final List parameterNames; + private final List componentNames; + + private JavaNames( + String className, + String methodName, + List parameterNames, + List componentNames + ) { + this.className = className; + this.methodName = methodName; + this.parameterNames = parameterNames; + this.componentNames = componentNames; + } + + static JavaNames of(QueryModel query) { + String className = className(query.name()); + + 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 + ) + ); + } + + String className() { + return className; + } + + String resultTypeName() { + return className + "Result"; + } + + String methodName() { + return methodName; + } + + List parameterNames() { + return parameterNames; + } + + List componentNames() { + return componentNames; + } + + private static String className(String queryName) { + String name = normalize(queryName); + + while (RESERVED_CLASS_NAMES.contains(name)) { + name += "_"; + } + + return name; + } + + private static String methodName(String className) { + String name = decapitalize(className); + + while (isKeyword(name) || RESERVED_MEMBER_NAMES.contains(name)) { + name += "_"; + } + + 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) { + List bases = sqlNames.stream() + .map(JavaNames::normalize) + .toList(); + + Map occurrences = bases.stream() + .collect( + Collectors.groupingBy( + Function.identity(), + Collectors.counting() + ) + ); + + Set used = new HashSet<>(); + List names = new ArrayList<>(bases.size()); + + for (String base : bases) { + names.add( + resolveName( + base, + occurrences.get(base) > 1, + reserved, + used + ) + ); + } + + return List.copyOf(names); + } + + private static String resolveName( + String base, + boolean repeated, + Set reserved, + Set used + ) { + if (!repeated && !reserved.contains(base) && !used.contains(base)) { + used.add(base); + return base; + } + + int suffix = 1; + String candidate = base + suffix; + + while (reserved.contains(candidate) || used.contains(candidate)) { + suffix++; + candidate = base + suffix; + } + + used.add(candidate); + + return candidate; + } + + /** + * Turns a SQL name into a valid Java identifier, preserving names that are + * already valid and non-reserved. + */ + private static String normalize(String sqlName) { + if (SourceVersion.isIdentifier(sqlName) && !isKeyword(sqlName)) { + return sqlName; + } + + StringBuilder builder = new StringBuilder(); + boolean invalidRun = false; + 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); + continue; + } + + invalidRun = true; + } + + if (invalidRun) { + builder.append('_'); + } + + if (builder.isEmpty() || !Character.isJavaIdentifierStart(builder.codePointAt(0))) { + builder.insert(0, '_'); + } + + String name = builder.toString(); + + return isKeyword(name) ? name + "_" : name; + } + + private static boolean isKeyword(String name) { + return SourceVersion.isKeyword(name, SOURCE_VERSION); + } +} diff --git a/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java b/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java index 00ccbfa..6ef6412 100644 --- a/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java +++ b/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java @@ -8,6 +8,7 @@ import dev.sqlcj.schema.Table; import net.sf.jsqlparser.JSQLParserException; import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.MultiPartName; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.Statements; import net.sf.jsqlparser.statement.create.table.ColumnDefinition; @@ -45,7 +46,7 @@ public Schema parse(String sql) { } private Table parseTable(CreateTable createTable) { - String tableName = createTable.getTable().getName(); + String tableName = createTable.getTable().getUnquotedName(); List columns = new ArrayList<>(); List constraints = new ArrayList<>(); @@ -65,7 +66,7 @@ private Table parseTable(CreateTable createTable) { } private Column parseColumn(ColumnDefinition definition) { - String name = definition.getColumnName(); + String name = columnName(definition); ColumnType type = parseColumnType(definition); boolean nullable = isNullable(definition); @@ -76,6 +77,13 @@ private Column parseColumn(ColumnDefinition definition) { ); } + /** + * Returns the canonical column name without SQL identifier delimiters. + */ + private String columnName(ColumnDefinition definition) { + return MultiPartName.unquote(definition.getColumnName()); + } + private ColumnType parseColumnType(ColumnDefinition definition) { String typeName = definition.getColDataType() .getDataType() @@ -136,7 +144,7 @@ private List parseColumnConstraints(ColumnDefinition definition) { constraints.add( new Constraint( ConstraintType.PRIMARY_KEY, - List.of(definition.getColumnName()) + List.of(columnName(definition)) ) ); } @@ -145,7 +153,7 @@ private List parseColumnConstraints(ColumnDefinition definition) { constraints.add( new Constraint( ConstraintType.UNIQUE, - List.of(definition.getColumnName()) + List.of(columnName(definition)) ) ); } @@ -177,7 +185,9 @@ private List parseTableConstraints(CreateTable createTable) { constraints.add( new Constraint( type, - index.getColumnsNames() + index.getColumnsNames().stream() + .map(MultiPartName::unquote) + .toList() ) ); } diff --git a/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java b/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java index ccbf1db..69b9879 100644 --- a/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -815,6 +815,64 @@ void shouldResolveBindingIndexesInTextualOrder() { ); } + @Test + void shouldResolveQuotedIdentifiersWithoutDelimiters() { + Schema quotedSchema = new Schema( + List.of( + new Table( + "user data", + List.of( + new Column("user id", ColumnType.BIGINT, false), + new Column("select", ColumnType.VARCHAR, true) + ), + List.of() + ) + ) + ); + + String sql = """ + SELECT "user id", "select" + FROM "user data" + WHERE "select" = $1 + """; + + Query query = new Query( + "ListUserData", + QueryType.MANY, + sql + ); + + QueryModel model = analyzer.analyze( + query, + parser.parse(sql), + quotedSchema + ); + + assertEquals("user data", model.table()); + + assertEquals( + List.of( + new QueryColumn("user id", ColumnType.BIGINT, false), + new QueryColumn("select", ColumnType.VARCHAR, true) + ), + model.columns() + ); + + assertEquals( + List.of(new QueryParameter(1, "select", ColumnType.VARCHAR)), + model.parameters() + ); + + assertEquals( + """ + SELECT "user id", "select" + FROM "user data" + WHERE "select" = ? + """, + model.executableSql() + ); + } + @Test void shouldResolveEmptyBindingIndexesWithoutParameters() { String sql = """ diff --git a/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java b/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java index aa2601d..3166172 100644 --- a/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -835,6 +835,195 @@ void shouldRejectDuplicateQueryNameAcrossEntriesBeforeWriting() throws IOExcepti assertFalse(Files.exists(generatedDirectory)); } + @Test + void shouldRejectNormalizedGeneratedPathCollisionBeforeOverwriting() throws IOException { + Path generatedDirectory = tempDir.resolve("generated"); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> compileUsersQueries( + """ + -- name: Get.User :one + SELECT id + FROM users + WHERE id = $1; + + -- name: Get-User :one + SELECT name + FROM users + WHERE id = $1; + """, + generatedDirectory + ) + ); + + assertEquals( + "Duplicate generated file for queries 'Get.User' and 'Get-User': " + + Path.of("generated", "Get_User.java"), + exception.getMessage() + ); + + String generated = Files.readString( + generatedDirectory.resolve("generated").resolve("Get_User.java") + ); + + assertTrue(generated.contains("resultSet.getObject(\"id\", Long.class)")); + assertFalse(generated.contains("resultSet.getObject(\"name\", String.class)")); + } + + @Test + void shouldRejectGeneratedPathsThatDifferOnlyByCaseBeforeOverwriting() throws IOException { + Path generatedDirectory = tempDir.resolve("generated"); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> compileUsersQueries( + """ + -- name: GetUser :one + SELECT id + FROM users + WHERE id = $1; + + -- name: getuser :one + SELECT name + FROM users + WHERE id = $1; + """, + generatedDirectory + ) + ); + + assertEquals( + "Generated file paths for queries 'GetUser' and 'getuser' differ only by case: " + + Path.of("generated", "GetUser.java") + + " and " + + Path.of("generated", "getuser.java"), + exception.getMessage() + ); + + assertTrue( + Files.exists( + generatedDirectory.resolve("generated").resolve("GetUser.java") + ) + ); + + assertFalse( + Files.exists( + generatedDirectory.resolve("generated").resolve("getuser.java") + ) + ); + } + + @Test + void shouldGenerateCompilableJavaForQuotedSqlIdentifiers() throws IOException { + Path schemaFile = tempDir.resolve("schema.sql"); + Path queriesFile = tempDir.resolve("queries.sql"); + Path generatedDirectory = tempDir.resolve("generated"); + Path classesDirectory = tempDir.resolve("classes"); + + Files.writeString( + schemaFile, + """ + CREATE TABLE "user data" + ( + "user id" BIGINT NOT NULL, + "class" VARCHAR(255) + ); + """ + ); + + Files.writeString( + queriesFile, + """ + -- name: ListUserData :many + SELECT "user id", "class" + FROM "user data" + WHERE "class" = $1; + """ + ); + + Config config = new Config( + List.of( + new SqlConfig( + schemaFile.toString(), + queriesFile.toString() + ) + ), + new JavaConfig( + generatedDirectory.toString(), + "generated" + ) + ); + + new SqlcjCompiler().compile(config); + + Path generatedFile = generatedDirectory + .resolve("generated") + .resolve("ListUserData.java"); + + String source = Files.readString(generatedFile); + + assertTrue(source.contains("Long user_id")); + assertTrue(source.contains("String class_")); + assertTrue(source.contains("resultSet.getObject(\"user id\", Long.class)")); + assertTrue(source.contains("resultSet.getObject(\"class\", String.class)")); + assertTrue(source.contains("listUserData(String class_)")); + assertTrue(source.contains("WHERE \"class\" = ?")); + + Files.createDirectories(classesDirectory); + + JavaCompiler compilerApi = ToolProvider.getSystemJavaCompiler(); + + assertNotNull(compilerApi); + + assertEquals( + 0, + compilerApi.run( + null, + null, + null, + "-classpath", + System.getProperty("java.class.path"), + "-d", + classesDirectory.toString(), + generatedFile.toString() + ) + ); + } + + private void compileUsersQueries(String queries, 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, queries); + + Config config = new Config( + List.of( + new SqlConfig( + schemaFile.toString(), + queriesFile.toString() + ) + ), + new JavaConfig( + generatedDirectory.toString(), + "generated" + ) + ); + + new SqlcjCompiler().compile(config); + } + private Path generateAndCompile(String queries, String queryName) throws IOException { Path schemaFile = tempDir.resolve("schema.sql"); Path queriesFile = tempDir.resolve("queries.sql"); diff --git a/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java b/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java new file mode 100644 index 0000000..829f271 --- /dev/null +++ b/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java @@ -0,0 +1,324 @@ +package dev.sqlcj.generator; + +import dev.sqlcj.analysis.QueryColumn; +import dev.sqlcj.analysis.QueryModel; +import dev.sqlcj.analysis.QueryParameter; +import dev.sqlcj.parser.QueryType; +import dev.sqlcj.runtime.QueryExecutor; +import dev.sqlcj.runtime.RowMapper; +import dev.sqlcj.schema.ColumnType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.tools.JavaCompiler; +import javax.tools.ToolProvider; +import java.io.IOException; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JavaCodeGeneratorNamingTest { + + private static final String SQL = """ + SELECT id + FROM users + """; + + private final CodeGenerator codeGenerator = new JavaCodeGenerator(); + + @TempDir + Path tempDir; + + @Test + void shouldGenerateKeywordSafeMethodForKeywordClassName() throws IOException { + GeneratedFile file = codeGenerator.generate( + query( + "Class", + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + List.of() + ) + ); + + 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)")); + assertTrue(source.contains("public List class_()")); + + assertCompiles(file); + } + + @Test + void shouldNormalizeUnsafeQueryNameAndEscapeJavadoc() throws IOException { + GeneratedFile file = codeGenerator.generate( + query( + "Get*/User", + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + List.of() + ) + ); + + 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)")); + assertTrue(source.contains("public List get_User()")); + assertTrue(source.contains("Query: Get*/User")); + assertFalse(source.contains("Query: Get*/User")); + + assertCompiles(file); + } + + @Test + void shouldRenameClassConflictingWithImportedType() throws IOException { + GeneratedFile file = codeGenerator.generate( + query( + "List", + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + List.of() + ) + ); + + String source = file.content(); + + assertEquals(Path.of("generated", "List_.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_()")); + + assertCompiles(file); + } + + @Test + void shouldKeepSqlLabelsWhenRenamingResultComponents() throws IOException { + GeneratedFile file = codeGenerator.generate( + query( + "ListUsers", + List.of( + new QueryColumn("class", ColumnType.BIGINT, false), + new QueryColumn("hashCode", ColumnType.VARCHAR, true), + new QueryColumn("user id", ColumnType.VARCHAR, true), + new QueryColumn("user-id", ColumnType.VARCHAR, true) + ), + List.of() + ) + ); + + String source = file.content(); + + 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("resultSet.getObject(\"class\", Long.class)")); + assertTrue(source.contains("resultSet.getObject(\"hashCode\", String.class)")); + assertTrue(source.contains("resultSet.getObject(\"user id\", String.class)")); + assertTrue(source.contains("resultSet.getObject(\"user-id\", String.class)")); + + assertCompiles(file); + } + + @Test + void shouldEscapeQuotedSqlLabelInRowMapper() throws IOException { + GeneratedFile file = codeGenerator.generate( + query( + "ListUsers", + List.of(new QueryColumn("user\"id", ColumnType.VARCHAR, true)), + List.of() + ) + ); + + String source = file.content(); + + assertTrue(source.contains("String user_id")); + assertTrue(source.contains("resultSet.getObject(\"user\\\"id\", String.class)")); + + assertCompiles(file); + } + + @Test + void shouldUseResolvedParameterNamesInSignatureAndArguments() throws IOException { + GeneratedFile file = codeGenerator.generate( + query( + "FindUsers", + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + List.of( + new QueryParameter(1, "executor", ColumnType.BIGINT), + new QueryParameter(2, "user id", ColumnType.VARCHAR), + new QueryParameter(3, "user-id", ColumnType.VARCHAR) + ) + ) + ); + + String source = file.content(); + + assertTrue( + source.contains( + "public List findUsers(Long executor1, String user_id1, String user_id2)" + ) + ); + + assertTrue(source.contains("List.of(executor1, user_id1, user_id2)")); + + assertCompiles(file); + } + + @Test + void shouldExecuteExactSqlForQuotedIdentifierWithBackslash() throws Exception { + String sql = """ + SELECT "a\\q" + FROM users + WHERE "a\\q" = ?"""; + + QueryModel query = new QueryModel( + "ListUsers", + QueryType.MANY, + "users", + sql, + List.of(1), + List.of(new QueryColumn("a\\q", ColumnType.VARCHAR, true)), + List.of(new QueryParameter(1, "a\\q", ColumnType.VARCHAR)) + ); + + GeneratedFile file = codeGenerator.generate(query); + + assertTrue(file.content().contains("SELECT \"a\\\\q\"")); + assertTrue(file.content().contains("String a_q")); + + assertCompiles(file); + + assertEquals(sql, executedSql(file, "listUsers", "value")); + } + + @Test + void shouldExecuteExactSqlForTextBlockDelimiterAndTrailingWhitespace() throws Exception { + String sql = "SELECT '\"\"\"' \nFROM users"; + + QueryModel query = new QueryModel( + "ListUsers", + QueryType.MANY, + "users", + sql, + List.of(), + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + List.of() + ); + + GeneratedFile file = codeGenerator.generate(query); + + assertCompiles(file); + + assertEquals(sql, executedSql(file, "listUsers")); + } + + /** + * Compiles the generated class, runs its query method against a recording + * executor, and returns the SQL the generated code passed to the runtime. + */ + private String executedSql(GeneratedFile file, String methodName, Object... arguments) throws Exception { + Path classesDirectory = tempDir.resolve("classes"); + + try ( + URLClassLoader classLoader = new URLClassLoader( + new URL[] { classesDirectory.toUri().toURL() }, + getClass().getClassLoader() + ) + ) { + Class generatedClass = classLoader.loadClass( + "generated." + file.path().getFileName().toString().replace(".java", "") + ); + + RecordingQueryExecutor executor = new RecordingQueryExecutor(); + + Object instance = generatedClass + .getConstructor(QueryExecutor.class) + .newInstance(executor); + + Method method = List.of(generatedClass.getMethods()).stream() + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst() + .orElseThrow(); + + method.invoke(instance, arguments); + + return executor.sql; + } + } + + private static final class RecordingQueryExecutor implements QueryExecutor { + + private String sql; + + @Override + public T query(String sql, List parameters, RowMapper mapper) { + this.sql = sql; + + return null; + } + + @Override + public List queryMany(String sql, List parameters, RowMapper mapper) { + this.sql = sql; + + return List.of(); + } + } + + private QueryModel query(String name, List columns, List parameters) { + return new QueryModel( + name, + QueryType.MANY, + "users", + SQL, + parameters.stream() + .map(QueryParameter::index) + .toList(), + columns, + parameters + ); + } + + private void assertCompiles(GeneratedFile file) throws IOException { + Path sourceDirectory = tempDir.resolve("generated"); + Path outputDirectory = tempDir.resolve("classes"); + + Files.createDirectories(sourceDirectory); + Files.createDirectories(outputDirectory); + + Path sourceFile = sourceDirectory.resolve(file.path()); + + Files.createDirectories(sourceFile.getParent()); + + Files.writeString(sourceFile, file.content()); + + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + + assertNotNull(compiler); + + int result = compiler.run( + null, + null, + null, + "-classpath", + System.getProperty("java.class.path"), + "-d", + outputDirectory.toString(), + sourceFile.toString() + ); + + assertEquals(0, result); + } +} diff --git a/src/test/java/dev/sqlcj/generator/JavaNamesTest.java b/src/test/java/dev/sqlcj/generator/JavaNamesTest.java new file mode 100644 index 0000000..a32f16a --- /dev/null +++ b/src/test/java/dev/sqlcj/generator/JavaNamesTest.java @@ -0,0 +1,190 @@ +package dev.sqlcj.generator; + +import dev.sqlcj.analysis.QueryColumn; +import dev.sqlcj.analysis.QueryModel; +import dev.sqlcj.analysis.QueryParameter; +import dev.sqlcj.parser.QueryType; +import dev.sqlcj.schema.ColumnType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.util.List; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class JavaNamesTest { + + private static final String SQL = "SELECT 1"; + + @Test + void shouldKeepSafeQueryNameAsClassName() { + JavaNames names = names("GetUser"); + + assertEquals("GetUser", names.className()); + assertEquals("GetUserResult", names.resultTypeName()); + assertEquals("getUser", names.methodName()); + } + + @Test + void shouldKeepSafeLowercaseQueryNameAsClassName() { + JavaNames names = names("getUser"); + + assertEquals("getUser", names.className()); + assertEquals("getUser", names.methodName()); + } + + @Test + void shouldKeepClassNameThatIsOnlyAKeywordAsMethodName() { + JavaNames names = names("Class"); + + assertEquals("Class", names.className()); + 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_" + } + ) + void shouldRenameClassConflictingWithGeneratedOrImportedType(String queryName, String expectedClassName) { + assertEquals(expectedClassName, names(queryName).className()); + } + + @ParameterizedTest + @CsvSource( + { + "ToString, toString_", + "HashCode, hashCode_", + "GetClass, getClass_", + "Wait, wait_" + } + ) + void shouldRenameMethodConflictingWithInheritedObjectMethod(String queryName, String expectedMethodName) { + assertEquals(expectedMethodName, names(queryName).methodName()); + } + + @Test + void shouldKeepSafeColumnNamesAsResultComponents() { + JavaNames names = names("ListUsers", List.of(), List.of("id", "created_at")); + + assertEquals(List.of("id", "created_at"), names.componentNames()); + } + + @Test + void shouldNormalizeAndDisambiguateResultComponents() { + JavaNames names = names( + "ListUsers", + List.of(), + List.of("user id", "user-id", "class", "hashCode") + ); + + assertEquals( + List.of("user_id1", "user_id2", "class_", "hashCode1"), + names.componentNames() + ); + } + + @Test + void shouldSkipUsedCandidateWhenDisambiguatingResultComponents() { + JavaNames names = names( + "ListUsers", + List.of(), + List.of("id", "id", "id1") + ); + + assertEquals(List.of("id1", "id2", "id11"), names.componentNames()); + } + + @Test + void shouldKeepExistingDuplicateParameterNaming() { + JavaNames names = names("FindUsers", List.of("id", "id"), List.of()); + + assertEquals(List.of("id1", "id2"), names.parameterNames()); + } + + @Test + void shouldResolveParameterNamesReservedByGeneratedCode() { + JavaNames names = names( + "FindUsers", + List.of("executor", "ROW_MAPPER", "name"), + List.of() + ); + + assertEquals( + List.of("executor1", "ROW_MAPPER1", "name"), + names.parameterNames() + ); + } + + @Test + void shouldResolveParameterAndComponentNamesIndependently() { + JavaNames names = names( + "FindUsers", + List.of("id", "id"), + List.of("id") + ); + + assertEquals(List.of("id1", "id2"), names.parameterNames()); + assertEquals(List.of("id"), names.componentNames()); + } + + private JavaNames names(String queryName) { + return names(queryName, List.of(), List.of()); + } + + private JavaNames names(String queryName, List parameterNames, List columnNames) { + List parameters = IntStream.range(0, parameterNames.size()) + .mapToObj( + index -> new QueryParameter( + index + 1, + parameterNames.get(index), + ColumnType.BIGINT + ) + ) + .toList(); + + List columns = columnNames.stream() + .map(name -> 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 + ) + ); + } +} diff --git a/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java b/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java index 3155439..f650784 100644 --- a/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java +++ b/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java @@ -114,6 +114,47 @@ name VARCHAR(255) assertTrue(table.constraints().isEmpty()); } + @Test + void shouldCanonicalizeQuotedTableAndColumnNames() { + String sql = """ + CREATE TABLE "user data" ( + "user id" BIGINT NOT NULL, + "select" VARCHAR(255) UNIQUE, + PRIMARY KEY ("user id") + ); + """; + + Schema schema = parser.parse(sql); + + Table table = schema.tables().getFirst(); + + assertEquals("user data", table.name()); + + assertEquals( + new Column("user id", ColumnType.BIGINT, false), + table.columns().get(0) + ); + + assertEquals( + new Column("select", ColumnType.VARCHAR, true), + table.columns().get(1) + ); + + assertEquals( + List.of( + new Constraint( + ConstraintType.UNIQUE, + List.of("select") + ), + new Constraint( + ConstraintType.PRIMARY_KEY, + List.of("user id") + ) + ), + table.constraints() + ); + } + @Test void shouldThrowSchemaParseExceptionForInvalidSql() { String sql = """