@tablecraft-app/plugin-api 0.0.0-stage → 1.2.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 +67 -0
- package/dist/errors.js +124 -0
- package/dist/explain.d.ts +58 -0
- package/dist/explain.js +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/inspection.d.ts +38 -0
- package/dist/inspection.js +21 -0
- package/dist/keyspace.d.ts +180 -0
- package/dist/keyspace.js +2 -0
- package/dist/manifest.d.ts +200 -0
- package/dist/manifest.js +238 -0
- package/dist/objects.d.ts +140 -0
- package/dist/objects.js +2 -0
- package/dist/plugin.d.ts +565 -0
- package/dist/plugin.js +58 -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 +274 -0
- package/dist/testing/index.js +2398 -0
- package/dist/tls.d.ts +33 -0
- package/dist/tls.js +118 -0
- package/dist/url.d.ts +54 -0
- package/dist/url.js +228 -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,480 @@
|
|
|
1
|
+
import { functionItem, functionName, isAvailable } from "./functions.js";
|
|
2
|
+
import { callAt } from "./signature.js";
|
|
3
|
+
import { parseSimpleSelect, SELECT_READ_ONLY_REASONS } from "./select.js";
|
|
4
|
+
import { scopeAt } from "./scope.js";
|
|
5
|
+
import { namespaceSlotAt } from "./namespace.js";
|
|
6
|
+
import { levels, nameRules, referenceName, samePath, targetable, visibleCtes, } from "./resolution.js";
|
|
7
|
+
/** Reserved words every built-in dialect shares; a plugin adds its own. */
|
|
8
|
+
export const SQL_KEYWORDS = [
|
|
9
|
+
"ALL",
|
|
10
|
+
"AND",
|
|
11
|
+
"AS",
|
|
12
|
+
"ASC",
|
|
13
|
+
"BETWEEN",
|
|
14
|
+
"BY",
|
|
15
|
+
"CASE",
|
|
16
|
+
"CREATE",
|
|
17
|
+
"CROSS",
|
|
18
|
+
"DELETE",
|
|
19
|
+
"DESC",
|
|
20
|
+
"DISTINCT",
|
|
21
|
+
"DROP",
|
|
22
|
+
"ELSE",
|
|
23
|
+
"END",
|
|
24
|
+
"EXISTS",
|
|
25
|
+
"FALSE",
|
|
26
|
+
"FROM",
|
|
27
|
+
"FULL",
|
|
28
|
+
"GROUP",
|
|
29
|
+
"HAVING",
|
|
30
|
+
"IN",
|
|
31
|
+
"INNER",
|
|
32
|
+
"INSERT",
|
|
33
|
+
"INTO",
|
|
34
|
+
"IS",
|
|
35
|
+
"JOIN",
|
|
36
|
+
"LEFT",
|
|
37
|
+
"LIKE",
|
|
38
|
+
"LIMIT",
|
|
39
|
+
"NOT",
|
|
40
|
+
"NULL",
|
|
41
|
+
"OFFSET",
|
|
42
|
+
"ON",
|
|
43
|
+
"OR",
|
|
44
|
+
"ORDER",
|
|
45
|
+
"OUTER",
|
|
46
|
+
"RIGHT",
|
|
47
|
+
"SELECT",
|
|
48
|
+
"SET",
|
|
49
|
+
"TABLE",
|
|
50
|
+
"THEN",
|
|
51
|
+
"TRUE",
|
|
52
|
+
"UNION",
|
|
53
|
+
"UPDATE",
|
|
54
|
+
"USING",
|
|
55
|
+
"VALUES",
|
|
56
|
+
"WHEN",
|
|
57
|
+
"WHERE",
|
|
58
|
+
"WITH",
|
|
59
|
+
];
|
|
60
|
+
/** Ranks items the typed prefix matches equally well (plan 009 decision 12). */
|
|
61
|
+
const BOOST = {
|
|
62
|
+
join: 50,
|
|
63
|
+
column: 40,
|
|
64
|
+
cte: 35,
|
|
65
|
+
table: 30,
|
|
66
|
+
view: 30,
|
|
67
|
+
alias: 20,
|
|
68
|
+
namespace: 10,
|
|
69
|
+
function: 0,
|
|
70
|
+
keyword: -10,
|
|
71
|
+
};
|
|
72
|
+
const UNQUOTED_WORD = "^[A-Za-z0-9_$]*$";
|
|
73
|
+
const ALIAS_WORD_BREAK = /[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])/;
|
|
74
|
+
const FALLBACK_ALIAS = "t";
|
|
75
|
+
const QUOTES = new Set(['"', "`", "["]);
|
|
76
|
+
/** Below every other namespace: `USE mysql` is legitimate but rarely meant. */
|
|
77
|
+
const SYSTEM_NAMESPACE_BOOST = -20;
|
|
78
|
+
const CURRENT_DETAIL = "current";
|
|
79
|
+
function relationKind(relation) {
|
|
80
|
+
return relation.kind === "table" ? "table" : "view";
|
|
81
|
+
}
|
|
82
|
+
/** The shared completion and navigation every built-in plugin spreads into its `SqlLanguage` (decision 028). */
|
|
83
|
+
export function sqlCompleter(options) {
|
|
84
|
+
const reserved = new Set(options.keywords.map((keyword) => keyword.toUpperCase()));
|
|
85
|
+
const { denotes, sameIdentifier, cteNamed, sourceNamed, serverOf, Lookup } = nameRules(options);
|
|
86
|
+
const specs = new Map();
|
|
87
|
+
for (const spec of options.functions) {
|
|
88
|
+
if (typeof spec === "string")
|
|
89
|
+
continue;
|
|
90
|
+
const key = spec.name.toUpperCase();
|
|
91
|
+
specs.set(key, [...(specs.get(key) ?? []), spec]);
|
|
92
|
+
}
|
|
93
|
+
function specNamed(name, server) {
|
|
94
|
+
return specs.get(name.toUpperCase())?.find((spec) => isAvailable(spec, server));
|
|
95
|
+
}
|
|
96
|
+
function quoted(name) {
|
|
97
|
+
return options.plainIdentifier.test(name) && !reserved.has(name.toUpperCase())
|
|
98
|
+
? name
|
|
99
|
+
: options.quoteIdentifier(name);
|
|
100
|
+
}
|
|
101
|
+
function item(kind, label, apply, detail) {
|
|
102
|
+
return { label, kind, detail, apply, boost: BOOST[kind], info: null, caret: null };
|
|
103
|
+
}
|
|
104
|
+
function relationItems(relations, namespace) {
|
|
105
|
+
return relations.map((relation) => item(relationKind(relation), relation.name, quoted(relation.name), namespace));
|
|
106
|
+
}
|
|
107
|
+
function columnItems(columns) {
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
return columns
|
|
110
|
+
.filter((column) => !seen.has(column.name) && seen.add(column.name))
|
|
111
|
+
.map((column) => item("column", column.name, quoted(column.name), column.type));
|
|
112
|
+
}
|
|
113
|
+
function keywordItems(except = new Set()) {
|
|
114
|
+
return options.keywords
|
|
115
|
+
.filter((keyword) => !except.has(keyword.toUpperCase()))
|
|
116
|
+
.map((keyword) => item("keyword", keyword, keyword, null));
|
|
117
|
+
}
|
|
118
|
+
function availableFunctions(server) {
|
|
119
|
+
const offered = new Set();
|
|
120
|
+
return options.functions.filter((spec) => {
|
|
121
|
+
const name = functionName(spec).toUpperCase();
|
|
122
|
+
if (offered.has(name) || (typeof spec !== "string" && !isAvailable(spec, server)))
|
|
123
|
+
return false;
|
|
124
|
+
offered.add(name);
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async function qualifiedItems(scope, lookup) {
|
|
129
|
+
const { qualifier, query, clause } = scope;
|
|
130
|
+
const last = qualifier.at(-1);
|
|
131
|
+
if (last === undefined)
|
|
132
|
+
return [];
|
|
133
|
+
const relational = clause === "relation" || clause === "join";
|
|
134
|
+
if (qualifier.length === 1 && !relational) {
|
|
135
|
+
const source = sourceNamed(query, last);
|
|
136
|
+
if (source !== undefined)
|
|
137
|
+
return columnItems(await lookup.columnsOf(source, query));
|
|
138
|
+
const cte = cteNamed(query, last);
|
|
139
|
+
if (cte !== undefined)
|
|
140
|
+
return columnItems(await lookup.cteColumns(cte, 1));
|
|
141
|
+
}
|
|
142
|
+
const namespace = await lookup.namespace(qualifier);
|
|
143
|
+
if (namespace !== undefined)
|
|
144
|
+
return relationItems(await lookup.relations(namespace.path), null);
|
|
145
|
+
if (relational)
|
|
146
|
+
return [];
|
|
147
|
+
const found = await lookup.relation(qualifier.slice(0, -1), last);
|
|
148
|
+
return found === null ? [] : columnItems(found.relation.columns);
|
|
149
|
+
}
|
|
150
|
+
async function relationContextItems(scope, lookup) {
|
|
151
|
+
const current = lookup.current;
|
|
152
|
+
const namespaces = await lookup.namespaces();
|
|
153
|
+
const currentName = namespaces.find((namespace) => samePath(namespace.path, current))?.name ?? null;
|
|
154
|
+
const relations = current === null ? [] : await lookup.relations(current);
|
|
155
|
+
const items = [
|
|
156
|
+
...visibleCtes(scope.query).map((cte) => item("cte", cte.name.name, quoted(cte.name.name), null)),
|
|
157
|
+
...relationItems(relations, currentName),
|
|
158
|
+
...namespaces
|
|
159
|
+
.filter((namespace) => !namespace.system && !samePath(namespace.path, current))
|
|
160
|
+
.map((namespace) => item("namespace", namespace.name, quoted(namespace.name), namespace.kind)),
|
|
161
|
+
];
|
|
162
|
+
if (scope.clause !== "join")
|
|
163
|
+
return items;
|
|
164
|
+
return [...(await joinItems(scope, lookup)), ...items];
|
|
165
|
+
}
|
|
166
|
+
function aliasFor(name, taken) {
|
|
167
|
+
const initials = name
|
|
168
|
+
.split(ALIAS_WORD_BREAK)
|
|
169
|
+
.filter((word) => word !== "")
|
|
170
|
+
.map((word) => word.charAt(0).toLowerCase())
|
|
171
|
+
.join("");
|
|
172
|
+
const base = initials === "" || !options.plainIdentifier.test(initials) ? FALLBACK_ALIAS : initials;
|
|
173
|
+
let alias = base;
|
|
174
|
+
for (let suffix = 1; taken.has(alias) || reserved.has(alias.toUpperCase()); suffix += 1)
|
|
175
|
+
alias = `${base}${String(suffix)}`;
|
|
176
|
+
taken.add(alias);
|
|
177
|
+
return alias;
|
|
178
|
+
}
|
|
179
|
+
function relationReference(path, name, current) {
|
|
180
|
+
const namespace = path.at(-1);
|
|
181
|
+
return samePath(path, current) || namespace === undefined ? quoted(name) : `${quoted(namespace)}.${quoted(name)}`;
|
|
182
|
+
}
|
|
183
|
+
/** `customers c ON c.id = o.customer_id` for each foreign key to or from a relation already in scope (plan 009 decision 7). */
|
|
184
|
+
async function joinItems(scope, lookup) {
|
|
185
|
+
const before = scope.query.sources.filter((source) => source.kind === "relation" && source.to <= scope.prefixFrom);
|
|
186
|
+
const taken = new Set(scope.query.sources
|
|
187
|
+
.map((source) => referenceName(source))
|
|
188
|
+
.filter((name) => name !== null)
|
|
189
|
+
.map((name) => name.name.toLowerCase()));
|
|
190
|
+
const items = [];
|
|
191
|
+
const seen = new Set();
|
|
192
|
+
const add = (label, detail) => {
|
|
193
|
+
if (seen.has(label))
|
|
194
|
+
return;
|
|
195
|
+
seen.add(label);
|
|
196
|
+
items.push(item("join", label, label, detail));
|
|
197
|
+
};
|
|
198
|
+
for (const source of before) {
|
|
199
|
+
if (source.qualifier.length === 0 && cteNamed(scope.query, source.name) !== undefined)
|
|
200
|
+
continue;
|
|
201
|
+
const found = await lookup.relation(source.qualifier, source.name);
|
|
202
|
+
if (found === null)
|
|
203
|
+
continue;
|
|
204
|
+
const near = source.alias === null
|
|
205
|
+
? relationReference(found.path, found.relation.name, lookup.current)
|
|
206
|
+
: quoted(source.alias.name);
|
|
207
|
+
for (const key of found.relation.foreignKeys) {
|
|
208
|
+
const alias = aliasFor(key.target.relation, new Set(taken));
|
|
209
|
+
const on = key.columns
|
|
210
|
+
.map((column, at) => `${alias}.${quoted(key.target.columns[at] ?? column)} = ${near}.${quoted(column)}`)
|
|
211
|
+
.join(" AND ");
|
|
212
|
+
add(`${relationReference(key.target.path, key.target.relation, lookup.current)} ${alias} ON ${on}`, key.name);
|
|
213
|
+
}
|
|
214
|
+
for (const incoming of await lookup.referencing(found.path, found.relation.name)) {
|
|
215
|
+
const key = incoming.foreignKey;
|
|
216
|
+
const alias = aliasFor(incoming.relation, new Set(taken));
|
|
217
|
+
const on = key.columns
|
|
218
|
+
.map((column, at) => `${alias}.${quoted(column)} = ${near}.${quoted(key.target.columns[at] ?? column)}`)
|
|
219
|
+
.join(" AND ");
|
|
220
|
+
add(`${relationReference(incoming.path, incoming.relation, lookup.current)} ${alias} ON ${on}`, key.name);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return items;
|
|
224
|
+
}
|
|
225
|
+
async function inScopeColumnItems(scope, lookup) {
|
|
226
|
+
const { query } = scope;
|
|
227
|
+
const perSource = await Promise.all(query.sources.map((source) => lookup.columnsOf(source, query)));
|
|
228
|
+
const owners = new Map();
|
|
229
|
+
for (const columns of perSource) {
|
|
230
|
+
for (const name of new Set(columns.map((column) => column.name)))
|
|
231
|
+
owners.set(name, (owners.get(name) ?? 0) + 1);
|
|
232
|
+
}
|
|
233
|
+
const items = [];
|
|
234
|
+
const seen = new Set();
|
|
235
|
+
query.sources.forEach((source, at) => {
|
|
236
|
+
const reference = referenceName(source);
|
|
237
|
+
for (const column of perSource[at] ?? []) {
|
|
238
|
+
const shared = (owners.get(column.name) ?? 0) > 1 && reference !== null;
|
|
239
|
+
const prefix = shared ? `${quoted(reference.name)}.` : "";
|
|
240
|
+
const label = shared ? `${reference.name}.${column.name}` : column.name;
|
|
241
|
+
if (seen.has(label))
|
|
242
|
+
continue;
|
|
243
|
+
seen.add(label);
|
|
244
|
+
items.push(item("column", label, `${prefix}${quoted(column.name)}`, column.type));
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
const aliases = query.sources
|
|
248
|
+
.filter((source) => source.alias !== null)
|
|
249
|
+
.map((source) => item("alias", source.alias?.name ?? "", quoted(source.alias?.name ?? ""), source.kind === "relation" ? source.name.name : null));
|
|
250
|
+
const functions = availableFunctions(lookup.server);
|
|
251
|
+
const functionNames = new Set(functions.map((spec) => functionName(spec).toUpperCase()));
|
|
252
|
+
return [
|
|
253
|
+
...items,
|
|
254
|
+
...aliases,
|
|
255
|
+
...functions.map((spec) => functionItem(spec, BOOST.function)),
|
|
256
|
+
...keywordItems(functionNames),
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
async function itemsFor(scope, lookup) {
|
|
260
|
+
if (scope.clause === "none")
|
|
261
|
+
return [];
|
|
262
|
+
if (scope.qualifier.length > 0)
|
|
263
|
+
return qualifiedItems(scope, lookup);
|
|
264
|
+
switch (scope.clause) {
|
|
265
|
+
case "relation":
|
|
266
|
+
case "join":
|
|
267
|
+
return relationContextItems(scope, lookup);
|
|
268
|
+
case "columns":
|
|
269
|
+
return inScopeColumnItems(scope, lookup);
|
|
270
|
+
case "insertColumns": {
|
|
271
|
+
const target = scope.query.insert?.source;
|
|
272
|
+
const found = target === undefined ? null : await lookup.relation(target.qualifier, target.name);
|
|
273
|
+
return found === null ? [] : columnItems(found.relation.columns);
|
|
274
|
+
}
|
|
275
|
+
case "keywords":
|
|
276
|
+
return keywordItems();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/** Inside a quoted name every name is offered quoted, since the editor filters on what was typed. */
|
|
280
|
+
function quotedLabels(items) {
|
|
281
|
+
return items.map((entry) => entry.kind === "keyword" || entry.kind === "function" || entry.kind === "join"
|
|
282
|
+
? entry
|
|
283
|
+
: { ...entry, label: options.quoteIdentifier(entry.label) });
|
|
284
|
+
}
|
|
285
|
+
/** A clause body with `scope.relation` in scope reads as the WHERE of a `SELECT` from it. */
|
|
286
|
+
function wrap(request) {
|
|
287
|
+
if (request.scope === undefined)
|
|
288
|
+
return { text: request.text, offset: request.offset, shift: 0 };
|
|
289
|
+
const { path, relation } = request.scope;
|
|
290
|
+
const namespace = path.at(-1);
|
|
291
|
+
const table = namespace === undefined
|
|
292
|
+
? options.quoteIdentifier(relation)
|
|
293
|
+
: `${options.quoteIdentifier(namespace)}.${options.quoteIdentifier(relation)}`;
|
|
294
|
+
const head = `SELECT * FROM ${table} WHERE `;
|
|
295
|
+
return { text: head + request.text, offset: request.offset + head.length, shift: head.length };
|
|
296
|
+
}
|
|
297
|
+
/** Decision 2 and 3 of plan 029: what a `USE` or search path element can name, as the caret's quote needs it. */
|
|
298
|
+
async function namespaceItems(slot, lookup) {
|
|
299
|
+
const target = { kind: slot.kind, candidates: [] };
|
|
300
|
+
const namespaces = (await lookup.namespaces()).filter((namespace) => targetable(target, namespace) &&
|
|
301
|
+
!slot.listed.some((listed) => denotes(listed, namespace.path.at(-1) ?? namespace.name)));
|
|
302
|
+
const ordered = [
|
|
303
|
+
...namespaces.filter((namespace) => !namespace.system),
|
|
304
|
+
...namespaces.filter((namespace) => namespace.system),
|
|
305
|
+
];
|
|
306
|
+
const quote = slot.quote;
|
|
307
|
+
return ordered.map((namespace) => {
|
|
308
|
+
const name = namespace.path.at(-1) ?? namespace.name;
|
|
309
|
+
const apply = quote === null
|
|
310
|
+
? quoted(name)
|
|
311
|
+
: name.replaceAll(quote.char, quote.char + quote.char) + (quote.closed ? "" : quote.char);
|
|
312
|
+
const detail = samePath(namespace.path, lookup.current) ? CURRENT_DETAIL : null;
|
|
313
|
+
const entry = item("namespace", name, apply, detail);
|
|
314
|
+
return namespace.system ? { ...entry, boost: SYSTEM_NAMESPACE_BOOST } : entry;
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
async function complete(request, catalog) {
|
|
318
|
+
if (request.scope === undefined) {
|
|
319
|
+
const slot = namespaceSlotAt(request.text, request.offset, options.dialect);
|
|
320
|
+
if (slot !== null) {
|
|
321
|
+
const lookup = new Lookup(catalog);
|
|
322
|
+
await lookup.follow(request.text, request.offset);
|
|
323
|
+
const items = await namespaceItems(slot, lookup);
|
|
324
|
+
return { from: slot.from, to: slot.to, items, validFor: slot.quote === null ? UNQUOTED_WORD : null };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const { text, offset, shift } = wrap(request);
|
|
328
|
+
const scope = scopeAt(text, offset, options.dialect);
|
|
329
|
+
const lookup = new Lookup(catalog);
|
|
330
|
+
if (request.scope === undefined)
|
|
331
|
+
await lookup.follow(text, offset);
|
|
332
|
+
const items = await itemsFor(scope, lookup);
|
|
333
|
+
const typedQuote = QUOTES.has(text.charAt(scope.prefixFrom)) && scope.prefixFrom < offset;
|
|
334
|
+
return {
|
|
335
|
+
from: scope.prefixFrom - shift,
|
|
336
|
+
to: offset - shift,
|
|
337
|
+
items: typedQuote ? quotedLabels(items) : items,
|
|
338
|
+
validFor: typedQuote ? null : UNQUOTED_WORD,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
function sourceDeclaring(query, word) {
|
|
342
|
+
for (const level of levels(query)) {
|
|
343
|
+
for (const source of level.sources) {
|
|
344
|
+
if (source.kind === "relation" && (source.name.from === word.from || source.alias?.from === word.from))
|
|
345
|
+
return source;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
async function relationDeclaration(lookup, qualifier, name) {
|
|
351
|
+
const found = await lookup.relation(qualifier, name);
|
|
352
|
+
return found === null ? null : { kind: "relation", path: found.path, relation: found.relation.name };
|
|
353
|
+
}
|
|
354
|
+
async function columnDeclaration(lookup, source, query, column) {
|
|
355
|
+
if (source.kind !== "relation")
|
|
356
|
+
return null;
|
|
357
|
+
if (source.qualifier.length === 0 && cteNamed(query, source.name) !== undefined)
|
|
358
|
+
return null;
|
|
359
|
+
const found = await lookup.relation(source.qualifier, source.name);
|
|
360
|
+
const meta = found?.relation.columns.find((candidate) => denotes(column, candidate.name));
|
|
361
|
+
return found === null || meta === undefined
|
|
362
|
+
? null
|
|
363
|
+
: { kind: "column", path: found.path, relation: found.relation.name, column: meta.name };
|
|
364
|
+
}
|
|
365
|
+
async function resolve(request, catalog) {
|
|
366
|
+
const scope = scopeAt(request.text, request.offset, options.dialect, "resolve");
|
|
367
|
+
const { word, qualifier, query } = scope;
|
|
368
|
+
if (word === null || scope.clause === "none")
|
|
369
|
+
return null;
|
|
370
|
+
const lookup = new Lookup(catalog);
|
|
371
|
+
await lookup.follow(request.text, request.offset);
|
|
372
|
+
const cte = visibleCtes(query).find((candidate) => candidate.name.from === word.from);
|
|
373
|
+
if (cte !== undefined)
|
|
374
|
+
return { kind: "cte", from: cte.name.from, to: cte.name.to };
|
|
375
|
+
const declaring = sourceDeclaring(query, word);
|
|
376
|
+
if (declaring !== undefined) {
|
|
377
|
+
if (declaring.alias?.from === word.from)
|
|
378
|
+
return { kind: "alias", from: declaring.from, to: declaring.to };
|
|
379
|
+
if (declaring.qualifier.length === 0) {
|
|
380
|
+
const named = cteNamed(query, word);
|
|
381
|
+
if (named !== undefined)
|
|
382
|
+
return { kind: "cte", from: named.name.from, to: named.name.to };
|
|
383
|
+
}
|
|
384
|
+
return relationDeclaration(lookup, declaring.qualifier, declaring.name);
|
|
385
|
+
}
|
|
386
|
+
if (qualifier.length === 0) {
|
|
387
|
+
const source = sourceNamed(query, word);
|
|
388
|
+
if (source !== undefined && (scope.dotAfter || source.alias !== null)) {
|
|
389
|
+
if (source.alias !== null)
|
|
390
|
+
return { kind: "alias", from: source.from, to: source.to };
|
|
391
|
+
if (source.kind === "relation")
|
|
392
|
+
return relationDeclaration(lookup, source.qualifier, source.name);
|
|
393
|
+
}
|
|
394
|
+
const named = cteNamed(query, word);
|
|
395
|
+
if (named !== undefined)
|
|
396
|
+
return { kind: "cte", from: named.name.from, to: named.name.to };
|
|
397
|
+
if (!scope.dotAfter && scope.clause === "columns") {
|
|
398
|
+
for (const candidate of query.sources) {
|
|
399
|
+
const declaration = await columnDeclaration(lookup, candidate, query, word);
|
|
400
|
+
if (declaration !== null)
|
|
401
|
+
return declaration;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return relationDeclaration(lookup, [], word);
|
|
405
|
+
}
|
|
406
|
+
if (qualifier.length === 1 && qualifier[0] !== undefined) {
|
|
407
|
+
const source = sourceNamed(query, qualifier[0]);
|
|
408
|
+
if (source !== undefined)
|
|
409
|
+
return columnDeclaration(lookup, source, query, word);
|
|
410
|
+
}
|
|
411
|
+
const asRelation = await relationDeclaration(lookup, qualifier, word);
|
|
412
|
+
if (asRelation !== null)
|
|
413
|
+
return asRelation;
|
|
414
|
+
const owner = qualifier.at(-1);
|
|
415
|
+
if (owner === undefined)
|
|
416
|
+
return null;
|
|
417
|
+
const found = await lookup.relation(qualifier.slice(0, -1), owner);
|
|
418
|
+
const meta = found?.relation.columns.find((candidate) => denotes(word, candidate.name));
|
|
419
|
+
return found === null || meta === undefined
|
|
420
|
+
? null
|
|
421
|
+
: { kind: "column", path: found.path, relation: found.relation.name, column: meta.name };
|
|
422
|
+
}
|
|
423
|
+
/** Whether a qualifier written before a column or `*` names the one table the SELECT reads. */
|
|
424
|
+
function namesTable(qualifier, select) {
|
|
425
|
+
const last = qualifier.at(-1);
|
|
426
|
+
if (last === undefined)
|
|
427
|
+
return true;
|
|
428
|
+
if (select.alias !== null)
|
|
429
|
+
return qualifier.length === 1 && sameIdentifier(last, select.alias);
|
|
430
|
+
const written = [...select.qualifier, select.name];
|
|
431
|
+
const tail = written.slice(-qualifier.length);
|
|
432
|
+
return (tail.length === qualifier.length &&
|
|
433
|
+
qualifier.every((part, at) => {
|
|
434
|
+
const other = tail[at];
|
|
435
|
+
return other !== undefined && sameIdentifier(part, other);
|
|
436
|
+
}));
|
|
437
|
+
}
|
|
438
|
+
async function selectSource(sql, catalog) {
|
|
439
|
+
const select = parseSimpleSelect(sql, options.dialect);
|
|
440
|
+
if ("readOnly" in select)
|
|
441
|
+
return { kind: "readOnly", reason: select.readOnly };
|
|
442
|
+
const found = await new Lookup(catalog).relation(select.qualifier, select.name);
|
|
443
|
+
if (found === null)
|
|
444
|
+
return { kind: "readOnly", reason: `the catalog does not know ${select.name.name}` };
|
|
445
|
+
const tableColumns = found.relation.columns.map((column) => column.name);
|
|
446
|
+
const columns = [];
|
|
447
|
+
for (const item of select.items) {
|
|
448
|
+
if (item.kind === "expression") {
|
|
449
|
+
columns.push(null);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const qualifier = item.kind === "star" ? (item.qualifier === null ? [] : [item.qualifier]) : item.qualifier;
|
|
453
|
+
if (!namesTable(qualifier, select))
|
|
454
|
+
return { kind: "readOnly", reason: SELECT_READ_ONLY_REASONS.notSelect };
|
|
455
|
+
if (item.kind === "star") {
|
|
456
|
+
columns.push(...tableColumns);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
columns.push(tableColumns.find((name) => denotes(item.name, name)) ?? null);
|
|
460
|
+
}
|
|
461
|
+
return { kind: "table", path: found.path, relation: found.relation.name, columns };
|
|
462
|
+
}
|
|
463
|
+
function signatureAt(request, catalog) {
|
|
464
|
+
const { text, offset, shift } = wrap(request);
|
|
465
|
+
const server = serverOf(catalog);
|
|
466
|
+
const call = callAt(text, offset, options.dialect, reserved, (name) => specNamed(name, server) !== undefined);
|
|
467
|
+
const spec = call === null ? undefined : specNamed(call.name, server);
|
|
468
|
+
if (call === null || spec === undefined)
|
|
469
|
+
return Promise.resolve(null);
|
|
470
|
+
return Promise.resolve({
|
|
471
|
+
name: spec.name.toLowerCase(),
|
|
472
|
+
summary: spec.summary,
|
|
473
|
+
signatures: spec.signatures,
|
|
474
|
+
argument: call.argument,
|
|
475
|
+
from: call.from - shift,
|
|
476
|
+
to: call.to - shift,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
return { complete, resolve, selectSource, signatureAt };
|
|
480
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Row, SqlLanguage } from "@tablecraft-app/plugin-api";
|
|
2
|
+
import { type SqlDialectOptions } from "./tokens.js";
|
|
3
|
+
/** What `sqlExplainable` needs to know about a dialect's EXPLAIN (decision 031). */
|
|
4
|
+
export interface ExplainableOptions {
|
|
5
|
+
readonly dialect: SqlDialectOptions;
|
|
6
|
+
/** The database's name, as a refusal names it: `PostgreSQL`. */
|
|
7
|
+
readonly name: string;
|
|
8
|
+
/**
|
|
9
|
+
* The verbs EXPLAIN accepts, as the verb that decides what a statement does (`SELECT`, or the one
|
|
10
|
+
* after `WITH …`); `CREATE_TABLE_AS` stands for `CREATE TABLE … AS`.
|
|
11
|
+
*/
|
|
12
|
+
readonly verbs: readonly string[];
|
|
13
|
+
/** The verbs Explain Analyse accepts, a subset of `verbs`; empty where the dialect cannot analyse. */
|
|
14
|
+
readonly analyzeVerbs: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
export declare const NOTHING_TO_EXPLAIN = "There is no statement to explain; put the caret in one.";
|
|
17
|
+
export declare const EXPLAIN_ONE_STATEMENT = "Explain one statement at a time.";
|
|
18
|
+
export declare const ALREADY_EXPLAIN = "This statement is already an EXPLAIN; run it with Run to see its rows.";
|
|
19
|
+
/** How `ExplainableOptions` names `CREATE TABLE … AS`, which `CREATE TABLE` alone is not. */
|
|
20
|
+
export declare const CREATE_TABLE_AS = "CREATE TABLE \u2026 AS";
|
|
21
|
+
/**
|
|
22
|
+
* The refusals every dialect shares: nothing to explain, more than one statement, and a statement
|
|
23
|
+
* that already is an EXPLAIN. Null when none applies.
|
|
24
|
+
*/
|
|
25
|
+
export declare function commonExplainRefusal(sql: string, dialect: SqlDialectOptions): string | null;
|
|
26
|
+
/**
|
|
27
|
+
* The verb that decides what `sql` does, upper case: `SELECT`, the verb after `WITH …`, or
|
|
28
|
+
* `CREATE_TABLE_AS`; empty for no statement. A plugin picks its EXPLAIN format by it.
|
|
29
|
+
*/
|
|
30
|
+
export declare function explainedVerb(sql: string, dialect: SqlDialectOptions): string;
|
|
31
|
+
/**
|
|
32
|
+
* `SqlLanguage.explainable` from a dialect's verb lists: the shared refusals, then the statement's
|
|
33
|
+
* main verb against what the dialect explains and analyses. A statement writes when it is not a read.
|
|
34
|
+
*/
|
|
35
|
+
export declare function sqlExplainable(options: ExplainableOptions): Required<Pick<SqlLanguage, "explainable">>;
|
|
36
|
+
/** The first cell of every row as text, one row per line: what an EXPLAIN printed. */
|
|
37
|
+
export declare function planText(rows: readonly Row[]): string;
|
|
38
|
+
/**
|
|
39
|
+
* A plan property's value as one line of text: strings as they are, lists joined with commas,
|
|
40
|
+
* anything nested as JSON. Null for nothing worth showing: null, or an empty list.
|
|
41
|
+
*/
|
|
42
|
+
export declare function planPropertyValue(value: unknown): string | null;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { classifyTokens } from "./classify.js";
|
|
2
|
+
import { sqlSplitter } from "./splitter.js";
|
|
3
|
+
import { significantTokens } from "./tokens.js";
|
|
4
|
+
import { EXPLAIN_VERBS, mainVerb, words } from "./words.js";
|
|
5
|
+
export const NOTHING_TO_EXPLAIN = "There is no statement to explain; put the caret in one.";
|
|
6
|
+
export const EXPLAIN_ONE_STATEMENT = "Explain one statement at a time.";
|
|
7
|
+
export const ALREADY_EXPLAIN = "This statement is already an EXPLAIN; run it with Run to see its rows.";
|
|
8
|
+
const CREATE = "CREATE";
|
|
9
|
+
const TABLE = "TABLE";
|
|
10
|
+
const AS = "AS";
|
|
11
|
+
/** How `ExplainableOptions` names `CREATE TABLE … AS`, which `CREATE TABLE` alone is not. */
|
|
12
|
+
export const CREATE_TABLE_AS = "CREATE TABLE … AS";
|
|
13
|
+
function anyOf(items) {
|
|
14
|
+
if (items.length <= 1)
|
|
15
|
+
return items.join("");
|
|
16
|
+
return `${items.slice(0, -1).join(", ")} or ${items.at(-1) ?? ""}`;
|
|
17
|
+
}
|
|
18
|
+
function isCreateTableAs(list) {
|
|
19
|
+
if (list[0]?.word !== CREATE)
|
|
20
|
+
return false;
|
|
21
|
+
const table = list.findIndex((entry) => entry.depth === 0 && entry.word === TABLE);
|
|
22
|
+
return table > 0 && list.some((entry, at) => at > table && entry.depth === 0 && entry.word === AS);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The refusals every dialect shares: nothing to explain, more than one statement, and a statement
|
|
26
|
+
* that already is an EXPLAIN. Null when none applies.
|
|
27
|
+
*/
|
|
28
|
+
export function commonExplainRefusal(sql, dialect) {
|
|
29
|
+
const spans = sqlSplitter(dialect)(sql);
|
|
30
|
+
if (spans.length === 0)
|
|
31
|
+
return NOTHING_TO_EXPLAIN;
|
|
32
|
+
if (spans.length > 1)
|
|
33
|
+
return EXPLAIN_ONE_STATEMENT;
|
|
34
|
+
const first = words(sql, significantTokens(sql, dialect))[0]?.word;
|
|
35
|
+
return first !== undefined && EXPLAIN_VERBS.has(first) ? ALREADY_EXPLAIN : null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The verb that decides what `sql` does, upper case: `SELECT`, the verb after `WITH …`, or
|
|
39
|
+
* `CREATE_TABLE_AS`; empty for no statement. A plugin picks its EXPLAIN format by it.
|
|
40
|
+
*/
|
|
41
|
+
export function explainedVerb(sql, dialect) {
|
|
42
|
+
const list = words(sql, significantTokens(sql, dialect));
|
|
43
|
+
return isCreateTableAs(list) ? CREATE_TABLE_AS : (list[mainVerb(list)]?.word ?? "");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* `SqlLanguage.explainable` from a dialect's verb lists: the shared refusals, then the statement's
|
|
47
|
+
* main verb against what the dialect explains and analyses. A statement writes when it is not a read.
|
|
48
|
+
*/
|
|
49
|
+
export function sqlExplainable(options) {
|
|
50
|
+
const verbs = new Set(options.verbs);
|
|
51
|
+
const analyzeVerbs = new Set(options.analyzeVerbs);
|
|
52
|
+
const explainable = (sql, { analyze }) => {
|
|
53
|
+
const common = commonExplainRefusal(sql, options.dialect);
|
|
54
|
+
if (common !== null)
|
|
55
|
+
return { ok: false, reason: common };
|
|
56
|
+
const statement = sqlSplitter(options.dialect)(sql)[0]?.sql ?? sql;
|
|
57
|
+
const verb = explainedVerb(statement, options.dialect);
|
|
58
|
+
if (!verbs.has(verb)) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
reason: `${options.name} cannot explain a ${verb} statement. Explain ${anyOf(options.verbs)} instead.`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (analyze && !analyzeVerbs.has(verb)) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
reason: analyzeVerbs.size === 0
|
|
68
|
+
? `${options.name} cannot run Explain Analyse; use Explain Plan.`
|
|
69
|
+
: `${options.name} analyses only ${anyOf(options.analyzeVerbs)} statements; use Explain Plan for this ${verb}.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, writes: classifyTokens(statement, options.dialect).kind !== "read" };
|
|
73
|
+
};
|
|
74
|
+
return { explainable };
|
|
75
|
+
}
|
|
76
|
+
const LINE_BREAK = "\n";
|
|
77
|
+
/** The first cell of every row as text, one row per line: what an EXPLAIN printed. */
|
|
78
|
+
export function planText(rows) {
|
|
79
|
+
return rows
|
|
80
|
+
.map((row) => {
|
|
81
|
+
const cell = row[0];
|
|
82
|
+
if (cell === null || cell === undefined)
|
|
83
|
+
return "";
|
|
84
|
+
if (typeof cell === "object")
|
|
85
|
+
return cell.kind === "json" ? cell.text : JSON.stringify(cell);
|
|
86
|
+
return String(cell);
|
|
87
|
+
})
|
|
88
|
+
.join(LINE_BREAK);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* A plan property's value as one line of text: strings as they are, lists joined with commas,
|
|
92
|
+
* anything nested as JSON. Null for nothing worth showing: null, or an empty list.
|
|
93
|
+
*/
|
|
94
|
+
export function planPropertyValue(value) {
|
|
95
|
+
if (value === null || value === undefined)
|
|
96
|
+
return null;
|
|
97
|
+
if (typeof value === "string")
|
|
98
|
+
return value;
|
|
99
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
100
|
+
return String(value);
|
|
101
|
+
if (Array.isArray(value)) {
|
|
102
|
+
if (value.length === 0)
|
|
103
|
+
return null;
|
|
104
|
+
const flat = value.every((item) => item === null || typeof item !== "object");
|
|
105
|
+
return flat ? value.map((item) => String(item)).join(", ") : JSON.stringify(value);
|
|
106
|
+
}
|
|
107
|
+
return JSON.stringify(value);
|
|
108
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ParameterStyle, SqlLanguage } from "@tablecraft-app/plugin-api";
|
|
2
|
+
import { type DialectOptions } from "sql-formatter";
|
|
3
|
+
import { type SqlDialectOptions } from "./tokens.js";
|
|
4
|
+
/** The `sql-formatter` dialects plugins may name (decisions 042 and 100). */
|
|
5
|
+
export declare const FORMAT_DIALECTS: {
|
|
6
|
+
readonly postgresql: DialectOptions;
|
|
7
|
+
readonly mysql: DialectOptions;
|
|
8
|
+
readonly sqlite: DialectOptions;
|
|
9
|
+
readonly plsql: DialectOptions;
|
|
10
|
+
readonly transactsql: DialectOptions;
|
|
11
|
+
};
|
|
12
|
+
export type FormatDialect = keyof typeof FORMAT_DIALECTS;
|
|
13
|
+
export interface FormatterOptions {
|
|
14
|
+
readonly dialect: FormatDialect;
|
|
15
|
+
/** The same dialect options the splitter gets; `delimiterCommand` makes a `DELIMITER` line refuse. */
|
|
16
|
+
readonly lexer: SqlDialectOptions;
|
|
17
|
+
/** The placeholders `SqlLanguage.parameters` reads, so the formatter keeps `:id` and `$1` whole. */
|
|
18
|
+
readonly parameters: readonly ParameterStyle[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* `SqlLanguage.format`: the aligned layout (decision 061) or `sql-formatter`'s (decision 042), which
|
|
22
|
+
* also takes every statement the aligned one does not read (decision 062). Throws an `Error` with a
|
|
23
|
+
* one-line reason rather than returning half-formatted text; comments and literals pass through as
|
|
24
|
+
* written.
|
|
25
|
+
*/
|
|
26
|
+
export declare function sqlFormatter(options: FormatterOptions): Pick<SqlLanguage, "format">;
|