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>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-01 16:05:39 +02:00
parent 25b8e95611
commit 244153e9e4
20 changed files with 217 additions and 29 deletions

View file

@ -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. 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 ## Spike: det skal verificeres på rigtige VM'er
Versionerne 2000–2016 findes ikke som containere, så de kan ikke testes i CI med Testcontainers. Versionerne 2000–2016 findes ikke som containere, så de kan ikke testes i CI med Testcontainers.

View file

@ -126,8 +126,17 @@ public sealed partial class InstanceMonitor(
{ {
var missing = capabilities.Missing(collector.RequiredCapabilities); var missing = capabilities.Missing(collector.RequiredCapabilities);
if (missing.Count > 0) if (missing.Count > 0)
{
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)}."); _data.MarkUnavailable(collector.DataType, $"Ikke målbar på {info.Release.DisplayName()}: kræver {string.Join(", ", missing)}.");
}
return; return;
} }

View file

@ -2,19 +2,20 @@ namespace SQLInsight.Core.Collection;
/// <summary> /// <summary>
/// The latest data collected from one instance, keyed by data type. A collector that could not run records why, /// 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.
/// </summary> /// </summary>
public sealed class CollectedData public sealed class CollectedData
{ {
private readonly Dictionary<Type, object> _values = []; private readonly Dictionary<Type, object> _values = [];
private readonly Dictionary<Type, string> _unavailable = []; private readonly Dictionary<Type, string> _unavailable = [];
private readonly HashSet<Type> _notApplicable = [];
public void Set<T>(T value) public void Set<T>(T value)
where T : class where T : class
{ {
ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(value);
_values[typeof(T)] = value; Set(typeof(T), value);
_unavailable.Remove(typeof(T));
} }
public void Set(Type type, object value) public void Set(Type type, object value)
@ -23,6 +24,7 @@ public sealed class CollectedData
ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(value);
_values[type] = value; _values[type] = value;
_unavailable.Remove(type); _unavailable.Remove(type);
_notApplicable.Remove(type);
} }
public void MarkUnavailable(Type type, string reason) public void MarkUnavailable(Type type, string reason)
@ -30,10 +32,21 @@ public sealed class CollectedData
ArgumentNullException.ThrowIfNull(type); ArgumentNullException.ThrowIfNull(type);
_values.Remove(type); _values.Remove(type);
_unavailable[type] = reason; _unavailable[type] = reason;
_notApplicable.Remove(type);
} }
public void MarkUnavailable<T>(string reason) => MarkUnavailable(typeof(T), reason); public void MarkUnavailable<T>(string reason) => MarkUnavailable(typeof(T), reason);
/// <summary>
/// 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.
/// </summary>
public void MarkNotApplicable(Type type, string reason)
{
MarkUnavailable(type, reason);
_notApplicable.Add(type);
}
public bool TryGet<T>(out T value) public bool TryGet<T>(out T value)
where T : class where T : class
{ {
@ -49,7 +62,7 @@ public sealed class CollectedData
/// <summary> /// <summary>
/// Returns the data, or throws <see cref="DataUnavailableException"/> which the rule engine reports as /// Returns the data, or throws <see cref="DataUnavailableException"/> which the rule engine reports as
/// not measurable. /// not measurable (or not applicable, see <see cref="MarkNotApplicable"/>).
/// </summary> /// </summary>
public T Get<T>() public T Get<T>()
where T : class where T : class
@ -62,7 +75,10 @@ public sealed class CollectedData
throw new DataUnavailableException( throw new DataUnavailableException(
_unavailable.TryGetValue(typeof(T), out var reason) _unavailable.TryGetValue(typeof(T), out var reason)
? 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) : base(message, innerException)
{ {
} }
/// <summary>The data cannot exist on this SQL Server version, as opposed to missing rights or a failure.</summary>
public bool NotApplicable { get; init; }
} }

View file

@ -77,6 +77,23 @@ public sealed class InstanceCapabilities
return required.Where(c => !_capabilities.Contains(c)).Distinct().ToList(); return required.Where(c => !_capabilities.Contains(c)).Distinct().ToList();
} }
/// <summary>
/// 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.
/// </summary>
public static bool IsInherent(Capability capability) =>
MinimumVersion.ContainsKey(capability) || capability is Capability.QueryStatsMemoryColumns or Capability.SelfManagedInfrastructure;
/// <summary>
/// "kræver Query Store (fra SQL Server 2016)" for the missing capabilities, for not-applicable reasons.
/// </summary>
public static string Describe(IEnumerable<Capability> missing)
{
ArgumentNullException.ThrowIfNull(missing);
return string.Join(", ", missing.Select(c => MinimumVersionFor(c) is { } v ? $"{c} (fra {v.Release.DisplayName()})" : c.ToString()));
}
/// <summary> /// <summary>
/// The minimum version for a version-gated capability, or <see langword="null"/> for runtime-only capabilities. /// The minimum version for a version-gated capability, or <see langword="null"/> for runtime-only capabilities.
/// </summary> /// </summary>

View file

@ -208,7 +208,7 @@ public static class ManagementReport
private static void Controls(Section section, ReportData data) private static void Controls(Section section, ReportData data)
{ {
section.AddParagraph("Kontroller", StyleNames.Heading1); 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"; 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)); var table = Table(section, ("Kontrol", 5.2), ("Opfyldt", 1.8), ("Ikke opfyldt", 2.1), ("Ikke målt", 1.9), ("Instanser, der ikke opfylder", 6));

View file

@ -79,6 +79,12 @@ public sealed record ComplianceControl(string Name, string Description, params s
return ControlStatus.NotMet; 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 // 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). // instance without availability groups, for example).
return relevant.Any(s => s.RuleId == Rules[0] && s.Outcome == RuleOutcome.Passed) ? ControlStatus.Met : ControlStatus.NotMeasured; return relevant.Any(s => s.RuleId == Rules[0] && s.Outcome == RuleOutcome.Passed) ? ControlStatus.Met : ControlStatus.NotMeasured;
@ -90,4 +96,7 @@ public enum ControlStatus
Met, Met,
NotMet, NotMet,
NotMeasured, NotMeasured,
/// <summary>The control does not apply to the instance's SQL Server version; counted neither as met nor as not measured.</summary>
NotApplicable,
} }

View file

@ -20,7 +20,7 @@ public sealed class DeclarativeRule(CustomRuleDefinition definition) : IRule
ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(context);
if (context.Instance.Version < Definition.MinimumSqlVersion) 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<CustomQueryResults>(); var results = context.Data.Get<CustomQueryResults>();

View file

@ -67,15 +67,18 @@ public sealed class RuleEngine
var missing = context.Capabilities.Missing(rule.RequiredCapabilities); var missing = context.Capabilities.Missing(rule.RequiredCapabilities);
if (missing.Count > 0) 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 return new RuleResult
{ {
RuleId = rule.Id, RuleId = rule.Id,
RuleTitle = rule.Title, RuleTitle = rule.Title,
Outcome = RuleOutcome.NotMeasurable, Outcome = inherent ? RuleOutcome.NotApplicable : RuleOutcome.NotMeasurable,
MissingCapabilities = missing, MissingCapabilities = missing,
Reason = missing.Contains(Capability.SelfManagedInfrastructure) Reason = missing.Contains(Capability.SelfManagedInfrastructure) ? $"Håndteres af Azure på {release}."
? $"Håndteres af Azure på {context.Instance.Release.DisplayName()}." : inherent ? $"Ikke relevant for {release}: kræver {InstanceCapabilities.Describe(missing)}."
: $"Ikke målbar på {context.Instance.Release.DisplayName()}: kræver {string.Join(", ", missing)}.", : $"Ikke målbar på {release}: kræver {string.Join(", ", missing)}.",
}; };
} }
@ -97,7 +100,7 @@ public sealed class RuleEngine
{ {
RuleId = rule.Id, RuleId = rule.Id,
RuleTitle = rule.Title, RuleTitle = rule.Title,
Outcome = RuleOutcome.NotMeasurable, Outcome = ex.NotApplicable ? RuleOutcome.NotApplicable : RuleOutcome.NotMeasurable,
Reason = ex.Message, Reason = ex.Message,
}; };
} }

View file

@ -34,6 +34,8 @@ public sealed record RuleEvaluation
} }
public static RuleEvaluation NotMeasurable(string reason) => new(RuleOutcome.NotMeasurable, [], reason); public static RuleEvaluation NotMeasurable(string reason) => new(RuleOutcome.NotMeasurable, [], reason);
public static RuleEvaluation NotApplicable(string reason) => new(RuleOutcome.NotApplicable, [], reason);
} }
public enum RuleOutcome public enum RuleOutcome
@ -49,4 +51,11 @@ public enum RuleOutcome
/// <summary>The rule failed while running. This is never the same as <see cref="Passed"/>.</summary> /// <summary>The rule failed while running. This is never the same as <see cref="Passed"/>.</summary>
Error, Error,
/// <summary>
/// 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.
/// </summary>
NotApplicable,
} }

View file

@ -13,7 +13,7 @@ public sealed record RuleResult
public required RuleOutcome Outcome { get; init; } public required RuleOutcome Outcome { get; init; }
public IReadOnlyList<Finding> Findings { get; init; } = []; public IReadOnlyList<Finding> Findings { get; init; } = [];
/// <summary>Capabilities the instance lacks, when the outcome is <see cref="RuleOutcome.NotMeasurable"/> for that reason.</summary> /// <summary>Capabilities the instance lacks, when the outcome is <see cref="RuleOutcome.NotApplicable"/> or <see cref="RuleOutcome.NotMeasurable"/> for that reason.</summary>
public IReadOnlyList<Capability> MissingCapabilities { get; init; } = []; public IReadOnlyList<Capability> MissingCapabilities { get; init; } = [];
/// <summary>Why the rule was not measurable or failed.</summary> /// <summary>Why the rule was not measurable or failed.</summary>

View file

@ -159,7 +159,7 @@ else
<div class="card-head"><h2>Ikke målbart på denne instans</h2></div> <div class="card-head"><h2>Ikke målbart på denne instans</h2></div>
@if (_notMeasurable.Count == 0) @if (_notMeasurable.Count == 0)
{ {
<p class="muted">Alle regler kunne evalueres.</p> <p class="muted">Alle regler, der er relevante for instansen, kunne evalueres.</p>
} }
else else
{ {
@ -174,6 +174,22 @@ else
</tbody> </tbody>
</table> </table>
} }
@if (_notApplicable.Count > 0)
{
<details class="not-applicable">
<summary>Ikke relevant for @Display.Release(_instance.ProductVersion, _instance.EngineEdition) (@_notApplicable.Count)</summary>
<p class="muted">Regler om noget, denne SQL Server-version ikke har (fx Query Store eller system_health). Det er ikke et hul i overvågningen.</p>
<table>
<thead><tr><th>Regel</th><th></th><th>Hvorfor</th></tr></thead>
<tbody>
@foreach (var r in _notApplicable)
{
<tr><td class="mono">@r.RuleId</td><td>@r.RuleTitle</td><td>@r.Reason</td></tr>
}
</tbody>
</table>
</details>
}
</section> </section>
<section class="card"> <section class="card">
@ -203,6 +219,7 @@ else
private IReadOnlyList<StoredFinding> _findings = []; private IReadOnlyList<StoredFinding> _findings = [];
private IReadOnlyList<StoredFinding> _resolved = []; private IReadOnlyList<StoredFinding> _resolved = [];
private IReadOnlyList<StoredRuleState> _notMeasurable = []; private IReadOnlyList<StoredRuleState> _notMeasurable = [];
private IReadOnlyList<StoredRuleState> _notApplicable = [];
private IReadOnlyList<MetricPoint> _cpuSql = [], _cpuOther = [], _batches = [], _ple = []; private IReadOnlyList<MetricPoint> _cpuSql = [], _cpuOther = [], _batches = [], _ple = [];
private IReadOnlyList<WaitTotal> _waits = []; private IReadOnlyList<WaitTotal> _waits = [];
private IReadOnlyList<FileLatency> _files = []; private IReadOnlyList<FileLatency> _files = [];
@ -221,7 +238,9 @@ else
var day = DateTimeOffset.UtcNow.AddDays(-1); var day = DateTimeOffset.UtcNow.AddDays(-1);
_findings = await Repository.GetActiveFindingsAsync(id); _findings = await Repository.GetActiveFindingsAsync(id);
_resolved = await Repository.GetRecentlyResolvedAsync(id, 30); _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); _cpuSql = await Repository.GetMetricAsync(id, Metrics.CpuSqlServer, day);
_cpuOther = await Repository.GetMetricAsync(id, Metrics.CpuOther, day); _cpuOther = await Repository.GetMetricAsync(id, Metrics.CpuOther, day);
_batches = await Repository.GetMetricAsync(id, Metrics.BatchRequestsPerSecond, day); _batches = await Repository.GetMetricAsync(id, Metrics.BatchRequestsPerSecond, day);

View file

@ -188,3 +188,8 @@ a.button:hover { filter: brightness(1.08); }
.panel.unusual { border-left: 4px solid var(--critical); } .panel.unusual { border-left: 4px solid var(--critical); }
tr.unusual td { background: var(--critical-soft); } 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; } 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; }

View file

@ -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
}
}

View file

@ -104,6 +104,29 @@ public class WebTests(SqlServerFixture sql) : IAsyncLifetime
Assert.Contains($"/deadlocks/{_deadlockId}.xdl", html, StringComparison.Ordinal); 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("<details class=\"not-applicable\">", 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] [Fact]
public async Task Finding_page_shows_explanation_evidence_and_script_html_encoded() public async Task Finding_page_shows_explanation_evidence_and_script_html_encoded()
{ {

View file

@ -115,13 +115,13 @@ public class CustomRuleTests
} }
[Fact] [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 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"))); 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); Assert.Contains("13.0", result.Reason, StringComparison.Ordinal);
} }

View file

@ -147,11 +147,11 @@ public class DatabaseRuleTests
} }
[Fact] [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()))); 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] [Fact]

View file

@ -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" }) 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); 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); Assert.Equal("Håndteres af Azure på Azure SQL Managed Instance.", result.Reason);
} }
} }

View file

@ -86,9 +86,9 @@ public class PlanRuleTests
} }
[Fact] [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] [Fact]

View file

@ -121,10 +121,10 @@ public class QueryStoreRuleTests
} }
[Fact] [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")); var result = Run(new PlanRegressionRule(), WithVersion("12.0.6024.0"));
Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome); Assert.Equal(RuleOutcome.NotApplicable, result.Outcome);
} }
} }

View file

@ -21,19 +21,62 @@ public class RuleEngineTests
} }
[Fact] [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 rule = new StubRule("QS", [Capability.QueryStore], _ => RuleEvaluation.Passed());
var engine = new RuleEngine([rule]); 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.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); 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<SQLInsight.Core.Data.DeadlockSnapshot>();
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<SQLInsight.Core.Data.DeadlockSnapshot>("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] [Fact]
public void Rule_with_capabilities_runs() public void Rule_with_capabilities_runs()
{ {