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
28 changes: 16 additions & 12 deletions storm-core/src/main/java/st/orm/core/spi/Providers.java
Original file line number Diff line number Diff line change
Expand Up @@ -179,17 +179,27 @@ public static <ID, P extends Projection<ID>> ProjectionRepository<P, ID> getProj
.orElseThrow();
}

private static final AtomicReference<QueryBuilderProvider> 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 <T extends Data, R, ID> QueryBuilder<T, R, ID> selectFrom(
@Nonnull QueryTemplate queryTemplate,
@Nonnull Class<T> fromType,
@Nonnull Class<R> selectType,
@Nonnull TemplateString template,
boolean subquery,
@Nonnull Supplier<Model<T, ID>> 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 <T extends Data, R extends Data, ID> QueryBuilder<T, Ref<R>, ID> selectRefFrom(
Expand All @@ -198,20 +208,14 @@ public static <T extends Data, R extends Data, ID> QueryBuilder<T, Ref<R>, ID> s
@Nonnull Class<R> refType,
@Nonnull Class<?> pkType,
@Nonnull Supplier<Model<T, ID>> 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 <T extends Data, ID> QueryBuilder<T, ?, ID> deleteFrom(
@Nonnull QueryTemplate queryTemplate,
@Nonnull Class<T> fromType,
@Nonnull Supplier<Model<T, ID>> 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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -465,6 +465,53 @@ static void validateParameters(@Nonnull List<Parameter> 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<Parameter> 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<Parameter> parameters, int expectedPositionalParameters) throws SqlTemplateException {
SortedSet<Integer> positionSet = new TreeSet<>();
for (Parameter param : parameters) {
if (param instanceof PositionalParameter pp) {
Expand Down Expand Up @@ -498,23 +545,18 @@ private static void validatePositionalParameters(@Nonnull List<Parameter> parame
* @throws SqlTemplateException if a named parameter is being used multiple times with varying values.
*/
private static void validateNamedParameters(List<Parameter> parameters) throws SqlTemplateException {
var namedParameters = parameters.stream()
.filter(SqlTemplate.NamedParameter.class::isInstance)
.map(SqlTemplate.NamedParameter.class::cast)
.collect(Collectors.<SqlTemplate.NamedParameter, String>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<String, Object> 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()));
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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.
*
Expand All @@ -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(), ""),
Expand All @@ -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(""));
}

Expand All @@ -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(), "''");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> unsafeWarning;

/**
* Compile-time only: whether the compiled template requires binding.
*/
Expand Down Expand Up @@ -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,
Expand All @@ -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
);
}

Expand Down
12 changes: 11 additions & 1 deletion storm-foundation/src/main/java/st/orm/AbstractMetamodel.java
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,22 @@ public final boolean equals(Object o) {

/**
* Hash code is based on {@link #fieldType()} of {@link #table()} and {@link #field()}.
*
* <p>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.</p>
*/
@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.
Expand Down
Loading