diff --git a/storm-core/src/main/java/st/orm/core/spi/OrderableHelper.java b/storm-core/src/main/java/st/orm/core/spi/OrderableHelper.java index 1ecfcdb72..5f613ffd0 100644 --- a/storm-core/src/main/java/st/orm/core/spi/OrderableHelper.java +++ b/storm-core/src/main/java/st/orm/core/spi/OrderableHelper.java @@ -89,6 +89,9 @@ static > List sort(@Nonnull List orderables) { * @return Sorted list of orderables. */ static > List sort(@Nonnull List 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> classOrder = getClassOrder(orderables.stream() .map(Object::getClass) .collect(toList()), cache); 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 536396376..9605849e8 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 @@ -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()) { @@ -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 getOptionalResult() { + return optionalResultInternal(); + } + + /** + * Backs {@link #getOptionalResult()}. The default consumes {@link #getMaterializedResultStream()}; subclasses may + * override with a cheaper single-row path. + */ + protected Optional optionalResultInternal() { try (var stream = getMaterializedResultStream()) { var iterator = stream.iterator(); if (!iterator.hasNext()) { 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 d0ae9f5c7..e08263811 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 @@ -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; @@ -501,14 +502,27 @@ public PredicateBuilder 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 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 whereRef(@Nonnull Ref 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 @@ -528,12 +542,14 @@ public PredicateBuilder whereAny(@Nonnull Data record) { @Override public PredicateBuilder whereId(@Nonnull Iterable 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 whereRef(@Nonnull Iterable> 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 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 a6cfb4451..7016af021 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 @@ -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; @@ -399,6 +401,60 @@ public Stream> getRefStream(@Nonnull Class 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(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 SingleRow readSingleRow(@Nonnull Class 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 @@ -408,9 +464,18 @@ public Object[] getSingleResult() { @Override public T getSingleResult(@Nonnull Class 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 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 @@ -422,9 +487,18 @@ public Optional getOptionalResult() { @Override public Optional getOptionalResult(@Nonnull Class 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 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 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 0ced231c7..2f64bef79 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 @@ -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 @@ -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++) { 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 9e35d38f5..1e14482ee 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 @@ -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; @@ -358,4 +359,22 @@ private Stream 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 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); + } } 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 ed309c7fd..f508f3ef8 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 @@ -112,6 +112,12 @@ public R get(@Nonnull Supplier op) { private static final ReadWriteLock LOCK = new ReentrantReadWriteLock(); private static final Set 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> LOCAL_OPERATORS = ThreadLocal.withInitial(() -> new ArrayDeque<>(4)); private SqlInterceptorManager() { @@ -126,6 +132,7 @@ public static void registerGlobalInterceptor(@Nonnull UnaryOperator interce LOCK.writeLock().lock(); try { GLOBAL_OPERATORS.add(interceptor); + globalOperatorCount = GLOBAL_OPERATORS.size(); } finally { LOCK.writeLock().unlock(); } @@ -140,6 +147,7 @@ public static void registerGlobalObserver(@Nonnull Consumer observer) { LOCK.writeLock().lock(); try { GLOBAL_OPERATORS.add(observer); + globalOperatorCount = GLOBAL_OPERATORS.size(); } finally { LOCK.writeLock().unlock(); } @@ -154,6 +162,7 @@ public static void unregisterGlobalObserver(@Nonnull UnaryOperator observer LOCK.writeLock().lock(); try { GLOBAL_OPERATORS.remove(observer); + globalOperatorCount = GLOBAL_OPERATORS.size(); } finally { LOCK.writeLock().unlock(); } @@ -168,6 +177,7 @@ public static void unregisterGlobalObserver(@Nonnull Consumer observer) { LOCK.writeLock().lock(); try { GLOBAL_OPERATORS.remove(observer); + globalOperatorCount = GLOBAL_OPERATORS.size(); } finally { LOCK.writeLock().unlock(); } @@ -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) { diff --git a/storm-core/src/main/java/st/orm/core/template/impl/SqlParser.java b/storm-core/src/main/java/st/orm/core/template/impl/SqlParser.java index d1fcfb5cc..0f638b9c5 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/SqlParser.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/SqlParser.java @@ -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; @@ -38,12 +36,6 @@ final class SqlParser { private static final Pattern WITH_PATTERN = Pattern.compile("^(?i:WITH)\\b.*", DOTALL); - private static final Map 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 ); @@ -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 == '_'); } /** diff --git a/storm-core/src/main/java/st/orm/core/template/impl/TemplateMetrics.java b/storm-core/src/main/java/st/orm/core/template/impl/TemplateMetrics.java index 6aa1c2d81..748be02d0 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/TemplateMetrics.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/TemplateMetrics.java @@ -17,7 +17,8 @@ import java.lang.management.ManagementFactory; import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.LongAccumulator; +import java.util.concurrent.atomic.LongAdder; import javax.management.MBeanServer; import javax.management.ObjectName; import org.slf4j.Logger; @@ -51,20 +52,21 @@ public static TemplateMetrics getInstance() { return Holder.INSTANCE; } - // Request totals. - private final AtomicLong requests = new AtomicLong(); - private final AtomicLong requestNanosTotal = new AtomicLong(); - private final AtomicLong requestNanosMax = new AtomicLong(); + // Request totals. LongAdder/LongAccumulator stripe updates across cells so concurrent callers do not contend on a + // single CAS the way AtomicLong does on the hot path. + private final LongAdder requests = new LongAdder(); + private final LongAdder requestNanosTotal = new LongAdder(); + private final LongAccumulator requestNanosMax = new LongAccumulator(Long::max, 0); // Hit totals. - private final AtomicLong hits = new AtomicLong(); - private final AtomicLong hitNanosTotal = new AtomicLong(); - private final AtomicLong hitNanosMax = new AtomicLong(); + private final LongAdder hits = new LongAdder(); + private final LongAdder hitNanosTotal = new LongAdder(); + private final LongAccumulator hitNanosMax = new LongAccumulator(Long::max, 0); // Miss totals. - private final AtomicLong misses = new AtomicLong(); - private final AtomicLong missNanosTotal = new AtomicLong(); - private final AtomicLong missNanosMax = new AtomicLong(); + private final LongAdder misses = new LongAdder(); + private final LongAdder missNanosTotal = new LongAdder(); + private final LongAccumulator missNanosMax = new LongAccumulator(Long::max, 0); // Configuration. private final AtomicInteger templateCacheSize = new AtomicInteger(); @@ -99,47 +101,47 @@ public Request startRequest() { } private void record(long nanos, Outcome outcome) { - requests.incrementAndGet(); - requestNanosTotal.addAndGet(nanos); - requestNanosMax.accumulateAndGet(nanos, Math::max); + requests.increment(); + requestNanosTotal.add(nanos); + requestNanosMax.accumulate(nanos); if (outcome == Outcome.HIT) { - hits.incrementAndGet(); - hitNanosTotal.addAndGet(nanos); - hitNanosMax.accumulateAndGet(nanos, Math::max); + hits.increment(); + hitNanosTotal.add(nanos); + hitNanosMax.accumulate(nanos); } else if (outcome == Outcome.MISS) { - misses.incrementAndGet(); - missNanosTotal.addAndGet(nanos); - missNanosMax.accumulateAndGet(nanos, Math::max); + misses.increment(); + missNanosTotal.add(nanos); + missNanosMax.accumulate(nanos); } } @Override public long getRequests() { - return requests.get(); + return requests.sum(); } @Override public long getHits() { - return hits.get(); + return hits.sum(); } @Override public long getMisses() { - return misses.get(); + return misses.sum(); } @Override public long getHitRatioPercent() { - long h = hits.get(); - long m = misses.get(); + long h = hits.sum(); + long m = misses.sum(); long total = h + m; return total == 0 ? 0 : (h * 100 / total); } @Override public long getAvgRequestMicros() { - long r = requests.get(); - return r == 0 ? 0 : (requestNanosTotal.get() / r) / 1_000; + long r = requests.sum(); + return r == 0 ? 0 : (requestNanosTotal.sum() / r) / 1_000; } @Override @@ -149,8 +151,8 @@ public long getMaxRequestMicros() { @Override public long getAvgHitMicros() { - long h = hits.get(); - return h == 0 ? 0 : (hitNanosTotal.get() / h) / 1_000; + long h = hits.sum(); + return h == 0 ? 0 : (hitNanosTotal.sum() / h) / 1_000; } @Override @@ -160,8 +162,8 @@ public long getMaxHitMicros() { @Override public long getAvgMissMicros() { - long m = misses.get(); - return m == 0 ? 0 : (missNanosTotal.get() / m) / 1_000; + long m = misses.sum(); + return m == 0 ? 0 : (missNanosTotal.sum() / m) / 1_000; } @Override @@ -176,15 +178,15 @@ public int getTemplateCacheSize() { @Override public void reset() { - requests.set(0); - requestNanosTotal.set(0); - requestNanosMax.set(0); - hits.set(0); - hitNanosTotal.set(0); - hitNanosMax.set(0); - misses.set(0); - missNanosTotal.set(0); - missNanosMax.set(0); + requests.reset(); + requestNanosTotal.reset(); + requestNanosMax.reset(); + hits.reset(); + hitNanosTotal.reset(); + hitNanosMax.reset(); + misses.reset(); + missNanosTotal.reset(); + missNanosMax.reset(); } private enum Outcome { HIT, MISS }