Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 22 additions & 24 deletions docs/postgresql.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | |

Expand Down Expand Up @@ -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.

Expand All @@ -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:
Expand Down Expand Up @@ -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: ";" <ST_SEMICOLON> at line 4, column 1
```
Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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.
*
* <p>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
Expand Down Expand Up @@ -1541,6 +1694,12 @@ private List<String> recordComponentNames(Object record) {
.toList();
}

private List<Class<?>> recordComponentTypes(Object record) {
return Arrays.stream(record.getClass().getRecordComponents())
.<Class<?>>map(RecordComponent::getType)
.toList();
}

private Object component(Object record, String componentName) throws Exception {
return record
.getClass()
Expand Down
Loading
Loading