diff --git a/.changeset/26950-predicate-write-summary-recompute.md b/.changeset/26950-predicate-write-summary-recompute.md new file mode 100644 index 00000000000..bd89ec438a5 --- /dev/null +++ b/.changeset/26950-predicate-write-summary-recompute.md @@ -0,0 +1,12 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql): a predicate update or delete (`multi: true` with a filter) recomputes the parent roll-up summaries its matched rows name (#26950) + +Clause-②: no — no write is refused and no key, value or shape changes: a stored roll-up that a predicate write used to leave stale is now recomputed, as the same write done record by record recomputes it. +Metadata-protocol: none + +- **Before.** `engine.update(object, data, { where, multi: true })` and `engine.delete(object, { where, multi: true })`, which a flow's `update_record` / `delete_record` node with `multi: true` and a filter reaches, recomputed no `summary` roll-up. The parent's count, sum, min, max or average kept its old value until some by-id write on one of its children recomputed it. A bulk step that deleted or reassigned line items left the order totals wrong, with no error. +- **Now.** When a roll-up aggregates the object, the predicate write reads the foreign-key columns those roll-ups name off the rows it matches, before it writes, through the same row-scoped query, and recomputes every parent they name after the write: for a delete the old parents, for an update the old and the new ones, so a reparent recomputes both. A recompute failure surfaces as the by-id write's does (`SummaryRecomputeError`: the rows were written, the roll-up may be stale). +- **Unchanged.** An object no roll-up aggregates reads no extra row: its predicate write is still one bulk driver call. Where the write already reads its matched rows (per-row hooks, per-row validation, the referential walk of a predicate delete), that read carries the foreign keys and no second read is made. diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index fb30f0f73cb..55ca68fe301 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -199,6 +199,7 @@ import { TransactionUnsupportedError } from './transaction-errors.js'; import { aggregateSummaryValue, summaryEmptySetValue, + withoutDeletedSelfParents, type SummaryDescriptor, } from './summary-aggregate.js'; import { ReadonlyFieldRejectedError } from './readonly-strict-errors.js'; @@ -12402,6 +12403,14 @@ export class ObjectQL implements IObjectQLEngine { return this.summaryIndex!.get(childObject) ?? []; } + /** [#26950] The child-side foreign-key columns the roll-ups over + * `childObject` read: what a predicate write must read off its matched rows, + * before it writes, to hand {@link recomputeSummaries} the parents to + * recompute. Empty when no roll-up aggregates `childObject`. */ + private summaryForeignKeyColumns(childObject: string): string[] { + return [...new Set(this.getSummaryDescriptors(childObject).map((desc) => desc.fkField))]; + } + /** Roll-up descriptors for the summary fields `parentObject` OWNS (#5749) — * i.e. the ones a NEW row of `parentObject` must have seeded. * @@ -16735,6 +16744,11 @@ export class ObjectQL implements IObjectQLEngine { // driver returning a row from `updateMany` would silently reroute a // bulk write onto the per-record contract if we inferred it. let isPredicateWrite = false; + // [#26950] The roll-up input a predicate write hands + // `recomputeSummaries`: each matched row as it was (`previous`) and + // as the write leaves it (`next`). `null` when no roll-up aggregates + // this object, and on the by-id branch, which passes its own row. + let summaryRows: { previous: Record[]; next: Record[] } | null = null; const mediaValueShapeStrict = await this.mediaValueShapeStrictFor(updateSchema); const valueShapeStrict = await this.valueShapeStrictFor(updateSchema); const updateMsgCtx = this.validationMessageContext(object, opCtx.context); @@ -17504,6 +17518,23 @@ export class ObjectQL implements IObjectQLEngine { // the N matched rows conflicted — a fabricated row attribution // is worse than an absent one (triage ruling, 2026-09-02), // exactly as the composite-index case already behaves on insert. + // + // [#26950] The parents this write moves a roll-up of, read BEFORE + // the write: the matched rows' foreign keys as they are, and as + // the payload leaves them, so a reparent recomputes the old parent + // and the new one, as the by-id branch does with `priorRecord`. + // Only the descriptors' FK columns are read, through this write's + // own row-scoped AST and driver options; the memoized matched-row + // read serves instead when a consumer above already made it. An + // object no roll-up aggregates reads nothing here. + const summaryKeyColumns = this.summaryForeignKeyColumns(object); + if (summaryKeyColumns.length > 0) { + const previous = priorRowsRead + ? ((await readPriorRows()) ?? []) + : (((await driver.find(object, { ...ast, fields: summaryKeyColumns }, hookContext.input.options as Parameters[2])) ?? []) as Record[]); + const payload = hookContext.input.data as Record; + summaryRows = { previous, next: previous.map((row) => ({ ...row, ...payload })) }; + } try { result = await driver.updateMany!(object, ast, hookContext.input.data as Record, hookContext.input.options as any); } catch (driverError) { @@ -17591,7 +17622,10 @@ export class ObjectQL implements IObjectQLEngine { // Roll-up: recompute parent summaries; pass priorRecord too so a child // that moved to a different parent updates BOTH old and new parent. - const summaryFailures = await this.recomputeSummaries(object, result, priorRecord, opCtx.context); + // [#26950] A predicate write passes its matched rows, old and new. + const summaryFailures = summaryRows + ? await this.recomputeSummaries(object, summaryRows.next, summaryRows.previous, opCtx.context) + : await this.recomputeSummaries(object, result, priorRecord, opCtx.context); // Publish the update event under whichever contract this write can // honour: per-record `data.record.updated` (#4626), or the aggregate @@ -19607,6 +19641,12 @@ export class ObjectQL implements IObjectQLEngine { // `record-after-delete` flow must see each deleted row rather than one // context that names none of them. let bulkPerRowRows: Record[] | null = null; + // [#26950] The predicate path's matched rows as the per-row hook read + // below took them (`null` when no delete-side hook asked for them), and + // the rows whose foreign keys `recomputeSummaries` is handed after the + // delete (`null` when no roll-up aggregates this object). + let predicateMatchedRows: Record[] | null = null; + let summaryPriorRows: Record[] | null = null; // [#5574] The dispatch ladder, resolved BEFORE the before phase — see // update()'s twin for the full reasoning. Keyed on the SAME falsy-`id` @@ -19746,6 +19786,7 @@ export class ObjectQL implements IObjectQLEngine { (await driver.find(object, ast, preOpts) as Record[]) ?? [], declaredDeleteColumns, ); + predicateMatchedRows = doomed; // [D6] One ceiling, both phases, BEFORE the first per-row dispatch // and before the driver call. this.assertBulkPerRowHookBudget( @@ -19841,8 +19882,23 @@ export class ObjectQL implements IObjectQLEngine { // `'none'` means no relation of the delete family points at // this object, so the walk could write nothing and the arm keeps // its single `deleteMany`, no rows read and no transaction. + // + // [#26950] A roll-up over this object needs the matched rows' + // foreign keys, read BEFORE the delete, to find the parents to + // recompute after it: the by-id branch hands `recomputeSummaries` + // its pre-image, and this arm hands it the matched rows. Only the + // descriptors' FK columns are read, through the composed AST and + // the write's driver options, and only once: the walk's own id + // read carries them below, and with no walk the per-row hook + // read serves when it was made. An object no roll-up aggregates + // reads nothing new. + const summaryKeyColumns = this.summaryForeignKeyColumns(object); const plan = this.planCascadeAtomicity(object); if (plan === 'none') { + if (summaryKeyColumns.length > 0) { + summaryPriorRows = predicateMatchedRows + ?? (((await driver.find(object, { ...opCtx.ast!, fields: summaryKeyColumns }, hookContext.input.options as Parameters[2])) ?? []) as Record[]); + } result = await driver.deleteMany!(object, opCtx.ast!, hookContext.input.options as any); } else { // Otherwise: resolve the matched rows through the composed, @@ -19857,8 +19913,9 @@ export class ObjectQL implements IObjectQLEngine { // branch's, for the same reasons (#7413). const runPredicateDelete = async (writeContext?: ExecutionContext): Promise => { const writeOptions = this.buildDriverOptions(object, writeContext, hookContext.input.options as any); - const matched = ((await driver.find(object, { ...opCtx.ast!, fields: ['id'] }, writeOptions)) ?? []) as Array>; + const matched = ((await driver.find(object, { ...opCtx.ast!, fields: ['id', ...summaryKeyColumns] }, writeOptions)) ?? []) as Array>; const matchedIds = matched.map((row) => row?.id).filter((v): v is string | number => v != null); + if (summaryKeyColumns.length > 0) summaryPriorRows = withoutDeletedSelfParents(object, matched, matchedIds, this.getSummaryDescriptors(object)); for (const matchedId of matchedIds) { await this.cascadeDeleteRelations(object, matchedId, writeContext); } @@ -19926,9 +19983,11 @@ export class ObjectQL implements IObjectQLEngine { // from the row's FK values captured BEFORE deletion. [#5272] That // capture is now the same single pre-image read `previous` rides on // (it used to be its own later `findOne`), which is also what - // `update()` passes here. - const summaryFailures = priorRecord - ? await this.recomputeSummaries(object, null, priorRecord, opCtx.context) + // `update()` passes here. [#26950] A predicate delete passes the + // matched rows it read before the delete. + const summaryPrevious = priorRecord ?? summaryPriorRows; + const summaryFailures = summaryPrevious + ? await this.recomputeSummaries(object, null, summaryPrevious, opCtx.context) : []; // Same split as update(): per-record `data.record.deleted` (#4626), diff --git a/packages/objectql/src/summary-aggregate.ts b/packages/objectql/src/summary-aggregate.ts index 8b6c878dcff..560d42ec30d 100644 --- a/packages/objectql/src/summary-aggregate.ts +++ b/packages/objectql/src/summary-aggregate.ts @@ -120,3 +120,38 @@ export async function aggregateSummaryValue( const value = rows?.[0]?.value; return value == null ? summaryEmptySetValue(desc.fn) : value; } + +/** + * [#26950] The matched rows a predicate delete hands the recompute, less every + * foreign key that names a row the same delete removed. + * + * A roll-up over its own object (a `summary` aggregating `object` through a + * self-reference) can name, from one matched row, a parent that is itself a + * matched row. That parent is gone after the delete, so it has no roll-up left + * to recompute, and asking would answer not-found for a row the delete itself + * took: a `SummaryRecomputeError` on a delete that succeeded. The by-id delete + * never meets this, because it deletes one row and its children's own deletes + * recompute it while it still exists. Only self-referencing descriptors are + * touched; every other foreign key is handed over as read. + */ +export function withoutDeletedSelfParents( + object: string, + rows: Array>, + deletedIds: ReadonlyArray, + descriptors: readonly SummaryDescriptor[], +): Array> { + const selfKeys = descriptors.filter((desc) => desc.parentObject === object).map((desc) => desc.fkField); + if (selfKeys.length === 0 || deletedIds.length === 0) return rows; + const gone = new Set(deletedIds.map(String)); + return rows.map((row) => { + let out = row; + for (const key of selfKeys) { + const v = row?.[key]; + if (v != null && gone.has(String(v))) { + if (out === row) out = { ...row }; + out[key] = null; + } + } + return out; + }); +} diff --git a/packages/objectql/src/summary-rollup.test.ts b/packages/objectql/src/summary-rollup.test.ts index 3321f653f68..e5328044f16 100644 --- a/packages/objectql/src/summary-rollup.test.ts +++ b/packages/objectql/src/summary-rollup.test.ts @@ -45,11 +45,33 @@ function makeDriver() { }); }; let n = 0; + // [#26950] Every row read and predicate write, in order, so a test can count + // the reads a write made BEFORE its driver write. + const log: Array<{ op: 'find' | 'updateMany' | 'deleteMany'; object: string; ast: any }> = []; const driver: any = { name: 'memory', version: '0.0.0', supports: {}, async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, async find(object: string, ast: any) { - return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + log.push({ op: 'find', object, ast }); + return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)).map((r) => ({ ...r })); + }, + async updateMany(object: string, ast: any, data: Record) { + log.push({ op: 'updateMany', object, ast }); + const s = storeFor(object); + let count = 0; + for (const [id, r] of Array.from(s.entries())) { + if (matches(r, ast?.where)) { s.set(id, { ...r, ...data, id }); count += 1; } + } + return count; + }, + async deleteMany(object: string, ast: any) { + log.push({ op: 'deleteMany', object, ast }); + const s = storeFor(object); + let count = 0; + for (const [id, r] of Array.from(s.entries())) { + if (matches(r, ast?.where)) { s.delete(id); count += 1; } + } + return count; }, async findOne(object: string, ast: any) { for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; @@ -77,7 +99,7 @@ function makeDriver() { async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; }, async commit() {}, async rollback() {}, }; - return { driver, storeFor }; + return { driver, storeFor, log }; } describe('roll-up summary fields', () => { @@ -448,3 +470,138 @@ describe('roll-up summary seeding on the PARENT insert (#5749)', () => { expect(storeFor('sprint').get(s.id).story_count).toBe(0); }); }); + +describe('[#26950] a predicate update or delete hands recomputeSummaries its matched rows', () => { + let engine: ObjectQL; + let storeFor: ReturnType['storeFor']; + let log: ReturnType['log']; + + beforeEach(async () => { + engine = new ObjectQL(); + const d = makeDriver(); + storeFor = d.storeFor; + log = d.log; + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject({ + name: 'inv', + fields: { + name: { type: 'text' }, + line_total: { type: 'summary', summaryOperations: { object: 'inv_line', field: 'amount', function: 'sum' } }, + line_count: { type: 'summary', summaryOperations: { object: 'inv_line', field: 'amount', function: 'count' } }, + }, + } as any); + engine.registry.registerObject({ + name: 'inv_line', + fields: { + grp: { type: 'text' }, + amount: { type: 'number' }, + inv: { type: 'master_detail', reference: 'inv' }, + }, + } as any); + // No roll-up aggregates it and nothing points at it: the control. + engine.registry.registerObject({ + name: 'plain', + fields: { grp: { type: 'text' }, amount: { type: 'number' } }, + } as any); + }); + + const parent = (id: string) => storeFor('inv').get(id); + /** The row reads of `object` made before the first predicate driver write. */ + const readsBeforeWrite = (object: string) => { + const at = log.findIndex((e) => e.op !== 'find'); + return log.slice(0, at === -1 ? log.length : at).filter((e) => e.object === object); + }; + const seed = async () => { + const a = await engine.insert('inv', { name: 'A' }); + const b = await engine.insert('inv', { name: 'B' }); + await engine.insert('inv_line', { inv: a.id, grp: 'x', amount: 10 }); + await engine.insert('inv_line', { inv: a.id, grp: 'x', amount: 32 }); + await engine.insert('inv_line', { inv: b.id, grp: 'y', amount: 5 }); + log.length = 0; + return { a: a.id as string, b: b.id as string }; + }; + + it('control: a predicate write on an object no roll-up aggregates reads no row', async () => { + await engine.insert('plain', { grp: 'x', amount: 1 }); + await engine.insert('plain', { grp: 'x', amount: 2 }); + log.length = 0; + + expect(await engine.update('plain', { amount: 9 }, { where: { grp: 'x' }, multi: true })).toBe(2); + expect(await engine.delete('plain', { where: { grp: 'x' }, multi: true })).toBe(2); + expect(log.map((e) => `${e.op} ${e.object}`)).toEqual(['updateMany plain', 'deleteMany plain']); + }); + + it('a predicate update reads only the FK columns, once, and recomputes the old and the new parent', async () => { + const { a, b } = await seed(); + expect(await engine.update('inv_line', { inv: b }, { where: { grp: 'x' }, multi: true })).toBe(2); + + const reads = readsBeforeWrite('inv_line'); + expect(reads).toHaveLength(1); + expect(reads[0].ast.fields).toEqual(['inv']); + expect(reads[0].ast.where).toEqual({ grp: 'x' }); + expect(parent(a)).toMatchObject({ line_total: 0, line_count: 0 }); + expect(parent(b)).toMatchObject({ line_total: 47, line_count: 3 }); + }); + + it('a predicate delete reads only the FK columns, once, and recomputes the parent its rows named', async () => { + const { a, b } = await seed(); + expect(await engine.delete('inv_line', { where: { grp: 'x' }, multi: true })).toBe(2); + + const reads = readsBeforeWrite('inv_line'); + expect(reads).toHaveLength(1); + expect(reads[0].ast.fields).toEqual(['inv']); + expect(parent(a)).toMatchObject({ line_total: 0, line_count: 0 }); + expect(parent(b)).toMatchObject({ line_total: 5, line_count: 1 }); + }); + + it('reuses the matched-row read the per-row hooks already made: no second read', async () => { + const { a, b } = await seed(); + engine.registerHook('afterUpdate', () => {}, { object: 'inv_line' } as any); + engine.registerHook('afterDelete', () => {}, { object: 'inv_line' } as any); + + await engine.update('inv_line', { inv: b }, { where: { grp: 'x' }, multi: true }); + expect(readsBeforeWrite('inv_line'), 'update: the hooks\' one read').toHaveLength(1); + expect(parent(a)).toMatchObject({ line_count: 0 }); + expect(parent(b)).toMatchObject({ line_count: 3 }); + + log.length = 0; + await engine.delete('inv_line', { where: { inv: b }, multi: true }); + expect(readsBeforeWrite('inv_line'), 'delete: the hooks\' one read').toHaveLength(1); + expect(parent(b)).toMatchObject({ line_total: 0, line_count: 0 }); + }); + + it('a delete that runs the referential walk carries the FK columns on the walk\'s own id read', async () => { + engine.registry.registerObject({ + name: 'inv_note', + fields: { line: { type: 'lookup', reference: 'inv_line' } }, + } as any); + const { a } = await seed(); + expect(await engine.delete('inv_line', { where: { grp: 'x' }, multi: true })).toBe(2); + + expect(readsBeforeWrite('inv_line').map((e) => e.ast.fields)).toEqual([['id', 'inv']]); + expect(parent(a)).toMatchObject({ line_total: 0, line_count: 0 }); + }); + + it('a roll-up over its own object does not recompute a parent the same delete removed', async () => { + // A self roll-up: a node counts the nodes whose `up` names it. Deleting a + // node and its child in one predicate delete leaves the child naming a + // parent that is gone; only the surviving root is recomputed, and the + // delete answers its count instead of a recompute failure. + engine.registry.registerObject({ + name: 'node', + fields: { + grp: { type: 'text' }, + up: { type: 'lookup', reference: 'node' }, + kids: { type: 'summary', summaryOperations: { object: 'node', field: 'grp', function: 'count', relationshipField: 'up' } }, + }, + } as any); + const root = await engine.insert('node', { grp: 'keep' }); + const mid = await engine.insert('node', { grp: 'x', up: root.id }); + await engine.insert('node', { grp: 'x', up: mid.id }); + expect(storeFor('node').get(root.id).kids).toBe(1); + + expect(await engine.delete('node', { where: { grp: 'x' }, multi: true })).toBe(2); + expect(storeFor('node').get(root.id).kids).toBe(0); + }); +}); diff --git a/packages/services/service-automation/src/builtin/predicate-write-summary-recompute.integration.test.ts b/packages/services/service-automation/src/builtin/predicate-write-summary-recompute.integration.test.ts new file mode 100644 index 00000000000..e2564b89d3e --- /dev/null +++ b/packages/services/service-automation/src/builtin/predicate-write-summary-recompute.integration.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A predicate write on a child object (`multi: true` with a filter) recomputes + * the parent roll-up `summary` fields its matched rows name, as the same write + * done record by record does. + * + * `recomputeSummaries` finds the parents to recompute from the child rows' + * foreign keys. The by-id arms hand it the row they read before the write; the + * predicate arms handed it nothing (an update's affected-row count, a delete's + * unbound pre-image), so a bulk flow step that deleted or reparented line + * items left the order's count and total at their old values, with no error. + * The predicate arms now hand it the matched rows' foreign keys: the old ones + * for a delete, the old and the new ones for an update, so a reparent + * recomputes both parents. + * + * Composition: the real stack the other `*.integration.test.ts` files in this + * package boot: `ObjectKernel`, `ObjectQLPlugin`, `driver-sql` on + * better-sqlite3 `:memory:`, and the real `AutomationServicePlugin` for the + * flow cases. Parent roll-ups are read out of the DRIVER, not through the + * engine. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FilterCondition } from '@objectstack/spec/data'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { AutomationServicePlugin } from '../plugin.js'; +import type { AutomationEngine } from '../engine.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; + +const SYS = { isSystem: true } as const; + +/** The parent: a `count` and a `sum` roll-up over its lines. */ +const ORDER = { + name: 'pin_rollup_order', + label: 'Pin roll-up order', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + line_count: { name: 'line_count', label: 'Line count', type: 'summary', summaryOperations: { object: 'pin_rollup_line', field: 'amount', function: 'count' } }, + line_total: { name: 'line_total', label: 'Line total', type: 'summary', summaryOperations: { object: 'pin_rollup_line', field: 'amount', function: 'sum' } }, + item_count: { name: 'item_count', label: 'Item count', type: 'summary', summaryOperations: { object: 'pin_rollup_item', field: 'title', function: 'count' } }, + }, +}; +/** The child: a `master_detail` to the order. */ +const LINE = { + name: 'pin_rollup_line', + label: 'Pin roll-up line', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + grp: { name: 'grp', label: 'Group', type: 'text' }, + amount: { name: 'amount', label: 'Amount', type: 'number' }, + order: { name: 'order', label: 'Order', type: 'master_detail', reference: 'pin_rollup_order' }, + }, +}; +/** + * A second child, which a `set_null` lookup points at: a predicate delete of + * it takes the referential walk's arm (#26871), not the single `deleteMany`. + */ +const ITEM = { + name: 'pin_rollup_item', + label: 'Pin roll-up item', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + grp: { name: 'grp', label: 'Group', type: 'text' }, + order: { name: 'order', label: 'Order', type: 'master_detail', reference: 'pin_rollup_order' }, + }, +}; +const NOTE = { + name: 'pin_rollup_note', + label: 'Pin roll-up note', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + item: { name: 'item', label: 'Item', type: 'lookup', reference: 'pin_rollup_item' }, + }, +}; + +describe('[#26950] a predicate update or delete recomputes the roll-ups of every parent its matched rows name', () => { + let kernel: ObjectKernel; + let ql: ObjectQL; + let driver: SqlDriver; + let automation: AutomationEngine; + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'fatal' } }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); + await kernel.bootstrap(); + ql = kernel.getService('objectql'); + automation = kernel.getService('automation'); + + driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await driver.connect(); + ql.registerDriver(driver, true); + for (const o of [ORDER, LINE, ITEM, NOTE]) ql.registry.registerObject(o as any, 'pin-rollup', 'pin-rollup'); + await ql.syncSchemas(); + await installDefaultOrganization(ql); + }, 60_000); + + afterAll(async () => { + try { await kernel?.shutdown(); } catch { /* best-effort teardown */ } + }); + + const insert = async (object: string, data: Record): Promise => + ((await ql.insert(object, data, { context: SYS })) as any).id; + /** The order's two roll-ups, read from the driver with no engine in between. */ + const rollup = async (id: string): Promise<{ count: number; total: number }> => { + const [row] = (await driver.find(ORDER.name, { where: { id } as FilterCondition })) as any[]; + return { count: Number(row.line_count), total: Number(row.line_total) }; + }; + /** Two orders, `a` with two lines in `grp`, `b` with one line elsewhere. */ + const seed = async (grp: string) => { + const a = await insert(ORDER.name, { title: `a-${grp}` }); + const b = await insert(ORDER.name, { title: `b-${grp}` }); + await insert(LINE.name, { title: 'a1', grp, amount: 10, order: a }); + await insert(LINE.name, { title: 'a2', grp, amount: 32, order: a }); + await insert(LINE.name, { title: 'b1', grp: `${grp}-other`, amount: 5, order: b }); + // Control: the by-id inserts maintained both roll-ups. + expect(await rollup(a)).toEqual({ count: 2, total: 42 }); + expect(await rollup(b)).toEqual({ count: 1, total: 5 }); + return { a, b }; + }; + + it('a predicate delete that runs the referential walk recomputes the parent from the rows it walked', async () => { + const { a, b } = await seed('walk'); + const i1 = await insert(ITEM.name, { title: 'i1', grp: 'walk', order: a }); + await insert(ITEM.name, { title: 'i2', grp: 'walk', order: a }); + await insert(ITEM.name, { title: 'i3', grp: 'walk-other', order: b }); + const note = await insert(NOTE.name, { title: 'n1', item: i1 }); + const items = async (id: string) => + Number(((await driver.find(ORDER.name, { where: { id } as FilterCondition })) as any[])[0].item_count); + expect(await items(a)).toBe(2); + + const removed = await ql.delete(ITEM.name, { where: { grp: 'walk' }, multi: true, context: SYS }); + expect(removed).toBe(2); + expect(await items(a)).toBe(0); + expect(await items(b)).toBe(1); + // The walk ran: the note's lookup to a deleted item is cleared. + const [noteRow] = (await driver.find(NOTE.name, { where: { id: note } as FilterCondition })) as any[]; + expect(noteRow.item ?? null).toBeNull(); + }); + + function bulkFlow(name: string, node: Record) { + return { + name, label: name, type: 'autolaunched', runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'bulk', label: 'Bulk', ...node }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'bulk' }, + { id: 'e2', source: 'bulk', target: 'end' }, + ], + }; + } + + it('a predicate delete of both of one order\'s lines brings its count and total to 0', async () => { + const { a, b } = await seed('del'); + const removed = await ql.delete(LINE.name, { where: { grp: 'del' }, multi: true, context: SYS }); + expect(removed).toBe(2); + expect(await rollup(a)).toEqual({ count: 0, total: 0 }); + expect(await rollup(b), 'an order no matched line names is untouched').toEqual({ count: 1, total: 5 }); + }); + + it('a predicate update that moves both lines to the other order recomputes both orders', async () => { + const { a, b } = await seed('move'); + const affected = await ql.update(LINE.name, { order: b }, { where: { grp: 'move' }, multi: true, context: SYS }); + expect(affected).toBe(2); + expect(await rollup(a), 'the old parent').toEqual({ count: 0, total: 0 }); + expect(await rollup(b), 'the new parent').toEqual({ count: 3, total: 47 }); + }); + + it('a predicate update of the summed field recomputes the parent it stays under', async () => { + const { a, b } = await seed('amt'); + await ql.update(LINE.name, { amount: 1 }, { where: { grp: 'amt' }, multi: true, context: SYS }); + expect(await rollup(a)).toEqual({ count: 2, total: 2 }); + expect(await rollup(b)).toEqual({ count: 1, total: 5 }); + }); + + it('a flow delete_record with multi: true recomputes the order its lines named', async () => { + const { a, b } = await seed('flowdel'); + const flow = bulkFlow('pin_rollup_flow_delete', { + type: 'delete_record', config: { objectName: LINE.name, filter: { grp: 'flowdel' }, multi: true }, + }); + automation.registerFlow(flow.name, flow as any); + const res: any = await automation.execute(flow.name, {}); + expect(res.success, `run failed: ${JSON.stringify(res.error ?? res)}`).toBe(true); + expect(await rollup(a)).toEqual({ count: 0, total: 0 }); + expect(await rollup(b)).toEqual({ count: 1, total: 5 }); + }); + + it('a flow update_record with multi: true that reparents the lines recomputes both orders', async () => { + const { a, b } = await seed('flowmove'); + const flow = bulkFlow('pin_rollup_flow_update', { + type: 'update_record', config: { objectName: LINE.name, filter: { grp: 'flowmove' }, fields: { order: b }, multi: true }, + }); + automation.registerFlow(flow.name, flow as any); + const res: any = await automation.execute(flow.name, {}); + expect(res.success, `run failed: ${JSON.stringify(res.error ?? res)}`).toBe(true); + expect(await rollup(a)).toEqual({ count: 0, total: 0 }); + expect(await rollup(b)).toEqual({ count: 3, total: 47 }); + }); +});