fix: digest real production plans (nesting depth, Object table names, character refs)

Measured against a 2.7 MB production plan that failed to import:
- maxNestedTags: real spaghettified plans nest 2729 tags deep and fast-xml-parser caps at 128, which surfaced as 'Maximum nested tags exceeded' and blamed the export. Raised to 20000; the 12 MB input cap bounds the worst case.
- table names live on <Object>, not on <IndexScan>/<TableScan>. The digest reported 0 tables for a plan with 51 objects; the analyst model never saw a table name. Now 37 distinct tables.
- SSMS writes StatementText with numeric character references (&#xD;&#xA; on every line, 90 in this plan). Attribute/tag processors decode them, so the model gets SQL instead of entity noise.
- 3 new digest tests, each from one of the three measurements above. 39 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ahmed mohammed
2026-09-22 11:16:18 +03:00
co-authored by Claude Opus 4.7
parent 4b5c19b5d2
commit 3fa436103a
4 changed files with 104 additions and 2 deletions
+24 -2
View File
@@ -3,7 +3,26 @@ import { XMLParser } from "fast-xml-parser";
export class PlanError extends Error {}
export const MAX_XML_BYTES = 12_000_000;
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "", removeNSPrefix: true });
// maxNestedTags defaults to 128 and a spaghettified SQL Server plan blows straight through it: a real
// 2.7 MB production plan measured 2729 tags deep. The 12 MB input cap bounds the worst case.
const NAMED_ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
// SSMS writes plan text with numeric references (&#xD;&#xA; for each line break, 90 of them in a production
// plan) and fast-xml-parser leaves them alone, so the model was handed entity noise instead of SQL.
const decodeEntities = (v: string) => v.includes("&")
? v.replace(/&(#[0-9]+|#[xX][0-9a-fA-F]+|[a-zA-Z]+);/g, (m, body) => {
if (body[0] === "#") {
const cp = /^#[xX]/.test(body) ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
return Number.isFinite(cp) && cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
}
return NAMED_ENTITIES[body] ?? m;
})
: v;
const parser = new XMLParser({
ignoreAttributes: false, attributeNamePrefix: "", removeNSPrefix: true, maxNestedTags: 20000,
attributeValueProcessor: (_n: string, value: string) => decodeEntities(value),
tagValueProcessor: (_n: string, value: string) => decodeEntities(value),
});
export type Operator = { id: string; op: string; cost: number; estRows: number; actualRows: number | null;
execs: number | null; io: number | null; cpu: number | null; spillLevels: number | null; parallel: boolean };
@@ -65,8 +84,11 @@ export function digestPlan(raw: string): Digest {
// Database/Schema/Table arrive as separate attributes, each bracketed: rebuild the three-part name.
const fullName = (n: any) => [n.Database, n.Schema, n.Table].map(unbrack).filter(Boolean).join(".");
// The names sit on <Object> (or its <Table> child), not on <IndexScan>/<TableScan> itself: a production
// plan with 51 objects digested to zero tables until this reached into the child.
const scanName = (n: any) => fullName(n) || fullName(n?.Object ?? {});
const tables = [...new Set([...collect(doc, "IndexScan"), ...collect(doc, "TableScan"), ...collect(doc, "Table")]
.map(fullName).filter(Boolean))];
.map(scanName).filter(Boolean))];
const missingIndexes: MissingIndex[] = collect(doc, "MissingIndexGroup").map((g: any) => {
const mi = collect(g, "MissingIndex")[0] ?? {};