Skip to content

Commit 5185218

Browse files
committed
feat(objectql): filtered roll-up summary fields (#1868)
`summaryOperations` gains an optional `filter` — a query `where` FilterCondition evaluated against each child row — so a roll-up `summary` field aggregates only the matching children instead of the whole child collection. This is the piece the cross-object rollup templates were missing: it lets a single child object feed several distinct parent totals (e.g. content_publication.total_signups vs total_clicks over one engagement child, or procurement_order.received_amount summing only received receipt lines in a 3-way match). The engine ANDs the predicate with the parent-FK match when it recomputes, and because the whole filtered aggregate is re-run on every child insert/update/delete, a child that moves in or out of the predicate (a status change) keeps the parent current with no extra wiring. Operator and compound filter forms work too. Purely additive: omitting `filter` aggregates every child exactly as before. - spec: add `summaryOperations.filter` (FilterCondition) + tests; regen reference doc; note the sub-key in the liveness ledger - objectql: thread the filter through SummaryDescriptor / buildSummaryIndex / recomputeSummaries; tests for filtered sum/count, $in/$gte compounds, and in/out-of-filter recompute on update & delete - docs: document `filter` in field-types.mdx and the objectstack-data relationships skill; fix a stale non-canonical summary example in that skill Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HbK4FqcHwp9jSwdhTtxYuC
1 parent 06cb319 commit 5185218

9 files changed

Lines changed: 260 additions & 9 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
---
5+
6+
feat(objectql): roll-up `summary` fields can filter which child rows they aggregate (#1868)
7+
8+
`summaryOperations` gains an optional `filter` — a query `where` FilterCondition
9+
evaluated against each child row, so a summary aggregates only the matching
10+
children instead of the whole collection. This is what lets a single child object
11+
feed several distinct parent totals, which the cross-object rollup templates need:
12+
13+
```typescript
14+
// One `engagement` child → distinct filtered totals.
15+
total_signups: {
16+
type: 'summary',
17+
summaryOperations: { object: 'engagement', field: 'id', function: 'count', filter: { type: 'signup' } },
18+
}
19+
// Sum only received receipt lines (3-way match).
20+
received_amount: {
21+
type: 'summary',
22+
summaryOperations: { object: 'procurement_receipt', field: 'amount', function: 'sum', filter: { status: 'received' } },
23+
}
24+
```
25+
26+
The engine ANDs the predicate with the parent-FK match when it recomputes, and
27+
because the whole filtered aggregate is re-run on every child write, a child that
28+
moves in or out of the predicate (e.g. a status change) keeps the parent current
29+
with no extra wiring. Operator and compound forms work too
30+
(`filter: { type: { $in: ['signup', 'trial'] }, amount: { $gte: 100 } }`).
31+
32+
Purely additive: omitting `filter` aggregates every child exactly as before.

content/docs/data-modeling/field-types.mdx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@ Roll-up summary from child records.
447447
| `summaryOperations.field` | `string` | **required** | Child field to aggregate; ignored for `count` |
448448
| `summaryOperations.function` | `enum` | **required** | `count`, `sum`, `avg`, `min`, or `max` |
449449
| `summaryOperations.relationshipField` | `string` | auto | Child FK back to the parent when it cannot be inferred |
450+
| `summaryOperations.filter` | `FilterCondition` || Only child rows matching this `where` predicate are aggregated |
450451

451452
```typescript
452453
{
@@ -462,6 +463,32 @@ Roll-up summary from child records.
462463
}
463464
```
464465
466+
Add a `filter` to aggregate only a subset of children — this is how one child
467+
object feeds several different totals. The engine ANDs the predicate with the
468+
parent-FK match and recomputes on the child's next write, so a row moving in or
469+
out of the filter (a status change) keeps the parent current:
470+
471+
```typescript
472+
// Sum only received receipt lines (3-way match), not every line.
473+
{
474+
name: 'received_amount',
475+
type: 'summary',
476+
summaryOperations: {
477+
object: 'procurement_receipt',
478+
field: 'amount',
479+
function: 'sum',
480+
filter: { status: 'received' },
481+
},
482+
}
483+
484+
// One `engagement` child → distinct totals, differentiated only by the filter.
485+
{
486+
name: 'total_signups',
487+
type: 'summary',
488+
summaryOperations: { object: 'engagement', field: 'id', function: 'count', filter: { type: 'signup' } },
489+
}
490+
```
491+
465492
### `autonumber`
466493
Auto-incrementing number with format template.
467494

content/docs/references/data/field.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ const result = Address.parse(data);
132132
| **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. |
133133
| **expression** | `string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Formula expression (CEL). e.g. F`record.amount * 0.1` |
134134
| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date'>` | optional | Inferred value type of a formula field (number/text/boolean/date) |
135-
| **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
135+
| **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
136136
| **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |
137137
| **step** | `number` | optional | Step increment for slider (default: 1) |
138138
| **currencyConfig** | `{ precision?: integer; currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type |

packages/objectql/src/engine.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,12 @@ interface SummaryDescriptor {
283283
fn: 'count' | 'sum' | 'min' | 'max' | 'avg';
284284
/** Child field aggregated (unused for count). */
285285
sourceField: string;
286+
/**
287+
* Optional predicate (a query `where` FilterCondition) restricting which child
288+
* rows are aggregated. ANDed with the parent-FK match when the aggregate runs.
289+
* Undefined ⇒ aggregate every child of the parent.
290+
*/
291+
filter?: Record<string, unknown>;
286292
}
287293

288294
export class ObjectQL implements IDataEngine {
@@ -1841,8 +1847,14 @@ export class ObjectQL implements IDataEngine {
18411847
}
18421848
}
18431849
if (!fkField) continue; // can't resolve the relationship — skip
1850+
// Optional per-summary predicate: only child rows matching it are
1851+
// aggregated (e.g. sum receipts where { status: 'received' }). ANDed with
1852+
// the parent-FK match at recompute time. Ignore a non-object filter.
1853+
const filter = so.filter && typeof so.filter === 'object' && !Array.isArray(so.filter)
1854+
? so.filter as Record<string, unknown>
1855+
: undefined;
18441856
const list = index.get(childObject) ?? [];
1845-
list.push({ parentObject: parent.name, summaryField, fkField, fn, sourceField: so.field });
1857+
list.push({ parentObject: parent.name, summaryField, fkField, fn, sourceField: so.field, filter });
18461858
index.set(childObject, list);
18471859
}
18481860
}
@@ -1882,8 +1894,12 @@ export class ObjectQL implements IDataEngine {
18821894
// aggregate/update) with backoff — a network blip here used to leave
18831895
// the parent summary silently stale (framework#3147).
18841896
await withTransientRetry(async () => {
1897+
// AND the parent-FK match with the optional per-summary filter so
1898+
// only matching child rows are aggregated (e.g. received receipts).
1899+
const fkMatch = { [desc.fkField]: parentId };
1900+
const where = desc.filter ? { $and: [fkMatch, desc.filter] } : fkMatch;
18851901
const rows = await this.aggregate(childObject, {
1886-
where: { [desc.fkField]: parentId },
1902+
where,
18871903
aggregations: [{
18881904
function: desc.fn,
18891905
...(desc.fn === 'count' ? {} : { field: desc.sourceField }),

packages/objectql/src/summary-rollup.test.ts

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,35 @@ function makeDriver() {
1414
if (!s) { s = new Map(); stores.set(o, s); }
1515
return s;
1616
};
17+
// Minimal FilterCondition matcher: implicit equality, a few operators, and the
18+
// logical `$and`/`$or`/`$not` the engine emits when a summary carries a filter
19+
// (`{ $and: [{ fk: parentId }, <filter> }] }`). Enough to exercise the merge.
20+
const checkOp = (value: any, cond: any): boolean => {
21+
if (cond === null || typeof cond !== 'object' || Array.isArray(cond) || cond instanceof Date) {
22+
return value === cond;
23+
}
24+
return Object.entries(cond).every(([op, target]: [string, any]) => {
25+
switch (op) {
26+
case '$eq': return value === target;
27+
case '$ne': return value !== target;
28+
case '$gt': return value > target;
29+
case '$gte': return value >= target;
30+
case '$lt': return value < target;
31+
case '$lte': return value <= target;
32+
case '$in': return Array.isArray(target) && target.includes(value);
33+
case '$nin': return Array.isArray(target) && !target.includes(value);
34+
default: return true;
35+
}
36+
});
37+
};
1738
const matches = (row: any, where: any): boolean => {
1839
if (!where || typeof where !== 'object') return true;
19-
return Object.entries(where).every(([k, v]) => row?.[k] === v);
40+
return Object.entries(where).every(([k, v]: [string, any]) => {
41+
if (k === '$and') return (v as any[]).every((w) => matches(row, w));
42+
if (k === '$or') return (v as any[]).some((w) => matches(row, w));
43+
if (k === '$not') return !matches(row, v);
44+
return checkOp(row?.[k], v);
45+
});
2046
};
2147
let n = 0;
2248
const driver: any = {
@@ -123,3 +149,91 @@ describe('roll-up summary fields', () => {
123149
expect(parent(b.id).line_total).toBe(3);
124150
});
125151
});
152+
153+
describe('roll-up summary fields with a filter predicate', () => {
154+
let engine: ObjectQL;
155+
let storeFor: ReturnType<typeof makeDriver>['storeFor'];
156+
157+
beforeEach(async () => {
158+
engine = new ObjectQL();
159+
const d = makeDriver();
160+
storeFor = d.storeFor;
161+
engine.registerDriver(d.driver, true);
162+
await engine.init();
163+
// A publication rolls up ONE child object (`engagement`) into several totals,
164+
// each differentiated only by a `filter` — the shape the issue's
165+
// content_publication.total_views/clicks/signups fields need.
166+
engine.registry.registerObject({
167+
name: 'publication',
168+
fields: {
169+
name: { type: 'text' },
170+
total_events: { type: 'summary', summaryOperations: { object: 'engagement', field: 'id', function: 'count' } },
171+
total_signups: { type: 'summary', summaryOperations: { object: 'engagement', field: 'id', function: 'count', filter: { type: 'signup' } } },
172+
total_revenue: { type: 'summary', summaryOperations: { object: 'engagement', field: 'amount', function: 'sum', filter: { type: 'signup' } } },
173+
premium_signups: { type: 'summary', summaryOperations: { object: 'engagement', field: 'id', function: 'count', filter: { type: { $in: ['signup', 'trial'] }, amount: { $gte: 100 } } } },
174+
},
175+
} as any);
176+
engine.registry.registerObject({
177+
name: 'engagement',
178+
fields: {
179+
type: { type: 'text' },
180+
amount: { type: 'number' },
181+
publication: { type: 'master_detail', reference: 'publication' },
182+
},
183+
} as any);
184+
});
185+
186+
const pub = (id: string) => storeFor('publication').get(id);
187+
188+
it('aggregates only the child rows matching the filter', async () => {
189+
const p = await engine.insert('publication', { name: 'POST-1' });
190+
await engine.insert('engagement', { publication: p.id, type: 'view', amount: 0 });
191+
await engine.insert('engagement', { publication: p.id, type: 'view', amount: 0 });
192+
await engine.insert('engagement', { publication: p.id, type: 'signup', amount: 30 });
193+
await engine.insert('engagement', { publication: p.id, type: 'signup', amount: 50 });
194+
195+
expect(pub(p.id).total_events).toBe(4); // unfiltered
196+
expect(pub(p.id).total_signups).toBe(2); // filter: type == signup
197+
expect(pub(p.id).total_revenue).toBe(80); // sum(amount) where type == signup
198+
});
199+
200+
it('honours operator/compound filters ($in + $gte)', async () => {
201+
const p = await engine.insert('publication', { name: 'POST-2' });
202+
await engine.insert('engagement', { publication: p.id, type: 'signup', amount: 40 }); // amount < 100
203+
await engine.insert('engagement', { publication: p.id, type: 'signup', amount: 150 }); // matches
204+
await engine.insert('engagement', { publication: p.id, type: 'trial', amount: 200 }); // matches
205+
await engine.insert('engagement', { publication: p.id, type: 'view', amount: 999 }); // wrong type
206+
207+
expect(pub(p.id).premium_signups).toBe(2);
208+
});
209+
210+
it('recomputes when a child moves in/out of the filter via an update', async () => {
211+
const p = await engine.insert('publication', { name: 'POST-3' });
212+
const e = await engine.insert('engagement', { publication: p.id, type: 'view', amount: 25 });
213+
expect(pub(p.id).total_signups).toBe(0);
214+
expect(pub(p.id).total_revenue).toBe(0);
215+
216+
// Reclassify the same row as a signup — it now enters the filtered rollups.
217+
await engine.update('engagement', { id: e.id, type: 'signup' });
218+
expect(pub(p.id).total_signups).toBe(1);
219+
expect(pub(p.id).total_revenue).toBe(25);
220+
221+
// And back out again.
222+
await engine.update('engagement', { id: e.id, type: 'view' });
223+
expect(pub(p.id).total_signups).toBe(0);
224+
expect(pub(p.id).total_revenue).toBe(0);
225+
});
226+
227+
it('recomputes the filtered rollup when a matching child is deleted', async () => {
228+
const p = await engine.insert('publication', { name: 'POST-4' });
229+
const e = await engine.insert('engagement', { publication: p.id, type: 'signup', amount: 60 });
230+
await engine.insert('engagement', { publication: p.id, type: 'view', amount: 0 });
231+
expect(pub(p.id).total_signups).toBe(1);
232+
expect(pub(p.id).total_revenue).toBe(60);
233+
234+
await engine.delete('engagement', { where: { id: e.id } });
235+
expect(pub(p.id).total_signups).toBe(0);
236+
expect(pub(p.id).total_revenue).toBe(0);
237+
expect(pub(p.id).total_events).toBe(1); // the unfiltered count still sees the view
238+
});
239+
});

packages/spec/liveness/field.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
"summaryOperations": {
6666
"status": "live",
6767
"evidence": "packages/objectql/src/engine.ts",
68-
"note": "rollup {object,field,function,relationshipField}."
68+
"note": "rollup {object,field,function,relationshipField,filter}. `filter` (a query `where` FilterCondition) is read by buildSummaryIndex and ANDed with the parent-FK match in recomputeSummaries, so one child object can feed several filtered totals."
6969
},
7070
"requiredWhen": {
7171
"status": "live",

packages/spec/src/data/field.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ describe('FieldSchema', () => {
382382

383383
it('should accept all summary functions', () => {
384384
const functions = ['count', 'sum', 'min', 'max', 'avg'] as const;
385-
385+
386386
functions.forEach(fn => {
387387
const field: Field = {
388388
name: 'test_summary',
@@ -398,6 +398,40 @@ describe('FieldSchema', () => {
398398
expect(() => FieldSchema.parse(field)).not.toThrow();
399399
});
400400
});
401+
402+
it('should accept a summary field with a filter predicate', () => {
403+
const field: Field = {
404+
name: 'received_amount',
405+
label: 'Received Amount',
406+
type: 'summary',
407+
summaryOperations: {
408+
object: 'procurement_receipt',
409+
field: 'amount',
410+
function: 'sum',
411+
filter: { status: 'received' },
412+
},
413+
};
414+
415+
const parsed = FieldSchema.parse(field);
416+
expect(parsed.summaryOperations?.filter).toEqual({ status: 'received' });
417+
});
418+
419+
it('should accept a filtered count with operator syntax and an explicit relationship field', () => {
420+
const field: Field = {
421+
name: 'signup_count',
422+
label: 'Signups',
423+
type: 'summary',
424+
summaryOperations: {
425+
object: 'engagement',
426+
field: 'id',
427+
function: 'count',
428+
relationshipField: 'publication_id',
429+
filter: { type: { $in: ['signup', 'trial'] } },
430+
},
431+
};
432+
433+
expect(() => FieldSchema.parse(field)).not.toThrow();
434+
});
401435
});
402436

403437
describe('Security and Visibility', () => {

packages/spec/src/data/field.zod.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { z } from 'zod';
44
import { SystemIdentifierSchema } from '../shared/identifiers.zod';
55
import { ExpressionInputSchema } from '../shared/expression.zod';
6+
import { FilterConditionSchema } from './filter.zod';
67

78
/**
89
* Field Type Enum
@@ -402,6 +403,19 @@ export const FieldSchema = lazySchema(() => z.object({
402403
field: z.string().describe('Field on child object to aggregate (ignored for count)'),
403404
function: z.enum(['count', 'sum', 'min', 'max', 'avg']).describe('Aggregation function to apply'),
404405
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.'),
406+
/**
407+
* Optional predicate that restricts WHICH child rows are aggregated — a
408+
* FilterCondition (the same object DSL as a query `where`), evaluated against
409+
* each child row. Omit to aggregate every child. This is what lets several
410+
* summaries roll up the SAME child object into different totals — e.g.
411+
* `content_publication.total_signups` = count of engagement rows where
412+
* `{ type: 'signup' }` vs `total_clicks` where `{ type: 'click' }`, or
413+
* `procurement_order.received_amount` = sum of receipt lines where
414+
* `{ status: 'received' }`. The engine ANDs it with the parent-FK match, so
415+
* a child moving in or out of the predicate (a status change) recomputes the
416+
* parent on its next write like any other child update.
417+
*/
418+
filter: FilterConditionSchema.optional().describe("Predicate restricting which child rows are aggregated (a query `where` FilterCondition, e.g. { status: 'received' } or { type: { $in: ['signup','trial'] } }). Omit to aggregate all children. Lets one child object feed multiple filtered roll-ups."),
405419
}).optional().describe('Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted.'),
406420

407421
/** Enhanced Field Type Configurations */

skills/objectstack-data/rules/relationships.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,11 @@ export default ObjectSchema.create({
6363
invoice_number: { type: 'text', required: true },
6464
total: {
6565
type: 'summary',
66-
reference: 'invoice_line_item',
67-
summaryType: 'sum',
68-
summaryField: 'amount',
66+
summaryOperations: {
67+
object: 'invoice_line_item',
68+
field: 'amount',
69+
function: 'sum',
70+
},
6971
},
7072
}
7173
});
@@ -181,6 +183,8 @@ transaction as the write, so it's consistent and never summed on the client).
181183
// relationshipField: 'invoice' // optional; auto-detected from the child's
182184
// master_detail/lookup field referencing
183185
// this parent when omitted
186+
// filter: { status: 'received' } // optional; only children matching this
187+
// `where` predicate are aggregated
184188
},
185189
}
186190
```
@@ -189,6 +193,16 @@ Empty collections roll up to `0` for `count`/`sum`, `null` for `min`/`max`/`avg`
189193
Pairs naturally with inline editing (below): the parent total updates atomically
190194
as line items are saved.
191195
196+
Add `filter` (a query `where` FilterCondition) to aggregate only a **subset** of
197+
the children — this is how a single child object feeds several different totals.
198+
For example one `engagement` child yields `total_signups`
199+
(`filter: { type: 'signup' }`) and `total_clicks` (`filter: { type: 'click' }`),
200+
and a `procurement_receipt` child yields `received_amount`
201+
(`function: 'sum', filter: { status: 'received' }`). The predicate is ANDed with
202+
the parent-FK match; a child moving in or out of it (e.g. a status change)
203+
recomputes the parent on its next write like any other child update. Operator
204+
and compound forms work too (`filter: { type: { $in: ['signup','trial'] } }`).
205+
192206
## Inline Editing (Master-Detail Entry)
193207
194208
Declare inline editing **on the relationship**, in the data model — not in a form

0 commit comments

Comments
 (0)