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

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions storm-core/src/main/java/st/orm/core/spi/JsonString.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright 2024 - 2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package st.orm.core.spi;

import jakarta.annotation.Nonnull;

/**
* A serialized JSON value produced by a JSON converter.
*
* <p>Carrying the JSON as a distinct type instead of a plain string lets dialects choose the appropriate JDBC
* binding via the {@code SqlDialect.setParameter} overload for this type: most databases bind JSON as a plain
* string, while PostgreSQL requires an untyped parameter so the server can cast it to native {@code json} or
* {@code jsonb} columns.</p>
*
* @param value the serialized JSON text.
* @since 1.12
*/
public record JsonString(@Nonnull String value) {

/**
* Returns the raw JSON text, so the value renders correctly when inlined as a SQL string literal.
*/
@Override
public String toString() {
return value;
}
}
20 changes: 20 additions & 0 deletions storm-core/src/main/java/st/orm/core/template/SqlDialect.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import java.util.function.Function;
import java.util.regex.Pattern;
import st.orm.Operator;
import st.orm.core.spi.JsonString;

/**
* Represents a specific SQL dialect with methods to determine feature support and handle identifier escaping.
Expand Down Expand Up @@ -390,6 +391,25 @@ default void setParameter(@Nonnull PreparedStatement preparedStatement, int inde
preparedStatement.setTimestamp(index, timestamp, calendar);
}

/**
* Sets a serialized JSON parameter on the given prepared statement.
*
* <p>The default implementation sets the JSON as a string, which is compatible with databases that store JSON
* in character types or have implicit string-to-JSON conversion. Dialects with strictly typed native JSON
* columns should override this method — PostgreSQL, for example, binds the value as an untyped parameter so
* the server casts it to {@code json} or {@code jsonb}.</p>
*
* @param preparedStatement the prepared statement.
* @param index the parameter index.
* @param json the serialized JSON value.
* @throws SQLException if a database access error occurs.
* @since 1.12
*/
default void setParameter(@Nonnull PreparedStatement preparedStatement, int index,
@Nonnull JsonString json) throws SQLException {
preparedStatement.setString(index, json.value());
}

/**
* Returns the SQL statement for getting the next value of the given sequence.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import st.orm.Data;
import st.orm.Ref;
import st.orm.StormConfig;
import st.orm.core.spi.JsonString;
import st.orm.core.spi.Provider;
import st.orm.core.spi.Providers;
import st.orm.core.spi.QueryFactory;
Expand Down Expand Up @@ -131,6 +132,7 @@ private void setParameters(@Nonnull jakarta.persistence.Query query, @Nonnull Li
case java.sql.Date d -> query.setParameter(p.position(), d, DATE);
case java.sql.Time d -> query.setParameter(p.position(), d, TIME);
case java.sql.Timestamp d -> query.setParameter(p.position(), d, TIMESTAMP);
case JsonString js -> query.setParameter(p.position(), js.value());
default -> query.setParameter(p.position(), dbValue);
}
}
Expand All @@ -140,6 +142,7 @@ private void setParameters(@Nonnull jakarta.persistence.Query query, @Nonnull Li
case java.sql.Date d -> query.setParameter(n.name(), d, DATE);
case java.sql.Time d -> query.setParameter(n.name(), d, TIME);
case java.sql.Timestamp d -> query.setParameter(n.name(), d, TIMESTAMP);
case JsonString js -> query.setParameter(n.name(), js.value());
default -> query.setParameter(n.name(), dbValue);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
import st.orm.BindVars;
import st.orm.PersistenceException;
import st.orm.StormConfig;
import st.orm.core.spi.JsonString;
import st.orm.core.spi.Provider;
import st.orm.core.spi.Providers;
import st.orm.core.spi.QueryFactory;
Expand Down Expand Up @@ -395,6 +396,7 @@ private static void setParameters(@Nonnull PreparedStatement preparedStatement,
case Byte b -> preparedStatement.setByte(idx, b);
case Boolean b -> preparedStatement.setBoolean(idx, b);
case String s -> preparedStatement.setString(idx, s);
case JsonString js -> dialect.setParameter(preparedStatement, idx, js);
case BigDecimal bd -> preparedStatement.setBigDecimal(idx, bd);
case ByteBuffer buf -> {
byte[] bytes = new byte[buf.remaining()];
Expand Down
15 changes: 15 additions & 0 deletions storm-core/src/test/java/st/orm/core/JpaIntegrationTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import st.orm.core.model.Pet;
import st.orm.core.model.PetTypeEnum;
import st.orm.core.model.Vet;
import st.orm.core.spi.JsonString;
import st.orm.core.template.JpaTemplate;
import st.orm.core.template.ORMTemplate;

Expand Down Expand Up @@ -387,6 +388,20 @@ void jpaOrm_selectVetRecord() {
}
}

@Test
void jpaOrm_bindsJsonStringParameter() {
// JsonString parameters are unwrapped to their JSON text before being handed to the JPA provider;
// without the unwrap, the provider cannot map the type.
try (var q = ORM(entityManager).query(raw("UPDATE owner SET address = \0 WHERE id = \0",
new JsonString("{\"street\":\"271 University Ave\",\"city\":\"Palo Alto\"}"), 1)).prepare()) {
assertEquals(1, q.executeUpdate());
}
try (var q = ORM(entityManager).query(raw("SELECT address FROM owner WHERE id = \0", 1)).prepare()) {
assertEquals("{\"street\":\"271 University Ave\",\"city\":\"Palo Alto\"}",
q.getResultStream(String.class).findFirst().orElseThrow());
}
}

@Test
void jpaOrm_executeUpdate_insertCity() {
try (var q = ORM(entityManager).query(raw("INSERT INTO city (name) VALUES (\0)", "TestCity")).prepare()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import st.orm.DbTable;
import st.orm.Entity;
import st.orm.PK;
import st.orm.core.spi.JsonString;
import st.orm.core.template.Sql;
import st.orm.core.template.SqlTemplate;
import st.orm.core.template.SqlTemplateException;
Expand All @@ -27,6 +28,17 @@ record TestEntity(@PK Integer id, String name) implements Entity<Integer> {}
*/
private static final SqlTemplate INLINE_TEMPLATE = SqlTemplate.PS.withInlineParameters(true);

// toLiteral: JsonString value

@Test
void testInlineJsonStringParameter() throws SqlTemplateException {
Sql sql = INLINE_TEMPLATE.process(raw("SELECT * FROM test_entity WHERE name = \0",
new JsonString("{\"name\":\"O'Brien\"}")));
assertNotNull(sql);
// The JSON renders as a quoted literal with the embedded single quote doubled.
assertTrue(sql.statement().contains("'{\"name\":\"O''Brien\"}'"));
}

// toLiteral: null value

@Test
Expand Down
Loading
Loading