From a5d3f2df3545c88cb76d86a33db323cc4ee4c145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Mon, 28 Sep 2026 20:47:14 +0300 Subject: [PATCH 1/2] feat: map REAL, DOUBLE PRECISION, BYTEA, and TIME to Java types --- .../sqlcj/generator/JavaCodeGenerator.java | 1 + .../java/dev/sqlcj/schema/ColumnType.java | 6 +- .../schema/parser/DefaultSchemaParser.java | 4 + .../dev/sqlcj/type/DefaultTypeResolver.java | 4 + .../compiler/PostgresIntegrationTest.java | 147 ++++++++++++++++++ .../generator/JavaCodeGeneratorTest.java | 123 ++++++++++++++- .../parser/DefaultSchemaParserTest.java | 28 +++- 7 files changed, 308 insertions(+), 5 deletions(-) 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 45e1584..00c1bc9 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java @@ -318,6 +318,7 @@ private boolean returnsSharedRow(QueryModel query) { private String resolveImport(ColumnType type) { return switch (type) { case DATE -> "java.time.LocalDate"; + case TIME -> "java.time.LocalTime"; case TIMESTAMP -> "java.time.LocalDateTime"; case TIMESTAMP_WITH_TIME_ZONE -> "java.time.OffsetDateTime"; case DECIMAL -> "java.math.BigDecimal"; 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 92c03df..adc018c 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/schema/ColumnType.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/schema/ColumnType.java @@ -8,8 +8,12 @@ public enum ColumnType { VARCHAR, TEXT, DATE, + TIME, TIMESTAMP, TIMESTAMP_WITH_TIME_ZONE, DECIMAL, - UUID + REAL, + DOUBLE_PRECISION, + UUID, + BYTEA } 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 43e2114..403308b 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 @@ -723,10 +723,14 @@ private ColumnType columnType(String typeName) { case "VARCHAR", "CHARACTER VARYING", "CHAR", "CHARACTER" -> ColumnType.VARCHAR; case "TEXT" -> ColumnType.TEXT; case "DATE" -> ColumnType.DATE; + case "TIME", "TIME WITHOUT TIME ZONE" -> ColumnType.TIME; case "TIMESTAMP", "TIMESTAMP WITHOUT TIME ZONE" -> ColumnType.TIMESTAMP; case "TIMESTAMP WITH TIME ZONE", "TIMESTAMPTZ" -> ColumnType.TIMESTAMP_WITH_TIME_ZONE; case "DECIMAL", "NUMERIC" -> ColumnType.DECIMAL; + case "REAL", "FLOAT4" -> ColumnType.REAL; + case "DOUBLE PRECISION", "FLOAT8" -> ColumnType.DOUBLE_PRECISION; case "UUID" -> ColumnType.UUID; + case "BYTEA" -> ColumnType.BYTEA; default -> null; }; } 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 99684de..0b9c0d0 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/type/DefaultTypeResolver.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/type/DefaultTypeResolver.java @@ -13,10 +13,14 @@ public String resolve(ColumnType type) { case BOOLEAN -> "Boolean"; case VARCHAR, TEXT -> "String"; case DATE -> "LocalDate"; + case TIME -> "LocalTime"; case TIMESTAMP -> "LocalDateTime"; case TIMESTAMP_WITH_TIME_ZONE -> "OffsetDateTime"; case DECIMAL -> "BigDecimal"; + case REAL -> "Float"; + case DOUBLE_PRECISION -> "Double"; case UUID -> "UUID"; + case BYTEA -> "byte[]"; }; } } 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 d3a7787..ba3ea6f 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -33,6 +33,7 @@ import java.sql.Statement; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.ArrayList; @@ -43,6 +44,7 @@ import java.util.UUID; import java.util.stream.Stream; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -146,6 +148,25 @@ created_at TIMESTAMP(3) WITHOUT TIME ZONE, ); """; + /** + * A snapshot that declares every accepted floating-point, binary, and time + * spelling, each of them nullable so that one row can carry values and + * another can carry nulls. + */ + private static final String MEASUREMENT_SCHEMA = """ + CREATE TABLE measurements + ( + id BIGINT PRIMARY KEY, + amount REAL, + ratio FLOAT4, + total DOUBLE PRECISION, + average FLOAT8, + payload BYTEA, + opened_at TIME, + closed_at TIME(3) WITHOUT TIME ZONE + ); + """; + /** * Queries used by the caller-owned transaction tests: an affected-row * write, a returning write, and a read. @@ -301,6 +322,7 @@ void resetDatabase() throws Exception { execute("DROP TYPE IF EXISTS stage_setting"); execute("DROP TABLE IF EXISTS customer_orders"); execute("DROP TABLE IF EXISTS customers"); + execute("DROP TABLE IF EXISTS measurements"); execute("DROP TABLE IF EXISTS user_aliases"); execute("DROP TABLE IF EXISTS users"); execute(SCHEMA); @@ -1013,6 +1035,131 @@ void shouldRoundTripPostgresTypeSpellingValues() throws Exception { } } + /** + * Proves that the floating-point, binary, and time spellings round trip + * through generated code: one row is written with non-null values and one + * with nulls, and both are read back as {@code Float}, {@code Double}, + * {@code byte[]}, and {@code LocalTime} components. + */ + @Test + void shouldRoundTripFloatingPointBinaryAndTimeValues() throws Exception { + execute(MEASUREMENT_SCHEMA); + + Path classesDirectory = generateAndCompile( + MEASUREMENT_SCHEMA, + """ + -- name: InsertMeasurement :exec + INSERT INTO measurements + (id, amount, ratio, total, average, payload, opened_at, closed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8); + + -- name: GetMeasurement :one + SELECT id, amount, ratio, total, average, payload, opened_at, closed_at + FROM measurements + WHERE id = $1; + """ + ); + + byte[] payload = { 1, 2, 3, -128 }; + + LocalTime openedAt = LocalTime.of(9, 30); + LocalTime closedAt = LocalTime.of(17, 45, 30); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object repository = newRepository(classLoader); + + Method insertMethod = repository.getClass().getMethod( + "insertMeasurement", + Long.class, + Float.class, + Float.class, + Double.class, + Double.class, + byte[].class, + LocalTime.class, + LocalTime.class + ); + + assertEquals( + 1, + insertMethod.invoke( + repository, + 1L, + 1.5f, + 2.25f, + 3.5d, + 4.125d, + payload, + openedAt, + closedAt + ) + ); + + assertEquals( + 1, + insertMethod.invoke(repository, 2L, null, null, null, null, null, null, null) + ); + + Method queryMethod = repository.getClass().getMethod("getMeasurement", Long.class); + + Object result = queryMethod.invoke(repository, 1L); + + assertNotNull(result); + + assertEquals( + List.of( + "id", + "amount", + "ratio", + "total", + "average", + "payload", + "openedAt", + "closedAt" + ), + recordComponentNames(result) + ); + + assertEquals( + List.of( + Long.class, + Float.class, + Float.class, + Double.class, + Double.class, + byte[].class, + LocalTime.class, + LocalTime.class + ), + recordComponentTypes(result) + ); + + assertEquals(1.5f, component(result, "amount")); + assertEquals(2.25f, component(result, "ratio")); + assertEquals(3.5d, component(result, "total")); + assertEquals(4.125d, component(result, "average")); + assertArrayEquals( + payload, + assertInstanceOf(byte[].class, component(result, "payload")) + ); + assertEquals(openedAt, component(result, "openedAt")); + assertEquals(closedAt, component(result, "closedAt")); + + Object nullResult = queryMethod.invoke(repository, 2L); + + assertNotNull(nullResult); + + assertEquals(2L, component(nullResult, "id")); + assertNull(component(nullResult, "amount")); + assertNull(component(nullResult, "ratio")); + assertNull(component(nullResult, "total")); + assertNull(component(nullResult, "average")); + assertNull(component(nullResult, "payload")); + assertNull(component(nullResult, "openedAt")); + assertNull(component(nullResult, "closedAt")); + } + } + /** * Proves that a snapshot carrying table-level foreign key and check * constraints, column defaults, and named constraints is valid PostgreSQL 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 b4dde17..74ae83a 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java @@ -479,7 +479,11 @@ void shouldGenerateDistinctComponentsAndPositionalReadsForDuplicateColumns() thr "TIMESTAMP, LocalDateTime", "TIMESTAMP_WITH_TIME_ZONE, OffsetDateTime", "DECIMAL, BigDecimal", - "UUID, UUID" + "UUID, UUID", + "REAL, Float", + "DOUBLE_PRECISION, Double", + "BYTEA, byte[]", + "TIME, LocalTime" } ) void shouldGenerateJavaTypeForQueryParameter( @@ -555,6 +559,34 @@ void shouldGenerateImportForLocalDate() { ); } + @Test + void shouldGenerateImportForLocalTime() { + QueryModel query = new QueryModel( + "GetUser", + QueryType.ONE, + "users", + SQL, + List.of(), + List.of( + new QueryColumn( + "openedAt", + ColumnType.TIME, + true + ) + ), + List.of(), + null + ); + + GeneratedFile file = generate(query); + + assertTrue( + file.content().contains( + "import java.time.LocalTime;" + ) + ); + } + @Test void shouldGenerateImportForBigDecimal() { QueryModel query = new QueryModel( @@ -822,6 +854,89 @@ void shouldGenerateCompilableJavaSource() throws IOException { assertEquals(0, compile(file, "UsersRepository.java")); } + /** + * Compiles a repository that binds and reads the floating-point, binary, + * and time types, including a query whose only binding parameter is the + * array type {@code byte[]}. + */ + @Test + void shouldGenerateCompilableJavaSourceForFloatingPointBinaryAndTimeTypes() + throws IOException { + QueryModel listMeasurements = new QueryModel( + "ListMeasurements", + QueryType.MANY, + "measurements", + SQL, + List.of(1, 2, 3, 4), + List.of( + new QueryColumn( + "amount", + ColumnType.REAL, + true + ), + new QueryColumn( + "ratio", + ColumnType.DOUBLE_PRECISION, + true + ), + new QueryColumn( + "payload", + ColumnType.BYTEA, + true + ), + new QueryColumn( + "opened_at", + ColumnType.TIME, + true + ) + ), + List.of( + new QueryParameter(1, "amount", ColumnType.REAL), + new QueryParameter(2, "ratio", ColumnType.DOUBLE_PRECISION), + new QueryParameter(3, "payload", ColumnType.BYTEA), + new QueryParameter(4, "opened_at", ColumnType.TIME) + ), + null + ); + + QueryModel getMeasurementByPayload = new QueryModel( + "GetMeasurementByPayload", + QueryType.ONE, + "measurements", + SQL, + List.of(1), + List.of( + new QueryColumn( + "payload", + ColumnType.BYTEA, + false + ) + ), + List.of( + new QueryParameter(1, "payload", ColumnType.BYTEA) + ), + null + ); + + GeneratedFile file = codeGenerator.generate( + new QueryGroupModel( + GROUP, + List.of(listMeasurements, getMeasurementByPayload) + ) + ); + + String source = file.content(); + + assertTrue(source.contains("import java.time.LocalTime;")); + assertTrue(source.contains("Float amount")); + assertTrue(source.contains("Double ratio")); + assertTrue(source.contains("byte[] payload")); + assertTrue(source.contains("LocalTime openedAt")); + assertTrue(source.contains("resultSet.getObject(3, byte[].class)")); + + assertEquals(0, compile(file, "UsersRepository.java")); + } + /** Compiles one generated source file in an isolated temporary location. */ private int compile(GeneratedFile file, String fileName) throws IOException { Path sourceDirectory = tempDir.resolve("generated"); @@ -862,7 +977,11 @@ private int compile(GeneratedFile file, String fileName) throws IOException { "TIMESTAMP, LocalDateTime", "TIMESTAMP_WITH_TIME_ZONE, OffsetDateTime", "DECIMAL, BigDecimal", - "UUID, UUID" + "UUID, UUID", + "REAL, Float", + "DOUBLE_PRECISION, Double", + "BYTEA, byte[]", + "TIME, LocalTime" } ) void shouldGenerateJavaTypeForResultColumn(ColumnType columnType, String expectedJavaType) { 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 53dd66a..3f81e81 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 @@ -342,7 +342,23 @@ CREATE TABLE users ( "char, VARCHAR", "CHAR(2), VARCHAR", "CHARACTER, VARCHAR", - "character(3), VARCHAR" + "character(3), VARCHAR", + "REAL, REAL", + "real, REAL", + "FLOAT4, REAL", + "float4, REAL", + "DOUBLE PRECISION, DOUBLE_PRECISION", + "double precision, DOUBLE_PRECISION", + "FLOAT8, DOUBLE_PRECISION", + "float8, DOUBLE_PRECISION", + "BYTEA, BYTEA", + "bytea, BYTEA", + "TIME, TIME", + "time, TIME", + "TIME(3), TIME", + "TIME WITHOUT TIME ZONE, TIME", + "time without time zone, TIME", + "TIME(3) WITHOUT TIME ZONE, TIME" } ) void shouldParsePostgresTypeSpellings(String sqlType, ColumnType expectedType) { @@ -443,12 +459,20 @@ CREATE TABLE users ( * only a query that uses the column fails. The recorded text is the * canonical spelling of the declared type, followed by {@code []} per * declared array dimension. + * + *

{@code FLOAT} and {@code FLOAT(p)}, whose precision selects the type, + * and the time-zone-aware time spellings are unmapped beside the mapped + * floating-point, binary, and time spellings. */ @ParameterizedTest @CsvSource( { "JSONB, JSONB", - "DOUBLE PRECISION, DOUBLE PRECISION", + "FLOAT, FLOAT", + "float(24), FLOAT", + "TIMETZ, TIMETZ", + "time with time zone, TIME WITH TIME ZONE", + "real[], REAL[]", "\"char\", \"CHAR\"", "varchar(20)[], VARCHAR[]", "integer[][], INTEGER[][]", From 9fe52f8e7f9749785e9b1d902bcb811810a2e159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Mon, 28 Sep 2026 20:47:20 +0300 Subject: [PATCH 2/2] docs: document floating-point, binary, and time type mappings --- docs/postgresql.md | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/postgresql.md b/docs/postgresql.md index cdedc1b..82167f2 100644 --- a/docs/postgresql.md +++ b/docs/postgresql.md @@ -123,21 +123,25 @@ accepted and map exactly like their unparameterized spellings. | `CHAR`, `CHARACTER` | `String` | PostgreSQL blank-pads a stored value to the declared length, so `ab` written into a `CHAR(3)` column reads back as `ab `. | | `TEXT` | `String` | | | `DATE` | `java.time.LocalDate` | | +| `TIME`, `TIME WITHOUT TIME ZONE` | `java.time.LocalTime` | | | `TIMESTAMP`, `TIMESTAMP WITHOUT TIME ZONE` | `java.time.LocalDateTime` | | | `TIMESTAMP WITH TIME ZONE`, `TIMESTAMPTZ` | `java.time.OffsetDateTime` | PostgreSQL normalizes the stored value to the session time zone, so a value read back equals the written value by instant rather than by offset. | | `DECIMAL`, `NUMERIC` | `java.math.BigDecimal` | | +| `REAL`, `FLOAT4` | `Float` | | +| `DOUBLE PRECISION`, `FLOAT8` | `Double` | | | `UUID` | `java.util.UUID` | | +| `BYTEA` | `byte[]` | A record compares an array component by reference, so two row records holding equal bytes are not `equals`. | 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 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.LocalDateTime`, -`java.time.OffsetDateTime`, `java.math.BigDecimal`, and `java.util.UUID` as -needed; the remaining types need no import. Each result column is read with -`resultSet.getObject(position, JavaType.class)` at its one-based position in the -selected-column list. +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 with `resultSet.getObject(position, JavaType.class)` at its +one-based position in the selected-column list. ## Supported `CREATE TABLE` Constructs @@ -300,17 +304,15 @@ Nullability itself is parsed from `NOT NULL` only: analyzed model, but the generated type is resolved from the column type alone. Null binding and null reading are executed against PostgreSQL for the nullable -columns of the integration schema snapshot, which cover `SMALLINT`, `VARCHAR`, -`TEXT`, `BOOLEAN`, `DATE`, `TIMESTAMP`, `DECIMAL`, `UUID`, and -`TIMESTAMP WITH TIME ZONE`. +columns of the integration schema snapshots, which cover `SMALLINT`, `VARCHAR`, +`TEXT`, `BOOLEAN`, `DATE`, `TIMESTAMP`, `DECIMAL`, `UUID`, +`TIMESTAMP WITH TIME ZONE`, `REAL`, `DOUBLE PRECISION`, `BYTEA`, and `TIME`. ## Unsupported Types and DDL The following type families have no Java mapping, because only the spellings listed in [Supported Column Types](#supported-column-types) are mapped: -- floating point, such as `REAL` and `DOUBLE PRECISION`, -- binary, such as `BYTEA`, - `JSON` and `JSONB`, - arrays, - enum types, @@ -319,6 +321,11 @@ listed in [Supported Column Types](#supported-column-types) are mapped: - composite types, - spatial types. +These spellings of otherwise mapped families are unmapped as well: + +- `FLOAT` and `FLOAT(p)`, whose precision selects `REAL` or `DOUBLE PRECISION`, +- `TIME WITH TIME ZONE` and `TIMETZ`. + A column of such a type does not fail the schema. It is recorded with its declared type, written as the canonical spelling of that type — upper case, with parenthesized type arguments removed — followed by `[]` for each declared array @@ -370,7 +377,8 @@ Type table: - `PostgresIntegrationTest.shouldExecuteGeneratedOneQueryAgainstPostgres`, `PostgresIntegrationTest.shouldExecuteGeneratedWriteAgainstPostgres`, `PostgresIntegrationTest.shouldRoundTripSerialUuidAndTimestampWithTimeZoneValues`, - and `PostgresIntegrationTest.shouldRoundTripPostgresTypeSpellingValues` + `PostgresIntegrationTest.shouldRoundTripPostgresTypeSpellingValues`, and + `PostgresIntegrationTest.shouldRoundTripFloatingPointBinaryAndTimeValues` execute the Java mappings, including the blank-padded `CHAR` values, against PostgreSQL 16. - `DefaultSchemaParserTest.shouldRecordUnsupportedColumnType` and @@ -470,7 +478,8 @@ Ignored statements: Nulls: - `PostgresIntegrationTest.shouldBindAndReadNullValuesThroughGeneratedCode` - binds null arguments and reads null results through generated code against + and `PostgresIntegrationTest.shouldRoundTripFloatingPointBinaryAndTimeValues` + bind null arguments and read null results through generated code against PostgreSQL. - `PostgresIntegrationTest.shouldEnforceResultCardinalitiesAgainstPostgres` proves that row absence is reported by `:optional` and `:one` rather than by a