@dudousxd/nestjs-catalog 0.13.0 → 0.15.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 +66 -6
- package/dist/catalog.csv.d.ts +89 -0
- package/dist/catalog.csv.js +163 -0
- package/dist/catalog.filters.d.ts +174 -0
- package/dist/catalog.filters.js +272 -0
- package/dist/catalog.identifiers.d.ts +161 -0
- package/dist/catalog.identifiers.js +195 -0
- package/dist/catalog.pipeline.d.ts +392 -35
- package/dist/catalog.pipeline.js +175 -25
- package/dist/catalog.query-cache.d.ts +0 -2
- package/dist/catalog.query-cache.js +0 -18
- package/dist/catalog.query.d.ts +43 -0
- package/dist/catalog.query.js +5 -0
- package/dist/catalog.service.d.ts +51 -0
- package/dist/catalog.service.js +126 -3
- package/dist/catalog.store.d.ts +84 -22
- package/dist/catalog.store.js +36 -68
- package/dist/catalog.types.d.ts +64 -0
- package/dist/client.d.ts +70 -2
- package/dist/client.js +66 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +24 -3
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a name becomes a column, and what it has to look like by the end.
|
|
3
|
+
*
|
|
4
|
+
* Two rules, and they are here together because neither is usable without the
|
|
5
|
+
* other. {@link physicalColumn} is the *cleaning* — the lossy map from a
|
|
6
|
+
* property's name to the column a store creates for it. {@link isSafeIdentifier}
|
|
7
|
+
* is the *character set* the result of that cleaning has to be in. What a
|
|
8
|
+
* publisher is actually promised is the composition: a name may be spelled
|
|
9
|
+
* however the source spells it, and what the cleaning produces has to be
|
|
10
|
+
* something a store can write.
|
|
11
|
+
*
|
|
12
|
+
* Identifiers themselves are *rejected*, never escaped. Every table and column
|
|
13
|
+
* name a store emits arrives from another application over HTTP and ends up in
|
|
14
|
+
* DDL and in SELECT lists, where no placeholder can stand in for it, so anything
|
|
15
|
+
* outside this character set never becomes SQL at all.
|
|
16
|
+
*
|
|
17
|
+
* It is part of what the catalog promises a *publisher*. Refuse a property name
|
|
18
|
+
* and the sentence explaining why is the only statement of the rule most people
|
|
19
|
+
* will ever read, so it belongs to the contract rather than to whichever adapter
|
|
20
|
+
* happens to be mounted.
|
|
21
|
+
*
|
|
22
|
+
* And for one more reason. It used to be two copies — `store-mikro-orm` and
|
|
23
|
+
* `store-clickhouse` each carried this pattern and this sentence, byte for byte
|
|
24
|
+
* — and the publish-time refusal in the pipeline package borrowed the MySQL one
|
|
25
|
+
* so that publish-time and DDL-time could not disagree about the character set,
|
|
26
|
+
* the length or the wording. That bought the guarantee for a MySQL deployment
|
|
27
|
+
* and left a ClickHouse-only one trusting two files to be edited together. One
|
|
28
|
+
* definition is the guarantee; two identical ones are a habit.
|
|
29
|
+
*
|
|
30
|
+
* ---
|
|
31
|
+
*
|
|
32
|
+
* **Why this is its own module, and why it imports nothing.**
|
|
33
|
+
*
|
|
34
|
+
* All of this used to live in `catalog.store.ts`, which is still where every
|
|
35
|
+
* server-side caller reaches it from — that file re-exports all five names, so no
|
|
36
|
+
* import anywhere had to change. What could not stay there is the
|
|
37
|
+
* *reachability*: `catalog.store.ts` imports `BadRequestException` from
|
|
38
|
+
* `@nestjs/common` at module scope, so anything importing a **value** out of it
|
|
39
|
+
* drags NestJS along. That is fine on the server and disqualifying for
|
|
40
|
+
* `/client`, which exists precisely so a browser can share the server's rules
|
|
41
|
+
* without shipping the server.
|
|
42
|
+
*
|
|
43
|
+
* And a browser now has to be able to ask this question. A workflow template
|
|
44
|
+
* that proposes replicating a table has to know, while somebody is still
|
|
45
|
+
* choosing, whether the source's column names could be published as property
|
|
46
|
+
* names — because if they could not, the graph it would draw is refused at
|
|
47
|
+
* publish, or worse, gets "fixed" by a rename that commits nulls and reports
|
|
48
|
+
* success. Answering that from a copy of the pattern is the one thing this
|
|
49
|
+
* module's own history says not to do: the copy is what drifts, and a canvas
|
|
50
|
+
* whose idea of a legal name differs from the store's by one character is a
|
|
51
|
+
* canvas that promises a load the publisher then refuses.
|
|
52
|
+
*
|
|
53
|
+
* That is why {@link physicalColumn} had to come along rather than only
|
|
54
|
+
* {@link isSafeIdentifier}. The question a publisher is refused on is
|
|
55
|
+
* `isSafeIdentifier(physicalColumn(name))`, not `isSafeIdentifier(name)`, and a
|
|
56
|
+
* browser holding only half the composition would answer a different question
|
|
57
|
+
* from the server's — which is the same drift by another route.
|
|
58
|
+
*
|
|
59
|
+
* So the rule moved to a file with no imports, and is exported from both entry
|
|
60
|
+
* points. `validateWorkflow` set the precedent and made the same argument.
|
|
61
|
+
*/
|
|
62
|
+
/**
|
|
63
|
+
* Why a name cannot be written into SQL, in the words a publisher is given.
|
|
64
|
+
*
|
|
65
|
+
* One class for the whole ecosystem rather than one per adapter, so
|
|
66
|
+
* `instanceof` is a usable question across packages. The publish-time check in
|
|
67
|
+
* the pipeline package catches this to tell "that name cannot be an identifier"
|
|
68
|
+
* from "something else failed inside the store", and with a class per adapter
|
|
69
|
+
* that check would re-throw the moment the mounted store was not the one it
|
|
70
|
+
* imported — turning a 400 that names the property into a 500 that names
|
|
71
|
+
* nothing.
|
|
72
|
+
*/
|
|
73
|
+
export declare class UnsafeIdentifierError extends Error {
|
|
74
|
+
constructor(value: string);
|
|
75
|
+
}
|
|
76
|
+
/** Whether a name can be written into SQL as it stands. */
|
|
77
|
+
export declare function isSafeIdentifier(value: string): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Refuse a name that cannot be a SQL identifier.
|
|
80
|
+
*
|
|
81
|
+
* Throws rather than answering, because the caller's next line writes the value
|
|
82
|
+
* into a statement: a boolean that can be ignored is a boolean that eventually
|
|
83
|
+
* is. {@link isSafeIdentifier} is there for the callers that are asking rather
|
|
84
|
+
* than about to build.
|
|
85
|
+
*/
|
|
86
|
+
export declare function assertSafeIdentifier(value: string): void;
|
|
87
|
+
/**
|
|
88
|
+
* A property's name, cleaned into the column a store can create for it.
|
|
89
|
+
*
|
|
90
|
+
* Here rather than in each adapter for the reason {@link assertSafeIdentifier}
|
|
91
|
+
* is: this is no longer only an adapter's private repair. It decides the column
|
|
92
|
+
* a load's values are written to, the column a filter is applied to, the name a
|
|
93
|
+
* committed view exposes the field under, and — since it does all of that — it
|
|
94
|
+
* decides whether a published name can work at all. The pipeline package refuses
|
|
95
|
+
* a name at publish time by asking whether *this* produces an identifier, so the
|
|
96
|
+
* refusal and the DDL have to be running the same cleaning rather than two
|
|
97
|
+
* copies of it. `store-mikro-orm` and `store-clickhouse` each carried a
|
|
98
|
+
* byte-identical private copy, and `store-mikro-orm` carried two of its own —
|
|
99
|
+
* one in `query.ts` for the view, one in `mysql-warehouse.store.ts` for
|
|
100
|
+
* everything else. Three copies of the function that decides where a column's
|
|
101
|
+
* data lives is three chances for a view to point at a column no load ever
|
|
102
|
+
* wrote.
|
|
103
|
+
*
|
|
104
|
+
* Lossy on purpose, and lossy in a way callers must handle rather than assume
|
|
105
|
+
* away: `Asset Id` and `Asset/Id` both clean to `Asset_Id`, which is what
|
|
106
|
+
* `assertNoColumnCollisions` exists to catch.
|
|
107
|
+
*
|
|
108
|
+
* 60 characters, not the 63 the identifier rule allows, and the three characters
|
|
109
|
+
* of headroom are not decorative — a store that needs to derive a second name
|
|
110
|
+
* from a column has room inside MySQL's 64-character ceiling to do it. Widening
|
|
111
|
+
* this would silently rename the column of every property whose name is 61 to 63
|
|
112
|
+
* characters long, so it stays where it is.
|
|
113
|
+
*
|
|
114
|
+
* Not every output is an identifier: `1 2 3` cleans to `1_2_3`, which no store
|
|
115
|
+
* will quote. That is not this function's business to fix — a suggestion is
|
|
116
|
+
* `toPhysicalName` in an adapter, and a refusal is `assertSafeIdentifier` on the
|
|
117
|
+
* result.
|
|
118
|
+
*/
|
|
119
|
+
export declare function physicalColumn(propertyName: string): string;
|
|
120
|
+
/**
|
|
121
|
+
* The column name a store exposes a property under, in the committed view and
|
|
122
|
+
* in the SELECT list of a read.
|
|
123
|
+
*
|
|
124
|
+
* **Why this is not simply the property's name.** It used to be. Every store
|
|
125
|
+
* wrote `\`Asset_Id\` AS \`Asset Id\`` — the physical column reached by cleaning,
|
|
126
|
+
* the alias taken verbatim — and the alias went through `ident`, which refuses
|
|
127
|
+
* rather than escapes. So a property could only be named something that was
|
|
128
|
+
* already a SQL identifier, which meant a source column genuinely called `Asset
|
|
129
|
+
* Id` could not be published under its own spelling.
|
|
130
|
+
*
|
|
131
|
+
* That mattered far more than it looks. A load matches a source's record to a
|
|
132
|
+
* property by property NAME — the store reads `row[property.name]` — so a
|
|
133
|
+
* publisher forced to rename the property to `Asset_Id`, keeping `Asset Id` in
|
|
134
|
+
* `columnName`, was publishing a type whose every read of that field returned
|
|
135
|
+
* `undefined`. `columnName` is lineage; nothing consults it on the write path.
|
|
136
|
+
* The loads committed, the row counts were right, and the column was NULL in
|
|
137
|
+
* every row. Thirteen types were loaded that way and six of them came out with
|
|
138
|
+
* most of their columns empty — 73 of 84 on the largest, across 313,833 rows.
|
|
139
|
+
* The verbatim alias is what forced the rename, so the alias is what changed.
|
|
140
|
+
*
|
|
141
|
+
* **Why the name is still kept when it is already an identifier.** The obvious
|
|
142
|
+
* fix — always alias to {@link physicalColumn} — would rename the output column
|
|
143
|
+
* of every existing view whose property name is not equal to its own cleaned
|
|
144
|
+
* form: a property called `Asset__Id` (two underscores collapse to one) or one
|
|
145
|
+
* 61 characters long (cut to 60). Those views work today and somebody is
|
|
146
|
+
* selecting from them by name. Renaming a column under a working consumer to
|
|
147
|
+
* tidy up an inconsistency is not a trade worth making, so the rule is the
|
|
148
|
+
* narrower one: **a name that SQL can take as it stands is kept exactly; only a
|
|
149
|
+
* name SQL cannot take is cleaned.** Every view that resolves today keeps every
|
|
150
|
+
* column name it has today.
|
|
151
|
+
*
|
|
152
|
+
* **This introduces no new way for two properties to collide.** If two distinct
|
|
153
|
+
* names produce one alias then they also produce one {@link physicalColumn}, so
|
|
154
|
+
* `assertNoColumnCollisions` already refuses the pair. Both unsafe: equal
|
|
155
|
+
* aliases *are* equal physical columns. Both safe: equal aliases are equal
|
|
156
|
+
* names, and there is only one property per name. One of each — a safe `x` and
|
|
157
|
+
* an unsafe `y` with `physicalColumn(y) === x` — means `x` contains no run of
|
|
158
|
+
* underscores and is at most 60 characters, so `physicalColumn(x) === x ===
|
|
159
|
+
* physicalColumn(y)` and the columns collide too.
|
|
160
|
+
*/
|
|
161
|
+
export declare function outputAlias(propertyName: string): string;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* How a name becomes a column, and what it has to look like by the end.
|
|
4
|
+
*
|
|
5
|
+
* Two rules, and they are here together because neither is usable without the
|
|
6
|
+
* other. {@link physicalColumn} is the *cleaning* — the lossy map from a
|
|
7
|
+
* property's name to the column a store creates for it. {@link isSafeIdentifier}
|
|
8
|
+
* is the *character set* the result of that cleaning has to be in. What a
|
|
9
|
+
* publisher is actually promised is the composition: a name may be spelled
|
|
10
|
+
* however the source spells it, and what the cleaning produces has to be
|
|
11
|
+
* something a store can write.
|
|
12
|
+
*
|
|
13
|
+
* Identifiers themselves are *rejected*, never escaped. Every table and column
|
|
14
|
+
* name a store emits arrives from another application over HTTP and ends up in
|
|
15
|
+
* DDL and in SELECT lists, where no placeholder can stand in for it, so anything
|
|
16
|
+
* outside this character set never becomes SQL at all.
|
|
17
|
+
*
|
|
18
|
+
* It is part of what the catalog promises a *publisher*. Refuse a property name
|
|
19
|
+
* and the sentence explaining why is the only statement of the rule most people
|
|
20
|
+
* will ever read, so it belongs to the contract rather than to whichever adapter
|
|
21
|
+
* happens to be mounted.
|
|
22
|
+
*
|
|
23
|
+
* And for one more reason. It used to be two copies — `store-mikro-orm` and
|
|
24
|
+
* `store-clickhouse` each carried this pattern and this sentence, byte for byte
|
|
25
|
+
* — and the publish-time refusal in the pipeline package borrowed the MySQL one
|
|
26
|
+
* so that publish-time and DDL-time could not disagree about the character set,
|
|
27
|
+
* the length or the wording. That bought the guarantee for a MySQL deployment
|
|
28
|
+
* and left a ClickHouse-only one trusting two files to be edited together. One
|
|
29
|
+
* definition is the guarantee; two identical ones are a habit.
|
|
30
|
+
*
|
|
31
|
+
* ---
|
|
32
|
+
*
|
|
33
|
+
* **Why this is its own module, and why it imports nothing.**
|
|
34
|
+
*
|
|
35
|
+
* All of this used to live in `catalog.store.ts`, which is still where every
|
|
36
|
+
* server-side caller reaches it from — that file re-exports all five names, so no
|
|
37
|
+
* import anywhere had to change. What could not stay there is the
|
|
38
|
+
* *reachability*: `catalog.store.ts` imports `BadRequestException` from
|
|
39
|
+
* `@nestjs/common` at module scope, so anything importing a **value** out of it
|
|
40
|
+
* drags NestJS along. That is fine on the server and disqualifying for
|
|
41
|
+
* `/client`, which exists precisely so a browser can share the server's rules
|
|
42
|
+
* without shipping the server.
|
|
43
|
+
*
|
|
44
|
+
* And a browser now has to be able to ask this question. A workflow template
|
|
45
|
+
* that proposes replicating a table has to know, while somebody is still
|
|
46
|
+
* choosing, whether the source's column names could be published as property
|
|
47
|
+
* names — because if they could not, the graph it would draw is refused at
|
|
48
|
+
* publish, or worse, gets "fixed" by a rename that commits nulls and reports
|
|
49
|
+
* success. Answering that from a copy of the pattern is the one thing this
|
|
50
|
+
* module's own history says not to do: the copy is what drifts, and a canvas
|
|
51
|
+
* whose idea of a legal name differs from the store's by one character is a
|
|
52
|
+
* canvas that promises a load the publisher then refuses.
|
|
53
|
+
*
|
|
54
|
+
* That is why {@link physicalColumn} had to come along rather than only
|
|
55
|
+
* {@link isSafeIdentifier}. The question a publisher is refused on is
|
|
56
|
+
* `isSafeIdentifier(physicalColumn(name))`, not `isSafeIdentifier(name)`, and a
|
|
57
|
+
* browser holding only half the composition would answer a different question
|
|
58
|
+
* from the server's — which is the same drift by another route.
|
|
59
|
+
*
|
|
60
|
+
* So the rule moved to a file with no imports, and is exported from both entry
|
|
61
|
+
* points. `validateWorkflow` set the precedent and made the same argument.
|
|
62
|
+
*/
|
|
63
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
64
|
+
exports.UnsafeIdentifierError = void 0;
|
|
65
|
+
exports.isSafeIdentifier = isSafeIdentifier;
|
|
66
|
+
exports.assertSafeIdentifier = assertSafeIdentifier;
|
|
67
|
+
exports.physicalColumn = physicalColumn;
|
|
68
|
+
exports.outputAlias = outputAlias;
|
|
69
|
+
/**
|
|
70
|
+
* 63 characters because it is under MySQL's 64-character ceiling and no engine
|
|
71
|
+
* a store here targets refuses a name that short, and because the number is
|
|
72
|
+
* quoted in the refusal below: a per-store limit would mean a publisher being
|
|
73
|
+
* told a different rule depending on what is mounted, for a name the catalog
|
|
74
|
+
* would then be unable to promise anything about across a fan-out.
|
|
75
|
+
*
|
|
76
|
+
* Not exported. A `RegExp` is mutable and shared state, and the two questions
|
|
77
|
+
* anyone has of it — "may I?" and "why not?" — are {@link isSafeIdentifier} and
|
|
78
|
+
* {@link UnsafeIdentifierError}.
|
|
79
|
+
*/
|
|
80
|
+
const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
|
|
81
|
+
/**
|
|
82
|
+
* Why a name cannot be written into SQL, in the words a publisher is given.
|
|
83
|
+
*
|
|
84
|
+
* One class for the whole ecosystem rather than one per adapter, so
|
|
85
|
+
* `instanceof` is a usable question across packages. The publish-time check in
|
|
86
|
+
* the pipeline package catches this to tell "that name cannot be an identifier"
|
|
87
|
+
* from "something else failed inside the store", and with a class per adapter
|
|
88
|
+
* that check would re-throw the moment the mounted store was not the one it
|
|
89
|
+
* imported — turning a 400 that names the property into a 500 that names
|
|
90
|
+
* nothing.
|
|
91
|
+
*/
|
|
92
|
+
class UnsafeIdentifierError extends Error {
|
|
93
|
+
constructor(value) {
|
|
94
|
+
super(`Refusing to use "${value}" as a SQL identifier: letters, digits and underscore only, starting with a letter or underscore, 63 characters max.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
exports.UnsafeIdentifierError = UnsafeIdentifierError;
|
|
98
|
+
/** Whether a name can be written into SQL as it stands. */
|
|
99
|
+
function isSafeIdentifier(value) {
|
|
100
|
+
return SAFE_IDENTIFIER.test(value);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Refuse a name that cannot be a SQL identifier.
|
|
104
|
+
*
|
|
105
|
+
* Throws rather than answering, because the caller's next line writes the value
|
|
106
|
+
* into a statement: a boolean that can be ignored is a boolean that eventually
|
|
107
|
+
* is. {@link isSafeIdentifier} is there for the callers that are asking rather
|
|
108
|
+
* than about to build.
|
|
109
|
+
*/
|
|
110
|
+
function assertSafeIdentifier(value) {
|
|
111
|
+
if (!isSafeIdentifier(value))
|
|
112
|
+
throw new UnsafeIdentifierError(value);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A property's name, cleaned into the column a store can create for it.
|
|
116
|
+
*
|
|
117
|
+
* Here rather than in each adapter for the reason {@link assertSafeIdentifier}
|
|
118
|
+
* is: this is no longer only an adapter's private repair. It decides the column
|
|
119
|
+
* a load's values are written to, the column a filter is applied to, the name a
|
|
120
|
+
* committed view exposes the field under, and — since it does all of that — it
|
|
121
|
+
* decides whether a published name can work at all. The pipeline package refuses
|
|
122
|
+
* a name at publish time by asking whether *this* produces an identifier, so the
|
|
123
|
+
* refusal and the DDL have to be running the same cleaning rather than two
|
|
124
|
+
* copies of it. `store-mikro-orm` and `store-clickhouse` each carried a
|
|
125
|
+
* byte-identical private copy, and `store-mikro-orm` carried two of its own —
|
|
126
|
+
* one in `query.ts` for the view, one in `mysql-warehouse.store.ts` for
|
|
127
|
+
* everything else. Three copies of the function that decides where a column's
|
|
128
|
+
* data lives is three chances for a view to point at a column no load ever
|
|
129
|
+
* wrote.
|
|
130
|
+
*
|
|
131
|
+
* Lossy on purpose, and lossy in a way callers must handle rather than assume
|
|
132
|
+
* away: `Asset Id` and `Asset/Id` both clean to `Asset_Id`, which is what
|
|
133
|
+
* `assertNoColumnCollisions` exists to catch.
|
|
134
|
+
*
|
|
135
|
+
* 60 characters, not the 63 the identifier rule allows, and the three characters
|
|
136
|
+
* of headroom are not decorative — a store that needs to derive a second name
|
|
137
|
+
* from a column has room inside MySQL's 64-character ceiling to do it. Widening
|
|
138
|
+
* this would silently rename the column of every property whose name is 61 to 63
|
|
139
|
+
* characters long, so it stays where it is.
|
|
140
|
+
*
|
|
141
|
+
* Not every output is an identifier: `1 2 3` cleans to `1_2_3`, which no store
|
|
142
|
+
* will quote. That is not this function's business to fix — a suggestion is
|
|
143
|
+
* `toPhysicalName` in an adapter, and a refusal is `assertSafeIdentifier` on the
|
|
144
|
+
* result.
|
|
145
|
+
*/
|
|
146
|
+
function physicalColumn(propertyName) {
|
|
147
|
+
return propertyName
|
|
148
|
+
.replace(/[^A-Za-z0-9_]/g, '_')
|
|
149
|
+
.replace(/_+/g, '_')
|
|
150
|
+
.slice(0, 60);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The column name a store exposes a property under, in the committed view and
|
|
154
|
+
* in the SELECT list of a read.
|
|
155
|
+
*
|
|
156
|
+
* **Why this is not simply the property's name.** It used to be. Every store
|
|
157
|
+
* wrote `\`Asset_Id\` AS \`Asset Id\`` — the physical column reached by cleaning,
|
|
158
|
+
* the alias taken verbatim — and the alias went through `ident`, which refuses
|
|
159
|
+
* rather than escapes. So a property could only be named something that was
|
|
160
|
+
* already a SQL identifier, which meant a source column genuinely called `Asset
|
|
161
|
+
* Id` could not be published under its own spelling.
|
|
162
|
+
*
|
|
163
|
+
* That mattered far more than it looks. A load matches a source's record to a
|
|
164
|
+
* property by property NAME — the store reads `row[property.name]` — so a
|
|
165
|
+
* publisher forced to rename the property to `Asset_Id`, keeping `Asset Id` in
|
|
166
|
+
* `columnName`, was publishing a type whose every read of that field returned
|
|
167
|
+
* `undefined`. `columnName` is lineage; nothing consults it on the write path.
|
|
168
|
+
* The loads committed, the row counts were right, and the column was NULL in
|
|
169
|
+
* every row. Thirteen types were loaded that way and six of them came out with
|
|
170
|
+
* most of their columns empty — 73 of 84 on the largest, across 313,833 rows.
|
|
171
|
+
* The verbatim alias is what forced the rename, so the alias is what changed.
|
|
172
|
+
*
|
|
173
|
+
* **Why the name is still kept when it is already an identifier.** The obvious
|
|
174
|
+
* fix — always alias to {@link physicalColumn} — would rename the output column
|
|
175
|
+
* of every existing view whose property name is not equal to its own cleaned
|
|
176
|
+
* form: a property called `Asset__Id` (two underscores collapse to one) or one
|
|
177
|
+
* 61 characters long (cut to 60). Those views work today and somebody is
|
|
178
|
+
* selecting from them by name. Renaming a column under a working consumer to
|
|
179
|
+
* tidy up an inconsistency is not a trade worth making, so the rule is the
|
|
180
|
+
* narrower one: **a name that SQL can take as it stands is kept exactly; only a
|
|
181
|
+
* name SQL cannot take is cleaned.** Every view that resolves today keeps every
|
|
182
|
+
* column name it has today.
|
|
183
|
+
*
|
|
184
|
+
* **This introduces no new way for two properties to collide.** If two distinct
|
|
185
|
+
* names produce one alias then they also produce one {@link physicalColumn}, so
|
|
186
|
+
* `assertNoColumnCollisions` already refuses the pair. Both unsafe: equal
|
|
187
|
+
* aliases *are* equal physical columns. Both safe: equal aliases are equal
|
|
188
|
+
* names, and there is only one property per name. One of each — a safe `x` and
|
|
189
|
+
* an unsafe `y` with `physicalColumn(y) === x` — means `x` contains no run of
|
|
190
|
+
* underscores and is at most 60 characters, so `physicalColumn(x) === x ===
|
|
191
|
+
* physicalColumn(y)` and the columns collide too.
|
|
192
|
+
*/
|
|
193
|
+
function outputAlias(propertyName) {
|
|
194
|
+
return isSafeIdentifier(propertyName) ? propertyName : physicalColumn(propertyName);
|
|
195
|
+
}
|