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
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
196 lines
8.7 KiB
C#
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);
|
|
}
|
|
}
|