@protobase/server 0.19.16 → 0.20.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/dist/aggregate-groups.js +137 -0
- package/dist/aggregate-service.js +11 -10
- package/dist/check-view-queries.js +2 -1
- package/dist/check-views.js +6 -3
- package/dist/openapi/resource-paths.js +66 -0
- package/dist/routes/collection.js +5 -0
- package/dist/view-access.js +2 -6
- package/dist/view-chart.js +33 -0
- package/package.json +4 -4
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { badRequest } from "./problem.js";
|
|
2
|
+
import { readTransaction } from "./transactions.js";
|
|
3
|
+
import { parseParams } from "./params.js";
|
|
4
|
+
import { labelRequests, relatedLabels } from "./related-labels.js";
|
|
5
|
+
import { scoped, seriesRange } from "./aggregate-service.js";
|
|
6
|
+
import { groupTotals, series } from "@protobase/query";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
//#region src/aggregate-groups.ts
|
|
9
|
+
const name = z.string().min(1);
|
|
10
|
+
const aggregateParams = z.object({
|
|
11
|
+
group_by: name,
|
|
12
|
+
filter: z.string().optional(),
|
|
13
|
+
sum: name.optional(),
|
|
14
|
+
average: name.optional(),
|
|
15
|
+
limit: z.coerce.number().int().optional(),
|
|
16
|
+
label: name.optional(),
|
|
17
|
+
range: z.enum([
|
|
18
|
+
"7d",
|
|
19
|
+
"30d",
|
|
20
|
+
"90d",
|
|
21
|
+
"1y",
|
|
22
|
+
"all"
|
|
23
|
+
]).optional(),
|
|
24
|
+
from: z.string().optional(),
|
|
25
|
+
to: z.string().optional(),
|
|
26
|
+
granularity: z.enum([
|
|
27
|
+
"hour",
|
|
28
|
+
"day",
|
|
29
|
+
"week",
|
|
30
|
+
"month",
|
|
31
|
+
"year"
|
|
32
|
+
]).optional(),
|
|
33
|
+
time_zone: z.string().optional()
|
|
34
|
+
});
|
|
35
|
+
const invalid = (parameter, message) => badRequest("invalid-parameter", `Invalid parameter ${parameter}: ${message}`, { errors: [{
|
|
36
|
+
parameter,
|
|
37
|
+
message
|
|
38
|
+
}] });
|
|
39
|
+
const measureOf = (params) => {
|
|
40
|
+
if (params.sum !== void 0 && params.average !== void 0) throw invalid("average", "Give sum or average, not both");
|
|
41
|
+
if (params.sum !== void 0) return {
|
|
42
|
+
kind: "sum",
|
|
43
|
+
field: params.sum
|
|
44
|
+
};
|
|
45
|
+
if (params.average !== void 0) return {
|
|
46
|
+
kind: "average",
|
|
47
|
+
field: params.average
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
const optionErrors = (params, dated) => {
|
|
51
|
+
const given = (names) => names.filter((option) => params[option] !== void 0);
|
|
52
|
+
if (dated) return given(["limit", "label"]).map((option) => invalid(option, `Not for a date or timestamp group_by`));
|
|
53
|
+
return given([
|
|
54
|
+
"range",
|
|
55
|
+
"from",
|
|
56
|
+
"to",
|
|
57
|
+
"granularity",
|
|
58
|
+
"time_zone"
|
|
59
|
+
]).map((option) => invalid(option, "Only for a date or timestamp group_by"));
|
|
60
|
+
};
|
|
61
|
+
const isDated = (model, field) => {
|
|
62
|
+
const type = model.fields[field]?.type;
|
|
63
|
+
return type === "date" || type === "timestamp";
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Records per value of a field, or per hour, day, week, month or year of a date field with the empty ones included,
|
|
67
|
+
* each with how many there are and, with `sum` or `average`, that of a numeric field. Read as a list is: the caller's
|
|
68
|
+
* filter on the model as they see it, inside their tenant and row filter; the scan guard judges the grouped field.
|
|
69
|
+
*/
|
|
70
|
+
const aggregate = async (deps, entry, session, input) => {
|
|
71
|
+
const params = parseParams(aggregateParams, input);
|
|
72
|
+
const { access, model, filter, scope } = await scoped(deps, entry, session, params.filter);
|
|
73
|
+
if (!Object.hasOwn(model.fields, params.group_by)) throw invalid("group_by", `Unknown field "${params.group_by}"`);
|
|
74
|
+
const measure = measureOf(params);
|
|
75
|
+
const dated = isDated(model, params.group_by);
|
|
76
|
+
const [problem] = optionErrors(params, dated);
|
|
77
|
+
if (problem) throw problem;
|
|
78
|
+
const warning = await deps.guard(entry, {
|
|
79
|
+
...filter && { filter },
|
|
80
|
+
groupBy: params.group_by
|
|
81
|
+
}, {
|
|
82
|
+
model,
|
|
83
|
+
scope
|
|
84
|
+
});
|
|
85
|
+
if (dated) {
|
|
86
|
+
const granularity = params.granularity ?? "day";
|
|
87
|
+
const timeZone = params.time_zone ?? "UTC";
|
|
88
|
+
const range = await seriesRange(deps, access, params.group_by, params, scope);
|
|
89
|
+
const groups = (range ? await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => series(trx, model, params.group_by, {
|
|
90
|
+
range,
|
|
91
|
+
granularity,
|
|
92
|
+
timeZone,
|
|
93
|
+
...filter && { filter },
|
|
94
|
+
...measure && { measure },
|
|
95
|
+
scope
|
|
96
|
+
})) : []).map(({ bucket, ...totals }) => ({
|
|
97
|
+
value: bucket,
|
|
98
|
+
...totals
|
|
99
|
+
}));
|
|
100
|
+
return {
|
|
101
|
+
body: {
|
|
102
|
+
group_by: params.group_by,
|
|
103
|
+
granularity,
|
|
104
|
+
time_zone: timeZone,
|
|
105
|
+
groups
|
|
106
|
+
},
|
|
107
|
+
warning
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const labels = params.label === void 0 ? [] : await labelRequests(deps, session, model, [params.group_by], `${params.group_by}.${params.label}`);
|
|
111
|
+
return {
|
|
112
|
+
body: await readTransaction(deps.db, deps.statementTimeoutMs, async (trx) => {
|
|
113
|
+
const totals = await groupTotals(trx, model, params.group_by, {
|
|
114
|
+
statementTimeoutMs: deps.statementTimeoutMs,
|
|
115
|
+
...filter && { filter },
|
|
116
|
+
...measure && { measure },
|
|
117
|
+
...params.limit !== void 0 && { limit: params.limit },
|
|
118
|
+
scope
|
|
119
|
+
});
|
|
120
|
+
const names = (await relatedLabels(trx, labels, totals.map((group) => ({ [params.group_by]: group.value }))))[params.group_by] ?? {};
|
|
121
|
+
const groups = totals.map((group) => {
|
|
122
|
+
const label = group.value === null ? void 0 : names[String(group.value)];
|
|
123
|
+
return label === void 0 ? group : {
|
|
124
|
+
...group,
|
|
125
|
+
label
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
group_by: params.group_by,
|
|
130
|
+
groups
|
|
131
|
+
};
|
|
132
|
+
}),
|
|
133
|
+
warning
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
//#endregion
|
|
137
|
+
export { aggregate };
|
|
@@ -103,25 +103,26 @@ const earliest = async (deps, access, fieldName, scope) => {
|
|
|
103
103
|
return (await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => sql`select min(${columnId(target)})::text as lowest from ${tableId(access.full)} as t${whereClause(conditions)}`.execute(trx))).rows[0]?.lowest ?? void 0;
|
|
104
104
|
};
|
|
105
105
|
const bound = (model, fieldName, value) => model.fields[fieldName]?.type === "date" ? value.slice(0, 10) : value;
|
|
106
|
-
|
|
106
|
+
/** The span a series of `field` covers: `from` and `to`, else the `range` up to now, `all` from the earliest row. */
|
|
107
|
+
const seriesRange = async (deps, access, field, params, scope) => {
|
|
107
108
|
const to = params.to ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
108
109
|
const { model } = access;
|
|
109
110
|
if (params.from) return {
|
|
110
|
-
from: bound(model,
|
|
111
|
-
to: bound(model,
|
|
111
|
+
from: bound(model, field, params.from),
|
|
112
|
+
to: bound(model, field, to)
|
|
112
113
|
};
|
|
113
114
|
const range = params.range ?? "30d";
|
|
114
115
|
if (range !== "all") {
|
|
115
116
|
const from = (/* @__PURE__ */ new Date(Date.now() - rangeDays[range] * 864e5)).toISOString();
|
|
116
117
|
return {
|
|
117
|
-
from: bound(model,
|
|
118
|
-
to: bound(model,
|
|
118
|
+
from: bound(model, field, from),
|
|
119
|
+
to: bound(model, field, to)
|
|
119
120
|
};
|
|
120
121
|
}
|
|
121
|
-
const lowest = await earliest(deps, access,
|
|
122
|
+
const lowest = await earliest(deps, access, field, scope);
|
|
122
123
|
return lowest ? {
|
|
123
|
-
from: bound(model,
|
|
124
|
-
to: bound(model,
|
|
124
|
+
from: bound(model, field, lowest),
|
|
125
|
+
to: bound(model, field, to)
|
|
125
126
|
} : void 0;
|
|
126
127
|
};
|
|
127
128
|
/** Row counts per calendar bucket of a date or timestamp field, empty buckets included. */
|
|
@@ -132,7 +133,7 @@ const timeSeries = async (deps, entry, session, input) => {
|
|
|
132
133
|
parameter: "field",
|
|
133
134
|
message: "Unknown field"
|
|
134
135
|
}] });
|
|
135
|
-
const range = await seriesRange(deps, access, params, scope);
|
|
136
|
+
const range = await seriesRange(deps, access, params.field, params, scope);
|
|
136
137
|
const timeZone = params.time_zone ?? "UTC";
|
|
137
138
|
const points = range ? await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => series(trx, model, params.field, {
|
|
138
139
|
range,
|
|
@@ -159,4 +160,4 @@ const numericHistogram = async (deps, entry, session, input) => {
|
|
|
159
160
|
};
|
|
160
161
|
};
|
|
161
162
|
//#endregion
|
|
162
|
-
export { facets, numericHistogram, timeSeries };
|
|
163
|
+
export { facets, numericHistogram, scoped, seriesRange, timeSeries };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isGroupChart } from "@protobase/schema/internal";
|
|
1
2
|
//#region src/check-view-queries.ts
|
|
2
3
|
const widgetName = {
|
|
3
4
|
facets: "facets filter",
|
|
@@ -24,7 +25,7 @@ const searchErrors = (model, view) => (view.list?.search ?? []).filter((name) =>
|
|
|
24
25
|
*/
|
|
25
26
|
const viewQueryErrors = (view, model) => [
|
|
26
27
|
...view.filters.flatMap((widget) => filterableErrors(model, widget.kind === "flags" ? widget.fields : [widget.field], `the ${widgetName[widget.kind]}`)),
|
|
27
|
-
...view.chart ? filterableErrors(model, [view.chart.field], "the chart") : [],
|
|
28
|
+
...view.chart ? filterableErrors(model, [isGroupChart(view.chart) ? view.chart.groupBy : view.chart.field], "the chart") : [],
|
|
28
29
|
...listSortErrors(model, view),
|
|
29
30
|
...searchErrors(model, view)
|
|
30
31
|
];
|
package/dist/check-views.js
CHANGED
|
@@ -2,10 +2,11 @@ import { relatedErrors } from "./view-related.js";
|
|
|
2
2
|
import { cardPhotosErrors } from "./view-card-photos.js";
|
|
3
3
|
import { cardFavoriteErrors, favoriteOwnerErrors } from "./view-card-favorite.js";
|
|
4
4
|
import { saveSearchErrors, searchesOfErrors } from "./view-saved-search.js";
|
|
5
|
+
import { chartErrors } from "./view-chart.js";
|
|
5
6
|
import { mapDisplayErrors, mapErrors } from "./view-map.js";
|
|
6
7
|
import { relatedColumnErrors } from "./view-related-column.js";
|
|
7
8
|
import { editableErrors } from "./view-editable.js";
|
|
8
|
-
import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, linkActionError } from "@protobase/schema/internal";
|
|
9
|
+
import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, chartFields, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, linkActionError } from "@protobase/schema/internal";
|
|
9
10
|
//#region src/check-views.ts
|
|
10
11
|
const linkFields = (run) => run?.kind !== "link" ? [] : "to" in run ? addressPlaceholders(run.to) : hrefPlaceholders(run.href);
|
|
11
12
|
const sensitiveErrors = (view, model) => {
|
|
@@ -20,7 +21,7 @@ const sensitiveErrors = (view, model) => {
|
|
|
20
21
|
]],
|
|
21
22
|
["a list display", (view.list?.displays ?? []).flatMap(displayFields)],
|
|
22
23
|
["a filter", view.filters.flatMap((widget) => widget.kind === "flags" ? widget.fields : [widget.field])],
|
|
23
|
-
["the chart", view.chart ?
|
|
24
|
+
["the chart", view.chart ? chartFields(view.chart) : []],
|
|
24
25
|
["the sidebar", view.layout.flatMap((item) => item.kind === "sidebar" ? item.fields : [])],
|
|
25
26
|
["a map", view.layout.flatMap((item) => item.kind === "map" ? [item.latitude, item.longitude] : [])],
|
|
26
27
|
["a gallery", view.layout.flatMap((item) => item.kind === "gallery" && item.image !== void 0 ? [item.image] : [])],
|
|
@@ -111,7 +112,7 @@ const listOfErrors = (model, name, models) => {
|
|
|
111
112
|
* edits a list column in its cell that is not a field with an editor of one line the API may write,
|
|
112
113
|
* has 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
|
|
113
114
|
* `searchesOf` that do not fit the resources, cards
|
|
114
|
-
* displays of one list that name different photos, a map that cannot show the
|
|
115
|
+
* 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
|
|
115
116
|
* record's position or a map display that cannot find the records in its view, a board whose columns are no filterable enum, a link to an address the app does not have, action inputs that do not fit, or a create page that is not one of `pages`.
|
|
116
117
|
*/
|
|
117
118
|
const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
@@ -131,6 +132,7 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
|
131
132
|
const searches = [...view.saveSearch ? saveSearchErrors(view.saveSearch, view.resource, models, views).map((error) => `saveSearch: ${error}`) : [], ...view.searchesOf ? searchesOfErrors(view.searchesOf, view.resource, models).map((error) => `searchesOf: ${error}`) : []];
|
|
132
133
|
const maps = [...view.layout.flatMap((item) => item.kind === "map" ? mapErrors(item, model).map((error) => `map "${item.title}": ${error}`) : []), ...(view.list?.displays ?? []).flatMap((display) => display.kind === "map" ? mapDisplayErrors(display, model).map((error) => `display "${display.name}": ${error}`) : [])];
|
|
133
134
|
const boards = (view.list?.displays ?? []).flatMap((display) => display.kind === "board" ? boardErrors(display, model).map((error) => `display "${display.name}": ${error}`) : []);
|
|
135
|
+
const chart = view.chart ? chartErrors(view.chart, model).map((error) => `chart: ${error}`) : [];
|
|
134
136
|
return [
|
|
135
137
|
...sensitiveErrors(view, model),
|
|
136
138
|
...unknownFieldErrors(view, model),
|
|
@@ -146,6 +148,7 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
|
146
148
|
...searches,
|
|
147
149
|
...maps,
|
|
148
150
|
...boards,
|
|
151
|
+
...chart,
|
|
149
152
|
...linkErrors(view, model, models, pageNames),
|
|
150
153
|
...inputErrors(view, models),
|
|
151
154
|
...createErrors(view, pageNames)
|
|
@@ -130,6 +130,7 @@ const needs = {
|
|
|
130
130
|
list: "list",
|
|
131
131
|
search: "list",
|
|
132
132
|
facets: "list",
|
|
133
|
+
aggregate: "list",
|
|
133
134
|
series: "list",
|
|
134
135
|
histogram: "list",
|
|
135
136
|
map: "list",
|
|
@@ -363,6 +364,71 @@ const resourcePaths = (access) => {
|
|
|
363
364
|
...auth
|
|
364
365
|
}
|
|
365
366
|
} },
|
|
367
|
+
[`/${entry.name}:aggregate`]: { get: {
|
|
368
|
+
operationId: `aggregate_${entry.name}`,
|
|
369
|
+
tags: tag,
|
|
370
|
+
summary: `Totals per group of ${entry.name} records`,
|
|
371
|
+
description: "Groups the records the filter finds, its own terms on `group_by` included, by the value of a field, or by the hour, day, week, month or year of a date or timestamp field with the empty ones included. Each group has its count and, with `sum` or `average`, that of a numeric field, `null` when no record of the group has a value. Value groups come largest first. An index must serve the grouped field.",
|
|
372
|
+
parameters: [
|
|
373
|
+
query("group_by", "A filterable field", { type: "string" }, true),
|
|
374
|
+
param("filter"),
|
|
375
|
+
query("sum", "A numeric field to add up per group"),
|
|
376
|
+
query("average", "A numeric field to average per group, instead of `sum`"),
|
|
377
|
+
query("limit", "Most value groups to return, default 100, at most 1000", { type: "integer" }),
|
|
378
|
+
query("label", "For a relation to a single-key resource: the field of that resource whose value each group carries as `label`"),
|
|
379
|
+
query("granularity", "For a date group_by: the bucket, default day", {
|
|
380
|
+
type: "string",
|
|
381
|
+
enum: [
|
|
382
|
+
"hour",
|
|
383
|
+
"day",
|
|
384
|
+
"week",
|
|
385
|
+
"month",
|
|
386
|
+
"year"
|
|
387
|
+
]
|
|
388
|
+
}),
|
|
389
|
+
query("range", "For a date group_by: the relative range ending now (default 30d); `all` starts at the earliest row", {
|
|
390
|
+
type: "string",
|
|
391
|
+
enum: [
|
|
392
|
+
"7d",
|
|
393
|
+
"30d",
|
|
394
|
+
"90d",
|
|
395
|
+
"1y",
|
|
396
|
+
"all"
|
|
397
|
+
]
|
|
398
|
+
}),
|
|
399
|
+
query("from", "For a date group_by: the explicit start (ISO 8601); with `to`, replaces range"),
|
|
400
|
+
query("to", "For a date group_by: the exclusive end (ISO 8601), default now"),
|
|
401
|
+
query("time_zone", "For a date group_by: the IANA time zone of the buckets, default UTC")
|
|
402
|
+
],
|
|
403
|
+
responses: {
|
|
404
|
+
200: {
|
|
405
|
+
description: "The groups; a date group's `value` is its bucket in local ISO time",
|
|
406
|
+
content: json({
|
|
407
|
+
type: "object",
|
|
408
|
+
properties: {
|
|
409
|
+
group_by: { type: "string" },
|
|
410
|
+
granularity: { type: "string" },
|
|
411
|
+
time_zone: { type: "string" },
|
|
412
|
+
groups: {
|
|
413
|
+
type: "array",
|
|
414
|
+
items: {
|
|
415
|
+
type: "object",
|
|
416
|
+
properties: {
|
|
417
|
+
value: {},
|
|
418
|
+
label: { type: "string" },
|
|
419
|
+
count: { type: "integer" },
|
|
420
|
+
sum: { type: ["number", "null"] },
|
|
421
|
+
average: { type: ["number", "null"] }
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
})
|
|
427
|
+
},
|
|
428
|
+
400: error("BadRequest"),
|
|
429
|
+
...auth
|
|
430
|
+
}
|
|
431
|
+
} },
|
|
366
432
|
[`/${entry.name}:series`]: { get: {
|
|
367
433
|
operationId: `series_${entry.name}`,
|
|
368
434
|
tags: tag,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { runWrite } from "../write-pipeline.js";
|
|
2
2
|
import { listRecords } from "../list-service.js";
|
|
3
3
|
import { facets, numericHistogram, timeSeries } from "../aggregate-service.js";
|
|
4
|
+
import { aggregate } from "../aggregate-groups.js";
|
|
4
5
|
import { mapRecords } from "../map-service.js";
|
|
5
6
|
import { uploadFile } from "../files/upload.js";
|
|
6
7
|
import { returnPreference } from "./preferences.js";
|
|
@@ -25,6 +26,10 @@ const collectionRoutes = (deps) => {
|
|
|
25
26
|
const { body, warning } = await facets(deps, entry, session, query);
|
|
26
27
|
return c.json(body, 200, warningHeader(warning));
|
|
27
28
|
}
|
|
29
|
+
if (action === "aggregate") {
|
|
30
|
+
const { body, warning } = await aggregate(deps, entry, session, query);
|
|
31
|
+
return c.json(body, 200, warningHeader(warning));
|
|
32
|
+
}
|
|
28
33
|
if (action === "series") return c.json(await timeSeries(deps, entry, session, query));
|
|
29
34
|
if (action === "histogram") return c.json(await numericHistogram(deps, entry, session, query));
|
|
30
35
|
if (action === "map") {
|
package/dist/view-access.js
CHANGED
|
@@ -4,7 +4,7 @@ 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
6
|
import { resolveFieldPath } from "@protobase/layout";
|
|
7
|
-
import { addressErrors, hrefPlaceholders, navRecentErrors, pruneByShape, shapes, tableDisplay } from "@protobase/schema/internal";
|
|
7
|
+
import { addressErrors, chartFields, hrefPlaceholders, navRecentErrors, pruneByShape, shapes, tableDisplay } from "@protobase/schema/internal";
|
|
8
8
|
//#region src/view-access.ts
|
|
9
9
|
const pruneRelated = (item, resource, models) => {
|
|
10
10
|
const { columns, ...rest } = item;
|
|
@@ -218,11 +218,7 @@ const viewShape = (resource, model, models, prompted) => shapes.object({
|
|
|
218
218
|
list: listShape(resource, model, models, prompted),
|
|
219
219
|
filters: shapes.list(widgetShape),
|
|
220
220
|
layout: shapes.list(layoutShape(resource, model, models)),
|
|
221
|
-
chart: shapes.
|
|
222
|
-
field: shapes.field,
|
|
223
|
-
range: shapes.data,
|
|
224
|
-
granularity: shapes.data
|
|
225
|
-
}, { requires: ["field"] }),
|
|
221
|
+
chart: shapes.map((chart) => chartFields(chart).every((name) => Object.hasOwn(model.fields, name)) ? chart : void 0),
|
|
226
222
|
recordMode: shapes.data,
|
|
227
223
|
helpWhen: shapes.data,
|
|
228
224
|
saveFeedback: shapes.data,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { isGroupChart } from "@protobase/schema/internal";
|
|
2
|
+
//#region src/view-chart.ts
|
|
3
|
+
const numericTypes = [
|
|
4
|
+
"integer",
|
|
5
|
+
"bigint",
|
|
6
|
+
"decimal"
|
|
7
|
+
];
|
|
8
|
+
const ungroupable = [
|
|
9
|
+
"date",
|
|
10
|
+
"timestamp",
|
|
11
|
+
"json",
|
|
12
|
+
"file"
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* What is wrong with a list's chart per value of a field: a field to group by that has no values to share out, or one
|
|
16
|
+
* to add up that is no number. The filterable and sensitive checks of every chart are with the other fields'.
|
|
17
|
+
*/
|
|
18
|
+
const chartErrors = (chart, model) => {
|
|
19
|
+
if (!isGroupChart(chart)) return [];
|
|
20
|
+
const fieldOf = (name) => Object.hasOwn(model.fields, name) ? model.fields[name] : void 0;
|
|
21
|
+
const group = fieldOf(chart.groupBy);
|
|
22
|
+
const measured = chart.sum ?? chart.average;
|
|
23
|
+
const measure = measured === void 0 ? void 0 : fieldOf(measured);
|
|
24
|
+
return [
|
|
25
|
+
...!group ? [`groupBy: unknown field "${chart.groupBy}" on ${model.name}`] : [],
|
|
26
|
+
...group && ungroupable.includes(group.type) ? [`groupBy: "${chart.groupBy}" is a ${group.type} field; chart a date with { field, range, granularity }, and group by a field of values`] : [],
|
|
27
|
+
...measured !== void 0 && !measure ? [`unknown field "${measured}" on ${model.name} to add up`] : [],
|
|
28
|
+
...measure && !numericTypes.includes(measure.type) ? [`"${measured}" is ${measure.type}, and only a number can be added up`] : [],
|
|
29
|
+
...chart.type === "donut" && chart.average !== void 0 ? ["a donut shares out a total, and averages add up to none: count or sum instead"] : []
|
|
30
|
+
];
|
|
31
|
+
};
|
|
32
|
+
//#endregion
|
|
33
|
+
export { chartErrors };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
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": {
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@better-auth/passkey": "^1.7.7",
|
|
33
|
-
"@protobase/layout": "0.
|
|
34
|
-
"@protobase/query": "0.
|
|
35
|
-
"@protobase/schema": "0.
|
|
33
|
+
"@protobase/layout": "0.20.0",
|
|
34
|
+
"@protobase/query": "0.20.0",
|
|
35
|
+
"@protobase/schema": "0.20.0",
|
|
36
36
|
"@react-email/render": "^2.1.0",
|
|
37
37
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
38
38
|
"@sentry/core": "^11.7.0",
|