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
3 changes: 3 additions & 0 deletions storm-core/src/main/java/st/orm/core/spi/OrderableHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ static <T extends Orderable<?>> List<T> sort(@Nonnull List<T> orderables) {
* @return Sorted list of orderables.
*/
static <T extends Orderable<?>> List<T> sort(@Nonnull List<T> orderables, boolean cache) {
if (orderables.size() <= 1) {
return orderables; // A list of zero or one elements is already sorted; skip the graph and topological sort.
}
List<Class<?>> classOrder = getClassOrder(orderables.stream()
.map(Object::getClass)
.collect(toList()), cache);
Expand Down
16 changes: 16 additions & 0 deletions storm-core/src/main/java/st/orm/core/template/QueryBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,14 @@ public int size() {
* @throws PersistenceException if the single row's value is null, or the query fails.
*/
public final R getSingleResult() {
return singleResultInternal();
}

/**
* Backs {@link #getSingleResult()}. The default consumes {@link #getMaterializedResultStream()}; subclasses may
* override with a cheaper single-row path.
*/
protected R singleResultInternal() {
try (var stream = getMaterializedResultStream()) {
var iterator = stream.iterator();
if (!iterator.hasNext()) {
Expand All @@ -1179,6 +1187,14 @@ public final R getSingleResult() {
* @throws PersistenceException if the single row's value is null, or the query fails.
*/
public final Optional<R> getOptionalResult() {
return optionalResultInternal();
}

/**
* Backs {@link #getOptionalResult()}. The default consumes {@link #getMaterializedResultStream()}; subclasses may
* override with a cheaper single-row path.
*/
protected Optional<R> optionalResultInternal() {
try (var stream = getMaterializedResultStream()) {
var iterator = stream.iterator();
if (!iterator.hasNext()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import static st.orm.core.template.TemplateString.wrap;

import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
Expand Down Expand Up @@ -501,14 +502,27 @@ public PredicateBuilder<TX, RX, IDX> notExists(@Nonnull QueryBuilder<?, ?, ?> su
return new PredicateBuilderImpl<>(TemplateString.raw("NOT EXISTS (\0)", subquery));
}

/**
* Returns the root primary-key metamodel to pin on id/ref predicates, or {@code null} when the model has no
* single primary-key path. Pinning lets binding resolve the target column directly instead of re-deriving it
* from the value's runtime type on every execution; passing {@code null} falls back to that derivation, so the
* behavior is identical to the unpinned path for models without a primary key.
*/
@Nullable
private Metamodel<?, ?> primaryKeyMetamodel() {
return queryBuilder.modelSupplier.get().getPrimaryKeyMetamodel().orElse(null);
}

@Override
public PredicateBuilder<TX, RX, IDX> whereId(@Nonnull IDX id) {
return new PredicateBuilderImpl<>(wrap(new ObjectExpression(EQUALS, id)));
// whereId targets the root primary key by contract, so pin its metamodel.
return new PredicateBuilderImpl<>(wrap(new ObjectExpression(primaryKeyMetamodel(), EQUALS, id)));
}

@Override
public PredicateBuilder<TX, RX, IDX> whereRef(@Nonnull Ref<TX> ref) {
return new PredicateBuilderImpl<>(wrap(new ObjectExpression(EQUALS, ref)));
// A ref to the root entity resolves to its primary key, so pin the primary-key metamodel.
return new PredicateBuilderImpl<>(wrap(new ObjectExpression(primaryKeyMetamodel(), EQUALS, ref)));
}

@Override
Expand All @@ -528,12 +542,14 @@ public PredicateBuilder<TX, RX, IDX> whereAny(@Nonnull Data record) {

@Override
public PredicateBuilder<TX, RX, IDX> whereId(@Nonnull Iterable<? extends IDX> it) {
return new PredicateBuilderImpl<>(wrap(new ObjectExpression(IN, it)));
// whereId targets the root primary key by contract, so pin its metamodel.
return new PredicateBuilderImpl<>(wrap(new ObjectExpression(primaryKeyMetamodel(), IN, it)));
}

@Override
public PredicateBuilder<TX, RX, IDX> whereRef(@Nonnull Iterable<? extends Ref<TX>> it) {
return new PredicateBuilderImpl<>(wrap(new ObjectExpression(IN, it)));
// Refs to the root entity resolve to its primary key, so pin the primary-key metamodel.
return new PredicateBuilderImpl<>(wrap(new ObjectExpression(primaryKeyMetamodel(), IN, it)));
}

@Override
Expand Down
86 changes: 80 additions & 6 deletions storm-core/src/main/java/st/orm/core/template/impl/QueryImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
import java.util.stream.StreamSupport;
import st.orm.Data;
import st.orm.Entity;
import st.orm.NoResultException;
import st.orm.NonUniqueResultException;
import st.orm.PersistenceException;
import st.orm.Ref;
import st.orm.core.spi.QueryContext;
Expand Down Expand Up @@ -399,6 +401,60 @@ public <T extends Data> Stream<Ref<T>> getRefStream(@Nonnull Class<T> type, @Non
});
}

/**
* The at-most-one row read by {@link #readSingleRow(Class)}: {@code present} distinguishes an absent row from a
* present row whose mapped value is {@code null} (a value-type pass-through for a SQL NULL column), which a bare
* nullable reference could not.
*/
private record SingleRow<T>(boolean present, @Nullable T value) {}

/**
* Executes the query and reads at most one row without materializing a stream. This mirrors the resource handling
* of {@link #getResultStream(Class)} but consumes and closes synchronously in the same call, so it skips the
* stream pipeline, the leak-detection proxy, and the deferred close registered by {@code getResultStream}, none of
* which earn anything when the caller wants a single row. Reads a second row only to enforce uniqueness.
*
* @throws NonUniqueResultException if the query returns more than one row.
*/
private <T> SingleRow<T> readSingleRow(@Nonnull Class<T> type) {
var observation = observe(ExecutionKind.QUERY);
try {
PreparedStatement statement = getStatement();
boolean closeStatementHere = true;
try {
applyFetchSize(statement);
Runnable streamingCleanup = configureStreamingTransaction(statement);
ResultSet resultSet = statement.executeQuery();
closeStatementHere = false; // close(resultSet, statement, ...) below owns the statement from here.
try {
int columnCount = resultSet.getMetaData().getColumnCount();
var mapper = getObjectMapper(columnCount, type, refFactory)
.orElseThrow(() -> new SqlTemplateException("No suitable constructor found for %s.".formatted(type.getName())));
var spliterator = rowSpliterator(resultSet, columnCount, mapper);
var holder = new Object[1];
// tryAdvance's boolean return, not the value, signals presence: the mapped value may itself be null.
boolean present = spliterator.tryAdvance(value -> holder[0] = value);
if (present && spliterator.tryAdvance(ignore -> { })) {
throw new NonUniqueResultException("Expected single result, but found more than one.");
}
//noinspection unchecked
return new SingleRow<>(present, (T) holder[0]);
} finally {
close(resultSet, statement, streamingCleanup);
}
} finally {
if (closeStatementHere && closeStatement()) {
statement.close();
}
}
} catch (Exception e) {
observationError(observation, e);
throw exceptionTransformer.apply(e);
} finally {
closeObservation(observation);
}
}

@Override
public Object[] getSingleResult() {
return streamOnlyFetchSize && defaultFetchSize != 0
Expand All @@ -408,9 +464,18 @@ public Object[] getSingleResult() {

@Override
public <T> T getSingleResult(@Nonnull Class<T> type) {
return streamOnlyFetchSize && defaultFetchSize != 0
? withoutFetchSize().getSingleResult(type)
: Query.super.getSingleResult(type);
if (streamOnlyFetchSize && defaultFetchSize != 0) {
return withoutFetchSize().getSingleResult(type);
}
// Read the single row directly instead of building the full stream pipeline (see readSingleRow).
SingleRow<T> row = readSingleRow(type);
if (!row.present()) {
throw new NoResultException("Expected single result, but found none.");
}
if (row.value() == null) {
throw new PersistenceException("Expected single result, but found null. Wrap the field in COALESCE() to provide a non-null default.");
}
return row.value();
}

@Override
Expand All @@ -422,9 +487,18 @@ public Optional<Object[]> getOptionalResult() {

@Override
public <T> Optional<T> getOptionalResult(@Nonnull Class<T> type) {
return streamOnlyFetchSize && defaultFetchSize != 0
? withoutFetchSize().getOptionalResult(type)
: Query.super.getOptionalResult(type);
if (streamOnlyFetchSize && defaultFetchSize != 0) {
return withoutFetchSize().getOptionalResult(type);
}
// Read the single row directly instead of building the full stream pipeline (see readSingleRow).
SingleRow<T> row = readSingleRow(type);
if (!row.present()) {
return Optional.empty();
}
if (row.value() == null) {
throw new PersistenceException("Result is null. Wrap the field in COALESCE() to provide a non-null default.");
}
return Optional.of(row.value());
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -734,10 +734,20 @@ final class Offset {
private static final class CompiledArgumentPlan implements ArgumentPlan {
private final RecordType type;
private final Step[] steps;
/** True when every step is a one-column pass-through, so the flat args line up with the constructor params. */
private final boolean trivial;

private CompiledArgumentPlan(@Nonnull RecordType type, @Nonnull Step[] steps) {
this.type = type;
this.steps = steps;
boolean allPlain = true;
for (Step step : steps) {
if (!(step instanceof PlainStep)) {
allPlain = false;
break;
}
}
this.trivial = allPlain;
}

@Override
Expand All @@ -747,6 +757,21 @@ public Result adapt(@Nonnull Object[] flatArgs,
@Nonnull RefFactory refFactory,
@Nonnull WeakInterner interner,
@Nullable TransactionContext tx) throws SqlTemplateException {
if (trivial && offset == 0 && flatArgs.length == steps.length) {
// Every step is a one-column pass-through and the flat args already line up one-to-one with the
// constructor parameters, so validate non-null components in place and reuse the array directly
// instead of allocating and copying into a second one.
for (int p = 0; p < steps.length; p++) {
RecordField field = type.fields().get(p);
if (!(parentNullable || field.nullable()) && isArgNull(flatArgs[p])) {
throw new SqlTemplateException(
"Database returned NULL for non-nullable component '%s.%s'. Either %s, or ensure the corresponding column is NOT NULL in the database."
.formatted(type.type().getSimpleName(), field.name(), nullableHint(type.type()))
);
}
}
return new Result(flatArgs, offset + steps.length);
}
Object[] constructorArgs = new Object[steps.length];
Step.Offset stepOffset = new Step.Offset(offset);
for (int p = 0; p < steps.length; p++) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.stream.Stream;
import st.orm.Data;
Expand Down Expand Up @@ -358,4 +359,22 @@ private Stream<R> getResultStream(@Nonnull Query query) {
}
return query.getResultStream(selectType);
}

@Override
protected R singleResultInternal() {
if (refType != null) {
// Ref results are produced through getRefStream, which the single-row query path does not cover.
return super.singleResultInternal();
}
return build().withoutFetchSize().getSingleResult(selectType);
}

@Override
protected Optional<R> optionalResultInternal() {
if (refType != null) {
// Ref results are produced through getRefStream, which the single-row query path does not cover.
return super.optionalResultInternal();
}
return build().withoutFetchSize().getOptionalResult(selectType);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ public <R> R get(@Nonnull Supplier<? extends R> op) {
private static final ReadWriteLock LOCK = new ReentrantReadWriteLock();
private static final Set<Object> GLOBAL_OPERATORS = newSetFromMap(new IdentityHashMap<>());

/**
* Number of registered global operators. Written under {@link #LOCK}'s write lock, read without locking so the
* hot {@link #intercept(Sql)} path can skip acquiring the read lock when no global operators are registered.
*/
private static volatile int globalOperatorCount = 0;

private static final ThreadLocal<Deque<Operator>> LOCAL_OPERATORS = ThreadLocal.withInitial(() -> new ArrayDeque<>(4));

private SqlInterceptorManager() {
Expand All @@ -126,6 +132,7 @@ public static void registerGlobalInterceptor(@Nonnull UnaryOperator<Sql> interce
LOCK.writeLock().lock();
try {
GLOBAL_OPERATORS.add(interceptor);
globalOperatorCount = GLOBAL_OPERATORS.size();
} finally {
LOCK.writeLock().unlock();
}
Expand All @@ -140,6 +147,7 @@ public static void registerGlobalObserver(@Nonnull Consumer<Sql> observer) {
LOCK.writeLock().lock();
try {
GLOBAL_OPERATORS.add(observer);
globalOperatorCount = GLOBAL_OPERATORS.size();
} finally {
LOCK.writeLock().unlock();
}
Expand All @@ -154,6 +162,7 @@ public static void unregisterGlobalObserver(@Nonnull UnaryOperator<Sql> observer
LOCK.writeLock().lock();
try {
GLOBAL_OPERATORS.remove(observer);
globalOperatorCount = GLOBAL_OPERATORS.size();
} finally {
LOCK.writeLock().unlock();
}
Expand All @@ -168,6 +177,7 @@ public static void unregisterGlobalObserver(@Nonnull Consumer<Sql> observer) {
LOCK.writeLock().lock();
try {
GLOBAL_OPERATORS.remove(observer);
globalOperatorCount = GLOBAL_OPERATORS.size();
} finally {
LOCK.writeLock().unlock();
}
Expand Down Expand Up @@ -288,6 +298,10 @@ static Sql intercept(@Nonnull Sql sql) {
} catch (ConcurrentModificationException e) {
throw new PersistenceException("Registering interceptors from within their execution scope is not allowed.");
}
if (globalOperatorCount == 0) {
// No global operators are registered, so skip acquiring the read lock entirely.
return adjusted;
}
LOCK.readLock().lock();
try {
for (var operator : GLOBAL_OPERATORS) {
Expand Down
36 changes: 23 additions & 13 deletions storm-core/src/main/java/st/orm/core/template/impl/SqlParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@

import jakarta.annotation.Nonnull;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.regex.Pattern;
import st.orm.core.template.SqlDialect;
import st.orm.core.template.SqlOperation;
Expand All @@ -38,12 +36,6 @@
final class SqlParser {

private static final Pattern WITH_PATTERN = Pattern.compile("^(?i:WITH)\\b.*", DOTALL);
private static final Map<Pattern, SqlOperation> SQL_MODES = Map.of(
Pattern.compile("^(?i:SELECT)\\b.*", DOTALL), SELECT,
Pattern.compile("^(?i:INSERT)\\b.*", DOTALL), INSERT,
Pattern.compile("^(?i:UPDATE)\\b.*", DOTALL), UPDATE,
Pattern.compile("^(?i:DELETE)\\b.*", DOTALL), DELETE
);
private static final Pattern WHERE_PATTERN = Pattern.compile(
"(?i:\\bWHERE\\b)", DOTALL
);
Expand Down Expand Up @@ -78,11 +70,29 @@ private static String getRawSql(@Nonnull TemplateString template) {
* @return the SQL mode.
*/
private static SqlOperation getSqlOperation(@Nonnull String sql) {
return SQL_MODES.entrySet().stream()
.filter(e -> e.getKey().matcher(sql).matches())
.map(Entry::getValue)
.findFirst()
.orElse(UNDEFINED);
// Match a leading SQL keyword followed by a word boundary, mirroring the "^(?i:KEYWORD)\b" patterns without
// evaluating a stream of regexes on every call.
if (startsWithKeyword(sql, "SELECT")) return SELECT;
if (startsWithKeyword(sql, "INSERT")) return INSERT;
if (startsWithKeyword(sql, "UPDATE")) return UPDATE;
if (startsWithKeyword(sql, "DELETE")) return DELETE;
return UNDEFINED;
}

/**
* Returns whether {@code sql} begins with {@code keyword} (case-insensitive) followed by a word boundary, matching
* the semantics of the {@code ^(?i:keyword)\b} anchored pattern.
*/
private static boolean startsWithKeyword(@Nonnull String sql, @Nonnull String keyword) {
int length = keyword.length();
if (sql.length() < length || !sql.regionMatches(true, 0, keyword, 0, length)) {
return false;
}
if (sql.length() == length) {
return true;
}
char next = sql.charAt(length);
return !(Character.isLetterOrDigit(next) || next == '_');
}

/**
Expand Down
Loading
Loading