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() {