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
10 changes: 10 additions & 0 deletions src/sqlancer/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,11 @@ public void setReductionContext(List<Query<?>> setupStatements, String bugInform
this.reduceBugInformation = bugInformation;
}

// for reducers that rewrite the failing queries themselves (e.g., TransformationReducer)
public void updateReducedBugInformation(String bugInformation) {
this.reduceBugInformation = bugInformation;
}

public void logReduced(StateToReproduce state) {
nrReductionAttempts++;
logReduced(state, "Reduction attempt " + nrReductionAttempts
Expand Down Expand Up @@ -525,6 +530,11 @@ public void run() throws Exception {
astBasedReducer.reduce(state, reproducer, newGlobalState);
}

// reduces the oracle's transformed query itself; a no-op for reproducers whose queries are not
// built from reducible transformations
Reducer<G> transformationReducer = new TransformationReducer<>(provider);
transformationReducer.reduce(state, reproducer, newGlobalState);

// reassemble the statements so that the main log looks like one produced
// without the reducer, with the generation statements replaced by the reduced
// ones and the oracle queries at the end
Expand Down
23 changes: 23 additions & 0 deletions src/sqlancer/Randomly.java
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,29 @@ public static double getUncachedDouble() {
return getThreadRandom().get().nextDouble();
}

/**
* Computes {@code value} with this thread's random number generator temporarily replaced by a fixed-seed one,
* restoring the previous generator afterwards. This makes computations that draw randomness deterministic: for
* example, rendering an AST to SQL draws random textual variants in some DBMS implementations, and test-case
* reduction relies on re-rendering the same AST to the same string.
*
* @param <T>
* the type of the computed value
* @param value
* the computation to run deterministically
*
* @return the computed value
*/
public static <T> T withFixedSeedRandom(Supplier<T> value) {
Random previousRandom = THREAD_RANDOM.get();
THREAD_RANDOM.set(new Random(0));
try {
return value.get();
} finally {
THREAD_RANDOM.set(previousRandom);
}
}

public String getChar() {
while (true) {
String s = getString();
Expand Down
182 changes: 182 additions & 0 deletions src/sqlancer/TransformationReducer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package sqlancer;

import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;

import sqlancer.common.query.Query;

/**
* Reduces the transformed query of a {@link TransformationReproducer} by disabling transformation sites, searching
* (with the same delta-debugging strategy as {@link StatementReducer}) for a minimal set of sites that still triggers
* the bug. Because each site is an individually equivalence-preserving rewrite, any subset of sites yields a
* transformed query that is still semantically equivalent to the original query, so the reduction is sound. This
* reducer runs after statement reduction, evaluating each candidate against the already-reduced database; for
* reproducers that do not implement {@link TransformationReproducer}, it does nothing.
*
* @param <G>
* the DBMS-specific global state class
* @param <O>
* the DBMS-specific options class
* @param <C>
* the DBMS-specific connection class
*/
public class TransformationReducer<G extends GlobalState<O, ?, C>, O extends DBMSSpecificOptions<?>, C extends SQLancerDBConnection>
implements Reducer<G> {

private final DatabaseProvider<G, O, C> provider;
private List<Query<C>> statements;
private boolean observedChange;
private int partitionNum;

private long currentReduceSteps;
private long currentReduceTime;

private long maxReduceSteps;
private long maxReduceTime;

private Instant timeOfReductionBegins;

public TransformationReducer(DatabaseProvider<G, O, C> provider) {
this.provider = provider;
}

private boolean hasNotReachedLimit(long curr, long limit) {
if (limit == MainOptions.NO_REDUCE_LIMIT) {
return true;
}
return curr < limit;
}

@SuppressWarnings("unchecked")
@Override
public void reduce(G state, Reproducer<G> reproducer, G newGlobalState) throws Exception {
if (!(reproducer instanceof TransformationReproducer)) {
return;
}
TransformationReproducer<G> transformationReproducer = (TransformationReproducer<G>) reproducer;

maxReduceTime = state.getOptions().getMaxStatementReduceTime();
maxReduceSteps = state.getOptions().getMaxStatementReduceSteps();

// Snapshot the (already reduced) generation statements once: createDatabase logs its setup statements
// (DROP/CREATE/USE) into the state, so the state's statement list must be reset for every candidate rather
// than read back, lest the setup statements accumulate and get re-executed mid-test-case.
statements = new ArrayList<>();
for (Query<?> stat : newGlobalState.getState().getStatements()) {
statements.add((Query<C>) stat);
}

List<Integer> enabledSites = new ArrayList<>();
for (int site = 0; site < transformationReproducer.getTransformationSiteCount(); site++) {
enabledSites.add(site);
}
// With every site disabled the transformed query renders as the original one, which cannot mismatch with
// itself, so a single remaining site cannot be reduced further.
if (enabledSites.size() < 2) {
return;
}

timeOfReductionBegins = Instant.now();
currentReduceSteps = 0;
currentReduceTime = 0;
partitionNum = 2;

while (enabledSites.size() >= 2 && hasNotReachedLimit(currentReduceSteps, maxReduceSteps)
&& hasNotReachedLimit(currentReduceTime, maxReduceTime)) {
observedChange = false;

enabledSites = tryReduction(transformationReproducer, newGlobalState, enabledSites);

if (!observedChange) {
if (partitionNum == enabledSites.size()) {
break;
}
// increase the search granularity
partitionNum = Math.min(partitionNum * 2, enabledSites.size());
}
}

// Leave the reproducer holding the reduced transformed query (the last candidate tried may have failed), so
// the final bug information reflects the reduction.
transformationReproducer.setEnabledTransformationSites(new HashSet<>(enabledSites));
newGlobalState.getState().setStatements(new ArrayList<>(statements));
newGlobalState.getLogger().updateReducedBugInformation(transformationReproducer.getBugInformation());
newGlobalState.getLogger().logReduced(newGlobalState.getState(),
"Transformation reduction finished; the transformed query was reduced to the one shown below");
}

private List<Integer> tryReduction(TransformationReproducer<G> transformationReproducer, G newGlobalState,
List<Integer> enabledSites) throws Exception {

List<Integer> sites = enabledSites;

int start = 0;
int subLength = sites.size() / partitionNum;
while (start < sites.size()) {
// candidateSites = sites[:start] + sites[start+subLength:]
// in other words, remove [start, start+subLength) from sites
List<Integer> candidateSites = new ArrayList<>(sites);
int endPoint = Math.min(start + subLength, candidateSites.size());
candidateSites.subList(start, endPoint).clear();

if (bugStillTriggersWith(transformationReproducer, newGlobalState, candidateSites)) {
observedChange = true;
sites = candidateSites;
partitionNum = Math.max(partitionNum - 1, 2);
newGlobalState.getLogger().updateReducedBugInformation(transformationReproducer.getBugInformation());
newGlobalState.getLogger().logReduced(newGlobalState.getState());
break;
}

currentReduceSteps++;
currentReduceTime = Duration.between(timeOfReductionBegins, Instant.now()).getSeconds();
if (!hasNotReachedLimit(currentReduceSteps, maxReduceSteps)
|| !hasNotReachedLimit(currentReduceTime, maxReduceTime)) {
return sites;
}
start = start + subLength;
}
return sites;
}

/**
* Whether the bug still triggers with only {@code candidateSites} applied to the transformed query, evaluated
* against a freshly recreated database populated with the (already reduced) generation statements.
*
* @param transformationReproducer
* the reproducer whose transformed query is being reduced
* @param newGlobalState
* the state the candidate is evaluated against
* @param candidateSites
* the transformation sites to keep applied
*
* @return {@code true} if the bug still triggers with the candidate sites
*/
private boolean bugStillTriggersWith(TransformationReproducer<G> transformationReproducer, G newGlobalState,
List<Integer> candidateSites) {
transformationReproducer.setEnabledTransformationSites(new HashSet<>(candidateSites));
try (C con2 = provider.createDatabase(newGlobalState)) {
newGlobalState.setConnection(con2);
// discard the setup statements createDatabase just logged into the state
newGlobalState.getState().setStatements(new ArrayList<>(statements));
for (Query<C> s : statements) {
try {
s.execute(newGlobalState);
} catch (Throwable ignoredException) {
// ignore
}
}
try {
return transformationReproducer.bugStillTriggers(newGlobalState);
} catch (Throwable ignoredException) {
// fall through: this candidate no longer triggers the bug
}
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
}
33 changes: 33 additions & 0 deletions src/sqlancer/TransformationReproducer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package sqlancer;

import java.util.Set;

/**
* A {@link Reproducer} for bugs found by comparing an original query against a transformed one, where the transformed
* query was built by applying individually equivalence-preserving transformations (e.g. the EET rules) to the original.
* Each such application is a transformation site, identified by an index that stays stable no matter which sites are
* enabled. Disabling any subset of sites re-renders a transformed query that is still semantically equivalent to the
* original, so {@link TransformationReducer} can soundly search for a minimal set of sites that still triggers the bug.
*
* @param <G>
* the DBMS-specific global state class
*/
public interface TransformationReproducer<G extends GlobalState<?, ?, ?>> extends Reproducer<G> {

/**
* The total number of transformation sites the transformed query was built with. This does not change when sites
* are disabled.
*
* @return the total number of transformation sites
*/
int getTransformationSiteCount();

/**
* Re-renders the transformed query with only the given transformation sites applied. Later
* {@link #bugStillTriggers} calls and {@link #getBugInformation} use the re-rendered query.
*
* @param enabledSites
* the indices ({@code 0} to {@code getTransformationSiteCount() - 1}) of the sites to keep applied
*/
void setEnabledTransformationSites(Set<Integer> enabledSites);
}
85 changes: 77 additions & 8 deletions src/sqlancer/common/oracle/EETOracle.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package sqlancer.common.oracle;

import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.Set;

import sqlancer.ComparatorHelper;
import sqlancer.Randomly;
import sqlancer.Reproducer;
import sqlancer.SQLGlobalState;
import sqlancer.TransformationReproducer;
import sqlancer.common.ast.newast.Expression;
import sqlancer.common.ast.newast.Join;
import sqlancer.common.ast.newast.Select;
Expand Down Expand Up @@ -53,13 +56,72 @@ public class EETOracle<Z extends Select<J, E, T, C>, J extends Join<E, T, C>, E
private Reproducer<G> reproducer;
private String generatedQueryString;

private final class EETReproducer extends AbstractComparisonReproducer<G, List<String>> {
private final class EETReproducer extends AbstractComparisonReproducer<G, List<String>>
implements TransformationReproducer<G> {
private final String originalQueryString;
private final String transformedQueryString;
// Mutable: transformation reduction re-renders the transformed query with some transformation sites disabled.
private String transformedQueryString;
private final String initialTransformedQueryString;

EETReproducer(String originalQueryString, String transformedQueryString) {
// The query parts needed to re-render the transformed query: the SELECT whose fetch columns and WHERE clause
// are replaced, the untransformed expressions, and the records of their transformations.
private final Z select;
private final List<E> fetchColumns;
private final List<EETTransformer.TransformationRecord> fetchColumnRecords;
private final E whereClause;
private final EETTransformer.TransformationRecord whereClauseRecord;

EETReproducer(String originalQueryString, String transformedQueryString, Z select, List<E> fetchColumns,
List<EETTransformer.TransformationRecord> fetchColumnRecords, E whereClause,
EETTransformer.TransformationRecord whereClauseRecord) {
this.originalQueryString = originalQueryString;
this.transformedQueryString = transformedQueryString;
this.initialTransformedQueryString = transformedQueryString;
this.select = select;
this.fetchColumns = fetchColumns;
this.fetchColumnRecords = fetchColumnRecords;
this.whereClause = whereClause;
this.whereClauseRecord = whereClauseRecord;
}

@Override
public int getTransformationSiteCount() {
int siteCount = whereClauseRecord.getSiteCount();
for (EETTransformer.TransformationRecord record : fetchColumnRecords) {
siteCount += record.getSiteCount();
}
return siteCount;
}

@Override
public void setEnabledTransformationSites(Set<Integer> enabledSites) {
if (enabledSites.size() == getTransformationSiteCount()) {
// With every site enabled, the transformed query is the unreduced one; keep the exact string that
// originally detected the bug rather than re-rendering it (rendering an AST draws random textual
// variants, so a re-render would produce a semantically equal but untested string).
transformedQueryString = initialTransformedQueryString;
return;
}
// Pin the RNG while re-rendering so the same enabled sites always yield the same query string; the string
// tested during reduction is then exactly the string the reduced test case reports.
transformedQueryString = Randomly.withFixedSeedRandom(() -> {
// Global site indices are assigned over the fetch columns' records first (in column order), then the
// WHERE clause's record.
List<E> replayedFetchColumns = new ArrayList<>();
int offset = 0;
for (int i = 0; i < fetchColumns.size(); i++) {
int base = offset;
replayedFetchColumns.add(transformer.replay(fetchColumns.get(i), false, fetchColumnRecords.get(i),
site -> enabledSites.contains(base + site)));
offset += fetchColumnRecords.get(i).getSiteCount();
}
int whereBase = offset;
E replayedWhereClause = transformer.replay(whereClause, true, whereClauseRecord,
site -> enabledSites.contains(whereBase + site));
select.setFetchColumns(replayedFetchColumns);
select.setWhereClause(replayedWhereClause);
return select.asString();
});
}

@Override
Expand Down Expand Up @@ -160,11 +222,17 @@ public void check() throws SQLException {
}

// Transform the query's expressions into semantically equivalent ones. Fetch columns are scalar expressions,
// while the WHERE clause is evaluated in a boolean context.
List<E> transformedFetchColumns = fetchColumns.stream().map(c -> transformer.transform(c, false))
.collect(Collectors.toList());
// while the WHERE clause is evaluated in a boolean context. Each transformation's record is kept so the
// reproducer can replay it with transformation sites disabled during reduction.
List<E> transformedFetchColumns = new ArrayList<>();
List<EETTransformer.TransformationRecord> fetchColumnRecords = new ArrayList<>();
for (E fetchColumn : fetchColumns) {
transformedFetchColumns.add(transformer.transform(fetchColumn, false));
fetchColumnRecords.add(transformer.getLastTransformationRecord());
}
select.setFetchColumns(transformedFetchColumns);
select.setWhereClause(transformer.transform(whereClause, true));
EETTransformer.TransformationRecord whereClauseRecord = transformer.getLastTransformationRecord();

String transformedQueryString = select.asString();
List<String> transformedResultSet;
Expand All @@ -181,7 +249,8 @@ public void check() throws SQLException {

// Set the reproducer before the assertion: assumeResultSetsAreEqual throws when the bug is
// detected, so creating the reproducer afterwards would leave it null and prevent any reduction.
reproducer = new EETReproducer(originalQueryString, transformedQueryString);
reproducer = new EETReproducer(originalQueryString, transformedQueryString, select, fetchColumns,
fetchColumnRecords, whereClause, whereClauseRecord);

ComparatorHelper.assumeResultSetsAreEqual(originalResultSet, transformedResultSet, originalQueryString,
List.of(transformedQueryString), state);
Expand Down
Loading
Loading