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
56 changes: 41 additions & 15 deletions docs/postgresql.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ accepted and map exactly like their unparameterized spellings.
| `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`. |
| `JSON` | `String` | The JSON text itself. PostgreSQL stores it as written, so it reads back exactly as written. sqlcj never parses, validates, or normalizes it. |
| `JSONB` | `String` | The JSON text itself. PostgreSQL stores a decomposed value, so the text reads back as PostgreSQL renders it rather than as written, and `=` compares by value. sqlcj never parses, validates, or normalizes it. |

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
Expand All @@ -140,8 +142,17 @@ failing the schema, and fails only a query that uses it; see
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.
column is read at its one-based position in the selected-column list, with
`resultSet.getObject(position, JavaType.class)`, or with
`resultSet.getString(position)` for a `JSON` or `JSONB` column, which the driver
reports as a type of its own rather than as a character type.

A `JSON` or `JSONB` argument is passed to the executor as
`new dev.sqlcj.runtime.UntypedText(value)`, written out in full so that the
generated imports are unchanged, and `JdbcQueryExecutor` binds that text with
`java.sql.Types.OTHER`. PostgreSQL then types the text from the context of its
placeholder, which is what a `json` or `jsonb` column or comparison needs: text
bound as `varchar` is rejected there.

## Supported `CREATE TABLE` Constructs

Expand Down Expand Up @@ -283,14 +294,18 @@ mixed:
component and no method parameter is wrapped in `Optional`, and no custom
nullable wrapper is used.

Null handling is uniform and independent of the column type:
Null handling does not depend on the column type:

- the generated argument list is built with `java.util.Arrays.asList`, which
accepts null elements,
- `JdbcQueryExecutor` binds each argument positionally with
`PreparedStatement.setObject`, so a null argument is bound as SQL `NULL`,
- each result column is read with `ResultSet.getObject(position, Class)`, so a
SQL `NULL` is read back as `null`.
`PreparedStatement.setObject`, so a null argument is bound as SQL `NULL`. A
`JSON` or `JSONB` argument is bound the same way through its
`dev.sqlcj.runtime.UntypedText` wrapper, so a null value becomes a SQL `NULL`
without a declared type,
- each result column is read with `ResultSet.getObject(position, Class)`, or
with `ResultSet.getString(position)` for `JSON` and `JSONB`, so a SQL `NULL` is
read back as `null`.

Nullability itself is parsed from `NOT NULL` only:

Expand All @@ -306,15 +321,15 @@ Nullability itself is parsed from `NOT NULL` only:
Null binding and null reading are executed against PostgreSQL for the nullable
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`.
`TIMESTAMP WITH TIME ZONE`, `REAL`, `DOUBLE PRECISION`, `BYTEA`, `TIME`, `JSON`,
and `JSONB`.

## 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:

- `JSON` and `JSONB`,
- arrays,
- arrays, including `JSON[]` and `JSONB[]`,
- enum types,
- domain types,
- range types,
Expand All @@ -329,7 +344,7 @@ These spellings of otherwise mapped families are unmapped as well:
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
dimension, so `jsonb` is recorded as `JSONB`, `varchar(20)[]` as `VARCHAR[]`,
dimension, so `xml` is recorded as `XML`, `varchar(20)[]` as `VARCHAR[]`,
and `integer[][]` as `INTEGER[][]`.

A recorded column fails only the analysis of a query that
Expand Down Expand Up @@ -377,10 +392,21 @@ Type table:
- `PostgresIntegrationTest.shouldExecuteGeneratedOneQueryAgainstPostgres`,
`PostgresIntegrationTest.shouldExecuteGeneratedWriteAgainstPostgres`,
`PostgresIntegrationTest.shouldRoundTripSerialUuidAndTimestampWithTimeZoneValues`,
`PostgresIntegrationTest.shouldRoundTripPostgresTypeSpellingValues`, and
`PostgresIntegrationTest.shouldRoundTripFloatingPointBinaryAndTimeValues`
execute the Java mappings, including the blank-padded `CHAR` values, against
PostgreSQL 16.
`PostgresIntegrationTest.shouldRoundTripPostgresTypeSpellingValues`,
`PostgresIntegrationTest.shouldRoundTripFloatingPointBinaryAndTimeValues`, and
`PostgresIntegrationTest.shouldRoundTripJsonValues`
execute the Java mappings, including the blank-padded `CHAR` values, the JSON
text as written and as PostgreSQL renders it, and a `JSONB` equality
predicate, against PostgreSQL 16.
- `JavaCodeGeneratorTest.shouldWrapJsonArgumentsAndReadJsonColumnsAsText` and
`JavaCodeGeneratorTest.shouldGenerateCompilableJavaSourceForJsonTypes` cover
the generated `UntypedText` argument at every binding position of a JSON
placeholder, the `getString` read, the unchanged binding and reading of the
other `String` types, and compilation of the generated source.
- `JdbcQueryExecutorTest.shouldBindUntypedTextWithoutADeclaredSqlType` and
`JdbcQueryExecutorTest.shouldBindNullUntypedTextWithoutADeclaredSqlType`
cover the runtime binding of a null and a non-null `UntypedText` beside an
ordinary argument.
- `DefaultSchemaParserTest.shouldRecordUnsupportedColumnType` and
`DefaultSchemaParserTest.shouldParseNullabilityOfUnsupportedColumnTypes` cover
the recorded type of an unmapped spelling and of an array, beside the mapped
Expand All @@ -393,7 +419,7 @@ Type table:
- `SqlcjCompilerIntegrationTest.shouldReportTheQueryThatReadsAnUnsupportedTypeColumn`
covers the query diagnostic and that no file is written, and
`SqlcjCompilerIntegrationTest.shouldGenerateCompilableRepositoryBesideUnsupportedTypeColumns`
compiles a repository generated beside an array and a `JSONB` column.
compiles a repository generated beside an array and an `XML` column.

`CREATE TABLE` table:

Expand Down
5 changes: 4 additions & 1 deletion docs/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,10 @@ whose complete row an entry returns.
- A `:exec` query generates no result record and returns `int`.
- Every generated method passes the repository class name and the query name to
the executor, as the first two arguments of its call, so a runtime failure
names the query the application called.
names the query the application called. A `JSON` or `JSONB` argument is passed
as `new dev.sqlcj.runtime.UntypedText(<parameter>)`, written out in full, so
that the runtime binds its text without a declared SQL type; see
[Supported Column Types](postgresql.md#supported-column-types).
- Generated repository source imports only `dev.sqlcj.runtime.QueryExecutor`,
`dev.sqlcj.runtime.RowMapper`, `java.util.List`, `java.util.Optional` when the
entry declares an `:optional` query, and the JDK types of the mapped columns,
Expand Down
58 changes: 50 additions & 8 deletions sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -637,27 +637,57 @@ private boolean isLineEnd(String sql, int index) {
* than {@code List.of} so that a null argument can be bound.
*/
private String generateParameterList(QueryModel query, JavaNames.QueryNames names) {
Map<Integer, String> namesByIndex = generateParameterNamesByIndex(query, names);
Map<Integer, String> argumentsByIndex = generateArgumentsByIndex(query, names);

return query.bindingParameterIndexes().stream()
.map(namesByIndex::get)
.map(argumentsByIndex::get)
.collect(Collectors.joining(", ", "java.util.Arrays.asList(", ")"));
}

private Map<Integer, String> generateParameterNamesByIndex(
/**
* Renders the executor argument of each logical parameter, keyed by the
* placeholder index it is bound at, so that a repeated index renders the
* same argument at each of its binding positions.
*/
private Map<Integer, String> generateArgumentsByIndex(
QueryModel query,
JavaNames.QueryNames names
) {
Map<Integer, String> namesByIndex = new LinkedHashMap<>();
Map<Integer, String> argumentsByIndex = new LinkedHashMap<>();

for (int index = 0; index < query.parameters().size(); index++) {
namesByIndex.put(
query.parameters().get(index).index(),
names.parameterNames().get(index)
QueryParameter parameter = query.parameters().get(index);

argumentsByIndex.put(
parameter.index(),
generateArgument(parameter, names.parameterNames().get(index))
);
}

return namesByIndex;
return argumentsByIndex;
}

/**
* Renders one executor argument. A JSON parameter is wrapped in
* {@code dev.sqlcj.runtime.UntypedText} so that the runtime binds its text
* without a declared SQL type and the database types it from the context of
* its placeholder. The wrapper is written out in full, so the generated
* imports are the same as without it.
*/
private String generateArgument(QueryParameter parameter, String name) {
if (isUntypedText(parameter.type())) {
return "new dev.sqlcj.runtime.UntypedText(" + name + ")";
}

return name;
}

/**
* Reports whether a type's Java text is bound and read as text the database
* types itself rather than through the JDBC type of {@code String}.
*/
private boolean isUntypedText(ColumnType type) {
return type == ColumnType.JSON || type == ColumnType.JSONB;
}

private String generateReturnType(QueryModel query, JavaNames.QueryNames names) {
Expand Down Expand Up @@ -703,7 +733,19 @@ private String generateResultMappings(List<QueryColumn> columns) {
.collect(Collectors.joining(",\n"));
}

/**
* A JSON column is read with {@code getString}, because a driver reports it
* as a type of its own for which {@code getObject(position, String.class)}
* is not defined; every other column is read as its mapped Java type.
*/
private String generateResultMapping(QueryColumn column, int position) {
if (isUntypedText(column.type())) {
return "resultSet.getString(%d)"
.formatted(position)
.indent(8)
.stripTrailing();
}

String javaType = typeResolver.resolve(column.type());

return "resultSet.getObject(%d, %s.class)"
Expand Down
4 changes: 3 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 @@ -15,5 +15,7 @@ public enum ColumnType {
REAL,
DOUBLE_PRECISION,
UUID,
BYTEA
BYTEA,
JSON,
JSONB
}
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,8 @@ private ColumnType columnType(String typeName) {
case "DOUBLE PRECISION", "FLOAT8" -> ColumnType.DOUBLE_PRECISION;
case "UUID" -> ColumnType.UUID;
case "BYTEA" -> ColumnType.BYTEA;
case "JSON" -> ColumnType.JSON;
case "JSONB" -> ColumnType.JSONB;
default -> null;
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public String resolve(ColumnType type) {
case BIGINT -> "Long";
case SMALLINT -> "Short";
case BOOLEAN -> "Boolean";
case VARCHAR, TEXT -> "String";
case VARCHAR, TEXT, JSON, JSONB -> "String";
case DATE -> "LocalDate";
case TIME -> "LocalTime";
case TIMESTAMP -> "LocalDateTime";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,19 @@ closed_at TIME(3) WITHOUT TIME ZONE
);
""";

/**
* A snapshot declaring one nullable column of each JSON spelling, so that
* one row can carry JSON text and another can carry nulls.
*/
private static final String DOCUMENT_SCHEMA = """
CREATE TABLE documents
(
id BIGINT PRIMARY KEY,
payload JSON,
config JSONB
);
""";

/**
* Queries used by the caller-owned transaction tests: an affected-row
* write, a returning write, and a read.
Expand Down Expand Up @@ -323,6 +336,7 @@ void resetDatabase() throws Exception {
execute("DROP TABLE IF EXISTS customer_orders");
execute("DROP TABLE IF EXISTS customers");
execute("DROP TABLE IF EXISTS measurements");
execute("DROP TABLE IF EXISTS documents");
execute("DROP TABLE IF EXISTS user_aliases");
execute("DROP TABLE IF EXISTS users");
execute(SCHEMA);
Expand Down Expand Up @@ -1160,6 +1174,102 @@ void shouldRoundTripFloatingPointBinaryAndTimeValues() throws Exception {
}
}

/**
* Proves that JSON text round trips through generated code: one row is
* written with JSON values and one with nulls, both are read back as
* {@code String} components, and a {@code JSONB} equality predicate matches
* by value rather than by text. {@code JSON} keeps the text as written and
* {@code JSONB} reads back as PostgreSQL normalizes it. The equality
* predicate is proven on {@code JSONB} alone, because PostgreSQL defines no
* {@code json = json} operator.
*/
@Test
void shouldRoundTripJsonValues() throws Exception {
execute(DOCUMENT_SCHEMA);

Path classesDirectory = generateAndCompile(
DOCUMENT_SCHEMA,
"""
-- name: InsertDocument :exec
INSERT INTO documents (id, payload, config)
VALUES ($1, $2, $3);

-- name: GetDocument :one
SELECT id, payload, config
FROM documents
WHERE id = $1;

-- name: FindDocumentByConfig :optional
SELECT id, payload, config
FROM documents
WHERE config = $1;
"""
);

String payload = "{\"b\": 2,\n \"a\": 1}";
String config = "{\"b\": 2, \"a\": 1}";

try (URLClassLoader classLoader = classLoader(classesDirectory)) {
Object repository = newRepository(classLoader);

Method insertMethod = repository.getClass().getMethod(
"insertDocument",
Long.class,
String.class,
String.class
);

assertEquals(1, insertMethod.invoke(repository, 1L, payload, config));
assertEquals(1, insertMethod.invoke(repository, 2L, null, null));

Method queryMethod = repository.getClass().getMethod("getDocument", Long.class);

Object result = queryMethod.invoke(repository, 1L);

assertNotNull(result);

assertEquals(
List.of("id", "payload", "config"),
recordComponentNames(result)
);

assertEquals(
List.of(Long.class, String.class, String.class),
recordComponentTypes(result)
);

assertEquals(payload, component(result, "payload"));
assertEquals("{\"a\": 1, \"b\": 2}", component(result, "config"));

Object nullResult = queryMethod.invoke(repository, 2L);

assertNotNull(nullResult);

assertEquals(2L, component(nullResult, "id"));
assertNull(component(nullResult, "payload"));
assertNull(component(nullResult, "config"));

Method findMethod = repository.getClass().getMethod(
"findDocumentByConfig",
String.class
);

Optional<?> found = assertInstanceOf(
Optional.class,
findMethod.invoke(repository, "{\"a\":1, \"b\":2}")
);

assertEquals(1L, component(found.orElseThrow(), "id"));

assertTrue(
assertInstanceOf(
Optional.class,
findMethod.invoke(repository, new Object[] { null })
).isEmpty()
);
}
}

/**
* Proves that a snapshot carrying table-level foreign key and check
* constraints, column defaults, and named constraints is valid PostgreSQL
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2853,7 +2853,7 @@ void shouldGenerateCompilableRepositoryBesideUnsupportedTypeColumns() throws IOE
id BIGINT NOT NULL,
name VARCHAR(255),
tags VARCHAR(20)[],
metadata JSONB
metadata XML
);
""",
"""
Expand Down
Loading
Loading