@dudousxd/nestjs-catalog 0.13.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.filters.d.ts +170 -0
- package/dist/catalog.filters.js +272 -0
- package/dist/catalog.service.d.ts +15 -0
- package/dist/catalog.service.js +54 -1
- package/dist/catalog.store.d.ts +62 -1
- package/dist/catalog.store.js +6 -0
- package/dist/catalog.types.d.ts +57 -0
- package/dist/client.d.ts +38 -0
- package/dist/client.js +28 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +9 -1
- 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
|
*
|
|
@@ -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
|
+
}
|
|
@@ -63,6 +63,21 @@ export declare class CatalogService {
|
|
|
63
63
|
readObjects(typeName: string, query: CatalogObjectQuery & {
|
|
64
64
|
snapshot?: string;
|
|
65
65
|
}): Promise<CatalogObjectPage>;
|
|
66
|
+
/** What the mounted store can push into a read predicate. Empty when it cannot. */
|
|
67
|
+
private filterOperators;
|
|
68
|
+
/**
|
|
69
|
+
* Every filter, or a refusal naming all of them at once.
|
|
70
|
+
*
|
|
71
|
+
* One message listing every problem rather than the first: somebody who built
|
|
72
|
+
* four filters and got two of them wrong should learn that in one round trip.
|
|
73
|
+
*
|
|
74
|
+
* The store is asked whether it can honour the operators before the read runs,
|
|
75
|
+
* which is what stops a store that does not filter from answering with an
|
|
76
|
+
* unfiltered page. That refusal is worth more than it costs — a screen only
|
|
77
|
+
* offers what `filterOperators` reported, so a caller reaching this branch is
|
|
78
|
+
* one that built the request itself.
|
|
79
|
+
*/
|
|
80
|
+
private resolveFilters;
|
|
66
81
|
/** Empty when the store keeps no history. */
|
|
67
82
|
listSnapshots(typeName: string): Promise<SnapshotRef[]>;
|
|
68
83
|
/** What the mounted store can do — the screens branch on this. */
|
package/dist/catalog.service.js
CHANGED
|
@@ -15,6 +15,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
15
15
|
exports.CatalogService = void 0;
|
|
16
16
|
const common_1 = require("@nestjs/common");
|
|
17
17
|
const catalog_events_1 = require("./catalog.events");
|
|
18
|
+
const catalog_filters_1 = require("./catalog.filters");
|
|
18
19
|
const catalog_options_1 = require("./catalog.options");
|
|
19
20
|
const catalog_query_1 = require("./catalog.query");
|
|
20
21
|
const catalog_query_cache_1 = require("./catalog.query-cache");
|
|
@@ -130,14 +131,24 @@ let CatalogService = class CatalogService {
|
|
|
130
131
|
// Sort is validated here rather than in the store: an unrecognised column
|
|
131
132
|
// must never reach a query builder, whatever the engine.
|
|
132
133
|
const sort = columns.some((c) => c.name === query.sort) ? query.sort : undefined;
|
|
133
|
-
|
|
134
|
+
// Filters, against the same `columns` a sort is checked against and for the
|
|
135
|
+
// same reason — with one difference in what a failure means. An unrecognised
|
|
136
|
+
// sort falls back to the primary key, because the rows are the same rows in a
|
|
137
|
+
// different order. An unrecognised filter cannot fall back to anything: the
|
|
138
|
+
// read would come back holding rows the caller asked to exclude, and neither
|
|
139
|
+
// the caller nor the screen has any way to tell.
|
|
140
|
+
const filters = this.resolveFilters(columns, query.filters ?? []);
|
|
141
|
+
const result = await this.store.read(type, fields, {
|
|
134
142
|
page,
|
|
135
143
|
size,
|
|
136
144
|
search: query.search,
|
|
137
145
|
sort,
|
|
138
146
|
dir: query.dir === 'desc' ? 'desc' : 'asc',
|
|
139
147
|
snapshot: query.snapshot,
|
|
148
|
+
...(filters.length > 0 ? { filters } : {}),
|
|
140
149
|
});
|
|
150
|
+
const { rows, total } = result;
|
|
151
|
+
const storeOperators = this.filterOperators();
|
|
141
152
|
return {
|
|
142
153
|
type: type.name,
|
|
143
154
|
page,
|
|
@@ -150,10 +161,52 @@ let CatalogService = class CatalogService {
|
|
|
150
161
|
type: c.type,
|
|
151
162
|
classification: c.classification,
|
|
152
163
|
unit: c.unit,
|
|
164
|
+
columnName: c.columnName,
|
|
165
|
+
// What this deployment will actually accept for this column: the rule
|
|
166
|
+
// derived from the column, narrowed by what the mounted store can do.
|
|
167
|
+
// Sent per column so a console needs no second request and no table of
|
|
168
|
+
// its own — see `catalog.filters.ts` on why a hand-kept list is the
|
|
169
|
+
// failure mode being avoided.
|
|
170
|
+
filterOperators: (0, catalog_filters_1.offeredFilterOperators)(c, storeOperators),
|
|
153
171
|
})),
|
|
154
172
|
rows,
|
|
173
|
+
...(result.snapshot ? { snapshot: result.snapshot } : {}),
|
|
155
174
|
};
|
|
156
175
|
}
|
|
176
|
+
/** What the mounted store can push into a read predicate. Empty when it cannot. */
|
|
177
|
+
filterOperators() {
|
|
178
|
+
return (0, catalog_store_1.supportsObjectFilters)(this.store) ? this.store.objectFilterOperators : [];
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Every filter, or a refusal naming all of them at once.
|
|
182
|
+
*
|
|
183
|
+
* One message listing every problem rather than the first: somebody who built
|
|
184
|
+
* four filters and got two of them wrong should learn that in one round trip.
|
|
185
|
+
*
|
|
186
|
+
* The store is asked whether it can honour the operators before the read runs,
|
|
187
|
+
* which is what stops a store that does not filter from answering with an
|
|
188
|
+
* unfiltered page. That refusal is worth more than it costs — a screen only
|
|
189
|
+
* offers what `filterOperators` reported, so a caller reaching this branch is
|
|
190
|
+
* one that built the request itself.
|
|
191
|
+
*/
|
|
192
|
+
resolveFilters(columns, raw) {
|
|
193
|
+
if (raw.length === 0)
|
|
194
|
+
return [];
|
|
195
|
+
const { filters, problems } = (0, catalog_filters_1.resolveObjectFilters)(columns, raw);
|
|
196
|
+
const supported = this.filterOperators();
|
|
197
|
+
const unsupported = filters
|
|
198
|
+
.map((filter) => filter.op)
|
|
199
|
+
.filter((op) => !supported.some((available) => available === op));
|
|
200
|
+
if (unsupported.length > 0) {
|
|
201
|
+
throw new common_1.BadRequestException(supported.length === 0
|
|
202
|
+
? "This catalog's store does not filter object reads, so it can only be paged, searched and sorted."
|
|
203
|
+
: `This catalog's store cannot filter with ${[...new Set(unsupported)].join(', ')}. It applies ${supported.join(', ')}.`);
|
|
204
|
+
}
|
|
205
|
+
if (problems.length > 0) {
|
|
206
|
+
throw new common_1.BadRequestException(problems.join(' '));
|
|
207
|
+
}
|
|
208
|
+
return filters;
|
|
209
|
+
}
|
|
157
210
|
/** Empty when the store keeps no history. */
|
|
158
211
|
async listSnapshots(typeName) {
|
|
159
212
|
const type = this.registry.getType(typeName);
|
package/dist/catalog.store.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BadRequestException } from '@nestjs/common';
|
|
2
|
+
import type { CatalogFilterOperator, CatalogResolvedFilter } from './catalog.filters';
|
|
2
3
|
import type { CatalogObjectQuery, CatalogObjectTypeDef } from './catalog.types';
|
|
3
4
|
/**
|
|
4
5
|
* Where the objects actually live.
|
|
@@ -149,14 +150,74 @@ export interface CatalogStoreCapabilities {
|
|
|
149
150
|
* into a boot failure.
|
|
150
151
|
*/
|
|
151
152
|
export declare function isCatalogStoreCapabilities(value: unknown): value is CatalogStoreCapabilities;
|
|
152
|
-
|
|
153
|
+
/**
|
|
154
|
+
* What a store is asked for, once the service has vetted it.
|
|
155
|
+
*
|
|
156
|
+
* `Omit<..., 'filters'>` and not a plain extension, and the omission is the
|
|
157
|
+
* point: `CatalogObjectQuery.filters` is the caller's raw
|
|
158
|
+
* `property:operator:value` text, and a store must never be handed one. What
|
|
159
|
+
* arrives here instead is {@link CatalogResolvedFilter}, whose property is the
|
|
160
|
+
* type's own definition — so the column a predicate is built from came off the
|
|
161
|
+
* type rather than off the request, and the type system says so rather than a
|
|
162
|
+
* comment. `sort` is a bare string only because every store already re-matches it
|
|
163
|
+
* against the type before using it; a filter carries more than a name, so
|
|
164
|
+
* resolving it once in the service is both cheaper and harder to get wrong.
|
|
165
|
+
*/
|
|
166
|
+
export interface CatalogReadQuery extends Omit<CatalogObjectQuery, 'filters'> {
|
|
153
167
|
/** Read as of a specific snapshot. Ignored when `timeTravel` is false. */
|
|
154
168
|
snapshot?: string;
|
|
169
|
+
/**
|
|
170
|
+
* Every one of these must be applied. A store that cannot apply one must not
|
|
171
|
+
* silently return the rows it would have returned anyway — declare the
|
|
172
|
+
* operators it can honour (see {@link CatalogFilteringReadStore}) and the
|
|
173
|
+
* service will refuse the read instead.
|
|
174
|
+
*/
|
|
175
|
+
filters?: CatalogResolvedFilter[];
|
|
155
176
|
}
|
|
156
177
|
export interface CatalogReadResult {
|
|
157
178
|
rows: Array<Record<string, unknown>>;
|
|
158
179
|
total: number;
|
|
180
|
+
/**
|
|
181
|
+
* Which snapshot these rows came from, and whether it is the one being served.
|
|
182
|
+
*
|
|
183
|
+
* Answered by the store because the store is what resolved it: a read that was
|
|
184
|
+
* given no snapshot falls back to the pointer, so only the store knows which id
|
|
185
|
+
* the rows actually carry. Reporting it costs nothing — every store that keeps
|
|
186
|
+
* history has already read both values by the time it builds the query — and it
|
|
187
|
+
* is what lets a screen say "this is not the current load" on the strength of
|
|
188
|
+
* what was read rather than of what it thinks it asked for.
|
|
189
|
+
*
|
|
190
|
+
* Absent from a store that keeps no history, which is the honest answer there:
|
|
191
|
+
* the rows are the current state and there is no other state to be reading.
|
|
192
|
+
*/
|
|
193
|
+
snapshot?: {
|
|
194
|
+
id: string;
|
|
195
|
+
current: boolean;
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* A store that applies {@link CatalogReadQuery.filters}.
|
|
200
|
+
*
|
|
201
|
+
* Declared, never assumed, and the reason is the same one the capability object
|
|
202
|
+
* one file up gives for every field on it: a store that ignores a filter answers
|
|
203
|
+
* with more rows than were asked for, and there is nothing about that answer to
|
|
204
|
+
* distinguish it from a filter that genuinely matched everything. So a store says
|
|
205
|
+
* which operators it can push into its predicate, the service offers exactly
|
|
206
|
+
* those to the screen, and a filter naming anything else is refused rather than
|
|
207
|
+
* quietly dropped.
|
|
208
|
+
*
|
|
209
|
+
* A guard rather than a field on `CatalogStoreCapabilities`, deliberately: the
|
|
210
|
+
* capability object is intersected by the fan-out through an exhaustiveness check
|
|
211
|
+
* that fails to compile when a field is added and not composed, and this is not a
|
|
212
|
+
* property that composes the way those do — a fan-out reads through its primary,
|
|
213
|
+
* so what its primary can filter is what it can filter. Asking the object it
|
|
214
|
+
* holds is the check that stays true when that changes.
|
|
215
|
+
*/
|
|
216
|
+
export interface CatalogFilteringReadStore extends CatalogReadStore {
|
|
217
|
+
/** Which operators this store can apply. A subset of `CATALOG_FILTER_OPERATORS`. */
|
|
218
|
+
readonly objectFilterOperators: readonly CatalogFilterOperator[];
|
|
159
219
|
}
|
|
220
|
+
export declare function supportsObjectFilters(store: unknown): store is CatalogFilteringReadStore;
|
|
160
221
|
/** The minimum a store must do: return rows of a catalogued type. */
|
|
161
222
|
export interface CatalogReadStore {
|
|
162
223
|
readonly capabilities: CatalogStoreCapabilities;
|
package/dist/catalog.store.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.CATALOG_STORE = exports.CatalogColumnCollisionError = exports.UnsafeIdentifierError = exports.CATALOG_RESERVED_COLUMNS = exports.CATALOG_SNAPSHOT_MODES = void 0;
|
|
4
4
|
exports.isCatalogStoreCapabilities = isCatalogStoreCapabilities;
|
|
5
|
+
exports.supportsObjectFilters = supportsObjectFilters;
|
|
5
6
|
exports.isReservedColumn = isReservedColumn;
|
|
6
7
|
exports.isSafeIdentifier = isSafeIdentifier;
|
|
7
8
|
exports.assertSafeIdentifier = assertSafeIdentifier;
|
|
@@ -49,6 +50,11 @@ function isCatalogStoreCapabilities(value) {
|
|
|
49
50
|
}
|
|
50
51
|
return true;
|
|
51
52
|
}
|
|
53
|
+
function supportsObjectFilters(store) {
|
|
54
|
+
return (typeof store === 'object' &&
|
|
55
|
+
store !== null &&
|
|
56
|
+
Array.isArray(Reflect.get(store, 'objectFilterOperators')));
|
|
57
|
+
}
|
|
52
58
|
/**
|
|
53
59
|
* The columns a snapshot-emulating store adds to every object table.
|
|
54
60
|
*
|
package/dist/catalog.types.d.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* need a migration and never need an engineer. That boundary is what makes
|
|
16
16
|
* it safe to hand the editor to a non-engineer.
|
|
17
17
|
*/
|
|
18
|
+
import type { CatalogFilterOperator } from './catalog.filters';
|
|
18
19
|
export type ScalarType = 'string' | 'number' | 'boolean' | 'date' | 'json' | 'uuid' | 'unknown';
|
|
19
20
|
export type RelationKind = '1:1' | '1:m' | 'm:1' | 'm:n';
|
|
20
21
|
/** A single scalar field on an object type. */
|
|
@@ -264,6 +265,16 @@ export interface CatalogObjectQuery {
|
|
|
264
265
|
search?: string;
|
|
265
266
|
sort?: string;
|
|
266
267
|
dir?: 'asc' | 'desc';
|
|
268
|
+
/**
|
|
269
|
+
* Column filters, as they arrived: `property:operator:value`, one string each.
|
|
270
|
+
*
|
|
271
|
+
* Unvalidated, exactly like `sort` and `search` beside it — these are what a
|
|
272
|
+
* caller typed. `CatalogService.readObjects` resolves them against the type
|
|
273
|
+
* before any store sees them, and refuses the read if any of them cannot be
|
|
274
|
+
* honoured. See `catalog.filters.ts`, which owns both halves of that rule and
|
|
275
|
+
* is also what a console derives its controls from.
|
|
276
|
+
*/
|
|
277
|
+
filters?: string[];
|
|
267
278
|
}
|
|
268
279
|
export interface CatalogObjectPage {
|
|
269
280
|
type: string;
|
|
@@ -284,6 +295,52 @@ export interface CatalogObjectPage {
|
|
|
284
295
|
type: ScalarType;
|
|
285
296
|
classification?: string;
|
|
286
297
|
unit?: string;
|
|
298
|
+
/**
|
|
299
|
+
* How the source spells this column, when it is not how the property is
|
|
300
|
+
* named.
|
|
301
|
+
*
|
|
302
|
+
* Carried because on a published type the two really do differ: a source
|
|
303
|
+
* column called `Asset Id` cannot be a SQL identifier, so the property is
|
|
304
|
+
* `Asset_Id` and `columnName` keeps the original. A reader recognises the
|
|
305
|
+
* source spelling — it is what is on their spreadsheet — and a filter has to
|
|
306
|
+
* be built from the property name, so a screen that shows only one of the two
|
|
307
|
+
* either fails to be recognised or invites a filter on a name that resolves
|
|
308
|
+
* to nothing. Both are here so a screen can show one and send the other.
|
|
309
|
+
*
|
|
310
|
+
* Optional: a page served by a version of this library that predates the
|
|
311
|
+
* field simply does not say, and a screen falls back to the property name.
|
|
312
|
+
*/
|
|
313
|
+
columnName?: string;
|
|
314
|
+
/**
|
|
315
|
+
* What this column may be filtered with, here and now.
|
|
316
|
+
*
|
|
317
|
+
* Derived from the column by `filterOperatorsFor` and then narrowed to what
|
|
318
|
+
* the mounted store can actually apply, so the list is the server's answer
|
|
319
|
+
* rather than the screen's guess. **Empty means not filterable** — a
|
|
320
|
+
* classified column, a blob, or a store that does not filter at all.
|
|
321
|
+
*
|
|
322
|
+
* Optional, and absent is not the same as empty: a server older than this
|
|
323
|
+
* field has not been asked. A screen must read absent the pessimistic way and
|
|
324
|
+
* offer nothing, because offering a control the server will refuse is worse
|
|
325
|
+
* than offering none.
|
|
326
|
+
*/
|
|
327
|
+
filterOperators?: CatalogFilterOperator[];
|
|
287
328
|
}>;
|
|
288
329
|
rows: Array<Record<string, unknown>>;
|
|
330
|
+
/**
|
|
331
|
+
* Which load these rows came from, when the store keeps history.
|
|
332
|
+
*
|
|
333
|
+
* Reported by the store as part of the read rather than looked up separately,
|
|
334
|
+
* so it costs nothing and — more importantly — it describes the snapshot that
|
|
335
|
+
* was actually read rather than the one the caller believes it asked for. A
|
|
336
|
+
* screen that drew its "you are looking at an old load" banner from its own
|
|
337
|
+
* state would be trusting the wrong end of the request.
|
|
338
|
+
*
|
|
339
|
+
* Absent when the store keeps no snapshots at all.
|
|
340
|
+
*/
|
|
341
|
+
snapshot?: {
|
|
342
|
+
id: string;
|
|
343
|
+
/** False means these rows are NOT what a reader gets by default. */
|
|
344
|
+
current: boolean;
|
|
345
|
+
};
|
|
289
346
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -15,6 +15,24 @@ export { CATALOG_REVISION_LIMIT } from './catalog.workspace';
|
|
|
15
15
|
export type { CatalogQueryRelation, CatalogQueryRequest, CatalogQueryResult, } from './catalog.query';
|
|
16
16
|
export type { CatalogSearchField, CatalogSearchHit, CatalogSearchKind, CatalogSearchRank, CatalogSearchResult, } from './search.types';
|
|
17
17
|
export type { CatalogGraph, CatalogObjectPage, CatalogObjectQuery, CatalogObjectTypeDef, CatalogOverlay, CatalogPropertyDef, CatalogRelationDef, CatalogSnapshot, RelationKind, ScalarType, } from './catalog.types';
|
|
18
|
+
/**
|
|
19
|
+
* The filter rule, shipped to the browser deliberately — the same exception, for
|
|
20
|
+
* the same reason, that `validateWorkflow` further down is.
|
|
21
|
+
*
|
|
22
|
+
* A console has to know which control to draw for a column, and the only way for
|
|
23
|
+
* that answer to match what the server will accept is for both to run this
|
|
24
|
+
* function. A screen with its own copy of the rules eventually lies: it offers a
|
|
25
|
+
* control the read refuses, or omits one that would have worked, and on types
|
|
26
|
+
* that are created at runtime nobody notices until a publisher adds a column. The
|
|
27
|
+
* functions are pure and import nothing.
|
|
28
|
+
*
|
|
29
|
+
* `SnapshotRef` rides along because a snapshot picker is a browser screen and
|
|
30
|
+
* `GET objects/:name/snapshots` is what fills it. The endpoints alone are not an
|
|
31
|
+
* API; the endpoints plus the response types are.
|
|
32
|
+
*/
|
|
33
|
+
export { CATALOG_FILTER_LIMIT, CATALOG_FILTER_OPERATORS, coerceFilterValue, encodeObjectFilter, filterOperatorTakesValue, filterOperatorsFor, isCatalogFilterOperator, offeredFilterOperators, parseObjectFilter, resolveObjectFilters, VALUELESS_FILTER_OPERATORS, } from './catalog.filters';
|
|
34
|
+
export type { CatalogFilterableColumn, CatalogFilterOperator, CatalogFilterResolution, CatalogObjectFilter, CatalogResolvedFilter, } from './catalog.filters';
|
|
35
|
+
export type { SnapshotRef } from './catalog.store';
|
|
18
36
|
/** What a tier-0 edit to a type may change. */
|
|
19
37
|
export interface TypePatch {
|
|
20
38
|
displayName?: string;
|
|
@@ -39,6 +57,26 @@ export interface ObjectQueryParams {
|
|
|
39
57
|
search?: string;
|
|
40
58
|
sort?: string;
|
|
41
59
|
dir?: 'asc' | 'desc';
|
|
60
|
+
/**
|
|
61
|
+
* `property:operator:value`, one entry per filter, ANDed by the server.
|
|
62
|
+
*
|
|
63
|
+
* Named `filter` rather than `filters` because that is the query parameter the
|
|
64
|
+
* route reads, and this object is handed to a transport that serialises it
|
|
65
|
+
* verbatim — a name that disagreed with the route would be a filter that is
|
|
66
|
+
* sent, ignored, and reported by the screen as applied.
|
|
67
|
+
*
|
|
68
|
+
* Build entries with `encodeObjectFilter` rather than by hand: it is what the
|
|
69
|
+
* server parses with, and the two colons are load-bearing.
|
|
70
|
+
*/
|
|
71
|
+
filter?: string[];
|
|
72
|
+
/**
|
|
73
|
+
* Read the type as of an earlier load. Omit for the current one, which is what
|
|
74
|
+
* every reader must get by default.
|
|
75
|
+
*
|
|
76
|
+
* Ids come from `GET objects/:name/snapshots`. A store that keeps no history
|
|
77
|
+
* refuses this rather than answering with current state.
|
|
78
|
+
*/
|
|
79
|
+
snapshot?: string;
|
|
42
80
|
}
|
|
43
81
|
/**
|
|
44
82
|
* Builds the paths the catalog controller serves, relative to wherever it was
|
package/dist/client.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* types are.
|
|
12
12
|
*/
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
-
exports.DELETE_RECONCILIATION_STRATEGIES = exports.isWorkflowStatus = exports.isWorkflowNodeKind = exports.isWorkflowExecutionMode = exports.workflowRunOrder = exports.workflowGraphHash = exports.WORKFLOW_STATUSES = exports.WORKFLOW_NODE_KINDS = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_EXECUTION_MODES = exports.validateWorkflow = exports.TRANSFORM_LANGUAGES = exports.isWorkflowNode = exports.isWorkflowEdge = exports.isTransformLanguage = exports.isConnectorKind = exports.CONNECTOR_KINDS = exports.catalogRoutes = exports.CATALOG_REVISION_LIMIT = void 0;
|
|
14
|
+
exports.DELETE_RECONCILIATION_STRATEGIES = exports.isWorkflowStatus = exports.isWorkflowNodeKind = exports.isWorkflowExecutionMode = exports.workflowRunOrder = exports.workflowGraphHash = exports.WORKFLOW_STATUSES = exports.WORKFLOW_NODE_KINDS = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_EXECUTION_MODES = exports.validateWorkflow = exports.TRANSFORM_LANGUAGES = exports.isWorkflowNode = exports.isWorkflowEdge = exports.isTransformLanguage = exports.isConnectorKind = exports.CONNECTOR_KINDS = exports.catalogRoutes = exports.VALUELESS_FILTER_OPERATORS = exports.resolveObjectFilters = exports.parseObjectFilter = exports.offeredFilterOperators = exports.isCatalogFilterOperator = exports.filterOperatorsFor = exports.filterOperatorTakesValue = exports.encodeObjectFilter = exports.coerceFilterValue = exports.CATALOG_FILTER_OPERATORS = exports.CATALOG_FILTER_LIMIT = exports.CATALOG_REVISION_LIMIT = void 0;
|
|
15
15
|
exports.isDeleteReconciliationStrategy = isDeleteReconciliationStrategy;
|
|
16
16
|
exports.pipelineExpectationRoutes = pipelineExpectationRoutes;
|
|
17
17
|
// A value, not a type: a screen saying how far back the history goes should read
|
|
@@ -19,6 +19,33 @@ exports.pipelineExpectationRoutes = pipelineExpectationRoutes;
|
|
|
19
19
|
// costs.
|
|
20
20
|
var catalog_workspace_1 = require("./catalog.workspace");
|
|
21
21
|
Object.defineProperty(exports, "CATALOG_REVISION_LIMIT", { enumerable: true, get: function () { return catalog_workspace_1.CATALOG_REVISION_LIMIT; } });
|
|
22
|
+
/**
|
|
23
|
+
* The filter rule, shipped to the browser deliberately — the same exception, for
|
|
24
|
+
* the same reason, that `validateWorkflow` further down is.
|
|
25
|
+
*
|
|
26
|
+
* A console has to know which control to draw for a column, and the only way for
|
|
27
|
+
* that answer to match what the server will accept is for both to run this
|
|
28
|
+
* function. A screen with its own copy of the rules eventually lies: it offers a
|
|
29
|
+
* control the read refuses, or omits one that would have worked, and on types
|
|
30
|
+
* that are created at runtime nobody notices until a publisher adds a column. The
|
|
31
|
+
* functions are pure and import nothing.
|
|
32
|
+
*
|
|
33
|
+
* `SnapshotRef` rides along because a snapshot picker is a browser screen and
|
|
34
|
+
* `GET objects/:name/snapshots` is what fills it. The endpoints alone are not an
|
|
35
|
+
* API; the endpoints plus the response types are.
|
|
36
|
+
*/
|
|
37
|
+
var catalog_filters_1 = require("./catalog.filters");
|
|
38
|
+
Object.defineProperty(exports, "CATALOG_FILTER_LIMIT", { enumerable: true, get: function () { return catalog_filters_1.CATALOG_FILTER_LIMIT; } });
|
|
39
|
+
Object.defineProperty(exports, "CATALOG_FILTER_OPERATORS", { enumerable: true, get: function () { return catalog_filters_1.CATALOG_FILTER_OPERATORS; } });
|
|
40
|
+
Object.defineProperty(exports, "coerceFilterValue", { enumerable: true, get: function () { return catalog_filters_1.coerceFilterValue; } });
|
|
41
|
+
Object.defineProperty(exports, "encodeObjectFilter", { enumerable: true, get: function () { return catalog_filters_1.encodeObjectFilter; } });
|
|
42
|
+
Object.defineProperty(exports, "filterOperatorTakesValue", { enumerable: true, get: function () { return catalog_filters_1.filterOperatorTakesValue; } });
|
|
43
|
+
Object.defineProperty(exports, "filterOperatorsFor", { enumerable: true, get: function () { return catalog_filters_1.filterOperatorsFor; } });
|
|
44
|
+
Object.defineProperty(exports, "isCatalogFilterOperator", { enumerable: true, get: function () { return catalog_filters_1.isCatalogFilterOperator; } });
|
|
45
|
+
Object.defineProperty(exports, "offeredFilterOperators", { enumerable: true, get: function () { return catalog_filters_1.offeredFilterOperators; } });
|
|
46
|
+
Object.defineProperty(exports, "parseObjectFilter", { enumerable: true, get: function () { return catalog_filters_1.parseObjectFilter; } });
|
|
47
|
+
Object.defineProperty(exports, "resolveObjectFilters", { enumerable: true, get: function () { return catalog_filters_1.resolveObjectFilters; } });
|
|
48
|
+
Object.defineProperty(exports, "VALUELESS_FILTER_OPERATORS", { enumerable: true, get: function () { return catalog_filters_1.VALUELESS_FILTER_OPERATORS; } });
|
|
22
49
|
/**
|
|
23
50
|
* Builds the paths the catalog controller serves, relative to wherever it was
|
|
24
51
|
* mounted. Kept as string builders rather than a fetch wrapper so the host
|
package/dist/index.d.ts
CHANGED
|
@@ -18,7 +18,8 @@ export type { CatalogSearchField, CatalogSearchHit, CatalogSearchKind, CatalogSe
|
|
|
18
18
|
export { type AuditQuery, CATALOG_REVISION_LIMIT, CATALOG_TRACE_OUTCOMES, CATALOG_TRACE_STORE, CATALOG_WORKSPACE_STORE, type CatalogAuditEvent, type CatalogRevision, type CatalogTrace, type CatalogTraceList, type CatalogTraceOutcome, type CatalogTraceSpan, type CatalogTraceStore, type CatalogTraceTotals, type CatalogUnlinkedList, type CatalogWorkspaceStore, type Dashboard, type DashboardCard, type EmbeddedChart, type EmbeddedChartPlacement, type EmbeddedDashboard, embeddedVisualization, isCatalogTraceOutcome, isTraceStore, isWorkspaceStore, type QueryVisualization, type SaveQueryInput, type SavedQuery, supportsSavedQueryRevisions, type TraceQuery, traceOutcomeFilter, } from './catalog.workspace';
|
|
19
19
|
export { CATALOG_PRINCIPAL_RESOLVER, type CatalogActor, type CatalogGrants, type CatalogPrincipal, type CatalogPrincipalResolver, type CatalogScope, composePrincipalId, delegatePrincipal, expandScopes, hasScope, parsePrincipalId, PRINCIPAL_ACTOR_SEPARATOR, maySeeClassification, mayRead, mayWrite, readableObjectPage, StaticKeyPrincipalResolver, } from './catalog.principal';
|
|
20
20
|
export * from './catalog.access';
|
|
21
|
-
export
|
|
21
|
+
export * from './catalog.filters';
|
|
22
|
+
export { assertNoColumnCollisions, assertSafeIdentifier, CATALOG_RESERVED_COLUMNS, CATALOG_SNAPSHOT_MODES, CATALOG_STORE, type CarryForwardResult, type CatalogColumnCollision, CatalogColumnCollisionError, type CatalogFilteringReadStore, supportsObjectFilters, type CatalogMergeStore, type CatalogReadQuery, type CatalogReadResult, type CatalogReadStore, type CatalogReservedColumn, type CatalogSnapshotMode, type CatalogStoreCapabilities, type CatalogWriteStore, type ColumnCollisionOptions, findColumnCollisions, isCatalogStoreCapabilities, isReservedColumn, isSafeIdentifier, isWriteStore, type SnapshotRef, supportsCarryForward, UnsafeIdentifierError, } from './catalog.store';
|
|
22
23
|
export { MikroOrmReadStore } from './stores/mikro-orm-read.store';
|
|
23
24
|
export type { CatalogGraph, CatalogObjectPage, CatalogObjectQuery, CatalogObjectTypeDef, CatalogOverlay, CatalogPropertyDef, CatalogRelationDef, CatalogSnapshot, RelationKind, ScalarType, } from './catalog.types';
|
|
24
25
|
export { REQUIRED_SCOPES, REQUIRES_HUMAN, RequireHuman, RequireScopes, } from './catalog.route-auth';
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
exports.MAX_SEARCH_LIMIT = exports.DEFAULT_SEARCH_LIMIT = exports.CatalogService = exports.SubprocessTransformRunner = exports.toCsv = exports.QueryCache = exports.workflowRunOrder = exports.workflowGraphHash = exports.WORKFLOW_STATUSES = exports.WORKFLOW_NODE_KINDS = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_EXECUTION_MODES = exports.validateWorkflow = exports.TRANSFORM_LANGUAGES = exports.TRANSFORM_RUNNER = exports.supportsWorkflowStages = exports.supportsWorkflows = exports.isWorkflowStatus = exports.isWorkflowNodeKind = exports.isWorkflowNode = exports.isWorkflowExecutionMode = exports.isWorkflowEdge = exports.supportsTransformRevisions = exports.supportsLoadExpectations = exports.isTransformLanguage = exports.isPipelineStore = exports.isConnectorKind = exports.CONNECTOR_KINDS = exports.CATALOG_PIPELINE_STORE = exports.CatalogRegistry = exports.MikroOrmCatalogRegistry = exports.CATALOG_OVERLAY_STORE = exports.InMemoryCatalogOverlayStore = exports.FileCatalogOverlayStore = exports.CATALOG_OPTIONS = exports.isQueryStore = exports.assertReadOnlyShape = exports.CatalogModule = exports.emitCatalog = exports.curationActor = exports.channelNameFor = exports.catalogEventPhase = exports.UNATTRIBUTED_PRINCIPAL_ID = exports.CATALOG_LIB = exports.CATALOG_EVENTS = exports.CATALOG_EVENT_PHASE_FALLBACK = exports.CATALOG_EVENT_PHASE = exports.CatalogType = exports.CatalogProperty = void 0;
|
|
18
|
-
exports.RequireScopes = exports.RequireHuman = exports.REQUIRES_HUMAN = exports.REQUIRED_SCOPES = exports.MikroOrmReadStore = exports.UnsafeIdentifierError = exports.supportsCarryForward = exports.isWriteStore = exports.isSafeIdentifier = exports.isReservedColumn = exports.isCatalogStoreCapabilities = exports.findColumnCollisions = exports.CatalogColumnCollisionError = exports.CATALOG_STORE = exports.CATALOG_SNAPSHOT_MODES = exports.CATALOG_RESERVED_COLUMNS = exports.assertSafeIdentifier = exports.assertNoColumnCollisions = exports.StaticKeyPrincipalResolver = exports.readableObjectPage = exports.mayWrite = exports.mayRead = exports.maySeeClassification = exports.PRINCIPAL_ACTOR_SEPARATOR = exports.parsePrincipalId = exports.hasScope = exports.expandScopes = exports.delegatePrincipal = exports.composePrincipalId = exports.CATALOG_PRINCIPAL_RESOLVER = exports.traceOutcomeFilter = exports.supportsSavedQueryRevisions = exports.isWorkspaceStore = exports.isTraceStore = exports.isCatalogTraceOutcome = exports.embeddedVisualization = exports.CATALOG_WORKSPACE_STORE = exports.CATALOG_TRACE_STORE = exports.CATALOG_TRACE_OUTCOMES = exports.CATALOG_REVISION_LIMIT = exports.visibleToPrincipal = exports.searchCatalog = exports.maySearch = exports.emptySearch = exports.bestMatch = void 0;
|
|
18
|
+
exports.RequireScopes = exports.RequireHuman = exports.REQUIRES_HUMAN = exports.REQUIRED_SCOPES = exports.MikroOrmReadStore = exports.UnsafeIdentifierError = exports.supportsCarryForward = exports.isWriteStore = exports.isSafeIdentifier = exports.isReservedColumn = exports.isCatalogStoreCapabilities = exports.findColumnCollisions = exports.supportsObjectFilters = exports.CatalogColumnCollisionError = exports.CATALOG_STORE = exports.CATALOG_SNAPSHOT_MODES = exports.CATALOG_RESERVED_COLUMNS = exports.assertSafeIdentifier = exports.assertNoColumnCollisions = exports.StaticKeyPrincipalResolver = exports.readableObjectPage = exports.mayWrite = exports.mayRead = exports.maySeeClassification = exports.PRINCIPAL_ACTOR_SEPARATOR = exports.parsePrincipalId = exports.hasScope = exports.expandScopes = exports.delegatePrincipal = exports.composePrincipalId = exports.CATALOG_PRINCIPAL_RESOLVER = exports.traceOutcomeFilter = exports.supportsSavedQueryRevisions = exports.isWorkspaceStore = exports.isTraceStore = exports.isCatalogTraceOutcome = exports.embeddedVisualization = exports.CATALOG_WORKSPACE_STORE = exports.CATALOG_TRACE_STORE = exports.CATALOG_TRACE_OUTCOMES = exports.CATALOG_REVISION_LIMIT = exports.visibleToPrincipal = exports.searchCatalog = exports.maySearch = exports.emptySearch = exports.bestMatch = void 0;
|
|
19
19
|
var catalog_decorators_1 = require("./catalog.decorators");
|
|
20
20
|
Object.defineProperty(exports, "CatalogProperty", { enumerable: true, get: function () { return catalog_decorators_1.CatalogProperty; } });
|
|
21
21
|
Object.defineProperty(exports, "CatalogType", { enumerable: true, get: function () { return catalog_decorators_1.CatalogType; } });
|
|
@@ -132,6 +132,13 @@ Object.defineProperty(exports, "StaticKeyPrincipalResolver", { enumerable: true,
|
|
|
132
132
|
// implemented only by a host willing to restate them — which nothing in this
|
|
133
133
|
// repo would have caught: no consumer compiles against the built barrel here.
|
|
134
134
|
__exportStar(require("./catalog.access"), exports);
|
|
135
|
+
// The filter rule, whole. A store implementing `CatalogFilteringReadStore` needs
|
|
136
|
+
// the operator list to declare what it applies and `CatalogResolvedFilter` to
|
|
137
|
+
// read what it was handed, and a host writing its own objects route needs
|
|
138
|
+
// `resolveObjectFilters` — shipping the interface without them would be the same
|
|
139
|
+
// unimplementable seam the barrel spec above was written after. It is also on
|
|
140
|
+
// `/client`, because the console derives its controls from the same function.
|
|
141
|
+
__exportStar(require("./catalog.filters"), exports);
|
|
135
142
|
var catalog_store_1 = require("./catalog.store");
|
|
136
143
|
Object.defineProperty(exports, "assertNoColumnCollisions", { enumerable: true, get: function () { return catalog_store_1.assertNoColumnCollisions; } });
|
|
137
144
|
Object.defineProperty(exports, "assertSafeIdentifier", { enumerable: true, get: function () { return catalog_store_1.assertSafeIdentifier; } });
|
|
@@ -139,6 +146,7 @@ Object.defineProperty(exports, "CATALOG_RESERVED_COLUMNS", { enumerable: true, g
|
|
|
139
146
|
Object.defineProperty(exports, "CATALOG_SNAPSHOT_MODES", { enumerable: true, get: function () { return catalog_store_1.CATALOG_SNAPSHOT_MODES; } });
|
|
140
147
|
Object.defineProperty(exports, "CATALOG_STORE", { enumerable: true, get: function () { return catalog_store_1.CATALOG_STORE; } });
|
|
141
148
|
Object.defineProperty(exports, "CatalogColumnCollisionError", { enumerable: true, get: function () { return catalog_store_1.CatalogColumnCollisionError; } });
|
|
149
|
+
Object.defineProperty(exports, "supportsObjectFilters", { enumerable: true, get: function () { return catalog_store_1.supportsObjectFilters; } });
|
|
142
150
|
Object.defineProperty(exports, "findColumnCollisions", { enumerable: true, get: function () { return catalog_store_1.findColumnCollisions; } });
|
|
143
151
|
Object.defineProperty(exports, "isCatalogStoreCapabilities", { enumerable: true, get: function () { return catalog_store_1.isCatalogStoreCapabilities; } });
|
|
144
152
|
Object.defineProperty(exports, "isReservedColumn", { enumerable: true, get: function () { return catalog_store_1.isReservedColumn; } });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EntityManager } from '@mikro-orm/core';
|
|
2
2
|
import { MikroOrmCatalogRegistry } from '../catalog.registry';
|
|
3
|
-
import type { CatalogReadQuery, CatalogReadResult,
|
|
3
|
+
import type { CatalogFilteringReadStore, CatalogReadQuery, CatalogReadResult, CatalogStoreCapabilities } from '../catalog.store';
|
|
4
4
|
import type { CatalogObjectTypeDef } from '../catalog.types';
|
|
5
5
|
/**
|
|
6
6
|
* Reads objects straight out of the application's own tables, through the ORM
|
|
@@ -11,10 +11,16 @@ import type { CatalogObjectTypeDef } from '../catalog.types';
|
|
|
11
11
|
* trade is that there is also no history — the tables hold current state, and
|
|
12
12
|
* nothing here can show you last Tuesday.
|
|
13
13
|
*/
|
|
14
|
-
export declare class MikroOrmReadStore implements
|
|
14
|
+
export declare class MikroOrmReadStore implements CatalogFilteringReadStore {
|
|
15
15
|
private readonly registry;
|
|
16
16
|
private readonly em;
|
|
17
17
|
readonly capabilities: CatalogStoreCapabilities;
|
|
18
|
+
/**
|
|
19
|
+
* All of them: every operator maps onto a MikroORM query-builder operator, and
|
|
20
|
+
* the ORM writes the column name from the entity metadata rather than from
|
|
21
|
+
* anything a caller sent.
|
|
22
|
+
*/
|
|
23
|
+
readonly objectFilterOperators: readonly ["eq", "ne", "contains", "gte", "lte", "gt", "lt", "empty", "notEmpty"];
|
|
18
24
|
constructor(registry: MikroOrmCatalogRegistry, em: EntityManager);
|
|
19
25
|
read(type: CatalogObjectTypeDef, fields: string[], query: CatalogReadQuery): Promise<CatalogReadResult>;
|
|
20
26
|
}
|
|
@@ -12,6 +12,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
12
12
|
exports.MikroOrmReadStore = void 0;
|
|
13
13
|
const core_1 = require("@mikro-orm/core");
|
|
14
14
|
const common_1 = require("@nestjs/common");
|
|
15
|
+
const catalog_filters_1 = require("../catalog.filters");
|
|
15
16
|
const catalog_registry_1 = require("../catalog.registry");
|
|
16
17
|
/**
|
|
17
18
|
* Reads objects straight out of the application's own tables, through the ORM
|
|
@@ -30,6 +31,12 @@ let MikroOrmReadStore = class MikroOrmReadStore {
|
|
|
30
31
|
writable: false,
|
|
31
32
|
timeTravel: false,
|
|
32
33
|
};
|
|
34
|
+
/**
|
|
35
|
+
* All of them: every operator maps onto a MikroORM query-builder operator, and
|
|
36
|
+
* the ORM writes the column name from the entity metadata rather than from
|
|
37
|
+
* anything a caller sent.
|
|
38
|
+
*/
|
|
39
|
+
objectFilterOperators = catalog_filters_1.CATALOG_FILTER_OPERATORS;
|
|
33
40
|
constructor(registry, em) {
|
|
34
41
|
this.registry = registry;
|
|
35
42
|
this.em = em;
|
|
@@ -48,7 +55,7 @@ let MikroOrmReadStore = class MikroOrmReadStore {
|
|
|
48
55
|
// No explicit type arguments: `findAndCount` declares `Fields extends string
|
|
49
56
|
// = never`, so naming even one generic makes the rest fall back to their
|
|
50
57
|
// defaults and types `fields` as `never[]`. Inference gets it right.
|
|
51
|
-
const [rows, total] = await em.findAndCount(entityClass, buildWhere(type, query
|
|
58
|
+
const [rows, total] = await em.findAndCount(entityClass, buildWhere(type, query), {
|
|
52
59
|
limit: size,
|
|
53
60
|
offset: (page - 1) * size,
|
|
54
61
|
orderBy: buildOrderBy(type, query.sort, query.dir),
|
|
@@ -67,19 +74,78 @@ exports.MikroOrmReadStore = MikroOrmReadStore = __decorate([
|
|
|
67
74
|
core_1.EntityManager])
|
|
68
75
|
], MikroOrmReadStore);
|
|
69
76
|
/**
|
|
70
|
-
*
|
|
77
|
+
* The search term and the column filters, ANDed.
|
|
71
78
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
79
|
+
* Search reaches only string columns the catalog says are visible: a search that
|
|
80
|
+
* reached a classified column would leak it through row membership even though
|
|
81
|
+
* the value is never rendered. `filterOperatorsFor` refuses a classified column
|
|
82
|
+
* for the same reason and a sharper one — a range filter lets a reader
|
|
83
|
+
* binary-search a value they may not see.
|
|
84
|
+
*
|
|
85
|
+
* The filters are ANDed with each other and with the search, which is what makes
|
|
86
|
+
* a filter narrowing: two conditions on one column express a range, and a caller
|
|
87
|
+
* that wanted alternatives has `contains` or a second request.
|
|
74
88
|
*/
|
|
75
|
-
function buildWhere(type,
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
89
|
+
function buildWhere(type, query) {
|
|
90
|
+
const conditions = [];
|
|
91
|
+
const term = query.search?.trim();
|
|
92
|
+
if (term) {
|
|
93
|
+
const searchable = type.properties.filter((p) => !p.hidden && p.type === 'string' && !p.classification);
|
|
94
|
+
if (searchable.length > 0) {
|
|
95
|
+
conditions.push({ $or: searchable.map((p) => ({ [p.name]: { $like: `%${term}%` } })) });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const filter of query.filters ?? []) {
|
|
99
|
+
conditions.push({ [filter.property.name]: comparison(filter) });
|
|
100
|
+
}
|
|
101
|
+
if (conditions.length === 0)
|
|
81
102
|
return {};
|
|
82
|
-
|
|
103
|
+
if (conditions.length === 1)
|
|
104
|
+
return conditions[0];
|
|
105
|
+
return { $and: conditions };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* One operator as MikroORM spells it.
|
|
109
|
+
*
|
|
110
|
+
* The property name is the ORM's own — it came off the type, which was built
|
|
111
|
+
* from the entity metadata — so the column in the emitted SQL is written by the
|
|
112
|
+
* ORM from that metadata and never by string concatenation here. That is the same
|
|
113
|
+
* guarantee the sort above relies on.
|
|
114
|
+
*/
|
|
115
|
+
function comparison(filter) {
|
|
116
|
+
const value = filter.value;
|
|
117
|
+
switch (filter.op) {
|
|
118
|
+
case 'eq':
|
|
119
|
+
return { $eq: value };
|
|
120
|
+
case 'ne':
|
|
121
|
+
// `!=` in SQL is never true of NULL, so a row whose column is empty would
|
|
122
|
+
// drop out of "is not X" — which reads as those rows having the value.
|
|
123
|
+
return { $or: [{ $ne: value }, { $eq: null }] };
|
|
124
|
+
case 'contains':
|
|
125
|
+
return { $like: `%${String(value)}%` };
|
|
126
|
+
case 'gt':
|
|
127
|
+
return { $gt: value };
|
|
128
|
+
case 'gte':
|
|
129
|
+
return { $gte: value };
|
|
130
|
+
case 'lt':
|
|
131
|
+
return { $lt: value };
|
|
132
|
+
case 'lte':
|
|
133
|
+
return { $lte: value };
|
|
134
|
+
case 'empty':
|
|
135
|
+
// A blank string is empty to a reader, and only a text column can hold
|
|
136
|
+
// one. Both spellings, so "no value" means what it says on either.
|
|
137
|
+
return { $or: [{ $eq: null }, { $eq: '' }] };
|
|
138
|
+
case 'notEmpty':
|
|
139
|
+
return { $and: [{ $ne: null }, { $ne: '' }] };
|
|
140
|
+
default:
|
|
141
|
+
// No operator falls through to a silent `{}`, which would be a filter
|
|
142
|
+
// that matches everything. An operator added to the contract and not to
|
|
143
|
+
// this switch fails to compile here rather than at read time.
|
|
144
|
+
return unknownOperator(filter.op);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function unknownOperator(operator) {
|
|
148
|
+
throw new common_1.BadRequestException(`This store cannot filter with ${String(operator)}.`);
|
|
83
149
|
}
|
|
84
150
|
/** Only ever a column the catalog vouched for; falls back to the key. */
|
|
85
151
|
function buildOrderBy(type, sort, dir) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dudousxd/nestjs-catalog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "A metadata registry for NestJS: object types, properties and relations, derived from your ORM and enriched with decorators.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Davide Carvalho",
|