Skip to content

Commit 2828175

Browse files
authored
Fix PowerQuery List() returning 'Error Query' for workbooks with manual tables (#237)
Fixes #236 Problem: - List() returned fake 'Error Query' entries when workbooks contained manually created Excel tables (ListObjects without QueryTables) - Accessing listObject.QueryTable on manual tables throws 0x800A03EC - Outer catch block suppressed exceptions and created fake entries Solution: - Add specific try-catch for QueryTable access that continues on 0x800A03EC - Manual tables are now skipped gracefully during IsConnectionOnly detection - Queries return with correct names and formulas Changes: - PowerQueryCommands.Lifecycle.cs: Handle 0x800A03EC for QueryTable access - Tests: Add regression test with user file + synthetic test - Tests: Add diagnostic test to pinpoint exception location Test results: - All 16 PowerQuery tests pass - New regression tests verify fix works
1 parent 0b6003e commit 2828175

File tree

4 files changed

+384
-1
lines changed

4 files changed

+384
-1
lines changed

src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Lifecycle.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,20 @@ public PowerQueryListResult List(IExcelBatch batch)
7373
try
7474
{
7575
listObject = listObjects.Item(lo);
76-
queryTable = listObject.QueryTable;
76+
77+
// QueryTable property may throw 0x800A03EC if ListObject doesn't have a valid QueryTable
78+
// This is normal - not all ListObjects have QueryTables (e.g., manually created tables)
79+
try
80+
{
81+
queryTable = listObject.QueryTable;
82+
}
83+
catch (System.Runtime.InteropServices.COMException ex)
84+
when (ex.HResult == unchecked((int)0x800A03EC))
85+
{
86+
// ListObject doesn't have QueryTable - skip it
87+
continue;
88+
}
89+
7790
if (queryTable == null) continue;
7891

7992
wbConn = queryTable.WorkbookConnection;
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
using Sbroenne.ExcelMcp.ComInterop;
2+
using Sbroenne.ExcelMcp.ComInterop.Session;
3+
using System.Runtime.InteropServices;
4+
using Xunit;
5+
using Xunit.Abstractions;
6+
7+
namespace Sbroenne.ExcelMcp.Core.Tests.Diagnostics;
8+
9+
/// <summary>
10+
/// Diagnostic test to pinpoint exact location of 0x800A03EC exception in PowerQuery List()
11+
/// </summary>
12+
[Trait("RunType", "OnDemand")]
13+
[Trait("Layer", "Diagnostics")]
14+
[Trait("Feature", "PowerQuery")]
15+
public class PowerQueryListDiagnostic
16+
{
17+
private readonly ITestOutputHelper _output;
18+
19+
public PowerQueryListDiagnostic(ITestOutputHelper output)
20+
{
21+
_output = output;
22+
}
23+
24+
[Fact]
25+
public void DiagnoseConsumptionPlanBaseException()
26+
{
27+
const string testFile = @"D:\source\mcp-server-excel\ConsumptionPlan_Base.xlsx";
28+
29+
if (!System.IO.File.Exists(testFile))
30+
{
31+
_output.WriteLine("Test file not found - skipping");
32+
return;
33+
}
34+
35+
using var batch = ExcelSession.BeginBatch(testFile);
36+
37+
batch.Execute((ctx, ct) =>
38+
{
39+
dynamic? queriesCollection = null;
40+
try
41+
{
42+
queriesCollection = ctx.Book.Queries;
43+
int count = queriesCollection.Count;
44+
_output.WriteLine($"Total queries: {count}");
45+
46+
for (int i = 1; i <= count; i++)
47+
{
48+
dynamic? query = null;
49+
try
50+
{
51+
_output.WriteLine($"\n=== Processing Query {i} ===");
52+
53+
query = queriesCollection.Item(i);
54+
_output.WriteLine($"✓ Got query object");
55+
56+
string name = "UNKNOWN";
57+
try
58+
{
59+
name = query.Name ?? $"Query{i}";
60+
_output.WriteLine($"✓ Name: {name}");
61+
}
62+
catch (COMException ex)
63+
{
64+
_output.WriteLine($"✗ Name access failed: 0x{ex.HResult:X} - {ex.Message}");
65+
throw;
66+
}
67+
68+
try
69+
{
70+
string formula = query.Formula?.ToString() ?? "";
71+
_output.WriteLine($"✓ Formula length: {formula.Length}");
72+
}
73+
catch (COMException ex)
74+
{
75+
_output.WriteLine($"✗ Formula access failed: 0x{ex.HResult:X} - {ex.Message}");
76+
}
77+
78+
// Check IsConnectionOnly logic
79+
_output.WriteLine("Checking IsConnectionOnly...");
80+
dynamic? worksheets = null;
81+
try
82+
{
83+
worksheets = ctx.Book.Worksheets;
84+
_output.WriteLine($"✓ Got worksheets: {worksheets.Count}");
85+
86+
for (int ws = 1; ws <= worksheets.Count; ws++)
87+
{
88+
dynamic? worksheet = null;
89+
dynamic? listObjects = null;
90+
try
91+
{
92+
worksheet = worksheets.Item(ws);
93+
string sheetName = worksheet.Name;
94+
listObjects = worksheet.ListObjects;
95+
_output.WriteLine($" Sheet {ws} ({sheetName}): {listObjects.Count} ListObjects");
96+
97+
for (int lo = 1; lo <= listObjects.Count; lo++)
98+
{
99+
dynamic? listObject = null;
100+
dynamic? queryTable = null;
101+
dynamic? wbConn = null;
102+
dynamic? oledbConn = null;
103+
try
104+
{
105+
listObject = listObjects.Item(lo);
106+
queryTable = listObject.QueryTable;
107+
if (queryTable == null)
108+
{
109+
_output.WriteLine($" ListObject {lo}: No QueryTable");
110+
continue;
111+
}
112+
113+
wbConn = queryTable.WorkbookConnection;
114+
if (wbConn == null)
115+
{
116+
_output.WriteLine($" ListObject {lo}: No WorkbookConnection");
117+
continue;
118+
}
119+
120+
oledbConn = wbConn.OLEDBConnection;
121+
if (oledbConn == null)
122+
{
123+
_output.WriteLine($" ListObject {lo}: No OLEDBConnection");
124+
continue;
125+
}
126+
127+
string connString = oledbConn.Connection?.ToString() ?? "";
128+
bool isMashup = connString.Contains("Provider=Microsoft.Mashup.OleDb.1", StringComparison.OrdinalIgnoreCase);
129+
bool locationMatches = connString.Contains($"Location={name}", StringComparison.OrdinalIgnoreCase);
130+
_output.WriteLine($" ListObject {lo}: Mashup={isMashup}, LocationMatches={locationMatches}");
131+
}
132+
catch (COMException ex)
133+
{
134+
_output.WriteLine($" ListObject {lo}: EXCEPTION 0x{ex.HResult:X} - {ex.Message}");
135+
throw;
136+
}
137+
finally
138+
{
139+
if (oledbConn != null) ComUtilities.Release(ref oledbConn!);
140+
if (wbConn != null) ComUtilities.Release(ref wbConn!);
141+
if (queryTable != null) ComUtilities.Release(ref queryTable!);
142+
if (listObject != null) ComUtilities.Release(ref listObject!);
143+
}
144+
}
145+
}
146+
catch (COMException ex)
147+
{
148+
_output.WriteLine($" Sheet {ws}: EXCEPTION 0x{ex.HResult:X} - {ex.Message}");
149+
throw;
150+
}
151+
finally
152+
{
153+
if (listObjects != null) ComUtilities.Release(ref listObjects!);
154+
if (worksheet != null) ComUtilities.Release(ref worksheet!);
155+
}
156+
}
157+
}
158+
finally
159+
{
160+
if (worksheets != null) ComUtilities.Release(ref worksheets!);
161+
}
162+
163+
_output.WriteLine($"✓ Query {i} processed successfully");
164+
}
165+
catch (Exception ex)
166+
{
167+
_output.WriteLine($"✗✗✗ CAUGHT EXCEPTION for Query {i}: {ex.GetType().Name} - {ex.Message}");
168+
if (ex is COMException comEx)
169+
{
170+
_output.WriteLine($" HResult: 0x{comEx.HResult:X}");
171+
}
172+
_output.WriteLine($" Stack: {ex.StackTrace}");
173+
}
174+
finally
175+
{
176+
if (query != null) ComUtilities.Release(ref query!);
177+
}
178+
}
179+
}
180+
finally
181+
{
182+
if (queriesCollection != null) ComUtilities.Release(ref queriesCollection!);
183+
}
184+
});
185+
}
186+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using Sbroenne.ExcelMcp.ComInterop.Session;
2+
using Sbroenne.ExcelMcp.Core.Commands;
3+
using Sbroenne.ExcelMcp.Core.Tests.Helpers;
4+
using Xunit;
5+
6+
namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PowerQuery;
7+
8+
/// <summary>
9+
/// Regression tests for "Error Query" bug where List() suppresses exceptions
10+
/// Issue: When COM error 0x800A03EC occurs, List() creates fake "Error Query {i}" entries
11+
/// Expected: Exceptions should propagate naturally (CRITICAL-RULES.md Rule 1b)
12+
/// </summary>
13+
[Trait("Layer", "Core")]
14+
[Trait("Category", "Integration")]
15+
[Trait("RequiresExcel", "true")]
16+
[Trait("Feature", "PowerQuery")]
17+
[Trait("Speed", "Medium")]
18+
public partial class PowerQueryCommandsTests
19+
{
20+
/// <summary>
21+
/// Reproduces the "Error Query" bug with ConsumptionPlan_Base.xlsx
22+
/// LLM got result with 4 "Error Query" entries instead of actual query names
23+
/// </summary>
24+
[Fact]
25+
public void List_ConsumptionPlanBaseFile_ReturnsActualQueryNames()
26+
{
27+
// Arrange
28+
const string testFile = @"D:\source\mcp-server-excel\ConsumptionPlan_Base.xlsx";
29+
30+
// Skip if file doesn't exist (not in all test environments)
31+
if (!System.IO.File.Exists(testFile))
32+
{
33+
return;
34+
}
35+
36+
var dataModelCommands = new DataModelCommands();
37+
var commands = new PowerQueryCommands(dataModelCommands);
38+
39+
// Act
40+
using var batch = ExcelSession.BeginBatch(testFile);
41+
var result = commands.List(batch);
42+
43+
// Assert
44+
Assert.True(result.Success, $"List failed: {result.ErrorMessage}");
45+
Assert.NotNull(result.Queries);
46+
47+
// Should have 4 actual queries, not "Error Query" entries
48+
Assert.Equal(4, result.Queries.Count);
49+
50+
// Verify NO "Error Query" fake entries
51+
Assert.DoesNotContain(result.Queries, q => q.Name.StartsWith("Error Query", StringComparison.Ordinal));
52+
53+
// Verify actual query names are present
54+
Assert.Contains(result.Queries, q => q.Name == "Milestones_Base");
55+
Assert.Contains(result.Queries, q => q.Name == "fnEnsureColumn");
56+
Assert.Contains(result.Queries, q => q.Name == "fnEnsureColumn_New");
57+
Assert.Contains(result.Queries, q => q.Name == "Milestones_Base_New");
58+
59+
// Verify formulas are accessible (not empty due to catch block)
60+
foreach (var query in result.Queries)
61+
{
62+
Assert.NotEmpty(query.Formula);
63+
Assert.DoesNotContain("Error:", query.FormulaPreview);
64+
}
65+
}
66+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
using Sbroenne.ExcelMcp.ComInterop;
2+
using Sbroenne.ExcelMcp.ComInterop.Session;
3+
using Sbroenne.ExcelMcp.Core.Commands;
4+
using Sbroenne.ExcelMcp.Core.Models;
5+
using Sbroenne.ExcelMcp.Core.Tests.Helpers;
6+
using Xunit;
7+
8+
namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PowerQuery;
9+
10+
/// <summary>
11+
/// Tests verifying List() handles manually created tables (ListObjects without QueryTables)
12+
/// </summary>
13+
[Trait("Layer", "Core")]
14+
[Trait("Category", "Integration")]
15+
[Trait("RequiresExcel", "true")]
16+
[Trait("Feature", "PowerQuery")]
17+
[Trait("Speed", "Medium")]
18+
public partial class PowerQueryCommandsTests
19+
{
20+
/// <summary>
21+
/// Verifies List() handles workbooks with both Power Queries AND manually created tables
22+
/// Manually created tables don't have QueryTable property and throw 0x800A03EC
23+
/// List() should skip those tables gracefully without creating "Error Query" entries
24+
/// </summary>
25+
[Fact]
26+
public void List_WorkbookWithManualTable_ReturnsOnlyQueries()
27+
{
28+
// Arrange
29+
var testFile = CoreTestHelper.CreateUniqueTestFile(
30+
nameof(PowerQueryCommandsTests),
31+
nameof(List_WorkbookWithManualTable_ReturnsOnlyQueries),
32+
_tempDir,
33+
".xlsx");
34+
35+
var dataModelCommands = new DataModelCommands();
36+
var commands = new PowerQueryCommands(dataModelCommands);
37+
38+
const string queryName = "TestQuery";
39+
const string mCode = @"let
40+
Source = #table(
41+
{""Column1"", ""Column2""},
42+
{
43+
{""A"", ""B""},
44+
{""C"", ""D""}
45+
}
46+
)
47+
in
48+
Source";
49+
50+
// Act
51+
using var batch = ExcelSession.BeginBatch(testFile);
52+
53+
// Step 1: Create a manually created table (no Power Query connection)
54+
batch.Execute((ctx, ct) =>
55+
{
56+
dynamic? sheet = null;
57+
dynamic? range = null;
58+
dynamic? listObjects = null;
59+
try
60+
{
61+
sheet = ctx.Book.Worksheets.Item(1);
62+
sheet.Name = "TestSheet";
63+
64+
// Add some data
65+
range = sheet.Range["A1:B3"];
66+
range.Value2 = new object[,]
67+
{
68+
{ "Header1", "Header2" },
69+
{ "Data1", "Data2" },
70+
{ "Data3", "Data4" }
71+
};
72+
73+
// Create a manual table (ListObject) - NO QueryTable
74+
listObjects = sheet.ListObjects;
75+
dynamic? listObject = listObjects.Add(
76+
1, // xlSrcRange (manual table from range)
77+
range, // Source range
78+
Type.Missing, // LinkSource
79+
1, // xlYes (has headers)
80+
Type.Missing // Destination
81+
);
82+
listObject.Name = "ManualTable";
83+
ComUtilities.Release(ref listObject!);
84+
}
85+
finally
86+
{
87+
ComUtilities.Release(ref listObjects!);
88+
ComUtilities.Release(ref range!);
89+
ComUtilities.Release(ref sheet!);
90+
}
91+
});
92+
93+
// Step 2: Create a Power Query (connection-only)
94+
commands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly);
95+
96+
// Step 3: List queries
97+
var result = commands.List(batch);
98+
99+
// Assert
100+
Assert.True(result.Success, $"List failed: {result.ErrorMessage}");
101+
Assert.NotNull(result.Queries);
102+
103+
// Should have 1 query (not "Error Query" entries from manual table)
104+
Assert.Single(result.Queries);
105+
106+
// Verify NO "Error Query" fake entries
107+
Assert.DoesNotContain(result.Queries, q => q.Name.StartsWith("Error Query", StringComparison.Ordinal));
108+
109+
// Verify the actual query is present
110+
var query = Assert.Single(result.Queries);
111+
Assert.Equal(queryName, query.Name);
112+
Assert.NotEmpty(query.Formula);
113+
Assert.DoesNotContain("Error:", query.FormulaPreview);
114+
115+
// Query should be connection-only (manual table shouldn't affect this)
116+
Assert.True(query.IsConnectionOnly);
117+
}
118+
}

0 commit comments

Comments
 (0)