SnapshotNormalizer.java
package li.selman.persistencetest.snapshot;
import com.google.errorprone.annotations.Var;
import java.util.List;
import java.util.regex.Pattern;
import org.jspecify.annotations.Nullable;
/**
* Built-in {@link QuerySnapshotTransformer}: masks non-deterministic literals (UUIDs, timestamps,
* generated IDs) in {@link QuerySnapshotEntry#normalizedSql()} and can strip schema qualifiers from table
* names, so a snapshot doesn't churn on every run just because a generated value changed.
*
* <pre>{@code
* SnapshotNormalizer.builder()
* .replaceUuid("<uuid>")
* .replaceTimestamp("<timestamp>")
* .replaceGeneratedIds("<id>")
* .build();
* }</pre>
*
* <p>Replacements are applied in a fixed order (UUIDs, then timestamps, then generated IDs) regardless of
* the order {@code replace*} was called in on the builder, since {@code replaceGeneratedIds}'s bare-integer
* match would otherwise consume digits out of an not-yet-masked timestamp.
*
* <p><b>{@code ignoreAliases()} and {@code ignoreComments()} are no-ops</b>, kept only so code written
* against the example API above compiles: comments never survive {@code JSqlParserSqlNormalizer}'s
* parse/re-print round trip in the first place, and alias canonicalization is the same tracked limitation
* documented on {@code JSqlParserSqlNormalizer} - collapsing alias names correctly requires rewriting every
* column reference that points at them, which isn't implemented yet.
*/
public final class SnapshotNormalizer implements QuerySnapshotTransformer {
private static final Pattern UUID_PATTERN =
Pattern.compile("\\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\\b");
private static final Pattern TIMESTAMP_PATTERN =
Pattern.compile("\\d{4}-\\d{2}-\\d{2}[ T]\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?");
private static final Pattern GENERATED_ID_PATTERN = Pattern.compile("\\b\\d+\\b");
private final @Nullable String uuidReplacement;
private final @Nullable String timestampReplacement;
private final @Nullable String generatedIdReplacement;
private final boolean stripSchemas;
private SnapshotNormalizer(
@Nullable String uuidReplacement,
@Nullable String timestampReplacement,
@Nullable String generatedIdReplacement,
boolean stripSchemas) {
this.uuidReplacement = uuidReplacement;
this.timestampReplacement = timestampReplacement;
this.generatedIdReplacement = generatedIdReplacement;
this.stripSchemas = stripSchemas;
}
public static Builder builder() {
return new Builder();
}
@Override
public QuerySnapshot transform(QuerySnapshot snapshot) {
return new QuerySnapshot(
snapshot.queries().stream().map(this::transformEntry).toList());
}
private QuerySnapshotEntry transformEntry(QuerySnapshotEntry entry) {
@Var String sql = entry.normalizedSql();
if (uuidReplacement != null) {
sql = UUID_PATTERN.matcher(sql).replaceAll(uuidReplacement);
}
if (timestampReplacement != null) {
sql = TIMESTAMP_PATTERN.matcher(sql).replaceAll(timestampReplacement);
}
if (generatedIdReplacement != null) {
sql = GENERATED_ID_PATTERN.matcher(sql).replaceAll(generatedIdReplacement);
}
List<String> tables = stripSchemas
? entry.tables().stream().map(SnapshotNormalizer::stripSchema).toList()
: entry.tables();
return new QuerySnapshotEntry(entry.statementType(), tables, sql, entry.count());
}
private static String stripSchema(String table) {
int lastDot = table.lastIndexOf('.');
return lastDot < 0 ? table : table.substring(lastDot + 1);
}
/** Builds a {@link SnapshotNormalizer}. */
public static final class Builder {
private @Nullable String uuidReplacement;
private @Nullable String timestampReplacement;
private @Nullable String generatedIdReplacement;
private boolean stripSchemas;
private Builder() {}
/** Replaces UUID-shaped literals (e.g. {@code 8-4-4-4-12} hex groups) in the SQL text. */
public Builder replaceUuid(String replacement) {
this.uuidReplacement = replacement;
return this;
}
/** Replaces ISO-8601-ish timestamp literals (e.g. {@code 2026-07-26 12:34:56.789}) in the SQL text. */
public Builder replaceTimestamp(String replacement) {
this.timestampReplacement = replacement;
return this;
}
/**
* Replaces every bare integer literal in the SQL text. Deliberately broad: distinguishing a
* generated primary key literal from any other integer literal (e.g. a {@code LIMIT} value) would
* require deeper semantic analysis than a text-level transformer can do.
*/
public Builder replaceGeneratedIds(String replacement) {
this.generatedIdReplacement = replacement;
return this;
}
/** No-op; see the class Javadoc. */
public Builder ignoreAliases() {
return this;
}
/** No-op; see the class Javadoc. */
public Builder ignoreComments() {
return this;
}
/** Strips a {@code schema.} prefix from every table name in {@link QuerySnapshotEntry#tables()}. */
public Builder ignoreSchemas() {
this.stripSchemas = true;
return this;
}
public SnapshotNormalizer build() {
return new SnapshotNormalizer(uuidReplacement, timestampReplacement, generatedIdReplacement, stripSchemas);
}
}
}