QueryAnalyzer.java
package li.selman.persistencetest.analysis;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import li.selman.persistencetest.core.BindParameter;
import li.selman.persistencetest.core.CapturedQuery;
import li.selman.persistencetest.core.StatementType;
/**
* Reusable analyzers over a list of {@link CapturedQuery}.
*
* <p>Every method here is a pure function of its input list - none of them read {@code QueryCaptureContext}
* or any other ambient state, so they're just as usable outside a test assertion (e.g. in a profiling
* report) as inside one.
*/
public final class QueryAnalyzer {
/**
* Default minimum number of times a query shape must repeat to be considered an N+1 candidate by
* {@link #nPlusOneCandidatesOf(List)}.
*/
public static final int DEFAULT_N_PLUS_ONE_THRESHOLD = 2;
private QueryAnalyzer() {}
/** Aggregate counts and timing over {@code queries}. */
public static QueryStatistics statisticsOf(List<CapturedQuery> queries) {
return QueryStatistics.of(queries);
}
/** Union of every query's {@link CapturedQuery#tables()}. */
public static Set<String> accessedTablesOf(List<CapturedQuery> queries) {
return statisticsOf(queries).accessedTables();
}
/** {@code queries} sorted by {@link CapturedQuery#sequence()} - the order they were executed in. */
public static List<CapturedQuery> timelineOf(List<CapturedQuery> queries) {
return queries.stream()
.sorted(Comparator.comparingLong(CapturedQuery::sequence))
.toList();
}
/**
* Groups of queries with identical normalized SQL <em>and</em> identical bind parameter values,
* executed more than once - genuinely redundant queries. See {@link #repeatedShapesOf(List)} for the
* same-SQL-different-parameters case.
*/
public static List<DuplicateQueryGroup> duplicatesOf(List<CapturedQuery> queries) {
Map<QuerySignature, List<CapturedQuery>> grouped = new LinkedHashMap<>();
for (CapturedQuery query : queries) {
grouped.computeIfAbsent(
new QuerySignature(query.normalizedSql(), query.parameters()), key -> new ArrayList<>())
.add(query);
}
return grouped.entrySet().stream()
.filter(entry -> entry.getValue().size() > 1)
.map(entry -> new DuplicateQueryGroup(entry.getKey().normalizedSql(), entry.getValue()))
.toList();
}
/**
* Groups of queries with identical normalized SQL, executed more than once, regardless of whether their
* bind parameters differ. A superset of {@link #duplicatesOf(List)}: every duplicate group is also a
* repeated shape, but a repeated shape whose occurrences have differing parameters is not a duplicate.
*/
public static List<RepeatedQueryShape> repeatedShapesOf(List<CapturedQuery> queries) {
Map<String, List<CapturedQuery>> grouped = new LinkedHashMap<>();
for (CapturedQuery query : queries) {
grouped.computeIfAbsent(query.normalizedSql(), key -> new ArrayList<>())
.add(query);
}
return grouped.entrySet().stream()
.filter(entry -> entry.getValue().size() > 1)
.map(entry -> new RepeatedQueryShape(entry.getKey(), entry.getValue()))
.toList();
}
/**
* {@link #repeatedShapesOf(List)}, filtered to {@code SELECT} shapes repeated at least
* {@value #DEFAULT_N_PLUS_ONE_THRESHOLD} times - the textbook N+1 symptom of one query per row of an
* outer result set. This is a heuristic based purely on repetition count, since captured queries don't
* track how many rows an outer {@code SELECT} returned; a repeated shape that happens to be legitimate
* (e.g. a batch job intentionally querying per item) will still be flagged.
*/
public static List<RepeatedQueryShape> nPlusOneCandidatesOf(List<CapturedQuery> queries) {
return nPlusOneCandidatesOf(queries, DEFAULT_N_PLUS_ONE_THRESHOLD);
}
/** Like {@link #nPlusOneCandidatesOf(List)}, with a caller-supplied minimum occurrence count. */
public static List<RepeatedQueryShape> nPlusOneCandidatesOf(List<CapturedQuery> queries, int threshold) {
if (threshold < 2) {
throw new IllegalArgumentException("threshold must be >= 2, got " + threshold);
}
return repeatedShapesOf(queries).stream()
.filter(shape -> shape.occurrences().getFirst().statementType() == StatementType.SELECT)
.filter(shape -> shape.occurrenceCount() >= threshold)
.toList();
}
private record QuerySignature(String normalizedSql, List<BindParameter> parameters) {}
}