@protobase/schema 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/access/capability.d.ts +10 -0
- package/dist/access/capability.js +30 -0
- package/dist/access/combine.js +39 -0
- package/dist/access/index.d.ts +8 -0
- package/dist/access/pick-view.d.ts +8 -0
- package/dist/access/pick-view.js +11 -0
- package/dist/access/resolve.d.ts +61 -0
- package/dist/access/resolve.js +150 -0
- package/dist/access/restrict.d.ts +16 -0
- package/dist/access/restrict.js +37 -0
- package/dist/access/roles.d.ts +30 -0
- package/dist/access/roles.js +55 -0
- package/dist/access/rule.d.ts +11 -0
- package/dist/access/rule.js +9 -0
- package/dist/access/types.d.ts +37 -0
- package/dist/enum-label.d.ts +4 -0
- package/dist/enum-label.js +5 -0
- package/dist/field.d.ts +69 -0
- package/dist/field.js +85 -0
- package/dist/fields/bigint.js +12 -0
- package/dist/fields/boolean.js +9 -0
- package/dist/fields/country.js +9 -0
- package/dist/fields/currency.js +9 -0
- package/dist/fields/date.js +9 -0
- package/dist/fields/decimal.js +22 -0
- package/dist/fields/enum.js +12 -0
- package/dist/fields/integer.js +14 -0
- package/dist/fields/json.js +9 -0
- package/dist/fields/relation.js +9 -0
- package/dist/fields/text.js +19 -0
- package/dist/fields/timestamp.js +9 -0
- package/dist/fields/uuid.js +9 -0
- package/dist/fields.d.ts +23 -0
- package/dist/fields.js +49 -0
- package/dist/filter/check-field.js +25 -0
- package/dist/filter/check-model.js +123 -0
- package/dist/filter/check-value.js +64 -0
- package/dist/filter/check.d.ts +16 -0
- package/dist/filter/check.js +22 -0
- package/dist/filter/datetime.js +33 -0
- package/dist/filter/duration.js +16 -0
- package/dist/filter/errors.js +29 -0
- package/dist/filter/evaluate.d.ts +12 -0
- package/dist/filter/evaluate.js +33 -0
- package/dist/filter/index.d.ts +7 -0
- package/dist/filter/lift.js +107 -0
- package/dist/filter/lower.js +170 -0
- package/dist/filter/operators.js +26 -0
- package/dist/filter/order-by.d.ts +23 -0
- package/dist/filter/order-by.js +51 -0
- package/dist/filter/parse.d.ts +20 -0
- package/dist/filter/parse.js +28 -0
- package/dist/filter/print.d.ts +5 -0
- package/dist/filter/print.js +7 -0
- package/dist/filter/profile.js +69 -0
- package/dist/filter/where.d.ts +58 -0
- package/dist/filter/where.js +121 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +21 -0
- package/dist/keys.d.ts +12 -0
- package/dist/keys.js +57 -0
- package/dist/layout.d.ts +12 -0
- package/dist/layout.js +21 -0
- package/dist/model.d.ts +333 -0
- package/dist/naming.js +4 -0
- package/dist/nav-recent.d.ts +8 -0
- package/dist/nav-recent.js +37 -0
- package/dist/refs.d.ts +11 -0
- package/dist/refs.js +8 -0
- package/dist/relation-field.d.ts +6 -0
- package/dist/relation-field.js +12 -0
- package/dist/resource-model.d.ts +15 -0
- package/dist/resource-model.js +71 -0
- package/dist/resource-types.d.ts +21 -0
- package/dist/resource.d.ts +47 -0
- package/dist/resource.js +97 -0
- package/dist/search-match.d.ts +29 -0
- package/dist/search-match.js +50 -0
- package/dist/user-menu.d.ts +25 -0
- package/dist/user-menu.js +32 -0
- package/dist/view-actions.d.ts +24 -0
- package/dist/view-actions.js +35 -0
- package/dist/view-field.d.ts +16 -0
- package/dist/view-field.js +39 -0
- package/dist/view-widgets.d.ts +16 -0
- package/dist/view-widgets.js +24 -0
- package/dist/view.d.ts +69 -0
- package/dist/view.js +109 -0
- package/package.json +31 -3
- package/README.md +0 -3
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { filterError } from "./errors.js";
|
|
2
|
+
import { checkValue } from "./check-value.js";
|
|
3
|
+
import { resolveField } from "./check-field.js";
|
|
4
|
+
import { compareOps, isOpaque } from "./operators.js";
|
|
5
|
+
//#region src/filter/check-model.ts
|
|
6
|
+
const value = (ctx, field, node) => {
|
|
7
|
+
const problem = checkValue(field, node);
|
|
8
|
+
if (problem) ctx.errors.push(filterError("invalid-value", problem.message, problem.hint, node.span));
|
|
9
|
+
return !problem;
|
|
10
|
+
};
|
|
11
|
+
const operatorError = (ctx, message, hint, span) => {
|
|
12
|
+
ctx.errors.push(filterError("operator-not-allowed", message, hint, span));
|
|
13
|
+
};
|
|
14
|
+
const isWildcard = (op, node) => (op === "=" || op === "!=") && node.kind === "string" && node.value.includes("*");
|
|
15
|
+
const checkCompare = (ctx, node) => {
|
|
16
|
+
const field = resolveField(ctx, node.field, "filterable");
|
|
17
|
+
if (!field) return void 0;
|
|
18
|
+
const allowed = compareOps(field.type);
|
|
19
|
+
if (!allowed.includes(node.op)) {
|
|
20
|
+
operatorError(ctx, `Operator "${node.op}" is not supported for ${field.type} fields`, allowed.length ? `Supported: ${allowed.join(" ")}` : "Use field:value on json fields, or isNull(field)", node.span);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (isWildcard(node.op, node.value) && field.type !== "text") {
|
|
24
|
+
ctx.errors.push(filterError("wildcard-not-allowed", `Wildcards are only supported on text fields, not ${field.type}`, "Remove the \"*\" or filter a text field", node.value.span));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (!isWildcard(node.op, node.value) && !value(ctx, field, node.value)) return void 0;
|
|
28
|
+
return {
|
|
29
|
+
...node,
|
|
30
|
+
field
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
const checkHas = (ctx, node) => {
|
|
34
|
+
const field = resolveField(ctx, node.field, "filterable");
|
|
35
|
+
if (!field) return void 0;
|
|
36
|
+
if (field.type !== "json") {
|
|
37
|
+
operatorError(ctx, `":" is only supported on json fields, not ${field.type}`, `Use = on ${field.type} fields, or field:* to test for a value`, node.span);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
...node,
|
|
42
|
+
field
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
const checkIn = (ctx, node) => {
|
|
46
|
+
const field = resolveField(ctx, node.field, "filterable");
|
|
47
|
+
if (!field) return void 0;
|
|
48
|
+
if (isOpaque(field.type)) {
|
|
49
|
+
operatorError(ctx, `in() is not supported for ${field.type} fields`, "Use field:value on json fields", node.span);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
return node.values.map((literal) => value(ctx, field, literal)).every(Boolean) ? {
|
|
53
|
+
...node,
|
|
54
|
+
field
|
|
55
|
+
} : void 0;
|
|
56
|
+
};
|
|
57
|
+
const checkText = (ctx, name, path) => {
|
|
58
|
+
const field = resolveField(ctx, path, "filterable");
|
|
59
|
+
if (field && field.type !== "text") {
|
|
60
|
+
operatorError(ctx, `${name}() only works on text fields, not ${field.type}`, `Field "${field.name}" is ${field.type}`, path.span);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
return field;
|
|
64
|
+
};
|
|
65
|
+
const checkNode = (ctx, node) => {
|
|
66
|
+
switch (node.kind) {
|
|
67
|
+
case "and":
|
|
68
|
+
case "or": {
|
|
69
|
+
const args = node.args.map((arg) => checkNode(ctx, arg));
|
|
70
|
+
return args.every((arg) => arg) ? {
|
|
71
|
+
...node,
|
|
72
|
+
args
|
|
73
|
+
} : void 0;
|
|
74
|
+
}
|
|
75
|
+
case "not": {
|
|
76
|
+
const arg = checkNode(ctx, node.arg);
|
|
77
|
+
return arg && {
|
|
78
|
+
...node,
|
|
79
|
+
arg
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
case "compare": return checkCompare(ctx, node);
|
|
83
|
+
case "has": return checkHas(ctx, node);
|
|
84
|
+
case "in": return checkIn(ctx, node);
|
|
85
|
+
case "search":
|
|
86
|
+
if (ctx.model.search?.length) return node;
|
|
87
|
+
ctx.errors.push(filterError("no-search-fields", "This resource has no search fields", "Declare them with .search((r) => [r.title]) in data.ts", node.span));
|
|
88
|
+
return;
|
|
89
|
+
case "present":
|
|
90
|
+
case "isNull": {
|
|
91
|
+
const field = resolveField(ctx, node.field, "filterable");
|
|
92
|
+
return field && {
|
|
93
|
+
...node,
|
|
94
|
+
field
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
case "similar":
|
|
98
|
+
case "regex": {
|
|
99
|
+
const field = checkText(ctx, node.kind, node.field);
|
|
100
|
+
return field && {
|
|
101
|
+
...node,
|
|
102
|
+
field
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const checkAgainstModel = (model, ast) => {
|
|
108
|
+
const ctx = {
|
|
109
|
+
model,
|
|
110
|
+
errors: []
|
|
111
|
+
};
|
|
112
|
+
const filter = checkNode(ctx, ast);
|
|
113
|
+
if (!filter || ctx.errors.length > 0) return {
|
|
114
|
+
ok: false,
|
|
115
|
+
errors: [...ctx.errors].sort((a, b) => a.span.start - b.span.start)
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
ok: true,
|
|
119
|
+
filter
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
//#endregion
|
|
123
|
+
export { checkAgainstModel };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { isValidDate, isValidTimestamp } from "./datetime.js";
|
|
2
|
+
//#region src/filter/check-value.ts
|
|
3
|
+
const problem = (message, hint) => ({
|
|
4
|
+
message,
|
|
5
|
+
hint
|
|
6
|
+
});
|
|
7
|
+
const int64Min = -(2n ** 63n);
|
|
8
|
+
const int64Max = 2n ** 63n - 1n;
|
|
9
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
|
+
const numericText = (value) => value.kind === "number" ? value.raw : value.kind === "string" ? value.value : void 0;
|
|
11
|
+
const expected = (what, hint = `Write ${what} values like the examples in the docs`) => problem(`Expected ${what}`, hint);
|
|
12
|
+
const checkInteger = (value) => {
|
|
13
|
+
const text = numericText(value);
|
|
14
|
+
const number = text === void 0 ? NaN : Number(text);
|
|
15
|
+
if (text === void 0 || text.trim() === "" || !Number.isSafeInteger(number)) return expected("a whole number", "Use an integer such as 42, or a bigint field for larger values");
|
|
16
|
+
};
|
|
17
|
+
const checkBigint = (value) => {
|
|
18
|
+
const text = numericText(value);
|
|
19
|
+
if (text === void 0 || !/^-?\d+$/.test(text) || BigInt(text) < int64Min || BigInt(text) > int64Max) return expected("a 64-bit integer", "Use plain digits such as 9007199254740993 (no exponent or fraction)");
|
|
20
|
+
};
|
|
21
|
+
const checkDecimal = (value) => {
|
|
22
|
+
const text = numericText(value);
|
|
23
|
+
if (text === void 0 || !/^-?\d+(\.\d+)?$/.test(text)) return expected("a decimal number", "Use plain digits such as 100.50, or quote it: \"100.50\" (no exponent)");
|
|
24
|
+
};
|
|
25
|
+
const checkValue = (field, value) => {
|
|
26
|
+
if (value.kind === "now") {
|
|
27
|
+
if (field.type === "date" || field.type === "timestamp") return void 0;
|
|
28
|
+
return problem("now() only works with date and timestamp fields", `Field "${field.name}" is ${field.type}`);
|
|
29
|
+
}
|
|
30
|
+
switch (field.type) {
|
|
31
|
+
case "text":
|
|
32
|
+
if (value.kind !== "string") return expected("a quoted string", "Wrap the text in double quotes");
|
|
33
|
+
return;
|
|
34
|
+
case "integer": return checkInteger(value);
|
|
35
|
+
case "bigint": return checkBigint(value);
|
|
36
|
+
case "decimal": return checkDecimal(value);
|
|
37
|
+
case "boolean": return value.kind === "boolean" ? void 0 : expected("true or false");
|
|
38
|
+
case "date":
|
|
39
|
+
if (value.kind === "string" && isValidDate(value.value)) return void 0;
|
|
40
|
+
return expected("a date", "Quote a calendar date: \"2012-04-21\"");
|
|
41
|
+
case "timestamp":
|
|
42
|
+
if (value.kind === "timestamp" || value.kind === "string" && isValidTimestamp(value.value)) return void 0;
|
|
43
|
+
return expected("an RFC-3339 timestamp", "Use 2012-04-21T11:30:00Z, or now() - 7d");
|
|
44
|
+
case "uuid":
|
|
45
|
+
if (value.kind === "string" && uuid.test(value.value)) return void 0;
|
|
46
|
+
return expected("a uuid", "Quote a uuid like \"0b5a4f0e-6f2a-4a3c-9d4e-1f2a3b4c5d6e\"");
|
|
47
|
+
case "enum":
|
|
48
|
+
if (value.kind === "string" && field.enumValues?.includes(value.value)) return void 0;
|
|
49
|
+
return problem("Invalid enum value", `Allowed values: ${(field.enumValues ?? []).join(", ")}`);
|
|
50
|
+
case "currency":
|
|
51
|
+
if (value.kind === "string" && /^[A-Z]{3}$/.test(value.value)) return void 0;
|
|
52
|
+
return expected("an ISO 4217 currency code", "Use three capital letters, for example \"EUR\"");
|
|
53
|
+
case "country":
|
|
54
|
+
if (value.kind === "string" && /^[A-Z]{2}$/.test(value.value)) return void 0;
|
|
55
|
+
return expected("an ISO 3166-1 country code", "Use two capital letters, for example \"NL\"");
|
|
56
|
+
case "relation":
|
|
57
|
+
if (value.kind === "string" || value.kind === "number" && Number.isSafeInteger(value.value)) return void 0;
|
|
58
|
+
return expected("a record key", "Use the related record key: 42 or \"abc\"");
|
|
59
|
+
case "json":
|
|
60
|
+
case "file": return problem(`${field.type} fields cannot be compared`, "Use field:value on json fields, or isNull(field)");
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
//#endregion
|
|
64
|
+
export { checkValue };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { CheckedFilter, FilterError, FilterExpr, ResourceModel } from "../model.js";
|
|
2
|
+
//#region src/filter/check.d.ts
|
|
3
|
+
export type CheckResult = {
|
|
4
|
+
ok: true;
|
|
5
|
+
filter: CheckedFilter;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
errors: FilterError[];
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Validates against the resource: the library checks fields, aliases, filterability, enum
|
|
12
|
+
* values, operators and function signatures; the model pass then checks the value formats the
|
|
13
|
+
* library cannot know (decimal, bigint, uuid, date, ...), traversal and search fields.
|
|
14
|
+
*/
|
|
15
|
+
export declare const checkFilter: (model: ResourceModel, ast: FilterExpr) => CheckResult;
|
|
16
|
+
//#endregion
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { schemaOf } from "./profile.js";
|
|
2
|
+
import { checkAgainstModel } from "./check-model.js";
|
|
3
|
+
import { lift } from "./lift.js";
|
|
4
|
+
import { checkFilter } from "aip-parsers/filter";
|
|
5
|
+
//#region src/filter/check.ts
|
|
6
|
+
/**
|
|
7
|
+
* Validates against the resource: the library checks fields, aliases, filterability, enum
|
|
8
|
+
* values, operators and function signatures; the model pass then checks the value formats the
|
|
9
|
+
* library cannot know (decimal, bigint, uuid, date, ...), traversal and search fields.
|
|
10
|
+
*/
|
|
11
|
+
const checkFilter$1 = (model, ast) => {
|
|
12
|
+
const traversal = checkAgainstModel(model, ast);
|
|
13
|
+
if (!traversal.ok && traversal.errors.some((e) => e.code === "unsupported-traversal")) return traversal;
|
|
14
|
+
const generic = checkFilter(lift(ast), schemaOf(model));
|
|
15
|
+
if (!generic.ok) return {
|
|
16
|
+
ok: false,
|
|
17
|
+
errors: generic.errors
|
|
18
|
+
};
|
|
19
|
+
return checkAgainstModel(model, ast);
|
|
20
|
+
};
|
|
21
|
+
//#endregion
|
|
22
|
+
export { checkFilter$1 as checkFilter };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//#region src/filter/datetime.ts
|
|
2
|
+
const daysIn = (year, month) => {
|
|
3
|
+
if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
|
|
4
|
+
return [
|
|
5
|
+
4,
|
|
6
|
+
6,
|
|
7
|
+
9,
|
|
8
|
+
11
|
|
9
|
+
].includes(month) ? 30 : 31;
|
|
10
|
+
};
|
|
11
|
+
const isValidDate = (text) => {
|
|
12
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
|
|
13
|
+
if (!match) return false;
|
|
14
|
+
const [year, month, day] = [
|
|
15
|
+
Number(match[1]),
|
|
16
|
+
Number(match[2]),
|
|
17
|
+
Number(match[3])
|
|
18
|
+
];
|
|
19
|
+
return month >= 1 && month <= 12 && day >= 1 && day <= daysIn(year, month);
|
|
20
|
+
};
|
|
21
|
+
const isValidTimestamp = (text) => {
|
|
22
|
+
const match = /^(\d{4}-\d{2}-\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|[+-](\d{2}):(\d{2}))$/.exec(text);
|
|
23
|
+
if (!match) return false;
|
|
24
|
+
const [hour, minute, second] = [
|
|
25
|
+
Number(match[2]),
|
|
26
|
+
Number(match[3]),
|
|
27
|
+
Number(match[4])
|
|
28
|
+
];
|
|
29
|
+
const [offsetHour, offsetMinute] = [Number(match[5] ?? 0), Number(match[6] ?? 0)];
|
|
30
|
+
return isValidDate(match[1]) && hour <= 23 && minute <= 59 && second <= 59 && offsetHour <= 23 && offsetMinute <= 59;
|
|
31
|
+
};
|
|
32
|
+
//#endregion
|
|
33
|
+
export { isValidDate, isValidTimestamp };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/filter/duration.ts
|
|
2
|
+
const parseDurationText = (text) => {
|
|
3
|
+
const match = /^(\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)([smhdw])$/.exec(text);
|
|
4
|
+
if (!match) throw new Error(`Invalid duration "${text}", expected for example 30d or 1.5h`);
|
|
5
|
+
return {
|
|
6
|
+
kind: "duration",
|
|
7
|
+
amount: Number(match[1]),
|
|
8
|
+
unit: match[2],
|
|
9
|
+
span: {
|
|
10
|
+
start: 0,
|
|
11
|
+
end: 0
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
export { parseDurationText };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//#region src/filter/errors.ts
|
|
2
|
+
const filterError = (code, message, hint, span) => ({
|
|
3
|
+
code,
|
|
4
|
+
message,
|
|
5
|
+
hint,
|
|
6
|
+
span
|
|
7
|
+
});
|
|
8
|
+
const distance = (a, b) => {
|
|
9
|
+
let row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
10
|
+
for (let i = 1; i <= a.length; i++) {
|
|
11
|
+
const next = [i];
|
|
12
|
+
for (let j = 1; j <= b.length; j++) {
|
|
13
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
14
|
+
next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + cost);
|
|
15
|
+
}
|
|
16
|
+
row = next;
|
|
17
|
+
}
|
|
18
|
+
return row[b.length];
|
|
19
|
+
};
|
|
20
|
+
const suggest = (name, candidates) => {
|
|
21
|
+
const lower = name.toLowerCase();
|
|
22
|
+
const scored = candidates.map((candidate) => ({
|
|
23
|
+
candidate,
|
|
24
|
+
score: distance(lower, candidate.toLowerCase())
|
|
25
|
+
})).sort((a, b) => a.score - b.score)[0];
|
|
26
|
+
return scored && scored.score <= Math.max(2, Math.floor(name.length / 3)) ? scored.candidate : void 0;
|
|
27
|
+
};
|
|
28
|
+
//#endregion
|
|
29
|
+
export { filterError, suggest };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { CheckedFilter } from "../model.js";
|
|
2
|
+
//#region src/filter/evaluate.d.ts
|
|
3
|
+
export type EvaluateOptions = {
|
|
4
|
+
/** Clock for `now()`; defaults to the current time. */
|
|
5
|
+
now?: () => Date;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Evaluates a checked filter against a plain record keyed by field name, for rules such as
|
|
9
|
+
* access filters on create. Throws `Not implemented` for `search` and `similar`.
|
|
10
|
+
*/
|
|
11
|
+
export declare const evaluateFilter: (checked: CheckedFilter, record: unknown, options?: EvaluateOptions) => boolean;
|
|
12
|
+
//#endregion
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { functions } from "./profile.js";
|
|
2
|
+
import { lift } from "./lift.js";
|
|
3
|
+
import { evaluateFilter } from "aip-parsers/filter";
|
|
4
|
+
//#region src/filter/evaluate.ts
|
|
5
|
+
const needsDatabase = (name) => () => {
|
|
6
|
+
throw new Error(`Not implemented: ${name}() needs the database (the resource's search fields, trigram matching), so it cannot be evaluated against a record`);
|
|
7
|
+
};
|
|
8
|
+
const implementations = (now) => ({
|
|
9
|
+
...functions,
|
|
10
|
+
search: {
|
|
11
|
+
...functions.search,
|
|
12
|
+
evaluate: needsDatabase("search")
|
|
13
|
+
},
|
|
14
|
+
similar: {
|
|
15
|
+
...functions.similar,
|
|
16
|
+
evaluate: needsDatabase("similar")
|
|
17
|
+
},
|
|
18
|
+
regex: {
|
|
19
|
+
...functions.regex,
|
|
20
|
+
evaluate: (values, pattern) => values.some((value) => typeof value === "string" && new RegExp(pattern).test(value))
|
|
21
|
+
},
|
|
22
|
+
now: {
|
|
23
|
+
...functions.now,
|
|
24
|
+
evaluate: now
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* Evaluates a checked filter against a plain record keyed by field name, for rules such as
|
|
29
|
+
* access filters on create. Throws `Not implemented` for `search` and `similar`.
|
|
30
|
+
*/
|
|
31
|
+
const evaluateFilter$1 = (checked, record, options = {}) => evaluateFilter(lift(checked, { expand: true }), record, { functions: implementations(options.now ?? (() => /* @__PURE__ */ new Date())) });
|
|
32
|
+
//#endregion
|
|
33
|
+
export { evaluateFilter$1 as evaluateFilter };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { FilterLimits, FilterParse, parseFilter } from "./parse.js";
|
|
2
|
+
import { CheckResult, checkFilter } from "./check.js";
|
|
3
|
+
import { EvaluateOptions, evaluateFilter } from "./evaluate.js";
|
|
4
|
+
import { printFilter } from "./print.js";
|
|
5
|
+
import { OrderByCheck, OrderByParse, checkOrderBy, parseOrderBy, printOrderBy } from "./order-by.js";
|
|
6
|
+
import { createWhere, where } from "./where.js";
|
|
7
|
+
export { type CheckResult, type EvaluateOptions, type FilterLimits, type FilterParse, type OrderByCheck, type OrderByParse, checkFilter, checkOrderBy, createWhere, evaluateFilter, parseFilter, parseOrderBy, printFilter, printOrderBy, where };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { libraryType } from "./profile.js";
|
|
2
|
+
//#region src/filter/lift.ts
|
|
3
|
+
const member = (field, span) => "path" in field ? {
|
|
4
|
+
kind: "member",
|
|
5
|
+
path: field.path,
|
|
6
|
+
span: field.span
|
|
7
|
+
} : {
|
|
8
|
+
kind: "member",
|
|
9
|
+
path: [field.name],
|
|
10
|
+
field: {
|
|
11
|
+
name: field.name,
|
|
12
|
+
spec: { type: libraryType(field) }
|
|
13
|
+
},
|
|
14
|
+
span
|
|
15
|
+
};
|
|
16
|
+
const call = (name, args, span) => ({
|
|
17
|
+
kind: "call",
|
|
18
|
+
name,
|
|
19
|
+
args,
|
|
20
|
+
span
|
|
21
|
+
});
|
|
22
|
+
const text = (value, span) => ({
|
|
23
|
+
kind: "string",
|
|
24
|
+
value,
|
|
25
|
+
span
|
|
26
|
+
});
|
|
27
|
+
const value = (node) => {
|
|
28
|
+
if (node.kind !== "now") return node;
|
|
29
|
+
const now = {
|
|
30
|
+
kind: "call",
|
|
31
|
+
name: "now",
|
|
32
|
+
args: [],
|
|
33
|
+
span: node.span
|
|
34
|
+
};
|
|
35
|
+
if (!node.offset) return now;
|
|
36
|
+
return {
|
|
37
|
+
kind: "binary",
|
|
38
|
+
op: node.offset.sign,
|
|
39
|
+
left: now,
|
|
40
|
+
right: node.offset.duration,
|
|
41
|
+
span: node.span
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
/** Protobase's filter shape as a generic AIP-160 expression, for checking, printing and evaluating. */
|
|
45
|
+
const lift = (node, options = {}) => {
|
|
46
|
+
const next = (child) => lift(child, options);
|
|
47
|
+
switch (node.kind) {
|
|
48
|
+
case "and":
|
|
49
|
+
case "or": return {
|
|
50
|
+
kind: node.kind,
|
|
51
|
+
args: node.args.map(next),
|
|
52
|
+
span: node.span
|
|
53
|
+
};
|
|
54
|
+
case "not": return {
|
|
55
|
+
kind: "not",
|
|
56
|
+
arg: next(node.arg),
|
|
57
|
+
span: node.span
|
|
58
|
+
};
|
|
59
|
+
case "compare": return {
|
|
60
|
+
kind: "compare",
|
|
61
|
+
op: node.op,
|
|
62
|
+
left: member(node.field, node.span),
|
|
63
|
+
right: value(node.value),
|
|
64
|
+
span: node.span
|
|
65
|
+
};
|
|
66
|
+
case "has": return {
|
|
67
|
+
kind: "has",
|
|
68
|
+
left: member(node.field, node.span),
|
|
69
|
+
right: node.value,
|
|
70
|
+
span: node.span
|
|
71
|
+
};
|
|
72
|
+
case "present": return {
|
|
73
|
+
kind: "present",
|
|
74
|
+
left: member(node.field, node.span),
|
|
75
|
+
span: node.span
|
|
76
|
+
};
|
|
77
|
+
case "in":
|
|
78
|
+
if (options.expand) return {
|
|
79
|
+
kind: "or",
|
|
80
|
+
args: node.values.map((literal) => ({
|
|
81
|
+
kind: "compare",
|
|
82
|
+
op: "=",
|
|
83
|
+
left: member(node.field, node.span),
|
|
84
|
+
right: literal,
|
|
85
|
+
span: node.span
|
|
86
|
+
})),
|
|
87
|
+
span: node.span
|
|
88
|
+
};
|
|
89
|
+
return call("in", [member(node.field, node.span), ...node.values], node.span);
|
|
90
|
+
case "search": return call("search", [text(node.text, node.span)], node.span);
|
|
91
|
+
case "similar": return call("similar", [member(node.field, node.span), text(node.text, node.span)], node.span);
|
|
92
|
+
case "regex": return call("regex", [member(node.field, node.span), text(node.pattern, node.span)], node.span);
|
|
93
|
+
case "isNull":
|
|
94
|
+
if (options.expand) return {
|
|
95
|
+
kind: "not",
|
|
96
|
+
arg: {
|
|
97
|
+
kind: "present",
|
|
98
|
+
left: member(node.field, node.span),
|
|
99
|
+
span: node.span
|
|
100
|
+
},
|
|
101
|
+
span: node.span
|
|
102
|
+
};
|
|
103
|
+
return call("isNull", [member(node.field, node.span)], node.span);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
//#endregion
|
|
107
|
+
export { lift };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { filterError } from "./errors.js";
|
|
2
|
+
import { functions } from "./profile.js";
|
|
3
|
+
//#region src/filter/lower.ts
|
|
4
|
+
const unsupported = (l, message, hint, span) => {
|
|
5
|
+
l.errors.push(filterError("unsupported", message, hint, span));
|
|
6
|
+
};
|
|
7
|
+
const fieldOf = (l, node) => node.kind === "member" ? {
|
|
8
|
+
kind: "field",
|
|
9
|
+
path: node.path,
|
|
10
|
+
span: node.span
|
|
11
|
+
} : unsupported(l, "Only fields can be compared", "Put a field name on the left, for example total > 100", node.span);
|
|
12
|
+
const valueOf = (l, node) => {
|
|
13
|
+
switch (node.kind) {
|
|
14
|
+
case "member": return {
|
|
15
|
+
kind: "string",
|
|
16
|
+
value: node.path.join("."),
|
|
17
|
+
span: node.span
|
|
18
|
+
};
|
|
19
|
+
case "call":
|
|
20
|
+
if (node.name === "now" && node.args.length === 0) return {
|
|
21
|
+
kind: "now",
|
|
22
|
+
span: node.span
|
|
23
|
+
};
|
|
24
|
+
return unsupported(l, `${node.name}() cannot be used as a value`, "Only now() is a value function", node.span);
|
|
25
|
+
case "binary": {
|
|
26
|
+
const left = node.left;
|
|
27
|
+
if (left.kind === "call" && left.name === "now" && left.args.length === 0 && node.right.kind === "duration") return {
|
|
28
|
+
kind: "now",
|
|
29
|
+
offset: {
|
|
30
|
+
sign: node.op,
|
|
31
|
+
duration: node.right
|
|
32
|
+
},
|
|
33
|
+
span: node.span
|
|
34
|
+
};
|
|
35
|
+
return unsupported(l, "Arithmetic is only supported as now() +/- duration", "Write now() - 30d", node.span);
|
|
36
|
+
}
|
|
37
|
+
default: return node;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const literalOf = (l, node) => {
|
|
41
|
+
const value = "kind" in node && [
|
|
42
|
+
"member",
|
|
43
|
+
"call",
|
|
44
|
+
"binary"
|
|
45
|
+
].includes(node.kind) ? void 0 : node;
|
|
46
|
+
if (value) return value;
|
|
47
|
+
if (node.kind === "member") return {
|
|
48
|
+
kind: "string",
|
|
49
|
+
value: node.path.join("."),
|
|
50
|
+
span: node.span
|
|
51
|
+
};
|
|
52
|
+
return unsupported(l, "Expected a literal value", "Use strings, numbers, true/false, durations or timestamps", node.span);
|
|
53
|
+
};
|
|
54
|
+
const wordOf = (node) => {
|
|
55
|
+
switch (node.kind) {
|
|
56
|
+
case "member": return node.path.join(".");
|
|
57
|
+
case "string":
|
|
58
|
+
case "timestamp": return node.kind === "string" ? node.value : node.value;
|
|
59
|
+
case "number": return node.raw;
|
|
60
|
+
case "boolean": return String(node.value);
|
|
61
|
+
case "duration": return `${node.amount}${node.unit}`;
|
|
62
|
+
default: return "";
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const lowerCall = (l, node) => {
|
|
66
|
+
const [first, second, ...rest] = node.args;
|
|
67
|
+
const field = first?.kind === "member" ? fieldOf(l, first) : void 0;
|
|
68
|
+
const string = (arg) => arg?.kind === "string" ? arg.value : void 0;
|
|
69
|
+
const bad = (usage) => {
|
|
70
|
+
l.errors.push(filterError("invalid-arguments", `Invalid arguments to ${node.name}()`, `Usage: ${usage}`, node.span));
|
|
71
|
+
};
|
|
72
|
+
switch (node.name) {
|
|
73
|
+
case "in": {
|
|
74
|
+
const values = node.args.slice(1).map((arg) => literalOf(l, arg));
|
|
75
|
+
if (!field || values.length === 0) return bad("in(field, value1, value2, ...)");
|
|
76
|
+
return values.every((v) => v) ? {
|
|
77
|
+
kind: "in",
|
|
78
|
+
field,
|
|
79
|
+
values,
|
|
80
|
+
span: node.span
|
|
81
|
+
} : void 0;
|
|
82
|
+
}
|
|
83
|
+
case "search": return string(first) !== void 0 && !second ? {
|
|
84
|
+
kind: "search",
|
|
85
|
+
text: string(first),
|
|
86
|
+
span: node.span
|
|
87
|
+
} : bad("search(\"text\")");
|
|
88
|
+
case "similar": return field && string(second) !== void 0 && rest.length === 0 ? {
|
|
89
|
+
kind: "similar",
|
|
90
|
+
field,
|
|
91
|
+
text: string(second),
|
|
92
|
+
span: node.span
|
|
93
|
+
} : bad("similar(field, \"text\")");
|
|
94
|
+
case "regex": return field && string(second) !== void 0 && rest.length === 0 ? {
|
|
95
|
+
kind: "regex",
|
|
96
|
+
field,
|
|
97
|
+
pattern: string(second),
|
|
98
|
+
span: node.span
|
|
99
|
+
} : bad("regex(field, \"pattern\")");
|
|
100
|
+
case "isNull": return field && !second ? {
|
|
101
|
+
kind: "isNull",
|
|
102
|
+
field,
|
|
103
|
+
span: node.span
|
|
104
|
+
} : bad("isNull(field)");
|
|
105
|
+
default:
|
|
106
|
+
l.errors.push(filterError("unknown-function", `Unknown function "${node.name}"`, `Available functions: ${Object.keys(functions).join(", ")}`, node.span));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const all = (items) => items.every((item) => item) ? items : void 0;
|
|
111
|
+
/** Lowers a generic AIP-160 expression to Protobase's filter shape; unsupported constructs become errors. */
|
|
112
|
+
const lower = (l, node) => {
|
|
113
|
+
switch (node.kind) {
|
|
114
|
+
case "sequence":
|
|
115
|
+
case "and":
|
|
116
|
+
case "or": {
|
|
117
|
+
const args = all(node.args.map((arg) => lower(l, arg)));
|
|
118
|
+
return args && {
|
|
119
|
+
kind: node.kind === "or" ? "or" : "and",
|
|
120
|
+
args,
|
|
121
|
+
span: node.span
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
case "not": {
|
|
125
|
+
const arg = lower(l, node.arg);
|
|
126
|
+
return arg && {
|
|
127
|
+
kind: "not",
|
|
128
|
+
arg,
|
|
129
|
+
span: node.span
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
case "compare": {
|
|
133
|
+
const field = fieldOf(l, node.left);
|
|
134
|
+
const value = valueOf(l, node.right);
|
|
135
|
+
return field && value && {
|
|
136
|
+
kind: "compare",
|
|
137
|
+
op: node.op,
|
|
138
|
+
field,
|
|
139
|
+
value,
|
|
140
|
+
span: node.span
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
case "has": {
|
|
144
|
+
const field = fieldOf(l, node.left);
|
|
145
|
+
const value = literalOf(l, node.right);
|
|
146
|
+
return field && value && {
|
|
147
|
+
kind: "has",
|
|
148
|
+
field,
|
|
149
|
+
value,
|
|
150
|
+
span: node.span
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
case "present": {
|
|
154
|
+
const field = fieldOf(l, node.left);
|
|
155
|
+
return field && {
|
|
156
|
+
kind: "present",
|
|
157
|
+
field,
|
|
158
|
+
span: node.span
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
case "global": return {
|
|
162
|
+
kind: "search",
|
|
163
|
+
text: wordOf(node.value),
|
|
164
|
+
span: node.span
|
|
165
|
+
};
|
|
166
|
+
case "call": return lowerCall(l, node);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
//#endregion
|
|
170
|
+
export { lower };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//#region src/filter/operators.ts
|
|
2
|
+
const equality = ["=", "!="];
|
|
3
|
+
const ordered = [
|
|
4
|
+
"=",
|
|
5
|
+
"!=",
|
|
6
|
+
"<",
|
|
7
|
+
"<=",
|
|
8
|
+
">",
|
|
9
|
+
">="
|
|
10
|
+
];
|
|
11
|
+
const compareOps = (type) => {
|
|
12
|
+
switch (type) {
|
|
13
|
+
case "text":
|
|
14
|
+
case "integer":
|
|
15
|
+
case "bigint":
|
|
16
|
+
case "decimal":
|
|
17
|
+
case "date":
|
|
18
|
+
case "timestamp": return ordered;
|
|
19
|
+
case "json":
|
|
20
|
+
case "file": return [];
|
|
21
|
+
default: return equality;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const isOpaque = (type) => type === "json" || type === "file";
|
|
25
|
+
//#endregion
|
|
26
|
+
export { compareOps, isOpaque };
|