@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,117 @@
|
|
|
1
|
+
import { type SqlDialectOptions, type Token } from "./tokens.js";
|
|
2
|
+
/** A name as written: `name` is unquoted, `quoted` says whether the dialect folds it. */
|
|
3
|
+
export interface Identifier {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly quoted: boolean;
|
|
6
|
+
readonly from: number;
|
|
7
|
+
readonly to: number;
|
|
8
|
+
}
|
|
9
|
+
/** `[schema.]table [AS] alias` in a FROM, JOIN, UPDATE or INTO; spans cover the name through the alias. */
|
|
10
|
+
export interface RelationSource {
|
|
11
|
+
readonly kind: "relation";
|
|
12
|
+
readonly qualifier: readonly Identifier[];
|
|
13
|
+
readonly name: Identifier;
|
|
14
|
+
readonly alias: Identifier | null;
|
|
15
|
+
readonly from: number;
|
|
16
|
+
readonly to: number;
|
|
17
|
+
}
|
|
18
|
+
/** `( SELECT … ) [AS] alias`. */
|
|
19
|
+
export interface DerivedSource {
|
|
20
|
+
readonly kind: "derived";
|
|
21
|
+
readonly alias: Identifier | null;
|
|
22
|
+
readonly query: Query;
|
|
23
|
+
readonly from: number;
|
|
24
|
+
readonly to: number;
|
|
25
|
+
}
|
|
26
|
+
export type Source = RelationSource | DerivedSource;
|
|
27
|
+
/** A select-list entry: the name it is read back by, or a `*` / `x.*`. */
|
|
28
|
+
export type SelectItem = {
|
|
29
|
+
readonly kind: "named";
|
|
30
|
+
readonly name: Identifier;
|
|
31
|
+
} | {
|
|
32
|
+
readonly kind: "star";
|
|
33
|
+
readonly qualifier: Identifier | null;
|
|
34
|
+
};
|
|
35
|
+
export interface Cte {
|
|
36
|
+
readonly name: Identifier;
|
|
37
|
+
/** `WITH x (a, b) AS …`; null when the select list names the columns. */
|
|
38
|
+
readonly columns: readonly Identifier[] | null;
|
|
39
|
+
readonly query: Query;
|
|
40
|
+
}
|
|
41
|
+
/** One `SELECT`, `UPDATE`, `INSERT` or `DELETE` level: the statement itself or a parenthesised subquery. */
|
|
42
|
+
export interface Query {
|
|
43
|
+
readonly ctes: readonly Cte[];
|
|
44
|
+
readonly sources: readonly Source[];
|
|
45
|
+
/** The first select list, which is what a CTE or derived table exposes. */
|
|
46
|
+
readonly select: readonly SelectItem[];
|
|
47
|
+
/** The select list has an entry with no name to read back, such as `count(*)`, so `select` is incomplete. */
|
|
48
|
+
readonly opaqueSelect: boolean;
|
|
49
|
+
/** `INSERT INTO t (…)`: the target and the column list's parentheses, as text offsets. */
|
|
50
|
+
readonly insert: {
|
|
51
|
+
readonly source: RelationSource;
|
|
52
|
+
readonly from: number;
|
|
53
|
+
readonly to: number;
|
|
54
|
+
} | null;
|
|
55
|
+
/** The query this one is nested in, whose sources and CTEs it can also see. */
|
|
56
|
+
readonly parent: Query | null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* What the caret is completing: `relation` after FROM, JOIN, UPDATE or INTO (`join` after JOIN),
|
|
60
|
+
* `columns` in an expression, `insertColumns` in `INSERT INTO t (…)`, `keywords` elsewhere, `none`
|
|
61
|
+
* inside a string or comment.
|
|
62
|
+
*/
|
|
63
|
+
export type Clause = "relation" | "join" | "columns" | "insertColumns" | "keywords" | "none";
|
|
64
|
+
export interface ScopeAt {
|
|
65
|
+
readonly clause: Clause;
|
|
66
|
+
/** The names before the dots ahead of the caret's word, outermost first: `s.t.c` at `c` gives [s, t]. */
|
|
67
|
+
readonly qualifier: readonly Identifier[];
|
|
68
|
+
/** Where the word the caret is in starts; the caret itself when it is in none. */
|
|
69
|
+
readonly prefixFrom: number;
|
|
70
|
+
/** The whole word the caret touches, when it is a name. */
|
|
71
|
+
readonly word: Identifier | null;
|
|
72
|
+
/** Whether a `.` follows the word, so it qualifies another name. */
|
|
73
|
+
readonly dotAfter: boolean;
|
|
74
|
+
readonly query: Query;
|
|
75
|
+
}
|
|
76
|
+
/** The statement's own level, standing where a `(` index would. */
|
|
77
|
+
export declare const NO_GROUP = -1;
|
|
78
|
+
export declare const RELATION_INTRODUCERS: ReadonlySet<string>;
|
|
79
|
+
export declare const EXPRESSION_CLAUSES: ReadonlySet<string>;
|
|
80
|
+
export declare const CLAUSE_WORDS: ReadonlySet<string>;
|
|
81
|
+
/** The character that closes a quoted name or string opened by `open`: `]` for SQL Server's `[`. */
|
|
82
|
+
export declare function closingQuote(open: string): string;
|
|
83
|
+
export declare function unquote(raw: string): string;
|
|
84
|
+
/** The tokens of one statement, its parentheses matched, read for the queries in it. */
|
|
85
|
+
export declare class Statement {
|
|
86
|
+
#private;
|
|
87
|
+
readonly tokens: readonly Token[];
|
|
88
|
+
constructor(text: string, tokens: readonly Token[]);
|
|
89
|
+
word(index: number | undefined): string | null;
|
|
90
|
+
isSymbol(index: number | undefined, symbol: string): boolean;
|
|
91
|
+
/** A name, quoted or not, that no keyword claims. */
|
|
92
|
+
identifier(index: number | undefined): Identifier | null;
|
|
93
|
+
/** The `(` that `open` sits in, or `NO_GROUP`. */
|
|
94
|
+
parentOf(open: number): number;
|
|
95
|
+
closeOf(open: number): number;
|
|
96
|
+
/** The token indices directly inside `open`, a nested group standing as its `(`. */
|
|
97
|
+
level(open: number): number[];
|
|
98
|
+
isQuery(open: number): boolean;
|
|
99
|
+
/** The innermost group holding `offset`, by text position. */
|
|
100
|
+
groupAt(offset: number): number;
|
|
101
|
+
enclosingQuery(open: number): number;
|
|
102
|
+
/** Memoised before it is read, so a subquery naming its parent and the parent reading the subquery meet. */
|
|
103
|
+
query(open: number): Query;
|
|
104
|
+
}
|
|
105
|
+
/** The statement around `offset` without its comments, and whether the caret sits in a string or comment. */
|
|
106
|
+
export declare function statementTokens(text: string, offset: number, dialect: SqlDialectOptions): {
|
|
107
|
+
tokens: Token[];
|
|
108
|
+
literal: boolean;
|
|
109
|
+
};
|
|
110
|
+
type WordMode = "complete" | "resolve";
|
|
111
|
+
/**
|
|
112
|
+
* The statement around `offset` read as far as completion and navigation need (decision 028): the
|
|
113
|
+
* clause the caret is in, the qualifier before it, and the query level whose sources are in scope.
|
|
114
|
+
* It reads tokens, never a grammar, so it answers for half-typed SQL.
|
|
115
|
+
*/
|
|
116
|
+
export declare function scopeAt(text: string, offset: number, dialect: SqlDialectOptions, mode?: WordMode): ScopeAt;
|
|
117
|
+
export {};
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { Lexer } from "./tokens.js";
|
|
2
|
+
const OPEN = "(";
|
|
3
|
+
const CLOSE = ")";
|
|
4
|
+
const DOT = ".";
|
|
5
|
+
const COMMA = ",";
|
|
6
|
+
/** The statement's own level, standing where a `(` index would. */
|
|
7
|
+
export const NO_GROUP = -1;
|
|
8
|
+
const QUERY_STARTS = new Set(["SELECT", "WITH"]);
|
|
9
|
+
export const RELATION_INTRODUCERS = new Set(["FROM", "JOIN", "UPDATE", "INTO", "STRAIGHT_JOIN"]);
|
|
10
|
+
const RELATION_MODIFIERS = new Set(["LATERAL", "ONLY"]);
|
|
11
|
+
export const EXPRESSION_CLAUSES = new Set([
|
|
12
|
+
"SELECT",
|
|
13
|
+
"WHERE",
|
|
14
|
+
"ON",
|
|
15
|
+
"BY",
|
|
16
|
+
"HAVING",
|
|
17
|
+
"SET",
|
|
18
|
+
"RETURNING",
|
|
19
|
+
"USING",
|
|
20
|
+
]);
|
|
21
|
+
/** Clause keywords that end a FROM list; ON and USING belong to a join inside it. */
|
|
22
|
+
const FROM_LIST_ENDS = new Set([
|
|
23
|
+
"SELECT",
|
|
24
|
+
"WHERE",
|
|
25
|
+
"GROUP",
|
|
26
|
+
"ORDER",
|
|
27
|
+
"HAVING",
|
|
28
|
+
"LIMIT",
|
|
29
|
+
"OFFSET",
|
|
30
|
+
"SET",
|
|
31
|
+
"VALUES",
|
|
32
|
+
"UNION",
|
|
33
|
+
"INTERSECT",
|
|
34
|
+
"EXCEPT",
|
|
35
|
+
"RETURNING",
|
|
36
|
+
"WINDOW",
|
|
37
|
+
"FETCH",
|
|
38
|
+
"FOR",
|
|
39
|
+
]);
|
|
40
|
+
const SELECT_LIST_ENDS = new Set([...FROM_LIST_ENDS, "FROM", "INTO"]);
|
|
41
|
+
export const CLAUSE_WORDS = new Set([
|
|
42
|
+
...FROM_LIST_ENDS,
|
|
43
|
+
...RELATION_INTRODUCERS,
|
|
44
|
+
...EXPRESSION_CLAUSES,
|
|
45
|
+
"DELETE",
|
|
46
|
+
"INSERT",
|
|
47
|
+
]);
|
|
48
|
+
/** Words that end a table reference instead of naming or aliasing it. */
|
|
49
|
+
const NOT_A_NAME = new Set([
|
|
50
|
+
...CLAUSE_WORDS,
|
|
51
|
+
"AS",
|
|
52
|
+
"INNER",
|
|
53
|
+
"LEFT",
|
|
54
|
+
"RIGHT",
|
|
55
|
+
"FULL",
|
|
56
|
+
"OUTER",
|
|
57
|
+
"CROSS",
|
|
58
|
+
"NATURAL",
|
|
59
|
+
"LATERAL",
|
|
60
|
+
"USE",
|
|
61
|
+
"FORCE",
|
|
62
|
+
"IGNORE",
|
|
63
|
+
"WITH",
|
|
64
|
+
"DEFAULT",
|
|
65
|
+
"TABLESAMPLE",
|
|
66
|
+
"WHEN",
|
|
67
|
+
"THEN",
|
|
68
|
+
"ELSE",
|
|
69
|
+
"END",
|
|
70
|
+
"AND",
|
|
71
|
+
"OR",
|
|
72
|
+
"NOT",
|
|
73
|
+
"DISTINCT",
|
|
74
|
+
"ALL",
|
|
75
|
+
"CASE",
|
|
76
|
+
"NULL",
|
|
77
|
+
]);
|
|
78
|
+
const AS = "AS";
|
|
79
|
+
const WITH = "WITH";
|
|
80
|
+
const RECURSIVE = "RECURSIVE";
|
|
81
|
+
const CTE_MODIFIERS = new Set(["NOT", "MATERIALIZED"]);
|
|
82
|
+
const SELECT_MODIFIERS = new Set(["DISTINCT", "ALL"]);
|
|
83
|
+
const STAR = "*";
|
|
84
|
+
const DIGIT = /^[0-9]/;
|
|
85
|
+
const BLOCK_COMMENT_END = "*/";
|
|
86
|
+
const LINE_COMMENT_STARTS = ["--", "#"];
|
|
87
|
+
const BRACKET_OPEN = "[";
|
|
88
|
+
const BRACKET_CLOSE = "]";
|
|
89
|
+
/** The character that closes a quoted name or string opened by `open`: `]` for SQL Server's `[`. */
|
|
90
|
+
export function closingQuote(open) {
|
|
91
|
+
return open === BRACKET_OPEN ? BRACKET_CLOSE : open;
|
|
92
|
+
}
|
|
93
|
+
export function unquote(raw) {
|
|
94
|
+
const quote = closingQuote(raw.charAt(0));
|
|
95
|
+
const inner = raw.length >= 2 && raw.endsWith(quote) ? raw.slice(1, -1) : raw.slice(1);
|
|
96
|
+
return inner.replaceAll(quote + quote, quote);
|
|
97
|
+
}
|
|
98
|
+
/** Whether the caret at `offset` sits inside a string or comment token. */
|
|
99
|
+
function insideLiteral(text, token, offset) {
|
|
100
|
+
if (token.kind !== "string" && token.kind !== "comment")
|
|
101
|
+
return false;
|
|
102
|
+
if (offset <= token.from || offset > token.to)
|
|
103
|
+
return false;
|
|
104
|
+
if (offset < token.to)
|
|
105
|
+
return true;
|
|
106
|
+
const raw = text.slice(token.from, token.to);
|
|
107
|
+
if (token.kind === "comment")
|
|
108
|
+
return LINE_COMMENT_STARTS.some((start) => raw.startsWith(start)) || !raw.endsWith(BLOCK_COMMENT_END);
|
|
109
|
+
const quote = raw.charAt(0);
|
|
110
|
+
return raw.length < 2 || !raw.endsWith(quote);
|
|
111
|
+
}
|
|
112
|
+
/** The tokens of one statement, its parentheses matched, read for the queries in it. */
|
|
113
|
+
export class Statement {
|
|
114
|
+
#text;
|
|
115
|
+
tokens;
|
|
116
|
+
/** For each `(`, the index of its `)`, or the token count when it is never closed. */
|
|
117
|
+
#close = new Map();
|
|
118
|
+
/** For each `(`, the `(` it sits in, or `NO_GROUP`. */
|
|
119
|
+
#parent = new Map();
|
|
120
|
+
#queries = new Map();
|
|
121
|
+
constructor(text, tokens) {
|
|
122
|
+
this.#text = text;
|
|
123
|
+
this.tokens = tokens;
|
|
124
|
+
const open = [];
|
|
125
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
126
|
+
const symbol = this.#symbol(index);
|
|
127
|
+
if (symbol === OPEN) {
|
|
128
|
+
this.#parent.set(index, open.at(-1) ?? NO_GROUP);
|
|
129
|
+
open.push(index);
|
|
130
|
+
}
|
|
131
|
+
else if (symbol === CLOSE) {
|
|
132
|
+
const opened = open.pop();
|
|
133
|
+
if (opened !== undefined)
|
|
134
|
+
this.#close.set(opened, index);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const opened of open)
|
|
138
|
+
this.#close.set(opened, tokens.length);
|
|
139
|
+
}
|
|
140
|
+
#symbol(index) {
|
|
141
|
+
const token = index === undefined ? undefined : this.tokens[index];
|
|
142
|
+
return token?.kind === "symbol" ? this.#text.charAt(token.from) : null;
|
|
143
|
+
}
|
|
144
|
+
word(index) {
|
|
145
|
+
const token = index === undefined ? undefined : this.tokens[index];
|
|
146
|
+
return token?.kind === "word" ? this.#text.slice(token.from, token.to).toUpperCase() : null;
|
|
147
|
+
}
|
|
148
|
+
isSymbol(index, symbol) {
|
|
149
|
+
return this.#symbol(index) === symbol;
|
|
150
|
+
}
|
|
151
|
+
/** A name, quoted or not, that no keyword claims. */
|
|
152
|
+
identifier(index) {
|
|
153
|
+
const token = index === undefined ? undefined : this.tokens[index];
|
|
154
|
+
if (token === undefined)
|
|
155
|
+
return null;
|
|
156
|
+
const raw = this.#text.slice(token.from, token.to);
|
|
157
|
+
if (token.kind === "quoted")
|
|
158
|
+
return { name: unquote(raw), quoted: true, from: token.from, to: token.to };
|
|
159
|
+
if (token.kind !== "word" || DIGIT.test(raw) || NOT_A_NAME.has(raw.toUpperCase()))
|
|
160
|
+
return null;
|
|
161
|
+
return { name: raw, quoted: false, from: token.from, to: token.to };
|
|
162
|
+
}
|
|
163
|
+
/** The `(` that `open` sits in, or `NO_GROUP`. */
|
|
164
|
+
parentOf(open) {
|
|
165
|
+
return this.#parent.get(open) ?? NO_GROUP;
|
|
166
|
+
}
|
|
167
|
+
closeOf(open) {
|
|
168
|
+
return open === NO_GROUP ? this.tokens.length : (this.#close.get(open) ?? this.tokens.length);
|
|
169
|
+
}
|
|
170
|
+
/** The token indices directly inside `open`, a nested group standing as its `(`. */
|
|
171
|
+
level(open) {
|
|
172
|
+
const indices = [];
|
|
173
|
+
const end = this.closeOf(open);
|
|
174
|
+
for (let index = open + 1; index < end; index += 1) {
|
|
175
|
+
indices.push(index);
|
|
176
|
+
if (this.isSymbol(index, OPEN))
|
|
177
|
+
index = this.closeOf(index);
|
|
178
|
+
}
|
|
179
|
+
return indices;
|
|
180
|
+
}
|
|
181
|
+
isQuery(open) {
|
|
182
|
+
if (open === NO_GROUP)
|
|
183
|
+
return true;
|
|
184
|
+
const first = this.word(open + 1);
|
|
185
|
+
return first !== null && QUERY_STARTS.has(first) && open + 1 < this.closeOf(open);
|
|
186
|
+
}
|
|
187
|
+
/** The innermost group holding `offset`, by text position. */
|
|
188
|
+
groupAt(offset) {
|
|
189
|
+
let found = NO_GROUP;
|
|
190
|
+
for (const [open, close] of this.#close) {
|
|
191
|
+
const opened = this.tokens[open];
|
|
192
|
+
const closed = this.tokens[close];
|
|
193
|
+
if (opened === undefined || opened.to > offset)
|
|
194
|
+
continue;
|
|
195
|
+
if (closed !== undefined && closed.from < offset)
|
|
196
|
+
continue;
|
|
197
|
+
if (found === NO_GROUP || open > found)
|
|
198
|
+
found = open;
|
|
199
|
+
}
|
|
200
|
+
return found;
|
|
201
|
+
}
|
|
202
|
+
enclosingQuery(open) {
|
|
203
|
+
let group = open;
|
|
204
|
+
while (!this.isQuery(group))
|
|
205
|
+
group = this.#parent.get(group) ?? NO_GROUP;
|
|
206
|
+
return group;
|
|
207
|
+
}
|
|
208
|
+
/** Memoised before it is read, so a subquery naming its parent and the parent reading the subquery meet. */
|
|
209
|
+
query(open) {
|
|
210
|
+
const known = this.#queries.get(open);
|
|
211
|
+
if (known !== undefined)
|
|
212
|
+
return known;
|
|
213
|
+
const query = { ctes: [], sources: [], select: [], opaqueSelect: false, insert: null, parent: null };
|
|
214
|
+
this.#queries.set(open, query);
|
|
215
|
+
query.parent = open === NO_GROUP ? null : this.query(this.enclosingQuery(this.#parent.get(open) ?? NO_GROUP));
|
|
216
|
+
new QueryReader(this, open, query).read();
|
|
217
|
+
return query;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Reads one level's CTEs, sources and select list; anything it does not recognise it skips. */
|
|
221
|
+
class QueryReader {
|
|
222
|
+
#statement;
|
|
223
|
+
#query;
|
|
224
|
+
#indices;
|
|
225
|
+
#at = 0;
|
|
226
|
+
constructor(statement, open, query) {
|
|
227
|
+
this.#statement = statement;
|
|
228
|
+
this.#query = query;
|
|
229
|
+
this.#indices = statement.level(open);
|
|
230
|
+
}
|
|
231
|
+
#index(offset = 0) {
|
|
232
|
+
return this.#indices[this.#at + offset];
|
|
233
|
+
}
|
|
234
|
+
read() {
|
|
235
|
+
const statement = this.#statement;
|
|
236
|
+
if (statement.word(this.#index()) === WITH)
|
|
237
|
+
this.#readCtes();
|
|
238
|
+
let expectRelation = false;
|
|
239
|
+
let fromList = false;
|
|
240
|
+
let into = false;
|
|
241
|
+
let selecting = false;
|
|
242
|
+
let selected = false;
|
|
243
|
+
let item = [];
|
|
244
|
+
const flush = () => {
|
|
245
|
+
if (!selected)
|
|
246
|
+
this.#selectItem(item);
|
|
247
|
+
item = [];
|
|
248
|
+
};
|
|
249
|
+
for (; this.#at < this.#indices.length; this.#at += 1) {
|
|
250
|
+
const index = this.#index();
|
|
251
|
+
const word = statement.word(index);
|
|
252
|
+
if (selecting) {
|
|
253
|
+
if (word !== null && SELECT_LIST_ENDS.has(word)) {
|
|
254
|
+
flush();
|
|
255
|
+
selecting = false;
|
|
256
|
+
selected = true;
|
|
257
|
+
}
|
|
258
|
+
else if (statement.isSymbol(index, COMMA)) {
|
|
259
|
+
flush();
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
if (!(item.length === 0 && word !== null && SELECT_MODIFIERS.has(word)) && index !== undefined)
|
|
264
|
+
item.push(index);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (word === "SELECT") {
|
|
269
|
+
selecting = !selected;
|
|
270
|
+
expectRelation = false;
|
|
271
|
+
fromList = false;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (word !== null && RELATION_INTRODUCERS.has(word)) {
|
|
275
|
+
expectRelation = true;
|
|
276
|
+
into = word === "INTO";
|
|
277
|
+
fromList = word !== "INTO";
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (word !== null && CLAUSE_WORDS.has(word)) {
|
|
281
|
+
expectRelation = false;
|
|
282
|
+
if (FROM_LIST_ENDS.has(word))
|
|
283
|
+
fromList = false;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (expectRelation) {
|
|
287
|
+
if (word !== null && RELATION_MODIFIERS.has(word))
|
|
288
|
+
continue;
|
|
289
|
+
this.#readSource(into);
|
|
290
|
+
expectRelation = false;
|
|
291
|
+
into = false;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (fromList && statement.isSymbol(index, COMMA))
|
|
295
|
+
expectRelation = true;
|
|
296
|
+
}
|
|
297
|
+
if (selecting)
|
|
298
|
+
flush();
|
|
299
|
+
}
|
|
300
|
+
#readCtes() {
|
|
301
|
+
const statement = this.#statement;
|
|
302
|
+
this.#at += 1;
|
|
303
|
+
if (statement.word(this.#index()) === RECURSIVE)
|
|
304
|
+
this.#at += 1;
|
|
305
|
+
for (;;) {
|
|
306
|
+
const name = statement.identifier(this.#index());
|
|
307
|
+
if (name === null)
|
|
308
|
+
return;
|
|
309
|
+
this.#at += 1;
|
|
310
|
+
let columns = null;
|
|
311
|
+
const listOpen = this.#index();
|
|
312
|
+
if (listOpen !== undefined && statement.isSymbol(listOpen, OPEN)) {
|
|
313
|
+
columns = statement
|
|
314
|
+
.level(listOpen)
|
|
315
|
+
.map((index) => statement.identifier(index))
|
|
316
|
+
.filter((identifier) => identifier !== null);
|
|
317
|
+
this.#at += 1;
|
|
318
|
+
}
|
|
319
|
+
if (statement.word(this.#index()) === AS)
|
|
320
|
+
this.#at += 1;
|
|
321
|
+
while (CTE_MODIFIERS.has(statement.word(this.#index()) ?? ""))
|
|
322
|
+
this.#at += 1;
|
|
323
|
+
const body = this.#index();
|
|
324
|
+
if (body === undefined || !statement.isSymbol(body, OPEN))
|
|
325
|
+
return;
|
|
326
|
+
this.#query.ctes.push({ name, columns, query: statement.query(body) });
|
|
327
|
+
this.#at += 1;
|
|
328
|
+
if (!statement.isSymbol(this.#index(), COMMA))
|
|
329
|
+
return;
|
|
330
|
+
this.#at += 1;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/** Reads one table reference at the cursor and leaves the cursor on its last token. */
|
|
334
|
+
#readSource(into) {
|
|
335
|
+
const statement = this.#statement;
|
|
336
|
+
const start = this.#index();
|
|
337
|
+
if (start === undefined)
|
|
338
|
+
return;
|
|
339
|
+
const startToken = statement.tokens[start];
|
|
340
|
+
if (startToken === undefined)
|
|
341
|
+
return;
|
|
342
|
+
if (statement.isSymbol(start, OPEN)) {
|
|
343
|
+
const closeToken = statement.tokens[statement.closeOf(start)];
|
|
344
|
+
const alias = this.#alias();
|
|
345
|
+
this.#query.sources.push({
|
|
346
|
+
kind: "derived",
|
|
347
|
+
alias,
|
|
348
|
+
query: statement.query(start),
|
|
349
|
+
from: startToken.from,
|
|
350
|
+
to: alias?.to ?? closeToken?.to ?? startToken.to,
|
|
351
|
+
});
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const first = statement.identifier(start);
|
|
355
|
+
if (first === null) {
|
|
356
|
+
this.#at -= 1;
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const parts = [first];
|
|
360
|
+
while (statement.isSymbol(this.#index(1), DOT)) {
|
|
361
|
+
const next = statement.identifier(this.#index(2));
|
|
362
|
+
if (next === null)
|
|
363
|
+
break;
|
|
364
|
+
parts.push(next);
|
|
365
|
+
this.#at += 2;
|
|
366
|
+
}
|
|
367
|
+
const name = parts.pop() ?? first;
|
|
368
|
+
const following = this.#index(1);
|
|
369
|
+
if (following !== undefined && statement.isSymbol(following, OPEN)) {
|
|
370
|
+
const source = {
|
|
371
|
+
kind: "relation",
|
|
372
|
+
qualifier: parts,
|
|
373
|
+
name,
|
|
374
|
+
alias: null,
|
|
375
|
+
from: first.from,
|
|
376
|
+
to: name.to,
|
|
377
|
+
};
|
|
378
|
+
if (into) {
|
|
379
|
+
const closeToken = statement.tokens[statement.closeOf(following)];
|
|
380
|
+
const openToken = statement.tokens[following];
|
|
381
|
+
this.#query.sources.push(source);
|
|
382
|
+
this.#query.insert = {
|
|
383
|
+
source,
|
|
384
|
+
from: openToken?.to ?? name.to,
|
|
385
|
+
to: closeToken?.from ?? Number.MAX_SAFE_INTEGER,
|
|
386
|
+
};
|
|
387
|
+
this.#at += 1;
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
// A table function such as `generate_series(…)`: its columns are unknown, but its alias still scopes.
|
|
391
|
+
this.#at += 1;
|
|
392
|
+
}
|
|
393
|
+
const alias = this.#alias();
|
|
394
|
+
this.#query.sources.push({
|
|
395
|
+
kind: "relation",
|
|
396
|
+
qualifier: parts,
|
|
397
|
+
name,
|
|
398
|
+
alias,
|
|
399
|
+
from: first.from,
|
|
400
|
+
to: alias?.to ?? name.to,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
/** `[AS] alias` after the cursor; advances past it when present. */
|
|
404
|
+
#alias() {
|
|
405
|
+
const statement = this.#statement;
|
|
406
|
+
const hasAs = statement.word(this.#index(1)) === AS;
|
|
407
|
+
const alias = statement.identifier(this.#index(hasAs ? 2 : 1));
|
|
408
|
+
if (alias !== null)
|
|
409
|
+
this.#at += hasAs ? 2 : 1;
|
|
410
|
+
else if (hasAs)
|
|
411
|
+
this.#at += 1;
|
|
412
|
+
return alias;
|
|
413
|
+
}
|
|
414
|
+
#selectItem(item) {
|
|
415
|
+
const statement = this.#statement;
|
|
416
|
+
const last = item.at(-1);
|
|
417
|
+
if (last === undefined)
|
|
418
|
+
return;
|
|
419
|
+
if (statement.isSymbol(last, STAR)) {
|
|
420
|
+
const qualified = item.length >= 3 && statement.isSymbol(item.at(-2), DOT);
|
|
421
|
+
this.#query.select.push({ kind: "star", qualifier: qualified ? statement.identifier(item.at(-3)) : null });
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const name = statement.identifier(last);
|
|
425
|
+
if (name !== null)
|
|
426
|
+
this.#query.select.push({ kind: "named", name });
|
|
427
|
+
else
|
|
428
|
+
this.#query.opaqueSelect = true;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
/** The statement around `offset` without its comments, and whether the caret sits in a string or comment. */
|
|
432
|
+
export function statementTokens(text, offset, dialect) {
|
|
433
|
+
const lexer = new Lexer(text, dialect);
|
|
434
|
+
let tokens = [];
|
|
435
|
+
let literal = false;
|
|
436
|
+
for (let token = lexer.next(); token !== null; token = lexer.next()) {
|
|
437
|
+
if (insideLiteral(text, token, offset))
|
|
438
|
+
literal = true;
|
|
439
|
+
if (token.kind === "comment")
|
|
440
|
+
continue;
|
|
441
|
+
if (token.kind === "delimiter") {
|
|
442
|
+
if (token.to <= offset)
|
|
443
|
+
tokens = [];
|
|
444
|
+
else if (token.from >= offset)
|
|
445
|
+
break;
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
tokens.push(token);
|
|
449
|
+
}
|
|
450
|
+
return { tokens, literal };
|
|
451
|
+
}
|
|
452
|
+
/** The name token the caret is in; completing needs a character before the caret, resolving does not. */
|
|
453
|
+
function wordIndexAt(tokens, offset, mode) {
|
|
454
|
+
const index = tokens.findIndex((token) => (token.kind === "word" || token.kind === "quoted") &&
|
|
455
|
+
(mode === "complete" ? token.from < offset : token.from <= offset) &&
|
|
456
|
+
offset <= token.to);
|
|
457
|
+
return index < 0 ? null : index;
|
|
458
|
+
}
|
|
459
|
+
/** `a.b.` written without spaces before the token at `index`, which starts at `end`. */
|
|
460
|
+
function qualifierBefore(statement, index, end) {
|
|
461
|
+
const names = [];
|
|
462
|
+
let at = index;
|
|
463
|
+
let reach = end;
|
|
464
|
+
for (;;) {
|
|
465
|
+
const dot = statement.tokens[at - 1];
|
|
466
|
+
const before = statement.identifier(at - 2);
|
|
467
|
+
if (dot === undefined || before === null || !statement.isSymbol(at - 1, DOT))
|
|
468
|
+
break;
|
|
469
|
+
if (dot.to !== reach || before.to !== dot.from)
|
|
470
|
+
break;
|
|
471
|
+
names.unshift(before);
|
|
472
|
+
reach = before.from;
|
|
473
|
+
at -= 2;
|
|
474
|
+
}
|
|
475
|
+
return names;
|
|
476
|
+
}
|
|
477
|
+
function clauseOf(statement, queryOpen, group, anchor, query) {
|
|
478
|
+
let clause = null;
|
|
479
|
+
let fromList = false;
|
|
480
|
+
let last = null;
|
|
481
|
+
for (const index of statement.level(queryOpen)) {
|
|
482
|
+
const token = statement.tokens[index];
|
|
483
|
+
if (token === undefined || token.from >= anchor)
|
|
484
|
+
break;
|
|
485
|
+
const word = statement.word(index);
|
|
486
|
+
if (word !== null && CLAUSE_WORDS.has(word)) {
|
|
487
|
+
clause = word;
|
|
488
|
+
if (RELATION_INTRODUCERS.has(word) && word !== "INTO")
|
|
489
|
+
fromList = true;
|
|
490
|
+
else if (FROM_LIST_ENDS.has(word))
|
|
491
|
+
fromList = false;
|
|
492
|
+
}
|
|
493
|
+
last = index;
|
|
494
|
+
}
|
|
495
|
+
if (group !== queryOpen) {
|
|
496
|
+
const insert = query.insert;
|
|
497
|
+
if (insert !== null && insert.from <= anchor && anchor <= insert.to)
|
|
498
|
+
return "insertColumns";
|
|
499
|
+
return clause !== null && EXPRESSION_CLAUSES.has(clause) ? "columns" : "keywords";
|
|
500
|
+
}
|
|
501
|
+
const lastWord = statement.word(last ?? undefined);
|
|
502
|
+
if (lastWord !== null && (RELATION_INTRODUCERS.has(lastWord) || RELATION_MODIFIERS.has(lastWord))) {
|
|
503
|
+
return lastWord === "JOIN" || lastWord === "STRAIGHT_JOIN" ? "join" : "relation";
|
|
504
|
+
}
|
|
505
|
+
if (fromList && last !== null && statement.isSymbol(last, COMMA))
|
|
506
|
+
return "relation";
|
|
507
|
+
if (clause !== null && EXPRESSION_CLAUSES.has(clause))
|
|
508
|
+
return "columns";
|
|
509
|
+
return "keywords";
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* The statement around `offset` read as far as completion and navigation need (decision 028): the
|
|
513
|
+
* clause the caret is in, the qualifier before it, and the query level whose sources are in scope.
|
|
514
|
+
* It reads tokens, never a grammar, so it answers for half-typed SQL.
|
|
515
|
+
*/
|
|
516
|
+
export function scopeAt(text, offset, dialect, mode = "complete") {
|
|
517
|
+
const { tokens, literal } = statementTokens(text, offset, dialect);
|
|
518
|
+
const statement = new Statement(text, tokens);
|
|
519
|
+
const wordIndex = wordIndexAt(tokens, offset, mode);
|
|
520
|
+
const wordToken = wordIndex === null ? undefined : tokens[wordIndex];
|
|
521
|
+
let word = null;
|
|
522
|
+
if (wordToken !== undefined) {
|
|
523
|
+
const raw = text.slice(wordToken.from, wordToken.to);
|
|
524
|
+
word =
|
|
525
|
+
wordToken.kind === "quoted"
|
|
526
|
+
? { name: unquote(raw), quoted: true, from: wordToken.from, to: wordToken.to }
|
|
527
|
+
: { name: raw, quoted: false, from: wordToken.from, to: wordToken.to };
|
|
528
|
+
}
|
|
529
|
+
const prefixFrom = word?.from ?? offset;
|
|
530
|
+
const following = tokens.findIndex((token) => token.from >= offset);
|
|
531
|
+
const caretIndex = wordIndex ?? (following < 0 ? tokens.length : following);
|
|
532
|
+
const qualifier = qualifierBefore(statement, caretIndex, prefixFrom);
|
|
533
|
+
const anchor = qualifier[0]?.from ?? prefixFrom;
|
|
534
|
+
const dotAfter = wordIndex !== null && statement.isSymbol(wordIndex + 1, DOT) && tokens[wordIndex + 1]?.from === word?.to;
|
|
535
|
+
const group = statement.groupAt(anchor);
|
|
536
|
+
const queryOpen = statement.enclosingQuery(group);
|
|
537
|
+
const query = statement.query(queryOpen);
|
|
538
|
+
const clause = literal ? "none" : clauseOf(statement, queryOpen, group, anchor, query);
|
|
539
|
+
return { clause, qualifier, prefixFrom, word, dotAfter, query };
|
|
540
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type Identifier } from "./scope.js";
|
|
2
|
+
import { type SqlDialectOptions } from "./tokens.js";
|
|
3
|
+
/** One select-list entry of a simple SELECT: `*`, `x.*`, a column reference, or anything else. */
|
|
4
|
+
export type SimpleSelectItem = {
|
|
5
|
+
readonly kind: "star";
|
|
6
|
+
readonly qualifier: Identifier | null;
|
|
7
|
+
} | {
|
|
8
|
+
readonly kind: "column";
|
|
9
|
+
readonly qualifier: readonly Identifier[];
|
|
10
|
+
readonly name: Identifier;
|
|
11
|
+
} | {
|
|
12
|
+
readonly kind: "expression";
|
|
13
|
+
};
|
|
14
|
+
/** A `SELECT items FROM [qualifier.]name [alias]` with nothing but filters, order and limits after it. */
|
|
15
|
+
export interface SimpleSelect {
|
|
16
|
+
readonly qualifier: readonly Identifier[];
|
|
17
|
+
readonly name: Identifier;
|
|
18
|
+
readonly alias: Identifier | null;
|
|
19
|
+
readonly items: readonly SimpleSelectItem[];
|
|
20
|
+
}
|
|
21
|
+
/** Why a statement is not a simple SELECT, for the console's toolbar (plan 026). */
|
|
22
|
+
export declare const SELECT_READ_ONLY_REASONS: {
|
|
23
|
+
readonly notSelect: "the statement is not a simple SELECT";
|
|
24
|
+
readonly with: "the statement has a WITH clause";
|
|
25
|
+
readonly distinct: "the result is DISTINCT";
|
|
26
|
+
readonly joins: "the result joins two or more tables";
|
|
27
|
+
readonly aggregates: "the result aggregates rows";
|
|
28
|
+
readonly windows: "the result uses window functions";
|
|
29
|
+
readonly combines: "the result combines queries";
|
|
30
|
+
readonly derived: "the result reads a subquery or function, not a table";
|
|
31
|
+
readonly noTable: "the result reads no table";
|
|
32
|
+
};
|
|
33
|
+
type Parsed = SimpleSelect | {
|
|
34
|
+
readonly readOnly: string;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* `sql`, one statement, read as a single-table SELECT (plan 026). Strict on purpose: whatever
|
|
38
|
+
* it does not recognise is read-only, because a wrong "editable" writes to the wrong rows.
|
|
39
|
+
*/
|
|
40
|
+
export declare function parseSimpleSelect(sql: string, dialect: SqlDialectOptions): Parsed;
|
|
41
|
+
export {};
|