diff --git a/.changeset/summary-rollup-filter.md b/.changeset/summary-rollup-filter.md new file mode 100644 index 0000000000..a97e2b0800 --- /dev/null +++ b/.changeset/summary-rollup-filter.md @@ -0,0 +1,32 @@ +--- +'@objectstack/spec': minor +'@objectstack/objectql': minor +--- + +feat(objectql): roll-up `summary` fields can filter which child rows they aggregate (#1868) + +`summaryOperations` gains an optional `filter` — a query `where` FilterCondition +evaluated against each child row, so a summary aggregates only the matching +children instead of the whole collection. This is what lets a single child object +feed several distinct parent totals, which the cross-object rollup templates need: + +```typescript +// One `engagement` child → distinct filtered totals. +total_signups: { + type: 'summary', + summaryOperations: { object: 'engagement', field: 'id', function: 'count', filter: { type: 'signup' } }, +} +// Sum only received receipt lines (3-way match). +received_amount: { + type: 'summary', + summaryOperations: { object: 'procurement_receipt', field: 'amount', function: 'sum', filter: { status: 'received' } }, +} +``` + +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 write, a child that +moves in or out of the predicate (e.g. a status change) keeps the parent current +with no extra wiring. Operator and compound forms work too +(`filter: { type: { $in: ['signup', 'trial'] }, amount: { $gte: 100 } }`). + +Purely additive: omitting `filter` aggregates every child exactly as before. diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index 5a2164cf10..3c73e321b6 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -447,6 +447,7 @@ Roll-up summary from child records. | `summaryOperations.field` | `string` | **required** | Child field to aggregate; ignored for `count` | | `summaryOperations.function` | `enum` | **required** | `count`, `sum`, `avg`, `min`, or `max` | | `summaryOperations.relationshipField` | `string` | auto | Child FK back to the parent when it cannot be inferred | +| `summaryOperations.filter` | `FilterCondition` | — | Only child rows matching this `where` predicate are aggregated | ```typescript { @@ -462,6 +463,32 @@ Roll-up summary from child records. } ``` +Add a `filter` to aggregate only a subset of children — this is how one child +object feeds several different totals. The engine ANDs the predicate with the +parent-FK match and recomputes on the child's next write, so a row moving in or +out of the filter (a status change) keeps the parent current: + +```typescript +// Sum only received receipt lines (3-way match), not every line. +{ + name: 'received_amount', + type: 'summary', + summaryOperations: { + object: 'procurement_receipt', + field: 'amount', + function: 'sum', + filter: { status: 'received' }, + }, +} + +// One `engagement` child → distinct totals, differentiated only by the filter. +{ + name: 'total_signups', + type: 'summary', + summaryOperations: { object: 'engagement', field: 'id', function: 'count', filter: { type: 'signup' } }, +} +``` + ### `autonumber` Auto-incrementing number with format template. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 4ae53eabb6..abe7245952 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -132,7 +132,7 @@ const result = Address.parse(data); | **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. | | **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` | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date'>` | optional | Inferred value type of a formula field (number/text/boolean/date) | -| **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. | +| **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. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | | **currencyConfig** | `{ precision?: integer; currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | diff --git a/examples/app-showcase/src/data/objects/expense-report.object.ts b/examples/app-showcase/src/data/objects/expense-report.object.ts new file mode 100644 index 0000000000..e958c93273 --- /dev/null +++ b/examples/app-showcase/src/data/objects/expense-report.object.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * Expense Report + Expense Line — the canonical demonstration of **filtered** + * roll-up `summary` fields (framework#1868). + * + * A plain `summary` rolls up EVERY child row (see `showcase_invoice.total`). + * The power added by `summaryOperations.filter` is that ONE child object can + * feed SEVERAL different parent totals, each aggregating only the child rows + * that match a predicate — something that was impossible to express before and + * had to be hand-maintained (and drifted the moment a real child row was added). + * + * Here a single `showcase_expense_line` child feeds six rollups on the report: + * + * • total_amount SUM(amount) — every line + * • approved_amount SUM(amount) WHERE status=approved — filtered sum (equality) + * • reimbursable_amount SUM(amount) WHERE billable=true — filtered sum (boolean) + * • line_count COUNT — every line + * • rejected_count COUNT WHERE status=rejected — filtered count (equality) + * • over_limit_count COUNT WHERE amount>=500 — filtered count (operator) + * + * The engine keeps all six current server-side as lines are inserted / updated + * / deleted. Because each recompute re-runs the whole filtered aggregate, a + * line moving in or out of a predicate — e.g. a manager flipping one line's + * `status` from `submitted` to `approved` in the inline grid — updates the + * relevant totals on that write with no extra wiring. That is the interactive + * story to drive in the running app: edit line statuses and watch + * approved/rejected/reimbursable diverge from the unfiltered total. + */ +export const ExpenseReport = ObjectSchema.create({ + name: 'showcase_expense_report', + // [ADR-0090 D1] grandfather stamp: world-writable demo object so any seeded + // persona (and the browser e2e) can create/edit reports without a bespoke + // permission set. Belonging to no permission set, it is intentionally absent + // from the access-matrix snapshot (cf. showcase_cascade). + sharingModel: 'public_read_write', + label: 'Expense Report', + pluralLabel: 'Expense Reports', + icon: 'wallet', + description: 'An expense report whose parent totals are filtered roll-ups of its line items.', + + fields: { + name: Field.text({ label: 'Report Title', required: true, searchable: true, maxLength: 120 }), + employee: Field.text({ label: 'Employee', searchable: true, maxLength: 120 }), + status: Field.select({ + label: 'Status', + required: true, + options: [ + { label: 'Draft', value: 'draft', default: true, color: '#94A3B8' }, + { label: 'Submitted', value: 'submitted', color: '#3B82F6' }, + { label: 'Approved', value: 'approved', color: '#10B981' }, + { label: 'Reimbursed', value: 'reimbursed', color: '#6366F1' }, + ], + }), + submitted_on: Field.date({ label: 'Submitted On' }), + + // ── Filtered roll-ups (the point of this object) ───────────────────────── + // Child FK is auto-detected (showcase_expense_line.expense_report), so none + // of these need `relationshipField`. + + /** Baseline: unfiltered — the grand total of every line. */ + total_amount: Field.summary({ + label: 'Total', + summaryOperations: { object: 'showcase_expense_line', field: 'amount', function: 'sum' }, + }), + /** Filtered SUM (equality) — only lines a manager has approved. */ + approved_amount: Field.summary({ + label: 'Approved', + summaryOperations: { + object: 'showcase_expense_line', + field: 'amount', + function: 'sum', + filter: { status: 'approved' }, + }, + }), + /** Filtered SUM (boolean) — only lines billable back to a client. */ + reimbursable_amount: Field.summary({ + label: 'Reimbursable', + summaryOperations: { + object: 'showcase_expense_line', + field: 'amount', + function: 'sum', + filter: { billable: true }, + }, + }), + /** Baseline: unfiltered line count. */ + line_count: Field.summary({ + label: 'Lines', + summaryOperations: { object: 'showcase_expense_line', field: 'amount', function: 'count' }, + }), + /** Filtered COUNT (equality) — lines a manager rejected. */ + rejected_count: Field.summary({ + label: 'Rejected', + summaryOperations: { + object: 'showcase_expense_line', + field: 'amount', + function: 'count', + filter: { status: 'rejected' }, + }, + }), + /** Filtered COUNT (operator) — lines at or above the $500 receipt-required + * threshold. Shows the FilterCondition operator form, not just equality. */ + over_limit_count: Field.summary({ + label: 'Over $500', + summaryOperations: { + object: 'showcase_expense_line', + field: 'amount', + function: 'count', + filter: { amount: { $gte: 500 } }, + }, + }), + }, +}); + +/** Expense line item — owned by its report, entered inline in the grid. */ +export const ExpenseLine = ObjectSchema.create({ + name: 'showcase_expense_line', + label: 'Expense Line', + pluralLabel: 'Expense Lines', + icon: 'receipt', + description: 'A single expense on a report; the parent report rolls these up with filters.', + + // ADR-0055: a line's access is CONTROLLED BY ITS PARENT report — the same + // master-detail pattern as showcase_invoice_line. No RLS authored here; the + // security layer derives it from the required master_detail relationship. + sharingModel: 'controlled_by_parent', + + fields: { + expense_report: Field.masterDetail('showcase_expense_report', { + label: 'Report', + required: true, + deleteBehavior: 'cascade', + // Thin, high-volume line items → the editable grid form factor. The + // report's filtered summaries recompute as rows are saved in this grid. + inlineEdit: 'grid', + inlineTitle: 'Expense Lines', + }), + merchant: Field.text({ label: 'Merchant', required: true, maxLength: 120 }), + category: Field.select({ + label: 'Category', + options: [ + { label: 'Travel', value: 'travel' }, + { label: 'Meals', value: 'meals' }, + { label: 'Lodging', value: 'lodging' }, + { label: 'Supplies', value: 'supplies' }, + { label: 'Software', value: 'software' }, + { label: 'Other', value: 'other', default: true }, + ], + }), + amount: Field.currency({ label: 'Amount', required: true, scale: 2, min: 0 }), + // Whether the expense is billable back to a client — the `billable: true` + // filter on the report's `reimbursable_amount` rollup reads this. + billable: Field.boolean({ label: 'Billable to client', defaultValue: false }), + // Per-line approval status — a manager approves/rejects individual lines, + // which is what the report's `approved_amount` / `rejected_count` filter on. + status: Field.select({ + label: 'Line Status', + required: true, + options: [ + { label: 'Submitted', value: 'submitted', default: true, color: '#3B82F6' }, + { label: 'Approved', value: 'approved', color: '#10B981' }, + { label: 'Rejected', value: 'rejected', color: '#EF4444' }, + ], + }), + incurred_on: Field.date({ label: 'Incurred On' }), + }, +}); diff --git a/examples/app-showcase/src/data/objects/index.ts b/examples/app-showcase/src/data/objects/index.ts index 2487c2b029..d461ed1916 100644 --- a/examples/app-showcase/src/data/objects/index.ts +++ b/examples/app-showcase/src/data/objects/index.ts @@ -7,6 +7,7 @@ export { Category } from './category.object.js'; export { BusinessUnit } from './business-unit.object.js'; export { Team, ProjectMembership } from './team.object.js'; export { Product, Invoice, InvoiceLine } from './invoice.object.js'; +export { ExpenseReport, ExpenseLine } from './expense-report.object.js'; export { FieldZoo } from './field-zoo.object.js'; export { CascadingSelect } from './cascading-select.object.js'; export { Preference } from './preference.object.js'; diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index 819337cc78..dd226b4640 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -10,6 +10,7 @@ import { Category } from '../objects/category.object.js'; import { BusinessUnit } from '../objects/business-unit.object.js'; import { Team, ProjectMembership } from '../objects/team.object.js'; import { Product, Invoice, InvoiceLine } from '../objects/invoice.object.js'; +import { ExpenseReport, ExpenseLine } from '../objects/expense-report.object.js'; import { Contact } from '../objects/contact.object.js'; import { Inquiry } from '../objects/inquiry.object.js'; import { FieldZoo } from '../objects/field-zoo.object.js'; @@ -300,6 +301,41 @@ const invoiceLines = defineSeed(InvoiceLine, { ], }); +// Expense reports + lines — the filtered-rollup demo (framework#1868). Line +// amounts, `billable`, and per-line `status` are chosen so the report's SIX +// summaries show visibly DIFFERENT values on first boot: the unfiltered total +// vs the approved-only sum vs the billable-only sum, etc. Drive the interactive +// story in the app by flipping a line's status in the inline grid and watching +// approved_amount / rejected_count move. Merchants are unique → externalId. +const expenseReports = defineSeed(ExpenseReport, { + mode: 'upsert', + externalId: 'name', + records: [ + { name: 'EXP-2001', employee: 'Ada Lovelace', status: 'submitted', submitted_on: cel`daysAgo(5)` }, + { name: 'EXP-2002', employee: 'Linus Torvalds', status: 'approved', submitted_on: cel`daysAgo(12)` }, + { name: 'EXP-2003', employee: 'Grace Hopper', status: 'draft' }, + ], +}); + +const expenseLines = defineSeed(ExpenseLine, { + mode: 'upsert', + externalId: 'merchant', + records: [ + // EXP-2001 → total 1500.50 · approved 960 · reimbursable 512 · rejected 0 · over$500 2 + { merchant: 'United Airlines', expense_report: 'EXP-2001', category: 'travel', amount: 620, billable: false, status: 'approved', incurred_on: cel`daysAgo(9)` }, + { merchant: 'Marriott Downtown', expense_report: 'EXP-2001', category: 'lodging', amount: 340, billable: false, status: 'approved', incurred_on: cel`daysAgo(8)` }, + { merchant: 'Chipotle', expense_report: 'EXP-2001', category: 'meals', amount: 28.5, billable: false, status: 'submitted', incurred_on: cel`daysAgo(8)` }, + { merchant: 'AWS', expense_report: 'EXP-2001', category: 'software', amount: 512, billable: true, status: 'submitted', incurred_on: cel`daysAgo(7)` }, + // EXP-2002 → total 917 · approved 825 · reimbursable 825 · rejected 1 · over$500 1 + { merchant: 'Delta Air Lines', expense_report: 'EXP-2002', category: 'travel', amount: 780, billable: true, status: 'approved', incurred_on: cel`daysAgo(15)` }, + { merchant: 'Uber', expense_report: 'EXP-2002', category: 'travel', amount: 45, billable: true, status: 'approved', incurred_on: cel`daysAgo(14)` }, + { merchant: 'Staples', expense_report: 'EXP-2002', category: 'supplies', amount: 92, billable: false, status: 'rejected', incurred_on: cel`daysAgo(14)` }, + // EXP-2003 (draft) → total 225.75 · approved 0 · reimbursable 0 · rejected 0 · over$500 0 + { merchant: 'Hilton Garden Inn', expense_report: 'EXP-2003', category: 'lodging', amount: 210, billable: false, status: 'submitted', incurred_on: cel`daysAgo(3)` }, + { merchant: 'Starbucks', expense_report: 'EXP-2003', category: 'meals', amount: 15.75, billable: false, status: 'submitted', incurred_on: cel`daysAgo(2)` }, + ], +}); + // Inquiries so the staff triage list (inquiry views + Contact Form page) // renders on first boot — the "every view renders real data" principle. One // per status; the `closed` row doubles as live prey for InquiryPurgeFlow. @@ -337,4 +373,4 @@ const announcements = defineSeed(Announcement, { ], }); -export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, preferences, announcements]; +export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements]; diff --git a/examples/app-showcase/src/ui/apps/index.ts b/examples/app-showcase/src/ui/apps/index.ts index 1669d5ab76..f00477d6b6 100644 --- a/examples/app-showcase/src/ui/apps/index.ts +++ b/examples/app-showcase/src/ui/apps/index.ts @@ -52,6 +52,9 @@ export const ShowcaseApp = App.create({ { id: 'nav_accounts', type: 'object', objectName: 'showcase_account', label: 'Accounts', icon: 'building' }, { id: 'nav_contacts', type: 'object', objectName: 'showcase_contact', label: 'Contacts', icon: 'user' }, { id: 'nav_invoices', type: 'object', objectName: 'showcase_invoice', label: 'Invoices', icon: 'receipt' }, + // Filtered roll-up summary demo (framework#1868): one expense-line child + // feeds six parent totals, each aggregating only the lines a filter matches. + { id: 'nav_expense_reports', type: 'object', objectName: 'showcase_expense_report', label: 'Expense Reports', icon: 'wallet' }, { id: 'nav_products', type: 'object', objectName: 'showcase_product', label: 'Products', icon: 'package' }, { id: 'nav_teams', type: 'object', objectName: 'showcase_team', label: 'Teams', icon: 'users' }, { id: 'nav_categories', type: 'object', objectName: 'showcase_category', label: 'Categories', icon: 'list-tree' }, diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 3956cbae32..d1ca50bf0d 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -283,6 +283,12 @@ interface SummaryDescriptor { fn: 'count' | 'sum' | 'min' | 'max' | 'avg'; /** Child field aggregated (unused for count). */ sourceField: string; + /** + * Optional predicate (a query `where` FilterCondition) restricting which child + * rows are aggregated. ANDed with the parent-FK match when the aggregate runs. + * Undefined ⇒ aggregate every child of the parent. + */ + filter?: Record; } export class ObjectQL implements IDataEngine { @@ -1841,8 +1847,14 @@ export class ObjectQL implements IDataEngine { } } if (!fkField) continue; // can't resolve the relationship — skip + // Optional per-summary predicate: only child rows matching it are + // aggregated (e.g. sum receipts where { status: 'received' }). ANDed with + // the parent-FK match at recompute time. Ignore a non-object filter. + const filter = so.filter && typeof so.filter === 'object' && !Array.isArray(so.filter) + ? so.filter as Record + : undefined; const list = index.get(childObject) ?? []; - list.push({ parentObject: parent.name, summaryField, fkField, fn, sourceField: so.field }); + list.push({ parentObject: parent.name, summaryField, fkField, fn, sourceField: so.field, filter }); index.set(childObject, list); } } @@ -1882,8 +1894,12 @@ export class ObjectQL implements IDataEngine { // aggregate/update) with backoff — a network blip here used to leave // the parent summary silently stale (framework#3147). await withTransientRetry(async () => { + // AND the parent-FK match with the optional per-summary filter so + // only matching child rows are aggregated (e.g. received receipts). + const fkMatch = { [desc.fkField]: parentId }; + const where = desc.filter ? { $and: [fkMatch, desc.filter] } : fkMatch; const rows = await this.aggregate(childObject, { - where: { [desc.fkField]: parentId }, + where, aggregations: [{ function: desc.fn, ...(desc.fn === 'count' ? {} : { field: desc.sourceField }), diff --git a/packages/objectql/src/summary-rollup.test.ts b/packages/objectql/src/summary-rollup.test.ts index 8c806be61a..44a783ea1d 100644 --- a/packages/objectql/src/summary-rollup.test.ts +++ b/packages/objectql/src/summary-rollup.test.ts @@ -14,9 +14,35 @@ function makeDriver() { if (!s) { s = new Map(); stores.set(o, s); } return s; }; + // Minimal FilterCondition matcher: implicit equality, a few operators, and the + // logical `$and`/`$or`/`$not` the engine emits when a summary carries a filter + // (`{ $and: [{ fk: parentId }, }] }`). Enough to exercise the merge. + const checkOp = (value: any, cond: any): boolean => { + if (cond === null || typeof cond !== 'object' || Array.isArray(cond) || cond instanceof Date) { + return value === cond; + } + return Object.entries(cond).every(([op, target]: [string, any]) => { + switch (op) { + case '$eq': return value === target; + case '$ne': return value !== target; + case '$gt': return value > target; + case '$gte': return value >= target; + case '$lt': return value < target; + case '$lte': return value <= target; + case '$in': return Array.isArray(target) && target.includes(value); + case '$nin': return Array.isArray(target) && !target.includes(value); + default: return true; + } + }); + }; const matches = (row: any, where: any): boolean => { if (!where || typeof where !== 'object') return true; - return Object.entries(where).every(([k, v]) => row?.[k] === v); + return Object.entries(where).every(([k, v]: [string, any]) => { + if (k === '$and') return (v as any[]).every((w) => matches(row, w)); + if (k === '$or') return (v as any[]).some((w) => matches(row, w)); + if (k === '$not') return !matches(row, v); + return checkOp(row?.[k], v); + }); }; let n = 0; const driver: any = { @@ -123,3 +149,91 @@ describe('roll-up summary fields', () => { expect(parent(b.id).line_total).toBe(3); }); }); + +describe('roll-up summary fields with a filter predicate', () => { + let engine: ObjectQL; + let storeFor: ReturnType['storeFor']; + + beforeEach(async () => { + engine = new ObjectQL(); + const d = makeDriver(); + storeFor = d.storeFor; + engine.registerDriver(d.driver, true); + await engine.init(); + // A publication rolls up ONE child object (`engagement`) into several totals, + // each differentiated only by a `filter` — the shape the issue's + // content_publication.total_views/clicks/signups fields need. + engine.registry.registerObject({ + name: 'publication', + fields: { + name: { type: 'text' }, + total_events: { type: 'summary', summaryOperations: { object: 'engagement', field: 'id', function: 'count' } }, + total_signups: { type: 'summary', summaryOperations: { object: 'engagement', field: 'id', function: 'count', filter: { type: 'signup' } } }, + total_revenue: { type: 'summary', summaryOperations: { object: 'engagement', field: 'amount', function: 'sum', filter: { type: 'signup' } } }, + premium_signups: { type: 'summary', summaryOperations: { object: 'engagement', field: 'id', function: 'count', filter: { type: { $in: ['signup', 'trial'] }, amount: { $gte: 100 } } } }, + }, + } as any); + engine.registry.registerObject({ + name: 'engagement', + fields: { + type: { type: 'text' }, + amount: { type: 'number' }, + publication: { type: 'master_detail', reference: 'publication' }, + }, + } as any); + }); + + const pub = (id: string) => storeFor('publication').get(id); + + it('aggregates only the child rows matching the filter', async () => { + const p = await engine.insert('publication', { name: 'POST-1' }); + await engine.insert('engagement', { publication: p.id, type: 'view', amount: 0 }); + await engine.insert('engagement', { publication: p.id, type: 'view', amount: 0 }); + await engine.insert('engagement', { publication: p.id, type: 'signup', amount: 30 }); + await engine.insert('engagement', { publication: p.id, type: 'signup', amount: 50 }); + + expect(pub(p.id).total_events).toBe(4); // unfiltered + expect(pub(p.id).total_signups).toBe(2); // filter: type == signup + expect(pub(p.id).total_revenue).toBe(80); // sum(amount) where type == signup + }); + + it('honours operator/compound filters ($in + $gte)', async () => { + const p = await engine.insert('publication', { name: 'POST-2' }); + await engine.insert('engagement', { publication: p.id, type: 'signup', amount: 40 }); // amount < 100 + await engine.insert('engagement', { publication: p.id, type: 'signup', amount: 150 }); // matches + await engine.insert('engagement', { publication: p.id, type: 'trial', amount: 200 }); // matches + await engine.insert('engagement', { publication: p.id, type: 'view', amount: 999 }); // wrong type + + expect(pub(p.id).premium_signups).toBe(2); + }); + + it('recomputes when a child moves in/out of the filter via an update', async () => { + const p = await engine.insert('publication', { name: 'POST-3' }); + const e = await engine.insert('engagement', { publication: p.id, type: 'view', amount: 25 }); + expect(pub(p.id).total_signups).toBe(0); + expect(pub(p.id).total_revenue).toBe(0); + + // Reclassify the same row as a signup — it now enters the filtered rollups. + await engine.update('engagement', { id: e.id, type: 'signup' }); + expect(pub(p.id).total_signups).toBe(1); + expect(pub(p.id).total_revenue).toBe(25); + + // And back out again. + await engine.update('engagement', { id: e.id, type: 'view' }); + expect(pub(p.id).total_signups).toBe(0); + expect(pub(p.id).total_revenue).toBe(0); + }); + + it('recomputes the filtered rollup when a matching child is deleted', async () => { + const p = await engine.insert('publication', { name: 'POST-4' }); + const e = await engine.insert('engagement', { publication: p.id, type: 'signup', amount: 60 }); + await engine.insert('engagement', { publication: p.id, type: 'view', amount: 0 }); + expect(pub(p.id).total_signups).toBe(1); + expect(pub(p.id).total_revenue).toBe(60); + + await engine.delete('engagement', { where: { id: e.id } }); + expect(pub(p.id).total_signups).toBe(0); + expect(pub(p.id).total_revenue).toBe(0); + expect(pub(p.id).total_events).toBe(1); // the unfiltered count still sees the view + }); +}); diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 148c679989..775eb3e1e3 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -65,7 +65,7 @@ "summaryOperations": { "status": "live", "evidence": "packages/objectql/src/engine.ts", - "note": "rollup {object,field,function,relationshipField}." + "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." }, "requiredWhen": { "status": "live", diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index 4ff5dc3b98..cb2ea8afef 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -382,7 +382,7 @@ describe('FieldSchema', () => { it('should accept all summary functions', () => { const functions = ['count', 'sum', 'min', 'max', 'avg'] as const; - + functions.forEach(fn => { const field: Field = { name: 'test_summary', @@ -398,6 +398,40 @@ describe('FieldSchema', () => { expect(() => FieldSchema.parse(field)).not.toThrow(); }); }); + + it('should accept a summary field with a filter predicate', () => { + const field: Field = { + name: 'received_amount', + label: 'Received Amount', + type: 'summary', + summaryOperations: { + object: 'procurement_receipt', + field: 'amount', + function: 'sum', + filter: { status: 'received' }, + }, + }; + + const parsed = FieldSchema.parse(field); + expect(parsed.summaryOperations?.filter).toEqual({ status: 'received' }); + }); + + it('should accept a filtered count with operator syntax and an explicit relationship field', () => { + const field: Field = { + name: 'signup_count', + label: 'Signups', + type: 'summary', + summaryOperations: { + object: 'engagement', + field: 'id', + function: 'count', + relationshipField: 'publication_id', + filter: { type: { $in: ['signup', 'trial'] } }, + }, + }; + + expect(() => FieldSchema.parse(field)).not.toThrow(); + }); }); describe('Security and Visibility', () => { diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 3bab92fd4f..5a19a34663 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { SystemIdentifierSchema } from '../shared/identifiers.zod'; import { ExpressionInputSchema } from '../shared/expression.zod'; +import { FilterConditionSchema } from './filter.zod'; /** * Field Type Enum @@ -402,6 +403,19 @@ export const FieldSchema = lazySchema(() => z.object({ 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'), 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 predicate that restricts WHICH child rows are aggregated — a + * FilterCondition (the same object DSL as a query `where`), evaluated against + * each child row. Omit to aggregate every child. This is what lets several + * summaries roll up the SAME child object into different totals — e.g. + * `content_publication.total_signups` = count of engagement rows where + * `{ type: 'signup' }` vs `total_clicks` where `{ type: 'click' }`, or + * `procurement_order.received_amount` = sum of receipt lines where + * `{ status: 'received' }`. The engine ANDs it with the parent-FK match, so + * a child moving in or out of the predicate (a status change) recomputes the + * parent on its next write like any other child update. + */ + 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."), }).optional().describe('Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted.'), /** Enhanced Field Type Configurations */ diff --git a/skills/objectstack-data/references/_index.md b/skills/objectstack-data/references/_index.md index 6a9ebc7417..17380b0116 100644 --- a/skills/objectstack-data/references/_index.md +++ b/skills/objectstack-data/references/_index.md @@ -18,6 +18,7 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies +- `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Capability tokens a script body may request. - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) - `node_modules/@objectstack/spec/src/kernel/public-auth-features.ts` — Public auth feature-flag registry (#2874) diff --git a/skills/objectstack-data/rules/relationships.md b/skills/objectstack-data/rules/relationships.md index 11d970d041..f627cecc64 100644 --- a/skills/objectstack-data/rules/relationships.md +++ b/skills/objectstack-data/rules/relationships.md @@ -63,9 +63,11 @@ export default ObjectSchema.create({ invoice_number: { type: 'text', required: true }, total: { type: 'summary', - reference: 'invoice_line_item', - summaryType: 'sum', - summaryField: 'amount', + summaryOperations: { + object: 'invoice_line_item', + field: 'amount', + function: 'sum', + }, }, } }); @@ -181,6 +183,8 @@ transaction as the write, so it's consistent and never summed on the client). // relationshipField: 'invoice' // optional; auto-detected from the child's // master_detail/lookup field referencing // this parent when omitted + // filter: { status: 'received' } // optional; only children matching this + // `where` predicate are aggregated }, } ``` @@ -189,6 +193,16 @@ Empty collections roll up to `0` for `count`/`sum`, `null` for `min`/`max`/`avg` Pairs naturally with inline editing (below): the parent total updates atomically as line items are saved. +Add `filter` (a query `where` FilterCondition) to aggregate only a **subset** of +the children — this is how a single child object feeds several different totals. +For example one `engagement` child yields `total_signups` +(`filter: { type: 'signup' }`) and `total_clicks` (`filter: { type: 'click' }`), +and a `procurement_receipt` child yields `received_amount` +(`function: 'sum', filter: { status: 'received' }`). The predicate is ANDed with +the parent-FK match; a child moving in or out of it (e.g. a status change) +recomputes the parent on its next write like any other child update. Operator +and compound forms work too (`filter: { type: { $in: ['signup','trial'] } }`). + ## Inline Editing (Master-Detail Entry) Declare inline editing **on the relationship**, in the data model — not in a form diff --git a/skills/objectstack-platform/references/_index.md b/skills/objectstack-platform/references/_index.md index a200bdfbd4..f852885ecb 100644 --- a/skills/objectstack-platform/references/_index.md +++ b/skills/objectstack-platform/references/_index.md @@ -23,6 +23,7 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum +- `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Capability tokens a script body may request. - `node_modules/@objectstack/spec/src/kernel/cluster.zod.ts` — Cluster Protocol - `node_modules/@objectstack/spec/src/kernel/metadata-customization.zod.ts` — Metadata Customization Layer Protocol