Skip to content

Commit 4ad3ce5

Browse files
fix(lint, service-analytics): a total over a formula field names the stored-number remedy, not a count (#24505)
Fixes #24426 Clause-②: no ## What changed A dataset measure (or an authored cube measure) that takes `sum` / `avg` / `min` / `max` over a `formula` field is still refused. Only the prescription changed. It used to name a count as the way out: > Either point "sum" at a field of an accepted type, or aggregate "leave_days" with one its `formula` type accepts: count, count_distinct. A count read as a total is the plausible wrong number from objectstack-ai/cloud#2861: "Total Leave Days" showed 3 records against a true total of 10 days. The hint now gives the spec's remedy first and names the count only as a different question: > The field is computed on read and stores no value: store the value as a number field (kept by a hook) and aggregate that. `count` / `count_distinct` answer a different question: how many records. ### One source - **Home: `@objectstack/spec/data`, in `aggregate-field-type-compatibility.ts` (the table module).** It exports `VIRTUAL_FIELD_AGGREGATE_REMEDY` (the sentence) and `virtualFieldAggregateRemedy(aggregate, fieldType)`. The function returns the sentence for a REFUSED `sum`/`avg`/`min`/`max` over a type in `SEARCH_VIRTUAL_TYPES` (today `formula`, the drivers' storage rule). It returns `undefined` for every other pair. Like the predicate, it fails closed on non-string input. The class is not a new list: it reuses `SEARCH_VIRTUAL_TYPES`, and a spec test holds every member refused on all four value-reading rows. - **Why there and not beside the semantic migration entry (the claim's expected spot).** Both consumers already import that module (`isAggregateCompatibleWithFieldType`, `AGGREGATE_FIELD_TYPE_COMPATIBILITY`). Its TSDoc is where the module says formula is refused because it is virtual. An extra export in a `migrations/entries/semantic/*.ts` file would sit inside the generated-registry tree, which holds `SemanticMigration` entries only. Dependency direction checked: `@objectstack/lint` and `@objectstack/service-analytics` both list `@objectstack/spec` as `workspace:*`. Spec imports neither of them. - **Readers:** - lint `measure-aggregate-field-type-refused` hint (`validate-dataset-measure-aggregates.ts`, dataset and cube legs); - the dataset compile leg's `REMEDY_BY_SOURCE_CLASS` (`dataset-compiler.ts`), which now asks the spec first; - `os explain measure-aggregate-field-type-refused` (`rule-explanations.ts`, that entry only). This module imports nothing by design, so the entry writes the sentence out and a lint test holds it equal to the constant. I also dropped "(a computed column)" from the existing stored-numeric sentence, because a formula IS a computed column, so that wording pointed the reader the wrong way. - **Unchanged:** every other refused pair keeps its own words. That includes `count_distinct` over a JSON-stored type and over a `multiple: true` declaration ("… declaration accepts: count."), the temporal sentence, the min/max sort sentence, and the non-numeric sentence. No pair moved between accepted and refused. ## Tests (at `3ecaba7595`, after merging `origin/main`, which already contains #24474) - `@objectstack/lint` `validate-dataset-measure-aggregates.test.ts`. New describe block: - `sum` over a formula: hint `=== VIRTUAL_FIELD_AGGREGATE_REMEDY`, contains "store the value as a number field", the remedy comes before the `count` mention, and no "accepts: count"; - `avg`/`min`/`max` read the same sentence; - `os explain` contains the sentence verbatim; - controls: `sum` × `text` keeps "type accepts: count, count_distinct.", `count_distinct` × `select` `multiple: true` keeps "declaration accepts: count.", `count_distinct` × `json` keeps "type accepts: count.", and `count` × formula fires nothing. - The explain pin "STORED as a numeric field (a computed column)" is flipped to "STORED as a numeric field and aggregated as one" plus the remedy sentence. The suite's existing 200-character `fix:` line pin now covers the formula hint too. - `@objectstack/service-analytics` `aggregate-nontemporal-measure-refusal.test.ts`: `sum`/`avg`/`min`/`max` × `formula` gives `DATASET_INVALID` / 400 with no SQL emitted, and the message contains the spec sentence and neither the non-numeric nor the sort sentence. Control: `sum` × `text` keeps "For a non-numeric field". - `@objectstack/spec` `aggregate-field-type-compatibility.test.ts`: the four aggregates over formula return the sentence, `undefined` everywhere else (accepted pairs and stored classes), fail-closed shape cases, one line of at most 200 characters, and the virtual class refused on all four rows. ### Package runs - At `3ecaba7595` (merged head): `@objectstack/spec` `pnpm test`, 699 files / 21281 passed; spec `test:repo` was run before the merge, 70 files / 1162 passed; the three targeted files above, 76 + 49 + 28 passed. - Before the merge (branch head `b3a054b0af` plus its changeset and regen commits): `@objectstack/lint` `pnpm test`, 145 files / 6870 passed; `@objectstack/service-analytics` `pnpm test`, 183 files / 4498 passed, 262 skipped; `typecheck` green for lint, service-analytics and spec. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` at `3ecaba7595` derived 91 commands. All 91 were run, every one exited 0, and `--ran` reports 91 run, 0 NOT-MEASURED, 0 UNRUN. `check:api-surface` and `check:export-origins` went red on the new export as expected and were regenerated (`gen:api-surface`, `gen:export-origins`): two added names, nothing removed. ### Reverse verification (fix committed first; `scripts/ablation-replace.mjs`, which restores to HEAD and checks `git diff HEAD` is empty) - Deleted the lint line `virtualFieldAggregateRemedy(aggregate, fieldType) ??` (anchor 1 → 0, blob e0f8418 → cd6b974fa3a7). Two lint cases went red with Received "Either point "sum" at a field of an accepted type, or aggregate "measured" with one its `formula` type accepts: count, count_distinct." The explain-equality case stayed green, as expected: that text does not depend on the line. Restored: blob == HEAD. - Deleted the compile-leg line `if (virtual !== undefined) return virtual;` (blob 170605d → 68caccc65865). The new compile case went red with the old "For a non-numeric field, `count` accepts every type …" remedy. Restored: blob == HEAD. - Both subjects are imported from `src/` by relative path, so the ablations needed no `dist/` rebuild. ## Acceptance notes - **Same family, other doors, not changed here (outside the claim's file surface).** For a formula, these still name a count as the way out: the analytics door's cube-query refusal (`service-analytics/src/cube-measure-field-type-door.ts`, "… aggregate a field of one of those types, or count the rows with count.", `400 INVALID_FIELD`) and the engine's `aggregate` door (`objectql/src/aggregate-field-type-door.ts`, the same clause). Both can read `virtualFieldAggregateRemedy` the same way. Reported for the seat to place; not measured over HTTP in this run. - **The divergence sentence is still worded for a stored column.** For a formula, the compile leg's divergence sentence still says "derives a NUMBER from the stored values, so over a `formula` column …". The lint message says the same thing ("the number would depend on the SQL dialect"). For a virtual field the real reason is that no column exists. The wording is inaccurate but not misdirecting, since the remedy line now names the fix. Noted only. - **`count_distinct` × `formula`.** The table accepts it, and the shared sentence names it as "a different question". Whether every backend can actually lower a `count_distinct` over a column that is never emitted is not measured here. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3ae0b77 commit 4ad3ce5

10 files changed

Lines changed: 221 additions & 7 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/lint": patch
4+
"@objectstack/service-analytics": patch
5+
---
6+
7+
fix(lint, service-analytics): a total over a formula field names the stored-number remedy, not a count
8+
9+
Clause-②: no
10+
11+
- **What changed.** A dataset or cube measure taking `sum`, `avg`, `min` or `max` over a `formula` field is still refused (`measure-aggregate-field-type-refused` in `os validate`, `400 DATASET_INVALID` at the dataset compile). The prescription changed. It used to name `count` / `count_distinct` as the way out, and a count read as a total is a plausible wrong number (3 records shown as "total leave days" where the true total is 10). It now reads: "The field is computed on read and stores no value: store the value as a number field (kept by a hook) and aggregate that. `count` / `count_distinct` answer a different question: how many records."
12+
- **One source.** The sentence lives in `@objectstack/spec/data` as `VIRTUAL_FIELD_AGGREGATE_REMEDY`, returned by `virtualFieldAggregateRemedy(aggregate, fieldType)` for a refused value-reading aggregate over a virtual type (`SEARCH_VIRTUAL_TYPES`, which is `formula`). The function returns `undefined` for every other pair. The lint hint, its `os explain measure-aggregate-field-type-refused` entry and the dataset compile leg all print this one sentence.
13+
- **What stays the same.** Nothing is newly accepted or refused. Every other refused pair keeps its own hint, including `count_distinct` over a JSON-stored or `multiple: true` field. The analytics door's cube-query refusal (`400 INVALID_FIELD`) and the engine's `aggregate` door keep their own wording.

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1890,8 +1890,15 @@ const MEASURE_AGGREGATE_FIELD_TYPE_REFUSED_EXPLANATION: RuleExplanation = {
18901890
'toggle; `min` and `max`: number, currency, percent, rating, slider, progress, summary, date, ' +
18911891
'datetime, time, boolean, toggle.',
18921892
'So the fix is a field of an accepted type, or an aggregate the field\'s declaration accepts. A ' +
1893-
'quantity that must be added up or averaged has to be STORED as a numeric field (a computed ' +
1894-
'column) and aggregated as one.',
1893+
'quantity that must be added up or averaged has to be STORED as a numeric field and aggregated as ' +
1894+
'one.',
1895+
// Held equal to `VIRTUAL_FIELD_AGGREGATE_REMEDY` (`@objectstack/spec/data`) — the `fix:` line, and
1896+
// the compile leg's remedy, for this pair — by `validate-dataset-measure-aggregates.test.ts`.
1897+
'A `formula` field stores no value, whatever its `returnType` says, so `sum`, `avg`, `min` and `max` ' +
1898+
'over one have no column to read, and a count substituted for the total is a plausible wrong ' +
1899+
'number. Its fix line reads: The field is computed on read and stores no value: store the value as ' +
1900+
'a number field (kept by a hook) and aggregate that. `count` / `count_distinct` answer a different ' +
1901+
'question: how many records.',
18951902
JSON_STORED_COLUMN,
18961903
'The per-type row cannot see `multiple: true`, so `count_distinct` over a multi-capable field ' +
18971904
'flagged that way is refused by the declaration: the value is a list stored as JSON, and ' +

‎packages/lint/src/validate-dataset-measure-aggregates.test.ts‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
MULTI_OPTION_TYPES,
2323
NUMERIC_VALUE_TYPES,
2424
STRUCTURED_JSON_TYPES,
25+
VIRTUAL_FIELD_AGGREGATE_REMEDY,
2526
isAggregateCompatibleWithFieldType,
2627
isMultiValueField,
2728
type Field,
@@ -432,6 +433,54 @@ const flaggedStackWith = (aggregate: string, fieldType: string): Record<string,
432433
fields: { name: { type: 'text' }, measured: { type: fieldType, multiple: true } },
433434
});
434435

436+
// [#24426] A total over a VIRTUAL field. A `formula` stores no value, so the
437+
// way to its total is to store the value as a number field and aggregate that;
438+
// the hint used to name `count` / `count_distinct` as the way out instead,
439+
// which turns "total leave days" into a record count. The sentence is the
440+
// spec's, the one the compile leg prints too.
441+
describe('measure-aggregate-field-type-refused — a total over a formula names the stored-number remedy', () => {
442+
it('sum over a formula field: the hint is the spec\'s stored-number remedy, the count only another question', () => {
443+
const found = findings(stackWith('sum', 'formula'));
444+
expect(found).toHaveLength(1);
445+
const [issue] = found;
446+
expect(issue.rule).toBe(RULE);
447+
expect(issue.severity).toBe('error');
448+
expect(issue.path).toBe('datasets[0].measures[0].aggregate');
449+
expect(issue.hint).toBe(VIRTUAL_FIELD_AGGREGATE_REMEDY);
450+
expect(issue.hint).toContain('store the value as a number field');
451+
expect(issue.hint).toContain('answer a different question: how many records');
452+
// The remedy comes first, and no count is offered as the way to the total.
453+
expect(issue.hint.indexOf('number field')).toBeLessThan(issue.hint.indexOf('`count`'));
454+
expect(issue.hint).not.toContain('accepts: count');
455+
expect(issue.hint).not.toContain('Either point');
456+
});
457+
458+
it('avg, min and max over a formula read the same sentence', () => {
459+
for (const aggregate of ['avg', 'min', 'max']) {
460+
const [issue] = findings(stackWith(aggregate, 'formula'));
461+
expect(issue?.hint, aggregate).toBe(VIRTUAL_FIELD_AGGREGATE_REMEDY);
462+
}
463+
});
464+
465+
it('`os explain` holds the same sentence verbatim', () => {
466+
expect(explanationOf(RULE)).toContain(VIRTUAL_FIELD_AGGREGATE_REMEDY);
467+
});
468+
469+
it('the controls: a stored class and the count_distinct × JSON-list case keep their own hints', () => {
470+
const [text] = findings(stackWith('sum', 'text'));
471+
expect(text?.hint).toContain('type accepts: count, count_distinct.');
472+
expect(text?.hint).not.toBe(VIRTUAL_FIELD_AGGREGATE_REMEDY);
473+
const [list] = findings(flaggedStackWith('count_distinct', 'select'));
474+
expect(list?.hint).toContain('declaration accepts: count.');
475+
expect(list?.hint).not.toContain('number field');
476+
const [json] = findings(stackWith('count_distinct', 'json'));
477+
expect(json?.hint).toContain('type accepts: count.');
478+
expect(json?.hint).not.toContain('number field');
479+
// count over a formula is accepted — nothing to prescribe.
480+
expect(findings(stackWith('count', 'formula'))).toEqual([]);
481+
});
482+
});
483+
435484
describe('measure-aggregate-field-type-refused — reads the declaration, not the type alone', () => {
436485
// ⭐ The addendum's pin and its control, one pair.
437486
it('refuses count_distinct over a select declared multiple: true, and accepts it over a single select', () => {
@@ -1271,7 +1320,8 @@ describe('[#22161] one-line fixes — the two ids', () => {
12711320
const facts: Record<string, readonly string[]> = {
12721321
[MEASURE_AGGREGATE_FIELD_TYPE_REFUSED]: [
12731322
'`count` accepts every type, because it reads no value',
1274-
'STORED as a numeric field (a computed column)',
1323+
'STORED as a numeric field and aggregated as one',
1324+
VIRTUAL_FIELD_AGGREGATE_REMEDY,
12751325
"a dataset's by its compile (`400 DATASET_INVALID`)",
12761326
"a cube's by the analytics door when a query names the measure (`400 INVALID_FIELD`)",
12771327
],

‎packages/lint/src/validate-dataset-measure-aggregates.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ import {
199199
STRUCTURED_JSON_TYPES,
200200
isAggregateCompatibleWithFieldType,
201201
isMultiValueField,
202+
virtualFieldAggregateRemedy,
202203
type ValueShapeFieldDef,
203204
} from '@objectstack/spec/data';
204205

@@ -417,6 +418,12 @@ function jsonStoredDimensionFinding(
417418
* further, the stored-numeric route for a sum or an average, and which door
418419
* refuses the same pair later (a dataset's compile, a cube's query) are the
419420
* same explanation's.
421+
*
422+
* [#24426] Except over a VIRTUAL field (`formula`): there the remedy line is
423+
* the spec's `virtualFieldAggregateRemedy`, the sentence the compile leg prints
424+
* too — store the value as a number field and aggregate that — because naming
425+
* `count` as the way out steers a total to a plausible wrong number (a record
426+
* count read as a sum). The count stays named, as a different question.
420427
*/
421428
function refusedMeasureFinding(
422429
site: MemberSite,
@@ -441,6 +448,7 @@ function refusedMeasureFinding(
441448
: `${pair(`\`${fieldType}\``)} is refused by the aggregate × field-type table: the number would depend ` +
442449
'on the SQL dialect',
443450
hint:
451+
virtualFieldAggregateRemedy(aggregate, fieldType) ??
444452
`Either point "${aggregate}" at a field of an accepted type, or aggregate ` +
445453
`"${site.column}" with one its ${declaredAs(shape)} ${shape.multiple === true ? 'declaration' : 'type'} accepts: ` +
446454
`${aggregatesAccepting(shape).join(', ')}.`,

‎packages/services/service-analytics/src/__tests__/aggregate-nontemporal-measure-refusal.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
import { describe, it, expect } from 'vitest';
5858
import {
5959
AGGREGATE_FIELD_TYPE_COMPATIBILITY,
60+
VIRTUAL_FIELD_AGGREGATE_REMEDY,
6061
isAggregateCompatibleWithFieldType,
6162
FieldType,
6263
} from '@objectstack/spec/data';
@@ -242,6 +243,28 @@ describe('#16099 — a DERIVING aggregate over a field type that cannot carry it
242243
expect(err.message).not.toContain('real instant');
243244
});
244245

246+
// [#24426] A total over a VIRTUAL field (`formula`) names the spec's remedy
247+
// first — store the value as a number field and aggregate that — and a count
248+
// only as a different question. The sentence is the spec's, the one the lint
249+
// leg prints too, so the two doors cannot prescribe two different ways out.
250+
it('a total over a formula prints the stored-number remedy — the spec\'s sentence, not the count or sort one', async () => {
251+
for (const aggregate of ['sum', 'avg', 'min', 'max'] as const) {
252+
const { go, sqls } = run(aggregate, 'margin', [{ status: 'open', probe_measure: 1 }]);
253+
const err = await refusalOf(go);
254+
expect(err.code, aggregate).toBe('DATASET_INVALID');
255+
expect(err.status, aggregate).toBe(400);
256+
expect(err.message, aggregate).toContain(VIRTUAL_FIELD_AGGREGATE_REMEDY);
257+
expect(err.message, aggregate).toContain('store the value as a number field');
258+
expect(err.message, aggregate).not.toContain('For a non-numeric field');
259+
expect(err.message, aggregate).not.toContain('SORT on the record list');
260+
expect(sqls.length, aggregate).toBe(0);
261+
}
262+
// Control: a refused pair over a STORED non-numeric class keeps its own words.
263+
const text = await refusalOf(run('sum', 'note').go);
264+
expect(text.message).toContain('For a non-numeric field');
265+
expect(text.message).not.toContain(VIRTUAL_FIELD_AGGREGATE_REMEDY);
266+
});
267+
245268
it('a TEMPORAL refusal keeps #16778\'s own remedy sentence — continuity control', async () => {
246269
const err = await refusalOf(run('avg', 'submitted_at').go);
247270
expect(err.code).toBe('DATASET_INVALID');

‎packages/services/service-analytics/src/dataset-compiler.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
AggregationFunction,
88
isAggregateCompatibleWithFieldType,
99
isMultiValueField,
10+
virtualFieldAggregateRemedy,
1011
} from '@objectstack/spec/data';
1112
import type { Dataset, DatasetMeasure, DatasetDimension } from '@objectstack/spec/ui';
1213
import { resolveI18nLabel } from '@objectstack/spec/ui';
@@ -271,9 +272,15 @@ const DIVERGENCE_BY_AGGREGATE = (aggregate: string, fieldType: string, column =
271272
* counts rows, or counts distinct a scalar field holding the part they meant.
272273
* The other two non-temporal sentences no longer say `count_distinct` accepts
273274
* every type: since #20808 it accepts every type but the JSON-stored ones.
275+
* [#24426] A VIRTUAL field (`formula`) is answered first, and not here: its
276+
* sentence is the spec's `virtualFieldAggregateRemedy`, the one the lint leg
277+
* prints too — store the value as a number field and aggregate that, with a
278+
* count named only as a different question. A count is no way to a total.
274279
*/
275-
const REMEDY_BY_SOURCE_CLASS = (fieldType: string, aggregate: string): string =>
276-
TEMPORAL_SOURCE_FIELD_TYPES.has(fieldType)
280+
const REMEDY_BY_SOURCE_CLASS = (fieldType: string, aggregate: string): string => {
281+
const virtual = virtualFieldAggregateRemedy(aggregate, fieldType);
282+
if (virtual !== undefined) return virtual;
283+
return TEMPORAL_SOURCE_FIELD_TYPES.has(fieldType)
277284
? 'For a temporal field, `min`/`max` return a real instant; a DURATION has to be '
278285
+ 'stored as a number (a computed "days open" field) and aggregated as one.'
279286
: aggregate === 'count_distinct'
@@ -288,6 +295,7 @@ const REMEDY_BY_SOURCE_CLASS = (fieldType: string, aggregate: string): string =>
288295
: 'For a non-numeric field, `count` accepts every type because it reads no value, and '
289296
+ '`count_distinct` every type but the JSON-stored ones; a quantity that should be '
290297
+ 'added up has to be stored as a numeric field and aggregated as one.';
298+
};
291299

292300
/**
293301
* [#16737 / #16099 / #17560] Refuse a measure whose AGGREGATE cannot meaningfully

‎packages/spec/api-surface/data.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,7 @@
804804
"VALUE_ROUNDTRIP_COLLISION_PAIRS (const)",
805805
"VALUE_ROUNDTRIP_FIELDS (const)",
806806
"VALUE_ROUNDTRIP_ROWS (const)",
807+
"VIRTUAL_FIELD_AGGREGATE_REMEDY (const)",
807808
"ValidationAdvisoryEvent (type)",
808809
"ValidationAdvisoryEventSchema (const)",
809810
"ValidationRule (type)",
@@ -994,6 +995,7 @@
994995
"utcInstantMs (function)",
995996
"validateDriverConfig (function)",
996997
"valueRoundTripDivergence (function)",
997-
"valueSchemaFor (function)"
998+
"valueSchemaFor (function)",
999+
"virtualFieldAggregateRemedy (function)"
9981000
]
9991001
}

‎packages/spec/export-origins/data.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,7 @@
790790
"VALUE_ROUNDTRIP_COLLISION_PAIRS": "src/data/value-roundtrip-conformance.ts#VALUE_ROUNDTRIP_COLLISION_PAIRS (const)",
791791
"VALUE_ROUNDTRIP_FIELDS": "src/data/value-roundtrip-conformance.ts#VALUE_ROUNDTRIP_FIELDS (const)",
792792
"VALUE_ROUNDTRIP_ROWS": "src/data/value-roundtrip-conformance.ts#VALUE_ROUNDTRIP_ROWS (const)",
793+
"VIRTUAL_FIELD_AGGREGATE_REMEDY": "src/data/aggregate-field-type-compatibility.ts#VIRTUAL_FIELD_AGGREGATE_REMEDY (const)",
793794
"ValidationAdvisoryEvent": "src/data/data-engine.zod.ts#ValidationAdvisoryEvent (type)",
794795
"ValidationAdvisoryEventSchema": "src/data/data-engine.zod.ts#ValidationAdvisoryEventSchema (const)",
795796
"ValidationRule": "src/data/validation.zod.ts#ValidationRule (type)",
@@ -980,6 +981,7 @@
980981
"utcInstantMs": "src/data/calendar-day.ts#utcInstantMs (function)",
981982
"validateDriverConfig": "src/data/driver/config-registry.zod.ts#validateDriverConfig (function)",
982983
"valueRoundTripDivergence": "src/data/value-roundtrip-conformance.ts#valueRoundTripDivergence (function)",
983-
"valueSchemaFor": "src/data/field-value.zod.ts#valueSchemaFor (function)"
984+
"valueSchemaFor": "src/data/field-value.zod.ts#valueSchemaFor (function)",
985+
"virtualFieldAggregateRemedy": "src/data/aggregate-field-type-compatibility.ts#virtualFieldAggregateRemedy (function)"
984986
}
985987
}

‎packages/spec/src/data/aggregate-field-type-compatibility.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,12 @@ import {
3131
} from './field-value.zod';
3232
import { isIncoherentAggregate } from './aggregation-policy';
3333
import { AGGREGATION_CASES } from './aggregation-conformance';
34+
import { SEARCH_VIRTUAL_TYPES } from './search-fields';
3435
import {
3536
AGGREGATE_FIELD_TYPE_COMPATIBILITY,
37+
VIRTUAL_FIELD_AGGREGATE_REMEDY,
3638
isAggregateCompatibleWithFieldType,
39+
virtualFieldAggregateRemedy,
3740
} from './aggregate-field-type-compatibility';
3841

3942
const sorted = (xs: Iterable<string>) => [...xs].sort();
@@ -244,3 +247,54 @@ describe('isAggregateCompatibleWithFieldType — the pairs the card is about', (
244247
}
245248
});
246249
});
250+
251+
describe('virtualFieldAggregateRemedy — the one prescription for a total over a virtual field', () => {
252+
it('names the stored-number remedy for sum / avg / min / max over a formula, and a count only as another question', () => {
253+
for (const aggregate of ['sum', 'avg', 'min', 'max']) {
254+
// Not vacuous: the table refuses every one of these pairs.
255+
expect(isAggregateCompatibleWithFieldType(aggregate, 'formula'), aggregate).toBe(false);
256+
expect(virtualFieldAggregateRemedy(aggregate, 'formula'), aggregate).toBe(VIRTUAL_FIELD_AGGREGATE_REMEDY);
257+
}
258+
expect(VIRTUAL_FIELD_AGGREGATE_REMEDY).toContain('store the value as a number field');
259+
expect(VIRTUAL_FIELD_AGGREGATE_REMEDY).toContain('and aggregate that');
260+
expect(VIRTUAL_FIELD_AGGREGATE_REMEDY).toContain('answer a different question: how many records');
261+
// The remedy comes FIRST: the count is named only after it.
262+
expect(VIRTUAL_FIELD_AGGREGATE_REMEDY.indexOf('number field'))
263+
.toBeLessThan(VIRTUAL_FIELD_AGGREGATE_REMEDY.indexOf('`count`'));
264+
});
265+
266+
it('is one line of at most 200 characters — the lint prints it whole as its `fix:` line', () => {
267+
expect(VIRTUAL_FIELD_AGGREGATE_REMEDY).not.toContain('\n');
268+
expect(VIRTUAL_FIELD_AGGREGATE_REMEDY.length).toBeLessThanOrEqual(200);
269+
});
270+
271+
it('answers undefined for every other pair — each keeps its own class\'s prescription', () => {
272+
// An accepted pair over the virtual class: no refusal, no prescription.
273+
expect(isAggregateCompatibleWithFieldType('count', 'formula')).toBe(true);
274+
expect(virtualFieldAggregateRemedy('count', 'formula')).toBeUndefined();
275+
expect(virtualFieldAggregateRemedy('count_distinct', 'formula')).toBeUndefined();
276+
// A refused pair over a STORED class keeps its own words.
277+
for (const fieldType of ['text', 'autonumber', 'json', 'datetime', 'percent']) {
278+
for (const aggregate of ['sum', 'avg', 'min', 'max', 'count_distinct']) {
279+
expect(virtualFieldAggregateRemedy(aggregate, fieldType), `${aggregate} × ${fieldType}`).toBeUndefined();
280+
}
281+
}
282+
// Fail-closed on shape, like the predicate.
283+
const loose = virtualFieldAggregateRemedy as unknown as (a: unknown, f: unknown) => string | undefined;
284+
expect(loose(['sum'], 'formula')).toBeUndefined();
285+
expect(loose('sum', ['formula'])).toBeUndefined();
286+
expect(loose(undefined, 'formula')).toBeUndefined();
287+
});
288+
289+
it('the virtual class is the drivers\' storage rule — every type in it is refused for all four value-reading rows', () => {
290+
// The prescription keys on `SEARCH_VIRTUAL_TYPES`; a type joining that set
291+
// must be one this table already refuses to add up, or the remedy would
292+
// speak for an accepted pair.
293+
expect([...SEARCH_VIRTUAL_TYPES]).toContain('formula');
294+
for (const t of SEARCH_VIRTUAL_TYPES) {
295+
for (const aggregate of ['sum', 'avg', 'min', 'max']) {
296+
expect(AGGREGATE_FIELD_TYPE_COMPATIBILITY[aggregate as 'sum'] as readonly string[]).not.toContain(t);
297+
}
298+
}
299+
});
300+
});

0 commit comments

Comments
 (0)