@dudousxd/nestjs-catalog 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalog.controller.js +18 -2
- package/dist/catalog.events.d.ts +10 -3
- package/dist/catalog.filters.d.ts +170 -0
- package/dist/catalog.filters.js +272 -0
- package/dist/catalog.pipeline.d.ts +283 -0
- package/dist/catalog.pipeline.js +24 -0
- package/dist/catalog.service.d.ts +15 -0
- package/dist/catalog.service.js +54 -1
- package/dist/catalog.store.d.ts +87 -1
- package/dist/catalog.store.js +76 -1
- package/dist/catalog.types.d.ts +57 -0
- package/dist/client.d.ts +147 -0
- package/dist/client.js +83 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +14 -2
- package/dist/stores/mikro-orm-read.store.d.ts +8 -2
- package/dist/stores/mikro-orm-read.store.js +77 -11
- package/package.json +1 -1
|
@@ -191,7 +191,7 @@ function createCatalogController(path, guards, decorators = []) {
|
|
|
191
191
|
return this.registry.getSnapshot();
|
|
192
192
|
}
|
|
193
193
|
/** One generic read endpoint for every type in the catalog. */
|
|
194
|
-
objects(name, page, size, search, sort, dir, snapshot) {
|
|
194
|
+
objects(name, page, size, search, sort, dir, snapshot, filter) {
|
|
195
195
|
return this.service.readObjects(name, {
|
|
196
196
|
page: page ? Number(page) : undefined,
|
|
197
197
|
size: size ? Number(size) : undefined,
|
|
@@ -199,6 +199,7 @@ function createCatalogController(path, guards, decorators = []) {
|
|
|
199
199
|
sort,
|
|
200
200
|
dir: dir === 'desc' ? 'desc' : 'asc',
|
|
201
201
|
snapshot,
|
|
202
|
+
filters: repeatable(filter),
|
|
202
203
|
});
|
|
203
204
|
}
|
|
204
205
|
/**
|
|
@@ -492,8 +493,9 @@ function createCatalogController(path, guards, decorators = []) {
|
|
|
492
493
|
__param(4, (0, common_1.Query)('sort')),
|
|
493
494
|
__param(5, (0, common_1.Query)('dir')),
|
|
494
495
|
__param(6, (0, common_1.Query)('snapshot')),
|
|
496
|
+
__param(7, (0, common_1.Query)('filter')),
|
|
495
497
|
__metadata("design:type", Function),
|
|
496
|
-
__metadata("design:paramtypes", [String, String, String, String, String, String, String]),
|
|
498
|
+
__metadata("design:paramtypes", [String, String, String, String, String, String, String, Object]),
|
|
497
499
|
__metadata("design:returntype", void 0)
|
|
498
500
|
], CatalogController.prototype, "objects", null);
|
|
499
501
|
__decorate([
|
|
@@ -739,6 +741,20 @@ function actorOf(request, claimed) {
|
|
|
739
741
|
return resolved;
|
|
740
742
|
return claimed?.trim() || 'console';
|
|
741
743
|
}
|
|
744
|
+
/**
|
|
745
|
+
* A query parameter that may appear once or many times, as a list either way.
|
|
746
|
+
*
|
|
747
|
+
* Unlike {@link parseOutcomes} below, nothing is split on commas and nothing is
|
|
748
|
+
* dropped. A filter's value is free text that may contain a comma — a
|
|
749
|
+
* description, a date range written by hand — and an unrecognised filter must
|
|
750
|
+
* reach the service so it can be refused by name rather than vanish into an
|
|
751
|
+
* unfiltered page.
|
|
752
|
+
*/
|
|
753
|
+
function repeatable(raw) {
|
|
754
|
+
if (raw === undefined)
|
|
755
|
+
return undefined;
|
|
756
|
+
return Array.isArray(raw) ? raw : [raw];
|
|
757
|
+
}
|
|
742
758
|
/**
|
|
743
759
|
* `?outcome=failed`, `?outcome=failed,incomplete`, `?outcome=a&outcome=b`.
|
|
744
760
|
*
|
package/dist/catalog.events.d.ts
CHANGED
|
@@ -106,10 +106,17 @@ export interface CatalogEventPayloads {
|
|
|
106
106
|
snapshotId: string;
|
|
107
107
|
};
|
|
108
108
|
/**
|
|
109
|
-
* Someone changed a label, description, unit or visibility
|
|
109
|
+
* Someone changed a label, description, unit or visibility — or stated how a
|
|
110
|
+
* type reconciles deletes.
|
|
110
111
|
*
|
|
111
|
-
* Presentation
|
|
112
|
-
* is a governance question
|
|
112
|
+
* Presentation was the whole of it once, and emitted anyway: "who renamed this
|
|
113
|
+
* column and when" is a governance question whose answer is otherwise nowhere.
|
|
114
|
+
* Per-type load expectations then arrived on the same event, and a delete
|
|
115
|
+
* strategy is not presentation — it decides whether an incremental load of the
|
|
116
|
+
* type may commit at all. `changed` tells the two apart (`expectation.deletes`
|
|
117
|
+
* and `expectation.cleared` against the field names a rename carries), which is
|
|
118
|
+
* why widening this event was better than minting a second one nobody's
|
|
119
|
+
* recorder would have been reading.
|
|
113
120
|
*/
|
|
114
121
|
'type.curated': {
|
|
115
122
|
typeName: string;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { CatalogPropertyDef, ScalarType } from './catalog.types';
|
|
2
|
+
/**
|
|
3
|
+
* Filtering a catalogued type, derived from the type.
|
|
4
|
+
*
|
|
5
|
+
* **Nothing here lists a filterable column, and nothing anywhere else may.** The
|
|
6
|
+
* types this catalog serves are created at runtime — `PUT publish/:type/schema`
|
|
7
|
+
* writes the properties and the store builds the physical columns from them — so
|
|
8
|
+
* a hand-maintained list of what is filterable is a list that goes quiet the day
|
|
9
|
+
* somebody publishes a column nobody edited it for. That is the failure mode this
|
|
10
|
+
* module exists to remove: the operators a column offers are a function of the
|
|
11
|
+
* column, computed by {@link filterOperatorsFor}, and both the server (deciding
|
|
12
|
+
* what it will accept) and the console (deciding what to draw) call that one
|
|
13
|
+
* function. A screen offering a control the server would refuse, or refusing one
|
|
14
|
+
* the server would take, is then not expressible.
|
|
15
|
+
*
|
|
16
|
+
* This is also why `@dudousxd/nestjs-filter` is not what is used here. That
|
|
17
|
+
* library derives its filters from entities and routes known at compile time, and
|
|
18
|
+
* these types do not exist at compile time.
|
|
19
|
+
*
|
|
20
|
+
* **The module is pure and imports nothing but types**, because it ships to the
|
|
21
|
+
* browser through `@dudousxd/nestjs-catalog/client` — the same reason
|
|
22
|
+
* `validateWorkflow` does. A second copy of the derivation living in the console
|
|
23
|
+
* is exactly the drift that entry point exists to prevent.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Every operator, in the order a control should offer them.
|
|
27
|
+
*
|
|
28
|
+
* One list, so nothing narrows a stored or transmitted operator against a second
|
|
29
|
+
* hand-maintained copy of these names — the same argument
|
|
30
|
+
* `CATALOG_SNAPSHOT_MODES` makes one file over.
|
|
31
|
+
*
|
|
32
|
+
* There is no `between`. A range is `gte` and `lte` on one property, which is two
|
|
33
|
+
* filters that compose with everything else rather than one operator that needs
|
|
34
|
+
* its own wire form, its own second value and its own "what if the ends are the
|
|
35
|
+
* wrong way round" answer.
|
|
36
|
+
*/
|
|
37
|
+
export declare const CATALOG_FILTER_OPERATORS: readonly ["eq", "ne", "contains", "gte", "lte", "gt", "lt", "empty", "notEmpty"];
|
|
38
|
+
export type CatalogFilterOperator = (typeof CATALOG_FILTER_OPERATORS)[number];
|
|
39
|
+
export declare function isCatalogFilterOperator(value: string): value is CatalogFilterOperator;
|
|
40
|
+
/**
|
|
41
|
+
* The operators that take no value.
|
|
42
|
+
*
|
|
43
|
+
* "Has no value" cannot be spelled as a comparison — `= NULL` matches nothing in
|
|
44
|
+
* SQL and `= ''` misses the NULLs — so it is an operator rather than a value a
|
|
45
|
+
* caller types, and a filter carrying one is complete without a `value`.
|
|
46
|
+
*/
|
|
47
|
+
export declare const VALUELESS_FILTER_OPERATORS: readonly ["empty", "notEmpty"];
|
|
48
|
+
export declare function filterOperatorTakesValue(operator: CatalogFilterOperator): boolean;
|
|
49
|
+
/** One filter as it crosses the wire: a property name, an operator, raw text. */
|
|
50
|
+
export interface CatalogObjectFilter {
|
|
51
|
+
/**
|
|
52
|
+
* The property's `name`, which is its identity in the type — never its
|
|
53
|
+
* `columnName`. On a published type the two differ whenever the source spelled
|
|
54
|
+
* a column in a way SQL cannot: `Asset Id` arrives as `columnName` and the
|
|
55
|
+
* property is `Asset_Id`. Filtering by the source spelling would resolve to no
|
|
56
|
+
* property at all on every one of them.
|
|
57
|
+
*/
|
|
58
|
+
property: string;
|
|
59
|
+
op: CatalogFilterOperator;
|
|
60
|
+
/** Absent for {@link VALUELESS_FILTER_OPERATORS}. Text, as it was typed. */
|
|
61
|
+
value?: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The minimum a column has to say for the rule below to run on it.
|
|
65
|
+
*
|
|
66
|
+
* Structural rather than `CatalogPropertyDef`, because the console holds the
|
|
67
|
+
* *page's* columns rather than the type's — `CatalogObjectPage.columns` — and the
|
|
68
|
+
* whole point is that both sides ask the same function. Both shapes satisfy this.
|
|
69
|
+
*/
|
|
70
|
+
export interface CatalogFilterableColumn {
|
|
71
|
+
name: string;
|
|
72
|
+
type: ScalarType;
|
|
73
|
+
hidden?: boolean;
|
|
74
|
+
classification?: string;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* What may be filtered on this column, derived from what the column is.
|
|
78
|
+
*
|
|
79
|
+
* An empty list means "not filterable", and there are three ways to get one.
|
|
80
|
+
*
|
|
81
|
+
* **Hidden** is the overlay saying a column is not part of the generic UI, and a
|
|
82
|
+
* filter is generic UI.
|
|
83
|
+
*
|
|
84
|
+
* **Classified** is the one worth stating out loud, because the column is
|
|
85
|
+
* otherwise perfectly filterable and the value is never rendered anyway. It is
|
|
86
|
+
* excluded for the reason `MikroOrmReadStore.buildWhere` already excludes it from
|
|
87
|
+
* SEARCH: a predicate over a classified column leaks it through row membership.
|
|
88
|
+
* Filtering is strictly worse than searching there — `gte`/`lte` let a reader
|
|
89
|
+
* binary-search a value they may not see, in as many requests as it takes.
|
|
90
|
+
*
|
|
91
|
+
* **`json`** because a blob has no useful comparison, and it is already dropped
|
|
92
|
+
* from the readable columns by `CatalogService.visibleColumns`.
|
|
93
|
+
*/
|
|
94
|
+
export declare function filterOperatorsFor(column: CatalogFilterableColumn): CatalogFilterOperator[];
|
|
95
|
+
/** Which operators a column offers, once the store's own limits are applied. */
|
|
96
|
+
export declare function offeredFilterOperators(column: CatalogFilterableColumn, supported: readonly CatalogFilterOperator[]): CatalogFilterOperator[];
|
|
97
|
+
/**
|
|
98
|
+
* `property:op:value`, repeated once per filter.
|
|
99
|
+
*
|
|
100
|
+
* The value is everything after the second colon, so it may contain colons — a
|
|
101
|
+
* timestamp does. A property name containing one cannot be addressed by this
|
|
102
|
+
* form; that is refused by name in {@link resolveObjectFilters} rather than
|
|
103
|
+
* silently mis-parsed, because a filter that quietly does not apply is a screen
|
|
104
|
+
* showing every row as though it had been filtered.
|
|
105
|
+
*/
|
|
106
|
+
export declare function encodeObjectFilter(filter: CatalogObjectFilter): string;
|
|
107
|
+
export declare function parseObjectFilter(raw: string): CatalogObjectFilter | undefined;
|
|
108
|
+
/**
|
|
109
|
+
* A filter with the property it names already resolved to the type's own.
|
|
110
|
+
*
|
|
111
|
+
* The property is carried as the definition rather than as a string, and that is
|
|
112
|
+
* the guard rather than a nicety: a store builds a predicate out of a column
|
|
113
|
+
* name, and the only names it can be handed here are ones that came off the type.
|
|
114
|
+
* A caller's string never reaches SQL — it is matched against the type first, and
|
|
115
|
+
* the store then maps the property to its physical column exactly as `sort` and
|
|
116
|
+
* `search` already do, through the adapter's identifier rule.
|
|
117
|
+
*/
|
|
118
|
+
export interface CatalogResolvedFilter {
|
|
119
|
+
property: CatalogPropertyDef;
|
|
120
|
+
op: CatalogFilterOperator;
|
|
121
|
+
/** Coerced to the property's type. Absent for the valueless operators. */
|
|
122
|
+
value?: string | number | boolean | Date;
|
|
123
|
+
}
|
|
124
|
+
export interface CatalogFilterResolution {
|
|
125
|
+
filters: CatalogResolvedFilter[];
|
|
126
|
+
/** One sentence per filter that could not be honoured. Empty means all were. */
|
|
127
|
+
problems: string[];
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* How many filters one read may carry.
|
|
131
|
+
*
|
|
132
|
+
* A cap rather than none, because every filter is another conjunct in the two
|
|
133
|
+
* statements a paged read issues, and a caller that can send five hundred can
|
|
134
|
+
* make one request cost whatever it likes. Twenty is far past what a person
|
|
135
|
+
* builds by hand and far short of anything that would trouble the optimiser.
|
|
136
|
+
*/
|
|
137
|
+
export declare const CATALOG_FILTER_LIMIT = 20;
|
|
138
|
+
/**
|
|
139
|
+
* Turn what arrived into what a store may be given, or say why not.
|
|
140
|
+
*
|
|
141
|
+
* **Unhonourable filters are reported, never dropped.** The neighbouring
|
|
142
|
+
* `parseOutcomes` on the controller drops an unrecognised trace outcome on
|
|
143
|
+
* purpose, and this goes the other way for a reason that is worth the two
|
|
144
|
+
* sentences: dropping there costs a filter, so a typo widens the result set and
|
|
145
|
+
* shows more traces than asked for. Dropping HERE would narrow nothing — the read
|
|
146
|
+
* would come back unfiltered and the screen would present the whole table as
|
|
147
|
+
* though it were the matching rows. A filter that silently does not apply is the
|
|
148
|
+
* one failure mode a filtering UI must not have.
|
|
149
|
+
*
|
|
150
|
+
* @param columns the properties this reader may see. Deliberately the visible,
|
|
151
|
+
* non-blob columns rather than `type.properties`: a filter is only ever resolved
|
|
152
|
+
* against what the same request would return.
|
|
153
|
+
*/
|
|
154
|
+
export declare function resolveObjectFilters(columns: readonly CatalogPropertyDef[], raw: readonly string[]): CatalogFilterResolution;
|
|
155
|
+
/**
|
|
156
|
+
* A typed value, or the reason there is none.
|
|
157
|
+
*
|
|
158
|
+
* Refusing rather than passing the text through is the whole of this function's
|
|
159
|
+
* value. MySQL compares a string to a `DOUBLE` by coercing the string — `'abc'`
|
|
160
|
+
* becomes `0` — so `mileage >= abc` is not an error, it is `mileage >= 0`, and it
|
|
161
|
+
* comes back as a full page of rows that look filtered. The same is true of a
|
|
162
|
+
* date that does not parse.
|
|
163
|
+
*/
|
|
164
|
+
export declare function coerceFilterValue(type: ScalarType, value: string): {
|
|
165
|
+
ok: true;
|
|
166
|
+
value: string | number | boolean | Date;
|
|
167
|
+
} | {
|
|
168
|
+
ok: false;
|
|
169
|
+
problem: string;
|
|
170
|
+
};
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CATALOG_FILTER_LIMIT = exports.VALUELESS_FILTER_OPERATORS = exports.CATALOG_FILTER_OPERATORS = void 0;
|
|
4
|
+
exports.isCatalogFilterOperator = isCatalogFilterOperator;
|
|
5
|
+
exports.filterOperatorTakesValue = filterOperatorTakesValue;
|
|
6
|
+
exports.filterOperatorsFor = filterOperatorsFor;
|
|
7
|
+
exports.offeredFilterOperators = offeredFilterOperators;
|
|
8
|
+
exports.encodeObjectFilter = encodeObjectFilter;
|
|
9
|
+
exports.parseObjectFilter = parseObjectFilter;
|
|
10
|
+
exports.resolveObjectFilters = resolveObjectFilters;
|
|
11
|
+
exports.coerceFilterValue = coerceFilterValue;
|
|
12
|
+
/**
|
|
13
|
+
* Filtering a catalogued type, derived from the type.
|
|
14
|
+
*
|
|
15
|
+
* **Nothing here lists a filterable column, and nothing anywhere else may.** The
|
|
16
|
+
* types this catalog serves are created at runtime — `PUT publish/:type/schema`
|
|
17
|
+
* writes the properties and the store builds the physical columns from them — so
|
|
18
|
+
* a hand-maintained list of what is filterable is a list that goes quiet the day
|
|
19
|
+
* somebody publishes a column nobody edited it for. That is the failure mode this
|
|
20
|
+
* module exists to remove: the operators a column offers are a function of the
|
|
21
|
+
* column, computed by {@link filterOperatorsFor}, and both the server (deciding
|
|
22
|
+
* what it will accept) and the console (deciding what to draw) call that one
|
|
23
|
+
* function. A screen offering a control the server would refuse, or refusing one
|
|
24
|
+
* the server would take, is then not expressible.
|
|
25
|
+
*
|
|
26
|
+
* This is also why `@dudousxd/nestjs-filter` is not what is used here. That
|
|
27
|
+
* library derives its filters from entities and routes known at compile time, and
|
|
28
|
+
* these types do not exist at compile time.
|
|
29
|
+
*
|
|
30
|
+
* **The module is pure and imports nothing but types**, because it ships to the
|
|
31
|
+
* browser through `@dudousxd/nestjs-catalog/client` — the same reason
|
|
32
|
+
* `validateWorkflow` does. A second copy of the derivation living in the console
|
|
33
|
+
* is exactly the drift that entry point exists to prevent.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Every operator, in the order a control should offer them.
|
|
37
|
+
*
|
|
38
|
+
* One list, so nothing narrows a stored or transmitted operator against a second
|
|
39
|
+
* hand-maintained copy of these names — the same argument
|
|
40
|
+
* `CATALOG_SNAPSHOT_MODES` makes one file over.
|
|
41
|
+
*
|
|
42
|
+
* There is no `between`. A range is `gte` and `lte` on one property, which is two
|
|
43
|
+
* filters that compose with everything else rather than one operator that needs
|
|
44
|
+
* its own wire form, its own second value and its own "what if the ends are the
|
|
45
|
+
* wrong way round" answer.
|
|
46
|
+
*/
|
|
47
|
+
exports.CATALOG_FILTER_OPERATORS = [
|
|
48
|
+
'eq',
|
|
49
|
+
'ne',
|
|
50
|
+
'contains',
|
|
51
|
+
'gte',
|
|
52
|
+
'lte',
|
|
53
|
+
'gt',
|
|
54
|
+
'lt',
|
|
55
|
+
'empty',
|
|
56
|
+
'notEmpty',
|
|
57
|
+
];
|
|
58
|
+
function isCatalogFilterOperator(value) {
|
|
59
|
+
return exports.CATALOG_FILTER_OPERATORS.some((operator) => operator === value);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The operators that take no value.
|
|
63
|
+
*
|
|
64
|
+
* "Has no value" cannot be spelled as a comparison — `= NULL` matches nothing in
|
|
65
|
+
* SQL and `= ''` misses the NULLs — so it is an operator rather than a value a
|
|
66
|
+
* caller types, and a filter carrying one is complete without a `value`.
|
|
67
|
+
*/
|
|
68
|
+
exports.VALUELESS_FILTER_OPERATORS = ['empty', 'notEmpty'];
|
|
69
|
+
function filterOperatorTakesValue(operator) {
|
|
70
|
+
return !exports.VALUELESS_FILTER_OPERATORS.some((valueless) => valueless === operator);
|
|
71
|
+
}
|
|
72
|
+
const TEXT_OPERATORS = [
|
|
73
|
+
'contains',
|
|
74
|
+
'eq',
|
|
75
|
+
'ne',
|
|
76
|
+
'empty',
|
|
77
|
+
'notEmpty',
|
|
78
|
+
];
|
|
79
|
+
const NUMBER_OPERATORS = [
|
|
80
|
+
'eq',
|
|
81
|
+
'ne',
|
|
82
|
+
'gte',
|
|
83
|
+
'lte',
|
|
84
|
+
'gt',
|
|
85
|
+
'lt',
|
|
86
|
+
'empty',
|
|
87
|
+
'notEmpty',
|
|
88
|
+
];
|
|
89
|
+
/**
|
|
90
|
+
* A date gets the two range ends and nothing else.
|
|
91
|
+
*
|
|
92
|
+
* No `eq`, deliberately. These columns are `DATETIME`, and a person filtering a
|
|
93
|
+
* date types a day — so `= 2026-03-04` compares against midnight and misses every
|
|
94
|
+
* row loaded at any other second of that day. It looks like "nothing happened on
|
|
95
|
+
* the 4th", which is the most expensive wrong answer a filter can give. `gte` the
|
|
96
|
+
* 4th and `lte` the 4th is the same intent expressed in a way that is true.
|
|
97
|
+
*/
|
|
98
|
+
const DATE_OPERATORS = ['gte', 'lte', 'empty', 'notEmpty'];
|
|
99
|
+
const BOOLEAN_OPERATORS = ['eq', 'empty', 'notEmpty'];
|
|
100
|
+
/**
|
|
101
|
+
* What may be filtered on this column, derived from what the column is.
|
|
102
|
+
*
|
|
103
|
+
* An empty list means "not filterable", and there are three ways to get one.
|
|
104
|
+
*
|
|
105
|
+
* **Hidden** is the overlay saying a column is not part of the generic UI, and a
|
|
106
|
+
* filter is generic UI.
|
|
107
|
+
*
|
|
108
|
+
* **Classified** is the one worth stating out loud, because the column is
|
|
109
|
+
* otherwise perfectly filterable and the value is never rendered anyway. It is
|
|
110
|
+
* excluded for the reason `MikroOrmReadStore.buildWhere` already excludes it from
|
|
111
|
+
* SEARCH: a predicate over a classified column leaks it through row membership.
|
|
112
|
+
* Filtering is strictly worse than searching there — `gte`/`lte` let a reader
|
|
113
|
+
* binary-search a value they may not see, in as many requests as it takes.
|
|
114
|
+
*
|
|
115
|
+
* **`json`** because a blob has no useful comparison, and it is already dropped
|
|
116
|
+
* from the readable columns by `CatalogService.visibleColumns`.
|
|
117
|
+
*/
|
|
118
|
+
function filterOperatorsFor(column) {
|
|
119
|
+
if (column.hidden === true)
|
|
120
|
+
return [];
|
|
121
|
+
if (column.classification)
|
|
122
|
+
return [];
|
|
123
|
+
switch (column.type) {
|
|
124
|
+
case 'number':
|
|
125
|
+
return [...NUMBER_OPERATORS];
|
|
126
|
+
case 'date':
|
|
127
|
+
return [...DATE_OPERATORS];
|
|
128
|
+
case 'boolean':
|
|
129
|
+
return [...BOOLEAN_OPERATORS];
|
|
130
|
+
case 'json':
|
|
131
|
+
return [];
|
|
132
|
+
default:
|
|
133
|
+
// `string`, `uuid` and `unknown`. The warehouse stores all three as text
|
|
134
|
+
// and the ORM store compares them as strings, so they take the same
|
|
135
|
+
// operators; `unknown` is a column whose type nobody could derive, and
|
|
136
|
+
// treating it as text is what every other read path here does with it.
|
|
137
|
+
return [...TEXT_OPERATORS];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Which operators a column offers, once the store's own limits are applied. */
|
|
141
|
+
function offeredFilterOperators(column, supported) {
|
|
142
|
+
return filterOperatorsFor(column).filter((operator) => supported.some((available) => available === operator));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* `property:op:value`, repeated once per filter.
|
|
146
|
+
*
|
|
147
|
+
* The value is everything after the second colon, so it may contain colons — a
|
|
148
|
+
* timestamp does. A property name containing one cannot be addressed by this
|
|
149
|
+
* form; that is refused by name in {@link resolveObjectFilters} rather than
|
|
150
|
+
* silently mis-parsed, because a filter that quietly does not apply is a screen
|
|
151
|
+
* showing every row as though it had been filtered.
|
|
152
|
+
*/
|
|
153
|
+
function encodeObjectFilter(filter) {
|
|
154
|
+
if (!filterOperatorTakesValue(filter.op))
|
|
155
|
+
return `${filter.property}:${filter.op}`;
|
|
156
|
+
return `${filter.property}:${filter.op}:${filter.value ?? ''}`;
|
|
157
|
+
}
|
|
158
|
+
function parseObjectFilter(raw) {
|
|
159
|
+
const parts = raw.split(':');
|
|
160
|
+
if (parts.length < 2)
|
|
161
|
+
return undefined;
|
|
162
|
+
const [property, op, ...rest] = parts;
|
|
163
|
+
if (!property || !isCatalogFilterOperator(op))
|
|
164
|
+
return undefined;
|
|
165
|
+
if (!filterOperatorTakesValue(op))
|
|
166
|
+
return { property, op };
|
|
167
|
+
return { property, op, value: rest.join(':') };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* How many filters one read may carry.
|
|
171
|
+
*
|
|
172
|
+
* A cap rather than none, because every filter is another conjunct in the two
|
|
173
|
+
* statements a paged read issues, and a caller that can send five hundred can
|
|
174
|
+
* make one request cost whatever it likes. Twenty is far past what a person
|
|
175
|
+
* builds by hand and far short of anything that would trouble the optimiser.
|
|
176
|
+
*/
|
|
177
|
+
exports.CATALOG_FILTER_LIMIT = 20;
|
|
178
|
+
/**
|
|
179
|
+
* Turn what arrived into what a store may be given, or say why not.
|
|
180
|
+
*
|
|
181
|
+
* **Unhonourable filters are reported, never dropped.** The neighbouring
|
|
182
|
+
* `parseOutcomes` on the controller drops an unrecognised trace outcome on
|
|
183
|
+
* purpose, and this goes the other way for a reason that is worth the two
|
|
184
|
+
* sentences: dropping there costs a filter, so a typo widens the result set and
|
|
185
|
+
* shows more traces than asked for. Dropping HERE would narrow nothing — the read
|
|
186
|
+
* would come back unfiltered and the screen would present the whole table as
|
|
187
|
+
* though it were the matching rows. A filter that silently does not apply is the
|
|
188
|
+
* one failure mode a filtering UI must not have.
|
|
189
|
+
*
|
|
190
|
+
* @param columns the properties this reader may see. Deliberately the visible,
|
|
191
|
+
* non-blob columns rather than `type.properties`: a filter is only ever resolved
|
|
192
|
+
* against what the same request would return.
|
|
193
|
+
*/
|
|
194
|
+
function resolveObjectFilters(columns, raw) {
|
|
195
|
+
const filters = [];
|
|
196
|
+
const problems = [];
|
|
197
|
+
if (raw.length > exports.CATALOG_FILTER_LIMIT) {
|
|
198
|
+
problems.push(`${raw.length} filters were sent and at most ${exports.CATALOG_FILTER_LIMIT} are accepted on one read.`);
|
|
199
|
+
return { filters, problems };
|
|
200
|
+
}
|
|
201
|
+
for (const entry of raw) {
|
|
202
|
+
const resolved = resolveOne(columns, entry);
|
|
203
|
+
if ('problem' in resolved)
|
|
204
|
+
problems.push(resolved.problem);
|
|
205
|
+
else
|
|
206
|
+
filters.push(resolved.filter);
|
|
207
|
+
}
|
|
208
|
+
return { filters, problems };
|
|
209
|
+
}
|
|
210
|
+
/** One entry: the filter it names, or the sentence explaining why it is not one. */
|
|
211
|
+
function resolveOne(columns, entry) {
|
|
212
|
+
const parsed = parseObjectFilter(entry);
|
|
213
|
+
if (!parsed) {
|
|
214
|
+
return {
|
|
215
|
+
problem: `"${entry}" is not a filter. The form is property:operator:value, and the operator is one of ${exports.CATALOG_FILTER_OPERATORS.join(', ')}.`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const property = columns.find((column) => column.name === parsed.property);
|
|
219
|
+
if (!property) {
|
|
220
|
+
return {
|
|
221
|
+
problem: `${parsed.property} is not a readable property of this type. Filter by a property's name — on a published type that is the identifier form, which is not always how the source spelled the column.`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const allowed = filterOperatorsFor(property);
|
|
225
|
+
if (!allowed.some((operator) => operator === parsed.op)) {
|
|
226
|
+
return {
|
|
227
|
+
problem: allowed.length === 0
|
|
228
|
+
? `${property.name} cannot be filtered.`
|
|
229
|
+
: `${property.name} is ${property.type} and cannot be filtered with ${parsed.op}. It takes ${allowed.join(', ')}.`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (!filterOperatorTakesValue(parsed.op))
|
|
233
|
+
return { filter: { property, op: parsed.op } };
|
|
234
|
+
const coerced = coerceFilterValue(property.type, parsed.value ?? '');
|
|
235
|
+
if (!coerced.ok)
|
|
236
|
+
return { problem: `${property.name}: ${coerced.problem}` };
|
|
237
|
+
return { filter: { property, op: parsed.op, value: coerced.value } };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* A typed value, or the reason there is none.
|
|
241
|
+
*
|
|
242
|
+
* Refusing rather than passing the text through is the whole of this function's
|
|
243
|
+
* value. MySQL compares a string to a `DOUBLE` by coercing the string — `'abc'`
|
|
244
|
+
* becomes `0` — so `mileage >= abc` is not an error, it is `mileage >= 0`, and it
|
|
245
|
+
* comes back as a full page of rows that look filtered. The same is true of a
|
|
246
|
+
* date that does not parse.
|
|
247
|
+
*/
|
|
248
|
+
function coerceFilterValue(type, value) {
|
|
249
|
+
if (type === 'number') {
|
|
250
|
+
const parsed = Number(value);
|
|
251
|
+
if (value.trim() === '' || !Number.isFinite(parsed)) {
|
|
252
|
+
return { ok: false, problem: `"${value}" is not a number.` };
|
|
253
|
+
}
|
|
254
|
+
return { ok: true, value: parsed };
|
|
255
|
+
}
|
|
256
|
+
if (type === 'date') {
|
|
257
|
+
const parsed = new Date(value);
|
|
258
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
259
|
+
return { ok: false, problem: `"${value}" is not a date.` };
|
|
260
|
+
}
|
|
261
|
+
return { ok: true, value: parsed };
|
|
262
|
+
}
|
|
263
|
+
if (type === 'boolean') {
|
|
264
|
+
const normalised = value.trim().toLowerCase();
|
|
265
|
+
if (['true', '1', 'yes'].includes(normalised))
|
|
266
|
+
return { ok: true, value: true };
|
|
267
|
+
if (['false', '0', 'no'].includes(normalised))
|
|
268
|
+
return { ok: true, value: false };
|
|
269
|
+
return { ok: false, problem: `"${value}" is not true or false.` };
|
|
270
|
+
}
|
|
271
|
+
return { ok: true, value };
|
|
272
|
+
}
|