From 244153e9e485183b1fc949d3b9ee412456da9755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Eggert=20Lundsteen=20Olsen?= Date: Thu, 1 Oct 2026 16:05:39 +0200 Subject: [PATCH] Rules the version does not support are "not applicable", not "not measurable" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/legacy-versions.md | 4 ++ .../Monitoring/InstanceMonitor.cs | 11 +++- .../Collection/CollectedData.cs | 29 +++++++++-- .../Instances/InstanceCapabilities.cs | 17 +++++++ src/SQLInsight.Reports/ManagementReport.cs | 2 +- src/SQLInsight.Reports/ReportData.cs | 9 ++++ .../Custom/DeclarativeRule.cs | 2 +- src/SQLInsight.Rules/Engine/RuleEngine.cs | 13 +++-- src/SQLInsight.Rules/Engine/RuleEvaluation.cs | 9 ++++ src/SQLInsight.Rules/Engine/RuleResult.cs | 2 +- .../Components/Pages/Instance.razor | 23 ++++++++- src/SQLInsight.Web/wwwroot/app.css | 5 ++ .../ComplianceControlTests.cs | 28 ++++++++++ tests/SQLInsight.IntegrationTests/WebTests.cs | 23 +++++++++ .../SQLInsight.Rules.Tests/CustomRuleTests.cs | 4 +- .../DatabaseRuleTests.cs | 4 +- .../ManagedInstanceTests.cs | 2 +- tests/SQLInsight.Rules.Tests/PlanRuleTests.cs | 4 +- .../QueryStoreRuleTests.cs | 4 +- .../SQLInsight.Rules.Tests/RuleEngineTests.cs | 51 +++++++++++++++++-- 20 files changed, 217 insertions(+), 29 deletions(-) create mode 100644 tests/SQLInsight.Agent.Tests/ComplianceControlTests.cs diff --git a/docs/legacy-versions.md b/docs/legacy-versions.md index db31a39..65154b7 100644 --- a/docs/legacy-versions.md +++ b/docs/legacy-versions.md @@ -12,6 +12,10 @@ SQLInsight Pro overvåger alle versioner fra SQL Server 2000. Denne side beskriv Agenten bruger som standard `Provider=Auto`: den prøver SqlClient først og falder kun tilbage til ODBC, når fejlen ligner en versions- eller TLS-inkompatibilitet (`LegacyFailureClassifier`). Login- og netværksfejl giver ikke fallback, for så ville den rigtige fejl blive skjult. Når fallback bruges, logges en advarsel. +## Ikke relevant eller ikke målbar + +Regler om noget, versionen ikke har (Query Store før 2016, system_health før 2008, hukommelseskolonner i plan cache før 2016 SP2 …), vises som **ikke relevant for SQL Server 20xx** i en sammenklappet liste på instansens side. De tæller ikke med i "ikke målbare" på oversigten, i API'et eller i rapportens kontroller. **Ikke målbar** er forbeholdt det, der burde kunne måles: manglende rettigheder, en fejl eller en indstilling (fx Always On, der ikke er slået til). + ## Spike: det skal verificeres på rigtige VM'er Versionerne 2000–2016 findes ikke som containere, så de kan ikke testes i CI med Testcontainers. diff --git a/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs b/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs index c772d82..bfa5c9c 100644 --- a/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs +++ b/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs @@ -127,7 +127,16 @@ public sealed partial class InstanceMonitor( var missing = capabilities.Missing(collector.RequiredCapabilities); if (missing.Count > 0) { - _data.MarkUnavailable(collector.DataType, $"Ikke målbar på {info.Release.DisplayName()}: kræver {string.Join(", ", missing)}."); + if (missing.All(InstanceCapabilities.IsInherent)) + { + // The version lacks it (Query Store on 2005 …): not applicable, not a gap in the monitoring. + _data.MarkNotApplicable(collector.DataType, $"Ikke relevant for {info.Release.DisplayName()}: kræver {InstanceCapabilities.Describe(missing)}."); + } + else + { + _data.MarkUnavailable(collector.DataType, $"Ikke målbar på {info.Release.DisplayName()}: kræver {string.Join(", ", missing)}."); + } + return; } diff --git a/src/SQLInsight.Core/Collection/CollectedData.cs b/src/SQLInsight.Core/Collection/CollectedData.cs index 50f3096..4b13993 100644 --- a/src/SQLInsight.Core/Collection/CollectedData.cs +++ b/src/SQLInsight.Core/Collection/CollectedData.cs @@ -2,19 +2,20 @@ namespace SQLInsight.Core.Collection; /// /// The latest data collected from one instance, keyed by data type. A collector that could not run records why, -/// so rules depending on it are reported as not measurable with that reason. +/// so rules depending on it are reported as not measurable with that reason, or as not applicable when the +/// instance's SQL Server version does not have the data at all. /// public sealed class CollectedData { private readonly Dictionary _values = []; private readonly Dictionary _unavailable = []; + private readonly HashSet _notApplicable = []; public void Set(T value) where T : class { ArgumentNullException.ThrowIfNull(value); - _values[typeof(T)] = value; - _unavailable.Remove(typeof(T)); + Set(typeof(T), value); } public void Set(Type type, object value) @@ -23,6 +24,7 @@ public sealed class CollectedData ArgumentNullException.ThrowIfNull(value); _values[type] = value; _unavailable.Remove(type); + _notApplicable.Remove(type); } public void MarkUnavailable(Type type, string reason) @@ -30,10 +32,21 @@ public sealed class CollectedData ArgumentNullException.ThrowIfNull(type); _values.Remove(type); _unavailable[type] = reason; + _notApplicable.Remove(type); } public void MarkUnavailable(string reason) => MarkUnavailable(typeof(T), reason); + /// + /// The data does not exist on this kind of instance (its SQL Server version lacks it): rules depending on it are + /// reported as not applicable rather than not measurable. + /// + public void MarkNotApplicable(Type type, string reason) + { + MarkUnavailable(type, reason); + _notApplicable.Add(type); + } + public bool TryGet(out T value) where T : class { @@ -49,7 +62,7 @@ public sealed class CollectedData /// /// Returns the data, or throws which the rule engine reports as - /// not measurable. + /// not measurable (or not applicable, see ). /// public T Get() where T : class @@ -62,7 +75,10 @@ public sealed class CollectedData throw new DataUnavailableException( _unavailable.TryGetValue(typeof(T), out var reason) ? reason - : $"Der er endnu ikke indsamlet data af typen {typeof(T).Name}."); + : $"Der er endnu ikke indsamlet data af typen {typeof(T).Name}.") + { + NotApplicable = _notApplicable.Contains(typeof(T)), + }; } } @@ -84,4 +100,7 @@ public sealed class DataUnavailableException : Exception : base(message, innerException) { } + + /// The data cannot exist on this SQL Server version, as opposed to missing rights or a failure. + public bool NotApplicable { get; init; } } diff --git a/src/SQLInsight.Core/Instances/InstanceCapabilities.cs b/src/SQLInsight.Core/Instances/InstanceCapabilities.cs index d1acf39..4df5d69 100644 --- a/src/SQLInsight.Core/Instances/InstanceCapabilities.cs +++ b/src/SQLInsight.Core/Instances/InstanceCapabilities.cs @@ -77,6 +77,23 @@ public sealed class InstanceCapabilities return required.Where(c => !_capabilities.Contains(c)).Distinct().ToList(); } + /// + /// Whether lacking the capability is a matter of what the instance is (its SQL Server version, or Azure managing the + /// infrastructure), so whatever needs it is not applicable there. Capabilities that depend on settings or rights + /// (Always On enabled …) are not: lacking them leaves something not measurable. + /// + public static bool IsInherent(Capability capability) => + MinimumVersion.ContainsKey(capability) || capability is Capability.QueryStatsMemoryColumns or Capability.SelfManagedInfrastructure; + + /// + /// "kræver Query Store (fra SQL Server 2016)" for the missing capabilities, for not-applicable reasons. + /// + public static string Describe(IEnumerable missing) + { + ArgumentNullException.ThrowIfNull(missing); + return string.Join(", ", missing.Select(c => MinimumVersionFor(c) is { } v ? $"{c} (fra {v.Release.DisplayName()})" : c.ToString())); + } + /// /// The minimum version for a version-gated capability, or for runtime-only capabilities. /// diff --git a/src/SQLInsight.Reports/ManagementReport.cs b/src/SQLInsight.Reports/ManagementReport.cs index b67b1d3..49fbc3b 100644 --- a/src/SQLInsight.Reports/ManagementReport.cs +++ b/src/SQLInsight.Reports/ManagementReport.cs @@ -208,7 +208,7 @@ public static class ManagementReport private static void Controls(Section section, ReportData data) { section.AddParagraph("Kontroller", StyleNames.Heading1); - var intro = section.AddParagraph("Opfyldt betyder, at kontrollen er målt og intet fund er registreret. Ikke målt betyder, at versionen, rettighederne eller en fejl forhindrede målingen; det er ikke det samme som opfyldt."); + var intro = section.AddParagraph("Opfyldt betyder, at kontrollen er målt og intet fund er registreret. Ikke målt betyder, at rettighederne eller en fejl forhindrede målingen; det er ikke det samme som opfyldt. Instanser, hvor kontrollen ikke er relevant for SQL Server-versionen, tælles ikke med."); intro.Style = "Muted"; var table = Table(section, ("Kontrol", 5.2), ("Opfyldt", 1.8), ("Ikke opfyldt", 2.1), ("Ikke målt", 1.9), ("Instanser, der ikke opfylder", 6)); diff --git a/src/SQLInsight.Reports/ReportData.cs b/src/SQLInsight.Reports/ReportData.cs index 0f246bc..5443d65 100644 --- a/src/SQLInsight.Reports/ReportData.cs +++ b/src/SQLInsight.Reports/ReportData.cs @@ -79,6 +79,12 @@ public sealed record ComplianceControl(string Name, string Description, params s return ControlStatus.NotMet; } + // The core rule does not apply to this version (an integrity check that needs 2016 SP2 on 2005 …): neither met nor a gap. + if (relevant.Any(s => s.RuleId == Rules[0] && s.Outcome == RuleOutcome.NotApplicable)) + { + return ControlStatus.NotApplicable; + } + // The first rule is the core of the control; the others only count where they apply (an AG rule on an // instance without availability groups, for example). return relevant.Any(s => s.RuleId == Rules[0] && s.Outcome == RuleOutcome.Passed) ? ControlStatus.Met : ControlStatus.NotMeasured; @@ -90,4 +96,7 @@ public enum ControlStatus Met, NotMet, NotMeasured, + + /// The control does not apply to the instance's SQL Server version; counted neither as met nor as not measured. + NotApplicable, } diff --git a/src/SQLInsight.Rules/Custom/DeclarativeRule.cs b/src/SQLInsight.Rules/Custom/DeclarativeRule.cs index cbdfad2..5dffb7f 100644 --- a/src/SQLInsight.Rules/Custom/DeclarativeRule.cs +++ b/src/SQLInsight.Rules/Custom/DeclarativeRule.cs @@ -20,7 +20,7 @@ public sealed class DeclarativeRule(CustomRuleDefinition definition) : IRule ArgumentNullException.ThrowIfNull(context); if (context.Instance.Version < Definition.MinimumSqlVersion) { - return RuleEvaluation.NotMeasurable($"Reglen kræver SQL Server {Definition.MinimumVersion} eller nyere."); + return RuleEvaluation.NotApplicable($"Ikke relevant for {context.Instance.Release.DisplayName()}: reglen kræver SQL Server {Definition.MinimumVersion} eller nyere."); } var results = context.Data.Get(); diff --git a/src/SQLInsight.Rules/Engine/RuleEngine.cs b/src/SQLInsight.Rules/Engine/RuleEngine.cs index bcd49dc..9290d02 100644 --- a/src/SQLInsight.Rules/Engine/RuleEngine.cs +++ b/src/SQLInsight.Rules/Engine/RuleEngine.cs @@ -67,15 +67,18 @@ public sealed class RuleEngine var missing = context.Capabilities.Missing(rule.RequiredCapabilities); if (missing.Count > 0) { + // Missing because of what the instance is (its version, or Azure) is not a gap in the monitoring. + var inherent = missing.All(InstanceCapabilities.IsInherent); + var release = context.Instance.Release.DisplayName(); return new RuleResult { RuleId = rule.Id, RuleTitle = rule.Title, - Outcome = RuleOutcome.NotMeasurable, + Outcome = inherent ? RuleOutcome.NotApplicable : RuleOutcome.NotMeasurable, MissingCapabilities = missing, - Reason = missing.Contains(Capability.SelfManagedInfrastructure) - ? $"Håndteres af Azure på {context.Instance.Release.DisplayName()}." - : $"Ikke målbar på {context.Instance.Release.DisplayName()}: kræver {string.Join(", ", missing)}.", + Reason = missing.Contains(Capability.SelfManagedInfrastructure) ? $"Håndteres af Azure på {release}." + : inherent ? $"Ikke relevant for {release}: kræver {InstanceCapabilities.Describe(missing)}." + : $"Ikke målbar på {release}: kræver {string.Join(", ", missing)}.", }; } @@ -97,7 +100,7 @@ public sealed class RuleEngine { RuleId = rule.Id, RuleTitle = rule.Title, - Outcome = RuleOutcome.NotMeasurable, + Outcome = ex.NotApplicable ? RuleOutcome.NotApplicable : RuleOutcome.NotMeasurable, Reason = ex.Message, }; } diff --git a/src/SQLInsight.Rules/Engine/RuleEvaluation.cs b/src/SQLInsight.Rules/Engine/RuleEvaluation.cs index 7d8b964..03861ad 100644 --- a/src/SQLInsight.Rules/Engine/RuleEvaluation.cs +++ b/src/SQLInsight.Rules/Engine/RuleEvaluation.cs @@ -34,6 +34,8 @@ public sealed record RuleEvaluation } public static RuleEvaluation NotMeasurable(string reason) => new(RuleOutcome.NotMeasurable, [], reason); + + public static RuleEvaluation NotApplicable(string reason) => new(RuleOutcome.NotApplicable, [], reason); } public enum RuleOutcome @@ -49,4 +51,11 @@ public enum RuleOutcome /// The rule failed while running. This is never the same as . Error, + + /// + /// The rule does not apply to this kind of instance: its SQL Server version lacks what the rule looks at (Query + /// Store on 2005 …), or Azure manages it. Not a gap in the monitoring, so not counted as not measurable. + /// Last in the enum: outcomes are stored as numbers. + /// + NotApplicable, } diff --git a/src/SQLInsight.Rules/Engine/RuleResult.cs b/src/SQLInsight.Rules/Engine/RuleResult.cs index ff60ea9..4dc3dbe 100644 --- a/src/SQLInsight.Rules/Engine/RuleResult.cs +++ b/src/SQLInsight.Rules/Engine/RuleResult.cs @@ -13,7 +13,7 @@ public sealed record RuleResult public required RuleOutcome Outcome { get; init; } public IReadOnlyList Findings { get; init; } = []; - /// Capabilities the instance lacks, when the outcome is for that reason. + /// Capabilities the instance lacks, when the outcome is or for that reason. public IReadOnlyList MissingCapabilities { get; init; } = []; /// Why the rule was not measurable or failed. diff --git a/src/SQLInsight.Web/Components/Pages/Instance.razor b/src/SQLInsight.Web/Components/Pages/Instance.razor index 9151969..2f83a42 100644 --- a/src/SQLInsight.Web/Components/Pages/Instance.razor +++ b/src/SQLInsight.Web/Components/Pages/Instance.razor @@ -159,7 +159,7 @@ else

Ikke målbart på denne instans

@if (_notMeasurable.Count == 0) { -

Alle regler kunne evalueres.

+

Alle regler, der er relevante for instansen, kunne evalueres.

} else { @@ -174,6 +174,22 @@ else } +@if (_notApplicable.Count > 0) +{ +
+ Ikke relevant for @Display.Release(_instance.ProductVersion, _instance.EngineEdition) (@_notApplicable.Count) +

Regler om noget, denne SQL Server-version ikke har (fx Query Store eller system_health). Det er ikke et hul i overvågningen.

+ + + + @foreach (var r in _notApplicable) + { + + } + +
RegelHvorfor
@r.RuleId@r.RuleTitle@r.Reason
+
+}
@@ -203,6 +219,7 @@ else private IReadOnlyList _findings = []; private IReadOnlyList _resolved = []; private IReadOnlyList _notMeasurable = []; + private IReadOnlyList _notApplicable = []; private IReadOnlyList _cpuSql = [], _cpuOther = [], _batches = [], _ple = []; private IReadOnlyList _waits = []; private IReadOnlyList _files = []; @@ -221,7 +238,9 @@ else var day = DateTimeOffset.UtcNow.AddDays(-1); _findings = await Repository.GetActiveFindingsAsync(id); _resolved = await Repository.GetRecentlyResolvedAsync(id, 30); - _notMeasurable = (await Repository.GetRuleStatesAsync(id)).Where(r => r.Outcome is RuleOutcome.NotMeasurable or RuleOutcome.Error).ToList(); + var states = await Repository.GetRuleStatesAsync(id); + _notMeasurable = states.Where(r => r.Outcome is RuleOutcome.NotMeasurable or RuleOutcome.Error).ToList(); + _notApplicable = states.Where(r => r.Outcome == RuleOutcome.NotApplicable).ToList(); _cpuSql = await Repository.GetMetricAsync(id, Metrics.CpuSqlServer, day); _cpuOther = await Repository.GetMetricAsync(id, Metrics.CpuOther, day); _batches = await Repository.GetMetricAsync(id, Metrics.BatchRequestsPerSecond, day); diff --git a/src/SQLInsight.Web/wwwroot/app.css b/src/SQLInsight.Web/wwwroot/app.css index 787ba35..7dc9ab0 100644 --- a/src/SQLInsight.Web/wwwroot/app.css +++ b/src/SQLInsight.Web/wwwroot/app.css @@ -188,3 +188,8 @@ a.button:hover { filter: brightness(1.08); } .panel.unusual { border-left: 4px solid var(--critical); } tr.unusual td { background: var(--critical-soft); } pre.sql { white-space: pre-wrap; word-break: break-word; max-height: 9rem; overflow: auto; margin: .25rem 0 0; font-size: .8rem; } + +/* Rules that do not apply to the instance's version: collapsed, out of the way of real gaps */ +details.not-applicable { margin-top: 1rem; } +details.not-applicable summary { cursor: pointer; color: var(--text-2); font-weight: 600; } +details.not-applicable > table { margin-top: .75rem; } diff --git a/tests/SQLInsight.Agent.Tests/ComplianceControlTests.cs b/tests/SQLInsight.Agent.Tests/ComplianceControlTests.cs new file mode 100644 index 0000000..028b39a --- /dev/null +++ b/tests/SQLInsight.Agent.Tests/ComplianceControlTests.cs @@ -0,0 +1,28 @@ +using SQLInsight.Reports; +using SQLInsight.Repository; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.Agent.Tests; + +public class ComplianceControlTests +{ + private static readonly ComplianceControl Integrity = ComplianceControl.All.Single(c => c.Name == "Integritetstjek"); + + [Fact] + public void Control_whose_core_rule_does_not_apply_to_the_version_is_not_counted_as_not_measured() + { + var states = new[] + { + new InstanceRuleState("KGAA-S05", "SQLI-DB-009", RuleOutcome.NotApplicable, "Ikke relevant for SQL Server 2005"), + new InstanceRuleState("KGAA-S05", "SQLI-DB-010", RuleOutcome.Passed, null), + new InstanceRuleState("SQL01", "SQLI-DB-009", RuleOutcome.NotMeasurable, "Adgang nægtet"), + new InstanceRuleState("SQL02", "SQLI-DB-009", RuleOutcome.Passed, null), + new InstanceRuleState("SQL03", "SQLI-DB-010", RuleOutcome.Findings, null), + }; + + Assert.Equal(ControlStatus.NotApplicable, Integrity.StatusOn("KGAA-S05", states)); + Assert.Equal(ControlStatus.NotMeasured, Integrity.StatusOn("SQL01", states)); // a real gap stays a gap + Assert.Equal(ControlStatus.Met, Integrity.StatusOn("SQL02", states)); + Assert.Equal(ControlStatus.NotMet, Integrity.StatusOn("SQL03", states)); // a finding always counts + } +} diff --git a/tests/SQLInsight.IntegrationTests/WebTests.cs b/tests/SQLInsight.IntegrationTests/WebTests.cs index c08d5eb..a3ddee1 100644 --- a/tests/SQLInsight.IntegrationTests/WebTests.cs +++ b/tests/SQLInsight.IntegrationTests/WebTests.cs @@ -104,6 +104,29 @@ public class WebTests(SqlServerFixture sql) : IAsyncLifetime Assert.Contains($"/deadlocks/{_deadlockId}.xdl", html, StringComparison.Ordinal); } + [Fact] + public async Task Rules_not_applicable_to_the_version_are_collapsed_and_not_counted_as_not_measurable() + { + var id = (await _repository.GetInstanceAsync("prod-sql01"))!.InstanceId; + await _repository.SaveRuleResultsAsync(id, + [ + new RuleResult { RuleId = "SQLI-QS-001", RuleTitle = "Query Store er slået fra", Outcome = RuleOutcome.NotApplicable, Reason = "Ikke relevant for SQL Server 2019: kræver noget nyere." }, + new RuleResult { RuleId = "SQLI-QS-002", RuleTitle = "Query Store er skrivebeskyttet", Outcome = RuleOutcome.NotApplicable, Reason = "Ikke relevant for SQL Server 2019: kræver noget nyere." }, + ], DateTimeOffset.UtcNow); + + var overview = (await _repository.GetInstancesAsync()).Single(i => i.Name == "prod-sql01"); + Assert.Equal(1, overview.NotMeasurable); // only SQLI-DB-011, a real gap + + var html = await _factory.CreateClient().GetStringAsync("/instanser/prod-sql01"); + var notMeasurable = html[html.IndexOf("Ikke målbart på denne instans", StringComparison.Ordinal)..]; + var collapsed = notMeasurable[notMeasurable.IndexOf("
", StringComparison.Ordinal)..]; + Assert.Contains("Ikke relevant for SQL Server 2019 (2)", collapsed, StringComparison.Ordinal); + Assert.Contains("SQLI-QS-001", collapsed, StringComparison.Ordinal); + // The real gap stays visible, before and outside the collapsed list. + Assert.DoesNotContain("SQLI-DB-011", collapsed, StringComparison.Ordinal); + Assert.Contains("SQLI-DB-011", notMeasurable, StringComparison.Ordinal); + } + [Fact] public async Task Finding_page_shows_explanation_evidence_and_script_html_encoded() { diff --git a/tests/SQLInsight.Rules.Tests/CustomRuleTests.cs b/tests/SQLInsight.Rules.Tests/CustomRuleTests.cs index b1630fa..657f883 100644 --- a/tests/SQLInsight.Rules.Tests/CustomRuleTests.cs +++ b/tests/SQLInsight.Rules.Tests/CustomRuleTests.cs @@ -115,13 +115,13 @@ public class CustomRuleTests } [Fact] - public void Rules_needing_a_newer_version_are_not_measurable() + public void Rules_needing_a_newer_version_are_not_applicable() { var rule = Rule(Valid.Replace("\"severity\"", "\"minimumVersion\": \"13.0\", \"severity\"", StringComparison.Ordinal)); var result = Assert.Single(new RuleEngine([], Catalog(rule)).Evaluate(TestInstances.Context("11.0.7001.0"))); - Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome); + Assert.Equal(RuleOutcome.NotApplicable, result.Outcome); Assert.Contains("13.0", result.Reason, StringComparison.Ordinal); } diff --git a/tests/SQLInsight.Rules.Tests/DatabaseRuleTests.cs b/tests/SQLInsight.Rules.Tests/DatabaseRuleTests.cs index 25494bf..d8de50e 100644 --- a/tests/SQLInsight.Rules.Tests/DatabaseRuleTests.cs +++ b/tests/SQLInsight.Rules.Tests/DatabaseRuleTests.cs @@ -147,11 +147,11 @@ public class DatabaseRuleTests } [Fact] - public void CheckDb_is_not_measurable_before_2016_sp2() + public void CheckDb_is_not_applicable_before_2016_sp2() { var result = Run(new CheckDbRule(), WithVersion("13.0.4001.0", DatabaseList(Database()))); - Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome); + Assert.Equal(RuleOutcome.NotApplicable, result.Outcome); } [Fact] diff --git a/tests/SQLInsight.Rules.Tests/ManagedInstanceTests.cs b/tests/SQLInsight.Rules.Tests/ManagedInstanceTests.cs index 7ac4ec2..f306588 100644 --- a/tests/SQLInsight.Rules.Tests/ManagedInstanceTests.cs +++ b/tests/SQLInsight.Rules.Tests/ManagedInstanceTests.cs @@ -30,7 +30,7 @@ public class ManagedInstanceTests foreach (var id in new[] { "SQLI-VER-001", "SQLI-VER-002", "SQLI-CFG-001", "SQLI-DB-007", "SQLI-DB-009", "SQLI-DB-015" }) { var result = results.Single(r => r.RuleId == id); - Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome); + Assert.Equal(RuleOutcome.NotApplicable, result.Outcome); Assert.Equal("Håndteres af Azure på Azure SQL Managed Instance.", result.Reason); } } diff --git a/tests/SQLInsight.Rules.Tests/PlanRuleTests.cs b/tests/SQLInsight.Rules.Tests/PlanRuleTests.cs index 1076492..0dbda6f 100644 --- a/tests/SQLInsight.Rules.Tests/PlanRuleTests.cs +++ b/tests/SQLInsight.Rules.Tests/PlanRuleTests.cs @@ -86,9 +86,9 @@ public class PlanRuleTests } [Fact] - public void Memory_rules_are_not_measurable_without_the_columns() + public void Memory_rules_are_not_applicable_without_the_columns() { - Assert.Equal(RuleOutcome.NotMeasurable, Run(new SpillRule(), WithVersion("14.0.1000.169", new PlanAnalysisSnapshot { CapturedAt = Now, Statements = [] })).Outcome); + Assert.Equal(RuleOutcome.NotApplicable, Run(new SpillRule(), WithVersion("14.0.1000.169", new PlanAnalysisSnapshot { CapturedAt = Now, Statements = [] })).Outcome); } [Fact] diff --git a/tests/SQLInsight.Rules.Tests/QueryStoreRuleTests.cs b/tests/SQLInsight.Rules.Tests/QueryStoreRuleTests.cs index b3df142..ab83006 100644 --- a/tests/SQLInsight.Rules.Tests/QueryStoreRuleTests.cs +++ b/tests/SQLInsight.Rules.Tests/QueryStoreRuleTests.cs @@ -121,10 +121,10 @@ public class QueryStoreRuleTests } [Fact] - public void Query_store_rules_are_not_measurable_before_2016() + public void Query_store_rules_are_not_applicable_before_2016() { var result = Run(new PlanRegressionRule(), WithVersion("12.0.6024.0")); - Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome); + Assert.Equal(RuleOutcome.NotApplicable, result.Outcome); } } diff --git a/tests/SQLInsight.Rules.Tests/RuleEngineTests.cs b/tests/SQLInsight.Rules.Tests/RuleEngineTests.cs index 9e90316..1965d65 100644 --- a/tests/SQLInsight.Rules.Tests/RuleEngineTests.cs +++ b/tests/SQLInsight.Rules.Tests/RuleEngineTests.cs @@ -21,19 +21,62 @@ public class RuleEngineTests } [Fact] - public void Rule_missing_capabilities_is_not_measurable_and_never_runs() + public void Rule_needing_what_the_version_lacks_is_not_applicable_and_never_runs() { var rule = new StubRule("QS", [Capability.QueryStore], _ => RuleEvaluation.Passed()); var engine = new RuleEngine([rule]); - var result = Assert.Single(engine.Evaluate(TestInstances.Context("11.0.7001.0"))); + var result = Assert.Single(engine.Evaluate(TestInstances.Context("9.0.5057.0"))); - Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome); + Assert.Equal(RuleOutcome.NotApplicable, result.Outcome); Assert.Equal([Capability.QueryStore], result.MissingCapabilities); - Assert.Contains("SQL Server 2012", result.Reason, StringComparison.Ordinal); + Assert.Equal("Ikke relevant for SQL Server 2005: kræver QueryStore (fra SQL Server 2016).", result.Reason); Assert.Equal(0, rule.Calls); } + [Fact] + public void Rule_needing_a_setting_the_instance_lacks_is_still_not_measurable() + { + // Always On not enabled is a setting, not the version: the rule could run, so it is a gap, not "not applicable". + var rule = new StubRule("AG", [Capability.HadrEnabled], _ => RuleEvaluation.Passed()); + + var result = Assert.Single(new RuleEngine([rule]).Evaluate(TestInstances.Context("16.0.4135.4"))); + + Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome); + Assert.Contains("Ikke målbar", result.Reason, StringComparison.Ordinal); + } + + [Fact] + public void Rule_needing_data_the_version_does_not_have_is_not_applicable() + { + var rule = new StubRule("D", [], ctx => + { + ctx.Data.Get(); + return RuleEvaluation.Passed(); + }); + var context = TestInstances.Context("9.0.5057.0"); + context.Data.MarkNotApplicable(typeof(SQLInsight.Core.Data.DeadlockSnapshot), "Ikke relevant for SQL Server 2005: kræver SystemHealthSession (fra SQL Server 2008)."); + + var result = Assert.Single(new RuleEngine([rule]).Evaluate(context)); + + Assert.Equal(RuleOutcome.NotApplicable, result.Outcome); + Assert.Contains("SystemHealthSession", result.Reason, StringComparison.Ordinal); + + // Collected later (or failing for another reason): the flag does not stick. + context.Data.MarkUnavailable("Collector 'deadlocks' fejlede: adgang nægtet."); + Assert.Equal(RuleOutcome.NotMeasurable, Assert.Single(new RuleEngine([rule]).Evaluate(context)).Outcome); + } + + [Theory] + [InlineData(Capability.QueryStore, true)] + [InlineData(Capability.SystemHealthSession, true)] + [InlineData(Capability.TextQueryPlan, true)] + [InlineData(Capability.QueryStatsMemoryColumns, true)] + [InlineData(Capability.SelfManagedInfrastructure, true)] + [InlineData(Capability.HadrEnabled, false)] + public void Only_what_the_instance_is_makes_a_rule_not_applicable(Capability capability, bool inherent) => + Assert.Equal(inherent, InstanceCapabilities.IsInherent(capability)); + [Fact] public void Rule_with_capabilities_runs() {