@tablecraft-app/plugin-api 0.0.0-stage → 1.1.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 +58 -0
- package/dist/errors.js +106 -0
- package/dist/explain.d.ts +58 -0
- package/dist/explain.js +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/inspection.d.ts +38 -0
- package/dist/inspection.js +21 -0
- package/dist/manifest.d.ts +186 -0
- package/dist/manifest.js +176 -0
- package/dist/objects.d.ts +140 -0
- package/dist/objects.js +2 -0
- package/dist/plugin.d.ts +460 -0
- package/dist/plugin.js +49 -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 +177 -0
- package/dist/testing/index.js +2070 -0
- package/dist/tls.d.ts +33 -0
- package/dist/tls.js +118 -0
- package/dist/url.d.ts +52 -0
- package/dist/url.js +219 -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,36 @@
|
|
|
1
|
+
/** How many "Did you mean" fixes an unresolved name offers. */
|
|
2
|
+
export const MAX_SUGGESTIONS = 3;
|
|
3
|
+
/** A candidate this close is always offered; longer names allow a third of their length. */
|
|
4
|
+
const MIN_SUGGESTION_DISTANCE = 2;
|
|
5
|
+
const SUGGESTION_LENGTH_RATIO = 3;
|
|
6
|
+
/** The edit distance between two names, compared case-blind. */
|
|
7
|
+
export function editDistance(left, right) {
|
|
8
|
+
const a = left.toLowerCase();
|
|
9
|
+
const b = right.toLowerCase();
|
|
10
|
+
let previous = Array.from({ length: b.length + 1 }, (_, at) => at);
|
|
11
|
+
for (let row = 1; row <= a.length; row += 1) {
|
|
12
|
+
const current = [row];
|
|
13
|
+
for (let column = 1; column <= b.length; column += 1) {
|
|
14
|
+
const substitution = (previous[column - 1] ?? 0) + (a.charAt(row - 1) === b.charAt(column - 1) ? 0 : 1);
|
|
15
|
+
current.push(Math.min((previous[column] ?? 0) + 1, (current[column - 1] ?? 0) + 1, substitution));
|
|
16
|
+
}
|
|
17
|
+
previous = current;
|
|
18
|
+
}
|
|
19
|
+
return previous[b.length] ?? 0;
|
|
20
|
+
}
|
|
21
|
+
/** The closest `candidates` to `name`, nearest first, within max(2, a third of its length). */
|
|
22
|
+
export function suggestions(name, candidates) {
|
|
23
|
+
const threshold = Math.max(MIN_SUGGESTION_DISTANCE, Math.floor(name.length / SUGGESTION_LENGTH_RATIO));
|
|
24
|
+
const scored = new Map();
|
|
25
|
+
for (const candidate of candidates) {
|
|
26
|
+
if (scored.has(candidate) || candidate.toLowerCase() === name.toLowerCase())
|
|
27
|
+
continue;
|
|
28
|
+
const distance = editDistance(name, candidate);
|
|
29
|
+
if (distance <= threshold)
|
|
30
|
+
scored.set(candidate, distance);
|
|
31
|
+
}
|
|
32
|
+
return [...scored]
|
|
33
|
+
.sort(([leftName, left], [rightName, right]) => left - right || leftName.localeCompare(rightName))
|
|
34
|
+
.slice(0, MAX_SUGGESTIONS)
|
|
35
|
+
.map(([candidate]) => candidate);
|
|
36
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** What a dialect needs the shared splitter and classifier to know (decision 023). */
|
|
2
|
+
export interface SqlDialectOptions {
|
|
3
|
+
/** PostgreSQL `$tag$ … $tag$` strings. */
|
|
4
|
+
readonly dollarQuotes?: boolean;
|
|
5
|
+
/** MySQL `'\''`: a backslash escapes the next character in strings and double-quoted text. */
|
|
6
|
+
readonly backslashEscapes?: boolean;
|
|
7
|
+
/** The MySQL client's `DELIMITER //` line, which changes what ends a statement. */
|
|
8
|
+
readonly delimiterCommand?: boolean;
|
|
9
|
+
/** PostgreSQL block comments nest: a comment inside a comment needs its own close. */
|
|
10
|
+
readonly nestedBlockComments?: boolean;
|
|
11
|
+
/** MySQL `# comment`; PostgreSQL uses `#` as an operator. */
|
|
12
|
+
readonly hashComments?: boolean;
|
|
13
|
+
/** Keywords that open a body holding semicolons, closed by `END`, inside `CREATE TRIGGER`, `FUNCTION` and the like. */
|
|
14
|
+
readonly blockKeywords?: readonly string[];
|
|
15
|
+
/** The statements that move the session's current namespace in this dialect (plan 022). */
|
|
16
|
+
readonly namespaceStatements?: readonly NamespaceStatementForm[];
|
|
17
|
+
/** SQL Server `[name]`, where `]]` is a `]` inside the name (decision 100). */
|
|
18
|
+
readonly bracketIdentifiers?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* A line that ends a batch and belongs to no statement: SQL Server's `GO` (optionally `GO 3`) or
|
|
21
|
+
* Oracle's `/`. A routine definition, and under `slash` an anonymous block, keeps its own `;` and
|
|
22
|
+
* runs to that line (decision 100).
|
|
23
|
+
*/
|
|
24
|
+
readonly batchSeparator?: BatchSeparator;
|
|
25
|
+
/** Oracle `q'[…]'` strings, whose delimiter is chosen per string. */
|
|
26
|
+
readonly qQuotes?: boolean;
|
|
27
|
+
/** `N'…'` national character strings. */
|
|
28
|
+
readonly nationalStrings?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export type BatchSeparator = "go" | "slash";
|
|
31
|
+
/**
|
|
32
|
+
* `use` is MySQL's `USE db`; `searchPath` is PostgreSQL's `SET [SESSION | LOCAL] search_path`,
|
|
33
|
+
* `SET SCHEMA` and `RESET search_path` / `RESET ALL`.
|
|
34
|
+
*/
|
|
35
|
+
export type NamespaceStatementForm = "use" | "searchPath";
|
|
36
|
+
export type TokenKind = "word" | "quoted" | "string" | "comment" | "symbol" | "delimiter";
|
|
37
|
+
export interface Token {
|
|
38
|
+
readonly kind: TokenKind;
|
|
39
|
+
readonly from: number;
|
|
40
|
+
readonly to: number;
|
|
41
|
+
}
|
|
42
|
+
export declare const DEFAULT_DELIMITER = ";";
|
|
43
|
+
/** A hand-written scanner: it knows quoting and comments, never grammar, so it cannot reject a statement. */
|
|
44
|
+
export declare class Lexer {
|
|
45
|
+
#private;
|
|
46
|
+
readonly text: string;
|
|
47
|
+
readonly options: SqlDialectOptions;
|
|
48
|
+
position: number;
|
|
49
|
+
constructor(text: string, options: SqlDialectOptions);
|
|
50
|
+
/** The next token after whitespace, or null at the end. `delimiter` ends a statement wherever it starts outside quotes. */
|
|
51
|
+
next(delimiter?: string): Token | null;
|
|
52
|
+
}
|
|
53
|
+
/** A batch separator line: where it ends, and `GO <n>`'s count. */
|
|
54
|
+
export interface SeparatorLine {
|
|
55
|
+
readonly to: number;
|
|
56
|
+
readonly repeat: number | undefined;
|
|
57
|
+
}
|
|
58
|
+
/** The separator line `token` starts, or null when `token` is not alone on its line as one. */
|
|
59
|
+
export declare function separatorLineAt(text: string, token: Token, separator: BatchSeparator): SeparatorLine | null;
|
|
60
|
+
/** Every token of `text` but comments, as one dialect reads it. */
|
|
61
|
+
export declare function significantTokens(text: string, options: SqlDialectOptions): Token[];
|
|
62
|
+
export declare function wordOf(text: string, token: Token): string;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
export const DEFAULT_DELIMITER = ";";
|
|
2
|
+
const NEWLINE = "\n";
|
|
3
|
+
const FIRST_NON_ASCII = 0x80;
|
|
4
|
+
const DOLLAR_TAG = /\$(?:[A-Za-z_\u0080-\uffff][A-Za-z0-9_\u0080-\uffff]*)?\$/y;
|
|
5
|
+
/** PostgreSQL's `E'…'` string, where backslashes escape whatever the dialect's default. */
|
|
6
|
+
const ESCAPE_STRING_PREFIX = /^[eE]$/;
|
|
7
|
+
const NATIONAL_STRING_PREFIX = /^[nN]$/;
|
|
8
|
+
/** Oracle's `q'…'` and `Nq'…'`. */
|
|
9
|
+
const Q_QUOTE_PREFIX = /^[nN]?[qQ]$/;
|
|
10
|
+
const Q_QUOTE_PAIRS = new Map([
|
|
11
|
+
["[", "]"],
|
|
12
|
+
["{", "}"],
|
|
13
|
+
["(", ")"],
|
|
14
|
+
["<", ">"],
|
|
15
|
+
]);
|
|
16
|
+
const STRING_QUOTE = "'";
|
|
17
|
+
const BRACKET_OPEN = "[";
|
|
18
|
+
const BRACKET_CLOSE = "]";
|
|
19
|
+
function isSpace(char) {
|
|
20
|
+
return char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f" || char === "\v";
|
|
21
|
+
}
|
|
22
|
+
function isWordChar(char) {
|
|
23
|
+
const code = char.charCodeAt(0);
|
|
24
|
+
if (code >= FIRST_NON_ASCII)
|
|
25
|
+
return true;
|
|
26
|
+
return ((char >= "a" && char <= "z") ||
|
|
27
|
+
(char >= "A" && char <= "Z") ||
|
|
28
|
+
(char >= "0" && char <= "9") ||
|
|
29
|
+
char === "_" ||
|
|
30
|
+
char === "$");
|
|
31
|
+
}
|
|
32
|
+
/** A hand-written scanner: it knows quoting and comments, never grammar, so it cannot reject a statement. */
|
|
33
|
+
export class Lexer {
|
|
34
|
+
text;
|
|
35
|
+
options;
|
|
36
|
+
position = 0;
|
|
37
|
+
constructor(text, options) {
|
|
38
|
+
this.text = text;
|
|
39
|
+
this.options = options;
|
|
40
|
+
}
|
|
41
|
+
/** The next token after whitespace, or null at the end. `delimiter` ends a statement wherever it starts outside quotes. */
|
|
42
|
+
next(delimiter = DEFAULT_DELIMITER) {
|
|
43
|
+
const text = this.text;
|
|
44
|
+
let at = this.position;
|
|
45
|
+
while (at < text.length && isSpace(text.charAt(at)))
|
|
46
|
+
at += 1;
|
|
47
|
+
if (at >= text.length) {
|
|
48
|
+
this.position = at;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const char = text.charAt(at);
|
|
52
|
+
const following = text.charAt(at + 1);
|
|
53
|
+
let token;
|
|
54
|
+
if (char === "-" && following === "-")
|
|
55
|
+
token = this.#lineComment(at);
|
|
56
|
+
else if (char === "#" && this.options.hashComments === true)
|
|
57
|
+
token = this.#lineComment(at);
|
|
58
|
+
else if (char === "/" && following === "*")
|
|
59
|
+
token = this.#blockComment(at);
|
|
60
|
+
else if (text.startsWith(delimiter, at))
|
|
61
|
+
token = { kind: "delimiter", from: at, to: at + delimiter.length };
|
|
62
|
+
else if (char === "'")
|
|
63
|
+
token = this.#quoted(at, "'", "string", this.options.backslashEscapes === true);
|
|
64
|
+
else if (char === '"')
|
|
65
|
+
token = this.#quoted(at, '"', "quoted", this.options.backslashEscapes === true);
|
|
66
|
+
else if (char === "`")
|
|
67
|
+
token = this.#quoted(at, "`", "quoted", false);
|
|
68
|
+
else if (char === BRACKET_OPEN && this.options.bracketIdentifiers === true)
|
|
69
|
+
token = this.#bracketed(at);
|
|
70
|
+
else if (char === "$" && this.options.dollarQuotes === true && this.#dollarTag(at) !== null) {
|
|
71
|
+
token = this.#dollarQuoted(at, this.#dollarTag(at) ?? "");
|
|
72
|
+
}
|
|
73
|
+
else if (isWordChar(char))
|
|
74
|
+
token = this.#word(at, delimiter);
|
|
75
|
+
else
|
|
76
|
+
token = { kind: "symbol", from: at, to: at + 1 };
|
|
77
|
+
this.position = token.to;
|
|
78
|
+
return token;
|
|
79
|
+
}
|
|
80
|
+
#lineComment(from) {
|
|
81
|
+
const end = this.text.indexOf(NEWLINE, from);
|
|
82
|
+
return { kind: "comment", from, to: end < 0 ? this.text.length : end };
|
|
83
|
+
}
|
|
84
|
+
#blockComment(from) {
|
|
85
|
+
const text = this.text;
|
|
86
|
+
const nested = this.options.nestedBlockComments === true;
|
|
87
|
+
let depth = 1;
|
|
88
|
+
let at = from + 2;
|
|
89
|
+
while (at < text.length && depth > 0) {
|
|
90
|
+
if (text.startsWith("*/", at)) {
|
|
91
|
+
depth -= 1;
|
|
92
|
+
at += 2;
|
|
93
|
+
}
|
|
94
|
+
else if (nested && text.startsWith("/*", at)) {
|
|
95
|
+
depth += 1;
|
|
96
|
+
at += 2;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
at += 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { kind: "comment", from, to: Math.min(at, text.length) };
|
|
103
|
+
}
|
|
104
|
+
/** A doubled quote is the quote itself; an unterminated one runs to the end of the text. */
|
|
105
|
+
#quoted(from, quote, kind, backslashEscapes) {
|
|
106
|
+
const text = this.text;
|
|
107
|
+
let at = from + 1;
|
|
108
|
+
while (at < text.length) {
|
|
109
|
+
const char = text.charAt(at);
|
|
110
|
+
if (backslashEscapes && char === "\\") {
|
|
111
|
+
at += 2;
|
|
112
|
+
}
|
|
113
|
+
else if (char === quote) {
|
|
114
|
+
if (text.charAt(at + 1) !== quote)
|
|
115
|
+
return { kind, from, to: at + 1 };
|
|
116
|
+
at += 2;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
at += 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { kind, from, to: text.length };
|
|
123
|
+
}
|
|
124
|
+
/** `]]` is a `]` inside the name; `[` needs no escape. */
|
|
125
|
+
#bracketed(from) {
|
|
126
|
+
const text = this.text;
|
|
127
|
+
let at = from + 1;
|
|
128
|
+
while (at < text.length) {
|
|
129
|
+
if (text.charAt(at) === BRACKET_CLOSE) {
|
|
130
|
+
if (text.charAt(at + 1) !== BRACKET_CLOSE)
|
|
131
|
+
return { kind: "quoted", from, to: at + 1 };
|
|
132
|
+
at += 2;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
at += 1;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { kind: "quoted", from, to: text.length };
|
|
139
|
+
}
|
|
140
|
+
/** `q'<open>…<close>'`, where `[`, `{`, `(` and `<` close with their pair and any other character with itself. */
|
|
141
|
+
#qQuoted(from, quote) {
|
|
142
|
+
const open = this.text.charAt(quote + 1);
|
|
143
|
+
if (open === "" || isSpace(open))
|
|
144
|
+
return null;
|
|
145
|
+
const terminator = (Q_QUOTE_PAIRS.get(open) ?? open) + STRING_QUOTE;
|
|
146
|
+
const end = this.text.indexOf(terminator, quote + 2);
|
|
147
|
+
return { kind: "string", from, to: end < 0 ? this.text.length : end + terminator.length };
|
|
148
|
+
}
|
|
149
|
+
#dollarTag(at) {
|
|
150
|
+
if (at > 0 && isWordChar(this.text.charAt(at - 1)))
|
|
151
|
+
return null;
|
|
152
|
+
DOLLAR_TAG.lastIndex = at;
|
|
153
|
+
return DOLLAR_TAG.exec(this.text)?.[0] ?? null;
|
|
154
|
+
}
|
|
155
|
+
#dollarQuoted(from, tag) {
|
|
156
|
+
const end = this.text.indexOf(tag, from + tag.length);
|
|
157
|
+
return { kind: "string", from, to: end < 0 ? this.text.length : end + tag.length };
|
|
158
|
+
}
|
|
159
|
+
#word(from, delimiter) {
|
|
160
|
+
const text = this.text;
|
|
161
|
+
const customDelimiter = delimiter !== DEFAULT_DELIMITER;
|
|
162
|
+
let at = from;
|
|
163
|
+
while (at < text.length && isWordChar(text.charAt(at))) {
|
|
164
|
+
if (customDelimiter && at > from && text.startsWith(delimiter, at))
|
|
165
|
+
break;
|
|
166
|
+
at += 1;
|
|
167
|
+
}
|
|
168
|
+
if (text.charAt(at) !== STRING_QUOTE)
|
|
169
|
+
return { kind: "word", from, to: at };
|
|
170
|
+
const prefix = text.slice(from, at);
|
|
171
|
+
if (ESCAPE_STRING_PREFIX.test(prefix)) {
|
|
172
|
+
const string = this.#quoted(at, STRING_QUOTE, "string", true);
|
|
173
|
+
return { kind: "string", from, to: string.to };
|
|
174
|
+
}
|
|
175
|
+
if (this.options.qQuotes === true && Q_QUOTE_PREFIX.test(prefix)) {
|
|
176
|
+
const string = this.#qQuoted(from, at);
|
|
177
|
+
if (string !== null)
|
|
178
|
+
return string;
|
|
179
|
+
}
|
|
180
|
+
if (this.options.nationalStrings === true && NATIONAL_STRING_PREFIX.test(prefix)) {
|
|
181
|
+
const string = this.#quoted(at, STRING_QUOTE, "string", this.options.backslashEscapes === true);
|
|
182
|
+
return { kind: "string", from, to: string.to };
|
|
183
|
+
}
|
|
184
|
+
return { kind: "word", from, to: at };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const GO = "GO";
|
|
188
|
+
const SLASH = "/";
|
|
189
|
+
/** What may follow `GO` on its line: a count, then a comment, as `sqlcmd` reads it. */
|
|
190
|
+
const GO_REST = /^(?:[ \t]+([1-9]\d*))?[ \t]*(?:--.*)?\r?$/;
|
|
191
|
+
const SLASH_REST = /^[ \t]*\r?$/;
|
|
192
|
+
const LINE_INDENT = /^[ \t]*$/;
|
|
193
|
+
/** The separator line `token` starts, or null when `token` is not alone on its line as one. */
|
|
194
|
+
export function separatorLineAt(text, token, separator) {
|
|
195
|
+
const lineStart = text.lastIndexOf(NEWLINE, token.from - 1) + 1;
|
|
196
|
+
if (!LINE_INDENT.test(text.slice(lineStart, token.from)))
|
|
197
|
+
return null;
|
|
198
|
+
const newline = text.indexOf(NEWLINE, token.to);
|
|
199
|
+
const lineEnd = newline < 0 ? text.length : newline;
|
|
200
|
+
const rest = text.slice(token.to, lineEnd);
|
|
201
|
+
const word = text.slice(token.from, token.to);
|
|
202
|
+
if (separator === "slash") {
|
|
203
|
+
return token.kind === "symbol" && word === SLASH && SLASH_REST.test(rest)
|
|
204
|
+
? { to: lineEnd, repeat: undefined }
|
|
205
|
+
: null;
|
|
206
|
+
}
|
|
207
|
+
if (token.kind !== "word" || word.toUpperCase() !== GO)
|
|
208
|
+
return null;
|
|
209
|
+
const match = GO_REST.exec(rest);
|
|
210
|
+
if (match === null)
|
|
211
|
+
return null;
|
|
212
|
+
const count = match[1];
|
|
213
|
+
return { to: lineEnd, repeat: count === undefined ? undefined : Number(count) };
|
|
214
|
+
}
|
|
215
|
+
/** Every token of `text` but comments, as one dialect reads it. */
|
|
216
|
+
export function significantTokens(text, options) {
|
|
217
|
+
const lexer = new Lexer(text, options);
|
|
218
|
+
const tokens = [];
|
|
219
|
+
for (let token = lexer.next(); token !== null; token = lexer.next()) {
|
|
220
|
+
if (token.kind !== "comment")
|
|
221
|
+
tokens.push(token);
|
|
222
|
+
}
|
|
223
|
+
return tokens;
|
|
224
|
+
}
|
|
225
|
+
export function wordOf(text, token) {
|
|
226
|
+
return text.slice(token.from, token.to).toUpperCase();
|
|
227
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Token } from "./tokens.js";
|
|
2
|
+
export interface Word {
|
|
3
|
+
readonly word: string;
|
|
4
|
+
/** Parentheses around it; 0 is the statement's own level. */
|
|
5
|
+
readonly depth: number;
|
|
6
|
+
}
|
|
7
|
+
/** The statement's words, upper-cased, each with its parenthesis depth. */
|
|
8
|
+
export declare function words(sql: string, tokens: readonly Token[]): Word[];
|
|
9
|
+
/** The verb that decides what a `WITH …` statement does: the first one outside the CTEs' parentheses. */
|
|
10
|
+
export declare function mainVerb(list: readonly Word[]): number;
|
|
11
|
+
/** MySQL reads `DESCRIBE` and `DESC` before a statement as `EXPLAIN`, `ANALYZE` included. */
|
|
12
|
+
export declare const EXPLAIN_VERBS: ReadonlySet<string>;
|
|
13
|
+
export interface ExplainPrefix {
|
|
14
|
+
/** Whether the statement runs: `ANALYZE` bare, or on inside the parenthesised options. */
|
|
15
|
+
readonly analyze: boolean;
|
|
16
|
+
/** The index in the word list where the explained statement starts. */
|
|
17
|
+
readonly statementAt: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Reads `EXPLAIN [ANALYZE | ANALYSE] [VERBOSE] [( … )] [FORMAT = x] [QUERY PLAN] <statement>`
|
|
21
|
+
* (decision 031); null when the list does not start with `EXPLAIN`, `DESCRIBE` or `DESC`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function explainPrefix(list: readonly Word[]): ExplainPrefix | null;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { wordOf } from "./tokens.js";
|
|
2
|
+
const OPEN_PAREN = "(";
|
|
3
|
+
const CLOSE_PAREN = ")";
|
|
4
|
+
const WITH = "WITH";
|
|
5
|
+
const MAIN_VERBS = new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "MERGE"]);
|
|
6
|
+
/** The statement's words, upper-cased, each with its parenthesis depth. */
|
|
7
|
+
export function words(sql, tokens) {
|
|
8
|
+
const found = [];
|
|
9
|
+
let depth = 0;
|
|
10
|
+
for (const token of tokens) {
|
|
11
|
+
if (token.kind === "symbol") {
|
|
12
|
+
const char = sql.charAt(token.from);
|
|
13
|
+
if (char === OPEN_PAREN)
|
|
14
|
+
depth += 1;
|
|
15
|
+
else if (char === CLOSE_PAREN)
|
|
16
|
+
depth = Math.max(0, depth - 1);
|
|
17
|
+
}
|
|
18
|
+
else if (token.kind === "word") {
|
|
19
|
+
found.push({ word: wordOf(sql, token), depth });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return found;
|
|
23
|
+
}
|
|
24
|
+
/** The verb that decides what a `WITH …` statement does: the first one outside the CTEs' parentheses. */
|
|
25
|
+
export function mainVerb(list) {
|
|
26
|
+
if (list[0]?.word !== WITH)
|
|
27
|
+
return 0;
|
|
28
|
+
const index = list.findIndex((entry, at) => at > 0 && entry.depth === 0 && MAIN_VERBS.has(entry.word));
|
|
29
|
+
return index < 0 ? 0 : index;
|
|
30
|
+
}
|
|
31
|
+
/** MySQL reads `DESCRIBE` and `DESC` before a statement as `EXPLAIN`, `ANALYZE` included. */
|
|
32
|
+
export const EXPLAIN_VERBS = new Set(["EXPLAIN", "DESCRIBE", "DESC"]);
|
|
33
|
+
const ANALYZE_WORDS = new Set(["ANALYZE", "ANALYSE"]);
|
|
34
|
+
/** PostgreSQL's boolean option values that turn `ANALYZE` off inside `EXPLAIN ( … )`. */
|
|
35
|
+
const FALSE_WORDS = new Set(["FALSE", "OFF", "0"]);
|
|
36
|
+
/** Words between `EXPLAIN` and the statement that take no value. */
|
|
37
|
+
const FLAG_WORDS = new Set(["VERBOSE", "QUERY", "PLAN", "EXTENDED", "PARTITIONS"]);
|
|
38
|
+
const FORMAT = "FORMAT";
|
|
39
|
+
/**
|
|
40
|
+
* Reads `EXPLAIN [ANALYZE | ANALYSE] [VERBOSE] [( … )] [FORMAT = x] [QUERY PLAN] <statement>`
|
|
41
|
+
* (decision 031); null when the list does not start with `EXPLAIN`, `DESCRIBE` or `DESC`.
|
|
42
|
+
*/
|
|
43
|
+
export function explainPrefix(list) {
|
|
44
|
+
const first = list[0]?.word;
|
|
45
|
+
if (first === undefined || !EXPLAIN_VERBS.has(first))
|
|
46
|
+
return null;
|
|
47
|
+
let analyze = false;
|
|
48
|
+
let at = 1;
|
|
49
|
+
for (let entry = list[at]; entry !== undefined; entry = list[at]) {
|
|
50
|
+
if (entry.depth > 0) {
|
|
51
|
+
if (ANALYZE_WORDS.has(entry.word)) {
|
|
52
|
+
const value = list[at + 1];
|
|
53
|
+
analyze = !(value !== undefined && value.depth > 0 && FALSE_WORDS.has(value.word));
|
|
54
|
+
}
|
|
55
|
+
at += 1;
|
|
56
|
+
}
|
|
57
|
+
else if (ANALYZE_WORDS.has(entry.word)) {
|
|
58
|
+
analyze = true;
|
|
59
|
+
at += 1;
|
|
60
|
+
}
|
|
61
|
+
else if (entry.word === FORMAT) {
|
|
62
|
+
at += 2;
|
|
63
|
+
}
|
|
64
|
+
else if (FLAG_WORDS.has(entry.word)) {
|
|
65
|
+
at += 1;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { analyze, statementAt: Math.min(at, list.length) };
|
|
72
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { z, type DatasourcePlugin, type PluginManifest, type Routine, type RoutineArgument, type Row, type ServerMessage } from "@tablecraft-app/plugin-api";
|
|
2
|
+
export interface ContractFixture {
|
|
3
|
+
/** The plugin's parsed `table-craft-plugin.json`, whose connection form the suite checks. */
|
|
4
|
+
readonly manifest: PluginManifest;
|
|
5
|
+
/** Configuration that connects to a reachable, writable database seeded with `TYPE_PROBE_TABLE`. */
|
|
6
|
+
readonly config: unknown;
|
|
7
|
+
/** The same database with a wrong password; omitted by plugins that do not authenticate. */
|
|
8
|
+
readonly wrongPasswordConfig?: unknown;
|
|
9
|
+
readonly statements?: {
|
|
10
|
+
/** A statement that runs for `seconds`; required when the plugin declares `cancel`. */
|
|
11
|
+
readonly sleep?: (seconds: number) => string;
|
|
12
|
+
/**
|
|
13
|
+
* A write that succeeds on a writable session and leaves the fixture unchanged, such as an
|
|
14
|
+
* update setting a column to itself; the read-only case is skipped without one.
|
|
15
|
+
*/
|
|
16
|
+
readonly write?: string;
|
|
17
|
+
/**
|
|
18
|
+
* A statement the server answers with a warning or notice, and the level and code of the
|
|
19
|
+
* first one; required when the plugin declares `serverMessages`.
|
|
20
|
+
*/
|
|
21
|
+
readonly message?: {
|
|
22
|
+
readonly sql: string;
|
|
23
|
+
readonly level: ServerMessage["level"];
|
|
24
|
+
readonly code: string | null;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* A statement with a syntax error and the 0-based offset the plugin's `PluginError.position`
|
|
28
|
+
* must resolve to; leave it out for a database that cannot locate one.
|
|
29
|
+
*/
|
|
30
|
+
readonly syntaxError?: {
|
|
31
|
+
readonly sql: string;
|
|
32
|
+
readonly offset: number;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
readonly typeProbe: {
|
|
36
|
+
/** The rows of `SELECT * FROM type_probe ORDER BY id`, as wire values. */
|
|
37
|
+
readonly expected: readonly Row[];
|
|
38
|
+
/**
|
|
39
|
+
* Creates `TYPE_PROBE_COPY_TABLE` with `type_probe`'s columns and declared types; the literal
|
|
40
|
+
* round trip creates and drops it. `CREATE TABLE … AS` will not do where it drops declared types.
|
|
41
|
+
*/
|
|
42
|
+
readonly copyTable: string;
|
|
43
|
+
};
|
|
44
|
+
/** Scripts only this dialect splits, and the statements `language.split` must find in each. */
|
|
45
|
+
readonly scripts?: readonly SplitCase[];
|
|
46
|
+
/** Required when the plugin declares `schemaTree`. */
|
|
47
|
+
readonly catalog?: {
|
|
48
|
+
/** The namespace the fixture tables live in, which is also the connection's current one. */
|
|
49
|
+
readonly path: readonly string[];
|
|
50
|
+
/** An identifier that needs quoting, and what `qualifiedName(path, name)` must return for it. */
|
|
51
|
+
readonly quoted: {
|
|
52
|
+
readonly name: string;
|
|
53
|
+
readonly qualified: string;
|
|
54
|
+
};
|
|
55
|
+
/** `LINE_ITEMS_TABLE` as the dialect writes it in a statement, which completion must insert. */
|
|
56
|
+
readonly lineItems: string;
|
|
57
|
+
/** The name `orders`' check constraint carries, which its definition must show. */
|
|
58
|
+
readonly ordersCheck: string;
|
|
59
|
+
/** `customers`' table comment; null where the dialect has no comments. */
|
|
60
|
+
readonly customersComment: string | null;
|
|
61
|
+
};
|
|
62
|
+
/** Required when the plugin declares `currentNamespace`, which also needs `catalog`. */
|
|
63
|
+
readonly namespaceSwitch?: {
|
|
64
|
+
/** A statement that makes another namespace current, such as `USE archive`. */
|
|
65
|
+
readonly statement: string;
|
|
66
|
+
/** The path `currentNamespace` reports after it. */
|
|
67
|
+
readonly path: readonly string[];
|
|
68
|
+
/** A switch to a namespace that does not exist, where the dialect rejects one. */
|
|
69
|
+
readonly missing?: string;
|
|
70
|
+
};
|
|
71
|
+
/** Required when the plugin declares `sessions`, which also needs `statements.sleep` and `write`. */
|
|
72
|
+
readonly sessions?: {
|
|
73
|
+
/** Connects as an account that sees every session and may stop any, such as a superuser. */
|
|
74
|
+
readonly monitorConfig: unknown;
|
|
75
|
+
/** An account that sees only its own sessions, and what the listing's note must say about it. */
|
|
76
|
+
readonly limited?: {
|
|
77
|
+
readonly config: unknown;
|
|
78
|
+
readonly note: RegExp;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
/** Required when the plugin declares `routineCall`, which also needs `catalog`. */
|
|
82
|
+
readonly routines?: {
|
|
83
|
+
/** Every routine the fixture seeds in `catalog.path`, as `describe` lists them. */
|
|
84
|
+
readonly expected: readonly Routine[];
|
|
85
|
+
/** A function given values, and the rows its call returns. */
|
|
86
|
+
readonly function: RoutineCase;
|
|
87
|
+
/** A call with one argument NULL, which must bind as SQL NULL. */
|
|
88
|
+
readonly nullArgument: RoutineCase;
|
|
89
|
+
/** A procedure whose OUT values come back as the last statement's rows. */
|
|
90
|
+
readonly procedureOut: RoutineCase;
|
|
91
|
+
/** A call that leaves a parameter to its default; omitted where the dialect has no defaults. */
|
|
92
|
+
readonly defaulted?: RoutineCase;
|
|
93
|
+
};
|
|
94
|
+
/** The statements whose text differs by dialect; each defaults to the text PostgreSQL, MySQL and SQLite accept. */
|
|
95
|
+
readonly sql?: ContractSql;
|
|
96
|
+
/**
|
|
97
|
+
* How the database stores an unquoted fixture name, such as Oracle's upper case; identity by
|
|
98
|
+
* default. `LINE_ITEMS_TABLE` is quoted in every dialect and never folded.
|
|
99
|
+
*/
|
|
100
|
+
readonly foldName?: (name: string) => string;
|
|
101
|
+
}
|
|
102
|
+
/** Dialect-specific text the suite runs; every member is optional (decision 100). */
|
|
103
|
+
export interface ContractSql {
|
|
104
|
+
/** A one-row query of `expressions`; Oracle adds `FROM DUAL`. */
|
|
105
|
+
readonly select?: (expressions: string) => string;
|
|
106
|
+
/** At least 100,000 rows; built from `select` by default. */
|
|
107
|
+
readonly largeResult?: string;
|
|
108
|
+
/** A join over `orders` and `customers` that plans with more than one step. */
|
|
109
|
+
readonly explainJoin?: string;
|
|
110
|
+
/** Replaces the scripts every other dialect splits the same way. */
|
|
111
|
+
readonly sharedScripts?: readonly SplitCase[];
|
|
112
|
+
/** Drops table `name` where it exists and does nothing where it does not. */
|
|
113
|
+
readonly dropTableIfExists?: (name: string) => string;
|
|
114
|
+
/** Creates a session-scoped table from `name`, and the name later statements reach it by. */
|
|
115
|
+
readonly temporaryTable?: (name: string, columns: string) => {
|
|
116
|
+
readonly create: string;
|
|
117
|
+
readonly name: string;
|
|
118
|
+
};
|
|
119
|
+
/** Opens a transaction explicitly in auto-commit mode. */
|
|
120
|
+
readonly begin?: string;
|
|
121
|
+
}
|
|
122
|
+
/** One routine call: which routine (from `routines.expected`), its arguments, and the rows it returns. */
|
|
123
|
+
export interface RoutineCase {
|
|
124
|
+
readonly name: string;
|
|
125
|
+
readonly kind: Routine["kind"];
|
|
126
|
+
readonly signature: string;
|
|
127
|
+
readonly args: readonly RoutineArgument[];
|
|
128
|
+
readonly rows: readonly Row[];
|
|
129
|
+
}
|
|
130
|
+
export interface SplitCase {
|
|
131
|
+
readonly text: string;
|
|
132
|
+
readonly statements: readonly string[];
|
|
133
|
+
}
|
|
134
|
+
/** The table every fixture database carries, one column per value rule the contract pins. */
|
|
135
|
+
export declare const TYPE_PROBE_TABLE = "type_probe";
|
|
136
|
+
/** Where the literal round trip inserts `type_probe`'s rows; created and dropped by the suite. */
|
|
137
|
+
export declare const TYPE_PROBE_COPY_TABLE = "type_probe_copy";
|
|
138
|
+
export declare const TYPE_PROBE_COLUMNS: readonly ["id", "small_int", "big_int", "exact_decimal", "unicode_text", "flag", "doc", "payload", "day", "moment"];
|
|
139
|
+
/** A fixture table whose name needs quoting in every dialect, for completion's quoting. */
|
|
140
|
+
export declare const LINE_ITEMS_TABLE = "Line Items";
|
|
141
|
+
/** The relations every fixture database carries, sorted by name as `describe` returns them. */
|
|
142
|
+
export declare const FIXTURE_RELATIONS: readonly [{
|
|
143
|
+
readonly name: "customer_totals";
|
|
144
|
+
readonly kind: "view";
|
|
145
|
+
}, {
|
|
146
|
+
readonly name: "customers";
|
|
147
|
+
readonly kind: "table";
|
|
148
|
+
}, {
|
|
149
|
+
readonly name: "orders";
|
|
150
|
+
readonly kind: "table";
|
|
151
|
+
}, {
|
|
152
|
+
readonly name: "edit_probe";
|
|
153
|
+
readonly kind: "table";
|
|
154
|
+
}, {
|
|
155
|
+
readonly name: "no_key_probe";
|
|
156
|
+
readonly kind: "table";
|
|
157
|
+
}, {
|
|
158
|
+
readonly name: "type_probe";
|
|
159
|
+
readonly kind: "table";
|
|
160
|
+
}, {
|
|
161
|
+
readonly name: "Line Items";
|
|
162
|
+
readonly kind: "table";
|
|
163
|
+
}];
|
|
164
|
+
/** The data editor's table (decision 026): a serial key and a default for every other column. */
|
|
165
|
+
export declare const EDIT_PROBE_TABLE = "edit_probe";
|
|
166
|
+
/** The statements the suite runs for `fixture`, each default filled in. */
|
|
167
|
+
export declare function contractSql(fixture: Pick<ContractFixture, "sql">): Required<ContractSql>;
|
|
168
|
+
/**
|
|
169
|
+
* Where a manifest's connection form and the plugin's `connectionSchema` disagree (decision 019),
|
|
170
|
+
* one sentence per problem; empty when they agree.
|
|
171
|
+
*/
|
|
172
|
+
export declare function connectionFormProblems(manifest: PluginManifest, connectionSchema: z.ZodType): string[];
|
|
173
|
+
/**
|
|
174
|
+
* The behaviour every plugin owes the host, gated on the capabilities it declares (decision 013).
|
|
175
|
+
* Call it from a Vitest file; it registers one `describe` block.
|
|
176
|
+
*/
|
|
177
|
+
export declare function runPluginContract(plugin: DatasourcePlugin, fixture: ContractFixture): void;
|