@protobase/query 0.22.5 → 0.22.7
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/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/series-buckets.d.ts +20 -0
- package/dist/series-buckets.js +57 -0
- package/dist/series.d.ts +4 -16
- package/dist/series.js +2 -36
- package/dist/split-series.d.ts +26 -0
- package/dist/split-series.js +71 -0
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,9 @@ import { countUpTo } from "./count-up-to.js";
|
|
|
9
9
|
import { FacetCount, FacetOptions, facetCounts } from "./facet-counts.js";
|
|
10
10
|
import { Measure } from "./measure.js";
|
|
11
11
|
import { GroupTotal, GroupTotalsOptions, groupTotals } from "./group-totals.js";
|
|
12
|
-
import { Granularity
|
|
12
|
+
import { Granularity } from "./series-buckets.js";
|
|
13
|
+
import { SeriesOptions, SeriesPoint, series } from "./series.js";
|
|
14
|
+
import { SplitPoint, SplitSeriesOptions, splitSeries } from "./split-series.js";
|
|
13
15
|
import { Histogram, HistogramBucket, histogram } from "./histogram.js";
|
|
14
16
|
import { MapBounds, MapBucket, MapBuckets, MapPosition, cellsAcross, checkMapPosition, mapBuckets, mapCellPixels, maxMapBuckets, maxMapZoom } from "./map-buckets.js";
|
|
15
17
|
import { withoutField } from "./prune-filter.js";
|
|
@@ -18,4 +20,4 @@ import { IndexKey, TableIndex, TableIndexes, tableIndexes } from "./table-indexe
|
|
|
18
20
|
import { ColumnUse, CoverageQuery, IndexNeed, UncoveredColumn, uncoveredColumns } from "./index-coverage.js";
|
|
19
21
|
import { QueryError, QueryErrorCode } from "./fields/query-error.js";
|
|
20
22
|
import { SeekRequest, seekToPosition } from "./seek-to-position.js";
|
|
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 };
|
|
23
|
+
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 SplitPoint, type SplitSeriesOptions, 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, splitSeries, tableIndexes, uncoveredColumns, withoutField };
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,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
10
|
import { groupTotals } from "./group-totals.js";
|
|
11
|
+
import { splitSeries } from "./split-series.js";
|
|
11
12
|
import { series } from "./series.js";
|
|
12
13
|
import { histogram } from "./histogram.js";
|
|
13
14
|
import { cellsAcross, checkMapPosition, mapBuckets, mapCellPixels, maxMapBuckets, maxMapZoom } from "./map-buckets.js";
|
|
@@ -15,4 +16,4 @@ import { scrollMode } from "./scroll-mode.js";
|
|
|
15
16
|
import { tableIndexes } from "./table-indexes.js";
|
|
16
17
|
import { uncoveredColumns } from "./index-coverage.js";
|
|
17
18
|
import { seekToPosition } from "./seek-to-position.js";
|
|
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 };
|
|
19
|
+
export { QueryError, buildListQuery, buildRelatedRows, cellsAcross, checkMapPosition, compileFilter, countUpTo, estimateCount, exactCount, facetCounts, groupTotals, histogram, listQuery, mapBuckets, mapCellPixels, maxLimit, maxMapBuckets, maxMapZoom, relatedRows, scrollMode, seekToPosition, series, splitSeries, tableIndexes, uncoveredColumns, withoutField };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
import { ResourceModel } from "@protobase/schema";
|
|
3
|
+
//#region src/series-buckets.d.ts
|
|
4
|
+
declare const approxMs: {
|
|
5
|
+
hour: number;
|
|
6
|
+
day: number;
|
|
7
|
+
week: number;
|
|
8
|
+
month: number;
|
|
9
|
+
year: number;
|
|
10
|
+
};
|
|
11
|
+
export type Granularity = keyof typeof approxMs;
|
|
12
|
+
export type BucketOptions = {
|
|
13
|
+
range: {
|
|
14
|
+
from: string | Date;
|
|
15
|
+
to: string | Date;
|
|
16
|
+
};
|
|
17
|
+
granularity: Granularity;
|
|
18
|
+
timeZone?: string;
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { QueryError } from "./fields/query-error.js";
|
|
2
|
+
import { columnRef, requireField } from "./columns.js";
|
|
3
|
+
import { rangeBound } from "./range-bound.js";
|
|
4
|
+
import { sql } from "kysely";
|
|
5
|
+
//#region src/series-buckets.ts
|
|
6
|
+
const maxBuckets = 1e4;
|
|
7
|
+
const approxMs = {
|
|
8
|
+
hour: 36e5,
|
|
9
|
+
day: 864e5,
|
|
10
|
+
week: 6048e5,
|
|
11
|
+
month: 26298e5,
|
|
12
|
+
year: 315576e5
|
|
13
|
+
};
|
|
14
|
+
const checkTimeZone = (timeZone) => {
|
|
15
|
+
try {
|
|
16
|
+
new Intl.DateTimeFormat("en", { timeZone });
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error instanceof RangeError) throw new QueryError("invalid_option", `Unknown time zone "${timeZone}"`);
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Validates a date or timestamp field, range, granularity and time zone, and builds the SQL pieces that bucket the
|
|
24
|
+
* field over time. Shared by `series` and `splitSeries`.
|
|
25
|
+
*/
|
|
26
|
+
const seriesBuckets = (model, field, options) => {
|
|
27
|
+
const target = requireField(model, field, "filterable");
|
|
28
|
+
if (target.type !== "date" && target.type !== "timestamp") throw new QueryError("unsupported_field_type", `Series needs a date or timestamp field, "${field}" is ${target.type}`);
|
|
29
|
+
if (!Object.hasOwn(approxMs, options.granularity)) throw new QueryError("invalid_option", "Unknown granularity");
|
|
30
|
+
const timeZone = options.timeZone ?? "UTC";
|
|
31
|
+
checkTimeZone(timeZone);
|
|
32
|
+
const from = rangeBound(options.range.from, target);
|
|
33
|
+
const to = rangeBound(options.range.to, target);
|
|
34
|
+
const spanMs = new Date(to).getTime() - new Date(from).getTime();
|
|
35
|
+
if (!(spanMs > 0)) throw new QueryError("invalid_option", "range.to must be after range.from");
|
|
36
|
+
if (spanMs / approxMs[options.granularity] > maxBuckets) throw new QueryError("invalid_option", `Range spans more than ${maxBuckets} buckets`);
|
|
37
|
+
const col = columnRef(target);
|
|
38
|
+
const unit = sql.lit(options.granularity);
|
|
39
|
+
const step = sql`${sql.lit(`1 ${options.granularity}`)}::interval`;
|
|
40
|
+
const isDate = target.type === "date";
|
|
41
|
+
const bound = sql.raw(isDate ? "date" : "timestamptz");
|
|
42
|
+
const local = (value) => isDate ? sql`${value}::timestamp` : sql`(${value}::timestamptz at time zone ${timeZone}::text)`;
|
|
43
|
+
return {
|
|
44
|
+
target,
|
|
45
|
+
col,
|
|
46
|
+
from,
|
|
47
|
+
to,
|
|
48
|
+
unit,
|
|
49
|
+
step,
|
|
50
|
+
bound,
|
|
51
|
+
local,
|
|
52
|
+
laterBucket: sql`date_trunc(${unit}, ${local(to)} - interval '1 microsecond')`,
|
|
53
|
+
isDate
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
export { seriesBuckets };
|
package/dist/series.d.ts
CHANGED
|
@@ -1,23 +1,10 @@
|
|
|
1
1
|
import { Db } from "./db.js";
|
|
2
2
|
import { Scope } from "./scope.js";
|
|
3
3
|
import { Measure } from "./measure.js";
|
|
4
|
+
import { BucketOptions, Granularity } from "./series-buckets.js";
|
|
4
5
|
import { CheckedFilter, ResourceModel } from "@protobase/schema";
|
|
5
6
|
//#region src/series.d.ts
|
|
6
|
-
|
|
7
|
-
hour: number;
|
|
8
|
-
day: number;
|
|
9
|
-
week: number;
|
|
10
|
-
month: number;
|
|
11
|
-
year: number;
|
|
12
|
-
};
|
|
13
|
-
export type Granularity = keyof typeof approxMs;
|
|
14
|
-
export type SeriesOptions = {
|
|
15
|
-
range: {
|
|
16
|
-
from: string | Date;
|
|
17
|
-
to: string | Date;
|
|
18
|
-
};
|
|
19
|
-
granularity: Granularity;
|
|
20
|
-
timeZone?: string;
|
|
7
|
+
export type SeriesOptions = BucketOptions & {
|
|
21
8
|
filter?: CheckedFilter;
|
|
22
9
|
scope?: Scope;
|
|
23
10
|
/** Adds up a numeric field per bucket besides the count. */
|
|
@@ -36,4 +23,5 @@ export type SeriesPoint = {
|
|
|
36
23
|
* up has `null` for it.
|
|
37
24
|
*/
|
|
38
25
|
export declare const series: (db: Db, model: ResourceModel, field: string, options: SeriesOptions) => Promise<SeriesPoint[]>;
|
|
39
|
-
//#endregion
|
|
26
|
+
//#endregion
|
|
27
|
+
export type { Granularity };
|
package/dist/series.js
CHANGED
|
@@ -1,28 +1,10 @@
|
|
|
1
|
-
import { QueryError } from "./fields/query-error.js";
|
|
2
|
-
import { columnRef, requireField } from "./columns.js";
|
|
3
1
|
import { baseQuery } from "./base-query.js";
|
|
4
2
|
import { assertFilterRequirements } from "./filter-requirements.js";
|
|
5
3
|
import { toNumber } from "./numbers.js";
|
|
6
4
|
import { measureSql, measureValue } from "./measure.js";
|
|
7
|
-
import {
|
|
5
|
+
import { seriesBuckets } from "./series-buckets.js";
|
|
8
6
|
import { sql } from "kysely";
|
|
9
7
|
//#region src/series.ts
|
|
10
|
-
const maxBuckets = 1e4;
|
|
11
|
-
const approxMs = {
|
|
12
|
-
hour: 36e5,
|
|
13
|
-
day: 864e5,
|
|
14
|
-
week: 6048e5,
|
|
15
|
-
month: 26298e5,
|
|
16
|
-
year: 315576e5
|
|
17
|
-
};
|
|
18
|
-
const checkTimeZone = (timeZone) => {
|
|
19
|
-
try {
|
|
20
|
-
new Intl.DateTimeFormat("en", { timeZone });
|
|
21
|
-
} catch (error) {
|
|
22
|
-
if (error instanceof RangeError) throw new QueryError("invalid_option", `Unknown time zone "${timeZone}"`);
|
|
23
|
-
throw error;
|
|
24
|
-
}
|
|
25
|
-
};
|
|
26
8
|
/**
|
|
27
9
|
* Row counts per calendar bucket, in `timeZone`, with empty buckets present. `to` is exclusive.
|
|
28
10
|
* Buckets are returned as local ISO strings without offset. `date` fields ignore the time zone;
|
|
@@ -30,26 +12,10 @@ const checkTimeZone = (timeZone) => {
|
|
|
30
12
|
* up has `null` for it.
|
|
31
13
|
*/
|
|
32
14
|
const series = async (db, model, field, options) => {
|
|
33
|
-
const
|
|
34
|
-
if (target.type !== "date" && target.type !== "timestamp") throw new QueryError("unsupported_field_type", `Series needs a date or timestamp field, "${field}" is ${target.type}`);
|
|
35
|
-
if (!Object.hasOwn(approxMs, options.granularity)) throw new QueryError("invalid_option", "Unknown granularity");
|
|
36
|
-
const timeZone = options.timeZone ?? "UTC";
|
|
37
|
-
checkTimeZone(timeZone);
|
|
38
|
-
const from = rangeBound(options.range.from, target);
|
|
39
|
-
const to = rangeBound(options.range.to, target);
|
|
40
|
-
const spanMs = new Date(to).getTime() - new Date(from).getTime();
|
|
41
|
-
if (!(spanMs > 0)) throw new QueryError("invalid_option", "range.to must be after range.from");
|
|
42
|
-
if (spanMs / approxMs[options.granularity] > maxBuckets) throw new QueryError("invalid_option", `Range spans more than ${maxBuckets} buckets`);
|
|
15
|
+
const { col, from, to, unit, step, bound, local, laterBucket, isDate } = seriesBuckets(model, field, options);
|
|
43
16
|
const { measure } = options;
|
|
44
17
|
const amount = measure ? measureSql(model, measure) : void 0;
|
|
45
18
|
await assertFilterRequirements(db, options.filter, options.scope);
|
|
46
|
-
const col = columnRef(target);
|
|
47
|
-
const unit = sql.lit(options.granularity);
|
|
48
|
-
const step = sql`${sql.lit(`1 ${options.granularity}`)}::interval`;
|
|
49
|
-
const isDate = target.type === "date";
|
|
50
|
-
const bound = sql.raw(isDate ? "date" : "timestamptz");
|
|
51
|
-
const local = (value) => isDate ? sql`${value}::timestamp` : sql`(${value}::timestamptz at time zone ${timeZone}::text)`;
|
|
52
|
-
const laterBucket = sql`date_trunc(${unit}, ${local(to)} - interval '1 microsecond')`;
|
|
53
19
|
const counts = baseQuery(db, model, options.filter, options.scope).where(sql`${col} >= ${from}::${bound} and ${col} < ${to}::${bound}`).select([
|
|
54
20
|
sql`date_trunc(${unit}, ${isDate ? sql`${col}::timestamp` : local(col)})`.as("bucket"),
|
|
55
21
|
sql`count(*)::text`.as("n"),
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Db } from "./db.js";
|
|
2
|
+
import { SeriesOptions } from "./series.js";
|
|
3
|
+
import { ResourceModel } from "@protobase/schema";
|
|
4
|
+
//#region src/split-series.d.ts
|
|
5
|
+
export type SplitSeriesOptions = SeriesOptions & {
|
|
6
|
+
limit?: number;
|
|
7
|
+
};
|
|
8
|
+
/** The rows of one split value in one bucket; buckets and values without rows have no point. */
|
|
9
|
+
export type SplitPoint = {
|
|
10
|
+
bucket: string;
|
|
11
|
+
split: unknown;
|
|
12
|
+
count: number;
|
|
13
|
+
sum?: number | null;
|
|
14
|
+
average?: number | null;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The rows of each calendar bucket of `field` (as `series` buckets it) split by the values of another field.
|
|
18
|
+
* Only the `limit` largest values over the whole range are kept, ranked by the sum with a sum measure and by row count
|
|
19
|
+
* otherwise (averages of averages add up to nothing); `splits` lists them in rank order, and a point is present only
|
|
20
|
+
* where a value has rows. What the other values hold is the bucket's total from `series` minus its points.
|
|
21
|
+
*/
|
|
22
|
+
export declare const splitSeries: (db: Db, model: ResourceModel, field: string, split: string, options: SplitSeriesOptions) => Promise<{
|
|
23
|
+
splits: unknown[];
|
|
24
|
+
points: SplitPoint[];
|
|
25
|
+
}>;
|
|
26
|
+
//#endregion
|
|
@@ -0,0 +1,71 @@
|
|
|
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 { measureSql, measureValue } from "./measure.js";
|
|
7
|
+
import { seriesBuckets } from "./series-buckets.js";
|
|
8
|
+
import { sql } from "kysely";
|
|
9
|
+
//#region src/split-series.ts
|
|
10
|
+
const defaultLimit = 8;
|
|
11
|
+
const maxLimit = 100;
|
|
12
|
+
const unsplittable = [
|
|
13
|
+
"date",
|
|
14
|
+
"timestamp",
|
|
15
|
+
"json"
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* The rows of each calendar bucket of `field` (as `series` buckets it) split by the values of another field.
|
|
19
|
+
* Only the `limit` largest values over the whole range are kept, ranked by the sum with a sum measure and by row count
|
|
20
|
+
* otherwise (averages of averages add up to nothing); `splits` lists them in rank order, and a point is present only
|
|
21
|
+
* where a value has rows. What the other values hold is the bucket's total from `series` minus its points.
|
|
22
|
+
*/
|
|
23
|
+
const splitSeries = async (db, model, field, split, options) => {
|
|
24
|
+
const { col, from, to, unit, bound, local, isDate } = seriesBuckets(model, field, options);
|
|
25
|
+
const splitField = requireField(model, split, "filterable");
|
|
26
|
+
if (unsplittable.includes(splitField.type)) throw new QueryError("unsupported_field_type", `Cannot split by "${split}", which is ${splitField.type}`);
|
|
27
|
+
if (splitField.sensitive) throw new QueryError("unsupported_field_type", `"${split}" is sensitive, so it cannot be split by`);
|
|
28
|
+
if (split === field) throw new QueryError("invalid_option", "A series cannot be split by its own field");
|
|
29
|
+
const limit = options.limit ?? defaultLimit;
|
|
30
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > maxLimit) throw new QueryError("invalid_option", `limit must be between 1 and ${maxLimit}`);
|
|
31
|
+
const { measure } = options;
|
|
32
|
+
const amount = measure ? measureSql(model, measure) : void 0;
|
|
33
|
+
await assertFilterRequirements(db, options.filter, options.scope);
|
|
34
|
+
const splitCol = columnRef(splitField);
|
|
35
|
+
const bucketSql = sql`to_char(date_trunc(${unit}, ${isDate ? sql`${col}::timestamp` : local(col)}), 'YYYY-MM-DD"T"HH24:MI:SS')`;
|
|
36
|
+
const parts = baseQuery(db, model, options.filter, options.scope).where(sql`${col} >= ${from}::${bound} and ${col} < ${to}::${bound}`).select([
|
|
37
|
+
bucketSql.as("bucket"),
|
|
38
|
+
sql`${splitCol}`.as("split"),
|
|
39
|
+
sql`count(*)`.as("n"),
|
|
40
|
+
...amount ? [sql`${amount}`.as("m")] : []
|
|
41
|
+
]).groupBy([sql`1`, sql`2`]);
|
|
42
|
+
const largest = measure?.kind === "sum" ? sql`sum(m::numeric) desc nulls last` : sql`sum(n::bigint) desc`;
|
|
43
|
+
const result = await sql`
|
|
44
|
+
with c as (${parts}), r as (
|
|
45
|
+
select split, row_number() over (order by ${largest}, split asc) as rank
|
|
46
|
+
from c group by split order by rank limit ${sql.lit(limit)}
|
|
47
|
+
)
|
|
48
|
+
select c.bucket, c.split, r.rank::text as rank, c.n::text as n${amount ? sql`, c.m::text as m` : sql``}
|
|
49
|
+
from c join r on c.split is not distinct from r.split
|
|
50
|
+
order by c.bucket, r.rank
|
|
51
|
+
`.execute(db);
|
|
52
|
+
const byRank = /* @__PURE__ */ new Map();
|
|
53
|
+
const points = result.rows.map((row) => {
|
|
54
|
+
byRank.set(toNumber(row.rank), row.split);
|
|
55
|
+
const point = {
|
|
56
|
+
bucket: row.bucket,
|
|
57
|
+
split: row.split,
|
|
58
|
+
count: toNumber(row.n)
|
|
59
|
+
};
|
|
60
|
+
return measure ? {
|
|
61
|
+
...point,
|
|
62
|
+
[measure.kind]: measureValue(row.m)
|
|
63
|
+
} : point;
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
splits: [...byRank].sort(([a], [b]) => a - b).map(([, value]) => value),
|
|
67
|
+
points
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
//#endregion
|
|
71
|
+
export { splitSeries };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/query",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.7",
|
|
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.22.
|
|
32
|
+
"@protobase/schema": "0.22.7",
|
|
33
33
|
"kysely": "^0.29.6"
|
|
34
34
|
}
|
|
35
35
|
}
|