A rule whose topic applies but whose way of measuring needs a newer version or patch now gives the new outcome ManualCheck with concrete Danish guidance, instead of NotApplicable. It is marked explicitly per rule (IRule.ManualCheck) and only used when every missing capability is a measurement capability (InstanceCapabilities.IsMeasurement): CHECKDB status (SQLI-DB-009), VLFs (SQLI-DB-011), spills and memory grants (SQLI-PLAN-004/005) and deadlocks on 2005 (SQLI-DLK-001). Query Store, AG and Azure-managed topics stay "not relevant". Shown in a visible "Skal tjekkes manuelt" section on the instance page, a KPI on the overview, a "Tjek manuelt" column in the management report's controls, manualChecks in /api/v1/instances and manualCheck in rule-states; not counted as not measurable. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
104 lines
4.5 KiB
C#
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_must_be_checked_by_hand_without_the_columns()
|
|
{
|
|
Assert.Equal(RuleOutcome.ManualCheck, 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);
|
|
}
|
|
}
|