@protobase/server 0.26.7 → 0.26.9
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/dist/check-views.js +4 -2
- package/dist/rest/embed-params.js +64 -0
- package/dist/rest/embed-shape.js +42 -0
- package/dist/rest/embed-sql.js +24 -0
- package/dist/rest/find-relationship.js +39 -0
- package/dist/rest/foreign-keys.js +32 -0
- package/dist/rest/parse-query.js +11 -10
- package/dist/rest/parse-select.js +20 -2
- package/dist/rest/read-rows.js +11 -9
- package/dist/rest/rest-routes.js +2 -1
- package/dist/rest/rest-scan.js +22 -10
- package/dist/rest/rpc-route.js +7 -2
- package/dist/rest/shape-sql.js +8 -5
- package/dist/rest/table-routes.js +73 -33
- package/dist/rest/whole-number.js +9 -0
- package/dist/rest/write-rows.js +2 -1
- package/dist/view-access.js +11 -1
- package/dist/view-action-when.js +23 -0
- package/package.json +4 -4
package/dist/check-views.js
CHANGED
|
@@ -6,6 +6,7 @@ import { chartErrors } from "./view-chart.js";
|
|
|
6
6
|
import { mapDisplayErrors, mapErrors } from "./view-map.js";
|
|
7
7
|
import { relatedColumnErrors } from "./view-related-column.js";
|
|
8
8
|
import { editableErrors } from "./view-editable.js";
|
|
9
|
+
import { actionWhenErrors, actionWhenFields } from "./view-action-when.js";
|
|
9
10
|
import { conditionProblems } from "@protobase/layout";
|
|
10
11
|
import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, chartFields, datesErrors, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, layoutItems, linkActionError, promptError, promptFields, updateSetErrors, viewOfList } from "@protobase/schema/internal";
|
|
11
12
|
import { isRecordCondition } from "@protobase/schema";
|
|
@@ -33,7 +34,7 @@ const sensitiveErrors = (view, model) => {
|
|
|
33
34
|
["a map", layoutItems(view.layout).flatMap((item) => item.kind === "map" ? [item.latitude, item.longitude] : [])],
|
|
34
35
|
["a gallery", layoutItems(view.layout).flatMap((item) => item.kind === "gallery" && item.image !== void 0 ? [item.image] : [])],
|
|
35
36
|
["the recent records", view.nav?.recent ? [view.nav.recent.status] : []],
|
|
36
|
-
["an action", view.actions.flatMap((action) => actionFields(action.run))]
|
|
37
|
+
["an action", view.actions.flatMap((action) => [...actionFields(action.run), ...actionWhenFields(action.when)])]
|
|
37
38
|
].flatMap(([place, names]) => [...new Set(names.filter(sensitive))].map((name) => `sensitive field "${name}" cannot be in ${place}`));
|
|
38
39
|
};
|
|
39
40
|
const valueLabelErrors = (view, model) => Object.entries(view.fields).flatMap(([name, hints]) => {
|
|
@@ -147,7 +148,7 @@ const listOfErrors = (model, name, models) => {
|
|
|
147
148
|
* has a section, related section or sidebar whose `when` condition names what the app does not have, a related section or related list column, a cards display's or gallery's photos or a cards display's favorite button, a `listOf`, a "Save search" button or a
|
|
148
149
|
* `searchesOf` that do not fit the resources, cards
|
|
149
150
|
* displays of one list that name different photos, a chart per value that cannot group or add up its fields, a map that cannot show the
|
|
150
|
-
* record's position or a map display that cannot find the records in its view, a board whose columns are no filterable enum, a calendar or timeline whose dates are no filterable date or timestamp fields, a link to an address the app does not have, an AI action that cannot write its field, action inputs that do not fit, or a create page that is not one of `pages`.
|
|
151
|
+
* record's position or a map display that cannot find the records in its view, a board whose columns are no filterable enum, a calendar or timeline whose dates are no filterable date or timestamp fields, a link to an address the app does not have, an action whose `when` reads what the record does not have, an AI action that cannot write its field, action inputs that do not fit, or a create page that is not one of `pages`.
|
|
151
152
|
*/
|
|
152
153
|
const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
153
154
|
const models = Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model]));
|
|
@@ -191,6 +192,7 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
|
191
192
|
...chart,
|
|
192
193
|
...linkErrors(view, model, models, pageNames, createPages),
|
|
193
194
|
...aiActionErrors(view, model),
|
|
195
|
+
...actionWhenErrors(view, model),
|
|
194
196
|
...inputErrors(view, models),
|
|
195
197
|
...createErrors(view, pageNames)
|
|
196
198
|
];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { restError } from "./rest-error.js";
|
|
2
|
+
import { parseFilter } from "./parse-filter.js";
|
|
3
|
+
import { parseOrder } from "./parse-order.js";
|
|
4
|
+
import { embedKey } from "./parse-select.js";
|
|
5
|
+
import { wholeNumber } from "./whole-number.js";
|
|
6
|
+
//#region src/rest/embed-params.ts
|
|
7
|
+
/**
|
|
8
|
+
* The embed path a parameter's key is under, and its own name: `comments.author.username` is `username` under
|
|
9
|
+
* `comments.author`, `comments.not.or` is `not.or` under `comments`. A key outside any embed has no path. A JSON path's
|
|
10
|
+
* keys may hold dots, so only the part before its first arrow is split.
|
|
11
|
+
*/
|
|
12
|
+
const splitEmbedKey = (key) => {
|
|
13
|
+
const arrow = key.indexOf("->");
|
|
14
|
+
const head = arrow < 0 ? key : key.slice(0, arrow);
|
|
15
|
+
const tail = arrow < 0 ? "" : key.slice(arrow);
|
|
16
|
+
const segments = head.split(".");
|
|
17
|
+
const own = segments.length >= 2 && segments.at(-2) === "not" && /^(and|or)$/.test(segments.at(-1)) ? 2 : 1;
|
|
18
|
+
if (segments.length <= own) return { name: key };
|
|
19
|
+
return {
|
|
20
|
+
path: segments.slice(0, -own).join("."),
|
|
21
|
+
name: segments.slice(-own).join(".") + tail
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
/** The path of every embed of `select`, nested ones as `comments.author`, by the names they go by. */
|
|
25
|
+
const embedPaths = (select, prefix = "") => select.flatMap((item) => {
|
|
26
|
+
if (item.kind !== "embed") return [];
|
|
27
|
+
const path = prefix + embedKey(item);
|
|
28
|
+
return [path, ...embedPaths(item.select, `${path}.`)];
|
|
29
|
+
});
|
|
30
|
+
/**
|
|
31
|
+
* The parameters under an embed's path, by path: `comments.likes=gte.2` filters the comments, `comments.order`,
|
|
32
|
+
* `comments.limit` and `comments.offset` sort and page them. A path no embed of `select` has is PGRST108.
|
|
33
|
+
*/
|
|
34
|
+
const parseEmbedParams = (params, select) => {
|
|
35
|
+
const known = new Set(embedPaths(select));
|
|
36
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
37
|
+
for (const [key, value] of params) {
|
|
38
|
+
const { path, name } = splitEmbedKey(key);
|
|
39
|
+
if (path === void 0) continue;
|
|
40
|
+
if (!known.has(path)) {
|
|
41
|
+
const resource = path.split(".").at(-1);
|
|
42
|
+
throw restError(400, "PGRST108", `'${resource}' is not an embedded resource in this request`, null, `Verify that '${resource}' is included in the 'select' query parameter.`);
|
|
43
|
+
}
|
|
44
|
+
const embed = byPath.get(path) ?? {
|
|
45
|
+
filters: [],
|
|
46
|
+
order: [],
|
|
47
|
+
range: { offset: 0 }
|
|
48
|
+
};
|
|
49
|
+
byPath.set(path, embed);
|
|
50
|
+
if (name === "order") embed.order = parseOrder(value);
|
|
51
|
+
else if (name === "limit") embed.range = {
|
|
52
|
+
...embed.range,
|
|
53
|
+
limit: wholeNumber(key, value)
|
|
54
|
+
};
|
|
55
|
+
else if (name === "offset") embed.range = {
|
|
56
|
+
...embed.range,
|
|
57
|
+
offset: wholeNumber(key, value)
|
|
58
|
+
};
|
|
59
|
+
else embed.filters.push(parseFilter(name, value));
|
|
60
|
+
}
|
|
61
|
+
return byPath;
|
|
62
|
+
};
|
|
63
|
+
//#endregion
|
|
64
|
+
export { parseEmbedParams, splitEmbedKey };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { embedKey } from "./parse-select.js";
|
|
2
|
+
import { findRelationship } from "./find-relationship.js";
|
|
3
|
+
//#region src/rest/embed-shape.ts
|
|
4
|
+
const noParams = {
|
|
5
|
+
filters: [],
|
|
6
|
+
order: [],
|
|
7
|
+
range: { offset: 0 }
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Resolves the embeds of `select`, read from rows of `source`, to the relationships they go through, depth first:
|
|
11
|
+
* PGRST200 or PGRST201 for an embed without one or with several.
|
|
12
|
+
*/
|
|
13
|
+
const resolveShape = (context, source, select) => {
|
|
14
|
+
let count = 0;
|
|
15
|
+
const resolve = (table, items, prefix) => items.map((item) => {
|
|
16
|
+
if (item.kind !== "embed") return item;
|
|
17
|
+
const from = context.served(item.name);
|
|
18
|
+
const relationship = findRelationship(from ? context.foreignKeys : [], table, item.name, item.hint);
|
|
19
|
+
const path = prefix + embedKey(item);
|
|
20
|
+
const alias = `${item.name}_${++count}`;
|
|
21
|
+
return {
|
|
22
|
+
kind: "embed",
|
|
23
|
+
key: embedKey(item),
|
|
24
|
+
table: item.name,
|
|
25
|
+
from,
|
|
26
|
+
alias,
|
|
27
|
+
relationship,
|
|
28
|
+
toOne: relationship.cardinality !== "one-to-many",
|
|
29
|
+
inner: item.inner,
|
|
30
|
+
shown: item.select.length > 0,
|
|
31
|
+
shape: resolve(item.name, item.select, `${path}.`),
|
|
32
|
+
params: context.embedded.get(path) ?? noParams
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
return resolve(source, select, "");
|
|
36
|
+
};
|
|
37
|
+
/** Every embed of the shape, nested ones included. */
|
|
38
|
+
const embedsOf = (shape) => shape.flatMap((item) => item.kind === "embed" ? [item, ...embedsOf(item.shape)] : []);
|
|
39
|
+
/** The name of the lateral subquery an embed's JSON comes from, as `body`. */
|
|
40
|
+
const lateralName = (embed) => `pgrst_${embed.alias}`;
|
|
41
|
+
//#endregion
|
|
42
|
+
export { embedsOf, lateralName, resolveShape };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { lateralName } from "./embed-shape.js";
|
|
2
|
+
import { filterSql } from "./filter-sql.js";
|
|
3
|
+
import { orderSql, rangeSql, selectSql } from "./shape-sql.js";
|
|
4
|
+
import { sql } from "kysely";
|
|
5
|
+
//#region src/rest/embed-sql.ts
|
|
6
|
+
const relatedRowsSql = (source, embed) => {
|
|
7
|
+
const related = embed.relationship.join.map((pair) => sql`${sql.id(embed.alias, pair.target)} = ${sql.id(source, pair.source)}`);
|
|
8
|
+
const where = sql.join([...related, ...embed.params.filters.map((filter) => filterSql(embed.alias, filter))], sql` and `);
|
|
9
|
+
const columns = selectSql(embed.alias, embed.shape);
|
|
10
|
+
return sql`select ${columns} from ${embed.from} as ${sql.id(embed.alias)}${joinsSql(embed.alias, embed.shape)} where ${where}${orderSql(embed.alias, embed.params.order)}${rangeSql(embed.params.range)}`;
|
|
11
|
+
};
|
|
12
|
+
const lateralSql = (source, embed) => {
|
|
13
|
+
const rows = relatedRowsSql(source, embed);
|
|
14
|
+
const body = embed.toOne ? sql`select row_to_json(pgrst_rows) as body from (${rows}) as pgrst_rows` : sql`select coalesce(json_agg(pgrst_rows), '[]') as body from (${rows}) as pgrst_rows${embed.inner ? sql` having count(*) > 0` : sql``}`;
|
|
15
|
+
return sql` ${sql.raw(embed.inner ? "inner" : "left")} join lateral (${body}) as ${sql.id(lateralName(embed))} on true`;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The lateral joins that read the embeds of the shape for each row of `source`, which `selectSql` picks their JSON
|
|
19
|
+
* from. They run in the same query as the caller, so the RLS policies of every embedded table apply. An inner embed's
|
|
20
|
+
* join drops the rows without a related row.
|
|
21
|
+
*/
|
|
22
|
+
const joinsSql = (source, shape) => sql.join(shape.flatMap((item) => item.kind === "embed" ? [lateralSql(source, item)] : []), sql``);
|
|
23
|
+
//#endregion
|
|
24
|
+
export { joinsSql };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { restError } from "./rest-error.js";
|
|
2
|
+
//#region src/rest/find-relationship.ts
|
|
3
|
+
const pairs = (sources, targets) => sources.map((source, i) => ({
|
|
4
|
+
source,
|
|
5
|
+
target: targets[i]
|
|
6
|
+
}));
|
|
7
|
+
/** The relationships from `source` to `target`: its foreign keys to `target`, and `target`'s foreign keys to it. */
|
|
8
|
+
const relationshipsBetween = (keys, source, target) => [...keys.filter((key) => key.table === source && key.foreignTable === target).map((key) => ({
|
|
9
|
+
foreignKey: key,
|
|
10
|
+
cardinality: key.unique ? "one-to-one" : "many-to-one",
|
|
11
|
+
join: pairs(key.columns, key.foreignColumns)
|
|
12
|
+
})), ...keys.filter((key) => key.foreignTable === source && key.table === target).map((key) => ({
|
|
13
|
+
foreignKey: key,
|
|
14
|
+
cardinality: key.unique ? "one-to-one" : "one-to-many",
|
|
15
|
+
join: pairs(key.foreignColumns, key.columns)
|
|
16
|
+
}))];
|
|
17
|
+
const matchesHint = (relationship, hint) => {
|
|
18
|
+
const { name, columns, foreignColumns } = relationship.foreignKey;
|
|
19
|
+
return name === hint || columns.length === 1 && (columns[0] === hint || foreignColumns[0] === hint);
|
|
20
|
+
};
|
|
21
|
+
const columnList = (table, columns) => `${table}(${columns.join(", ")})`;
|
|
22
|
+
const ambiguity = (source, target, found) => restError(300, "PGRST201", `Could not embed because more than one relationship was found for '${source}' and '${target}'`, found.map((one) => ({
|
|
23
|
+
cardinality: one.cardinality,
|
|
24
|
+
embedding: `${source} with ${target}`,
|
|
25
|
+
relationship: `${one.foreignKey.name} using ${columnList(source, one.join.map((pair) => pair.source))} and ${columnList(target, one.join.map((pair) => pair.target))}`
|
|
26
|
+
})), `Try changing '${target}' to one of the following: ${found.map((one) => `'${target}!${one.foreignKey.name}'`).join(", ")}. Find the desired relationship in the 'details' key.`);
|
|
27
|
+
/**
|
|
28
|
+
* The one relationship an embed of `target` in rows of `source` goes through, narrowed by its hint: PGRST200 when
|
|
29
|
+
* there is none, PGRST201 when several remain.
|
|
30
|
+
*/
|
|
31
|
+
const findRelationship = (keys, source, target, hint) => {
|
|
32
|
+
const found = relationshipsBetween(keys, source, target).filter((one) => hint === void 0 || matchesHint(one, hint));
|
|
33
|
+
if (found.length === 1) return found[0];
|
|
34
|
+
if (found.length > 1) throw ambiguity(source, target, found);
|
|
35
|
+
const using = hint === void 0 ? "" : ` using the hint '${hint}'`;
|
|
36
|
+
throw restError(400, "PGRST200", `Could not find a relationship between '${source}' and '${target}' in the schema cache`, `Searched for a foreign key relationship between '${source}' and '${target}'${using} in the schema 'public', but no matches were found.`);
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
export { findRelationship };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
//#region src/rest/foreign-keys.ts
|
|
3
|
+
const readForeignKeys = async (db) => {
|
|
4
|
+
const { rows } = await sql`
|
|
5
|
+
select c.conname::text as name, s.relname::text as table, t.relname::text as "foreignTable",
|
|
6
|
+
array(select a.attname::text from unnest(c.conkey) with ordinality k(attnum, ord) join pg_attribute a on a.attrelid = c.conrelid and a.attnum = k.attnum order by k.ord) as columns,
|
|
7
|
+
array(select a.attname::text from unnest(c.confkey) with ordinality k(attnum, ord) join pg_attribute a on a.attrelid = c.confrelid and a.attnum = k.attnum order by k.ord) as "foreignColumns",
|
|
8
|
+
exists (select 1 from pg_constraint u where u.conrelid = c.conrelid and u.contype in ('p', 'u') and u.conkey @> c.conkey and u.conkey <@ c.conkey) as unique
|
|
9
|
+
from pg_constraint c
|
|
10
|
+
join pg_class s on s.oid = c.conrelid
|
|
11
|
+
join pg_class t on t.oid = c.confrelid
|
|
12
|
+
where c.contype = 'f' and s.relnamespace = 'public'::regnamespace and t.relnamespace = 'public'::regnamespace
|
|
13
|
+
order by c.conname
|
|
14
|
+
`.execute(db);
|
|
15
|
+
return rows;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The foreign keys of the `public` schema, which `/rest/v1` embeds through, read from the catalog once and kept for the
|
|
19
|
+
* process lifetime, as the scan guard keeps indexes: migrations run before a new version serves.
|
|
20
|
+
*/
|
|
21
|
+
const foreignKeyCache = (db) => {
|
|
22
|
+
let known;
|
|
23
|
+
return () => {
|
|
24
|
+
known ??= readForeignKeys(db).catch((error) => {
|
|
25
|
+
known = void 0;
|
|
26
|
+
throw error;
|
|
27
|
+
});
|
|
28
|
+
return known;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
export { foreignKeyCache };
|
package/dist/rest/parse-query.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { parseError } from "./rest-error.js";
|
|
2
1
|
import { splitList, unquote } from "./split-list.js";
|
|
3
2
|
import { parseFilter } from "./parse-filter.js";
|
|
4
3
|
import { parseOrder } from "./parse-order.js";
|
|
5
4
|
import { parseSelect } from "./parse-select.js";
|
|
5
|
+
import { wholeNumber } from "./whole-number.js";
|
|
6
|
+
import { parseEmbedParams, splitEmbedKey } from "./embed-params.js";
|
|
6
7
|
//#region src/rest/parse-query.ts
|
|
7
8
|
const reserved = /* @__PURE__ */ new Set([
|
|
8
9
|
"select",
|
|
@@ -12,10 +13,6 @@ const reserved = /* @__PURE__ */ new Set([
|
|
|
12
13
|
"on_conflict",
|
|
13
14
|
"columns"
|
|
14
15
|
]);
|
|
15
|
-
const count = (name, text) => {
|
|
16
|
-
if (!/^\d+$/.test(text)) throw parseError(`"failed to parse ${name} (${text})"`, `${name} takes a whole number`);
|
|
17
|
-
return Number(text);
|
|
18
|
-
};
|
|
19
16
|
const names = (text) => splitList(text).map((name) => unquote(name.trim()));
|
|
20
17
|
/** `Range: 2-4` or `Range: 5-`, in items; anything else asks for every row, as PostgREST takes it. */
|
|
21
18
|
const rangeHeader = (header) => {
|
|
@@ -37,7 +34,8 @@ const intersect = (a, b) => {
|
|
|
37
34
|
};
|
|
38
35
|
/**
|
|
39
36
|
* Parses a request's query parameters. The ones in `args` are a function's arguments, not filters. `select`, `order`,
|
|
40
|
-
* `limit`, `offset`, `on_conflict` and `columns` count once, the first time;
|
|
37
|
+
* `limit`, `offset`, `on_conflict` and `columns` count once, the first time; a parameter under an embed's path, such
|
|
38
|
+
* as `comments.likes`, is the embed's (see `parseEmbedParams`); every other parameter is a filter.
|
|
41
39
|
*/
|
|
42
40
|
const parseQuery = (params, rangeText = null, args = /* @__PURE__ */ new Set()) => {
|
|
43
41
|
const first = (name) => params.find(([key]) => key === name)?.[1];
|
|
@@ -46,13 +44,16 @@ const parseQuery = (params, rangeText = null, args = /* @__PURE__ */ new Set())
|
|
|
46
44
|
const order = first("order");
|
|
47
45
|
const onConflict = first("on_conflict");
|
|
48
46
|
const columns = first("columns");
|
|
47
|
+
const select = parseSelect(first("select"));
|
|
48
|
+
const own = params.filter(([key]) => !reserved.has(key) && !args.has(key));
|
|
49
49
|
const fromParams = {
|
|
50
|
-
offset: offset === void 0 ? 0 :
|
|
51
|
-
...limit !== void 0 && { limit:
|
|
50
|
+
offset: offset === void 0 ? 0 : wholeNumber("offset", offset),
|
|
51
|
+
...limit !== void 0 && { limit: wholeNumber("limit", limit) }
|
|
52
52
|
};
|
|
53
53
|
return {
|
|
54
|
-
select
|
|
55
|
-
filters:
|
|
54
|
+
select,
|
|
55
|
+
filters: own.filter(([key]) => splitEmbedKey(key).path === void 0).map(([key, value]) => parseFilter(key, value)),
|
|
56
|
+
embedded: parseEmbedParams(own, select),
|
|
56
57
|
order: order === void 0 ? [] : parseOrder(order),
|
|
57
58
|
range: intersect(fromParams, rangeHeader(rangeText)),
|
|
58
59
|
...onConflict !== void 0 && { onConflict: names(onConflict) },
|
|
@@ -3,10 +3,26 @@ import { parseFieldPath } from "./field-path.js";
|
|
|
3
3
|
import { splitList } from "./split-list.js";
|
|
4
4
|
//#region src/rest/parse-select.ts
|
|
5
5
|
const typeName = /^[A-Za-z_][A-Za-z0-9_]*(\[\])?$/;
|
|
6
|
+
const embedPattern = /^(?:([^:(!]+):(?!:))?([^!(]+)((?:![^!(]+)*)\(([\s\S]*)\)$/;
|
|
7
|
+
const parseEmbed = (item) => {
|
|
8
|
+
const [, alias, nameText = "", modifiers = "", inner = ""] = embedPattern.exec(item) ?? [];
|
|
9
|
+
const name = nameText.trim();
|
|
10
|
+
if (!name || name.startsWith("...") || name.includes(".") || name === "count") throw restError(400, "PGRST100", `"${item}" spreads a resource or calls an aggregate, which /rest/v1 does not support yet`);
|
|
11
|
+
const flags = modifiers.split("!").slice(1).map((flag) => flag.trim());
|
|
12
|
+
const hint = flags.find((flag) => flag !== "inner" && flag !== "left");
|
|
13
|
+
return {
|
|
14
|
+
kind: "embed",
|
|
15
|
+
name,
|
|
16
|
+
...alias?.trim() && { alias: alias.trim() },
|
|
17
|
+
...hint !== void 0 && { hint },
|
|
18
|
+
inner: flags.includes("inner"),
|
|
19
|
+
select: inner.trim() === "" ? [] : splitList(inner).map(parseItem)
|
|
20
|
+
};
|
|
21
|
+
};
|
|
6
22
|
const parseItem = (text) => {
|
|
7
23
|
const item = text.trim();
|
|
8
24
|
if (item === "*") return { kind: "all" };
|
|
9
|
-
if (item.includes("("))
|
|
25
|
+
if (item.includes("(")) return parseEmbed(item);
|
|
10
26
|
const aliased = /^([^:]+):(?!:)([\s\S]*)$/.exec(item);
|
|
11
27
|
const alias = aliased?.[1]?.trim();
|
|
12
28
|
const [fieldText = "", cast, ...more] = (aliased ? aliased[2] : item).split("::");
|
|
@@ -20,5 +36,7 @@ const parseItem = (text) => {
|
|
|
20
36
|
};
|
|
21
37
|
/** Parses `select`: items separated by commas, `*` for every column. Missing or empty, it is `*`. */
|
|
22
38
|
const parseSelect = (text) => text === void 0 || text.trim() === "" ? [{ kind: "all" }] : splitList(text).map(parseItem);
|
|
39
|
+
/** The name an embed goes by in a row and in the parameters that filter it: its alias, else its name. */
|
|
40
|
+
const embedKey = (item) => item.alias ?? item.name;
|
|
23
41
|
//#endregion
|
|
24
|
-
export { parseSelect };
|
|
42
|
+
export { embedKey, parseSelect };
|
package/dist/rest/read-rows.js
CHANGED
|
@@ -1,34 +1,36 @@
|
|
|
1
1
|
import { whereSql } from "./filter-sql.js";
|
|
2
2
|
import { orderSql, rangeSql, selectSql } from "./shape-sql.js";
|
|
3
|
+
import { joinsSql } from "./embed-sql.js";
|
|
3
4
|
import { sql } from "kysely";
|
|
4
5
|
//#region src/rest/read-rows.ts
|
|
5
6
|
/** The most rows a read returns, as Supabase's `db-max-rows` default. */
|
|
6
7
|
const maxRows = 1e3;
|
|
7
|
-
const plannedRows = async (trx, source,
|
|
8
|
-
const { rows } = await sql`explain (format json) select 1 from ${source.from}${
|
|
8
|
+
const plannedRows = async (trx, source, filtered) => {
|
|
9
|
+
const { rows } = await sql`explain (format json) select 1 from ${source.from}${filtered}`.execute(trx);
|
|
9
10
|
return rows[0]["QUERY PLAN"][0].Plan["Plan Rows"];
|
|
10
11
|
};
|
|
11
12
|
/**
|
|
12
|
-
* Reads the rows of `source` the query asks for: filtered, shaped by `select`, sorted, in its range and never more than
|
|
13
|
+
* Reads the rows of `source` the query asks for: filtered, shaped by `shape` (its `select`, embeds resolved), sorted, in its range and never more than
|
|
13
14
|
* `maxRows`, as one JSON array built by Postgres, so values reach the client exactly as Postgres writes them. `count`
|
|
14
15
|
* counts the matching rows: `exact` by counting, `planned` by the planner's estimate, `estimated` by counting up to
|
|
15
|
-
* `maxRows` and estimating beyond.
|
|
16
|
+
* `maxRows` and estimating beyond. The rows an inner embed drops are not counted.
|
|
16
17
|
*/
|
|
17
|
-
const readRows = async (trx, source, query, count) => {
|
|
18
|
+
const readRows = async (trx, source, shape, query, count) => {
|
|
18
19
|
const where = whereSql(source.alias, query.filters);
|
|
20
|
+
const filtered = sql`${joinsSql(source.alias, shape.filter((item) => item.kind === "embed" && item.inner))}${where}`;
|
|
19
21
|
const range = {
|
|
20
22
|
offset: query.range.offset,
|
|
21
23
|
limit: Math.min(query.range.limit ?? 1e3, maxRows)
|
|
22
24
|
};
|
|
23
|
-
const columns = source.scalar ? sql`${sql.id(source.alias)} as pgrst_scalar` : selectSql(source.alias,
|
|
24
|
-
const rows = sql`select ${columns} from ${source.from}${where}${orderSql(source.alias, query.order)}${rangeSql(range)}`;
|
|
25
|
-
const counted = count === "exact" || count === "estimated" ? sql`(select count(*) from ${source.from}${
|
|
25
|
+
const columns = source.scalar ? sql`${sql.id(source.alias)} as pgrst_scalar` : selectSql(source.alias, shape);
|
|
26
|
+
const rows = sql`select ${columns} from ${source.from}${joinsSql(source.alias, shape)}${where}${orderSql(source.alias, query.order)}${rangeSql(range)}`;
|
|
27
|
+
const counted = count === "exact" || count === "estimated" ? sql`(select count(*) from ${source.from}${filtered})` : sql`null::bigint`;
|
|
26
28
|
const aggregate = source.scalar ? sql`json_agg(pgrst_rows.pgrst_scalar)` : sql`json_agg(pgrst_rows)`;
|
|
27
29
|
const { rows: [result] } = await sql`
|
|
28
30
|
select coalesce(${aggregate}, '[]')::text as body, count(*) as page, ${counted} as total from (${rows}) as pgrst_rows
|
|
29
31
|
`.execute(trx);
|
|
30
32
|
const exact = result.total === null ? void 0 : Number(result.total);
|
|
31
|
-
const total = count === "planned" || count === "estimated" && exact > 1e3 ? await plannedRows(trx, source,
|
|
33
|
+
const total = count === "planned" || count === "estimated" && exact > 1e3 ? await plannedRows(trx, source, filtered) : exact;
|
|
32
34
|
return {
|
|
33
35
|
body: result.body,
|
|
34
36
|
page: Number(result.page),
|
package/dist/rest/rest-routes.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HttpProblem } from "../problem.js";
|
|
2
|
+
import { foreignKeyCache } from "./foreign-keys.js";
|
|
2
3
|
import { restError, restErrorOf } from "./rest-error.js";
|
|
3
4
|
import { errorResponse } from "./respond.js";
|
|
4
5
|
import { rpcRoute } from "./rpc-route.js";
|
|
@@ -25,7 +26,7 @@ const restRoutes = ({ deps, caller, report }) => {
|
|
|
25
26
|
await next();
|
|
26
27
|
});
|
|
27
28
|
app.route("/", rpcRoute(deps));
|
|
28
|
-
app.route("/", tableRoutes(deps));
|
|
29
|
+
app.route("/", tableRoutes(deps, foreignKeyCache(deps.db)));
|
|
29
30
|
app.notFound(() => errorResponse(restError(404, "PGRST125", "Invalid path specified in request URL")));
|
|
30
31
|
return app;
|
|
31
32
|
};
|
package/dist/rest/rest-scan.js
CHANGED
|
@@ -71,22 +71,34 @@ const suggestion = (table, read) => {
|
|
|
71
71
|
return `create index on ${table} (${column})`;
|
|
72
72
|
};
|
|
73
73
|
/**
|
|
74
|
-
* The reads of a request no index of the table serves: each
|
|
75
|
-
*
|
|
74
|
+
* The reads of a request no index of the table serves: each column an embed joins on, which equals one value per
|
|
75
|
+
* related row; each filtered field, by the kind of index its operator needs; and the first sort field. A view or a
|
|
76
|
+
* foreign table has no indexes of its own and is not judged.
|
|
76
77
|
*/
|
|
77
|
-
const uncoveredReads = (table, filters, order) => {
|
|
78
|
+
const uncoveredReads = (table, filters, order, joined = []) => {
|
|
78
79
|
if (table.kind !== "table") return [];
|
|
79
|
-
const pinned = new Set(filters.flatMap(pinnedFields));
|
|
80
|
-
return [
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
const pinned = /* @__PURE__ */ new Set([...joined, ...filters.flatMap(pinnedFields)]);
|
|
81
|
+
return [
|
|
82
|
+
...joined.map((column) => ({
|
|
83
|
+
field: {
|
|
84
|
+
column,
|
|
85
|
+
json: []
|
|
86
|
+
},
|
|
87
|
+
need: "equality",
|
|
88
|
+
use: "join"
|
|
89
|
+
})),
|
|
90
|
+
...filters.flatMap(filterReads),
|
|
91
|
+
...order[0] ? [{
|
|
92
|
+
field: order[0].field,
|
|
93
|
+
need: "btree",
|
|
94
|
+
use: "sort"
|
|
95
|
+
}] : []
|
|
96
|
+
].filter((read) => !table.indexes.some((index) => serves(index, read, pinned)));
|
|
85
97
|
};
|
|
86
98
|
/** The 400 error of a read no index serves, with the statement that adds one as its hint. */
|
|
87
99
|
const scanRefusal = (table, read) => {
|
|
88
100
|
const tableName = table.schema ? `${quoted(table.schema)}.${quoted(table.name)}` : quoted(table.name);
|
|
89
|
-
const what = read.use === "sort" ? "Sorting by" : "Filtering on";
|
|
101
|
+
const what = read.use === "sort" ? "Sorting by" : read.use === "join" ? "Embedding through" : "Filtering on";
|
|
90
102
|
const index = read.need === "btree" || read.need === "equality" ? "an index" : read.need === "trigram" ? "a pg_trgm index" : read.need === "gist" ? "a GiST index" : "a GIN index";
|
|
91
103
|
return restError(400, "expensive-query", `${what} "${pathKey(read.field)}" needs ${index} that serves it`, null, suggestion(tableName, read));
|
|
92
104
|
};
|
package/dist/rest/rpc-route.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { restError } from "./rest-error.js";
|
|
1
2
|
import { jsonType, onlyElement, pageResponse, wantsObject } from "./respond.js";
|
|
2
3
|
import { callSource, chooseFunction, findFunctions } from "./functions.js";
|
|
3
4
|
import { parsePrefer } from "./parse-prefer.js";
|
|
@@ -36,6 +37,10 @@ const call = async (deps, c) => {
|
|
|
36
37
|
const args = get ? Object.fromEntries(params.filter(([key]) => given.includes(key))) : body;
|
|
37
38
|
const source = callSource(chosen, args);
|
|
38
39
|
const query = parseQuery(params, c.req.header("range") ?? null, new Set(given));
|
|
40
|
+
const shape = query.select.map((item) => {
|
|
41
|
+
if (item.kind === "embed") throw restError(400, "PGRST100", `"${item.name}" embeds a resource in a function's rows, which /rest/v1 does not support yet`);
|
|
42
|
+
return item;
|
|
43
|
+
});
|
|
39
44
|
if (chosen.info.returns === "void") {
|
|
40
45
|
await sql`select 1 from ${source.from}`.execute(trx);
|
|
41
46
|
return new Response(null, {
|
|
@@ -44,7 +49,7 @@ const call = async (deps, c) => {
|
|
|
44
49
|
});
|
|
45
50
|
}
|
|
46
51
|
if (!chosen.info.set) {
|
|
47
|
-
const page = await readRows(trx, source, query);
|
|
52
|
+
const page = await readRows(trx, source, shape, query);
|
|
48
53
|
return new Response(onlyElement(page.body), {
|
|
49
54
|
status: 200,
|
|
50
55
|
headers: {
|
|
@@ -53,7 +58,7 @@ const call = async (deps, c) => {
|
|
|
53
58
|
}
|
|
54
59
|
});
|
|
55
60
|
}
|
|
56
|
-
return pageResponse(await readRows(trx, source, query, prefer.count), query.range.offset, object);
|
|
61
|
+
return pageResponse(await readRows(trx, source, shape, query, prefer.count), query.range.offset, object);
|
|
57
62
|
});
|
|
58
63
|
};
|
|
59
64
|
const rpcRoute = (deps) => {
|
package/dist/rest/shape-sql.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { pathName } from "./field-path.js";
|
|
2
|
+
import { lateralName } from "./embed-shape.js";
|
|
2
3
|
import { fieldSql } from "./filter-sql.js";
|
|
3
4
|
import { sql } from "kysely";
|
|
4
5
|
//#region src/rest/shape-sql.ts
|
|
5
6
|
/**
|
|
6
|
-
* The select list of
|
|
7
|
-
* key or its column, cast when asked
|
|
7
|
+
* The select list of a shape on the rows of `source`: `*` its columns, a field under its alias, its JSON path's last
|
|
8
|
+
* key or its column, cast when asked, and an embed's JSON from its lateral subquery (see `joinsSql`) under its key; an
|
|
9
|
+
* embed of nothing shows nothing. Cast types are checked to be bare type names when parsed.
|
|
8
10
|
*/
|
|
9
|
-
const selectSql = (source, items) => sql.join(items.
|
|
10
|
-
if (item.kind === "all") return sql`${sql.id(source)}
|
|
11
|
+
const selectSql = (source, items) => sql.join(items.flatMap((item) => {
|
|
12
|
+
if (item.kind === "all") return [sql`${sql.id(source)}.*`];
|
|
13
|
+
if (item.kind === "embed") return item.shown ? [sql`${sql.id(lateralName(item))}.body as ${sql.id(item.key)}`] : [];
|
|
11
14
|
const value = fieldSql(source, item.field);
|
|
12
15
|
const cast = item.cast === void 0 ? value : sql`cast(${value} as ${sql.raw(item.cast)})`;
|
|
13
|
-
return sql`${cast} as ${sql.id(item.alias ?? pathName(item.field))}
|
|
16
|
+
return [sql`${cast} as ${sql.id(item.alias ?? pathName(item.field))}`];
|
|
14
17
|
}));
|
|
15
18
|
/** ` order by ...` for the terms; nothing without any. */
|
|
16
19
|
const orderSql = (source, terms) => terms.length === 0 ? sql`` : sql` order by ${sql.join(terms.map((term) => sql`${fieldSql(source, term.field)}${sql.raw(term.direction ? ` ${term.direction}` : "")}${sql.raw(term.nulls ? ` nulls ${term.nulls}` : "")}`))}`;
|
|
@@ -4,6 +4,7 @@ import { contentRange } from "./content-range.js";
|
|
|
4
4
|
import { checkSingle, jsonType, objectType, onlyElement, pageResponse, wantsObject } from "./respond.js";
|
|
5
5
|
import { parsePrefer } from "./parse-prefer.js";
|
|
6
6
|
import { parseQuery } from "./parse-query.js";
|
|
7
|
+
import { embedsOf, resolveShape } from "./embed-shape.js";
|
|
7
8
|
import { readRows } from "./read-rows.js";
|
|
8
9
|
import { asCaller, queryParams, readBody } from "./rest-context.js";
|
|
9
10
|
import { scanRefusal, uncoveredReads } from "./rest-scan.js";
|
|
@@ -15,25 +16,56 @@ import { sql } from "kysely";
|
|
|
15
16
|
* The resource whose table `name` is, in the `public` schema or the search path's: `/rest/v1` serves the tables the app
|
|
16
17
|
* declares as resources, as PostgREST serves those of its exposed schema. Any other is 404 (PGRST205).
|
|
17
18
|
*/
|
|
19
|
+
const servedEntry = (deps, name) => deps.registry.entries.find((one) => one.model.table.name === name && (one.model.table.schema ?? "public") === "public");
|
|
18
20
|
const tableEntry = (deps, name) => {
|
|
19
|
-
const entry = deps
|
|
21
|
+
const entry = servedEntry(deps, name);
|
|
20
22
|
if (!entry) throw restError(404, "PGRST205", `Could not find the table 'public.${name}' in the schema cache`);
|
|
21
23
|
return entry;
|
|
22
24
|
};
|
|
25
|
+
/** The select list of the query on rows of `entry`'s table, its embeds resolved through the foreign keys of served tables. */
|
|
26
|
+
const shapeOf = async (deps, foreignKeys, entry, query) => {
|
|
27
|
+
if (!query.select.some((item) => item.kind === "embed")) return query.select;
|
|
28
|
+
const served = (name) => {
|
|
29
|
+
const target = servedEntry(deps, name);
|
|
30
|
+
return target && tableId(target.model);
|
|
31
|
+
};
|
|
32
|
+
return resolveShape({
|
|
33
|
+
foreignKeys: await foreignKeys(),
|
|
34
|
+
served,
|
|
35
|
+
embedded: query.embedded
|
|
36
|
+
}, entry.model.table.name, query.select);
|
|
37
|
+
};
|
|
23
38
|
const targetOf = (entry) => ({
|
|
24
39
|
table: tableId(entry.model),
|
|
25
40
|
alias: entry.model.table.name
|
|
26
41
|
});
|
|
27
|
-
/**
|
|
28
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Refuses filters and a sort no index serves, as the scan guard does on `/api/v1`, on the table and on every embedded
|
|
44
|
+
* one, whose join columns need an index too; in `warn` mode, a warning header instead.
|
|
45
|
+
*/
|
|
46
|
+
const scanCheck = async (deps, entry, filters, order, shape = []) => {
|
|
29
47
|
if (deps.guard.mode === "off") return {};
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
const reads = [{
|
|
49
|
+
entry,
|
|
50
|
+
filters,
|
|
51
|
+
order,
|
|
52
|
+
joined: []
|
|
53
|
+
}, ...embedsOf(shape).map((embed) => ({
|
|
54
|
+
entry: tableEntry(deps, embed.table),
|
|
55
|
+
filters: embed.params.filters,
|
|
56
|
+
order: embed.params.order,
|
|
57
|
+
joined: embed.relationship.join.map((pair) => pair.target)
|
|
58
|
+
}))];
|
|
59
|
+
for (const read of reads) {
|
|
60
|
+
const [first] = uncoveredReads(await deps.guard.indexesOf(read.entry), read.filters, read.order, read.joined);
|
|
61
|
+
if (!first) continue;
|
|
62
|
+
const refusal = scanRefusal(read.entry.model.table, first);
|
|
63
|
+
if (deps.guard.mode === "warn") return { "X-Protobase-Warning": refusal.body.message };
|
|
64
|
+
throw refusal;
|
|
65
|
+
}
|
|
66
|
+
return {};
|
|
35
67
|
};
|
|
36
|
-
const returningOf = (prefer,
|
|
68
|
+
const returningOf = (prefer, shape) => prefer.return === "representation" ? { shape } : void 0;
|
|
37
69
|
const counted = (prefer, total) => prefer.count === void 0 ? void 0 : total;
|
|
38
70
|
/** A write's response: the rows it returns when asked for, as an array or one object; else no body, with `emptyStatus`. */
|
|
39
71
|
const writeResponse = (written, object, status, emptyStatus, range) => {
|
|
@@ -49,6 +81,10 @@ const writeResponse = (written, object, status, emptyStatus, range) => {
|
|
|
49
81
|
}
|
|
50
82
|
});
|
|
51
83
|
};
|
|
84
|
+
const withHeaders = (response, headers) => {
|
|
85
|
+
for (const [name, value] of Object.entries(headers)) response.headers.set(name, value);
|
|
86
|
+
return response;
|
|
87
|
+
};
|
|
52
88
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
53
89
|
/**
|
|
54
90
|
* The rows and columns of an insert's body: an object or an array of objects. The columns are `columns` when given,
|
|
@@ -69,24 +105,27 @@ const insertRowsOf = (body, query) => {
|
|
|
69
105
|
columns
|
|
70
106
|
};
|
|
71
107
|
};
|
|
72
|
-
const read = async (deps, c) => {
|
|
108
|
+
const read = async (deps, foreignKeys, c) => {
|
|
73
109
|
const entry = tableEntry(deps, c.req.param("table"));
|
|
74
110
|
const query = parseQuery(queryParams(c), c.req.header("range") ?? null);
|
|
75
111
|
const prefer = parsePrefer(c.req.header("prefer") ?? null);
|
|
76
|
-
const
|
|
112
|
+
const shape = await shapeOf(deps, foreignKeys, entry, query);
|
|
113
|
+
const warning = await scanCheck(deps, entry, query.filters, query.order, shape);
|
|
77
114
|
const alias = entry.model.table.name;
|
|
78
115
|
const source = {
|
|
79
116
|
from: sql`${tableId(entry.model)} as ${sql.id(alias)}`,
|
|
80
117
|
alias
|
|
81
118
|
};
|
|
82
|
-
const page = await asCaller(deps, c, true, (trx) => readRows(trx, source, query, prefer.count));
|
|
119
|
+
const page = await asCaller(deps, c, true, (trx) => readRows(trx, source, shape, query, prefer.count));
|
|
83
120
|
return pageResponse(page, query.range.offset, wantsObject(c.req.raw), warning);
|
|
84
121
|
};
|
|
85
|
-
const insert = async (deps, c) => {
|
|
122
|
+
const insert = async (deps, foreignKeys, c) => {
|
|
86
123
|
const entry = tableEntry(deps, c.req.param("table"));
|
|
87
124
|
const query = parseQuery(queryParams(c));
|
|
88
125
|
const prefer = parsePrefer(c.req.header("prefer") ?? null);
|
|
89
126
|
const object = wantsObject(c.req.raw);
|
|
127
|
+
const shape = await shapeOf(deps, foreignKeys, entry, query);
|
|
128
|
+
const warning = await scanCheck(deps, entry, [], [], shape);
|
|
90
129
|
const { rows, columns } = insertRowsOf(await readBody(c), query);
|
|
91
130
|
if (columns.length === 0 && rows.length > 1) throw restError(400, "PGRST102", "Several rows without any column cannot be inserted at once");
|
|
92
131
|
if (rows.length === 0 && object) checkSingle(0);
|
|
@@ -101,55 +140,56 @@ const insert = async (deps, c) => {
|
|
|
101
140
|
defaults: prefer.missing === "default",
|
|
102
141
|
resolution: prefer.resolution,
|
|
103
142
|
conflict
|
|
104
|
-
}, returningOf(prefer,
|
|
143
|
+
}, returningOf(prefer, shape));
|
|
105
144
|
if (object) checkSingle(result.total);
|
|
106
145
|
return result;
|
|
107
146
|
});
|
|
108
147
|
const status = written.inserted === 0 ? 200 : 201;
|
|
109
|
-
return writeResponse(written, object, status, status, `*/${counted(prefer, written.total) ?? "*"}`);
|
|
148
|
+
return withHeaders(writeResponse(written, object, status, status, `*/${counted(prefer, written.total) ?? "*"}`), warning);
|
|
110
149
|
};
|
|
111
|
-
const update = async (deps, c) => {
|
|
150
|
+
const update = async (deps, foreignKeys, c) => {
|
|
112
151
|
const entry = tableEntry(deps, c.req.param("table"));
|
|
113
152
|
const query = parseQuery(queryParams(c));
|
|
114
153
|
const prefer = parsePrefer(c.req.header("prefer") ?? null);
|
|
115
154
|
const object = wantsObject(c.req.raw);
|
|
116
155
|
const body = await readBody(c);
|
|
117
156
|
if (!isRecord(body)) throw restError(400, "PGRST102", "Empty or invalid json");
|
|
118
|
-
const
|
|
157
|
+
const shape = await shapeOf(deps, foreignKeys, entry, query);
|
|
158
|
+
const warning = await scanCheck(deps, entry, query.filters, [], shape);
|
|
119
159
|
const written = Object.keys(body).length === 0 ? {
|
|
120
160
|
total: 0,
|
|
121
161
|
...prefer.return === "representation" && { body: "[]" }
|
|
122
162
|
} : await asCaller(deps, c, false, async (trx) => {
|
|
123
|
-
const result = await updateRows(trx, targetOf(entry), body, query.filters, returningOf(prefer,
|
|
163
|
+
const result = await updateRows(trx, targetOf(entry), body, query.filters, returningOf(prefer, shape));
|
|
124
164
|
if (object) checkSingle(result.total);
|
|
125
165
|
return result;
|
|
126
166
|
});
|
|
127
|
-
|
|
128
|
-
for (const [name, value] of Object.entries(warning)) response.headers.set(name, value);
|
|
129
|
-
return response;
|
|
167
|
+
return withHeaders(writeResponse(written, object, 200, 204, contentRange(0, written.total, counted(prefer, written.total))), warning);
|
|
130
168
|
};
|
|
131
|
-
const remove = async (deps, c) => {
|
|
169
|
+
const remove = async (deps, foreignKeys, c) => {
|
|
132
170
|
const entry = tableEntry(deps, c.req.param("table"));
|
|
133
171
|
const query = parseQuery(queryParams(c));
|
|
134
172
|
const prefer = parsePrefer(c.req.header("prefer") ?? null);
|
|
135
173
|
const object = wantsObject(c.req.raw);
|
|
136
|
-
const
|
|
174
|
+
const shape = await shapeOf(deps, foreignKeys, entry, query);
|
|
175
|
+
const warning = await scanCheck(deps, entry, query.filters, [], shape);
|
|
137
176
|
const written = await asCaller(deps, c, false, async (trx) => {
|
|
138
|
-
const result = await deleteRows(trx, targetOf(entry), query.filters, returningOf(prefer,
|
|
177
|
+
const result = await deleteRows(trx, targetOf(entry), query.filters, returningOf(prefer, shape));
|
|
139
178
|
if (object) checkSingle(result.total);
|
|
140
179
|
return result;
|
|
141
180
|
});
|
|
142
|
-
|
|
143
|
-
for (const [name, value] of Object.entries(warning)) response.headers.set(name, value);
|
|
144
|
-
return response;
|
|
181
|
+
return withHeaders(writeResponse(written, object, 200, 204, `*/${counted(prefer, written.total) ?? "*"}`), warning);
|
|
145
182
|
};
|
|
146
|
-
/**
|
|
147
|
-
|
|
183
|
+
/**
|
|
184
|
+
* `/rest/v1/<table>`: read with GET and HEAD, insert and upsert with POST, update with PATCH, delete with DELETE. Reads
|
|
185
|
+
* and the rows a write returns embed related rows through `foreignKeys`.
|
|
186
|
+
*/
|
|
187
|
+
const tableRoutes = (deps, foreignKeys) => {
|
|
148
188
|
const app = new Hono();
|
|
149
|
-
app.get("/:table", (c) => read(deps, c));
|
|
150
|
-
app.post("/:table", (c) => insert(deps, c));
|
|
151
|
-
app.patch("/:table", (c) => update(deps, c));
|
|
152
|
-
app.delete("/:table", (c) => remove(deps, c));
|
|
189
|
+
app.get("/:table", (c) => read(deps, foreignKeys, c));
|
|
190
|
+
app.post("/:table", (c) => insert(deps, foreignKeys, c));
|
|
191
|
+
app.patch("/:table", (c) => update(deps, foreignKeys, c));
|
|
192
|
+
app.delete("/:table", (c) => remove(deps, foreignKeys, c));
|
|
153
193
|
return app;
|
|
154
194
|
};
|
|
155
195
|
//#endregion
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { parseError } from "./rest-error.js";
|
|
2
|
+
//#region src/rest/whole-number.ts
|
|
3
|
+
/** The whole number a `limit` or `offset` parameter named `name` gives. */
|
|
4
|
+
const wholeNumber = (name, text) => {
|
|
5
|
+
if (!/^\d+$/.test(text)) throw parseError(`"failed to parse ${name} (${text})"`, `${name} takes a whole number`);
|
|
6
|
+
return Number(text);
|
|
7
|
+
};
|
|
8
|
+
//#endregion
|
|
9
|
+
export { wholeNumber };
|
package/dist/rest/write-rows.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { whereSql } from "./filter-sql.js";
|
|
2
2
|
import { selectSql } from "./shape-sql.js";
|
|
3
|
+
import { joinsSql } from "./embed-sql.js";
|
|
3
4
|
import { sql } from "kysely";
|
|
4
5
|
//#region src/rest/write-rows.ts
|
|
5
6
|
const returningSql = (target, returning, upsert) => {
|
|
@@ -8,7 +9,7 @@ const returningSql = (target, returning, upsert) => {
|
|
|
8
9
|
};
|
|
9
10
|
const run = async (trx, target, payload, write, returning, upsert) => {
|
|
10
11
|
const inserted = upsert ? sql`(select count(*) from pgrst_source where pgrst_inserted)` : sql`null::bigint`;
|
|
11
|
-
const body = returning ? sql`(select coalesce(json_agg(pgrst_rows), '[]')::text from (select ${selectSql(target.alias, returning.
|
|
12
|
+
const body = returning ? sql`(select coalesce(json_agg(pgrst_rows), '[]')::text from (select ${selectSql(target.alias, returning.shape)} from (select (pgrst_source.pgrst_row).* from pgrst_source) as ${sql.id(target.alias)}${joinsSql(target.alias, returning.shape)}) as pgrst_rows)` : sql`null::text`;
|
|
12
13
|
const { rows: [result] } = await sql`
|
|
13
14
|
with pgrst_payload as (select ${JSON.stringify(payload ?? null)}::json as body), pgrst_source as (${write}${returningSql(target, returning, upsert)})
|
|
14
15
|
select (select count(*) from pgrst_source) as total, ${inserted} as inserted, ${body} as body
|
package/dist/view-access.js
CHANGED
|
@@ -3,6 +3,7 @@ import { cardPhotosErrors } from "./view-card-photos.js";
|
|
|
3
3
|
import { cardFavoriteErrors } from "./view-card-favorite.js";
|
|
4
4
|
import { searchesOfFor } from "./view-saved-search.js";
|
|
5
5
|
import { relatedColumnErrors } from "./view-related-column.js";
|
|
6
|
+
import { actionWhenFields } from "./view-action-when.js";
|
|
6
7
|
import { conditionReadable, resolveFieldPath } from "@protobase/layout";
|
|
7
8
|
import { addressErrors, chartFields, hrefPlaceholders, navRecentErrors, promptFields, pruneByShape, shapes, tableDisplay } from "@protobase/schema/internal";
|
|
8
9
|
import { isRecordCondition } from "@protobase/schema";
|
|
@@ -258,12 +259,17 @@ const aiShape = (model) => shapes.map((run) => {
|
|
|
258
259
|
} : void 0;
|
|
259
260
|
});
|
|
260
261
|
const inputsShape = (models) => shapes.map((inputs) => inputs.every((input) => input.resource === void 0 || Object.hasOwn(models, input.resource)) ? inputs : void 0);
|
|
262
|
+
const actionWhenShape = (model) => shapes.map((when) => actionWhenFields(when).every((name) => Object.hasOwn(model.fields, name)) ? when : void 0);
|
|
261
263
|
const actionShape = (model, models, createPages) => shapes.object({
|
|
262
264
|
name: shapes.data,
|
|
263
265
|
label: shapes.data,
|
|
264
266
|
icon: shapes.data,
|
|
265
267
|
confirm: shapes.data,
|
|
266
268
|
bulk: shapes.data,
|
|
269
|
+
when: actionWhenShape(model),
|
|
270
|
+
otherwise: shapes.data,
|
|
271
|
+
reason: shapes.data,
|
|
272
|
+
variant: shapes.data,
|
|
267
273
|
run: shapes.oneOf({
|
|
268
274
|
update: shapes.object({
|
|
269
275
|
kind: shapes.data,
|
|
@@ -274,7 +280,11 @@ const actionShape = (model, models, createPages) => shapes.object({
|
|
|
274
280
|
ai: aiShape(model)
|
|
275
281
|
}),
|
|
276
282
|
inputs: inputsShape(models)
|
|
277
|
-
}, { requires: [
|
|
283
|
+
}, { requires: [
|
|
284
|
+
"run",
|
|
285
|
+
"inputs",
|
|
286
|
+
"when"
|
|
287
|
+
] });
|
|
278
288
|
const viewShape = (resource, model, models, prompted, createPages) => {
|
|
279
289
|
const nav = shapes.object({
|
|
280
290
|
hidden: shapes.data,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { parseCondition } from "@protobase/layout";
|
|
2
|
+
import { actionConditionError } from "@protobase/schema/internal";
|
|
3
|
+
//#region src/view-action-when.ts
|
|
4
|
+
/** The fields an action's `when` reads, or none when it has none or does not parse, which startup refuses. */
|
|
5
|
+
const actionWhenFields = (when) => {
|
|
6
|
+
if (when === void 0) return [];
|
|
7
|
+
const parsed = parseCondition(when);
|
|
8
|
+
return parsed.ok ? parsed.paths : [];
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* An action's `when` parses and reads only fields the record has: a list decides it for every row from the row alone,
|
|
12
|
+
* so it follows no relation and counts no records. A view written as data is checked as the builder checks its options.
|
|
13
|
+
*/
|
|
14
|
+
const actionWhenErrors = (view, model) => view.actions.flatMap((action) => {
|
|
15
|
+
const option = actionConditionError(action);
|
|
16
|
+
if (option) return [`action "${action.name}": ${option}`];
|
|
17
|
+
if (action.when === void 0) return [];
|
|
18
|
+
const parsed = parseCondition(action.when);
|
|
19
|
+
if (!parsed.ok) return [`action "${action.name}": "when" is not a valid condition: ${parsed.message}`];
|
|
20
|
+
return parsed.paths.filter((path) => !Object.hasOwn(model.fields, path)).map((path) => `action "${action.name}": "when" names "${path}", but an action's condition reads only fields ${model.name} has`);
|
|
21
|
+
});
|
|
22
|
+
//#endregion
|
|
23
|
+
export { actionWhenErrors, actionWhenFields };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.9",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@better-auth/passkey": "^1.7.7",
|
|
34
34
|
"@better-auth/sso": "^1.7.7",
|
|
35
|
-
"@protobase/layout": "0.26.
|
|
36
|
-
"@protobase/query": "0.26.
|
|
37
|
-
"@protobase/schema": "0.26.
|
|
35
|
+
"@protobase/layout": "0.26.9",
|
|
36
|
+
"@protobase/query": "0.26.9",
|
|
37
|
+
"@protobase/schema": "0.26.9",
|
|
38
38
|
"@react-email/render": "^2.1.0",
|
|
39
39
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
40
40
|
"@sentry/core": "^11.7.0",
|