diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
index 758b21f..20c2f54 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
@@ -67,6 +67,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -330,6 +363,16 @@
Foreground="{DynamicResource ForegroundBrush}"/>
+
+
+
+
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
index 08f3ee1..71c779a 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
@@ -44,6 +44,8 @@ public partial class QueryStoreGridControl : UserControl
private bool _waitStatsSupported; // false until version + capture mode confirmed
private bool _waitStatsEnabled = true;
private bool _waitPercentMode;
+ private QueryStoreGroupBy _groupByMode = QueryStoreGroupBy.None;
+ private List _groupedRootRows = new(); // top-level rows for grouped mode
public event EventHandler>? PlansSelected;
public event EventHandler? DatabaseChanged;
@@ -185,8 +187,10 @@ private async System.Threading.Tasks.Task FetchPlansForRangeAsync()
StatusText.Text = "Fetching plans...";
GridLoadingOverlay.IsVisible = true;
GridLoadingText.Text = "Fetching plans...";
+ GridEmptyMessage.IsVisible = false;
_rows.Clear();
_filteredRows.Clear();
+ _groupedRootRows.Clear();
// Start global + ribbon wait stats early (they don't depend on plan results)
if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
@@ -194,28 +198,14 @@ private async System.Threading.Tasks.Task FetchPlansForRangeAsync()
try
{
- var plans = await QueryStoreService.FetchTopPlansAsync(
- _connectionString, topN, orderBy, filter: filter, ct: ct,
- startUtc: _slicerStartUtc, endUtc: _slicerEndUtc);
-
- GridLoadingOverlay.IsVisible = false;
-
- if (plans.Count == 0)
+ if (_groupByMode == QueryStoreGroupBy.None)
{
- StatusText.Text = "No Query Store data found for the selected range.";
- return;
+ await FetchFlatPlansAsync(topN, orderBy, filter, ct);
+ }
+ else
+ {
+ await FetchGroupedPlansAsync(topN, orderBy, filter, ct);
}
-
- foreach (var plan in plans)
- _rows.Add(new QueryStoreRow(plan));
-
- ApplyFilters();
- LoadButton.IsEnabled = true;
- SelectToggleButton.Content = "Select All";
-
- // Fetch per-plan wait stats after grid is populated (needs plan IDs)
- if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
- _ = FetchPerPlanWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct);
}
catch (OperationCanceledException)
{
@@ -232,6 +222,409 @@ private async System.Threading.Tasks.Task FetchPlansForRangeAsync()
}
}
+ private async System.Threading.Tasks.Task FetchFlatPlansAsync(
+ int topN, string orderBy, QueryStoreFilter? filter, CancellationToken ct)
+ {
+ var plans = await QueryStoreService.FetchTopPlansAsync(
+ _connectionString, topN, orderBy, filter: filter, ct: ct,
+ startUtc: _slicerStartUtc, endUtc: _slicerEndUtc);
+
+ GridLoadingOverlay.IsVisible = false;
+
+ if (plans.Count == 0)
+ {
+ StatusText.Text = "No Query Store data found for the selected range.";
+ return;
+ }
+
+ foreach (var plan in plans)
+ _rows.Add(new QueryStoreRow(plan));
+
+ ApplyFilters();
+ LoadButton.IsEnabled = true;
+ SelectToggleButton.Content = "Select All";
+
+ // Fetch per-plan wait stats after grid is populated (needs plan IDs)
+ if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
+ _ = FetchPerPlanWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct);
+ }
+
+ private async System.Threading.Tasks.Task FetchGroupedPlansAsync(
+ int topN, string orderBy, QueryStoreFilter? filter, CancellationToken ct)
+ {
+ QueryStoreGroupedResult grouped;
+ if (_groupByMode == QueryStoreGroupBy.QueryHash)
+ {
+ grouped = await QueryStoreService.FetchGroupedByQueryHashAsync(
+ _connectionString, topN, orderBy, filter, ct,
+ _slicerStartUtc, _slicerEndUtc);
+ }
+ else // Module
+ {
+ grouped = await QueryStoreService.FetchGroupedByModuleAsync(
+ _connectionString, topN, orderBy, filter, ct,
+ _slicerStartUtc, _slicerEndUtc);
+ }
+
+ GridLoadingOverlay.IsVisible = false;
+ GridEmptyMessage.IsVisible = false;
+
+ if (grouped.IntermediateRows.Count == 0)
+ {
+ if (_groupByMode == QueryStoreGroupBy.Module)
+ {
+ GridEmptyMessageText.Text = "No module found in the selected period";
+ GridEmptyMessage.IsVisible = true;
+ }
+ else
+ {
+ StatusText.Text = "No Query Store data found for the selected range.";
+ }
+ return;
+ }
+
+ var rootRows = BuildGroupedRows(grouped);
+
+ // Sort root rows by consolidated metric descending
+ var metricAccessor = GetMetricAccessor(orderBy);
+ rootRows = rootRows.OrderByDescending(r => metricAccessor(r)).ToList();
+ _groupedRootRows = rootRows;
+
+ // Flatten to _rows (all levels) and show only top-level in _filteredRows
+ foreach (var root in rootRows)
+ {
+ _rows.Add(root);
+ foreach (var mid in root.Children)
+ {
+ _rows.Add(mid);
+ foreach (var leaf in mid.Children)
+ _rows.Add(leaf);
+ }
+ }
+
+ // Show only root-level rows initially (collapsed)
+ _filteredRows.Clear();
+ foreach (var root in rootRows)
+ _filteredRows.Add(root);
+
+ LoadButton.IsEnabled = true;
+ SelectToggleButton.Content = "Select All";
+
+ // Auto-expand the first root row to the deepest level
+ if (rootRows.Count > 0)
+ {
+ var first = rootRows[0];
+ ExpandRowRecursive(first);
+ }
+
+ UpdateStatusText();
+ UpdateBarRatios();
+
+ // Fetch per-plan wait stats for leaf rows, then consolidate upward
+ if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
+ _ = FetchGroupedWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct);
+ }
+
+ ///
+ /// Recursively expands a row and all its children, inserting them into _filteredRows.
+ ///
+ private void ExpandRowRecursive(QueryStoreRow row)
+ {
+ if (!row.HasChildren) return;
+ row.IsExpanded = true;
+
+ var idx = _filteredRows.IndexOf(row);
+ if (idx < 0) return;
+
+ var insertAt = idx + 1;
+ foreach (var child in row.Children)
+ {
+ _filteredRows.Insert(insertAt, child);
+ insertAt++;
+ }
+
+ // Recurse into each child that has children
+ foreach (var child in row.Children)
+ ExpandRowRecursive(child);
+ }
+
+ ///
+ /// Fetches per-plan wait stats for all real plan IDs found in the grouped hierarchy,
+ /// assigns them to leaf rows, then consolidates upward to intermediate and root rows.
+ ///
+ private async System.Threading.Tasks.Task FetchGroupedWaitStatsAsync(
+ DateTime startUtc, DateTime endUtc, CancellationToken ct)
+ {
+ try
+ {
+ // Collect all real plan IDs from rows that have a real PlanId
+ var allPlanIds = _rows
+ .Where(r => r.PlanId > 0)
+ .Select(r => r.PlanId)
+ .Distinct()
+ .ToList();
+
+ if (allPlanIds.Count == 0) return;
+
+ var planWaits = await QueryStoreService.FetchPlanWaitStatsAsync(
+ _connectionString, startUtc, endUtc, allPlanIds, ct);
+ if (ct.IsCancellationRequested) return;
+
+ // Build lookup: plan_id → list of WaitCategoryTotal
+ var byPlan = planWaits
+ .GroupBy(x => x.PlanId)
+ .ToDictionary(g => g.Key, g => g.Select(x => x.Wait).ToList());
+
+ // 1. Assign raw waits + profiles to rows with a real PlanId
+ foreach (var row in _rows)
+ {
+ if (row.PlanId > 0 && byPlan.TryGetValue(row.PlanId, out var waits))
+ {
+ row.RawWaitCategories = waits;
+ row.WaitProfile = QueryStoreService.BuildWaitProfile(waits);
+ }
+ }
+
+ // 2. Consolidate upward through the hierarchy
+ foreach (var root in _groupedRootRows)
+ ConsolidateWaitProfileUpward(root);
+
+ UpdateWaitBarMode();
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception) { }
+ }
+
+ ///
+ /// Recursively consolidates wait profiles from children into their parent.
+ /// For each parent: merges all children's RawWaitCategories by summing WaitRatio
+ /// per category, then builds a new WaitProfile from the merged totals.
+ ///
+ private static void ConsolidateWaitProfileUpward(QueryStoreRow parent)
+ {
+ if (parent.Children.Count == 0) return;
+
+ // Recurse first so children are consolidated before we merge them
+ foreach (var child in parent.Children)
+ ConsolidateWaitProfileUpward(child);
+
+ // Merge all children's raw wait categories by summing WaitRatio per category
+ var merged = parent.Children
+ .SelectMany(c => c.RawWaitCategories)
+ .GroupBy(w => new { w.WaitCategory, w.WaitCategoryDesc })
+ .Select(g => new WaitCategoryTotal
+ {
+ WaitCategory = g.Key.WaitCategory,
+ WaitCategoryDesc = g.Key.WaitCategoryDesc,
+ WaitRatio = g.Sum(w => w.WaitRatio),
+ })
+ .ToList();
+
+ if (merged.Count > 0)
+ {
+ parent.RawWaitCategories = merged;
+ parent.WaitProfile = QueryStoreService.BuildWaitProfile(merged);
+ }
+ }
+
+ /// Maps an orderBy metric string to a Func that extracts the sort value from a QueryStoreRow.
+ private static Func GetMetricAccessor(string orderBy) => orderBy.ToLowerInvariant() switch
+ {
+ "cpu" => r => r.TotalCpuSort,
+ "avg-cpu" => r => r.AvgCpuSort,
+ "duration" => r => r.TotalDurSort,
+ "avg-duration" => r => r.AvgDurSort,
+ "reads" => r => r.TotalReadsSort,
+ "avg-reads" => r => r.AvgReadsSort,
+ "writes" => r => r.TotalWritesSort,
+ "avg-writes" => r => r.AvgWritesSort,
+ "physical-reads" => r => r.TotalPhysReadsSort,
+ "avg-physical-reads" => r => r.AvgPhysReadsSort,
+ "memory" => r => r.TotalMemSort,
+ "avg-memory" => r => r.AvgMemSort,
+ "executions" => r => r.ExecsSort,
+ _ => r => r.TotalCpuSort,
+ };
+
+ private List BuildGroupedRows(QueryStoreGroupedResult grouped)
+ {
+ var roots = new List();
+ var metricAccessor = GetMetricAccessor(_lastFetchedOrderBy);
+
+ if (_groupByMode == QueryStoreGroupBy.QueryHash)
+ {
+ // Level 0: QueryHash groups
+ var queryHashGroups = grouped.IntermediateRows
+ .GroupBy(r => r.QueryHash)
+ .ToList();
+
+ foreach (var qhGroup in queryHashGroups)
+ {
+ var qhKey = qhGroup.Key;
+ var intermediateRows = qhGroup.ToList();
+
+ // Build level-1 children (PlanHash)
+ var midChildren = new List();
+ foreach (var mid in intermediateRows)
+ {
+ // Build level-2 children (QueryId/PlanId)
+ var leafChildren = new List();
+ var leaves = grouped.LeafRows
+ .Where(l => l.QueryHash == mid.QueryHash && l.QueryPlanHash == mid.QueryPlanHash)
+ .ToList();
+ foreach (var leaf in leaves)
+ {
+ var leafPlan = GroupedRowToPlan(leaf);
+ leafChildren.Add(new QueryStoreRow(leafPlan, 2,
+ $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List()));
+ }
+
+ // Sort leaf children by metric descending
+ leafChildren = leafChildren.OrderByDescending(r => metricAccessor(r)).ToList();
+
+ var midPlan = GroupedRowToPlan(mid);
+ // Populate QueryText from the top representative leaf for this plan hash
+ var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault();
+ if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText))
+ midPlan.QueryText = topLeafForMid.QueryText;
+ midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryPlanHash, leafChildren));
+ }
+
+ // Sort mid children by metric descending
+ midChildren = midChildren.OrderByDescending(r => metricAccessor(r)).ToList();
+
+ // Aggregate metrics at QueryHash level
+ var aggPlan = AggregateGroupedRows(intermediateRows, qhKey, intermediateRows.FirstOrDefault()?.ModuleName ?? "");
+ // Populate QueryText from the top representative leaf across all leaves in this query hash group
+ var topLeafForRoot = grouped.LeafRows
+ .Where(l => l.QueryHash == qhKey && l.IsTopRepresentative && !string.IsNullOrEmpty(l.QueryText))
+ .FirstOrDefault()
+ ?? grouped.LeafRows.FirstOrDefault(l => l.QueryHash == qhKey && !string.IsNullOrEmpty(l.QueryText));
+ if (topLeafForRoot != null)
+ aggPlan.QueryText = topLeafForRoot.QueryText;
+ roots.Add(new QueryStoreRow(aggPlan, 0, qhKey, midChildren));
+ }
+ }
+ else // Module
+ {
+ // Level 0: Module groups
+ var moduleGroups = grouped.IntermediateRows
+ .GroupBy(r => r.ModuleName)
+ .ToList();
+
+ foreach (var modGroup in moduleGroups)
+ {
+ var modKey = modGroup.Key;
+ var intermediateRows = modGroup.ToList();
+
+ // Build level-1 children (QueryHash)
+ var midChildren = new List();
+ foreach (var mid in intermediateRows)
+ {
+ // Build level-2 children (QueryId/PlanId)
+ var leafChildren = new List();
+ var leaves = grouped.LeafRows
+ .Where(l => l.ModuleName == mid.ModuleName && l.QueryHash == mid.QueryHash)
+ .ToList();
+ foreach (var leaf in leaves)
+ {
+ var leafPlan = GroupedRowToPlan(leaf);
+ leafChildren.Add(new QueryStoreRow(leafPlan, 2,
+ $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List()));
+ }
+
+ // Sort leaf children by metric descending
+ leafChildren = leafChildren.OrderByDescending(r => metricAccessor(r)).ToList();
+
+ var midPlan = GroupedRowToPlan(mid);
+ // Populate QueryText from the top representative leaf for this query hash
+ var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault();
+ if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText))
+ midPlan.QueryText = topLeafForMid.QueryText;
+ midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryHash, leafChildren));
+ }
+
+ // Sort mid children by metric descending
+ midChildren = midChildren.OrderByDescending(r => metricAccessor(r)).ToList();
+
+ // Aggregate metrics at Module level
+ var aggPlan = AggregateGroupedRows(intermediateRows, "", modKey);
+ // Populate QueryText from the top representative leaf across all leaves in this module group
+ var topLeafForRoot = grouped.LeafRows
+ .Where(l => l.ModuleName == modKey && l.IsTopRepresentative && !string.IsNullOrEmpty(l.QueryText))
+ .FirstOrDefault()
+ ?? grouped.LeafRows.FirstOrDefault(l => l.ModuleName == modKey && !string.IsNullOrEmpty(l.QueryText));
+ if (topLeafForRoot != null)
+ aggPlan.QueryText = topLeafForRoot.QueryText;
+ roots.Add(new QueryStoreRow(aggPlan, 0, modKey, midChildren));
+ }
+ }
+
+ return roots;
+ }
+
+ private static QueryStorePlan GroupedRowToPlan(QueryStoreGroupedPlanRow row)
+ {
+ var totalExecs = row.CountExecutions > 0 ? row.CountExecutions : 1;
+ return new QueryStorePlan
+ {
+ QueryId = row.QueryId,
+ PlanId = row.PlanId,
+ QueryHash = row.QueryHash,
+ QueryPlanHash = row.QueryPlanHash,
+ ModuleName = row.ModuleName,
+ QueryText = row.QueryText,
+ PlanXml = row.PlanXml,
+ CountExecutions = row.CountExecutions,
+ TotalCpuTimeUs = row.TotalCpuTimeUs,
+ TotalDurationUs = row.TotalDurationUs,
+ TotalLogicalIoReads = row.TotalLogicalIoReads,
+ TotalLogicalIoWrites = row.TotalLogicalIoWrites,
+ TotalPhysicalIoReads = row.TotalPhysicalIoReads,
+ TotalMemoryGrantPages = row.TotalMemoryGrantPages,
+ AvgCpuTimeUs = (double)row.TotalCpuTimeUs / totalExecs,
+ AvgDurationUs = (double)row.TotalDurationUs / totalExecs,
+ AvgLogicalIoReads = (double)row.TotalLogicalIoReads / totalExecs,
+ AvgLogicalIoWrites = (double)row.TotalLogicalIoWrites / totalExecs,
+ AvgPhysicalIoReads = (double)row.TotalPhysicalIoReads / totalExecs,
+ AvgMemoryGrantPages = (double)row.TotalMemoryGrantPages / totalExecs,
+ LastExecutedUtc = row.LastExecutedUtc,
+ };
+ }
+
+ private static QueryStorePlan AggregateGroupedRows(List rows, string queryHash, string moduleName)
+ {
+ var totalExecs = rows.Sum(r => r.CountExecutions);
+ var safeExecs = totalExecs > 0 ? totalExecs : 1;
+ var totalCpu = rows.Sum(r => r.TotalCpuTimeUs);
+ var totalDur = rows.Sum(r => r.TotalDurationUs);
+ var totalReads = rows.Sum(r => r.TotalLogicalIoReads);
+ var totalWrites = rows.Sum(r => r.TotalLogicalIoWrites);
+ var totalPhysReads = rows.Sum(r => r.TotalPhysicalIoReads);
+ var totalMem = rows.Sum(r => r.TotalMemoryGrantPages);
+ var lastExec = rows.Max(r => r.LastExecutedUtc);
+
+ return new QueryStorePlan
+ {
+ QueryHash = queryHash,
+ ModuleName = moduleName,
+ CountExecutions = totalExecs,
+ TotalCpuTimeUs = totalCpu,
+ TotalDurationUs = totalDur,
+ TotalLogicalIoReads = totalReads,
+ TotalLogicalIoWrites = totalWrites,
+ TotalPhysicalIoReads = totalPhysReads,
+ TotalMemoryGrantPages = totalMem,
+ AvgCpuTimeUs = (double)totalCpu / safeExecs,
+ AvgDurationUs = (double)totalDur / safeExecs,
+ AvgLogicalIoReads = (double)totalReads / safeExecs,
+ AvgLogicalIoWrites = (double)totalWrites / safeExecs,
+ AvgPhysicalIoReads = (double)totalPhysReads / safeExecs,
+ AvgMemoryGrantPages = (double)totalMem / safeExecs,
+ LastExecutedUtc = lastExec,
+ };
+ }
+
private QueryStoreFilter? BuildSearchFilter()
{
var searchType = (SearchTypeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString();
@@ -282,6 +675,152 @@ private void SearchValue_KeyDown(object? sender, Avalonia.Input.KeyEventArgs e)
}
}
+ private int[]? _savedColumnDisplayIndices;
+
+ private void GroupBy_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (!_initialOrderByLoaded) return;
+ var tag = (GroupByBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "none";
+ var newMode = tag switch
+ {
+ "query-hash" => QueryStoreGroupBy.QueryHash,
+ "module" => QueryStoreGroupBy.Module,
+ _ => QueryStoreGroupBy.None,
+ };
+ if (newMode == _groupByMode) return;
+ _groupByMode = newMode;
+
+ // Show/hide the expand column (first column in the grid)
+ ResultsGrid.Columns[0].IsVisible = _groupByMode != QueryStoreGroupBy.None;
+
+ // Reorder columns: move the group key column right after expand+checkbox
+ ReorderColumnsForGroupBy();
+
+ // Re-fetch with new grouping
+ Fetch_Click(null, new RoutedEventArgs());
+ }
+
+ private void ReorderColumnsForGroupBy()
+ {
+ var cols = ResultsGrid.Columns;
+
+ if (_groupByMode == QueryStoreGroupBy.None)
+ {
+ // Restore original column order
+ if (_savedColumnDisplayIndices != null)
+ {
+ for (int i = 0; i < cols.Count && i < _savedColumnDisplayIndices.Length; i++)
+ cols[i].DisplayIndex = _savedColumnDisplayIndices[i];
+ _savedColumnDisplayIndices = null;
+ }
+ // Reset header colors
+ ApplyGroupByHeaderColors();
+ return;
+ }
+
+ // Save original order if not yet saved
+ _savedColumnDisplayIndices ??= cols.Select(c => c.DisplayIndex).ToArray();
+
+ // Column definition indices (AXAML order):
+ // 0=Expand, 1=Checkbox, 2=QueryId, 3=PlanId, 4=QueryHash, 5=PlanHash, 6=Module
+ if (_groupByMode == QueryStoreGroupBy.QueryHash)
+ {
+ // Order: Expand, Checkbox, QueryHash, PlanHash, QueryId, PlanId, ...
+ cols[4].DisplayIndex = 2; // QueryHash → 2
+ cols[5].DisplayIndex = 3; // PlanHash → 3
+ cols[2].DisplayIndex = 4; // QueryId → 4
+ cols[3].DisplayIndex = 5; // PlanId → 5
+ }
+ else // Module
+ {
+ // Order: Expand, Checkbox, Module, QueryHash, QueryId, PlanId, ...
+ cols[6].DisplayIndex = 2; // Module → 2
+ cols[4].DisplayIndex = 3; // QueryHash → 3
+ cols[2].DisplayIndex = 4; // QueryId → 4
+ cols[3].DisplayIndex = 5; // PlanId → 5
+ }
+
+ // Apply golden header colors for expandable columns
+ ApplyGroupByHeaderColors();
+ }
+
+ ///
+ /// Applies golden foreground to column headers that represent expandable/collapsible
+ /// grouping levels in the current GroupBy mode, and resets others.
+ ///
+ private void ApplyGroupByHeaderColors()
+ {
+ // Column definition indices: 4=QueryHash, 5=PlanHash, 6=Module
+ var goldenCols = _groupByMode switch
+ {
+ QueryStoreGroupBy.QueryHash => new HashSet { 4, 5 }, // QueryHash + PlanHash
+ QueryStoreGroupBy.Module => new HashSet { 6, 4 }, // Module + QueryHash
+ _ => new HashSet(),
+ };
+
+ var goldenBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xD7, 0x00)); // Gold
+
+ for (int i = 0; i < ResultsGrid.Columns.Count; i++)
+ {
+ var col = ResultsGrid.Columns[i];
+ if (col.Header is not StackPanel sp) continue;
+ var label = sp.Children.OfType().LastOrDefault();
+ if (label == null) continue;
+
+ if (goldenCols.Contains(i))
+ label.Foreground = goldenBrush;
+ else
+ label.ClearValue(TextBlock.ForegroundProperty);
+ }
+ }
+
+ private void ExpandRow_Click(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not Button btn) return;
+ if (btn.DataContext is not QueryStoreRow row) return;
+ if (!row.HasChildren) return;
+
+ row.IsExpanded = !row.IsExpanded;
+
+ if (row.IsExpanded)
+ {
+ // Insert children after this row in _filteredRows
+ var idx = _filteredRows.IndexOf(row);
+ if (idx < 0) return;
+ var insertAt = idx + 1;
+ foreach (var child in row.Children)
+ {
+ _filteredRows.Insert(insertAt, child);
+ insertAt++;
+ }
+
+ // Scroll the first child into view so the expansion is visible
+ if (row.Children.Count > 0)
+ ResultsGrid.ScrollIntoView(row.Children[0], null);
+ }
+ else
+ {
+ // Remove children (and their expanded children) recursively
+ CollapseRowChildren(row);
+ }
+
+ UpdateStatusText();
+ UpdateBarRatios();
+ }
+
+ private void CollapseRowChildren(QueryStoreRow parent)
+ {
+ foreach (var child in parent.Children)
+ {
+ if (child.IsExpanded)
+ {
+ child.IsExpanded = false;
+ CollapseRowChildren(child);
+ }
+ _filteredRows.Remove(child);
+ }
+ }
+
private async void OrderBy_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (!_initialOrderByLoaded) return;
@@ -462,7 +1001,10 @@ private async System.Threading.Tasks.Task FetchWaitStatsAsync(
DateTime startUtc, DateTime endUtc, CancellationToken ct)
{
await FetchGlobalWaitStatsOnlyAsync(startUtc, endUtc, ct);
- await FetchPerPlanWaitStatsAsync(startUtc, endUtc, ct);
+ if (_groupByMode != QueryStoreGroupBy.None)
+ await FetchGroupedWaitStatsAsync(startUtc, endUtc, ct);
+ else
+ await FetchPerPlanWaitStatsAsync(startUtc, endUtc, ct);
}
private void OnWaitCategoryClicked(object? sender, string category)
@@ -573,15 +1115,57 @@ private void SelectToggle_Click(object? sender, RoutedEventArgs e)
private void LoadSelected_Click(object? sender, RoutedEventArgs e)
{
- var selected = _filteredRows.Where(r => r.IsSelected).Select(r => r.Plan).ToList();
+ List selected;
+ if (_groupByMode != QueryStoreGroupBy.None)
+ {
+ // In grouped mode, expand selected grouped rows to their leaf plans
+ selected = _filteredRows
+ .Where(r => r.IsSelected)
+ .SelectMany(r => r.HasChildren ? CollectLeafPlans(r) : (r.PlanId > 0 && r.QueryId > 0 ? [r.Plan] : []))
+ .ToList();
+ }
+ else
+ {
+ selected = _filteredRows.Where(r => r.IsSelected).Select(r => r.Plan).ToList();
+ }
if (selected.Count > 0)
PlansSelected?.Invoke(this, selected);
}
private void LoadHighlightedPlan_Click(object? sender, RoutedEventArgs e)
{
- if (ResultsGrid.SelectedItem is QueryStoreRow row)
+ if (ResultsGrid.SelectedItem is not QueryStoreRow row) return;
+
+ // In grouped mode, load all descendant leaf plans with real IDs
+ if (_groupByMode != QueryStoreGroupBy.None && row.HasChildren)
+ {
+ var leafPlans = CollectLeafPlans(row);
+ if (leafPlans.Count > 0)
+ PlansSelected?.Invoke(this, leafPlans);
+ }
+ else if (row.PlanId > 0 && row.QueryId > 0)
+ {
PlansSelected?.Invoke(this, new List { row.Plan });
+ }
+ }
+
+ ///
+ /// Recursively collects all leaf-level plans (PlanId > 0 and QueryId > 0) from a grouped row and its descendants.
+ ///
+ private static List CollectLeafPlans(QueryStoreRow row)
+ {
+ var plans = new List();
+ if (row.Children.Count == 0)
+ {
+ if (row.PlanId > 0 && row.QueryId > 0)
+ plans.Add(row.Plan);
+ }
+ else
+ {
+ foreach (var child in row.Children)
+ plans.AddRange(CollectLeafPlans(child));
+ }
+ return plans;
}
private async void ViewHistory_Click(object? sender, RoutedEventArgs e)
@@ -698,27 +1282,28 @@ private async System.Threading.Tasks.Task SetClipboardTextAsync(string text)
private void SetupColumnHeaders()
{
var cols = ResultsGrid.Columns;
- SetColumnFilterButton(cols[1], "QueryId", "Query ID");
- SetColumnFilterButton(cols[2], "PlanId", "Plan ID");
- SetColumnFilterButton(cols[3], "QueryHash", "Query Hash");
- SetColumnFilterButton(cols[4], "PlanHash", "Plan Hash");
- SetColumnFilterButton(cols[5], "ModuleName", "Module");
- // cols[6] = WaitProfile (no filter button)
- SetColumnFilterButton(cols[7], "LastExecuted", "Last Executed (Local)");
- SetColumnFilterButton(cols[8], "Executions", "Executions");
- SetColumnFilterButton(cols[9], "TotalCpu", "Total CPU (ms)");
- SetColumnFilterButton(cols[10], "AvgCpu", "Avg CPU (ms)");
- SetColumnFilterButton(cols[11], "TotalDuration", "Total Duration (ms)");
- SetColumnFilterButton(cols[12], "AvgDuration", "Avg Duration (ms)");
- SetColumnFilterButton(cols[13], "TotalReads", "Total Reads");
- SetColumnFilterButton(cols[14], "AvgReads", "Avg Reads");
- SetColumnFilterButton(cols[15], "TotalWrites", "Total Writes");
- SetColumnFilterButton(cols[16], "AvgWrites", "Avg Writes");
- SetColumnFilterButton(cols[17], "TotalPhysReads", "Total Physical Reads");
- SetColumnFilterButton(cols[18], "AvgPhysReads", "Avg Physical Reads");
- SetColumnFilterButton(cols[19], "TotalMemory", "Total Memory (MB)");
- SetColumnFilterButton(cols[20], "AvgMemory", "Avg Memory (MB)");
- SetColumnFilterButton(cols[21], "QueryText", "Query Text");
+ // cols[0] = Expand column, cols[1] = Checkbox
+ SetColumnFilterButton(cols[2], "QueryId", "Query ID");
+ SetColumnFilterButton(cols[3], "PlanId", "Plan ID");
+ SetColumnFilterButton(cols[4], "QueryHash", "Query Hash");
+ SetColumnFilterButton(cols[5], "PlanHash", "Plan Hash");
+ SetColumnFilterButton(cols[6], "ModuleName", "Module");
+ // cols[7] = WaitProfile (no filter button)
+ SetColumnFilterButton(cols[8], "LastExecuted", "Last Executed (Local)");
+ SetColumnFilterButton(cols[9], "Executions", "Executions");
+ SetColumnFilterButton(cols[10], "TotalCpu", "Total CPU (ms)");
+ SetColumnFilterButton(cols[11], "AvgCpu", "Avg CPU (ms)");
+ SetColumnFilterButton(cols[12], "TotalDuration", "Total Duration (ms)");
+ SetColumnFilterButton(cols[13], "AvgDuration", "Avg Duration (ms)");
+ SetColumnFilterButton(cols[14], "TotalReads", "Total Reads");
+ SetColumnFilterButton(cols[15], "AvgReads", "Avg Reads");
+ SetColumnFilterButton(cols[16], "TotalWrites", "Total Writes");
+ SetColumnFilterButton(cols[17], "AvgWrites", "Avg Writes");
+ SetColumnFilterButton(cols[18], "TotalPhysReads", "Total Physical Reads");
+ SetColumnFilterButton(cols[19], "AvgPhysReads", "Avg Physical Reads");
+ SetColumnFilterButton(cols[20], "TotalMemory", "Total Memory (MB)");
+ SetColumnFilterButton(cols[21], "AvgMemory", "Avg Memory (MB)");
+ SetColumnFilterButton(cols[22], "QueryText", "Query Text");
}
private void SetColumnFilterButton(DataGridColumn col, string columnId, string label)
@@ -884,9 +1469,20 @@ private bool RowMatchesAllFilters(QueryStoreRow row)
private void UpdateStatusText()
{
if (_rows.Count == 0) return;
- StatusText.Text = _filteredRows.Count == _rows.Count
- ? $"{_rows.Count} plans"
- : $"{_filteredRows.Count} / {_rows.Count} plans (filtered)";
+ if (_groupByMode != QueryStoreGroupBy.None)
+ {
+ var rootCount = _groupedRootRows.Count;
+ var visibleRoots = _filteredRows.Count(r => r.IndentLevel == 0);
+ StatusText.Text = visibleRoots == rootCount
+ ? $"{rootCount} groups ({_rows.Count} total rows)"
+ : $"{visibleRoots} / {rootCount} groups (filtered)";
+ }
+ else
+ {
+ StatusText.Text = _filteredRows.Count == _rows.Count
+ ? $"{_rows.Count} plans"
+ : $"{_filteredRows.Count} / {_rows.Count} plans (filtered)";
+ }
}
private void ResultsGrid_Sorting(object? sender, DataGridColumnEventArgs e)
@@ -954,6 +1550,12 @@ private void ReapplyTopNSelection()
private void ApplySortAndFilters()
{
+ if (_groupByMode != QueryStoreGroupBy.None)
+ {
+ ApplySortAndFiltersGrouped();
+ return;
+ }
+
IEnumerable source = _rows.Where(RowMatchesAllFilters);
if (_sortedColumnTag != null)
@@ -972,6 +1574,40 @@ private void ApplySortAndFilters()
UpdateBarRatios();
}
+ private void ApplySortAndFiltersGrouped()
+ {
+ // In grouped mode, sort/filter only root rows and rebuild the visible list
+ IEnumerable source = _groupedRootRows.Where(RowMatchesAllFilters);
+
+ if (_sortedColumnTag != null)
+ {
+ source = _sortAscending
+ ? source.OrderBy(r => GetSortKey(_sortedColumnTag, r))
+ : source.OrderByDescending(r => GetSortKey(_sortedColumnTag, r));
+ }
+
+ _filteredRows.Clear();
+ foreach (var root in source)
+ {
+ _filteredRows.Add(root);
+ if (root.IsExpanded)
+ AddExpandedChildren(root);
+ }
+
+ UpdateStatusText();
+ UpdateBarRatios();
+ }
+
+ private void AddExpandedChildren(QueryStoreRow parent)
+ {
+ foreach (var child in parent.Children)
+ {
+ _filteredRows.Add(child);
+ if (child.IsExpanded)
+ AddExpandedChildren(child);
+ }
+ }
+
// ── Bar chart ratio computation ────────────────────────────────────────
// Maps a ColumnId (used in BarChartConfig) to the accessor that returns the raw sort value.
@@ -1098,13 +1734,64 @@ public class QueryStoreRow : INotifyPropertyChanged
private WaitProfile? _waitProfile;
private string? _waitHighlightCategory;
+ /// Raw wait category totals for this row. Used for upward consolidation in grouped mode.
+ public List RawWaitCategories { get; set; } = new();
+
+ // Hierarchy support
+ private bool _isExpanded;
+ private int _indentLevel;
+
+ /// Standard constructor for flat (ungrouped) rows.
public QueryStoreRow(QueryStorePlan plan)
{
Plan = plan;
}
+ /// Constructor for grouped parent/intermediate rows (aggregated, no single plan).
+ public QueryStoreRow(QueryStorePlan syntheticPlan, int indentLevel, string groupLabel, List children)
+ {
+ Plan = syntheticPlan;
+ _indentLevel = indentLevel;
+ GroupLabel = groupLabel;
+ Children = children;
+ }
+
public QueryStorePlan Plan { get; }
+ // ── Hierarchy properties ───────────────────────────────────────────────
+
+ /// Indentation level: 0 = top group, 1 = intermediate, 2 = leaf.
+ public int IndentLevel
+ {
+ get => _indentLevel;
+ set { _indentLevel = value; OnPropertyChanged(); }
+ }
+
+ /// Label shown for grouped rows (e.g. "0x1A2B3C" or "dbo.MyProc").
+ public string GroupLabel { get; set; } = "";
+
+ /// Direct children of this group row.
+ public List Children { get; set; } = new();
+
+ public bool HasChildren => Children.Count > 0;
+
+ public bool IsExpanded
+ {
+ get => _isExpanded;
+ set { _isExpanded = value; OnPropertyChanged(); OnPropertyChanged(nameof(ExpandChevron)); }
+ }
+
+ public string ExpandChevron => HasChildren ? (IsExpanded ? "▾" : "▸") : "";
+
+ /// Left margin that increases with indent level to visually show hierarchy.
+ public Avalonia.Thickness IndentMargin => new(IndentLevel * 20, 0, 0, 0);
+
+ /// Text shown next to the chevron: the group label for parent rows, or QueryId/PlanId for leaves.
+ public string GroupDisplayText => !string.IsNullOrEmpty(GroupLabel) ? GroupLabel : "";
+
+ /// Bold for top-level groups, normal for children.
+ public Avalonia.Media.FontWeight GroupFontWeight => IndentLevel == 0 ? Avalonia.Media.FontWeight.Bold : Avalonia.Media.FontWeight.Normal;
+
public bool IsSelected
{
get => _isSelected;
diff --git a/src/PlanViewer.Core/Models/QueryStoreGroupBy.cs b/src/PlanViewer.Core/Models/QueryStoreGroupBy.cs
new file mode 100644
index 0000000..6d62f70
--- /dev/null
+++ b/src/PlanViewer.Core/Models/QueryStoreGroupBy.cs
@@ -0,0 +1,65 @@
+namespace PlanViewer.Core.Models;
+
+///
+/// Specifies how Query Store grid results are grouped.
+///
+public enum QueryStoreGroupBy
+{
+ /// No grouping — one row per plan (existing behaviour).
+ None,
+ /// Group by query_hash → plan_hash → query_id/plan_id.
+ QueryHash,
+ /// Group by module → query_hash → query_id/plan_id.
+ Module,
+}
+
+///
+/// A row returned by the grouped query. Contains raw totals for app-side aggregation.
+/// Used as the leaf or intermediate row in the hierarchy; parent-level aggregation
+/// (SUM totals, recomputed averages) is done on the application side.
+///
+public class QueryStoreGroupedPlanRow
+{
+ // Grouping keys
+ public string ModuleName { get; set; } = "";
+ public string QueryHash { get; set; } = "";
+ public string QueryPlanHash { get; set; } = "";
+ public long QueryId { get; set; }
+ public long PlanId { get; set; }
+ public string QueryText { get; set; } = "";
+ public string PlanXml { get; set; } = "";
+
+ // Raw totals (aggregated across intervals for this plan_id / plan_hash level)
+ public long CountExecutions { get; set; }
+ public long TotalCpuTimeUs { get; set; }
+ public long TotalDurationUs { get; set; }
+ public long TotalLogicalIoReads { get; set; }
+ public long TotalLogicalIoWrites { get; set; }
+ public long TotalPhysicalIoReads { get; set; }
+ public long TotalMemoryGrantPages { get; set; }
+ public DateTime LastExecutedUtc { get; set; }
+
+ ///
+ /// Indicates whether this row is the "top" (true) or "bottom" (false) representative
+ /// for a query_hash/plan_hash pair. Only meaningful for leaf-level (QueryId/PlanId) rows.
+ ///
+ public bool IsTopRepresentative { get; set; }
+}
+
+///
+/// Complete result of a grouped fetch. Contains intermediate-level rows (per plan_hash)
+/// and leaf-level rows (per query_id/plan_id).
+///
+public class QueryStoreGroupedResult
+{
+ ///
+ /// Intermediate rows: one per (query_hash, plan_hash) or (module, query_hash).
+ /// These carry the aggregated metrics for that group.
+ ///
+ public List IntermediateRows { get; set; } = new();
+
+ ///
+ /// Leaf rows: top and bottom query_id/plan_id representatives per group.
+ ///
+ public List LeafRows { get; set; } = new();
+}
diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs
index 8b96fbd..cbbda7a 100644
--- a/src/PlanViewer.Core/Services/QueryStoreService.cs
+++ b/src/PlanViewer.Core/Services/QueryStoreService.cs
@@ -920,6 +920,575 @@ GROUP BY DATEADD(HOUR, DATEDIFF(HOUR, 0, rsi.start_time), 0),
return rows;
}
+ // ── Grouped fetches ──────────────────────────────────────────────────
+
+ ///
+ /// Helper: resolves the metric alias used inside aggregation temp tables.
+ /// Returns (planStatsColumn, aggAlias) where planStatsColumn references #plan_stats
+ /// and aggAlias is the column name used in GROUP BY aggregation selects.
+ ///
+ private static (string PlanStatsCol, string AggAlias) ResolveGroupMetric(string orderBy)
+ {
+ var key = orderBy.ToLowerInvariant();
+ return key switch
+ {
+ "cpu" or "avg-cpu" => ("total_cpu_us", "total_cpu_us"),
+ "duration" or "avg-duration" => ("total_duration_us", "total_duration_us"),
+ "reads" or "avg-reads" => ("total_reads", "total_reads"),
+ "writes" or "avg-writes" => ("total_writes", "total_writes"),
+ "physical-reads" or "avg-physical-reads" => ("total_physical_reads","total_physical_reads"),
+ "memory" or "avg-memory" => ("total_memory_pages", "total_memory_pages"),
+ "executions" => ("total_executions", "total_executions"),
+ _ => ("total_cpu_us", "total_cpu_us"),
+ };
+ }
+
+ ///
+ /// Fetches grouped-by-QueryHash results.
+ /// Step 1: Top X query hashes by metric.
+ /// Step 2: Top 5 plan hashes per query hash with metrics.
+ /// Step 3: Top and bottom QueryId/PlanId per query_hash/plan_hash.
+ /// Final : Fetch Query Text and Plan XML for the identified QueryId/PlanId.
+ /// Returns intermediate (plan_hash level) and leaf (query_id/plan_id level) rows.
+ ///
+ public static async Task FetchGroupedByQueryHashAsync(
+ string connectionString, int topN = 25, string orderBy = "cpu",
+ QueryStoreFilter? filter = null, CancellationToken ct = default,
+ DateTime? startUtc = null, DateTime? endUtc = null)
+ {
+ var (metricCol, _) = ResolveGroupMetric(orderBy);
+ var parameters = new List();
+
+ // Time-range filter
+ string intervalWhereClause;
+ if (startUtc.HasValue && endUtc.HasValue)
+ {
+ intervalWhereClause = "WHERE rsi.start_time >= @rangeStart AND rsi.start_time < @rangeEnd";
+ parameters.Add(new SqlParameter("@rangeStart", startUtc.Value));
+ parameters.Add(new SqlParameter("@rangeEnd", endUtc.Value));
+ }
+ else
+ {
+ intervalWhereClause = "WHERE rsi.start_time >= DATEADD(HOUR, -24, GETUTCDATE())";
+ }
+
+ // Filter clauses
+ var filterClauses = new List();
+ if (filter?.QueryId != null)
+ {
+ filterClauses.Add("AND q.query_id = @filterQueryId");
+ parameters.Add(new SqlParameter("@filterQueryId", filter.QueryId.Value));
+ }
+ if (!string.IsNullOrWhiteSpace(filter?.QueryHash))
+ {
+ filterClauses.Add("AND q.query_hash = CONVERT(binary(8), @filterQueryHash, 1)");
+ parameters.Add(new SqlParameter("@filterQueryHash", filter.QueryHash.Trim()));
+ }
+ if (!string.IsNullOrWhiteSpace(filter?.ModuleName))
+ {
+ var moduleVal = filter.ModuleName.Trim();
+ if (moduleVal.Contains('%'))
+ filterClauses.Add("AND OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) LIKE @filterModule");
+ else
+ filterClauses.Add("AND OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) = @filterModule");
+ parameters.Add(new SqlParameter("@filterModule", moduleVal));
+ }
+ var filterSql = filterClauses.Count > 0 ? "\n" + string.Join("\n", filterClauses) : "";
+
+ var sql = $@"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+/* Phase 1: Pre-filter matching interval IDs */
+DROP TABLE IF EXISTS #intervals;
+CREATE TABLE #intervals (runtime_stats_interval_id bigint NOT NULL PRIMARY KEY CLUSTERED);
+INSERT INTO #intervals (runtime_stats_interval_id)
+SELECT rsi.runtime_stats_interval_id
+FROM sys.query_store_runtime_stats_interval AS rsi
+{intervalWhereClause}
+OPTION (RECOMPILE);
+
+/* Phase 2: Aggregate runtime stats by plan_id */
+DROP TABLE IF EXISTS #plan_stats;
+CREATE TABLE #plan_stats (
+ plan_id bigint NOT NULL PRIMARY KEY CLUSTERED,
+ total_cpu_us float NOT NULL,
+ total_duration_us float NOT NULL,
+ total_reads float NOT NULL,
+ total_writes float NOT NULL,
+ total_physical_reads float NOT NULL,
+ total_memory_pages float NOT NULL,
+ total_executions bigint NOT NULL,
+ last_execution_time datetimeoffset NOT NULL
+);
+INSERT INTO #plan_stats
+SELECT
+ rs.plan_id,
+ SUM(rs.avg_cpu_time * rs.count_executions),
+ SUM(rs.avg_duration * rs.count_executions),
+ SUM(rs.avg_logical_io_reads * rs.count_executions),
+ SUM(rs.avg_logical_io_writes * rs.count_executions),
+ SUM(rs.avg_physical_io_reads * rs.count_executions),
+ SUM(rs.avg_query_max_used_memory * rs.count_executions),
+ SUM(rs.count_executions),
+ MAX(rs.last_execution_time)
+FROM sys.query_store_runtime_stats AS rs
+WHERE EXISTS (SELECT 1 FROM #intervals AS i WHERE i.runtime_stats_interval_id = rs.runtime_stats_interval_id)
+GROUP BY rs.plan_id
+OPTION (RECOMPILE);
+
+/* Step 1: Top X query hashes by metric */
+DROP TABLE IF EXISTS #top_hashes;
+;WITH qh AS (
+ SELECT
+ q.query_hash,
+ SUM(ps.{metricCol}) AS metric_total
+ FROM #plan_stats ps
+ JOIN sys.query_store_plan p ON ps.plan_id = p.plan_id
+ JOIN sys.query_store_query q ON p.query_id = q.query_id
+ WHERE 1=1{filterSql}
+ GROUP BY q.query_hash
+)
+SELECT TOP ({topN}) query_hash, metric_total
+INTO #top_hashes
+FROM qh
+ORDER BY metric_total DESC;
+
+/* Step 2: Top 5 plan hashes per query hash with metrics */
+DROP TABLE IF EXISTS #plan_hash_rows;
+;WITH ph AS (
+ SELECT
+ CONVERT(varchar(18), q.query_hash, 1) AS query_hash,
+ CONVERT(varchar(18), p.query_plan_hash, 1) AS plan_hash,
+ CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END AS module_name,
+ SUM(ps.total_cpu_us) AS total_cpu_us,
+ SUM(ps.total_duration_us) AS total_duration_us,
+ SUM(ps.total_reads) AS total_reads,
+ SUM(ps.total_writes) AS total_writes,
+ SUM(ps.total_physical_reads) AS total_physical_reads,
+ SUM(ps.total_memory_pages) AS total_memory_pages,
+ SUM(ps.total_executions) AS total_executions,
+ MAX(ps.last_execution_time) AS last_execution_time,
+ ROW_NUMBER() OVER (PARTITION BY q.query_hash ORDER BY SUM(ps.{metricCol}) DESC) AS rnum
+ FROM #plan_stats ps
+ JOIN sys.query_store_plan p ON ps.plan_id = p.plan_id
+ JOIN sys.query_store_query q ON p.query_id = q.query_id
+ WHERE EXISTS (SELECT 1 FROM #top_hashes th WHERE th.query_hash = q.query_hash)
+ GROUP BY q.query_hash, p.query_plan_hash,
+ CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END
+)
+SELECT query_hash, plan_hash, module_name,
+ CAST(total_cpu_us AS bigint) AS total_cpu_us,
+ CAST(total_duration_us AS bigint) AS total_duration_us,
+ CAST(total_reads AS bigint) AS total_reads,
+ CAST(total_writes AS bigint) AS total_writes,
+ CAST(total_physical_reads AS bigint) AS total_physical_reads,
+ CAST(total_memory_pages AS bigint) AS total_memory_pages,
+ total_executions,
+ last_execution_time
+INTO #plan_hash_rows
+FROM ph WHERE rnum <= 5;
+
+/* Step 3: Top and bottom QueryId/PlanId per query_hash/plan_hash */
+;WITH ranked AS (
+ SELECT
+ CONVERT(varchar(18), q.query_hash, 1) AS query_hash,
+ CONVERT(varchar(18), p.query_plan_hash, 1) AS plan_hash,
+ q.query_id,
+ ps.plan_id,
+ q.query_text_id,
+ CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END AS module_name,
+ CAST(ps.total_cpu_us AS bigint) AS total_cpu_us,
+ CAST(ps.total_duration_us AS bigint) AS total_duration_us,
+ CAST(ps.total_reads AS bigint) AS total_reads,
+ CAST(ps.total_writes AS bigint) AS total_writes,
+ CAST(ps.total_physical_reads AS bigint) AS total_physical_reads,
+ CAST(ps.total_memory_pages AS bigint) AS total_memory_pages,
+ ps.total_executions,
+ ps.last_execution_time,
+ ROW_NUMBER() OVER (PARTITION BY q.query_hash, p.query_plan_hash ORDER BY ps.{metricCol} DESC) AS rn_top,
+ ROW_NUMBER() OVER (PARTITION BY q.query_hash, p.query_plan_hash ORDER BY ps.{metricCol} ASC) AS rn_bottom
+ FROM #plan_stats ps
+ JOIN sys.query_store_plan p ON ps.plan_id = p.plan_id
+ JOIN sys.query_store_query q ON p.query_id = q.query_id
+ JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_id
+ WHERE EXISTS (SELECT 1 FROM #plan_hash_rows phr
+ WHERE phr.query_hash = CONVERT(varchar(18), q.query_hash, 1)
+ AND phr.plan_hash = CONVERT(varchar(18), p.query_plan_hash, 1))
+)
+SELECT *
+into #ranked_light
+FROM ranked
+WHERE rn_top = 1 OR rn_bottom = 1;
+
+/* Final select: join heavy elements (query_text, plan_xml) only for the top/bottom representatives */
+SELECT
+r.query_hash,
+r.plan_hash,
+r.query_id,
+r.plan_id,
+qt.query_sql_text,
+TRY_CONVERT(nvarchar(max), p.query_plan) AS plan_xml,
+r.module_name,
+r.total_cpu_us,
+r.total_duration_us,
+r.total_reads,
+r.total_writes,
+r.total_physical_reads,
+r.total_memory_pages,
+r.total_executions,
+r.last_execution_time,
+CASE WHEN r.rn_top = 1 THEN 1 ELSE 0 END AS is_top
+FROM #ranked_light r
+JOIN sys.query_store_query_text qt ON r.query_text_id = qt.query_text_id
+JOIN sys.query_store_plan p ON r.plan_id = p.plan_id;
+
+/* Return intermediate rows (result set 1) */
+SELECT * FROM #plan_hash_rows ORDER BY query_hash, total_executions DESC;
+";
+
+ var result = new QueryStoreGroupedResult();
+ await using var conn = new SqlConnection(connectionString);
+ await conn.OpenAsync(ct);
+ await using var cmd = new SqlCommand(sql, conn) { CommandTimeout = 120 };
+ foreach (var p in parameters) cmd.Parameters.Add(p);
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+
+ // Result set 1: Leaf rows (top/bottom per query_hash/plan_hash)
+ while (await reader.ReadAsync(ct))
+ {
+ result.LeafRows.Add(new QueryStoreGroupedPlanRow
+ {
+ QueryHash = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ QueryPlanHash = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ QueryId = reader.GetInt64(2),
+ PlanId = reader.GetInt64(3),
+ QueryText = reader.IsDBNull(4) ? "" : reader.GetString(4),
+ PlanXml = reader.IsDBNull(5) ? "" : reader.GetString(5),
+ ModuleName = reader.IsDBNull(6) ? "" : reader.GetString(6),
+ TotalCpuTimeUs = reader.GetInt64(7),
+ TotalDurationUs = reader.GetInt64(8),
+ TotalLogicalIoReads = reader.GetInt64(9),
+ TotalLogicalIoWrites = reader.GetInt64(10),
+ TotalPhysicalIoReads = reader.GetInt64(11),
+ TotalMemoryGrantPages = reader.GetInt64(12),
+ CountExecutions = reader.GetInt64(13),
+ LastExecutedUtc = ((DateTimeOffset)reader.GetValue(14)).UtcDateTime,
+ IsTopRepresentative = reader.GetInt32(15) == 1,
+ });
+ }
+
+ // Result set 2: Intermediate rows (plan_hash level aggregated)
+ if (await reader.NextResultAsync(ct))
+ {
+ while (await reader.ReadAsync(ct))
+ {
+ result.IntermediateRows.Add(new QueryStoreGroupedPlanRow
+ {
+ QueryHash = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ QueryPlanHash = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ ModuleName = reader.IsDBNull(2) ? "" : reader.GetString(2),
+ TotalCpuTimeUs = reader.GetInt64(3),
+ TotalDurationUs = reader.GetInt64(4),
+ TotalLogicalIoReads = reader.GetInt64(5),
+ TotalLogicalIoWrites = reader.GetInt64(6),
+ TotalPhysicalIoReads = reader.GetInt64(7),
+ TotalMemoryGrantPages = reader.GetInt64(8),
+ CountExecutions = reader.GetInt64(9),
+ LastExecutedUtc = ((DateTimeOffset)reader.GetValue(10)).UtcDateTime,
+ });
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Fetches grouped-by-Module results.
+ /// Step 1: Top X modules by metric.
+ /// Step 2: Top 5 query hashes per module with metrics.
+ /// Step 3: Top and bottom QueryId/PlanId per module/query_hash.
+ /// Final Step: Fetch Query Text and Plan XML for the identified QueryId/PlanId.
+ /// Returns intermediate (query_hash level) and leaf (query_id/plan_id level) rows.
+ ///
+ public static async Task FetchGroupedByModuleAsync(
+ string connectionString, int topN = 25, string orderBy = "cpu",
+ QueryStoreFilter? filter = null, CancellationToken ct = default,
+ DateTime? startUtc = null, DateTime? endUtc = null)
+ {
+ var (metricCol, _) = ResolveGroupMetric(orderBy);
+ var parameters = new List();
+
+ // Time-range filter
+ string intervalWhereClause;
+ if (startUtc.HasValue && endUtc.HasValue)
+ {
+ intervalWhereClause = "WHERE rsi.start_time >= @rangeStart AND rsi.start_time < @rangeEnd";
+ parameters.Add(new SqlParameter("@rangeStart", startUtc.Value));
+ parameters.Add(new SqlParameter("@rangeEnd", endUtc.Value));
+ }
+ else
+ {
+ intervalWhereClause = "WHERE rsi.start_time >= DATEADD(HOUR, -24, GETUTCDATE())";
+ }
+
+ // Filter clauses
+ var filterClauses = new List();
+ if (!string.IsNullOrWhiteSpace(filter?.ModuleName))
+ {
+ var moduleVal = filter.ModuleName.Trim();
+ if (moduleVal.Contains('%'))
+ filterClauses.Add("AND OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) LIKE @filterModule");
+ else
+ filterClauses.Add("AND OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) = @filterModule");
+ parameters.Add(new SqlParameter("@filterModule", moduleVal));
+ }
+ if (!string.IsNullOrWhiteSpace(filter?.QueryHash))
+ {
+ filterClauses.Add("AND q.query_hash = CONVERT(binary(8), @filterQueryHash, 1)");
+ parameters.Add(new SqlParameter("@filterQueryHash", filter.QueryHash.Trim()));
+ }
+ var filterSql = filterClauses.Count > 0 ? "\n" + string.Join("\n", filterClauses) : "";
+
+ var sql = $@"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+/* Phase 1: Pre-filter matching interval IDs */
+DROP TABLE IF EXISTS #intervals;
+CREATE TABLE #intervals (runtime_stats_interval_id bigint NOT NULL PRIMARY KEY CLUSTERED);
+INSERT INTO #intervals (runtime_stats_interval_id)
+SELECT rsi.runtime_stats_interval_id
+FROM sys.query_store_runtime_stats_interval AS rsi
+{intervalWhereClause}
+OPTION (RECOMPILE);
+
+/* Phase 2: Aggregate runtime stats by plan_id */
+DROP TABLE IF EXISTS #plan_stats;
+CREATE TABLE #plan_stats (
+ plan_id bigint NOT NULL PRIMARY KEY CLUSTERED,
+ total_cpu_us float NOT NULL,
+ total_duration_us float NOT NULL,
+ total_reads float NOT NULL,
+ total_writes float NOT NULL,
+ total_physical_reads float NOT NULL,
+ total_memory_pages float NOT NULL,
+ total_executions bigint NOT NULL,
+ last_execution_time datetimeoffset NOT NULL
+);
+INSERT INTO #plan_stats
+SELECT
+ rs.plan_id,
+ SUM(rs.avg_cpu_time * rs.count_executions),
+ SUM(rs.avg_duration * rs.count_executions),
+ SUM(rs.avg_logical_io_reads * rs.count_executions),
+ SUM(rs.avg_logical_io_writes * rs.count_executions),
+ SUM(rs.avg_physical_io_reads * rs.count_executions),
+ SUM(rs.avg_query_max_used_memory * rs.count_executions),
+ SUM(rs.count_executions),
+ MAX(rs.last_execution_time)
+FROM sys.query_store_runtime_stats AS rs
+WHERE EXISTS (SELECT 1 FROM #intervals AS i WHERE i.runtime_stats_interval_id = rs.runtime_stats_interval_id)
+GROUP BY rs.plan_id
+OPTION (RECOMPILE);
+
+/* Step 1: Top X modules by metric */
+DROP TABLE IF EXISTS #top_modules;
+;WITH md AS (
+ SELECT
+ CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END AS module_name,
+ SUM(ps.{metricCol}) AS metric_total
+ FROM #plan_stats ps
+ JOIN sys.query_store_plan p ON ps.plan_id = p.plan_id
+ JOIN sys.query_store_query q ON p.query_id = q.query_id
+ WHERE q.object_id <> 0{filterSql}
+ GROUP BY CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END
+)
+SELECT TOP ({topN}) module_name, metric_total
+INTO #top_modules
+FROM md
+ORDER BY metric_total DESC;
+
+/* Step 2: Top 5 query hashes per module with metrics */
+DROP TABLE IF EXISTS #qhash_rows;
+;WITH qh AS (
+ SELECT
+ CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END AS module_name,
+ CONVERT(varchar(18), q.query_hash, 1) AS query_hash,
+ SUM(ps.total_cpu_us) AS total_cpu_us,
+ SUM(ps.total_duration_us) AS total_duration_us,
+ SUM(ps.total_reads) AS total_reads,
+ SUM(ps.total_writes) AS total_writes,
+ SUM(ps.total_physical_reads) AS total_physical_reads,
+ SUM(ps.total_memory_pages) AS total_memory_pages,
+ SUM(ps.total_executions) AS total_executions,
+ MAX(ps.last_execution_time) AS last_execution_time,
+ ROW_NUMBER() OVER (PARTITION BY
+ CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END
+ ORDER BY SUM(ps.{metricCol}) DESC) AS rnum
+ FROM #plan_stats ps
+ JOIN sys.query_store_plan p ON ps.plan_id = p.plan_id
+ JOIN sys.query_store_query q ON p.query_id = q.query_id
+ WHERE EXISTS (SELECT 1 FROM #top_modules tm
+ WHERE tm.module_name = CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END)
+ GROUP BY CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END,
+ q.query_hash
+)
+SELECT module_name, query_hash,
+ CAST(total_cpu_us AS bigint) AS total_cpu_us,
+ CAST(total_duration_us AS bigint) AS total_duration_us,
+ CAST(total_reads AS bigint) AS total_reads,
+ CAST(total_writes AS bigint) AS total_writes,
+ CAST(total_physical_reads AS bigint) AS total_physical_reads,
+ CAST(total_memory_pages AS bigint) AS total_memory_pages,
+ total_executions,
+ last_execution_time
+INTO #qhash_rows
+FROM qh WHERE rnum <= 5;
+
+/* Step 3: Top and bottom QueryId/PlanId per module/query_hash */
+;WITH ranked AS (
+ SELECT
+ CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END AS module_name,
+ CONVERT(varchar(18), q.query_hash, 1) AS query_hash,
+ CONVERT(varchar(18), p.query_plan_hash, 1) AS plan_hash,
+ q.query_id,
+ ps.plan_id,
+ qt.query_text_id,
+ CAST(ps.total_cpu_us AS bigint) AS total_cpu_us,
+ CAST(ps.total_duration_us AS bigint) AS total_duration_us,
+ CAST(ps.total_reads AS bigint) AS total_reads,
+ CAST(ps.total_writes AS bigint) AS total_writes,
+ CAST(ps.total_physical_reads AS bigint) AS total_physical_reads,
+ CAST(ps.total_memory_pages AS bigint) AS total_memory_pages,
+ ps.total_executions,
+ ps.last_execution_time,
+ ROW_NUMBER() OVER (PARTITION BY
+ CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END,
+ q.query_hash
+ ORDER BY ps.{metricCol} DESC) AS rn_top,
+ ROW_NUMBER() OVER (PARTITION BY
+ CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END,
+ q.query_hash
+ ORDER BY ps.{metricCol} ASC) AS rn_bottom
+ FROM #plan_stats ps
+ JOIN sys.query_store_plan p ON ps.plan_id = p.plan_id
+ JOIN sys.query_store_query q ON p.query_id = q.query_id
+ JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_id
+ WHERE EXISTS (SELECT 1 FROM #qhash_rows qhr
+ WHERE qhr.module_name = CASE WHEN q.object_id <> 0
+ THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id)
+ ELSE N'' END
+ AND qhr.query_hash = CONVERT(varchar(18), q.query_hash, 1))
+)
+SELECT *
+into #ranked_light
+FROM ranked
+WHERE rn_top = 1 OR rn_bottom = 1;
+
+/* Final select: join heavy elements (query_text, plan_xml) only for the top/bottom representatives */
+SELECT
+ r.module_name,
+ r.query_hash,
+ r.plan_hash,
+ r.query_id,
+ r.plan_id,
+ qt.query_sql_text,
+ TRY_CONVERT(nvarchar(max), p.query_plan) AS plan_xml,
+ r.total_cpu_us,
+ r.total_duration_us,
+ r.total_reads,
+ r.total_writes,
+ r.total_physical_reads,
+ r.total_memory_pages,
+ r.total_executions,
+ r.last_execution_time,
+ CASE WHEN r.rn_top = 1 THEN 1 ELSE 0 END AS is_top
+FROM #ranked_light r
+JOIN sys.query_store_query_text qt ON r.query_text_id = qt.query_text_id
+JOIN sys.query_store_plan p ON r.plan_id = p.plan_id;
+
+/* Return intermediate rows (result set 2) */
+SELECT * FROM #qhash_rows ORDER BY module_name, total_executions DESC;
+";
+
+ var result = new QueryStoreGroupedResult();
+ await using var conn = new SqlConnection(connectionString);
+ await conn.OpenAsync(ct);
+ await using var cmd = new SqlCommand(sql, conn) { CommandTimeout = 120 };
+ foreach (var p in parameters) cmd.Parameters.Add(p);
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+
+ // Result set 1: Leaf rows (top/bottom per module/query_hash)
+ while (await reader.ReadAsync(ct))
+ {
+ result.LeafRows.Add(new QueryStoreGroupedPlanRow
+ {
+ ModuleName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ QueryHash = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ QueryPlanHash = reader.IsDBNull(2) ? "" : reader.GetString(2),
+ QueryId = reader.GetInt64(3),
+ PlanId = reader.GetInt64(4),
+ QueryText = reader.IsDBNull(5) ? "" : reader.GetString(5),
+ PlanXml = reader.IsDBNull(6) ? "" : reader.GetString(6),
+ TotalCpuTimeUs = reader.GetInt64(7),
+ TotalDurationUs = reader.GetInt64(8),
+ TotalLogicalIoReads = reader.GetInt64(9),
+ TotalLogicalIoWrites = reader.GetInt64(10),
+ TotalPhysicalIoReads = reader.GetInt64(11),
+ TotalMemoryGrantPages = reader.GetInt64(12),
+ CountExecutions = reader.GetInt64(13),
+ LastExecutedUtc = ((DateTimeOffset)reader.GetValue(14)).UtcDateTime,
+ IsTopRepresentative = reader.GetInt32(15) == 1,
+ });
+ }
+
+ // Result set 2: Intermediate rows (query_hash level aggregated under module)
+ if (await reader.NextResultAsync(ct))
+ {
+ while (await reader.ReadAsync(ct))
+ {
+ result.IntermediateRows.Add(new QueryStoreGroupedPlanRow
+ {
+ ModuleName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ QueryHash = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ TotalCpuTimeUs = reader.GetInt64(2),
+ TotalDurationUs = reader.GetInt64(3),
+ TotalLogicalIoReads = reader.GetInt64(4),
+ TotalLogicalIoWrites = reader.GetInt64(5),
+ TotalPhysicalIoReads = reader.GetInt64(6),
+ TotalMemoryGrantPages = reader.GetInt64(7),
+ CountExecutions = reader.GetInt64(8),
+ LastExecutedUtc = ((DateTimeOffset)reader.GetValue(9)).UtcDateTime,
+ });
+ }
+ }
+
+ return result;
+ }
+
///
/// Builds a WaitProfile from raw category totals.
/// Top 3 categories are kept; everything else is consolidated into "Others".