SQL Server 2005: fix agent jobs and plan analysis, guard against 2008+ syntax

Seen on a customer's SQL Server 2005 SP4 (legacy ODBC):
- agent-jobs declared @since with an initial value (2008+): "Incorrect syntax
  near CONVERT" and SQLI-JOB-001/002 never ran. Now DECLARE; SET.
- plan-analysis used query_hash (2008+) and failed every 30 minutes for
  SQLI-PLAN-001/002/003. On 2005 a statement is now identified by
  sql_handle + offsets, as top SQL does. It needs sys.dm_exec_text_query_plan,
  so the new capability TextQueryPlan (2005 SP2, 9.0.3042) is required.
- COM-DB-001 used OBJECT_SCHEMA_NAME (2005 SP2+); now joins sys.objects and
  sys.schemas.

Audit of all collector SQL and community rules without minimumVersion 10.0:
the rest is 2005 syntax or behind a version/capability check.

Sql2005CompatibilityTests (2005 cannot run in CI): every collector that can
run on 2005 is scanned for 2008+ syntax in its SQL (inline DECLARE, +=,
MERGE, CONCAT/IIF/FORMAT/TRY_*, OFFSET/FETCH, date/time types, multi-row
VALUES), version-branched files listed explicitly; the same for community
rules; and the 2005 variants of plan analysis and top SQL must not use
query_hash. Verified to fail on the old agent-jobs SQL.

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 15:51:39 +02:00
parent 554a6a71f5
commit 25b8e95611
6 changed files with 151 additions and 6 deletions

View file

@ -6,7 +6,7 @@
"title": "Fremmednøgler, som SQL Server ikke stoler på", "title": "Fremmednøgler, som SQL Server ikke stoler på",
"severity": "Low", "severity": "Low",
"scope": "Database", "scope": "Database",
"query": "SELECT OBJECT_SCHEMA_NAME(parent_object_id) + '.' + OBJECT_NAME(parent_object_id) AS [table], name AS [constraint] FROM sys.foreign_keys WHERE is_not_trusted = 1 AND is_disabled = 0", "query": "SELECT s.name + '.' + o.name AS [table], fk.name AS [constraint] FROM sys.foreign_keys fk JOIN sys.objects o ON o.object_id = fk.parent_object_id JOIN sys.schemas s ON s.schema_id = o.schema_id WHERE fk.is_not_trusted = 1 AND fk.is_disabled = 0",
"subject": "constraint: {database}/{constraint}", "subject": "constraint: {database}/{constraint}",
"findingTitle": "{database}: fremmednøglen {constraint} på {table} er ikke betroet", "findingTitle": "{database}: fremmednøglen {constraint} på {table} er ikke betroet",
"explanation": "Nøglen er blevet slået til igen uden WITH CHECK, fx efter en dataindlæsning. Den håndhæves for nye rækker, men SQL Server kan ikke stole på eksisterende data, så optimizeren udnytter den ikke til at fjerne joins, og der kan ligge ugyldige data.", "explanation": "Nøglen er blevet slået til igen uden WITH CHECK, fx efter en dataindlæsning. Den håndhæves for nye rækker, men SQL Server kan ikke stole på eksisterende data, så optimizeren udnytter den ikke til at fjerne joins, og der kan ligge ugyldige data.",

View file

@ -13,7 +13,8 @@ public sealed class AgentJobCollector : Collector<AgentJobSnapshot>
internal const string RunningSql = "SELECT COUNT(*) FROM sys.dm_exec_sessions WHERE program_name LIKE N'SQLAgent - %'"; internal const string RunningSql = "SELECT COUNT(*) FROM sys.dm_exec_sessions WHERE program_name LIKE N'SQLAgent - %'";
internal const string JobsSql = """ internal const string JobsSql = """
DECLARE @since int = CONVERT(int, CONVERT(char(8), DATEADD(day, -1, GETDATE()), 112)); DECLARE @since int;
SET @since = CONVERT(int, CONVERT(char(8), DATEADD(day, -1, GETDATE()), 112));
SELECT j.name, j.enabled, last_run.run_status, last_run.run_date, last_run.run_time, SELECT j.name, j.enabled, last_run.run_status, last_run.run_date, last_run.run_time,
failed_step.step_name, LEFT(failed_step.message, 2000), failures.n failed_step.step_name, LEFT(failed_step.message, 2000), failures.n
FROM msdb.dbo.sysjobs j FROM msdb.dbo.sysjobs j

View file

@ -14,12 +14,13 @@ public sealed class PlanAnalysisCollector : Collector<PlanAnalysisSnapshot>
public override string Name => "plan-analysis"; public override string Name => "plan-analysis";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(30); public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(30);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews]; public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews, Capability.TextQueryPlan];
protected override async Task<PlanAnalysisSnapshot> CollectAsync(CollectorContext context, PlanAnalysisSnapshot? previous) protected override async Task<PlanAnalysisSnapshot> CollectAsync(CollectorContext context, PlanAnalysisSnapshot? previous)
{ {
var memory = context.Capabilities.Has(Capability.QueryStatsMemoryColumns); var memory = context.Capabilities.Has(Capability.QueryStatsMemoryColumns);
var statements = await Db.QueryAsync(context.Connection, BuildSql(memory), r => new AnalyzedStatement var queryHash = context.Instance.Version.Major >= 10;
var statements = await Db.QueryAsync(context.Connection, BuildSql(memory, queryHash), r => new AnalyzedStatement
{ {
QueryHash = r.String(0), QueryHash = r.String(0),
DatabaseName = r.NullableString(1), DatabaseName = r.NullableString(1),
@ -38,8 +39,12 @@ public sealed class PlanAnalysisCollector : Collector<PlanAnalysisSnapshot>
return new PlanAnalysisSnapshot { CapturedAt = context.Now, Statements = statements }; return new PlanAnalysisSnapshot { CapturedAt = context.Now, Statements = statements };
} }
internal static string BuildSql(bool memoryColumns) /// <param name="queryHash">query_hash exists from SQL Server 2008; on 2005 a statement is its batch and offsets.</param>
internal static string BuildSql(bool memoryColumns, bool queryHash = true)
{ {
var key = queryHash
? "CONVERT(varchar(20), qs.query_hash, 1)"
: "sys.fn_varbintohexstr(qs.sql_handle) + ':' + CAST(qs.statement_start_offset AS varchar(12)) + ':' + CAST(qs.statement_end_offset AS varchar(12))";
var memory = memoryColumns var memory = memoryColumns
? "qs.total_spills, qs.total_grant_kb, qs.total_used_grant_kb" ? "qs.total_spills, qs.total_grant_kb, qs.total_used_grant_kb"
: "CAST(NULL AS bigint), CAST(NULL AS bigint), CAST(NULL AS bigint)"; : "CAST(NULL AS bigint), CAST(NULL AS bigint), CAST(NULL AS bigint)";
@ -50,7 +55,7 @@ public sealed class PlanAnalysisCollector : Collector<PlanAnalysisSnapshot>
WHERE qs.last_execution_time >= DATEADD(hour, -24, GETDATE()) WHERE qs.last_execution_time >= DATEADD(hour, -24, GETDATE())
ORDER BY qs.total_worker_time DESC ORDER BY qs.total_worker_time DESC
) )
SELECT CONVERT(varchar(20), qs.query_hash, 1), SELECT {key},
DB_NAME(CONVERT(int, pa.value)), DB_NAME(CONVERT(int, pa.value)),
LEFT(SUBSTRING(t.text, qs.statement_start_offset / 2 + 1, LEFT(SUBSTRING(t.text, qs.statement_start_offset / 2 + 1,
(CASE WHEN qs.statement_end_offset = -1 THEN DATALENGTH(t.text) ELSE qs.statement_end_offset END - qs.statement_start_offset) / 2 + 1), 4000), (CASE WHEN qs.statement_end_offset = -1 THEN DATALENGTH(t.text) ELSE qs.statement_end_offset END - qs.statement_start_offset) / 2 + 1), 4000),

View file

@ -61,4 +61,7 @@ public enum Capability
/// <summary>Granular server permissions such as <c>VIEW SERVER PERFORMANCE STATE</c>. SQL Server 2022+.</summary> /// <summary>Granular server permissions such as <c>VIEW SERVER PERFORMANCE STATE</c>. SQL Server 2022+.</summary>
GranularServerPermissions, GranularServerPermissions,
/// <summary><c>sys.dm_exec_text_query_plan</c> (cached plans as text, per statement). SQL Server 2005 SP2+.</summary>
TextQueryPlan,
} }

View file

@ -12,6 +12,7 @@ public sealed class InstanceCapabilities
[Capability.DynamicManagementViews] = new(9, 0, 0, 0), [Capability.DynamicManagementViews] = new(9, 0, 0, 0),
[Capability.DefaultTrace] = new(9, 0, 0, 0), [Capability.DefaultTrace] = new(9, 0, 0, 0),
[Capability.ViewServerStatePermission] = new(9, 0, 0, 0), [Capability.ViewServerStatePermission] = new(9, 0, 0, 0),
[Capability.TextQueryPlan] = new(9, 0, 3042, 0), // 2005 SP2
[Capability.ExtendedEvents] = new(10, 0, 0, 0), [Capability.ExtendedEvents] = new(10, 0, 0, 0),
[Capability.SystemHealthSession] = new(10, 0, 0, 0), [Capability.SystemHealthSession] = new(10, 0, 0, 0),
[Capability.SystemHealthFileTarget] = new(11, 0, 0, 0), [Capability.SystemHealthFileTarget] = new(11, 0, 0, 0),

View file

@ -0,0 +1,135 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Tests;
/// <summary>
/// SQL Server 2005 cannot run in CI (no container image), and its T-SQL lacks much of 2008's. Every collector that can
/// run on 2005 (all its required capabilities exist from 9.0) is checked for 2008+ syntax in its SQL, and so is every
/// community rule that does not ask for 10.0 or later. A collector with version branches must keep 2008+ syntax in the
/// branch for newer versions; those few are listed with what they gate.
/// </summary>
public sealed partial class Sql2005CompatibilityTests
{
/// <summary>2008+ constructs that make a whole batch fail on 2005, whatever branch it was meant for.</summary>
private static readonly (string Name, Regex Pattern)[] Sql2008Only =
[
("DECLARE med startværdi", new Regex(@"DECLARE\s+@\w+\s+\w+(\s*\([\d\s,]+\))?\s*=", RegexOptions.IgnoreCase)),
("+= / -= på variabel", new Regex(@"@\w+\s*[+\-*/]=", RegexOptions.IgnoreCase)),
("MERGE", new Regex(@"\bMERGE\s", RegexOptions.IgnoreCase)),
("CONCAT/IIF/CHOOSE/FORMAT/TRY_*", new Regex(@"\b(CONCAT|IIF|CHOOSE|FORMAT|TRY_CONVERT|TRY_CAST|TRY_PARSE|EOMONTH|DATEFROMPARTS|STRING_AGG|STRING_SPLIT)\s*\(", RegexOptions.IgnoreCase)),
("OFFSET … FETCH", new Regex(@"\bOFFSET\s+\S+\s+ROWS\b", RegexOptions.IgnoreCase)),
("date/time/datetime2/datetimeoffset", new Regex(@"\bAS\s+(date|time|datetime2|datetimeoffset)\b|\b(datetime2|datetimeoffset)\s*\(", RegexOptions.IgnoreCase)),
("VALUES med flere rækker", new Regex(@"\bVALUES\s*\([^)]*\)\s*,\s*\(", RegexOptions.IgnoreCase)),
];
/// <summary>
/// Collectors that run on 2005 but deliberately contain 2008+ syntax in a branch that 2005 never takes.
/// Only patterns from <see cref="Sql2008Only"/> listed here are tolerated, and only in that file.
/// </summary>
private static readonly Dictionary<string, string[]> VersionBranched = new(StringComparer.Ordinal)
{
// Query Store queries only run when Capability.QueryStore is present (2016+).
["QueryStoreCollector"] = ["DECLARE med startværdi", "date/time/datetime2/datetimeoffset"],
};
[Fact]
public void Collectors_that_run_on_sql_server_2005_use_only_2005_syntax()
{
var problems = new List<string>();
foreach (var collector in BuiltInCollectors.Create())
{
var runsOn2005 = collector.RequiredCapabilities.All(c => InstanceCapabilities.MinimumVersionFor(c) is not { Major: > 9 });
if (!runsOn2005)
{
continue;
}
var name = collector.GetType().Name;
var source = SourceOf(name);
VersionBranched.TryGetValue(name, out var tolerated);
foreach (Match literal in RawString().Matches(source))
{
foreach (var (pattern, regex) in Sql2008Only)
{
if (regex.IsMatch(literal.Value) && tolerated?.Contains(pattern) != true)
{
problems.Add($"{name}: {pattern}");
}
}
}
}
Assert.True(problems.Count == 0, "SQL Server 2008+-syntaks i SQL, der også køres på 2005: " + string.Join("; ", problems.Distinct()));
}
[Fact]
public void Community_rules_for_sql_server_2005_use_only_2005_syntax()
{
var problems = new List<string>();
foreach (var file in Directory.EnumerateFiles(Path.Combine(RepositoryRoot(), "rules", "community"), "*.json"))
{
using var document = JsonDocument.Parse(File.ReadAllText(file), new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
var rules = document.RootElement.ValueKind == JsonValueKind.Array ? document.RootElement.EnumerateArray().ToList() : [document.RootElement];
foreach (var rule in rules)
{
var minimum = rule.TryGetProperty("minimumVersion", out var v) ? v.GetString() : "9.0";
if (SqlServerVersion.Parse(minimum + ".0.0").Major > 9)
{
continue;
}
var query = rule.GetProperty("query").GetString()!;
problems.AddRange(Sql2008Only.Where(p => p.Pattern.IsMatch(query)).Select(p => $"{rule.GetProperty("id").GetString()}: {p.Name}"));
}
}
Assert.True(problems.Count == 0, "SQL Server 2008+-syntaks i regler uden minimumVersion 10.0: " + string.Join("; ", problems));
}
[Fact]
public void Plan_analysis_and_top_sql_identify_statements_without_query_hash_on_2005()
{
var sql2005 = new[]
{
Performance.PlanAnalysisCollector.BuildSql(memoryColumns: false, queryHash: false),
Performance.QueryStatsCollector.SqlFor(SqlServerVersion.Parse("9.0.5057.0"), new DateTime(2026, 10, 1)),
};
Assert.All(sql2005, sql => Assert.DoesNotContain("query_hash", sql, StringComparison.OrdinalIgnoreCase));
Assert.All(sql2005, sql => Assert.Contains("sys.fn_varbintohexstr(", sql, StringComparison.Ordinal));
Assert.Contains("query_hash", Performance.PlanAnalysisCollector.BuildSql(memoryColumns: true), StringComparison.Ordinal);
}
[Theory]
[InlineData("DECLARE @since int = CONVERT(int, 1);", true)]
[InlineData("DECLARE @x nvarchar(60) = N'a';", true)]
[InlineData("DECLARE @since int;\nSET @since = 1;", false)]
[InlineData("SET @n += 1", true)]
[InlineData("SELECT CONVERT(datetime, '2026-10-01', 126)", false)]
public void The_check_itself_recognises_2008_syntax(string sql, bool expected) =>
Assert.Equal(expected, Sql2008Only.Any(p => p.Pattern.IsMatch(sql)));
private static string SourceOf(string typeName)
{
var folder = Path.Combine(RepositoryRoot(), "src", "SQLInsight.Collectors");
var file = Directory.EnumerateFiles(folder, typeName + ".cs", SearchOption.AllDirectories).SingleOrDefault();
return file is null ? throw new InvalidOperationException($"{typeName}.cs ikke fundet") : File.ReadAllText(file);
}
private static string RepositoryRoot()
{
var root = AppContext.BaseDirectory;
while (!File.Exists(Path.Combine(root, "SQLInsight.slnx")))
{
root = Path.GetDirectoryName(root) ?? throw new InvalidOperationException("SQLInsight.slnx ikke fundet.");
}
return root;
}
// C# raw string literals ("""…""" and $"""…"""): where the collectors keep their SQL.
[GeneratedRegex("\"\"\"(.*?)\"\"\"", RegexOptions.Singleline)]
private static partial Regex RawString();
}