SonarQube Analysis / sonarqube (pull_request) Successful in 3m9s
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.
278 lines
9.4 KiB
C#
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;
|
|
}
|
|
}
|