@tablecraft-app/plugin-api 0.0.0-stage → 1.1.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 +46 -2
- package/dist/catalog.d.ts +112 -0
- package/dist/catalog.js +5 -0
- package/dist/completion.d.ts +109 -0
- package/dist/completion.js +12 -0
- package/dist/errors.d.ts +58 -0
- package/dist/errors.js +106 -0
- package/dist/explain.d.ts +58 -0
- package/dist/explain.js +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/inspection.d.ts +38 -0
- package/dist/inspection.js +21 -0
- package/dist/manifest.d.ts +186 -0
- package/dist/manifest.js +176 -0
- package/dist/objects.d.ts +140 -0
- package/dist/objects.js +2 -0
- package/dist/plugin.d.ts +460 -0
- package/dist/plugin.js +49 -0
- package/dist/sessions.d.ts +59 -0
- package/dist/sessions.js +2 -0
- package/dist/sql/classify.d.ts +19 -0
- package/dist/sql/classify.js +95 -0
- package/dist/sql/complete.d.ts +23 -0
- package/dist/sql/complete.js +480 -0
- package/dist/sql/explain.d.ts +42 -0
- package/dist/sql/explain.js +108 -0
- package/dist/sql/format.d.ts +26 -0
- package/dist/sql/format.js +117 -0
- package/dist/sql/functions.d.ts +47 -0
- package/dist/sql/functions.js +159 -0
- package/dist/sql/generate.d.ts +22 -0
- package/dist/sql/generate.js +64 -0
- package/dist/sql/index.d.ts +16 -0
- package/dist/sql/index.js +16 -0
- package/dist/sql/inspect.d.ts +10 -0
- package/dist/sql/inspect.js +134 -0
- package/dist/sql/inspectContext.d.ts +78 -0
- package/dist/sql/inspectContext.js +251 -0
- package/dist/sql/inspectResolve.d.ts +6 -0
- package/dist/sql/inspectResolve.js +391 -0
- package/dist/sql/inspectStyle.d.ts +3 -0
- package/dist/sql/inspectStyle.js +384 -0
- package/dist/sql/inspectSyntax.d.ts +10 -0
- package/dist/sql/inspectSyntax.js +179 -0
- package/dist/sql/layout.d.ts +8 -0
- package/dist/sql/layout.js +114 -0
- package/dist/sql/layoutNodes.d.ts +46 -0
- package/dist/sql/layoutNodes.js +317 -0
- package/dist/sql/layoutPrint.d.ts +60 -0
- package/dist/sql/layoutPrint.js +806 -0
- package/dist/sql/layoutWriter.d.ts +21 -0
- package/dist/sql/layoutWriter.js +77 -0
- package/dist/sql/literals.d.ts +28 -0
- package/dist/sql/literals.js +78 -0
- package/dist/sql/namespace.d.ts +41 -0
- package/dist/sql/namespace.js +220 -0
- package/dist/sql/objects.d.ts +42 -0
- package/dist/sql/objects.js +222 -0
- package/dist/sql/parameters.d.ts +20 -0
- package/dist/sql/parameters.js +88 -0
- package/dist/sql/resolution.d.ts +73 -0
- package/dist/sql/resolution.js +192 -0
- package/dist/sql/routines.d.ts +27 -0
- package/dist/sql/routines.js +42 -0
- package/dist/sql/scope.d.ts +117 -0
- package/dist/sql/scope.js +540 -0
- package/dist/sql/select.d.ts +41 -0
- package/dist/sql/select.js +287 -0
- package/dist/sql/signature.d.ts +18 -0
- package/dist/sql/signature.js +137 -0
- package/dist/sql/splitter.d.ts +15 -0
- package/dist/sql/splitter.js +217 -0
- package/dist/sql/suggest.d.ts +6 -0
- package/dist/sql/suggest.js +36 -0
- package/dist/sql/tokens.d.ts +62 -0
- package/dist/sql/tokens.js +227 -0
- package/dist/sql/words.d.ts +23 -0
- package/dist/sql/words.js +72 -0
- package/dist/testing/index.d.ts +177 -0
- package/dist/testing/index.js +2070 -0
- package/dist/tls.d.ts +33 -0
- package/dist/tls.js +118 -0
- package/dist/url.d.ts +52 -0
- package/dist/url.js +219 -0
- package/dist/values.d.ts +44 -0
- package/dist/values.js +44 -0
- package/dist/version.d.ts +22 -0
- package/dist/version.js +29 -0
- package/package.json +42 -4
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { unquote } from "./scope.js";
|
|
2
|
+
import { significantTokens, wordOf } from "./tokens.js";
|
|
3
|
+
/** Why a statement is not a simple SELECT, for the console's toolbar (plan 026). */
|
|
4
|
+
export const SELECT_READ_ONLY_REASONS = {
|
|
5
|
+
notSelect: "the statement is not a simple SELECT",
|
|
6
|
+
with: "the statement has a WITH clause",
|
|
7
|
+
distinct: "the result is DISTINCT",
|
|
8
|
+
joins: "the result joins two or more tables",
|
|
9
|
+
aggregates: "the result aggregates rows",
|
|
10
|
+
windows: "the result uses window functions",
|
|
11
|
+
combines: "the result combines queries",
|
|
12
|
+
derived: "the result reads a subquery or function, not a table",
|
|
13
|
+
noTable: "the result reads no table",
|
|
14
|
+
};
|
|
15
|
+
const SELECT = "SELECT";
|
|
16
|
+
const FROM = "FROM";
|
|
17
|
+
const AS = "AS";
|
|
18
|
+
const STAR = "*";
|
|
19
|
+
const DOT = ".";
|
|
20
|
+
const COMMA = ",";
|
|
21
|
+
const OPEN = "(";
|
|
22
|
+
const CLOSE = ")";
|
|
23
|
+
const OVER = "OVER";
|
|
24
|
+
const ONLY = "ONLY";
|
|
25
|
+
const ON = "ON";
|
|
26
|
+
const NOT = "NOT";
|
|
27
|
+
const WITH = "WITH";
|
|
28
|
+
const DISTINCT = new Set(["DISTINCT", "DISTINCTROW", "UNIQUE"]);
|
|
29
|
+
const SELECT_ALL = "ALL";
|
|
30
|
+
/** What may follow the table at the top level. */
|
|
31
|
+
const TAIL_CLAUSES = new Set(["WHERE", "ORDER", "LIMIT", "OFFSET", "FETCH"]);
|
|
32
|
+
const JOINS = new Set(["JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "NATURAL", "STRAIGHT_JOIN", "OUTER"]);
|
|
33
|
+
const GROUPING = new Set(["GROUP", "HAVING"]);
|
|
34
|
+
const SET_OPERATIONS = new Set(["UNION", "INTERSECT", "EXCEPT", "MINUS"]);
|
|
35
|
+
/** Top-level words after the table that make the statement more than a read of its rows. */
|
|
36
|
+
const OTHER_TAIL = new Set([
|
|
37
|
+
"WINDOW",
|
|
38
|
+
"INTO",
|
|
39
|
+
"FOR",
|
|
40
|
+
"LOCK",
|
|
41
|
+
"QUALIFY",
|
|
42
|
+
"TABLESAMPLE",
|
|
43
|
+
"PARTITION",
|
|
44
|
+
"USE",
|
|
45
|
+
"FORCE",
|
|
46
|
+
"IGNORE",
|
|
47
|
+
"INDEXED",
|
|
48
|
+
"NOT",
|
|
49
|
+
]);
|
|
50
|
+
const AGGREGATES = new Set([
|
|
51
|
+
"COUNT",
|
|
52
|
+
"SUM",
|
|
53
|
+
"AVG",
|
|
54
|
+
"MIN",
|
|
55
|
+
"MAX",
|
|
56
|
+
"ARRAY_AGG",
|
|
57
|
+
"STRING_AGG",
|
|
58
|
+
"GROUP_CONCAT",
|
|
59
|
+
"JSON_AGG",
|
|
60
|
+
"JSONB_AGG",
|
|
61
|
+
"JSON_OBJECT_AGG",
|
|
62
|
+
"JSONB_OBJECT_AGG",
|
|
63
|
+
"JSON_ARRAYAGG",
|
|
64
|
+
"JSON_OBJECTAGG",
|
|
65
|
+
"JSON_GROUP_ARRAY",
|
|
66
|
+
"JSON_GROUP_OBJECT",
|
|
67
|
+
"BOOL_AND",
|
|
68
|
+
"BOOL_OR",
|
|
69
|
+
"EVERY",
|
|
70
|
+
"BIT_AND",
|
|
71
|
+
"BIT_OR",
|
|
72
|
+
"BIT_XOR",
|
|
73
|
+
"STDDEV",
|
|
74
|
+
"STDDEV_POP",
|
|
75
|
+
"STDDEV_SAMP",
|
|
76
|
+
"VARIANCE",
|
|
77
|
+
"VAR_POP",
|
|
78
|
+
"VAR_SAMP",
|
|
79
|
+
"TOTAL",
|
|
80
|
+
"XMLAGG",
|
|
81
|
+
"PERCENTILE_CONT",
|
|
82
|
+
"PERCENTILE_DISC",
|
|
83
|
+
"MODE",
|
|
84
|
+
]);
|
|
85
|
+
const NOT_A_NAME = /^[0-9$]/;
|
|
86
|
+
/** The text and kind checks the parser needs over one statement's tokens. */
|
|
87
|
+
class Reader {
|
|
88
|
+
text;
|
|
89
|
+
tokens;
|
|
90
|
+
constructor(text, tokens) {
|
|
91
|
+
this.text = text;
|
|
92
|
+
this.tokens = tokens;
|
|
93
|
+
}
|
|
94
|
+
word(token) {
|
|
95
|
+
return token?.kind === "word" ? wordOf(this.text, token) : null;
|
|
96
|
+
}
|
|
97
|
+
symbol(token) {
|
|
98
|
+
return token?.kind === "symbol" ? this.text.slice(token.from, token.to) : null;
|
|
99
|
+
}
|
|
100
|
+
identifier(token) {
|
|
101
|
+
if (token === undefined)
|
|
102
|
+
return null;
|
|
103
|
+
const raw = this.text.slice(token.from, token.to);
|
|
104
|
+
if (token.kind === "quoted")
|
|
105
|
+
return { name: unquote(raw), quoted: true, from: token.from, to: token.to };
|
|
106
|
+
if (token.kind === "word" && !NOT_A_NAME.test(raw))
|
|
107
|
+
return { name: raw, quoted: false, from: token.from, to: token.to };
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
/** Each token's parenthesis depth, and whether it sits inside a parenthesised subquery. */
|
|
111
|
+
depths() {
|
|
112
|
+
const depth = [];
|
|
113
|
+
const inSubquery = [];
|
|
114
|
+
const subqueries = [];
|
|
115
|
+
this.tokens.forEach((token, index) => {
|
|
116
|
+
const symbol = this.symbol(token);
|
|
117
|
+
if (symbol === CLOSE)
|
|
118
|
+
subqueries.pop();
|
|
119
|
+
depth.push(subqueries.length);
|
|
120
|
+
inSubquery.push(subqueries.some(Boolean));
|
|
121
|
+
if (symbol === OPEN)
|
|
122
|
+
subqueries.push(this.word(this.tokens[index + 1]) === SELECT);
|
|
123
|
+
});
|
|
124
|
+
return { depth, inSubquery };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** A dotted name `a.b.c` from `at`: its parts and the index after it, or null when `at` holds no name. */
|
|
128
|
+
function dottedName(reader, at) {
|
|
129
|
+
const first = reader.identifier(reader.tokens[at]);
|
|
130
|
+
if (first === null)
|
|
131
|
+
return null;
|
|
132
|
+
const parts = [first];
|
|
133
|
+
let next = at + 1;
|
|
134
|
+
while (reader.symbol(reader.tokens[next]) === DOT) {
|
|
135
|
+
const part = reader.identifier(reader.tokens[next + 1]);
|
|
136
|
+
if (part === null)
|
|
137
|
+
break;
|
|
138
|
+
parts.push(part);
|
|
139
|
+
next += 2;
|
|
140
|
+
}
|
|
141
|
+
return { parts, next };
|
|
142
|
+
}
|
|
143
|
+
function selectItem(reader, from, to) {
|
|
144
|
+
const tokens = reader.tokens;
|
|
145
|
+
if (to - from === 1 && reader.symbol(tokens[from]) === STAR)
|
|
146
|
+
return { kind: "star", qualifier: null };
|
|
147
|
+
const name = dottedName(reader, from);
|
|
148
|
+
if (name === null)
|
|
149
|
+
return { kind: "expression" };
|
|
150
|
+
if (name.next === to - 2 && reader.symbol(tokens[name.next]) === DOT && reader.symbol(tokens[to - 1]) === STAR) {
|
|
151
|
+
return { kind: "star", qualifier: name.parts.at(-1) ?? null };
|
|
152
|
+
}
|
|
153
|
+
const last = name.parts.at(-1);
|
|
154
|
+
if (last === undefined)
|
|
155
|
+
return { kind: "expression" };
|
|
156
|
+
const column = () => ({ kind: "column", qualifier: name.parts.slice(0, -1), name: last });
|
|
157
|
+
if (name.next === to)
|
|
158
|
+
return column();
|
|
159
|
+
const aliasAt = reader.word(tokens[name.next]) === AS ? name.next + 1 : name.next;
|
|
160
|
+
if (aliasAt === to - 1 && (tokens[aliasAt]?.kind === "string" || reader.identifier(tokens[aliasAt]) !== null)) {
|
|
161
|
+
return column();
|
|
162
|
+
}
|
|
163
|
+
return { kind: "expression" };
|
|
164
|
+
}
|
|
165
|
+
/** Why the select list makes the result something other than the table's rows, or null. */
|
|
166
|
+
function selectListRefusal(reader, from, to, inSubquery) {
|
|
167
|
+
const tokens = reader.tokens;
|
|
168
|
+
let aggregate = false;
|
|
169
|
+
for (let at = from; at < to; at += 1) {
|
|
170
|
+
if (inSubquery[at] === true)
|
|
171
|
+
continue;
|
|
172
|
+
const word = reader.word(tokens[at]);
|
|
173
|
+
if (word === OVER)
|
|
174
|
+
return SELECT_READ_ONLY_REASONS.windows;
|
|
175
|
+
if (word !== null && AGGREGATES.has(word) && reader.symbol(tokens[at + 1]) === OPEN)
|
|
176
|
+
aggregate = true;
|
|
177
|
+
}
|
|
178
|
+
return aggregate ? SELECT_READ_ONLY_REASONS.aggregates : null;
|
|
179
|
+
}
|
|
180
|
+
/** Why the words after the table refuse editing, or null when they only filter, order and limit. */
|
|
181
|
+
function tailRefusal(reader, from, depth) {
|
|
182
|
+
const tokens = reader.tokens;
|
|
183
|
+
const first = reader.word(tokens[from]);
|
|
184
|
+
if (from < tokens.length && (first === null || !TAIL_CLAUSES.has(first))) {
|
|
185
|
+
if (reader.symbol(tokens[from]) === COMMA || (first !== null && JOINS.has(first))) {
|
|
186
|
+
return SELECT_READ_ONLY_REASONS.joins;
|
|
187
|
+
}
|
|
188
|
+
if (first !== null && GROUPING.has(first))
|
|
189
|
+
return SELECT_READ_ONLY_REASONS.aggregates;
|
|
190
|
+
if (first !== null && SET_OPERATIONS.has(first))
|
|
191
|
+
return SELECT_READ_ONLY_REASONS.combines;
|
|
192
|
+
return SELECT_READ_ONLY_REASONS.notSelect;
|
|
193
|
+
}
|
|
194
|
+
for (let at = from; at < tokens.length; at += 1) {
|
|
195
|
+
if (depth[at] !== 0)
|
|
196
|
+
continue;
|
|
197
|
+
const word = reader.word(tokens[at]);
|
|
198
|
+
if (word === null)
|
|
199
|
+
continue;
|
|
200
|
+
if (GROUPING.has(word))
|
|
201
|
+
return SELECT_READ_ONLY_REASONS.aggregates;
|
|
202
|
+
if (SET_OPERATIONS.has(word))
|
|
203
|
+
return SELECT_READ_ONLY_REASONS.combines;
|
|
204
|
+
if (JOINS.has(word) || (word === ON && at > from))
|
|
205
|
+
return SELECT_READ_ONLY_REASONS.joins;
|
|
206
|
+
if (OTHER_TAIL.has(word) && word !== NOT)
|
|
207
|
+
return SELECT_READ_ONLY_REASONS.notSelect;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* `sql`, one statement, read as a single-table SELECT (plan 026). Strict on purpose: whatever
|
|
213
|
+
* it does not recognise is read-only, because a wrong "editable" writes to the wrong rows.
|
|
214
|
+
*/
|
|
215
|
+
export function parseSimpleSelect(sql, dialect) {
|
|
216
|
+
const tokens = significantTokens(sql, dialect).filter((token) => token.kind !== "delimiter");
|
|
217
|
+
const reader = new Reader(sql, tokens);
|
|
218
|
+
const head = reader.word(tokens[0]);
|
|
219
|
+
if (head === WITH)
|
|
220
|
+
return { readOnly: SELECT_READ_ONLY_REASONS.with };
|
|
221
|
+
if (reader.symbol(tokens[0]) === OPEN)
|
|
222
|
+
return { readOnly: SELECT_READ_ONLY_REASONS.combines };
|
|
223
|
+
if (head !== SELECT)
|
|
224
|
+
return { readOnly: SELECT_READ_ONLY_REASONS.notSelect };
|
|
225
|
+
const { depth, inSubquery } = reader.depths();
|
|
226
|
+
let at = 1;
|
|
227
|
+
const modifier = reader.word(tokens[at]);
|
|
228
|
+
if (modifier !== null && DISTINCT.has(modifier))
|
|
229
|
+
return { readOnly: SELECT_READ_ONLY_REASONS.distinct };
|
|
230
|
+
if (modifier === SELECT_ALL)
|
|
231
|
+
at += 1;
|
|
232
|
+
const fromAt = tokens.findIndex((token, index) => index >= at && depth[index] === 0 && reader.word(token) === FROM);
|
|
233
|
+
if (fromAt < 0)
|
|
234
|
+
return { readOnly: SELECT_READ_ONLY_REASONS.noTable };
|
|
235
|
+
const refusal = selectListRefusal(reader, at, fromAt, inSubquery);
|
|
236
|
+
if (refusal !== null)
|
|
237
|
+
return { readOnly: refusal };
|
|
238
|
+
const items = [];
|
|
239
|
+
let itemFrom = at;
|
|
240
|
+
for (let index = at; index <= fromAt; index += 1) {
|
|
241
|
+
if (index === fromAt || (depth[index] === 0 && reader.symbol(tokens[index]) === COMMA)) {
|
|
242
|
+
if (index === itemFrom)
|
|
243
|
+
return { readOnly: SELECT_READ_ONLY_REASONS.notSelect };
|
|
244
|
+
items.push(selectItem(reader, itemFrom, index));
|
|
245
|
+
itemFrom = index + 1;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (reader.symbol(tokens[fromAt + 1]) === OPEN)
|
|
249
|
+
return { readOnly: SELECT_READ_ONLY_REASONS.derived };
|
|
250
|
+
if (reader.word(tokens[fromAt + 1]) === ONLY)
|
|
251
|
+
return { readOnly: SELECT_READ_ONLY_REASONS.notSelect };
|
|
252
|
+
const table = dottedName(reader, fromAt + 1);
|
|
253
|
+
if (table === null)
|
|
254
|
+
return { readOnly: SELECT_READ_ONLY_REASONS.notSelect };
|
|
255
|
+
if (reader.symbol(tokens[table.next]) === OPEN)
|
|
256
|
+
return { readOnly: SELECT_READ_ONLY_REASONS.derived };
|
|
257
|
+
let next = table.next;
|
|
258
|
+
let alias = null;
|
|
259
|
+
const afterTable = reader.word(tokens[next]);
|
|
260
|
+
if (afterTable === AS) {
|
|
261
|
+
alias = reader.identifier(tokens[next + 1]);
|
|
262
|
+
if (alias === null)
|
|
263
|
+
return { readOnly: SELECT_READ_ONLY_REASONS.notSelect };
|
|
264
|
+
next += 2;
|
|
265
|
+
}
|
|
266
|
+
else if (tokens[next] !== undefined && (afterTable === null || !isClauseWord(afterTable))) {
|
|
267
|
+
alias = reader.identifier(tokens[next]);
|
|
268
|
+
if (alias !== null)
|
|
269
|
+
next += 1;
|
|
270
|
+
}
|
|
271
|
+
const tail = tailRefusal(reader, next, depth);
|
|
272
|
+
if (tail !== null)
|
|
273
|
+
return { readOnly: tail };
|
|
274
|
+
const name = table.parts.at(-1);
|
|
275
|
+
if (name === undefined)
|
|
276
|
+
return { readOnly: SELECT_READ_ONLY_REASONS.notSelect };
|
|
277
|
+
return { qualifier: table.parts.slice(0, -1), name, alias, items };
|
|
278
|
+
}
|
|
279
|
+
function isClauseWord(word) {
|
|
280
|
+
return (TAIL_CLAUSES.has(word) ||
|
|
281
|
+
JOINS.has(word) ||
|
|
282
|
+
GROUPING.has(word) ||
|
|
283
|
+
SET_OPERATIONS.has(word) ||
|
|
284
|
+
OTHER_TAIL.has(word) ||
|
|
285
|
+
word === ON ||
|
|
286
|
+
word === ONLY);
|
|
287
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SqlDialectOptions } from "./tokens.js";
|
|
2
|
+
/** The function call around a caret, before the plugin looks its name up. */
|
|
3
|
+
export interface CallAt {
|
|
4
|
+
/** As written. */
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly argument: number;
|
|
7
|
+
readonly from: number;
|
|
8
|
+
readonly to: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Finds the innermost call around `offset` whose name `known` accepts, counting its top-level
|
|
12
|
+
* commas; strings, comments and quoted names are tokens of their own, so their commas never count.
|
|
13
|
+
* Grouping parentheses are looked through; a subquery, a non-function name before `(` or a caret
|
|
14
|
+
* in a string or comment ends the search with null. `reserved` holds the dialect's keywords: a
|
|
15
|
+
* function spelled like one (`VALUES`, `LEFT`) is a call only where an operand fits, not after a
|
|
16
|
+
* name such as `INSERT INTO t VALUES (`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function callAt(text: string, offset: number, dialect: SqlDialectOptions, reserved: ReadonlySet<string>, known: (name: string) => boolean): CallAt | null;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { statementTokens } from "./scope.js";
|
|
2
|
+
/** Words a relation or routine name follows, so `TABLE upper (` defines rather than calls. */
|
|
3
|
+
const NAME_INTRODUCERS = new Set([
|
|
4
|
+
"CALL",
|
|
5
|
+
"EXISTS",
|
|
6
|
+
"FROM",
|
|
7
|
+
"FUNCTION",
|
|
8
|
+
"INDEX",
|
|
9
|
+
"INTO",
|
|
10
|
+
"JOIN",
|
|
11
|
+
"KEY",
|
|
12
|
+
"PROCEDURE",
|
|
13
|
+
"REFERENCES",
|
|
14
|
+
"TABLE",
|
|
15
|
+
"TRIGGER",
|
|
16
|
+
"UPDATE",
|
|
17
|
+
"VIEW",
|
|
18
|
+
]);
|
|
19
|
+
const QUERY_STARTS = new Set(["SELECT", "WITH"]);
|
|
20
|
+
function symbol(text, token, char) {
|
|
21
|
+
return token?.kind === "symbol" && text.slice(token.from, token.to) === char;
|
|
22
|
+
}
|
|
23
|
+
function upperWord(text, token) {
|
|
24
|
+
return token?.kind === "word" ? text.slice(token.from, token.to).toUpperCase() : null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Finds the innermost call around `offset` whose name `known` accepts, counting its top-level
|
|
28
|
+
* commas; strings, comments and quoted names are tokens of their own, so their commas never count.
|
|
29
|
+
* Grouping parentheses are looked through; a subquery, a non-function name before `(` or a caret
|
|
30
|
+
* in a string or comment ends the search with null. `reserved` holds the dialect's keywords: a
|
|
31
|
+
* function spelled like one (`VALUES`, `LEFT`) is a call only where an operand fits, not after a
|
|
32
|
+
* name such as `INSERT INTO t VALUES (`.
|
|
33
|
+
*/
|
|
34
|
+
export function callAt(text, offset, dialect, reserved, known) {
|
|
35
|
+
const { tokens, literal } = statementTokens(text, offset, dialect);
|
|
36
|
+
if (literal)
|
|
37
|
+
return null;
|
|
38
|
+
function callable(at) {
|
|
39
|
+
const name = tokens[at];
|
|
40
|
+
const word = upperWord(text, name);
|
|
41
|
+
if (name === undefined || word === null || !known(word))
|
|
42
|
+
return null;
|
|
43
|
+
const before = tokens[at - 1];
|
|
44
|
+
if (symbol(text, before, "."))
|
|
45
|
+
return null;
|
|
46
|
+
const previous = upperWord(text, before);
|
|
47
|
+
if (previous !== null && NAME_INTRODUCERS.has(previous))
|
|
48
|
+
return null;
|
|
49
|
+
if (!reserved.has(word))
|
|
50
|
+
return name;
|
|
51
|
+
const operandFits = before === undefined ||
|
|
52
|
+
(before.kind === "symbol" && !symbol(text, before, ")")) ||
|
|
53
|
+
(previous !== null && reserved.has(previous));
|
|
54
|
+
return operandFits ? name : null;
|
|
55
|
+
}
|
|
56
|
+
const frames = [];
|
|
57
|
+
let index = 0;
|
|
58
|
+
for (; index < tokens.length; index += 1) {
|
|
59
|
+
const token = tokens[index];
|
|
60
|
+
if (token === undefined || token.from >= offset)
|
|
61
|
+
break;
|
|
62
|
+
if (symbol(text, token, "(")) {
|
|
63
|
+
const name = callable(index - 1);
|
|
64
|
+
frames.push({ name, named: name === null && !grouping(text, tokens[index - 1]), open: index, commas: 0 });
|
|
65
|
+
}
|
|
66
|
+
else if (symbol(text, token, ")"))
|
|
67
|
+
frames.pop();
|
|
68
|
+
else if (symbol(text, token, ",")) {
|
|
69
|
+
const top = frames.at(-1);
|
|
70
|
+
if (top !== undefined)
|
|
71
|
+
top.commas += 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (let depth = frames.length - 1; depth >= 0; depth -= 1) {
|
|
75
|
+
const frame = frames[depth];
|
|
76
|
+
if (frame === undefined)
|
|
77
|
+
return null;
|
|
78
|
+
if (frame.name === null) {
|
|
79
|
+
if (frame.named || QUERY_STARTS.has(upperWord(text, tokens[frame.open + 1]) ?? ""))
|
|
80
|
+
return null;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
name: text.slice(frame.name.from, frame.name.to),
|
|
85
|
+
argument: frame.commas,
|
|
86
|
+
from: frame.name.from,
|
|
87
|
+
to: closeOf(text, tokens, index, frames.length - depth) ?? offset,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/** Whether a `(` after `before` only groups: it starts the statement or follows an operator or operand keyword. */
|
|
93
|
+
function grouping(text, before) {
|
|
94
|
+
if (before === undefined)
|
|
95
|
+
return true;
|
|
96
|
+
if (before.kind === "symbol")
|
|
97
|
+
return !symbol(text, before, ")");
|
|
98
|
+
return GROUPING_WORDS.has(upperWord(text, before) ?? "");
|
|
99
|
+
}
|
|
100
|
+
/** Keywords an operand follows, so a `(` right after one groups an expression instead of naming a list. */
|
|
101
|
+
const GROUPING_WORDS = new Set([
|
|
102
|
+
"AND",
|
|
103
|
+
"AS",
|
|
104
|
+
"BETWEEN",
|
|
105
|
+
"BY",
|
|
106
|
+
"CASE",
|
|
107
|
+
"DISTINCT",
|
|
108
|
+
"ELSE",
|
|
109
|
+
"HAVING",
|
|
110
|
+
"IS",
|
|
111
|
+
"LIKE",
|
|
112
|
+
"NOT",
|
|
113
|
+
"ON",
|
|
114
|
+
"OR",
|
|
115
|
+
"RETURN",
|
|
116
|
+
"SELECT",
|
|
117
|
+
"SET",
|
|
118
|
+
"THEN",
|
|
119
|
+
"WHEN",
|
|
120
|
+
"WHERE",
|
|
121
|
+
"XOR",
|
|
122
|
+
]);
|
|
123
|
+
/** The end of the `)` closing the call `levels` parentheses up from the caret, if the statement has one. */
|
|
124
|
+
function closeOf(text, tokens, from, levels) {
|
|
125
|
+
let open = levels;
|
|
126
|
+
for (let at = from; at < tokens.length; at += 1) {
|
|
127
|
+
const token = tokens[at];
|
|
128
|
+
if (symbol(text, token, "("))
|
|
129
|
+
open += 1;
|
|
130
|
+
else if (symbol(text, token, ")")) {
|
|
131
|
+
open -= 1;
|
|
132
|
+
if (open === 0)
|
|
133
|
+
return token?.to ?? null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { StatementSpan, StatementStream } from "@tablecraft-app/plugin-api";
|
|
2
|
+
import { type SqlDialectOptions } from "./tokens.js";
|
|
3
|
+
/**
|
|
4
|
+
* A configurable splitter plugins may build `SqlLanguage.split` on (decision 023). It tokenizes
|
|
5
|
+
* strings, quoted identifiers, comments, dollar quotes, `DELIMITER` lines and `BEGIN … END`
|
|
6
|
+
* bodies, and drops statements that hold nothing but comments.
|
|
7
|
+
*/
|
|
8
|
+
export declare function sqlSplitter(options?: SqlDialectOptions): (text: string) => StatementSpan[];
|
|
9
|
+
/**
|
|
10
|
+
* `sqlSplitter` over text that arrives in pieces, for `SqlLanguage.splitStream` (decision 081).
|
|
11
|
+
* Only whole lines are scanned before `end`, so a token, a `DELIMITER` line or a multi-character
|
|
12
|
+
* delimiter cut by a push is read once its line is complete. Statements come out exactly as
|
|
13
|
+
* `sqlSplitter` would find them in the concatenated text.
|
|
14
|
+
*/
|
|
15
|
+
export declare function sqlStatementStream(options?: SqlDialectOptions): () => StatementStream;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { DEFAULT_DELIMITER, Lexer, separatorLineAt, wordOf, } from "./tokens.js";
|
|
2
|
+
const DELIMITER_COMMAND = "DELIMITER";
|
|
3
|
+
/** A body only opens inside a statement that defines a routine, so a column called `begin` stays a column. */
|
|
4
|
+
const BODY_OWNERS = new Set(["TRIGGER", "FUNCTION", "PROCEDURE", "EVENT"]);
|
|
5
|
+
const CREATE = "CREATE";
|
|
6
|
+
const CASE = "CASE";
|
|
7
|
+
const END = "END";
|
|
8
|
+
/** `END IF`, `END LOOP` and the like close a construct the splitter never counted as opened. */
|
|
9
|
+
const UNCOUNTED_END_TARGETS = new Set(["IF", "LOOP", "WHILE", "REPEAT"]);
|
|
10
|
+
/** Words that may stand between `CREATE` and the kind of routine: `CREATE OR REPLACE EDITIONABLE PROCEDURE`. */
|
|
11
|
+
const ROUTINE_HEAD_WORDS = new Set(["OR", "REPLACE", "ALTER", "EDITIONABLE", "NONEDITIONABLE"]);
|
|
12
|
+
const ALTER = "ALTER";
|
|
13
|
+
/** What `sqlcmd` and SQL*Plus send whole up to the separator line, `;` included. */
|
|
14
|
+
const SEPARATED_ROUTINES = {
|
|
15
|
+
go: new Set(["PROCEDURE", "PROC", "FUNCTION", "TRIGGER"]),
|
|
16
|
+
slash: new Set(["PROCEDURE", "FUNCTION", "TRIGGER", "PACKAGE", "TYPE"]),
|
|
17
|
+
};
|
|
18
|
+
/** An Oracle anonymous block, which also runs to the `/` line. */
|
|
19
|
+
const ANONYMOUS_BLOCK_STARTS = new Set(["DECLARE", "BEGIN"]);
|
|
20
|
+
const LINE_BREAK = /\r?\n/;
|
|
21
|
+
const NEWLINE = "\n";
|
|
22
|
+
/**
|
|
23
|
+
* A stream rescans its unfinished text only once it has doubled, so one statement spanning a
|
|
24
|
+
* thousand pushes is read a logarithmic number of times rather than once per push.
|
|
25
|
+
*/
|
|
26
|
+
const RESCAN_GROWTH = 2;
|
|
27
|
+
function freshStatement(from) {
|
|
28
|
+
return {
|
|
29
|
+
from,
|
|
30
|
+
to: from,
|
|
31
|
+
words: 0,
|
|
32
|
+
isCreate: false,
|
|
33
|
+
definesRoutine: false,
|
|
34
|
+
depth: 0,
|
|
35
|
+
pendingEnd: false,
|
|
36
|
+
routineHead: false,
|
|
37
|
+
toSeparator: false,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function scanner(options) {
|
|
41
|
+
const blockKeywords = new Set((options.blockKeywords ?? []).map((keyword) => keyword.toUpperCase()));
|
|
42
|
+
const separator = options.batchSeparator;
|
|
43
|
+
return (text, startDelimiter, final) => {
|
|
44
|
+
const spans = [];
|
|
45
|
+
const lexer = new Lexer(text, options);
|
|
46
|
+
let delimiter = startDelimiter;
|
|
47
|
+
let statement = null;
|
|
48
|
+
let checkpoint = { offset: 0, delimiter };
|
|
49
|
+
const flush = (end, repeat) => {
|
|
50
|
+
if (statement !== null) {
|
|
51
|
+
const span = { from: statement.from, to: statement.to, sql: text.slice(statement.from, statement.to) };
|
|
52
|
+
spans.push(repeat === undefined ? span : { ...span, repeat });
|
|
53
|
+
}
|
|
54
|
+
statement = null;
|
|
55
|
+
checkpoint = { offset: end, delimiter };
|
|
56
|
+
};
|
|
57
|
+
const readDelimiterCommand = (token) => {
|
|
58
|
+
const rest = text.slice(token.to);
|
|
59
|
+
const line = rest.split(LINE_BREAK, 1)[0] ?? "";
|
|
60
|
+
const value = line.trim().split(/\s+/, 1)[0] ?? "";
|
|
61
|
+
if (value !== "")
|
|
62
|
+
delimiter = value;
|
|
63
|
+
lexer.position = token.to + line.length;
|
|
64
|
+
checkpoint = { offset: lexer.position, delimiter };
|
|
65
|
+
};
|
|
66
|
+
const resolvePendingEnd = (current, token) => {
|
|
67
|
+
if (!current.pendingEnd)
|
|
68
|
+
return false;
|
|
69
|
+
current.pendingEnd = false;
|
|
70
|
+
const word = token.kind === "word" ? wordOf(text, token) : "";
|
|
71
|
+
if (UNCOUNTED_END_TARGETS.has(word))
|
|
72
|
+
return true;
|
|
73
|
+
current.depth = Math.max(0, current.depth - 1);
|
|
74
|
+
return word === CASE;
|
|
75
|
+
};
|
|
76
|
+
for (let token = lexer.next(delimiter); token !== null; token = lexer.next(delimiter)) {
|
|
77
|
+
if (token.kind === "comment")
|
|
78
|
+
continue;
|
|
79
|
+
const line = separator === undefined ? null : separatorLineAt(text, token, separator);
|
|
80
|
+
if (line !== null) {
|
|
81
|
+
flush(line.to, line.repeat);
|
|
82
|
+
lexer.position = line.to;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (token.kind === "delimiter") {
|
|
86
|
+
const current = statement;
|
|
87
|
+
if (current !== null)
|
|
88
|
+
resolvePendingEnd(current, token);
|
|
89
|
+
const insideBody = current !== null && (current.toSeparator || (current.depth > 0 && delimiter === DEFAULT_DELIMITER));
|
|
90
|
+
if (insideBody)
|
|
91
|
+
current.to = token.to;
|
|
92
|
+
else
|
|
93
|
+
flush(token.to);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (statement === null) {
|
|
97
|
+
if (options.delimiterCommand === true && token.kind === "word" && wordOf(text, token) === DELIMITER_COMMAND) {
|
|
98
|
+
readDelimiterCommand(token);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
statement = freshStatement(token.from);
|
|
102
|
+
}
|
|
103
|
+
const current = statement;
|
|
104
|
+
current.to = token.to;
|
|
105
|
+
const consumed = resolvePendingEnd(current, token);
|
|
106
|
+
if (token.kind !== "word" || consumed)
|
|
107
|
+
continue;
|
|
108
|
+
const word = wordOf(text, token);
|
|
109
|
+
current.words += 1;
|
|
110
|
+
if (current.words === 1) {
|
|
111
|
+
current.isCreate = word === CREATE;
|
|
112
|
+
current.routineHead = current.isCreate || (separator === "go" && word === ALTER);
|
|
113
|
+
current.toSeparator = separator === "slash" && ANONYMOUS_BLOCK_STARTS.has(word);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (current.routineHead && separator !== undefined) {
|
|
117
|
+
current.routineHead = ROUTINE_HEAD_WORDS.has(word);
|
|
118
|
+
if (SEPARATED_ROUTINES[separator].has(word))
|
|
119
|
+
current.toSeparator = true;
|
|
120
|
+
}
|
|
121
|
+
if (current.depth > 0) {
|
|
122
|
+
if (word === END)
|
|
123
|
+
current.pendingEnd = true;
|
|
124
|
+
else if (blockKeywords.has(word) || word === CASE)
|
|
125
|
+
current.depth += 1;
|
|
126
|
+
}
|
|
127
|
+
else if (current.definesRoutine && blockKeywords.has(word)) {
|
|
128
|
+
current.depth = 1;
|
|
129
|
+
}
|
|
130
|
+
else if (current.isCreate && BODY_OWNERS.has(word)) {
|
|
131
|
+
current.definesRoutine = true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (final)
|
|
135
|
+
flush(text.length);
|
|
136
|
+
return { spans, checkpoint };
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* A configurable splitter plugins may build `SqlLanguage.split` on (decision 023). It tokenizes
|
|
141
|
+
* strings, quoted identifiers, comments, dollar quotes, `DELIMITER` lines and `BEGIN … END`
|
|
142
|
+
* bodies, and drops statements that hold nothing but comments.
|
|
143
|
+
*/
|
|
144
|
+
export function sqlSplitter(options = {}) {
|
|
145
|
+
const scan = scanner(options);
|
|
146
|
+
return (text) => scan(text, DEFAULT_DELIMITER, true).spans;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* `sqlSplitter` over text that arrives in pieces, for `SqlLanguage.splitStream` (decision 081).
|
|
150
|
+
* Only whole lines are scanned before `end`, so a token, a `DELIMITER` line or a multi-character
|
|
151
|
+
* delimiter cut by a push is read once its line is complete. Statements come out exactly as
|
|
152
|
+
* `sqlSplitter` would find them in the concatenated text.
|
|
153
|
+
*/
|
|
154
|
+
export function sqlStatementStream(options = {}) {
|
|
155
|
+
const scan = scanner(options);
|
|
156
|
+
return () => {
|
|
157
|
+
let pending = "";
|
|
158
|
+
/** Where `pending` starts in everything pushed, and the line and column there. */
|
|
159
|
+
let base = 0;
|
|
160
|
+
let line = 1;
|
|
161
|
+
let column = 1;
|
|
162
|
+
let delimiter = DEFAULT_DELIMITER;
|
|
163
|
+
let scannedLength = 0;
|
|
164
|
+
const advance = (consumed) => {
|
|
165
|
+
let lineStart = -1;
|
|
166
|
+
for (let at = consumed.indexOf(NEWLINE); at >= 0; at = consumed.indexOf(NEWLINE, at + 1)) {
|
|
167
|
+
line += 1;
|
|
168
|
+
lineStart = at + 1;
|
|
169
|
+
}
|
|
170
|
+
column = lineStart < 0 ? column + consumed.length : consumed.length - lineStart + 1;
|
|
171
|
+
base += consumed.length;
|
|
172
|
+
};
|
|
173
|
+
const located = (spans) => {
|
|
174
|
+
const startLine = line;
|
|
175
|
+
const startColumn = column;
|
|
176
|
+
let cursor = 0;
|
|
177
|
+
let cursorLine = startLine;
|
|
178
|
+
let cursorColumn = startColumn;
|
|
179
|
+
return spans.map((span) => {
|
|
180
|
+
for (let at = pending.indexOf(NEWLINE, cursor); at >= 0 && at < span.from; at = pending.indexOf(NEWLINE, at + 1)) {
|
|
181
|
+
cursorLine += 1;
|
|
182
|
+
cursorColumn = 1;
|
|
183
|
+
cursor = at + 1;
|
|
184
|
+
}
|
|
185
|
+
cursorColumn += span.from - cursor;
|
|
186
|
+
cursor = span.from;
|
|
187
|
+
return { ...span, from: base + span.from, to: base + span.to, line: cursorLine, column: cursorColumn };
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
const run = (final) => {
|
|
191
|
+
const scanned = final ? pending : pending.slice(0, pending.lastIndexOf(NEWLINE) + 1);
|
|
192
|
+
if (scanned === "")
|
|
193
|
+
return [];
|
|
194
|
+
const { spans, checkpoint } = scan(scanned, delimiter, final);
|
|
195
|
+
const statements = located(spans);
|
|
196
|
+
const consumed = final ? pending.length : checkpoint.offset;
|
|
197
|
+
advance(pending.slice(0, consumed));
|
|
198
|
+
pending = pending.slice(consumed);
|
|
199
|
+
delimiter = checkpoint.delimiter;
|
|
200
|
+
scannedLength = pending.length;
|
|
201
|
+
return statements;
|
|
202
|
+
};
|
|
203
|
+
return {
|
|
204
|
+
push: (text) => {
|
|
205
|
+
pending += text;
|
|
206
|
+
if (pending.length < scannedLength * RESCAN_GROWTH)
|
|
207
|
+
return [];
|
|
208
|
+
return run(false);
|
|
209
|
+
},
|
|
210
|
+
end: () => {
|
|
211
|
+
const statements = run(true);
|
|
212
|
+
pending = "";
|
|
213
|
+
return statements;
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** How many "Did you mean" fixes an unresolved name offers. */
|
|
2
|
+
export declare const MAX_SUGGESTIONS = 3;
|
|
3
|
+
/** The edit distance between two names, compared case-blind. */
|
|
4
|
+
export declare function editDistance(left: string, right: string): number;
|
|
5
|
+
/** The closest `candidates` to `name`, nearest first, within max(2, a third of its length). */
|
|
6
|
+
export declare function suggestions(name: string, candidates: Iterable<string>): string[];
|