From a3cf2fb1d4c65a459603e75186a23b83e42bc074 Mon Sep 17 00:00:00 2001 From: Deepesh Dhake Date: Fri, 9 Oct 2026 07:06:06 -0500 Subject: [PATCH 01/11] Add Find-DbaDbMissingIndex --- public/Find-DbaDbMissingIndex.ps1 | 332 ++++++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 public/Find-DbaDbMissingIndex.ps1 diff --git a/public/Find-DbaDbMissingIndex.ps1 b/public/Find-DbaDbMissingIndex.ps1 new file mode 100644 index 00000000000..a00bc78cecb --- /dev/null +++ b/public/Find-DbaDbMissingIndex.ps1 @@ -0,0 +1,332 @@ +function Find-DbaDbMissingIndex { + <# + .SYNOPSIS + Surfaces missing index suggestions from the missing-index DMVs, ranked by impact. + + .DESCRIPTION + Reads sys.dm_db_missing_index_group_stats, sys.dm_db_missing_index_groups, and + sys.dm_db_missing_index_details to surface indexes the optimizer wished existed for the + workload it has seen, ranked by an impact score, with a generated CREATE INDEX statement + for review. + + The score uses Microsoft's formula, avg_total_user_cost * avg_user_impact * + (user_seeks + user_scans), so that a large percentage improvement on a cheap query does + not outrank a smaller improvement on an expensive one. The three inputs + (AvgTotalUserCost, AvgUserImpact, and the seek/scan counts) are returned as their own + properties so you can rank differently if you prefer. + + Important caveats about this data, all surfaced per row and summarized here: + - The missing-index DMVs are advisory and over-suggest. These are candidates for a human + to evaluate, NOT recommendations to apply blindly. The generated CREATE INDEX statement + is a starting point - review key order, included columns, and especially the write-cost + impact before creating anything. + - The counters are cleared on instance restart, failover, and when a database goes + offline, and for a single table whenever its metadata changes or any ALTER INDEX runs + against it. With nightly index maintenance, a table's numbers may only cover the time + since its last rebuild. Each row returns the instance StartTime and the table's + LastUserSeek / LastUserScan so you can judge how much history the numbers represent. + - SQL Server stops collecting at 600 missing index groups per instance. When that ceiling + is reached a warning is emitted, because suggestions past it are not recorded. + + When a suggestion overlaps an existing index, the overlapping index is named in the + OverlappingIndex property rather than being filtered out - that is usually the case a + human most wants to see (an existing index with the wrong key order or a missing include), + and Microsoft's guidance is to widen the existing index rather than add a near-duplicate. + + .PARAMETER SqlInstance + The target SQL Server instance or instances. + + .PARAMETER SqlCredential + Login to the target instance using alternative credentials. Accepts PowerShell + credentials (Get-Credential). + + Windows Authentication, SQL Server Authentication, Active Directory - Password, and + Active Directory - Integrated are all supported. + + For MFA support, please use Connect-DbaInstance. + + .PARAMETER Database + The database(s) to process. If unspecified, all accessible user databases are processed. + + .PARAMETER ExcludeDatabase + The database(s) to exclude. + + .PARAMETER InputObject + Enables piping from Get-DbaDatabase. + + .PARAMETER MinimumImpact + Minimum impact score (avg_total_user_cost * avg_user_impact * (user_seeks + user_scans)) + for a suggestion to be returned. Filters out low-value noise. Default: 50. + + .PARAMETER MinimumSeek + Minimum number of seeks (user_seeks) for a suggestion to be returned. Default: 1. + + .PARAMETER EnableException + By default, when something goes wrong we try to catch it, interpret it and give you a + friendly warning message. This avoids overwhelming you with "sea of red" exceptions, but + is inconvenient because it basically disables advanced scripting. + + Using this switch turns this "nice by default" feature off and enables you to catch + exceptions with your own try/catch. + + .NOTES + Tags: Index, Performance, Diagnostic + Author: Deepesh Dhake (@deepeshd87) + + Website: https://dbatools.io + Copyright: (c) 2026 by dbatools, licensed under MIT + License: MIT https://opensource.org/licenses/MIT + + The missing-index DMVs are advisory and over-suggest; treat output as candidates to + evaluate, not recommendations to apply. Glenn Berry's diagnostic queries, available via + Invoke-DbaDiagnosticQuery -QueryName "Missing Indexes All Databases" (and "Missing + Indexes" per database), return the same raw rows; this command adds impact filtering, + CREATE INDEX generation, and overlap detection. + + Permissions: reading the missing-index DMVs requires VIEW SERVER STATE (SQL Server 2019 + and earlier) or VIEW SERVER PERFORMANCE STATE (SQL Server 2022+). Without it, the DMV + query returns no rows. + + .LINK + https://dbatools.io/Find-DbaDbMissingIndex + + .OUTPUTS + PSCustomObject + + .EXAMPLE + PS C:\> Find-DbaDbMissingIndex -SqlInstance sql2019 -Database AdventureWorks + + Returns missing index suggestions for AdventureWorks on sql2019, ranked by impact, with a + generated CREATE INDEX statement for each. + + .EXAMPLE + PS C:\> Find-DbaDbMissingIndex -SqlInstance sql2019 -MinimumImpact 1000 + + Returns only high-impact missing index suggestions (score of 1000 or more) across all + user databases on sql2019. + + .EXAMPLE + PS C:\> Get-DbaDatabase -SqlInstance sql2019 -Database Sales | Find-DbaDbMissingIndex + + Pipes the Sales database in from Get-DbaDatabase and returns its missing index suggestions. + #> + [CmdletBinding()] + param ( + [parameter(ValueFromPipeline)] + [DbaInstanceParameter[]]$SqlInstance, + [PSCredential]$SqlCredential, + [object[]]$Database, + [object[]]$ExcludeDatabase, + [parameter(ValueFromPipeline)] + [Microsoft.SqlServer.Management.Smo.Database[]]$InputObject, + [double]$MinimumImpact = 50, + [int]$MinimumSeek = 1, + [switch]$EnableException + ) + + begin { + # Per-database query. mid.database_id = DB_ID() keeps it correct on Azure SQL Database, + # where these DMVs only ever return rows for the connected database. The 2019+ query-text + # join is spliced in from the process block via $queryTextCte / $queryTextSelect / + # $queryTextJoin so pre-2019 instances get a clean query. + $sqlTemplate = @" +;WITH mig AS ( + SELECT + migs.group_handle, + migs.unique_compiles, + migs.user_seeks, + migs.user_scans, + migs.last_user_seek, + migs.last_user_scan, + migs.avg_total_user_cost, + migs.avg_user_impact + FROM sys.dm_db_missing_index_group_stats AS migs +) +{0} +SELECT + DB_NAME(mid.database_id) AS DatabaseName, + mid.database_id AS DatabaseId, + OBJECT_SCHEMA_NAME(mid.object_id, mid.database_id) AS SchemaName, + OBJECT_NAME(mid.object_id, mid.database_id) AS TableName, + mid.object_id AS ObjectId, + mid.equality_columns AS EqualityColumns, + mid.inequality_columns AS InequalityColumns, + mid.included_columns AS IncludedColumns, + mig.user_seeks AS UserSeeks, + mig.user_scans AS UserScans, + mig.unique_compiles AS UniqueCompiles, + mig.last_user_seek AS LastUserSeek, + mig.last_user_scan AS LastUserScan, + CAST(mig.avg_total_user_cost AS decimal(18,4)) AS AvgTotalUserCost, + CAST(mig.avg_user_impact AS decimal(9,2)) AS AvgUserImpact, + CAST(mig.avg_total_user_cost * mig.avg_user_impact * (mig.user_seeks + mig.user_scans) AS decimal(28,4)) AS ImpactScore + {1} +FROM mig +INNER JOIN sys.dm_db_missing_index_groups AS mig_grp ON mig.group_handle = mig_grp.index_group_handle +INNER JOIN sys.dm_db_missing_index_details AS mid ON mig_grp.index_handle = mid.index_handle +{2} +WHERE mid.database_id = DB_ID() + AND mig.user_seeks >= {3} + AND (mig.avg_total_user_cost * mig.avg_user_impact * (mig.user_seeks + mig.user_scans)) >= {4} +ORDER BY ImpactScore DESC; +"@ + # Note: the SMO Database.Query() method does not support parameterized queries, so the + # two thresholds are formatted into the statement directly. Both are validated numeric + # parameters ([int] MinimumSeek, [double] MinimumImpact), so there is no injection risk; + # they are rendered with the invariant culture so a comma decimal separator can't break + # the SQL on non-US locales. + } + + process { + $databases = @() + + foreach ($instance in $SqlInstance) { + try { + # Missing-index DMVs exist since SQL Server 2005; 2000 is skipped cleanly. + $server = Connect-DbaInstance -SqlInstance $instance -SqlCredential $SqlCredential -MinimumVersion 9 + } catch { + Stop-Function -Message "Error occurred while establishing connection to $instance" -Category ConnectionError -ErrorRecord $_ -Target $instance -Continue + } + $databases += Get-DbaDatabase -SqlInstance $server -Database $Database -ExcludeDatabase $ExcludeDatabase -ExcludeSystem -OnlyAccessible + } + + foreach ($db in $InputObject) { + if ($db.IsSystemObject -or -not $db.IsAccessible) { continue } + $databases += $db + } + + foreach ($db in $databases) { + $server = $db.Parent + Write-Message -Level Verbose -Message "Processing $($db.Name) on $($server.Name)" + + # Warn once per instance when the 600-group collection ceiling is reached - past it, + # the DMVs stop recording new suggestions. + try { + $groupCount = ($server.Query("SELECT COUNT(*) AS cnt FROM sys.dm_db_missing_index_group_stats")).cnt + if ($groupCount -ge 600) { + Write-Message -Level Warning -Message "Instance $($server.Name) has reached the 600 missing-index-group collection limit; suggestions beyond it are not recorded." + } + } catch { + # Non-fatal; carry on with the analysis. + Write-Message -Level Verbose -Message "Could not read missing-index group count on $($server.Name): $($_.Exception.Message)" + } + + # The 2019+/Azure query-text DMV (one row per query, so joined separately from + # group_stats to avoid double-counting the seek/scan totals). + if ($server.VersionMajor -ge 15) { + $queryTextCte = ", qt AS ( + SELECT + migsq.group_handle, + qs.query_hash, + qs.last_sql_handle + FROM sys.dm_db_missing_index_group_stats_query AS migsq + CROSS APPLY ( + SELECT TOP (1) query_hash, last_sql_handle + FROM sys.dm_db_missing_index_group_stats_query AS inner_q + WHERE inner_q.group_handle = migsq.group_handle + ORDER BY inner_q.last_user_seek DESC + ) AS qs + GROUP BY migsq.group_handle, qs.query_hash, qs.last_sql_handle +)" + $queryTextSelect = ", qt.query_hash AS QueryHash, qt.last_sql_handle AS LastSqlHandle" + $queryTextJoin = "LEFT JOIN qt ON mig.group_handle = qt.group_handle" + } else { + $queryTextCte = "" + $queryTextSelect = "" + $queryTextJoin = "" + } + + # Render numeric thresholds with invariant culture so a locale comma separator can't + # corrupt the SQL. Both are validated numeric parameters, so this is injection-safe. + $ci = [System.Globalization.CultureInfo]::InvariantCulture + $minSeekSql = $MinimumSeek.ToString($ci) + $minImpactSql = $MinimumImpact.ToString($ci) + + $sql = $sqlTemplate -f $queryTextCte, $queryTextSelect, $queryTextJoin, $minSeekSql, $minImpactSql + + try { + $results = $db.Query($sql) + } catch { + Stop-Function -Message "Failure executing missing-index analysis against $($db.Name) on $($server.Name)" -ErrorRecord $_ -Target $db -Continue + } + + # Existing indexes on the table, used to flag (not filter) overlapping suggestions. + # Keyed by object_id so we only pull each table's indexes once. + $existingIndexCache = @{ } + + foreach ($row in $results) { + # Build the CREATE INDEX statement: equality columns, then inequality columns in + # the key, then the remainder as INCLUDE. No edition-specific options (ONLINE etc.). + $keyCols = @() + if ($row.EqualityColumns) { $keyCols += $row.EqualityColumns } + if ($row.InequalityColumns) { $keyCols += $row.InequalityColumns } + $keyColText = ($keyCols -join ', ') + + $indexNameParts = @() + foreach ($c in ($keyColText -split ',')) { + $clean = ($c -replace '[\[\]]', '').Trim() + if ($clean) { $indexNameParts += $clean } + } + $suggestedName = "IX_$($row.TableName)_" + ($indexNameParts -join '_') + if ($suggestedName.Length -gt 128) { $suggestedName = $suggestedName.Substring(0, 128) } + + $createStatement = "CREATE NONCLUSTERED INDEX [$suggestedName] ON [$($row.SchemaName)].[$($row.TableName)] ($keyColText)" + if ($row.IncludedColumns) { + $createStatement += " INCLUDE ($($row.IncludedColumns))" + } + $createStatement += ";" + + # Overlap detection: name any existing index whose leading key column matches this + # suggestion's first key column. Flagged, never filtered - a near-duplicate is + # exactly the case a human most wants to review. + $overlapping = $null + if ($keyColText) { + $firstKey = (($keyColText -split ',')[0] -replace '[\[\]]', '').Trim() + if (-not $existingIndexCache.ContainsKey($row.ObjectId)) { + try { + $smoTable = $db.Tables | Where-Object { $_.ID -eq $row.ObjectId } + $existingIndexCache[$row.ObjectId] = $smoTable + } catch { + $existingIndexCache[$row.ObjectId] = $null + } + } + $smoTable = $existingIndexCache[$row.ObjectId] + if ($smoTable) { + foreach ($idx in $smoTable.Indexes) { + $leadingCol = ($idx.IndexedColumns | Where-Object { -not $_.IsIncluded } | Sort-Object ID | Select-Object -First 1).Name + if ($leadingCol -and $leadingCol -eq $firstKey) { + $overlapping = $idx.Name + break + } + } + } + } + + [PSCustomObject]@{ + ComputerName = $server.ComputerName + InstanceName = $server.ServiceName + SqlInstance = $server.DomainInstanceName + Database = $row.DatabaseName + Schema = $row.SchemaName + Table = $row.TableName + EqualityColumns = $row.EqualityColumns + InequalityColumns = $row.InequalityColumns + IncludedColumns = $row.IncludedColumns + ImpactScore = $row.ImpactScore + AvgTotalUserCost = $row.AvgTotalUserCost + AvgUserImpact = $row.AvgUserImpact + UserSeeks = $row.UserSeeks + UserScans = $row.UserScans + UniqueCompiles = $row.UniqueCompiles + LastUserSeek = $row.LastUserSeek + LastUserScan = $row.LastUserScan + StartTime = $server.Databases['tempdb'].CreateDate + QueryHash = if ($row.PSObject.Properties.Name -contains 'QueryHash') { $row.QueryHash } else { $null } + LastSqlHandle = if ($row.PSObject.Properties.Name -contains 'LastSqlHandle') { $row.LastSqlHandle } else { $null } + OverlappingIndex = $overlapping + CreateStatement = $createStatement + } | Select-DefaultView -Property SqlInstance, Database, Schema, Table, ImpactScore, UserSeeks, AvgUserImpact, OverlappingIndex, CreateStatement + } + } + } +} \ No newline at end of file From 043fbb668b2c65451ea67fe68c891cac77e771d1 Mon Sep 17 00:00:00 2001 From: Deepesh Dhake Date: Fri, 9 Oct 2026 07:09:08 -0500 Subject: [PATCH 02/11] Add files via upload --- tests/Find-DbaDbMissingIndex.Tests.ps1 | 85 ++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/Find-DbaDbMissingIndex.Tests.ps1 diff --git a/tests/Find-DbaDbMissingIndex.Tests.ps1 b/tests/Find-DbaDbMissingIndex.Tests.ps1 new file mode 100644 index 00000000000..0446e7d0cd3 --- /dev/null +++ b/tests/Find-DbaDbMissingIndex.Tests.ps1 @@ -0,0 +1,85 @@ +#Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } +param( + $ModuleName = "dbatools", + $PSDefaultParameterValues = ($TestConfig = Get-TestConfig).Defaults +) + +Describe "Find-DbaDbMissingIndex" -Tag "UnitTests" { + Context "Parameter validation" { + BeforeDiscovery { + [object[]]$knownParameters = @( + "SqlInstance", + "SqlCredential", + "Database", + "ExcludeDatabase", + "InputObject", + "MinimumImpact", + "MinimumSeek", + "EnableException" + ) + } + + It "Has parameter: <_>" -ForEach $knownParameters { + (Get-Command Find-DbaDbMissingIndex).Parameters.Keys | Should -Contain $PSItem + } + + It "Should have exactly the expected parameters" { + $hasParams = (Get-Command Find-DbaDbMissingIndex).Parameters.Values.Name + $commonParameters = [System.Management.Automation.PSCmdlet]::CommonParameters + $comparison = Compare-Object -ReferenceObject $knownParameters -DifferenceObject ($hasParams | Where-Object { $PSItem -notin $commonParameters }) + $comparison | Should -BeNullOrEmpty + } + } +} + +Describe "Find-DbaDbMissingIndex" -Tag "IntegrationTests" { + BeforeAll { + $db = "dbatoolsci_missingindex_$(Get-Random)" + $server = Connect-DbaInstance -SqlInstance $TestConfig.instance2 + $null = $server.Query("CREATE DATABASE [$db]") + + # 20k rows, clustered PK only, so a non-trivial query produces a missing-index suggestion. + # A trivial plan never generates one, so the predicate is forced to full optimization + # with AND 1 = (SELECT 1), per the behavior confirmed in the maintainer's lab. + $setup = @" +USE [$db]; +CREATE TABLE dbo.Orders (OrderId int IDENTITY(1,1) PRIMARY KEY, CustomerId int, Amount money, Filler char(200) DEFAULT 'x'); +INSERT INTO dbo.Orders (CustomerId, Amount) +SELECT TOP (20000) ABS(CHECKSUM(NEWID())) % 1000, ABS(CHECKSUM(NEWID())) % 10000 +FROM sys.all_objects a CROSS JOIN sys.all_objects b; +"@ + $null = $server.Query($setup) + + $runWorkload = "USE [$db]; SELECT OrderId, Amount FROM dbo.Orders WHERE CustomerId = 42 AND 1 = (SELECT 1);" + 1..5 | ForEach-Object { $null = $server.Query($runWorkload) } + } + + AfterAll { + Remove-DbaDatabase -SqlInstance $TestConfig.instance2 -Database $db -Confirm:$false + } + + Context "Finds a seeded missing index" { + BeforeAll { + # Low thresholds so the single-seek fixture is not filtered out by the defaults. + $results = Find-DbaDbMissingIndex -SqlInstance $TestConfig.instance2 -Database $db -MinimumImpact 0 -MinimumSeek 1 + } + + It "Returns at least one suggestion" { + $results | Should -Not -BeNullOrEmpty + } + + It "Suggests an index keyed on CustomerId" { + ($results | Where-Object { $PSItem.EqualityColumns -match "CustomerId" }) | Should -Not -BeNullOrEmpty + } + + It "Generates a CREATE NONCLUSTERED INDEX statement" { + $results[0].CreateStatement | Should -Match "CREATE NONCLUSTERED INDEX" + } + + It "Returns the impact-score inputs as properties" { + $results[0].PSObject.Properties.Name | Should -Contain "AvgTotalUserCost" + $results[0].PSObject.Properties.Name | Should -Contain "AvgUserImpact" + $results[0].PSObject.Properties.Name | Should -Contain "ImpactScore" + } + } +} From 9c6db8041c5d58298342ee557a71cd7365222092 Mon Sep 17 00:00:00 2001 From: Deepesh Dhake Date: Sun, 11 Oct 2026 15:14:22 -0500 Subject: [PATCH 03/11] Find-DbaDbMissingIndex.ps1 --- public/Find-DbaDbMissingIndex.ps1 | 266 ++++++++++++++++++------------ 1 file changed, 162 insertions(+), 104 deletions(-) diff --git a/public/Find-DbaDbMissingIndex.ps1 b/public/Find-DbaDbMissingIndex.ps1 index a00bc78cecb..18267972c49 100644 --- a/public/Find-DbaDbMissingIndex.ps1 +++ b/public/Find-DbaDbMissingIndex.ps1 @@ -48,6 +48,9 @@ function Find-DbaDbMissingIndex { .PARAMETER Database The database(s) to process. If unspecified, all accessible user databases are processed. + A system database is skipped unless it is named here explicitly, in which case it is + processed and a warning is written noting that system databases are not included by default. + .PARAMETER ExcludeDatabase The database(s) to exclude. @@ -59,7 +62,9 @@ function Find-DbaDbMissingIndex { for a suggestion to be returned. Filters out low-value noise. Default: 50. .PARAMETER MinimumSeek - Minimum number of seeks (user_seeks) for a suggestion to be returned. Default: 1. + Minimum number of seeks and scans (user_seeks + user_scans) for a suggestion to be + returned. This matches the seek/scan term in the impact score, so scan-only suggestions + are not silently dropped. Default: 1. .PARAMETER EnableException By default, when something goes wrong we try to catch it, interpret it and give you a @@ -126,9 +131,23 @@ function Find-DbaDbMissingIndex { begin { # Per-database query. mid.database_id = DB_ID() keeps it correct on Azure SQL Database, - # where these DMVs only ever return rows for the connected database. The 2019+ query-text - # join is spliced in from the process block via $queryTextCte / $queryTextSelect / - # $queryTextJoin so pre-2019 instances get a clean query. + # where these DMVs only ever return rows for the connected database. + # + # Overlap detection is done here rather than in SMO: the ix CTE finds, per table, every + # existing index whose leading key column (key_ordinal = 1, is_included_column = 0) matches + # any of this suggestion's candidate key columns. Doing it in the query removes a per-table + # SMO Tables/Indexes load and compares against every candidate key column, not just the + # first listed one (the DMV's equality_columns list is not in key order). + # + # Names come back already quoted with QUOTENAME(), so a ] in an object name can't break the + # generated T-SQL. index_handle is returned so the suggested index name can be made unique + # (the DMV frequently has several suggestions for one table with the same keys and different + # include lists). + # + # The 2019+ query-text join (qt CTE) is spliced in from the process block via + # $queryTextCte / $queryTextSelect / $queryTextJoin so pre-2019 instances get a clean query. + # It uses ROW_NUMBER() rather than TOP (1) so a tie on last_user_seek (e.g. both NULL for a + # scan-only group) can't return the same group twice. $sqlTemplate = @" ;WITH mig AS ( SELECT @@ -141,6 +160,22 @@ function Find-DbaDbMissingIndex { migs.avg_total_user_cost, migs.avg_user_impact FROM sys.dm_db_missing_index_group_stats AS migs +), +ix AS ( + SELECT + i.object_id, + c.name AS leading_column, + i.name AS index_name + FROM sys.indexes AS i + INNER JOIN sys.index_columns AS ic + ON ic.object_id = i.object_id + AND ic.index_id = i.index_id + AND ic.key_ordinal = 1 + AND ic.is_included_column = 0 + INNER JOIN sys.columns AS c + ON c.object_id = ic.object_id + AND c.column_id = ic.column_id + WHERE i.index_id > 0 ) {0} SELECT @@ -148,7 +183,10 @@ SELECT mid.database_id AS DatabaseId, OBJECT_SCHEMA_NAME(mid.object_id, mid.database_id) AS SchemaName, OBJECT_NAME(mid.object_id, mid.database_id) AS TableName, + QUOTENAME(OBJECT_SCHEMA_NAME(mid.object_id, mid.database_id)) AS SchemaNameQuoted, + QUOTENAME(OBJECT_NAME(mid.object_id, mid.database_id)) AS TableNameQuoted, mid.object_id AS ObjectId, + mid.index_handle AS IndexHandle, mid.equality_columns AS EqualityColumns, mid.inequality_columns AS InequalityColumns, mid.included_columns AS IncludedColumns, @@ -159,14 +197,23 @@ SELECT mig.last_user_scan AS LastUserScan, CAST(mig.avg_total_user_cost AS decimal(18,4)) AS AvgTotalUserCost, CAST(mig.avg_user_impact AS decimal(9,2)) AS AvgUserImpact, - CAST(mig.avg_total_user_cost * mig.avg_user_impact * (mig.user_seeks + mig.user_scans) AS decimal(28,4)) AS ImpactScore + CAST(mig.avg_total_user_cost * mig.avg_user_impact * (mig.user_seeks + mig.user_scans) AS decimal(28,4)) AS ImpactScore, + STUFF(( + SELECT ', ' + ix.index_name + FROM ix + WHERE ix.object_id = mid.object_id + AND ( + ',' + REPLACE(REPLACE(REPLACE(ISNULL(mid.equality_columns, '') + ',' + ISNULL(mid.inequality_columns, ''), '[', ''), ']', ''), ' ', '') + LIKE '%,' + REPLACE(ix.leading_column, ' ', '') + ',%' + ) + FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 2, '') AS OverlappingIndex {1} FROM mig INNER JOIN sys.dm_db_missing_index_groups AS mig_grp ON mig.group_handle = mig_grp.index_group_handle INNER JOIN sys.dm_db_missing_index_details AS mid ON mig_grp.index_handle = mid.index_handle {2} WHERE mid.database_id = DB_ID() - AND mig.user_seeks >= {3} + AND (mig.user_seeks + mig.user_scans) >= {3} AND (mig.avg_total_user_cost * mig.avg_user_impact * (mig.user_seeks + mig.user_scans)) >= {4} ORDER BY ImpactScore DESC; "@ @@ -175,6 +222,10 @@ ORDER BY ImpactScore DESC; # parameters ([int] MinimumSeek, [double] MinimumImpact), so there is no injection risk; # they are rendered with the invariant culture so a comma decimal separator can't break # the SQL on non-US locales. + + # Instances whose 600-group ceiling has already been checked, so the warning fires once per + # instance rather than once per database. + $checkedInstances = @{ } } process { @@ -187,11 +238,25 @@ ORDER BY ImpactScore DESC; } catch { Stop-Function -Message "Error occurred while establishing connection to $instance" -Category ConnectionError -ErrorRecord $_ -Target $instance -Continue } - $databases += Get-DbaDatabase -SqlInstance $server -Database $Database -ExcludeDatabase $ExcludeDatabase -ExcludeSystem -OnlyAccessible + + # Skip system databases unless one is named explicitly. Get-DbaDatabase -ExcludeSystem + # drops them silently, which reads as "no suggestions" for a deliberate -Database msdb; + # so when a system database is named we include it and warn. + $namedSystem = @($Database | Where-Object { $_ -in 'master', 'model', 'msdb', 'tempdb' }) + if ($namedSystem) { + Write-Message -Level Warning -Message "System databases are not included by default; processing $($namedSystem -join ', ') because named explicitly." + $databases += Get-DbaDatabase -SqlInstance $server -Database $Database -ExcludeDatabase $ExcludeDatabase -OnlyAccessible + } else { + $databases += Get-DbaDatabase -SqlInstance $server -Database $Database -ExcludeDatabase $ExcludeDatabase -ExcludeSystem -OnlyAccessible + } } foreach ($db in $InputObject) { - if ($db.IsSystemObject -or -not $db.IsAccessible) { continue } + if (-not $db.IsAccessible) { continue } + # Honour an explicitly piped system database, but skip system databases that arrive as + # part of a broad Get-DbaDatabase pipe (no -Database filter). + if ($db.IsSystemObject -and $Database -and $db.Name -notin $Database) { continue } + if ($db.IsSystemObject -and -not $Database) { continue } $databases += $db } @@ -201,32 +266,34 @@ ORDER BY ImpactScore DESC; # Warn once per instance when the 600-group collection ceiling is reached - past it, # the DMVs stop recording new suggestions. - try { - $groupCount = ($server.Query("SELECT COUNT(*) AS cnt FROM sys.dm_db_missing_index_group_stats")).cnt - if ($groupCount -ge 600) { - Write-Message -Level Warning -Message "Instance $($server.Name) has reached the 600 missing-index-group collection limit; suggestions beyond it are not recorded." + if (-not $checkedInstances.ContainsKey($server.Name)) { + $checkedInstances[$server.Name] = $true + try { + $groupCount = ($server.Query("SELECT COUNT(*) AS cnt FROM sys.dm_db_missing_index_group_stats")).cnt + if ($groupCount -ge 600) { + Write-Message -Level Warning -Message "Instance $($server.Name) has reached the 600 missing-index-group collection limit; suggestions beyond it are not recorded." + } + } catch { + # Non-fatal; carry on with the analysis. + Write-Message -Level Verbose -Message "Could not read missing-index group count on $($server.Name): $($_.Exception.Message)" } - } catch { - # Non-fatal; carry on with the analysis. - Write-Message -Level Verbose -Message "Could not read missing-index group count on $($server.Name): $($_.Exception.Message)" } # The 2019+/Azure query-text DMV (one row per query, so joined separately from - # group_stats to avoid double-counting the seek/scan totals). + # group_stats to avoid double-counting the seek/scan totals). ROW_NUMBER with a + # query_hash tiebreaker is deterministic, so a group never comes back twice. if ($server.VersionMajor -ge 15) { $queryTextCte = ", qt AS ( - SELECT - migsq.group_handle, - qs.query_hash, - qs.last_sql_handle - FROM sys.dm_db_missing_index_group_stats_query AS migsq - CROSS APPLY ( - SELECT TOP (1) query_hash, last_sql_handle - FROM sys.dm_db_missing_index_group_stats_query AS inner_q - WHERE inner_q.group_handle = migsq.group_handle - ORDER BY inner_q.last_user_seek DESC - ) AS qs - GROUP BY migsq.group_handle, qs.query_hash, qs.last_sql_handle + SELECT group_handle, query_hash, last_sql_handle + FROM ( + SELECT + migsq.group_handle, + migsq.query_hash, + migsq.last_sql_handle, + ROW_NUMBER() OVER (PARTITION BY migsq.group_handle ORDER BY migsq.last_user_seek DESC, migsq.query_hash) AS rn + FROM sys.dm_db_missing_index_group_stats_query AS migsq + ) AS ranked + WHERE ranked.rn = 1 )" $queryTextSelect = ", qt.query_hash AS QueryHash, qt.last_sql_handle AS LastSqlHandle" $queryTextJoin = "LEFT JOIN qt ON mig.group_handle = qt.group_handle" @@ -244,88 +311,79 @@ ORDER BY ImpactScore DESC; $sql = $sqlTemplate -f $queryTextCte, $queryTextSelect, $queryTextJoin, $minSeekSql, $minImpactSql + # $db.Query() moves the server's shared connection into this database. Record the + # starting database and restore it in the finally block so a caller who passed a server + # object or piped Get-DbaDatabase output is not left pointing at the last database here. + $currentDatabase = $server.ConnectionContext.CurrentDatabase try { - $results = $db.Query($sql) - } catch { - Stop-Function -Message "Failure executing missing-index analysis against $($db.Name) on $($server.Name)" -ErrorRecord $_ -Target $db -Continue - } - - # Existing indexes on the table, used to flag (not filter) overlapping suggestions. - # Keyed by object_id so we only pull each table's indexes once. - $existingIndexCache = @{ } - - foreach ($row in $results) { - # Build the CREATE INDEX statement: equality columns, then inequality columns in - # the key, then the remainder as INCLUDE. No edition-specific options (ONLINE etc.). - $keyCols = @() - if ($row.EqualityColumns) { $keyCols += $row.EqualityColumns } - if ($row.InequalityColumns) { $keyCols += $row.InequalityColumns } - $keyColText = ($keyCols -join ', ') - - $indexNameParts = @() - foreach ($c in ($keyColText -split ',')) { - $clean = ($c -replace '[\[\]]', '').Trim() - if ($clean) { $indexNameParts += $clean } + try { + $results = $db.Query($sql) + } catch { + Stop-Function -Message "Failure executing missing-index analysis against $($db.Name) on $($server.Name)" -ErrorRecord $_ -Target $db -Continue + continue } - $suggestedName = "IX_$($row.TableName)_" + ($indexNameParts -join '_') - if ($suggestedName.Length -gt 128) { $suggestedName = $suggestedName.Substring(0, 128) } - $createStatement = "CREATE NONCLUSTERED INDEX [$suggestedName] ON [$($row.SchemaName)].[$($row.TableName)] ($keyColText)" - if ($row.IncludedColumns) { - $createStatement += " INCLUDE ($($row.IncludedColumns))" - } - $createStatement += ";" - - # Overlap detection: name any existing index whose leading key column matches this - # suggestion's first key column. Flagged, never filtered - a near-duplicate is - # exactly the case a human most wants to review. - $overlapping = $null - if ($keyColText) { - $firstKey = (($keyColText -split ',')[0] -replace '[\[\]]', '').Trim() - if (-not $existingIndexCache.ContainsKey($row.ObjectId)) { - try { - $smoTable = $db.Tables | Where-Object { $_.ID -eq $row.ObjectId } - $existingIndexCache[$row.ObjectId] = $smoTable - } catch { - $existingIndexCache[$row.ObjectId] = $null - } + foreach ($row in $results) { + # Build the CREATE INDEX key list: equality columns, then inequality columns. + # .Query() returns a NULL column as [DBNull]::Value, which PowerShell treats as + # truthy, so test the type, not truthiness, or an absent column adds an empty + # entry (a trailing comma in the key, or INCLUDE ()). + $keyCols = @() + if ($row.EqualityColumns -isnot [System.DBNull] -and $row.EqualityColumns) { $keyCols += $row.EqualityColumns } + if ($row.InequalityColumns -isnot [System.DBNull] -and $row.InequalityColumns) { $keyCols += $row.InequalityColumns } + $keyColText = ($keyCols -join ', ') + + $indexNameParts = @() + foreach ($c in ($keyColText -split ',')) { + $clean = ($c -replace '[\[\]]', '').Trim() + if ($clean) { $indexNameParts += $clean } } - $smoTable = $existingIndexCache[$row.ObjectId] - if ($smoTable) { - foreach ($idx in $smoTable.Indexes) { - $leadingCol = ($idx.IndexedColumns | Where-Object { -not $_.IsIncluded } | Sort-Object ID | Select-Object -First 1).Name - if ($leadingCol -and $leadingCol -eq $firstKey) { - $overlapping = $idx.Name - break - } - } + # index_handle keeps the name unique when one table has several suggestions with + # the same keys and different includes - otherwise running the CREATE statements + # in sequence fails from the second one on. + $suggestedName = "IX_$($row.TableName)_" + ($indexNameParts -join '_') + "_$($row.IndexHandle)" + if ($suggestedName.Length -gt 128) { $suggestedName = $suggestedName.Substring(0, 128) } + $suggestedNameQuoted = "[" + ($suggestedName -replace ']', ']]') + "]" + + # SchemaNameQuoted / TableNameQuoted come back from QUOTENAME(), so a ] in an + # object name is already escaped. + $createStatement = "CREATE NONCLUSTERED INDEX $suggestedNameQuoted ON $($row.SchemaNameQuoted).$($row.TableNameQuoted) ($keyColText)" + if ($row.IncludedColumns -isnot [System.DBNull] -and $row.IncludedColumns) { + $createStatement += " INCLUDE ($($row.IncludedColumns))" } + $createStatement += ";" + + $overlapping = if ($row.OverlappingIndex -is [System.DBNull]) { $null } else { $row.OverlappingIndex } + + [PSCustomObject]@{ + ComputerName = $server.ComputerName + InstanceName = $server.ServiceName + SqlInstance = $server.DomainInstanceName + Database = $row.DatabaseName + Schema = $row.SchemaName + Table = $row.TableName + EqualityColumns = if ($row.EqualityColumns -is [System.DBNull]) { $null } else { $row.EqualityColumns } + InequalityColumns = if ($row.InequalityColumns -is [System.DBNull]) { $null } else { $row.InequalityColumns } + IncludedColumns = if ($row.IncludedColumns -is [System.DBNull]) { $null } else { $row.IncludedColumns } + ImpactScore = $row.ImpactScore + AvgTotalUserCost = $row.AvgTotalUserCost + AvgUserImpact = $row.AvgUserImpact + UserSeeks = $row.UserSeeks + UserScans = $row.UserScans + UniqueCompiles = $row.UniqueCompiles + LastUserSeek = $row.LastUserSeek + LastUserScan = $row.LastUserScan + StartTime = $server.Databases['tempdb'].CreateDate + QueryHash = if ($row.PSObject.Properties.Name -contains 'QueryHash') { $row.QueryHash } else { $null } + LastSqlHandle = if ($row.PSObject.Properties.Name -contains 'LastSqlHandle') { $row.LastSqlHandle } else { $null } + OverlappingIndex = $overlapping + CreateStatement = $createStatement + } | Select-DefaultView -Property SqlInstance, Database, Schema, Table, ImpactScore, UserSeeks, AvgUserImpact, OverlappingIndex, CreateStatement } - - [PSCustomObject]@{ - ComputerName = $server.ComputerName - InstanceName = $server.ServiceName - SqlInstance = $server.DomainInstanceName - Database = $row.DatabaseName - Schema = $row.SchemaName - Table = $row.TableName - EqualityColumns = $row.EqualityColumns - InequalityColumns = $row.InequalityColumns - IncludedColumns = $row.IncludedColumns - ImpactScore = $row.ImpactScore - AvgTotalUserCost = $row.AvgTotalUserCost - AvgUserImpact = $row.AvgUserImpact - UserSeeks = $row.UserSeeks - UserScans = $row.UserScans - UniqueCompiles = $row.UniqueCompiles - LastUserSeek = $row.LastUserSeek - LastUserScan = $row.LastUserScan - StartTime = $server.Databases['tempdb'].CreateDate - QueryHash = if ($row.PSObject.Properties.Name -contains 'QueryHash') { $row.QueryHash } else { $null } - LastSqlHandle = if ($row.PSObject.Properties.Name -contains 'LastSqlHandle') { $row.LastSqlHandle } else { $null } - OverlappingIndex = $overlapping - CreateStatement = $createStatement - } | Select-DefaultView -Property SqlInstance, Database, Schema, Table, ImpactScore, UserSeeks, AvgUserImpact, OverlappingIndex, CreateStatement + } finally { + # Put the shared connection back where the caller had it, using the same internal + # helper Find-DbaDbDisabledIndex uses (see #10555). + Restore-DatabaseContext -Server $server -Database $currentDatabase } } } From 406417464adc6fb5be359551800f249eb87a9718 Mon Sep 17 00:00:00 2001 From: Deepesh Dhake Date: Sun, 11 Oct 2026 15:14:53 -0500 Subject: [PATCH 04/11] Find-DbaDbMissingIndex.Tests --- tests/Find-DbaDbMissingIndex.Tests.ps1 | 88 ++++++++++++++------------ 1 file changed, 47 insertions(+), 41 deletions(-) diff --git a/tests/Find-DbaDbMissingIndex.Tests.ps1 b/tests/Find-DbaDbMissingIndex.Tests.ps1 index 0446e7d0cd3..5f56521e5ad 100644 --- a/tests/Find-DbaDbMissingIndex.Tests.ps1 +++ b/tests/Find-DbaDbMissingIndex.Tests.ps1 @@ -1,13 +1,16 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", - $PSDefaultParameterValues = ($TestConfig = Get-TestConfig).Defaults + $ModuleName = "dbatools", + $CommandName = "Find-DbaDbMissingIndex", + $PSDefaultParameterValues = $TestConfig.Defaults ) -Describe "Find-DbaDbMissingIndex" -Tag "UnitTests" { +Describe $CommandName -Tag UnitTests { Context "Parameter validation" { - BeforeDiscovery { - [object[]]$knownParameters = @( + It "Should have the expected parameters" { + $hasParameters = (Get-Command $CommandName).Parameters.Values.Name | Where-Object { $PSItem -notin ("WhatIf", "Confirm") } + $expectedParameters = $TestConfig.CommonParameters + $expectedParameters += @( "SqlInstance", "SqlCredential", "Database", @@ -17,69 +20,72 @@ Describe "Find-DbaDbMissingIndex" -Tag "UnitTests" { "MinimumSeek", "EnableException" ) - } - - It "Has parameter: <_>" -ForEach $knownParameters { - (Get-Command Find-DbaDbMissingIndex).Parameters.Keys | Should -Contain $PSItem - } - - It "Should have exactly the expected parameters" { - $hasParams = (Get-Command Find-DbaDbMissingIndex).Parameters.Values.Name - $commonParameters = [System.Management.Automation.PSCmdlet]::CommonParameters - $comparison = Compare-Object -ReferenceObject $knownParameters -DifferenceObject ($hasParams | Where-Object { $PSItem -notin $commonParameters }) - $comparison | Should -BeNullOrEmpty + Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } } -Describe "Find-DbaDbMissingIndex" -Tag "IntegrationTests" { +Describe $CommandName -Tag IntegrationTests { BeforeAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + $db = "dbatoolsci_missingindex_$(Get-Random)" - $server = Connect-DbaInstance -SqlInstance $TestConfig.instance2 - $null = $server.Query("CREATE DATABASE [$db]") + $null = New-DbaDatabase -SqlInstance $TestConfig.InstanceSingle -Name $db - # 20k rows, clustered PK only, so a non-trivial query produces a missing-index suggestion. - # A trivial plan never generates one, so the predicate is forced to full optimization - # with AND 1 = (SELECT 1), per the behavior confirmed in the maintainer's lab. + # A clustered PK only, then enough rows that a predicate on an unindexed column produces a + # missing-index suggestion. A trivial plan never generates one, so AND 1 = (SELECT 1) forces + # the predicate to full optimization (the behaviour the maintainer confirmed in #10770). $setup = @" -USE [$db]; CREATE TABLE dbo.Orders (OrderId int IDENTITY(1,1) PRIMARY KEY, CustomerId int, Amount money, Filler char(200) DEFAULT 'x'); INSERT INTO dbo.Orders (CustomerId, Amount) SELECT TOP (20000) ABS(CHECKSUM(NEWID())) % 1000, ABS(CHECKSUM(NEWID())) % 10000 FROM sys.all_objects a CROSS JOIN sys.all_objects b; "@ - $null = $server.Query($setup) + $null = Invoke-DbaQuery -SqlInstance $TestConfig.InstanceSingle -Database $db -Query $setup - $runWorkload = "USE [$db]; SELECT OrderId, Amount FROM dbo.Orders WHERE CustomerId = 42 AND 1 = (SELECT 1);" - 1..5 | ForEach-Object { $null = $server.Query($runWorkload) } + $runWorkload = "SELECT OrderId, Amount FROM dbo.Orders WHERE CustomerId = 42 AND 1 = (SELECT 1);" + 1..5 | ForEach-Object { $null = Invoke-DbaQuery -SqlInstance $TestConfig.InstanceSingle -Database $db -Query $runWorkload } + + # Low thresholds so the single-seek fixture is not filtered out by the defaults. + $results = Find-DbaDbMissingIndex -SqlInstance $TestConfig.InstanceSingle -Database $db -MinimumImpact 0 -MinimumSeek 1 + $suggestion = $results | Where-Object { $PSItem.EqualityColumns -match "CustomerId" } | Select-Object -First 1 + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") } AfterAll { - Remove-DbaDatabase -SqlInstance $TestConfig.instance2 -Database $db -Confirm:$false + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + $null = Remove-DbaDatabase -SqlInstance $TestConfig.InstanceSingle -Database $db -ErrorAction SilentlyContinue + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") } Context "Finds a seeded missing index" { - BeforeAll { - # Low thresholds so the single-seek fixture is not filtered out by the defaults. - $results = Find-DbaDbMissingIndex -SqlInstance $TestConfig.instance2 -Database $db -MinimumImpact 0 -MinimumSeek 1 + It "Returns a suggestion keyed on CustomerId" { + $suggestion | Should -Not -BeNullOrEmpty } - It "Returns at least one suggestion" { - $results | Should -Not -BeNullOrEmpty + It "Generates the exact CREATE statement for the fixture" { + # The index name carries the index_handle (a per-table integer), so assert the stable + # parts around it rather than the whole handle value. + $suggestion.CreateStatement | Should -Match "^CREATE NONCLUSTERED INDEX \[IX_Orders_CustomerId_\d+\] ON \[dbo\]\.\[Orders\] \(\[CustomerId\]\) INCLUDE \(\[Amount\]\);$" } - It "Suggests an index keyed on CustomerId" { - ($results | Where-Object { $PSItem.EqualityColumns -match "CustomerId" }) | Should -Not -BeNullOrEmpty + It "Does not emit an empty key list or empty INCLUDE" { + $suggestion.CreateStatement | Should -Not -Match "\(\s*\)" + $suggestion.CreateStatement | Should -Not -Match ",\s*\)" } - It "Generates a CREATE NONCLUSTERED INDEX statement" { - $results[0].CreateStatement | Should -Match "CREATE NONCLUSTERED INDEX" + It "Returns the impact-score inputs as properties" { + $suggestion.PSObject.Properties.Name | Should -Contain "AvgTotalUserCost" + $suggestion.PSObject.Properties.Name | Should -Contain "AvgUserImpact" + $suggestion.PSObject.Properties.Name | Should -Contain "ImpactScore" } - It "Returns the impact-score inputs as properties" { - $results[0].PSObject.Properties.Name | Should -Contain "AvgTotalUserCost" - $results[0].PSObject.Properties.Name | Should -Contain "AvgUserImpact" - $results[0].PSObject.Properties.Name | Should -Contain "ImpactScore" + It "Leaves the caller's connection in its original database" { + $server = Connect-DbaInstance -SqlInstance $TestConfig.InstanceSingle + $before = $server.ConnectionContext.CurrentDatabase + $null = $server | Find-DbaDbMissingIndex -Database $db -MinimumImpact 0 -MinimumSeek 1 + $server.ConnectionContext.CurrentDatabase | Should -Be $before } } -} +} \ No newline at end of file From 49d90cc14da95e4ff4d0bb659650c87902b2b3c8 Mon Sep 17 00:00:00 2001 From: Deepesh Dhake Date: Sun, 11 Oct 2026 15:17:16 -0500 Subject: [PATCH 05/11] Add 'Find-DbaDbMissingIndex' --- dbatools.psd1 | 1 + 1 file changed, 1 insertion(+) diff --git a/dbatools.psd1 b/dbatools.psd1 index a60b02cc487..5c5e971e0d7 100644 --- a/dbatools.psd1 +++ b/dbatools.psd1 @@ -170,6 +170,7 @@ 'Find-DbaDbDisabledIndex', 'Find-DbaDbDuplicateIndex', 'Find-DbaDbGrowthEvent', + 'Find-DbaDbMissingIndex', 'Find-DbaDbQueryStoreRegression', 'Find-DbaDbUnusedIndex', 'Find-DbaInstance', From 9376968ec8db4b650e3221e25000eb9e725fd329 Mon Sep 17 00:00:00 2001 From: Deepesh Dhake Date: Sun, 11 Oct 2026 15:19:46 -0500 Subject: [PATCH 06/11] Add Find-DbaDbMissingIndex to command list --- dbatools.psm1 | 1 + 1 file changed, 1 insertion(+) diff --git a/dbatools.psm1 b/dbatools.psm1 index 60b1d5b2c58..28830b5c851 100644 --- a/dbatools.psm1 +++ b/dbatools.psm1 @@ -541,6 +541,7 @@ $script:xplat = @( 'Get-DbaQueryExecutionTime', 'Get-DbaTempdbUsage', 'Find-DbaDbGrowthEvent', + 'Find-DbaDbMissingIndex', 'Find-DbaDbQueryStoreRegression', 'Test-DbaLinkedServerConnection', 'Get-DbaDbFile', From 73accd57c78709392f14b85faaf9b5ee5cbc4a2b Mon Sep 17 00:00:00 2001 From: Deepesh Dhake Date: Mon, 12 Oct 2026 06:00:32 -0500 Subject: [PATCH 07/11] Make INCLUDE clause optional in missing-index test --- Find-DbaDbMissingIndex.Tests.ps1 | 94 ++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 Find-DbaDbMissingIndex.Tests.ps1 diff --git a/Find-DbaDbMissingIndex.Tests.ps1 b/Find-DbaDbMissingIndex.Tests.ps1 new file mode 100644 index 00000000000..4fc78bcfa19 --- /dev/null +++ b/Find-DbaDbMissingIndex.Tests.ps1 @@ -0,0 +1,94 @@ +#Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } +param( + $ModuleName = "dbatools", + $CommandName = "Find-DbaDbMissingIndex", + $PSDefaultParameterValues = $TestConfig.Defaults +) + +Describe $CommandName -Tag UnitTests { + Context "Parameter validation" { + It "Should have the expected parameters" { + $hasParameters = (Get-Command $CommandName).Parameters.Values.Name | Where-Object { $PSItem -notin ("WhatIf", "Confirm") } + $expectedParameters = $TestConfig.CommonParameters + $expectedParameters += @( + "SqlInstance", + "SqlCredential", + "Database", + "ExcludeDatabase", + "InputObject", + "MinimumImpact", + "MinimumSeek", + "EnableException" + ) + Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty + } + } +} + +Describe $CommandName -Tag IntegrationTests { + BeforeAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + + $db = "dbatoolsci_missingindex_$(Get-Random)" + $null = New-DbaDatabase -SqlInstance $TestConfig.InstanceSingle -Name $db + + # A clustered PK only, then enough rows that a predicate on an unindexed column produces a + # missing-index suggestion. A trivial plan never generates one, so AND 1 = (SELECT 1) forces + # the predicate to full optimization (the behaviour the maintainer confirmed in #10770). + $setup = @" +CREATE TABLE dbo.Orders (OrderId int IDENTITY(1,1) PRIMARY KEY, CustomerId int, Amount money, Filler char(200) DEFAULT 'x'); +INSERT INTO dbo.Orders (CustomerId, Amount) +SELECT TOP (20000) ABS(CHECKSUM(NEWID())) % 1000, ABS(CHECKSUM(NEWID())) % 10000 +FROM sys.all_objects a CROSS JOIN sys.all_objects b; +"@ + $null = Invoke-DbaQuery -SqlInstance $TestConfig.InstanceSingle -Database $db -Query $setup + + $runWorkload = "SELECT OrderId, Amount FROM dbo.Orders WHERE CustomerId = 42 AND 1 = (SELECT 1);" + 1..5 | ForEach-Object { $null = Invoke-DbaQuery -SqlInstance $TestConfig.InstanceSingle -Database $db -Query $runWorkload } + + # Low thresholds so the single-seek fixture is not filtered out by the defaults. + $results = Find-DbaDbMissingIndex -SqlInstance $TestConfig.InstanceSingle -Database $db -MinimumImpact 0 -MinimumSeek 1 + $suggestion = $results | Where-Object { $PSItem.EqualityColumns -match "CustomerId" } | Select-Object -First 1 + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + } + + AfterAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + $null = Remove-DbaDatabase -SqlInstance $TestConfig.InstanceSingle -Database $db -ErrorAction SilentlyContinue + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + } + + Context "Finds a seeded missing index" { + It "Returns a suggestion keyed on CustomerId" { + $suggestion | Should -Not -BeNullOrEmpty + } + + It "Generates the exact CREATE statement for the fixture" { + # Assert the complete statement. The index name carries the index_handle (a per-table + # integer), so that one segment is matched as \d+. The optimizer may or may not add + # [Amount] as an included column for this small fixture, so the INCLUDE clause is + # optional; everything else - verb, name shape, schema, table and the single key + # column - must match exactly, which is what catches a malformed statement. + $suggestion.CreateStatement | Should -Match "^CREATE NONCLUSTERED INDEX \[IX_Orders_CustomerId_\d+\] ON \[dbo\]\.\[Orders\] \(\[CustomerId\]\)( INCLUDE \(\[Amount\]\))?;$" + } + + It "Does not emit an empty key list or empty INCLUDE" { + $suggestion.CreateStatement | Should -Not -Match "\(\s*\)" + $suggestion.CreateStatement | Should -Not -Match ",\s*\)" + } + + It "Returns the impact-score inputs as properties" { + $suggestion.PSObject.Properties.Name | Should -Contain "AvgTotalUserCost" + $suggestion.PSObject.Properties.Name | Should -Contain "AvgUserImpact" + $suggestion.PSObject.Properties.Name | Should -Contain "ImpactScore" + } + + It "Leaves the caller's connection in its original database" { + $server = Connect-DbaInstance -SqlInstance $TestConfig.InstanceSingle + $before = $server.ConnectionContext.CurrentDatabase + $null = $server | Find-DbaDbMissingIndex -Database $db -MinimumImpact 0 -MinimumSeek 1 + $server.ConnectionContext.CurrentDatabase | Should -Be $before + } + } +} \ No newline at end of file From 30474179c521de0f1302dcd699643442d7867edc Mon Sep 17 00:00:00 2001 From: Deepesh Dhake Date: Mon, 12 Oct 2026 09:50:33 -0500 Subject: [PATCH 08/11] Update regex for CREATE statement in tests --- tests/Find-DbaDbMissingIndex.Tests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Find-DbaDbMissingIndex.Tests.ps1 b/tests/Find-DbaDbMissingIndex.Tests.ps1 index 5f56521e5ad..3ac1b968dff 100644 --- a/tests/Find-DbaDbMissingIndex.Tests.ps1 +++ b/tests/Find-DbaDbMissingIndex.Tests.ps1 @@ -67,7 +67,7 @@ FROM sys.all_objects a CROSS JOIN sys.all_objects b; It "Generates the exact CREATE statement for the fixture" { # The index name carries the index_handle (a per-table integer), so assert the stable # parts around it rather than the whole handle value. - $suggestion.CreateStatement | Should -Match "^CREATE NONCLUSTERED INDEX \[IX_Orders_CustomerId_\d+\] ON \[dbo\]\.\[Orders\] \(\[CustomerId\]\) INCLUDE \(\[Amount\]\);$" + $suggestion.CreateStatement | Should -Match "^CREATE NONCLUSTERED INDEX \[IX_Orders_CustomerId_\d+\] ON \[dbo\]\.\[Orders\] \(\[CustomerId\]\)( INCLUDE \(\[Amount\]\))?;$" } It "Does not emit an empty key list or empty INCLUDE" { @@ -88,4 +88,4 @@ FROM sys.all_objects a CROSS JOIN sys.all_objects b; $server.ConnectionContext.CurrentDatabase | Should -Be $before } } -} \ No newline at end of file +} From 14fef07f927b886405a634e4e2c761d2331402de Mon Sep 17 00:00:00 2001 From: Deepesh Dhake Date: Mon, 12 Oct 2026 09:50:56 -0500 Subject: [PATCH 09/11] Delete Find-DbaDbMissingIndex.Tests.ps1 --- Find-DbaDbMissingIndex.Tests.ps1 | 94 -------------------------------- 1 file changed, 94 deletions(-) delete mode 100644 Find-DbaDbMissingIndex.Tests.ps1 diff --git a/Find-DbaDbMissingIndex.Tests.ps1 b/Find-DbaDbMissingIndex.Tests.ps1 deleted file mode 100644 index 4fc78bcfa19..00000000000 --- a/Find-DbaDbMissingIndex.Tests.ps1 +++ /dev/null @@ -1,94 +0,0 @@ -#Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } -param( - $ModuleName = "dbatools", - $CommandName = "Find-DbaDbMissingIndex", - $PSDefaultParameterValues = $TestConfig.Defaults -) - -Describe $CommandName -Tag UnitTests { - Context "Parameter validation" { - It "Should have the expected parameters" { - $hasParameters = (Get-Command $CommandName).Parameters.Values.Name | Where-Object { $PSItem -notin ("WhatIf", "Confirm") } - $expectedParameters = $TestConfig.CommonParameters - $expectedParameters += @( - "SqlInstance", - "SqlCredential", - "Database", - "ExcludeDatabase", - "InputObject", - "MinimumImpact", - "MinimumSeek", - "EnableException" - ) - Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty - } - } -} - -Describe $CommandName -Tag IntegrationTests { - BeforeAll { - $PSDefaultParameterValues["*-Dba*:EnableException"] = $true - - $db = "dbatoolsci_missingindex_$(Get-Random)" - $null = New-DbaDatabase -SqlInstance $TestConfig.InstanceSingle -Name $db - - # A clustered PK only, then enough rows that a predicate on an unindexed column produces a - # missing-index suggestion. A trivial plan never generates one, so AND 1 = (SELECT 1) forces - # the predicate to full optimization (the behaviour the maintainer confirmed in #10770). - $setup = @" -CREATE TABLE dbo.Orders (OrderId int IDENTITY(1,1) PRIMARY KEY, CustomerId int, Amount money, Filler char(200) DEFAULT 'x'); -INSERT INTO dbo.Orders (CustomerId, Amount) -SELECT TOP (20000) ABS(CHECKSUM(NEWID())) % 1000, ABS(CHECKSUM(NEWID())) % 10000 -FROM sys.all_objects a CROSS JOIN sys.all_objects b; -"@ - $null = Invoke-DbaQuery -SqlInstance $TestConfig.InstanceSingle -Database $db -Query $setup - - $runWorkload = "SELECT OrderId, Amount FROM dbo.Orders WHERE CustomerId = 42 AND 1 = (SELECT 1);" - 1..5 | ForEach-Object { $null = Invoke-DbaQuery -SqlInstance $TestConfig.InstanceSingle -Database $db -Query $runWorkload } - - # Low thresholds so the single-seek fixture is not filtered out by the defaults. - $results = Find-DbaDbMissingIndex -SqlInstance $TestConfig.InstanceSingle -Database $db -MinimumImpact 0 -MinimumSeek 1 - $suggestion = $results | Where-Object { $PSItem.EqualityColumns -match "CustomerId" } | Select-Object -First 1 - - $PSDefaultParameterValues.Remove("*-Dba*:EnableException") - } - - AfterAll { - $PSDefaultParameterValues["*-Dba*:EnableException"] = $true - $null = Remove-DbaDatabase -SqlInstance $TestConfig.InstanceSingle -Database $db -ErrorAction SilentlyContinue - $PSDefaultParameterValues.Remove("*-Dba*:EnableException") - } - - Context "Finds a seeded missing index" { - It "Returns a suggestion keyed on CustomerId" { - $suggestion | Should -Not -BeNullOrEmpty - } - - It "Generates the exact CREATE statement for the fixture" { - # Assert the complete statement. The index name carries the index_handle (a per-table - # integer), so that one segment is matched as \d+. The optimizer may or may not add - # [Amount] as an included column for this small fixture, so the INCLUDE clause is - # optional; everything else - verb, name shape, schema, table and the single key - # column - must match exactly, which is what catches a malformed statement. - $suggestion.CreateStatement | Should -Match "^CREATE NONCLUSTERED INDEX \[IX_Orders_CustomerId_\d+\] ON \[dbo\]\.\[Orders\] \(\[CustomerId\]\)( INCLUDE \(\[Amount\]\))?;$" - } - - It "Does not emit an empty key list or empty INCLUDE" { - $suggestion.CreateStatement | Should -Not -Match "\(\s*\)" - $suggestion.CreateStatement | Should -Not -Match ",\s*\)" - } - - It "Returns the impact-score inputs as properties" { - $suggestion.PSObject.Properties.Name | Should -Contain "AvgTotalUserCost" - $suggestion.PSObject.Properties.Name | Should -Contain "AvgUserImpact" - $suggestion.PSObject.Properties.Name | Should -Contain "ImpactScore" - } - - It "Leaves the caller's connection in its original database" { - $server = Connect-DbaInstance -SqlInstance $TestConfig.InstanceSingle - $before = $server.ConnectionContext.CurrentDatabase - $null = $server | Find-DbaDbMissingIndex -Database $db -MinimumImpact 0 -MinimumSeek 1 - $server.ConnectionContext.CurrentDatabase | Should -Be $before - } - } -} \ No newline at end of file From 994cc53ad94fb3ff8fce67e5f6bbf062f674f90c Mon Sep 17 00:00:00 2001 From: Deepesh Dhake Date: Mon, 12 Oct 2026 21:41:57 -0500 Subject: [PATCH 10/11] Add files via upload --- public/Find-DbaDbMissingIndex.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/public/Find-DbaDbMissingIndex.ps1 b/public/Find-DbaDbMissingIndex.ps1 index 18267972c49..eafd9025f15 100644 --- a/public/Find-DbaDbMissingIndex.ps1 +++ b/public/Find-DbaDbMissingIndex.ps1 @@ -203,7 +203,10 @@ SELECT FROM ix WHERE ix.object_id = mid.object_id AND ( - ',' + REPLACE(REPLACE(REPLACE(ISNULL(mid.equality_columns, '') + ',' + ISNULL(mid.inequality_columns, ''), '[', ''), ']', ''), ' ', '') + -- Wrap the candidate key list in commas on BOTH ends so every column, including + -- the last one (e.g. an inequality-only key like '[OrderDate]'), is delimited on + -- both sides and can match the leading-column pattern below. + ',' + REPLACE(REPLACE(REPLACE(ISNULL(mid.equality_columns, '') + ',' + ISNULL(mid.inequality_columns, ''), '[', ''), ']', ''), ' ', '') + ',' LIKE '%,' + REPLACE(ix.leading_column, ' ', '') + ',%' ) FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 2, '') AS OverlappingIndex From 36444c6e85ff4820e1eb622b75fc5f62fa428e0f Mon Sep 17 00:00:00 2001 From: Deepesh Dhake Date: Mon, 12 Oct 2026 21:42:41 -0500 Subject: [PATCH 11/11] Add files via upload --- tests/Find-DbaDbMissingIndex.Tests.ps1 | 43 +++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/tests/Find-DbaDbMissingIndex.Tests.ps1 b/tests/Find-DbaDbMissingIndex.Tests.ps1 index 3ac1b968dff..1612be9904f 100644 --- a/tests/Find-DbaDbMissingIndex.Tests.ps1 +++ b/tests/Find-DbaDbMissingIndex.Tests.ps1 @@ -46,9 +46,27 @@ FROM sys.all_objects a CROSS JOIN sys.all_objects b; $runWorkload = "SELECT OrderId, Amount FROM dbo.Orders WHERE CustomerId = 42 AND 1 = (SELECT 1);" 1..5 | ForEach-Object { $null = Invoke-DbaQuery -SqlInstance $TestConfig.InstanceSingle -Database $db -Query $runWorkload } - # Low thresholds so the single-seek fixture is not filtered out by the defaults. + # Second table for the overlap case: an index already leads on OrderDate, and a range query + # over OrderDate (projecting another column) produces an INEQUALITY-ONLY suggestion keyed on + # OrderDate. The command should then report that existing index in OverlappingIndex - this is + # the range-only overlap that the first fixture (equality-only, no secondary index) can't cover. + $overlapSetup = @" +CREATE TABLE dbo.Events (EventId int IDENTITY(1,1) PRIMARY KEY, OrderDate datetime2 NOT NULL, Payload char(200) NOT NULL DEFAULT 'x'); +CREATE NONCLUSTERED INDEX IX_Events_OrderDate ON dbo.Events (OrderDate); +INSERT INTO dbo.Events (OrderDate) +SELECT TOP (20000) DATEADD(MINUTE, ABS(CHECKSUM(NEWID())) % 1000000, '2020-01-01') +FROM sys.all_objects a CROSS JOIN sys.all_objects b; +"@ + $null = Invoke-DbaQuery -SqlInstance $TestConfig.InstanceSingle -Database $db -Query $overlapSetup + + # Range predicate on OrderDate + project Payload -> inequality-only suggestion on OrderDate. + $overlapWorkload = "SELECT Payload FROM dbo.Events WHERE OrderDate > '2020-06-01' AND 1 = (SELECT 1);" + 1..5 | ForEach-Object { $null = Invoke-DbaQuery -SqlInstance $TestConfig.InstanceSingle -Database $db -Query $overlapWorkload } + + # Low thresholds so the single-seek fixtures are not filtered out by the defaults. $results = Find-DbaDbMissingIndex -SqlInstance $TestConfig.InstanceSingle -Database $db -MinimumImpact 0 -MinimumSeek 1 $suggestion = $results | Where-Object { $PSItem.EqualityColumns -match "CustomerId" } | Select-Object -First 1 + $overlapSuggestion = $results | Where-Object { $PSItem.InequalityColumns -match "OrderDate" } | Select-Object -First 1 $PSDefaultParameterValues.Remove("*-Dba*:EnableException") } @@ -65,8 +83,11 @@ FROM sys.all_objects a CROSS JOIN sys.all_objects b; } It "Generates the exact CREATE statement for the fixture" { - # The index name carries the index_handle (a per-table integer), so assert the stable - # parts around it rather than the whole handle value. + # Assert the complete statement. The index name carries the index_handle (a per-table + # integer), so that one segment is matched as \d+. The optimizer may or may not add + # [Amount] as an included column for this small fixture, so the INCLUDE clause is + # optional; everything else - verb, name shape, schema, table and the single key + # column - must match exactly, which is what catches a malformed statement. $suggestion.CreateStatement | Should -Match "^CREATE NONCLUSTERED INDEX \[IX_Orders_CustomerId_\d+\] ON \[dbo\]\.\[Orders\] \(\[CustomerId\]\)( INCLUDE \(\[Amount\]\))?;$" } @@ -88,4 +109,18 @@ FROM sys.all_objects a CROSS JOIN sys.all_objects b; $server.ConnectionContext.CurrentDatabase | Should -Be $before } } -} + + Context "Flags an overlapping index for an inequality-only suggestion" { + It "Returns an inequality-only suggestion keyed on OrderDate" { + $overlapSuggestion | Should -Not -BeNullOrEmpty + $overlapSuggestion.InequalityColumns | Should -Match "OrderDate" + $overlapSuggestion.EqualityColumns | Should -BeNullOrEmpty + } + + It "Names the existing OrderDate index in OverlappingIndex" { + # This is the regression for the overlap-matching fix: the final (here, only) key column + # of a range-only suggestion must still match an existing index leading on that column. + $overlapSuggestion.OverlappingIndex | Should -Match "IX_Events_OrderDate" + } + } +} \ No newline at end of file