pgexplain 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -2
- package/dist/cli.js +1031 -75
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +30 -2
- package/dist/index.js +532 -30
- package/dist/index.js.map +1 -1
- package/dist/server.js +3996 -0
- package/dist/server.js.map +1 -0
- package/dist/web/assets/PlanGraph-0P3xGm2e.js +23 -0
- package/dist/web/assets/PlanGraph-C5ap-Sga.css +1 -0
- package/dist/web/assets/index-BqB6p5Pn.js +227 -0
- package/dist/web/assets/index-ByEFSLsN.css +1 -0
- package/dist/web/index.html +13 -0
- package/package.json +10 -1
package/dist/cli.js
CHANGED
|
@@ -4,10 +4,12 @@ import { readFile, writeFile, stat, readdir } from 'fs/promises';
|
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import pc from 'picocolors';
|
|
7
|
+
import { tmpdir } from 'os';
|
|
8
|
+
import { spawn } from 'child_process';
|
|
7
9
|
|
|
8
10
|
// package.json
|
|
9
11
|
var package_default = {
|
|
10
|
-
version: "0.
|
|
12
|
+
version: "0.2.0"};
|
|
11
13
|
|
|
12
14
|
// src/diagnostics/diagnostic.ts
|
|
13
15
|
var AppError = class extends Error {
|
|
@@ -482,6 +484,308 @@ async function loadConfig(explicitPath, cwd = process.cwd()) {
|
|
|
482
484
|
}
|
|
483
485
|
return { ...DEFAULT_CONFIG, thresholds: { ...DEFAULT_THRESHOLDS }, rules: {} };
|
|
484
486
|
}
|
|
487
|
+
|
|
488
|
+
// src/core/parse-text.ts
|
|
489
|
+
var cap = (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase();
|
|
490
|
+
var numeric = (v) => {
|
|
491
|
+
const t = v.trim();
|
|
492
|
+
return /^-?\d+(\.\d+)?$/.test(t) ? Number(t) : t;
|
|
493
|
+
};
|
|
494
|
+
function splitList(s) {
|
|
495
|
+
const out = [];
|
|
496
|
+
let depth = 0;
|
|
497
|
+
let cur = "";
|
|
498
|
+
for (const ch of s) {
|
|
499
|
+
if (ch === "(") depth++;
|
|
500
|
+
else if (ch === ")") depth--;
|
|
501
|
+
if (ch === "," && depth === 0) {
|
|
502
|
+
if (cur.trim()) out.push(cur.trim());
|
|
503
|
+
cur = "";
|
|
504
|
+
} else {
|
|
505
|
+
cur += ch;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (cur.trim()) out.push(cur.trim());
|
|
509
|
+
return out;
|
|
510
|
+
}
|
|
511
|
+
function splitIntoLines(text) {
|
|
512
|
+
const out = [];
|
|
513
|
+
const lines = text.split(/\r?\n/);
|
|
514
|
+
const count = (s, re) => (s.match(re) || []).length;
|
|
515
|
+
const closingFirst = (s) => {
|
|
516
|
+
const c = s.indexOf(")");
|
|
517
|
+
const o = s.indexOf("(");
|
|
518
|
+
return c !== -1 && c < o;
|
|
519
|
+
};
|
|
520
|
+
const sameIndent = (a, b) => a.search(/\S/) === b.search(/\S/);
|
|
521
|
+
for (const line of lines) {
|
|
522
|
+
const prev = out[out.length - 1];
|
|
523
|
+
if (prev && count(prev, /\)/g) !== count(prev, /\(/g)) {
|
|
524
|
+
out[out.length - 1] += line;
|
|
525
|
+
} else if (/^(?:Total\s+runtime|Planning(\s+time)?|Execution\s+time|Time|Filter|Output|JIT|Trigger|Settings|Serialization)/i.test(
|
|
526
|
+
line
|
|
527
|
+
)) {
|
|
528
|
+
out.push(line);
|
|
529
|
+
} else if (/^\S/.test(line) || /^\s*\(/.test(line) || closingFirst(line)) {
|
|
530
|
+
if (prev) out[out.length - 1] += line;
|
|
531
|
+
else out.push(line);
|
|
532
|
+
} else if (prev && /,\s*$/.test(prev) && !sameIndent(prev, line) && !/^\s*->/i.test(line)) {
|
|
533
|
+
out[out.length - 1] += line;
|
|
534
|
+
} else {
|
|
535
|
+
out.push(line);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return out;
|
|
539
|
+
}
|
|
540
|
+
var estimation = String.raw`\(cost=(\d+\.\d+)\.\.(\d+\.\d+)\s+rows=(\d+)\s+width=(\d+)\)`;
|
|
541
|
+
var actual = String.raw`(?:actual(?:\stime=(\d+\.\d+)\.\.(\d+\.\d+))?\srows=(\d+(?:\.\d+)?)\sloops=(\d+)|(never\s+executed))`;
|
|
542
|
+
var nodeRe = new RegExp(
|
|
543
|
+
String.raw`^(\s*->\s*|\s*)(Finalize|Simple|Partial)*\s*([^\r\n\t\f\v(]*?)\s*` + String.raw`(?:(?:${estimation}\s+\(${actual}\))|(?:${estimation})|(?:\(${actual}\)))\s*$`
|
|
544
|
+
);
|
|
545
|
+
var subRe = /^((?:Sub|Init)Plan)\s*(?:\d+\s*)?(?:\(returns.*\))?\s*$/;
|
|
546
|
+
var cteRe = /^CTE\s+(\S+)\s*$/;
|
|
547
|
+
var workerRe = /^Worker\s+(\d+):\s+(?:actual(?:\stime=(\d+\.\d+)\.\.(\d+\.\d+))?\srows=(\d+(?:\.\d+)?)\sloops=(\d+)|never\s+executed)(.*)$/;
|
|
548
|
+
var triggerRe = /^Trigger\s+(.*):\s+time=(\d+\.\d+)\s+calls=(\d+)\s*$/;
|
|
549
|
+
var headerRe = /^(QUERY PLAN|-{2,}|#|\(\d+ rows?\))/;
|
|
550
|
+
function splitNodeType(text) {
|
|
551
|
+
let s = text.trim();
|
|
552
|
+
let indexName;
|
|
553
|
+
let relationName;
|
|
554
|
+
let schema;
|
|
555
|
+
let alias;
|
|
556
|
+
const using = s.match(/\susing (\S+)/);
|
|
557
|
+
if (using?.[1]) {
|
|
558
|
+
indexName = using[1];
|
|
559
|
+
s = s.replace(using[0] ?? "", "");
|
|
560
|
+
}
|
|
561
|
+
const on = s.match(/\son (\S+?)(?:\s+(\S+))?\s*$/);
|
|
562
|
+
if (on?.[1]) {
|
|
563
|
+
let rel = on[1];
|
|
564
|
+
alias = on[2];
|
|
565
|
+
const dot = rel.lastIndexOf(".");
|
|
566
|
+
if (dot !== -1) {
|
|
567
|
+
schema = rel.slice(0, dot);
|
|
568
|
+
rel = rel.slice(dot + 1);
|
|
569
|
+
}
|
|
570
|
+
relationName = rel;
|
|
571
|
+
s = s.slice(0, on.index).trim();
|
|
572
|
+
}
|
|
573
|
+
const nodeType = s.replace(/^Parallel\s+/, "").trim();
|
|
574
|
+
if (nodeType === "Bitmap Index Scan" && relationName && !indexName) {
|
|
575
|
+
indexName = relationName;
|
|
576
|
+
relationName = void 0;
|
|
577
|
+
schema = void 0;
|
|
578
|
+
alias = void 0;
|
|
579
|
+
}
|
|
580
|
+
const out = { "Node Type": nodeType };
|
|
581
|
+
if (relationName) out["Relation Name"] = relationName;
|
|
582
|
+
if (indexName) out["Index Name"] = indexName;
|
|
583
|
+
if (schema) out.Schema = schema;
|
|
584
|
+
if (alias && alias !== relationName) out.Alias = alias;
|
|
585
|
+
return out;
|
|
586
|
+
}
|
|
587
|
+
function parseSort(text, node) {
|
|
588
|
+
const m = text.match(/^Sort Method:\s+(.*?)\s+(Memory|Disk):\s+(\S+)kB\s*$/);
|
|
589
|
+
if (!m?.[1] || !m[2] || m[3] === void 0) return false;
|
|
590
|
+
node["Sort Method"] = m[1].trim();
|
|
591
|
+
node["Sort Space Type"] = m[2];
|
|
592
|
+
node["Sort Space Used"] = Number(m[3]);
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
function parseBuffers(text, node) {
|
|
596
|
+
const m = text.match(/^Buffers:\s+(.*)$/);
|
|
597
|
+
if (!m?.[1]) return false;
|
|
598
|
+
for (const group of m[1].split(/,\s+/)) {
|
|
599
|
+
const g = group.match(/^(shared|temp|local)\s+(.*)$/);
|
|
600
|
+
if (!g?.[1] || g[2] === void 0) continue;
|
|
601
|
+
const type = cap(g[1]);
|
|
602
|
+
for (const kv of g[2].trim().split(/\s+/)) {
|
|
603
|
+
const [method, value] = kv.split("=");
|
|
604
|
+
if (method && value !== void 0) node[`${type} ${cap(method)} Blocks`] = Number(value);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
function parseWal(text, node) {
|
|
610
|
+
const m = text.match(/^WAL:\s+(.*)$/);
|
|
611
|
+
if (!m?.[1]) return false;
|
|
612
|
+
for (const kv of m[1].trim().split(/\s+/)) {
|
|
613
|
+
const [k, value] = kv.split("=");
|
|
614
|
+
if (!k || value === void 0) continue;
|
|
615
|
+
node[`WAL ${k === "fpi" ? "FPI" : cap(k)}`] = Number(value);
|
|
616
|
+
}
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
function parseIoTimings(text, node) {
|
|
620
|
+
const m = text.match(/^I\/O Timings:\s+(.*)$/);
|
|
621
|
+
if (!m?.[1]) return false;
|
|
622
|
+
const read = m[1].match(/(?:^|\s)read=(\d+(?:\.\d+)?)/);
|
|
623
|
+
const write2 = m[1].match(/(?:^|\s)write=(\d+(?:\.\d+)?)/);
|
|
624
|
+
if (read?.[1]) node["I/O Read Time"] = Number(read[1]);
|
|
625
|
+
if (write2?.[1]) node["I/O Write Time"] = Number(write2[1]);
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
628
|
+
function parseSettings(text) {
|
|
629
|
+
const out = {};
|
|
630
|
+
for (const pair of splitList(text)) {
|
|
631
|
+
const m = pair.match(/^(\S+)\s*=\s*(.*)$/);
|
|
632
|
+
if (m?.[1] && m[2] !== void 0) out[m[1]] = m[2].replace(/^'|'$/g, "");
|
|
633
|
+
}
|
|
634
|
+
return out;
|
|
635
|
+
}
|
|
636
|
+
var LIST_KEYS = /* @__PURE__ */ new Set(["Output", "Sort Key", "Presorted Key", "Group Key"]);
|
|
637
|
+
function parseTextToStatements(input) {
|
|
638
|
+
const statements = [];
|
|
639
|
+
let stmt = null;
|
|
640
|
+
let stack = [];
|
|
641
|
+
let current = null;
|
|
642
|
+
let jit = null;
|
|
643
|
+
const finish = () => {
|
|
644
|
+
if (stmt?.Plan) statements.push(stmt);
|
|
645
|
+
stmt = null;
|
|
646
|
+
stack = [];
|
|
647
|
+
current = null;
|
|
648
|
+
jit = null;
|
|
649
|
+
};
|
|
650
|
+
for (let raw of splitIntoLines(input)) {
|
|
651
|
+
raw = raw.replace(/"\s*$/, "").replace(/^\s*"/, "").replace(/\t/g, " ");
|
|
652
|
+
const depth = raw.match(/^\s*/)?.[0].length ?? 0;
|
|
653
|
+
const line = raw.slice(depth);
|
|
654
|
+
if (line === "" || headerRe.test(line)) {
|
|
655
|
+
if (line === "" && stmt?.Plan) finish();
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
const nodeM = nodeRe.exec(line);
|
|
659
|
+
const subM = subRe.exec(line);
|
|
660
|
+
const cteM = cteRe.exec(line);
|
|
661
|
+
if (nodeM && !subM && !cteM) {
|
|
662
|
+
if (!stmt) stmt = {};
|
|
663
|
+
jit = null;
|
|
664
|
+
const node = { ...splitNodeType(nodeM[3] ?? "") };
|
|
665
|
+
if (nodeM[2]) node["Partial Mode"] = nodeM[2];
|
|
666
|
+
const startup = nodeM[4] ?? nodeM[13];
|
|
667
|
+
const total = nodeM[5] ?? nodeM[14];
|
|
668
|
+
if (startup && total) {
|
|
669
|
+
node["Startup Cost"] = Number(startup);
|
|
670
|
+
node["Total Cost"] = Number(total);
|
|
671
|
+
node["Plan Rows"] = Number(nodeM[6] ?? nodeM[15]);
|
|
672
|
+
node["Plan Width"] = Number(nodeM[7] ?? nodeM[16]);
|
|
673
|
+
}
|
|
674
|
+
const st = nodeM[8] ?? nodeM[17];
|
|
675
|
+
const tt = nodeM[9] ?? nodeM[18];
|
|
676
|
+
if (st && tt) {
|
|
677
|
+
node["Actual Startup Time"] = Number(st);
|
|
678
|
+
node["Actual Total Time"] = Number(tt);
|
|
679
|
+
}
|
|
680
|
+
const rows = nodeM[10] ?? nodeM[19];
|
|
681
|
+
const loops = nodeM[11] ?? nodeM[20];
|
|
682
|
+
if (rows && loops) {
|
|
683
|
+
node["Actual Rows"] = Number(rows);
|
|
684
|
+
node["Actual Loops"] = Number(loops);
|
|
685
|
+
}
|
|
686
|
+
if (nodeM[12] ?? nodeM[21]) {
|
|
687
|
+
node["Actual Loops"] = 0;
|
|
688
|
+
node["Actual Rows"] = 0;
|
|
689
|
+
}
|
|
690
|
+
stack = stack.filter((f) => f.depth < depth);
|
|
691
|
+
const parent = stack[stack.length - 1];
|
|
692
|
+
if (!parent) {
|
|
693
|
+
stmt.Plan = node;
|
|
694
|
+
} else {
|
|
695
|
+
if (parent.rel) {
|
|
696
|
+
node["Parent Relationship"] = parent.rel;
|
|
697
|
+
if (parent.name) node["Subplan Name"] = parent.name;
|
|
698
|
+
}
|
|
699
|
+
const parentNode = parent.node;
|
|
700
|
+
if (!parentNode.Plans) parentNode.Plans = [];
|
|
701
|
+
parentNode.Plans.push(node);
|
|
702
|
+
}
|
|
703
|
+
stack.push({ depth, node });
|
|
704
|
+
current = node;
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
if (subM || cteM) {
|
|
708
|
+
stack = stack.filter((f) => f.depth < depth);
|
|
709
|
+
const parent = stack[stack.length - 1];
|
|
710
|
+
if (!parent) continue;
|
|
711
|
+
if (cteM?.[1])
|
|
712
|
+
stack.push({ depth, node: parent.node, rel: "InitPlan", name: `CTE ${cteM[1]}` });
|
|
713
|
+
else if (subM?.[1])
|
|
714
|
+
stack.push({
|
|
715
|
+
depth,
|
|
716
|
+
node: parent.node,
|
|
717
|
+
rel: subM[1],
|
|
718
|
+
name: (subM[0] ?? "").trim()
|
|
719
|
+
});
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
const workerM = workerRe.exec(line);
|
|
723
|
+
if (workerM && current) {
|
|
724
|
+
const worker = { "Worker Number": Number(workerM[1]) };
|
|
725
|
+
if (workerM[2] && workerM[3]) {
|
|
726
|
+
worker["Actual Startup Time"] = Number(workerM[2]);
|
|
727
|
+
worker["Actual Total Time"] = Number(workerM[3]);
|
|
728
|
+
}
|
|
729
|
+
if (workerM[4] && workerM[5]) {
|
|
730
|
+
worker["Actual Rows"] = Number(workerM[4]);
|
|
731
|
+
worker["Actual Loops"] = Number(workerM[5]);
|
|
732
|
+
}
|
|
733
|
+
if (!Array.isArray(current.Workers)) current.Workers = [];
|
|
734
|
+
current.Workers.push(worker);
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
const trigM = triggerRe.exec(line);
|
|
738
|
+
if (trigM && stmt) {
|
|
739
|
+
if (!Array.isArray(stmt.Triggers)) stmt.Triggers = [];
|
|
740
|
+
stmt.Triggers.push({
|
|
741
|
+
"Trigger Name": trigM[1],
|
|
742
|
+
Time: Number(trigM[2]),
|
|
743
|
+
Calls: Number(trigM[3])
|
|
744
|
+
});
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
const kv = line.match(/^([^:]+):\s*(.*)$/);
|
|
748
|
+
if (!kv?.[1]) continue;
|
|
749
|
+
const key = kv[1].trim();
|
|
750
|
+
const value = (kv[2] ?? "").trim();
|
|
751
|
+
if (key === "JIT") {
|
|
752
|
+
jit = {};
|
|
753
|
+
if (stmt) stmt.JIT = jit;
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
if (jit) {
|
|
757
|
+
if (key === "Functions") jit.Functions = Number(value);
|
|
758
|
+
else if (key === "Timing") {
|
|
759
|
+
const timing = {};
|
|
760
|
+
for (const part of value.split(/,\s*/)) {
|
|
761
|
+
const t = part.match(/^(\S+)\s+(\d+\.\d+)\s*ms/);
|
|
762
|
+
if (t?.[1]) timing[t[1]] = Number(t[2]);
|
|
763
|
+
}
|
|
764
|
+
jit.Timing = timing;
|
|
765
|
+
}
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
if (key === "Planning Time") {
|
|
769
|
+
if (stmt) stmt["Planning Time"] = parseFloat(value);
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (key === "Execution Time" || key === "Total runtime") {
|
|
773
|
+
if (stmt) stmt["Execution Time"] = parseFloat(value);
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
if (key === "Settings") {
|
|
777
|
+
if (stmt) stmt.Settings = parseSettings(value);
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (!current) continue;
|
|
781
|
+
if (parseSort(line, current) || parseBuffers(line, current) || parseWal(line, current) || parseIoTimings(line, current)) {
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
current[key] = LIST_KEYS.has(key) ? splitList(value) : numeric(value);
|
|
785
|
+
}
|
|
786
|
+
finish();
|
|
787
|
+
return statements;
|
|
788
|
+
}
|
|
485
789
|
var PlanNodeSchema = z.looseObject({
|
|
486
790
|
"Node Type": z.string(),
|
|
487
791
|
get Plans() {
|
|
@@ -601,8 +905,13 @@ function normalizeNode(raw, nextId) {
|
|
|
601
905
|
ioReadTime: num(raw, "I/O Read Time"),
|
|
602
906
|
ioWriteTime: num(raw, "I/O Write Time"),
|
|
603
907
|
workersPlanned: num(raw, "Workers Planned"),
|
|
604
|
-
workersLaunched: num(raw, "Workers Launched")
|
|
908
|
+
workersLaunched: num(raw, "Workers Launched"),
|
|
909
|
+
walRecords: num(raw, "WAL Records"),
|
|
910
|
+
walBytes: num(raw, "WAL Bytes"),
|
|
911
|
+
walFpi: num(raw, "WAL FPI")
|
|
605
912
|
});
|
|
913
|
+
const workers = parseWorkers(raw.Workers);
|
|
914
|
+
if (workers.length) node.workers = workers;
|
|
606
915
|
const childPlans = raw.Plans;
|
|
607
916
|
if (Array.isArray(childPlans)) {
|
|
608
917
|
for (const child of childPlans) {
|
|
@@ -616,6 +925,24 @@ function assign(target, fields) {
|
|
|
616
925
|
if (v !== void 0) target[k] = v;
|
|
617
926
|
}
|
|
618
927
|
}
|
|
928
|
+
function parseWorkers(raw) {
|
|
929
|
+
if (!Array.isArray(raw)) return [];
|
|
930
|
+
const out = [];
|
|
931
|
+
for (const w of raw) {
|
|
932
|
+
const r = w;
|
|
933
|
+
const number = num(r, "Worker Number");
|
|
934
|
+
if (number === void 0) continue;
|
|
935
|
+
const stat2 = { number };
|
|
936
|
+
assign(stat2, {
|
|
937
|
+
actualRows: num(r, "Actual Rows"),
|
|
938
|
+
actualLoops: num(r, "Actual Loops"),
|
|
939
|
+
actualStartupTime: num(r, "Actual Startup Time"),
|
|
940
|
+
actualTotalTime: num(r, "Actual Total Time")
|
|
941
|
+
});
|
|
942
|
+
out.push(stat2);
|
|
943
|
+
}
|
|
944
|
+
return out;
|
|
945
|
+
}
|
|
619
946
|
function parseTriggers(raw) {
|
|
620
947
|
if (!Array.isArray(raw)) return [];
|
|
621
948
|
return raw.map((t) => {
|
|
@@ -649,6 +976,36 @@ function parseJit(raw) {
|
|
|
649
976
|
}
|
|
650
977
|
return jit;
|
|
651
978
|
}
|
|
979
|
+
function statementToTree(stmt) {
|
|
980
|
+
let id = 0;
|
|
981
|
+
const root = normalizeNode(stmt.Plan, () => id++);
|
|
982
|
+
const hasAnalyze = root.actualLoops !== void 0 || stmt["Execution Time"] !== void 0;
|
|
983
|
+
const hasBuffers = root.sharedHitBlocks !== void 0 || root.sharedReadBlocks !== void 0;
|
|
984
|
+
const tree = {
|
|
985
|
+
root,
|
|
986
|
+
triggers: parseTriggers(stmt.Triggers),
|
|
987
|
+
hasAnalyze,
|
|
988
|
+
hasBuffers,
|
|
989
|
+
raw: stmt.Plan
|
|
990
|
+
};
|
|
991
|
+
if (typeof stmt["Planning Time"] === "number") tree.planningTime = stmt["Planning Time"];
|
|
992
|
+
if (typeof stmt["Execution Time"] === "number") tree.executionTime = stmt["Execution Time"];
|
|
993
|
+
const serialization = stmt.Serialization;
|
|
994
|
+
if (serialization && typeof serialization === "object") {
|
|
995
|
+
const t = num(serialization, "Time");
|
|
996
|
+
if (t !== void 0) tree.serializationTime = t;
|
|
997
|
+
}
|
|
998
|
+
const jit = parseJit(stmt.JIT);
|
|
999
|
+
if (jit) tree.jit = jit;
|
|
1000
|
+
if (stmt.Settings) tree.settings = stmt.Settings;
|
|
1001
|
+
return tree;
|
|
1002
|
+
}
|
|
1003
|
+
function parseExplain(input) {
|
|
1004
|
+
return /^\s*[[{]/.test(input) ? parseExplainJson(input) : parseExplainText(input);
|
|
1005
|
+
}
|
|
1006
|
+
function parseExplainText(input) {
|
|
1007
|
+
return parseTextToStatements(input).map(statementToTree);
|
|
1008
|
+
}
|
|
652
1009
|
function parseExplainJson(input) {
|
|
653
1010
|
const json = parseJsonWithLocation(input);
|
|
654
1011
|
let candidate = json;
|
|
@@ -663,25 +1020,7 @@ function parseExplainJson(input) {
|
|
|
663
1020
|
location: { kind: "input" }
|
|
664
1021
|
});
|
|
665
1022
|
}
|
|
666
|
-
return result.data.map((stmt) =>
|
|
667
|
-
let id = 0;
|
|
668
|
-
const root = normalizeNode(stmt.Plan, () => id++);
|
|
669
|
-
const hasAnalyze = root.actualLoops !== void 0 || stmt["Execution Time"] !== void 0;
|
|
670
|
-
const hasBuffers = root.sharedHitBlocks !== void 0 || root.sharedReadBlocks !== void 0;
|
|
671
|
-
const tree = {
|
|
672
|
-
root,
|
|
673
|
-
triggers: parseTriggers(stmt.Triggers),
|
|
674
|
-
hasAnalyze,
|
|
675
|
-
hasBuffers,
|
|
676
|
-
raw: stmt.Plan
|
|
677
|
-
};
|
|
678
|
-
if (stmt["Planning Time"] !== void 0) tree.planningTime = stmt["Planning Time"];
|
|
679
|
-
if (stmt["Execution Time"] !== void 0) tree.executionTime = stmt["Execution Time"];
|
|
680
|
-
const jit = parseJit(stmt.JIT);
|
|
681
|
-
if (jit) tree.jit = jit;
|
|
682
|
-
if (stmt.Settings) tree.settings = stmt.Settings;
|
|
683
|
-
return tree;
|
|
684
|
-
});
|
|
1023
|
+
return result.data.map((stmt) => statementToTree(stmt));
|
|
685
1024
|
}
|
|
686
1025
|
function walk(node, visit) {
|
|
687
1026
|
visit(node);
|
|
@@ -746,6 +1085,31 @@ function executionMs(tree) {
|
|
|
746
1085
|
function bottlenecks(tree, n = 5) {
|
|
747
1086
|
return flatten(tree.root).filter((node) => node.metrics.selfMs !== void 0).sort((a, b) => (b.metrics.selfMs ?? 0) - (a.metrics.selfMs ?? 0)).slice(0, n);
|
|
748
1087
|
}
|
|
1088
|
+
function aggregateStats(tree) {
|
|
1089
|
+
const total = executionMs(tree) ?? 0;
|
|
1090
|
+
const groupBy = (keyOf) => {
|
|
1091
|
+
const acc = /* @__PURE__ */ new Map();
|
|
1092
|
+
for (const n of flatten(tree.root)) {
|
|
1093
|
+
const key = keyOf(n);
|
|
1094
|
+
if (!key) continue;
|
|
1095
|
+
const e = acc.get(key) ?? { count: 0, selfMs: 0 };
|
|
1096
|
+
e.count++;
|
|
1097
|
+
e.selfMs += n.metrics.selfMs ?? 0;
|
|
1098
|
+
acc.set(key, e);
|
|
1099
|
+
}
|
|
1100
|
+
return [...acc.entries()].map(([key, e]) => ({
|
|
1101
|
+
key,
|
|
1102
|
+
count: e.count,
|
|
1103
|
+
selfMs: e.selfMs,
|
|
1104
|
+
pctOfTotal: total > 0 ? 100 * e.selfMs / total : 0
|
|
1105
|
+
})).sort((a, b) => b.selfMs - a.selfMs || b.count - a.count);
|
|
1106
|
+
};
|
|
1107
|
+
return {
|
|
1108
|
+
byNodeType: groupBy((n) => n.nodeType),
|
|
1109
|
+
byRelation: groupBy((n) => n.relationName),
|
|
1110
|
+
byIndex: groupBy((n) => n.indexName)
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
749
1113
|
function nodeLabel(node) {
|
|
750
1114
|
let label = node.nodeType;
|
|
751
1115
|
if (node.indexName && node.relationName)
|
|
@@ -1330,8 +1694,8 @@ var rowMisestimate = {
|
|
|
1330
1694
|
const target = rel ?? "the underlying table";
|
|
1331
1695
|
const under = estimateDirection === "under";
|
|
1332
1696
|
const direction = under ? "underestimate" : "overestimate";
|
|
1333
|
-
const
|
|
1334
|
-
const detail = `Postgres estimated ${fmtInt(node.planRows)} rows but ${fmtInt(
|
|
1697
|
+
const actual2 = totalRows ?? 0;
|
|
1698
|
+
const detail = `Postgres estimated ${fmtInt(node.planRows)} rows but ${fmtInt(actual2)} were produced \u2014 a ${fmtInt(factor)}x ${direction}${onRel}.`;
|
|
1335
1699
|
return [
|
|
1336
1700
|
makeFinding(rowMisestimate, ctx, node, {
|
|
1337
1701
|
// Severity: underestimates are the dangerous ones (under-sized joins/memory).
|
|
@@ -1366,7 +1730,7 @@ ANALYZE ${rel ?? "<relation>"};`
|
|
|
1366
1730
|
docsUrl: `${DOCS2}/planner-stats.html`,
|
|
1367
1731
|
meta: {
|
|
1368
1732
|
estimatedRows: Math.round(node.planRows),
|
|
1369
|
-
actualRows: Math.round(
|
|
1733
|
+
actualRows: Math.round(actual2),
|
|
1370
1734
|
factor,
|
|
1371
1735
|
direction: estimateDirection
|
|
1372
1736
|
}
|
|
@@ -1711,6 +2075,138 @@ function redactPlanTree(tree) {
|
|
|
1711
2075
|
walk(tree.root, redactNode);
|
|
1712
2076
|
}
|
|
1713
2077
|
|
|
2078
|
+
// src/locks/advisor.ts
|
|
2079
|
+
var DOCS3 = "https://www.postgresql.org/docs/current";
|
|
2080
|
+
function analyzeLocks(sql, tree) {
|
|
2081
|
+
const code = stripSql(sql);
|
|
2082
|
+
const upper = code.toUpperCase();
|
|
2083
|
+
const kw = (code.trim().split(/\s+/)[0] ?? "").toUpperCase();
|
|
2084
|
+
const out = [];
|
|
2085
|
+
const add = (id, severity, parts) => {
|
|
2086
|
+
out.push({
|
|
2087
|
+
code: id,
|
|
2088
|
+
domain: "plan",
|
|
2089
|
+
severity,
|
|
2090
|
+
title: parts.title,
|
|
2091
|
+
detail: parts.detail,
|
|
2092
|
+
cause: parts.cause,
|
|
2093
|
+
remediation: { summary: parts.fix, commands: parts.commands },
|
|
2094
|
+
docsUrl: `${DOCS3}/explicit-locking.html`
|
|
2095
|
+
});
|
|
2096
|
+
};
|
|
2097
|
+
if (/\bVACUUM\s+FULL\b/.test(upper) || /\bCLUSTER\b/.test(upper) || /\bALTER\s+TABLE\b[\s\S]*\b(TYPE|SET\s+DATA\s+TYPE)\b/.test(upper)) {
|
|
2098
|
+
add("PGX_LOCK_TABLE_REWRITE", "error", {
|
|
2099
|
+
title: "Operation rewrites the table under an ACCESS EXCLUSIVE lock",
|
|
2100
|
+
detail: "VACUUM FULL / CLUSTER / a column-type change rewrites the whole table and holds ACCESS EXCLUSIVE for the duration.",
|
|
2101
|
+
cause: "ACCESS EXCLUSIVE blocks every reader and writer until the rewrite finishes \u2014 an outage on a busy table.",
|
|
2102
|
+
fix: "Avoid the full rewrite: use pg_repack for bloat instead of VACUUM FULL/CLUSTER; for type changes, add a new column, backfill in batches, and swap. Always do rewrites off-peak with a lock_timeout.",
|
|
2103
|
+
commands: [{ label: "Bound the wait", sql: "SET lock_timeout = '3s';" }]
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
if (/\bCREATE\s+(UNIQUE\s+)?INDEX\b/.test(upper) && !/\bCONCURRENTLY\b/.test(upper)) {
|
|
2107
|
+
add("PGX_DDL_NO_CONCURRENTLY", "warn", {
|
|
2108
|
+
title: "CREATE INDEX without CONCURRENTLY blocks writes",
|
|
2109
|
+
detail: "A plain CREATE INDEX takes a SHARE lock, blocking all writes to the table until the build completes.",
|
|
2110
|
+
cause: "On a large or busy table the build can take minutes, during which inserts/updates/deletes are blocked.",
|
|
2111
|
+
fix: "Build the index online with CONCURRENTLY (note: it cannot run inside a transaction and may leave an INVALID index on failure, which you then drop and recreate).",
|
|
2112
|
+
commands: [{ label: "Build online", sql: "CREATE INDEX CONCURRENTLY ON <table> (<cols>);" }]
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
if (/\bDROP\s+INDEX\b/.test(upper) && !/\bCONCURRENTLY\b/.test(upper)) {
|
|
2116
|
+
add("PGX_DROP_INDEX_NO_CONCURRENTLY", "warn", {
|
|
2117
|
+
title: "DROP INDEX without CONCURRENTLY takes ACCESS EXCLUSIVE",
|
|
2118
|
+
detail: "A plain DROP INDEX locks the table with ACCESS EXCLUSIVE.",
|
|
2119
|
+
cause: "Readers and writers block until the drop completes.",
|
|
2120
|
+
fix: "Use DROP INDEX CONCURRENTLY to avoid blocking.",
|
|
2121
|
+
commands: [{ label: "Drop online", sql: "DROP INDEX CONCURRENTLY <index>;" }]
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
if (/\bTRUNCATE\b/.test(upper)) {
|
|
2125
|
+
add("PGX_LOCK_TRUNCATE", "info", {
|
|
2126
|
+
title: "TRUNCATE takes an ACCESS EXCLUSIVE lock",
|
|
2127
|
+
detail: "TRUNCATE briefly locks the table with ACCESS EXCLUSIVE.",
|
|
2128
|
+
cause: "It is fast (no row scan) but still blocks all access while it runs and is transactional.",
|
|
2129
|
+
fix: "Fine for maintenance windows; on a hot table, set a lock_timeout so it fails fast instead of queueing behind/ahead of other transactions.",
|
|
2130
|
+
commands: [{ label: "Bound the wait", sql: "SET lock_timeout = '3s';" }]
|
|
2131
|
+
});
|
|
2132
|
+
}
|
|
2133
|
+
if (/\bLOCK\s+TABLE\b/.test(upper)) {
|
|
2134
|
+
add("PGX_LOCK_TABLE_EXPLICIT", "info", {
|
|
2135
|
+
title: "Explicit LOCK TABLE",
|
|
2136
|
+
detail: "An explicit LOCK TABLE acquires the named lock mode for the rest of the transaction.",
|
|
2137
|
+
cause: "Holding a strong lock longer than necessary blocks other sessions.",
|
|
2138
|
+
fix: "Use the lowest lock mode that suffices and keep the transaction short."
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
if (/\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/.test(upper) && !/\bLIMIT\b/.test(upper)) {
|
|
2142
|
+
add("PGX_SELECT_FOR_UPDATE_UNBOUNDED", "warn", {
|
|
2143
|
+
title: "Row-locking SELECT without a LIMIT",
|
|
2144
|
+
detail: "SELECT \u2026 FOR UPDATE/SHARE locks every row it matches, held until the transaction ends.",
|
|
2145
|
+
cause: "Locking an unbounded set increases contention and deadlock risk with concurrent updaters.",
|
|
2146
|
+
fix: "Bound the set with a deterministic ORDER BY + LIMIT (and process in batches); a consistent lock order also avoids deadlocks.",
|
|
2147
|
+
commands: [{ label: "Bound + order", sql: "SELECT \u2026 ORDER BY id FOR UPDATE LIMIT 100;" }]
|
|
2148
|
+
});
|
|
2149
|
+
}
|
|
2150
|
+
if (kw === "UPDATE" || kw === "DELETE") {
|
|
2151
|
+
if (!/\bWHERE\b/.test(upper)) {
|
|
2152
|
+
add("PGX_WRITE_NO_WHERE", "warn", {
|
|
2153
|
+
title: `${kw} without a WHERE clause locks every row`,
|
|
2154
|
+
detail: `This ${kw} touches the whole table, taking a row lock on every row until commit.`,
|
|
2155
|
+
cause: "All rows are locked for the transaction's duration, blocking concurrent writers and bloating the table.",
|
|
2156
|
+
fix: "Add a WHERE clause; for large rewrites, update in batches (e.g. by primary-key ranges) and commit between batches."
|
|
2157
|
+
});
|
|
2158
|
+
} else if (tree && hasSeqScanOnTarget(tree, targetTable(code, kw))) {
|
|
2159
|
+
const rel = targetTable(code, kw);
|
|
2160
|
+
add("PGX_UPDATE_UNINDEXED_PREDICATE", "warn", {
|
|
2161
|
+
title: `${kw} scans ${rel ?? "the table"} sequentially to find rows`,
|
|
2162
|
+
detail: `The plan uses a Seq Scan on ${rel ?? "the target table"}, so the ${kw} reads (and locks the touched rows of) the whole table.`,
|
|
2163
|
+
cause: "An unindexed predicate means more rows scanned and locked, and the locks are held until commit.",
|
|
2164
|
+
fix: `Index the ${kw}'s WHERE columns so it finds rows via an index and locks only what it changes.`,
|
|
2165
|
+
commands: [
|
|
2166
|
+
{
|
|
2167
|
+
label: "Index the predicate",
|
|
2168
|
+
sql: `CREATE INDEX ON ${rel ?? "<table>"} (<where columns>);`
|
|
2169
|
+
}
|
|
2170
|
+
]
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
if (/^(ALTER|CREATE|DROP)\b/.test(kw) && !/\bCONCURRENTLY\b/.test(upper) && !/\bSET\s+LOCK_TIMEOUT\b/.test(upper)) {
|
|
2175
|
+
add("PGX_DDL_NO_LOCK_TIMEOUT", "warn", {
|
|
2176
|
+
title: "DDL without a lock_timeout can stall the whole table",
|
|
2177
|
+
detail: "This DDL needs a strong lock; if it waits behind a long transaction, every query that arrives after it also queues behind the DDL.",
|
|
2178
|
+
cause: "A blocked ACCESS EXCLUSIVE request sits at the head of the lock queue and blocks new readers/writers too.",
|
|
2179
|
+
fix: "Set a short lock_timeout before the DDL and retry, so it fails fast instead of forming a queue.",
|
|
2180
|
+
commands: [
|
|
2181
|
+
{
|
|
2182
|
+
label: "Fail fast, then retry",
|
|
2183
|
+
sql: "SET lock_timeout = '3s';\n-- run the DDL; on timeout, retry later"
|
|
2184
|
+
}
|
|
2185
|
+
]
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
return out;
|
|
2189
|
+
}
|
|
2190
|
+
function stripSql(sql) {
|
|
2191
|
+
return sql.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ").replace(/'(?:[^']|'')*'/g, "''").replace(/"(?:[^"]|"")*"/g, '"x"');
|
|
2192
|
+
}
|
|
2193
|
+
function targetTable(code, kw) {
|
|
2194
|
+
const re = kw === "DELETE" ? /\bDELETE\s+FROM\s+([A-Za-z_][\w.]*)/i : /\bUPDATE\s+(?:ONLY\s+)?([A-Za-z_][\w.]*)/i;
|
|
2195
|
+
const m = re.exec(code);
|
|
2196
|
+
return m?.[1];
|
|
2197
|
+
}
|
|
2198
|
+
function hasSeqScanOnTarget(tree, table) {
|
|
2199
|
+
let found = false;
|
|
2200
|
+
walk(tree.root, (n) => {
|
|
2201
|
+
if (n.nodeType === "Seq Scan" && (!table || n.relationName === bareName(table))) found = true;
|
|
2202
|
+
});
|
|
2203
|
+
return found;
|
|
2204
|
+
}
|
|
2205
|
+
function bareName(qualified) {
|
|
2206
|
+
const parts = qualified.split(".");
|
|
2207
|
+
return parts[parts.length - 1] ?? qualified;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
1714
2210
|
// src/report/tree.ts
|
|
1715
2211
|
function treeLines(tree, glyphs) {
|
|
1716
2212
|
const lines = [];
|
|
@@ -1751,21 +2247,28 @@ function nodeSummary(node) {
|
|
|
1751
2247
|
// src/report/json.ts
|
|
1752
2248
|
var JSON_SCHEMA_VERSION = 1;
|
|
1753
2249
|
function renderJson(result, pretty = true) {
|
|
2250
|
+
return JSON.stringify(buildReport(result), null, pretty ? 2 : 0);
|
|
2251
|
+
}
|
|
2252
|
+
function buildReport(result) {
|
|
1754
2253
|
const { tree, diagnostics, bottlenecks: bottlenecks2 } = result;
|
|
1755
2254
|
const counts = { error: 0, warn: 0, info: 0 };
|
|
1756
2255
|
for (const d of diagnostics) counts[d.severity]++;
|
|
1757
|
-
|
|
2256
|
+
return {
|
|
1758
2257
|
schemaVersion: JSON_SCHEMA_VERSION,
|
|
1759
2258
|
verdict: result.verdict,
|
|
1760
2259
|
worstSeverity: result.worstSeverity,
|
|
1761
2260
|
summary: {
|
|
1762
2261
|
planningTimeMs: tree.planningTime ?? null,
|
|
1763
2262
|
executionTimeMs: executionMs(tree) ?? null,
|
|
2263
|
+
serializationTimeMs: tree.serializationTime ?? null,
|
|
1764
2264
|
hasAnalyze: tree.hasAnalyze,
|
|
1765
2265
|
hasBuffers: tree.hasBuffers,
|
|
1766
2266
|
nodeCount: flatten(tree.root).length,
|
|
1767
2267
|
findings: counts
|
|
1768
2268
|
},
|
|
2269
|
+
triggers: tree.triggers,
|
|
2270
|
+
jit: tree.jit ?? null,
|
|
2271
|
+
settings: tree.settings ?? null,
|
|
1769
2272
|
diagnostics,
|
|
1770
2273
|
bottlenecks: bottlenecks2.filter((n) => (n.metrics.selfMs ?? 0) > 0).map((n) => ({
|
|
1771
2274
|
id: n.id,
|
|
@@ -1776,9 +2279,9 @@ function renderJson(result, pretty = true) {
|
|
|
1776
2279
|
pctOfTotal: n.metrics.pctOfTotal ?? null,
|
|
1777
2280
|
totalRows: n.metrics.totalRows ?? null
|
|
1778
2281
|
})),
|
|
2282
|
+
stats: aggregateStats(tree),
|
|
1779
2283
|
plan: serializeNode(tree.root)
|
|
1780
2284
|
};
|
|
1781
|
-
return JSON.stringify(report, null, pretty ? 2 : 0);
|
|
1782
2285
|
}
|
|
1783
2286
|
function serializeNode(node) {
|
|
1784
2287
|
const { children, metrics, raw, ...fields } = node;
|
|
@@ -2074,14 +2577,15 @@ function render(result, opts) {
|
|
|
2074
2577
|
|
|
2075
2578
|
// src/index.ts
|
|
2076
2579
|
function analyze(input, options = {}) {
|
|
2077
|
-
const trees =
|
|
2580
|
+
const trees = parseExplain(input);
|
|
2078
2581
|
const tree = selectStatement(trees, options.statement);
|
|
2079
2582
|
if (options.redact) redactPlanTree(tree);
|
|
2080
2583
|
computeMetrics(tree);
|
|
2081
2584
|
const result = runAdvisor(tree, options.config ?? DEFAULT_CONFIG);
|
|
2082
|
-
const
|
|
2083
|
-
if (
|
|
2084
|
-
|
|
2585
|
+
const extra = planNotices(tree);
|
|
2586
|
+
if (options.sql) extra.push(...analyzeLocks(options.sql, tree));
|
|
2587
|
+
if (extra.length) {
|
|
2588
|
+
result.diagnostics = [...result.diagnostics, ...extra].sort(bySeverity);
|
|
2085
2589
|
result.worstSeverity = result.diagnostics.reduce(
|
|
2086
2590
|
(worst, d) => worst === null ? d.severity : maxSeverity(worst, d.severity),
|
|
2087
2591
|
null
|
|
@@ -2163,6 +2667,50 @@ async function resolvePlanInput(file) {
|
|
|
2163
2667
|
if (!text.trim()) throw opError("PGX_EMPTY_INPUT");
|
|
2164
2668
|
return text;
|
|
2165
2669
|
}
|
|
2670
|
+
|
|
2671
|
+
// src/util/log.ts
|
|
2672
|
+
var level = "normal";
|
|
2673
|
+
function setLogLevel(l) {
|
|
2674
|
+
level = l;
|
|
2675
|
+
}
|
|
2676
|
+
function isDebug() {
|
|
2677
|
+
return level === "debug";
|
|
2678
|
+
}
|
|
2679
|
+
function write(msg) {
|
|
2680
|
+
process.stderr.write(`${msg}
|
|
2681
|
+
`);
|
|
2682
|
+
}
|
|
2683
|
+
function logInfo(msg) {
|
|
2684
|
+
if (level !== "quiet") write(msg);
|
|
2685
|
+
}
|
|
2686
|
+
function logVerbose(msg) {
|
|
2687
|
+
if (level === "verbose" || level === "debug") write(msg);
|
|
2688
|
+
}
|
|
2689
|
+
function logError(msg) {
|
|
2690
|
+
write(msg);
|
|
2691
|
+
}
|
|
2692
|
+
function browserCommand(platform) {
|
|
2693
|
+
if (platform === "darwin") return "open";
|
|
2694
|
+
if (platform === "win32") return "start";
|
|
2695
|
+
return "xdg-open";
|
|
2696
|
+
}
|
|
2697
|
+
function openInBrowser(target, platform = process.platform) {
|
|
2698
|
+
const cmd = browserCommand(platform);
|
|
2699
|
+
try {
|
|
2700
|
+
const child = spawn(cmd, [target], {
|
|
2701
|
+
stdio: "ignore",
|
|
2702
|
+
detached: true,
|
|
2703
|
+
shell: platform === "win32"
|
|
2704
|
+
// `start` is a shell builtin
|
|
2705
|
+
});
|
|
2706
|
+
child.on("error", () => {
|
|
2707
|
+
});
|
|
2708
|
+
child.unref();
|
|
2709
|
+
} catch {
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
// src/commands/emit.ts
|
|
2166
2714
|
async function emit(result, opts) {
|
|
2167
2715
|
configureColor(opts.format === "terminal" ? opts.color : "never");
|
|
2168
2716
|
const text = render(result, {
|
|
@@ -2173,6 +2721,12 @@ async function emit(result, opts) {
|
|
|
2173
2721
|
});
|
|
2174
2722
|
if (opts.output) {
|
|
2175
2723
|
await writeFile(opts.output, text);
|
|
2724
|
+
if (opts.format === "html" && opts.openHtml) openInBrowser(opts.output);
|
|
2725
|
+
} else if (opts.format === "html" && opts.openHtml) {
|
|
2726
|
+
const file = join(tmpdir(), `pg-explain-${Date.now()}.html`);
|
|
2727
|
+
await writeFile(file, text);
|
|
2728
|
+
logInfo(`Opened HTML report: ${file}`);
|
|
2729
|
+
openInBrowser(file);
|
|
2176
2730
|
} else {
|
|
2177
2731
|
process.stdout.write(text.endsWith("\n") ? text : `${text}
|
|
2178
2732
|
`);
|
|
@@ -2499,28 +3053,6 @@ async function readPlan(path) {
|
|
|
2499
3053
|
}
|
|
2500
3054
|
}
|
|
2501
3055
|
|
|
2502
|
-
// src/util/log.ts
|
|
2503
|
-
var level = "normal";
|
|
2504
|
-
function setLogLevel(l) {
|
|
2505
|
-
level = l;
|
|
2506
|
-
}
|
|
2507
|
-
function isDebug() {
|
|
2508
|
-
return level === "debug";
|
|
2509
|
-
}
|
|
2510
|
-
function write(msg) {
|
|
2511
|
-
process.stderr.write(`${msg}
|
|
2512
|
-
`);
|
|
2513
|
-
}
|
|
2514
|
-
function logInfo(msg) {
|
|
2515
|
-
if (level !== "quiet") write(msg);
|
|
2516
|
-
}
|
|
2517
|
-
function logVerbose(msg) {
|
|
2518
|
-
if (level === "verbose" || level === "debug") write(msg);
|
|
2519
|
-
}
|
|
2520
|
-
function logError(msg) {
|
|
2521
|
-
write(msg);
|
|
2522
|
-
}
|
|
2523
|
-
|
|
2524
3056
|
// src/db/version.ts
|
|
2525
3057
|
function capabilities(versionNum) {
|
|
2526
3058
|
const major = Math.floor(versionNum / 1e4);
|
|
@@ -2742,6 +3274,67 @@ async function runExplain(opts) {
|
|
|
2742
3274
|
function msInt(ms) {
|
|
2743
3275
|
return Math.max(0, Math.floor(ms));
|
|
2744
3276
|
}
|
|
3277
|
+
async function explainScript(connection, units, opts) {
|
|
3278
|
+
const ca = connection.sslrootcert ? await readFile(connection.sslrootcert, "utf8").catch(() => void 0) : void 0;
|
|
3279
|
+
const client = await newClient(buildClientConfig(connection, ca));
|
|
3280
|
+
try {
|
|
3281
|
+
await client.connect();
|
|
3282
|
+
} catch (err) {
|
|
3283
|
+
throw mapConnectError(err);
|
|
3284
|
+
}
|
|
3285
|
+
try {
|
|
3286
|
+
const caps = capabilities(await fetchVersionNum(client));
|
|
3287
|
+
await client.query("BEGIN");
|
|
3288
|
+
const results = [];
|
|
3289
|
+
try {
|
|
3290
|
+
await client.query(`SET LOCAL statement_timeout = ${msInt(opts.statementTimeoutMs)}`);
|
|
3291
|
+
await client.query(`SET LOCAL lock_timeout = ${msInt(opts.lockTimeoutMs)}`);
|
|
3292
|
+
await client.query("SET LOCAL transaction_read_only = on");
|
|
3293
|
+
for (const unit of units) {
|
|
3294
|
+
const flags = {
|
|
3295
|
+
analyze: false,
|
|
3296
|
+
// never execute
|
|
3297
|
+
buffers: false,
|
|
3298
|
+
// BUFFERS requires ANALYZE pre-16
|
|
3299
|
+
verbose: opts.verbose ?? false,
|
|
3300
|
+
settings: opts.settings ?? false,
|
|
3301
|
+
wal: false,
|
|
3302
|
+
timing: false,
|
|
3303
|
+
costs: true,
|
|
3304
|
+
summary: false,
|
|
3305
|
+
genericPlan: caps.genericPlan && /\$\d+/.test(unit.sql),
|
|
3306
|
+
compat: true
|
|
3307
|
+
// auto-omit anything the server is too old for
|
|
3308
|
+
};
|
|
3309
|
+
try {
|
|
3310
|
+
const { prefix } = buildExplain(flags, caps);
|
|
3311
|
+
const res = await client.query(`${prefix} ${unit.sql}`);
|
|
3312
|
+
const r = { label: unit.label, planJson: extractPlanJson(res.rows) };
|
|
3313
|
+
if (unit.loopNote) r.loopNote = unit.loopNote;
|
|
3314
|
+
results.push(r);
|
|
3315
|
+
} catch (err) {
|
|
3316
|
+
const diag = err instanceof AppError ? err.diagnostic : mapQueryError(err).diagnostic;
|
|
3317
|
+
const r = { label: unit.label, error: diag };
|
|
3318
|
+
if (unit.loopNote) r.loopNote = unit.loopNote;
|
|
3319
|
+
results.push(r);
|
|
3320
|
+
await client.query("ROLLBACK").catch(() => {
|
|
3321
|
+
});
|
|
3322
|
+
await client.query("BEGIN").catch(() => {
|
|
3323
|
+
});
|
|
3324
|
+
await client.query("SET LOCAL transaction_read_only = on").catch(() => {
|
|
3325
|
+
});
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
return { units: results, caps };
|
|
3329
|
+
} finally {
|
|
3330
|
+
await client.query("ROLLBACK").catch(() => {
|
|
3331
|
+
});
|
|
3332
|
+
}
|
|
3333
|
+
} finally {
|
|
3334
|
+
await client.end().catch(() => {
|
|
3335
|
+
});
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
2745
3338
|
async function fetchVersionNum(client) {
|
|
2746
3339
|
try {
|
|
2747
3340
|
const res = await client.query("SHOW server_version_num");
|
|
@@ -2791,7 +3384,9 @@ function mapQueryError(err) {
|
|
|
2791
3384
|
if (err instanceof AppError) return err;
|
|
2792
3385
|
const e = asPgError(err);
|
|
2793
3386
|
const msg = e.message ?? "";
|
|
2794
|
-
const meta =
|
|
3387
|
+
const meta = {};
|
|
3388
|
+
if (e.code) meta.sqlState = e.code;
|
|
3389
|
+
if (e.position) meta.position = Number(e.position);
|
|
2795
3390
|
switch (e.code) {
|
|
2796
3391
|
case "57014":
|
|
2797
3392
|
return /statement timeout/i.test(msg) ? opError("PGX_STATEMENT_TIMEOUT", { detail: msg, meta }, err) : opError("PGX_QUERY_CANCELED", { detail: msg, meta }, err);
|
|
@@ -2811,34 +3406,326 @@ function mapQueryError(err) {
|
|
|
2811
3406
|
}
|
|
2812
3407
|
}
|
|
2813
3408
|
|
|
3409
|
+
// src/sql/extract.ts
|
|
3410
|
+
var DML = /* @__PURE__ */ new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "VALUES", "TABLE", "WITH"]);
|
|
3411
|
+
function classifyStatement(sql) {
|
|
3412
|
+
const kw = firstKeyword(sql);
|
|
3413
|
+
if (!kw) return "empty";
|
|
3414
|
+
if (kw === "DO") return "do-block";
|
|
3415
|
+
if (DML.has(kw) || kw === "EXECUTE") return "explainable";
|
|
3416
|
+
return "utility";
|
|
3417
|
+
}
|
|
3418
|
+
function extractAnalyzableUnits(sql) {
|
|
3419
|
+
const units = [];
|
|
3420
|
+
for (const stmt of splitStatements(sql)) {
|
|
3421
|
+
const cls = classifyStatement(stmt);
|
|
3422
|
+
if (cls === "empty") continue;
|
|
3423
|
+
if (cls === "explainable") {
|
|
3424
|
+
units.push({ kind: "explainable", label: unitLabel(stmt), sql: stmt });
|
|
3425
|
+
} else if (cls === "do-block") {
|
|
3426
|
+
units.push(...extractFromDo(stmt));
|
|
3427
|
+
} else {
|
|
3428
|
+
const kw = firstKeyword(stmt);
|
|
3429
|
+
units.push({
|
|
3430
|
+
kind: "skipped",
|
|
3431
|
+
label: `${kw} \u2026`,
|
|
3432
|
+
reason: `EXPLAIN cannot analyze a ${kw} statement (it is a utility/transaction-control command, not an optimizable query).`
|
|
3433
|
+
});
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
return units;
|
|
3437
|
+
}
|
|
3438
|
+
function extractFromDo(doSql) {
|
|
3439
|
+
const body = dollarBody(doSql);
|
|
3440
|
+
if (body === null) {
|
|
3441
|
+
return [
|
|
3442
|
+
{ kind: "skipped", label: "DO block", reason: "Could not find the block body ($$ \u2026 $$)." }
|
|
3443
|
+
];
|
|
3444
|
+
}
|
|
3445
|
+
const out = [];
|
|
3446
|
+
for (const frag of splitStatements(body)) {
|
|
3447
|
+
const stripped = stripControl(frag);
|
|
3448
|
+
if (!stripped) continue;
|
|
3449
|
+
const kw = firstKeyword(stripped.sql);
|
|
3450
|
+
if (kw === "EXECUTE") {
|
|
3451
|
+
out.push({
|
|
3452
|
+
kind: "skipped",
|
|
3453
|
+
label: `${stripped.context}EXECUTE (dynamic SQL)`,
|
|
3454
|
+
reason: "Dynamic SQL built at runtime \u2014 the statement text isn't known statically, so it can't be analyzed."
|
|
3455
|
+
});
|
|
3456
|
+
continue;
|
|
3457
|
+
}
|
|
3458
|
+
if (DML.has(kw)) {
|
|
3459
|
+
const unit = {
|
|
3460
|
+
kind: "explainable",
|
|
3461
|
+
label: stripped.context + unitLabel(stripped.sql),
|
|
3462
|
+
sql: stripped.sql
|
|
3463
|
+
};
|
|
3464
|
+
if (stripped.loop) unit.loopNote = "runs once per loop iteration in the block";
|
|
3465
|
+
out.push(unit);
|
|
3466
|
+
}
|
|
3467
|
+
}
|
|
3468
|
+
if (out.length === 0) {
|
|
3469
|
+
out.push({
|
|
3470
|
+
kind: "skipped",
|
|
3471
|
+
label: "DO block",
|
|
3472
|
+
reason: "No top-level DML statements found to analyze."
|
|
3473
|
+
});
|
|
3474
|
+
}
|
|
3475
|
+
return out;
|
|
3476
|
+
}
|
|
3477
|
+
function stripControl(frag) {
|
|
3478
|
+
let rest = frag;
|
|
3479
|
+
let context = "";
|
|
3480
|
+
let loop = false;
|
|
3481
|
+
for (let guard = 0; guard < 8; guard++) {
|
|
3482
|
+
rest = rest.replace(/^\s+/, "");
|
|
3483
|
+
const masked = maskNonCode(rest);
|
|
3484
|
+
const kw2 = (/^[A-Za-z_]+/.exec(masked)?.[0] ?? "").toUpperCase();
|
|
3485
|
+
if (kw2 === "IF" || kw2 === "ELSIF") {
|
|
3486
|
+
const then = /\bTHEN\b/i.exec(masked);
|
|
3487
|
+
if (!then) break;
|
|
3488
|
+
context += kw2 === "IF" ? "IF-branch \u203A " : "ELSIF-branch \u203A ";
|
|
3489
|
+
rest = rest.slice(then.index + 4);
|
|
3490
|
+
} else if (kw2 === "ELSE") {
|
|
3491
|
+
context += "ELSE-branch \u203A ";
|
|
3492
|
+
rest = rest.replace(/^\s*ELSE\b/i, "");
|
|
3493
|
+
} else if (kw2 === "FOR" || kw2 === "WHILE") {
|
|
3494
|
+
const lp = /\bLOOP\b/i.exec(masked);
|
|
3495
|
+
if (!lp) break;
|
|
3496
|
+
loop = true;
|
|
3497
|
+
context += "loop \u203A ";
|
|
3498
|
+
rest = rest.slice(lp.index + 4);
|
|
3499
|
+
} else if (kw2 === "LOOP") {
|
|
3500
|
+
loop = true;
|
|
3501
|
+
context += "loop \u203A ";
|
|
3502
|
+
rest = rest.replace(/^\s*LOOP\b/i, "");
|
|
3503
|
+
} else if (kw2 === "BEGIN" || kw2 === "THEN") {
|
|
3504
|
+
rest = rest.replace(/^\s*(BEGIN|THEN)\b/i, "");
|
|
3505
|
+
} else {
|
|
3506
|
+
break;
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
rest = rest.replace(/^\s+/, "").replace(/;\s*$/, "").trim();
|
|
3510
|
+
if (!rest) return null;
|
|
3511
|
+
const kw = firstKeyword(rest);
|
|
3512
|
+
if (kw === "PERFORM") return { context, sql: rest.replace(/^\s*PERFORM\b/i, "SELECT"), loop };
|
|
3513
|
+
if (DML.has(kw) || kw === "EXECUTE") return { context, sql: rest, loop };
|
|
3514
|
+
return null;
|
|
3515
|
+
}
|
|
3516
|
+
function firstKeyword(sql) {
|
|
3517
|
+
const m = /^[A-Za-z_]+/.exec(maskNonCode(sql).trim());
|
|
3518
|
+
return m ? m[0].toUpperCase() : "";
|
|
3519
|
+
}
|
|
3520
|
+
function unitLabel(stmt) {
|
|
3521
|
+
const kw = firstKeyword(stmt);
|
|
3522
|
+
const t = targetTable2(stmt, kw);
|
|
3523
|
+
return t ? `${kw} ${t}` : kw || "statement";
|
|
3524
|
+
}
|
|
3525
|
+
function targetTable2(stmt, kw) {
|
|
3526
|
+
const re = kw === "DELETE" ? /\bDELETE\s+FROM\s+([A-Za-z_][\w.]*)/i : kw === "INSERT" ? /\bINSERT\s+INTO\s+([A-Za-z_][\w.]*)/i : kw === "UPDATE" ? /\bUPDATE\s+(?:ONLY\s+)?([A-Za-z_][\w.]*)/i : void 0;
|
|
3527
|
+
return re ? re.exec(stmt)?.[1] ?? void 0 : void 0;
|
|
3528
|
+
}
|
|
3529
|
+
function dollarBody(doSql) {
|
|
3530
|
+
const m = /\$([A-Za-z_]*)\$/.exec(doSql);
|
|
3531
|
+
if (!m) return null;
|
|
3532
|
+
const tag = m[0];
|
|
3533
|
+
const start = m.index + tag.length;
|
|
3534
|
+
const end = doSql.indexOf(tag, start);
|
|
3535
|
+
return end < 0 ? null : doSql.slice(start, end);
|
|
3536
|
+
}
|
|
3537
|
+
function maskNonCode(sql) {
|
|
3538
|
+
const out = sql.split("");
|
|
3539
|
+
const n = sql.length;
|
|
3540
|
+
let i = 0;
|
|
3541
|
+
const blank = (a, b) => {
|
|
3542
|
+
for (let k = a; k < b && k < n; k++) if (out[k] !== "\n") out[k] = " ";
|
|
3543
|
+
};
|
|
3544
|
+
while (i < n) {
|
|
3545
|
+
const two = sql.slice(i, i + 2);
|
|
3546
|
+
if (two === "--") {
|
|
3547
|
+
let j = sql.indexOf("\n", i);
|
|
3548
|
+
if (j < 0) j = n;
|
|
3549
|
+
blank(i, j);
|
|
3550
|
+
i = j;
|
|
3551
|
+
} else if (two === "/*") {
|
|
3552
|
+
let j = sql.indexOf("*/", i + 2);
|
|
3553
|
+
j = j < 0 ? n : j + 2;
|
|
3554
|
+
blank(i, j);
|
|
3555
|
+
i = j;
|
|
3556
|
+
} else if (sql[i] === "'" || sql[i] === '"') {
|
|
3557
|
+
const q = sql[i];
|
|
3558
|
+
let j = i + 1;
|
|
3559
|
+
while (j < n) {
|
|
3560
|
+
if (sql[j] === q) {
|
|
3561
|
+
if (sql[j + 1] === q) j += 2;
|
|
3562
|
+
else {
|
|
3563
|
+
j++;
|
|
3564
|
+
break;
|
|
3565
|
+
}
|
|
3566
|
+
} else j++;
|
|
3567
|
+
}
|
|
3568
|
+
blank(i, j);
|
|
3569
|
+
i = j;
|
|
3570
|
+
} else if (sql[i] === "$") {
|
|
3571
|
+
const m = /^\$[A-Za-z_]*\$/.exec(sql.slice(i));
|
|
3572
|
+
if (m) {
|
|
3573
|
+
const tag = m[0];
|
|
3574
|
+
let j = sql.indexOf(tag, i + tag.length);
|
|
3575
|
+
j = j < 0 ? n : j + tag.length;
|
|
3576
|
+
blank(i, j);
|
|
3577
|
+
i = j;
|
|
3578
|
+
} else i++;
|
|
3579
|
+
} else i++;
|
|
3580
|
+
}
|
|
3581
|
+
return out.join("");
|
|
3582
|
+
}
|
|
3583
|
+
async function analyzeScript(connection, sql, opts) {
|
|
3584
|
+
const units = extractAnalyzableUnits(sql);
|
|
3585
|
+
const explainable = units.filter((u) => u.kind === "explainable");
|
|
3586
|
+
const exec = explainable.length ? await explainScript(
|
|
3587
|
+
connection,
|
|
3588
|
+
explainable.map((u) => ({
|
|
3589
|
+
label: u.label,
|
|
3590
|
+
sql: u.sql,
|
|
3591
|
+
...u.loopNote ? { loopNote: u.loopNote } : {}
|
|
3592
|
+
})),
|
|
3593
|
+
{
|
|
3594
|
+
statementTimeoutMs: opts.statementTimeoutMs,
|
|
3595
|
+
lockTimeoutMs: opts.lockTimeoutMs,
|
|
3596
|
+
verbose: opts.verbose,
|
|
3597
|
+
settings: opts.settings
|
|
3598
|
+
}
|
|
3599
|
+
) : null;
|
|
3600
|
+
let ei = 0;
|
|
3601
|
+
const out = units.map((u) => {
|
|
3602
|
+
if (u.kind === "skipped") return { label: u.label, status: "skipped", reason: u.reason };
|
|
3603
|
+
const r = exec?.units[ei++];
|
|
3604
|
+
if (!r) return { label: u.label, status: "skipped", reason: "not analyzed" };
|
|
3605
|
+
if (r.error) {
|
|
3606
|
+
return {
|
|
3607
|
+
label: u.label,
|
|
3608
|
+
status: "error",
|
|
3609
|
+
reason: r.error.detail,
|
|
3610
|
+
errorCode: r.error.code,
|
|
3611
|
+
...r.loopNote ? { loopNote: r.loopNote } : {}
|
|
3612
|
+
};
|
|
3613
|
+
}
|
|
3614
|
+
const result = analyze(r.planJson, {
|
|
3615
|
+
sql: u.sql,
|
|
3616
|
+
config: opts.config,
|
|
3617
|
+
redact: opts.redact
|
|
3618
|
+
});
|
|
3619
|
+
return {
|
|
3620
|
+
label: u.label,
|
|
3621
|
+
status: "analyzed",
|
|
3622
|
+
result,
|
|
3623
|
+
report: buildReport(result),
|
|
3624
|
+
...r.loopNote ? { loopNote: r.loopNote } : {}
|
|
3625
|
+
};
|
|
3626
|
+
});
|
|
3627
|
+
return { executed: false, units: out, ...exec ? { serverMajor: exec.caps.major } : {} };
|
|
3628
|
+
}
|
|
3629
|
+
async function emitScript(analysis, opts) {
|
|
3630
|
+
configureColor(opts.format === "terminal" ? opts.color : "never");
|
|
3631
|
+
const text = opts.format === "json" ? renderJsonScript(analysis, opts.pretty ?? true) : renderTextScript(analysis, opts);
|
|
3632
|
+
if (opts.output) await writeFile(opts.output, text);
|
|
3633
|
+
else process.stdout.write(text.endsWith("\n") ? text : `${text}
|
|
3634
|
+
`);
|
|
3635
|
+
if (opts.failOn) {
|
|
3636
|
+
for (const u of analysis.units) {
|
|
3637
|
+
if (u.result?.worstSeverity && severityAtLeast(u.result.worstSeverity, opts.failOn))
|
|
3638
|
+
return 1 /* CiGate */;
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
return 0 /* Success */;
|
|
3642
|
+
}
|
|
3643
|
+
function renderJsonScript(analysis, pretty) {
|
|
3644
|
+
const units = analysis.units.map((u) => ({
|
|
3645
|
+
label: u.label,
|
|
3646
|
+
status: u.status,
|
|
3647
|
+
loopNote: u.loopNote ?? null,
|
|
3648
|
+
report: u.report ?? null,
|
|
3649
|
+
reason: u.reason ?? null,
|
|
3650
|
+
errorCode: u.errorCode ?? null
|
|
3651
|
+
}));
|
|
3652
|
+
return JSON.stringify(
|
|
3653
|
+
{ executed: false, serverMajor: analysis.serverMajor ?? null, units },
|
|
3654
|
+
null,
|
|
3655
|
+
pretty ? 2 : 0
|
|
3656
|
+
);
|
|
3657
|
+
}
|
|
3658
|
+
function renderTextScript(analysis, opts) {
|
|
3659
|
+
const c = colors();
|
|
3660
|
+
const analyzed = analysis.units.filter((u) => u.status === "analyzed").length;
|
|
3661
|
+
const skipped = analysis.units.length - analyzed;
|
|
3662
|
+
const out = [];
|
|
3663
|
+
out.push(
|
|
3664
|
+
c.bold("Cost-only analysis \u2014 nothing was executed.") + c.dim(` ${analyzed} analyzed, ${skipped} skipped/failed.`)
|
|
3665
|
+
);
|
|
3666
|
+
out.push("");
|
|
3667
|
+
for (const u of analysis.units) {
|
|
3668
|
+
out.push(c.bold(`\u25B8 ${u.label}`) + (u.loopNote ? c.dim(` (${u.loopNote})`) : ""));
|
|
3669
|
+
if (u.status === "analyzed" && u.result) {
|
|
3670
|
+
out.push(render(u.result, { format: opts.format, tldr: opts.tldr, ascii: opts.ascii }));
|
|
3671
|
+
} else {
|
|
3672
|
+
const tag = u.status === "error" ? c.yellow("could not analyze") : c.dim("skipped");
|
|
3673
|
+
out.push(
|
|
3674
|
+
` ${tag}: ${u.reason ?? "(no detail)"}${u.errorCode ? c.dim(` [${u.errorCode}]`) : ""}`
|
|
3675
|
+
);
|
|
3676
|
+
}
|
|
3677
|
+
out.push("");
|
|
3678
|
+
}
|
|
3679
|
+
return out.join("\n").trimEnd();
|
|
3680
|
+
}
|
|
3681
|
+
|
|
2814
3682
|
// src/commands/run.ts
|
|
2815
3683
|
async function runRun(args) {
|
|
2816
|
-
const
|
|
2817
|
-
const
|
|
2818
|
-
const
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
3684
|
+
const fullSql = await resolveSql(args);
|
|
3685
|
+
const sql = args.statementIndex !== void 0 ? selectStatement2(splitStatements(fullSql), args.statementIndex) : fullSql;
|
|
3686
|
+
const units = extractAnalyzableUnits(sql);
|
|
3687
|
+
const single = units.length === 1 && units[0]?.kind === "explainable" ? units[0] : null;
|
|
3688
|
+
const measured = single?.kind === "explainable" && args.flags.analyze && !args.flags.genericPlan && (isReadOnlyStatement(single.sql) || args.forceWrite);
|
|
3689
|
+
if (measured && single) {
|
|
3690
|
+
const result = await runExplain({
|
|
3691
|
+
connection: args.connection,
|
|
3692
|
+
statement: single.sql,
|
|
3693
|
+
params: args.params,
|
|
3694
|
+
flags: args.flags,
|
|
3695
|
+
statementTimeoutMs: args.statementTimeoutMs,
|
|
3696
|
+
lockTimeoutMs: args.lockTimeoutMs,
|
|
3697
|
+
forceWrite: args.forceWrite,
|
|
3698
|
+
rollback: args.rollback
|
|
3699
|
+
});
|
|
3700
|
+
if (result.omitted.length) {
|
|
3701
|
+
logInfo(
|
|
3702
|
+
`Note: server is PostgreSQL ${result.caps.major}; skipped unsupported option(s): ${result.omitted.join(", ")}.`
|
|
3703
|
+
);
|
|
3704
|
+
}
|
|
3705
|
+
const analysis2 = analyze(result.json, {
|
|
3706
|
+
config: args.config,
|
|
3707
|
+
redact: args.redact,
|
|
3708
|
+
sql: single.sql
|
|
2823
3709
|
});
|
|
3710
|
+
return emit(analysis2, args);
|
|
2824
3711
|
}
|
|
2825
|
-
const
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
params: args.params,
|
|
2829
|
-
flags: args.flags,
|
|
3712
|
+
const analysis = await analyzeScript(args.connection, sql, {
|
|
3713
|
+
config: args.config,
|
|
3714
|
+
redact: args.redact,
|
|
2830
3715
|
statementTimeoutMs: args.statementTimeoutMs,
|
|
2831
3716
|
lockTimeoutMs: args.lockTimeoutMs,
|
|
2832
|
-
|
|
2833
|
-
|
|
3717
|
+
verbose: args.flags.verbose,
|
|
3718
|
+
settings: args.flags.settings
|
|
3719
|
+
});
|
|
3720
|
+
return emitScript(analysis, {
|
|
3721
|
+
format: args.format,
|
|
3722
|
+
output: args.output,
|
|
3723
|
+
color: args.color,
|
|
3724
|
+
ascii: args.ascii,
|
|
3725
|
+
tldr: args.tldr,
|
|
3726
|
+
pretty: args.pretty,
|
|
3727
|
+
failOn: args.failOn
|
|
2834
3728
|
});
|
|
2835
|
-
if (result.omitted.length) {
|
|
2836
|
-
logInfo(
|
|
2837
|
-
`Note: server is PostgreSQL ${result.caps.major}; skipped unsupported option(s): ${result.omitted.join(", ")}.`
|
|
2838
|
-
);
|
|
2839
|
-
}
|
|
2840
|
-
const analysis = analyze(result.json, { config: args.config, redact: args.redact });
|
|
2841
|
-
return emit(analysis, args);
|
|
2842
3729
|
}
|
|
2843
3730
|
async function resolveSql(args) {
|
|
2844
3731
|
if (args.query) return args.query;
|
|
@@ -2880,6 +3767,35 @@ function selectStatement2(statements, index) {
|
|
|
2880
3767
|
return statements[0];
|
|
2881
3768
|
}
|
|
2882
3769
|
|
|
3770
|
+
// src/commands/studio.ts
|
|
3771
|
+
async function runStudio(args) {
|
|
3772
|
+
const loopback = args.host === "127.0.0.1" || args.host === "localhost" || args.host === "::1";
|
|
3773
|
+
if (!loopback && !args.unsafeHost) {
|
|
3774
|
+
logInfo(
|
|
3775
|
+
`Refusing to bind ${args.host}: the studio can connect to arbitrary databases, so exposing it off-loopback is an SSRF/credential risk. Pass --unsafe-host to override.`
|
|
3776
|
+
);
|
|
3777
|
+
return 2 /* Usage */;
|
|
3778
|
+
}
|
|
3779
|
+
const mod = await import(new URL("./server.js", import.meta.url).href);
|
|
3780
|
+
const server = await mod.startStudio({ host: args.host, port: args.port });
|
|
3781
|
+
logInfo(`
|
|
3782
|
+
pgexplain studio ${server.url}
|
|
3783
|
+
Press Ctrl-C to stop.
|
|
3784
|
+
`);
|
|
3785
|
+
if (args.open) openInBrowser(server.url);
|
|
3786
|
+
process.removeAllListeners("SIGINT");
|
|
3787
|
+
process.removeAllListeners("SIGTERM");
|
|
3788
|
+
await new Promise((resolve) => {
|
|
3789
|
+
const stop = () => {
|
|
3790
|
+
logInfo("\nShutting down\u2026");
|
|
3791
|
+
server.close().finally(resolve);
|
|
3792
|
+
};
|
|
3793
|
+
process.once("SIGINT", stop);
|
|
3794
|
+
process.once("SIGTERM", stop);
|
|
3795
|
+
});
|
|
3796
|
+
return 0 /* Success */;
|
|
3797
|
+
}
|
|
3798
|
+
|
|
2883
3799
|
// src/diagnostics/print.ts
|
|
2884
3800
|
function formatDiagnostic(d) {
|
|
2885
3801
|
const c = colors();
|
|
@@ -2915,6 +3831,11 @@ var outputArgs = {
|
|
|
2915
3831
|
},
|
|
2916
3832
|
tldr: { type: "boolean", description: "Summary + findings only (no plan tree)" },
|
|
2917
3833
|
redact: { type: "boolean", description: "Strip literal values from expressions (safe to share)" },
|
|
3834
|
+
open: {
|
|
3835
|
+
type: "boolean",
|
|
3836
|
+
description: "Open the HTML report in your browser (default: on when interactive)"
|
|
3837
|
+
},
|
|
3838
|
+
"no-open": { type: "boolean", description: "Never open the HTML report in the browser" },
|
|
2918
3839
|
ascii: { type: "boolean", description: "Use ASCII tree glyphs instead of Unicode" },
|
|
2919
3840
|
color: { type: "string", default: "auto", description: "auto | always | never" },
|
|
2920
3841
|
"no-color": { type: "boolean", description: "Disable color (same as --color never)" },
|
|
@@ -2948,6 +3869,7 @@ function emitOptionsFrom(args) {
|
|
|
2948
3869
|
if (args.output) opts.output = args.output;
|
|
2949
3870
|
const failOn = resolveFailOn(args);
|
|
2950
3871
|
if (failOn) opts.failOn = failOn;
|
|
3872
|
+
opts.openHtml = args["no-open"] ? false : args.open ? true : Boolean(process.stdout.isTTY) && !process.env.CI;
|
|
2951
3873
|
return opts;
|
|
2952
3874
|
}
|
|
2953
3875
|
function resolveFormat(value) {
|
|
@@ -3157,6 +4079,40 @@ var diffCmd = defineCommand({
|
|
|
3157
4079
|
}
|
|
3158
4080
|
}
|
|
3159
4081
|
});
|
|
4082
|
+
var studioCmd = defineCommand({
|
|
4083
|
+
meta: { name: "studio", description: "Launch the local pgexplain Studio web app." },
|
|
4084
|
+
args: {
|
|
4085
|
+
port: { type: "string", default: "5177", description: "Port to listen on" },
|
|
4086
|
+
host: {
|
|
4087
|
+
type: "string",
|
|
4088
|
+
default: "127.0.0.1",
|
|
4089
|
+
description: "Host to bind (loopback only unless --unsafe-host)"
|
|
4090
|
+
},
|
|
4091
|
+
"no-open": { type: "boolean", description: "Do not open the browser automatically" },
|
|
4092
|
+
"unsafe-host": {
|
|
4093
|
+
type: "boolean",
|
|
4094
|
+
description: "Allow binding a non-loopback host (SSRF/credential risk)"
|
|
4095
|
+
},
|
|
4096
|
+
debug: { type: "boolean", description: "Print stack traces on internal errors" }
|
|
4097
|
+
},
|
|
4098
|
+
async run({ args }) {
|
|
4099
|
+
try {
|
|
4100
|
+
applyGlobalFlags(args);
|
|
4101
|
+
const port = Number(args.port);
|
|
4102
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
4103
|
+
throw usageError(`Invalid --port '${args.port}'`, "Use a port between 0 and 65535.");
|
|
4104
|
+
}
|
|
4105
|
+
process.exitCode = await runStudio({
|
|
4106
|
+
host: args.host,
|
|
4107
|
+
port,
|
|
4108
|
+
open: !args["no-open"],
|
|
4109
|
+
unsafeHost: !!args["unsafe-host"]
|
|
4110
|
+
});
|
|
4111
|
+
} catch (err) {
|
|
4112
|
+
process.exitCode = handleFatal(err);
|
|
4113
|
+
}
|
|
4114
|
+
}
|
|
4115
|
+
});
|
|
3160
4116
|
var main = defineCommand({
|
|
3161
4117
|
meta: {
|
|
3162
4118
|
name: "pg-explain",
|
|
@@ -3196,7 +4152,7 @@ var argv = process.argv.slice(2);
|
|
|
3196
4152
|
if (argv[0] === "completion") {
|
|
3197
4153
|
process.exitCode = runCompletion(argv[1]);
|
|
3198
4154
|
} else {
|
|
3199
|
-
const started = argv[0] === "run" ? runMain(runCmd, { rawArgs: argv.slice(1) }) : argv[0] === "diff" ? runMain(diffCmd, { rawArgs: argv.slice(1) }) : runMain(main, { rawArgs: argv });
|
|
4155
|
+
const started = argv[0] === "run" ? runMain(runCmd, { rawArgs: argv.slice(1) }) : argv[0] === "diff" ? runMain(diffCmd, { rawArgs: argv.slice(1) }) : argv[0] === "studio" ? runMain(studioCmd, { rawArgs: argv.slice(1) }) : runMain(main, { rawArgs: argv });
|
|
3200
4156
|
started.catch((err) => {
|
|
3201
4157
|
process.exitCode = handleFatal(err);
|
|
3202
4158
|
});
|