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
25 changes: 25 additions & 0 deletions .changeset/aggregate-field-type-compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@objectstack/spec": minor
---

feat(spec): declare the aggregate × field-type compatibility matrix a dataset measure is judged against — `AGGREGATE_FIELD_TYPE_COMPATIBILITY` and `isAggregateCompatibleWithFieldType` (#16353, spec half of #16099)

A dataset measure pairs an `aggregate` with a `field`, and nothing between author and driver correlated the two: `avg` over a `Field.datetime` compiled to `AVG(col)` and reached the backend, where one SQL family averages the column's storage form and another rejects the call — one metadata document, two answers. Which pairs are accepted is a contract, so it is now declared once in `@objectstack/spec/data`:

| Aggregate | Accepted field types |
|---|---|
| `count`, `count_distinct` | every `FieldType` |
| `sum` | `number`, `currency`, `rating`, `slider`, `progress`, `summary` — the numeric class EXCEPT `percent` (a rate does not add; `isIncoherentAggregate` already says so) |
| `avg` | the numeric class, `percent` included |
| `min`, `max` | the numeric class plus `date`, `datetime`, `time` — both return a value of the field's own type |
| every other pair | refused |

The ruling (director, decision batch #59, 2026-09-06) named its buckets by category; the table resolves them against the real `FieldType` membership through the `field-value.zod` semantic classes: "numeric" is `NUMERIC_VALUE_TYPES` (`integer` is a driver-internal column alias, not a `FieldType` — the integer-valued authorable members are `rating` / `slider` / `progress`); "temporal" is the three temporal classes, `time` included because its stored form is a dialect question exactly like `date` / `datetime` (native TIME on Postgres and MySQL, canonical `HH:MM:SS[.fff]` TEXT on SQLite), the canonical form orders chronologically on every dialect, and `AnalyticsResult.fields[].type` already describes `min` / `max` over it as temporal (#15768). `formula` is refused for arithmetic aggregates whatever its declared `returnType`: it is virtual in SQL storage, no column exists to aggregate.

Two refused rows override existing opinions and are recorded as such, not presented as agreement. **Booleans** are refused for `sum` / `avg` / `min` / `max` by the ruling's "every other pair: refused", while maintainer ruling #11152 already has every backend answer them as numbers (`sum(flag)=3`, `avg(flag)=0.5`, `min(flag)=0`, `max(flag)=1`, pinned in the spec's `AGGREGATION_CASES`; `driver-sql` casts the aggregand on Postgres to make it hold). That refusal is therefore not grounded in backend divergence; whether booleans belong in those rows is a collision between two rulings and is referred to the maintainer as its own decision — the row ships exactly as batch #59 stated it. **The string classes** are refused for `min` / `max` here, while `service-analytics` (#15768) already types `min` / `max` over them as a supported `'string'` result; the refusal is defensible (string order is collation-dependent) but it overrides that opinion.

**The narrowing, stated plainly.** Every pair outside the table — `avg` × `datetime`, `sum` × `boolean`, `min` × `text`, `sum` × `percent`, and so on — is an authoring shape `DatasetMeasureSchema` accepts today and will be REFUSED once the two consumer legs land: the compile-time refusal in the dataset compiler (#16099) and the authoring-time lint rule (its devx sub-card). A measure whose pair is refused is fixed by changing the aggregate to one the field's type supports (`min` / `max` for a temporal field; `avg` for a `percent`; `count` for anything), never by widening the table.

**Not breaking in this release, `minor` on purpose.** This changeset ships a table and a predicate that nothing yet enforces: `DatasetMeasureSchema` accepts byte-for-byte what it accepted before, no export is removed or narrowed, and no runtime path reads the table yet. It is an additive widening of the published surface — two new exports in `dist/*.d.ts` — which the maintainer ruling of 2026-09-04 (decision batch #35) puts at `minor`. The refusal itself arrives with the consumer legs, whose changesets carry the breaking declaration, its migration prescription and the ADR-0087 disposition; this one names the narrowing so an upgrading author can read the contract before it is executed.

`isIncoherentAggregate` is unchanged and stays the semantic opinion beside this table. The two diverge on exactly one pair: `count_distinct` × `percent` is flagged there and accepted here (the ruling reads `count_distinct` as "any type"). That divergence is pinned in the table's test and reported on #16353 rather than resolved silently.
2 changes: 2 additions & 0 deletions packages/spec/api-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"exports": [
"ACCEPTED_FILTER_COMPARAND_TYPES (const)",
"ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE (const)",
"AGGREGATE_FIELD_TYPE_COMPATIBILITY (const)",
"AGGREGATION_CASES (const)",
"AGGREGATION_ROWS (const)",
"ALL_OPERATORS (const)",
Expand Down Expand Up @@ -738,6 +739,7 @@
"hookForm (const)",
"injectedSystemColumnDefs (function)",
"isAcceptedFilterComparand (function)",
"isAggregateCompatibleWithFieldType (function)",
"isAnalyticsDateRangeRefusalIssue (function)",
"isApiOperationAllowed (function)",
"isApiPrimitive (function)",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/export-origins/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"exports": {
"ACCEPTED_FILTER_COMPARAND_TYPES": "src/data/filter-comparand-type.ts#ACCEPTED_FILTER_COMPARAND_TYPES (const)",
"ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE": "src/data/filter-comparand-type.ts#ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE (const)",
"AGGREGATE_FIELD_TYPE_COMPATIBILITY": "src/data/aggregate-field-type-compatibility.ts#AGGREGATE_FIELD_TYPE_COMPATIBILITY (const)",
"AGGREGATION_CASES": "src/data/aggregation-conformance.ts#AGGREGATION_CASES (const)",
"AGGREGATION_ROWS": "src/data/aggregation-conformance.ts#AGGREGATION_ROWS (const)",
"ALL_OPERATORS": "src/data/filter.zod.ts#ALL_OPERATORS (const)",
Expand Down Expand Up @@ -725,6 +726,7 @@
"hookForm": "src/data/hook.form.ts#hookForm (const)",
"injectedSystemColumnDefs": "src/data/injected-system-column-provenance.ts#injectedSystemColumnDefs (function)",
"isAcceptedFilterComparand": "src/data/filter-comparand-type.ts#isAcceptedFilterComparand (function)",
"isAggregateCompatibleWithFieldType": "src/data/aggregate-field-type-compatibility.ts#isAggregateCompatibleWithFieldType (function)",
"isAnalyticsDateRangeRefusalIssue": "src/data/analytics.zod.ts#isAnalyticsDateRangeRefusalIssue (function)",
"isApiOperationAllowed": "src/data/api-derivation.ts#isApiOperationAllowed (function)",
"isApiPrimitive": "src/data/api-derivation.ts#isApiPrimitive (function)",
Expand Down
177 changes: 177 additions & 0 deletions packages/spec/src/data/aggregate-field-type-compatibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins for the aggregate × field-type compatibility table (#16353).
*
* Two totality claims are the card's acceptance and are held here literally:
* every `AggregationFunction` member has a row, and every `FieldType` member
* is classified (in or out) on every row. The per-row memberships are pinned
* as the LITERAL sets the ruling resolved to, and tied to the `field-value.zod`
* semantic classes so a field type joining the numeric or temporal class
* elsewhere reds this file until the table records a decision.
*/

import { describe, it, expect } from 'vitest';
import { AggregationFunction } from './query.zod';
import { FieldType } from './field.zod';
import {
NUMERIC_VALUE_TYPES,
CALENDAR_DATE_TYPES,
INSTANT_TYPES,
CLOCK_TIME_TYPES,
} from './field-value.zod';
import { isIncoherentAggregate } from './aggregation-policy';
import {
AGGREGATE_FIELD_TYPE_COMPATIBILITY,
isAggregateCompatibleWithFieldType,
} from './aggregate-field-type-compatibility';

const sorted = (xs: Iterable<string>) => [...xs].sort();

const NUMERIC = ['currency', 'number', 'percent', 'progress', 'rating', 'slider', 'summary'];
const ADDITIVE = NUMERIC.filter((t) => t !== 'percent');
const TEMPORAL = ['date', 'datetime', 'time'];

describe('AGGREGATE_FIELD_TYPE_COMPATIBILITY — totality', () => {
it('every AggregationFunction member has a row, and no row is for a non-member', () => {
expect(sorted(Object.keys(AGGREGATE_FIELD_TYPE_COMPATIBILITY))).toEqual(sorted(AggregationFunction.options));
});

it('every row member is a declared FieldType', () => {
const all = new Set<string>(FieldType.options);
for (const row of Object.values(AGGREGATE_FIELD_TYPE_COMPATIBILITY)) {
for (const t of row) expect(all).toContain(t);
}
});

it('every FieldType member is classified on every row — the predicate answers a boolean for all pairs', () => {
for (const fn of AggregationFunction.options) {
const row = AGGREGATE_FIELD_TYPE_COMPATIBILITY[fn];
for (const t of FieldType.options) {
expect(isAggregateCompatibleWithFieldType(fn, t)).toBe(row.includes(t));
}
}
});

it('the table and its rows are frozen — consumers read, never edit', () => {
expect(Object.isFrozen(AGGREGATE_FIELD_TYPE_COMPATIBILITY)).toBe(true);
for (const row of Object.values(AGGREGATE_FIELD_TYPE_COMPATIBILITY)) expect(Object.isFrozen(row)).toBe(true);
});
});

describe('AGGREGATE_FIELD_TYPE_COMPATIBILITY — the ruled rows, resolved against the membership', () => {
it('`count` / `count_distinct`: every FieldType', () => {
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.count)).toEqual(sorted(FieldType.options));
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.count_distinct)).toEqual(sorted(FieldType.options));
});

it('`sum`: the numeric class EXCEPT `percent`', () => {
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.sum)).toEqual(ADDITIVE);
expect(isAggregateCompatibleWithFieldType('sum', 'percent')).toBe(false);
});

it('`avg`: the numeric class, `percent` included', () => {
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.avg)).toEqual(NUMERIC);
});

it('`min` / `max`: the numeric class plus the temporal class', () => {
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.min)).toEqual(sorted([...NUMERIC, ...TEMPORAL]));
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.max)).toEqual(sorted([...NUMERIC, ...TEMPORAL]));
});

it('the numeric bucket IS the field-value numeric class — a type joining it elsewhere must be decided here', () => {
// `avg` accepts exactly the numeric class; `sum` is that class minus the rate.
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.avg)).toEqual(sorted(NUMERIC_VALUE_TYPES));
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.sum)).toEqual(sorted([...NUMERIC_VALUE_TYPES].filter((t) => t !== 'percent')));
});

it('the temporal bucket IS the three field-value temporal classes', () => {
const temporal = sorted([...CALENDAR_DATE_TYPES, ...INSTANT_TYPES, ...CLOCK_TIME_TYPES]);
expect(temporal).toEqual(TEMPORAL);
const minOnly = AGGREGATE_FIELD_TYPE_COMPATIBILITY.min.filter((t) => !NUMERIC_VALUE_TYPES.has(t));
expect(sorted(minOnly)).toEqual(temporal);
});
});

describe('isAggregateCompatibleWithFieldType — the pairs the card is about', () => {
it('refuses the motivating defect: `avg` (and `sum`) over a datetime', () => {
expect(isAggregateCompatibleWithFieldType('avg', 'datetime')).toBe(false);
expect(isAggregateCompatibleWithFieldType('sum', 'datetime')).toBe(false);
expect(isAggregateCompatibleWithFieldType('avg', 'date')).toBe(false);
});

it('accepts `min` / `max` over every temporal type — they return a value of the field\'s own type', () => {
for (const t of TEMPORAL) {
expect(isAggregateCompatibleWithFieldType('min', t)).toBe(true);
expect(isAggregateCompatibleWithFieldType('max', t)).toBe(true);
}
});

it('refuses arithmetic over booleans (row as ruled; membership referred, see module TSDoc) and over the computed / text / structured types', () => {
for (const fn of ['sum', 'avg', 'min', 'max'] as const) {
for (const t of ['boolean', 'toggle', 'formula', 'autonumber', 'text', 'select', 'lookup', 'json', 'vector', 'file']) {
expect(isAggregateCompatibleWithFieldType(fn, t)).toBe(false);
}
}
});

it('accepts `count` / `count_distinct` over anything, `vector` and `formula` included', () => {
for (const t of FieldType.options) {
expect(isAggregateCompatibleWithFieldType('count', t)).toBe(true);
expect(isAggregateCompatibleWithFieldType('count_distinct', t)).toBe(true);
}
});

it('agrees with `isIncoherentAggregate` on `sum` × `percent`; the one divergence is `count_distinct` × `percent`', () => {
// Both refuse the sum of a rate.
expect(isIncoherentAggregate('sum', 'percent')).toBe(true);
expect(isAggregateCompatibleWithFieldType('sum', 'percent')).toBe(false);
// The semantic opinion flags count_distinct of a rate; the ruling reads
// `count_distinct` as "any type" and this table follows the ruling. Pinned
// so the divergence is visible, not discovered (reported on #16353).
expect(isIncoherentAggregate('count_distinct', 'percent')).toBe(true);
expect(isAggregateCompatibleWithFieldType('count_distinct', 'percent')).toBe(true);
});

it('fails closed on vocabulary it does not know — driver aliases and retired functions', () => {
expect(isAggregateCompatibleWithFieldType('sum', 'integer')).toBe(false);
expect(isAggregateCompatibleWithFieldType('sum', 'float')).toBe(false);
expect(isAggregateCompatibleWithFieldType('array_agg', 'text')).toBe(false);
expect(isAggregateCompatibleWithFieldType('countDistinct', 'text')).toBe(false);
expect(isAggregateCompatibleWithFieldType('toString', 'text')).toBe(false);
expect(isAggregateCompatibleWithFieldType('', '')).toBe(false);
});

it('fails closed on SHAPE — a non-string that would coerce to a member spelling is refused, not looked up', () => {
// `hasOwnProperty.call` applies ToPropertyKey, so without the typeof guard
// `['count']` reads as 'count' and an object with a toString reads as
// 'sum'. A refusal gate must not be talked past by coercion.
const loose = isAggregateCompatibleWithFieldType as unknown as (a: unknown, f: unknown) => boolean;
expect(loose(['count'], 'number')).toBe(false);
expect(loose({ toString: () => 'sum' }, 'currency')).toBe(false);
expect(loose('sum', ['currency'])).toBe(false);
expect(loose('min', { toString: () => 'date' })).toBe(false);
expect(loose(undefined, 'number')).toBe(false);
expect(loose(null, 'number')).toBe(false);
expect(loose('count', undefined)).toBe(false);
expect(loose('count', null)).toBe(false);
expect(loose(1, 'number')).toBe(false);
expect(loose('count', 1)).toBe(false);
expect(loose(Symbol('count'), 'number')).toBe(false);
});

it('records the two overrides of existing opinions without changing the rows: booleans and the string classes', () => {
// Booleans: refused here by the ruling's default; #11152 / AGGREGATION_CASES
// answer them as numbers on every face. Row kept as ruled, question referred.
for (const fn of ['sum', 'avg', 'min', 'max']) {
expect(isAggregateCompatibleWithFieldType(fn, 'boolean')).toBe(false);
expect(isAggregateCompatibleWithFieldType(fn, 'toggle')).toBe(false);
}
// String classes: min/max refused here; measureResultType (#15768) types
// min/max over them as a supported 'string' result. Override recorded.
for (const t of ['text', 'select', 'lookup', 'autonumber']) {
expect(isAggregateCompatibleWithFieldType('min', t)).toBe(false);
expect(isAggregateCompatibleWithFieldType('max', t)).toBe(false);
}
});
});
Loading
Loading