@dudousxd/nestjs-catalog 0.26.0 → 0.27.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 +390 -3
- package/dist/catalog.pipeline.js +547 -7
- package/dist/client.d.ts +2 -2
- package/dist/client.js +31 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +35 -2
- package/package.json +1 -1
package/dist/catalog.pipeline.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* systems each believing they decide when a load runs.
|
|
10
10
|
*/
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.REDACTED_SECRET = exports.CATALOG_PIPELINE_STORE = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_CALL_CONTRACT = exports.WORKFLOW_EXECUTION_MODES = exports.WORKFLOW_STATUSES = exports.WORKFLOW_BRANCH_LABELS = exports.NODE_KIND_IS_REUSABLE = exports.REUSABLE_NODE_KINDS = exports.WORKFLOW_RENAME_MAX_COLUMNS = exports.WORKFLOW_RENAME_UNNAMED = exports.WORKFLOW_FILTER_MAX_VALUES = exports.WORKFLOW_FILTER_MAX_DEPTH = exports.WORKFLOW_FILTER_COLUMN_PATTERN = exports.WORKFLOW_FILTER_OPERATORS = exports.WORKFLOW_FILTER_PREDICATE_KINDS = exports.WORKFLOW_PREDICATE_KINDS = exports.WORKFLOW_CALL_MODES = exports.WORKFLOW_ROW_GAP = exports.WORKFLOW_COLUMN_GAP = exports.WORKFLOW_NODE_HEIGHT = exports.WORKFLOW_NODE_WIDTH = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_NODE_KINDS = exports.WORKFLOW_SKIP_REASONS = exports.CODE_CONTEXT_CONTRACT = exports.TRANSFORM_RUNNER = exports.TRANSFORM_MODES = exports.TRANSFORM_LANGUAGES = exports.SOURCE_FORMATS = exports.CATALOG_SOURCE_TYPE_KEY = exports.CONNECTOR_KINDS = void 0;
|
|
12
|
+
exports.REDACTED_SECRET = exports.CATALOG_PIPELINE_STORE = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_CALL_CONTRACT = exports.WORKFLOW_EXECUTION_MODES = exports.WORKFLOW_STATUSES = exports.WORKFLOW_BRANCH_LABELS = exports.NODE_KIND_IS_REUSABLE = exports.REUSABLE_NODE_KINDS = exports.WORKFLOW_AGGREGATE_DEFAULT_SEPARATOR = exports.WORKFLOW_AGGREGATE_MAX_SEPARATOR = exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING = exports.WORKFLOW_AGGREGATE_JOIN_MAX_LENGTH = exports.WORKFLOW_AGGREGATE_GROUPS_CEILING = exports.WORKFLOW_AGGREGATE_MAX_GROUPS = exports.WORKFLOW_AGGREGATE_MAX_AGGREGATES = exports.WORKFLOW_AGGREGATE_MAX_GROUP_BY = exports.WORKFLOW_AGGREGATE_FUNCTIONS = exports.WORKFLOW_RENAME_MAX_COLUMNS = exports.WORKFLOW_RENAME_UNNAMED = exports.WORKFLOW_FILTER_MAX_VALUES = exports.WORKFLOW_FILTER_MAX_DEPTH = exports.WORKFLOW_FILTER_COLUMN_PATTERN = exports.WORKFLOW_FILTER_OPERATORS = exports.WORKFLOW_FILTER_PREDICATE_KINDS = exports.WORKFLOW_PREDICATE_KINDS = exports.WORKFLOW_CALL_MODES = exports.WORKFLOW_ROW_GAP = exports.WORKFLOW_COLUMN_GAP = exports.WORKFLOW_NODE_HEIGHT = exports.WORKFLOW_NODE_WIDTH = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_NODE_KINDS = exports.WORKFLOW_SKIP_REASONS = exports.CODE_CONTEXT_CONTRACT = exports.TRANSFORM_RUNNER = exports.TRANSFORM_MODES = exports.TRANSFORM_LANGUAGES = exports.SOURCE_FORMATS = exports.CATALOG_SOURCE_TYPE_KEY = exports.CONNECTOR_KINDS = void 0;
|
|
13
13
|
exports.isConnectorKind = isConnectorKind;
|
|
14
14
|
exports.unreachableConnectorKind = unreachableConnectorKind;
|
|
15
15
|
exports.workflowSourceObjectType = workflowSourceObjectType;
|
|
@@ -44,6 +44,16 @@ exports.unreachableRenameUnnamed = unreachableRenameUnnamed;
|
|
|
44
44
|
exports.workflowRenameUnnamed = workflowRenameUnnamed;
|
|
45
45
|
exports.renameColumnRefusals = renameColumnRefusals;
|
|
46
46
|
exports.isWorkflowRenameColumns = isWorkflowRenameColumns;
|
|
47
|
+
exports.isWorkflowAggregateFunction = isWorkflowAggregateFunction;
|
|
48
|
+
exports.unreachableAggregateFunction = unreachableAggregateFunction;
|
|
49
|
+
exports.workflowAggregateMaxGroups = workflowAggregateMaxGroups;
|
|
50
|
+
exports.workflowAggregateSeparator = workflowAggregateSeparator;
|
|
51
|
+
exports.workflowAggregateJoinMaxLength = workflowAggregateJoinMaxLength;
|
|
52
|
+
exports.workflowAggregateNeedsColumn = workflowAggregateNeedsColumn;
|
|
53
|
+
exports.workflowAggregateColumns = workflowAggregateColumns;
|
|
54
|
+
exports.workflowAggregateOutputColumns = workflowAggregateOutputColumns;
|
|
55
|
+
exports.aggregateRefusals = aggregateRefusals;
|
|
56
|
+
exports.isWorkflowAggregates = isWorkflowAggregates;
|
|
47
57
|
exports.isReusableNodeKind = isReusableNodeKind;
|
|
48
58
|
exports.nodeKindIsReusable = nodeKindIsReusable;
|
|
49
59
|
exports.unreachableReusableNodeKind = unreachableReusableNodeKind;
|
|
@@ -532,6 +542,8 @@ exports.WORKFLOW_NODE_KINDS = [
|
|
|
532
542
|
'filter',
|
|
533
543
|
/** Renames columns, declaratively. See {@link WorkflowRenameNode}. */
|
|
534
544
|
'rename',
|
|
545
|
+
/** Groups records and summarises each group. See {@link WorkflowAggregateNode}. */
|
|
546
|
+
'aggregate',
|
|
535
547
|
];
|
|
536
548
|
/** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
|
|
537
549
|
function isWorkflowNodeKind(value) {
|
|
@@ -1306,6 +1318,403 @@ function isWorkflowRenameColumns(value) {
|
|
|
1306
1318
|
}
|
|
1307
1319
|
return false;
|
|
1308
1320
|
}
|
|
1321
|
+
/* --- aggregate ----------------------------------------------------------- */
|
|
1322
|
+
/**
|
|
1323
|
+
* The aggregate functions this node computes, and the rule that closes the list.
|
|
1324
|
+
*
|
|
1325
|
+
* A closed list with an exhaustiveness guard, for the reason every other list in
|
|
1326
|
+
* this file is one. What is different here is that the list has a **stated
|
|
1327
|
+
* admission rule**, because "we will keep it narrow" is a promise nobody can
|
|
1328
|
+
* check and a rule is:
|
|
1329
|
+
*
|
|
1330
|
+
* > A function is in if it can be computed from a **fixed-size accumulator**,
|
|
1331
|
+
* > and if its answer does not depend on a decision the config would have to
|
|
1332
|
+
* > carry.
|
|
1333
|
+
*
|
|
1334
|
+
* The first half is the node's whole reason to exist. A hash aggregate is cheap
|
|
1335
|
+
* because it holds one entry per group; an accumulator whose size grows with the
|
|
1336
|
+
* number of *rows* in a group puts the rows back in memory and gives up the
|
|
1337
|
+
* property. The second half is what keeps the config from becoming a small
|
|
1338
|
+
* language: a function that needs an extra field to say what it means is a
|
|
1339
|
+
* function whose meaning was not decided.
|
|
1340
|
+
*
|
|
1341
|
+
* What the rule excludes, so the omissions are on the record rather than
|
|
1342
|
+
* implied:
|
|
1343
|
+
*
|
|
1344
|
+
* - **`countDistinct`** — the sharpest one. It needs a set of the distinct
|
|
1345
|
+
* values *per group per column*, so its accumulator is O(distinct values) and
|
|
1346
|
+
* a high-cardinality column inside a group holds the load. It is the exact
|
|
1347
|
+
* thing this node was built to stop doing, wearing an aggregate's name. A
|
|
1348
|
+
* sketch (HyperLogLog) is fixed-size and is a different function — an
|
|
1349
|
+
* estimate — which is not something to ship under the word `distinct`.
|
|
1350
|
+
* - **`median`, percentiles, `stddev` of a stream** — all need the values, or a
|
|
1351
|
+
* digest that is an approximation with an error bound the config would have to
|
|
1352
|
+
* carry.
|
|
1353
|
+
* - **`first` / `last`** — fixed-size, and excluded on the other half of the
|
|
1354
|
+
* rule: they mean "in input order", and this node's input order is a
|
|
1355
|
+
* `SELECT` without an `ORDER BY`. An aggregate that returns a different value
|
|
1356
|
+
* on a rerun is a load nobody can diff. `min`/`max` are the order-independent
|
|
1357
|
+
* version and are what somebody reaching for `first` usually wants.
|
|
1358
|
+
* - **Conditional aggregation — `MAX(CASE WHEN … THEN … END)`** — deliberately
|
|
1359
|
+
* out of scope, and it is the one omission a reader of flip's `wo` query will
|
|
1360
|
+
* go looking for, because that query has a three-branch status ladder in it.
|
|
1361
|
+
* Admitting it means admitting a predicate *inside* an aggregate, which is a
|
|
1362
|
+
* second expression language nested in the first, evaluated per row per
|
|
1363
|
+
* aggregate. That is transform territory and the generic
|
|
1364
|
+
* {@link WorkflowTransformNode} still exists. What the ladder actually is, is
|
|
1365
|
+
* a priority ordering over a closed set of codes, and it composes: map the
|
|
1366
|
+
* code to a rank in a transform above this node, `min` the rank, map it back
|
|
1367
|
+
* below. Two cheap per-record steps instead of a language.
|
|
1368
|
+
* - **`avg` is in**, and it is in *because* of the rule rather than despite it.
|
|
1369
|
+
* It is `sum` and `count` in one accumulator, both of which are already here,
|
|
1370
|
+
* and SQL has exactly one answer for it. Excluding it would have made the list
|
|
1371
|
+
* an arbitrary set that happened to cover one query, which is the thing the
|
|
1372
|
+
* rule is for.
|
|
1373
|
+
*/
|
|
1374
|
+
exports.WORKFLOW_AGGREGATE_FUNCTIONS = [
|
|
1375
|
+
/** Rows in the group, or non-null values of a column. `COUNT(*)` / `COUNT(c)`. */
|
|
1376
|
+
'count',
|
|
1377
|
+
/** The total. See `addToSum` for the summation error and what is done about it. */
|
|
1378
|
+
'sum',
|
|
1379
|
+
/** The mean of the non-null values. `sum` and `count` in one accumulator. */
|
|
1380
|
+
'avg',
|
|
1381
|
+
/** The least value. See `compareValues` for the order, which is not MySQL's. */
|
|
1382
|
+
'min',
|
|
1383
|
+
/** The greatest value. Same comparison, same docblock. */
|
|
1384
|
+
'max',
|
|
1385
|
+
/** The values, concatenated. `GROUP_CONCAT`, with a bound that refuses. */
|
|
1386
|
+
'join',
|
|
1387
|
+
];
|
|
1388
|
+
/** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
|
|
1389
|
+
function isWorkflowAggregateFunction(value) {
|
|
1390
|
+
return exports.WORKFLOW_AGGREGATE_FUNCTIONS.some((fn) => fn === value);
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* {@link unreachableNodeKind}, one level down, and for the identical reason.
|
|
1394
|
+
*
|
|
1395
|
+
* Every branch over {@link WorkflowAggregateFunction} ends here, so a seventh
|
|
1396
|
+
* function added to the list without an accumulator, a finisher, a canonical
|
|
1397
|
+
* form and a sentence is a type error naming the file rather than a node that
|
|
1398
|
+
* saves, draws and then computes nothing. It throws as well, because a function
|
|
1399
|
+
* name arrives as JSON out of a column and a build older than the data is a
|
|
1400
|
+
* thing that happens.
|
|
1401
|
+
*/
|
|
1402
|
+
function unreachableAggregateFunction(fn, where) {
|
|
1403
|
+
throw new Error(`${where} has no rule for the aggregate function ${JSON.stringify(fn)}. It was added to WORKFLOW_AGGREGATE_FUNCTIONS without teaching this code how to compute it, and guessing would commit a number nobody derived.`);
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* How many columns one node may group on.
|
|
1407
|
+
*
|
|
1408
|
+
* The same argument {@link WORKFLOW_RENAME_MAX_COLUMNS} makes, plus one specific
|
|
1409
|
+
* to this node: every extra group-by column can only ever *increase* the number
|
|
1410
|
+
* of groups, so a long list is the shape a high-cardinality grouping arrives in.
|
|
1411
|
+
* flip's real derivation groups on two.
|
|
1412
|
+
*/
|
|
1413
|
+
exports.WORKFLOW_AGGREGATE_MAX_GROUP_BY = 16;
|
|
1414
|
+
/**
|
|
1415
|
+
* How many aggregates one node may compute.
|
|
1416
|
+
*
|
|
1417
|
+
* flip's `wo` derivation has 49, so the bound has to be comfortably above that
|
|
1418
|
+
* or the node does not do the job it was written for. Past a few hundred the
|
|
1419
|
+
* thing being expressed is a table definition rather than a summary, and the
|
|
1420
|
+
* cost is real: every aggregate is an accumulator held **per group**, so this
|
|
1421
|
+
* number multiplies {@link WORKFLOW_AGGREGATE_MAX_GROUPS} in the heap.
|
|
1422
|
+
*/
|
|
1423
|
+
exports.WORKFLOW_AGGREGATE_MAX_AGGREGATES = 256;
|
|
1424
|
+
/**
|
|
1425
|
+
* The default ceiling on distinct groups, and the loud refusal that goes with
|
|
1426
|
+
* it.
|
|
1427
|
+
*
|
|
1428
|
+
* A hash aggregate is cheap **only while the groups are far fewer than the
|
|
1429
|
+
* rows**. Group on a near-unique column and it holds one accumulator row per
|
|
1430
|
+
* input row, which is the whole-batch behaviour this node replaces, arrived at
|
|
1431
|
+
* by a different route and with nothing on the canvas to point at. So the
|
|
1432
|
+
* ceiling exists, it is crossed loudly, and the message names the columns being
|
|
1433
|
+
* grouped on — because a bound that is merely reported is a bound that is
|
|
1434
|
+
* discovered by the machine running out of memory.
|
|
1435
|
+
*
|
|
1436
|
+
* A million is chosen against the measurement rather than as a round number:
|
|
1437
|
+
* flip's derivation holds 16,119, so the default is 62× the real case and no
|
|
1438
|
+
* author of a sane grouping ever meets it. What it catches is `groupBy:
|
|
1439
|
+
* ['combinedId']` on a 44,720-row type — a grouping that is *legal*, produces
|
|
1440
|
+
* one group per row, and is somebody having picked the wrong column.
|
|
1441
|
+
*
|
|
1442
|
+
* The number is a proxy and it is worth saying which part it cannot see: what a
|
|
1443
|
+
* group costs in bytes depends on how many aggregates the node has and how long
|
|
1444
|
+
* a `join` grows. The first is bounded by
|
|
1445
|
+
* {@link WORKFLOW_AGGREGATE_MAX_AGGREGATES}; the second has its own bound on the
|
|
1446
|
+
* aggregate, because it is the one accumulator whose size is not fixed by the
|
|
1447
|
+
* group count.
|
|
1448
|
+
*/
|
|
1449
|
+
exports.WORKFLOW_AGGREGATE_MAX_GROUPS = 1_000_000;
|
|
1450
|
+
/**
|
|
1451
|
+
* The highest ceiling an author may ask for.
|
|
1452
|
+
*
|
|
1453
|
+
* Configurable because "how many groups is too many" genuinely depends on the
|
|
1454
|
+
* machine and on how wide the node is, and a hard-coded limit would make the
|
|
1455
|
+
* node unusable for the one legitimate large grouping. Bounded because past this
|
|
1456
|
+
* the answer is not a bigger number — it is that the grouping belongs in the
|
|
1457
|
+
* source query, where the database already has spill-to-disk and this process
|
|
1458
|
+
* does not.
|
|
1459
|
+
*/
|
|
1460
|
+
exports.WORKFLOW_AGGREGATE_GROUPS_CEILING = 20_000_000;
|
|
1461
|
+
/**
|
|
1462
|
+
* The default bound on one joined value, in characters.
|
|
1463
|
+
*
|
|
1464
|
+
* 65,535 because that is what a MySQL `TEXT` column holds, and a value the
|
|
1465
|
+
* target column cannot store is the same defect one layer further down. See
|
|
1466
|
+
* `appendJoin` for the full argument, including the five groups per column that
|
|
1467
|
+
* are silently truncated in production today under a limit of 1,024.
|
|
1468
|
+
*/
|
|
1469
|
+
exports.WORKFLOW_AGGREGATE_JOIN_MAX_LENGTH = 65_535;
|
|
1470
|
+
/** The highest an author may raise a `join` bound to. One `MEDIUMTEXT`. */
|
|
1471
|
+
exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING = 16_777_215;
|
|
1472
|
+
/** The longest separator a `join` may use. Long enough for `" | "`, short enough not to be data. */
|
|
1473
|
+
exports.WORKFLOW_AGGREGATE_MAX_SEPARATOR = 16;
|
|
1474
|
+
/** The separator a `join` uses when the aggregate does not name one. */
|
|
1475
|
+
exports.WORKFLOW_AGGREGATE_DEFAULT_SEPARATOR = ', ';
|
|
1476
|
+
/** {@link WorkflowAggregateNode.maxGroups}, resolved. One reader of the default. */
|
|
1477
|
+
function workflowAggregateMaxGroups(node) {
|
|
1478
|
+
const asked = node.maxGroups;
|
|
1479
|
+
if (typeof asked !== 'number' || !Number.isInteger(asked) || asked < 1) {
|
|
1480
|
+
return exports.WORKFLOW_AGGREGATE_MAX_GROUPS;
|
|
1481
|
+
}
|
|
1482
|
+
return Math.min(asked, exports.WORKFLOW_AGGREGATE_GROUPS_CEILING);
|
|
1483
|
+
}
|
|
1484
|
+
/** {@link WorkflowAggregate.separator}, resolved. One reader of the default. */
|
|
1485
|
+
function workflowAggregateSeparator(aggregate) {
|
|
1486
|
+
const asked = aggregate.separator;
|
|
1487
|
+
return typeof asked === 'string' ? asked : exports.WORKFLOW_AGGREGATE_DEFAULT_SEPARATOR;
|
|
1488
|
+
}
|
|
1489
|
+
/** {@link WorkflowAggregate.maxLength}, resolved. One reader of the default. */
|
|
1490
|
+
function workflowAggregateJoinMaxLength(aggregate) {
|
|
1491
|
+
const asked = aggregate.maxLength;
|
|
1492
|
+
if (typeof asked !== 'number' || !Number.isInteger(asked) || asked < 1) {
|
|
1493
|
+
return exports.WORKFLOW_AGGREGATE_JOIN_MAX_LENGTH;
|
|
1494
|
+
}
|
|
1495
|
+
return Math.min(asked, exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING);
|
|
1496
|
+
}
|
|
1497
|
+
/** Whether this function reads a column. Only `count` may go without one. */
|
|
1498
|
+
function workflowAggregateNeedsColumn(fn) {
|
|
1499
|
+
return fn !== 'count';
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* The columns an aggregate node **reads**: its group keys and its inputs.
|
|
1503
|
+
*
|
|
1504
|
+
* What `checkColumnsProduced` tests against what the graph can prove is there,
|
|
1505
|
+
* and what the run log reports as never-seen. Deduplicated and in a stable
|
|
1506
|
+
* order, because it goes into a sentence.
|
|
1507
|
+
*/
|
|
1508
|
+
function workflowAggregateColumns(node) {
|
|
1509
|
+
const columns = new Set();
|
|
1510
|
+
for (const column of node.groupBy ?? []) {
|
|
1511
|
+
if (typeof column === 'string' && column.length > 0)
|
|
1512
|
+
columns.add(column);
|
|
1513
|
+
}
|
|
1514
|
+
for (const aggregate of node.aggregates ?? []) {
|
|
1515
|
+
const column = aggregate?.column;
|
|
1516
|
+
if (typeof column === 'string' && column.length > 0)
|
|
1517
|
+
columns.add(column);
|
|
1518
|
+
}
|
|
1519
|
+
return [...columns];
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* The columns an aggregate node **produces**, which is all of them and nothing
|
|
1523
|
+
* else.
|
|
1524
|
+
*
|
|
1525
|
+
* Closed by the config, and closed *exactly* rather than as an upper bound —
|
|
1526
|
+
* every emitted record carries every one of these keys, whatever was upstream
|
|
1527
|
+
* and whatever the values turned out to be. That is a stronger claim than the
|
|
1528
|
+
* one `rename` introduced, and it is stronger for a structural reason: a rename
|
|
1529
|
+
* only produces a target where the input actually held the source column,
|
|
1530
|
+
* whereas an aggregate writes a group's answer whether or not anything in the
|
|
1531
|
+
* group had a value for it.
|
|
1532
|
+
*
|
|
1533
|
+
* The one thing it does not claim is that the values are useful. An aggregate
|
|
1534
|
+
* over a column that no record carried produces the column, holding `null`.
|
|
1535
|
+
*/
|
|
1536
|
+
function workflowAggregateOutputColumns(node) {
|
|
1537
|
+
const columns = [];
|
|
1538
|
+
for (const column of node.groupBy ?? []) {
|
|
1539
|
+
if (typeof column === 'string' && column.length > 0 && !columns.includes(column)) {
|
|
1540
|
+
columns.push(column);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
for (const aggregate of node.aggregates ?? []) {
|
|
1544
|
+
const as = aggregate?.as;
|
|
1545
|
+
if (typeof as === 'string' && as.length > 0 && !columns.includes(as))
|
|
1546
|
+
columns.push(as);
|
|
1547
|
+
}
|
|
1548
|
+
return columns;
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Every reason an aggregate cannot be stored, as sentences, or empty.
|
|
1552
|
+
*
|
|
1553
|
+
* One function, called by {@link validateWorkflow}, by the HTTP boundary, by the
|
|
1554
|
+
* canvas and by the fold itself, for the reason {@link renameColumnRefusals} is
|
|
1555
|
+
* shared: a screen with its own copy of the identifier pattern is a screen that
|
|
1556
|
+
* accepts something the server refuses, halfway through a save.
|
|
1557
|
+
*
|
|
1558
|
+
* All of them rather than the first, exactly as
|
|
1559
|
+
* {@link refuseUnpublishablePropertyNames} argues: a node with forty aggregates
|
|
1560
|
+
* typed in one sitting is usually wrong about several in the same way.
|
|
1561
|
+
*/
|
|
1562
|
+
function aggregateRefusals(node) {
|
|
1563
|
+
const groupBy = Array.isArray(node.groupBy) ? node.groupBy : [];
|
|
1564
|
+
const aggregates = Array.isArray(node.aggregates) ? node.aggregates : [];
|
|
1565
|
+
const named = new Set();
|
|
1566
|
+
const refusals = [
|
|
1567
|
+
...groupByRefusals(groupBy, named),
|
|
1568
|
+
...aggregatesRefusals(aggregates, named),
|
|
1569
|
+
...ceilingRefusals(node.maxGroups),
|
|
1570
|
+
];
|
|
1571
|
+
return refusals;
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* What the grouping half can be wrong about, and the names it accepted.
|
|
1575
|
+
*
|
|
1576
|
+
* The accepted set is threaded out rather than recomputed, because the sharpest
|
|
1577
|
+
* refusal in the file needs both halves: an aggregate writing into a column the
|
|
1578
|
+
* node also groups on is one name holding two values, and only a reader that has
|
|
1579
|
+
* seen the group-by list can see it.
|
|
1580
|
+
*/
|
|
1581
|
+
function groupByRefusals(groupBy, accepted) {
|
|
1582
|
+
const refusals = [];
|
|
1583
|
+
if (groupBy.length === 0) {
|
|
1584
|
+
refusals.push('It groups on nothing. With no group-by columns an aggregate returns exactly one row whether it read a billion records or none, so a run that summarised everything and a run that read an empty source commit the same thing. If a grand total is wanted, add a constant column in a transform above this node and group on it, so the one row is visible in the graph.');
|
|
1585
|
+
}
|
|
1586
|
+
if (groupBy.length > exports.WORKFLOW_AGGREGATE_MAX_GROUP_BY) {
|
|
1587
|
+
refusals.push(`It groups on ${groupBy.length} columns, and at most ${exports.WORKFLOW_AGGREGATE_MAX_GROUP_BY} may be named. Every extra column can only increase the number of groups, and a long list is the shape a grouping that holds the whole load arrives in.`);
|
|
1588
|
+
}
|
|
1589
|
+
for (const column of groupBy) {
|
|
1590
|
+
if (typeof column !== 'string' || !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(column)) {
|
|
1591
|
+
refusals.push(`It groups on ${JSON.stringify(column)}, which is not a name a column can have: letters, digits and underscore, starting with a letter or an underscore. A group key comes out under the name it went in under, so a name this service cannot carry downstream is one that loads NULL into every row and reports success. A source whose own headers are spelled like ${JSON.stringify('Work Order Id')} is what a rename node above this one is for.`);
|
|
1592
|
+
continue;
|
|
1593
|
+
}
|
|
1594
|
+
if (accepted.has(column)) {
|
|
1595
|
+
refusals.push(`It groups on ${JSON.stringify(column)} twice. The second one changes no group and produces no second column, so it is either a typo or a column somebody meant to name instead.`);
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1598
|
+
accepted.add(column);
|
|
1599
|
+
}
|
|
1600
|
+
return refusals;
|
|
1601
|
+
}
|
|
1602
|
+
/** What the computing half can be wrong about, given the group keys already accepted. */
|
|
1603
|
+
function aggregatesRefusals(aggregates, groupedOn) {
|
|
1604
|
+
const refusals = [];
|
|
1605
|
+
if (aggregates.length === 0) {
|
|
1606
|
+
refusals.push('It computes nothing. An aggregate with no functions is a node that emits the distinct combinations of its group-by columns and drops every other column of every row — which is a real operation and a completely different one. Reaching it by deleting the last row of a form is how a published type loses forty columns.');
|
|
1607
|
+
}
|
|
1608
|
+
if (aggregates.length > exports.WORKFLOW_AGGREGATE_MAX_AGGREGATES) {
|
|
1609
|
+
refusals.push(`It computes ${aggregates.length} aggregates, and at most ${exports.WORKFLOW_AGGREGATE_MAX_AGGREGATES} may be named in one node. Every one of them is an accumulator held per group, so this number multiplies the group ceiling in memory.`);
|
|
1610
|
+
}
|
|
1611
|
+
const names = new Map();
|
|
1612
|
+
for (const entry of aggregates) {
|
|
1613
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
1614
|
+
refusals.push(`One aggregate is ${JSON.stringify(entry)} rather than a function, a column and a name.`);
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
refusals.push(...oneAggregateRefusals(entry, groupedOn, names));
|
|
1618
|
+
}
|
|
1619
|
+
for (const [as, count] of names) {
|
|
1620
|
+
if (count < 2)
|
|
1621
|
+
continue;
|
|
1622
|
+
refusals.push(`${count} aggregates are written out as ${JSON.stringify(as)}. Two columns cannot share one name, and picking a winner would be a rule about which of somebody's numbers survives.`);
|
|
1623
|
+
}
|
|
1624
|
+
return refusals;
|
|
1625
|
+
}
|
|
1626
|
+
/** One entry of the list: its name, its function, its column, and its two optional fields. */
|
|
1627
|
+
function oneAggregateRefusals(entry, groupedOn, names) {
|
|
1628
|
+
const refusals = [];
|
|
1629
|
+
const as = Reflect.get(entry, 'as');
|
|
1630
|
+
const fn = Reflect.get(entry, 'fn');
|
|
1631
|
+
const column = Reflect.get(entry, 'column');
|
|
1632
|
+
const label = typeof as === 'string' && as.length > 0 ? JSON.stringify(as) : 'One aggregate';
|
|
1633
|
+
if (typeof as !== 'string' || !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(as)) {
|
|
1634
|
+
refusals.push(`${label} is written out as ${JSON.stringify(as)}, which is not a name a column can have: letters, digits and underscore, starting with a letter or an underscore. A load looks every field up as \`row[name]\`, so a column this service cannot name downstream is one that loads NULL into every row and reports success.`);
|
|
1635
|
+
}
|
|
1636
|
+
else {
|
|
1637
|
+
names.set(as, (names.get(as) ?? 0) + 1);
|
|
1638
|
+
if (groupedOn.has(as)) {
|
|
1639
|
+
refusals.push(`${label} writes into a column this node also groups on. One name cannot hold both the group key and a summary of the group, and picking a winner would be a rule about which of somebody's data survives.`);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
if (!isWorkflowAggregateFunction(fn)) {
|
|
1643
|
+
refusals.push(`${label} uses the function ${JSON.stringify(fn)}, which this service cannot compute. The functions are ${exports.WORKFLOW_AGGREGATE_FUNCTIONS.join(', ')}; anything else is a transform.`);
|
|
1644
|
+
return refusals;
|
|
1645
|
+
}
|
|
1646
|
+
if (workflowAggregateNeedsColumn(fn)) {
|
|
1647
|
+
if (typeof column !== 'string' || !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(column)) {
|
|
1648
|
+
refusals.push(`${label} reads ${JSON.stringify(column)} with ${fn}, and that is not a column name: letters, digits and underscore, starting with a letter or an underscore. Only \`count\` may go without a column, where it means how many records landed in the group.`);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
else if (column !== undefined && !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(String(column))) {
|
|
1652
|
+
refusals.push(`${label} counts ${JSON.stringify(column)}, and that is not a column name. Leave the column out to count the records in the group, or name one to count its non-null values.`);
|
|
1653
|
+
}
|
|
1654
|
+
refusals.push(...joinFieldRefusals(entry, fn, label));
|
|
1655
|
+
return refusals;
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* The two fields only `join` reads.
|
|
1659
|
+
*
|
|
1660
|
+
* Refused on any other function rather than ignored, which is the rule the whole
|
|
1661
|
+
* config follows: a field that only some functions read is a field somebody sets
|
|
1662
|
+
* on the wrong one and never finds out.
|
|
1663
|
+
*/
|
|
1664
|
+
function joinFieldRefusals(entry, fn, label) {
|
|
1665
|
+
const refusals = [];
|
|
1666
|
+
const separator = Reflect.get(entry, 'separator');
|
|
1667
|
+
if (separator !== undefined) {
|
|
1668
|
+
if (fn !== 'join') {
|
|
1669
|
+
refusals.push(`${label} carries a separator and computes ${fn}, which has nothing to separate. A field that only some functions read is a field somebody will set on the wrong one and never find out.`);
|
|
1670
|
+
}
|
|
1671
|
+
else if (typeof separator !== 'string' ||
|
|
1672
|
+
separator.length > exports.WORKFLOW_AGGREGATE_MAX_SEPARATOR) {
|
|
1673
|
+
refusals.push(`${label} joins with ${JSON.stringify(separator)}, and a separator has to be text of at most ${exports.WORKFLOW_AGGREGATE_MAX_SEPARATOR} characters. Past that it is data rather than punctuation, and it is data repeated once per record.`);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
const maxLength = Reflect.get(entry, 'maxLength');
|
|
1677
|
+
if (maxLength === undefined)
|
|
1678
|
+
return refusals;
|
|
1679
|
+
if (fn !== 'join') {
|
|
1680
|
+
refusals.push(`${label} carries a maximum length and computes ${fn}, which produces no text to bound.`);
|
|
1681
|
+
return refusals;
|
|
1682
|
+
}
|
|
1683
|
+
if (typeof maxLength !== 'number' ||
|
|
1684
|
+
!Number.isInteger(maxLength) ||
|
|
1685
|
+
maxLength < 1 ||
|
|
1686
|
+
maxLength > exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING) {
|
|
1687
|
+
refusals.push(`${label} bounds its joined value at ${JSON.stringify(maxLength)}, and the bound has to be a whole number of characters between 1 and ${exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING}. Past that the value cannot be stored in the column it is going into, which is the same failure one layer further down.`);
|
|
1688
|
+
}
|
|
1689
|
+
return refusals;
|
|
1690
|
+
}
|
|
1691
|
+
/** The group ceiling, when the node set one. See {@link WORKFLOW_AGGREGATE_MAX_GROUPS}. */
|
|
1692
|
+
function ceilingRefusals(maxGroups) {
|
|
1693
|
+
if (maxGroups === undefined)
|
|
1694
|
+
return [];
|
|
1695
|
+
if (typeof maxGroups === 'number' &&
|
|
1696
|
+
Number.isInteger(maxGroups) &&
|
|
1697
|
+
maxGroups >= 1 &&
|
|
1698
|
+
maxGroups <= exports.WORKFLOW_AGGREGATE_GROUPS_CEILING) {
|
|
1699
|
+
return [];
|
|
1700
|
+
}
|
|
1701
|
+
return [
|
|
1702
|
+
`It caps itself at ${JSON.stringify(maxGroups)} groups, and the cap has to be a whole number between 1 and ${exports.WORKFLOW_AGGREGATE_GROUPS_CEILING}. Past that the answer is not a bigger number — it is that the grouping belongs in the source query, where the database has spill-to-disk and this process does not.`,
|
|
1703
|
+
];
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* Whether a stored aggregate list is one this build can run.
|
|
1707
|
+
*
|
|
1708
|
+
* Refused rather than repaired, the stance {@link isWorkflowRenameColumns} takes
|
|
1709
|
+
* and for the same reason one step further along: an aggregate list read back
|
|
1710
|
+
* with one entry silently dropped is a graph that commits a column of nulls
|
|
1711
|
+
* under a name somebody put in an object type on purpose.
|
|
1712
|
+
*/
|
|
1713
|
+
function isWorkflowAggregates(value) {
|
|
1714
|
+
if (!Array.isArray(value))
|
|
1715
|
+
return false;
|
|
1716
|
+
return aggregateRefusals({ groupBy: ['x'], aggregates: value }).length === 0;
|
|
1717
|
+
}
|
|
1309
1718
|
/* --- reusable nodes ------------------------------------------------------ */
|
|
1310
1719
|
/**
|
|
1311
1720
|
* The node kinds that can be saved once and used in several graphs.
|
|
@@ -1364,6 +1773,13 @@ function isReusableNodeKind(value) {
|
|
|
1364
1773
|
* *about* one drop of one file. `Mgmt Cd → mgmtCd` saved under a name and
|
|
1365
1774
|
* dropped into a graph reading a different system renames nothing at all, and
|
|
1366
1775
|
* the symptom is a column of NULLs rather than a failure.
|
|
1776
|
+
* - `aggregate` — the same again, and it fails in both directions at once. Its
|
|
1777
|
+
* group-by columns and its inputs name one type's columns, so a shared one
|
|
1778
|
+
* groups a graph it was not written for on a column that is not there — which
|
|
1779
|
+
* collapses every record into one null-keyed group rather than erroring. And
|
|
1780
|
+
* its *output* column set is the thing downstream nodes are validated against,
|
|
1781
|
+
* so a shared node editable from elsewhere would silently change what another
|
|
1782
|
+
* graph's sink is allowed to write.
|
|
1367
1783
|
*/
|
|
1368
1784
|
exports.NODE_KIND_IS_REUSABLE = {
|
|
1369
1785
|
source: true,
|
|
@@ -1373,6 +1789,7 @@ exports.NODE_KIND_IS_REUSABLE = {
|
|
|
1373
1789
|
if: false,
|
|
1374
1790
|
filter: false,
|
|
1375
1791
|
rename: false,
|
|
1792
|
+
aggregate: false,
|
|
1376
1793
|
};
|
|
1377
1794
|
/** Whether this kind can be saved as a reusable node. Reads {@link NODE_KIND_IS_REUSABLE}. */
|
|
1378
1795
|
function nodeKindIsReusable(kind) {
|
|
@@ -1794,6 +2211,19 @@ exports.WORKFLOW_ISSUE_CODES = [
|
|
|
1794
2211
|
* {@link renameColumnRefusals} for the sentences.
|
|
1795
2212
|
*/
|
|
1796
2213
|
'rename-invalid',
|
|
2214
|
+
/**
|
|
2215
|
+
* An aggregate this service will not store: grouping on nothing or on a name
|
|
2216
|
+
* a column cannot have, computing nothing, two aggregates sharing an output
|
|
2217
|
+
* name, a function this build cannot compute, a separator on something that
|
|
2218
|
+
* does not join.
|
|
2219
|
+
*
|
|
2220
|
+
* Every one of those is decidable from the node alone, and every one of them
|
|
2221
|
+
* is silent if it is let through — the two worst being an empty `groupBy`,
|
|
2222
|
+
* which commits exactly one row whether the source held everything or nothing,
|
|
2223
|
+
* and an empty `aggregates`, which drops every column the node does not group
|
|
2224
|
+
* on. See {@link aggregateRefusals} for the sentences.
|
|
2225
|
+
*/
|
|
2226
|
+
'aggregate-invalid',
|
|
1797
2227
|
/**
|
|
1798
2228
|
* A node naming a column that nothing upstream can produce.
|
|
1799
2229
|
*
|
|
@@ -2263,8 +2693,31 @@ function nodeIsUnconfigured(node) {
|
|
|
2263
2693
|
return filterIsUnconfigured(node);
|
|
2264
2694
|
if (node.kind === 'rename')
|
|
2265
2695
|
return renameIsUnconfigured(node);
|
|
2696
|
+
if (node.kind === 'aggregate')
|
|
2697
|
+
return aggregateIsUnconfigured(node);
|
|
2266
2698
|
return undefined;
|
|
2267
2699
|
}
|
|
2700
|
+
/**
|
|
2701
|
+
* An aggregate this service will not store.
|
|
2702
|
+
*
|
|
2703
|
+
* The refusals come from {@link aggregateRefusals} rather than being restated
|
|
2704
|
+
* here, so the canvas, the HTTP boundary, the validator and the fold itself say
|
|
2705
|
+
* the same sentence about the same node. Every one of them is a *silent* failure
|
|
2706
|
+
* if it were let through, and the two most dangerous point in opposite
|
|
2707
|
+
* directions: no group-by columns commits exactly one row whatever the source
|
|
2708
|
+
* held, and no aggregates commits the distinct group keys with every other
|
|
2709
|
+
* column of every row gone.
|
|
2710
|
+
*/
|
|
2711
|
+
function aggregateIsUnconfigured(node) {
|
|
2712
|
+
const refusals = aggregateRefusals(node);
|
|
2713
|
+
if (refusals.length === 0)
|
|
2714
|
+
return undefined;
|
|
2715
|
+
return {
|
|
2716
|
+
code: 'aggregate-invalid',
|
|
2717
|
+
nodeIds: [node.id],
|
|
2718
|
+
message: `Aggregate "${node.name}" (${node.id}) cannot be stored as it is. ${refusals.join(' ')}`,
|
|
2719
|
+
};
|
|
2720
|
+
}
|
|
2268
2721
|
/**
|
|
2269
2722
|
* A rename whose map this service will not store.
|
|
2270
2723
|
*
|
|
@@ -2875,6 +3328,8 @@ function canonicalNode(node) {
|
|
|
2875
3328
|
}
|
|
2876
3329
|
if (node.kind === 'rename')
|
|
2877
3330
|
return canonicalRename(node);
|
|
3331
|
+
if (node.kind === 'aggregate')
|
|
3332
|
+
return canonicalAggregate(node);
|
|
2878
3333
|
if (node.kind === 'sink') {
|
|
2879
3334
|
return JSON.stringify([
|
|
2880
3335
|
node.id,
|
|
@@ -2908,6 +3363,47 @@ function canonicalRename(node) {
|
|
|
2908
3363
|
...(workflowRenameUnnamed(node) === 'drop' ? ['drop'] : []),
|
|
2909
3364
|
]);
|
|
2910
3365
|
}
|
|
3366
|
+
/**
|
|
3367
|
+
* An aggregate, canonicalised.
|
|
3368
|
+
*
|
|
3369
|
+
* **`groupBy` is sorted and the aggregates are sorted by output name**, because
|
|
3370
|
+
* neither order changes what the node computes: a set of group-by columns
|
|
3371
|
+
* defines the same groups whatever order they are listed in, and two aggregates
|
|
3372
|
+
* are independent of each other. Order *is* what the output columns come out in,
|
|
3373
|
+
* and a record's key order is not something anything downstream reads — the sink
|
|
3374
|
+
* looks every property up by name. So reordering rows in the inspector is not an
|
|
3375
|
+
* edit and does not bump a version.
|
|
3376
|
+
*
|
|
3377
|
+
* Each aggregate's optional fields are appended **only when set**, exactly as
|
|
3378
|
+
* `edge.branch` and a rename's `unnamed` are, so the default has one spelling.
|
|
3379
|
+
* The point of that rule is the same one every time: adding a node kind to this
|
|
3380
|
+
* file must not renumber a graph that did not change. Here it holds for a
|
|
3381
|
+
* stronger reason as well — no stored graph contains an aggregate node at all,
|
|
3382
|
+
* because this release is the first one in which such a node can be saved, so
|
|
3383
|
+
* every existing graph's canonical string is byte-identical to what it was.
|
|
3384
|
+
*/
|
|
3385
|
+
function canonicalAggregate(node) {
|
|
3386
|
+
const groupBy = [...(node.groupBy ?? [])].sort();
|
|
3387
|
+
const aggregates = [...(node.aggregates ?? [])]
|
|
3388
|
+
.sort((left, right) => (left.as < right.as ? -1 : left.as > right.as ? 1 : 0))
|
|
3389
|
+
.map((each) => [
|
|
3390
|
+
each.as,
|
|
3391
|
+
each.fn,
|
|
3392
|
+
each.column ?? '',
|
|
3393
|
+
...(each.separator === undefined ? [] : [each.separator]),
|
|
3394
|
+
...(each.maxLength === undefined ? [] : [each.maxLength]),
|
|
3395
|
+
]);
|
|
3396
|
+
return JSON.stringify([
|
|
3397
|
+
node.id,
|
|
3398
|
+
node.kind,
|
|
3399
|
+
groupBy,
|
|
3400
|
+
aggregates,
|
|
3401
|
+
// In the fingerprint because it decides whether a run finishes or refuses,
|
|
3402
|
+
// which is a difference between two runs of "the same" graph worth being
|
|
3403
|
+
// able to point at. Appended only when set, so the default has one spelling.
|
|
3404
|
+
...(node.maxGroups === undefined ? [] : [node.maxGroups]),
|
|
3405
|
+
]);
|
|
3406
|
+
}
|
|
2911
3407
|
/**
|
|
2912
3408
|
* The reusable reference, as zero, one or two trailing hash components.
|
|
2913
3409
|
*
|
|
@@ -3157,6 +3653,13 @@ function producedColumns(node, upstream, knowledge) {
|
|
|
3157
3653
|
renamed.add(node.columns?.[column] ?? column);
|
|
3158
3654
|
return renamed;
|
|
3159
3655
|
}
|
|
3656
|
+
// The one kind whose output set is *exact* rather than an upper bound, and it
|
|
3657
|
+
// is exact without looking upstream at all: an aggregate emits its group-by
|
|
3658
|
+
// columns and its named aggregates on every record it produces, whatever it
|
|
3659
|
+
// was handed and whatever the values turned out to be. See
|
|
3660
|
+
// {@link workflowAggregateOutputColumns} for the one thing it does not claim.
|
|
3661
|
+
if (node.kind === 'aggregate')
|
|
3662
|
+
return new Set(workflowAggregateOutputColumns(node));
|
|
3160
3663
|
// Neither of these touches a column: a filter decides which *rows* survive and
|
|
3161
3664
|
// an `if` decides which *nodes* run. Both hand on exactly the shape they were
|
|
3162
3665
|
// given, which is what makes a closed set survive one.
|
|
@@ -3243,11 +3746,9 @@ function checkColumnsProduced(graph, issues, knowledge) {
|
|
|
3243
3746
|
// Narrowed off the union rather than tested with a property check, so a kind
|
|
3244
3747
|
// that starts naming columns without being answered for here is a type error
|
|
3245
3748
|
// at `missingColumnMessage` and not a check that silently passes.
|
|
3246
|
-
if (node.kind !== 'filter' && node.kind !== 'rename')
|
|
3749
|
+
if (node.kind !== 'filter' && node.kind !== 'rename' && node.kind !== 'aggregate')
|
|
3247
3750
|
continue;
|
|
3248
|
-
const named = node
|
|
3249
|
-
? workflowFilterColumns(node.predicate)
|
|
3250
|
-
: Object.keys(node.columns ?? {});
|
|
3751
|
+
const named = columnsNamedBy(node);
|
|
3251
3752
|
if (named.length === 0)
|
|
3252
3753
|
continue;
|
|
3253
3754
|
const known = workflowKnownColumns(graph, node.id, knowledge);
|
|
@@ -3263,13 +3764,33 @@ function checkColumnsProduced(graph, issues, knowledge) {
|
|
|
3263
3764
|
});
|
|
3264
3765
|
}
|
|
3265
3766
|
}
|
|
3767
|
+
/**
|
|
3768
|
+
* The columns a node names, per kind that names any.
|
|
3769
|
+
*
|
|
3770
|
+
* Its own function so the union it takes and the union
|
|
3771
|
+
* {@link missingColumnMessage} takes are the same three kinds written down
|
|
3772
|
+
* twice — which is what makes a fourth kind that starts naming columns a type
|
|
3773
|
+
* error in both places rather than a check that silently passes.
|
|
3774
|
+
*/
|
|
3775
|
+
function columnsNamedBy(node) {
|
|
3776
|
+
if (node.kind === 'filter')
|
|
3777
|
+
return workflowFilterColumns(node.predicate);
|
|
3778
|
+
if (node.kind === 'rename')
|
|
3779
|
+
return Object.keys(node.columns ?? {});
|
|
3780
|
+
return workflowAggregateColumns(node);
|
|
3781
|
+
}
|
|
3266
3782
|
/** The sentence {@link checkColumnsProduced} says, per kind. */
|
|
3267
3783
|
function missingColumnMessage(node, missing, known) {
|
|
3268
3784
|
const quoted = (names) => [...names].map((column) => JSON.stringify(column)).join(', ');
|
|
3269
3785
|
const consequence = node.kind === 'filter'
|
|
3270
3786
|
? 'A test on a column that is not there matches no row — not even a "does not equal" test — so this load would come out empty and every node would report success.'
|
|
3271
|
-
:
|
|
3272
|
-
|
|
3787
|
+
: node.kind === 'rename'
|
|
3788
|
+
? 'A rename of a column that is not there does nothing, so the column it was meant to produce is absent and a sink writing it commits NULL into every row.'
|
|
3789
|
+
: // Both halves of an aggregate fail silently, and they fail differently,
|
|
3790
|
+
// which is why this sentence names both rather than picking one.
|
|
3791
|
+
'Grouping on a column that is not there puts every record into one null-keyed group, so a summary of sixteen thousand work orders comes out as a single row. Aggregating one that is not there answers null for every group, which a sink commits as a column of NULLs. Neither reports an error.';
|
|
3792
|
+
const label = node.kind === 'filter' ? 'Filter' : node.kind === 'rename' ? 'Rename' : 'Aggregate';
|
|
3793
|
+
return `${label} "${node.name}" (${node.id}) names ${quoted(missing)}, and nothing upstream produces ${missing.length === 1 ? 'that column' : 'those columns'}. Something above this node closes the column set — a rename that drops what it does not name, an aggregate, or a source reading a published object type — so what reaches here is exactly ${quoted(known)}. ${consequence}`;
|
|
3273
3794
|
}
|
|
3274
3795
|
function sortedEntries(config) {
|
|
3275
3796
|
return Object.keys(config)
|
|
@@ -3328,6 +3849,8 @@ function isWorkflowNode(value) {
|
|
|
3328
3849
|
}
|
|
3329
3850
|
if (kind === 'rename')
|
|
3330
3851
|
return isRenameNodeShape(value);
|
|
3852
|
+
if (kind === 'aggregate')
|
|
3853
|
+
return isAggregateNodeShape(value);
|
|
3331
3854
|
if (kind === 'source') {
|
|
3332
3855
|
const sourceKind = Reflect.get(value, 'sourceKind');
|
|
3333
3856
|
const config = Reflect.get(value, 'config');
|
|
@@ -3351,6 +3874,23 @@ function isRenameNodeShape(value) {
|
|
|
3351
3874
|
return false;
|
|
3352
3875
|
return isWorkflowRenameColumns(Reflect.get(value, 'columns'));
|
|
3353
3876
|
}
|
|
3877
|
+
/**
|
|
3878
|
+
* Everything an `aggregate` node carries, through the one refusal list.
|
|
3879
|
+
*
|
|
3880
|
+
* The whole node rather than field by field, so a stored aggregate is read back
|
|
3881
|
+
* under exactly the rule that would have refused to store it. Refused rather
|
|
3882
|
+
* than repaired, the stance every guard in this file takes: an aggregate read
|
|
3883
|
+
* back with one entry silently dropped is a load that commits a column of nulls
|
|
3884
|
+
* under a name somebody put in an object type on purpose, and one read back with
|
|
3885
|
+
* its `groupBy` dropped is a load that commits a single row.
|
|
3886
|
+
*/
|
|
3887
|
+
function isAggregateNodeShape(value) {
|
|
3888
|
+
return (aggregateRefusals({
|
|
3889
|
+
groupBy: Reflect.get(value, 'groupBy'),
|
|
3890
|
+
aggregates: Reflect.get(value, 'aggregates'),
|
|
3891
|
+
maxGroups: Reflect.get(value, 'maxGroups'),
|
|
3892
|
+
}).length === 0);
|
|
3893
|
+
}
|
|
3354
3894
|
/**
|
|
3355
3895
|
* Everything a `call` node carries, checked as strictly as a source's.
|
|
3356
3896
|
*
|