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));