fix: decode .sqlplan files the way SSMS writes them; name the parse failure
- lib/plan-file.ts: BOM-aware decode for UTF-8, UTF-16LE/BE (with and without BOM) and UTF-32, with a NUL-density fallback for BOM-less UTF-16. The browser had a two-byte FF FE sniff only, so a UTF-16BE or BOM-less plan arrived as NUL-filled junk and the digest blamed the export. - digestPlan now quotes fast-xml-parser's first line instead of a fixed sentence, so a truncated file, a stray entity and a wrong file stay distinguishable. - plan-file.ts holds the decode so the XML parser stays out of the client bundle. - tests: 6 encodings round-trip through decodePlan into a digest; a truncated plan's message is asserted to carry a reason; e2e drops a real UTF-16LE file. 36 unit, 23 e2e. - history.spec picked the rail's first row, which is now one of the new failing-input rows; it selects the newest finished run instead. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
3d1866367b
commit
4b5c19b5d2
+7
-1
@@ -36,7 +36,13 @@ export function digestPlan(raw: string): Digest {
|
||||
if (xml.length > MAX_XML_BYTES)
|
||||
throw new PlanError(`Plan is ${Math.round(xml.length / 1e6)} MB, over the ${MAX_XML_BYTES / 1e6} MB cap.`);
|
||||
let doc: any;
|
||||
try { doc = parser.parse(xml); } catch { throw new PlanError("That XML will not parse — re-export the actual execution plan."); }
|
||||
// Name the parser's complaint. "re-export the plan" is what a user gets told either way, so a truncated
|
||||
// file, a stray entity and a genuinely wrong file have to stay distinguishable.
|
||||
try { doc = parser.parse(xml); }
|
||||
catch (e: any) {
|
||||
const why = String(e?.message ?? e).split("\n")[0].slice(0, 140);
|
||||
throw new PlanError(`The XML stopped parsing: ${why}. Check the file was saved completely (SSMS: Save As, not copy-paste).`);
|
||||
}
|
||||
if (!doc?.ShowPlanXML) throw new PlanError("Not a SQL Server ShowPlan XML document. In SSMS: press Ctrl+M, run the query, then save the Execution Plan tab as .sqlplan.");
|
||||
|
||||
const stmts = collect(doc, "StmtSimple");
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
// SSMS saves .sqlplan as UTF-16LE with a BOM; other tools write UTF-8 (sometimes with a BOM), and a few write
|
||||
// UTF-16BE or BOM-less UTF-16. File.text() assumes UTF-8, which turns a UTF-16 plan into NUL-filled junk that
|
||||
// fails to parse with a message blaming the user's export. BOM first, then a NUL-density guess.
|
||||
export function decodePlan(buf: Uint8Array): string {
|
||||
const enc = utfEncoding(buf);
|
||||
return new TextDecoder(enc).decode(buf).replace(/^\uFEFF/, "");
|
||||
}
|
||||
|
||||
function utfEncoding(buf: Uint8Array): "utf-8" | "utf-16le" | "utf-16be" | "utf-32le" | "utf-32be" {
|
||||
const b = (i: number) => buf[i] ?? 0;
|
||||
if (b(0) === 0xff && b(1) === 0xfe && b(2) === 0 && b(3) === 0) return "utf-32le";
|
||||
if (b(0) === 0 && b(1) === 0 && b(2) === 0xfe && b(3) === 0xff) return "utf-32be";
|
||||
if (b(0) === 0xff && b(1) === 0xfe) return "utf-16le";
|
||||
if (b(0) === 0xfe && b(1) === 0xff) return "utf-16be";
|
||||
// BOM-less UTF-16 of ASCII XML: in the first 200 bytes roughly every other byte is 0x00.
|
||||
const n = Math.min(buf.length - (buf.length % 2), 200);
|
||||
if (n < 40) return "utf-8";
|
||||
let even = 0, odd = 0;
|
||||
for (let i = 0; i < n; i += 2) { if (b(i) === 0) even++; if (b(i + 1) === 0) odd++; }
|
||||
if (odd > n / 6) return "utf-16le";
|
||||
if (even > n / 6) return "utf-16be";
|
||||
return "utf-8";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user