@tablecraft-app/plugin-api 1.4.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.
@@ -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
  }
@@ -70,6 +70,8 @@ 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(['"', "`", "["]);
@@ -126,6 +128,7 @@ function relationKind(relation) {
126
128
  /** The shared completion and navigation every built-in plugin spreads into its `SqlLanguage` (decision 028). */
127
129
  export function sqlCompleter(options) {
128
130
  const reserved = new Set(options.keywords.map((keyword) => keyword.toUpperCase()));
131
+ const unquotedWord = options.dialect.variables === true ? VARIABLE_WORD : UNQUOTED_WORD;
129
132
  const { denotes, sameIdentifier, cteNamed, sourceNamed, serverOf, Lookup } = nameRules(options);
130
133
  const specs = new Map();
131
134
  for (const spec of options.functions) {
@@ -224,7 +227,9 @@ export function sqlCompleter(options) {
224
227
  .filter((word) => word !== "")
225
228
  .map((word) => word.charAt(0).toLowerCase())
226
229
  .join("");
227
- 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;
228
233
  let alias = base;
229
234
  for (let suffix = 1; taken.has(alias) || reserved.has(alias.toUpperCase()); suffix += 1)
230
235
  alias = `${base}${String(suffix)}`;
@@ -258,7 +263,9 @@ export function sqlCompleter(options) {
258
263
  continue;
259
264
  const near = source.alias === null
260
265
  ? relationReference(found.path, found.relation.name, lookup.current)
261
- : quoted(source.alias.name);
266
+ : source.alias.quoted
267
+ ? options.quoteIdentifier(source.alias.name)
268
+ : source.alias.name;
262
269
  for (const key of found.relation.foreignKeys) {
263
270
  const alias = aliasFor(key.target.relation, new Set(taken));
264
271
  const on = key.columns
@@ -376,7 +383,7 @@ export function sqlCompleter(options) {
376
383
  const lookup = new Lookup(catalog);
377
384
  await lookup.follow(request.text, request.offset);
378
385
  const items = await namespaceItems(slot, lookup);
379
- 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 };
380
387
  }
381
388
  }
382
389
  const { text, offset, shift } = wrap(request);
@@ -390,7 +397,7 @@ export function sqlCompleter(options) {
390
397
  from: scope.prefixFrom - shift,
391
398
  to: offset - shift,
392
399
  items: typedQuote ? quotedLabels(items) : items,
393
- validFor: typedQuote ? null : UNQUOTED_WORD,
400
+ validFor: typedQuote ? null : unquotedWord,
394
401
  };
395
402
  }
396
403
  function sourceDeclaring(query, word) {
@@ -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
  }
@@ -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.4.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.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "The datasource plugin contract for TableCraft",
5
5
  "license": "MIT",
6
6
  "type": "module",