Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/summary-rollup-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@objectstack/spec': minor
'@objectstack/objectql': minor
---

feat(objectql): compute roll-up `summary` fields server-side

The `summary` field type was declared in the spec but never computed — its value
stayed empty. ObjectQL now recomputes roll-up summaries automatically: a parent
field whose `summaryOperations` aggregates (`count`/`sum`/`min`/`max`/`avg`) a
field across child records is recalculated whenever a child is inserted,
updated, or deleted.

- **`@objectstack/spec`** — `summaryOperations` gains an optional
`relationshipField` (the child→parent FK). When omitted the engine
auto-detects it from the child's `lookup`/`master_detail` field whose
`reference` points back at the parent; set it explicitly only when the child
has more than one such reference.

- **`@objectstack/objectql`** — after `afterInsert` / `afterUpdate` /
`afterDelete` on a child object, the engine finds the affected parent (from
the child's FK, plus the prior FK on update/delete so a re-parented child
updates both), re-aggregates the child collection, and writes the result onto
the parent's summary field. It runs in the caller's execution context, so when
a transaction is open (e.g. the cross-object `/api/v1/batch`) the rollup
commits atomically with the child writes. A small index of child→summary
descriptors is built lazily from the registry and invalidated on package
registration.

Empty collections roll up to `0` for `count`/`sum` and `null` for
`min`/`max`/`avg`. This lets master-detail forms stop computing parent totals on
the client — the server is now the single source of truth.
2 changes: 1 addition & 1 deletion .objectui-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
a211acc5d7cda00a176cbd237e15657df170a994
514f426600bcc6284b67909dd7ced7e1bcb1d762
11 changes: 10 additions & 1 deletion examples/app-showcase/src/objects/project.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,16 @@ export const Project = ObjectSchema.create({
start_date: Field.date({ label: 'Start Date' }),
end_date: Field.date({ label: 'Target End Date' }),
owner: Field.text({ label: 'Owner', maxLength: 200 }),
summary: Field.summary({ label: 'Open Tasks' }),
// Roll-up summaries — recomputed server-side whenever a child task is
// inserted / updated / deleted (FK auto-detected: showcase_task.project).
task_count: Field.summary({
label: 'Tasks',
summaryOperations: { object: 'showcase_task', field: 'estimate_hours', function: 'count' },
}),
total_estimate: Field.summary({
label: 'Total Estimate (h)',
summaryOperations: { object: 'showcase_task', field: 'estimate_hours', function: 'sum' },
}),
},

validations: [
Expand Down
128 changes: 128 additions & 0 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,17 @@ function resolveMetadataItemName(key: string, item: any): string | undefined {
* - CoreServiceName.data (CRUD)
* - CoreServiceName.metadata (Schema Registry)
*/
/** A roll-up `summary` field on a parent object that aggregates a child. */
interface SummaryDescriptor {
parentObject: string;
summaryField: string;
/** FK field on the child pointing back to the parent. */
fkField: string;
fn: 'count' | 'sum' | 'min' | 'max' | 'avg';
/** Child field aggregated (unused for count). */
sourceField: string;
}

export class ObjectQL implements IDataEngine {
/**
* Ambient transaction store (ADR-0034). While a `transaction()` callback
Expand Down Expand Up @@ -805,6 +816,7 @@ export class ObjectQL implements IDataEngine {
registerApp(manifest: any) {
const id = manifest.id || manifest.name;
const namespace = manifest.namespace as string | undefined;
this.invalidateSummaryIndex(); // new objects may add/change summary fields
this.logger.debug('Registering package manifest', { id, namespace });
console.warn(`[ObjectQL:registerApp] id=${id} flows=${Array.isArray(manifest.flows) ? manifest.flows.length : typeof manifest.flows} keys=${Object.keys(manifest).join(',')}`);

Expand Down Expand Up @@ -1527,6 +1539,103 @@ export class ObjectQL implements IDataEngine {
* lazily seeded from the current max in the store. */
private readonly autonumberCounters = new Map<string, number>();

/** Lazily-built index: child object name → roll-up summary descriptors on
* parent objects that aggregate it. Invalidated when packages register. */
private summaryIndex: Map<string, SummaryDescriptor[]> | null = null;

/** Invalidate the cached roll-up summary index (call when metadata changes). */
private invalidateSummaryIndex(): void {
this.summaryIndex = null;
}

/** Scan all registered objects for `summary` fields and index them by the
* child object they aggregate, resolving the child→parent FK field. */
private buildSummaryIndex(): Map<string, SummaryDescriptor[]> {
const index = new Map<string, SummaryDescriptor[]>();
let objects: any[] = [];
try { objects = (this._registry as any).getAllObjects?.() ?? []; } catch { objects = []; }
for (const parent of objects) {
const fields = parent?.fields;
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) continue;
for (const [summaryField, def] of Object.entries(fields)) {
const d: any = def;
if (d?.type !== 'summary' || !d.summaryOperations) continue;
const so = d.summaryOperations;
const childObject = so.object;
const fn = so.function;
if (!childObject || !fn) continue;
// Resolve the FK on the child pointing back to this parent.
let fkField: string | undefined = so.relationshipField;
if (!fkField) {
const child = this._registry.getObject(childObject) as any;
const cfields = child?.fields || {};
for (const [cfName, cdef] of Object.entries(cfields)) {
const cd: any = cdef;
if ((cd?.type === 'master_detail' || cd?.type === 'lookup') && cd?.reference === parent.name) {
fkField = cfName;
break;
}
}
}
if (!fkField) continue; // can't resolve the relationship — skip
const list = index.get(childObject) ?? [];
list.push({ parentObject: parent.name, summaryField, fkField, fn, sourceField: so.field });
index.set(childObject, list);
}
}
return index;
}

private getSummaryDescriptors(childObject: string): SummaryDescriptor[] {
if (!this.summaryIndex) this.summaryIndex = this.buildSummaryIndex();
return this.summaryIndex.get(childObject) ?? [];
}

/**
* Recompute roll-up `summary` fields on parent records after a child write.
* For each affected parent (the FK value on the changed/old child record), it
* aggregates the child collection and writes the result onto the parent's
* summary field. Runs in the caller's execution context so it joins the same
* transaction (e.g. the cross-object batch) when one is open.
*/
private async recomputeSummaries(
childObject: string,
records: any,
previous: any,
execCtx?: ExecutionContext,
): Promise<void> {
const descriptors = this.getSummaryDescriptors(childObject);
if (descriptors.length === 0) return;
const recs = Array.isArray(records) ? records : records ? [records] : [];
const prevs = Array.isArray(previous) ? previous : previous ? [previous] : [];
for (const desc of descriptors) {
const ids = new Set<string>();
for (const r of recs) { const v = r?.[desc.fkField]; if (v != null && v !== '') ids.add(String(v)); }
for (const p of prevs) { const v = p?.[desc.fkField]; if (v != null && v !== '') ids.add(String(v)); }
for (const parentId of ids) {
try {
const rows = await this.aggregate(childObject, {
where: { [desc.fkField]: parentId },
aggregations: [{
function: desc.fn,
...(desc.fn === 'count' ? {} : { field: desc.sourceField }),
alias: 'value',
}],
context: execCtx,
} as any);
let value = rows?.[0]?.value;
if (value == null) value = (desc.fn === 'count' || desc.fn === 'sum') ? 0 : null;
await this.update(desc.parentObject, { id: parentId, [desc.summaryField]: value }, { context: execCtx } as any);
} catch (err) {
this.logger.warn('Roll-up summary recompute failed', {
childObject, parentObject: desc.parentObject, parentId, field: desc.summaryField,
error: (err as any)?.message,
});
}
}
}
}

/**
* Post-process expand: resolve lookup/master_detail fields by batch-loading related records.
*
Expand Down Expand Up @@ -1885,6 +1994,9 @@ export class ObjectQL implements IDataEngine {
hookContext.result = result;
await this.triggerHooks('afterInsert', hookContext);

// Roll-up: recompute parent summary fields that aggregate this object.
await this.recomputeSummaries(object, result, null, opCtx.context);

// Publish data.record.created event to realtime service
if (this.realtimeService) {
try {
Expand Down Expand Up @@ -2016,6 +2128,10 @@ export class ObjectQL implements IDataEngine {
if (priorRecord) hookContext.previous = priorRecord;
await this.triggerHooks('afterUpdate', hookContext);

// Roll-up: recompute parent summaries; pass priorRecord too so a child
// that moved to a different parent updates BOTH old and new parent.
await this.recomputeSummaries(object, result, priorRecord, opCtx.context);

// Publish data.record.updated event to realtime service
if (this.realtimeService) {
try {
Expand Down Expand Up @@ -2158,6 +2274,15 @@ export class ObjectQL implements IDataEngine {

try {
let result;
// Capture the row's FK values BEFORE deletion so roll-up summaries can
// recompute the (now-orphaned) parent. Only when a summary aggregates
// this object — avoids an extra read on every delete.
let summaryPrev: any = null;
if (hookContext.input.id && this.getSummaryDescriptors(object).length > 0) {
try {
summaryPrev = await this.findOne(object, { where: { id: hookContext.input.id }, context: opCtx.context } as any);
} catch { /* best-effort */ }
}
if (hookContext.input.id) {
// Honor referential delete behavior (cascade/set_null/restrict)
// for relations pointing at this record before removing it.
Expand All @@ -2174,6 +2299,9 @@ export class ObjectQL implements IDataEngine {
hookContext.result = result;
await this.triggerHooks('afterDelete', hookContext);

// Roll-up: recompute the parent summary now that the child is gone.
if (summaryPrev) await this.recomputeSummaries(object, null, summaryPrev, opCtx.context);

// Publish data.record.deleted event to realtime service
if (this.realtimeService) {
try {
Expand Down
125 changes: 125 additions & 0 deletions packages/objectql/src/summary-rollup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
//
// Roll-up `summary` fields: a parent field whose value is an aggregate over a
// child collection (SUM/COUNT/...). The engine must recompute it whenever a
// child record is inserted / updated / deleted.

import { describe, it, expect, beforeEach } from 'vitest';
import { ObjectQL } from './engine.js';

function makeDriver() {
const stores = new Map<string, Map<string, any>>();
const storeFor = (o: string) => {
let s = stores.get(o);
if (!s) { s = new Map(); stores.set(o, s); }
return s;
};
const matches = (row: any, where: any): boolean => {
if (!where || typeof where !== 'object') return true;
return Object.entries(where).every(([k, v]) => row?.[k] === v);
};
let n = 0;
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));
},
findStream() { throw new Error('ni'); },
async findOne(object: string, ast: any) {
for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r;
return null;
},
async create(object: string, data: Record<string, unknown>) {
n += 1;
const id = (data.id as string) ?? `r_${n}`;
const row = { ...data, id };
storeFor(object).set(id, row);
return row;
},
async update(object: string, id: string, data: Record<string, unknown>) {
const s = storeFor(object);
const row = { ...s.get(id), ...data, id };
s.set(id, row);
return row;
},
async delete(object: string, id: string) { return storeFor(object).delete(id); },
async count() { return 0; },
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
return Promise.all(rows.map((r) => this.create(object, r, undefined)));
},
async bulkUpdate() { return []; }, async bulkDelete() {},
async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; },
async commit() {}, async rollback() {},
};
return { driver, storeFor };
}

describe('roll-up summary fields', () => {
let engine: ObjectQL;
let storeFor: ReturnType<typeof makeDriver>['storeFor'];

beforeEach(async () => {
engine = new ObjectQL();
const d = makeDriver();
storeFor = d.storeFor;
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: {
amount: { type: 'number' },
inv: { type: 'master_detail', reference: 'inv' },
},
} as any);
});

const parent = (id: string) => storeFor('inv').get(id);

it('computes SUM and COUNT on the parent as children are inserted', async () => {
const p = await engine.insert('inv', { name: 'INV-1' });
await engine.insert('inv_line', { inv: p.id, amount: 10 });
await engine.insert('inv_line', { inv: p.id, amount: 32 });

expect(parent(p.id).line_total).toBe(42);
expect(parent(p.id).line_count).toBe(2);
});

it('recomputes when a child amount is updated', async () => {
const p = await engine.insert('inv', { name: 'INV-2' });
const l1 = await engine.insert('inv_line', { inv: p.id, amount: 10 });
await engine.insert('inv_line', { inv: p.id, amount: 5 });
expect(parent(p.id).line_total).toBe(15);

await engine.update('inv_line', { id: l1.id, amount: 100 });
expect(parent(p.id).line_total).toBe(105);
});

it('recomputes when a child is deleted (down to 0 with no children)', async () => {
const p = await engine.insert('inv', { name: 'INV-3' });
const l1 = await engine.insert('inv_line', { inv: p.id, amount: 10 });
expect(parent(p.id).line_total).toBe(10);

await engine.delete('inv_line', { where: { id: l1.id } });
expect(parent(p.id).line_total).toBe(0);
expect(parent(p.id).line_count).toBe(0);
});

it('only recomputes the affected parent', 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, amount: 7 });
await engine.insert('inv_line', { inv: b.id, amount: 3 });

expect(parent(a.id).line_total).toBe(7);
expect(parent(b.id).line_total).toBe(3);
});
});
5 changes: 3 additions & 2 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,9 +408,10 @@ export const FieldSchema = lazySchema(() => z.object({
expression: ExpressionInputSchema.optional().describe('Formula expression (CEL). e.g. F`record.amount * 0.1`'),
summaryOperations: z.object({
object: z.string().describe('Source child object name for roll-up'),
field: z.string().describe('Field on child object to aggregate'),
field: z.string().describe('Field on child object to aggregate (ignored for count)'),
function: z.enum(['count', 'sum', 'min', 'max', 'avg']).describe('Aggregation function to apply'),
}).optional().describe('Roll-up summary definition'),
relationshipField: z.string().optional().describe('FK field on the child pointing back to this parent. Auto-detected from the child\'s lookup/master_detail field referencing this object when omitted; set explicitly only when the child has more than one such reference.'),
}).optional().describe('Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted.'),

/** Enhanced Field Type Configurations */
// Code field config
Expand Down