From 4ec5e37b210a65da40ee34212db817f415b2af48 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Fri, 3 Jul 2026 21:25:16 +0200 Subject: [PATCH 1/3] fix(storm-test): split scripts on semicolons outside comments and literals The @StormTest script runner split statements with a bare split(";"), so a semicolon inside a line comment, block comment, string literal or quoted identifier broke every test class using the script. Statements are now split with a small scanner that skips those regions and drops comment-only fragments. A semicolon planted in test-schema.sql guards the integration path. --- .../main/java/st/orm/test/StormExtension.java | 68 +++++++++++++- .../test/StormExtensionScriptSplitTest.java | 92 +++++++++++++++++++ storm-test/src/test/resources/test-schema.sql | 1 + 3 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 storm-test/src/test/java/st/orm/test/StormExtensionScriptSplitTest.java 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)); From fc1ef1bcc73f357216c409d7133dadd9cdb1fd7f Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Fri, 3 Jul 2026 21:25:34 +0200 Subject: [PATCH 2/3] fix: enable H2 natural-key upserts and share the MERGE upsert across dialects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H2 cannot infer the type of a bare parameter in the projection of the MERGE source query and failed natural-key upserts with "Unknown data type". Each parameter is now cast to the H2 type matching how the ORM binds the value; foreign key columns resolve their per-column leaf type through the referenced key (including compound record keys). The six previously @Disabled H2 upsert tests are re-enabled, with new coverage for dependent one-to-one entities (PK-is-FK) with temporal columns and compound foreign keys. The near-identical MERGE implementations of H2, Oracle and SQL Server are extracted into a shared MergeEntityRepositoryImpl with hooks for the dialect differences (cast type, source suffix, statement suffix, version expressions, insert clause). The source query now projects only declared columns — expanded foreign-relation columns were bound but never referenced — and identifier quoting is applied consistently in the update and bind-vars clauses. --- .../impl/MergeEntityRepositoryImpl.java | 361 ++++++++++++++++++ .../st/orm/spi/h2/H2EntityRepositoryImpl.java | 327 ++++++---------- .../st/orm/spi/h2/H2EntityRepositoryTest.java | 77 +++- storm-h2/src/test/resources/data.sql | 17 + .../MSSQLServerEntityRepositoryImpl.java | 260 +------------ .../oracle/OracleEntityRepositoryImpl.java | 258 +------------ 6 files changed, 577 insertions(+), 723 deletions(-) create mode 100644 storm-core/src/main/java/st/orm/core/repository/impl/MergeEntityRepositoryImpl.java 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-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-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) { From 1b91e38bd74a65e2c30ddf3c0dbc92fc23bc2963 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Fri, 3 Jul 2026 21:25:53 +0200 Subject: [PATCH 3/3] fix: bind @Json values as native JSON on PostgreSQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Json converter output was bound with setString, which PostgreSQL rejects for json/jsonb columns ("column is of type jsonb but expression is of type character varying") — even though the documentation recommends JSONB columns. The JSON converters now wrap their serialized output in a JsonString marker, bound through a new SqlDialect.setParameter(JsonString) overload: the default binds as a plain string (unchanged behavior for other databases), while PostgreSQL binds an untyped parameter so the server casts it to the column's JSON type. The JPA template unwraps the marker and inline literal rendering quotes it like a string. New Testcontainers coverage exercises insert, update, ON CONFLICT upsert and batch upsert against real jsonb columns, plus tests for the JPA binding and inline literal paths. --- .../main/java/st/orm/core/spi/JsonString.java | 40 +++++ .../java/st/orm/core/template/SqlDialect.java | 20 +++ .../core/template/impl/JpaTemplateImpl.java | 3 + .../impl/PreparedStatementTemplateImpl.java | 2 + .../java/st/orm/core/JpaIntegrationTest.java | 15 ++ .../template/impl/InlineParametersTest.java | 12 ++ .../orm/jackson/spi/JsonORMConverterImpl.java | 3 +- .../orm/jackson/spi/JsonORMConverterImpl.java | 3 +- .../serialization/spi/JsonORMConverterImpl.kt | 2 +- storm-postgresql/pom.xml | 12 ++ .../spi/postgresql/PostgreSQLSqlDialect.java | 21 +++ .../spi/postgresql/PostgreSQLJsonTest.java | 153 ++++++++++++++++++ storm-postgresql/src/test/resources/data.sql | 15 ++ 13 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 storm-core/src/main/java/st/orm/core/spi/JsonString.java create mode 100644 storm-postgresql/src/test/java/st/orm/spi/postgresql/PostgreSQLJsonTest.java 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-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-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;