Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions storm-core/src/main/java/st/orm/core/template/impl/ColumnSkipper.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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 <em>before</em> 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.</p>
*
* <p>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).</p>
*
* <p>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.</p>
*
* <p>This class is not thread-safe. A new instance is expected to be created for each query execution.</p>
*/
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<SkipRegion> regions;
private final WeakInterner interner;
@Nullable
private final TransactionContext context;
@Nullable
private final EntityCache<Entity<?>, ?> topLevelCache;
private final boolean topLevelCacheReadEnabled;

/** Strong references to interner hits for the current row; prevents collection before the mapper's lookup. */
private final List<Entity<?>> 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<SkipRegion> regions,
@Nonnull WeakInterner interner,
@Nullable TransactionContext context,
@Nullable EntityCache<Entity<?>, ?> 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<Entity<?>, ?>) 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package st.orm.core.template.impl;

import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import st.orm.core.template.SqlTemplateException;

/**
Expand All @@ -33,6 +34,17 @@ public interface ObjectMapper<T> {
*/
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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,25 +160,28 @@ private static <T> T construct(@Nonnull Constructor<T> constructor, @Nonnull Obj
*/
static <T> T construct(@Nonnull Constructor<T> 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();
}
Expand All @@ -189,6 +192,37 @@ static <T> T construct(@Nonnull Constructor<T> 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<Constructor<?>, 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<Class<? extends Annotation>>) () -> {
try {
Expand Down Expand Up @@ -234,20 +268,17 @@ static String nullableHint(@Nonnull Class<?> clazz) {
}
}).get();

private static final Map<Parameter, Boolean> NONNULL_CACHE = new ConcurrentHashMap<>();

/**
* Returns true if the specified parameter is marked as non-null, false otherwise.
*
* <p>Only called when building {@link ConstructorMeta}, which caches the result per constructor parameter.</p>
*
* @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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,7 @@ protected <T> Spliterator<T> 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
Expand All @@ -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;
Expand Down
Loading
Loading