sqlinsight-pro/tests/SQLInsight.Collectors.Tests/Sql2005CompatibilityTests.cs
Søren Eggert Lundsteen Olsen 25b8e95611 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>
2026-10-01 15:51:39 +02:00

135 lines
6.6 KiB
C#

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();
}