Skip to content

Commit af1e1e0

Browse files
fix(objectql)!: a child rule reading a parent summary counts the earlier admitted rows of the same bulk create (#26680)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X5eC1quqfmMtQmRtZFEkgFm)_ Fixes #25579 Clause-②: no (narrowing) — a bulk create (`insert(array)`, `insertMany`, and the doors on them: `createManyData`, `insertManyData`, the import's batched creates) refuses a row whose rule reads a parent `summary` that the earlier admitted rows of the same batch move past the rule's limit, which it admitted before. Nothing is widened. Metadata-protocol: none Skills-docs: none — the published skills teach no rule that reads a parent summary (`skills/objectstack-data/rules/relationships.md` "Roll-up Summaries", `rules/validation.md`), so nothing there becomes false; the docs page `content/docs/data-modeling/fields.mdx` carries the batch rule and the concurrency limit. Ruling-ref: ruling `6377847361` letter C on #25477 (maintainer 「#9 同意」), quoted: 「C = 不加键:批量写入里,子表规则读父级汇总时把同批前面的行算进去(批量等于逐条)。实现须在内存里按批累计汇总(S),⛔ 不得每行回库重算;并发写不在范围,文档写明「批量等于逐条,并发不保证」。验收:本卡复现表的两行只建成一行,另一行报规则错误。」 ## 维护者速读 - **改了什么**:批量新建(导入、`createManyData`、`insertManyData`)里,子表规则读父记录汇总时,会把同一批里前面已通过规则的行算进去,在内存里累计,不再每批只看写入前的旧值。 - **为什么改**:hotsrm「每个供应商最多一个主联系人」单条能挡住,同一次导入的两条主联系人却都建成了;裁决 C 要求批量等于逐条。 - **风险与回滚**:原来整批放行、违反规则的行现在会被拒(这是收窄,标了 BREAKING);只影响读父汇总的规则,单条写入不变。回滚即撤回本 PR。 - **先看**:`packages/objectql/src/summary-batch-accumulation.ts`(累计与覆盖逻辑);`packages/objectql/src/engine.ts` 插入校验循环里的 `batchSummaries` 两处;`packages/rest/src/import-batch-parent-summary-rule.test.ts`(卡片复现)。 - **两分钟验证**:`pnpm --filter @objectstack/rest exec vitest run src/import-batch-parent-summary-rule.test.ts`,预期 9 个用例全绿;第一个用例里两条主联系人只建成一条,另一条报「A supplier has at most one primary contact.」。 - **需要你做的**:无,席位落地。 ## What changes **BREAKING** accept-set narrowing at the bulk insert (`@objectstack/objectql`), served by every bulk create door. A bulk insert resolved the related rows its predicate rules read through a reference once for the whole batch (`resolvePredicateRelated`), and recomputed parent summaries once, after the batch was written. So every row of one batch read a parent `summary` as it stood before the batch, and two primary contacts for one supplier in one import both read `primary_contact_count = 0` and both landed. - `packages/objectql/src/summary-batch-accumulation.ts` (new): `createBatchSummaryAccumulator(descriptors, childFields)` keeps, in memory, the batch's admitted rows per roll-up descriptor and per parent id. `overlay(binding)` hands a row's rules a copy of each related parent row with the summary value those rows move it to; `accept(row)` counts a row in. ⛔ No database read: the stored row the hop resolved is the base, the admitted rows are the delta. The summary's `filter` is evaluated on the new row by the in-memory aggregation's walker (`matchesAggregationFilter`, with the child's declared field classes and JSON-stored set). - `packages/objectql/src/engine.ts`, `insert()`: for a batch of more than one row, the rule evaluation reads `batchSummaries.overlay(...)`, and a row is accepted only after its rules and its reference check passed. A refused row moves nothing. `validate()` (insert mode, more than one row) does the same, so a multi-row preview answers what the write answers. - Which roll-ups accumulate: `count`, and `sum` / `min` / `max` over number values. `avg`, a `sum` / `min` / `max` that meets a non-number value, and a summary whose `filter` names a dotted path keep reading the stored value, as before. - Unchanged: each door's success semantics (`insertMany` reports the refused row and writes the rest; `insert(array)` refuses the whole batch), single writes, and the recompute written after the batch. - `content/docs/data-modeling/fields.mdx`: the batch rule, which summaries accumulate, and 「批量等于逐条,并发不保证」. The docs site keeps English only (`apps/docs/lib/i18n.ts`, `languages: ['en']`), so there is no translation to update. - `.changeset/25579-batch-parent-summary-rule.md`: `patch` on `@objectstack/objectql`, both declaration lines, the BREAKING block and the ADR-0087 disposition `not-required (no-migration-prescription)`. No `packages/spec/**` and no `docs/adr/**` change. ## Tests - `packages/rest/src/import-batch-parent-summary-rule.test.ts` (new, 9 cases): the card's reproduction through the real import door (`POST /api/v1/data/:object/import`), a real ObjectQL engine and a real sqlite `:memory:` driver. The acceptance case (two primary rows, one created, the other carries the rule's error, stored count 1); two non-primary rows both land; a primary and a non-primary both land; a row the rules refuse does not raise the count a later row reads; `insertMany` refuses the second primary with the rule's envelope (`code` `VALIDATION_FAILED`, door `status` 400); `insert(array)` refuses the whole batch and writes nothing; a single write behaves as before; a multi-row `validate` answers what the write answers and writes nothing; a batch after a stored primary refuses its primary row. - `packages/objectql/src/summary-batch-accumulation.test.ts` (new, 11 cases): per roll-up kind (count with filter, sum with null and never-computed base, min / max with an empty base), and what stays as it was (avg, a dotted filter, a non-number value, a filter the walker refuses, a non-number stored value, another parent, an unread roll-up, an unavailable binding, another parent object, and the shared row never mutated). ## Acceptance notes - **Import dry run.** The import's dry run asks `validateData` one row at a time, so its rows still each read the stored summary: a dry run of the card's file reports both rows valid while the commit refuses one. The engine's multi-row preview does accumulate (pinned above); making the import dry run send its creates as one multi-row preview is a change to `packages/core/src/utils/import-runner.ts` this PR does not make. Reported to the seat. - **Partial mode, after validation.** Under `insertMany`, a row admitted by its rules and its reference check is counted even if a later step of the same write fails for that row (an autonumber issue, the driver write). That row then lands nowhere, while a later row of the batch read it in the summary, so the later row can be refused when it would have been admitted one by one. The window is narrow (the failures there are faults, not rule verdicts). - **`parent` root.** The master-detail `parent` binding of a field `requiredWhen` is a different seam and is not accumulated; the card's rule reads through `related`, which is. - **Bulk update.** A predicate or multi-row update that moves a child into a summary's filter is not in the card and is not covered. - **Concurrency.** Out of scope by ruling and documented as such. ## Verification All readings on head `c429449567` (the merge of `origin/main`, 4 commits ahead of base `c739de755c`) unless marked. <details><summary>Red first on main `c739de755c`: 4 of 8 red</summary> ``` × two primary rows for a new supplier in one import: one is created, the other carries the rule's error AssertionError: expected 2 to be 1 × `insertMany` (partial success): … expected [ true, true ] to deeply equal [ true, false ] × `insert(array)` (all or nothing): … promise resolved "[ { id: 'k1', …(5) }, …(1) ]" instead of rejecting × a single write … (the first draft asserted a `status` the engine error does not carry; fixed to read the door's status through `mapDataError`) Tests 4 failed | 4 passed (8) ``` </details> <details><summary>Green</summary> ``` pnpm --filter @objectstack/objectql exec vitest run src/summary-batch-accumulation.test.ts Tests 11 passed (11) pnpm --filter @objectstack/rest exec vitest run src/import-batch-parent-summary-rule.test.ts Tests 9 passed (9) pnpm --filter @objectstack/objectql test Test Files 473 passed (473) · Tests 8806 passed (8806) (pre-merge 4177e93 and final c429449 alike) pnpm --filter @objectstack/objectql typecheck · pnpm --filter @objectstack/rest typecheck exit 0 eslint --no-inline-config on the four touched source/test files exit 0 ``` </details> <details><summary>Ablation: drop the accumulation (accept nothing)</summary> `node scripts/ablation-replace.mjs` on `summary-batch-accumulation.ts`: anchor 1 → 0, blob `311c5d654d06` → `81c1062115ed`; objectql rebuilt; `ablation-dist-preflight` marker present in 4 built files. ``` rest: × acceptance · × insertMany · × insert(array) · × multi-row validate Tests 4 failed | 5 passed (9) objectql: × count · × sum · × min / max · × non-number sibling · × parent/unread/shared Tests 5 failed | 6 passed (11) ``` Restored: blob == HEAD `311c5d654d06`, `git diff HEAD` empty; objectql rebuilt; `--absent` marker absent from all 14 built files. The cases that stay green under the ablation are exactly the ones that pin what the accumulation must not do (non-primary, mixed, refused row, single write, stored primary). </details> <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on c429449 (not stale at derivation): 93 commands; each run with its exit code captured before any pipe. ``` Run reconciliation — 93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN. ✓ dispatch-gates --ran: 93 derived famil(ies) accounted for — 93 run, 0 NOT-MEASURED every one of the 93: exit 0 ``` On the pre-merge head 4177e93 the same 93 were green too, after a full build for the three that read built output (`check-plugin-teardown-shape --self-test` needed an unshallowed clone, `check:skill-examples` and `check:dual-build-cjs-loads` a full `turbo run build`, 116/116). `test:repo` was not run locally: the diff touches no `repo`-project test and no derived list names it; CI's Test Core runs it. </details> <details><summary>Dogfood battery (narrowing arm)</summary> `pnpm --filter @objectstack/dogfood test` under the verify lock, no shard, on c429449 after a full build: ``` Test Files 301 passed | 3 skipped (304) Tests 2501 passed | 18 skipped (2519) os-verify-lock: VERDICT command-exit 0 · held the lock 1286s ``` </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5kCCq5FhpjgjomBn3f --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kCCq5FhpjgjomBn3f)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent da1e09f commit af1e1e0

6 files changed

Lines changed: 625 additions & 2 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql)!: inside one bulk create, a child rule that reads a parent summary counts the earlier rows of the same batch its rules admitted, so a batch is judged as its rows would be one by one (#25579)
6+
7+
Clause-②: no (narrowing) — a bulk create (`insert(array)`, `insertMany`, and the doors on them: `createManyData`, `insertManyData`, the import's batched creates) refuses a row whose rule reads a parent `summary` that the earlier admitted rows of the same batch move past the rule's limit, which it admitted before. Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: an existing validation rule is now enforced on the rows of one bulk create the way it is already enforced one write at a time. The refusal is the rule's own VALIDATION_FAILED, worded by its author, so there is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing at the bulk insert (`@objectstack/objectql`), served by every bulk create door.
13+
14+
- **Before.** A bulk insert read the related rows its rules read through a reference once, before any row of the batch was written, and recomputed parent summaries once, after. So "each supplier has at most one primary contact" (a `count` summary filtered on `{ is_primary: true }`, and the child rule `record.is_primary == true && record.supplier.primary_contact_count > 0`) refused a second primary contact written on its own, but admitted two primary contacts for one supplier in one import: both rows read `0`.
15+
- **Now.** Each row's rules read the parent summary as it stands after the earlier rows of the same batch that the rules admitted. The batch's admitted rows are accumulated in memory per summary and per parent; ⛔ nothing is re-read from the database per row. In the card's import, one contact is created and the other row reports the rule's error. A multi-row insert preview (`validate` with an array, mode `insert`) answers the same way.
16+
- **Unchanged.** Which rows land and how a refusal is reported stay each door's own: `insertMany` / `insertManyData` (the import) report the refused row and write the rest; `insert(array)` / `createManyData` refuse the whole batch. A row the rules refuse is not counted. A single write reads the stored summary as before. The summary written after the batch is the same recompute as before.
17+
- **Which summaries accumulate.** `count`, and `sum` / `min` / `max` over number values, with the summary's `filter` evaluated on the new row. An `avg` summary, a `sum` / `min` / `max` that meets a value that is not a number, and a summary whose `filter` reads a dotted path through another reference keep reading the stored value, as before.
18+
- **Not covered.** Concurrent writes: two writes in flight at the same time each read the summary they started from (批量等于逐条,并发不保证).
19+
- **Who sees it.** An app whose child rule reads a parent summary and that bulk-creates children: a batch that broke the rule was admitted whole before, and now its offending rows are refused.
20+
21+
**The remedy.** None needed for a rule that meant what it says. A batch that must land whole has to satisfy the rule row by row, in order.

‎content/docs/data-modeling/fields.mdx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,23 @@ row is unaffected — a user still needs edit access to change any other field o
309309
it — and the summary column stays subject to the parent's field-level security
310310
on read.
311311

312+
A child validation rule can read a parent summary one hop through its
313+
reference, for example "each supplier has at most one primary contact" as a
314+
`count` summary filtered on `{ is_primary: true }` plus the child rule
315+
`record.is_primary == true && record.supplier.primary_contact_count > 0`.
316+
**A bulk create equals one-by-one creates (批量等于逐条):** inside one bulk
317+
insert (an import batch, `createManyData`, `insertManyData`) each row's rules
318+
read the summary as it stands after the earlier rows of the same batch that the
319+
rules admitted, accumulated in memory, so two primary contacts in one import
320+
create one row and refuse the other with the rule's error. A row the rules
321+
refuse is not counted. This holds for `count`, and for `sum`, `min` and `max`
322+
over number values. An `avg` summary, a `sum`/`min`/`max` meeting a value that
323+
is not a number, and a summary whose `filter` reads a dotted path through
324+
another reference keep reading the stored value, as they did before.
325+
**Concurrent writes are not guaranteed (并发不保证):** two writes in flight at
326+
the same time each read the summary they started from, so a guard like the one
327+
above can still admit one row from each.
328+
312329
### Specialized Types
313330

314331
| Type | Factory | Description |

‎packages/objectql/src/engine.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ import { declaredHookCondition } from './hook-wrappers.js';
324324
import { validateRecord, validateRecordInScope, normalizeMultiValueFields, normalizeBlankTypedValues, normalizeNumericStringValues, coerceBooleanFields, ValidationError, type FieldValidationError, resolveFieldLabel, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js';
325325
import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js';
326326
import type { RelatedFieldBinding, RelatedRecordBinding } from './validation/rule-validator.js';
327+
import { createBatchSummaryAccumulator } from './summary-batch-accumulation.js';
327328
import { collectReferenceTraversals, referenceTraversalConflicts, evaluateValidationRules, emitValidationAdvisories, optionVisibilityReadsPermissions, readsPermissionPredicate, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, hasParentScopedReadonlyWhenInPayload, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields, staticReadonlyInsertSubject, preserveAuditIgnoredOnInsertWarning } from './validation/rule-validator.js';
328329
// [#14088] The before-phase write recorder — the provenance channel the static
329330
// `readonly` strip needs to tell a hook's write from a caller's echo of the
@@ -14520,6 +14521,12 @@ export class ObjectQL implements IObjectQLEngine {
1452014521
// write would — resolved once for the whole set, like every posture input
1452114522
// above. A resolution failure rejects the preview, as it fails the write.
1452214523
const previewPermissionsFor = await this.resolveOptionPermissions(schemaForValidation, rows, permissionResolution);
14524+
// [#25579] A multi-row insert preview judges a rule reading a parent
14525+
// roll-up the way the write judges the batch: each row sees the earlier
14526+
// rows the preview accepted, accumulated in memory (see `insert()`).
14527+
const batchSummaries = mode === 'insert' && rows.length > 1
14528+
? createBatchSummaryAccumulator(this.getSummaryDescriptors(object), (schemaForValidation as any)?.fields)
14529+
: undefined;
1452314530

1452414531
const results: NonNullable<ValidateDataResponse['results']> = rows.map((row, i) => {
1452514532
const warnings: ValidateDataIssue[] = [];
@@ -14574,7 +14581,7 @@ export class ObjectQL implements IObjectQLEngine {
1457414581
// repoint, `roWhenPreviousParent`.
1457514582
parent: previewParents?.parents[i],
1457614583
previousParent: previewParents?.previousParents[i],
14577-
related: previewRelatedForRow(judgedViews[i]),
14584+
related: batchSummaries ? batchSummaries.overlay(previewRelatedForRow(judgedViews[i])) : previewRelatedForRow(judgedViews[i]),
1457814585
permissions: previewPermissionsFor(row),
1457914586
}));
1458014587
} catch (e) {
@@ -14583,6 +14590,7 @@ export class ObjectQL implements IObjectQLEngine {
1458314590
}
1458414591
throw e;
1458514592
}
14593+
batchSummaries?.accept(row);
1458614594
// [#20922] THIS row's drops, from the strips' own per-row record above
1458714595
// — the report `insertMany` hangs on the same row's `ok` outcome, so a
1458814596
// dry run and its commit answer one row in one vocabulary. Only on a
@@ -15572,6 +15580,15 @@ export class ObjectQL implements IObjectQLEngine {
1557215580
// when no rule traverses. Read under SYSTEM authority, bounded by the
1557315581
// projection — see `resolvePredicateRelated`.
1557415582
const insertRelatedForRow = await this.resolvePredicateRelated(schemaForValidation, rows, opCtx.context);
15583+
// [#25579] Batch equals one-by-one: the related rows above were read
15584+
// ONCE, before any row of this batch is written, so a rule reading a
15585+
// parent roll-up would judge every row against the summary as it stood
15586+
// before the batch. The rows the rules admit are accumulated in memory
15587+
// and overlaid on each later row's binding — never re-read per row.
15588+
// `undefined` (no roll-up aggregates this object exactly) pays nothing.
15589+
const batchSummaries = rows.length > 1
15590+
? createBatchSummaryAccumulator(this.getSummaryDescriptors(object), (schemaForValidation as any)?.fields)
15591+
: undefined;
1557515592
// [#18783] The subject's effective object permissions, for an option
1557615593
// gated on `current_user.can(…)`: ONE resolution for the whole batch,
1557715594
// and none at all unless a row picks such an option. A resolution
@@ -15591,10 +15608,12 @@ export class ObjectQL implements IObjectQLEngine {
1559115608
normalizeMultiValueFields(schemaForValidation, rows[i], 'include');
1559215609
// [#22183] `keptOptionValues`: the option values an import kept for this write.
1559315610
validateRecordInScope(schemaForValidation, rows[i], 'insert', 'include', { mediaValueShapeStrict, valueShapeStrict, messages: msgCtx, onAdmittedValueShapeViolation, keptOptionValues: opCtx.context?.keptOptionValues });
15594-
emitValidationAdvisories(options?.onValidationAdvisory, evaluateValidationRules(schemaForValidation as any, rows[i], 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: msgCtx, parent: insertParentForRow?.(rows[i]), related: insertRelatedForRow(rows[i]), permissions: insertPermissionsFor(rows[i]) }), this.logger, object);
15611+
emitValidationAdvisories(options?.onValidationAdvisory, evaluateValidationRules(schemaForValidation as any, rows[i], 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: msgCtx, parent: insertParentForRow?.(rows[i]), related: batchSummaries ? batchSummaries.overlay(insertRelatedForRow(rows[i])) : insertRelatedForRow(rows[i]), permissions: insertPermissionsFor(rows[i]) }), this.logger, object);
1559515612
await this.assertReferencesResolve(
1559615613
schemaForValidation, rows[i], suppliedPerRow[i], opCtx.context, msgCtx,
1559715614
);
15615+
// [#25579] Admitted: a later row of this batch reads it in the summary.
15616+
batchSummaries?.accept(rows[i]);
1559815617
} catch (e) {
1559915618
if (!partialMode) throw e;
1560015619
rowErrors[i] = e;
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25579] The in-memory accumulation a bulk insert overlays on its rules'
5+
* related bindings — one function of the stored summary and the batch's
6+
* admitted rows, per roll-up kind. The end-to-end half (the real import door,
7+
* the real engine, a real sqlite driver) is
8+
* `packages/rest/src/import-batch-parent-summary-rule.test.ts`.
9+
*/
10+
11+
import { describe, it, expect } from 'vitest';
12+
import { createBatchSummaryAccumulator } from './summary-batch-accumulation.js';
13+
import type { SummaryDescriptor } from './summary-aggregate.js';
14+
import type { RelatedRecordBinding } from './validation/rule-validator.js';
15+
16+
const CHILD_FIELDS = {
17+
amount: { type: 'number' },
18+
is_primary: { type: 'boolean' },
19+
kind: { type: 'text' },
20+
note: { type: 'text' },
21+
order: { type: 'lookup', reference: 'xt_order' },
22+
};
23+
24+
const desc = (over: Partial<SummaryDescriptor>): SummaryDescriptor => ({
25+
parentObject: 'xt_order', summaryField: 'total', childObject: 'xt_line', fkField: 'order',
26+
fn: 'sum', sourceField: 'amount', ...over,
27+
});
28+
29+
/** The binding the engine hands a rule reading `record.order.<field>` on parent `id`. */
30+
const bound = (row: Record<string, unknown>): RelatedRecordBinding => ({ order: { object: 'xt_order', row } });
31+
32+
describe('[#25579] createBatchSummaryAccumulator — per roll-up kind', () => {
33+
it('count: each admitted row adds one; the summary filter is evaluated on the new row', () => {
34+
const acc = createBatchSummaryAccumulator([desc({ summaryField: 'primaries', fn: 'count', filter: { is_primary: true } })], CHILD_FIELDS)!;
35+
const stored = bound({ id: 'o1', primaries: 0 });
36+
acc.accept({ order: 'o1', is_primary: false });
37+
expect(acc.overlay(stored)).toBe(stored);
38+
acc.accept({ order: 'o1', is_primary: true });
39+
expect(acc.overlay(stored)!.order!.row).toEqual({ id: 'o1', primaries: 1 });
40+
acc.accept({ order: 'o1', is_primary: true });
41+
expect(acc.overlay(stored)!.order!.row).toEqual({ id: 'o1', primaries: 2 });
42+
});
43+
44+
it('sum: adds the admitted values, skips a null, starts a never-computed (null) sum at 0', () => {
45+
const acc = createBatchSummaryAccumulator([desc({})], CHILD_FIELDS)!;
46+
acc.accept({ order: 'o1', amount: 60 });
47+
acc.accept({ order: 'o1', amount: null });
48+
acc.accept({ order: 'o1', amount: 15 });
49+
expect(acc.overlay(bound({ id: 'o1', total: 10 }))!.order!.row!.total).toBe(85);
50+
expect(acc.overlay(bound({ id: 'o1', total: null }))!.order!.row!.total).toBe(75);
51+
});
52+
53+
it('min / max: fold the admitted values into the stored one; an empty (null) stored value is the admitted extreme', () => {
54+
const acc = createBatchSummaryAccumulator([
55+
desc({ summaryField: 'lowest', fn: 'min' }),
56+
desc({ summaryField: 'highest', fn: 'max' }),
57+
], CHILD_FIELDS)!;
58+
acc.accept({ order: 'o1', amount: 7 });
59+
acc.accept({ order: 'o1', amount: 3 });
60+
expect(acc.overlay(bound({ id: 'o1', lowest: 5, highest: 5 }))!.order!.row).toEqual({ id: 'o1', lowest: 3, highest: 7 });
61+
expect(acc.overlay(bound({ id: 'o1', lowest: 1, highest: 9 }))!.order!.row).toEqual({ id: 'o1', lowest: 1, highest: 9 });
62+
expect(acc.overlay(bound({ id: 'o1', lowest: null, highest: null }))!.order!.row).toEqual({ id: 'o1', lowest: 3, highest: 7 });
63+
});
64+
65+
it('a row never accepted moves nothing: the caller accepts only the rows its rules admitted', () => {
66+
const acc = createBatchSummaryAccumulator([desc({ summaryField: 'primaries', fn: 'count' })], CHILD_FIELDS)!;
67+
const stored = bound({ id: 'o1', primaries: 0 });
68+
expect(acc.overlay(stored)).toBe(stored);
69+
});
70+
});
71+
72+
describe('[#25579] createBatchSummaryAccumulator — what it leaves as it was', () => {
73+
it('avg is not accumulated: no accumulator at all when it is the only roll-up', () => {
74+
expect(createBatchSummaryAccumulator([desc({ fn: 'avg' })], CHILD_FIELDS)).toBeUndefined();
75+
expect(createBatchSummaryAccumulator([], CHILD_FIELDS)).toBeUndefined();
76+
});
77+
78+
it('a filter naming a dotted path is not evaluated on the new row: that roll-up keeps the stored value', () => {
79+
expect(createBatchSummaryAccumulator([desc({ fn: 'count', filter: { 'order.status': 'open' } })], CHILD_FIELDS)).toBeUndefined();
80+
expect(createBatchSummaryAccumulator([desc({ fn: 'count', filter: { $or: [{ 'order.status': 'open' }] } })], CHILD_FIELDS)).toBeUndefined();
81+
});
82+
83+
it('a non-number value stops that roll-up for the rest of the batch; a sibling roll-up keeps accumulating', () => {
84+
const acc = createBatchSummaryAccumulator([desc({}), desc({ summaryField: 'lines', fn: 'count' })], CHILD_FIELDS)!;
85+
acc.accept({ order: 'o1', amount: 5 });
86+
acc.accept({ order: 'o1', amount: 'five' });
87+
expect(acc.overlay(bound({ id: 'o1', total: 1, lines: 0 }))!.order!.row).toEqual({ id: 'o1', total: 1, lines: 2 });
88+
});
89+
90+
it('a filter the walker refuses stops that roll-up, rather than guessing', () => {
91+
const acc = createBatchSummaryAccumulator([desc({ fn: 'count', summaryField: 'lines', filter: { kind: { $nope: 1 } } })], CHILD_FIELDS)!;
92+
acc.accept({ order: 'o1', kind: 'a' });
93+
const stored = bound({ id: 'o1', lines: 0 });
94+
expect(acc.overlay(stored)).toBe(stored);
95+
});
96+
97+
it('a stored value that is not a number is left as it is', () => {
98+
const acc = createBatchSummaryAccumulator([desc({})], CHILD_FIELDS)!;
99+
acc.accept({ order: 'o1', amount: 5 });
100+
expect(acc.overlay(bound({ id: 'o1', total: 'n/a' }))!.order!.row!.total).toBe('n/a');
101+
});
102+
103+
it('only the parent the admitted rows name moves, only the roll-up the rule reads, and the shared row is never mutated', () => {
104+
const acc = createBatchSummaryAccumulator([desc({ summaryField: 'lines', fn: 'count' })], CHILD_FIELDS)!;
105+
acc.accept({ order: 'o1' });
106+
const other = bound({ id: 'o2', lines: 4 });
107+
expect(acc.overlay(other)).toBe(other);
108+
const unread = bound({ id: 'o1', name: 'O-1' });
109+
expect(acc.overlay(unread)).toBe(unread);
110+
const sharedRow = { id: 'o1', lines: 4 };
111+
const shared = bound(sharedRow);
112+
expect(acc.overlay(shared)!.order!.row!.lines).toBe(5);
113+
expect(sharedRow.lines).toBe(4);
114+
});
115+
116+
it('an unavailable binding and a different parent object pass through untouched', () => {
117+
const acc = createBatchSummaryAccumulator([desc({ summaryField: 'lines', fn: 'count' })], CHILD_FIELDS)!;
118+
acc.accept({ order: 'o1' });
119+
const unavailable: RelatedRecordBinding = { order: { object: 'xt_order', unavailable: 'no-reference' } };
120+
expect(acc.overlay(unavailable)).toBe(unavailable);
121+
const foreign: RelatedRecordBinding = { order: { object: 'xt_quote', row: { id: 'o1', lines: 1 } } };
122+
expect(acc.overlay(foreign)).toBe(foreign);
123+
expect(acc.overlay(undefined)).toBeUndefined();
124+
});
125+
});

0 commit comments

Comments
 (0)