@tablecraft-app/plugin-api 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/errors.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Failures the host tells apart; anything else a plugin throws is reported as `statementFailed`. */
2
- export declare const PLUGIN_ERROR_CODES: readonly ["cancelled", "connectionFailed", "statementFailed", "introspectionFailed", "definitionNotFound", "explainUnsupported", "connectionLost", "sessionBusy", "conflict"];
2
+ export declare const PLUGIN_ERROR_CODES: readonly ["cancelled", "connectionFailed", "statementFailed", "introspectionFailed", "definitionNotFound", "explainUnsupported", "connectionLost", "sessionBusy", "conflict", "timedOut"];
3
3
  export type PluginErrorCode = (typeof PLUGIN_ERROR_CODES)[number];
4
4
  /**
5
5
  * Where the database located a failure, relative to the statement text the plugin received:
package/dist/errors.js CHANGED
@@ -23,6 +23,11 @@ export const PLUGIN_ERROR_CODES = [
23
23
  * nothing (decision 115); the host offers to reload or to overwrite.
24
24
  */
25
25
  "conflict",
26
+ /**
27
+ * The server stopped the statement at the limit `ExecuteOptions.serverTimeoutMs` set (decision
28
+ * 129); the message names the server's setting.
29
+ */
30
+ "timedOut",
26
31
  ];
27
32
  /** What a plugin throws when the host should react to the kind of failure, not just show it. */
28
33
  export class PluginError extends Error {
package/dist/explain.d.ts CHANGED
@@ -44,6 +44,8 @@ export interface ExplainedPlan {
44
44
  export interface ExplainOptions {
45
45
  /** Run the statement and report what it did (Explain Analyse), not only how it would run. */
46
46
  readonly analyze: boolean;
47
+ /** `ExecuteOptions.serverTimeoutMs` for the statement `explain` runs (decision 129). */
48
+ readonly serverTimeoutMs?: number;
47
49
  }
48
50
  /**
49
51
  * Whether a statement can be explained, decided without a database. `writes` says the statement
package/dist/index.d.ts CHANGED
@@ -5,9 +5,9 @@ export { ConflictError, DefinitionNotFoundError, type ErrorPosition, errorPositi
5
5
  export { type ExplainedPlan, type Explainability, type ExplainOptions, type PlanNode, type PlanProperty, } from "./explain.js";
6
6
  export { KEY_TYPES, type KeyApplied, type KeyApplyOptions, type KeyBytes, type KeyChange, type KeyContents, type KeyDatabase, type KeyDetails, type KeyExpiry, type KeyInfo, type KeyName, type KeyReadRequest, type KeyScan, type KeyScanRequest, type KeySource, type Keyspace, type KeyType, } from "./keyspace.js";
7
7
  export { OBJECT_KINDS, type CatalogObject, type CheckDesign, type ColumnDesign, type DdlContext, type DdlScript, type ForeignKeyDesign, type IndexDesign, type ObjectChange, type ObjectChangeKind, type ObjectEditor, type ObjectFeatures, type ObjectKind, type TableDesign, } from "./objects.js";
8
- export { CONNECTION_FIELD_GROUPS, CONNECTION_FIELD_KINDS, CONNECTION_URL_FORMS, CONNECTION_URL_PARTS, connectionFieldSchema, connectionUrlSchema, MANIFEST_FILE, networkSpecSchema, pluginManifestSchema, TLS_FIELD_ROLES, tlsSpecSchema, type ConnectionField, type ConnectionFieldGroup, type ConnectionFieldKind, type ConnectionUrl, type ConnectionUrlForm, type ConnectionUrlPart, type NetworkSpec, type PluginManifest, type TlsFieldRole, type TlsSpec, } from "./manifest.js";
8
+ export { BRAND_ID, brandSchema, CONNECTION_FIELD_GROUPS, CONNECTION_FIELD_KINDS, CONNECTION_URL_FORMS, CONNECTION_URL_PARTS, connectionFieldSchema, connectionUrlSchema, MANIFEST_FILE, networkSpecSchema, pluginManifestSchema, TLS_FIELD_ROLES, tlsSpecSchema, type Brand, type ConnectionField, type ConnectionFieldGroup, type ConnectionFieldKind, type ConnectionUrl, type ConnectionUrlForm, type ConnectionUrlPart, type NetworkSpec, type PluginManifest, type TlsFieldRole, type TlsSpec, } from "./manifest.js";
9
9
  export { ConnectionUrlError, formatConnectionUrl, parseConnectionUrl, type ConnectionForm, type ConnectionUrlSpec, type ConnectionValue, type ParsedConnectionUrl, } from "./url.js";
10
- export { type BoundStatement, CAPABILITIES, type Capability, type Column, type ColumnOrigin, type CommandLanguage, type CommandReply, type ConnectOptions, type DatasourcePlugin, type ExecuteOptions, type ExecuteResult, type FormatOptions, FORMAT_STYLES, type FormatStyle, GENERATED_STATEMENTS, type GeneratedStatement, IMPORT_KINDS, type ImportKind, type InferredColumn, type InsertRow, type InsertTarget, KEYWORD_CASES, type NamespaceSwitch, NODE_ALL_PRIMARIES, NODE_AUTO, NODE_ROLES, type NodeRole, type ServerInfo, type ServerInfoSection, type ServerNode, type SlowCommand, switchableTo, type NewColumn, type KeywordCase, PARAMETER_STYLES, type ParameterStyle, type ParameterValue, type RoutineArgument, type RoutineCall, type Row, type SavepointStatements, type SelectSource, omittedMessages, SERVER_MESSAGE_LEVELS, SERVER_MESSAGE_LIMIT, type ServerMessage, type ServerMessageLevel, type SqlParameter, type Session, type SqlLanguage, STATEMENT_KINDS, type StatementClass, type StatementKind, type StatementSpan, type StatementStream, type StreamedStatement, type TlsInfo, TRANSACTION_MODES, type TransactionMode, type Transactions, } from "./plugin.js";
10
+ export { type BoundStatement, CAPABILITIES, type Capability, type Column, type ColumnOrigin, type CommandLanguage, type CommandReply, type ConnectOptions, type DatasourcePlugin, type ExecuteOptions, type ExecuteResult, type FormatOptions, FORMAT_STYLES, type FormatStyle, GENERATED_STATEMENTS, type GeneratedStatement, IMPORT_KINDS, type ImportKind, type InferredColumn, type InsertRow, type InsertTarget, KEYWORD_CASES, type NamespaceSwitch, NODE_ALL_PRIMARIES, NODE_AUTO, NODE_ROLES, type NodeRole, type ServerInfo, type ServerInfoSection, type ServerNode, type ServerTimeout, type SlowCommand, switchableTo, type NewColumn, type KeywordCase, PARAMETER_STYLES, type ParameterStyle, type ParameterValue, type RoutineArgument, type RoutineCall, type Row, type SavepointStatements, type SelectSource, omittedMessages, SERVER_MESSAGE_LEVELS, SERVER_MESSAGE_LIMIT, type ServerMessage, type ServerMessageLevel, type SqlParameter, type Session, type SqlLanguage, STATEMENT_KINDS, type StatementClass, type StatementKind, type StatementSpan, type StatementStream, type StreamedStatement, type TlsInfo, TRANSACTION_MODES, type TransactionMode, type Transactions, } from "./plugin.js";
11
11
  export { SERVER_SESSION_STATES, type ServerSession, type ServerSessionState, type SessionListing, type SessionMonitor, type SessionTarget, } from "./sessions.js";
12
12
  export { TLS_CHAIN_CODES, TLS_HOST_CODE, homeExpanded, tlsFailureMessage, tlsFileMessage, tlsKeyIsEncrypted, tlsKeyMessage, type TlsVerification, } from "./tls.js";
13
13
  export { BYTES_PREVIEW_LENGTH, toBytesValue, toWireInteger, toWireValue, type WireValueOptions, type BigIntValue, type BytesValue, type DateValue, type JsonValue, type TypeClass, type WireValue, } from "./values.js";
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ export { ConflictError, DefinitionNotFoundError, errorPositionOf, isPluginError,
5
5
  export {} from "./explain.js";
6
6
  export { KEY_TYPES, } from "./keyspace.js";
7
7
  export { OBJECT_KINDS, } from "./objects.js";
8
- export { CONNECTION_FIELD_GROUPS, CONNECTION_FIELD_KINDS, CONNECTION_URL_FORMS, CONNECTION_URL_PARTS, connectionFieldSchema, connectionUrlSchema, MANIFEST_FILE, networkSpecSchema, pluginManifestSchema, TLS_FIELD_ROLES, tlsSpecSchema, } from "./manifest.js";
8
+ export { BRAND_ID, brandSchema, CONNECTION_FIELD_GROUPS, CONNECTION_FIELD_KINDS, CONNECTION_URL_FORMS, CONNECTION_URL_PARTS, connectionFieldSchema, connectionUrlSchema, MANIFEST_FILE, networkSpecSchema, pluginManifestSchema, TLS_FIELD_ROLES, tlsSpecSchema, } from "./manifest.js";
9
9
  export { ConnectionUrlError, formatConnectionUrl, parseConnectionUrl, } from "./url.js";
10
10
  export { CAPABILITIES, FORMAT_STYLES, GENERATED_STATEMENTS, IMPORT_KINDS, KEYWORD_CASES, NODE_ALL_PRIMARIES, NODE_AUTO, NODE_ROLES, switchableTo, PARAMETER_STYLES, omittedMessages, SERVER_MESSAGE_LEVELS, SERVER_MESSAGE_LIMIT, STATEMENT_KINDS, TRANSACTION_MODES, } from "./plugin.js";
11
11
  export { SERVER_SESSION_STATES, } from "./sessions.js";
@@ -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
  /**
@@ -105,6 +106,14 @@ export type TlsSpec = z.infer<typeof tlsSpecSchema>;
105
106
  /** The roles of `TlsSpec` that name a field, in the order the dialog draws them. */
106
107
  export declare const TLS_FIELD_ROLES: readonly ["mode", "caFile", "certFile", "keyFile", "keyPassphrase"];
107
108
  export type TlsFieldRole = (typeof TLS_FIELD_ROLES)[number];
109
+ /** A simple-icons slug, the one public catalogue of database product ids (decision 128). */
110
+ export declare const BRAND_ID: RegExp;
111
+ /** A database product a plugin speaks; `id` is a simple-icons slug, `name` what a row shows. */
112
+ export declare const brandSchema: z.ZodObject<{
113
+ id: z.ZodString;
114
+ name: z.ZodString;
115
+ }, z.core.$strict>;
116
+ export type Brand = z.infer<typeof brandSchema>;
108
117
  export declare const pluginManifestSchema: z.ZodObject<{
109
118
  id: z.ZodString;
110
119
  name: z.ZodString;
@@ -130,8 +139,13 @@ export declare const pluginManifestSchema: z.ZodObject<{
130
139
  keyValue: "keyValue";
131
140
  nodes: "nodes";
132
141
  serverInfo: "serverInfo";
142
+ serverTimeout: "serverTimeout";
133
143
  }>>;
134
144
  highlight: z.ZodString;
145
+ brands: z.ZodOptional<z.ZodArray<z.ZodObject<{
146
+ id: z.ZodString;
147
+ name: z.ZodString;
148
+ }, z.core.$strict>>>;
135
149
  connection: z.ZodObject<{
136
150
  fields: z.ZodArray<z.ZodObject<{
137
151
  key: z.ZodString;
@@ -181,6 +195,7 @@ export declare const pluginManifestSchema: z.ZodObject<{
181
195
  values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
182
196
  }, z.core.$strict>>>;
183
197
  sets: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>>;
198
+ semicolonParams: z.ZodOptional<z.ZodBoolean>;
184
199
  }, z.core.$strict>>;
185
200
  network: z.ZodOptional<z.ZodObject<{
186
201
  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
@@ -152,6 +157,15 @@ function urlSetsDeclared(connection) {
152
157
  return Object.entries(url.sets).every(([scheme, values]) => url.schemes.includes(scheme) &&
153
158
  Object.keys(values).every((key) => connection.fields.some((field) => field.key === key)));
154
159
  }
160
+ /** A simple-icons slug, the one public catalogue of database product ids (decision 128). */
161
+ export const BRAND_ID = /^[a-z0-9_]+$/;
162
+ /** A database product a plugin speaks; `id` is a simple-icons slug, `name` what a row shows. */
163
+ export const brandSchema = z.strictObject({
164
+ id: z
165
+ .string()
166
+ .regex(BRAND_ID, { message: "expected a simple-icons slug: lower-case letters, digits and underscores" }),
167
+ name: z.string().min(1),
168
+ });
155
169
  function includesAny(capabilities, ...wanted) {
156
170
  return wanted.some((capability) => capabilities.includes(capability));
157
171
  }
@@ -166,6 +180,14 @@ export const pluginManifestSchema = z
166
180
  capabilities: z.array(z.enum(CAPABILITIES)),
167
181
  /** The highlighter the console uses for this plugin's dialect (decision 008). */
168
182
  highlight: z.string().min(1),
183
+ /** The database products the plugin speaks, its default first (decision 128). Absent: rows name the plugin. */
184
+ brands: z
185
+ .array(brandSchema)
186
+ .min(1)
187
+ .refine((brands) => new Set(brands.map((brand) => brand.id)).size === brands.length, {
188
+ message: "brand ids must be unique",
189
+ })
190
+ .optional(),
169
191
  /** The connection form, readable without running plugin code (decision 019). */
170
192
  connection: z
171
193
  .strictObject({
package/dist/plugin.d.ts CHANGED
@@ -11,7 +11,7 @@ import type { TypeClass, WireValue } from "./values.js";
11
11
  * What a plugin can do. The UI branches on these, never on a plugin id (decision 007), and a
12
12
  * capability without contract-suite coverage cannot be declared (decision 013).
13
13
  */
14
- export declare const CAPABILITIES: readonly ["sql", "cancel", "schemaTree", "transactions", "ddl", "explain", "explainAnalyze", "currentNamespace", "keepAlive", "bulkInsert", "objects", "serverMessages", "sessions", "routineCall", "commands", "keyValue", "nodes", "serverInfo"];
14
+ export declare const CAPABILITIES: readonly ["sql", "cancel", "schemaTree", "transactions", "ddl", "explain", "explainAnalyze", "currentNamespace", "keepAlive", "bulkInsert", "objects", "serverMessages", "sessions", "routineCall", "commands", "keyValue", "nodes", "serverInfo", "serverTimeout"];
15
15
  export type Capability = (typeof CAPABILITIES)[number];
16
16
  /** Statements `SqlLanguage.generate` writes from a catalog relation (plan 010). */
17
17
  export declare const GENERATED_STATEMENTS: readonly ["drop", "select", "insert", "update"];
@@ -122,6 +122,31 @@ 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;
131
+ /**
132
+ * Have the server itself stop the statement after this many milliseconds, at least 1 (decision
133
+ * 129), through its own setting, never above a limit the server already had. The host sends it
134
+ * only to a session whose `serverTimeout` is not null. Absent: the plugin puts back the
135
+ * server's own value. A statement the server stops rejects with `timedOut`.
136
+ */
137
+ readonly serverTimeoutMs?: number;
138
+ }
139
+ /** What the connected server stops by itself once `ExecuteOptions.serverTimeoutMs` passes (decision 129). */
140
+ export interface ServerTimeout {
141
+ /** The server's own name for it, for messages: `max_execution_time`, `statement_timeout`. */
142
+ readonly setting: string;
143
+ /** `reads`: only `SELECT` (and `SELECT … FOR UPDATE`); `all`: every statement. */
144
+ readonly covers: "reads" | "all";
145
+ /**
146
+ * `statement`: from the start until the last row is sent, so a result read slowly is cut too;
147
+ * `serverWork`: only while the server works on it.
148
+ */
149
+ readonly counts: "statement" | "serverWork";
125
150
  }
126
151
  /** `ExecuteOptions.node`: the plugin routes the command by its keys (decision 114). */
127
152
  export declare const NODE_AUTO = "auto";
@@ -269,6 +294,8 @@ export interface Session {
269
294
  close(): Promise<void>;
270
295
  /** What the server reports about itself, such as `17.4`; null when it reports nothing. */
271
296
  readonly serverVersion: string | null;
297
+ /** The manifest `brands` id of the product this session reached; absent when the plugin declares no brands or cannot tell. */
298
+ readonly serverBrand?: string;
272
299
  /**
273
300
  * True when another session on the same configuration sees a different database, as with
274
301
  * SQLite's `:memory:`; the engine then never re-runs this session's statements elsewhere.
@@ -279,6 +306,12 @@ export interface Session {
279
306
  * (decision 072).
280
307
  */
281
308
  readonly tls?: TlsInfo | null;
309
+ /**
310
+ * Present exactly when the plugin declares `serverTimeout` (decision 129): what this server stops
311
+ * by itself under `ExecuteOptions.serverTimeoutMs`, read once at connect; null when this server
312
+ * version has no such setting.
313
+ */
314
+ readonly serverTimeout?: ServerTimeout | null;
282
315
  }
283
316
  /** What a TLS handshake agreed, as Node's socket names it: `TLSv1.3`, `TLS_AES_256_GCM_SHA384`. */
284
317
  export interface TlsInfo {
@@ -311,7 +344,8 @@ export interface StatementSpan {
311
344
  readonly sql: string;
312
345
  /**
313
346
  * 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.
347
+ * (decision 100). Absent means once. The host never repeats a statement; it passes the count
348
+ * as `ExecuteOptions.repeat` and the plugin runs it that many times.
315
349
  */
316
350
  readonly repeat?: number;
317
351
  }
package/dist/plugin.js CHANGED
@@ -21,6 +21,7 @@ export const CAPABILITIES = [
21
21
  "keyValue",
22
22
  "nodes",
23
23
  "serverInfo",
24
+ "serverTimeout",
24
25
  ];
25
26
  /** Statements `SqlLanguage.generate` writes from a catalog relation (plan 010). */
26
27
  export const GENERATED_STATEMENTS = ["drop", "select", "insert", "update"];
@@ -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;
@@ -9,6 +9,18 @@ export interface ContractFixture {
9
9
  readonly statements?: {
10
10
  /** A statement that runs for `seconds`; required when the plugin declares `cancel`. */
11
11
  readonly sleep?: (seconds: number) => string;
12
+ /**
13
+ * A read that runs at least `seconds` on an idle server and then ends by itself, which the
14
+ * server's own limit stops with an error; required when a session reports a `serverTimeout`
15
+ * (decision 129). Not `sleep`, which MySQL answers with 1 instead of an error when the limit
16
+ * cuts it.
17
+ */
18
+ readonly slowRead?: (seconds: number) => string;
19
+ /**
20
+ * Sets the session's own statement limit to `ms`, as a DBA's per-user default would; required
21
+ * with `slowRead`.
22
+ */
23
+ readonly sessionTimeout?: (ms: number) => string;
12
24
  /**
13
25
  * A write that succeeds on a writable session and leaves the fixture unchanged, such as an
14
26
  * update setting a column to itself; the read-only case is skipped without one.
@@ -87,6 +99,16 @@ export interface ContractFixture {
87
99
  readonly config: unknown;
88
100
  readonly note: RegExp;
89
101
  };
102
+ /**
103
+ * False where the dialect cannot interrupt another session's statement and keep it connected,
104
+ * as SQL Server's `KILL` cannot: every row's `cancel` is null and `cancelSession` rejects.
105
+ */
106
+ readonly cancels?: boolean;
107
+ /**
108
+ * A session id no session has, for servers that hand a closed session's id to the next login
109
+ * (SQL Server): without it the test closes a session of its own and waits for its id to go.
110
+ */
111
+ readonly missingId?: string;
90
112
  };
91
113
  /** Required when the plugin declares `routineCall`, which also needs `catalog`. */
92
114
  readonly routines?: {
@@ -108,6 +130,8 @@ export interface ContractFixture {
108
130
  * default. `LINE_ITEMS_TABLE` is quoted in every dialect and never folded.
109
131
  */
110
132
  readonly foldName?: (name: string) => string;
133
+ /** The database stores an empty string as NULL, as Oracle does, so an empty text reads back null. */
134
+ readonly emptyTextIsNull?: boolean;
111
135
  }
112
136
  /** Dialect-specific text the suite runs; every member is optional (decision 100). */
113
137
  export interface ContractSql {
@@ -128,6 +152,10 @@ export interface ContractSql {
128
152
  };
129
153
  /** Opens a transaction explicitly in auto-commit mode. */
130
154
  readonly begin?: string;
155
+ /** A name only quoting makes legal, which the dialect accepts; Oracle refuses a `"` inside one. */
156
+ readonly awkwardIdentifier?: string;
157
+ /** What `ExplainedPlan.statement` starts with; `EXPLAIN` by default, `SET SHOWPLAN_XML ON` on SQL Server. */
158
+ readonly explainStatement?: RegExp;
131
159
  }
132
160
  /** A command and the result tab it opens: column names and rows as wire values. */
133
161
  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;
@@ -176,6 +179,16 @@ const SLEEP_SECONDS = 30;
176
179
  const CANCEL_DELAY_MS = 500;
177
180
  /** Well under `SLEEP_SECONDS`, so a cancel that did nothing fails the test instead of passing late. */
178
181
  const CANCEL_DEADLINE_MS = 10_000;
182
+ /** The limit the `serverTimeout` cases ask for; servers check theirs about every 100 ms. */
183
+ const SERVER_LIMIT_MS = 1500;
184
+ /** Far past `SERVER_LIMIT_MS`, so a limit that did nothing fails the test instead of passing late. */
185
+ const SLOW_READ_SECONDS = 8;
186
+ /** Long enough to show a read ran past `SERVER_LIMIT_MS` with no limit, short enough to wait for. */
187
+ const SHORT_READ_SECONDS = 3;
188
+ /** A limit the slow read never reaches, for the cases where something else must stop it first. */
189
+ const UNREACHED_LIMIT_MS = 2 * SLOW_READ_SECONDS * 1000;
190
+ /** When a stopped statement must have rejected: the limit plus the server's slack. */
191
+ const SERVER_LIMIT_DEADLINE_MS = 5_000;
179
192
  const KEEPALIVE_DELAY_MS = 1000;
180
193
  /** How long a listing may take to show what another connection just did. */
181
194
  const LISTING_DEADLINE_MS = 10_000;
@@ -357,6 +370,15 @@ export function runPluginContract(plugin, fixture) {
357
370
  return Promise.resolve();
358
371
  });
359
372
  });
373
+ const brands = fixture.manifest.brands;
374
+ it.runIf(brands !== undefined)("givenBrands_whenSessionOpens_thenServerBrandIsDeclared", async () => {
375
+ await withSession(plugin, fixture, (session) => {
376
+ if (session.serverBrand !== undefined) {
377
+ expect(brands?.map((brand) => brand.id)).toContain(session.serverBrand);
378
+ }
379
+ return Promise.resolve();
380
+ });
381
+ });
360
382
  it("givenSession_whenConnected_thenTlsIsNullOrDescribed", async () => {
361
383
  await withSession(plugin, fixture, (session) => {
362
384
  if (session.tls !== undefined && session.tls !== null) {
@@ -678,9 +700,9 @@ export function runPluginContract(plugin, fixture) {
678
700
  });
679
701
  it("givenAwkwardIdentifier_whenQuotedAndSelected_thenNameReadsBack", async () => {
680
702
  await withSession(plugin, fixture, async (session) => {
681
- const result = await session.execute(dialectSql.select(`1 AS ${language().quoteIdentifier(AWKWARD_IDENTIFIER)}`));
703
+ const result = await session.execute(dialectSql.select(`1 AS ${language().quoteIdentifier(dialectSql.awkwardIdentifier)}`));
682
704
  await collect(result.rows);
683
- expect(result.columns.map((column) => column.name)).toEqual([AWKWARD_IDENTIFIER]);
705
+ expect(result.columns.map((column) => column.name)).toEqual([dialectSql.awkwardIdentifier]);
684
706
  });
685
707
  });
686
708
  it("givenTypeProbeRow_whenSqlInsertPerDialect_thenRoundTripsEqual", async () => {
@@ -861,7 +883,7 @@ export function runPluginContract(plugin, fixture) {
861
883
  const texts = AWKWARD_TEXTS;
862
884
  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
885
  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]));
886
+ 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
887
  });
866
888
  });
867
889
  it("givenCreateTable_whenEveryImportType_thenTableTakesSampleText", async () => {
@@ -904,7 +926,7 @@ export function runPluginContract(plugin, fixture) {
904
926
  return session.introspector;
905
927
  }
906
928
  async function relationOf(session, name) {
907
- return (await introspectorOf(session).describe(path())).relations.find((relation) => relation.name === name);
929
+ return (await introspectorOf(session).describe(path())).relations.find((relation) => relation.name === fold(name));
908
930
  }
909
931
  /** Runs a script as the engine does: prepare, the statements in one transaction where it says so, verify, finish. */
910
932
  async function apply(session, script) {
@@ -948,14 +970,14 @@ export function runPluginContract(plugin, fixture) {
948
970
  return editorOf().script(change, context);
949
971
  }
950
972
  function column(name, type, nullable = true) {
951
- return { name, type, nullable, default: null, autoIncrement: false, comment: null, original: null };
973
+ return { name: fold(name), type, nullable, default: null, autoIncrement: false, comment: null, original: null };
952
974
  }
953
975
  function parentDesign() {
954
976
  return {
955
- name: OBJECT_PARENT_TABLE,
977
+ name: fold(OBJECT_PARENT_TABLE),
956
978
  comment: null,
957
979
  columns: [column("id", OBJECT_KEY_TYPE, false)],
958
- primaryKey: ["id"],
980
+ primaryKey: [fold("id")],
959
981
  primaryKeyName: null,
960
982
  indexes: [],
961
983
  foreignKeys: [],
@@ -964,7 +986,7 @@ export function runPluginContract(plugin, fixture) {
964
986
  }
965
987
  function childDesign() {
966
988
  return {
967
- name: OBJECT_CHILD_TABLE,
989
+ name: fold(OBJECT_CHILD_TABLE),
968
990
  comment: null,
969
991
  columns: [
970
992
  column("id", OBJECT_KEY_TYPE, false),
@@ -972,14 +994,14 @@ export function runPluginContract(plugin, fixture) {
972
994
  column("label", OBJECT_NARROW_TYPE),
973
995
  column("obsolete", OBJECT_NARROW_TYPE),
974
996
  ],
975
- primaryKey: ["id"],
997
+ primaryKey: [fold("id")],
976
998
  primaryKeyName: null,
977
- indexes: [{ name: OBJECT_INDEX, columns: ["label"], unique: false, original: null }],
999
+ indexes: [{ name: fold(OBJECT_INDEX), columns: [fold("label")], unique: false, original: null }],
978
1000
  foreignKeys: [
979
1001
  {
980
- name: OBJECT_FOREIGN_KEY,
981
- columns: ["parent_id"],
982
- target: { path: path(), relation: OBJECT_PARENT_TABLE, columns: ["id"] },
1002
+ name: fold(OBJECT_FOREIGN_KEY),
1003
+ columns: [fold("parent_id")],
1004
+ target: { path: path(), relation: fold(OBJECT_PARENT_TABLE), columns: [fold("id")] },
983
1005
  onDelete: "CASCADE",
984
1006
  onUpdate: null,
985
1007
  original: null,
@@ -1020,8 +1042,8 @@ export function runPluginContract(plugin, fixture) {
1020
1042
  ...described,
1021
1043
  columns: [
1022
1044
  ...described.columns
1023
- .filter((entry) => entry.name !== "obsolete")
1024
- .map((entry) => (entry.name === "label" ? { ...entry, type: OBJECT_WIDE_TYPE } : entry)),
1045
+ .filter((entry) => entry.name !== fold("obsolete"))
1046
+ .map((entry) => (entry.name === fold("label") ? { ...entry, type: OBJECT_WIDE_TYPE } : entry)),
1025
1047
  column("note", OBJECT_NARROW_TYPE),
1026
1048
  ],
1027
1049
  ...extra,
@@ -1030,11 +1052,11 @@ export function runPluginContract(plugin, fixture) {
1030
1052
  it("givenObjectsDeclared_whenCreateTableWithKeyIndexAndForeignKey_thenDescribedAsDesigned", async () => {
1031
1053
  await withTables(async (session) => {
1032
1054
  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);
1055
+ expect(child?.columns.map((entry) => entry.name)).toEqual(["id", "parent_id", "label", "obsolete"].map(fold));
1056
+ expect(child?.primaryKey?.columns).toEqual([fold("id")]);
1057
+ expect(child?.indexes.map((index) => index.name)).toContain(fold(OBJECT_INDEX));
1036
1058
  expect(child?.foreignKeys.map((key) => [key.target.relation, key.target.columns, key.onDelete])).toEqual([
1037
- [OBJECT_PARENT_TABLE, ["id"], "CASCADE"],
1059
+ [fold(OBJECT_PARENT_TABLE), [fold("id")], "CASCADE"],
1038
1060
  ]);
1039
1061
  });
1040
1062
  });
@@ -1046,7 +1068,7 @@ export function runPluginContract(plugin, fixture) {
1046
1068
  const script = await scriptOf(session, {
1047
1069
  kind: "alterTable",
1048
1070
  path: path(),
1049
- relation: OBJECT_CHILD_TABLE,
1071
+ relation: fold(OBJECT_CHILD_TABLE),
1050
1072
  design: editorOf().design(relation),
1051
1073
  });
1052
1074
  expect(script.statements).toEqual([]);
@@ -1055,37 +1077,38 @@ export function runPluginContract(plugin, fixture) {
1055
1077
  it("givenTable_whenColumnAddedTypeChangedAndDropped_thenDescribedAfterAndRowsKept", async () => {
1056
1078
  await withTables(async (session) => {
1057
1079
  const design = await widenedChild(session);
1058
- await apply(session, await scriptOf(session, { kind: "alterTable", path: path(), relation: OBJECT_CHILD_TABLE, design }));
1080
+ await apply(session, await scriptOf(session, { kind: "alterTable", path: path(), relation: fold(OBJECT_CHILD_TABLE), design }));
1059
1081
  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);
1082
+ expect(child?.columns.map((entry) => entry.name)).toEqual(["id", "parent_id", "label", "note"].map(fold));
1083
+ expect(child?.columns.find((entry) => entry.name === fold("label"))?.type).toMatch(OBJECT_WIDE_LENGTH);
1062
1084
  expect(child?.foreignKeys).toHaveLength(1);
1063
- expect(child?.indexes.map((index) => index.name)).toContain(OBJECT_INDEX);
1085
+ expect(child?.indexes.map((index) => index.name)).toContain(fold(OBJECT_INDEX));
1064
1086
  expect(await run(session, `SELECT label FROM ${OBJECT_CHILD_TABLE}`)).toEqual([["kept"]]);
1065
1087
  });
1066
1088
  });
1067
1089
  it.runIf(plugin.capabilities.includes("transactions"))("givenTransactionalScript_whenStatementFails_thenNothingApplied", async () => {
1068
1090
  await withTables(async (session) => {
1069
- // An index named like a table clashes in every dialect whose index names share the table namespace.
1091
+ // A second index under the table's own index name clashes in every dialect; one named like a
1092
+ // table does not where index names are per table, as on SQL Server.
1070
1093
  const design = await widenedChild(session, {});
1071
1094
  const clashing = {
1072
1095
  ...design,
1073
1096
  indexes: [
1074
1097
  ...design.indexes,
1075
- { name: OBJECT_PARENT_TABLE, columns: ["note"], unique: false, original: null },
1098
+ { name: fold(OBJECT_INDEX), columns: [fold("note")], unique: false, original: null },
1076
1099
  ],
1077
1100
  };
1078
1101
  const script = await scriptOf(session, {
1079
1102
  kind: "alterTable",
1080
1103
  path: path(),
1081
- relation: OBJECT_CHILD_TABLE,
1104
+ relation: fold(OBJECT_CHILD_TABLE),
1082
1105
  design: clashing,
1083
1106
  });
1084
1107
  if (!script.transactional)
1085
1108
  return;
1086
1109
  await expect(apply(session, script)).rejects.toThrow();
1087
1110
  const child = await relationOf(session, OBJECT_CHILD_TABLE);
1088
- expect(child?.columns.map((entry) => entry.name)).toEqual(["id", "parent_id", "label", "obsolete"]);
1111
+ expect(child?.columns.map((entry) => entry.name)).toEqual(["id", "parent_id", "label", "obsolete"].map(fold));
1089
1112
  expect(await run(session, `SELECT label FROM ${OBJECT_CHILD_TABLE}`)).toEqual([["kept"]]);
1090
1113
  });
1091
1114
  });
@@ -1094,8 +1117,8 @@ export function runPluginContract(plugin, fixture) {
1094
1117
  await apply(session, await scriptOf(session, {
1095
1118
  kind: "rename",
1096
1119
  path: path(),
1097
- object: { kind: "table", name: OBJECT_CHILD_TABLE },
1098
- name: OBJECT_RENAMED_TABLE,
1120
+ object: { kind: "table", name: fold(OBJECT_CHILD_TABLE) },
1121
+ name: fold(OBJECT_RENAMED_TABLE),
1099
1122
  }));
1100
1123
  expect(await relationOf(session, OBJECT_CHILD_TABLE)).toBeUndefined();
1101
1124
  expect((await relationOf(session, OBJECT_RENAMED_TABLE))?.columns).toHaveLength(4);
@@ -1103,7 +1126,12 @@ export function runPluginContract(plugin, fixture) {
1103
1126
  });
1104
1127
  it("givenTableWithRows_whenTruncated_thenEmptyAndStillDescribed", async () => {
1105
1128
  await withTables(async (session) => {
1106
- await apply(session, await scriptOf(session, { kind: "truncate", path: path(), relation: OBJECT_CHILD_TABLE, cascade: false }));
1129
+ await apply(session, await scriptOf(session, {
1130
+ kind: "truncate",
1131
+ path: path(),
1132
+ relation: fold(OBJECT_CHILD_TABLE),
1133
+ cascade: false,
1134
+ }));
1107
1135
  expect(await run(session, `SELECT id FROM ${OBJECT_CHILD_TABLE}`)).toEqual([]);
1108
1136
  expect(await relationOf(session, OBJECT_CHILD_TABLE)).toBeDefined();
1109
1137
  });
@@ -1113,7 +1141,7 @@ export function runPluginContract(plugin, fixture) {
1113
1141
  await apply(session, await scriptOf(session, {
1114
1142
  kind: "drop",
1115
1143
  path: path(),
1116
- object: { kind: "table", name: OBJECT_CHILD_TABLE },
1144
+ object: { kind: "table", name: fold(OBJECT_CHILD_TABLE) },
1117
1145
  cascade: false,
1118
1146
  }));
1119
1147
  expect(await relationOf(session, OBJECT_CHILD_TABLE)).toBeUndefined();
@@ -1405,7 +1433,10 @@ export function runPluginContract(plugin, fixture) {
1405
1433
  await withCompletion(async (language, catalog) => {
1406
1434
  const result = await language.complete(at("SELECT * FROM orders o JOIN |", "|"), catalog);
1407
1435
  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" });
1436
+ expect(first).toMatchObject({
1437
+ kind: "join",
1438
+ apply: `${fold("customers")} c ON c.${fold("id")} = o.${fold("customer_id")}`,
1439
+ });
1409
1440
  });
1410
1441
  });
1411
1442
  it("givenNameNeedingQuotes_whenCompleting_thenApplyIsQuotedByDialect", async () => {
@@ -1498,7 +1529,7 @@ export function runPluginContract(plugin, fixture) {
1498
1529
  const read = session.introspector?.definition?.bind(session.introspector);
1499
1530
  if (read === undefined)
1500
1531
  throw new Error("A ddl session needs introspector.definition.");
1501
- definition = await read(path, name);
1532
+ definition = await read(path, fold(name));
1502
1533
  });
1503
1534
  return definition;
1504
1535
  }
@@ -1634,7 +1665,7 @@ export function runPluginContract(plugin, fixture) {
1634
1665
  await withSession(plugin, fixture, async (session) => {
1635
1666
  const plan = await explained(session, dialectSql.explainJoin, false);
1636
1667
  expect(plan.analyzed).toBe(false);
1637
- expect(plan.statement).toMatch(/^EXPLAIN\b/i);
1668
+ expect(plan.statement).toMatch(dialectSql.explainStatement);
1638
1669
  expect(plan.statement).toContain(dialectSql.explainJoin);
1639
1670
  expect(plan.raw).toMatch(/\S/);
1640
1671
  const nodes = planNodes(plan.roots);
@@ -1787,10 +1818,14 @@ export function runPluginContract(plugin, fixture) {
1787
1818
  const { path } = namespaceSwitch();
1788
1819
  const written = switcher();
1789
1820
  await withSession(plugin, fixture, async (session) => {
1821
+ // A database switch reaches [database, default schema] on SQL Server, so the target may be a prefix.
1790
1822
  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);
1823
+ const target = listed
1824
+ .filter((namespace) => namespace.path.every((part, index) => part === path[index]))
1825
+ .filter((namespace) => switchableTo(written.targets, namespace))
1826
+ .sort((a, b) => b.path.length - a.path.length)[0];
1827
+ expect(target).toBeDefined();
1828
+ const sql = written.statement(target?.path ?? path);
1794
1829
  expect(plugin.language?.classify(sql).setsNamespace).toBe(true);
1795
1830
  await collect((await session.execute(sql)).rows);
1796
1831
  expect(await current(session)).toEqual(path);
@@ -2127,6 +2162,76 @@ export function runPluginContract(plugin, fixture) {
2127
2162
  });
2128
2163
  });
2129
2164
  });
2165
+ describe.runIf(plugin.capabilities.includes("serverTimeout"))("serverTimeout", () => {
2166
+ function slowRead(seconds) {
2167
+ const read = fixture.statements?.slowRead;
2168
+ if (read === undefined)
2169
+ throw new Error("A server with a serverTimeout needs fixture.statements.slowRead.");
2170
+ return read(seconds);
2171
+ }
2172
+ /** Runs `sql` to its last row, so a limit that strikes while rows stream counts too. */
2173
+ async function runToEnd(session, sql, serverTimeoutMs) {
2174
+ const options = serverTimeoutMs === undefined ? undefined : { serverTimeoutMs };
2175
+ await collect((await session.execute(sql, undefined, options)).rows);
2176
+ }
2177
+ /** Runs `use` on a session whose server enforces a limit; a server without one has nothing to check. */
2178
+ async function withLimitedSession(use) {
2179
+ await withSession(plugin, fixture, async (session) => {
2180
+ if (session.serverTimeout === null || session.serverTimeout === undefined)
2181
+ return;
2182
+ await use(session);
2183
+ });
2184
+ }
2185
+ it("givenSession_whenInspected_thenReportsServerTimeout", async () => {
2186
+ await withSession(plugin, fixture, (session) => {
2187
+ expect(session.serverTimeout).not.toBeUndefined();
2188
+ if (session.serverTimeout !== null && session.serverTimeout !== undefined) {
2189
+ expect(session.serverTimeout.setting).toMatch(/\S/);
2190
+ }
2191
+ return Promise.resolve();
2192
+ });
2193
+ });
2194
+ it("givenServerTimeout_whenSlowReadPastLimit_thenRejectsTimedOut", async () => {
2195
+ await withLimitedSession(async (session) => {
2196
+ const started = Date.now();
2197
+ const code = await rejectionCode(runToEnd(session, slowRead(SLOW_READ_SECONDS), SERVER_LIMIT_MS));
2198
+ expect(code).toBe("timedOut");
2199
+ expect(Date.now() - started).toBeLessThan(SERVER_LIMIT_DEADLINE_MS);
2200
+ await expectAlive(session);
2201
+ });
2202
+ });
2203
+ it("givenServerTimeoutThenNone_whenSlowRead_thenRunsPastIt", async () => {
2204
+ await withLimitedSession(async (session) => {
2205
+ await runToEnd(session, selectOne, SERVER_LIMIT_MS);
2206
+ const started = Date.now();
2207
+ await runToEnd(session, slowRead(SHORT_READ_SECONDS));
2208
+ expect(Date.now() - started).toBeGreaterThan(SERVER_LIMIT_MS);
2209
+ });
2210
+ });
2211
+ it("givenOwnLowerLimit_whenHigherAsked_thenOwnStops", async () => {
2212
+ await withLimitedSession(async (session) => {
2213
+ const own = fixture.statements?.sessionTimeout;
2214
+ if (own === undefined)
2215
+ throw new Error("A server with a serverTimeout needs fixture.statements.sessionTimeout.");
2216
+ await runToEnd(session, own(SERVER_LIMIT_MS));
2217
+ const started = Date.now();
2218
+ const code = await rejectionCode(runToEnd(session, slowRead(SLOW_READ_SECONDS), UNREACHED_LIMIT_MS));
2219
+ expect(code).toBe("timedOut");
2220
+ expect(Date.now() - started).toBeLessThan(SERVER_LIMIT_DEADLINE_MS);
2221
+ });
2222
+ });
2223
+ it("givenServerTimeout_whenCancelled_thenCancelledNotTimedOut", async () => {
2224
+ await withLimitedSession(async (session) => {
2225
+ const started = Date.now();
2226
+ const running = rejectionCode(runToEnd(session, slowRead(SLOW_READ_SECONDS), UNREACHED_LIMIT_MS));
2227
+ await delay(CANCEL_DELAY_MS);
2228
+ await session.cancel?.();
2229
+ expect(await running).toBe("cancelled");
2230
+ expect(Date.now() - started).toBeLessThan(CANCEL_DEADLINE_MS);
2231
+ await expectAlive(session);
2232
+ });
2233
+ });
2234
+ });
2130
2235
  describe.runIf(plugin.capabilities.includes("keepAlive"))("keepAlive", () => {
2131
2236
  it("givenKeepAlive_whenConnectedWithDelay_thenPingResolvesAndSessionWorks", async () => {
2132
2237
  await withSession(plugin, fixture, async (session) => {
@@ -2240,7 +2345,16 @@ export function runPluginContract(plugin, fixture) {
2240
2345
  });
2241
2346
  });
2242
2347
  });
2243
- it("givenActiveQuery_whenCancel_thenQueryCancelled", async () => {
2348
+ it.runIf(fixture.sessions?.cancels === false)("givenNoCancel_whenCancelSession_thenRejects", async () => {
2349
+ await withMonitor(async (monitor) => {
2350
+ await withSleeper(async (sleeper) => {
2351
+ const row = await present(monitor, idOf(sleeper), (listed) => listed.state === "active");
2352
+ expect(row.cancel).toBeNull();
2353
+ expect(await rejectionCode(monitor.cancelSession({ id: row.id, instance: row.instance }))).toBe("statementFailed");
2354
+ });
2355
+ });
2356
+ });
2357
+ it.runIf(fixture.sessions?.cancels !== false)("givenActiveQuery_whenCancel_thenQueryCancelled", async () => {
2244
2358
  await withMonitor(async (monitor) => {
2245
2359
  await withSleeper(async (sleeper, running) => {
2246
2360
  const row = await present(monitor, idOf(sleeper), (listed) => listed.state === "active");
@@ -2268,6 +2382,15 @@ export function runPluginContract(plugin, fixture) {
2268
2382
  }
2269
2383
  });
2270
2384
  it("givenMissingSession_whenTerminate_thenRejects", async () => {
2385
+ const missingId = sessionsFixture().missingId;
2386
+ if (missingId !== undefined) {
2387
+ await withMonitor(async (monitor) => {
2388
+ const listed = await monitor.list();
2389
+ expect(listed.sessions.map((session) => session.id)).not.toContain(missingId);
2390
+ expect(await rejectionCode(monitor.terminateSession({ id: missingId, instance: null }))).toBe("statementFailed");
2391
+ });
2392
+ return;
2393
+ }
2271
2394
  const victim = await connect(plugin, fixture.config);
2272
2395
  const id = idOf(victim);
2273
2396
  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.6.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.6.0",
4
4
  "description": "The datasource plugin contract for TableCraft",
5
5
  "license": "MIT",
6
6
  "type": "module",