@kazurolabs/psql-explorer 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +505 -2
- package/RELEASES.md +87 -0
- package/dist/app.js +39 -0
- package/dist/cli.js +307 -0
- package/dist/commands/configgen.js +128 -0
- package/dist/commands/explore.js +388 -0
- package/dist/commands/terminal-prompt.js +68 -0
- package/dist/config/load.js +324 -0
- package/dist/config/resolve-environment.js +112 -0
- package/dist/config/resolvers/env.js +12 -0
- package/dist/config/resolvers/file.js +24 -0
- package/dist/config/resolvers/groups.js +1 -0
- package/dist/config/resolvers/index.js +49 -0
- package/dist/config/resolvers/literal.js +7 -0
- package/dist/config/resolvers/prompt.js +159 -0
- package/dist/config/resolvers/stored.js +13 -0
- package/dist/config/resolvers/types.js +18 -0
- package/dist/config/resolvers/vault.js +283 -0
- package/dist/config/schema.js +24 -0
- package/dist/config/source.js +105 -0
- package/dist/db/connect.js +55 -0
- package/dist/db/identifiers.js +6 -0
- package/dist/db/paths.js +14 -0
- package/dist/db/store.js +323 -0
- package/dist/db/tester.js +190 -0
- package/dist/db/validate.js +99 -0
- package/dist/environments.js +140 -0
- package/dist/explorer/alter.js +493 -0
- package/dist/explorer/catalog.js +414 -0
- package/dist/explorer/connect-entry.js +42 -0
- package/dist/explorer/cypher-parse.js +478 -0
- package/dist/explorer/cypher.js +963 -0
- package/dist/explorer/ddl.js +502 -0
- package/dist/explorer/dependents.js +222 -0
- package/dist/explorer/design-load.js +254 -0
- package/dist/explorer/design.js +45 -0
- package/dist/explorer/edit.js +108 -0
- package/dist/explorer/explain.js +37 -0
- package/dist/explorer/filter.js +121 -0
- package/dist/explorer/format.js +134 -0
- package/dist/explorer/graph.js +679 -0
- package/dist/explorer/gucs.js +31 -0
- package/dist/explorer/profile.js +102 -0
- package/dist/explorer/references.js +95 -0
- package/dist/explorer/rows.js +64 -0
- package/dist/explorer/schema-diff.js +360 -0
- package/dist/explorer/search.js +309 -0
- package/dist/explorer/security.js +152 -0
- package/dist/explorer/seed.js +1376 -0
- package/dist/explorer/session.js +122 -0
- package/dist/explorer/stats.js +135 -0
- package/dist/explorer/timeout.js +13 -0
- package/dist/prompts.js +52 -0
- package/dist/screens/environments.js +229 -0
- package/dist/screens/explorer.js +145 -0
- package/dist/screens/header.js +8 -0
- package/dist/screens/index.js +13 -0
- package/dist/screens/new-connection.js +178 -0
- package/dist/screens/panes/common.js +35 -0
- package/dist/screens/panes/history.js +36 -0
- package/dist/screens/panes/info.js +61 -0
- package/dist/screens/panes/query.js +113 -0
- package/dist/screens/panes/table.js +178 -0
- package/dist/screens/panes/tables.js +142 -0
- package/dist/screens/prompt-modal.js +18 -0
- package/dist/screens/record-view.js +42 -0
- package/dist/screens/result-grid.js +126 -0
- package/dist/screens/scroll.js +12 -0
- package/dist/screens/splash.js +78 -0
- package/dist/screens/terminal.js +25 -0
- package/dist/screens/types.js +1 -0
- package/dist/services.js +1 -0
- package/dist/startup.js +84 -0
- package/dist/web/server.js +988 -0
- package/package.json +69 -5
- package/web/dist/assets/anton-latin-400-normal-AUNGEG_V.woff +0 -0
- package/web/dist/assets/anton-latin-400-normal-Byf51wtH.woff2 +0 -0
- package/web/dist/assets/anton-latin-ext-400-normal-BMODBQc6.woff +0 -0
- package/web/dist/assets/anton-latin-ext-400-normal-SyiqE2Jt.woff2 +0 -0
- package/web/dist/assets/anton-vietnamese-400-normal-2FfR1wHA.woff +0 -0
- package/web/dist/assets/anton-vietnamese-400-normal-CkBxLiRJ.woff2 +0 -0
- package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
- package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
- package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
- package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
- package/web/dist/assets/button-ClA70PLR.js +1 -0
- package/web/dist/assets/code-xml-CXkpHeU9.js +1 -0
- package/web/dist/assets/compare-view-DKl5l_Jw.js +2 -0
- package/web/dist/assets/copy-CCXzeW-s.js +1 -0
- package/web/dist/assets/cypher-view-Pd0WFH8F.js +13 -0
- package/web/dist/assets/design-view-CIHSmH4u.js +9 -0
- package/web/dist/assets/erd-view-CV7mFGAN.js +1 -0
- package/web/dist/assets/format-sql-BLj5_RDZ.js +5 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/graph-force-yBLyErpS.js +1 -0
- package/web/dist/assets/graph-link-OCo_s0zO.js +1 -0
- package/web/dist/assets/graph-view-sM1OlByf.js +3 -0
- package/web/dist/assets/index-CDcnUacl.js +10 -0
- package/web/dist/assets/index-OzJqnJWG.css +2 -0
- package/web/dist/assets/key-round-Cv_7QeED.js +1 -0
- package/web/dist/assets/loader-circle-BK_GMdvN.js +1 -0
- package/web/dist/assets/orbitron-latin-700-normal-4jsRXGGJ.woff2 +0 -0
- package/web/dist/assets/orbitron-latin-700-normal-DmYOh-W9.woff +0 -0
- package/web/dist/assets/orbitron-latin-900-normal-BbJatbhp.woff +0 -0
- package/web/dist/assets/orbitron-latin-900-normal-DrIi7unX.woff2 +0 -0
- package/web/dist/assets/query-builder-view-ChNFb51g.js +4 -0
- package/web/dist/assets/rotate-ccw-CdCaf7Q6.js +1 -0
- package/web/dist/assets/route-DO6IZ7of.js +15 -0
- package/web/dist/assets/seed-view-DFNB8WHE.js +7 -0
- package/web/dist/assets/share-tech-mono-latin-400-normal-15MgdvEM.woff +0 -0
- package/web/dist/assets/share-tech-mono-latin-400-normal-CCqdbj5z.woff2 +0 -0
- package/web/dist/assets/shield-alert-CMS-3j4U.js +1 -0
- package/web/dist/assets/sliders-horizontal-Dg51yc-C.js +1 -0
- package/web/dist/assets/sql-tabs-B4jt6bQj.js +2 -0
- package/web/dist/assets/table-DfMz2f8M.js +1 -0
- package/web/dist/assets/triangle-alert-COGnHCD1.js +1 -0
- package/web/dist/assets/zone-D9dXk8tn.js +5 -0
- package/web/dist/index.html +26 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Beyond this many steps out the chain is followed no further.
|
|
2
|
+
const MAX_DEPTH = 8;
|
|
3
|
+
const MAX_DEPENDENTS = 500;
|
|
4
|
+
/** The catalog and oid of the object; null when there is no such object. */
|
|
5
|
+
async function locate(session, target) {
|
|
6
|
+
if (target.kind === 'column') {
|
|
7
|
+
const [row] = await session.rows(`SELECT c.oid::text AS oid, a.attnum::text AS attnum FROM pg_catalog.pg_class c
|
|
8
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
9
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND NOT a.attisdropped
|
|
10
|
+
WHERE n.nspname = $1 AND c.relname = $2 AND a.attname = $3`, [target.schema, target.table, target.name]);
|
|
11
|
+
return row?.oid
|
|
12
|
+
? { classid: 'pg_catalog.pg_class', objid: row.oid, subid: Number(row.attnum) }
|
|
13
|
+
: null;
|
|
14
|
+
}
|
|
15
|
+
if (target.kind === 'constraint') {
|
|
16
|
+
// What points at a primary key or unique constraint points at the index behind it.
|
|
17
|
+
const [row] = await session.rows(`SELECT CASE WHEN k.contype IN ('p', 'u') THEN k.conindid ELSE k.oid END::text AS oid,
|
|
18
|
+
CASE WHEN k.contype IN ('p', 'u') THEN 'i' ELSE 'c' END AS kind
|
|
19
|
+
FROM pg_catalog.pg_constraint k
|
|
20
|
+
JOIN pg_catalog.pg_class c ON c.oid = k.conrelid
|
|
21
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
22
|
+
WHERE n.nspname = $1 AND c.relname = $2 AND k.conname = $3`, [target.schema, target.table, target.name]);
|
|
23
|
+
if (!row?.oid)
|
|
24
|
+
return null;
|
|
25
|
+
return {
|
|
26
|
+
classid: row.kind === 'i' ? 'pg_catalog.pg_class' : 'pg_catalog.pg_constraint',
|
|
27
|
+
objid: row.oid,
|
|
28
|
+
subid: 0,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (target.kind === 'index') {
|
|
32
|
+
const [row] = await session.rows(`SELECT c.oid::text AS oid FROM pg_catalog.pg_class c
|
|
33
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
34
|
+
WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind IN ('i', 'I')`, [target.schema, target.name]);
|
|
35
|
+
return row?.oid ? { classid: 'pg_catalog.pg_class', objid: row.oid, subid: 0 } : null;
|
|
36
|
+
}
|
|
37
|
+
if (target.kind === 'function') {
|
|
38
|
+
const [row] = await session.rows(`SELECT p.oid::text AS oid FROM pg_catalog.pg_proc p
|
|
39
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
|
40
|
+
WHERE n.nspname = $1 AND p.proname = $2
|
|
41
|
+
AND pg_catalog.pg_get_function_identity_arguments(p.oid) = $3`, [target.schema, target.name, target.args]);
|
|
42
|
+
return row?.oid ? { classid: 'pg_catalog.pg_proc', objid: row.oid, subid: 0 } : null;
|
|
43
|
+
}
|
|
44
|
+
if (target.kind === 'type') {
|
|
45
|
+
const [row] = await session.rows(`SELECT t.oid::text AS oid FROM pg_catalog.pg_type t
|
|
46
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
47
|
+
WHERE n.nspname = $1 AND t.typname = $2`, [target.schema, target.name]);
|
|
48
|
+
return row?.oid ? { classid: 'pg_catalog.pg_type', objid: row.oid, subid: 0 } : null;
|
|
49
|
+
}
|
|
50
|
+
const [row] = await session.rows(`SELECT c.oid::text AS oid FROM pg_catalog.pg_class c
|
|
51
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
52
|
+
WHERE n.nspname = $1 AND c.relname = $2`, [target.schema, target.name]);
|
|
53
|
+
return row?.oid ? { classid: 'pg_catalog.pg_class', objid: row.oid, subid: 0 } : null;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Everything that depends on the object, directly or through something else that does, nearest
|
|
57
|
+
* first; null when there is no such object. A view's rule is reported as the view, and a column
|
|
58
|
+
* default as its table.
|
|
59
|
+
*/
|
|
60
|
+
export async function listDependents(session, target) {
|
|
61
|
+
const found = await locate(session, target);
|
|
62
|
+
if (!found)
|
|
63
|
+
return null;
|
|
64
|
+
const rows = await session.rows(`WITH RECURSIVE
|
|
65
|
+
-- A view's rule stands for the view; a column default for the table it belongs to.
|
|
66
|
+
edge AS (
|
|
67
|
+
SELECT d.refclassid, d.refobjid, d.refobjsubid, d.deptype,
|
|
68
|
+
CASE WHEN d.classid = 'pg_catalog.pg_rewrite'::regclass
|
|
69
|
+
OR d.classid = 'pg_catalog.pg_attrdef'::regclass
|
|
70
|
+
THEN 'pg_catalog.pg_class'::regclass::oid ELSE d.classid END AS classid,
|
|
71
|
+
coalesce(r.ev_class, ad.adrelid, d.objid) AS objid,
|
|
72
|
+
CASE WHEN d.classid = 'pg_catalog.pg_rewrite'::regclass
|
|
73
|
+
OR d.classid = 'pg_catalog.pg_attrdef'::regclass THEN 0 ELSE d.objsubid END AS objsubid
|
|
74
|
+
FROM pg_catalog.pg_depend d
|
|
75
|
+
LEFT JOIN pg_catalog.pg_rewrite r
|
|
76
|
+
ON d.classid = 'pg_catalog.pg_rewrite'::regclass AND r.oid = d.objid
|
|
77
|
+
LEFT JOIN pg_catalog.pg_attrdef ad
|
|
78
|
+
ON d.classid = 'pg_catalog.pg_attrdef'::regclass AND ad.oid = d.objid
|
|
79
|
+
-- Internal dependencies are parts of the object itself (its row type, TOAST table, a view's
|
|
80
|
+
-- rule), except the index behind a primary key or unique constraint, which is worth naming.
|
|
81
|
+
WHERE d.deptype IN ('n', 'a') OR (d.deptype = 'i'
|
|
82
|
+
AND d.classid = 'pg_catalog.pg_class'::regclass
|
|
83
|
+
AND d.refclassid = 'pg_catalog.pg_constraint'::regclass)
|
|
84
|
+
),
|
|
85
|
+
walk AS (
|
|
86
|
+
SELECT e.classid, e.objid, e.objsubid, 1 AS depth, e.deptype = 'n' AS blocking,
|
|
87
|
+
NULL::oid AS via_classid, NULL::oid AS via_objid,
|
|
88
|
+
ARRAY[$1::regclass::oid, $2::oid, e.classid, e.objid] AS path
|
|
89
|
+
FROM edge e
|
|
90
|
+
WHERE e.refclassid = $1::regclass::oid AND e.refobjid = $2::oid
|
|
91
|
+
-- For a column, only what depends on that column.
|
|
92
|
+
AND ($3::int = 0 OR e.refobjsubid = $3::int)
|
|
93
|
+
AND NOT (e.classid = $1::regclass::oid AND e.objid = $2::oid)
|
|
94
|
+
UNION ALL
|
|
95
|
+
SELECT e.classid, e.objid, e.objsubid, w.depth + 1, w.blocking OR e.deptype = 'n',
|
|
96
|
+
w.classid, w.objid, w.path || ARRAY[e.classid, e.objid]
|
|
97
|
+
FROM walk w
|
|
98
|
+
JOIN edge e ON e.refclassid = w.classid AND e.refobjid = w.objid
|
|
99
|
+
WHERE w.depth < ${MAX_DEPTH}
|
|
100
|
+
AND NOT (e.classid = $1::regclass::oid AND e.objid = $2::oid)
|
|
101
|
+
-- Not back to something already on this path, so a cycle ends.
|
|
102
|
+
AND NOT EXISTS (SELECT 1 FROM generate_subscripts(w.path, 1) s
|
|
103
|
+
WHERE s % 2 = 1 AND w.path[s] = e.classid AND w.path[s + 1] = e.objid)
|
|
104
|
+
),
|
|
105
|
+
-- Each object once: the nearest way to it, blocking if any way to it is.
|
|
106
|
+
nearest AS (
|
|
107
|
+
SELECT DISTINCT ON (classid, objid, objsubid) classid, objid, objsubid, depth,
|
|
108
|
+
via_classid, via_objid,
|
|
109
|
+
bool_or(blocking) OVER (PARTITION BY classid, objid, objsubid) AS blocking
|
|
110
|
+
FROM walk
|
|
111
|
+
ORDER BY classid, objid, objsubid, depth, blocking DESC
|
|
112
|
+
LIMIT ${MAX_DEPENDENTS}
|
|
113
|
+
)
|
|
114
|
+
SELECT o.type, o.identity, x.depth, x.blocking,
|
|
115
|
+
CASE WHEN x.via_classid IS NULL THEN NULL
|
|
116
|
+
ELSE (pg_catalog.pg_identify_object(x.via_classid, x.via_objid, 0)).identity END AS via,
|
|
117
|
+
link.kind AS link_kind, link.schema AS link_schema, link.name AS link_name,
|
|
118
|
+
link.args AS link_args
|
|
119
|
+
FROM nearest x
|
|
120
|
+
CROSS JOIN LATERAL pg_catalog.pg_identify_object(x.classid, x.objid, x.objsubid) o
|
|
121
|
+
LEFT JOIN LATERAL (
|
|
122
|
+
-- Relations open themselves; constraints, triggers, indexes, policies and rules their table.
|
|
123
|
+
SELECT CASE WHEN c.relkind = 'S' THEN 'sequence' ELSE 'table' END AS kind,
|
|
124
|
+
n.nspname AS schema, c.relname AS name, NULL AS args
|
|
125
|
+
FROM pg_catalog.pg_class c
|
|
126
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
127
|
+
WHERE c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S') AND c.oid = CASE
|
|
128
|
+
WHEN x.classid = 'pg_catalog.pg_class'::regclass THEN
|
|
129
|
+
coalesce((SELECT i.indrelid FROM pg_catalog.pg_index i WHERE i.indexrelid = x.objid), x.objid)
|
|
130
|
+
WHEN x.classid = 'pg_catalog.pg_constraint'::regclass THEN
|
|
131
|
+
(SELECT k.conrelid FROM pg_catalog.pg_constraint k WHERE k.oid = x.objid)
|
|
132
|
+
WHEN x.classid = 'pg_catalog.pg_trigger'::regclass THEN
|
|
133
|
+
(SELECT t.tgrelid FROM pg_catalog.pg_trigger t WHERE t.oid = x.objid)
|
|
134
|
+
WHEN x.classid = 'pg_catalog.pg_policy'::regclass THEN
|
|
135
|
+
(SELECT p.polrelid FROM pg_catalog.pg_policy p WHERE p.oid = x.objid)
|
|
136
|
+
END
|
|
137
|
+
UNION ALL
|
|
138
|
+
SELECT 'function', n.nspname, p.proname, pg_catalog.pg_get_function_identity_arguments(p.oid)
|
|
139
|
+
FROM pg_catalog.pg_proc p
|
|
140
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
|
141
|
+
WHERE x.classid = 'pg_catalog.pg_proc'::regclass AND p.oid = x.objid
|
|
142
|
+
UNION ALL
|
|
143
|
+
SELECT 'type', n.nspname, t.typname, NULL
|
|
144
|
+
FROM pg_catalog.pg_type t
|
|
145
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
146
|
+
WHERE x.classid = 'pg_catalog.pg_type'::regclass AND t.oid = x.objid
|
|
147
|
+
AND t.typtype IN ('e', 'c', 'd', 'r', 'm')
|
|
148
|
+
AND (t.typtype <> 'c' OR EXISTS (SELECT 1 FROM pg_catalog.pg_class c
|
|
149
|
+
WHERE c.oid = t.typrelid AND c.relkind = 'c'))
|
|
150
|
+
LIMIT 1
|
|
151
|
+
) link ON true
|
|
152
|
+
ORDER BY x.blocking DESC, x.depth, o.type, o.identity`, [found.classid, found.objid, found.subid]);
|
|
153
|
+
const dependents = rows.map((row) => {
|
|
154
|
+
const link = row.link_kind === null || row.link_schema === null || row.link_name === null
|
|
155
|
+
? null
|
|
156
|
+
: row.link_kind === 'function'
|
|
157
|
+
? {
|
|
158
|
+
kind: 'function',
|
|
159
|
+
schema: row.link_schema,
|
|
160
|
+
name: row.link_name,
|
|
161
|
+
args: row.link_args ?? '',
|
|
162
|
+
}
|
|
163
|
+
: {
|
|
164
|
+
kind: row.link_kind,
|
|
165
|
+
schema: row.link_schema,
|
|
166
|
+
name: row.link_name,
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
type: row.type ?? '',
|
|
170
|
+
identity: row.identity ?? '',
|
|
171
|
+
depth: Number(row.depth),
|
|
172
|
+
// A table's own checks, indexes and triggers go with it (or with the column they use), whatever
|
|
173
|
+
// the path to them in pg_depend.
|
|
174
|
+
blocking: row.blocking === 't' &&
|
|
175
|
+
!((target.kind === 'table' || target.kind === 'column') &&
|
|
176
|
+
link?.kind === 'table' &&
|
|
177
|
+
link.schema === target.schema &&
|
|
178
|
+
link.name === (target.kind === 'column' ? target.table : target.name) &&
|
|
179
|
+
/constraint|index|trigger|policy/.test(row.type ?? '')),
|
|
180
|
+
via: row.via ?? null,
|
|
181
|
+
link,
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
if (target.kind === 'column' || target.kind === 'constraint' || target.kind === 'index') {
|
|
185
|
+
return { dependents, mentions: [] };
|
|
186
|
+
}
|
|
187
|
+
return { dependents, mentions: await listMentions(session, target, found.objid) };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Functions and procedures whose source names the object as a whole word: Postgres keeps no
|
|
191
|
+
* record of what a body refers to (except a SQL function written with BEGIN ATOMIC), so these are
|
|
192
|
+
* only found by reading the text, and some may be false alarms.
|
|
193
|
+
*/
|
|
194
|
+
async function listMentions(session, target, objid) {
|
|
195
|
+
const rows = await session.rows(`SELECT n.nspname AS schema, p.proname AS name,
|
|
196
|
+
pg_catalog.pg_get_function_identity_arguments(p.oid) AS args, l.lanname AS language
|
|
197
|
+
FROM pg_catalog.pg_proc p
|
|
198
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
|
199
|
+
JOIN pg_catalog.pg_language l ON l.oid = p.prolang
|
|
200
|
+
WHERE l.lanname NOT IN ('c', 'internal')
|
|
201
|
+
AND n.nspname !~ '^pg_' AND n.nspname <> 'information_schema'
|
|
202
|
+
-- What an extension brings is not the user's to change.
|
|
203
|
+
AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend e
|
|
204
|
+
WHERE e.classid = 'pg_catalog.pg_proc'::regclass AND e.objid = p.oid AND e.deptype = 'e')
|
|
205
|
+
AND NOT (p.oid = $2::oid AND $3)
|
|
206
|
+
AND p.prosrc ~* $1
|
|
207
|
+
ORDER BY n.nspname, p.proname, args
|
|
208
|
+
LIMIT 200`, [
|
|
209
|
+
// The name as a word of its own, so "users" does not find "users_archive"; a quoted name
|
|
210
|
+
// matches too, since the quote is not part of a word.
|
|
211
|
+
`(^|[^a-z0-9_$])${escapeRegex(target.name)}($|[^a-z0-9_$])`,
|
|
212
|
+
objid,
|
|
213
|
+
target.kind === 'function',
|
|
214
|
+
]);
|
|
215
|
+
return rows.map((row) => ({
|
|
216
|
+
schema: row.schema ?? '',
|
|
217
|
+
name: row.name ?? '',
|
|
218
|
+
args: row.args ?? '',
|
|
219
|
+
language: row.language ?? '',
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Reads an existing table back as a TableDesign, so the designer can edit it. What a design cannot
|
|
2
|
+
// say (exclusion constraints, indexes with a custom operator class or null ordering) is listed as
|
|
3
|
+
// `untouched`: the page shows it, and the diff never changes it.
|
|
4
|
+
import { BUILTIN_TYPES, } from './ddl.js';
|
|
5
|
+
// Catalog names of types that SQL spells another way.
|
|
6
|
+
const BUILTIN_BY_CATALOG = {
|
|
7
|
+
int2: 'smallint',
|
|
8
|
+
int4: 'integer',
|
|
9
|
+
int8: 'bigint',
|
|
10
|
+
float4: 'real',
|
|
11
|
+
float8: 'double precision',
|
|
12
|
+
bool: 'boolean',
|
|
13
|
+
bpchar: 'char',
|
|
14
|
+
varbit: 'varbit',
|
|
15
|
+
timestamp: 'timestamp',
|
|
16
|
+
timestamptz: 'timestamptz',
|
|
17
|
+
};
|
|
18
|
+
const ACTIONS = {
|
|
19
|
+
a: 'no action',
|
|
20
|
+
r: 'restrict',
|
|
21
|
+
c: 'cascade',
|
|
22
|
+
n: 'set null',
|
|
23
|
+
d: 'set default',
|
|
24
|
+
};
|
|
25
|
+
const METHODS = ['btree', 'hash', 'gin', 'gist', 'brin'];
|
|
26
|
+
/** A catalog type, its modifier and array dimensions as a design type. */
|
|
27
|
+
export function columnType(schema, name, typmod, array) {
|
|
28
|
+
const sql = BUILTIN_BY_CATALOG[name] ?? name;
|
|
29
|
+
const builtin = schema === 'pg_catalog' || sql === 'citext' || sql === 'hstore' ? sql : null;
|
|
30
|
+
const dims = array > 0 ? { array } : {};
|
|
31
|
+
const known = builtin !== null && BUILTIN_TYPES.some((type) => type.name === builtin);
|
|
32
|
+
if (!known)
|
|
33
|
+
return { name, schema, ...dims };
|
|
34
|
+
const sized = BUILTIN_TYPES.find((type) => type.name === builtin)?.sized;
|
|
35
|
+
let size = {};
|
|
36
|
+
if (typmod >= 0) {
|
|
37
|
+
if (sized === 'length')
|
|
38
|
+
size = { length: builtin === 'bit' || builtin === 'varbit' ? typmod : typmod - 4 };
|
|
39
|
+
else if (sized === 'numeric') {
|
|
40
|
+
const packed = typmod - 4;
|
|
41
|
+
size = { precision: (packed >> 16) & 0xffff, scale: packed & 0xffff };
|
|
42
|
+
}
|
|
43
|
+
else if (sized === 'precision') {
|
|
44
|
+
// An interval keeps its precision in the low 16 bits, 0xFFFF meaning none.
|
|
45
|
+
const precision = builtin === 'interval' ? typmod & 0xffff : typmod;
|
|
46
|
+
if (precision !== 0xffff)
|
|
47
|
+
size = { length: precision };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { name: builtin, ...size, ...dims };
|
|
51
|
+
}
|
|
52
|
+
/** `(id * 2)` as `id * 2`: Postgres brackets a stored expression, and the builder brackets it again. */
|
|
53
|
+
function unwrap(sql) {
|
|
54
|
+
if (!sql.startsWith('(') || !sql.endsWith(')'))
|
|
55
|
+
return sql;
|
|
56
|
+
let depth = 0;
|
|
57
|
+
for (let at = 0; at < sql.length; at++) {
|
|
58
|
+
if (sql[at] === '(')
|
|
59
|
+
depth++;
|
|
60
|
+
else if (sql[at] === ')' && --depth === 0 && at < sql.length - 1)
|
|
61
|
+
return sql;
|
|
62
|
+
}
|
|
63
|
+
return sql.slice(1, -1).trim();
|
|
64
|
+
}
|
|
65
|
+
function stripCheck(definition) {
|
|
66
|
+
const match = /^CHECK \((.*)\)( NOT VALID)?$/s.exec(definition.trim());
|
|
67
|
+
return match ? match[1].trim() : null;
|
|
68
|
+
}
|
|
69
|
+
/** Loads the table; null when there is no such table (or it is not an ordinary or partitioned one). */
|
|
70
|
+
export async function loadDesign(session, table) {
|
|
71
|
+
const [head] = await session.rows(`SELECT c.oid::text AS oid, c.relkind AS kind, c.relpersistence = 'u' AS unlogged,
|
|
72
|
+
pg_catalog.obj_description(c.oid, 'pg_class') AS comment, c.reltuples::bigint::text AS rows
|
|
73
|
+
FROM pg_catalog.pg_class c
|
|
74
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
75
|
+
WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind IN ('r', 'p')`, [table.schema, table.name]);
|
|
76
|
+
if (!head?.oid)
|
|
77
|
+
return null;
|
|
78
|
+
const oid = head.oid;
|
|
79
|
+
const [version] = await session.rows(`SELECT (current_setting('server_version_num')::int / 10000) AS major`);
|
|
80
|
+
const columnRows = await session.rows(`SELECT a.attname AS name,
|
|
81
|
+
coalesce(et.nspname, tn.nspname) AS type_schema, coalesce(e.typname, t.typname) AS type_name,
|
|
82
|
+
a.atttypmod AS typmod, a.attndims AS dims, (t.typcategory = 'A' AND t.typelem <> 0) AS is_array,
|
|
83
|
+
a.attnotnull AS not_null, pg_catalog.pg_get_expr(d.adbin, d.adrelid) AS default,
|
|
84
|
+
a.attidentity AS identity, a.attgenerated AS generated,
|
|
85
|
+
pg_catalog.col_description(a.attrelid, a.attnum) AS comment
|
|
86
|
+
FROM pg_catalog.pg_attribute a
|
|
87
|
+
JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
|
|
88
|
+
JOIN pg_catalog.pg_namespace tn ON tn.oid = t.typnamespace
|
|
89
|
+
LEFT JOIN pg_catalog.pg_type e ON t.typcategory = 'A' AND e.oid = t.typelem
|
|
90
|
+
LEFT JOIN pg_catalog.pg_namespace et ON et.oid = e.typnamespace
|
|
91
|
+
LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
|
92
|
+
WHERE a.attrelid = $1::oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
93
|
+
ORDER BY a.attnum`, [oid]);
|
|
94
|
+
const columns = columnRows.map((row) => {
|
|
95
|
+
const generated = row.generated === 's';
|
|
96
|
+
const identity = row.identity === 'a' ? 'always' : row.identity === 'd' ? 'by default' : null;
|
|
97
|
+
const array = row.is_array === 't' ? Math.max(Number(row.dims), 1) : 0;
|
|
98
|
+
return {
|
|
99
|
+
name: row.name ?? '',
|
|
100
|
+
type: columnType(row.type_schema ?? '', row.type_name ?? '', Number(row.typmod), array),
|
|
101
|
+
...(row.not_null === 't' ? { notNull: true } : {}),
|
|
102
|
+
...(generated && row.default ? { generated: unwrap(row.default) } : {}),
|
|
103
|
+
...(!generated && !identity && row.default ? { default: row.default } : {}),
|
|
104
|
+
...(identity ? { identity } : {}),
|
|
105
|
+
...(row.comment ? { comment: row.comment } : {}),
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
const untouched = [];
|
|
109
|
+
const constraintRows = await session.rows(`SELECT c.conname AS name, c.contype AS kind, pg_catalog.pg_get_constraintdef(c.oid, true) AS definition,
|
|
110
|
+
(SELECT string_agg(a.attname, E'\\t' ORDER BY k.ord) FROM unnest(c.conkey) WITH ORDINALITY k(attnum, ord)
|
|
111
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum) AS columns,
|
|
112
|
+
rn.nspname AS ref_schema, rc.relname AS ref_table,
|
|
113
|
+
(SELECT string_agg(a.attname, E'\\t' ORDER BY k.ord) FROM unnest(c.confkey) WITH ORDINALITY k(attnum, ord)
|
|
114
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.attnum) AS ref_columns,
|
|
115
|
+
c.confdeltype AS del, c.confupdtype AS upd, c.confmatchtype = 'f' AS full,
|
|
116
|
+
c.condeferrable AS deferrable, c.condeferred AS deferred, c.conparentid <> 0 AS inherited
|
|
117
|
+
FROM pg_catalog.pg_constraint c
|
|
118
|
+
LEFT JOIN pg_catalog.pg_class rc ON rc.oid = c.confrelid
|
|
119
|
+
LEFT JOIN pg_catalog.pg_namespace rn ON rn.oid = rc.relnamespace
|
|
120
|
+
WHERE c.conrelid = $1::oid AND c.contype IN ('p', 'u', 'c', 'f', 'x')
|
|
121
|
+
ORDER BY array_position(ARRAY['p', 'u', 'f', 'c', 'x']::"char"[], c.contype), c.conname`, [oid]);
|
|
122
|
+
const constraints = [];
|
|
123
|
+
for (const row of constraintRows) {
|
|
124
|
+
const name = row.name ?? '';
|
|
125
|
+
const own = (row.columns ?? '').split('\t').filter(Boolean);
|
|
126
|
+
if (row.inherited === 't') {
|
|
127
|
+
untouched.push({ name, reason: 'inherited from the partitioned table' });
|
|
128
|
+
}
|
|
129
|
+
else if (row.kind === 'p') {
|
|
130
|
+
constraints.push({ kind: 'primary', name, columns: own });
|
|
131
|
+
}
|
|
132
|
+
else if (row.kind === 'u') {
|
|
133
|
+
constraints.push({
|
|
134
|
+
kind: 'unique',
|
|
135
|
+
name,
|
|
136
|
+
columns: own,
|
|
137
|
+
...(/NULLS NOT DISTINCT/.test(row.definition ?? '') ? { nullsNotDistinct: true } : {}),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
else if (row.kind === 'c') {
|
|
141
|
+
const expression = stripCheck(row.definition ?? '');
|
|
142
|
+
if (expression === null)
|
|
143
|
+
untouched.push({ name, reason: 'a check the designer cannot read' });
|
|
144
|
+
else
|
|
145
|
+
constraints.push({ kind: 'check', name, expression });
|
|
146
|
+
}
|
|
147
|
+
else if (row.kind === 'f') {
|
|
148
|
+
const onDelete = ACTIONS[row.del ?? 'a'];
|
|
149
|
+
const onUpdate = ACTIONS[row.upd ?? 'a'];
|
|
150
|
+
constraints.push({
|
|
151
|
+
kind: 'foreign',
|
|
152
|
+
name,
|
|
153
|
+
columns: own,
|
|
154
|
+
references: {
|
|
155
|
+
schema: row.ref_schema ?? '',
|
|
156
|
+
table: row.ref_table ?? '',
|
|
157
|
+
columns: (row.ref_columns ?? '').split('\t').filter(Boolean),
|
|
158
|
+
},
|
|
159
|
+
...(onDelete && onDelete !== 'no action' ? { onDelete } : {}),
|
|
160
|
+
...(onUpdate && onUpdate !== 'no action' ? { onUpdate } : {}),
|
|
161
|
+
...(row.full === 't' ? { matchFull: true } : {}),
|
|
162
|
+
...(row.deferrable === 't' ? { deferrable: true } : {}),
|
|
163
|
+
...(row.deferred === 't' ? { initiallyDeferred: true } : {}),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
untouched.push({ name, reason: 'an exclusion constraint' });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const indexRows = await session.rows(`SELECT ic.relname AS name, i.indisunique AS unique, am.amname AS method,
|
|
171
|
+
(SELECT string_agg(
|
|
172
|
+
CASE WHEN i.indkey[k - 1] = 0 THEN 'e:' ELSE 'c:' END || pg_catalog.pg_get_indexdef(i.indexrelid, k, true),
|
|
173
|
+
E'\\t' ORDER BY k) FROM generate_series(1, i.indnkeyatts) k) AS keys,
|
|
174
|
+
(SELECT string_agg(pg_catalog.pg_get_indexdef(i.indexrelid, k, true), E'\\t' ORDER BY k)
|
|
175
|
+
FROM generate_series(i.indnkeyatts + 1, i.indnatts) k) AS include,
|
|
176
|
+
pg_catalog.pg_get_expr(i.indpred, i.indrelid) AS predicate,
|
|
177
|
+
i.indoption::text AS options,
|
|
178
|
+
(SELECT bool_or(NOT oc.opcdefault) FROM unnest(i.indclass::oid[]) o(oid)
|
|
179
|
+
JOIN pg_catalog.pg_opclass oc ON oc.oid = o.oid) AS custom,
|
|
180
|
+
pg_catalog.pg_get_indexdef(i.indexrelid) AS definition
|
|
181
|
+
FROM pg_catalog.pg_index i
|
|
182
|
+
JOIN pg_catalog.pg_class ic ON ic.oid = i.indexrelid
|
|
183
|
+
JOIN pg_catalog.pg_am am ON am.oid = ic.relam
|
|
184
|
+
WHERE i.indrelid = $1::oid AND NOT i.indisprimary
|
|
185
|
+
-- An index behind a unique or exclusion constraint belongs to that constraint.
|
|
186
|
+
AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_constraint k
|
|
187
|
+
WHERE k.conindid = i.indexrelid AND k.contype IN ('u', 'x', 'p'))
|
|
188
|
+
ORDER BY ic.relname`, [oid]);
|
|
189
|
+
const indexes = [];
|
|
190
|
+
for (const row of indexRows) {
|
|
191
|
+
const name = row.name ?? '';
|
|
192
|
+
// indoption: bit 1 is DESC, bit 2 NULLS FIRST. DESC with NULLS FIRST is the default for DESC.
|
|
193
|
+
const options = (row.options ?? '').split(' ').map(Number);
|
|
194
|
+
const odd = options.some((option) => option !== 0 && option !== 3);
|
|
195
|
+
if (!METHODS.includes(row.method ?? '')) {
|
|
196
|
+
untouched.push({ name, reason: `a ${row.method} index` });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (row.custom === 't' || odd || /\bCOLLATE\b/.test(row.definition ?? '')) {
|
|
200
|
+
untouched.push({ name, reason: 'an index with an operator class, collation or null ordering' });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const items = (row.keys ?? '').split('\t').map((entry, at) => {
|
|
204
|
+
const descending = options[at] === 3;
|
|
205
|
+
const body = entry.slice(2);
|
|
206
|
+
return entry.startsWith('c:')
|
|
207
|
+
? { column: body, ...(descending ? { descending } : {}) }
|
|
208
|
+
: { expression: body, ...(descending ? { descending } : {}) };
|
|
209
|
+
});
|
|
210
|
+
const include = (row.include ?? '').split('\t').filter(Boolean);
|
|
211
|
+
indexes.push({
|
|
212
|
+
name,
|
|
213
|
+
...(row.unique === 't' ? { unique: true } : {}),
|
|
214
|
+
...(row.method !== 'btree' ? { method: row.method } : {}),
|
|
215
|
+
items,
|
|
216
|
+
...(include.length > 0 ? { include } : {}),
|
|
217
|
+
...(row.predicate ? { where: unwrap(row.predicate) } : {}),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const [partition] = head.kind === 'p'
|
|
221
|
+
? await session.rows(`SELECT p.partstrat AS strategy, a.attname AS column, p.partnatts = 1 AS simple
|
|
222
|
+
FROM pg_catalog.pg_partitioned_table p
|
|
223
|
+
LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = p.partrelid AND a.attnum = p.partattrs[0]
|
|
224
|
+
WHERE p.partrelid = $1::oid`, [oid])
|
|
225
|
+
: [];
|
|
226
|
+
const methods = {
|
|
227
|
+
r: 'range',
|
|
228
|
+
l: 'list',
|
|
229
|
+
h: 'hash',
|
|
230
|
+
};
|
|
231
|
+
const method = methods[partition?.strategy ?? ''];
|
|
232
|
+
if (partition && (!method || partition.simple !== 't' || !partition.column)) {
|
|
233
|
+
untouched.push({ name: table.name, reason: 'a partition key the designer cannot read' });
|
|
234
|
+
}
|
|
235
|
+
const design = {
|
|
236
|
+
schema: table.schema,
|
|
237
|
+
name: table.name,
|
|
238
|
+
columns,
|
|
239
|
+
constraints,
|
|
240
|
+
indexes,
|
|
241
|
+
...(head.unlogged === 't' ? { unlogged: true } : {}),
|
|
242
|
+
...(head.comment ? { comment: head.comment } : {}),
|
|
243
|
+
...(partition && method && partition.column
|
|
244
|
+
? { partition: { method, column: partition.column } }
|
|
245
|
+
: {}),
|
|
246
|
+
};
|
|
247
|
+
const rows = Number(head.rows);
|
|
248
|
+
return {
|
|
249
|
+
design,
|
|
250
|
+
estimatedRows: Number.isFinite(rows) && rows >= 0 ? rows : null,
|
|
251
|
+
serverMajor: Number(version?.major ?? 0),
|
|
252
|
+
untouched,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export class DesignRunError extends Error {
|
|
2
|
+
statement;
|
|
3
|
+
cause;
|
|
4
|
+
constructor(message,
|
|
5
|
+
// Which statement failed, counting from 0.
|
|
6
|
+
statement, cause) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.statement = statement;
|
|
9
|
+
this.cause = cause;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Runs the statements in one transaction; rolls everything back if any of them fails. */
|
|
13
|
+
export async function runStatements(session, statements, describe) {
|
|
14
|
+
const started = performance.now();
|
|
15
|
+
await session.run('BEGIN');
|
|
16
|
+
let at = 0;
|
|
17
|
+
try {
|
|
18
|
+
for (; at < statements.length; at++)
|
|
19
|
+
await session.run(statements[at]);
|
|
20
|
+
await session.run('COMMIT');
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
await session.run('ROLLBACK').catch(() => undefined);
|
|
24
|
+
throw new DesignRunError(describe(error), at, error);
|
|
25
|
+
}
|
|
26
|
+
return { durationMs: performance.now() - started };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Runs a plan: the statements that can go in a transaction in one, and then those that cannot (an
|
|
30
|
+
* index built CONCURRENTLY), one by one. A failure of the second kind leaves the first committed.
|
|
31
|
+
*/
|
|
32
|
+
export async function runPlan(session, plan, describe) {
|
|
33
|
+
const started = performance.now();
|
|
34
|
+
if (plan.transactional.length > 0)
|
|
35
|
+
await runStatements(session, plan.transactional, describe);
|
|
36
|
+
for (const [at, sql] of plan.concurrent.entries()) {
|
|
37
|
+
try {
|
|
38
|
+
await session.run(sql);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
throw new DesignRunError(`${describe(error)}\nThe statements before this one were already committed.`, plan.transactional.length + at, error);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { durationMs: performance.now() - started };
|
|
45
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Editing the rows of a table: the UPDATE, INSERT or DELETE for one row, shown before it is run,
|
|
2
|
+
// and run in a transaction that only commits when exactly the intended row changed.
|
|
3
|
+
import { quoteIdentifier } from '../db/identifiers.js';
|
|
4
|
+
import { qualified } from './catalog.js';
|
|
5
|
+
export class EditError extends Error {
|
|
6
|
+
}
|
|
7
|
+
// A value longer than this is cut short where the statement is shown, not where it is run.
|
|
8
|
+
const SHOWN_LENGTH = 200;
|
|
9
|
+
const literal = (value) => {
|
|
10
|
+
if (value === null)
|
|
11
|
+
return 'NULL';
|
|
12
|
+
const shown = value.length > SHOWN_LENGTH
|
|
13
|
+
? `${value.slice(0, SHOWN_LENGTH)}... (${value.length} characters)`
|
|
14
|
+
: value;
|
|
15
|
+
return `'${shown.replaceAll("'", "''")}'`;
|
|
16
|
+
};
|
|
17
|
+
/** Whether rows of a table can be told apart, which editing needs: the names of its primary key. */
|
|
18
|
+
export const primaryKeyOf = (columns) => columns.filter((column) => column.primaryKey).map((column) => column.name);
|
|
19
|
+
function knownColumns(columns, names) {
|
|
20
|
+
for (const name of names) {
|
|
21
|
+
if (!columns.some((column) => column.name === name))
|
|
22
|
+
throw new EditError(`no column "${name}"`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** `"a" = $1 AND "b" = $2`, numbered from `first`; the key must be exactly the primary key. */
|
|
26
|
+
function keyClause(columns, key, first) {
|
|
27
|
+
const primary = primaryKeyOf(columns);
|
|
28
|
+
if (primary.length === 0)
|
|
29
|
+
throw new EditError('this table has no primary key, so its rows cannot be edited');
|
|
30
|
+
const given = Object.keys(key);
|
|
31
|
+
if (given.length !== primary.length || !primary.every((name) => given.includes(name))) {
|
|
32
|
+
throw new EditError(`the key must be the primary key (${primary.join(', ')})`);
|
|
33
|
+
}
|
|
34
|
+
const values = primary.map((name) => {
|
|
35
|
+
const value = key[name];
|
|
36
|
+
if (typeof value !== 'string')
|
|
37
|
+
throw new EditError(`the key needs a value for "${name}"`);
|
|
38
|
+
return value;
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
sql: primary.map((name, i) => `${quoteIdentifier(name)} = $${first + i}`).join(' AND '),
|
|
42
|
+
display: primary
|
|
43
|
+
.map((name, i) => `${quoteIdentifier(name)} = ${literal(values[i])}`)
|
|
44
|
+
.join(' AND '),
|
|
45
|
+
values,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function buildEdit(table, columns, request) {
|
|
49
|
+
const target = qualified(table);
|
|
50
|
+
if (request.action === 'delete') {
|
|
51
|
+
const where = keyClause(columns, request.key, 1);
|
|
52
|
+
return {
|
|
53
|
+
sql: `DELETE FROM ${target} WHERE ${where.sql}`,
|
|
54
|
+
params: where.values,
|
|
55
|
+
display: `DELETE FROM ${target} WHERE ${where.display}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const names = Object.keys(request.values);
|
|
59
|
+
knownColumns(columns, names);
|
|
60
|
+
if (request.action === 'insert') {
|
|
61
|
+
if (names.length === 0) {
|
|
62
|
+
return {
|
|
63
|
+
sql: `INSERT INTO ${target} DEFAULT VALUES`,
|
|
64
|
+
params: [],
|
|
65
|
+
display: `INSERT INTO ${target} DEFAULT VALUES`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const list = names.map(quoteIdentifier).join(', ');
|
|
69
|
+
const values = names.map((name) => request.values[name]);
|
|
70
|
+
return {
|
|
71
|
+
sql: `INSERT INTO ${target} (${list}) VALUES (${names.map((_, i) => `$${i + 1}`).join(', ')})`,
|
|
72
|
+
params: values,
|
|
73
|
+
display: `INSERT INTO ${target} (${list}) VALUES (${values.map(literal).join(', ')})`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (names.length === 0)
|
|
77
|
+
throw new EditError('nothing was changed');
|
|
78
|
+
const where = keyClause(columns, request.key, names.length + 1);
|
|
79
|
+
const values = names.map((name) => request.values[name]);
|
|
80
|
+
const set = names.map((name, i) => `${quoteIdentifier(name)} = $${i + 1}`).join(', ');
|
|
81
|
+
const shown = names
|
|
82
|
+
.map((name, i) => `${quoteIdentifier(name)} = ${literal(values[i])}`)
|
|
83
|
+
.join(', ');
|
|
84
|
+
return {
|
|
85
|
+
sql: `UPDATE ${target} SET ${set} WHERE ${where.sql}`,
|
|
86
|
+
params: [...values, ...where.values],
|
|
87
|
+
display: `UPDATE ${target} SET ${shown} WHERE ${where.display}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Runs the edit inside BEGIN ... COMMIT. When it did not change exactly one row (the row is gone,
|
|
92
|
+
* or a key matched more than it should) it is rolled back and says so.
|
|
93
|
+
*/
|
|
94
|
+
export async function applyEdit(session, edit) {
|
|
95
|
+
await session.run('BEGIN');
|
|
96
|
+
try {
|
|
97
|
+
const result = await session.run(edit.sql, edit.params);
|
|
98
|
+
if (result.rowCount !== 1) {
|
|
99
|
+
throw new EditError(`the statement changed ${result.rowCount ?? 0} rows, not 1, so nothing was changed`);
|
|
100
|
+
}
|
|
101
|
+
await session.run('COMMIT');
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
await session.run('ROLLBACK').catch(() => undefined);
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan for a statement, with schemas and qualified column names so that advice can name them.
|
|
3
|
+
* Without ANALYZE the statement is planned but never run, so explaining
|
|
4
|
+
* an UPDATE or DELETE changes nothing.
|
|
5
|
+
*/
|
|
6
|
+
export async function explainQuery(session, sql) {
|
|
7
|
+
const statement = sql.replace(/[\s;]+$/, '');
|
|
8
|
+
const [row] = await session.rows(`EXPLAIN (VERBOSE, FORMAT JSON) ${statement}`);
|
|
9
|
+
const plan = JSON.parse(row?.['QUERY PLAN'] ?? '[]');
|
|
10
|
+
if (!plan[0]?.Plan)
|
|
11
|
+
throw new Error('Postgres did not return a plan');
|
|
12
|
+
return plan[0].Plan;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Runs the statement to measure it (EXPLAIN ANALYZE), so every step of the plan has its actual
|
|
16
|
+
* time, rows and buffers next to the planner's estimates. It runs inside a transaction that is
|
|
17
|
+
* always rolled back, so rows it inserts, updates or deletes are not kept; what cannot be undone
|
|
18
|
+
* (a sequence's nextval, a NOTIFY sent by a trigger, ...) still happens.
|
|
19
|
+
*/
|
|
20
|
+
export async function explainAnalyze(session, sql) {
|
|
21
|
+
const statement = sql.replace(/[\s;]+$/, '');
|
|
22
|
+
await session.run('BEGIN');
|
|
23
|
+
try {
|
|
24
|
+
const [row] = await session.rows(`EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT JSON) ${statement}`);
|
|
25
|
+
const plan = JSON.parse(row?.['QUERY PLAN'] ?? '[]');
|
|
26
|
+
if (!plan[0]?.Plan)
|
|
27
|
+
throw new Error('Postgres did not return a plan');
|
|
28
|
+
return {
|
|
29
|
+
plan: plan[0].Plan,
|
|
30
|
+
planningMs: plan[0]['Planning Time'] ?? null,
|
|
31
|
+
executionMs: plan[0]['Execution Time'] ?? null,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await session.run('ROLLBACK').catch(() => undefined);
|
|
36
|
+
}
|
|
37
|
+
}
|