@adrdp/pg-rest 0.0.0-stage → 0.1.1
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/README.md +184 -2
- package/dist/adapter-SqPs6SuJ.d.ts +27 -0
- package/dist/adapters/bun.d.ts +22 -0
- package/dist/adapters/bun.js +22 -0
- package/dist/adapters/node-postgres.d.ts +27 -0
- package/dist/adapters/node-postgres.js +34 -0
- package/dist/adapters/pglite.d.ts +24 -0
- package/dist/adapters/pglite.js +20 -0
- package/dist/adapters/postgres-js.d.ts +24 -0
- package/dist/adapters/postgres-js.js +21 -0
- package/dist/index.d.ts +259 -0
- package/dist/index.js +3549 -0
- package/package.json +87 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,3549 @@
|
|
|
1
|
+
//#region src/errors.ts
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by the pure parts of the library (parsers, SQL builders). It is
|
|
4
|
+
* converted into an {@link ApiError} at the request boundary.
|
|
5
|
+
*/
|
|
6
|
+
var PgRestError = class extends Error {
|
|
7
|
+
constructor(status, code, message, details = null, hint = null, headers = {}) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.details = details;
|
|
12
|
+
this.hint = hint;
|
|
13
|
+
this.headers = headers;
|
|
14
|
+
this.name = "PgRestError";
|
|
15
|
+
}
|
|
16
|
+
get body() {
|
|
17
|
+
return {
|
|
18
|
+
code: this.code,
|
|
19
|
+
message: this.message,
|
|
20
|
+
details: this.details,
|
|
21
|
+
hint: this.hint
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/** An error that has already been mapped to an HTTP response. */
|
|
26
|
+
var ApiError = class extends Error {
|
|
27
|
+
_tag = "ApiError";
|
|
28
|
+
status;
|
|
29
|
+
body;
|
|
30
|
+
headers;
|
|
31
|
+
constructor(fields) {
|
|
32
|
+
super(fields.body.message);
|
|
33
|
+
this.name = "ApiError";
|
|
34
|
+
this.status = fields.status;
|
|
35
|
+
this.body = fields.body;
|
|
36
|
+
this.headers = fields.headers;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
/** Normalized error thrown by a {@link DbAdapter}. */
|
|
40
|
+
var DbQueryError = class extends Error {
|
|
41
|
+
constructor(sqlState, message, detail, hint) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.sqlState = sqlState;
|
|
44
|
+
this.detail = detail;
|
|
45
|
+
this.hint = hint;
|
|
46
|
+
this.name = "DbQueryError";
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const SQLSTATE = /^[0-9A-Z]{5}$/;
|
|
50
|
+
/**
|
|
51
|
+
* Recognise a database error thrown by any driver and turn it into a {@link DbQueryError}.
|
|
52
|
+
* Drivers put the SQLSTATE in different places: `code` (node-postgres, postgres.js, PGlite),
|
|
53
|
+
* `errno` (Bun.sql) or `sqlState`. Anything else (connection errors, bugs) is returned unchanged.
|
|
54
|
+
*/
|
|
55
|
+
const normalizeDbError = (e) => {
|
|
56
|
+
if (e instanceof DbQueryError || !e || typeof e !== "object") return e;
|
|
57
|
+
const x = e;
|
|
58
|
+
for (const key of [
|
|
59
|
+
"sqlState",
|
|
60
|
+
"code",
|
|
61
|
+
"errno"
|
|
62
|
+
]) {
|
|
63
|
+
const v = x[key];
|
|
64
|
+
if (typeof v === "string" && SQLSTATE.test(v)) return new DbQueryError(v, String(x["message"] ?? ""), typeof x["detail"] === "string" ? x["detail"] : void 0, typeof x["hint"] === "string" ? x["hint"] : void 0);
|
|
65
|
+
}
|
|
66
|
+
return e;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* SQLSTATE → HTTP status, following the PostgREST 16 table
|
|
70
|
+
* (https://docs.postgrest.org/en/v16/references/errors.html).
|
|
71
|
+
*/
|
|
72
|
+
const statusFromSqlState = (state, role, anon) => {
|
|
73
|
+
if (/^PT\d{3}$/.test(state)) return Number(state.slice(2));
|
|
74
|
+
switch (state) {
|
|
75
|
+
case "23503":
|
|
76
|
+
case "23505": return 409;
|
|
77
|
+
case "25006": return 405;
|
|
78
|
+
case "42883":
|
|
79
|
+
case "42P01": return 404;
|
|
80
|
+
case "42P17": return 500;
|
|
81
|
+
case "42501": return role === anon ? 401 : 403;
|
|
82
|
+
case "P0001": return 400;
|
|
83
|
+
case "53400": return 500;
|
|
84
|
+
}
|
|
85
|
+
switch (state.slice(0, 2)) {
|
|
86
|
+
case "08":
|
|
87
|
+
case "53": return 503;
|
|
88
|
+
case "0L":
|
|
89
|
+
case "0P":
|
|
90
|
+
case "28": return 403;
|
|
91
|
+
case "09":
|
|
92
|
+
case "25":
|
|
93
|
+
case "2D":
|
|
94
|
+
case "38":
|
|
95
|
+
case "39":
|
|
96
|
+
case "3B":
|
|
97
|
+
case "40":
|
|
98
|
+
case "54":
|
|
99
|
+
case "55":
|
|
100
|
+
case "57":
|
|
101
|
+
case "58":
|
|
102
|
+
case "F0":
|
|
103
|
+
case "HV":
|
|
104
|
+
case "P0":
|
|
105
|
+
case "XX": return 500;
|
|
106
|
+
default: return 400;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Map a Postgres error to a PostgREST-style API error.
|
|
111
|
+
*
|
|
112
|
+
* Supports PostgREST's custom error conventions:
|
|
113
|
+
* - `RAISE ... USING ERRCODE = 'PTxyz'` → HTTP status xyz
|
|
114
|
+
* - `RAISE SQLSTATE 'PGRST'` with a JSON message/detail
|
|
115
|
+
*/
|
|
116
|
+
const dbErrorToApiError = (err, role, anonymousRole) => {
|
|
117
|
+
if (err.sqlState === "PGRST") try {
|
|
118
|
+
const msg = JSON.parse(err.message);
|
|
119
|
+
const det = err.detail ? JSON.parse(err.detail) : {};
|
|
120
|
+
return new ApiError({
|
|
121
|
+
status: det.status ?? 500,
|
|
122
|
+
headers: det.headers ?? {},
|
|
123
|
+
body: {
|
|
124
|
+
code: msg.code ?? "PGRST",
|
|
125
|
+
message: msg.message ?? "",
|
|
126
|
+
details: msg.details ?? null,
|
|
127
|
+
hint: msg.hint ?? null
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
} catch {
|
|
131
|
+
return new ApiError({
|
|
132
|
+
status: 500,
|
|
133
|
+
headers: {},
|
|
134
|
+
body: {
|
|
135
|
+
code: "PGRST121",
|
|
136
|
+
message: "Could not parse JSON in the RAISE error",
|
|
137
|
+
details: err.message,
|
|
138
|
+
hint: null
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return new ApiError({
|
|
143
|
+
status: statusFromSqlState(err.sqlState, role, anonymousRole),
|
|
144
|
+
headers: {},
|
|
145
|
+
body: {
|
|
146
|
+
code: err.sqlState,
|
|
147
|
+
message: err.message,
|
|
148
|
+
details: err.detail ?? null,
|
|
149
|
+
hint: err.hint ?? null
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/parse/field.ts
|
|
156
|
+
const parseError = (message, details) => new PgRestError(400, "PGRST100", message, details ?? null);
|
|
157
|
+
/**
|
|
158
|
+
* Scan `s` while tracking double-quote and parenthesis state, calling `visit`
|
|
159
|
+
* with the index of every character that is at depth 0 and outside quotes.
|
|
160
|
+
* Returning `true` from `visit` stops the scan.
|
|
161
|
+
*/
|
|
162
|
+
const scanTopLevel = (s, visit) => {
|
|
163
|
+
let depth = 0;
|
|
164
|
+
let quoted = false;
|
|
165
|
+
for (let i = 0; i < s.length; i++) {
|
|
166
|
+
const ch = s[i];
|
|
167
|
+
if (quoted) {
|
|
168
|
+
if (ch === "\\") i++;
|
|
169
|
+
else if (ch === "\"") quoted = false;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (ch === "\"") {
|
|
173
|
+
quoted = true;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (ch === "(") {
|
|
177
|
+
depth++;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (ch === ")") {
|
|
181
|
+
depth--;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (depth === 0 && visit(i, ch) === true) return;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
/** Split on `sep` at depth 0, outside quotes. */
|
|
188
|
+
const splitTopLevel = (s, sep) => {
|
|
189
|
+
const parts = [];
|
|
190
|
+
let start = 0;
|
|
191
|
+
scanTopLevel(s, (i, ch) => {
|
|
192
|
+
if (ch === sep) {
|
|
193
|
+
parts.push(s.slice(start, i));
|
|
194
|
+
start = i + 1;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
parts.push(s.slice(start));
|
|
198
|
+
return parts;
|
|
199
|
+
};
|
|
200
|
+
const unquote = (s) => {
|
|
201
|
+
const t = s.trim();
|
|
202
|
+
if (t.length >= 2 && t.startsWith("\"") && t.endsWith("\"")) return t.slice(1, -1).replace(/\\(["\\])/g, "$1");
|
|
203
|
+
return t;
|
|
204
|
+
};
|
|
205
|
+
/** Parse `col`, `"odd col"`, `col->a->>b`, `col->0`. */
|
|
206
|
+
const parseField = (input) => {
|
|
207
|
+
const s = input.trim();
|
|
208
|
+
if (!s) throw parseError("Empty column name");
|
|
209
|
+
const tokens = [];
|
|
210
|
+
const arrows = [];
|
|
211
|
+
let start = 0;
|
|
212
|
+
let quoted = false;
|
|
213
|
+
for (let i = 0; i < s.length; i++) {
|
|
214
|
+
const ch = s[i];
|
|
215
|
+
if (quoted) {
|
|
216
|
+
if (ch === "\\") i++;
|
|
217
|
+
else if (ch === "\"") quoted = false;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (ch === "\"") quoted = true;
|
|
221
|
+
else if (ch === "-" && s[i + 1] === ">") {
|
|
222
|
+
const long = s[i + 2] === ">";
|
|
223
|
+
tokens.push(s.slice(start, i));
|
|
224
|
+
arrows.push(long ? "->>" : "->");
|
|
225
|
+
i += long ? 2 : 1;
|
|
226
|
+
start = i + 1;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
tokens.push(s.slice(start));
|
|
230
|
+
const name = unquote(tokens[0]);
|
|
231
|
+
if (!name) throw parseError("Empty column name", `in "${input}"`);
|
|
232
|
+
const json = [];
|
|
233
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
234
|
+
const raw = tokens[i].trim();
|
|
235
|
+
if (!raw) throw parseError("Invalid JSON path", `in "${input}"`);
|
|
236
|
+
const isIndex = /^-?\d+$/.test(raw);
|
|
237
|
+
json.push({
|
|
238
|
+
arrow: arrows[i - 1],
|
|
239
|
+
key: isIndex ? raw : unquote(raw),
|
|
240
|
+
isIndex
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
name,
|
|
245
|
+
json
|
|
246
|
+
};
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
//#endregion
|
|
250
|
+
//#region src/quote.ts
|
|
251
|
+
/** Quote an SQL identifier: `a"b` → `"a""b"`. */
|
|
252
|
+
const quoteIdent = (name) => {
|
|
253
|
+
if (name.includes("\0")) throw new PgRestError(400, "PGRST100", "Invalid identifier", "contains a NUL byte");
|
|
254
|
+
return `"${name.replace(/"/g, "\"\"")}"`;
|
|
255
|
+
};
|
|
256
|
+
const quoteQualified = (schema, name) => `${quoteIdent(schema)}.${quoteIdent(name)}`;
|
|
257
|
+
/**
|
|
258
|
+
* Escape a string as an SQL literal of *unknown* type, so Postgres infers the
|
|
259
|
+
* type from context (`int_col = '5'` works). Mirrors `pg.escapeLiteral`.
|
|
260
|
+
*
|
|
261
|
+
* We inline literals (like PostgREST does) instead of binding parameters
|
|
262
|
+
* because some drivers (Bun.sql) re-encode string parameters as JSON when the
|
|
263
|
+
* inferred type is json/jsonb.
|
|
264
|
+
*/
|
|
265
|
+
const literal = (value) => {
|
|
266
|
+
if (value.includes("\0")) throw new PgRestError(400, "PGRST100", "Invalid value", "contains a NUL byte");
|
|
267
|
+
let out = `'${value.replace(/'/g, "''")}'`;
|
|
268
|
+
if (out.includes("\\")) out = `E${out.replace(/\\/g, "\\\\")}`;
|
|
269
|
+
return out;
|
|
270
|
+
};
|
|
271
|
+
const ID = "[A-Za-z_][A-Za-z0-9_]*";
|
|
272
|
+
const TYPE_RE = new RegExp(`^(?:(?:double precision|character varying|national character varying|national character|bit varying|timestamp(?:\\(\\d+\\))? with(?:out)? time zone|time(?:\\(\\d+\\))? with(?:out)? time zone)|${ID}(?:\\.${ID})?)(?:\\(\\s*\\d+(?:\\s*,\\s*\\d+)?\\s*\\))?(?:\\[\\])*$`, "i");
|
|
273
|
+
/** Validate a user-supplied cast target (`::type`) and return it verbatim. */
|
|
274
|
+
const safeType = (type) => {
|
|
275
|
+
const t = type.trim();
|
|
276
|
+
if (t.length > 100 || !TYPE_RE.test(t)) throw new PgRestError(400, "PGRST100", "Invalid type in cast", `"${type}" is not an allowed cast target`);
|
|
277
|
+
return t;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
//#endregion
|
|
281
|
+
//#region src/parse/select.ts
|
|
282
|
+
const AGGREGATES = new Set([
|
|
283
|
+
"count",
|
|
284
|
+
"sum",
|
|
285
|
+
"avg",
|
|
286
|
+
"max",
|
|
287
|
+
"min"
|
|
288
|
+
]);
|
|
289
|
+
/** Output key of a field in the JSON result. */
|
|
290
|
+
const outputName = (item) => {
|
|
291
|
+
if (item.alias) return item.alias;
|
|
292
|
+
if (item.kind === "aggregate") return item.fn;
|
|
293
|
+
const last = item.field.json[item.field.json.length - 1];
|
|
294
|
+
return last ? last.key : item.field.name;
|
|
295
|
+
};
|
|
296
|
+
const parseItem = (raw) => {
|
|
297
|
+
let s = raw.trim();
|
|
298
|
+
if (!s) throw parseError("Empty item in select", `in "${raw}"`);
|
|
299
|
+
if (s === "*") return { kind: "star" };
|
|
300
|
+
let spread = false;
|
|
301
|
+
if (s.startsWith("...")) {
|
|
302
|
+
spread = true;
|
|
303
|
+
s = s.slice(3);
|
|
304
|
+
}
|
|
305
|
+
let cast;
|
|
306
|
+
let castAt = -1;
|
|
307
|
+
scanTopLevel(s, (i, ch) => {
|
|
308
|
+
if (ch === ":" && s[i + 1] === ":") castAt = i;
|
|
309
|
+
});
|
|
310
|
+
if (castAt >= 0) {
|
|
311
|
+
cast = safeType(s.slice(castAt + 2));
|
|
312
|
+
s = s.slice(0, castAt);
|
|
313
|
+
}
|
|
314
|
+
let alias;
|
|
315
|
+
let aliasAt = -1;
|
|
316
|
+
scanTopLevel(s, (i, ch) => {
|
|
317
|
+
if (ch === ":") {
|
|
318
|
+
aliasAt = i;
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
return false;
|
|
322
|
+
});
|
|
323
|
+
if (aliasAt >= 0) {
|
|
324
|
+
alias = unquote(s.slice(0, aliasAt));
|
|
325
|
+
s = s.slice(aliasAt + 1).trim();
|
|
326
|
+
if (!alias) throw parseError("Empty alias in select", `in "${raw}"`);
|
|
327
|
+
}
|
|
328
|
+
const parenAt = firstParen(s);
|
|
329
|
+
if (parenAt >= 0) {
|
|
330
|
+
if (!s.endsWith(")")) throw parseError("Unbalanced parentheses in select", `in "${raw}"`);
|
|
331
|
+
const head = s.slice(0, parenAt).trim();
|
|
332
|
+
const inner = s.slice(parenAt + 1, -1);
|
|
333
|
+
if (inner.trim() === "") {
|
|
334
|
+
const dot = head.lastIndexOf(".");
|
|
335
|
+
const fn = (dot >= 0 ? head.slice(dot + 1) : head).trim().toLowerCase();
|
|
336
|
+
const colPart = dot >= 0 ? head.slice(0, dot) : "";
|
|
337
|
+
if (AGGREGATES.has(fn) && (colPart || fn === "count")) {
|
|
338
|
+
if (spread) throw parseError("Cannot spread an aggregate", `in "${raw}"`);
|
|
339
|
+
return {
|
|
340
|
+
kind: "aggregate",
|
|
341
|
+
fn,
|
|
342
|
+
...colPart ? { field: parseField(colPart) } : {},
|
|
343
|
+
...alias ? { alias } : {},
|
|
344
|
+
...cast ? { cast } : {}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (cast) throw parseError("Cannot cast an embedded resource", `in "${raw}"`);
|
|
349
|
+
const [targetRaw, ...mods] = splitTopLevel(head, "!");
|
|
350
|
+
const target = unquote(targetRaw ?? "");
|
|
351
|
+
if (!target) throw parseError("Empty embedded resource name", `in "${raw}"`);
|
|
352
|
+
let join$1 = "left";
|
|
353
|
+
let hint;
|
|
354
|
+
for (const m of mods) {
|
|
355
|
+
const mod = unquote(m);
|
|
356
|
+
if (mod === "inner") join$1 = "inner";
|
|
357
|
+
else if (mod === "left") join$1 = "left";
|
|
358
|
+
else if (hint === void 0) hint = mod;
|
|
359
|
+
else throw parseError("Only one hint is allowed per embedded resource", `in "${raw}"`);
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
kind: "embed",
|
|
363
|
+
target,
|
|
364
|
+
...alias ? { alias } : {},
|
|
365
|
+
...hint ? { hint } : {},
|
|
366
|
+
join: join$1,
|
|
367
|
+
spread,
|
|
368
|
+
items: inner.trim() === "" ? [] : parseSelect(inner)
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
if (spread) throw parseError("Only embedded resources can be spread", `in "${raw}"`);
|
|
372
|
+
return {
|
|
373
|
+
kind: "field",
|
|
374
|
+
field: parseField(s),
|
|
375
|
+
...alias ? { alias } : {},
|
|
376
|
+
...cast ? { cast } : {}
|
|
377
|
+
};
|
|
378
|
+
};
|
|
379
|
+
const firstParen = (s) => {
|
|
380
|
+
let quoted = false;
|
|
381
|
+
for (let i = 0; i < s.length; i++) {
|
|
382
|
+
const ch = s[i];
|
|
383
|
+
if (quoted) {
|
|
384
|
+
if (ch === "\\") i++;
|
|
385
|
+
else if (ch === "\"") quoted = false;
|
|
386
|
+
} else if (ch === "\"") quoted = true;
|
|
387
|
+
else if (ch === "(") return i;
|
|
388
|
+
}
|
|
389
|
+
return -1;
|
|
390
|
+
};
|
|
391
|
+
/** Parse the value of the `select` query parameter. */
|
|
392
|
+
const parseSelect = (input) => {
|
|
393
|
+
if (input.trim() === "") return [{ kind: "star" }];
|
|
394
|
+
return splitTopLevel(input, ",").map(parseItem);
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
//#endregion
|
|
398
|
+
//#region src/parse/parsec.ts
|
|
399
|
+
/**
|
|
400
|
+
* A tiny port of Haskell's Parsec (the semantics of consumed/empty results, `try`, `<|>`, labels and error
|
|
401
|
+
* merging), just enough to run PostgREST's parsers and produce the same error messages.
|
|
402
|
+
*/
|
|
403
|
+
/** Message kinds, in Parsec's sort order. */
|
|
404
|
+
const SYS_UNEXPECT = 0;
|
|
405
|
+
const UNEXPECT = 1;
|
|
406
|
+
const EXPECT = 2;
|
|
407
|
+
const MESSAGE = 3;
|
|
408
|
+
const unknown = (pos) => ({
|
|
409
|
+
pos,
|
|
410
|
+
msgs: []
|
|
411
|
+
});
|
|
412
|
+
const mergeError = (e1, e2) => {
|
|
413
|
+
if (e2.msgs.length === 0 && e1.msgs.length > 0) return e1;
|
|
414
|
+
if (e1.msgs.length === 0 && e2.msgs.length > 0) return e2;
|
|
415
|
+
if (e1.pos === e2.pos) return {
|
|
416
|
+
pos: e1.pos,
|
|
417
|
+
msgs: [...e1.msgs, ...e2.msgs]
|
|
418
|
+
};
|
|
419
|
+
return e1.pos > e2.pos ? e1 : e2;
|
|
420
|
+
};
|
|
421
|
+
const setExpect = (e, label$1) => ({
|
|
422
|
+
pos: e.pos,
|
|
423
|
+
msgs: [...e.msgs.filter((m) => m.k !== EXPECT), ...label$1 ? [{
|
|
424
|
+
k: EXPECT,
|
|
425
|
+
s: label$1
|
|
426
|
+
}] : []]
|
|
427
|
+
});
|
|
428
|
+
const showChar = (c) => `'${c}'`;
|
|
429
|
+
/** Haskell `show` of a one-character string. */
|
|
430
|
+
const showStr1 = (c) => {
|
|
431
|
+
if (c === "\"") return "\"\\\"\"";
|
|
432
|
+
if (c === "\\") return "\"\\\\\"";
|
|
433
|
+
if (c === "\n") return "\"\\n\"";
|
|
434
|
+
if (c === " ") return "\"\\t\"";
|
|
435
|
+
const code = c.codePointAt(0);
|
|
436
|
+
return code > 126 || code < 32 ? `"\\${code}"` : `"${c}"`;
|
|
437
|
+
};
|
|
438
|
+
const showStr = (s) => s.length === 1 ? showStr1(s) : `"${s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
439
|
+
const pure = (v) => (_s, i) => ({
|
|
440
|
+
ok: true,
|
|
441
|
+
consumed: false,
|
|
442
|
+
v,
|
|
443
|
+
i,
|
|
444
|
+
err: unknown(i)
|
|
445
|
+
});
|
|
446
|
+
const zero = (_s, i) => ({
|
|
447
|
+
ok: false,
|
|
448
|
+
consumed: false,
|
|
449
|
+
err: unknown(i)
|
|
450
|
+
});
|
|
451
|
+
const unexpected = (msg) => (_s, i) => ({
|
|
452
|
+
ok: false,
|
|
453
|
+
consumed: false,
|
|
454
|
+
err: {
|
|
455
|
+
pos: i,
|
|
456
|
+
msgs: [{
|
|
457
|
+
k: UNEXPECT,
|
|
458
|
+
s: msg
|
|
459
|
+
}]
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
const codePointAt = (s, i) => {
|
|
463
|
+
if (i >= s.length) return void 0;
|
|
464
|
+
const cp = s.codePointAt(i);
|
|
465
|
+
return String.fromCodePoint(cp);
|
|
466
|
+
};
|
|
467
|
+
/** Consume one character satisfying `f`. */
|
|
468
|
+
const satisfy = (f) => (s, i) => {
|
|
469
|
+
const c = codePointAt(s, i);
|
|
470
|
+
if (c === void 0) return {
|
|
471
|
+
ok: false,
|
|
472
|
+
consumed: false,
|
|
473
|
+
err: {
|
|
474
|
+
pos: i,
|
|
475
|
+
msgs: [{
|
|
476
|
+
k: SYS_UNEXPECT,
|
|
477
|
+
s: ""
|
|
478
|
+
}]
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
if (!f(c)) return {
|
|
482
|
+
ok: false,
|
|
483
|
+
consumed: false,
|
|
484
|
+
err: {
|
|
485
|
+
pos: i,
|
|
486
|
+
msgs: [{
|
|
487
|
+
k: SYS_UNEXPECT,
|
|
488
|
+
s: showStr1(c)
|
|
489
|
+
}]
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
const j = i + c.length;
|
|
493
|
+
return {
|
|
494
|
+
ok: true,
|
|
495
|
+
consumed: true,
|
|
496
|
+
v: c,
|
|
497
|
+
i: j,
|
|
498
|
+
err: unknown(j)
|
|
499
|
+
};
|
|
500
|
+
};
|
|
501
|
+
const label = (p, name) => (s, i) => {
|
|
502
|
+
const r = p(s, i);
|
|
503
|
+
if (r.consumed) return r;
|
|
504
|
+
if (r.ok) return {
|
|
505
|
+
...r,
|
|
506
|
+
err: r.err.msgs.length === 0 ? r.err : setExpect(r.err, name)
|
|
507
|
+
};
|
|
508
|
+
return {
|
|
509
|
+
...r,
|
|
510
|
+
err: setExpect(r.err, name)
|
|
511
|
+
};
|
|
512
|
+
};
|
|
513
|
+
const char = (c) => label(satisfy((x) => x === c), showStr1(c));
|
|
514
|
+
const oneOf = (cs) => satisfy((x) => cs.includes(x));
|
|
515
|
+
const noneOf = (cs) => satisfy((x) => !cs.includes(x));
|
|
516
|
+
const anyChar = satisfy(() => true);
|
|
517
|
+
const letter = label(satisfy((c) => /\p{L}/u.test(c)), "letter");
|
|
518
|
+
const digit = label(satisfy((c) => c >= "0" && c <= "9"), "digit");
|
|
519
|
+
/** Parsec's `string`: fails after consuming when only a prefix matches (wrap in `tryP` to backtrack). */
|
|
520
|
+
const string = (w) => (s, i) => {
|
|
521
|
+
const expect = {
|
|
522
|
+
k: EXPECT,
|
|
523
|
+
s: showStr(w)
|
|
524
|
+
};
|
|
525
|
+
for (let k = 0; k < w.length; k++) {
|
|
526
|
+
const c = codePointAt(s, i + k);
|
|
527
|
+
if (c === void 0) return {
|
|
528
|
+
ok: false,
|
|
529
|
+
consumed: k > 0,
|
|
530
|
+
err: {
|
|
531
|
+
pos: i,
|
|
532
|
+
msgs: [{
|
|
533
|
+
k: SYS_UNEXPECT,
|
|
534
|
+
s: ""
|
|
535
|
+
}, expect]
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
if (c !== w[k]) {
|
|
539
|
+
const err = {
|
|
540
|
+
pos: i,
|
|
541
|
+
msgs: [{
|
|
542
|
+
k: SYS_UNEXPECT,
|
|
543
|
+
s: showStr1(c)
|
|
544
|
+
}, expect]
|
|
545
|
+
};
|
|
546
|
+
return {
|
|
547
|
+
ok: false,
|
|
548
|
+
consumed: k > 0,
|
|
549
|
+
err
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const j = i + w.length;
|
|
554
|
+
return {
|
|
555
|
+
ok: true,
|
|
556
|
+
consumed: w.length > 0,
|
|
557
|
+
v: w,
|
|
558
|
+
i: j,
|
|
559
|
+
err: unknown(j)
|
|
560
|
+
};
|
|
561
|
+
};
|
|
562
|
+
const tryP = (p) => (s, i) => {
|
|
563
|
+
const r = p(s, i);
|
|
564
|
+
return !r.ok && r.consumed ? {
|
|
565
|
+
ok: false,
|
|
566
|
+
consumed: false,
|
|
567
|
+
err: r.err
|
|
568
|
+
} : r;
|
|
569
|
+
};
|
|
570
|
+
const bind = (p, f) => (s, i) => {
|
|
571
|
+
const r = p(s, i);
|
|
572
|
+
if (!r.ok) return r;
|
|
573
|
+
const q = f(r.v)(s, r.i);
|
|
574
|
+
if (r.consumed) {
|
|
575
|
+
if (q.consumed) return q;
|
|
576
|
+
return q.ok ? {
|
|
577
|
+
...q,
|
|
578
|
+
consumed: true,
|
|
579
|
+
err: mergeError(r.err, q.err)
|
|
580
|
+
} : {
|
|
581
|
+
ok: false,
|
|
582
|
+
consumed: true,
|
|
583
|
+
err: mergeError(r.err, q.err)
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
if (q.consumed) return q;
|
|
587
|
+
return q.ok ? {
|
|
588
|
+
...q,
|
|
589
|
+
err: mergeError(r.err, q.err)
|
|
590
|
+
} : {
|
|
591
|
+
ok: false,
|
|
592
|
+
consumed: false,
|
|
593
|
+
err: mergeError(r.err, q.err)
|
|
594
|
+
};
|
|
595
|
+
};
|
|
596
|
+
const map = (p, f) => bind(p, (a) => pure(f(a)));
|
|
597
|
+
/** `p <* q` */
|
|
598
|
+
const before = (p, q) => bind(p, (a) => map(q, () => a));
|
|
599
|
+
/** `p *> q` */
|
|
600
|
+
const after = (p, q) => bind(p, () => q);
|
|
601
|
+
const alt = (...ps) => (s, i) => {
|
|
602
|
+
let acc;
|
|
603
|
+
for (const p of ps) {
|
|
604
|
+
const r = p(s, i);
|
|
605
|
+
if (r.consumed) return r;
|
|
606
|
+
if (r.ok) return acc ? {
|
|
607
|
+
...r,
|
|
608
|
+
err: mergeError(acc, r.err)
|
|
609
|
+
} : r;
|
|
610
|
+
acc = acc ? mergeError(acc, r.err) : r.err;
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
ok: false,
|
|
614
|
+
consumed: false,
|
|
615
|
+
err: acc ?? unknown(i)
|
|
616
|
+
};
|
|
617
|
+
};
|
|
618
|
+
const many = (p) => (s, i) => {
|
|
619
|
+
const out = [];
|
|
620
|
+
let consumed = false;
|
|
621
|
+
let pos = i;
|
|
622
|
+
for (;;) {
|
|
623
|
+
const r = p(s, pos);
|
|
624
|
+
if (!r.ok) {
|
|
625
|
+
if (r.consumed) return r;
|
|
626
|
+
return {
|
|
627
|
+
ok: true,
|
|
628
|
+
consumed,
|
|
629
|
+
v: out,
|
|
630
|
+
i: pos,
|
|
631
|
+
err: r.err
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
if (!r.consumed) throw new Error("combinator 'many' is applied to a parser that accepts an empty string");
|
|
635
|
+
out.push(r.v);
|
|
636
|
+
consumed = true;
|
|
637
|
+
pos = r.i;
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
const many1 = (p) => bind(p, (x) => map(many(p), (xs) => [x, ...xs]));
|
|
641
|
+
const sepBy1 = (p, sep) => bind(p, (x) => map(many(after(sep, p)), (xs) => [x, ...xs]));
|
|
642
|
+
const sepBy = (p, sep) => alt(sepBy1(p, sep), pure([]));
|
|
643
|
+
const option = (d, p) => alt(p, pure(d));
|
|
644
|
+
const optionMaybe = (p) => option(void 0, p);
|
|
645
|
+
const between = (open, close, p) => after(open, before(p, close));
|
|
646
|
+
const lookAhead = (p) => (s, i) => {
|
|
647
|
+
const r = p(s, i);
|
|
648
|
+
if (!r.ok) return r;
|
|
649
|
+
return {
|
|
650
|
+
ok: true,
|
|
651
|
+
consumed: false,
|
|
652
|
+
v: r.v,
|
|
653
|
+
i,
|
|
654
|
+
err: unknown(i)
|
|
655
|
+
};
|
|
656
|
+
};
|
|
657
|
+
const notFollowedBy = (p) => tryP(alt(bind(tryP(p), (c) => unexpected(typeof c === "string" && c.length === 1 ? showChar(c) : showStr(String(c)))), pure(void 0)));
|
|
658
|
+
/** `eof` does not move the error position past the offending character (Parsec's `anyToken` keeps the position). */
|
|
659
|
+
const eof = label((s, i) => {
|
|
660
|
+
const c = codePointAt(s, i);
|
|
661
|
+
if (c === void 0) return {
|
|
662
|
+
ok: true,
|
|
663
|
+
consumed: false,
|
|
664
|
+
v: void 0,
|
|
665
|
+
i,
|
|
666
|
+
err: unknown(i)
|
|
667
|
+
};
|
|
668
|
+
return {
|
|
669
|
+
ok: false,
|
|
670
|
+
consumed: false,
|
|
671
|
+
err: {
|
|
672
|
+
pos: i,
|
|
673
|
+
msgs: [{
|
|
674
|
+
k: UNEXPECT,
|
|
675
|
+
s: showChar(c)
|
|
676
|
+
}]
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
}, "end of input");
|
|
680
|
+
const guard = (ok) => ok ? pure(void 0) : zero;
|
|
681
|
+
const columnAt = (s, offset) => {
|
|
682
|
+
let col = 1;
|
|
683
|
+
for (let k = 0; k < offset; k++) col = s[k] === " " ? col + 8 - (col - 1) % 8 : col + 1;
|
|
684
|
+
return col;
|
|
685
|
+
};
|
|
686
|
+
const clean = (xs) => [...new Set(xs.filter((x) => x !== ""))];
|
|
687
|
+
const commasOr = (xs) => xs.length === 0 ? "" : xs.length === 1 ? xs[0] : `${xs.slice(0, -1).join(", ")} or ${xs[xs.length - 1]}`;
|
|
688
|
+
/** Parsec's `showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input"`. */
|
|
689
|
+
const showMessages = (msgs) => {
|
|
690
|
+
if (msgs.length === 0) return "unknown parse error";
|
|
691
|
+
const sorted = [...msgs].sort((a, b) => a.k - b.k);
|
|
692
|
+
const sys = sorted.filter((m) => m.k === SYS_UNEXPECT);
|
|
693
|
+
const unex = sorted.filter((m) => m.k === UNEXPECT);
|
|
694
|
+
const expect = sorted.filter((m) => m.k === EXPECT);
|
|
695
|
+
const other = sorted.filter((m) => m.k === MESSAGE);
|
|
696
|
+
const showMany = (pre, ms) => {
|
|
697
|
+
const c = commasOr(clean(ms.map((m) => m.s)));
|
|
698
|
+
return c === "" ? "" : pre ? `${pre} ${c}` : c;
|
|
699
|
+
};
|
|
700
|
+
return clean([
|
|
701
|
+
unex.length > 0 || sys.length === 0 ? "" : sys[0].s === "" ? "unexpected end of input" : `unexpected ${sys[0].s}`,
|
|
702
|
+
showMany("unexpected", unex),
|
|
703
|
+
showMany("expecting", expect),
|
|
704
|
+
showMany("", other)
|
|
705
|
+
]).join(" ");
|
|
706
|
+
};
|
|
707
|
+
/** Run `p` at the start of `input` (no `eof` is implied, as in Parsec's `parse`). */
|
|
708
|
+
const run$1 = (p, input) => {
|
|
709
|
+
const r = p(input, 0);
|
|
710
|
+
if (r.ok) return {
|
|
711
|
+
ok: true,
|
|
712
|
+
value: r.v,
|
|
713
|
+
end: r.i
|
|
714
|
+
};
|
|
715
|
+
return {
|
|
716
|
+
ok: false,
|
|
717
|
+
failure: {
|
|
718
|
+
column: columnAt(input, r.err.pos),
|
|
719
|
+
details: showMessages(r.err.msgs).trim()
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
//#endregion
|
|
725
|
+
//#region src/parse/grammar.ts
|
|
726
|
+
/**
|
|
727
|
+
* PostgREST v16's query-string parsers (`PostgREST.ApiRequest.QueryParams`), ported on top of `parsec.ts`
|
|
728
|
+
* so that syntax errors read exactly like PostgREST's. They only validate: the structures the rest of the
|
|
729
|
+
* library works with come from the hand-written parsers in this directory.
|
|
730
|
+
*/
|
|
731
|
+
const ws = many(oneOf(" "));
|
|
732
|
+
const lexeme = (p) => before(after(ws, p), ws);
|
|
733
|
+
const lazy = (f) => (s, i) => f()(s, i);
|
|
734
|
+
const join = (xs) => xs.join("");
|
|
735
|
+
const identifier = map(many1(alt(letter, digit, oneOf("_ $"))), (cs) => join(cs).trim());
|
|
736
|
+
const dash = tryP(after(char("-"), notFollowedBy(char(">"))));
|
|
737
|
+
const sepByDash = (p) => map(sepBy1(p, dash), (xs) => xs.join("-"));
|
|
738
|
+
const slashedOrPlain = alt(noneOf("\\\""), after(char("\\"), anyChar));
|
|
739
|
+
const quotedValue = map(between(char("\""), char("\""), many(slashedOrPlain)), join);
|
|
740
|
+
const fieldName = label(alt(quotedValue, sepByDash(identifier)), "field name (* or [a..z0..9_$])");
|
|
741
|
+
const jsonKeyName = label(alt(quotedValue, sepByDash(map(many1(noneOf("(-:.,>)")), (cs) => join(cs).trim()))), "any non reserved character different from: .,>()");
|
|
742
|
+
const delimiter = label(char("."), "delimiter (.)");
|
|
743
|
+
const jsonEnd = alt(tryP(lookAhead(string("->"))), tryP(lookAhead(string("::"))), tryP(lookAhead(string("."))), tryP(lookAhead(string(","))), tryP(eof));
|
|
744
|
+
const jsonIdx = before(bind(option("+", char("-")), () => many1(digit)), jsonEnd);
|
|
745
|
+
const jsonPath = many(after(alt(tryP(string("->>")), tryP(string("->"))), alt(tryP(jsonIdx), tryP(jsonKeyName))));
|
|
746
|
+
const field = lexeme(bind(fieldName, () => option([], jsonPath)));
|
|
747
|
+
const aliasPrefix = optionMaybe(tryP(before(fieldName, after(char(":"), notFollowedBy(char(":"))))));
|
|
748
|
+
const embedParam = after(char("!"), alt(tryP(string("left")), tryP(string("inner")), tryP(fieldName)));
|
|
749
|
+
const embedParams = after(optionMaybe(embedParam), optionMaybe(embedParam));
|
|
750
|
+
const selectEnd = alt(tryP(lookAhead(string(")"))), tryP(lookAhead(string(","))), tryP(eof));
|
|
751
|
+
const aggregation = alt(string("sum"), string("avg"), string("count"), tryP(string("max")), tryP(string("min")), zero);
|
|
752
|
+
const relationSelect = lexeme(bind(aliasPrefix, () => bind(fieldName, (name) => after(guard(name !== "count"), after(embedParams, tryP(lookAhead(string("("))))))));
|
|
753
|
+
const spreadRelationSelect = lexeme(after(string("..."), after(fieldName, after(embedParams, tryP(lookAhead(string("(")))))));
|
|
754
|
+
const fieldSelect = lexeme(alt(tryP(after(string("*"), selectEnd)), tryP(after(aliasPrefix, after(string("count()"), after(optionMaybe(after(string("::"), identifier)), selectEnd)))), after(aliasPrefix, after(field, after(optionMaybe(after(string("::"), identifier)), after(optionMaybe(tryP(before(after(char("."), aggregation), string("()")))), after(optionMaybe(after(string("::"), identifier)), selectEnd)))))));
|
|
755
|
+
const forest = sepBy(alt(bind(tryP(spreadRelationSelect), () => between(char("("), char(")"), lazy(() => forest))), bind(tryP(relationSelect), () => between(char("("), char(")"), lazy(() => forest))), fieldSelect), lexeme(char(",")));
|
|
756
|
+
const ciChar = (c) => alt(char(c), char(c.toUpperCase()));
|
|
757
|
+
const ciString = (w) => map(seq([...w].map(ciChar)), join);
|
|
758
|
+
const seq = (ps) => ps.reduce((acc, p) => bind(acc, (xs) => map(p, (x) => [...xs, x])), pure([]));
|
|
759
|
+
const listElement = alt(tryP(before(quotedValue, notFollowedBy(noneOf(",)")))), map(many(noneOf(",)")), join));
|
|
760
|
+
const listVal = before(after(lexeme(char("(")), sepBy1(listElement, char(","))), lexeme(char(")")));
|
|
761
|
+
const isVal = label(alt(tryP(ciString("null")), tryP(ciString("not_null")), tryP(ciString("true")), tryP(ciString("false")), tryP(ciString("unknown"))), "isVal: (null, not_null, true, false, unknown)");
|
|
762
|
+
const simpleOperator = label(alt(...[
|
|
763
|
+
"neq",
|
|
764
|
+
"cs",
|
|
765
|
+
"cd",
|
|
766
|
+
"ov",
|
|
767
|
+
"sl",
|
|
768
|
+
"sr",
|
|
769
|
+
"nxr",
|
|
770
|
+
"nxl",
|
|
771
|
+
"adj"
|
|
772
|
+
].map((o) => tryP(string(o)))), "unknown single value operator");
|
|
773
|
+
const quantOperator = label(alt(...[
|
|
774
|
+
"eq",
|
|
775
|
+
"gte",
|
|
776
|
+
"gt",
|
|
777
|
+
"lte",
|
|
778
|
+
"lt",
|
|
779
|
+
"like",
|
|
780
|
+
"ilike",
|
|
781
|
+
"match",
|
|
782
|
+
"imatch"
|
|
783
|
+
].map((o) => tryP(string(o)))), "unknown single value operator");
|
|
784
|
+
const opExpr = (singleVal$1) => {
|
|
785
|
+
const pIn = after(tryP(after(string("in"), delimiter)), listVal);
|
|
786
|
+
const pIs = after(tryP(after(string("is"), delimiter)), isVal);
|
|
787
|
+
const pIsDist = after(tryP(after(string("isdistinct"), delimiter)), singleVal$1);
|
|
788
|
+
const pSimple = bind(simpleOperator, () => after(delimiter, singleVal$1));
|
|
789
|
+
const pQuant = bind(quantOperator, () => after(optionMaybe(tryP(between(char("("), char(")"), alt(tryP(string("any")), string("all"))))), after(delimiter, singleVal$1)));
|
|
790
|
+
const operation = label(alt(pIn, pIs, pIsDist, tryP(bind(alt(...[
|
|
791
|
+
"fts",
|
|
792
|
+
"plfts",
|
|
793
|
+
"phfts",
|
|
794
|
+
"wfts"
|
|
795
|
+
].map((o) => tryP(string(o)))), () => after(optionMaybe(tryP(between(char("("), char(")"), identifier))), after(delimiter, singleVal$1)))), tryP(pSimple), tryP(pQuant)), "operator (eq, gt, ...)");
|
|
796
|
+
return after(alt(tryP(after(string("not"), delimiter)), pure("")), operation);
|
|
797
|
+
};
|
|
798
|
+
const singleVal = map(many(anyChar), join);
|
|
799
|
+
const orderEnd = alt(tryP(lookAhead(char(","))), tryP(eof));
|
|
800
|
+
const orderNulls = alt(tryP(after(delimiter, string("nullsfirst"))), tryP(after(delimiter, string("nullslast"))));
|
|
801
|
+
const orderTail = after(optionMaybe(alt(tryP(after(delimiter, string("asc"))), tryP(after(delimiter, string("desc"))))), alt(before(optionMaybe(orderNulls), orderEnd), after(orderEnd, pure(void 0))));
|
|
802
|
+
const orderTerm = after(field, orderTail);
|
|
803
|
+
const order = sepBy1(lexeme(alt(tryP(after(fieldName, after(between(char("("), char(")"), field), orderTail))), orderTerm)), char(","));
|
|
804
|
+
const pgArray = map(bind(string("{"), (a) => bind(many(noneOf("{}")), (b) => map(string("}"), (c) => a + join(b) + c))), (x) => x);
|
|
805
|
+
const logicSingleVal = alt(tryP(before(quotedValue, notFollowedBy(noneOf(",)")))), tryP(pgArray), map(many(noneOf(",)")), join));
|
|
806
|
+
const logicNot = label(alt(tryP(after(string("not"), after(delimiter, pure(true)))), pure(false)), "negation operator (not)");
|
|
807
|
+
const logicOp = label(alt(tryP(string("and")), string("or")), "logic operator (and, or)");
|
|
808
|
+
const logicTree = alt(tryP(after(field, after(delimiter, opExpr(logicSingleVal)))), bind(logicNot, () => bind(logicOp, () => after(lexeme(char("(")), before(sepBy1(lazy(() => logicTree), lexeme(char(","))), lexeme(char(")")))))));
|
|
809
|
+
const fail = (what, input, f) => new PgRestError(400, "PGRST100", `"failed to parse ${what} (${input})" (line 1, column ${f.column})`, f.details);
|
|
810
|
+
/** `select=`: returns where the parse stopped (PostgREST ignores anything after it), or the error. */
|
|
811
|
+
const checkSelect = (input) => {
|
|
812
|
+
const r = run$1(forest, input);
|
|
813
|
+
return r.ok ? r.end : fail("select parameter", input, r.failure);
|
|
814
|
+
};
|
|
815
|
+
/** The right-hand side of a filter (`not.eq.1`); `undefined` when it parses. */
|
|
816
|
+
const checkFilter = (input) => {
|
|
817
|
+
const r = run$1(opExpr(singleVal), input);
|
|
818
|
+
return r.ok ? void 0 : fail("filter", input, r.failure);
|
|
819
|
+
};
|
|
820
|
+
/** An `order=` value; `undefined` when it parses. */
|
|
821
|
+
const checkOrder = (input) => {
|
|
822
|
+
const r = run$1(order, input);
|
|
823
|
+
return r.ok ? void 0 : fail("order", input, r.failure);
|
|
824
|
+
};
|
|
825
|
+
/** `and=`/`or=`: `op` is `and`, `or`, `not.and` or `not.or`; returns how much of `value` the tree uses. */
|
|
826
|
+
const checkLogicTree = (op, value) => {
|
|
827
|
+
const r = run$1(logicTree, op + value);
|
|
828
|
+
return r.ok ? Math.max(0, r.end - op.length) : fail("logic tree", value, r.failure);
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
//#endregion
|
|
832
|
+
//#region src/parse/syntax.ts
|
|
833
|
+
/** Helpers for the lenient parts of PostgREST's filter syntax (the error wording lives in `grammar.ts`). */
|
|
834
|
+
const IS_VALUES_ORDER = [
|
|
835
|
+
"null",
|
|
836
|
+
"not_null",
|
|
837
|
+
"true",
|
|
838
|
+
"false",
|
|
839
|
+
"unknown"
|
|
840
|
+
];
|
|
841
|
+
const at = (s, i) => i < s.length ? s[i] : void 0;
|
|
842
|
+
/**
|
|
843
|
+
* Where a parenthesised `in` list ends (offset after the closing `)`). PostgREST stops there and ignores what follows;
|
|
844
|
+
* an unterminated list returns the input length (the grammar reports the error before this is used).
|
|
845
|
+
*/
|
|
846
|
+
const scanList = (s, from) => {
|
|
847
|
+
let i = from + 1;
|
|
848
|
+
for (;;) {
|
|
849
|
+
let end = i;
|
|
850
|
+
if (at(s, i) === "\"") {
|
|
851
|
+
let k = i + 1;
|
|
852
|
+
while (k < s.length && s[k] !== "\"") k += s[k] === "\\" ? 2 : 1;
|
|
853
|
+
if (k < s.length && (at(s, k + 1) === "," || at(s, k + 1) === ")")) end = k + 1;
|
|
854
|
+
}
|
|
855
|
+
if (end === i) while (end < s.length && s[end] !== "," && s[end] !== ")") end++;
|
|
856
|
+
const c = at(s, end);
|
|
857
|
+
if (c === ")") return end + 1;
|
|
858
|
+
if (c !== ",") return s.length;
|
|
859
|
+
i = end + 1;
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
//#endregion
|
|
864
|
+
//#region src/parse/filter.ts
|
|
865
|
+
const OPERATORS = new Set([
|
|
866
|
+
"eq",
|
|
867
|
+
"neq",
|
|
868
|
+
"gt",
|
|
869
|
+
"gte",
|
|
870
|
+
"lt",
|
|
871
|
+
"lte",
|
|
872
|
+
"like",
|
|
873
|
+
"ilike",
|
|
874
|
+
"match",
|
|
875
|
+
"imatch",
|
|
876
|
+
"in",
|
|
877
|
+
"is",
|
|
878
|
+
"isdistinct",
|
|
879
|
+
"fts",
|
|
880
|
+
"plfts",
|
|
881
|
+
"phfts",
|
|
882
|
+
"wfts",
|
|
883
|
+
"cs",
|
|
884
|
+
"cd",
|
|
885
|
+
"ov",
|
|
886
|
+
"sl",
|
|
887
|
+
"sr",
|
|
888
|
+
"nxl",
|
|
889
|
+
"nxr",
|
|
890
|
+
"adj"
|
|
891
|
+
]);
|
|
892
|
+
const QUANTIFIABLE = new Set([
|
|
893
|
+
"eq",
|
|
894
|
+
"gt",
|
|
895
|
+
"gte",
|
|
896
|
+
"lt",
|
|
897
|
+
"lte",
|
|
898
|
+
"like",
|
|
899
|
+
"ilike",
|
|
900
|
+
"match",
|
|
901
|
+
"imatch"
|
|
902
|
+
]);
|
|
903
|
+
const FTS = new Set([
|
|
904
|
+
"fts",
|
|
905
|
+
"plfts",
|
|
906
|
+
"phfts",
|
|
907
|
+
"wfts"
|
|
908
|
+
]);
|
|
909
|
+
const IS_VALUES = new Set([
|
|
910
|
+
"null",
|
|
911
|
+
"not_null",
|
|
912
|
+
"true",
|
|
913
|
+
"false",
|
|
914
|
+
"unknown"
|
|
915
|
+
]);
|
|
916
|
+
const emptyParams = () => ({
|
|
917
|
+
filters: [],
|
|
918
|
+
order: [],
|
|
919
|
+
presence: []
|
|
920
|
+
});
|
|
921
|
+
const parseList = (raw, where) => {
|
|
922
|
+
const t = raw.trim();
|
|
923
|
+
if (!t.startsWith("(") || !t.endsWith(")")) throw parseError(`failed to parse filter (${where})`, "expected a list in parentheses, like (a,b,c)");
|
|
924
|
+
const inner = t.slice(1, -1);
|
|
925
|
+
if (inner.trim() === "") return [];
|
|
926
|
+
return splitTopLevel(inner, ",").map((v) => unquote(v));
|
|
927
|
+
};
|
|
928
|
+
/**
|
|
929
|
+
* Parse `[not.]op[(modifier)].value`, the right-hand side of a filter.
|
|
930
|
+
* `unquoteValue` is true inside and()/or() trees where values may be quoted.
|
|
931
|
+
*/
|
|
932
|
+
const parseOperatorExpr = (field$1, expr, unquoteValue = false) => {
|
|
933
|
+
if (!unquoteValue) {
|
|
934
|
+
const bad = checkFilter(expr);
|
|
935
|
+
if (bad) throw bad;
|
|
936
|
+
}
|
|
937
|
+
let rest = expr;
|
|
938
|
+
let negate = false;
|
|
939
|
+
if (rest.startsWith("not.")) {
|
|
940
|
+
negate = true;
|
|
941
|
+
rest = rest.slice(4);
|
|
942
|
+
}
|
|
943
|
+
const dot = rest.indexOf(".");
|
|
944
|
+
if (dot < 0) throw parseError(`failed to parse filter (${expr})`);
|
|
945
|
+
const opToken = rest.slice(0, dot);
|
|
946
|
+
const rawValue = rest.slice(dot + 1);
|
|
947
|
+
const m = /^([a-z]+)(?:\(([^)]*)\))?$/.exec(opToken);
|
|
948
|
+
if (!m || !OPERATORS.has(m[1])) throw parseError(`failed to parse filter (${expr})`, `unknown operator "${opToken}"`);
|
|
949
|
+
const op = m[1];
|
|
950
|
+
const modifier = m[2];
|
|
951
|
+
let quantifier;
|
|
952
|
+
let lang;
|
|
953
|
+
if (modifier !== void 0) if (QUANTIFIABLE.has(op)) {
|
|
954
|
+
if (modifier !== "any" && modifier !== "all") throw parseError(`failed to parse filter (${expr})`, `"${modifier}" is not a valid quantifier, use any or all`);
|
|
955
|
+
quantifier = modifier;
|
|
956
|
+
} else if (FTS.has(op)) {
|
|
957
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(modifier)) throw parseError(`failed to parse filter (${expr})`, `"${modifier}" is not a valid text search configuration`);
|
|
958
|
+
lang = modifier;
|
|
959
|
+
} else throw parseError(`failed to parse filter (${expr})`, `operator ${op} does not take a modifier`);
|
|
960
|
+
let value = unquoteValue ? unquote(rawValue) : rawValue;
|
|
961
|
+
if (op === "in") {
|
|
962
|
+
const end = unquoteValue ? void 0 : scanList(rawValue, 0);
|
|
963
|
+
value = parseList(typeof end === "number" ? rawValue.slice(0, end) : rawValue, expr);
|
|
964
|
+
} else if (op === "is") {
|
|
965
|
+
const v = ((unquoteValue ? void 0 : IS_VALUES_ORDER.find((w) => rawValue.startsWith(w))) ?? String(value)).toLowerCase();
|
|
966
|
+
if (!IS_VALUES.has(v)) throw parseError(`failed to parse filter (${expr})`, "is only accepts null, not_null, true, false and unknown");
|
|
967
|
+
value = v;
|
|
968
|
+
}
|
|
969
|
+
return {
|
|
970
|
+
kind: "filter",
|
|
971
|
+
field: field$1,
|
|
972
|
+
negate,
|
|
973
|
+
op,
|
|
974
|
+
...quantifier ? { quantifier } : {},
|
|
975
|
+
...lang ? { lang } : {},
|
|
976
|
+
value
|
|
977
|
+
};
|
|
978
|
+
};
|
|
979
|
+
const parseLogicItem = (raw) => {
|
|
980
|
+
const item = raw.trim();
|
|
981
|
+
const m = /^(not\.)?(and|or)\(/.exec(item);
|
|
982
|
+
if (m) {
|
|
983
|
+
if (!item.endsWith(")")) throw parseError("Unbalanced parentheses in logic tree", `in "${item}"`);
|
|
984
|
+
const inner = item.slice(m[0].length - 1);
|
|
985
|
+
return parseLogic(m[2], Boolean(m[1]), inner);
|
|
986
|
+
}
|
|
987
|
+
const dot = item.indexOf(".");
|
|
988
|
+
if (dot < 0) throw parseError(`failed to parse logic tree (${item})`);
|
|
989
|
+
return parseOperatorExpr(parseField(item.slice(0, dot)), item.slice(dot + 1), true);
|
|
990
|
+
};
|
|
991
|
+
/** Parse `(a.eq.1,or(b.eq.2,c.eq.3))` into a {@link Logic} node. */
|
|
992
|
+
const parseLogic = (op, negate, value) => {
|
|
993
|
+
const v = value.trim();
|
|
994
|
+
if (!v.startsWith("(") || !v.endsWith(")")) throw parseError(`failed to parse logic tree (${value})`, "expected a list in parentheses");
|
|
995
|
+
const inner = v.slice(1, -1);
|
|
996
|
+
if (inner.trim() === "") throw parseError(`failed to parse logic tree (${value})`, "empty tree");
|
|
997
|
+
return {
|
|
998
|
+
kind: "logic",
|
|
999
|
+
op,
|
|
1000
|
+
negate,
|
|
1001
|
+
children: splitTopLevel(inner, ",").map(parseLogicItem)
|
|
1002
|
+
};
|
|
1003
|
+
};
|
|
1004
|
+
const parseOrder = (input) => {
|
|
1005
|
+
const bad = checkOrder(input);
|
|
1006
|
+
if (bad) throw bad;
|
|
1007
|
+
return parseOrderTerms(input);
|
|
1008
|
+
};
|
|
1009
|
+
const parseOrderTerms = (input) => splitTopLevel(input, ",").map((raw) => {
|
|
1010
|
+
const tokens = raw.trim().split(".");
|
|
1011
|
+
let nulls;
|
|
1012
|
+
let dir = "asc";
|
|
1013
|
+
const last = () => tokens[tokens.length - 1]?.toLowerCase();
|
|
1014
|
+
if (tokens.length > 1 && (last() === "nullsfirst" || last() === "nullslast")) {
|
|
1015
|
+
nulls = last() === "nullsfirst" ? "first" : "last";
|
|
1016
|
+
tokens.pop();
|
|
1017
|
+
}
|
|
1018
|
+
if (tokens.length > 1 && (last() === "asc" || last() === "desc")) {
|
|
1019
|
+
dir = last();
|
|
1020
|
+
tokens.pop();
|
|
1021
|
+
}
|
|
1022
|
+
const expr = tokens.join(".");
|
|
1023
|
+
const related = /^([^()>-]+)\((.+)\)$/.exec(expr);
|
|
1024
|
+
if (related && /[()]/.test(related[2])) throw parseError(`"failed to parse order (${input})"`, `unexpected '(' in "${expr}"`);
|
|
1025
|
+
return {
|
|
1026
|
+
...related ? {
|
|
1027
|
+
relation: unquote(related[1]),
|
|
1028
|
+
field: parseField(related[2])
|
|
1029
|
+
} : { field: parseField(expr) },
|
|
1030
|
+
dir,
|
|
1031
|
+
...nulls ? { nulls } : {}
|
|
1032
|
+
};
|
|
1033
|
+
});
|
|
1034
|
+
/** PostgREST ignores a `limit`/`offset` that is not an integer; a negative limit is refused later. */
|
|
1035
|
+
const parseCount = (name, v) => {
|
|
1036
|
+
return (name === "limit" ? /^-?\d+$/.test(v) : /^\d+$/.test(v)) ? Number(v) : void 0;
|
|
1037
|
+
};
|
|
1038
|
+
const walkEmbeds = (items, prefix, known) => {
|
|
1039
|
+
for (const it of items) {
|
|
1040
|
+
if (it.kind !== "embed") continue;
|
|
1041
|
+
const name = it.alias ?? it.target;
|
|
1042
|
+
const canonical = [...prefix, name].join(".");
|
|
1043
|
+
known.set(canonical, canonical);
|
|
1044
|
+
if (it.alias) known.set([...prefix, it.target].join("."), canonical);
|
|
1045
|
+
walkEmbeds(it.items, [...prefix, name], known);
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
const RESERVED_TERMINALS = new Set([
|
|
1049
|
+
"limit",
|
|
1050
|
+
"offset",
|
|
1051
|
+
"order",
|
|
1052
|
+
"and",
|
|
1053
|
+
"or"
|
|
1054
|
+
]);
|
|
1055
|
+
/** Parse everything PostgREST reads from the query string, except function arguments. */
|
|
1056
|
+
const parseQuery = (params, opts = {}) => {
|
|
1057
|
+
let select = [{ kind: "star" }];
|
|
1058
|
+
let onConflict;
|
|
1059
|
+
let columns;
|
|
1060
|
+
for (const [k, v] of params) if (k === "select") {
|
|
1061
|
+
const end = checkSelect(v);
|
|
1062
|
+
if (end instanceof PgRestError) throw end;
|
|
1063
|
+
const used = v.slice(0, end).trim();
|
|
1064
|
+
select = used === "" ? [{ kind: "star" }] : parseSelect(used);
|
|
1065
|
+
} else if (k === "on_conflict") onConflict = splitTopLevel(v, ",").map(unquote).filter(Boolean);
|
|
1066
|
+
else if (k === "columns") columns = splitTopLevel(v, ",").map(unquote).filter(Boolean);
|
|
1067
|
+
const known = /* @__PURE__ */ new Map();
|
|
1068
|
+
walkEmbeds(select, [], known);
|
|
1069
|
+
const root = emptyParams();
|
|
1070
|
+
const embeds = /* @__PURE__ */ new Map();
|
|
1071
|
+
const paramsFor = (path, key) => {
|
|
1072
|
+
if (path.length === 0) return root;
|
|
1073
|
+
const joined = path.join(".");
|
|
1074
|
+
const canonical = known.get(joined);
|
|
1075
|
+
if (!canonical) throw new PgRestError(400, "PGRST108", `'${joined}' is not an embedded resource in this request`, null, `Verify that '${joined}' is included in the 'select' query parameter.`);
|
|
1076
|
+
let p = embeds.get(canonical);
|
|
1077
|
+
if (!p) {
|
|
1078
|
+
p = emptyParams();
|
|
1079
|
+
embeds.set(canonical, p);
|
|
1080
|
+
}
|
|
1081
|
+
return p;
|
|
1082
|
+
};
|
|
1083
|
+
for (const [key, value] of params) {
|
|
1084
|
+
if (key === "select" || key === "on_conflict" || key === "columns") continue;
|
|
1085
|
+
if (opts.allowFilterKey && !opts.allowFilterKey(key)) continue;
|
|
1086
|
+
const parts = key.split(".");
|
|
1087
|
+
const last = parts[parts.length - 1];
|
|
1088
|
+
if (RESERVED_TERMINALS.has(last)) {
|
|
1089
|
+
let path = parts.slice(0, -1);
|
|
1090
|
+
if (last === "and" || last === "or") {
|
|
1091
|
+
const negate = path[path.length - 1] === "not";
|
|
1092
|
+
if (negate) path = path.slice(0, -1);
|
|
1093
|
+
const used = checkLogicTree(negate ? `not.${last}` : last, value);
|
|
1094
|
+
if (used instanceof PgRestError) throw used;
|
|
1095
|
+
paramsFor(path, key).filters.push(parseLogic(last, negate, value.slice(0, used)));
|
|
1096
|
+
} else if (last === "order") paramsFor(path, key).order.push(...parseOrder(value));
|
|
1097
|
+
else if (last === "limit") {
|
|
1098
|
+
const n = parseCount("limit", value);
|
|
1099
|
+
if (n !== void 0) paramsFor(path, key).limit = n;
|
|
1100
|
+
} else {
|
|
1101
|
+
const n = parseCount("offset", value);
|
|
1102
|
+
if (n !== void 0) paramsFor(path, key).offset = n;
|
|
1103
|
+
}
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
const embedPath = parts.join(".");
|
|
1107
|
+
if (known.has(embedPath)) {
|
|
1108
|
+
const m = /^(not\.)?is\.null$/.exec(value);
|
|
1109
|
+
if (!m) throw new PgRestError(400, "PGRST120", "Embedded resource filter only accepts is.null or not.is.null", null, null);
|
|
1110
|
+
paramsFor(parts.slice(0, -1), key).presence.push({
|
|
1111
|
+
embed: known.get(embedPath).split(".").pop(),
|
|
1112
|
+
present: Boolean(m[1])
|
|
1113
|
+
});
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
paramsFor(parts.slice(0, -1), key).filters.push(parseOperatorExpr(parseField(last), value));
|
|
1117
|
+
}
|
|
1118
|
+
return {
|
|
1119
|
+
select,
|
|
1120
|
+
root,
|
|
1121
|
+
embeds,
|
|
1122
|
+
...onConflict ? { onConflict } : {},
|
|
1123
|
+
...columns ? { columns } : {}
|
|
1124
|
+
};
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
//#endregion
|
|
1128
|
+
//#region src/parse/request.ts
|
|
1129
|
+
const METHODS = new Set([
|
|
1130
|
+
"GET",
|
|
1131
|
+
"HEAD",
|
|
1132
|
+
"POST",
|
|
1133
|
+
"PATCH",
|
|
1134
|
+
"PUT",
|
|
1135
|
+
"DELETE",
|
|
1136
|
+
"OPTIONS"
|
|
1137
|
+
]);
|
|
1138
|
+
const isSupportedMethod = (m) => METHODS.has(m.toUpperCase());
|
|
1139
|
+
const parseMethod = (m) => {
|
|
1140
|
+
const up = m.toUpperCase();
|
|
1141
|
+
if (!METHODS.has(up)) throw new PgRestError(405, "PGRST117", `Unsupported HTTP method: ${m}`);
|
|
1142
|
+
return up;
|
|
1143
|
+
};
|
|
1144
|
+
/**
|
|
1145
|
+
* Route a request path. `basePath` is stripped first, so the library can be
|
|
1146
|
+
* mounted anywhere (`/api/rest`, ...).
|
|
1147
|
+
*/
|
|
1148
|
+
const parseTarget = (pathname, basePath = "") => {
|
|
1149
|
+
let path = pathname;
|
|
1150
|
+
if (basePath && basePath !== "/") {
|
|
1151
|
+
const base = basePath.replace(/\/+$/, "");
|
|
1152
|
+
if (path === base) path = "/";
|
|
1153
|
+
else if (path.startsWith(`${base}/`)) path = path.slice(base.length);
|
|
1154
|
+
else throw new PgRestError(404, "PGRST125", `Invalid path specified in request URL`);
|
|
1155
|
+
}
|
|
1156
|
+
const segs = path.split("/").filter(Boolean).map((s) => {
|
|
1157
|
+
try {
|
|
1158
|
+
return decodeURIComponent(s);
|
|
1159
|
+
} catch {
|
|
1160
|
+
throw new PgRestError(400, "PGRST100", "Invalid URL encoding in path");
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
if (segs.length === 0) return { type: "root" };
|
|
1164
|
+
if (segs[0] === "rpc") {
|
|
1165
|
+
if (segs.length !== 2) throw new PgRestError(404, "PGRST125", "Invalid path specified in request URL");
|
|
1166
|
+
return {
|
|
1167
|
+
type: "rpc",
|
|
1168
|
+
name: segs[1]
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
if (segs.length !== 1) throw new PgRestError(404, "PGRST125", "Invalid path specified in request URL");
|
|
1172
|
+
return {
|
|
1173
|
+
type: "table",
|
|
1174
|
+
name: segs[0]
|
|
1175
|
+
};
|
|
1176
|
+
};
|
|
1177
|
+
const PREFERENCE_VALUES = {
|
|
1178
|
+
return: [
|
|
1179
|
+
"minimal",
|
|
1180
|
+
"headers-only",
|
|
1181
|
+
"representation"
|
|
1182
|
+
],
|
|
1183
|
+
count: [
|
|
1184
|
+
"exact",
|
|
1185
|
+
"planned",
|
|
1186
|
+
"estimated"
|
|
1187
|
+
],
|
|
1188
|
+
resolution: ["merge-duplicates", "ignore-duplicates"],
|
|
1189
|
+
missing: ["default", "null"],
|
|
1190
|
+
handling: ["strict", "lenient"],
|
|
1191
|
+
tx: ["commit", "rollback"]
|
|
1192
|
+
};
|
|
1193
|
+
/** Parse one or more `Prefer` header values (comma separated tokens). */
|
|
1194
|
+
const parsePrefer = (header) => {
|
|
1195
|
+
const out = { invalid: [] };
|
|
1196
|
+
const invalid = [];
|
|
1197
|
+
if (!header) return out;
|
|
1198
|
+
for (const raw of header.split(",")) {
|
|
1199
|
+
const token = raw.trim();
|
|
1200
|
+
if (!token) continue;
|
|
1201
|
+
const eq = token.indexOf("=");
|
|
1202
|
+
if (eq < 0) {
|
|
1203
|
+
invalid.push(token);
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
const key = token.slice(0, eq).trim().toLowerCase();
|
|
1207
|
+
const value = token.slice(eq + 1).trim();
|
|
1208
|
+
if (key === "timezone") out.timezone = value;
|
|
1209
|
+
else if (key === "max-affected") if (/^\d+$/.test(value)) out.maxAffected = Number(value);
|
|
1210
|
+
else invalid.push(token);
|
|
1211
|
+
else if (PREFERENCE_VALUES[key]?.includes(value)) out[key] = value;
|
|
1212
|
+
else invalid.push(token);
|
|
1213
|
+
}
|
|
1214
|
+
out.invalid = invalid;
|
|
1215
|
+
return out;
|
|
1216
|
+
};
|
|
1217
|
+
/** `Range: 5-14` (with `Range-Unit: items`, or none). */
|
|
1218
|
+
const parseRange = (range, unit) => {
|
|
1219
|
+
if (!range) return void 0;
|
|
1220
|
+
if (unit && unit.toLowerCase() !== "items") return void 0;
|
|
1221
|
+
const m = /^(?:items=)?(\d+)-(\d*)$/.exec(range.trim());
|
|
1222
|
+
if (!m) return void 0;
|
|
1223
|
+
const start = Number(m[1]);
|
|
1224
|
+
if (m[2] === "") return { offset: start };
|
|
1225
|
+
const end = Number(m[2]);
|
|
1226
|
+
if (end < start) throw new PgRestError(416, "PGRST103", "Requested range not satisfiable", `The lower boundary must be lower than or equal to the upper boundary in the Range header.`, null);
|
|
1227
|
+
return {
|
|
1228
|
+
offset: start,
|
|
1229
|
+
limit: end - start + 1
|
|
1230
|
+
};
|
|
1231
|
+
};
|
|
1232
|
+
const parseAcceptEntry = (entry) => {
|
|
1233
|
+
const [type = "", ...params] = entry.split(";").map((s) => s.trim());
|
|
1234
|
+
let q = 1;
|
|
1235
|
+
let stripNulls = false;
|
|
1236
|
+
let forType;
|
|
1237
|
+
let options = [];
|
|
1238
|
+
for (const p of params) {
|
|
1239
|
+
const eq = p.indexOf("=");
|
|
1240
|
+
const k = (eq < 0 ? p : p.slice(0, eq)).trim();
|
|
1241
|
+
const v = (eq < 0 ? "" : p.slice(eq + 1)).trim().replace(/^"|"$/g, "");
|
|
1242
|
+
if (k === "q") q = Number(v);
|
|
1243
|
+
else if (k === "nulls" && v === "stripped") stripNulls = true;
|
|
1244
|
+
else if (k === "for") forType = v;
|
|
1245
|
+
else if (k === "options") options = v.split("|").filter((o) => PLAN_OPTIONS.has(o));
|
|
1246
|
+
}
|
|
1247
|
+
return {
|
|
1248
|
+
type: type.toLowerCase(),
|
|
1249
|
+
q: Number.isNaN(q) ? 1 : q,
|
|
1250
|
+
stripNulls,
|
|
1251
|
+
forType,
|
|
1252
|
+
options
|
|
1253
|
+
};
|
|
1254
|
+
};
|
|
1255
|
+
const PLAN_OPTIONS = new Set([
|
|
1256
|
+
"analyze",
|
|
1257
|
+
"verbose",
|
|
1258
|
+
"settings",
|
|
1259
|
+
"buffers",
|
|
1260
|
+
"wal"
|
|
1261
|
+
]);
|
|
1262
|
+
const describeEntry = (e) => {
|
|
1263
|
+
if (!e.type.startsWith("application/vnd.pgrst.plan")) return e.type;
|
|
1264
|
+
const fmt = e.type.endsWith("+json") ? "+json" : "+text";
|
|
1265
|
+
const opts = e.options.length ? `; options=${e.options.join("|")}` : "";
|
|
1266
|
+
return `application/vnd.pgrst.plan${fmt}; for="${e.forType ?? "application/json"}"${opts}`;
|
|
1267
|
+
};
|
|
1268
|
+
/** Pick an output format from the `Accept` header. */
|
|
1269
|
+
const parseAccept = (accept, isRoot, planEnabled = false, choices = []) => {
|
|
1270
|
+
const entries = (accept ?? "*/*").split(",").map((e) => e.trim()).filter(Boolean).map(parseAcceptEntry).sort((a, b) => b.q - a.q);
|
|
1271
|
+
if (entries.length === 0) entries.push({
|
|
1272
|
+
type: "*/*",
|
|
1273
|
+
q: 1,
|
|
1274
|
+
stripNulls: false,
|
|
1275
|
+
forType: void 0,
|
|
1276
|
+
options: []
|
|
1277
|
+
});
|
|
1278
|
+
for (const { type, stripNulls, forType, options } of entries) {
|
|
1279
|
+
if (type === "application/vnd.pgrst.plan" || type === "application/vnd.pgrst.plan+json" || type === "application/vnd.pgrst.plan+text") {
|
|
1280
|
+
if (!planEnabled || isRoot) continue;
|
|
1281
|
+
const inner = parseAccept(forType ?? "application/json", false, false);
|
|
1282
|
+
return {
|
|
1283
|
+
kind: "plan",
|
|
1284
|
+
format: type.endsWith("+json") ? "json" : "text",
|
|
1285
|
+
forType: forType ?? "application/json",
|
|
1286
|
+
options,
|
|
1287
|
+
inner
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
const custom = choices.find((c) => c.mediaType === type);
|
|
1291
|
+
if (custom && !isRoot) return {
|
|
1292
|
+
kind: "handler",
|
|
1293
|
+
choice: custom
|
|
1294
|
+
};
|
|
1295
|
+
switch (type) {
|
|
1296
|
+
case "*/*":
|
|
1297
|
+
case "application/*":
|
|
1298
|
+
case "application/json":
|
|
1299
|
+
case "application/openapi+json":
|
|
1300
|
+
if (isRoot) return { kind: "openapi" };
|
|
1301
|
+
if (type !== "application/openapi+json") return {
|
|
1302
|
+
kind: "json",
|
|
1303
|
+
stripNulls
|
|
1304
|
+
};
|
|
1305
|
+
break;
|
|
1306
|
+
case "application/vnd.pgrst.array+json": return {
|
|
1307
|
+
kind: "json",
|
|
1308
|
+
stripNulls
|
|
1309
|
+
};
|
|
1310
|
+
case "application/vnd.pgrst.object+json":
|
|
1311
|
+
case "application/vnd.pgrst.object": return {
|
|
1312
|
+
kind: "object",
|
|
1313
|
+
stripNulls
|
|
1314
|
+
};
|
|
1315
|
+
case "text/csv": return { kind: "csv" };
|
|
1316
|
+
case "application/geo+json":
|
|
1317
|
+
if (!isRoot) return { kind: "geojson" };
|
|
1318
|
+
break;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
throw new PgRestError(406, "PGRST107", "None of these media types are available: " + entries.map(describeEntry).join(", "));
|
|
1322
|
+
};
|
|
1323
|
+
/** Decode the request body into a JSON string (array of objects, or an object). */
|
|
1324
|
+
const parseBody = async (request, method) => {
|
|
1325
|
+
if (method !== "POST" && method !== "PATCH" && method !== "PUT") return void 0;
|
|
1326
|
+
const ct = (request.headers.get("content-type") ?? "application/json").split(";")[0].trim().toLowerCase();
|
|
1327
|
+
const text = await request.text();
|
|
1328
|
+
if (ct === "application/json" || ct === "application/vnd.pgrst.array+json") {
|
|
1329
|
+
if (text.trim() === "") return void 0;
|
|
1330
|
+
try {
|
|
1331
|
+
return {
|
|
1332
|
+
kind: "json",
|
|
1333
|
+
value: JSON.parse(text)
|
|
1334
|
+
};
|
|
1335
|
+
} catch {
|
|
1336
|
+
throw new PgRestError(400, "PGRST102", "Empty or invalid json");
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
if (ct === "application/x-www-form-urlencoded") {
|
|
1340
|
+
const obj = {};
|
|
1341
|
+
for (const [k, v] of new URLSearchParams(text)) obj[k] = v;
|
|
1342
|
+
return {
|
|
1343
|
+
kind: "urlencoded",
|
|
1344
|
+
value: obj
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
if (ct === "text/csv") return {
|
|
1348
|
+
kind: "csv",
|
|
1349
|
+
value: parseCsv(text)
|
|
1350
|
+
};
|
|
1351
|
+
throw new PgRestError(415, "PGRST107", `Content-Type not acceptable: ${ct}`);
|
|
1352
|
+
};
|
|
1353
|
+
/** Minimal RFC 4180 CSV → array of objects (first row is the header). */
|
|
1354
|
+
const parseCsv = (text) => {
|
|
1355
|
+
const rows = [];
|
|
1356
|
+
let row = [];
|
|
1357
|
+
let field$1 = "";
|
|
1358
|
+
let quoted = false;
|
|
1359
|
+
let sawAny = false;
|
|
1360
|
+
for (let i = 0; i < text.length; i++) {
|
|
1361
|
+
const ch = text[i];
|
|
1362
|
+
sawAny = true;
|
|
1363
|
+
if (quoted) if (ch === "\"") if (text[i + 1] === "\"") {
|
|
1364
|
+
field$1 += "\"";
|
|
1365
|
+
i++;
|
|
1366
|
+
} else quoted = false;
|
|
1367
|
+
else field$1 += ch;
|
|
1368
|
+
else if (ch === "\"") quoted = true;
|
|
1369
|
+
else if (ch === ",") {
|
|
1370
|
+
row.push(field$1);
|
|
1371
|
+
field$1 = "";
|
|
1372
|
+
} else if (ch === "\n" || ch === "\r") {
|
|
1373
|
+
if (ch === "\r" && text[i + 1] === "\n") i++;
|
|
1374
|
+
row.push(field$1);
|
|
1375
|
+
rows.push(row);
|
|
1376
|
+
row = [];
|
|
1377
|
+
field$1 = "";
|
|
1378
|
+
sawAny = false;
|
|
1379
|
+
} else field$1 += ch;
|
|
1380
|
+
}
|
|
1381
|
+
if (sawAny || field$1 !== "" || row.length > 0) {
|
|
1382
|
+
row.push(field$1);
|
|
1383
|
+
rows.push(row);
|
|
1384
|
+
}
|
|
1385
|
+
const [header, ...data] = rows;
|
|
1386
|
+
if (!header) return [];
|
|
1387
|
+
if (header.some((h) => h === "")) throw parseError("Invalid CSV", "the header row has an empty column name");
|
|
1388
|
+
return data.filter((r) => !(r.length === 1 && r[0] === "")).map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] === "" || r[i] === void 0 ? null : r[i]])));
|
|
1389
|
+
};
|
|
1390
|
+
|
|
1391
|
+
//#endregion
|
|
1392
|
+
//#region src/schema.ts
|
|
1393
|
+
const relKey = (schema, name) => `${schema}.${name}`;
|
|
1394
|
+
const kindOf = (k) => k === "v" ? "view" : k === "m" ? "matview" : k === "f" ? "foreign" : k === "p" ? "partitioned" : "table";
|
|
1395
|
+
const schemaList = (schemas) => `ARRAY[${schemas.map(literal).join(",")}]::text[]`;
|
|
1396
|
+
const asJson = (inner) => `SELECT coalesce(json_agg(r), '[]'::json)::text AS j FROM (${inner}) r`;
|
|
1397
|
+
const run = async (db, sql) => {
|
|
1398
|
+
const j = (await db.query(asJson(sql)))[0]?.["j"];
|
|
1399
|
+
return JSON.parse(typeof j === "string" ? j : "[]");
|
|
1400
|
+
};
|
|
1401
|
+
/**
|
|
1402
|
+
* Foreign keys seen through views: a view that exposes the columns of a foreign key (directly, or
|
|
1403
|
+
* through other views) gets the same relationships as the table, towards tables and views alike.
|
|
1404
|
+
*/
|
|
1405
|
+
const viewForeignKeys = (fks, rows) => {
|
|
1406
|
+
const viewCols = /* @__PURE__ */ new Map();
|
|
1407
|
+
for (const r of rows) {
|
|
1408
|
+
const key = relKey(r.vschema, r.vname);
|
|
1409
|
+
const m = viewCols.get(key) ?? /* @__PURE__ */ new Map();
|
|
1410
|
+
m.set(r.vcol, {
|
|
1411
|
+
key: relKey(r.tschema, r.tname),
|
|
1412
|
+
col: r.tcol
|
|
1413
|
+
});
|
|
1414
|
+
viewCols.set(key, m);
|
|
1415
|
+
}
|
|
1416
|
+
const base = (key, col, depth = 0) => {
|
|
1417
|
+
const next = viewCols.get(key)?.get(col);
|
|
1418
|
+
return next && depth < 16 ? base(next.key, next.col, depth + 1) : {
|
|
1419
|
+
key,
|
|
1420
|
+
col
|
|
1421
|
+
};
|
|
1422
|
+
};
|
|
1423
|
+
/** Every view that exposes all of `cols` of table `tableKey`, with the view-side column names. */
|
|
1424
|
+
const exposing = (tableKey, cols) => {
|
|
1425
|
+
const out$1 = [];
|
|
1426
|
+
for (const [vkey, m] of viewCols) {
|
|
1427
|
+
const names = [];
|
|
1428
|
+
for (const c of cols) {
|
|
1429
|
+
let found;
|
|
1430
|
+
for (const [vcol] of m) {
|
|
1431
|
+
const b = base(vkey, vcol);
|
|
1432
|
+
if (b.key === tableKey && b.col === c) {
|
|
1433
|
+
found = vcol;
|
|
1434
|
+
break;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
if (found === void 0) break;
|
|
1438
|
+
names.push(found);
|
|
1439
|
+
}
|
|
1440
|
+
if (names.length === cols.length) {
|
|
1441
|
+
const [schema, ...rest] = vkey.split(".");
|
|
1442
|
+
out$1.push({
|
|
1443
|
+
schema,
|
|
1444
|
+
name: rest.join("."),
|
|
1445
|
+
cols: names
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
return out$1;
|
|
1450
|
+
};
|
|
1451
|
+
const out = [];
|
|
1452
|
+
for (const fk of fks) {
|
|
1453
|
+
const sources = [{
|
|
1454
|
+
schema: fk.schema,
|
|
1455
|
+
name: fk.table,
|
|
1456
|
+
cols: [...fk.cols]
|
|
1457
|
+
}, ...exposing(relKey(fk.schema, fk.table), fk.cols)];
|
|
1458
|
+
const targets = [{
|
|
1459
|
+
schema: fk.fschema,
|
|
1460
|
+
name: fk.ftable,
|
|
1461
|
+
cols: [...fk.fcols]
|
|
1462
|
+
}, ...exposing(relKey(fk.fschema, fk.ftable), fk.fcols)];
|
|
1463
|
+
for (const [i, s] of sources.entries()) for (const [j, t] of targets.entries()) {
|
|
1464
|
+
if (i === 0 && j === 0) continue;
|
|
1465
|
+
out.push({
|
|
1466
|
+
name: fk.name,
|
|
1467
|
+
schema: s.schema,
|
|
1468
|
+
table: s.name,
|
|
1469
|
+
cols: s.cols,
|
|
1470
|
+
fschema: t.schema,
|
|
1471
|
+
ftable: t.name,
|
|
1472
|
+
fcols: t.cols
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
return out;
|
|
1477
|
+
};
|
|
1478
|
+
/** Read the catalog and build a fresh {@link SchemaCache}. */
|
|
1479
|
+
const introspect = async (db, schemas) => {
|
|
1480
|
+
const S = schemaList(schemas);
|
|
1481
|
+
const relRows = await run(db, `SELECT n.nspname AS schema, c.relname AS name, c.relkind::text AS kind,
|
|
1482
|
+
obj_description(c.oid, 'pg_class') AS comment,
|
|
1483
|
+
pg_relation_is_updatable(c.oid, true) AS bits
|
|
1484
|
+
FROM pg_class c
|
|
1485
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
1486
|
+
WHERE n.nspname = ANY(${S})
|
|
1487
|
+
AND c.relkind IN ('r','v','m','f','p')
|
|
1488
|
+
AND NOT c.relispartition
|
|
1489
|
+
ORDER BY 1, 2`);
|
|
1490
|
+
const colRows = await run(db, `SELECT n.nspname AS schema, c.relname AS "table", a.attname AS name,
|
|
1491
|
+
format_type(a.atttypid, a.atttypmod) AS type,
|
|
1492
|
+
NOT a.attnotnull AS nullable,
|
|
1493
|
+
(a.atthasdef OR a.attidentity <> '') AS has_default,
|
|
1494
|
+
pg_get_expr(d.adbin, d.adrelid) AS default_expr,
|
|
1495
|
+
(a.attgenerated <> '') AS generated,
|
|
1496
|
+
col_description(c.oid, a.attnum) AS comment,
|
|
1497
|
+
a.attnum AS pos
|
|
1498
|
+
FROM pg_attribute a
|
|
1499
|
+
JOIN pg_class c ON c.oid = a.attrelid
|
|
1500
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
1501
|
+
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
|
1502
|
+
WHERE n.nspname = ANY(${S})
|
|
1503
|
+
AND c.relkind IN ('r','v','m','f','p')
|
|
1504
|
+
AND NOT c.relispartition
|
|
1505
|
+
AND a.attnum > 0 AND NOT a.attisdropped
|
|
1506
|
+
ORDER BY 1, 2, a.attnum`);
|
|
1507
|
+
const conRows = await run(db, `SELECT con.conname AS name, con.contype::text AS type,
|
|
1508
|
+
ns.nspname AS schema, rel.relname AS "table",
|
|
1509
|
+
(SELECT json_agg(a.attname ORDER BY k.ord)
|
|
1510
|
+
FROM unnest(con.conkey) WITH ORDINALITY k(attnum, ord)
|
|
1511
|
+
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum) AS cols,
|
|
1512
|
+
fns.nspname AS fschema, frel.relname AS ftable,
|
|
1513
|
+
(SELECT json_agg(a.attname ORDER BY k.ord)
|
|
1514
|
+
FROM unnest(con.confkey) WITH ORDINALITY k(attnum, ord)
|
|
1515
|
+
JOIN pg_attribute a ON a.attrelid = con.confrelid AND a.attnum = k.attnum) AS fcols
|
|
1516
|
+
FROM pg_constraint con
|
|
1517
|
+
JOIN pg_class rel ON rel.oid = con.conrelid
|
|
1518
|
+
JOIN pg_namespace ns ON ns.oid = rel.relnamespace
|
|
1519
|
+
LEFT JOIN pg_class frel ON frel.oid = con.confrelid
|
|
1520
|
+
LEFT JOIN pg_namespace fns ON fns.oid = frel.relnamespace
|
|
1521
|
+
WHERE con.contype IN ('p','u','f')
|
|
1522
|
+
AND con.conparentid = 0
|
|
1523
|
+
AND (ns.nspname = ANY(${S}) OR fns.nspname = ANY(${S}))`);
|
|
1524
|
+
const viewRows = await run(db, `SELECT DISTINCT ON (vc.oid, m.x[1]::int)
|
|
1525
|
+
vn.nspname AS vschema, vc.relname AS vname, va.attname AS vcol,
|
|
1526
|
+
tn.nspname AS tschema, tc.relname AS tname, ta.attname AS tcol
|
|
1527
|
+
FROM pg_rewrite rw
|
|
1528
|
+
JOIN pg_class vc ON vc.oid = rw.ev_class AND vc.relkind IN ('v','m')
|
|
1529
|
+
JOIN pg_namespace vn ON vn.oid = vc.relnamespace
|
|
1530
|
+
CROSS JOIN LATERAL regexp_matches(rw.ev_action::text,
|
|
1531
|
+
':resno (\\d+) :resname [^:]* :ressortgroupref \\d+ :resorigtbl (\\d+) :resorigcol (\\d+) :resjunk false', 'g')
|
|
1532
|
+
WITH ORDINALITY AS m(x, ord)
|
|
1533
|
+
JOIN pg_attribute va ON va.attrelid = vc.oid AND va.attnum = m.x[1]::int AND NOT va.attisdropped
|
|
1534
|
+
JOIN pg_class tc ON tc.oid = m.x[2]::oid
|
|
1535
|
+
JOIN pg_namespace tn ON tn.oid = tc.relnamespace
|
|
1536
|
+
JOIN pg_attribute ta ON ta.attrelid = tc.oid AND ta.attnum = m.x[3]::int
|
|
1537
|
+
WHERE vn.nspname = ANY(${S}) AND rw.rulename = '_RETURN' AND m.x[2]::oid <> 0
|
|
1538
|
+
ORDER BY vc.oid, m.x[1]::int, m.ord DESC`);
|
|
1539
|
+
const fnRows = await run(db, `SELECT n.nspname AS schema, p.proname AS name,
|
|
1540
|
+
p.proretset AS returns_set, p.provolatile::text AS volatility,
|
|
1541
|
+
format_type(p.prorettype, NULL) AS return_type,
|
|
1542
|
+
rt.typtype::text AS return_typtype,
|
|
1543
|
+
coalesce(p.proargmodes && ARRAY['o','b','t']::"char"[], false) AS has_out,
|
|
1544
|
+
p.pronargdefaults AS n_defaults,
|
|
1545
|
+
obj_description(p.oid, 'pg_proc') AS comment,
|
|
1546
|
+
p.prorows::float8 AS rows,
|
|
1547
|
+
CASE WHEN rt.typtype = 'd' AND rt.typname LIKE '%/%'
|
|
1548
|
+
THEN json_build_object('name', rt.typname, 'bytea', rt.typbasetype = 'bytea'::regtype) END AS ret_media,
|
|
1549
|
+
(SELECT json_build_object('schema', n2.nspname, 'name', c2.relname)
|
|
1550
|
+
FROM pg_type t2 JOIN pg_class c2 ON c2.oid = t2.typrelid JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
|
|
1551
|
+
WHERE p.pronargs = 1 AND t2.oid = p.proargtypes[0] AND c2.relkind IN ('r','v','m','f','p')) AS arg_rel,
|
|
1552
|
+
(SELECT json_build_object('schema', n2.nspname, 'name', c2.relname)
|
|
1553
|
+
FROM pg_type t2 JOIN pg_class c2 ON c2.oid = t2.typrelid JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
|
|
1554
|
+
WHERE t2.oid = p.prorettype AND c2.relkind IN ('r','v','m','f','p')) AS ret_rel,
|
|
1555
|
+
(SELECT coalesce(json_agg(json_build_object(
|
|
1556
|
+
'name', coalesce(p.proargnames[t.i], ''),
|
|
1557
|
+
'type', format_type(t.typ, NULL),
|
|
1558
|
+
'mode', coalesce(p.proargmodes[t.i]::text, 'i')) ORDER BY t.i), '[]'::json)
|
|
1559
|
+
FROM unnest(coalesce(p.proallargtypes, string_to_array(p.proargtypes::text, ' ')::oid[]))
|
|
1560
|
+
WITH ORDINALITY AS t(typ, i)
|
|
1561
|
+
WHERE coalesce(p.proargmodes[t.i]::text, 'i') IN ('i','b','v')) AS args
|
|
1562
|
+
FROM pg_proc p
|
|
1563
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
1564
|
+
JOIN pg_type rt ON rt.oid = p.prorettype
|
|
1565
|
+
WHERE n.nspname = ANY(${S})
|
|
1566
|
+
AND p.prokind = 'f'
|
|
1567
|
+
AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e')
|
|
1568
|
+
ORDER BY 1, 2`);
|
|
1569
|
+
const mediaHandlers = (await run(db, `SELECT n.nspname AS schema, p.proname AS name,
|
|
1570
|
+
(SELECT json_build_object('schema', n2.nspname, 'name', c2.relname)
|
|
1571
|
+
FROM pg_type t2 JOIN pg_class c2 ON c2.oid = t2.typrelid JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
|
|
1572
|
+
WHERE t2.oid = p.proargtypes[0] AND c2.relkind IN ('r','v','m','f','p')) AS arg_rel,
|
|
1573
|
+
rt.typbasetype = 'bytea'::regtype AS bytea
|
|
1574
|
+
FROM pg_proc p
|
|
1575
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
1576
|
+
JOIN pg_type rt ON rt.oid = p.prorettype AND rt.typtype = 'd'
|
|
1577
|
+
WHERE n.nspname = ANY(${S})
|
|
1578
|
+
AND p.prokind = 'a' AND p.pronargs = 1 AND p.proname LIKE '%/%'
|
|
1579
|
+
AND (p.proargtypes[0] = 'anyelement'::regtype
|
|
1580
|
+
OR EXISTS (SELECT 1 FROM pg_type t3 JOIN pg_class c3 ON c3.oid = t3.typrelid WHERE t3.oid = p.proargtypes[0]))
|
|
1581
|
+
ORDER BY 1, 2`)).map((h) => ({
|
|
1582
|
+
schema: h.schema,
|
|
1583
|
+
mediaType: h.name.toLowerCase(),
|
|
1584
|
+
...h.arg_rel ? { argRelation: h.arg_rel } : {},
|
|
1585
|
+
bytea: h.bytea
|
|
1586
|
+
}));
|
|
1587
|
+
const pk = /* @__PURE__ */ new Map();
|
|
1588
|
+
const uniques = /* @__PURE__ */ new Map();
|
|
1589
|
+
const foreignKeys = [];
|
|
1590
|
+
for (const c of conRows) {
|
|
1591
|
+
const key = relKey(c.schema, c.table);
|
|
1592
|
+
if (c.type === "p") pk.set(key, c.cols);
|
|
1593
|
+
else if (c.type === "u") uniques.set(key, [...uniques.get(key) ?? [], c.cols]);
|
|
1594
|
+
else if (c.type === "f" && c.fschema && c.ftable && c.fcols) foreignKeys.push({
|
|
1595
|
+
name: c.name,
|
|
1596
|
+
schema: c.schema,
|
|
1597
|
+
table: c.table,
|
|
1598
|
+
cols: c.cols,
|
|
1599
|
+
fschema: c.fschema,
|
|
1600
|
+
ftable: c.ftable,
|
|
1601
|
+
fcols: c.fcols
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
foreignKeys.push(...viewForeignKeys(foreignKeys, viewRows));
|
|
1605
|
+
const colsByRel = /* @__PURE__ */ new Map();
|
|
1606
|
+
for (const c of colRows) {
|
|
1607
|
+
const key = relKey(c.schema, c.table);
|
|
1608
|
+
const list = colsByRel.get(key) ?? [];
|
|
1609
|
+
list.push({
|
|
1610
|
+
name: c.name,
|
|
1611
|
+
type: c.type,
|
|
1612
|
+
nullable: c.nullable,
|
|
1613
|
+
hasDefault: c.has_default,
|
|
1614
|
+
defaultExpr: c.default_expr,
|
|
1615
|
+
generated: c.generated,
|
|
1616
|
+
comment: c.comment,
|
|
1617
|
+
position: c.pos
|
|
1618
|
+
});
|
|
1619
|
+
colsByRel.set(key, list);
|
|
1620
|
+
}
|
|
1621
|
+
const relations = /* @__PURE__ */ new Map();
|
|
1622
|
+
for (const r of relRows) {
|
|
1623
|
+
const key = relKey(r.schema, r.name);
|
|
1624
|
+
relations.set(key, {
|
|
1625
|
+
schema: r.schema,
|
|
1626
|
+
name: r.name,
|
|
1627
|
+
kind: kindOf(r.kind),
|
|
1628
|
+
columns: colsByRel.get(key) ?? [],
|
|
1629
|
+
pk: pk.get(key) ?? [],
|
|
1630
|
+
uniques: uniques.get(key) ?? [],
|
|
1631
|
+
insertable: (r.bits & 8) === 8,
|
|
1632
|
+
updatable: (r.bits & 4) === 4,
|
|
1633
|
+
deletable: (r.bits & 16) === 16,
|
|
1634
|
+
comment: r.comment
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
const functions = /* @__PURE__ */ new Map();
|
|
1638
|
+
for (const f of fnRows) {
|
|
1639
|
+
const nDefaults = f.n_defaults;
|
|
1640
|
+
const args = f.args.map((a, i) => ({
|
|
1641
|
+
name: a.name,
|
|
1642
|
+
type: a.type,
|
|
1643
|
+
hasDefault: i >= f.args.length - nDefaults,
|
|
1644
|
+
variadic: a.mode === "v"
|
|
1645
|
+
}));
|
|
1646
|
+
const def = {
|
|
1647
|
+
schema: f.schema,
|
|
1648
|
+
name: f.name,
|
|
1649
|
+
args,
|
|
1650
|
+
returnType: f.return_type,
|
|
1651
|
+
returnsSet: f.returns_set,
|
|
1652
|
+
returnsComposite: f.has_out || f.return_typtype === "c" || f.return_type === "record",
|
|
1653
|
+
returnsVoid: f.return_type === "void",
|
|
1654
|
+
volatility: f.volatility === "i" ? "immutable" : f.volatility === "s" ? "stable" : "volatile",
|
|
1655
|
+
comment: f.comment,
|
|
1656
|
+
rows: f.rows,
|
|
1657
|
+
...f.arg_rel ? { argRelation: f.arg_rel } : {},
|
|
1658
|
+
...f.ret_rel ? { returnRelation: f.ret_rel } : {},
|
|
1659
|
+
...f.ret_media && !f.returns_set ? { returnMediaType: f.ret_media } : {}
|
|
1660
|
+
};
|
|
1661
|
+
const key = relKey(f.schema, f.name);
|
|
1662
|
+
functions.set(key, [...functions.get(key) ?? [], def]);
|
|
1663
|
+
}
|
|
1664
|
+
return {
|
|
1665
|
+
schemas,
|
|
1666
|
+
relations,
|
|
1667
|
+
foreignKeys,
|
|
1668
|
+
functions,
|
|
1669
|
+
mediaHandlers
|
|
1670
|
+
};
|
|
1671
|
+
};
|
|
1672
|
+
const sameCols = (a, b) => a.length === b.length && a.every((c) => b.includes(c));
|
|
1673
|
+
const isUnique = (rel, cols) => rel.pk.length > 0 && sameCols(rel.pk, cols) || rel.uniques.some((u) => sameCols(u, cols));
|
|
1674
|
+
const describe = (r) => {
|
|
1675
|
+
const j = r.junction;
|
|
1676
|
+
if (j) return `'${j.relation.name}' using '${j.fkFrom.name}' and '${j.fkTo.name}' (many-to-many)`;
|
|
1677
|
+
if (r.computed) return `'${r.computed.name}' (computed, ${r.kind === "m2o" ? "many-to-one" : "one-to-many"})`;
|
|
1678
|
+
return `'${r.fk.name}' (${r.kind === "m2o" ? "many-to-one" : r.kind === "o2o" ? "one-to-one" : "one-to-many"}) on ${r.fk.table}(${r.fk.cols.join(",")})`;
|
|
1679
|
+
};
|
|
1680
|
+
/** Find the relationships between `from` and the table/view named `target`. */
|
|
1681
|
+
const findRelationships = (cache, from, target, hint) => {
|
|
1682
|
+
const out = [];
|
|
1683
|
+
const rel = (schema, name) => cache.relations.get(relKey(schema, name));
|
|
1684
|
+
for (const fk of cache.foreignKeys) {
|
|
1685
|
+
const selfReference = fk.schema === fk.fschema && fk.table === fk.ftable;
|
|
1686
|
+
if (fk.schema === from.schema && fk.table === from.name && fk.ftable === target && !selfReference) {
|
|
1687
|
+
const to = rel(fk.fschema, fk.ftable);
|
|
1688
|
+
if (to) out.push({
|
|
1689
|
+
kind: "m2o",
|
|
1690
|
+
from,
|
|
1691
|
+
to,
|
|
1692
|
+
fromCols: fk.cols,
|
|
1693
|
+
toCols: fk.fcols,
|
|
1694
|
+
fk
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
if (fk.schema === from.schema && fk.table === from.name && fk.cols.length === 1 && fk.cols[0] === target) {
|
|
1698
|
+
const to = rel(fk.fschema, fk.ftable);
|
|
1699
|
+
if (to) out.push({
|
|
1700
|
+
kind: "m2o",
|
|
1701
|
+
from,
|
|
1702
|
+
to,
|
|
1703
|
+
fromCols: fk.cols,
|
|
1704
|
+
toCols: fk.fcols,
|
|
1705
|
+
fk
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
if (fk.fschema === from.schema && fk.ftable === from.name && fk.table === target) {
|
|
1709
|
+
const to = rel(fk.schema, fk.table);
|
|
1710
|
+
if (to) out.push({
|
|
1711
|
+
kind: isUnique(to, fk.cols) ? "o2o" : "o2m",
|
|
1712
|
+
from,
|
|
1713
|
+
to,
|
|
1714
|
+
fromCols: fk.fcols,
|
|
1715
|
+
toCols: fk.cols,
|
|
1716
|
+
fk
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
for (const f of cache.functions.get(relKey(from.schema, target)) ?? []) {
|
|
1721
|
+
if (f.argRelation?.schema !== from.schema || f.argRelation.name !== from.name || !f.returnRelation) continue;
|
|
1722
|
+
const to = rel(f.returnRelation.schema, f.returnRelation.name);
|
|
1723
|
+
if (!to) continue;
|
|
1724
|
+
const fk = {
|
|
1725
|
+
name: f.name,
|
|
1726
|
+
schema: from.schema,
|
|
1727
|
+
table: from.name,
|
|
1728
|
+
cols: [],
|
|
1729
|
+
fschema: to.schema,
|
|
1730
|
+
ftable: to.name,
|
|
1731
|
+
fcols: []
|
|
1732
|
+
};
|
|
1733
|
+
out.push({
|
|
1734
|
+
kind: !f.returnsSet || f.rows === 1 ? "m2o" : "o2m",
|
|
1735
|
+
from,
|
|
1736
|
+
to,
|
|
1737
|
+
fromCols: [],
|
|
1738
|
+
toCols: [],
|
|
1739
|
+
fk,
|
|
1740
|
+
computed: f
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
for (const fk1 of cache.foreignKeys) {
|
|
1744
|
+
if (fk1.fschema !== from.schema || fk1.ftable !== from.name) continue;
|
|
1745
|
+
const junction = rel(fk1.schema, fk1.table);
|
|
1746
|
+
if (!junction) continue;
|
|
1747
|
+
for (const fk2 of cache.foreignKeys) {
|
|
1748
|
+
if (fk2 === fk1) continue;
|
|
1749
|
+
if (fk2.schema !== fk1.schema || fk2.table !== fk1.table) continue;
|
|
1750
|
+
if (fk2.ftable !== target) continue;
|
|
1751
|
+
const to = rel(fk2.fschema, fk2.ftable);
|
|
1752
|
+
if (!to) continue;
|
|
1753
|
+
out.push({
|
|
1754
|
+
kind: "m2m",
|
|
1755
|
+
from,
|
|
1756
|
+
to,
|
|
1757
|
+
fromCols: fk1.fcols,
|
|
1758
|
+
toCols: fk2.fcols,
|
|
1759
|
+
fk: fk1,
|
|
1760
|
+
junction: {
|
|
1761
|
+
relation: junction,
|
|
1762
|
+
fkFrom: fk1,
|
|
1763
|
+
fkTo: fk2
|
|
1764
|
+
}
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
if (!hint) return out;
|
|
1769
|
+
return out.filter((r) => {
|
|
1770
|
+
if (r.junction) return r.junction.relation.name === hint || r.junction.fkFrom.name === hint || r.junction.fkTo.name === hint;
|
|
1771
|
+
return r.fk.name === hint || r.fk.cols.includes(hint) || r.fk.fcols.includes(hint);
|
|
1772
|
+
});
|
|
1773
|
+
};
|
|
1774
|
+
/**
|
|
1775
|
+
* Resolve exactly one relationship or throw PostgREST-style PGRST200/PGRST201.
|
|
1776
|
+
* `target` may also be a table that is not related at all (→ PGRST200).
|
|
1777
|
+
*/
|
|
1778
|
+
const resolveRelationship = (cache, from, target, hint) => {
|
|
1779
|
+
const found = findRelationships(cache, from, target, hint);
|
|
1780
|
+
if (found.length === 1) return found[0];
|
|
1781
|
+
if (found.length === 0) throw new PgRestError(400, "PGRST200", `Could not find a relationship between '${from.name}' and '${target}' in the schema cache`, hint ? `Searched for a foreign key relationship between '${from.name}' and '${target}' using the hint '${hint}' in the schema '${from.schema}', but no matches were found.` : `Searched for a foreign key relationship between '${from.name}' and '${target}' in the schema '${from.schema}', but no matches were found.`, null);
|
|
1782
|
+
throw new PgRestError(300, "PGRST201", `Could not embed because more than one relationship was found for '${from.name}' and '${target}'`, JSON.stringify(found.map((r) => describe(r))), `Try changing '${target}' to one of the following: ${found.map((r) => `'${target}!${r.junction ? r.junction.relation.name : r.fk.name}'`).join(", ")}. Find the desired relationship in the 'details' key.`);
|
|
1783
|
+
};
|
|
1784
|
+
/** Look up a relation by (optional) schema and name, or throw PGRST205. */
|
|
1785
|
+
const getRelation = (cache, schema, name) => {
|
|
1786
|
+
const rel = cache.relations.get(relKey(schema, name));
|
|
1787
|
+
if (!rel) throw new PgRestError(404, "PGRST205", `Could not find the table '${schema}.${name}' in the schema cache`, null, suggest(cache, schema, name));
|
|
1788
|
+
return rel;
|
|
1789
|
+
};
|
|
1790
|
+
const suggest = (cache, schema, name) => {
|
|
1791
|
+
let best;
|
|
1792
|
+
for (const r of cache.relations.values()) {
|
|
1793
|
+
if (r.schema !== schema) continue;
|
|
1794
|
+
const d = levenshtein(name.toLowerCase(), r.name.toLowerCase());
|
|
1795
|
+
if (d <= Math.max(1, Math.floor(name.length / 4)) && (!best || d < best.d)) best = {
|
|
1796
|
+
name: r.name,
|
|
1797
|
+
d
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
return best ? `Perhaps you meant the table '${schema}.${best.name}'` : null;
|
|
1801
|
+
};
|
|
1802
|
+
const levenshtein = (a, b) => {
|
|
1803
|
+
const dp = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
1804
|
+
for (let i = 1; i <= a.length; i++) {
|
|
1805
|
+
let prev = dp[0];
|
|
1806
|
+
dp[0] = i;
|
|
1807
|
+
for (let j = 1; j <= b.length; j++) {
|
|
1808
|
+
const tmp = dp[j];
|
|
1809
|
+
dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
1810
|
+
prev = tmp;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
return dp[b.length];
|
|
1814
|
+
};
|
|
1815
|
+
|
|
1816
|
+
//#endregion
|
|
1817
|
+
//#region src/media.ts
|
|
1818
|
+
const choiceFor = (mediaType, bytea, extra) => ({
|
|
1819
|
+
mediaType,
|
|
1820
|
+
contentType: mediaType === "*/*" || bytea ? mediaType === "*/*" ? "application/octet-stream" : mediaType : `${mediaType}; charset=utf-8`,
|
|
1821
|
+
bytea,
|
|
1822
|
+
...extra
|
|
1823
|
+
});
|
|
1824
|
+
/** Aggregates in the exposed schemas that render rows of `relation` (or of any type) as a custom media type. */
|
|
1825
|
+
const aggregateChoices = (cache, schema, relation, allowTyped) => cache.mediaHandlers.filter((h) => h.schema === schema).filter((h) => h.argRelation ? allowTyped && h.argRelation.schema === relation.schema && h.argRelation.name === relation.name : true).map((h) => choiceFor(h.mediaType, h.bytea, {
|
|
1826
|
+
aggregate: quoteQualified(h.schema, h.mediaType),
|
|
1827
|
+
...h.argRelation ? { rowType: quoteQualified(h.argRelation.schema, h.argRelation.name) } : {}
|
|
1828
|
+
}));
|
|
1829
|
+
/**
|
|
1830
|
+
* The custom media types a request can be answered with. Handlers that take a specific table apply only when
|
|
1831
|
+
* the request returns whole rows of it (no `select`); `anyelement` handlers apply to any rows.
|
|
1832
|
+
*/
|
|
1833
|
+
const mediaChoices = (cache, schema, target, hasSelect) => {
|
|
1834
|
+
if (cache.mediaHandlers.length === 0 && ![...cache.functions.values()].some((fs) => fs.some((f) => f.returnMediaType))) return [];
|
|
1835
|
+
if (target.type === "table") {
|
|
1836
|
+
const rel = cache.relations.get(relKey(schema, target.name));
|
|
1837
|
+
return rel ? aggregateChoices(cache, schema, rel, !hasSelect) : [];
|
|
1838
|
+
}
|
|
1839
|
+
const fns = cache.functions.get(relKey(schema, target.name)) ?? [];
|
|
1840
|
+
const out = [];
|
|
1841
|
+
for (const f of fns) if (f.returnMediaType) out.push(choiceFor(f.returnMediaType.name.toLowerCase(), f.returnMediaType.bytea, {}));
|
|
1842
|
+
else if (f.returnRelation && f.returnsSet) out.push(...aggregateChoices(cache, schema, f.returnRelation, !hasSelect));
|
|
1843
|
+
if (fns.some((f) => !f.returnsSet && !f.returnsComposite && !f.returnsVoid)) {
|
|
1844
|
+
const any = cache.mediaHandlers.find((h) => h.schema === schema && h.mediaType === "*/*" && !h.argRelation);
|
|
1845
|
+
if (any) out.push(choiceFor("*/*", any.bytea, {}));
|
|
1846
|
+
}
|
|
1847
|
+
return out;
|
|
1848
|
+
};
|
|
1849
|
+
|
|
1850
|
+
//#endregion
|
|
1851
|
+
//#region src/openapi.ts
|
|
1852
|
+
/** Map a Postgres `format_type` string to an OpenAPI 2.0 schema fragment. */
|
|
1853
|
+
const pgTypeToOpenApi = (pgType) => {
|
|
1854
|
+
const t = pgType.trim();
|
|
1855
|
+
if (t.endsWith("[]")) return {
|
|
1856
|
+
type: "array",
|
|
1857
|
+
items: pgTypeToOpenApi(t.slice(0, -2))
|
|
1858
|
+
};
|
|
1859
|
+
const base = t.replace(/\(.*\)$/, "");
|
|
1860
|
+
switch (base) {
|
|
1861
|
+
case "smallint":
|
|
1862
|
+
case "integer": return {
|
|
1863
|
+
type: "integer",
|
|
1864
|
+
format: base
|
|
1865
|
+
};
|
|
1866
|
+
case "bigint": return {
|
|
1867
|
+
type: "integer",
|
|
1868
|
+
format: "bigint"
|
|
1869
|
+
};
|
|
1870
|
+
case "numeric":
|
|
1871
|
+
case "decimal": return {
|
|
1872
|
+
type: "number",
|
|
1873
|
+
format: "numeric"
|
|
1874
|
+
};
|
|
1875
|
+
case "real": return {
|
|
1876
|
+
type: "number",
|
|
1877
|
+
format: "real"
|
|
1878
|
+
};
|
|
1879
|
+
case "double precision": return {
|
|
1880
|
+
type: "number",
|
|
1881
|
+
format: "double precision"
|
|
1882
|
+
};
|
|
1883
|
+
case "boolean": return {
|
|
1884
|
+
type: "boolean",
|
|
1885
|
+
format: "boolean"
|
|
1886
|
+
};
|
|
1887
|
+
case "json":
|
|
1888
|
+
case "jsonb": return { format: base };
|
|
1889
|
+
case "uuid":
|
|
1890
|
+
case "text":
|
|
1891
|
+
case "date":
|
|
1892
|
+
case "bytea":
|
|
1893
|
+
case "money":
|
|
1894
|
+
case "interval": return {
|
|
1895
|
+
type: "string",
|
|
1896
|
+
format: base
|
|
1897
|
+
};
|
|
1898
|
+
case "character varying":
|
|
1899
|
+
case "character": return {
|
|
1900
|
+
type: "string",
|
|
1901
|
+
format: t
|
|
1902
|
+
};
|
|
1903
|
+
case "timestamp with time zone":
|
|
1904
|
+
case "timestamp without time zone":
|
|
1905
|
+
case "time with time zone":
|
|
1906
|
+
case "time without time zone": return {
|
|
1907
|
+
type: "string",
|
|
1908
|
+
format: base
|
|
1909
|
+
};
|
|
1910
|
+
default: return {
|
|
1911
|
+
type: "string",
|
|
1912
|
+
format: t
|
|
1913
|
+
};
|
|
1914
|
+
}
|
|
1915
|
+
};
|
|
1916
|
+
const columnSchema = (rel, c) => {
|
|
1917
|
+
const s = pgTypeToOpenApi(c.type);
|
|
1918
|
+
const notes = [];
|
|
1919
|
+
if (c.comment) notes.push(c.comment);
|
|
1920
|
+
if (rel.pk.includes(c.name)) notes.push("Note:\nThis is a Primary Key.<pk/>");
|
|
1921
|
+
if (notes.length) s["description"] = notes.join("\n\n");
|
|
1922
|
+
if (c.hasDefault && c.defaultExpr && !c.defaultExpr.includes("nextval(")) s["default"] = c.defaultExpr;
|
|
1923
|
+
return s;
|
|
1924
|
+
};
|
|
1925
|
+
const FILTER_NOTE = "Filter using PostgREST operators, e.g. `eq.1`, `gt.5`, `in.(a,b)`, `is.null`";
|
|
1926
|
+
/** Build the Swagger 2.0 document PostgREST serves at `/`. */
|
|
1927
|
+
const buildOpenApi = (cache, schema, opts) => {
|
|
1928
|
+
let host = "localhost";
|
|
1929
|
+
let scheme = "http";
|
|
1930
|
+
try {
|
|
1931
|
+
const u = new URL(opts.serverUrl);
|
|
1932
|
+
host = u.host;
|
|
1933
|
+
scheme = u.protocol.replace(":", "");
|
|
1934
|
+
} catch {}
|
|
1935
|
+
const relations = [...cache.relations.values()].filter((r) => r.schema === schema).sort((a, b) => a.name.localeCompare(b.name));
|
|
1936
|
+
const functions = [...cache.functions.values()].flat().filter((f) => f.schema === schema).sort((a, b) => a.name.localeCompare(b.name));
|
|
1937
|
+
const parameters = {
|
|
1938
|
+
preferReturn: {
|
|
1939
|
+
name: "Prefer",
|
|
1940
|
+
description: "Preference",
|
|
1941
|
+
required: false,
|
|
1942
|
+
in: "header",
|
|
1943
|
+
type: "string",
|
|
1944
|
+
enum: [
|
|
1945
|
+
"return=representation",
|
|
1946
|
+
"return=minimal",
|
|
1947
|
+
"return=headers-only"
|
|
1948
|
+
]
|
|
1949
|
+
},
|
|
1950
|
+
preferCount: {
|
|
1951
|
+
name: "Prefer",
|
|
1952
|
+
description: "Preference",
|
|
1953
|
+
required: false,
|
|
1954
|
+
in: "header",
|
|
1955
|
+
type: "string",
|
|
1956
|
+
enum: ["count=none"]
|
|
1957
|
+
},
|
|
1958
|
+
preferPost: {
|
|
1959
|
+
name: "Prefer",
|
|
1960
|
+
description: "Preference",
|
|
1961
|
+
required: false,
|
|
1962
|
+
in: "header",
|
|
1963
|
+
type: "string",
|
|
1964
|
+
enum: [
|
|
1965
|
+
"return=representation",
|
|
1966
|
+
"return=minimal",
|
|
1967
|
+
"return=headers-only",
|
|
1968
|
+
"resolution=ignore-duplicates",
|
|
1969
|
+
"resolution=merge-duplicates"
|
|
1970
|
+
]
|
|
1971
|
+
},
|
|
1972
|
+
select: {
|
|
1973
|
+
name: "select",
|
|
1974
|
+
description: "Filtering Columns",
|
|
1975
|
+
required: false,
|
|
1976
|
+
in: "query",
|
|
1977
|
+
type: "string"
|
|
1978
|
+
},
|
|
1979
|
+
on_conflict: {
|
|
1980
|
+
name: "on_conflict",
|
|
1981
|
+
description: "On Conflict",
|
|
1982
|
+
required: false,
|
|
1983
|
+
in: "query",
|
|
1984
|
+
type: "string"
|
|
1985
|
+
},
|
|
1986
|
+
order: {
|
|
1987
|
+
name: "order",
|
|
1988
|
+
description: "Ordering",
|
|
1989
|
+
required: false,
|
|
1990
|
+
in: "query",
|
|
1991
|
+
type: "string"
|
|
1992
|
+
},
|
|
1993
|
+
range: {
|
|
1994
|
+
name: "Range",
|
|
1995
|
+
description: "Limiting and Pagination",
|
|
1996
|
+
required: false,
|
|
1997
|
+
in: "header",
|
|
1998
|
+
type: "string"
|
|
1999
|
+
},
|
|
2000
|
+
rangeUnit: {
|
|
2001
|
+
name: "Range-Unit",
|
|
2002
|
+
description: "Limiting and Pagination",
|
|
2003
|
+
required: false,
|
|
2004
|
+
default: "items",
|
|
2005
|
+
in: "header",
|
|
2006
|
+
type: "string"
|
|
2007
|
+
},
|
|
2008
|
+
offset: {
|
|
2009
|
+
name: "offset",
|
|
2010
|
+
description: "Limiting and Pagination",
|
|
2011
|
+
required: false,
|
|
2012
|
+
in: "query",
|
|
2013
|
+
type: "string"
|
|
2014
|
+
},
|
|
2015
|
+
limit: {
|
|
2016
|
+
name: "limit",
|
|
2017
|
+
description: "Limiting and Pagination",
|
|
2018
|
+
required: false,
|
|
2019
|
+
in: "query",
|
|
2020
|
+
type: "string"
|
|
2021
|
+
}
|
|
2022
|
+
};
|
|
2023
|
+
const paths = { "/": { get: {
|
|
2024
|
+
tags: ["Introspection"],
|
|
2025
|
+
summary: "OpenAPI description (this document)",
|
|
2026
|
+
produces: ["application/openapi+json", "application/json"],
|
|
2027
|
+
responses: { "200": { description: "OK" } }
|
|
2028
|
+
} } };
|
|
2029
|
+
const definitions = {};
|
|
2030
|
+
for (const rel of relations) {
|
|
2031
|
+
const props = {};
|
|
2032
|
+
const required = [];
|
|
2033
|
+
for (const c of rel.columns) {
|
|
2034
|
+
props[c.name] = columnSchema(rel, c);
|
|
2035
|
+
if (!c.nullable && !c.hasDefault) required.push(c.name);
|
|
2036
|
+
}
|
|
2037
|
+
definitions[rel.name] = {
|
|
2038
|
+
type: "object",
|
|
2039
|
+
...required.length ? { required } : {},
|
|
2040
|
+
properties: props,
|
|
2041
|
+
...rel.comment ? { description: rel.comment } : {}
|
|
2042
|
+
};
|
|
2043
|
+
const filterRefs = [];
|
|
2044
|
+
for (const c of rel.columns) {
|
|
2045
|
+
const key = `rowFilter.${rel.name}.${c.name}`;
|
|
2046
|
+
parameters[key] = {
|
|
2047
|
+
name: c.name,
|
|
2048
|
+
required: false,
|
|
2049
|
+
in: "query",
|
|
2050
|
+
type: "string",
|
|
2051
|
+
description: FILTER_NOTE
|
|
2052
|
+
};
|
|
2053
|
+
filterRefs.push({ $ref: `#/parameters/${key}` });
|
|
2054
|
+
}
|
|
2055
|
+
const bodyKey = `body.${rel.name}`;
|
|
2056
|
+
parameters[bodyKey] = {
|
|
2057
|
+
name: rel.name,
|
|
2058
|
+
description: rel.name,
|
|
2059
|
+
required: false,
|
|
2060
|
+
in: "body",
|
|
2061
|
+
schema: { $ref: `#/definitions/${rel.name}` }
|
|
2062
|
+
};
|
|
2063
|
+
const ref = `#/definitions/${rel.name}`;
|
|
2064
|
+
const op = { get: {
|
|
2065
|
+
tags: [rel.name],
|
|
2066
|
+
parameters: [
|
|
2067
|
+
...filterRefs,
|
|
2068
|
+
{ $ref: "#/parameters/select" },
|
|
2069
|
+
{ $ref: "#/parameters/order" },
|
|
2070
|
+
{ $ref: "#/parameters/range" },
|
|
2071
|
+
{ $ref: "#/parameters/rangeUnit" },
|
|
2072
|
+
{ $ref: "#/parameters/offset" },
|
|
2073
|
+
{ $ref: "#/parameters/limit" },
|
|
2074
|
+
{ $ref: "#/parameters/preferCount" }
|
|
2075
|
+
],
|
|
2076
|
+
responses: {
|
|
2077
|
+
"200": {
|
|
2078
|
+
description: "OK",
|
|
2079
|
+
schema: {
|
|
2080
|
+
type: "array",
|
|
2081
|
+
items: { $ref: ref }
|
|
2082
|
+
}
|
|
2083
|
+
},
|
|
2084
|
+
"206": { description: "Partial Content" }
|
|
2085
|
+
},
|
|
2086
|
+
...rel.comment ? { summary: rel.comment } : {}
|
|
2087
|
+
} };
|
|
2088
|
+
if (rel.insertable) op["post"] = {
|
|
2089
|
+
tags: [rel.name],
|
|
2090
|
+
parameters: [
|
|
2091
|
+
{ $ref: `#/parameters/${bodyKey}` },
|
|
2092
|
+
{ $ref: "#/parameters/select" },
|
|
2093
|
+
{ $ref: "#/parameters/preferPost" }
|
|
2094
|
+
],
|
|
2095
|
+
responses: { "201": { description: "Created" } }
|
|
2096
|
+
};
|
|
2097
|
+
if (rel.deletable) op["delete"] = {
|
|
2098
|
+
tags: [rel.name],
|
|
2099
|
+
parameters: [...filterRefs, { $ref: "#/parameters/preferReturn" }],
|
|
2100
|
+
responses: { "204": { description: "No Content" } }
|
|
2101
|
+
};
|
|
2102
|
+
if (rel.updatable) op["patch"] = {
|
|
2103
|
+
tags: [rel.name],
|
|
2104
|
+
parameters: [
|
|
2105
|
+
...filterRefs,
|
|
2106
|
+
{ $ref: `#/parameters/${bodyKey}` },
|
|
2107
|
+
{ $ref: "#/parameters/preferReturn" }
|
|
2108
|
+
],
|
|
2109
|
+
responses: { "204": { description: "No Content" } }
|
|
2110
|
+
};
|
|
2111
|
+
paths[`/${rel.name}`] = op;
|
|
2112
|
+
}
|
|
2113
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2114
|
+
for (const fn of functions) {
|
|
2115
|
+
if (seen.has(fn.name)) continue;
|
|
2116
|
+
seen.add(fn.name);
|
|
2117
|
+
paths[`/rpc/${fn.name}`] = rpcPath(fn);
|
|
2118
|
+
}
|
|
2119
|
+
return {
|
|
2120
|
+
swagger: "2.0",
|
|
2121
|
+
info: {
|
|
2122
|
+
title: `standard ${schema} schema`,
|
|
2123
|
+
description: "",
|
|
2124
|
+
version: "16"
|
|
2125
|
+
},
|
|
2126
|
+
host,
|
|
2127
|
+
basePath: opts.basePath || "/",
|
|
2128
|
+
schemes: [scheme],
|
|
2129
|
+
consumes: [
|
|
2130
|
+
"application/json",
|
|
2131
|
+
"application/vnd.pgrst.object+json;nulls=stripped",
|
|
2132
|
+
"application/vnd.pgrst.object+json",
|
|
2133
|
+
"text/csv"
|
|
2134
|
+
],
|
|
2135
|
+
produces: [
|
|
2136
|
+
"application/json",
|
|
2137
|
+
"application/vnd.pgrst.object+json;nulls=stripped",
|
|
2138
|
+
"application/vnd.pgrst.object+json",
|
|
2139
|
+
"text/csv"
|
|
2140
|
+
],
|
|
2141
|
+
paths,
|
|
2142
|
+
definitions,
|
|
2143
|
+
parameters,
|
|
2144
|
+
externalDocs: {
|
|
2145
|
+
description: "PostgREST Documentation",
|
|
2146
|
+
url: "https://postgrest.org/en/v16/references/api.html"
|
|
2147
|
+
}
|
|
2148
|
+
};
|
|
2149
|
+
};
|
|
2150
|
+
const rpcPath = (fn) => {
|
|
2151
|
+
const props = {};
|
|
2152
|
+
const required = [];
|
|
2153
|
+
for (const a of fn.args) {
|
|
2154
|
+
if (!a.name) continue;
|
|
2155
|
+
props[a.name] = pgTypeToOpenApi(a.type);
|
|
2156
|
+
if (!a.hasDefault) required.push(a.name);
|
|
2157
|
+
}
|
|
2158
|
+
const path = { post: {
|
|
2159
|
+
tags: [`(rpc) ${fn.name}`],
|
|
2160
|
+
produces: [
|
|
2161
|
+
"application/json",
|
|
2162
|
+
"application/vnd.pgrst.object+json;nulls=stripped",
|
|
2163
|
+
"application/vnd.pgrst.object+json"
|
|
2164
|
+
],
|
|
2165
|
+
parameters: [{
|
|
2166
|
+
name: "args",
|
|
2167
|
+
required: true,
|
|
2168
|
+
in: "body",
|
|
2169
|
+
schema: {
|
|
2170
|
+
type: "object",
|
|
2171
|
+
properties: props,
|
|
2172
|
+
...required.length ? { required } : {}
|
|
2173
|
+
}
|
|
2174
|
+
}],
|
|
2175
|
+
responses: { "200": { description: "OK" } },
|
|
2176
|
+
...fn.comment ? { summary: fn.comment } : {}
|
|
2177
|
+
} };
|
|
2178
|
+
if (fn.volatility !== "volatile") path["get"] = {
|
|
2179
|
+
tags: [`(rpc) ${fn.name}`],
|
|
2180
|
+
produces: [
|
|
2181
|
+
"application/json",
|
|
2182
|
+
"application/vnd.pgrst.object+json;nulls=stripped",
|
|
2183
|
+
"application/vnd.pgrst.object+json"
|
|
2184
|
+
],
|
|
2185
|
+
parameters: fn.args.filter((a) => a.name).map((a) => ({
|
|
2186
|
+
name: a.name,
|
|
2187
|
+
required: !a.hasDefault,
|
|
2188
|
+
in: "query",
|
|
2189
|
+
...pgTypeToOpenApi(a.type)
|
|
2190
|
+
})),
|
|
2191
|
+
responses: { "200": { description: "OK" } }
|
|
2192
|
+
};
|
|
2193
|
+
return path;
|
|
2194
|
+
};
|
|
2195
|
+
|
|
2196
|
+
//#endregion
|
|
2197
|
+
//#region src/sql/filters.ts
|
|
2198
|
+
/** `"alias"."column"->'a'->>'b'` */
|
|
2199
|
+
const fieldSql = (alias, field$1) => {
|
|
2200
|
+
let sql = `${quoteIdent(alias)}.${quoteIdent(field$1.name)}`;
|
|
2201
|
+
for (const step of field$1.json) sql += ` ${step.arrow} ${step.isIndex ? String(Number(step.key)) : literal(step.key)}`;
|
|
2202
|
+
return sql;
|
|
2203
|
+
};
|
|
2204
|
+
const COMPARISON = {
|
|
2205
|
+
eq: "=",
|
|
2206
|
+
neq: "<>",
|
|
2207
|
+
gt: ">",
|
|
2208
|
+
gte: ">=",
|
|
2209
|
+
lt: "<",
|
|
2210
|
+
lte: "<=",
|
|
2211
|
+
like: "LIKE",
|
|
2212
|
+
ilike: "ILIKE",
|
|
2213
|
+
match: "~",
|
|
2214
|
+
imatch: "~*",
|
|
2215
|
+
cs: "@>",
|
|
2216
|
+
cd: "<@",
|
|
2217
|
+
ov: "&&",
|
|
2218
|
+
sl: "<<",
|
|
2219
|
+
sr: ">>",
|
|
2220
|
+
nxl: "&>",
|
|
2221
|
+
nxr: "&<",
|
|
2222
|
+
adj: "-|-"
|
|
2223
|
+
};
|
|
2224
|
+
const FTS_FUNCTIONS = {
|
|
2225
|
+
fts: "to_tsquery",
|
|
2226
|
+
plfts: "plainto_tsquery",
|
|
2227
|
+
phfts: "phraseto_tsquery",
|
|
2228
|
+
wfts: "websearch_to_tsquery"
|
|
2229
|
+
};
|
|
2230
|
+
const columnType = (rel, field$1) => field$1.json.length === 0 ? rel?.columns.find((c) => c.name === field$1.name)?.type : void 0;
|
|
2231
|
+
const patternValue = (op, v) => op === "like" || op === "ilike" ? v.replace(/\*/g, "%") : v;
|
|
2232
|
+
/** SQL boolean expression for one filter. */
|
|
2233
|
+
const filterSql = (f, alias, rel) => {
|
|
2234
|
+
const lhs = fieldSql(alias, f.field);
|
|
2235
|
+
let expr;
|
|
2236
|
+
switch (f.op) {
|
|
2237
|
+
case "in": {
|
|
2238
|
+
const list = f.value;
|
|
2239
|
+
expr = list.length === 0 ? "FALSE" : `${lhs} IN (${list.map(literal).join(", ")})`;
|
|
2240
|
+
break;
|
|
2241
|
+
}
|
|
2242
|
+
case "is":
|
|
2243
|
+
switch (f.value) {
|
|
2244
|
+
case "null":
|
|
2245
|
+
expr = `${lhs} IS NULL`;
|
|
2246
|
+
break;
|
|
2247
|
+
case "not_null":
|
|
2248
|
+
expr = `${lhs} IS NOT NULL`;
|
|
2249
|
+
break;
|
|
2250
|
+
case "true":
|
|
2251
|
+
expr = `${lhs} IS TRUE`;
|
|
2252
|
+
break;
|
|
2253
|
+
case "false":
|
|
2254
|
+
expr = `${lhs} IS FALSE`;
|
|
2255
|
+
break;
|
|
2256
|
+
default: expr = `${lhs} IS UNKNOWN`;
|
|
2257
|
+
}
|
|
2258
|
+
break;
|
|
2259
|
+
case "isdistinct":
|
|
2260
|
+
expr = `${lhs} IS DISTINCT FROM ${literal(f.value)}`;
|
|
2261
|
+
break;
|
|
2262
|
+
case "fts":
|
|
2263
|
+
case "plfts":
|
|
2264
|
+
case "phfts":
|
|
2265
|
+
case "wfts": {
|
|
2266
|
+
const fn = FTS_FUNCTIONS[f.op];
|
|
2267
|
+
const cfg = f.lang ? `${literal(f.lang)}, ` : "";
|
|
2268
|
+
expr = `${columnType(rel, f.field) === "tsvector" ? lhs : `to_tsvector(${cfg}${lhs})`} @@ ${fn}(${cfg}${literal(f.value)})`;
|
|
2269
|
+
break;
|
|
2270
|
+
}
|
|
2271
|
+
default: {
|
|
2272
|
+
const sqlOp = COMPARISON[f.op];
|
|
2273
|
+
const value = patternValue(f.op, f.value);
|
|
2274
|
+
expr = f.quantifier ? `${lhs} ${sqlOp} ${f.quantifier.toUpperCase()} (${literal(value)})` : `${lhs} ${sqlOp} ${literal(value)}`;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
return f.negate ? `NOT (${expr})` : expr;
|
|
2278
|
+
};
|
|
2279
|
+
const nodeSql = (node, alias, rel) => {
|
|
2280
|
+
if (node.kind === "filter") return filterSql(node, alias, rel);
|
|
2281
|
+
const joiner = node.op === "and" ? " AND " : " OR ";
|
|
2282
|
+
const wrapped = `(${node.children.map((c) => nodeSql(c, alias, rel)).join(joiner)})`;
|
|
2283
|
+
return node.negate ? `NOT ${wrapped}` : wrapped;
|
|
2284
|
+
};
|
|
2285
|
+
const whereSql = (nodes, alias, rel) => nodes.map((n) => nodeSql(n, alias, rel));
|
|
2286
|
+
|
|
2287
|
+
//#endregion
|
|
2288
|
+
//#region src/sql/read.ts
|
|
2289
|
+
const ROOT_ALIAS = "pgrst_root";
|
|
2290
|
+
var Aliases = class {
|
|
2291
|
+
n = 0;
|
|
2292
|
+
next(prefix) {
|
|
2293
|
+
return `${prefix}${++this.n}`;
|
|
2294
|
+
}
|
|
2295
|
+
};
|
|
2296
|
+
const fieldExpr = (item, alias) => {
|
|
2297
|
+
const base = fieldSql(alias, item.field);
|
|
2298
|
+
const expr = item.cast ? `${base}::${item.cast}` : base;
|
|
2299
|
+
return !item.cast && item.field.json.length === 0 && !item.alias ? expr : `${expr} AS ${quoteIdent(outputName(item))}`;
|
|
2300
|
+
};
|
|
2301
|
+
const aggregateExpr = (item, alias) => {
|
|
2302
|
+
const arg = item.field ? fieldSql(alias, item.field) : "*";
|
|
2303
|
+
let expr = `${item.fn}(${arg})`;
|
|
2304
|
+
if (item.cast) expr = `(${expr})::${item.cast}`;
|
|
2305
|
+
return `${expr} AS ${quoteIdent(outputName(item))}`;
|
|
2306
|
+
};
|
|
2307
|
+
/** Output column names produced by a list of select items (used for spreads). */
|
|
2308
|
+
const itemNames = (ctx, items, rel) => {
|
|
2309
|
+
const out = [];
|
|
2310
|
+
for (const it of items) if (it.kind === "star") out.push(...rel.columns.map((c) => c.name));
|
|
2311
|
+
else if (it.kind === "field" || it.kind === "aggregate") out.push(outputName(it));
|
|
2312
|
+
else if (it.items.length === 0) continue;
|
|
2313
|
+
else if (it.spread) out.push(...itemNames(ctx, it.items, resolveRelationship(ctx.cache, rel, it.target, it.hint).to));
|
|
2314
|
+
else out.push(it.alias ?? it.target);
|
|
2315
|
+
return out;
|
|
2316
|
+
};
|
|
2317
|
+
const orderClause = (ctx, terms, rel, alias, items) => {
|
|
2318
|
+
if (terms.length === 0) return "";
|
|
2319
|
+
const q = quoteIdent;
|
|
2320
|
+
return `ORDER BY ${terms.map((t) => {
|
|
2321
|
+
const tail = `${t.dir.toUpperCase()}${t.nulls ? ` NULLS ${t.nulls.toUpperCase()}` : ""}`;
|
|
2322
|
+
if (!t.relation) return `${fieldSql(alias, t.field)} ${tail}`;
|
|
2323
|
+
const embed = items.find((i) => i.kind === "embed" && (i.alias ?? i.target) === t.relation);
|
|
2324
|
+
if (!embed) throw new PgRestError(400, "PGRST108", `'${t.relation}' is not an embedded resource in this request`, null, `Verify that '${t.relation}' is included in the 'select' query parameter.`);
|
|
2325
|
+
const r = resolveRelationship(ctx.cache, rel, embed.target, embed.hint);
|
|
2326
|
+
if (r.kind !== "m2o" && r.kind !== "o2o") throw new PgRestError(400, "PGRST118", `A related order on '${t.relation}' is not possible`, `'${rel.name}' and '${embed.target}' do not form a many-to-one or one-to-one relationship`, null);
|
|
2327
|
+
const selected = embed.items.filter((i) => i.kind !== "embed");
|
|
2328
|
+
if (selected.length > 0 && !selected.some((i) => i.kind === "star" || i.kind === "field" && i.field.name === t.field.name)) throw new PgRestError(400, "42703", `column ${rel.name}_${embed.target}_1.${t.field.name} does not exist`, null, null);
|
|
2329
|
+
const o = ctx.aliases.next("pgrst_o");
|
|
2330
|
+
if (r.computed) return `(SELECT ${fieldSql(o, t.field)} FROM ${quoteQualified(r.computed.schema, r.computed.name)}(${q(alias)}) AS ${q(o)} LIMIT 1) ${tail}`;
|
|
2331
|
+
const cond = r.toCols.map((c, i) => `${q(o)}.${q(c)} = ${q(alias)}.${q(r.fromCols[i])}`).join(" AND ");
|
|
2332
|
+
return `(SELECT ${fieldSql(o, t.field)} FROM ${quoteQualified(r.to.schema, r.to.name)} AS ${q(o)} WHERE ${cond} LIMIT 1) ${tail}`;
|
|
2333
|
+
}).join(", ")}`;
|
|
2334
|
+
};
|
|
2335
|
+
const buildItems = (ctx, items, rel, alias, path) => {
|
|
2336
|
+
const cols = [];
|
|
2337
|
+
const joins = [];
|
|
2338
|
+
const groupBy = [];
|
|
2339
|
+
const aggregated = items.some((i) => i.kind === "aggregate");
|
|
2340
|
+
for (const item of items) switch (item.kind) {
|
|
2341
|
+
case "star":
|
|
2342
|
+
if (aggregated) for (const c of rel.columns) {
|
|
2343
|
+
const e = `${quoteIdent(alias)}.${quoteIdent(c.name)}`;
|
|
2344
|
+
cols.push(e);
|
|
2345
|
+
groupBy.push(e);
|
|
2346
|
+
}
|
|
2347
|
+
else cols.push(`${quoteIdent(alias)}.*`);
|
|
2348
|
+
break;
|
|
2349
|
+
case "field":
|
|
2350
|
+
cols.push(fieldExpr(item, alias));
|
|
2351
|
+
if (aggregated) groupBy.push(fieldSql(alias, item.field));
|
|
2352
|
+
break;
|
|
2353
|
+
case "aggregate":
|
|
2354
|
+
cols.push(aggregateExpr(item, alias));
|
|
2355
|
+
break;
|
|
2356
|
+
case "embed": {
|
|
2357
|
+
const e = buildEmbed(ctx, item, rel, alias, path);
|
|
2358
|
+
cols.push(...e.cols);
|
|
2359
|
+
joins.push(e.join);
|
|
2360
|
+
if (aggregated) groupBy.push(...e.group);
|
|
2361
|
+
break;
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
return {
|
|
2365
|
+
cols,
|
|
2366
|
+
joins,
|
|
2367
|
+
groupBy,
|
|
2368
|
+
aggregated
|
|
2369
|
+
};
|
|
2370
|
+
};
|
|
2371
|
+
const presenceConditions = (params, joins) => (params?.presence ?? []).map((p) => {
|
|
2372
|
+
const j = joins.find((x) => x.name === p.embed);
|
|
2373
|
+
if (!j) throw new PgRestError(400, "PGRST108", `'${p.embed}' is not an embedded resource in this request`, null, `Verify that '${p.embed}' is included in the 'select' query parameter.`);
|
|
2374
|
+
return `${quoteIdent(j.alias)}.j IS ${p.present ? "NOT " : ""}NULL`;
|
|
2375
|
+
});
|
|
2376
|
+
const paging = (limit, offset) => {
|
|
2377
|
+
let s = "";
|
|
2378
|
+
if (limit !== void 0) s += ` LIMIT ${Math.trunc(limit)}`;
|
|
2379
|
+
if (offset !== void 0 && offset > 0) s += ` OFFSET ${Math.trunc(offset)}`;
|
|
2380
|
+
return s;
|
|
2381
|
+
};
|
|
2382
|
+
const buildEmbed = (ctx, item, parentRel, parentAlias, path) => {
|
|
2383
|
+
const relationship = resolveRelationship(ctx.cache, parentRel, item.target, item.hint);
|
|
2384
|
+
const name = item.alias ?? item.target;
|
|
2385
|
+
const childPath = [...path, name];
|
|
2386
|
+
const params = ctx.query.embeds.get(childPath.join("."));
|
|
2387
|
+
const toOne = relationship.kind === "m2o" || relationship.kind === "o2o";
|
|
2388
|
+
const child = relationship.to;
|
|
2389
|
+
const cAlias = ctx.aliases.next("pgrst_c");
|
|
2390
|
+
const eAlias = ctx.aliases.next("pgrst_j");
|
|
2391
|
+
const inner = buildItems(ctx, item.items, child, cAlias, childPath);
|
|
2392
|
+
const hasCols = inner.cols.length > 0;
|
|
2393
|
+
const q = quoteIdent;
|
|
2394
|
+
const conds = [];
|
|
2395
|
+
let from;
|
|
2396
|
+
if (relationship.computed) {
|
|
2397
|
+
const fn = relationship.computed;
|
|
2398
|
+
from = `${quoteQualified(fn.schema, fn.name)}(${q(parentAlias)}) AS ${q(cAlias)}`;
|
|
2399
|
+
conds.push("TRUE");
|
|
2400
|
+
} else if (relationship.junction) {
|
|
2401
|
+
const j = relationship.junction;
|
|
2402
|
+
const jAlias = ctx.aliases.next("pgrst_m");
|
|
2403
|
+
const onTo = j.fkTo.cols.map((c, i) => `${q(jAlias)}.${q(c)} = ${q(cAlias)}.${q(j.fkTo.fcols[i])}`);
|
|
2404
|
+
from = `${quoteQualified(child.schema, child.name)} AS ${q(cAlias)} JOIN ${quoteQualified(j.relation.schema, j.relation.name)} AS ${q(jAlias)} ON ${onTo.join(" AND ")}`;
|
|
2405
|
+
conds.push(...j.fkFrom.cols.map((c, i) => `${q(jAlias)}.${q(c)} = ${q(parentAlias)}.${q(j.fkFrom.fcols[i])}`));
|
|
2406
|
+
} else {
|
|
2407
|
+
from = `${quoteQualified(child.schema, child.name)} AS ${q(cAlias)}`;
|
|
2408
|
+
conds.push(...relationship.toCols.map((c, i) => `${q(cAlias)}.${q(c)} = ${q(parentAlias)}.${q(relationship.fromCols[i])}`));
|
|
2409
|
+
}
|
|
2410
|
+
if (inner.joins.length) from += ` ${inner.joins.map((j) => j.sql).join(" ")}`;
|
|
2411
|
+
conds.push(...params ? whereSql(params.filters, cAlias, child) : []);
|
|
2412
|
+
conds.push(...presenceConditions(params, inner.joins));
|
|
2413
|
+
const order$1 = params ? orderClause(ctx, params.order, child, cAlias, item.items) : "";
|
|
2414
|
+
const page = paging(params?.limit, params?.offset);
|
|
2415
|
+
const sel = hasCols ? inner.cols.join(", ") : "1";
|
|
2416
|
+
const groupSql = inner.aggregated && inner.groupBy.length ? ` GROUP BY ${inner.groupBy.join(", ")}` : "";
|
|
2417
|
+
const body = `SELECT ${sel} FROM ${from} WHERE ${conds.join(" AND ")}${groupSql}${order$1 ? ` ${order$1}` : ""}${page}`;
|
|
2418
|
+
const J = "jsonb";
|
|
2419
|
+
const joinKw = item.join === "inner" ? "JOIN LATERAL" : "LEFT JOIN LATERAL";
|
|
2420
|
+
if (item.spread) {
|
|
2421
|
+
const names = itemNames(ctx, item.items, child);
|
|
2422
|
+
if (toOne) {
|
|
2423
|
+
const lateral$2 = `${joinKw} (${body}${page ? "" : " LIMIT 1"}) AS ${q(eAlias)} ON TRUE`;
|
|
2424
|
+
const refs$1 = names.map((n) => `${q(eAlias)}.${q(n)}`);
|
|
2425
|
+
return {
|
|
2426
|
+
cols: names.map((n, i) => `${refs$1[i]} AS ${q(n)}`),
|
|
2427
|
+
join: {
|
|
2428
|
+
sql: lateral$2,
|
|
2429
|
+
alias: eAlias,
|
|
2430
|
+
name,
|
|
2431
|
+
inner: item.join === "inner"
|
|
2432
|
+
},
|
|
2433
|
+
group: refs$1
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2436
|
+
if (inner.aggregated) throw new PgRestError(400, "PGRST127", "Feature not implemented", "Aggregates are not implemented for one-to-many or many-to-many spreads.", null);
|
|
2437
|
+
const agg = names.map((n) => `coalesce(${J}_agg(_r.${q(n)}), '[]'::${J}) AS ${q(n)}`).join(", ");
|
|
2438
|
+
const having = item.join === "inner" ? " HAVING count(*) > 0" : "";
|
|
2439
|
+
const lateral$1 = `${joinKw} (SELECT ${agg || "1"} FROM (${body}) AS _r${having}) AS ${q(eAlias)} ON TRUE`;
|
|
2440
|
+
const refs = names.map((n) => `${q(eAlias)}.${q(n)}`);
|
|
2441
|
+
return {
|
|
2442
|
+
cols: names.map((n, i) => `${refs[i]} AS ${q(n)}`),
|
|
2443
|
+
join: {
|
|
2444
|
+
sql: lateral$1,
|
|
2445
|
+
alias: eAlias,
|
|
2446
|
+
name,
|
|
2447
|
+
inner: item.join === "inner"
|
|
2448
|
+
},
|
|
2449
|
+
group: refs
|
|
2450
|
+
};
|
|
2451
|
+
}
|
|
2452
|
+
if (toOne) {
|
|
2453
|
+
const lateral$1 = `${joinKw} (SELECT to_${J}(_r) AS j FROM (${body}) AS _r LIMIT 1) AS ${q(eAlias)} ON TRUE`;
|
|
2454
|
+
return {
|
|
2455
|
+
cols: hasCols ? [`${q(eAlias)}.j AS ${q(name)}`] : [],
|
|
2456
|
+
join: {
|
|
2457
|
+
sql: lateral$1,
|
|
2458
|
+
alias: eAlias,
|
|
2459
|
+
name,
|
|
2460
|
+
inner: item.join === "inner"
|
|
2461
|
+
},
|
|
2462
|
+
group: hasCols ? [`${q(eAlias)}.j`] : []
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
const lateral = `${joinKw} (SELECT ${J}_agg(_r) AS j FROM (${body}) AS _r${item.join === "inner" ? " HAVING count(*) > 0" : ""}) AS ${q(eAlias)} ON TRUE`;
|
|
2466
|
+
return {
|
|
2467
|
+
cols: hasCols ? [`coalesce(${q(eAlias)}.j, '[]'::${J}) AS ${q(name)}`] : [],
|
|
2468
|
+
join: {
|
|
2469
|
+
sql: lateral,
|
|
2470
|
+
alias: eAlias,
|
|
2471
|
+
name,
|
|
2472
|
+
inner: item.join === "inner"
|
|
2473
|
+
},
|
|
2474
|
+
group: hasCols ? [`${q(eAlias)}.j`] : []
|
|
2475
|
+
};
|
|
2476
|
+
};
|
|
2477
|
+
/** Build the SELECT (and matching COUNT) for a read request. */
|
|
2478
|
+
const buildRead = (opts) => {
|
|
2479
|
+
const ctx = {
|
|
2480
|
+
cache: opts.cache,
|
|
2481
|
+
query: opts.query,
|
|
2482
|
+
aliases: new Aliases()
|
|
2483
|
+
};
|
|
2484
|
+
const built = buildItems(ctx, opts.query.select, opts.rel, ROOT_ALIAS, []);
|
|
2485
|
+
const where = [...whereSql(opts.query.root.filters, ROOT_ALIAS, opts.rel), ...presenceConditions(opts.query.root, built.joins)];
|
|
2486
|
+
const whereClause = where.length ? ` WHERE ${where.join(" AND ")}` : "";
|
|
2487
|
+
const fromClause = `${opts.from} AS ${quoteIdent(ROOT_ALIAS)}`;
|
|
2488
|
+
const joinSql = built.joins.map((j) => j.sql).join(" ");
|
|
2489
|
+
const groupClause = built.aggregated && built.groupBy.length ? ` GROUP BY ${built.groupBy.join(", ")}` : "";
|
|
2490
|
+
const order$1 = orderClause(ctx, opts.query.root.order, opts.rel, ROOT_ALIAS, opts.query.select);
|
|
2491
|
+
const unpaged = `SELECT ${built.cols.length ? built.cols.join(", ") : "1"} FROM ${fromClause}${joinSql ? ` ${joinSql}` : ""}${whereClause}${groupClause}`;
|
|
2492
|
+
const rows = `${unpaged}${order$1 ? ` ${order$1}` : ""}${paging(opts.limit, opts.offset)}`;
|
|
2493
|
+
let count;
|
|
2494
|
+
if (built.aggregated) count = `SELECT count(*) FROM (${unpaged}) AS _c`;
|
|
2495
|
+
else if (built.joins.some((j) => j.inner) || (opts.query.root.presence.length ?? 0) > 0) count = `SELECT count(*) FROM ${fromClause} ${joinSql}${whereClause}`;
|
|
2496
|
+
else count = `SELECT count(*) FROM ${fromClause}${whereClause}`;
|
|
2497
|
+
return {
|
|
2498
|
+
rows,
|
|
2499
|
+
count,
|
|
2500
|
+
unpaged
|
|
2501
|
+
};
|
|
2502
|
+
};
|
|
2503
|
+
|
|
2504
|
+
//#endregion
|
|
2505
|
+
//#region src/sql/rpc.ts
|
|
2506
|
+
const SINGLE_BODY_TYPES = new Set([
|
|
2507
|
+
"json",
|
|
2508
|
+
"jsonb",
|
|
2509
|
+
"text",
|
|
2510
|
+
"bytea"
|
|
2511
|
+
]);
|
|
2512
|
+
const isSingleUnnamedParam = (fn) => fn.args.length === 1 && fn.args[0].name === "" && SINGLE_BODY_TYPES.has(fn.args[0].type);
|
|
2513
|
+
/** PostgREST's spelling used in PGRST203: `schema.fn(a => integer, b => text)`. */
|
|
2514
|
+
const arrowSignature = (fn) => `${fn.schema}.${fn.name}(${fn.args.map((a) => `${a.name} => ${a.type}`).join(", ")})`;
|
|
2515
|
+
/** Similarity of two names, 1 for equal; PostgREST suggests the closest name above ~0.75. */
|
|
2516
|
+
const similarity = (a, b) => 1 - levenshtein(a.toLowerCase(), b.toLowerCase()) / Math.max(a.length, b.length, 1);
|
|
2517
|
+
const SUGGEST_AT = .75;
|
|
2518
|
+
/** The "Perhaps you meant ..." hint of PGRST202. */
|
|
2519
|
+
const functionHint = (cache, schema, name, candidates, provided) => {
|
|
2520
|
+
if (candidates.length > 0) {
|
|
2521
|
+
let best$1;
|
|
2522
|
+
for (const fn of candidates) {
|
|
2523
|
+
let score = 0;
|
|
2524
|
+
for (const p of provided) if (fn.args.some((a) => a.name === p)) score += 2;
|
|
2525
|
+
else if (fn.args.some((a) => similarity(a.name, p) >= SUGGEST_AT)) score += 1;
|
|
2526
|
+
if (score > 0 && (!best$1 || score > best$1.score)) best$1 = {
|
|
2527
|
+
fn,
|
|
2528
|
+
score
|
|
2529
|
+
};
|
|
2530
|
+
}
|
|
2531
|
+
return best$1 ? `Perhaps you meant to call the function ${schema}.${name}(${best$1.fn.args.map((a) => a.name).sort().join(", ")})` : null;
|
|
2532
|
+
}
|
|
2533
|
+
let best;
|
|
2534
|
+
for (const fns of cache.functions.values()) {
|
|
2535
|
+
const fn = fns[0];
|
|
2536
|
+
if (!fn || fn.schema !== schema) continue;
|
|
2537
|
+
const score = similarity(name, fn.name);
|
|
2538
|
+
if (score >= SUGGEST_AT && (!best || score > best.score)) best = {
|
|
2539
|
+
name: fn.name,
|
|
2540
|
+
score
|
|
2541
|
+
};
|
|
2542
|
+
}
|
|
2543
|
+
return best ? `Perhaps you meant to call the function ${schema}.${best.name}` : null;
|
|
2544
|
+
};
|
|
2545
|
+
/**
|
|
2546
|
+
* Pick the function to call from the argument names the client supplied.
|
|
2547
|
+
* `rawBody` is true when a request body that is not a JSON object was given;
|
|
2548
|
+
* `post` tells whether the function was invoked with POST (which also allows a single unnamed parameter).
|
|
2549
|
+
*/
|
|
2550
|
+
const resolveFunction = (cache, schema, name, provided, hasBody, post = true) => {
|
|
2551
|
+
const candidates = cache.functions.get(relKey(schema, name)) ?? [];
|
|
2552
|
+
const names = [...provided].sort();
|
|
2553
|
+
const params = names.length === 0 ? "without parameters" : `with parameter${names.length === 1 ? "" : "s"} ${names.join(", ")}`;
|
|
2554
|
+
const notFound = () => new PgRestError(404, "PGRST202", names.length === 0 ? `Could not find the function ${schema}.${name} without parameters in the schema cache` : `Could not find the function ${schema}.${name}(${names.join(", ")}) in the schema cache`, `Searched for the function ${schema}.${name} ${params}${post ? " or with a single unnamed json/jsonb parameter" : ""}, but no matches were found in the schema cache.`, functionHint(cache, schema, name, candidates, provided));
|
|
2555
|
+
if (candidates.length === 0) throw notFound();
|
|
2556
|
+
const named = candidates.filter((fn) => {
|
|
2557
|
+
const argNames = new Set(fn.args.map((a) => a.name));
|
|
2558
|
+
for (const p of provided) if (!argNames.has(p)) return false;
|
|
2559
|
+
return fn.args.every((a) => a.hasDefault || provided.has(a.name));
|
|
2560
|
+
});
|
|
2561
|
+
const single = hasBody ? candidates.filter(isSingleUnnamedParam) : [];
|
|
2562
|
+
const matches = provided.size === 0 && hasBody && single.length > 0 ? single : [...named, ...single.filter((s) => !named.includes(s))];
|
|
2563
|
+
if (matches.length === 0) throw notFound();
|
|
2564
|
+
if (matches.length === 1) return matches[0];
|
|
2565
|
+
const exact = matches.filter((fn) => fn.args.length === provided.size);
|
|
2566
|
+
if (exact.length === 1) return exact[0];
|
|
2567
|
+
throw new PgRestError(300, "PGRST203", `Could not choose the best candidate function between: ${matches.map(arrowSignature).sort().join(", ")}`, null, "Try renaming the parameters or the function itself in the database so function overloading can be resolved");
|
|
2568
|
+
};
|
|
2569
|
+
const stripQuotes = (s) => s.replace(/^"|"$/g, "");
|
|
2570
|
+
/** Find the table a composite return type refers to, if it is an exposed relation. */
|
|
2571
|
+
const relationOfReturnType = (cache, fn) => {
|
|
2572
|
+
if (!fn.returnsComposite) return void 0;
|
|
2573
|
+
const t = fn.returnType.replace(/\[\]$/, "");
|
|
2574
|
+
const dot = t.indexOf(".");
|
|
2575
|
+
if (dot > 0) return cache.relations.get(relKey(stripQuotes(t.slice(0, dot)), stripQuotes(t.slice(dot + 1))));
|
|
2576
|
+
const bare = stripQuotes(t);
|
|
2577
|
+
const inOwn = cache.relations.get(relKey(fn.schema, bare));
|
|
2578
|
+
if (inOwn) return inOwn;
|
|
2579
|
+
for (const r of cache.relations.values()) if (r.name === bare) return r;
|
|
2580
|
+
};
|
|
2581
|
+
const syntheticRelation = (fn) => ({
|
|
2582
|
+
schema: fn.schema,
|
|
2583
|
+
name: fn.name,
|
|
2584
|
+
kind: "view",
|
|
2585
|
+
columns: [],
|
|
2586
|
+
pk: [],
|
|
2587
|
+
uniques: [],
|
|
2588
|
+
insertable: false,
|
|
2589
|
+
updatable: false,
|
|
2590
|
+
deletable: false,
|
|
2591
|
+
comment: null
|
|
2592
|
+
});
|
|
2593
|
+
/**
|
|
2594
|
+
* Build the SQL that calls `fn`. Arguments travel as one JSON text (`$1`) and
|
|
2595
|
+
* are converted by `json_to_record` using the declared types, so values are
|
|
2596
|
+
* never interpolated into SQL.
|
|
2597
|
+
*/
|
|
2598
|
+
const buildRpc = (cache, fn, provided, rawBody) => {
|
|
2599
|
+
const fnRef = quoteQualified(fn.schema, fn.name);
|
|
2600
|
+
let args;
|
|
2601
|
+
let usesBody = false;
|
|
2602
|
+
let from = "";
|
|
2603
|
+
if (rawBody && isSingleUnnamedParam(fn)) {
|
|
2604
|
+
args = `($1::text)::${fn.args[0].type}`;
|
|
2605
|
+
usesBody = true;
|
|
2606
|
+
} else {
|
|
2607
|
+
const used = fn.args.filter((a) => provided.includes(a.name));
|
|
2608
|
+
args = used.map((a) => `${a.variadic ? "VARIADIC " : ""}${quoteIdent(a.name)} := _args.${quoteIdent(a.name)}`).join(", ");
|
|
2609
|
+
if (used.length > 0) {
|
|
2610
|
+
usesBody = true;
|
|
2611
|
+
from = `json_to_record(($1::text)::json) AS _args(${used.map((a) => `${quoteIdent(a.name)} ${a.type}`).join(", ")}) CROSS JOIN LATERAL `;
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
const call = `${from}${fnRef}(${args}) AS _f`;
|
|
2615
|
+
const base = fn.returnsVoid ? `SELECT 1 AS pgrst_scalar FROM ${call}` : fn.returnsComposite ? `SELECT _f.* FROM ${call}` : `SELECT _f AS pgrst_scalar FROM ${call}`;
|
|
2616
|
+
const shape = fn.returnsVoid ? "void" : fn.returnsComposite ? fn.returnsSet ? "rows" : "object" : fn.returnsSet ? "scalar-set" : "scalar";
|
|
2617
|
+
const relation = relationOfReturnType(cache, fn) ?? syntheticRelation(fn);
|
|
2618
|
+
return {
|
|
2619
|
+
fn,
|
|
2620
|
+
shape,
|
|
2621
|
+
source: base,
|
|
2622
|
+
usesBody,
|
|
2623
|
+
relation
|
|
2624
|
+
};
|
|
2625
|
+
};
|
|
2626
|
+
|
|
2627
|
+
//#endregion
|
|
2628
|
+
//#region src/sql/statement.ts
|
|
2629
|
+
const CSV_BODY = "coalesce((SELECT string_agg(k, ',') FROM (SELECT json_object_keys(r)::text AS k FROM (SELECT row_to_json(hh) AS r FROM pgrst_body AS hh LIMIT 1) s) a), '') || E'\\n' || coalesce(string_agg(substring(_t::text, 2, length(_t::text) - 2), E'\\n'), '')";
|
|
2630
|
+
const handlerBody = (h) => {
|
|
2631
|
+
if (h.aggregate) {
|
|
2632
|
+
const call = `${h.aggregate}(${h.rowType ? `ROW(_t.*)::${h.rowType}` : "_t"})`;
|
|
2633
|
+
return h.bytea ? `coalesce(encode(${call}::bytea, 'hex'), '')` : `coalesce(${call}::text, '')`;
|
|
2634
|
+
}
|
|
2635
|
+
return h.bytea ? `coalesce(encode((array_agg(_t.pgrst_scalar::bytea))[1], 'hex'), '')` : `coalesce((array_agg(_t.pgrst_scalar::text))[1], '')`;
|
|
2636
|
+
};
|
|
2637
|
+
const GEOJSON_BODY = "json_build_object('type', 'FeatureCollection', 'features', coalesce(json_agg(ST_AsGeoJSON(_t)::json), '[]'::json))::text";
|
|
2638
|
+
const bodyExpr = (shape, strip, csv, handler, geojson) => {
|
|
2639
|
+
if (geojson && shape !== "none") return GEOJSON_BODY;
|
|
2640
|
+
if (handler && shape !== "none") return handlerBody(handler);
|
|
2641
|
+
if (csv && shape !== "none") return CSV_BODY;
|
|
2642
|
+
const wrap = (e) => strip ? `json_strip_nulls(${e})` : e;
|
|
2643
|
+
switch (shape) {
|
|
2644
|
+
case "array": return `coalesce(${wrap("json_agg(_t)")}, '[]'::json)::text`;
|
|
2645
|
+
case "object": return `coalesce((${wrap("json_agg(_t)")} -> 0), 'null'::json)::text`;
|
|
2646
|
+
case "scalar-array": return `coalesce(json_agg(_t.pgrst_scalar), '[]'::json)::text`;
|
|
2647
|
+
case "scalar": return `coalesce((json_agg(_t.pgrst_scalar) -> 0)::text, 'null')`;
|
|
2648
|
+
case "none": return `''::text`;
|
|
2649
|
+
}
|
|
2650
|
+
};
|
|
2651
|
+
/**
|
|
2652
|
+
* The one statement that runs a request. Result columns:
|
|
2653
|
+
* `page_total`, `body`, `total_result_set`, `response_headers`, `response_status`.
|
|
2654
|
+
*
|
|
2655
|
+
* `response.headers` / `response.status` are read at the end so that functions
|
|
2656
|
+
* (RPC, triggers) can influence the response by calling `set_config`.
|
|
2657
|
+
*/
|
|
2658
|
+
const buildStatement = (o) => {
|
|
2659
|
+
const up = o.upsertColumns;
|
|
2660
|
+
return `WITH ${[...o.source && up ? [`pgrst_ins AS (${o.source})`, `pgrst_source AS (SELECT ${up.join(", ")} FROM pgrst_ins)`] : o.source ? [`pgrst_source AS (${o.source})`] : [], `pgrst_body AS (${o.rows})`].join(", ")} SELECT count(*)::text AS page_total, ${bodyExpr(o.shape, Boolean(o.stripNulls), Boolean(o.csv), o.handler, o.geojson)} AS body, ${o.count ? `(${o.count})::text` : "NULL::text"} AS total_result_set, nullif(current_setting('response.headers', true), '') AS response_headers, ` + (up ? `coalesce(nullif(current_setting('response.status', true), ''), CASE WHEN EXISTS (SELECT 1 FROM pgrst_ins) AND NOT EXISTS (SELECT 1 FROM pgrst_ins WHERE pgrst_inserted) THEN '200' END)` : `nullif(current_setting('response.status', true), '')`) + " AS response_status FROM pgrst_body AS _t";
|
|
2661
|
+
};
|
|
2662
|
+
|
|
2663
|
+
//#endregion
|
|
2664
|
+
//#region src/sql/write.ts
|
|
2665
|
+
const returningSql = (rel, returning) => {
|
|
2666
|
+
const alias = quoteIdent(ROOT_ALIAS);
|
|
2667
|
+
if (returning === "representation") return `${alias}.*`;
|
|
2668
|
+
if (returning === "pk" && rel.pk.length > 0) return rel.pk.map((c) => `${alias}.${quoteIdent(c)}`).join(", ");
|
|
2669
|
+
return "1";
|
|
2670
|
+
};
|
|
2671
|
+
const columnNotFound = (rel, column) => new PgRestError(400, "PGRST204", `Could not find the '${column}' column of '${rel.name}' in the schema cache`, null, null);
|
|
2672
|
+
const checkColumns = (rel, cols) => {
|
|
2673
|
+
const known = new Set(rel.columns.map((c) => c.name));
|
|
2674
|
+
for (const c of cols) if (!known.has(c)) throw columnNotFound(rel, c);
|
|
2675
|
+
};
|
|
2676
|
+
const unionKeys = (rows) => {
|
|
2677
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2678
|
+
for (const r of rows) for (const k of Object.keys(r)) seen.add(k);
|
|
2679
|
+
return [...seen];
|
|
2680
|
+
};
|
|
2681
|
+
/**
|
|
2682
|
+
* `INSERT … RETURNING …`. The payload is `$1` (JSON text, an array of objects);
|
|
2683
|
+
* it is always cast through `$1::text` so the driver never re-encodes it.
|
|
2684
|
+
*/
|
|
2685
|
+
const buildInsert = (o) => {
|
|
2686
|
+
const { rel } = o;
|
|
2687
|
+
const table = quoteQualified(rel.schema, rel.name);
|
|
2688
|
+
const target = `${table} AS ${quoteIdent(ROOT_ALIAS)}`;
|
|
2689
|
+
const returning = returningSql(rel, o.returning);
|
|
2690
|
+
const cols = o.columns ? [...o.columns] : unionKeys(o.rows);
|
|
2691
|
+
if (!o.columns && o.rows.some((r) => Object.keys(r).length !== cols.length)) throw new PgRestError(400, "PGRST102", "All object keys must match");
|
|
2692
|
+
checkColumns(rel, cols);
|
|
2693
|
+
if (o.rows.length === 0) {
|
|
2694
|
+
const first = rel.columns[0];
|
|
2695
|
+
if (!first) throw new PgRestError(400, "PGRST102", "Cannot insert into a relation without columns");
|
|
2696
|
+
return `INSERT INTO ${target} (${quoteIdent(first.name)}) SELECT NULL WHERE FALSE RETURNING ${returning}`;
|
|
2697
|
+
}
|
|
2698
|
+
if (cols.length === 0) {
|
|
2699
|
+
const first = rel.columns[0];
|
|
2700
|
+
if (!first) throw new PgRestError(400, "PGRST102", "Cannot insert into a relation without columns");
|
|
2701
|
+
const values = o.rows.map(() => "(DEFAULT)").join(", ");
|
|
2702
|
+
return `INSERT INTO ${target} (${quoteIdent(first.name)}) VALUES ${values || "(DEFAULT)"} RETURNING ${returning}`;
|
|
2703
|
+
}
|
|
2704
|
+
const colList = cols.map(quoteIdent).join(", ");
|
|
2705
|
+
const typed = `null::${table}`;
|
|
2706
|
+
const json = "($1::text)::json";
|
|
2707
|
+
let select;
|
|
2708
|
+
if (o.missingDefault) {
|
|
2709
|
+
const byName = new Map(rel.columns.map((c) => [c.name, c]));
|
|
2710
|
+
select = `SELECT ${cols.map((c) => {
|
|
2711
|
+
const allPresent = o.rows.every((r) => c in r);
|
|
2712
|
+
const value = `_p.${quoteIdent(c)}`;
|
|
2713
|
+
if (allPresent) return value;
|
|
2714
|
+
const dflt = byName.get(c)?.defaultExpr ?? "NULL";
|
|
2715
|
+
return `CASE WHEN (_e.value::jsonb) ? ${"'" + c.replace(/'/g, "''") + "'"} THEN ${value} ELSE ${dflt} END`;
|
|
2716
|
+
}).join(", ")} FROM json_array_elements(${json}) AS _e(value) CROSS JOIN LATERAL json_populate_record(${typed}, _e.value) AS _p`;
|
|
2717
|
+
} else select = `SELECT ${colList} FROM json_populate_recordset(${typed}, ${json})`;
|
|
2718
|
+
let conflict = "";
|
|
2719
|
+
if (o.resolution) {
|
|
2720
|
+
const keys = o.onConflict && o.onConflict.length > 0 ? o.onConflict : rel.pk;
|
|
2721
|
+
checkColumns(rel, keys);
|
|
2722
|
+
const targetCols = keys.length ? ` (${keys.map(quoteIdent).join(", ")})` : "";
|
|
2723
|
+
if (o.resolution === "ignore-duplicates") conflict = ` ON CONFLICT${targetCols} DO NOTHING`;
|
|
2724
|
+
else {
|
|
2725
|
+
if (keys.length === 0) throw new PgRestError(400, "PGRST100", "Upsert needs a conflict target", `"${rel.name}" has no primary key; pass on_conflict=<columns>`);
|
|
2726
|
+
conflict = ` ON CONFLICT${targetCols} DO UPDATE SET ${cols.map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`).join(", ")}`;
|
|
2727
|
+
}
|
|
2728
|
+
} else if (o.onConflict && o.onConflict.length > 0) {}
|
|
2729
|
+
const flag = o.flagInserted && o.resolution === "merge-duplicates" ? `, (${quoteIdent(ROOT_ALIAS)}.xmax = 0) AS pgrst_inserted` : "";
|
|
2730
|
+
return `INSERT INTO ${target} (${colList}) ${select}${conflict} RETURNING ${flag && returning === "1" ? "1 AS pgrst_one" : returning}${flag}`;
|
|
2731
|
+
};
|
|
2732
|
+
/** Columns of `buildInsert`'s output without the `pgrst_inserted` marker, for a merge-duplicates upsert. */
|
|
2733
|
+
const upsertColumns = (rel, returning) => {
|
|
2734
|
+
if (returning === "representation") return rel.columns.map((c) => quoteIdent(c.name));
|
|
2735
|
+
if (returning === "pk" && rel.pk.length > 0) return rel.pk.map(quoteIdent);
|
|
2736
|
+
return ["pgrst_one"];
|
|
2737
|
+
};
|
|
2738
|
+
/** `UPDATE … SET … RETURNING …`, or `null` when the payload sets no columns. */
|
|
2739
|
+
const buildUpdate = (o) => {
|
|
2740
|
+
const { rel } = o;
|
|
2741
|
+
const cols = Object.keys(o.body);
|
|
2742
|
+
checkColumns(rel, cols);
|
|
2743
|
+
if (cols.length === 0) return null;
|
|
2744
|
+
const table = quoteQualified(rel.schema, rel.name);
|
|
2745
|
+
const set = cols.map((c) => `${quoteIdent(c)} = _u.${quoteIdent(c)}`).join(", ");
|
|
2746
|
+
const where = whereSql(o.filters, ROOT_ALIAS, rel);
|
|
2747
|
+
return `UPDATE ${table} AS ${quoteIdent(ROOT_ALIAS)} SET ${set} FROM (SELECT * FROM json_populate_record(null::${table}, ($1::text)::json)) AS _u${where.length ? ` WHERE ${where.join(" AND ")}` : ""} RETURNING ${returningSql(rel, o.returning)}`;
|
|
2748
|
+
};
|
|
2749
|
+
const buildDelete = (o) => {
|
|
2750
|
+
const where = whereSql(o.filters, ROOT_ALIAS, o.rel);
|
|
2751
|
+
return `DELETE FROM ${quoteQualified(o.rel.schema, o.rel.name)} AS ${quoteIdent(ROOT_ALIAS)}${where.length ? ` WHERE ${where.join(" AND ")}` : ""} RETURNING ${returningSql(o.rel, o.returning)}`;
|
|
2752
|
+
};
|
|
2753
|
+
|
|
2754
|
+
//#endregion
|
|
2755
|
+
//#region src/core.ts
|
|
2756
|
+
/** Statement options that depend on the negotiated output format. */
|
|
2757
|
+
const formatOptions = (format) => ({
|
|
2758
|
+
csv: format.kind === "csv",
|
|
2759
|
+
geojson: format.kind === "geojson",
|
|
2760
|
+
handler: format.kind === "handler" ? format.choice : void 0
|
|
2761
|
+
});
|
|
2762
|
+
const JSON_CT = "application/json; charset=utf-8";
|
|
2763
|
+
const readSchema = (env, request, method) => {
|
|
2764
|
+
const wanted = (method === "GET" || method === "HEAD" ? request.headers.get("accept-profile") : request.headers.get("content-profile"))?.trim();
|
|
2765
|
+
if (!wanted) return env.config.schemas[0];
|
|
2766
|
+
if (!env.config.schemas.includes(wanted)) throw new PgRestError(406, "PGRST106", `Invalid schema: ${wanted}`, null, `Only the following schemas are exposed: ${env.config.schemas.join(", ")}`);
|
|
2767
|
+
return wanted;
|
|
2768
|
+
};
|
|
2769
|
+
const parseCookies = (header) => {
|
|
2770
|
+
const out = {};
|
|
2771
|
+
if (!header) return out;
|
|
2772
|
+
for (const part of header.split(";")) {
|
|
2773
|
+
const eq = part.indexOf("=");
|
|
2774
|
+
if (eq < 0) continue;
|
|
2775
|
+
const k = part.slice(0, eq).trim();
|
|
2776
|
+
if (!k) continue;
|
|
2777
|
+
const v = part.slice(eq + 1).trim().replace(/^"|"$/g, "");
|
|
2778
|
+
try {
|
|
2779
|
+
out[k] = decodeURIComponent(v);
|
|
2780
|
+
} catch {
|
|
2781
|
+
out[k] = v;
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
return out;
|
|
2785
|
+
};
|
|
2786
|
+
const applyCtxSql = (env, ctx, role, claims, request, method, pathname, schema, prefer) => {
|
|
2787
|
+
const entries = [["role", role], ["request.jwt.claims", JSON.stringify(claims)]];
|
|
2788
|
+
if (env.config.requestSettings) {
|
|
2789
|
+
const headers = {};
|
|
2790
|
+
request.headers.forEach((v, k) => {
|
|
2791
|
+
headers[k] = v;
|
|
2792
|
+
});
|
|
2793
|
+
entries.push(["request.method", method], ["request.path", pathname], ["request.headers", JSON.stringify(headers)], ["request.cookies", JSON.stringify(parseCookies(request.headers.get("cookie")))]);
|
|
2794
|
+
}
|
|
2795
|
+
const searchPath = [schema, ...env.config.extraSearchPath].map(quoteIdent).join(", ");
|
|
2796
|
+
entries.push(["search_path", searchPath]);
|
|
2797
|
+
if (prefer.timezone) entries.push(["timezone", prefer.timezone]);
|
|
2798
|
+
for (const [k, v] of Object.entries(ctx.settings ?? {})) {
|
|
2799
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z0-9_.]+$/.test(k)) throw new PgRestError(500, "PGRST100", `Invalid setting name "${k}"`, null, "Custom settings need a prefix, e.g. app.tenant_id");
|
|
2800
|
+
entries.push([k, v]);
|
|
2801
|
+
}
|
|
2802
|
+
return {
|
|
2803
|
+
sql: `SELECT ${entries.map(([k], i) => `set_config(${literal(k)}, $${i + 1}::text, true)`).join(", ")}`,
|
|
2804
|
+
params: entries.map(([, v]) => v)
|
|
2805
|
+
};
|
|
2806
|
+
};
|
|
2807
|
+
/** Parsed `response.headers` GUC: `[{"X-A": "b"}, ...]`. */
|
|
2808
|
+
const parseResponseHeaders = (raw) => {
|
|
2809
|
+
if (raw == null || raw === "") return {};
|
|
2810
|
+
const bad = () => new PgRestError(500, "PGRST111", "response.headers guc must be a JSON array composed of objects with a single key and a string value");
|
|
2811
|
+
let parsed;
|
|
2812
|
+
try {
|
|
2813
|
+
parsed = JSON.parse(String(raw));
|
|
2814
|
+
} catch {
|
|
2815
|
+
throw bad();
|
|
2816
|
+
}
|
|
2817
|
+
if (!Array.isArray(parsed)) throw bad();
|
|
2818
|
+
const out = {};
|
|
2819
|
+
for (const h of parsed) {
|
|
2820
|
+
if (!h || typeof h !== "object" || Array.isArray(h)) throw bad();
|
|
2821
|
+
const keys = Object.keys(h);
|
|
2822
|
+
if (keys.length !== 1) throw bad();
|
|
2823
|
+
const v = h[keys[0]];
|
|
2824
|
+
if (typeof v !== "string") throw bad();
|
|
2825
|
+
out[keys[0]] = v;
|
|
2826
|
+
}
|
|
2827
|
+
return out;
|
|
2828
|
+
};
|
|
2829
|
+
const parseStatusGuc = (raw) => {
|
|
2830
|
+
if (raw == null || raw === "") return void 0;
|
|
2831
|
+
const n = Number(raw);
|
|
2832
|
+
if (!Number.isInteger(n) || n < 100 || n > 599) throw new PgRestError(500, "PGRST112", "response.status guc must be a valid status code");
|
|
2833
|
+
return n;
|
|
2834
|
+
};
|
|
2835
|
+
const appliedHeader = (p, honoredTx) => {
|
|
2836
|
+
const parts = [];
|
|
2837
|
+
if (honoredTx && p.tx) parts.push(`tx=${p.tx}`);
|
|
2838
|
+
if (p.return) parts.push(`return=${p.return}`);
|
|
2839
|
+
if (p.count) parts.push(`count=${p.count}`);
|
|
2840
|
+
if (p.resolution) parts.push(`resolution=${p.resolution}`);
|
|
2841
|
+
if (p.missing) parts.push(`missing=${p.missing}`);
|
|
2842
|
+
if (p.handling) parts.push(`handling=${p.handling}`);
|
|
2843
|
+
if (p.timezone) parts.push(`timezone=${p.timezone}`);
|
|
2844
|
+
if (p.maxAffected !== void 0 && p.handling === "strict") parts.push(`max-affected=${p.maxAffected}`);
|
|
2845
|
+
return parts.length ? parts.join(", ") : void 0;
|
|
2846
|
+
};
|
|
2847
|
+
var RollbackWith = class extends Error {
|
|
2848
|
+
constructor(value) {
|
|
2849
|
+
super("rollback");
|
|
2850
|
+
this.value = value;
|
|
2851
|
+
}
|
|
2852
|
+
};
|
|
2853
|
+
const asRows = (body) => {
|
|
2854
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
2855
|
+
if (Array.isArray(body)) {
|
|
2856
|
+
if (!body.every(isObj)) throw new PgRestError(400, "PGRST102", "All object keys must match");
|
|
2857
|
+
return body;
|
|
2858
|
+
}
|
|
2859
|
+
return isObj(body) ? [body] : [];
|
|
2860
|
+
};
|
|
2861
|
+
const effectivePaging = (env, query, range) => {
|
|
2862
|
+
if (query.root.limit !== void 0 && query.root.limit < 0) throw new PgRestError(416, "PGRST103", "Requested range not satisfiable", "Limit should be greater than or equal to zero.", null);
|
|
2863
|
+
let limit = query.root.limit ?? range?.limit;
|
|
2864
|
+
const offset = query.root.offset ?? range?.offset ?? 0;
|
|
2865
|
+
const max = env.config.maxRows;
|
|
2866
|
+
if (max !== void 0) limit = limit === void 0 ? max : Math.min(limit, max);
|
|
2867
|
+
return {
|
|
2868
|
+
limit,
|
|
2869
|
+
offset
|
|
2870
|
+
};
|
|
2871
|
+
};
|
|
2872
|
+
const handleRequest = async (env, request, ctx) => {
|
|
2873
|
+
if (!ctx.role && env.config.anonymousRole === void 0) throw new PgRestError(401, "PGRST302", "Anonymous access is disabled", null, null);
|
|
2874
|
+
if (!isSupportedMethod(request.method)) return new Response(null, {
|
|
2875
|
+
status: 405,
|
|
2876
|
+
headers: { "Content-Type": JSON_CT }
|
|
2877
|
+
});
|
|
2878
|
+
const method = parseMethod(request.method);
|
|
2879
|
+
const url = new URL(request.url);
|
|
2880
|
+
const target = parseTarget(url.pathname, env.config.basePath);
|
|
2881
|
+
const schema = readSchema(env, request, method);
|
|
2882
|
+
const prefer = parsePrefer(request.headers.get("prefer"));
|
|
2883
|
+
const invalid = [...prefer.invalid];
|
|
2884
|
+
const honoredTx = env.config.allowTxOverride;
|
|
2885
|
+
if (prefer.tx && !honoredTx) invalid.push(`tx=${prefer.tx}`);
|
|
2886
|
+
if (prefer.handling === "strict" && invalid.length > 0) throw new PgRestError(400, "PGRST122", "Invalid preferences given with handling=strict", `Invalid preferences: ${invalid.join(", ")}`);
|
|
2887
|
+
if (method === "OPTIONS") {
|
|
2888
|
+
const allow = target.type === "root" ? "OPTIONS,GET,HEAD" : target.type === "rpc" ? "OPTIONS,GET,HEAD,POST" : (() => {
|
|
2889
|
+
const rel = getRelation(env.cache, schema, target.name);
|
|
2890
|
+
const m = [
|
|
2891
|
+
"OPTIONS",
|
|
2892
|
+
"GET",
|
|
2893
|
+
"HEAD"
|
|
2894
|
+
];
|
|
2895
|
+
if (rel.insertable) m.push("POST");
|
|
2896
|
+
if (rel.insertable && rel.updatable) m.push("PUT");
|
|
2897
|
+
if (rel.updatable) m.push("PATCH");
|
|
2898
|
+
if (rel.deletable) m.push("DELETE");
|
|
2899
|
+
return m.join(",");
|
|
2900
|
+
})();
|
|
2901
|
+
return new Response(null, {
|
|
2902
|
+
status: 200,
|
|
2903
|
+
headers: { Allow: allow }
|
|
2904
|
+
});
|
|
2905
|
+
}
|
|
2906
|
+
if (target.type === "root") {
|
|
2907
|
+
if (method !== "GET" && method !== "HEAD") throw new PgRestError(405, "PGRST117", `Unsupported HTTP method: ${method}`);
|
|
2908
|
+
if (!env.config.openApi) throw new PgRestError(404, "PGRST126", "Root endpoint metadata is disabled");
|
|
2909
|
+
parseAccept(request.headers.get("accept"), true);
|
|
2910
|
+
const doc = buildOpenApi(env.cache, schema, {
|
|
2911
|
+
serverUrl: env.config.serverUrl ?? url.origin,
|
|
2912
|
+
basePath: env.config.basePath
|
|
2913
|
+
});
|
|
2914
|
+
return new Response(method === "HEAD" ? null : JSON.stringify(doc), {
|
|
2915
|
+
status: 200,
|
|
2916
|
+
headers: { "Content-Type": "application/openapi+json; charset=utf-8" }
|
|
2917
|
+
});
|
|
2918
|
+
}
|
|
2919
|
+
const requested = parseAccept(request.headers.get("accept"), false, env.config.planEnabled, mediaChoices(env.cache, schema, target, url.searchParams.has("select")));
|
|
2920
|
+
const format = requested.kind === "plan" ? requested.inner : requested;
|
|
2921
|
+
const wantsPlan = requested.kind === "plan" ? requested : void 0;
|
|
2922
|
+
const range = parseRange(request.headers.get("range"), request.headers.get("range-unit"));
|
|
2923
|
+
const body = await parseBody(request, method);
|
|
2924
|
+
let plan;
|
|
2925
|
+
if (target.type === "table") plan = planTable(env, method, schema, target.name, url, prefer, format, range, body);
|
|
2926
|
+
else plan = planRpc(env, method, schema, target.name, url, prefer, format, range, body);
|
|
2927
|
+
return execute(env, ctx, request, method, url.pathname, schema, prefer, format, plan, honoredTx, wantsPlan);
|
|
2928
|
+
};
|
|
2929
|
+
const planTable = (env, method, schema, name, url, prefer, format, range, body) => {
|
|
2930
|
+
const rel = getRelation(env.cache, schema, name);
|
|
2931
|
+
const query = parseQuery([...url.searchParams]);
|
|
2932
|
+
const strip = format.kind === "json" || format.kind === "object" ? format.stripNulls : false;
|
|
2933
|
+
const singular = format.kind === "object";
|
|
2934
|
+
if (method === "GET" || method === "HEAD") {
|
|
2935
|
+
const { limit, offset } = effectivePaging(env, query, range);
|
|
2936
|
+
const read = buildRead({
|
|
2937
|
+
cache: env.cache,
|
|
2938
|
+
rel,
|
|
2939
|
+
from: `${quoteIdent(rel.schema)}.${quoteIdent(rel.name)}`,
|
|
2940
|
+
query,
|
|
2941
|
+
limit,
|
|
2942
|
+
offset
|
|
2943
|
+
});
|
|
2944
|
+
const shape$1 = singular ? "object" : "array";
|
|
2945
|
+
const paged = limit !== void 0 || offset > 0;
|
|
2946
|
+
const needCount = prefer.count === "exact" || prefer.count === "estimated";
|
|
2947
|
+
return {
|
|
2948
|
+
method,
|
|
2949
|
+
kind: "read",
|
|
2950
|
+
readOnly: true,
|
|
2951
|
+
source: false,
|
|
2952
|
+
params: [],
|
|
2953
|
+
shape: shape$1,
|
|
2954
|
+
rel,
|
|
2955
|
+
offset,
|
|
2956
|
+
limit,
|
|
2957
|
+
pagedRead: paged,
|
|
2958
|
+
countInStatement: needCount && paged,
|
|
2959
|
+
planned: prefer.count === "planned" || prefer.count === "estimated" ? read.unpaged : void 0,
|
|
2960
|
+
statement: buildStatement({
|
|
2961
|
+
rows: read.rows,
|
|
2962
|
+
count: needCount && paged ? read.count : void 0,
|
|
2963
|
+
shape: shape$1,
|
|
2964
|
+
stripNulls: strip,
|
|
2965
|
+
...formatOptions(format)
|
|
2966
|
+
})
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
const returning = prefer.return === "representation" ? "representation" : prefer.return === "headers-only" ? "pk" : "minimal";
|
|
2970
|
+
const shapeFor = (r) => r === "representation" ? singular ? "object" : "array" : r === "pk" ? "array" : "none";
|
|
2971
|
+
const rowsFor = (r) => {
|
|
2972
|
+
if (r === "representation") {
|
|
2973
|
+
const resultQuery = {
|
|
2974
|
+
...query,
|
|
2975
|
+
root: {
|
|
2976
|
+
...query.root,
|
|
2977
|
+
filters: [],
|
|
2978
|
+
presence: []
|
|
2979
|
+
}
|
|
2980
|
+
};
|
|
2981
|
+
return {
|
|
2982
|
+
rows: buildRead({
|
|
2983
|
+
cache: env.cache,
|
|
2984
|
+
rel,
|
|
2985
|
+
from: "pgrst_source",
|
|
2986
|
+
query: resultQuery
|
|
2987
|
+
}).rows,
|
|
2988
|
+
shape: shapeFor(r)
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2991
|
+
return {
|
|
2992
|
+
rows: "SELECT * FROM pgrst_source",
|
|
2993
|
+
shape: shapeFor(r)
|
|
2994
|
+
};
|
|
2995
|
+
};
|
|
2996
|
+
const common = {
|
|
2997
|
+
method,
|
|
2998
|
+
rel,
|
|
2999
|
+
readOnly: false,
|
|
3000
|
+
source: true,
|
|
3001
|
+
offset: 0,
|
|
3002
|
+
countInStatement: false,
|
|
3003
|
+
pagedRead: false
|
|
3004
|
+
};
|
|
3005
|
+
if (method === "POST") {
|
|
3006
|
+
if (!body) throw new PgRestError(400, "PGRST102", "Empty or invalid json");
|
|
3007
|
+
const rows = asRows(body.value);
|
|
3008
|
+
const insertSql = buildInsert({
|
|
3009
|
+
rel,
|
|
3010
|
+
rows,
|
|
3011
|
+
columns: query.columns,
|
|
3012
|
+
onConflict: query.onConflict,
|
|
3013
|
+
resolution: prefer.resolution,
|
|
3014
|
+
missingDefault: prefer.missing === "default",
|
|
3015
|
+
flagInserted: true,
|
|
3016
|
+
returning
|
|
3017
|
+
});
|
|
3018
|
+
const { rows: outRows$1, shape: shape$1 } = rowsFor(returning);
|
|
3019
|
+
return {
|
|
3020
|
+
...common,
|
|
3021
|
+
kind: "insert",
|
|
3022
|
+
returning,
|
|
3023
|
+
params: insertSql.includes("$1") ? [JSON.stringify(rows)] : [],
|
|
3024
|
+
shape: shape$1,
|
|
3025
|
+
statement: buildStatement({
|
|
3026
|
+
source: insertSql,
|
|
3027
|
+
rows: outRows$1,
|
|
3028
|
+
shape: shape$1,
|
|
3029
|
+
stripNulls: strip,
|
|
3030
|
+
...formatOptions(format),
|
|
3031
|
+
upsertColumns: insertSql.includes("pgrst_inserted") ? upsertColumns(rel, returning) : void 0
|
|
3032
|
+
})
|
|
3033
|
+
};
|
|
3034
|
+
}
|
|
3035
|
+
if (method === "PATCH") {
|
|
3036
|
+
if (!body) throw new PgRestError(400, "PGRST102", "Empty or invalid json");
|
|
3037
|
+
const first = Array.isArray(body.value) ? body.value[0] : body.value;
|
|
3038
|
+
const obj$1 = first !== null && typeof first === "object" && !Array.isArray(first) ? first : {};
|
|
3039
|
+
const updateSql = buildUpdate({
|
|
3040
|
+
rel,
|
|
3041
|
+
body: obj$1,
|
|
3042
|
+
filters: query.root.filters,
|
|
3043
|
+
returning
|
|
3044
|
+
});
|
|
3045
|
+
const { rows: outRows$1, shape: shape$1 } = rowsFor(returning);
|
|
3046
|
+
if (updateSql === null) {
|
|
3047
|
+
const emptyShape = returning === "representation" ? singular ? "object" : "array" : "none";
|
|
3048
|
+
return {
|
|
3049
|
+
...common,
|
|
3050
|
+
kind: "update",
|
|
3051
|
+
returning,
|
|
3052
|
+
params: [],
|
|
3053
|
+
shape: emptyShape,
|
|
3054
|
+
statement: buildStatement({
|
|
3055
|
+
source: "SELECT 1 WHERE FALSE",
|
|
3056
|
+
rows: "SELECT * FROM pgrst_source",
|
|
3057
|
+
shape: emptyShape,
|
|
3058
|
+
...formatOptions(format)
|
|
3059
|
+
})
|
|
3060
|
+
};
|
|
3061
|
+
}
|
|
3062
|
+
return {
|
|
3063
|
+
...common,
|
|
3064
|
+
kind: "update",
|
|
3065
|
+
returning,
|
|
3066
|
+
params: [JSON.stringify(obj$1)],
|
|
3067
|
+
shape: shape$1,
|
|
3068
|
+
statement: buildStatement({
|
|
3069
|
+
source: updateSql,
|
|
3070
|
+
rows: outRows$1,
|
|
3071
|
+
shape: shape$1,
|
|
3072
|
+
stripNulls: strip,
|
|
3073
|
+
...formatOptions(format)
|
|
3074
|
+
})
|
|
3075
|
+
};
|
|
3076
|
+
}
|
|
3077
|
+
if (method === "DELETE") {
|
|
3078
|
+
const deleteSql = buildDelete({
|
|
3079
|
+
rel,
|
|
3080
|
+
filters: query.root.filters,
|
|
3081
|
+
returning
|
|
3082
|
+
});
|
|
3083
|
+
const { rows: outRows$1, shape: shape$1 } = rowsFor(returning);
|
|
3084
|
+
return {
|
|
3085
|
+
...common,
|
|
3086
|
+
kind: "delete",
|
|
3087
|
+
returning,
|
|
3088
|
+
params: [],
|
|
3089
|
+
shape: shape$1,
|
|
3090
|
+
statement: buildStatement({
|
|
3091
|
+
source: deleteSql,
|
|
3092
|
+
rows: outRows$1,
|
|
3093
|
+
shape: shape$1,
|
|
3094
|
+
stripNulls: strip,
|
|
3095
|
+
...formatOptions(format)
|
|
3096
|
+
})
|
|
3097
|
+
};
|
|
3098
|
+
}
|
|
3099
|
+
if (!body) throw new PgRestError(400, "PGRST102", "Empty or invalid json");
|
|
3100
|
+
const row = body.value;
|
|
3101
|
+
if (Array.isArray(row) && !row.every((r) => r !== null && typeof r === "object" && !Array.isArray(r))) throw new PgRestError(400, "PGRST102", "All object keys must match");
|
|
3102
|
+
if (row === null || typeof row !== "object" || Array.isArray(row)) throw new PgRestError(400, "PGRST102", "Empty or invalid json", "The payload of a PUT request must be a JSON object");
|
|
3103
|
+
if (query.root.limit !== void 0 || query.root.offset !== void 0) throw new PgRestError(400, "PGRST114", "limit/offset querystring parameters are not allowed for PUT");
|
|
3104
|
+
if (rel.pk.length === 0) throw new PgRestError(405, "PGRST105", "Filters must include all and only primary key columns with 'eq' operators");
|
|
3105
|
+
const keyValues = /* @__PURE__ */ new Map();
|
|
3106
|
+
for (const f of query.root.filters) if (f.kind === "filter" && f.op === "eq" && !f.negate && !f.quantifier && f.field.json.length === 0 && typeof f.value === "string" && rel.pk.includes(f.field.name)) keyValues.set(f.field.name, f.value);
|
|
3107
|
+
else throw new PgRestError(405, "PGRST105", "Filters must include all and only primary key columns with 'eq' operators");
|
|
3108
|
+
if (!rel.pk.every((c) => keyValues.has(c))) throw new PgRestError(405, "PGRST105", "Filters must include all and only primary key columns with 'eq' operators");
|
|
3109
|
+
const obj = row;
|
|
3110
|
+
for (const c of rel.pk) if (c in obj && String(obj[c]) !== keyValues.get(c)) throw new PgRestError(400, "PGRST115", "Payload values do not match URL in primary key column(s)");
|
|
3111
|
+
const full = {
|
|
3112
|
+
...Object.fromEntries(rel.pk.map((c) => [c, keyValues.get(c)])),
|
|
3113
|
+
...obj
|
|
3114
|
+
};
|
|
3115
|
+
const putSql = buildInsert({
|
|
3116
|
+
rel,
|
|
3117
|
+
rows: [full],
|
|
3118
|
+
onConflict: rel.pk,
|
|
3119
|
+
resolution: "merge-duplicates",
|
|
3120
|
+
returning
|
|
3121
|
+
});
|
|
3122
|
+
const { rows: outRows, shape } = rowsFor(returning);
|
|
3123
|
+
return {
|
|
3124
|
+
...common,
|
|
3125
|
+
kind: "put",
|
|
3126
|
+
returning,
|
|
3127
|
+
params: [JSON.stringify([full])],
|
|
3128
|
+
shape,
|
|
3129
|
+
statement: buildStatement({
|
|
3130
|
+
source: putSql,
|
|
3131
|
+
rows: outRows,
|
|
3132
|
+
shape,
|
|
3133
|
+
stripNulls: strip,
|
|
3134
|
+
...formatOptions(format)
|
|
3135
|
+
})
|
|
3136
|
+
};
|
|
3137
|
+
};
|
|
3138
|
+
/** A query-string value in the filter syntax (`eq.5`, `not.in.(1,2)`, `like(any).{a*}`), not a plain argument. */
|
|
3139
|
+
const looksLikeFilter = (v) => /^(not\.)?(eq|neq|gt|gte|lt|lte|like|ilike|match|imatch|in|is|isdistinct|cs|cd|ov|sl|sr|nxl|nxr|adj|fts|plfts|phfts|wfts)(\([a-z]+\))?\./.test(v);
|
|
3140
|
+
const planRpc = (env, method, schema, name, url, prefer, format, range, body) => {
|
|
3141
|
+
if (method !== "GET" && method !== "HEAD" && method !== "POST") throw new PgRestError(405, "PGRST101", `Cannot use the ${method} method on RPC`);
|
|
3142
|
+
const RESERVED = new Set([
|
|
3143
|
+
"select",
|
|
3144
|
+
"order",
|
|
3145
|
+
"limit",
|
|
3146
|
+
"offset",
|
|
3147
|
+
"and",
|
|
3148
|
+
"or",
|
|
3149
|
+
"columns",
|
|
3150
|
+
"on_conflict"
|
|
3151
|
+
]);
|
|
3152
|
+
let argValues = {};
|
|
3153
|
+
let nonObjectBody = false;
|
|
3154
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
3155
|
+
const getArgKeys = /* @__PURE__ */ new Set();
|
|
3156
|
+
const bare = /* @__PURE__ */ new Set();
|
|
3157
|
+
if (method === "POST") {
|
|
3158
|
+
if (body) {
|
|
3159
|
+
const v = body.value;
|
|
3160
|
+
if (isObj(v)) argValues = v;
|
|
3161
|
+
else {
|
|
3162
|
+
nonObjectBody = true;
|
|
3163
|
+
if (Array.isArray(v) && v.length > 0) {
|
|
3164
|
+
const keysOf = (o) => isObj(o) ? Object.keys(o).sort().join("\0") : void 0;
|
|
3165
|
+
const first = keysOf(v[0]);
|
|
3166
|
+
if (first === void 0 || v.some((o) => keysOf(o) !== first)) throw new PgRestError(400, "PGRST102", "All object keys must match", null, null);
|
|
3167
|
+
argValues = v[0];
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
} else {
|
|
3172
|
+
for (const kv of url.search.slice(1).split("&")) if (kv !== "" && !kv.includes("=")) bare.add(decodeURIComponent(kv));
|
|
3173
|
+
const lists = /* @__PURE__ */ new Map();
|
|
3174
|
+
for (const [k, v] of url.searchParams) {
|
|
3175
|
+
if (RESERVED.has(k) || bare.has(k) || looksLikeFilter(v) || k.includes(".")) continue;
|
|
3176
|
+
lists.set(k, [...lists.get(k) ?? [], v]);
|
|
3177
|
+
}
|
|
3178
|
+
for (const [k, vs] of lists) {
|
|
3179
|
+
getArgKeys.add(k);
|
|
3180
|
+
argValues[k] = vs.length === 1 ? vs[0] : vs;
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
const provided = Object.keys(argValues);
|
|
3184
|
+
const fn = resolveFunction(env.cache, schema, name, new Set(provided), nonObjectBody, method === "POST");
|
|
3185
|
+
for (const a of fn.args) if (a.variadic && getArgKeys.has(a.name) && !Array.isArray(argValues[a.name])) argValues[a.name] = [argValues[a.name]];
|
|
3186
|
+
const rawBody = nonObjectBody && isSingleUnnamedParam(fn);
|
|
3187
|
+
const call = buildRpc(env.cache, fn, provided, rawBody);
|
|
3188
|
+
const fnArgNames = new Set(fn.args.map((a) => a.name));
|
|
3189
|
+
const query = parseQuery([...url.searchParams].filter(([k, v]) => !(v === "" && bare.has(k))), { allowFilterKey: (k) => method === "POST" || !fnArgNames.has(k) });
|
|
3190
|
+
const argsJson = rawBody ? JSON.stringify(body.value) : JSON.stringify(argValues);
|
|
3191
|
+
const params = call.usesBody ? [rawBody && body.kind === "json" && typeof body.value === "string" ? body.value : argsJson] : [];
|
|
3192
|
+
const strip = format.kind === "json" || format.kind === "object" ? format.stripNulls : false;
|
|
3193
|
+
const base = {
|
|
3194
|
+
method,
|
|
3195
|
+
kind: "rpc",
|
|
3196
|
+
readOnly: method !== "POST",
|
|
3197
|
+
source: false,
|
|
3198
|
+
rpc: call,
|
|
3199
|
+
rel: call.relation,
|
|
3200
|
+
params,
|
|
3201
|
+
offset: 0,
|
|
3202
|
+
countInStatement: false,
|
|
3203
|
+
pagedRead: false
|
|
3204
|
+
};
|
|
3205
|
+
if (call.shape === "rows" || call.shape === "object") {
|
|
3206
|
+
const { limit, offset } = effectivePaging(env, query, range);
|
|
3207
|
+
const read = buildRead({
|
|
3208
|
+
cache: env.cache,
|
|
3209
|
+
rel: call.relation,
|
|
3210
|
+
from: `(${call.source})`,
|
|
3211
|
+
query,
|
|
3212
|
+
limit,
|
|
3213
|
+
offset
|
|
3214
|
+
});
|
|
3215
|
+
const shape$1 = call.shape === "object" || format.kind === "object" ? "object" : "array";
|
|
3216
|
+
const paged = limit !== void 0 || offset > 0;
|
|
3217
|
+
const needCount = prefer.count === "exact" || prefer.count === "estimated";
|
|
3218
|
+
return {
|
|
3219
|
+
...base,
|
|
3220
|
+
offset,
|
|
3221
|
+
limit,
|
|
3222
|
+
pagedRead: paged,
|
|
3223
|
+
countInStatement: needCount && paged,
|
|
3224
|
+
planned: prefer.count === "planned" || prefer.count === "estimated" ? read.unpaged : void 0,
|
|
3225
|
+
shape: shape$1,
|
|
3226
|
+
statement: buildStatement({
|
|
3227
|
+
rows: read.rows,
|
|
3228
|
+
count: needCount && paged ? read.count : void 0,
|
|
3229
|
+
shape: shape$1,
|
|
3230
|
+
stripNulls: strip,
|
|
3231
|
+
...formatOptions(format)
|
|
3232
|
+
})
|
|
3233
|
+
};
|
|
3234
|
+
}
|
|
3235
|
+
const shape = call.shape === "void" && format.kind !== "geojson" ? "none" : call.shape === "scalar" ? "scalar" : "scalar-array";
|
|
3236
|
+
return {
|
|
3237
|
+
...base,
|
|
3238
|
+
shape,
|
|
3239
|
+
statement: buildStatement({
|
|
3240
|
+
rows: call.source,
|
|
3241
|
+
shape,
|
|
3242
|
+
...formatOptions(format)
|
|
3243
|
+
})
|
|
3244
|
+
};
|
|
3245
|
+
};
|
|
3246
|
+
const execute = async (env, ctx, request, method, pathname, schema, prefer, format, plan, honoredTx, wantsPlan) => {
|
|
3247
|
+
const role = ctx.role ?? env.config.anonymousRole;
|
|
3248
|
+
const claims = {
|
|
3249
|
+
role,
|
|
3250
|
+
...ctx.claims ?? {}
|
|
3251
|
+
};
|
|
3252
|
+
const setup = applyCtxSql(env, ctx, role, claims, request, method, pathname, schema, prefer);
|
|
3253
|
+
const strict = prefer.handling === "strict";
|
|
3254
|
+
const rollback = honoredTx && prefer.tx === "rollback" || wantsPlan !== void 0;
|
|
3255
|
+
const attempt = async () => {
|
|
3256
|
+
try {
|
|
3257
|
+
return await env.db.transaction(async (s) => {
|
|
3258
|
+
if (plan.readOnly) await s.query("SET TRANSACTION READ ONLY");
|
|
3259
|
+
await s.query(setup.sql, setup.params);
|
|
3260
|
+
const pre = env.config.preRequest;
|
|
3261
|
+
if (typeof pre === "string") await s.query(`SELECT ${pre.split(".").map(quoteIdent).join(".")}()`);
|
|
3262
|
+
else if (pre) await pre(s, {
|
|
3263
|
+
role,
|
|
3264
|
+
claims,
|
|
3265
|
+
method,
|
|
3266
|
+
path: pathname,
|
|
3267
|
+
request
|
|
3268
|
+
});
|
|
3269
|
+
let planned;
|
|
3270
|
+
if (plan.planned) {
|
|
3271
|
+
const cell = (await s.query(`EXPLAIN (FORMAT JSON) ${plan.planned}`))[0]?.["QUERY PLAN"];
|
|
3272
|
+
const parsed = typeof cell === "string" ? JSON.parse(cell) : cell;
|
|
3273
|
+
const n = Number(parsed?.[0]?.Plan?.["Plan Rows"]);
|
|
3274
|
+
planned = Number.isFinite(n) ? n : void 0;
|
|
3275
|
+
}
|
|
3276
|
+
if (wantsPlan) {
|
|
3277
|
+
const opts = ["FORMAT " + wantsPlan.format.toUpperCase(), ...wantsPlan.options.map((o) => o.toUpperCase())];
|
|
3278
|
+
const cells = (await s.query(`EXPLAIN (${opts.join(", ")}) ${plan.statement}`, plan.params)).map((l) => l["QUERY PLAN"]);
|
|
3279
|
+
throw new RollbackWith({
|
|
3280
|
+
pageTotal: 0,
|
|
3281
|
+
body: wantsPlan.format === "json" ? typeof cells[0] === "string" ? cells[0] : JSON.stringify(cells[0], null, 2) : cells.join("\n"),
|
|
3282
|
+
total: void 0,
|
|
3283
|
+
planned: void 0,
|
|
3284
|
+
responseHeaders: {},
|
|
3285
|
+
responseStatus: void 0
|
|
3286
|
+
});
|
|
3287
|
+
}
|
|
3288
|
+
const row = (await s.query(plan.statement, plan.params))[0] ?? {};
|
|
3289
|
+
const pageTotal = Number(row["page_total"] ?? 0);
|
|
3290
|
+
const raw = {
|
|
3291
|
+
pageTotal,
|
|
3292
|
+
body: String(row["body"] ?? ""),
|
|
3293
|
+
total: row["total_result_set"] == null ? void 0 : Number(row["total_result_set"]),
|
|
3294
|
+
planned,
|
|
3295
|
+
responseHeaders: parseResponseHeaders(row["response_headers"]),
|
|
3296
|
+
responseStatus: parseStatusGuc(row["response_status"])
|
|
3297
|
+
};
|
|
3298
|
+
if (format.kind === "object" && plan.shape === "object" && plan.rpc?.shape !== "object" && pageTotal !== 1) throw new PgRestError(406, "PGRST116", "Cannot coerce the result to a single JSON object", `The result contains ${pageTotal} rows`);
|
|
3299
|
+
if (strict && prefer.maxAffected !== void 0) {
|
|
3300
|
+
if (plan.kind === "rpc" && plan.rpc && !plan.rpc.fn.returnsSet) throw new PgRestError(400, "PGRST128", "max-affected preference is only valid for functions that return a set", null, null);
|
|
3301
|
+
if ((plan.kind === "update" || plan.kind === "delete" || plan.kind === "rpc") && pageTotal > prefer.maxAffected) throw new PgRestError(400, "PGRST124", "Query result exceeds max-affected preference constraint", `The query affects ${pageTotal} rows`);
|
|
3302
|
+
}
|
|
3303
|
+
if (rollback) throw new RollbackWith(raw);
|
|
3304
|
+
return raw;
|
|
3305
|
+
});
|
|
3306
|
+
} catch (e) {
|
|
3307
|
+
if (e instanceof RollbackWith) return e.value;
|
|
3308
|
+
if (e instanceof DbQueryError && plan.kind !== "rpc" && plan.rel) {
|
|
3309
|
+
const fix = (t) => t?.replace(/"pgrst_root"/g, `"${plan.rel.name}"`).replace(/pgrst_root\./g, `${plan.rel.name}.`);
|
|
3310
|
+
throw new DbQueryError(e.sqlState, fix(e.message) ?? e.message, fix(e.detail), fix(e.hint));
|
|
3311
|
+
}
|
|
3312
|
+
throw e;
|
|
3313
|
+
}
|
|
3314
|
+
};
|
|
3315
|
+
let result;
|
|
3316
|
+
for (let i = 0;; i++) try {
|
|
3317
|
+
result = await attempt();
|
|
3318
|
+
break;
|
|
3319
|
+
} catch (e) {
|
|
3320
|
+
if (e instanceof DbQueryError && e.sqlState === "40001" && i < 2) continue;
|
|
3321
|
+
throw e;
|
|
3322
|
+
}
|
|
3323
|
+
if (wantsPlan) {
|
|
3324
|
+
const opts = wantsPlan.options.length ? `; options=${wantsPlan.options.join("|")}` : "";
|
|
3325
|
+
return new Response(method === "HEAD" ? null : result.body, {
|
|
3326
|
+
status: 200,
|
|
3327
|
+
headers: {
|
|
3328
|
+
"Content-Type": `application/vnd.pgrst.plan+${wantsPlan.format}; for="${wantsPlan.forType}"${opts}; charset=utf-8`,
|
|
3329
|
+
Vary: "Accept, Prefer, Range"
|
|
3330
|
+
}
|
|
3331
|
+
});
|
|
3332
|
+
}
|
|
3333
|
+
return buildResponse(env, request, method, prefer, format, plan, result, honoredTx);
|
|
3334
|
+
};
|
|
3335
|
+
const buildResponse = (env, request, method, prefer, format, plan, r, honoredTx) => {
|
|
3336
|
+
const headers = { Vary: "Accept, Prefer, Range" };
|
|
3337
|
+
const applied = appliedHeader(prefer, honoredTx);
|
|
3338
|
+
if (applied) headers["Preference-Applied"] = applied;
|
|
3339
|
+
let total;
|
|
3340
|
+
if (prefer.count === "exact") total = plan.countInStatement ? r.total : r.pageTotal;
|
|
3341
|
+
else if (prefer.count === "planned") total = r.planned;
|
|
3342
|
+
else if (prefer.count === "estimated") {
|
|
3343
|
+
const max = env.config.maxRows;
|
|
3344
|
+
total = max !== void 0 && r.planned !== void 0 && r.planned > max ? r.planned : plan.countInStatement ? r.total : r.pageTotal;
|
|
3345
|
+
}
|
|
3346
|
+
const totalStr = total === void 0 ? "*" : String(total);
|
|
3347
|
+
let status = 200;
|
|
3348
|
+
let text = r.body;
|
|
3349
|
+
let contentType = JSON_CT;
|
|
3350
|
+
if (plan.kind === "read" || plan.kind === "rpc" && plan.pagedRead !== void 0 && (plan.shape === "array" || plan.shape === "object")) {
|
|
3351
|
+
const first = plan.offset;
|
|
3352
|
+
const last = plan.offset + r.pageTotal - 1;
|
|
3353
|
+
headers["Content-Range"] = r.pageTotal > 0 ? `${first}-${last}/${totalStr}` : `*/${totalStr}`;
|
|
3354
|
+
if (total !== void 0 && plan.offset > 0 && r.pageTotal === 0 && plan.offset >= total && prefer.count !== "planned") throw new PgRestError(416, "PGRST103", "Requested range not satisfiable", `An offset of ${plan.offset} was requested, but there are only ${total} rows.`, null, {
|
|
3355
|
+
"Content-Range": `*/${total}`,
|
|
3356
|
+
...applied ? { "Preference-Applied": applied } : {}
|
|
3357
|
+
});
|
|
3358
|
+
if (total !== void 0 && r.pageTotal > 0 && r.pageTotal < total) status = 206;
|
|
3359
|
+
if (total !== void 0 && r.pageTotal === total && plan.offset > 0) status = 206;
|
|
3360
|
+
} else if (plan.kind === "rpc") {
|
|
3361
|
+
status = plan.shape === "none" ? 204 : 200;
|
|
3362
|
+
headers["Content-Range"] = r.pageTotal > 0 ? `0-${r.pageTotal - 1}/${totalStr}` : `*/${totalStr}`;
|
|
3363
|
+
} else {
|
|
3364
|
+
if (plan.kind === "update") {
|
|
3365
|
+
const n = r.pageTotal;
|
|
3366
|
+
headers["Content-Range"] = n > 0 ? `0-${n - 1}/${prefer.count === "exact" ? String(n) : "*"}` : `*/${prefer.count === "exact" ? "0" : "*"}`;
|
|
3367
|
+
} else if (plan.kind !== "put") headers["Content-Range"] = `*/${prefer.count === "exact" ? String(r.pageTotal) : "*"}`;
|
|
3368
|
+
const ret = plan.returning ?? "minimal";
|
|
3369
|
+
if (plan.kind === "insert") {
|
|
3370
|
+
status = 201;
|
|
3371
|
+
if (ret === "pk") {
|
|
3372
|
+
const rows = JSON.parse(r.body || "[]");
|
|
3373
|
+
const pk = plan.rel?.pk ?? [];
|
|
3374
|
+
if (rows.length === 1 && pk.length > 0) {
|
|
3375
|
+
const q = pk.map((c) => `${encodeURIComponent(c)}=eq.${encodeURIComponent(String(rows[0][c]))}`).join("&");
|
|
3376
|
+
headers["Location"] = `/${encodeURIComponent(plan.rel.name)}?${q}`;
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
} else if (plan.kind === "put") status = ret === "representation" ? 200 : 204;
|
|
3380
|
+
else status = ret === "representation" ? 200 : 204;
|
|
3381
|
+
if (ret !== "representation") text = "";
|
|
3382
|
+
else if (plan.shape === "none") text = "";
|
|
3383
|
+
}
|
|
3384
|
+
if (plan.kind === "insert" && (plan.returning ?? "minimal") !== "representation") text = "";
|
|
3385
|
+
if (format.kind === "handler") contentType = format.choice.contentType;
|
|
3386
|
+
else if (format.kind === "csv") contentType = "text/csv; charset=utf-8";
|
|
3387
|
+
else if (format.kind === "geojson") contentType = "application/geo+json; charset=utf-8";
|
|
3388
|
+
else if (format.kind === "object") contentType = `application/vnd.pgrst.object+json${format.stripNulls ? ";nulls=stripped" : ""}; charset=utf-8`;
|
|
3389
|
+
else if (format.kind === "json" && format.stripNulls) contentType = "application/vnd.pgrst.array+json;nulls=stripped; charset=utf-8";
|
|
3390
|
+
Object.assign(headers, r.responseHeaders);
|
|
3391
|
+
if (r.responseStatus !== void 0) status = r.responseStatus;
|
|
3392
|
+
const noBody = status === 204 || status === 304 || text === "" || method === "HEAD";
|
|
3393
|
+
if (!(status === 204 || status === 304 || text === "" && method !== "HEAD" && !(format.kind === "handler" && plan.kind === "read"))) headers["Content-Type"] = contentType;
|
|
3394
|
+
if (!noBody && format.kind === "handler" && format.choice.bytea) return new Response(Buffer.from(text, "hex"), {
|
|
3395
|
+
status,
|
|
3396
|
+
headers
|
|
3397
|
+
});
|
|
3398
|
+
return new Response(noBody ? null : text, {
|
|
3399
|
+
status,
|
|
3400
|
+
headers
|
|
3401
|
+
});
|
|
3402
|
+
};
|
|
3403
|
+
|
|
3404
|
+
//#endregion
|
|
3405
|
+
//#region src/service.ts
|
|
3406
|
+
/** The schema could not be read from the database catalog. */
|
|
3407
|
+
var SchemaLoadError = class extends Error {
|
|
3408
|
+
_tag = "SchemaLoadError";
|
|
3409
|
+
constructor(cause) {
|
|
3410
|
+
super(`pg-rest: could not load the database schema: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
3411
|
+
this.cause = cause;
|
|
3412
|
+
this.name = "SchemaLoadError";
|
|
3413
|
+
}
|
|
3414
|
+
};
|
|
3415
|
+
const createSchemaStore = async (db, schemas) => {
|
|
3416
|
+
const load = () => introspect(db, schemas).catch((cause) => {
|
|
3417
|
+
throw new SchemaLoadError(cause);
|
|
3418
|
+
});
|
|
3419
|
+
let cache = await load();
|
|
3420
|
+
let queue = Promise.resolve();
|
|
3421
|
+
return {
|
|
3422
|
+
current: () => cache,
|
|
3423
|
+
reload() {
|
|
3424
|
+
const run$2 = queue.then(async () => {
|
|
3425
|
+
try {
|
|
3426
|
+
cache = await load();
|
|
3427
|
+
return cache;
|
|
3428
|
+
} catch (e) {
|
|
3429
|
+
console.warn("pg-rest: schema cache reload failed, keeping the previous cache", e.cause);
|
|
3430
|
+
throw e;
|
|
3431
|
+
}
|
|
3432
|
+
});
|
|
3433
|
+
queue = run$2.catch(() => void 0);
|
|
3434
|
+
return run$2;
|
|
3435
|
+
}
|
|
3436
|
+
};
|
|
3437
|
+
};
|
|
3438
|
+
const CONNECTION_ERROR = /^(ERR_POSTGRES_(CONNECTION|AUTH|TLS|IDLE|LIFETIME)|ECONN|ETIMEDOUT|EPIPE|ENOTFOUND)/;
|
|
3439
|
+
/** Map anything thrown while handling a request to an {@link ApiError}. */
|
|
3440
|
+
const toApiError = (e, role, anonymousRole) => {
|
|
3441
|
+
if (e instanceof ApiError) return e;
|
|
3442
|
+
if (e instanceof PgRestError) return new ApiError({
|
|
3443
|
+
status: e.status,
|
|
3444
|
+
body: e.body,
|
|
3445
|
+
headers: e.headers
|
|
3446
|
+
});
|
|
3447
|
+
if (e instanceof DbQueryError) return dbErrorToApiError(e, role, anonymousRole);
|
|
3448
|
+
const code = e?.code;
|
|
3449
|
+
if (typeof code === "string" && CONNECTION_ERROR.test(code)) return new ApiError({
|
|
3450
|
+
status: 503,
|
|
3451
|
+
headers: {},
|
|
3452
|
+
body: {
|
|
3453
|
+
code: "PGRST001",
|
|
3454
|
+
message: "Database connection failed",
|
|
3455
|
+
details: e instanceof Error ? e.message : null,
|
|
3456
|
+
hint: null
|
|
3457
|
+
}
|
|
3458
|
+
});
|
|
3459
|
+
return new ApiError({
|
|
3460
|
+
status: 500,
|
|
3461
|
+
headers: {},
|
|
3462
|
+
body: {
|
|
3463
|
+
code: "PGRSTX00",
|
|
3464
|
+
message: "Internal error",
|
|
3465
|
+
details: null,
|
|
3466
|
+
hint: null
|
|
3467
|
+
}
|
|
3468
|
+
});
|
|
3469
|
+
};
|
|
3470
|
+
const errorResponse = (err) => new Response(JSON.stringify(err.body), {
|
|
3471
|
+
status: err.status,
|
|
3472
|
+
headers: {
|
|
3473
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
3474
|
+
...err.status === 401 ? { "WWW-Authenticate": "Bearer" } : {},
|
|
3475
|
+
...err.headers
|
|
3476
|
+
}
|
|
3477
|
+
});
|
|
3478
|
+
/** Handle a request. Never rejects: errors become PostgREST-style JSON responses. */
|
|
3479
|
+
const handleSafely = async (env, request, ctx) => {
|
|
3480
|
+
try {
|
|
3481
|
+
return await handleRequest(env, request, ctx);
|
|
3482
|
+
} catch (e) {
|
|
3483
|
+
const api = toApiError(e, ctx.role ?? env.config.anonymousRole, env.config.anonymousRole);
|
|
3484
|
+
if (api.status >= 500 && !(e instanceof PgRestError) && !(e instanceof DbQueryError)) console.error("pg-rest: unexpected error while handling a request", e);
|
|
3485
|
+
return errorResponse(api);
|
|
3486
|
+
}
|
|
3487
|
+
};
|
|
3488
|
+
|
|
3489
|
+
//#endregion
|
|
3490
|
+
//#region src/index.ts
|
|
3491
|
+
const UNEXPOSABLE = new Set(["pg_catalog", "information_schema"]);
|
|
3492
|
+
/** Create a PostgREST-compatible handler over a Postgres database. */
|
|
3493
|
+
const createPgRest = async (options) => {
|
|
3494
|
+
const schemas = options.schemas?.length ? [...options.schemas] : ["public"];
|
|
3495
|
+
for (const s of schemas) if (UNEXPOSABLE.has(s)) throw new Error(`pg-rest: the schema "${s}" cannot be exposed`);
|
|
3496
|
+
const user = options.db;
|
|
3497
|
+
const adapter = {
|
|
3498
|
+
async query(text, params) {
|
|
3499
|
+
try {
|
|
3500
|
+
return await user.query(text, params);
|
|
3501
|
+
} catch (e) {
|
|
3502
|
+
throw normalizeDbError(e);
|
|
3503
|
+
}
|
|
3504
|
+
},
|
|
3505
|
+
async transaction(fn) {
|
|
3506
|
+
try {
|
|
3507
|
+
return await user.transaction(fn);
|
|
3508
|
+
} catch (e) {
|
|
3509
|
+
throw normalizeDbError(e);
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
};
|
|
3513
|
+
const config = {
|
|
3514
|
+
schemas,
|
|
3515
|
+
anonymousRole: options.anonymousRole === false ? void 0 : options.anonymousRole ?? "anon",
|
|
3516
|
+
maxRows: options.maxRows,
|
|
3517
|
+
basePath: options.basePath ?? "",
|
|
3518
|
+
extraSearchPath: options.extraSearchPath ?? ["public"],
|
|
3519
|
+
allowTxOverride: options.allowTxOverride ?? false,
|
|
3520
|
+
planEnabled: options.planEnabled ?? false,
|
|
3521
|
+
openApi: options.openApi ?? true,
|
|
3522
|
+
requestSettings: options.requestSettings ?? true,
|
|
3523
|
+
preRequest: options.preRequest,
|
|
3524
|
+
serverUrl: options.serverUrl
|
|
3525
|
+
};
|
|
3526
|
+
const store = await createSchemaStore(adapter, schemas);
|
|
3527
|
+
const handle = async (request, ctx) => {
|
|
3528
|
+
let context = ctx;
|
|
3529
|
+
if (!context && options.resolveContext) try {
|
|
3530
|
+
context = await options.resolveContext(request);
|
|
3531
|
+
} catch (e) {
|
|
3532
|
+
return errorResponse(e instanceof ApiError ? e : toApiError(e, config.anonymousRole, config.anonymousRole));
|
|
3533
|
+
}
|
|
3534
|
+
return handleSafely({
|
|
3535
|
+
db: adapter,
|
|
3536
|
+
config,
|
|
3537
|
+
cache: store.current()
|
|
3538
|
+
}, request, context ?? {});
|
|
3539
|
+
};
|
|
3540
|
+
return {
|
|
3541
|
+
handle,
|
|
3542
|
+
reloadSchema: async () => void await store.reload(),
|
|
3543
|
+
schemaCache: async () => store.current(),
|
|
3544
|
+
close: async () => {}
|
|
3545
|
+
};
|
|
3546
|
+
};
|
|
3547
|
+
|
|
3548
|
+
//#endregion
|
|
3549
|
+
export { ApiError, DbQueryError, PgRestError, SchemaLoadError, createPgRest };
|