@kazurolabs/psql-explorer 0.1.0 → 0.3.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/README.md +229 -18
- package/RELEASES.md +95 -0
- package/dist/cli.js +57 -13
- package/dist/commands/configgen.js +25 -25
- package/dist/commands/explore.js +86 -4
- package/dist/config/load.js +11 -73
- package/dist/config/resolve-environment.js +10 -62
- package/dist/config/resolvers/index.js +1 -1
- package/dist/config/resolvers/prompt.js +2 -2
- package/dist/config/resolvers/vault.js +1 -83
- package/dist/db/store.js +57 -0
- package/dist/environments.js +5 -2
- package/dist/explorer/alter.js +493 -0
- package/dist/explorer/catalog.js +31 -0
- package/dist/explorer/connect-entry.js +29 -18
- package/dist/explorer/cypher-parse.js +478 -0
- package/dist/explorer/cypher.js +963 -0
- package/dist/explorer/ddl.js +502 -0
- package/dist/explorer/dependents.js +222 -0
- package/dist/explorer/design-load.js +254 -0
- package/dist/explorer/design.js +45 -0
- package/dist/explorer/edit.js +108 -0
- package/dist/explorer/explain.js +44 -3
- package/dist/explorer/filter.js +121 -0
- package/dist/explorer/graph.js +679 -0
- package/dist/explorer/gucs.js +31 -0
- package/dist/explorer/profile.js +102 -0
- package/dist/explorer/references.js +95 -0
- package/dist/explorer/rows.js +100 -13
- package/dist/explorer/schema-diff.js +360 -0
- package/dist/explorer/search.js +309 -0
- package/dist/explorer/security.js +152 -0
- package/dist/explorer/seed.js +1376 -0
- package/dist/explorer/session.js +25 -1
- package/dist/explorer/stats.js +135 -0
- package/dist/explorer/timeout.js +13 -0
- package/dist/screens/environments.js +6 -3
- package/dist/screens/explorer.js +0 -3
- package/dist/screens/splash.js +4 -2
- package/dist/web/server.js +655 -22
- package/package.json +4 -1
- package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
- package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
- package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
- package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
- package/web/dist/assets/button-BiFx4Lyw.js +1 -0
- package/web/dist/assets/code-xml-CPEjNFOw.js +1 -0
- package/web/dist/assets/compare-view-COQ3yE76.js +2 -0
- package/web/dist/assets/copy-C2N9tUYJ.js +1 -0
- package/web/dist/assets/cypher-view-BzyL4mNU.js +13 -0
- package/web/dist/assets/design-view-BPm7dSTY.js +9 -0
- package/web/dist/assets/erd-view-DqGDws2_.js +1 -0
- package/web/dist/assets/format-sql-CoddYUOF.js +5 -0
- package/web/dist/assets/graph-force-ChCGXQUt.js +1 -0
- package/web/dist/assets/graph-link-C8P9s_0W.js +1 -0
- package/web/dist/assets/graph-view-BpoUrUCP.js +3 -0
- package/web/dist/assets/index-CpHnjRLv.js +10 -0
- package/web/dist/assets/index-OzJqnJWG.css +2 -0
- package/web/dist/assets/key-round-JiGCgCWS.js +1 -0
- package/web/dist/assets/loader-circle-BxDJOoJO.js +1 -0
- package/web/dist/assets/query-builder-view-CtRexMEX.js +4 -0
- package/web/dist/assets/rotate-ccw-CwdA-J3t.js +1 -0
- package/web/dist/assets/route-7eu-iAqD.js +15 -0
- package/web/dist/assets/seed-view-BIkBTiU6.js +7 -0
- package/web/dist/assets/shield-alert-keJIAND5.js +1 -0
- package/web/dist/assets/sliders-horizontal-DKtL_ql7.js +1 -0
- package/web/dist/assets/sql-tabs-DUSXdYCr.js +2 -0
- package/web/dist/assets/table-Drc_n1gF.js +1 -0
- package/web/dist/assets/triangle-alert-C5nF69rW.js +1 -0
- package/web/dist/assets/zone-CHDOrDBF.js +5 -0
- package/web/dist/index.html +9 -2
- package/web/dist/assets/index-BMLFCtFY.js +0 -17
- package/web/dist/assets/index-CMXJRL9O.css +0 -2
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export class GucError extends Error {
|
|
2
|
+
}
|
|
3
|
+
// Plain (work_mem) or custom (app.tenant_id): words joined by dots.
|
|
4
|
+
const validName = /^[a-z_][a-z0-9_$]*(?:\.[a-z_][a-z0-9_$]*)*$/i;
|
|
5
|
+
// These would lift the read only protection of the environment.
|
|
6
|
+
const readOnlySettings = new Set(['default_transaction_read_only', 'transaction_read_only']);
|
|
7
|
+
/** The settings a request asked for, from `[{name, value}]`; throws a GucError for anything else. */
|
|
8
|
+
export function parseGucs(raw) {
|
|
9
|
+
if (raw === undefined || raw === null)
|
|
10
|
+
return [];
|
|
11
|
+
if (!Array.isArray(raw))
|
|
12
|
+
throw new GucError('settings is a list of {name, value}');
|
|
13
|
+
return raw.map((item) => {
|
|
14
|
+
if (typeof item?.name !== 'string' || typeof item.value !== 'string') {
|
|
15
|
+
throw new GucError('settings is a list of {name, value}');
|
|
16
|
+
}
|
|
17
|
+
const name = item.name.trim().toLowerCase();
|
|
18
|
+
if (!validName.test(name))
|
|
19
|
+
throw new GucError(`"${item.name}" is not a setting name`);
|
|
20
|
+
return { name, value: item.value };
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/** Sets each setting for the rest of the session; the Postgres error says if one is unknown or refused. */
|
|
24
|
+
export async function applyGucs(session, gucs) {
|
|
25
|
+
for (const { name, value } of gucs) {
|
|
26
|
+
if (session.readOnly && readOnlySettings.has(name)) {
|
|
27
|
+
throw new GucError(`${name} cannot be changed: this environment is read only`);
|
|
28
|
+
}
|
|
29
|
+
await session.rows('SELECT pg_catalog.set_config($1, $2, false)', [name, value]);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// What a column holds, worked out from a sample so it stays fast on big tables.
|
|
2
|
+
import { quoteIdentifier } from '../db/identifiers.js';
|
|
3
|
+
import { qualified } from './catalog.js';
|
|
4
|
+
import { withStatementTimeout } from './timeout.js';
|
|
5
|
+
export const SAMPLE_ROWS = 10_000;
|
|
6
|
+
export const PROFILE_TIMEOUT_MS = 8000;
|
|
7
|
+
export const HISTOGRAM_BUCKETS = 10;
|
|
8
|
+
const numberType = /^(smallint|integer|bigint|numeric|decimal|real|double precision)\b/;
|
|
9
|
+
const dateType = /^(date|timestamp)\b/;
|
|
10
|
+
const errorCode = (error) => error?.code ?? '';
|
|
11
|
+
/** The size of a sample that holds about SAMPLE_ROWS rows of a table of `estimated` rows, in percent. */
|
|
12
|
+
export const samplePercent = (estimated) => Math.min(100, Math.max(0.0001, (SAMPLE_ROWS / estimated) * 100));
|
|
13
|
+
const parse = (text) => (text === null ? [] : (JSON.parse(text) ?? []));
|
|
14
|
+
/** Counts by bucket number (from 1), with the empty buckets filled in. */
|
|
15
|
+
const bucketsOf = (found) => {
|
|
16
|
+
const counts = Array.from({ length: HISTOGRAM_BUCKETS }, () => 0);
|
|
17
|
+
for (const { bucket, count } of found)
|
|
18
|
+
counts[Number(bucket) - 1] = Number(count);
|
|
19
|
+
return counts;
|
|
20
|
+
};
|
|
21
|
+
/** Profiles one column of a table or view. Throws if the column does not exist. */
|
|
22
|
+
export async function profileColumn(session, table, column) {
|
|
23
|
+
const [info] = await session.rows(`SELECT pg_catalog.format_type(a.atttypid, a.atttypmod) AS type, c.relkind AS kind,
|
|
24
|
+
c.reltuples::float8 AS tuples
|
|
25
|
+
FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
|
|
26
|
+
WHERE a.attrelid = $1::regclass AND a.attname = $2 AND a.attnum > 0 AND NOT a.attisdropped`, [qualified(table), column]);
|
|
27
|
+
if (!info)
|
|
28
|
+
throw new Error(`no column "${column}" in ${table.schema}.${table.name}`);
|
|
29
|
+
const type = info.type ?? '';
|
|
30
|
+
const tuples = Number(info.tuples ?? -1);
|
|
31
|
+
const estimatedRows = tuples >= 0 ? Math.round(tuples) : null;
|
|
32
|
+
// A table sampled by pages is random; a view, or a table nobody has analyzed, gives its first rows.
|
|
33
|
+
const sampleable = info.kind === 'r' || info.kind === 'm';
|
|
34
|
+
let source;
|
|
35
|
+
let from;
|
|
36
|
+
if (estimatedRows !== null && estimatedRows <= SAMPLE_ROWS * 2) {
|
|
37
|
+
source = 'all';
|
|
38
|
+
from = qualified(table);
|
|
39
|
+
}
|
|
40
|
+
else if (sampleable && estimatedRows !== null) {
|
|
41
|
+
source = 'sample';
|
|
42
|
+
from = `${qualified(table)} TABLESAMPLE SYSTEM (${samplePercent(estimatedRows).toFixed(4)})`;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
source = 'first';
|
|
46
|
+
from = `(SELECT * FROM ${qualified(table)} LIMIT ${SAMPLE_ROWS}) AS head`;
|
|
47
|
+
}
|
|
48
|
+
const name = quoteIdentifier(column);
|
|
49
|
+
// The first try compares the values themselves; types that cannot be compared (json, xml,
|
|
50
|
+
// point...) are compared as text instead, without min, max and a histogram.
|
|
51
|
+
const query = (orderable) => {
|
|
52
|
+
const value = orderable ? 'v' : 'v::text';
|
|
53
|
+
const number = numberType.test(type)
|
|
54
|
+
? 'v::numeric'
|
|
55
|
+
: dateType.test(type)
|
|
56
|
+
? 'extract(epoch from v)::numeric'
|
|
57
|
+
: null;
|
|
58
|
+
const histogram = orderable && number
|
|
59
|
+
? `(SELECT json_agg(json_build_object('bucket', h.bucket, 'count', h.count)) FROM (
|
|
60
|
+
SELECT LEAST(width_bucket(${number}, r.lo, r.hi, ${HISTOGRAM_BUCKETS}), ${HISTOGRAM_BUCKETS}) AS bucket,
|
|
61
|
+
count(*) AS count
|
|
62
|
+
FROM s, (SELECT min(${number}) AS lo, max(${number}) AS hi FROM s WHERE v IS NOT NULL) r
|
|
63
|
+
WHERE v IS NOT NULL AND r.hi > r.lo GROUP BY 1) h)`
|
|
64
|
+
: 'NULL';
|
|
65
|
+
return `WITH s AS MATERIALIZED (SELECT ${name} AS v FROM ${from})
|
|
66
|
+
SELECT (SELECT count(*) FROM s) AS total,
|
|
67
|
+
(SELECT count(*) FROM s WHERE v IS NULL) AS nulls,
|
|
68
|
+
(SELECT count(DISTINCT ${value}) FROM s) AS distinct,
|
|
69
|
+
(SELECT json_agg(t) FROM (
|
|
70
|
+
SELECT ${value}::text AS value, count(*) AS count FROM s WHERE v IS NOT NULL
|
|
71
|
+
GROUP BY ${value} ORDER BY 2 DESC, 1 LIMIT 10) t) AS top,
|
|
72
|
+
${orderable ? '(SELECT min(v)::text FROM s)' : 'NULL'} AS min,
|
|
73
|
+
${orderable ? '(SELECT max(v)::text FROM s)' : 'NULL'} AS max,
|
|
74
|
+
${histogram} AS histogram`;
|
|
75
|
+
};
|
|
76
|
+
const [row] = await withStatementTimeout(session, PROFILE_TIMEOUT_MS, async () => {
|
|
77
|
+
try {
|
|
78
|
+
return await session.rows(query(true));
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
// 42883: no operator or function for the type; 42804: the types do not match.
|
|
82
|
+
if (!['42883', '42804', '22003', '22P02'].includes(errorCode(error)))
|
|
83
|
+
throw error;
|
|
84
|
+
return session.rows(query(false));
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
const total = Number(row?.total ?? 0);
|
|
88
|
+
return {
|
|
89
|
+
source,
|
|
90
|
+
sampleRows: total,
|
|
91
|
+
estimatedRows,
|
|
92
|
+
nullFraction: total === 0 ? 0 : Number(row?.nulls ?? 0) / total,
|
|
93
|
+
distinct: Number(row?.distinct ?? 0),
|
|
94
|
+
top: parse(row?.top ?? null).map((entry) => ({
|
|
95
|
+
value: entry.value,
|
|
96
|
+
count: Number(entry.count),
|
|
97
|
+
})),
|
|
98
|
+
min: row?.min ?? null,
|
|
99
|
+
max: row?.max ?? null,
|
|
100
|
+
histogram: row?.histogram ? bucketsOf(parse(row.histogram)) : null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Foreign keys between tables: where a row points, and which rows point at it.
|
|
2
|
+
import { quoteIdentifier } from '../db/identifiers.js';
|
|
3
|
+
import { qualified } from './catalog.js';
|
|
4
|
+
import { withStatementTimeout } from './timeout.js';
|
|
5
|
+
const KEY_COLUMNS = `c.conname AS name, fn.nspname AS from_schema, fc.relname AS from_table,
|
|
6
|
+
tn.nspname AS to_schema, tc.relname AS to_table,
|
|
7
|
+
(SELECT json_agg(a.attname ORDER BY k.ord)
|
|
8
|
+
FROM unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
|
|
9
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum) AS from_columns,
|
|
10
|
+
(SELECT json_agg(a.attname ORDER BY k.ord)
|
|
11
|
+
FROM unnest(c.confkey) WITH ORDINALITY AS k(attnum, ord)
|
|
12
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.attnum) AS to_columns`;
|
|
13
|
+
const KEY_FROM = `FROM pg_catalog.pg_constraint c
|
|
14
|
+
JOIN pg_catalog.pg_class fc ON fc.oid = c.conrelid
|
|
15
|
+
JOIN pg_catalog.pg_namespace fn ON fn.oid = fc.relnamespace
|
|
16
|
+
JOIN pg_catalog.pg_class tc ON tc.oid = c.confrelid
|
|
17
|
+
JOIN pg_catalog.pg_namespace tn ON tn.oid = tc.relnamespace`;
|
|
18
|
+
const keyOf = (row) => ({
|
|
19
|
+
name: row.name ?? '',
|
|
20
|
+
from: { schema: row.from_schema ?? '', name: row.from_table ?? '' },
|
|
21
|
+
to: { schema: row.to_schema ?? '', name: row.to_table ?? '' },
|
|
22
|
+
fromColumns: JSON.parse(row.from_columns ?? '[]'),
|
|
23
|
+
toColumns: JSON.parse(row.to_columns ?? '[]'),
|
|
24
|
+
});
|
|
25
|
+
/** Names a foreign key across the database (constraint names are only unique within a table). */
|
|
26
|
+
export const foreignKeyId = (key) => JSON.stringify([key.from.schema, key.from.name, key.name]);
|
|
27
|
+
/** Foreign keys of the table, and those of other tables that point at it. */
|
|
28
|
+
export async function listForeignKeys(session, table) {
|
|
29
|
+
const rows = await session.rows(`SELECT ${KEY_COLUMNS}, c.conrelid = $1::regclass AS outgoing
|
|
30
|
+
${KEY_FROM}
|
|
31
|
+
WHERE c.contype = 'f' AND (c.conrelid = $1::regclass OR c.confrelid = $1::regclass)
|
|
32
|
+
ORDER BY fn.nspname, fc.relname, c.conname`, [qualified(table)]);
|
|
33
|
+
const outgoing = [];
|
|
34
|
+
const incoming = [];
|
|
35
|
+
for (const row of rows) {
|
|
36
|
+
const key = keyOf(row);
|
|
37
|
+
// A table that points at itself is both.
|
|
38
|
+
if (row.outgoing === 't' || row.outgoing === 'true')
|
|
39
|
+
outgoing.push(key);
|
|
40
|
+
if (key.to.schema === table.schema && key.to.name === table.name)
|
|
41
|
+
incoming.push(key);
|
|
42
|
+
}
|
|
43
|
+
return { outgoing, incoming };
|
|
44
|
+
}
|
|
45
|
+
/** Every foreign key that starts or ends in the schema. */
|
|
46
|
+
export async function listSchemaForeignKeys(session, schema) {
|
|
47
|
+
const rows = await session.rows(`SELECT ${KEY_COLUMNS}
|
|
48
|
+
${KEY_FROM}
|
|
49
|
+
WHERE c.contype = 'f' AND (fn.nspname = $1 OR tn.nspname = $1)
|
|
50
|
+
ORDER BY fn.nspname, fc.relname, c.conname`, [schema]);
|
|
51
|
+
return rows.map(keyOf);
|
|
52
|
+
}
|
|
53
|
+
export const DEFAULT_COUNT_CAP = 1000;
|
|
54
|
+
export const COUNT_TIMEOUT_MS = 3000;
|
|
55
|
+
const timedOut = (error) => error?.code === '57014';
|
|
56
|
+
/**
|
|
57
|
+
* How many rows of each referencing table point at the row of `table` with these values (by
|
|
58
|
+
* column name). Counting stops at `cap` per table, and a count that takes longer than
|
|
59
|
+
* COUNT_TIMEOUT_MS is given up, so a row in a huge child table cannot hang the page.
|
|
60
|
+
*/
|
|
61
|
+
export async function countReferences(session, table, values, { cap = DEFAULT_COUNT_CAP } = {}) {
|
|
62
|
+
const { incoming } = await listForeignKeys(session, table);
|
|
63
|
+
return withStatementTimeout(session, COUNT_TIMEOUT_MS, async () => {
|
|
64
|
+
const counts = [];
|
|
65
|
+
for (const key of incoming) {
|
|
66
|
+
const parameters = key.toColumns.map((column) => values[column]);
|
|
67
|
+
// A null never matches a foreign key, so a row without the value has no children.
|
|
68
|
+
if (parameters.some((value) => value === undefined || value === null)) {
|
|
69
|
+
counts.push({ key, count: 0, more: false });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const where = key.fromColumns
|
|
73
|
+
.map((column, i) => `${quoteIdentifier(column)} = $${i + 1}`)
|
|
74
|
+
.join(' AND ');
|
|
75
|
+
try {
|
|
76
|
+
const [row] = await session.rows(`SELECT count(*) AS n FROM (SELECT 1 FROM ${qualified(key.from)} WHERE ${where} LIMIT ${cap + 1}) AS limited`, parameters);
|
|
77
|
+
const n = Number(row?.n ?? 0);
|
|
78
|
+
counts.push({ key, count: Math.min(n, cap), more: n > cap });
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
counts.push({
|
|
82
|
+
key,
|
|
83
|
+
count: null,
|
|
84
|
+
more: false,
|
|
85
|
+
error: timedOut(error)
|
|
86
|
+
? `took longer than ${COUNT_TIMEOUT_MS / 1000}s`
|
|
87
|
+
: error instanceof Error
|
|
88
|
+
? error.message
|
|
89
|
+
: String(error),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return counts;
|
|
94
|
+
});
|
|
95
|
+
}
|
package/dist/explorer/rows.js
CHANGED
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
import { quoteIdentifier } from '../db/identifiers.js';
|
|
3
3
|
import { qualified } from './catalog.js';
|
|
4
4
|
export const DEFAULT_PAGE_SIZE = 100;
|
|
5
|
-
export function browseSql(table, { orderBy = [] }) {
|
|
5
|
+
export function browseSql(table, { orderBy = [], where }) {
|
|
6
6
|
const order = orderBy
|
|
7
7
|
.map(({ column, descending }) => `${quoteIdentifier(column)} ${descending ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`)
|
|
8
8
|
.join(', ');
|
|
9
|
-
|
|
9
|
+
const n = where?.params.length ?? 0;
|
|
10
|
+
return `SELECT * FROM ${qualified(table)}${where?.sql ?? ''}${order ? ` ORDER BY ${order}` : ''} LIMIT $${n + 1} OFFSET $${n + 2}`;
|
|
10
11
|
}
|
|
11
12
|
/** One page of a table's rows. */
|
|
12
13
|
export function browseRows(session, table, options) {
|
|
13
|
-
return session.run(browseSql(table, options),
|
|
14
|
-
|
|
15
|
-
});
|
|
14
|
+
return session.run(browseSql(table, options),
|
|
15
|
+
// One more row than wanted, which says whether there is a next page.
|
|
16
|
+
[...(options.where?.params ?? []), options.limit + 1, options.offset ?? 0], { maxRows: options.limit });
|
|
16
17
|
}
|
|
17
18
|
/** The exact number of rows; this reads the whole table, so the statement timeout bounds it. */
|
|
18
19
|
export async function countRows(session, table) {
|
|
@@ -23,17 +24,88 @@ export async function countRows(session, table) {
|
|
|
23
24
|
const readsRows = /^(?:select|with|values|table)\b/i;
|
|
24
25
|
// SQLSTATE 42601 (syntax error) and 0A000 (not supported): the statement cannot be wrapped.
|
|
25
26
|
const cannotWrap = (error) => ['42601', '0A000'].includes(error?.code ?? '');
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
const identifierChar = /[\p{L}\p{N}_$]/u;
|
|
28
|
+
const dollarTag = /\$(?:[\p{L}_][\p{L}\p{N}_]*)?\$/uy;
|
|
29
|
+
/** Where the string, quoted name or dollar-quoted body that opens at `i` ends. */
|
|
30
|
+
function quotedEnd(sql, i) {
|
|
31
|
+
const quote = sql[i];
|
|
32
|
+
if (quote === '$') {
|
|
33
|
+
dollarTag.lastIndex = i;
|
|
34
|
+
const tag = dollarTag.exec(sql)[0];
|
|
35
|
+
const close = sql.indexOf(tag, i + tag.length);
|
|
36
|
+
return close < 0 ? sql.length : close + tag.length;
|
|
37
|
+
}
|
|
38
|
+
// E'...' takes backslash escapes; every string and quoted name doubles its quote.
|
|
39
|
+
const escapes = quote === "'" && /[eE]/.test(sql[i - 1] ?? '') && !identifierChar.test(sql[i - 2] ?? '');
|
|
40
|
+
for (let j = i + 1; j < sql.length; j++) {
|
|
41
|
+
if (escapes && sql[j] === '\\')
|
|
42
|
+
j++;
|
|
43
|
+
else if (sql[j] === quote && sql[j + 1] === quote)
|
|
44
|
+
j++;
|
|
45
|
+
else if (sql[j] === quote)
|
|
46
|
+
return j + 1;
|
|
47
|
+
}
|
|
48
|
+
return sql.length;
|
|
49
|
+
}
|
|
50
|
+
/** Where the comment that opens at `i` ends; block comments nest in Postgres. */
|
|
51
|
+
function commentEnd(sql, i) {
|
|
52
|
+
if (sql.startsWith('--', i)) {
|
|
53
|
+
const newline = sql.indexOf('\n', i);
|
|
54
|
+
return newline < 0 ? sql.length : newline + 1;
|
|
55
|
+
}
|
|
56
|
+
let depth = 0;
|
|
57
|
+
do {
|
|
58
|
+
if (sql.startsWith('/*', i))
|
|
59
|
+
depth++;
|
|
60
|
+
else if (sql.startsWith('*/', i))
|
|
61
|
+
depth--;
|
|
62
|
+
else {
|
|
63
|
+
i++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
i += 2;
|
|
67
|
+
} while (depth > 0 && i < sql.length);
|
|
68
|
+
return i;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The statement without the whitespace, comments and semicolons around it. Strings are read the
|
|
72
|
+
* way Postgres reads them ('...', E'...', "...", $tag$...$tag$), so a `;` or `--` in one is kept.
|
|
73
|
+
*/
|
|
74
|
+
export function trimStatement(sql) {
|
|
75
|
+
let start = -1;
|
|
76
|
+
let end = 0;
|
|
77
|
+
for (let i = 0; i < sql.length;) {
|
|
78
|
+
const c = sql[i];
|
|
79
|
+
if (/[\s;]/.test(c)) {
|
|
80
|
+
i++;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (sql.startsWith('--', i) || sql.startsWith('/*', i)) {
|
|
84
|
+
i = commentEnd(sql, i);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
dollarTag.lastIndex = i;
|
|
88
|
+
const quoted = c === "'" ||
|
|
89
|
+
c === '"' ||
|
|
90
|
+
(c === '$' && !identifierChar.test(sql[i - 1] ?? '') && dollarTag.test(sql));
|
|
91
|
+
const next = quoted ? quotedEnd(sql, i) : i + 1;
|
|
92
|
+
if (start < 0)
|
|
93
|
+
start = i;
|
|
94
|
+
end = next;
|
|
95
|
+
i = next;
|
|
96
|
+
}
|
|
97
|
+
return start < 0 ? '' : sql.slice(start, end);
|
|
98
|
+
}
|
|
30
99
|
/**
|
|
31
100
|
* One page of any statement's rows, without the statement needing a LIMIT of its own. A query is
|
|
32
101
|
* wrapped in `SELECT * FROM (...) LIMIT OFFSET`, so the planner knows that few rows are wanted and
|
|
33
|
-
* starts returning them early, as it does for an explicit LIMIT. Other statements
|
|
34
|
-
* cannot be wrapped
|
|
102
|
+
* starts returning them early, as it does for an explicit LIMIT. Other statements run as they are
|
|
103
|
+
* and give their first rows. A query that cannot be wrapped runs as it is only when `asWritten`
|
|
104
|
+
* says the person asked for exactly that text: Postgres refusing the wrapper often means the
|
|
105
|
+
* statement writes (SELECT ... INTO, a WITH that deletes), so a caller that only lets queries
|
|
106
|
+
* through (the Build page) can count on nothing else running.
|
|
35
107
|
*/
|
|
36
|
-
export async function runPage(session, sql, { limit, offset = 0 }) {
|
|
108
|
+
export async function runPage(session, sql, { limit, offset = 0, asWritten = false }) {
|
|
37
109
|
const statement = trimStatement(sql);
|
|
38
110
|
if (!readsRows.test(statement))
|
|
39
111
|
return session.run(sql, [], { maxRows: limit });
|
|
@@ -43,8 +115,23 @@ export async function runPage(session, sql, { limit, offset = 0 }) {
|
|
|
43
115
|
return await session.run(wrapped, [], { maxRows: limit });
|
|
44
116
|
}
|
|
45
117
|
catch (error) {
|
|
46
|
-
if (!cannotWrap(error))
|
|
118
|
+
if (!asWritten || !cannotWrap(error))
|
|
47
119
|
throw error;
|
|
48
120
|
return session.run(sql, [], { maxRows: limit });
|
|
49
121
|
}
|
|
50
122
|
}
|
|
123
|
+
/** The most rows an export reads; more than this is cut off and says so. */
|
|
124
|
+
export const EXPORT_MAX_ROWS = 100_000;
|
|
125
|
+
/** Whether a statement is one that `exportRows` can read all of. */
|
|
126
|
+
export const canExport = (sql) => readsRows.test(trimStatement(sql));
|
|
127
|
+
/**
|
|
128
|
+
* Up to EXPORT_MAX_ROWS rows of a query, from its first. The statement is always wrapped, so an
|
|
129
|
+
* export can never run anything that is not a query, and never falls back to running it as it is.
|
|
130
|
+
*/
|
|
131
|
+
export function exportRows(session, sql) {
|
|
132
|
+
const statement = trimStatement(sql);
|
|
133
|
+
if (!readsRows.test(statement)) {
|
|
134
|
+
throw new Error('Only a query that returns rows can be exported');
|
|
135
|
+
}
|
|
136
|
+
return session.run(`SELECT * FROM (\n${statement}\n) AS export LIMIT ${EXPORT_MAX_ROWS + 1}`, [], { maxRows: EXPORT_MAX_ROWS });
|
|
137
|
+
}
|