@kazurolabs/psql-explorer 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +505 -2
- package/RELEASES.md +87 -0
- package/dist/app.js +39 -0
- package/dist/cli.js +307 -0
- package/dist/commands/configgen.js +128 -0
- package/dist/commands/explore.js +388 -0
- package/dist/commands/terminal-prompt.js +68 -0
- package/dist/config/load.js +324 -0
- package/dist/config/resolve-environment.js +112 -0
- package/dist/config/resolvers/env.js +12 -0
- package/dist/config/resolvers/file.js +24 -0
- package/dist/config/resolvers/groups.js +1 -0
- package/dist/config/resolvers/index.js +49 -0
- package/dist/config/resolvers/literal.js +7 -0
- package/dist/config/resolvers/prompt.js +159 -0
- package/dist/config/resolvers/stored.js +13 -0
- package/dist/config/resolvers/types.js +18 -0
- package/dist/config/resolvers/vault.js +283 -0
- package/dist/config/schema.js +24 -0
- package/dist/config/source.js +105 -0
- package/dist/db/connect.js +55 -0
- package/dist/db/identifiers.js +6 -0
- package/dist/db/paths.js +14 -0
- package/dist/db/store.js +323 -0
- package/dist/db/tester.js +190 -0
- package/dist/db/validate.js +99 -0
- package/dist/environments.js +140 -0
- package/dist/explorer/alter.js +493 -0
- package/dist/explorer/catalog.js +414 -0
- package/dist/explorer/connect-entry.js +42 -0
- package/dist/explorer/cypher-parse.js +478 -0
- package/dist/explorer/cypher.js +963 -0
- package/dist/explorer/ddl.js +502 -0
- package/dist/explorer/dependents.js +222 -0
- package/dist/explorer/design-load.js +254 -0
- package/dist/explorer/design.js +45 -0
- package/dist/explorer/edit.js +108 -0
- package/dist/explorer/explain.js +37 -0
- package/dist/explorer/filter.js +121 -0
- package/dist/explorer/format.js +134 -0
- package/dist/explorer/graph.js +679 -0
- package/dist/explorer/gucs.js +31 -0
- package/dist/explorer/profile.js +102 -0
- package/dist/explorer/references.js +95 -0
- package/dist/explorer/rows.js +64 -0
- package/dist/explorer/schema-diff.js +360 -0
- package/dist/explorer/search.js +309 -0
- package/dist/explorer/security.js +152 -0
- package/dist/explorer/seed.js +1376 -0
- package/dist/explorer/session.js +122 -0
- package/dist/explorer/stats.js +135 -0
- package/dist/explorer/timeout.js +13 -0
- package/dist/prompts.js +52 -0
- package/dist/screens/environments.js +229 -0
- package/dist/screens/explorer.js +145 -0
- package/dist/screens/header.js +8 -0
- package/dist/screens/index.js +13 -0
- package/dist/screens/new-connection.js +178 -0
- package/dist/screens/panes/common.js +35 -0
- package/dist/screens/panes/history.js +36 -0
- package/dist/screens/panes/info.js +61 -0
- package/dist/screens/panes/query.js +113 -0
- package/dist/screens/panes/table.js +178 -0
- package/dist/screens/panes/tables.js +142 -0
- package/dist/screens/prompt-modal.js +18 -0
- package/dist/screens/record-view.js +42 -0
- package/dist/screens/result-grid.js +126 -0
- package/dist/screens/scroll.js +12 -0
- package/dist/screens/splash.js +78 -0
- package/dist/screens/terminal.js +25 -0
- package/dist/screens/types.js +1 -0
- package/dist/services.js +1 -0
- package/dist/startup.js +84 -0
- package/dist/web/server.js +988 -0
- package/package.json +69 -5
- package/web/dist/assets/anton-latin-400-normal-AUNGEG_V.woff +0 -0
- package/web/dist/assets/anton-latin-400-normal-Byf51wtH.woff2 +0 -0
- package/web/dist/assets/anton-latin-ext-400-normal-BMODBQc6.woff +0 -0
- package/web/dist/assets/anton-latin-ext-400-normal-SyiqE2Jt.woff2 +0 -0
- package/web/dist/assets/anton-vietnamese-400-normal-2FfR1wHA.woff +0 -0
- package/web/dist/assets/anton-vietnamese-400-normal-CkBxLiRJ.woff2 +0 -0
- package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
- package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
- package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
- package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
- package/web/dist/assets/button-ClA70PLR.js +1 -0
- package/web/dist/assets/code-xml-CXkpHeU9.js +1 -0
- package/web/dist/assets/compare-view-DKl5l_Jw.js +2 -0
- package/web/dist/assets/copy-CCXzeW-s.js +1 -0
- package/web/dist/assets/cypher-view-Pd0WFH8F.js +13 -0
- package/web/dist/assets/design-view-CIHSmH4u.js +9 -0
- package/web/dist/assets/erd-view-CV7mFGAN.js +1 -0
- package/web/dist/assets/format-sql-BLj5_RDZ.js +5 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/graph-force-yBLyErpS.js +1 -0
- package/web/dist/assets/graph-link-OCo_s0zO.js +1 -0
- package/web/dist/assets/graph-view-sM1OlByf.js +3 -0
- package/web/dist/assets/index-CDcnUacl.js +10 -0
- package/web/dist/assets/index-OzJqnJWG.css +2 -0
- package/web/dist/assets/key-round-Cv_7QeED.js +1 -0
- package/web/dist/assets/loader-circle-BK_GMdvN.js +1 -0
- package/web/dist/assets/orbitron-latin-700-normal-4jsRXGGJ.woff2 +0 -0
- package/web/dist/assets/orbitron-latin-700-normal-DmYOh-W9.woff +0 -0
- package/web/dist/assets/orbitron-latin-900-normal-BbJatbhp.woff +0 -0
- package/web/dist/assets/orbitron-latin-900-normal-DrIi7unX.woff2 +0 -0
- package/web/dist/assets/query-builder-view-ChNFb51g.js +4 -0
- package/web/dist/assets/rotate-ccw-CdCaf7Q6.js +1 -0
- package/web/dist/assets/route-DO6IZ7of.js +15 -0
- package/web/dist/assets/seed-view-DFNB8WHE.js +7 -0
- package/web/dist/assets/share-tech-mono-latin-400-normal-15MgdvEM.woff +0 -0
- package/web/dist/assets/share-tech-mono-latin-400-normal-CCqdbj5z.woff2 +0 -0
- package/web/dist/assets/shield-alert-CMS-3j4U.js +1 -0
- package/web/dist/assets/sliders-horizontal-Dg51yc-C.js +1 -0
- package/web/dist/assets/sql-tabs-B4jt6bQj.js +2 -0
- package/web/dist/assets/table-DfMz2f8M.js +1 -0
- package/web/dist/assets/triangle-alert-COGnHCD1.js +1 -0
- package/web/dist/assets/zone-D9dXk8tn.js +5 -0
- package/web/dist/index.html +26 -0
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
// Cypher queries over a relational database. Labels are tables, relationships are foreign keys (or
|
|
2
|
+
// a chain of them through tables in between), and a query is turned into one SELECT. Before it runs,
|
|
3
|
+
// Postgres is asked for its plan, which together with the shape of the query says how heavy it is.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { quoteIdentifier } from '../db/identifiers.js';
|
|
6
|
+
import { listColumns, listSchemas, listTables, qualified, tableLabel, } from './catalog.js';
|
|
7
|
+
import { primaryKeyOf } from './edit.js';
|
|
8
|
+
import { keyLabel, nodeId } from './graph.js';
|
|
9
|
+
import { AGGREGATES, CypherError, parseCypher, } from './cypher-parse.js';
|
|
10
|
+
import { foreignKeyId, listSchemaForeignKeys } from './references.js';
|
|
11
|
+
import { describeQueryError } from './session.js';
|
|
12
|
+
export { CypherError, parseCypher } from './cypher-parse.js';
|
|
13
|
+
/** Foreign keys followed between two nodes at most: up to MAX_STEPS - 1 tables are only passed through. */
|
|
14
|
+
export const MAX_STEPS = 4;
|
|
15
|
+
/** Rows read when the query has no LIMIT, and the most rows any query reads. */
|
|
16
|
+
export const DEFAULT_ROWS = 200;
|
|
17
|
+
export const MAX_ROWS = 1000;
|
|
18
|
+
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
19
|
+
const labelsOf = (query) => query.patterns.flatMap((pattern) => pattern.nodes.flatMap((node) => (node.label ? [node.label] : [])));
|
|
20
|
+
/** Reads what compiling needs from the database: tables, foreign keys, and the columns of the tables the query names. */
|
|
21
|
+
export async function loadCypherModel(session, query) {
|
|
22
|
+
const tables = [];
|
|
23
|
+
const keys = new Map();
|
|
24
|
+
for (const schema of await listSchemas(session)) {
|
|
25
|
+
for (const table of await listTables(session, schema.name)) {
|
|
26
|
+
tables.push({
|
|
27
|
+
ref: { schema: table.schema, name: table.name },
|
|
28
|
+
kind: table.kind,
|
|
29
|
+
estimatedRows: table.estimatedRows,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
for (const key of await listSchemaForeignKeys(session, schema.name)) {
|
|
33
|
+
keys.set(foreignKeyId(key), key);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const model = {
|
|
37
|
+
defaultSchema: session.params.schema,
|
|
38
|
+
tables,
|
|
39
|
+
keys: [...keys.values()],
|
|
40
|
+
columns: new Map(),
|
|
41
|
+
};
|
|
42
|
+
for (const label of labelsOf(query)) {
|
|
43
|
+
const table = findTable(model, label);
|
|
44
|
+
if (!model.columns.has(tableLabel(table.ref))) {
|
|
45
|
+
model.columns.set(tableLabel(table.ref), await listColumns(session, table.ref));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return model;
|
|
49
|
+
}
|
|
50
|
+
const plain = (text) => text
|
|
51
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
52
|
+
.replace(/[\s-]+/g, '_')
|
|
53
|
+
.toLowerCase();
|
|
54
|
+
/** `Customer` → customers, `OrderLine` → order_lines, `Category` → categories: the forms a table may be called. */
|
|
55
|
+
function variantsOf(name) {
|
|
56
|
+
const base = plain(name);
|
|
57
|
+
const forms = new Set([base, `${base}s`, `${base}es`]);
|
|
58
|
+
if (base.endsWith('y'))
|
|
59
|
+
forms.add(`${base.slice(0, -1)}ies`);
|
|
60
|
+
if (base.endsWith('s'))
|
|
61
|
+
forms.add(base.slice(0, -1));
|
|
62
|
+
if (base.endsWith('ies'))
|
|
63
|
+
forms.add(`${base.slice(0, -3)}y`);
|
|
64
|
+
return [...forms];
|
|
65
|
+
}
|
|
66
|
+
/** The table a label names: exactly, then without regard to case, then in its plural or snake case form. */
|
|
67
|
+
export function findTable(model, label) {
|
|
68
|
+
const dot = label.indexOf('.');
|
|
69
|
+
const schema = dot > 0 ? label.slice(0, dot) : undefined;
|
|
70
|
+
const name = dot > 0 ? label.slice(dot + 1) : label;
|
|
71
|
+
const inScope = model.tables.filter((table) => !schema || table.ref.schema === schema);
|
|
72
|
+
const steps = [
|
|
73
|
+
(table) => table.ref.name === name,
|
|
74
|
+
(table) => table.ref.name.toLowerCase() === name.toLowerCase(),
|
|
75
|
+
(table) => variantsOf(name).includes(plain(table.ref.name)),
|
|
76
|
+
];
|
|
77
|
+
for (const matches of steps) {
|
|
78
|
+
const found = inScope.filter(matches);
|
|
79
|
+
if (found.length === 0)
|
|
80
|
+
continue;
|
|
81
|
+
const here = found.filter((table) => table.ref.schema === model.defaultSchema);
|
|
82
|
+
const pick = here.length > 0 ? here : found;
|
|
83
|
+
if (pick.length === 1)
|
|
84
|
+
return pick[0];
|
|
85
|
+
throw new CypherError(`${label} could be any of ${pick.map((table) => tableLabel(table.ref)).join(', ')}: write the schema, e.g. ${tableLabel(pick[0].ref)}`);
|
|
86
|
+
}
|
|
87
|
+
const known = inScope
|
|
88
|
+
.filter((table) => table.ref.schema === (schema ?? model.defaultSchema))
|
|
89
|
+
.map((table) => table.ref.name)
|
|
90
|
+
.slice(0, 12);
|
|
91
|
+
throw new CypherError(`no table matches the label ${label}${known.length > 0 ? `; tables here include ${known.join(', ')}` : ''}`);
|
|
92
|
+
}
|
|
93
|
+
const stepEnd = (step) => (step.forward ? step.key.to : step.key.from);
|
|
94
|
+
const sameTable = (a, b) => a.schema === b.schema && a.name === b.name;
|
|
95
|
+
const shortName = (model, table) => table.schema === model.defaultSchema ? table.name : tableLabel(table);
|
|
96
|
+
/** `customers ← orders ← order_lines → products`: the arrow points at the table that is pointed at. */
|
|
97
|
+
export function describeRoute(model, start, steps) {
|
|
98
|
+
let text = shortName(model, start);
|
|
99
|
+
for (const step of steps)
|
|
100
|
+
text += ` ${step.forward ? '→' : '←'} ${shortName(model, stepEnd(step))}`;
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
const matchesKey = (step, hint) => step.key.name.toLowerCase() === hint.toLowerCase() ||
|
|
104
|
+
step.key.fromColumns.some((column) => column.toLowerCase() === hint.toLowerCase());
|
|
105
|
+
const matchesTable = (table, hint) => table.name.toLowerCase() === hint.toLowerCase() ||
|
|
106
|
+
tableLabel(table).toLowerCase() === hint.toLowerCase();
|
|
107
|
+
function stepsFrom(keys, table) {
|
|
108
|
+
const steps = [];
|
|
109
|
+
for (const key of keys) {
|
|
110
|
+
if (sameTable(key.from, table))
|
|
111
|
+
steps.push({ key, forward: true });
|
|
112
|
+
if (sameTable(key.to, table))
|
|
113
|
+
steps.push({ key, forward: false });
|
|
114
|
+
}
|
|
115
|
+
return steps;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The foreign keys that join two tables. A key between them is used directly; otherwise the shortest
|
|
119
|
+
* chain of keys through other tables (the direction of the arrow is then not used, as the keys along a
|
|
120
|
+
* chain point both ways). Chains that tie are an error, since the query would mean something else for
|
|
121
|
+
* each: a hint (`-[:orders]-`, the name of a key, a column or a table on the way) settles it.
|
|
122
|
+
*/
|
|
123
|
+
export function routeBetween(model, from, to, direction, hint) {
|
|
124
|
+
const name = `${shortName(model, from)} and ${shortName(model, to)}`;
|
|
125
|
+
const all = stepsFrom(model.keys, from).filter((step) => sameTable(stepEnd(step), to));
|
|
126
|
+
const wanted = all
|
|
127
|
+
.filter((step) => direction === 'any' || (direction === 'out') === step.forward)
|
|
128
|
+
.filter((step) => !hint || matchesKey(step, hint));
|
|
129
|
+
if (wanted.length === 1)
|
|
130
|
+
return wanted;
|
|
131
|
+
if (wanted.length > 1) {
|
|
132
|
+
throw new CypherError(`${name} are joined by ${wanted.length} foreign keys (${wanted.map((step) => step.key.name).join(', ')}): name one, e.g. -[:${wanted[0].key.name}]-` +
|
|
133
|
+
(direction === 'any' && sameTable(from, to) ? ', or give the arrow a direction' : ''));
|
|
134
|
+
}
|
|
135
|
+
if (!hint && all.length > 0) {
|
|
136
|
+
throw new CypherError(`the foreign key between ${name} points the other way: ${describeRoute(model, from, all.slice(0, 1))}; use ${direction === 'out' ? '<-' : '->'} instead`);
|
|
137
|
+
}
|
|
138
|
+
if (hint && all.length > 0) {
|
|
139
|
+
// Keys join them directly, so the hint cannot be about a table on the way.
|
|
140
|
+
throw new CypherError(`no foreign key between ${name} is called "${hint}"; they have ${all.map((step) => `${step.key.name} (${step.key.fromColumns.join(', ')})`).join(', ')}`);
|
|
141
|
+
}
|
|
142
|
+
// Chains through other tables.
|
|
143
|
+
const reach = distancesTo(model.keys, to);
|
|
144
|
+
for (let limit = 2; limit <= MAX_STEPS; limit++) {
|
|
145
|
+
const found = [];
|
|
146
|
+
const walk = (at, steps) => {
|
|
147
|
+
if (steps.length === limit) {
|
|
148
|
+
if (sameTable(at, to) && (!hint || satisfies(steps, hint)))
|
|
149
|
+
found.push(steps);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
for (const step of stepsFrom(model.keys, at)) {
|
|
153
|
+
const last = steps[steps.length - 1];
|
|
154
|
+
// Straight back along the key just followed is not a new way.
|
|
155
|
+
if (last && last.key === step.key && last.forward !== step.forward)
|
|
156
|
+
continue;
|
|
157
|
+
const next = stepEnd(step);
|
|
158
|
+
if (steps.length + 1 + (reach.get(tableLabel(next)) ?? Infinity) > limit)
|
|
159
|
+
continue;
|
|
160
|
+
walk(next, [...steps, step]);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
walk(from, []);
|
|
164
|
+
if (found.length === 1)
|
|
165
|
+
return found[0];
|
|
166
|
+
if (found.length > 1) {
|
|
167
|
+
const ways = found.map((steps) => ` ${describeRoute(model, from, steps)} (${steps.map((s) => s.key.name).join(', ')})`);
|
|
168
|
+
// A table on the first way that not every way goes through tells them apart, else one of its keys.
|
|
169
|
+
const inAll = (table) => found.every((steps) => steps.slice(0, -1).some((s) => sameTable(stepEnd(s), table)));
|
|
170
|
+
const odd = found[0]
|
|
171
|
+
.slice(0, -1)
|
|
172
|
+
.map(stepEnd)
|
|
173
|
+
.find((table) => !inAll(table));
|
|
174
|
+
throw new CypherError(`${name} are joined in ${found.length} equally short ways:\n${ways.join('\n')}\nname one with a table or key on the way, e.g. -[:${odd?.name ?? found[0][0].key.name}]-, or write the nodes along it`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (hint) {
|
|
178
|
+
throw new CypherError(`no way to join ${name} goes through "${hint}" within ${MAX_STEPS} foreign keys`);
|
|
179
|
+
}
|
|
180
|
+
throw new CypherError(`${name} are not joined by foreign keys, directly or through up to ${MAX_STEPS - 1} tables in between`);
|
|
181
|
+
}
|
|
182
|
+
function satisfies(steps, hint) {
|
|
183
|
+
return (steps.some((step) => matchesKey(step, hint)) ||
|
|
184
|
+
steps.slice(0, -1).some((step) => matchesTable(stepEnd(step), hint)));
|
|
185
|
+
}
|
|
186
|
+
/** Steps from each table to `target` along foreign keys either way, for not walking where it cannot lead. */
|
|
187
|
+
function distancesTo(keys, target) {
|
|
188
|
+
const dist = new Map([[tableLabel(target), 0]]);
|
|
189
|
+
let level = [target];
|
|
190
|
+
for (let d = 1; d <= MAX_STEPS && level.length > 0; d++) {
|
|
191
|
+
const next = [];
|
|
192
|
+
for (const table of level) {
|
|
193
|
+
for (const step of stepsFrom(keys, table)) {
|
|
194
|
+
const other = stepEnd(step);
|
|
195
|
+
if (!dist.has(tableLabel(other))) {
|
|
196
|
+
dist.set(tableLabel(other), d);
|
|
197
|
+
next.push(other);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
level = next;
|
|
202
|
+
}
|
|
203
|
+
return dist;
|
|
204
|
+
}
|
|
205
|
+
const quoteLiteral = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
206
|
+
function emitter(inline) {
|
|
207
|
+
const values = [];
|
|
208
|
+
return {
|
|
209
|
+
values,
|
|
210
|
+
value(value, cast) {
|
|
211
|
+
if (value === null)
|
|
212
|
+
return 'NULL';
|
|
213
|
+
if (typeof value === 'boolean')
|
|
214
|
+
return value ? 'TRUE' : 'FALSE';
|
|
215
|
+
if (typeof value === 'number')
|
|
216
|
+
return Number.isFinite(value) ? String(value) : 'NULL';
|
|
217
|
+
if (inline)
|
|
218
|
+
return quoteLiteral(value);
|
|
219
|
+
values.push(value);
|
|
220
|
+
return `$${values.length}${cast ? `::${cast}` : ''}`;
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const walk = (expr, visit) => {
|
|
225
|
+
visit(expr);
|
|
226
|
+
switch (expr.kind) {
|
|
227
|
+
case 'compare':
|
|
228
|
+
case 'logic':
|
|
229
|
+
walk(expr.left, visit);
|
|
230
|
+
walk(expr.right, visit);
|
|
231
|
+
break;
|
|
232
|
+
case 'not':
|
|
233
|
+
case 'null':
|
|
234
|
+
walk(expr.operand, visit);
|
|
235
|
+
break;
|
|
236
|
+
case 'in':
|
|
237
|
+
walk(expr.operand, visit);
|
|
238
|
+
for (const item of expr.list)
|
|
239
|
+
walk(item, visit);
|
|
240
|
+
break;
|
|
241
|
+
case 'call':
|
|
242
|
+
for (const arg of expr.args)
|
|
243
|
+
walk(arg, visit);
|
|
244
|
+
break;
|
|
245
|
+
default:
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
const variablesIn = (expr) => {
|
|
249
|
+
const found = new Set();
|
|
250
|
+
walk(expr, (e) => {
|
|
251
|
+
if (e.kind === 'property')
|
|
252
|
+
found.add(e.variable);
|
|
253
|
+
if (e.kind === 'variable')
|
|
254
|
+
found.add(e.name);
|
|
255
|
+
});
|
|
256
|
+
return found;
|
|
257
|
+
};
|
|
258
|
+
const hasAggregate = (expr) => {
|
|
259
|
+
let found = false;
|
|
260
|
+
walk(expr, (e) => {
|
|
261
|
+
if (e.kind === 'call' && AGGREGATES.includes(e.name))
|
|
262
|
+
found = true;
|
|
263
|
+
});
|
|
264
|
+
return found;
|
|
265
|
+
};
|
|
266
|
+
export function compileCypher(query, model) {
|
|
267
|
+
const scope = new Map();
|
|
268
|
+
const order = [];
|
|
269
|
+
const hiddenCount = new Map();
|
|
270
|
+
const hiddenAlias = (table) => {
|
|
271
|
+
const n = (hiddenCount.get(table.name) ?? 0) + 1;
|
|
272
|
+
hiddenCount.set(table.name, n);
|
|
273
|
+
return quoteIdentifier(`${table.name}#${n}`);
|
|
274
|
+
};
|
|
275
|
+
// Nodes: variables and their tables.
|
|
276
|
+
let anonymous = 0;
|
|
277
|
+
const bind = (node) => {
|
|
278
|
+
const known = node.variable ? scope.get(node.variable) : undefined;
|
|
279
|
+
if (known) {
|
|
280
|
+
if (node.label && !sameTable(findTable(model, node.label).ref, known.table.ref)) {
|
|
281
|
+
throw new CypherError(`${node.variable} is already ${shortName(model, known.table.ref)}, not ${node.label}`, node.at);
|
|
282
|
+
}
|
|
283
|
+
return known;
|
|
284
|
+
}
|
|
285
|
+
if (!node.label) {
|
|
286
|
+
throw new CypherError(node.variable
|
|
287
|
+
? `say which table ${node.variable} is a row of, e.g. (${node.variable}:Customer)`
|
|
288
|
+
: 'say which table this node is a row of, e.g. (:Customer)', node.at);
|
|
289
|
+
}
|
|
290
|
+
const table = findTable(model, node.label);
|
|
291
|
+
const columns = model.columns.get(tableLabel(table.ref));
|
|
292
|
+
if (!columns)
|
|
293
|
+
throw new CypherError(`the columns of ${node.label} were not loaded`, node.at);
|
|
294
|
+
const name = node.variable ?? `anonymous${++anonymous}`;
|
|
295
|
+
const binding = {
|
|
296
|
+
name,
|
|
297
|
+
alias: node.variable ? quoteIdentifier(name) : hiddenAlias(table.ref),
|
|
298
|
+
table,
|
|
299
|
+
columns,
|
|
300
|
+
anonymous: !node.variable,
|
|
301
|
+
};
|
|
302
|
+
scope.set(name, binding);
|
|
303
|
+
order.push(binding);
|
|
304
|
+
return binding;
|
|
305
|
+
};
|
|
306
|
+
const column = (binding, name, at) => {
|
|
307
|
+
const exact = binding.columns.find((c) => c.name === name);
|
|
308
|
+
const loose = binding.columns.filter((c) => c.name.toLowerCase() === name.toLowerCase());
|
|
309
|
+
const found = exact ?? (loose.length === 1 ? loose[0] : undefined);
|
|
310
|
+
if (!found) {
|
|
311
|
+
throw new CypherError(`${shortName(model, binding.table.ref)} has no column "${name}"; its columns are ${binding.columns
|
|
312
|
+
.slice(0, 12)
|
|
313
|
+
.map((c) => c.name)
|
|
314
|
+
.join(', ')}`, at);
|
|
315
|
+
}
|
|
316
|
+
return `${binding.alias}.${quoteIdentifier(found.name)}`;
|
|
317
|
+
};
|
|
318
|
+
// What identifies a row of a node: its primary key, or else its physical address.
|
|
319
|
+
const keyParts = (binding) => {
|
|
320
|
+
const primary = primaryKeyOf(binding.columns);
|
|
321
|
+
return primary.length > 0
|
|
322
|
+
? primary.map((name) => `${binding.alias}.${quoteIdentifier(name)}`)
|
|
323
|
+
: [`${binding.alias}.ctid`];
|
|
324
|
+
};
|
|
325
|
+
// Relationships become joins, in the order they are written.
|
|
326
|
+
const from = [];
|
|
327
|
+
const bound = new Set();
|
|
328
|
+
const closing = [];
|
|
329
|
+
const joins = [];
|
|
330
|
+
const links = [];
|
|
331
|
+
const used = [];
|
|
332
|
+
const hiddenTables = [];
|
|
333
|
+
const addTable = (table, alias, on) => {
|
|
334
|
+
from.push({ table: qualified(table), alias, on });
|
|
335
|
+
bound.add(alias);
|
|
336
|
+
};
|
|
337
|
+
const connect = (pattern) => {
|
|
338
|
+
let left = bind(pattern.nodes[0]);
|
|
339
|
+
if (!bound.has(left.alias))
|
|
340
|
+
addTable(left.table.ref, left.alias, from.length === 0 ? [] : null);
|
|
341
|
+
pattern.relationships.forEach((relationship, index) => {
|
|
342
|
+
const right = bind(pattern.nodes[index + 1]);
|
|
343
|
+
const steps = routeBetween(model, left.table.ref, right.table.ref, relationship.direction, relationship.hint);
|
|
344
|
+
links.push([left.name, right.name]);
|
|
345
|
+
joins.push(`${nodeText(left)} – ${nodeText(right)}: ${steps.length === 1
|
|
346
|
+
? `${describeStep(steps[0])}`
|
|
347
|
+
: `${describeRoute(model, left.table.ref, steps)} (${steps.map((s) => s.key.name).join(', ')})`}`);
|
|
348
|
+
const holders = [];
|
|
349
|
+
let at = left.alias;
|
|
350
|
+
steps.forEach((step, i) => {
|
|
351
|
+
const last = i === steps.length - 1;
|
|
352
|
+
const end = stepEnd(step);
|
|
353
|
+
let next;
|
|
354
|
+
if (last)
|
|
355
|
+
next = right.alias;
|
|
356
|
+
else {
|
|
357
|
+
next = hiddenAlias(end);
|
|
358
|
+
hiddenTables.push({
|
|
359
|
+
alias: next,
|
|
360
|
+
table: model.tables.find((t) => sameTable(t.ref, end)) ?? {
|
|
361
|
+
ref: end,
|
|
362
|
+
kind: 'table',
|
|
363
|
+
estimatedRows: null,
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
const mine = step.forward ? step.key.fromColumns : step.key.toColumns;
|
|
368
|
+
const theirs = step.forward ? step.key.toColumns : step.key.fromColumns;
|
|
369
|
+
const on = mine.map((c, j) => `${at}.${quoteIdentifier(c)} = ${next}.${quoteIdentifier(theirs[j])}`);
|
|
370
|
+
holders.push({ key: step.key, holder: step.forward ? at : next });
|
|
371
|
+
if (bound.has(next))
|
|
372
|
+
closing.push(...on);
|
|
373
|
+
else
|
|
374
|
+
addTable(end, next, on);
|
|
375
|
+
at = next;
|
|
376
|
+
});
|
|
377
|
+
used.push(holders);
|
|
378
|
+
left = right;
|
|
379
|
+
});
|
|
380
|
+
};
|
|
381
|
+
for (const pattern of query.patterns)
|
|
382
|
+
connect(pattern);
|
|
383
|
+
// A relationship is used once in a pattern: two that follow the same keys must differ in at least one
|
|
384
|
+
// of the rows holding them (a chain of keys is one relationship, so it may share some of its rows).
|
|
385
|
+
const distinct = [];
|
|
386
|
+
const holdersByKey = (holders) => {
|
|
387
|
+
const byKey = new Map(holders.map(({ key, holder }) => [foreignKeyId(key), holder]));
|
|
388
|
+
return byKey.size === holders.length ? byKey : null;
|
|
389
|
+
};
|
|
390
|
+
for (let i = 0; i < used.length; i++) {
|
|
391
|
+
for (let j = i + 1; j < used.length; j++) {
|
|
392
|
+
const [a, b] = [holdersByKey(used[i]), holdersByKey(used[j])];
|
|
393
|
+
if (!a || !b || a.size !== b.size || [...a.keys()].some((id) => !b.has(id)))
|
|
394
|
+
continue;
|
|
395
|
+
const apart = [
|
|
396
|
+
...new Set([...a].flatMap(([id, holder]) => holder === b.get(id) ? [] : [`${holder}.ctid <> ${b.get(id)}.ctid`])),
|
|
397
|
+
];
|
|
398
|
+
distinct.push(apart.length === 0 ? 'FALSE' : apart.length === 1 ? apart[0] : `(${apart.join(' OR ')})`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
function nodeText(binding) {
|
|
402
|
+
return `(${binding.anonymous ? '' : binding.name}:${shortName(model, binding.table.ref)})`;
|
|
403
|
+
}
|
|
404
|
+
function describeStep(step) {
|
|
405
|
+
const [a, b] = [step.key.fromColumns.join(', '), step.key.toColumns.join(', ')];
|
|
406
|
+
return `${shortName(model, step.key.from)}.${a} → ${shortName(model, step.key.to)}.${b} (${step.key.name})`;
|
|
407
|
+
}
|
|
408
|
+
// Expressions.
|
|
409
|
+
const lookup = (name, at) => {
|
|
410
|
+
const binding = scope.get(name);
|
|
411
|
+
if (!binding || binding.anonymous) {
|
|
412
|
+
throw new CypherError(`unknown variable ${name}${order.filter((b) => !b.anonymous).length > 0
|
|
413
|
+
? `; the pattern has ${order
|
|
414
|
+
.filter((b) => !b.anonymous)
|
|
415
|
+
.map((b) => b.name)
|
|
416
|
+
.join(', ')}`
|
|
417
|
+
: ''}`, at);
|
|
418
|
+
}
|
|
419
|
+
return binding;
|
|
420
|
+
};
|
|
421
|
+
const nodeEquality = (a, b, negate) => {
|
|
422
|
+
if (!sameTable(a.table.ref, b.table.ref)) {
|
|
423
|
+
throw new CypherError(`${a.name} (${shortName(model, a.table.ref)}) and ${b.name} (${shortName(model, b.table.ref)}) are rows of different tables, so they cannot be compared`);
|
|
424
|
+
}
|
|
425
|
+
const parts = keyParts(a).map((part, i) => `${part} ${negate ? '<>' : '='} ${keyParts(b)[i]}`);
|
|
426
|
+
return parts.length === 1 ? parts[0] : `(${parts.join(negate ? ' OR ' : ' AND ')})`;
|
|
427
|
+
};
|
|
428
|
+
const sqlOf = (expr, emit) => {
|
|
429
|
+
// Text functions take text, whatever the column is; what already is text is left as it is.
|
|
430
|
+
const text = (e) => {
|
|
431
|
+
const sql = sqlOf(e, emit);
|
|
432
|
+
if (e.kind === 'literal' && typeof e.value === 'string') {
|
|
433
|
+
return sql.replace(/^(\$\d+)$/, '$1::text');
|
|
434
|
+
}
|
|
435
|
+
return e.kind === 'call' && ['lower', 'tolower', 'upper', 'toupper'].includes(e.name)
|
|
436
|
+
? sql
|
|
437
|
+
: `${sql}::text`;
|
|
438
|
+
};
|
|
439
|
+
switch (expr.kind) {
|
|
440
|
+
case 'literal':
|
|
441
|
+
return emit.value(expr.value);
|
|
442
|
+
case 'property':
|
|
443
|
+
return column(lookup(expr.variable, expr.at), expr.column, expr.at);
|
|
444
|
+
case 'variable':
|
|
445
|
+
lookup(expr.name, expr.at);
|
|
446
|
+
throw new CypherError(`${expr.name} is a whole row: compare it only with another row (${expr.name} <> other), or use one of its columns (${expr.name}.${scope.get(expr.name).columns[0]?.name ?? 'id'})`, expr.at);
|
|
447
|
+
case 'not':
|
|
448
|
+
return `NOT (${sqlOf(expr.operand, emit)})`;
|
|
449
|
+
case 'logic':
|
|
450
|
+
return `(${sqlOf(expr.left, emit)} ${expr.op.toUpperCase()} ${sqlOf(expr.right, emit)})`;
|
|
451
|
+
case 'null':
|
|
452
|
+
return `${sqlOf(expr.operand, emit)} IS ${expr.negated ? 'NOT ' : ''}NULL`;
|
|
453
|
+
case 'in':
|
|
454
|
+
return expr.list.length === 0
|
|
455
|
+
? 'FALSE'
|
|
456
|
+
: `${sqlOf(expr.operand, emit)} IN (${expr.list.map((item) => sqlOf(item, emit)).join(', ')})`;
|
|
457
|
+
case 'compare': {
|
|
458
|
+
const { left, right, op } = expr;
|
|
459
|
+
if (left.kind === 'variable' && right.kind === 'variable') {
|
|
460
|
+
if (op !== '=' && op !== '<>') {
|
|
461
|
+
throw new CypherError(`rows can only be compared with = and <>, not ${op}`, left.at);
|
|
462
|
+
}
|
|
463
|
+
return nodeEquality(lookup(left.name, left.at), lookup(right.name, right.at), op === '<>');
|
|
464
|
+
}
|
|
465
|
+
const l = sqlOf(left, emit);
|
|
466
|
+
const compare = (o) => {
|
|
467
|
+
switch (o) {
|
|
468
|
+
case 'starts':
|
|
469
|
+
return `starts_with(${text(left)}, ${text(right)})`;
|
|
470
|
+
case 'ends':
|
|
471
|
+
return `right(${text(left)}, char_length(${text(right)})) = ${text(right)}`;
|
|
472
|
+
case 'contains':
|
|
473
|
+
return `strpos(${text(left)}, ${text(right)}) > 0`;
|
|
474
|
+
case '=~':
|
|
475
|
+
return `${text(left)} ~ ('^(?:' || ${text(right)} || ')$')`;
|
|
476
|
+
default:
|
|
477
|
+
return `${l} ${o} ${sqlOf(right, emit)}`;
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
return compare(op);
|
|
481
|
+
}
|
|
482
|
+
case 'call': {
|
|
483
|
+
if (AGGREGATES.includes(expr.name))
|
|
484
|
+
return aggregate(expr, emit);
|
|
485
|
+
const args = expr.args.map((arg) => sqlOf(arg, emit));
|
|
486
|
+
switch (expr.name) {
|
|
487
|
+
case 'lower':
|
|
488
|
+
case 'tolower':
|
|
489
|
+
return `lower(${text(expr.args[0])})`;
|
|
490
|
+
case 'upper':
|
|
491
|
+
case 'toupper':
|
|
492
|
+
return `upper(${text(expr.args[0])})`;
|
|
493
|
+
case 'abs':
|
|
494
|
+
return `abs(${args[0]})`;
|
|
495
|
+
default:
|
|
496
|
+
return `coalesce(${args.join(', ')})`;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
const aggregate = (call, emit) => {
|
|
502
|
+
if (call.star)
|
|
503
|
+
return 'count(*)';
|
|
504
|
+
const argument = call.args[0];
|
|
505
|
+
const modifier = call.distinct ? 'DISTINCT ' : '';
|
|
506
|
+
if (argument.kind === 'variable') {
|
|
507
|
+
const binding = lookup(argument.name, argument.at);
|
|
508
|
+
if (call.name === 'collect')
|
|
509
|
+
return `jsonb_agg(${modifier}to_jsonb(${binding.alias}))`;
|
|
510
|
+
if (call.name !== 'count') {
|
|
511
|
+
throw new CypherError(`${call.name}() needs a column, e.g. ${call.name}(${argument.name}.${binding.columns[0]?.name ?? 'id'})`, call.at);
|
|
512
|
+
}
|
|
513
|
+
const parts = keyParts(binding);
|
|
514
|
+
return `count(${modifier}${parts.length === 1 ? parts[0] : `ROW(${parts.join(', ')})`})`;
|
|
515
|
+
}
|
|
516
|
+
const sql = sqlOf(argument, emit);
|
|
517
|
+
return call.name === 'collect'
|
|
518
|
+
? `array_agg(${modifier}${sql})`
|
|
519
|
+
: `${call.name}(${modifier}${sql})`;
|
|
520
|
+
};
|
|
521
|
+
// WHERE and the properties written in the nodes.
|
|
522
|
+
const conditions = (emit) => {
|
|
523
|
+
const out = [...closing, ...distinct];
|
|
524
|
+
for (const filter of nodeFilters) {
|
|
525
|
+
const sql = column(filter.binding, filter.column, filter.at);
|
|
526
|
+
out.push(filter.value === null ? `${sql} IS NULL` : `${sql} = ${emit.value(filter.value)}`);
|
|
527
|
+
}
|
|
528
|
+
for (const where of query.where)
|
|
529
|
+
out.push(sqlOf(where, emit));
|
|
530
|
+
return out;
|
|
531
|
+
};
|
|
532
|
+
// Properties of nodes: those of unnamed nodes are matched to the binding made for them, in order.
|
|
533
|
+
const nodeFilters = [];
|
|
534
|
+
{
|
|
535
|
+
let unnamed = 0;
|
|
536
|
+
const anonymousBindings = order.filter((b) => b.anonymous);
|
|
537
|
+
for (const pattern of query.patterns) {
|
|
538
|
+
for (const node of pattern.nodes) {
|
|
539
|
+
const binding = node.variable ? scope.get(node.variable) : anonymousBindings[unnamed++];
|
|
540
|
+
for (const property of node.properties) {
|
|
541
|
+
nodeFilters.push({
|
|
542
|
+
binding,
|
|
543
|
+
column: property.column,
|
|
544
|
+
value: property.value,
|
|
545
|
+
at: property.at,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// RETURN.
|
|
552
|
+
const items = query.returns ??
|
|
553
|
+
order
|
|
554
|
+
.filter((b) => !b.anonymous)
|
|
555
|
+
.map((b) => ({ expr: { kind: 'variable', name: b.name, at: 0 }, text: b.name }));
|
|
556
|
+
if (items.length === 0)
|
|
557
|
+
throw new CypherError('RETURN * needs named nodes, e.g. (c:Customer)');
|
|
558
|
+
const aggregated = items.some((item) => hasAggregate(item.expr));
|
|
559
|
+
const columns = items.map((item) => {
|
|
560
|
+
const name = item.alias ?? item.text;
|
|
561
|
+
if (item.expr.kind === 'variable') {
|
|
562
|
+
const binding = lookup(item.expr.name, item.expr.at);
|
|
563
|
+
return {
|
|
564
|
+
name,
|
|
565
|
+
kind: 'node',
|
|
566
|
+
variable: binding.name,
|
|
567
|
+
table: binding.table.ref,
|
|
568
|
+
aggregate: false,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
return { name, kind: 'value', aggregate: hasAggregate(item.expr) };
|
|
572
|
+
});
|
|
573
|
+
const selectOf = (item, emit) => item.expr.kind === 'variable'
|
|
574
|
+
? `to_jsonb(${lookup(item.expr.name, item.expr.at).alias})`
|
|
575
|
+
: sqlOf(item.expr, emit);
|
|
576
|
+
const groupBy = (emit) => {
|
|
577
|
+
const out = [];
|
|
578
|
+
for (const item of items) {
|
|
579
|
+
if (hasAggregate(item.expr))
|
|
580
|
+
continue;
|
|
581
|
+
if (item.expr.kind === 'variable') {
|
|
582
|
+
const binding = lookup(item.expr.name, item.expr.at);
|
|
583
|
+
const primary = primaryKeyOf(binding.columns);
|
|
584
|
+
out.push(...(primary.length > 0 ? keyParts(binding) : [`to_jsonb(${binding.alias})`]));
|
|
585
|
+
}
|
|
586
|
+
else if (item.expr.kind !== 'literal')
|
|
587
|
+
out.push(sqlOf(item.expr, emit));
|
|
588
|
+
}
|
|
589
|
+
return out;
|
|
590
|
+
};
|
|
591
|
+
const orderTerm = (term, emit) => {
|
|
592
|
+
const named = items.findIndex((item) => (item.alias ?? item.text) === term.text ||
|
|
593
|
+
(term.expr.kind === 'variable' && item.alias === term.expr.name));
|
|
594
|
+
const sql = named >= 0 ? quoteIdentifier(columns[named].name) : orderExpr(term.expr, emit);
|
|
595
|
+
return `${sql}${term.descending ? ' DESC' : ''}`;
|
|
596
|
+
};
|
|
597
|
+
const orderExpr = (expr, emit) => {
|
|
598
|
+
if (expr.kind === 'variable') {
|
|
599
|
+
const binding = lookup(expr.name, expr.at);
|
|
600
|
+
return keyParts(binding).join(', ');
|
|
601
|
+
}
|
|
602
|
+
return sqlOf(expr, emit);
|
|
603
|
+
};
|
|
604
|
+
const limit = query.limit === undefined ? undefined : Math.min(query.limit, MAX_ROWS);
|
|
605
|
+
const rowCap = limit ?? DEFAULT_ROWS;
|
|
606
|
+
const build = (inline) => {
|
|
607
|
+
const emit = emitter(inline);
|
|
608
|
+
const select = items.map((item, i) => `${selectOf(item, emit)} AS ${quoteIdentifier(columns[i].name)}`);
|
|
609
|
+
const lines = [`SELECT ${query.distinct ? 'DISTINCT ' : ''}${select.join(',\n ')}`];
|
|
610
|
+
const [first, ...rest] = from;
|
|
611
|
+
lines.push(`FROM ${first.table} AS ${first.alias}`);
|
|
612
|
+
for (const entry of rest) {
|
|
613
|
+
lines.push(entry.on === null
|
|
614
|
+
? `CROSS JOIN ${entry.table} AS ${entry.alias}`
|
|
615
|
+
: `JOIN ${entry.table} AS ${entry.alias} ON ${entry.on.join(' AND ')}`);
|
|
616
|
+
}
|
|
617
|
+
const where = conditions(emit);
|
|
618
|
+
if (where.length > 0)
|
|
619
|
+
lines.push(`WHERE ${where.join('\n AND ')}`);
|
|
620
|
+
if (aggregated) {
|
|
621
|
+
const group = groupBy(emit);
|
|
622
|
+
if (group.length > 0)
|
|
623
|
+
lines.push(`GROUP BY ${group.join(', ')}`);
|
|
624
|
+
}
|
|
625
|
+
if (query.orderBy.length > 0)
|
|
626
|
+
lines.push(`ORDER BY ${query.orderBy.map((term) => orderTerm(term, emit)).join(', ')}`);
|
|
627
|
+
if (limit !== undefined)
|
|
628
|
+
lines.push(`LIMIT ${limit}`);
|
|
629
|
+
if (query.skip)
|
|
630
|
+
lines.push(`OFFSET ${query.skip}`);
|
|
631
|
+
return { text: lines.join('\n'), values: emit.values };
|
|
632
|
+
};
|
|
633
|
+
const param = build(false);
|
|
634
|
+
let display = build(true).text;
|
|
635
|
+
if (limit === undefined)
|
|
636
|
+
display += `\n-- only the first ${DEFAULT_ROWS} rows are read`;
|
|
637
|
+
// Parts of the pattern that nothing joins: variables linked by a relationship, or compared in WHERE.
|
|
638
|
+
const parent = new Map([...scope.keys()].map((name) => [name, name]));
|
|
639
|
+
const root = (name) => {
|
|
640
|
+
let at = name;
|
|
641
|
+
while (parent.get(at) !== at)
|
|
642
|
+
at = parent.get(at);
|
|
643
|
+
return at;
|
|
644
|
+
};
|
|
645
|
+
const join = (a, b) => parent.set(root(a), root(b));
|
|
646
|
+
for (const [a, b] of links)
|
|
647
|
+
join(a, b);
|
|
648
|
+
for (const where of query.where) {
|
|
649
|
+
const names = [...variablesIn(where)].filter((name) => scope.has(name));
|
|
650
|
+
for (const name of names.slice(1))
|
|
651
|
+
join(names[0], name);
|
|
652
|
+
}
|
|
653
|
+
const filteredAliases = new Set();
|
|
654
|
+
for (const filter of nodeFilters)
|
|
655
|
+
filteredAliases.add(filter.binding.alias);
|
|
656
|
+
for (const where of query.where) {
|
|
657
|
+
for (const name of variablesIn(where))
|
|
658
|
+
if (scope.has(name))
|
|
659
|
+
filteredAliases.add(scope.get(name).alias);
|
|
660
|
+
}
|
|
661
|
+
const facts = {
|
|
662
|
+
tables: [
|
|
663
|
+
...order.map((b) => ({
|
|
664
|
+
alias: b.alias,
|
|
665
|
+
table: b.table.ref,
|
|
666
|
+
estimatedRows: b.table.estimatedRows,
|
|
667
|
+
passThrough: false,
|
|
668
|
+
filtered: filteredAliases.has(b.alias),
|
|
669
|
+
})),
|
|
670
|
+
...hiddenTables.map((h) => ({
|
|
671
|
+
alias: h.alias,
|
|
672
|
+
table: h.table.ref,
|
|
673
|
+
estimatedRows: h.table.estimatedRows,
|
|
674
|
+
passThrough: true,
|
|
675
|
+
filtered: false,
|
|
676
|
+
})),
|
|
677
|
+
],
|
|
678
|
+
crossJoins: new Set([...scope.keys()].map(root)).size - 1,
|
|
679
|
+
aggregated,
|
|
680
|
+
ordered: query.orderBy.length > 0,
|
|
681
|
+
limit: limit ?? null,
|
|
682
|
+
};
|
|
683
|
+
return {
|
|
684
|
+
sql: param.text,
|
|
685
|
+
values: param.values,
|
|
686
|
+
display,
|
|
687
|
+
columns,
|
|
688
|
+
joins,
|
|
689
|
+
facts,
|
|
690
|
+
rowCap,
|
|
691
|
+
links,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
// Postgres's cost units are arbitrary, but about 10 000 is a few milliseconds' work on a typical server.
|
|
695
|
+
const COST_MEDIUM = 50_000;
|
|
696
|
+
const COST_HIGH = 1_000_000;
|
|
697
|
+
const ROWS_MEDIUM = 1_000_000;
|
|
698
|
+
const ROWS_HIGH = 20_000_000;
|
|
699
|
+
const BIG_TABLE = 100_000;
|
|
700
|
+
export function compact(n) {
|
|
701
|
+
if (n >= 1e9)
|
|
702
|
+
return `${(n / 1e9).toFixed(1)}B`;
|
|
703
|
+
if (n >= 1e6)
|
|
704
|
+
return `${(n / 1e6).toFixed(1)}M`;
|
|
705
|
+
if (n >= 1e3)
|
|
706
|
+
return `${(n / 1e3).toFixed(1)}k`;
|
|
707
|
+
return String(Math.round(n));
|
|
708
|
+
}
|
|
709
|
+
const maxJoinRows = (plan) => {
|
|
710
|
+
const own = /Join|Nested Loop/.test(plan['Node Type']) ? plan['Plan Rows'] : 0;
|
|
711
|
+
return Math.max(own, ...(plan.Plans ?? []).map(maxJoinRows));
|
|
712
|
+
};
|
|
713
|
+
export function assessComplexity(facts, plan) {
|
|
714
|
+
const warnings = [];
|
|
715
|
+
const passThrough = facts.tables.filter((t) => t.passThrough);
|
|
716
|
+
if (facts.crossJoins > 0) {
|
|
717
|
+
warnings.push({
|
|
718
|
+
level: 'high',
|
|
719
|
+
message: `The pattern has ${facts.crossJoins + 1} parts that nothing joins, so every row of one is paired with every row of the others (a cross join). Connect them with a relationship or a WHERE condition.`,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
// Many joins matter when the planner does not say they are cheap.
|
|
723
|
+
if (facts.tables.length >= 5 && (!plan || plan['Total Cost'] >= COST_MEDIUM / 2)) {
|
|
724
|
+
warnings.push({
|
|
725
|
+
level: facts.tables.length >= 9 ? 'high' : 'medium',
|
|
726
|
+
message: `Joins ${facts.tables.length} tables${passThrough.length > 0 ? `, ${passThrough.length} of them only to connect nodes (${[...new Set(passThrough.map((t) => t.table.name))].join(', ')})` : ''}.`,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
const biggest = Math.max(0, ...facts.tables.map((t) => t.estimatedRows ?? 0));
|
|
730
|
+
if ((facts.aggregated || facts.ordered) &&
|
|
731
|
+
biggest >= BIG_TABLE &&
|
|
732
|
+
!facts.tables.some((t) => t.filtered)) {
|
|
733
|
+
warnings.push({
|
|
734
|
+
level: 'medium',
|
|
735
|
+
message: `${facts.aggregated ? 'Grouping' : 'Sorting'} needs every matching row before LIMIT can cut the result, and nothing narrows the tables (the largest has about ${compact(biggest)} rows). Filter with WHERE or a {property: value} on a node.`,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
let cost = null;
|
|
739
|
+
let joinRows = null;
|
|
740
|
+
if (plan) {
|
|
741
|
+
cost = plan['Total Cost'];
|
|
742
|
+
joinRows = maxJoinRows(plan);
|
|
743
|
+
if (cost >= COST_MEDIUM) {
|
|
744
|
+
warnings.push({
|
|
745
|
+
level: cost >= COST_HIGH ? 'high' : 'medium',
|
|
746
|
+
message: `Postgres estimates a cost of ${compact(cost)}${cost >= COST_HIGH ? ', which can take a long time' : ', which may take a few seconds'}.`,
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
if (joinRows >= ROWS_MEDIUM) {
|
|
750
|
+
warnings.push({
|
|
751
|
+
level: joinRows >= ROWS_HIGH ? 'high' : 'medium',
|
|
752
|
+
message: `A join in the plan is expected to produce about ${compact(joinRows)} rows.`,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const level = warnings.some((w) => w.level === 'high')
|
|
757
|
+
? 'high'
|
|
758
|
+
: warnings.length > 0
|
|
759
|
+
? 'medium'
|
|
760
|
+
: 'low';
|
|
761
|
+
return {
|
|
762
|
+
level,
|
|
763
|
+
warnings,
|
|
764
|
+
cost,
|
|
765
|
+
joinRows,
|
|
766
|
+
tables: facts.tables.length,
|
|
767
|
+
passThrough: passThrough.length,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
const TITLE_COLUMNS = [
|
|
771
|
+
'name',
|
|
772
|
+
'title',
|
|
773
|
+
'label',
|
|
774
|
+
'full_name',
|
|
775
|
+
'display_name',
|
|
776
|
+
'username',
|
|
777
|
+
'email',
|
|
778
|
+
'sku',
|
|
779
|
+
'code',
|
|
780
|
+
'slug',
|
|
781
|
+
'description',
|
|
782
|
+
];
|
|
783
|
+
const cellOf = (value) => value === null || value === undefined
|
|
784
|
+
? null
|
|
785
|
+
: typeof value === 'object'
|
|
786
|
+
? JSON.stringify(value)
|
|
787
|
+
: String(value);
|
|
788
|
+
/** The node a returned row (as JSON) is: its id from the primary key, and a label to show it by. */
|
|
789
|
+
function identify(table, cell, model) {
|
|
790
|
+
const parsed = JSON.parse(cell);
|
|
791
|
+
const data = {};
|
|
792
|
+
for (const [name, value] of Object.entries(parsed))
|
|
793
|
+
data[name] = cellOf(value);
|
|
794
|
+
const columns = model.columns.get(tableLabel(table)) ?? [];
|
|
795
|
+
const primary = primaryKeyOf(columns);
|
|
796
|
+
const key = {};
|
|
797
|
+
let id;
|
|
798
|
+
if (primary.length > 0) {
|
|
799
|
+
for (const name of primary)
|
|
800
|
+
key[name] = data[name] ?? null;
|
|
801
|
+
id = nodeId(table, primary.map((name) => data[name] ?? ''));
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
id = nodeId(table, `~${createHash('sha1').update(cell).digest('hex').slice(0, 12)}`);
|
|
805
|
+
}
|
|
806
|
+
const title = TITLE_COLUMNS.map((name) => columns.find((c) => c.name === name)).find((c) => c && /char|text/.test(c.type) && data[c.name])?.name;
|
|
807
|
+
return { id, table, label: title ? data[title] : keyLabel(key), key, row: data };
|
|
808
|
+
}
|
|
809
|
+
/** The graph of a result: a node for each row returned, linked as the pattern links them. */
|
|
810
|
+
export function graphOf(compiled, rows, model) {
|
|
811
|
+
const nodes = new Map();
|
|
812
|
+
const edges = new Map();
|
|
813
|
+
const nodeColumns = compiled.columns.flatMap((column, index) => column.kind === 'node' ? [{ column, index }] : []);
|
|
814
|
+
const weightAt = compiled.columns.findIndex((column) => column.kind === 'value' && column.aggregate);
|
|
815
|
+
const returned = new Set(nodeColumns.map(({ column }) => column.variable));
|
|
816
|
+
// Which returned variables are linked, through variables that are not returned.
|
|
817
|
+
const near = new Map();
|
|
818
|
+
for (const [a, b] of compiled.links) {
|
|
819
|
+
if (!near.has(a))
|
|
820
|
+
near.set(a, new Set());
|
|
821
|
+
if (!near.has(b))
|
|
822
|
+
near.set(b, new Set());
|
|
823
|
+
near.get(a).add(b);
|
|
824
|
+
near.get(b).add(a);
|
|
825
|
+
}
|
|
826
|
+
const pairs = [];
|
|
827
|
+
for (const start of returned) {
|
|
828
|
+
const seen = new Set([start]);
|
|
829
|
+
const queue = [start];
|
|
830
|
+
while (queue.length > 0) {
|
|
831
|
+
for (const next of near.get(queue.shift()) ?? []) {
|
|
832
|
+
if (seen.has(next))
|
|
833
|
+
continue;
|
|
834
|
+
seen.add(next);
|
|
835
|
+
if (returned.has(next)) {
|
|
836
|
+
if (start < next)
|
|
837
|
+
pairs.push([start, next]);
|
|
838
|
+
}
|
|
839
|
+
else
|
|
840
|
+
queue.push(next);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
for (const row of rows) {
|
|
845
|
+
const ids = new Map();
|
|
846
|
+
for (const { column, index } of nodeColumns) {
|
|
847
|
+
const cell = row[index];
|
|
848
|
+
if (cell === null || cell === undefined)
|
|
849
|
+
continue;
|
|
850
|
+
const node = identify(column.table, cell, model);
|
|
851
|
+
// The unmatched side of an outer join is a row of nothing but nulls, not a row.
|
|
852
|
+
if (Object.values(node.row).every((value) => value === null))
|
|
853
|
+
continue;
|
|
854
|
+
ids.set(column.variable, node.id);
|
|
855
|
+
if (!nodes.has(node.id))
|
|
856
|
+
nodes.set(node.id, node);
|
|
857
|
+
}
|
|
858
|
+
for (const [a, b] of pairs) {
|
|
859
|
+
const [from, to] = [ids.get(a), ids.get(b)];
|
|
860
|
+
if (!from || !to || from === to)
|
|
861
|
+
continue;
|
|
862
|
+
const [low, high] = from < to ? [from, to] : [to, from];
|
|
863
|
+
const id = `edge:${low}|${high}`;
|
|
864
|
+
if (!edges.has(id))
|
|
865
|
+
edges.set(id, {
|
|
866
|
+
id,
|
|
867
|
+
from: low,
|
|
868
|
+
to: high,
|
|
869
|
+
weight: weightAt >= 0 ? (row[weightAt] ?? null) : null,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return { nodes: [...nodes.values()], edges: [...edges.values()] };
|
|
874
|
+
}
|
|
875
|
+
export function parseCypherRequest(body) {
|
|
876
|
+
const entry = body;
|
|
877
|
+
if (typeof entry?.query !== 'string')
|
|
878
|
+
throw new CypherError('send {"query", "run"?, "force"?}');
|
|
879
|
+
if (entry.query.length > 20_000)
|
|
880
|
+
throw new CypherError('the query is too long');
|
|
881
|
+
return { query: entry.query, run: entry.run === true, force: entry.force === true };
|
|
882
|
+
}
|
|
883
|
+
const readPlan = async (session, sql, values) => {
|
|
884
|
+
const [row] = await session.rows(`EXPLAIN (FORMAT JSON) ${sql}`, values);
|
|
885
|
+
const raw = row?.['QUERY PLAN'];
|
|
886
|
+
const plan = (typeof raw === 'string' ? JSON.parse(raw) : raw);
|
|
887
|
+
if (!plan?.[0]?.Plan)
|
|
888
|
+
throw new Error('Postgres did not return a plan');
|
|
889
|
+
return plan[0].Plan;
|
|
890
|
+
};
|
|
891
|
+
/**
|
|
892
|
+
* Translates a query, has Postgres plan it (which runs nothing), and, when asked, runs it: in a read only
|
|
893
|
+
* transaction with a time limit, unless the plan is judged heavy and `force` is not set.
|
|
894
|
+
*/
|
|
895
|
+
export async function runCypher(session, request) {
|
|
896
|
+
const query = parseCypher(request.query);
|
|
897
|
+
const model = await loadCypherModel(session, query);
|
|
898
|
+
const compiled = compileCypher(query, model);
|
|
899
|
+
const plan = await readPlan(session, compiled.sql, compiled.values).catch((error) => {
|
|
900
|
+
throw new CypherError(`Postgres cannot plan the generated SQL: ${describeQueryError(error)}`);
|
|
901
|
+
});
|
|
902
|
+
const complexity = assessComplexity(compiled.facts, plan);
|
|
903
|
+
const base = {
|
|
904
|
+
sql: compiled.display,
|
|
905
|
+
joins: compiled.joins,
|
|
906
|
+
complexity,
|
|
907
|
+
rowCap: compiled.rowCap,
|
|
908
|
+
};
|
|
909
|
+
const columns = compiled.columns.map((column) => ({
|
|
910
|
+
name: column.name,
|
|
911
|
+
type: column.kind === 'node' ? 'row' : '',
|
|
912
|
+
node: column.kind === 'node',
|
|
913
|
+
}));
|
|
914
|
+
if (!request.run || (complexity.level === 'high' && !request.force)) {
|
|
915
|
+
return {
|
|
916
|
+
...base,
|
|
917
|
+
ran: false,
|
|
918
|
+
columns,
|
|
919
|
+
rows: [],
|
|
920
|
+
truncated: false,
|
|
921
|
+
nodes: [],
|
|
922
|
+
edges: [],
|
|
923
|
+
durationMs: null,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
const timeout = Math.floor(request.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
927
|
+
await session.run('BEGIN READ ONLY');
|
|
928
|
+
let result;
|
|
929
|
+
try {
|
|
930
|
+
await session.run(`SET LOCAL statement_timeout = ${timeout}`);
|
|
931
|
+
result = await session.run(compiled.sql, compiled.values, { maxRows: compiled.rowCap });
|
|
932
|
+
}
|
|
933
|
+
catch (error) {
|
|
934
|
+
if (error?.code === '57014') {
|
|
935
|
+
throw new CypherError(`The query was stopped after ${Math.round(timeout / 1000)} s. Narrow it with WHERE or a {property: value} on a node, or add a LIMIT.`);
|
|
936
|
+
}
|
|
937
|
+
throw new CypherError(describeQueryError(error));
|
|
938
|
+
}
|
|
939
|
+
finally {
|
|
940
|
+
await session.run('ROLLBACK').catch(() => undefined);
|
|
941
|
+
}
|
|
942
|
+
const graph = graphOf(compiled, result.rows, model);
|
|
943
|
+
return {
|
|
944
|
+
...base,
|
|
945
|
+
ran: true,
|
|
946
|
+
columns: result.columns.map((column, i) => ({
|
|
947
|
+
name: compiled.columns[i]?.name ?? column.name,
|
|
948
|
+
type: compiled.columns[i]?.kind === 'node' ? 'row' : column.type,
|
|
949
|
+
node: compiled.columns[i]?.kind === 'node',
|
|
950
|
+
})),
|
|
951
|
+
// A node is shown in the table by its label.
|
|
952
|
+
rows: result.rows.map((row) => row.map((cell, i) => {
|
|
953
|
+
const column = compiled.columns[i];
|
|
954
|
+
return cell !== null && column?.kind === 'node'
|
|
955
|
+
? identify(column.table, cell, model).label
|
|
956
|
+
: cell;
|
|
957
|
+
})),
|
|
958
|
+
truncated: result.truncated,
|
|
959
|
+
nodes: graph.nodes,
|
|
960
|
+
edges: graph.edges,
|
|
961
|
+
durationMs: result.durationMs,
|
|
962
|
+
};
|
|
963
|
+
}
|