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-filter.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions content/docs/data-modeling/field-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
169 changes: 169 additions & 0 deletions examples/app-showcase/src/data/objects/expense-report.object.ts
Original file line number Diff line number Diff line change
@@ -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' }),
},
});
1 change: 1 addition & 0 deletions examples/app-showcase/src/data/objects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
38 changes: 37 additions & 1 deletion examples/app-showcase/src/data/seed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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];
3 changes: 3 additions & 0 deletions examples/app-showcase/src/ui/apps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
20 changes: 18 additions & 2 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
}

export class ObjectQL implements IDataEngine {
Expand Down Expand Up @@ -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<string, unknown>
: 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);
}
}
Expand Down Expand Up @@ -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 }),
Expand Down
Loading