@protobase/query 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/dist/anchor-positions.js +37 -0
- package/dist/base-query.js +26 -0
- package/dist/columns.js +26 -0
- package/dist/compile-compare.js +25 -0
- package/dist/compile-filter.d.ts +10 -0
- package/dist/compile-filter.js +35 -0
- package/dist/compile-text-match.js +48 -0
- package/dist/cursor.js +52 -0
- package/dist/db.d.ts +5 -0
- package/dist/db.js +5 -0
- package/dist/estimate-count.d.ts +10 -0
- package/dist/estimate-count.js +35 -0
- package/dist/exact-count.d.ts +7 -0
- package/dist/exact-count.js +13 -0
- package/dist/explain-guard.d.ts +21 -0
- package/dist/explain-guard.js +42 -0
- package/dist/explain.js +10 -0
- package/dist/facet-counts.d.ts +16 -0
- package/dist/facet-counts.js +27 -0
- package/dist/field-values.js +36 -0
- package/dist/fields/bigint.js +13 -0
- package/dist/fields/boolean.js +8 -0
- package/dist/fields/country.js +5 -0
- package/dist/fields/currency.js +5 -0
- package/dist/fields/date.js +11 -0
- package/dist/fields/decimal.js +9 -0
- package/dist/fields/enum.js +9 -0
- package/dist/fields/integer.js +9 -0
- package/dist/fields/json.js +15 -0
- package/dist/fields/query-error.d.ts +8 -0
- package/dist/fields/query-error.js +12 -0
- package/dist/fields/relation.js +10 -0
- package/dist/fields/sql-common.js +30 -0
- package/dist/fields/text.js +10 -0
- package/dist/fields/timestamp.js +10 -0
- package/dist/fields/uuid.js +10 -0
- package/dist/filter-requirements.js +15 -0
- package/dist/histogram.d.ts +17 -0
- package/dist/histogram.js +57 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/dist/list-query.d.ts +40 -0
- package/dist/list-query.js +61 -0
- package/dist/numbers.js +5 -0
- package/dist/order.js +14 -0
- package/dist/prune-filter.js +26 -0
- package/dist/range-bound.js +15 -0
- package/dist/scope.d.ts +17 -0
- package/dist/scope.js +17 -0
- package/dist/scroll-anchors.js +68 -0
- package/dist/scroll-exact.js +27 -0
- package/dist/scroll-mode.d.ts +4 -0
- package/dist/scroll-mode.js +10 -0
- package/dist/scroll-stats.js +27 -0
- package/dist/seek-to-position.d.ts +20 -0
- package/dist/seek-to-position.js +30 -0
- package/dist/seek.js +52 -0
- package/dist/series.d.ts +33 -0
- package/dist/series.js +63 -0
- package/dist/sort.d.ts +8 -0
- package/dist/sort.js +33 -0
- package/dist/with-statement-timeout.js +32 -0
- package/package.json +31 -3
- package/README.md +0 -3
|
@@ -0,0 +1,27 @@
|
|
|
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 { withoutField } from "./prune-filter.js";
|
|
7
|
+
import { withStatementTimeout } from "./with-statement-timeout.js";
|
|
8
|
+
import { sql } from "kysely";
|
|
9
|
+
//#region src/facet-counts.ts
|
|
10
|
+
const defaultLimit = 100;
|
|
11
|
+
const maxLimit = 1e3;
|
|
12
|
+
/** Counts per value of `field`, with every filter applied except the ones on `field` itself. */
|
|
13
|
+
const facetCounts = async (db, model, field, filter, options) => {
|
|
14
|
+
const column = columnRef(requireField(model, field, "filterable"));
|
|
15
|
+
const limit = options.limit ?? defaultLimit;
|
|
16
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > maxLimit) throw new QueryError("invalid_option", `limit must be between 1 and ${maxLimit}`);
|
|
17
|
+
const others = withoutField(filter, field);
|
|
18
|
+
await assertFilterRequirements(db, others, options.scope);
|
|
19
|
+
return withStatementTimeout(db, options.statementTimeoutMs, async (trx) => {
|
|
20
|
+
return (await baseQuery(trx, model, others, options.scope).select([sql`${column}`.as("value"), sql`count(*)::text`.as("n")]).groupBy(column).orderBy(sql`count(*)`, "desc").orderBy(column, "asc").limit(limit).execute()).map((row) => ({
|
|
21
|
+
value: row.value,
|
|
22
|
+
count: toNumber(row.n)
|
|
23
|
+
}));
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
//#endregion
|
|
27
|
+
export { facetCounts };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { QueryError } from "./fields/query-error.js";
|
|
2
|
+
import { toParam } from "./fields/text.js";
|
|
3
|
+
import { toParam as toParam$1 } from "./fields/bigint.js";
|
|
4
|
+
import { toParam as toParam$2 } from "./fields/boolean.js";
|
|
5
|
+
import { toParam as toParam$3 } from "./fields/country.js";
|
|
6
|
+
import { toParam as toParam$4 } from "./fields/currency.js";
|
|
7
|
+
import { toParam as toParam$5 } from "./fields/date.js";
|
|
8
|
+
import { toParam as toParam$6 } from "./fields/decimal.js";
|
|
9
|
+
import { toParam as toParam$7 } from "./fields/enum.js";
|
|
10
|
+
import { toParam as toParam$8 } from "./fields/integer.js";
|
|
11
|
+
import { toParam as toParam$9 } from "./fields/relation.js";
|
|
12
|
+
import { toParam as toParam$10 } from "./fields/timestamp.js";
|
|
13
|
+
import { toParam as toParam$11 } from "./fields/uuid.js";
|
|
14
|
+
//#region src/field-values.ts
|
|
15
|
+
const byType = {
|
|
16
|
+
text: toParam,
|
|
17
|
+
integer: toParam$8,
|
|
18
|
+
bigint: toParam$1,
|
|
19
|
+
decimal: toParam$6,
|
|
20
|
+
boolean: toParam$2,
|
|
21
|
+
date: toParam$5,
|
|
22
|
+
timestamp: toParam$10,
|
|
23
|
+
uuid: toParam$11,
|
|
24
|
+
currency: toParam$4,
|
|
25
|
+
country: toParam$3,
|
|
26
|
+
relation: toParam$9,
|
|
27
|
+
enum: toParam$7
|
|
28
|
+
};
|
|
29
|
+
/** json and file fields have no scalar comparison; the checker rejects them before they reach here. */
|
|
30
|
+
const paramFor = (value, field) => {
|
|
31
|
+
const convert = byType[field.type];
|
|
32
|
+
if (!convert) throw new QueryError("unsupported_field_type", `Field "${field.name}" (${field.type}) cannot be compared`);
|
|
33
|
+
return convert(value, field);
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { paramFor };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { invalidValue, numericText } from "./sql-common.js";
|
|
2
|
+
//#region src/fields/bigint.ts
|
|
3
|
+
const int64Max = 2n ** 63n - 1n;
|
|
4
|
+
/** Sent as text so no precision is lost. */
|
|
5
|
+
const toParam = (value, field) => {
|
|
6
|
+
const text = numericText(value, field, "a 64-bit integer");
|
|
7
|
+
if (!/^-?\d{1,19}$/.test(text)) throw invalidValue(field, "a 64-bit integer");
|
|
8
|
+
const parsed = BigInt(text);
|
|
9
|
+
if (parsed > int64Max || parsed < -int64Max - 1n) throw invalidValue(field, "a 64-bit integer");
|
|
10
|
+
return text;
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { toParam };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { invalidValue, nowExpression, stringValue } from "./sql-common.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
//#region src/fields/date.ts
|
|
4
|
+
const toParam = (value, field) => {
|
|
5
|
+
if (value.kind === "now") return sql`(${nowExpression(value)})::date`;
|
|
6
|
+
const text = stringValue(value, field, "a date (YYYY-MM-DD)");
|
|
7
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(text) || Number.isNaN(Date.parse(text))) throw invalidValue(field, "a date (YYYY-MM-DD)");
|
|
8
|
+
return text;
|
|
9
|
+
};
|
|
10
|
+
//#endregion
|
|
11
|
+
export { toParam };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { invalidValue, numericText } from "./sql-common.js";
|
|
2
|
+
//#region src/fields/decimal.ts
|
|
3
|
+
const toParam = (value, field) => {
|
|
4
|
+
const text = numericText(value, field, "a decimal number");
|
|
5
|
+
if (!/^-?\d+(\.\d+)?$/.test(text)) throw invalidValue(field, "a decimal number");
|
|
6
|
+
return text;
|
|
7
|
+
};
|
|
8
|
+
//#endregion
|
|
9
|
+
export { toParam };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { invalidValue, stringValue } from "./sql-common.js";
|
|
2
|
+
//#region src/fields/enum.ts
|
|
3
|
+
const toParam = (value, field) => {
|
|
4
|
+
const text = stringValue(value, field, "a string");
|
|
5
|
+
if (field.enumValues && !field.enumValues.includes(text)) throw invalidValue(field, "one of its enum values");
|
|
6
|
+
return text;
|
|
7
|
+
};
|
|
8
|
+
//#endregion
|
|
9
|
+
export { toParam };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { invalidValue, numericText } from "./sql-common.js";
|
|
2
|
+
//#region src/fields/integer.ts
|
|
3
|
+
const toParam = (value, field) => {
|
|
4
|
+
const number = Number(numericText(value, field, "a 32-bit integer"));
|
|
5
|
+
if (!Number.isSafeInteger(number) || Math.abs(number) > 2 ** 31 - 1) throw invalidValue(field, "a 32-bit integer");
|
|
6
|
+
return number;
|
|
7
|
+
};
|
|
8
|
+
//#endregion
|
|
9
|
+
export { toParam };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
//#region src/fields/json.ts
|
|
3
|
+
/** `field:value`: the json contains the scalar, as an array element, an equal value or (for text) an object key. */
|
|
4
|
+
const jsonHas = (col, value) => {
|
|
5
|
+
switch (value.kind) {
|
|
6
|
+
case "string": return sql`(jsonb_exists(${col}::jsonb, ${value.value}) or ${col}::jsonb @> to_jsonb(${value.value}::text))`;
|
|
7
|
+
case "number": return sql`${col}::jsonb @> to_jsonb(${value.raw}::numeric)`;
|
|
8
|
+
case "boolean": return sql`${col}::jsonb @> to_jsonb(${value.value}::boolean)`;
|
|
9
|
+
default: throw new Error(`Not implemented: json containment for ${value.kind} literals, the parser only produces strings, numbers and booleans`);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
/** `field:*`: a json value other than SQL NULL and JSON null. */
|
|
13
|
+
const jsonPresent = (col) => sql`(${col} is not null and ${col}::jsonb <> 'null'::jsonb)`;
|
|
14
|
+
//#endregion
|
|
15
|
+
export { jsonHas, jsonPresent };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
//#region src/fields/query-error.d.ts
|
|
2
|
+
export type QueryErrorCode = 'unknown_field' | 'not_filterable' | 'not_sortable' | 'unsupported_operator' | 'unsupported_field_type' | 'missing_tenant' | 'invalid_filter' | 'invalid_value' | 'invalid_sort' | 'invalid_cursor' | 'invalid_limit' | 'invalid_option' | 'no_statistics' | 'no_search_fields' | 'missing_extension';
|
|
3
|
+
/** Thrown for any request the query layer refuses; `code` is the stable part, `message` is for humans. */
|
|
4
|
+
export declare class QueryError extends Error {
|
|
5
|
+
readonly code: QueryErrorCode;
|
|
6
|
+
constructor(code: QueryErrorCode, message: string);
|
|
7
|
+
}
|
|
8
|
+
//#endregion
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/fields/query-error.ts
|
|
2
|
+
/** Thrown for any request the query layer refuses; `code` is the stable part, `message` is for humans. */
|
|
3
|
+
var QueryError = class extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.name = "QueryError";
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { QueryError };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { invalidValue } from "./sql-common.js";
|
|
2
|
+
//#region src/fields/relation.ts
|
|
3
|
+
/** A related record's key: text, or a safe integer. */
|
|
4
|
+
const toParam = (value, field) => {
|
|
5
|
+
if (value.kind === "string") return value.value;
|
|
6
|
+
if (value.kind === "number" && Number.isSafeInteger(value.value)) return value.raw;
|
|
7
|
+
throw invalidValue(field, "a record key");
|
|
8
|
+
};
|
|
9
|
+
//#endregion
|
|
10
|
+
export { toParam };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { QueryError } from "./query-error.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
//#region src/fields/sql-common.ts
|
|
4
|
+
const invalidValue = (field, expected) => new QueryError("invalid_value", `Field "${field.name}" expects ${expected}`);
|
|
5
|
+
/** Numbers keep their written text so bigint and decimal precision is never lost to a JS double. */
|
|
6
|
+
const numericText = (value, field, expected) => {
|
|
7
|
+
if (value.kind === "number") return value.raw;
|
|
8
|
+
if (value.kind === "string") return value.value;
|
|
9
|
+
throw invalidValue(field, expected);
|
|
10
|
+
};
|
|
11
|
+
const stringValue = (value, field, expected) => {
|
|
12
|
+
if (value.kind !== "string") throw invalidValue(field, expected);
|
|
13
|
+
return value.value;
|
|
14
|
+
};
|
|
15
|
+
const secondsPerUnit = {
|
|
16
|
+
s: 1,
|
|
17
|
+
m: 60,
|
|
18
|
+
h: 3600,
|
|
19
|
+
d: 86400,
|
|
20
|
+
w: 604800
|
|
21
|
+
};
|
|
22
|
+
/** `now()` with its optional +/- duration offset, as a timestamptz expression. */
|
|
23
|
+
const nowExpression = (value) => {
|
|
24
|
+
if (!value.offset) return sql`now()`;
|
|
25
|
+
const { sign, duration } = value.offset;
|
|
26
|
+
const seconds = duration.amount * secondsPerUnit[duration.unit];
|
|
27
|
+
return sql`now() ${sql.raw(sign === "-" ? "-" : "+")} ${seconds}::float8 * interval '1 second'`;
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
export { invalidValue, nowExpression, numericText, stringValue };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { stringValue } from "./sql-common.js";
|
|
2
|
+
//#region src/fields/text.ts
|
|
3
|
+
const toParam = (value, field) => stringValue(value, field, "a string");
|
|
4
|
+
const escapeLike = (text) => text.replace(/[\\%_]/g, "\\$&");
|
|
5
|
+
/** Escapes LIKE wildcards (backslash is LIKE's default escape), then turns the filter wildcard `*` into `%`. */
|
|
6
|
+
const wildcardPattern = (text) => escapeLike(text).replaceAll("*", "%");
|
|
7
|
+
/** A LIKE pattern for values that contain the text anywhere, taken literally. */
|
|
8
|
+
const containsPattern = (text) => `%${escapeLike(text)}%`;
|
|
9
|
+
//#endregion
|
|
10
|
+
export { containsPattern, toParam, wildcardPattern };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { invalidValue, nowExpression } from "./sql-common.js";
|
|
2
|
+
//#region src/fields/timestamp.ts
|
|
3
|
+
/** Parsed RFC 3339 timestamps are sent as written, which keeps microseconds. */
|
|
4
|
+
const toParam = (value, field) => {
|
|
5
|
+
if (value.kind === "now") return nowExpression(value);
|
|
6
|
+
if (value.kind !== "timestamp" && value.kind !== "string" || Number.isNaN(Date.parse(value.value))) throw invalidValue(field, "an RFC 3339 timestamp");
|
|
7
|
+
return value.value;
|
|
8
|
+
};
|
|
9
|
+
//#endregion
|
|
10
|
+
export { toParam };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { invalidValue, stringValue } from "./sql-common.js";
|
|
2
|
+
//#region src/fields/uuid.ts
|
|
3
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4
|
+
const toParam = (value, field) => {
|
|
5
|
+
const text = stringValue(value, field, "a UUID");
|
|
6
|
+
if (!uuidPattern.test(text)) throw invalidValue(field, "a UUID");
|
|
7
|
+
return text;
|
|
8
|
+
};
|
|
9
|
+
//#endregion
|
|
10
|
+
export { toParam };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { QueryError } from "./fields/query-error.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
//#region src/filter-requirements.ts
|
|
4
|
+
const usesSimilar = (node) => {
|
|
5
|
+
if (node.kind === "similar") return true;
|
|
6
|
+
if (node.kind === "and" || node.kind === "or") return node.args.some(usesSimilar);
|
|
7
|
+
return node.kind === "not" && usesSimilar(node.arg);
|
|
8
|
+
};
|
|
9
|
+
/** Fails with a clear error, before any query runs, when the filter needs an extension the database lacks. */
|
|
10
|
+
const assertFilterRequirements = async (db, filter, scope) => {
|
|
11
|
+
if (!(filter && usesSimilar(filter)) && !(scope?.rowFilter && usesSimilar(scope.rowFilter))) return;
|
|
12
|
+
if ((await sql`select count(*)::text as n from pg_extension where extname = 'pg_trgm'`.execute(db)).rows[0]?.n === "0") throw new QueryError("missing_extension", "similar() needs the pg_trgm extension: run \"create extension pg_trgm\"");
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { assertFilterRequirements };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Db } from "./db.js";
|
|
2
|
+
import { Scope } from "./scope.js";
|
|
3
|
+
import { CheckedFilter, ResourceModel } from "@protobase/schema";
|
|
4
|
+
//#region src/histogram.d.ts
|
|
5
|
+
export type HistogramBucket = {
|
|
6
|
+
from: number;
|
|
7
|
+
to: number;
|
|
8
|
+
count: number;
|
|
9
|
+
};
|
|
10
|
+
export type Histogram = {
|
|
11
|
+
min: number | null;
|
|
12
|
+
max: number | null;
|
|
13
|
+
buckets: HistogramBucket[];
|
|
14
|
+
};
|
|
15
|
+
/** Equal-width buckets between the filtered min and max, empty buckets included. */
|
|
16
|
+
export declare const histogram: (db: Db, model: ResourceModel, field: string, filter: CheckedFilter | undefined, buckets: number, scope?: Scope) => Promise<Histogram>;
|
|
17
|
+
//#endregion
|
|
@@ -0,0 +1,57 @@
|
|
|
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 { sql } from "kysely";
|
|
7
|
+
//#region src/histogram.ts
|
|
8
|
+
const maxBuckets = 200;
|
|
9
|
+
const numericTypes = [
|
|
10
|
+
"integer",
|
|
11
|
+
"bigint",
|
|
12
|
+
"decimal",
|
|
13
|
+
"currency"
|
|
14
|
+
];
|
|
15
|
+
/** Equal-width buckets between the filtered min and max, empty buckets included. */
|
|
16
|
+
const histogram = async (db, model, field, filter, buckets, scope) => {
|
|
17
|
+
const target = requireField(model, field, "filterable");
|
|
18
|
+
if (!numericTypes.includes(target.type)) throw new QueryError("unsupported_field_type", `Histogram needs a numeric field, "${field}" is ${target.type}`);
|
|
19
|
+
if (!Number.isInteger(buckets) || buckets < 1 || buckets > maxBuckets) throw new QueryError("invalid_option", `buckets must be between 1 and ${maxBuckets}`);
|
|
20
|
+
await assertFilterRequirements(db, filter, scope);
|
|
21
|
+
const col = columnRef(target);
|
|
22
|
+
const present = baseQuery(db, model, filter, scope).where(sql`${col} is not null`);
|
|
23
|
+
const bounds = await present.select([sql`min(${col})::float8::text`.as("lo"), sql`max(${col})::float8::text`.as("hi")]).executeTakeFirstOrThrow();
|
|
24
|
+
if (bounds.lo === null || bounds.hi === null) return {
|
|
25
|
+
min: null,
|
|
26
|
+
max: null,
|
|
27
|
+
buckets: []
|
|
28
|
+
};
|
|
29
|
+
const min = Number(bounds.lo);
|
|
30
|
+
const max = Number(bounds.hi);
|
|
31
|
+
if (min === max) {
|
|
32
|
+
const total = await present.select(sql`count(*)::text`.as("n")).executeTakeFirstOrThrow();
|
|
33
|
+
return {
|
|
34
|
+
min,
|
|
35
|
+
max,
|
|
36
|
+
buckets: [{
|
|
37
|
+
from: min,
|
|
38
|
+
to: max,
|
|
39
|
+
count: toNumber(total.n)
|
|
40
|
+
}]
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const counted = await present.select([sql`least(width_bucket(${col}::float8, ${min}::float8, ${max}::float8, ${buckets}::int), ${buckets}::int)`.as("bucket"), sql`count(*)::text`.as("n")]).groupBy(sql`1`).execute();
|
|
44
|
+
const counts = new Map(counted.map((row) => [toNumber(row.bucket), toNumber(row.n)]));
|
|
45
|
+
const width = (max - min) / buckets;
|
|
46
|
+
return {
|
|
47
|
+
min,
|
|
48
|
+
max,
|
|
49
|
+
buckets: Array.from({ length: buckets }, (_, i) => ({
|
|
50
|
+
from: min + i * width,
|
|
51
|
+
to: i === buckets - 1 ? max : min + (i + 1) * width,
|
|
52
|
+
count: counts.get(i + 1) ?? 0
|
|
53
|
+
}))
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
export { histogram };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { CompileOptions, compileFilter } from "./compile-filter.js";
|
|
2
|
+
import { Db } from "./db.js";
|
|
3
|
+
import { Scope } from "./scope.js";
|
|
4
|
+
import { ExtraColumn, ListOptions, ListPlan, ListResult, buildListQuery, listQuery, maxLimit } from "./list-query.js";
|
|
5
|
+
import { estimateCount } from "./estimate-count.js";
|
|
6
|
+
import { exactCount } from "./exact-count.js";
|
|
7
|
+
import { FacetCount, FacetOptions, facetCounts } from "./facet-counts.js";
|
|
8
|
+
import { Granularity, SeriesOptions, SeriesPoint, series } from "./series.js";
|
|
9
|
+
import { Histogram, HistogramBucket, histogram } from "./histogram.js";
|
|
10
|
+
import { ScrollMode, scrollMode } from "./scroll-mode.js";
|
|
11
|
+
import { GuardFinding, GuardOptions, GuardReport, explainGuard } from "./explain-guard.js";
|
|
12
|
+
import { QueryError, QueryErrorCode } from "./fields/query-error.js";
|
|
13
|
+
import { SeekRequest, seekToPosition } from "./seek-to-position.js";
|
|
14
|
+
export { type CompileOptions, type Db, type ExtraColumn, type FacetCount, type FacetOptions, type Granularity, type GuardFinding, type GuardOptions, type GuardReport, type Histogram, type HistogramBucket, type ListOptions, type ListPlan, type ListResult, QueryError, type QueryErrorCode, type Scope, type ScrollMode, type SeekRequest, type SeriesOptions, type SeriesPoint, buildListQuery, compileFilter, estimateCount, exactCount, explainGuard, facetCounts, histogram, listQuery, maxLimit, scrollMode, seekToPosition, series };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { QueryError } from "./fields/query-error.js";
|
|
2
|
+
import { compileFilter } from "./compile-filter.js";
|
|
3
|
+
import { buildListQuery, listQuery, maxLimit } from "./list-query.js";
|
|
4
|
+
import { estimateCount } from "./estimate-count.js";
|
|
5
|
+
import { exactCount } from "./exact-count.js";
|
|
6
|
+
import { facetCounts } from "./facet-counts.js";
|
|
7
|
+
import { series } from "./series.js";
|
|
8
|
+
import { histogram } from "./histogram.js";
|
|
9
|
+
import { scrollMode } from "./scroll-mode.js";
|
|
10
|
+
import { explainGuard } from "./explain-guard.js";
|
|
11
|
+
import { seekToPosition } from "./seek-to-position.js";
|
|
12
|
+
export { QueryError, buildListQuery, compileFilter, estimateCount, exactCount, explainGuard, facetCounts, histogram, listQuery, maxLimit, scrollMode, seekToPosition, series };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Db } from "./db.js";
|
|
2
|
+
import { Scope } from "./scope.js";
|
|
3
|
+
import { SortKey } from "./sort.js";
|
|
4
|
+
import { ListQuery, ResourceModel } from "@protobase/schema";
|
|
5
|
+
//#region src/list-query.d.ts
|
|
6
|
+
export declare const maxLimit = 500;
|
|
7
|
+
export type ListResult = {
|
|
8
|
+
rows: Array<Record<string, unknown>>;
|
|
9
|
+
nextCursor: string | null;
|
|
10
|
+
prevCursor: string | null;
|
|
11
|
+
};
|
|
12
|
+
/** An extra output column, mapped from a model field and never from raw SQL. */
|
|
13
|
+
export type ExtraColumn = {
|
|
14
|
+
field: string;
|
|
15
|
+
as: 'text';
|
|
16
|
+
alias: string;
|
|
17
|
+
};
|
|
18
|
+
export type ListOptions = {
|
|
19
|
+
/** `as: 'text'` selects `column::text`, so timestamps and numerics keep full database precision. */
|
|
20
|
+
extraColumns?: ExtraColumn[];
|
|
21
|
+
};
|
|
22
|
+
export type ListPlan = {
|
|
23
|
+
/** The exact query listQuery runs (fetches limit + 1 rows); explain it, or execute it yourself. */
|
|
24
|
+
query: ReturnType<typeof selectPage>;
|
|
25
|
+
limit: number;
|
|
26
|
+
keys: SortKey[];
|
|
27
|
+
reverse: boolean;
|
|
28
|
+
hasCursor: boolean;
|
|
29
|
+
};
|
|
30
|
+
declare const selectPage: (db: Db, model: ResourceModel, query: ListQuery, scope: Scope | undefined, keys: SortKey[], cursorValues: Array<string | null> | undefined, options: ListOptions) => import("kysely").SelectQueryBuilder<{
|
|
31
|
+
[x: string]: any;
|
|
32
|
+
[x: number]: any;
|
|
33
|
+
[x: symbol]: any;
|
|
34
|
+
}, "t", {
|
|
35
|
+
[x: string]: unknown;
|
|
36
|
+
}>;
|
|
37
|
+
/** Validates the request and builds, without executing, the query listQuery would run. */
|
|
38
|
+
export declare const buildListQuery: (db: Db, model: ResourceModel, query: ListQuery, scope?: Scope, options?: ListOptions) => Promise<ListPlan>;
|
|
39
|
+
export declare const listQuery: (db: Db, model: ResourceModel, query: ListQuery, scope?: Scope, options?: ListOptions) => Promise<ListResult>;
|
|
40
|
+
//#endregion
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { QueryError } from "./fields/query-error.js";
|
|
2
|
+
import { columnRef, findField } from "./columns.js";
|
|
3
|
+
import { baseQuery } from "./base-query.js";
|
|
4
|
+
import { effectiveSort, flip } from "./sort.js";
|
|
5
|
+
import { decodeCursor, encodeCursor } from "./cursor.js";
|
|
6
|
+
import { assertFilterRequirements } from "./filter-requirements.js";
|
|
7
|
+
import { orderTerm } from "./order.js";
|
|
8
|
+
import { seekCondition } from "./seek.js";
|
|
9
|
+
import { sql } from "kysely";
|
|
10
|
+
//#region src/list-query.ts
|
|
11
|
+
const maxLimit = 500;
|
|
12
|
+
const keyAlias = (index) => `__pb_key${index}`;
|
|
13
|
+
const clampLimit = (limit) => {
|
|
14
|
+
if (!Number.isInteger(limit) || limit < 1) throw new QueryError("invalid_limit", "limit must be a positive integer");
|
|
15
|
+
return Math.min(limit, 500);
|
|
16
|
+
};
|
|
17
|
+
const requestedFields = (model, columns) => (columns ?? Object.keys(model.fields)).map((name) => findField(model, name));
|
|
18
|
+
const extraSelections = (model, extras = []) => extras.map(({ field, as, alias }) => {
|
|
19
|
+
if (as !== "text") throw new QueryError("invalid_option", "extraColumns only supports as: \"text\"");
|
|
20
|
+
if (Object.hasOwn(model.fields, alias) || alias.startsWith("__pb_key")) throw new QueryError("invalid_option", `extra column alias "${alias}" collides with a field or internal column`);
|
|
21
|
+
return sql`${columnRef(findField(model, field))}::text`.as(alias);
|
|
22
|
+
});
|
|
23
|
+
const selectPage = (db, model, query, scope, keys, cursorValues, options) => {
|
|
24
|
+
const reading = query.direction ?? "after";
|
|
25
|
+
const reverse = reading === "before";
|
|
26
|
+
let select = baseQuery(db, model, query.filter, scope).select(requestedFields(model, query.columns).map((field) => sql`${columnRef(field)}`.as(field.name))).select(extraSelections(model, options.extraColumns)).select(keys.map((key, i) => sql`${columnRef(key.field)}::text`.as(keyAlias(i))));
|
|
27
|
+
if (cursorValues) select = select.where(seekCondition(keys, cursorValues, reading));
|
|
28
|
+
for (const key of keys) select = select.orderBy(orderTerm(key, reverse ? flip(key.direction) : key.direction));
|
|
29
|
+
return select.limit(clampLimit(query.limit) + 1);
|
|
30
|
+
};
|
|
31
|
+
/** Validates the request and builds, without executing, the query listQuery would run. */
|
|
32
|
+
const buildListQuery = async (db, model, query, scope, options = {}) => {
|
|
33
|
+
const limit = clampLimit(query.limit);
|
|
34
|
+
await assertFilterRequirements(db, query.filter, scope);
|
|
35
|
+
const keys = effectiveSort(model, query.sort);
|
|
36
|
+
const cursorValues = query.cursor === void 0 ? void 0 : decodeCursor(query.cursor, keys);
|
|
37
|
+
return {
|
|
38
|
+
query: selectPage(db, model, query, scope, keys, cursorValues, options),
|
|
39
|
+
limit,
|
|
40
|
+
keys,
|
|
41
|
+
reverse: query.direction === "before",
|
|
42
|
+
hasCursor: cursorValues !== void 0
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
const listQuery = async (db, model, query, scope, options = {}) => {
|
|
46
|
+
const { query: select, limit, keys, reverse, hasCursor } = await buildListQuery(db, model, query, scope, options);
|
|
47
|
+
const fetched = await select.execute();
|
|
48
|
+
const more = fetched.length > limit;
|
|
49
|
+
const page = more ? fetched.slice(0, limit) : fetched;
|
|
50
|
+
if (reverse) page.reverse();
|
|
51
|
+
const cursorOf = (row) => encodeCursor(keys, keys.map((_, i) => row[keyAlias(i)] === null ? null : String(row[keyAlias(i)])));
|
|
52
|
+
const first = page[0];
|
|
53
|
+
const last = page.at(-1);
|
|
54
|
+
return {
|
|
55
|
+
rows: page.map((row) => Object.fromEntries(Object.entries(row).filter(([name]) => !name.startsWith("__pb_key")))),
|
|
56
|
+
nextCursor: last && (reverse ? hasCursor : more) ? cursorOf(last) : null,
|
|
57
|
+
prevCursor: first && (reverse ? more : hasCursor) ? cursorOf(first) : null
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
//#endregion
|
|
61
|
+
export { buildListQuery, listQuery, maxLimit };
|
package/dist/numbers.js
ADDED
package/dist/order.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { columnRef } from "./columns.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
//#region src/order.ts
|
|
4
|
+
/**
|
|
5
|
+
* ORDER BY term for a sort key. Nulls follow Postgres defaults (last for asc, first for desc), written out for
|
|
6
|
+
* nullable columns so the seek predicates in seek.ts and the ordering can never drift apart.
|
|
7
|
+
*/
|
|
8
|
+
const orderTerm = (key, direction = key.direction) => {
|
|
9
|
+
const column = columnRef(key.field);
|
|
10
|
+
if (!key.field.nullable) return sql`${column} ${sql.raw(direction)}`;
|
|
11
|
+
return sql`${column} ${sql.raw(direction)} nulls ${sql.raw(direction === "asc" ? "last" : "first")}`;
|
|
12
|
+
};
|
|
13
|
+
//#endregion
|
|
14
|
+
export { orderTerm };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//#region src/prune-filter.ts
|
|
2
|
+
/** Removes every condition on `field`, dropping groups that end up empty (disjunctive faceting). Text search stays. */
|
|
3
|
+
const withoutField = (node, field) => {
|
|
4
|
+
if (!node) return void 0;
|
|
5
|
+
switch (node.kind) {
|
|
6
|
+
case "and":
|
|
7
|
+
case "or": {
|
|
8
|
+
const args = node.args.map((arg) => withoutField(arg, field)).filter((arg) => arg !== void 0);
|
|
9
|
+
return args.length === 0 ? void 0 : {
|
|
10
|
+
...node,
|
|
11
|
+
args
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
case "not": {
|
|
15
|
+
const arg = withoutField(node.arg, field);
|
|
16
|
+
return arg && {
|
|
17
|
+
...node,
|
|
18
|
+
arg
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
case "search": return node;
|
|
22
|
+
default: return node.field.name === field ? void 0 : node;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { withoutField };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { QueryError } from "./fields/query-error.js";
|
|
2
|
+
//#region src/range-bound.ts
|
|
3
|
+
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
|
4
|
+
const timestampPattern = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?)?(Z|[+-]\d{2}(:?\d{2})?)?$/;
|
|
5
|
+
/** Validates a series range bound: a Date or ISO text (dates only for `date` fields). Returns text to bind. */
|
|
6
|
+
const rangeBound = (value, field) => {
|
|
7
|
+
const wantsDate = field.type === "date";
|
|
8
|
+
const text = value instanceof Date ? value.toISOString() : value;
|
|
9
|
+
const pattern = wantsDate ? datePattern : timestampPattern;
|
|
10
|
+
const date = wantsDate && value instanceof Date ? text.slice(0, 10) : text;
|
|
11
|
+
if (!pattern.test(date) || Number.isNaN(Date.parse(date))) throw new QueryError("invalid_option", `Range bounds for "${field.name}" must be ${wantsDate ? "YYYY-MM-DD dates" : "ISO 8601 timestamps"}`);
|
|
12
|
+
return date;
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { rangeBound };
|
package/dist/scope.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Expression, SqlBool } from "kysely";
|
|
2
|
+
import { CheckedFilter, ResourceModel } from "@protobase/schema";
|
|
3
|
+
//#region src/scope.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Who is asking, and which rows they may see.
|
|
6
|
+
* - `tenantValue`: models with a `tenant` column only ever see rows of this value.
|
|
7
|
+
* - `rowFilter`: a filter from access rules, ANDed in before the user's filter. It is checked against the FULL model,
|
|
8
|
+
* so it may use hidden or non-filterable columns the user's own (restricted) model does not expose.
|
|
9
|
+
* - `fullModel`: that full model. Soft delete, tenant and `rowFilter` resolve against it; everything the user
|
|
10
|
+
* supplies (filter, sort, columns, fields) resolves against the model argument. Defaults to the model argument.
|
|
11
|
+
*/
|
|
12
|
+
export type Scope = {
|
|
13
|
+
tenantValue?: string | number;
|
|
14
|
+
rowFilter?: CheckedFilter;
|
|
15
|
+
fullModel?: ResourceModel;
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
package/dist/scope.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { QueryError } from "./fields/query-error.js";
|
|
2
|
+
import { columnRef } from "./columns.js";
|
|
3
|
+
import { sql } from "kysely";
|
|
4
|
+
//#region src/scope.ts
|
|
5
|
+
const fullModelOf = (model, scope) => scope?.fullModel ?? model;
|
|
6
|
+
/** `tenant = value` for tenant models; throws when the scope is missing, so a forgotten scope never leaks rows. */
|
|
7
|
+
const tenantCondition = (model, scope) => {
|
|
8
|
+
const full = fullModelOf(model, scope);
|
|
9
|
+
const tenant = full.tenant ?? model.tenant;
|
|
10
|
+
if (!tenant) return void 0;
|
|
11
|
+
if (scope?.tenantValue === void 0) throw new QueryError("missing_tenant", `Resource "${model.name}" is tenant-scoped; pass scope.tenantValue`);
|
|
12
|
+
const field = Object.hasOwn(full.fields, tenant) ? full.fields[tenant] : void 0;
|
|
13
|
+
const column = field ? columnRef(field) : sql`${sql.id("t", tenant)}`;
|
|
14
|
+
return sql`${column} = ${scope.tenantValue}`;
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { fullModelOf, tenantCondition };
|