@coradb/coradb-sqlsyntaxchecker-wasm 0.3.3
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/CHANGELOG.md +55 -0
- package/LICENSE +21 -0
- package/NOTICE +14 -0
- package/README-schema.md +362 -0
- package/README.md +318 -0
- package/build-info.json +11 -0
- package/coradb-sqlsyntaxchecker.wasm +0 -0
- package/index.d.ts +234 -0
- package/index.js +529 -0
- package/package.json +60 -0
- package/schema-check.js +715 -0
- package/schema.json +127 -0
- package/tools/coradbSchema.ts +483 -0
package/schema-check.js
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
// schema-check.js — the name checks.
|
|
2
|
+
//
|
|
3
|
+
// The names in a statement the grammar accepted, looked up in a schema: the
|
|
4
|
+
// tables, views, node and edge classes it uses, their columns, the stored
|
|
5
|
+
// functions and procedures it calls (and how many arguments it passes them),
|
|
6
|
+
// serials, and the values an INSERT gives. The grammar says where each of
|
|
7
|
+
// those is (see EVENTS in build/generate.mjs); this decides
|
|
8
|
+
// what each name means and whether the schema has it.
|
|
9
|
+
//
|
|
10
|
+
// Only what is certain is reported. A name that could mean something this
|
|
11
|
+
// does not know about — a derived table's columns, a system catalog class, an
|
|
12
|
+
// object the same script creates, a part of the schema that was not given — is
|
|
13
|
+
// let through. A false alarm is worse than a missed one.
|
|
14
|
+
/** The kinds of event the grammar reports; the numbers are EVENTS in generate.mjs. */
|
|
15
|
+
const EV = {
|
|
16
|
+
STMT: 1,
|
|
17
|
+
QUERY: 2,
|
|
18
|
+
SELECT: 3,
|
|
19
|
+
INSERT: 4,
|
|
20
|
+
UPDATE: 5,
|
|
21
|
+
DELETE: 6,
|
|
22
|
+
MERGE: 7,
|
|
23
|
+
CALL: 8,
|
|
24
|
+
TABLE: 10,
|
|
25
|
+
TSPEC: 11,
|
|
26
|
+
TALIAS: 12,
|
|
27
|
+
DALIAS: 13,
|
|
28
|
+
COLUMN: 20,
|
|
29
|
+
STAR_QUALIFIER: 21,
|
|
30
|
+
SELECT_ALIAS: 22,
|
|
31
|
+
CTE: 23,
|
|
32
|
+
FUNC_NAME: 30,
|
|
33
|
+
FUNC_ARGS: 31,
|
|
34
|
+
FUNC_TARGET: 32,
|
|
35
|
+
NODE_VAR: 40,
|
|
36
|
+
NODE_LABEL: 41,
|
|
37
|
+
EDGE_VAR: 42,
|
|
38
|
+
EDGE_LABELS: 43,
|
|
39
|
+
INS_TARGET: 50,
|
|
40
|
+
INS_COLS: 51,
|
|
41
|
+
INS_VERTEX: 52,
|
|
42
|
+
INS_EDGE: 53,
|
|
43
|
+
INS_SETCOL: 54,
|
|
44
|
+
INS_ROW: 55,
|
|
45
|
+
INS_ROW_EMPTY: 56,
|
|
46
|
+
};
|
|
47
|
+
const SCOPE_KINDS = new Set([EV.QUERY, EV.SELECT, EV.INSERT, EV.UPDATE, EV.DELETE, EV.MERGE]);
|
|
48
|
+
// --- names -------------------------------------------------------------------
|
|
49
|
+
/** `a.b.c`, with "…", […] and `…` quoting, lower-cased; null when it is not just a name. */
|
|
50
|
+
function nameParts(text) {
|
|
51
|
+
const parts = [];
|
|
52
|
+
let i = 0;
|
|
53
|
+
const n = text.length;
|
|
54
|
+
const skip = () => {
|
|
55
|
+
while (i < n && /\s/.test(text[i]))
|
|
56
|
+
i += 1;
|
|
57
|
+
};
|
|
58
|
+
skip();
|
|
59
|
+
while (i < n) {
|
|
60
|
+
const ch = text[i];
|
|
61
|
+
let part;
|
|
62
|
+
if (ch === '"' || ch === '`' || ch === '[') {
|
|
63
|
+
const close = ch === '[' ? ']' : ch;
|
|
64
|
+
const j = text.indexOf(close, i + 1);
|
|
65
|
+
if (j < 0)
|
|
66
|
+
return null;
|
|
67
|
+
part = text.slice(i + 1, j);
|
|
68
|
+
i = j + 1;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const m = /^[A-Za-z_#\u0080-][\w#$\u0080-]*/.exec(text.slice(i));
|
|
72
|
+
if (!m)
|
|
73
|
+
return null;
|
|
74
|
+
part = m[0];
|
|
75
|
+
i += m[0].length;
|
|
76
|
+
}
|
|
77
|
+
parts.push(part.toLowerCase());
|
|
78
|
+
skip();
|
|
79
|
+
if (i >= n)
|
|
80
|
+
break;
|
|
81
|
+
if (text[i] !== '.')
|
|
82
|
+
return null;
|
|
83
|
+
i += 1;
|
|
84
|
+
skip();
|
|
85
|
+
}
|
|
86
|
+
return parts.length ? parts : null;
|
|
87
|
+
}
|
|
88
|
+
/** The name an alias clause gives: ` t`, `AS t`, `t (a, b)` (renames: columns unknown), `p =`. */
|
|
89
|
+
function aliasOf(text) {
|
|
90
|
+
let t = text.trim().replace(/=\s*$/, '').trim();
|
|
91
|
+
t = t.replace(/^AS\s+/i, '');
|
|
92
|
+
const paren = t.indexOf('(');
|
|
93
|
+
const renames = paren >= 0;
|
|
94
|
+
if (renames)
|
|
95
|
+
t = t.slice(0, paren);
|
|
96
|
+
const parts = nameParts(t);
|
|
97
|
+
return parts && parts.length === 1 ? { name: parts[0], renames } : null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* How many top-level, comma-separated items `text` holds (strings, comments
|
|
101
|
+
* and brackets skipped). With `backslashEscapes` (no_backslash_escapes off), a
|
|
102
|
+
* backslash in a string escapes what follows it, as the engine reads it.
|
|
103
|
+
*/
|
|
104
|
+
function countItems(text, backslashEscapes = false) {
|
|
105
|
+
let depth = 0;
|
|
106
|
+
let commas = 0;
|
|
107
|
+
let any = false;
|
|
108
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
109
|
+
const ch = text[i];
|
|
110
|
+
const next = text[i + 1];
|
|
111
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
112
|
+
let j = i + 1;
|
|
113
|
+
while (j < text.length) {
|
|
114
|
+
if (backslashEscapes && ch !== '`' && text[j] === '\\')
|
|
115
|
+
j += 2;
|
|
116
|
+
else if (text[j] === ch) {
|
|
117
|
+
if (text[j + 1] === ch)
|
|
118
|
+
j += 2;
|
|
119
|
+
else
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
else
|
|
123
|
+
j += 1;
|
|
124
|
+
}
|
|
125
|
+
i = j;
|
|
126
|
+
any = true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if ((ch === '-' && next === '-') || (ch === '/' && next === '/')) {
|
|
130
|
+
const nl = text.indexOf('\n', i);
|
|
131
|
+
i = nl < 0 ? text.length : nl;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (ch === '/' && next === '*') {
|
|
135
|
+
const close = text.indexOf('*/', i + 2);
|
|
136
|
+
i = close < 0 ? text.length : close + 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (ch === '(' || ch === '[' || ch === '{')
|
|
140
|
+
depth += 1;
|
|
141
|
+
else if (ch === ')' || ch === ']' || ch === '}')
|
|
142
|
+
depth -= 1;
|
|
143
|
+
else if (ch === ',' && depth === 0)
|
|
144
|
+
commas += 1;
|
|
145
|
+
if (!/\s/.test(ch))
|
|
146
|
+
any = true;
|
|
147
|
+
}
|
|
148
|
+
return any ? commas + 1 : 0;
|
|
149
|
+
}
|
|
150
|
+
/** Each identifier in a bracketed list, `(a, [b], "c")`, with where it is. */
|
|
151
|
+
function listedNames(text, start) {
|
|
152
|
+
const out = [];
|
|
153
|
+
const open = text.indexOf('(');
|
|
154
|
+
const close = text.lastIndexOf(')');
|
|
155
|
+
if (open < 0 || close < open)
|
|
156
|
+
return out;
|
|
157
|
+
let pos = open + 1;
|
|
158
|
+
for (const piece of text.slice(open + 1, close).split(',')) {
|
|
159
|
+
const lead = piece.length - piece.trimStart().length;
|
|
160
|
+
const parts = nameParts(piece);
|
|
161
|
+
if (parts && parts.length >= 1) {
|
|
162
|
+
out.push({ name: parts[parts.length - 1], start: start + pos + lead, end: start + pos + lead + piece.trim().length });
|
|
163
|
+
}
|
|
164
|
+
pos += piece.length + 1;
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
/** "1", "1 or 2", "2 to 4", "3 or more", from a bit per count. */
|
|
169
|
+
function countWords(mask) {
|
|
170
|
+
const counts = [];
|
|
171
|
+
for (let c = 0; c <= 29; c += 1)
|
|
172
|
+
if (mask & (1 << c))
|
|
173
|
+
counts.push(c);
|
|
174
|
+
const more = (mask & (1 << 30)) !== 0;
|
|
175
|
+
if (more && counts.length > 0 && counts.every((c, i) => i === 0 || c === counts[i - 1] + 1) && counts[counts.length - 1] === 29) {
|
|
176
|
+
return `${counts[0]} or more`;
|
|
177
|
+
}
|
|
178
|
+
if (counts.length === 0)
|
|
179
|
+
return more ? '30 or more' : 'no';
|
|
180
|
+
if (counts.length === 1)
|
|
181
|
+
return String(counts[0]);
|
|
182
|
+
const contiguous = counts.every((c, i) => i === 0 || c === counts[i - 1] + 1);
|
|
183
|
+
if (contiguous && counts.length > 2)
|
|
184
|
+
return `${counts[0]} to ${counts[counts.length - 1]}`;
|
|
185
|
+
return `${counts.slice(0, -1).join(', ')} or ${counts[counts.length - 1]}`;
|
|
186
|
+
}
|
|
187
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
188
|
+
/**
|
|
189
|
+
* Names a script creates, renames or changes: CREATE TABLE/VIEW/… x, ALTER …
|
|
190
|
+
* x, RENAME a TO b, CREATE PROCEDURE/FUNCTION/SERIAL/SYNONYM x. Statements
|
|
191
|
+
* after (or before) them may use those names, which the schema does not know
|
|
192
|
+
* yet, so they are let through.
|
|
193
|
+
*/
|
|
194
|
+
export function createdNames(statements) {
|
|
195
|
+
const out = new Set();
|
|
196
|
+
const IDENT = String.raw `(?:\[[^\]]+\]|"[^"]+"|` + '`[^`]+`' + String.raw `|[A-Za-z_#][\w#$]*)`;
|
|
197
|
+
const NAME = `(${IDENT}(?:\\s*\\.\\s*${IDENT})?)`;
|
|
198
|
+
const add = (text) => {
|
|
199
|
+
const parts = nameParts(text);
|
|
200
|
+
if (parts)
|
|
201
|
+
out.add(parts[parts.length - 1]);
|
|
202
|
+
};
|
|
203
|
+
const patterns = [
|
|
204
|
+
// VERTEX TABLE / EDGE TABLE first, or the name read would be TABLE
|
|
205
|
+
new RegExp(String.raw `^\s*CREATE\b[\s\S]*?\b(?:(?:VERTEX|EDGE)\s+TABLE|TABLE|CLASS|VIEW|VCLASS|VERTEX|EDGE|LABEL)\s+(?:IF\s+NOT\s+EXISTS\s+)?` + NAME, 'i'),
|
|
206
|
+
new RegExp(String.raw `^\s*ALTER\s+(?:\w+\s+)?(?:TABLE|CLASS|VIEW|VCLASS|VERTEX|EDGE|LABEL)\s+` + NAME, 'i'),
|
|
207
|
+
new RegExp(String.raw `^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:PROCEDURE|FUNCTION)\s+` + NAME, 'i'),
|
|
208
|
+
new RegExp(String.raw `^\s*CREATE\s+SERIAL\s+` + NAME, 'i'),
|
|
209
|
+
new RegExp(String.raw `^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:PUBLIC\s+|PRIVATE\s+)?SYNONYM\s+` + NAME, 'i'),
|
|
210
|
+
];
|
|
211
|
+
for (const raw of statements) {
|
|
212
|
+
const sql = raw.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(--|\/\/).*$/gm, ' ');
|
|
213
|
+
for (const re of patterns) {
|
|
214
|
+
const m = re.exec(sql);
|
|
215
|
+
if (m)
|
|
216
|
+
add(m[1]);
|
|
217
|
+
}
|
|
218
|
+
const rename = new RegExp(String.raw `^\s*RENAME\s+(?:TABLE\s+|CLASS\s+|VIEW\s+|VCLASS\s+)?` + NAME + String.raw `\s+(?:AS|TO)\s+` + NAME, 'i').exec(sql);
|
|
219
|
+
if (rename) {
|
|
220
|
+
add(rename[1]);
|
|
221
|
+
add(rename[2]);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
// --- the check -----------------------------------------------------------------
|
|
227
|
+
const named = (x) => x !== null && typeof x === 'object' && typeof x.name === 'string';
|
|
228
|
+
const ownerOf = (o) => (typeof o.owner === 'string' ? o.owner : '').toLowerCase();
|
|
229
|
+
/**
|
|
230
|
+
* The schema document's lists, indexed by lower-case name, with entries that
|
|
231
|
+
* have no name left out. validate() makes it once for all the statements of a
|
|
232
|
+
* text and passes it as `ctx.index`.
|
|
233
|
+
*/
|
|
234
|
+
export function indexSchema(schema) {
|
|
235
|
+
const byName = (list) => {
|
|
236
|
+
const map = new Map();
|
|
237
|
+
for (const x of Array.isArray(list) ? list : []) {
|
|
238
|
+
if (!named(x))
|
|
239
|
+
continue;
|
|
240
|
+
const key = x.name.toLowerCase();
|
|
241
|
+
map.set(key, [...(map.get(key) ?? []), x]);
|
|
242
|
+
}
|
|
243
|
+
return map;
|
|
244
|
+
};
|
|
245
|
+
return {
|
|
246
|
+
objects: byName(schema.objects),
|
|
247
|
+
synonyms: byName(schema.synonyms),
|
|
248
|
+
procedures: Array.isArray(schema.procedures) ? byName(schema.procedures) : null,
|
|
249
|
+
serials: Array.isArray(schema.serials) ? byName(schema.serials) : null,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Check the names in one statement the grammar accepted. `events` are the
|
|
254
|
+
* grammar's, their places in characters of `sql`.
|
|
255
|
+
*/
|
|
256
|
+
export function checkNames(sql, events, schema, ctx) {
|
|
257
|
+
// A trigger's statements use OBJ, NEW and OLD, which only the trigger defines.
|
|
258
|
+
if (/^\s*(?:CREATE|ALTER)\s+(?:OR\s+REPLACE\s+)?TRIGGER\b/i.test(sql.replace(/^(\s*(--|\/\/)[^\n]*\n|\s*\/\*[\s\S]*?\*\/)*/, ''))) {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
// DROP … IF EXISTS asks for nothing to exist.
|
|
262
|
+
if (/^\s*DROP\b[^;]*?\bIF\s+EXISTS\b/i.test(sql)) {
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
const problems = [];
|
|
266
|
+
const created = ctx.created ?? new Set();
|
|
267
|
+
const backslashEscapes = Boolean(ctx.backslashEscapes);
|
|
268
|
+
const index = ctx.index ?? indexSchema(schema);
|
|
269
|
+
// The variables list_comp(x IN …), reduce(acc = …, x IN …) and similar bind: not columns.
|
|
270
|
+
const bound = new Set();
|
|
271
|
+
for (const m of sql.matchAll(/\breduce\s*\(\s*([A-Za-z_]\w*)\s*=/gi))
|
|
272
|
+
bound.add(m[1].toLowerCase());
|
|
273
|
+
for (const m of sql.matchAll(/[(,|]\s*([A-Za-z_]\w*)\s+IN\s+(?=[\w.]+\s*\(|\{|[\w.]+\s*\|)/gi))
|
|
274
|
+
bound.add(m[1].toLowerCase());
|
|
275
|
+
const report = (start, end, message) => {
|
|
276
|
+
if (!problems.some((p) => p.start === start && p.message === message))
|
|
277
|
+
problems.push({ start, end, message });
|
|
278
|
+
};
|
|
279
|
+
const textOf = (e) => sql.slice(e.start, e.end);
|
|
280
|
+
const isSystem = (name) => name.startsWith('db_') || name.startsWith('_db_') || name === 'dual';
|
|
281
|
+
const ctes = new Set(events.filter((e) => e.kind === EV.CTE).map((e) => nameParts(textOf(e))?.[0] ?? ''));
|
|
282
|
+
/**
|
|
283
|
+
* What a table name means: its objects, or lenient, or a problem to report.
|
|
284
|
+
* `via` holds the synonyms followed to get here: a synonym that leads back
|
|
285
|
+
* to itself explains nothing, and is let through.
|
|
286
|
+
*/
|
|
287
|
+
const lookup = (parts, via = new Set()) => {
|
|
288
|
+
if (parts.length > 2)
|
|
289
|
+
return { objects: [], lenient: true, missing: false };
|
|
290
|
+
const name = parts[parts.length - 1];
|
|
291
|
+
const owner = parts.length === 2 ? parts[0] : null;
|
|
292
|
+
if ((owner === null && ctes.has(name)) || created.has(name) || isSystem(name)) {
|
|
293
|
+
return { objects: [], lenient: true, missing: false };
|
|
294
|
+
}
|
|
295
|
+
const found = (index.objects.get(name) ?? []).filter((o) => owner === null || ownerOf(o) === owner);
|
|
296
|
+
if (found.length > 0)
|
|
297
|
+
return { objects: found, lenient: false, missing: false };
|
|
298
|
+
const syn = (index.synonyms.get(name) ?? []).find((s) => owner === null || ownerOf(s) === owner);
|
|
299
|
+
if (syn) {
|
|
300
|
+
const target = typeof syn.target === 'string' && !via.has(syn) ? nameParts(syn.target) : null;
|
|
301
|
+
if (target) {
|
|
302
|
+
const t = lookup(target, new Set([...via, syn]));
|
|
303
|
+
if (!t.missing)
|
|
304
|
+
return t;
|
|
305
|
+
}
|
|
306
|
+
return { objects: [], lenient: true, missing: false };
|
|
307
|
+
}
|
|
308
|
+
return { objects: [], lenient: false, missing: true };
|
|
309
|
+
};
|
|
310
|
+
/** The columns of an object, the ones it inherits included; null when one of its parents is not known. */
|
|
311
|
+
const columnsCache = new Map();
|
|
312
|
+
const columnsOf = (o, seen = new Set()) => {
|
|
313
|
+
if (columnsCache.has(o))
|
|
314
|
+
return columnsCache.get(o) ?? null;
|
|
315
|
+
if (seen.has(o))
|
|
316
|
+
return new Set();
|
|
317
|
+
seen.add(o);
|
|
318
|
+
if (!Array.isArray(o.columns)) {
|
|
319
|
+
columnsCache.set(o, null);
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
const cols = new Set(o.columns.filter(named).map((c) => c.name.toLowerCase()));
|
|
323
|
+
for (const parent of Array.isArray(o.under) ? o.under : []) {
|
|
324
|
+
const p = typeof parent === 'string' ? nameParts(parent) : null;
|
|
325
|
+
const found = p ? lookup(p) : null;
|
|
326
|
+
if (!found || found.objects.length === 0) {
|
|
327
|
+
columnsCache.set(o, null);
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
for (const po of found.objects) {
|
|
331
|
+
const pc = columnsOf(po, seen);
|
|
332
|
+
if (pc === null) {
|
|
333
|
+
columnsCache.set(o, null);
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
for (const c of pc)
|
|
337
|
+
cols.add(c);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
columnsCache.set(o, cols);
|
|
341
|
+
return cols;
|
|
342
|
+
};
|
|
343
|
+
const bindingHas = (b, col) => {
|
|
344
|
+
if (b.lenient)
|
|
345
|
+
return null;
|
|
346
|
+
let unknown = false;
|
|
347
|
+
for (const o of b.objects) {
|
|
348
|
+
const cols = columnsOf(o);
|
|
349
|
+
if (cols === null)
|
|
350
|
+
unknown = true;
|
|
351
|
+
else if (cols.has(col))
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
return unknown ? null : false;
|
|
355
|
+
};
|
|
356
|
+
// --- scopes ---
|
|
357
|
+
const scopes = events
|
|
358
|
+
.filter((e) => SCOPE_KINDS.has(e.kind))
|
|
359
|
+
.map((e) => ({
|
|
360
|
+
kind: e.kind,
|
|
361
|
+
start: e.start,
|
|
362
|
+
end: e.end,
|
|
363
|
+
parent: null,
|
|
364
|
+
bindings: [],
|
|
365
|
+
aliases: new Set(),
|
|
366
|
+
natural: /\bNATURAL\b/i.test(textOf(e)),
|
|
367
|
+
}));
|
|
368
|
+
// Smallest first; of two with the same text, the SELECT is inside the QUERY.
|
|
369
|
+
scopes.sort((a, b) => a.end - a.start - (b.end - b.start) || (a.kind === EV.QUERY ? 1 : 0) - (b.kind === EV.QUERY ? 1 : 0));
|
|
370
|
+
const contains = (outer, s, e) => outer.start <= s && e <= outer.end;
|
|
371
|
+
for (let i = 0; i < scopes.length; i += 1) {
|
|
372
|
+
for (let j = i + 1; j < scopes.length; j += 1) {
|
|
373
|
+
if (contains(scopes[j], scopes[i].start, scopes[i].end)) {
|
|
374
|
+
scopes[i].parent = scopes[j];
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const scopeAt = (s, e) => scopes.find((sc) => contains(sc, s, e)) ?? null;
|
|
380
|
+
// --- what each FROM, MATCH and INSERT brings into its scope ---
|
|
381
|
+
const tableChecked = new Set();
|
|
382
|
+
const bindTable = (e, scope, kindWanted) => {
|
|
383
|
+
const parts = nameParts(textOf(e));
|
|
384
|
+
if (!parts)
|
|
385
|
+
return null;
|
|
386
|
+
const found = lookup(parts);
|
|
387
|
+
const name = parts[parts.length - 1];
|
|
388
|
+
if (found.missing && !tableChecked.has(e.start)) {
|
|
389
|
+
tableChecked.add(e.start);
|
|
390
|
+
const what = kindWanted === 'node' ? 'node class' : kindWanted === 'edge' ? 'edge class' : 'table or view';
|
|
391
|
+
report(e.start, e.end, `There is no ${what} named ${name}.`);
|
|
392
|
+
}
|
|
393
|
+
let wrongKind = false;
|
|
394
|
+
if (kindWanted && found.objects.length > 0 && !found.objects.some((o) => o.kind === kindWanted)) {
|
|
395
|
+
wrongKind = true;
|
|
396
|
+
if (!tableChecked.has(e.start)) {
|
|
397
|
+
tableChecked.add(e.start);
|
|
398
|
+
report(e.start, e.end, `${name} is not ${kindWanted === 'node' ? 'a node' : 'an edge'} class.`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const b = {
|
|
402
|
+
names: [name, ...(parts.length === 2 ? [parts.join('.')] : [])],
|
|
403
|
+
objects: found.objects,
|
|
404
|
+
lenient: found.lenient || found.missing || wrongKind,
|
|
405
|
+
label: name,
|
|
406
|
+
};
|
|
407
|
+
if (scope)
|
|
408
|
+
scope.bindings.push(b);
|
|
409
|
+
return b;
|
|
410
|
+
};
|
|
411
|
+
for (let i = 0; i < events.length; i += 1) {
|
|
412
|
+
const e = events[i];
|
|
413
|
+
if (e.kind === EV.TSPEC) {
|
|
414
|
+
const scope = scopeAt(e.start, e.end);
|
|
415
|
+
const tables = events.filter((t) => t.kind === EV.TABLE && t.start >= e.start && t.end <= e.end);
|
|
416
|
+
const aliasEv = events[i + 1]?.kind === EV.TALIAS ? events[i + 1] : null;
|
|
417
|
+
const alias = aliasEv ? aliasOf(textOf(aliasEv)) : null;
|
|
418
|
+
if (tables.length === 1) {
|
|
419
|
+
const b = bindTable(tables[0], null);
|
|
420
|
+
if (b && scope) {
|
|
421
|
+
if (alias) {
|
|
422
|
+
b.names.unshift(alias.name);
|
|
423
|
+
if (alias.renames)
|
|
424
|
+
b.lenient = true;
|
|
425
|
+
}
|
|
426
|
+
scope.bindings.push(b);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
for (const t of tables)
|
|
431
|
+
bindTable(t, null);
|
|
432
|
+
if (scope && alias)
|
|
433
|
+
scope.bindings.push({ names: [alias.name], objects: [], lenient: true, label: alias.name });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
else if (e.kind === EV.DALIAS) {
|
|
437
|
+
const alias = aliasOf(textOf(e));
|
|
438
|
+
const scope = scopeAt(e.start, e.end);
|
|
439
|
+
if (alias && scope)
|
|
440
|
+
scope.bindings.push({ names: [alias.name], objects: [], lenient: true, label: alias.name });
|
|
441
|
+
}
|
|
442
|
+
else if (e.kind === EV.NODE_VAR && events[i + 1]?.kind === EV.NODE_LABEL) {
|
|
443
|
+
const label = events[i + 1];
|
|
444
|
+
const v = nameParts(textOf(e));
|
|
445
|
+
const scope = scopeAt(e.start, e.end);
|
|
446
|
+
const b = bindTable(label, null, 'node');
|
|
447
|
+
if (b && v && v.length === 1) {
|
|
448
|
+
b.names = [v[0]];
|
|
449
|
+
scope?.bindings.push(b);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
else if (e.kind === EV.EDGE_LABELS) {
|
|
453
|
+
const varEv = events[i - 1]?.kind === EV.EDGE_VAR ? events[i - 1] : null;
|
|
454
|
+
const v = varEv ? nameParts(textOf(varEv)) : null;
|
|
455
|
+
const scope = scopeAt(e.start, e.end);
|
|
456
|
+
const objects = [];
|
|
457
|
+
let lenient = false;
|
|
458
|
+
let pos = e.start;
|
|
459
|
+
for (const piece of textOf(e).split('|')) {
|
|
460
|
+
const lead = piece.length - piece.trimStart().length;
|
|
461
|
+
const b = bindTable({ kind: EV.TABLE, start: pos + lead, end: pos + lead + piece.trim().length }, null, 'edge');
|
|
462
|
+
if (b) {
|
|
463
|
+
objects.push(...b.objects);
|
|
464
|
+
lenient || (lenient = b.lenient);
|
|
465
|
+
}
|
|
466
|
+
else
|
|
467
|
+
lenient = true;
|
|
468
|
+
pos += piece.length + 1;
|
|
469
|
+
}
|
|
470
|
+
if (v && v.length === 1 && scope)
|
|
471
|
+
scope.bindings.push({ names: [v[0]], objects, lenient, label: v[0] });
|
|
472
|
+
}
|
|
473
|
+
else if (e.kind === EV.INS_TARGET || e.kind === EV.INS_VERTEX || e.kind === EV.INS_EDGE) {
|
|
474
|
+
const scope = scopeAt(e.start, e.end);
|
|
475
|
+
bindTable(e, scope, e.kind === EV.INS_VERTEX ? 'node' : e.kind === EV.INS_EDGE ? 'edge' : undefined);
|
|
476
|
+
}
|
|
477
|
+
else if (e.kind === EV.SELECT_ALIAS) {
|
|
478
|
+
const alias = aliasOf(textOf(e));
|
|
479
|
+
const scope = scopeAt(e.start, e.end);
|
|
480
|
+
if (alias && scope)
|
|
481
|
+
scope.aliases.add(alias.name);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// Tables named anywhere else a table spec reaches (UPDATE, DELETE, MERGE) are
|
|
485
|
+
// all bound through TSPEC above; any left over are still checked.
|
|
486
|
+
for (const e of events) {
|
|
487
|
+
if (e.kind === EV.TABLE && !tableChecked.has(e.start)) {
|
|
488
|
+
const parts = nameParts(textOf(e));
|
|
489
|
+
if (parts && lookup(parts).missing) {
|
|
490
|
+
tableChecked.add(e.start);
|
|
491
|
+
report(e.start, e.end, `There is no table or view named ${parts[parts.length - 1]}.`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// --- columns ---
|
|
496
|
+
/** The bindings a reference in `scope` sees there: a QUERY's are those of its SELECTs (for ORDER BY). */
|
|
497
|
+
const visible = (scope) => {
|
|
498
|
+
if (scope.kind !== EV.QUERY)
|
|
499
|
+
return { bindings: scope.bindings, aliases: scope.aliases };
|
|
500
|
+
const bindings = [...scope.bindings];
|
|
501
|
+
const aliases = new Set(scope.aliases);
|
|
502
|
+
for (const s of scopes) {
|
|
503
|
+
if (s.parent === scope && s.kind === EV.SELECT) {
|
|
504
|
+
bindings.push(...s.bindings);
|
|
505
|
+
for (const a of s.aliases)
|
|
506
|
+
aliases.add(a);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return { bindings, aliases };
|
|
510
|
+
};
|
|
511
|
+
const chain = (scope) => {
|
|
512
|
+
const out = [];
|
|
513
|
+
for (let s = scope; s; s = s.parent)
|
|
514
|
+
out.push(s);
|
|
515
|
+
return out;
|
|
516
|
+
};
|
|
517
|
+
const findBinding = (scopesUp, name) => {
|
|
518
|
+
for (const s of scopesUp) {
|
|
519
|
+
const b = visible(s).bindings.find((x) => x.names.includes(name));
|
|
520
|
+
if (b)
|
|
521
|
+
return b;
|
|
522
|
+
}
|
|
523
|
+
return null;
|
|
524
|
+
};
|
|
525
|
+
const SERIAL_PARTS = new Set(['next_value', 'current_value']);
|
|
526
|
+
const serialKnown = (parts) => {
|
|
527
|
+
if (!index.serials)
|
|
528
|
+
return true;
|
|
529
|
+
const name = parts[parts.length - 1];
|
|
530
|
+
if (created.has(name))
|
|
531
|
+
return true;
|
|
532
|
+
const owner = parts.length === 2 ? parts[0] : null;
|
|
533
|
+
return (index.serials.get(name) ?? []).some((s) => owner === null || ownerOf(s) === owner);
|
|
534
|
+
};
|
|
535
|
+
for (const e of events) {
|
|
536
|
+
if (e.kind !== EV.COLUMN && e.kind !== EV.STAR_QUALIFIER)
|
|
537
|
+
continue;
|
|
538
|
+
const text = textOf(e);
|
|
539
|
+
// Not a plain column: a call, a parameter, a CLASS reference, a set element.
|
|
540
|
+
if (/[({]|->/.test(text) || /^\s*[:?]/.test(text) || /^\s*CLASS\s/i.test(text))
|
|
541
|
+
continue;
|
|
542
|
+
const parts = nameParts(text);
|
|
543
|
+
if (!parts)
|
|
544
|
+
continue;
|
|
545
|
+
if (bound.has(parts[0]))
|
|
546
|
+
continue;
|
|
547
|
+
const scope = scopeAt(e.start, e.end);
|
|
548
|
+
if (!scope)
|
|
549
|
+
continue;
|
|
550
|
+
const up = chain(scope);
|
|
551
|
+
if (e.kind === EV.STAR_QUALIFIER) {
|
|
552
|
+
const q = parts.join('.');
|
|
553
|
+
if (!findBinding(up, q) && !findBinding(up, parts[parts.length - 1])) {
|
|
554
|
+
report(e.start, e.end, `There is no table or alias named ${parts.join('.')} in this query.`);
|
|
555
|
+
}
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (parts.length === 1) {
|
|
559
|
+
const col = parts[0];
|
|
560
|
+
let found = false;
|
|
561
|
+
for (const s of up) {
|
|
562
|
+
const { bindings, aliases } = visible(s);
|
|
563
|
+
if (aliases.has(col) || bindings.some((b) => b.names.includes(col))) {
|
|
564
|
+
found = true;
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
if (bindings.some((b) => bindingHas(b, col) === null)) {
|
|
568
|
+
found = true;
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
const having = bindings.filter((b) => bindingHas(b, col) === true);
|
|
572
|
+
if (having.length > 0) {
|
|
573
|
+
found = true;
|
|
574
|
+
const distinct = [...new Set(having)];
|
|
575
|
+
if (distinct.length > 1 && !s.natural && s.kind !== EV.QUERY) {
|
|
576
|
+
const labels = distinct.map((b) => b.label);
|
|
577
|
+
const who = `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`;
|
|
578
|
+
report(e.start, e.end, `Column ${col} is ambiguous: ${who} ${labels.length === 2 ? 'both' : 'all'} have it.`);
|
|
579
|
+
}
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (!found) {
|
|
584
|
+
const tables = [...new Set(up.flatMap((s) => visible(s).bindings))];
|
|
585
|
+
report(e.start, e.end, tables.length === 0
|
|
586
|
+
? `There is no column named ${col}: the query reads no table.`
|
|
587
|
+
: tables.length === 1
|
|
588
|
+
? `${tables[0].label} has no column named ${col}.`
|
|
589
|
+
: `None of the tables in this query has a column named ${col}.`);
|
|
590
|
+
}
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
// q.c, owner.table.c, serial.NEXT_VALUE, column.attribute (a path)
|
|
594
|
+
const tryQualified = (q, col) => {
|
|
595
|
+
const b = findBinding(up, q);
|
|
596
|
+
if (!b)
|
|
597
|
+
return false;
|
|
598
|
+
const has = bindingHas(b, col);
|
|
599
|
+
if (has === false)
|
|
600
|
+
report(e.start, e.end, `${b.label} has no column named ${col}.`);
|
|
601
|
+
return true;
|
|
602
|
+
};
|
|
603
|
+
if (parts.length >= 3 && tryQualified(`${parts[0]}.${parts[1]}`, parts[2]))
|
|
604
|
+
continue;
|
|
605
|
+
if (tryQualified(parts[0], parts[1]))
|
|
606
|
+
continue;
|
|
607
|
+
if (SERIAL_PARTS.has(parts[parts.length - 1])) {
|
|
608
|
+
const serial = parts.slice(0, -1);
|
|
609
|
+
if (serial.length <= 2 && !serialKnown(serial)) {
|
|
610
|
+
report(e.start, e.end, `There is no serial named ${serial[serial.length - 1]}.`);
|
|
611
|
+
}
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
// A column whose value is an object: its attributes are read through it.
|
|
615
|
+
const asColumn = up.some((s) => visible(s).bindings.some((b) => bindingHas(b, parts[0]) !== false));
|
|
616
|
+
if (asColumn || parts.length >= 3)
|
|
617
|
+
continue;
|
|
618
|
+
report(e.start, e.end, `There is no table or alias named ${parts[0]} in this query.`);
|
|
619
|
+
}
|
|
620
|
+
// --- INSERT: its columns and how many values each row has ---
|
|
621
|
+
for (const scope of scopes) {
|
|
622
|
+
if (scope.kind !== EV.INSERT)
|
|
623
|
+
continue;
|
|
624
|
+
const target = scope.bindings[0];
|
|
625
|
+
if (!target)
|
|
626
|
+
continue;
|
|
627
|
+
const inScope = (e) => scopeAt(e.start, e.end) === scope;
|
|
628
|
+
const colsEv = events.find((e) => e.kind === EV.INS_COLS && inScope(e));
|
|
629
|
+
const listed = colsEv ? listedNames(textOf(colsEv), colsEv.start) : [];
|
|
630
|
+
const listedText = colsEv ? textOf(colsEv).trim() : '';
|
|
631
|
+
for (const c of listed) {
|
|
632
|
+
if (bindingHas(target, c.name) === false)
|
|
633
|
+
report(c.start, c.end, `${target.label} has no column named ${c.name}.`);
|
|
634
|
+
}
|
|
635
|
+
for (const e of events) {
|
|
636
|
+
if (e.kind === EV.INS_SETCOL && inScope(e)) {
|
|
637
|
+
const p = nameParts(textOf(e));
|
|
638
|
+
if (p && bindingHas(target, p[p.length - 1]) === false) {
|
|
639
|
+
report(e.start, e.end, `${target.label} has no column named ${p[p.length - 1]}.`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
let expected = null;
|
|
644
|
+
let against = '';
|
|
645
|
+
if (listedText !== '') {
|
|
646
|
+
expected = listed.length;
|
|
647
|
+
against = plural(expected, 'column');
|
|
648
|
+
}
|
|
649
|
+
else if (!target.lenient && target.objects.length === 1) {
|
|
650
|
+
const o = target.objects[0];
|
|
651
|
+
if ((o.kind === 'table' || o.kind === 'view') && !(Array.isArray(o.under) && o.under.length) && Array.isArray(o.columns)) {
|
|
652
|
+
expected = o.columns.length;
|
|
653
|
+
against = `the ${plural(expected, 'column')} of ${target.label}`;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (expected === null)
|
|
657
|
+
continue;
|
|
658
|
+
for (const e of events) {
|
|
659
|
+
if ((e.kind !== EV.INS_ROW && e.kind !== EV.INS_ROW_EMPTY) || !inScope(e))
|
|
660
|
+
continue;
|
|
661
|
+
const count = e.kind === EV.INS_ROW_EMPTY ? 0 : countItems(textOf(e), backslashEscapes);
|
|
662
|
+
if (count !== expected) {
|
|
663
|
+
report(e.start, e.end, `${plural(count, 'value')} for ${against}.`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// --- functions and procedures ---
|
|
668
|
+
const procedures = index.procedures;
|
|
669
|
+
const findProcedure = (name) => procedures?.get(name)?.[0];
|
|
670
|
+
const calls = events.filter((e) => e.kind === EV.CALL);
|
|
671
|
+
for (let i = 0; i < events.length; i += 1) {
|
|
672
|
+
const e = events[i];
|
|
673
|
+
if (e.kind !== EV.FUNC_NAME)
|
|
674
|
+
continue;
|
|
675
|
+
const argsEv = events[i + 1]?.kind === EV.FUNC_ARGS ? events[i + 1] : null;
|
|
676
|
+
const targetEv = events[i + 2]?.kind === EV.FUNC_TARGET ? events[i + 2] : null;
|
|
677
|
+
if (targetEv && textOf(targetEv).trim() !== '')
|
|
678
|
+
continue; // a method, ON its object
|
|
679
|
+
// obj.method(), owner.function(): not looked up.
|
|
680
|
+
if (/\.\s*$/.test(sql.slice(0, e.start)))
|
|
681
|
+
continue;
|
|
682
|
+
const parts = nameParts(textOf(e));
|
|
683
|
+
if (!parts || parts.length !== 1)
|
|
684
|
+
continue;
|
|
685
|
+
const name = parts[0];
|
|
686
|
+
const argc = argsEv ? countItems(textOf(argsEv), backslashEscapes) : 0;
|
|
687
|
+
const proc = findProcedure(name);
|
|
688
|
+
const mask = ctx.builtin(name);
|
|
689
|
+
if (mask !== -2) {
|
|
690
|
+
const allowed = mask === -1 || (argc <= 29 ? (mask & (1 << argc)) !== 0 : (mask & (1 << 30)) !== 0);
|
|
691
|
+
if (!allowed && !proc) {
|
|
692
|
+
const words = countWords(mask);
|
|
693
|
+
report(e.start, e.end, `${name.toUpperCase()} takes ${words} ${words === '1' ? 'argument' : 'arguments'}, not ${argc}.`);
|
|
694
|
+
}
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
if (!procedures || created.has(name))
|
|
698
|
+
continue;
|
|
699
|
+
if (!proc) {
|
|
700
|
+
report(e.start, e.end, `There is no function or procedure named ${name}.`);
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
if (Array.isArray(proc.params) && proc.params.length !== argc) {
|
|
704
|
+
report(e.start, e.end, `${proc.name} takes ${plural(proc.params.length, 'argument')}, not ${argc}.`);
|
|
705
|
+
}
|
|
706
|
+
const called = calls.some((c) => {
|
|
707
|
+
const after = sql.slice(c.start, c.end).match(/^\s*CALL\s+/i);
|
|
708
|
+
return after !== null && c.start + after[0].length === e.start;
|
|
709
|
+
});
|
|
710
|
+
if (!called && proc.kind === 'procedure') {
|
|
711
|
+
report(e.start, e.end, `${proc.name} is a procedure: run it with CALL; it has no value to use here.`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return problems.sort((a, b) => a.start - b.start);
|
|
715
|
+
}
|