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
33 changes: 21 additions & 12 deletions docs/postgresql.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
6 changes: 5 additions & 1 deletion sqlcj-cli/src/main/java/dev/sqlcj/schema/ColumnType.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ public enum ColumnType {
VARCHAR,
TEXT,
DATE,
TIME,
TIMESTAMP,
TIMESTAMP_WITH_TIME_ZONE,
DECIMAL,
UUID
REAL,
DOUBLE_PRECISION,
UUID,
BYTEA
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[]";
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading