diff --git a/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java b/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java index 3300c10..0ae43f8 100644 --- a/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java +++ b/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java @@ -127,7 +127,9 @@ private String resolveImport(ColumnType type) { return switch (type) { case DATE -> "java.time.LocalDate"; case TIMESTAMP -> "java.time.LocalDateTime"; + case TIMESTAMP_WITH_TIME_ZONE -> "java.time.OffsetDateTime"; case DECIMAL -> "java.math.BigDecimal"; + case UUID -> "java.util.UUID"; default -> null; }; } diff --git a/src/main/java/dev/sqlcj/generator/JavaNames.java b/src/main/java/dev/sqlcj/generator/JavaNames.java index fe59350..c127b22 100644 --- a/src/main/java/dev/sqlcj/generator/JavaNames.java +++ b/src/main/java/dev/sqlcj/generator/JavaNames.java @@ -36,6 +36,8 @@ final class JavaNames { "BigDecimal", "LocalDate", "LocalDateTime", + "OffsetDateTime", + "UUID", "Boolean", "Integer", "Long", diff --git a/src/main/java/dev/sqlcj/schema/ColumnType.java b/src/main/java/dev/sqlcj/schema/ColumnType.java index 691de9e..92c03df 100644 --- a/src/main/java/dev/sqlcj/schema/ColumnType.java +++ b/src/main/java/dev/sqlcj/schema/ColumnType.java @@ -9,5 +9,7 @@ public enum ColumnType { TEXT, DATE, TIMESTAMP, - DECIMAL + TIMESTAMP_WITH_TIME_ZONE, + DECIMAL, + UUID } diff --git a/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java b/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java index 6ef6412..2db9093 100644 --- a/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java +++ b/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java @@ -18,9 +18,13 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.regex.Pattern; public class DefaultSchemaParser implements SchemaParser { + /** One parenthesized type argument group, such as {@code (10, 2)}. */ + private static final Pattern TYPE_ARGUMENTS = Pattern.compile("\\([^)]*\\)"); + @Override public Schema parse(String sql) { try { @@ -66,14 +70,12 @@ private Table parseTable(CreateTable createTable) { } private Column parseColumn(ColumnDefinition definition) { - String name = columnName(definition); - ColumnType type = parseColumnType(definition); - boolean nullable = isNullable(definition); + String typeName = typeName(definition); return new Column( - name, - type, - nullable + columnName(definition), + columnType(typeName), + !isSerial(typeName) && isNullable(definition) ); } @@ -84,33 +86,51 @@ private String columnName(ColumnDefinition definition) { return MultiPartName.unquote(definition.getColumnName()); } - private ColumnType parseColumnType(ColumnDefinition definition) { - String typeName = definition.getColDataType() + /** + * Returns the canonical spelling of a declared SQL type: upper case, + * without type arguments such as a length or a precision, and with single + * spaces between the remaining words. + */ + private String typeName(ColumnDefinition definition) { + String dataType = definition.getColDataType() .getDataType() .toUpperCase(Locale.ROOT); - int parenthesisIndex = typeName.indexOf('('); - - if (parenthesisIndex >= 0) { - typeName = typeName.substring(0, parenthesisIndex).trim(); - } + return TYPE_ARGUMENTS + .matcher(dataType) + .replaceAll(" ") + .replaceAll("\\s+", " ") + .trim(); + } + private ColumnType columnType(String typeName) { return switch (typeName) { - case "INTEGER", "INT" -> ColumnType.INTEGER; - case "BIGINT" -> ColumnType.BIGINT; + case "INTEGER", "INT", "SERIAL" -> ColumnType.INTEGER; + case "BIGINT", "BIGSERIAL" -> ColumnType.BIGINT; case "SMALLINT" -> ColumnType.SMALLINT; case "BOOLEAN", "BOOL" -> ColumnType.BOOLEAN; case "VARCHAR" -> ColumnType.VARCHAR; case "TEXT" -> ColumnType.TEXT; case "DATE" -> ColumnType.DATE; case "TIMESTAMP" -> ColumnType.TIMESTAMP; + case "TIMESTAMP WITH TIME ZONE" -> ColumnType.TIMESTAMP_WITH_TIME_ZONE; case "DECIMAL", "NUMERIC" -> ColumnType.DECIMAL; + case "UUID" -> ColumnType.UUID; default -> throw new UnsupportedOperationException( "Unsupported SQL column type: " + typeName ); }; } + /** + * PostgreSQL defines the serial spellings as an integer type with a + * sequence default and {@code NOT NULL}, so such a column is never + * nullable. + */ + private boolean isSerial(String typeName) { + return "SERIAL".equals(typeName) || "BIGSERIAL".equals(typeName); + } + private boolean isNullable(ColumnDefinition definition) { List specs = definition.getColumnSpecs(); diff --git a/src/main/java/dev/sqlcj/type/DefaultTypeResolver.java b/src/main/java/dev/sqlcj/type/DefaultTypeResolver.java index cdc6f68..99684de 100644 --- a/src/main/java/dev/sqlcj/type/DefaultTypeResolver.java +++ b/src/main/java/dev/sqlcj/type/DefaultTypeResolver.java @@ -14,7 +14,9 @@ public String resolve(ColumnType type) { case VARCHAR, TEXT -> "String"; case DATE -> "LocalDate"; case TIMESTAMP -> "LocalDateTime"; + case TIMESTAMP_WITH_TIME_ZONE -> "OffsetDateTime"; case DECIMAL -> "BigDecimal"; + case UUID -> "UUID"; }; } } diff --git a/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java b/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java index f467972..326ab61 100644 --- a/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -31,9 +31,12 @@ import java.sql.Statement; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.UUID; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -55,18 +58,35 @@ class PostgresIntegrationTest { private static final String SCHEMA = """ CREATE TABLE users ( - id BIGINT PRIMARY KEY, - code INTEGER NOT NULL, - score SMALLINT, - name VARCHAR(255), - bio TEXT, - active BOOLEAN, - birth_date DATE, - created_at TIMESTAMP, - balance DECIMAL(10, 2) + id BIGINT PRIMARY KEY, + code INTEGER NOT NULL, + score SMALLINT, + name VARCHAR(255), + bio TEXT, + active BOOLEAN, + birth_date DATE, + created_at TIMESTAMP, + balance DECIMAL(10, 2), + serial_id SERIAL, + revision BIGSERIAL, + external_id UUID, + updated_at TIMESTAMP(3) WITH TIME ZONE ); """; + private static final UUID EXTERNAL_ID = UUID.fromString("3f2504e0-4f89-11d3-9a0c-0305e82c3301"); + + private static final OffsetDateTime UPDATED_AT = OffsetDateTime.of( + 2026, + 1, + 1, + 10, + 0, + 0, + 0, + ZoneOffset.ofHours(3) + ); + private static PostgreSQLContainer postgres; private static DataSource dataSource; @@ -291,11 +311,13 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception { Path classesDirectory = generateAndCompile(""" -- name: InsertUser :exec INSERT INTO users - (id, code, score, name, bio, active, birth_date, created_at, balance) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9); + (id, code, score, name, bio, active, birth_date, created_at, balance, + external_id, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11); -- name: GetUser :one - SELECT id, code, score, name, bio, active, birth_date, created_at, balance + SELECT id, code, score, name, bio, active, birth_date, created_at, balance, + external_id, updated_at FROM users WHERE id = $1; """); @@ -313,7 +335,9 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception { Boolean.class, LocalDate.class, LocalDateTime.class, - BigDecimal.class + BigDecimal.class, + UUID.class, + OffsetDateTime.class ); Object affectedRows = insertMethod.invoke( @@ -326,6 +350,8 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception { null, null, null, + null, + null, null ); @@ -348,6 +374,81 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception { assertNull(component(result, "birth_date")); assertNull(component(result, "created_at")); assertNull(component(result, "balance")); + assertNull(component(result, "external_id")); + assertNull(component(result, "updated_at")); + } + } + + /** + * Covers the PostgreSQL round trip of the serial, UUID, and + * timestamptz mappings: every value is written through the generated + * write method and read back through the generated read method. + * + *

PostgreSQL normalizes a {@code timestamptz} to the session time zone, + * so the read value is compared by instant rather than by offset. + */ + @Test + void shouldRoundTripSerialUuidAndTimestampWithTimeZoneValues() throws Exception { + Path classesDirectory = generateAndCompile(""" + -- name: InsertUser :exec + INSERT INTO users (id, code, serial_id, revision, external_id, updated_at) + VALUES ($1, $2, $3, $4, $5, $6); + + -- name: GetUser :one + SELECT id, serial_id, revision, external_id, updated_at + FROM users + WHERE id = $1; + """); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object insert = newQuery(classLoader, "InsertUser"); + + Method insertMethod = insert.getClass().getMethod( + "insertUser", + Long.class, + Integer.class, + Integer.class, + Long.class, + UUID.class, + OffsetDateTime.class + ); + + Object affectedRows = insertMethod.invoke( + insert, + 7L, + 42, + 101, + 202L, + EXTERNAL_ID, + UPDATED_AT + ); + + assertEquals(1, affectedRows); + + Object query = newQuery(classLoader, "GetUser"); + + Method queryMethod = query.getClass().getMethod("getUser", Long.class); + + Object result = queryMethod.invoke(query, 7L); + + assertNotNull(result); + + assertEquals( + List.of("id", "serial_id", "revision", "external_id", "updated_at"), + recordComponentNames(result) + ); + + assertEquals(7L, component(result, "id")); + assertEquals(101, component(result, "serial_id")); + assertEquals(202L, component(result, "revision")); + assertEquals(EXTERNAL_ID, component(result, "external_id")); + + OffsetDateTime updatedAt = assertInstanceOf( + OffsetDateTime.class, + component(result, "updated_at") + ); + + assertEquals(UPDATED_AT.toInstant(), updatedAt.toInstant()); } } diff --git a/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java b/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java index 38380b9..b501c61 100644 --- a/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java +++ b/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java @@ -102,6 +102,28 @@ void shouldRenameClassConflictingWithImportedType() throws IOException { assertCompiles(file); } + @Test + void shouldRenameClassConflictingWithImportedUuidType() throws IOException { + GeneratedFile file = codeGenerator.generate( + query( + "UUID", + List.of(new QueryColumn("external_id", ColumnType.UUID, true)), + List.of() + ) + ); + + String source = file.content(); + + assertEquals(Path.of("generated", "UUID_.java"), file.path()); + assertTrue(source.contains("import java.util.UUID;")); + assertTrue(source.contains("public final class UUID_ {")); + assertTrue(source.contains("public record UUID_Result(")); + assertTrue(source.contains("UUID external_id")); + assertTrue(source.contains("resultSet.getObject(1, UUID.class)")); + + assertCompiles(file); + } + @Test void shouldReadRenamedResultComponentsByProjectionPosition() throws IOException { GeneratedFile file = codeGenerator.generate( diff --git a/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java b/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java index e8faa02..f8e14e6 100644 --- a/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java +++ b/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java @@ -426,7 +426,9 @@ void shouldGenerateDistinctComponentsAndPositionalReadsForDuplicateColumns() thr "TEXT, String", "DATE, LocalDate", "TIMESTAMP, LocalDateTime", - "DECIMAL, BigDecimal" + "TIMESTAMP_WITH_TIME_ZONE, OffsetDateTime", + "DECIMAL, BigDecimal", + "UUID, UUID" } ) void shouldGenerateJavaTypeForQueryParameter( @@ -527,6 +529,62 @@ void shouldGenerateImportForBigDecimal() { ); } + @Test + void shouldGenerateImportsForUuidAndTimestampWithTimeZone() throws IOException { + QueryModel query = new QueryModel( + "GetUser", + QueryType.ONE, + "users", + SQL, + List.of(1, 2), + List.of( + new QueryColumn( + "external_id", + ColumnType.UUID, + true + ), + new QueryColumn( + "created_at", + ColumnType.TIMESTAMP_WITH_TIME_ZONE, + true + ) + ), + List.of( + new QueryParameter( + 1, + "externalId", + ColumnType.UUID + ), + new QueryParameter( + 2, + "createdAt", + ColumnType.TIMESTAMP_WITH_TIME_ZONE + ) + ) + ); + + GeneratedFile file = codeGenerator.generate(query); + + String source = file.content(); + + assertTrue(source.contains("import java.util.UUID;")); + assertTrue(source.contains("import java.time.OffsetDateTime;")); + + assertTrue( + source.contains( + "public GetUserResult getUser(UUID externalId, OffsetDateTime createdAt)" + ) + ); + + assertTrue(source.contains("UUID external_id")); + assertTrue(source.contains("OffsetDateTime created_at")); + + assertTrue(source.contains("resultSet.getObject(1, UUID.class)")); + assertTrue(source.contains("resultSet.getObject(2, OffsetDateTime.class)")); + + assertCompiles(file); + } + @Test void shouldGenerateImportForQueryParameterType() { QueryModel query = new QueryModel( @@ -742,7 +800,9 @@ private int compile(GeneratedFile file, String fileName) throws IOException { "TEXT, String", "DATE, LocalDate", "TIMESTAMP, LocalDateTime", - "DECIMAL, BigDecimal" + "TIMESTAMP_WITH_TIME_ZONE, OffsetDateTime", + "DECIMAL, BigDecimal", + "UUID, UUID" } ) void shouldGenerateJavaTypeForResultColumn(ColumnType columnType, String expectedJavaType) { diff --git a/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java b/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java index f650784..4d3d1db 100644 --- a/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java +++ b/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java @@ -7,10 +7,14 @@ import dev.sqlcj.schema.Schema; import dev.sqlcj.schema.Table; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; 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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -155,6 +159,138 @@ PRIMARY KEY ("user id") ); } + @ParameterizedTest + @CsvSource( + { + "SERIAL, INTEGER", + "serial, INTEGER", + "BIGSERIAL, BIGINT", + "bigserial, BIGINT", + "UUID, UUID", + "uuid, UUID", + "TIMESTAMP WITH TIME ZONE, TIMESTAMP_WITH_TIME_ZONE", + "timestamp with time zone, TIMESTAMP_WITH_TIME_ZONE", + "TIMESTAMP(3) WITH TIME ZONE, TIMESTAMP_WITH_TIME_ZONE", + "timestamp(3) with time zone, TIMESTAMP_WITH_TIME_ZONE" + } + ) + void shouldParseAddedColumnTypes(String sqlType, ColumnType expectedType) { + String sql = """ + CREATE TABLE users ( + value %s + ); + """ + .formatted(sqlType); + + Schema schema = parser.parse(sql); + + assertEquals( + expectedType, + schema.tables().getFirst().columns().getFirst().type() + ); + } + + @ParameterizedTest + @CsvSource( + { + "INTEGER, INTEGER", + "INT, INTEGER", + "BIGINT, BIGINT", + "SMALLINT, SMALLINT", + "BOOLEAN, BOOLEAN", + "BOOL, BOOLEAN", + "VARCHAR(255), VARCHAR", + "TEXT, TEXT", + "DATE, DATE", + "TIMESTAMP, TIMESTAMP", + "'DECIMAL(10, 2)', DECIMAL", + "NUMERIC, DECIMAL" + } + ) + void shouldKeepParsingDeliveredColumnTypes(String sqlType, ColumnType expectedType) { + String sql = """ + CREATE TABLE users ( + value %s + ); + """ + .formatted(sqlType); + + Schema schema = parser.parse(sql); + + assertEquals( + expectedType, + schema.tables().getFirst().columns().getFirst().type() + ); + } + + @ParameterizedTest + @ValueSource( + strings = { + "SERIAL", + "BIGSERIAL" + } + ) + void shouldParseSerialColumnAsNotNullable(String sqlType) { + String sql = """ + CREATE TABLE users ( + id %s PRIMARY KEY + ); + """ + .formatted(sqlType); + + Schema schema = parser.parse(sql); + + assertFalse(schema.tables().getFirst().columns().getFirst().nullable()); + } + + @Test + void shouldParseNullabilityOfAddedColumnTypes() { + String sql = """ + CREATE TABLE users ( + external_id UUID, + created_at TIMESTAMP WITH TIME ZONE NOT NULL + ); + """; + + Schema schema = parser.parse(sql); + + Table table = schema.tables().getFirst(); + + assertEquals( + new Column("external_id", ColumnType.UUID, true), + table.columns().get(0) + ); + + assertEquals( + new Column("created_at", ColumnType.TIMESTAMP_WITH_TIME_ZONE, false), + table.columns().get(1) + ); + } + + @ParameterizedTest + @ValueSource( + strings = { + "TIMESTAMPTZ", + "SERIAL4", + "SERIAL8", + "SMALLSERIAL", + "TIMESTAMP WITHOUT TIME ZONE" + } + ) + void shouldRejectUnsupportedColumnType(String sqlType) { + String sql = """ + CREATE TABLE users ( + value %s + ); + """ + .formatted(sqlType); + + assertThrows( + UnsupportedOperationException.class, + () -> parser.parse(sql) + ); + } + @Test void shouldThrowSchemaParseExceptionForInvalidSql() { String sql = """