From 05f349cb449f8af5b46c62e1e4b72d641a479102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Sat, 19 Sep 2026 23:08:39 +0300 Subject: [PATCH 1/2] feat: add SERIAL, BIGSERIAL, UUID, and TIMESTAMP WITH TIME ZONE columns Canonicalize a declared SQL type by removing its parenthesized arguments so TIMESTAMP WITH TIME ZONE resolves with or without a precision, in any letter case. SERIAL and BIGSERIAL reuse the integer and bigint mappings and are modeled not nullable, UUID maps to java.util.UUID, and TIMESTAMP WITH TIME ZONE maps to java.time.OffsetDateTime in generated parameters, result components, and imports. Both simple type names are reserved so a query name cannot collide with a generated import. No other type or alias spelling is accepted. --- .../sqlcj/generator/JavaCodeGenerator.java | 2 + .../java/dev/sqlcj/generator/JavaNames.java | 2 + .../java/dev/sqlcj/schema/ColumnType.java | 4 +- .../schema/parser/DefaultSchemaParser.java | 50 +++++-- .../dev/sqlcj/type/DefaultTypeResolver.java | 2 + .../JavaCodeGeneratorNamingTest.java | 22 +++ .../generator/JavaCodeGeneratorTest.java | 64 ++++++++- .../parser/DefaultSchemaParserTest.java | 136 ++++++++++++++++++ 8 files changed, 264 insertions(+), 18 deletions(-) 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/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 = """ From 3d5cc926996771975f30be7299de5e6caa64492e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Sat, 19 Sep 2026 23:08:57 +0300 Subject: [PATCH 2/2] test(integration): round-trip serial, UUID, and time-zone timestamps Extend the PostgreSQL schema snapshot with serial, bigserial, uuid, and timestamptz columns, write and read every value through generated methods, and assert that null binds and reads back as null for the nullable columns. The timestamptz value is compared by instant because PostgreSQL normalizes it to the session time zone. --- .../compiler/PostgresIntegrationTest.java | 127 ++++++++++++++++-- 1 file changed, 114 insertions(+), 13 deletions(-) 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()); } }