diff --git a/storm-core/src/main/java/st/orm/core/repository/impl/MergeEntityRepositoryImpl.java b/storm-core/src/main/java/st/orm/core/repository/impl/MergeEntityRepositoryImpl.java
new file mode 100644
index 000000000..cbb552a68
--- /dev/null
+++ b/storm-core/src/main/java/st/orm/core/repository/impl/MergeEntityRepositoryImpl.java
@@ -0,0 +1,361 @@
+/*
+ * 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.repository.impl;
+
+import static java.util.function.Predicate.not;
+import static java.util.stream.Collectors.joining;
+import static st.orm.core.template.SqlInterceptor.intercept;
+import static st.orm.core.template.TemplateString.combine;
+import static st.orm.core.template.TemplateString.raw;
+import static st.orm.core.template.TemplateString.wrap;
+import static st.orm.core.template.Templates.bindVar;
+import static st.orm.core.template.Templates.table;
+import static st.orm.core.template.impl.StringTemplates.flatten;
+
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import java.math.BigInteger;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.IntStream;
+import st.orm.BindVars;
+import st.orm.Entity;
+import st.orm.PersistenceException;
+import st.orm.core.spi.EntityCache;
+import st.orm.core.template.Column;
+import st.orm.core.template.Model;
+import st.orm.core.template.ORMTemplate;
+import st.orm.core.template.PreparedQuery;
+import st.orm.core.template.SqlTemplateException;
+import st.orm.core.template.TemplateString;
+
+/**
+ * Shared support for dialects that implement upsert with a SQL {@code MERGE} statement backed by a source query
+ * ({@code MERGE INTO t USING (SELECT ... ) src ON (...) WHEN MATCHED ... WHEN NOT MATCHED ...}), such as H2,
+ * Oracle and SQL Server.
+ *
+ *
The source query only projects the entity's declared columns — columns of expanded foreign relations are
+ * never referenced by the ON, UPDATE or INSERT clauses and are therefore not bound.
+ *
+ * Dialect differences are expressed as hooks: {@link #castType(Column)} for dialects whose parser cannot infer
+ * the type of a bare parameter in the source query (H2), {@link #mergeSourceSuffix()} for dialects that require a
+ * FROM clause (Oracle), {@link #statementSuffix()} for dialects that require a statement terminator (SQL Server),
+ * the version expressions, and {@link #mergeInsert()} for the dialect-specific WHEN NOT MATCHED clause.
+ *
+ * @since 1.12
+ */
+public abstract class MergeEntityRepositoryImpl, ID> extends EntityRepositoryImpl {
+
+ protected MergeEntityRepositoryImpl(@Nonnull ORMTemplate ormTemplate, @Nonnull Model model) {
+ super(ormTemplate, model);
+ }
+
+ /**
+ * Returns {@code true} when the entity should be routed to {@link #insert(Entity)} during an upsert.
+ *
+ * MERGE-based dialects cannot fetch database-generated keys from a MERGE statement, so entities with an
+ * auto-generated primary key that is still at its default value are routed to insert. Entities with an
+ * auto-generated primary key that carry a non-default value never reach the MERGE either: they are routed to
+ * update by {@link #isUpsertUpdate(Entity)}, which takes precedence. As a result, the MERGE path only handles
+ * entities whose primary key is not auto-generated.
+ *
+ * @param entity the entity to check.
+ * @return {@code true} if the upsert should be routed to insert.
+ */
+ @Override
+ protected boolean isUpsertInsert(@Nonnull E entity) {
+ return isAutoGeneratedPrimaryKey();
+ }
+
+ /**
+ * Returns the dialect type used to cast a bound parameter of the given column in the MERGE source query, or
+ * {@code null} to bind the parameter without a cast. The default implementation returns {@code null}; dialects
+ * whose parser cannot infer the type of a bare parameter in the source query's projection override this.
+ *
+ * @param column the column the parameter belongs to.
+ * @return the cast type, or {@code null} for no cast.
+ */
+ @Nullable
+ protected String castType(@Nonnull Column column) {
+ return null;
+ }
+
+ /**
+ * Returns the suffix appended to the MERGE source query, such as {@code " FROM DUAL"} on Oracle. The default
+ * implementation returns an empty string.
+ */
+ protected String mergeSourceSuffix() {
+ return "";
+ }
+
+ /**
+ * Returns the terminator appended to the MERGE statement, such as {@code ";"} on SQL Server. The default
+ * implementation returns an empty string.
+ */
+ protected String statementSuffix() {
+ return "";
+ }
+
+ /**
+ * Returns the expression used to touch a temporal version column on update, such as {@code SYSTIMESTAMP} on
+ * Oracle. The default implementation returns {@code CURRENT_TIMESTAMP}.
+ */
+ protected String versionTimestampExpression() {
+ return "CURRENT_TIMESTAMP";
+ }
+
+ /**
+ * Returns the expression used to increment a numeric version column on update. The default implementation
+ * increments the incoming source value.
+ *
+ * @param qualifiedName the qualified column name.
+ */
+ protected String versionIncrementExpression(@Nonnull String qualifiedName) {
+ return "src.%s + 1".formatted(qualifiedName);
+ }
+
+ /**
+ * Builds the WHEN NOT MATCHED clause of the MERGE statement. Dialects differ in how auto-generated and
+ * sequence-backed columns participate in the insert branch.
+ */
+ protected abstract TemplateString mergeInsert();
+
+ private String getVersionString(@Nonnull Column column) {
+ String columnName = column.qualifiedName(ormTemplate.dialect());
+ String updateExpression = switch (column.type()) {
+ case Class> c when Integer.TYPE.isAssignableFrom(c)
+ || Long.TYPE.isAssignableFrom(c)
+ || Integer.class.isAssignableFrom(c)
+ || Long.class.isAssignableFrom(c)
+ || BigInteger.class.isAssignableFrom(c) -> versionIncrementExpression(columnName);
+ case Class> c when Instant.class.isAssignableFrom(c)
+ || Date.class.isAssignableFrom(c)
+ || Calendar.class.isAssignableFrom(c)
+ || Timestamp.class.isAssignableFrom(c) -> versionTimestampExpression();
+ default ->
+ throw new PersistenceException("Unsupported version type: %s.".formatted(column.type().getSimpleName()));
+ };
+ return "t.%s = %s".formatted(columnName, updateExpression);
+ }
+
+ private TemplateString castColumn(@Nonnull TemplateString parameter, @Nonnull Column column) {
+ String alias = column.qualifiedName(ormTemplate.dialect());
+ String castType = castType(column);
+ if (castType == null) {
+ return combine(parameter, TemplateString.of(" AS %s".formatted(alias)));
+ }
+ return combine(TemplateString.of("CAST("), parameter, TemplateString.of(" AS %s) AS %s".formatted(castType, alias)));
+ }
+
+ /**
+ * Builds the MERGE source query from the entity's current values. Only declared columns are projected.
+ */
+ protected TemplateString mergeSelect(@Nonnull E entity) {
+ assert !isAutoGeneratedPrimaryKey();
+ var duplicates = new HashSet<>(); // Compound PKs may also have their columns included as stand-alone fields. Only include them once.
+ try {
+ var mapped = model.declaredValues(entity);
+ return mapped.entrySet()
+ .stream()
+ .filter(entry -> duplicates.add(entry.getKey().name()))
+ .map(entry -> {
+ Object value = entry.getValue();
+ if (entry.getKey().primaryKey()) {
+ //noinspection unchecked
+ if (model.isDefaultPrimaryKey((ID) value)) {
+ value = null; // Always pass NULL to force a mismatch.
+ }
+ }
+ return castColumn(wrap(value), entry.getKey());
+ })
+ .reduce((left, right) -> combine(left, TemplateString.of(", "), right))
+ .map(t -> combine(TemplateString.of("SELECT "), t, TemplateString.of(mergeSourceSuffix())))
+ .orElseThrow();
+ } catch (SqlTemplateException e) {
+ throw new PersistenceException("Failed to map entity to SQL parameters.", e);
+ }
+ }
+
+ /**
+ * Builds the MERGE source query from bind variables. Only declared columns are projected.
+ */
+ protected TemplateString mergeSelect(@Nonnull BindVars bindVars) {
+ var values = new AtomicReference>();
+ bindVars.setRecordListener(record -> {
+ try {
+ //noinspection unchecked
+ values.setPlain(model.declaredValues((E) record));
+ } catch (SqlTemplateException e) {
+ throw new PersistenceException("Failed to map entity to SQL parameters.", e);
+ }
+ });
+ var duplicates = new HashSet<>(); // Compound PKs may also have their columns included as stand-alone fields. Only include them once.
+ return model.declaredColumns().stream()
+ .filter(column -> duplicates.add(column.name()))
+ .map(c -> castColumn(wrap(bindVar(bindVars, ignore -> values.getPlain().get(c))), c))
+ .reduce((left, right) -> combine(left, TemplateString.of(", "), right))
+ .map(t -> combine(TemplateString.of("SELECT "), t, TemplateString.of(mergeSourceSuffix())))
+ .orElseThrow();
+ }
+
+ /**
+ * Builds the ON clause by equating primary key columns.
+ */
+ protected TemplateString mergeOn() {
+ var dialect = ormTemplate.dialect();
+ var primaryKeys = model.declaredColumns().stream()
+ .filter(Column::primaryKey)
+ .toList();
+ String sql = primaryKeys.stream()
+ .map(c -> "t.%s = src.%s".formatted(c.qualifiedName(dialect), c.qualifiedName(dialect)))
+ .collect(joining(" AND "));
+ return TemplateString.of(sql);
+ }
+
+ /**
+ * Builds the WHEN MATCHED clause of the MERGE statement.
+ */
+ protected TemplateString mergeUpdate(@Nonnull AtomicBoolean versionAware) {
+ var dialect = ormTemplate.dialect();
+ var duplicates = new HashSet<>(); // Compound PKs may also have their columns included as stand-alone fields. Only include them once.
+ var args = model.declaredColumns().stream()
+ .filter(not(Column::primaryKey))
+ .filter(Column::updatable)
+ .filter(column -> duplicates.add(column.name()))
+ .map(column -> {
+ if (column.version()) {
+ versionAware.setPlain(true);
+ return getVersionString(column);
+ }
+ return "t.%s = src.%s".formatted(column.qualifiedName(dialect), column.qualifiedName(dialect));
+ })
+ .toList();
+ if (args.isEmpty()) {
+ return TemplateString.EMPTY;
+ }
+ String sql = args.stream().collect(joining(", ", "UPDATE SET ", ""));
+ return TemplateString.of("\nWHEN MATCHED THEN\n\t%s".formatted(sql));
+ }
+
+ @Override
+ protected E validateUpsert(@Nonnull E entity) {
+ assert !isAutoGeneratedPrimaryKey();
+ if (model.isDefaultPrimaryKey(entity.id())) {
+ throw new PersistenceException("Primary key must be set for non-auto-generated primary keys for upserts.");
+ }
+ return entity;
+ }
+
+ private TemplateString mergeStatement(@Nonnull TemplateString mergeSelect, @Nonnull AtomicBoolean versionAware) {
+ return flatten(raw("""
+ MERGE INTO \0 t
+ USING (\0) src
+ ON (\0)\0\0""" + statementSuffix(),
+ table(model.type()), mergeSelect, mergeOn(), mergeUpdate(versionAware), mergeInsert()));
+ }
+
+ /**
+ * Performs the SQL-level upsert (MERGE) for a single entity, without lifecycle callbacks or routing.
+ */
+ @Override
+ protected void doUpsert(@Nonnull E entity) {
+ validateUpsert(entity);
+ entityCache().ifPresent(cache -> {
+ if (!model.isDefaultPrimaryKey(entity.id())) {
+ cache.remove(entity.id());
+ }
+ });
+ var versionAware = new AtomicBoolean();
+ intercept(sql -> sql.versionAware(versionAware.getPlain()), () -> {
+ var query = ormTemplate.query(mergeStatement(mergeSelect(entity), versionAware)).managed();
+ query.executeUpdate();
+ });
+ }
+
+ /**
+ * Performs the SQL-level upsert (MERGE) for a single entity and returns its ID, without lifecycle callbacks
+ * or routing.
+ */
+ @Override
+ protected ID doUpsertAndFetchId(@Nonnull E entity) {
+ validateUpsert(entity);
+ entityCache().ifPresent(cache -> {
+ if (!model.isDefaultPrimaryKey(entity.id())) {
+ cache.remove(entity.id());
+ }
+ });
+ var versionAware = new AtomicBoolean();
+ intercept(sql -> sql.versionAware(versionAware.getPlain()), () -> {
+ var query = ormTemplate.query(mergeStatement(mergeSelect(entity), versionAware)).managed();
+ query.executeUpdate();
+ });
+ return entity.id();
+ }
+
+ @Override
+ protected PreparedQuery prepareUpsertQuery() {
+ var bindVars = ormTemplate.createBindVars();
+ var versionAware = new AtomicBoolean();
+ return intercept(sql -> sql.versionAware(versionAware.getPlain()), () ->
+ ormTemplate.query(mergeStatement(mergeSelect(bindVars), versionAware)).managed().prepare());
+ }
+
+ @Override
+ protected void doUpsertBatch(@Nonnull List batch, @Nonnull PreparedQuery query,
+ @Nullable EntityCache cache) {
+ if (batch.isEmpty()) {
+ return;
+ }
+ batch.stream().map(this::validateUpsert).forEach(query::addBatch);
+ if (cache != null) {
+ batch.stream()
+ .filter(e -> !model.isDefaultPrimaryKey(e.id()))
+ .forEach(e -> cache.remove(e.id()));
+ }
+ int[] result = query.executeBatch();
+ if (IntStream.of(result).anyMatch(r -> r != 0 && r != 1 && r != 2)) {
+ throw new PersistenceException(upsertFailureMessage(batch.size()));
+ }
+ }
+
+ @Override
+ protected List doUpsertAndFetchIdsBatch(@Nonnull List batch, @Nonnull PreparedQuery query,
+ @Nullable EntityCache cache) {
+ if (batch.isEmpty()) {
+ return List.of();
+ }
+ batch.stream().map(this::validateUpsert).forEach(query::addBatch);
+ if (cache != null) {
+ batch.stream()
+ .filter(e -> !model.isDefaultPrimaryKey(e.id()))
+ .forEach(e -> cache.remove(e.id()));
+ }
+ int[] result = query.executeBatch();
+ if (IntStream.of(result).anyMatch(r -> r != 0 && r != 1 && r != 2)) {
+ throw new PersistenceException(upsertFailureMessage(batch.size()));
+ }
+ // Entities on the MERGE path always carry their primary key: auto-generated keys are routed to insert or
+ // update before reaching this point, and validateUpsert rejects default keys for the rest.
+ return batch.stream().map(Entity::id).toList();
+ }
+}
diff --git a/storm-core/src/main/java/st/orm/core/spi/JsonString.java b/storm-core/src/main/java/st/orm/core/spi/JsonString.java
new file mode 100644
index 000000000..79ab1761f
--- /dev/null
+++ b/storm-core/src/main/java/st/orm/core/spi/JsonString.java
@@ -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.
+ *
+ * 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.
+ *
+ * @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;
+ }
+}
diff --git a/storm-core/src/main/java/st/orm/core/template/SqlDialect.java b/storm-core/src/main/java/st/orm/core/template/SqlDialect.java
index ce125608d..ca21af023 100644
--- a/storm-core/src/main/java/st/orm/core/template/SqlDialect.java
+++ b/storm-core/src/main/java/st/orm/core/template/SqlDialect.java
@@ -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.
@@ -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.
+ *
+ * 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}.
+ *
+ * @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.
*
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/JpaTemplateImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/JpaTemplateImpl.java
index 1db9a4f83..840014e00 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/JpaTemplateImpl.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/JpaTemplateImpl.java
@@ -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;
@@ -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);
}
}
@@ -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);
}
}
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/PreparedStatementTemplateImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/PreparedStatementTemplateImpl.java
index 3d418c4d5..01f7a3fd6 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/PreparedStatementTemplateImpl.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/PreparedStatementTemplateImpl.java
@@ -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;
@@ -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()];
diff --git a/storm-core/src/test/java/st/orm/core/JpaIntegrationTest.java b/storm-core/src/test/java/st/orm/core/JpaIntegrationTest.java
index 934438a41..f8ab35428 100644
--- a/storm-core/src/test/java/st/orm/core/JpaIntegrationTest.java
+++ b/storm-core/src/test/java/st/orm/core/JpaIntegrationTest.java
@@ -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;
@@ -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()) {
diff --git a/storm-core/src/test/java/st/orm/core/template/impl/InlineParametersTest.java b/storm-core/src/test/java/st/orm/core/template/impl/InlineParametersTest.java
index 5ddf63909..8182f36dd 100644
--- a/storm-core/src/test/java/st/orm/core/template/impl/InlineParametersTest.java
+++ b/storm-core/src/test/java/st/orm/core/template/impl/InlineParametersTest.java
@@ -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;
@@ -27,6 +28,17 @@ record TestEntity(@PK Integer id, String name) implements Entity {}
*/
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
diff --git a/storm-h2/src/main/java/st/orm/spi/h2/H2EntityRepositoryImpl.java b/storm-h2/src/main/java/st/orm/spi/h2/H2EntityRepositoryImpl.java
index ec5953fc6..2fcfd812e 100644
--- a/storm-h2/src/main/java/st/orm/spi/h2/H2EntityRepositoryImpl.java
+++ b/storm-h2/src/main/java/st/orm/spi/h2/H2EntityRepositoryImpl.java
@@ -15,166 +15,154 @@
*/
package st.orm.spi.h2;
-import static java.util.function.Predicate.not;
import static java.util.stream.Collectors.joining;
import static st.orm.GenerationStrategy.IDENTITY;
import static st.orm.GenerationStrategy.SEQUENCE;
-import static st.orm.core.template.SqlInterceptor.intercept;
-import static st.orm.core.template.TemplateString.combine;
-import static st.orm.core.template.TemplateString.raw;
-import static st.orm.core.template.TemplateString.wrap;
-import static st.orm.core.template.Templates.bindVar;
-import static st.orm.core.template.Templates.table;
-import static st.orm.core.template.impl.StringTemplates.flatten;
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
+import java.lang.reflect.RecordComponent;
+import java.math.BigDecimal;
import java.math.BigInteger;
import java.sql.Timestamp;
import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.stream.IntStream;
-import st.orm.BindVars;
+import java.util.UUID;
+import st.orm.Data;
import st.orm.Entity;
+import st.orm.PK;
import st.orm.PersistenceException;
import st.orm.core.repository.EntityRepository;
-import st.orm.core.repository.impl.EntityRepositoryImpl;
-import st.orm.core.spi.EntityCache;
+import st.orm.core.repository.impl.MergeEntityRepositoryImpl;
import st.orm.core.template.Column;
import st.orm.core.template.Model;
import st.orm.core.template.ORMTemplate;
-import st.orm.core.template.PreparedQuery;
-import st.orm.core.template.SqlTemplateException;
import st.orm.core.template.TemplateString;
/**
* Implementation of {@link EntityRepository} for H2.
*/
-public class H2EntityRepositoryImpl, ID> extends EntityRepositoryImpl {
+public class H2EntityRepositoryImpl, ID> extends MergeEntityRepositoryImpl {
+
+ /**
+ * H2 types used to cast bound parameters in the MERGE source query, keyed by the Java type of the column.
+ * The temporal mappings follow how the ORM binds values: Instant, OffsetDateTime and ZonedDateTime are bound
+ * as {@link Timestamp}, so they cast to TIMESTAMP. BigDecimal casts to DECFLOAT rather than NUMERIC, as a
+ * bare NUMERIC in H2 has scale 0 and would truncate the fraction.
+ */
+ private static final Map, String> CAST_TYPES = Map.ofEntries(
+ Map.entry(String.class, "VARCHAR"),
+ Map.entry(char.class, "CHAR"),
+ Map.entry(Character.class, "CHAR"),
+ Map.entry(boolean.class, "BOOLEAN"),
+ Map.entry(Boolean.class, "BOOLEAN"),
+ Map.entry(byte.class, "TINYINT"),
+ Map.entry(Byte.class, "TINYINT"),
+ Map.entry(short.class, "SMALLINT"),
+ Map.entry(Short.class, "SMALLINT"),
+ Map.entry(int.class, "INTEGER"),
+ Map.entry(Integer.class, "INTEGER"),
+ Map.entry(long.class, "BIGINT"),
+ Map.entry(Long.class, "BIGINT"),
+ Map.entry(float.class, "REAL"),
+ Map.entry(Float.class, "REAL"),
+ Map.entry(double.class, "DOUBLE PRECISION"),
+ Map.entry(Double.class, "DOUBLE PRECISION"),
+ Map.entry(BigDecimal.class, "DECFLOAT"),
+ Map.entry(BigInteger.class, "DECFLOAT"),
+ Map.entry(byte[].class, "VARBINARY"),
+ Map.entry(UUID.class, "UUID"),
+ Map.entry(LocalDate.class, "DATE"),
+ Map.entry(LocalTime.class, "TIME"),
+ Map.entry(LocalDateTime.class, "TIMESTAMP"),
+ Map.entry(OffsetTime.class, "TIME WITH TIME ZONE"),
+ Map.entry(Instant.class, "TIMESTAMP"),
+ Map.entry(OffsetDateTime.class, "TIMESTAMP"),
+ Map.entry(ZonedDateTime.class, "TIMESTAMP"),
+ Map.entry(java.sql.Date.class, "DATE"),
+ Map.entry(java.sql.Time.class, "TIME"),
+ Map.entry(Timestamp.class, "TIMESTAMP"));
public H2EntityRepositoryImpl(@Nonnull ORMTemplate ormTemplate, @Nonnull Model model) {
super(ormTemplate, model);
}
/**
- * Returns {@code true} when the entity should be routed to {@link #insert(Entity)} during an upsert.
- *
- * H2 cannot perform a SQL-level MERGE with auto-generated primary keys, so when the primary key
- * is auto-generated, the upsert is routed to insert instead.
+ * Returns the H2 type used to cast a bound parameter of the given column, or {@code null} when no mapping is
+ * known — the parameter is left uncast in that case.
*
- * @param entity the entity to check.
- * @return {@code true} if the primary key is auto-generated.
+ * H2 cannot infer the type of a bare {@code ?} in the projection of the MERGE source query and fails with
+ * "Unknown data type". Casting each parameter gives the parser the missing type information.
*/
+ @Nullable
@Override
- protected boolean isUpsertInsert(@Nonnull E entity) {
- return isAutoGeneratedPrimaryKey();
- }
-
- private String getVersionString(@Nonnull Column column) {
- String columnName = column.qualifiedName(ormTemplate.dialect());
- String updateExpression = switch (column.type()) {
- case Class> c when Integer.TYPE.isAssignableFrom(c)
- || Long.TYPE.isAssignableFrom(c)
- || Integer.class.isAssignableFrom(c)
- || Long.class.isAssignableFrom(c)
- || BigInteger.class.isAssignableFrom(c) -> "src.%s + 1".formatted(columnName);
- case Class> c when Instant.class.isAssignableFrom(c)
- || Date.class.isAssignableFrom(c)
- || Calendar.class.isAssignableFrom(c)
- || Timestamp.class.isAssignableFrom(c) -> "CURRENT_TIMESTAMP";
- default ->
- throw new PersistenceException("Unsupported version type: %s.".formatted(column.type().getSimpleName()));
- };
- return "t.%s = %s".formatted(columnName, updateExpression);
- }
-
- private TemplateString mergeSelect(@Nonnull E entity) {
- assert !isAutoGeneratedPrimaryKey();
- var dialect = ormTemplate.dialect();
- var duplicates = new HashSet<>();
- try {
- var mapped = model.values(entity);
- return mapped.entrySet()
- .stream()
- .filter(entry -> duplicates.add(entry.getKey().name()))
- .map(entry -> {
- Object value = entry.getValue();
- if (entry.getKey().primaryKey()) {
- //noinspection unchecked
- if (model.isDefaultPrimaryKey((ID) value)) {
- value = null;
- }
- }
- return combine(wrap(value), TemplateString.of(" AS %s".formatted(entry.getKey().qualifiedName(dialect))));
- })
- .reduce((left, right) -> combine(left, TemplateString.of(", "), right))
- .map(t -> combine(TemplateString.of("SELECT "), t))
- .orElseThrow();
- } catch (SqlTemplateException e) {
- throw new PersistenceException("Failed to map entity to SQL parameters.", e);
- }
- }
-
- private TemplateString mergeSelect(@Nonnull BindVars bindVars) {
- var dialect = ormTemplate.dialect();
- var values = new AtomicReference>();
- bindVars.setRecordListener(record -> {
- try {
- //noinspection unchecked
- values.setPlain(model.values((E) record));
- } catch (SqlTemplateException e) {
- throw new PersistenceException("Failed to map entity to SQL parameters.", e);
+ protected String castType(@Nonnull Column column) {
+ Class> type = column.type();
+ if (column.foreignKey()) {
+ var secondary = column.secondaryMetamodel();
+ if (secondary == null) {
+ return null;
}
- });
- var duplicates = new HashSet<>();
- return model.declaredColumns().stream()
- .filter(column -> duplicates.add(column.name()))
- .map(c -> combine(wrap(bindVar(bindVars, ignore -> values.getPlain().get(c))), TemplateString.of(" AS %s".formatted(c.qualifiedName(dialect)))))
- .reduce((left, right) -> combine(left, TemplateString.of(", "), right))
- .map(t -> combine(TemplateString.of("SELECT "), t))
- .orElseThrow();
- }
-
- private TemplateString mergeOn() {
- var dialect = ormTemplate.dialect();
- var primaryKeys = model.declaredColumns().stream()
- .filter(Column::primaryKey)
- .toList();
- String sql = primaryKeys.stream()
- .map(c -> "t.%s = src.%s".formatted(c.qualifiedName(dialect), c.qualifiedName(dialect)))
- .collect(joining(" AND "));
- return TemplateString.of(sql);
+ // Foreign key columns store the referenced entity's primary key. For compound keys the foreign key
+ // spans multiple columns; keyIndex identifies this column's position within the flattened key.
+ var leaves = new ArrayList>();
+ flattenKeyTypes(secondary.fieldType(), leaves);
+ int index = column.keyIndex();
+ if (index < 1 || index > leaves.size()) {
+ return null;
+ }
+ type = leaves.get(index - 1);
+ }
+ String mapped = CAST_TYPES.get(type);
+ if (mapped != null) {
+ return mapped;
+ }
+ if (type.isEnum()) {
+ return "VARCHAR"; // Enums are bound by name.
+ }
+ if (Date.class.isAssignableFrom(type) || Calendar.class.isAssignableFrom(type)) {
+ return "TIMESTAMP";
+ }
+ return null;
}
- private TemplateString mergeUpdate(@Nonnull AtomicBoolean versionAware) {
- var dialect = ormTemplate.dialect();
- var duplicates = new HashSet<>();
- var args = model.declaredColumns().stream()
- .filter(not(Column::primaryKey))
- .filter(Column::updatable)
- .filter(column -> duplicates.add(column.name()))
- .map(column -> {
- if (column.version()) {
- versionAware.setPlain(true);
- return getVersionString(column);
+ /**
+ * Flattens a key type into the Java types of its database columns, in declaration order: records recurse into
+ * their components, entity references resolve to their primary key, and simple types are the leaves.
+ */
+ private static void flattenKeyTypes(@Nonnull Class> type, @Nonnull List> leaves) {
+ if (type.isRecord()) {
+ if (Data.class.isAssignableFrom(type)) {
+ for (RecordComponent component : type.getRecordComponents()) {
+ if (component.isAnnotationPresent(PK.class)) {
+ flattenKeyTypes(component.getType(), leaves);
+ return;
}
- return "t.%s = src.%s".formatted(column.qualifiedName(dialect), column.qualifiedName(dialect));
- })
- .toList();
- if (args.isEmpty()) {
- return TemplateString.of("");
+ }
+ leaves.add(type); // No primary key found; unmappable.
+ return;
+ }
+ for (RecordComponent component : type.getRecordComponents()) {
+ flattenKeyTypes(component.getType(), leaves);
+ }
+ return;
}
- String sql = args.stream().collect(joining(", ", "UPDATE SET ", ""));
- return TemplateString.of("\nWHEN MATCHED THEN\n\t%s".formatted(sql));
+ leaves.add(type);
}
- private TemplateString mergeInsert() {
+ @Override
+ protected TemplateString mergeInsert() {
var dialect = ormTemplate.dialect();
var insertDuplicates = new HashSet<>();
var insertArgs = model.declaredColumns().stream()
@@ -203,53 +191,6 @@ private TemplateString mergeInsert() {
return TemplateString.of("\nWHEN NOT MATCHED THEN%s".formatted(sql));
}
- @Override
- protected E validateUpsert(@Nonnull E entity) {
- assert !isAutoGeneratedPrimaryKey();
- if (model.isDefaultPrimaryKey(entity.id())) {
- throw new PersistenceException("Primary key must be set for non-auto-generated primary keys for upserts.");
- }
- return entity;
- }
-
- @Override
- protected void doUpsert(@Nonnull E entity) {
- validateUpsert(entity);
- entityCache().ifPresent(cache -> {
- if (!model.isDefaultPrimaryKey(entity.id())) {
- cache.remove(entity.id());
- }
- });
- var versionAware = new AtomicBoolean();
- intercept(sql -> sql.versionAware(versionAware.getPlain()), () -> {
- var query = ormTemplate.query(flatten(raw("""
- MERGE INTO \0 t
- USING (\0) src
- ON (\0)\0\0""", table(model.type()), mergeSelect(entity), mergeOn(), mergeUpdate(versionAware), mergeInsert()))).managed();
- query.executeUpdate();
- });
- }
-
- @Override
- protected ID doUpsertAndFetchId(@Nonnull E entity) {
- validateUpsert(entity);
- entityCache().ifPresent(cache -> {
- if (!model.isDefaultPrimaryKey(entity.id())) {
- cache.remove(entity.id());
- }
- });
- var versionAware = new AtomicBoolean();
- intercept(sql -> sql.versionAware(versionAware.getPlain()), () -> {
- var query = ormTemplate.query(flatten(raw("""
- MERGE INTO \0 t
- USING (\0) src
- ON (\0)\0\0""", table(model.type()), mergeSelect(entity), mergeOn(), mergeUpdate(versionAware), mergeInsert())))
- .managed();
- query.executeUpdate();
- });
- return entity.id();
- }
-
@Override
public List upsertAndFetchIds(@Nonnull Iterable entities) {
if (isAutoGeneratedPrimaryKey() && generationStrategy == SEQUENCE) {
@@ -258,60 +199,6 @@ public List upsertAndFetchIds(@Nonnull Iterable entities) {
return super.upsertAndFetchIds(entities);
}
- @Override
- protected PreparedQuery prepareUpsertQuery() {
- var bindVars = ormTemplate.createBindVars();
- var versionAware = new AtomicBoolean();
- return intercept(sql -> sql.versionAware(versionAware.getPlain()), () ->
- ormTemplate.query(flatten(raw("""
- MERGE INTO \0 t
- USING (\0) src
- ON (\0)\0\0""", table(model.type()), mergeSelect(bindVars), mergeOn(), mergeUpdate(versionAware), mergeInsert())))
- .managed().prepare());
- }
-
- @Override
- protected void doUpsertBatch(@Nonnull List batch, @Nonnull PreparedQuery query,
- @Nullable EntityCache cache) {
- if (batch.isEmpty()) {
- return;
- }
- batch.stream().map(this::validateUpsert).forEach(query::addBatch);
- if (cache != null) {
- batch.stream()
- .filter(e -> !model.isDefaultPrimaryKey(e.id()))
- .forEach(e -> cache.remove(e.id()));
- }
- int[] result = query.executeBatch();
- if (IntStream.of(result).anyMatch(r -> r != 0 && r != 1 && r != 2)) {
- throw new PersistenceException(upsertFailureMessage(batch.size()));
- }
- }
-
- @Override
- protected List doUpsertAndFetchIdsBatch(@Nonnull List batch, @Nonnull PreparedQuery query,
- @Nullable EntityCache cache) {
- if (batch.isEmpty()) {
- return List.of();
- }
- batch.stream().map(this::validateUpsert).forEach(query::addBatch);
- if (cache != null) {
- batch.stream()
- .filter(e -> !model.isDefaultPrimaryKey(e.id()))
- .forEach(e -> cache.remove(e.id()));
- }
- int[] result = query.executeBatch();
- if (IntStream.of(result).anyMatch(r -> r != 0 && r != 1 && r != 2)) {
- throw new PersistenceException(upsertFailureMessage(batch.size()));
- }
- if (isAutoGeneratedPrimaryKey()) {
- try (var generatedKeys = query.getGeneratedKeys(model.primaryKeyType())) {
- return generatedKeys.toList();
- }
- }
- return batch.stream().map(Entity::id).toList();
- }
-
@Override
public ID insertAndFetchId(@Nonnull E entity) {
if (generationStrategy == SEQUENCE) {
diff --git a/storm-h2/src/test/java/st/orm/spi/h2/H2EntityRepositoryTest.java b/storm-h2/src/test/java/st/orm/spi/h2/H2EntityRepositoryTest.java
index dcc989090..edf3dc9e6 100644
--- a/storm-h2/src/test/java/st/orm/spi/h2/H2EntityRepositoryTest.java
+++ b/storm-h2/src/test/java/st/orm/spi/h2/H2EntityRepositoryTest.java
@@ -26,7 +26,6 @@
import javax.sql.DataSource;
import lombok.Builder;
import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
@@ -362,7 +361,6 @@ public record Specialty(
) implements Entity {}
@Test
- @Disabled("H2 v2 MERGE fails with 'Unknown data type' when binding untyped parameters in SELECT ... AS")
public void testUpsertNonAutoGenerated() {
// H2 uses MERGE syntax for non-auto-generated PKs. Verify functional behavior.
var repo = PreparedStatementTemplate.ORM(dataSource).entity(Specialty.class);
@@ -375,7 +373,6 @@ public void testUpsertNonAutoGenerated() {
}
@Test
- @Disabled("H2 v2 MERGE fails with 'Unknown data type' when binding untyped parameters in SELECT ... AS")
public void testUpsertAndFetchNonAutoGenerated() {
var repo = PreparedStatementTemplate.ORM(dataSource).entity(Specialty.class);
var entity = repo.upsertAndFetch(Specialty.builder().id(4).name("anaesthetics").build());
@@ -385,7 +382,6 @@ public void testUpsertAndFetchNonAutoGenerated() {
}
@Test
- @Disabled("H2 v2 MERGE fails with 'Unknown data type' when binding untyped parameters in SELECT ... AS")
public void testUpsertNonAutoGeneratedBatch() {
var repo = PreparedStatementTemplate.ORM(dataSource).entity(Specialty.class);
repo.upsert(List.of(
@@ -399,7 +395,6 @@ public void testUpsertNonAutoGeneratedBatch() {
}
@Test
- @Disabled("H2 v2 MERGE fails with 'Unknown data type' when binding untyped parameters in SELECT ... AS")
public void testUpsertAndFetchNonAutoGeneratedBatch() {
var repo = PreparedStatementTemplate.ORM(dataSource).entity(Specialty.class);
var entities = repo.upsertAndFetch(List.of(
@@ -410,6 +405,57 @@ public void testUpsertAndFetchNonAutoGeneratedBatch() {
assertTrue(updated.stream().allMatch(entity -> entity.name().endsWith("s")));
}
+ @Builder(toBuilder = true)
+ @DbTable("specialty_note")
+ public record SpecialtyNote(
+ @Nonnull @PK(generation = NONE) @FK Specialty specialty, // Dependent one-to-one: the PK is the FK.
+ @Nonnull String note,
+ @Nonnull Instant updatedAt
+ ) implements Entity {}
+
+ @Test
+ public void testUpsertDependentOneToOne() {
+ // The PK is the FK to specialty and the entity carries a temporal column: the MERGE source query must
+ // cast its parameters for H2 to accept them, resolving the FK column type via the referenced PK.
+ var specialty = PreparedStatementTemplate.ORM(dataSource).entity(Specialty.class).getById(1);
+ var repo = PreparedStatementTemplate.ORM(dataSource).entity(SpecialtyNote.class);
+ repo.upsert(SpecialtyNote.builder()
+ .specialty(specialty)
+ .note("first")
+ .updatedAt(Instant.parse("2026-01-01T10:00:00Z"))
+ .build());
+ var stored = repo.getById(specialty);
+ assertEquals("first", stored.note());
+ repo.upsert(stored.toBuilder()
+ .note("second")
+ .updatedAt(Instant.parse("2026-01-02T10:00:00Z"))
+ .build());
+ var updated = repo.getById(specialty);
+ assertEquals("second", updated.note());
+ assertEquals(Instant.parse("2026-01-02T10:00:00Z"), updated.updatedAt());
+ }
+
+ @Test
+ public void testUpsertDependentOneToOneBatch() {
+ var specialtyRepo = PreparedStatementTemplate.ORM(dataSource).entity(Specialty.class);
+ var repo = PreparedStatementTemplate.ORM(dataSource).entity(SpecialtyNote.class);
+ var notes = List.of(
+ SpecialtyNote.builder()
+ .specialty(specialtyRepo.getById(2))
+ .note("surgery note")
+ .updatedAt(Instant.parse("2026-01-01T10:00:00Z"))
+ .build(),
+ SpecialtyNote.builder()
+ .specialty(specialtyRepo.getById(3))
+ .note("dentistry note")
+ .updatedAt(Instant.parse("2026-01-01T10:00:00Z"))
+ .build());
+ repo.upsert(notes);
+ repo.upsert(notes.stream().map(n -> n.toBuilder().note("%s updated".formatted(n.note())).build()).toList());
+ assertEquals("surgery note updated", repo.getById(specialtyRepo.getById(2)).note());
+ assertEquals("dentistry note updated", repo.getById(specialtyRepo.getById(3)).note());
+ }
+
@Builder(toBuilder = true)
public record VetSpecialtyPK(
int vetId,
@@ -476,6 +522,25 @@ INSERT INTO vet_specialty (vet_id, specialty_id)
});
}
+ @Builder(toBuilder = true)
+ @DbTable("vet_specialty_note")
+ public record VetSpecialtyNote(
+ @Nonnull @PK(generation = NONE) @FK VetSpecialty vetSpecialty, // The PK is a compound FK spanning two columns.
+ @Nonnull String note
+ ) implements Entity {}
+
+ @Test
+ public void testUpsertCompoundForeignKeyAsPrimaryKey() {
+ // The MERGE source query must resolve each foreign key column to its leaf type within the referenced
+ // compound key for the H2 casts to apply.
+ var repo = PreparedStatementTemplate.ORM(dataSource).entity(VetSpecialtyNote.class);
+ var vetSpecialty = new VetSpecialty(new VetSpecialtyPK(2, 1));
+ repo.upsert(VetSpecialtyNote.builder().vetSpecialty(vetSpecialty).note("first").build());
+ assertEquals("first", repo.getById(vetSpecialty).note());
+ repo.upsert(VetSpecialtyNote.builder().vetSpecialty(vetSpecialty).note("second").build());
+ assertEquals("second", repo.getById(vetSpecialty).note());
+ }
+
@Builder(toBuilder = true)
@DbTable("pet")
public record Pet(
@@ -1380,7 +1445,6 @@ public void testUpsertBatchWithVersionInstant() {
}
@Test
- @Disabled("H2 v2 MERGE fails with 'Unknown data type' when binding untyped parameters in SELECT ... AS")
public void testUpsertPkOnlyEntity() {
// H2 uses MERGE syntax for PK-only entities with non-auto-generated PKs.
var repo = PreparedStatementTemplate.ORM(dataSource).entity(PkOnlyEntity.class);
@@ -1390,7 +1454,6 @@ public void testUpsertPkOnlyEntity() {
}
@Test
- @Disabled("H2 v2 MERGE fails with 'Unknown data type' when binding untyped parameters in SELECT ... AS")
public void testUpsertBatchPkOnlyEntity() {
var repo = PreparedStatementTemplate.ORM(dataSource).entity(PkOnlyEntity.class);
repo.upsert(List.of(
diff --git a/storm-h2/src/test/resources/data.sql b/storm-h2/src/test/resources/data.sql
index 7e909fdb3..feac60150 100644
--- a/storm-h2/src/test/resources/data.sql
+++ b/storm-h2/src/test/resources/data.sql
@@ -2,8 +2,10 @@ DROP TABLE IF EXISTS owner CASCADE;
DROP TABLE IF EXISTS pet CASCADE;
DROP TABLE IF EXISTS pet_type CASCADE;
DROP TABLE IF EXISTS specialty CASCADE;
+DROP TABLE IF EXISTS specialty_note CASCADE;
DROP TABLE IF EXISTS vet CASCADE;
DROP TABLE IF EXISTS vet_specialty CASCADE;
+DROP TABLE IF EXISTS vet_specialty_note CASCADE;
DROP TABLE IF EXISTS visit CASCADE;
DROP VIEW IF EXISTS owner_view;
DROP VIEW IF EXISTS visit_view;
@@ -44,6 +46,13 @@ CREATE TABLE specialty (
UNIQUE(name)
);
+-- Dependent one-to-one on specialty: the primary key is the foreign key.
+CREATE TABLE specialty_note (
+ specialty_id integer PRIMARY KEY REFERENCES specialty (id),
+ note varchar(255) NOT NULL,
+ updated_at timestamp NOT NULL
+);
+
CREATE TABLE vet (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
first_name varchar(255),
@@ -56,6 +65,14 @@ CREATE TABLE vet_specialty (
PRIMARY KEY (vet_id, specialty_id)
);
+-- Dependent one-to-one on vet_specialty: the primary key is a compound foreign key.
+CREATE TABLE vet_specialty_note (
+ vet_id integer NOT NULL,
+ specialty_id integer NOT NULL,
+ note varchar(255) NOT NULL,
+ PRIMARY KEY (vet_id, specialty_id)
+);
+
CREATE TABLE visit (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
visit_date date,
diff --git a/storm-jackson2/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java b/storm-jackson2/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java
index 7e2ef8090..15e763321 100644
--- a/storm-jackson2/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java
+++ b/storm-jackson2/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java
@@ -38,6 +38,7 @@
import java.util.List;
import java.util.Optional;
import st.orm.Json;
+import st.orm.core.spi.JsonString;
import st.orm.core.spi.Name;
import st.orm.core.spi.ORMConverter;
import st.orm.core.spi.ORMReflection;
@@ -168,7 +169,7 @@ public List getColumns(@Nonnull NameResolver nameResolver) throws SqlTempl
public List toDatabase(@Nullable Object record) throws SqlTemplateException {
try {
Object o = record == null ? null : REFLECTION.invoke(field, record);
- return singletonList(o == null ? null : mapper.writeValueAsString(o));
+ return singletonList(o == null ? null : new JsonString(mapper.writeValueAsString(o)));
} catch (Throwable e) {
throw new SqlTemplateException(e);
}
diff --git a/storm-jackson3/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java b/storm-jackson3/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java
index 771f12a1e..50c9823c9 100644
--- a/storm-jackson3/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java
+++ b/storm-jackson3/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java
@@ -29,6 +29,7 @@
import java.util.List;
import java.util.Optional;
import st.orm.Json;
+import st.orm.core.spi.JsonString;
import st.orm.core.spi.Name;
import st.orm.core.spi.ORMConverter;
import st.orm.core.spi.ORMReflection;
@@ -168,7 +169,7 @@ public List getColumns(@Nonnull NameResolver nameResolver) throws SqlTempl
public List toDatabase(@Nullable Object record) throws SqlTemplateException {
try {
Object o = record == null ? null : REFLECTION.invoke(field, record);
- return singletonList(o == null ? null : mapper.writeValueAsString(o));
+ return singletonList(o == null ? null : new JsonString(mapper.writeValueAsString(o)));
} catch (Throwable e) {
throw new SqlTemplateException(e);
}
diff --git a/storm-kotlinx-serialization/src/main/kotlin/st/orm/serialization/spi/JsonORMConverterImpl.kt b/storm-kotlinx-serialization/src/main/kotlin/st/orm/serialization/spi/JsonORMConverterImpl.kt
index ceb49ac1e..676e4cdff 100644
--- a/storm-kotlinx-serialization/src/main/kotlin/st/orm/serialization/spi/JsonORMConverterImpl.kt
+++ b/storm-kotlinx-serialization/src/main/kotlin/st/orm/serialization/spi/JsonORMConverterImpl.kt
@@ -178,7 +178,7 @@ class JsonORMConverterImpl(
override fun toDatabase(record: Any?): List = try {
val v = if (record == null) null else REFLECTION.invoke(field, record)
- listOf(v?.let { this@JsonORMConverterImpl.json.encodeToString(serializer, it) })
+ listOf(v?.let { JsonString(this@JsonORMConverterImpl.json.encodeToString(serializer, it)) })
} catch (t: Throwable) {
throw SqlTemplateException(t)
}
diff --git a/storm-mssqlserver/src/main/java/st/orm/spi/mssqlserver/MSSQLServerEntityRepositoryImpl.java b/storm-mssqlserver/src/main/java/st/orm/spi/mssqlserver/MSSQLServerEntityRepositoryImpl.java
index c249c6b99..b59534783 100644
--- a/storm-mssqlserver/src/main/java/st/orm/spi/mssqlserver/MSSQLServerEntityRepositoryImpl.java
+++ b/storm-mssqlserver/src/main/java/st/orm/spi/mssqlserver/MSSQLServerEntityRepositoryImpl.java
@@ -15,8 +15,6 @@
*/
package st.orm.spi.mssqlserver;
-import static java.util.function.Predicate.not;
-import static java.util.stream.Collectors.joining;
import static st.orm.GenerationStrategy.IDENTITY;
import static st.orm.GenerationStrategy.NONE;
import static st.orm.GenerationStrategy.SEQUENCE;
@@ -25,17 +23,10 @@
import static st.orm.core.template.TemplateString.combine;
import static st.orm.core.template.TemplateString.raw;
import static st.orm.core.template.TemplateString.wrap;
-import static st.orm.core.template.Templates.bindVar;
import static st.orm.core.template.impl.StringTemplates.flatten;
import jakarta.annotation.Nonnull;
-import jakarta.annotation.Nullable;
-import java.math.BigInteger;
-import java.sql.Timestamp;
-import java.time.Instant;
import java.util.ArrayList;
-import java.util.Calendar;
-import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@@ -43,15 +34,11 @@
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.stream.IntStream;
-import st.orm.BindVars;
import st.orm.Entity;
import st.orm.Metamodel;
import st.orm.PersistenceException;
import st.orm.core.repository.EntityRepository;
-import st.orm.core.repository.impl.EntityRepositoryImpl;
-import st.orm.core.spi.EntityCache;
+import st.orm.core.repository.impl.MergeEntityRepositoryImpl;
import st.orm.core.template.Column;
import st.orm.core.template.Model;
import st.orm.core.template.ORMTemplate;
@@ -65,101 +52,26 @@
* Implementation of {@link EntityRepository} for SQL Server.
*/
public class MSSQLServerEntityRepositoryImpl, ID>
- extends EntityRepositoryImpl {
+ extends MergeEntityRepositoryImpl {
public MSSQLServerEntityRepositoryImpl(@Nonnull ORMTemplate ormTemplate, @Nonnull Model model) {
super(ormTemplate, model);
}
/**
- * Returns {@code true} when the entity should be routed to {@link #insert(Entity)} during upsert.
- *
- * SQL Server's MERGE statement cannot handle auto-generated primary keys, so entities with
- * auto-generated keys that are not updates must be routed to an insert instead.
- *
- * @param entity the entity to check.
- * @return {@code true} if the upsert should be routed to insert.
- * @since 1.9
+ * SQL Server requires MERGE statements to be terminated with a semicolon.
*/
@Override
- protected boolean isUpsertInsert(@Nonnull E entity) {
- return isAutoGeneratedPrimaryKey();
- }
-
- /**
- * Constructs a version update string for a version column. For numeric types the column is incremented,
- * for date/timestamp types CURRENT_TIMESTAMP is used.
- */
- private String getVersionString(@Nonnull Column column) {
- String columnName = column.qualifiedName(ormTemplate.dialect());
- String updateExpression = switch (column.type()) {
- case Class> c when Integer.TYPE.isAssignableFrom(c)
- || Long.TYPE.isAssignableFrom(c)
- || Integer.class.isAssignableFrom(c)
- || Long.class.isAssignableFrom(c)
- || BigInteger.class.isAssignableFrom(c) -> "t.%s + 1".formatted(columnName);
- case Class> c when Instant.class.isAssignableFrom(c)
- || Date.class.isAssignableFrom(c)
- || Calendar.class.isAssignableFrom(c)
- || Timestamp.class.isAssignableFrom(c) -> "CURRENT_TIMESTAMP";
- default ->
- throw new PersistenceException("Unsupported version type: %s.".formatted(column.type().getSimpleName()));
- };
- return "t.%s = %s".formatted(columnName, updateExpression);
- }
-
- /**
- * Builds a SELECT clause for the merge source based on the entity's current values.
- * (Note: Unlike Oracle, SQL Server does not require a FROM DUAL clause.)
- */
- private TemplateString mergeSelect(@Nonnull E entity) {
- assert !isAutoGeneratedPrimaryKey();
- var dialect = ormTemplate.dialect();
- var duplicates = new HashSet<>(); // Ensure each column appears only once.
- try {
- var mapped = model.declaredValues(entity);
- return mapped.entrySet()
- .stream()
- .filter(entry -> duplicates.add(entry.getKey().name()))
- .map(entry -> {
- Object value = entry.getValue();
- if (entry.getKey().primaryKey()) {
- //noinspection unchecked
- if (model.isDefaultPrimaryKey((ID) value)) {
- value = null; // Always pass NULL to force a mismatch.
- }
- }
- return combine(wrap(value), TemplateString.of(" AS %s".formatted(entry.getKey().qualifiedName(dialect))));
- })
- .reduce((left, right) -> combine(left, TemplateString.of(", "), right))
- .map(t -> combine(TemplateString.of("SELECT "), t))
- .orElseThrow();
- } catch (SqlTemplateException e) {
- throw new PersistenceException("Failed to map entity to SQL parameters.", e);
- }
+ protected String statementSuffix() {
+ return ";";
}
/**
- * Builds a SELECT clause for the merge source based on bind variables.
+ * SQL Server increments the stored version rather than the incoming source value.
*/
- private TemplateString mergeSelect(@Nonnull BindVars bindVars) {
- var values = new AtomicReference>();
- bindVars.setRecordListener(record -> {
- try {
- //noinspection unchecked
- values.setPlain(model.declaredValues((E) record));
- } catch (SqlTemplateException e) {
- throw new PersistenceException("Failed to map entity to SQL parameters.", e);
- }
- });
- var duplicates = new HashSet<>();
- return model.declaredColumns().stream()
- .filter(column -> duplicates.add(column.name()))
- .map(c -> combine(wrap(bindVar(bindVars, ignore -> values.getPlain().get(c))),
- TemplateString.of(" AS %s".formatted(c.name()))))
- .reduce((left, right) -> combine(left, TemplateString.of(", "), right))
- .map(t -> combine(TemplateString.of("SELECT "), t))
- .orElseThrow();
+ @Override
+ protected String versionIncrementExpression(@Nonnull String qualifiedName) {
+ return "t.%s + 1".formatted(qualifiedName);
}
/**
@@ -211,49 +123,11 @@ private TemplateString mergeSource() {
.orElseThrow();
}
- /**
- * Constructs the ON clause by equating primary key columns.
- */
- private TemplateString mergeOn() {
- var dialect = ormTemplate.dialect();
- var primaryKeys = model.declaredColumns().stream()
- .filter(Column::primaryKey)
- .toList();
- String sql = primaryKeys.stream()
- .map(c -> "t.%s = src.%s".formatted(c.qualifiedName(dialect), c.qualifiedName(dialect)))
- .collect(joining(" AND "));
- return TemplateString.of(sql);
- }
-
- /**
- * Constructs the UPDATE clause for the MERGE statement.
- */
- private TemplateString mergeUpdate(@Nonnull AtomicBoolean versionAware) {
- var dialect = ormTemplate.dialect();
- var duplicates = new HashSet<>();
- var args = model.declaredColumns().stream()
- .filter(not(Column::primaryKey))
- .filter(Column::updatable)
- .filter(column -> duplicates.add(column.name()))
- .map(column -> {
- if (column.version()) {
- versionAware.setPlain(true);
- return getVersionString(column);
- }
- return "t.%s = src.%s".formatted(column.qualifiedName(dialect), column.qualifiedName(dialect));
- })
- .toList();
- if (args.isEmpty()) {
- return TemplateString.EMPTY;
- }
- String sql = args.stream().collect(joining(", ", "UPDATE SET ", ""));
- return TemplateString.of("\nWHEN MATCHED THEN\n\t%s".formatted(sql));
- }
-
/**
* Constructs the INSERT clause for the MERGE statement.
*/
- private TemplateString mergeInsert() {
+ @Override
+ protected TemplateString mergeInsert() {
var dialect = ormTemplate.dialect();
var insertDuplicates = new HashSet<>();
var insertArgs = model.declaredColumns().stream()
@@ -285,64 +159,6 @@ private TemplateString mergeInsert() {
return TemplateString.of("\nWHEN NOT MATCHED THEN%s".formatted(sql));
}
- /**
- * Validates the entity for an upsert operation.
- */
- @Override
- protected E validateUpsert(@Nonnull E entity) {
- assert !isAutoGeneratedPrimaryKey();
- if (model.isDefaultPrimaryKey(entity.id())) {
- throw new PersistenceException("Primary key must be set for non-auto-generated primary keys for upserts.");
- }
- return entity;
- }
-
- /**
- * Performs the SQL-level MERGE upsert for a single entity, without lifecycle callbacks or routing.
- */
- @Override
- protected void doUpsert(@Nonnull E entity) {
- validateUpsert(entity);
- entityCache().ifPresent(cache -> {
- if (!model.isDefaultPrimaryKey(entity.id())) {
- cache.remove(entity.id());
- }
- });
- var versionAware = new AtomicBoolean();
- intercept(sql -> sql.versionAware(versionAware.getPlain()), () -> {
- // Note: SQL Server's MERGE syntax does not require a FROM DUAL clause.
- var query = ormTemplate.query(flatten(raw("""
- MERGE INTO \0 t
- USING (\0) src
- ON (\0)\0\0;""", model.type(), mergeSelect(entity), mergeOn(), mergeUpdate(versionAware), mergeInsert()))).managed();
- query.executeUpdate();
- });
- }
-
- /**
- * Performs the SQL-level MERGE upsert for a single entity and returns its ID, without lifecycle callbacks
- * or routing.
- */
- @Override
- protected ID doUpsertAndFetchId(@Nonnull E entity) {
- validateUpsert(entity);
- entityCache().ifPresent(cache -> {
- if (!model.isDefaultPrimaryKey(entity.id())) {
- cache.remove(entity.id());
- }
- });
- var versionAware = new AtomicBoolean();
- intercept(sql -> sql.versionAware(versionAware.getPlain()), () -> {
- var query = ormTemplate.query(flatten(raw("""
- MERGE INTO \0 t
- USING (\0) src
- ON (\0)\0\0;""", model.type(), mergeSelect(entity), mergeOn(), mergeUpdate(versionAware), mergeInsert())))
- .managed();
- query.executeUpdate();
- });
- return entity.id();
- }
-
// Partition keys for the SEQUENCE-specific upsertAndFetchIds.
private sealed interface SeqPartitionKey {}
private static final class SeqNoOpKey implements SeqPartitionKey {
@@ -452,60 +268,6 @@ private Query getUpsertQuery(@Nonnull Iterable entities) {
.managed());
}
- @Override
- protected PreparedQuery prepareUpsertQuery() {
- var bindVars = ormTemplate.createBindVars();
- var versionAware = new AtomicBoolean();
- return intercept(sql -> sql.versionAware(versionAware.getPlain()), () ->
- ormTemplate.query(flatten(raw("""
- MERGE INTO \0 t
- USING (\0) src
- ON (\0)\0\0;""", model.type(), mergeSelect(bindVars), mergeOn(), mergeUpdate(versionAware), mergeInsert())))
- .managed().prepare());
- }
-
- @Override
- protected void doUpsertBatch(@Nonnull List batch, @Nonnull PreparedQuery query,
- @Nullable EntityCache cache) {
- if (batch.isEmpty()) {
- return;
- }
- batch.stream().map(this::validateUpsert).forEach(query::addBatch);
- if (cache != null) {
- batch.stream()
- .filter(e -> !model.isDefaultPrimaryKey(e.id()))
- .forEach(e -> cache.remove(e.id()));
- }
- int[] result = query.executeBatch();
- if (IntStream.of(result).anyMatch(r -> r != 0 && r != 1 && r != 2)) {
- throw new PersistenceException(upsertFailureMessage(batch.size()));
- }
- }
-
- @Override
- protected List doUpsertAndFetchIdsBatch(@Nonnull List batch, @Nonnull PreparedQuery query,
- @Nullable EntityCache cache) {
- if (batch.isEmpty()) {
- return List.of();
- }
- batch.stream().map(this::validateUpsert).forEach(query::addBatch);
- if (cache != null) {
- batch.stream()
- .filter(e -> !model.isDefaultPrimaryKey(e.id()))
- .forEach(e -> cache.remove(e.id()));
- }
- int[] result = query.executeBatch();
- if (IntStream.of(result).anyMatch(r -> r != 0 && r != 1 && r != 2)) {
- throw new PersistenceException(upsertFailureMessage(batch.size()));
- }
- if (isAutoGeneratedPrimaryKey()) {
- try (var generatedKeys = query.getGeneratedKeys(model.primaryKeyType())) {
- return generatedKeys.toList();
- }
- }
- return batch.stream().map(Entity::id).toList();
- }
-
/**
* Overrides joined entity batch insert to use SQL Server's {@code OUTPUT INSERTED} clause instead of
* {@code executeBatch()} followed by {@code getGeneratedKeys()}, which SQL Server does not support.
diff --git a/storm-oracle/src/main/java/st/orm/spi/oracle/OracleEntityRepositoryImpl.java b/storm-oracle/src/main/java/st/orm/spi/oracle/OracleEntityRepositoryImpl.java
index 22645ab95..80715478a 100644
--- a/storm-oracle/src/main/java/st/orm/spi/oracle/OracleEntityRepositoryImpl.java
+++ b/storm-oracle/src/main/java/st/orm/spi/oracle/OracleEntityRepositoryImpl.java
@@ -15,174 +15,50 @@
*/
package st.orm.spi.oracle;
-import static java.util.function.Predicate.not;
import static java.util.stream.Collectors.joining;
import static st.orm.GenerationStrategy.IDENTITY;
import static st.orm.GenerationStrategy.SEQUENCE;
-import static st.orm.core.template.SqlInterceptor.intercept;
-import static st.orm.core.template.TemplateString.combine;
-import static st.orm.core.template.TemplateString.raw;
-import static st.orm.core.template.TemplateString.wrap;
-import static st.orm.core.template.Templates.bindVar;
-import static st.orm.core.template.Templates.table;
-import static st.orm.core.template.impl.StringTemplates.flatten;
import jakarta.annotation.Nonnull;
-import jakarta.annotation.Nullable;
-import java.math.BigInteger;
-import java.sql.Timestamp;
-import java.time.Instant;
-import java.util.Calendar;
-import java.util.Date;
import java.util.HashSet;
import java.util.List;
-import java.util.Map;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.stream.IntStream;
-import st.orm.BindVars;
import st.orm.Entity;
import st.orm.PersistenceException;
import st.orm.core.repository.EntityRepository;
-import st.orm.core.repository.impl.EntityRepositoryImpl;
-import st.orm.core.spi.EntityCache;
-import st.orm.core.template.Column;
+import st.orm.core.repository.impl.MergeEntityRepositoryImpl;
import st.orm.core.template.Model;
import st.orm.core.template.ORMTemplate;
-import st.orm.core.template.PreparedQuery;
-import st.orm.core.template.SqlTemplateException;
import st.orm.core.template.TemplateString;
/**
* Implementation of {@link EntityRepository} for Oracle.
*/
-public class OracleEntityRepositoryImpl, ID> extends EntityRepositoryImpl {
+public class OracleEntityRepositoryImpl, ID> extends MergeEntityRepositoryImpl {
public OracleEntityRepositoryImpl(@Nonnull ORMTemplate ormTemplate, @Nonnull Model model) {
super(ormTemplate, model);
}
- /**
- * Returns {@code true} when the entity should be routed to {@link #insert(Entity)} during an upsert.
- *
- * Oracle cannot perform a SQL-level MERGE with auto-generated primary keys, so when the primary key
- * is auto-generated, the upsert is routed to insert instead.
- *
- * @param entity the entity to check.
- * @return {@code true} if the primary key is auto-generated.
- */
@Override
- protected boolean isUpsertInsert(@Nonnull E entity) {
- return isAutoGeneratedPrimaryKey();
- }
-
- private String getVersionString(@Nonnull Column column) {
- String columnName = column.qualifiedName(ormTemplate.dialect());
- String updateExpression = switch (column.type()) {
- case Class> c when Integer.TYPE.isAssignableFrom(c)
- || Long.TYPE.isAssignableFrom(c)
- || Integer.class.isAssignableFrom(c)
- || Long.class.isAssignableFrom(c)
- || BigInteger.class.isAssignableFrom(c) -> "src.%s + 1".formatted(columnName);
- case Class> c when Instant.class.isAssignableFrom(c)
- || Date.class.isAssignableFrom(c)
- || Calendar.class.isAssignableFrom(c)
- || Timestamp.class.isAssignableFrom(c) -> "SYSTIMESTAMP";
- default ->
- throw new PersistenceException("Unsupported version type: %s.".formatted(column.type().getSimpleName()));
- };
- return "t.%s = %s".formatted(columnName, updateExpression);
- }
-
- private TemplateString mergeSelect(@Nonnull E entity) {
- assert !isAutoGeneratedPrimaryKey();
- var dialect = ormTemplate.dialect();
- var duplicates = new HashSet<>(); // CompoundPks may also have their columns included as stand-alone fields. Only include them once.
- try {
- var mapped = model.values(entity);
- return mapped.entrySet()
- .stream()
- .filter(entry -> duplicates.add(entry.getKey().name()))
- .map(entry -> {
- Object value = entry.getValue();
- if (entry.getKey().primaryKey()) {
- //noinspection unchecked
- if (model.isDefaultPrimaryKey((ID) value)) {
- value = null; // Always pass NULL to force a mismatch.
- }
- }
- return combine(wrap(value), TemplateString.of(" AS %s".formatted(entry.getKey().qualifiedName(dialect))));
- })
- .reduce((left, right) -> combine(left, TemplateString.of(", "), right))
- .map(t -> combine(TemplateString.of("SELECT "), t, TemplateString.of(" FROM DUAL")))
- .orElseThrow();
- } catch (SqlTemplateException e) {
- throw new PersistenceException("Failed to map entity to SQL parameters.", e);
- }
+ protected String mergeSourceSuffix() {
+ return " FROM DUAL";
}
- private TemplateString mergeSelect(@Nonnull BindVars bindVars) {
- var dialect = ormTemplate.dialect();
- var values = new AtomicReference>();
- bindVars.setRecordListener(record -> {
- try {
- //noinspection unchecked
- values.setPlain(model.values((E) record));
- } catch (SqlTemplateException e) {
- throw new PersistenceException("Failed to map entity to SQL parameters.", e);
- }
- });
- var duplicates = new HashSet<>(); // CompoundPks may also have their columns included as stand-alone fields. Only include them once.
- return model.declaredColumns().stream()
- .filter(column -> duplicates.add(column.name()))
- .map(c -> combine(wrap(bindVar(bindVars, ignore -> values.getPlain().get(c))), TemplateString.of(" AS %s".formatted(c.qualifiedName(dialect)))))
- .reduce((left, right) -> combine(left, TemplateString.of(", "), right))
- .map(t -> combine(TemplateString.of("SELECT "), t, TemplateString.of(" FROM DUAL")))
- .orElseThrow();
- }
-
- private TemplateString mergeOn() {
- var dialect = ormTemplate.dialect();
- var primaryKeys = model.declaredColumns().stream()
- .filter(Column::primaryKey)
- .toList();
- String sql = primaryKeys.stream()
- .map(c -> "t.%s = src.%s".formatted(c.qualifiedName(dialect), c.qualifiedName(dialect)))
- .collect(joining(" AND "));
- return TemplateString.of(sql);
- }
-
- private TemplateString mergeUpdate(@Nonnull AtomicBoolean versionAware) {
- var dialect = ormTemplate.dialect();
- var duplicates = new HashSet<>(); // CompoundPks may also have their columns included as stand-alone fields. Only include them once.
- var args = model.declaredColumns().stream()
- .filter(not(Column::primaryKey))
- .filter(Column::updatable)
- .filter(column -> duplicates.add(column.name()))
- .map(column -> {
- if (column.version()) {
- versionAware.setPlain(true);
- return getVersionString(column);
- }
- return "t.%s = src.%s".formatted(column.name(), column.qualifiedName(dialect));
- })
- .toList();
- if (args.isEmpty()) {
- return TemplateString.of("");
- }
- String sql = args.stream().collect(joining(", ", "UPDATE SET ", ""));
- return TemplateString.of("\nWHEN MATCHED THEN\n\t%s".formatted(sql));
+ @Override
+ protected String versionTimestampExpression() {
+ return "SYSTIMESTAMP";
}
- private TemplateString mergeInsert() {
+ @Override
+ protected TemplateString mergeInsert() {
var dialect = ormTemplate.dialect();
- var insertDuplicates = new HashSet<>(); // CompoundPks may also have their columns included as stand-alone fields. Only include them once.
+ var insertDuplicates = new HashSet<>(); // Compound PKs may also have their columns included as stand-alone fields. Only include them once.
var insertArgs = model.declaredColumns().stream()
.filter(c -> !c.primaryKey() || c.generation() != IDENTITY)
.filter(column -> insertDuplicates.add(column.name()))
.map(c -> c.qualifiedName(dialect))
.toList();
- var valuesDuplicates = new HashSet<>(); // CompoundPks may also have their columns included as stand-alone fields. Only include them once.
+ var valuesDuplicates = new HashSet<>(); // Compound PKs may also have their columns included as stand-alone fields. Only include them once.
var valuesArgs = model.declaredColumns().stream()
.filter(c -> !c.primaryKey() || c.generation() != IDENTITY)
.filter(column -> valuesDuplicates.add(column.name()))
@@ -198,66 +74,6 @@ private TemplateString mergeInsert() {
return TemplateString.of("\nWHEN NOT MATCHED THEN%s".formatted(sql));
}
- protected E validateUpsert(@Nonnull E entity) {
- assert !isAutoGeneratedPrimaryKey();
- if (model.isDefaultPrimaryKey(entity.id())) {
- throw new PersistenceException("Primary key must be set for non-auto-generated primary keys for upserts.");
- }
- return entity;
- }
-
- /**
- * Performs the SQL-level upsert (MERGE) for a single entity, without lifecycle callbacks or routing.
- *
- * @param entity the entity to upsert.
- * @throws PersistenceException if the upsert operation fails.
- */
- @Override
- protected void doUpsert(@Nonnull E entity) {
- validateUpsert(entity);
- var versionAware = new AtomicBoolean();
- entityCache().ifPresent(cache -> {
- if (!model.isDefaultPrimaryKey(entity.id())) {
- cache.remove(entity.id());
- }
- });
- intercept(sql -> sql.versionAware(versionAware.getPlain()), () -> {
- var query = ormTemplate.query(flatten(raw("""
- MERGE INTO \0 t
- USING (\0) src
- ON (\0)\0\0""", table(model.type()), mergeSelect(entity), mergeOn(), mergeUpdate(versionAware), mergeInsert()))).managed();
- query.executeUpdate();
- });
- }
-
- /**
- * Performs the SQL-level upsert (MERGE) for a single entity and returns its ID, without lifecycle callbacks
- * or routing.
- *
- * @param entity the entity to upsert.
- * @return the ID of the upserted entity.
- * @throws PersistenceException if the upsert operation fails.
- */
- @Override
- protected ID doUpsertAndFetchId(@Nonnull E entity) {
- validateUpsert(entity);
- entityCache().ifPresent(cache -> {
- if (!model.isDefaultPrimaryKey(entity.id())) {
- cache.remove(entity.id());
- }
- });
- var versionAware = new AtomicBoolean();
- intercept(sql -> sql.versionAware(versionAware.getPlain()), () -> {
- var query = ormTemplate.query(flatten(raw("""
- MERGE INTO \0 t
- USING (\0) src
- ON (\0)\0\0""", table(model.type()), mergeSelect(entity), mergeOn(), mergeUpdate(versionAware), mergeInsert())))
- .managed();
- query.executeUpdate();
- });
- return entity.id();
- }
-
/**
* Inserts or updates a collection of entities in the database in batches and returns a list of their IDs.
*
@@ -275,58 +91,6 @@ public List upsertAndFetchIds(@Nonnull Iterable entities) {
return super.upsertAndFetchIds(entities);
}
- @Override
- protected PreparedQuery prepareUpsertQuery() {
- var bindVars = ormTemplate.createBindVars();
- var versionAware = new AtomicBoolean();
- return intercept(sql -> sql.versionAware(versionAware.getPlain()), () ->
- ormTemplate.query(flatten(raw("""
- MERGE INTO \0 t
- USING (\0) src
- ON (\0)\0\0""", table(model.type()), mergeSelect(bindVars), mergeOn(), mergeUpdate(versionAware), mergeInsert())))
- .managed().prepare());
- }
-
- @Override
- protected void doUpsertBatch(@Nonnull List batch, @Nonnull PreparedQuery query, @Nullable EntityCache cache) {
- if (batch.isEmpty()) {
- return;
- }
- batch.stream().map(this::validateUpsert).forEach(query::addBatch);
- if (cache != null) {
- batch.stream()
- .filter(e -> !model.isDefaultPrimaryKey(e.id()))
- .forEach(e -> cache.remove(e.id()));
- }
- int[] result = query.executeBatch();
- if (IntStream.of(result).anyMatch(r -> r != 0 && r != 1 && r != 2)) {
- throw new PersistenceException(upsertFailureMessage(batch.size()));
- }
- }
-
- @Override
- protected List doUpsertAndFetchIdsBatch(@Nonnull List batch, @Nonnull PreparedQuery query, @Nullable EntityCache cache) {
- if (batch.isEmpty()) {
- return List.of();
- }
- batch.stream().map(this::validateUpsert).forEach(query::addBatch);
- if (cache != null) {
- batch.stream()
- .filter(e -> !model.isDefaultPrimaryKey(e.id()))
- .forEach(e -> cache.remove(e.id()));
- }
- int[] result = query.executeBatch();
- if (IntStream.of(result).anyMatch(r -> r != 0 && r != 1 && r != 2)) {
- throw new PersistenceException(upsertFailureMessage(batch.size()));
- }
- if (isAutoGeneratedPrimaryKey()) {
- try (var generatedKeys = query.getGeneratedKeys(model.primaryKeyType())) {
- return generatedKeys.toList();
- }
- }
- return batch.stream().map(Entity::id).toList();
- }
-
@Override
public ID insertAndFetchId(@Nonnull E entity) {
if (generationStrategy != SEQUENCE) {
diff --git a/storm-postgresql/pom.xml b/storm-postgresql/pom.xml
index e756ead87..4c48ee75b 100644
--- a/storm-postgresql/pom.xml
+++ b/storm-postgresql/pom.xml
@@ -139,5 +139,17 @@
postgresql
42.7.4
+
+ st.orm
+ storm-jackson2
+ ${project.version}
+ test
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.17.0
+ test
+
diff --git a/storm-postgresql/src/main/java/st/orm/spi/postgresql/PostgreSQLSqlDialect.java b/storm-postgresql/src/main/java/st/orm/spi/postgresql/PostgreSQLSqlDialect.java
index 3ed31004f..77d806342 100644
--- a/storm-postgresql/src/main/java/st/orm/spi/postgresql/PostgreSQLSqlDialect.java
+++ b/storm-postgresql/src/main/java/st/orm/spi/postgresql/PostgreSQLSqlDialect.java
@@ -29,6 +29,7 @@
import jakarta.annotation.Nonnull;
import java.sql.PreparedStatement;
import java.sql.SQLException;
+import java.sql.Types;
import java.util.List;
import java.util.SequencedMap;
import java.util.Set;
@@ -39,6 +40,7 @@
import st.orm.Operator;
import st.orm.StormConfig;
import st.orm.core.spi.DefaultSqlDialect;
+import st.orm.core.spi.JsonString;
import st.orm.core.template.SqlDialect;
import st.orm.core.template.SqlTemplateException;
@@ -284,6 +286,25 @@ public void setParameter(@Nonnull PreparedStatement preparedStatement, int index
preparedStatement.setObject(index, uuid);
}
+ /**
+ * Sets a serialized JSON parameter on the given prepared statement.
+ *
+ * PostgreSQL rejects string-typed parameters for {@code json} and {@code jsonb} columns ("column is of
+ * type jsonb but expression is of type character varying"). Binding the value as an untyped parameter lets
+ * the server cast it to the column's JSON type.
+ *
+ * @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
+ */
+ @Override
+ public void setParameter(@Nonnull PreparedStatement preparedStatement, int index,
+ @Nonnull JsonString json) throws SQLException {
+ preparedStatement.setObject(index, json.value(), Types.OTHER);
+ }
+
/**
* Returns the SQL statement for getting the next value of the given sequence.
*
diff --git a/storm-postgresql/src/test/java/st/orm/spi/postgresql/PostgreSQLJsonTest.java b/storm-postgresql/src/test/java/st/orm/spi/postgresql/PostgreSQLJsonTest.java
new file mode 100644
index 000000000..525eb962f
--- /dev/null
+++ b/storm-postgresql/src/test/java/st/orm/spi/postgresql/PostgreSQLJsonTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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.spi.postgresql;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static st.orm.GenerationStrategy.NONE;
+
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import java.util.Map;
+import javax.sql.DataSource;
+import lombok.Builder;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import st.orm.DbTable;
+import st.orm.Entity;
+import st.orm.Json;
+import st.orm.PK;
+import st.orm.core.template.PreparedStatementTemplate;
+
+/**
+ * Verifies that {@code @Json} fields bind correctly against PostgreSQL's native {@code jsonb} columns.
+ *
+ * PostgreSQL rejects string-typed parameters for {@code json}/{@code jsonb} columns, so the JSON converter
+ * output is bound as an untyped parameter via the dialect — these tests exercise the insert, update and upsert
+ * (INSERT ... ON CONFLICT) paths against real {@code jsonb} columns.
+ */
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration(classes = IntegrationConfig.class)
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // Prevent swapping to H2.
+@DataJpaTest(showSql = false)
+@Testcontainers
+public class PostgreSQLJsonTest {
+
+ @SuppressWarnings("resource")
+ @Container
+ public static PostgreSQLContainer> postgresContainer = new PostgreSQLContainer<>("postgres:latest")
+ .withDatabaseName("test")
+ .withUsername("test")
+ .withPassword("test")
+ .waitingFor(Wait.forListeningPort());
+
+ @DynamicPropertySource
+ static void overrideProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", postgresContainer::getJdbcUrl);
+ registry.add("spring.datasource.username", postgresContainer::getUsername);
+ registry.add("spring.datasource.password", postgresContainer::getPassword);
+ }
+
+ @Autowired
+ private DataSource dataSource;
+
+ // Note: @Json fields use Map types here — Jackson reflects on java.base types without requiring this
+ // module to be opened to jackson-databind. Structured @Json records are covered by the jackson2/jackson3
+ // module tests.
+ @Builder(toBuilder = true)
+ @DbTable("user_profile")
+ public record UserProfile(
+ @PK Integer id,
+ @Nonnull String name,
+ @Nonnull @Json Map attributes,
+ @Nullable @Json Map address
+ ) implements Entity {}
+
+ @Builder(toBuilder = true)
+ @DbTable("document")
+ public record Document(
+ @PK(generation = NONE) String key,
+ @Nonnull @Json Map payload
+ ) implements Entity {}
+
+ @Test
+ public void testInsertAndReadJsonbColumns() {
+ var repo = PreparedStatementTemplate.ORM(dataSource).entity(UserProfile.class);
+ var inserted = repo.insertAndFetch(UserProfile.builder()
+ .name("Alice")
+ .attributes(Map.of("theme", "dark", "language", "en"))
+ .address(Map.of("address", "271 University Ave", "city", "Palo Alto"))
+ .build());
+ assertEquals(Map.of("theme", "dark", "language", "en"), inserted.attributes());
+ assertEquals("Palo Alto", inserted.address().get("city"));
+ }
+
+ @Test
+ public void testInsertNullJsonbColumn() {
+ var repo = PreparedStatementTemplate.ORM(dataSource).entity(UserProfile.class);
+ var inserted = repo.insertAndFetch(UserProfile.builder()
+ .name("Bob")
+ .attributes(Map.of())
+ .address(null)
+ .build());
+ assertNull(inserted.address());
+ assertEquals(Map.of(), inserted.attributes());
+ }
+
+ @Test
+ public void testUpdateJsonbColumn() {
+ var repo = PreparedStatementTemplate.ORM(dataSource).entity(UserProfile.class);
+ var inserted = repo.insertAndFetch(UserProfile.builder()
+ .name("Carol")
+ .attributes(Map.of("theme", "light"))
+ .build());
+ repo.update(inserted.toBuilder().attributes(Map.of("theme", "dark")).build());
+ assertEquals(Map.of("theme", "dark"), repo.getById(inserted.id()).attributes());
+ }
+
+ @Test
+ public void testUpsertJsonbColumn() {
+ // Natural key: exercises PostgreSQL's INSERT ... ON CONFLICT upsert with a jsonb parameter.
+ var repo = PreparedStatementTemplate.ORM(dataSource).entity(Document.class);
+ repo.upsert(Document.builder().key("settings").payload(Map.of("volume", "20")).build());
+ assertEquals(Map.of("volume", "20"), repo.getById("settings").payload());
+ repo.upsert(Document.builder().key("settings").payload(Map.of("volume", "80")).build());
+ assertEquals(Map.of("volume", "80"), repo.getById("settings").payload());
+ }
+
+ @Test
+ public void testUpsertBatchJsonbColumn() {
+ var repo = PreparedStatementTemplate.ORM(dataSource).entity(Document.class);
+ var documents = java.util.List.of(
+ Document.builder().key("doc-1").payload(Map.of("state", "draft")).build(),
+ Document.builder().key("doc-2").payload(Map.of("state", "draft")).build());
+ repo.upsert(documents);
+ repo.upsert(documents.stream().map(d -> d.toBuilder().payload(Map.of("state", "final")).build()).toList());
+ assertEquals(Map.of("state", "final"), repo.getById("doc-1").payload());
+ assertEquals(Map.of("state", "final"), repo.getById("doc-2").payload());
+ }
+}
diff --git a/storm-postgresql/src/test/resources/data.sql b/storm-postgresql/src/test/resources/data.sql
index 4c956ba8e..5073f961c 100644
--- a/storm-postgresql/src/test/resources/data.sql
+++ b/storm-postgresql/src/test/resources/data.sql
@@ -5,6 +5,8 @@ DROP TABLE IF EXISTS specialty CASCADE;
DROP TABLE IF EXISTS vet CASCADE;
DROP TABLE IF EXISTS vet_specialty CASCADE;
DROP TABLE IF EXISTS visit CASCADE;
+DROP TABLE IF EXISTS user_profile CASCADE;
+DROP TABLE IF EXISTS document CASCADE;
DROP VIEW IF EXISTS owner_view;
DROP VIEW IF EXISTS visit_view;
@@ -18,6 +20,19 @@ CREATE TABLE owner (
version integer DEFAULT 0
);
+-- Native JSON columns for the @Json converter tests.
+CREATE TABLE user_profile (
+ id serial PRIMARY KEY,
+ name varchar(255) NOT NULL,
+ attributes jsonb NOT NULL,
+ address jsonb
+);
+
+CREATE TABLE document (
+ key varchar(64) PRIMARY KEY,
+ payload jsonb NOT NULL
+);
+
CREATE SEQUENCE pet_id_seq
START WITH 1
INCREMENT BY 1;
diff --git a/storm-test/src/main/java/st/orm/test/StormExtension.java b/storm-test/src/main/java/st/orm/test/StormExtension.java
index 0bcf3b875..7f4c027ce 100644
--- a/storm-test/src/main/java/st/orm/test/StormExtension.java
+++ b/storm-test/src/main/java/st/orm/test/StormExtension.java
@@ -27,6 +27,8 @@
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
+import java.util.ArrayList;
+import java.util.List;
import java.util.logging.Logger;
import javax.sql.DataSource;
import org.junit.jupiter.api.extension.BeforeAllCallback;
@@ -225,14 +227,70 @@ private static String readScript(Class> testClass, String path) {
}
private static void executeScript(Connection conn, String sql) throws SQLException {
- for (String statement : sql.split(";")) {
- String trimmed = statement.trim();
- if (!trimmed.isEmpty()) {
- try (var stmt = conn.createStatement()) {
- stmt.execute(trimmed);
+ for (String statement : splitStatements(sql)) {
+ try (var stmt = conn.createStatement()) {
+ stmt.execute(statement);
+ }
+ }
+ }
+
+ /**
+ * Splits a SQL script into individual statements on semicolons, ignoring semicolons that appear inside line
+ * comments, block comments, string literals and quoted identifiers. Fragments that contain only comments and
+ * whitespace are dropped.
+ */
+ static List splitStatements(String script) {
+ var statements = new ArrayList();
+ var current = new StringBuilder();
+ boolean hasContent = false;
+ int length = script.length();
+ int i = 0;
+ while (i < length) {
+ char c = script.charAt(i);
+ char next = i + 1 < length ? script.charAt(i + 1) : '\0';
+ if (c == '-' && next == '-') {
+ int end = script.indexOf('\n', i);
+ end = end == -1 ? length : end;
+ current.append(script, i, end);
+ i = end;
+ } else if (c == '/' && next == '*') {
+ int end = script.indexOf("*/", i + 2);
+ end = end == -1 ? length : end + 2;
+ current.append(script, i, end);
+ i = end;
+ } else if (c == '\'' || c == '"') {
+ int end = i + 1;
+ while (end < length) {
+ if (script.charAt(end) == c) {
+ if (c == '\'' && end + 1 < length && script.charAt(end + 1) == '\'') {
+ end += 2; // A doubled quote escapes itself within a string literal.
+ continue;
+ }
+ end++;
+ break;
+ }
+ end++;
}
+ current.append(script, i, end);
+ hasContent = true;
+ i = end;
+ } else if (c == ';') {
+ if (hasContent) {
+ statements.add(current.toString().trim());
+ }
+ current.setLength(0);
+ hasContent = false;
+ i++;
+ } else {
+ current.append(c);
+ hasContent |= !Character.isWhitespace(c);
+ i++;
}
}
+ if (hasContent) {
+ statements.add(current.toString().trim());
+ }
+ return statements;
}
// --- Simple DataSource implementation ---
diff --git a/storm-test/src/test/java/st/orm/test/StormExtensionScriptSplitTest.java b/storm-test/src/test/java/st/orm/test/StormExtensionScriptSplitTest.java
new file mode 100644
index 000000000..ecdcc9ea9
--- /dev/null
+++ b/storm-test/src/test/java/st/orm/test/StormExtensionScriptSplitTest.java
@@ -0,0 +1,92 @@
+/*
+ * 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.test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static st.orm.test.StormExtension.splitStatements;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class StormExtensionScriptSplitTest {
+
+ @Test
+ void splitsOnSemicolons() {
+ var statements = splitStatements("create table a (id int);\ncreate table b (id int);");
+ assertEquals(List.of("create table a (id int)", "create table b (id int)"), statements);
+ }
+
+ @Test
+ void ignoresSemicolonInLineComment() {
+ var statements = splitStatements("""
+ -- a comment; with a semicolon
+ create table a (id int);
+ """);
+ assertEquals(1, statements.size());
+ assertTrue(statements.getFirst().endsWith("create table a (id int)"));
+ }
+
+ @Test
+ void ignoresSemicolonInBlockComment() {
+ var statements = splitStatements("""
+ /* block; comment;
+ spanning lines; */
+ create table a (id int);
+ create table b (id int);
+ """);
+ assertEquals(2, statements.size());
+ }
+
+ @Test
+ void ignoresSemicolonInStringLiteral() {
+ var statements = splitStatements("insert into a (name) values ('x; y');");
+ assertEquals(List.of("insert into a (name) values ('x; y')"), statements);
+ }
+
+ @Test
+ void handlesDoubledQuoteEscapeInStringLiteral() {
+ var statements = splitStatements("insert into a (name) values ('it''s; fine');");
+ assertEquals(List.of("insert into a (name) values ('it''s; fine')"), statements);
+ }
+
+ @Test
+ void ignoresSemicolonInQuotedIdentifier() {
+ var statements = splitStatements("create table \"weird;name\" (id int);");
+ assertEquals(List.of("create table \"weird;name\" (id int)"), statements);
+ }
+
+ @Test
+ void dropsCommentOnlyFragments() {
+ var statements = splitStatements("""
+ create table a (id int);
+ -- trailing comment only
+ """);
+ assertEquals(1, statements.size());
+ }
+
+ @Test
+ void dropsEmptyFragments() {
+ var statements = splitStatements(";;create table a (id int);;");
+ assertEquals(List.of("create table a (id int)"), statements);
+ }
+
+ @Test
+ void keepsStatementWithoutTrailingSemicolon() {
+ var statements = splitStatements("create table a (id int)");
+ assertEquals(List.of("create table a (id int)"), statements);
+ }
+}
diff --git a/storm-test/src/test/resources/test-schema.sql b/storm-test/src/test/resources/test-schema.sql
index c77e12a15..cee025098 100644
--- a/storm-test/src/test/resources/test-schema.sql
+++ b/storm-test/src/test/resources/test-schema.sql
@@ -1,2 +1,3 @@
+-- Storm test schema; semicolons inside comments must not split statements.
drop table if exists item;
create table item (id integer auto_increment, name varchar(255), primary key (id));