diff --git a/docs/postgresql.md b/docs/postgresql.md index cbc52d8..2486c43 100644 --- a/docs/postgresql.md +++ b/docs/postgresql.md @@ -110,15 +110,16 @@ accepted and map exactly like their unparameterized spellings. | Accepted SQL spellings | Java type | Notes | | --- | --- | --- | -| `INTEGER`, `INT`, `SERIAL` | `Integer` | A `SERIAL` column is always modeled non-null. | -| `BIGINT`, `BIGSERIAL` | `Long` | A `BIGSERIAL` column is always modeled non-null. | -| `SMALLINT` | `Short` | | +| `INTEGER`, `INT`, `INT4`, `SERIAL`, `SERIAL4` | `Integer` | A serial column is always modeled non-null. | +| `BIGINT`, `INT8`, `BIGSERIAL`, `SERIAL8` | `Long` | A serial column is always modeled non-null. | +| `SMALLINT`, `INT2`, `SMALLSERIAL`, `SERIAL2` | `Short` | A serial column is always modeled non-null. | | `BOOLEAN`, `BOOL` | `Boolean` | | -| `VARCHAR` | `String` | | +| `VARCHAR`, `CHARACTER VARYING` | `String` | | +| `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` | | -| `TIMESTAMP` | `java.time.LocalDateTime` | | -| `TIMESTAMP WITH TIME ZONE` | `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. | +| `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` | | | `UUID` | `java.util.UUID` | | @@ -185,9 +186,10 @@ Nullability itself is parsed from `NOT NULL` only: - a column without `NOT NULL` is modeled nullable, so a column declared bare `PRIMARY KEY` is modeled nullable, -- a `SERIAL` or `BIGSERIAL` column is always modeled non-null, because - PostgreSQL defines those spellings as an integer type with a sequence default - and `NOT NULL`, +- a serial column, declared `SMALLSERIAL`, `SERIAL`, `BIGSERIAL`, `SERIAL2`, + `SERIAL4`, or `SERIAL8`, is always modeled non-null, because PostgreSQL + defines those spellings as an integer type with a sequence default and + `NOT NULL`, - nullability never changes a generated Java type. It is carried into the analyzed model, but the generated type is resolved from the column type alone. @@ -198,14 +200,6 @@ columns of the integration schema snapshot, which cover `SMALLINT`, `VARCHAR`, ## Unsupported Types and DDL -These spellings are rejected even though PostgreSQL accepts them: - -- `TIMESTAMPTZ` -- `SERIAL4` -- `SERIAL8` -- `SMALLSERIAL` -- `TIMESTAMP WITHOUT TIME ZONE` - The following type families are not supported at all, because only the spellings listed in [Supported Column Types](#supported-column-types) are accepted: @@ -235,7 +229,7 @@ The message names the schema source and the offending type or statement, or the syntax error with the line and column it was found at: ```text -sqlcj: Invalid schema source /home/dev/project/schema.sql: Unsupported SQL column type: TIMESTAMPTZ +sqlcj: Invalid schema source /home/dev/project/schema.sql: Unsupported SQL column type: JSONB sqlcj: Invalid schema source /home/dev/project/schema.sql: Unsupported schema statement: Alter sqlcj: Invalid schema source /home/dev/project/schema.sql: Encountered unexpected token: ";" at line 4, column 1 ``` @@ -244,13 +238,16 @@ sqlcj: Invalid schema source /home/dev/project/schema.sql: Encountered unexpecte Type table: -- `DefaultSchemaParserTest.shouldKeepParsingDeliveredColumnTypes` and - `DefaultSchemaParserTest.shouldParseAddedColumnTypes` cover the accepted +- `DefaultSchemaParserTest.shouldKeepParsingDeliveredColumnTypes`, + `DefaultSchemaParserTest.shouldParseAddedColumnTypes`, and + `DefaultSchemaParserTest.shouldParsePostgresTypeSpellings` cover the accepted spellings, their case-insensitive forms, and the ignored type arguments. - `PostgresIntegrationTest.shouldExecuteGeneratedOneQueryAgainstPostgres`, - `PostgresIntegrationTest.shouldExecuteGeneratedWriteAgainstPostgres`, and - `PostgresIntegrationTest.shouldRoundTripSerialUuidAndTimestampWithTimeZoneValues` - execute the Java mappings against PostgreSQL 16. + `PostgresIntegrationTest.shouldExecuteGeneratedWriteAgainstPostgres`, + `PostgresIntegrationTest.shouldRoundTripSerialUuidAndTimestampWithTimeZoneValues`, + and `PostgresIntegrationTest.shouldRoundTripPostgresTypeSpellingValues` + execute the Java mappings, including the blank-padded `CHAR` values, against + PostgreSQL 16. - `DefaultSchemaParserTest.shouldRejectUnsupportedColumnType` covers the rejected spellings. @@ -278,7 +275,8 @@ Nulls: proves that row absence is reported by `:optional` and `:one` rather than by a null result. - `DefaultSchemaParserTest.shouldParseColumns`, - `DefaultSchemaParserTest.shouldParseNullabilityOfAddedColumnTypes`, and + `DefaultSchemaParserTest.shouldParseNullabilityOfAddedColumnTypes`, + `DefaultSchemaParserTest.shouldParseNullabilityOfIntegerAliasColumns`, and `DefaultSchemaParserTest.shouldParseSerialColumnAsNotNullable` cover parsed nullability. 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 8c0f52f..220daaa 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 @@ -108,15 +108,15 @@ private String typeName(ColumnDefinition definition) { private ColumnType columnType(String typeName) { return switch (typeName) { - case "INTEGER", "INT", "SERIAL" -> ColumnType.INTEGER; - case "BIGINT", "BIGSERIAL" -> ColumnType.BIGINT; - case "SMALLINT" -> ColumnType.SMALLINT; + case "INTEGER", "INT", "INT4", "SERIAL", "SERIAL4" -> ColumnType.INTEGER; + case "BIGINT", "INT8", "BIGSERIAL", "SERIAL8" -> ColumnType.BIGINT; + case "SMALLINT", "INT2", "SMALLSERIAL", "SERIAL2" -> ColumnType.SMALLINT; case "BOOLEAN", "BOOL" -> ColumnType.BOOLEAN; - case "VARCHAR" -> ColumnType.VARCHAR; + case "VARCHAR", "CHARACTER VARYING", "CHAR", "CHARACTER" -> 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 "TIMESTAMP", "TIMESTAMP WITHOUT TIME ZONE" -> ColumnType.TIMESTAMP; + case "TIMESTAMP WITH TIME ZONE", "TIMESTAMPTZ" -> ColumnType.TIMESTAMP_WITH_TIME_ZONE; case "DECIMAL", "NUMERIC" -> ColumnType.DECIMAL; case "UUID" -> ColumnType.UUID; default -> throw new UnsupportedOperationException( @@ -131,7 +131,10 @@ private ColumnType columnType(String typeName) { * nullable. */ private boolean isSerial(String typeName) { - return "SERIAL".equals(typeName) || "BIGSERIAL".equals(typeName); + return switch (typeName) { + case "SERIAL", "BIGSERIAL", "SMALLSERIAL", "SERIAL2", "SERIAL4", "SERIAL8" -> true; + default -> false; + }; } private boolean isNullable(ColumnDefinition definition) { 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 b366068..a6b22b5 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -123,6 +123,29 @@ CONSTRAINT customer_orders_quantity_check CHECK (quantity > 0), ); """; + /** + * A snapshot that declares every supported type through its PostgreSQL + * spelling, including the serial spellings, which PostgreSQL fills from + * their own sequences. + */ + private static final String ALIAS_SCHEMA = """ + CREATE TABLE user_aliases + ( + id INT8 PRIMARY KEY, + code INT4 NOT NULL, + score INT2, + label CHARACTER VARYING(20), + tag CHAR(3), + initials CHARACTER(3), + created_at TIMESTAMP(3) WITHOUT TIME ZONE, + updated_at TIMESTAMPTZ, + small_serial SMALLSERIAL, + serial_two SERIAL2, + serial_four SERIAL4, + serial_eight SERIAL8 + ); + """; + /** * Queries used by the caller-owned transaction tests: an affected-row * write, a returning write, and a read. @@ -229,6 +252,7 @@ static void stopPostgres() { void resetDatabase() throws Exception { execute("DROP TABLE IF EXISTS customer_orders"); execute("DROP TABLE IF EXISTS customers"); + execute("DROP TABLE IF EXISTS user_aliases"); execute("DROP TABLE IF EXISTS users"); execute(SCHEMA); } @@ -811,6 +835,135 @@ INSERT INTO users (id, code, serial_id, revision, external_id, updated_at) } } + /** + * Proves that a snapshot declaring the PostgreSQL type spellings is valid + * PostgreSQL DDL and round trips through generated code: the non-serial + * columns are written through the generated write method and read back with + * the Java types the spellings map to, while the omitted serial columns are + * filled by their sequences. + * + *

PostgreSQL blank-pads a {@code character} value to the declared + * length, so {@code "ab"} written into {@code CHAR(3)} reads back as + * {@code "ab "}. A {@code timestamptz} is normalized to the session time + * zone, so it is compared by instant. + */ + @Test + void shouldRoundTripPostgresTypeSpellingValues() throws Exception { + execute(ALIAS_SCHEMA); + + Path classesDirectory = generateAndCompile( + ALIAS_SCHEMA, + """ + -- name: InsertUserAlias :exec + INSERT INTO user_aliases + (id, code, score, label, tag, initials, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8); + + -- name: GetUserAlias :one + SELECT id, code, score, label, tag, initials, created_at, updated_at, + small_serial, serial_two, serial_four, serial_eight + FROM user_aliases + WHERE id = $1; + """ + ); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object repository = newRepository(classLoader); + + Method insertMethod = repository.getClass().getMethod( + "insertUserAlias", + Long.class, + Integer.class, + Short.class, + String.class, + String.class, + String.class, + LocalDateTime.class, + OffsetDateTime.class + ); + + Object affectedRows = insertMethod.invoke( + repository, + 8L, + 42, + (short) 7, + "gold", + "ab", + "xyz", + LocalDateTime.of(2026, 1, 1, 10, 0), + UPDATED_AT + ); + + assertEquals(1, affectedRows); + + Object result = repository + .getClass() + .getMethod("getUserAlias", Long.class) + .invoke(repository, 8L); + + assertNotNull(result); + + assertEquals( + List.of( + "id", + "code", + "score", + "label", + "tag", + "initials", + "createdAt", + "updatedAt", + "smallSerial", + "serialTwo", + "serialFour", + "serialEight" + ), + recordComponentNames(result) + ); + + assertEquals( + List.of( + Long.class, + Integer.class, + Short.class, + String.class, + String.class, + String.class, + LocalDateTime.class, + OffsetDateTime.class, + Short.class, + Short.class, + Integer.class, + Long.class + ), + recordComponentTypes(result) + ); + + assertEquals(8L, component(result, "id")); + assertEquals(42, component(result, "code")); + assertEquals((short) 7, component(result, "score")); + assertEquals("gold", component(result, "label")); + assertEquals("ab ", component(result, "tag")); + assertEquals("xyz", component(result, "initials")); + assertEquals( + LocalDateTime.of(2026, 1, 1, 10, 0), + component(result, "createdAt") + ); + + OffsetDateTime updatedAt = assertInstanceOf( + OffsetDateTime.class, + component(result, "updatedAt") + ); + + assertEquals(UPDATED_AT.toInstant(), updatedAt.toInstant()); + + assertEquals((short) 1, component(result, "smallSerial")); + assertEquals((short) 1, component(result, "serialTwo")); + assertEquals(1, component(result, "serialFour")); + assertEquals(1L, component(result, "serialEight")); + } + } + /** * Proves that a snapshot carrying table-level foreign key and check * constraints, column defaults, and named constraints is valid PostgreSQL @@ -1541,6 +1694,12 @@ private List recordComponentNames(Object record) { .toList(); } + private List> recordComponentTypes(Object record) { + return Arrays.stream(record.getClass().getRecordComponents()) + .>map(RecordComponent::getType) + .toList(); + } + private Object component(Object record, String componentName) throws Exception { return record .getClass() 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 6aefcb6..49586ac 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 @@ -223,11 +223,64 @@ CREATE TABLE users ( ); } + @ParameterizedTest + @CsvSource( + { + "TIMESTAMPTZ, TIMESTAMP_WITH_TIME_ZONE", + "timestamptz, TIMESTAMP_WITH_TIME_ZONE", + "TIMESTAMPTZ(3), TIMESTAMP_WITH_TIME_ZONE", + "TIMESTAMP WITHOUT TIME ZONE, TIMESTAMP", + "timestamp without time zone, TIMESTAMP", + "TIMESTAMP(3) WITHOUT TIME ZONE, TIMESTAMP", + "INT2, SMALLINT", + "int2, SMALLINT", + "INT4, INTEGER", + "int4, INTEGER", + "INT8, BIGINT", + "int8, BIGINT", + "SMALLSERIAL, SMALLINT", + "smallserial, SMALLINT", + "SERIAL2, SMALLINT", + "serial2, SMALLINT", + "SERIAL4, INTEGER", + "serial4, INTEGER", + "SERIAL8, BIGINT", + "serial8, BIGINT", + "CHARACTER VARYING, VARCHAR", + "character varying, VARCHAR", + "CHARACTER VARYING(20), VARCHAR", + "CHAR, VARCHAR", + "char, VARCHAR", + "CHAR(2), VARCHAR", + "CHARACTER, VARCHAR", + "character(3), VARCHAR" + } + ) + void shouldParsePostgresTypeSpellings(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" + "BIGSERIAL", + "SMALLSERIAL", + "SERIAL2", + "SERIAL4", + "SERIAL8" } ) void shouldParseSerialColumnAsNotNullable(String sqlType) { @@ -267,14 +320,40 @@ CREATE TABLE users ( ); } + /** + * The integer aliases carry no implicit {@code NOT NULL}, unlike the serial + * spellings, so their nullability comes from {@code NOT NULL} only. + */ + @Test + void shouldParseNullabilityOfIntegerAliasColumns() { + String sql = """ + CREATE TABLE users ( + score INT2, + code INT4, + revision INT8 + ); + """; + + Schema schema = parser.parse(sql); + + Table table = schema.tables().getFirst(); + + assertEquals( + List.of( + new Column("score", ColumnType.SMALLINT, true), + new Column("code", ColumnType.INTEGER, true), + new Column("revision", ColumnType.BIGINT, true) + ), + table.columns() + ); + } + @ParameterizedTest @ValueSource( strings = { - "TIMESTAMPTZ", - "SERIAL4", - "SERIAL8", - "SMALLSERIAL", - "TIMESTAMP WITHOUT TIME ZONE" + "JSONB", + "DOUBLE PRECISION", + "\"char\"" } ) void shouldRejectUnsupportedColumnType(String sqlType) {