@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 +1 -1
- package/dist/errors.js +5 -0
- package/dist/explain.d.ts +2 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/manifest.d.ts +15 -0
- package/dist/manifest.js +22 -0
- package/dist/plugin.d.ts +36 -2
- package/dist/plugin.js +1 -0
- package/dist/sql/complete.js +11 -4
- package/dist/sql/inspectResolve.js +4 -0
- package/dist/sql/namespace.d.ts +2 -1
- package/dist/sql/namespace.js +33 -3
- package/dist/sql/tokens.d.ts +5 -2
- package/dist/sql/tokens.js +6 -2
- package/dist/testing/index.d.ts +28 -0
- package/dist/testing/index.js +162 -39
- package/dist/url.d.ts +1 -0
- package/dist/url.js +88 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/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";
|
package/dist/manifest.d.ts
CHANGED
|
@@ -74,6 +74,7 @@ export declare const connectionUrlSchema: z.ZodObject<{
|
|
|
74
74
|
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
75
75
|
}, z.core.$strict>>>;
|
|
76
76
|
sets: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>>;
|
|
77
|
+
semicolonParams: z.ZodOptional<z.ZodBoolean>;
|
|
77
78
|
}, z.core.$strict>;
|
|
78
79
|
export type ConnectionUrl = z.infer<typeof connectionUrlSchema>;
|
|
79
80
|
/**
|
|
@@ -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;
|
|
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"];
|
package/dist/sql/complete.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
:
|
|
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 ?
|
|
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 :
|
|
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) {
|
package/dist/sql/namespace.d.ts
CHANGED
|
@@ -23,7 +23,8 @@ export interface NamespaceSlot {
|
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
25
|
* Where the caret at `offset` names a namespace a session-namespace statement of the dialect moves
|
|
26
|
-
* to: `USE |` or the elements of `SET search_path TO a, |`
|
|
26
|
+
* to: `USE |`, `ALTER SESSION SET CURRENT_SCHEMA = |` or the elements of `SET search_path TO a, |`
|
|
27
|
+
* and `SET SCHEMA |`. Null anywhere else,
|
|
27
28
|
* and after a name whose closing quote is typed.
|
|
28
29
|
*/
|
|
29
30
|
export declare function namespaceSlotAt(text: string, offset: number, dialect: SqlDialectOptions): NamespaceSlot | null;
|
package/dist/sql/namespace.js
CHANGED
|
@@ -82,6 +82,17 @@ function searchPathTarget(text, tokens) {
|
|
|
82
82
|
return null;
|
|
83
83
|
return { kind: "schema", candidates: names };
|
|
84
84
|
}
|
|
85
|
+
const ALTER_SESSION_PREFIX = ["ALTER", "SESSION", "SET", "CURRENT_SCHEMA", "="];
|
|
86
|
+
function alterSessionPrefix(text, tokens) {
|
|
87
|
+
return ALTER_SESSION_PREFIX.every((word, at) => wordAt(text, tokens, at) === word);
|
|
88
|
+
}
|
|
89
|
+
function alterSessionTarget(text, tokens) {
|
|
90
|
+
if (tokens.length !== ALTER_SESSION_PREFIX.length + 1 || !alterSessionPrefix(text, tokens))
|
|
91
|
+
return null;
|
|
92
|
+
const token = tokens[ALTER_SESSION_PREFIX.length];
|
|
93
|
+
const name = identifier(text, token);
|
|
94
|
+
return name === null || token?.kind === "string" ? null : { kind: "schema", candidates: [name] };
|
|
95
|
+
}
|
|
85
96
|
const NAME_TOKENS = new Set(["word", "quoted", "string"]);
|
|
86
97
|
/** The statement's tokens around `offset`, comments dropped; null when the caret is in a comment. */
|
|
87
98
|
function tokensAround(text, offset, dialect) {
|
|
@@ -136,7 +147,8 @@ function searchPathListStart(text, tokens) {
|
|
|
136
147
|
}
|
|
137
148
|
/**
|
|
138
149
|
* Where the caret at `offset` names a namespace a session-namespace statement of the dialect moves
|
|
139
|
-
* to: `USE |` or the elements of `SET search_path TO a, |`
|
|
150
|
+
* to: `USE |`, `ALTER SESSION SET CURRENT_SCHEMA = |` or the elements of `SET search_path TO a, |`
|
|
151
|
+
* and `SET SCHEMA |`. Null anywhere else,
|
|
140
152
|
* and after a name whose closing quote is typed.
|
|
141
153
|
*/
|
|
142
154
|
export function namespaceSlotAt(text, offset, dialect) {
|
|
@@ -168,6 +180,13 @@ export function namespaceSlotAt(text, offset, dialect) {
|
|
|
168
180
|
continue;
|
|
169
181
|
return { kind: "database", from, to: offset, quote, listed: [] };
|
|
170
182
|
}
|
|
183
|
+
if (form === "alterSessionSchema") {
|
|
184
|
+
if (prefix?.kind === "string" || before.length !== ALTER_SESSION_PREFIX.length)
|
|
185
|
+
continue;
|
|
186
|
+
if (!alterSessionPrefix(text, before))
|
|
187
|
+
continue;
|
|
188
|
+
return { kind: "schema", from, to: offset, quote, listed: [] };
|
|
189
|
+
}
|
|
171
190
|
const start = searchPathListStart(text, before);
|
|
172
191
|
if (start === null)
|
|
173
192
|
continue;
|
|
@@ -190,15 +209,26 @@ export function namespaceTarget(sql, dialect) {
|
|
|
190
209
|
return null;
|
|
191
210
|
const tokens = significantTokens(sql, dialect).filter((token) => token.kind !== "delimiter");
|
|
192
211
|
for (const form of forms) {
|
|
193
|
-
const target = form
|
|
212
|
+
const target = TARGET_READERS[form](sql, tokens);
|
|
194
213
|
if (target !== null)
|
|
195
214
|
return target;
|
|
196
215
|
}
|
|
197
216
|
return null;
|
|
198
217
|
}
|
|
218
|
+
const TARGET_READERS = {
|
|
219
|
+
use: useTarget,
|
|
220
|
+
searchPath: searchPathTarget,
|
|
221
|
+
alterSessionSchema: alterSessionTarget,
|
|
222
|
+
};
|
|
199
223
|
const SWITCH_TARGETS = {
|
|
200
224
|
use: "topLevel",
|
|
201
225
|
searchPath: "schema",
|
|
226
|
+
alterSessionSchema: "schema",
|
|
227
|
+
};
|
|
228
|
+
const SWITCH_STATEMENTS = {
|
|
229
|
+
use: (quoted) => `USE ${quoted}`,
|
|
230
|
+
searchPath: (quoted) => `SET search_path TO ${quoted}`,
|
|
231
|
+
alterSessionSchema: (quoted) => `ALTER SESSION SET CURRENT_SCHEMA = ${quoted}`,
|
|
202
232
|
};
|
|
203
233
|
/**
|
|
204
234
|
* The console's namespace switch for a dialect moving its namespace by `form` (decision 060):
|
|
@@ -214,7 +244,7 @@ export function namespaceSwitcher(form, quoteIdentifier) {
|
|
|
214
244
|
if (name === undefined) {
|
|
215
245
|
throw new PluginError("statementFailed", "A namespace switch names no namespace; pick one from the list.");
|
|
216
246
|
}
|
|
217
|
-
return form
|
|
247
|
+
return SWITCH_STATEMENTS[form](quoteIdentifier(name));
|
|
218
248
|
},
|
|
219
249
|
};
|
|
220
250
|
}
|
package/dist/sql/tokens.d.ts
CHANGED
|
@@ -26,13 +26,16 @@ export interface SqlDialectOptions {
|
|
|
26
26
|
readonly qQuotes?: boolean;
|
|
27
27
|
/** `N'…'` national character strings. */
|
|
28
28
|
readonly nationalStrings?: boolean;
|
|
29
|
+
/** SQL Server `@local`, `@@global` and `#temp` names: `@` and `#` are part of a word. */
|
|
30
|
+
readonly variables?: boolean;
|
|
29
31
|
}
|
|
30
32
|
export type BatchSeparator = "go" | "slash";
|
|
31
33
|
/**
|
|
32
34
|
* `use` is MySQL's `USE db`; `searchPath` is PostgreSQL's `SET [SESSION | LOCAL] search_path`,
|
|
33
|
-
* `SET SCHEMA` and `RESET search_path` / `RESET ALL
|
|
35
|
+
* `SET SCHEMA` and `RESET search_path` / `RESET ALL`; `alterSessionSchema` is Oracle's
|
|
36
|
+
* `ALTER SESSION SET CURRENT_SCHEMA = name`.
|
|
34
37
|
*/
|
|
35
|
-
export type NamespaceStatementForm = "use" | "searchPath";
|
|
38
|
+
export type NamespaceStatementForm = "use" | "searchPath" | "alterSessionSchema";
|
|
36
39
|
export type TokenKind = "word" | "quoted" | "string" | "comment" | "symbol" | "delimiter";
|
|
37
40
|
export interface Token {
|
|
38
41
|
readonly kind: TokenKind;
|
package/dist/sql/tokens.js
CHANGED
|
@@ -16,6 +16,7 @@ const Q_QUOTE_PAIRS = new Map([
|
|
|
16
16
|
const STRING_QUOTE = "'";
|
|
17
17
|
const BRACKET_OPEN = "[";
|
|
18
18
|
const BRACKET_CLOSE = "]";
|
|
19
|
+
const VARIABLE_CHARS = new Set(["@", "#"]);
|
|
19
20
|
function isSpace(char) {
|
|
20
21
|
return char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f" || char === "\v";
|
|
21
22
|
}
|
|
@@ -70,13 +71,16 @@ export class Lexer {
|
|
|
70
71
|
else if (char === "$" && this.options.dollarQuotes === true && this.#dollarTag(at) !== null) {
|
|
71
72
|
token = this.#dollarQuoted(at, this.#dollarTag(at) ?? "");
|
|
72
73
|
}
|
|
73
|
-
else if (isWordChar(char))
|
|
74
|
+
else if (this.#isWordChar(char))
|
|
74
75
|
token = this.#word(at, delimiter);
|
|
75
76
|
else
|
|
76
77
|
token = { kind: "symbol", from: at, to: at + 1 };
|
|
77
78
|
this.position = token.to;
|
|
78
79
|
return token;
|
|
79
80
|
}
|
|
81
|
+
#isWordChar(char) {
|
|
82
|
+
return isWordChar(char) || (this.options.variables === true && VARIABLE_CHARS.has(char));
|
|
83
|
+
}
|
|
80
84
|
#lineComment(from) {
|
|
81
85
|
const end = this.text.indexOf(NEWLINE, from);
|
|
82
86
|
return { kind: "comment", from, to: end < 0 ? this.text.length : end };
|
|
@@ -160,7 +164,7 @@ export class Lexer {
|
|
|
160
164
|
const text = this.text;
|
|
161
165
|
const customDelimiter = delimiter !== DEFAULT_DELIMITER;
|
|
162
166
|
let at = from;
|
|
163
|
-
while (at < text.length && isWordChar(text.charAt(at))) {
|
|
167
|
+
while (at < text.length && this.#isWordChar(text.charAt(at))) {
|
|
164
168
|
if (customDelimiter && at > from && text.startsWith(delimiter, at))
|
|
165
169
|
break;
|
|
166
170
|
at += 1;
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -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 {
|
package/dist/testing/index.js
CHANGED
|
@@ -117,8 +117,11 @@ export function contractSql(fixture) {
|
|
|
117
117
|
temporaryTable: fixture.sql?.temporaryTable ??
|
|
118
118
|
((name, columns) => ({ create: `CREATE TEMPORARY TABLE ${name} (${columns})`, name })),
|
|
119
119
|
begin: fixture.sql?.begin ?? "BEGIN",
|
|
120
|
+
awkwardIdentifier: fixture.sql?.awkwardIdentifier ?? AWKWARD_IDENTIFIER,
|
|
121
|
+
explainStatement: fixture.sql?.explainStatement ?? DEFAULT_EXPLAIN_STATEMENT,
|
|
120
122
|
};
|
|
121
123
|
}
|
|
124
|
+
const DEFAULT_EXPLAIN_STATEMENT = /^EXPLAIN\b/i;
|
|
122
125
|
const ROWS_READ_BEFORE_RETURN = 3;
|
|
123
126
|
/** Longer than `BYTES_PREVIEW_LENGTH` and within every fixture's `payload` column (MySQL's is `VARBINARY(64)`). */
|
|
124
127
|
const LONG_BYTES_LENGTH = 48;
|
|
@@ -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(
|
|
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([
|
|
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
|
-
//
|
|
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:
|
|
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, {
|
|
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({
|
|
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(
|
|
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
|
|
1792
|
-
|
|
1793
|
-
|
|
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("
|
|
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.
|
|
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) {
|