From 09cba2bf068caae375787d35d86014765e723aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Tue, 29 Sep 2026 00:59:29 +0300 Subject: [PATCH 1/2] feat: map PostgreSQL enum columns to generated Java enums --- .../dev/sqlcj/analysis/QueryAnalyzer.java | 74 +++- .../java/dev/sqlcj/analysis/QueryColumn.java | 15 +- .../dev/sqlcj/analysis/QueryGroupModel.java | 14 +- .../dev/sqlcj/analysis/QueryParameter.java | 15 +- .../dev/sqlcj/compiler/SqlcjCompiler.java | 5 +- .../dev/sqlcj/generator/CodeGenerator.java | 9 +- .../sqlcj/generator/JavaCodeGenerator.java | 357 +++++++++++++++- .../java/dev/sqlcj/generator/JavaNames.java | 88 +++- .../main/java/dev/sqlcj/schema/Column.java | 26 +- .../java/dev/sqlcj/schema/ColumnType.java | 8 +- .../main/java/dev/sqlcj/schema/EnumType.java | 21 + .../main/java/dev/sqlcj/schema/Schema.java | 9 +- .../schema/parser/DefaultSchemaParser.java | 251 ++++++++++-- .../dev/sqlcj/type/DefaultTypeResolver.java | 3 + .../dev/sqlcj/analysis/QueryAnalyzerTest.java | 127 ++++++ .../compiler/PostgresIntegrationTest.java | 162 ++++++++ .../SqlcjCompilerIntegrationTest.java | 186 +++++++++ .../generator/JavaCodeGeneratorTest.java | 385 ++++++++++++++++++ .../parser/DefaultSchemaParserTest.java | 268 +++++++++++- 19 files changed, 1942 insertions(+), 81 deletions(-) create mode 100644 sqlcj-cli/src/main/java/dev/sqlcj/schema/EnumType.java 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 4de4f43..8218669 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -337,7 +337,8 @@ private List resolveReturningItem(Expression expression, Source sou new QueryColumn( schemaColumn.name(), requireSupportedType(schemaColumn), - schemaColumn.nullable() + schemaColumn.nullable(), + schemaColumn.enumType() ) ); } @@ -556,22 +557,47 @@ private List toParameters(List occurrences) { return parameters; } + /** + * Requires two occurrences of one placeholder index to have the same type. + * An enum occurrence is the same type only as an occurrence of the same + * enum type, because each enum type generates a Java type of its own; every + * other occurrence is compared by the Java type it resolves to. + */ private void requireSameParameterType(QueryParameter parameter, QueryParameter occurrence) { - String type = typeResolver.resolve(parameter.type()); - String occurrenceType = typeResolver.resolve(occurrence.type()); + if (isSameParameterType(parameter, occurrence)) { + return; + } - if (!type.equals(occurrenceType)) { - throw new UnsupportedOperationException( - "Placeholder $%d has conflicting types: %s from '%s' and %s from '%s'" - .formatted( - parameter.index(), - type, - parameter.name(), - occurrenceType, - occurrence.name() - ) - ); + throw new UnsupportedOperationException( + "Placeholder $%d has conflicting types: %s from '%s' and %s from '%s'" + .formatted( + parameter.index(), + describeParameterType(parameter), + parameter.name(), + describeParameterType(occurrence), + occurrence.name() + ) + ); + } + + private boolean isSameParameterType(QueryParameter parameter, QueryParameter occurrence) { + if (parameter.type() == ColumnType.ENUM || occurrence.type() == ColumnType.ENUM) { + return parameter.type() == occurrence.type() + && parameter.enumType().equalsIgnoreCase(occurrence.enumType()); } + + return typeResolver.resolve(parameter.type()) + .equals(typeResolver.resolve(occurrence.type())); + } + + /** + * Describes a parameter's type for a diagnostic. An enum is described by + * its PostgreSQL name, because analysis renders no Java name. + */ + private String describeParameterType(QueryParameter parameter) { + return parameter.type() == ColumnType.ENUM + ? parameter.enumType() + : typeResolver.resolve(parameter.type()); } private void requireContiguousIndexes(List parameters) { @@ -1187,6 +1213,7 @@ private void addParameter( parameter, column.name(), requireSupportedType(column), + column.enumType(), parameters ); } @@ -1196,6 +1223,16 @@ private void addParameter( String name, ColumnType type, List parameters + ) { + addParameter(parameter, name, type, null, parameters); + } + + private void addParameter( + JdbcParameter parameter, + String name, + ColumnType type, + String enumType, + List parameters ) { if (!parameter.isUseFixedIndex()) { throw new UnsupportedOperationException(ANONYMOUS_PARAMETER_REJECTION); @@ -1205,7 +1242,8 @@ private void addParameter( new QueryParameter( parameter.getIndex(), name, - type + type, + enumType ) ); } @@ -1277,7 +1315,8 @@ private List resolveColumns(PlainSelect plainSelect, List s new QueryColumn( selectedColumnName(selectItem.getAlias(), schemaColumn), requireSupportedType(schemaColumn), - schemaColumn.nullable() || resolved.source().leftJoined() + schemaColumn.nullable() || resolved.source().leftJoined(), + schemaColumn.enumType() ) ); @@ -1462,7 +1501,8 @@ private List resolveAllColumns(Source source) { column -> new QueryColumn( column.name(), requireSupportedType(column), - column.nullable() || source.leftJoined() + column.nullable() || source.leftJoined(), + column.enumType() ) ) .toList(); diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryColumn.java b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryColumn.java index c4279fe..f189bb8 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryColumn.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryColumn.java @@ -2,9 +2,22 @@ import dev.sqlcj.schema.ColumnType; +/** + * One analyzed result column. + * + * @param enumType the schema's declared name of the column's enum type when + * {@code type} is {@link ColumnType#ENUM}, and {@code null} + * otherwise + */ public record QueryColumn( String name, ColumnType type, - boolean nullable + boolean nullable, + String enumType ) { + + /** A column of a type whose Java type is decided by {@code type} alone. */ + public QueryColumn(String name, ColumnType type, boolean nullable) { + this(name, type, nullable, null); + } } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryGroupModel.java b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryGroupModel.java index 78fc781..12e5b33 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryGroupModel.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryGroupModel.java @@ -1,15 +1,25 @@ package dev.sqlcj.analysis; +import dev.sqlcj.schema.EnumType; + import java.util.List; /** * One analyzed query group and the queries its generated repository exposes, * in the order they were declared in the group's query source. * - * @param name the configured group identity that names the generated repository + * @param name the configured group identity that names the generated repository + * @param enums the enum types the group's schema declares, which define the + * labels of every enum column and parameter of its queries */ public record QueryGroupModel( String name, - List queries + List queries, + List enums ) { + + /** A group whose schema declares no enum type. */ + public QueryGroupModel(String name, List queries) { + this(name, queries, List.of()); + } } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryParameter.java b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryParameter.java index 6db0090..22f99c9 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryParameter.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryParameter.java @@ -2,9 +2,22 @@ import dev.sqlcj.schema.ColumnType; +/** + * One analyzed parameter occurrence. + * + * @param enumType the schema's declared name of the parameter's enum type when + * {@code type} is {@link ColumnType#ENUM}, and {@code null} + * otherwise + */ public record QueryParameter( int index, String name, - ColumnType type + ColumnType type, + String enumType ) { + + /** A parameter of a type whose Java type is decided by {@code type} alone. */ + public QueryParameter(int index, String name, ColumnType type) { + this(index, name, type, null); + } } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java index f968198..426f770 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java @@ -69,7 +69,7 @@ public void compile(Config config) { /** * Generates one repository per configured query group, followed by the row - * records those groups share. + * records and the enum types those groups share. */ private List generate(List sources, CodeGenerator codeGenerator) { List files = new ArrayList<>(); @@ -90,6 +90,7 @@ private List generate(List sources, CodeGenerator codeGen } files.addAll(codeGenerator.generateRows()); + files.addAll(codeGenerator.generateEnums()); return files; } @@ -128,7 +129,7 @@ private QueryGroupModel analyze(Source source, Schema schema) { queries.add(analyzeQuery(source, query, schema)); } - return new QueryGroupModel(source.name(), List.copyOf(queries)); + return new QueryGroupModel(source.name(), List.copyOf(queries), schema.enums()); } /** diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/generator/CodeGenerator.java b/sqlcj-cli/src/main/java/dev/sqlcj/generator/CodeGenerator.java index 5ddf479..9c8c7f0 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/generator/CodeGenerator.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/generator/CodeGenerator.java @@ -10,7 +10,8 @@ * *

A generator is used for one package: every group of the package is * generated through the same generator, in configuration order, and - * {@link #generateRows()} then yields the row records those groups share. + * {@link #generateRows()} and {@link #generateEnums()} then yield the row + * records and enum types those groups share. */ public interface CodeGenerator { @@ -26,4 +27,10 @@ public interface CodeGenerator { * returns, in the order the groups first returned them. */ List generateRows(); + + /** + * Generates one Java enum per enum type a column or parameter of a + * generated group uses, in the order the groups first used them. + */ + List generateEnums(); } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java b/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java index 12da604..ed0d237 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java @@ -6,6 +6,7 @@ import dev.sqlcj.analysis.QueryParameter; import dev.sqlcj.parser.QueryType; import dev.sqlcj.schema.ColumnType; +import dev.sqlcj.schema.EnumType; import dev.sqlcj.type.DefaultTypeResolver; import dev.sqlcj.type.TypeResolver; @@ -40,6 +41,22 @@ public final class JavaCodeGenerator implements CodeGenerator { /** The rows of the package, keyed by the lower-case row type name. */ private final Map rowsByPortabilityKey = new LinkedHashMap<>(); + /** + * The enum types the package generates, keyed by enum identity and ordered + * by the group that first used them. + */ + private final Map enums = new LinkedHashMap<>(); + + /** The enum types of the package, keyed by the lower-case enum type name. */ + private final Map enumsByPortabilityKey = new LinkedHashMap<>(); + + /** + * Every other type the package generates — repositories, row records, and + * nested result records — keyed by its lower-case name, so that an enum + * type cannot take a name one of them already generated. + */ + private final Map generatedTypesByPortabilityKey = new LinkedHashMap<>(); + public JavaCodeGenerator() { this(DEFAULT_PACKAGE); } @@ -58,6 +75,8 @@ public GeneratedFile generate(QueryGroupModel group) { JavaNames names = JavaNames.of(group); recordRows(group, names); + recordGeneratedTypes(group, names); + recordEnums(group); return new GeneratedFile( buildPath(names), @@ -72,6 +91,13 @@ public List generateRows() { .toList(); } + @Override + public List generateEnums() { + return enums.values().stream() + .map(this::generateEnumFile) + .toList(); + } + /** * Records the row records of one group, rejecting a row that conflicts with * a row an earlier group of the package already generated. @@ -159,6 +185,169 @@ String portabilityKey() { } } + /** + * Records the types one group generates beside its enums: its repository, + * the row records it returns, and the nested result records of its other + * queries. + */ + private void recordGeneratedTypes(QueryGroupModel group, JavaNames names) { + recordGeneratedType(names.repositoryClassName()); + + for (JavaNames.RowNames row : names.rows()) { + recordGeneratedType(row.typeName()); + } + + for (int index = 0; index < group.queries().size(); index++) { + QueryModel query = group.queries().get(index); + + if (hasResultType(query) && !returnsSharedRow(query)) { + recordGeneratedType(names.queries().get(index).resultTypeName()); + } + } + } + + /** + * Records one generated type of the package, rejecting a type whose name is + * equal ignoring case to an enum type an earlier group already generated, + * because the compiled class files of those types are one path on a + * case-insensitive filesystem. + */ + private void recordGeneratedType(String typeName) { + String portabilityKey = typeName.toLowerCase(Locale.ROOT); + SharedEnum generatedEnum = enumsByPortabilityKey.get(portabilityKey); + + if (generatedEnum != null) { + throw enumTypeCollision(generatedEnum, typeName); + } + + generatedTypesByPortabilityKey.putIfAbsent(portabilityKey, typeName); + } + + /** + * Records the enum types one group uses, in the order its columns and + * parameters first name them. + */ + private void recordEnums(QueryGroupModel group) { + for (QueryModel query : group.queries()) { + for (QueryColumn column : query.columns()) { + if (column.type() == ColumnType.ENUM) { + recordEnum(group, column.enumType()); + } + } + + for (QueryParameter parameter : query.parameters()) { + if (parameter.type() == ColumnType.ENUM) { + recordEnum(group, parameter.enumType()); + } + } + } + } + + private void recordEnum(QueryGroupModel group, String enumName) { + EnumType declared = group.enums().stream() + .filter(candidate -> candidate.name().equalsIgnoreCase(enumName)) + .findFirst() + .orElseThrow( + () -> new IllegalStateException("Enum type not found in schema: " + enumName) + ); + + recordEnum( + new SharedEnum( + group.name(), + declared.name(), + JavaNames.enumTypeName(declared.name()), + declared.labels(), + JavaNames.enumConstantNames(declared.name(), declared.labels()) + ) + ); + } + + /** + * Records one enum type of the package. An enum already recorded under the + * same enum type is the same Java enum, so its labels must be the ones the + * earlier group defined; an enum type equal ignoring case to another enum + * or to another generated type is rejected, because the compiled class + * files of those types are one path on a case-insensitive filesystem. + */ + private void recordEnum(SharedEnum sharedEnum) { + SharedEnum recorded = enums.get(sharedEnum.identity()); + + if (recorded != null) { + if (!recorded.labels().equals(sharedEnum.labels())) { + throw new IllegalArgumentException( + "Enum type '%s' differs from its definition in query group '%s', which generates the same enum type %s" + .formatted( + sharedEnum.enumName(), + recorded.groupName(), + sharedEnum.typeName() + ) + ); + } + + return; + } + + SharedEnum previous = enumsByPortabilityKey.get(sharedEnum.portabilityKey()); + + if (previous != null) { + throw new IllegalArgumentException( + "Enum types '%s' and '%s' generate enum types that are equal ignoring case: %s and %s" + .formatted( + previous.enumName(), + sharedEnum.enumName(), + previous.typeName(), + sharedEnum.typeName() + ) + ); + } + + String generatedType = generatedTypesByPortabilityKey.get(sharedEnum.portabilityKey()); + + if (generatedType != null) { + throw enumTypeCollision(sharedEnum, generatedType); + } + + enums.put(sharedEnum.identity(), sharedEnum); + enumsByPortabilityKey.put(sharedEnum.portabilityKey(), sharedEnum); + } + + private IllegalArgumentException enumTypeCollision(SharedEnum sharedEnum, String typeName) { + return new IllegalArgumentException( + "Enum type '%s' generates %s, which is equal ignoring case to the generated type %s" + .formatted( + sharedEnum.enumName(), + sharedEnum.typeName(), + typeName + ) + ); + } + + /** + * One Java enum of the package, generated once from the group that first + * used the enum type it is generated from. + */ + private record SharedEnum( + String groupName, + String enumName, + String typeName, + List labels, + List constantNames + ) { + + /** + * Two uses are the same Java enum when they generate the same enum type + * from the same enum type name, compared as a case-insensitive + * filesystem compares the type's generated file. + */ + String identity() { + return typeName + "\n" + enumName.toLowerCase(Locale.ROOT); + } + + String portabilityKey() { + return typeName.toLowerCase(Locale.ROOT); + } + } + private Path buildPath(JavaNames names) { return packageDirectory().resolve(names.repositoryClassName() + ".java"); } @@ -226,6 +415,124 @@ private String generateRowSource(SharedRow row) { return String.join("\n\n", sections); } + /** Generates the file of one Java enum of the package. */ + private GeneratedFile generateEnumFile(SharedEnum sharedEnum) { + return new GeneratedFile( + packageDirectory().resolve(sharedEnum.typeName() + ".java"), + generateEnumSource(sharedEnum) + ); + } + + private String generateEnumSource(SharedEnum sharedEnum) { + return String.join( + "\n\n", + GENERATED_NOTICE, + generatePackage(), + generateEnumJavaDoc(sharedEnum).stripTrailing(), + generateEnum(sharedEnum) + ); + } + + private String generateEnumJavaDoc(SharedEnum sharedEnum) { + return """ + /** + * Generated by sqlcj. + * + * Enum: %s + */ + """ + .formatted(escapeJavadoc(sharedEnum.enumName())); + } + + /** + * Generates one Java enum, holding one constant per label in label order, + * each carrying the label PostgreSQL stores. The constant list ends with + * {@code ;} even when the enum type declares no label, so the generated + * source compiles either way. + */ + private String generateEnum(SharedEnum sharedEnum) { + List members = List.of( + generateEnumConstants(sharedEnum), + "private final String label;", + generateEnumConstructor(sharedEnum), + generateEnumLabelMethod(), + generateFromLabelMethod(sharedEnum) + ); + + return """ + public enum %s { + + %s + } + """ + .formatted( + sharedEnum.typeName(), + members.stream() + .map(this::indentEnumMember) + .collect(Collectors.joining("\n\n")) + ); + } + + private String generateEnumConstants(SharedEnum sharedEnum) { + return IntStream.range(0, sharedEnum.labels().size()) + .mapToObj( + index -> sharedEnum.constantNames().get(index) + + "(" + + generateStringLiteral(sharedEnum.labels().get(index)) + + ")" + ) + .collect(Collectors.joining(",\n")) + + ";"; + } + + private String generateEnumConstructor(SharedEnum sharedEnum) { + return """ + %s(String label) { + this.label = label; + } + """ + .formatted(sharedEnum.typeName()); + } + + private String generateEnumLabelMethod() { + return """ + public String label() { + return label; + } + """; + } + + /** + * Generates the reverse lookup a generated read uses: {@code null} for a + * SQL {@code NULL}, and a failure for a label the generated enum does not + * hold, which means the database has a label the schema source does not + * declare. + */ + private String generateFromLabelMethod(SharedEnum sharedEnum) { + return """ + public static %s fromLabel(String label) { + if (label == null) { + return null; + } + + for (%s value : values()) { + if (value.label.equals(label)) { + return value; + } + } + + throw new IllegalArgumentException(%s + label); + } + """ + .formatted( + sharedEnum.typeName(), + sharedEnum.typeName(), + generateStringLiteral( + "Unknown label for enum type " + sharedEnum.enumName() + ": " + ) + ); + } + /** A row record depends on the JDK types of its components alone. */ private String generateRowImports(SharedRow row) { return row.columns().stream() @@ -418,6 +725,18 @@ private String indent(String text) { return text.indent(4).stripTrailing(); } + /** + * Indents one member of a generated enum, leaving a blank line inside it + * blank rather than indented, so the generated enum carries no trailing + * whitespace. Only these members are written with blank lines inside them. + */ + private String indentEnumMember(String text) { + return indent(text) + .lines() + .map(String::stripTrailing) + .collect(Collectors.joining("\n")); + } + /** Generates one result or row record in selected-column order. */ private String generateRecord( String typeName, @@ -450,19 +769,31 @@ private String generateResultComponents(List columns, List private String generateResultComponent(QueryColumn column, String name) { return "%s %s" .formatted( - typeResolver.resolve(column.type()), + javaType(column.type(), column.enumType()), name ) .indent(4) .stripTrailing(); } + /** + * The Java type of one column or parameter, which is the generated enum of + * its enum type for an enum and the mapped type of its column type + * otherwise. A generated enum belongs to the generated package, so it is + * referenced by its simple name and needs no import. + */ + private String javaType(ColumnType type, String enumType) { + return type == ColumnType.ENUM + ? JavaNames.enumTypeName(enumType) + : typeResolver.resolve(type); + } + private String generateMethodParameters(QueryModel query, JavaNames.QueryNames names) { return IntStream.range(0, query.parameters().size()) .mapToObj(index -> { QueryParameter parameter = query.parameters().get(index); - return typeResolver.resolve(parameter.type()) + return javaType(parameter.type(), parameter.enumType()) + " " + names.parameterNames().get(index); }) @@ -671,10 +1002,17 @@ private Map generateArgumentsByIndex( * Renders one executor argument. A JSON parameter is wrapped in * {@code dev.sqlcj.runtime.UntypedText} so that the runtime binds its text * without a declared SQL type and the database types it from the context of - * its placeholder. The wrapper is written out in full, so the generated - * imports are the same as without it. + * its placeholder. An enum parameter is wrapped the same way, around the + * label of the generated constant, because PostgreSQL rejects a label bound + * as {@code varchar} where an enum is expected. The wrapper is written out + * in full, so the generated imports are the same as without it. */ private String generateArgument(QueryParameter parameter, String name) { + if (parameter.type() == ColumnType.ENUM) { + return "new dev.sqlcj.runtime.UntypedText(%s == null ? null : %s.label())" + .formatted(name, name); + } + if (isUntypedText(parameter.type())) { return "new dev.sqlcj.runtime.UntypedText(" + name + ")"; } @@ -736,9 +1074,18 @@ private String generateResultMappings(List columns) { /** * A JSON column is read with {@code getString}, because a driver reports it * as a type of its own for which {@code getObject(position, String.class)} - * is not defined; every other column is read as its mapped Java type. + * is not defined. An enum column reads its label the same way and resolves + * it to the generated constant; every other column is read as its mapped + * Java type. */ private String generateResultMapping(QueryColumn column, int position) { + if (column.type() == ColumnType.ENUM) { + return "%s.fromLabel(resultSet.getString(%d))" + .formatted(JavaNames.enumTypeName(column.enumType()), position) + .indent(8) + .stripTrailing(); + } + if (isUntypedText(column.type())) { return "resultSet.getString(%d)" .formatted(position) 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 f73c57e..91ea2c4 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaNames.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaNames.java @@ -143,6 +143,64 @@ static JavaNames of(QueryGroupModel group) { ); } + /** + * The Java type generated for one enum type, which is the upper camel form + * of its PostgreSQL name, as in {@code stage_setting} to + * {@code StageSetting}. + */ + static String enumTypeName(String enumName) { + return upperCamelCase(enumName, null); + } + + /** + * The constants generated for the labels of one enum type, in label order. + * A constant is the label's words, upper-cased and joined with {@code _}, + * so {@code in progress} and {@code in-progress} both become + * {@code IN_PROGRESS}, and {@code InProgress}, which is one word, becomes + * {@code INPROGRESS}. + * + *

A label with no letter and no digit generates no constant, and two + * labels that generate one constant are rejected, because a generated + * constant is a name the application reads. + */ + static List enumConstantNames(String enumName, List labels) { + Map labelsByConstant = new LinkedHashMap<>(); + List constants = new ArrayList<>(labels.size()); + + for (String label : labels) { + String constant = enumConstantName(enumName, label); + String previous = labelsByConstant.putIfAbsent(constant, label); + + if (previous != null) { + throw new IllegalArgumentException( + "Labels '%s' and '%s' of enum type '%s' generate the same constant %s" + .formatted(previous, label, enumName, constant) + ); + } + + constants.add(constant); + } + + return List.copyOf(constants); + } + + private static String enumConstantName(String enumName, String label) { + List words = splitWords(label); + + if (words.isEmpty()) { + throw new IllegalArgumentException( + "Label '%s' of enum type '%s' has no letter or digit to generate a Java constant from" + .formatted(label, enumName) + ); + } + + return startIdentifier( + words.stream() + .map(word -> word.toUpperCase(Locale.ROOT)) + .collect(Collectors.joining("_")) + ); + } + String repositoryClassName() { return repositoryClassName; } @@ -524,6 +582,26 @@ private static String decapitalize(String name) { * such as {@code ID} or {@code URL} becomes one ordinary word. */ private static List words(String sqlName, String queryName) { + List words = splitWords(sqlName); + + 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) + ); + } + + return words; + } + + /** + * Splits a SQL name into its words, which is empty for a name with no + * letter and no digit at all. + */ + private static List splitWords(String sqlName) { List words = new ArrayList<>(); StringBuilder word = new StringBuilder(); int index = 0; @@ -542,16 +620,6 @@ private static List words(String sqlName, String queryName) { 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) - ); - } - return words; } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/schema/Column.java b/sqlcj-cli/src/main/java/dev/sqlcj/schema/Column.java index 98791d7..6d05c0f 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/schema/Column.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/schema/Column.java @@ -7,16 +7,38 @@ * {@code unsupportedType} instead, so the schema loads and only a query that * uses the column fails. Exactly one of {@code type} and * {@code unsupportedType} is non-null. + * + *

A column of a declared enum type carries {@link ColumnType#ENUM} and the + * schema's declared name of that type in {@code enumType}, which is the only + * column kind whose Java type is not decided by {@code type} alone. */ public record Column( String name, ColumnType type, boolean nullable, - String unsupportedType + String unsupportedType, + String enumType ) { /** A column of a mapped type. */ public Column(String name, ColumnType type, boolean nullable) { - this(name, type, nullable, null); + this( + name, + type, + nullable, + null, + null + ); + } + + /** A column of a mapped type, or one recorded with its declared type. */ + public Column(String name, ColumnType type, boolean nullable, String unsupportedType) { + this( + name, + type, + nullable, + unsupportedType, + null + ); } } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/schema/ColumnType.java b/sqlcj-cli/src/main/java/dev/sqlcj/schema/ColumnType.java index 55c976c..6dc6eea 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/schema/ColumnType.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/schema/ColumnType.java @@ -17,5 +17,11 @@ public enum ColumnType { UUID, BYTEA, JSON, - JSONB + JSONB, + + /** + * A column of a declared enum type, whose Java type is the enum generated + * for the type named by the column. + */ + ENUM } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/schema/EnumType.java b/sqlcj-cli/src/main/java/dev/sqlcj/schema/EnumType.java new file mode 100644 index 0000000..38c509f --- /dev/null +++ b/sqlcj-cli/src/main/java/dev/sqlcj/schema/EnumType.java @@ -0,0 +1,21 @@ +package dev.sqlcj.schema; + +import java.util.List; + +/** + * One enum type declared by a schema source. + * + * @param name the schema's declared name of the type + * @param labels the labels of the type in PostgreSQL's sort order, which is the + * declared order with each added label in the position its + * {@code ALTER TYPE ... ADD VALUE} places it + */ +public record EnumType( + String name, + List labels +) { + + public EnumType { + labels = List.copyOf(labels); + } +} diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/schema/Schema.java b/sqlcj-cli/src/main/java/dev/sqlcj/schema/Schema.java index 57b9000..f944ccf 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/schema/Schema.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/schema/Schema.java @@ -3,10 +3,17 @@ import java.util.List; public record Schema( - List tables + List
tables, + List enums ) { public Schema { tables = List.copyOf(tables); + enums = List.copyOf(enums); + } + + /** A schema that declares no enum type. */ + public Schema(List
tables) { + this(tables, List.of()); } } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java b/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java index 65ef5ec..ed49cef 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java @@ -4,6 +4,7 @@ import dev.sqlcj.schema.ColumnType; import dev.sqlcj.schema.Constraint; import dev.sqlcj.schema.ConstraintType; +import dev.sqlcj.schema.EnumType; import dev.sqlcj.schema.Schema; import dev.sqlcj.schema.Table; import dev.sqlcj.sql.SqlParseReason; @@ -20,6 +21,7 @@ import net.sf.jsqlparser.statement.alter.Alter; import net.sf.jsqlparser.statement.alter.AlterExpression; import net.sf.jsqlparser.statement.alter.AlterOperation; +import net.sf.jsqlparser.statement.alter.AlterType; import net.sf.jsqlparser.statement.alter.sequence.AlterSequence; import net.sf.jsqlparser.statement.comment.Comment; import net.sf.jsqlparser.statement.create.extension.CreateExtension; @@ -69,16 +71,17 @@ public Schema parse(Schema schema, String sql) { Statements statements = CCJSqlParserUtil.parseStatements(sql, parser::set); List
tables = new ArrayList<>(schema.tables()); + List enums = new ArrayList<>(schema.enums()); if (!statements.isEmpty()) { List lines = statementLines(parser.get()); for (int i = 0; i < statements.size(); i++) { - apply(statements.get(i), statementLine(lines, i), tables); + apply(statements.get(i), statementLine(lines, i), tables, enums); } } - return new Schema(tables); + return new Schema(tables, enums); } catch (JSQLParserException e) { throw new SchemaParseException(SqlParseReason.of(e, true), e); } @@ -125,19 +128,121 @@ private int statementLine(List lines, int index) { return lines.get(Math.min(index, lines.size() - 1)); } - /** Applies one statement to the tables the statements before it left. */ - private void apply(Statement statement, int line, List
tables) { + /** + * Applies one statement to the tables and enum types the statements before + * it left. + */ + private void apply(Statement statement, int line, List
tables, List enums) { if (statement instanceof CreateTable createTable) { - applyCreateTable(createTable, tables); + applyCreateTable(createTable, enums, tables); } else if (statement instanceof Alter alter) { - applyAlterTable(alter, line, tables); + applyAlterTable(alter, line, enums, tables); } else if (statement instanceof Drop drop && drop.getObjectType() == Drop.ObjectType.TABLE) { applyDropTable(drop, tables); + } else if ( + statement instanceof CreateType createType + && createType.getDefinition() instanceof EnumTypeDefinition definition + ) { + applyCreateEnumType(createType, definition, enums); + } else if ( + statement instanceof AlterType alterType + && alterType.getAction() == AlterType.Action.ADD_VALUE + ) { + applyAddEnumValue(alterType, enums); } else if (!isIgnored(statement)) { throw unsupportedStatement(statement, line); } } + /** + * Appends one enum type with its declared labels. A repeated type name + * fails, as PostgreSQL rejects it, and so does a repeated label. An enum is + * the only type sqlcj models; every other {@code CREATE TYPE} is rejected. + */ + private void applyCreateEnumType( + CreateType createType, + EnumTypeDefinition definition, + List enums + ) { + String typeName = MultiPartName.unquote(createType.getName()); + + if (indexOfEnum(enums, typeName) >= 0) { + throw typeAlreadyExists(typeName); + } + + List labels = new ArrayList<>(); + + for (StringValue declaredLabel : definition.getLabels()) { + String label = declaredLabel.getNotExcapedValue(); + + if (labels.contains(label)) { + throw labelAlreadyExists(typeName, label); + } + + labels.add(label); + } + + enums.add(new EnumType(typeName, labels)); + } + + /** + * Inserts one label into an enum type: after the last label by default, and + * otherwise directly before or after the stated neighbor, so the modeled + * labels stay in PostgreSQL's sort order. Adding a value is the only + * {@code ALTER TYPE} action sqlcj models; every other action is rejected. + * + *

As PostgreSQL does, {@code IF NOT EXISTS} is decided by the label + * alone: a label the type already has is a no-op even when the stated + * neighbor is not one of its labels. + */ + private void applyAddEnumValue(AlterType alterType, List enums) { + String typeName = MultiPartName.unquote(alterType.getName()); + + int index = indexOfEnum(enums, typeName); + + if (index < 0) { + throw typeNotFound(typeName); + } + + EnumType enumType = enums.get(index); + String label = alterType.getValue().getNotExcapedValue(); + + if (enumType.labels().contains(label)) { + if (alterType.isIfNotExists()) { + return; + } + + throw labelAlreadyExists(typeName, label); + } + + List labels = new ArrayList<>(enumType.labels()); + + labels.add(labelPosition(alterType, enumType), label); + + enums.set(index, new EnumType(enumType.name(), labels)); + } + + /** The position an added label takes among the type's existing labels. */ + private int labelPosition(AlterType alterType, EnumType enumType) { + AlterType.Position position = alterType.getPosition(); + + if (position == null) { + return enumType.labels().size(); + } + + String neighbor = alterType.getNeighborValue().getNotExcapedValue(); + + int index = enumType.labels().indexOf(neighbor); + + if (index < 0) { + throw labelNotFound(enumType.name(), neighbor); + } + + return position == AlterType.Position.BEFORE + ? index + : index + 1; + } + /** * Reports whether a statement is one of the documented statements a schema * source may state without changing the schema model. Such a statement is @@ -152,10 +257,6 @@ private boolean isIgnored(Statement statement) { return hasDollarQuotedBody(createFunction); } - if (statement instanceof CreateType createType) { - return createType.getDefinition() instanceof EnumTypeDefinition; - } - if (statement instanceof UnsupportedStatement unsupported) { return ALTER_INDEX.matcher(unsupported.toString()).find(); } @@ -207,7 +308,11 @@ private boolean hasDollarQuotedBody(CreateFunction createFunction) { * Appends one table. A repeated table name fails, as PostgreSQL rejects it, * unless the statement declares {@code IF NOT EXISTS}. */ - private void applyCreateTable(CreateTable createTable, List

tables) { + private void applyCreateTable( + CreateTable createTable, + List enums, + List
tables + ) { String tableName = createTable.getTable().getUnquotedName(); if (indexOfTable(tables, tableName) >= 0) { @@ -218,7 +323,7 @@ private void applyCreateTable(CreateTable createTable, List
tables) { throw tableAlreadyExists(tableName); } - tables.add(parseTable(createTable)); + tables.add(parseTable(createTable, enums)); } /** Removes every named table, keeping the order of the remaining ones. */ @@ -245,7 +350,7 @@ private void applyDropTable(Drop drop, List
tables) { * of the altered table. {@code ALTER TABLE IF EXISTS} covers only the table, * so a missing column of an existing table still fails. */ - private void applyAlterTable(Alter alter, int line, List
tables) { + private void applyAlterTable(Alter alter, int line, List enums, List
tables) { String tableName = alter.getTable().getUnquotedName(); int index = indexOfTable(tables, tableName); @@ -259,7 +364,7 @@ private void applyAlterTable(Alter alter, int line, List
tables) { } for (AlterExpression expression : alter.getAlterExpressions()) { - tables.set(index, applyAlterExpression(alter, line, expression, tables, index)); + tables.set(index, applyAlterExpression(alter, line, expression, enums, tables, index)); } } @@ -273,6 +378,7 @@ private Table applyAlterExpression( Alter alter, int line, AlterExpression expression, + List enums, List
tables, int index ) { @@ -280,7 +386,7 @@ private Table applyAlterExpression( AlterOperation operation = expression.getOperation(); if (operation == AlterOperation.ADD && isNotEmpty(expression.getColDataTypeList())) { - return addColumns(expression, table); + return addColumns(expression, enums, table); } if (operation == AlterOperation.DROP && expression.getColumnName() != null) { @@ -296,7 +402,7 @@ private Table applyAlterExpression( } if (operation == AlterOperation.ALTER) { - return alterColumns(alter, line, expression, table); + return alterColumns(alter, line, expression, enums, table); } if (isIgnoredConstraintAction(expression)) { @@ -344,13 +450,19 @@ private boolean isIgnoredConstraintKind(Index index) { * Applies one {@code ALTER COLUMN} action: a new type, which keeps the * column's position and nullability, or a nullability change. */ - private Table alterColumns(Alter alter, int line, AlterExpression expression, Table table) { + private Table alterColumns( + Alter alter, + int line, + AlterExpression expression, + List enums, + Table table + ) { if (isNotEmpty(expression.getColDataTypeList())) { if (!statesNewTypes(expression.getColDataTypeList())) { throw unsupportedStatement(alter, line); } - return changeColumnTypes(expression, table); + return changeColumnTypes(expression, enums, table); } if (isNotEmpty(expression.getColumnSetNotNullList())) { @@ -393,12 +505,12 @@ private boolean statesNewTypes(List definitions) * Appends each added column, typed exactly as a {@code CREATE TABLE} column * of the same declaration, and records its column-level constraints. */ - private Table addColumns(AlterExpression expression, Table table) { + private Table addColumns(AlterExpression expression, List enums, Table table) { List columns = new ArrayList<>(table.columns()); List constraints = new ArrayList<>(table.constraints()); for (AlterExpression.ColumnDataType definition : expression.getColDataTypeList()) { - Column column = parseColumn(definition); + Column column = parseColumn(definition, enums); if (indexOfColumn(columns, column.name()) >= 0) { if (expression.isUseIfNotExists()) { @@ -464,7 +576,13 @@ private Table renameColumn(AlterExpression expression, Table table) { columns.set( index, - new Column(newName, column.type(), column.nullable(), column.unsupportedType()) + new Column( + newName, + column.type(), + column.nullable(), + column.unsupportedType(), + column.enumType() + ) ); List constraints = table.constraints().stream() @@ -494,7 +612,11 @@ private Table renameTable(AlterExpression expression, List
tables, int in * type as a {@code CREATE TABLE} column of the same type would be, and * keeping the column's nullability, which a type change does not state. */ - private Table changeColumnTypes(AlterExpression expression, Table table) { + private Table changeColumnTypes( + AlterExpression expression, + List enums, + Table table + ) { List columns = new ArrayList<>(table.columns()); for (AlterExpression.ColumnDataType definition : expression.getColDataTypeList()) { @@ -507,7 +629,7 @@ private Table changeColumnTypes(AlterExpression expression, Table table) { } Column column = columns.get(index); - Column retyped = parseColumn(definition); + Column retyped = parseColumn(definition, enums); columns.set( index, @@ -515,7 +637,8 @@ private Table changeColumnTypes(AlterExpression expression, Table table) { column.name(), retyped.type(), column.nullable(), - retyped.unsupportedType() + retyped.unsupportedType(), + retyped.enumType() ) ); } @@ -544,7 +667,13 @@ private Table changeNullability( columns.set( index, - new Column(column.name(), column.type(), nullable, column.unsupportedType()) + new Column( + column.name(), + column.type(), + nullable, + column.unsupportedType(), + column.enumType() + ) ); } @@ -583,6 +712,17 @@ private int indexOfTable(List
tables, String tableName) { return -1; } + /** Enum type names are matched case-insensitively, as a column names them. */ + private int indexOfEnum(List enums, String typeName) { + for (int i = 0; i < enums.size(); i++) { + if (enums.get(i).name().equalsIgnoreCase(typeName)) { + return i; + } + } + + return -1; + } + private int indexOfColumn(List columns, String columnName) { for (int i = 0; i < columns.size(); i++) { if (columns.get(i).name().equalsIgnoreCase(columnName)) { @@ -617,6 +757,26 @@ private IllegalArgumentException columnAlreadyExists(String tableName, String co ); } + private IllegalArgumentException typeNotFound(String typeName) { + return new IllegalArgumentException("Type not found in schema: " + typeName); + } + + private IllegalArgumentException typeAlreadyExists(String typeName) { + return new IllegalArgumentException("Type already exists in schema: " + typeName); + } + + private IllegalArgumentException labelNotFound(String typeName, String label) { + return new IllegalArgumentException( + "Label not found in type %s: %s".formatted(typeName, label) + ); + } + + private IllegalArgumentException labelAlreadyExists(String typeName, String label) { + return new IllegalArgumentException( + "Label already exists in type %s: %s".formatted(typeName, label) + ); + } + private UnsupportedOperationException unsupportedStatement(Statement statement, int line) { return new UnsupportedOperationException( "Unsupported schema statement: %s at line %d" @@ -624,14 +784,14 @@ private UnsupportedOperationException unsupportedStatement(Statement statement, ); } - private Table parseTable(CreateTable createTable) { + private Table parseTable(CreateTable createTable, List enums) { String tableName = createTable.getTable().getUnquotedName(); List columns = new ArrayList<>(); List constraints = new ArrayList<>(); for (ColumnDefinition definition : createTable.getColumnDefinitions()) { - Column column = parseColumn(definition); + Column column = parseColumn(definition, enums); if (indexOfColumn(columns, column.name()) >= 0) { throw columnAlreadyExists(tableName, column.name()); @@ -653,9 +813,10 @@ private Table parseTable(CreateTable createTable) { /** * Parses one column, recording a column sqlcj cannot map with its declared * type text instead of failing the schema. An array column is such a column - * regardless of its element type, because the mapped types are all scalar. + * regardless of its element type, because the mapped types and the modeled + * enum types are all scalar. */ - private Column parseColumn(ColumnDefinition definition) { + private Column parseColumn(ColumnDefinition definition, List enums) { String typeName = typeName(definition); int arrayDimensions = arrayDimensions(definition); boolean nullable = !isSerial(typeName) && isNullable(definition); @@ -668,6 +829,20 @@ private Column parseColumn(ColumnDefinition definition) { return new Column(columnName(definition), type, nullable); } + EnumType enumType = arrayDimensions == 0 + ? declaredEnum(definition, enums) + : null; + + if (enumType != null) { + return new Column( + columnName(definition), + ColumnType.ENUM, + nullable, + null, + enumType.name() + ); + } + return new Column( columnName(definition), null, @@ -676,6 +851,24 @@ private Column parseColumn(ColumnDefinition definition) { ); } + /** + * The enum type a column of an unmapped type names, or {@code null} when + * the schema declares no such type. The declared type is matched without + * its SQL identifier delimiters and case-insensitively, as PostgreSQL + * resolves an unquoted type name. + */ + private EnumType declaredEnum(ColumnDefinition definition, List enums) { + String declaredType = MultiPartName.unquote( + definition.getColDataType().getDataType() + ); + + int index = indexOfEnum(enums, declaredType); + + return index < 0 + ? null + : enums.get(index); + } + /** * Reports how many array dimensions a column declares. The parser reports * an array's dimensions separately from its element type, so a column with diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/type/DefaultTypeResolver.java b/sqlcj-cli/src/main/java/dev/sqlcj/type/DefaultTypeResolver.java index 8970307..2aa3c1f 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/type/DefaultTypeResolver.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/type/DefaultTypeResolver.java @@ -21,6 +21,9 @@ public String resolve(ColumnType type) { case DOUBLE_PRECISION -> "Double"; case UUID -> "UUID"; case BYTEA -> "byte[]"; + case ENUM -> throw new IllegalArgumentException( + "An enum column has no mapped Java type; its generated enum type is named by the generator" + ); }; } } 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 e723a8b..cd15a35 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -4,6 +4,7 @@ import dev.sqlcj.parser.QueryType; import dev.sqlcj.schema.Column; import dev.sqlcj.schema.ColumnType; +import dev.sqlcj.schema.EnumType; import dev.sqlcj.schema.Schema; import dev.sqlcj.schema.Table; import dev.sqlcj.sql.ParsedSql; @@ -36,6 +37,29 @@ class QueryAnalyzerTest { ) ); + /** + * A schema whose {@code stages} table carries a column of each declared + * enum type, beside a text column. + */ + private static final Schema enumSchema = new Schema( + List.of( + new Table( + "stages", + List.of( + new Column("id", ColumnType.BIGINT, false), + new Column("setting", ColumnType.ENUM, false, null, "stage_setting"), + new Column("state", ColumnType.ENUM, true, null, "shelf_state"), + new Column("handle", ColumnType.TEXT, true) + ), + List.of() + ) + ), + List.of( + new EnumType("stage_setting", List.of("indoor", "outdoor")), + new EnumType("shelf_state", List.of("stocked")) + ) + ); + /** A schema with both text column types, for the text predicate forms. */ private static final Schema predicateSchema = new Schema( List.of( @@ -2939,4 +2963,107 @@ void shouldAnalyzeIsNullOnAnUnsupportedTypeColumn() { model.executableSql() ); } + + /** + * A selected enum column, an enum column returned by a write, and a + * placeholder typed from one carry the enum type the schema declared, so + * generation can name the Java enum of that type. + */ + @Test + void shouldCarryTheEnumTypeOfASelectedColumnAndItsParameter() { + String sql = "SELECT setting, handle FROM stages WHERE setting = $1"; + + QueryModel model = analyzer.analyze( + new Query("ListStages", QueryType.MANY, sql), + parser.parse(sql), + enumSchema + ); + + assertEquals( + List.of( + new QueryColumn("setting", ColumnType.ENUM, false, "stage_setting"), + new QueryColumn("handle", ColumnType.TEXT, true) + ), + model.columns() + ); + + assertEquals( + List.of(new QueryParameter(1, "setting", ColumnType.ENUM, "stage_setting")), + model.parameters() + ); + } + + @Test + void shouldCarryTheEnumTypeOfAReturningColumnAndItsParameter() { + String sql = "INSERT INTO stages (id, setting) VALUES ($1, $2) RETURNING setting"; + + QueryModel model = analyzer.analyze( + new Query("CreateStage", QueryType.ONE, sql), + parser.parse(sql), + enumSchema + ); + + assertEquals( + List.of(new QueryColumn("setting", ColumnType.ENUM, false, "stage_setting")), + model.columns() + ); + + assertEquals( + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "setting", ColumnType.ENUM, "stage_setting") + ), + model.parameters() + ); + } + + /** + * A repeated placeholder index must resolve to one Java type, and each + * enum type generates a Java type of its own, so an index shared by an enum + * and a text column, or by two enum types, is rejected. An enum is named by + * its PostgreSQL type, because analysis renders no Java name. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + quoteCharacter = '"', + value = { + "UPDATE stages SET setting = $1 WHERE handle = $1|" + + "Placeholder $1 has conflicting types: stage_setting from 'setting' and String from 'handle'", + "UPDATE stages SET handle = $1 WHERE setting = $1|" + + "Placeholder $1 has conflicting types: String from 'handle' and stage_setting from 'setting'", + "UPDATE stages SET setting = $1 WHERE state = $1|" + + "Placeholder $1 has conflicting types: stage_setting from 'setting' and shelf_state from 'state'" + } + ) + void shouldRejectARepeatedIndexAcrossConflictingEnumTypes(String sql, String message) { + Query query = new Query("UpdateStage", QueryType.EXEC, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, enumSchema) + ); + + assertEquals(message, exception.getMessage()); + } + + /** A repeated index of one enum type shares one generated parameter. */ + @Test + void shouldAcceptARepeatedIndexOfOneEnumType() { + String sql = "SELECT id FROM stages WHERE setting = $1 OR setting = $1"; + + QueryModel model = analyzer.analyze( + new Query("ListStages", QueryType.MANY, sql), + parser.parse(sql), + enumSchema + ); + + assertEquals( + List.of(new QueryParameter(1, "setting", ColumnType.ENUM, "stage_setting")), + model.parameters() + ); + + assertEquals(List.of(1, 1), model.bindingParameterIndexes()); + } } 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 73ffde5..d1e5d09 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -30,6 +30,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.sql.Connection; +import java.sql.ResultSet; import java.sql.Statement; import java.time.LocalDate; import java.time.LocalDateTime; @@ -180,6 +181,29 @@ closed_at TIME(3) WITHOUT TIME ZONE ); """; + /** + * A snapshot whose enum type is declared and then extended in the order the + * statements state, so the modeled labels are PostgreSQL's own sort order. + * The added labels are not used by this DDL itself, which PostgreSQL + * forbids inside the transaction that adds them. + */ + private static final String STAGE_EVENT_SCHEMA = """ + CREATE TYPE stage_setting AS ENUM ('indoor', 'outdoor'); + + ALTER TYPE stage_setting ADD VALUE 'covered' BEFORE 'outdoor'; + + ALTER TYPE stage_setting ADD VALUE 'open air' AFTER 'outdoor'; + + ALTER TYPE stage_setting ADD VALUE IF NOT EXISTS 'indoor'; + + CREATE TABLE stage_events + ( + id BIGINT PRIMARY KEY, + setting stage_setting, + title VARCHAR(255) + ); + """; + /** * Queries used by the caller-owned transaction tests: an affected-row * write, a returning write, and a read. @@ -332,6 +356,7 @@ static void stopPostgres() { void resetDatabase() throws Exception { execute("DROP TABLE IF EXISTS stage"); execute("DROP TABLE IF EXISTS region"); + execute("DROP TABLE IF EXISTS stage_events"); execute("DROP TYPE IF EXISTS stage_setting"); execute("DROP TABLE IF EXISTS customer_orders"); execute("DROP TABLE IF EXISTS customers"); @@ -1174,6 +1199,123 @@ void shouldRoundTripFloatingPointBinaryAndTimeValues() throws Exception { } } + /** + * Proves that enum values round trip through generated code: the generated + * constants carry PostgreSQL's own labels in its own sort order, a null and + * a non-null value are written as {@code INSERT} values and read back as + * constants of the generated enum, and an equality predicate matches by + * label while a null argument matches no row. + */ + @Test + void shouldRoundTripEnumValues() throws Exception { + execute(STAGE_EVENT_SCHEMA); + + Path classesDirectory = generateAndCompile( + STAGE_EVENT_SCHEMA, + """ + -- name: InsertStageEvent :exec + INSERT INTO stage_events (id, setting, title) + VALUES ($1, $2, $3); + + -- name: GetStageEvent :one + SELECT id, setting, title + FROM stage_events + WHERE id = $1; + + -- name: FindStageEventBySetting :optional + SELECT id, setting, title + FROM stage_events + WHERE setting = $1; + """ + ); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Class stageSetting = Class.forName( + "generated.StageSetting", + true, + classLoader + ); + + assertTrue(stageSetting.isEnum()); + + Object[] constants = stageSetting.getEnumConstants(); + + assertEquals( + List.of("INDOOR", "COVERED", "OUTDOOR", "OPEN_AIR"), + Arrays.stream(constants).map(Object::toString).toList() + ); + + Method label = stageSetting.getMethod("label"); + + List labels = new ArrayList<>(); + + for (Object constant : constants) { + labels.add(label.invoke(constant)); + } + + assertEquals(enumLabels("stage_setting"), labels); + + Object covered = constants[1]; + + Object repository = newRepository(classLoader); + + Method insertMethod = repository.getClass().getMethod( + "insertStageEvent", + Long.class, + stageSetting, + String.class + ); + + assertEquals(1, insertMethod.invoke(repository, 1L, covered, "Covered Stage")); + assertEquals(1, insertMethod.invoke(repository, 2L, null, null)); + + Method queryMethod = repository.getClass().getMethod("getStageEvent", Long.class); + + Object result = queryMethod.invoke(repository, 1L); + + assertNotNull(result); + + assertEquals( + List.of("id", "setting", "title"), + recordComponentNames(result) + ); + + assertEquals( + List.of(Long.class, stageSetting, String.class), + recordComponentTypes(result) + ); + + assertEquals(covered, component(result, "setting")); + assertEquals("Covered Stage", component(result, "title")); + + Object nullResult = queryMethod.invoke(repository, 2L); + + assertNotNull(nullResult); + + assertEquals(2L, component(nullResult, "id")); + assertNull(component(nullResult, "setting")); + + Method findMethod = repository.getClass().getMethod( + "findStageEventBySetting", + stageSetting + ); + + Optional found = assertInstanceOf( + Optional.class, + findMethod.invoke(repository, covered) + ); + + assertEquals(1L, component(found.orElseThrow(), "id")); + + assertTrue( + assertInstanceOf( + Optional.class, + findMethod.invoke(repository, new Object[] { null }) + ).isEmpty() + ); + } + } + /** * Proves that JSON text round trips through generated code: one row is * written with JSON values and one with nulls, both are read back as @@ -2075,6 +2217,26 @@ private Object newRepository( return constructor.newInstance(executor); } + /** The labels of one enum type in PostgreSQL's own sort order. */ + private List enumLabels(String typeName) throws Exception { + List labels = new ArrayList<>(); + + try ( + Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery( + "SELECT enumlabel FROM pg_enum WHERE enumtypid = '%s'::regtype ORDER BY enumsortorder" + .formatted(typeName) + ) + ) { + while (resultSet.next()) { + labels.add(resultSet.getString(1)); + } + } + + return labels; + } + private void execute(String sql) throws Exception { try ( Connection connection = dataSource.getConnection(); 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 4a09321..d052824 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -1747,6 +1747,192 @@ private Path authorQueries() throws IOException { return queriesFile; } + /** + * Two entries over one migration directory that use one enum type share + * the single Java enum the package generates for it, which is listed in the + * manifest like every other generated file. + */ + @Test + void shouldGenerateOneSharedEnumForTwoEntries() throws Exception { + Path migrations = stageMigrations(); + Path queriesFile = stageQueries(); + Path generatedDirectory = tempDir.resolve("generated"); + + new SqlcjCompiler().compile( + authorAndLibraryConfig( + migrations, + queriesFile, + migrations, + queriesFile, + generatedDirectory + ) + ); + + Path packageDirectory = generatedDirectory.resolve("dev/example/generated"); + + assertTrue(Files.exists(packageDirectory.resolve("StageSetting.java"))); + + assertEquals( + "dev/example/generated/AuthorRepository.java\n" + + "dev/example/generated/LibraryRepository.java\n" + + "dev/example/generated/StageSetting.java\n", + Files.readString(generatedDirectory.resolve("sqlcj-manifest.txt")) + ); + + Path classesDirectory = tempDir.resolve("classes"); + + assertCompiles(generatedDirectory, classesDirectory); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Class stageSetting = Class.forName( + "dev.example.generated.StageSetting", + true, + classLoader + ); + + assertTrue(stageSetting.isEnum()); + assertNull(stageSetting.getEnclosingClass()); + + for (String repositoryName : List.of("AuthorRepository", "LibraryRepository")) { + Class repository = Class.forName( + "dev.example.generated." + repositoryName, + true, + classLoader + ); + + Method method = repository.getMethod("getStage", stageSetting); + + assertEquals( + stageSetting, + method.getReturnType().getRecordComponents()[1].getType() + ); + } + } + } + + /** A Java enum no entry uses anymore is deleted on regeneration. */ + @Test + void shouldDeleteTheStaleEnumOfARemovedEnumQuery() throws IOException { + Path migrations = stageMigrations(); + Path queriesFile = stageQueries(); + Path generatedDirectory = tempDir.resolve("generated"); + + Config config = new Config( + List.of(new SqlConfig("Author", migrations.toString(), queriesFile.toString())), + new JavaConfig(generatedDirectory.toString(), "dev.example.generated") + ); + + new SqlcjCompiler().compile(config); + + Path enumFile = generatedDirectory.resolve("dev/example/generated/StageSetting.java"); + + assertTrue(Files.exists(enumFile)); + + Files.writeString( + queriesFile, + """ + -- name: GetStage :one + SELECT id + FROM stages + WHERE id = $1; + """ + ); + + new SqlcjCompiler().compile(config); + + assertFalse(Files.exists(enumFile)); + + assertEquals( + "dev/example/generated/AuthorRepository.java\n", + Files.readString(generatedDirectory.resolve("sqlcj-manifest.txt")) + ); + } + + /** + * Two entries that use one enum type must define its labels alike, because + * the package generates one Java enum for it. + */ + @Test + void shouldReportTwoEntriesThatDefineOneEnumTypeDifferently() throws IOException { + Path authorSchema = tempDir.resolve("author-schema.sql"); + Path librarySchema = tempDir.resolve("library-schema.sql"); + Path queriesFile = stageQueries(); + Path generatedDirectory = tempDir.resolve("generated"); + + Files.writeString(authorSchema, stageSchema("'indoor', 'outdoor'")); + Files.writeString(librarySchema, stageSchema("'indoor'")); + + Config config = authorAndLibraryConfig( + authorSchema, + queriesFile, + librarySchema, + queriesFile, + generatedDirectory + ); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> new SqlcjCompiler().compile(config) + ); + + assertEquals( + "Invalid query group 'Library' in %s: ".formatted(queriesFile) + + "Enum type 'stage_setting' differs from its definition in query group 'Author', " + + "which generates the same enum type StageSetting", + exception.getMessage() + ); + + assertFalse(Files.exists(generatedDirectory)); + } + + /** A migration directory that declares an enum type and a table using it. */ + private Path stageMigrations() throws IOException { + Path migrations = Files.createDirectories(tempDir.resolve("migrations")); + + Files.writeString( + migrations.resolve("V1__stages.sql"), + stageSchema("'indoor'") + ); + + Files.writeString( + migrations.resolve("V2__outdoor.sql"), + "ALTER TYPE stage_setting ADD VALUE 'outdoor';\n" + ); + + return migrations; + } + + /** A schema declaring the enum type with {@code labels} and its table. */ + private String stageSchema(String labels) { + return """ + CREATE TYPE stage_setting AS ENUM (%s); + + CREATE TABLE stages + ( + id BIGINT NOT NULL, + setting stage_setting + ); + """ + .formatted(labels); + } + + /** One query reading and binding the enum column of the table stages. */ + private Path stageQueries() throws IOException { + Path queriesFile = tempDir.resolve("stage-queries.sql"); + + Files.writeString( + queriesFile, + """ + -- name: GetStage :one + SELECT id, setting + FROM stages + WHERE setting = $1; + """ + ); + + return queriesFile; + } + private Config authorAndLibraryConfig( Path authorSchema, Path authorQueries, 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 0d6227e..f70e2f8 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java @@ -6,6 +6,7 @@ import dev.sqlcj.analysis.QueryParameter; import dev.sqlcj.parser.QueryType; import dev.sqlcj.schema.ColumnType; +import dev.sqlcj.schema.EnumType; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; @@ -14,14 +15,21 @@ import javax.tools.JavaCompiler; import javax.tools.ToolProvider; import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +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.ArrayList; +import java.util.Arrays; 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.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -2596,4 +2604,381 @@ private QueryModel query(String name, QueryType type, List param null ); } + + /** + * An enum column and an enum parameter use the Java enum the package + * generates for their enum type, by its simple name and without an import: + * the parameter is bound as the label of its constant, wrapped so that + * PostgreSQL types it from the context of its placeholder, and the column + * is read from its label at every binding position of its index. + */ + @Test + void shouldBindEnumLabelsAndReadEnumColumnsByLabel() throws IOException { + QueryModel query = new QueryModel( + "FindStage", + QueryType.ONE, + "stages", + """ + SELECT id, setting, handle + FROM stages + WHERE handle = ? + AND (setting = ? OR setting = ?) + """, + List.of(2, 1, 1), + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("setting", ColumnType.ENUM, true, "stage_setting"), + new QueryColumn("handle", ColumnType.VARCHAR, true) + ), + List.of( + new QueryParameter(1, "setting", ColumnType.ENUM, "stage_setting"), + new QueryParameter(2, "handle", ColumnType.VARCHAR) + ), + null + ); + + GeneratedFile repository = generate( + enumGroup( + GROUP, + query, + new EnumType("stage_setting", List.of("indoor", "outdoor")) + ) + ); + + String source = repository.content(); + + assertTrue( + source.contains("public FindStageResult findStage(StageSetting setting, String handle)") + ); + + assertTrue( + source.contains( + "java.util.Arrays.asList(" + + "handle, " + + "new dev.sqlcj.runtime.UntypedText(setting == null ? null : setting.label()), " + + "new dev.sqlcj.runtime.UntypedText(setting == null ? null : setting.label()))" + ) + ); + + assertTrue(source.contains("StageSetting setting")); + assertTrue(source.contains("StageSetting.fromLabel(resultSet.getString(2))")); + assertTrue(source.contains("resultSet.getObject(1, Long.class)")); + assertTrue(source.contains("resultSet.getObject(3, String.class)")); + + assertFalse(source.contains("import generated.StageSetting;")); + + assertCompiles(repository, codeGenerator.generateEnums().getFirst()); + } + + /** + * The generated enum names one constant per label, in label order, holds + * the exact labels, and resolves a label back to its constant. A null label + * reads back as {@code null}, and a label the enum does not hold, which + * means the database declares one the schema source does not, fails naming + * the enum type and the label. + */ + @Test + void shouldGenerateCompilableEnumWithLabelLookups() throws Exception { + GeneratedFile repository = generate( + enumGroup( + GROUP, + enumQuery("GetStage"), + new EnumType("stage_setting", List.of("indoor", "out door")) + ) + ); + + List enums = codeGenerator.generateEnums(); + + assertEquals(1, enums.size()); + assertEquals(Path.of("generated", "StageSetting.java"), enums.getFirst().path()); + + assertCompiles(repository, enums.getFirst()); + + try (URLClassLoader classLoader = classLoader()) { + Class type = Class.forName("generated.StageSetting", true, classLoader); + + assertTrue(type.isEnum()); + + Object[] constants = type.getEnumConstants(); + + assertEquals( + List.of("INDOOR", "OUT_DOOR"), + Arrays.stream(constants).map(Object::toString).toList() + ); + + Method label = type.getMethod("label"); + + assertEquals("indoor", label.invoke(constants[0])); + assertEquals("out door", label.invoke(constants[1])); + + Method fromLabel = type.getMethod("fromLabel", String.class); + + assertEquals(constants[1], fromLabel.invoke(null, "out door")); + assertNull(fromLabel.invoke(null, new Object[] { null })); + + InvocationTargetException failure = assertThrows( + InvocationTargetException.class, + () -> fromLabel.invoke(null, "covered") + ); + + assertInstanceOf(IllegalArgumentException.class, failure.getCause()); + + assertEquals( + "Unknown label for enum type stage_setting: covered", + failure.getCause().getMessage() + ); + } + } + + /** An enum type no column and no parameter uses generates no file. */ + @Test + void shouldGenerateOnlyTheEnumTypesTheQueriesUse() { + codeGenerator.generate( + new QueryGroupModel( + GROUP, + List.of(enumQuery("GetStage")), + List.of( + new EnumType("stage_setting", List.of("indoor")), + new EnumType("shelf_state", List.of("stocked")) + ) + ) + ); + + assertEquals( + List.of(Path.of("generated", "StageSetting.java")), + codeGenerator.generateEnums().stream() + .map(GeneratedFile::path) + .toList() + ); + } + + /** + * An enum type name is the upper camel form of its PostgreSQL name, and a + * constant is the label's words, upper-cased and joined with {@code _}, + * with a leading {@code _} for a constant that would start with a digit. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + quoteCharacter = '"', + value = { + "stage_setting|indoor|StageSetting|INDOOR", + "HTTP_state|in progress|HttpState|IN_PROGRESS", + "stage_setting|in-progress|StageSetting|IN_PROGRESS", + "stage_setting|InProgress|StageSetting|INPROGRESS", + "stage_setting|ID|StageSetting|ID", + "stage_setting|2fast|StageSetting|_2FAST" + } + ) + void shouldNameTheGeneratedEnumAndItsConstants( + String enumName, + String label, + String typeName, + String constant + ) { + codeGenerator.generate( + enumGroup( + GROUP, + enumQuery("GetStage", enumName), + new EnumType(enumName, List.of(label)) + ) + ); + + GeneratedFile file = codeGenerator.generateEnums().getFirst(); + + assertEquals(Path.of("generated", typeName + ".java"), file.path()); + assertTrue(file.content().contains("public enum " + typeName + " {")); + assertTrue(file.content().contains(constant + "(\"" + label + "\");")); + } + + /** A label with no letter and no digit generates no Java constant. */ + @Test + void shouldRejectALabelWithoutALetterOrDigit() { + QueryGroupModel group = enumGroup( + GROUP, + enumQuery("GetStage"), + new EnumType("stage_setting", List.of("indoor", "***")) + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> codeGenerator.generate(group) + ); + + assertEquals( + "Label '***' of enum type 'stage_setting' has no letter or digit to generate a Java constant from", + exception.getMessage() + ); + } + + /** Two labels of one enum type must generate two constants. */ + @Test + void shouldRejectTwoLabelsThatGenerateOneConstant() { + QueryGroupModel group = enumGroup( + GROUP, + enumQuery("GetStage"), + new EnumType("stage_setting", List.of("in progress", "in-progress")) + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> codeGenerator.generate(group) + ); + + assertEquals( + "Labels 'in progress' and 'in-progress' of enum type 'stage_setting' generate the same constant IN_PROGRESS", + exception.getMessage() + ); + } + + /** + * Two groups that use one enum type generate one Java enum, so they must + * define its labels alike. + */ + @Test + void shouldRejectTwoGroupsThatDefineOneEnumTypeDifferently() { + codeGenerator.generate( + enumGroup( + "Stages", + enumQuery("GetStage"), + new EnumType("stage_setting", List.of("indoor", "outdoor")) + ) + ); + + QueryGroupModel group = enumGroup( + "Venues", + enumQuery("GetVenue"), + new EnumType("stage_setting", List.of("indoor")) + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> codeGenerator.generate(group) + ); + + assertEquals( + "Enum type 'stage_setting' differs from its definition in query group 'Stages', " + + "which generates the same enum type StageSetting", + exception.getMessage() + ); + + assertEquals(1, codeGenerator.generateEnums().size()); + } + + /** + * Two enum types whose Java enums are equal ignoring case are rejected, + * because the compiled class files of those types are one path on a + * case-insensitive filesystem. + */ + @Test + void shouldRejectEnumTypesThatAreEqualIgnoringCase() { + codeGenerator.generate( + enumGroup( + "Stages", + enumQuery("GetStage"), + new EnumType("stage_setting", List.of("indoor")) + ) + ); + + QueryGroupModel group = enumGroup( + "Venues", + enumQuery("GetVenue", "stagesetting"), + new EnumType("stagesetting", List.of("indoor")) + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> codeGenerator.generate(group) + ); + + assertEquals( + "Enum types 'stage_setting' and 'stagesetting' generate enum types that are equal ignoring case: " + + "StageSetting and Stagesetting", + exception.getMessage() + ); + } + + /** + * A Java enum must not take the name of another generated type of the + * package, in either generation order. + */ + @Test + void shouldRejectAnEnumTypeThatCollidesWithAnEarlierGeneratedType() { + codeGenerator.generate( + new QueryGroupModel("Users", List.of(getUsersRow("GetUser"))) + ); + + QueryGroupModel group = enumGroup( + "Stages", + enumQuery("GetStage", "users_row"), + new EnumType("users_row", List.of("indoor")) + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> codeGenerator.generate(group) + ); + + assertEquals( + "Enum type 'users_row' generates UsersRow, which is equal ignoring case to the generated type UsersRow", + exception.getMessage() + ); + } + + @Test + void shouldRejectAGeneratedTypeThatCollidesWithAnEarlierEnumType() { + codeGenerator.generate( + enumGroup( + "Stages", + enumQuery("GetStage", "usersrow"), + new EnumType("usersrow", List.of("indoor")) + ) + ); + + QueryGroupModel group = new QueryGroupModel("Users", List.of(getUsersRow("GetUser"))); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> codeGenerator.generate(group) + ); + + assertEquals( + "Enum type 'usersrow' generates Usersrow, which is equal ignoring case to the generated type UsersRow", + exception.getMessage() + ); + } + + /** A {@code :one} query reading and binding one enum column. */ + private QueryModel enumQuery(String queryName) { + return enumQuery(queryName, "stage_setting"); + } + + private QueryModel enumQuery(String queryName, String enumName) { + return new QueryModel( + queryName, + QueryType.ONE, + "stages", + "SELECT setting FROM stages WHERE setting = ?", + List.of(1), + List.of(new QueryColumn("setting", ColumnType.ENUM, true, enumName)), + List.of(new QueryParameter(1, "setting", ColumnType.ENUM, enumName)), + null + ); + } + + private QueryGroupModel enumGroup(String groupName, QueryModel query, EnumType enumType) { + return new QueryGroupModel(groupName, List.of(query), List.of(enumType)); + } + + private GeneratedFile generate(QueryGroupModel group) { + return codeGenerator.generate(group); + } + + /** Loads the classes the compiled generated files left. */ + private URLClassLoader classLoader() throws IOException { + return new URLClassLoader( + new URL[] { tempDir.resolve("classes").toUri().toURL() }, + getClass().getClassLoader() + ); + } } diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java index 36b1873..299a992 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java @@ -4,6 +4,7 @@ import dev.sqlcj.schema.ColumnType; import dev.sqlcj.schema.Constraint; import dev.sqlcj.schema.ConstraintType; +import dev.sqlcj.schema.EnumType; import dev.sqlcj.schema.Schema; import dev.sqlcj.schema.Table; import org.junit.jupiter.api.Test; @@ -755,8 +756,7 @@ void shouldRejectAlterColumnAddIdentity() { "DROP TRIGGER users_touch ON users;", "INSERT INTO users (id) VALUES (1);", "UPDATE users SET name = 'new';", - "DELETE FROM users;", - "CREATE TYPE status AS ENUM ('draft', 'sent');" + "DELETE FROM users;" } ) void shouldIgnoreDocumentedStatements(String statement) { @@ -854,7 +854,8 @@ void shouldApplyAModeledActionBesideAnIgnoredConstraintAction() { value = { "CREATE VIEW active_users AS SELECT id FROM users;|CreateView", "CREATE TYPE address AS (street TEXT, city TEXT);|CreateType", - "ALTER TYPE status ADD VALUE 'archived';|AlterType", + "ALTER TYPE status RENAME TO state;|AlterType", + "ALTER TYPE status RENAME VALUE 'draft' TO 'new';|AlterType", "CREATE DOMAIN positive AS INTEGER CHECK (VALUE > 0);|CreateDomain", "CREATE SCHEMA app;|CreateSchema", "DROP VIEW active_users;|Drop", @@ -1398,8 +1399,9 @@ CREATE TABLE users ( /** * A single-file schema of the kind published sqlc examples use loads whole: - * its enum type, indexes, and dollar-quoted function are accepted and - * ignored, and its enum and array columns are recorded as unsupported. + * its indexes and dollar-quoted function are accepted and ignored, its enum + * type is modeled together with the column that names it, and its array + * column is recorded as unsupported. */ @Test void shouldLoadASingleFileSchemaWithEnumArrayIndexAndFunctionStatements() { @@ -1407,6 +1409,16 @@ void shouldLoadASingleFileSchemaWithEnumArrayIndexAndFunctionStatements() { assertEquals(List.of("shelves", "records"), tableNames(schema)); + assertEquals( + List.of( + new EnumType( + "shelf_state", + List.of("stocked", "reserved", "retired") + ) + ), + schema.enums() + ); + Table shelves = table(schema, "shelves"); assertEquals( @@ -1434,7 +1446,7 @@ void shouldLoadASingleFileSchemaWithEnumArrayIndexAndFunctionStatements() { new Column("record_id", ColumnType.INTEGER, false), new Column("shelf_id", ColumnType.INTEGER, false), new Column("catalog_no", ColumnType.TEXT, false), - new Column("state", null, false, "SHELF_STATE"), + new Column("state", ColumnType.ENUM, false, null, "shelf_state"), new Column( "released_on", ColumnType.TIMESTAMP_WITH_TIME_ZONE, @@ -1483,8 +1495,9 @@ void shouldRejectTheCommentOnTypeStatementOfAMigrationFile() { /** * Without that one statement the same migration files load in order: the * remaining {@code COMMENT ON} targets, the enum type, and the reshaping - * statements are applied, and the enum, enum-array, and text-array columns - * are recorded as unsupported. + * statements are applied, the enum column is modeled from the declared + * type, and the enum-array and text-array columns are recorded as + * unsupported. */ @Test void shouldLoadTheMigrationFilesInOrderWithoutTheCommentOnTypeStatement() { @@ -1531,7 +1544,7 @@ void shouldLoadTheMigrationFilesInOrderWithoutTheCommentOnTypeStatement() { List.of( new Column("id", ColumnType.INTEGER, false), new Column("handle", ColumnType.TEXT, false), - new Column("setting", null, false, "STAGE_SETTING"), + new Column("setting", ColumnType.ENUM, false, null, "stage_setting"), new Column("past_settings", null, true, "STAGE_SETTING[]"), new Column("title", ColumnType.VARCHAR, false), new Column("region", ColumnType.TEXT, false), @@ -1552,6 +1565,243 @@ void shouldLoadTheMigrationFilesInOrderWithoutTheCommentOnTypeStatement() { ); } + /** + * {@code CREATE TYPE ... AS ENUM} adds the type with its labels in declared + * order, and a later source sees it. + */ + @Test + void shouldAddEnumTypeWithItsDeclaredLabels() { + Schema schema = parser.parse( + parser.parse("CREATE TYPE stage_setting AS ENUM ('indoor', 'outdoor');"), + "CREATE TYPE shelf_state AS ENUM ('stocked');" + ); + + assertEquals( + List.of( + new EnumType("stage_setting", List.of("indoor", "outdoor")), + new EnumType("shelf_state", List.of("stocked")) + ), + schema.enums() + ); + } + + /** + * {@code ALTER TYPE ... ADD VALUE} inserts the label at the end by default + * and directly before or after the stated neighbor otherwise, so the + * modeled labels keep PostgreSQL's sort order. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + quoteCharacter = '"', + value = { + "ALTER TYPE stage_setting ADD VALUE 'hybrid';|indoor,outdoor,hybrid", + "ALTER TYPE stage_setting ADD VALUE 'hybrid' BEFORE 'indoor';|hybrid,indoor,outdoor", + "ALTER TYPE stage_setting ADD VALUE 'hybrid' AFTER 'indoor';|indoor,hybrid,outdoor", + "ALTER TYPE stage_setting ADD VALUE IF NOT EXISTS 'hybrid';|indoor,outdoor,hybrid" + } + ) + void shouldAddEnumLabelInItsStatedPosition(String statement, String labels) { + Schema schema = parser.parse(enumSchema(), statement); + + assertEquals( + List.of(new EnumType("stage_setting", List.of(labels.split(",")))), + schema.enums() + ); + } + + /** + * {@code ADD VALUE IF NOT EXISTS} of a label the type already has changes + * nothing. As PostgreSQL does, the existing label is decided before the + * neighbor, so a neighbor the type does not have is not resolved at all. + */ + @ParameterizedTest + @ValueSource( + strings = { + "ALTER TYPE stage_setting ADD VALUE IF NOT EXISTS 'indoor';", + "ALTER TYPE stage_setting ADD VALUE IF NOT EXISTS 'indoor' BEFORE 'missing';" + } + ) + void shouldIgnoreAddValueIfNotExistsForAnExistingLabel(String statement) { + Schema schema = parser.parse(enumSchema(), statement); + + assertEquals( + List.of(new EnumType("stage_setting", List.of("indoor", "outdoor"))), + schema.enums() + ); + } + + /** Each enum diagnostic names the type and, where it applies, the label. */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + quoteCharacter = '"', + value = { + "CREATE TYPE stage_setting AS ENUM ('covered');|Type already exists in schema: stage_setting", + "CREATE TYPE shelf_state AS ENUM ('new', 'new');|Label already exists in type shelf_state: new", + "ALTER TYPE shelf_state ADD VALUE 'stocked';|Type not found in schema: shelf_state", + "ALTER TYPE stage_setting ADD VALUE 'indoor';|Label already exists in type stage_setting: indoor", + "ALTER TYPE stage_setting ADD VALUE 'hybrid' AFTER 'missing';|Label not found in type stage_setting: missing" + } + ) + void shouldReportTheEnumStatementItCannotApply(String statement, String message) { + Schema schema = enumSchema(); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> parser.parse(schema, statement) + ); + + assertEquals(message, exception.getMessage()); + } + + /** + * A column of a declared enum type is modeled as an enum column carrying + * the type's declared name, through every path that types a column: a + * {@code CREATE TABLE} column, an added column, and a changed column type. + * The type name is matched case-insensitively and without its SQL + * identifier delimiters, as PostgreSQL resolves it. + */ + @Test + void shouldModelColumnsOfADeclaredEnumType() { + Schema schema = parser.parse( + enumSchema(), + """ + CREATE TABLE stages ( + id SERIAL PRIMARY KEY, + setting STAGE_SETTING NOT NULL, + title text + ); + + ALTER TABLE stages ADD COLUMN backstage "stage_setting"; + ALTER TABLE stages ALTER COLUMN title TYPE stage_setting; + """ + ); + + assertEquals( + List.of( + new Column("id", ColumnType.INTEGER, false), + new Column("setting", ColumnType.ENUM, false, null, "stage_setting"), + new Column("title", ColumnType.ENUM, true, null, "stage_setting"), + new Column("backstage", ColumnType.ENUM, true, null, "stage_setting") + ), + table(schema, "stages").columns() + ); + } + + /** + * An enum column keeps its type through a rename and through a nullability + * change, which state no type of their own. + */ + @Test + void shouldKeepTheEnumTypeOfARenamedAndRetypedColumn() { + Schema schema = parser.parse( + enumSchema(), + """ + CREATE TABLE stages ( + setting stage_setting + ); + + ALTER TABLE stages RENAME COLUMN setting TO stage_setting; + ALTER TABLE stages ALTER COLUMN stage_setting SET NOT NULL; + """ + ); + + assertEquals( + List.of(new Column("stage_setting", ColumnType.ENUM, false, null, "stage_setting")), + table(schema, "stages").columns() + ); + + Schema nullable = parser.parse( + schema, + "ALTER TABLE stages ALTER COLUMN stage_setting DROP NOT NULL;" + ); + + assertEquals( + List.of(new Column("stage_setting", ColumnType.ENUM, true, null, "stage_setting")), + table(nullable, "stages").columns() + ); + } + + /** + * A label added after a table is created belongs to the one modeled type, + * so the column declared earlier reads the added label too. + */ + @Test + void shouldApplyAnAddedLabelToAnEarlierEnumColumn() { + Schema schema = parser.parse( + enumSchema(), + """ + CREATE TABLE stages ( + setting stage_setting NOT NULL + ); + + ALTER TYPE stage_setting ADD VALUE 'hybrid' AFTER 'indoor'; + """ + ); + + assertEquals( + List.of(new Column("setting", ColumnType.ENUM, false, null, "stage_setting")), + table(schema, "stages").columns() + ); + + assertEquals( + List.of(new EnumType("stage_setting", List.of("indoor", "hybrid", "outdoor"))), + schema.enums() + ); + } + + /** + * An array of a declared enum type and a type the schema does not declare + * are recorded with their declared type as before, because only a scalar + * column of a declared enum type is modeled as an enum. + */ + @Test + void shouldRecordEnumArraysAndUndeclaredTypesAsUnsupported() { + Schema schema = parser.parse( + enumSchema(), + """ + CREATE TABLE stages ( + past_settings stage_setting[], + state shelf_state + ); + """ + ); + + assertEquals( + List.of( + new Column("past_settings", null, true, "STAGE_SETTING[]"), + new Column("state", null, true, "SHELF_STATE") + ), + table(schema, "stages").columns() + ); + } + + /** + * {@code DROP TYPE} removes an enum type sqlcj models, so it stays + * rejected. sqlcj's parser does not read the statement at all, so it is + * reported as a syntax error rather than as an unsupported statement. + */ + @Test + void shouldRejectDropType() { + Schema schema = enumSchema(); + + SchemaParseException exception = assertThrows( + SchemaParseException.class, + () -> parser.parse(schema, "DROP TYPE stage_setting;") + ); + + assertEquals( + "Encountered unexpected token: \"TYPE\" at line 1, column 6", + exception.getMessage() + ); + } + + /** A schema declaring the enum type the enum tests apply statements to. */ + private Schema enumSchema() { + return parser.parse("CREATE TYPE stage_setting AS ENUM ('indoor', 'outdoor');"); + } + /** The schema {@code statements} leave when applied to the base schema. */ private Schema applied(String statements) { return parser.parse(parser.parse(BASE_SCHEMA), statements); From 3e731e9f6a69579f69af664d41e72ec3c824b058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Tue, 29 Sep 2026 00:59:35 +0300 Subject: [PATCH 2/2] docs: document PostgreSQL enum types and generated enums --- docs/configuration.md | 46 ++++++++-- docs/postgresql.md | 189 ++++++++++++++++++++++++++++++++++++++---- docs/queries.md | 12 ++- 3 files changed, 224 insertions(+), 23 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index e05fe27..12085dd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -218,9 +218,10 @@ target/generated-sources/sqlcj/dev/example/generated/OrderRepository.java Both repositories may contain a query named `GetById`, because each name is resolved inside its own repository. -The entries share one generated package, so they also share its row records: a -table whose complete row both entries return generates one `Row` -file that both repositories return. +The entries share one generated package, so they also share its row records and +its enums: a table whose complete row both entries return generates one +`Row` file that both repositories return, and an enum type both +entries use generates one Java enum file that both repositories use. ## Generated Java Names @@ -261,6 +262,14 @@ uses no locale-dependent case mapping and no configuration: 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 ``` + An enum label is rejected the same way, and so are two labels of one enum + type that generate one constant: + + ```text + sqlcj: Invalid query group 'Stage' in /home/dev/project/sql/queries.sql: Label '***' of enum type 'stage_setting' has no letter or digit to generate a Java constant from + sqlcj: Invalid query group 'Stage' in /home/dev/project/sql/queries.sql: Labels 'in progress' and 'in-progress' of enum type 'stage_setting' generate the same constant IN_PROGRESS + ``` + The rules are applied as follows: - the repository type is the upper camel form of `sql[].name` followed by @@ -272,6 +281,14 @@ The rules are applied as follows: singularized. A row record is a top-level type of `java.package`, generated once per table into its own file and shared by every repository of the package that returns that row, +- an enum type is the upper camel form of its PostgreSQL name, so + `stage_setting` generates `StageSetting`. It is a top-level type of + `java.package`, generated once per enum type into its own file, and only for + an enum type a query reads or binds, +- an enum constant is the label's words, upper-cased and joined with `_`, so the + labels `in progress` and `in-progress` both generate `IN_PROGRESS`, the label + `InProgress`, which is one word, generates `INPROGRESS`, and the label `2fast` + generates `_2FAST`, - 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`, and the mapper @@ -340,6 +357,16 @@ the package, the two tables may come from one entry or from two: sqlcj: Invalid query group 'User' in /home/dev/project/sql/queries.sql: Tables 'user_data' and 'userdata' generate row types that are equal ignoring case: UserDataRow and UserdataRow ``` +Two enum types whose Java enums are equal ignoring case, and an enum type whose +Java enum is equal ignoring case to a repository, row record, or nested result +record of the package, are rejected on the same grounds, in either generation +order: + +```text +sqlcj: Invalid query group 'Stage' in /home/dev/project/sql/queries.sql: Enum types 'stage_setting' and 'stagesetting' generate enum types that are equal ignoring case: StageSetting and Stagesetting +sqlcj: Invalid query group 'Stage' in /home/dev/project/sql/queries.sql: Enum type 'users_row' generates UsersRow, which is equal ignoring case to the generated type UsersRow +``` + ### Row record collisions Two entries of one package that return the complete row of one table generate @@ -351,6 +378,13 @@ the run ends, naming the entry that defined the table first: sqlcj: Invalid query group 'Library' in /home/dev/project/sql/library.sql: Table 'authors' differs from its definition in query group 'Author', which generates the same row type AuthorsRow ``` +Two entries that use one enum type must define its labels alike for the same +reason, because the package generates one Java enum for it: + +```text +sqlcj: Invalid query group 'Library' in /home/dev/project/sql/library.sql: Enum type 'stage_setting' differs from its definition in query group 'Author', which generates the same enum type StageSetting +``` + A cross-entry failure is reported against the later entry in configuration order and, like every generation failure, ends the run before any file is written. @@ -408,11 +442,13 @@ files the run had already written stay in place. A successful run records what it generated in `sqlcj-manifest.txt` inside `java.out`. The manifest is a UTF-8 text file listing every generated file, -repositories and row records alike, as a path relative to `java.out`, with `/` +repositories, row records, and enums alike, as a path relative to `java.out`, +with `/` between its name elements, sorted, one per line. The next successful run deletes the files the previous manifest listed that it did not generate itself, so a renamed or removed configuration entry leaves no stale repository behind, and a -row record no entry returns any more is deleted too. +row record no entry returns any more, and an enum no entry uses any more, are +deleted too. Cleanup is deliberately narrow: diff --git a/docs/postgresql.md b/docs/postgresql.md index a24b43d..d93ac69 100644 --- a/docs/postgresql.md +++ b/docs/postgresql.md @@ -133,26 +133,139 @@ accepted and map exactly like their unparameterized spellings. | `BYTEA` | `byte[]` | A record compares an array component by reference, so two row records holding equal bytes are not `equals`. | | `JSON` | `String` | The JSON text itself. PostgreSQL stores it as written, so it reads back exactly as written. sqlcj never parses, validates, or normalizes it. | | `JSONB` | `String` | The JSON text itself. PostgreSQL stores a decomposed value, so the text reads back as PostgreSQL renders it rather than as written, and `=` compares by value. sqlcj never parses, validates, or normalizes it. | +| The name of an enum type the schema declares | The generated Java enum of that type | Matched without SQL identifier delimiters and case-insensitively, as PostgreSQL resolves an unquoted type name. See [Enum Types](#enum-types). | -Any spelling that is not listed above, and any array of any element type, has no -Java mapping. Such a column is recorded with its declared type instead of +Any spelling that is not listed above, and any array of any element type, +including an array of an enum type, has no Java mapping. Such a column is recorded with its declared type instead of failing the schema, and fails only a query that uses it; see [Unsupported Types and DDL](#unsupported-types-and-ddl). The generated repository imports `java.time.LocalDate`, `java.time.LocalTime`, `java.time.LocalDateTime`, `java.time.OffsetDateTime`, `java.math.BigDecimal`, -and `java.util.UUID` as needed; the remaining types need no import. Each result -column is read at its one-based position in the selected-column list, with -`resultSet.getObject(position, JavaType.class)`, or with -`resultSet.getString(position)` for a `JSON` or `JSONB` column, which the driver -reports as a type of its own rather than as a character type. +and `java.util.UUID` as needed; the remaining types need no import. A generated +enum belongs to the generated package, so it is used by its simple name and +needs no import either. Each result column is read at its one-based position in +the selected-column list, with `resultSet.getObject(position, JavaType.class)`, +or with `resultSet.getString(position)` for a `JSON` or `JSONB` column, which +the driver reports as a type of its own rather than as a character type. An +enum column reads its label the same way and resolves it with +`.fromLabel(...)`. A `JSON` or `JSONB` argument is passed to the executor as `new dev.sqlcj.runtime.UntypedText(value)`, written out in full so that the generated imports are unchanged, and `JdbcQueryExecutor` binds that text with `java.sql.Types.OTHER`. PostgreSQL then types the text from the context of its placeholder, which is what a `json` or `jsonb` column or comparison needs: text -bound as `varchar` is rejected there. +bound as `varchar` is rejected there. An enum argument is passed the same way, +around the label of its constant, because a label bound as `varchar` is +rejected where an enum is expected. + +## Enum Types + +`CREATE TYPE AS ENUM (...)` adds an enum type to the schema, and a +non-array column whose declared type names it is modeled as a column of that +type. Every query that reads or binds such a column uses the one Java enum the +package generates for the type: + +```sql +CREATE TYPE stage_setting AS ENUM ('indoor', 'outdoor'); + +ALTER TYPE stage_setting ADD VALUE 'covered' BEFORE 'outdoor'; +``` + +```java +// Code generated by sqlcj. DO NOT EDIT. + +package com.example.app.db; + +/** + * Generated by sqlcj. + * + * Enum: stage_setting + */ + +public enum StageSetting { + + INDOOR("indoor"), + COVERED("covered"), + OUTDOOR("outdoor"); + + private final String label; + + StageSetting(String label) { + this.label = label; + } + + public String label() { + return label; + } + + public static StageSetting fromLabel(String label) { + if (label == null) { + return null; + } + + for (StageSetting value : values()) { + if (value.label.equals(label)) { + return value; + } + } + + throw new IllegalArgumentException("Unknown label for enum type stage_setting: " + label); + } +} +``` + +- The constants keep their exact PostgreSQL labels and follow them in + PostgreSQL's own sort order, which is the declared order with each added + label in the position its `ALTER TYPE ... ADD VALUE` places it. +- A constant is named by the naming rules in + [Generated Java Names](configuration.md#generated-java-names). +- `fromLabel` reads back a label: `null` for a SQL `NULL`, and a failure for a + label the generated enum does not hold, which means the database declares one + the schema source does not. +- Only an enum type a query actually uses generates a file. +- An array of an enum type, such as `stage_setting[]`, has no Java mapping and + is recorded with its declared type like any other array. + +These statements update the enum types the statements and files before them +left: + +| Statement | Handling | +| --- | --- | +| `CREATE TYPE ... AS ENUM (...)` | Adds the type with its declared labels. | +| `ALTER TYPE ... ADD VALUE '