Skip to content

Commit d98bf24

Browse files
fix(objectql)!: engine aggregate judges the sum row too — sum over a refused type answers INVALID_FIELD / 400 on every driver (#21103)
Fixes #20914 Clause-②: no (narrowing) The follow-up round of this card, after PR #21037 landed as `a75311dd2` (`Part of`, the `sum` row held). It executes triage's answer 5924445782 (A), whose execution line reads: "`'sum'` leaves `ROWS_HELD_FOR_TRIAGE`, and the held pins flip. If the constant is then empty, delete it." With this PR the engine's aggregate door asks every row of `AGGREGATE_FIELD_TYPE_COMPATIBILITY`, so the card's direction (the whole table) is complete. ## What this changes - `packages/objectql/src/aggregate-field-type-door.ts`: `'sum'` leaves `ROWS_HELD_FOR_TRIAGE`. The set is then empty, so the constant is deleted with its one read. `assertAggregationFieldTypesAccepted` now judges all six rows (`count` refuses no type). ⛔ No second table and no per-pair exemption: the door still carries no accept list of its own. In the module header, the section "The row the census held back" becomes "Every row is judged — `sum` included" and records the release. It also gains the measured `sum` table, and "a held row" leaves the not-judged list. - The refusal words are the door's existing machinery (`FUNCTION_WORDS.sum`: "sums" / "does not sum"). The route is read off the table's `sum` row: `sum accepts a field of type number, currency, rating, slider, progress, summary, boolean or toggle: aggregate a field of one of those types, or count the rows with count.` It sits inside the 500 characters the REST door keeps. - `packages/spec/src/data/aggregate-field-type-compatibility.ts`, TSDoc only. Review 5924038187 ① 7 named three published sentences; each now states the rows the engine door judges: - the module header: "on every row: `count_distinct` since #20808 …, and `sum`, `avg`, `min` and `max` since #20914 (`count` refuses no type, so its row never refuses there)". The pointer to an objectql source file a spec consumer cannot see is gone. - the predicate's TSDoc: "on every row of the table". - the "does NOT do" section: "at query time, on every row too". - `radio` joins the multi-capable list in the JSON-stored paragraph (`MULTI_CAPABLE_TYPES` holds it). - A comment-stripped compare (the TypeScript printer with `removeComments`) of the file at base and head is byte-identical, sha256 `22d8df93…` on both sides: ⛔ the table and the predicate did not move. The new clause is in the built `dist/data/index.d.ts`, and "other rows since #20914" occurs in 0 built `.d.ts`. - `.changeset/20914-release-sum-row.md`: - `@objectstack/objectql: minor` with a BREAKING banner and `Clause-②: no (narrowing)`. It carries the FROM → TO per type class, what to write instead, and who is affected. - Exactly one ADR-0087 marker: `not-required (already-registered dataset-measure-aggregate-field-type-refused)`. That registered id's prescription already covers `sum` / `avg` over every class the table refuses; the selecting id covers only `min` / `max`. - It names the earlier entry's `radio` omission (review ① 3) in its own text, and that entry's "Not judged yet: `sum`" paragraph as superseded. ⛔ The landed file is not edited. - `@objectstack/spec: patch` for the TSDoc. ## FROM → TO, measured on three drivers (H2) Through `engine.aggregate`, two rows, on the real `InMemoryDriver`, on `SqlDriver` over SQLite, and on a private PostgreSQL 16.14. Before = base `2821e9f15`; after = this branch's built `dist`. | `sum` over | before: memory / SQLite / PostgreSQL | after, all three | |:--|:--|:--| | `json` | `0` / `0` / **500** `function sum(json) does not exist` | 400 `INVALID_FIELD` | | `text` | `0` / `0` / **500** `function sum(text) does not exist` | 400 `INVALID_FIELD` | | single-value `select` | `0` / `0` / **500** `function sum(character varying) does not exist` | 400 `INVALID_FIELD` | | `formula` | `0` / 400 `INVALID_FIELD` (driver: no column) / 400 (driver: no column) | 400 `INVALID_FIELD`, the engine's words | | `tags` | `0` / `0` / **500** | 400 `INVALID_FIELD` | | `datetime` | `0` / `4052` (the years added) / **500** | 400 `INVALID_FIELD` | | `percent` (refused by the table: a rate does not add) | `30` / `30` / `30` | 400 `INVALID_FIELD` | | controls: `currency`, `number`, `boolean`; `max` `number`; `count` `json` | `30`, `3`, `1`, `2`, `2` on all three | unchanged | The `percent` row is the one whose old answer agreed across drivers. The table refuses it on the semantic ground its TSDoc names (`isIncoherentAggregate`), and it is in the changeset's FROM → TO with its route (`avg`). ## The census, re-run before the flip (H1) Query: every line carrying a `'sum'` / `"sum"` literal, plus the unquoted and backtick spellings. Each hit was resolved by hand to its object and the field's declared type, then asked of the table. - **`examples/**` at `2821e9f15`: 17 lines, 0 refused pairs.** They are dataset measures, roll-up `summaryOperations`, a cube measure, a `kpi` metric and an `ObjectChart` aggregate. Every operand is a `currency`, `number` or `summary` field (11 distinct object.field pairs). - **hotcrm (`objectstack-ai/hotcrm` cloned at its head `fb408a73`): 33 lines in `src`.** One is a comment (a `derived` op), which leaves 32 authored `sum` aggregations over 18 distinct object.field pairs. 31 are over `currency` or `number`. **1 refused pair:** `src/sales/views/forecast.view.ts:28`, `crm_forecast.expected_amount` (`Field.formula`), the list-column `summary: 'sum'`. That is the known hit: triage-answered and carried by objectstack-ai/hotcrm#1980, which has not changed it yet. **No other hit, so the flip stands.** Outside `src`: 2 lines in `test`, and 3 in docs and the changelog (prose). - The query is its own positive control: it finds the known hotcrm hit. ## Pins: the held `sum` pins flipped, ⛔ not deleted - `packages/objectql/src/engine-aggregate-field-type-door.test.ts`: - The CONTROL's two held cases (`sum meta`, `sum title`, which reached the driver while the row was held) move into a new refusal test. It covers `sum` over `json`, `text`, `select`, `formula`, `tags`, `percent`, `datetime` and a `select` with `multiple: true`, with envelope `INVALID_FIELD` / 400 / `httpStatus` 400, `field` / `fields` / `object` / `param`, the message head and the route, and no read. - The CONTROL gains `sum` over a `currency` and a `boolean`. - The GUARD that asserted "no verdict for `sum` × every type" now walks every row read off the table, so a row the door skipped would turn it red. It asserts the key set is the six functions, with a `sum` floor beside the others. - The fail-closed GUARD covers `sum` too. - `packages/rest/src/data-aggregate-field-type-door.test.ts`: a `sum` refusal cell over `json`, `text`, `select`, `tags` and `formula` at `POST /api/v1/data/:object/query` (SQLite always; PostgreSQL where `OS_TEST_POSTGRES_URL` is set). The CONTROL gains `sum` over a `currency` (60) and a `boolean` (2). ⚠️ As before, no CI job provisions the PostgreSQL / MySQL cells; the PostgreSQL evidence is the local run below. - `packages/objectql/src/engine-json-stored-group-distinct-door.test.ts`, its GUARD clause: `sum` moves from "passes, held for triage" to the refused set beside `avg` / `min` / `max` over `json`, in the same envelope at the same position. `count` still passes. ## Ablation (from committed code, `1519ed6bd`) - **Mutate.** `node scripts/ablation-replace.mjs` (WRAP, trap-restored) put `sum` back in the held set: the table-vocabulary `continue` became `… || (fn === 'sum' ? 'ablation-20914-sum-A1' : '')) continue;`. Anchor 1 → 0, blob `9a2bc89178af` → `adcfb213d670`. objectql was rebuilt, and `ablation-dist-preflight.mjs` found the marker in 4 built files. - **Predicted:** red on the flipped `sum` pins only. - **Observed, objectql:** 3 failed / 28 passed — the new `sum` refusal test, the every-row GUARD (`sum × text: expected null`), and the group-distinct GUARD clause. Every other pin was green. - **Observed, REST:** 2 failed / 14 passed / 8 skipped (MySQL) — the `sum` cell on SQLite and on PostgreSQL. The controls and the `min` / `max` / `avg` / `count_distinct` cells were green. - **Restore.** Blob == HEAD (`9a2bc89178af`), `git diff HEAD` 0 bytes, rebuilt. `--absent` found the marker in 0 of 14 built files and the tree clean. The same pins were green again: objectql 31 / 31, REST 16 passed + 8 skipped. ## Tests (at `1519ed6bd`, the merge of `origin/main` `b3d7a7086`; all with a private PostgreSQL 16.14 where a cell reads it) - objectql full: 353 files, 6889 passed. `pnpm --filter @objectstack/objectql typecheck` exit 0; test layer 40 files / 234 errors / 65 pinned, held. - rest full (`OS_TEST_POSTGRES_URL` set): 259 files, 5124 passed / 52 skipped. typecheck exit 0; test layer 0 / 0 / 0. - metadata-protocol full: 196 files passed, 3 skipped; 2930 tests passed, 19 skipped. - service-analytics full: 154 files, 3498 passed / 10 skipped. - Every other test file in the workspace that mentions a `sum` aggregation and reaches the engine: runtime 4 files / 25 tests, plugin-security 2 / 297, platform-objects 2 / 56, mcp 1 / 17, dogfood 2 / 12. All passed. - Driver conformance before and after: 50 covered, 0 DEBT, 0 exempt; the two readings are identical. - Lint, narrowed and proven at `1519ed6bd`: - eslint `--no-inline-config --format json` over the 6 changed `.ts` files: 6 results, 0 errors, 0 warnings; - ESLint `isPathIgnored` is false for all 6; - `calculateConfigForFile` shows no `parserOptions.project` / `projectService` on any of them. No type-aware lint is on, so this diff cannot move a verdict on an untouched file. - Repo-wide `pnpm lint` is CI's. - `pnpm --filter @objectstack/spec check:generated`: all 15 generated artifacts up to date. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, run with no paths, derived 88 commands at `1519ed6bd`. All 88 were run, each exit code recorded before any pipe, and all exited 0. `--ran` reconciles: 88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN. Among them: `check:adr-0087-registration` (accepted, `not-required (already-registered)`), `check:changeset-no-major`, `check:empty-changeset`, `check:doc-authoring`, `check:nul-bytes`, `check:engine-double-contract` and `check:api-surface`. NOT MEASURED locally (CI-owned): the 5 path-scheduled CI jobs, the workspace type-check lanes beyond objectql and rest, the MySQL cells (no server here), and repo-wide `pnpm lint`. `origin/main` moved 13 commits after this merge. A local `git merge-tree` of the head with it is clean. One of them, `88b484e00`, touches `engine.ts` where metadata collections load (`picklists`), not at the aggregate call site. The merge queue rebuilds on current `main`. ## Deviations from the claim's file surface, each named - `packages/objectql/src/engine.ts`, **one comment** at the aggregate door's call site. "The `sum` row is held back by that card's census (see the door's header)" would have been false after this PR. It now says every row is asked, and adds the measured `sum` answer. No code line moved. - `radio` added to two comment lists of the multi-capable types: the door's DECLARATION paragraph, and the spec TSDoc's JSON-stored paragraph. This is the same omission review ① 3 named in the landed changeset. The door's own behaviour already held `radio` (it asks `isMultiValueField`). ## Acceptance notes - `sum` × `percent` answers with the door's generic reason ("accepts only the pairs every backend answers alike, and it refuses this one"), which is true but not the table's ground for that pair (a rate does not add). Its route lists the accepted types, not `avg`; the changeset carries the `avg` route. A rate-specific reason would be a words change on the door, so it was left out of this PR. Carrier: none. - The hotcrm exhibit (`crm_forecast.expected_amount`, `summary: 'sum'` on a `formula`) stays with objectstack-ai/hotcrm#1980. It is a client-side list footer and does not reach `engine.aggregate`, so this PR does not change what that view shows. --- _Generated by [Claude Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 32d3b3c commit d98bf24

7 files changed

Lines changed: 183 additions & 62 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
fix(objectql)!: the engine's `aggregate` judges the `sum` row of the aggregate × field-type table too, so `sum` over a type the table refuses answers `INVALID_FIELD` / 400 on every driver instead of `0` in memory and on SQLite and a 500 on PostgreSQL
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (already-registered dataset-measure-aggregate-field-type-refused) the pairs this change refuses are exactly the pairs the sum row of AGGREGATE_FIELD_TYPE_COMPATIBILITY already refuses, and the table is not edited: that id's prescription covers sum / avg over every field class the table refuses (widened to them by a later change that registered against it), with its routes (count, an aggregate the type accepts, or a numeric field for a quantity stored otherwise). This change lets the engine door ask the one row it had skipped; it refuses a query shape, not a stored one, and no authorable key, export or stored row moves. -->
11+
12+
**BREAKING** (`@objectstack/objectql`): this narrows what `aggregate` accepts, on every driver and for every caller that reaches the engine — the REST query door, a flow or hook, a roll-up summary's recompute, and the analytics strategy that lowers a cube query onto `engine.aggregate`. Shipped as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
13+
14+
This completes the change of this same release that made the engine's `aggregate` ask the table for `min`, `max` and `avg`, and that held the `sum` row back. Its paragraph "Not judged yet: `sum`" is superseded: this entry is the later word, and the door now asks every row of the table.
15+
16+
FROM → TO, per aggregation `{ function: 'sum', field }` naming a declared field:
17+
18+
- `sum` over a type outside `number`, `currency`, `rating`, `slider`, `progress`, `summary`, `boolean` and `toggle` — a `percent` (a rate does not add), the temporal types (`date`, `datetime`, `time`), the string family (`text`, `email`, `url`, `phone`, …), the option and reference types (`select`, `radio`, `lookup`, `master_detail`, `tree`, `user`), `autonumber`, the file family, the structured-JSON types (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`), the multi-option types (`multiselect`, `checkboxes`, `tags`) and `formula` — and over any `select`, `radio`, `lookup`, `user`, `file` or `image` declared `multiple: true`: FROM whatever the driver answered TO `400 INVALID_FIELD`. Measured through `engine.aggregate` over two rows: a `json`, `text`, `select` or `tags` field summed to `0` in memory and on SQLite and answered 500 `DATABASE_ERROR` on PostgreSQL 16 (`function sum(json) does not exist`); a `datetime` field summed to `0` in memory, to the years added on SQLite and a 500 on PostgreSQL; a `formula` field summed to `0` in memory and was already refused `400 INVALID_FIELD` by both SQL drivers, which have no column for it; a `percent` field added the rates on all three.
19+
20+
**What an author sees now.** `400 INVALID_FIELD`, naming the position (`aggregations[0].field`), what the function does and the field with its declaration (`sums 'meta', a declared json field — a structured-JSON value`), saying the query was not run, and naming the types `sum` accepts, read off the table, inside the first 500 characters the REST door keeps. The thrown error carries `field`, `fields` (every offending aggregation), `object` and `param` (`aggregations`).
21+
22+
**What to write instead.** Sum a field of a type `sum` accepts: `number`, `currency`, `rating`, `slider`, `progress`, `summary`, `boolean` or `toggle`. A rate stored as a `percent` is averaged (`avg` accepts it), or the quantity it is a rate of is summed. A value computed by a `formula` is stored in a numeric field of its own when it must be summed on the server. A question that was counting in disguise is `count`.
23+
24+
**Who is affected.** A caller that asked `sum` of such a field on the in-memory driver or SQLite and read the `0` as a real total, and a caller that summed a `percent` field on any driver. On PostgreSQL the other measured pairs were already refused (a 500, or a 400 for a `formula`), and on SQLite so was a `formula`. Metadata that lowers onto `engine.aggregate` takes the same verdict at run time: a roll-up summary (`summaryOperations`) whose `sum` names such a child field records a failed recompute, a grouped list view's server-side header summary is refused, and a chart or metric component's `sum` over such a field is refused. No example app authors such a pair. One published stack authors a `sum` list-column summary over a `formula` field; that summary is computed client-side and does not reach `engine.aggregate`.
25+
26+
**Unchanged.** Every pair the table accepts, `sum` over the eight types above included; `count` over any field; an aggregation that names no field; an undeclared name or a relationship path, which this door does not judge (the REST door answers an unknown name `INVALID_FIELD` before the engine is reached); a field whose declared type is outside `FieldType`.
27+
28+
**A correction to the earlier entry of this release.** It listed the multi-capable types declared `multiple: true` as `select`, `lookup`, `user`, `file` or `image`; the list is `select`, `radio`, `lookup`, `user`, `file` and `image` (`MULTI_CAPABLE_TYPES`). A `radio` declared `multiple: true` was refused by `min` / `max` / `avg` there all the same, by its type's own row, and it is refused by `sum` here.
29+
30+
`@objectstack/spec`: the TSDoc of `AGGREGATE_FIELD_TYPE_COMPATIBILITY` and `isAggregateCompatibleWithFieldType` states that the engine's `aggregate` door asks every row of the table, where it said "the other rows" while one was held, and names `radio` among the multi-capable types. The table and the predicate are unchanged.

‎packages/objectql/src/aggregate-field-type-door.ts‎

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@
3636
* | `avg` over a `datetime` field | 200, `null` | 200, `2026` | **500 `DATABASE_ERROR`** |
3737
* | `max` over a `number`, `min` over a `datetime`, `avg` over a `percent` (the controls) | one answer | the same | the same |
3838
*
39+
* [#20914] measured the `sum` row on `origin/main` `2821e9f15`, the same way:
40+
*
41+
* | aggregation | InMemoryDriver | SqlDriver, SQLite | SqlDriver, PostgreSQL 16 |
42+
* |:--|:--|:--|:--|
43+
* | `sum` over a `json`, `text`, single-value `select` or `tags` field | 200, `0` | 200, `0` | **500 `DATABASE_ERROR`** (`function sum(json)` — or `sum(text)`, `sum(character varying)` — `does not exist`) |
44+
* | `sum` over a `datetime` field | 200, `0` | 200, `4052` (the years added) | **500 `DATABASE_ERROR`** |
45+
* | `sum` over a `formula` field | 200, `0` | 400 `INVALID_FIELD` (no column) | 400 `INVALID_FIELD` (no column) |
46+
* | `sum` over a `percent` field (refused by the table: a rate does not add) | 200, the rates added | the same | the same |
47+
* | `sum` over a `number`, `currency` or `boolean` (the controls) | one answer | the same | the same |
48+
*
3949
* ## Whose verdict it is
4050
*
4151
* The TYPE half is the spec table's: `AGGREGATE_FIELD_TYPE_COMPATIBILITY`
@@ -46,25 +56,28 @@
4656
* nothing. The refusal names the row's accepted set, read off the table.
4757
*
4858
* The DECLARATION half is `isMultiValueField`: a multi-capable type flagged
49-
* `multiple: true` (`select`, `lookup`, `user`, `file`, `image`) is stored in
50-
* the JSON column the multi-option types (`MULTI_OPTION_TYPES`) are stored in
51-
* and holds the same value — a list — but a per-TYPE table cannot see the
52-
* flag. So the declaration takes the verdict the row gives that class: a row
53-
* that refuses any multi-option type refuses a multi-value declaration too
54-
* (`count_distinct`, `sum`, `avg`, `min`, `max`), and a row that accepts the
55-
* whole class accepts it (`count`, which compares no value). A field is
56-
* refused if either half refuses it.
59+
* `multiple: true` (`MULTI_CAPABLE_TYPES`: `select`, `radio`, `lookup`,
60+
* `user`, `file`, `image`) is stored in the JSON column the multi-option types
61+
* (`MULTI_OPTION_TYPES`) are stored in and holds the same value — a list — but
62+
* a per-TYPE table cannot see the flag. So the declaration takes the verdict
63+
* the row gives that class: a row that refuses any multi-option type refuses a
64+
* multi-value declaration too (`count_distinct`, `sum`, `avg`, `min`, `max`),
65+
* and a row that accepts the whole class accepts it (`count`, which compares
66+
* no value). A field is refused if either half refuses it.
5767
*
58-
* ## The row the census held back: `sum`
68+
* ## Every row is judged — `sum` included
5969
*
6070
* [#20914] The triage direction sent the door to the whole table "census
6171
* first": an authored pair the table refuses, in `examples/**` or a published
62-
* stack, stops that row and goes back to triage. The census found one, in the
63-
* published hotcrm stack: a grouped list view whose column summary sums a
64-
* `formula` field (`sum` × `formula`, refused by the table — a formula is
65-
* virtual in SQL storage). So `sum` is in {@link ROWS_HELD_FOR_TRIAGE} and is
66-
* not judged here; releasing it is deleting that entry and flipping its pins.
67-
* Every other row is judged.
72+
* stack, stops that row and goes back to triage. The census found one `sum`
73+
* pair, in the published hotcrm stack: a grouped list view whose column
74+
* summary sums a `formula` field (`sum` × `formula`, refused by the table — a
75+
* formula is virtual in SQL storage). The row was held for one landing, then
76+
* released by triage's answer: that summary is a client-side list footer that
77+
* never reaches `engine.aggregate`, and both SQL drivers already refused the
78+
* pair, while `sum` over a `json`, `text` or `select` field answered a
79+
* plausible `0` or a 500. ⛔ No row is held and no pair is exempted: the door
80+
* asks the table's row whole for all six functions.
6881
*
6982
* ## Where it stands, and what it judges
7083
*
@@ -79,12 +92,11 @@
7992
* **Not judged** (no verdict, the aggregation passes on as it came): an
8093
* aggregation that names no field (a fieldless `count`, or `'*'`), a function
8194
* outside the table's vocabulary (that is the query schema's refusal, not a
82-
* field-type one), a held row, an undeclared name (a relationship path
83-
* included), a registry-less host (no field map, no verdict), and a declared
84-
* type outside `FieldType` (a driver-internal alias such as `string` or
85-
* `integer` on an introspected object): the table is fail-closed on
86-
* vocabulary, and "cannot answer, do not block" is this consumer's tier, as
87-
* the table's own TSDoc says.
95+
* field-type one), an undeclared name (a relationship path included), a
96+
* registry-less host (no field map, no verdict), and a declared type outside
97+
* `FieldType` (a driver-internal alias such as `string` or `integer` on an
98+
* introspected object): the table is fail-closed on vocabulary, and "cannot
99+
* answer, do not block" is this consumer's tier, as the table's own TSDoc says.
88100
*
89101
* `INVALID_FIELD`, the code the `groupBy` door beside it answers: the verdict
90102
* is about the NAMED field's type at a position.
@@ -107,13 +119,6 @@ import {
107119
/** The declared `FieldType` vocabulary — the only types the table can answer for. */
108120
const DECLARED_FIELD_TYPES: ReadonlySet<string> = new Set(FieldType.options);
109121

110-
/**
111-
* Rows of the table this door does not judge yet, each held by the census
112-
* that preceded it — see the module header. ⛔ A row is held, never trimmed:
113-
* the door asks the table's row whole or not at all.
114-
*/
115-
const ROWS_HELD_FOR_TRIAGE: ReadonlySet<string> = new Set(['sum']);
116-
117122
/** Does the table's `fn` row accept the multi-value class — every multi-option type? */
118123
function rowAcceptsMultiValue(fn: string): boolean {
119124
for (const type of MULTI_OPTION_TYPES) {
@@ -152,7 +157,6 @@ function refusedAggregations(
152157
const fn = (agg as { function?: unknown }).function;
153158
if (typeof fn !== 'string') continue;
154159
if (!Object.prototype.hasOwnProperty.call(AGGREGATE_FIELD_TYPE_COMPATIBILITY, fn)) continue;
155-
if (ROWS_HELD_FOR_TRIAGE.has(fn)) continue;
156160
const field = (agg as { field?: unknown }).field;
157161
if (typeof field !== 'string' || field === '*') continue;
158162
if (!Object.prototype.hasOwnProperty.call(fields, field)) continue;

0 commit comments

Comments
 (0)