From c70c498c35a921e0d6d59b2d6c51db0c3f29149b Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Sat, 29 Aug 2026 22:44:38 +0200 Subject: [PATCH] refactor: deduplicate shared logic and remove dead code across modules --- .../repository/impl/EntityRepositoryImpl.java | 178 +++-- .../impl/MergeEntityRepositoryImpl.java | 26 +- .../impl/OnConflictEntityRepositoryImpl.java | 214 ++++++ .../java/st/orm/core/spi/AbstractRef.java | 10 +- .../main/java/st/orm/core/spi/BaseRef.java | 124 ++++ .../main/java/st/orm/core/spi/Providers.java | 14 +- .../main/java/st/orm/core/spi/RefImpl.java | 92 +-- .../java/st/orm/core/spi/ScalarRefImpl.java | 90 +-- .../st/orm/core/spi/TransactionScope.java | 6 +- .../st/orm/core/template/QueryBuilder.java | 9 - .../core/template/impl/DeleteBuilderImpl.java | 19 +- .../template/impl/ObjectMapperFactory.java | 4 - .../impl/PredicateBuilderFactory.java | 18 - .../impl/PreparedStatementTemplateImpl.java | 19 - .../core/template/impl/QueryBuilderImpl.java | 25 + .../core/template/impl/QueryModelImpl.java | 2 +- .../orm/core/template/impl/RecordMapper.java | 25 +- .../core/template/impl/RecordValidation.java | 3 - .../core/template/impl/SchemaValidator.java | 4 +- .../core/template/impl/SelectBuilderImpl.java | 19 +- .../template/impl/SqlInterceptorManager.java | 40 +- .../core/template/impl/SqlTemplateImpl.java | 70 +- .../template/impl/TemplatePreparation.java | 92 ++- .../core/template/impl/TemplateProcessor.java | 23 +- .../src/main/java/st/orm/LoadedRef.java | 69 ++ .../src/main/java/st/orm/Ref.java | 86 +-- storm-h2/pom.xml | 8 +- .../main/java/st/orm/spi/h2/H2SqlDialect.java | 69 +- .../st.orm.core.spi.ConnectionProvider | 2 +- .../orm/jackson/spi/JsonORMConverterImpl.java | 2 +- .../orm/jackson/spi/JsonORMConverterImpl.java | 2 +- .../repository/impl/EntityRepositoryImpl.java | 7 - .../impl/ProjectionRepositoryImpl.java | 9 - .../autoconfigure/StormAutoConfiguration.kt | 2 - .../java/st/orm/spi/ORMReflectionImpl.java | 12 - .../st/orm/repository/RepositoryLookup.kt | 219 ++----- .../kotlin/st/orm/template/QueryBuilder.kt | 48 +- .../st/orm/template/impl/ORMTemplateImpl.kt | 51 +- .../template/impl/PredicateBuilderFactory.kt | 36 -- .../orm/template/impl/TransactionCallbacks.kt | 12 +- .../kotlin/st/orm/template/TemplatesTest.kt | 12 - .../src/main/kotlin/st/orm/ktor/Storm.kt | 14 +- storm-mariadb/pom.xml | 8 +- storm-mariadb/src/main/java/module-info.java | 1 - .../mariadb/MariaDBEntityRepositoryImpl.java | 97 +-- .../TestSpringConnectionProvider.java | 47 -- .../st.orm.core.spi.ConnectionProvider | 2 +- .../st/orm/metamodel/MetamodelProcessor.kt | 229 +++---- .../st/orm/metamodel/MetamodelProcessor.java | 608 +++++++----------- .../OtelDatabaseObservationConvention.java | 5 +- .../st/orm/micrometer/QueryObservers.java | 3 - storm-mssqlserver/pom.xml | 8 +- .../MSSQLServerEntityRepositoryImpl.java | 65 +- .../mssqlserver/MSSQLServerSqlDialect.java | 31 +- .../TestSpringConnectionProvider.java | 47 -- .../st.orm.core.spi.ConnectionProvider | 2 +- storm-mysql/pom.xml | 8 +- .../st/orm/spi/mysql/MySQLSqlDialect.java | 19 +- .../TestSpringConnectionProvider.java | 47 -- .../st.orm.core.spi.ConnectionProvider | 2 +- storm-oracle/pom.xml | 8 +- .../st/orm/spi/oracle/OracleSqlDialect.java | 66 +- .../TestSpringConnectionProvider.java | 47 -- .../st.orm.core.spi.ConnectionProvider | 2 +- storm-postgresql/pom.xml | 8 +- .../PostgreSQLEntityRepositoryImpl.java | 253 +------- .../spi/postgresql/PostgreSQLSqlDialect.java | 69 +- .../TestSpringConnectionProvider.java | 47 -- .../st.orm.core.spi.ConnectionProvider | 2 +- .../autoconfigure/StormAutoConfiguration.java | 2 - .../StormRepositoryAutoConfiguration.java | 1 + ...DataStormTestContextCustomizerFactory.java | 6 +- .../boot/test/DataStormTypeExcludeFilter.java | 2 +- storm-spring/src/main/java/module-info.java | 13 +- ...actRepositoryBeanFactoryPostProcessor.java | 11 +- .../SpringTransactionTemplateProvider.java | 14 +- .../st/orm/spring/boot/PerformanceLog.java | 16 +- ...PerformanceLogEntryPointPostProcessor.java | 7 +- .../boot/StormPerformanceLogFilter.java | 8 +- .../st/orm/spring/boot/StormProperties.java | 14 +- .../java/st/orm/spring/impl/EntityCaches.java | 62 ++ .../spring/impl/SpringTransactionContext.java | 49 +- storm-sqlite/pom.xml | 8 +- .../sqlite/SQLiteEntityRepositoryImpl.java | 178 +---- .../st/orm/spi/sqlite/SQLiteSqlDialect.java | 38 +- .../TestSpringConnectionProvider.java | 47 -- .../st.orm.core.spi.ConnectionProvider | 2 +- storm-test/pom.xml | 7 + storm-test/src/main/java/module-info.java | 3 + .../src/main/java/st/orm/test/SqlCapture.java | 8 +- .../spring}/TestSpringConnectionProvider.java | 7 +- 91 files changed, 1358 insertions(+), 2662 deletions(-) create mode 100644 storm-core/src/main/java/st/orm/core/repository/impl/OnConflictEntityRepositoryImpl.java create mode 100644 storm-core/src/main/java/st/orm/core/spi/BaseRef.java create mode 100644 storm-foundation/src/main/java/st/orm/LoadedRef.java delete mode 100644 storm-mariadb/src/test/java/st/orm/spi/mariadb/testsupport/TestSpringConnectionProvider.java delete mode 100644 storm-mssqlserver/src/test/java/st/orm/spi/mssqlserver/testsupport/TestSpringConnectionProvider.java delete mode 100644 storm-mysql/src/test/java/st/orm/spi/mysql/testsupport/TestSpringConnectionProvider.java delete mode 100644 storm-oracle/src/test/java/st/orm/spi/oracle/testsupport/TestSpringConnectionProvider.java delete mode 100644 storm-postgresql/src/test/java/st/orm/spi/postgresql/testsupport/TestSpringConnectionProvider.java create mode 100644 storm-spring/src/main/java/st/orm/spring/impl/EntityCaches.java delete mode 100644 storm-sqlite/src/test/java/st/orm/spi/sqlite/testsupport/TestSpringConnectionProvider.java rename {storm-h2/src/test/java/st/orm/spi/h2/testsupport => storm-test/src/main/java/st/orm/test/spring}/TestSpringConnectionProvider.java (85%) diff --git a/storm-core/src/main/java/st/orm/core/repository/impl/EntityRepositoryImpl.java b/storm-core/src/main/java/st/orm/core/repository/impl/EntityRepositoryImpl.java index ea105f4ad..53f3546a3 100644 --- a/storm-core/src/main/java/st/orm/core/repository/impl/EntityRepositoryImpl.java +++ b/storm-core/src/main/java/st/orm/core/repository/impl/EntityRepositoryImpl.java @@ -30,6 +30,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.function.BiFunction; import java.util.function.Function; import java.util.stream.IntStream; import java.util.stream.Stream; @@ -1717,16 +1718,7 @@ protected void insert(List batch, PreparedQuery query, boolean ignoreAutoGene if (batch.isEmpty()) { return; } - List transformed = batch.stream() - .map(this::fireBeforeInsert) - .toList(); - transformed.stream() - .map(e -> validateInsert(e, ignoreAutoGenerate)) - .forEach(query::addBatch); - int[] result = query.executeBatch(); - if (IntStream.of(result).anyMatch(r -> r != 1)) { - throw new PersistenceException("Batch insert of %s failed. One or more rows were not affected.".formatted(model.type().getSimpleName())); - } + List transformed = executeInsertBatch(batch, query, ignoreAutoGenerate); transformed.forEach(this::fireAfterInsert); } @@ -1739,16 +1731,7 @@ private List insertAndFetchIds(List batch, PreparedQuery query, boolean i if (batch.isEmpty()) { return List.of(); } - List transformed = batch.stream() - .map(this::fireBeforeInsert) - .toList(); - transformed.stream() - .map(e -> validateInsert(e, ignoreAutoGenerate)) - .forEach(query::addBatch); - int[] result = query.executeBatch(); - if (IntStream.of(result).anyMatch(r -> r != 1)) { - throw new PersistenceException("Batch insert of %s failed. One or more rows were not affected.".formatted(model.type().getSimpleName())); - } + List transformed = executeInsertBatch(batch, query, ignoreAutoGenerate); List ids; if (isAutoGeneratedPrimaryKey() && !ignoreAutoGenerate) { try (var stream = query.getGeneratedKeys(model.primaryKeyType())) { @@ -1761,6 +1744,26 @@ private List insertAndFetchIds(List batch, PreparedQuery query, boolean i return ids; } + /** + * Runs the batch through the prepared insert: fires the before-insert callbacks, validates, executes the + * batch and checks that every row was inserted. + * + * @return the entities as transformed by the before-insert callbacks. + */ + private List executeInsertBatch(List batch, PreparedQuery query, boolean ignoreAutoGenerate) { + List transformed = batch.stream() + .map(this::fireBeforeInsert) + .toList(); + transformed.stream() + .map(e -> validateInsert(e, ignoreAutoGenerate)) + .forEach(query::addBatch); + int[] result = query.executeBatch(); + if (IntStream.of(result).anyMatch(r -> r != 1)) { + throw new PersistenceException("Batch insert of %s failed. One or more rows were not affected.".formatted(model.type().getSimpleName())); + } + return transformed; + } + /** * Updates a stream of entities in the database using the default batch size. * @@ -1984,22 +1987,7 @@ protected PreparedQuery prepareUpdateQuery(Set> fields) { } protected void update(List batch, PreparedQuery query, @Nullable EntityCache cache) { - if (batch.isEmpty()) { - return; - } - batch.stream().map(this::validateUpdate).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 (query.isVersionAware() && IntStream.of(result).anyMatch(r -> r == 0)) { - throw new OptimisticLockException("Batch update of %s failed due to optimistic lock. One or more entities may have been modified or deleted by another transaction.".formatted(model.type().getSimpleName())); - } else if (IntStream.of(result).anyMatch(r -> r != 1)) { - throw new PersistenceException("Batch update of %s failed. One or more rows were not affected.".formatted(model.type().getSimpleName())); - } - batch.forEach(this::fireAfterUpdate); + updateAndFetchIds(batch, query, cache); } protected List updateAndFetchIds(List batch, PreparedQuery query, @Nullable EntityCache cache) { @@ -2168,6 +2156,124 @@ protected List doUpsertAndFetchIdsBatch(List batch, PreparedQuery query, throw upsertNotAvailable(); } + // Partition keys for the dialects' id-returning upsertAndFetchIds implementations. + public sealed interface SeqPartitionKey {} + public static final class SeqNoOpKey implements SeqPartitionKey { + public static final SeqNoOpKey INSTANCE = new SeqNoOpKey(); + private SeqNoOpKey() { + } + } + public static final class SeqUpsertKey implements SeqPartitionKey { + public static final SeqUpsertKey INSTANCE = new SeqUpsertKey(); + private SeqUpsertKey() { + } + } + public record SeqUpdateKey(Set> fields) implements SeqPartitionKey { + public SeqUpdateKey() { + this(Set.of()); // All fields. + } + } + + /** + * The upsert-and-fetch-ids loop for dialects that fetch the ids through a single id-returning query + * (such as {@code INSERT ... RETURNING} or {@code MERGE ... OUTPUT}) rather than batched prepared + * statements: the entities are partitioned into no-op, upsert and per-shape update batches, and the + * resulting ids come back in entity order. + * + * @param entities the entities to upsert. + * @param upsertPartition executes one upsert partition and returns its ids; receives the partition's + * chunk and the entity cache in effect for the operation. Dialects with the + * standard cache-eviction semantics pass {@link #upsertPartitionAndFetchIds}. + * @return the ids of the upserted entities. + * @since 1.14 + */ + protected List upsertAndFetchIdsPartitioned( + Iterable entities, + BiFunction, Optional>, List> upsertPartition) { + var updateQueries = new HashMap>, PreparedQuery>(); + try { + var result = new ArrayList(); + var entityCache = entityCache(); + partitioned(toStream(entities), defaultBatchSize, entity -> { + if (isUpsertUpdate(entity)) { + var dirty = getDirty(entity, entityCache.orElse(null)); + if (dirty.isEmpty()) { + return SeqNoOpKey.INSTANCE; + } + return new SeqUpdateKey(dirty.get()); + } else { + return SeqUpsertKey.INSTANCE; + } + }, getMaxShapes(), new SeqUpdateKey()).forEach(partition -> { + switch (partition.key()) { + case SeqNoOpKey ignore -> result.addAll(partition.chunk().stream().map(E::id).toList()); + case SeqUpsertKey ignore -> result.addAll(upsertPartition.apply(partition.chunk(), entityCache)); + case SeqUpdateKey u -> { + List batch = hasEntityCallbacks() + ? partition.chunk().stream().map(this::fireBeforeUpdate).toList() + : partition.chunk(); + result.addAll(updateAndFetchIds(batch, + updateQueries.computeIfAbsent(u.fields(), this::prepareUpdateQuery), + entityCache.orElse(null))); + } + } + }); + return result; + } finally { + closeQuietly(updateQueries.values().stream()); + } + } + + /** + * Executes one upsert partition through a single id-returning query: fires the before-upsert callbacks, + * evicts the entities with non-default primary keys from the cache (the upsert may update them), runs + * the query and reports the returned ids to the after-upsert callbacks. + * + * @param chunk the partition's entities. + * @param entityCache the entity cache in effect for the operation. + * @param upsertQuery creates the id-returning upsert query for a batch. + * @return the ids of the upserted entities. + * @since 1.14 + */ + protected List upsertPartitionAndFetchIds(List chunk, + Optional> entityCache, + Function, Query> upsertQuery) { + List batch = hasEntityCallbacks() + ? chunk.stream().map(this::fireBeforeUpsert).toList() + : chunk; + entityCache.ifPresent(cache -> batch.stream() + .filter(e -> !model.isDefaultPrimaryKey(e.id())) + .forEach(e -> cache.remove(e.id()))); + List ids = upsertQuery.apply(batch).getResultList(model.primaryKeyType()); + fireAfterUpsert(batch, ids); + return ids; + } + + /** + * Inserts the entity and fetches the generated primary key through the dialect's + * {@code INSERT ... RETURNING} clause, for dialects whose driver cannot report sequence-generated + * keys through {@code getGeneratedKeys()}. + * + * @param entity the entity to insert. + * @return the primary key of the inserted entity. + * @since 1.14 + */ + protected ID insertAndFetchIdReturning(E entity) { + entity = fireBeforeInsert(entity); + validateInsert(entity); + assert primaryKeyColumns.size() == 1; + var primaryKeyColumn = primaryKeyColumns.getFirst(); + String pkName = primaryKeyColumn.qualifiedName(ormTemplate.dialect()); + try (var query = ormTemplate.query(TemplateString.raw(""" + INSERT INTO \0 + VALUES \0 + RETURNING %s""".formatted(pkName), model.type(), entity)).managed().prepare()) { + ID id = query.getSingleResult(model.primaryKeyType()); + fireAfterInsert(entity, id); + return id; + } + } + /** * Removes a stream of entities from the database in batches. * 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 index f975cea6a..5934a45f4 100644 --- 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 @@ -278,17 +278,7 @@ private TemplateString mergeStatement(TemplateString mergeSelect, AtomicBoolean */ @Override protected void doUpsert(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(); - }); + doUpsertAndFetchId(entity); } /** @@ -322,19 +312,7 @@ protected PreparedQuery prepareUpsertQuery() { @Override protected void doUpsertBatch(List batch, 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())); - } + doUpsertAndFetchIdsBatch(batch, query, cache); } @Override diff --git a/storm-core/src/main/java/st/orm/core/repository/impl/OnConflictEntityRepositoryImpl.java b/storm-core/src/main/java/st/orm/core/repository/impl/OnConflictEntityRepositoryImpl.java new file mode 100644 index 000000000..16c72fe53 --- /dev/null +++ b/storm-core/src/main/java/st/orm/core/repository/impl/OnConflictEntityRepositoryImpl.java @@ -0,0 +1,214 @@ +/* + * 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 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.Templates.table; +import static st.orm.core.template.impl.StringTemplates.flatten; + +import java.math.BigInteger; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.IntStream; +import org.jspecify.annotations.Nullable; +import st.orm.Data; +import st.orm.Entity; +import st.orm.NoResultException; +import st.orm.NonUniqueResultException; +import st.orm.PersistenceException; +import st.orm.core.repository.EntityRepository; +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.TemplateString; + +/** + * Implementation of {@link EntityRepository} for dialects that upsert through the + * {@code INSERT ... ON CONFLICT () DO UPDATE SET ...} clause with the {@code EXCLUDED} + * pseudo-table, such as PostgreSQL and SQLite. + * + * @since 1.14 + */ +public class OnConflictEntityRepositoryImpl, ID> + extends EntityRepositoryImpl { + + protected OnConflictEntityRepositoryImpl(ORMTemplate ormTemplate, Model model) { + super(ormTemplate, model); + } + + private TemplateString getVersionString(Class type, Column column) { + TemplateString columnName = TemplateString.of(column.qualifiedName(ormTemplate.dialect())); + TemplateString 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) -> raw("\0.\0 + 1", table(type), columnName); + case Class c when Instant.class.isAssignableFrom(c) + || Date.class.isAssignableFrom(c) + || Calendar.class.isAssignableFrom(c) + || Timestamp.class.isAssignableFrom(c) -> TemplateString.of(ormTemplate.dialect().currentTimestamp()); + default -> + throw new PersistenceException("Unsupported version type: %s.".formatted(column.type().getSimpleName())); + }; + return flatten(raw("\0 = \0", columnName, updateExpression)); + } + + /** + * Constructs the conflict clause for an upsert. + * + *

This method builds an "ON CONFLICT (<primary_keys>) DO UPDATE SET ..." clause. + * For non-primary key columns, it assigns the value from the EXCLUDED pseudo-table. + * Version columns are updated using {@link #getVersionString(Class, Column)}.

+ * + * @param versionAware a flag that will be set if a version column is encountered. + * @return the conflict clause as a TemplateString. + */ + protected TemplateString onConflictClause(AtomicBoolean versionAware) { + var dialect = ormTemplate.dialect(); + // Determine the conflict target from primary key columns. + String conflictTarget = model.declaredColumns().stream() + .filter(Column::primaryKey) + .map(c -> c.qualifiedName(dialect)) + .reduce("%s, %s"::formatted) + .orElseThrow(() -> new PersistenceException("No primary key defined.")); + // Build the assignment list for non-primary key updatable columns. + var assignments = model.declaredColumns().stream() + .filter(not(Column::primaryKey)) + .filter(Column::updatable) + .map(column -> { + if (column.version()) { + versionAware.setPlain(true); + return getVersionString(model.type(), column); + } + return TemplateString.of("%s = EXCLUDED.%s".formatted(column.qualifiedName(dialect), column.qualifiedName(dialect))); + }) + .reduce((left, right) -> combine(left, TemplateString.of(", "), right)) + .map(st -> combine(TemplateString.of("DO UPDATE SET "), st)) + .orElse(TemplateString.of("DO NOTHING")); + return flatten(combine(TemplateString.of("\nON CONFLICT ("), TemplateString.of(conflictTarget), raw(") \0", assignments))); + } + + @Override + protected void doUpsert(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(""" + INSERT INTO \0 + VALUES \0\0""", model.type(), entity, onConflictClause(versionAware)))).managed(); + query.executeUpdate(); + }); + } + + /** + * Performs the upsert and reads the primary key back through the driver's generated keys. Dialects with + * an id-returning path for sequence-generated keys override this method and fall back to this + * implementation for the other generation strategies. + */ + @Override + protected ID doUpsertAndFetchId(E entity) { + validateUpsert(entity); + entityCache().ifPresent(cache -> { + if (!model.isDefaultPrimaryKey(entity.id())) { + cache.remove(entity.id()); + } + }); + var versionAware = new AtomicBoolean(); + return intercept(sql -> sql.versionAware(versionAware.getPlain()), () -> { + try (var query = ormTemplate.query(flatten(raw(""" + INSERT INTO \0 + VALUES \0\0""", model.type(), entity, onConflictClause(versionAware)))).managed().prepare()) { + query.executeUpdate(); + if (isAutoGeneratedPrimaryKey()) { + try (var stream = query.getGeneratedKeys(model.primaryKeyType())) { + return stream.reduce((ignore1, ignore2) -> { + throw new NonUniqueResultException("Expected single result, but found more than one."); + }).orElseThrow(() -> new NoResultException("Expected single result, but found none.")); + } + } + return entity.id(); + } + }); + } + + @Override + protected PreparedQuery prepareUpsertQuery() { + var bindVars = ormTemplate.createBindVars(); + var versionAware = new AtomicBoolean(); + return intercept(sql -> sql.versionAware(versionAware.getPlain()), () -> + ormTemplate.query(flatten(raw(""" + INSERT INTO \0 + VALUES \0\0""", model.type(), bindVars, onConflictClause(versionAware)))) + .managed().prepare()); + } + + @Override + protected void doUpsertBatch(List batch, 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(List batch, 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(); + } +} diff --git a/storm-core/src/main/java/st/orm/core/spi/AbstractRef.java b/storm-core/src/main/java/st/orm/core/spi/AbstractRef.java index 5174b0e96..8fd2e93ee 100644 --- a/storm-core/src/main/java/st/orm/core/spi/AbstractRef.java +++ b/storm-core/src/main/java/st/orm/core/spi/AbstractRef.java @@ -18,6 +18,7 @@ import java.util.Objects; import st.orm.Data; import st.orm.Ref; +import st.orm.core.template.impl.LazySupplier; /** * Abstract implementation of {@link Ref} to have consistent implementations of {@link #hashCode()} @@ -30,9 +31,14 @@ * as-is.

* * @param record type. + * @param primary key type. * @since 1.3 */ -abstract class AbstractRef implements Ref { +abstract class AbstractRef extends BaseRef { + + AbstractRef(LazySupplier supplier, Class type, ID pk) { + super(supplier, type, pk); + } /** * Lazily computed row identity of the id. Computed outside construction because only map-keyed usage needs the @@ -72,7 +78,7 @@ public boolean equals(Object obj) { if (this == obj) { return true; } - if (obj instanceof AbstractRef other) { + if (obj instanceof AbstractRef other) { return Objects.equals(type(), other.type()) && Objects.equals(rowId(), other.rowId()); } diff --git a/storm-core/src/main/java/st/orm/core/spi/BaseRef.java b/storm-core/src/main/java/st/orm/core/spi/BaseRef.java new file mode 100644 index 000000000..3da7b75a8 --- /dev/null +++ b/storm-core/src/main/java/st/orm/core/spi/BaseRef.java @@ -0,0 +1,124 @@ +/* + * 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 static java.util.Objects.requireNonNull; + +import org.jspecify.annotations.Nullable; +import st.orm.Data; +import st.orm.Entity; +import st.orm.Ref; +import st.orm.core.repository.EntityRepository; +import st.orm.core.template.impl.LazySupplier; + +/** + * Base implementation for the attached {@link Ref} implementations: holds the record type, the primary key and + * the lazy supplier the record is fetched through. Identity semantics are left to the subclasses, which differ + * in whether they cache a normalized row identity. + * + * @param record type. + * @param primary key type. + * @since 1.14 + */ +abstract class BaseRef implements Ref { + private final LazySupplier supplier; + private final Class type; + private final ID pk; + + BaseRef(LazySupplier supplier, Class type, ID pk) { + this.supplier = requireNonNull(supplier, "supplier"); + this.type = requireNonNull(type, "type"); + this.pk = requireNonNull(pk, "pk"); + } + + /** + * The type of the record. + * + * @return the type of the record. + */ + @Override + public Class type() { + return type; + } + + /** + * Returns the record if it has already been fetched, without triggering a database call. + * + * @return the record if already loaded, or {@code null} if not yet fetched. + * @since 1.7 + */ + @Nullable + @Override + public T getOrNull() { + return supplier.value().orElse(null); + } + + /** + * Returns the primary key of the record. + * + *

This method is provided for convenience. If the type of the id is known, you can cast it to the appropriate + * type.

+ * + * @return the primary key as an Object. + */ + @Override + public ID id() { + return pk; + } + + /** + * Fetches the record from the database if the record has not been fetched yet. The record will be fetched at most + * once. + * + * @return the fetched record. + * @since 1.7 + */ + @Override + public T fetchOrNull() { + return supplier.get(); + } + + /** + * Returns whether this ref is attached to a database context and capable of fetching the record on demand. + * + *

A fetchable ref has access to a database connection and can attempt to retrieve the record when + * {@link #fetch()} or {@link #fetchOrNull()} is called. A non-fetchable (detached) ref can only return + * data that was already loaded at the time of its creation.

+ * + *

Note that this method indicates the capability to fetch, not a guarantee of success. A fetchable + * ref may still fail to retrieve a record if it has been deleted from the database or if the connection + * encounters an error.

+ * + * @return {@code true} if this ref can attempt to fetch from the database, {@code false} if it is detached. + * @since 1.7 + */ + @Override + public boolean isFetchable() { + return true; + } + + /** + * Returns a detached ref with the same identity but without data. The returned ref is not attached to a database + * context. To obtain an attached ref that can re-fetch the record, use + * {@link EntityRepository#unload(Entity) EntityRepository.unload()} instead. + * + * @return a detached ref with the same type and primary key but without cached data. + */ + @Override + public Ref unload() { + return Ref.of(type, pk); + } +} diff --git a/storm-core/src/main/java/st/orm/core/spi/Providers.java b/storm-core/src/main/java/st/orm/core/spi/Providers.java index e6314037f..315f89f0a 100644 --- a/storm-core/src/main/java/st/orm/core/spi/Providers.java +++ b/storm-core/src/main/java/st/orm/core/spi/Providers.java @@ -311,7 +311,7 @@ public static String getDatabaseProductName(DataSource dataSource) { } return DATABASE_PRODUCT_NAMES.computeIfAbsent(new DataSourceIdentity(dataSource, DATA_SOURCE_QUEUE), ignore -> { try (Connection connection = dataSource.getConnection()) { - return connection.getMetaData().getDatabaseProductName(); + return getDatabaseProductName(connection); } catch (SQLException e) { throw new PersistenceException("Failed to determine database product name.", e); } @@ -358,12 +358,7 @@ public static String getDatabaseProductName(Connection connection) { * @since 1.11 */ public static SqlDialect getSqlDialect(DataSource dataSource, StormConfig config) { - String productName = getDatabaseProductName(dataSource); - return enabled(SQL_DIALECT_PROVIDERS) - .filter(p -> p.supports(productName)) - .map(p -> p.getSqlDialect(config)) - .findFirst() - .orElseThrow(); + return sqlDialectFor(getDatabaseProductName(dataSource), config); } /** @@ -376,7 +371,10 @@ public static SqlDialect getSqlDialect(DataSource dataSource, StormConfig config * @since 1.11 */ public static SqlDialect getSqlDialect(Connection connection, StormConfig config) { - String productName = getDatabaseProductName(connection); + return sqlDialectFor(getDatabaseProductName(connection), config); + } + + private static SqlDialect sqlDialectFor(String productName, StormConfig config) { return enabled(SQL_DIALECT_PROVIDERS) .filter(p -> p.supports(productName)) .map(p -> p.getSqlDialect(config)) diff --git a/storm-core/src/main/java/st/orm/core/spi/RefImpl.java b/storm-core/src/main/java/st/orm/core/spi/RefImpl.java index ec520a1bf..2bdb65894 100644 --- a/storm-core/src/main/java/st/orm/core/spi/RefImpl.java +++ b/storm-core/src/main/java/st/orm/core/spi/RefImpl.java @@ -15,13 +15,8 @@ */ package st.orm.core.spi; -import static java.util.Objects.requireNonNull; - -import org.jspecify.annotations.Nullable; import st.orm.Data; -import st.orm.Entity; import st.orm.Ref; -import st.orm.core.repository.EntityRepository; import st.orm.core.template.impl.LazySupplier; /** @@ -30,92 +25,9 @@ * @param record type. * @param primary key type. */ -final class RefImpl extends AbstractRef { - private final LazySupplier supplier; - private final Class type; - private final ID pk; +final class RefImpl extends AbstractRef { RefImpl(LazySupplier supplier, Class type, ID pk) { - this.supplier = requireNonNull(supplier, "supplier"); - this.type = requireNonNull(type, "type"); - this.pk = requireNonNull(pk, "pk"); - } - - /** - * The type of the record. - * - * @return the type of the record. - */ - @Override - public Class type() { - return type; - } - - /** - * Returns the record if it has already been fetched, without triggering a database call. - * - * @return the record if already loaded, or {@code null} if not yet fetched. - * @since 1.7 - */ - @Nullable - @Override - public T getOrNull() { - return supplier.value().orElse(null); - } - - /** - * Returns the primary key of the record. - * - *

This method is provided for convenience. If the type of the id is known, you can cast it to the appropriate - * type.

- * - * @return the primary key as an Object. - */ - @Override - public ID id() { - return pk; - } - - /** - * Fetches the record from the database if the record has not been fetched yet. The record will be fetched at most - * once. - * - * @return the fetched record. - * @since 1.7 - */ - @Override - public T fetchOrNull() { - return supplier.get(); - } - - /** - * Returns whether this ref is attached to a database context and capable of fetching the record on demand. - * - *

A fetchable ref has access to a database connection and can attempt to retrieve the record when - * {@link #fetch()} or {@link #fetchOrNull()} is called. A non-fetchable (detached) ref can only return - * data that was already loaded at the time of its creation.

- * - *

Note that this method indicates the capability to fetch, not a guarantee of success. A fetchable - * ref may still fail to retrieve a record if it has been deleted from the database or if the connection - * encounters an error.

- * - * @return {@code true} if this ref can attempt to fetch from the database, {@code false} if it is detached. - * @since 1.7 - */ - @Override - public boolean isFetchable() { - return true; - } - - /** - * Returns a detached ref with the same identity but without data. The returned ref is not attached to a database - * context. To obtain an attached ref that can re-fetch the record, use - * {@link EntityRepository#unload(Entity) EntityRepository.unload()} instead. - * - * @return a detached ref with the same type and primary key but without cached data. - */ - @Override - public Ref unload() { - return Ref.of(type, pk); + super(supplier, type, pk); } } diff --git a/storm-core/src/main/java/st/orm/core/spi/ScalarRefImpl.java b/storm-core/src/main/java/st/orm/core/spi/ScalarRefImpl.java index bd8fd7d33..fc9c3836f 100644 --- a/storm-core/src/main/java/st/orm/core/spi/ScalarRefImpl.java +++ b/storm-core/src/main/java/st/orm/core/spi/ScalarRefImpl.java @@ -15,14 +15,9 @@ */ package st.orm.core.spi; -import static java.util.Objects.requireNonNull; - import java.util.Objects; -import org.jspecify.annotations.Nullable; import st.orm.Data; -import st.orm.Entity; import st.orm.Ref; -import st.orm.core.repository.EntityRepository; import st.orm.core.template.impl.LazySupplier; /** @@ -38,84 +33,15 @@ * @param record type. * @param primary key type. */ -final class ScalarRefImpl implements Ref { - private final LazySupplier supplier; - private final Class type; - private final ID pk; +final class ScalarRefImpl extends BaseRef { ScalarRefImpl(LazySupplier supplier, Class type, ID pk) { - this.supplier = requireNonNull(supplier, "supplier"); - this.type = requireNonNull(type, "type"); - this.pk = requireNonNull(pk, "pk"); - } - - /** - * The type of the record. - * - * @return the type of the record. - */ - @Override - public Class type() { - return type; - } - - /** - * Returns the record if it has already been fetched, without triggering a database call. - * - * @return the record if already loaded, or {@code null} if not yet fetched. - */ - @Nullable - @Override - public T getOrNull() { - return supplier.value().orElse(null); - } - - /** - * Returns the primary key of the record. - * - * @return the primary key as an Object. - */ - @Override - public ID id() { - return pk; - } - - /** - * Fetches the record from the database if the record has not been fetched yet. The record will be fetched at - * most once. - * - * @return the fetched record. - */ - @Override - public T fetchOrNull() { - return supplier.get(); - } - - /** - * Returns whether this ref is attached to a database context and capable of fetching the record on demand. - * - * @return {@code true}, this implementation is created attached to a database context. - */ - @Override - public boolean isFetchable() { - return true; - } - - /** - * Returns a detached ref with the same identity but without data. The returned ref is not attached to a - * database context. To obtain an attached ref that can re-fetch the record, use - * {@link EntityRepository#unload(Entity) EntityRepository.unload()} instead. - * - * @return a detached ref with the same type and primary key but without cached data. - */ - @Override - public Ref unload() { - return Ref.of(type, pk); + super(supplier, type, pk); } @Override public int hashCode() { - return RowIdentity.hash(type, pk); + return RowIdentity.hash(type(), id()); } @Override @@ -126,19 +52,19 @@ public boolean equals(Object obj) { if (obj instanceof ScalarRefImpl other) { // Both sides hold their identity as the raw pk of a class that is its own row identity; the type gate guarantees the // pk classes match. - return Objects.equals(type, other.type) && pk.equals(other.pk); + return Objects.equals(type(), other.type()) && id().equals(other.id()); } if (obj instanceof Ref l) { - return RowIdentity.refEquals(type, pk, l); + return RowIdentity.refEquals(type(), id(), l); } return false; } @Override public String toString() { - Class type = this.type; + Class type = type(); return type == Record.class - ? "%s".formatted(pk) - : "%s@%s".formatted(type.getSimpleName(), pk); + ? "%s".formatted(id()) + : "%s@%s".formatted(type.getSimpleName(), id()); } } diff --git a/storm-core/src/main/java/st/orm/core/spi/TransactionScope.java b/storm-core/src/main/java/st/orm/core/spi/TransactionScope.java index 947310183..50970468c 100644 --- a/storm-core/src/main/java/st/orm/core/spi/TransactionScope.java +++ b/storm-core/src/main/java/st/orm/core/spi/TransactionScope.java @@ -144,11 +144,7 @@ public static TransactionScope create(Options options, @Nullable TransactionScop * path are not observed. */ public static @Nullable TransactionContext resolveContext(TransactionTemplateProvider provider) { - var scope = CURRENT.get(); - if (scope != null) { - return scope.getOrMaterializeContext(provider); - } - return provider.getTransactionTemplate().currentContext().orElse(null); + return resolveContext(provider, null); } /** diff --git a/storm-core/src/main/java/st/orm/core/template/QueryBuilder.java b/storm-core/src/main/java/st/orm/core/template/QueryBuilder.java index 21317de46..e38bb10c9 100644 --- a/storm-core/src/main/java/st/orm/core/template/QueryBuilder.java +++ b/storm-core/src/main/java/st/orm/core/template/QueryBuilder.java @@ -352,15 +352,6 @@ public final QueryBuilder where(Navigable Metamodel[] asMetamodels(Navigable[] paths) { - Metamodel[] result = new Metamodel[paths.length]; - for (int i = 0; i < paths.length; i++) { - result[i] = paths[i].asMetamodel(); - } - return result; - } - /** * Adds a WHERE clause that matches the specified ref. The ref can represent any of the related tables in the * table graph. diff --git a/storm-core/src/main/java/st/orm/core/template/impl/DeleteBuilderImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/DeleteBuilderImpl.java index 07368bd5c..bd34b81f9 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/DeleteBuilderImpl.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/DeleteBuilderImpl.java @@ -195,24 +195,7 @@ private TemplateString toTemplateString() { } else { template = TemplateString.combine(TemplateString.of("SELECT "), getPrimaryKeyTemplate(true), TemplateString.raw("\nFROM \0", from(fromType, true))); } - //noinspection DuplicatedCode - if (!join.isEmpty()) { - template = join.stream() - .reduce(template, - (acc, join) -> TemplateString.combine(acc, wrap(join)), - TemplateString::combine); - } - if (!where.isEmpty()) { - if (where.size() == 1) { - template = TemplateString.combine(template, TemplateString.of("\nWHERE "), wrap(where.getFirst())); - } else { - TemplateString whereClause = where.stream() - .map(w -> TemplateString.combine(TemplateString.of("("), wrap(w), TemplateString.of(")"))) - .reduce((a, b) -> TemplateString.combine(a, TemplateString.of("\n AND "), b)) - .orElseThrow(); - template = TemplateString.combine(template, TemplateString.of("\nWHERE "), whereClause); - } - } + template = appendJoinsAndWhere(template); if (!supportsJoin()) { template = TemplateString.combine(TemplateString.raw("DELETE\nFROM \0\nWHERE (", from(fromType, false)), getPrimaryKeyTemplate(false), TemplateString.of(") IN ("), wrap(subquery(template, false)), TemplateString.of(")")); diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java b/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java index 5aba68695..382efed2f 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java @@ -37,8 +37,6 @@ import st.orm.PK; import st.orm.core.spi.Instantiators; import st.orm.core.spi.Nullability; -import st.orm.core.spi.ORMReflection; -import st.orm.core.spi.Providers; import st.orm.core.spi.RefFactory; import st.orm.core.template.SqlTemplateException; import st.orm.mapping.Instantiator; @@ -48,8 +46,6 @@ */ public final class ObjectMapperFactory { - private static final ORMReflection REFLECTION = Providers.getORMReflection(); - private ObjectMapperFactory() { } diff --git a/storm-core/src/main/java/st/orm/core/template/impl/PredicateBuilderFactory.java b/storm-core/src/main/java/st/orm/core/template/impl/PredicateBuilderFactory.java index 20077fff6..45f1bbc1a 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/PredicateBuilderFactory.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/PredicateBuilderFactory.java @@ -89,22 +89,4 @@ static PredicateBuilder createWithId( Iterable o) { return new PredicateBuilderImpl<>(wrap(new ObjectExpression(path, operator, o))); } - /** - * Creates a new instance of {@link PredicateBuilder} for the specified path, operator, and values with an ID. - * - * @param path the metamodel path representing the field to be queried - * @param operator the operator to be used in the predicate - * @param o the values to be used in the predicate - * @param the type of the record - * @param the type of the result - * @param the type of the ID - * @param the type of the values - * @return a new instance of {@link PredicateBuilder} - */ - static PredicateBuilder createRefWithId( - Metamodel path, - Operator operator, - Iterable> o) { - return new PredicateBuilderImpl<>(wrap(new ObjectExpression(path, operator, o))); - } } 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 3f2f13e3a..18fe61706 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 @@ -43,7 +43,6 @@ import java.util.List; import java.util.TimeZone; import java.util.UUID; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import java.util.function.Predicate; import java.util.function.Supplier; @@ -572,24 +571,6 @@ private static void setParameters(PreparedStatement preparedStatement, } } - @FunctionalInterface - interface SqlRunnable { void run() throws SQLException; } - - private static void setObjectOr(PreparedStatement ps, - AtomicBoolean supportsSetObject, - SqlRunnable typedSetter, - SqlRunnable legacyFallback) throws SQLException { - if (supportsSetObject.get()) { - try { - typedSetter.run(); - return; - } catch (SQLFeatureNotSupportedException e) { - supportsSetObject.set(false); - } - } - legacyFallback.run(); - } - @Override public @Nullable DataSource dataSource() { return dataSource; diff --git a/storm-core/src/main/java/st/orm/core/template/impl/QueryBuilderImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/QueryBuilderImpl.java index 884631c19..4cbf375bc 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/QueryBuilderImpl.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/QueryBuilderImpl.java @@ -176,6 +176,31 @@ abstract QueryBuilder copyWith(QueryTemplate queryTemplate, */ protected abstract boolean supportsJoin(); + /** + * Appends the registered joins and the WHERE clause, with multiple where-conditions AND-ed together, to the + * given template. + */ + protected final TemplateString appendJoinsAndWhere(TemplateString template) { + if (!join.isEmpty()) { + template = join.stream() + .reduce(template, + (acc, join) -> TemplateString.combine(acc, wrap(join)), + TemplateString::combine); + } + if (!where.isEmpty()) { + if (where.size() == 1) { + template = TemplateString.combine(template, TemplateString.of("\nWHERE "), wrap(where.getFirst())); + } else { + TemplateString whereClause = where.stream() + .map(w -> TemplateString.combine(TemplateString.of("("), wrap(w), TemplateString.of(")"))) + .reduce((a, b) -> TemplateString.combine(a, TemplateString.of("\n AND "), b)) + .orElseThrow(); + template = TemplateString.combine(template, TemplateString.of("\nWHERE "), whereClause); + } + } + return template; + } + /** * Resolving a reference selects the referenced table's columns into the row that holds the reference, which only a * statement that selects rows can do. diff --git a/storm-core/src/main/java/st/orm/core/template/impl/QueryModelImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/QueryModelImpl.java index 12fdee18b..402486185 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/QueryModelImpl.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/QueryModelImpl.java @@ -607,7 +607,7 @@ private static boolean isPrimitiveCompatible(Object o, Class clazz) { * @return the resolved value. * @throws SqlTemplateException if the value is invalid in this context. */ - private Object resolveElements(@Nullable Object value) throws SqlTemplateException { + private @Nullable Object resolveElements(@Nullable Object value) throws SqlTemplateException { return switch (value) { case TemplateString ignore -> throw new SqlTemplateException("TemplateString is not allowed as a string template value."); case Stream ignore -> throw new SqlTemplateException("Stream is not supported as a string template value. Collect the Stream into a List before passing it."); diff --git a/storm-core/src/main/java/st/orm/core/template/impl/RecordMapper.java b/storm-core/src/main/java/st/orm/core/template/impl/RecordMapper.java index cebb9c79d..683deb9ff 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/RecordMapper.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/RecordMapper.java @@ -1272,9 +1272,12 @@ private static RecordStep recordStep(RecordField field, * @return the reader for the target's primary key. * @throws SqlTemplateException if the type declares no primary key. */ - private static KeyReader keyReader(RecordType type, FetchPlan fetchPlan) throws SqlTemplateException { - RecordField pkField = findPkField(type.type()).orElseThrow(() -> new SqlTemplateException( - "Cannot resolve a reference to %s: the type declares no primary key.".formatted(type.type().getSimpleName()))); + /** + * Returns the flat column offset of the primary key field: the sum of the column counts of all fields + * preceding it. + */ + private static int pkColumnOffset(RecordType type, RecordField pkField, FetchPlan fetchPlan) + throws SqlTemplateException { int offset = 0; for (RecordField field : type.fields()) { if (field.name().equals(pkField.name())) { @@ -1282,6 +1285,13 @@ private static KeyReader keyReader(RecordType type, FetchPlan fetchPlan) throws } offset += getFieldColumnCount(field, fetchPlan); } + return offset; + } + + private static KeyReader keyReader(RecordType type, FetchPlan fetchPlan) throws SqlTemplateException { + RecordField pkField = findPkField(type.type()).orElseThrow(() -> new SqlTemplateException( + "Cannot resolve a reference to %s: the type declares no primary key.".formatted(type.type().getSimpleName()))); + int offset = pkColumnOffset(type, pkField, fetchPlan); int columnCount = getFieldColumnCount(pkField, fetchPlan); Constructor keyConstructor = null; if (columnCount > 1) { @@ -1401,14 +1411,7 @@ private static PkInfo calculatePkInfo(RecordType type, FetchPlan fetchPlan) thro return PkInfo.NONE; } RecordField pkField = pkFieldOpt.get(); - // Calculate the offset: sum of column counts for all fields before the PK field. - int offset = 0; - for (RecordField field : type.fields()) { - if (field.name().equals(pkField.name())) { - break; - } - offset += getFieldColumnCount(field, fetchPlan); - } + int offset = pkColumnOffset(type, pkField, fetchPlan); // Calculate how many columns the PK spans. int pkColumnCount = getFieldColumnCount(pkField, fetchPlan); // For composite PKs (record types), we need the constructor. diff --git a/storm-core/src/main/java/st/orm/core/template/impl/RecordValidation.java b/storm-core/src/main/java/st/orm/core/template/impl/RecordValidation.java index 11e3ec87f..eaafac6d8 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/RecordValidation.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/RecordValidation.java @@ -54,8 +54,6 @@ import st.orm.Ref; import st.orm.StormConfig; import st.orm.Version; -import st.orm.core.spi.ORMReflection; -import st.orm.core.spi.Providers; import st.orm.core.spi.TypeDiscovery; import st.orm.core.template.SqlTemplate; import st.orm.core.template.SqlTemplate.Parameter; @@ -70,7 +68,6 @@ @SuppressWarnings("ALL") final class RecordValidation { - private static final ORMReflection REFLECTION = Providers.getORMReflection(); private static final Logger LOGGER = LoggerFactory.getLogger("st.orm.validation"); private RecordValidation() { diff --git a/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java b/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java index ba50c0153..32cacdbec 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java @@ -255,9 +255,7 @@ public List validateAndReport(Iterable> types, boo *

This is a convenience method for "warn" mode: validation issues are logged but never cause an exception.

*/ public void validateOrWarn() { - LOGGER.info("Validating Data types for schema compatibility."); - List> types = TypeDiscovery.getDataTypes(); - reportErrors(validate(types), false, types.size()); + validateAndReport(false); } /** diff --git a/storm-core/src/main/java/st/orm/core/template/impl/SelectBuilderImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/SelectBuilderImpl.java index d458110fb..ef7e0362b 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/SelectBuilderImpl.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/SelectBuilderImpl.java @@ -254,24 +254,7 @@ private TemplateString toTemplateString(TemplateString selectClause, boolean wit if (hasLock && queryTemplate.dialect().applyLockHintAfterFrom()) { template = TemplateString.combine(template, TemplateString.of("\n"), forLock); } - //noinspection DuplicatedCode - if (!join.isEmpty()) { - template = join.stream() - .reduce(template, - (acc, join) -> TemplateString.combine(acc, wrap(join)), - TemplateString::combine); - } - if (!where.isEmpty()) { - if (where.size() == 1) { - template = TemplateString.combine(template, TemplateString.of("\nWHERE "), wrap(where.getFirst())); - } else { - TemplateString whereClause = where.stream() - .map(w -> TemplateString.combine(TemplateString.of("("), wrap(w), TemplateString.of(")"))) - .reduce((a, b) -> TemplateString.combine(a, TemplateString.of("\n AND "), b)) - .orElseThrow(); - template = TemplateString.combine(template, TemplateString.of("\nWHERE "), whereClause); - } - } + template = appendJoinsAndWhere(template); if (!groupBy.isEmpty()) { TemplateString groupByClause = groupBy.stream() .reduce((a, b) -> TemplateString.combine(a, TemplateString.of(", "), b)) diff --git a/storm-core/src/main/java/st/orm/core/template/impl/SqlInterceptorManager.java b/storm-core/src/main/java/st/orm/core/template/impl/SqlInterceptorManager.java index 0d0c8f4b7..523b3b505 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/SqlInterceptorManager.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/SqlInterceptorManager.java @@ -226,13 +226,7 @@ private SqlInterceptorManager() { * @param interceptor the interceptor to call for each SQL statement. */ public static void registerGlobalInterceptor(UnaryOperator interceptor) { - LOCK.writeLock().lock(); - try { - GLOBAL_OPERATORS.add(interceptor); - globalOperatorCount = GLOBAL_OPERATORS.size(); - } finally { - LOCK.writeLock().unlock(); - } + addGlobalOperator(interceptor); } /** @@ -241,13 +235,7 @@ public static void registerGlobalInterceptor(UnaryOperator interceptor) { * @param observer the observer to call for each SQL statement. */ public static void registerGlobalObserver(Consumer observer) { - LOCK.writeLock().lock(); - try { - GLOBAL_OPERATORS.add(observer); - globalOperatorCount = GLOBAL_OPERATORS.size(); - } finally { - LOCK.writeLock().unlock(); - } + addGlobalOperator(observer); } /** @@ -256,13 +244,7 @@ public static void registerGlobalObserver(Consumer observer) { * @param observer the observer to unregister. */ public static void unregisterGlobalObserver(UnaryOperator observer) { - LOCK.writeLock().lock(); - try { - GLOBAL_OPERATORS.remove(observer); - globalOperatorCount = GLOBAL_OPERATORS.size(); - } finally { - LOCK.writeLock().unlock(); - } + removeGlobalOperator(observer); } /** @@ -271,9 +253,23 @@ public static void unregisterGlobalObserver(UnaryOperator observer) { * @param observer the observer to unregister. */ public static void unregisterGlobalObserver(Consumer observer) { + removeGlobalOperator(observer); + } + + private static void addGlobalOperator(Object operator) { + LOCK.writeLock().lock(); + try { + GLOBAL_OPERATORS.add(operator); + globalOperatorCount = GLOBAL_OPERATORS.size(); + } finally { + LOCK.writeLock().unlock(); + } + } + + private static void removeGlobalOperator(Object operator) { LOCK.writeLock().lock(); try { - GLOBAL_OPERATORS.remove(observer); + GLOBAL_OPERATORS.remove(operator); globalOperatorCount = GLOBAL_OPERATORS.size(); } finally { LOCK.writeLock().unlock(); diff --git a/storm-core/src/main/java/st/orm/core/template/impl/SqlTemplateImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/SqlTemplateImpl.java index b63237a6c..4f46dde33 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/SqlTemplateImpl.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/SqlTemplateImpl.java @@ -474,74 +474,60 @@ Sql process(TemplateString template, boolean applyInterceptors) throws SqlTempla * that differ only in how far a collection expanded share it. */ private Object getShapeKey(BindingContext bindingContext) { - try { - var fragments = bindingContext.fragments(); - var elements = bindingContext.elements(); - var shapeGenerator = shapeGenerator(); - var shapeKey = new ArrayList<>(fragments.size() + elements.size()); - for (int i = 0, size = fragments.size(); i < size; i++) { - shapeKey.add(fragments.get(i)); - if (i < elements.size()) { - var element = elements.get(i); - if (element instanceof Wrapped(var wrapped)) { - for (var e : wrapped) { - if (!e.synthetic()) { - var key = getElementProcessor(e.element()).getShapeKey(e.element(), shapeGenerator); - if (key != null) { - shapeKey.add(key); - } else { - return null; - } - } - } - } else { - var key = getElementProcessor(element).getShapeKey(element, shapeGenerator); - if (key != null) { - shapeKey.add(key); - } else { - return null; - } - } - } - } - return shapeKey; - } catch (SqlTemplateException e) { - throw new UncheckedSqlTemplateException(e); - } + var shapeGenerator = shapeGenerator(); + return buildKey(bindingContext, + element -> getElementProcessor(element).getShapeKey(element, shapeGenerator)); } private Object getCompilationKey(BindingContext bindingContext) { + return buildKey(bindingContext, + element -> getElementProcessor(element).getCompilationKey(element, keyGenerator)); + } + + /** + * The per-element contribution to a template key, or {@code null} when the element does not support keying. + */ + @FunctionalInterface + private interface KeyExtractor { + @Nullable Object apply(Element element) throws SqlTemplateException; + } + + /** + * Interleaves the template's fragments with the per-element keys the extractor produces, or returns + * {@code null} as soon as any element yields no key. + */ + private @Nullable Object buildKey(BindingContext bindingContext, KeyExtractor extractor) { try { var fragments = bindingContext.fragments(); var elements = bindingContext.elements(); // Runs for every processed template; sized for the common case of one key per fragment and element. - var compilationKey = new ArrayList<>(fragments.size() + elements.size()); + var templateKey = new ArrayList<>(fragments.size() + elements.size()); for (int i = 0, size = fragments.size(); i < size; i++) { - compilationKey.add(fragments.get(i)); + templateKey.add(fragments.get(i)); if (i < elements.size()) { var element = elements.get(i); if (element instanceof Wrapped(var wrapped)) { for (var e : wrapped) { - if (!e.synthetic()) { // Ignore synthetic elements for the compilation key. - var key = getElementProcessor(e.element()).getCompilationKey(e.element(), keyGenerator); + if (!e.synthetic()) { // Ignore synthetic elements for the key. + var key = extractor.apply(e.element()); if (key != null) { - compilationKey.add(key); + templateKey.add(key); } else { return null; } } } } else { - var key = getElementProcessor(element).getCompilationKey(element, keyGenerator); + var key = extractor.apply(element); if (key != null) { - compilationKey.add(key); + templateKey.add(key); } else { return null; } } } } - return compilationKey; + return templateKey; } catch (SqlTemplateException e) { throw new UncheckedSqlTemplateException(e); } diff --git a/storm-core/src/main/java/st/orm/core/template/impl/TemplatePreparation.java b/storm-core/src/main/java/st/orm/core/template/impl/TemplatePreparation.java index 412ba29fe..861dfa7b0 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/TemplatePreparation.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/TemplatePreparation.java @@ -330,15 +330,7 @@ private Element resolveObjectElement( ) throws SqlTemplateException { String previous = removeComments(previousFragment, template.dialect()).stripTrailing().toUpperCase(); return switch (operation) { - case SELECT, DELETE, UNDEFINED -> { - if (endsWithKeyword(previous, "WHERE")) { - if (o != null) { - yield where(o); - } - throw new SqlTemplateException("Non-null object expected after WHERE."); - } - yield param(o); - } + case SELECT, DELETE, UNDEFINED -> whereOrParam(previous, o); case INSERT -> { if (endsWithKeyword(previous, "VALUES")) { if (o instanceof Data r) { @@ -346,13 +338,7 @@ private Element resolveObjectElement( } throw new SqlTemplateException("Record expected after VALUES."); } - if (endsWithKeyword(previous, "WHERE")) { - if (o != null) { - yield where(o); - } - throw new SqlTemplateException("Non-null object expected after WHERE."); - } - yield param(o); + yield whereOrParam(previous, o); } case UPDATE -> { if (endsWithKeyword(previous, "SET")) { @@ -361,17 +347,25 @@ private Element resolveObjectElement( } throw new SqlTemplateException("Record expected after SET."); } - if (endsWithKeyword(previous, "WHERE")) { - if (o != null) { - yield where(o); - } - throw new SqlTemplateException("Non-null object expected after WHERE."); - } - yield param(o); + yield whereOrParam(previous, o); } }; } + /** + * Resolves an object in a position where a where-object is accepted: after WHERE the object becomes a + * where-element (and must be non-null), anywhere else it becomes a parameter. + */ + private Element whereOrParam(String previous, @Nullable Object o) throws SqlTemplateException { + if (endsWithKeyword(previous, "WHERE")) { + if (o != null) { + return where(o); + } + throw new SqlTemplateException("Non-null object expected after WHERE."); + } + return param(o); + } + /** * Resolves an array value into an element. * @@ -1170,19 +1164,7 @@ private void deriveAutoJoins( if (beyondRef && !isReferencedBeyond(referenced.joined(), pkPath)) { continue; } - String fromAlias; - if (fkName == null) { - fromAlias = aliasMapper.getAlias( - table, - fkPath, - INNER, - template.dialect(), - () -> new SqlTemplateException("Table %s for From not found at path %s." - .formatted(type.type().getSimpleName(), fkPath)) - ); - } else { - fromAlias = fkName; - } + String fromAlias = resolveFromAlias(fkName, table, fkPath, type, aliasMapper); RecordType fieldType = getRecordType(field.type()); boolean effectiveOuterJoin = outerJoin || field.nullable(); String alias = addForeignKeyJoin(table, rootTable, field, fieldType, fromAlias, fkPath, @@ -1198,24 +1180,36 @@ private void deriveAutoJoins( if (beyondRef && !isReferencedBeyond(referenced.joined(), pkPath)) { continue; } - String fromAlias; - if (fkName == null) { - fromAlias = aliasMapper.getAlias( - table, - fkPath, - INNER, - template.dialect(), - () -> new SqlTemplateException("Table %s for From not found at path %s." - .formatted(type.type().getSimpleName(), fkPath)) - ); - } else { - fromAlias = fkName; - } + String fromAlias = resolveFromAlias(fkName, table, fkPath, type, aliasMapper); deriveAutoJoins(getRecordType(field.type()), table, copy, fromAlias, outerJoin, beyondRef, context); } } } + /** + * Resolves the alias a derived join starts from: the enclosing foreign key's name when the traversal is inside + * one, or the alias registered for the table at the foreign key path otherwise. + */ + private String resolveFromAlias( + @Nullable String fkName, + Class table, + String fkPath, + RecordType type, + AliasMapper aliasMapper + ) throws SqlTemplateException { + if (fkName != null) { + return fkName; + } + return aliasMapper.getAlias( + table, + fkPath, + INNER, + template.dialect(), + () -> new SqlTemplateException("Table %s for From not found at path %s." + .formatted(type.type().getSimpleName(), fkPath)) + ); + } + /** * Generates a foreign key join from {@code fromAlias} on {@code table} to the table backing {@code fieldType}, * registers the new alias and foreign key mapping, and appends the join to {@code joins}. The join is marked as an diff --git a/storm-core/src/main/java/st/orm/core/template/impl/TemplateProcessor.java b/storm-core/src/main/java/st/orm/core/template/impl/TemplateProcessor.java index 1b91ea1bd..04d078ff1 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/TemplateProcessor.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/TemplateProcessor.java @@ -1054,19 +1054,7 @@ private final class BindingSession implements TemplateBinder { * @throws SqlTemplateException if binding fails. */ void bindElements(BindingContext context) throws SqlTemplateException { - for (Element element : context.elements()) { - if (element instanceof Wrapped(var wrapped)) { - for (var e : wrapped) { - if (!e.synthetic()) { - var hint = nextHint(); - getElementProcessor(e.element()).bind(e.element(), this, hint); - } - } - } else { - var hint = nextHint(); - getElementProcessor(element).bind(element, this, hint); - } - } + bindAll(context.elements()); if (hintCursor != bindHints.size()) { throw new UncheckedSqlTemplateException(new SqlTemplateException( "Bind hint consumption mismatch. Used %d hints but %d were produced." @@ -1118,7 +1106,14 @@ private void assertAllHintsConsumed() { * @throws SqlTemplateException if binding fails. */ private void bindPreparedAttached(PreparedTemplate preparedTemplate) throws SqlTemplateException { - for (var element : preparedTemplate.context().elements()) { + bindAll(preparedTemplate.context().elements()); + } + + /** + * Binds the given elements, consuming a bind hint from the shared tape for each non-synthetic element. + */ + private void bindAll(List elements) throws SqlTemplateException { + for (Element element : elements) { if (element instanceof Wrapped(var wrapped)) { for (var e : wrapped) { if (!e.synthetic()) { diff --git a/storm-foundation/src/main/java/st/orm/LoadedRef.java b/storm-foundation/src/main/java/st/orm/LoadedRef.java new file mode 100644 index 000000000..424457345 --- /dev/null +++ b/storm-foundation/src/main/java/st/orm/LoadedRef.java @@ -0,0 +1,69 @@ +/* + * 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; + +import static java.util.Objects.requireNonNull; + +/** + * Detached {@link Ref} implementation wrapping an already loaded record along with its row identity. + * + *

The id is stored rather than derived so one implementation serves both entities, whose id comes from the + * record itself, and projections, whose row identity is supplied separately. The ref is not attached to a + * database context; the wrapped record is all it can return.

+ * + * @param record type. + * @since 1.14 + */ +final class LoadedRef extends AbstractRef { + private final T value; + private final Object id; + + LoadedRef(T value, Object id) { + this.value = requireNonNull(value, "value"); + this.id = id; + } + + @Override + public Class type() { + //noinspection unchecked + return (Class) value.getClass(); + } + + @Override + public Object id() { + return id; + } + + @Override + public T getOrNull() { + return value; + } + + @Override + public T fetchOrNull() { + return value; + } + + @Override + public boolean isFetchable() { + return false; + } + + @Override + public Ref unload() { + return Ref.of(type(), id()); + } +} diff --git a/storm-foundation/src/main/java/st/orm/Ref.java b/storm-foundation/src/main/java/st/orm/Ref.java index db51c3642..40db58877 100644 --- a/storm-foundation/src/main/java/st/orm/Ref.java +++ b/storm-foundation/src/main/java/st/orm/Ref.java @@ -75,46 +75,8 @@ static Ref of(Class type, ID pk) { * @return a fully loaded ref instance for the provided entity. */ static > Ref of(E entity) { - class DetachedEntity> extends AbstractRef { - private final TE entity; - - DetachedEntity(TE entity) { - requireNonNull(entity, "Entity cannot be null."); - this.entity = entity; - } - - @Override - public Class type() { - //noinspection unchecked - return (Class) entity.getClass(); - } - - @Override - public Object id() { - return entity.id(); - } - - @Override - public TE getOrNull() { - return entity; - } - - @Override - public TE fetchOrNull() { - return entity; - } - - @Override - public boolean isFetchable() { - return false; - } - - @Override - public Ref unload() { - return Ref.of(type(), id()); - } - } - return new DetachedEntity<>(entity); + requireNonNull(entity, "Entity cannot be null."); + return new LoadedRef<>(entity, entity.id()); } /** @@ -132,47 +94,9 @@ public Ref unload() { * @return a fully loaded ref instance for the provided projection. */ static

, ID> Ref

of(P projection, ID id) { - class DetachedProjection, TID> extends AbstractRef { - private final TID id; - private final TE projection; - - DetachedProjection(TID id, TE projection) { - this.id = requireNonNull(id, "ID cannot be null."); - this.projection = requireNonNull(projection, "Projection cannot be null."); - } - - @Override - public Class type() { - //noinspection unchecked - return (Class) projection.getClass(); - } - - @Override - public Object id() { - return id; - } - - @Override - public TE getOrNull() { - return projection; - } - - @Override - public TE fetchOrNull() { - return projection; - } - - @Override - public boolean isFetchable() { - return false; - } - - @Override - public Ref unload() { - return Ref.of(type(), id()); - } - } - return new DetachedProjection<>(id, projection); + requireNonNull(id, "ID cannot be null."); + requireNonNull(projection, "Projection cannot be null."); + return new LoadedRef<>(projection, id); } /** diff --git a/storm-h2/pom.xml b/storm-h2/pom.xml index 100e2d5a8..ff2de8c12 100644 --- a/storm-h2/pom.xml +++ b/storm-h2/pom.xml @@ -37,7 +37,7 @@ maven-surefire-plugin false @@ -101,6 +101,12 @@ provided + + st.orm + storm-test + ${project.version} + test + org.springframework.boot spring-boot-starter-test diff --git a/storm-h2/src/main/java/st/orm/spi/h2/H2SqlDialect.java b/storm-h2/src/main/java/st/orm/spi/h2/H2SqlDialect.java index 9cc1cd967..e95e080bb 100644 --- a/storm-h2/src/main/java/st/orm/spi/h2/H2SqlDialect.java +++ b/storm-h2/src/main/java/st/orm/spi/h2/H2SqlDialect.java @@ -26,14 +26,12 @@ import java.sql.SQLException; import java.util.Set; import java.util.UUID; -import java.util.regex.Pattern; import java.util.stream.Stream; import st.orm.Operator; import st.orm.StormConfig; import st.orm.core.spi.DefaultSqlDialect; -import st.orm.core.template.SqlDialect; -public class H2SqlDialect extends DefaultSqlDialect implements SqlDialect { +public class H2SqlDialect extends DefaultSqlDialect { public H2SqlDialect() { } @@ -53,14 +51,6 @@ public String name() { return "H2"; } - /** - * H2 does not support aliasing the target table in DELETE statements. - */ - @Override - public boolean supportsDeleteAlias() { - return false; - } - /** * H2 supports multi-value tuples in the IN clause. */ @@ -69,19 +59,6 @@ public boolean supportsMultiValueTuples() { return true; } - private static final Pattern H2_IDENTIFIER = Pattern.compile("^[A-Za-z][A-Za-z0-9_]*$"); - - /** - * Returns the pattern for valid identifiers. - * - * @return the pattern for valid identifiers. - * @since 1.11 - */ - @Override - public Pattern getValidIdentifierPattern() { - return H2_IDENTIFIER; - } - private static final Set H2_KEYWORDS = Stream.concat(ANSI_KEYWORDS.stream(), Stream.of( "AUTOINCREMENT", "CACHED", "EXPLAIN", "IF", "ILIKE", "INDEX", "KEY", "LIMIT", "MEMORY", "MINUS", "OFFSET", "QUALIFY", "REGEXP", "ROWNUM", "SYSDATE", "SYSTIME", @@ -111,40 +88,6 @@ public String escape(String name) { return "\"%s\"".formatted(name.replace("\"", "\"\"")); } - /** - * Regex for double-quoted identifiers (handling doubled double quotes as escapes). - */ - private static final Pattern IDENTIFIER_PATTERN = Pattern.compile( - "\"(?:\"\"|[^\"])*\"" - ); - - /** - * Returns the pattern for identifiers. - * - * @return the pattern for identifiers. - */ - @Override - public Pattern getIdentifierPattern() { - return IDENTIFIER_PATTERN; - } - - /** - * Regex for single-quoted string literals, handling both doubled single quotes and backslash escapes. - */ - private static final Pattern QUOTE_LITERAL_PATTERN = Pattern.compile( - "'(?:''|\\\\.|[^'\\\\])*'" - ); - - /** - * Returns the pattern for string literals. - * - * @return the pattern for string literals. - */ - @Override - public Pattern getQuoteLiteralPattern() { - return QUOTE_LITERAL_PATTERN; - } - /** * Returns whether a multi-column comparison renders as a row value tuple, which for H2 is the case for an * ordering comparison and for a multi-row list. @@ -229,16 +172,6 @@ public String forShareLockHint() { return ""; } - /** - * Returns the lock hint for a write lock. - * - * @return the lock hint for a write lock. - */ - @Override - public String forUpdateLockHint() { - return "FOR UPDATE"; - } - /** * Sets a UUID parameter using {@link PreparedStatement#setObject(int, Object)}, which allows the H2 JDBC * driver to bind the value as a native UUID type. diff --git a/storm-h2/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-h2/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider index f0d44d863..b51953f70 100644 --- a/storm-h2/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider +++ b/storm-h2/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -1 +1 @@ -st.orm.spi.h2.testsupport.TestSpringConnectionProvider +st.orm.test.spring.TestSpringConnectionProvider 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 0468b5995..cfc8dfd0b 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 @@ -227,7 +227,7 @@ public Object fromDatabase(Object[] values, RefFactory refFactory) throws SqlTem RefFactory outerRefFactory = REF_FACTORY.get(); REF_FACTORY.set(refFactory); try { - return mapper.readValue((String) values[0], typeReference); + return mapper.readValue((String) value, typeReference); } catch (JsonProcessingException e) { throw new SqlTemplateException(e); } finally { 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 b6abb0445..2ef9a98bb 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 @@ -227,7 +227,7 @@ public Object fromDatabase(Object[] values, RefFactory refFactory) throws SqlTem RefFactory outerRefFactory = REF_FACTORY.get(); REF_FACTORY.set(refFactory); try { - return mapper.readValue((String) values[0], typeReference); + return mapper.readValue((String) value, typeReference); } catch (JacksonException e) { throw new SqlTemplateException(e); } finally { diff --git a/storm-java21/src/main/java/st/orm/repository/impl/EntityRepositoryImpl.java b/storm-java21/src/main/java/st/orm/repository/impl/EntityRepositoryImpl.java index 4778a2261..f759e85b7 100644 --- a/storm-java21/src/main/java/st/orm/repository/impl/EntityRepositoryImpl.java +++ b/storm-java21/src/main/java/st/orm/repository/impl/EntityRepositoryImpl.java @@ -27,8 +27,6 @@ import st.orm.Page; import st.orm.Pageable; import st.orm.Ref; -import st.orm.Scrollable; -import st.orm.Window; import st.orm.repository.EntityRepository; import st.orm.template.Model; import st.orm.template.ORMTemplate; @@ -248,11 +246,6 @@ public Page> pageRef(Pageable pageable) { return core.pageRef(pageable); } - @Override - public Window scroll(Scrollable scrollable) { - return select().scroll(scrollable); - } - @Override public List findAll() { return core.findAll(); diff --git a/storm-java21/src/main/java/st/orm/repository/impl/ProjectionRepositoryImpl.java b/storm-java21/src/main/java/st/orm/repository/impl/ProjectionRepositoryImpl.java index b926bc0db..233b67fa1 100644 --- a/storm-java21/src/main/java/st/orm/repository/impl/ProjectionRepositoryImpl.java +++ b/storm-java21/src/main/java/st/orm/repository/impl/ProjectionRepositoryImpl.java @@ -27,8 +27,6 @@ import st.orm.Pageable; import st.orm.Projection; import st.orm.Ref; -import st.orm.Scrollable; -import st.orm.Window; import st.orm.repository.ProjectionRepository; import st.orm.template.Model; import st.orm.template.ORMTemplate; @@ -133,13 +131,6 @@ public Page> pageRef(Pageable pageable) { return core.pageRef(pageable); } - // Window methods. - - @Override - public Window

scroll(Scrollable

scrollable) { - return select().scroll(scrollable); - } - @Override public Optional

findById(ID id) { return core.findById(id); diff --git a/storm-kotlin-spring-boot-starter/src/main/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.kt b/storm-kotlin-spring-boot-starter/src/main/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.kt index 5d2677858..3e97238f0 100644 --- a/storm-kotlin-spring-boot-starter/src/main/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.kt +++ b/storm-kotlin-spring-boot-starter/src/main/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.kt @@ -55,8 +55,6 @@ import javax.sql.DataSource @EnableConfigurationProperties(StormProperties::class) public open class StormAutoConfiguration { - private val logger = org.slf4j.LoggerFactory.getLogger(StormAutoConfiguration::class.java) - /** * Creates an [ORMTemplate] bean using the provided [DataSource] and [StormProperties]. * diff --git a/storm-kotlin/src/main/java/st/orm/spi/ORMReflectionImpl.java b/storm-kotlin/src/main/java/st/orm/spi/ORMReflectionImpl.java index 396471dad..61ce1b9b0 100644 --- a/storm-kotlin/src/main/java/st/orm/spi/ORMReflectionImpl.java +++ b/storm-kotlin/src/main/java/st/orm/spi/ORMReflectionImpl.java @@ -160,15 +160,6 @@ public Optional findRecordType(Class type) { }); } - private KProperty1 findVarProperty(KClass kClass) { - for (KProperty1 prop : KClasses.getMemberProperties(kClass)) { - if (prop instanceof KMutableProperty1) { - return prop; - } - } - return null; - } - private boolean isMutableProperty(Class declaringClass, String name) { KClass kClass = JvmClassMappingKt.getKotlinClass(declaringClass); for (KProperty1 p : KClasses.getMemberProperties(kClass)) { @@ -268,9 +259,6 @@ private Optional> findCanonicalConstructor(Class type) { return empty(); } KClass kClass = JvmClassMappingKt.getKotlinClass(type); - if (!kClass.isData()) { - return Optional.empty(); - } @SuppressWarnings("unchecked") KFunction primary = KClasses.getPrimaryConstructor((KClass) kClass); if (primary == null) { diff --git a/storm-kotlin/src/main/kotlin/st/orm/repository/RepositoryLookup.kt b/storm-kotlin/src/main/kotlin/st/orm/repository/RepositoryLookup.kt index 197cbe7ba..ccf0c218a 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/repository/RepositoryLookup.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/repository/RepositoryLookup.kt @@ -232,12 +232,7 @@ public inline fun RepositoryLookup.findAll(): List = if (T * * @return list containing all records. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.findAllRef(): List> = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectRef().resultList as List> -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().resultList as List> -} +public inline fun RepositoryLookup.findAllRef(): List> = selectRef().resultList /** * Retrieves an optional record of type [T] based on a single field and its value. @@ -249,12 +244,7 @@ public inline fun RepositoryLookup.findAllRef(): List> * @param value the value to match against. * @return an optional record, or null if none found. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.findBy(field: Metamodel, value: V): T? = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).select().where((field as Metamodel, V>) eq value).optionalResult as T? -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).select().where((field as Metamodel, V>) eq value).optionalResult as T? -} +public inline fun RepositoryLookup.findBy(field: Metamodel, value: V): T? = select().where(field eq value).optionalResult /** * Retrieves an optional record of type [T] based on a single field and its value. @@ -266,12 +256,7 @@ public inline fun RepositoryLookup.findBy(field: Metamodel * @param value the value to match against. * @return an optional record, or null if none found. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.findBy(field: Metamodel, value: Ref): T? = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).select().where(field as Metamodel, V>, value).optionalResult as T? -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).select().where(field as Metamodel, V>, value).optionalResult as T? -} +public inline fun RepositoryLookup.findBy(field: Metamodel, value: Ref): T? = select().where(field, value).optionalResult /** * Retrieves records of type [T] matching a single field and a single value. @@ -283,12 +268,7 @@ public inline fun RepositoryLookup.findBy(field: Me * @param value the value to match against. * @return list of matching records. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.findAllBy(field: Metamodel, value: V): List = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).select().where((field as Metamodel, V>) eq value).resultList as List -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).select().where((field as Metamodel, V>) eq value).resultList as List -} +public inline fun RepositoryLookup.findAllBy(field: Metamodel, value: V): List = select().where(field eq value).resultList /** * Retrieves records of type [T] matching a single field and a single value. @@ -300,12 +280,7 @@ public inline fun RepositoryLookup.findAllBy(field: Metamo * @param value the value to match against. * @return list of matching records. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.findAllBy(field: Metamodel, value: Ref): List = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).select().where(field as Metamodel, V>, value).resultList as List -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).select().where(field as Metamodel, V>, value).resultList as List -} +public inline fun RepositoryLookup.findAllBy(field: Metamodel, value: Ref): List = select().where(field, value).resultList /** * Retrieves records of type [T] matching a single field against multiple values. @@ -317,12 +292,7 @@ public inline fun RepositoryLookup.findAllBy(field: * @param values Iterable of values to match against. * @return list of matching records. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.findAllBy(field: Metamodel, values: Iterable): List = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).select().where((field as Metamodel, V>) inList values).resultList as List -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).select().where((field as Metamodel, V>) inList values).resultList as List -} +public inline fun RepositoryLookup.findAllBy(field: Metamodel, values: Iterable): List = select().where(field inList values).resultList /** * Retrieves records of type [T] matching a single field against multiple values. @@ -334,12 +304,7 @@ public inline fun RepositoryLookup.findAllBy(field: Metamo * @param values Iterable of values to match against. * @return list of matching records. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.findAllByRef(field: Metamodel, values: Iterable>): List = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).select().whereRef(field as Metamodel, V>, values).resultList as List -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).select().whereRef(field as Metamodel, V>, values).resultList as List -} +public inline fun RepositoryLookup.findAllByRef(field: Metamodel, values: Iterable>): List = select().whereRef(field, values).resultList /** * Retrieves exactly one record of type [T] based on a single field and its value. @@ -353,12 +318,7 @@ public inline fun RepositoryLookup.findAllByRef(fie * @throws st.orm.NoResultException if there is no result. * @throws st.orm.NonUniqueResultException if more than one result. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.getBy(field: Metamodel, value: V): T = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).select().where((field as Metamodel, V>) eq value).singleResult as T -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).select().where((field as Metamodel, V>) eq value).singleResult as T -} +public inline fun RepositoryLookup.getBy(field: Metamodel, value: V): T = select().where(field eq value).singleResult /** * Retrieves exactly one record of type [T] based on a single field and its value. @@ -372,12 +332,7 @@ public inline fun RepositoryLookup.getBy(field: Metamodel< * @throws st.orm.NoResultException if there is no result. * @throws st.orm.NonUniqueResultException if more than one result. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.getBy(field: Metamodel, value: Ref): T = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).select().where(field as Metamodel, V>, value).singleResult as T -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).select().where(field as Metamodel, V>, value).singleResult as T -} +public inline fun RepositoryLookup.getBy(field: Metamodel, value: Ref): T = select().where(field, value).singleResult /** * Retrieves an optional entity of type [T] based on a single field and its value. @@ -387,12 +342,7 @@ public inline fun RepositoryLookup.getBy(field: Met * @param value the value to match against. * @return an optional entity, or null if none found. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.findRefBy(field: Metamodel, value: V): Ref? = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectRef().where((field as Metamodel, V>) eq value).optionalResult as Ref? -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().where((field as Metamodel, V>) eq value).optionalResult as Ref? -} +public inline fun RepositoryLookup.findRefBy(field: Metamodel, value: V): Ref? = selectRef().where(field eq value).optionalResult /** * Retrieves an optional entity of type [T] based on a single field and its value. @@ -402,12 +352,7 @@ public inline fun RepositoryLookup.findRefBy(field: Metamo * @param value the value to match against. * @return an optional entity, or null if none found. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.findRefBy(field: Metamodel, value: Ref): Ref? = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectRef().where(field as Metamodel, V>, value).optionalResult as Ref? -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().where(field as Metamodel, V>, value).optionalResult as Ref? -} +public inline fun RepositoryLookup.findRefBy(field: Metamodel, value: Ref): Ref? = selectRef().where(field, value).optionalResult /** * Retrieves entities of type [T] matching a single field and a single value. @@ -417,12 +362,7 @@ public inline fun RepositoryLookup.findRefBy(field: * @param value the value to match against. * @return list of matching entities. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.findAllRefBy(field: Metamodel, value: V): List> = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectRef().where((field as Metamodel, V>) eq value).resultList as List> -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().where((field as Metamodel, V>) eq value).resultList as List> -} +public inline fun RepositoryLookup.findAllRefBy(field: Metamodel, value: V): List> = selectRef().where(field eq value).resultList /** * Retrieves entities of type [T] matching a single field and a single value. @@ -432,12 +372,7 @@ public inline fun RepositoryLookup.findAllRefBy(field: Met * @param value the value to match against. * @return list of matching entities. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.findAllRefBy(field: Metamodel, value: Ref): List> = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectRef().where(field as Metamodel, V>, value).resultList as List> -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().where(field as Metamodel, V>, value).resultList as List> -} +public inline fun RepositoryLookup.findAllRefBy(field: Metamodel, value: Ref): List> = selectRef().where(field, value).resultList /** * Retrieves entities of type [T] matching a single field against multiple values. @@ -447,12 +382,7 @@ public inline fun RepositoryLookup.findAllRefBy(fie * @param values Iterable of values to match against. * @return list of matching entities. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.findAllRefBy(field: Metamodel, values: Iterable): List> = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectRef().where((field as Metamodel, V>) inList values).resultList as List> -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().where((field as Metamodel, V>) inList values).resultList as List> -} +public inline fun RepositoryLookup.findAllRefBy(field: Metamodel, values: Iterable): List> = selectRef().where(field inList values).resultList /** * Retrieves entities of type [T] matching a single field against multiple values. @@ -462,12 +392,7 @@ public inline fun RepositoryLookup.findAllRefBy(field: Met * @param values Iterable of values to match against. * @return list of matching entities. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.findAllRefByRef(field: Metamodel, values: Iterable>): List> = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectRef().whereRef(field as Metamodel, V>, values).resultList as List> -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().whereRef(field as Metamodel, V>, values).resultList as List> -} +public inline fun RepositoryLookup.findAllRefByRef(field: Metamodel, values: Iterable>): List> = selectRef().whereRef(field, values).resultList /** * Retrieves exactly one entity of type [T] based on a single field and its value. @@ -479,12 +404,7 @@ public inline fun RepositoryLookup.findAllRefByRef( * @throws st.orm.NoResultException if there is no result. * @throws st.orm.NonUniqueResultException if more than one result. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.getRefBy(field: Metamodel, value: V): Ref = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectRef().where((field as Metamodel, V>) eq value).singleResult as Ref -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().where((field as Metamodel, V>) eq value).singleResult as Ref -} +public inline fun RepositoryLookup.getRefBy(field: Metamodel, value: V): Ref = selectRef().where(field eq value).singleResult /** * Retrieves exactly one entity of type [T] based on a single field and its value. @@ -496,12 +416,7 @@ public inline fun RepositoryLookup.getRefBy(field: Metamod * @throws st.orm.NoResultException if there is no result. * @throws st.orm.NonUniqueResultException if more than one result. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.getRefBy(field: Metamodel, value: Ref): Ref = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectRef().where(field as Metamodel, V>, value).singleResult as Ref -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().where(field as Metamodel, V>, value).singleResult as Ref -} +public inline fun RepositoryLookup.getRefBy(field: Metamodel, value: Ref): Ref = selectRef().where(field, value).singleResult /** * Creates a query builder to select records of type [T]. @@ -510,12 +425,7 @@ public inline fun RepositoryLookup.getRefBy(field: * * @return A [QueryBuilder] for selecting records of type [T]. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.findAll(predicate: PredicateBuilder): List = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).select().where(predicate as PredicateBuilder, *, *>).resultList as List -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).select().where(predicate as PredicateBuilder, *, *>).resultList as List -} +public inline fun RepositoryLookup.findAll(predicate: PredicateBuilder): List = select().where(predicate).resultList /** * Creates a query builder to select records of type [T]. @@ -524,12 +434,7 @@ public inline fun RepositoryLookup.findAll(predicate: Predica * * @return A [QueryBuilder] for selecting records of type [T]. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.findAllRef(predicate: PredicateBuilder): List> = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectRef().where(predicate as PredicateBuilder, *, *>).resultList as List> -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().where(predicate as PredicateBuilder, *, *>).resultList as List> -} +public inline fun RepositoryLookup.findAllRef(predicate: PredicateBuilder): List> = selectRef().where(predicate).resultList /** * Creates a query builder to select records of type [T]. @@ -538,12 +443,7 @@ public inline fun RepositoryLookup.findAllRef(predicate: Pred * * @return A [QueryBuilder] for selecting records of type [T]. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.find(predicate: PredicateBuilder): T? = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).select().where(predicate as PredicateBuilder, *, *>).optionalResult as T? -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).select().where(predicate as PredicateBuilder, *, *>).optionalResult as T? -} +public inline fun RepositoryLookup.find(predicate: PredicateBuilder): T? = select().where(predicate).optionalResult /** * Creates a query builder to select records of type [T]. @@ -552,12 +452,7 @@ public inline fun RepositoryLookup.find(predicate: PredicateB * * @return A [QueryBuilder] for selecting records of type [T]. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.findRef(predicate: PredicateBuilder): Ref? = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectRef().where(predicate as PredicateBuilder, *, *>).optionalResult as Ref? -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().where(predicate as PredicateBuilder, *, *>).optionalResult as Ref? -} +public inline fun RepositoryLookup.findRef(predicate: PredicateBuilder): Ref? = selectRef().where(predicate).optionalResult /** * Creates a query builder to select records of type [T]. @@ -566,12 +461,7 @@ public inline fun RepositoryLookup.findRef(predicate: Predica * * @return A [QueryBuilder] for selecting records of type [T]. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.get(predicate: PredicateBuilder): T = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).select().where(predicate as PredicateBuilder, *, *>).singleResult as T -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).select().where(predicate as PredicateBuilder, *, *>).singleResult as T -} +public inline fun RepositoryLookup.get(predicate: PredicateBuilder): T = select().where(predicate).singleResult /** * Creates a query builder to select records of type [T]. @@ -580,12 +470,7 @@ public inline fun RepositoryLookup.get(predicate: PredicateBu * * @return A [QueryBuilder] for selecting records of type [T]. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.getRef(predicate: PredicateBuilder): Ref = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectRef().where(predicate as PredicateBuilder, *, *>).singleResult as Ref -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().where(predicate as PredicateBuilder, *, *>).singleResult as Ref -} +public inline fun RepositoryLookup.getRef(predicate: PredicateBuilder): Ref = selectRef().where(predicate).singleResult /** * Creates a query builder to select records of type [T]. @@ -612,7 +497,6 @@ public inline fun RepositoryLookup.select(): QueryBuilder RepositoryLookup.select( predicate: PredicateBuilder, ): QueryBuilder = select().where(predicate) @@ -629,6 +513,21 @@ public inline fun RepositoryLookup.selectRef(): QueryBuilder< (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef() as QueryBuilder, *> } +/** + * Creates a query builder to count records of type [T]; the counting counterpart of [select]. + * + * [T] must be either an Entity or Projection type. + * + * @return A [QueryBuilder] counting records of type [T]. + */ +@PublishedApi +@Suppress("UNCHECKED_CAST") +internal inline fun RepositoryLookup.selectCount(): QueryBuilder = if (T::class.isSubclassOf(Entity::class)) { + (entity(T::class as KClass>) as EntityRepository, *>).selectCount() as QueryBuilder +} else { + (projection(T::class as KClass>) as ProjectionRepository, *>).selectCount() as QueryBuilder +} + /** * Counts entities of type [T] matching the specified field and value. * @@ -636,12 +535,7 @@ public inline fun RepositoryLookup.selectRef(): QueryBuilder< * @param value the value to match against. * @return the count of matching entities. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.countBy(field: Metamodel, value: V): Long = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectCount().where((field as Metamodel, V>) eq value).singleResult -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectCount().where((field as Metamodel, V>) eq value).singleResult -} +public inline fun RepositoryLookup.countBy(field: Metamodel, value: V): Long = selectCount().where(field eq value).singleResult /** * Counts entities of type [T] matching the specified field and referenced value. @@ -650,12 +544,7 @@ public inline fun RepositoryLookup.countBy(field: Metamode * @param value the referenced value to match against. * @return the count of matching entities. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.countBy(field: Metamodel, value: Ref): Long = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectCount().where(field as Metamodel, V>, value).singleResult -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectCount().where(field as Metamodel, V>, value).singleResult -} +public inline fun RepositoryLookup.countBy(field: Metamodel, value: Ref): Long = selectCount().where(field, value).singleResult /** * Counts entities of type [T] matching the specified predicate. @@ -675,12 +564,7 @@ public inline fun RepositoryLookup.countAll(): Long = if (T:: * @param predicate Lambda to build the WHERE clause. * @return the count of matching entities. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.count(predicate: PredicateBuilder): Long = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectCount().where(predicate as PredicateBuilder, *, *>).singleResult -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectCount().where(predicate as PredicateBuilder, *, *>).singleResult -} +public inline fun RepositoryLookup.count(predicate: PredicateBuilder): Long = selectCount().where(predicate).singleResult /** * Checks if entities of type [T] matching the specified field and value exists. @@ -689,12 +573,7 @@ public inline fun RepositoryLookup.count(predicate: Predicate * @param value the value to match against. * @return true if any matching entities exist, false otherwise. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.existsBy(field: Metamodel, value: V): Boolean = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectCount().where((field as Metamodel, V>) eq value).singleResult > 0 -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectCount().where((field as Metamodel, V>) eq value).singleResult > 0 -} +public inline fun RepositoryLookup.existsBy(field: Metamodel, value: V): Boolean = selectCount().where(field eq value).singleResult > 0 /** * Checks if entities of type [T] matching the specified field and referenced value exists. @@ -703,12 +582,7 @@ public inline fun RepositoryLookup.existsBy(field: Metamod * @param value the referenced value to match against. * @return true if any matching entities exist, false otherwise. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.existsBy(field: Metamodel, value: Ref): Boolean = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectCount().where(field as Metamodel, V>, value).singleResult > 0 -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectCount().where(field as Metamodel, V>, value).singleResult > 0 -} +public inline fun RepositoryLookup.existsBy(field: Metamodel, value: Ref): Boolean = selectCount().where(field, value).singleResult > 0 /** * Checks if entities of type [T] matching the specified predicate exists. @@ -728,12 +602,7 @@ public inline fun RepositoryLookup.exists(): Boolean = if (T: * @param predicate Lambda to build the WHERE clause. * @return true if any matching entities exist, false otherwise. */ -@Suppress("UNCHECKED_CAST") -public inline fun RepositoryLookup.exists(predicate: PredicateBuilder): Boolean = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectCount().where(predicate as PredicateBuilder, *, *>).singleResult > 0 -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectCount().where(predicate as PredicateBuilder, *, *>).singleResult > 0 -} +public inline fun RepositoryLookup.exists(predicate: PredicateBuilder): Boolean = selectCount().where(predicate).singleResult > 0 /** * Inserts an entity of type [T] into the repository. diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/QueryBuilder.kt b/storm-kotlin/src/main/kotlin/st/orm/template/QueryBuilder.kt index 45ce74b14..70eab1eab 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/QueryBuilder.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/QueryBuilder.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.stream.consumeAsFlow import st.orm.* import st.orm.Operator.* import st.orm.ResolveScope.CASCADE +import st.orm.core.template.impl.Elements import st.orm.core.template.impl.Elements.Clause.GROUP_BY import st.orm.core.template.impl.Elements.Clause.ORDER_BY_ASCENDING import st.orm.core.template.impl.Elements.Clause.ORDER_BY_DESCENDING @@ -665,18 +666,29 @@ public abstract class QueryBuilder { // We can safely invoke groupByAny as the underlying logic is identical. The main purpose of having these // separate methods is to provide (more) type safety when using metamodels that are guaranteed to be present in // the table graph. + return groupBy(columnList(path, GROUP_BY, "GROUP BY")) + } + + /** + * Renders the paths as a comma-separated column list for the given clause. + */ + private fun columnList( + path: Array>, + clause: Elements.Clause, + clauseName: String, + ): TemplateString { if (path.isEmpty()) { - throw PersistenceException("At least one path must be provided for GROUP BY clause.") + throw PersistenceException("At least one path must be provided for $clauseName clause.") } val templates = buildList { path.forEachIndexed { index, navigable -> - add(wrap(Columns(listOf(navigable.asMetamodel()), CASCADE, GROUP_BY))) + add(wrap(Columns(listOf(navigable.asMetamodel()), CASCADE, clause))) if (index < path.lastIndex) { add(raw(", ")) } } } - return groupBy(combine(*templates.toTypedArray())) + return combine(*templates.toTypedArray()) } /** @@ -801,20 +813,7 @@ public abstract class QueryBuilder { * @return the query builder. * @since 1.2 */ - public fun orderBy(vararg path: Navigable): QueryBuilder { - if (path.isEmpty()) { - throw PersistenceException("At least one path must be provided for ORDER BY clause.") - } - val templates = buildList { - path.forEachIndexed { index, navigable -> - add(wrap(Columns(listOf(navigable.asMetamodel()), CASCADE, ORDER_BY_ASCENDING))) - if (index < path.lastIndex) { - add(raw(", ")) - } - } - } - return orderBy(combine(*templates.toTypedArray())) - } + public fun orderBy(vararg path: Navigable): QueryBuilder = orderBy(columnList(path, ORDER_BY_ASCENDING, "ORDER BY")) /** * Adds an ORDER BY clause to the query for the field at the specified path in the table graph. The results are @@ -834,20 +833,7 @@ public abstract class QueryBuilder { * @return the query builder. * @since 1.9 */ - public fun orderByDescending(vararg path: Navigable): QueryBuilder { - if (path.isEmpty()) { - throw PersistenceException("At least one path must be provided for ORDER BY clause.") - } - val templates = buildList { - path.forEachIndexed { index, navigable -> - add(wrap(Columns(listOf(navigable.asMetamodel()), CASCADE, ORDER_BY_DESCENDING))) - if (index < path.size - 1) { - add(raw(", ")) - } - } - } - return orderBy(combine(*templates.toTypedArray())) - } + public fun orderByDescending(vararg path: Navigable): QueryBuilder = orderBy(columnList(path, ORDER_BY_DESCENDING, "ORDER BY")) /** * Adds an ORDER BY clause to the query using a string template. The results are sorted in descending order. diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/impl/ORMTemplateImpl.kt b/storm-kotlin/src/main/kotlin/st/orm/template/impl/ORMTemplateImpl.kt index d6f6d979f..f8a4ae294 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/impl/ORMTemplateImpl.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/impl/ORMTemplateImpl.kt @@ -137,52 +137,37 @@ internal class ORMTemplateImpl(private val core: st.orm.core.template.ORMTemplat } as R } - private fun createEntityRepository(type: KClass<*>): EntityRepository<*, *>? { - if (!EntityRepository::class.java.isAssignableFrom(type.java)) return null - var entityClass: Class<*>? = null - for (iface in type.java.genericInterfaces) { - if (iface is ParameterizedType && - (iface.rawType as? Class<*>)?.let { - EntityRepository::class.java.isAssignableFrom(it) - } == true - ) { - val arg = iface.actualTypeArguments[0] - if (arg is Class<*>) { - entityClass = arg - break - } - } - } - requireNotNull(entityClass) { - "Could not determine entity class for repository: ${type.simpleName}." - } - // Use Java reflection to invoke the generic 'entity' method at runtime. - val method = this.javaClass.getMethod("entity", KClass::class.java) - return method.invoke(this, entityClass.kotlin) as EntityRepository<*, *> - } + private fun createEntityRepository(type: KClass<*>): EntityRepository<*, *>? = createRepositoryFor(type, EntityRepository::class.java, "entity") as EntityRepository<*, *>? + + private fun createProjectionRepository(type: KClass<*>): ProjectionRepository<*, *>? = createRepositoryFor(type, ProjectionRepository::class.java, "projection") as ProjectionRepository<*, *>? - private fun createProjectionRepository(type: KClass<*>): ProjectionRepository<*, *>? { - if (!ProjectionRepository::class.java.isAssignableFrom(type.java)) return null - var projectionClass: Class<*>? = null + /** + * Creates the backing repository for a proxied repository interface: resolves the data type from the + * repository interface's type argument and invokes the given factory method ('entity' or 'projection') + * with it, or returns null when the interface does not extend the given repository type. + */ + private fun createRepositoryFor(type: KClass<*>, repositoryInterface: Class<*>, factoryMethod: String): Any? { + if (!repositoryInterface.isAssignableFrom(type.java)) return null + var dataClass: Class<*>? = null for (iface in type.java.genericInterfaces) { if (iface is ParameterizedType && (iface.rawType as? Class<*>)?.let { - ProjectionRepository::class.java.isAssignableFrom(it) + repositoryInterface.isAssignableFrom(it) } == true ) { val arg = iface.actualTypeArguments[0] if (arg is Class<*>) { - projectionClass = arg + dataClass = arg break } } } - requireNotNull(projectionClass) { - "Could not determine projection class for repository: ${type.simpleName}." + requireNotNull(dataClass) { + "Could not determine $factoryMethod class for repository: ${type.simpleName}." } - // Use Java reflection to invoke the generic 'projection' method at runtime. - val method = this.javaClass.getMethod("projection", KClass::class.java) - return method.invoke(this, projectionClass.kotlin) as ProjectionRepository<*, *> + // Use Java reflection to invoke the generic factory method at runtime. + val method = this.javaClass.getMethod(factoryMethod, KClass::class.java) + return method.invoke(this, dataClass.kotlin) } private fun createRepository(): Repository = object : Repository { diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/impl/PredicateBuilderFactory.kt b/storm-kotlin/src/main/kotlin/st/orm/template/impl/PredicateBuilderFactory.kt index 231b72596..73c09700f 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/impl/PredicateBuilderFactory.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/impl/PredicateBuilderFactory.kt @@ -57,42 +57,6 @@ internal fun createRef( o: Iterable>, ): PredicateBuilder = PredicateBuilderImpl(PredicateBuilderFactory.createRef(path, operator, o)) -/** - * Creates a new instance of [PredicateBuilder] for the specified path, operator, and values with an ID. - * - * @param path the metamodel path representing the field to be queried - * @param operator the operator to be used in the predicate - * @param o the values to be used in the predicate - * @param the type of the record - * @param the type of the result - * @param the type of the ID - * @param the type of the values - * @return a new instance of [PredicateBuilder] - */ -internal fun createWithId( - path: Metamodel<*, V>, - operator: Operator, - o: Iterable, -): PredicateBuilder = PredicateBuilderImpl(PredicateBuilderFactory.createWithId(path, operator, o)) - -/** - * Creates a new instance of [PredicateBuilder] for the specified path, operator, and values with an ID. - * - * @param path the metamodel path representing the field to be queried - * @param operator the operator to be used in the predicate - * @param o the values to be used in the predicate - * @param the type of the record - * @param the type of the result - * @param the type of the ID - * @param the type of the values - * @return a new instance of [PredicateBuilder] - */ -internal fun createRefWithId( - path: Metamodel<*, V>, - operator: Operator, - o: Iterable>, -): PredicateBuilder = PredicateBuilderImpl(PredicateBuilderFactory.createRefWithId(path, operator, o)) - /** * Combines two predicates using an AND condition, rooting the result at the operands' least common root. * diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/impl/TransactionCallbacks.kt b/storm-kotlin/src/main/kotlin/st/orm/template/impl/TransactionCallbacks.kt index da356919d..08705bb8d 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/impl/TransactionCallbacks.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/impl/TransactionCallbacks.kt @@ -25,7 +25,7 @@ import java.util.function.Consumer * * Callbacks registered via [addOnCommit], [addOnRollback] and [addOnCompletion] are stored in a single list, so * they run in registration order regardless of which kind they are; the ones that do not apply to the outcome are - * skipped. When [fireCommit] or [fireRollback] is called, the applicable callbacks are executed sequentially in + * skipped. When the transaction settles, the applicable callbacks are executed sequentially in * the enclosing coroutine context. If any callback throws, remaining callbacks still execute and the failures are * reported as a [TransactionCallbackException] whose cause is the first one, with subsequent ones added to it as * suppressed. @@ -95,15 +95,7 @@ internal class TransactionCallbacks : st.orm.core.spi.TransactionCallbacks { return } } - if (committed) fireCommit() else fireRollback() - } - - suspend fun fireCommit() { - fire(true) - } - - suspend fun fireRollback() { - fire(false) + fire(committed) } private suspend fun fire(committed: Boolean) { diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/TemplatesTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/TemplatesTest.kt index b7ba09147..2b18dfd96 100644 --- a/storm-kotlin/src/test/kotlin/st/orm/template/TemplatesTest.kt +++ b/storm-kotlin/src/test/kotlin/st/orm/template/TemplatesTest.kt @@ -614,18 +614,6 @@ internal open class TemplatesTest( predicate.shouldBeInstanceOf>() } - @Test - fun `createWithId predicate builder should return PredicateBuilder`() { - val metamodel = Metamodel.of(City::class.java, "id") - val predicate = st.orm.template.impl.createWithId( - metamodel, - Operator.IN, - listOf(1, 2, 3), - ) - predicate.shouldNotBe(null) - predicate.shouldBeInstanceOf>() - } - @Test fun `predicate builder or composition should return PredicateBuilder`() { val metamodel = Metamodel.of(City::class.java, "id") diff --git a/storm-ktor/src/main/kotlin/st/orm/ktor/Storm.kt b/storm-ktor/src/main/kotlin/st/orm/ktor/Storm.kt index dcd53c6df..968277fb2 100644 --- a/storm-ktor/src/main/kotlin/st/orm/ktor/Storm.kt +++ b/storm-ktor/src/main/kotlin/st/orm/ktor/Storm.kt @@ -272,12 +272,13 @@ public val Storm: ApplicationPlugin = createApplicationPlugin // The packages declared by the named databases partition validation: each database validates only the entity // and projection types under its packages, and the primary validates everything else. + val rootSchemaMode = pluginConfig.schemaValidation + ?: application.environment.config.propertyOrNull("storm.validation.schemaMode")?.getString() + ?: application.environment.config.propertyOrNull("storm.validation.schema_mode")?.getString() + ?: "fail" application.runSchemaValidation( template = ormTemplate, - configuredMode = pluginConfig.schemaValidation - ?: application.environment.config.propertyOrNull("storm.validation.schemaMode")?.getString() - ?: application.environment.config.propertyOrNull("storm.validation.schema_mode")?.getString() - ?: "fail", + configuredMode = rootSchemaMode, property = "storm.validation.schemaMode", description = "primary database", ) { type -> claimedPackages.keys.none { type.java.name.startsWith("$it.") } } @@ -290,10 +291,7 @@ public val Storm: ApplicationPlugin = createApplicationPlugin configuredMode = databaseConfig.schemaValidation ?: application.environment.config.propertyOrNull("storm.databases.$name.validation.schemaMode")?.getString() ?: application.environment.config.propertyOrNull("storm.databases.$name.validation.schema_mode")?.getString() - ?: pluginConfig.schemaValidation - ?: application.environment.config.propertyOrNull("storm.validation.schemaMode")?.getString() - ?: application.environment.config.propertyOrNull("storm.validation.schema_mode")?.getString() - ?: "fail", + ?: rootSchemaMode, property = "storm.databases.$name.validation.schemaMode", description = "database '$name'", ) { type -> databaseConfig.repositoryPackages.any { type.java.name.startsWith("$it.") } } diff --git a/storm-mariadb/pom.xml b/storm-mariadb/pom.xml index a8460380b..b0c8fd267 100644 --- a/storm-mariadb/pom.xml +++ b/storm-mariadb/pom.xml @@ -37,7 +37,7 @@ maven-surefire-plugin false @@ -107,6 +107,12 @@ compile + + st.orm + storm-test + ${project.version} + test + org.springframework.boot spring-boot-starter-test diff --git a/storm-mariadb/src/main/java/module-info.java b/storm-mariadb/src/main/java/module-info.java index e8d691f74..19201f131 100644 --- a/storm-mariadb/src/main/java/module-info.java +++ b/storm-mariadb/src/main/java/module-info.java @@ -4,7 +4,6 @@ requires storm.core; requires storm.mysql; requires static org.jspecify; - requires org.jetbrains.annotations; provides st.orm.core.spi.EntityRepositoryProvider with st.orm.spi.mariadb.MariaDBEntityRepositoryProviderImpl; provides st.orm.core.spi.SqlDialectProvider with st.orm.spi.mariadb.MariaDBSqlDialectProviderImpl; } diff --git a/storm-mariadb/src/main/java/st/orm/spi/mariadb/MariaDBEntityRepositoryImpl.java b/storm-mariadb/src/main/java/st/orm/spi/mariadb/MariaDBEntityRepositoryImpl.java index 4d6aef7b3..1c5db41c6 100644 --- a/storm-mariadb/src/main/java/st/orm/spi/mariadb/MariaDBEntityRepositoryImpl.java +++ b/storm-mariadb/src/main/java/st/orm/spi/mariadb/MariaDBEntityRepositoryImpl.java @@ -16,23 +16,16 @@ package st.orm.spi.mariadb; import static st.orm.GenerationStrategy.SEQUENCE; -import static st.orm.core.repository.impl.StreamSupport.partitioned; import static st.orm.core.template.SqlInterceptor.intercept; import static st.orm.core.template.TemplateString.raw; import static st.orm.core.template.impl.StringTemplates.flatten; -import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import st.orm.Entity; -import st.orm.Metamodel; import st.orm.core.repository.EntityRepository; import st.orm.core.template.Model; import st.orm.core.template.ORMTemplate; -import st.orm.core.template.PreparedQuery; import st.orm.core.template.Query; import st.orm.core.template.TemplateString; import st.orm.spi.mysql.MySQLEntityRepositoryImpl; @@ -52,19 +45,7 @@ public ID insertAndFetchId(E entity) { if (generationStrategy != SEQUENCE) { return super.insertAndFetchId(entity); } - entity = fireBeforeInsert(entity); - validateInsert(entity); - assert primaryKeyColumns.size() == 1; - var primaryKeyColumn = primaryKeyColumns.getFirst(); - String pkName = primaryKeyColumn.qualifiedName(ormTemplate.dialect()); - try (var query = ormTemplate.query(TemplateString.raw(""" - INSERT INTO \0 - VALUES \0 - RETURNING %s""".formatted(pkName), model.type(), entity)).managed().prepare()) { - ID id = query.getSingleResult(model.primaryKeyType()); - fireAfterInsert(entity, id); - return id; - } + return insertAndFetchIdReturning(entity); } /** @@ -123,75 +104,31 @@ protected ID doUpsertAndFetchId(E entity) { }); } - // Partition keys for the SEQUENCE-specific upsertAndFetchIds. - private sealed interface SeqPartitionKey {} - private static final class SeqNoOpKey implements SeqPartitionKey { - private static final SeqNoOpKey INSTANCE = new SeqNoOpKey(); - } - private static final class SeqUpsertKey implements SeqPartitionKey { - private static final SeqUpsertKey INSTANCE = new SeqUpsertKey(); - } - private record SeqUpdateKey(Set> fields) implements SeqPartitionKey { - SeqUpdateKey() { - this(Set.of()); - } - } - @Override public List upsertAndFetchIds(Iterable entities) { if (generationStrategy != SEQUENCE) { return super.upsertAndFetchIds(entities); } // SEQUENCE path: use a single query with RETURNING clause instead of batched prepared statements. - Map>, PreparedQuery> updateQueries = new HashMap<>(); - try { - var result = new ArrayList(); - var entityCache = entityCache(); - partitioned(toStream(entities), defaultBatchSize, entity -> { - if (isUpsertUpdate(entity)) { - var dirty = getDirty(entity, entityCache.orElse(null)); - if (dirty.isEmpty()) { - return SeqNoOpKey.INSTANCE; - } - return new SeqUpdateKey(dirty.get()); + return upsertAndFetchIdsPartitioned(entities, (chunk, entityCache) -> { + List batch = hasEntityCallbacks() + ? chunk.stream().map(this::fireBeforeUpsert).toList() + : chunk; + entityCache.ifPresent(cache -> { + if (batch.stream().anyMatch(e -> model.isDefaultPrimaryKey(e.id()))) { + // MySQL/MariaDB can update a record with the same unique key so we need to clear the + // cache as we cannot predict which record is updated. + cache.clear(); } else { - return SeqUpsertKey.INSTANCE; - } - }, getMaxShapes(), new SeqUpdateKey()).forEach(partition -> { - switch (partition.key()) { - case SeqNoOpKey ignore -> result.addAll(partition.chunk().stream().map(E::id).toList()); - case SeqUpsertKey ignore -> { - List batch = hasEntityCallbacks() - ? partition.chunk().stream().map(this::fireBeforeUpsert).toList() - : partition.chunk(); - entityCache.ifPresent(cache -> { - if (batch.stream().anyMatch(e -> model.isDefaultPrimaryKey(e.id()))) { - // MySQL/MariaDB can update a record with the same unique key so we need to clear the - // cache as we cannot predict which record is updated. - cache.clear(); - } else { - batch.forEach(e -> cache.remove(e.id())); - } - }); - result.addAll(getUpsertQuery(batch).getResultList(model.primaryKeyType())); - if (hasEntityCallbacks()) { - batch.forEach(this::fireAfterUpsert); - } - } - case SeqUpdateKey u -> { - List batch = hasEntityCallbacks() - ? partition.chunk().stream().map(this::fireBeforeUpdate).toList() - : partition.chunk(); - result.addAll(updateAndFetchIds(batch, - updateQueries.computeIfAbsent(u.fields(), this::prepareUpdateQuery), - entityCache.orElse(null))); - } + batch.forEach(e -> cache.remove(e.id())); } }); - return result; - } finally { - closeQuietly(updateQueries.values().stream()); - } + List ids = getUpsertQuery(batch).getResultList(model.primaryKeyType()); + if (hasEntityCallbacks()) { + batch.forEach(this::fireAfterUpsert); + } + return ids; + }); } private Query getUpsertQuery(Iterable entities) { diff --git a/storm-mariadb/src/test/java/st/orm/spi/mariadb/testsupport/TestSpringConnectionProvider.java b/storm-mariadb/src/test/java/st/orm/spi/mariadb/testsupport/TestSpringConnectionProvider.java deleted file mode 100644 index 82e4e7e60..000000000 --- a/storm-mariadb/src/test/java/st/orm/spi/mariadb/testsupport/TestSpringConnectionProvider.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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.mariadb.testsupport; - -import java.sql.Connection; -import javax.sql.DataSource; -import org.jspecify.annotations.Nullable; -import org.springframework.jdbc.datasource.DataSourceUtils; -import st.orm.PersistenceException; -import st.orm.core.spi.ConnectionProvider; -import st.orm.core.spi.Orderable.BeforeAny; -import st.orm.core.spi.TransactionContext; - -/** - * Test-only connection provider that binds connections to Spring's transaction management, so the test suite's - * transaction-per-test isolation applies to statements executed by Storm templates. - */ -@BeforeAny -public class TestSpringConnectionProvider implements ConnectionProvider { - - @Override - public Connection getConnection(DataSource dataSource, @Nullable TransactionContext context) { - try { - return DataSourceUtils.getConnection(dataSource); - } catch (Exception e) { - throw new PersistenceException("Failed to get connection from DataSource.", e); - } - } - - @Override - public void releaseConnection(Connection connection, DataSource dataSource, @Nullable TransactionContext context) { - DataSourceUtils.releaseConnection(connection, dataSource); - } -} diff --git a/storm-mariadb/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-mariadb/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider index 95aca5c0f..b51953f70 100644 --- a/storm-mariadb/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider +++ b/storm-mariadb/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -1 +1 @@ -st.orm.spi.mariadb.testsupport.TestSpringConnectionProvider +st.orm.test.spring.TestSpringConnectionProvider diff --git a/storm-metamodel-ksp/src/main/kotlin/st/orm/metamodel/MetamodelProcessor.kt b/storm-metamodel-ksp/src/main/kotlin/st/orm/metamodel/MetamodelProcessor.kt index 81e116ca8..6101c8be5 100644 --- a/storm-metamodel-ksp/src/main/kotlin/st/orm/metamodel/MetamodelProcessor.kt +++ b/storm-metamodel-ksp/src/main/kotlin/st/orm/metamodel/MetamodelProcessor.kt @@ -368,6 +368,16 @@ class MetamodelProcessor( } private fun renderKotlinBaseName(qualifiedName: String, currentPackage: String): String = when (qualifiedName) { + "kotlin.Any" -> "Any" + else -> renderResolvedKotlinName(qualifiedName, currentPackage) + } + + /** + * Renders the Kotlin name of a resolved declaration for the generated source: primitive-mapped names stay + * simple, a name in the current package drops the package, and everything else stays fully qualified. The + * `kotlin.Any` mapping is deliberately absent; the resolved-field call sites render it qualified. + */ + private fun renderResolvedKotlinName(qualifiedName: String, currentPackage: String): String = when (qualifiedName) { "kotlin.String", "java.lang.String" -> "String" "kotlin.Int", "java.lang.Integer" -> "Int" "kotlin.Long", "java.lang.Long" -> "Long" @@ -377,7 +387,7 @@ class MetamodelProcessor( "kotlin.Double", "java.lang.Double" -> "Double" "kotlin.Float", "java.lang.Float" -> "Float" "kotlin.Char", "java.lang.Character" -> "Char" - "java.lang.Object", "kotlin.Any" -> "Any" + "java.lang.Object" -> "Any" else -> { if (qualifiedName.startsWith(currentPackage) && currentPackage.isNotEmpty()) { val simpleName = qualifiedName.substring(currentPackage.length + 1) @@ -403,26 +413,7 @@ class MetamodelProcessor( val decl = resolvedType.declaration val qualifiedName = decl.qualifiedName?.asString() ?: return "Any" - val baseName = when (qualifiedName) { - "kotlin.String", "java.lang.String" -> "String" - "kotlin.Int", "java.lang.Integer" -> "Int" - "kotlin.Long", "java.lang.Long" -> "Long" - "kotlin.Short", "java.lang.Short" -> "Short" - "kotlin.Byte", "java.lang.Byte" -> "Byte" - "kotlin.Boolean", "java.lang.Boolean" -> "Boolean" - "kotlin.Double", "java.lang.Double" -> "Double" - "kotlin.Float", "java.lang.Float" -> "Float" - "kotlin.Char", "java.lang.Character" -> "Char" - "java.lang.Object" -> "Any" - else -> { - if (qualifiedName.startsWith(currentPackage) && currentPackage.isNotEmpty()) { - val simpleName = qualifiedName.substring(currentPackage.length + 1) - if (!simpleName.contains(".")) simpleName else qualifiedName - } else { - qualifiedName - } - } - } + val baseName = renderResolvedKotlinName(qualifiedName, currentPackage) val typeArguments = resolvedType.arguments if (typeArguments.isNotEmpty() && typeArguments.all { it.variance != Variance.STAR }) { val wildcards = typeArguments.joinToString(", ") { "*" } @@ -448,26 +439,10 @@ class MetamodelProcessor( fun render(type: KSType): String { val decl = type.declaration val qualifiedName = decl.qualifiedName?.asString() ?: decl.simpleName.asString() - val baseName = when (qualifiedName) { - "st.orm.Ref" -> "st.orm.Ref" - "kotlin.String", "java.lang.String" -> "String" - "kotlin.Int", "java.lang.Integer" -> "Int" - "kotlin.Long", "java.lang.Long" -> "Long" - "kotlin.Short", "java.lang.Short" -> "Short" - "kotlin.Byte", "java.lang.Byte" -> "Byte" - "kotlin.Boolean", "java.lang.Boolean" -> "Boolean" - "kotlin.Double", "java.lang.Double" -> "Double" - "kotlin.Float", "java.lang.Float" -> "Float" - "kotlin.Char", "java.lang.Character" -> "Char" - "java.lang.Object" -> "Any" - else -> { - if (qualifiedName.startsWith(currentPackage) && currentPackage.isNotEmpty()) { - val simpleName = qualifiedName.substring(currentPackage.length + 1) - if (!simpleName.contains(".")) simpleName else qualifiedName - } else { - qualifiedName - } - } + val baseName = if (qualifiedName == "st.orm.Ref") { + "st.orm.Ref" + } else { + renderResolvedKotlinName(qualifiedName, currentPackage) } val args = type.arguments val typeArgs = if (args.isNotEmpty()) { @@ -741,67 +716,75 @@ class MetamodelProcessor( private fun ensureNullable(typeName: String): String = if (typeName.endsWith("?")) typeName else "$typeName?" + /** + * Renders the comparison for a primitive-typed value: floats and doubles compare by bit pattern so NaN + * equals NaN, the other primitives by value; the null-safe form reads through nullable receivers. + */ + private fun primitiveEq(left: String, right: String, pk: PrimitiveKind, nullSafe: Boolean): String = when (pk) { + PrimitiveKind.FLOAT, + PrimitiveKind.DOUBLE, + -> if (nullSafe) "($left)?.toBits() == ($right)?.toBits()" else "($left).toBits() == ($right).toBits()" + PrimitiveKind.BOOLEAN, + PrimitiveKind.BYTE, + PrimitiveKind.SHORT, + PrimitiveKind.INT, + PrimitiveKind.LONG, + PrimitiveKind.CHAR, + -> "$left == $right" + } + private fun sameExpr(left: String, right: String, typeRef: KSTypeReference, forceNullableChain: Boolean): String { val pk = primitiveKind(typeRef) - return if (forceNullableChain && pk != null) { - when (pk) { - PrimitiveKind.FLOAT -> "($left)?.toBits() == ($right)?.toBits()" - PrimitiveKind.DOUBLE -> "($left)?.toBits() == ($right)?.toBits()" - PrimitiveKind.BOOLEAN, - PrimitiveKind.BYTE, - PrimitiveKind.SHORT, - PrimitiveKind.INT, - PrimitiveKind.LONG, - PrimitiveKind.CHAR, - -> "$left == $right" - } - } else { - when (pk) { - PrimitiveKind.FLOAT -> "($left).toBits() == ($right).toBits()" - PrimitiveKind.DOUBLE -> "($left).toBits() == ($right).toBits()" - PrimitiveKind.BOOLEAN, - PrimitiveKind.BYTE, - PrimitiveKind.SHORT, - PrimitiveKind.INT, - PrimitiveKind.LONG, - PrimitiveKind.CHAR, - -> "$left == $right" - null -> if (isKotlinArrayType(typeRef)) "($left).contentEquals($right)" else "$left == $right" - } + if (pk != null) { + return primitiveEq(left, right, pk, nullSafe = forceNullableChain) } + return if (isKotlinArrayType(typeRef)) "($left).contentEquals($right)" else "$left == $right" } private fun identicalExpr(left: String, right: String, typeRef: KSTypeReference, forceNullableChain: Boolean): String { val pk = primitiveKind(typeRef) - return if (forceNullableChain && pk != null) { - when (pk) { - PrimitiveKind.FLOAT -> "($left)?.toBits() == ($right)?.toBits()" - PrimitiveKind.DOUBLE -> "($left)?.toBits() == ($right)?.toBits()" - PrimitiveKind.BOOLEAN, - PrimitiveKind.BYTE, - PrimitiveKind.SHORT, - PrimitiveKind.INT, - PrimitiveKind.LONG, - PrimitiveKind.CHAR, - -> "$left == $right" - } - } else { - when (pk) { - PrimitiveKind.FLOAT -> "($left).toBits() == ($right).toBits()" - PrimitiveKind.DOUBLE -> "($left).toBits() == ($right).toBits()" - PrimitiveKind.BOOLEAN, - PrimitiveKind.BYTE, - PrimitiveKind.SHORT, - PrimitiveKind.INT, - PrimitiveKind.LONG, - PrimitiveKind.CHAR, - -> "$left == $right" - null -> { - if (isNullableKotlinPrimitive(typeRef)) return "$left == $right" - if (isValueBasedType(typeRef)) "$left == $right" else "$left === $right" - } - } + if (pk != null) { + return primitiveEq(left, right, pk, nullSafe = forceNullableChain) } + if (isNullableKotlinPrimitive(typeRef)) return "$left == $right" + return if (isValueBasedType(typeRef)) "$left == $right" else "$left === $right" + } + + /** + * Reports the key annotations that are not supported on inline record fields: @PK, @FK and @UK apply to + * top-level entity fields only. + */ + private fun reportInlineKeyAnnotations(prop: KSPropertyDeclaration) { + if (hasAnnotationOrMeta(prop, PRIMARY_KEY)) { + logger.error( + "@PK is not supported on inline record fields. " + + "Primary keys are only supported on top-level entity fields.", + prop, + ) + } + if (hasAnnotationOrMeta(prop, FOREIGN_KEY)) { + logger.error( + "@FK is not supported on inline record fields. " + + "Foreign keys are only supported on top-level entity fields.", + prop, + ) + } + if (hasAnnotationOrMeta(prop, UNIQUE_KEY)) { + logger.error( + "@UK is not supported on inline record fields. " + + "Unique keys are only supported on top-level entity fields.", + prop, + ) + } + } + + /** + * Returns the incremental-processing dependencies of a generated file: its declaration's containing file, + * or no sources for a declaration that has none (such as one from a library). + */ + private fun dependenciesOf(classDeclaration: KSClassDeclaration): Dependencies { + val containingFile = classDeclaration.containingFile + return if (containingFile != null) Dependencies(true, containingFile) else Dependencies(false) } private fun buildInterfaceFields( @@ -914,27 +897,7 @@ class MetamodelProcessor( // Validate: @PK, @FK, and @UK are not supported on inline record fields. Both chain variants walk // the same properties; only the base pass reports, so a diagnostic prints once. if (!forceNullableChain && !classDeclaration.implementsInterface(DATA)) { - if (hasAnnotationOrMeta(prop, PRIMARY_KEY)) { - logger.error( - "@PK is not supported on inline record fields. " + - "Primary keys are only supported on top-level entity fields.", - prop, - ) - } - if (hasAnnotationOrMeta(prop, FOREIGN_KEY)) { - logger.error( - "@FK is not supported on inline record fields. " + - "Foreign keys are only supported on top-level entity fields.", - prop, - ) - } - if (hasAnnotationOrMeta(prop, UNIQUE_KEY)) { - logger.error( - "@UK is not supported on inline record fields. " + - "Unique keys are only supported on top-level entity fields.", - prop, - ) - } + reportInlineKeyAnnotations(prop) } val inlineFlag = if (isChildData) "false" else "true" val childForceNullable = forceNullableChain || propNullable @@ -1022,27 +985,7 @@ class MetamodelProcessor( // Validate: @PK, @FK, and @UK are not supported on inline record fields. Both chain variants walk // the same properties; only the base pass reports, so a diagnostic prints once. if (!forceNullableChain && !isData) { - if (hasAnnotationOrMeta(prop, PRIMARY_KEY)) { - logger.error( - "@PK is not supported on inline record fields. " + - "Primary keys are only supported on top-level entity fields.", - prop, - ) - } - if (hasAnnotationOrMeta(prop, FOREIGN_KEY)) { - logger.error( - "@FK is not supported on inline record fields. " + - "Foreign keys are only supported on top-level entity fields.", - prop, - ) - } - if (hasAnnotationOrMeta(prop, UNIQUE_KEY)) { - logger.error( - "@UK is not supported on inline record fields. " + - "Unique keys are only supported on top-level entity fields.", - prop, - ) - } + reportInlineKeyAnnotations(prop) } val effectivelyNullable = if (!isData) { // Leaf of a compound key: report raw field nullability for runtime derivation. @@ -1230,8 +1173,7 @@ class MetamodelProcessor( val metaClassName = "Navigable${className}Metamodel" val navFields = buildNavClassFields(classDeclaration, packageName) val initFields = initNavClassFields(classDeclaration, packageName) - val containingFile = classDeclaration.containingFile - val deps = if (containingFile != null) Dependencies(true, containingFile) else Dependencies(false) + val deps = dependenciesOf(classDeclaration) val file = codeGenerator.createNewFile(dependencies = deps, packageName = packageName, fileName = metaClassName) OutputStreamWriter(file).use { writer -> writer.write( @@ -1275,8 +1217,7 @@ class MetamodelProcessor( val refType = "st.orm.Ref<$className>" val navFields = buildNavClassFields(classDeclaration, packageName) val initFields = initNavClassFields(classDeclaration, packageName) - val containingFile = classDeclaration.containingFile - val deps = if (containingFile != null) Dependencies(true, containingFile) else Dependencies(false) + val deps = dependenciesOf(classDeclaration) val file = codeGenerator.createNewFile(dependencies = deps, packageName = packageName, fileName = metaClassName) OutputStreamWriter(file).use { writer -> writer.write( @@ -1415,7 +1356,7 @@ class MetamodelProcessor( val componentNames = primaryConstructor.parameters.map { it.name?.asString() ?: return } val components = componentNames.joinToString(",\n") { " instance.${escaped(it)}" } val containingFile = classDeclaration.containingFile - val deps = if (containingFile != null) Dependencies(true, containingFile) else Dependencies(false) + val deps = dependenciesOf(classDeclaration) val file = codeGenerator.createNewFile( dependencies = deps, packageName = packageName, @@ -1474,8 +1415,7 @@ class MetamodelProcessor( val packageName = classDeclaration.packageName.asString() val className = classDeclaration.simpleName.asString() val metaInterfaceName = "${className}_" - val containingFile = classDeclaration.containingFile - val deps = if (containingFile != null) Dependencies(true, containingFile) else Dependencies(false) + val deps = dependenciesOf(classDeclaration) val file = codeGenerator.createNewFile( dependencies = deps, packageName = packageName, @@ -1603,8 +1543,7 @@ class MetamodelProcessor( | } """.trimMargin().trim('\n') - val containingFile = classDeclaration.containingFile - val deps = if (containingFile != null) Dependencies(true, containingFile) else Dependencies(false) + val deps = dependenciesOf(classDeclaration) val file = codeGenerator.createNewFile( dependencies = deps, packageName = packageName, diff --git a/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java b/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java index e612a6b0f..1245e6613 100644 --- a/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java +++ b/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java @@ -1111,11 +1111,7 @@ private void generateInstantiator(Element recordElement) { components.append(components.isEmpty() ? "" : ",\n") .append(" instance.").append(parameters.get(i).getSimpleName()).append("()"); } - try { - JavaFileObject fileObject = processingEnv.getFiler() - .createSourceFile((packageName.isEmpty() ? "" : packageName + ".") + instantiatorName, recordElement); - try (Writer writer = fileObject.openWriter()) { - writer.write(String.format(""" + writeSourceFile(packageName, instantiatorName, recordElement, String.format(""" %simport javax.annotation.processing.Generated; /** @@ -1157,11 +1153,7 @@ public Object[] deconstruct(%s instance) { recordName, components )); - } - generatedInstantiators.add((packageName.isEmpty() ? "" : packageName + ".") + instantiatorName); - } catch (IOException e) { - throw new UncheckedIOException("Failed to write " + instantiatorName, e); - } + generatedInstantiators.add((packageName.isEmpty() ? "" : packageName + ".") + instantiatorName); } private void generateMetamodelInterface(Element recordElement) { @@ -1171,35 +1163,37 @@ private void generateMetamodelInterface(Element recordElement) { String packageName = elementUtils.getPackageOf(recordElement).getQualifiedName().toString(); String recordName = recordElement.getSimpleName().toString(); String metaInterfaceName = recordName + "_"; - try { - JavaFileObject fileObject = processingEnv.getFiler() - .createSourceFile((packageName.isEmpty() ? "" : packageName + ".") + metaInterfaceName, recordElement); - try (Writer writer = fileObject.openWriter()) { - writer.write(String.format(""" - %simport st.orm.Metamodel; - import st.orm.AbstractMetamodel; - import st.orm.AbstractKeyMetamodel; - import javax.annotation.processing.Generated; + writeMetamodelInterface(packageName, recordName, metaInterfaceName, + buildInterfaceFields(recordElement, packageName), recordElement); + } - /** - * Metamodel for %s. - */ - @Generated("%s") - public interface %s extends Metamodel<%s, %s> { - %s - }""", - (packageName.isEmpty() ? "" : "package " + packageName + ";\n\n"), - recordName, - getClass().getName(), - metaInterfaceName, - recordName, - recordName, - buildInterfaceFields(recordElement, packageName) - )); - } - } catch (IOException e) { - throw new UncheckedIOException("Failed to write " + metaInterfaceName, e); - } + /** + * Writes a metamodel interface: the per-field constants the callers assemble, wrapped in the shared + * interface shell. + */ + private void writeMetamodelInterface(String packageName, String typeName, String metaInterfaceName, + String fields, Element originating) { + writeSourceFile(packageName, metaInterfaceName, originating, String.format(""" + %simport st.orm.Metamodel; + import st.orm.AbstractMetamodel; + import st.orm.AbstractKeyMetamodel; + import javax.annotation.processing.Generated; + + /** + * Metamodel for %s. + */ + @Generated("%s") + public interface %s extends Metamodel<%s, %s> { + %s + }""", + (packageName.isEmpty() ? "" : "package " + packageName + ";\n\n"), + typeName, + getClass().getName(), + metaInterfaceName, + typeName, + typeName, + fields + )); } private String buildClassFields(Element recordElement, @@ -1277,24 +1271,7 @@ private String initClassFields(Element recordElement, // Validate: @PK, @FK, and @UK are not supported on inline record fields. Both chain variants walk // the same fields; only the base pass reports, so a diagnostic prints once. if (!nullableChain && !implementsData(recordElement)) { - if (hasAnnotationOrMeta(enclosed, PRIMARY_KEY)) { - processingEnv.getMessager().printMessage(ERROR, - "@PK is not supported on inline record fields. " - + "Primary keys are only supported on top-level entity fields.", - enclosed); - } - if (hasAnnotationOrMeta(enclosed, FOREIGN_KEY)) { - processingEnv.getMessager().printMessage(ERROR, - "@FK is not supported on inline record fields. " - + "Foreign keys are only supported on top-level entity fields.", - enclosed); - } - if (hasAnnotationOrMeta(enclosed, UNIQUE_KEY)) { - processingEnv.getMessager().printMessage(ERROR, - "@UK is not supported on inline record fields. " - + "Unique keys are only supported on top-level entity fields.", - enclosed); - } + reportInlineKeyAnnotations(enclosed); } String inlineFlag = inline ? "true" : "false"; // Null-safe nested getter: parent record (root getter) can be null. @@ -1349,24 +1326,7 @@ private String initClassFields(Element recordElement, // Validate: @PK, @FK, and @UK are not supported on inline record fields. Both chain variants walk // the same fields; only the base pass reports, so a diagnostic prints once. if (!nullableChain && !isData) { - if (hasAnnotationOrMeta(enclosed, PRIMARY_KEY)) { - processingEnv.getMessager().printMessage(ERROR, - "@PK is not supported on inline record fields. " - + "Primary keys are only supported on top-level entity fields.", - enclosed); - } - if (hasAnnotationOrMeta(enclosed, FOREIGN_KEY)) { - processingEnv.getMessager().printMessage(ERROR, - "@FK is not supported on inline record fields. " - + "Foreign keys are only supported on top-level entity fields.", - enclosed); - } - if (hasAnnotationOrMeta(enclosed, UNIQUE_KEY)) { - processingEnv.getMessager().printMessage(ERROR, - "@UK is not supported on inline record fields. " - + "Unique keys are only supported on top-level entity fields.", - enclosed); - } + reportInlineKeyAnnotations(enclosed); } String baseClass = (!isData || unique) ? "AbstractKeyMetamodel" : "AbstractMetamodel"; boolean effectivelyNullable = false; @@ -1703,6 +1663,42 @@ private void writeSourceFile(String packageName, String className, Element origi } } + /** + * Renders the body of the root metamodel's isSame: reads both roots through the getter, treats a null + * root as equal only to another null, and applies the given comparison to the non-null pair. + */ + private static String rootIsSameBody(String typeName, String comparison) { + return typeName + " ra = getter.apply(a);\n" + + " " + typeName + " rb = getter.apply(b);\n" + + " if (ra == null || rb == null) return ra == rb;\n" + + " return " + comparison + ";"; + } + + /** + * Reports the key annotations that are not supported on inline record fields: @PK, @FK and @UK apply to + * top-level entity fields only. + */ + private void reportInlineKeyAnnotations(Element enclosed) { + if (hasAnnotationOrMeta(enclosed, PRIMARY_KEY)) { + processingEnv.getMessager().printMessage(ERROR, + "@PK is not supported on inline record fields. " + + "Primary keys are only supported on top-level entity fields.", + enclosed); + } + if (hasAnnotationOrMeta(enclosed, FOREIGN_KEY)) { + processingEnv.getMessager().printMessage(ERROR, + "@FK is not supported on inline record fields. " + + "Foreign keys are only supported on top-level entity fields.", + enclosed); + } + if (hasAnnotationOrMeta(enclosed, UNIQUE_KEY)) { + processingEnv.getMessager().printMessage(ERROR, + "@UK is not supported on inline record fields. " + + "Unique keys are only supported on top-level entity fields.", + enclosed); + } + } + private void generateMetamodelClass(Element recordElement, boolean nullableChain) { String packageName = elementUtils.getPackageOf(recordElement).getQualifiedName().toString(); String recordName = recordElement.getSimpleName().toString(); @@ -1711,7 +1707,7 @@ private void generateMetamodelClass(Element recordElement, boolean nullableChain // Root isSame: compare by PK if present, else compare by value, but guard for null root record. Optional pkNameOpt = findPrimaryKeyFieldName(recordElement); - String rootIsSameBody; + String rootIsSameBody = null; if (pkNameOpt.isPresent()) { String pkName = pkNameOpt.get(); TypeMirror pkType = getTypeElement(recordElement, pkName); @@ -1720,174 +1716,160 @@ private void generateMetamodelClass(Element recordElement, boolean nullableChain processingEnv.getMessager().printMessage(ERROR, "Found @PK on '" + pkName + "' but could not resolve its type on " + recordName); } - rootIsSameBody = - recordName + " ra = getter.apply(a);\n" + - " " + recordName + " rb = getter.apply(b);\n" + - " if (ra == null || rb == null) return ra == rb;\n" + - " return Objects.equals(ra, rb);"; } else { String left = accessorExpr(recordElement, "ra", pkName, pkType); String right = accessorExpr(recordElement, "rb", pkName, pkType); - rootIsSameBody = - recordName + " ra = getter.apply(a);\n" + - " " + recordName + " rb = getter.apply(b);\n" + - " if (ra == null || rb == null) return ra == rb;\n" + - " return " + sameComparisonExpr(left, right, pkType) + ";"; + rootIsSameBody = rootIsSameBody(recordName, sameComparisonExpr(left, right, pkType)); } - } else { - rootIsSameBody = - recordName + " ra = getter.apply(a);\n" + - " " + recordName + " rb = getter.apply(b);\n" + - " if (ra == null || rb == null) return ra == rb;\n" + - " return Objects.equals(ra, rb);"; + } + if (rootIsSameBody == null) { + rootIsSameBody = rootIsSameBody(recordName, "Objects.equals(ra, rb)"); } - try { - JavaFileObject fileObject = processingEnv.getFiler() - .createSourceFile((packageName.isEmpty() ? "" : packageName + ".") + metaClassName, recordElement); - - String classFields = buildClassFields(recordElement, packageName, recordName, nullableChain); - String initFields = initClassFields(recordElement, packageName, recordName, metaClassName, nullableChain); - - String header = - (packageName.isEmpty() ? "" : "package " + packageName + ";\n\n") + - "import st.orm.Metamodel;\n" + - "import st.orm.AbstractMetamodel;\n" + - "import st.orm.AbstractKeyMetamodel;\n" + - "import javax.annotation.processing.Generated;\n" + - "import java.util.Objects;\n\n" + - "/**\n" + - (nullableChain - ? " * Nullable-chain metamodel implementation for " + recordName - + ": a parent in the graph can be null, so every value read through it can be.\n" - : " * Metamodel implementation for " + recordName + ".\n") + - " *\n" + - " * @param the record type of the root table of the entity graph.\n" + - " */\n" + - "@Generated(\"" + getClass().getName() + "\")\n" + - "public final class " + metaClassName + " extends " + (isData ? "AbstractMetamodel" : "AbstractKeyMetamodel") + " {\n\n"; - - String flattenMethod = buildFlattenMethod(recordElement, isData); - - String isNullableOverride = ""; - if (!isData) { - isNullableOverride = - " @Override\n" + - " @SuppressWarnings(\"rawtypes\")\n" + - " public boolean isNullable() {\n" + - " if (!super.isNullable()) return false;\n" + - " for (var leaf : flatten()) {\n" + - " if (leaf instanceof Metamodel.Key key && key.isNullable()) return true;\n" + - " }\n" + - " return false;\n" + - " }\n\n"; - } + String classFields = buildClassFields(recordElement, packageName, recordName, nullableChain); + String initFields = initClassFields(recordElement, packageName, recordName, metaClassName, nullableChain); + String flattenMethod = buildFlattenMethod(recordElement, isData); + + String isNullableOverride = ""; + if (!isData) { + isNullableOverride = + " @Override\n" + + " @SuppressWarnings(\"rawtypes\")\n" + + " public boolean isNullable() {\n" + + " if (!super.isNullable()) return false;\n" + + " for (var leaf : flatten()) {\n" + + " if (leaf instanceof Metamodel.Key key && key.isNullable()) return true;\n" + + " }\n" + + " return false;\n" + + " }\n\n"; + } + + writeSourceFile(packageName, metaClassName, recordElement, + renderMetamodelClassSource(packageName, recordName, metaClassName, isData, nullableChain, + rootIsSameBody, classFields, initFields, flattenMethod, isNullableOverride)); + } - String body = - classFields + "\n" + - " private final java.util.function.Function getter;\n\n" + - " @Override\n" + - " public " + nullableReturnType(recordName) + " getValue(T record) {\n" + - " return getter.apply(record);\n" + + /** + * Renders a metamodel class: the shared shell around the pieces the record and sealed-interface + * generators derive differently (fields, initializers, the root isSame body, the flatten method and the + * optional isNullable override). + */ + private String renderMetamodelClassSource(String packageName, + String typeName, + String metaClassName, + boolean isData, + boolean nullableChain, + String rootIsSameBody, + String classFields, + String initFields, + String flattenMethod, + String isNullableOverride) { + String header = + (packageName.isEmpty() ? "" : "package " + packageName + ";\n\n") + + "import st.orm.Metamodel;\n" + + "import st.orm.AbstractMetamodel;\n" + + "import st.orm.AbstractKeyMetamodel;\n" + + "import javax.annotation.processing.Generated;\n" + + "import java.util.Objects;\n\n" + + "/**\n" + + (nullableChain + ? " * Nullable-chain metamodel implementation for " + typeName + + ": a parent in the graph can be null, so every value read through it can be.\n" + : " * Metamodel implementation for " + typeName + ".\n") + + " *\n" + + " * @param the record type of the root table of the entity graph.\n" + + " */\n" + + "@Generated(\"" + getClass().getName() + "\")\n" + + "public final class " + metaClassName + " extends " + (isData ? "AbstractMetamodel" : "AbstractKeyMetamodel") + " {\n\n"; + String body = + classFields + "\n" + + " private final java.util.function.Function getter;\n\n" + + " @Override\n" + + " public " + nullableReturnType(typeName) + " getValue(T record) {\n" + + " return getter.apply(record);\n" + + " }\n\n" + + " @Override\n" + + " public boolean isIdentical(T a, T b) {\n" + + " " + typeName + " ra = getter.apply(a);\n" + + " " + typeName + " rb = getter.apply(b);\n" + + " return ra == rb;\n" + + " }\n\n" + + " @Override\n" + + " public boolean isSame(T a, T b) {\n" + + " " + rootIsSameBody + "\n" + + " }\n\n" + + flattenMethod + + isNullableOverride; + String constructors; + if (isData) { + constructors = + " public " + metaClassName + "() {\n" + + " this(\"\", \"\", false, (Metamodel) Metamodel.root(" + typeName + ".class), " + + "t -> (" + typeName + ") t);\n" + " }\n\n" + - " @Override\n" + - " public boolean isIdentical(T a, T b) {\n" + - " " + recordName + " ra = getter.apply(a);\n" + - " " + recordName + " rb = getter.apply(b);\n" + - " return ra == rb;\n" + + " public " + metaClassName + "(String field, Metamodel parent) {\n" + + " this(\"\", field, false, parent, t -> (" + typeName + ") t);\n" + " }\n\n" + - " @Override\n" + - " public boolean isSame(T a, T b) {\n" + - " " + rootIsSameBody + "\n" + + " public " + metaClassName + "(String path, String field, Metamodel parent) {\n" + + " this(path, field, false, parent, t -> (" + typeName + ") t);\n" + " }\n\n" + - flattenMethod + - isNullableOverride; - String constructors; - if (isData) { - constructors = - " public " + metaClassName + "() {\n" + - " this(\"\", \"\", false, (Metamodel) Metamodel.root(" + recordName + ".class), " + - "t -> (" + recordName + ") t);\n" + - " }\n\n" + - " public " + metaClassName + "(String field, Metamodel parent) {\n" + - " this(\"\", field, false, parent, t -> (" + recordName + ") t);\n" + - " }\n\n" + - " public " + metaClassName + "(String path, String field, Metamodel parent) {\n" + - " this(path, field, false, parent, t -> (" + recordName + ") t);\n" + - " }\n\n" + - " public " + metaClassName + "(String path, String field, Metamodel parent, " + - "java.util.function.Function getter) {\n" + - " this(path, field, false, parent, getter);\n" + - " }\n\n" + - " public " + metaClassName + "(String path, String field, boolean inline, Metamodel parent) {\n" + - " this(path, field, inline, parent, t -> (" + recordName + ") t);\n" + - " }\n\n"; - } else { - constructors = - " public " + metaClassName + "(String path, String field, Metamodel parent, " + - "java.util.function.Function getter) {\n" + - " this(path, field, false, parent, getter);\n" + - " }\n\n"; - } - String fullCtor; - if (isData) { - fullCtor = - " public " + metaClassName + "(String path, String field, boolean inline, Metamodel parent, " + - "java.util.function.Function getter) {\n" + - " super(" + recordName + ".class, path, field, inline, parent);\n" + - " this.getter = getter;\n\n" + - " String subPath = inline ? path : field.isEmpty() ? path : path.isEmpty() ? field : " + - "path + \".\" + field;\n" + - " String fieldBase = inline ? (field.isEmpty() ? \"\" : field + \".\") : \"\";\n\n" + - initFields + "\n" + - " }\n"; - } else { - fullCtor = - " public " + metaClassName + "(String path, String field, boolean inline, Metamodel parent, " + - "java.util.function.Function getter, boolean nullable) {\n" + - " super(" + recordName + ".class, path, field, inline, parent, !inline && !field.isEmpty(), nullable);\n" + - " this.getter = getter;\n\n" + - " String subPath = inline ? path : field.isEmpty() ? path : path.isEmpty() ? field : " + - "path + \".\" + field;\n" + - " String fieldBase = inline ? (field.isEmpty() ? \"\" : field + \".\") : \"\";\n\n" + - initFields + "\n" + - " }\n\n" + - " public " + metaClassName + "(String path, String field, boolean inline, Metamodel parent, " + - "java.util.function.Function getter) {\n" + - " this(path, field, inline, parent, getter, false);\n" + - " }\n"; - } - String staticInstance = ""; - if (isData && !nullableChain) { - staticInstance = - "\n @SuppressWarnings(\"rawtypes\")\n" + - " private static final " + metaClassName + " INSTANCE = new " + metaClassName + "();\n\n" + - " @SuppressWarnings(\"unchecked\")\n" + - " public static " + metaClassName + " instance() {\n" + - " return INSTANCE;\n" + - " }\n"; - } - String footer = "}\n"; - try (Writer writer = fileObject.openWriter()) { - writer.write(header); - writer.write(body); - writer.write(constructors); - writer.write(fullCtor); - writer.write(staticInstance); - writer.write(footer); - } - } catch (IOException e) { - throw new UncheckedIOException("Failed to write " + metaClassName, e); + " public " + metaClassName + "(String path, String field, Metamodel parent, " + + "java.util.function.Function getter) {\n" + + " this(path, field, false, parent, getter);\n" + + " }\n\n" + + " public " + metaClassName + "(String path, String field, boolean inline, Metamodel parent) {\n" + + " this(path, field, inline, parent, t -> (" + typeName + ") t);\n" + + " }\n\n"; + } else { + constructors = + " public " + metaClassName + "(String path, String field, Metamodel parent, " + + "java.util.function.Function getter) {\n" + + " this(path, field, false, parent, getter);\n" + + " }\n\n"; + } + String fullCtor; + if (isData) { + fullCtor = + " public " + metaClassName + "(String path, String field, boolean inline, Metamodel parent, " + + "java.util.function.Function getter) {\n" + + " super(" + typeName + ".class, path, field, inline, parent);\n" + + " this.getter = getter;\n\n" + + " String subPath = inline ? path : field.isEmpty() ? path : path.isEmpty() ? field : " + + "path + \".\" + field;\n" + + " String fieldBase = inline ? (field.isEmpty() ? \"\" : field + \".\") : \"\";\n\n" + + initFields + "\n" + + " }\n"; + } else { + fullCtor = + " public " + metaClassName + "(String path, String field, boolean inline, Metamodel parent, " + + "java.util.function.Function getter, boolean nullable) {\n" + + " super(" + typeName + ".class, path, field, inline, parent, !inline && !field.isEmpty(), nullable);\n" + + " this.getter = getter;\n\n" + + " String subPath = inline ? path : field.isEmpty() ? path : path.isEmpty() ? field : " + + "path + \".\" + field;\n" + + " String fieldBase = inline ? (field.isEmpty() ? \"\" : field + \".\") : \"\";\n\n" + + initFields + "\n" + + " }\n\n" + + " public " + metaClassName + "(String path, String field, boolean inline, Metamodel parent, " + + "java.util.function.Function getter) {\n" + + " this(path, field, inline, parent, getter, false);\n" + + " }\n"; + } + String staticInstance = ""; + if (isData && !nullableChain) { + staticInstance = + "\n @SuppressWarnings(\"rawtypes\")\n" + + " private static final " + metaClassName + " INSTANCE = new " + metaClassName + "();\n\n" + + " @SuppressWarnings(\"unchecked\")\n" + + " public static " + metaClassName + " instance() {\n" + + " return INSTANCE;\n" + + " }\n"; } + return header + body + constructors + fullCtor + staticInstance + "}\n"; } // ---- Sealed interface support ---- - private static boolean hasAnnotation(Element element, String annotationFqn) { - return element.getAnnotationMirrors().stream() - .anyMatch(am -> annotationFqn.equals(am.getAnnotationType().toString())); - } - private static List getDeclaredAbstractGetters(TypeElement sealedInterface) { return sealedInterface.getEnclosedElements().stream() .filter(e -> e.getKind() == ElementKind.METHOD) @@ -1914,11 +1896,6 @@ private boolean isPrimaryKeyOnSubclass(TypeElement sealedInterface, String field return firstRecord != null && isPrimaryKeyField(firstRecord, fieldName); } - private boolean isUniqueFieldOnSubclass(TypeElement sealedInterface, String fieldName) { - TypeElement firstRecord = getFirstPermittedRecord(sealedInterface); - return firstRecord != null && isUniqueField(firstRecord, fieldName); - } - /** * Returns {@code true} if the field should be treated as a unique key for metamodel generation purposes. */ @@ -2027,35 +2004,7 @@ private void generateSealedMetamodelInterface(TypeElement sealedInterface, fields.setLength(fields.length() - 1); } - try { - JavaFileObject fileObject = processingEnv.getFiler() - .createSourceFile((packageName.isEmpty() ? "" : packageName + ".") + metaInterfaceName, sealedInterface); - try (Writer writer = fileObject.openWriter()) { - writer.write(String.format(""" - %simport st.orm.Metamodel; - import st.orm.AbstractMetamodel; - import st.orm.AbstractKeyMetamodel; - import javax.annotation.processing.Generated; - - /** - * Metamodel for %s. - */ - @Generated("%s") - public interface %s extends Metamodel<%s, %s> { - %s - }""", - (packageName.isEmpty() ? "" : "package " + packageName + ";\n\n"), - typeName, - getClass().getName(), - metaInterfaceName, - typeName, - typeName, - fields.toString() - )); - } - } catch (IOException e) { - throw new UncheckedIOException("Failed to write " + metaInterfaceName, e); - } + writeMetamodelInterface(packageName, typeName, metaInterfaceName, fields.toString(), sealedInterface); } private void generateSealedMetamodelClass(TypeElement sealedInterface, @@ -2082,17 +2031,9 @@ private void generateSealedMetamodelClass(TypeElement sealedInterface, if (pkName != null && pkType != null) { String left = "ra." + pkName + "()"; String right = "rb." + pkName + "()"; - rootIsSameBody = - typeName + " ra = getter.apply(a);\n" + - " " + typeName + " rb = getter.apply(b);\n" + - " if (ra == null || rb == null) return ra == rb;\n" + - " return " + sameComparisonExpr(left, right, pkType) + ";"; + rootIsSameBody = rootIsSameBody(typeName, sameComparisonExpr(left, right, pkType)); } else { - rootIsSameBody = - typeName + " ra = getter.apply(a);\n" + - " " + typeName + " rb = getter.apply(b);\n" + - " if (ra == null || rb == null) return ra == rb;\n" + - " return Objects.equals(ra, rb);"; + rootIsSameBody = rootIsSameBody(typeName, "Objects.equals(ra, rb)"); } // Build class fields. @@ -2254,125 +2195,8 @@ private void generateSealedMetamodelClass(TypeElement sealedInterface, flattenMethod.append(" }\n\n"); // Assemble the class. - try { - JavaFileObject fileObject = processingEnv.getFiler() - .createSourceFile((packageName.isEmpty() ? "" : packageName + ".") + metaClassName, sealedInterface); - - String header = - (packageName.isEmpty() ? "" : "package " + packageName + ";\n\n") + - "import st.orm.Metamodel;\n" + - "import st.orm.AbstractMetamodel;\n" + - "import st.orm.AbstractKeyMetamodel;\n" + - "import javax.annotation.processing.Generated;\n" + - "import java.util.Objects;\n\n" + - "/**\n" + - (nullableChain - ? " * Nullable-chain metamodel implementation for " + typeName - + ": a parent in the graph can be null, so every value read through it can be.\n" - : " * Metamodel implementation for " + typeName + ".\n") + - " *\n" + - " * @param the record type of the root table of the entity graph.\n" + - " */\n" + - "@Generated(\"" + getClass().getName() + "\")\n" + - "public final class " + metaClassName + " extends " + - (isData ? "AbstractMetamodel" : "AbstractKeyMetamodel") + " {\n\n"; - - String body = - classFields + "\n" + - " private final java.util.function.Function getter;\n\n" + - " @Override\n" + - " public " + nullableReturnType(typeName) + " getValue(T record) {\n" + - " return getter.apply(record);\n" + - " }\n\n" + - " @Override\n" + - " public boolean isIdentical(T a, T b) {\n" + - " " + typeName + " ra = getter.apply(a);\n" + - " " + typeName + " rb = getter.apply(b);\n" + - " return ra == rb;\n" + - " }\n\n" + - " @Override\n" + - " public boolean isSame(T a, T b) {\n" + - " " + rootIsSameBody + "\n" + - " }\n\n" + - flattenMethod; - - String constructors; - if (isData) { - constructors = - " public " + metaClassName + "() {\n" + - " this(\"\", \"\", false, (Metamodel) Metamodel.root(" + typeName + ".class), " + - "t -> (" + typeName + ") t);\n" + - " }\n\n" + - " public " + metaClassName + "(String field, Metamodel parent) {\n" + - " this(\"\", field, false, parent, t -> (" + typeName + ") t);\n" + - " }\n\n" + - " public " + metaClassName + "(String path, String field, Metamodel parent) {\n" + - " this(path, field, false, parent, t -> (" + typeName + ") t);\n" + - " }\n\n" + - " public " + metaClassName + "(String path, String field, Metamodel parent, " + - "java.util.function.Function getter) {\n" + - " this(path, field, false, parent, getter);\n" + - " }\n\n" + - " public " + metaClassName + "(String path, String field, boolean inline, Metamodel parent) {\n" + - " this(path, field, inline, parent, t -> (" + typeName + ") t);\n" + - " }\n\n"; - } else { - constructors = - " public " + metaClassName + "(String path, String field, Metamodel parent, " + - "java.util.function.Function getter) {\n" + - " this(path, field, false, parent, getter);\n" + - " }\n\n"; - } - - String fullCtor; - if (isData) { - fullCtor = - " public " + metaClassName + "(String path, String field, boolean inline, Metamodel parent, " + - "java.util.function.Function getter) {\n" + - " super(" + typeName + ".class, path, field, inline, parent);\n" + - " this.getter = getter;\n\n" + - " String subPath = inline ? path : field.isEmpty() ? path : path.isEmpty() ? field : " + - "path + \".\" + field;\n" + - " String fieldBase = inline ? (field.isEmpty() ? \"\" : field + \".\") : \"\";\n\n" + - initFields + "\n" + - " }\n"; - } else { - fullCtor = - " public " + metaClassName + "(String path, String field, boolean inline, Metamodel parent, " + - "java.util.function.Function getter, boolean nullable) {\n" + - " super(" + typeName + ".class, path, field, inline, parent, !inline && !field.isEmpty(), nullable);\n" + - " this.getter = getter;\n\n" + - " String subPath = inline ? path : field.isEmpty() ? path : path.isEmpty() ? field : " + - "path + \".\" + field;\n" + - " String fieldBase = inline ? (field.isEmpty() ? \"\" : field + \".\") : \"\";\n\n" + - initFields + "\n" + - " }\n\n" + - " public " + metaClassName + "(String path, String field, boolean inline, Metamodel parent, " + - "java.util.function.Function getter) {\n" + - " this(path, field, inline, parent, getter, false);\n" + - " }\n"; - } - String staticInstance = ""; - if (isData && !nullableChain) { - staticInstance = - "\n @SuppressWarnings(\"rawtypes\")\n" + - " private static final " + metaClassName + " INSTANCE = new " + metaClassName + "();\n\n" + - " @SuppressWarnings(\"unchecked\")\n" + - " public static " + metaClassName + " instance() {\n" + - " return INSTANCE;\n" + - " }\n"; - } - String footer = "}\n"; - try (Writer writer = fileObject.openWriter()) { - writer.write(header); - writer.write(body); - writer.write(constructors); - writer.write(fullCtor); - writer.write(staticInstance); - writer.write(footer); - } - } catch (IOException e) { - throw new UncheckedIOException("Failed to write " + metaClassName, e); - } + writeSourceFile(packageName, metaClassName, sealedInterface, + renderMetamodelClassSource(packageName, typeName, metaClassName, isData, nullableChain, + rootIsSameBody, classFields.toString(), initFields.toString(), flattenMethod.toString(), "")); } } diff --git a/storm-micrometer/src/main/java/st/orm/micrometer/OtelDatabaseObservationConvention.java b/storm-micrometer/src/main/java/st/orm/micrometer/OtelDatabaseObservationConvention.java index 280e921b3..8ea5aeecd 100644 --- a/storm-micrometer/src/main/java/st/orm/micrometer/OtelDatabaseObservationConvention.java +++ b/storm-micrometer/src/main/java/st/orm/micrometer/OtelDatabaseObservationConvention.java @@ -97,8 +97,9 @@ public KeyValues getLowCardinalityKeyValues(StormQueryObservationContext context @Override public KeyValues getHighCardinalityKeyValues(StormQueryObservationContext context) { + var keyValues = super.getHighCardinalityKeyValues(context); return context.queryContext().statement() - .map(statement -> super.getHighCardinalityKeyValues(context).and("db.query.text", statement)) - .orElseGet(() -> super.getHighCardinalityKeyValues(context)); + .map(statement -> keyValues.and("db.query.text", statement)) + .orElse(keyValues); } } diff --git a/storm-micrometer/src/main/java/st/orm/micrometer/QueryObservers.java b/storm-micrometer/src/main/java/st/orm/micrometer/QueryObservers.java index dbe1b8398..587f39fc1 100644 --- a/storm-micrometer/src/main/java/st/orm/micrometer/QueryObservers.java +++ b/storm-micrometer/src/main/java/st/orm/micrometer/QueryObservers.java @@ -54,9 +54,6 @@ public static QueryObserver create( @Nullable ObservationConvention transactionConvention, @Nullable String database) { var keyValues = database == null ? KeyValues.empty() : KeyValues.of("storm.database", database); - if (queryConvention == null && transactionConvention == null) { - return new MicrometerQueryObserver(observationRegistry, keyValues); - } return new MicrometerQueryObserver( observationRegistry, queryConvention != null ? queryConvention : new StormQueryObservationConvention(), diff --git a/storm-mssqlserver/pom.xml b/storm-mssqlserver/pom.xml index 2f4f2ec68..385aff8b8 100644 --- a/storm-mssqlserver/pom.xml +++ b/storm-mssqlserver/pom.xml @@ -37,7 +37,7 @@ maven-surefire-plugin false @@ -101,6 +101,12 @@ provided + + st.orm + storm-test + ${project.version} + test + org.springframework.boot spring-boot-starter-test 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 7d74eeedd..b74709acd 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 @@ -18,7 +18,6 @@ import static st.orm.GenerationStrategy.IDENTITY; import static st.orm.GenerationStrategy.NONE; import static st.orm.GenerationStrategy.SEQUENCE; -import static st.orm.core.repository.impl.StreamSupport.partitioned; import static st.orm.core.template.SqlInterceptor.intercept; import static st.orm.core.template.TemplateString.combine; import static st.orm.core.template.TemplateString.raw; @@ -26,22 +25,17 @@ import static st.orm.core.template.impl.StringTemplates.flatten; import java.util.ArrayList; -import java.util.HashMap; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import st.orm.Entity; -import st.orm.Metamodel; import st.orm.PersistenceException; import st.orm.core.repository.EntityRepository; 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.Query; import st.orm.core.template.SqlTemplateException; import st.orm.core.template.TemplateString; @@ -158,20 +152,6 @@ protected TemplateString mergeInsert() { return TemplateString.of("\nWHEN NOT MATCHED THEN%s".formatted(sql)); } - // Partition keys for the SEQUENCE-specific upsertAndFetchIds. - private sealed interface SeqPartitionKey {} - private static final class SeqNoOpKey implements SeqPartitionKey { - private static final SeqNoOpKey INSTANCE = new SeqNoOpKey(); - } - private static final class SeqUpsertKey implements SeqPartitionKey { - private static final SeqUpsertKey INSTANCE = new SeqUpsertKey(); - } - private record SeqUpdateKey(Set> fields) implements SeqPartitionKey { - SeqUpdateKey() { - this(Set.of()); // All fields. - } - } - /** * Overrides to use SEQUENCE-specific OUTPUT clause for batch fetch IDs when applicable. * @@ -207,49 +187,8 @@ public List upsertAndFetchIds(Iterable entities) { "Use the column's DEFAULT constraint for sequence values instead."); } // SEQUENCE path with empty sequence: use a single query with OUTPUT clause instead of batched prepared statements. - Map>, PreparedQuery> updateQueries = new HashMap<>(); - try { - var result = new ArrayList(); - var entityCache = entityCache(); - partitioned(toStream(entities), defaultBatchSize, entity -> { - if (isUpsertUpdate(entity)) { - var dirty = getDirty(entity, entityCache.orElse(null)); - if (dirty.isEmpty()) { - return SeqNoOpKey.INSTANCE; - } - return new SeqUpdateKey(dirty.get()); - } else { - return SeqUpsertKey.INSTANCE; - } - }, getMaxShapes(), new SeqUpdateKey()).forEach(partition -> { - switch (partition.key()) { - case SeqNoOpKey ignore -> result.addAll(partition.chunk().stream().map(E::id).toList()); - case SeqUpsertKey ignore -> { - List batch = hasEntityCallbacks() - ? partition.chunk().stream().map(this::fireBeforeUpsert).toList() - : partition.chunk(); - // Remove from cache entities with non-default PKs (could be updates via MERGE). - entityCache.ifPresent(cache -> batch.stream() - .filter(e -> !model.isDefaultPrimaryKey(e.id())) - .forEach(e -> cache.remove(e.id()))); - List ids = getUpsertQuery(batch).getResultList(model.primaryKeyType()); - result.addAll(ids); - fireAfterUpsert(batch, ids); - } - case SeqUpdateKey u -> { - List batch = hasEntityCallbacks() - ? partition.chunk().stream().map(this::fireBeforeUpdate).toList() - : partition.chunk(); - result.addAll(updateAndFetchIds(batch, - updateQueries.computeIfAbsent(u.fields(), this::prepareUpdateQuery), - entityCache.orElse(null))); - } - } - }); - return result; - } finally { - closeQuietly(updateQueries.values().stream()); - } + return upsertAndFetchIdsPartitioned(entities, + (chunk, entityCache) -> upsertPartitionAndFetchIds(chunk, entityCache, this::getUpsertQuery)); } private Query getUpsertQuery(Iterable entities) { diff --git a/storm-mssqlserver/src/main/java/st/orm/spi/mssqlserver/MSSQLServerSqlDialect.java b/storm-mssqlserver/src/main/java/st/orm/spi/mssqlserver/MSSQLServerSqlDialect.java index 28a72c2df..5eb230be9 100644 --- a/storm-mssqlserver/src/main/java/st/orm/spi/mssqlserver/MSSQLServerSqlDialect.java +++ b/storm-mssqlserver/src/main/java/st/orm/spi/mssqlserver/MSSQLServerSqlDialect.java @@ -24,9 +24,8 @@ import st.orm.StormConfig; import st.orm.core.spi.DefaultSqlDialect; import st.orm.core.template.Column; -import st.orm.core.template.SqlDialect; -public class MSSQLServerSqlDialect extends DefaultSqlDialect implements SqlDialect { +public class MSSQLServerSqlDialect extends DefaultSqlDialect { public MSSQLServerSqlDialect() { } @@ -56,18 +55,6 @@ public boolean supportsDeleteAlias() { return true; } - /** - * Indicates whether the SQL dialect supports multi-value tuples in the IN clause. - * - * @return {@code true} if multi-value tuples are supported, {@code false} otherwise. - * @since 1.2 - */ - @Override - public boolean supportsMultiValueTuples() { - // SQL Server does not support multi-value tuple IN clauses. - return false; - } - /** * Returns the selected columns rather than the key alone. * @@ -144,22 +131,6 @@ public Pattern getIdentifierPattern() { return IDENTIFIER_PATTERN; } - /** - * Regex for single-quoted string literals, handling both double single quotes and backslash escapes. - */ - private static final Pattern QUOTE_LITERAL_PATTERN = Pattern.compile("'(?:''|\\\\.|[^'\\\\])*'"); - - /** - * Returns the pattern for string literals. - * - * @return the pattern for string literals. - * @since 1.2 - */ - @Override - public Pattern getQuoteLiteralPattern() { - return QUOTE_LITERAL_PATTERN; - } - /** * Returns {@code true} if the limit should be applied after the SELECT clause, {@code false} to apply the limit at * the end of the query. diff --git a/storm-mssqlserver/src/test/java/st/orm/spi/mssqlserver/testsupport/TestSpringConnectionProvider.java b/storm-mssqlserver/src/test/java/st/orm/spi/mssqlserver/testsupport/TestSpringConnectionProvider.java deleted file mode 100644 index a8d6665a2..000000000 --- a/storm-mssqlserver/src/test/java/st/orm/spi/mssqlserver/testsupport/TestSpringConnectionProvider.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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.mssqlserver.testsupport; - -import java.sql.Connection; -import javax.sql.DataSource; -import org.jspecify.annotations.Nullable; -import org.springframework.jdbc.datasource.DataSourceUtils; -import st.orm.PersistenceException; -import st.orm.core.spi.ConnectionProvider; -import st.orm.core.spi.Orderable.BeforeAny; -import st.orm.core.spi.TransactionContext; - -/** - * Test-only connection provider that binds connections to Spring's transaction management, so the test suite's - * transaction-per-test isolation applies to statements executed by Storm templates. - */ -@BeforeAny -public class TestSpringConnectionProvider implements ConnectionProvider { - - @Override - public Connection getConnection(DataSource dataSource, @Nullable TransactionContext context) { - try { - return DataSourceUtils.getConnection(dataSource); - } catch (Exception e) { - throw new PersistenceException("Failed to get connection from DataSource.", e); - } - } - - @Override - public void releaseConnection(Connection connection, DataSource dataSource, @Nullable TransactionContext context) { - DataSourceUtils.releaseConnection(connection, dataSource); - } -} diff --git a/storm-mssqlserver/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-mssqlserver/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider index ed8241117..b51953f70 100644 --- a/storm-mssqlserver/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider +++ b/storm-mssqlserver/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -1 +1 @@ -st.orm.spi.mssqlserver.testsupport.TestSpringConnectionProvider +st.orm.test.spring.TestSpringConnectionProvider diff --git a/storm-mysql/pom.xml b/storm-mysql/pom.xml index bd9eb5173..9b90e7644 100644 --- a/storm-mysql/pom.xml +++ b/storm-mysql/pom.xml @@ -37,7 +37,7 @@ maven-surefire-plugin false @@ -68,6 +68,12 @@ provided + + st.orm + storm-test + ${project.version} + test + org.springframework.boot spring-boot-starter-test diff --git a/storm-mysql/src/main/java/st/orm/spi/mysql/MySQLSqlDialect.java b/storm-mysql/src/main/java/st/orm/spi/mysql/MySQLSqlDialect.java index 595934059..3e41257ed 100644 --- a/storm-mysql/src/main/java/st/orm/spi/mysql/MySQLSqlDialect.java +++ b/storm-mysql/src/main/java/st/orm/spi/mysql/MySQLSqlDialect.java @@ -24,9 +24,8 @@ import st.orm.PersistenceException; import st.orm.StormConfig; import st.orm.core.spi.DefaultSqlDialect; -import st.orm.core.template.SqlDialect; -public class MySQLSqlDialect extends DefaultSqlDialect implements SqlDialect { +public class MySQLSqlDialect extends DefaultSqlDialect { public MySQLSqlDialect() { } @@ -140,22 +139,6 @@ public Pattern getIdentifierPattern() { return IDENTIFIER_PATTERN; } - /** - * Regex for single-quoted string literals, handling both double single quotes and backslash escapes. - */ - private static final Pattern QUOTE_LITERAL_PATTERN = Pattern.compile("'(?:''|\\\\.|[^'\\\\])*'"); - - /** - * Returns the pattern for string literals. - * - * @return the pattern for string literals. - * @since 1.2 - */ - @Override - public Pattern getQuoteLiteralPattern() { - return QUOTE_LITERAL_PATTERN; - } - /** * Returns whether a multi-column comparison renders as a row value tuple, which for MySQL is the case for a * multi-row list only. diff --git a/storm-mysql/src/test/java/st/orm/spi/mysql/testsupport/TestSpringConnectionProvider.java b/storm-mysql/src/test/java/st/orm/spi/mysql/testsupport/TestSpringConnectionProvider.java deleted file mode 100644 index 4ce89d4c3..000000000 --- a/storm-mysql/src/test/java/st/orm/spi/mysql/testsupport/TestSpringConnectionProvider.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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.mysql.testsupport; - -import java.sql.Connection; -import javax.sql.DataSource; -import org.jspecify.annotations.Nullable; -import org.springframework.jdbc.datasource.DataSourceUtils; -import st.orm.PersistenceException; -import st.orm.core.spi.ConnectionProvider; -import st.orm.core.spi.Orderable.BeforeAny; -import st.orm.core.spi.TransactionContext; - -/** - * Test-only connection provider that binds connections to Spring's transaction management, so the test suite's - * transaction-per-test isolation applies to statements executed by Storm templates. - */ -@BeforeAny -public class TestSpringConnectionProvider implements ConnectionProvider { - - @Override - public Connection getConnection(DataSource dataSource, @Nullable TransactionContext context) { - try { - return DataSourceUtils.getConnection(dataSource); - } catch (Exception e) { - throw new PersistenceException("Failed to get connection from DataSource.", e); - } - } - - @Override - public void releaseConnection(Connection connection, DataSource dataSource, @Nullable TransactionContext context) { - DataSourceUtils.releaseConnection(connection, dataSource); - } -} diff --git a/storm-mysql/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-mysql/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider index 0c1629510..b51953f70 100644 --- a/storm-mysql/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider +++ b/storm-mysql/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -1 +1 @@ -st.orm.spi.mysql.testsupport.TestSpringConnectionProvider +st.orm.test.spring.TestSpringConnectionProvider diff --git a/storm-oracle/pom.xml b/storm-oracle/pom.xml index 96457e405..7c650ac85 100644 --- a/storm-oracle/pom.xml +++ b/storm-oracle/pom.xml @@ -37,7 +37,7 @@ maven-surefire-plugin false @@ -68,6 +68,12 @@ provided + + st.orm + storm-test + ${project.version} + test + org.springframework.boot spring-boot-starter-test diff --git a/storm-oracle/src/main/java/st/orm/spi/oracle/OracleSqlDialect.java b/storm-oracle/src/main/java/st/orm/spi/oracle/OracleSqlDialect.java index f43336b83..0e88bd59c 100644 --- a/storm-oracle/src/main/java/st/orm/spi/oracle/OracleSqlDialect.java +++ b/storm-oracle/src/main/java/st/orm/spi/oracle/OracleSqlDialect.java @@ -8,7 +8,7 @@ * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software - * distributed under the "AS IS" BASIS, + * 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. @@ -24,15 +24,13 @@ import java.util.List; import java.util.Set; -import java.util.regex.Pattern; import java.util.stream.Stream; import st.orm.Operator; import st.orm.StormConfig; import st.orm.core.spi.DefaultSqlDialect; import st.orm.core.template.Column; -import st.orm.core.template.SqlDialect; -public class OracleSqlDialect extends DefaultSqlDialect implements SqlDialect { +public class OracleSqlDialect extends DefaultSqlDialect { public OracleSqlDialect() { } @@ -52,20 +50,6 @@ public String name() { return "Oracle"; } - /** - * Indicates whether the SQL dialect supports delete aliases. - * - *

Delete aliases allow delete statements to use table aliases in joins, making it easier to filter rows based - * on related data.

- * - * @return {@code true} if delete aliases are supported, {@code false} otherwise. - */ - @Override - public boolean supportsDeleteAlias() { - // Oracle doesn't allow table aliases in DELETE. - return false; - } - /** * Indicates whether the SQL dialect supports multi-value tuples in the IN clause. * @@ -90,19 +74,6 @@ public List groupBy(List key, List selected) { return selected; } - private static final Pattern ORACLE_IDENTIFIER = Pattern.compile("^[A-Za-z][A-Za-z0-9_]*$"); - - /** - * Returns the pattern for valid identifiers. - * - * @return the pattern for valid identifiers. - * @since 1.2 - */ - @Override - public Pattern getValidIdentifierPattern() { - return ORACLE_IDENTIFIER; - } - private static final Set ORACLE_RESERVED = Stream.concat(ANSI_KEYWORDS.stream(), Stream.of( "ACCESS", "AUDIT", "CLUSTER", "COMMENT", "COMPRESS", "EXCLUSIVE", "FILE", "IDENTIFIED", "INCREMENT", "INDEX", "INITIAL", "LOCK", "LONG", "MAXEXTENTS", "MLSLABEL", "MODE", "MODIFY", "NOWAIT", @@ -127,28 +98,6 @@ public String escape(String name) { return "\"%s\"".formatted(name.replace("\"", "\"\"")); } - /** - * Regex for double-quoted identifiers in Oracle (embedded quotes are doubled). - */ - private static final Pattern IDENTIFIER_PATTERN = Pattern.compile( - "\"(?:\"\"|[^\"])*\"" - ); - - @Override - public Pattern getIdentifierPattern() { - return IDENTIFIER_PATTERN; - } - - /** - * Regex for single-quoted string literals in Oracle (escaped by doubling the single quote). - */ - private static final Pattern QUOTE_LITERAL_PATTERN = Pattern.compile("'(?:''|\\\\.|[^'\\\\])*'"); - - @Override - public Pattern getQuoteLiteralPattern() { - return QUOTE_LITERAL_PATTERN; - } - /** * Returns whether a multi-column comparison renders as a row value tuple, which for Oracle is the case for an * ordering comparison and for a multi-row list. @@ -235,17 +184,6 @@ public String forShareLockHint() { return ""; } - /** - * Returns the lock hint for a write lock in Oracle. - * Oracle supports "FOR UPDATE" to lock rows for update. - * - * @return the lock hint for a write lock. - */ - @Override - public String forUpdateLockHint() { - return "FOR UPDATE"; - } - /** * Returns the strategy for discovering sequences in the database schema. * diff --git a/storm-oracle/src/test/java/st/orm/spi/oracle/testsupport/TestSpringConnectionProvider.java b/storm-oracle/src/test/java/st/orm/spi/oracle/testsupport/TestSpringConnectionProvider.java deleted file mode 100644 index a3ce3d992..000000000 --- a/storm-oracle/src/test/java/st/orm/spi/oracle/testsupport/TestSpringConnectionProvider.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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.oracle.testsupport; - -import java.sql.Connection; -import javax.sql.DataSource; -import org.jspecify.annotations.Nullable; -import org.springframework.jdbc.datasource.DataSourceUtils; -import st.orm.PersistenceException; -import st.orm.core.spi.ConnectionProvider; -import st.orm.core.spi.Orderable.BeforeAny; -import st.orm.core.spi.TransactionContext; - -/** - * Test-only connection provider that binds connections to Spring's transaction management, so the test suite's - * transaction-per-test isolation applies to statements executed by Storm templates. - */ -@BeforeAny -public class TestSpringConnectionProvider implements ConnectionProvider { - - @Override - public Connection getConnection(DataSource dataSource, @Nullable TransactionContext context) { - try { - return DataSourceUtils.getConnection(dataSource); - } catch (Exception e) { - throw new PersistenceException("Failed to get connection from DataSource.", e); - } - } - - @Override - public void releaseConnection(Connection connection, DataSource dataSource, @Nullable TransactionContext context) { - DataSourceUtils.releaseConnection(connection, dataSource); - } -} diff --git a/storm-oracle/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-oracle/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider index 12a418db8..b51953f70 100644 --- a/storm-oracle/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider +++ b/storm-oracle/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -1 +1 @@ -st.orm.spi.oracle.testsupport.TestSpringConnectionProvider +st.orm.test.spring.TestSpringConnectionProvider diff --git a/storm-postgresql/pom.xml b/storm-postgresql/pom.xml index ee90032c4..43ce5ed37 100644 --- a/storm-postgresql/pom.xml +++ b/storm-postgresql/pom.xml @@ -37,7 +37,7 @@ maven-surefire-plugin false @@ -101,6 +101,12 @@ provided
+ + st.orm + storm-test + ${project.version} + test + org.springframework.boot spring-boot-starter-test diff --git a/storm-postgresql/src/main/java/st/orm/spi/postgresql/PostgreSQLEntityRepositoryImpl.java b/storm-postgresql/src/main/java/st/orm/spi/postgresql/PostgreSQLEntityRepositoryImpl.java index b14a2f28a..bf28f5803 100644 --- a/storm-postgresql/src/main/java/st/orm/spi/postgresql/PostgreSQLEntityRepositoryImpl.java +++ b/storm-postgresql/src/main/java/st/orm/spi/postgresql/PostgreSQLEntityRepositoryImpl.java @@ -15,129 +15,34 @@ */ package st.orm.spi.postgresql; -import static java.util.function.Predicate.not; import static st.orm.GenerationStrategy.SEQUENCE; -import static st.orm.core.repository.impl.StreamSupport.partitioned; 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.Templates.table; import static st.orm.core.template.impl.StringTemplates.flatten; -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.List; -import java.util.Map; -import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.stream.IntStream; -import org.jspecify.annotations.Nullable; -import st.orm.Data; import st.orm.Entity; -import st.orm.Metamodel; -import st.orm.NoResultException; -import st.orm.NonUniqueResultException; -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.OnConflictEntityRepositoryImpl; import st.orm.core.template.Model; import st.orm.core.template.ORMTemplate; -import st.orm.core.template.PreparedQuery; import st.orm.core.template.Query; -import st.orm.core.template.TemplateString; /** * Implementation of {@link EntityRepository} for PostgreSQL. */ public class PostgreSQLEntityRepositoryImpl, ID> - extends EntityRepositoryImpl { + extends OnConflictEntityRepositoryImpl { public PostgreSQLEntityRepositoryImpl(ORMTemplate ormTemplate, Model model) { super(ormTemplate, model); } - private TemplateString getVersionString(Class type, Column column) { - TemplateString columnName = TemplateString.of(column.qualifiedName(ormTemplate.dialect())); - TemplateString 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) -> raw("\0.\0 + 1", table(type), columnName); - case Class c when Instant.class.isAssignableFrom(c) - || Date.class.isAssignableFrom(c) - || Calendar.class.isAssignableFrom(c) - || Timestamp.class.isAssignableFrom(c) -> TemplateString.of("CURRENT_TIMESTAMP"); - default -> - throw new PersistenceException("Unsupported version type: %s.".formatted(column.type().getSimpleName())); - }; - return flatten(raw("\0 = \0", columnName, updateExpression)); - } - - /** - * Constructs the PostgreSQL conflict clause for an upsert. - *

- * This method builds an "ON CONFLICT () DO UPDATE SET ..." clause. - * For non-primary key columns, it assigns the value from the EXCLUDED pseudo‑table. - * Version columns are updated using {@link #getVersionString(Class, Column)}. - *

- * - * @param versionAware a flag that will be set if a version column is encountered. - * @return the conflict clause as a TemplateString. - */ - private TemplateString onConflictClause(AtomicBoolean versionAware) { - var dialect = ormTemplate.dialect(); - // Determine the conflict target from primary key columns. - String conflictTarget = model.declaredColumns().stream() - .filter(Column::primaryKey) - .map(c -> c.qualifiedName(dialect)) - .reduce("%s, %s"::formatted) - .orElseThrow(() -> new PersistenceException("No primary key defined.")); - // Build the assignment list for non-primary key updatable columns. - var assignments = model.declaredColumns().stream() - .filter(not(Column::primaryKey)) - .filter(Column::updatable) - .map(column -> { - if (column.version()) { - versionAware.setPlain(true); - return getVersionString(model.type(), column); - } - return TemplateString.of("%s = EXCLUDED.%s".formatted(column.qualifiedName(dialect), column.qualifiedName(dialect))); - }) - .reduce((left, right) -> combine(left, TemplateString.of(", "), right)) - .map(st -> combine(TemplateString.of("DO UPDATE SET "), st)) - .orElse(TemplateString.of("DO NOTHING")); - return flatten(combine(TemplateString.of("\nON CONFLICT ("), TemplateString.of(conflictTarget), raw(") \0", assignments))); - } - - @Override - protected void doUpsert(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(""" - INSERT INTO \0 - VALUES \0\0""", model.type(), entity, onConflictClause(versionAware)))).managed(); - query.executeUpdate(); - }); - } - @Override protected ID doUpsertAndFetchId(E entity) { if (generationStrategy != SEQUENCE) { - return doUpsertAndFetchIdNoSequence(entity); + return super.doUpsertAndFetchId(entity); } validateUpsert(entity); entityCache().ifPresent(cache -> { @@ -159,45 +64,6 @@ protected ID doUpsertAndFetchId(E entity) { }); } - private ID doUpsertAndFetchIdNoSequence(E entity) { - validateUpsert(entity); - entityCache().ifPresent(cache -> { - if (!model.isDefaultPrimaryKey(entity.id())) { - cache.remove(entity.id()); - } - }); - var versionAware = new AtomicBoolean(); - return intercept(sql -> sql.versionAware(versionAware.getPlain()), () -> { - try (var query = ormTemplate.query(flatten(raw(""" - INSERT INTO \0 - VALUES \0\0""", model.type(), entity, onConflictClause(versionAware)))).managed().prepare()) { - query.executeUpdate(); - if (isAutoGeneratedPrimaryKey()) { - try (var stream = query.getGeneratedKeys(model.primaryKeyType())) { - return stream.reduce((ignore1, ignore2) -> { - throw new NonUniqueResultException("Expected single result, but found more than one."); - }).orElseThrow(() -> new NoResultException("Expected single result, but found none.")); - } - } - return entity.id(); - } - }); - } - - // Partition keys for the SEQUENCE-specific upsertAndFetchIds. - private sealed interface SeqPartitionKey {} - private static final class SeqNoOpKey implements SeqPartitionKey { - private static final SeqNoOpKey INSTANCE = new SeqNoOpKey(); - } - private static final class SeqUpsertKey implements SeqPartitionKey { - private static final SeqUpsertKey INSTANCE = new SeqUpsertKey(); - } - private record SeqUpdateKey(Set> fields) implements SeqPartitionKey { - SeqUpdateKey() { - this(Set.of()); - } - } - /** * Overrides to use SEQUENCE-specific RETURNING clause for batch fetch IDs when applicable. */ @@ -207,49 +73,8 @@ public List upsertAndFetchIds(Iterable entities) { return super.upsertAndFetchIds(entities); } // SEQUENCE path: use a single query with RETURNING clause instead of batched prepared statements. - Map>, PreparedQuery> updateQueries = new HashMap<>(); - try { - var result = new ArrayList(); - var entityCache = entityCache(); - partitioned(toStream(entities), defaultBatchSize, entity -> { - if (isUpsertUpdate(entity)) { - var dirty = getDirty(entity, entityCache.orElse(null)); - if (dirty.isEmpty()) { - return SeqNoOpKey.INSTANCE; - } - return new SeqUpdateKey(dirty.get()); - } else { - return SeqUpsertKey.INSTANCE; - } - }, getMaxShapes(), new SeqUpdateKey()).forEach(partition -> { - switch (partition.key()) { - case SeqNoOpKey ignore -> result.addAll(partition.chunk().stream().map(E::id).toList()); - case SeqUpsertKey ignore -> { - List batch = hasEntityCallbacks() - ? partition.chunk().stream().map(this::fireBeforeUpsert).toList() - : partition.chunk(); - // Remove from cache entities with non-default PKs (could be updates via ON CONFLICT). - entityCache.ifPresent(cache -> batch.stream() - .filter(e -> !model.isDefaultPrimaryKey(e.id())) - .forEach(e -> cache.remove(e.id()))); - List ids = getUpsertQuery(batch).getResultList(model.primaryKeyType()); - result.addAll(ids); - fireAfterUpsert(batch, ids); - } - case SeqUpdateKey u -> { - List batch = hasEntityCallbacks() - ? partition.chunk().stream().map(this::fireBeforeUpdate).toList() - : partition.chunk(); - result.addAll(updateAndFetchIds(batch, - updateQueries.computeIfAbsent(u.fields(), this::prepareUpdateQuery), - entityCache.orElse(null))); - } - } - }); - return result; - } finally { - closeQuietly(updateQueries.values().stream()); - } + return upsertAndFetchIdsPartitioned(entities, + (chunk, entityCache) -> upsertPartitionAndFetchIds(chunk, entityCache, this::getUpsertQuery)); } private Query getUpsertQuery(Iterable entities) { @@ -265,77 +90,11 @@ private Query getUpsertQuery(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(""" - INSERT INTO \0 - VALUES \0\0""", model.type(), bindVars, onConflictClause(versionAware)))) - .managed().prepare()); - } - - @Override - protected void doUpsertBatch(List batch, 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(List batch, 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(E entity) { if (generationStrategy != SEQUENCE) { return super.insertAndFetchId(entity); } - entity = fireBeforeInsert(entity); - validateInsert(entity); - assert primaryKeyColumns.size() == 1; - var primaryKeyColumn = primaryKeyColumns.getFirst(); - String pkName = primaryKeyColumn.qualifiedName(ormTemplate.dialect()); - try (var query = ormTemplate.query(TemplateString.raw(""" - INSERT INTO \0 - VALUES \0 - RETURNING %s""".formatted(pkName), model.type(), entity)).managed().prepare()) { - ID id = query.getSingleResult(model.primaryKeyType()); - fireAfterInsert(entity, id); - return id; - } + return insertAndFetchIdReturning(entity); } - } 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 7b6ff4cee..34b72e199 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 @@ -27,15 +27,13 @@ import java.sql.Types; import java.util.Set; import java.util.UUID; -import java.util.regex.Pattern; import java.util.stream.Stream; 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; -public class PostgreSQLSqlDialect extends DefaultSqlDialect implements SqlDialect { +public class PostgreSQLSqlDialect extends DefaultSqlDialect { public PostgreSQLSqlDialect() { } @@ -55,14 +53,6 @@ public String name() { return "PostgreSQL"; } - /** - * PostgreSQL does not support aliasing the target table in DELETE statements. - */ - @Override - public boolean supportsDeleteAlias() { - return false; - } - /** * PostgreSQL supports multi-value tuples in the IN clause. */ @@ -71,19 +61,6 @@ public boolean supportsMultiValueTuples() { return true; } - private static final Pattern POSTGRESQL_IDENTIFIER = Pattern.compile("^[A-Za-z][A-Za-z0-9_]*$"); - - /** - * Returns the pattern for valid identifiers. - * - * @return the pattern for valid identifiers. - * @since 1.2 - */ - @Override - public Pattern getValidIdentifierPattern() { - return POSTGRESQL_IDENTIFIER; - } - private static final Set POSTGRESQL_KEYWORDS = Stream.concat(ANSI_KEYWORDS.stream(), Stream.of( "ANALYSE", "BIGSERIAL", "ILIKE", "INDEX", "INITIALLY", "LIMIT", "PLACING", "RETURNING", "SERIAL", "SMALLSERIAL", "UNLOGGED", "VARIADIC", "VERBOSE", "WITHIN GROUP", "XML" @@ -112,40 +89,6 @@ public String escape(String name) { return "\"%s\"".formatted(name.replace("\"", "\"\"")); } - /** - * Regex for double-quoted identifiers (handling doubled double quotes as escapes). - */ - private static final Pattern IDENTIFIER_PATTERN = Pattern.compile( - "\"(?:\"\"|[^\"])*\"" - ); - - /** - * Returns the pattern for identifiers. - * - * @return the pattern for identifiers. - */ - @Override - public Pattern getIdentifierPattern() { - return IDENTIFIER_PATTERN; - } - - /** - * Regex for single-quoted string literals, handling both doubled single quotes and backslash escapes. - */ - private static final Pattern QUOTE_LITERAL_PATTERN = Pattern.compile( - "'(?:''|\\\\.|[^'\\\\])*'" - ); - - /** - * Returns the pattern for string literals. - * - * @return the pattern for string literals. - */ - @Override - public Pattern getQuoteLiteralPattern() { - return QUOTE_LITERAL_PATTERN; - } - /** * Returns whether a multi-column comparison renders as a row value tuple, which for PostgreSQL is the case for * an ordering comparison and for a multi-row list. @@ -279,16 +222,6 @@ public String forShareLockHint() { return "FOR KEY SHARE"; } - /** - * Returns the lock hint for a write lock. - * - * @return the lock hint for a write lock. - */ - @Override - public String forUpdateLockHint() { - return "FOR UPDATE"; - } - /** * Sets a UUID parameter using {@link PreparedStatement#setObject(int, Object)}, which allows the PostgreSQL JDBC * driver to bind the value as a native UUID type. diff --git a/storm-postgresql/src/test/java/st/orm/spi/postgresql/testsupport/TestSpringConnectionProvider.java b/storm-postgresql/src/test/java/st/orm/spi/postgresql/testsupport/TestSpringConnectionProvider.java deleted file mode 100644 index 4fd7686ab..000000000 --- a/storm-postgresql/src/test/java/st/orm/spi/postgresql/testsupport/TestSpringConnectionProvider.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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.testsupport; - -import java.sql.Connection; -import javax.sql.DataSource; -import org.jspecify.annotations.Nullable; -import org.springframework.jdbc.datasource.DataSourceUtils; -import st.orm.PersistenceException; -import st.orm.core.spi.ConnectionProvider; -import st.orm.core.spi.Orderable.BeforeAny; -import st.orm.core.spi.TransactionContext; - -/** - * Test-only connection provider that binds connections to Spring's transaction management, so the test suite's - * transaction-per-test isolation applies to statements executed by Storm templates. - */ -@BeforeAny -public class TestSpringConnectionProvider implements ConnectionProvider { - - @Override - public Connection getConnection(DataSource dataSource, @Nullable TransactionContext context) { - try { - return DataSourceUtils.getConnection(dataSource); - } catch (Exception e) { - throw new PersistenceException("Failed to get connection from DataSource.", e); - } - } - - @Override - public void releaseConnection(Connection connection, DataSource dataSource, @Nullable TransactionContext context) { - DataSourceUtils.releaseConnection(connection, dataSource); - } -} diff --git a/storm-postgresql/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-postgresql/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider index f5329c1e0..b51953f70 100644 --- a/storm-postgresql/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider +++ b/storm-postgresql/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -1 +1 @@ -st.orm.spi.postgresql.testsupport.TestSpringConnectionProvider +st.orm.test.spring.TestSpringConnectionProvider diff --git a/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.java b/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.java index 87218d875..e1cd7c2fb 100644 --- a/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.java +++ b/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.java @@ -58,8 +58,6 @@ @EnableConfigurationProperties(StormProperties.class) public class StormAutoConfiguration { - private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(StormAutoConfiguration.class); - /** * Creates an {@link ORMTemplate} bean using the provided {@link DataSource} and {@link StormProperties}. * diff --git a/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormRepositoryAutoConfiguration.java b/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormRepositoryAutoConfiguration.java index 37c2a2c44..387f70210 100644 --- a/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormRepositoryAutoConfiguration.java +++ b/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormRepositoryAutoConfiguration.java @@ -20,6 +20,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import st.orm.spring.AbstractRepositoryBeanFactoryPostProcessor; +import st.orm.spring.RepositoryBeanFactoryPostProcessor; import st.orm.template.ORMTemplate; /** diff --git a/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTestContextCustomizerFactory.java b/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTestContextCustomizerFactory.java index 0aaf5a37f..6a4fc2320 100644 --- a/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTestContextCustomizerFactory.java +++ b/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTestContextCustomizerFactory.java @@ -56,13 +56,13 @@ public void customizeContext(ConfigurableApplicationContext context, @Override public boolean equals(Object other) { return other instanceof DataStormTestContextCustomizer customizer - && Objects.equals(new DataStormTypeExcludeFilter(annotation), - new DataStormTypeExcludeFilter(customizer.annotation)); + && Objects.equals(DataStormTypeExcludeFilter.annotationState(annotation), + DataStormTypeExcludeFilter.annotationState(customizer.annotation)); } @Override public int hashCode() { - return new DataStormTypeExcludeFilter(annotation).hashCode(); + return DataStormTypeExcludeFilter.annotationState(annotation).hashCode(); } } } diff --git a/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTypeExcludeFilter.java b/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTypeExcludeFilter.java index f46988da8..72f83ec3d 100644 --- a/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTypeExcludeFilter.java +++ b/storm-spring-boot-test-autoconfigure/src/main/java/st/orm/spring/boot/test/DataStormTypeExcludeFilter.java @@ -113,7 +113,7 @@ public int hashCode() { return annotationState(annotation).hashCode(); } - private static List annotationState(DataStormTest annotation) { + static List annotationState(DataStormTest annotation) { return List.of( annotation.useDefaultFilters(), Arrays.stream(annotation.includeFilters()).map(AnnotationUtils::getAnnotationAttributes).toList(), diff --git a/storm-spring/src/main/java/module-info.java b/storm-spring/src/main/java/module-info.java index 2e9f181b4..a11040c58 100644 --- a/storm-spring/src/main/java/module-info.java +++ b/storm-spring/src/main/java/module-info.java @@ -1,3 +1,6 @@ +// The qualified exports below target sibling modules that depend on storm-spring, so they are never +// observable while storm-spring itself compiles; suppress the resulting "module not found" warnings. +@SuppressWarnings("module") module storm.spring { requires static storm.java; requires static storm.micrometer; @@ -27,6 +30,14 @@ requires java.sql; exports st.orm.spring; exports st.orm.spring.boot; - exports st.orm.spring.impl; + // The impl package is reachable by Storm's own modules and by the Spring modules that reflectively + // instantiate its auto-configuration, registrar and runtime-hints classes; to everyone else it is not API. + exports st.orm.spring.impl to + storm.kotlin.spring, + spring.beans, + spring.boot, + spring.boot.autoconfigure, + spring.context, + spring.core; provides st.orm.core.spi.ExternalTransactionProvider with st.orm.spring.SpringExternalTransactionProvider; } diff --git a/storm-spring/src/main/java/st/orm/spring/AbstractRepositoryBeanFactoryPostProcessor.java b/storm-spring/src/main/java/st/orm/spring/AbstractRepositoryBeanFactoryPostProcessor.java index e7661261d..5e55679b6 100644 --- a/storm-spring/src/main/java/st/orm/spring/AbstractRepositoryBeanFactoryPostProcessor.java +++ b/storm-spring/src/main/java/st/orm/spring/AbstractRepositoryBeanFactoryPostProcessor.java @@ -164,11 +164,13 @@ private void registerRepositories( Stream> repositories ) { repositories.forEach(type -> { + var factoryBeanClass = getRepositoryFactoryBeanClass(); + String prefix = getRepositoryPrefix(); // A FactoryBean definition carrying the repository interface as a constructor argument, rather // than an instance supplier: suppliers cannot be processed by Spring AOT into generated code, // which would make scanned repositories unavailable in GraalVM native images. var definition = (RootBeanDefinition) BeanDefinitionBuilder - .rootBeanDefinition(getRepositoryFactoryBeanClass()) + .rootBeanDefinition(factoryBeanClass) .addConstructorArgValue(type) .addConstructorArgValue(getOrmTemplateBeanName()) .getBeanDefinition(); @@ -177,9 +179,8 @@ private void registerRepositories( // The attribute is not carried into AOT-generated registrations; the target type is, and it // binds the FactoryBean's generic to the repository interface so the repository can still be // autowired by type in a native image. - definition.setTargetType(ResolvableType.forClassWithGenerics(getRepositoryFactoryBeanClass(), type)); - definition.setAttribute("qualifier", getRepositoryPrefix()); - String prefix = getRepositoryPrefix(); + definition.setTargetType(ResolvableType.forClassWithGenerics(factoryBeanClass, type)); + definition.setAttribute("qualifier", prefix); if (prefix != null && !prefix.isEmpty()) { // The qualifier attribute supports autowiring by qualifier on the JVM; the definition-level // qualifier survives AOT processing, where attributes are not carried into generated code. @@ -188,7 +189,7 @@ private void registerRepositories( } else { LOGGER.debug("Registering repository {}.", type.getName()); } - String name = getRepositoryPrefix() + type.getSimpleName(); + String name = prefix + type.getSimpleName(); registry.registerBeanDefinition(name, definition); }); } diff --git a/storm-spring/src/main/java/st/orm/spring/SpringTransactionTemplateProvider.java b/storm-spring/src/main/java/st/orm/spring/SpringTransactionTemplateProvider.java index 8216ba3a4..8cd29776d 100644 --- a/storm-spring/src/main/java/st/orm/spring/SpringTransactionTemplateProvider.java +++ b/storm-spring/src/main/java/st/orm/spring/SpringTransactionTemplateProvider.java @@ -36,11 +36,11 @@ import st.orm.TransactionPropagation; import st.orm.core.spi.CacheRetention; import st.orm.core.spi.EntityCache; -import st.orm.core.spi.EntityCacheImpl; import st.orm.core.spi.TransactionContext; import st.orm.core.spi.TransactionStatus; import st.orm.core.spi.TransactionTemplate; import st.orm.core.spi.TransactionTemplateProvider; +import st.orm.spring.impl.EntityCaches; import st.orm.spring.impl.SpringTransactionContext; /** @@ -259,16 +259,12 @@ public boolean isRepeatableRead() { // The context is bound once per physical Spring transaction, which gives correct cache scoping for // REQUIRED and REQUIRES_NEW. NESTED savepoint rollbacks are not observable through Spring's hooks, so no // cache splitting is attempted for savepoints. - return caches.computeIfAbsent(entityType, ignore -> new EntityCacheImpl<>(retention)); + return EntityCaches.entityCache(caches, entityType, retention); } @Override public EntityCache, ?> getEntityCache(Class> entityType) { - var cache = caches.get(entityType); - if (cache == null) { - throw new IllegalStateException("No entity cache exists for " + entityType.getName() + "."); - } - return cache; + return EntityCaches.getEntityCache(caches, entityType); } @Override @@ -278,9 +274,7 @@ public boolean isRepeatableRead() { @Override public void clearAllEntityCaches() { - for (EntityCache, ?> cache : caches.values()) { - cache.clear(); - } + EntityCaches.clearAll(caches); } @Override diff --git a/storm-spring/src/main/java/st/orm/spring/boot/PerformanceLog.java b/storm-spring/src/main/java/st/orm/spring/boot/PerformanceLog.java index 0052ec793..7cc1851e8 100644 --- a/storm-spring/src/main/java/st/orm/spring/boot/PerformanceLog.java +++ b/storm-spring/src/main/java/st/orm/spring/boot/PerformanceLog.java @@ -18,6 +18,7 @@ import java.time.Duration; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import st.orm.core.template.SqlLog; /** @@ -30,6 +31,9 @@ */ final class PerformanceLog { + /** The logger every boundary reports under. */ + static final Logger LOGGER = LoggerFactory.getLogger("st.orm.sql.perf"); + private PerformanceLog() { } @@ -77,14 +81,14 @@ interface Boundary { * Returns whether a summary would reach the logger, so a caller can skip opening a scope whose summary * nothing consumes. */ - static boolean consumes(Logger logger, Settings settings) { - return settings.thresholded() ? logger.isWarnEnabled() : logger.isInfoEnabled(); + static boolean consumes(Settings settings) { + return settings.thresholded() ? LOGGER.isWarnEnabled() : LOGGER.isInfoEnabled(); } /** * Reports the summary. A unit of work that touched no database says nothing worth a line. */ - static void report(Logger logger, SqlLog.Summary summary, Settings settings) { + static void report(SqlLog.Summary summary, Settings settings) { if (summary.statementCount() == 0) { return; } @@ -93,15 +97,15 @@ static void report(Logger logger, SqlLog.Summary summary, Settings settings) { // At TRACE the full statement texts follow the summary, so an elided row can be matched to its // statement. TRACE rather than DEBUG because this logger is a child of st.orm.sql: raising that to DEBUG // for per-statement logging would otherwise repeat every statement the statement logger already wrote. - Object rendered = logger.isTraceEnabled() ? summary.toDetailedString() : summary; + Object rendered = LOGGER.isTraceEnabled() ? summary.toDetailedString() : summary; if (statementThreshold == null && durationThreshold == null) { - logger.info("{}", rendered); + LOGGER.info("{}", rendered); return; } boolean exceeded = (statementThreshold != null && summary.statementCount() >= statementThreshold) || (durationThreshold != null && summary.durationNanos() >= durationThreshold.toNanos()); if (exceeded) { - logger.warn("{}", rendered); + LOGGER.warn("{}", rendered); } } } diff --git a/storm-spring/src/main/java/st/orm/spring/boot/StormPerformanceLogEntryPointPostProcessor.java b/storm-spring/src/main/java/st/orm/spring/boot/StormPerformanceLogEntryPointPostProcessor.java index 27ea76baf..231558ce9 100644 --- a/storm-spring/src/main/java/st/orm/spring/boot/StormPerformanceLogEntryPointPostProcessor.java +++ b/storm-spring/src/main/java/st/orm/spring/boot/StormPerformanceLogEntryPointPostProcessor.java @@ -26,7 +26,6 @@ import org.aopalliance.intercept.MethodInvocation; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.aop.framework.Advised; import org.springframework.aop.framework.ProxyFactory; import org.springframework.aop.support.AopUtils; @@ -59,7 +58,7 @@ public class StormPerformanceLogEntryPointPostProcessor implements BeanPostProcessor, Ordered, PerformanceLog.Boundary { - private static final Logger LOGGER = LoggerFactory.getLogger("st.orm.sql.perf"); + private static final Logger LOGGER = PerformanceLog.LOGGER; private final Set entryPointAnnotations; @@ -179,7 +178,7 @@ public Object invoke(MethodInvocation invocation) throws Throwable { // Read once, so a replacement mid-invocation cannot report an invocation against settings it was not // recorded under. var settings = StormPerformanceLogEntryPointPostProcessor.this.settings; - if (!PerformanceLog.consumes(LOGGER, settings)) { + if (!PerformanceLog.consumes(settings)) { // Nothing consumes the summary, so do not open a scope to build one. return invocation.proceed(); } @@ -189,7 +188,7 @@ public Object invoke(MethodInvocation invocation) throws Throwable { return invocation.proceed(); } finally { scope.close(); - PerformanceLog.report(LOGGER, scope.summary(), settings); + PerformanceLog.report(scope.summary(), settings); } } } diff --git a/storm-spring/src/main/java/st/orm/spring/boot/StormPerformanceLogFilter.java b/storm-spring/src/main/java/st/orm/spring/boot/StormPerformanceLogFilter.java index edd44af7a..08e6aae33 100644 --- a/storm-spring/src/main/java/st/orm/spring/boot/StormPerformanceLogFilter.java +++ b/storm-spring/src/main/java/st/orm/spring/boot/StormPerformanceLogFilter.java @@ -22,8 +22,6 @@ import java.io.IOException; import java.time.Duration; import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.web.filter.OncePerRequestFilter; import st.orm.core.template.SqlLog; @@ -58,8 +56,6 @@ */ public class StormPerformanceLogFilter extends OncePerRequestFilter implements PerformanceLog.Boundary { - private static final Logger LOGGER = LoggerFactory.getLogger("st.orm.sql.perf"); - /** Read per request, so a replacement takes effect on the request after it. */ private volatile PerformanceLog.Settings settings; @@ -120,7 +116,7 @@ protected void doFilterInternal(HttpServletRequest request, // Read once, so a replacement mid-request cannot report a request against settings it was not recorded // under. var settings = this.settings; - if (!PerformanceLog.consumes(LOGGER, settings)) { + if (!PerformanceLog.consumes(settings)) { // Nothing consumes the summary, so do not open a scope to build one. chain.doFilter(request, response); return; @@ -130,7 +126,7 @@ protected void doFilterInternal(HttpServletRequest request, SqlLog.recordThrowing(name, settings.limit(), settings.callSites(), () -> { chain.doFilter(request, response); return null; - }, summary -> PerformanceLog.report(LOGGER, summary, settings)); + }, summary -> PerformanceLog.report(summary, settings)); } catch (ServletException | IOException | RuntimeException e) { throw e; } catch (Exception e) { diff --git a/storm-spring/src/main/java/st/orm/spring/boot/StormProperties.java b/storm-spring/src/main/java/st/orm/spring/boot/StormProperties.java index 26b29d8f5..1ec3b0276 100644 --- a/storm-spring/src/main/java/st/orm/spring/boot/StormProperties.java +++ b/storm-spring/src/main/java/st/orm/spring/boot/StormProperties.java @@ -248,11 +248,6 @@ public static class Validation { public void setStrict(Boolean strict) { this.strict = strict; } } - /** - * Maps these properties onto Storm's configuration. - * - * @since 1.13 - */ /** Tracing configuration. */ public static class Tracing { @@ -284,7 +279,7 @@ public static class SqlLog { * that asked for the work rather than the application's own database plumbing. Shared: both logs * attribute through the same walker. */ - private List callSiteSkip = java.util.List.of(); + private List callSiteSkip = List.of(); /** Returns the packages skipped in call-site attribution. */ public List getCallSiteSkip() { return callSiteSkip; } @@ -376,7 +371,7 @@ public static class Performance { * library is absent from the classpath never matches and costs nothing. Setting the property replaces * the default list; an empty list turns entry-point wrapping off.

*/ - private List entryPoints = java.util.List.of( + private List entryPoints = List.of( "org.springframework.scheduling.annotation.Scheduled", "org.springframework.scheduling.annotation.Schedules", "org.springframework.kafka.annotation.KafkaListener", @@ -498,6 +493,11 @@ public static class ExceptionTranslation { public void setEnabled(Boolean enabled) { this.enabled = enabled; } } + /** + * Maps these properties onto Storm's configuration. + * + * @since 1.13 + */ public StormConfig toStormConfig() { Map map = new HashMap<>(); if (update.getDefaultMode() != null) { diff --git a/storm-spring/src/main/java/st/orm/spring/impl/EntityCaches.java b/storm-spring/src/main/java/st/orm/spring/impl/EntityCaches.java new file mode 100644 index 000000000..fd481fc35 --- /dev/null +++ b/storm-spring/src/main/java/st/orm/spring/impl/EntityCaches.java @@ -0,0 +1,62 @@ +/* + * 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.spring.impl; + +import java.util.Map; +import st.orm.core.spi.CacheRetention; +import st.orm.core.spi.EntityCache; +import st.orm.core.spi.EntityCacheImpl; + +/** + * The entity-cache bookkeeping shared by the Spring transaction contexts: each context owns one cache map + * per physical transaction, keyed by entity type. + * + * @since 1.14 + */ +public final class EntityCaches { + + private EntityCaches() { + } + + /** + * Returns the cache for the entity type, creating it with the given retention on first use. + */ + @SuppressWarnings("unchecked") + public static , C extends EntityCache> C entityCache(Map caches, + K entityType, + CacheRetention retention) { + return caches.computeIfAbsent(entityType, ignore -> (C) new EntityCacheImpl<>(retention)); + } + + /** + * Returns the cache for the entity type, failing when none exists. + */ + public static , C extends EntityCache> C getEntityCache(Map caches, + K entityType) { + var cache = caches.get(entityType); + if (cache == null) { + throw new IllegalStateException("No entity cache exists for " + entityType.getName() + "."); + } + return cache; + } + + /** + * Clears every cache in the map. + */ + public static void clearAll(Map> caches) { + caches.values().forEach(EntityCache::clear); + } +} diff --git a/storm-spring/src/main/java/st/orm/spring/impl/SpringTransactionContext.java b/storm-spring/src/main/java/st/orm/spring/impl/SpringTransactionContext.java index 810d78e11..c6d9b99f0 100644 --- a/storm-spring/src/main/java/st/orm/spring/impl/SpringTransactionContext.java +++ b/storm-spring/src/main/java/st/orm/spring/impl/SpringTransactionContext.java @@ -58,7 +58,6 @@ import st.orm.UnexpectedRollbackException; import st.orm.core.spi.CacheRetention; import st.orm.core.spi.EntityCache; -import st.orm.core.spi.EntityCacheImpl; import st.orm.core.spi.TransactionContext; /** @@ -135,6 +134,10 @@ Integer remainingSeconds() { long remaining = deadlineNanos - nowNanos(); return remaining <= 0L ? 0 : (int) (remaining / NANOS_PER_SECOND); } + + boolean deadlineExpired() { + return deadlineNanos != null && nowNanos() >= deadlineNanos; + } } private final Supplier> transactionManagers; @@ -275,18 +278,15 @@ public boolean isRepeatableRead() { @Override public EntityCache, ?> entityCache(Class> entityType, CacheRetention retention) { - return (EntityCache, ?>) currentState().entityCacheMap - .computeIfAbsent(entityType, ignore -> new EntityCacheImpl<>(retention)); + return (EntityCache, ?>) EntityCaches.entityCache( + currentState().entityCacheMap, entityType, retention); } @SuppressWarnings("unchecked") @Override public EntityCache, ?> getEntityCache(Class> entityType) { - var cache = (EntityCache, ?>) currentState().entityCacheMap.get(entityType); - if (cache == null) { - throw new IllegalStateException("No entity cache exists for " + entityType.getName() + "."); - } - return cache; + return (EntityCache, ?>) EntityCaches.getEntityCache( + currentState().entityCacheMap, entityType); } @SuppressWarnings("unchecked") @@ -301,7 +301,7 @@ public boolean isRepeatableRead() { */ @Override public void clearAllEntityCaches() { - currentState().entityCacheMap.values().forEach(EntityCache::clear); + EntityCaches.clearAll(currentState().entityCacheMap); } @SuppressWarnings("unchecked") @@ -658,16 +658,14 @@ private static TransactionStatus getTransaction(PlatformTransactionManager trans private void commit() { var current = currentState(); - boolean expired = current.deadlineNanos != null && nowNanos() >= current.deadlineNanos; - if (current.rollbackOnly || expired) { + if (current.rollbackOnly || current.deadlineExpired()) { rollback(); return; } var state = popState(); // If this frame never touched a DataSource/started a status, still enforce timeout deterministically. if (state.transactionStatus == null) { - boolean expiredAfter = state.deadlineNanos != null && nowNanos() >= state.deadlineNanos; - if (expiredAfter) { + if (state.deadlineExpired()) { throw new TransactionTimedOutException( "Transaction did not complete within timeout (" + state.timeoutSeconds + "s)."); } @@ -676,8 +674,7 @@ private void commit() { try { state.transactionManager.commit(state.transactionStatus); } catch (org.springframework.transaction.TransactionTimedOutException e) { - throw new TransactionTimedOutException( - e.getMessage() == null ? "Did not complete within timeout." : e.getMessage()); + throw timedOut(e); } catch (org.springframework.transaction.UnexpectedRollbackException e) { // If Spring threw because some inner joined frame marked rollback-only, surface a clean message. throw new UnexpectedRollbackException( @@ -692,18 +689,13 @@ private void rollback() { var state = popState(); // If the status never started, just check the deadline and throw appropriately. if (state.transactionStatus == null) { - boolean expired = state.deadlineNanos != null && nowNanos() >= state.deadlineNanos; - if (expired) { - throw new TransactionTimedOutException( - "Did not complete within timeout (" + state.timeoutSeconds + "s)."); - } + throwIfDeadlineExpired(state); return; } try { state.transactionManager.rollback(state.transactionStatus); } catch (org.springframework.transaction.TransactionTimedOutException e) { - throw new TransactionTimedOutException( - e.getMessage() == null ? "Did not complete within timeout." : e.getMessage()); + throw timedOut(e); } catch (Exception e) { throw new PersistenceException(e); } @@ -716,13 +708,22 @@ private void rollback() { outer.entityCacheMap.clear(); } } - boolean expired = state.deadlineNanos != null && nowNanos() >= state.deadlineNanos; - if (expired) { + throwIfDeadlineExpired(state); + } + + private static void throwIfDeadlineExpired(TransactionState state) { + if (state.deadlineExpired()) { throw new TransactionTimedOutException( "Did not complete within timeout (" + state.timeoutSeconds + "s)."); } } + private static TransactionTimedOutException timedOut( + org.springframework.transaction.TransactionTimedOutException e) { + return new TransactionTimedOutException( + e.getMessage() == null ? "Did not complete within timeout." : e.getMessage()); + } + private TransactionState popState() { if (stack.isEmpty()) { throw new IllegalStateException("No transaction in progress to commit/rollback."); diff --git a/storm-sqlite/pom.xml b/storm-sqlite/pom.xml index ce5557592..0b30f08e5 100644 --- a/storm-sqlite/pom.xml +++ b/storm-sqlite/pom.xml @@ -37,7 +37,7 @@ maven-surefire-plugin false @@ -101,6 +101,12 @@ provided + + st.orm + storm-test + ${project.version} + test + org.springframework.boot spring-boot-starter-test diff --git a/storm-sqlite/src/main/java/st/orm/spi/sqlite/SQLiteEntityRepositoryImpl.java b/storm-sqlite/src/main/java/st/orm/spi/sqlite/SQLiteEntityRepositoryImpl.java index 975d3a5a4..b5d6c5187 100644 --- a/storm-sqlite/src/main/java/st/orm/spi/sqlite/SQLiteEntityRepositoryImpl.java +++ b/storm-sqlite/src/main/java/st/orm/spi/sqlite/SQLiteEntityRepositoryImpl.java @@ -15,36 +15,15 @@ */ package st.orm.spi.sqlite; -import static java.util.function.Predicate.not; import static st.orm.GenerationStrategy.NONE; -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.Templates.table; -import static st.orm.core.template.impl.StringTemplates.flatten; -import java.math.BigInteger; -import java.sql.Timestamp; -import java.time.Instant; -import java.util.Calendar; -import java.util.Date; import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.stream.IntStream; -import org.jspecify.annotations.Nullable; -import st.orm.Data; import st.orm.Entity; -import st.orm.NoResultException; -import st.orm.NonUniqueResultException; -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.OnConflictEntityRepositoryImpl; import st.orm.core.template.Model; import st.orm.core.template.ORMTemplate; -import st.orm.core.template.PreparedQuery; -import st.orm.core.template.TemplateString; import st.orm.core.template.impl.JoinedEntityHelper; /** @@ -54,159 +33,12 @@ * (available since SQLite 3.24).

*/ public class SQLiteEntityRepositoryImpl, ID> - extends EntityRepositoryImpl { + extends OnConflictEntityRepositoryImpl { public SQLiteEntityRepositoryImpl(ORMTemplate ormTemplate, Model model) { super(ormTemplate, model); } - private TemplateString getVersionString(Class type, Column column) { - TemplateString columnName = TemplateString.of(column.qualifiedName(ormTemplate.dialect())); - TemplateString 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) -> raw("\0.\0 + 1", table(type), columnName); - case Class c when Instant.class.isAssignableFrom(c) - || Date.class.isAssignableFrom(c) - || Calendar.class.isAssignableFrom(c) - || Timestamp.class.isAssignableFrom(c) -> TemplateString.of(ormTemplate.dialect().currentTimestamp()); - default -> - throw new PersistenceException("Unsupported version type: %s.".formatted(column.type().getSimpleName())); - }; - return flatten(raw("\0 = \0", columnName, updateExpression)); - } - - /** - * Constructs the SQLite conflict clause for an upsert. - * - *

This method builds an "ON CONFLICT () DO UPDATE SET ..." clause. - * For non-primary key columns, it assigns the value from the EXCLUDED pseudo-table. - * Version columns are updated using {@link #getVersionString(Class, Column)}.

- * - * @param versionAware a flag that will be set if a version column is encountered. - * @return the conflict clause as a TemplateString. - */ - private TemplateString onConflictClause(AtomicBoolean versionAware) { - var dialect = ormTemplate.dialect(); - String conflictTarget = model.declaredColumns().stream() - .filter(Column::primaryKey) - .map(c -> c.qualifiedName(dialect)) - .reduce("%s, %s"::formatted) - .orElseThrow(() -> new PersistenceException("No primary key defined.")); - var assignments = model.declaredColumns().stream() - .filter(not(Column::primaryKey)) - .filter(Column::updatable) - .map(column -> { - if (column.version()) { - versionAware.setPlain(true); - return getVersionString(model.type(), column); - } - return TemplateString.of("%s = EXCLUDED.%s".formatted(column.qualifiedName(dialect), column.qualifiedName(dialect))); - }) - .reduce((left, right) -> combine(left, TemplateString.of(", "), right)) - .map(st -> combine(TemplateString.of("DO UPDATE SET "), st)) - .orElse(TemplateString.of("DO NOTHING")); - return flatten(combine(TemplateString.of("\nON CONFLICT ("), TemplateString.of(conflictTarget), raw(") \0", assignments))); - } - - @Override - protected void doUpsert(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(""" - INSERT INTO \0 - VALUES \0\0""", model.type(), entity, onConflictClause(versionAware)))).managed(); - query.executeUpdate(); - }); - } - - @Override - protected ID doUpsertAndFetchId(E entity) { - validateUpsert(entity); - entityCache().ifPresent(cache -> { - if (!model.isDefaultPrimaryKey(entity.id())) { - cache.remove(entity.id()); - } - }); - var versionAware = new AtomicBoolean(); - return intercept(sql -> sql.versionAware(versionAware.getPlain()), () -> { - try (var query = ormTemplate.query(flatten(raw(""" - INSERT INTO \0 - VALUES \0\0""", model.type(), entity, onConflictClause(versionAware)))).managed().prepare()) { - query.executeUpdate(); - if (isAutoGeneratedPrimaryKey()) { - try (var stream = query.getGeneratedKeys(model.primaryKeyType())) { - return stream.reduce((ignore1, ignore2) -> { - throw new NonUniqueResultException("Expected single result, but found more than one."); - }).orElseThrow(() -> new NoResultException("Expected single result, but found none.")); - } - } - return entity.id(); - } - }); - } - - @Override - protected PreparedQuery prepareUpsertQuery() { - var bindVars = ormTemplate.createBindVars(); - var versionAware = new AtomicBoolean(); - return intercept(sql -> sql.versionAware(versionAware.getPlain()), () -> - ormTemplate.query(flatten(raw(""" - INSERT INTO \0 - VALUES \0\0""", model.type(), bindVars, onConflictClause(versionAware)))) - .managed().prepare()); - } - - @Override - protected void doUpsertBatch(List batch, 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(List batch, 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 SQLite's {@code RETURNING} clause instead of * {@code executeBatch()} followed by {@code getGeneratedKeys()}, which the SQLite JDBC driver @@ -237,10 +69,4 @@ protected List insertJoinedBatch(List entities) { JoinedEntityHelper.insertExtensionTables(ormTemplate, model, entities, ids); return ids; } - - @Override - public ID insertAndFetchId(E entity) { - // SQLite does not support sequences. - return super.insertAndFetchId(entity); - } } diff --git a/storm-sqlite/src/main/java/st/orm/spi/sqlite/SQLiteSqlDialect.java b/storm-sqlite/src/main/java/st/orm/spi/sqlite/SQLiteSqlDialect.java index 4d03cf38b..010a703d3 100644 --- a/storm-sqlite/src/main/java/st/orm/spi/sqlite/SQLiteSqlDialect.java +++ b/storm-sqlite/src/main/java/st/orm/spi/sqlite/SQLiteSqlDialect.java @@ -29,9 +29,8 @@ import st.orm.PersistenceException; import st.orm.StormConfig; import st.orm.core.spi.DefaultSqlDialect; -import st.orm.core.template.SqlDialect; -public class SQLiteSqlDialect extends DefaultSqlDialect implements SqlDialect { +public class SQLiteSqlDialect extends DefaultSqlDialect { public SQLiteSqlDialect() { } @@ -51,22 +50,6 @@ public String name() { return "SQLite"; } - /** - * SQLite does not support aliasing the target table in DELETE statements. - */ - @Override - public boolean supportsDeleteAlias() { - return false; - } - - /** - * SQLite does not support multi-value tuples in the IN clause. - */ - @Override - public boolean supportsMultiValueTuples() { - return false; - } - private static final Pattern SQLITE_IDENTIFIER = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$"); /** @@ -111,24 +94,7 @@ public String escape(String name) { } /** - * Regex for double-quoted identifiers (handling doubled double quotes as escapes). - */ - private static final Pattern IDENTIFIER_PATTERN = Pattern.compile( - "\"(?:\"\"|[^\"])*\"" - ); - - /** - * Returns the pattern for identifiers. - * - * @return the pattern for identifiers. - */ - @Override - public Pattern getIdentifierPattern() { - return IDENTIFIER_PATTERN; - } - - /** - * Regex for single-quoted string literals, handling both doubled single quotes and backslash escapes. + * Regex for single-quoted string literals, handling doubled single quotes as escapes. */ private static final Pattern QUOTE_LITERAL_PATTERN = Pattern.compile( "'(?:''|[^'])*'" diff --git a/storm-sqlite/src/test/java/st/orm/spi/sqlite/testsupport/TestSpringConnectionProvider.java b/storm-sqlite/src/test/java/st/orm/spi/sqlite/testsupport/TestSpringConnectionProvider.java deleted file mode 100644 index e883c923b..000000000 --- a/storm-sqlite/src/test/java/st/orm/spi/sqlite/testsupport/TestSpringConnectionProvider.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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.sqlite.testsupport; - -import java.sql.Connection; -import javax.sql.DataSource; -import org.jspecify.annotations.Nullable; -import org.springframework.jdbc.datasource.DataSourceUtils; -import st.orm.PersistenceException; -import st.orm.core.spi.ConnectionProvider; -import st.orm.core.spi.Orderable.BeforeAny; -import st.orm.core.spi.TransactionContext; - -/** - * Test-only connection provider that binds connections to Spring's transaction management, so the test suite's - * transaction-per-test isolation applies to statements executed by Storm templates. - */ -@BeforeAny -public class TestSpringConnectionProvider implements ConnectionProvider { - - @Override - public Connection getConnection(DataSource dataSource, @Nullable TransactionContext context) { - try { - return DataSourceUtils.getConnection(dataSource); - } catch (Exception e) { - throw new PersistenceException("Failed to get connection from DataSource.", e); - } - } - - @Override - public void releaseConnection(Connection connection, DataSource dataSource, @Nullable TransactionContext context) { - DataSourceUtils.releaseConnection(connection, dataSource); - } -} diff --git a/storm-sqlite/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-sqlite/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider index 268e99aa1..b51953f70 100644 --- a/storm-sqlite/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider +++ b/storm-sqlite/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -1 +1 @@ -st.orm.spi.sqlite.testsupport.TestSpringConnectionProvider +st.orm.test.spring.TestSpringConnectionProvider diff --git a/storm-test/pom.xml b/storm-test/pom.xml index d840c2042..5ebb49a97 100644 --- a/storm-test/pom.xml +++ b/storm-test/pom.xml @@ -123,6 +123,13 @@ h2 provided
+ + + org.springframework + spring-jdbc + provided + diff --git a/storm-test/src/main/java/module-info.java b/storm-test/src/main/java/module-info.java index 4944e493a..e1735a333 100644 --- a/storm-test/src/main/java/module-info.java +++ b/storm-test/src/main/java/module-info.java @@ -1,7 +1,10 @@ module storm.test { + // st.orm.test.spring is deliberately not exported: the suites that use it run on the classpath, where the + // ServiceLoader registration in their test resources picks it up; it is not API. exports st.orm.test; requires storm.core; requires static org.junit.jupiter.api; + requires static spring.jdbc; requires java.sql; requires java.logging; requires static org.jspecify; diff --git a/storm-test/src/main/java/st/orm/test/SqlCapture.java b/storm-test/src/main/java/st/orm/test/SqlCapture.java index 5f4481b3e..9f1b626fd 100644 --- a/storm-test/src/main/java/st/orm/test/SqlCapture.java +++ b/storm-test/src/main/java/st/orm/test/SqlCapture.java @@ -200,9 +200,7 @@ public int count() { * @return the matching statement count. */ public int count(Operation operation) { - return (int) statements.stream() - .filter(s -> s.operation() == operation) - .count(); + return statements(operation).size(); } /** @@ -216,9 +214,7 @@ public int count(Operation operation) { * @since 1.13 */ public int count(Origin origin) { - return (int) statements.stream() - .filter(s -> s.origin() == origin) - .count(); + return statements(origin).size(); } /** diff --git a/storm-h2/src/test/java/st/orm/spi/h2/testsupport/TestSpringConnectionProvider.java b/storm-test/src/main/java/st/orm/test/spring/TestSpringConnectionProvider.java similarity index 85% rename from storm-h2/src/test/java/st/orm/spi/h2/testsupport/TestSpringConnectionProvider.java rename to storm-test/src/main/java/st/orm/test/spring/TestSpringConnectionProvider.java index e78bb859a..4c8889ec3 100644 --- a/storm-h2/src/test/java/st/orm/spi/h2/testsupport/TestSpringConnectionProvider.java +++ b/storm-test/src/main/java/st/orm/test/spring/TestSpringConnectionProvider.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package st.orm.spi.h2.testsupport; +package st.orm.test.spring; import java.sql.Connection; import javax.sql.DataSource; @@ -25,8 +25,11 @@ import st.orm.core.spi.TransactionContext; /** - * Test-only connection provider that binds connections to Spring's transaction management, so the test suite's + * Test-only connection provider that binds connections to Spring's transaction management, so a test suite's * transaction-per-test isolation applies to statements executed by Storm templates. + * + *

A test suite registers this provider through {@code META-INF/services} on the test classpath; the + * production integration configures {@code SpringConnectionProvider} on the template builder instead.

*/ @BeforeAny public class TestSpringConnectionProvider implements ConnectionProvider {