QueriesAssert.java

package li.selman.persistencetest.assertions;

import java.util.List;
import java.util.function.Predicate;
import li.selman.persistencetest.analysis.DuplicateQueryGroup;
import li.selman.persistencetest.analysis.QueryAnalyzer;
import li.selman.persistencetest.analysis.RepeatedQueryShape;
import li.selman.persistencetest.core.CapturedQuery;
import li.selman.persistencetest.core.StatementType;
import org.assertj.core.api.AbstractAssert;

/**
 * Fluent assertions over a list of {@link CapturedQuery}. Obtain one via
 * {@link QueryAssertions#assertThatQueries()}.
 */
public final class QueriesAssert extends AbstractAssert<QueriesAssert, List<CapturedQuery>> {

    QueriesAssert(List<CapturedQuery> actual) {
        super(actual, QueriesAssert.class);
    }

    // --- filtering -----------------------------------------------------------------------------------

    /**
     * Returns a new assertion over only the queries for which {@code predicate} is {@code false} -
     * everything matching {@code predicate} is excluded from every assertion made afterward. See
     * {@link QueryFilters} for common predicates (Flyway/Liquibase/catalog noise).
     */
    public QueriesAssert ignore(Predicate<CapturedQuery> predicate) {
        isNotNull();
        return new QueriesAssert(
                actual.stream().filter(query -> !predicate.test(query)).toList());
    }

    /** Shorthand for {@code ignore(QueryFilters.accessesAnyTable(tables))}. */
    public QueriesAssert ignoreTables(String... tables) {
        return ignore(QueryFilters.accessesAnyTable(tables));
    }

    // --- counts ----------------------------------------------------------------------------------------

    public QueriesAssert hasTotalCount(int expected) {
        isNotNull();
        if (actual.size() != expected) {
            failWithMessage(
                    "%s",
                    Diagnostics.withContext(
                            "Expected %d captured queries but found %d.".formatted(expected, actual.size()), actual));
        }
        return this;
    }

    public QueriesAssert selects(int expected) {
        return hasCountOfType(StatementType.SELECT, expected);
    }

    public QueriesAssert inserts(int expected) {
        return hasCountOfType(StatementType.INSERT, expected);
    }

    public QueriesAssert updates(int expected) {
        return hasCountOfType(StatementType.UPDATE, expected);
    }

    public QueriesAssert deletes(int expected) {
        return hasCountOfType(StatementType.DELETE, expected);
    }

    private QueriesAssert hasCountOfType(StatementType type, int expected) {
        isNotNull();
        int actualCount = countOfType(type);
        if (actualCount != expected) {
            failWithMessage(
                    "%s",
                    Diagnostics.withContext(
                            "Expected %d %s quer%s but found %d."
                                    .formatted(expected, type, expected == 1 ? "y" : "ies", actualCount),
                            actual));
        }
        return this;
    }

    private int countOfType(StatementType type) {
        return (int)
                actual.stream().filter(query -> query.statementType() == type).count();
    }

    // --- presence / absence -----------------------------------------------------------------------------

    public QueriesAssert containsNoSelect() {
        return containsNoneOfType(StatementType.SELECT);
    }

    public QueriesAssert containsNoInsert() {
        return containsNoneOfType(StatementType.INSERT);
    }

    public QueriesAssert containsNoUpdate() {
        return containsNoneOfType(StatementType.UPDATE);
    }

    public QueriesAssert containsNoDelete() {
        return containsNoneOfType(StatementType.DELETE);
    }

    private QueriesAssert containsNoneOfType(StatementType type) {
        isNotNull();
        List<CapturedQuery> matches =
                actual.stream().filter(query -> query.statementType() == type).toList();
        if (!matches.isEmpty()) {
            failWithMessage(
                    "%s",
                    Diagnostics.withContext(
                            "Expected no %s queries but found %d.".formatted(type, matches.size()), actual));
        }
        return this;
    }

    /** Whether any captured query's {@link CapturedQuery#tables()} contains {@code table} (case-insensitive). */
    public QueriesAssert containsTable(String table) {
        isNotNull();
        String lower = Diagnostics.lowercase(table);
        boolean found = actual.stream().anyMatch(query -> query.tables().contains(lower));
        if (!found) {
            failWithMessage(
                    "%s",
                    Diagnostics.withContext(
                            "Expected queries to access table '%s' but none did. Accessed tables: %s"
                                    .formatted(table, QueryAnalyzer.accessedTablesOf(actual)),
                            actual));
        }
        return this;
    }

    // --- analysis-based ----------------------------------------------------------------------------------

    /** No {@code SELECT} shape repeated {@value QueryAnalyzer#DEFAULT_N_PLUS_ONE_THRESHOLD}+ times. */
    public QueriesAssert hasNoNPlusOne() {
        return hasNoNPlusOne(QueryAnalyzer.DEFAULT_N_PLUS_ONE_THRESHOLD);
    }

    /** Like {@link #hasNoNPlusOne()}, with a caller-supplied minimum occurrence count. */
    public QueriesAssert hasNoNPlusOne(int threshold) {
        isNotNull();
        List<RepeatedQueryShape> candidates = QueryAnalyzer.nPlusOneCandidatesOf(actual, threshold);
        if (!candidates.isEmpty()) {
            failWithMessage(
                    "%s",
                    Diagnostics.withContext(
                            "Detected %d likely N+1 pattern(s) - the same SELECT shape executed %d+ times:%n%n%s"
                                    .formatted(
                                            candidates.size(),
                                            threshold,
                                            Diagnostics.describeRepeatedShapes(candidates)),
                            actual));
        }
        return this;
    }

    /** No query executed more than once with identical SQL and identical bind parameters. */
    public QueriesAssert hasNoDuplicates() {
        isNotNull();
        List<DuplicateQueryGroup> duplicates = QueryAnalyzer.duplicatesOf(actual);
        if (!duplicates.isEmpty()) {
            failWithMessage(
                    "%s",
                    Diagnostics.withContext(
                            "Detected %d duplicate quer%s (identical SQL and parameters executed more than once):%n%n%s"
                                    .formatted(
                                            duplicates.size(),
                                            duplicates.size() == 1 ? "y" : "ies",
                                            duplicates.stream()
                                                    .map(group -> "  executed %d times: %s"
                                                            .formatted(group.occurrenceCount(), group.normalizedSql()))
                                                    .collect(java.util.stream.Collectors.joining(
                                                            System.lineSeparator()))),
                            actual));
        }
        return this;
    }

    // --- single-query views -------------------------------------------------------------------------------

    public SingleCapturedQueryAssert firstSelect() {
        return singleOfType(StatementType.SELECT, true);
    }

    public SingleCapturedQueryAssert lastSelect() {
        return singleOfType(StatementType.SELECT, false);
    }

    public SingleCapturedQueryAssert first() {
        return single(true);
    }

    public SingleCapturedQueryAssert last() {
        return single(false);
    }

    private SingleCapturedQueryAssert singleOfType(StatementType type, boolean first) {
        isNotNull();
        List<CapturedQuery> matches = QueryAnalyzer.timelineOf(
                actual.stream().filter(query -> query.statementType() == type).toList());
        if (matches.isEmpty()) {
            failWithMessage(
                    "%s",
                    Diagnostics.withContext("Expected at least one %s query but found none.".formatted(type), actual));
        }
        return new SingleCapturedQueryAssert(first ? matches.getFirst() : matches.getLast());
    }

    private SingleCapturedQueryAssert single(boolean first) {
        isNotNull();
        if (actual.isEmpty()) {
            failWithMessage(
                    "%s", Diagnostics.withContext("Expected at least one captured query but found none.", actual));
        }
        List<CapturedQuery> timeline = QueryAnalyzer.timelineOf(actual);
        return new SingleCapturedQueryAssert(first ? timeline.getFirst() : timeline.getLast());
    }
}