@protobase/query 0.19.15 → 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/group-totals.d.ts +25 -0
- package/dist/group-totals.js +43 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/measure.d.ts +8 -0
- package/dist/measure.js +21 -0
- package/dist/series.d.ts +7 -1
- package/dist/series.js +21 -7
- package/package.json +2 -2
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Db } from "./db.js";
|
|
2
|
+
import { Scope } from "./scope.js";
|
|
3
|
+
import { Measure } from "./measure.js";
|
|
4
|
+
import { CheckedFilter, ResourceModel } from "@protobase/schema";
|
|
5
|
+
//#region src/group-totals.d.ts
|
|
6
|
+
export type GroupTotalsOptions = {
|
|
7
|
+
statementTimeoutMs: number;
|
|
8
|
+
filter?: CheckedFilter;
|
|
9
|
+
measure?: Measure;
|
|
10
|
+
limit?: number;
|
|
11
|
+
scope?: Scope;
|
|
12
|
+
};
|
|
13
|
+
/** One value of the grouped field: how many rows have it and, with a measure, their sum or average. */
|
|
14
|
+
export type GroupTotal = {
|
|
15
|
+
value: unknown;
|
|
16
|
+
count: number;
|
|
17
|
+
sum?: number | null;
|
|
18
|
+
average?: number | null;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Rows per value of `field` under the whole filter, largest first: by the measure when there is one, else by count.
|
|
22
|
+
* Unlike facets, the filter's own terms on `field` apply too, so the groups add up to what the filter finds.
|
|
23
|
+
*/
|
|
24
|
+
export declare const groupTotals: (db: Db, model: ResourceModel, field: string, options: GroupTotalsOptions) => Promise<GroupTotal[]>;
|
|
25
|
+
//#endregion
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { QueryError } from "./fields/query-error.js";
|
|
2
|
+
import { columnRef, requireField } from "./columns.js";
|
|
3
|
+
import { baseQuery } from "./base-query.js";
|
|
4
|
+
import { assertFilterRequirements } from "./filter-requirements.js";
|
|
5
|
+
import { toNumber } from "./numbers.js";
|
|
6
|
+
import { withStatementTimeout } from "./with-statement-timeout.js";
|
|
7
|
+
import { measureSql, measureValue } from "./measure.js";
|
|
8
|
+
import { sql } from "kysely";
|
|
9
|
+
//#region src/group-totals.ts
|
|
10
|
+
const defaultLimit = 100;
|
|
11
|
+
const maxLimit = 1e3;
|
|
12
|
+
/**
|
|
13
|
+
* Rows per value of `field` under the whole filter, largest first: by the measure when there is one, else by count.
|
|
14
|
+
* Unlike facets, the filter's own terms on `field` apply too, so the groups add up to what the filter finds.
|
|
15
|
+
*/
|
|
16
|
+
const groupTotals = async (db, model, field, options) => {
|
|
17
|
+
const target = requireField(model, field, "filterable");
|
|
18
|
+
if (target.type === "json") throw new QueryError("unsupported_field_type", `Cannot group by "${field}", which is json`);
|
|
19
|
+
const limit = options.limit ?? defaultLimit;
|
|
20
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > maxLimit) throw new QueryError("invalid_option", `limit must be between 1 and ${maxLimit}`);
|
|
21
|
+
const { measure } = options;
|
|
22
|
+
const amount = measure ? measureSql(model, measure) : void 0;
|
|
23
|
+
const column = columnRef(target);
|
|
24
|
+
await assertFilterRequirements(db, options.filter, options.scope);
|
|
25
|
+
return withStatementTimeout(db, options.statementTimeoutMs, async (trx) => {
|
|
26
|
+
return (await baseQuery(trx, model, options.filter, options.scope).select([
|
|
27
|
+
sql`${column}`.as("value"),
|
|
28
|
+
sql`count(*)::text`.as("n"),
|
|
29
|
+
...amount ? [sql`${amount}::text`.as("m")] : []
|
|
30
|
+
]).groupBy(column).orderBy(amount ? sql`${amount} desc nulls last` : sql`count(*) desc`).orderBy(column, "asc").limit(limit).execute()).map((row) => {
|
|
31
|
+
const total = {
|
|
32
|
+
value: row.value,
|
|
33
|
+
count: toNumber(row.n)
|
|
34
|
+
};
|
|
35
|
+
return measure ? {
|
|
36
|
+
...total,
|
|
37
|
+
[measure.kind]: measureValue(row.m)
|
|
38
|
+
} : total;
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { groupTotals };
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { estimateCount } from "./estimate-count.js";
|
|
|
7
7
|
import { exactCount } from "./exact-count.js";
|
|
8
8
|
import { countUpTo } from "./count-up-to.js";
|
|
9
9
|
import { FacetCount, FacetOptions, facetCounts } from "./facet-counts.js";
|
|
10
|
+
import { Measure } from "./measure.js";
|
|
11
|
+
import { GroupTotal, GroupTotalsOptions, groupTotals } from "./group-totals.js";
|
|
10
12
|
import { Granularity, SeriesOptions, SeriesPoint, series } from "./series.js";
|
|
11
13
|
import { Histogram, HistogramBucket, histogram } from "./histogram.js";
|
|
12
14
|
import { MapBounds, MapBucket, MapBuckets, MapPosition, cellsAcross, checkMapPosition, mapBuckets, mapCellPixels, maxMapBuckets, maxMapZoom } from "./map-buckets.js";
|
|
@@ -16,4 +18,4 @@ import { IndexKey, TableIndex, TableIndexes, tableIndexes } from "./table-indexe
|
|
|
16
18
|
import { ColumnUse, CoverageQuery, IndexNeed, UncoveredColumn, uncoveredColumns } from "./index-coverage.js";
|
|
17
19
|
import { QueryError, QueryErrorCode } from "./fields/query-error.js";
|
|
18
20
|
import { SeekRequest, seekToPosition } from "./seek-to-position.js";
|
|
19
|
-
export { type ColumnUse, type CompileOptions, type CoverageQuery, type Db, type ExtraColumn, type FacetCount, type FacetOptions, type Granularity, type Histogram, type HistogramBucket, type IndexKey, type IndexNeed, type ListOptions, type ListPlan, type ListResult, type MapBounds, type MapBucket, type MapBuckets, type MapPosition, QueryError, type QueryErrorCode, type RelatedRowsQuery, type Scope, type ScrollMode, type SeekRequest, type SeriesOptions, type SeriesPoint, type TableIndex, type TableIndexes, type UncoveredColumn, buildListQuery, buildRelatedRows, cellsAcross, checkMapPosition, compileFilter, countUpTo, estimateCount, exactCount, facetCounts, histogram, listQuery, mapBuckets, mapCellPixels, maxLimit, maxMapBuckets, maxMapZoom, relatedRows, scrollMode, seekToPosition, series, tableIndexes, uncoveredColumns, withoutField };
|
|
21
|
+
export { type ColumnUse, type CompileOptions, type CoverageQuery, type Db, type ExtraColumn, type FacetCount, type FacetOptions, type Granularity, type GroupTotal, type GroupTotalsOptions, type Histogram, type HistogramBucket, type IndexKey, type IndexNeed, type ListOptions, type ListPlan, type ListResult, type MapBounds, type MapBucket, type MapBuckets, type MapPosition, type Measure, QueryError, type QueryErrorCode, type RelatedRowsQuery, type Scope, type ScrollMode, type SeekRequest, type SeriesOptions, type SeriesPoint, type TableIndex, type TableIndexes, type UncoveredColumn, buildListQuery, buildRelatedRows, cellsAcross, checkMapPosition, compileFilter, countUpTo, estimateCount, exactCount, facetCounts, groupTotals, histogram, listQuery, mapBuckets, mapCellPixels, maxLimit, maxMapBuckets, maxMapZoom, relatedRows, scrollMode, seekToPosition, series, tableIndexes, uncoveredColumns, withoutField };
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { exactCount } from "./exact-count.js";
|
|
|
7
7
|
import { countUpTo } from "./count-up-to.js";
|
|
8
8
|
import { withoutField } from "./prune-filter.js";
|
|
9
9
|
import { facetCounts } from "./facet-counts.js";
|
|
10
|
+
import { groupTotals } from "./group-totals.js";
|
|
10
11
|
import { series } from "./series.js";
|
|
11
12
|
import { histogram } from "./histogram.js";
|
|
12
13
|
import { cellsAcross, checkMapPosition, mapBuckets, mapCellPixels, maxMapBuckets, maxMapZoom } from "./map-buckets.js";
|
|
@@ -14,4 +15,4 @@ import { scrollMode } from "./scroll-mode.js";
|
|
|
14
15
|
import { tableIndexes } from "./table-indexes.js";
|
|
15
16
|
import { uncoveredColumns } from "./index-coverage.js";
|
|
16
17
|
import { seekToPosition } from "./seek-to-position.js";
|
|
17
|
-
export { QueryError, buildListQuery, buildRelatedRows, cellsAcross, checkMapPosition, compileFilter, countUpTo, estimateCount, exactCount, facetCounts, histogram, listQuery, mapBuckets, mapCellPixels, maxLimit, maxMapBuckets, maxMapZoom, relatedRows, scrollMode, seekToPosition, series, tableIndexes, uncoveredColumns, withoutField };
|
|
18
|
+
export { QueryError, buildListQuery, buildRelatedRows, cellsAcross, checkMapPosition, compileFilter, countUpTo, estimateCount, exactCount, facetCounts, groupTotals, histogram, listQuery, mapBuckets, mapCellPixels, maxLimit, maxMapBuckets, maxMapZoom, relatedRows, scrollMode, seekToPosition, series, tableIndexes, uncoveredColumns, withoutField };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ResourceModel } from "@protobase/schema";
|
|
2
|
+
//#region src/measure.d.ts
|
|
3
|
+
/** What a group adds up besides its count: the sum or the average of a numeric field over its rows. */
|
|
4
|
+
export type Measure = {
|
|
5
|
+
kind: 'sum' | 'average';
|
|
6
|
+
field: string;
|
|
7
|
+
};
|
|
8
|
+
//#endregion
|
package/dist/measure.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { QueryError } from "./fields/query-error.js";
|
|
2
|
+
import { columnRef, findField } from "./columns.js";
|
|
3
|
+
import { sql } from "kysely";
|
|
4
|
+
//#region src/measure.ts
|
|
5
|
+
const numericTypes = [
|
|
6
|
+
"integer",
|
|
7
|
+
"bigint",
|
|
8
|
+
"decimal"
|
|
9
|
+
];
|
|
10
|
+
/** The SQL aggregate of a measure; select it `::text`, so a decimal keeps its digits until it is read. */
|
|
11
|
+
const measureSql = (model, measure) => {
|
|
12
|
+
const field = findField(model, measure.field);
|
|
13
|
+
if (!numericTypes.includes(field.type)) throw new QueryError("unsupported_field_type", `A ${measure.kind} needs a numeric field, "${measure.field}" is ${field.type}`);
|
|
14
|
+
if (field.sensitive) throw new QueryError("unsupported_field_type", `"${measure.field}" is sensitive, so it cannot be added up`);
|
|
15
|
+
const column = columnRef(field);
|
|
16
|
+
return measure.kind === "sum" ? sql`sum(${column})` : sql`avg(${column})`;
|
|
17
|
+
};
|
|
18
|
+
/** A measure's value: `null` when no row of the group has a value to add up. */
|
|
19
|
+
const measureValue = (raw) => raw === null || raw === void 0 ? null : Number(raw);
|
|
20
|
+
//#endregion
|
|
21
|
+
export { measureSql, measureValue };
|
package/dist/series.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Db } from "./db.js";
|
|
2
2
|
import { Scope } from "./scope.js";
|
|
3
|
+
import { Measure } from "./measure.js";
|
|
3
4
|
import { CheckedFilter, ResourceModel } from "@protobase/schema";
|
|
4
5
|
//#region src/series.d.ts
|
|
5
6
|
declare const approxMs: {
|
|
@@ -19,15 +20,20 @@ export type SeriesOptions = {
|
|
|
19
20
|
timeZone?: string;
|
|
20
21
|
filter?: CheckedFilter;
|
|
21
22
|
scope?: Scope;
|
|
23
|
+
/** Adds up a numeric field per bucket besides the count. */
|
|
24
|
+
measure?: Measure;
|
|
22
25
|
};
|
|
23
26
|
export type SeriesPoint = {
|
|
24
27
|
bucket: string;
|
|
25
28
|
count: number;
|
|
29
|
+
sum?: number | null;
|
|
30
|
+
average?: number | null;
|
|
26
31
|
};
|
|
27
32
|
/**
|
|
28
33
|
* Row counts per calendar bucket, in `timeZone`, with empty buckets present. `to` is exclusive.
|
|
29
34
|
* Buckets are returned as local ISO strings without offset. `date` fields ignore the time zone;
|
|
30
|
-
* `timestamp` columns without a zone are read in the session time zone.
|
|
35
|
+
* `timestamp` columns without a zone are read in the session time zone. With a measure, a bucket without a row to add
|
|
36
|
+
* up has `null` for it.
|
|
31
37
|
*/
|
|
32
38
|
export declare const series: (db: Db, model: ResourceModel, field: string, options: SeriesOptions) => Promise<SeriesPoint[]>;
|
|
33
39
|
//#endregion
|
package/dist/series.js
CHANGED
|
@@ -3,6 +3,7 @@ import { columnRef, requireField } from "./columns.js";
|
|
|
3
3
|
import { baseQuery } from "./base-query.js";
|
|
4
4
|
import { assertFilterRequirements } from "./filter-requirements.js";
|
|
5
5
|
import { toNumber } from "./numbers.js";
|
|
6
|
+
import { measureSql, measureValue } from "./measure.js";
|
|
6
7
|
import { rangeBound } from "./range-bound.js";
|
|
7
8
|
import { sql } from "kysely";
|
|
8
9
|
//#region src/series.ts
|
|
@@ -25,7 +26,8 @@ const checkTimeZone = (timeZone) => {
|
|
|
25
26
|
/**
|
|
26
27
|
* Row counts per calendar bucket, in `timeZone`, with empty buckets present. `to` is exclusive.
|
|
27
28
|
* Buckets are returned as local ISO strings without offset. `date` fields ignore the time zone;
|
|
28
|
-
* `timestamp` columns without a zone are read in the session time zone.
|
|
29
|
+
* `timestamp` columns without a zone are read in the session time zone. With a measure, a bucket without a row to add
|
|
30
|
+
* up has `null` for it.
|
|
29
31
|
*/
|
|
30
32
|
const series = async (db, model, field, options) => {
|
|
31
33
|
const target = requireField(model, field, "filterable");
|
|
@@ -38,6 +40,8 @@ const series = async (db, model, field, options) => {
|
|
|
38
40
|
const spanMs = new Date(to).getTime() - new Date(from).getTime();
|
|
39
41
|
if (!(spanMs > 0)) throw new QueryError("invalid_option", "range.to must be after range.from");
|
|
40
42
|
if (spanMs / approxMs[options.granularity] > maxBuckets) throw new QueryError("invalid_option", `Range spans more than ${maxBuckets} buckets`);
|
|
43
|
+
const { measure } = options;
|
|
44
|
+
const amount = measure ? measureSql(model, measure) : void 0;
|
|
41
45
|
await assertFilterRequirements(db, options.filter, options.scope);
|
|
42
46
|
const col = columnRef(target);
|
|
43
47
|
const unit = sql.lit(options.granularity);
|
|
@@ -46,18 +50,28 @@ const series = async (db, model, field, options) => {
|
|
|
46
50
|
const bound = sql.raw(isDate ? "date" : "timestamptz");
|
|
47
51
|
const local = (value) => isDate ? sql`${value}::timestamp` : sql`(${value}::timestamptz at time zone ${timeZone}::text)`;
|
|
48
52
|
const laterBucket = sql`date_trunc(${unit}, ${local(to)} - interval '1 microsecond')`;
|
|
49
|
-
const counts = baseQuery(db, model, options.filter, options.scope).where(sql`${col} >= ${from}::${bound} and ${col} < ${to}::${bound}`).select([
|
|
53
|
+
const counts = baseQuery(db, model, options.filter, options.scope).where(sql`${col} >= ${from}::${bound} and ${col} < ${to}::${bound}`).select([
|
|
54
|
+
sql`date_trunc(${unit}, ${isDate ? sql`${col}::timestamp` : local(col)})`.as("bucket"),
|
|
55
|
+
sql`count(*)::text`.as("n"),
|
|
56
|
+
...amount ? [sql`${amount}::text`.as("m")] : []
|
|
57
|
+
]).groupBy(sql`1`);
|
|
50
58
|
return (await sql`
|
|
51
59
|
with b as (
|
|
52
60
|
select generate_series(date_trunc(${unit}, ${local(from)}), ${laterBucket}, ${step}) as bucket
|
|
53
61
|
), c as (${counts})
|
|
54
|
-
select to_char(b.bucket, 'YYYY-MM-DD"T"HH24:MI:SS') as bucket, coalesce(c.n, '0') as n
|
|
62
|
+
select to_char(b.bucket, 'YYYY-MM-DD"T"HH24:MI:SS') as bucket, coalesce(c.n, '0') as n${amount ? sql`, c.m` : sql``}
|
|
55
63
|
from b left join c on c.bucket = b.bucket
|
|
56
64
|
order by b.bucket
|
|
57
|
-
`.execute(db)).rows.map((row) =>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
`.execute(db)).rows.map((row) => {
|
|
66
|
+
const point = {
|
|
67
|
+
bucket: row.bucket,
|
|
68
|
+
count: toNumber(row.n)
|
|
69
|
+
};
|
|
70
|
+
return measure ? {
|
|
71
|
+
...point,
|
|
72
|
+
[measure.kind]: measureValue(row.m)
|
|
73
|
+
} : point;
|
|
74
|
+
});
|
|
61
75
|
};
|
|
62
76
|
//#endregion
|
|
63
77
|
export { series };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/query",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Kysely list, filter, count and aggregate queries over Protobase resources.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"access": "public"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@protobase/schema": "0.
|
|
32
|
+
"@protobase/schema": "0.20.0",
|
|
33
33
|
"kysely": "^0.29.6"
|
|
34
34
|
}
|
|
35
35
|
}
|