From f0310db24b2f773e7eddc933c03dfbd6f9b37d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Mon, 28 Sep 2026 23:37:05 +0300 Subject: [PATCH 1/3] feat(runtime): bind UntypedText arguments without a declared SQL type --- .../dev/sqlcj/runtime/JdbcQueryExecutor.java | 20 ++- .../java/dev/sqlcj/runtime/QueryExecutor.java | 5 + .../java/dev/sqlcj/runtime/UntypedText.java | 19 +++ .../sqlcj/runtime/JdbcQueryExecutorTest.java | 144 ++++++++++++++++++ 4 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 sqlcj-runtime/src/main/java/dev/sqlcj/runtime/UntypedText.java 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 From 3310684b186c2c3c586b3f90b34853f8e514e7c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Mon, 28 Sep 2026 23:37:33 +0300 Subject: [PATCH 2/3] feat: map JSON and JSONB columns to String Generated code passes JSON and JSONB arguments as UntypedText so PostgreSQL types the text from its context, and reads those columns with getString. The text is never parsed or normalized. --- .../sqlcj/generator/JavaCodeGenerator.java | 58 ++++++-- .../java/dev/sqlcj/schema/ColumnType.java | 4 +- .../schema/parser/DefaultSchemaParser.java | 2 + .../dev/sqlcj/type/DefaultTypeResolver.java | 2 +- .../compiler/PostgresIntegrationTest.java | 110 +++++++++++++++ .../SqlcjCompilerIntegrationTest.java | 2 +- .../generator/JavaCodeGeneratorTest.java | 129 +++++++++++++++++- .../parser/DefaultSchemaParserTest.java | 27 ++-- 8 files changed, 311 insertions(+), 23 deletions(-) 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; """); From 711e6bf5fe951be955f727e7bf066cd6cfbdf33b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Mon, 28 Sep 2026 23:37:38 +0300 Subject: [PATCH 3/3] docs: document JSON and JSONB type mappings --- docs/postgresql.md | 56 +++++++++++++++++++++++++++++++++------------- docs/queries.md | 5 ++++- 2 files changed, 45 insertions(+), 16 deletions(-) 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,