@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,222 @@
|
|
|
1
|
+
import { PluginError, } from "@tablecraft-app/plugin-api";
|
|
2
|
+
/** The action a dialect applies when a foreign key names none, which a design leaves null. */
|
|
3
|
+
const DEFAULT_ACTIONS = new Set(["NO ACTION", "RESTRICT"]);
|
|
4
|
+
/** The referential actions a design may name, as SQL spells them. */
|
|
5
|
+
export const REFERENTIAL_ACTIONS = ["CASCADE", "SET NULL", "SET DEFAULT", "RESTRICT", "NO ACTION"];
|
|
6
|
+
/** A failure the user fixes in the dialog, so the host shows it as the dialog's error. */
|
|
7
|
+
export function designError(message) {
|
|
8
|
+
return new PluginError("statementFailed", message);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The design of a described table (decision 052). `columnDefault` turns the catalog's default into
|
|
12
|
+
* the expression a `DEFAULT` clause takes, for a dialect whose catalog stores it otherwise.
|
|
13
|
+
*/
|
|
14
|
+
export function tableDesignOf(relation, columnDefault = (column) => column.default) {
|
|
15
|
+
const primary = new Set(relation.primaryKey === null ? [] : [relation.primaryKey.name]);
|
|
16
|
+
return {
|
|
17
|
+
name: relation.name,
|
|
18
|
+
comment: relation.comment,
|
|
19
|
+
columns: relation.columns.map((column) => ({
|
|
20
|
+
name: column.name,
|
|
21
|
+
type: column.type,
|
|
22
|
+
nullable: column.nullable,
|
|
23
|
+
default: columnDefault(column),
|
|
24
|
+
autoIncrement: column.autoIncrement,
|
|
25
|
+
comment: column.comment,
|
|
26
|
+
original: column.name,
|
|
27
|
+
})),
|
|
28
|
+
primaryKey: relation.primaryKey?.columns ?? [],
|
|
29
|
+
primaryKeyName: relation.primaryKey?.name ?? null,
|
|
30
|
+
indexes: relation.indexes
|
|
31
|
+
.filter((index) => !index.primary && !primary.has(index.name))
|
|
32
|
+
.map((index) => ({
|
|
33
|
+
name: index.name,
|
|
34
|
+
columns: index.columns,
|
|
35
|
+
unique: index.unique,
|
|
36
|
+
original: index.name,
|
|
37
|
+
})),
|
|
38
|
+
foreignKeys: relation.foreignKeys.map((key) => ({
|
|
39
|
+
name: key.name,
|
|
40
|
+
columns: key.columns,
|
|
41
|
+
target: key.target,
|
|
42
|
+
onDelete: referentialAction(key.onDelete),
|
|
43
|
+
onUpdate: referentialAction(key.onUpdate),
|
|
44
|
+
original: key.name,
|
|
45
|
+
})),
|
|
46
|
+
checks: [],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function referentialAction(action) {
|
|
50
|
+
const spelled = action.toUpperCase();
|
|
51
|
+
return DEFAULT_ACTIONS.has(spelled) ? null : spelled;
|
|
52
|
+
}
|
|
53
|
+
function trimmed(text) {
|
|
54
|
+
return text.trim();
|
|
55
|
+
}
|
|
56
|
+
/** Throws a `designError` naming the first thing in `design` no dialect can create. */
|
|
57
|
+
export function validateDesign(design) {
|
|
58
|
+
if (trimmed(design.name) === "")
|
|
59
|
+
throw designError("Name the table.");
|
|
60
|
+
if (design.columns.length === 0)
|
|
61
|
+
throw designError("Add a column: a table needs at least one.");
|
|
62
|
+
const names = new Set();
|
|
63
|
+
for (const column of design.columns) {
|
|
64
|
+
if (trimmed(column.name) === "")
|
|
65
|
+
throw designError("Name every column.");
|
|
66
|
+
if (names.has(column.name))
|
|
67
|
+
throw designError(`Two columns are named ${column.name}; rename one.`);
|
|
68
|
+
if (trimmed(column.type) === "")
|
|
69
|
+
throw designError(`Give column ${column.name} a type.`);
|
|
70
|
+
names.add(column.name);
|
|
71
|
+
}
|
|
72
|
+
const known = (columns, what) => {
|
|
73
|
+
if (columns.length === 0)
|
|
74
|
+
throw designError(`Pick the columns of ${what}.`);
|
|
75
|
+
const missing = columns.find((column) => !names.has(column));
|
|
76
|
+
if (missing !== undefined)
|
|
77
|
+
throw designError(`${what} names column ${missing}, which the table does not have.`);
|
|
78
|
+
};
|
|
79
|
+
if (design.primaryKey.length > 0)
|
|
80
|
+
known(design.primaryKey, "The primary key");
|
|
81
|
+
for (const index of design.indexes) {
|
|
82
|
+
if (trimmed(index.name) === "")
|
|
83
|
+
throw designError("Name every index.");
|
|
84
|
+
known(index.columns, `Index ${index.name}`);
|
|
85
|
+
}
|
|
86
|
+
for (const key of design.foreignKeys) {
|
|
87
|
+
if (trimmed(key.name) === "")
|
|
88
|
+
throw designError("Name every foreign key.");
|
|
89
|
+
known(key.columns, `Foreign key ${key.name}`);
|
|
90
|
+
if (trimmed(key.target.relation) === "")
|
|
91
|
+
throw designError(`Pick the table foreign key ${key.name} references.`);
|
|
92
|
+
for (const action of [key.onDelete, key.onUpdate]) {
|
|
93
|
+
if (action !== null && !REFERENTIAL_ACTIONS.some((known) => known === action)) {
|
|
94
|
+
throw designError(`Foreign key ${key.name} names action ${action}; pick one of ${REFERENTIAL_ACTIONS.join(", ")}.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (key.target.columns.length !== key.columns.length) {
|
|
98
|
+
throw designError(`Foreign key ${key.name} needs as many referenced columns as it has columns.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const check of design.checks) {
|
|
102
|
+
if (trimmed(check.name) === "")
|
|
103
|
+
throw designError("Name every check.");
|
|
104
|
+
if (trimmed(check.expression) === "")
|
|
105
|
+
throw designError(`Give check ${check.name} an expression.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function sameList(left, right) {
|
|
109
|
+
return left.length === right.length && left.every((item, index) => item === right[index]);
|
|
110
|
+
}
|
|
111
|
+
function sameIndex(left, right) {
|
|
112
|
+
return left.name === right.name && left.unique === right.unique && sameList(left.columns, right.columns);
|
|
113
|
+
}
|
|
114
|
+
function sameForeignKey(left, right) {
|
|
115
|
+
return (left.name === right.name &&
|
|
116
|
+
sameList(left.columns, right.columns) &&
|
|
117
|
+
sameList(left.target.path, right.target.path) &&
|
|
118
|
+
left.target.relation === right.target.relation &&
|
|
119
|
+
sameList(left.target.columns, right.target.columns) &&
|
|
120
|
+
left.onDelete === right.onDelete &&
|
|
121
|
+
left.onUpdate === right.onUpdate);
|
|
122
|
+
}
|
|
123
|
+
function sameCheck(left, right) {
|
|
124
|
+
return left.name === right.name && left.expression.trim() === right.expression.trim();
|
|
125
|
+
}
|
|
126
|
+
/** Whether a column's definition, everything but its name, differs. */
|
|
127
|
+
export function columnChanged(before, after) {
|
|
128
|
+
return (before.type.trim() !== after.type.trim() ||
|
|
129
|
+
before.nullable !== after.nullable ||
|
|
130
|
+
(before.default ?? "").trim() !== (after.default ?? "").trim() ||
|
|
131
|
+
before.autoIncrement !== after.autoIncrement ||
|
|
132
|
+
(before.comment ?? "") !== (after.comment ?? ""));
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Parts matched by `original`: one whose original is gone from `after` was dropped, one without an
|
|
136
|
+
* original was added, and a changed index, key or check is dropped and added again.
|
|
137
|
+
*/
|
|
138
|
+
function partsDiff(before, after, same) {
|
|
139
|
+
const kept = new Map(after.filter((part) => part.original !== null).map((part) => [part.original, part]));
|
|
140
|
+
const dropped = [];
|
|
141
|
+
const added = after.filter((part) => part.original === null);
|
|
142
|
+
for (const part of before) {
|
|
143
|
+
const next = kept.get(part.name);
|
|
144
|
+
if (next === undefined)
|
|
145
|
+
dropped.push(part);
|
|
146
|
+
else if (!same(part, next)) {
|
|
147
|
+
dropped.push(part);
|
|
148
|
+
added.push(next);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { dropped, added };
|
|
152
|
+
}
|
|
153
|
+
export function tableDifference(before, after) {
|
|
154
|
+
const byOriginal = new Map(after.columns.filter((column) => column.original !== null).map((c) => [c.original, c]));
|
|
155
|
+
const changedColumns = [];
|
|
156
|
+
const droppedColumns = [];
|
|
157
|
+
for (const column of before.columns) {
|
|
158
|
+
const next = byOriginal.get(column.name);
|
|
159
|
+
if (next === undefined)
|
|
160
|
+
droppedColumns.push(column);
|
|
161
|
+
else if (next.name !== column.name || columnChanged(column, next)) {
|
|
162
|
+
changedColumns.push({ before: column, after: next });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const indexes = partsDiff(before.indexes, after.indexes, sameIndex);
|
|
166
|
+
const foreignKeys = partsDiff(before.foreignKeys, after.foreignKeys, sameForeignKey);
|
|
167
|
+
const checks = partsDiff(before.checks, after.checks, sameCheck);
|
|
168
|
+
return {
|
|
169
|
+
renamedTo: after.name === before.name ? null : after.name,
|
|
170
|
+
commentChanged: (before.comment ?? "") !== (after.comment ?? ""),
|
|
171
|
+
droppedColumns,
|
|
172
|
+
addedColumns: after.columns.filter((column) => column.original === null),
|
|
173
|
+
changedColumns,
|
|
174
|
+
primaryKeyChanged: !sameList(before.primaryKey, after.primaryKey),
|
|
175
|
+
droppedIndexes: indexes.dropped,
|
|
176
|
+
addedIndexes: indexes.added,
|
|
177
|
+
droppedForeignKeys: foreignKeys.dropped,
|
|
178
|
+
addedForeignKeys: foreignKeys.added,
|
|
179
|
+
droppedChecks: checks.dropped,
|
|
180
|
+
addedChecks: checks.added,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** Whether a difference holds no change at all. */
|
|
184
|
+
export function unchanged(difference) {
|
|
185
|
+
return (difference.renamedTo === null &&
|
|
186
|
+
!difference.commentChanged &&
|
|
187
|
+
!difference.primaryKeyChanged &&
|
|
188
|
+
[
|
|
189
|
+
difference.droppedColumns,
|
|
190
|
+
difference.addedColumns,
|
|
191
|
+
difference.changedColumns,
|
|
192
|
+
difference.droppedIndexes,
|
|
193
|
+
difference.addedIndexes,
|
|
194
|
+
difference.droppedForeignKeys,
|
|
195
|
+
difference.addedForeignKeys,
|
|
196
|
+
difference.droppedChecks,
|
|
197
|
+
difference.addedChecks,
|
|
198
|
+
].every((parts) => parts.length === 0));
|
|
199
|
+
}
|
|
200
|
+
/** A script of `statements` with nothing around them. */
|
|
201
|
+
export function ddlScript(statements, options) {
|
|
202
|
+
return {
|
|
203
|
+
prepare: options.prepare ?? [],
|
|
204
|
+
statements,
|
|
205
|
+
verify: options.verify ?? [],
|
|
206
|
+
finish: options.finish ?? [],
|
|
207
|
+
transactional: options.transactional,
|
|
208
|
+
notes: options.notes ?? [],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/** `NOTHING_TO_CHANGE` is the note an alter without a difference carries, so the dialog can say so. */
|
|
212
|
+
export const NOTHING_TO_CHANGE = "The design matches the table; there is nothing to run.";
|
|
213
|
+
/** The `REFERENCES` tail of a foreign key, actions only where they differ from the default. */
|
|
214
|
+
export function referencesClause(key, qualifiedTarget, name) {
|
|
215
|
+
const actions = [
|
|
216
|
+
key.onDelete === null ? "" : ` ON DELETE ${key.onDelete}`,
|
|
217
|
+
key.onUpdate === null ? "" : ` ON UPDATE ${key.onUpdate}`,
|
|
218
|
+
].join("");
|
|
219
|
+
return `FOREIGN KEY (${key.columns.map(name).join(", ")}) REFERENCES ${qualifiedTarget} (${key.target.columns
|
|
220
|
+
.map(name)
|
|
221
|
+
.join(", ")})${actions}`;
|
|
222
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ParameterStyle, type SqlLanguage } from "@tablecraft-app/plugin-api";
|
|
2
|
+
import { type SqlDialectOptions } from "./tokens.js";
|
|
3
|
+
/**
|
|
4
|
+
* How a driver spells the placeholders it binds: `$1` once per value (node-postgres), `?` once per
|
|
5
|
+
* occurrence (mysql2, SQLite), `:1` once per occurrence (node-oracledb, which binds an array by
|
|
6
|
+
* position of occurrence), or `@p1` once per value (tedious, which binds by name).
|
|
7
|
+
*/
|
|
8
|
+
export type NativePlaceholder = "numbered" | "positional" | "colonNumbered" | "atNumbered";
|
|
9
|
+
/** What `sqlParameters` needs to know about a dialect (decision 043). */
|
|
10
|
+
export interface ParameterOptions {
|
|
11
|
+
readonly dialect: SqlDialectOptions;
|
|
12
|
+
/** The placeholder forms a statement may hold; anything else is left as SQL. */
|
|
13
|
+
readonly styles: readonly ParameterStyle[];
|
|
14
|
+
readonly native: NativePlaceholder;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Detection and binding of `:name`, `$1` and `?` placeholders on the lexer the splitter uses
|
|
18
|
+
* (decision 043), so nothing inside a string, quoted identifier, comment or dollar quote counts.
|
|
19
|
+
*/
|
|
20
|
+
export declare function sqlParameters(options: ParameterOptions): Pick<SqlLanguage, "parameters" | "bind">;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { PluginError, } from "@tablecraft-app/plugin-api";
|
|
2
|
+
import { Lexer } from "./tokens.js";
|
|
3
|
+
const NATIVE_FORMS = {
|
|
4
|
+
numbered: { perName: true, spell: (number) => `$${String(number)}` },
|
|
5
|
+
positional: { perName: false, spell: () => "?" },
|
|
6
|
+
colonNumbered: { perName: false, spell: (number) => `:${String(number)}` },
|
|
7
|
+
atNumbered: { perName: true, spell: (number) => `@p${String(number)}` },
|
|
8
|
+
};
|
|
9
|
+
const NAMED_PREFIX = ":";
|
|
10
|
+
const POSITIONAL_MARK = "?";
|
|
11
|
+
const NUMBERED = /^\$[1-9]\d*$/;
|
|
12
|
+
const NAME_START = /^[A-Za-z_\u0080-\uffff]/;
|
|
13
|
+
/** A `:` after one of these is a slice bound, a label or the second half of `::`, not a parameter. */
|
|
14
|
+
const NOT_BEFORE_NAMED = /[A-Za-z0-9_$:\u0080-\uffff\]]/;
|
|
15
|
+
function namedAt(text, colon, next) {
|
|
16
|
+
if (next?.kind !== "word" || next.from !== colon.to)
|
|
17
|
+
return null;
|
|
18
|
+
if (!NAME_START.test(text.charAt(next.from)))
|
|
19
|
+
return null;
|
|
20
|
+
const before = colon.from > 0 ? text.charAt(colon.from - 1) : "";
|
|
21
|
+
return NOT_BEFORE_NAMED.test(before) ? null : next;
|
|
22
|
+
}
|
|
23
|
+
function missingValue(name) {
|
|
24
|
+
return new PluginError("statementFailed", `No value for the parameter ${name}; run the statement again and give it one.`);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Detection and binding of `:name`, `$1` and `?` placeholders on the lexer the splitter uses
|
|
28
|
+
* (decision 043), so nothing inside a string, quoted identifier, comment or dollar quote counts.
|
|
29
|
+
*/
|
|
30
|
+
export function sqlParameters(options) {
|
|
31
|
+
const styles = new Set(options.styles);
|
|
32
|
+
const parameters = (statement) => {
|
|
33
|
+
const lexer = new Lexer(statement, options.dialect);
|
|
34
|
+
const found = [];
|
|
35
|
+
let positional = 0;
|
|
36
|
+
let token = lexer.next();
|
|
37
|
+
while (token !== null) {
|
|
38
|
+
const next = lexer.next();
|
|
39
|
+
const text = statement.slice(token.from, token.to);
|
|
40
|
+
if (token.kind === "symbol" && text === NAMED_PREFIX && styles.has("named")) {
|
|
41
|
+
const name = namedAt(statement, token, next);
|
|
42
|
+
if (name !== null) {
|
|
43
|
+
found.push({ name: statement.slice(token.from, name.to), from: token.from, to: name.to, style: "named" });
|
|
44
|
+
token = lexer.next();
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else if (token.kind === "symbol" && text === POSITIONAL_MARK && styles.has("positional")) {
|
|
49
|
+
positional += 1;
|
|
50
|
+
found.push({
|
|
51
|
+
name: `${POSITIONAL_MARK}${String(positional)}`,
|
|
52
|
+
from: token.from,
|
|
53
|
+
to: token.to,
|
|
54
|
+
style: "positional",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
else if (token.kind === "word" && styles.has("numbered") && NUMBERED.test(text)) {
|
|
58
|
+
found.push({ name: text, from: token.from, to: token.to, style: "numbered" });
|
|
59
|
+
}
|
|
60
|
+
token = next;
|
|
61
|
+
}
|
|
62
|
+
return found;
|
|
63
|
+
};
|
|
64
|
+
const bind = (statement, values) => {
|
|
65
|
+
const found = parameters(statement);
|
|
66
|
+
const form = NATIVE_FORMS[options.native];
|
|
67
|
+
const params = [];
|
|
68
|
+
const numbers = new Map();
|
|
69
|
+
let sql = "";
|
|
70
|
+
let at = 0;
|
|
71
|
+
for (const parameter of found) {
|
|
72
|
+
if (!Object.hasOwn(values, parameter.name))
|
|
73
|
+
throw missingValue(parameter.name);
|
|
74
|
+
const value = values[parameter.name] ?? null;
|
|
75
|
+
let number = form.perName ? numbers.get(parameter.name) : undefined;
|
|
76
|
+
if (number === undefined) {
|
|
77
|
+
params.push(value);
|
|
78
|
+
number = params.length;
|
|
79
|
+
numbers.set(parameter.name, number);
|
|
80
|
+
}
|
|
81
|
+
const placeholder = form.spell(number);
|
|
82
|
+
sql += statement.slice(at, parameter.from) + placeholder;
|
|
83
|
+
at = parameter.to;
|
|
84
|
+
}
|
|
85
|
+
return { sql: sql + statement.slice(at), params };
|
|
86
|
+
};
|
|
87
|
+
return { parameters, bind };
|
|
88
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type CompletionCatalog, type Namespace, type Relation } from "@tablecraft-app/plugin-api";
|
|
2
|
+
import type { ServerIdentity } from "./functions.js";
|
|
3
|
+
import { type NamespaceTarget } from "./namespace.js";
|
|
4
|
+
import type { Cte, Identifier, Query, Source } from "./scope.js";
|
|
5
|
+
import type { SqlDialectOptions } from "./tokens.js";
|
|
6
|
+
/** How a dialect reads unquoted names: PostgreSQL folds them to lower case, MySQL and SQLite compare them as written. */
|
|
7
|
+
export type IdentifierFolding = "lower" | "upper" | "none";
|
|
8
|
+
/** What name resolution needs to know about a dialect; a subset of `CompleterOptions`. */
|
|
9
|
+
export interface NameOptions {
|
|
10
|
+
readonly dialect: SqlDialectOptions;
|
|
11
|
+
readonly identifierFolding: IdentifierFolding;
|
|
12
|
+
/** Reads the catalog's server version into the flavour and version the specs' ranges name; null when it cannot. */
|
|
13
|
+
readonly server?: (version: string) => ServerIdentity | null;
|
|
14
|
+
}
|
|
15
|
+
/** CTEs that read each other stop expanding here, so a recursive one cannot loop. */
|
|
16
|
+
export declare const MAX_EXPANSION_DEPTH = 4;
|
|
17
|
+
export interface ColumnInfo {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly type: string | null;
|
|
20
|
+
}
|
|
21
|
+
export interface FoundRelation {
|
|
22
|
+
readonly path: readonly string[];
|
|
23
|
+
readonly relation: Relation;
|
|
24
|
+
}
|
|
25
|
+
export declare function samePath(left: readonly string[] | null, right: readonly string[] | null): boolean;
|
|
26
|
+
/** Whether `namespace` is one `target` can switch to (found live, plan 022). */
|
|
27
|
+
export declare function targetable(target: NamespaceTarget, namespace: Namespace): boolean;
|
|
28
|
+
/** Every query level from `query` outwards. */
|
|
29
|
+
export declare function levels(query: Query): Query[];
|
|
30
|
+
export declare function visibleCtes(query: Query): Cte[];
|
|
31
|
+
/** The name a source is referred to by: its alias, else its relation's name. */
|
|
32
|
+
export declare function referenceName(source: Source): Identifier | null;
|
|
33
|
+
/**
|
|
34
|
+
* The dialect's name rules and a catalog reader over them, shared by completion, navigation and
|
|
35
|
+
* inspections (decision 028, plan 108) so all three agree on what a name denotes.
|
|
36
|
+
*/
|
|
37
|
+
export declare function nameRules(options: NameOptions): {
|
|
38
|
+
fold: (name: string) => string;
|
|
39
|
+
denotes: (identifier: Identifier, catalogName: string) => boolean;
|
|
40
|
+
sameIdentifier: (left: Identifier, right: Identifier) => boolean;
|
|
41
|
+
cteNamed: (query: Query, name: Identifier) => Cte | undefined;
|
|
42
|
+
sourceNamed: (query: Query, name: Identifier) => Source | undefined;
|
|
43
|
+
serverOf: (catalog: CompletionCatalog) => ServerIdentity | null;
|
|
44
|
+
Lookup: {
|
|
45
|
+
new (catalog: CompletionCatalog): {
|
|
46
|
+
readonly "__#private@#catalog": CompletionCatalog;
|
|
47
|
+
"__#private@#namespaces": Promise<readonly Namespace[]> | null;
|
|
48
|
+
readonly "__#private@#relations": Map<string, Promise<readonly Relation[]>>;
|
|
49
|
+
"__#private@#current": readonly string[] | null;
|
|
50
|
+
get server(): ServerIdentity | null;
|
|
51
|
+
get current(): readonly string[] | null;
|
|
52
|
+
/**
|
|
53
|
+
* Makes the script's own namespace current: the nearest statement before `offset` that moves
|
|
54
|
+
* the session's namespace, when one of its targets exists (plan 022 decision 7).
|
|
55
|
+
*/
|
|
56
|
+
follow(text: string, offset: number): Promise<void>;
|
|
57
|
+
/** Moves `current` to the namespace `target` names, when the catalog has one. */
|
|
58
|
+
switchTo(target: NamespaceTarget): Promise<void>;
|
|
59
|
+
namespaces(): Promise<readonly Namespace[]>;
|
|
60
|
+
relations(path: readonly string[]): Promise<readonly Relation[]>;
|
|
61
|
+
referencing(path: readonly string[], relation: string): Promise<readonly import("@tablecraft-app/plugin-api").IncomingForeignKey[] | never[]>;
|
|
62
|
+
/** The namespace a qualifier names: its trailing path parts, as written. */
|
|
63
|
+
namespace(qualifier: readonly Identifier[]): Promise<Namespace | undefined>;
|
|
64
|
+
relation(qualifier: readonly Identifier[], name: Identifier): Promise<FoundRelation | null>;
|
|
65
|
+
columnsOf(source: Source, query: Query, depth?: number): Promise<ColumnInfo[]>;
|
|
66
|
+
cteColumns(cte: Cte, depth: number): Promise<ColumnInfo[]>;
|
|
67
|
+
/** A subquery's select list, `*` expanded through its own sources. */
|
|
68
|
+
selectColumns(query: Query, depth: number): Promise<ColumnInfo[]>;
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
export type NameRules = ReturnType<typeof nameRules>;
|
|
73
|
+
export type Lookup = InstanceType<NameRules["Lookup"]>;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { switchableTo } from "@tablecraft-app/plugin-api";
|
|
2
|
+
import { namespaceTarget } from "./namespace.js";
|
|
3
|
+
import { sqlSplitter } from "./splitter.js";
|
|
4
|
+
/** CTEs that read each other stop expanding here, so a recursive one cannot loop. */
|
|
5
|
+
export const MAX_EXPANSION_DEPTH = 4;
|
|
6
|
+
export function samePath(left, right) {
|
|
7
|
+
return (left !== null && right !== null && left.length === right.length && left.every((part, at) => part === right[at]));
|
|
8
|
+
}
|
|
9
|
+
/** Whether `namespace` is one `target` can switch to (found live, plan 022). */
|
|
10
|
+
export function targetable(target, namespace) {
|
|
11
|
+
return switchableTo(target.kind === "database" ? "topLevel" : "schema", namespace);
|
|
12
|
+
}
|
|
13
|
+
/** Every query level from `query` outwards. */
|
|
14
|
+
export function levels(query) {
|
|
15
|
+
const found = [];
|
|
16
|
+
for (let level = query; level !== null; level = level.parent)
|
|
17
|
+
found.push(level);
|
|
18
|
+
return found;
|
|
19
|
+
}
|
|
20
|
+
export function visibleCtes(query) {
|
|
21
|
+
return levels(query).flatMap((level) => level.ctes);
|
|
22
|
+
}
|
|
23
|
+
/** The name a source is referred to by: its alias, else its relation's name. */
|
|
24
|
+
export function referenceName(source) {
|
|
25
|
+
return source.alias ?? (source.kind === "relation" ? source.name : null);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The dialect's name rules and a catalog reader over them, shared by completion, navigation and
|
|
29
|
+
* inspections (decision 028, plan 108) so all three agree on what a name denotes.
|
|
30
|
+
*/
|
|
31
|
+
export function nameRules(options) {
|
|
32
|
+
const split = sqlSplitter(options.dialect);
|
|
33
|
+
function fold(name) {
|
|
34
|
+
switch (options.identifierFolding) {
|
|
35
|
+
case "lower":
|
|
36
|
+
return name.toLowerCase();
|
|
37
|
+
case "upper":
|
|
38
|
+
return name.toUpperCase();
|
|
39
|
+
case "none":
|
|
40
|
+
return name;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Whether a name as written denotes `catalogName`: quoted exactly, unquoted folded, or case-blind where nothing folds. */
|
|
44
|
+
function denotes(identifier, catalogName) {
|
|
45
|
+
if (options.identifierFolding === "none")
|
|
46
|
+
return identifier.name.toLowerCase() === catalogName.toLowerCase();
|
|
47
|
+
return (identifier.quoted ? identifier.name : fold(identifier.name)) === catalogName;
|
|
48
|
+
}
|
|
49
|
+
function sameIdentifier(left, right) {
|
|
50
|
+
const key = (identifier) => options.identifierFolding === "none"
|
|
51
|
+
? identifier.name.toLowerCase()
|
|
52
|
+
: identifier.quoted
|
|
53
|
+
? identifier.name
|
|
54
|
+
: fold(identifier.name);
|
|
55
|
+
return key(left) === key(right);
|
|
56
|
+
}
|
|
57
|
+
function cteNamed(query, name) {
|
|
58
|
+
return visibleCtes(query).find((cte) => sameIdentifier(cte.name, name));
|
|
59
|
+
}
|
|
60
|
+
function sourceNamed(query, name) {
|
|
61
|
+
for (const level of levels(query)) {
|
|
62
|
+
const source = level.sources.find((candidate) => {
|
|
63
|
+
const reference = referenceName(candidate);
|
|
64
|
+
return reference !== null && sameIdentifier(reference, name);
|
|
65
|
+
});
|
|
66
|
+
if (source !== undefined)
|
|
67
|
+
return source;
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
function serverOf(catalog) {
|
|
72
|
+
const version = catalog.server.version;
|
|
73
|
+
return version === null || options.server === undefined ? null : options.server(version);
|
|
74
|
+
}
|
|
75
|
+
/** One request's view of the catalog: every read is caught, and each namespace is asked once. */
|
|
76
|
+
class Lookup {
|
|
77
|
+
#catalog;
|
|
78
|
+
#namespaces = null;
|
|
79
|
+
#relations = new Map();
|
|
80
|
+
#current;
|
|
81
|
+
constructor(catalog) {
|
|
82
|
+
this.#catalog = catalog;
|
|
83
|
+
this.#current = catalog.current;
|
|
84
|
+
}
|
|
85
|
+
get server() {
|
|
86
|
+
return serverOf(this.#catalog);
|
|
87
|
+
}
|
|
88
|
+
get current() {
|
|
89
|
+
return this.#current;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Makes the script's own namespace current: the nearest statement before `offset` that moves
|
|
93
|
+
* the session's namespace, when one of its targets exists (plan 022 decision 7).
|
|
94
|
+
*/
|
|
95
|
+
async follow(text, offset) {
|
|
96
|
+
const preceding = split(text).filter((span) => span.to < offset);
|
|
97
|
+
for (let at = preceding.length - 1; at >= 0; at -= 1) {
|
|
98
|
+
const span = preceding[at];
|
|
99
|
+
const target = span === undefined ? null : namespaceTarget(span.sql, options.dialect);
|
|
100
|
+
if (target === null)
|
|
101
|
+
continue;
|
|
102
|
+
await this.switchTo(target);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Moves `current` to the namespace `target` names, when the catalog has one. */
|
|
107
|
+
async switchTo(target) {
|
|
108
|
+
const namespaces = (await this.namespaces()).filter((namespace) => targetable(target, namespace));
|
|
109
|
+
for (const candidate of target.candidates) {
|
|
110
|
+
const found = namespaces.find((namespace) => denotes(candidate, namespace.path.at(-1) ?? ""));
|
|
111
|
+
if (found !== undefined) {
|
|
112
|
+
this.#current = found.path;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
namespaces() {
|
|
118
|
+
this.#namespaces ??= this.#catalog.namespaces().catch(() => []);
|
|
119
|
+
return this.#namespaces;
|
|
120
|
+
}
|
|
121
|
+
relations(path) {
|
|
122
|
+
const key = JSON.stringify(path);
|
|
123
|
+
let found = this.#relations.get(key);
|
|
124
|
+
if (found === undefined) {
|
|
125
|
+
found = this.#catalog.relations(path).catch(() => []);
|
|
126
|
+
this.#relations.set(key, found);
|
|
127
|
+
}
|
|
128
|
+
return found;
|
|
129
|
+
}
|
|
130
|
+
referencing(path, relation) {
|
|
131
|
+
return this.#catalog.referencing(path, relation).catch(() => []);
|
|
132
|
+
}
|
|
133
|
+
/** The namespace a qualifier names: its trailing path parts, as written. */
|
|
134
|
+
async namespace(qualifier) {
|
|
135
|
+
if (qualifier.length === 0)
|
|
136
|
+
return undefined;
|
|
137
|
+
const namespaces = await this.namespaces();
|
|
138
|
+
const matches = (namespace) => {
|
|
139
|
+
const tail = namespace.path.slice(-qualifier.length);
|
|
140
|
+
return tail.length === qualifier.length && qualifier.every((part, at) => denotes(part, tail[at] ?? ""));
|
|
141
|
+
};
|
|
142
|
+
return namespaces.find((namespace) => matches(namespace) && !namespace.system) ?? namespaces.find(matches);
|
|
143
|
+
}
|
|
144
|
+
async relation(qualifier, name) {
|
|
145
|
+
const path = qualifier.length === 0 ? this.current : ((await this.namespace(qualifier))?.path ?? null);
|
|
146
|
+
if (path === null)
|
|
147
|
+
return null;
|
|
148
|
+
const relations = await this.relations(path);
|
|
149
|
+
const relation = relations.find((candidate) => candidate.name === name.name) ??
|
|
150
|
+
relations.find((candidate) => denotes(name, candidate.name));
|
|
151
|
+
return relation === undefined ? null : { path, relation };
|
|
152
|
+
}
|
|
153
|
+
async columnsOf(source, query, depth = 0) {
|
|
154
|
+
if (source.kind === "derived")
|
|
155
|
+
return this.selectColumns(source.query, depth + 1);
|
|
156
|
+
if (source.qualifier.length === 0) {
|
|
157
|
+
const cte = cteNamed(query, source.name);
|
|
158
|
+
if (cte !== undefined)
|
|
159
|
+
return this.cteColumns(cte, depth + 1);
|
|
160
|
+
}
|
|
161
|
+
const found = await this.relation(source.qualifier, source.name);
|
|
162
|
+
return found?.relation.columns.map((column) => ({ name: column.name, type: column.type })) ?? [];
|
|
163
|
+
}
|
|
164
|
+
cteColumns(cte, depth) {
|
|
165
|
+
if (cte.columns !== null)
|
|
166
|
+
return Promise.resolve(cte.columns.map((column) => ({ name: column.name, type: null })));
|
|
167
|
+
return this.selectColumns(cte.query, depth);
|
|
168
|
+
}
|
|
169
|
+
/** A subquery's select list, `*` expanded through its own sources. */
|
|
170
|
+
async selectColumns(query, depth) {
|
|
171
|
+
if (depth > MAX_EXPANSION_DEPTH)
|
|
172
|
+
return [];
|
|
173
|
+
const columns = [];
|
|
174
|
+
for (const item of query.select) {
|
|
175
|
+
if (item.kind === "named") {
|
|
176
|
+
columns.push({ name: item.name.name, type: null });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const sources = item.qualifier === null
|
|
180
|
+
? query.sources
|
|
181
|
+
: query.sources.filter((source) => {
|
|
182
|
+
const reference = referenceName(source);
|
|
183
|
+
return reference !== null && item.qualifier !== null && sameIdentifier(reference, item.qualifier);
|
|
184
|
+
});
|
|
185
|
+
for (const source of sources)
|
|
186
|
+
columns.push(...(await this.columnsOf(source, query, depth)));
|
|
187
|
+
}
|
|
188
|
+
return columns;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { fold, denotes, sameIdentifier, cteNamed, sourceNamed, serverOf, Lookup };
|
|
192
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ParameterValue, type Routine, type RoutineArgument, type RoutineParameter } from "@tablecraft-app/plugin-api";
|
|
2
|
+
/** What a dialect's routine call writes for one parameter (decision 090). */
|
|
3
|
+
export type RoutineSlot = {
|
|
4
|
+
readonly kind: "value";
|
|
5
|
+
readonly parameter: RoutineParameter;
|
|
6
|
+
readonly position: number;
|
|
7
|
+
readonly marker: string;
|
|
8
|
+
} | {
|
|
9
|
+
readonly kind: "default";
|
|
10
|
+
readonly parameter: RoutineParameter;
|
|
11
|
+
readonly position: number;
|
|
12
|
+
} | {
|
|
13
|
+
readonly kind: "out";
|
|
14
|
+
readonly parameter: RoutineParameter;
|
|
15
|
+
readonly position: number;
|
|
16
|
+
};
|
|
17
|
+
export interface RoutineSlots {
|
|
18
|
+
readonly slots: readonly RoutineSlot[];
|
|
19
|
+
/** Keyed by marker with its colon, as `RoutineCall.params` is. */
|
|
20
|
+
readonly params: Readonly<Record<string, ParameterValue>>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Pairs each parameter with its argument and gives every value a named `:marker` (`:` + the
|
|
24
|
+
* parameter's name when it is a plain identifier, else `:arg<position>`), unique in the call.
|
|
25
|
+
* Throws a `PluginError` coded `statementFailed` when the arguments do not fit the parameters.
|
|
26
|
+
*/
|
|
27
|
+
export declare function routineSlots(routine: Routine, args: readonly RoutineArgument[]): RoutineSlots;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { PluginError, } from "@tablecraft-app/plugin-api";
|
|
2
|
+
/** A name every dialect's placeholder scanner reads whole after the colon. */
|
|
3
|
+
const PLAIN_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
4
|
+
const POSITIONAL_PREFIX = "arg";
|
|
5
|
+
/**
|
|
6
|
+
* Pairs each parameter with its argument and gives every value a named `:marker` (`:` + the
|
|
7
|
+
* parameter's name when it is a plain identifier, else `:arg<position>`), unique in the call.
|
|
8
|
+
* Throws a `PluginError` coded `statementFailed` when the arguments do not fit the parameters.
|
|
9
|
+
*/
|
|
10
|
+
export function routineSlots(routine, args) {
|
|
11
|
+
if (args.length !== routine.parameters.length) {
|
|
12
|
+
throw new PluginError("statementFailed", `${routine.name} takes ${String(routine.parameters.length)} arguments, one per parameter; ${String(args.length)} were given. Describe the routine again and fill every parameter.`);
|
|
13
|
+
}
|
|
14
|
+
const used = new Set();
|
|
15
|
+
const params = {};
|
|
16
|
+
const slots = routine.parameters.map((parameter, index) => {
|
|
17
|
+
const position = index + 1;
|
|
18
|
+
if (parameter.mode === "out")
|
|
19
|
+
return { kind: "out", parameter, position };
|
|
20
|
+
const argument = args[index] ?? { kind: "default" };
|
|
21
|
+
if (argument.kind === "default") {
|
|
22
|
+
if (!parameter.hasDefault) {
|
|
23
|
+
throw new PluginError("statementFailed", `Parameter ${parameter.name ?? String(position)} of ${routine.name} has no default. Give it a value or NULL.`);
|
|
24
|
+
}
|
|
25
|
+
return { kind: "default", parameter, position };
|
|
26
|
+
}
|
|
27
|
+
const marker = uniqueMarker(parameter, position, used);
|
|
28
|
+
used.add(marker);
|
|
29
|
+
params[marker] = argument.value;
|
|
30
|
+
return { kind: "value", parameter, position, marker };
|
|
31
|
+
});
|
|
32
|
+
return { slots, params };
|
|
33
|
+
}
|
|
34
|
+
function uniqueMarker(parameter, position, used) {
|
|
35
|
+
const base = parameter.name !== null && PLAIN_NAME.test(parameter.name)
|
|
36
|
+
? `:${parameter.name}`
|
|
37
|
+
: `:${POSITIONAL_PREFIX}${String(position)}`;
|
|
38
|
+
let marker = base;
|
|
39
|
+
for (let suffix = 2; used.has(marker); suffix++)
|
|
40
|
+
marker = `${base}_${String(suffix)}`;
|
|
41
|
+
return marker;
|
|
42
|
+
}
|