@tablecraft-app/plugin-api 1.3.0 → 1.5.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/dist/completion.d.ts +6 -1
- package/dist/manifest.d.ts +2 -0
- package/dist/manifest.js +5 -0
- package/dist/plugin.d.ts +8 -1
- package/dist/sql/complete.js +78 -12
- package/dist/sql/functions.d.ts +20 -4
- package/dist/sql/functions.js +115 -24
- package/dist/sql/inspectResolve.js +4 -0
- package/dist/sql/namespace.d.ts +2 -1
- package/dist/sql/namespace.js +33 -3
- package/dist/sql/signature.d.ts +9 -1
- package/dist/sql/signature.js +48 -10
- package/dist/sql/tokens.d.ts +5 -2
- package/dist/sql/tokens.js +6 -2
- package/dist/testing/index.d.ts +16 -0
- package/dist/testing/index.js +73 -39
- package/dist/url.d.ts +1 -0
- package/dist/url.js +88 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/completion.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface FunctionParameter {
|
|
|
22
22
|
readonly optional?: boolean;
|
|
23
23
|
/** Repeats: the call may pass it any number of times. */
|
|
24
24
|
readonly variadic?: boolean;
|
|
25
|
+
/** The keyword written before it instead of a comma, such as `FROM` in `EXTRACT(unit FROM date)`; `|` separates alternatives. */
|
|
26
|
+
readonly lead?: string;
|
|
25
27
|
}
|
|
26
28
|
/** One way to call a function: its parameters in order and what it returns. */
|
|
27
29
|
export interface FunctionSignature {
|
|
@@ -33,7 +35,10 @@ export interface SignatureHelp {
|
|
|
33
35
|
readonly name: string;
|
|
34
36
|
readonly summary: string;
|
|
35
37
|
readonly signatures: readonly FunctionSignature[];
|
|
36
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* The zero-based parameter the caret is in: the top-level commas before it inside the call, or,
|
|
40
|
+
* when the signature has keyword-led parameters (`lead`), the one the separators walk to.
|
|
41
|
+
*/
|
|
37
42
|
readonly argument: number;
|
|
38
43
|
/** The call's name to the end of its arguments, or to the caret while it is still open. */
|
|
39
44
|
readonly from: number;
|
package/dist/manifest.d.ts
CHANGED
|
@@ -74,6 +74,7 @@ export declare const connectionUrlSchema: z.ZodObject<{
|
|
|
74
74
|
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
75
75
|
}, z.core.$strict>>>;
|
|
76
76
|
sets: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>>;
|
|
77
|
+
semicolonParams: z.ZodOptional<z.ZodBoolean>;
|
|
77
78
|
}, z.core.$strict>;
|
|
78
79
|
export type ConnectionUrl = z.infer<typeof connectionUrlSchema>;
|
|
79
80
|
/**
|
|
@@ -181,6 +182,7 @@ export declare const pluginManifestSchema: z.ZodObject<{
|
|
|
181
182
|
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
182
183
|
}, z.core.$strict>>>;
|
|
183
184
|
sets: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>>;
|
|
185
|
+
semicolonParams: z.ZodOptional<z.ZodBoolean>;
|
|
184
186
|
}, z.core.$strict>>;
|
|
185
187
|
network: z.ZodOptional<z.ZodObject<{
|
|
186
188
|
host: z.ZodString;
|
package/dist/manifest.js
CHANGED
|
@@ -68,6 +68,11 @@ export const connectionUrlSchema = z.strictObject({
|
|
|
68
68
|
sets: z
|
|
69
69
|
.record(z.string().regex(URL_SCHEME), z.record(z.string().regex(FIELD_KEY), z.union([z.string(), z.number(), z.boolean()])))
|
|
70
70
|
.optional(),
|
|
71
|
+
/**
|
|
72
|
+
* The JDBC SQL Server form, `scheme://host[\instance][:port][;name=value…]` (plan 055): parameters
|
|
73
|
+
* follow `;`, a `{…}` value may hold one, and `\instance` is the parameter `instanceName`.
|
|
74
|
+
*/
|
|
75
|
+
semicolonParams: z.boolean().optional(),
|
|
71
76
|
});
|
|
72
77
|
/**
|
|
73
78
|
* Which fields hold the server's address (decision 070), so the host can open an SSH tunnel and
|
package/dist/plugin.d.ts
CHANGED
|
@@ -122,6 +122,12 @@ export interface ExecuteOptions {
|
|
|
122
122
|
* leads each row with a `node` column, and anything else is a `ServerNode.id`.
|
|
123
123
|
*/
|
|
124
124
|
readonly node?: string;
|
|
125
|
+
/**
|
|
126
|
+
* `StatementSpan.repeat` of the statement, at least 2, when the script asks for it to run more
|
|
127
|
+
* than once (SQL Server's `GO 3`, decision 100). A plugin that splits such scripts runs it that
|
|
128
|
+
* many times and answers with the last run; any other plugin never receives it.
|
|
129
|
+
*/
|
|
130
|
+
readonly repeat?: number;
|
|
125
131
|
}
|
|
126
132
|
/** `ExecuteOptions.node`: the plugin routes the command by its keys (decision 114). */
|
|
127
133
|
export declare const NODE_AUTO = "auto";
|
|
@@ -311,7 +317,8 @@ export interface StatementSpan {
|
|
|
311
317
|
readonly sql: string;
|
|
312
318
|
/**
|
|
313
319
|
* How many times the script asks for the statement to run: SQL Server's `GO 3` after it
|
|
314
|
-
* (decision 100). Absent means once. The host never repeats a statement;
|
|
320
|
+
* (decision 100). Absent means once. The host never repeats a statement; it passes the count
|
|
321
|
+
* as `ExecuteOptions.repeat` and the plugin runs it that many times.
|
|
315
322
|
*/
|
|
316
323
|
readonly repeat?: number;
|
|
317
324
|
}
|
package/dist/sql/complete.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { functionItem, functionName, isAvailable } from "./functions.js";
|
|
1
|
+
import { functionItem, functionName, functionPackage, isAvailable, leadMatches, memberItem, } from "./functions.js";
|
|
2
2
|
import { callAt } from "./signature.js";
|
|
3
3
|
import { parseSimpleSelect, SELECT_READ_ONLY_REASONS } from "./select.js";
|
|
4
4
|
import { scopeAt } from "./scope.js";
|
|
@@ -70,18 +70,65 @@ const BOOST = {
|
|
|
70
70
|
keyword: -10,
|
|
71
71
|
};
|
|
72
72
|
const UNQUOTED_WORD = "^[A-Za-z0-9_$]*$";
|
|
73
|
+
/** Where `@` and `#` start a name (`variables`), the typed `@@` stays part of what is filtered. */
|
|
74
|
+
const VARIABLE_WORD = "^[@#]*[A-Za-z0-9_$]*$";
|
|
73
75
|
const ALIAS_WORD_BREAK = /[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])/;
|
|
74
76
|
const FALLBACK_ALIAS = "t";
|
|
75
77
|
const QUOTES = new Set(['"', "`", "["]);
|
|
76
78
|
/** Below every other namespace: `USE mysql` is legitimate but rarely meant. */
|
|
77
79
|
const SYSTEM_NAMESPACE_BOOST = -20;
|
|
78
80
|
const CURRENT_DETAIL = "current";
|
|
81
|
+
const COMMA = ",";
|
|
82
|
+
const MEMBER_SEPARATOR = ".";
|
|
83
|
+
/**
|
|
84
|
+
* Walks `separators` through `signature`: a comma moves to the next parameter without a lead, a
|
|
85
|
+
* word to the next one it leads. A word nothing leads is an argument's own (`YEAR`, `DECIMAL`).
|
|
86
|
+
* Past the last parameter a comma keeps counting, as the hint's variadic rule expects; a variadic
|
|
87
|
+
* parameter keyword-led ones follow (`CHAR(code... USING charset)`) takes every comma.
|
|
88
|
+
*/
|
|
89
|
+
function argumentAt(signature, separators) {
|
|
90
|
+
const { parameters } = signature;
|
|
91
|
+
let at = 0;
|
|
92
|
+
let entered = false;
|
|
93
|
+
let led = 0;
|
|
94
|
+
for (const separator of separators) {
|
|
95
|
+
if (separator === COMMA) {
|
|
96
|
+
const next = parameters.findIndex((parameter, index) => index > at && parameter.lead === undefined);
|
|
97
|
+
if (next >= 0)
|
|
98
|
+
at = next;
|
|
99
|
+
else if (!(parameters[at]?.variadic === true && at < parameters.length - 1))
|
|
100
|
+
at += 1;
|
|
101
|
+
entered = true;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const next = parameters.findIndex((parameter, index) => (index > at || (index === at && !entered)) &&
|
|
105
|
+
parameter.lead !== undefined &&
|
|
106
|
+
leadMatches(parameter.lead, separator));
|
|
107
|
+
if (next < 0)
|
|
108
|
+
continue;
|
|
109
|
+
at = next;
|
|
110
|
+
entered = true;
|
|
111
|
+
led += 1;
|
|
112
|
+
}
|
|
113
|
+
return { argument: at, led };
|
|
114
|
+
}
|
|
115
|
+
/** The argument the caret is in, by the overload whose leads the call follows most; the first on a tie. */
|
|
116
|
+
function argumentOf(spec, call) {
|
|
117
|
+
let best = null;
|
|
118
|
+
for (const signature of spec.signatures) {
|
|
119
|
+
const found = argumentAt(signature, call.separators ?? []);
|
|
120
|
+
if (best === null || found.led > best.led)
|
|
121
|
+
best = found;
|
|
122
|
+
}
|
|
123
|
+
return best?.argument ?? call.argument;
|
|
124
|
+
}
|
|
79
125
|
function relationKind(relation) {
|
|
80
126
|
return relation.kind === "table" ? "table" : "view";
|
|
81
127
|
}
|
|
82
128
|
/** The shared completion and navigation every built-in plugin spreads into its `SqlLanguage` (decision 028). */
|
|
83
129
|
export function sqlCompleter(options) {
|
|
84
130
|
const reserved = new Set(options.keywords.map((keyword) => keyword.toUpperCase()));
|
|
131
|
+
const unquotedWord = options.dialect.variables === true ? VARIABLE_WORD : UNQUOTED_WORD;
|
|
85
132
|
const { denotes, sameIdentifier, cteNamed, sourceNamed, serverOf, Lookup } = nameRules(options);
|
|
86
133
|
const specs = new Map();
|
|
87
134
|
for (const spec of options.functions) {
|
|
@@ -93,6 +140,13 @@ export function sqlCompleter(options) {
|
|
|
93
140
|
function specNamed(name, server) {
|
|
94
141
|
return specs.get(name.toUpperCase())?.find((spec) => isAvailable(spec, server));
|
|
95
142
|
}
|
|
143
|
+
/** The members of the package `qualifier` names, as offered after its dot. */
|
|
144
|
+
function memberItems(qualifier, server) {
|
|
145
|
+
const name = qualifier.map((part) => part.name.toUpperCase()).join(MEMBER_SEPARATOR);
|
|
146
|
+
return availableFunctions(server, true)
|
|
147
|
+
.filter((spec) => typeof spec !== "string" && functionPackage(spec) === name)
|
|
148
|
+
.map((spec) => memberItem(spec, BOOST.function));
|
|
149
|
+
}
|
|
96
150
|
function quoted(name) {
|
|
97
151
|
return options.plainIdentifier.test(name) && !reserved.has(name.toUpperCase())
|
|
98
152
|
? name
|
|
@@ -115,10 +169,13 @@ export function sqlCompleter(options) {
|
|
|
115
169
|
.filter((keyword) => !except.has(keyword.toUpperCase()))
|
|
116
170
|
.map((keyword) => item("keyword", keyword, keyword, null));
|
|
117
171
|
}
|
|
118
|
-
|
|
172
|
+
/** Plain functions, or with `members` the package members only: a bare name never offers `pkg.member`. */
|
|
173
|
+
function availableFunctions(server, members = false) {
|
|
119
174
|
const offered = new Set();
|
|
120
175
|
return options.functions.filter((spec) => {
|
|
121
176
|
const name = functionName(spec).toUpperCase();
|
|
177
|
+
if ((functionPackage(spec) !== null) !== members)
|
|
178
|
+
return false;
|
|
122
179
|
if (offered.has(name) || (typeof spec !== "string" && !isAvailable(spec, server)))
|
|
123
180
|
return false;
|
|
124
181
|
offered.add(name);
|
|
@@ -131,21 +188,22 @@ export function sqlCompleter(options) {
|
|
|
131
188
|
if (last === undefined)
|
|
132
189
|
return [];
|
|
133
190
|
const relational = clause === "relation" || clause === "join";
|
|
191
|
+
const members = relational ? [] : memberItems(qualifier, lookup.server);
|
|
134
192
|
if (qualifier.length === 1 && !relational) {
|
|
135
193
|
const source = sourceNamed(query, last);
|
|
136
194
|
if (source !== undefined)
|
|
137
|
-
return columnItems(await lookup.columnsOf(source, query));
|
|
195
|
+
return [...columnItems(await lookup.columnsOf(source, query)), ...members];
|
|
138
196
|
const cte = cteNamed(query, last);
|
|
139
197
|
if (cte !== undefined)
|
|
140
|
-
return columnItems(await lookup.cteColumns(cte, 1));
|
|
198
|
+
return [...columnItems(await lookup.cteColumns(cte, 1)), ...members];
|
|
141
199
|
}
|
|
142
200
|
const namespace = await lookup.namespace(qualifier);
|
|
143
201
|
if (namespace !== undefined)
|
|
144
|
-
return relationItems(await lookup.relations(namespace.path), null);
|
|
202
|
+
return [...members, ...relationItems(await lookup.relations(namespace.path), null)];
|
|
145
203
|
if (relational)
|
|
146
204
|
return [];
|
|
147
205
|
const found = await lookup.relation(qualifier.slice(0, -1), last);
|
|
148
|
-
return found === null ?
|
|
206
|
+
return found === null ? members : [...columnItems(found.relation.columns), ...members];
|
|
149
207
|
}
|
|
150
208
|
async function relationContextItems(scope, lookup) {
|
|
151
209
|
const current = lookup.current;
|
|
@@ -169,7 +227,9 @@ export function sqlCompleter(options) {
|
|
|
169
227
|
.filter((word) => word !== "")
|
|
170
228
|
.map((word) => word.charAt(0).toLowerCase())
|
|
171
229
|
.join("");
|
|
172
|
-
|
|
230
|
+
// Unquoted, an alias folds like any name; `c` is written as is and reads back as `C` under upper folding.
|
|
231
|
+
const folded = options.identifierFolding === "upper" ? initials.toUpperCase() : initials;
|
|
232
|
+
const base = initials === "" || !options.plainIdentifier.test(folded) ? FALLBACK_ALIAS : initials;
|
|
173
233
|
let alias = base;
|
|
174
234
|
for (let suffix = 1; taken.has(alias) || reserved.has(alias.toUpperCase()); suffix += 1)
|
|
175
235
|
alias = `${base}${String(suffix)}`;
|
|
@@ -203,7 +263,9 @@ export function sqlCompleter(options) {
|
|
|
203
263
|
continue;
|
|
204
264
|
const near = source.alias === null
|
|
205
265
|
? relationReference(found.path, found.relation.name, lookup.current)
|
|
206
|
-
:
|
|
266
|
+
: source.alias.quoted
|
|
267
|
+
? options.quoteIdentifier(source.alias.name)
|
|
268
|
+
: source.alias.name;
|
|
207
269
|
for (const key of found.relation.foreignKeys) {
|
|
208
270
|
const alias = aliasFor(key.target.relation, new Set(taken));
|
|
209
271
|
const on = key.columns
|
|
@@ -321,7 +383,7 @@ export function sqlCompleter(options) {
|
|
|
321
383
|
const lookup = new Lookup(catalog);
|
|
322
384
|
await lookup.follow(request.text, request.offset);
|
|
323
385
|
const items = await namespaceItems(slot, lookup);
|
|
324
|
-
return { from: slot.from, to: slot.to, items, validFor: slot.quote === null ?
|
|
386
|
+
return { from: slot.from, to: slot.to, items, validFor: slot.quote === null ? unquotedWord : null };
|
|
325
387
|
}
|
|
326
388
|
}
|
|
327
389
|
const { text, offset, shift } = wrap(request);
|
|
@@ -335,7 +397,7 @@ export function sqlCompleter(options) {
|
|
|
335
397
|
from: scope.prefixFrom - shift,
|
|
336
398
|
to: offset - shift,
|
|
337
399
|
items: typedQuote ? quotedLabels(items) : items,
|
|
338
|
-
validFor: typedQuote ? null :
|
|
400
|
+
validFor: typedQuote ? null : unquotedWord,
|
|
339
401
|
};
|
|
340
402
|
}
|
|
341
403
|
function sourceDeclaring(query, word) {
|
|
@@ -463,7 +525,11 @@ export function sqlCompleter(options) {
|
|
|
463
525
|
function signatureAt(request, catalog) {
|
|
464
526
|
const { text, offset, shift } = wrap(request);
|
|
465
527
|
const server = serverOf(catalog);
|
|
466
|
-
const
|
|
528
|
+
const callable = (name) => {
|
|
529
|
+
const spec = specNamed(name, server);
|
|
530
|
+
return spec !== undefined && spec.bare !== true;
|
|
531
|
+
};
|
|
532
|
+
const call = callAt(text, offset, options.dialect, reserved, callable);
|
|
467
533
|
const spec = call === null ? undefined : specNamed(call.name, server);
|
|
468
534
|
if (call === null || spec === undefined)
|
|
469
535
|
return Promise.resolve(null);
|
|
@@ -471,7 +537,7 @@ export function sqlCompleter(options) {
|
|
|
471
537
|
name: spec.name.toLowerCase(),
|
|
472
538
|
summary: spec.summary,
|
|
473
539
|
signatures: spec.signatures,
|
|
474
|
-
argument: call
|
|
540
|
+
argument: argumentOf(spec, call),
|
|
475
541
|
from: call.from - shift,
|
|
476
542
|
to: call.to - shift,
|
|
477
543
|
});
|
package/dist/sql/functions.d.ts
CHANGED
|
@@ -21,26 +21,42 @@ export interface FunctionSpec {
|
|
|
21
21
|
readonly summary: string;
|
|
22
22
|
readonly category: FunctionCategory;
|
|
23
23
|
readonly available?: ServerRange;
|
|
24
|
+
/** Written without parentheses, such as Oracle's `SYSDATE`; the call is the name alone and has no hint. */
|
|
25
|
+
readonly bare?: true;
|
|
24
26
|
}
|
|
25
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* `[name, signatures, summary, available?]`, a signature written `"date, format -> string"`, a
|
|
29
|
+
* keyword-led parameter `"unit FROM date -> integer"`, a bare function `"= date"` and a procedure
|
|
30
|
+
* `"str ->"`. A name `PKG.MEMBER` is offered after `pkg.`.
|
|
31
|
+
*/
|
|
26
32
|
export type FunctionEntry = readonly [
|
|
27
33
|
name: string,
|
|
28
34
|
signatures: string | readonly string[],
|
|
29
35
|
summary: string,
|
|
30
36
|
available?: ServerRange
|
|
31
37
|
];
|
|
32
|
-
/**
|
|
38
|
+
/** Whether `lead` is `word` or offers it among its `|` alternatives. */
|
|
39
|
+
export declare function leadMatches(lead: string, word: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Reads `"str, len? -> string"`: `?` marks an optional parameter, `...` one that repeats, an
|
|
42
|
+
* upper-case word between names the keyword before the next (`"unit FROM date -> integer"`).
|
|
43
|
+
* `"= date"` is a bare function's and `"str ->"` a procedure's, which returns nothing.
|
|
44
|
+
*/
|
|
33
45
|
export declare function parseSignature(written: string): FunctionSignature;
|
|
34
46
|
/** Specs for one category, from compact entries a plugin's data file lists. */
|
|
35
47
|
export declare function functionSpecs(category: FunctionCategory, entries: readonly FunctionEntry[]): FunctionSpec[];
|
|
36
48
|
/** Whether `spec` exists on `server`; an unknown server has every function any flavour has. */
|
|
37
49
|
export declare function isAvailable(spec: FunctionSpec, server: ServerIdentity | null): boolean;
|
|
38
|
-
/** `(date, format) → string`, the popup's detail for one signature
|
|
50
|
+
/** `(date, format) → string`, the popup's detail for one signature; a lead replaces the comma, a procedure has no `→`. */
|
|
39
51
|
export declare function signatureLabel(signature: FunctionSignature): string;
|
|
40
52
|
/** Every signature on its own line, then the summary. */
|
|
41
53
|
export declare function functionInfo(spec: FunctionSpec): string;
|
|
42
|
-
/** A spec as an item: `name()` with the caret between the parentheses when it takes arguments. */
|
|
54
|
+
/** A spec as an item: `name()` with the caret between the parentheses when it takes arguments, the name alone when bare. */
|
|
43
55
|
export declare function functionItem(spec: FunctionSpec | string, boost: number): CompletionItem;
|
|
56
|
+
/** The package a spec named `PKG.MEMBER` belongs to, upper case; null for a plain function. */
|
|
57
|
+
export declare function functionPackage(spec: FunctionSpec | string): string | null;
|
|
58
|
+
/** A package member as offered after `pkg.`: the member's name alone, the info naming it in full. */
|
|
59
|
+
export declare function memberItem(spec: FunctionSpec, boost: number): CompletionItem;
|
|
44
60
|
/** The name a function entry is offered under, either form. */
|
|
45
61
|
export declare function functionName(spec: FunctionSpec | string): string;
|
|
46
62
|
/** Aggregate and scalar functions every built-in dialect has; a plugin adds its own. Written in our own words. */
|
package/dist/sql/functions.js
CHANGED
|
@@ -20,7 +20,13 @@ const SHOWN_ARROW = "→";
|
|
|
20
20
|
const OPTIONAL_MARK = "?";
|
|
21
21
|
const VARIADIC_MARK = "...";
|
|
22
22
|
const COMPARATOR = /^(>=|<=|>|<|=)?(\d+(?:\.\d+)*)$/;
|
|
23
|
-
|
|
23
|
+
const BARE_MARK = "=";
|
|
24
|
+
const LEAD_WORD = /^[A-Z][A-Z_]*$/;
|
|
25
|
+
const LEAD_CHOICE = "|";
|
|
26
|
+
const PARAMETER_SEPARATOR = ",";
|
|
27
|
+
const COMMA_SHOWN = ", ";
|
|
28
|
+
const MEMBER_SEPARATOR = ".";
|
|
29
|
+
function parameter(written, lead) {
|
|
24
30
|
let name = written.trim();
|
|
25
31
|
const variadic = name.endsWith(VARIADIC_MARK);
|
|
26
32
|
if (variadic)
|
|
@@ -28,28 +34,85 @@ function parameter(written) {
|
|
|
28
34
|
const optional = name.endsWith(OPTIONAL_MARK);
|
|
29
35
|
if (optional)
|
|
30
36
|
name = name.slice(0, -OPTIONAL_MARK.length).trim();
|
|
31
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
name,
|
|
39
|
+
...(optional ? { optional } : {}),
|
|
40
|
+
...(variadic ? { variadic } : {}),
|
|
41
|
+
...(lead === null ? {} : { lead }),
|
|
42
|
+
};
|
|
32
43
|
}
|
|
33
|
-
|
|
44
|
+
function isLeadWord(word) {
|
|
45
|
+
return word.split(LEAD_CHOICE).every((choice) => LEAD_WORD.test(choice));
|
|
46
|
+
}
|
|
47
|
+
/** Whether `lead` is `word` or offers it among its `|` alternatives. */
|
|
48
|
+
export function leadMatches(lead, word) {
|
|
49
|
+
return lead.split(LEAD_CHOICE).includes(word);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* One comma-separated part: an upper-case word between names leads the next, as one before the
|
|
53
|
+
* first parameter does; one standing alone is a keyword choice. After a comma the keyword is the
|
|
54
|
+
* argument's own (`date, INTERVAL expr unit`), since the comma already separates it.
|
|
55
|
+
*/
|
|
56
|
+
function leadParameters(part, index) {
|
|
57
|
+
const words = part.trim().split(/\s+/);
|
|
58
|
+
const parameters = [];
|
|
59
|
+
let lead = null;
|
|
60
|
+
let name = [];
|
|
61
|
+
const flush = () => {
|
|
62
|
+
if (name.length > 0)
|
|
63
|
+
parameters.push(parameter(name.join(" "), lead));
|
|
64
|
+
name = [];
|
|
65
|
+
lead = null;
|
|
66
|
+
};
|
|
67
|
+
words.forEach((word, at) => {
|
|
68
|
+
const next = words[at + 1];
|
|
69
|
+
const afterComma = at === 0 && index > 0;
|
|
70
|
+
if (!afterComma && isLeadWord(word) && next !== undefined && !isLeadWord(next)) {
|
|
71
|
+
flush();
|
|
72
|
+
lead = word;
|
|
73
|
+
}
|
|
74
|
+
else
|
|
75
|
+
name.push(word);
|
|
76
|
+
});
|
|
77
|
+
flush();
|
|
78
|
+
return parameters;
|
|
79
|
+
}
|
|
80
|
+
function isBareSignature(written) {
|
|
81
|
+
return written.trim().startsWith(BARE_MARK);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Reads `"str, len? -> string"`: `?` marks an optional parameter, `...` one that repeats, an
|
|
85
|
+
* upper-case word between names the keyword before the next (`"unit FROM date -> integer"`).
|
|
86
|
+
* `"= date"` is a bare function's and `"str ->"` a procedure's, which returns nothing.
|
|
87
|
+
*/
|
|
34
88
|
export function parseSignature(written) {
|
|
89
|
+
if (isBareSignature(written))
|
|
90
|
+
return { parameters: [], returns: written.trim().slice(BARE_MARK.length).trim() };
|
|
35
91
|
const arrow = written.lastIndexOf(RETURNS_ARROW);
|
|
36
92
|
if (arrow < 0)
|
|
37
93
|
throw new Error(`Signature "${written}" has no "${RETURNS_ARROW} type"; add what it returns.`);
|
|
38
94
|
const list = written.slice(0, arrow).trim();
|
|
39
95
|
return {
|
|
40
|
-
parameters: list === "" ? [] : list.split(
|
|
96
|
+
parameters: list === "" ? [] : list.split(PARAMETER_SEPARATOR).flatMap(leadParameters),
|
|
41
97
|
returns: written.slice(arrow + RETURNS_ARROW.length).trim(),
|
|
42
98
|
};
|
|
43
99
|
}
|
|
44
100
|
/** Specs for one category, from compact entries a plugin's data file lists. */
|
|
45
101
|
export function functionSpecs(category, entries) {
|
|
46
|
-
return entries.map(([name, signatures, summary, available]) =>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
102
|
+
return entries.map(([name, signatures, summary, available]) => {
|
|
103
|
+
const written = typeof signatures === "string" ? [signatures] : signatures;
|
|
104
|
+
const bare = written.filter(isBareSignature).length;
|
|
105
|
+
if (bare > 0 && bare < written.length)
|
|
106
|
+
throw new Error(`${name} mixes "${BARE_MARK} type" with called signatures; write the bare form as "-> type".`);
|
|
107
|
+
return {
|
|
108
|
+
name,
|
|
109
|
+
signatures: written.map(parseSignature),
|
|
110
|
+
summary,
|
|
111
|
+
category,
|
|
112
|
+
...(available === undefined ? {} : { available }),
|
|
113
|
+
...(bare > 0 ? { bare: true } : {}),
|
|
114
|
+
};
|
|
115
|
+
});
|
|
53
116
|
}
|
|
54
117
|
function compareVersions(left, right) {
|
|
55
118
|
for (let at = 0; at < Math.max(left.length, right.length); at += 1) {
|
|
@@ -91,37 +154,65 @@ export function isAvailable(spec, server) {
|
|
|
91
154
|
.filter((comparator) => comparator !== "")
|
|
92
155
|
.every((comparator) => satisfies(server.version, comparator));
|
|
93
156
|
}
|
|
94
|
-
function shownParameter(parameter) {
|
|
157
|
+
function shownParameter(parameter, index) {
|
|
95
158
|
const name = parameter.variadic === true ? `${parameter.name}${VARIADIC_MARK}` : parameter.name;
|
|
96
|
-
|
|
159
|
+
const led = parameter.lead === undefined ? name : `${parameter.lead} ${name}`;
|
|
160
|
+
const separator = index === 0 ? "" : parameter.lead === undefined ? COMMA_SHOWN : " ";
|
|
161
|
+
return separator + (parameter.optional === true ? `[${led}]` : led);
|
|
97
162
|
}
|
|
98
|
-
|
|
163
|
+
function returnsLabel(returns) {
|
|
164
|
+
return returns === "" ? "" : `${SHOWN_ARROW} ${returns}`;
|
|
165
|
+
}
|
|
166
|
+
/** `(date, format) → string`, the popup's detail for one signature; a lead replaces the comma, a procedure has no `→`. */
|
|
99
167
|
export function signatureLabel(signature) {
|
|
100
|
-
|
|
168
|
+
const list = `(${signature.parameters.map(shownParameter).join("")})`;
|
|
169
|
+
const returns = returnsLabel(signature.returns);
|
|
170
|
+
return returns === "" ? list : `${list} ${returns}`;
|
|
171
|
+
}
|
|
172
|
+
function shownSignature(spec, signature) {
|
|
173
|
+
return spec.bare === true ? returnsLabel(signature.returns) : signatureLabel(signature);
|
|
101
174
|
}
|
|
102
175
|
/** Every signature on its own line, then the summary. */
|
|
103
176
|
export function functionInfo(spec) {
|
|
104
177
|
const label = spec.name.toLowerCase();
|
|
105
|
-
const
|
|
178
|
+
const gap = spec.bare === true ? " " : "";
|
|
179
|
+
const lines = spec.signatures.map((signature) => `${label}${gap}${shownSignature(spec, signature)}`);
|
|
106
180
|
return `${lines.join("\n")}\n\n${spec.summary}`;
|
|
107
181
|
}
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
182
|
+
/**
|
|
183
|
+
* A spec as an item: `name()` with the caret between the parentheses when it takes arguments, or
|
|
184
|
+
* the name alone for a bare one. `label` is what the popup offers, the member alone after `pkg.`.
|
|
185
|
+
*/
|
|
186
|
+
function specItem(spec, label, boost) {
|
|
113
187
|
const first = spec.signatures[0];
|
|
114
188
|
const takesArguments = spec.signatures.some((signature) => signature.parameters.length > 0);
|
|
189
|
+
const bare = spec.bare === true;
|
|
115
190
|
return {
|
|
116
191
|
label,
|
|
117
192
|
kind: "function",
|
|
118
|
-
detail: first === undefined ? null :
|
|
119
|
-
apply: `${label}()`,
|
|
193
|
+
detail: first === undefined ? null : shownSignature(spec, first),
|
|
194
|
+
apply: bare ? label : `${label}()`,
|
|
120
195
|
boost,
|
|
121
196
|
info: functionInfo(spec),
|
|
122
|
-
caret: takesArguments ? label.length + 1 : null,
|
|
197
|
+
caret: takesArguments && !bare ? label.length + 1 : null,
|
|
123
198
|
};
|
|
124
199
|
}
|
|
200
|
+
/** A spec as an item: `name()` with the caret between the parentheses when it takes arguments, the name alone when bare. */
|
|
201
|
+
export function functionItem(spec, boost) {
|
|
202
|
+
if (typeof spec === "string")
|
|
203
|
+
return { label: spec, kind: "function", detail: null, apply: spec, boost, info: null, caret: null };
|
|
204
|
+
return specItem(spec, spec.name.toLowerCase(), boost);
|
|
205
|
+
}
|
|
206
|
+
/** The package a spec named `PKG.MEMBER` belongs to, upper case; null for a plain function. */
|
|
207
|
+
export function functionPackage(spec) {
|
|
208
|
+
const name = functionName(spec);
|
|
209
|
+
const dot = name.lastIndexOf(MEMBER_SEPARATOR);
|
|
210
|
+
return dot < 0 ? null : name.slice(0, dot).toUpperCase();
|
|
211
|
+
}
|
|
212
|
+
/** A package member as offered after `pkg.`: the member's name alone, the info naming it in full. */
|
|
213
|
+
export function memberItem(spec, boost) {
|
|
214
|
+
return specItem(spec, spec.name.slice(spec.name.lastIndexOf(MEMBER_SEPARATOR) + 1).toLowerCase(), boost);
|
|
215
|
+
}
|
|
125
216
|
/** The name a function entry is offered under, either form. */
|
|
126
217
|
export function functionName(spec) {
|
|
127
218
|
return typeof spec === "string" ? spec : spec.name;
|
|
@@ -28,6 +28,7 @@ const COLUMN_CLAUSES = new Set([
|
|
|
28
28
|
/** Symbols right before a word that make it a parameter or variable: `:name`, `@var`, `x::type`. */
|
|
29
29
|
const PARAMETER_MARKS = new Set([":", "@", "?"]);
|
|
30
30
|
const PARAMETER_START = /^[$@:]/;
|
|
31
|
+
const PARAMETER_OR_TEMPORARY = /^[@#]/;
|
|
31
32
|
/** Rows a statement may name without declaring them: PostgreSQL's `EXCLUDED`, a trigger's `NEW` and `OLD`. */
|
|
32
33
|
const PSEUDO_RELATIONS = new Set(["EXCLUDED", "NEW", "OLD"]);
|
|
33
34
|
const RELATION_INTRODUCERS = new Set(["FROM", "JOIN", "UPDATE", "INTO", "STRAIGHT_JOIN"]);
|
|
@@ -146,6 +147,9 @@ async function unresolvedRelation(view, query, source) {
|
|
|
146
147
|
return [];
|
|
147
148
|
if (!source.name.quoted && NOT_RELATION_NAMES.has(source.name.name.toUpperCase()))
|
|
148
149
|
return [];
|
|
150
|
+
// SQL Server's `#temp` and `@table` live in no catalog the explorer reads.
|
|
151
|
+
if (!source.name.quoted && PARAMETER_OR_TEMPORARY.test(source.name.name))
|
|
152
|
+
return [];
|
|
149
153
|
const { lookup } = view;
|
|
150
154
|
let path = lookup.current;
|
|
151
155
|
if (source.qualifier.length > 0) {
|
package/dist/sql/namespace.d.ts
CHANGED
|
@@ -23,7 +23,8 @@ export interface NamespaceSlot {
|
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
25
|
* Where the caret at `offset` names a namespace a session-namespace statement of the dialect moves
|
|
26
|
-
* to: `USE |` or the elements of `SET search_path TO a, |`
|
|
26
|
+
* to: `USE |`, `ALTER SESSION SET CURRENT_SCHEMA = |` or the elements of `SET search_path TO a, |`
|
|
27
|
+
* and `SET SCHEMA |`. Null anywhere else,
|
|
27
28
|
* and after a name whose closing quote is typed.
|
|
28
29
|
*/
|
|
29
30
|
export declare function namespaceSlotAt(text: string, offset: number, dialect: SqlDialectOptions): NamespaceSlot | null;
|
package/dist/sql/namespace.js
CHANGED
|
@@ -82,6 +82,17 @@ function searchPathTarget(text, tokens) {
|
|
|
82
82
|
return null;
|
|
83
83
|
return { kind: "schema", candidates: names };
|
|
84
84
|
}
|
|
85
|
+
const ALTER_SESSION_PREFIX = ["ALTER", "SESSION", "SET", "CURRENT_SCHEMA", "="];
|
|
86
|
+
function alterSessionPrefix(text, tokens) {
|
|
87
|
+
return ALTER_SESSION_PREFIX.every((word, at) => wordAt(text, tokens, at) === word);
|
|
88
|
+
}
|
|
89
|
+
function alterSessionTarget(text, tokens) {
|
|
90
|
+
if (tokens.length !== ALTER_SESSION_PREFIX.length + 1 || !alterSessionPrefix(text, tokens))
|
|
91
|
+
return null;
|
|
92
|
+
const token = tokens[ALTER_SESSION_PREFIX.length];
|
|
93
|
+
const name = identifier(text, token);
|
|
94
|
+
return name === null || token?.kind === "string" ? null : { kind: "schema", candidates: [name] };
|
|
95
|
+
}
|
|
85
96
|
const NAME_TOKENS = new Set(["word", "quoted", "string"]);
|
|
86
97
|
/** The statement's tokens around `offset`, comments dropped; null when the caret is in a comment. */
|
|
87
98
|
function tokensAround(text, offset, dialect) {
|
|
@@ -136,7 +147,8 @@ function searchPathListStart(text, tokens) {
|
|
|
136
147
|
}
|
|
137
148
|
/**
|
|
138
149
|
* Where the caret at `offset` names a namespace a session-namespace statement of the dialect moves
|
|
139
|
-
* to: `USE |` or the elements of `SET search_path TO a, |`
|
|
150
|
+
* to: `USE |`, `ALTER SESSION SET CURRENT_SCHEMA = |` or the elements of `SET search_path TO a, |`
|
|
151
|
+
* and `SET SCHEMA |`. Null anywhere else,
|
|
140
152
|
* and after a name whose closing quote is typed.
|
|
141
153
|
*/
|
|
142
154
|
export function namespaceSlotAt(text, offset, dialect) {
|
|
@@ -168,6 +180,13 @@ export function namespaceSlotAt(text, offset, dialect) {
|
|
|
168
180
|
continue;
|
|
169
181
|
return { kind: "database", from, to: offset, quote, listed: [] };
|
|
170
182
|
}
|
|
183
|
+
if (form === "alterSessionSchema") {
|
|
184
|
+
if (prefix?.kind === "string" || before.length !== ALTER_SESSION_PREFIX.length)
|
|
185
|
+
continue;
|
|
186
|
+
if (!alterSessionPrefix(text, before))
|
|
187
|
+
continue;
|
|
188
|
+
return { kind: "schema", from, to: offset, quote, listed: [] };
|
|
189
|
+
}
|
|
171
190
|
const start = searchPathListStart(text, before);
|
|
172
191
|
if (start === null)
|
|
173
192
|
continue;
|
|
@@ -190,15 +209,26 @@ export function namespaceTarget(sql, dialect) {
|
|
|
190
209
|
return null;
|
|
191
210
|
const tokens = significantTokens(sql, dialect).filter((token) => token.kind !== "delimiter");
|
|
192
211
|
for (const form of forms) {
|
|
193
|
-
const target = form
|
|
212
|
+
const target = TARGET_READERS[form](sql, tokens);
|
|
194
213
|
if (target !== null)
|
|
195
214
|
return target;
|
|
196
215
|
}
|
|
197
216
|
return null;
|
|
198
217
|
}
|
|
218
|
+
const TARGET_READERS = {
|
|
219
|
+
use: useTarget,
|
|
220
|
+
searchPath: searchPathTarget,
|
|
221
|
+
alterSessionSchema: alterSessionTarget,
|
|
222
|
+
};
|
|
199
223
|
const SWITCH_TARGETS = {
|
|
200
224
|
use: "topLevel",
|
|
201
225
|
searchPath: "schema",
|
|
226
|
+
alterSessionSchema: "schema",
|
|
227
|
+
};
|
|
228
|
+
const SWITCH_STATEMENTS = {
|
|
229
|
+
use: (quoted) => `USE ${quoted}`,
|
|
230
|
+
searchPath: (quoted) => `SET search_path TO ${quoted}`,
|
|
231
|
+
alterSessionSchema: (quoted) => `ALTER SESSION SET CURRENT_SCHEMA = ${quoted}`,
|
|
202
232
|
};
|
|
203
233
|
/**
|
|
204
234
|
* The console's namespace switch for a dialect moving its namespace by `form` (decision 060):
|
|
@@ -214,7 +244,7 @@ export function namespaceSwitcher(form, quoteIdentifier) {
|
|
|
214
244
|
if (name === undefined) {
|
|
215
245
|
throw new PluginError("statementFailed", "A namespace switch names no namespace; pick one from the list.");
|
|
216
246
|
}
|
|
217
|
-
return form
|
|
247
|
+
return SWITCH_STATEMENTS[form](quoteIdentifier(name));
|
|
218
248
|
},
|
|
219
249
|
};
|
|
220
250
|
}
|
package/dist/sql/signature.d.ts
CHANGED
|
@@ -3,13 +3,21 @@ import type { SqlDialectOptions } from "./tokens.js";
|
|
|
3
3
|
export interface CallAt {
|
|
4
4
|
/** As written. */
|
|
5
5
|
readonly name: string;
|
|
6
|
+
/** The top-level commas before the caret. */
|
|
6
7
|
readonly argument: number;
|
|
8
|
+
/**
|
|
9
|
+
* The call's top-level `,` and words before the caret, in order, words upper case: what moves a
|
|
10
|
+
* keyword-led parameter list such as `EXTRACT(YEAR FROM d)` along. Always set by `callAt`;
|
|
11
|
+
* optional so a value built against the older shape still type-checks (decision 106).
|
|
12
|
+
*/
|
|
13
|
+
readonly separators?: readonly string[];
|
|
7
14
|
readonly from: number;
|
|
8
15
|
readonly to: number;
|
|
9
16
|
}
|
|
10
17
|
/**
|
|
11
18
|
* 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,
|
|
19
|
+
* commas and listing its separators; strings, comments and quoted names are tokens of their own,
|
|
20
|
+
* so their commas never count. `pkg.member (` asks `known` for `PKG.MEMBER`.
|
|
13
21
|
* Grouping parentheses are looked through; a subquery, a non-function name before `(` or a caret
|
|
14
22
|
* in a string or comment ends the search with null. `reserved` holds the dialect's keywords: a
|
|
15
23
|
* function spelled like one (`VALUES`, `LEFT`) is a call only where an operand fits, not after a
|
package/dist/sql/signature.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { statementTokens } from "./scope.js";
|
|
2
|
+
const COMMA = ",";
|
|
3
|
+
const MEMBER_SEPARATOR = ".";
|
|
4
|
+
/** Calls a procedure by name, so it may come before a package member as it never may before a function. */
|
|
5
|
+
const CALL_WORD = "CALL";
|
|
2
6
|
/** Words a relation or routine name follows, so `TABLE upper (` defines rather than calls. */
|
|
3
7
|
const NAME_INTRODUCERS = new Set([
|
|
4
8
|
"CALL",
|
|
@@ -25,7 +29,8 @@ function upperWord(text, token) {
|
|
|
25
29
|
}
|
|
26
30
|
/**
|
|
27
31
|
* 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,
|
|
32
|
+
* commas and listing its separators; strings, comments and quoted names are tokens of their own,
|
|
33
|
+
* so their commas never count. `pkg.member (` asks `known` for `PKG.MEMBER`.
|
|
29
34
|
* Grouping parentheses are looked through; a subquery, a non-function name before `(` or a caret
|
|
30
35
|
* in a string or comment ends the search with null. `reserved` holds the dialect's keywords: a
|
|
31
36
|
* function spelled like one (`VALUES`, `LEFT`) is a call only where an operand fits, not after a
|
|
@@ -35,23 +40,42 @@ export function callAt(text, offset, dialect, reserved, known) {
|
|
|
35
40
|
const { tokens, literal } = statementTokens(text, offset, dialect);
|
|
36
41
|
if (literal)
|
|
37
42
|
return null;
|
|
38
|
-
|
|
43
|
+
/** `pkg.member` ending at `at`, when the package's spec knows the member. */
|
|
44
|
+
function member(at) {
|
|
39
45
|
const name = tokens[at];
|
|
46
|
+
const owner = tokens[at - 2];
|
|
40
47
|
const word = upperWord(text, name);
|
|
41
|
-
|
|
48
|
+
const ownerWord = upperWord(text, owner);
|
|
49
|
+
if (name === undefined || owner === undefined || word === null || ownerWord === null)
|
|
50
|
+
return null;
|
|
51
|
+
if (!known(`${ownerWord}${MEMBER_SEPARATOR}${word}`))
|
|
42
52
|
return null;
|
|
53
|
+
const previous = upperWord(text, tokens[at - 3]);
|
|
54
|
+
if (previous !== null && previous !== CALL_WORD && NAME_INTRODUCERS.has(previous))
|
|
55
|
+
return null;
|
|
56
|
+
return {
|
|
57
|
+
written: `${text.slice(owner.from, owner.to)}${MEMBER_SEPARATOR}${text.slice(name.from, name.to)}`,
|
|
58
|
+
from: owner.from,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function callable(at) {
|
|
62
|
+
const name = tokens[at];
|
|
43
63
|
const before = tokens[at - 1];
|
|
44
|
-
if (symbol(text, before,
|
|
64
|
+
if (symbol(text, before, MEMBER_SEPARATOR))
|
|
65
|
+
return member(at);
|
|
66
|
+
const word = upperWord(text, name);
|
|
67
|
+
if (name === undefined || word === null || !known(word))
|
|
45
68
|
return null;
|
|
69
|
+
const found = { written: text.slice(name.from, name.to), from: name.from };
|
|
46
70
|
const previous = upperWord(text, before);
|
|
47
71
|
if (previous !== null && NAME_INTRODUCERS.has(previous))
|
|
48
72
|
return null;
|
|
49
73
|
if (!reserved.has(word))
|
|
50
|
-
return
|
|
74
|
+
return found;
|
|
51
75
|
const operandFits = before === undefined ||
|
|
52
76
|
(before.kind === "symbol" && !symbol(text, before, ")")) ||
|
|
53
77
|
(previous !== null && reserved.has(previous));
|
|
54
|
-
return operandFits ?
|
|
78
|
+
return operandFits ? found : null;
|
|
55
79
|
}
|
|
56
80
|
const frames = [];
|
|
57
81
|
let index = 0;
|
|
@@ -61,14 +85,27 @@ export function callAt(text, offset, dialect, reserved, known) {
|
|
|
61
85
|
break;
|
|
62
86
|
if (symbol(text, token, "(")) {
|
|
63
87
|
const name = callable(index - 1);
|
|
64
|
-
frames.push({
|
|
88
|
+
frames.push({
|
|
89
|
+
name,
|
|
90
|
+
named: name === null && !grouping(text, tokens[index - 1]),
|
|
91
|
+
open: index,
|
|
92
|
+
commas: 0,
|
|
93
|
+
separators: [],
|
|
94
|
+
});
|
|
65
95
|
}
|
|
66
96
|
else if (symbol(text, token, ")"))
|
|
67
97
|
frames.pop();
|
|
68
|
-
else if (symbol(text, token,
|
|
98
|
+
else if (symbol(text, token, COMMA)) {
|
|
69
99
|
const top = frames.at(-1);
|
|
70
|
-
if (top !== undefined)
|
|
100
|
+
if (top !== undefined) {
|
|
71
101
|
top.commas += 1;
|
|
102
|
+
top.separators.push(COMMA);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
const word = upperWord(text, token);
|
|
107
|
+
if (word !== null)
|
|
108
|
+
frames.at(-1)?.separators.push(word);
|
|
72
109
|
}
|
|
73
110
|
}
|
|
74
111
|
for (let depth = frames.length - 1; depth >= 0; depth -= 1) {
|
|
@@ -81,8 +118,9 @@ export function callAt(text, offset, dialect, reserved, known) {
|
|
|
81
118
|
continue;
|
|
82
119
|
}
|
|
83
120
|
return {
|
|
84
|
-
name:
|
|
121
|
+
name: frame.name.written,
|
|
85
122
|
argument: frame.commas,
|
|
123
|
+
separators: frame.separators,
|
|
86
124
|
from: frame.name.from,
|
|
87
125
|
to: closeOf(text, tokens, index, frames.length - depth) ?? offset,
|
|
88
126
|
};
|
package/dist/sql/tokens.d.ts
CHANGED
|
@@ -26,13 +26,16 @@ export interface SqlDialectOptions {
|
|
|
26
26
|
readonly qQuotes?: boolean;
|
|
27
27
|
/** `N'…'` national character strings. */
|
|
28
28
|
readonly nationalStrings?: boolean;
|
|
29
|
+
/** SQL Server `@local`, `@@global` and `#temp` names: `@` and `#` are part of a word. */
|
|
30
|
+
readonly variables?: boolean;
|
|
29
31
|
}
|
|
30
32
|
export type BatchSeparator = "go" | "slash";
|
|
31
33
|
/**
|
|
32
34
|
* `use` is MySQL's `USE db`; `searchPath` is PostgreSQL's `SET [SESSION | LOCAL] search_path`,
|
|
33
|
-
* `SET SCHEMA` and `RESET search_path` / `RESET ALL
|
|
35
|
+
* `SET SCHEMA` and `RESET search_path` / `RESET ALL`; `alterSessionSchema` is Oracle's
|
|
36
|
+
* `ALTER SESSION SET CURRENT_SCHEMA = name`.
|
|
34
37
|
*/
|
|
35
|
-
export type NamespaceStatementForm = "use" | "searchPath";
|
|
38
|
+
export type NamespaceStatementForm = "use" | "searchPath" | "alterSessionSchema";
|
|
36
39
|
export type TokenKind = "word" | "quoted" | "string" | "comment" | "symbol" | "delimiter";
|
|
37
40
|
export interface Token {
|
|
38
41
|
readonly kind: TokenKind;
|
package/dist/sql/tokens.js
CHANGED
|
@@ -16,6 +16,7 @@ const Q_QUOTE_PAIRS = new Map([
|
|
|
16
16
|
const STRING_QUOTE = "'";
|
|
17
17
|
const BRACKET_OPEN = "[";
|
|
18
18
|
const BRACKET_CLOSE = "]";
|
|
19
|
+
const VARIABLE_CHARS = new Set(["@", "#"]);
|
|
19
20
|
function isSpace(char) {
|
|
20
21
|
return char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f" || char === "\v";
|
|
21
22
|
}
|
|
@@ -70,13 +71,16 @@ export class Lexer {
|
|
|
70
71
|
else if (char === "$" && this.options.dollarQuotes === true && this.#dollarTag(at) !== null) {
|
|
71
72
|
token = this.#dollarQuoted(at, this.#dollarTag(at) ?? "");
|
|
72
73
|
}
|
|
73
|
-
else if (isWordChar(char))
|
|
74
|
+
else if (this.#isWordChar(char))
|
|
74
75
|
token = this.#word(at, delimiter);
|
|
75
76
|
else
|
|
76
77
|
token = { kind: "symbol", from: at, to: at + 1 };
|
|
77
78
|
this.position = token.to;
|
|
78
79
|
return token;
|
|
79
80
|
}
|
|
81
|
+
#isWordChar(char) {
|
|
82
|
+
return isWordChar(char) || (this.options.variables === true && VARIABLE_CHARS.has(char));
|
|
83
|
+
}
|
|
80
84
|
#lineComment(from) {
|
|
81
85
|
const end = this.text.indexOf(NEWLINE, from);
|
|
82
86
|
return { kind: "comment", from, to: end < 0 ? this.text.length : end };
|
|
@@ -160,7 +164,7 @@ export class Lexer {
|
|
|
160
164
|
const text = this.text;
|
|
161
165
|
const customDelimiter = delimiter !== DEFAULT_DELIMITER;
|
|
162
166
|
let at = from;
|
|
163
|
-
while (at < text.length && isWordChar(text.charAt(at))) {
|
|
167
|
+
while (at < text.length && this.#isWordChar(text.charAt(at))) {
|
|
164
168
|
if (customDelimiter && at > from && text.startsWith(delimiter, at))
|
|
165
169
|
break;
|
|
166
170
|
at += 1;
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -87,6 +87,16 @@ export interface ContractFixture {
|
|
|
87
87
|
readonly config: unknown;
|
|
88
88
|
readonly note: RegExp;
|
|
89
89
|
};
|
|
90
|
+
/**
|
|
91
|
+
* False where the dialect cannot interrupt another session's statement and keep it connected,
|
|
92
|
+
* as SQL Server's `KILL` cannot: every row's `cancel` is null and `cancelSession` rejects.
|
|
93
|
+
*/
|
|
94
|
+
readonly cancels?: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* A session id no session has, for servers that hand a closed session's id to the next login
|
|
97
|
+
* (SQL Server): without it the test closes a session of its own and waits for its id to go.
|
|
98
|
+
*/
|
|
99
|
+
readonly missingId?: string;
|
|
90
100
|
};
|
|
91
101
|
/** Required when the plugin declares `routineCall`, which also needs `catalog`. */
|
|
92
102
|
readonly routines?: {
|
|
@@ -108,6 +118,8 @@ export interface ContractFixture {
|
|
|
108
118
|
* default. `LINE_ITEMS_TABLE` is quoted in every dialect and never folded.
|
|
109
119
|
*/
|
|
110
120
|
readonly foldName?: (name: string) => string;
|
|
121
|
+
/** The database stores an empty string as NULL, as Oracle does, so an empty text reads back null. */
|
|
122
|
+
readonly emptyTextIsNull?: boolean;
|
|
111
123
|
}
|
|
112
124
|
/** Dialect-specific text the suite runs; every member is optional (decision 100). */
|
|
113
125
|
export interface ContractSql {
|
|
@@ -128,6 +140,10 @@ export interface ContractSql {
|
|
|
128
140
|
};
|
|
129
141
|
/** Opens a transaction explicitly in auto-commit mode. */
|
|
130
142
|
readonly begin?: string;
|
|
143
|
+
/** A name only quoting makes legal, which the dialect accepts; Oracle refuses a `"` inside one. */
|
|
144
|
+
readonly awkwardIdentifier?: string;
|
|
145
|
+
/** What `ExplainedPlan.statement` starts with; `EXPLAIN` by default, `SET SHOWPLAN_XML ON` on SQL Server. */
|
|
146
|
+
readonly explainStatement?: RegExp;
|
|
131
147
|
}
|
|
132
148
|
/** A command and the result tab it opens: column names and rows as wire values. */
|
|
133
149
|
export interface CommandRows {
|
package/dist/testing/index.js
CHANGED
|
@@ -117,8 +117,11 @@ export function contractSql(fixture) {
|
|
|
117
117
|
temporaryTable: fixture.sql?.temporaryTable ??
|
|
118
118
|
((name, columns) => ({ create: `CREATE TEMPORARY TABLE ${name} (${columns})`, name })),
|
|
119
119
|
begin: fixture.sql?.begin ?? "BEGIN",
|
|
120
|
+
awkwardIdentifier: fixture.sql?.awkwardIdentifier ?? AWKWARD_IDENTIFIER,
|
|
121
|
+
explainStatement: fixture.sql?.explainStatement ?? DEFAULT_EXPLAIN_STATEMENT,
|
|
120
122
|
};
|
|
121
123
|
}
|
|
124
|
+
const DEFAULT_EXPLAIN_STATEMENT = /^EXPLAIN\b/i;
|
|
122
125
|
const ROWS_READ_BEFORE_RETURN = 3;
|
|
123
126
|
/** Longer than `BYTES_PREVIEW_LENGTH` and within every fixture's `payload` column (MySQL's is `VARBINARY(64)`). */
|
|
124
127
|
const LONG_BYTES_LENGTH = 48;
|
|
@@ -678,9 +681,9 @@ export function runPluginContract(plugin, fixture) {
|
|
|
678
681
|
});
|
|
679
682
|
it("givenAwkwardIdentifier_whenQuotedAndSelected_thenNameReadsBack", async () => {
|
|
680
683
|
await withSession(plugin, fixture, async (session) => {
|
|
681
|
-
const result = await session.execute(dialectSql.select(`1 AS ${language().quoteIdentifier(
|
|
684
|
+
const result = await session.execute(dialectSql.select(`1 AS ${language().quoteIdentifier(dialectSql.awkwardIdentifier)}`));
|
|
682
685
|
await collect(result.rows);
|
|
683
|
-
expect(result.columns.map((column) => column.name)).toEqual([
|
|
686
|
+
expect(result.columns.map((column) => column.name)).toEqual([dialectSql.awkwardIdentifier]);
|
|
684
687
|
});
|
|
685
688
|
});
|
|
686
689
|
it("givenTypeProbeRow_whenSqlInsertPerDialect_thenRoundTripsEqual", async () => {
|
|
@@ -861,7 +864,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
861
864
|
const texts = AWKWARD_TEXTS;
|
|
862
865
|
const rows = texts.map((text, index) => target.columns.map((column) => column.name === fold("id") ? String(index + 1) : column.name === fold("unicode_text") ? text : null));
|
|
863
866
|
await insertRowsOf(session)(target, rows);
|
|
864
|
-
expect(await run(session, `SELECT unicode_text FROM ${TYPE_PROBE_COPY_TABLE} ORDER BY id`)).toEqual(texts.map((text) => [text]));
|
|
867
|
+
expect(await run(session, `SELECT unicode_text FROM ${TYPE_PROBE_COPY_TABLE} ORDER BY id`)).toEqual(texts.map((text) => [text === "" && fixture.emptyTextIsNull === true ? null : text]));
|
|
865
868
|
});
|
|
866
869
|
});
|
|
867
870
|
it("givenCreateTable_whenEveryImportType_thenTableTakesSampleText", async () => {
|
|
@@ -904,7 +907,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
904
907
|
return session.introspector;
|
|
905
908
|
}
|
|
906
909
|
async function relationOf(session, name) {
|
|
907
|
-
return (await introspectorOf(session).describe(path())).relations.find((relation) => relation.name === name);
|
|
910
|
+
return (await introspectorOf(session).describe(path())).relations.find((relation) => relation.name === fold(name));
|
|
908
911
|
}
|
|
909
912
|
/** Runs a script as the engine does: prepare, the statements in one transaction where it says so, verify, finish. */
|
|
910
913
|
async function apply(session, script) {
|
|
@@ -948,14 +951,14 @@ export function runPluginContract(plugin, fixture) {
|
|
|
948
951
|
return editorOf().script(change, context);
|
|
949
952
|
}
|
|
950
953
|
function column(name, type, nullable = true) {
|
|
951
|
-
return { name, type, nullable, default: null, autoIncrement: false, comment: null, original: null };
|
|
954
|
+
return { name: fold(name), type, nullable, default: null, autoIncrement: false, comment: null, original: null };
|
|
952
955
|
}
|
|
953
956
|
function parentDesign() {
|
|
954
957
|
return {
|
|
955
|
-
name: OBJECT_PARENT_TABLE,
|
|
958
|
+
name: fold(OBJECT_PARENT_TABLE),
|
|
956
959
|
comment: null,
|
|
957
960
|
columns: [column("id", OBJECT_KEY_TYPE, false)],
|
|
958
|
-
primaryKey: ["id"],
|
|
961
|
+
primaryKey: [fold("id")],
|
|
959
962
|
primaryKeyName: null,
|
|
960
963
|
indexes: [],
|
|
961
964
|
foreignKeys: [],
|
|
@@ -964,7 +967,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
964
967
|
}
|
|
965
968
|
function childDesign() {
|
|
966
969
|
return {
|
|
967
|
-
name: OBJECT_CHILD_TABLE,
|
|
970
|
+
name: fold(OBJECT_CHILD_TABLE),
|
|
968
971
|
comment: null,
|
|
969
972
|
columns: [
|
|
970
973
|
column("id", OBJECT_KEY_TYPE, false),
|
|
@@ -972,14 +975,14 @@ export function runPluginContract(plugin, fixture) {
|
|
|
972
975
|
column("label", OBJECT_NARROW_TYPE),
|
|
973
976
|
column("obsolete", OBJECT_NARROW_TYPE),
|
|
974
977
|
],
|
|
975
|
-
primaryKey: ["id"],
|
|
978
|
+
primaryKey: [fold("id")],
|
|
976
979
|
primaryKeyName: null,
|
|
977
|
-
indexes: [{ name: OBJECT_INDEX, columns: ["label"], unique: false, original: null }],
|
|
980
|
+
indexes: [{ name: fold(OBJECT_INDEX), columns: [fold("label")], unique: false, original: null }],
|
|
978
981
|
foreignKeys: [
|
|
979
982
|
{
|
|
980
|
-
name: OBJECT_FOREIGN_KEY,
|
|
981
|
-
columns: ["parent_id"],
|
|
982
|
-
target: { path: path(), relation: OBJECT_PARENT_TABLE, columns: ["id"] },
|
|
983
|
+
name: fold(OBJECT_FOREIGN_KEY),
|
|
984
|
+
columns: [fold("parent_id")],
|
|
985
|
+
target: { path: path(), relation: fold(OBJECT_PARENT_TABLE), columns: [fold("id")] },
|
|
983
986
|
onDelete: "CASCADE",
|
|
984
987
|
onUpdate: null,
|
|
985
988
|
original: null,
|
|
@@ -1020,8 +1023,8 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1020
1023
|
...described,
|
|
1021
1024
|
columns: [
|
|
1022
1025
|
...described.columns
|
|
1023
|
-
.filter((entry) => entry.name !== "obsolete")
|
|
1024
|
-
.map((entry) => (entry.name === "label" ? { ...entry, type: OBJECT_WIDE_TYPE } : entry)),
|
|
1026
|
+
.filter((entry) => entry.name !== fold("obsolete"))
|
|
1027
|
+
.map((entry) => (entry.name === fold("label") ? { ...entry, type: OBJECT_WIDE_TYPE } : entry)),
|
|
1025
1028
|
column("note", OBJECT_NARROW_TYPE),
|
|
1026
1029
|
],
|
|
1027
1030
|
...extra,
|
|
@@ -1030,11 +1033,11 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1030
1033
|
it("givenObjectsDeclared_whenCreateTableWithKeyIndexAndForeignKey_thenDescribedAsDesigned", async () => {
|
|
1031
1034
|
await withTables(async (session) => {
|
|
1032
1035
|
const child = await relationOf(session, OBJECT_CHILD_TABLE);
|
|
1033
|
-
expect(child?.columns.map((entry) => entry.name)).toEqual(["id", "parent_id", "label", "obsolete"]);
|
|
1034
|
-
expect(child?.primaryKey?.columns).toEqual(["id"]);
|
|
1035
|
-
expect(child?.indexes.map((index) => index.name)).toContain(OBJECT_INDEX);
|
|
1036
|
+
expect(child?.columns.map((entry) => entry.name)).toEqual(["id", "parent_id", "label", "obsolete"].map(fold));
|
|
1037
|
+
expect(child?.primaryKey?.columns).toEqual([fold("id")]);
|
|
1038
|
+
expect(child?.indexes.map((index) => index.name)).toContain(fold(OBJECT_INDEX));
|
|
1036
1039
|
expect(child?.foreignKeys.map((key) => [key.target.relation, key.target.columns, key.onDelete])).toEqual([
|
|
1037
|
-
[OBJECT_PARENT_TABLE, ["id"], "CASCADE"],
|
|
1040
|
+
[fold(OBJECT_PARENT_TABLE), [fold("id")], "CASCADE"],
|
|
1038
1041
|
]);
|
|
1039
1042
|
});
|
|
1040
1043
|
});
|
|
@@ -1046,7 +1049,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1046
1049
|
const script = await scriptOf(session, {
|
|
1047
1050
|
kind: "alterTable",
|
|
1048
1051
|
path: path(),
|
|
1049
|
-
relation: OBJECT_CHILD_TABLE,
|
|
1052
|
+
relation: fold(OBJECT_CHILD_TABLE),
|
|
1050
1053
|
design: editorOf().design(relation),
|
|
1051
1054
|
});
|
|
1052
1055
|
expect(script.statements).toEqual([]);
|
|
@@ -1055,37 +1058,38 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1055
1058
|
it("givenTable_whenColumnAddedTypeChangedAndDropped_thenDescribedAfterAndRowsKept", async () => {
|
|
1056
1059
|
await withTables(async (session) => {
|
|
1057
1060
|
const design = await widenedChild(session);
|
|
1058
|
-
await apply(session, await scriptOf(session, { kind: "alterTable", path: path(), relation: OBJECT_CHILD_TABLE, design }));
|
|
1061
|
+
await apply(session, await scriptOf(session, { kind: "alterTable", path: path(), relation: fold(OBJECT_CHILD_TABLE), design }));
|
|
1059
1062
|
const child = await relationOf(session, OBJECT_CHILD_TABLE);
|
|
1060
|
-
expect(child?.columns.map((entry) => entry.name)).toEqual(["id", "parent_id", "label", "note"]);
|
|
1061
|
-
expect(child?.columns.find((entry) => entry.name === "label")?.type).toMatch(OBJECT_WIDE_LENGTH);
|
|
1063
|
+
expect(child?.columns.map((entry) => entry.name)).toEqual(["id", "parent_id", "label", "note"].map(fold));
|
|
1064
|
+
expect(child?.columns.find((entry) => entry.name === fold("label"))?.type).toMatch(OBJECT_WIDE_LENGTH);
|
|
1062
1065
|
expect(child?.foreignKeys).toHaveLength(1);
|
|
1063
|
-
expect(child?.indexes.map((index) => index.name)).toContain(OBJECT_INDEX);
|
|
1066
|
+
expect(child?.indexes.map((index) => index.name)).toContain(fold(OBJECT_INDEX));
|
|
1064
1067
|
expect(await run(session, `SELECT label FROM ${OBJECT_CHILD_TABLE}`)).toEqual([["kept"]]);
|
|
1065
1068
|
});
|
|
1066
1069
|
});
|
|
1067
1070
|
it.runIf(plugin.capabilities.includes("transactions"))("givenTransactionalScript_whenStatementFails_thenNothingApplied", async () => {
|
|
1068
1071
|
await withTables(async (session) => {
|
|
1069
|
-
//
|
|
1072
|
+
// A second index under the table's own index name clashes in every dialect; one named like a
|
|
1073
|
+
// table does not where index names are per table, as on SQL Server.
|
|
1070
1074
|
const design = await widenedChild(session, {});
|
|
1071
1075
|
const clashing = {
|
|
1072
1076
|
...design,
|
|
1073
1077
|
indexes: [
|
|
1074
1078
|
...design.indexes,
|
|
1075
|
-
{ name:
|
|
1079
|
+
{ name: fold(OBJECT_INDEX), columns: [fold("note")], unique: false, original: null },
|
|
1076
1080
|
],
|
|
1077
1081
|
};
|
|
1078
1082
|
const script = await scriptOf(session, {
|
|
1079
1083
|
kind: "alterTable",
|
|
1080
1084
|
path: path(),
|
|
1081
|
-
relation: OBJECT_CHILD_TABLE,
|
|
1085
|
+
relation: fold(OBJECT_CHILD_TABLE),
|
|
1082
1086
|
design: clashing,
|
|
1083
1087
|
});
|
|
1084
1088
|
if (!script.transactional)
|
|
1085
1089
|
return;
|
|
1086
1090
|
await expect(apply(session, script)).rejects.toThrow();
|
|
1087
1091
|
const child = await relationOf(session, OBJECT_CHILD_TABLE);
|
|
1088
|
-
expect(child?.columns.map((entry) => entry.name)).toEqual(["id", "parent_id", "label", "obsolete"]);
|
|
1092
|
+
expect(child?.columns.map((entry) => entry.name)).toEqual(["id", "parent_id", "label", "obsolete"].map(fold));
|
|
1089
1093
|
expect(await run(session, `SELECT label FROM ${OBJECT_CHILD_TABLE}`)).toEqual([["kept"]]);
|
|
1090
1094
|
});
|
|
1091
1095
|
});
|
|
@@ -1094,8 +1098,8 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1094
1098
|
await apply(session, await scriptOf(session, {
|
|
1095
1099
|
kind: "rename",
|
|
1096
1100
|
path: path(),
|
|
1097
|
-
object: { kind: "table", name: OBJECT_CHILD_TABLE },
|
|
1098
|
-
name: OBJECT_RENAMED_TABLE,
|
|
1101
|
+
object: { kind: "table", name: fold(OBJECT_CHILD_TABLE) },
|
|
1102
|
+
name: fold(OBJECT_RENAMED_TABLE),
|
|
1099
1103
|
}));
|
|
1100
1104
|
expect(await relationOf(session, OBJECT_CHILD_TABLE)).toBeUndefined();
|
|
1101
1105
|
expect((await relationOf(session, OBJECT_RENAMED_TABLE))?.columns).toHaveLength(4);
|
|
@@ -1103,7 +1107,12 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1103
1107
|
});
|
|
1104
1108
|
it("givenTableWithRows_whenTruncated_thenEmptyAndStillDescribed", async () => {
|
|
1105
1109
|
await withTables(async (session) => {
|
|
1106
|
-
await apply(session, await scriptOf(session, {
|
|
1110
|
+
await apply(session, await scriptOf(session, {
|
|
1111
|
+
kind: "truncate",
|
|
1112
|
+
path: path(),
|
|
1113
|
+
relation: fold(OBJECT_CHILD_TABLE),
|
|
1114
|
+
cascade: false,
|
|
1115
|
+
}));
|
|
1107
1116
|
expect(await run(session, `SELECT id FROM ${OBJECT_CHILD_TABLE}`)).toEqual([]);
|
|
1108
1117
|
expect(await relationOf(session, OBJECT_CHILD_TABLE)).toBeDefined();
|
|
1109
1118
|
});
|
|
@@ -1113,7 +1122,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1113
1122
|
await apply(session, await scriptOf(session, {
|
|
1114
1123
|
kind: "drop",
|
|
1115
1124
|
path: path(),
|
|
1116
|
-
object: { kind: "table", name: OBJECT_CHILD_TABLE },
|
|
1125
|
+
object: { kind: "table", name: fold(OBJECT_CHILD_TABLE) },
|
|
1117
1126
|
cascade: false,
|
|
1118
1127
|
}));
|
|
1119
1128
|
expect(await relationOf(session, OBJECT_CHILD_TABLE)).toBeUndefined();
|
|
@@ -1405,7 +1414,10 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1405
1414
|
await withCompletion(async (language, catalog) => {
|
|
1406
1415
|
const result = await language.complete(at("SELECT * FROM orders o JOIN |", "|"), catalog);
|
|
1407
1416
|
const first = [...result.items].sort((left, right) => right.boost - left.boost)[0];
|
|
1408
|
-
expect(first).toMatchObject({
|
|
1417
|
+
expect(first).toMatchObject({
|
|
1418
|
+
kind: "join",
|
|
1419
|
+
apply: `${fold("customers")} c ON c.${fold("id")} = o.${fold("customer_id")}`,
|
|
1420
|
+
});
|
|
1409
1421
|
});
|
|
1410
1422
|
});
|
|
1411
1423
|
it("givenNameNeedingQuotes_whenCompleting_thenApplyIsQuotedByDialect", async () => {
|
|
@@ -1498,7 +1510,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1498
1510
|
const read = session.introspector?.definition?.bind(session.introspector);
|
|
1499
1511
|
if (read === undefined)
|
|
1500
1512
|
throw new Error("A ddl session needs introspector.definition.");
|
|
1501
|
-
definition = await read(path, name);
|
|
1513
|
+
definition = await read(path, fold(name));
|
|
1502
1514
|
});
|
|
1503
1515
|
return definition;
|
|
1504
1516
|
}
|
|
@@ -1634,7 +1646,7 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1634
1646
|
await withSession(plugin, fixture, async (session) => {
|
|
1635
1647
|
const plan = await explained(session, dialectSql.explainJoin, false);
|
|
1636
1648
|
expect(plan.analyzed).toBe(false);
|
|
1637
|
-
expect(plan.statement).toMatch(
|
|
1649
|
+
expect(plan.statement).toMatch(dialectSql.explainStatement);
|
|
1638
1650
|
expect(plan.statement).toContain(dialectSql.explainJoin);
|
|
1639
1651
|
expect(plan.raw).toMatch(/\S/);
|
|
1640
1652
|
const nodes = planNodes(plan.roots);
|
|
@@ -1787,10 +1799,14 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1787
1799
|
const { path } = namespaceSwitch();
|
|
1788
1800
|
const written = switcher();
|
|
1789
1801
|
await withSession(plugin, fixture, async (session) => {
|
|
1802
|
+
// A database switch reaches [database, default schema] on SQL Server, so the target may be a prefix.
|
|
1790
1803
|
const listed = (await session.introspector?.namespaces()) ?? [];
|
|
1791
|
-
const target = listed
|
|
1792
|
-
|
|
1793
|
-
|
|
1804
|
+
const target = listed
|
|
1805
|
+
.filter((namespace) => namespace.path.every((part, index) => part === path[index]))
|
|
1806
|
+
.filter((namespace) => switchableTo(written.targets, namespace))
|
|
1807
|
+
.sort((a, b) => b.path.length - a.path.length)[0];
|
|
1808
|
+
expect(target).toBeDefined();
|
|
1809
|
+
const sql = written.statement(target?.path ?? path);
|
|
1794
1810
|
expect(plugin.language?.classify(sql).setsNamespace).toBe(true);
|
|
1795
1811
|
await collect((await session.execute(sql)).rows);
|
|
1796
1812
|
expect(await current(session)).toEqual(path);
|
|
@@ -2240,7 +2256,16 @@ export function runPluginContract(plugin, fixture) {
|
|
|
2240
2256
|
});
|
|
2241
2257
|
});
|
|
2242
2258
|
});
|
|
2243
|
-
it("
|
|
2259
|
+
it.runIf(fixture.sessions?.cancels === false)("givenNoCancel_whenCancelSession_thenRejects", async () => {
|
|
2260
|
+
await withMonitor(async (monitor) => {
|
|
2261
|
+
await withSleeper(async (sleeper) => {
|
|
2262
|
+
const row = await present(monitor, idOf(sleeper), (listed) => listed.state === "active");
|
|
2263
|
+
expect(row.cancel).toBeNull();
|
|
2264
|
+
expect(await rejectionCode(monitor.cancelSession({ id: row.id, instance: row.instance }))).toBe("statementFailed");
|
|
2265
|
+
});
|
|
2266
|
+
});
|
|
2267
|
+
});
|
|
2268
|
+
it.runIf(fixture.sessions?.cancels !== false)("givenActiveQuery_whenCancel_thenQueryCancelled", async () => {
|
|
2244
2269
|
await withMonitor(async (monitor) => {
|
|
2245
2270
|
await withSleeper(async (sleeper, running) => {
|
|
2246
2271
|
const row = await present(monitor, idOf(sleeper), (listed) => listed.state === "active");
|
|
@@ -2268,6 +2293,15 @@ export function runPluginContract(plugin, fixture) {
|
|
|
2268
2293
|
}
|
|
2269
2294
|
});
|
|
2270
2295
|
it("givenMissingSession_whenTerminate_thenRejects", async () => {
|
|
2296
|
+
const missingId = sessionsFixture().missingId;
|
|
2297
|
+
if (missingId !== undefined) {
|
|
2298
|
+
await withMonitor(async (monitor) => {
|
|
2299
|
+
const listed = await monitor.list();
|
|
2300
|
+
expect(listed.sessions.map((session) => session.id)).not.toContain(missingId);
|
|
2301
|
+
expect(await rejectionCode(monitor.terminateSession({ id: missingId, instance: null }))).toBe("statementFailed");
|
|
2302
|
+
});
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2271
2305
|
const victim = await connect(plugin, fixture.config);
|
|
2272
2306
|
const id = idOf(victim);
|
|
2273
2307
|
await victim.close();
|
package/dist/url.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface ConnectionUrlSpec {
|
|
|
25
25
|
readonly values?: Readonly<Record<string, string>> | undefined;
|
|
26
26
|
}[] | undefined;
|
|
27
27
|
readonly sets?: Readonly<Record<string, Readonly<Record<string, ConnectionValue>>>> | undefined;
|
|
28
|
+
readonly semicolonParams?: boolean | undefined;
|
|
28
29
|
}
|
|
29
30
|
interface UrlField {
|
|
30
31
|
readonly key: string;
|
package/dist/url.js
CHANGED
|
@@ -9,6 +9,9 @@ const SCHEME = /^([a-zA-Z][a-zA-Z0-9+.-]*):/;
|
|
|
9
9
|
const WINDOWS_DRIVE_PATH = /^\/[A-Za-z]:[/\\]/;
|
|
10
10
|
const LEADING_SLASHES = /^\/+/;
|
|
11
11
|
const LOCAL_HOSTS = new Set(["", "localhost"]);
|
|
12
|
+
/** `host`, `[v6]`, then SQL Server's `\instance` and the port, as the JDBC driver reads them. */
|
|
13
|
+
const SEMICOLON_AUTHORITY = /^(\[[^\]]*\]|[^\\:]*)(?:\\([^:]*))?(?::([^:]*))?$/;
|
|
14
|
+
const INSTANCE_PARAM = "instanceName";
|
|
12
15
|
/** libpq's names for the parts of the URL, accepted as query parameters by every server form. */
|
|
13
16
|
const PART_PARAMS = {
|
|
14
17
|
host: "host",
|
|
@@ -104,6 +107,8 @@ function parseServer(form, url, text) {
|
|
|
104
107
|
if (scheme === undefined || !url.schemes.includes(scheme) || !text.slice(scheme.length + 1).startsWith("//")) {
|
|
105
108
|
throw new ConnectionUrlError(`Expected a URL starting with ${schemeList(url)}.`);
|
|
106
109
|
}
|
|
110
|
+
if (url.semicolonParams === true)
|
|
111
|
+
return parseSemicolon(form, url, scheme, text);
|
|
107
112
|
let parsed;
|
|
108
113
|
try {
|
|
109
114
|
parsed = new URL(text);
|
|
@@ -121,6 +126,56 @@ function parseServer(form, url, text) {
|
|
|
121
126
|
collected.query(parsed.search);
|
|
122
127
|
return collected.done();
|
|
123
128
|
}
|
|
129
|
+
/** Splits on `;` outside `{…}`, which the JDBC driver uses to put a `;` in a value. */
|
|
130
|
+
function semicolonPairs(text) {
|
|
131
|
+
const pairs = [];
|
|
132
|
+
let current = "";
|
|
133
|
+
let braced = false;
|
|
134
|
+
for (const char of text) {
|
|
135
|
+
if (char === "{")
|
|
136
|
+
braced = true;
|
|
137
|
+
else if (char === "}")
|
|
138
|
+
braced = false;
|
|
139
|
+
if (char === ";" && !braced) {
|
|
140
|
+
pairs.push(current);
|
|
141
|
+
current = "";
|
|
142
|
+
}
|
|
143
|
+
else
|
|
144
|
+
current += char;
|
|
145
|
+
}
|
|
146
|
+
pairs.push(current);
|
|
147
|
+
return pairs.filter((pair) => pair.trim() !== "");
|
|
148
|
+
}
|
|
149
|
+
function unbraced(value) {
|
|
150
|
+
const trimmed = value.trim();
|
|
151
|
+
return trimmed.startsWith("{") && trimmed.endsWith("}") ? trimmed.slice(1, -1) : trimmed;
|
|
152
|
+
}
|
|
153
|
+
function parseSemicolon(form, url, scheme, text) {
|
|
154
|
+
const [authority = "", ...pairs] = semicolonPairs(text.slice(scheme.length + "://".length));
|
|
155
|
+
const match = SEMICOLON_AUTHORITY.exec(authority.trim());
|
|
156
|
+
if (match === null) {
|
|
157
|
+
throw new ConnectionUrlError(`"${authority}" is not host, host\\instance or host:port.`);
|
|
158
|
+
}
|
|
159
|
+
const [, host = "", instance, port] = match;
|
|
160
|
+
if (port !== undefined && !/^\d+$/.test(port)) {
|
|
161
|
+
throw new ConnectionUrlError(`The port "${port}" is not a number.`);
|
|
162
|
+
}
|
|
163
|
+
const collected = new Collected(form, url);
|
|
164
|
+
Object.assign(collected.values, url.sets?.[scheme]);
|
|
165
|
+
collected.part("host", host.replace(/^\[(.*)\]$/, "$1"));
|
|
166
|
+
if (port !== undefined)
|
|
167
|
+
collected.part("port", port);
|
|
168
|
+
if (instance !== undefined && instance !== "")
|
|
169
|
+
collected.param(INSTANCE_PARAM, instance);
|
|
170
|
+
for (const pair of pairs) {
|
|
171
|
+
const equals = pair.indexOf("=");
|
|
172
|
+
if (equals < 0)
|
|
173
|
+
collected.ignored.push(pair.trim());
|
|
174
|
+
else
|
|
175
|
+
collected.param(pair.slice(0, equals).trim(), unbraced(pair.slice(equals + 1)));
|
|
176
|
+
}
|
|
177
|
+
return collected.done();
|
|
178
|
+
}
|
|
124
179
|
function parseFile(form, url, text) {
|
|
125
180
|
const collected = new Collected(form, url);
|
|
126
181
|
const scheme = SCHEME.exec(text)?.[1]?.toLowerCase();
|
|
@@ -175,6 +230,37 @@ function isConnectionValue(value) {
|
|
|
175
230
|
function written(value) {
|
|
176
231
|
return String(value);
|
|
177
232
|
}
|
|
233
|
+
function bracedValue(value) {
|
|
234
|
+
return /[;{}=]/.test(value) || value !== value.trim() ? `{${value}}` : value;
|
|
235
|
+
}
|
|
236
|
+
/** Every parameter after a `;`, one per field, so a part a parameter also names is written once. */
|
|
237
|
+
function semicolonUrl(form, url, scheme, config) {
|
|
238
|
+
const text = (key) => {
|
|
239
|
+
if (key === undefined || fieldOf(form, key)?.secret === true)
|
|
240
|
+
return "";
|
|
241
|
+
const value = config[key];
|
|
242
|
+
return isConnectionValue(value) ? written(value) : "";
|
|
243
|
+
};
|
|
244
|
+
const host = text(url.parts.host);
|
|
245
|
+
const port = text(url.parts.port);
|
|
246
|
+
const pairs = [];
|
|
247
|
+
const keys = new Set([url.parts.host, url.parts.port]);
|
|
248
|
+
for (const param of url.params ?? []) {
|
|
249
|
+
if (keys.has(param.key))
|
|
250
|
+
continue;
|
|
251
|
+
keys.add(param.key);
|
|
252
|
+
const value = config[param.key];
|
|
253
|
+
const field = fieldOf(form, param.key);
|
|
254
|
+
if (field?.secret === true || !isConnectionValue(value) || value === "" || value === field?.default)
|
|
255
|
+
continue;
|
|
256
|
+
const shown = param.values === undefined
|
|
257
|
+
? written(value)
|
|
258
|
+
: Object.entries(param.values).find(([, mapped]) => mapped === value)?.[0];
|
|
259
|
+
if (shown !== undefined)
|
|
260
|
+
pairs.push(`;${param.name}=${bracedValue(shown)}`);
|
|
261
|
+
}
|
|
262
|
+
return `${scheme}://${host.includes(":") ? `[${host}]` : host}${port === "" ? "" : `:${port}`}${pairs.join("")}`;
|
|
263
|
+
}
|
|
178
264
|
function encodedPath(path) {
|
|
179
265
|
return encodeURI(path).replaceAll("?", "%3F").replaceAll("#", "%23");
|
|
180
266
|
}
|
|
@@ -193,6 +279,8 @@ export function formatConnectionUrl(form, config) {
|
|
|
193
279
|
url.schemes.find((candidate) => url.sets?.[candidate] === undefined) ??
|
|
194
280
|
url.schemes[0] ??
|
|
195
281
|
"";
|
|
282
|
+
if (url.semicolonParams === true)
|
|
283
|
+
return semicolonUrl(form, url, scheme, config);
|
|
196
284
|
const valueOf = (part) => {
|
|
197
285
|
const key = url.parts[part];
|
|
198
286
|
if (key === undefined || fieldOf(form, key)?.secret === true)
|
package/dist/version.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The plugin contract's version, by semver (decision 106): a host loads a plugin built for the same
|
|
3
3
|
* major and an older or equal minor. Typed `string` so a bump does not change the API report.
|
|
4
4
|
*/
|
|
5
|
-
export const API_VERSION = "1.
|
|
5
|
+
export const API_VERSION = "1.5.0";
|
|
6
6
|
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
7
7
|
/** `major.minor.patch` and nothing else; pre-release and build tags are not accepted. */
|
|
8
8
|
export function parseVersion(text) {
|