diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ColumnSkipper.java b/storm-core/src/main/java/st/orm/core/template/impl/ColumnSkipper.java
new file mode 100644
index 000000000..0bd84aca7
--- /dev/null
+++ b/storm-core/src/main/java/st/orm/core/template/impl/ColumnSkipper.java
@@ -0,0 +1,220 @@
+/*
+ * 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.template.impl;
+
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import java.lang.reflect.Constructor;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+import st.orm.Entity;
+import st.orm.StormConfig;
+import st.orm.core.spi.CacheRetention;
+import st.orm.core.spi.EntityCache;
+import st.orm.core.spi.TransactionContext;
+import st.orm.core.spi.WeakInterner;
+import st.orm.core.template.SqlTemplateException;
+
+/**
+ * Skips JDBC column decoding for entity column regions whose entity is already cached.
+ *
+ *
Queries that return duplicate entity references (joins that repeat the same parent entity across rows) pay
+ * for decoding the parent's columns on every row, even though the mapper's early cache lookup skips construction
+ * on a hit. This class probes the same caches before column extraction: for each entity region it decodes
+ * only the columns up to and including the primary key, and on a cache hit leaves the remaining columns undecoded.
+ * The mapper's early lookup then resolves the entity from the primary key alone and never reads the skipped
+ * slots.
+ *
+ * Columns are always accessed in ascending order, so drivers that only support forward column access within a
+ * row are unaffected. Entities found in the query-scoped {@link WeakInterner} are pinned in a strong reference
+ * list until the next row is read, guaranteeing that the mapper's subsequent lookup of the same key succeeds even
+ * if a garbage collection runs in between (the interner holds entities weakly).
+ *
+ * The cache policy mirrors the mapper's early lookup exactly: nested entity regions consult the
+ * transaction-scoped {@link EntityCache} when the isolation level is {@code REPEATABLE_READ} or higher and the
+ * query-scoped {@link WeakInterner} otherwise; the top-level region only consults the {@link EntityCache}, since
+ * top-level records are never interned.
+ *
+ * This class is not thread-safe. A new instance is expected to be created for each query execution.
+ */
+final class ColumnSkipper {
+
+ /**
+ * A flat column region occupied by an entity that is eligible for the early cache lookup.
+ *
+ * @param start the absolute offset of the region's first column.
+ * @param end the absolute offset just past the region's last column.
+ * @param pkOffset the absolute offset of the first primary key column.
+ * @param pkColumnCount the number of columns the primary key spans.
+ * @param pkConstructor the constructor for composite primary keys (null for single-column keys).
+ * @param entityType the entity type occupying the region.
+ * @param topLevel whether the region covers the top-level record rather than a nested entity.
+ */
+ record SkipRegion(int start,
+ int end,
+ int pkOffset,
+ int pkColumnCount,
+ @Nullable Constructor> pkConstructor,
+ @Nonnull Class extends Entity>> entityType,
+ boolean topLevel) {}
+
+ /**
+ * Reads and converts a single JDBC column value.
+ */
+ @FunctionalInterface
+ interface ColumnReader {
+
+ /**
+ * Reads the column at the given zero-based index.
+ *
+ * @param index the zero-based column index.
+ * @return the converted column value, or {@code null} if the column is SQL NULL.
+ * @throws SQLException if a database access error occurs.
+ */
+ @Nullable
+ Object read(int index) throws SQLException;
+ }
+
+ private final List regions;
+ private final WeakInterner interner;
+ @Nullable
+ private final TransactionContext context;
+ @Nullable
+ private final EntityCache, ?> topLevelCache;
+ private final boolean topLevelCacheReadEnabled;
+
+ /** Strong references to interner hits for the current row; prevents collection before the mapper's lookup. */
+ private final List> pinned = new ArrayList<>();
+
+ /**
+ * Creates a new column skipper.
+ *
+ * @param regions the entity regions eligible for skipping, ordered by start offset; outer regions precede the
+ * regions they contain.
+ * @param interner the query-scoped interner, shared with the mapper.
+ * @param context the transaction context, or null if not in a transaction.
+ * @param topLevelCache the entity cache for the top-level record, or null if not applicable.
+ * @param topLevelCacheReadEnabled whether cache reads are enabled for the top-level record.
+ */
+ ColumnSkipper(@Nonnull List regions,
+ @Nonnull WeakInterner interner,
+ @Nullable TransactionContext context,
+ @Nullable EntityCache, ?> topLevelCache,
+ boolean topLevelCacheReadEnabled) {
+ this.regions = regions;
+ this.interner = interner;
+ this.context = context;
+ this.topLevelCache = topLevelCache;
+ this.topLevelCacheReadEnabled = topLevelCacheReadEnabled;
+ }
+
+ /**
+ * Reads a single row into {@code args}, skipping the non-key columns of entity regions that hit the cache.
+ * Skipped slots are left {@code null}; the mapper never reads them, as its early lookup returns the cached
+ * instance from the primary key alone.
+ *
+ * @param args the flat argument array to fill; its length must match the query's column count.
+ * @param reader reads and converts a single column value.
+ * @throws SQLException if a database access error occurs.
+ * @throws SqlTemplateException if a composite primary key cannot be constructed.
+ */
+ void readRow(@Nonnull Object[] args, @Nonnull ColumnReader reader) throws SQLException, SqlTemplateException {
+ pinned.clear();
+ boolean cacheReadEnabled = context != null && context.isRepeatableRead();
+ int i = 0;
+ for (SkipRegion region : regions) {
+ if (region.start() < i) {
+ // The region lies within an outer region that was already skipped.
+ continue;
+ }
+ for (; i < region.start(); i++) {
+ args[i] = reader.read(i);
+ }
+ // Decode up to and including the primary key columns; column access stays in ascending order.
+ int pkEnd = region.pkOffset() + region.pkColumnCount();
+ for (; i < pkEnd; i++) {
+ args[i] = reader.read(i);
+ }
+ Object pk = extractPk(args, region);
+ if (pk != null && isCached(region, pk, cacheReadEnabled)) {
+ i = region.end();
+ }
+ }
+ for (; i < args.length; i++) {
+ args[i] = reader.read(i);
+ }
+ }
+
+ /**
+ * Extracts the primary key for the given region from the decoded columns.
+ *
+ * @param args the flat argument array with the region's primary key columns decoded.
+ * @param region the region to extract the primary key for.
+ * @return the primary key value, or {@code null} if any key column is null or the key cannot be constructed.
+ */
+ @Nullable
+ private Object extractPk(@Nonnull Object[] args, @Nonnull SkipRegion region) throws SqlTemplateException {
+ if (region.pkColumnCount() == 1) {
+ return args[region.pkOffset()];
+ }
+ if (region.pkConstructor() == null) {
+ return null;
+ }
+ Object[] pkArgs = new Object[region.pkColumnCount()];
+ for (int i = 0; i < region.pkColumnCount(); i++) {
+ Object arg = args[region.pkOffset() + i];
+ if (arg == null) {
+ return null; // Null in composite PK means no valid PK.
+ }
+ pkArgs[i] = arg;
+ }
+ return ObjectMapperFactory.construct(region.pkConstructor(), pkArgs, region.pkOffset());
+ }
+
+ /**
+ * Checks whether the entity for the given region and primary key is cached, mirroring the cache policy of the
+ * mapper's early lookup.
+ *
+ * @param region the region being probed.
+ * @param pk the primary key value.
+ * @param cacheReadEnabled whether transaction-scoped cache reads are enabled for this row.
+ * @return {@code true} if the entity is cached and decoding the region's remaining columns can be skipped.
+ */
+ private boolean isCached(@Nonnull SkipRegion region, @Nonnull Object pk, boolean cacheReadEnabled) {
+ if (region.topLevel()) {
+ // Top-level records are never interned; only the entity cache applies.
+ if (topLevelCache == null || !topLevelCacheReadEnabled) {
+ return false;
+ }
+ //noinspection unchecked,rawtypes
+ return ((EntityCache) topLevelCache).get(pk).isPresent();
+ }
+ if (cacheReadEnabled) {
+ //noinspection unchecked
+ var entityCache = (EntityCache, ?>) context.entityCache(
+ region.entityType(), CacheRetention.fromConfig(StormConfig.defaults()));
+ //noinspection unchecked,rawtypes
+ return ((EntityCache) entityCache).get(pk).isPresent();
+ }
+ Entity> cached = interner.get(region.entityType(), pk);
+ if (cached != null) {
+ pinned.add(cached);
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapper.java b/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapper.java
index 709e0458e..0dd580121 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapper.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapper.java
@@ -16,6 +16,7 @@
package st.orm.core.template.impl;
import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
import st.orm.core.template.SqlTemplateException;
/**
@@ -33,6 +34,17 @@ public interface ObjectMapper {
*/
Class>[] getParameterTypes() throws SqlTemplateException;
+ /**
+ * Returns the column skipper that allows the row reader to skip decoding columns of cached entities, or
+ * {@code null} if this mapper does not support column skipping.
+ *
+ * @return the column skipper, or {@code null} if not applicable.
+ */
+ @Nullable
+ default ColumnSkipper columnSkipper() {
+ return null;
+ }
+
/**
* Creates a new instance of the type.
*
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 4dc069e7a..f8fec1546 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
@@ -160,25 +160,28 @@ private static T construct(@Nonnull Constructor constructor, @Nonnull Obj
*/
static T construct(@Nonnull Constructor constructor, @Nonnull Object[] args, int offset) throws SqlTemplateException {
try {
- Class>[] parameterTypes = constructor.getParameterTypes();
- Parameter[] parameters = constructor.getParameters();
- for (int i = 0; i < parameterTypes.length; i++) {
- Object arg = args[i];
- Class> paramType = parameterTypes[i];
- if (arg == null) {
- if (isNonnull(parameters[i])) {
+ // Constructor metadata is precomputed and cached: per-invocation getParameterTypes/getParameters calls
+ // clone their arrays, which is measurable on the row mapping hot path.
+ ConstructorMeta meta = CONSTRUCTOR_META.computeIfAbsent(constructor, ObjectMapperFactory::constructorMeta);
+ boolean[] nonNull = meta.nonNull();
+ boolean[] primitive = meta.primitive();
+ for (int i = 0; i < nonNull.length; i++) {
+ if (args[i] == null) {
+ if (nonNull[i]) {
throw new SqlTemplateException("Database returned NULL for non-nullable field '%s.%s' at column position %d. Either %s, ensure the column has a NOT NULL constraint with a default value, or verify the query returns the expected data."
- .formatted(constructor.getDeclaringClass().getSimpleName(), parameters[i].getName(), offset + i + 1, nullableHint(constructor.getDeclaringClass())));
+ .formatted(constructor.getDeclaringClass().getSimpleName(), meta.parameterNames()[i], offset + i + 1, nullableHint(constructor.getDeclaringClass())));
}
- if (paramType.isPrimitive()) {
+ if (primitive[i]) {
throw new SqlTemplateException("Database returned NULL for primitive field '%s.%s' at column position %d. Primitive types cannot hold null values. Change the field type to its wrapper class (e.g., int to Integer) and %s, or ensure the column is NOT NULL."
- .formatted(constructor.getDeclaringClass().getSimpleName(), parameters[i].getName(), offset + i + 1, nullableHint(constructor.getDeclaringClass())));
+ .formatted(constructor.getDeclaringClass().getSimpleName(), meta.parameterNames()[i], offset + i + 1, nullableHint(constructor.getDeclaringClass())));
}
}
}
- constructor.setAccessible(true);
try {
- return constructor.newInstance(args);
+ // Use the map's constructor instance: its accessible flag was set before publication, so the
+ // happens-before edge of the concurrent map makes the flag visible to all threads.
+ //noinspection unchecked
+ return (T) meta.constructor().newInstance(args);
} catch (InvocationTargetException e) {
throw e.getTargetException();
}
@@ -189,6 +192,37 @@ static T construct(@Nonnull Constructor constructor, @Nonnull Object[] ar
}
}
+ /**
+ * Precomputed constructor metadata for the row mapping hot path.
+ *
+ * @param constructor the constructor with its accessible flag set.
+ * @param parameterNames the parameter names, for error messages.
+ * @param nonNull whether each parameter is marked as non-null.
+ * @param primitive whether each parameter is a primitive type.
+ */
+ private record ConstructorMeta(@Nonnull Constructor> constructor,
+ @Nonnull String[] parameterNames,
+ @Nonnull boolean[] nonNull,
+ @Nonnull boolean[] primitive) {}
+
+ /** Cache of precomputed constructor metadata, keyed by constructor. Thread-safe for concurrent access. */
+ private static final Map, ConstructorMeta> CONSTRUCTOR_META = new ConcurrentHashMap<>();
+
+ private static ConstructorMeta constructorMeta(@Nonnull Constructor> constructor) {
+ Class>[] parameterTypes = constructor.getParameterTypes();
+ Parameter[] parameters = constructor.getParameters();
+ String[] parameterNames = new String[parameters.length];
+ boolean[] nonNull = new boolean[parameters.length];
+ boolean[] primitive = new boolean[parameters.length];
+ for (int i = 0; i < parameters.length; i++) {
+ parameterNames[i] = parameters[i].getName();
+ nonNull[i] = isNonnull(parameters[i]);
+ primitive[i] = parameterTypes[i].isPrimitive();
+ }
+ constructor.setAccessible(true);
+ return new ConstructorMeta(constructor, parameterNames, nonNull, primitive);
+ }
+
@SuppressWarnings("unchecked")
private static final Class extends Annotation> KOTLIN_METADATA = ((Supplier>) () -> {
try {
@@ -234,20 +268,17 @@ static String nullableHint(@Nonnull Class> clazz) {
}
}).get();
- private static final Map NONNULL_CACHE = new ConcurrentHashMap<>();
-
/**
* Returns true if the specified parameter is marked as non-null, false otherwise.
*
+ * Only called when building {@link ConstructorMeta}, which caches the result per constructor parameter.
+ *
* @param parameter the parameter to check for a non-null characteristics.
* @return true if the specified parameter is marked as non-null, false otherwise.
*/
static boolean isNonnull(@Nonnull Parameter parameter) {
- // Use the cache to return the result if it's already calculated
- return NONNULL_CACHE.computeIfAbsent(parameter, param ->
- param.isAnnotationPresent(PK.class)
- || (JAVAX_NONNULL != null && param.isAnnotationPresent(JAVAX_NONNULL))
- || (JAKARTA_NONNULL != null && param.isAnnotationPresent(JAKARTA_NONNULL))
- );
+ return parameter.isAnnotationPresent(PK.class)
+ || (JAVAX_NONNULL != null && parameter.isAnnotationPresent(JAVAX_NONNULL))
+ || (JAKARTA_NONNULL != null && parameter.isAnnotationPresent(JAKARTA_NONNULL));
}
}
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/QueryImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/QueryImpl.java
index 9bab3950e..9f977ec3d 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/QueryImpl.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/QueryImpl.java
@@ -623,6 +623,7 @@ protected Spliterator rowSpliterator(@Nonnull ResultSet resultSet, int co
} catch (SqlTemplateException e) {
throw new PersistenceException(e);
}
+ var columnSkipper = mapper.columnSkipper();
var calendarSupplier = lazy(() -> Calendar.getInstance(TimeZone.getTimeZone(ZoneOffset.UTC)));
return new Spliterators.AbstractSpliterator<>(Long.MAX_VALUE, Spliterator.ORDERED) {
@Override
@@ -632,8 +633,14 @@ public boolean tryAdvance(@Nonnull Consumer super T> action) {
return false;
}
Object[] args = new Object[columnCount];
- for (int i = 0; i < columnCount; i++) {
- args[i] = readColumnValue(resultSet, i + 1, types[i], calendarSupplier);
+ if (columnSkipper != null) {
+ // Skip decoding non-key columns of entities that are already cached.
+ columnSkipper.readRow(args, index ->
+ readColumnValue(resultSet, index + 1, types[index], calendarSupplier));
+ } else {
+ for (int i = 0; i < columnCount; i++) {
+ args[i] = readColumnValue(resultSet, i + 1, types[i], calendarSupplier);
+ }
}
action.accept(mapper.newInstance(args));
return true;
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 0df9c5633..bd2527a05 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
@@ -364,8 +364,12 @@ private static SealedCompiled compileSealedPlan(@Nonnull Class> sealedType,
*
* @param plan the compiled argument plan for adapting flat JDBC args to constructor args.
* @param parameterTypes the expanded JDBC column types (flattened from nested records).
+ * @param skipRegions the column regions of nested entities eligible for skipping decode on cache hits.
*/
- private record Compiled(@Nonnull ArgumentPlan plan, @Nonnull Class>[] parameterTypes, @Nonnull PkInfo pkInfo) {}
+ private record Compiled(@Nonnull ArgumentPlan plan,
+ @Nonnull Class>[] parameterTypes,
+ @Nonnull PkInfo pkInfo,
+ @Nonnull List skipRegions) {}
/** Global cache of compiled plans, keyed by record class. Thread-safe for concurrent access. */
private static final ConcurrentMap, Compiled> COMPILED = new ConcurrentHashMap<>();
@@ -386,7 +390,10 @@ private static Compiled compiledFor(@Nonnull RecordType type,
PkInfo pkInfo = Entity.class.isAssignableFrom(type.type())
? calculatePkInfo(type)
: PkInfo.NONE;
- return new Compiled(compilePlan(type), expandParameterTypes(type, refFactory), pkInfo);
+ List skipRegions = new ArrayList<>();
+ collectSkipRegions(type, 0, skipRegions);
+ return new Compiled(compilePlan(type), expandParameterTypes(type, refFactory), pkInfo,
+ List.copyOf(skipRegions));
} catch (SqlTemplateException e) {
throw new RuntimeException(e);
}
@@ -460,12 +467,19 @@ private static ObjectMapper wrapConstructor(@Nonnull RecordType type,
entityCache = null;
}
PkInfo pkInfo = compiled.pkInfo();
+ ColumnSkipper columnSkipper = createColumnSkipper(type, compiled, cacheReadEnabled, entityCache,
+ interner, transactionContext);
return new ObjectMapper<>() {
@Override
public Class>[] getParameterTypes() {
return compiled.parameterTypes();
}
+ @Override
+ public ColumnSkipper columnSkipper() {
+ return columnSkipper;
+ }
+
@SuppressWarnings("unchecked")
@Override
public T newInstance(@Nonnull Object[] args) throws SqlTemplateException {
@@ -531,6 +545,48 @@ private Object extractPk(@Nonnull Object[] args, @Nonnull PkInfo pkInfo) throws
};
}
+ /**
+ * Creates the column skipper for the given compiled plan, or {@code null} when no column region can ever be
+ * skipped for the mapped type.
+ *
+ * The top-level region mirrors the early cache lookup in the mapper's {@code newInstance}: it only applies
+ * when cache reads are enabled, as top-level records are never interned.
+ *
+ * @param type the mapped record type.
+ * @param compiled the compiled plan for the mapped type.
+ * @param cacheReadEnabled whether transaction-scoped cache reads are enabled.
+ * @param entityCache the entity cache for the top-level record, or null if not applicable.
+ * @param interner the query-scoped interner, shared with the mapper.
+ * @param transactionContext the transaction context, or null if not in a transaction.
+ * @return the column skipper, or {@code null} if no region is eligible.
+ */
+ @Nullable
+ private static ColumnSkipper createColumnSkipper(@Nonnull RecordType type,
+ @Nonnull Compiled compiled,
+ boolean cacheReadEnabled,
+ @Nullable EntityCache, ?> entityCache,
+ @Nonnull WeakInterner interner,
+ @Nullable TransactionContext transactionContext) {
+ PkInfo pkInfo = compiled.pkInfo();
+ boolean topLevel = entityCache != null && cacheReadEnabled && pkInfo.offset() >= 0
+ && (pkInfo.columnCount() == 1 || pkInfo.constructor() != null);
+ List skipRegions = compiled.skipRegions();
+ if (!topLevel && skipRegions.isEmpty()) {
+ return null;
+ }
+ List regions;
+ if (topLevel) {
+ regions = new ArrayList<>(skipRegions.size() + 1);
+ //noinspection unchecked
+ regions.add(new ColumnSkipper.SkipRegion(0, compiled.parameterTypes().length, pkInfo.offset(),
+ pkInfo.columnCount(), pkInfo.constructor(), (Class extends Entity>>) type.type(), true));
+ regions.addAll(skipRegions);
+ } else {
+ regions = skipRegions;
+ }
+ return new ColumnSkipper(regions, interner, transactionContext, entityCache, cacheReadEnabled);
+ }
+
/**
* Expands the specified parameter types to include the types of record components.
*
@@ -765,16 +821,17 @@ interface ConverterInvoker {
*
*/
private static final class EnumStep implements Step {
- private final Class> enumType;
private final EnumType mapping;
private final String ownerSimpleName;
private final String fieldName;
+ private final ObjectMapper> enumMapper;
private EnumStep(Class> enumType, EnumType mapping, String ownerSimpleName, String fieldName) {
- this.enumType = enumType;
this.mapping = mapping;
this.ownerSimpleName = ownerSimpleName;
this.fieldName = fieldName;
+ // Resolved once at plan compilation; the factory lookup is too costly to repeat per row.
+ this.enumMapper = EnumMapper.getFactory(1, enumType).orElseThrow();
}
@Override
@@ -800,7 +857,7 @@ public Object apply(@Nonnull Object[] flatArgs,
);
}
};
- return EnumMapper.getFactory(1, enumType).orElseThrow().newInstance(new Object[]{v});
+ return enumMapper.newInstance(new Object[]{v});
}
}
@@ -1167,6 +1224,51 @@ private static PkInfo calculatePkInfo(@Nonnull RecordType type) throws SqlTempla
return new PkInfo(offset, pkColumnCount, pkConstructor);
}
+ /**
+ * Collects the flat column regions of nested entities that are eligible for the early cache lookup, in
+ * ascending column order with outer regions preceding the regions they contain.
+ *
+ * These regions allow the row reader to skip decoding the non-key columns of an entity that is already
+ * cached; see {@link ColumnSkipper}. A region is only eligible when the primary key can be extracted directly
+ * from the flat columns, mirroring the conditions of the early cache lookup in {@link RecordStep}.
+ *
+ * @param type the record type to analyze.
+ * @param base the absolute column offset at which the record type starts.
+ * @param out the list to add the regions to.
+ */
+ private static void collectSkipRegions(@Nonnull RecordType type,
+ int base,
+ @Nonnull List out) throws SqlTemplateException {
+ int cursor = base;
+ for (RecordField field : type.fields()) {
+ var converter = getORMConverter(field);
+ if (converter.isPresent()) {
+ cursor += converter.get().getParameterCount();
+ continue;
+ }
+ if (isRecord(field.type())) {
+ RecordType sub = getRecordType(field.type());
+ int totalColumnCount = getParameterCount(sub);
+ if (Entity.class.isAssignableFrom(sub.type())) {
+ PkInfo pkInfo = calculatePkInfo(sub);
+ if (pkInfo.offset() >= 0 && (pkInfo.columnCount() == 1 || pkInfo.constructor() != null)) {
+ //noinspection unchecked
+ out.add(new ColumnSkipper.SkipRegion(cursor, cursor + totalColumnCount,
+ cursor + pkInfo.offset(), pkInfo.columnCount(), pkInfo.constructor(),
+ (Class extends Entity>>) sub.type(), false));
+ }
+ }
+ collectSkipRegions(sub, cursor, out);
+ cursor += totalColumnCount;
+ } else if (Ref.class.isAssignableFrom(field.type()) && isPolymorphicData(getRefDataType(field))) {
+ // Polymorphic FK: discriminator + PK columns.
+ cursor += 2;
+ } else {
+ cursor += 1;
+ }
+ }
+ }
+
/**
* Returns the number of JDBC columns a field consumes in the flat args array.
*
diff --git a/storm-core/src/test/java/st/orm/core/ColumnSkipperIntegrationTest.java b/storm-core/src/test/java/st/orm/core/ColumnSkipperIntegrationTest.java
new file mode 100644
index 000000000..5db7ae09d
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/ColumnSkipperIntegrationTest.java
@@ -0,0 +1,161 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Proxy;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.sql.DataSource;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import st.orm.core.model.City;
+import st.orm.core.model.Owner;
+import st.orm.core.model.Pet;
+import st.orm.core.template.ORMTemplate;
+
+/**
+ * Verifies that the row reader skips decoding the non-key columns of entities that are already interned within the
+ * query. The pet graph joins each pet to its owner and the owner's city, so owners and cities repeat across rows;
+ * only their first occurrence should decode the full column region, subsequent rows should decode the primary key
+ * alone.
+ */
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration(classes = IntegrationConfig.class)
+@DataJpaTest(showSql = false)
+public class ColumnSkipperIntegrationTest {
+
+ // Flat column layout of the Pet graph: pet (id, name, birth_date, type ref), then the owner region
+ // (id, first_name, last_name, address, city region (id, name), telephone, version).
+ private static final int PET_COLUMNS = 4;
+ private static final int OWNER_PREFIX_COLUMNS = 3; // Owner columns between the owner PK and the city region.
+ private static final int CITY_COLUMNS = 2;
+ private static final int OWNER_SUFFIX_COLUMNS = 2; // Owner columns after the city region.
+
+ @Autowired
+ private DataSource dataSource;
+
+ @Test
+ public void testSkipsDecodingColumnsOfInternedEntities() throws Exception {
+ var counter = new ColumnReadCounter();
+ List pets;
+ try (var connection = dataSource.getConnection()) {
+ pets = ORMTemplate.of(counter.wrap(connection)).entity(Pet.class).select().getResultList();
+ }
+
+ // Repeated owners and cities must resolve to the same instance (query-scoped interning).
+ Map ownersById = new HashMap<>();
+ Map citiesById = new HashMap<>();
+ for (Pet pet : pets) {
+ Owner owner = pet.owner();
+ if (owner != null) {
+ assertSame(ownersById.merge(owner.id(), owner, (a, b) -> a), owner);
+ City city = owner.address().city();
+ if (city != null) {
+ assertSame(citiesById.merge(city.id(), city, (a, b) -> a), city);
+ }
+ }
+ }
+
+ // Hydration must be unaffected by the skipped columns.
+ var reference = ORMTemplate.of(dataSource).entity(Pet.class).select().getResultList();
+ assertEquals(Set.copyOf(reference), Set.copyOf(pets));
+
+ // Each row decodes the pet columns and the owner PK. The remaining owner and city columns are only decoded
+ // on their first occurrence; when the owner PK is null (left join), the full region decodes as usual.
+ Set seenOwners = new HashSet<>();
+ Set seenCities = new HashSet<>();
+ int expected = 0;
+ for (Pet pet : pets) {
+ expected += PET_COLUMNS + 1;
+ Owner owner = pet.owner();
+ if (owner == null) {
+ expected += OWNER_PREFIX_COLUMNS + CITY_COLUMNS + OWNER_SUFFIX_COLUMNS;
+ continue;
+ }
+ if (!seenOwners.add(owner.id())) {
+ continue;
+ }
+ expected += OWNER_PREFIX_COLUMNS + 1 + OWNER_SUFFIX_COLUMNS;
+ City city = owner.address().city();
+ if (city == null || seenCities.add(city.id())) {
+ expected += CITY_COLUMNS - 1;
+ }
+ }
+ assertEquals(expected, counter.reads().get());
+ }
+
+ /**
+ * Wraps a connection so that every positional column getter on the result sets it produces is counted.
+ */
+ private static final class ColumnReadCounter {
+ private final AtomicInteger reads = new AtomicInteger();
+
+ AtomicInteger reads() {
+ return reads;
+ }
+
+ Connection wrap(Connection connection) {
+ return (Connection) proxy(connection, Connection.class);
+ }
+
+ private Object proxy(Object target, Class> type) {
+ InvocationHandler handler = (proxyInstance, method, args) -> {
+ if (type == ResultSet.class
+ && method.getName().startsWith("get")
+ && args != null && args.length > 0 && args[0] instanceof Integer) {
+ reads.incrementAndGet();
+ }
+ Object result;
+ try {
+ result = method.invoke(target, args);
+ } catch (InvocationTargetException e) {
+ throw e.getTargetException();
+ }
+ if (result instanceof ResultSet resultSet) {
+ return proxy(resultSet, ResultSet.class);
+ }
+ if (result instanceof PreparedStatement statement) {
+ return proxy(statement, PreparedStatement.class);
+ }
+ if (result instanceof Statement statement) {
+ return proxy(statement, Statement.class);
+ }
+ if (result instanceof Connection connection) {
+ return proxy(connection, Connection.class);
+ }
+ return result;
+ };
+ return Proxy.newProxyInstance(getClass().getClassLoader(), new Class>[] { type }, handler);
+ }
+ }
+}