tablewalk 0.0.1
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 +553 -0
- package/dist/adapters/adapter.js +372 -0
- package/dist/adapters/connect.js +33 -0
- package/dist/adapters/mysql.js +951 -0
- package/dist/adapters/postgres.js +1000 -0
- package/dist/adapters/sqlite.js +781 -0
- package/dist/client/agent.js +262 -0
- package/dist/client/app.js +973 -0
- package/dist/client/arrange.js +254 -0
- package/dist/client/ask.js +133 -0
- package/dist/client/breakdown.js +317 -0
- package/dist/client/clauses.js +390 -0
- package/dist/client/columns.js +98 -0
- package/dist/client/complete.js +437 -0
- package/dist/client/compose.js +166 -0
- package/dist/client/composer.css +495 -0
- package/dist/client/composer.js +1972 -0
- package/dist/client/connections.js +234 -0
- package/dist/client/connmanager.js +962 -0
- package/dist/client/connurl.js +188 -0
- package/dist/client/core.js +893 -0
- package/dist/client/deeplink.js +270 -0
- package/dist/client/delete.js +144 -0
- package/dist/client/diagram.js +885 -0
- package/dist/client/dropdown.js +279 -0
- package/dist/client/export.js +456 -0
- package/dist/client/features.css +524 -0
- package/dist/client/findvalue.js +169 -0
- package/dist/client/grid.js +205 -0
- package/dist/client/handoff.js +153 -0
- package/dist/client/help.css +145 -0
- package/dist/client/help.js +881 -0
- package/dist/client/history.js +222 -0
- package/dist/client/index.html +116 -0
- package/dist/client/insert.js +151 -0
- package/dist/client/menu.js +160 -0
- package/dist/client/nested.js +255 -0
- package/dist/client/page.css +713 -0
- package/dist/client/page.js +1345 -0
- package/dist/client/pagebuilder.js +1222 -0
- package/dist/client/pagemarks.js +95 -0
- package/dist/client/palette.js +374 -0
- package/dist/client/peek.js +254 -0
- package/dist/client/picker.js +139 -0
- package/dist/client/pins.js +140 -0
- package/dist/client/prompt.js +129 -0
- package/dist/client/record.js +707 -0
- package/dist/client/schemaexport.js +242 -0
- package/dist/client/schematext.js +125 -0
- package/dist/client/shape.js +178 -0
- package/dist/client/shapecheck.js +129 -0
- package/dist/client/skeleton.js +139 -0
- package/dist/client/sql.css +126 -0
- package/dist/client/sql.js +398 -0
- package/dist/client/sqlcomplete.js +163 -0
- package/dist/client/sqlsaved.js +107 -0
- package/dist/client/style.css +2711 -0
- package/dist/client/summary.js +259 -0
- package/dist/client/table.js +1035 -0
- package/dist/client/template.js +539 -0
- package/dist/client/theme.js +74 -0
- package/dist/client/tour.js +324 -0
- package/dist/client/undo.js +105 -0
- package/dist/client/url.js +166 -0
- package/dist/client/value.js +223 -0
- package/dist/client/views.js +215 -0
- package/dist/client/virtual.js +176 -0
- package/dist/client/welcome.js +170 -0
- package/dist/client/write.js +414 -0
- package/dist/server/changeimpact.js +195 -0
- package/dist/server/connections.js +615 -0
- package/dist/server/constraints.js +62 -0
- package/dist/server/credentials.js +230 -0
- package/dist/server/fixture.js +199 -0
- package/dist/server/graph.js +194 -0
- package/dist/server/impact.js +48 -0
- package/dist/server/index.js +2204 -0
- package/dist/server/journal.js +173 -0
- package/dist/server/layouts.js +128 -0
- package/dist/server/mcp.js +2840 -0
- package/dist/server/shapeonly.js +91 -0
- package/dist/shared/breakdown.js +231 -0
- package/dist/shared/breakdowntext.js +257 -0
- package/dist/shared/diff.js +130 -0
- package/dist/shared/like.js +29 -0
- package/dist/shared/lint.js +149 -0
- package/dist/shared/order.js +133 -0
- package/dist/shared/page.js +932 -0
- package/dist/shared/query.js +831 -0
- package/dist/shared/recordview.js +343 -0
- package/dist/shared/schema.js +377 -0
- package/dist/shared/sqlsaved.js +67 -0
- package/dist/shared/view.js +981 -0
- package/dist/shared/viewtext.js +273 -0
- package/dist/shared/vocabulary.js +164 -0
- package/package.json +57 -0
|
@@ -0,0 +1,1000 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres adapter, on `pg`.
|
|
3
|
+
*
|
|
4
|
+
* Where SQLite hands you a per-table PRAGMA, Postgres hands you a catalog you
|
|
5
|
+
* can query in one shot. That difference is worth exploiting: introspection
|
|
6
|
+
* here is three queries total regardless of how many tables exist, which
|
|
7
|
+
* matters the first time someone points this at a schema with four hundred of
|
|
8
|
+
* them.
|
|
9
|
+
*
|
|
10
|
+
* The queries below read `information_schema` for columns and `pg_catalog` for
|
|
11
|
+
* keys and row estimates. `information_schema` is the portable half but it has
|
|
12
|
+
* no cheap row count and describes composite keys awkwardly; `pg_catalog`
|
|
13
|
+
* carries both, at the cost of being Postgres-specific. Since we are already
|
|
14
|
+
* inside the Postgres adapter, that cost is zero.
|
|
15
|
+
*/
|
|
16
|
+
import { POSTGRES_STYLE, buildWhere, clampLimit, boundStatement, dedupe, looksWrappable, refuseStacked, trimStatement, clampOffset, Refusal, Serial, } from './adapter.js';
|
|
17
|
+
import { coerceForColumn, deleteRuleOf } from '../shared/schema.js';
|
|
18
|
+
import { checkVocabulary, checksIn } from '../shared/vocabulary.js';
|
|
19
|
+
import { compileView, compileViewCount } from '../shared/view.js';
|
|
20
|
+
import { compileBreakdown, compileBreakdownCount } from '../shared/breakdown.js';
|
|
21
|
+
/** Schemas that are the server's own business, not the user's data. */
|
|
22
|
+
const SYSTEM_SCHEMAS = ['pg_catalog', 'information_schema', 'pg_toast'];
|
|
23
|
+
/** The first check that pins this column's values, if any does. */
|
|
24
|
+
function firstVocabulary(checks, column) {
|
|
25
|
+
for (const check of checks) {
|
|
26
|
+
const values = checkVocabulary(check, column);
|
|
27
|
+
if (values)
|
|
28
|
+
return values;
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Hand dates and timestamps back as the text the server stores.
|
|
34
|
+
*
|
|
35
|
+
* `pg` parses `timestamp without time zone` into a JavaScript `Date`, which
|
|
36
|
+
* has no such thing as "without time zone" — so it reads the value in the
|
|
37
|
+
* *process's* offset and prints it back in UTC. A row written 09:00 came out
|
|
38
|
+
* of this adapter as `2026-08-01T13:00:00.000Z`: not a formatting difference,
|
|
39
|
+
* a different time, on a value the column explicitly says has no zone. The
|
|
40
|
+
* MySQL adapter avoids the whole question with `dateStrings: true`; `pg` has
|
|
41
|
+
* no such option, so the parsers are replaced instead.
|
|
42
|
+
*
|
|
43
|
+
* `timestamptz` is included even though it is a real instant. Its text form
|
|
44
|
+
* carries the offset, which is more information than a `Date` rendered in
|
|
45
|
+
* whatever zone the browser happens to be in — and tablewalk's job is to show
|
|
46
|
+
* what is stored.
|
|
47
|
+
*
|
|
48
|
+
* Set on the module, because that is the only place `pg` keeps them. Done
|
|
49
|
+
* once, and idempotent, so opening a second connection does not repeat it.
|
|
50
|
+
*/
|
|
51
|
+
const PG_DATE_OIDS = [
|
|
52
|
+
1082, // date
|
|
53
|
+
1083, // time
|
|
54
|
+
1114, // timestamp without time zone
|
|
55
|
+
1184, // timestamp with time zone
|
|
56
|
+
1266, // time with time zone
|
|
57
|
+
];
|
|
58
|
+
let datesKept = false;
|
|
59
|
+
function keepDatesAsText(pg) {
|
|
60
|
+
if (datesKept)
|
|
61
|
+
return;
|
|
62
|
+
const mod = pg;
|
|
63
|
+
const types = mod.types ?? mod.default?.types;
|
|
64
|
+
/* A `pg` that does not expose `types` is one this cannot fix, and a
|
|
65
|
+
connection is worth more than a formatting guarantee. */
|
|
66
|
+
if (!types?.setTypeParser)
|
|
67
|
+
return;
|
|
68
|
+
for (const oid of PG_DATE_OIDS)
|
|
69
|
+
types.setTypeParser(oid, (value) => value);
|
|
70
|
+
datesKept = true;
|
|
71
|
+
}
|
|
72
|
+
export class PostgresAdapter {
|
|
73
|
+
connectionString;
|
|
74
|
+
label;
|
|
75
|
+
schemas;
|
|
76
|
+
dialect = 'postgres';
|
|
77
|
+
client = null;
|
|
78
|
+
/* The second connection, opened the first time a write is actually applied.
|
|
79
|
+
See `writeHandle`. */
|
|
80
|
+
writer = null;
|
|
81
|
+
/* Every write on that connection, one at a time. See `Serial`. */
|
|
82
|
+
writes = new Serial();
|
|
83
|
+
constructor(connectionString, label = redact(connectionString),
|
|
84
|
+
/** Restrict to these schemas. Empty means every non-system schema. */
|
|
85
|
+
schemas = []) {
|
|
86
|
+
this.connectionString = connectionString;
|
|
87
|
+
this.label = label;
|
|
88
|
+
this.schemas = schemas;
|
|
89
|
+
}
|
|
90
|
+
async conn() {
|
|
91
|
+
if (this.client)
|
|
92
|
+
return this.client;
|
|
93
|
+
/* Imported lazily, and through a variable, so `pg` is only required when
|
|
94
|
+
someone actually opens a Postgres URL. The indirection also stops the
|
|
95
|
+
type checker demanding the package be installed to compile the file. */
|
|
96
|
+
const specifier = 'pg';
|
|
97
|
+
const pg = (await import(specifier).catch(() => {
|
|
98
|
+
throw new Error('Postgres support needs the "pg" package. Install it with: npm install pg');
|
|
99
|
+
}));
|
|
100
|
+
const Client = pg.Client ?? pg.default?.Client;
|
|
101
|
+
if (!Client)
|
|
102
|
+
throw new Error('Could not find a Client export in the "pg" package.');
|
|
103
|
+
keepDatesAsText(pg);
|
|
104
|
+
const client = new Client({ connectionString: this.connectionString });
|
|
105
|
+
await client.connect();
|
|
106
|
+
/* Belt and braces alongside the query builder: even if a read somehow
|
|
107
|
+
compiled to a write, the transaction would refuse it. */
|
|
108
|
+
await client.query('SET default_transaction_read_only = on');
|
|
109
|
+
await client.query("SET statement_timeout = '30s'");
|
|
110
|
+
/* A dead client must not be handed out again.
|
|
111
|
+
|
|
112
|
+
The cached client was returned for the life of the process with no
|
|
113
|
+
liveness check, so a server restart or a dropped socket left every
|
|
114
|
+
later request failing identically — the same error for ever, with no
|
|
115
|
+
way back short of a manual refresh, on a connection the database itself
|
|
116
|
+
had long since made available again. `pg` emits `error` on a client
|
|
117
|
+
whose connection has gone; forgetting it there means the next `conn()`
|
|
118
|
+
dials a new one, which is what "reconnect" means for a pool of size
|
|
119
|
+
one.
|
|
120
|
+
|
|
121
|
+
The listener is required, not optional: an `error` event with nothing
|
|
122
|
+
listening is an unhandled `'error'` on an EventEmitter, which takes the
|
|
123
|
+
process down. A read-only database browser crashing because the
|
|
124
|
+
database bounced would be a poor trade for a stack trace. */
|
|
125
|
+
client.on?.('error', () => {
|
|
126
|
+
if (this.client === client)
|
|
127
|
+
this.client = null;
|
|
128
|
+
});
|
|
129
|
+
this.client = client;
|
|
130
|
+
return client;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Run a statement the user wrote.
|
|
134
|
+
*
|
|
135
|
+
* The session already carries `default_transaction_read_only = on`, and for
|
|
136
|
+
* everything else in this adapter that is enough, because every statement is
|
|
137
|
+
* one this file built. It is **not** enough here: `SET
|
|
138
|
+
* default_transaction_read_only = off` is itself a statement, and a user who
|
|
139
|
+
* typed it followed by a `DELETE` would get both.
|
|
140
|
+
*
|
|
141
|
+
* `BEGIN READ ONLY` is the version that holds. A transaction opened read-only
|
|
142
|
+
* cannot be promoted from inside it — Postgres refuses `SET TRANSACTION READ
|
|
143
|
+
* WRITE` once the transaction has begun — so the guarantee stops depending on
|
|
144
|
+
* what the statement does or does not contain. The `ROLLBACK` afterwards also
|
|
145
|
+
* discards any session state the statement set, so one run cannot change what
|
|
146
|
+
* the next one means.
|
|
147
|
+
*
|
|
148
|
+
* The rollback runs in a `finally`: a statement that errors has still opened
|
|
149
|
+
* a transaction, and leaving it open would leave the connection in an aborted
|
|
150
|
+
* state where every later query fails with "current transaction is aborted".
|
|
151
|
+
*
|
|
152
|
+
* `write: true` is the other half, and it is a different request rather than
|
|
153
|
+
* a relaxation of this one: a separate handle, no READ ONLY transaction, and
|
|
154
|
+
* a decision made further up about whether this connection may write at all.
|
|
155
|
+
* The two paths share the statement text and nothing else.
|
|
156
|
+
*/
|
|
157
|
+
async runSql(request) {
|
|
158
|
+
const stacked = refuseStacked(request.text);
|
|
159
|
+
if (stacked)
|
|
160
|
+
throw new Error(stacked);
|
|
161
|
+
const limit = clampLimit(request.limit);
|
|
162
|
+
/* A write is never wrapped in a bounding subquery: `UPDATE … RETURNING`
|
|
163
|
+
inside `SELECT * FROM (…)` is a different statement, and one Postgres
|
|
164
|
+
refuses outright. The caller asked for this text; it runs as typed. */
|
|
165
|
+
const bounded = !request.write && looksWrappable(request.text);
|
|
166
|
+
const text = bounded ? boundStatement(request.text, limit) : trimStatement(request.text);
|
|
167
|
+
if (request.write) {
|
|
168
|
+
/* On the write handle, and outside the READ ONLY transaction every other
|
|
169
|
+
statement here gets — which is the whole difference. Whether this is
|
|
170
|
+
allowed at all was decided before the request arrived: the connection
|
|
171
|
+
has to be marked writable in the config, and that check does not live
|
|
172
|
+
in the adapter.
|
|
173
|
+
|
|
174
|
+
No wrapper transaction of our own. One statement is already atomic in
|
|
175
|
+
Postgres, and a BEGIN/COMMIT around it would only add a way to leave a
|
|
176
|
+
transaction open on a connection that outlives the request. */
|
|
177
|
+
const write = await this.writeHandle();
|
|
178
|
+
const started = performance.now();
|
|
179
|
+
const result = await write.query(text, []);
|
|
180
|
+
return {
|
|
181
|
+
/* A write that returns rows — `RETURNING` — is worth showing, and one
|
|
182
|
+
that does not returns an empty grid rather than a fabricated one. */
|
|
183
|
+
columns: dedupe(result.fields?.map((f) => f.name) ?? []),
|
|
184
|
+
rows: (result.rows ?? []).map((row) => Object.values(toJsonSafe(row)).map(valueSafe)),
|
|
185
|
+
ms: Math.round(performance.now() - started),
|
|
186
|
+
truncated: false,
|
|
187
|
+
bounded: false,
|
|
188
|
+
affected: Number(result.rowCount ?? 0),
|
|
189
|
+
sql: { text, params: [] },
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const db = await this.conn();
|
|
193
|
+
const started = performance.now();
|
|
194
|
+
/* SET LOCAL dies with the transaction, so the session's 30s cap is
|
|
195
|
+
untouched — and the floor stops a caller asking for a timeout so low
|
|
196
|
+
that even the fast path loses races to it. */
|
|
197
|
+
const cap = request.timeoutMs === undefined
|
|
198
|
+
? undefined
|
|
199
|
+
: Math.max(100, Math.min(30_000, Math.floor(request.timeoutMs)));
|
|
200
|
+
await db.query('BEGIN READ ONLY');
|
|
201
|
+
try {
|
|
202
|
+
if (cap !== undefined)
|
|
203
|
+
await db.query(`SET LOCAL statement_timeout = ${cap}`);
|
|
204
|
+
/* `rowMode: 'array'` so two output columns of the same name keep both
|
|
205
|
+
values; the default object rows would let the second overwrite the
|
|
206
|
+
first. `values: []` keeps this on the extended protocol, which carries
|
|
207
|
+
exactly one statement — a second line of defence behind the stacking
|
|
208
|
+
check above. */
|
|
209
|
+
let wrapped = bounded;
|
|
210
|
+
const result = await db
|
|
211
|
+
.query({ text, values: [], rowMode: 'array' })
|
|
212
|
+
.catch(async (err) => {
|
|
213
|
+
/* The wrap is an optimisation that is allowed to fail — not every
|
|
214
|
+
legal statement survives being made a derived table. Run as
|
|
215
|
+
written and reported as unbounded. The transaction has to be
|
|
216
|
+
restarted: a failed statement aborts it, and every later query in
|
|
217
|
+
it would fail with "current transaction is aborted". */
|
|
218
|
+
if (!bounded)
|
|
219
|
+
throw err;
|
|
220
|
+
wrapped = false;
|
|
221
|
+
await db.query('ROLLBACK');
|
|
222
|
+
await db.query('BEGIN READ ONLY');
|
|
223
|
+
if (cap !== undefined)
|
|
224
|
+
await db.query(`SET LOCAL statement_timeout = ${cap}`);
|
|
225
|
+
return db.query({ text: trimStatement(request.text), values: [], rowMode: 'array' });
|
|
226
|
+
});
|
|
227
|
+
const truncated = result.rows.length > limit;
|
|
228
|
+
return {
|
|
229
|
+
columns: dedupe(result.fields.map((f) => f.name)),
|
|
230
|
+
rows: (truncated ? result.rows.slice(0, limit) : result.rows).map((row) => row.map(valueSafe)),
|
|
231
|
+
ms: Math.round(performance.now() - started),
|
|
232
|
+
truncated,
|
|
233
|
+
bounded: wrapped,
|
|
234
|
+
sql: { text: wrapped ? text : trimStatement(request.text), params: [] },
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
await db.query('ROLLBACK').catch(() => {
|
|
239
|
+
/* The connection is already unusable if this fails; the original error
|
|
240
|
+
is the one worth reporting. */
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* EXPLAIN, plain — never the analysing variant that executes to time.
|
|
246
|
+
*
|
|
247
|
+
* `runSql`'s bounding wrap is precisely wrong here: a statement made a
|
|
248
|
+
* derived table is a different statement, with a different plan. The text
|
|
249
|
+
* goes to the planner as written, inside the same READ ONLY transaction
|
|
250
|
+
* every user statement gets — so even a smuggled write is only ever
|
|
251
|
+
* planned, and an execution attempt is the transaction's to refuse, not a
|
|
252
|
+
* string check's.
|
|
253
|
+
*/
|
|
254
|
+
async explainSql(request) {
|
|
255
|
+
const stacked = refuseStacked(request.text);
|
|
256
|
+
if (stacked)
|
|
257
|
+
throw new Error(stacked);
|
|
258
|
+
const db = await this.conn();
|
|
259
|
+
const started = performance.now();
|
|
260
|
+
await db.query('BEGIN READ ONLY');
|
|
261
|
+
try {
|
|
262
|
+
const result = await db.query({
|
|
263
|
+
text: `EXPLAIN ${trimStatement(request.text)}`,
|
|
264
|
+
values: (request.params ?? []),
|
|
265
|
+
rowMode: 'array',
|
|
266
|
+
});
|
|
267
|
+
return {
|
|
268
|
+
plan: result.rows.map((r) => String(r[0])),
|
|
269
|
+
ms: Math.round(performance.now() - started),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
await db.query('ROLLBACK').catch(() => { });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The planner's own statistics, read rather than computed.
|
|
278
|
+
*
|
|
279
|
+
* `pg_stats` already holds the null share, the distinct estimate, the most
|
|
280
|
+
* common values with their frequencies, and the histogram — ANALYZE paid
|
|
281
|
+
* for them, reading them touches no user table. The numbers are
|
|
282
|
+
* sample-based and stale until the next ANALYZE, which is what
|
|
283
|
+
* `source: 'statistics'` tells the caller; a column ANALYZE has never
|
|
284
|
+
* visited simply has no numbers, which beats invented ones.
|
|
285
|
+
*/
|
|
286
|
+
async profile(table) {
|
|
287
|
+
const { schema, name } = await this.assertTable(table);
|
|
288
|
+
const db = await this.conn();
|
|
289
|
+
const started = performance.now();
|
|
290
|
+
const rel = await db.query(`SELECT CASE WHEN c.reltuples < 0 THEN NULL ELSE c.reltuples END AS approx_rows
|
|
291
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
292
|
+
WHERE n.nspname = $1 AND c.relname = $2`, [schema, name]);
|
|
293
|
+
const approx = rel.rows[0]?.approx_rows;
|
|
294
|
+
const rows = approx == null ? undefined : Math.round(Number(approx));
|
|
295
|
+
/* Every live column, with its stats row where ANALYZE has made one.
|
|
296
|
+
`most_common_vals` and `histogram_bounds` are `anyarray`, which the
|
|
297
|
+
driver has no parser for; the ::text::text[] two-step turns them into
|
|
298
|
+
the text array it can parse. */
|
|
299
|
+
const stats = await db.query(`SELECT a.attname::text AS column,
|
|
300
|
+
format_type(a.atttypid, a.atttypmod) AS type,
|
|
301
|
+
s.null_frac,
|
|
302
|
+
s.n_distinct,
|
|
303
|
+
s.most_common_vals::text::text[] AS common_values,
|
|
304
|
+
s.most_common_freqs AS common_freqs,
|
|
305
|
+
s.histogram_bounds::text::text[] AS bounds
|
|
306
|
+
FROM pg_attribute a
|
|
307
|
+
JOIN pg_class c ON c.oid = a.attrelid
|
|
308
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
309
|
+
LEFT JOIN pg_stats s ON s.schemaname = n.nspname AND s.tablename = c.relname AND s.attname = a.attname
|
|
310
|
+
WHERE n.nspname = $1 AND c.relname = $2 AND a.attnum > 0 AND NOT a.attisdropped
|
|
311
|
+
ORDER BY a.attnum`, [schema, name]);
|
|
312
|
+
const columns = stats.rows.map((r) => {
|
|
313
|
+
/* Negative n_distinct is a ratio — "-0.3 of the rows are distinct" —
|
|
314
|
+
which only converts when a row estimate exists to multiply by. */
|
|
315
|
+
const distinct = r.n_distinct == null ? undefined
|
|
316
|
+
: Number(r.n_distinct) >= 0 ? Math.round(Number(r.n_distinct))
|
|
317
|
+
: rows === undefined ? undefined
|
|
318
|
+
: Math.round(-Number(r.n_distinct) * rows);
|
|
319
|
+
const common = r.common_values?.length && r.common_freqs?.length
|
|
320
|
+
? r.common_values.map((v, i) => ({ value: v, frac: Number(r.common_freqs?.[i] ?? 0) }))
|
|
321
|
+
: undefined;
|
|
322
|
+
return {
|
|
323
|
+
column: r.column,
|
|
324
|
+
type: r.type,
|
|
325
|
+
nullFrac: r.null_frac == null ? undefined : Number(r.null_frac),
|
|
326
|
+
distinct,
|
|
327
|
+
/* The histogram's outer edges, as text from the sample. A column too
|
|
328
|
+
uniform to earn a histogram gets no min/max — its common values
|
|
329
|
+
sorted lexically would call 9 larger than 10. */
|
|
330
|
+
min: r.bounds?.length ? r.bounds[0] : undefined,
|
|
331
|
+
max: r.bounds?.length ? r.bounds[r.bounds.length - 1] : undefined,
|
|
332
|
+
common,
|
|
333
|
+
};
|
|
334
|
+
});
|
|
335
|
+
return { table, rows, columns, source: 'statistics', ms: Math.round(performance.now() - started) };
|
|
336
|
+
}
|
|
337
|
+
async introspect() {
|
|
338
|
+
const db = await this.conn();
|
|
339
|
+
const filterSchemas = this.schemas.length
|
|
340
|
+
? `AND n.nspname = ANY($1)`
|
|
341
|
+
: `AND n.nspname <> ALL($1)`;
|
|
342
|
+
const schemaParam = this.schemas.length ? this.schemas : SYSTEM_SCHEMAS;
|
|
343
|
+
/* 1. Relations, with the planner's row estimate.
|
|
344
|
+
`reltuples` is an estimate, not a count, and is labelled as such in
|
|
345
|
+
the UI. An exact count on a large table is a sequential scan, which
|
|
346
|
+
is a strange thing for a schema browser to do on connect. */
|
|
347
|
+
const rels = await db.query(`SELECT n.nspname AS table_schema,
|
|
348
|
+
c.relname AS table_name,
|
|
349
|
+
c.relkind AS kind,
|
|
350
|
+
CASE WHEN c.reltuples < 0 THEN 0 ELSE c.reltuples END AS approx_rows,
|
|
351
|
+
obj_description(c.oid) AS comment
|
|
352
|
+
FROM pg_class c
|
|
353
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
354
|
+
WHERE c.relkind IN ('r','v','m','p','f')
|
|
355
|
+
${filterSchemas}
|
|
356
|
+
ORDER BY n.nspname, c.relname`, [schemaParam]);
|
|
357
|
+
const relRows = rels.rows;
|
|
358
|
+
const wanted = new Set(relRows.map((r) => `${r.table_schema}.${r.table_name}`));
|
|
359
|
+
/* 2. Columns, in one pass over information_schema. */
|
|
360
|
+
const cols = await db.query(
|
|
361
|
+
/* `col_description` joined on rather than left out.
|
|
362
|
+
|
|
363
|
+
A column comment is the table's own documentation — the one place a
|
|
364
|
+
schema explains itself — and the MySQL adapter has always read them
|
|
365
|
+
while this one did not, so the same database described in two engines
|
|
366
|
+
gave a different amount of help. It lives in `pg_description` rather
|
|
367
|
+
than `information_schema`, which is why it needs the join. */
|
|
368
|
+
`SELECT c.table_schema, c.table_name, c.column_name, c.data_type,
|
|
369
|
+
c.is_nullable, c.column_default, c.ordinal_position,
|
|
370
|
+
col_description(pc.oid, c.ordinal_position::int) AS comment,
|
|
371
|
+
/* Cast to text before aggregating: enumlabel has type "name",
|
|
372
|
+
and the driver hands a name[] back as the literal string
|
|
373
|
+
'{open,held,closed}' rather than an array — the same value,
|
|
374
|
+
typed as something no caller can use. */
|
|
375
|
+
(SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder)
|
|
376
|
+
FROM pg_enum e
|
|
377
|
+
JOIN pg_type t ON t.oid = e.enumtypid
|
|
378
|
+
JOIN pg_namespace tn ON tn.oid = t.typnamespace
|
|
379
|
+
WHERE t.typname = c.udt_name AND tn.nspname = c.udt_schema) AS enum_labels
|
|
380
|
+
FROM information_schema.columns c
|
|
381
|
+
LEFT JOIN pg_namespace pn ON pn.nspname = c.table_schema
|
|
382
|
+
LEFT JOIN pg_class pc ON pc.relname = c.table_name AND pc.relnamespace = pn.oid
|
|
383
|
+
WHERE c.table_schema ${this.schemas.length ? '= ANY($1)' : '<> ALL($1)'}
|
|
384
|
+
ORDER BY c.table_schema, c.table_name, c.ordinal_position`, [schemaParam]);
|
|
385
|
+
/* Checks, for the other half of the vocabulary. A `status text CHECK
|
|
386
|
+
(status IN (...))` is far commoner in the wild than a real enum type,
|
|
387
|
+
and to anything reading the catalog it is an untyped text column unless
|
|
388
|
+
the constraint is read too. Postgres stores what it rewrote rather than
|
|
389
|
+
what was written, which `checkVocabulary` knows about. */
|
|
390
|
+
const checks = await db.query(`SELECT n.nspname AS table_schema, cl.relname AS table_name,
|
|
391
|
+
pg_get_constraintdef(co.oid) AS expression
|
|
392
|
+
FROM pg_constraint co
|
|
393
|
+
JOIN pg_class cl ON cl.oid = co.conrelid
|
|
394
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
395
|
+
WHERE co.contype = 'c'
|
|
396
|
+
AND n.nspname ${this.schemas.length ? '= ANY($1)' : '<> ALL($1)'}`, [schemaParam]);
|
|
397
|
+
const checksByTable = new Map();
|
|
398
|
+
for (const row of checks.rows) {
|
|
399
|
+
const id = `${row.table_schema}.${row.table_name}`;
|
|
400
|
+
const list = checksByTable.get(id) ?? [];
|
|
401
|
+
/* `pg_get_constraintdef` returns `CHECK ((expr))`; the parser wants the
|
|
402
|
+
expression, and `checksIn` is the same unwrapping SQLite needs. */
|
|
403
|
+
list.push(...checksIn(row.expression));
|
|
404
|
+
checksByTable.set(id, list);
|
|
405
|
+
}
|
|
406
|
+
/* 3. Keys. `unnest ... WITH ORDINALITY` keeps composite key columns in
|
|
407
|
+
their declared order, which a plain array_agg over conkey would not
|
|
408
|
+
guarantee and which matters for a two-column join.
|
|
409
|
+
|
|
410
|
+
`attname` is of type `name`, not `text`, so aggregating it yields
|
|
411
|
+
`name[]` — an array OID the driver has no parser for, which arrives
|
|
412
|
+
as the raw string "{a,b}" rather than an array. The ::text casts are
|
|
413
|
+
what make these come back as JavaScript arrays. */
|
|
414
|
+
const keys = await db.query(`SELECT con.conname AS constraint_name,
|
|
415
|
+
fn.nspname AS from_schema,
|
|
416
|
+
f.relname AS from_table,
|
|
417
|
+
(SELECT array_agg(a.attname::text ORDER BY k.ord)
|
|
418
|
+
FROM unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord)
|
|
419
|
+
JOIN pg_attribute a ON a.attrelid = f.oid AND a.attnum = k.attnum) AS from_columns,
|
|
420
|
+
tn.nspname AS to_schema,
|
|
421
|
+
t.relname AS to_table,
|
|
422
|
+
(SELECT array_agg(a.attname::text ORDER BY k.ord)
|
|
423
|
+
FROM unnest(con.confkey) WITH ORDINALITY AS k(attnum, ord)
|
|
424
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum) AS to_columns,
|
|
425
|
+
con.confdeltype AS delete_rule
|
|
426
|
+
FROM pg_constraint con
|
|
427
|
+
JOIN pg_class f ON f.oid = con.conrelid
|
|
428
|
+
JOIN pg_namespace fn ON fn.oid = f.relnamespace
|
|
429
|
+
JOIN pg_class t ON t.oid = con.confrelid
|
|
430
|
+
JOIN pg_namespace tn ON tn.oid = t.relnamespace
|
|
431
|
+
WHERE con.contype = 'f'`);
|
|
432
|
+
const pks = await db.query(`SELECT n.nspname AS table_schema,
|
|
433
|
+
c.relname AS table_name,
|
|
434
|
+
(SELECT array_agg(a.attname::text ORDER BY k.ord)
|
|
435
|
+
FROM unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord)
|
|
436
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = k.attnum) AS columns
|
|
437
|
+
FROM pg_constraint con
|
|
438
|
+
JOIN pg_class c ON c.oid = con.conrelid
|
|
439
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
440
|
+
WHERE con.contype = 'p'`);
|
|
441
|
+
const pkByTable = new Map();
|
|
442
|
+
for (const row of pks.rows) {
|
|
443
|
+
pkByTable.set(`${row.table_schema}.${row.table_name}`, row.columns ?? []);
|
|
444
|
+
}
|
|
445
|
+
/* Only the leading column of each index.
|
|
446
|
+
|
|
447
|
+
`indkey[0]` is the first key column, and it is the only one an ORDER BY
|
|
448
|
+
can be satisfied by — an index on (customer_id, opened_at) makes
|
|
449
|
+
`ORDER BY customer_id` free and `ORDER BY opened_at` a full sort. An
|
|
450
|
+
expression index has attnum 0 and joins to nothing, which drops it, as
|
|
451
|
+
it should: there is no bare column to sort by. */
|
|
452
|
+
const indexes = await db.query(`SELECT n.nspname AS table_schema,
|
|
453
|
+
c.relname AS table_name,
|
|
454
|
+
a.attname::text AS column_name
|
|
455
|
+
FROM pg_index i
|
|
456
|
+
JOIN pg_class c ON c.oid = i.indrelid
|
|
457
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
458
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = i.indkey[0]
|
|
459
|
+
WHERE i.indisvalid`);
|
|
460
|
+
const indexedByTable = new Map();
|
|
461
|
+
for (const row of indexes.rows) {
|
|
462
|
+
const id = `${row.table_schema}.${row.table_name}`;
|
|
463
|
+
const set = indexedByTable.get(id) ?? new Set();
|
|
464
|
+
set.add(row.column_name);
|
|
465
|
+
indexedByTable.set(id, set);
|
|
466
|
+
}
|
|
467
|
+
const foreignKeys = [];
|
|
468
|
+
/* A column-level reference map, so the table view can show "this column
|
|
469
|
+
points there" without searching the whole key list per cell. */
|
|
470
|
+
const refByColumn = new Map();
|
|
471
|
+
for (const row of keys.rows) {
|
|
472
|
+
const from = `${row.from_schema}.${row.from_table}`;
|
|
473
|
+
const to = `${row.to_schema}.${row.to_table}`;
|
|
474
|
+
// Keys can point out of the browsed schema set; those edges lead
|
|
475
|
+
// nowhere the user can click, so they are dropped rather than shown broken.
|
|
476
|
+
if (!wanted.has(from) || !wanted.has(to))
|
|
477
|
+
continue;
|
|
478
|
+
foreignKeys.push({
|
|
479
|
+
name: row.constraint_name,
|
|
480
|
+
from: { table: from, columns: row.from_columns ?? [] },
|
|
481
|
+
to: { table: to, columns: row.to_columns ?? [] },
|
|
482
|
+
onDelete: deleteRuleOf(row.delete_rule),
|
|
483
|
+
});
|
|
484
|
+
(row.from_columns ?? []).forEach((col, i) => {
|
|
485
|
+
refByColumn.set(`${from}.${col}`, {
|
|
486
|
+
table: to,
|
|
487
|
+
column: row.to_columns?.[i] ?? row.to_columns?.[0] ?? '',
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
const columnsByTable = new Map();
|
|
492
|
+
for (const row of cols.rows) {
|
|
493
|
+
const id = `${row.table_schema}.${row.table_name}`;
|
|
494
|
+
if (!wanted.has(id))
|
|
495
|
+
continue;
|
|
496
|
+
const list = columnsByTable.get(id) ?? [];
|
|
497
|
+
list.push(row);
|
|
498
|
+
columnsByTable.set(id, list);
|
|
499
|
+
}
|
|
500
|
+
const tables = relRows.map((r) => {
|
|
501
|
+
const id = `${r.table_schema}.${r.table_name}`;
|
|
502
|
+
const pk = new Set(pkByTable.get(id) ?? []);
|
|
503
|
+
const tableChecks = checksByTable.get(id) ?? [];
|
|
504
|
+
const columns = (columnsByTable.get(id) ?? []).map((c) => ({
|
|
505
|
+
name: c.column_name,
|
|
506
|
+
type: c.data_type,
|
|
507
|
+
nullable: c.is_nullable === 'YES',
|
|
508
|
+
primaryKey: pk.has(c.column_name),
|
|
509
|
+
references: refByColumn.get(`${id}.${c.column_name}`),
|
|
510
|
+
default: c.column_default ?? undefined,
|
|
511
|
+
comment: c.comment ?? undefined,
|
|
512
|
+
/* The type wins where there is one: an enum's labels are the whole
|
|
513
|
+
truth, while a check is one rule among however many the table has. */
|
|
514
|
+
allowed: c.enum_labels?.length
|
|
515
|
+
? c.enum_labels
|
|
516
|
+
: firstVocabulary(tableChecks, c.column_name),
|
|
517
|
+
}));
|
|
518
|
+
return {
|
|
519
|
+
name: r.table_name,
|
|
520
|
+
schema: r.table_schema,
|
|
521
|
+
id,
|
|
522
|
+
columns,
|
|
523
|
+
approxRows: Math.max(0, Math.round(Number(r.approx_rows) || 0)),
|
|
524
|
+
comment: r.comment ?? undefined,
|
|
525
|
+
// Views and materialised views are read-only; foreign tables are
|
|
526
|
+
// remote. All three are worth flagging rather than silently listing.
|
|
527
|
+
isView: r.kind === 'v' || r.kind === 'm' || r.kind === 'f',
|
|
528
|
+
indexed: [...(indexedByTable.get(id) ?? [])],
|
|
529
|
+
};
|
|
530
|
+
});
|
|
531
|
+
return {
|
|
532
|
+
label: this.label,
|
|
533
|
+
dialect: this.dialect,
|
|
534
|
+
tables,
|
|
535
|
+
foreignKeys,
|
|
536
|
+
readAt: new Date().toISOString(),
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
async query(request) {
|
|
540
|
+
const db = await this.conn();
|
|
541
|
+
const { schema, name } = await this.assertTable(request.table);
|
|
542
|
+
const qualified = `${POSTGRES_STYLE.quote(schema)}.${POSTGRES_STYLE.quote(name)}`;
|
|
543
|
+
const where = buildWhere(request.filter, POSTGRES_STYLE);
|
|
544
|
+
const cols = request.columns?.length
|
|
545
|
+
? request.columns.map((c) => POSTGRES_STYLE.quote(c)).join(', ')
|
|
546
|
+
: '*';
|
|
547
|
+
const order = request.orderBy?.length
|
|
548
|
+
? ' ORDER BY ' +
|
|
549
|
+
request.orderBy
|
|
550
|
+
.map((o) => `${POSTGRES_STYLE.quote(o.column)} ${o.direction === 'desc' ? 'DESC' : 'ASC'}`)
|
|
551
|
+
.join(', ')
|
|
552
|
+
: '';
|
|
553
|
+
/* Every read is bounded. There is no code path that reaches the database
|
|
554
|
+
without a LIMIT, including one that forgot to ask for one. */
|
|
555
|
+
const limit = clampLimit(request.limit);
|
|
556
|
+
/* LIMIT and OFFSET continue the placeholder numbering the WHERE clause
|
|
557
|
+
started, which is the one thing Postgres needs that SQLite does not. */
|
|
558
|
+
const n = where.params.length;
|
|
559
|
+
const text = `SELECT ${cols} FROM ${qualified}${where.text}${order} LIMIT $${n + 1} OFFSET $${n + 2}`;
|
|
560
|
+
const params = [...where.params, limit, clampOffset(request.offset)];
|
|
561
|
+
const started = performance.now();
|
|
562
|
+
const result = await db.query(text, params);
|
|
563
|
+
const ms = Math.round(performance.now() - started);
|
|
564
|
+
return {
|
|
565
|
+
// Taking columns from `fields` rather than the first row means an empty
|
|
566
|
+
// result still renders its headers instead of collapsing to nothing.
|
|
567
|
+
columns: result.fields.map((f) => f.name),
|
|
568
|
+
rows: result.rows.map(toJsonSafe),
|
|
569
|
+
ms,
|
|
570
|
+
sql: { text, params },
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Run a compiled view.
|
|
575
|
+
*
|
|
576
|
+
* Note what is *not* here: any check that the tables named in the view
|
|
577
|
+
* exist. `compileView` resolves the base table and every hop against the
|
|
578
|
+
* introspected catalog and refuses anything it cannot find, so the only
|
|
579
|
+
* identifiers that reach the quoting step are ones the catalog produced.
|
|
580
|
+
* The user's contribution — the values — never leaves the parameter array.
|
|
581
|
+
*
|
|
582
|
+
* The dialect difference this exercises is placeholder numbering:
|
|
583
|
+
* `POSTGRES_STYLE` counts $1..$n across the whole statement, and the joins
|
|
584
|
+
* sitting between the SELECT list and the WHERE clause are exactly what
|
|
585
|
+
* makes an off-by-one here easy and invisible.
|
|
586
|
+
*/
|
|
587
|
+
async runView(view, offset = 0) {
|
|
588
|
+
const db = await this.conn();
|
|
589
|
+
const schema = await this.schema();
|
|
590
|
+
const compiled = compileView(schema, view, POSTGRES_STYLE, offset);
|
|
591
|
+
const started = performance.now();
|
|
592
|
+
const result = await db.query(compiled.text, compiled.params);
|
|
593
|
+
const ms = Math.round(performance.now() - started);
|
|
594
|
+
return {
|
|
595
|
+
columns: compiled.columns.map((c) => c.name),
|
|
596
|
+
rows: result.rows.map(toJsonSafe),
|
|
597
|
+
ms,
|
|
598
|
+
sql: { text: compiled.text, params: compiled.params },
|
|
599
|
+
resolved: compiled.columns,
|
|
600
|
+
joins: compiled.joins,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
async runBreakdown(breakdown, offset = 0) {
|
|
604
|
+
const db = await this.conn();
|
|
605
|
+
const compiled = compileBreakdown(await this.schema(), breakdown, POSTGRES_STYLE, offset);
|
|
606
|
+
const started = performance.now();
|
|
607
|
+
const result = await db.query(compiled.text, compiled.params);
|
|
608
|
+
return {
|
|
609
|
+
columns: compiled.columns.map((c) => c.name),
|
|
610
|
+
rows: result.rows.map(toJsonSafe),
|
|
611
|
+
ms: Math.round(performance.now() - started),
|
|
612
|
+
sql: { text: compiled.text, params: compiled.params },
|
|
613
|
+
resolved: compiled.columns,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
async countBreakdown(breakdown) {
|
|
617
|
+
const db = await this.conn();
|
|
618
|
+
const compiled = compileBreakdownCount(await this.schema(), breakdown, POSTGRES_STYLE);
|
|
619
|
+
const result = await db.query(compiled.text, compiled.params);
|
|
620
|
+
return Number(result.rows[0]?.n ?? 0);
|
|
621
|
+
}
|
|
622
|
+
async countView(view) {
|
|
623
|
+
const db = await this.conn();
|
|
624
|
+
const compiled = compileViewCount(await this.schema(), view, POSTGRES_STYLE);
|
|
625
|
+
const result = await db.query(compiled.text, compiled.params);
|
|
626
|
+
return Number(result.rows[0]?.n ?? 0);
|
|
627
|
+
}
|
|
628
|
+
/* Introspection here is three catalog queries; a view preview runs on every
|
|
629
|
+
keystroke. Caching it is the difference between a live preview and a
|
|
630
|
+
stutter. The registry refreshes by discarding the adapter, so a stale
|
|
631
|
+
cache cannot outlive an explicit reload. */
|
|
632
|
+
cachedSchema = null;
|
|
633
|
+
async schema() {
|
|
634
|
+
if (!this.cachedSchema)
|
|
635
|
+
this.cachedSchema = await this.introspect();
|
|
636
|
+
return this.cachedSchema;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* The planner's row estimate, straight from `pg_class`.
|
|
640
|
+
*
|
|
641
|
+
* `reltuples` is what the planner itself uses to decide how to run a query,
|
|
642
|
+
* so it is the number Postgres is already trusting. It is -1 on a table that
|
|
643
|
+
* has never been analysed, which is a "do not know" rather than a zero —
|
|
644
|
+
* returning undefined there gets an exact count instead of confidently
|
|
645
|
+
* reporting an empty table that is not empty.
|
|
646
|
+
*/
|
|
647
|
+
async estimateCount(table) {
|
|
648
|
+
const db = await this.conn();
|
|
649
|
+
const { schema, name } = await this.assertTable(table);
|
|
650
|
+
const result = await db.query(`SELECT c.reltuples AS n
|
|
651
|
+
FROM pg_class c JOIN pg_namespace ns ON ns.oid = c.relnamespace
|
|
652
|
+
WHERE ns.nspname = $1 AND c.relname = $2`, [schema, name]);
|
|
653
|
+
const n = Number(result.rows[0]?.n);
|
|
654
|
+
if (!Number.isFinite(n) || n < 0)
|
|
655
|
+
return undefined;
|
|
656
|
+
return Math.round(n);
|
|
657
|
+
}
|
|
658
|
+
async count(table, filter) {
|
|
659
|
+
const db = await this.conn();
|
|
660
|
+
const { schema, name } = await this.assertTable(table);
|
|
661
|
+
const where = buildWhere(filter, POSTGRES_STYLE);
|
|
662
|
+
const text = `SELECT COUNT(*) AS n FROM ${POSTGRES_STYLE.quote(schema)}.${POSTGRES_STYLE.quote(name)}${where.text}`;
|
|
663
|
+
const result = await db.query(text, where.params);
|
|
664
|
+
return Number(result.rows[0]?.n ?? 0);
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* The write path, on its own handle.
|
|
668
|
+
*
|
|
669
|
+
* `this.client` carries `default_transaction_read_only = on` for the life of
|
|
670
|
+
* the process, so browsing can never write no matter what the UI believes.
|
|
671
|
+
* A second connection — without that setting — is opened the first time an
|
|
672
|
+
* edit is actually applied, and not before: a connection that can write
|
|
673
|
+
* should exist because somebody wrote, not because somebody connected.
|
|
674
|
+
*
|
|
675
|
+
* Every guard below is the SQLite adapter's, one for one. An edit refused on
|
|
676
|
+
* SQLite and allowed on Postgres would mean the safety of a cell editor
|
|
677
|
+
* depended on which database it was pointed at.
|
|
678
|
+
*/
|
|
679
|
+
async writeHandle() {
|
|
680
|
+
if (this.writer)
|
|
681
|
+
return this.writer;
|
|
682
|
+
const specifier = 'pg';
|
|
683
|
+
const pg = (await import(specifier));
|
|
684
|
+
const Client = (pg.Client ?? pg.default?.Client);
|
|
685
|
+
const client = new Client({ connectionString: this.connectionString });
|
|
686
|
+
await client.connect();
|
|
687
|
+
await client.query("SET statement_timeout = '30s'");
|
|
688
|
+
// The same liveness rule as the read handle: a dead client must not be
|
|
689
|
+
// handed out again, and an unheard 'error' event ends the process.
|
|
690
|
+
client.on?.('error', () => {
|
|
691
|
+
if (this.writer === client)
|
|
692
|
+
this.writer = null;
|
|
693
|
+
});
|
|
694
|
+
this.writer = client;
|
|
695
|
+
return client;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* A table's columns as the catalog has them now, not as they were on connect.
|
|
699
|
+
*
|
|
700
|
+
* `information_schema` would do for the names and types, but not for the
|
|
701
|
+
* primary key — it describes a composite one across two more views — and
|
|
702
|
+
* the key is the whole safety argument for a write. `pg_catalog` answers
|
|
703
|
+
* both in one query, and this is the Postgres adapter, so being
|
|
704
|
+
* Postgres-specific costs nothing.
|
|
705
|
+
*
|
|
706
|
+
* Read per write rather than from the cached schema: a column added since
|
|
707
|
+
* the connection opened is a column the editor should be able to write, and
|
|
708
|
+
* one dropped since is one it must not.
|
|
709
|
+
*/
|
|
710
|
+
async columnsOf(schema, name) {
|
|
711
|
+
const db = await this.conn();
|
|
712
|
+
const result = await db.query(`SELECT a.attname AS name,
|
|
713
|
+
format_type(a.atttypid, a.atttypmod) AS type,
|
|
714
|
+
a.attnotnull AS notnull,
|
|
715
|
+
COALESCE(k.is_pk, false) AS is_pk
|
|
716
|
+
FROM pg_attribute a
|
|
717
|
+
JOIN pg_class c ON c.oid = a.attrelid
|
|
718
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
719
|
+
LEFT JOIN LATERAL (
|
|
720
|
+
SELECT true AS is_pk FROM pg_index i
|
|
721
|
+
WHERE i.indrelid = c.oid AND i.indisprimary AND a.attnum = ANY (i.indkey)
|
|
722
|
+
) k ON true
|
|
723
|
+
WHERE n.nspname = $1 AND c.relname = $2
|
|
724
|
+
AND a.attnum > 0 AND NOT a.attisdropped
|
|
725
|
+
ORDER BY a.attnum`, [schema, name]);
|
|
726
|
+
return result.rows;
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Update one row, having proved it is one row.
|
|
730
|
+
*
|
|
731
|
+
* The pre-flight count runs on the *read* handle, so the check that decides
|
|
732
|
+
* whether to write cannot itself write. A key that matches anything other
|
|
733
|
+
* than exactly one row stops here rather than at the UPDATE — the danger in
|
|
734
|
+
* a cell editor was never injection, it is a WHERE broader than its author
|
|
735
|
+
* believed.
|
|
736
|
+
*/
|
|
737
|
+
async update(request) {
|
|
738
|
+
return this.writes.run(() => this.updateNow(request));
|
|
739
|
+
}
|
|
740
|
+
async updateNow(request) {
|
|
741
|
+
const found = await this.assertTable(request.table);
|
|
742
|
+
const id = `${found.schema}.${found.name}`;
|
|
743
|
+
/* Postgres updates through a simple view, and the update lands on the
|
|
744
|
+
table underneath. Refused: what the editor showed and what changed are
|
|
745
|
+
then two different objects. */
|
|
746
|
+
if (found.isView)
|
|
747
|
+
throw new Refusal(`${id} is a view, so no single row can be identified.`);
|
|
748
|
+
const meta = await this.columnsOf(found.schema, found.name);
|
|
749
|
+
const pk = meta.filter((c) => c.is_pk).map((c) => c.name);
|
|
750
|
+
if (!pk.length)
|
|
751
|
+
throw new Refusal(`${id} has no primary key, so no single row can be identified.`);
|
|
752
|
+
const keyColumns = Object.keys(request.key);
|
|
753
|
+
/* The key must be the whole primary key and nothing else. A partial key
|
|
754
|
+
would let a two-column key match a whole group of rows, and extra
|
|
755
|
+
columns would let the caller widen the match. */
|
|
756
|
+
const same = keyColumns.length === pk.length && pk.every((c) => keyColumns.includes(c));
|
|
757
|
+
if (!same)
|
|
758
|
+
throw new Refusal(`Updating ${id} needs exactly its primary key (${pk.join(', ')}).`);
|
|
759
|
+
const entries = Object.entries(request.values ?? {});
|
|
760
|
+
if (!entries.length)
|
|
761
|
+
throw new Refusal('Nothing to update.');
|
|
762
|
+
const applied = {};
|
|
763
|
+
for (const [column, raw] of entries) {
|
|
764
|
+
const declared = meta.find((c) => c.name === column);
|
|
765
|
+
if (!declared)
|
|
766
|
+
throw new Refusal(`${id} has no column called "${column}".`);
|
|
767
|
+
if (pk.includes(column)) {
|
|
768
|
+
// Editing a key in place silently repoints every row that referenced
|
|
769
|
+
// it, or fails on the constraint. Neither belongs behind an editor.
|
|
770
|
+
throw new Refusal(`"${column}" is part of the primary key and cannot be edited here.`);
|
|
771
|
+
}
|
|
772
|
+
/* Coerced against the declared type, because everything typed into a
|
|
773
|
+
text box arrives as a string — and Postgres, unlike SQLite, rejects
|
|
774
|
+
"17901" for a numeric column rather than quietly taking it. */
|
|
775
|
+
applied[column] = coerceForColumn(declared.type || '', raw);
|
|
776
|
+
if (applied[column] === null && declared.notnull) {
|
|
777
|
+
throw new Refusal(`"${column}" is NOT NULL and cannot be emptied.`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
const filter = {
|
|
781
|
+
groups: [pk.map((column) => ({ column, op: '=', value: request.key[column] }))],
|
|
782
|
+
};
|
|
783
|
+
const matches = await this.count(id, filter);
|
|
784
|
+
if (matches !== 1) {
|
|
785
|
+
throw new Refusal(`Refused: that key matches ${matches} rows, not 1. An update is only allowed when it is certain which row it changes.`);
|
|
786
|
+
}
|
|
787
|
+
/* The WHERE is built after the SET, so its placeholders continue the same
|
|
788
|
+
numbering — `$1, $2` for the assignments, `$3…` for the key. Postgres
|
|
789
|
+
numbers parameters rather than taking them in order, which is the one
|
|
790
|
+
place this differs from the other two adapters. */
|
|
791
|
+
const columns = Object.keys(applied);
|
|
792
|
+
const assignments = columns
|
|
793
|
+
.map((c, i) => `${POSTGRES_STYLE.quote(c)} = ${POSTGRES_STYLE.placeholder(i + 1)}`)
|
|
794
|
+
.join(', ');
|
|
795
|
+
const where = buildWhere(filter, POSTGRES_STYLE, columns.length + 1);
|
|
796
|
+
const text = `UPDATE ${qualified(found)} SET ${assignments}${where.text}`;
|
|
797
|
+
const params = [...Object.values(applied), ...where.params];
|
|
798
|
+
const write = await this.writeHandle();
|
|
799
|
+
const result = await write.query(text, params);
|
|
800
|
+
/* Postgres counts rows matched, not rows whose bytes changed, so saving a
|
|
801
|
+
cell without changing its value still reports 1 — the editor would
|
|
802
|
+
otherwise tell the user a successful write failed. */
|
|
803
|
+
return { affected: Number(result.rowCount ?? 0), applied, sql: { text, params } };
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Add one row.
|
|
807
|
+
*
|
|
808
|
+
* `update`'s guards where they apply, with the key rules inverted: a primary
|
|
809
|
+
* key may be supplied (a country code somebody types) or left for the
|
|
810
|
+
* database to assign. What is deliberately *not* pre-checked is
|
|
811
|
+
* completeness — a missing NOT NULL column with no default is the database's
|
|
812
|
+
* refusal to make, and its message names the column better than a paraphrase
|
|
813
|
+
* would.
|
|
814
|
+
*
|
|
815
|
+
* `RETURNING *` rather than a re-read by key: one statement gives back the
|
|
816
|
+
* row as stored, defaults filled and the assigned key included, with no
|
|
817
|
+
* window for another write to land in between.
|
|
818
|
+
*/
|
|
819
|
+
async insert(request) {
|
|
820
|
+
return this.writes.run(() => this.insertNow(request));
|
|
821
|
+
}
|
|
822
|
+
async insertNow(request) {
|
|
823
|
+
const found = await this.assertTable(request.table);
|
|
824
|
+
const id = `${found.schema}.${found.name}`;
|
|
825
|
+
if (found.isView)
|
|
826
|
+
throw new Refusal(`${id} is a view. Insert into the table underneath it.`);
|
|
827
|
+
const meta = await this.columnsOf(found.schema, found.name);
|
|
828
|
+
const entries = Object.entries(request.values ?? {});
|
|
829
|
+
if (!entries.length)
|
|
830
|
+
throw new Refusal('Nothing to insert. Name at least one column.');
|
|
831
|
+
const applied = {};
|
|
832
|
+
for (const [column, raw] of entries) {
|
|
833
|
+
const declared = meta.find((c) => c.name === column);
|
|
834
|
+
if (!declared)
|
|
835
|
+
throw new Refusal(`${id} has no column called "${column}".`);
|
|
836
|
+
applied[column] = coerceForColumn(declared.type || '', raw);
|
|
837
|
+
if (applied[column] === null && declared.notnull) {
|
|
838
|
+
throw new Refusal(`"${column}" is NOT NULL and cannot be null. Leave it out to use its default, if it has one.`);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const columns = Object.keys(applied);
|
|
842
|
+
const text = `INSERT INTO ${qualified(found)} (${columns.map((c) => POSTGRES_STYLE.quote(c)).join(', ')})`
|
|
843
|
+
+ ` VALUES (${columns.map((_, i) => POSTGRES_STYLE.placeholder(i + 1)).join(', ')}) RETURNING *`;
|
|
844
|
+
const params = Object.values(applied);
|
|
845
|
+
const write = await this.writeHandle();
|
|
846
|
+
const result = await write.query(text, params);
|
|
847
|
+
const row = result.rows[0] ? toJsonSafe(result.rows[0]) : undefined;
|
|
848
|
+
const pk = meta.filter((c) => c.is_pk).map((c) => c.name);
|
|
849
|
+
/* Read off the returned row rather than off what was sent: a key the
|
|
850
|
+
database assigned is only knowable from what came back, and a key that
|
|
851
|
+
was supplied comes back identical. */
|
|
852
|
+
const key = {};
|
|
853
|
+
if (row)
|
|
854
|
+
for (const c of pk)
|
|
855
|
+
key[c] = row[c];
|
|
856
|
+
return { key, row, applied, sql: { text, params } };
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Remove one row, having proved it is one row.
|
|
860
|
+
*
|
|
861
|
+
* What is *not* checked here is whether the row should go: what references
|
|
862
|
+
* it, and what the database will do to those references, is answered above
|
|
863
|
+
* this layer where the schema is in hand. A `RESTRICT` that fires is the
|
|
864
|
+
* correct outcome in the engine's own words.
|
|
865
|
+
*/
|
|
866
|
+
async remove(request) {
|
|
867
|
+
return this.writes.run(() => this.removeNow(request));
|
|
868
|
+
}
|
|
869
|
+
async removeNow(request) {
|
|
870
|
+
const found = await this.assertTable(request.table);
|
|
871
|
+
const id = `${found.schema}.${found.name}`;
|
|
872
|
+
if (found.isView)
|
|
873
|
+
throw new Refusal(`${id} is a view, so no single row can be identified.`);
|
|
874
|
+
const meta = await this.columnsOf(found.schema, found.name);
|
|
875
|
+
const pk = meta.filter((c) => c.is_pk).map((c) => c.name);
|
|
876
|
+
if (!pk.length)
|
|
877
|
+
throw new Refusal(`${id} has no primary key, so no single row can be identified.`);
|
|
878
|
+
const keyColumns = Object.keys(request.key);
|
|
879
|
+
const same = keyColumns.length === pk.length && pk.every((c) => keyColumns.includes(c));
|
|
880
|
+
if (!same)
|
|
881
|
+
throw new Refusal(`Deleting from ${id} needs exactly its primary key (${pk.join(', ')}).`);
|
|
882
|
+
const filter = {
|
|
883
|
+
groups: [pk.map((column) => ({ column, op: '=', value: request.key[column] }))],
|
|
884
|
+
};
|
|
885
|
+
const matches = await this.count(id, filter);
|
|
886
|
+
if (matches !== 1) {
|
|
887
|
+
throw new Refusal(`Refused: that key matches ${matches} rows, not 1. A delete is only allowed when it is certain which row it removes.`);
|
|
888
|
+
}
|
|
889
|
+
const where = buildWhere(filter, POSTGRES_STYLE);
|
|
890
|
+
const text = `DELETE FROM ${qualified(found)}${where.text}`;
|
|
891
|
+
const write = await this.writeHandle();
|
|
892
|
+
const result = await write.query(text, where.params);
|
|
893
|
+
return { affected: Number(result.rowCount ?? 0), sql: { text, params: where.params } };
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Several writes as one, or none of them.
|
|
897
|
+
*
|
|
898
|
+
* Plain `BEGIN` / `COMMIT` on the write connection. The callback is handed
|
|
899
|
+
* the same write bodies the public methods call, so every guard — the whole
|
|
900
|
+
* key, the pre-flight count, the type coercion — applies inside a
|
|
901
|
+
* transaction exactly as it does outside one.
|
|
902
|
+
*
|
|
903
|
+
* The pre-flight counts run on the *read* connection, which cannot see this
|
|
904
|
+
* transaction. That is the right answer for a teardown, where the rows were
|
|
905
|
+
* committed long ago, and the wrong one for "delete a row this same
|
|
906
|
+
* transaction inserted" — which nothing asks for and which the rollback
|
|
907
|
+
* would undo anyway.
|
|
908
|
+
*/
|
|
909
|
+
async transaction(run) {
|
|
910
|
+
return this.writes.run(async () => {
|
|
911
|
+
const write = await this.writeHandle();
|
|
912
|
+
await write.query('BEGIN');
|
|
913
|
+
try {
|
|
914
|
+
const out = await run({
|
|
915
|
+
insert: (r) => this.insertNow(r),
|
|
916
|
+
remove: (r) => this.removeNow(r),
|
|
917
|
+
update: (r) => this.updateNow(r),
|
|
918
|
+
});
|
|
919
|
+
await write.query('COMMIT');
|
|
920
|
+
return out;
|
|
921
|
+
}
|
|
922
|
+
catch (err) {
|
|
923
|
+
/* A failed statement aborts a Postgres transaction, so the rollback is
|
|
924
|
+
what makes the connection usable again — not merely tidiness. Its
|
|
925
|
+
own failure is swallowed: the original error is the one worth
|
|
926
|
+
reporting, and a connection this broken will be replaced by the
|
|
927
|
+
liveness listener anyway. */
|
|
928
|
+
await write.query('ROLLBACK').catch(() => { });
|
|
929
|
+
throw err;
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
async close() {
|
|
934
|
+
if (this.client)
|
|
935
|
+
await this.client.end();
|
|
936
|
+
this.client = null;
|
|
937
|
+
if (this.writer)
|
|
938
|
+
await this.writer.end();
|
|
939
|
+
this.writer = null;
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Identifiers cannot be bound, so a table name is checked against the
|
|
943
|
+
* catalog before it is quoted into a statement. The check is a parameterised
|
|
944
|
+
* query, which means the untrusted string never reaches SQL as syntax.
|
|
945
|
+
*/
|
|
946
|
+
async assertTable(id) {
|
|
947
|
+
const dot = id.indexOf('.');
|
|
948
|
+
const schema = dot === -1 ? 'public' : id.slice(0, dot);
|
|
949
|
+
const name = dot === -1 ? id : id.slice(dot + 1);
|
|
950
|
+
const db = await this.conn();
|
|
951
|
+
const found = await db.query(`SELECT c.relkind AS kind FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
952
|
+
WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind IN ('r','v','m','p','f')`, [schema, name]);
|
|
953
|
+
if (!found.rows.length)
|
|
954
|
+
throw new Error(`No table or view called "${id}" in this database.`);
|
|
955
|
+
/* `v` is a view and `m` a materialised one. Both are read-only as far as
|
|
956
|
+
the write path is concerned — the relkind is asked for here rather than
|
|
957
|
+
re-derived from the cached schema, so a view created since the
|
|
958
|
+
connection opened is still recognised as one. */
|
|
959
|
+
const kind = String(found.rows[0]?.kind ?? 'r');
|
|
960
|
+
return { schema, name, isView: kind === 'v' || kind === 'm' };
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
/** `schema.table`, both halves quoted. */
|
|
964
|
+
function qualified(at) {
|
|
965
|
+
return `${POSTGRES_STYLE.quote(at.schema)}.${POSTGRES_STYLE.quote(at.name)}`;
|
|
966
|
+
}
|
|
967
|
+
/** Keep the password out of the label, the logs and the UI. */
|
|
968
|
+
function redact(connectionString) {
|
|
969
|
+
try {
|
|
970
|
+
const url = new URL(connectionString);
|
|
971
|
+
return `${url.hostname}${url.pathname}`;
|
|
972
|
+
}
|
|
973
|
+
catch {
|
|
974
|
+
return 'postgres';
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
/** One value, made safe to send as JSON. See `toJsonSafe` for the row form. */
|
|
978
|
+
function valueSafe(v) {
|
|
979
|
+
if (typeof v === 'bigint')
|
|
980
|
+
return v.toString();
|
|
981
|
+
if (v instanceof Uint8Array)
|
|
982
|
+
return `<${v.byteLength} bytes>`;
|
|
983
|
+
if (v instanceof Date)
|
|
984
|
+
return v.toISOString();
|
|
985
|
+
return v;
|
|
986
|
+
}
|
|
987
|
+
function toJsonSafe(row) {
|
|
988
|
+
const out = {};
|
|
989
|
+
for (const [k, v] of Object.entries(row)) {
|
|
990
|
+
if (typeof v === 'bigint')
|
|
991
|
+
out[k] = v.toString();
|
|
992
|
+
else if (v instanceof Uint8Array)
|
|
993
|
+
out[k] = `<${v.byteLength} bytes>`;
|
|
994
|
+
else if (v instanceof Date)
|
|
995
|
+
out[k] = v.toISOString();
|
|
996
|
+
else
|
|
997
|
+
out[k] = v;
|
|
998
|
+
}
|
|
999
|
+
return out;
|
|
1000
|
+
}
|