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
22 changes: 22 additions & 0 deletions .changeset/27462-count-rollup-without-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/spec': minor
'@objectstack/objectql': minor
'@objectstack/skills': minor
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
---

fix(spec): a count roll-up saves without `summaryOperations.field`

Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: updated here — `skills/objectstack-data/rules/field-types.md` spells `field?` in the `summary` config row, with "(omit for `count`)"; the same file's `sum` example drops its now-duplicate "ignored for count" comment, so the file's size is unchanged.

The description of `summaryOperations.field` always said it is ignored for `count`, yet the schema required it for every function, so a count roll-up with no child field was refused (`422 INVALID_METADATA` at `fields.<name>.summaryOperations.field`, "expected string") and Studio's author had to pick an arbitrary child field to save one. The schema now follows its own description:

- **`count` may omit `field`.** A count counts the matching child rows and reads no field, so `{ object, function: 'count' }` parses and saves as written. A count that carries a `field` is still accepted, and the field is still ignored.
- **Every other function still needs it.** `sum`, `min`, `max` and `avg` without `field` are refused at `summaryOperations.field`, and the message names the function and the fix: name the child field to aggregate, or use `function: 'count'`.
- **One rule, read twice.** `summaryFunctionReadsField(fn)` (exported from `@objectstack/spec/data`) is where "does this function read a child field" is written. The schema's refusal reads it, and Studio's roll-up editor reads it: for a count the editor offers no "Child field to aggregate" control, only a note that a count needs none, and saves the roll-up without `field`.
- **The engine counts it.** The roll-up index carries no source field for such a count (`SummaryDescriptor.sourceField` is now optional), and the recompute and bulk-insert paths count child rows without reading one, as they already did for every count.
- **Published JSON Schema.** `summaryOperations.field` is no longer in `required`; the "required unless `count`" rule is a refinement the JSON Schema does not state, so `summaryOperations` joins the dropped-refinements ledger at each schema that embeds a field.
- **Metadata forms.** The `field` sub-row of the roll-up composite (field editor and object quick-add grid) is no longer marked required; its help text, "ignored for count", is unchanged.
3 changes: 1 addition & 2 deletions content/docs/data-modeling/field-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,7 @@ Roll-up summary from child records.
| Property | Type | Default | Description |
|:---|:---|:---|:---|
| `summaryOperations.object` | `string` | **required** | Child object to aggregate |
| `summaryOperations.field` | `string` | **required** | Child field to aggregate; ignored for `count` |
| `summaryOperations.field` | `string` | **required** for `sum`, `avg`, `min`, `max` | Child field to aggregate; a `count` counts the child rows and reads no field, so it may omit it |
| `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 |
Expand All @@ -711,7 +711,6 @@ Roll-up summary from child records.
type: 'summary',
summaryOperations: {
object: 'task',
field: 'id',
function: 'count',
relationshipField: 'project',
},
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const result = CurrencyConfigSchema.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' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). |
| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). |
| **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** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
Expand Down Expand Up @@ -227,7 +227,7 @@ const result = CurrencyConfigSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Source child object name for roll-up |
| **field** | `string` | ✅ | Field on child object to aggregate (ignored for count) |
| **field** | `string` | optional | Field on the child object to aggregate. Required for sum, min, max and avg; count counts the child rows and reads no field, so a count may omit it (ignored when given). |
| **function** | `Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>` | ✅ | Aggregation function to apply |
| **relationshipField** | `string` | optional | 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. |
| **filter** | `any` | optional | 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. |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ const result = ApiMethod.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' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). |
| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). |
| **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** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
Expand Down Expand Up @@ -628,7 +628,7 @@ const result = ApiMethod.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' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). |
| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). |
| **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** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/system/migration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Add a new field to an existing object
| **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' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). |
| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). |
| **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** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
Expand Down Expand Up @@ -530,7 +530,7 @@ Add a new field to an existing object
| **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' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). |
| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). |
| **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** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ Sort field and direction pair
| **maxLength** | `integer` | optional | Maximum character count — the control's ceiling (a textarea also draws its counter) |
| **pattern** | `string` | optional | Regular expression the value must match, as a string — the native control's `pattern`, enforced by the browser at submit |
| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | The value type a formula field displays (number / text / boolean / date / currency) |
| **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | The roll-up a summary field displays — the object field's own `{ object, field, function, … }` |
| **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | The roll-up a summary field displays — the object field's own `{ object, field, function, … }` |
| **columns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | The columns of a `grid` field — the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes |
| **minRows** | `number` | optional | A `grid` field's minimum row count: its Remove action is disabled at this many rows (no minimum when unset). Read only by the `grid` widget |
| **maxRows** | `number` | optional | A `grid` field's maximum row count: its Add and Duplicate actions are disabled, and no blank entry row is drawn, at this many rows (no maximum when unset). Read only by the `grid` widget |
Expand Down
8 changes: 6 additions & 2 deletions packages/objectql/src/summary-aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,12 @@ export interface SummaryDescriptor {
/** 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;
/**
* Child field aggregated — `summaryOperations.field`. Absent on a `count`,
* which counts the child rows and reads no field (the spec's
* `summaryFunctionReadsField`); every other function carries one.
*/
sourceField?: string;
/**
* Optional predicate (a query `where` FilterCondition) restricting which child
* rows are aggregated. ANDed with the parent-FK match when the aggregate runs.
Expand Down
7 changes: 7 additions & 0 deletions packages/objectql/src/summary-batch-accumulation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ describe('[#25579] createBatchSummaryAccumulator — per roll-up kind', () => {
expect(acc.overlay(bound({ id: 'o1', lowest: null, highest: null }))!.order!.row).toEqual({ id: 'o1', lowest: 3, highest: 7 });
});

it('[#27462] count with no sourceField (a count reads no child field): each admitted row still adds one', () => {
const acc = createBatchSummaryAccumulator([desc({ summaryField: 'lines', fn: 'count', sourceField: undefined })], CHILD_FIELDS)!;
acc.accept({ order: 'o1', amount: 5 });
acc.accept({ order: 'o1' });
expect(acc.overlay(bound({ id: 'o1', lines: 1 }))!.order!.row).toEqual({ id: 'o1', lines: 3 });
});

it('a row never accepted moves nothing: the caller accepts only the rows its rules admitted', () => {
const acc = createBatchSummaryAccumulator([desc({ summaryField: 'primaries', fn: 'count' })], CHILD_FIELDS)!;
const stored = bound({ id: 'o1', primaries: 0 });
Expand Down
Loading
Loading