diff --git a/docs/postgresql.md b/docs/postgresql.md index 82167f2..a24b43d 100644 --- a/docs/postgresql.md +++ b/docs/postgresql.md @@ -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 @@ -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 @@ -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: @@ -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, @@ -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 @@ -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 @@ -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: diff --git a/docs/queries.md b/docs/queries.md index c93c2b7..8b26e41 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -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()`, 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, 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 00c1bc9..12da604 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java @@ -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 namesByIndex = generateParameterNamesByIndex(query, names); + Map argumentsByIndex = generateArgumentsByIndex(query, names); return query.bindingParameterIndexes().stream() - .map(namesByIndex::get) + .map(argumentsByIndex::get) .collect(Collectors.joining(", ", "java.util.Arrays.asList(", ")")); } - private Map 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 generateArgumentsByIndex( QueryModel query, JavaNames.QueryNames names ) { - Map namesByIndex = new LinkedHashMap<>(); + Map 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) { @@ -703,7 +733,19 @@ private String generateResultMappings(List 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)" 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 adc018c..55c976c 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/schema/ColumnType.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/schema/ColumnType.java @@ -15,5 +15,7 @@ public enum ColumnType { REAL, DOUBLE_PRECISION, UUID, - BYTEA + BYTEA, + JSON, + JSONB } 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 403308b..65ef5ec 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 @@ -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; }; } 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 0b9c0d0..8970307 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/type/DefaultTypeResolver.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/type/DefaultTypeResolver.java @@ -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"; 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 ba3ea6f..73ffde5 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -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. @@ -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); @@ -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 diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java index 7618a99..4a09321 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -2853,7 +2853,7 @@ void shouldGenerateCompilableRepositoryBesideUnsupportedTypeColumns() throws IOE id BIGINT NOT NULL, name VARCHAR(255), tags VARCHAR(20)[], - metadata JSONB + metadata XML ); """, """ 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 74ae83a..0d6227e 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java @@ -483,7 +483,9 @@ void shouldGenerateDistinctComponentsAndPositionalReadsForDuplicateColumns() thr "REAL, Float", "DOUBLE_PRECISION, Double", "BYTEA, byte[]", - "TIME, LocalTime" + "TIME, LocalTime", + "JSON, String", + "JSONB, String" } ) void shouldGenerateJavaTypeForQueryParameter( @@ -937,6 +939,127 @@ void shouldGenerateCompilableJavaSourceForFloatingPointBinaryAndTimeTypes() assertEquals(0, compile(file, "UsersRepository.java")); } + /** + * A JSON parameter is bound through {@code dev.sqlcj.runtime.UntypedText} + * at every position its placeholder index is bound at, and a JSON result + * column is read as text. The other {@code String} types are bound and read + * exactly as before, and the wrapper is written out in full, so the + * generated imports are unchanged. + */ + @Test + void shouldWrapJsonArgumentsAndReadJsonColumnsAsText() throws IOException { + QueryModel query = new QueryModel( + "FindDocument", + QueryType.ONE, + "documents", + """ + SELECT id, metadata, profile, name + FROM documents + WHERE name = ? + AND (metadata = ? OR metadata = ?) + AND bio = ? + AND profile = ? + """, + List.of(3, 1, 1, 4, 2), + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("metadata", ColumnType.JSONB, true), + new QueryColumn("profile", ColumnType.JSON, true), + new QueryColumn("name", ColumnType.VARCHAR, true) + ), + List.of( + new QueryParameter(1, "metadata", ColumnType.JSONB), + new QueryParameter(2, "profile", ColumnType.JSON), + new QueryParameter(3, "name", ColumnType.VARCHAR), + new QueryParameter(4, "bio", ColumnType.TEXT) + ), + null + ); + + GeneratedFile file = generate(query); + + String source = file.content(); + + assertTrue( + source.contains( + "public FindDocumentResult findDocument(" + + "String metadata, String profile, String name, String bio)" + ) + ); + + assertTrue( + source.contains( + "java.util.Arrays.asList(" + + "name, " + + "new dev.sqlcj.runtime.UntypedText(metadata), " + + "new dev.sqlcj.runtime.UntypedText(metadata), " + + "bio, " + + "new dev.sqlcj.runtime.UntypedText(profile))" + ) + ); + + assertTrue(source.contains("resultSet.getObject(1, Long.class)")); + assertTrue(source.contains("resultSet.getString(2)")); + assertTrue(source.contains("resultSet.getString(3)")); + assertTrue(source.contains("resultSet.getObject(4, String.class)")); + + assertFalse(source.contains("import dev.sqlcj.runtime.UntypedText;")); + + assertCompiles(file); + } + + /** + * Compiles a repository that binds JSON text in a write and binds and reads + * it in a query. + */ + @Test + void shouldGenerateCompilableJavaSourceForJsonTypes() throws IOException { + QueryModel insertDocument = new QueryModel( + "InsertDocument", + QueryType.EXEC, + "documents", + SQL, + List.of(1, 2, 3), + List.of(), + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "metadata", ColumnType.JSONB), + new QueryParameter(3, "profile", ColumnType.JSON) + ), + null + ); + + QueryModel getDocument = new QueryModel( + "GetDocument", + QueryType.ONE, + "documents", + SQL, + List.of(1), + List.of( + new QueryColumn("metadata", ColumnType.JSONB, true), + new QueryColumn("profile", ColumnType.JSON, true) + ), + List.of( + new QueryParameter(1, "metadata", ColumnType.JSONB) + ), + null + ); + + GeneratedFile file = codeGenerator.generate( + new QueryGroupModel( + GROUP, + List.of(insertDocument, getDocument) + ) + ); + + String source = file.content(); + + assertTrue(source.contains("String metadata, String profile")); + assertTrue(source.contains("resultSet.getString(1)")); + + 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"); @@ -981,7 +1104,9 @@ private int compile(GeneratedFile file, String fileName) throws IOException { "REAL, Float", "DOUBLE_PRECISION, Double", "BYTEA, byte[]", - "TIME, LocalTime" + "TIME, LocalTime", + "JSON, String", + "JSONB, String" } ) 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 3f81e81..36b1873 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 @@ -358,7 +358,11 @@ CREATE TABLE users ( "TIME(3), TIME", "TIME WITHOUT TIME ZONE, TIME", "time without time zone, TIME", - "TIME(3) WITHOUT TIME ZONE, TIME" + "TIME(3) WITHOUT TIME ZONE, TIME", + "JSON, JSON", + "json, JSON", + "JSONB, JSONB", + "jsonb, JSONB" } ) void shouldParsePostgresTypeSpellings(String sqlType, ColumnType expectedType) { @@ -462,17 +466,20 @@ CREATE TABLE users ( * *

{@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. + * floating-point, binary, and time spellings. An array of a mapped JSON + * spelling is recorded like any other array. */ @ParameterizedTest @CsvSource( { - "JSONB, JSONB", + "XML, XML", "FLOAT, FLOAT", "float(24), FLOAT", "TIMETZ, TIMETZ", "time with time zone, TIME WITH TIME ZONE", "real[], REAL[]", + "json[], JSON[]", + "jsonb[], JSONB[]", "\"char\", \"CHAR\"", "varchar(20)[], VARCHAR[]", "integer[][], INTEGER[][]", @@ -508,7 +515,7 @@ void shouldParseNullabilityOfUnsupportedColumnTypes() { String sql = """ CREATE TABLE users ( tags INT[] NOT NULL, - metadata JSONB + metadata XML ); """; @@ -517,7 +524,7 @@ CREATE TABLE users ( assertEquals( List.of( new Column("tags", null, false, "INT[]"), - new Column("metadata", null, true, "JSONB") + new Column("metadata", null, true, "XML") ), table.columns() ); @@ -975,7 +982,7 @@ void shouldAppendAddedColumnsTypedLikeCreateTableColumns() { Schema schema = applied(""" ALTER TABLE users ADD COLUMN created_at TIMESTAMPTZ NOT NULL; ALTER TABLE users ADD COLUMN revision SERIAL; - ALTER TABLE users ADD COLUMN metadata JSONB; + ALTER TABLE users ADD COLUMN metadata XML; ALTER TABLE users ADD COLUMN tags varchar(20)[]; """); @@ -986,7 +993,7 @@ void shouldAppendAddedColumnsTypedLikeCreateTableColumns() { new Column("name", ColumnType.VARCHAR, true), new Column("created_at", ColumnType.TIMESTAMP_WITH_TIME_ZONE, false), new Column("revision", ColumnType.INTEGER, false), - new Column("metadata", null, true, "JSONB"), + new Column("metadata", null, true, "XML"), new Column("tags", null, true, "VARCHAR[]") ), table(schema, "users").columns() @@ -1075,13 +1082,13 @@ void shouldRenameTableInItsPosition() { void shouldChangeColumnTypeInItsPositionKeepingItsNullability() { Schema schema = applied(""" ALTER TABLE users ALTER COLUMN id TYPE INT4; - ALTER TABLE users ALTER COLUMN email TYPE JSONB; + ALTER TABLE users ALTER COLUMN email TYPE XML; """); assertEquals( List.of( new Column("id", ColumnType.INTEGER, false), - new Column("email", null, true, "JSONB"), + new Column("email", null, true, "XML"), new Column("name", ColumnType.VARCHAR, true) ), table(schema, "users").columns() @@ -1091,7 +1098,7 @@ void shouldChangeColumnTypeInItsPositionKeepingItsNullability() { @Test void shouldChangeARecordedColumnTypeBackToAMappedType() { Schema schema = applied(""" - ALTER TABLE users ALTER COLUMN name TYPE JSONB; + ALTER TABLE users ALTER COLUMN name TYPE XML; ALTER TABLE users ALTER COLUMN name SET NOT NULL; ALTER TABLE users ALTER COLUMN name TYPE TEXT; """); diff --git a/sqlcj-runtime/src/main/java/dev/sqlcj/runtime/JdbcQueryExecutor.java b/sqlcj-runtime/src/main/java/dev/sqlcj/runtime/JdbcQueryExecutor.java index 3d0a1bf..defa3c4 100644 --- a/sqlcj-runtime/src/main/java/dev/sqlcj/runtime/JdbcQueryExecutor.java +++ b/sqlcj-runtime/src/main/java/dev/sqlcj/runtime/JdbcQueryExecutor.java @@ -5,6 +5,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Types; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -32,6 +33,13 @@ * commit or rollback. * * + *

Each argument is bound at its one-based position with + * {@link PreparedStatement#setObject(int, Object)}, so a null argument is bound + * as SQL {@code NULL}. An {@link UntypedText} argument is the one exception: its + * {@link UntypedText#value() value} is bound with + * {@link java.sql.Types#OTHER}, which sends the text without a declared SQL + * type and lets the database type it from the context of its placeholder. + * *

{@link #queryOne} reads the first row, maps it, and then advances the * result set once more to prove that there is no second row. * {@link #queryOptional} does the same but reports no row as @@ -219,9 +227,19 @@ private T onConnection( } } + /** + * Binds each argument at its one-based position, an {@link UntypedText} as + * its text without a declared SQL type and every other argument as itself. + */ private void bindParameters(PreparedStatement statement, List parameters) throws SQLException { for (int i = 0; i < parameters.size(); i++) { - statement.setObject(i + 1, parameters.get(i)); + Object parameter = parameters.get(i); + + if (parameter instanceof UntypedText(String value)) { + statement.setObject(i + 1, value, Types.OTHER); + } else { + statement.setObject(i + 1, parameter); + } } } diff --git a/sqlcj-runtime/src/main/java/dev/sqlcj/runtime/QueryExecutor.java b/sqlcj-runtime/src/main/java/dev/sqlcj/runtime/QueryExecutor.java index bf5aed8..93f9215 100644 --- a/sqlcj-runtime/src/main/java/dev/sqlcj/runtime/QueryExecutor.java +++ b/sqlcj-runtime/src/main/java/dev/sqlcj/runtime/QueryExecutor.java @@ -9,6 +9,11 @@ *

Every operation is given the generated repository's class name and the * name of the query it was generated from, so an execution or cardinality * failure names the query the application called. + * + *

The parameters of an operation are the query's arguments in the order + * their placeholders appear in its SQL, and any of them may be {@code null}. An + * argument may also be an {@link UntypedText}, which carries text an + * implementation binds without a declared SQL type. */ public interface QueryExecutor { diff --git a/sqlcj-runtime/src/main/java/dev/sqlcj/runtime/UntypedText.java b/sqlcj-runtime/src/main/java/dev/sqlcj/runtime/UntypedText.java new file mode 100644 index 0000000..98f41a0 --- /dev/null +++ b/sqlcj-runtime/src/main/java/dev/sqlcj/runtime/UntypedText.java @@ -0,0 +1,19 @@ +package dev.sqlcj.runtime; + +/** + * A text argument that carries no declared SQL type. + * + *

{@link JdbcQueryExecutor} binds {@link #value()} as text without a + * declared SQL type, so the database types it from the context of the + * placeholder it is bound to rather than from the Java type of the value. That + * is how a value whose server type has no JDBC type of its own, such as + * PostgreSQL's {@code json} and {@code jsonb}, is accepted where a value bound + * as {@code varchar} would be rejected. + * + *

The value may be {@code null}, which is bound as an untyped SQL + * {@code NULL}. The text itself is never inspected, parsed, or normalized. + */ +public record UntypedText( + String value +) { +} diff --git a/sqlcj-runtime/src/test/java/dev/sqlcj/runtime/JdbcQueryExecutorTest.java b/sqlcj-runtime/src/test/java/dev/sqlcj/runtime/JdbcQueryExecutorTest.java index 2873255..27be45a 100644 --- a/sqlcj-runtime/src/test/java/dev/sqlcj/runtime/JdbcQueryExecutorTest.java +++ b/sqlcj-runtime/src/test/java/dev/sqlcj/runtime/JdbcQueryExecutorTest.java @@ -14,9 +14,11 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.sql.Types; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -129,6 +131,80 @@ void shouldBindSupportedJavaTypes() { assertEquals("Alice", result); } + /** + * An {@link UntypedText} argument is bound as its text without a declared + * SQL type, so the database types it from the context of its placeholder, + * while every other argument keeps its untouched {@code setObject} binding. + */ + @Test + void shouldBindUntypedTextWithoutADeclaredSqlType() throws SQLException { + BindingRecorder recorder = new BindingRecorder(); + + try (Connection connection = dataSource.getConnection()) { + JdbcQueryExecutor connectionExecutor = new JdbcQueryExecutor( + recorder.track(connection) + ); + + assertEquals( + 1, + connectionExecutor.execute( + REPOSITORY, + "UpdateUserName", + "UPDATE users SET name = ? WHERE id = ?", + Arrays.asList(new UntypedText("Alicia"), 1L) + ) + ); + } + + assertEquals( + List.of( + new BoundParameter(1, "Alicia", Types.OTHER), + new BoundParameter(2, 1L, null) + ), + recorder.bindings + ); + } + + /** A null {@link UntypedText} value is bound without a declared SQL type too. */ + @Test + void shouldBindNullUntypedTextWithoutADeclaredSqlType() throws SQLException { + BindingRecorder recorder = new BindingRecorder(); + + try (Connection connection = dataSource.getConnection()) { + JdbcQueryExecutor connectionExecutor = new JdbcQueryExecutor( + recorder.track(connection) + ); + + assertEquals( + 1, + connectionExecutor.execute( + REPOSITORY, + "UpdateUserName", + "UPDATE users SET name = ? WHERE id = ?", + Arrays.asList(new UntypedText(null), 1L) + ) + ); + } + + assertEquals( + List.of( + new BoundParameter(1, null, Types.OTHER), + new BoundParameter(2, 1L, null) + ), + recorder.bindings + ); + + assertNull( + executor.queryOne( + REPOSITORY, + "GetUser", + "SELECT name FROM users WHERE id = ?", + List.of(1L), + resultSet -> resultSet.getString("name") + ) + ); + } + @Test void shouldReturnAllRowsForQueryMany() { List results = executor.queryMany( @@ -830,6 +906,74 @@ private record User( ) { } + /** + * One {@code PreparedStatement.setObject} call an executor made, with the + * SQL type it declared or {@code null} when it declared none. + */ + private record BoundParameter( + int position, + Object value, + Integer sqlType + ) { + } + + /** + * Records the parameter bindings an executor made, in call order. A tracked + * connection is returned as a dynamic proxy that delegates every call and + * records the {@code setObject} calls of the prepared statements it hands + * out. + */ + private static final class BindingRecorder { + + private final List bindings = new ArrayList<>(); + + private Connection track(Connection connection) { + return (Connection) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[] { Connection.class }, + (proxy, method, arguments) -> { + Object result = invoke(connection, method, arguments); + + if (result instanceof PreparedStatement statement) { + return trackStatement(statement); + } + + return result; + } + ); + } + + private PreparedStatement trackStatement(PreparedStatement statement) { + return (PreparedStatement) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[] { PreparedStatement.class }, + (proxy, method, arguments) -> { + if ("setObject".equals(method.getName())) { + bindings.add( + new BoundParameter( + (Integer) arguments[0], + arguments[1], + arguments.length > 2 + ? (Integer) arguments[2] + : null + ) + ); + } + + return invoke(statement, method, arguments); + } + ); + } + + private Object invoke(Object delegate, Method method, Object[] arguments) throws Throwable { + try { + return method.invoke(delegate, arguments); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + } + /** * Records the JDBC resources an executor opened, so a test can assert which * of them were closed. A tracked object is returned as a dynamic proxy that