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 33fa96349..6aa482237 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 @@ -179,6 +179,19 @@ public static > ProjectionRepository getProj .orElseThrow(); } + private static final AtomicReference QUERY_BUILDER_PROVIDER = new AtomicReference<>(); + + /** + * Resolves the query builder provider once and reuses it, mirroring {@link #getORMReflection()}: query builders + * are created on every select and the provider order is fixed after startup. + */ + private static QueryBuilderProvider queryBuilderProvider() { + return QUERY_BUILDER_PROVIDER.updateAndGet(value -> requireNonNullElseGet(value, () -> + Orderable.sort(enabled(QUERY_BUILDER_REPOSITORY_PROVIDERS)) + .findFirst() + .orElseThrow())); + } + public static QueryBuilder selectFrom( @Nonnull QueryTemplate queryTemplate, @Nonnull Class fromType, @@ -186,10 +199,7 @@ public static QueryBuilder selectFrom( @Nonnull TemplateString template, boolean subquery, @Nonnull Supplier> modelSupplier) { - return Orderable.sort(enabled(QUERY_BUILDER_REPOSITORY_PROVIDERS)) - .map(provider -> provider.selectFrom(queryTemplate, fromType, selectType, template, subquery, modelSupplier)) - .findFirst() - .orElseThrow(); + return queryBuilderProvider().selectFrom(queryTemplate, fromType, selectType, template, subquery, modelSupplier); } public static QueryBuilder, ID> selectRefFrom( @@ -198,20 +208,14 @@ public static QueryBuilder, ID> s @Nonnull Class refType, @Nonnull Class pkType, @Nonnull Supplier> modelSupplier) { - return Orderable.sort(enabled(QUERY_BUILDER_REPOSITORY_PROVIDERS)) - .map(provider -> provider.selectRefFrom(queryTemplate, fromType, refType, pkType, modelSupplier)) - .findFirst() - .orElseThrow(); + return queryBuilderProvider().selectRefFrom(queryTemplate, fromType, refType, pkType, modelSupplier); } public static QueryBuilder deleteFrom( @Nonnull QueryTemplate queryTemplate, @Nonnull Class fromType, @Nonnull Supplier> modelSupplier) { - return Orderable.sort(enabled(QUERY_BUILDER_REPOSITORY_PROVIDERS)) - .map(provider -> provider.deleteFrom(queryTemplate, fromType, modelSupplier)) - .findFirst() - .orElseThrow(); + return queryBuilderProvider().deleteFrom(queryTemplate, fromType, modelSupplier); } public static SqlDialect getSqlDialect() { 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 cb85fc287..702df6123 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 @@ -666,11 +666,12 @@ private static PreparedStatement createProxy(@Nonnull PreparedStatement statemen @Override public Query create(@Nonnull TemplateString template) { try { - var sql = sqlTemplate().process(template); + var customizedTemplate = sqlTemplate(); + var sql = customizedTemplate.process(template); var bindVariables = sql.bindVariables().orElse(null); - SqlDialect dialect = providerFilter != null - ? getSqlDialect(providerFilter, config) - : getSqlDialect(config); + // The dialect of the template that generated the SQL; a provider lookup could select a different + // dialect than the one the statement was generated with. + SqlDialect dialect = customizedTemplate.dialect(); var environment = new QueryImpl.Environment( refFactory, strategies.transactionTemplateProvider(), 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 0ae27e5e7..cb6004f0b 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 @@ -26,6 +26,7 @@ import jakarta.annotation.Nonnull; import java.math.BigInteger; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; @@ -37,7 +38,6 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import st.orm.Data; @@ -465,6 +465,53 @@ static void validateParameters(@Nonnull List parameters, int expected * @throws SqlTemplateException if a positional parameter is missing or if there are gaps in the positions. */ private static void validatePositionalParameters(@Nonnull List parameters, int expectedPositionalParameters) throws SqlTemplateException { + // Positions are small dense integers generated by the compiler (1..n), so a bit set suffices. Non-positive + // positions cannot occur in compiled statements; they fall back to the boxed path for faithful error + // reporting. + java.util.BitSet positions = null; + int distinct = 0; + int minPosition = Integer.MAX_VALUE; + int maxPosition = 0; + for (Parameter param : parameters) { + if (param instanceof PositionalParameter pp) { + int position = pp.position(); + if (position < 1) { + validatePositionalParametersBoxed(parameters, expectedPositionalParameters); + return; + } + if (positions == null) { + positions = new java.util.BitSet(); + } + if (!positions.get(position)) { + positions.set(position); + distinct++; + } + if (position < minPosition) { + minPosition = position; + } + if (position > maxPosition) { + maxPosition = position; + } + } + } + if (distinct != expectedPositionalParameters) { + throw new SqlTemplateException("Expected %d positional parameters, but found %d instead.".formatted(expectedPositionalParameters, distinct)); + } + if (distinct == 0) { + return; + } + if (minPosition != 1) { + throw new SqlTemplateException("Positional parameters must start at 1, but found %d instead.".formatted(minPosition)); + } + // Check for consecutive coverage from 1 through maxPosition. + int missing = positions.nextClearBit(1); + if (missing <= maxPosition) { + throw new SqlTemplateException("Missing positional parameter at position %d".formatted(missing)); + } + } + + /** Fallback for non-positive positions, reporting errors from a fully sorted view of the positions. */ + private static void validatePositionalParametersBoxed(@Nonnull List parameters, int expectedPositionalParameters) throws SqlTemplateException { SortedSet positionSet = new TreeSet<>(); for (Parameter param : parameters) { if (param instanceof PositionalParameter pp) { @@ -498,23 +545,18 @@ private static void validatePositionalParameters(@Nonnull List parame * @throws SqlTemplateException if a named parameter is being used multiple times with varying values. */ private static void validateNamedParameters(List parameters) throws SqlTemplateException { - var namedParameters = parameters.stream() - .filter(SqlTemplate.NamedParameter.class::isInstance) - .map(SqlTemplate.NamedParameter.class::cast) - .collect(Collectors.groupingBy(SqlTemplate.NamedParameter::name)); - for (var entry : namedParameters.entrySet()) { - var list = entry.getValue(); - if (list.size() > 1) { - Object first = null; - for (var value : list) { - var v = value.dbValue(); - if (first == null) { - first = v; - } else { - if (!first.equals(v)) { - throw new SqlTemplateException("Named parameter '%s' is being used multiple times with varying values.".formatted(value.name())); - } - } + // Most statements carry only positional parameters, so the map is created only when the first named + // parameter appears. A name mapped to null is skipped when comparing occurrences. + Map seenValues = null; + for (Parameter parameter : parameters) { + if (parameter instanceof SqlTemplate.NamedParameter named) { + if (seenValues == null) { + seenValues = new HashMap<>(); + } + Object dbValue = named.dbValue(); + Object previous = seenValues.putIfAbsent(named.name(), dbValue); + if (previous != null && !previous.equals(dbValue)) { + throw new SqlTemplateException("Named parameter '%s' is being used multiple times with varying values.".formatted(named.name())); } } } 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 0f638b9c5..b6c88db3c 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 @@ -103,7 +103,9 @@ private static boolean startsWithKeyword(@Nonnull String sql, @Nonnull String ke */ static SqlOperation getSqlOperation(@Nonnull TemplateString template, @Nonnull SqlDialect dialect) { String rawSql = getRawSql(template); - SqlOperation operation = getSqlOperation(rawSql); // First try directly. + // Templates regularly start with a newline or indentation; stripping it lets them hit the keyword fast + // path directly. + SqlOperation operation = getSqlOperation(rawSql.stripLeading()); // First try directly. if (operation != UNDEFINED) { return operation; } @@ -157,6 +159,20 @@ private static String replaceAll(@Nonnull String sql, @Nonnull Pattern pattern, return pattern.matcher(sql).replaceAll(replacement); } + /** + * Returns whether {@code sql} contains any of the given marker characters. Used to skip regex passes on + * fragments that cannot contain the construct being removed. + */ + private static boolean containsAny(@Nonnull String sql, char first, char second, char third) { + for (int i = 0, length = sql.length(); i < length; i++) { + char c = sql.charAt(i); + if (c == first || c == second || c == third) { + return true; + } + } + return false; + } + /** * Removes both single-line and multi-line comments from a SQL string. * @@ -165,6 +181,11 @@ private static String replaceAll(@Nonnull String sql, @Nonnull Pattern pattern, * @return the SQL string with comments removed. */ static String removeComments(@Nonnull String sql, @Nonnull SqlDialect dialect) { + // Every supported comment syntax starts with '-' (--), '/' (/*) or '#' (MySQL); skip the regex passes when + // none of these characters appear at all. + if (!containsAny(sql, '-', '/', '#')) { + return sql; + } // Remove multi-line comments, then single-line comments. return replaceAll( replaceAll(sql, dialect.getMultiLineCommentPattern(), ""), @@ -182,6 +203,11 @@ static String removeComments(@Nonnull String sql, @Nonnull SqlDialect dialect) { * @return the modified SQL string. */ static String clearQuotedIdentifiers(@Nonnull String sql, @Nonnull SqlDialect dialect) { + // Every supported quoted-identifier syntax starts with '"', '`' (MySQL) or '[' (SQL Server); skip the regex + // pass when none of these characters appear at all. + if (!containsAny(sql, '"', '`', '[')) { + return sql; + } return replaceAll(sql, dialect.getIdentifierPattern(), dialect.escape("")); } @@ -193,6 +219,9 @@ static String clearQuotedIdentifiers(@Nonnull String sql, @Nonnull SqlDialect di * @return the modified SQL string. */ static String clearStringLiterals(@Nonnull String sql, @Nonnull SqlDialect dialect) { + if (sql.indexOf('\'') < 0) { + return sql; + } return replaceAll(sql, dialect.getQuoteLiteralPattern(), "''"); } } 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 c45fc59ac..ae35e2bd6 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 @@ -175,6 +175,13 @@ class TemplateProcessor { @Nullable private String sql; + /** + * The safety warning for the compiled SQL, computed once on first bind. A pure function of {@link #sql} and the + * operation; the benign race on initialization publishes an immutable {@link Optional}. + */ + @Nullable + private Optional unsafeWarning; + /** * Compile-time only: whether the compiled template requires binding. */ @@ -537,6 +544,13 @@ Sql bind(@Nonnull BindingContext context) throws SqlTemplateException { session.assertAllHintsConsumed(); } validateParameters(session.parameters, positionalParameterCount.getPlain()); + // The safety check is a pure function of the compiled SQL and operation and is computed once per + // processor. The benign race publishes an immutable Optional. + var warning = unsafeWarning; + if (warning == null) { + warning = checkSafety(sql, operation); + unsafeWarning = warning; + } return new SqlImpl( operation, sql, @@ -546,7 +560,7 @@ Sql bind(@Nonnull BindingContext context) throws SqlTemplateException { ofNullable(affectedType), ofNullable(dataType != null ? dataType : affectedType), versionAware != null && versionAware, - checkSafety(sql, operation) + warning ); } diff --git a/storm-foundation/src/main/java/st/orm/AbstractMetamodel.java b/storm-foundation/src/main/java/st/orm/AbstractMetamodel.java index 1f2f28961..82a705383 100644 --- a/storm-foundation/src/main/java/st/orm/AbstractMetamodel.java +++ b/storm-foundation/src/main/java/st/orm/AbstractMetamodel.java @@ -84,12 +84,22 @@ public final boolean equals(Object o) { /** * Hash code is based on {@link #fieldType()} of {@link #table()} and {@link #field()}. + * + *

The hash is cached: metamodel instances are immutable and are hashed on every compilation-key lookup. The + * benign race follows the {@link String#hashCode()} pattern, recomputing only when the hash happens to be zero.

*/ @Override public final int hashCode() { - return Objects.hash(table().fieldType(), path, field); + int cached = hash; + if (cached == 0) { + cached = Objects.hash(table().fieldType(), path, field); + hash = cached; + } + return cached; } + private int hash; + /** * Returns {@code true} if the metamodel corresponds to a database column, returns {@code false} otherwise, for * example, if the metamodel refers to the root metamodel or an inline record.