sqlinsight-pro/tests/SQLInsight.Rules.Tests/IndexRuleTests.cs
Claude 8cde94038c
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
2026-09-29 07:47:53 +00:00

196 lines
8.7 KiB
C#

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