|
| 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