From 7b195e9a308d1fe53cf1da561a49a81f89d457f0 Mon Sep 17 00:00:00 2001 From: Alex Bochkov Date: Sun, 23 Nov 2025 09:26:55 -0700 Subject: [PATCH 1/2] Add pivot table tab for result grids --- AxialSqlTools/AxialSqlTools.csproj | 5 + AxialSqlTools/Commands/ResultGridCommands.cs | 20 + .../PivotTable/PivotTableTabManager.cs | 397 ++++++++++++++++++ 3 files changed, 422 insertions(+) create mode 100644 AxialSqlTools/PivotTable/PivotTableTabManager.cs diff --git a/AxialSqlTools/AxialSqlTools.csproj b/AxialSqlTools/AxialSqlTools.csproj index 24a643a..87143c8 100644 --- a/AxialSqlTools/AxialSqlTools.csproj +++ b/AxialSqlTools/AxialSqlTools.csproj @@ -131,6 +131,7 @@ QueryHistoryWindowControl.xaml + @@ -155,6 +156,10 @@ Designer + + + + diff --git a/AxialSqlTools/Commands/ResultGridCommands.cs b/AxialSqlTools/Commands/ResultGridCommands.cs index ca7d2e0..b4022af 100644 --- a/AxialSqlTools/Commands/ResultGridCommands.cs +++ b/AxialSqlTools/Commands/ResultGridCommands.cs @@ -102,6 +102,11 @@ public static async Task InitializeAsync(AxialSqlToolsPackage package) btnControlSelectedHtml.Caption = "HTML"; btnControlSelectedHtml.Click += OnClick_CopySelectedAsHtml; + var btnControlPivot = (CommandBarButton)GridCommandBar.Controls.Add(MsoControlType.msoControlButton, Type.Missing, Type.Missing, Type.Missing, true); + btnControlPivot.Visible = true; + btnControlPivot.Caption = "Pivot Table"; + btnControlPivot.Click += OnClick_OpenPivotTable; + var btnControlCCN = (CommandBarButton)GridCommandBar.Controls.Add(MsoControlType.msoControlButton, Type.Missing, Type.Missing, Type.Missing, true); btnControlCCN.Visible = true; btnControlCCN.Caption = "Copy Selected Column Names"; @@ -132,6 +137,21 @@ private static void OnClick_CopySelectedColumnNames(CommandBarButton Ctrl, ref b CopyColumnNames(all: false); } + private static void OnClick_OpenPivotTable(CommandBarButton Ctrl, ref bool CancelDefault) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + var focusGridControl = GridAccess.GetFocusGridControl(); + if (focusGridControl == null) + return; + + using (var gridAdaptor = new ResultGridControlAdaptor(focusGridControl)) + { + var dataTable = gridAdaptor.GridFocusAsDatatable(); + PivotTableTabManager.ShowPivotTab(dataTable); + } + } + private static void OnClick_CopyAllColumnNames(CommandBarButton Ctrl, ref bool CancelDefault) { ThreadHelper.ThrowIfNotOnUIThread(); diff --git a/AxialSqlTools/PivotTable/PivotTableTabManager.cs b/AxialSqlTools/PivotTable/PivotTableTabManager.cs new file mode 100644 index 0000000..c6c6515 --- /dev/null +++ b/AxialSqlTools/PivotTable/PivotTableTabManager.cs @@ -0,0 +1,397 @@ +using Microsoft.VisualStudio.Shell; +using Microsoft.Web.WebView2.WinForms; +using NReco.PivotData; +using System; +using System.Collections.Generic; +using System.Data; +using System.Drawing; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace AxialSqlTools +{ + internal static class PivotTableTabManager + { + private static TabPage _pivotTab; + private static PivotTableView _pivotView; + + public static void ShowPivotTab(DataTable dataTable) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + var sqlResultsControl = GridAccess.GetSQLResultsControl(); + if (sqlResultsControl == null) + return; + + var tabControl = GridAccess.GetNonPublicField(sqlResultsControl, "m_tabControl") as TabControl; + if (tabControl == null) + return; + + if (_pivotTab == null || _pivotTab.IsDisposed) + { + _pivotTab = new TabPage("Pivot Table"); + _pivotView = new PivotTableView + { + Dock = DockStyle.Fill + }; + + _pivotTab.Controls.Add(_pivotView); + tabControl.TabPages.Add(_pivotTab); + } + + _pivotView?.LoadData(dataTable); + tabControl.SelectedTab = _pivotTab; + } + } + + internal sealed class PivotTableView : UserControl + { + private readonly SplitContainer _splitContainer; + private readonly CheckedListBox _rowsList; + private readonly CheckedListBox _columnsList; + private readonly ComboBox _valueFieldCombo; + private readonly ComboBox _aggregatorCombo; + private readonly Button _applyButton; + private readonly Label _statusLabel; + private readonly WebView2 _webView; + + private DataTable _dataTable; + private Task _webViewInitTask; + + private enum AggregationType + { + Sum, + Count, + Average, + Min, + Max + } + + public PivotTableView() + { + _splitContainer = new SplitContainer + { + Dock = DockStyle.Fill, + Orientation = Orientation.Vertical, + SplitterDistance = 280, + FixedPanel = FixedPanel.Panel1 + }; + + _rowsList = new CheckedListBox + { + Dock = DockStyle.Fill, + CheckOnClick = true + }; + + _columnsList = new CheckedListBox + { + Dock = DockStyle.Fill, + CheckOnClick = true + }; + + _valueFieldCombo = new ComboBox + { + Dock = DockStyle.Top, + DropDownStyle = ComboBoxStyle.DropDownList + }; + + _aggregatorCombo = new ComboBox + { + Dock = DockStyle.Top, + DropDownStyle = ComboBoxStyle.DropDownList + }; + + _applyButton = new Button + { + Text = "Build Pivot", + Dock = DockStyle.Top, + Height = 30 + }; + + _applyButton.Click += async (s, e) => await RenderPivotAsync(); + + _statusLabel = new Label + { + Dock = DockStyle.Top, + Padding = new Padding(4), + ForeColor = Color.DimGray, + AutoSize = false, + Height = 36 + }; + + _webView = new WebView2 + { + Dock = DockStyle.Fill + }; + + _splitContainer.Panel1.Controls.Add(CreateLayoutPanel()); + _splitContainer.Panel2.Controls.Add(_webView); + + Controls.Add(_splitContainer); + + PopulateAggregatorOptions(); + } + + public void LoadData(DataTable dataTable) + { + _dataTable = dataTable ?? throw new ArgumentNullException(nameof(dataTable)); + + _rowsList.Items.Clear(); + _columnsList.Items.Clear(); + _valueFieldCombo.Items.Clear(); + + foreach (DataColumn column in _dataTable.Columns) + { + _rowsList.Items.Add(column.ColumnName, false); + _columnsList.Items.Add(column.ColumnName, false); + _valueFieldCombo.Items.Add(column.ColumnName); + } + + if (_dataTable.Columns.Count > 0) + { + _rowsList.SetItemChecked(0, true); + if (_dataTable.Columns.Count > 1) + _columnsList.SetItemChecked(1, true); + + var defaultValueColumn = GetDefaultValueColumn(); + if (!string.IsNullOrEmpty(defaultValueColumn)) + _valueFieldCombo.SelectedItem = defaultValueColumn; + else + _valueFieldCombo.SelectedIndex = 0; + } + + _aggregatorCombo.SelectedIndex = 0; + _ = RenderPivotAsync(); + } + + private TableLayoutPanel CreateLayoutPanel() + { + var layout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 12, + Padding = new Padding(6) + }; + + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.Controls.Add(new Label { Text = "Rows", Dock = DockStyle.Top, Font = new Font(Font, FontStyle.Bold) }); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 35)); + layout.Controls.Add(_rowsList); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.Controls.Add(new Label { Text = "Columns", Dock = DockStyle.Top, Font = new Font(Font, FontStyle.Bold) }); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 35)); + layout.Controls.Add(_columnsList); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.Controls.Add(new Label { Text = "Values", Dock = DockStyle.Top, Font = new Font(Font, FontStyle.Bold) }); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.Controls.Add(_valueFieldCombo); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.Controls.Add(new Label { Text = "Aggregate", Dock = DockStyle.Top, Font = new Font(Font, FontStyle.Bold) }); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.Controls.Add(_aggregatorCombo); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.Controls.Add(_applyButton); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.Controls.Add(_statusLabel); + + return layout; + } + + private void PopulateAggregatorOptions() + { + _aggregatorCombo.Items.Clear(); + foreach (var aggr in Enum.GetValues(typeof(AggregationType))) + _aggregatorCombo.Items.Add(aggr.ToString()); + } + + private string GetDefaultValueColumn() + { + foreach (DataColumn column in _dataTable.Columns) + { + if (IsNumeric(column.DataType)) + return column.ColumnName; + } + return _dataTable.Columns.Count > 0 ? _dataTable.Columns[0].ColumnName : string.Empty; + } + + private async Task EnsureWebViewAsync() + { + if (_webViewInitTask == null) + { + _webViewInitTask = _webView.EnsureCoreWebView2Async(); + } + + await _webViewInitTask.ConfigureAwait(true); + } + + private async Task RenderPivotAsync() + { + if (_dataTable == null || _dataTable.Columns.Count == 0) + return; + + await EnsureWebViewAsync(); + + var rowDimensions = _rowsList.CheckedItems.Cast().ToArray(); + var columnDimensions = _columnsList.CheckedItems.Cast().ToArray(); + var valueField = _valueFieldCombo.SelectedItem as string ?? _dataTable.Columns[0].ColumnName; + + var aggregationType = AggregationType.Sum; + if (_aggregatorCombo.SelectedItem is string selectedAggr && + Enum.TryParse(selectedAggr, out AggregationType parsedAggr)) + { + aggregationType = parsedAggr; + } + + var aggregatorFactory = CreateAggregatorFactory(aggregationType, valueField); + var pivotData = new PivotData(rowDimensions.Concat(columnDimensions).ToArray(), aggregatorFactory, true); + pivotData.ProcessData(new DataTableReader(_dataTable)); + + var pivotTable = new PivotTable(rowDimensions, columnDimensions, pivotData); + var html = BuildPivotHtml(pivotTable, valueField, aggregationType.ToString()); + + _webView.CoreWebView2.NavigateToString(html); + _statusLabel.Text = $"Rows: {rowDimensions.Length}, Columns: {columnDimensions.Length}, Field: {valueField}, Aggr: {aggregationType}"; + } + + private static IAggregatorFactory CreateAggregatorFactory(AggregationType aggregationType, string valueField) + { + switch (aggregationType) + { + case AggregationType.Count: + return new CountAggregatorFactory(); + case AggregationType.Average: + return new AverageAggregatorFactory(valueField); + case AggregationType.Min: + return new MinAggregatorFactory(valueField); + case AggregationType.Max: + return new MaxAggregatorFactory(valueField); + default: + return new SumAggregatorFactory(valueField); + } + } + + private static bool IsNumeric(Type type) + { + if (type == null) + return false; + + var numericTypes = new HashSet + { + typeof(byte), typeof(short), typeof(int), typeof(long), typeof(float), typeof(double), typeof(decimal), typeof(sbyte), typeof(ushort), typeof(uint), typeof(ulong) + }; + + return numericTypes.Contains(Nullable.GetUnderlyingType(type) ?? type); + } + + private static string BuildPivotHtml(PivotTable pivotTable, string valueField, string aggregationName) + { + var sb = new StringBuilder(); + sb.Append(""); + sb.Append(""); + + sb.Append($"
Aggregation: {aggregationName} on {valueField}
"); + sb.Append(""); + + sb.Append(""); + + foreach (var colKey in pivotTable.ColumnKeys) + { + sb.Append(""); + } + + sb.Append(""); + + for (int r = 0; r < pivotTable.RowKeys.Length; r++) + { + sb.Append(""); + + for (int c = 0; c < pivotTable.ColumnKeys.Length; c++) + { + sb.Append(""); + } + + sb.Append(""); + } + + sb.Append(""); + for (int c = 0; c < pivotTable.ColumnKeys.Length; c++) + { + sb.Append(""); + } + + sb.Append(""); + + sb.Append("
"); + sb.Append(string.Join(" / ", pivotTable.Rows)); + sb.Append(""); + sb.Append(FormatKey(colKey)); + sb.Append("Total
"); + sb.Append(FormatKey(pivotTable.RowKeys[r])); + sb.Append(""); + sb.Append(FormatAggregatorValue(pivotTable[r, c])); + sb.Append(""); + sb.Append(FormatAggregatorValue(pivotTable[r, null])); + sb.Append("
Total"); + sb.Append(FormatAggregatorValue(pivotTable[null, c])); + sb.Append(""); + sb.Append(FormatAggregatorValue(pivotTable[null, null])); + sb.Append("
"); + return sb.ToString(); + } + + private static string FormatKey(ValueKey key) + { + if (key == null || key.DimKeys == null || key.DimKeys.Length == 0) + return "(All)"; + + var parts = key.DimKeys.Select(v => + { + if (v == null) + return "(null)"; + if (v.Equals(Key.Empty)) + return "(All)"; + return Convert.ToString(v, CultureInfo.InvariantCulture); + }); + + return string.Join(" / ", parts); + } + + private static string FormatAggregatorValue(IAggregator aggregator) + { + if (aggregator == null || aggregator.Count == 0) + return string.Empty; + + var value = aggregator.Value; + if (value is object[] values) + { + var formatted = values.Select(v => FormatValue(v)); + return string.Join(" | ", formatted); + } + + return FormatValue(value); + } + + private static string FormatValue(object value) + { + if (value == null) + return string.Empty; + + if (value is IFormattable formattable) + return formattable.ToString("G", CultureInfo.InvariantCulture); + + return value.ToString(); + } + } +} From 11bcfaebecaa063f3a7605fd4fcc7793077f3f78 Mon Sep 17 00:00:00 2001 From: Alex Bochkov Date: Sun, 23 Nov 2025 09:54:17 -0700 Subject: [PATCH 2/2] wip --- AxialSqlTools/AxialSqlTools.csproj | 2 +- AxialSqlTools/Modules/GridAccess.cs | 6 ++++ .../PivotTable/PivotTableTabManager.cs | 28 +++++++++++++++++-- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/AxialSqlTools/AxialSqlTools.csproj b/AxialSqlTools/AxialSqlTools.csproj index 87143c8..481e995 100644 --- a/AxialSqlTools/AxialSqlTools.csproj +++ b/AxialSqlTools/AxialSqlTools.csproj @@ -158,7 +158,7 @@
- + diff --git a/AxialSqlTools/Modules/GridAccess.cs b/AxialSqlTools/Modules/GridAccess.cs index 00d4419..c841773 100644 --- a/AxialSqlTools/Modules/GridAccess.cs +++ b/AxialSqlTools/Modules/GridAccess.cs @@ -38,6 +38,12 @@ public static FieldInfo GetNonPublicFieldInfo(object obj, string field) return obj.GetType().GetField(field, BindingFlags.NonPublic | BindingFlags.Instance); } + public static object GetTabControl(object obj, string property) + { + if (obj == null) return null; + return obj.GetType().GetProperty(property, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).GetValue(obj); + } + public static object GetSQLResultsControl() { var factoryType = ServiceCache.ScriptFactory.GetType(); diff --git a/AxialSqlTools/PivotTable/PivotTableTabManager.cs b/AxialSqlTools/PivotTable/PivotTableTabManager.cs index c6c6515..4c02113 100644 --- a/AxialSqlTools/PivotTable/PivotTableTabManager.cs +++ b/AxialSqlTools/PivotTable/PivotTableTabManager.cs @@ -1,5 +1,6 @@ using Microsoft.VisualStudio.Shell; using Microsoft.Web.WebView2.WinForms; +using Microsoft.Web.WebView2.Core; using NReco.PivotData; using System; using System.Collections.Generic; @@ -10,6 +11,7 @@ using System.Text; using System.Threading.Tasks; using System.Windows.Forms; +using System.IO; namespace AxialSqlTools { @@ -26,7 +28,7 @@ public static void ShowPivotTab(DataTable dataTable) if (sqlResultsControl == null) return; - var tabControl = GridAccess.GetNonPublicField(sqlResultsControl, "m_tabControl") as TabControl; + var tabControl = GridAccess.GetTabControl(sqlResultsControl, "MarshallingControl") as TabControl; if (tabControl == null) return; @@ -220,12 +222,24 @@ private string GetDefaultValueColumn() private async Task EnsureWebViewAsync() { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + if (_webViewInitTask == null) { + var userDataFolder = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "AxialSqlTools", + "WebView2"); + + Directory.CreateDirectory(userDataFolder); + + _webView.CreationProperties = new CoreWebView2CreationProperties + { + UserDataFolder = userDataFolder + }; + _webViewInitTask = _webView.EnsureCoreWebView2Async(); } - - await _webViewInitTask.ConfigureAwait(true); } private async Task RenderPivotAsync() @@ -235,6 +249,12 @@ private async Task RenderPivotAsync() await EnsureWebViewAsync(); + if (_webView?.CoreWebView2 == null) + { + _statusLabel.Text = "Unable to initialize WebView2. Please reopen the Pivot Table tab."; + return; + } + var rowDimensions = _rowsList.CheckedItems.Cast().ToArray(); var columnDimensions = _columnsList.CheckedItems.Cast().ToArray(); var valueField = _valueFieldCombo.SelectedItem as string ?? _dataTable.Columns[0].ColumnName; @@ -253,6 +273,8 @@ private async Task RenderPivotAsync() var pivotTable = new PivotTable(rowDimensions, columnDimensions, pivotData); var html = BuildPivotHtml(pivotTable, valueField, aggregationType.ToString()); + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + _webView.CoreWebView2.NavigateToString(html); _statusLabel.Text = $"Rows: {rowDimensions.Length}, Columns: {columnDimensions.Length}, Field: {valueField}, Aggr: {aggregationType}"; }