@getaster/data 0.0.0-stage → 0.1.0-preview.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.
Files changed (84) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +6 -2
  4. package/dist/authentication.d.ts +42 -0
  5. package/dist/authentication.js +121 -0
  6. package/dist/changes/changeset.d.ts +47 -0
  7. package/dist/changes/changeset.js +75 -0
  8. package/dist/collection/aggregate.d.ts +22 -0
  9. package/dist/collection/aggregate.js +94 -0
  10. package/dist/collection/collection.d.ts +96 -0
  11. package/dist/collection/collection.js +588 -0
  12. package/dist/collection/grouping.d.ts +75 -0
  13. package/dist/collection/grouping.js +190 -0
  14. package/dist/collection/groups.d.ts +31 -0
  15. package/dist/collection/groups.js +117 -0
  16. package/dist/collection/summary.d.ts +17 -0
  17. package/dist/collection/summary.js +103 -0
  18. package/dist/composition/activity.d.ts +36 -0
  19. package/dist/composition/activity.js +60 -0
  20. package/dist/composition/command.d.ts +22 -0
  21. package/dist/composition/command.js +57 -0
  22. package/dist/composition/lifetime.d.ts +11 -0
  23. package/dist/composition/lifetime.js +33 -0
  24. package/dist/composition/sessions.d.ts +64 -0
  25. package/dist/composition/sessions.js +209 -0
  26. package/dist/context.d.ts +76 -0
  27. package/dist/context.js +211 -0
  28. package/dist/criteria/ast.d.ts +79 -0
  29. package/dist/criteria/ast.js +1 -0
  30. package/dist/criteria/compile.d.ts +37 -0
  31. package/dist/criteria/compile.js +233 -0
  32. package/dist/criteria/evaluate.d.ts +20 -0
  33. package/dist/criteria/evaluate.js +171 -0
  34. package/dist/criteria/filter-draft.d.ts +79 -0
  35. package/dist/criteria/filter-draft.js +320 -0
  36. package/dist/criteria/relative.d.ts +13 -0
  37. package/dist/criteria/relative.js +82 -0
  38. package/dist/criteria/values.d.ts +20 -0
  39. package/dist/criteria/values.js +283 -0
  40. package/dist/edit/edit-model.d.ts +152 -0
  41. package/dist/edit/edit-model.js +620 -0
  42. package/dist/edit/list-edit.d.ts +116 -0
  43. package/dist/edit/list-edit.js +332 -0
  44. package/dist/edit/record-edit.d.ts +93 -0
  45. package/dist/edit/record-edit.js +317 -0
  46. package/dist/export/csv.d.ts +25 -0
  47. package/dist/export/csv.js +75 -0
  48. package/dist/index.d.ts +38 -0
  49. package/dist/index.js +35 -0
  50. package/dist/odata/errors.d.ts +25 -0
  51. package/dist/odata/errors.js +56 -0
  52. package/dist/odata/source.d.ts +65 -0
  53. package/dist/odata/source.js +193 -0
  54. package/dist/progress/status.d.ts +29 -0
  55. package/dist/progress/status.js +105 -0
  56. package/dist/schema/csdl.d.ts +13 -0
  57. package/dist/schema/csdl.js +531 -0
  58. package/dist/schema/model.d.ts +132 -0
  59. package/dist/schema/model.js +1 -0
  60. package/dist/scope/filters.d.ts +130 -0
  61. package/dist/scope/filters.js +349 -0
  62. package/dist/scope/publications.d.ts +27 -0
  63. package/dist/scope/publications.js +74 -0
  64. package/dist/scope/related-aggregate.d.ts +9 -0
  65. package/dist/scope/related-aggregate.js +67 -0
  66. package/dist/scope/scope.d.ts +181 -0
  67. package/dist/scope/scope.js +574 -0
  68. package/dist/selection/selection.d.ts +56 -0
  69. package/dist/selection/selection.js +137 -0
  70. package/dist/store/keys.d.ts +10 -0
  71. package/dist/store/keys.js +21 -0
  72. package/dist/store/store.d.ts +72 -0
  73. package/dist/store/store.js +143 -0
  74. package/dist/template/template.d.ts +19 -0
  75. package/dist/template/template.js +186 -0
  76. package/dist/transport.d.ts +28 -0
  77. package/dist/transport.js +20 -0
  78. package/dist/tree/tree.d.ts +117 -0
  79. package/dist/tree/tree.js +435 -0
  80. package/dist/validate/criteria-rule.d.ts +28 -0
  81. package/dist/validate/criteria-rule.js +22 -0
  82. package/dist/validate/validate.d.ts +13 -0
  83. package/dist/validate/validate.js +123 -0
  84. package/package.json +32 -4
@@ -0,0 +1,79 @@
1
+ import type { Value } from "../schema/model.ts";
2
+ /**
3
+ * A date relative to when a query runs: today, or now, taken to the start
4
+ * of its week (Monday) or month if asked, then moved by whole units. It stays
5
+ * symbolic in a query or a published filter and is resolved at each fetch,
6
+ * so "due this week" is still right next week.
7
+ */
8
+ export interface RelativeDate {
9
+ readonly relative: "today" | "now";
10
+ readonly days?: number;
11
+ readonly weeks?: number;
12
+ readonly months?: number;
13
+ readonly startOf?: "week" | "month";
14
+ }
15
+ /**
16
+ * Compiled to `$filter`, and evaluated locally with the same OData semantics.
17
+ * Comparisons are property against literal, or property against property of
18
+ * the same type, so local evaluation can match the server exactly.
19
+ */
20
+ export type Criteria = {
21
+ readonly op: "and" | "or";
22
+ readonly args: readonly Criteria[];
23
+ } | {
24
+ readonly op: "not";
25
+ readonly arg: Criteria;
26
+ } | {
27
+ readonly op: "eq" | "ne" | "gt" | "ge" | "lt" | "le";
28
+ readonly field: string;
29
+ /** A relative date on a date field; resolved (`resolveRelative`) before compiling or evaluating. */
30
+ readonly value: Value | RelativeDate;
31
+ } | {
32
+ readonly op: "eq" | "ne" | "gt" | "ge" | "lt" | "le";
33
+ readonly field: string;
34
+ /** Another property of the same type, compared with `field`. */
35
+ readonly other: string;
36
+ } | {
37
+ readonly op: "in";
38
+ readonly field: string;
39
+ readonly values: readonly Value[];
40
+ } | {
41
+ readonly op: "contains" | "startswith" | "endswith";
42
+ readonly field: string;
43
+ readonly value: string;
44
+ /** Compare through `tolower()` on both sides. */
45
+ readonly caseInsensitive?: boolean;
46
+ }
47
+ /**
48
+ * Over a to-many navigation (`answers`): some related record matches
49
+ * `where`, or every one does (so `all` of none holds). `where` names the
50
+ * related records' properties.
51
+ */
52
+ | {
53
+ readonly op: "any" | "all";
54
+ readonly field: string;
55
+ readonly where: Criteria;
56
+ }
57
+ /**
58
+ * Through a single-valued navigation (`project`): the related record
59
+ * matches `where`, which names its properties. Compiles to a path
60
+ * (`project/health eq 'behind'`); with no related record its properties
61
+ * are null, as on the server.
62
+ */
63
+ | {
64
+ readonly op: "via";
65
+ readonly field: string;
66
+ readonly where: Criteria;
67
+ };
68
+ export interface SortKey {
69
+ readonly field: string;
70
+ readonly descending?: boolean;
71
+ }
72
+ /** The query a collection is a window over. */
73
+ export interface Query {
74
+ readonly set: string;
75
+ readonly filter?: Criteria;
76
+ /** The key properties are appended as tie-breakers, as the server does. */
77
+ readonly orderBy?: readonly SortKey[];
78
+ readonly select?: readonly string[];
79
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ import type { EntitySet, EntityType, Field, Schema, Value } from "../schema/model.ts";
2
+ import type { Criteria, Query, SortKey } from "./ast.ts";
3
+ import { CriteriaError } from "./values.ts";
4
+ export { CriteriaError };
5
+ /** The OData URL literal for `value` as a `field`-typed value. */
6
+ export declare function formatLiteral(value: Value, field: Field): string;
7
+ /** The right-hand property of a property comparison: it must have `left`'s type. */
8
+ export declare function otherOf(type: EntityType, name: string, left: Field, set?: EntitySet): Field;
9
+ /** The `$filter` text (not percent-encoded). Throws `CriteriaError`. `any` and `all` need the schema. */
10
+ export declare function toFilter(criteria: Criteria, type: EntityType, set?: EntitySet, schema?: Schema): string;
11
+ /** `sort` with the key properties appended ascending, skipping keys already sorted on. */
12
+ export declare function withKeyTieBreakers(sort: readonly SortKey[], type: EntityType): readonly SortKey[];
13
+ /**
14
+ * A sort key's field. A key can be a path through single-valued
15
+ * navigations (`person/name`), which needs the schema to follow.
16
+ */
17
+ export declare function checkSortable(key: SortKey, type: EntityType, set?: EntitySet, schema?: Schema): Field;
18
+ /** The `$orderby` text, key tie-breakers included. */
19
+ export declare function toOrderBy(sort: readonly SortKey[], type: EntityType, schema?: Schema): string;
20
+ export declare function isPath(field: string): boolean;
21
+ /** The field at the end of `nav/.../field`, through single-valued navigations only. */
22
+ export declare function resolvePath(path: string, type: EntityType, schema: Schema | undefined): Field;
23
+ /** `$filter`, `$orderby` and `$select` as `a=b&c=d` with encoded values, no leading `?`. */
24
+ export declare function toQueryString(query: Query, schema: Schema): string;
25
+ /** One aggregate over a property (OData Data Aggregation `with`). */
26
+ export interface Aggregate {
27
+ readonly field: string;
28
+ readonly method: "sum" | "min" | "max" | "average" | "countdistinct";
29
+ readonly as: string;
30
+ }
31
+ /**
32
+ * `$apply` text: the filter, then grouping by `groupBy` (or none) with the
33
+ * row count as `count` and each aggregate under its alias.
34
+ */
35
+ export declare function toApply(type: EntityType, filter: Criteria | undefined, groupBy: readonly string[], aggregates: readonly Aggregate[], set?: EntitySet, schema?: Schema): string;
36
+ /** Queries built afresh are equal when they say the same thing. */
37
+ export declare function sameQuery(a: Query, b: Query): boolean;
@@ -0,0 +1,233 @@
1
+ import { assertLiteral, CriteriaError, plain } from "./values.js";
2
+ export { CriteriaError };
3
+ /** The OData URL literal for `value` as a `field`-typed value. */
4
+ export function formatLiteral(value, field) {
5
+ if (value === null)
6
+ return "null";
7
+ assertLiteral(value, field);
8
+ switch (field.type) {
9
+ case "Edm.String":
10
+ return `'${value.replaceAll("'", "''")}'`;
11
+ case "Edm.Single":
12
+ case "Edm.Double": {
13
+ const n = value;
14
+ if (Number.isNaN(n))
15
+ return "NaN";
16
+ if (n === Infinity)
17
+ return "INF";
18
+ if (n === -Infinity)
19
+ return "-INF";
20
+ return Object.is(n, -0) ? "0" : String(n);
21
+ }
22
+ case "Edm.Duration":
23
+ return `duration'${value}'`;
24
+ default:
25
+ return String(value);
26
+ }
27
+ }
28
+ function fieldOf(type, name, set) {
29
+ const f = type.field(name);
30
+ if (!f)
31
+ throw new CriteriaError(`unknown field '${name}' on ${type.name}`);
32
+ if (set?.nonFilterable.has(name))
33
+ throw new CriteriaError(`field '${name}' is not filterable`);
34
+ return f;
35
+ }
36
+ /** The right-hand property of a property comparison: it must have `left`'s type. */
37
+ export function otherOf(type, name, left, set) {
38
+ const f = fieldOf(type, name, set);
39
+ if (f.type !== left.type)
40
+ throw new CriteriaError(`can't compare ${left.name} (${left.type}) with ${f.name} (${f.type})`);
41
+ return f;
42
+ }
43
+ const TOP = { schema: undefined, prefix: "", depth: 0 };
44
+ // `and` binds tighter than `or`; every other node is atomic or self-delimiting.
45
+ function render(c, type, set, nested, at = TOP) {
46
+ const p = at.prefix;
47
+ switch (c.op) {
48
+ case "and":
49
+ case "or": {
50
+ if (c.args.length === 0)
51
+ return c.op === "and" ? "true" : "false";
52
+ if (c.args.length === 1)
53
+ return render(c.args[0], type, set, nested, at);
54
+ const text = c.args.map((a) => render(a, type, set, true, at)).join(` ${c.op} `);
55
+ return nested ? `(${text})` : text;
56
+ }
57
+ case "not":
58
+ return `not (${render(c.arg, type, set, false, at)})`;
59
+ case "any":
60
+ case "all": {
61
+ const nav = type.navigation(c.field);
62
+ if (!nav)
63
+ throw new CriteriaError(`unknown navigation '${c.field}' on ${type.name}`);
64
+ if (!nav.collection)
65
+ throw new CriteriaError(`'${c.field}' on ${type.name} isn't a collection; ${c.op} needs one`);
66
+ if (!at.schema)
67
+ throw new CriteriaError(`${c.op} over '${c.field}' needs the schema to follow it`);
68
+ const target = at.schema.set(nav.target);
69
+ const v = `x${at.depth}`;
70
+ return `${p}${nav.name}/${c.op}(${v}:${render(c.where, target.type, target, false, { schema: at.schema, prefix: `${v}/`, depth: at.depth + 1 })})`;
71
+ }
72
+ case "via": {
73
+ const nav = type.navigation(c.field);
74
+ if (!nav)
75
+ throw new CriteriaError(`unknown navigation '${c.field}' on ${type.name}`);
76
+ if (nav.collection)
77
+ throw new CriteriaError(`'${c.field}' on ${type.name} is a collection; via follows a single record, any and all a collection`);
78
+ if (!at.schema)
79
+ throw new CriteriaError(`via '${c.field}' needs the schema to follow it`);
80
+ const target = at.schema.set(nav.target);
81
+ return render(c.where, target.type, target, nested, { ...at, prefix: `${p}${nav.name}/` });
82
+ }
83
+ case "eq":
84
+ case "ne":
85
+ case "gt":
86
+ case "ge":
87
+ case "lt":
88
+ case "le": {
89
+ const f = fieldOf(type, c.field, set);
90
+ if ("other" in c)
91
+ return `${p}${f.name} ${c.op} ${p}${otherOf(type, c.other, f, set).name}`;
92
+ const value = plain(c.value);
93
+ if (c.op === "eq" || c.op === "ne")
94
+ return `${p}${f.name} ${c.op} ${formatLiteral(value, f)}`;
95
+ if (value === null)
96
+ throw new CriteriaError(`${c.op} ${f.name}: null is not orderable`);
97
+ return `${p}${f.name} ${c.op} ${formatLiteral(value, f)}`;
98
+ }
99
+ case "in": {
100
+ const f = fieldOf(type, c.field, set);
101
+ if (c.values.length === 0)
102
+ return "false";
103
+ return `${p}${f.name} in (${c.values.map((v) => formatLiteral(v, f)).join(",")})`;
104
+ }
105
+ case "contains":
106
+ case "startswith":
107
+ case "endswith": {
108
+ const f = fieldOf(type, c.field, set);
109
+ if (f.type !== "Edm.String")
110
+ throw new CriteriaError(`${c.op} needs a string field, ${f.name} is ${f.type}`);
111
+ if (typeof c.value !== "string")
112
+ throw new CriteriaError(`${c.op} ${f.name}: expected a string`);
113
+ return c.caseInsensitive
114
+ ? `${c.op}(tolower(${p}${f.name}),${formatLiteral(c.value.toLowerCase(), f)})`
115
+ : `${c.op}(${p}${f.name},${formatLiteral(c.value, f)})`;
116
+ }
117
+ }
118
+ }
119
+ /** The `$filter` text (not percent-encoded). Throws `CriteriaError`. `any` and `all` need the schema. */
120
+ export function toFilter(criteria, type, set, schema) {
121
+ return render(criteria, type, set, false, { ...TOP, schema });
122
+ }
123
+ /** `sort` with the key properties appended ascending, skipping keys already sorted on. */
124
+ export function withKeyTieBreakers(sort, type) {
125
+ const seen = new Set(sort.map((k) => k.field));
126
+ return [...sort, ...type.key.filter((k) => !seen.has(k)).map((field) => ({ field }))];
127
+ }
128
+ /**
129
+ * A sort key's field. A key can be a path through single-valued
130
+ * navigations (`person/name`), which needs the schema to follow.
131
+ */
132
+ export function checkSortable(key, type, set, schema) {
133
+ if (isPath(key.field)) {
134
+ const nav = key.field.split("/")[0];
135
+ if (set?.nonSortable.has(nav))
136
+ throw new CriteriaError(`'${key.field}' is not sortable: ${nav} is not`);
137
+ return resolvePath(key.field, type, schema);
138
+ }
139
+ const f = type.field(key.field);
140
+ if (!f)
141
+ throw new CriteriaError(`unknown field '${key.field}' on ${type.name}`);
142
+ if (f.type === "Edm.Binary")
143
+ throw new CriteriaError(`field '${key.field}' cannot be sorted`);
144
+ if (set?.nonSortable.has(key.field))
145
+ throw new CriteriaError(`field '${key.field}' is not sortable`);
146
+ return f;
147
+ }
148
+ /** The `$orderby` text, key tie-breakers included. */
149
+ export function toOrderBy(sort, type, schema) {
150
+ return withKeyTieBreakers(sort, type)
151
+ .map((k) => (checkSortable(k, type, undefined, schema), `${k.field}${k.descending ? " desc" : ""}`))
152
+ .join(",");
153
+ }
154
+ export function isPath(field) {
155
+ return field.includes("/");
156
+ }
157
+ /** The field at the end of `nav/.../field`, through single-valued navigations only. */
158
+ export function resolvePath(path, type, schema) {
159
+ if (!schema)
160
+ throw new CriteriaError(`'${path}' is a path; following it needs the schema`);
161
+ const steps = path.split("/");
162
+ let at = type;
163
+ for (const step of steps.slice(0, -1)) {
164
+ const nav = at.navigation(step);
165
+ if (!nav)
166
+ throw new CriteriaError(`unknown navigation '${step}' on ${at.name}`);
167
+ if (nav.collection)
168
+ throw new CriteriaError(`'${step}' on ${at.name} is a collection; a path can only follow single records`);
169
+ at = schema.set(nav.target).type;
170
+ }
171
+ const name = steps.at(-1);
172
+ const f = at.field(name);
173
+ if (!f)
174
+ throw new CriteriaError(`unknown field '${name}' on ${at.name}`);
175
+ if (f.type === "Edm.Binary")
176
+ throw new CriteriaError(`field '${path}' cannot be sorted`);
177
+ return f;
178
+ }
179
+ /** `$filter`, `$orderby` and `$select` as `a=b&c=d` with encoded values, no leading `?`. */
180
+ export function toQueryString(query, schema) {
181
+ const set = schema.set(query.set);
182
+ const type = set.type;
183
+ const parts = [];
184
+ if (query.filter)
185
+ parts.push(`$filter=${encodeURIComponent(toFilter(query.filter, type, set, schema))}`);
186
+ const orderBy = query.orderBy ?? [];
187
+ for (const k of orderBy)
188
+ checkSortable(k, type, set, schema);
189
+ parts.push(`$orderby=${encodeURIComponent(toOrderBy(orderBy, type, schema))}`);
190
+ if (query.select && query.select.length > 0) {
191
+ for (const s of query.select) {
192
+ if (!type.field(s))
193
+ throw new CriteriaError(`unknown field '${s}' on ${type.name}`);
194
+ }
195
+ parts.push(`$select=${encodeURIComponent(query.select.join(","))}`);
196
+ }
197
+ return parts.join("&");
198
+ }
199
+ /**
200
+ * `$apply` text: the filter, then grouping by `groupBy` (or none) with the
201
+ * row count as `count` and each aggregate under its alias.
202
+ */
203
+ export function toApply(type, filter, groupBy, aggregates, set, schema) {
204
+ for (const g of groupBy)
205
+ fieldOf(type, g, set);
206
+ const aliases = new Set(["count", ...groupBy]);
207
+ for (const a of aggregates) {
208
+ const field = fieldOf(type, a.field, set);
209
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(a.as) || aliases.has(a.as))
210
+ throw new CriteriaError(`Invalid or duplicate aggregate alias ${a.as}`);
211
+ aliases.add(a.as);
212
+ switch (a.method) {
213
+ case "sum":
214
+ case "average":
215
+ if (!["Edm.Byte", "Edm.SByte", "Edm.Int16", "Edm.Int32", "Edm.Int64", "Edm.Single", "Edm.Double", "Edm.Decimal"].includes(field.type))
216
+ throw new CriteriaError(`${a.method} requires a numeric field`);
217
+ break;
218
+ case "min":
219
+ case "max":
220
+ case "countdistinct":
221
+ break;
222
+ default:
223
+ throw new CriteriaError(`Unknown aggregate method ${String(a.method)}`);
224
+ }
225
+ }
226
+ const values = ["$count as count", ...aggregates.map((a) => `${a.field} with ${a.method} as ${a.as}`)].join(",");
227
+ const step = groupBy.length ? `groupby((${groupBy.join(",")}),aggregate(${values}))` : `aggregate(${values})`;
228
+ return filter ? `filter(${render(filter, type, set, false, { ...TOP, schema })})/${step}` : step;
229
+ }
230
+ /** Queries built afresh are equal when they say the same thing. */
231
+ export function sameQuery(a, b) {
232
+ return a === b || JSON.stringify(a) === JSON.stringify(b);
233
+ }
@@ -0,0 +1,20 @@
1
+ import type { Entity, EntityType, Field, Value } from "../schema/model.ts";
2
+ import type { Criteria, SortKey } from "./ast.ts";
3
+ /** The type of the records a navigation leads to, for `any`, `all` and `via`. */
4
+ export type Related = (from: EntityType, navigation: string) => EntityType | undefined;
5
+ /**
6
+ * Whether `entity` satisfies `criteria`, with the server's `$filter`
7
+ * semantics (null and unknown both exclude). `any` and `all` read the
8
+ * related records from the entity, as a list, and `via` the related record,
9
+ * typed by `related`. A test of a field in `unknown` (hidden on the record)
10
+ * is unknown, whatever value the entity holds, so `eq null` and `ne` can't
11
+ * decide on it either.
12
+ */
13
+ export declare function matches(criteria: Criteria, entity: Entity, type: EntityType, related?: Related, unknown?: ReadonlySet<string>): boolean;
14
+ /**
15
+ * Ascending total order for sorting: null first, a NaN after every number and
16
+ * equal to other NaNs. Descending sorts reverse all of it.
17
+ */
18
+ export declare function compareValues(a: Value, b: Value, field: Field): number;
19
+ /** Orders entities as `$orderby=<sort>` (key tie-breakers appended) does. */
20
+ export declare function sortComparator(sort: readonly SortKey[], type: EntityType): (a: Entity, b: Entity) => number;
@@ -0,0 +1,171 @@
1
+ import { checkSortable, otherOf, withKeyTieBreakers } from "./compile.js";
2
+ import { assertLiteral, CriteriaError, isNaNValue, ordering, plain } from "./values.js";
3
+ function fieldOf(type, name) {
4
+ const f = type.field(name);
5
+ if (!f)
6
+ throw new CriteriaError(`unknown field '${name}' on ${type.name}`);
7
+ return f;
8
+ }
9
+ function lower(s) {
10
+ // Matches Rust `str::to_lowercase`: full Unicode mapping with final-sigma handling.
11
+ return s.toLowerCase();
12
+ }
13
+ function evalNode(c, e, type, related, unknownFields) {
14
+ switch (c.op) {
15
+ case "and": {
16
+ let unknown = false;
17
+ for (const a of c.args) {
18
+ const t = evalNode(a, e, type, related, unknownFields);
19
+ if (t === false)
20
+ return false;
21
+ if (t === null)
22
+ unknown = true;
23
+ }
24
+ return unknown ? null : true;
25
+ }
26
+ case "or": {
27
+ let unknown = false;
28
+ for (const a of c.args) {
29
+ const t = evalNode(a, e, type, related, unknownFields);
30
+ if (t === true)
31
+ return true;
32
+ if (t === null)
33
+ unknown = true;
34
+ }
35
+ return unknown ? null : false;
36
+ }
37
+ case "not": {
38
+ const t = evalNode(c.arg, e, type, related, unknownFields);
39
+ return t === null ? null : !t;
40
+ }
41
+ case "any":
42
+ case "all": {
43
+ if (unknownFields?.has(c.field))
44
+ return null;
45
+ const target = related?.(type, c.field);
46
+ if (!target)
47
+ throw new CriteriaError(`${c.op} over '${c.field}' needs the related records' type`);
48
+ const rows = e[c.field];
49
+ if (!Array.isArray(rows))
50
+ throw new CriteriaError(`'${c.field}' isn't loaded as a list of records`);
51
+ // OData lambdas: a row whose test is unknown counts as not matching.
52
+ return c.op === "any"
53
+ ? rows.some((r) => evalNode(c.where, r, target, related) === true)
54
+ : rows.every((r) => evalNode(c.where, r, target, related) === true);
55
+ }
56
+ case "via": {
57
+ const target = related?.(type, c.field);
58
+ if (!target)
59
+ throw new CriteriaError(`via '${c.field}' needs the related record's type`);
60
+ const record = e[c.field];
61
+ if (record === undefined || (record !== null && typeof record !== "object"))
62
+ throw new CriteriaError(`'${c.field}' isn't loaded as a record`);
63
+ // No related record: its properties are all null, as on the server.
64
+ return evalNode(c.where, (record ?? {}), target, related);
65
+ }
66
+ case "eq":
67
+ case "ne":
68
+ case "gt":
69
+ case "ge":
70
+ case "lt":
71
+ case "le": {
72
+ const f = fieldOf(type, c.field);
73
+ if (unknownFields?.has(c.field) || ("other" in c && unknownFields?.has(c.other)))
74
+ return null;
75
+ let right;
76
+ if ("other" in c) {
77
+ right = e[otherOf(type, c.other, f).name] ?? null;
78
+ }
79
+ else {
80
+ right = plain(c.value);
81
+ assertLiteral(right, f);
82
+ }
83
+ const v = e[c.field] ?? null;
84
+ if (c.op === "eq" || c.op === "ne") {
85
+ // P2 5.1.1.1.1-2: null equals only null; `ne` is the exact negation.
86
+ const equal = v === null || right === null ? v === right : ordering(v, right, f) === 0;
87
+ return c.op === "eq" ? equal : !equal;
88
+ }
89
+ // P2 5.1.1.1.3: any null operand gives false, not unknown, so `not` of it is true.
90
+ if (v === null || right === null)
91
+ return false;
92
+ const o = ordering(v, right, f);
93
+ if (o === undefined)
94
+ return false;
95
+ return c.op === "gt" ? o > 0 : c.op === "ge" ? o >= 0 : c.op === "lt" ? o < 0 : o <= 0;
96
+ }
97
+ case "in": {
98
+ const f = fieldOf(type, c.field);
99
+ if (unknownFields?.has(c.field))
100
+ return null;
101
+ const v = e[c.field] ?? null;
102
+ for (const x of c.values)
103
+ assertLiteral(x, f);
104
+ // `in` is eq-based: a null needle matches a null member, and the result is never unknown.
105
+ if (v === null)
106
+ return c.values.includes(null);
107
+ return c.values.some((x) => x !== null && ordering(v, x, f) === 0);
108
+ }
109
+ case "contains":
110
+ case "startswith":
111
+ case "endswith": {
112
+ const f = fieldOf(type, c.field);
113
+ if (f.type !== "Edm.String")
114
+ throw new CriteriaError(`${c.op} needs a string field, ${f.name} is ${f.type}`);
115
+ if (typeof c.value !== "string")
116
+ throw new CriteriaError(`${c.op} ${f.name}: expected a string`);
117
+ if (unknownFields?.has(c.field))
118
+ return null;
119
+ const v = e[c.field] ?? null;
120
+ // Functions of a null argument are null, and `not` of null stays null (row excluded).
121
+ if (v === null)
122
+ return null;
123
+ if (typeof v !== "string")
124
+ throw new CriteriaError(`${f.name}: expected a string, got ${JSON.stringify(v)}`);
125
+ const h = c.caseInsensitive ? lower(v) : v;
126
+ const n = c.caseInsensitive ? lower(c.value) : c.value;
127
+ return c.op === "contains" ? h.includes(n) : c.op === "startswith" ? h.startsWith(n) : h.endsWith(n);
128
+ }
129
+ }
130
+ }
131
+ /**
132
+ * Whether `entity` satisfies `criteria`, with the server's `$filter`
133
+ * semantics (null and unknown both exclude). `any` and `all` read the
134
+ * related records from the entity, as a list, and `via` the related record,
135
+ * typed by `related`. A test of a field in `unknown` (hidden on the record)
136
+ * is unknown, whatever value the entity holds, so `eq null` and `ne` can't
137
+ * decide on it either.
138
+ */
139
+ export function matches(criteria, entity, type, related, unknown) {
140
+ return evalNode(criteria, entity, type, related, unknown) === true;
141
+ }
142
+ /**
143
+ * Ascending total order for sorting: null first, a NaN after every number and
144
+ * equal to other NaNs. Descending sorts reverse all of it.
145
+ */
146
+ export function compareValues(a, b, field) {
147
+ if (a === null || b === null)
148
+ return a === b ? 0 : a === null ? -1 : 1;
149
+ const o = ordering(a, b, field);
150
+ if (o !== undefined)
151
+ return o;
152
+ const an = isNaNValue(a);
153
+ const bn = isNaNValue(b);
154
+ return an === bn ? 0 : an ? 1 : -1;
155
+ }
156
+ /** Orders entities as `$orderby=<sort>` (key tie-breakers appended) does. */
157
+ export function sortComparator(sort, type) {
158
+ const keys = withKeyTieBreakers(sort, type).map((k) => ({
159
+ name: k.field,
160
+ field: checkSortable(k, type),
161
+ dir: k.descending ? -1 : 1,
162
+ }));
163
+ return (a, b) => {
164
+ for (const k of keys) {
165
+ const c = compareValues(a[k.name] ?? null, b[k.name] ?? null, k.field);
166
+ if (c !== 0)
167
+ return c * k.dir;
168
+ }
169
+ return 0;
170
+ };
171
+ }
@@ -0,0 +1,79 @@
1
+ import { type ReadonlySignal } from "@preact/signals-core";
2
+ import type { Criteria } from "./ast.ts";
3
+ import type { EntityType, Field, Schema } from "../schema/model.ts";
4
+ /** An editable boolean group. A synthetic root does not add a redundant AND. */
5
+ export interface FilterGroup {
6
+ readonly kind: "group";
7
+ readonly id: string;
8
+ readonly op: string;
9
+ readonly children: readonly FilterNode[];
10
+ readonly synthetic?: boolean;
11
+ }
12
+ /** A typed comparison; text remains unparsed while a person edits. */
13
+ export interface FilterCondition {
14
+ readonly kind: "condition";
15
+ readonly id: string;
16
+ readonly field: string;
17
+ readonly op: string;
18
+ readonly mode: "value" | "field" | "relative";
19
+ readonly text: string;
20
+ readonly other: string;
21
+ readonly caseInsensitive: boolean;
22
+ }
23
+ /** A related-record condition scoped to a navigation's entity type. */
24
+ export interface FilterRelated {
25
+ readonly kind: "related";
26
+ readonly id: string;
27
+ readonly field: string;
28
+ readonly op: string;
29
+ readonly children: readonly FilterNode[];
30
+ }
31
+ export type FilterNode = FilterGroup | FilterCondition | FilterRelated;
32
+ export interface FilterProblem {
33
+ readonly id: string;
34
+ readonly message: string;
35
+ }
36
+ export interface FilterResult {
37
+ readonly valid: boolean;
38
+ readonly criteria: Criteria | undefined;
39
+ readonly problems: readonly FilterProblem[];
40
+ }
41
+ export interface FilterChange {
42
+ readonly field?: string;
43
+ readonly op?: string;
44
+ readonly mode?: FilterCondition["mode"];
45
+ readonly text?: string;
46
+ readonly other?: string;
47
+ readonly caseInsensitive?: boolean;
48
+ }
49
+ /** Typed operators offered by both metadata-driven query controls and filter drafts. */
50
+ export declare function filterOperators(field: Field | undefined): readonly string[];
51
+ /** A query draft independent of database clause records. Only apply changes its committed criteria. */
52
+ export declare class FilterDraft {
53
+ #private;
54
+ readonly schema: Schema;
55
+ readonly set: string;
56
+ readonly root: ReadonlySignal<FilterGroup>;
57
+ readonly applied: ReadonlySignal<Criteria | undefined>;
58
+ readonly dirty: ReadonlySignal<boolean>;
59
+ readonly result: ReadonlySignal<FilterResult>;
60
+ constructor(schema: Schema, set: string, criteria?: Criteria);
61
+ /** Replaces the applied value and draft together; no query or record write is performed. */
62
+ reset(criteria?: Criteria): void;
63
+ /** Discards draft edits and restores the applied expression. */
64
+ cancel(): void;
65
+ /** Clears only the draft. Call apply to commit the cleared expression. */
66
+ clear(): void;
67
+ /** Commits a complete valid draft atomically; returns false for any invalid clause. */
68
+ apply(): boolean;
69
+ /** Changes a clause without dropping incompatible values or unsupported fields. */
70
+ change(id: string, change: FilterChange): void;
71
+ /** Adds a condition, boolean group or related-record group. Returns its stable draft ID. */
72
+ add(parent: string, kind?: FilterNode["kind"]): string;
73
+ /** Removes a clause and its descendants. The root remains available for adding conditions. */
74
+ remove(id: string): void;
75
+ /** Entity type in scope at a clause; undefined while an ancestor navigation is invalid. */
76
+ typeFor(id: string): EntityType | undefined;
77
+ }
78
+ /** A readable bracketed expression using metadata titles; this is not executable text. */
79
+ export declare function describeCriteria(criteria: Criteria | undefined, type: EntityType): string;