sqlinsight-pro/tests/SQLInsight.Rules.Tests/PlanRuleTests.cs
Claude f1d3f5188e
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
Fase 2: plan cache analysis
Collector takes the 50 heaviest statements (CPU, last 24 h), fetches
statement plans as text (dm_exec_query_plan returns NULL for deeply
nested plans) and analyzes them in C#. Rules:
- SQLI-PLAN-001 implicit conversion preventing an index seek
- SQLI-PLAN-002 join without predicate (deliberate CROSS JOIN excluded)
- SQLI-PLAN-003 non-inlined scalar UDF in a heavy statement
- SQLI-PLAN-004 spills to tempdb, SQLI-PLAN-005 oversized memory grants
  (2016 SP2+ / 2017 CU3+ columns, new capability)
Verified against real plans in SQL Server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
2026-09-29 07:40:09 +00:00

104 lines
4.5 KiB
C#

using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Rules.Engine;
using SQLInsight.Rules.Performance;
using static SQLInsight.Rules.Tests.TestInstances;
namespace SQLInsight.Rules.Tests;
public class PlanRuleTests
{
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
private static AnalyzedStatement Statement(PlanWarnings? warnings = null, long execs = 1000, long workerUs = 120_000_000, long? spills = null, long? grantKb = null, long? usedKb = null) => new()
{
QueryHash = "0x1234ABCD",
DatabaseName = "Sales",
StatementText = "SELECT * FROM dbo.Customers WHERE Code = @code",
Executions = execs,
TotalWorkerTimeUs = workerUs,
TotalElapsedTimeUs = workerUs,
TotalLogicalReads = execs * 1000,
TotalSpills = spills,
TotalGrantKb = grantKb,
TotalUsedGrantKb = usedKb,
Warnings = warnings ?? new PlanWarnings(),
};
private static RuleContext Plans(params AnalyzedStatement[] statements) => With(new PlanAnalysisSnapshot { CapturedAt = Now, Statements = statements });
[Fact]
public void Clean_plan_passes_all_plan_rules()
{
foreach (var rule in new IRule[] { new ImplicitConversionRule(), new NoJoinPredicateRule(), new ScalarUdfRule(), new SpillRule(), new ExcessiveGrantRule() })
{
Assert.Equal(RuleOutcome.Passed, Run(rule, Plans(Statement(spills: 0, grantKb: 1000, usedKb: 900))).Outcome);
}
}
[Fact]
public void Implicit_conversion_lists_the_expression()
{
var statement = Statement(new PlanWarnings { SeekPreventingConversions = ["CONVERT_IMPLICIT(nvarchar(20),[Sales].[dbo].[Customers].[Code],0)=[@code]"] });
var finding = Assert.Single(Run(new ImplicitConversionRule(), Plans(statement)).Findings);
Assert.Equal("statement: 0x1234ABCD", finding.Subject);
Assert.Contains(finding.Evidence, e => e.Value.StartsWith("CONVERT_IMPLICIT", StringComparison.Ordinal));
Assert.Contains("AnsiString", finding.Recommendation, StringComparison.Ordinal);
}
[Fact]
public void Missing_join_predicate_is_high()
{
Assert.Equal(Severity.High, Assert.Single(Run(new NoJoinPredicateRule(), Plans(Statement(new PlanWarnings { NoJoinPredicate = true }))).Findings).Severity);
}
[Fact]
public void Deliberate_cross_join_is_not_reported()
{
var statement = Statement(new PlanWarnings { NoJoinPredicate = true }) with { StatementText = "SELECT * FROM dbo.Sizes s CROSS JOIN dbo.Colors c" };
Assert.Equal(RuleOutcome.Passed, Run(new NoJoinPredicateRule(), Plans(statement)).Outcome);
}
[Fact]
public void Scalar_udf_only_matters_in_heavy_statements()
{
var udf = new PlanWarnings { ScalarUdfs = ["[Sales].[dbo].[fn_Price]"] };
Assert.Equal(RuleOutcome.Findings, Run(new ScalarUdfRule(), Plans(Statement(udf))).Outcome);
Assert.Equal(RuleOutcome.Passed, Run(new ScalarUdfRule(), Plans(Statement(udf, workerUs: 5_000_000))).Outcome);
}
[Fact]
public void Spills_per_execution_above_threshold()
{
Assert.Equal(RuleOutcome.Findings, Run(new SpillRule(), Plans(Statement(execs: 100, spills: 500_000))).Outcome);
Assert.Equal(RuleOutcome.Passed, Run(new SpillRule(), Plans(Statement(execs: 100, spills: 5_000))).Outcome);
}
[Fact]
public void Oversized_grant_used_under_ten_percent()
{
Assert.Equal(RuleOutcome.Findings, Run(new ExcessiveGrantRule(), Plans(Statement(execs: 10, grantKb: 10 * 500 * 1024, usedKb: 10 * 20 * 1024))).Outcome);
Assert.Equal(RuleOutcome.Passed, Run(new ExcessiveGrantRule(), Plans(Statement(execs: 10, grantKb: 10 * 500 * 1024, usedKb: 10 * 300 * 1024))).Outcome);
}
[Fact]
public void Memory_rules_are_not_measurable_without_the_columns()
{
Assert.Equal(RuleOutcome.NotMeasurable, Run(new SpillRule(), WithVersion("14.0.1000.169", new PlanAnalysisSnapshot { CapturedAt = Now, Statements = [] })).Outcome);
}
[Fact]
public void Findings_are_capped_and_heaviest_first()
{
var statements = Enumerable.Range(1, 30).Select(i => Statement(new PlanWarnings { NoJoinPredicate = true }, workerUs: i * 1_000_000) with { QueryHash = $"0x{i:X4}" }).ToArray();
var findings = Run(new NoJoinPredicateRule(), Plans(statements)).Findings;
Assert.Equal(PlanRule.MaxFindingsPerRule, findings.Count);
Assert.Equal("statement: 0x001E", findings[0].Subject);
}
}