Skip to content

Commit a00d7b7

Browse files
authored
fix(projection): a renamed key with a derived identity serves its item route in all five ports (#412)
* fix(projection): a renamed key with a derived identity serves its item route in all five ports A view-only projection that passes the base key through on a renamed field and omits @fields on its identity had no item route in TypeScript, did not compile in Kotlin and answered 500 in C#. Each port now takes the key from the loader's own derivation. Pinned by projection/keyed-by-derived-identity in the api-contract corpus. * no-mistakes(document): docs(projection): clarify when to omit @fields on projection identities
1 parent fff01da commit a00d7b7

23 files changed

Lines changed: 273 additions & 51 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,23 @@ until you regenerate.
348348

349349
### Fixed
350350

351+
- **TypeScript, Kotlin, C#: a read-only projection whose key is renamed and whose identity omits
352+
`@fields` now serves its item route.** The shape: a view-only `object.projection` that passes
353+
the base entity's key through on a field with another name (`regNo` extending `Invoice.id`) and
354+
declares `identity.primary extends Invoice.pk` with no `@fields`, the derived form the loader
355+
recommends. The loader computes the key from the pass-through field and never writes it back,
356+
but these three ports read the base identity's inherited `@fields` (`id`), which names no field
357+
of the projection. TypeScript mounted no `GET /{id}` route and no by-id query, Kotlin emitted a
358+
table and controller that did not compile, and C# built a model with no key for the
359+
projection, so every request of the application answered `500`. Each now takes the key from
360+
the loader's own derivation (TypeScript `getPkFields`, Kotlin `KotlinGenUtil.keyFields`, C#
361+
`MetaIdentity.Fields`), so all five ports serve `GET /{id}` on the renamed field, answer an
362+
unknown key with the `404 {"error": "not_found"}` envelope and refuse the item write verbs
363+
with the `405` envelope. Java and Python already did. A projection with an explicit `@fields`
364+
(which must equal the computed key) or a key that keeps its base name generates the same
365+
bytes as before. Gated in all five ports by `projection/keyed-by-derived-identity.yaml`.
366+
The Java loader's derivation is now a public `ValidationPhase.computePassthroughKey` that its
367+
own validation pass calls.
351368
- **Python, C#, Java: the generic `view.*` controls now load.** A document carrying
352369
`view.text`, `view.dropdown` or any of the other web-presentation controls (`textarea`, `date`,
353370
`month`, `hotlink`, `radio`, `checkbox`, `number`, `password`, `hidden`, `web`, `image`)

‎docs/CONFORMANCE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,10 +291,10 @@ scenarios the corpus carries six sub-corpora — `tph/` (10, single-table
291291
inheritance), `m2m/` (9 — 3 plain, 5 gating TPH x M:N together, the combination
292292
each corpus alone could not reach, and 1 pinning the collection-URL spelling),
293293
`jsonb/` (2, typed value-object columns),
294-
`write-through/` (2, table-write + view-read entities), `projection/` (11, a
294+
`write-through/` (2, table-write + view-read entities), `projection/` (12, a
295295
read-only view answers reads and refuses writes with 405; a projection with no declared
296296
identity has no item route; a projection keyed on a field not named `id` is addressed by
297-
it; decimal and float fields filter) and `report/` (13,
297+
it, whether its identity names `@fields` or derives them; decimal and float fields filter) and `report/` (13,
298298
FR-044: a view-backed `object.report` is listed, filtered, sorted and paged on
299299
its derived fields, answers `POST` with 405 and mounts no `/{id}`). All 5 ports — TS, Java,
300300
Kotlin, C#, Python — run it in BOTH lanes: a hand-rolled reference server and

‎docs/features/source-kinds.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,17 @@ owning entity's own columns, declare it as an `object.projection`, not an `objec
124124
present, MUST extend an entity identity; the example below omits it (a keyless read
125125
model).
126126

127+
> **Projection identities and the `@fields` attribute.** When a projection declares an
128+
> identity by extending an entity's identity, the `@fields` attribute is optional. When
129+
> omitted, the loader derives the identity key from the projection's fields that pass
130+
> through the base identity's key field(s). When a projection passes the key through on a
131+
> renamed field, omitting `@fields` (the recommended form) lets the loader automatically
132+
> compute the key — if you instead declare `@fields` explicitly and name a different field,
133+
> the declaration is honored but diverges from what the key actually is. Use the derived
134+
> form (omit `@fields`) whenever the projection's key field is derived from or passed
135+
> through from the base identity, and use explicit `@fields` only when the key is redefined
136+
> to an unrelated field.
137+
127138
**The view's SQL is generated — never hand-write it.** The `CREATE VIEW` body is
128139
derived from the projection's `origin.*` children (passthrough columns, aggregates,
129140
collections) and emitted by the Node `meta migrate` (schema migrations are Node-owned

‎fixtures/api-contract-conformance/projection/README.md‎

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,14 @@ the cross-port contract would drop a capability two ports already shipped.
3535
on a field named `number`, and its view has no `id` column at all. `GET
3636
/api/invoice_ledgers/2` answers that row, an unknown key answers the
3737
`404 {"error": "not_found"}` envelope, and the item write verbs are refused with the `405`
38-
envelope. The identity names its key explicitly (`@fields: number`) because that is the form
39-
every port reads the key from.
38+
envelope. The identity names its key explicitly (`@fields: number`).
39+
- **So does a key whose identity omits `@fields`.** `InvoiceRegister` passes the key through on
40+
`regNo` and declares `identity.primary extends Invoice.pk` with no `@fields`, the derived form
41+
the loader recommends: it computes the key from the pass-through field and never writes it
42+
back. The contract is the one `InvoiceLedger` pins. A port that read the base entity's
43+
inherited `@fields` (`id`) instead of the computed key broke here: TypeScript mounted no item
44+
route, Kotlin emitted a table and controller that did not compile, and C# built a model with
45+
no key for the projection, so every request answered `500`.
4046
- **Filters apply to `field.decimal` and `field.float`.** `InvoiceLedger` carries one of each;
4147
the scenarios assert only how many rows match, because each port spells a decimal its own way.
4248
- **The api docs list the same routes** (`docs-routes.json`, see below).
@@ -55,7 +61,7 @@ the cross-port contract would drop a capability two ports already shipped.
5561
```
5662
projection/
5763
├── README.md # this file
58-
├── meta.json # writable Invoice + three view-only projections
64+
├── meta.json # writable Invoice + four view-only projections
5965
├── seed.json # 4 seed Invoice rows (the views derive; they are never seeded)
6066
├── docs-routes.json # the routes each projection's api docs page lists, in every port
6167
└── scenarios/
@@ -68,14 +74,15 @@ projection/
6874
├── write-verbs-405.yaml # POST/PATCH/PUT/DELETE → 405 + envelope
6975
├── keyless-no-item-route.yaml # no declared identity (even with an `id` field) → no /{id} route
7076
├── keyed-by-non-id-field.yaml # key on `number`, view with no `id` column → that row, 404 envelope
77+
├── keyed-by-derived-identity.yaml # key on `regNo`, identity omits `@fields` → the same contract
7178
├── filter-decimal.yaml # FR-009 filter on a field.decimal
7279
└── filter-float.yaml # FR-009 filter on a field.float
7380
```
7481

75-
The three projections: `InvoiceSummary` (key passed through from `Invoice` on `id`),
76-
`InvoiceLedger` (key on `number`; also the decimal and float fields) and `InvoiceStub` (no
77-
identity). `docs-routes.json` is read by a per-port docs test, not by the scenario runners: it
78-
lists, for each projection, the `GET` routes its api docs page documents (no write verb, and no
82+
The four projections: `InvoiceSummary` (key passed through from `Invoice` on `id`),
83+
`InvoiceLedger` (key on `number`, `@fields` explicit; also the decimal and float fields),
84+
`InvoiceRegister` (key on `regNo`, `@fields` omitted) and `InvoiceStub` (no identity).
85+
`docs-routes.json` is read by a per-port docs test, not by the scenario runners: it lists, for each projection, the `GET` routes its api docs page documents (no write verb, and no
7986
`/{id}` for a keyless one), spelled without the api prefix and with `{id}`.
8087

8188
`seed.json` seeds the base `invoices` table. The views are created by each port's
@@ -101,11 +108,11 @@ nothing about the emitted artifact, which is the thing that was missing.
101108

102109
| Port | Generated lane | Note |
103110
|---|---|---|
104-
| TypeScript | **wired, green (11/11)** | `test/api-contract-projection.test.ts` |
105-
| Python | **wired, green (11/11)** | `tests/integration/test_api_contract_projection.py` |
106-
| C# | **wired, green (11/11)** | `MetaObjects.IntegrationTests/Api/ApiContractProjectionConformanceTest.cs` |
107-
| Java | **wired, green (11/11)** | `integration-tests/.../ProjectionGeneratedApiContractConformanceTest.java` |
108-
| Kotlin | **wired, green (11/11)** | `integration-tests-kotlin/.../ProjectionGeneratedApiContractConformanceTest.kt` |
111+
| TypeScript | **wired, green (12/12)** | `test/api-contract-projection.test.ts` |
112+
| Python | **wired, green (12/12)** | `tests/integration/test_api_contract_projection.py` |
113+
| C# | **wired, green (12/12)** | `MetaObjects.IntegrationTests/Api/ApiContractProjectionConformanceTest.cs` |
114+
| Java | **wired, green (12/12)** | `integration-tests/.../ProjectionGeneratedApiContractConformanceTest.java` |
115+
| Kotlin | **wired, green (12/12)** | `integration-tests-kotlin/.../ProjectionGeneratedApiContractConformanceTest.kt` |
109116

110117
The docs half (`docs-routes.json`) runs in each port's unit-test project, over the same
111118
`meta.json`:

‎fixtures/api-contract-conformance/projection/docs-routes.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"units": {
44
"InvoiceSummary": ["GET invoice_summaries", "GET invoice_summaries/{id}"],
55
"InvoiceLedger": ["GET invoice_ledgers", "GET invoice_ledgers/{id}"],
6+
"InvoiceRegister": ["GET invoice_registers", "GET invoice_registers/{id}"],
67
"InvoiceStub": ["GET invoice_stubs"]
78
}
89
}

‎fixtures/api-contract-conformance/projection/meta.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@
3737
{ "identity.primary": { "name": "pk", "extends": "Invoice.pk", "@fields": "number" } }
3838
]
3939
}},
40+
{ "object.projection": {
41+
"name": "InvoiceRegister",
42+
"children": [
43+
{ "source.rdb": { "@kind": "view", "@view": "v_invoice_register" } },
44+
{ "field.long": { "name": "regNo", "extends": "Invoice.id", "@filterable": true, "@sortable": true } },
45+
{ "field.string": { "name": "reference", "extends": "Invoice.reference", "@filterable": true, "@sortable": true } },
46+
{ "identity.primary": { "name": "pk", "extends": "Invoice.pk" } }
47+
]
48+
}},
4049
{ "object.projection": {
4150
"name": "InvoiceStub",
4251
"children": [
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
name: projection-keyed-by-derived-identity
2+
description: >
3+
InvoiceRegister passes the Invoice key through on a field named `regNo` (its view has
4+
NO `id` column) and its identity omits `@fields` — the derived form the loader
5+
recommends: the key is computed from the pass-through field, never restated. The item
6+
route addresses that computed field, exactly as it does for InvoiceLedger's explicit
7+
`@fields: number`. A known key answers its row, an unknown one answers the 404 envelope,
8+
and the item write verbs are refused with the 405 envelope.
9+
requests:
10+
- id: list
11+
method: GET
12+
path: "/api/invoice_registers?sort=regNo:asc"
13+
expect:
14+
status: 200
15+
body:
16+
equals:
17+
- { regNo: 1, reference: "INV-1001" }
18+
- { regNo: 2, reference: "INV-1002" }
19+
- { regNo: 3, reference: "INV-1003" }
20+
- { regNo: 4, reference: "INV-1004" }
21+
- id: get-known
22+
method: GET
23+
path: /api/invoice_registers/2
24+
expect:
25+
status: 200
26+
body:
27+
row: { regNo: 2 }
28+
- id: get-unknown
29+
method: GET
30+
path: /api/invoice_registers/999
31+
expect:
32+
status: 404
33+
body:
34+
error: "not_found"
35+
- id: patch-item
36+
method: PATCH
37+
path: /api/invoice_registers/2
38+
body: { reference: "X" }
39+
expect:
40+
status: 405
41+
body:
42+
error: "method_not_allowed"
43+
- id: put-item
44+
method: PUT
45+
path: /api/invoice_registers/2
46+
body: { regNo: 2, reference: "X" }
47+
expect:
48+
status: 405
49+
body:
50+
error: "method_not_allowed"
51+
- id: delete-item
52+
method: DELETE
53+
path: /api/invoice_registers/2
54+
expect:
55+
status: 405
56+
body:
57+
error: "method_not_allowed"

‎server/csharp/MetaObjects.IntegrationTests/Api/ProjectionFixture.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ CREATE VIEW "v_invoice_ledger" AS
4141
i."discount" AS "discount",
4242
i."weight" AS "weight"
4343
FROM "invoices" i;
44+
-- InvoiceRegister: key on `regNo`, identity omits @fields; the view has NO id column.
45+
CREATE VIEW "v_invoice_register" AS
46+
SELECT i."id" AS "regNo",
47+
i."reference" AS "reference"
48+
FROM "invoices" i;
4449
-- InvoiceStub: no declared identity; the view carries an id column all the same.
4550
CREATE VIEW "v_invoice_stub" AS
4651
SELECT i."id" AS "id",

‎server/csharp/MetaObjects.IntegrationTests/Api/ProjectionGeneratedServerFactory.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,9 @@ private static (Assembly Assembly, IReadOnlyList<string> RoutedNames) CompileGen
102102
.Where(o => RoutesGenerator.AppliesTo(o, root))
103103
.Select(o => CSharpNaming.Pascal(o.Name))
104104
.ToList();
105-
if (routedNames.Count != 4)
105+
if (routedNames.Count != 5)
106106
throw new InvalidOperationException(
107-
"expected routes for Invoice, InvoiceSummary, InvoiceLedger AND InvoiceStub, got: "
107+
"expected routes for Invoice, InvoiceSummary, InvoiceLedger, InvoiceRegister AND InvoiceStub, got: "
108108
+ string.Join(", ", routedNames));
109109

110110
var ctx = new GenContext

‎server/csharp/MetaObjects/Loader/ValidationPasses.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1507,7 +1507,7 @@ public static IReadOnlyList<MetaError> ValidateIdentityPassthrough(MetaData root
15071507
private static List<string>? IdentityEffectiveFields(MetaData identity)
15081508
=> NormalizeIdentityFields(identity.Attr(IDENTITY_ATTR_FIELDS));
15091509

1510-
private static (MetaData Entity, List<string> ComputedFields, List<string> Missing)?
1510+
internal static (MetaData Entity, List<string> ComputedFields, List<string> Missing)?
15111511
ResolveIdentityPassthrough(MetaData identity)
15121512
{
15131513
var extended = identity.SuperData;

0 commit comments

Comments
 (0)