pgkiln 0.46.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/108_sql_history.sql +25 -0
- package/db/migrations/109_builder_pins.sql +19 -0
- package/db/migrations/110_sql_charts.sql +23 -0
- package/db/migrations/111_sql_diagram.sql +24 -0
- package/db/migrations/113_rest_objects.sql +49 -0
- package/docs/apex-feature-parity.md +28 -2
- package/docs/guide/03-builder.md +178 -21
- package/docs/guide/08-security.md +19 -1
- package/docs/guide/10-tutorial.md +1 -1
- package/docs/guide/12-development.md +46 -2
- package/docs/guide/13-rest-api.md +74 -9
- package/docs/guide/16-files.md +2 -2
- package/package.json +1 -1
- package/public/builder.css +22 -0
- package/public/data-modeler.css +56 -0
- package/public/data-modeler.js +367 -0
- package/public/palette.js +133 -0
- package/public/worksheet.css +293 -0
- package/public/worksheet.js +669 -0
- package/src/app.ts +4 -0
- package/src/builder/ai-builder.ts +3 -3
- package/src/builder/autorest.ts +362 -0
- package/src/builder/data-modeler.ts +382 -0
- package/src/builder/db-users.ts +472 -0
- package/src/builder/launchpad.ts +140 -0
- package/src/builder/object-actions.ts +167 -0
- package/src/builder/object-browser.ts +47 -26
- package/src/builder/querybuilder.ts +1 -1
- package/src/builder/sampledata.ts +2 -2
- package/src/builder/scripts.ts +3 -3
- package/src/builder/sql-charts.ts +203 -0
- package/src/builder/sql-tools.ts +49 -0
- package/src/builder/sql.ts +28 -81
- package/src/builder/table-editor.ts +219 -0
- package/src/builder/ui.ts +21 -15
- package/src/builder/unload.ts +8 -4
- package/src/builder/worksheet.ts +592 -0
- package/src/builder/workspaces.ts +5 -3
- package/src/cli/replace.ts +3 -1
- package/src/datamodel.ts +324 -0
- package/src/dbroles.ts +215 -0
- package/src/runtime/autorest.ts +336 -0
- package/src/runtime/rest.ts +4 -4
- package/src/session.ts +2 -0
- package/src/sqlformat.ts +208 -0
- package/src/sqlplan.ts +133 -0
- package/src/sqlscript.ts +6 -2
- package/src/sqlworksheet.ts +278 -0
- package/src/tabledef.ts +226 -0
- package/src/unload.ts +14 -3
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
import { owner, runtime, type Client } from '../db.ts';
|
|
4
|
+
import { english } from '../i18n.ts';
|
|
5
|
+
import { loadApp, type App } from '../metadata.ts';
|
|
6
|
+
import { clientIp } from '../session.ts';
|
|
7
|
+
import { availabilityRefusal } from './availability.ts';
|
|
8
|
+
import { publicError } from './context.ts';
|
|
9
|
+
import { caller, RestError, restServicesOn } from './rest.ts';
|
|
10
|
+
|
|
11
|
+
// (sprint 100, Database Actions DA-11 part 1) AutoREST: a table or view that SQL Workshop › RESTful Services
|
|
12
|
+
// enabled (meta.rest_object, migration 113), served through an application:
|
|
13
|
+
//
|
|
14
|
+
// GET /a/<alias>/autorest/<object>/ rows, paged: ?limit= (1–500) &offset= ; filters: ?<column>=<value>
|
|
15
|
+
// (equality), ?q= (text columns contain it), ?order=col,col.desc
|
|
16
|
+
// POST /a/<alias>/autorest/<object>/ a JSON object: the new row (201)
|
|
17
|
+
// GET /a/<alias>/autorest/<object>/<key> one row by primary key (composite keys: 1,2), 404 when there is none
|
|
18
|
+
// PUT /a/<alias>/autorest/<object>/<key> the JSON object's columns changed; the row (200) or 404
|
|
19
|
+
// DELETE /a/<alias>/autorest/<object>/<key> 204 or 404
|
|
20
|
+
// GET /a/<alias>/autorest/openapi.json OpenAPI 3 of the application's objects (public ones without a token)
|
|
21
|
+
//
|
|
22
|
+
// Authentication is the application's (as for its REST modules, rest.ts caller()): a bearer token of an account
|
|
23
|
+
// or an OAuth client of the application; "public" objects need none (meta.app_user() = nobody). The statement
|
|
24
|
+
// runs as the application's database role with meta.app_user() and meta.has_role() set, so grants (also column
|
|
25
|
+
// grants) and row level security apply. The role may not be the object's owner (or a member of it), a superuser
|
|
26
|
+
// or a role that bypasses row level security: checked at every request, after SET ROLE. Names come from the
|
|
27
|
+
// catalog and are quoted; every value is a query parameter.
|
|
28
|
+
|
|
29
|
+
export interface RestObject {
|
|
30
|
+
id: number;
|
|
31
|
+
app_id: number;
|
|
32
|
+
schema_name: string;
|
|
33
|
+
object_name: string;
|
|
34
|
+
alias: string;
|
|
35
|
+
methods: string[];
|
|
36
|
+
auth: 'token' | 'public';
|
|
37
|
+
roles: string[];
|
|
38
|
+
page_size: number;
|
|
39
|
+
enabled: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Column {
|
|
43
|
+
name: string;
|
|
44
|
+
type: string;
|
|
45
|
+
/** pg_type.typcategory: S string, N numeric, B boolean, D date/time, … */
|
|
46
|
+
cat: string;
|
|
47
|
+
readable: boolean;
|
|
48
|
+
/** position in the primary key (1…), or null */
|
|
49
|
+
pk: number | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const AUTOREST_METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const;
|
|
53
|
+
export const MAX_PAGE = 500;
|
|
54
|
+
/** Query parameters that are not column filters. */
|
|
55
|
+
const RESERVED = new Set(['limit', 'offset', 'q', 'order']);
|
|
56
|
+
export const RELKINDS: Record<string, string> = { r: 'table', p: 'table', v: 'view', m: 'materialized view', f: 'foreign table' };
|
|
57
|
+
|
|
58
|
+
const ident = pg.escapeIdentifier;
|
|
59
|
+
export const qualified = (o: Pick<RestObject, 'schema_name' | 'object_name'>) => `${ident(o.schema_name)}.${ident(o.object_name)}`;
|
|
60
|
+
|
|
61
|
+
/** The columns of a relation as `role` may use them (the catalog only; no privilege needed to read it). */
|
|
62
|
+
export async function describeColumns(db: { query: (sql: string, params: unknown[]) => Promise<pg.QueryResult> }, schema: string, name: string, role: string): Promise<Column[]> {
|
|
63
|
+
return (await db.query(
|
|
64
|
+
`select a.attname as name, format_type(a.atttypid, a.atttypmod) as type, t.typcategory as cat,
|
|
65
|
+
has_column_privilege($3, c.oid, a.attnum, 'SELECT') as readable,
|
|
66
|
+
(select array_position(i.indkey::int2[], a.attnum) from pg_index i where i.indrelid = c.oid and i.indisprimary) as pk
|
|
67
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
68
|
+
join pg_attribute a on a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped
|
|
69
|
+
join pg_type t on t.oid = a.atttypid
|
|
70
|
+
where n.nspname = $1 and c.relname = $2
|
|
71
|
+
order by a.attnum`,
|
|
72
|
+
[schema, name, role],
|
|
73
|
+
)).rows as Column[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The primary key's columns in key order. */
|
|
77
|
+
export const keyColumns = (cols: Column[]) => cols.filter((c) => c.pk !== null).sort((a, b) => a.pk! - b.pk!);
|
|
78
|
+
|
|
79
|
+
/** The object a request names, if the application published it and it is on. */
|
|
80
|
+
async function objectOf(a: App, alias: string) {
|
|
81
|
+
const o = await runtime.one<RestObject>('select * from meta.rest_object where app_id = $1 and alias = $2', [a.id, alias]);
|
|
82
|
+
return o?.enabled ? o : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Split a key segment "1" or "10,20" into its values (each part URL-decoded on its own). */
|
|
86
|
+
export function splitKey(raw: string, n: number): string[] | null {
|
|
87
|
+
if (!raw || raw.includes('/')) return null;
|
|
88
|
+
const parts = raw.split(',');
|
|
89
|
+
if (parts.length !== n) return null;
|
|
90
|
+
try {
|
|
91
|
+
return parts.map((p) => decodeURIComponent(p));
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The ORDER BY of ?order=a,b.desc: only readable columns; null when it names something else. */
|
|
98
|
+
export function orderBy(spec: string | undefined, cols: Column[]): string | null {
|
|
99
|
+
const readable = new Map(cols.filter((c) => c.readable).map((c) => [c.name, c]));
|
|
100
|
+
if (spec === undefined || spec === '') {
|
|
101
|
+
const key = keyColumns(cols).filter((c) => c.readable);
|
|
102
|
+
return key.length ? key.map((c) => `t.${ident(c.name)}`).join(', ') : '1';
|
|
103
|
+
}
|
|
104
|
+
const out: string[] = [];
|
|
105
|
+
for (const part of spec.split(',')) {
|
|
106
|
+
const m = /^(.+?)(?:\.(asc|desc))?$/.exec(part);
|
|
107
|
+
if (!m || !readable.has(m[1])) return null;
|
|
108
|
+
out.push(`t.${ident(m[1])}${m[2] === 'desc' ? ' desc' : ''}`);
|
|
109
|
+
}
|
|
110
|
+
return out.join(', ');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const likeEscape = (s: string) => s.replace(/[\\%_]/g, (x) => `\\${x}`);
|
|
114
|
+
const one = (v: unknown) => (Array.isArray(v) ? v[0] : v);
|
|
115
|
+
|
|
116
|
+
/** Checks run after SET ROLE: the role isn't the owner and doesn't bypass row level security. */
|
|
117
|
+
async function checkRole(c: Client, o: RestObject) {
|
|
118
|
+
const r = (await c.query(
|
|
119
|
+
`select c.relkind, pg_has_role(current_user, c.relowner, 'MEMBER') as owns, r.rolsuper or r.rolbypassrls as bypass
|
|
120
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace, pg_roles r
|
|
121
|
+
where n.nspname = $1 and c.relname = $2 and r.rolname = current_user`,
|
|
122
|
+
[o.schema_name, o.object_name],
|
|
123
|
+
)).rows[0];
|
|
124
|
+
if (!r || !RELKINDS[r.relkind]) throw new RestError(404, 'Not found');
|
|
125
|
+
if (r.owns || r.bypass) throw new RestError(403, 'The application\'s database role owns this object or bypasses row level security: AutoREST refuses it.');
|
|
126
|
+
return r.relkind as string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A JSON body as an object of known columns; RestError otherwise. */
|
|
130
|
+
function bodyColumns(body: unknown, cols: Column[]) {
|
|
131
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || Buffer.isBuffer(body)) throw new RestError(400, 'The body is a JSON object of column values.');
|
|
132
|
+
const names = new Set(cols.map((c) => c.name));
|
|
133
|
+
const keys = Object.keys(body as object);
|
|
134
|
+
const unknown = keys.filter((k) => !names.has(k));
|
|
135
|
+
if (unknown.length) throw new RestError(400, `Unknown column(s): ${unknown.slice(0, 5).join(', ')}.`);
|
|
136
|
+
return keys;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function handle(req: FastifyRequest, reply: FastifyReply) {
|
|
140
|
+
const p = req.params as { alias: string; object: string; '*'?: string };
|
|
141
|
+
const keyRaw = (p['*'] ?? '').replace(/\/+$/, '');
|
|
142
|
+
const a = await loadApp(p.alias);
|
|
143
|
+
const o = a ? await objectOf(a, p.object) : undefined;
|
|
144
|
+
if (!a || !o || !(await restServicesOn(a.id))) return reply.code(404).send({ error: 'Not found' });
|
|
145
|
+
const method = req.method;
|
|
146
|
+
const allowed = o.methods.filter((m) => (keyRaw ? m !== 'POST' : m === 'GET' || m === 'POST'));
|
|
147
|
+
if (!o.methods.includes(method) || !allowed.includes(method)) return reply.code(405).header('allow', allowed.join(', ')).send({ error: `${method} is not allowed here.` });
|
|
148
|
+
try {
|
|
149
|
+
const who = await caller(req, a, o.auth === 'public');
|
|
150
|
+
const refusal = availabilityRefusal(a, { username: who.user === 'nobody' ? null : who.user, developer: false });
|
|
151
|
+
if (refusal) throw new RestError(refusal.status, refusal.message ?? english(refusal.key));
|
|
152
|
+
if (!a.db_role) throw new RestError(403, 'The application has no database role: AutoREST needs one.');
|
|
153
|
+
const result = await runtime.tx(async (c) => {
|
|
154
|
+
await c.query(
|
|
155
|
+
`select set_config('pgkiln.app_id', $1, true), set_config('pgkiln.app_user', $2, true), set_config('pgkiln.session_id', '', true),
|
|
156
|
+
set_config('request.jwt.claims', $3, true), set_config('statement_timeout', '30s', true)`,
|
|
157
|
+
[String(a.id), who.user, who.claims ? JSON.stringify(who.claims) : ''],
|
|
158
|
+
);
|
|
159
|
+
if (o.roles.length) {
|
|
160
|
+
const ok = (await c.query('select bool_or(meta.has_role(r)) as ok from unnest($1::text[]) r', [o.roles])).rows[0].ok;
|
|
161
|
+
if (!ok) throw new RestError(403, 'You do not have a role this endpoint needs.');
|
|
162
|
+
}
|
|
163
|
+
await c.query(`set local role ${ident(a.db_role!)}`);
|
|
164
|
+
await checkRole(c, o);
|
|
165
|
+
const cols = await describeColumns(c, o.schema_name, o.object_name, a.db_role!);
|
|
166
|
+
const readable = cols.filter((x) => x.readable);
|
|
167
|
+
const list = readable.map((x) => `t.${ident(x.name)}`).join(', ');
|
|
168
|
+
const returning = list ? ` returning ${list}` : '';
|
|
169
|
+
const rel = qualified(o);
|
|
170
|
+
const key = keyColumns(cols);
|
|
171
|
+
const params: unknown[] = [];
|
|
172
|
+
const param = (v: unknown) => `$${params.push(v)}`;
|
|
173
|
+
let where = '';
|
|
174
|
+
if (keyRaw) {
|
|
175
|
+
const values = key.length ? splitKey(keyRaw, key.length) : null;
|
|
176
|
+
if (!values) throw new RestError(404, 'Not found');
|
|
177
|
+
where = key.map((k, i) => `t.${ident(k.name)} = ${param(values[i])}`).join(' and ');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (method === 'GET' && !keyRaw) {
|
|
181
|
+
if (!readable.length) throw new RestError(403, 'Not allowed.');
|
|
182
|
+
const q = req.query as Record<string, string | string[]>;
|
|
183
|
+
const conds: string[] = [];
|
|
184
|
+
for (const [k, raw] of Object.entries(q)) {
|
|
185
|
+
if (RESERVED.has(k)) continue;
|
|
186
|
+
const col = readable.find((x) => x.name === k);
|
|
187
|
+
if (!col) throw new RestError(400, `Unknown parameter ${k.slice(0, 63)}: filter on a column, or use limit, offset, q, order.`);
|
|
188
|
+
conds.push(`t.${ident(col.name)} = ${param(String(one(raw)))}`);
|
|
189
|
+
}
|
|
190
|
+
const search = one(q.q);
|
|
191
|
+
if (search) {
|
|
192
|
+
const text = readable.filter((x) => x.cat === 'S');
|
|
193
|
+
if (!text.length) throw new RestError(400, 'q= searches text columns, and this object has none you may read.');
|
|
194
|
+
const v = param(`%${likeEscape(String(search))}%`);
|
|
195
|
+
conds.push(`(${text.map((x) => `t.${ident(x.name)} ilike ${v}`).join(' or ')})`);
|
|
196
|
+
}
|
|
197
|
+
const order = orderBy(one(q.order) as string | undefined, cols);
|
|
198
|
+
if (order === null) throw new RestError(400, 'order= is a list of readable columns, each optionally followed by .asc or .desc.');
|
|
199
|
+
const limitRaw = one(q.limit);
|
|
200
|
+
const offsetRaw = one(q.offset);
|
|
201
|
+
if ((limitRaw !== undefined && !/^\d{1,9}$/.test(String(limitRaw))) || (offsetRaw !== undefined && !/^\d{1,12}$/.test(String(offsetRaw))))
|
|
202
|
+
throw new RestError(400, 'limit and offset are whole numbers.');
|
|
203
|
+
const limit = Math.max(1, Math.min(MAX_PAGE, limitRaw !== undefined ? Number(limitRaw) : o.page_size));
|
|
204
|
+
const offset = offsetRaw !== undefined ? Number(offsetRaw) : 0;
|
|
205
|
+
const rows = (await c.query(
|
|
206
|
+
`select ${list} from ${rel} t${conds.length ? ` where ${conds.join(' and ')}` : ''} order by ${order} limit ${limit + 1} offset ${offset}`,
|
|
207
|
+
params,
|
|
208
|
+
)).rows;
|
|
209
|
+
return { status: 200, body: { items: rows.slice(0, limit), offset, limit, has_more: rows.length > limit } };
|
|
210
|
+
}
|
|
211
|
+
if (method === 'GET') {
|
|
212
|
+
if (!readable.length) throw new RestError(403, 'Not allowed.');
|
|
213
|
+
const row = (await c.query(`select ${list} from ${rel} t where ${where} limit 1`, params)).rows[0];
|
|
214
|
+
if (!row) throw new RestError(404, 'Not found');
|
|
215
|
+
return { status: 200, body: row };
|
|
216
|
+
}
|
|
217
|
+
if (method === 'POST') {
|
|
218
|
+
const names = bodyColumns(req.body, cols);
|
|
219
|
+
const sql = names.length
|
|
220
|
+
? `insert into ${rel} as t (${names.map(ident).join(', ')}) select ${names.map((n) => `b.${ident(n)}`).join(', ')} from json_populate_record(null::${rel}, ${param(JSON.stringify(req.body))}::json) b${returning}`
|
|
221
|
+
: `insert into ${rel} as t default values${returning}`;
|
|
222
|
+
const row = (await c.query(sql, params)).rows[0];
|
|
223
|
+
const keyOk = key.length && key.every((k) => k.readable) && row;
|
|
224
|
+
return { status: 201, body: row ?? {}, location: keyOk ? key.map((k) => encodeURIComponent(String(row[k.name] instanceof Date ? row[k.name].toISOString() : row[k.name]))).join(',') : undefined };
|
|
225
|
+
}
|
|
226
|
+
if (method === 'PUT') {
|
|
227
|
+
const names = bodyColumns(req.body, cols);
|
|
228
|
+
if (!names.length) throw new RestError(400, 'The body names no columns to change.');
|
|
229
|
+
const b = param(JSON.stringify(req.body));
|
|
230
|
+
const res = await c.query(
|
|
231
|
+
`update ${rel} as t set ${names.map((n) => `${ident(n)} = b.${ident(n)}`).join(', ')} from json_populate_record(null::${rel}, ${b}::json) b where ${where}${returning}`,
|
|
232
|
+
params,
|
|
233
|
+
);
|
|
234
|
+
if (!res.rowCount) throw new RestError(404, 'Not found');
|
|
235
|
+
return { status: 200, body: res.rows[0] ?? {} };
|
|
236
|
+
}
|
|
237
|
+
// DELETE
|
|
238
|
+
const res = await c.query(`delete from ${rel} t where ${where}`, params);
|
|
239
|
+
if (!res.rowCount) throw new RestError(404, 'Not found');
|
|
240
|
+
return { status: 204, body: undefined };
|
|
241
|
+
});
|
|
242
|
+
reply.header('cache-control', 'no-store');
|
|
243
|
+
if ('location' in result && result.location) reply.header('location', `/a/${encodeURIComponent(a.alias)}/autorest/${o.alias}/${result.location}`);
|
|
244
|
+
return result.body === undefined ? reply.code(204).send() : reply.code(result.status).send(result.body);
|
|
245
|
+
} catch (e) {
|
|
246
|
+
if (e instanceof RestError) return reply.code(e.status).send({ error: e.message });
|
|
247
|
+
const err = e as pg.DatabaseError;
|
|
248
|
+
const message = await publicError({ app: a, page: { page_no: 0 } as never, user: 'api', ip: clientIp(req) }, e, `AutoREST ${method} ${o.alias}`);
|
|
249
|
+
const status = err.code === '42501' ? 403 : err.code && /^(P0001|22|23|42804|428C9)/.test(err.code) ? 400 : 500;
|
|
250
|
+
return reply.code(status).send({ error: status === 403 ? 'Not allowed.' : message });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** JSON Schema of a column type (for the OpenAPI description). */
|
|
255
|
+
function jsonType(c: Column): Record<string, unknown> {
|
|
256
|
+
if (/^(smallint|integer|bigint)$/.test(c.type)) return { type: 'integer' };
|
|
257
|
+
if (c.cat === 'N') return { type: 'number' };
|
|
258
|
+
if (c.cat === 'B') return { type: 'boolean' };
|
|
259
|
+
if (c.type === 'date') return { type: 'string', format: 'date' };
|
|
260
|
+
if (c.cat === 'D') return { type: 'string', format: 'date-time' };
|
|
261
|
+
if (c.cat === 'S') return { type: 'string' };
|
|
262
|
+
if (c.cat === 'A') return { type: 'array', items: {} };
|
|
263
|
+
return {};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The OpenAPI 3 description of an application's AutoREST objects. */
|
|
267
|
+
export function autoRestOpenApi(a: Pick<App, 'alias' | 'name'>, objects: { o: RestObject; cols: Column[] }[]) {
|
|
268
|
+
const paths: Record<string, Record<string, unknown>> = {};
|
|
269
|
+
const schemas: Record<string, unknown> = {};
|
|
270
|
+
const errors = (o: RestObject) => (o.auth === 'public' ? {} : { 401: { description: 'No valid token' }, 403: { description: 'Not allowed' } });
|
|
271
|
+
for (const { o, cols } of objects) {
|
|
272
|
+
const readable = cols.filter((c) => c.readable);
|
|
273
|
+
const name = o.alias.replace(/[^A-Za-z0-9_.-]/g, '_');
|
|
274
|
+
schemas[name] = { type: 'object', properties: Object.fromEntries(readable.map((c) => [c.name, { ...jsonType(c), description: c.type }])) };
|
|
275
|
+
const ref = { $ref: `#/components/schemas/${name}` };
|
|
276
|
+
const sec = o.auth === 'public' ? { security: [] } : {};
|
|
277
|
+
const body = { required: true, content: { 'application/json': { schema: ref } } };
|
|
278
|
+
const coll: Record<string, unknown> = {};
|
|
279
|
+
if (o.methods.includes('GET'))
|
|
280
|
+
coll.get = {
|
|
281
|
+
summary: `Rows of ${o.schema_name}.${o.object_name}`, ...sec,
|
|
282
|
+
parameters: [
|
|
283
|
+
{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: MAX_PAGE, default: o.page_size } },
|
|
284
|
+
{ name: 'offset', in: 'query', schema: { type: 'integer', minimum: 0 } },
|
|
285
|
+
{ name: 'q', in: 'query', description: 'Rows whose text columns contain this', schema: { type: 'string' } },
|
|
286
|
+
{ name: 'order', in: 'query', description: 'Columns, each optionally .asc or .desc, comma separated', schema: { type: 'string' } },
|
|
287
|
+
...readable.filter((c) => !RESERVED.has(c.name)).map((c) => ({ name: c.name, in: 'query', description: `${c.name} equals`, schema: { type: 'string' } })),
|
|
288
|
+
],
|
|
289
|
+
responses: { 200: { description: '{items, offset, limit, has_more}', content: { 'application/json': { schema: { type: 'object', properties: { items: { type: 'array', items: ref }, offset: { type: 'integer' }, limit: { type: 'integer' }, has_more: { type: 'boolean' } } } } } }, 400: { description: 'A filter is not valid' }, ...errors(o) },
|
|
290
|
+
};
|
|
291
|
+
if (o.methods.includes('POST'))
|
|
292
|
+
coll.post = { summary: `New row in ${o.schema_name}.${o.object_name}`, ...sec, requestBody: body, responses: { 201: { description: 'The new row', content: { 'application/json': { schema: ref } } }, 400: { description: 'Invalid values' }, ...errors(o) } };
|
|
293
|
+
if (Object.keys(coll).length) paths[`/${o.alias}/`] = coll;
|
|
294
|
+
const key = keyColumns(cols);
|
|
295
|
+
if (!key.length) continue;
|
|
296
|
+
const item: Record<string, unknown> = { parameters: [{ name: 'key', in: 'path', required: true, description: `${key.map((k) => k.name).join(', ')}${key.length > 1 ? ' (comma separated)' : ''}`, schema: { type: 'string' } }] };
|
|
297
|
+
const notFound = { 404: { description: 'Not found' } };
|
|
298
|
+
if (o.methods.includes('GET')) item.get = { summary: 'One row by its key', ...sec, responses: { 200: { description: 'The row', content: { 'application/json': { schema: ref } } }, ...notFound, ...errors(o) } };
|
|
299
|
+
if (o.methods.includes('PUT')) item.put = { summary: 'Change the columns the body names', ...sec, requestBody: body, responses: { 200: { description: 'The changed row', content: { 'application/json': { schema: ref } } }, ...notFound, ...errors(o) } };
|
|
300
|
+
if (o.methods.includes('DELETE')) item.delete = { summary: 'Delete the row', ...sec, responses: { 204: { description: 'Deleted' }, ...notFound, ...errors(o) } };
|
|
301
|
+
if (Object.keys(item).length > 1) paths[`/${o.alias}/{key}`] = item;
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
openapi: '3.0.3',
|
|
305
|
+
info: { title: `${a.name} AutoREST`, description: 'Tables and views published with SQL Workshop › RESTful Services', version: '1' },
|
|
306
|
+
servers: [{ url: `/a/${a.alias}/autorest` }],
|
|
307
|
+
components: { securitySchemes: { bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } }, schemas },
|
|
308
|
+
security: [{ bearer: [] }],
|
|
309
|
+
paths,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function autoRestRoutes(app: FastifyInstance) {
|
|
314
|
+
// without a token: the public objects only; with a valid token of the application: all of them
|
|
315
|
+
app.get<{ Params: { alias: string } }>('/a/:alias/autorest/openapi.json', async (req, reply) => {
|
|
316
|
+
const a = await loadApp(req.params.alias);
|
|
317
|
+
if (!a || !(await restServicesOn(a.id))) return reply.code(404).send({ error: english('error.not_found') });
|
|
318
|
+
let all = false;
|
|
319
|
+
if (req.headers.authorization) {
|
|
320
|
+
try {
|
|
321
|
+
all = (await caller(req, a, false)).user !== 'nobody';
|
|
322
|
+
} catch (e) {
|
|
323
|
+
if (e instanceof RestError) return reply.code(e.status).send({ error: e.message });
|
|
324
|
+
throw e;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const rows = (await runtime.query<RestObject>('select * from meta.rest_object where app_id = $1 and enabled order by alias', [a.id])).rows.filter((o) => all || o.auth === 'public');
|
|
328
|
+
const objects = a.db_role
|
|
329
|
+
? await Promise.all(rows.map(async (o) => ({ o, cols: await describeColumns(owner, o.schema_name, o.object_name, a.db_role!) })))
|
|
330
|
+
: [];
|
|
331
|
+
return reply.header('cache-control', 'no-store').send(autoRestOpenApi(a, objects.filter((x) => x.cols.length)));
|
|
332
|
+
});
|
|
333
|
+
const methods = [...AUTOREST_METHODS] as never;
|
|
334
|
+
app.route({ method: methods, url: '/a/:alias/autorest/:object', handler: handle });
|
|
335
|
+
app.route({ method: methods, url: '/a/:alias/autorest/:object/*', handler: handle });
|
|
336
|
+
}
|
package/src/runtime/rest.ts
CHANGED
|
@@ -94,19 +94,19 @@ export function matchHandler(handlers: Handler[], method: string, path: string)
|
|
|
94
94
|
return methodMismatch ? 'method' : null;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
class RestError extends Error {
|
|
97
|
+
export class RestError extends Error {
|
|
98
98
|
constructor(readonly status: number, message: string) {
|
|
99
99
|
super(message);
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
interface Caller {
|
|
103
|
+
export interface Caller {
|
|
104
104
|
user: string;
|
|
105
105
|
claims: Record<string, unknown> | null;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/** The caller of a request: the bearer token checked like meta.api_check does. */
|
|
109
|
-
async function caller(req: FastifyRequest, a: App, publicHandler: boolean): Promise<Caller> {
|
|
109
|
+
export async function caller(req: FastifyRequest, a: App, publicHandler: boolean): Promise<Caller> {
|
|
110
110
|
const auth = String(req.headers.authorization ?? '');
|
|
111
111
|
if (!/^bearer /i.test(auth)) {
|
|
112
112
|
if (publicHandler) return { user: 'nobody', claims: null };
|
|
@@ -136,7 +136,7 @@ async function caller(req: FastifyRequest, a: App, publicHandler: boolean): Prom
|
|
|
136
136
|
const bindValue = (v: unknown) => (v === null || v === undefined ? null : typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
137
137
|
|
|
138
138
|
/** (093) The workspace preference "RESTful services": off, the workspace's REST modules don't exist. */
|
|
139
|
-
async function restServicesOn(appId: number) {
|
|
139
|
+
export async function restServicesOn(appId: number) {
|
|
140
140
|
const r = await owner.one<{ on: boolean }>('select coalesce((select w.rest_services from meta.workspace w where w.id = meta.app_workspace($1)), true) as on', [appId]);
|
|
141
141
|
return r?.on !== false;
|
|
142
142
|
}
|
package/src/session.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface Session {
|
|
|
32
32
|
/** Builder only: the developer's workspaces and the current one (src/builder/workspaces.ts). */
|
|
33
33
|
workspaces?: BuilderWorkspace[];
|
|
34
34
|
workspace?: BuilderWorkspace | null;
|
|
35
|
+
/** Builder only (sprint 99): an administrator of the installation (the Administration menu's Database users) */
|
|
36
|
+
instanceAdmin?: boolean;
|
|
35
37
|
/** Builder only (079): sha256 of the developer toolbar token (src/runtime/devbar.ts). */
|
|
36
38
|
devbar_hash?: string | null;
|
|
37
39
|
/** (085) when the session started, in ms since the epoch (its maximum length counts from here) */
|
package/src/sqlformat.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { skipQuoted } from './binds.ts';
|
|
2
|
+
|
|
3
|
+
// (sprint 93, Database Actions DA-9) Format SQL for the worksheet (Ctrl+F7): keywords in one case, each clause
|
|
4
|
+
// of a query on its own line (SELECT, FROM, WHERE, GROUP BY, …), joins indented, AND / OR of a condition and the
|
|
5
|
+
// columns of a select list one per line, subqueries indented. Strings, quoted identifiers, dollar-quoted bodies
|
|
6
|
+
// and comments are kept as they are; everything else is re-spaced. It is a layout aid, not a parser: SQL it
|
|
7
|
+
// doesn't understand keeps its words and order.
|
|
8
|
+
|
|
9
|
+
const KEYWORDS = new Set(`
|
|
10
|
+
select from where and or not in is null like ilike between exists as on join inner left right full outer cross natural
|
|
11
|
+
using group by order having limit offset fetch first next rows row only union all intersect except distinct case when
|
|
12
|
+
then else end insert into values update set delete returning with recursive create table view materialized index
|
|
13
|
+
replace drop alter add column constraint primary key foreign references unique check default cascade restrict
|
|
14
|
+
truncate grant revoke to begin commit rollback do language function procedure returns return true false asc desc
|
|
15
|
+
nulls last over partition window filter lateral any some array cast interval if conflict nothing merge matched
|
|
16
|
+
explain analyze schema sequence trigger policy for each execute`.trim().split(/\s+/));
|
|
17
|
+
|
|
18
|
+
/** Clauses that start a line in a query; two-word ones are matched as their first word plus the next. */
|
|
19
|
+
const CLAUSES = new Set(['select', 'from', 'where', 'having', 'limit', 'offset', 'fetch', 'union', 'intersect', 'except', 'window', 'returning', 'values', 'set', 'with', 'insert', 'update', 'delete', 'on conflict']);
|
|
20
|
+
const TWO_WORD = new Set(['group by', 'order by', 'partition by', 'on conflict', 'union all']);
|
|
21
|
+
const JOIN_START = new Set(['join', 'inner', 'left', 'right', 'full', 'cross', 'natural']);
|
|
22
|
+
|
|
23
|
+
type Tok = { t: 'ws' | 'word' | 'quoted' | 'comment' | 'punct' | 'num'; v: string };
|
|
24
|
+
|
|
25
|
+
function tokens(sql: string): Tok[] {
|
|
26
|
+
const out: Tok[] = [];
|
|
27
|
+
let i = 0;
|
|
28
|
+
while (i < sql.length) {
|
|
29
|
+
const c = sql[i];
|
|
30
|
+
if (/\s/.test(c)) {
|
|
31
|
+
let j = i + 1;
|
|
32
|
+
while (j < sql.length && /\s/.test(sql[j])) j++;
|
|
33
|
+
out.push({ t: 'ws', v: sql.slice(i, j) });
|
|
34
|
+
i = j;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const prev = i > 0 ? sql[i - 1] : '';
|
|
38
|
+
if (!(c === '$' && /[A-Za-z0-9_]/.test(prev))) {
|
|
39
|
+
const end = skipQuoted(sql, i);
|
|
40
|
+
if (end !== null && end > i) {
|
|
41
|
+
out.push({ t: c === '-' || c === '/' ? 'comment' : 'quoted', v: sql.slice(i, end) });
|
|
42
|
+
i = end;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (/[A-Za-z_\u0080-]/.test(c)) {
|
|
47
|
+
let j = i + 1;
|
|
48
|
+
while (j < sql.length && /[A-Za-z0-9_$\u0080-]/.test(sql[j])) j++;
|
|
49
|
+
// E'…' strings: the E belongs to the string
|
|
50
|
+
if ((sql[j] === "'") && j === i + 1 && /[eE]/.test(c)) {
|
|
51
|
+
const end = skipQuoted(sql, j)!;
|
|
52
|
+
out.push({ t: 'quoted', v: sql.slice(i, end) });
|
|
53
|
+
i = end;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
out.push({ t: 'word', v: sql.slice(i, j) });
|
|
57
|
+
i = j;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (/[0-9]/.test(c)) {
|
|
61
|
+
let j = i + 1;
|
|
62
|
+
while (j < sql.length && /[0-9.eE_]/.test(sql[j])) j++;
|
|
63
|
+
out.push({ t: 'num', v: sql.slice(i, j) });
|
|
64
|
+
i = j;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// $1 parameters
|
|
68
|
+
const param = c === '$' ? /^\$\d+/.exec(sql.slice(i, i + 12)) : null;
|
|
69
|
+
if (param) {
|
|
70
|
+
out.push({ t: 'num', v: param[0] });
|
|
71
|
+
i += param[0].length;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// operators of several characters stay together (::, <=, <>, ->>, ||, :=)
|
|
75
|
+
const op = /^(::|->>|->|#>>|#>|<=|>=|<>|!=|\|\||:=|=>)/.exec(sql.slice(i, i + 3));
|
|
76
|
+
out.push({ t: 'punct', v: op ? op[0] : c });
|
|
77
|
+
i += op ? op[0].length : 1;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function formatSql(sql: string, keywordCase: 'upper' | 'lower' = 'lower'): string {
|
|
83
|
+
const toks = tokens(sql).filter((t) => t.t !== 'ws');
|
|
84
|
+
const kw = (w: string) => (keywordCase === 'upper' ? w.toUpperCase() : w.toLowerCase());
|
|
85
|
+
interface Frame { query: boolean; base: number; clause: string; between: boolean }
|
|
86
|
+
const frames: Frame[] = [{ query: true, base: 0, clause: '', between: false }];
|
|
87
|
+
let out = '';
|
|
88
|
+
let line = ''; // the current line
|
|
89
|
+
let glue = false; // the next token sticks to the last one (a unary minus)
|
|
90
|
+
const flush = () => {
|
|
91
|
+
out += `${line.replace(/\s+$/, '')}\n`;
|
|
92
|
+
line = '';
|
|
93
|
+
};
|
|
94
|
+
const newline = (indent: number) => {
|
|
95
|
+
if (line.trim()) flush();
|
|
96
|
+
line = ' '.repeat(Math.max(0, indent));
|
|
97
|
+
};
|
|
98
|
+
const put = (s: string, space = true) => {
|
|
99
|
+
if (glue) {
|
|
100
|
+
glue = false;
|
|
101
|
+
line += s;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (space && line.trim() && !/[\s(]$/.test(line) && !/^[,).;:[\]]/.test(s) && !line.endsWith('::') && !line.endsWith('.')) line += ' ';
|
|
105
|
+
else if (!line.trim() && line.length === 0) line = ' '.repeat(frames[frames.length - 1].base);
|
|
106
|
+
line += s;
|
|
107
|
+
};
|
|
108
|
+
for (let k = 0; k < toks.length; k++) {
|
|
109
|
+
const t = toks[k];
|
|
110
|
+
const f = frames[frames.length - 1];
|
|
111
|
+
const lower = t.v.toLowerCase();
|
|
112
|
+
if (t.t === 'comment') {
|
|
113
|
+
put(t.v);
|
|
114
|
+
if (t.v.startsWith('--')) newline(f.base);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (t.t === 'word' && KEYWORDS.has(lower)) {
|
|
118
|
+
const nextWord = toks.slice(k + 1).find((x) => x.t !== 'comment');
|
|
119
|
+
const two = nextWord && nextWord.t === 'word' ? `${lower} ${nextWord.v.toLowerCase()}` : '';
|
|
120
|
+
if (f.query && TWO_WORD.has(two) && two !== 'partition by') {
|
|
121
|
+
newline(f.base);
|
|
122
|
+
put(`${kw(lower)} ${kw(nextWord!.v.toLowerCase())}`);
|
|
123
|
+
f.clause = two;
|
|
124
|
+
k = toks.indexOf(nextWord!);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (f.query && CLAUSES.has(lower) && !(lower === 'set' && f.clause !== 'update') && !(lower === 'values' && f.clause === 'values')) {
|
|
128
|
+
if ((out || line.trim()) && !line.endsWith('(')) newline(f.base);
|
|
129
|
+
put(kw(lower));
|
|
130
|
+
f.clause = lower;
|
|
131
|
+
f.between = false;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (f.query && JOIN_START.has(lower)) {
|
|
135
|
+
const prevWord = [...toks.slice(0, k)].reverse().find((x) => x.t !== 'comment');
|
|
136
|
+
const afterJoinWord = prevWord?.t === 'word' && /^(inner|left|right|full|cross|natural|outer)$/i.test(prevWord.v);
|
|
137
|
+
// left( and right( are functions; JOIN right after INNER / LEFT … stays on its line
|
|
138
|
+
const starts = lower === 'join' ? !afterJoinWord : !!nextWord && /^(join|outer|inner)$/i.test(nextWord.v);
|
|
139
|
+
if (starts && !afterJoinWord) {
|
|
140
|
+
newline(f.base + 2);
|
|
141
|
+
put(kw(lower));
|
|
142
|
+
f.clause = 'from';
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (f.query && (lower === 'and' || lower === 'or') && ['where', 'having', 'from'].includes(f.clause)) {
|
|
147
|
+
if (lower === 'and' && f.between) {
|
|
148
|
+
f.between = false;
|
|
149
|
+
put(kw(lower));
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
newline(f.base + (f.clause === 'from' ? 4 : 2) + (lower === 'and' ? 1 : 2));
|
|
153
|
+
put(kw(lower));
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (lower === 'between') f.between = true;
|
|
157
|
+
put(kw(lower));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (t.t === 'punct') {
|
|
161
|
+
if (t.v === '(') {
|
|
162
|
+
const next = toks.slice(k + 1).find((x) => x.t !== 'comment');
|
|
163
|
+
const sub = !!next && next.t === 'word' && /^(select|with|values)$/i.test(next.v);
|
|
164
|
+
const prevTok = toks[k - 1];
|
|
165
|
+
const before = toks[k - 2];
|
|
166
|
+
const named = before?.t === 'word' && /^(into|table|view|index|on|using|references|function|procedure|type)$/i.test(before.v);
|
|
167
|
+
const fn = !!prevTok && (prevTok.t === 'word' || prevTok.t === 'quoted') && !named
|
|
168
|
+
&& (!KEYWORDS.has(prevTok.v.toLowerCase()) || /^(left|right|replace|cast|any|some)$/i.test(prevTok.v));
|
|
169
|
+
put('(', !fn);
|
|
170
|
+
frames.push({ query: sub, base: sub ? line.length : f.base, clause: '', between: false });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (t.v === ')') {
|
|
174
|
+
if (frames.length > 1) frames.pop();
|
|
175
|
+
put(')', false);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (t.v === ',') {
|
|
179
|
+
put(',', false);
|
|
180
|
+
if (f.query && f.clause === 'select') newline(f.base + 7);
|
|
181
|
+
else if (f.query && (f.clause === 'group by' || f.clause === 'order by')) newline(f.base + 9);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (t.v === ';') {
|
|
185
|
+
put(';', false);
|
|
186
|
+
flush();
|
|
187
|
+
out += '\n';
|
|
188
|
+
frames.splice(1);
|
|
189
|
+
frames[0].clause = '';
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (t.v === '.' || t.v === '::') {
|
|
193
|
+
line += t.v;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const prevTok = toks[k - 1];
|
|
197
|
+
const unary = (t.v === '-' || t.v === '+') && (!prevTok || prevTok.t === 'punct' && prevTok.v !== ')' || (prevTok.t === 'word' && KEYWORDS.has(prevTok.v.toLowerCase())));
|
|
198
|
+
put(t.v);
|
|
199
|
+
if (unary) glue = true;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
// a word after "." or "::" sticks to it
|
|
203
|
+
if (line.endsWith('.') || line.endsWith('::')) line += t.v;
|
|
204
|
+
else put(t.v);
|
|
205
|
+
}
|
|
206
|
+
if (line.trim()) flush();
|
|
207
|
+
return out.replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + (sql.trimEnd().endsWith(';') ? '\n' : '');
|
|
208
|
+
}
|