@kazurolabs/psql-explorer 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +229 -18
- package/RELEASES.md +87 -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 +24 -0
- 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 +22 -8
- 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 +17 -0
- 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 +654 -21
- package/package.json +3 -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-ClA70PLR.js +1 -0
- package/web/dist/assets/code-xml-CXkpHeU9.js +1 -0
- package/web/dist/assets/compare-view-DKl5l_Jw.js +2 -0
- package/web/dist/assets/copy-CCXzeW-s.js +1 -0
- package/web/dist/assets/cypher-view-Pd0WFH8F.js +13 -0
- package/web/dist/assets/design-view-CIHSmH4u.js +9 -0
- package/web/dist/assets/erd-view-CV7mFGAN.js +1 -0
- package/web/dist/assets/format-sql-BLj5_RDZ.js +5 -0
- package/web/dist/assets/graph-force-yBLyErpS.js +1 -0
- package/web/dist/assets/graph-link-OCo_s0zO.js +1 -0
- package/web/dist/assets/graph-view-sM1OlByf.js +3 -0
- package/web/dist/assets/index-CDcnUacl.js +10 -0
- package/web/dist/assets/index-OzJqnJWG.css +2 -0
- package/web/dist/assets/key-round-Cv_7QeED.js +1 -0
- package/web/dist/assets/loader-circle-BK_GMdvN.js +1 -0
- package/web/dist/assets/query-builder-view-ChNFb51g.js +4 -0
- package/web/dist/assets/rotate-ccw-CdCaf7Q6.js +1 -0
- package/web/dist/assets/route-DO6IZ7of.js +15 -0
- package/web/dist/assets/seed-view-DFNB8WHE.js +7 -0
- package/web/dist/assets/shield-alert-CMS-3j4U.js +1 -0
- package/web/dist/assets/sliders-horizontal-Dg51yc-C.js +1 -0
- package/web/dist/assets/sql-tabs-B4jt6bQj.js +2 -0
- package/web/dist/assets/table-DfMz2f8M.js +1 -0
- package/web/dist/assets/triangle-alert-COGnHCD1.js +1 -0
- package/web/dist/assets/zone-D9dXk8tn.js +5 -0
- package/web/dist/index.html +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) {
|
|
@@ -24,9 +25,7 @@ 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
|
/** The statement without its leading comments and trailing semicolons. */
|
|
27
|
-
const trimStatement = (sql) => sql
|
|
28
|
-
.replace(/^(?:\s+|--[^\n]*\n?|\/\*[\s\S]*?\*\/)+/, '')
|
|
29
|
-
.replace(/[\s;]+$/, '');
|
|
28
|
+
const trimStatement = (sql) => sql.replace(/^(?:\s+|--[^\n]*\n?|\/\*[\s\S]*?\*\/)+/, '').replace(/[\s;]+$/, '');
|
|
30
29
|
/**
|
|
31
30
|
* One page of any statement's rows, without the statement needing a LIMIT of its own. A query is
|
|
32
31
|
* wrapped in `SELECT * FROM (...) LIMIT OFFSET`, so the planner knows that few rows are wanted and
|
|
@@ -48,3 +47,18 @@ export async function runPage(session, sql, { limit, offset = 0 }) {
|
|
|
48
47
|
return session.run(sql, [], { maxRows: limit });
|
|
49
48
|
}
|
|
50
49
|
}
|
|
50
|
+
/** The most rows an export reads; more than this is cut off and says so. */
|
|
51
|
+
export const EXPORT_MAX_ROWS = 100_000;
|
|
52
|
+
/** Whether a statement is one that `exportRows` can read all of. */
|
|
53
|
+
export const canExport = (sql) => readsRows.test(trimStatement(sql));
|
|
54
|
+
/**
|
|
55
|
+
* Up to EXPORT_MAX_ROWS rows of a query, from its first. The statement is always wrapped, so an
|
|
56
|
+
* export can never run anything that is not a query, and never falls back to running it as it is.
|
|
57
|
+
*/
|
|
58
|
+
export function exportRows(session, sql) {
|
|
59
|
+
const statement = trimStatement(sql);
|
|
60
|
+
if (!readsRows.test(statement)) {
|
|
61
|
+
throw new Error('Only a query that returns rows can be exported');
|
|
62
|
+
}
|
|
63
|
+
return session.run(`SELECT * FROM (\n${statement}\n) AS export LIMIT ${EXPORT_MAX_ROWS + 1}`, [], { maxRows: EXPORT_MAX_ROWS });
|
|
64
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
const relationKinds = {
|
|
2
|
+
r: 'table',
|
|
3
|
+
p: 'table',
|
|
4
|
+
v: 'view',
|
|
5
|
+
m: 'materialized view',
|
|
6
|
+
f: 'foreign table',
|
|
7
|
+
};
|
|
8
|
+
// The order kinds are listed in: what holds other things first.
|
|
9
|
+
const kindOrder = [
|
|
10
|
+
'extension',
|
|
11
|
+
'schema',
|
|
12
|
+
'type',
|
|
13
|
+
'sequence',
|
|
14
|
+
'table',
|
|
15
|
+
'foreign table',
|
|
16
|
+
'view',
|
|
17
|
+
'materialized view',
|
|
18
|
+
'function',
|
|
19
|
+
];
|
|
20
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
21
|
+
/**
|
|
22
|
+
* The text without its own schema's name in front of the names in it, so that "app.users" and
|
|
23
|
+
* "app_copy.users" read the same when one schema is compared with another. Names in other schemas
|
|
24
|
+
* keep theirs.
|
|
25
|
+
*/
|
|
26
|
+
export function unqualify(text, schema) {
|
|
27
|
+
const quoted = `"${schema.replaceAll('"', '""')}".`;
|
|
28
|
+
let out = text.split(quoted).join('');
|
|
29
|
+
if (/^[a-z_][a-z0-9_$]*$/.test(schema)) {
|
|
30
|
+
out = out.replace(new RegExp(`(?<![\\w$".])${escapeRegExp(schema)}\\.(?=[\\w"])`, 'g'), '');
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
// Excludes what an extension created, which its own version already accounts for.
|
|
35
|
+
const notInExtension = (catalog, oid) => `NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend e
|
|
36
|
+
WHERE e.classid = '${catalog}'::regclass AND e.objid = ${oid} AND e.deptype = 'e')`;
|
|
37
|
+
const userSchema = (column) => `${column} !~ '^pg_' AND ${column} <> 'information_schema' AND ($1::text IS NULL OR ${column} = $1)`;
|
|
38
|
+
/** The structure of a database (or one schema of it) as text, for comparing with another. */
|
|
39
|
+
export async function snapshotSchema(session, options = {}) {
|
|
40
|
+
const filter = [options.schema ?? null];
|
|
41
|
+
// With an empty search path Postgres writes every name with its schema, the same way on both
|
|
42
|
+
// sides, whatever each environment's search path is. Put back afterwards for the next statement.
|
|
43
|
+
const [saved] = await session.rows(`SELECT current_setting('search_path') AS path, pg_catalog.set_config('search_path', '', false)`);
|
|
44
|
+
try {
|
|
45
|
+
return { objects: await readObjects(session, filter) };
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
await session.rows(`SELECT pg_catalog.set_config('search_path', $1, false)`, [
|
|
49
|
+
saved?.path ?? '',
|
|
50
|
+
]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function readObjects(session, filter) {
|
|
54
|
+
const objects = new Map();
|
|
55
|
+
const add = (kind, schema, name) => {
|
|
56
|
+
const key = objectKey({ kind, schema, name });
|
|
57
|
+
let object = objects.get(key);
|
|
58
|
+
if (!object) {
|
|
59
|
+
object = { kind, schema, name, parts: {} };
|
|
60
|
+
objects.set(key, object);
|
|
61
|
+
}
|
|
62
|
+
return object;
|
|
63
|
+
};
|
|
64
|
+
// Parts of a table, view, ... by its schema and name, whose kind is already known.
|
|
65
|
+
const relations = new Map();
|
|
66
|
+
const partsOf = (schema, name) => relations.get(`${schema}\0${name}`);
|
|
67
|
+
const set = (parts, part, value, schema) => {
|
|
68
|
+
if (parts)
|
|
69
|
+
parts[part] = unqualify(value, schema ?? '');
|
|
70
|
+
};
|
|
71
|
+
for (const row of await session.rows(`SELECT n.nspname AS name FROM pg_catalog.pg_namespace n
|
|
72
|
+
WHERE ${userSchema('n.nspname')} AND ${notInExtension('pg_catalog.pg_namespace', 'n.oid')}`, filter)) {
|
|
73
|
+
add('schema', row.name ?? '', row.name ?? '');
|
|
74
|
+
}
|
|
75
|
+
for (const row of await session.rows(`SELECT x.extname AS name, x.extversion AS version, n.nspname AS schema
|
|
76
|
+
FROM pg_catalog.pg_extension x JOIN pg_catalog.pg_namespace n ON n.oid = x.extnamespace`)) {
|
|
77
|
+
const parts = add('extension', '', row.name ?? '').parts;
|
|
78
|
+
parts.version = row.version ?? '';
|
|
79
|
+
parts.schema = row.schema ?? '';
|
|
80
|
+
}
|
|
81
|
+
for (const row of await session.rows(`SELECT n.nspname AS schema, c.relname AS name, c.relkind AS kind,
|
|
82
|
+
CASE WHEN c.relkind IN ('v', 'm') THEN pg_catalog.pg_get_viewdef(c.oid, true) END AS definition,
|
|
83
|
+
CASE WHEN c.relkind = 'p' THEN pg_catalog.pg_get_partkeydef(c.oid) END AS partition_key,
|
|
84
|
+
(SELECT pg_catalog.format('%I.%I', pn.nspname, pc.relname)
|
|
85
|
+
FROM pg_catalog.pg_inherits i
|
|
86
|
+
JOIN pg_catalog.pg_class pc ON pc.oid = i.inhparent
|
|
87
|
+
JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace
|
|
88
|
+
WHERE i.inhrelid = c.oid AND c.relispartition) AS parent,
|
|
89
|
+
CASE WHEN c.relispartition THEN pg_catalog.pg_get_expr(c.relpartbound, c.oid) END AS bound,
|
|
90
|
+
c.relrowsecurity AS rls
|
|
91
|
+
FROM pg_catalog.pg_class c
|
|
92
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
93
|
+
WHERE c.relkind IN ('r', 'p', 'v', 'm', 'f') AND ${userSchema('n.nspname')}
|
|
94
|
+
AND ${notInExtension('pg_catalog.pg_class', 'c.oid')}`, filter)) {
|
|
95
|
+
const parts = add(relationKinds[row.kind ?? ''] ?? 'table', row.schema ?? '', row.name ?? '').parts;
|
|
96
|
+
relations.set(`${row.schema}\0${row.name}`, parts);
|
|
97
|
+
if (row.definition !== null)
|
|
98
|
+
set(parts, 'definition', row.definition.trim(), row.schema);
|
|
99
|
+
if (row.partition_key !== null)
|
|
100
|
+
set(parts, 'partitioned by', row.partition_key, row.schema);
|
|
101
|
+
if (row.parent !== null)
|
|
102
|
+
set(parts, 'partition of', `${row.parent} ${row.bound ?? ''}`.trim(), row.schema);
|
|
103
|
+
if (row.rls === 't')
|
|
104
|
+
parts['row level security'] = 'enabled';
|
|
105
|
+
}
|
|
106
|
+
// Columns of tables and foreign tables; a view's follow from its definition.
|
|
107
|
+
for (const row of await session.rows(`SELECT n.nspname AS schema, c.relname AS table, a.attname AS name,
|
|
108
|
+
pg_catalog.format_type(a.atttypid, a.atttypmod) AS type, a.attnotnull AS not_null,
|
|
109
|
+
pg_catalog.pg_get_expr(d.adbin, d.adrelid) AS default, a.attidentity AS identity,
|
|
110
|
+
a.attgenerated AS generated,
|
|
111
|
+
CASE WHEN a.attcollation <> t.typcollation THEN
|
|
112
|
+
(SELECT pg_catalog.format('%I.%I', cn.nspname, co.collname) FROM pg_catalog.pg_collation co
|
|
113
|
+
JOIN pg_catalog.pg_namespace cn ON cn.oid = co.collnamespace WHERE co.oid = a.attcollation)
|
|
114
|
+
END AS collation
|
|
115
|
+
FROM pg_catalog.pg_attribute a
|
|
116
|
+
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
|
|
117
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
118
|
+
JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
|
|
119
|
+
LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
|
120
|
+
WHERE c.relkind IN ('r', 'p', 'f') AND a.attnum > 0 AND NOT a.attisdropped
|
|
121
|
+
AND ${userSchema('n.nspname')}
|
|
122
|
+
ORDER BY a.attnum`, filter)) {
|
|
123
|
+
const words = [row.type ?? ''];
|
|
124
|
+
if (row.collation)
|
|
125
|
+
words.push(`COLLATE ${row.collation}`);
|
|
126
|
+
if (row.not_null === 't')
|
|
127
|
+
words.push('NOT NULL');
|
|
128
|
+
if (row.identity === 'a')
|
|
129
|
+
words.push('GENERATED ALWAYS AS IDENTITY');
|
|
130
|
+
else if (row.identity === 'd')
|
|
131
|
+
words.push('GENERATED BY DEFAULT AS IDENTITY');
|
|
132
|
+
else if (row.generated === 's')
|
|
133
|
+
words.push(`GENERATED ALWAYS AS (${row.default}) STORED`);
|
|
134
|
+
else if (row.generated === 'v')
|
|
135
|
+
words.push(`GENERATED ALWAYS AS (${row.default}) VIRTUAL`);
|
|
136
|
+
else if (row.default !== null)
|
|
137
|
+
words.push(`DEFAULT ${row.default}`);
|
|
138
|
+
set(partsOf(row.schema, row.table), `column ${row.name}`, words.join(' '), row.schema);
|
|
139
|
+
}
|
|
140
|
+
for (const row of await session.rows(`SELECT n.nspname AS schema, c.relname AS table, i.relname AS name,
|
|
141
|
+
pg_catalog.pg_get_indexdef(x.indexrelid) AS definition
|
|
142
|
+
FROM pg_catalog.pg_index x
|
|
143
|
+
JOIN pg_catalog.pg_class i ON i.oid = x.indexrelid
|
|
144
|
+
JOIN pg_catalog.pg_class c ON c.oid = x.indrelid
|
|
145
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
146
|
+
WHERE ${userSchema('n.nspname')}
|
|
147
|
+
ORDER BY x.indisprimary DESC, i.relname`, filter)) {
|
|
148
|
+
set(partsOf(row.schema, row.table), `index ${row.name}`, row.definition ?? '', row.schema);
|
|
149
|
+
}
|
|
150
|
+
// Not-null constraints (Postgres 18) are left out: the column already says NOT NULL.
|
|
151
|
+
for (const row of await session.rows(`SELECT n.nspname AS schema, c.relname AS table, k.conname AS name,
|
|
152
|
+
pg_catalog.pg_get_constraintdef(k.oid, true) AS definition
|
|
153
|
+
FROM pg_catalog.pg_constraint k
|
|
154
|
+
JOIN pg_catalog.pg_class c ON c.oid = k.conrelid
|
|
155
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
156
|
+
WHERE k.contype <> 'n' AND ${userSchema('n.nspname')}
|
|
157
|
+
ORDER BY array_position(ARRAY['p', 'u', 'f', 'c', 'x', 't']::"char"[], k.contype), k.conname`, filter)) {
|
|
158
|
+
set(partsOf(row.schema, row.table), `constraint ${row.name}`, row.definition ?? '', row.schema);
|
|
159
|
+
}
|
|
160
|
+
for (const row of await session.rows(`SELECT n.nspname AS schema, c.relname AS table, t.tgname AS name,
|
|
161
|
+
pg_catalog.pg_get_triggerdef(t.oid, true) AS definition, t.tgenabled AS enabled
|
|
162
|
+
FROM pg_catalog.pg_trigger t
|
|
163
|
+
JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid
|
|
164
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
165
|
+
WHERE NOT t.tgisinternal AND ${userSchema('n.nspname')}
|
|
166
|
+
ORDER BY t.tgname`, filter)) {
|
|
167
|
+
const disabled = row.enabled === 'D' ? ' (disabled)' : '';
|
|
168
|
+
set(partsOf(row.schema, row.table), `trigger ${row.name}`, `${row.definition}${disabled}`, row.schema);
|
|
169
|
+
}
|
|
170
|
+
for (const row of await session.rows(`SELECT schemaname AS schema, tablename AS table, policyname AS name, permissive,
|
|
171
|
+
array_to_string(roles, ', ') AS roles, cmd AS command, qual, with_check
|
|
172
|
+
FROM pg_catalog.pg_policies
|
|
173
|
+
WHERE ${userSchema('schemaname')}
|
|
174
|
+
ORDER BY policyname`, filter)) {
|
|
175
|
+
const words = [`AS ${row.permissive} FOR ${row.command} TO ${row.roles}`];
|
|
176
|
+
if (row.qual !== null)
|
|
177
|
+
words.push(`USING (${row.qual})`);
|
|
178
|
+
if (row.with_check !== null)
|
|
179
|
+
words.push(`WITH CHECK (${row.with_check})`);
|
|
180
|
+
set(partsOf(row.schema, row.table), `policy ${row.name}`, words.join(' '), row.schema);
|
|
181
|
+
}
|
|
182
|
+
for (const row of await session.rows(`SELECT n.nspname AS schema, p.proname AS name, pg_catalog.oidvectortypes(p.proargtypes) AS args,
|
|
183
|
+
p.prokind AS kind,
|
|
184
|
+
CASE WHEN p.prokind <> 'a' THEN pg_catalog.pg_get_functiondef(p.oid) END AS definition,
|
|
185
|
+
pg_catalog.pg_get_function_result(p.oid) AS returns
|
|
186
|
+
FROM pg_catalog.pg_proc p
|
|
187
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
|
188
|
+
WHERE ${userSchema('n.nspname')} AND ${notInExtension('pg_catalog.pg_proc', 'p.oid')}`, filter)) {
|
|
189
|
+
const schema = row.schema ?? '';
|
|
190
|
+
const name = `${row.name}(${unqualify(row.args ?? '', schema)})`;
|
|
191
|
+
const parts = add('function', schema, name).parts;
|
|
192
|
+
if (row.definition !== null)
|
|
193
|
+
set(parts, 'definition', row.definition.trim(), schema);
|
|
194
|
+
else
|
|
195
|
+
set(parts, 'aggregate returning', row.returns ?? '', schema);
|
|
196
|
+
}
|
|
197
|
+
for (const row of await session.rows(`SELECT s.schemaname AS schema, s.sequencename AS name, s.data_type::text AS type,
|
|
198
|
+
s.start_value::text AS start, s.increment_by::text AS increment, s.min_value::text AS min,
|
|
199
|
+
s.max_value::text AS max, s.cache_size::text AS cache, s.cycle,
|
|
200
|
+
(SELECT pg_catalog.format('%I.%I.%I', rn.nspname, r.relname, a.attname)
|
|
201
|
+
FROM pg_catalog.pg_depend d
|
|
202
|
+
JOIN pg_catalog.pg_class r ON r.oid = d.refobjid
|
|
203
|
+
JOIN pg_catalog.pg_namespace rn ON rn.oid = r.relnamespace
|
|
204
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid
|
|
205
|
+
WHERE d.objid = c.oid AND d.classid = 'pg_catalog.pg_class'::regclass
|
|
206
|
+
AND d.refclassid = 'pg_catalog.pg_class'::regclass AND d.deptype IN ('a', 'i')
|
|
207
|
+
LIMIT 1) AS owned_by
|
|
208
|
+
FROM pg_catalog.pg_sequences s
|
|
209
|
+
JOIN pg_catalog.pg_namespace n ON n.nspname = s.schemaname
|
|
210
|
+
JOIN pg_catalog.pg_class c ON c.relname = s.sequencename AND c.relnamespace = n.oid
|
|
211
|
+
WHERE ${userSchema('s.schemaname')} AND ${notInExtension('pg_catalog.pg_class', 'c.oid')}`, filter)) {
|
|
212
|
+
const schema = row.schema ?? '';
|
|
213
|
+
const parts = add('sequence', schema, row.name ?? '').parts;
|
|
214
|
+
for (const field of ['type', 'start', 'increment', 'min', 'max', 'cache']) {
|
|
215
|
+
parts[field] = row[field] ?? '';
|
|
216
|
+
}
|
|
217
|
+
parts.cycle = row.cycle === 't' ? 'yes' : 'no';
|
|
218
|
+
if (row.owned_by !== null)
|
|
219
|
+
set(parts, 'owned by', row.owned_by ?? '', schema);
|
|
220
|
+
}
|
|
221
|
+
for (const row of await session.rows(`SELECT n.nspname AS schema, t.typname AS name, t.typtype AS kind,
|
|
222
|
+
(SELECT string_agg(pg_catalog.quote_literal(e.enumlabel), ', ' ORDER BY e.enumsortorder)
|
|
223
|
+
FROM pg_catalog.pg_enum e WHERE e.enumtypid = t.oid) AS labels,
|
|
224
|
+
CASE t.typtype
|
|
225
|
+
WHEN 'd' THEN pg_catalog.format_type(t.typbasetype, t.typtypmod)
|
|
226
|
+
WHEN 'r' THEN (SELECT pg_catalog.format_type(r.rngsubtype, NULL)
|
|
227
|
+
FROM pg_catalog.pg_range r WHERE r.rngtypid = t.oid)
|
|
228
|
+
END AS base,
|
|
229
|
+
t.typdefault AS default, t.typnotnull AS not_null
|
|
230
|
+
FROM pg_catalog.pg_type t
|
|
231
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
232
|
+
WHERE t.typtype IN ('e', 'c', 'd', 'r') AND ${userSchema('n.nspname')}
|
|
233
|
+
AND (t.typtype <> 'c' OR EXISTS (
|
|
234
|
+
SELECT 1 FROM pg_catalog.pg_class c WHERE c.oid = t.typrelid AND c.relkind = 'c'))
|
|
235
|
+
AND ${notInExtension('pg_catalog.pg_type', 't.oid')}`, filter)) {
|
|
236
|
+
const schema = row.schema ?? '';
|
|
237
|
+
const parts = add('type', schema, row.name ?? '').parts;
|
|
238
|
+
if (row.kind === 'e')
|
|
239
|
+
parts['enum labels'] = row.labels ?? '';
|
|
240
|
+
if (row.kind === 'd') {
|
|
241
|
+
set(parts, 'domain of', row.base ?? '', schema);
|
|
242
|
+
if (row.default !== null)
|
|
243
|
+
set(parts, 'default', row.default, schema);
|
|
244
|
+
if (row.not_null === 't')
|
|
245
|
+
parts['not null'] = 'yes';
|
|
246
|
+
}
|
|
247
|
+
if (row.kind === 'r')
|
|
248
|
+
set(parts, 'range of', row.base ?? '', schema);
|
|
249
|
+
}
|
|
250
|
+
// The attributes of composite types and the constraints of domains.
|
|
251
|
+
for (const row of await session.rows(`SELECT n.nspname AS schema, t.typname AS type, a.attname AS name,
|
|
252
|
+
pg_catalog.format_type(a.atttypid, a.atttypmod) AS type_of
|
|
253
|
+
FROM pg_catalog.pg_type t
|
|
254
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
255
|
+
JOIN pg_catalog.pg_class c ON c.oid = t.typrelid AND c.relkind = 'c'
|
|
256
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
257
|
+
WHERE ${userSchema('n.nspname')}
|
|
258
|
+
ORDER BY a.attnum`, filter)) {
|
|
259
|
+
const object = objects.get(objectKey({ kind: 'type', schema: row.schema ?? '', name: row.type ?? '' }));
|
|
260
|
+
if (object)
|
|
261
|
+
set(object.parts, `attribute ${row.name}`, row.type_of ?? '', row.schema);
|
|
262
|
+
}
|
|
263
|
+
for (const row of await session.rows(`SELECT n.nspname AS schema, t.typname AS type, k.conname AS name,
|
|
264
|
+
pg_catalog.pg_get_constraintdef(k.oid, true) AS definition
|
|
265
|
+
FROM pg_catalog.pg_constraint k
|
|
266
|
+
JOIN pg_catalog.pg_type t ON t.oid = k.contypid
|
|
267
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
268
|
+
WHERE ${userSchema('n.nspname')}
|
|
269
|
+
ORDER BY k.conname`, filter)) {
|
|
270
|
+
const object = objects.get(objectKey({ kind: 'type', schema: row.schema ?? '', name: row.type ?? '' }));
|
|
271
|
+
if (object)
|
|
272
|
+
set(object.parts, `constraint ${row.name}`, row.definition ?? '', row.schema);
|
|
273
|
+
}
|
|
274
|
+
return [...objects.values()];
|
|
275
|
+
}
|
|
276
|
+
const objectKey = (object) => `${object.kind}\0${object.schema}\0${object.name}`;
|
|
277
|
+
// Beyond this many lines a side the comparison is not worth its time: all of a, then all of b.
|
|
278
|
+
const MAX_DIFF_LINES = 3000;
|
|
279
|
+
/** The lines of a and b in order, those they share once, by the longest common subsequence. */
|
|
280
|
+
export function diffLines(a, b) {
|
|
281
|
+
const left = a.split('\n');
|
|
282
|
+
const right = b.split('\n');
|
|
283
|
+
if (left.length > MAX_DIFF_LINES || right.length > MAX_DIFF_LINES) {
|
|
284
|
+
return [
|
|
285
|
+
...left.map((text) => ({ side: 'a', text })),
|
|
286
|
+
...right.map((text) => ({ side: 'b', text })),
|
|
287
|
+
];
|
|
288
|
+
}
|
|
289
|
+
// common[i][j]: how many lines left[i..] and right[j..] have in common.
|
|
290
|
+
const common = Array.from({ length: left.length + 1 }, () => new Uint32Array(right.length + 1));
|
|
291
|
+
for (let i = left.length - 1; i >= 0; i--) {
|
|
292
|
+
for (let j = right.length - 1; j >= 0; j--) {
|
|
293
|
+
common[i][j] =
|
|
294
|
+
left[i] === right[j]
|
|
295
|
+
? common[i + 1][j + 1] + 1
|
|
296
|
+
: Math.max(common[i + 1][j], common[i][j + 1]);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const lines = [];
|
|
300
|
+
let i = 0;
|
|
301
|
+
let j = 0;
|
|
302
|
+
while (i < left.length || j < right.length) {
|
|
303
|
+
if (i < left.length && j < right.length && left[i] === right[j]) {
|
|
304
|
+
lines.push({ side: 'both', text: left[i] });
|
|
305
|
+
i++;
|
|
306
|
+
j++;
|
|
307
|
+
}
|
|
308
|
+
else if (j >= right.length || (i < left.length && common[i + 1][j] >= common[i][j + 1])) {
|
|
309
|
+
lines.push({ side: 'a', text: left[i++] });
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
lines.push({ side: 'b', text: right[j++] });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return lines;
|
|
316
|
+
}
|
|
317
|
+
const partChange = (part, a, b) => a !== null && b !== null && (a.includes('\n') || b.includes('\n'))
|
|
318
|
+
? { part, a, b, lines: diffLines(a, b) }
|
|
319
|
+
: { part, a, b };
|
|
320
|
+
const sortKey = (object) => `${object.schema}\0${String(kindOrder.indexOf(object.kind)).padStart(2, '0')}\0${object.name}`;
|
|
321
|
+
/** The objects and parts that differ between two snapshots, by schema, kind and name. */
|
|
322
|
+
export function diffSnapshots(a, b, options = {}) {
|
|
323
|
+
const { schemas } = options;
|
|
324
|
+
// Side b's objects named as though in side a's schema, so the two line up.
|
|
325
|
+
const rename = (object) => schemas && object.schema === schemas.b
|
|
326
|
+
? { ...object, schema: schemas.a, name: object.kind === 'schema' ? schemas.a : object.name }
|
|
327
|
+
: object;
|
|
328
|
+
const left = new Map(a.objects.map((object) => [objectKey(object), object]));
|
|
329
|
+
const right = new Map(b.objects.map(rename).map((object) => [objectKey(object), object]));
|
|
330
|
+
const changes = [];
|
|
331
|
+
let same = 0;
|
|
332
|
+
for (const key of new Set([...left.keys(), ...right.keys()])) {
|
|
333
|
+
const one = left.get(key);
|
|
334
|
+
const other = right.get(key);
|
|
335
|
+
const object = (one ?? other);
|
|
336
|
+
const names = [
|
|
337
|
+
...new Set([...Object.keys(one?.parts ?? {}), ...Object.keys(other?.parts ?? {})]),
|
|
338
|
+
];
|
|
339
|
+
const parts = names
|
|
340
|
+
.filter((part) => !one || !other || one.parts[part] !== other.parts[part])
|
|
341
|
+
.map((part) => partChange(part, one?.parts[part] ?? null, other?.parts[part] ?? null));
|
|
342
|
+
const status = !other ? 'only-a' : !one ? 'only-b' : parts.length > 0 ? 'changed' : null;
|
|
343
|
+
if (status)
|
|
344
|
+
changes.push({ kind: object.kind, schema: object.schema, name: object.name, status, parts });
|
|
345
|
+
else
|
|
346
|
+
same++;
|
|
347
|
+
}
|
|
348
|
+
changes.sort((x, y) => sortKey(x).localeCompare(sortKey(y)));
|
|
349
|
+
return { changes, same };
|
|
350
|
+
}
|
|
351
|
+
/** Reads both sides at once (one after the other on the same session) and compares them. */
|
|
352
|
+
export async function compareSchemas(a, b, options = {}) {
|
|
353
|
+
const { schema } = options;
|
|
354
|
+
const withSchema = options.withSchema ?? schema;
|
|
355
|
+
const readA = () => snapshotSchema(a, schema ? { schema } : {});
|
|
356
|
+
const readB = () => snapshotSchema(b, withSchema ? { schema: withSchema } : {});
|
|
357
|
+
// One connection runs one statement at a time, and each read changes its search path meanwhile.
|
|
358
|
+
const [left, right] = a === b ? [await readA(), await readB()] : await Promise.all([readA(), readB()]);
|
|
359
|
+
return diffSnapshots(left, right, schema && withSchema && schema !== withSchema ? { schemas: { a: schema, b: withSchema } } : {});
|
|
360
|
+
}
|