@tablecraft-app/plugin-api 0.0.0-stage → 1.2.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/LICENSE +21 -0
- package/README.md +46 -2
- package/dist/catalog.d.ts +112 -0
- package/dist/catalog.js +5 -0
- package/dist/completion.d.ts +109 -0
- package/dist/completion.js +12 -0
- package/dist/errors.d.ts +67 -0
- package/dist/errors.js +124 -0
- package/dist/explain.d.ts +58 -0
- package/dist/explain.js +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/inspection.d.ts +38 -0
- package/dist/inspection.js +21 -0
- package/dist/keyspace.d.ts +180 -0
- package/dist/keyspace.js +2 -0
- package/dist/manifest.d.ts +200 -0
- package/dist/manifest.js +238 -0
- package/dist/objects.d.ts +140 -0
- package/dist/objects.js +2 -0
- package/dist/plugin.d.ts +565 -0
- package/dist/plugin.js +58 -0
- package/dist/sessions.d.ts +59 -0
- package/dist/sessions.js +2 -0
- package/dist/sql/classify.d.ts +19 -0
- package/dist/sql/classify.js +95 -0
- package/dist/sql/complete.d.ts +23 -0
- package/dist/sql/complete.js +480 -0
- package/dist/sql/explain.d.ts +42 -0
- package/dist/sql/explain.js +108 -0
- package/dist/sql/format.d.ts +26 -0
- package/dist/sql/format.js +117 -0
- package/dist/sql/functions.d.ts +47 -0
- package/dist/sql/functions.js +159 -0
- package/dist/sql/generate.d.ts +22 -0
- package/dist/sql/generate.js +64 -0
- package/dist/sql/index.d.ts +16 -0
- package/dist/sql/index.js +16 -0
- package/dist/sql/inspect.d.ts +10 -0
- package/dist/sql/inspect.js +134 -0
- package/dist/sql/inspectContext.d.ts +78 -0
- package/dist/sql/inspectContext.js +251 -0
- package/dist/sql/inspectResolve.d.ts +6 -0
- package/dist/sql/inspectResolve.js +391 -0
- package/dist/sql/inspectStyle.d.ts +3 -0
- package/dist/sql/inspectStyle.js +384 -0
- package/dist/sql/inspectSyntax.d.ts +10 -0
- package/dist/sql/inspectSyntax.js +179 -0
- package/dist/sql/layout.d.ts +8 -0
- package/dist/sql/layout.js +114 -0
- package/dist/sql/layoutNodes.d.ts +46 -0
- package/dist/sql/layoutNodes.js +317 -0
- package/dist/sql/layoutPrint.d.ts +60 -0
- package/dist/sql/layoutPrint.js +806 -0
- package/dist/sql/layoutWriter.d.ts +21 -0
- package/dist/sql/layoutWriter.js +77 -0
- package/dist/sql/literals.d.ts +28 -0
- package/dist/sql/literals.js +78 -0
- package/dist/sql/namespace.d.ts +41 -0
- package/dist/sql/namespace.js +220 -0
- package/dist/sql/objects.d.ts +42 -0
- package/dist/sql/objects.js +222 -0
- package/dist/sql/parameters.d.ts +20 -0
- package/dist/sql/parameters.js +88 -0
- package/dist/sql/resolution.d.ts +73 -0
- package/dist/sql/resolution.js +192 -0
- package/dist/sql/routines.d.ts +27 -0
- package/dist/sql/routines.js +42 -0
- package/dist/sql/scope.d.ts +117 -0
- package/dist/sql/scope.js +540 -0
- package/dist/sql/select.d.ts +41 -0
- package/dist/sql/select.js +287 -0
- package/dist/sql/signature.d.ts +18 -0
- package/dist/sql/signature.js +137 -0
- package/dist/sql/splitter.d.ts +15 -0
- package/dist/sql/splitter.js +217 -0
- package/dist/sql/suggest.d.ts +6 -0
- package/dist/sql/suggest.js +36 -0
- package/dist/sql/tokens.d.ts +62 -0
- package/dist/sql/tokens.js +227 -0
- package/dist/sql/words.d.ts +23 -0
- package/dist/sql/words.js +72 -0
- package/dist/testing/index.d.ts +274 -0
- package/dist/testing/index.js +2398 -0
- package/dist/tls.d.ts +33 -0
- package/dist/tls.js +118 -0
- package/dist/url.d.ts +54 -0
- package/dist/url.js +228 -0
- package/dist/values.d.ts +44 -0
- package/dist/values.js +44 -0
- package/dist/version.d.ts +22 -0
- package/dist/version.js +29 -0
- package/package.json +42 -4
|
@@ -0,0 +1,2398 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { BYTES_PREVIEW_LENGTH, CAPABILITIES, FORMAT_STYLES, IMPORT_KINDS, DefinitionNotFoundError, errorPositionOf, isPluginError, offsetOfPosition, switchableTo, z, } from "@tablecraft-app/plugin-api";
|
|
3
|
+
/** What every SQL dialect splits the same way (decision 023). */
|
|
4
|
+
const SHARED_SCRIPTS = [
|
|
5
|
+
{ text: "SELECT 'a;b' AS s; SELECT 2 AS n", statements: ["SELECT 'a;b' AS s", "SELECT 2 AS n"] },
|
|
6
|
+
{ text: "SELECT 'it''s; here' AS s;\nSELECT 2 AS n;", statements: ["SELECT 'it''s; here' AS s", "SELECT 2 AS n"] },
|
|
7
|
+
{ text: 'SELECT 1 AS "a;b"; SELECT 2 AS n', statements: ['SELECT 1 AS "a;b"', "SELECT 2 AS n"] },
|
|
8
|
+
{
|
|
9
|
+
text: "SELECT 1 AS n -- not; here\n;\n/* nor; here */ SELECT 2 AS n /* inside; */ ;",
|
|
10
|
+
statements: ["SELECT 1 AS n", "SELECT 2 AS n"],
|
|
11
|
+
},
|
|
12
|
+
{ text: ";; ; -- only comments;\n/* ; */ ;", statements: [] },
|
|
13
|
+
];
|
|
14
|
+
/** Every SQL dialect calls these destructive, or not (decision 011). */
|
|
15
|
+
const DESTRUCTIVE_CASES = [
|
|
16
|
+
["DELETE FROM orders", true],
|
|
17
|
+
["DELETE FROM orders WHERE id = 1", false],
|
|
18
|
+
["UPDATE orders SET total = 0", true],
|
|
19
|
+
["UPDATE orders SET total = (SELECT max(total) FROM orders o WHERE o.id = 1)", true],
|
|
20
|
+
["UPDATE orders SET total = total WHERE id = 1", false],
|
|
21
|
+
["DROP TABLE orders", true],
|
|
22
|
+
["TRUNCATE TABLE orders", true],
|
|
23
|
+
["SELECT * FROM orders", false],
|
|
24
|
+
// Plain EXPLAIN only plans; ANALYZE runs the statement, so it is as destructive as it (decision 031).
|
|
25
|
+
["EXPLAIN DELETE FROM orders", false],
|
|
26
|
+
["EXPLAIN ANALYZE DELETE FROM orders", true],
|
|
27
|
+
["EXPLAIN ANALYZE DELETE FROM orders WHERE id = 1", false],
|
|
28
|
+
];
|
|
29
|
+
/** A row the transaction cases add to `orders` and always remove, so the fixture's counts hold. */
|
|
30
|
+
const TRANSACTION_ROW_ID = 9001;
|
|
31
|
+
const INSERT_TRANSACTION_ROW = `INSERT INTO orders (id, customer_id, total, placed_at) VALUES (${TRANSACTION_ROW_ID}, 1, 1.00, '2024-01-01 00:00:00')`;
|
|
32
|
+
const SELECT_TRANSACTION_ROW = `SELECT id FROM orders WHERE id = ${TRANSACTION_ROW_ID}`;
|
|
33
|
+
const DELETE_TRANSACTION_ROW = `DELETE FROM orders WHERE id = ${TRANSACTION_ROW_ID}`;
|
|
34
|
+
/** The table every fixture database carries, one column per value rule the contract pins. */
|
|
35
|
+
export const TYPE_PROBE_TABLE = "type_probe";
|
|
36
|
+
/** Where the literal round trip inserts `type_probe`'s rows; created and dropped by the suite. */
|
|
37
|
+
export const TYPE_PROBE_COPY_TABLE = "type_probe_copy";
|
|
38
|
+
const AWKWARD_IDENTIFIER = 'we"ird`name';
|
|
39
|
+
export const TYPE_PROBE_COLUMNS = [
|
|
40
|
+
"id",
|
|
41
|
+
"small_int",
|
|
42
|
+
"big_int",
|
|
43
|
+
"exact_decimal",
|
|
44
|
+
"unicode_text",
|
|
45
|
+
"flag",
|
|
46
|
+
"doc",
|
|
47
|
+
"payload",
|
|
48
|
+
"day",
|
|
49
|
+
"moment",
|
|
50
|
+
];
|
|
51
|
+
/** A fixture table whose name needs quoting in every dialect, for completion's quoting. */
|
|
52
|
+
export const LINE_ITEMS_TABLE = "Line Items";
|
|
53
|
+
/** SQL no inspector can read, which must still resolve with fixes whose edits stay inside the text. */
|
|
54
|
+
const INSPECTION_GARBAGE = [
|
|
55
|
+
")))(((",
|
|
56
|
+
"SELECT FROM WHERE ((( ' \" /* ",
|
|
57
|
+
"WITH WITH AS AS ( SELECT",
|
|
58
|
+
"UPDATE SET WHERE",
|
|
59
|
+
". , ;",
|
|
60
|
+
];
|
|
61
|
+
/** The relations every fixture database carries, sorted by name as `describe` returns them. */
|
|
62
|
+
export const FIXTURE_RELATIONS = [
|
|
63
|
+
{ name: "customer_totals", kind: "view" },
|
|
64
|
+
{ name: "customers", kind: "table" },
|
|
65
|
+
{ name: "orders", kind: "table" },
|
|
66
|
+
{ name: "edit_probe", kind: "table" },
|
|
67
|
+
{ name: "no_key_probe", kind: "table" },
|
|
68
|
+
{ name: TYPE_PROBE_TABLE, kind: "table" },
|
|
69
|
+
{ name: LINE_ITEMS_TABLE, kind: "table" },
|
|
70
|
+
];
|
|
71
|
+
/** The data editor's table (decision 026): a serial key and a default for every other column. */
|
|
72
|
+
export const EDIT_PROBE_TABLE = "edit_probe";
|
|
73
|
+
const EDIT_PROBE_SELECT = `SELECT id, label, qty, note FROM ${EDIT_PROBE_TABLE} ORDER BY id`;
|
|
74
|
+
const EDIT_PROBE_KEY_TYPE = "integer";
|
|
75
|
+
const EDIT_PROBE_TEXT_TYPE = "text";
|
|
76
|
+
const EDIT_PROBE_DEFAULT_QTY = 1;
|
|
77
|
+
const EDIT_PROBE_DEFAULT_LABEL = "new";
|
|
78
|
+
const MISSING_ROW_ID = -1;
|
|
79
|
+
const MISSING_NAMESPACE = "table_craft_no_such_namespace";
|
|
80
|
+
const MISSING_RELATION = "table_craft_no_such_relation";
|
|
81
|
+
const ORDERS_INDEX = "orders_placed_at_idx";
|
|
82
|
+
/** Created and dropped by the generated-DROP case. */
|
|
83
|
+
const DROP_PROBE_TABLE = "table_craft_drop_probe";
|
|
84
|
+
/** A join every fixture database plans with more than one step. */
|
|
85
|
+
const EXPLAIN_JOIN = "SELECT o.id, c.name FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.total > 10";
|
|
86
|
+
const EXPLAIN_DELETE_ALL = "DELETE FROM orders";
|
|
87
|
+
const EXPLAIN_UPDATE = "UPDATE orders SET total = total WHERE id = 1";
|
|
88
|
+
/** The `orders` rows every fixture database is seeded with. */
|
|
89
|
+
const ORDERS_ROWS = 3;
|
|
90
|
+
const ANALYSED_ORDER_ID = 1;
|
|
91
|
+
const ANALYSED_UPDATE = `UPDATE orders SET total = 0 WHERE id = ${ANALYSED_ORDER_ID}`;
|
|
92
|
+
const ANALYSED_TOTAL = `SELECT total FROM orders WHERE id = ${ANALYSED_ORDER_ID}`;
|
|
93
|
+
function planNodes(roots) {
|
|
94
|
+
return roots.flatMap((node) => [node, ...planNodes(node.children)]);
|
|
95
|
+
}
|
|
96
|
+
const PROBE_TABLE = "table_craft_contract_probe";
|
|
97
|
+
const PROBE_ROWS = 2;
|
|
98
|
+
const PROBE_ROW_ID = 1;
|
|
99
|
+
/** Far from UTC either way, so a driver that parses timestamps as local time shifts them visibly. */
|
|
100
|
+
const DISTANT_TIME_ZONE = "Pacific/Kiritimati";
|
|
101
|
+
const DIGIT_COUNT = 10;
|
|
102
|
+
/** 100,000 rows, more than any driver buffers in one fetch. */
|
|
103
|
+
function largeResultSql(select) {
|
|
104
|
+
const digits = Array.from({ length: DIGIT_COUNT }, (_, digit) => select(`${digit} AS n`)).join(" UNION ALL ");
|
|
105
|
+
return (`SELECT a.n + 10 * b.n + 100 * c.n + 1000 * d.n + 10000 * e.n AS n FROM ` +
|
|
106
|
+
["a", "b", "c", "d", "e"].map((alias) => `(${digits}) ${alias}`).join(" CROSS JOIN "));
|
|
107
|
+
}
|
|
108
|
+
/** The statements the suite runs for `fixture`, each default filled in. */
|
|
109
|
+
export function contractSql(fixture) {
|
|
110
|
+
const select = fixture.sql?.select ?? ((expressions) => `SELECT ${expressions}`);
|
|
111
|
+
return {
|
|
112
|
+
select,
|
|
113
|
+
largeResult: fixture.sql?.largeResult ?? largeResultSql(select),
|
|
114
|
+
explainJoin: fixture.sql?.explainJoin ?? EXPLAIN_JOIN,
|
|
115
|
+
sharedScripts: fixture.sql?.sharedScripts ?? SHARED_SCRIPTS,
|
|
116
|
+
dropTableIfExists: fixture.sql?.dropTableIfExists ?? ((name) => `DROP TABLE IF EXISTS ${name}`),
|
|
117
|
+
temporaryTable: fixture.sql?.temporaryTable ??
|
|
118
|
+
((name, columns) => ({ create: `CREATE TEMPORARY TABLE ${name} (${columns})`, name })),
|
|
119
|
+
begin: fixture.sql?.begin ?? "BEGIN",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const ROWS_READ_BEFORE_RETURN = 3;
|
|
123
|
+
/** Longer than `BYTES_PREVIEW_LENGTH` and within every fixture's `payload` column (MySQL's is `VARBINARY(64)`). */
|
|
124
|
+
const LONG_BYTES_LENGTH = 48;
|
|
125
|
+
/** Stops a scan pass that never ends, so a broken cursor fails rather than hangs. */
|
|
126
|
+
const MAX_SCAN_BATCHES = 10_000;
|
|
127
|
+
const READ_WINDOW = 100;
|
|
128
|
+
const SLOW_LOG_LIMIT = 5;
|
|
129
|
+
const LONG_BYTES_HEX = "c3";
|
|
130
|
+
const HEX_DIGITS_PER_BYTE = 2;
|
|
131
|
+
/** Created and dropped by the import-type case. */
|
|
132
|
+
const IMPORT_PROBE_TABLE = "table_craft_import_probe";
|
|
133
|
+
/** The tables the objects block creates, alters, renames and drops (decision 052). */
|
|
134
|
+
const OBJECT_PARENT_TABLE = "table_craft_object_parent";
|
|
135
|
+
const OBJECT_CHILD_TABLE = "table_craft_object_child";
|
|
136
|
+
const OBJECT_RENAMED_TABLE = "table_craft_object_renamed";
|
|
137
|
+
const OBJECT_INDEX = "table_craft_object_label_idx";
|
|
138
|
+
const OBJECT_FOREIGN_KEY = "table_craft_object_parent_fk";
|
|
139
|
+
const OBJECT_KEY_TYPE = "integer";
|
|
140
|
+
const OBJECT_NARROW_TYPE = "varchar(40)";
|
|
141
|
+
const OBJECT_WIDE_TYPE = "varchar(80)";
|
|
142
|
+
const OBJECT_WIDE_LENGTH = /80/;
|
|
143
|
+
const OBJECT_ROW = "INSERT INTO table_craft_object_child (id, parent_id, label, obsolete) VALUES (1, 1, 'kept', 'gone')";
|
|
144
|
+
/** Text a file holds for each inferred kind; every proposed type must take it. */
|
|
145
|
+
const IMPORT_SAMPLES = {
|
|
146
|
+
boolean: "true",
|
|
147
|
+
integer: "-42",
|
|
148
|
+
bigint: "9007199254740993",
|
|
149
|
+
decimal: "1234567.891",
|
|
150
|
+
double: "1.5e3",
|
|
151
|
+
date: "2024-02-29",
|
|
152
|
+
timestamp: "2024-02-29 13:45:30",
|
|
153
|
+
text: "plain",
|
|
154
|
+
};
|
|
155
|
+
/** Characters a bulk path must carry through its own framing: COPY's tab, newline and backslash among them. */
|
|
156
|
+
const AWKWARD_TEXTS = ["tab\there", "line\nbreak\r\nand", "back\\slash \\N", "\\N", "quote \" ' ,", ""];
|
|
157
|
+
/** A cell as TableCraft's CSV export writes it (`cellText` in core), the text an import reads back. */
|
|
158
|
+
function exportedText(value) {
|
|
159
|
+
if (value === null)
|
|
160
|
+
return null;
|
|
161
|
+
if (typeof value !== "object")
|
|
162
|
+
return String(value);
|
|
163
|
+
switch (value.kind) {
|
|
164
|
+
case "bigint":
|
|
165
|
+
return value.value;
|
|
166
|
+
case "json":
|
|
167
|
+
return value.text;
|
|
168
|
+
case "date":
|
|
169
|
+
return value.iso;
|
|
170
|
+
case "bytes":
|
|
171
|
+
return `0x${value.preview}`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const SLEEP_SECONDS = 30;
|
|
175
|
+
/** Long enough for the statement to reach the database before it is cancelled. */
|
|
176
|
+
const CANCEL_DELAY_MS = 500;
|
|
177
|
+
/** Well under `SLEEP_SECONDS`, so a cancel that did nothing fails the test instead of passing late. */
|
|
178
|
+
const CANCEL_DEADLINE_MS = 10_000;
|
|
179
|
+
const KEEPALIVE_DELAY_MS = 1000;
|
|
180
|
+
/** How long a listing may take to show what another connection just did. */
|
|
181
|
+
const LISTING_DEADLINE_MS = 10_000;
|
|
182
|
+
const LISTING_POLL_MS = 200;
|
|
183
|
+
async function collect(rows) {
|
|
184
|
+
const collected = [];
|
|
185
|
+
for await (const row of rows)
|
|
186
|
+
collected.push(row);
|
|
187
|
+
return collected;
|
|
188
|
+
}
|
|
189
|
+
function typeProbeOf(fixture) {
|
|
190
|
+
if (fixture.typeProbe === undefined)
|
|
191
|
+
throw new Error("A plugin that declares sql needs fixture.typeProbe.");
|
|
192
|
+
return fixture.typeProbe;
|
|
193
|
+
}
|
|
194
|
+
function commandsOf(fixture) {
|
|
195
|
+
if (fixture.commands === undefined)
|
|
196
|
+
throw new Error("A plugin that declares commands needs fixture.commands.");
|
|
197
|
+
return fixture.commands;
|
|
198
|
+
}
|
|
199
|
+
function catalogPathOf(fixture) {
|
|
200
|
+
if (fixture.catalog === undefined)
|
|
201
|
+
throw new Error("A plugin that declares schemaTree needs fixture.catalog.");
|
|
202
|
+
return fixture.catalog.path;
|
|
203
|
+
}
|
|
204
|
+
const WRITABLE = { readOnly: false };
|
|
205
|
+
const READ_ONLY = { readOnly: true };
|
|
206
|
+
async function connect(plugin, config, options = WRITABLE) {
|
|
207
|
+
return plugin.connect(plugin.connectionSchema.parse(config), options);
|
|
208
|
+
}
|
|
209
|
+
/** A completion catalog straight over `introspector`, describing each namespace once. */
|
|
210
|
+
async function liveCompletionCatalog(introspector, serverVersion = null) {
|
|
211
|
+
const namespaces = await introspector.namespaces();
|
|
212
|
+
const described = new Map();
|
|
213
|
+
const describe = (path) => {
|
|
214
|
+
const key = JSON.stringify(path);
|
|
215
|
+
const known = described.get(key) ?? introspector.describe(path);
|
|
216
|
+
described.set(key, known);
|
|
217
|
+
return known;
|
|
218
|
+
};
|
|
219
|
+
return {
|
|
220
|
+
current: namespaces.find((namespace) => namespace.current)?.path ?? null,
|
|
221
|
+
server: { version: serverVersion },
|
|
222
|
+
namespaces: () => Promise.resolve(namespaces),
|
|
223
|
+
relations: async (path) => (await describe(path)).relations,
|
|
224
|
+
referencing: async (path, relation) => {
|
|
225
|
+
const catalogs = await Promise.all([...described.values()]);
|
|
226
|
+
return catalogs.flatMap((catalog) => catalog.relations.flatMap((source) => source.foreignKeys
|
|
227
|
+
.filter((key) => key.target.relation === relation && JSON.stringify(key.target.path) === JSON.stringify(path))
|
|
228
|
+
.map((foreignKey) => ({ path: catalog.path, relation: source.name, foreignKey }))));
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
async function withSession(plugin, fixture, use, options = WRITABLE) {
|
|
233
|
+
const session = await connect(plugin, fixture.config, options);
|
|
234
|
+
try {
|
|
235
|
+
await use(session);
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
await session.close();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async function rejectionCode(pending) {
|
|
242
|
+
try {
|
|
243
|
+
await pending;
|
|
244
|
+
return "resolved";
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
return isPluginError(error) ? error.code : "notAPluginError";
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function delay(ms) {
|
|
251
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Where a manifest's connection form and the plugin's `connectionSchema` disagree (decision 019),
|
|
255
|
+
* one sentence per problem; empty when they agree.
|
|
256
|
+
*/
|
|
257
|
+
export function connectionFormProblems(manifest, connectionSchema) {
|
|
258
|
+
const json = z.toJSONSchema(connectionSchema, { io: "input", unrepresentable: "any" });
|
|
259
|
+
const accepted = new Set(Object.keys(json.properties ?? {}));
|
|
260
|
+
const fields = manifest.connection.fields;
|
|
261
|
+
const keys = new Set(fields.map((field) => field.key));
|
|
262
|
+
const problems = [];
|
|
263
|
+
if (keys.size !== fields.length)
|
|
264
|
+
problems.push("Two connection fields share a key.");
|
|
265
|
+
for (const field of fields) {
|
|
266
|
+
if (!accepted.has(field.key))
|
|
267
|
+
problems.push(`Field "${field.key}" is not accepted by connectionSchema.`);
|
|
268
|
+
if (field.kind === "password" && field.secret !== true) {
|
|
269
|
+
problems.push(`Password field "${field.key}" must be marked secret.`);
|
|
270
|
+
}
|
|
271
|
+
if (field.kind === "select" && field.options === undefined) {
|
|
272
|
+
problems.push(`Select field "${field.key}" needs options.`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
for (const key of json.required ?? []) {
|
|
276
|
+
if (!keys.has(key))
|
|
277
|
+
problems.push(`connectionSchema requires "${key}", but no connection field fills it.`);
|
|
278
|
+
}
|
|
279
|
+
return [...problems, ...connectionUrlProblems(manifest)];
|
|
280
|
+
}
|
|
281
|
+
const SERVER_URL_PARTS = ["host", "port", "database", "user", "password"];
|
|
282
|
+
/** Where `connection.url` names fields the form lacks, or maps them inconsistently (decision 055). */
|
|
283
|
+
function connectionUrlProblems(manifest) {
|
|
284
|
+
const url = manifest.connection.url;
|
|
285
|
+
if (url === undefined)
|
|
286
|
+
return [];
|
|
287
|
+
const fields = new Map(manifest.connection.fields.map((field) => [field.key, field]));
|
|
288
|
+
const problems = [];
|
|
289
|
+
const allowed = url.form === "server" ? SERVER_URL_PARTS : ["path"];
|
|
290
|
+
for (const [part, key] of Object.entries(url.parts)) {
|
|
291
|
+
if (!allowed.includes(part))
|
|
292
|
+
problems.push(`A ${url.form} URL has no "${part}" part.`);
|
|
293
|
+
const field = fields.get(key);
|
|
294
|
+
if (field === undefined) {
|
|
295
|
+
problems.push(`URL part "${part}" fills "${key}", which no connection field declares.`);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if ((part === "password") !== (field.secret === true)) {
|
|
299
|
+
problems.push(`URL part "${part}" fills "${key}", but only the password part fills a secret field.`);
|
|
300
|
+
}
|
|
301
|
+
if (part === "port" && field.kind !== "number")
|
|
302
|
+
problems.push(`URL part "port" must fill a number field.`);
|
|
303
|
+
}
|
|
304
|
+
const required = url.form === "server" ? "host" : "path";
|
|
305
|
+
if (url.parts[required] === undefined)
|
|
306
|
+
problems.push(`A ${url.form} URL needs a "${required}" part.`);
|
|
307
|
+
for (const param of url.params ?? []) {
|
|
308
|
+
const field = fields.get(param.key);
|
|
309
|
+
if (field === undefined) {
|
|
310
|
+
problems.push(`URL parameter "${param.name}" fills "${param.key}", which no connection field declares.`);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (field.secret === true)
|
|
314
|
+
problems.push(`URL parameter "${param.name}" must not fill a secret field.`);
|
|
315
|
+
const options = new Set(field.options?.map((option) => option.value));
|
|
316
|
+
for (const value of Object.values(param.values ?? {})) {
|
|
317
|
+
if (field.kind === "select" && !options.has(value)) {
|
|
318
|
+
problems.push(`URL parameter "${param.name}" maps to "${value}", which "${param.key}" does not offer.`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return problems;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* The behaviour every plugin owes the host, gated on the capabilities it declares (decision 013).
|
|
326
|
+
* Call it from a Vitest file; it registers one `describe` block.
|
|
327
|
+
*/
|
|
328
|
+
export function runPluginContract(plugin, fixture) {
|
|
329
|
+
const dialectSql = contractSql(fixture);
|
|
330
|
+
const fold = (name) => (name === LINE_ITEMS_TABLE ? name : (fixture.foldName?.(name) ?? name));
|
|
331
|
+
const selectOne = dialectSql.select("1 AS one");
|
|
332
|
+
const dropIfExists = dialectSql.dropTableIfExists;
|
|
333
|
+
/** Shows `session` still answers: a one-row SELECT, or the fixture's scalar command. */
|
|
334
|
+
async function expectAlive(session) {
|
|
335
|
+
if (plugin.capabilities.includes("commands")) {
|
|
336
|
+
const { command, text } = commandsOf(fixture).scalar;
|
|
337
|
+
expect((await session.execute(command)).reply?.text).toBe(text);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
expect(await collect((await session.execute(selectOne)).rows)).toEqual([[1]]);
|
|
341
|
+
}
|
|
342
|
+
describe(`plugin contract: ${plugin.id}`, () => {
|
|
343
|
+
it("givenPlugin_whenInspected_thenDeclaresOnlyKnownCapabilities", () => {
|
|
344
|
+
for (const capability of plugin.capabilities)
|
|
345
|
+
expect(CAPABILITIES).toContain(capability);
|
|
346
|
+
});
|
|
347
|
+
it("givenFixtureConfig_whenParsedByConnectionSchema_thenSucceeds", () => {
|
|
348
|
+
expect(plugin.connectionSchema.safeParse(fixture.config).success).toBe(true);
|
|
349
|
+
});
|
|
350
|
+
it("givenManifestFields_whenComparedWithConnectionSchema_thenAgree", () => {
|
|
351
|
+
expect(fixture.manifest.id).toBe(plugin.id);
|
|
352
|
+
expect(connectionFormProblems(fixture.manifest, plugin.connectionSchema)).toEqual([]);
|
|
353
|
+
});
|
|
354
|
+
it("givenSession_whenConnected_thenServerVersionNonEmpty", async () => {
|
|
355
|
+
await withSession(plugin, fixture, (session) => {
|
|
356
|
+
expect(session.serverVersion).toMatch(/\S/);
|
|
357
|
+
return Promise.resolve();
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
it("givenSession_whenConnected_thenTlsIsNullOrDescribed", async () => {
|
|
361
|
+
await withSession(plugin, fixture, (session) => {
|
|
362
|
+
if (session.tls !== undefined && session.tls !== null) {
|
|
363
|
+
expect(session.tls.protocol).toMatch(/\S/);
|
|
364
|
+
expect(session.tls.cipher).toMatch(/\S/);
|
|
365
|
+
}
|
|
366
|
+
return Promise.resolve();
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
it("givenPlugin_whenInspected_thenHasLanguageExactlyWhenSqlDeclared", () => {
|
|
370
|
+
expect(plugin.language !== undefined).toBe(plugin.capabilities.includes("sql"));
|
|
371
|
+
});
|
|
372
|
+
it("givenPlugin_whenInspected_thenHasCommandLanguageExactlyWhenCommandsDeclared", () => {
|
|
373
|
+
const commands = plugin.capabilities.includes("commands");
|
|
374
|
+
if (commands)
|
|
375
|
+
expect(plugin.capabilities).not.toContain("sql");
|
|
376
|
+
expect(plugin.commandLanguage !== undefined).toBe(commands);
|
|
377
|
+
});
|
|
378
|
+
it("givenSession_whenInspected_thenHasKeyspaceExactlyWhenKeyValueDeclared", async () => {
|
|
379
|
+
const keyValue = plugin.capabilities.includes("keyValue");
|
|
380
|
+
if (keyValue)
|
|
381
|
+
expect(plugin.capabilities).toContain("commands");
|
|
382
|
+
await withSession(plugin, fixture, (session) => {
|
|
383
|
+
expect(session.keyspace !== undefined).toBe(keyValue);
|
|
384
|
+
return Promise.resolve();
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
it("givenSession_whenInspected_thenHasNodesAndServerInfoExactlyWhenDeclared", async () => {
|
|
388
|
+
await withSession(plugin, fixture, (session) => {
|
|
389
|
+
expect(session.nodes !== undefined).toBe(plugin.capabilities.includes("nodes"));
|
|
390
|
+
expect(session.serverInfo !== undefined).toBe(plugin.capabilities.includes("serverInfo"));
|
|
391
|
+
return Promise.resolve();
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
it("givenSession_whenInspected_thenHasTransactionsExactlyWhenDeclared", async () => {
|
|
395
|
+
await withSession(plugin, fixture, (session) => {
|
|
396
|
+
expect(session.transactions !== undefined).toBe(plugin.capabilities.includes("transactions"));
|
|
397
|
+
expect(session.transactions?.mode ?? "auto").toBe("auto");
|
|
398
|
+
expect(session.transactions?.open ?? false).toBe(false);
|
|
399
|
+
return Promise.resolve();
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
it("givenSession_whenInspected_thenHasIntrospectorExactlyWhenSchemaTreeDeclared", async () => {
|
|
403
|
+
await withSession(plugin, fixture, (session) => {
|
|
404
|
+
expect(session.introspector !== undefined).toBe(plugin.capabilities.includes("schemaTree"));
|
|
405
|
+
return Promise.resolve();
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
it("givenSession_whenInspected_thenHasDefinitionExactlyWhenDdlDeclared", async () => {
|
|
409
|
+
const ddl = plugin.capabilities.includes("ddl");
|
|
410
|
+
if (ddl)
|
|
411
|
+
expect(plugin.capabilities).toContain("schemaTree");
|
|
412
|
+
await withSession(plugin, fixture, (session) => {
|
|
413
|
+
expect(session.introspector?.definition !== undefined).toBe(ddl);
|
|
414
|
+
return Promise.resolve();
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
it("givenPlugin_whenInspected_thenHasExplainableAndExplainExactlyWhenExplainDeclared", async () => {
|
|
418
|
+
const explain = plugin.capabilities.includes("explain");
|
|
419
|
+
if (explain)
|
|
420
|
+
expect(plugin.capabilities).toContain("sql");
|
|
421
|
+
if (plugin.capabilities.includes("explainAnalyze"))
|
|
422
|
+
expect(plugin.capabilities).toContain("explain");
|
|
423
|
+
expect(plugin.language?.explainable !== undefined).toBe(explain);
|
|
424
|
+
await withSession(plugin, fixture, (session) => {
|
|
425
|
+
expect(session.explain !== undefined).toBe(explain);
|
|
426
|
+
return Promise.resolve();
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
it("givenSession_whenInspected_thenHasPingExactlyWhenKeepAliveDeclared", async () => {
|
|
430
|
+
await withSession(plugin, fixture, (session) => {
|
|
431
|
+
expect(session.ping !== undefined).toBe(plugin.capabilities.includes("keepAlive"));
|
|
432
|
+
return Promise.resolve();
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
it("givenSession_whenInspected_thenHasCurrentNamespaceExactlyWhenDeclared", async () => {
|
|
436
|
+
const declared = plugin.capabilities.includes("currentNamespace");
|
|
437
|
+
if (declared)
|
|
438
|
+
expect(plugin.capabilities).toContain("sql");
|
|
439
|
+
await withSession(plugin, fixture, (session) => {
|
|
440
|
+
expect(session.currentNamespace !== undefined).toBe(declared);
|
|
441
|
+
return Promise.resolve();
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
it("givenPlugin_whenInspected_thenHasNamespaceSwitchExactlyWhenCurrentNamespaceDeclared", () => {
|
|
445
|
+
expect(plugin.language?.namespaceSwitch !== undefined).toBe(plugin.capabilities.includes("currentNamespace"));
|
|
446
|
+
});
|
|
447
|
+
it("givenPlugin_whenInspected_thenHasInsertRowsAndImportLanguageExactlyWhenBulkInsertDeclared", async () => {
|
|
448
|
+
const bulk = plugin.capabilities.includes("bulkInsert");
|
|
449
|
+
if (bulk)
|
|
450
|
+
expect(plugin.capabilities).toContain("transactions");
|
|
451
|
+
expect(plugin.language?.importType !== undefined).toBe(bulk);
|
|
452
|
+
expect(plugin.language?.createTable !== undefined).toBe(bulk);
|
|
453
|
+
await withSession(plugin, fixture, (session) => {
|
|
454
|
+
expect(session.insertRows !== undefined).toBe(bulk);
|
|
455
|
+
return Promise.resolve();
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
it("givenSession_whenInspected_thenHasSessionMonitorExactlyWhenSessionsDeclared", async () => {
|
|
459
|
+
const declared = plugin.capabilities.includes("sessions");
|
|
460
|
+
if (declared) {
|
|
461
|
+
expect(plugin.capabilities.includes("sql") || plugin.capabilities.includes("commands")).toBe(true);
|
|
462
|
+
expect(plugin.capabilities.includes("schemaTree") || plugin.capabilities.includes("keyValue")).toBe(true);
|
|
463
|
+
}
|
|
464
|
+
await withSession(plugin, fixture, (session) => {
|
|
465
|
+
expect(session.sessions !== undefined).toBe(declared);
|
|
466
|
+
expect(typeof session.serverSessionId === "string").toBe(declared);
|
|
467
|
+
return Promise.resolve();
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
it("givenPlugin_whenInspected_thenHasObjectEditorExactlyWhenObjectsDeclared", () => {
|
|
471
|
+
const objects = plugin.capabilities.includes("objects");
|
|
472
|
+
if (objects)
|
|
473
|
+
expect(plugin.capabilities).toEqual(expect.arrayContaining(["sql", "schemaTree"]));
|
|
474
|
+
expect(plugin.language?.objects !== undefined).toBe(objects);
|
|
475
|
+
});
|
|
476
|
+
it("givenPlugin_whenInspected_thenHasRoutineCallExactlyWhenDeclared", () => {
|
|
477
|
+
const routineCall = plugin.capabilities.includes("routineCall");
|
|
478
|
+
if (routineCall)
|
|
479
|
+
expect(plugin.capabilities).toEqual(expect.arrayContaining(["sql", "schemaTree"]));
|
|
480
|
+
expect(plugin.language?.routineCall !== undefined).toBe(routineCall);
|
|
481
|
+
});
|
|
482
|
+
it.runIf(fixture.wrongPasswordConfig !== undefined)("givenWrongPassword_whenConnecting_thenRejectsWithConnectionFailed", async () => {
|
|
483
|
+
expect(await rejectionCode(connect(plugin, fixture.wrongPasswordConfig))).toBe("connectionFailed");
|
|
484
|
+
});
|
|
485
|
+
describe.runIf(plugin.capabilities.includes("sql"))("sql", () => {
|
|
486
|
+
it("givenSession_whenSelectLiterals_thenReturnsColumnsAndOneRow", async () => {
|
|
487
|
+
await withSession(plugin, fixture, async (session) => {
|
|
488
|
+
const result = await session.execute(dialectSql.select("1 AS one, 'two' AS two"));
|
|
489
|
+
expect(result.columns.map((column) => column.name)).toEqual(["one", "two"].map(fold));
|
|
490
|
+
expect(await collect(result.rows)).toEqual([[1, "two"]]);
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
it("givenStatementWithoutRows_whenExecuted_thenReturnsNoColumns", async () => {
|
|
494
|
+
await withSession(plugin, fixture, async (session) => {
|
|
495
|
+
const result = await session.execute(dialectSql.temporaryTable(PROBE_TABLE, "n INTEGER").create);
|
|
496
|
+
expect(result.columns).toEqual([]);
|
|
497
|
+
expect(await collect(result.rows)).toEqual([]);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
it("givenInsert_whenExecuted_thenReportsRowsAffected", async () => {
|
|
501
|
+
await withSession(plugin, fixture, async (session) => {
|
|
502
|
+
const probe = dialectSql.temporaryTable(PROBE_TABLE, "n INTEGER");
|
|
503
|
+
await session.execute(probe.create);
|
|
504
|
+
const result = await session.execute(`INSERT INTO ${probe.name} (n) VALUES (1), (2)`);
|
|
505
|
+
expect(result.rowsAffected).toBe(PROBE_ROWS);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
it("givenInvalidSql_whenExecuted_thenRejectsWithStatementFailed", async () => {
|
|
509
|
+
await withSession(plugin, fixture, async (session) => {
|
|
510
|
+
expect(await rejectionCode(session.execute("SELEC 1"))).toBe("statementFailed");
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
it.runIf(fixture.statements?.syntaxError !== undefined)("givenSyntaxError_whenExecuted_thenPositionPointsAtToken", async () => {
|
|
514
|
+
const { sql, offset } = fixture.statements?.syntaxError ?? { sql: "", offset: 0 };
|
|
515
|
+
await withSession(plugin, fixture, async (session) => {
|
|
516
|
+
const failure = await session.execute(sql).then(() => undefined, (error) => error);
|
|
517
|
+
const position = errorPositionOf(failure);
|
|
518
|
+
expect(position).toBeDefined();
|
|
519
|
+
expect(position === undefined ? null : offsetOfPosition(sql, position)).toBe(offset);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
it.runIf(fixture.statements?.write !== undefined)("givenReadOnlySession_whenInsert_thenStatementFailed", async () => {
|
|
523
|
+
const write = fixture.statements?.write ?? "";
|
|
524
|
+
await withSession(plugin, fixture, async (session) => {
|
|
525
|
+
await session.execute(write);
|
|
526
|
+
});
|
|
527
|
+
await withSession(plugin, fixture, async (session) => {
|
|
528
|
+
expect(await rejectionCode(session.execute(write))).toBe("statementFailed");
|
|
529
|
+
expect(await collect((await session.execute(selectOne)).rows)).toEqual([[1]]);
|
|
530
|
+
}, READ_ONLY);
|
|
531
|
+
});
|
|
532
|
+
it("givenFailedStatement_whenNextStatementRun_thenSessionStillWorks", async () => {
|
|
533
|
+
await withSession(plugin, fixture, async (session) => {
|
|
534
|
+
await rejectionCode(session.execute("SELEC 1"));
|
|
535
|
+
expect(await collect((await session.execute(selectOne)).rows)).toEqual([[1]]);
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
it("givenTypeProbe_whenSelected_thenCellsMatchExpectedWireValues", async () => {
|
|
539
|
+
await withSession(plugin, fixture, async (session) => {
|
|
540
|
+
const result = await session.execute(`SELECT * FROM ${TYPE_PROBE_TABLE} ORDER BY id`);
|
|
541
|
+
expect(result.columns.map((column) => column.name)).toEqual(TYPE_PROBE_COLUMNS.map(fold));
|
|
542
|
+
expect(await collect(result.rows)).toEqual(typeProbeOf(fixture).expected);
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
describe("bound parameters", () => {
|
|
546
|
+
function bound(sql, values) {
|
|
547
|
+
if (plugin.language === undefined)
|
|
548
|
+
throw new Error("A plugin that declares sql needs language.");
|
|
549
|
+
return plugin.language.bind(sql, values);
|
|
550
|
+
}
|
|
551
|
+
it("givenParams_whenExecute_thenBoundAsValues", async () => {
|
|
552
|
+
const statement = bound("SELECT name FROM customers WHERE id = :id AND name <> ':id'", { ":id": "2" });
|
|
553
|
+
await withSession(plugin, fixture, async (session) => {
|
|
554
|
+
expect(await collect((await session.execute(statement.sql, statement.params)).rows)).toEqual([
|
|
555
|
+
["Grace Hopper"],
|
|
556
|
+
]);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
it("givenNullParam_whenExecute_thenBindsNull", async () => {
|
|
560
|
+
const statement = bound("SELECT name FROM customers WHERE id = :id OR :id IS NULL ORDER BY id", {
|
|
561
|
+
":id": null,
|
|
562
|
+
});
|
|
563
|
+
await withSession(plugin, fixture, async (session) => {
|
|
564
|
+
expect(await collect((await session.execute(statement.sql, statement.params)).rows)).toEqual([
|
|
565
|
+
["Ada Lovelace"],
|
|
566
|
+
["Grace Hopper"],
|
|
567
|
+
]);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
it("givenQuoteInParam_whenExecute_thenLandsAsData", async () => {
|
|
571
|
+
const value = "'; DROP TABLE customers; --";
|
|
572
|
+
const statement = bound("SELECT name FROM customers WHERE name = :name OR id = :id", {
|
|
573
|
+
":name": value,
|
|
574
|
+
":id": "1",
|
|
575
|
+
});
|
|
576
|
+
await withSession(plugin, fixture, async (session) => {
|
|
577
|
+
expect(await collect((await session.execute(statement.sql, statement.params)).rows)).toEqual([
|
|
578
|
+
["Ada Lovelace"],
|
|
579
|
+
]);
|
|
580
|
+
const remaining = await session.execute("SELECT id FROM customers ORDER BY id");
|
|
581
|
+
expect(await collect(remaining.rows)).toEqual([[1], [2]]);
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
it("givenParamsThenPlainStatement_whenExecuted_thenBothRun", async () => {
|
|
585
|
+
const statement = bound("SELECT name FROM customers WHERE id = :id", { ":id": "1" });
|
|
586
|
+
await withSession(plugin, fixture, async (session) => {
|
|
587
|
+
for (let run = 0; run < 2; run += 1) {
|
|
588
|
+
expect(await collect((await session.execute(statement.sql, statement.params)).rows)).toEqual([
|
|
589
|
+
["Ada Lovelace"],
|
|
590
|
+
]);
|
|
591
|
+
}
|
|
592
|
+
expect(await collect((await session.execute(selectOne)).rows)).toEqual([[1]]);
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
it.each(FORMAT_STYLES)("givenLanguage_whenFormatAndBind_thenRoundTripsThroughExecute (%s)", async (style) => {
|
|
596
|
+
const language = plugin.language;
|
|
597
|
+
if (language === undefined)
|
|
598
|
+
throw new Error("A plugin that declares sql needs language.");
|
|
599
|
+
const formatted = language.format("select c.name as who, c.id from customers c where c.id = :id and (c.id > 0 or c.id < 0) -- the second", { style, keywordCase: "upper", indentWidth: 2, linesBetweenStatements: 1, alignAliases: true });
|
|
600
|
+
expect(formatted).toContain("-- the second");
|
|
601
|
+
expect(language.parameters(formatted).map((parameter) => parameter.name)).toEqual([":id"]);
|
|
602
|
+
const statement = language.bind(formatted, { ":id": "2" });
|
|
603
|
+
await withSession(plugin, fixture, async (session) => {
|
|
604
|
+
expect(await collect((await session.execute(statement.sql, statement.params)).rows)).toEqual([
|
|
605
|
+
["Grace Hopper", 2],
|
|
606
|
+
]);
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
describe("in a distant time zone", () => {
|
|
611
|
+
let original;
|
|
612
|
+
beforeEach(() => {
|
|
613
|
+
original = process.env.TZ;
|
|
614
|
+
process.env.TZ = DISTANT_TIME_ZONE;
|
|
615
|
+
});
|
|
616
|
+
afterEach(() => {
|
|
617
|
+
if (original === undefined)
|
|
618
|
+
delete process.env.TZ;
|
|
619
|
+
else
|
|
620
|
+
process.env.TZ = original;
|
|
621
|
+
});
|
|
622
|
+
it("givenTimestamp_whenSelected_thenTextIsUnshiftedByClientTimeZone", async () => {
|
|
623
|
+
const momentColumn = TYPE_PROBE_COLUMNS.indexOf("moment");
|
|
624
|
+
const dayColumn = TYPE_PROBE_COLUMNS.indexOf("day");
|
|
625
|
+
const expected = typeProbeOf(fixture).expected.find((row) => row[0] === PROBE_ROW_ID);
|
|
626
|
+
await withSession(plugin, fixture, async (session) => {
|
|
627
|
+
const result = await session.execute(`SELECT day, moment FROM ${TYPE_PROBE_TABLE} WHERE id = ${PROBE_ROW_ID}`);
|
|
628
|
+
expect(await collect(result.rows)).toEqual([[expected?.[dayColumn], expected?.[momentColumn]]]);
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
it("givenLargeResult_whenIteratorReturnedEarly_thenCursorClosedAndSessionUsable", async () => {
|
|
633
|
+
await withSession(plugin, fixture, async (session) => {
|
|
634
|
+
const result = await session.execute(dialectSql.largeResult);
|
|
635
|
+
const iterator = result.rows[Symbol.asyncIterator]();
|
|
636
|
+
for (let read = 0; read < ROWS_READ_BEFORE_RETURN; read += 1) {
|
|
637
|
+
expect((await iterator.next()).done).toBe(false);
|
|
638
|
+
}
|
|
639
|
+
await iterator.return?.();
|
|
640
|
+
expect(await collect((await session.execute(selectOne)).rows)).toEqual([[1]]);
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
describe.runIf(plugin.capabilities.includes("sql"))("language", () => {
|
|
645
|
+
function language() {
|
|
646
|
+
if (plugin.language === undefined)
|
|
647
|
+
throw new Error("A plugin that declares sql needs language.");
|
|
648
|
+
return plugin.language;
|
|
649
|
+
}
|
|
650
|
+
function statementsOf(text) {
|
|
651
|
+
const spans = language().split(text);
|
|
652
|
+
for (const span of spans)
|
|
653
|
+
expect(text.slice(span.from, span.to)).toBe(span.sql);
|
|
654
|
+
return spans.map((span) => span.sql);
|
|
655
|
+
}
|
|
656
|
+
it.each([...dialectSql.sharedScripts, ...(fixture.scripts ?? [])])("givenScript_whenSplit_thenStatementsMatch: $text", ({ text, statements }) => {
|
|
657
|
+
expect(statementsOf(text)).toEqual(statements);
|
|
658
|
+
});
|
|
659
|
+
it.each([...dialectSql.sharedScripts, ...(fixture.scripts ?? [])])("givenScriptInPieces_whenSplitStreamed_thenSameStatementsAsSplit: $text", ({ text }) => {
|
|
660
|
+
const stream = language().splitStream?.();
|
|
661
|
+
if (stream === undefined)
|
|
662
|
+
return;
|
|
663
|
+
const streamed = [...text].flatMap((character) => stream.push(character)).concat(stream.end());
|
|
664
|
+
expect(streamed.map(({ from, to, sql }) => ({ from, to, sql }))).toEqual(language()
|
|
665
|
+
.split(text)
|
|
666
|
+
.map(({ from, to, sql }) => ({ from, to, sql })));
|
|
667
|
+
});
|
|
668
|
+
it("givenSplitScript_whenEachStatementExecuted_thenEachSucceeds", async () => {
|
|
669
|
+
const statements = statementsOf(`${dialectSql.select("'a;b' AS s")};\n-- between\n${dialectSql.select("2 AS n")};`);
|
|
670
|
+
await withSession(plugin, fixture, async (session) => {
|
|
671
|
+
for (const sql of statements)
|
|
672
|
+
await collect((await session.execute(sql)).rows);
|
|
673
|
+
});
|
|
674
|
+
expect(statements).toHaveLength(2);
|
|
675
|
+
});
|
|
676
|
+
it.each(DESTRUCTIVE_CASES)("givenStatement_whenClassified_thenDestructiveMatches: %s → %s", (sql, destructive) => {
|
|
677
|
+
expect(language().classify(sql).destructive !== null).toBe(destructive);
|
|
678
|
+
});
|
|
679
|
+
it("givenAwkwardIdentifier_whenQuotedAndSelected_thenNameReadsBack", async () => {
|
|
680
|
+
await withSession(plugin, fixture, async (session) => {
|
|
681
|
+
const result = await session.execute(dialectSql.select(`1 AS ${language().quoteIdentifier(AWKWARD_IDENTIFIER)}`));
|
|
682
|
+
await collect(result.rows);
|
|
683
|
+
expect(result.columns.map((column) => column.name)).toEqual([AWKWARD_IDENTIFIER]);
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
it("givenTypeProbeRow_whenSqlInsertPerDialect_thenRoundTripsEqual", async () => {
|
|
687
|
+
const dialect = language();
|
|
688
|
+
const run = async (session, sql) => collect((await session.execute(sql)).rows);
|
|
689
|
+
await withSession(plugin, fixture, async (session) => {
|
|
690
|
+
const source = await session.execute(`SELECT * FROM ${TYPE_PROBE_TABLE} ORDER BY id`);
|
|
691
|
+
const rows = await collect(source.rows);
|
|
692
|
+
const names = source.columns.map((column) => dialect.quoteIdentifier(column.name)).join(", ");
|
|
693
|
+
await run(session, dropIfExists(TYPE_PROBE_COPY_TABLE));
|
|
694
|
+
await run(session, typeProbeOf(fixture).copyTable);
|
|
695
|
+
try {
|
|
696
|
+
for (const row of rows) {
|
|
697
|
+
const values = row.map((value, index) => dialect.literal(value, source.columns[index]?.type ?? null));
|
|
698
|
+
await run(session, `INSERT INTO ${dialect.quoteIdentifier(fold(TYPE_PROBE_COPY_TABLE))} (${names}) VALUES (${values.join(", ")})`);
|
|
699
|
+
}
|
|
700
|
+
expect(await run(session, `SELECT * FROM ${TYPE_PROBE_COPY_TABLE} ORDER BY id`)).toEqual(typeProbeOf(fixture).expected);
|
|
701
|
+
}
|
|
702
|
+
finally {
|
|
703
|
+
await run(session, dropIfExists(TYPE_PROBE_COPY_TABLE));
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
it("givenFullBytes_whenExecuting_thenBytesPreviewIsComplete", async () => {
|
|
708
|
+
const dialect = language();
|
|
709
|
+
const run = async (session, sql, options) => collect((await session.execute(sql, [], options)).rows);
|
|
710
|
+
const whole = LONG_BYTES_HEX.repeat(LONG_BYTES_LENGTH);
|
|
711
|
+
await withSession(plugin, fixture, async (session) => {
|
|
712
|
+
const probe = await session.execute(`SELECT id, payload FROM ${TYPE_PROBE_TABLE} ORDER BY id`);
|
|
713
|
+
const payloadType = probe.columns[1]?.type ?? null;
|
|
714
|
+
await collect(probe.rows);
|
|
715
|
+
await run(session, dropIfExists(TYPE_PROBE_COPY_TABLE));
|
|
716
|
+
await run(session, typeProbeOf(fixture).copyTable);
|
|
717
|
+
try {
|
|
718
|
+
const payload = dialect.literal({ kind: "bytes", preview: whole, length: LONG_BYTES_LENGTH }, payloadType);
|
|
719
|
+
await run(session, `INSERT INTO ${dialect.quoteIdentifier(fold(TYPE_PROBE_COPY_TABLE))} (id, payload) VALUES (${PROBE_ROW_ID}, ${payload})`);
|
|
720
|
+
const select = `SELECT payload FROM ${TYPE_PROBE_COPY_TABLE}`;
|
|
721
|
+
expect(await run(session, select, { fullBytes: true })).toEqual([
|
|
722
|
+
[{ kind: "bytes", preview: whole, length: LONG_BYTES_LENGTH }],
|
|
723
|
+
]);
|
|
724
|
+
expect(await run(session, select)).toEqual([
|
|
725
|
+
[
|
|
726
|
+
{
|
|
727
|
+
kind: "bytes",
|
|
728
|
+
preview: whole.slice(0, BYTES_PREVIEW_LENGTH * HEX_DIGITS_PER_BYTE),
|
|
729
|
+
length: LONG_BYTES_LENGTH,
|
|
730
|
+
},
|
|
731
|
+
],
|
|
732
|
+
]);
|
|
733
|
+
}
|
|
734
|
+
finally {
|
|
735
|
+
await run(session, dropIfExists(TYPE_PROBE_COPY_TABLE));
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
it("givenDropTable_whenClassified_thenDdl", () => {
|
|
740
|
+
expect(language().classify("DROP TABLE orders").kind).toBe("ddl");
|
|
741
|
+
expect(language().classify("CREATE TABLE t (id INTEGER)").kind).toBe("ddl");
|
|
742
|
+
expect(language().classify("SELECT 1").kind).toBe("read");
|
|
743
|
+
expect(language().classify("INSERT INTO t VALUES (1)").kind).toBe("write");
|
|
744
|
+
expect(language().classify("COMMIT").kind).toBe("transaction");
|
|
745
|
+
});
|
|
746
|
+
it("givenColumnAndExpression_whenSelected_thenOriginsTellThemApart", async () => {
|
|
747
|
+
await withSession(plugin, fixture, async (session) => {
|
|
748
|
+
const result = await session.execute(`SELECT id, id + 1 AS next FROM ${TYPE_PROBE_TABLE}`);
|
|
749
|
+
await collect(result.rows);
|
|
750
|
+
const [column, expression] = result.columns;
|
|
751
|
+
if (column?.origin === undefined)
|
|
752
|
+
return;
|
|
753
|
+
expect(column.origin).not.toBeNull();
|
|
754
|
+
expect(expression?.origin).toBeNull();
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
it.runIf(plugin.capabilities.includes("schemaTree"))("givenSingleTableSelect_whenSourceRead_thenNamesTheTableAndItsColumns", async () => {
|
|
758
|
+
await withSession(plugin, fixture, async (session) => {
|
|
759
|
+
const introspector = session.introspector;
|
|
760
|
+
const lang = language();
|
|
761
|
+
const selectSource = lang.selectSource?.bind(lang);
|
|
762
|
+
if (introspector === undefined || selectSource === undefined)
|
|
763
|
+
return;
|
|
764
|
+
const catalog = await liveCompletionCatalog(introspector);
|
|
765
|
+
const source = await selectSource(`SELECT id, id + 1 AS next FROM ${TYPE_PROBE_TABLE}`, catalog);
|
|
766
|
+
expect(source).toEqual({
|
|
767
|
+
kind: "table",
|
|
768
|
+
path: catalogPathOf(fixture),
|
|
769
|
+
relation: fold(TYPE_PROBE_TABLE),
|
|
770
|
+
columns: [fold("id"), null],
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
it("givenSavepoint_whenRolledBackToAndReleased_thenTheTransactionStaysOpen", async () => {
|
|
775
|
+
const lang = language();
|
|
776
|
+
const savepoint = lang.savepoint?.bind(lang);
|
|
777
|
+
if (savepoint === undefined)
|
|
778
|
+
return;
|
|
779
|
+
await withSession(plugin, fixture, async (session) => {
|
|
780
|
+
const transactions = session.transactions;
|
|
781
|
+
if (transactions === undefined)
|
|
782
|
+
return;
|
|
783
|
+
const statements = savepoint("table_craft_contract");
|
|
784
|
+
await transactions.setMode("manual");
|
|
785
|
+
try {
|
|
786
|
+
await collect((await session.execute(statements.set)).rows);
|
|
787
|
+
await collect((await session.execute(statements.rollback)).rows);
|
|
788
|
+
await collect((await session.execute(statements.release)).rows);
|
|
789
|
+
expect(transactions.open).toBe(true);
|
|
790
|
+
}
|
|
791
|
+
finally {
|
|
792
|
+
await transactions.rollback();
|
|
793
|
+
await transactions.setMode("auto");
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
describe.runIf(plugin.capabilities.includes("bulkInsert"))("bulkInsert", () => {
|
|
799
|
+
const run = async (session, sql) => collect((await session.execute(sql)).rows);
|
|
800
|
+
function insertRowsOf(session) {
|
|
801
|
+
const insert = session.insertRows?.bind(session);
|
|
802
|
+
if (insert === undefined)
|
|
803
|
+
throw new Error("A bulkInsert session needs insertRows.");
|
|
804
|
+
return insert;
|
|
805
|
+
}
|
|
806
|
+
/** The copy table as the engine targets it: named and typed by the introspector. */
|
|
807
|
+
async function copyTarget(session) {
|
|
808
|
+
await run(session, dropIfExists(TYPE_PROBE_COPY_TABLE));
|
|
809
|
+
await run(session, typeProbeOf(fixture).copyTable);
|
|
810
|
+
const introspector = session.introspector;
|
|
811
|
+
if (introspector === undefined) {
|
|
812
|
+
const probe = await session.execute(`SELECT * FROM ${TYPE_PROBE_COPY_TABLE}`);
|
|
813
|
+
await collect(probe.rows);
|
|
814
|
+
return { table: TYPE_PROBE_COPY_TABLE, columns: probe.columns };
|
|
815
|
+
}
|
|
816
|
+
const path = catalogPathOf(fixture);
|
|
817
|
+
const relation = (await introspector.describe(path)).relations.find((candidate) => candidate.name === fold(TYPE_PROBE_COPY_TABLE));
|
|
818
|
+
if (relation === undefined)
|
|
819
|
+
throw new Error(`${TYPE_PROBE_COPY_TABLE} was not described.`);
|
|
820
|
+
return {
|
|
821
|
+
table: introspector.qualifiedName(path, fold(TYPE_PROBE_COPY_TABLE)),
|
|
822
|
+
columns: relation.columns.map((column) => ({ name: column.name, type: column.type })),
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
async function withCopy(use) {
|
|
826
|
+
await withSession(plugin, fixture, async (session) => {
|
|
827
|
+
try {
|
|
828
|
+
await use(session, await copyTarget(session));
|
|
829
|
+
}
|
|
830
|
+
finally {
|
|
831
|
+
await session.transactions?.rollback();
|
|
832
|
+
await run(session, dropIfExists(TYPE_PROBE_COPY_TABLE));
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
it("givenBulkInsert_whenTypeProbeTextInserted_thenReadBackEqual", async () => {
|
|
837
|
+
await withCopy(async (session, target) => {
|
|
838
|
+
const rows = typeProbeOf(fixture).expected.map((row) => row.map(exportedText));
|
|
839
|
+
await insertRowsOf(session)(target, rows);
|
|
840
|
+
expect(await run(session, `SELECT * FROM ${TYPE_PROBE_COPY_TABLE} ORDER BY id`)).toEqual(typeProbeOf(fixture).expected);
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
it("givenBulkInsert_whenBatchRefused_thenNothingOfItAndTransactionUsable", async () => {
|
|
844
|
+
await withCopy(async (session, target) => {
|
|
845
|
+
const insert = insertRowsOf(session);
|
|
846
|
+
const row = (id) => target.columns.map((column) => (column.name === fold("id") ? id : null));
|
|
847
|
+
await session.transactions?.setMode("manual");
|
|
848
|
+
await insert(target, [row("1")]);
|
|
849
|
+
const refused = await rejectionCode(insert(target, [row("2"), row("not a number")]));
|
|
850
|
+
const open = session.transactions?.open;
|
|
851
|
+
await insert(target, [row("3")]);
|
|
852
|
+
await session.transactions?.commit();
|
|
853
|
+
await session.transactions?.setMode("auto");
|
|
854
|
+
expect(refused).toBe("statementFailed");
|
|
855
|
+
expect(open).toBe(true);
|
|
856
|
+
expect(await run(session, `SELECT id FROM ${TYPE_PROBE_COPY_TABLE} ORDER BY id`)).toEqual([[1], [3]]);
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
it("givenBulkInsert_whenControlCharacters_thenRoundTrip", async () => {
|
|
860
|
+
await withCopy(async (session, target) => {
|
|
861
|
+
const texts = AWKWARD_TEXTS;
|
|
862
|
+
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
|
+
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]));
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
it("givenCreateTable_whenEveryImportType_thenTableTakesSampleText", async () => {
|
|
868
|
+
const dialect = plugin.language;
|
|
869
|
+
if (dialect === undefined)
|
|
870
|
+
throw new Error("A bulkInsert plugin needs language.");
|
|
871
|
+
await withSession(plugin, fixture, async (session) => {
|
|
872
|
+
const columns = IMPORT_KINDS.map((kind) => ({
|
|
873
|
+
name: `${kind}_value`,
|
|
874
|
+
type: dialect.importType?.({ kind, integerDigits: 7, scale: 3 }) ?? "",
|
|
875
|
+
}));
|
|
876
|
+
const create = dialect.createTable?.(IMPORT_PROBE_TABLE, columns) ?? "";
|
|
877
|
+
await run(session, dropIfExists(IMPORT_PROBE_TABLE));
|
|
878
|
+
try {
|
|
879
|
+
expect(dialect.classify(create).kind).toBe("ddl");
|
|
880
|
+
await run(session, create);
|
|
881
|
+
await insertRowsOf(session)({ table: IMPORT_PROBE_TABLE, columns }, [
|
|
882
|
+
IMPORT_KINDS.map((kind) => IMPORT_SAMPLES[kind]),
|
|
883
|
+
]);
|
|
884
|
+
expect(await run(session, `SELECT COUNT(*) AS n FROM ${IMPORT_PROBE_TABLE}`)).toHaveLength(1);
|
|
885
|
+
}
|
|
886
|
+
finally {
|
|
887
|
+
await run(session, dropIfExists(IMPORT_PROBE_TABLE));
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
describe.runIf(plugin.capabilities.includes("objects"))("objects", () => {
|
|
893
|
+
const run = async (session, sql) => collect((await session.execute(sql)).rows);
|
|
894
|
+
const path = () => catalogPathOf(fixture);
|
|
895
|
+
function editorOf() {
|
|
896
|
+
const editor = plugin.language?.objects;
|
|
897
|
+
if (editor === undefined)
|
|
898
|
+
throw new Error("An objects plugin needs language.objects.");
|
|
899
|
+
return editor;
|
|
900
|
+
}
|
|
901
|
+
function introspectorOf(session) {
|
|
902
|
+
if (session.introspector === undefined)
|
|
903
|
+
throw new Error("An objects plugin needs an introspector.");
|
|
904
|
+
return session.introspector;
|
|
905
|
+
}
|
|
906
|
+
async function relationOf(session, name) {
|
|
907
|
+
return (await introspectorOf(session).describe(path())).relations.find((relation) => relation.name === name);
|
|
908
|
+
}
|
|
909
|
+
/** Runs a script as the engine does: prepare, the statements in one transaction where it says so, verify, finish. */
|
|
910
|
+
async function apply(session, script) {
|
|
911
|
+
for (const sql of script.prepare)
|
|
912
|
+
await run(session, sql);
|
|
913
|
+
const transactions = script.transactional ? session.transactions : undefined;
|
|
914
|
+
try {
|
|
915
|
+
await transactions?.setMode("manual");
|
|
916
|
+
try {
|
|
917
|
+
for (const sql of script.statements)
|
|
918
|
+
await run(session, sql);
|
|
919
|
+
for (const sql of script.verify) {
|
|
920
|
+
if ((await run(session, sql)).length > 0)
|
|
921
|
+
throw new Error(`${sql} found rows.`);
|
|
922
|
+
}
|
|
923
|
+
await transactions?.commit();
|
|
924
|
+
}
|
|
925
|
+
catch (error) {
|
|
926
|
+
await transactions?.rollback();
|
|
927
|
+
throw error;
|
|
928
|
+
}
|
|
929
|
+
finally {
|
|
930
|
+
await transactions?.setMode("auto");
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
finally {
|
|
934
|
+
for (const sql of script.finish)
|
|
935
|
+
await run(session, sql);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
async function scriptOf(session, change) {
|
|
939
|
+
const introspector = introspectorOf(session);
|
|
940
|
+
let current = null;
|
|
941
|
+
if (change.kind === "alterTable") {
|
|
942
|
+
const relation = await relationOf(session, change.relation);
|
|
943
|
+
if (relation === undefined)
|
|
944
|
+
throw new Error(`${change.relation} was not described.`);
|
|
945
|
+
current = editorOf().design(relation);
|
|
946
|
+
}
|
|
947
|
+
const context = { qualifiedName: introspector.qualifiedName.bind(introspector), current };
|
|
948
|
+
return editorOf().script(change, context);
|
|
949
|
+
}
|
|
950
|
+
function column(name, type, nullable = true) {
|
|
951
|
+
return { name, type, nullable, default: null, autoIncrement: false, comment: null, original: null };
|
|
952
|
+
}
|
|
953
|
+
function parentDesign() {
|
|
954
|
+
return {
|
|
955
|
+
name: OBJECT_PARENT_TABLE,
|
|
956
|
+
comment: null,
|
|
957
|
+
columns: [column("id", OBJECT_KEY_TYPE, false)],
|
|
958
|
+
primaryKey: ["id"],
|
|
959
|
+
primaryKeyName: null,
|
|
960
|
+
indexes: [],
|
|
961
|
+
foreignKeys: [],
|
|
962
|
+
checks: [],
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
function childDesign() {
|
|
966
|
+
return {
|
|
967
|
+
name: OBJECT_CHILD_TABLE,
|
|
968
|
+
comment: null,
|
|
969
|
+
columns: [
|
|
970
|
+
column("id", OBJECT_KEY_TYPE, false),
|
|
971
|
+
column("parent_id", OBJECT_KEY_TYPE),
|
|
972
|
+
column("label", OBJECT_NARROW_TYPE),
|
|
973
|
+
column("obsolete", OBJECT_NARROW_TYPE),
|
|
974
|
+
],
|
|
975
|
+
primaryKey: ["id"],
|
|
976
|
+
primaryKeyName: null,
|
|
977
|
+
indexes: [{ name: OBJECT_INDEX, columns: ["label"], unique: false, original: null }],
|
|
978
|
+
foreignKeys: [
|
|
979
|
+
{
|
|
980
|
+
name: OBJECT_FOREIGN_KEY,
|
|
981
|
+
columns: ["parent_id"],
|
|
982
|
+
target: { path: path(), relation: OBJECT_PARENT_TABLE, columns: ["id"] },
|
|
983
|
+
onDelete: "CASCADE",
|
|
984
|
+
onUpdate: null,
|
|
985
|
+
original: null,
|
|
986
|
+
},
|
|
987
|
+
],
|
|
988
|
+
checks: [],
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
async function dropAll(session) {
|
|
992
|
+
const introspector = introspectorOf(session);
|
|
993
|
+
for (const table of [OBJECT_CHILD_TABLE, OBJECT_RENAMED_TABLE, OBJECT_PARENT_TABLE]) {
|
|
994
|
+
await run(session, dropIfExists(introspector.qualifiedName(path(), fold(table))));
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
/** A parent and a child with one row each, created through the editor. */
|
|
998
|
+
async function withTables(use) {
|
|
999
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1000
|
+
await dropAll(session);
|
|
1001
|
+
try {
|
|
1002
|
+
await apply(session, await scriptOf(session, { kind: "createTable", path: path(), design: parentDesign() }));
|
|
1003
|
+
await apply(session, await scriptOf(session, { kind: "createTable", path: path(), design: childDesign() }));
|
|
1004
|
+
await run(session, `INSERT INTO ${OBJECT_PARENT_TABLE} (id) VALUES (1)`);
|
|
1005
|
+
await run(session, OBJECT_ROW);
|
|
1006
|
+
await use(session);
|
|
1007
|
+
}
|
|
1008
|
+
finally {
|
|
1009
|
+
await dropAll(session);
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
/** The child's design as described, with `label` widened, `obsolete` dropped and `note` added. */
|
|
1014
|
+
async function widenedChild(session, extra = {}) {
|
|
1015
|
+
const relation = await relationOf(session, OBJECT_CHILD_TABLE);
|
|
1016
|
+
if (relation === undefined)
|
|
1017
|
+
throw new Error(`${OBJECT_CHILD_TABLE} was not described.`);
|
|
1018
|
+
const described = editorOf().design(relation);
|
|
1019
|
+
return {
|
|
1020
|
+
...described,
|
|
1021
|
+
columns: [
|
|
1022
|
+
...described.columns
|
|
1023
|
+
.filter((entry) => entry.name !== "obsolete")
|
|
1024
|
+
.map((entry) => (entry.name === "label" ? { ...entry, type: OBJECT_WIDE_TYPE } : entry)),
|
|
1025
|
+
column("note", OBJECT_NARROW_TYPE),
|
|
1026
|
+
],
|
|
1027
|
+
...extra,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
it("givenObjectsDeclared_whenCreateTableWithKeyIndexAndForeignKey_thenDescribedAsDesigned", async () => {
|
|
1031
|
+
await withTables(async (session) => {
|
|
1032
|
+
const child = await relationOf(session, OBJECT_CHILD_TABLE);
|
|
1033
|
+
expect(child?.columns.map((entry) => entry.name)).toEqual(["id", "parent_id", "label", "obsolete"]);
|
|
1034
|
+
expect(child?.primaryKey?.columns).toEqual(["id"]);
|
|
1035
|
+
expect(child?.indexes.map((index) => index.name)).toContain(OBJECT_INDEX);
|
|
1036
|
+
expect(child?.foreignKeys.map((key) => [key.target.relation, key.target.columns, key.onDelete])).toEqual([
|
|
1037
|
+
[OBJECT_PARENT_TABLE, ["id"], "CASCADE"],
|
|
1038
|
+
]);
|
|
1039
|
+
});
|
|
1040
|
+
});
|
|
1041
|
+
it("givenDescribedDesign_whenScriptedUnchanged_thenNoStatement", async () => {
|
|
1042
|
+
await withTables(async (session) => {
|
|
1043
|
+
const relation = await relationOf(session, OBJECT_CHILD_TABLE);
|
|
1044
|
+
if (relation === undefined)
|
|
1045
|
+
throw new Error(`${OBJECT_CHILD_TABLE} was not described.`);
|
|
1046
|
+
const script = await scriptOf(session, {
|
|
1047
|
+
kind: "alterTable",
|
|
1048
|
+
path: path(),
|
|
1049
|
+
relation: OBJECT_CHILD_TABLE,
|
|
1050
|
+
design: editorOf().design(relation),
|
|
1051
|
+
});
|
|
1052
|
+
expect(script.statements).toEqual([]);
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
it("givenTable_whenColumnAddedTypeChangedAndDropped_thenDescribedAfterAndRowsKept", async () => {
|
|
1056
|
+
await withTables(async (session) => {
|
|
1057
|
+
const design = await widenedChild(session);
|
|
1058
|
+
await apply(session, await scriptOf(session, { kind: "alterTable", path: path(), relation: OBJECT_CHILD_TABLE, design }));
|
|
1059
|
+
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);
|
|
1062
|
+
expect(child?.foreignKeys).toHaveLength(1);
|
|
1063
|
+
expect(child?.indexes.map((index) => index.name)).toContain(OBJECT_INDEX);
|
|
1064
|
+
expect(await run(session, `SELECT label FROM ${OBJECT_CHILD_TABLE}`)).toEqual([["kept"]]);
|
|
1065
|
+
});
|
|
1066
|
+
});
|
|
1067
|
+
it.runIf(plugin.capabilities.includes("transactions"))("givenTransactionalScript_whenStatementFails_thenNothingApplied", async () => {
|
|
1068
|
+
await withTables(async (session) => {
|
|
1069
|
+
// An index named like a table clashes in every dialect whose index names share the table namespace.
|
|
1070
|
+
const design = await widenedChild(session, {});
|
|
1071
|
+
const clashing = {
|
|
1072
|
+
...design,
|
|
1073
|
+
indexes: [
|
|
1074
|
+
...design.indexes,
|
|
1075
|
+
{ name: OBJECT_PARENT_TABLE, columns: ["note"], unique: false, original: null },
|
|
1076
|
+
],
|
|
1077
|
+
};
|
|
1078
|
+
const script = await scriptOf(session, {
|
|
1079
|
+
kind: "alterTable",
|
|
1080
|
+
path: path(),
|
|
1081
|
+
relation: OBJECT_CHILD_TABLE,
|
|
1082
|
+
design: clashing,
|
|
1083
|
+
});
|
|
1084
|
+
if (!script.transactional)
|
|
1085
|
+
return;
|
|
1086
|
+
await expect(apply(session, script)).rejects.toThrow();
|
|
1087
|
+
const child = await relationOf(session, OBJECT_CHILD_TABLE);
|
|
1088
|
+
expect(child?.columns.map((entry) => entry.name)).toEqual(["id", "parent_id", "label", "obsolete"]);
|
|
1089
|
+
expect(await run(session, `SELECT label FROM ${OBJECT_CHILD_TABLE}`)).toEqual([["kept"]]);
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
it("givenTable_whenRenamed_thenDescribedUnderNewName", async () => {
|
|
1093
|
+
await withTables(async (session) => {
|
|
1094
|
+
await apply(session, await scriptOf(session, {
|
|
1095
|
+
kind: "rename",
|
|
1096
|
+
path: path(),
|
|
1097
|
+
object: { kind: "table", name: OBJECT_CHILD_TABLE },
|
|
1098
|
+
name: OBJECT_RENAMED_TABLE,
|
|
1099
|
+
}));
|
|
1100
|
+
expect(await relationOf(session, OBJECT_CHILD_TABLE)).toBeUndefined();
|
|
1101
|
+
expect((await relationOf(session, OBJECT_RENAMED_TABLE))?.columns).toHaveLength(4);
|
|
1102
|
+
});
|
|
1103
|
+
});
|
|
1104
|
+
it("givenTableWithRows_whenTruncated_thenEmptyAndStillDescribed", async () => {
|
|
1105
|
+
await withTables(async (session) => {
|
|
1106
|
+
await apply(session, await scriptOf(session, { kind: "truncate", path: path(), relation: OBJECT_CHILD_TABLE, cascade: false }));
|
|
1107
|
+
expect(await run(session, `SELECT id FROM ${OBJECT_CHILD_TABLE}`)).toEqual([]);
|
|
1108
|
+
expect(await relationOf(session, OBJECT_CHILD_TABLE)).toBeDefined();
|
|
1109
|
+
});
|
|
1110
|
+
});
|
|
1111
|
+
it("givenTable_whenDropped_thenNotDescribed", async () => {
|
|
1112
|
+
await withTables(async (session) => {
|
|
1113
|
+
await apply(session, await scriptOf(session, {
|
|
1114
|
+
kind: "drop",
|
|
1115
|
+
path: path(),
|
|
1116
|
+
object: { kind: "table", name: OBJECT_CHILD_TABLE },
|
|
1117
|
+
cascade: false,
|
|
1118
|
+
}));
|
|
1119
|
+
expect(await relationOf(session, OBJECT_CHILD_TABLE)).toBeUndefined();
|
|
1120
|
+
});
|
|
1121
|
+
});
|
|
1122
|
+
it("givenFeatures_whenRead_thenEveryDroppableKindNamedAndTypesSuggested", () => {
|
|
1123
|
+
const { features } = editorOf();
|
|
1124
|
+
expect(features.types.length).toBeGreaterThan(0);
|
|
1125
|
+
expect(features.droppable).toContain("table");
|
|
1126
|
+
expect(features.renamable).toContain("table");
|
|
1127
|
+
});
|
|
1128
|
+
});
|
|
1129
|
+
describe.runIf(plugin.capabilities.includes("transactions"))("transactions", () => {
|
|
1130
|
+
function transactionsOf(session) {
|
|
1131
|
+
if (session.transactions === undefined)
|
|
1132
|
+
throw new Error("A transactions session needs transactions.");
|
|
1133
|
+
return session.transactions;
|
|
1134
|
+
}
|
|
1135
|
+
async function rowsWithId(session) {
|
|
1136
|
+
return (await collect((await session.execute(SELECT_TRANSACTION_ROW)).rows)).length;
|
|
1137
|
+
}
|
|
1138
|
+
async function removeRow() {
|
|
1139
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1140
|
+
await session.execute(DELETE_TRANSACTION_ROW);
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
afterEach(removeRow);
|
|
1144
|
+
it("givenManualMode_whenInsertThenRollback_thenRowGoneAndNotOpen", async () => {
|
|
1145
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1146
|
+
const transactions = transactionsOf(session);
|
|
1147
|
+
await transactions.setMode("manual");
|
|
1148
|
+
expect(transactions.open).toBe(false);
|
|
1149
|
+
await session.execute(INSERT_TRANSACTION_ROW);
|
|
1150
|
+
expect(transactions.open).toBe(true);
|
|
1151
|
+
expect(await rowsWithId(session)).toBe(1);
|
|
1152
|
+
await transactions.rollback();
|
|
1153
|
+
expect(transactions.open).toBe(false);
|
|
1154
|
+
expect(await rowsWithId(session)).toBe(0);
|
|
1155
|
+
expect(transactions.mode).toBe("manual");
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
it("givenManualMode_whenInsertThenCommit_thenRowKept", async () => {
|
|
1159
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1160
|
+
const transactions = transactionsOf(session);
|
|
1161
|
+
await transactions.setMode("manual");
|
|
1162
|
+
await session.execute(INSERT_TRANSACTION_ROW);
|
|
1163
|
+
await withSession(plugin, fixture, async (other) => {
|
|
1164
|
+
expect(await rowsWithId(other)).toBe(0);
|
|
1165
|
+
});
|
|
1166
|
+
await transactions.commit();
|
|
1167
|
+
expect(transactions.open).toBe(false);
|
|
1168
|
+
});
|
|
1169
|
+
await withSession(plugin, fixture, async (other) => {
|
|
1170
|
+
expect(await rowsWithId(other)).toBe(1);
|
|
1171
|
+
});
|
|
1172
|
+
});
|
|
1173
|
+
it("givenAutoMode_whenExplicitBegin_thenTransactionOpen", async () => {
|
|
1174
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1175
|
+
const transactions = transactionsOf(session);
|
|
1176
|
+
await session.execute(INSERT_TRANSACTION_ROW);
|
|
1177
|
+
expect(transactions.open).toBe(false);
|
|
1178
|
+
await session.execute(DELETE_TRANSACTION_ROW);
|
|
1179
|
+
await session.execute(dialectSql.begin);
|
|
1180
|
+
expect(transactions.open).toBe(true);
|
|
1181
|
+
await session.execute(INSERT_TRANSACTION_ROW);
|
|
1182
|
+
await session.execute("ROLLBACK");
|
|
1183
|
+
expect(transactions.open).toBe(false);
|
|
1184
|
+
expect(await rowsWithId(session)).toBe(0);
|
|
1185
|
+
});
|
|
1186
|
+
});
|
|
1187
|
+
it("givenOpenTransaction_whenSwitchedToAuto_thenRejectsWithStatementFailedAndStaysOpen", async () => {
|
|
1188
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1189
|
+
const transactions = transactionsOf(session);
|
|
1190
|
+
await transactions.setMode("manual");
|
|
1191
|
+
await session.execute(INSERT_TRANSACTION_ROW);
|
|
1192
|
+
expect(await rejectionCode(transactions.setMode("auto"))).toBe("statementFailed");
|
|
1193
|
+
expect(transactions.open).toBe(true);
|
|
1194
|
+
await transactions.rollback();
|
|
1195
|
+
await transactions.setMode("auto");
|
|
1196
|
+
expect(transactions.mode).toBe("auto");
|
|
1197
|
+
});
|
|
1198
|
+
});
|
|
1199
|
+
it("givenNoOpenTransaction_whenCommitAndRollback_thenNoOps", async () => {
|
|
1200
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1201
|
+
const transactions = transactionsOf(session);
|
|
1202
|
+
await transactions.commit();
|
|
1203
|
+
await transactions.rollback();
|
|
1204
|
+
expect(transactions.open).toBe(false);
|
|
1205
|
+
expect(await collect((await session.execute(selectOne)).rows)).toEqual([[1]]);
|
|
1206
|
+
});
|
|
1207
|
+
});
|
|
1208
|
+
});
|
|
1209
|
+
describe.runIf(plugin.capabilities.includes("sql") && plugin.capabilities.includes("transactions"))("edits", () => {
|
|
1210
|
+
it("givenEditProbe_whenEditsRoundTrip_thenRowsMatch", async () => {
|
|
1211
|
+
const dialect = plugin.language;
|
|
1212
|
+
if (dialect === undefined)
|
|
1213
|
+
throw new Error("A plugin that declares sql needs language.");
|
|
1214
|
+
const table = dialect.quoteIdentifier(fold(EDIT_PROBE_TABLE));
|
|
1215
|
+
const name = (column) => dialect.quoteIdentifier(fold(column));
|
|
1216
|
+
const text = (value) => dialect.literal(value, EDIT_PROBE_TEXT_TYPE);
|
|
1217
|
+
const key = (id) => `${name("id")} = ${dialect.literal(id, EDIT_PROBE_KEY_TYPE)}`;
|
|
1218
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1219
|
+
const transactions = session.transactions;
|
|
1220
|
+
if (transactions === undefined)
|
|
1221
|
+
throw new Error("A transactions session needs transactions.");
|
|
1222
|
+
const read = async () => collect((await session.execute(EDIT_PROBE_SELECT)).rows);
|
|
1223
|
+
const affected = async (sql) => {
|
|
1224
|
+
const result = await session.execute(sql);
|
|
1225
|
+
await collect(result.rows);
|
|
1226
|
+
return result.rowsAffected;
|
|
1227
|
+
};
|
|
1228
|
+
const qtyDefault = (await session.introspector?.describe(catalogPathOf(fixture)))?.relations
|
|
1229
|
+
.find((relation) => relation.name === fold(EDIT_PROBE_TABLE))
|
|
1230
|
+
?.columns.find((column) => column.name === fold("qty"))?.default ?? String(EDIT_PROBE_DEFAULT_QTY);
|
|
1231
|
+
const before = await read();
|
|
1232
|
+
await transactions.setMode("manual");
|
|
1233
|
+
try {
|
|
1234
|
+
expect(await affected(`INSERT INTO ${table} (${name("label")}) VALUES (${text("round trip")})`)).toBe(1);
|
|
1235
|
+
expect(await affected(dialect.emptyInsert(table))).toBe(1);
|
|
1236
|
+
const inserted = (await read()).slice(before.length);
|
|
1237
|
+
expect(inserted.map((row) => row.slice(1))).toEqual([
|
|
1238
|
+
["round trip", EDIT_PROBE_DEFAULT_QTY, null],
|
|
1239
|
+
[EDIT_PROBE_DEFAULT_LABEL, EDIT_PROBE_DEFAULT_QTY, null],
|
|
1240
|
+
]);
|
|
1241
|
+
const [kept, dropped] = inserted;
|
|
1242
|
+
const update = `UPDATE ${table} SET ${name("label")} = ${text("it's changed")}, ${name("qty")} = ${text("7")}, ` +
|
|
1243
|
+
`${name("note")} = NULL WHERE ${key(kept?.[0] ?? null)}`;
|
|
1244
|
+
expect(await affected(update)).toBe(1);
|
|
1245
|
+
// Unchanged values must still count the matched row, or Submit would reject a no-op edit.
|
|
1246
|
+
expect(await affected(update)).toBe(1);
|
|
1247
|
+
const reset = `UPDATE ${table} SET ${name("qty")} = ${dialect.updateDefault(qtyDefault)} WHERE ${key(kept?.[0] ?? null)}`;
|
|
1248
|
+
expect(await affected(`UPDATE ${table} SET ${name("qty")} = 2 WHERE ${key(kept?.[0] ?? null)}`)).toBe(1);
|
|
1249
|
+
expect(await affected(reset)).toBe(1);
|
|
1250
|
+
expect(await affected(`DELETE FROM ${table} WHERE ${key(dropped?.[0] ?? null)}`)).toBe(1);
|
|
1251
|
+
expect((await read()).slice(before.length).map((row) => row.slice(1))).toEqual([
|
|
1252
|
+
["it's changed", EDIT_PROBE_DEFAULT_QTY, null],
|
|
1253
|
+
]);
|
|
1254
|
+
expect(await affected(`UPDATE ${table} SET ${name("label")} = ${text("x")} WHERE ${key(MISSING_ROW_ID)}`)).toBe(0);
|
|
1255
|
+
}
|
|
1256
|
+
finally {
|
|
1257
|
+
await transactions.rollback();
|
|
1258
|
+
await transactions.setMode("auto");
|
|
1259
|
+
}
|
|
1260
|
+
expect(await read()).toEqual(before);
|
|
1261
|
+
});
|
|
1262
|
+
});
|
|
1263
|
+
});
|
|
1264
|
+
describe.runIf(plugin.capabilities.includes("schemaTree"))("schemaTree", () => {
|
|
1265
|
+
function catalogFixture() {
|
|
1266
|
+
if (fixture.catalog === undefined) {
|
|
1267
|
+
throw new Error("A plugin that declares schemaTree needs fixture.catalog.");
|
|
1268
|
+
}
|
|
1269
|
+
return fixture.catalog;
|
|
1270
|
+
}
|
|
1271
|
+
async function withIntrospector(use) {
|
|
1272
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1273
|
+
const introspector = session.introspector;
|
|
1274
|
+
if (introspector === undefined)
|
|
1275
|
+
throw new Error("A schemaTree session needs an introspector.");
|
|
1276
|
+
await use(introspector);
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
async function describeFixture() {
|
|
1280
|
+
let described;
|
|
1281
|
+
await withIntrospector(async (introspector) => {
|
|
1282
|
+
described = await introspector.describe(catalogFixture().path);
|
|
1283
|
+
});
|
|
1284
|
+
if (described === undefined)
|
|
1285
|
+
throw new Error("describe resolved nothing.");
|
|
1286
|
+
return described;
|
|
1287
|
+
}
|
|
1288
|
+
function relation(catalog, name) {
|
|
1289
|
+
const found = catalog.relations.find((candidate) => candidate.name === fold(name));
|
|
1290
|
+
if (found === undefined)
|
|
1291
|
+
throw new Error(`The fixture namespace has no relation ${name}.`);
|
|
1292
|
+
return found;
|
|
1293
|
+
}
|
|
1294
|
+
it("givenSchemaTreePlugin_whenNamespacesListed_thenCurrentNamespaceIsMarked", async () => {
|
|
1295
|
+
await withIntrospector(async (introspector) => {
|
|
1296
|
+
const namespaces = await introspector.namespaces();
|
|
1297
|
+
const current = namespaces.filter((namespace) => namespace.current);
|
|
1298
|
+
expect(current.map((namespace) => namespace.path)).toEqual([catalogFixture().path]);
|
|
1299
|
+
expect(current[0]?.system).toBe(false);
|
|
1300
|
+
expect(current[0]?.name).toBe(catalogFixture().path.at(-1));
|
|
1301
|
+
});
|
|
1302
|
+
});
|
|
1303
|
+
it("givenFixture_whenDescribed_thenCustomersOrdersTypeProbeAndCustomerTotalsArePresent", async () => {
|
|
1304
|
+
const catalog = await describeFixture();
|
|
1305
|
+
expect(catalog.path).toEqual(catalogFixture().path);
|
|
1306
|
+
const names = catalog.relations.map((candidate) => candidate.name);
|
|
1307
|
+
expect(names).toEqual([...names].sort());
|
|
1308
|
+
expect(catalog.relations.map(({ name, kind }) => ({ name, kind }))).toEqual(expect.arrayContaining(FIXTURE_RELATIONS.map((expected) => ({ ...expected, name: fold(expected.name) }))));
|
|
1309
|
+
const probe = relation(catalog, TYPE_PROBE_TABLE);
|
|
1310
|
+
expect(probe.columns.map((column) => column.name)).toEqual(TYPE_PROBE_COLUMNS.map(fold));
|
|
1311
|
+
expect(probe.columns.map((column) => column.position)).toEqual(TYPE_PROBE_COLUMNS.map((_, index) => index + 1));
|
|
1312
|
+
for (const column of probe.columns)
|
|
1313
|
+
expect(column.type).toMatch(/\S/);
|
|
1314
|
+
});
|
|
1315
|
+
it("givenOrders_whenDescribed_thenForeignKeyTargetsCustomersId", async () => {
|
|
1316
|
+
const orders = relation(await describeFixture(), "orders");
|
|
1317
|
+
expect(orders.foreignKeys).toHaveLength(1);
|
|
1318
|
+
expect(orders.foreignKeys[0]).toMatchObject({
|
|
1319
|
+
columns: [fold("customer_id")],
|
|
1320
|
+
target: { path: catalogFixture().path, relation: fold("customers"), columns: [fold("id")] },
|
|
1321
|
+
});
|
|
1322
|
+
expect(orders.foreignKeys[0]?.onDelete).toMatch(/\S/);
|
|
1323
|
+
const customerId = orders.columns.find((column) => column.name === fold("customer_id"));
|
|
1324
|
+
expect(customerId?.nullable).toBe(false);
|
|
1325
|
+
});
|
|
1326
|
+
it("givenOrders_whenDescribed_thenIndexOrdersPlacedAtIdxIsListed", async () => {
|
|
1327
|
+
const orders = relation(await describeFixture(), "orders");
|
|
1328
|
+
expect(orders.indexes).toContainEqual({
|
|
1329
|
+
name: fold(ORDERS_INDEX),
|
|
1330
|
+
columns: [fold("placed_at")],
|
|
1331
|
+
unique: false,
|
|
1332
|
+
primary: false,
|
|
1333
|
+
});
|
|
1334
|
+
});
|
|
1335
|
+
it("givenCustomers_whenDescribed_thenIdIsPrimaryKeyAndEmailIsUnique", async () => {
|
|
1336
|
+
const customers = relation(await describeFixture(), "customers");
|
|
1337
|
+
expect(customers.comment).toBe(catalogFixture().customersComment);
|
|
1338
|
+
expect(customers.primaryKey?.columns).toEqual([fold("id")]);
|
|
1339
|
+
expect(customers.uniqueKeys.map((key) => key.columns)).toEqual([[fold("email")]]);
|
|
1340
|
+
expect(customers.columns.find((column) => column.name === fold("email"))?.nullable).toBe(false);
|
|
1341
|
+
});
|
|
1342
|
+
it("givenUnknownNamespace_whenDescribed_thenRejectsWithIntrospectionFailed", async () => {
|
|
1343
|
+
const missing = [...catalogFixture().path.slice(0, -1), MISSING_NAMESPACE];
|
|
1344
|
+
await withIntrospector(async (introspector) => {
|
|
1345
|
+
expect(await rejectionCode(introspector.describe(missing))).toBe("introspectionFailed");
|
|
1346
|
+
});
|
|
1347
|
+
});
|
|
1348
|
+
it("givenIdentifierNeedingQuotes_whenQualified_thenPluginQuotesIt", async () => {
|
|
1349
|
+
const { path, quoted } = catalogFixture();
|
|
1350
|
+
await withIntrospector((introspector) => {
|
|
1351
|
+
expect(introspector.qualifiedName(path, quoted.name)).toBe(quoted.qualified);
|
|
1352
|
+
return Promise.resolve();
|
|
1353
|
+
});
|
|
1354
|
+
});
|
|
1355
|
+
it("givenIntrospection_whenStatementRunAfter_thenSessionStillWorks", async () => {
|
|
1356
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1357
|
+
await session.introspector?.describe(catalogFixture().path);
|
|
1358
|
+
expect(await collect((await session.execute(selectOne)).rows)).toEqual([[1]]);
|
|
1359
|
+
});
|
|
1360
|
+
});
|
|
1361
|
+
});
|
|
1362
|
+
describe.runIf(plugin.capabilities.includes("sql") && plugin.capabilities.includes("schemaTree"))("completion", () => {
|
|
1363
|
+
function completionFixture() {
|
|
1364
|
+
const catalog = fixture.catalog;
|
|
1365
|
+
const language = plugin.language;
|
|
1366
|
+
if (catalog === undefined || language === undefined) {
|
|
1367
|
+
throw new Error("A plugin that declares sql and schemaTree needs a language and fixture.catalog.");
|
|
1368
|
+
}
|
|
1369
|
+
return { catalog, language };
|
|
1370
|
+
}
|
|
1371
|
+
/** The completer against the live database, read the way the engine's cache reads it. */
|
|
1372
|
+
async function withCompletion(use) {
|
|
1373
|
+
const { language } = completionFixture();
|
|
1374
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1375
|
+
const introspector = session.introspector;
|
|
1376
|
+
if (introspector === undefined)
|
|
1377
|
+
throw new Error("A schemaTree session needs an introspector.");
|
|
1378
|
+
await use(language, await liveCompletionCatalog(introspector, session.serverVersion));
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
function at(text, marker) {
|
|
1382
|
+
return { text: text.replace(marker, ""), offset: text.indexOf(marker) };
|
|
1383
|
+
}
|
|
1384
|
+
it("givenFromClause_whenCompleting_thenOffersFixtureTablesAndViews", async () => {
|
|
1385
|
+
await withCompletion(async (language, catalog) => {
|
|
1386
|
+
const result = await language.complete(at("SELECT * FROM |", "|"), catalog);
|
|
1387
|
+
const offered = result.items.map(({ label, kind }) => ({ label, kind }));
|
|
1388
|
+
expect(offered).toEqual(expect.arrayContaining([
|
|
1389
|
+
{ label: fold("orders"), kind: "table" },
|
|
1390
|
+
{ label: fold("customers"), kind: "table" },
|
|
1391
|
+
{ label: fold("customer_totals"), kind: "view" },
|
|
1392
|
+
]));
|
|
1393
|
+
});
|
|
1394
|
+
});
|
|
1395
|
+
it("givenAliasDot_whenCompleting_thenOffersAliasedColumnsWithTypes", async () => {
|
|
1396
|
+
await withCompletion(async (language, catalog) => {
|
|
1397
|
+
const result = await language.complete(at("SELECT o.| FROM orders o", "|"), catalog);
|
|
1398
|
+
const columns = result.items.filter((item) => item.kind === "column");
|
|
1399
|
+
expect(columns.map((item) => item.label)).toEqual(["id", "customer_id", "total", "placed_at"].map(fold));
|
|
1400
|
+
for (const column of columns)
|
|
1401
|
+
expect(column.detail).toMatch(/\S/);
|
|
1402
|
+
});
|
|
1403
|
+
});
|
|
1404
|
+
it("givenJoinAfterOrders_whenCompleting_thenCustomersJoinFirstWithOnClause", async () => {
|
|
1405
|
+
await withCompletion(async (language, catalog) => {
|
|
1406
|
+
const result = await language.complete(at("SELECT * FROM orders o JOIN |", "|"), catalog);
|
|
1407
|
+
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" });
|
|
1409
|
+
});
|
|
1410
|
+
});
|
|
1411
|
+
it("givenNameNeedingQuotes_whenCompleting_thenApplyIsQuotedByDialect", async () => {
|
|
1412
|
+
const { catalog: expected } = completionFixture();
|
|
1413
|
+
await withCompletion(async (language, catalog) => {
|
|
1414
|
+
const result = await language.complete(at("SELECT * FROM Li|", "|"), catalog);
|
|
1415
|
+
const item = result.items.find((candidate) => candidate.label === LINE_ITEMS_TABLE);
|
|
1416
|
+
expect(item?.apply).toBe(expected.lineItems);
|
|
1417
|
+
});
|
|
1418
|
+
});
|
|
1419
|
+
it("givenAliasReference_whenResolving_thenReturnsDeclarationSpan", async () => {
|
|
1420
|
+
await withCompletion(async (language, catalog) => {
|
|
1421
|
+
const text = "SELECT o.id FROM orders o";
|
|
1422
|
+
const declaration = await language.resolve({ text, offset: text.indexOf("o.id") }, catalog);
|
|
1423
|
+
const from = text.indexOf("orders o");
|
|
1424
|
+
expect(declaration).toEqual({ kind: "alias", from, to: from + "orders o".length });
|
|
1425
|
+
});
|
|
1426
|
+
});
|
|
1427
|
+
it("givenUpperCallOpen_whenAskingSignature_thenFirstArgumentOfUpper", async () => {
|
|
1428
|
+
await withCompletion(async (language, catalog) => {
|
|
1429
|
+
const text = "SELECT upper( FROM orders";
|
|
1430
|
+
const offset = text.indexOf("(") + 1;
|
|
1431
|
+
const help = await language.signatureAt?.({ text, offset }, catalog);
|
|
1432
|
+
expect(help).toMatchObject({ name: "upper", argument: 0, from: text.indexOf("upper") });
|
|
1433
|
+
expect(help?.signatures[0]?.parameters.length).toBe(1);
|
|
1434
|
+
expect(help?.summary).toMatch(/\S/);
|
|
1435
|
+
});
|
|
1436
|
+
});
|
|
1437
|
+
it("givenFunctionPrefix_whenCompleting_thenItemCarriesSignatureAndCaretInsideCall", async () => {
|
|
1438
|
+
await withCompletion(async (language, catalog) => {
|
|
1439
|
+
const result = await language.complete(at("SELECT upp| FROM orders", "|"), catalog);
|
|
1440
|
+
const upper = result.items.find((item) => item.kind === "function" && item.label === "upper");
|
|
1441
|
+
expect(upper).toMatchObject({ apply: "upper()", caret: "upper(".length });
|
|
1442
|
+
expect(upper?.detail).toMatch(/^\(.*\) → /);
|
|
1443
|
+
expect(upper?.info).toMatch(/\S/);
|
|
1444
|
+
});
|
|
1445
|
+
});
|
|
1446
|
+
it("givenRelationReference_whenResolving_thenReturnsCurrentPathAndRelation", async () => {
|
|
1447
|
+
const { catalog: expected } = completionFixture();
|
|
1448
|
+
await withCompletion(async (language, catalog) => {
|
|
1449
|
+
const text = "SELECT * FROM customers";
|
|
1450
|
+
const declaration = await language.resolve({ text, offset: text.indexOf("customers") + 2 }, catalog);
|
|
1451
|
+
expect(declaration).toEqual({ kind: "relation", path: expected.path, relation: fold("customers") });
|
|
1452
|
+
});
|
|
1453
|
+
});
|
|
1454
|
+
describe.runIf(plugin.language?.inspect !== undefined)("inspect", () => {
|
|
1455
|
+
async function inspect(text, catalog) {
|
|
1456
|
+
const inspector = plugin.language?.inspect?.bind(plugin.language);
|
|
1457
|
+
if (inspector === undefined)
|
|
1458
|
+
throw new Error("This block runs only when language.inspect exists.");
|
|
1459
|
+
return inspector({ text }, catalog);
|
|
1460
|
+
}
|
|
1461
|
+
it("givenGarbage_whenInspecting_thenResolvesWithEditsInsideTheText", async () => {
|
|
1462
|
+
await withCompletion(async (_language, catalog) => {
|
|
1463
|
+
for (const text of INSPECTION_GARBAGE) {
|
|
1464
|
+
for (const problem of await inspect(text, catalog)) {
|
|
1465
|
+
for (const edit of problem.fixes.flatMap((fix) => fix.edits)) {
|
|
1466
|
+
expect(0 <= edit.from && edit.from <= edit.to && edit.to <= text.length).toBe(true);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
});
|
|
1471
|
+
});
|
|
1472
|
+
it("givenFixtureQuery_whenInspecting_thenNothingUnresolved", async () => {
|
|
1473
|
+
await withCompletion(async (_language, catalog) => {
|
|
1474
|
+
const text = "SELECT o.id, c.name FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.id = 1";
|
|
1475
|
+
expect((await inspect(text, catalog)).map((problem) => problem.message)).toEqual([]);
|
|
1476
|
+
});
|
|
1477
|
+
});
|
|
1478
|
+
it("givenMisspelledTable_whenInspecting_thenUnresolvedWithFixToFixtureTable", async () => {
|
|
1479
|
+
await withCompletion(async (_language, catalog) => {
|
|
1480
|
+
const text = "SELECT * FROM ordrs WHERE id = 1";
|
|
1481
|
+
const problem = (await inspect(text, catalog)).find((found) => found.inspection === "SqlResolve");
|
|
1482
|
+
expect(text.slice(problem?.from, problem?.to)).toBe("ordrs");
|
|
1483
|
+
const edit = problem?.fixes[0]?.edits[0];
|
|
1484
|
+
expect(edit?.insert.toLowerCase()).toBe("orders");
|
|
1485
|
+
});
|
|
1486
|
+
});
|
|
1487
|
+
});
|
|
1488
|
+
});
|
|
1489
|
+
describe.runIf(plugin.capabilities.includes("ddl"))("ddl", () => {
|
|
1490
|
+
function catalogFixture() {
|
|
1491
|
+
if (fixture.catalog === undefined)
|
|
1492
|
+
throw new Error("A plugin that declares ddl needs fixture.catalog.");
|
|
1493
|
+
return fixture.catalog;
|
|
1494
|
+
}
|
|
1495
|
+
async function definitionOf(name, path = catalogFixture().path) {
|
|
1496
|
+
let definition = "";
|
|
1497
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1498
|
+
const read = session.introspector?.definition?.bind(session.introspector);
|
|
1499
|
+
if (read === undefined)
|
|
1500
|
+
throw new Error("A ddl session needs introspector.definition.");
|
|
1501
|
+
definition = await read(path, name);
|
|
1502
|
+
});
|
|
1503
|
+
return definition;
|
|
1504
|
+
}
|
|
1505
|
+
it("givenTable_whenReadingDefinition_thenIncludesColumnsKeysAndCheck", async () => {
|
|
1506
|
+
const definition = await definitionOf("orders");
|
|
1507
|
+
for (const column of ["id", "customer_id", "total", "placed_at"])
|
|
1508
|
+
expect(definition).toContain(fold(column));
|
|
1509
|
+
expect(definition).toMatch(/PRIMARY KEY/i);
|
|
1510
|
+
expect(definition).toContain(catalogFixture().ordersCheck);
|
|
1511
|
+
expect(definition).toMatch(/CHECK/i);
|
|
1512
|
+
expect(definition).toContain(fold(ORDERS_INDEX));
|
|
1513
|
+
expect(definition.trimEnd().endsWith(";")).toBe(true);
|
|
1514
|
+
});
|
|
1515
|
+
it("givenTableWithForeignKey_whenReadingDefinition_thenNamesTarget", async () => {
|
|
1516
|
+
expect(await definitionOf("orders")).toMatch(/REFERENCES\s+\S*customers\S*\s*\(\S*id\S*\)/i);
|
|
1517
|
+
});
|
|
1518
|
+
it("givenView_whenReadingDefinition_thenIncludesItsQuery", async () => {
|
|
1519
|
+
const definition = await definitionOf("customer_totals");
|
|
1520
|
+
expect(definition).toMatch(/CREATE\b[\s\S]*\bVIEW\b/i);
|
|
1521
|
+
expect(definition).toMatch(/coalesce/i);
|
|
1522
|
+
expect(definition).toMatch(/customers/);
|
|
1523
|
+
});
|
|
1524
|
+
it("givenCustomers_whenReadingDefinition_thenIncludesTheTableComment", async () => {
|
|
1525
|
+
const comment = catalogFixture().customersComment;
|
|
1526
|
+
const definition = await definitionOf("customers");
|
|
1527
|
+
if (comment !== null)
|
|
1528
|
+
expect(definition).toContain(comment);
|
|
1529
|
+
expect(definition).toContain(fold("email"));
|
|
1530
|
+
});
|
|
1531
|
+
it("givenUnknownRelation_whenReadingDefinition_thenDefinitionNotFound", async () => {
|
|
1532
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1533
|
+
const pending = session.introspector?.definition?.(catalogFixture().path, MISSING_RELATION);
|
|
1534
|
+
const failure = await pending?.catch((error) => error);
|
|
1535
|
+
expect(isPluginError(failure) && failure.code).toBe("definitionNotFound");
|
|
1536
|
+
expect(failure instanceof Error && failure.message).toContain(MISSING_RELATION);
|
|
1537
|
+
});
|
|
1538
|
+
});
|
|
1539
|
+
it("givenDefinitionNotFoundError_whenConstructed_thenNamesPathAndRelation", () => {
|
|
1540
|
+
const error = new DefinitionNotFoundError(catalogFixture().path, MISSING_RELATION);
|
|
1541
|
+
expect(isPluginError(error)).toBe(true);
|
|
1542
|
+
expect(error.message).toContain(catalogFixture().path.join("."));
|
|
1543
|
+
});
|
|
1544
|
+
});
|
|
1545
|
+
describe.runIf(plugin.capabilities.includes("sql") && plugin.capabilities.includes("schemaTree"))("generate", () => {
|
|
1546
|
+
async function withGenerator(use) {
|
|
1547
|
+
const language = plugin.language;
|
|
1548
|
+
if (language === undefined || fixture.catalog === undefined) {
|
|
1549
|
+
throw new Error("A plugin that declares sql and schemaTree needs a language and fixture.catalog.");
|
|
1550
|
+
}
|
|
1551
|
+
const path = fixture.catalog.path;
|
|
1552
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1553
|
+
const introspector = session.introspector;
|
|
1554
|
+
if (introspector === undefined)
|
|
1555
|
+
throw new Error("A schemaTree session needs an introspector.");
|
|
1556
|
+
const catalog = await introspector.describe(path);
|
|
1557
|
+
await use(session, (statement, name) => {
|
|
1558
|
+
const relation = catalog.relations.find((candidate) => candidate.name === fold(name));
|
|
1559
|
+
if (relation === undefined)
|
|
1560
|
+
throw new Error(`The fixture namespace has no relation ${name}.`);
|
|
1561
|
+
const sql = language.generate(statement, relation, introspector.qualifiedName(path, fold(name)));
|
|
1562
|
+
const spans = language.split(sql);
|
|
1563
|
+
expect(spans).toHaveLength(1);
|
|
1564
|
+
return spans[0]?.sql ?? "";
|
|
1565
|
+
});
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
it("givenGeneratedSelect_whenExecuted_thenReturnsTheTablesColumns", async () => {
|
|
1569
|
+
await withGenerator(async (session, generated) => {
|
|
1570
|
+
const result = await session.execute(generated("select", LINE_ITEMS_TABLE));
|
|
1571
|
+
await collect(result.rows);
|
|
1572
|
+
expect(result.columns.map((column) => column.name)).toEqual(["id", "sku"].map(fold));
|
|
1573
|
+
});
|
|
1574
|
+
});
|
|
1575
|
+
it("givenGeneratedUpdate_whenExecuted_thenAffectsNoRows", async () => {
|
|
1576
|
+
await withGenerator(async (session, generated) => {
|
|
1577
|
+
const sql = generated("update", "orders");
|
|
1578
|
+
const result = await session.execute(sql);
|
|
1579
|
+
await collect(result.rows);
|
|
1580
|
+
expect(result.rowsAffected).toBe(0);
|
|
1581
|
+
});
|
|
1582
|
+
});
|
|
1583
|
+
it.runIf(plugin.capabilities.includes("transactions"))("givenGeneratedInsert_whenExecutedInTransaction_thenInsertsOneRow", async () => {
|
|
1584
|
+
await withGenerator(async (session, generated) => {
|
|
1585
|
+
const transactions = session.transactions;
|
|
1586
|
+
if (transactions === undefined)
|
|
1587
|
+
throw new Error("A transactions session needs transactions.");
|
|
1588
|
+
await transactions.setMode("manual");
|
|
1589
|
+
try {
|
|
1590
|
+
const result = await session.execute(generated("insert", EDIT_PROBE_TABLE));
|
|
1591
|
+
await collect(result.rows);
|
|
1592
|
+
expect(result.rowsAffected).toBe(1);
|
|
1593
|
+
}
|
|
1594
|
+
finally {
|
|
1595
|
+
await transactions.rollback();
|
|
1596
|
+
await transactions.setMode("auto");
|
|
1597
|
+
}
|
|
1598
|
+
});
|
|
1599
|
+
});
|
|
1600
|
+
it("givenGeneratedDrop_whenExecuted_thenRelationGone", async () => {
|
|
1601
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1602
|
+
await collect((await session.execute(`CREATE TABLE ${DROP_PROBE_TABLE} (id INTEGER)`)).rows);
|
|
1603
|
+
});
|
|
1604
|
+
try {
|
|
1605
|
+
await withGenerator(async (session, generated) => {
|
|
1606
|
+
await collect((await session.execute(generated("drop", DROP_PROBE_TABLE))).rows);
|
|
1607
|
+
expect(await rejectionCode(session.execute(`SELECT * FROM ${DROP_PROBE_TABLE}`))).toBe("statementFailed");
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
finally {
|
|
1611
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1612
|
+
await collect((await session.execute(dropIfExists(DROP_PROBE_TABLE))).rows);
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
});
|
|
1616
|
+
});
|
|
1617
|
+
describe.runIf(plugin.capabilities.includes("explain"))("explain", () => {
|
|
1618
|
+
function explainable() {
|
|
1619
|
+
const language = plugin.language;
|
|
1620
|
+
if (language?.explainable === undefined) {
|
|
1621
|
+
throw new Error("A plugin that declares explain needs language.explainable.");
|
|
1622
|
+
}
|
|
1623
|
+
return (sql, options) => language.explainable?.(sql, options) ?? { ok: false, reason: "" };
|
|
1624
|
+
}
|
|
1625
|
+
async function explained(session, sql, analyze) {
|
|
1626
|
+
if (session.explain === undefined)
|
|
1627
|
+
throw new Error("An explain session needs explain.");
|
|
1628
|
+
return session.explain(sql, { analyze });
|
|
1629
|
+
}
|
|
1630
|
+
async function ordersRows(session) {
|
|
1631
|
+
return (await collect((await session.execute("SELECT id FROM orders")).rows)).length;
|
|
1632
|
+
}
|
|
1633
|
+
it("givenSelect_whenExplained_thenTreeWithAnOperation", async () => {
|
|
1634
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1635
|
+
const plan = await explained(session, dialectSql.explainJoin, false);
|
|
1636
|
+
expect(plan.analyzed).toBe(false);
|
|
1637
|
+
expect(plan.statement).toMatch(/^EXPLAIN\b/i);
|
|
1638
|
+
expect(plan.statement).toContain(dialectSql.explainJoin);
|
|
1639
|
+
expect(plan.raw).toMatch(/\S/);
|
|
1640
|
+
const nodes = planNodes(plan.roots);
|
|
1641
|
+
expect(nodes.length).toBeGreaterThan(1);
|
|
1642
|
+
for (const node of nodes) {
|
|
1643
|
+
expect(node.operation).toMatch(/\S/);
|
|
1644
|
+
expect([node.actualRows, node.actualTimeMs, node.loops]).toEqual([null, null, null]);
|
|
1645
|
+
}
|
|
1646
|
+
expect(await ordersRows(session)).toBe(ORDERS_ROWS);
|
|
1647
|
+
});
|
|
1648
|
+
});
|
|
1649
|
+
it("givenDeleteWithoutWhere_whenExplained_thenNothingRuns", async () => {
|
|
1650
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1651
|
+
const plan = await explained(session, EXPLAIN_DELETE_ALL, false);
|
|
1652
|
+
expect(plan.roots.length).toBeGreaterThan(0);
|
|
1653
|
+
expect(await ordersRows(session)).toBe(ORDERS_ROWS);
|
|
1654
|
+
});
|
|
1655
|
+
});
|
|
1656
|
+
it("givenDdl_whenExplainable_thenRefusedWithReason", async () => {
|
|
1657
|
+
const verdict = explainable()("CREATE TABLE t (id INTEGER)", { analyze: false });
|
|
1658
|
+
expect(verdict.ok).toBe(false);
|
|
1659
|
+
expect(verdict.ok ? "" : verdict.reason).toMatch(/\S/);
|
|
1660
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1661
|
+
expect(await rejectionCode(explained(session, "CREATE TABLE t (id INTEGER)", false))).toBe("explainUnsupported");
|
|
1662
|
+
expect(await ordersRows(session)).toBe(ORDERS_ROWS);
|
|
1663
|
+
});
|
|
1664
|
+
});
|
|
1665
|
+
it("givenTwoStatements_whenExplainable_thenRefused", () => {
|
|
1666
|
+
expect(explainable()("SELECT 1; SELECT 2", { analyze: false }).ok).toBe(false);
|
|
1667
|
+
expect(explainable()(" -- nothing\n", { analyze: false }).ok).toBe(false);
|
|
1668
|
+
});
|
|
1669
|
+
it("givenExplainStatement_whenExplainable_thenRefused", () => {
|
|
1670
|
+
expect(explainable()(`EXPLAIN ${dialectSql.explainJoin}`, { analyze: false }).ok).toBe(false);
|
|
1671
|
+
});
|
|
1672
|
+
it("givenReadAndWrite_whenExplainable_thenWritesOnlyForTheWrite", () => {
|
|
1673
|
+
expect(explainable()(dialectSql.explainJoin, { analyze: false })).toEqual({ ok: true, writes: false });
|
|
1674
|
+
expect(explainable()(EXPLAIN_UPDATE, { analyze: false })).toEqual({ ok: true, writes: true });
|
|
1675
|
+
});
|
|
1676
|
+
it.runIf(!plugin.capabilities.includes("explainAnalyze"))("givenNoExplainAnalyze_whenAnalyseExplainable_thenRefused", () => {
|
|
1677
|
+
expect(explainable()(dialectSql.explainJoin, { analyze: true }).ok).toBe(false);
|
|
1678
|
+
});
|
|
1679
|
+
});
|
|
1680
|
+
describe.runIf(plugin.capabilities.includes("explainAnalyze"))("explainAnalyze", () => {
|
|
1681
|
+
async function analysed(session, sql) {
|
|
1682
|
+
if (session.explain === undefined)
|
|
1683
|
+
throw new Error("An explain session needs explain.");
|
|
1684
|
+
return session.explain(sql, { analyze: true });
|
|
1685
|
+
}
|
|
1686
|
+
it("givenSelect_whenAnalysed_thenActualRowsPresent", async () => {
|
|
1687
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1688
|
+
const plan = await analysed(session, dialectSql.explainJoin);
|
|
1689
|
+
expect(plan.analyzed).toBe(true);
|
|
1690
|
+
expect(planNodes(plan.roots).some((node) => node.actualRows !== null && node.loops !== null)).toBe(true);
|
|
1691
|
+
});
|
|
1692
|
+
});
|
|
1693
|
+
it.runIf(plugin.capabilities.includes("transactions") &&
|
|
1694
|
+
plugin.language?.explainable?.(ANALYSED_UPDATE, { analyze: true }).ok === true)("givenUpdateInManualTransaction_whenAnalysed_thenRolledBackLeavesRows", async () => {
|
|
1695
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1696
|
+
const transactions = session.transactions;
|
|
1697
|
+
if (transactions === undefined)
|
|
1698
|
+
throw new Error("A transactions session needs transactions.");
|
|
1699
|
+
const total = async () => collect((await session.execute(ANALYSED_TOTAL)).rows);
|
|
1700
|
+
const before = await total();
|
|
1701
|
+
await transactions.setMode("manual");
|
|
1702
|
+
try {
|
|
1703
|
+
const plan = await analysed(session, ANALYSED_UPDATE);
|
|
1704
|
+
expect(plan.analyzed).toBe(true);
|
|
1705
|
+
expect(transactions.open).toBe(true);
|
|
1706
|
+
expect(await total()).not.toEqual(before);
|
|
1707
|
+
}
|
|
1708
|
+
finally {
|
|
1709
|
+
await transactions.rollback();
|
|
1710
|
+
await transactions.setMode("auto");
|
|
1711
|
+
}
|
|
1712
|
+
expect(await total()).toEqual(before);
|
|
1713
|
+
});
|
|
1714
|
+
});
|
|
1715
|
+
});
|
|
1716
|
+
describe.runIf(plugin.capabilities.includes("serverMessages"))("serverMessages", () => {
|
|
1717
|
+
function messageCase() {
|
|
1718
|
+
const message = fixture.statements?.message;
|
|
1719
|
+
if (message === undefined)
|
|
1720
|
+
throw new Error("A serverMessages fixture needs statements.message.");
|
|
1721
|
+
return message;
|
|
1722
|
+
}
|
|
1723
|
+
it("givenStatementWithWarnings_whenExecuted_thenMessagesReturned", async () => {
|
|
1724
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1725
|
+
const { sql, level, code } = messageCase();
|
|
1726
|
+
const result = await session.execute(sql);
|
|
1727
|
+
await collect(result.rows);
|
|
1728
|
+
expect(result.messages?.[0]).toMatchObject({ level, code });
|
|
1729
|
+
expect(result.messages?.[0]?.text).not.toBe("");
|
|
1730
|
+
});
|
|
1731
|
+
});
|
|
1732
|
+
it("givenQuietStatement_whenExecuted_thenNoMessages", async () => {
|
|
1733
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1734
|
+
const result = await session.execute(dialectSql.select("1 AS n"));
|
|
1735
|
+
await collect(result.rows);
|
|
1736
|
+
expect(result.messages).toEqual([]);
|
|
1737
|
+
});
|
|
1738
|
+
});
|
|
1739
|
+
it("givenStatementWithWarnings_whenNextStatementRuns_thenItsMessagesAreItsOwn", async () => {
|
|
1740
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1741
|
+
await collect((await session.execute(messageCase().sql)).rows);
|
|
1742
|
+
const quiet = await session.execute(dialectSql.select("1 AS n"));
|
|
1743
|
+
await collect(quiet.rows);
|
|
1744
|
+
expect(quiet.messages).toEqual([]);
|
|
1745
|
+
});
|
|
1746
|
+
});
|
|
1747
|
+
});
|
|
1748
|
+
describe.runIf(plugin.capabilities.includes("currentNamespace"))("currentNamespace", () => {
|
|
1749
|
+
function namespaceSwitch() {
|
|
1750
|
+
if (fixture.namespaceSwitch === undefined) {
|
|
1751
|
+
throw new Error("A plugin that declares currentNamespace needs fixture.namespaceSwitch.");
|
|
1752
|
+
}
|
|
1753
|
+
return fixture.namespaceSwitch;
|
|
1754
|
+
}
|
|
1755
|
+
async function current(session) {
|
|
1756
|
+
if (session.currentNamespace === undefined)
|
|
1757
|
+
throw new Error("A currentNamespace session needs it.");
|
|
1758
|
+
return session.currentNamespace();
|
|
1759
|
+
}
|
|
1760
|
+
it("givenFreshSession_whenCurrentNamespaceRead_thenIsCatalogPath", async () => {
|
|
1761
|
+
const path = fixture.catalog?.path;
|
|
1762
|
+
if (path === undefined)
|
|
1763
|
+
throw new Error("A plugin that declares currentNamespace needs fixture.catalog.");
|
|
1764
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1765
|
+
expect(await current(session)).toEqual(path);
|
|
1766
|
+
});
|
|
1767
|
+
});
|
|
1768
|
+
it("givenSwitchStatement_whenClassified_thenSetsNamespace", () => {
|
|
1769
|
+
expect(plugin.language?.classify(namespaceSwitch().statement).setsNamespace).toBe(true);
|
|
1770
|
+
expect(plugin.language?.classify("SELECT 1").setsNamespace ?? false).toBe(false);
|
|
1771
|
+
});
|
|
1772
|
+
it("givenSwitchStatement_whenExecuted_thenCurrentNamespaceIsTarget", async () => {
|
|
1773
|
+
const { statement, path } = namespaceSwitch();
|
|
1774
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1775
|
+
await collect((await session.execute(statement)).rows);
|
|
1776
|
+
expect(await current(session)).toEqual(path);
|
|
1777
|
+
expect(await collect((await session.execute(selectOne)).rows)).toEqual([[1]]);
|
|
1778
|
+
});
|
|
1779
|
+
});
|
|
1780
|
+
function switcher() {
|
|
1781
|
+
const found = plugin.language?.namespaceSwitch;
|
|
1782
|
+
if (found === undefined)
|
|
1783
|
+
throw new Error("A plugin that declares currentNamespace needs language.namespaceSwitch.");
|
|
1784
|
+
return found;
|
|
1785
|
+
}
|
|
1786
|
+
it("givenSwitchStatementForTarget_whenExecuted_thenCurrentNamespaceIsTarget", async () => {
|
|
1787
|
+
const { path } = namespaceSwitch();
|
|
1788
|
+
const written = switcher();
|
|
1789
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1790
|
+
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);
|
|
1794
|
+
expect(plugin.language?.classify(sql).setsNamespace).toBe(true);
|
|
1795
|
+
await collect((await session.execute(sql)).rows);
|
|
1796
|
+
expect(await current(session)).toEqual(path);
|
|
1797
|
+
});
|
|
1798
|
+
});
|
|
1799
|
+
it("givenEmptyPath_whenSwitchStatementWritten_thenThrows", () => {
|
|
1800
|
+
let thrown = null;
|
|
1801
|
+
try {
|
|
1802
|
+
switcher().statement([]);
|
|
1803
|
+
}
|
|
1804
|
+
catch (error) {
|
|
1805
|
+
thrown = error;
|
|
1806
|
+
}
|
|
1807
|
+
expect(isPluginError(thrown) ? thrown.code : thrown).toBe("statementFailed");
|
|
1808
|
+
});
|
|
1809
|
+
it.runIf(fixture.namespaceSwitch?.missing !== undefined)("givenMissingTarget_whenExecuted_thenRejectsAndNamespaceUnchanged", async () => {
|
|
1810
|
+
const { missing } = namespaceSwitch();
|
|
1811
|
+
if (missing === undefined)
|
|
1812
|
+
return;
|
|
1813
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1814
|
+
const before = await current(session);
|
|
1815
|
+
expect(await rejectionCode(session.execute(missing))).toBe("statementFailed");
|
|
1816
|
+
expect(await current(session)).toEqual(before);
|
|
1817
|
+
});
|
|
1818
|
+
});
|
|
1819
|
+
});
|
|
1820
|
+
describe.runIf(plugin.capabilities.includes("commands"))("commands", () => {
|
|
1821
|
+
function language() {
|
|
1822
|
+
if (plugin.commandLanguage === undefined)
|
|
1823
|
+
throw new Error("A plugin that declares commands has no commandLanguage.");
|
|
1824
|
+
return plugin.commandLanguage;
|
|
1825
|
+
}
|
|
1826
|
+
async function rowsOf(command, session) {
|
|
1827
|
+
const result = await session.execute(command);
|
|
1828
|
+
return { columns: result.columns.map((column) => column.name), rows: await collect(result.rows) };
|
|
1829
|
+
}
|
|
1830
|
+
it("givenCommands_whenSplit_thenOneSpanPerLineSkippingComments", () => {
|
|
1831
|
+
for (const script of commandsOf(fixture).scripts) {
|
|
1832
|
+
const spans = language().split(script.text);
|
|
1833
|
+
expect(spans.map((span) => span.sql)).toEqual(script.statements);
|
|
1834
|
+
for (const span of spans)
|
|
1835
|
+
expect(script.text.slice(span.from, span.to)).toBe(span.sql);
|
|
1836
|
+
}
|
|
1837
|
+
});
|
|
1838
|
+
it("givenScalarCommand_whenExecute_thenReplyAndNoColumns", async () => {
|
|
1839
|
+
const { command, text } = commandsOf(fixture).scalar;
|
|
1840
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1841
|
+
const result = await session.execute(command);
|
|
1842
|
+
expect(result.reply?.text).toBe(text);
|
|
1843
|
+
expect(result.columns).toEqual([]);
|
|
1844
|
+
expect(await collect(result.rows)).toEqual([]);
|
|
1845
|
+
});
|
|
1846
|
+
});
|
|
1847
|
+
it("givenArrayCommand_whenExecute_thenIndexValueRows", async () => {
|
|
1848
|
+
const { command, columns, rows } = commandsOf(fixture).array;
|
|
1849
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1850
|
+
expect(await rowsOf(command, session)).toEqual({ columns, rows });
|
|
1851
|
+
});
|
|
1852
|
+
});
|
|
1853
|
+
it("givenMapReply_whenExecute_thenKeyValueRows", async () => {
|
|
1854
|
+
const { command, columns, rows } = commandsOf(fixture).map;
|
|
1855
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1856
|
+
expect(await rowsOf(command, session)).toEqual({ columns, rows });
|
|
1857
|
+
});
|
|
1858
|
+
});
|
|
1859
|
+
it("givenBinaryValue_whenExecute_thenBytesValue", async () => {
|
|
1860
|
+
const { command, hex } = commandsOf(fixture).binary;
|
|
1861
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1862
|
+
const result = await session.execute(command, [], { fullBytes: true });
|
|
1863
|
+
expect(result.reply?.value).toEqual({
|
|
1864
|
+
kind: "bytes",
|
|
1865
|
+
preview: hex,
|
|
1866
|
+
length: hex.length / HEX_DIGITS_PER_BYTE,
|
|
1867
|
+
});
|
|
1868
|
+
});
|
|
1869
|
+
});
|
|
1870
|
+
it("givenReadOnly_whenWriteCommand_thenStatementFailedNamingIt", async () => {
|
|
1871
|
+
const { command, name } = commandsOf(fixture).write;
|
|
1872
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1873
|
+
const failure = await session.execute(command).then(() => undefined, (error) => error);
|
|
1874
|
+
expect(isPluginError(failure) ? failure.code : failure).toBe("statementFailed");
|
|
1875
|
+
expect(failure instanceof Error ? failure.message : "").toContain(name);
|
|
1876
|
+
}, READ_ONLY);
|
|
1877
|
+
});
|
|
1878
|
+
it("givenFlushdb_whenClassify_thenDestructiveConfirmByName", () => {
|
|
1879
|
+
const classified = language().classify(commandsOf(fixture).confirmByName);
|
|
1880
|
+
expect(classified.destructive).not.toBeNull();
|
|
1881
|
+
expect(classified.confirmByName).toBe(true);
|
|
1882
|
+
});
|
|
1883
|
+
it("givenKeysPattern_whenClassify_thenCostlyWithScanFix", async () => {
|
|
1884
|
+
const { command, fixed } = commandsOf(fixture).costly;
|
|
1885
|
+
expect(language().classify(command).costly).toMatch(/\S/);
|
|
1886
|
+
const problems = (await language().inspect?.({ text: command })) ?? [];
|
|
1887
|
+
const fix = problems[0]?.fixes[0];
|
|
1888
|
+
expect(fix).toBeDefined();
|
|
1889
|
+
let text = command;
|
|
1890
|
+
for (const edit of [...(fix?.edits ?? [])].sort((a, b) => b.from - a.from)) {
|
|
1891
|
+
text = text.slice(0, edit.from) + edit.insert + text.slice(edit.to);
|
|
1892
|
+
}
|
|
1893
|
+
expect(text).toBe(fixed);
|
|
1894
|
+
});
|
|
1895
|
+
it("givenCommand_whenComplete_thenNeverRejects", async () => {
|
|
1896
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1897
|
+
for (const text of ["", "h", "HGET ", '"unterminated', "# only a comment"]) {
|
|
1898
|
+
const result = await language().complete({ text, offset: text.length }, session);
|
|
1899
|
+
expect(result.from).toBeLessThanOrEqual(text.length);
|
|
1900
|
+
}
|
|
1901
|
+
const offline = await language().complete({ text: "h", offset: 1 }, null);
|
|
1902
|
+
expect(offline.to).toBe(1);
|
|
1903
|
+
});
|
|
1904
|
+
});
|
|
1905
|
+
});
|
|
1906
|
+
describe.runIf(plugin.capabilities.includes("keyValue"))("keyValue", () => {
|
|
1907
|
+
function keyspaceFixture() {
|
|
1908
|
+
if (fixture.keyspace === undefined)
|
|
1909
|
+
throw new Error("keyValue needs fixture.keyspace");
|
|
1910
|
+
return fixture.keyspace;
|
|
1911
|
+
}
|
|
1912
|
+
function keyspaceOf(session) {
|
|
1913
|
+
if (session.keyspace === undefined)
|
|
1914
|
+
throw new Error("keyValue needs session.keyspace");
|
|
1915
|
+
return session.keyspace;
|
|
1916
|
+
}
|
|
1917
|
+
/** Every key of one pass, and how many batches it took. */
|
|
1918
|
+
async function scanAll(keyspace, request) {
|
|
1919
|
+
const keys = [];
|
|
1920
|
+
let cursor = null;
|
|
1921
|
+
let batches = 0;
|
|
1922
|
+
do {
|
|
1923
|
+
const scan = await keyspace.scan({
|
|
1924
|
+
database: keyspaceFixture().database,
|
|
1925
|
+
cursor,
|
|
1926
|
+
match: request.match,
|
|
1927
|
+
type: request.type ?? null,
|
|
1928
|
+
...(request.count === undefined ? {} : { count: request.count }),
|
|
1929
|
+
});
|
|
1930
|
+
keys.push(...scan.keys);
|
|
1931
|
+
cursor = scan.cursor;
|
|
1932
|
+
batches += 1;
|
|
1933
|
+
} while (cursor !== null && batches < MAX_SCAN_BATCHES);
|
|
1934
|
+
expect(cursor).toBeNull();
|
|
1935
|
+
return { keys, batches };
|
|
1936
|
+
}
|
|
1937
|
+
it("givenKeyspace_whenDatabases_thenFixtureDatabaseCountedAndOneCurrent", async () => {
|
|
1938
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1939
|
+
const databases = await keyspaceOf(session).databases();
|
|
1940
|
+
const own = databases.find((database) => database.index === keyspaceFixture().database);
|
|
1941
|
+
expect(own?.keys).toBeGreaterThanOrEqual(keyspaceFixture().scan.count);
|
|
1942
|
+
expect(databases.filter((database) => database.current)).toHaveLength(1);
|
|
1943
|
+
expect(keyspaceOf(session).delimiter).not.toBe("");
|
|
1944
|
+
});
|
|
1945
|
+
});
|
|
1946
|
+
it("givenKeyspace_whenScanToEnd_thenEveryFixtureKeyOnceAndCursorNull", async () => {
|
|
1947
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1948
|
+
const { match, count, batch } = keyspaceFixture().scan;
|
|
1949
|
+
const { keys, batches } = await scanAll(keyspaceOf(session), { match, count: batch });
|
|
1950
|
+
const names = keys.map((key) => key.text);
|
|
1951
|
+
expect(new Set(names).size).toBe(count);
|
|
1952
|
+
expect(names).toHaveLength(count);
|
|
1953
|
+
expect(batches).toBeGreaterThan(1);
|
|
1954
|
+
});
|
|
1955
|
+
});
|
|
1956
|
+
it("givenMatchAndType_whenScan_thenOnlyMatchingKeys", async () => {
|
|
1957
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1958
|
+
const { match, type, keys } = keyspaceFixture().typed;
|
|
1959
|
+
const found = await scanAll(keyspaceOf(session), { match, type });
|
|
1960
|
+
expect(found.keys.map((key) => key.text).sort()).toEqual([...keys].sort());
|
|
1961
|
+
});
|
|
1962
|
+
});
|
|
1963
|
+
it("givenKeys_whenDescribe_thenTypeExpiryAndMissingInOrder", async () => {
|
|
1964
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1965
|
+
const { typed, expiring, database } = keyspaceFixture();
|
|
1966
|
+
const [first = ""] = typed.keys;
|
|
1967
|
+
const names = [first, expiring, "table-craft:contract:missing"];
|
|
1968
|
+
const infos = await keyspaceOf(session).describe(database, names.map((text) => ({ text, hex: null })));
|
|
1969
|
+
expect(infos.map((info) => info.key.text)).toEqual(names);
|
|
1970
|
+
expect(infos[0]).toMatchObject({ type: typed.type, ttlMs: null });
|
|
1971
|
+
expect(infos[1]?.ttlMs).toBeGreaterThan(0);
|
|
1972
|
+
expect(infos[2]).toMatchObject({ type: null, typeName: null, ttlMs: null });
|
|
1973
|
+
});
|
|
1974
|
+
});
|
|
1975
|
+
it("givenBinaryKeyName_whenScanAndRead_thenHexRoundTrips", async () => {
|
|
1976
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1977
|
+
const { binary, database } = keyspaceFixture();
|
|
1978
|
+
const found = await scanAll(keyspaceOf(session), { match: binary.match });
|
|
1979
|
+
const key = found.keys.find((candidate) => candidate.hex === binary.hex);
|
|
1980
|
+
expect(key?.text).toMatch(/\\x[0-9a-f]{2}/);
|
|
1981
|
+
if (key === undefined)
|
|
1982
|
+
return;
|
|
1983
|
+
const [info] = await keyspaceOf(session).describe(database, [key]);
|
|
1984
|
+
expect(info?.type).toBe(binary.type);
|
|
1985
|
+
const contents = await keyspaceOf(session).read(database, key, { window: READ_WINDOW });
|
|
1986
|
+
expect(contents.details.key).toEqual(key);
|
|
1987
|
+
expect(contents.value).toBe(binary.value);
|
|
1988
|
+
});
|
|
1989
|
+
});
|
|
1990
|
+
/** The key's rows, each cut to its first `width` cells. */
|
|
1991
|
+
async function readRows(session, key, width) {
|
|
1992
|
+
const contents = await keyspaceOf(session).read(keyspaceFixture().database, { text: key, hex: null }, { window: READ_WINDOW });
|
|
1993
|
+
if (contents.result === null)
|
|
1994
|
+
throw new Error(`${key} read no rows`);
|
|
1995
|
+
const rows = await collect(contents.result.rows);
|
|
1996
|
+
return {
|
|
1997
|
+
contents,
|
|
1998
|
+
columns: contents.result.columns.map((column) => column.name),
|
|
1999
|
+
rows: rows.map((row) => row.slice(0, width)),
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
it("givenHash_whenRead_thenFieldValueRows", async () => {
|
|
2003
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2004
|
+
const { hash } = keyspaceFixture();
|
|
2005
|
+
const read = await readRows(session, hash.key, 2);
|
|
2006
|
+
expect(read.columns.slice(0, 2)).toEqual(["field", "value"]);
|
|
2007
|
+
const sorted = (rows) => [...rows].sort((a, b) => JSON.stringify(a[0]).localeCompare(JSON.stringify(b[0])));
|
|
2008
|
+
expect(sorted(read.rows)).toEqual(sorted(hash.rows));
|
|
2009
|
+
expect(read.contents.details).toMatchObject({ type: "hash", length: hash.rows.length });
|
|
2010
|
+
});
|
|
2011
|
+
});
|
|
2012
|
+
it("givenZset_whenRead_thenMemberScoreRowsInRankOrder", async () => {
|
|
2013
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2014
|
+
const { zset } = keyspaceFixture();
|
|
2015
|
+
const read = await readRows(session, zset.key, 3);
|
|
2016
|
+
expect(read.columns).toEqual(["#", "member", "score"]);
|
|
2017
|
+
expect(read.rows.map((row) => row.slice(1))).toEqual(zset.rows);
|
|
2018
|
+
expect(read.rows.map((row) => row[0])).toEqual(zset.rows.map((_, index) => index));
|
|
2019
|
+
});
|
|
2020
|
+
});
|
|
2021
|
+
it("givenStream_whenRead_thenIdAndFieldColumns", async () => {
|
|
2022
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2023
|
+
const { stream } = keyspaceFixture();
|
|
2024
|
+
const read = await readRows(session, stream.key, stream.columns.length);
|
|
2025
|
+
expect(read.columns).toEqual(stream.columns);
|
|
2026
|
+
expect(read.rows).toEqual(stream.rows);
|
|
2027
|
+
});
|
|
2028
|
+
});
|
|
2029
|
+
it("givenMissingKey_whenRead_thenNoTypeAndNoContents", async () => {
|
|
2030
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2031
|
+
const contents = await keyspaceOf(session).read(keyspaceFixture().database, { text: "table-craft:contract:missing", hex: null }, { window: READ_WINDOW });
|
|
2032
|
+
expect(contents).toMatchObject({ details: { type: null }, result: null, value: null });
|
|
2033
|
+
});
|
|
2034
|
+
});
|
|
2035
|
+
/** Runs `use` with the scratch key removed before and after. */
|
|
2036
|
+
async function withScratchKey(use) {
|
|
2037
|
+
const { database, edit } = keyspaceFixture();
|
|
2038
|
+
const key = { text: edit.key, hex: null };
|
|
2039
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2040
|
+
const keyspace = keyspaceOf(session);
|
|
2041
|
+
await keyspace.remove(database, [key]);
|
|
2042
|
+
try {
|
|
2043
|
+
await use(keyspace, {
|
|
2044
|
+
set: (value, original, guard) => keyspace.apply(database, key, [edit.change(value, original)], { type: edit.type, guard }),
|
|
2045
|
+
value: async () => edit.valueOf(await keyspace.read(database, key, { window: READ_WINDOW })),
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
finally {
|
|
2049
|
+
await keyspace.remove(database, [key]);
|
|
2050
|
+
}
|
|
2051
|
+
});
|
|
2052
|
+
}
|
|
2053
|
+
it("givenChange_whenApply_thenValueWritten", async () => {
|
|
2054
|
+
await withScratchKey(async (keyspace, scratch) => {
|
|
2055
|
+
const { edit } = keyspaceFixture();
|
|
2056
|
+
const applied = await scratch.set("one", null, true);
|
|
2057
|
+
expect(await scratch.value()).toBe("one");
|
|
2058
|
+
const options = { type: edit.type, guard: true };
|
|
2059
|
+
expect(applied.commands).toEqual(keyspace.preview({ text: edit.key, hex: null }, [edit.change("one", null)], options));
|
|
2060
|
+
});
|
|
2061
|
+
});
|
|
2062
|
+
it("givenChangedSinceRead_whenApplyGuarded_thenConflictAndNothingWritten", async () => {
|
|
2063
|
+
await withScratchKey(async (_keyspace, scratch) => {
|
|
2064
|
+
await scratch.set("one", null, true);
|
|
2065
|
+
await scratch.set("two", "one", false);
|
|
2066
|
+
expect(await rejectionCode(scratch.set("three", "one", true))).toBe("conflict");
|
|
2067
|
+
expect(await scratch.value()).toBe("two");
|
|
2068
|
+
});
|
|
2069
|
+
});
|
|
2070
|
+
it("givenChangedSinceRead_whenApplyUnguarded_thenWritten", async () => {
|
|
2071
|
+
await withScratchKey(async (_keyspace, scratch) => {
|
|
2072
|
+
await scratch.set("one", null, true);
|
|
2073
|
+
await scratch.set("two", "one", false);
|
|
2074
|
+
await scratch.set("three", "one", false);
|
|
2075
|
+
expect(await scratch.value()).toBe("three");
|
|
2076
|
+
});
|
|
2077
|
+
});
|
|
2078
|
+
});
|
|
2079
|
+
describe.runIf(plugin.capabilities.includes("cancel"))("cancel", () => {
|
|
2080
|
+
function sleepSql() {
|
|
2081
|
+
const sleep = fixture.statements?.sleep;
|
|
2082
|
+
if (sleep === undefined)
|
|
2083
|
+
throw new Error("A plugin that declares cancel needs fixture.statements.sleep.");
|
|
2084
|
+
return sleep(SLEEP_SECONDS);
|
|
2085
|
+
}
|
|
2086
|
+
async function runAndCancel(session) {
|
|
2087
|
+
const started = Date.now();
|
|
2088
|
+
const running = rejectionCode(session.execute(sleepSql()));
|
|
2089
|
+
await delay(CANCEL_DELAY_MS);
|
|
2090
|
+
await session.cancel?.();
|
|
2091
|
+
const code = await running;
|
|
2092
|
+
expect(Date.now() - started).toBeLessThan(CANCEL_DEADLINE_MS);
|
|
2093
|
+
return code;
|
|
2094
|
+
}
|
|
2095
|
+
it("givenSession_whenInspected_thenImplementsCancel", async () => {
|
|
2096
|
+
await withSession(plugin, fixture, (session) => {
|
|
2097
|
+
expect(typeof session.cancel).toBe("function");
|
|
2098
|
+
return Promise.resolve();
|
|
2099
|
+
});
|
|
2100
|
+
});
|
|
2101
|
+
it("givenIdleSession_whenCancelled_thenResolvesAndSessionWorks", async () => {
|
|
2102
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2103
|
+
await session.cancel?.();
|
|
2104
|
+
await expectAlive(session);
|
|
2105
|
+
});
|
|
2106
|
+
});
|
|
2107
|
+
it("givenSleepingStatement_whenCancelled_thenExecuteRejectsWithCancelled", async () => {
|
|
2108
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2109
|
+
expect(await runAndCancel(session)).toBe("cancelled");
|
|
2110
|
+
});
|
|
2111
|
+
});
|
|
2112
|
+
it("givenCancelledStatement_whenNextStatementRun_thenSessionStillWorks", async () => {
|
|
2113
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2114
|
+
await runAndCancel(session);
|
|
2115
|
+
await expectAlive(session);
|
|
2116
|
+
});
|
|
2117
|
+
});
|
|
2118
|
+
});
|
|
2119
|
+
describe.runIf(plugin.capabilities.includes("keepAlive"))("keepAlive", () => {
|
|
2120
|
+
it("givenKeepAlive_whenConnectedWithDelay_thenPingResolvesAndSessionWorks", async () => {
|
|
2121
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2122
|
+
await session.ping?.();
|
|
2123
|
+
await session.ping?.();
|
|
2124
|
+
await expectAlive(session);
|
|
2125
|
+
}, { readOnly: false, keepAliveInitialDelayMs: KEEPALIVE_DELAY_MS });
|
|
2126
|
+
});
|
|
2127
|
+
it("givenClosedSession_whenPinged_thenRejectsWithConnectionLost", async () => {
|
|
2128
|
+
const session = await connect(plugin, fixture.config);
|
|
2129
|
+
await session.close();
|
|
2130
|
+
expect(await rejectionCode(session.ping?.() ?? Promise.resolve())).toBe("connectionLost");
|
|
2131
|
+
});
|
|
2132
|
+
});
|
|
2133
|
+
describe.runIf(plugin.capabilities.includes("serverInfo"))("serverInfo", () => {
|
|
2134
|
+
it("givenEveryNode_whenServerInfo_thenTitledSectionsWithEntries", async () => {
|
|
2135
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2136
|
+
const nodes = (await session.nodes?.()) ?? [];
|
|
2137
|
+
for (const node of [null, ...nodes.map((listed) => listed.id)]) {
|
|
2138
|
+
const info = await session.serverInfo?.(node);
|
|
2139
|
+
expect(info?.sections.length).toBeGreaterThan(0);
|
|
2140
|
+
for (const section of info?.sections ?? [])
|
|
2141
|
+
expect(section.title).not.toBe("");
|
|
2142
|
+
expect(info?.sections.some((section) => section.entries.length > 0)).toBe(true);
|
|
2143
|
+
if (node !== null)
|
|
2144
|
+
expect(info?.node).toBe(node);
|
|
2145
|
+
}
|
|
2146
|
+
});
|
|
2147
|
+
});
|
|
2148
|
+
it("givenSlowLog_whenRead_thenAtMostLimitNewestFirst", async () => {
|
|
2149
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2150
|
+
const entries = (await session.slowLog?.(null, SLOW_LOG_LIMIT)) ?? [];
|
|
2151
|
+
expect(entries.length).toBeLessThanOrEqual(SLOW_LOG_LIMIT);
|
|
2152
|
+
const times = entries.map((entry) => entry.at);
|
|
2153
|
+
expect(times).toEqual([...times].sort().reverse());
|
|
2154
|
+
});
|
|
2155
|
+
});
|
|
2156
|
+
});
|
|
2157
|
+
describe.runIf(plugin.capabilities.includes("sessions"))("sessions", () => {
|
|
2158
|
+
function sessionsFixture() {
|
|
2159
|
+
if (fixture.sessions === undefined)
|
|
2160
|
+
throw new Error("A plugin that declares sessions needs fixture.sessions.");
|
|
2161
|
+
return fixture.sessions;
|
|
2162
|
+
}
|
|
2163
|
+
function sleepSql() {
|
|
2164
|
+
const sleep = fixture.statements?.sleep;
|
|
2165
|
+
if (sleep === undefined)
|
|
2166
|
+
throw new Error("A plugin that declares sessions needs fixture.statements.sleep.");
|
|
2167
|
+
return sleep(SLEEP_SECONDS);
|
|
2168
|
+
}
|
|
2169
|
+
async function withMonitor(work) {
|
|
2170
|
+
const session = await connect(plugin, sessionsFixture().monitorConfig);
|
|
2171
|
+
try {
|
|
2172
|
+
if (session.sessions === undefined)
|
|
2173
|
+
throw new Error("A session of a sessions plugin has no monitor.");
|
|
2174
|
+
await work(session.sessions);
|
|
2175
|
+
}
|
|
2176
|
+
finally {
|
|
2177
|
+
await session.close();
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
function idOf(session) {
|
|
2181
|
+
if (session.serverSessionId === undefined)
|
|
2182
|
+
throw new Error("A session of a sessions plugin has no serverSessionId.");
|
|
2183
|
+
return session.serverSessionId;
|
|
2184
|
+
}
|
|
2185
|
+
async function listedUntil(monitor, id, ready) {
|
|
2186
|
+
const deadline = Date.now() + LISTING_DEADLINE_MS;
|
|
2187
|
+
for (;;) {
|
|
2188
|
+
const row = (await monitor.list()).sessions.find((listed) => listed.id === id);
|
|
2189
|
+
if (ready(row))
|
|
2190
|
+
return row;
|
|
2191
|
+
if (Date.now() > deadline)
|
|
2192
|
+
throw new Error(`Session ${id} never got there; last seen ${JSON.stringify(row)}.`);
|
|
2193
|
+
await delay(LISTING_POLL_MS);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
async function present(monitor, id, ready = (_) => true) {
|
|
2197
|
+
const row = await listedUntil(monitor, id, (listed) => listed !== undefined && ready(listed));
|
|
2198
|
+
if (row === undefined)
|
|
2199
|
+
throw new Error(`Session ${id} is not listed.`);
|
|
2200
|
+
return row;
|
|
2201
|
+
}
|
|
2202
|
+
async function withSleeper(work) {
|
|
2203
|
+
const sleeper = await connect(plugin, fixture.config);
|
|
2204
|
+
const running = rejectionCode(sleeper.execute(sleepSql()).then((result) => collect(result.rows)));
|
|
2205
|
+
try {
|
|
2206
|
+
await work(sleeper, running);
|
|
2207
|
+
}
|
|
2208
|
+
finally {
|
|
2209
|
+
await sleeper.cancel?.();
|
|
2210
|
+
await running;
|
|
2211
|
+
await sleeper.close();
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
it("givenOwnSession_whenList_thenServerSessionIdListed", async () => {
|
|
2215
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2216
|
+
await withMonitor(async (monitor) => {
|
|
2217
|
+
const row = await present(monitor, idOf(session));
|
|
2218
|
+
expect(row.system).toBe(false);
|
|
2219
|
+
expect(row.terminate).not.toBeNull();
|
|
2220
|
+
});
|
|
2221
|
+
});
|
|
2222
|
+
});
|
|
2223
|
+
it("givenSleepingSession_whenList_thenActiveWithQueryText", async () => {
|
|
2224
|
+
await withMonitor(async (monitor) => {
|
|
2225
|
+
await withSleeper(async (sleeper) => {
|
|
2226
|
+
const row = await present(monitor, idOf(sleeper), (listed) => listed.state === "active");
|
|
2227
|
+
expect(row.query).toContain(sessionsFixture().sleepQuery ?? sleepSql());
|
|
2228
|
+
expect(row.queryMs).not.toBeNull();
|
|
2229
|
+
});
|
|
2230
|
+
});
|
|
2231
|
+
});
|
|
2232
|
+
it("givenActiveQuery_whenCancel_thenQueryCancelled", async () => {
|
|
2233
|
+
await withMonitor(async (monitor) => {
|
|
2234
|
+
await withSleeper(async (sleeper, running) => {
|
|
2235
|
+
const row = await present(monitor, idOf(sleeper), (listed) => listed.state === "active");
|
|
2236
|
+
expect(row.cancel).not.toBeNull();
|
|
2237
|
+
const started = Date.now();
|
|
2238
|
+
await monitor.cancelSession({ id: row.id, instance: row.instance });
|
|
2239
|
+
// MySQL's SLEEP answers 1 when killed rather than failing; either way it stops early.
|
|
2240
|
+
expect(["cancelled", "resolved"]).toContain(await running);
|
|
2241
|
+
expect(Date.now() - started).toBeLessThan(CANCEL_DEADLINE_MS);
|
|
2242
|
+
await expectAlive(sleeper);
|
|
2243
|
+
});
|
|
2244
|
+
});
|
|
2245
|
+
});
|
|
2246
|
+
it("givenSession_whenTerminate_thenGone", async () => {
|
|
2247
|
+
const victim = await connect(plugin, fixture.config);
|
|
2248
|
+
try {
|
|
2249
|
+
await withMonitor(async (monitor) => {
|
|
2250
|
+
const row = await present(monitor, idOf(victim));
|
|
2251
|
+
await monitor.terminateSession({ id: row.id, instance: row.instance });
|
|
2252
|
+
await listedUntil(monitor, row.id, (listed) => listed === undefined);
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
finally {
|
|
2256
|
+
await victim.close();
|
|
2257
|
+
}
|
|
2258
|
+
});
|
|
2259
|
+
it("givenMissingSession_whenTerminate_thenRejects", async () => {
|
|
2260
|
+
const victim = await connect(plugin, fixture.config);
|
|
2261
|
+
const id = idOf(victim);
|
|
2262
|
+
await victim.close();
|
|
2263
|
+
await withMonitor(async (monitor) => {
|
|
2264
|
+
await listedUntil(monitor, id, (listed) => listed === undefined);
|
|
2265
|
+
expect(await rejectionCode(monitor.terminateSession({ id, instance: null }))).toBe("statementFailed");
|
|
2266
|
+
});
|
|
2267
|
+
});
|
|
2268
|
+
it.runIf(plugin.capabilities.includes("transactions"))("givenLockWait_whenList_thenBlockedByFilled", async () => {
|
|
2269
|
+
const write = fixture.statements?.write;
|
|
2270
|
+
if (write === undefined)
|
|
2271
|
+
throw new Error("A plugin that declares sessions needs fixture.statements.write.");
|
|
2272
|
+
const holder = await connect(plugin, fixture.config);
|
|
2273
|
+
const waiter = await connect(plugin, fixture.config);
|
|
2274
|
+
let waiting = Promise.resolve();
|
|
2275
|
+
try {
|
|
2276
|
+
await holder.transactions?.setMode("manual");
|
|
2277
|
+
await waiter.transactions?.setMode("manual");
|
|
2278
|
+
await collect((await holder.execute(write)).rows);
|
|
2279
|
+
waiting = waiter.execute(write).then((result) => collect(result.rows));
|
|
2280
|
+
await withMonitor(async (monitor) => {
|
|
2281
|
+
const row = await present(monitor, idOf(waiter), (listed) => listed.state === "blocked");
|
|
2282
|
+
expect(row.blockedBy).toContain(idOf(holder));
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
finally {
|
|
2286
|
+
await holder.transactions?.rollback();
|
|
2287
|
+
await waiting.catch(() => undefined);
|
|
2288
|
+
await waiter.transactions?.rollback();
|
|
2289
|
+
await holder.close();
|
|
2290
|
+
await waiter.close();
|
|
2291
|
+
}
|
|
2292
|
+
});
|
|
2293
|
+
it.runIf(fixture.sessions?.limited !== undefined)("givenNoProcessPrivilege_whenList_thenOwnOnlyWithExplanation", async () => {
|
|
2294
|
+
const limited = sessionsFixture().limited;
|
|
2295
|
+
if (limited === undefined)
|
|
2296
|
+
return;
|
|
2297
|
+
const session = await connect(plugin, limited.config);
|
|
2298
|
+
try {
|
|
2299
|
+
await withMonitor(async (monitor) => {
|
|
2300
|
+
const own = await present(monitor, idOf(session));
|
|
2301
|
+
const listing = await session.sessions?.list();
|
|
2302
|
+
expect(listing?.notes.some((note) => limited.note.test(note))).toBe(true);
|
|
2303
|
+
expect(listing?.sessions.map((row) => row.id)).toContain(own.id);
|
|
2304
|
+
const monitorIds = (await monitor.list()).sessions.map((row) => row.id);
|
|
2305
|
+
const foreign = monitorIds.filter((id) => !listing?.sessions.some((row) => row.id === id));
|
|
2306
|
+
expect(foreign.length).toBeGreaterThan(0);
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
finally {
|
|
2310
|
+
await session.close();
|
|
2311
|
+
}
|
|
2312
|
+
});
|
|
2313
|
+
});
|
|
2314
|
+
describe.runIf(plugin.capabilities.includes("routineCall"))("routineCall", () => {
|
|
2315
|
+
function routinesFixture() {
|
|
2316
|
+
if (fixture.routines === undefined)
|
|
2317
|
+
throw new Error("A plugin that declares routineCall needs fixture.routines.");
|
|
2318
|
+
return fixture.routines;
|
|
2319
|
+
}
|
|
2320
|
+
function introspectorOf(session) {
|
|
2321
|
+
if (session.introspector === undefined)
|
|
2322
|
+
throw new Error("A routineCall plugin needs an introspector.");
|
|
2323
|
+
return session.introspector;
|
|
2324
|
+
}
|
|
2325
|
+
async function seeded(session) {
|
|
2326
|
+
return (await introspectorOf(session).describe(catalogPathOf(fixture))).routines;
|
|
2327
|
+
}
|
|
2328
|
+
/** Runs the call as the console does: each statement split, bound with the call's values, in turn. */
|
|
2329
|
+
async function call(session, routineCase) {
|
|
2330
|
+
const language = plugin.language;
|
|
2331
|
+
const routineCall = language?.routineCall?.bind(language);
|
|
2332
|
+
if (language === undefined || routineCall === undefined) {
|
|
2333
|
+
throw new Error("A routineCall plugin needs language.routineCall.");
|
|
2334
|
+
}
|
|
2335
|
+
const routine = (await seeded(session)).find((candidate) => candidate.name === routineCase.name &&
|
|
2336
|
+
candidate.kind === routineCase.kind &&
|
|
2337
|
+
candidate.signature === routineCase.signature);
|
|
2338
|
+
if (routine === undefined)
|
|
2339
|
+
throw new Error(`The fixture routine ${routineCase.name} is not described.`);
|
|
2340
|
+
const qualified = introspectorOf(session).qualifiedName(catalogPathOf(fixture), routine.name);
|
|
2341
|
+
const { sql, params } = routineCall(routine, qualified, routineCase.args);
|
|
2342
|
+
let rows = [];
|
|
2343
|
+
for (const span of language.split(sql)) {
|
|
2344
|
+
const statement = language.bind(span.sql, params);
|
|
2345
|
+
rows = await collect((await session.execute(statement.sql, statement.params)).rows);
|
|
2346
|
+
}
|
|
2347
|
+
return rows;
|
|
2348
|
+
}
|
|
2349
|
+
it("givenFixtureRoutines_whenDescribe_thenParametersAndModes", async () => {
|
|
2350
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2351
|
+
expect(await seeded(session)).toEqual(routinesFixture().expected);
|
|
2352
|
+
});
|
|
2353
|
+
});
|
|
2354
|
+
it("givenFunction_whenCall_thenResultReturned", async () => {
|
|
2355
|
+
const routineCase = routinesFixture().function;
|
|
2356
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2357
|
+
expect(await call(session, routineCase)).toEqual(routineCase.rows);
|
|
2358
|
+
});
|
|
2359
|
+
});
|
|
2360
|
+
it("givenNullToggle_whenRun_thenBindsNull", async () => {
|
|
2361
|
+
const routineCase = routinesFixture().nullArgument;
|
|
2362
|
+
expect(routineCase.args.some((argument) => argument.kind === "value" && argument.value === null)).toBe(true);
|
|
2363
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2364
|
+
expect(await call(session, routineCase)).toEqual(routineCase.rows);
|
|
2365
|
+
});
|
|
2366
|
+
});
|
|
2367
|
+
it("givenProcedureOut_whenCall_thenOutValueReturned", async () => {
|
|
2368
|
+
const routineCase = routinesFixture().procedureOut;
|
|
2369
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2370
|
+
expect(await call(session, routineCase)).toEqual(routineCase.rows);
|
|
2371
|
+
});
|
|
2372
|
+
});
|
|
2373
|
+
it.runIf(fixture.routines?.defaulted !== undefined)("givenDefaultParameter_whenOmitted_thenUsesDefault", async () => {
|
|
2374
|
+
const routineCase = routinesFixture().defaulted;
|
|
2375
|
+
if (routineCase === undefined)
|
|
2376
|
+
return;
|
|
2377
|
+
expect(routineCase.args.some((argument) => argument.kind === "default")).toBe(true);
|
|
2378
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2379
|
+
expect(await call(session, routineCase)).toEqual(routineCase.rows);
|
|
2380
|
+
});
|
|
2381
|
+
});
|
|
2382
|
+
it("givenDefaultWithoutOne_whenCall_thenStatementFailed", async () => {
|
|
2383
|
+
const routineCase = routinesFixture().function;
|
|
2384
|
+
const routineCall = plugin.language?.routineCall?.bind(plugin.language);
|
|
2385
|
+
await withSession(plugin, fixture, async (session) => {
|
|
2386
|
+
const routine = (await seeded(session)).find((candidate) => candidate.name === routineCase.name && candidate.signature === routineCase.signature);
|
|
2387
|
+
const required = routine?.parameters.findIndex((parameter) => !parameter.hasDefault && parameter.mode !== "out");
|
|
2388
|
+
expect(required).toBeGreaterThanOrEqual(0);
|
|
2389
|
+
if (routine === undefined || routineCall === undefined || required === undefined)
|
|
2390
|
+
return;
|
|
2391
|
+
const args = routineCase.args.map((argument, index) => index === required ? { kind: "default" } : argument);
|
|
2392
|
+
const attempt = Promise.resolve().then(() => routineCall(routine, routine.name, args));
|
|
2393
|
+
expect(await rejectionCode(attempt)).toBe("statementFailed");
|
|
2394
|
+
});
|
|
2395
|
+
});
|
|
2396
|
+
});
|
|
2397
|
+
});
|
|
2398
|
+
}
|