sqlinsight-pro/tests/SQLInsight.Rules.Tests/PlanRuleTests.cs
Søren Eggert Lundsteen Olsen 244153e9e4 Rules the version does not support are "not applicable", not "not measurable"
On SQL Server 2005 twenty rules stood as "not measurable" (Query Store,
system_health, memory columns …) between the real gaps, and counted in the
overview's "regler ikke målbare". They are not gaps: the version simply
lacks what they look at.

- New RuleOutcome.NotApplicable (last in the enum: outcomes are stored as
  numbers, so existing rows keep their meaning).
- A rule or collector missing only capabilities inherent to the instance
  (version-gated, plan cache memory columns, or Azure-managed) is not
  applicable, with the version each needs: "Ikke relevant for SQL Server
  2005: kræver QueryStore (fra SQL Server 2016)". Missing settings or rights
  (HADR not enabled …), collector failures and permission errors stay
  "not measurable". CollectedData.MarkNotApplicable carries this from
  collectors to the rules that use their data; community rules below their
  minimumVersion are not applicable too.
- Counts are unchanged in shape: the overview, KPI, JSON API and Prometheus
  count outcomes 2 and 3 only. The instance page lists the rest in a
  collapsed "Ikke relevant for SQL Server 2005 (n)" (details/summary, no
  JS) after the real gaps. Report controls whose core rule does not apply
  count neither as met nor as not measured.

Tests: engine (version vs. setting vs. collected data), inherent
capabilities, the existing version tests now expect not applicable, report
control status, and the web page and overview count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:05:39 +02: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_applicable_without_the_columns()
{
Assert.Equal(RuleOutcome.NotApplicable, 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);
}
}