From 8cde94038c82cb998fa37df34ee79eaece7ff168 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:47:53 +0000 Subject: [PATCH] Fase 2: index and statistics analysis Collector (every 6 h, metadata and DMVs only, no scans): index inventory with usage and forwarded fetches, missing-index suggestions, statistics modifications via sysindexes.rowmodctr (needs only metadata visibility). Rules: - SQLI-IDX-001 missing index, evaluated against existing indexes: extends an index with the same keys instead of adding one; notes on write-heavy tables, many indexes, wide includes, short uptime - SQLI-IDX-002 unused index (needs 7 days of uptime; suggests DISABLE) - SQLI-IDX-003 duplicate or covered index (filters, uniqueness respected) - SQLI-IDX-004 heap with forwarded records - SQLI-STAT-001 stale statistics, SQLI-STAT-002 auto statistics off Column lists use U+E000 as separator: FOR XML cannot serialize NCHAR(1). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK --- .../BuiltInCollectors.cs | 1 + .../Databases/DatabaseCollector.cs | 9 +- .../Databases/IndexCollector.cs | 156 +++++++ src/SQLInsight.Core/Data/DatabaseSnapshot.cs | 2 + src/SQLInsight.Core/Data/Indexes.cs | 90 ++++ src/SQLInsight.Rules/BuiltInRules.cs | 9 + src/SQLInsight.Rules/Indexes/IndexRules.cs | 383 ++++++++++++++++++ .../CollectorTests.cs | 46 +++ .../SQLInsight.Rules.Tests/IndexRuleTests.cs | 196 +++++++++ 9 files changed, 890 insertions(+), 2 deletions(-) create mode 100644 src/SQLInsight.Collectors/Databases/IndexCollector.cs create mode 100644 src/SQLInsight.Core/Data/Indexes.cs create mode 100644 src/SQLInsight.Rules/Indexes/IndexRules.cs create mode 100644 tests/SQLInsight.Rules.Tests/IndexRuleTests.cs diff --git a/src/SQLInsight.Collectors/BuiltInCollectors.cs b/src/SQLInsight.Collectors/BuiltInCollectors.cs index 2f36b9f..0ee060e 100644 --- a/src/SQLInsight.Collectors/BuiltInCollectors.cs +++ b/src/SQLInsight.Collectors/BuiltInCollectors.cs @@ -23,5 +23,6 @@ public static class BuiltInCollectors new DeadlockCollector(), new QueryStoreCollector(), new PlanAnalysisCollector(), + new IndexCollector(), ]; } diff --git a/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs b/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs index 9d7bac2..c8c4042 100644 --- a/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs +++ b/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs @@ -107,7 +107,8 @@ public sealed class DatabaseCollector : Collector CASE WHEN b.last_full >= DATEADD(second, -1, d.create_date) THEN b.last_full END, CASE WHEN b.last_diff >= DATEADD(second, -1, d.create_date) THEN b.last_diff END, CASE WHEN b.last_log >= DATEADD(second, -1, d.create_date) THEN b.last_log END, - {checkDb} + {checkDb}, + d.is_auto_create_stats_on, d.is_auto_update_stats_on FROM sys.databases d {BackupJoin} WHERE d.name <> N'tempdb' @@ -130,7 +131,9 @@ public sealed class DatabaseCollector : Collector CASE WHEN b.last_full >= DATEADD(second, -1, d.crdate) THEN b.last_full END, CASE WHEN b.last_diff >= DATEADD(second, -1, d.crdate) THEN b.last_diff END, CASE WHEN b.last_log >= DATEADD(second, -1, d.crdate) THEN b.last_log END, - CAST(NULL AS datetime) + CAST(NULL AS datetime), + CAST(DATABASEPROPERTYEX(d.name, 'IsAutoCreateStatistics') AS int), + CAST(DATABASEPROPERTYEX(d.name, 'IsAutoUpdateStatistics') AS int) FROM master.dbo.sysdatabases d {BackupJoin} WHERE d.name <> N'tempdb' @@ -183,6 +186,8 @@ public sealed class DatabaseCollector : Collector LastDifferentialBackup = r.NullableDateTime(14), LastLogBackup = r.NullableDateTime(15), LastGoodCheckDb = r.NullableDateTime(16), + IsAutoCreateStatistics = r.Bool(17), + IsAutoUpdateStatistics = r.Bool(18), }; private static DatabaseInfo Map2000(Row r) => Map2005(r) with { State = r.NullableString(2) ?? "UNKNOWN", RecoveryModel = r.NullableString(3) ?? "UNKNOWN" }; diff --git a/src/SQLInsight.Collectors/Databases/IndexCollector.cs b/src/SQLInsight.Collectors/Databases/IndexCollector.cs new file mode 100644 index 0000000..432c155 --- /dev/null +++ b/src/SQLInsight.Collectors/Databases/IndexCollector.cs @@ -0,0 +1,156 @@ +using System.Data.Common; +using SQLInsight.Core.Data; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Collectors.Databases; + +/// +/// Index inventory with usage, missing-index suggestions and statistics for every readable user database. +/// Everything is metadata and DMVs; no table data is read and nothing is scanned (no index_physical_stats). +/// +public sealed class IndexCollector : Collector +{ + internal const string DatabasesSql = """ + SELECT d.name FROM sys.databases d + WHERE d.state = 0 AND d.database_id > 4 AND HAS_DBACCESS(d.name) = 1 + AND NOT EXISTS (SELECT 1 FROM sys.database_mirroring m WHERE m.database_id = d.database_id AND m.mirroring_role = 2) + """; + + internal const string MissingSql = """ + SELECT DB_NAME(d.database_id), d.statement, d.equality_columns, d.inequality_columns, d.included_columns, + gs.user_seeks, gs.user_scans, gs.avg_total_user_cost, gs.avg_user_impact, gs.last_user_seek + FROM sys.dm_db_missing_index_details d + JOIN sys.dm_db_missing_index_groups g ON g.index_handle = d.index_handle + JOIN sys.dm_db_missing_index_group_stats gs ON gs.group_handle = g.index_group_handle + """; + + public override string Name => "indexes"; + public override TimeSpan DefaultInterval => TimeSpan.FromHours(6); + public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews]; + + protected override async Task CollectAsync(CollectorContext context, IndexSnapshot? previous) + { + var connection = context.Connection; + var ct = context.CancellationToken; + var startTime = await Db.StartTimeAsync(connection, context.Instance.Version, ct).ConfigureAwait(false); + var names = await Db.QueryAsync(connection, DatabasesSql, r => r.String(0), ct).ConfigureAwait(false); + var missing = await Db.QueryAsync(connection, MissingSql, r => (Database: r.NullableString(0), Index: new MissingIndex + { + Table = r.String(1), + EqualityColumns = r.NullableString(2), + InequalityColumns = r.NullableString(3), + IncludedColumns = r.NullableString(4), + Seeks = r.Long(5), + Scans = r.Long(6), + AverageTotalUserCost = r.Double(7), + AverageUserImpact = r.Double(8), + LastUserSeek = r.NullableDateTime(9), + }), ct).ConfigureAwait(false); + + var databases = new List(names.Count); + foreach (var name in names) + { + databases.Add(await ReadAsync(connection, name, context.Instance.Version.Major >= 10, ct).ConfigureAwait(false) with + { + MissingIndexes = missing.Where(m => string.Equals(m.Database, name, StringComparison.OrdinalIgnoreCase)).Select(m => m.Index).ToList(), + }); + } + + return new IndexSnapshot { CapturedAt = context.Now, SqlServerStartTime = startTime, Databases = databases }; + } + + private static async Task ReadAsync(DbConnection connection, string database, bool filtered, CancellationToken ct) + { + try + { + var indexes = await Db.QueryAsync(connection, BuildIndexSql(database, filtered), r => new IndexInfo + { + Schema = r.String(0), + Table = r.String(1), + IndexId = r.Int(2), + Name = r.NullableString(3), + Type = r.String(4), + IsUnique = r.Bool(5), + IsPrimaryKey = r.Bool(6), + IsUniqueConstraint = r.Bool(7), + IsDisabled = r.Bool(8), + Filter = r.NullableString(9), + KeyColumns = Split(r.NullableString(10)), + IncludedColumns = Split(r.NullableString(11)), + Rows = r.NullableLong(12) ?? 0, + UsedPages = r.NullableLong(13) ?? 0, + UserSeeks = r.NullableLong(14) ?? 0, + UserScans = r.NullableLong(15) ?? 0, + UserLookups = r.NullableLong(16) ?? 0, + UserUpdates = r.NullableLong(17) ?? 0, + ForwardedFetches = r.NullableLong(18) ?? 0, + }, ct).ConfigureAwait(false); + + var statistics = await Db.QueryAsync(connection, BuildStatisticsSql(database), r => new StatisticInfo + { + Schema = r.String(0), + Table = r.String(1), + Name = r.String(2), + LastUpdated = r.NullableDateTime(3), + Rows = r.NullableLong(4) ?? 0, + Modifications = r.NullableLong(5) ?? 0, + }, ct).ConfigureAwait(false); + + return new IndexDatabase { Name = database, Indexes = indexes, Statistics = statistics }; + } + catch (DbException ex) + { + return new IndexDatabase { Name = database, Error = ex.Message }; + } + } + + private static string[] Split(string? columns) => + string.IsNullOrEmpty(columns) ? [] : columns.Split('\uE000', StringSplitOptions.RemoveEmptyEntries); + + // Filtered indexes (filter_definition) exist from 2008. + // Column lists are joined with U+E000 (private use: valid in XML, not found in real names) using FOR XML PATH, + // which works from 2005. Control characters such as NCHAR(1) cannot be serialized by FOR XML. + internal static string BuildIndexSql(string database, bool filteredIndexes = true) + { + var db = Db.Identifier(database); + var id = $"DB_ID({Db.Literal(database)})"; + string Columns(string included) => $""" + STUFF((SELECT NCHAR(57344) + c.name + CASE WHEN ic.is_descending_key = 1 THEN N' DESC' ELSE N'' END + FROM {db}.sys.index_columns ic JOIN {db}.sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id + WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = {included} + {(included == "0" ? "AND ic.key_ordinal > 0" : string.Empty)} + ORDER BY {(included == "0" ? "ic.key_ordinal" : "c.name")} + FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 1, N'') + """; + return $""" + SELECT s.name, o.name, i.index_id, i.name, i.type_desc, i.is_unique, i.is_primary_key, i.is_unique_constraint, i.is_disabled, + {(filteredIndexes ? "i.filter_definition" : "CAST(NULL AS nvarchar(max))")}, + {Columns("0")}, + {Columns("1")}, + ps.row_count, ps.used_pages, + us.user_seeks, us.user_scans, us.user_lookups, us.user_updates, + fw.forwarded + FROM {db}.sys.indexes i + JOIN {db}.sys.objects o ON o.object_id = i.object_id AND o.type = 'U' AND o.is_ms_shipped = 0 + JOIN {db}.sys.schemas s ON s.schema_id = o.schema_id + OUTER APPLY (SELECT SUM(p.row_count) AS row_count, SUM(p.used_page_count) AS used_pages + FROM {db}.sys.dm_db_partition_stats p WHERE p.object_id = i.object_id AND p.index_id = i.index_id) ps + LEFT JOIN sys.dm_db_index_usage_stats us ON us.database_id = {id} AND us.object_id = i.object_id AND us.index_id = i.index_id + OUTER APPLY (SELECT SUM(os.forwarded_fetch_count) AS forwarded + FROM sys.dm_db_index_operational_stats({id}, i.object_id, 0, NULL) os WHERE i.index_id = 0) fw; + """; + } + + // sys.sysindexes.rowmodctr needs only metadata visibility, unlike sys.dm_db_stats_properties. + internal static string BuildStatisticsSql(string database) + { + var db = Db.Identifier(database); + return $""" + SELECT s.name, o.name, si.name, STATS_DATE(si.id, si.indid), si.rowcnt, si.rowmodctr + FROM {db}.sys.sysindexes si + JOIN {db}.sys.objects o ON o.object_id = si.id AND o.type = 'U' AND o.is_ms_shipped = 0 + JOIN {db}.sys.schemas s ON s.schema_id = o.schema_id + WHERE si.indid > 0 AND si.indid < 255; + """; + } +} diff --git a/src/SQLInsight.Core/Data/DatabaseSnapshot.cs b/src/SQLInsight.Core/Data/DatabaseSnapshot.cs index 1d2a7e0..4326af5 100644 --- a/src/SQLInsight.Core/Data/DatabaseSnapshot.cs +++ b/src/SQLInsight.Core/Data/DatabaseSnapshot.cs @@ -29,6 +29,8 @@ public sealed record DatabaseInfo public string? PageVerify { get; init; } public bool IsAutoClose { get; init; } + public bool IsAutoCreateStatistics { get; init; } = true; + public bool IsAutoUpdateStatistics { get; init; } = true; public bool IsAutoShrink { get; init; } public bool IsReadOnly { get; init; } public bool IsTrustworthy { get; init; } diff --git a/src/SQLInsight.Core/Data/Indexes.cs b/src/SQLInsight.Core/Data/Indexes.cs new file mode 100644 index 0000000..4af39fd --- /dev/null +++ b/src/SQLInsight.Core/Data/Indexes.cs @@ -0,0 +1,90 @@ +namespace SQLInsight.Core.Data; + +/// +/// Indexes, their usage, missing-index suggestions and statistics per user database. +/// Usage counters reset when SQL Server restarts, so says how much history they hold. +/// +public sealed record IndexSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + public required DateTime SqlServerStartTime { get; init; } + public required IReadOnlyList Databases { get; init; } + + public TimeSpan Uptime => CapturedAt.UtcDateTime - SqlServerStartTime; +} + +public sealed record IndexDatabase +{ + public required string Name { get; init; } + public IReadOnlyList Indexes { get; init; } = []; + public IReadOnlyList MissingIndexes { get; init; } = []; + public IReadOnlyList Statistics { get; init; } = []; + public string? Error { get; init; } +} + +public sealed record IndexInfo +{ + public required string Schema { get; init; } + public required string Table { get; init; } + public required int IndexId { get; init; } + + /// for a heap. + public string? Name { get; init; } + + /// HEAP, CLUSTERED, NONCLUSTERED, COLUMNSTORE … + public required string Type { get; init; } + + public bool IsUnique { get; init; } + public bool IsPrimaryKey { get; init; } + public bool IsUniqueConstraint { get; init; } + public bool IsDisabled { get; init; } + public string? Filter { get; init; } + public IReadOnlyList KeyColumns { get; init; } = []; + public IReadOnlyList IncludedColumns { get; init; } = []; + public long Rows { get; init; } + public long UsedPages { get; init; } + public long UserSeeks { get; init; } + public long UserScans { get; init; } + public long UserLookups { get; init; } + public long UserUpdates { get; init; } + + /// Heaps only: rows read through a forwarding pointer. + public long ForwardedFetches { get; init; } + + public long Reads => UserSeeks + UserScans + UserLookups; + public double SizeMb => UsedPages * 8.0 / 1024; + public string QualifiedTable => $"{Schema}.{Table}"; +} + +public sealed record MissingIndex +{ + /// [db].[schema].[table] as SQL Server reports it. + public required string Table { get; init; } + + public string? EqualityColumns { get; init; } + public string? InequalityColumns { get; init; } + public string? IncludedColumns { get; init; } + public required long Seeks { get; init; } + public required long Scans { get; init; } + public required double AverageTotalUserCost { get; init; } + + /// Estimated improvement in percent. + public required double AverageUserImpact { get; init; } + + public DateTime? LastUserSeek { get; init; } + + /// The usual ranking measure: cost × impact × uses. + public double ImprovementMeasure => AverageTotalUserCost * (AverageUserImpact / 100) * (Seeks + Scans); +} + +public sealed record StatisticInfo +{ + public required string Schema { get; init; } + public required string Table { get; init; } + public required string Name { get; init; } + public DateTime? LastUpdated { get; init; } + public long Rows { get; init; } + + /// Row modifications since the last update (sysindexes.rowmodctr; approximate). + public long Modifications { get; init; } +} diff --git a/src/SQLInsight.Rules/BuiltInRules.cs b/src/SQLInsight.Rules/BuiltInRules.cs index b7de065..158bffb 100644 --- a/src/SQLInsight.Rules/BuiltInRules.cs +++ b/src/SQLInsight.Rules/BuiltInRules.cs @@ -2,6 +2,7 @@ using SQLInsight.Rules.Concurrency; using SQLInsight.Rules.Configuration; using SQLInsight.Rules.Databases; using SQLInsight.Rules.Engine; +using SQLInsight.Rules.Indexes; using SQLInsight.Rules.Lifecycle; using SQLInsight.Rules.Performance; @@ -54,6 +55,14 @@ public static class BuiltInRules new SpillRule(), new ExcessiveGrantRule(), + // Indexes and statistics + new MissingIndexRule(), + new UnusedIndexRule(), + new DuplicateIndexRule(), + new ForwardedRecordsRule(), + new StaleStatisticsRule(), + new AutoStatisticsRule(), + // Concurrency new BlockingRule(), new DeadlockRule(), diff --git a/src/SQLInsight.Rules/Indexes/IndexRules.cs b/src/SQLInsight.Rules/Indexes/IndexRules.cs new file mode 100644 index 0000000..b5d1111 --- /dev/null +++ b/src/SQLInsight.Rules/Indexes/IndexRules.cs @@ -0,0 +1,383 @@ +using System.Globalization; +using SQLInsight.Core.Collection; +using SQLInsight.Core.Data; +using SQLInsight.Core.Findings; +using SQLInsight.Core.Instances; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.Rules.Indexes; + +internal static class IndexText +{ + public const int MaxFindingsPerRule = 10; + public static readonly TimeSpan MinUptimeForUsage = TimeSpan.FromDays(7); + + public static string Quote(string name) => "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]"; + + public static string Table(IndexInfo i) => $"{Quote(i.Schema)}.{Quote(i.Table)}"; + + public static string Use(string database) => $"USE {Quote(database)};"; + + public static string Columns(IEnumerable columns) => string.Join(", ", columns.Select(c => + c.EndsWith(" DESC", StringComparison.Ordinal) ? Quote(c[..^5]) + " DESC" : Quote(c))); + + /// "[A], [B]" from the missing index DMVs to plain names. + public static List ParseColumnList(string? list) => string.IsNullOrWhiteSpace(list) + ? [] + : list.Split(',').Select(c => c.Trim().TrimStart('[').TrimEnd(']').Replace("]]", "]", StringComparison.Ordinal)).ToList(); + + /// "[db].[schema].[table]" to (schema, table). + public static (string Schema, string Table) ParseTable(string statement) + { + var parts = statement.Split("].[", StringSplitOptions.None).Select(p => p.Trim('[', ']')).ToArray(); + return parts.Length >= 3 ? (parts[^2], parts[^1]) : ("dbo", parts[^1]); + } + + public static Reference Docs(string title, string path) => Engine.Docs.Learn(title, path); + + public static IndexSnapshot Snapshot(RuleContext context) => context.Data.Get(); + + public static IndexSnapshot RequireUptime(RuleContext context) + { + var snapshot = Snapshot(context); + if (snapshot.Uptime < MinUptimeForUsage) + { + throw new DataUnavailableException( + $"Index-brug nulstilles ved genstart; SQL Server har kun kørt i {Format.Age(snapshot.Uptime)}. Vurderes efter 7 dages oppetid."); + } + + return snapshot; + } +} + +/// +/// Missing indexes from the optimizer, evaluated critically against what already exists. +/// +public sealed class MissingIndexRule : Rule +{ + public const double MinImprovementMeasure = 50_000; + public const long MinUses = 100; + + public override string Id => "SQLI-IDX-001"; + public override string Title => "Manglende index med stor effekt"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + var snapshot = IndexText.Snapshot(context); + var candidates = new List<(double Measure, Finding Finding)>(); + foreach (var db in snapshot.Databases) + { + foreach (var m in db.MissingIndexes.Where(m => m.ImprovementMeasure >= MinImprovementMeasure && m.Seeks + m.Scans >= MinUses)) + { + candidates.Add((m.ImprovementMeasure, Evaluate(snapshot, db, m))); + } + } + + return Result(candidates.OrderByDescending(c => c.Measure).Take(IndexText.MaxFindingsPerRule).Select(c => c.Finding)); + } + + private Finding Evaluate(IndexSnapshot snapshot, IndexDatabase db, MissingIndex m) + { + var (schema, table) = IndexText.ParseTable(m.Table); + var equality = IndexText.ParseColumnList(m.EqualityColumns); + var inequality = IndexText.ParseColumnList(m.InequalityColumns); + var include = IndexText.ParseColumnList(m.IncludedColumns); + var keys = equality.Concat(inequality).ToList(); + var existing = db.Indexes.Where(i => i.Schema == schema && i.Table == table).ToList(); + var notes = new List(); + + // An existing index whose leading keys are the equality columns: extend it instead of adding another. + var extend = existing.Where(i => i.Type == "NONCLUSTERED" && !i.IsDisabled && i.Filter is null && equality.Count > 0 + && i.KeyColumns.Count >= equality.Count + && i.KeyColumns.Take(equality.Count).Select(k => k.Replace(" DESC", string.Empty, StringComparison.Ordinal)).ToHashSet(StringComparer.OrdinalIgnoreCase).SetEquals(equality)) + .OrderBy(i => i.IncludedColumns.Count) + .FirstOrDefault(); + + var baseTable = existing.FirstOrDefault(i => i.IndexId <= 1); + if (baseTable is not null && baseTable.UserUpdates > 10 * Math.Max(1, existing.Sum(i => i.Reads))) + { + notes.Add("Tabellen skrives langt oftere, end den læses; hvert ekstra index gør alle skrivninger dyrere."); + } + + if (existing.Count(i => i.Type == "NONCLUSTERED") >= 10) + { + notes.Add($"Tabellen har allerede {existing.Count(i => i.Type == "NONCLUSTERED")} nonclustered indexes. Overvej at samle forslag frem for at tilføje flere."); + } + + if (include.Count > 5) + { + notes.Add($"Forslaget inkluderer {include.Count} kolonner. Et smallere index dækker ofte det meste af gevinsten til en brøkdel af pladsen."); + } + + if (snapshot.Uptime < IndexText.MinUptimeForUsage) + { + notes.Add($"Tallene dækker kun {Format.Age(snapshot.Uptime)} siden seneste genstart."); + } + + var fullTable = $"{IndexText.Quote(schema)}.{IndexText.Quote(table)}"; + var includeClause = include.Count > 0 ? $" INCLUDE ({IndexText.Columns(include)})" : string.Empty; + var name = $"IX_{table}_{string.Join('_', keys.Take(3))}"; + string fix; + string recommendation; + if (extend is not null) + { + var mergedIncludes = extend.IncludedColumns.Union(include, StringComparer.OrdinalIgnoreCase).Except(extend.KeyColumns, StringComparer.OrdinalIgnoreCase).ToList(); + fix = $"{IndexText.Use(db.Name)}\nCREATE INDEX {IndexText.Quote(extend.Name!)} ON {fullTable} ({IndexText.Columns(extend.KeyColumns)})" + + (mergedIncludes.Count > 0 ? $" INCLUDE ({IndexText.Columns(mergedIncludes)})" : string.Empty) + + " WITH (DROP_EXISTING = ON);"; + recommendation = $"Udvid det eksisterende index {extend.Name}, som allerede har de rigtige nøglekolonner, i stedet for at oprette et nyt."; + } + else + { + fix = $"{IndexText.Use(db.Name)}\nCREATE INDEX {IndexText.Quote(name)} ON {fullTable} ({IndexText.Columns(keys)}){includeClause};"; + recommendation = "Opret indexet efter at have vurderet det: lighedskolonnerne står først; sæt den mest selektive forrest. " + + "Tjek i den aktuelle forespørgselsplan, at indexet bliver brugt, og hold øje med skrivelatens bagefter."; + } + + var evidence = new List + { + new("Tabel", $"{db.Name}.{schema}.{table}"), + new("Lighed", m.EqualityColumns ?? "–"), + new("Ulighed", m.InequalityColumns ?? "–"), + new("Inkluder", m.IncludedColumns ?? "–"), + new("Brug", $"{Format.Number(m.Seeks)} seeks, {Format.Number(m.Scans)} scans siden genstart"), + new("Estimeret forbedring", $"{Format.Number(m.AverageUserImpact)} % af forespørgslernes omkostning (mål: {Format.Number(m.ImprovementMeasure)})"), + }; + evidence.AddRange(notes.Select(n => new Evidence("Bemærk", n))); + + return CreateFinding( + m.ImprovementMeasure >= 1_000_000 ? Severity.Medium : Severity.Low, + $"{db.Name}.{schema}.{table}: manglende index på ({string.Join(", ", keys)})", + "Optimizeren har gentagne gange kompileret planer, der ville være billigere med dette index. Forslagene er " + + "et godt spor, men ikke et færdigt design: de ignorerer eksisterende indexes, kolonnerækkefølge og skriveomkostninger. " + + "Derfor er forslaget her sammenholdt med tabellens nuværende indexes.", + recommendation, + evidence, + subject: $"missing-index: {db.Name}.{schema}.{table}({string.Join(",", keys)})", + fixScript: fix, + references: IndexText.Docs("Tune nonclustered indexes with missing index suggestions", "relational-databases/indexes/tune-nonclustered-missing-index-suggestions")); + } +} + +/// +/// Nonclustered indexes that are maintained on every write but never read. +/// +public sealed class UnusedIndexRule : Rule +{ + public override string Id => "SQLI-IDX-002"; + public override string Title => "Ubrugt index koster ved hver skrivning"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + var snapshot = IndexText.RequireUptime(context); + return Result(snapshot.Databases + .SelectMany(db => db.Indexes.Select(i => (Db: db.Name, Index: i))) + .Where(x => x.Index.Type == "NONCLUSTERED" && !x.Index.IsPrimaryKey && !x.Index.IsUniqueConstraint && !x.Index.IsUnique + && !x.Index.IsDisabled && x.Index.Reads == 0 && (x.Index.UserUpdates >= 1000 || x.Index.SizeMb >= 100)) + .OrderByDescending(x => x.Index.UserUpdates + x.Index.UsedPages) + .Take(IndexText.MaxFindingsPerRule) + .Select(x => CreateFinding( + x.Index.SizeMb >= 1024 ? Severity.Medium : Severity.Low, + $"{x.Db}.{x.Index.QualifiedTable}: index {x.Index.Name} er ikke læst i {Format.Age(snapshot.Uptime)}", + "Indexet opdateres ved hver INSERT, UPDATE og DELETE på tabellen, men ingen forespørgsel har læst det siden seneste " + + "genstart. Det koster skrivninger, transaktionslog, backup-størrelse og vedligeholdelsestid.", + "Deaktivér indexet (ALTER INDEX … DISABLE) i stedet for at slette det, så definitionen bevares, og vent en hel " + + "forretningscyklus (måneds- og kvartalsafslutning, rapporter). Er det stadig ubrugt, kan det slettes. Genopret med ALTER INDEX … REBUILD.", + [ + new Evidence("Index", $"{x.Index.Name} ({IndexText.Columns(x.Index.KeyColumns)})"), + new Evidence("Skrivninger / læsninger", $"{Format.Number(x.Index.UserUpdates)} / 0"), + new Evidence("Størrelse", $"{Format.Number(x.Index.SizeMb)} MB"), + new Evidence("Periode", $"siden genstart for {Format.Age(snapshot.Uptime)} siden"), + ], + subject: $"index: {x.Db}.{x.Index.QualifiedTable}.{x.Index.Name}", + fixScript: $"{IndexText.Use(x.Db)}\nALTER INDEX {IndexText.Quote(x.Index.Name!)} ON {IndexText.Table(x.Index)} DISABLE;", + references: IndexText.Docs("sys.dm_db_index_usage_stats", "relational-databases/system-dynamic-management-views/sys-dm-db-index-usage-stats-transact-sql")))); + } +} + +/// +/// Indexes made redundant by another index on the same table. +/// +public sealed class DuplicateIndexRule : Rule +{ + public override string Id => "SQLI-IDX-003"; + public override string Title => "Duplikeret eller overlappende index"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + var findings = new List(); + foreach (var db in IndexText.Snapshot(context).Databases) + { + foreach (var table in db.Indexes.Where(i => i.Type is "CLUSTERED" or "NONCLUSTERED" && !i.IsDisabled).GroupBy(i => (i.Schema, i.Table))) + { + var indexes = table.ToList(); + var reported = new HashSet(); + foreach (var redundant in indexes.Where(i => i.Type == "NONCLUSTERED" && !i.IsPrimaryKey && !i.IsUniqueConstraint)) + { + var cover = indexes.FirstOrDefault(other => other.IndexId != redundant.IndexId && !reported.Contains(other.IndexId) && Covers(other, redundant)); + if (cover is null) + { + continue; + } + + reported.Add(redundant.IndexId); + var exact = cover.KeyColumns.SequenceEqual(redundant.KeyColumns, StringComparer.OrdinalIgnoreCase); + findings.Add(CreateFinding( + exact ? Severity.Medium : Severity.Low, + $"{db.Name}.{redundant.QualifiedTable}: {redundant.Name} er {(exact ? "en dublet af" : "dækket af")} {cover.Name}", + exact + ? "To indexes har samme nøglekolonner i samme rækkefølge. Begge vedligeholdes ved hver skrivning, men kun det ene er nødvendigt." + : $"Nøglekolonnerne i {redundant.Name} er de første kolonner i {cover.Name}, og dets øvrige kolonner findes også dér. " + + $"Alle forespørgsler, der kan bruge {redundant.Name}, kan også bruge {cover.Name}.", + $"Deaktivér {redundant.Name}, og følg med i en forretningscyklus, før det slettes. Tjek, at det ikke er nævnt i index-hints.", + [ + new Evidence(redundant.Name!, $"({IndexText.Columns(redundant.KeyColumns)})" + (redundant.IncludedColumns.Count > 0 ? $" INCLUDE ({IndexText.Columns(redundant.IncludedColumns)})" : string.Empty) + $", {Format.Number(redundant.SizeMb)} MB, {Format.Number(redundant.Reads)} læsninger"), + new Evidence(cover.Name ?? "(heap)", $"({IndexText.Columns(cover.KeyColumns)})" + (cover.IncludedColumns.Count > 0 ? $" INCLUDE ({IndexText.Columns(cover.IncludedColumns)})" : string.Empty) + $", {Format.Number(cover.Reads)} læsninger"), + ], + subject: $"index: {db.Name}.{redundant.QualifiedTable}.{redundant.Name}", + fixScript: $"{IndexText.Use(db.Name)}\nALTER INDEX {IndexText.Quote(redundant.Name!)} ON {IndexText.Table(redundant)} DISABLE;", + references: IndexText.Docs("Index design guide", "relational-databases/sql-server-index-design-guide"))); + } + } + } + + return Result(findings.Take(IndexText.MaxFindingsPerRule)); + } + + /// + /// makes redundant: same filter, candidate's keys are a leading + /// prefix of cover's keys, and every included column of candidate is somewhere in cover. A clustered index covers + /// all columns. + /// + internal static bool Covers(IndexInfo cover, IndexInfo candidate) + { + if (!string.Equals(cover.Filter, candidate.Filter, StringComparison.OrdinalIgnoreCase) || candidate.KeyColumns.Count == 0 + || cover.KeyColumns.Count < candidate.KeyColumns.Count) + { + return false; + } + + if (!cover.KeyColumns.Take(candidate.KeyColumns.Count).SequenceEqual(candidate.KeyColumns, StringComparer.OrdinalIgnoreCase)) + { + return false; + } + + // A unique candidate enforces uniqueness on exactly its keys; a wider index cannot replace that. + if (candidate.IsUnique && cover.KeyColumns.Count != candidate.KeyColumns.Count) + { + return false; + } + + if (cover.Type == "CLUSTERED") + { + return cover.KeyColumns.Count == candidate.KeyColumns.Count; + } + + var available = cover.KeyColumns.Concat(cover.IncludedColumns).ToHashSet(StringComparer.OrdinalIgnoreCase); + return candidate.IncludedColumns.All(available.Contains); + } +} + +/// +/// Heaps where updates have moved rows and left forwarding pointers, so reads jump around. +/// +public sealed class ForwardedRecordsRule : Rule +{ + public override string Id => "SQLI-IDX-004"; + public override string Title => "Heap med mange forwarded records"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + return Result(IndexText.Snapshot(context).Databases + .SelectMany(db => db.Indexes.Where(i => i.Type == "HEAP" && i.Rows >= 10_000 && i.ForwardedFetches >= 10_000).Select(i => (Db: db.Name, Heap: i))) + .OrderByDescending(x => x.Heap.ForwardedFetches) + .Take(IndexText.MaxFindingsPerRule) + .Select(x => CreateFinding( + Severity.Medium, + $"{x.Db}.{x.Heap.QualifiedTable}: {Format.Number(x.Heap.ForwardedFetches)} forwarded fetches", + "Tabellen er en heap (uden clustered index). Når en række vokser og ikke kan være på sin side, flyttes den og efterlader " + + "en henvisning. Hver læsning af rækken skal derefter følge henvisningen, hvilket giver ekstra, tilfældig I/O.", + "Giv tabellen et clustered index (typisk den bedste løsning), eller genopbyg heapen med ALTER TABLE … REBUILD, " + + "som fjerner henvisningerne indtil videre.", + [new Evidence("Rækker", Format.Number(x.Heap.Rows)), new Evidence("Forwarded fetches siden genstart", Format.Number(x.Heap.ForwardedFetches))], + subject: $"heap: {x.Db}.{x.Heap.QualifiedTable}", + fixScript: $"{IndexText.Use(x.Db)}\nALTER TABLE {IndexText.Table(x.Heap)} REBUILD;", + references: IndexText.Docs("Heaps", "relational-databases/indexes/heaps-tables-without-clustered-indexes")))); + } +} + +/// +/// Statistics on large tables with many changes since they were last updated. +/// +public sealed class StaleStatisticsRule : Rule +{ + public const long MinRows = 10_000; + public const double MinModifiedShare = 0.2; + + public override string Id => "SQLI-STAT-001"; + public override string Title => "Forældede statistikker på store tabeller"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews]; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + return Result(IndexText.Snapshot(context).Databases + .SelectMany(db => db.Statistics.Where(s => s.Rows >= MinRows && s.Modifications >= s.Rows * MinModifiedShare).Select(s => (Db: db.Name, Stat: s))) + .OrderByDescending(x => x.Stat.Modifications) + .Take(IndexText.MaxFindingsPerRule) + .Select(x => CreateFinding( + Severity.Low, + $"{x.Db}.{x.Stat.Schema}.{x.Stat.Table}: statistikken {x.Stat.Name} er {Format.Number(100.0 * x.Stat.Modifications / x.Stat.Rows)} % ændret", + "Optimizeren vælger planer ud fra statistikkernes billede af data. Når en stor del af rækkerne er ændret siden seneste " + + "opdatering, rammer estimaterne ved siden af: for små memory grants, forkerte join-typer og spild til tempdb.", + "Opdatér statistikkerne som en del af det faste vedligehold (fx Ola Hallengrens IndexOptimize med @UpdateStatistics = 'ALL' og " + + "@OnlyModifiedStatistics = 'Y'). For store tabeller med stigende nøgler hjælper hyppigere opdatering eller " + + "compatibility level 130+ med den dynamiske tærskel.", + [ + new Evidence("Rækker", Format.Number(x.Stat.Rows)), + new Evidence("Ændringer siden opdatering", Format.Number(x.Stat.Modifications)), + new Evidence("Senest opdateret", Format.Date(x.Stat.LastUpdated)), + ], + subject: $"statistic: {x.Db}.{x.Stat.Schema}.{x.Stat.Table}.{x.Stat.Name}", + fixScript: $"{IndexText.Use(x.Db)}\nUPDATE STATISTICS {IndexText.Quote(x.Stat.Schema)}.{IndexText.Quote(x.Stat.Table)} {IndexText.Quote(x.Stat.Name)};", + references: IndexText.Docs("Statistics", "relational-databases/statistics/statistics")))); + } +} + +/// +/// Automatic creation or updating of statistics switched off. +/// +public sealed class AutoStatisticsRule : Rule +{ + public override string Id => "SQLI-STAT-002"; + public override string Title => "Automatisk statistik er slået fra"; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + return Result(context.Data.Get().Databases + .Where(d => d.State == "ONLINE" && !d.IsReadOnly && (!d.IsAutoCreateStatistics || !d.IsAutoUpdateStatistics)) + .Select(d => + { + var off = new[] { d.IsAutoCreateStatistics ? null : "AUTO_CREATE_STATISTICS", d.IsAutoUpdateStatistics ? null : "AUTO_UPDATE_STATISTICS" }.OfType().ToList(); + return CreateFinding( + Severity.Medium, + $"{d.Name}: {string.Join(" og ", off)} er slået fra", + "Uden automatisk statistik har optimizeren ofte ingen eller forældede oplysninger om data, og planerne bliver derefter. " + + "Det er kun rigtigt, hvis et gennemtestet vedligehold selv holder alle statistikker opdaterede, hvilket sjældent er tilfældet.", + "Slå automatisk statistik til. Oplever I kompileringspauser ved opdatering, så brug AUTO_UPDATE_STATISTICS_ASYNC i stedet for at slå det fra.", + off.Select(o => new Evidence(o, "OFF")), + subject: $"database: {d.Name}", + fixScript: string.Join('\n', off.Select(o => $"ALTER DATABASE {IndexText.Quote(d.Name)} SET {o} ON;")), + references: IndexText.Docs("Statistics", "relational-databases/statistics/statistics")); + })); + } +} diff --git a/tests/SQLInsight.IntegrationTests/CollectorTests.cs b/tests/SQLInsight.IntegrationTests/CollectorTests.cs index f3a96ae..12198df 100644 --- a/tests/SQLInsight.IntegrationTests/CollectorTests.cs +++ b/tests/SQLInsight.IntegrationTests/CollectorTests.cs @@ -267,6 +267,52 @@ public class CollectorTests(SqlServerFixture sql) Assert.NotNull(Probe("plan-probe-udf").TotalGrantKb); // 2017 CU3+ and 2022 } + [Fact] + public async Task Indexes_include_usage_missing_suggestions_duplicates_heaps_and_statistics() + { + await sql.ExecuteAsSaAsync($""" + USE [{SqlServerFixture.TestDatabase}]; + IF OBJECT_ID(N'dbo.IdxProbe') IS NULL + BEGIN + CREATE TABLE dbo.IdxProbe (Id int IDENTITY CONSTRAINT PK_IdxProbe PRIMARY KEY, CustomerId int NOT NULL, Status int NOT NULL, Note varchar(10) NOT NULL); + INSERT dbo.IdxProbe (CustomerId, Status, Note) SELECT TOP (20000) ABS(CHECKSUM(NEWID())) % 5000, 1, 'x' FROM sys.all_objects a CROSS JOIN sys.all_objects b; + CREATE INDEX IX_IdxProbe_Status ON dbo.IdxProbe (Status); + CREATE INDEX IX_IdxProbe_Status_Copy ON dbo.IdxProbe (Status); + CREATE TABLE dbo.HeapProbe (Id int NOT NULL, Payload varchar(4000) NULL); + INSERT dbo.HeapProbe (Id, Payload) SELECT TOP (2000) ROW_NUMBER() OVER (ORDER BY (SELECT 1)), 'a' FROM sys.all_objects; + UPDATE dbo.HeapProbe SET Payload = REPLICATE('b', 3000); + END; + """); + await using (var connection = new Microsoft.Data.SqlClient.SqlConnection(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase))) + { + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = """ + DECLARE @i int = 0, @n int; + WHILE @i < 20 BEGIN SELECT @n = COUNT(*) FROM dbo.IdxProbe WHERE CustomerId = @i; SET @i += 1; END; + SELECT @n = COUNT(*) FROM dbo.HeapProbe WHERE Payload LIKE 'b%'; + """; + await command.ExecuteNonQueryAsync(); + } + + await using var session = await AgentSession.OpenAsync(sql); + var snapshot = await session.CollectAsync(new IndexCollector()); + + var db = Assert.Single(snapshot.Databases, d => d.Name == SqlServerFixture.TestDatabase); + Assert.Null(db.Error); + var pk = Assert.Single(db.Indexes, i => i.Name == "PK_IdxProbe"); + Assert.True(pk.IsPrimaryKey); + Assert.Equal(["Id"], pk.KeyColumns); + Assert.Equal(20000, pk.Rows); + Assert.True(pk.UserScans > 0); + Assert.Equal(2, db.Indexes.Count(i => i.Table == "IdxProbe" && i.KeyColumns.SequenceEqual(["Status"]))); + Assert.Contains(db.MissingIndexes, m => m.Table.Contains("IdxProbe", StringComparison.Ordinal) && m.EqualityColumns == "[CustomerId]"); + var heap = Assert.Single(db.Indexes, i => i.Table == "HeapProbe"); + Assert.Equal("HEAP", heap.Type); + Assert.True(heap.ForwardedFetches > 0); + Assert.Contains(db.Statistics, s => s.Table == "IdxProbe" && s.Name == "PK_IdxProbe"); + } + private static async Task WaitForDeadlockAsync(AgentSession session, DeadlockCollector collector, DeadlockSnapshot previous) { // system_health dispatches events to its targets with up to 120 seconds latency. diff --git a/tests/SQLInsight.Rules.Tests/IndexRuleTests.cs b/tests/SQLInsight.Rules.Tests/IndexRuleTests.cs new file mode 100644 index 0000000..5c66a9d --- /dev/null +++ b/tests/SQLInsight.Rules.Tests/IndexRuleTests.cs @@ -0,0 +1,196 @@ +using SQLInsight.Core.Data; +using SQLInsight.Core.Findings; +using SQLInsight.Rules.Engine; +using SQLInsight.Rules.Indexes; +using static SQLInsight.Rules.Tests.TestInstances; + +namespace SQLInsight.Rules.Tests; + +public class IndexRuleTests +{ + private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context)); + + private static IndexInfo Index(int id, string? name, string type, string[] keys, string[]? include = null, bool unique = false, bool pk = false, + long reads = 100, long updates = 100, long pages = 1000, long rows = 100_000, long forwarded = 0, string table = "Orders") => new() + { + Schema = "dbo", Table = table, IndexId = id, Name = name, Type = type, KeyColumns = keys, IncludedColumns = include ?? [], + IsUnique = unique || pk, IsPrimaryKey = pk, UserSeeks = reads, UserUpdates = updates, UsedPages = pages, Rows = rows, ForwardedFetches = forwarded, + }; + + private static RuleContext Indexes(TimeSpan uptime, IndexDatabase db) => With(new IndexSnapshot + { + CapturedAt = Now, + SqlServerStartTime = Now.UtcDateTime - uptime, + Databases = [db], + }); + + private static readonly TimeSpan Month = TimeSpan.FromDays(30); + + [Fact] + public void Missing_index_suggests_create_with_equality_columns_first() + { + var db = new IndexDatabase + { + Name = "Sales", + Indexes = [Index(1, "PK_Orders", "CLUSTERED", ["Id"], pk: true)], + MissingIndexes = [new MissingIndex { Table = "[Sales].[dbo].[Orders]", EqualityColumns = "[CustomerId]", InequalityColumns = "[OrderDate]", IncludedColumns = "[Total]", Seeks = 50_000, Scans = 0, AverageTotalUserCost = 12, AverageUserImpact = 95 }], + }; + + var finding = Assert.Single(Run(new MissingIndexRule(), Indexes(Month, db)).Findings); + + Assert.Equal(Severity.Low, finding.Severity); + Assert.Contains("CREATE INDEX [IX_Orders_CustomerId_OrderDate] ON [dbo].[Orders] ([CustomerId], [OrderDate]) INCLUDE ([Total]);", finding.FixScript, StringComparison.Ordinal); + } + + [Fact] + public void Missing_index_extends_an_existing_index_with_the_same_keys() + { + var db = new IndexDatabase + { + Name = "Sales", + Indexes = [Index(1, "PK_Orders", "CLUSTERED", ["Id"], pk: true), Index(2, "IX_Orders_CustomerId", "NONCLUSTERED", ["CustomerId"], ["Status"])], + MissingIndexes = [new MissingIndex { Table = "[Sales].[dbo].[Orders]", EqualityColumns = "[CustomerId]", IncludedColumns = "[Total]", Seeks = 100_000, Scans = 0, AverageTotalUserCost = 20, AverageUserImpact = 90 }], + }; + + var finding = Assert.Single(Run(new MissingIndexRule(), Indexes(Month, db)).Findings); + + Assert.Contains("Udvid det eksisterende index IX_Orders_CustomerId", finding.Recommendation, StringComparison.Ordinal); + Assert.Contains("INCLUDE ([Status], [Total]) WITH (DROP_EXISTING = ON)", finding.FixScript, StringComparison.Ordinal); + } + + [Fact] + public void Missing_index_warns_about_write_heavy_table() + { + var db = new IndexDatabase + { + Name = "Sales", + Indexes = [Index(1, "PK_Orders", "CLUSTERED", ["Id"], pk: true, reads: 10, updates: 1_000_000)], + MissingIndexes = [new MissingIndex { Table = "[Sales].[dbo].[Orders]", EqualityColumns = "[CustomerId]", Seeks = 50_000, Scans = 0, AverageTotalUserCost = 12, AverageUserImpact = 95 }], + }; + + var finding = Assert.Single(Run(new MissingIndexRule(), Indexes(Month, db)).Findings); + + Assert.Contains(finding.Evidence, e => e.Value.Contains("skrives langt oftere", StringComparison.Ordinal)); + } + + [Fact] + public void Small_missing_index_suggestion_is_ignored() + { + var db = new IndexDatabase + { + Name = "Sales", + MissingIndexes = [new MissingIndex { Table = "[Sales].[dbo].[Orders]", EqualityColumns = "[CustomerId]", Seeks = 50, Scans = 0, AverageTotalUserCost = 0.5, AverageUserImpact = 40 }], + }; + + Assert.Equal(RuleOutcome.Passed, Run(new MissingIndexRule(), Indexes(Month, db)).Outcome); + } + + [Fact] + public void Unused_index_needs_seven_days_of_uptime() + { + var db = new IndexDatabase { Name = "Sales", Indexes = [Index(2, "IX_Unused", "NONCLUSTERED", ["Status"], reads: 0, updates: 50_000)] }; + + Assert.Equal(RuleOutcome.NotMeasurable, Run(new UnusedIndexRule(), Indexes(TimeSpan.FromDays(2), db)).Outcome); + var finding = Assert.Single(Run(new UnusedIndexRule(), Indexes(Month, db)).Findings); + Assert.Contains("DISABLE", finding.FixScript, StringComparison.Ordinal); + } + + [Fact] + public void Unique_and_primary_key_indexes_are_never_unused_candidates() + { + var db = new IndexDatabase + { + Name = "Sales", + Indexes = [Index(2, "UX_Code", "NONCLUSTERED", ["Code"], unique: true, reads: 0, updates: 50_000), Index(1, "PK", "CLUSTERED", ["Id"], pk: true, reads: 0, updates: 50_000)], + }; + + Assert.Equal(RuleOutcome.Passed, Run(new UnusedIndexRule(), Indexes(Month, db)).Outcome); + } + + [Theory] + [InlineData(new[] { "A" }, new string[0], new[] { "A" }, new string[0], true)] // exact duplicate + [InlineData(new[] { "A", "B" }, new string[0], new[] { "A" }, new string[0], true)] // left prefix + [InlineData(new[] { "A", "B" }, new[] { "C" }, new[] { "A" }, new[] { "C" }, true)] // include covered by include + [InlineData(new[] { "A", "B" }, new string[0], new[] { "A" }, new[] { "B" }, true)] // include covered by key + [InlineData(new[] { "A", "B" }, new string[0], new[] { "A" }, new[] { "C" }, false)] // include not covered + [InlineData(new[] { "B", "A" }, new string[0], new[] { "A" }, new string[0], false)] // not a leading prefix + public void Covers(string[] coverKeys, string[] coverInclude, string[] candidateKeys, string[] candidateInclude, bool expected) + { + var cover = Index(2, "IX_Cover", "NONCLUSTERED", coverKeys, coverInclude); + var candidate = Index(3, "IX_Candidate", "NONCLUSTERED", candidateKeys, candidateInclude); + + Assert.Equal(expected, DuplicateIndexRule.Covers(cover, candidate)); + } + + [Fact] + public void Unique_index_is_not_covered_by_a_wider_index() + { + Assert.False(DuplicateIndexRule.Covers(Index(2, "IX_Wide", "NONCLUSTERED", ["A", "B"]), Index(3, "UX_A", "NONCLUSTERED", ["A"], unique: true))); + } + + [Fact] + public void Filtered_indexes_only_cover_the_same_filter() + { + var filtered = Index(2, "IX_Active", "NONCLUSTERED", ["A"]) with { Filter = "([Active]=(1))" }; + + Assert.False(DuplicateIndexRule.Covers(filtered, Index(3, "IX_A", "NONCLUSTERED", ["A"]))); + } + + [Fact] + public void Duplicate_rule_reports_each_pair_once() + { + var db = new IndexDatabase + { + Name = "Sales", + Indexes = + [ + Index(1, "PK_Orders", "CLUSTERED", ["Id"], pk: true), + Index(2, "IX_A", "NONCLUSTERED", ["CustomerId"]), + Index(3, "IX_B", "NONCLUSTERED", ["CustomerId"]), + ], + }; + + var finding = Assert.Single(Run(new DuplicateIndexRule(), Indexes(Month, db)).Findings); + + Assert.Equal(Severity.Medium, finding.Severity); + } + + [Fact] + public void Heap_with_many_forwarded_fetches() + { + var db = new IndexDatabase { Name = "Sales", Indexes = [Index(0, null, "HEAP", [], forwarded: 250_000)] }; + + var finding = Assert.Single(Run(new ForwardedRecordsRule(), Indexes(Month, db)).Findings); + + Assert.Contains("ALTER TABLE [dbo].[Orders] REBUILD;", finding.FixScript, StringComparison.Ordinal); + } + + [Fact] + public void Stale_statistics_on_large_tables_only() + { + var db = new IndexDatabase + { + Name = "Sales", + Statistics = + [ + new StatisticInfo { Schema = "dbo", Table = "Orders", Name = "IX_Orders_Date", Rows = 1_000_000, Modifications = 400_000 }, + new StatisticInfo { Schema = "dbo", Table = "Small", Name = "PK_Small", Rows = 500, Modifications = 500 }, + new StatisticInfo { Schema = "dbo", Table = "Stable", Name = "PK_Stable", Rows = 1_000_000, Modifications = 1_000 }, + ], + }; + + var finding = Assert.Single(Run(new StaleStatisticsRule(), Indexes(Month, db)).Findings); + + Assert.Equal("statistic: Sales.dbo.Orders.IX_Orders_Date", finding.Subject); + } + + [Fact] + public void Auto_statistics_off() + { + var off = Database(change: d => d with { IsAutoUpdateStatistics = false }); + + var finding = Assert.Single(Run(new AutoStatisticsRule(), With(DatabaseList(off))).Findings); + + Assert.Equal("ALTER DATABASE [Sales] SET AUTO_UPDATE_STATISTICS ON;", finding.FixScript); + } +}