@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.
Files changed (127) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +505 -2
  3. package/RELEASES.md +87 -0
  4. package/dist/app.js +39 -0
  5. package/dist/cli.js +307 -0
  6. package/dist/commands/configgen.js +128 -0
  7. package/dist/commands/explore.js +388 -0
  8. package/dist/commands/terminal-prompt.js +68 -0
  9. package/dist/config/load.js +324 -0
  10. package/dist/config/resolve-environment.js +112 -0
  11. package/dist/config/resolvers/env.js +12 -0
  12. package/dist/config/resolvers/file.js +24 -0
  13. package/dist/config/resolvers/groups.js +1 -0
  14. package/dist/config/resolvers/index.js +49 -0
  15. package/dist/config/resolvers/literal.js +7 -0
  16. package/dist/config/resolvers/prompt.js +159 -0
  17. package/dist/config/resolvers/stored.js +13 -0
  18. package/dist/config/resolvers/types.js +18 -0
  19. package/dist/config/resolvers/vault.js +283 -0
  20. package/dist/config/schema.js +24 -0
  21. package/dist/config/source.js +105 -0
  22. package/dist/db/connect.js +55 -0
  23. package/dist/db/identifiers.js +6 -0
  24. package/dist/db/paths.js +14 -0
  25. package/dist/db/store.js +323 -0
  26. package/dist/db/tester.js +190 -0
  27. package/dist/db/validate.js +99 -0
  28. package/dist/environments.js +140 -0
  29. package/dist/explorer/alter.js +493 -0
  30. package/dist/explorer/catalog.js +414 -0
  31. package/dist/explorer/connect-entry.js +42 -0
  32. package/dist/explorer/cypher-parse.js +478 -0
  33. package/dist/explorer/cypher.js +963 -0
  34. package/dist/explorer/ddl.js +502 -0
  35. package/dist/explorer/dependents.js +222 -0
  36. package/dist/explorer/design-load.js +254 -0
  37. package/dist/explorer/design.js +45 -0
  38. package/dist/explorer/edit.js +108 -0
  39. package/dist/explorer/explain.js +37 -0
  40. package/dist/explorer/filter.js +121 -0
  41. package/dist/explorer/format.js +134 -0
  42. package/dist/explorer/graph.js +679 -0
  43. package/dist/explorer/gucs.js +31 -0
  44. package/dist/explorer/profile.js +102 -0
  45. package/dist/explorer/references.js +95 -0
  46. package/dist/explorer/rows.js +64 -0
  47. package/dist/explorer/schema-diff.js +360 -0
  48. package/dist/explorer/search.js +309 -0
  49. package/dist/explorer/security.js +152 -0
  50. package/dist/explorer/seed.js +1376 -0
  51. package/dist/explorer/session.js +122 -0
  52. package/dist/explorer/stats.js +135 -0
  53. package/dist/explorer/timeout.js +13 -0
  54. package/dist/prompts.js +52 -0
  55. package/dist/screens/environments.js +229 -0
  56. package/dist/screens/explorer.js +145 -0
  57. package/dist/screens/header.js +8 -0
  58. package/dist/screens/index.js +13 -0
  59. package/dist/screens/new-connection.js +178 -0
  60. package/dist/screens/panes/common.js +35 -0
  61. package/dist/screens/panes/history.js +36 -0
  62. package/dist/screens/panes/info.js +61 -0
  63. package/dist/screens/panes/query.js +113 -0
  64. package/dist/screens/panes/table.js +178 -0
  65. package/dist/screens/panes/tables.js +142 -0
  66. package/dist/screens/prompt-modal.js +18 -0
  67. package/dist/screens/record-view.js +42 -0
  68. package/dist/screens/result-grid.js +126 -0
  69. package/dist/screens/scroll.js +12 -0
  70. package/dist/screens/splash.js +78 -0
  71. package/dist/screens/terminal.js +25 -0
  72. package/dist/screens/types.js +1 -0
  73. package/dist/services.js +1 -0
  74. package/dist/startup.js +84 -0
  75. package/dist/web/server.js +988 -0
  76. package/package.json +69 -5
  77. package/web/dist/assets/anton-latin-400-normal-AUNGEG_V.woff +0 -0
  78. package/web/dist/assets/anton-latin-400-normal-Byf51wtH.woff2 +0 -0
  79. package/web/dist/assets/anton-latin-ext-400-normal-BMODBQc6.woff +0 -0
  80. package/web/dist/assets/anton-latin-ext-400-normal-SyiqE2Jt.woff2 +0 -0
  81. package/web/dist/assets/anton-vietnamese-400-normal-2FfR1wHA.woff +0 -0
  82. package/web/dist/assets/anton-vietnamese-400-normal-CkBxLiRJ.woff2 +0 -0
  83. package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
  84. package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
  85. package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
  86. package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
  87. package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
  88. package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
  89. package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
  90. package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
  91. package/web/dist/assets/button-ClA70PLR.js +1 -0
  92. package/web/dist/assets/code-xml-CXkpHeU9.js +1 -0
  93. package/web/dist/assets/compare-view-DKl5l_Jw.js +2 -0
  94. package/web/dist/assets/copy-CCXzeW-s.js +1 -0
  95. package/web/dist/assets/cypher-view-Pd0WFH8F.js +13 -0
  96. package/web/dist/assets/design-view-CIHSmH4u.js +9 -0
  97. package/web/dist/assets/erd-view-CV7mFGAN.js +1 -0
  98. package/web/dist/assets/format-sql-BLj5_RDZ.js +5 -0
  99. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  100. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  101. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  102. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  103. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  104. package/web/dist/assets/graph-force-yBLyErpS.js +1 -0
  105. package/web/dist/assets/graph-link-OCo_s0zO.js +1 -0
  106. package/web/dist/assets/graph-view-sM1OlByf.js +3 -0
  107. package/web/dist/assets/index-CDcnUacl.js +10 -0
  108. package/web/dist/assets/index-OzJqnJWG.css +2 -0
  109. package/web/dist/assets/key-round-Cv_7QeED.js +1 -0
  110. package/web/dist/assets/loader-circle-BK_GMdvN.js +1 -0
  111. package/web/dist/assets/orbitron-latin-700-normal-4jsRXGGJ.woff2 +0 -0
  112. package/web/dist/assets/orbitron-latin-700-normal-DmYOh-W9.woff +0 -0
  113. package/web/dist/assets/orbitron-latin-900-normal-BbJatbhp.woff +0 -0
  114. package/web/dist/assets/orbitron-latin-900-normal-DrIi7unX.woff2 +0 -0
  115. package/web/dist/assets/query-builder-view-ChNFb51g.js +4 -0
  116. package/web/dist/assets/rotate-ccw-CdCaf7Q6.js +1 -0
  117. package/web/dist/assets/route-DO6IZ7of.js +15 -0
  118. package/web/dist/assets/seed-view-DFNB8WHE.js +7 -0
  119. package/web/dist/assets/share-tech-mono-latin-400-normal-15MgdvEM.woff +0 -0
  120. package/web/dist/assets/share-tech-mono-latin-400-normal-CCqdbj5z.woff2 +0 -0
  121. package/web/dist/assets/shield-alert-CMS-3j4U.js +1 -0
  122. package/web/dist/assets/sliders-horizontal-Dg51yc-C.js +1 -0
  123. package/web/dist/assets/sql-tabs-B4jt6bQj.js +2 -0
  124. package/web/dist/assets/table-DfMz2f8M.js +1 -0
  125. package/web/dist/assets/triangle-alert-COGnHCD1.js +1 -0
  126. package/web/dist/assets/zone-D9dXk8tn.js +5 -0
  127. 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
+ }