@kazurolabs/psql-explorer 0.1.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +229 -18
- package/RELEASES.md +95 -0
- package/dist/cli.js +57 -13
- package/dist/commands/configgen.js +25 -25
- package/dist/commands/explore.js +86 -4
- package/dist/config/load.js +11 -73
- package/dist/config/resolve-environment.js +10 -62
- package/dist/config/resolvers/index.js +1 -1
- package/dist/config/resolvers/prompt.js +2 -2
- package/dist/config/resolvers/vault.js +1 -83
- package/dist/db/store.js +57 -0
- package/dist/environments.js +5 -2
- package/dist/explorer/alter.js +493 -0
- package/dist/explorer/catalog.js +31 -0
- package/dist/explorer/connect-entry.js +29 -18
- package/dist/explorer/cypher-parse.js +478 -0
- package/dist/explorer/cypher.js +963 -0
- package/dist/explorer/ddl.js +502 -0
- package/dist/explorer/dependents.js +222 -0
- package/dist/explorer/design-load.js +254 -0
- package/dist/explorer/design.js +45 -0
- package/dist/explorer/edit.js +108 -0
- package/dist/explorer/explain.js +44 -3
- package/dist/explorer/filter.js +121 -0
- package/dist/explorer/graph.js +679 -0
- package/dist/explorer/gucs.js +31 -0
- package/dist/explorer/profile.js +102 -0
- package/dist/explorer/references.js +95 -0
- package/dist/explorer/rows.js +100 -13
- package/dist/explorer/schema-diff.js +360 -0
- package/dist/explorer/search.js +309 -0
- package/dist/explorer/security.js +152 -0
- package/dist/explorer/seed.js +1376 -0
- package/dist/explorer/session.js +25 -1
- package/dist/explorer/stats.js +135 -0
- package/dist/explorer/timeout.js +13 -0
- package/dist/screens/environments.js +6 -3
- package/dist/screens/explorer.js +0 -3
- package/dist/screens/splash.js +4 -2
- package/dist/web/server.js +655 -22
- package/package.json +4 -1
- package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
- package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
- package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
- package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
- package/web/dist/assets/button-BiFx4Lyw.js +1 -0
- package/web/dist/assets/code-xml-CPEjNFOw.js +1 -0
- package/web/dist/assets/compare-view-COQ3yE76.js +2 -0
- package/web/dist/assets/copy-C2N9tUYJ.js +1 -0
- package/web/dist/assets/cypher-view-BzyL4mNU.js +13 -0
- package/web/dist/assets/design-view-BPm7dSTY.js +9 -0
- package/web/dist/assets/erd-view-DqGDws2_.js +1 -0
- package/web/dist/assets/format-sql-CoddYUOF.js +5 -0
- package/web/dist/assets/graph-force-ChCGXQUt.js +1 -0
- package/web/dist/assets/graph-link-C8P9s_0W.js +1 -0
- package/web/dist/assets/graph-view-BpoUrUCP.js +3 -0
- package/web/dist/assets/index-CpHnjRLv.js +10 -0
- package/web/dist/assets/index-OzJqnJWG.css +2 -0
- package/web/dist/assets/key-round-JiGCgCWS.js +1 -0
- package/web/dist/assets/loader-circle-BxDJOoJO.js +1 -0
- package/web/dist/assets/query-builder-view-CtRexMEX.js +4 -0
- package/web/dist/assets/rotate-ccw-CwdA-J3t.js +1 -0
- package/web/dist/assets/route-7eu-iAqD.js +15 -0
- package/web/dist/assets/seed-view-BIkBTiU6.js +7 -0
- package/web/dist/assets/shield-alert-keJIAND5.js +1 -0
- package/web/dist/assets/sliders-horizontal-DKtL_ql7.js +1 -0
- package/web/dist/assets/sql-tabs-DUSXdYCr.js +2 -0
- package/web/dist/assets/table-Drc_n1gF.js +1 -0
- package/web/dist/assets/triangle-alert-C5nF69rW.js +1 -0
- package/web/dist/assets/zone-CHDOrDBF.js +5 -0
- package/web/dist/index.html +9 -2
- package/web/dist/assets/index-BMLFCtFY.js +0 -17
- package/web/dist/assets/index-CMXJRL9O.css +0 -2
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
// A small, read only subset of Cypher (the query language of Neo4j): MATCH patterns, WHERE, and
|
|
2
|
+
// RETURN with aggregates, ORDER BY, SKIP and LIMIT. This turns the text into a syntax tree; cypher.ts
|
|
3
|
+
// resolves it against a database's tables and foreign keys.
|
|
4
|
+
export class CypherError extends Error {
|
|
5
|
+
position;
|
|
6
|
+
constructor(message,
|
|
7
|
+
// Where in the query it went wrong, as a character offset.
|
|
8
|
+
position) {
|
|
9
|
+
super(position === undefined ? message : `${message} (at character ${position + 1})`);
|
|
10
|
+
this.position = position;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export const AGGREGATES = ['count', 'sum', 'avg', 'min', 'max', 'collect'];
|
|
14
|
+
export const SCALARS = [
|
|
15
|
+
'lower',
|
|
16
|
+
'tolower',
|
|
17
|
+
'upper',
|
|
18
|
+
'toupper',
|
|
19
|
+
'coalesce',
|
|
20
|
+
'abs',
|
|
21
|
+
];
|
|
22
|
+
const TWO = ['<>', '<=', '>=', '=~', '!='];
|
|
23
|
+
const ONE = '()[]{},:.-<>=*|;';
|
|
24
|
+
function tokenize(source) {
|
|
25
|
+
const tokens = [];
|
|
26
|
+
let i = 0;
|
|
27
|
+
while (i < source.length) {
|
|
28
|
+
const char = source[i];
|
|
29
|
+
if (/\s/.test(char)) {
|
|
30
|
+
i++;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (source.startsWith('//', i)) {
|
|
34
|
+
while (i < source.length && source[i] !== '\n')
|
|
35
|
+
i++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (source.startsWith('/*', i)) {
|
|
39
|
+
const close = source.indexOf('*/', i + 2);
|
|
40
|
+
if (close < 0)
|
|
41
|
+
throw new CypherError('this comment is never closed', i);
|
|
42
|
+
i = close + 2;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const at = i;
|
|
46
|
+
if (char === "'" || char === '"') {
|
|
47
|
+
let value = '';
|
|
48
|
+
i++;
|
|
49
|
+
for (;;) {
|
|
50
|
+
const next = source[i];
|
|
51
|
+
if (next === undefined)
|
|
52
|
+
throw new CypherError('this string is never closed', at);
|
|
53
|
+
i++;
|
|
54
|
+
if (next === char) {
|
|
55
|
+
// A doubled quote is a quote, as in SQL.
|
|
56
|
+
if (source[i] !== char)
|
|
57
|
+
break;
|
|
58
|
+
value += char;
|
|
59
|
+
i++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (next === '\\') {
|
|
63
|
+
const escaped = source[i++];
|
|
64
|
+
if (escaped === undefined)
|
|
65
|
+
throw new CypherError('this string is never closed', at);
|
|
66
|
+
value += escaped === 'n' ? '\n' : escaped === 't' ? '\t' : escaped;
|
|
67
|
+
}
|
|
68
|
+
else
|
|
69
|
+
value += next;
|
|
70
|
+
}
|
|
71
|
+
tokens.push({ kind: 'string', text: source.slice(at, i), value, at, end: i });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (char === '`') {
|
|
75
|
+
let value = '';
|
|
76
|
+
i++;
|
|
77
|
+
for (;;) {
|
|
78
|
+
const next = source[i];
|
|
79
|
+
if (next === undefined)
|
|
80
|
+
throw new CypherError('this name is never closed', at);
|
|
81
|
+
i++;
|
|
82
|
+
if (next === '`') {
|
|
83
|
+
if (source[i] === '`') {
|
|
84
|
+
value += '`';
|
|
85
|
+
i++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
value += next;
|
|
91
|
+
}
|
|
92
|
+
if (!value)
|
|
93
|
+
throw new CypherError('a name in backticks cannot be empty', at);
|
|
94
|
+
tokens.push({ kind: 'quoted', text: value, at, end: i });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const number = /^\d+(\.\d+)?([eE][+-]?\d+)?/.exec(source.slice(i));
|
|
98
|
+
if (number) {
|
|
99
|
+
i += number[0].length;
|
|
100
|
+
tokens.push({ kind: 'number', text: number[0], value: Number(number[0]), at, end: i });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const word = /^[\p{L}_][\p{L}\p{N}_]*/u.exec(source.slice(i));
|
|
104
|
+
if (word) {
|
|
105
|
+
i += word[0].length;
|
|
106
|
+
tokens.push({ kind: 'word', text: word[0], at, end: i });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const pair = source.slice(i, i + 2);
|
|
110
|
+
if (TWO.includes(pair)) {
|
|
111
|
+
i += 2;
|
|
112
|
+
tokens.push({ kind: 'punct', text: pair === '!=' ? '<>' : pair, at, end: i });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (ONE.includes(char)) {
|
|
116
|
+
i++;
|
|
117
|
+
tokens.push({ kind: 'punct', text: char, at, end: i });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (char === '$')
|
|
121
|
+
throw new CypherError('parameters ($name) are not supported', at);
|
|
122
|
+
throw new CypherError(`unexpected "${char}"`, at);
|
|
123
|
+
}
|
|
124
|
+
tokens.push({ kind: 'end', text: '', at: source.length, end: source.length });
|
|
125
|
+
return tokens;
|
|
126
|
+
}
|
|
127
|
+
const UNSUPPORTED_CLAUSES = {
|
|
128
|
+
optional: 'OPTIONAL MATCH',
|
|
129
|
+
with: 'WITH',
|
|
130
|
+
unwind: 'UNWIND',
|
|
131
|
+
union: 'UNION',
|
|
132
|
+
call: 'CALL',
|
|
133
|
+
create: 'CREATE',
|
|
134
|
+
merge: 'MERGE',
|
|
135
|
+
delete: 'DELETE',
|
|
136
|
+
detach: 'DELETE',
|
|
137
|
+
set: 'SET',
|
|
138
|
+
remove: 'REMOVE',
|
|
139
|
+
foreach: 'FOREACH',
|
|
140
|
+
};
|
|
141
|
+
class Parser {
|
|
142
|
+
source;
|
|
143
|
+
tokens;
|
|
144
|
+
at = 0;
|
|
145
|
+
constructor(source, tokens) {
|
|
146
|
+
this.source = source;
|
|
147
|
+
this.tokens = tokens;
|
|
148
|
+
}
|
|
149
|
+
get token() {
|
|
150
|
+
return this.tokens[this.at];
|
|
151
|
+
}
|
|
152
|
+
describe(token) {
|
|
153
|
+
return token.kind === 'end' ? 'the end of the query' : `"${token.text}"`;
|
|
154
|
+
}
|
|
155
|
+
fail(message, token = this.token) {
|
|
156
|
+
throw new CypherError(message, token.at);
|
|
157
|
+
}
|
|
158
|
+
word(...names) {
|
|
159
|
+
const token = this.token;
|
|
160
|
+
return token.kind === 'word' && names.includes(token.text.toLowerCase());
|
|
161
|
+
}
|
|
162
|
+
punct(text) {
|
|
163
|
+
return this.token.kind === 'punct' && this.token.text === text;
|
|
164
|
+
}
|
|
165
|
+
acceptWord(name) {
|
|
166
|
+
if (!this.word(name))
|
|
167
|
+
return false;
|
|
168
|
+
this.at++;
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
acceptPunct(text) {
|
|
172
|
+
if (!this.punct(text))
|
|
173
|
+
return false;
|
|
174
|
+
this.at++;
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
expectPunct(text, what = `"${text}"`) {
|
|
178
|
+
if (!this.acceptPunct(text))
|
|
179
|
+
this.fail(`expected ${what} but found ${this.describe(this.token)}`);
|
|
180
|
+
}
|
|
181
|
+
expectWord(name) {
|
|
182
|
+
if (!this.acceptWord(name)) {
|
|
183
|
+
this.fail(`expected ${name.toUpperCase()} but found ${this.describe(this.token)}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** A name: a word, or anything in backticks. */
|
|
187
|
+
name(what) {
|
|
188
|
+
const token = this.token;
|
|
189
|
+
if (token.kind !== 'word' && token.kind !== 'quoted') {
|
|
190
|
+
this.fail(`expected ${what} but found ${this.describe(token)}`);
|
|
191
|
+
}
|
|
192
|
+
this.at++;
|
|
193
|
+
return token.text;
|
|
194
|
+
}
|
|
195
|
+
integer(what) {
|
|
196
|
+
const token = this.token;
|
|
197
|
+
if (token.kind !== 'number' || !Number.isInteger(token.value)) {
|
|
198
|
+
this.fail(`${what} must be a whole number`);
|
|
199
|
+
}
|
|
200
|
+
this.at++;
|
|
201
|
+
return token.value;
|
|
202
|
+
}
|
|
203
|
+
parse() {
|
|
204
|
+
const patterns = [];
|
|
205
|
+
const where = [];
|
|
206
|
+
while (this.word('match')) {
|
|
207
|
+
this.at++;
|
|
208
|
+
do
|
|
209
|
+
patterns.push(this.pattern());
|
|
210
|
+
while (this.acceptPunct(','));
|
|
211
|
+
if (this.acceptWord('where'))
|
|
212
|
+
where.push(this.expression(false));
|
|
213
|
+
}
|
|
214
|
+
if (patterns.length === 0) {
|
|
215
|
+
const first = this.token;
|
|
216
|
+
const unsupported = first.kind === 'word' ? UNSUPPORTED_CLAUSES[first.text.toLowerCase()] : null;
|
|
217
|
+
if (unsupported)
|
|
218
|
+
this.fail(`${unsupported} is not supported: queries are read only, MATCH ... RETURN`);
|
|
219
|
+
this.fail(`a query starts with MATCH, not ${this.describe(first)}`);
|
|
220
|
+
}
|
|
221
|
+
const unsupported = this.token.kind === 'word' ? UNSUPPORTED_CLAUSES[this.token.text.toLowerCase()] : null;
|
|
222
|
+
if (unsupported)
|
|
223
|
+
this.fail(`${unsupported} is not supported: queries are read only, MATCH ... RETURN`);
|
|
224
|
+
if (!this.word('return'))
|
|
225
|
+
this.fail(`expected RETURN but found ${this.describe(this.token)}`);
|
|
226
|
+
this.at++;
|
|
227
|
+
const distinct = this.acceptWord('distinct');
|
|
228
|
+
let returns = null;
|
|
229
|
+
if (this.acceptPunct('*')) {
|
|
230
|
+
if (this.punct(','))
|
|
231
|
+
this.fail('RETURN * cannot be combined with other items');
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
returns = [];
|
|
235
|
+
do
|
|
236
|
+
returns.push(this.returnItem());
|
|
237
|
+
while (this.acceptPunct(','));
|
|
238
|
+
}
|
|
239
|
+
const orderBy = [];
|
|
240
|
+
if (this.acceptWord('order')) {
|
|
241
|
+
this.expectWord('by');
|
|
242
|
+
do {
|
|
243
|
+
const start = this.token;
|
|
244
|
+
const expr = this.expression(true);
|
|
245
|
+
const text = this.source.slice(start.at, this.tokens[this.at - 1].end);
|
|
246
|
+
const descending = this.acceptWord('desc') || this.acceptWord('descending');
|
|
247
|
+
if (!descending && !this.acceptWord('asc'))
|
|
248
|
+
this.acceptWord('ascending');
|
|
249
|
+
orderBy.push({ expr, text, descending });
|
|
250
|
+
} while (this.acceptPunct(','));
|
|
251
|
+
}
|
|
252
|
+
const skip = this.acceptWord('skip') ? this.integer('SKIP') : undefined;
|
|
253
|
+
const limit = this.acceptWord('limit') ? this.integer('LIMIT') : undefined;
|
|
254
|
+
this.acceptPunct(';');
|
|
255
|
+
if (this.token.kind !== 'end') {
|
|
256
|
+
const next = this.token;
|
|
257
|
+
const clause = next.kind === 'word' ? UNSUPPORTED_CLAUSES[next.text.toLowerCase()] : null;
|
|
258
|
+
this.fail(clause ? `${clause} is not supported` : `unexpected ${this.describe(next)}`);
|
|
259
|
+
}
|
|
260
|
+
return { patterns, where, distinct, returns, orderBy, skip, limit };
|
|
261
|
+
}
|
|
262
|
+
returnItem() {
|
|
263
|
+
const start = this.token;
|
|
264
|
+
const expr = this.expression(true);
|
|
265
|
+
const text = this.source.slice(start.at, this.tokens[this.at - 1].end);
|
|
266
|
+
const alias = this.acceptWord('as') ? this.name('a name after AS') : undefined;
|
|
267
|
+
return { expr, text, alias };
|
|
268
|
+
}
|
|
269
|
+
pattern() {
|
|
270
|
+
if (this.token.kind === 'word' && this.tokens[this.at + 1]?.text === '=') {
|
|
271
|
+
this.fail('naming a whole path (p = ...) is not supported');
|
|
272
|
+
}
|
|
273
|
+
const nodes = [this.nodePattern()];
|
|
274
|
+
const relationships = [];
|
|
275
|
+
while (this.punct('-') || this.punct('<')) {
|
|
276
|
+
relationships.push(this.relationship());
|
|
277
|
+
nodes.push(this.nodePattern());
|
|
278
|
+
}
|
|
279
|
+
return { nodes, relationships };
|
|
280
|
+
}
|
|
281
|
+
nodePattern() {
|
|
282
|
+
const at = this.token.at;
|
|
283
|
+
this.expectPunct('(', 'a node such as "(c:Customer)"');
|
|
284
|
+
let variable;
|
|
285
|
+
let label;
|
|
286
|
+
if (this.token.kind === 'word' || this.token.kind === 'quoted')
|
|
287
|
+
variable = this.name('a name');
|
|
288
|
+
if (this.acceptPunct(':')) {
|
|
289
|
+
label = this.name('a table name after ":"');
|
|
290
|
+
// schema.table
|
|
291
|
+
if (this.acceptPunct('.'))
|
|
292
|
+
label += `.${this.name('a table name')}`;
|
|
293
|
+
if (this.punct(':'))
|
|
294
|
+
this.fail('a node has one label: the table it is a row of');
|
|
295
|
+
}
|
|
296
|
+
const properties = [];
|
|
297
|
+
if (this.acceptPunct('{')) {
|
|
298
|
+
if (!this.punct('}')) {
|
|
299
|
+
do {
|
|
300
|
+
const keyAt = this.token.at;
|
|
301
|
+
const column = this.name('a column name');
|
|
302
|
+
this.expectPunct(':');
|
|
303
|
+
properties.push({ column, value: this.literal(), at: keyAt });
|
|
304
|
+
} while (this.acceptPunct(','));
|
|
305
|
+
}
|
|
306
|
+
this.expectPunct('}');
|
|
307
|
+
}
|
|
308
|
+
this.expectPunct(')', '")" to close the node');
|
|
309
|
+
return { variable, label, properties, at };
|
|
310
|
+
}
|
|
311
|
+
relationship() {
|
|
312
|
+
const at = this.token.at;
|
|
313
|
+
const left = this.acceptPunct('<');
|
|
314
|
+
this.expectPunct('-');
|
|
315
|
+
let hint;
|
|
316
|
+
if (this.acceptPunct('[')) {
|
|
317
|
+
if (this.token.kind === 'word' || this.token.kind === 'quoted') {
|
|
318
|
+
this.fail('a relationship cannot be named: it is a foreign key, not a row', this.token);
|
|
319
|
+
}
|
|
320
|
+
if (this.acceptPunct(':')) {
|
|
321
|
+
hint = this.name('a foreign key, column or table name after ":"');
|
|
322
|
+
if (this.punct('|') || this.punct(':')) {
|
|
323
|
+
this.fail('a relationship has one type: a foreign key, column or table name');
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (this.punct('*')) {
|
|
327
|
+
this.fail('paths of any length are not supported: write the nodes along the way');
|
|
328
|
+
}
|
|
329
|
+
if (this.punct('{'))
|
|
330
|
+
this.fail('relationships have no properties');
|
|
331
|
+
this.expectPunct(']', '"]" to close the relationship');
|
|
332
|
+
}
|
|
333
|
+
this.expectPunct('-');
|
|
334
|
+
const right = this.acceptPunct('>');
|
|
335
|
+
if (left && right)
|
|
336
|
+
this.fail('a relationship points one way: use <- or ->, not <->', this.tokens[this.at - 1]);
|
|
337
|
+
return { direction: left ? 'in' : right ? 'out' : 'any', hint, at };
|
|
338
|
+
}
|
|
339
|
+
literal() {
|
|
340
|
+
const negative = this.acceptPunct('-');
|
|
341
|
+
const token = this.token;
|
|
342
|
+
if (token.kind === 'number') {
|
|
343
|
+
this.at++;
|
|
344
|
+
return negative ? -token.value : token.value;
|
|
345
|
+
}
|
|
346
|
+
if (negative)
|
|
347
|
+
this.fail('expected a number after "-"');
|
|
348
|
+
if (token.kind === 'string') {
|
|
349
|
+
this.at++;
|
|
350
|
+
return token.value;
|
|
351
|
+
}
|
|
352
|
+
if (this.acceptWord('true'))
|
|
353
|
+
return true;
|
|
354
|
+
if (this.acceptWord('false'))
|
|
355
|
+
return false;
|
|
356
|
+
if (this.acceptWord('null'))
|
|
357
|
+
return null;
|
|
358
|
+
return this.fail(`expected a value (a string, number, true, false or null) but found ${this.describe(token)}`);
|
|
359
|
+
}
|
|
360
|
+
/** `aggregates` is whether a function such as count(...) may appear (in RETURN and ORDER BY). */
|
|
361
|
+
expression(aggregates) {
|
|
362
|
+
return this.or(aggregates);
|
|
363
|
+
}
|
|
364
|
+
or(aggregates) {
|
|
365
|
+
let left = this.and(aggregates);
|
|
366
|
+
while (this.acceptWord('or'))
|
|
367
|
+
left = { kind: 'logic', op: 'or', left, right: this.and(aggregates) };
|
|
368
|
+
return left;
|
|
369
|
+
}
|
|
370
|
+
and(aggregates) {
|
|
371
|
+
let left = this.not(aggregates);
|
|
372
|
+
while (this.acceptWord('and'))
|
|
373
|
+
left = { kind: 'logic', op: 'and', left, right: this.not(aggregates) };
|
|
374
|
+
return left;
|
|
375
|
+
}
|
|
376
|
+
not(aggregates) {
|
|
377
|
+
if (this.acceptWord('not'))
|
|
378
|
+
return { kind: 'not', operand: this.not(aggregates) };
|
|
379
|
+
return this.comparison(aggregates);
|
|
380
|
+
}
|
|
381
|
+
comparison(aggregates) {
|
|
382
|
+
const left = this.primary(aggregates);
|
|
383
|
+
const token = this.token;
|
|
384
|
+
if (token.kind === 'punct' && ['=', '<>', '<', '<=', '>', '>=', '=~'].includes(token.text)) {
|
|
385
|
+
this.at++;
|
|
386
|
+
return { kind: 'compare', op: token.text, left, right: this.primary(aggregates) };
|
|
387
|
+
}
|
|
388
|
+
if (this.acceptWord('is')) {
|
|
389
|
+
const negated = this.acceptWord('not');
|
|
390
|
+
this.expectWord('null');
|
|
391
|
+
return { kind: 'null', operand: left, negated };
|
|
392
|
+
}
|
|
393
|
+
if (this.acceptWord('in')) {
|
|
394
|
+
this.expectPunct('[', 'a list such as [1, 2]');
|
|
395
|
+
const list = [];
|
|
396
|
+
if (!this.punct(']')) {
|
|
397
|
+
do
|
|
398
|
+
list.push(this.primary(aggregates));
|
|
399
|
+
while (this.acceptPunct(','));
|
|
400
|
+
}
|
|
401
|
+
this.expectPunct(']');
|
|
402
|
+
return { kind: 'in', operand: left, list };
|
|
403
|
+
}
|
|
404
|
+
if (this.word('starts', 'ends')) {
|
|
405
|
+
const op = this.token.text.toLowerCase() === 'starts' ? 'starts' : 'ends';
|
|
406
|
+
this.at++;
|
|
407
|
+
this.expectWord('with');
|
|
408
|
+
return { kind: 'compare', op, left, right: this.primary(aggregates) };
|
|
409
|
+
}
|
|
410
|
+
if (this.acceptWord('contains'))
|
|
411
|
+
return { kind: 'compare', op: 'contains', left, right: this.primary(aggregates) };
|
|
412
|
+
return left;
|
|
413
|
+
}
|
|
414
|
+
primary(aggregates) {
|
|
415
|
+
const token = this.token;
|
|
416
|
+
if (this.acceptPunct('(')) {
|
|
417
|
+
const inner = this.expression(aggregates);
|
|
418
|
+
this.expectPunct(')');
|
|
419
|
+
return inner;
|
|
420
|
+
}
|
|
421
|
+
if (token.kind === 'string' || token.kind === 'number' || this.punct('-')) {
|
|
422
|
+
return { kind: 'literal', value: this.literal() };
|
|
423
|
+
}
|
|
424
|
+
if (token.kind === 'word') {
|
|
425
|
+
const lower = token.text.toLowerCase();
|
|
426
|
+
if (lower === 'true' || lower === 'false' || lower === 'null') {
|
|
427
|
+
return { kind: 'literal', value: this.literal() };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (token.kind === 'word' || token.kind === 'quoted') {
|
|
431
|
+
this.at++;
|
|
432
|
+
if (token.kind === 'word' && this.punct('('))
|
|
433
|
+
return this.call(token, aggregates);
|
|
434
|
+
if (this.acceptPunct('.')) {
|
|
435
|
+
return {
|
|
436
|
+
kind: 'property',
|
|
437
|
+
variable: token.text,
|
|
438
|
+
column: this.name('a column name'),
|
|
439
|
+
at: token.at,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
return { kind: 'variable', name: token.text, at: token.at };
|
|
443
|
+
}
|
|
444
|
+
return this.fail(`expected a value but found ${this.describe(token)}`);
|
|
445
|
+
}
|
|
446
|
+
call(token, aggregates) {
|
|
447
|
+
const name = token.text.toLowerCase();
|
|
448
|
+
const aggregate = AGGREGATES.includes(name);
|
|
449
|
+
if (!aggregate && !SCALARS.includes(name)) {
|
|
450
|
+
this.fail(`unknown function ${token.text}(): try ${[...AGGREGATES, 'toLower', 'toUpper', 'coalesce'].join(', ')}`, token);
|
|
451
|
+
}
|
|
452
|
+
if (aggregate && !aggregates)
|
|
453
|
+
this.fail(`${token.text}() can only be used in RETURN and ORDER BY`, token);
|
|
454
|
+
this.expectPunct('(');
|
|
455
|
+
let star = false;
|
|
456
|
+
const args = [];
|
|
457
|
+
const distinct = aggregate && this.acceptWord('distinct');
|
|
458
|
+
if (this.acceptPunct('*')) {
|
|
459
|
+
if (name !== 'count')
|
|
460
|
+
this.fail(`only count(*) takes *`, token);
|
|
461
|
+
star = true;
|
|
462
|
+
}
|
|
463
|
+
else if (!this.punct(')')) {
|
|
464
|
+
do
|
|
465
|
+
args.push(this.expression(false));
|
|
466
|
+
while (this.acceptPunct(','));
|
|
467
|
+
}
|
|
468
|
+
this.expectPunct(')');
|
|
469
|
+
if (!star && args.length === 0)
|
|
470
|
+
this.fail(`${token.text}() needs an argument`, token);
|
|
471
|
+
return { kind: 'call', name, distinct, star, args, at: token.at };
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
export function parseCypher(source) {
|
|
475
|
+
if (!source.trim())
|
|
476
|
+
throw new CypherError('write a query, e.g. MATCH (c:Customer) RETURN c LIMIT 25');
|
|
477
|
+
return new Parser(source, tokenize(source)).parse();
|
|
478
|
+
}
|