@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.
@@ -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
- /** The zero-based argument the caret is in: the top-level commas before it inside the call. */
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;
@@ -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; a plugin may.
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
  }
@@ -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
- function availableFunctions(server) {
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 ? [] : columnItems(found.relation.columns);
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
- const base = initials === "" || !options.plainIdentifier.test(initials) ? FALLBACK_ALIAS : initials;
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
- : quoted(source.alias.name);
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 ? UNQUOTED_WORD : 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 : UNQUOTED_WORD,
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 call = callAt(text, offset, options.dialect, reserved, (name) => specNamed(name, server) !== undefined);
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.argument,
540
+ argument: argumentOf(spec, call),
475
541
  from: call.from - shift,
476
542
  to: call.to - shift,
477
543
  });
@@ -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
- /** `[name, signatures, summary, available?]`, a signature written `"date, format -> string"`. */
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
- /** Reads `"str, len? -> string"`: `?` marks an optional parameter, `...` one that repeats. */
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. */
@@ -20,7 +20,13 @@ const SHOWN_ARROW = "→";
20
20
  const OPTIONAL_MARK = "?";
21
21
  const VARIADIC_MARK = "...";
22
22
  const COMPARATOR = /^(>=|<=|>|<|=)?(\d+(?:\.\d+)*)$/;
23
- function parameter(written) {
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 { name, ...(optional ? { optional } : {}), ...(variadic ? { variadic } : {}) };
37
+ return {
38
+ name,
39
+ ...(optional ? { optional } : {}),
40
+ ...(variadic ? { variadic } : {}),
41
+ ...(lead === null ? {} : { lead }),
42
+ };
32
43
  }
33
- /** Reads `"str, len? -> string"`: `?` marks an optional parameter, `...` one that repeats. */
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(",").map(parameter),
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
- name,
48
- signatures: (typeof signatures === "string" ? [signatures] : signatures).map(parseSignature),
49
- summary,
50
- category,
51
- ...(available === undefined ? {} : { available }),
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
- return parameter.optional === true ? `[${name}]` : name;
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
- /** `(date, format) → string`, the popup's detail for one signature. */
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
- return `(${signature.parameters.map(shownParameter).join(", ")}) ${SHOWN_ARROW} ${signature.returns}`;
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 lines = spec.signatures.map((signature) => `${label}${signatureLabel(signature)}`);
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
- /** A spec as an item: `name()` with the caret between the parentheses when it takes arguments. */
109
- export function functionItem(spec, boost) {
110
- if (typeof spec === "string")
111
- return { label: spec, kind: "function", detail: null, apply: spec, boost, info: null, caret: null };
112
- const label = spec.name.toLowerCase();
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 : signatureLabel(first),
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) {
@@ -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, |` and `SET SCHEMA |`. Null anywhere else,
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;
@@ -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, |` and `SET SCHEMA |`. Null anywhere else,
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 === "use" ? useTarget(sql, tokens) : searchPathTarget(sql, tokens);
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 === "use" ? `USE ${quoteIdentifier(name)}` : `SET search_path TO ${quoteIdentifier(name)}`;
247
+ return SWITCH_STATEMENTS[form](quoteIdentifier(name));
218
248
  },
219
249
  };
220
250
  }
@@ -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, so their commas never count.
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
@@ -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, so their commas never count.
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
- function callable(at) {
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
- if (name === undefined || word === null || !known(word))
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 name;
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 ? name : null;
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({ name, named: name === null && !grouping(text, tokens[index - 1]), open: index, commas: 0 });
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: text.slice(frame.name.from, frame.name.to),
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
  };
@@ -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;
@@ -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;
@@ -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 {
@@ -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(AWKWARD_IDENTIFIER)}`));
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([AWKWARD_IDENTIFIER]);
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
- // An index named like a table clashes in every dialect whose index names share the table namespace.
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: OBJECT_PARENT_TABLE, columns: ["note"], unique: false, original: null },
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, { kind: "truncate", path: path(), relation: OBJECT_CHILD_TABLE, cascade: false }));
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({ kind: "join", apply: "customers c ON c.id = o.customer_id" });
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(/^EXPLAIN\b/i);
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.find((namespace) => namespace.path.join(".") === path.join("."));
1792
- expect(target === undefined ? false : switchableTo(written.targets, target)).toBe(true);
1793
- const sql = written.statement(path);
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("givenActiveQuery_whenCancel_thenQueryCancelled", async () => {
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.3.0";
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablecraft-app/plugin-api",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "The datasource plugin contract for TableCraft",
5
5
  "license": "MIT",
6
6
  "type": "module",