@dudousxd/nestjs-catalog 0.26.0 → 0.28.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.aggregate.d.ts +376 -0
- package/dist/catalog.aggregate.js +703 -0
- package/dist/catalog.pipeline.d.ts +786 -10
- package/dist/catalog.pipeline.js +1188 -63
- package/dist/client.d.ts +2 -2
- package/dist/client.js +49 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +49 -3
- package/package.json +1 -1
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The fold behind the `aggregate` node: one pass, one entry per group.
|
|
4
|
+
*
|
|
5
|
+
* ## The property this file exists to have
|
|
6
|
+
*
|
|
7
|
+
* A hash aggregate **consumes its input as a stream and holds only the groups.**
|
|
8
|
+
* It never needs the row it read two rows ago, and it never needs the row it is
|
|
9
|
+
* about to read. So the heap it occupies is a function of how many distinct
|
|
10
|
+
* groups there are, and not of how many rows there were.
|
|
11
|
+
*
|
|
12
|
+
* That is the entire argument for the node, and it is worth stating with the
|
|
13
|
+
* measurement that produced it. flip's `wo` derivation groups **44,720 rows into
|
|
14
|
+
* 16,119 groups** with about fifty aggregates over them. Today it is a
|
|
15
|
+
* whole-batch transform, and as a whole-batch transform it is one column away
|
|
16
|
+
* from dying:
|
|
17
|
+
*
|
|
18
|
+
* - the child is handed 65.51 MiB on stdin;
|
|
19
|
+
* - it answers with **one JSON line of 25.01 MiB — 78.2% of the hard 32 MiB
|
|
20
|
+
* output cap**, which the parent buffers as a single string before parsing;
|
|
21
|
+
* - and before any of that, the parent materialises all 44,720 rows through
|
|
22
|
+
* `readInputs`. A standalone equivalent peaked at 237 MiB heap, 406 MiB RSS.
|
|
23
|
+
*
|
|
24
|
+
* At **1.28×** that file it stops working, and it stops by being killed at the
|
|
25
|
+
* cap rather than by getting slower. SUBWO is already the largest file in the
|
|
26
|
+
* drop it comes from. Run through this file instead, the same derivation holds
|
|
27
|
+
* 16,119 accumulator rows and reads one staged batch at a time.
|
|
28
|
+
*
|
|
29
|
+
* ## Which is exactly why the bound is loud
|
|
30
|
+
*
|
|
31
|
+
* "Holds only the groups" is a *cheap* promise only while the groups are far
|
|
32
|
+
* fewer than the rows. Group by something near-unique and a hash aggregate holds
|
|
33
|
+
* the entire load, which is the thing being fixed rather than a corner of it. So
|
|
34
|
+
* {@link AggregateTable} carries a group ceiling and **refuses** when it is
|
|
35
|
+
* crossed, naming the columns it was grouping on. Peaking silently at the
|
|
36
|
+
* ceiling is the failure mode this node was written about.
|
|
37
|
+
*
|
|
38
|
+
* ## Every arithmetic decision in one place
|
|
39
|
+
*
|
|
40
|
+
* The node is the model; this is the semantics. Both are here rather than in the
|
|
41
|
+
* runner so that the canvas, a spec and a durable step get the same answer out
|
|
42
|
+
* of the same code, and so that the decisions below have one address:
|
|
43
|
+
*
|
|
44
|
+
* - **Comparison order for `min`/`max` over strings** — see {@link compareValues}.
|
|
45
|
+
* - **Summation error** — see {@link addToSum}.
|
|
46
|
+
* - **The bound on a `join`, and what happens at it** — see {@link appendJoin}.
|
|
47
|
+
* - **What makes two records the same group** — see {@link groupKeyOf}.
|
|
48
|
+
*
|
|
49
|
+
* None of them is inherited from MySQL, and where the answer differs from
|
|
50
|
+
* MySQL's the docblock says so and says why, because the alternative is a load
|
|
51
|
+
* that silently disagrees with the query it replaced.
|
|
52
|
+
*/
|
|
53
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
54
|
+
exports.AggregateTable = exports.WorkflowAggregateError = void 0;
|
|
55
|
+
exports.aggregateInputColumns = aggregateInputColumns;
|
|
56
|
+
exports.groupKeyOf = groupKeyOf;
|
|
57
|
+
exports.compareValues = compareValues;
|
|
58
|
+
exports.addToSum = addToSum;
|
|
59
|
+
exports.appendJoin = appendJoin;
|
|
60
|
+
exports.describeGroup = describeGroup;
|
|
61
|
+
exports.describeValue = describeValue;
|
|
62
|
+
const catalog_pipeline_1 = require("./catalog.pipeline");
|
|
63
|
+
/**
|
|
64
|
+
* A refusal raised while folding, rather than a wrong answer carried forward.
|
|
65
|
+
*
|
|
66
|
+
* Its own class so a caller can tell "this data cannot be aggregated the way the
|
|
67
|
+
* node says" from a bug, and turn the first into a 400 with the sentence intact.
|
|
68
|
+
* Every message it carries names the column, the group and the values, because a
|
|
69
|
+
* refusal at row ninety thousand that says only "incomparable" is a refusal
|
|
70
|
+
* somebody has to reproduce before they can act on it.
|
|
71
|
+
*/
|
|
72
|
+
class WorkflowAggregateError extends Error {
|
|
73
|
+
constructor(message) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = 'WorkflowAggregateError';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
exports.WorkflowAggregateError = WorkflowAggregateError;
|
|
79
|
+
/**
|
|
80
|
+
* The fold, as an object you push records into.
|
|
81
|
+
*
|
|
82
|
+
* An object rather than a `reduce` because the caller is a loop over staged
|
|
83
|
+
* batches with an `await` in it: the table has to survive between batches, and
|
|
84
|
+
* nothing about it may scale with how many batches there were.
|
|
85
|
+
*
|
|
86
|
+
* Built from the node, and it refuses a node the validator would refuse rather
|
|
87
|
+
* than folding under a configuration nobody could have saved — a graph can reach
|
|
88
|
+
* a runner from a database written by an older build, and `aggregateRefusals` is
|
|
89
|
+
* the one place that rule lives.
|
|
90
|
+
*/
|
|
91
|
+
class AggregateTable {
|
|
92
|
+
node;
|
|
93
|
+
groups = new Map();
|
|
94
|
+
groupBy;
|
|
95
|
+
aggregates;
|
|
96
|
+
maxGroups;
|
|
97
|
+
/** Named columns not yet seen in any record. Emptied as they turn up. */
|
|
98
|
+
unseen;
|
|
99
|
+
rowsIn = 0;
|
|
100
|
+
coercedFromString = 0;
|
|
101
|
+
longestJoin = 0;
|
|
102
|
+
constructor(node) {
|
|
103
|
+
this.node = node;
|
|
104
|
+
const refusals = (0, catalog_pipeline_1.aggregateRefusals)(node);
|
|
105
|
+
if (refusals.length > 0) {
|
|
106
|
+
throw new WorkflowAggregateError(`Aggregate "${node.name}" (${node.id}) cannot be run as it is. ${refusals.join(' ')}`);
|
|
107
|
+
}
|
|
108
|
+
this.groupBy = node.groupBy;
|
|
109
|
+
this.aggregates = node.aggregates;
|
|
110
|
+
this.maxGroups = (0, catalog_pipeline_1.workflowAggregateMaxGroups)(node);
|
|
111
|
+
this.unseen = new Set([...node.groupBy, ...aggregateInputColumns(node)]);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Fold one record in.
|
|
115
|
+
*
|
|
116
|
+
* Allocates nothing per record except when a record opens a group, which is
|
|
117
|
+
* the property that makes the whole thing a stream: the cost of a row is the
|
|
118
|
+
* group-key encoding plus one accumulator update per aggregate.
|
|
119
|
+
*/
|
|
120
|
+
push(row) {
|
|
121
|
+
this.rowsIn += 1;
|
|
122
|
+
if (this.unseen.size > 0) {
|
|
123
|
+
for (const column of this.unseen) {
|
|
124
|
+
if (row[column] !== undefined)
|
|
125
|
+
this.unseen.delete(column);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const key = [];
|
|
129
|
+
for (const column of this.groupBy)
|
|
130
|
+
key.push(row[column]);
|
|
131
|
+
const id = groupKeyOf(key, this.groupBy, this.node);
|
|
132
|
+
let group = this.groups.get(id);
|
|
133
|
+
if (group === undefined) {
|
|
134
|
+
if (this.groups.size >= this.maxGroups) {
|
|
135
|
+
throw new WorkflowAggregateError(`Aggregate "${this.node.name}" (${this.node.id}) has already held ${this.groups.size} distinct groups after ${this.rowsIn} records, which is the ceiling this node was given. A hash aggregate holds one entry per group, so grouping on ${this.groupBy.map((column) => JSON.stringify(column)).join(', ')} is holding the whole load rather than a summary of it — which is the thing this node exists to avoid. Group on fewer or coarser columns, or raise \`maxGroups\` deliberately and size the machine for it.`);
|
|
136
|
+
}
|
|
137
|
+
group = { key, acc: this.aggregates.map((each) => freshAccumulator(each)) };
|
|
138
|
+
this.groups.set(id, group);
|
|
139
|
+
}
|
|
140
|
+
for (let at = 0; at < this.aggregates.length; at += 1) {
|
|
141
|
+
const aggregate = this.aggregates[at];
|
|
142
|
+
if (aggregate === undefined)
|
|
143
|
+
continue;
|
|
144
|
+
this.accumulate(aggregate, group.acc[at], row, id);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* One aggregate, one record. Narrowed off the function list, never asserted.
|
|
149
|
+
*
|
|
150
|
+
* A dispatch and four one-function methods rather than four branches inline,
|
|
151
|
+
* so each function's rule about nulls — which is the part that has to match
|
|
152
|
+
* SQL and the part that is easy to get subtly wrong — sits next to its own
|
|
153
|
+
* name. Ends in the exhaustiveness guard, so a seventh function is a compile
|
|
154
|
+
* error here rather than a value nobody computed.
|
|
155
|
+
*/
|
|
156
|
+
accumulate(aggregate, slot, row, groupId) {
|
|
157
|
+
const fn = aggregate.fn;
|
|
158
|
+
if (fn === 'count') {
|
|
159
|
+
this.count(aggregate, slot, row);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (fn === 'sum' || fn === 'avg') {
|
|
163
|
+
this.total(aggregate, slot, row, groupId);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (fn === 'min' || fn === 'max') {
|
|
167
|
+
this.extremum(fn, aggregate, slot, row, groupId);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (fn === 'join') {
|
|
171
|
+
this.join(aggregate, slot, row, groupId);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
(0, catalog_pipeline_1.unreachableAggregateFunction)(fn, 'AggregateTable.accumulate');
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* `COUNT(*)` with no column and `COUNT(col)` with one.
|
|
178
|
+
*
|
|
179
|
+
* The two differ exactly where the data is sparse, which is where somebody is
|
|
180
|
+
* most likely to want to know — so both are offered rather than one being
|
|
181
|
+
* picked on the reader's behalf.
|
|
182
|
+
*/
|
|
183
|
+
count(aggregate, slot, row) {
|
|
184
|
+
if (!isCountAcc(slot))
|
|
185
|
+
return;
|
|
186
|
+
if (aggregate.column === undefined) {
|
|
187
|
+
slot.n += 1;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const value = row[aggregate.column];
|
|
191
|
+
if (value !== null && value !== undefined)
|
|
192
|
+
slot.n += 1;
|
|
193
|
+
}
|
|
194
|
+
/** `sum` and `avg`, which share an accumulator and differ only at the finish. */
|
|
195
|
+
total(aggregate, slot, row, groupId) {
|
|
196
|
+
if (!isSumAcc(slot) || aggregate.column === undefined)
|
|
197
|
+
return;
|
|
198
|
+
const numeric = this.toNumber(row[aggregate.column], aggregate, groupId);
|
|
199
|
+
if (numeric === undefined)
|
|
200
|
+
return;
|
|
201
|
+
addToSum(slot, numeric);
|
|
202
|
+
}
|
|
203
|
+
/** `min` and `max`. Nulls are skipped, as SQL skips them. */
|
|
204
|
+
extremum(fn, aggregate, slot, row, groupId) {
|
|
205
|
+
if (!isExtremumAcc(slot) || aggregate.column === undefined)
|
|
206
|
+
return;
|
|
207
|
+
const value = row[aggregate.column];
|
|
208
|
+
if (value === null || value === undefined)
|
|
209
|
+
return;
|
|
210
|
+
if (slot.value === undefined) {
|
|
211
|
+
slot.value = value;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const order = compareValues(slot.value, value, aggregate.column, groupId);
|
|
215
|
+
if (fn === 'min' ? order > 0 : order < 0)
|
|
216
|
+
slot.value = value;
|
|
217
|
+
}
|
|
218
|
+
/** `join`. Nulls skipped, empty strings kept — which is what GROUP_CONCAT does. */
|
|
219
|
+
join(aggregate, slot, row, groupId) {
|
|
220
|
+
if (!isJoinAcc(slot) || aggregate.column === undefined)
|
|
221
|
+
return;
|
|
222
|
+
const value = row[aggregate.column];
|
|
223
|
+
if (value === null || value === undefined)
|
|
224
|
+
return;
|
|
225
|
+
appendJoin(slot, joinText(value, aggregate.column, groupId), aggregate, this.node, groupId);
|
|
226
|
+
if (slot.text.length > this.longestJoin)
|
|
227
|
+
this.longestJoin = slot.text.length;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* A value on its way into a `sum` or an `avg`, or nothing.
|
|
231
|
+
*
|
|
232
|
+
* Three answers rather than two, and the middle one is the decision:
|
|
233
|
+
*
|
|
234
|
+
* - **null, undefined, or a string that is only whitespace** — skipped, and
|
|
235
|
+
* `n` does not move. A blank cell in a CSV is an absent number and not a
|
|
236
|
+
* zero, and SQL agrees: `SUM` over nothing but nulls is `NULL`, which is
|
|
237
|
+
* visibly different from a real total of zero.
|
|
238
|
+
* - **a string that parses as a finite number** — accepted, and counted in
|
|
239
|
+
* {@link AggregateTableStats.coercedFromString}. Refusing would make the
|
|
240
|
+
* node useless on the exact data it was written for, since a CSV delivers
|
|
241
|
+
* every number as text.
|
|
242
|
+
* - **anything else** — refused, naming the column, the group and the value.
|
|
243
|
+
* MySQL reads `'n/a'` as `0` and raises a warning MikroORM does not surface,
|
|
244
|
+
* which is how a total ends up quietly too small. This is that behaviour,
|
|
245
|
+
* not reproduced.
|
|
246
|
+
*/
|
|
247
|
+
toNumber(raw, aggregate, groupId) {
|
|
248
|
+
if (raw === null || raw === undefined)
|
|
249
|
+
return undefined;
|
|
250
|
+
if (typeof raw === 'number') {
|
|
251
|
+
if (Number.isFinite(raw))
|
|
252
|
+
return raw;
|
|
253
|
+
throw new WorkflowAggregateError(`Aggregate "${this.node.name}" (${this.node.id}) was asked to ${aggregate.fn} ${JSON.stringify(aggregate.column)} and found ${String(raw)} in the group ${describeGroup(groupId)}. There is no total that includes it, and carrying it would make every row of the group's answer ${String(raw)} without saying why.`);
|
|
254
|
+
}
|
|
255
|
+
if (typeof raw === 'bigint') {
|
|
256
|
+
this.coercedFromString += 0;
|
|
257
|
+
return Number(raw);
|
|
258
|
+
}
|
|
259
|
+
if (typeof raw === 'string') {
|
|
260
|
+
const trimmed = raw.trim();
|
|
261
|
+
if (trimmed.length === 0)
|
|
262
|
+
return undefined;
|
|
263
|
+
const parsed = Number(trimmed);
|
|
264
|
+
if (Number.isFinite(parsed)) {
|
|
265
|
+
this.coercedFromString += 1;
|
|
266
|
+
return parsed;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
throw new WorkflowAggregateError(`Aggregate "${this.node.name}" (${this.node.id}) was asked to ${aggregate.fn} ${JSON.stringify(aggregate.column)} and found ${describeValue(raw)} in the group ${describeGroup(groupId)}, which is not a number. MySQL would read that as 0 and raise a warning nobody sees, so the total would come out too small and the run would come out green. Normalise the column in a transform above this node, or aggregate a different one.`);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Every group, as records, in the order the groups were first seen.
|
|
273
|
+
*
|
|
274
|
+
* **Insertion order rather than sorted**, and that is a decision about
|
|
275
|
+
* determinism rather than about cost. Sorting would need a total order over
|
|
276
|
+
* heterogeneous key values — the thing {@link compareValues} refuses to invent
|
|
277
|
+
* — and would buy an ordering nobody asked for. Insertion order is a function
|
|
278
|
+
* of the input order, and the input is a numbered list of staged batches, so
|
|
279
|
+
* two runs over the same staged rows emit the same rows in the same order.
|
|
280
|
+
* What it does **not** promise is stability across a source that returns its
|
|
281
|
+
* own rows in a different order; a `SELECT` without an `ORDER BY` promises
|
|
282
|
+
* nothing and this node cannot promise more than it was given.
|
|
283
|
+
*
|
|
284
|
+
* Every record carries **every** output column — the group keys, then the
|
|
285
|
+
* aggregates, in the node's order — including the ones whose answer is `null`.
|
|
286
|
+
* That is what makes an aggregate's output set *exact* rather than an upper
|
|
287
|
+
* bound, which is the claim `producedColumns` makes about this kind and about
|
|
288
|
+
* no other.
|
|
289
|
+
*/
|
|
290
|
+
*emit() {
|
|
291
|
+
for (const group of this.groups.values()) {
|
|
292
|
+
const row = {};
|
|
293
|
+
for (let at = 0; at < this.groupBy.length; at += 1) {
|
|
294
|
+
const column = this.groupBy[at];
|
|
295
|
+
if (column === undefined)
|
|
296
|
+
continue;
|
|
297
|
+
const value = group.key[at];
|
|
298
|
+
// A record that did not carry the column grouped with the records whose
|
|
299
|
+
// column was null — see `groupKeyOf` — so it has to come out as null and
|
|
300
|
+
// not as absent, or the two halves of one group would disagree about
|
|
301
|
+
// whether the column exists.
|
|
302
|
+
row[column] = value === undefined ? null : value;
|
|
303
|
+
}
|
|
304
|
+
for (let at = 0; at < this.aggregates.length; at += 1) {
|
|
305
|
+
const aggregate = this.aggregates[at];
|
|
306
|
+
if (aggregate === undefined)
|
|
307
|
+
continue;
|
|
308
|
+
row[aggregate.as] = finishAccumulator(aggregate, group.acc[at]);
|
|
309
|
+
}
|
|
310
|
+
yield row;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/** What the run log says. Cheap: everything here was counted on the way past. */
|
|
314
|
+
stats() {
|
|
315
|
+
return {
|
|
316
|
+
rowsIn: this.rowsIn,
|
|
317
|
+
groups: this.groups.size,
|
|
318
|
+
unseenColumns: [...this.unseen],
|
|
319
|
+
coercedFromString: this.coercedFromString,
|
|
320
|
+
longestJoin: this.longestJoin,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
exports.AggregateTable = AggregateTable;
|
|
325
|
+
/** The columns a node's aggregates read, deduplicated, `count(*)` excluded. */
|
|
326
|
+
function aggregateInputColumns(node) {
|
|
327
|
+
const columns = new Set();
|
|
328
|
+
for (const aggregate of node.aggregates ?? []) {
|
|
329
|
+
if (aggregate.column !== undefined && aggregate.column.length > 0)
|
|
330
|
+
columns.add(aggregate.column);
|
|
331
|
+
}
|
|
332
|
+
return [...columns];
|
|
333
|
+
}
|
|
334
|
+
/** An empty accumulator, per function. Ends in the exhaustiveness guard. */
|
|
335
|
+
function freshAccumulator(aggregate) {
|
|
336
|
+
const fn = aggregate.fn;
|
|
337
|
+
if (fn === 'count')
|
|
338
|
+
return { n: 0 };
|
|
339
|
+
if (fn === 'sum' || fn === 'avg')
|
|
340
|
+
return { sum: 0, compensation: 0, n: 0 };
|
|
341
|
+
if (fn === 'min' || fn === 'max')
|
|
342
|
+
return { value: undefined };
|
|
343
|
+
if (fn === 'join')
|
|
344
|
+
return { text: '', n: 0 };
|
|
345
|
+
return (0, catalog_pipeline_1.unreachableAggregateFunction)(fn, 'freshAccumulator');
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* An accumulator, read out as the value the group's record carries.
|
|
349
|
+
*
|
|
350
|
+
* `null` wherever nothing non-null arrived — never `0`, and never the empty
|
|
351
|
+
* string. SQL agrees on all four, and the difference is the one signal that
|
|
352
|
+
* tells "this column was in no record" apart from "this column really did sum to
|
|
353
|
+
* nothing": an empty string is a value somebody wrote down and a null is a value
|
|
354
|
+
* nobody did.
|
|
355
|
+
*/
|
|
356
|
+
function finishAccumulator(aggregate, slot) {
|
|
357
|
+
const fn = aggregate.fn;
|
|
358
|
+
// Split three and three: the ones that answer with a number this node
|
|
359
|
+
// computed, and the ones that answer with a value the data already held. A
|
|
360
|
+
// seventh function is still a compile error — it belongs to neither union, so
|
|
361
|
+
// neither call below accepts it.
|
|
362
|
+
if (fn === 'count' || fn === 'sum' || fn === 'avg')
|
|
363
|
+
return finishedNumber(fn, slot);
|
|
364
|
+
return finishedValue(fn, slot);
|
|
365
|
+
}
|
|
366
|
+
/** `count`, `sum` and `avg`: a number this node derived, or null. */
|
|
367
|
+
function finishedNumber(fn, slot) {
|
|
368
|
+
if (fn === 'count')
|
|
369
|
+
return isCountAcc(slot) ? slot.n : 0;
|
|
370
|
+
const total = finishedSum(slot);
|
|
371
|
+
if (total === undefined)
|
|
372
|
+
return null;
|
|
373
|
+
return fn === 'sum' ? total.sum : total.sum / total.n;
|
|
374
|
+
}
|
|
375
|
+
/** `min`, `max` and `join`: a value the data held, or null. */
|
|
376
|
+
function finishedValue(fn, slot) {
|
|
377
|
+
if (fn === 'min' || fn === 'max')
|
|
378
|
+
return isExtremumAcc(slot) ? (slot.value ?? null) : null;
|
|
379
|
+
if (fn === 'join')
|
|
380
|
+
return isJoinAcc(slot) && slot.n > 0 ? slot.text : null;
|
|
381
|
+
return (0, catalog_pipeline_1.unreachableAggregateFunction)(fn, 'finishAccumulator');
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* The total and how many terms went into it, or nothing at all.
|
|
385
|
+
*
|
|
386
|
+
* The compensation is added back here and nowhere else, which is the point of
|
|
387
|
+
* the helper: `sum` and `avg` read the same accumulator, and one of them
|
|
388
|
+
* forgetting the low-order bits would be a difference nobody could see in a
|
|
389
|
+
* report and nobody could explain in a reconciliation.
|
|
390
|
+
*/
|
|
391
|
+
function finishedSum(slot) {
|
|
392
|
+
if (!isSumAcc(slot) || slot.n === 0)
|
|
393
|
+
return undefined;
|
|
394
|
+
return { sum: slot.sum + slot.compensation, n: slot.n };
|
|
395
|
+
}
|
|
396
|
+
/* --- the four decisions -------------------------------------------------- */
|
|
397
|
+
/**
|
|
398
|
+
* What makes two records the same group.
|
|
399
|
+
*
|
|
400
|
+
* The key is the group-by values, encoded into one string so a `Map` can hold
|
|
401
|
+
* them. Every part is **length-prefixed and type-tagged**, which closes two
|
|
402
|
+
* holes that a naive `values.join('|')` leaves open:
|
|
403
|
+
*
|
|
404
|
+
* - `["a|b", "c"]` and `["a", "b|c"]` are different groups and must stay
|
|
405
|
+
* different. Length prefixes make the encoding unambiguous whatever the values
|
|
406
|
+
* contain.
|
|
407
|
+
* - `1` and `"1"` are different groups, and that is a **decision against SQL**,
|
|
408
|
+
* which would coerce them together. Merging them means merging two things the
|
|
409
|
+
* source considered distinct, on a rule nobody chose; within one load a column
|
|
410
|
+
* comes from one system and is one type, so the coercion buys nothing and
|
|
411
|
+
* risks a group that quietly swallowed another.
|
|
412
|
+
*
|
|
413
|
+
* **Missing and null are the same group**, and that is a decision *for* SQL.
|
|
414
|
+
* `GROUP BY` collects all NULLs into one group, and a record that simply lacks
|
|
415
|
+
* the column is a record whose column is null. The stage encoding makes "absent"
|
|
416
|
+
* and "null" a difference in physical layout rather than in meaning — see the
|
|
417
|
+
* shape dictionary in `catalog.stage-encoding.ts` — so grouping them apart would
|
|
418
|
+
* make the answer depend on which shape a row happened to land in, which is not
|
|
419
|
+
* a thing to build a load on.
|
|
420
|
+
*
|
|
421
|
+
* A value that is an object or an array is **refused**. Two objects are equal
|
|
422
|
+
* under some rule and unequal under others, every one of those rules is
|
|
423
|
+
* somebody's convention, and picking one silently decides how many rows a
|
|
424
|
+
* published type ends up holding.
|
|
425
|
+
*/
|
|
426
|
+
function groupKeyOf(values, columns, node) {
|
|
427
|
+
let key = '';
|
|
428
|
+
for (let at = 0; at < values.length; at += 1) {
|
|
429
|
+
const part = keyPart(values[at], columns[at] ?? '', node);
|
|
430
|
+
key += `${part.length}${part}`;
|
|
431
|
+
}
|
|
432
|
+
return key;
|
|
433
|
+
}
|
|
434
|
+
function keyPart(value, column, node) {
|
|
435
|
+
if (value === null || value === undefined)
|
|
436
|
+
return '~';
|
|
437
|
+
if (typeof value === 'string')
|
|
438
|
+
return `s${value}`;
|
|
439
|
+
if (typeof value === 'number') {
|
|
440
|
+
if (!Number.isFinite(value)) {
|
|
441
|
+
throw new WorkflowAggregateError(`Aggregate "${node.name}" (${node.id}) groups on ${JSON.stringify(column)} and found ${String(value)} in it. It is not a value two records can be judged equal on, so it cannot name a group.`);
|
|
442
|
+
}
|
|
443
|
+
return `n${value}`;
|
|
444
|
+
}
|
|
445
|
+
if (typeof value === 'boolean')
|
|
446
|
+
return `b${value ? 1 : 0}`;
|
|
447
|
+
if (typeof value === 'bigint')
|
|
448
|
+
return `n${value}`;
|
|
449
|
+
if (value instanceof Date) {
|
|
450
|
+
const time = value.getTime();
|
|
451
|
+
if (Number.isNaN(time)) {
|
|
452
|
+
throw new WorkflowAggregateError(`Aggregate "${node.name}" (${node.id}) groups on ${JSON.stringify(column)} and found an invalid date in it. Every invalid date is equal to no other date including itself, so it cannot name a group.`);
|
|
453
|
+
}
|
|
454
|
+
return `d${time}`;
|
|
455
|
+
}
|
|
456
|
+
throw new WorkflowAggregateError(`Aggregate "${node.name}" (${node.id}) groups on ${JSON.stringify(column)} and found ${describeValue(value)} in it. Two of those are equal under some rule and unequal under others, and picking one here would silently decide how many rows this load commits. Group on a column of plain values, or flatten it in a transform above this node.`);
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* The order `min` and `max` compare in, and the one place it is decided.
|
|
460
|
+
*
|
|
461
|
+
* ## Strings compare by **code point**, and that is not MySQL's answer
|
|
462
|
+
*
|
|
463
|
+
* The column this node most often replaces sits in MySQL under
|
|
464
|
+
* `utf8mb4_0900_ai_ci` — case-insensitive and accent-insensitive — where
|
|
465
|
+
* `'apple' > 'Banana'` is false. JavaScript's `<` compares UTF-16 code points,
|
|
466
|
+
* where `'B'` is 66 and `'a'` is 97, so `'apple' > 'Banana'` is true. On the real
|
|
467
|
+
* SUBWO data the two answers agree on most columns and **differ on two**: 18 of
|
|
468
|
+
* 16,119 groups for `lastUpdatedBy` and 23 for `maintenanceLocation`, both of
|
|
469
|
+
* which hold mixed-case usernames and location codes.
|
|
470
|
+
*
|
|
471
|
+
* Code point order is implemented anyway, deliberately:
|
|
472
|
+
*
|
|
473
|
+
* - It is **total, stable and machine-independent.** The same rows produce the
|
|
474
|
+
* same answer on every Node build, in every locale, with no ICU data loaded.
|
|
475
|
+
* - The alternative is not "MySQL's answer" — it is *an approximation of one
|
|
476
|
+
* deployment's collation*. `Intl.Collator` implements the Unicode collation
|
|
477
|
+
* algorithm, MySQL implements its own table per collation, and a graph can
|
|
478
|
+
* read from a source that is not MySQL at all. Matching would mean carrying a
|
|
479
|
+
* collation name on the node and reimplementing it, which is a database inside
|
|
480
|
+
* a pipeline node.
|
|
481
|
+
* - Being *near* MySQL is worse than being clearly different. A comparison that
|
|
482
|
+
* agrees 99.8% of the time is one nobody checks and everybody trusts.
|
|
483
|
+
*
|
|
484
|
+
* So it is code point order, it is written down here, and the run log says
|
|
485
|
+
* nothing about it because there is nothing conditional to report — what a
|
|
486
|
+
* reader needs is this paragraph, which is why it is this long.
|
|
487
|
+
*
|
|
488
|
+
* ## Types are not compared across classes
|
|
489
|
+
*
|
|
490
|
+
* Numbers with numbers, strings with strings, dates with dates, booleans with
|
|
491
|
+
* booleans. A `max` over a column holding both `12` and `"12"` is **refused**,
|
|
492
|
+
* naming the column, the group and both values. There is no ordering between a
|
|
493
|
+
* number and a string that is not somebody's coercion rule, and every such rule
|
|
494
|
+
* produces a maximum that depends on which row arrived first — a wrong answer
|
|
495
|
+
* that reports success, which is the shape of failure this node was written
|
|
496
|
+
* about. The fix is one node upstream: normalise the column in a transform.
|
|
497
|
+
*/
|
|
498
|
+
function compareValues(left, right, column, groupId) {
|
|
499
|
+
if (typeof left === 'number' && typeof right === 'number')
|
|
500
|
+
return sign(left, right);
|
|
501
|
+
// Code point order. See the docblock: not MySQL's collation, on purpose.
|
|
502
|
+
if (typeof left === 'string' && typeof right === 'string')
|
|
503
|
+
return sign(left, right);
|
|
504
|
+
if (left instanceof Date && right instanceof Date)
|
|
505
|
+
return sign(left.getTime(), right.getTime());
|
|
506
|
+
if (typeof left === 'boolean' && typeof right === 'boolean') {
|
|
507
|
+
return sign(left ? 1 : 0, right ? 1 : 0);
|
|
508
|
+
}
|
|
509
|
+
if (typeof left === 'bigint' && typeof right === 'bigint')
|
|
510
|
+
return sign(left, right);
|
|
511
|
+
throw new WorkflowAggregateError(`Column ${JSON.stringify(column)} holds ${describeValue(left)} and ${describeValue(right)} in the group ${describeGroup(groupId)}, and there is no order between them that is not somebody's coercion rule. Whichever this node picked, the answer would depend on which record arrived first and the run would still report success. Normalise the column in a transform above this node.`);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Add one value to a running total, with the low-order bits kept.
|
|
515
|
+
*
|
|
516
|
+
* ## The measurement this is here for
|
|
517
|
+
*
|
|
518
|
+
* A prior comparison of flip's `wo` derivation against a JavaScript
|
|
519
|
+
* reimplementation found **17 of 16,119 groups differing in the last float64
|
|
520
|
+
* ulp** — `6442.999999999999` against `6443` — purely from the order the terms
|
|
521
|
+
* were added in. The grand totals reconciled exactly (212,192,113 on both
|
|
522
|
+
* sides), so the difference was tolerable. It was also nobody's decision, and
|
|
523
|
+
* that is the part worth fixing: a load that is off by an ulp because of an
|
|
524
|
+
* accident is one nobody can reason about the next time it is off by more.
|
|
525
|
+
*
|
|
526
|
+
* ## What is implemented, and what it does and does not promise
|
|
527
|
+
*
|
|
528
|
+
* **Neumaier summation.** One extra float per accumulator carries the bits the
|
|
529
|
+
* running total could not hold, and they are added back once at the end. On
|
|
530
|
+
* decimal money and hours — which is what every column this node sums actually
|
|
531
|
+
* is — the result is the correctly-rounded sum, so `6443` comes out as `6443`.
|
|
532
|
+
*
|
|
533
|
+
* What it promises:
|
|
534
|
+
*
|
|
535
|
+
* - The answer is **far** closer to the exact sum than `+=` is, and for values
|
|
536
|
+
* with a couple of decimal places it is the exact sum rounded once.
|
|
537
|
+
* - It costs one number per accumulator and two floating-point operations per
|
|
538
|
+
* row. At 44,720 rows that is unmeasurable.
|
|
539
|
+
*
|
|
540
|
+
* What it does **not** promise:
|
|
541
|
+
*
|
|
542
|
+
* - **Order independence.** Nothing short of exact arithmetic gives that, and
|
|
543
|
+
* exact arithmetic means holding a big decimal per accumulator, which trades
|
|
544
|
+
* the property the node is built on for a rounding difference nobody can
|
|
545
|
+
* observe in a report.
|
|
546
|
+
* - **Bit-for-bit agreement with MySQL.** MySQL sums in its own order and, for a
|
|
547
|
+
* `DECIMAL` column, not in binary floating point at all. Where the source
|
|
548
|
+
* column is `DECIMAL` the honest answer is that these are two different
|
|
549
|
+
* arithmetics and the difference is bounded by one rounding, not that they
|
|
550
|
+
* agree.
|
|
551
|
+
*/
|
|
552
|
+
function addToSum(slot, value) {
|
|
553
|
+
const total = slot.sum + value;
|
|
554
|
+
// Neumaier's refinement of Kahan: whichever of the two was larger in
|
|
555
|
+
// magnitude keeps its bits, and the other's lost low-order bits go to the
|
|
556
|
+
// compensation term. This is the branch Kahan's original gets wrong when the
|
|
557
|
+
// incoming value is the larger of the two.
|
|
558
|
+
slot.compensation +=
|
|
559
|
+
Math.abs(slot.sum) >= Math.abs(value) ? slot.sum - total + value : value - total + slot.sum;
|
|
560
|
+
slot.sum = total;
|
|
561
|
+
slot.n += 1;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Append one value to a joined string, and refuse rather than truncate.
|
|
565
|
+
*
|
|
566
|
+
* ## The behaviour this is designed against, which is live today
|
|
567
|
+
*
|
|
568
|
+
* flip's `wo` derivation uses `GROUP_CONCAT(... SEPARATOR '; ')`. In that
|
|
569
|
+
* deployment `group_concat_max_len` is **1024**. Real values reach **1,700 and
|
|
570
|
+
* 1,883 characters**, and **5 of 16,119 groups exceed the limit on each of two
|
|
571
|
+
* columns**. MySQL truncates at the limit and raises a warning, and MikroORM
|
|
572
|
+
* does not surface the warning. So five rows per column have been silently
|
|
573
|
+
* missing their tail, in committed data, with a green run every time.
|
|
574
|
+
*
|
|
575
|
+
* That is not a MySQL bug and it is not a configuration slip anybody would
|
|
576
|
+
* notice: the default is 1024, the query does not mention it, and a truncated
|
|
577
|
+
* string is a perfectly plausible string.
|
|
578
|
+
*
|
|
579
|
+
* ## What this does instead
|
|
580
|
+
*
|
|
581
|
+
* **Refuses, at the bound, naming the group and the length.** Not truncation
|
|
582
|
+
* with a log line, and not an unbounded string.
|
|
583
|
+
*
|
|
584
|
+
* - Truncating loudly was the other candidate and it loses on the same argument
|
|
585
|
+
* as everything else here: the run would go green, the snapshot would commit,
|
|
586
|
+
* and the log line would be one of twenty on a node nobody reads unless
|
|
587
|
+
* something already went wrong. The value would still be wrong in the
|
|
588
|
+
* warehouse.
|
|
589
|
+
* - Unbounded loses on the node's own thesis. A `join` is the one accumulator
|
|
590
|
+
* whose size is not bounded by the group count, so an unbounded one is a hole
|
|
591
|
+
* straight through "holds only the groups".
|
|
592
|
+
*
|
|
593
|
+
* The default bound is {@link WORKFLOW_AGGREGATE_JOIN_MAX_LENGTH} — 65,535
|
|
594
|
+
* characters, which is what one MySQL `TEXT` column holds, because a value the
|
|
595
|
+
* target column cannot store is the same defect one layer further down. flip's
|
|
596
|
+
* real maximum of 1,883 is 2.9% of it, so the derivation this node was written
|
|
597
|
+
* for runs untouched and would have refused loudly at 35× the data instead of
|
|
598
|
+
* quietly at 1.5×.
|
|
599
|
+
*
|
|
600
|
+
* An author may lower it (a column that should never exceed 200 characters is
|
|
601
|
+
* worth saying so about) or raise it up to a hard ceiling, which is on
|
|
602
|
+
* {@link workflowAggregateJoinMaxLength}.
|
|
603
|
+
*/
|
|
604
|
+
function appendJoin(slot, text, aggregate, node, groupId) {
|
|
605
|
+
const separator = (0, catalog_pipeline_1.workflowAggregateSeparator)(aggregate);
|
|
606
|
+
const addition = slot.n === 0 ? text : separator + text;
|
|
607
|
+
const limit = (0, catalog_pipeline_1.workflowAggregateJoinMaxLength)(aggregate);
|
|
608
|
+
const would = slot.text.length + addition.length;
|
|
609
|
+
if (would > limit) {
|
|
610
|
+
throw new WorkflowAggregateError(`Aggregate "${node.name}" (${node.id}) joins ${JSON.stringify(aggregate.column)} into ${JSON.stringify(aggregate.as)}, and the group ${describeGroup(groupId)} has reached ${would} characters against a limit of ${limit}. It is refused rather than truncated: MySQL's GROUP_CONCAT truncates at group_concat_max_len and raises a warning most drivers never surface, which is how a committed column ends up quietly missing its tail. Raise \`maxLength\` on this aggregate if the whole value is wanted, or count the rows instead of joining them.`);
|
|
611
|
+
}
|
|
612
|
+
slot.text += addition;
|
|
613
|
+
slot.n += 1;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* The order of two values of one type, as -1, 0 or 1.
|
|
617
|
+
*
|
|
618
|
+
* Generic over the comparable primitives rather than taking `unknown`, so
|
|
619
|
+
* {@link compareValues} does the narrowing once per class and this cannot be
|
|
620
|
+
* called on a pair whose comparison would be a coercion.
|
|
621
|
+
*/
|
|
622
|
+
function sign(left, right) {
|
|
623
|
+
if (left < right)
|
|
624
|
+
return -1;
|
|
625
|
+
if (left > right)
|
|
626
|
+
return 1;
|
|
627
|
+
return 0;
|
|
628
|
+
}
|
|
629
|
+
/** One value on its way into a `join`. Refuses what has no obvious spelling. */
|
|
630
|
+
function joinText(value, column, groupId) {
|
|
631
|
+
if (typeof value === 'string')
|
|
632
|
+
return value;
|
|
633
|
+
if (typeof value === 'number' || typeof value === 'bigint')
|
|
634
|
+
return String(value);
|
|
635
|
+
if (typeof value === 'boolean')
|
|
636
|
+
return value ? 'true' : 'false';
|
|
637
|
+
if (value instanceof Date)
|
|
638
|
+
return value.toISOString();
|
|
639
|
+
throw new WorkflowAggregateError(`Column ${JSON.stringify(column)} holds ${describeValue(value)} in the group ${describeGroup(groupId)}, and there is no one spelling of it to join. MySQL would render it as whatever its own cast produced; this node would rather be told which text was meant, in a transform above it.`);
|
|
640
|
+
}
|
|
641
|
+
/* --- narrowing, so nothing here is a type assertion ---------------------- */
|
|
642
|
+
function isCountAcc(slot) {
|
|
643
|
+
return typeof slot === 'object' && slot !== null && typeof Reflect.get(slot, 'n') === 'number';
|
|
644
|
+
}
|
|
645
|
+
function isSumAcc(slot) {
|
|
646
|
+
if (typeof slot !== 'object' || slot === null)
|
|
647
|
+
return false;
|
|
648
|
+
return (typeof Reflect.get(slot, 'sum') === 'number' &&
|
|
649
|
+
typeof Reflect.get(slot, 'compensation') === 'number' &&
|
|
650
|
+
typeof Reflect.get(slot, 'n') === 'number');
|
|
651
|
+
}
|
|
652
|
+
function isExtremumAcc(slot) {
|
|
653
|
+
return typeof slot === 'object' && slot !== null && 'value' in slot;
|
|
654
|
+
}
|
|
655
|
+
function isJoinAcc(slot) {
|
|
656
|
+
if (typeof slot !== 'object' || slot === null)
|
|
657
|
+
return false;
|
|
658
|
+
return (typeof Reflect.get(slot, 'text') === 'string' && typeof Reflect.get(slot, 'n') === 'number');
|
|
659
|
+
}
|
|
660
|
+
/* --- sentences ----------------------------------------------------------- */
|
|
661
|
+
/**
|
|
662
|
+
* A group key, back in something a person can look up.
|
|
663
|
+
*
|
|
664
|
+
* The key is a length-prefixed encoding, which is unreadable on purpose and
|
|
665
|
+
* useless in an error message. This walks it back into the values it was built
|
|
666
|
+
* from, so a refusal names the row somebody has to go and find.
|
|
667
|
+
*/
|
|
668
|
+
function describeGroup(key) {
|
|
669
|
+
const parts = [];
|
|
670
|
+
let at = 0;
|
|
671
|
+
while (at < key.length) {
|
|
672
|
+
const boundary = key.indexOf('', at);
|
|
673
|
+
if (boundary < 0)
|
|
674
|
+
break;
|
|
675
|
+
const length = Number(key.slice(at, boundary));
|
|
676
|
+
if (!Number.isInteger(length) || length < 0)
|
|
677
|
+
break;
|
|
678
|
+
const part = key.slice(boundary + 1, boundary + 1 + length);
|
|
679
|
+
at = boundary + 1 + length;
|
|
680
|
+
parts.push(part === '~' ? 'null' : JSON.stringify(part.slice(1)));
|
|
681
|
+
}
|
|
682
|
+
return parts.length === 0 ? '(unreadable)' : `(${parts.join(', ')})`;
|
|
683
|
+
}
|
|
684
|
+
/** A value, short enough for an error message and honest about its type. */
|
|
685
|
+
function describeValue(value) {
|
|
686
|
+
if (value === null)
|
|
687
|
+
return 'null';
|
|
688
|
+
if (value === undefined)
|
|
689
|
+
return 'nothing';
|
|
690
|
+
if (typeof value === 'string') {
|
|
691
|
+
const shown = value.length > 40 ? `${value.slice(0, 40)}…` : value;
|
|
692
|
+
return `the text ${JSON.stringify(shown)}`;
|
|
693
|
+
}
|
|
694
|
+
if (typeof value === 'number' || typeof value === 'bigint')
|
|
695
|
+
return `the number ${String(value)}`;
|
|
696
|
+
if (typeof value === 'boolean')
|
|
697
|
+
return `the boolean ${String(value)}`;
|
|
698
|
+
if (value instanceof Date)
|
|
699
|
+
return `the date ${value.toISOString()}`;
|
|
700
|
+
if (Array.isArray(value))
|
|
701
|
+
return `a list of ${value.length}`;
|
|
702
|
+
return 'an object';
|
|
703
|
+
}
|