@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.
@@ -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. */
@@ -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
- const { rows, total } = await this.store.read(type, fields, {
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);
@@ -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
- export interface CatalogReadQuery extends CatalogObjectQuery {
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;
@@ -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
  *
@@ -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 { assertNoColumnCollisions, assertSafeIdentifier, CATALOG_RESERVED_COLUMNS, CATALOG_SNAPSHOT_MODES, CATALOG_STORE, type CarryForwardResult, type CatalogColumnCollision, CatalogColumnCollisionError, 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';
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, CatalogReadStore, CatalogStoreCapabilities } from '../catalog.store';
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 CatalogReadStore {
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.search), {
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
- * Only string columns the catalog says are visible.
77
+ * The search term and the column filters, ANDed.
71
78
  *
72
- * A search that reached a classified column would leak it through row
73
- * membership even though the value is never rendered.
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, search) {
76
- const term = search?.trim();
77
- if (!term)
78
- return {};
79
- const searchable = type.properties.filter((p) => !p.hidden && p.type === 'string' && !p.classification);
80
- if (searchable.length === 0)
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
- return { $or: searchable.map((p) => ({ [p.name]: { $like: `%${term}%` } })) };
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.13.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",