vectrojs 0.0.1 → 0.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/CHANGELOG.md +71 -0
- package/README.md +821 -4
- package/bin/vectro.mjs +82 -0
- package/dist/vectro.wasm +0 -0
- package/mcp/server.mjs +288 -0
- package/package.json +84 -6
- package/src/arrow.mjs +905 -0
- package/src/bitpack.mjs +135 -0
- package/src/browser.mjs +129 -0
- package/src/bytes.mjs +60 -0
- package/src/csv-kernel.mjs +205 -0
- package/src/csv.mjs +270 -0
- package/src/dataset.mjs +1763 -0
- package/src/dates.mjs +139 -0
- package/src/emitter.mjs +109 -0
- package/src/engine.mjs +881 -0
- package/src/files-browser.mjs +104 -0
- package/src/files-node.mjs +59 -0
- package/src/frames.mjs +191 -0
- package/src/fuse.mjs +512 -0
- package/src/interp.mjs +305 -0
- package/src/join.mjs +534 -0
- package/src/kernels.mjs +312 -0
- package/src/ndjson.mjs +136 -0
- package/src/node.mjs +120 -0
- package/src/pack.mjs +114 -0
- package/src/parallel-sort.mjs +99 -0
- package/src/partition-kernels.mjs +388 -0
- package/src/partition.mjs +395 -0
- package/src/plan.mjs +551 -0
- package/src/portable.mjs +21 -0
- package/src/rank-kernels.mjs +81 -0
- package/src/rows.mjs +91 -0
- package/src/serve/catalog.mjs +148 -0
- package/src/serve/flight.mjs +270 -0
- package/src/serve/formats.mjs +87 -0
- package/src/serve/http.mjs +182 -0
- package/src/serve/index.mjs +9 -0
- package/src/serve/odata.mjs +291 -0
- package/src/serve/pg.mjs +588 -0
- package/src/serve/pgcatalog.mjs +220 -0
- package/src/serve/pgtypes.mjs +199 -0
- package/src/sketch.mjs +75 -0
- package/src/sort-kernels.mjs +117 -0
- package/src/sort.mjs +102 -0
- package/src/split-worker-core.mjs +252 -0
- package/src/split-worker-node.mjs +8 -0
- package/src/split-worker.mjs +7 -0
- package/src/split.mjs +741 -0
- package/src/sql.mjs +1558 -0
- package/src/sqltext.mjs +132 -0
- package/src/static-kernels.mjs +164 -0
- package/src/stream-worker.mjs +553 -0
- package/src/table-kernels.mjs +323 -0
- package/src/table.mjs +2206 -0
- package/src/textmap.mjs +159 -0
- package/src/vectors.mjs +138 -0
- package/src/wasm.mjs +254 -0
- package/src/window.mjs +160 -0
- package/src/worker-browser.mjs +21 -0
- package/src/worker-loop.mjs +106 -0
- package/src/worker.mjs +22 -0
- package/src/zones.mjs +75 -0
package/src/serve/pg.mjs
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
// The PostgreSQL wire protocol (version 3) over Vectro tables: Power BI,
|
|
2
|
+
// Tableau, Metabase, Grafana, DBeaver, psql and every Postgres driver can
|
|
3
|
+
// connect as if Vectro were a PostgreSQL 16 database, read only.
|
|
4
|
+
//
|
|
5
|
+
// import { servePg } from 'vectrojs/serve';
|
|
6
|
+
// await servePg({ tables: { orders }, port: 5433, user: 'me', password: 'secret' });
|
|
7
|
+
//
|
|
8
|
+
// Speaks: SSL / TLS (with a certificate given), SCRAM-SHA-256, md5 or plain
|
|
9
|
+
// passwords (none: anyone, which is only allowed on this machine); the simple
|
|
10
|
+
// and the extended query protocol (prepared statements, $1 parameters, text
|
|
11
|
+
// and binary values, portals fetched a part at a time); BEGIN / COMMIT, SET /
|
|
12
|
+
// SHOW, DECLARE / FETCH cursors; the catalog clients read (see pgcatalog.mjs).
|
|
13
|
+
import { createServer } from 'node:net';
|
|
14
|
+
import { TLSSocket, createSecureContext } from 'node:tls';
|
|
15
|
+
import { createHash, createHmac, pbkdf2Sync, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { once } from 'node:events';
|
|
18
|
+
import { sql, displayName } from '../sql.mjs';
|
|
19
|
+
import { openTables } from './catalog.mjs';
|
|
20
|
+
import { OID, TYPE_OF, typeLength, typeName, BINARY, paramLiteral, bindParams, dataRows, PgError } from './pgtypes.mjs';
|
|
21
|
+
import { describeTables, catalogTables, driverAnswer, readsCatalog, selectNames } from './pgcatalog.mjs';
|
|
22
|
+
|
|
23
|
+
const VERSION = '16.0';
|
|
24
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
25
|
+
const BATCH = 16_384; // rows per write
|
|
26
|
+
|
|
27
|
+
// ---- messages ------------------------------------------------------------------------
|
|
28
|
+
const int32 = (v) => { const b = Buffer.alloc(4); b.writeInt32BE(v); return b; };
|
|
29
|
+
const int16 = (v) => { const b = Buffer.alloc(2); b.writeInt16BE(v); return b; };
|
|
30
|
+
const cstr = (s) => Buffer.from(`${s}\0`, 'utf8');
|
|
31
|
+
function message(type, ...parts) {
|
|
32
|
+
const body = Buffer.concat(parts);
|
|
33
|
+
const head = Buffer.alloc(5);
|
|
34
|
+
head.write(type, 0, 'latin1');
|
|
35
|
+
head.writeInt32BE(body.length + 4, 1);
|
|
36
|
+
return Buffer.concat([head, body]);
|
|
37
|
+
}
|
|
38
|
+
const ready = (status) => message('Z', Buffer.from(status, 'latin1'));
|
|
39
|
+
const complete = (tag) => message('C', cstr(tag));
|
|
40
|
+
function errorMessage(err, type = 'E') {
|
|
41
|
+
const code = err.code && /^[0-9A-Z]{5}$/.test(err.code) ? err.code : codeOf(err);
|
|
42
|
+
const msg = String(err.message ?? err).replace(/^vectro( SQL)?:\s*/, '');
|
|
43
|
+
return message(type, Buffer.from('S'), cstr(type === 'N' ? 'NOTICE' : 'ERROR'), Buffer.from('V'), cstr(type === 'N' ? 'NOTICE' : 'ERROR'), Buffer.from('C'), cstr(code), Buffer.from('M'), cstr(msg), Buffer.from([0]));
|
|
44
|
+
}
|
|
45
|
+
function codeOf(err) {
|
|
46
|
+
const m = String(err.message ?? '');
|
|
47
|
+
if (/no table/.test(m)) return '42P01';
|
|
48
|
+
if (/no column/.test(m)) return '42703';
|
|
49
|
+
if (/not supported|isn't supported|is not supported/.test(m)) return '0A000';
|
|
50
|
+
if (/^vectro SQL:/.test(m) || err instanceof SyntaxError) return '42601';
|
|
51
|
+
return 'XX000';
|
|
52
|
+
}
|
|
53
|
+
function rowDescription(columns, formats) {
|
|
54
|
+
return message('T', int16(columns.length), ...columns.flatMap((c, k) => [
|
|
55
|
+
cstr(c.name), int32(0), int16(0), int32(OID[c.type] ?? OID.text), int16(typeLength(c.type)), int32(-1), int16(formats[k] ?? 0),
|
|
56
|
+
]));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---- answers ---------------------------------------------------------------------------
|
|
60
|
+
// A statement's answer: { columns: [{ name, type }], n, cell(i, k), tag } or { tag } (no rows) or { empty }
|
|
61
|
+
function rowsOf(columns, rows, tag = 'SELECT') {
|
|
62
|
+
return { columns, n: rows.length, cell: (i, k) => rows[i][k], tag, keepEmpty: true };
|
|
63
|
+
}
|
|
64
|
+
// a Vectro answer table as rows: its data copied out (toColumns), then read cell by cell
|
|
65
|
+
async function tableRows(columns, table) {
|
|
66
|
+
const data = await table.toColumnsAsync();
|
|
67
|
+
const readers = columns.map(({ name, col }) => {
|
|
68
|
+
const d = data[col ?? name];
|
|
69
|
+
if (d && d.labels) { const { codes, labels } = d; return (i) => { const s = labels[codes[i]]; return s === undefined ? null : s; }; }
|
|
70
|
+
return (i) => { const v = d[i]; return typeof v === 'number' && v !== v ? null : v; };
|
|
71
|
+
});
|
|
72
|
+
return { columns, n: table.length, cell: (i, k) => readers[k](i), tag: 'SELECT' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- the server ------------------------------------------------------------------------
|
|
76
|
+
export async function servePg({ tables = {}, root = null, engine = null, catalog = null, port = 5433, host = '127.0.0.1', user = null, password = null, auth = 'scram', tls = null, database = 'vectro', log = () => {}, logQueries = false } = {}) {
|
|
77
|
+
if (!password && !LOOPBACK.has(host)) throw new Error('vectro: a password is needed to accept connections from other machines (--password, or --host 127.0.0.1)');
|
|
78
|
+
const shared = !!catalog;
|
|
79
|
+
catalog ??= await openTables({ tables, root, engine, log });
|
|
80
|
+
const secure = tls ? createSecureContext({ cert: tls.cert ?? readFileSync(tls.certFile), key: tls.key ?? readFileSync(tls.keyFile) }) : null;
|
|
81
|
+
const sessions = new Map(); // pid -> session (for cancel requests)
|
|
82
|
+
let nextPid = 1000;
|
|
83
|
+
|
|
84
|
+
// catalog tables per database name and user, made again when the served
|
|
85
|
+
// tables change (the old ones are kept: a query may still be reading them)
|
|
86
|
+
const builtFor = new Map();
|
|
87
|
+
let builtVersion = null, old = [];
|
|
88
|
+
const catalogFor = (db, who) => {
|
|
89
|
+
const version = `${catalog.version}|${[...catalog.entries.keys()].join(',')}`;
|
|
90
|
+
if (version !== builtVersion) { for (const b of builtFor.values()) old.push(b); builtFor.clear(); builtVersion = version; }
|
|
91
|
+
const key = `${db}|${who}`;
|
|
92
|
+
let b = builtFor.get(key);
|
|
93
|
+
if (!b) {
|
|
94
|
+
const described = describeTables(catalog.named());
|
|
95
|
+
b = { described, tables: catalogTables(catalog.engine, described, { database: db, user: who, settings: { ...SETTINGS } }) };
|
|
96
|
+
builtFor.set(key, b);
|
|
97
|
+
}
|
|
98
|
+
return b;
|
|
99
|
+
};
|
|
100
|
+
const disposeCatalogs = () => { for (const b of [...builtFor.values(), ...old]) for (const t of Object.values(b.tables)) t.dispose(); builtFor.clear(); old = []; };
|
|
101
|
+
|
|
102
|
+
const server = createServer((socket) => {
|
|
103
|
+
const s = new Session(socket);
|
|
104
|
+
socket.on('error', () => {});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
class Session {
|
|
108
|
+
constructor(socket) {
|
|
109
|
+
this.socket = socket;
|
|
110
|
+
this.buf = Buffer.alloc(0);
|
|
111
|
+
this.started = false; // (after the startup message)
|
|
112
|
+
this.authed = false;
|
|
113
|
+
this.pid = nextPid++;
|
|
114
|
+
this.secret = randomBytes(4).readInt32BE();
|
|
115
|
+
this.params = {};
|
|
116
|
+
this.settings = { ...SETTINGS };
|
|
117
|
+
this.tx = 'I';
|
|
118
|
+
this.statements = new Map();
|
|
119
|
+
this.portals = new Map();
|
|
120
|
+
this.cursors = new Map();
|
|
121
|
+
this.skipToSync = false;
|
|
122
|
+
this.queue = Promise.resolve();
|
|
123
|
+
this.cancelled = false;
|
|
124
|
+
this.listen(socket);
|
|
125
|
+
}
|
|
126
|
+
listen(socket) {
|
|
127
|
+
this.onData = (chunk) => { this.buf = Buffer.concat([this.buf, chunk]); this.queue = this.queue.then(() => this.drain()).catch((err) => this.fatal(err)); };
|
|
128
|
+
socket.on('data', this.onData);
|
|
129
|
+
socket.on('close', () => { sessions.delete(this.pid); this.closed = true; });
|
|
130
|
+
}
|
|
131
|
+
write(b) { if (!this.closed) return this.socket.write(b); return true; }
|
|
132
|
+
async writeAll(parts) { if (!this.write(Buffer.concat(parts)) && !this.closed) await once(this.socket, 'drain').catch(() => {}); }
|
|
133
|
+
fatal(err) { log(`[pg] ${err.stack ?? err}`); try { this.write(errorMessage(err)); } catch {} this.socket.end(); }
|
|
134
|
+
|
|
135
|
+
async drain() {
|
|
136
|
+
for (;;) {
|
|
137
|
+
if (!this.started) {
|
|
138
|
+
if (this.buf.length < 8) return;
|
|
139
|
+
const len = this.buf.readInt32BE(0);
|
|
140
|
+
if (this.buf.length < len) return;
|
|
141
|
+
const body = this.buf.subarray(4, len);
|
|
142
|
+
this.buf = this.buf.subarray(len);
|
|
143
|
+
await this.startup(body);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (this.buf.length < 5) return;
|
|
147
|
+
const type = String.fromCharCode(this.buf[0]), len = this.buf.readInt32BE(1);
|
|
148
|
+
if (this.buf.length < len + 1) return;
|
|
149
|
+
const body = this.buf.subarray(5, len + 1);
|
|
150
|
+
this.buf = this.buf.subarray(len + 1);
|
|
151
|
+
await this.handle(type, body);
|
|
152
|
+
if (this.closed) return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- startup and authentication ----
|
|
157
|
+
async startup(body) {
|
|
158
|
+
const code = body.readInt32BE(0);
|
|
159
|
+
if (code === 80877103) { // SSLRequest
|
|
160
|
+
if (!secure) { this.write(Buffer.from('N')); return; }
|
|
161
|
+
this.write(Buffer.from('S'));
|
|
162
|
+
this.socket.removeListener('data', this.onData);
|
|
163
|
+
const plain = this.socket;
|
|
164
|
+
const t = new TLSSocket(plain, { isServer: true, secureContext: secure });
|
|
165
|
+
t.on('error', () => {});
|
|
166
|
+
this.socket = t;
|
|
167
|
+
this.listen(t);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (code === 80877104) { this.write(Buffer.from('N')); return; } // GSSENCRequest: no
|
|
171
|
+
if (code === 80877102) { // CancelRequest: on its own connection
|
|
172
|
+
const target = sessions.get(body.readInt32BE(4));
|
|
173
|
+
if (target && target.secret === body.readInt32BE(8)) target.cancelled = true;
|
|
174
|
+
this.socket.end();
|
|
175
|
+
this.closed = true;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (code >> 16 !== 3) { this.write(errorMessage(new PgError('0A000', `protocol ${code >> 16}.${code & 0xffff} is not supported (3.0 is)`))); this.socket.end(); this.closed = true; return; }
|
|
179
|
+
const parts = body.subarray(4).toString('utf8').split('\0');
|
|
180
|
+
for (let i = 0; i + 1 < parts.length; i += 2) if (parts[i]) this.params[parts[i]] = parts[i + 1];
|
|
181
|
+
this.user = this.params.user ?? 'vectro';
|
|
182
|
+
this.database = this.params.database || this.user;
|
|
183
|
+
this.started = true;
|
|
184
|
+
if (user && this.user !== user) return this.reject(`password authentication failed for user "${this.user}"`);
|
|
185
|
+
if (!password) return this.welcome();
|
|
186
|
+
if (auth === 'password') this.write(message('R', int32(3)));
|
|
187
|
+
else if (auth === 'md5') { this.salt = randomBytes(4); this.write(message('R', int32(5), this.salt)); }
|
|
188
|
+
else this.write(message('R', int32(10), cstr('SCRAM-SHA-256'), Buffer.from([0])));
|
|
189
|
+
this.authState = auth;
|
|
190
|
+
}
|
|
191
|
+
reject(msg) { this.write(errorMessage(new PgError('28P01', msg))); this.socket.end(); this.closed = true; }
|
|
192
|
+
password(body) {
|
|
193
|
+
if (this.authState === 'password') {
|
|
194
|
+
const given = body.toString('utf8').replace(/\0$/, '');
|
|
195
|
+
return safeEqual(given, password) ? this.welcome() : this.reject(`password authentication failed for user "${this.user}"`);
|
|
196
|
+
}
|
|
197
|
+
if (this.authState === 'md5') {
|
|
198
|
+
const given = body.toString('utf8').replace(/\0$/, '');
|
|
199
|
+
const inner = createHash('md5').update(password + this.user).digest('hex');
|
|
200
|
+
const want = `md5${createHash('md5').update(Buffer.concat([Buffer.from(inner), this.salt])).digest('hex')}`;
|
|
201
|
+
return safeEqual(given, want) ? this.welcome() : this.reject(`password authentication failed for user "${this.user}"`);
|
|
202
|
+
}
|
|
203
|
+
// SCRAM-SHA-256 (RFC 5802 / 7677)
|
|
204
|
+
if (!this.scram) {
|
|
205
|
+
const mech = body.subarray(0, body.indexOf(0)).toString();
|
|
206
|
+
if (mech !== 'SCRAM-SHA-256') return this.reject(`unsupported authentication mechanism ${mech}`);
|
|
207
|
+
const at = body.indexOf(0) + 1, n = body.readInt32BE(at);
|
|
208
|
+
const first = body.subarray(at + 4, at + 4 + n).toString('utf8');
|
|
209
|
+
const m = /^([ny]),[^,]*,(.*)$/.exec(first);
|
|
210
|
+
if (!m) return this.reject('channel binding is not supported');
|
|
211
|
+
const bare = m[2], cnonce = /(?:^|,)r=([^,]*)/.exec(bare)?.[1];
|
|
212
|
+
if (!cnonce) return this.reject('bad SCRAM message');
|
|
213
|
+
const salt = randomBytes(16), iterations = 4096, nonce = cnonce + randomBytes(18).toString('base64');
|
|
214
|
+
const serverFirst = `r=${nonce},s=${salt.toString('base64')},i=${iterations}`;
|
|
215
|
+
this.scram = { bare, serverFirst, nonce, salted: pbkdf2Sync(password, salt, iterations, 32, 'sha256'), gs2: `${m[1]},,` };
|
|
216
|
+
return this.write(message('R', int32(11), Buffer.from(serverFirst)));
|
|
217
|
+
}
|
|
218
|
+
const final = body.toString('utf8');
|
|
219
|
+
const proof = /,p=([^,]*)$/.exec(final)?.[1], withoutProof = final.replace(/,p=[^,]*$/, '');
|
|
220
|
+
if (!proof || /(?:^|,)r=([^,]*)/.exec(final)?.[1] !== this.scram.nonce) return this.reject('bad SCRAM message');
|
|
221
|
+
const { salted, bare, serverFirst } = this.scram;
|
|
222
|
+
const authMessage = `${bare},${serverFirst},${withoutProof}`;
|
|
223
|
+
const clientKey = createHmac('sha256', salted).update('Client Key').digest();
|
|
224
|
+
const storedKey = createHash('sha256').update(clientKey).digest();
|
|
225
|
+
const signature = createHmac('sha256', storedKey).update(authMessage).digest();
|
|
226
|
+
const given = Buffer.from(proof, 'base64');
|
|
227
|
+
if (given.length !== 32) return this.reject(`password authentication failed for user "${this.user}"`);
|
|
228
|
+
const recovered = Buffer.from(given.map((b, i) => b ^ signature[i]));
|
|
229
|
+
if (!timingSafeEqual(createHash('sha256').update(recovered).digest(), storedKey)) return this.reject(`password authentication failed for user "${this.user}"`);
|
|
230
|
+
const serverKey = createHmac('sha256', salted).update('Server Key').digest();
|
|
231
|
+
this.write(message('R', int32(12), Buffer.from(`v=${createHmac('sha256', serverKey).update(authMessage).digest('base64')}`)));
|
|
232
|
+
return this.welcome();
|
|
233
|
+
}
|
|
234
|
+
welcome() {
|
|
235
|
+
this.authed = true;
|
|
236
|
+
sessions.set(this.pid, this);
|
|
237
|
+
const status = {
|
|
238
|
+
server_version: VERSION, server_encoding: 'UTF8', client_encoding: 'UTF8', DateStyle: 'ISO, MDY', IntervalStyle: 'postgres',
|
|
239
|
+
integer_datetimes: 'on', is_superuser: 'off', session_authorization: this.user, standard_conforming_strings: 'on', TimeZone: 'UTC',
|
|
240
|
+
application_name: this.params.application_name ?? '', default_transaction_read_only: 'on', in_hot_standby: 'off',
|
|
241
|
+
};
|
|
242
|
+
this.settings.application_name = status.application_name;
|
|
243
|
+
const out = [message('R', int32(0)), ...Object.entries(status).map(([k, v]) => message('S', cstr(k), cstr(v))), message('K', int32(this.pid), int32(this.secret)), ready('I')];
|
|
244
|
+
this.write(Buffer.concat(out));
|
|
245
|
+
log(`[pg] ${this.user}@${this.database} connected (${this.params.application_name || 'no application name'})`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---- messages after startup ----
|
|
249
|
+
async handle(type, body) {
|
|
250
|
+
if (!this.authed) {
|
|
251
|
+
if (type === 'p') return this.password(body);
|
|
252
|
+
return this.reject('expected a password message');
|
|
253
|
+
}
|
|
254
|
+
if (type === 'X') { this.socket.end(); this.closed = true; return; }
|
|
255
|
+
if (this.skipToSync && type !== 'S') return; // (after an error in the extended protocol)
|
|
256
|
+
try {
|
|
257
|
+
switch (type) {
|
|
258
|
+
case 'Q': return await this.simpleQuery(body.toString('utf8').replace(/\0$/, ''));
|
|
259
|
+
case 'P': return this.parse(body);
|
|
260
|
+
case 'B': return this.bind(body);
|
|
261
|
+
case 'D': return await this.describe(body);
|
|
262
|
+
case 'E': return await this.execute(body);
|
|
263
|
+
case 'C': { const kind = String.fromCharCode(body[0]), name = body.subarray(1, body.indexOf(0, 1)).toString(); (kind === 'S' ? this.statements : this.portals).delete(name); return this.write(message('3')); }
|
|
264
|
+
case 'S': this.skipToSync = false; if (this.tx === 'I') this.portals.clear(); return this.write(ready(this.tx));
|
|
265
|
+
case 'H': return undefined;
|
|
266
|
+
case 'F': throw new PgError('0A000', 'function calls are not supported');
|
|
267
|
+
case 'd': case 'c': case 'f': throw new PgError('0A000', 'COPY is not supported');
|
|
268
|
+
default: throw new PgError('08P01', `unknown message type ${type}`);
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
if (!(err instanceof PgError) && !/^vectro/.test(err.message)) log(`[pg] error: ${err.stack}`);
|
|
272
|
+
this.write(errorMessage(err));
|
|
273
|
+
if (this.tx === 'T') this.tx = 'E';
|
|
274
|
+
if (type !== 'Q') this.skipToSync = true;
|
|
275
|
+
else this.write(ready(this.tx));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async simpleQuery(text) {
|
|
280
|
+
const statements = splitStatements(text);
|
|
281
|
+
if (!statements.length) { this.write(Buffer.concat([message('I'), ready(this.tx)])); return; }
|
|
282
|
+
for (const st of statements) {
|
|
283
|
+
if (this.tx === 'E' && !/^\s*(rollback|abort|commit|end)\b/i.test(st)) throw new PgError('25P02', 'current transaction is aborted, commands ignored until end of transaction block');
|
|
284
|
+
const res = await this.run(st);
|
|
285
|
+
if (res.empty) { this.write(message('I')); continue; }
|
|
286
|
+
if (res.columns) {
|
|
287
|
+
const formats = res.columns.map(() => (res.binary ? 1 : 0));
|
|
288
|
+
this.write(rowDescription(res.columns, formats));
|
|
289
|
+
await this.sendRows(res, 0, res.n, formats);
|
|
290
|
+
this.write(complete(res.tag === 'SELECT' ? `SELECT ${res.n}` : res.tag === 'FETCH' ? `FETCH ${res.n}` : res.tag));
|
|
291
|
+
} else this.write(complete(res.tag));
|
|
292
|
+
}
|
|
293
|
+
this.write(ready(this.tx));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
parse(body) {
|
|
297
|
+
let at = body.indexOf(0);
|
|
298
|
+
const name = body.subarray(0, at).toString();
|
|
299
|
+
const end = body.indexOf(0, at + 1);
|
|
300
|
+
const text = body.subarray(at + 1, end).toString('utf8');
|
|
301
|
+
at = end + 1;
|
|
302
|
+
const n = body.readInt16BE(at);
|
|
303
|
+
const oids = [];
|
|
304
|
+
for (let i = 0; i < n; i++) oids.push(body.readInt32BE(at + 2 + 4 * i));
|
|
305
|
+
if (splitStatements(text).length > 1) throw new PgError('42601', 'cannot insert multiple commands into a prepared statement');
|
|
306
|
+
this.statements.set(name, { text, oids });
|
|
307
|
+
this.write(message('1'));
|
|
308
|
+
}
|
|
309
|
+
bind(body) {
|
|
310
|
+
let at = body.indexOf(0);
|
|
311
|
+
const portal = body.subarray(0, at).toString();
|
|
312
|
+
const end = body.indexOf(0, at + 1);
|
|
313
|
+
const name = body.subarray(at + 1, end).toString();
|
|
314
|
+
at = end + 1;
|
|
315
|
+
const st = this.statements.get(name);
|
|
316
|
+
if (!st) throw new PgError('26000', `prepared statement "${name}" does not exist`);
|
|
317
|
+
const nf = body.readInt16BE(at); at += 2;
|
|
318
|
+
const fmts = [];
|
|
319
|
+
for (let i = 0; i < nf; i++) { fmts.push(body.readInt16BE(at)); at += 2; }
|
|
320
|
+
const np = body.readInt16BE(at); at += 2;
|
|
321
|
+
const literals = [];
|
|
322
|
+
for (let i = 0; i < np; i++) {
|
|
323
|
+
const len = body.readInt32BE(at); at += 4;
|
|
324
|
+
const bytes = len < 0 ? null : body.subarray(at, at + len);
|
|
325
|
+
if (len > 0) at += len;
|
|
326
|
+
literals.push(paramLiteral(bytes, nf === 1 ? fmts[0] : fmts[i] ?? 0, st.oids[i] ?? 0));
|
|
327
|
+
}
|
|
328
|
+
const nr = body.readInt16BE(at); at += 2;
|
|
329
|
+
const results = [];
|
|
330
|
+
for (let i = 0; i < nr; i++) { results.push(body.readInt16BE(at)); at += 2; }
|
|
331
|
+
this.portals.set(portal, { text: bindParams(st.text, literals), results, res: null, offset: 0 });
|
|
332
|
+
this.write(message('2'));
|
|
333
|
+
}
|
|
334
|
+
formatsFor(portal, columns) {
|
|
335
|
+
const r = portal.results;
|
|
336
|
+
return columns.map((c, k) => { const f = r.length === 1 ? r[0] : r[k] ?? 0; return f === 1 && BINARY.has(c.type) ? 1 : 0; });
|
|
337
|
+
}
|
|
338
|
+
async describe(body) {
|
|
339
|
+
const kind = String.fromCharCode(body[0]), name = body.subarray(1, body.indexOf(0, 1)).toString();
|
|
340
|
+
if (kind === 'S') {
|
|
341
|
+
const st = this.statements.get(name);
|
|
342
|
+
if (!st) throw new PgError('26000', `prepared statement "${name}" does not exist`);
|
|
343
|
+
const count = countParams(st.text);
|
|
344
|
+
const oids = Array.from({ length: count }, (_, i) => (st.oids[i] || OID.text));
|
|
345
|
+
this.write(message('t', int16(count), ...oids.map(int32)));
|
|
346
|
+
const columns = await this.columnsOnly(bindParams(st.text, Array.from({ length: count }, () => 'NULL')));
|
|
347
|
+
this.write(columns ? rowDescription(columns, columns.map(() => 0)) : message('n'));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const portal = this.portals.get(name);
|
|
351
|
+
if (!portal) throw new PgError('34000', `portal "${name}" does not exist`);
|
|
352
|
+
portal.res ??= await this.run(portal.text);
|
|
353
|
+
const res = portal.res;
|
|
354
|
+
this.write(res.columns ? rowDescription(res.columns, this.formatsFor(portal, res.columns)) : message('n'));
|
|
355
|
+
}
|
|
356
|
+
async execute(body) {
|
|
357
|
+
const at = body.indexOf(0), name = body.subarray(0, at).toString(), max = body.readInt32BE(at + 1);
|
|
358
|
+
const portal = this.portals.get(name);
|
|
359
|
+
if (!portal) throw new PgError('34000', `portal "${name}" does not exist`);
|
|
360
|
+
if (this.tx === 'E' && !/^\s*(rollback|abort|commit|end)\b/i.test(portal.text)) throw new PgError('25P02', 'current transaction is aborted, commands ignored until end of transaction block');
|
|
361
|
+
portal.res ??= await this.run(portal.text);
|
|
362
|
+
const res = portal.res;
|
|
363
|
+
if (res.empty) return this.write(message('I'));
|
|
364
|
+
if (!res.columns) { this.write(complete(res.tag)); return; }
|
|
365
|
+
const end = max > 0 ? Math.min(res.n, portal.offset + max) : res.n;
|
|
366
|
+
await this.sendRows(res, portal.offset, end, this.formatsFor(portal, res.columns));
|
|
367
|
+
const sent = end - portal.offset;
|
|
368
|
+
portal.offset = end;
|
|
369
|
+
if (end < res.n) this.write(message('s')); // PortalSuspended: Execute again for more
|
|
370
|
+
else this.write(complete(res.tag === 'SELECT' ? `SELECT ${portal.offset}` : res.tag === 'FETCH' ? `FETCH ${sent}` : res.tag));
|
|
371
|
+
}
|
|
372
|
+
async sendRows(res, from, to, formats) {
|
|
373
|
+
for (let a = from; a < to; a += BATCH) {
|
|
374
|
+
if (this.cancelled) { this.cancelled = false; throw new PgError('57014', 'canceling statement due to user request'); }
|
|
375
|
+
await this.writeAll([dataRows(res.columns, formats, res.cell, a, Math.min(to, a + BATCH), !!res.keepEmpty)]);
|
|
376
|
+
if (this.closed) return;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---- statements ----
|
|
381
|
+
sqlOpts() {
|
|
382
|
+
return { types: true, foldCase: true, database: this.database, session: {
|
|
383
|
+
version: `PostgreSQL ${VERSION} (Vectro) on JavaScript`, current_user: this.user, session_user: this.user, user: this.user, current_role: this.user,
|
|
384
|
+
current_database: this.database, current_catalog: this.database, current_schema: 'public', pg_backend_pid: this.pid,
|
|
385
|
+
pg_is_in_recovery: false, inet_server_port: port, txid_current: 1, pg_postmaster_start_time: STARTED,
|
|
386
|
+
}, functions: this.functions ??= catalogFunctions(this) };
|
|
387
|
+
}
|
|
388
|
+
tablesFor() {
|
|
389
|
+
const c = catalogFor(this.database, this.user);
|
|
390
|
+
return { tables: { ...catalog.named(), ...c.tables }, described: c.described };
|
|
391
|
+
}
|
|
392
|
+
// a query's columns without running it (Describe of a prepared statement)
|
|
393
|
+
async columnsOnly(text) {
|
|
394
|
+
const t = stripComments(text);
|
|
395
|
+
const word = firstWord(t);
|
|
396
|
+
if (!['SELECT', 'WITH', 'VALUES', 'TABLE', '(', 'SHOW', 'FETCH', 'EXPLAIN'].includes(word)) return null;
|
|
397
|
+
if (word !== 'SELECT' && word !== 'WITH' && word !== '(') { const r = await this.run(text); return r.columns ?? null; }
|
|
398
|
+
const { tables, described } = this.tablesFor();
|
|
399
|
+
const known = driverAnswer(text, described, this.database);
|
|
400
|
+
if (known) return known.columns.map((c) => ({ name: c.name, type: c.type }));
|
|
401
|
+
try {
|
|
402
|
+
const q = sql(t, tables, this.sqlOpts());
|
|
403
|
+
return q.columns.map((c) => ({ name: displayName(c.name), type: TYPE_OF[c.type] ?? 'float8' }));
|
|
404
|
+
} catch (err) {
|
|
405
|
+
if (readsCatalog(t)) return selectNames(t).map((name) => ({ name, type: 'text' }));
|
|
406
|
+
throw err;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async run(text) {
|
|
410
|
+
const t = stripComments(text).replace(/;\s*$/, '');
|
|
411
|
+
if (!t.trim()) return { empty: true };
|
|
412
|
+
if (logQueries) log(`[pg] ${this.user}: ${text.replace(/\s+/g, ' ').trim().slice(0, 20_000)}`);
|
|
413
|
+
const word = firstWord(t);
|
|
414
|
+
switch (word) {
|
|
415
|
+
case 'SELECT': case 'WITH': case '(': case 'VALUES': case 'TABLE': return this.query(word === 'TABLE' ? `SELECT * FROM ${t.trim().slice(5)}` : text.replace(/;\s*$/, '')); // (with its comments: known driver queries are told by them)
|
|
416
|
+
case 'SET': this.set(t); return { tag: 'SET' };
|
|
417
|
+
case 'RESET': return { tag: 'RESET' };
|
|
418
|
+
case 'SHOW': return this.show(t);
|
|
419
|
+
case 'BEGIN': case 'START': this.tx = 'T'; return { tag: 'BEGIN' };
|
|
420
|
+
case 'COMMIT': case 'END': { const was = this.tx; this.tx = 'I'; this.closeCursors(); return { tag: was === 'E' ? 'ROLLBACK' : 'COMMIT' }; }
|
|
421
|
+
case 'ROLLBACK': case 'ABORT':
|
|
422
|
+
if (/^\s*rollback\s+to\b/i.test(t)) { if (this.tx === 'E') this.tx = 'T'; return { tag: 'ROLLBACK' }; }
|
|
423
|
+
this.tx = 'I'; this.closeCursors(); return { tag: 'ROLLBACK' };
|
|
424
|
+
case 'SAVEPOINT': return { tag: 'SAVEPOINT' };
|
|
425
|
+
case 'RELEASE': return { tag: 'RELEASE' };
|
|
426
|
+
case 'DISCARD': this.statements.clear(); this.portals.clear(); this.cursors.clear(); this.settings = { ...SETTINGS }; return { tag: 'DISCARD ALL' };
|
|
427
|
+
case 'DEALLOCATE': { const m = /^\s*deallocate\s+(?:prepare\s+)?("?)([^"\s;]+)\1/i.exec(t); if (m?.[2].toLowerCase() === 'all') this.statements.clear(); else if (m) this.statements.delete(m[2]); return { tag: 'DEALLOCATE' }; }
|
|
428
|
+
case 'DECLARE': return this.declare(t);
|
|
429
|
+
case 'FETCH': case 'MOVE': return this.fetch(t, word);
|
|
430
|
+
case 'CLOSE': { const m = /^\s*close\s+("?)([^"\s;]+)\1/i.exec(t); if (m?.[2].toLowerCase() === 'all') this.cursors.clear(); else if (m) this.cursors.delete(m[2]); return { tag: 'CLOSE CURSOR' }; }
|
|
431
|
+
case 'EXPLAIN': {
|
|
432
|
+
const inner = t.replace(/^\s*explain\s+(\([^)]*\)\s*|analyze\s+|verbose\s+)*/i, '');
|
|
433
|
+
const { tables } = this.tablesFor();
|
|
434
|
+
const plan = sql(inner, tables, this.sqlOpts()).explain().split('\n');
|
|
435
|
+
return rowsOf([{ name: 'QUERY PLAN', type: 'text' }], plan.map((l) => [l]), 'EXPLAIN');
|
|
436
|
+
}
|
|
437
|
+
case 'LISTEN': case 'UNLISTEN': case 'NOTIFY': return { tag: word };
|
|
438
|
+
case 'ANALYZE': case 'VACUUM': case 'CHECKPOINT': return { tag: word };
|
|
439
|
+
case 'CREATE': case 'INSERT': case 'UPDATE': case 'DELETE': case 'DROP': case 'ALTER': case 'TRUNCATE': case 'COPY': case 'GRANT': case 'REVOKE': case 'MERGE': case 'COMMENT': case 'REFRESH': case 'LOCK': case 'CALL': case 'DO':
|
|
440
|
+
throw new PgError('25006', `Vectro serves its tables read only: ${word} is not possible here`);
|
|
441
|
+
case 'PREPARE': case 'EXECUTE': throw new PgError('0A000', `${word} statements are not supported (use the extended query protocol)`);
|
|
442
|
+
default: throw new PgError('42601', `syntax error at or near "${t.trim().split(/\s+/)[0]}"`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
async query(t) {
|
|
446
|
+
const { tables, described } = this.tablesFor();
|
|
447
|
+
const known = driverAnswer(t, described, this.database) ?? specialAnswer(t, this);
|
|
448
|
+
if (known) return rowsOf(known.columns, known.rows);
|
|
449
|
+
let q;
|
|
450
|
+
try { q = sql(t, tables, this.sqlOpts()); } catch (err) {
|
|
451
|
+
if (readsCatalog(t)) { // a catalog query beyond Vectro's SQL: nothing to report (no keys, indexes, comments...)
|
|
452
|
+
if (logQueries) log(`[pg] answered empty: ${err.message}`);
|
|
453
|
+
return rowsOf(selectNames(t).map((name) => ({ name, type: 'text' })), []);
|
|
454
|
+
}
|
|
455
|
+
throw err;
|
|
456
|
+
}
|
|
457
|
+
const columns = q.columns.map((c) => ({ name: displayName(c.name), col: c.name, type: TYPE_OF[c.type] ?? 'float8' }));
|
|
458
|
+
const table = await q.collectAsync();
|
|
459
|
+
try { return await tableRows(columns, table); } finally { table.dispose(); }
|
|
460
|
+
}
|
|
461
|
+
set(t) {
|
|
462
|
+
const m = /^\s*set\s+(?:session\s+|local\s+)?("?)([\w.]+)\1\s*(?:=|\s+to\s+)\s*(.*?)\s*$/is.exec(t);
|
|
463
|
+
if (!m) return; // (SET SESSION CHARACTERISTICS..., SET NAMES...: accepted)
|
|
464
|
+
const name = m[2].toLowerCase(), value = m[3].replace(/^'(.*)'$/s, '$1').replace(/^"(.*)"$/s, '$1');
|
|
465
|
+
this.settings[name] = value;
|
|
466
|
+
if (name === 'application_name') this.write(message('S', cstr('application_name'), cstr(value)));
|
|
467
|
+
}
|
|
468
|
+
show(t) {
|
|
469
|
+
const name = t.trim().replace(/^show\s+/i, '').replace(/;$/, '').trim().toLowerCase().replace(/^"(.*)"$/, '$1');
|
|
470
|
+
const key = name === 'transaction isolation level' ? 'transaction_isolation' : name;
|
|
471
|
+
if (key === 'all') return rowsOf([{ name: 'name', type: 'text' }, { name: 'setting', type: 'text' }, { name: 'description', type: 'text' }], Object.entries(this.settings).map(([k, v]) => [k, v, '']), 'SHOW');
|
|
472
|
+
const value = this.settings[key] ?? this.settings[Object.keys(this.settings).find((k) => k.toLowerCase() === key)];
|
|
473
|
+
if (value === undefined) throw new PgError('42704', `unrecognized configuration parameter "${name}"`);
|
|
474
|
+
return rowsOf([{ name: key, type: 'text' }], [[value]], 'SHOW');
|
|
475
|
+
}
|
|
476
|
+
async declare(t) {
|
|
477
|
+
const m = /^\s*declare\s+("?)([^"\s]+)\1\s+(binary\s+)?(?:(?:insensitive|asensitive|no\s+scroll|scroll)\s+)*cursor\s+(?:(?:with|without)\s+hold\s+)?for\s+([\s\S]+)$/i.exec(t);
|
|
478
|
+
if (!m) throw new PgError('42601', 'DECLARE name CURSOR FOR query');
|
|
479
|
+
const res = await this.run(m[4]);
|
|
480
|
+
if (!res.columns) throw new PgError('42601', 'a cursor needs a query');
|
|
481
|
+
this.cursors.set(m[2], { res, offset: 0, binary: !!m[3] });
|
|
482
|
+
return { tag: 'DECLARE CURSOR' };
|
|
483
|
+
}
|
|
484
|
+
fetch(t, word) {
|
|
485
|
+
const m = /^\s*(?:fetch|move)\s+(?:(next|prior|first|last|all|forward\s+all|forward\s+\d+|forward|absolute\s+-?\d+|relative\s+-?\d+|-?\d+)\s+)?(?:from\s+|in\s+)?("?)([^"\s;]+)\2\s*$/i.exec(t);
|
|
486
|
+
if (!m) throw new PgError('42601', 'FETCH [n | ALL | NEXT] FROM cursor');
|
|
487
|
+
const c = this.cursors.get(m[3]);
|
|
488
|
+
if (!c) throw new PgError('34000', `cursor "${m[3]}" does not exist`);
|
|
489
|
+
const how = (m[1] ?? 'next').toLowerCase().replace(/\s+/g, ' ');
|
|
490
|
+
const left = c.res.n - c.offset;
|
|
491
|
+
const count = how === 'next' || how === 'forward' ? 1 : /all/.test(how) ? left : /^\d+$/.test(how) ? Number(how) : /^forward \d+$/.test(how) ? Number(how.split(' ')[1]) : null;
|
|
492
|
+
if (count === null) throw new PgError('0A000', `FETCH ${how.toUpperCase()} is not supported (cursors here move forward)`);
|
|
493
|
+
const take = Math.max(0, Math.min(left, count)), start = c.offset;
|
|
494
|
+
c.offset += take;
|
|
495
|
+
if (word === 'MOVE') return { tag: `MOVE ${take}` };
|
|
496
|
+
const res = c.res;
|
|
497
|
+
return { columns: res.columns, n: take, cell: (i, k) => res.cell(start + i, k), tag: 'FETCH', binary: c.binary, keepEmpty: res.keepEmpty };
|
|
498
|
+
}
|
|
499
|
+
closeCursors() { this.cursors.clear(); }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
server.listen(port, host);
|
|
503
|
+
await once(server, 'listening');
|
|
504
|
+
const actual = server.address().port;
|
|
505
|
+
return {
|
|
506
|
+
port: actual,
|
|
507
|
+
host,
|
|
508
|
+
server,
|
|
509
|
+
catalog,
|
|
510
|
+
url: `postgresql://${user ?? 'vectro'}@${host.includes(':') ? `[${host}]` : host}:${actual}/${database}`,
|
|
511
|
+
async close() {
|
|
512
|
+
for (const s of sessions.values()) s.socket.destroy();
|
|
513
|
+
await new Promise((r) => server.close(r));
|
|
514
|
+
disposeCatalogs();
|
|
515
|
+
if (!shared) await catalog.close();
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const STARTED = new Date().toISOString();
|
|
521
|
+
const SETTINGS = {
|
|
522
|
+
server_version: VERSION, server_version_num: '160000', server_encoding: 'UTF8', client_encoding: 'UTF8', DateStyle: 'ISO, MDY', TimeZone: 'UTC',
|
|
523
|
+
integer_datetimes: 'on', standard_conforming_strings: 'on', transaction_isolation: 'read committed', default_transaction_isolation: 'read committed',
|
|
524
|
+
transaction_read_only: 'on', default_transaction_read_only: 'on', search_path: '"$user", public', max_identifier_length: '63', max_index_keys: '32',
|
|
525
|
+
block_size: '8192', lc_collate: 'C', lc_ctype: 'C', max_connections: '100', extra_float_digits: '1', IntervalStyle: 'postgres', application_name: '',
|
|
526
|
+
statement_timeout: '0', lock_timeout: '0', bytea_output: 'hex', is_superuser: 'off',
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
// functions catalog queries call (psql's \\dt, DBeaver, Metabase...)
|
|
530
|
+
function catalogFunctions(session) {
|
|
531
|
+
const yes = { out: 'bool', f: () => 1 }, none = { out: 'text', f: () => null }, zero = { out: 'number', f: () => 0 };
|
|
532
|
+
const may = { out: 'bool', f: (...args) => (/^\s*(select|usage|connect|execute)\b/i.test(String(args.at(-1) ?? '')) ? 1 : 0) }; // (read only: reading, nothing else)
|
|
533
|
+
return {
|
|
534
|
+
pg_get_userbyid: { out: 'text', f: () => session.user },
|
|
535
|
+
pg_table_is_visible: yes, pg_type_is_visible: yes, pg_function_is_visible: yes,
|
|
536
|
+
has_table_privilege: may, has_schema_privilege: may, has_database_privilege: may, has_column_privilege: may, has_any_column_privilege: may, has_function_privilege: may, has_sequence_privilege: may,
|
|
537
|
+
format_type: { out: 'text', f: (oid) => typeName(Number(oid)) ?? 'unknown' },
|
|
538
|
+
pg_get_expr: none, obj_description: none, col_description: none, shobj_description: none,
|
|
539
|
+
pg_get_constraintdef: none, pg_get_indexdef: none, pg_get_viewdef: none, pg_get_triggerdef: none, pg_get_functiondef: none, pg_tablespace_location: none,
|
|
540
|
+
pg_encoding_to_char: { out: 'text', f: () => 'UTF8' },
|
|
541
|
+
array_to_string: { out: 'text', f: (x) => x },
|
|
542
|
+
quote_ident: { out: 'text', f: (x) => (x === null ? null : /^[a-z_][a-z0-9_$]*$/.test(x) ? x : `"${String(x).replaceAll('"', '""')}"`) },
|
|
543
|
+
pg_size_pretty: { out: 'text', f: (n) => (n === null ? null : `${n} bytes`) },
|
|
544
|
+
pg_total_relation_size: zero, pg_relation_size: zero, pg_table_size: zero, pg_indexes_size: zero, pg_database_size: zero,
|
|
545
|
+
current_setting: { out: 'text', f: (name) => session.settings[name] ?? null },
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// a few calls with arguments that answer from the session
|
|
550
|
+
function specialAnswer(t, session) {
|
|
551
|
+
const m = /^\s*select\s+(?:pg_catalog\.)?current_setting\(\s*'([^']+)'\s*\)\s*(?:as\s+("?)(\w+)\2)?\s*$/i.exec(t);
|
|
552
|
+
if (m) {
|
|
553
|
+
const v = session.settings[m[1]] ?? session.settings[Object.keys(session.settings).find((k) => k.toLowerCase() === m[1].toLowerCase())];
|
|
554
|
+
if (v === undefined) throw new PgError('42704', `unrecognized configuration parameter "${m[1]}"`);
|
|
555
|
+
return { columns: [{ name: m[3] ?? 'current_setting', type: 'text' }], rows: [[v]] };
|
|
556
|
+
}
|
|
557
|
+
const sc = /^\s*select\s+(?:pg_catalog\.)?set_config\(\s*'([^']+)'\s*,\s*'([^']*)'\s*,\s*(?:true|false)\s*\)\s*;?\s*$/i.exec(t);
|
|
558
|
+
if (sc) { session.settings[sc[1]] = sc[2]; return { columns: [{ name: 'set_config', type: 'text' }], rows: [[sc[2]]] }; }
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const safeEqual = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && timingSafeEqual(x, y); };
|
|
563
|
+
const firstWord = (t) => { const s = t.trimStart(); if (s[0] === '(') return '('; return (/^[A-Za-z_]+/.exec(s)?.[0] ?? '').toUpperCase(); };
|
|
564
|
+
function stripComments(text) { // leading comments only
|
|
565
|
+
let s = text;
|
|
566
|
+
for (;;) {
|
|
567
|
+
const t = s.trimStart();
|
|
568
|
+
if (t.startsWith('--')) { const j = t.indexOf('\n'); s = j < 0 ? '' : t.slice(j + 1); continue; }
|
|
569
|
+
if (t.startsWith('/*')) { const j = t.indexOf('*/', 2); s = j < 0 ? '' : t.slice(j + 2); continue; }
|
|
570
|
+
return s;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const countParams = (text) => { let max = 0; text.replace(/\$(\d+)/g, (m, d) => { max = Math.max(max, Number(d)); return m; }); return max; };
|
|
574
|
+
// statements of a simple query, split at semicolons outside quotes and comments
|
|
575
|
+
function splitStatements(text) {
|
|
576
|
+
const out = [];
|
|
577
|
+
let cur = '', i = 0;
|
|
578
|
+
while (i < text.length) {
|
|
579
|
+
const c = text[i];
|
|
580
|
+
if (c === "'" || c === '"') { let j = i + 1; for (; j < text.length; j++) if (text[j] === c) { if (text[j + 1] === c) j++; else break; } cur += text.slice(i, j + 1); i = j + 1; continue; }
|
|
581
|
+
if (c === '-' && text[i + 1] === '-') { const j = text.indexOf('\n', i); const e = j < 0 ? text.length : j; cur += text.slice(i, e); i = e; continue; }
|
|
582
|
+
if (c === '/' && text[i + 1] === '*') { const j = text.indexOf('*/', i + 2); const e = j < 0 ? text.length : j + 2; cur += text.slice(i, e); i = e; continue; }
|
|
583
|
+
if (c === ';') { if (cur.trim()) out.push(cur); cur = ''; i++; continue; }
|
|
584
|
+
cur += c; i++;
|
|
585
|
+
}
|
|
586
|
+
if (stripComments(cur).trim()) out.push(cur);
|
|
587
|
+
return out;
|
|
588
|
+
}
|