sql-dev-inspector 0.0.0-stage → 1.0.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/LICENSE +21 -0
  3. package/README.VI.md +341 -0
  4. package/README.md +267 -2
  5. package/dist/client/nuxt-plugin.client.d.ts +2 -0
  6. package/dist/client/nuxt-plugin.client.js +11 -0
  7. package/dist/client/widget.d.ts +40 -0
  8. package/dist/client/widget.js +485 -0
  9. package/dist/i18n/index.d.ts +26 -0
  10. package/dist/i18n/index.js +48 -0
  11. package/dist/i18n/messages.d.ts +119 -0
  12. package/dist/i18n/messages.js +331 -0
  13. package/dist/index.d.ts +8 -0
  14. package/dist/index.js +7 -0
  15. package/dist/inspector-server.d.ts +11 -0
  16. package/dist/inspector-server.js +95 -0
  17. package/dist/nitro-plugin.d.ts +19 -0
  18. package/dist/nitro-plugin.js +30 -0
  19. package/dist/node.d.ts +27 -0
  20. package/dist/node.js +52 -0
  21. package/dist/nuxt.d.ts +20 -0
  22. package/dist/nuxt.js +21 -0
  23. package/dist/report.d.ts +24 -0
  24. package/dist/report.js +182 -0
  25. package/dist/runtime.d.ts +21 -0
  26. package/dist/runtime.js +42 -0
  27. package/dist/setup.d.ts +8 -0
  28. package/dist/setup.js +44 -0
  29. package/dist/sql-analyzer.d.ts +12 -0
  30. package/dist/sql-analyzer.js +214 -0
  31. package/dist/store.d.ts +36 -0
  32. package/dist/store.js +86 -0
  33. package/dist/terminal.d.ts +5 -0
  34. package/dist/terminal.js +64 -0
  35. package/dist/tracer.d.ts +36 -0
  36. package/dist/tracer.js +143 -0
  37. package/dist/types.d.ts +79 -0
  38. package/dist/types.js +1 -0
  39. package/dist/ui/client.d.ts +1 -0
  40. package/dist/ui/client.js +472 -0
  41. package/dist/ui/page.d.ts +6 -0
  42. package/dist/ui/page.js +73 -0
  43. package/dist/ui/styles.d.ts +7 -0
  44. package/dist/ui/styles.js +207 -0
  45. package/docs/PUBLISH-AND-INSTALL.md +267 -0
  46. package/package.json +80 -4
package/dist/node.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ export interface SqlInspector {
3
+ /** False when the environment switched the inspector off; every other member is then a no-op. */
4
+ enabled: boolean;
5
+ /** Connect / Express style middleware. Mount it before your routes. */
6
+ middleware: (req: IncomingMessage, res: ServerResponse, next: (error?: unknown) => void) => void;
7
+ /**
8
+ * For plain `http` servers and other frameworks: serves the inspector's own pages and
9
+ * returns true when it did, in which case the request must not be handled further.
10
+ */
11
+ handle: (req: IncomingMessage, res: ServerResponse) => boolean;
12
+ /** Runs `fn` as one traced request; everything awaited inside is attributed to it. */
13
+ track: <T>(req: IncomingMessage, res: ServerResponse, fn: () => T) => T;
14
+ /** Runs `fn` as one traced unit of work that is not an HTTP request (job, cron, script). */
15
+ task: <T>(name: string, fn: () => T | Promise<T>) => Promise<T>;
16
+ }
17
+ /**
18
+ * Framework-agnostic entry point for any Node server whose SQL goes through mysql2
19
+ * (directly, or via Drizzle, Knex, Sequelize, TypeORM, Prisma's mysql2 adapter…).
20
+ */
21
+ export declare function createSqlInspector(): SqlInspector;
22
+ declare module "node:http" {
23
+ interface IncomingMessage {
24
+ /** Set by Express when a router rewrites `url`. */
25
+ originalUrl?: string;
26
+ }
27
+ }
package/dist/node.js ADDED
@@ -0,0 +1,52 @@
1
+ import { handleInspectorRequest, inspectorSubPath } from "./inspector-server.js";
2
+ import { startInspector } from "./setup.js";
3
+ import { runInRequest } from "./tracer.js";
4
+ /**
5
+ * Framework-agnostic entry point for any Node server whose SQL goes through mysql2
6
+ * (directly, or via Drizzle, Knex, Sequelize, TypeORM, Prisma's mysql2 adapter…).
7
+ */
8
+ export function createSqlInspector() {
9
+ const inspector = startInspector();
10
+ if (!inspector) {
11
+ return {
12
+ enabled: false,
13
+ middleware: (_req, _res, next) => next(),
14
+ handle: () => false,
15
+ track: (_req, _res, fn) => fn(),
16
+ task: async (_name, fn) => await fn()
17
+ };
18
+ }
19
+ const { store } = inspector;
20
+ const handle = (req, res) => {
21
+ const subPath = inspectorSubPath(req.url);
22
+ if (subPath === null) {
23
+ return false;
24
+ }
25
+ handleInspectorRequest(req, res, subPath, inspector);
26
+ return true;
27
+ };
28
+ const track = (req, res, fn) => {
29
+ const trace = store.startRequest(req.method ?? "GET", req.originalUrl ?? req.url ?? "/");
30
+ res.once("close", () => store.finishRequest(trace, res.statusCode));
31
+ return runInRequest(trace, fn);
32
+ };
33
+ return {
34
+ enabled: true,
35
+ handle,
36
+ track,
37
+ middleware: (req, res, next) => {
38
+ if (!handle(req, res)) {
39
+ track(req, res, () => next());
40
+ }
41
+ },
42
+ task: async (name, fn) => {
43
+ const trace = store.startRequest("TASK", name);
44
+ try {
45
+ return await runInRequest(trace, fn);
46
+ }
47
+ finally {
48
+ store.finishRequest(trace, 0);
49
+ }
50
+ }
51
+ };
52
+ }
package/dist/nuxt.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ interface NuxtLike {
2
+ options: {
3
+ dev: boolean;
4
+ nitro: {
5
+ plugins?: string[];
6
+ };
7
+ plugins: (string | {
8
+ src: string;
9
+ mode?: string;
10
+ })[];
11
+ };
12
+ }
13
+ /**
14
+ * Nuxt module. In `nuxt dev` only, registers the SQL Inspector Nitro plugin (server) and the
15
+ * floating launcher (client), so `nuxt build` and `nuxt generate` never see them and nothing
16
+ * ships to a deployed environment. Written as a plain function (no `@nuxt/kit`), so it adds no
17
+ * dependency: `modules: ["<package>/nuxt"]`.
18
+ */
19
+ export default function sqlInspectorModule(_options: unknown, nuxt: NuxtLike): void;
20
+ export {};
package/dist/nuxt.js ADDED
@@ -0,0 +1,21 @@
1
+ import { existsSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ /** The compiled `.js` when installed from npm, the `.ts` source when used from a checkout. */
4
+ function resolveSibling(path) {
5
+ const js = fileURLToPath(new URL(`${path}.js`, import.meta.url));
6
+ return existsSync(js) ? js : fileURLToPath(new URL(`${path}.ts`, import.meta.url));
7
+ }
8
+ /**
9
+ * Nuxt module. In `nuxt dev` only, registers the SQL Inspector Nitro plugin (server) and the
10
+ * floating launcher (client), so `nuxt build` and `nuxt generate` never see them and nothing
11
+ * ships to a deployed environment. Written as a plain function (no `@nuxt/kit`), so it adds no
12
+ * dependency: `modules: ["<package>/nuxt"]`.
13
+ */
14
+ export default function sqlInspectorModule(_options, nuxt) {
15
+ if (!nuxt.options.dev) {
16
+ return;
17
+ }
18
+ nuxt.options.nitro.plugins ||= [];
19
+ nuxt.options.nitro.plugins.push(resolveSibling("./nitro-plugin"));
20
+ nuxt.options.plugins.push({ src: resolveSibling("./client/nuxt-plugin.client"), mode: "client" });
21
+ }
@@ -0,0 +1,24 @@
1
+ import type { QueryEvent, RequestReport, RequestTrace, TraceNode } from "./types.js";
2
+ import { type Locale } from "./i18n/index.js";
3
+ export interface ReportOptions {
4
+ slowMs: number;
5
+ nPlusOneThreshold: number;
6
+ manyQueriesThreshold: number;
7
+ /**
8
+ * Opt-in team convention check: reads belong outside a transaction, and a transaction is only
9
+ * for writes. Off by default because it is a style rule, not a defect.
10
+ */
11
+ transactionConvention: boolean;
12
+ /** Language of `Insight.message`. The structured fields are language-neutral. */
13
+ locale: Locale;
14
+ }
15
+ export declare const DEFAULT_REPORT_OPTIONS: ReportOptions;
16
+ /**
17
+ * Rebuilds the call structure of one request: statements run in a transaction nest under it,
18
+ * savepoints nest under their parent transaction, and everything else sits at the top level.
19
+ *
20
+ * Nesting is per connection. A transaction owns one connection, so two transactions running
21
+ * at once in the same request (Promise.all) stay apart instead of interleaving.
22
+ */
23
+ export declare function buildTree(queries: QueryEvent[]): TraceNode[];
24
+ export declare function buildReport(trace: RequestTrace, options?: ReportOptions): RequestReport;
package/dist/report.js ADDED
@@ -0,0 +1,182 @@
1
+ import { DEFAULT_LOCALE, translate } from "./i18n/index.js";
2
+ import { isControlStatement, isWriteStatement } from "./sql-analyzer.js";
3
+ export const DEFAULT_REPORT_OPTIONS = {
4
+ slowMs: 100,
5
+ nPlusOneThreshold: 3,
6
+ manyQueriesThreshold: 30,
7
+ transactionConvention: false,
8
+ locale: DEFAULT_LOCALE
9
+ };
10
+ const endOf = (query) => query.startedAt + (query.durationMs ?? 0);
11
+ /**
12
+ * Rebuilds the call structure of one request: statements run in a transaction nest under it,
13
+ * savepoints nest under their parent transaction, and everything else sits at the top level.
14
+ *
15
+ * Nesting is per connection. A transaction owns one connection, so two transactions running
16
+ * at once in the same request (Promise.all) stay apart instead of interleaving.
17
+ */
18
+ export function buildTree(queries) {
19
+ const roots = [];
20
+ const openByConnection = new Map();
21
+ const place = (node, connectionId) => {
22
+ const stack = openByConnection.get(connectionId);
23
+ const parent = stack?.at(-1);
24
+ if (parent) {
25
+ parent.children.push(node);
26
+ }
27
+ else {
28
+ roots.push(node);
29
+ }
30
+ };
31
+ const open = (query, type) => {
32
+ const node = {
33
+ type,
34
+ connectionId: query.connectionId,
35
+ name: query.analysis.savepoint,
36
+ status: "open",
37
+ startedAt: query.startedAt,
38
+ durationMs: null,
39
+ children: []
40
+ };
41
+ place(node, query.connectionId);
42
+ const stack = openByConnection.get(query.connectionId) ?? [];
43
+ stack.push(node);
44
+ openByConnection.set(query.connectionId, stack);
45
+ };
46
+ const close = (query, status, until) => {
47
+ const stack = openByConnection.get(query.connectionId) ?? [];
48
+ while (stack.length > 0) {
49
+ const node = stack.pop();
50
+ node.status = status;
51
+ node.durationMs = endOf(query) - node.startedAt;
52
+ if (until(node)) {
53
+ break;
54
+ }
55
+ }
56
+ };
57
+ for (const query of [...queries].sort((a, b) => a.seq - b.seq)) {
58
+ switch (query.analysis.kind) {
59
+ case "tx-begin":
60
+ open(query, "transaction");
61
+ break;
62
+ case "savepoint":
63
+ open(query, "savepoint");
64
+ break;
65
+ case "tx-commit":
66
+ close(query, "committed", () => false);
67
+ break;
68
+ case "tx-rollback":
69
+ close(query, "rolled-back", () => false);
70
+ break;
71
+ case "savepoint-release":
72
+ close(query, "committed", node => node.name === query.analysis.savepoint);
73
+ break;
74
+ case "savepoint-rollback":
75
+ close(query, "rolled-back", node => node.name === query.analysis.savepoint);
76
+ break;
77
+ case "tx-config":
78
+ break;
79
+ default:
80
+ place({ type: "query", query }, query.connectionId);
81
+ }
82
+ }
83
+ return roots;
84
+ }
85
+ function* walk(nodes, insideTransaction = false) {
86
+ for (const node of nodes) {
87
+ if (node.type === "query") {
88
+ yield { query: node.query, insideTransaction };
89
+ }
90
+ else {
91
+ yield* walk(node.children, true);
92
+ }
93
+ }
94
+ }
95
+ function groupBy(items, keyOf) {
96
+ const groups = new Map();
97
+ for (const item of items) {
98
+ const key = keyOf(item);
99
+ groups.set(key, [...(groups.get(key) ?? []), item]);
100
+ }
101
+ return groups;
102
+ }
103
+ const shorten = (text, max = 90) => text.length > max ? `${text.slice(0, max)}…` : text;
104
+ const sumMs = (queries) => queries.reduce((total, q) => total + (q.durationMs ?? 0), 0);
105
+ const formatMs = (value) => value < 1000 ? `${value.toFixed(value < 10 ? 2 : 1)}ms` : `${(value / 1000).toFixed(2)}s`;
106
+ /** Findings as data (type + params), so any front end can phrase them in its own language. */
107
+ function findInsights(tree, queries, options) {
108
+ const insights = [];
109
+ const statements = queries.filter(q => !isControlStatement(q.analysis.kind));
110
+ const flagged = new Set();
111
+ for (const group of groupBy(statements.filter(q => q.analysis.kind === "select"), q => q.analysis.fingerprint).values()) {
112
+ if (group.length < options.nPlusOneThreshold) {
113
+ continue;
114
+ }
115
+ const distinctSql = new Set(group.map(q => q.sql)).size;
116
+ // Same text repeated is plain duplication; same shape with different values is the N+1 signature.
117
+ const isNPlusOne = distinctSql > 1;
118
+ insights.push({
119
+ type: isNPlusOne ? "n-plus-one" : "duplicate",
120
+ severity: "warn",
121
+ params: isNPlusOne
122
+ ? { count: group.length, total: formatMs(sumMs(group)), sql: shorten(group[0].analysis.fingerprint) }
123
+ : { count: group.length, sql: shorten(group[0].sql) },
124
+ seqs: group.map(q => q.seq)
125
+ });
126
+ group.forEach(q => flagged.add(q.seq));
127
+ }
128
+ for (const group of groupBy(statements.filter(q => !flagged.has(q.seq)), q => q.sql).values()) {
129
+ if (group.length >= 2) {
130
+ insights.push({ type: "duplicate", severity: "warn", params: { count: group.length, sql: shorten(group[0].sql) }, seqs: group.map(q => q.seq) });
131
+ }
132
+ }
133
+ for (const query of statements) {
134
+ if (query.error) {
135
+ insights.push({ type: "error", severity: "error", params: { error: query.error }, seqs: [query.seq] });
136
+ }
137
+ if (query.durationMs !== null && query.durationMs >= options.slowMs) {
138
+ insights.push({
139
+ type: "slow",
140
+ severity: "warn",
141
+ params: { ms: formatMs(query.durationMs), threshold: formatMs(options.slowMs), sql: shorten(query.sql) },
142
+ seqs: [query.seq]
143
+ });
144
+ }
145
+ if (query.analysis.selectStar) {
146
+ insights.push({ type: "select-star", severity: "info", params: { tables: query.analysis.tables.join(", ") || "?" }, seqs: [query.seq] });
147
+ }
148
+ if (query.analysis.subqueryDepth >= 3) {
149
+ insights.push({ type: "deep-subquery", severity: "info", params: { depth: query.analysis.subqueryDepth }, seqs: [query.seq] });
150
+ }
151
+ }
152
+ const placed = [...walk(tree)];
153
+ const readsInside = placed.filter(p => p.insideTransaction && p.query.analysis.kind === "select");
154
+ if (options.transactionConvention && readsInside.length > 0) {
155
+ insights.push({ type: "select-in-transaction", severity: "info", params: { count: readsInside.length }, seqs: readsInside.map(p => p.query.seq) });
156
+ }
157
+ const writesOutside = placed.filter(p => !p.insideTransaction && isWriteStatement(p.query.analysis.kind));
158
+ if (options.transactionConvention && writesOutside.length >= 2) {
159
+ insights.push({ type: "write-outside-transaction", severity: "info", params: { count: writesOutside.length }, seqs: writesOutside.map(p => p.query.seq) });
160
+ }
161
+ if (statements.length >= options.manyQueriesThreshold) {
162
+ insights.push({ type: "many-queries", severity: "warn", params: { count: statements.length }, seqs: [] });
163
+ }
164
+ return insights;
165
+ }
166
+ export function buildReport(trace, options = DEFAULT_REPORT_OPTIONS) {
167
+ const tree = buildTree(trace.queries);
168
+ const statements = trace.queries.filter(q => !isControlStatement(q.analysis.kind));
169
+ return {
170
+ trace,
171
+ tree,
172
+ insights: findInsights(tree, trace.queries, options).map(insight => ({
173
+ ...insight,
174
+ message: translate(options.locale, `insight.msg.${insight.type}`, insight.params)
175
+ })),
176
+ totals: {
177
+ queryCount: statements.length,
178
+ sqlTimeMs: sumMs(statements),
179
+ maxSubqueryDepth: Math.max(0, ...statements.map(q => q.analysis.subqueryDepth))
180
+ }
181
+ };
182
+ }
@@ -0,0 +1,21 @@
1
+ import { type Locale } from "./i18n/index.js";
2
+ import { type ReportOptions } from "./report.js";
3
+ import { TraceStore } from "./store.js";
4
+ export interface Inspector {
5
+ store: TraceStore;
6
+ report: ReportOptions;
7
+ logToTerminal: boolean;
8
+ /** Language of server-side text (terminal output, `Insight.message`). */
9
+ locale: Locale;
10
+ /** Set only when `SQL_INSPECTOR_LANG` was given: tells the web UI which language to start in. */
11
+ localeHint: Locale | null;
12
+ }
13
+ /**
14
+ * Development only. Never in production (`NODE_ENV=production` is a hard stop, even with
15
+ * `SQL_INSPECTOR=1`); otherwise on when `NODE_ENV=development` (what `nuxt dev`, `next dev`,
16
+ * `vite` and most dev scripts set) or when asked for with `SQL_INSPECTOR=1`.
17
+ * `SQL_INSPECTOR=0` switches it off.
18
+ */
19
+ export declare function isInspectorEnabled(env?: NodeJS.ProcessEnv): boolean;
20
+ /** One inspector per process, however many bundles import this module. */
21
+ export declare function getInspector(env?: NodeJS.ProcessEnv): Inspector;
@@ -0,0 +1,42 @@
1
+ import process from "node:process";
2
+ import { chosenLocale, DEFAULT_LOCALE } from "./i18n/index.js";
3
+ import { DEFAULT_REPORT_OPTIONS } from "./report.js";
4
+ import { TraceStore } from "./store.js";
5
+ const KEY = Symbol.for("sqlDevInspector.runtime");
6
+ function positiveNumber(raw, fallback) {
7
+ const value = Number(raw);
8
+ return Number.isFinite(value) && value > 0 ? value : fallback;
9
+ }
10
+ /**
11
+ * Development only. Never in production (`NODE_ENV=production` is a hard stop, even with
12
+ * `SQL_INSPECTOR=1`); otherwise on when `NODE_ENV=development` (what `nuxt dev`, `next dev`,
13
+ * `vite` and most dev scripts set) or when asked for with `SQL_INSPECTOR=1`.
14
+ * `SQL_INSPECTOR=0` switches it off.
15
+ */
16
+ export function isInspectorEnabled(env = process.env) {
17
+ if (env.NODE_ENV === "production" || env.SQL_INSPECTOR === "0") {
18
+ return false;
19
+ }
20
+ return env.SQL_INSPECTOR === "1" || env.NODE_ENV === "development";
21
+ }
22
+ /** One inspector per process, however many bundles import this module. */
23
+ export function getInspector(env = process.env) {
24
+ const holder = globalThis;
25
+ const hint = chosenLocale(env);
26
+ holder[KEY] ??= {
27
+ locale: hint ?? DEFAULT_LOCALE,
28
+ localeHint: hint,
29
+ store: new TraceStore({
30
+ maxRequests: positiveNumber(env.SQL_INSPECTOR_MAX_REQUESTS, 200),
31
+ maxQueriesPerRequest: positiveNumber(env.SQL_INSPECTOR_MAX_QUERIES, 1000)
32
+ }),
33
+ report: {
34
+ ...DEFAULT_REPORT_OPTIONS,
35
+ slowMs: positiveNumber(env.SQL_INSPECTOR_SLOW_MS, DEFAULT_REPORT_OPTIONS.slowMs),
36
+ transactionConvention: env.SQL_INSPECTOR_TX_CONVENTION === "1",
37
+ locale: hint ?? DEFAULT_LOCALE
38
+ },
39
+ logToTerminal: env.SQL_INSPECTOR_LOG !== "0"
40
+ };
41
+ return holder[KEY];
42
+ }
@@ -0,0 +1,8 @@
1
+ import { type Inspector } from "./runtime.js";
2
+ /**
3
+ * Patches mysql2 and starts the terminal printer, once per process. Returns null when the
4
+ * environment says no (production, opted out), in which case nothing has been touched.
5
+ */
6
+ export declare function startInspector(env?: NodeJS.ProcessEnv): Inspector | null;
7
+ /** Test helper: forget that the inspector was started, so the next start installs again. */
8
+ export declare function resetInspectorForTests(): void;
package/dist/setup.js ADDED
@@ -0,0 +1,44 @@
1
+ import process from "node:process";
2
+ import { buildReport } from "./report.js";
3
+ import { getInspector, isInspectorEnabled } from "./runtime.js";
4
+ import { hasVisibleStatements, renderReport } from "./terminal.js";
5
+ import { installTracer } from "./tracer.js";
6
+ const STARTED = Symbol.for("sqlDevInspector.started");
7
+ /**
8
+ * Patches mysql2 and starts the terminal printer, once per process. Returns null when the
9
+ * environment says no (production, opted out), in which case nothing has been touched.
10
+ */
11
+ export function startInspector(env = process.env) {
12
+ if (!isInspectorEnabled(env)) {
13
+ return null;
14
+ }
15
+ const inspector = getInspector(env);
16
+ const holder = globalThis;
17
+ if (holder[STARTED]) {
18
+ return inspector;
19
+ }
20
+ holder[STARTED] = true;
21
+ const { store } = inspector;
22
+ installTracer(store);
23
+ if (inspector.logToTerminal) {
24
+ store.subscribe((change) => {
25
+ if (change.type !== "request-end") {
26
+ return;
27
+ }
28
+ const trace = store.get(change.requestId);
29
+ if (!trace) {
30
+ return;
31
+ }
32
+ const report = buildReport(trace, inspector.report);
33
+ if (hasVisibleStatements(report)) {
34
+ console.log(`\n${renderReport(report, inspector.locale)}`);
35
+ }
36
+ });
37
+ }
38
+ return inspector;
39
+ }
40
+ /** Test helper: forget that the inspector was started, so the next start installs again. */
41
+ export function resetInspectorForTests() {
42
+ const holder = globalThis;
43
+ holder[STARTED] = false;
44
+ }
@@ -0,0 +1,12 @@
1
+ import type { SqlAnalysis, StatementKind } from "./types.js";
2
+ /** Replace string literals and comments with placeholders; keep everything else intact. */
3
+ export declare function maskLiterals(sql: string): string;
4
+ export declare function fingerprintOf(sql: string): string;
5
+ export declare function analyzeSql(sql: string): SqlAnalysis;
6
+ export declare function isControlStatement(kind: StatementKind): kind is "tx-begin" | "tx-commit" | "tx-rollback" | "tx-config" | "savepoint" | "savepoint-release" | "savepoint-rollback";
7
+ export declare function isWriteStatement(kind: StatementKind): kind is "insert" | "update" | "delete" | "replace";
8
+ /**
9
+ * Indents by subquery depth and starts a new line at each major clause, so a nested Drizzle
10
+ * relational query reads as a tree. Display only — never executed.
11
+ */
12
+ export declare function formatSql(sql: string): string;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Not a SQL parser. Drizzle only emits well-formed statements, so a tokenizer that knows
3
+ * about string literals, identifiers and comments is enough to classify them, find their
4
+ * tables and measure subquery nesting. Anything it cannot read degrades to `other` rather
5
+ * than throwing — a query inspector must never break the query path.
6
+ */
7
+ const TOKEN = /'(?:[^'\\]|\\.|'')*'|"(?:[^"\\]|\\.|"")*"|`[^`]*`|\/\*[\s\S]*?\*\/|--[^\n]*|#[^\n]*|\w+|[\s\S]/g;
8
+ /** Replace string literals and comments with placeholders; keep everything else intact. */
9
+ export function maskLiterals(sql) {
10
+ return sql.replace(TOKEN, (token) => {
11
+ if (token.startsWith("'") || token.startsWith("\"")) {
12
+ return "?";
13
+ }
14
+ if (token.startsWith("/*") || token.startsWith("--") || token.startsWith("#")) {
15
+ return " ";
16
+ }
17
+ return token;
18
+ });
19
+ }
20
+ const WHITESPACE_RUN = /\s+/g;
21
+ const BACKTICKS = /`/g;
22
+ const PAREN_TOKEN = /\(\s*(select|with)\b|\(|\)/gi;
23
+ const LEADING_PARENS = /^\(+/;
24
+ const FIRST_WORD = /^(\w+)/;
25
+ const QUOTES_AND_SEMICOLON = /[`;]/g;
26
+ const START_TRANSACTION = /^start\s+transaction\b/;
27
+ const ROLLBACK_TO = /^rollback\s+to\b/;
28
+ const ROLLBACK_TO_NAME = /^rollback\s+to\s+(?:savepoint\s+)?(\S+)/;
29
+ const SAVEPOINT_NAME = /^savepoint\s+(\S+)/;
30
+ const RELEASE_SAVEPOINT = /^release\s+savepoint\b/;
31
+ const RELEASE_NAME = /^release\s+savepoint\s+(\S+)/;
32
+ const SET_TRANSACTION = /^set\s+(?:session\s+|global\s+)?transaction\b/;
33
+ const SELECT_STAR = /\bselect\s+(?:distinct\s+)?(?:\w+\.)?\*/i;
34
+ const SUBQUERY_KEYWORD = /^(?:select|with)$/i;
35
+ const WORD = /^\w+$/;
36
+ const NUMBER_LITERAL = /\b\d+(?:\.\d+)?\b/g;
37
+ const IN_LIST = /\bin\s*\(\s*\?(?:\s*,\s*\?)*\s*\)/gi;
38
+ const VALUES_ROWS = /\bvalues\s*\(\s*\?(?:\s*,\s*\?)*\s*\)(?:\s*,\s*\(\s*\?(?:\s*,\s*\?)*\s*\))*/gi;
39
+ export function fingerprintOf(sql) {
40
+ return maskLiterals(sql)
41
+ .replace(NUMBER_LITERAL, "?")
42
+ .replace(WHITESPACE_RUN, " ")
43
+ .replace(IN_LIST, "in (?)")
44
+ .replace(VALUES_ROWS, "values (?)")
45
+ .trim()
46
+ .toLowerCase();
47
+ }
48
+ const TABLE_REFERENCE = /(?<!\bon\s)(?<!\bkey\s)(?<!\bfor\s)\b(from|join|into|update)\s+((?:`[^`]+`|\w+)(?:\.(?:`[^`]+`|\w+))?)/gi;
49
+ const NOT_A_TABLE = new Set(["select", "set", "values", "lateral", "dual"]);
50
+ function tablesOf(masked) {
51
+ const tables = new Set();
52
+ for (const match of masked.matchAll(TABLE_REFERENCE)) {
53
+ const name = match[2].replace(BACKTICKS, "");
54
+ if (!NOT_A_TABLE.has(name.toLowerCase())) {
55
+ tables.add(name);
56
+ }
57
+ }
58
+ return [...tables];
59
+ }
60
+ function measureSubqueries(masked) {
61
+ // Each open paren is remembered with whether it opens a subquery, so `IN (1, 2)` and
62
+ // `COUNT(*)` do not count towards the depth and `((SELECT ...))` counts once.
63
+ const stack = [];
64
+ let depth = 0;
65
+ let count = 0;
66
+ let open = 0;
67
+ for (const match of masked.matchAll(PAREN_TOKEN)) {
68
+ const token = match[0];
69
+ if (token === ")") {
70
+ if (stack.pop()) {
71
+ open--;
72
+ }
73
+ continue;
74
+ }
75
+ const opensSubquery = match[1] !== undefined;
76
+ stack.push(opensSubquery);
77
+ if (opensSubquery) {
78
+ open++;
79
+ count++;
80
+ depth = Math.max(depth, open);
81
+ }
82
+ }
83
+ return { depth, count };
84
+ }
85
+ function kindOf(masked) {
86
+ const text = masked.trim().replace(LEADING_PARENS, "").toLowerCase();
87
+ const word = FIRST_WORD.exec(text)?.[1] ?? "";
88
+ const clean = (name) => name?.replace(QUOTES_AND_SEMICOLON, "") ?? null;
89
+ if (word === "select" || word === "with") {
90
+ return { kind: "select", savepoint: null };
91
+ }
92
+ if (word === "insert" || word === "update" || word === "delete" || word === "replace") {
93
+ return { kind: word, savepoint: null };
94
+ }
95
+ if (word === "begin" || START_TRANSACTION.test(text)) {
96
+ return { kind: "tx-begin", savepoint: null };
97
+ }
98
+ if (word === "commit") {
99
+ return { kind: "tx-commit", savepoint: null };
100
+ }
101
+ if (ROLLBACK_TO.test(text)) {
102
+ return { kind: "savepoint-rollback", savepoint: clean(ROLLBACK_TO_NAME.exec(text)?.[1]) };
103
+ }
104
+ if (word === "rollback") {
105
+ return { kind: "tx-rollback", savepoint: null };
106
+ }
107
+ if (word === "savepoint") {
108
+ return { kind: "savepoint", savepoint: clean(SAVEPOINT_NAME.exec(text)?.[1]) };
109
+ }
110
+ if (RELEASE_SAVEPOINT.test(text)) {
111
+ return { kind: "savepoint-release", savepoint: clean(RELEASE_NAME.exec(text)?.[1]) };
112
+ }
113
+ if (SET_TRANSACTION.test(text)) {
114
+ return { kind: "tx-config", savepoint: null };
115
+ }
116
+ return { kind: "other", savepoint: null };
117
+ }
118
+ export function analyzeSql(sql) {
119
+ try {
120
+ const masked = maskLiterals(sql);
121
+ const { kind, savepoint } = kindOf(masked);
122
+ const subqueries = measureSubqueries(masked);
123
+ return {
124
+ kind,
125
+ tables: tablesOf(masked),
126
+ subqueryDepth: subqueries.depth,
127
+ subqueryCount: subqueries.count,
128
+ fingerprint: fingerprintOf(sql),
129
+ selectStar: SELECT_STAR.test(masked),
130
+ savepoint
131
+ };
132
+ }
133
+ catch {
134
+ return { kind: "other", tables: [], subqueryDepth: 0, subqueryCount: 0, fingerprint: sql, selectStar: false, savepoint: null };
135
+ }
136
+ }
137
+ export function isControlStatement(kind) {
138
+ return kind === "tx-begin" || kind === "tx-commit" || kind === "tx-rollback" || kind === "tx-config"
139
+ || kind === "savepoint" || kind === "savepoint-release" || kind === "savepoint-rollback";
140
+ }
141
+ export function isWriteStatement(kind) {
142
+ return kind === "insert" || kind === "update" || kind === "delete" || kind === "replace";
143
+ }
144
+ const BREAK_BEFORE = /^(?:select|from|where|group|order|having|limit|offset|union|set|values|left|right|inner|cross|join|for)$/i;
145
+ const JOIN_MODIFIER = /^(?:left|right|inner|cross|full|outer|natural)$/i;
146
+ const WHITESPACE = /^\s+$/;
147
+ function nextSignificantToken(tokens, from) {
148
+ for (let i = from; i < tokens.length; i++) {
149
+ if (!WHITESPACE.test(tokens[i])) {
150
+ return tokens[i];
151
+ }
152
+ }
153
+ return "";
154
+ }
155
+ /**
156
+ * Indents by subquery depth and starts a new line at each major clause, so a nested Drizzle
157
+ * relational query reads as a tree. Display only — never executed.
158
+ */
159
+ export function formatSql(sql) {
160
+ const out = [];
161
+ const subqueryParens = [];
162
+ let indent = 0;
163
+ let previousWord = "";
164
+ let line = "";
165
+ const newline = () => {
166
+ if (line.trim()) {
167
+ out.push(line.trimEnd());
168
+ }
169
+ line = " ".repeat(indent);
170
+ };
171
+ const tokens = sql.match(TOKEN) ?? [];
172
+ for (const [i, token] of tokens.entries()) {
173
+ if (WHITESPACE.test(token)) {
174
+ if (line.trim() && !line.endsWith(" ")) {
175
+ line += " ";
176
+ }
177
+ continue;
178
+ }
179
+ if (token === "(") {
180
+ const opensSubquery = SUBQUERY_KEYWORD.test(nextSignificantToken(tokens, i + 1));
181
+ subqueryParens.push(opensSubquery);
182
+ line += "(";
183
+ if (opensSubquery) {
184
+ indent++;
185
+ newline();
186
+ }
187
+ previousWord = "";
188
+ continue;
189
+ }
190
+ if (token === ")") {
191
+ if (subqueryParens.pop()) {
192
+ indent = Math.max(0, indent - 1);
193
+ newline();
194
+ }
195
+ line += ")";
196
+ previousWord = "";
197
+ continue;
198
+ }
199
+ const word = token.toLowerCase();
200
+ if (WORD.test(token)) {
201
+ if (word === "and" || word === "or") {
202
+ newline();
203
+ line += " ";
204
+ }
205
+ else if (BREAK_BEFORE.test(word) && !(word === "join" && JOIN_MODIFIER.test(previousWord))) {
206
+ newline();
207
+ }
208
+ }
209
+ line += token;
210
+ previousWord = WORD.test(token) ? word : "";
211
+ }
212
+ newline();
213
+ return out.join("\n");
214
+ }