The index collector now sums index reads and writes between collections
across restarts of SQL Server (and of the agent, by seeding from the
stored snapshot) and keeps them per day in insight.IndexUsageDaily
(migration 0015, HourlyDays). SQLI-IDX-002 judges an index once it has
been followed for 7 days instead of needing 7 days of uptime.
GET /api/v1/instances/{name}/index-usage lists usage per index.
SQLI-STAT-001 also reports statistics that were never updated on tables
with rows; statistic rows now come from the table's heap or clustered
index, as a never-updated statistic has no row count of its own.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
231 lines
10 KiB
C#
231 lines
10 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, LastUpdated = Now.UtcDateTime.AddDays(-9) },
|
|
new StatisticInfo { Schema = "dbo", Table = "Small", Name = "PK_Small", Rows = 500, Modifications = 500, LastUpdated = Now.UtcDateTime.AddDays(-9) },
|
|
new StatisticInfo { Schema = "dbo", Table = "Stable", Name = "PK_Stable", Rows = 1_000_000, Modifications = 1_000, LastUpdated = Now.UtcDateTime.AddDays(-9) },
|
|
new StatisticInfo { Schema = "dbo", Table = "Empty", Name = "_WA_Sys_Empty", Rows = 0, Modifications = 0 },
|
|
],
|
|
};
|
|
|
|
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 Statistics_never_updated_on_a_table_with_rows_are_found_first()
|
|
{
|
|
var db = new IndexDatabase
|
|
{
|
|
Name = "AX",
|
|
Statistics =
|
|
[
|
|
new StatisticInfo { Schema = "dbo", Table = "Orders", Name = "IX_Orders_Date", Rows = 1_000_000, Modifications = 400_000, LastUpdated = Now.UtcDateTime.AddDays(-9) },
|
|
new StatisticInfo { Schema = "dbo", Table = "CUSTTRANS", Name = "_WA_Sys_00000003", Rows = 120 },
|
|
],
|
|
};
|
|
|
|
var findings = Run(new StaleStatisticsRule(), Indexes(Month, db)).Findings;
|
|
|
|
Assert.Equal(2, findings.Count);
|
|
Assert.Contains("aldrig opdateret", findings[0].Title, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Unused_index_is_judged_on_usage_followed_across_restarts()
|
|
{
|
|
var index = Index(2, "IX_Unused", "NONCLUSTERED", ["Status"], reads: 0, updates: 50_000) with
|
|
{
|
|
TrackedReads = 0, TrackedWrites = 900_000, TrackedSince = Now.AddDays(-40),
|
|
};
|
|
var db = new IndexDatabase { Name = "Sales", Indexes = [index] };
|
|
|
|
var finding = Assert.Single(Run(new UnusedIndexRule(), Indexes(TimeSpan.FromDays(1), db)).Findings); // restarted yesterday
|
|
|
|
Assert.Contains("40 dage", finding.Title, StringComparison.Ordinal);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new UnusedIndexRule(), Indexes(TimeSpan.FromDays(1), db with { Indexes = [index with { TrackedReads = 3 }] })).Outcome);
|
|
}
|
|
[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);
|
|
}
|
|
}
|