using System.Reflection; using System.Text.RegularExpressions; using SQLInsight.Rules; using SQLInsight.Rules.Lifecycle; namespace SQLInsight.Collectors.Tests.SqlScripts; /// /// Keeps the folder "SQL Scripts" (one reviewable script per rule, for DBAs) in step with the code: a script for every /// rule and no other, the collectors' SQL in them word for word, and nothing in them that writes. /// public sealed partial class SqlScriptTests { private static readonly string Folder = Path.Combine(RepositoryRoot(), "SQL Scripts"); [Fact] public void Every_registered_rule_has_a_script_and_every_script_a_rule() { var ids = BuiltInRules.Create().Select(r => r.Id) .Concat(RulePack.Embedded().CommunityRules.Select(r => r.Id)) .ToHashSet(StringComparer.Ordinal); var files = Directory.EnumerateFiles(Folder, "*.sql", SearchOption.TopDirectoryOnly) .Select(Path.GetFileNameWithoutExtension) .OfType() .ToHashSet(StringComparer.Ordinal); var missing = ids.Except(files).Order(StringComparer.Ordinal).ToList(); var unknown = files.Except(ids).Order(StringComparer.Ordinal).ToList(); Assert.True(missing.Count == 0, "Regler uden script i \"SQL Scripts\" (tilføj .sql, se CONTRIBUTING.md): " + string.Join(", ", missing)); Assert.True(unknown.Count == 0, "Scripts i \"SQL Scripts\" for regel-id'er, der ikke findes: " + string.Join(", ", unknown)); } [Fact] public void Rule_scripts_have_the_review_header() { string[] labels = [ "-- Regel-id:", "-- Titel:", "-- Alvor:", "-- Versioner:", "-- Hvad tjekkes, og hvorfor:", "-- Vurdering", "-- Grænseværdier", "-- Rettigheder", "-- Data kommer fra", "-- Regelkode:", "-- Read-only:", ]; var problems = new List(); foreach (var file in RuleScripts()) { var text = Read(file); var id = Path.GetFileNameWithoutExtension(file); problems.AddRange(labels.Where(l => !text.Contains(l, StringComparison.Ordinal)).Select(l => $"{id}: mangler '{l}'")); if (!Regex.IsMatch(text, @"^-- Regel-id:\s+" + Regex.Escape(id) + @"\s*$", RegexOptions.Multiline)) { problems.Add($"{id}: Regel-id i headeren er ikke filens navn"); } } Assert.True(problems.Count == 0, string.Join("; ", problems)); } [Fact] public void Scripts_contain_the_current_sql_from_the_code() { var problems = new List(); foreach (var file in AllScripts()) { var name = Path.GetRelativePath(Folder, file); var blocks = Blocks(Read(file)).ToList(); if (blocks.Count == 0) { problems.Add($"{name}: ingen '-- Kilde:'-blokke"); } foreach (var (key, body) in blocks) { if (!SqlScriptSources.ByKey.TryGetValue(key, out var source)) { problems.Add($"{name}: ukendt kilde '{key}'"); } else if (!SqlScriptSources.Pattern(source.Sql).IsMatch(body)) { problems.Add($"{name}: '{key}' er ikke længere den SQL, koden sender ({source.File} : {source.Member})"); } } } Assert.True(problems.Count == 0, "Opdatér \"SQL Scripts\": " + string.Join("; ", problems)); } [Fact] public void Every_collector_query_is_registered_and_in_a_collector_script() { var problems = new List(); var registered = SqlScriptSources.All.Select(s => s.Sql).ToList(); foreach (var type in typeof(ICollector).Assembly.GetTypes()) { const BindingFlags Static = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly; // SQL constants: found word for word in a registered text ({placeholders} are filled in by the code). foreach (var field in type.GetFields(Static).Where(f => f.IsLiteral && f.FieldType == typeof(string) && f.Name.Contains("Sql", StringComparison.Ordinal))) { var text = SqlScriptSources.Normalize((string)field.GetRawConstantValue()!); var pattern = new Regex(string.Join("(?:.+?)", Regex.Split(text, @"\{[A-Za-z]+\}").Select(Regex.Escape)), RegexOptions.Singleline); if (!registered.Any(pattern.IsMatch)) { problems.Add($"{type.Name}.{field.Name} er ikke registreret i SqlScriptSources"); } } // SQL builders: at least one registered rendering. foreach (var method in type.GetMethods(Static).Where(m => m.ReturnType == typeof(string) && !m.Name.Contains('<', StringComparison.Ordinal) && (m.Name.StartsWith("Build", StringComparison.Ordinal) || m.Name.EndsWith("Sql", StringComparison.Ordinal) || m.Name == "SqlFor"))) { if (!SqlScriptSources.All.Any(s => s.Member.StartsWith(type.Name + ".", StringComparison.Ordinal) && s.Member.Contains(method.Name + "(", StringComparison.Ordinal))) { problems.Add($"{type.Name}.{method.Name}() er ikke registreret i SqlScriptSources"); } } } var inCollectorScripts = Directory.EnumerateFiles(Path.Combine(Folder, "_collectors"), "*.sql") .SelectMany(f => Blocks(Read(f)).Select(b => b.Key)) .ToHashSet(StringComparer.Ordinal); problems.AddRange(SqlScriptSources.All.Where(s => !inCollectorScripts.Contains(s.Key)).Select(s => $"'{s.Key}' står ikke i _collectors/{s.Collector}.sql")); Assert.True(problems.Count == 0, string.Join("; ", problems)); } [Fact] public void Community_rule_scripts_contain_the_rule_query_verbatim() { var problems = RulePack.Embedded().CommunityRules .Where(r => !File.Exists(Path.Combine(Folder, r.Id + ".sql")) || !Read(Path.Combine(Folder, r.Id + ".sql")).Contains(SqlScriptSources.Normalize(r.Query), StringComparison.Ordinal)) .Select(r => r.Id) .ToList(); Assert.True(problems.Count == 0, "Community-reglens query står ikke ordret i SQL Scripts/.sql: " + string.Join(", ", problems)); } [Fact] public void Scripts_are_read_only_apart_from_the_documented_temp_table() { var problems = new List(); foreach (var file in AllScripts()) { var text = Read(file); var code = CommentsAndStrings().Replace(text, " "); code = AllowedStatements().Replace(code, " "); if (text.Contains("-- Kilde: DeadlockCollector.BuildFileSql", StringComparison.Ordinal)) { // Documented exception: a session-private temp table in tempdb (see the script's Read-only line). code = DeadlockTempTable().Replace(code, " "); } problems.AddRange(Writes().Matches(code).Select(m => $"{Path.GetRelativePath(Folder, file)}: {m.Value.ToUpperInvariant()}").Distinct()); } Assert.True(problems.Count == 0, "Skrivende sætninger i \"SQL Scripts\": " + string.Join("; ", problems)); } [Theory] [InlineData("SELECT name INTO dbo.x FROM sys.objects", true)] [InlineData("UPDATE dbo.t SET a = 1", true)] [InlineData("EXEC sys.sp_configure N'x', 1", true)] [InlineData("SELECT is_auto_update_stats_on, create_date FROM sys.databases -- DROP TABLE x", false)] [InlineData("EXEC master.dbo.xp_readerrorlog 0, 1, NULL, NULL, @fra, @til", false)] [InlineData("DBCC TRACESTATUS(-1) WITH NO_INFOMSGS", false)] [InlineData("SELECT N'DELETE' AS [UPDATE]", false)] public void The_read_only_check_itself_recognises_writes(string sql, bool writes) => Assert.Equal(writes, Writes().IsMatch(AllowedStatements().Replace(CommentsAndStrings().Replace(sql, " "), " "))); /// The -- Kilde: key blocks: everything after the line up to the next GO. private static IEnumerable<(string Key, string Body)> Blocks(string text) { string? key = null; var body = new List(); foreach (var line in text.Split('\n')) { if (line.StartsWith("-- Kilde: ", StringComparison.Ordinal)) { key = line["-- Kilde: ".Length..].Trim(); body.Clear(); } else if (key is not null && line.Trim() == "GO") { yield return (key, string.Join("\n", body)); key = null; } else if (key is not null) { body.Add(line); } } } private static IEnumerable RuleScripts() => Directory.EnumerateFiles(Folder, "*.sql", SearchOption.TopDirectoryOnly); private static IEnumerable AllScripts() => Directory.EnumerateFiles(Folder, "*.sql", SearchOption.AllDirectories); private static string Read(string file) => SqlScriptSources.Normalize(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; } [GeneratedRegex(@"--[^\n]*|/\*.*?\*/|N?'(?:[^']|'')*'|\[[^\]]*\]", RegexOptions.Singleline)] private static partial Regex CommentsAndStrings(); /// Read-only statements that use otherwise writing keywords. [GeneratedRegex(@"\bEXEC\s+master\.dbo\.xp_(readerrorlog|msver)\b|\bDBCC\s+TRACESTATUS\b|\bDBCC\s+SQLPERF\s*\(\s*WAITSTATS\s*\)", RegexOptions.IgnoreCase)] private static partial Regex AllowedStatements(); [GeneratedRegex(@"\bINTO\s+#system_health\b|\bDROP\s+TABLE\s+#system_health\b", RegexOptions.IgnoreCase)] private static partial Regex DeadlockTempTable(); [GeneratedRegex(@"\b(INSERT|UPDATE|DELETE|MERGE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|DENY|BACKUP|RESTORE|RECONFIGURE|KILL|SHUTDOWN|BULK|OPENROWSET|OPENQUERY|OPENDATASOURCE|WAITFOR|INTO|EXEC|EXECUTE|DBCC|SETUSER)\b", RegexOptions.IgnoreCase)] private static partial Regex Writes(); }