Files
sql-utilities/src/Strata.SqlTools.LinqToSql/Comparers/QueryComparator.cs
T
Thom Lamb e3153e58c4
SonarQube Analysis / sonarqube (pull_request) Successful in 3m9s
fix(security): Resolve SonarQube security hotspots
Introduce a default regex match timeout across the library to prevent potential ReDoS attacks (SonarQube rule S6444).
Implement `[OnDeserialized]` methods to re-establish object invariants and validate state after deserialization, addressing SonarQube rule S5766.
2026-05-20 17:19:17 -05:00

278 lines
9.4 KiB
C#

using Strata.SqlTools.Breakdowns.LinqToSql;
namespace Strata.SqlTools.Comparers.LinqToSql;
/// <summary>
/// Result of comparing two LinqQueryBreakdown instances.
/// </summary>
public record QueryComparisonResult(
bool AreEquivalent,
double SimilarityScore, // 0.0 to 1.0
List<string> Differences,
bool HaveSameSelectColumns,
bool HaveSameFromTable,
bool HaveSameWhereClause,
bool HaveSameGroupBy,
bool HaveSameHaving,
bool HaveSameOrderBy
)
{
/// <summary>
/// Gets a formatted comparison report.
/// </summary>
public string GetReport()
{
var report = new System.Text.StringBuilder();
report.AppendLine($"Query Comparison Report");
report.AppendLine($"Similarity: {(SimilarityScore * 100):F1}%");
report.AppendLine($"Equivalent: {(AreEquivalent ? "Yes" : "No")}");
report.AppendLine();
if (Differences.Count == 0)
{
report.AppendLine("✓ Queries are identical");
return report.ToString();
}
report.AppendLine("Differences:");
foreach (var diff in Differences)
{
report.AppendLine($" • {diff}");
}
return report.ToString();
}
}
/// <summary>
/// Compares LinqQueryBreakdown instances to detect similarity, equivalence, and duplicates.
/// </summary>
public class QueryComparator
{
private readonly LinqQueryBreakdown _query1;
private readonly LinqQueryBreakdown _query2;
private QueryComparisonResult? _result;
/// <summary>
/// Initializes a new instance of the <see cref="QueryComparator"/> class.
/// </summary>
/// <param name="query1">The first query to compare.</param>
/// <param name="query2">The second query to compare.</param>
public QueryComparator(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
_query1 = query1 ?? throw new ArgumentNullException(nameof(query1));
_query2 = query2 ?? throw new ArgumentNullException(nameof(query2));
}
/// <summary>
/// Gets the comparison result, calculating it if needed.
/// </summary>
public QueryComparisonResult Result =>
_result ??= PerformComparison();
/// <summary>
/// Gets a value indicating whether the queries are equivalent (same structure).
/// </summary>
public bool AreEquivalent => Result.AreEquivalent;
/// <summary>
/// Gets the similarity score from 0.0 (completely different) to 1.0 (identical).
/// </summary>
public double SimilarityScore => Result.SimilarityScore;
/// <summary>
/// Gets the list of differences found between the queries.
/// </summary>
public List<string> Differences => Result.Differences;
/// <summary>
/// Performs the actual comparison between the two queries.
/// </summary>
/// <returns>The comparison result.</returns>
private QueryComparisonResult PerformComparison()
{
var differences = new List<string>();
var scoreComponents = 0;
var scoreMatches = 0;
// Compare SELECT clause
var selectMatch = CompareSelectClauses(_query1, _query2);
if (!selectMatch)
{
differences.Add("SELECT clauses differ");
}
scoreComponents++;
if (selectMatch)
{ scoreMatches++; }
// Compare FROM clause
var fromMatch = CompareFromClauses(_query1, _query2);
if (!fromMatch)
{
differences.Add("FROM clauses differ");
}
scoreComponents++;
if (fromMatch)
{ scoreMatches++; }
// Compare WHERE clause
var whereMatch = CompareWhereClauses(_query1, _query2);
if (!whereMatch)
{
differences.Add("WHERE clauses differ");
}
scoreComponents++;
if (whereMatch)
{ scoreMatches++; }
// Compare GROUP BY clause
var groupByMatch = CompareGroupByClauses(_query1, _query2);
if (!groupByMatch)
{
differences.Add("GROUP BY clauses differ");
}
scoreComponents++;
if (groupByMatch)
{ scoreMatches++; }
// Compare HAVING clause
var havingMatch = CompareHavingClauses(_query1, _query2);
if (!havingMatch)
{
differences.Add("HAVING clauses differ");
}
scoreComponents++;
if (havingMatch)
{ scoreMatches++; }
// Compare ORDER BY clause
var orderByMatch = CompareOrderByClauses(_query1, _query2);
if (!orderByMatch)
{
differences.Add("ORDER BY clauses differ");
}
scoreComponents++;
if (orderByMatch)
{ scoreMatches++; }
var similarityScore = scoreComponents > 0 ? (double)scoreMatches / scoreComponents : 0.0;
var areEquivalent = differences.Count == 0;
return new QueryComparisonResult(
areEquivalent,
similarityScore,
differences,
selectMatch,
fromMatch,
whereMatch,
groupByMatch,
havingMatch,
orderByMatch);
}
private static bool CompareSelectClauses(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
var select1 = NormalizeClause(query1.SelectClause?.Clause ?? string.Empty);
var select2 = NormalizeClause(query2.SelectClause?.Clause ?? string.Empty);
return StringEquals(select1, select2);
}
private static bool CompareFromClauses(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
var from1 = NormalizeClause(query1.FromClause?.Clause ?? string.Empty);
var from2 = NormalizeClause(query2.FromClause?.Clause ?? string.Empty);
return StringEquals(from1, from2);
}
private static bool CompareWhereClauses(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
var where1 = NormalizeClause(query1.WhereClause?.Clause ?? string.Empty);
var where2 = NormalizeClause(query2.WhereClause?.Clause ?? string.Empty);
return StringEquals(where1, where2);
}
private static bool CompareGroupByClauses(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
var groupBy1 = NormalizeClause(query1.GroupByClause?.Clause ?? string.Empty);
var groupBy2 = NormalizeClause(query2.GroupByClause?.Clause ?? string.Empty);
return StringEquals(groupBy1, groupBy2);
}
private static bool CompareHavingClauses(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
var having1 = NormalizeClause(query1.HavingClause?.Clause ?? string.Empty);
var having2 = NormalizeClause(query2.HavingClause?.Clause ?? string.Empty);
return StringEquals(having1, having2);
}
private static bool CompareOrderByClauses(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
var orderBy1 = NormalizeClause(query1.OrderByClause?.Clause ?? string.Empty);
var orderBy2 = NormalizeClause(query2.OrderByClause?.Clause ?? string.Empty);
return StringEquals(orderBy1, orderBy2);
}
private static string NormalizeClause(string clause)
{
if (string.IsNullOrWhiteSpace(clause))
{
return string.Empty;
}
// Normalize whitespace and case
return System.Text.RegularExpressions.Regex
.Replace(clause.Trim(), @"\s+", " ", System.Text.RegularExpressions.RegexOptions.None, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout)
.ToUpperInvariant();
}
private static bool StringEquals(string? str1, string? str2)
{
return string.Equals(str1, str2, StringComparison.Ordinal);
}
/// <summary>
/// Creates a new comparator for two queries.
/// </summary>
/// <param name="query1">The first query.</param>
/// <param name="query2">The second query.</param>
/// <returns>A new QueryComparator instance.</returns>
public static QueryComparator Compare(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
return new QueryComparator(query1, query2);
}
/// <summary>
/// Checks if two queries are equivalent.
/// </summary>
/// <param name="query1">The first query.</param>
/// <param name="query2">The second query.</param>
/// <returns>True if the queries are equivalent; otherwise, false.</returns>
public static bool AreQueriesEquivalent(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
return new QueryComparator(query1, query2).AreEquivalent;
}
/// <summary>
/// Checks if two queries are identical (same text after normalization).
/// </summary>
/// <param name="query1">The first query.</param>
/// <param name="query2">The second query.</param>
/// <returns>True if the queries are identical; otherwise, false.</returns>
public static bool AreQueriesIdentical(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
var comparator = new QueryComparator(query1, query2);
return comparator.SimilarityScore >= 1.0;
}
/// <summary>
/// Gets the similarity score between two queries (0.0 to 1.0).
/// </summary>
/// <param name="query1">The first query.</param>
/// <param name="query2">The second query.</param>
/// <returns>A similarity score from 0.0 (completely different) to 1.0 (identical).</returns>
public static double GetSimilarity(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
return new QueryComparator(query1, query2).SimilarityScore;
}
}