From 4183cc678f13b186eaf99d68c3e12835c1b9f8af Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 6 Oct 2026 03:10:10 -0400 Subject: [PATCH 1/2] 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. --- CHANGELOG.md | 17 ++++++ docs/CONFORMANCE.md | 4 +- .../projection/README.md | 31 ++++++---- .../projection/docs-routes.json | 1 + .../projection/meta.json | 9 +++ .../scenarios/keyed-by-derived-identity.yaml | 57 ++++++++++++++++++ .../Api/ProjectionFixture.cs | 5 ++ .../Api/ProjectionGeneratedServerFactory.cs | 4 +- .../MetaObjects/Loader/ValidationPasses.cs | 2 +- .../csharp/MetaObjects/Meta/MetaIdentity.cs | 14 ++++- .../kotlin/KotlinExposedTableGenerator.kt | 2 +- .../generator/kotlin/KotlinGenUtil.kt | 16 +++++ .../kotlin/KotlinRelationsGenerator.kt | 2 +- .../kotlin/KotlinRepositoryGenerator.kt | 2 +- .../kotlin/KotlinSpringControllerGenerator.kt | 4 +- .../GeneratedProjectionControllerHarness.kt | 6 +- .../GeneratedProjectionControllerHarness.java | 8 ++- .../metaobjects/loader/ValidationPhase.java | 59 ++++++++++++------- .../integration/generated_projection_app.py | 4 +- .../codegen-ts/src/templates/queries.ts | 22 ++++++- .../test/projection/routes-file.test.ts | 37 ++++++++++++ ...pi-contract-projection-generated-server.ts | 7 +++ 22 files changed, 262 insertions(+), 51 deletions(-) create mode 100644 fixtures/api-contract-conformance/projection/scenarios/keyed-by-derived-identity.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 499da1f26..ad2260842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -348,6 +348,23 @@ until you regenerate. ### Fixed +- **TypeScript, Kotlin, C#: a read-only projection whose key is renamed and whose identity omits + `@fields` now serves its item route.** The shape: a view-only `object.projection` that passes + the base entity's key through on a field with another name (`regNo` extending `Invoice.id`) and + declares `identity.primary extends Invoice.pk` with no `@fields`, the derived form the loader + recommends. The loader computes the key from the pass-through field and never writes it back, + but these three ports read the base identity's inherited `@fields` (`id`), which names no field + of the projection. TypeScript mounted no `GET /{id}` route and no by-id query, Kotlin emitted a + table and controller that did not compile, and C# built a model with no key for the + projection, so every request of the application answered `500`. Each now takes the key from + the loader's own derivation (TypeScript `getPkFields`, Kotlin `KotlinGenUtil.keyFields`, C# + `MetaIdentity.Fields`), so all five ports serve `GET /{id}` on the renamed field, answer an + unknown key with the `404 {"error": "not_found"}` envelope and refuse the item write verbs + with the `405` envelope. Java and Python already did. A projection with an explicit `@fields` + (which must equal the computed key) or a key that keeps its base name generates the same + bytes as before. Gated in all five ports by `projection/keyed-by-derived-identity.yaml`. + The Java loader's derivation is now a public `ValidationPhase.computePassthroughKey` that its + own validation pass calls. - **Python, C#, Java: the generic `view.*` controls now load.** A document carrying `view.text`, `view.dropdown` or any of the other web-presentation controls (`textarea`, `date`, `month`, `hotlink`, `radio`, `checkbox`, `number`, `password`, `hidden`, `web`, `image`) diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index f82ef7817..96f595768 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -291,10 +291,10 @@ scenarios the corpus carries six sub-corpora — `tph/` (10, single-table inheritance), `m2m/` (9 — 3 plain, 5 gating TPH x M:N together, the combination each corpus alone could not reach, and 1 pinning the collection-URL spelling), `jsonb/` (2, typed value-object columns), -`write-through/` (2, table-write + view-read entities), `projection/` (11, a +`write-through/` (2, table-write + view-read entities), `projection/` (12, a read-only view answers reads and refuses writes with 405; a projection with no declared identity has no item route; a projection keyed on a field not named `id` is addressed by -it; decimal and float fields filter) and `report/` (13, +it, whether its identity names `@fields` or derives them; decimal and float fields filter) and `report/` (13, FR-044: a view-backed `object.report` is listed, filtered, sorted and paged on its derived fields, answers `POST` with 405 and mounts no `/{id}`). All 5 ports — TS, Java, Kotlin, C#, Python — run it in BOTH lanes: a hand-rolled reference server and diff --git a/fixtures/api-contract-conformance/projection/README.md b/fixtures/api-contract-conformance/projection/README.md index bcbd3d5e1..544dcf19a 100644 --- a/fixtures/api-contract-conformance/projection/README.md +++ b/fixtures/api-contract-conformance/projection/README.md @@ -35,8 +35,14 @@ the cross-port contract would drop a capability two ports already shipped. on a field named `number`, and its view has no `id` column at all. `GET /api/invoice_ledgers/2` answers that row, an unknown key answers the `404 {"error": "not_found"}` envelope, and the item write verbs are refused with the `405` - envelope. The identity names its key explicitly (`@fields: number`) because that is the form - every port reads the key from. + envelope. The identity names its key explicitly (`@fields: number`). +- **So does a key whose identity omits `@fields`.** `InvoiceRegister` passes the key through on + `regNo` and declares `identity.primary extends Invoice.pk` with no `@fields`, the derived form + the loader recommends: it computes the key from the pass-through field and never writes it + back. The contract is the one `InvoiceLedger` pins. A port that read the base entity's + inherited `@fields` (`id`) instead of the computed key broke here: TypeScript mounted no item + route, Kotlin emitted a table and controller that did not compile, and C# built a model with + no key for the projection, so every request answered `500`. - **Filters apply to `field.decimal` and `field.float`.** `InvoiceLedger` carries one of each; the scenarios assert only how many rows match, because each port spells a decimal its own way. - **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. ``` projection/ ├── README.md # this file -├── meta.json # writable Invoice + three view-only projections +├── meta.json # writable Invoice + four view-only projections ├── seed.json # 4 seed Invoice rows (the views derive; they are never seeded) ├── docs-routes.json # the routes each projection's api docs page lists, in every port └── scenarios/ @@ -68,14 +74,15 @@ projection/ ├── write-verbs-405.yaml # POST/PATCH/PUT/DELETE → 405 + envelope ├── keyless-no-item-route.yaml # no declared identity (even with an `id` field) → no /{id} route ├── keyed-by-non-id-field.yaml # key on `number`, view with no `id` column → that row, 404 envelope + ├── keyed-by-derived-identity.yaml # key on `regNo`, identity omits `@fields` → the same contract ├── filter-decimal.yaml # FR-009 filter on a field.decimal └── filter-float.yaml # FR-009 filter on a field.float ``` -The three projections: `InvoiceSummary` (key passed through from `Invoice` on `id`), -`InvoiceLedger` (key on `number`; also the decimal and float fields) and `InvoiceStub` (no -identity). `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 +The four projections: `InvoiceSummary` (key passed through from `Invoice` on `id`), +`InvoiceLedger` (key on `number`, `@fields` explicit; also the decimal and float fields), +`InvoiceRegister` (key on `regNo`, `@fields` omitted) and `InvoiceStub` (no identity). +`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 `/{id}` for a keyless one), spelled without the api prefix and with `{id}`. `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. | Port | Generated lane | Note | |---|---|---| -| TypeScript | **wired, green (11/11)** | `test/api-contract-projection.test.ts` | -| Python | **wired, green (11/11)** | `tests/integration/test_api_contract_projection.py` | -| C# | **wired, green (11/11)** | `MetaObjects.IntegrationTests/Api/ApiContractProjectionConformanceTest.cs` | -| Java | **wired, green (11/11)** | `integration-tests/.../ProjectionGeneratedApiContractConformanceTest.java` | -| Kotlin | **wired, green (11/11)** | `integration-tests-kotlin/.../ProjectionGeneratedApiContractConformanceTest.kt` | +| TypeScript | **wired, green (12/12)** | `test/api-contract-projection.test.ts` | +| Python | **wired, green (12/12)** | `tests/integration/test_api_contract_projection.py` | +| C# | **wired, green (12/12)** | `MetaObjects.IntegrationTests/Api/ApiContractProjectionConformanceTest.cs` | +| Java | **wired, green (12/12)** | `integration-tests/.../ProjectionGeneratedApiContractConformanceTest.java` | +| Kotlin | **wired, green (12/12)** | `integration-tests-kotlin/.../ProjectionGeneratedApiContractConformanceTest.kt` | The docs half (`docs-routes.json`) runs in each port's unit-test project, over the same `meta.json`: diff --git a/fixtures/api-contract-conformance/projection/docs-routes.json b/fixtures/api-contract-conformance/projection/docs-routes.json index 52cf27a20..621ce82c4 100644 --- a/fixtures/api-contract-conformance/projection/docs-routes.json +++ b/fixtures/api-contract-conformance/projection/docs-routes.json @@ -3,6 +3,7 @@ "units": { "InvoiceSummary": ["GET invoice_summaries", "GET invoice_summaries/{id}"], "InvoiceLedger": ["GET invoice_ledgers", "GET invoice_ledgers/{id}"], + "InvoiceRegister": ["GET invoice_registers", "GET invoice_registers/{id}"], "InvoiceStub": ["GET invoice_stubs"] } } diff --git a/fixtures/api-contract-conformance/projection/meta.json b/fixtures/api-contract-conformance/projection/meta.json index d8f7361de..b6f0103bd 100644 --- a/fixtures/api-contract-conformance/projection/meta.json +++ b/fixtures/api-contract-conformance/projection/meta.json @@ -37,6 +37,15 @@ { "identity.primary": { "name": "pk", "extends": "Invoice.pk", "@fields": "number" } } ] }}, + { "object.projection": { + "name": "InvoiceRegister", + "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_invoice_register" } }, + { "field.long": { "name": "regNo", "extends": "Invoice.id", "@filterable": true, "@sortable": true } }, + { "field.string": { "name": "reference", "extends": "Invoice.reference", "@filterable": true, "@sortable": true } }, + { "identity.primary": { "name": "pk", "extends": "Invoice.pk" } } + ] + }}, { "object.projection": { "name": "InvoiceStub", "children": [ diff --git a/fixtures/api-contract-conformance/projection/scenarios/keyed-by-derived-identity.yaml b/fixtures/api-contract-conformance/projection/scenarios/keyed-by-derived-identity.yaml new file mode 100644 index 000000000..c1081f22f --- /dev/null +++ b/fixtures/api-contract-conformance/projection/scenarios/keyed-by-derived-identity.yaml @@ -0,0 +1,57 @@ +name: projection-keyed-by-derived-identity +description: > + InvoiceRegister passes the Invoice key through on a field named `regNo` (its view has + NO `id` column) and its identity omits `@fields` — the derived form the loader + recommends: the key is computed from the pass-through field, never restated. The item + route addresses that computed field, exactly as it does for InvoiceLedger's explicit + `@fields: number`. A known key answers its row, an unknown one answers the 404 envelope, + and the item write verbs are refused with the 405 envelope. +requests: + - id: list + method: GET + path: "/api/invoice_registers?sort=regNo:asc" + expect: + status: 200 + body: + equals: + - { regNo: 1, reference: "INV-1001" } + - { regNo: 2, reference: "INV-1002" } + - { regNo: 3, reference: "INV-1003" } + - { regNo: 4, reference: "INV-1004" } + - id: get-known + method: GET + path: /api/invoice_registers/2 + expect: + status: 200 + body: + row: { regNo: 2 } + - id: get-unknown + method: GET + path: /api/invoice_registers/999 + expect: + status: 404 + body: + error: "not_found" + - id: patch-item + method: PATCH + path: /api/invoice_registers/2 + body: { reference: "X" } + expect: + status: 405 + body: + error: "method_not_allowed" + - id: put-item + method: PUT + path: /api/invoice_registers/2 + body: { regNo: 2, reference: "X" } + expect: + status: 405 + body: + error: "method_not_allowed" + - id: delete-item + method: DELETE + path: /api/invoice_registers/2 + expect: + status: 405 + body: + error: "method_not_allowed" diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionFixture.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionFixture.cs index 7201c5862..2d29abf99 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionFixture.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionFixture.cs @@ -41,6 +41,11 @@ CREATE VIEW "v_invoice_ledger" AS i."discount" AS "discount", i."weight" AS "weight" FROM "invoices" i; + -- InvoiceRegister: key on `regNo`, identity omits @fields; the view has NO id column. + CREATE VIEW "v_invoice_register" AS + SELECT i."id" AS "regNo", + i."reference" AS "reference" + FROM "invoices" i; -- InvoiceStub: no declared identity; the view carries an id column all the same. CREATE VIEW "v_invoice_stub" AS SELECT i."id" AS "id", diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionGeneratedServerFactory.cs index 3bbd5df20..2d91123bc 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionGeneratedServerFactory.cs @@ -102,9 +102,9 @@ private static (Assembly Assembly, IReadOnlyList RoutedNames) CompileGen .Where(o => RoutesGenerator.AppliesTo(o, root)) .Select(o => CSharpNaming.Pascal(o.Name)) .ToList(); - if (routedNames.Count != 4) + if (routedNames.Count != 5) throw new InvalidOperationException( - "expected routes for Invoice, InvoiceSummary, InvoiceLedger AND InvoiceStub, got: " + "expected routes for Invoice, InvoiceSummary, InvoiceLedger, InvoiceRegister AND InvoiceStub, got: " + string.Join(", ", routedNames)); var ctx = new GenContext diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs index 207c28680..038ccc299 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -1507,7 +1507,7 @@ public static IReadOnlyList ValidateIdentityPassthrough(MetaData root private static List? IdentityEffectiveFields(MetaData identity) => NormalizeIdentityFields(identity.Attr(IDENTITY_ATTR_FIELDS)); - private static (MetaData Entity, List ComputedFields, List Missing)? + internal static (MetaData Entity, List ComputedFields, List Missing)? ResolveIdentityPassthrough(MetaData identity) { var extended = identity.SuperData; diff --git a/server/csharp/MetaObjects/Meta/MetaIdentity.cs b/server/csharp/MetaObjects/Meta/MetaIdentity.cs index 1b11bb4b0..51ccf64f9 100644 --- a/server/csharp/MetaObjects/Meta/MetaIdentity.cs +++ b/server/csharp/MetaObjects/Meta/MetaIdentity.cs @@ -15,11 +15,23 @@ namespace MetaObjects.Meta; // (MetaSource / MetaRelationship) were reconciled to. public class MetaIdentity(TypeId typeId, string name) : MetaData(typeId, name) { - /// The field names that form this identity key. + /// + /// The field names that form this identity key. A projection's identity passes the base + /// entity's key through, and an @fields it omits is DERIVED: the local fields that + /// extend the base key's fields, computed by the loader's own pass-through resolution and + /// never written back into the tree. Reading the inherited @fields there would give + /// the BASE field names, so a key renamed on the projection (regNo extending + /// Invoice.id) would name a field the projection does not have. An explicit + /// @fields must equal the computed key (the loader rejects one that disagrees), so + /// that form is unchanged. + /// public IReadOnlyList Fields { get { + if (Parent is { SubType: OBJECT_SUBTYPE_PROJECTION } && + MetaObjects.Loader.ValidationPasses.ResolveIdentityPassthrough(this) is { Missing.Count: 0 } resolved) + return resolved.ComputedFields; var f = Attr(IDENTITY_ATTR_FIELDS); return f is IReadOnlyList list ? list : []; } diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt index acd3be3d3..0d66b4a90 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt @@ -787,7 +787,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase { + if (entity.subType == MetaObject.SUBTYPE_PROJECTION) { + ValidationPhase.computePassthroughKey(entity, identity)?.let { return it } + } + return identity.fields + } + /** * True when [field] participates in its owner's ASSIGNED primary key — an * `identity.primary` carrying no `@generation` (or an explicit `assigned`) — and has diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt index b4b9f1e39..2383f37e9 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt @@ -372,7 +372,7 @@ open class KotlinRelationsGenerator : MultiFileDirectGeneratorBase() val primary = entity.getIdentities(true) .filterIsInstance() .firstOrNull { it.isPrimary } ?: return LONG - val pkFieldName = primary.fields.firstOrNull() ?: return LONG + val pkFieldName = KotlinGenUtil.keyFields(entity, primary).firstOrNull() ?: return LONG val pkField = entity.metaFields.firstOrNull { it.name == pkFieldName } ?: return LONG return runCatching { KotlinTypeMapper.kotlinTypeName(pkField) }.getOrDefault(LONG) } diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRepositoryGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRepositoryGenerator.kt index 26dc136f9..56b7852e5 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRepositoryGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRepositoryGenerator.kt @@ -138,7 +138,7 @@ open class KotlinRepositoryGenerator : MultiFileDirectGeneratorBase( val writeObj = KotlinNaming.tableObjectName(shortName) val readObj = if (writeThrough) KotlinNaming.viewObjectName(shortName) else writeObj val repoName = KotlinNaming.repositoryBaseName(shortName) - val pkFieldName = primary?.fields?.firstOrNull() ?: DEFAULT_PK_FIELD + val pkFieldName = primary?.let { KotlinGenUtil.keyFields(entity, it).firstOrNull() } ?: DEFAULT_PK_FIELD val pkParamType = primaryKeyParamType(entity, pkFieldName) // Scalar columns only — ObjectField / MapField carry a jsonb/flattened shape the mapper diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt index efe286803..c207c43b6 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt @@ -196,7 +196,7 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase() .firstOrNull { it.isPrimary } - val pkFieldName = primary?.fields?.firstOrNull() ?: DEFAULT_PK_FIELD + val pkFieldName = primary?.let { KotlinGenUtil.keyFields(entity, it).firstOrNull() } ?: DEFAULT_PK_FIELD val pkParamType = primaryKeyParamType(entity, pkFieldName) // FR-035: the PATCH-settable columns = scalar + value-object fields minus the PK. Program D: @@ -1876,7 +1876,7 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase() .firstOrNull { it.isPrimary } val hasItem = RestSurfaceGate.hasItemRoute(entity) - val pkFieldName = primary?.fields?.firstOrNull() ?: DEFAULT_PK_FIELD + val pkFieldName = primary?.let { KotlinGenUtil.keyFields(entity, it).firstOrNull() } ?: DEFAULT_PK_FIELD val pkParamType = primaryKeyParamType(entity, pkFieldName) val sortFields = entity.metaFields diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/projection/generated/GeneratedProjectionControllerHarness.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/projection/generated/GeneratedProjectionControllerHarness.kt index 741e899fa..967067f2e 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/projection/generated/GeneratedProjectionControllerHarness.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/projection/generated/GeneratedProjectionControllerHarness.kt @@ -27,7 +27,7 @@ import kotlin.io.path.readText /** * F22 — host the GENERATED Kotlin Spring controllers (`InvoiceSummaryController`, - * `InvoiceLedgerController`, `InvoiceStubController`) for the view-only projection corpus over + * `InvoiceLedgerController`, `InvoiceRegisterController`, `InvoiceStubController`) for the view-only projection corpus over * real HTTP (one embedded Tomcat) and drive the `projection/` scenarios against them. Mirrors [com.metaobjects.integration.kotlin.api.writethrough.generated.GeneratedWriteThroughControllerHarness]. * * Mechanism: @@ -179,7 +179,7 @@ class GeneratedProjectionControllerHarness( const val INVOICE_TABLE_FQCN = "$ENTITY_PKG.InvoiceTable" /** The corpus's view-only projections, each mounted and served. */ - val PROJECTIONS = listOf("InvoiceSummary", "InvoiceLedger", "InvoiceStub") + val PROJECTIONS = listOf("InvoiceSummary", "InvoiceLedger", "InvoiceRegister", "InvoiceStub") /** The seed row keys, in `invoices` column order. */ val SEED_FIELDS = listOf("id", "reference", "status", "amountCents", "discount", "weight") @@ -193,6 +193,8 @@ class GeneratedProjectionControllerHarness( // Keyed on `number`; the view has NO `id` column. "InvoiceLedger" to ViewSpec("v_invoice_ledger", listOf( "number" to "id", "reference" to "reference", "discount" to "discount", "weight" to "weight")), + // Keyed on `regNo`, the identity omitting `@fields` (derived); the view has NO `id` column. + "InvoiceRegister" to ViewSpec("v_invoice_register", listOf("regNo" to "id", "reference" to "reference")), // No declared identity; the view carries an `id` column all the same. "InvoiceStub" to ViewSpec("v_invoice_stub", listOf("id" to "id", "reference" to "reference")), ) diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedProjectionControllerHarness.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedProjectionControllerHarness.java index 3360a1618..d708bbda6 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedProjectionControllerHarness.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedProjectionControllerHarness.java @@ -36,8 +36,8 @@ /** * F22 — host the GENERATED Java Spring {@code @RestController} of every view-only * projection in the {@code projection/} corpus ({@code InvoiceSummary}, {@code InvoiceLedger}, - * {@code InvoiceStub}) over real HTTP (one embedded Tomcat, {@link TomcatHost}) and drive the - * api-contract scenarios against them. Sibling of {@link GeneratedJsonbControllerHarness}. + * {@code InvoiceRegister}, {@code InvoiceStub}) over real HTTP (one embedded Tomcat, + * {@link TomcatHost}) and drive the api-contract scenarios against them. Sibling of {@link GeneratedJsonbControllerHarness}. * *

The artifacts under test are the GENERATED {@code Controller}s — read * routes plus a 405 refusal on every write verb — together with the read-only @@ -71,6 +71,10 @@ private record Spec(Map columns, String keyComponent, String key SPECS.put("InvoiceLedger", new Spec(columns( "number", "id", "reference", "reference", "discount", "discount", "weight", "weight"), "number", "Long")); + // Keyed on `regNo`, the identity omitting `@fields` (derived); the view has NO `id` column. + SPECS.put("InvoiceRegister", new Spec(columns( + "regNo", "id", "reference", "reference"), + "regNo", "Long")); // No declared identity; the view carries an `id` column all the same. SPECS.put("InvoiceStub", new Spec(columns("id", "id", "reference", "reference"), null, null)); } diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java index 1ef32f742..56a036fc4 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java @@ -4715,26 +4715,10 @@ static void validateIdentityPassthrough(MetaRoot root) { } MetaData extended = identity.getSuperData(); if (!(extended instanceof MetaIdentity)) continue; // unresolved/mismatch reported elsewhere - MetaData entityNode = extended.getParent(); - if (!(entityNode instanceof MetaObject)) continue; - MetaObject entity = (MetaObject) entityNode; - - java.util.List extendedFields = ((MetaIdentity) extended).getFields(); - java.util.List computed = new java.util.ArrayList<>(); - boolean missing = false; - for (String fn : extendedFields) { - MetaField entityField = findFieldByName(entity, fn); - if (entityField == null) { missing = true; break; } - MetaField local = null; - for (MetaData oc : obj.getChildren(MetaData.class, false)) { - if (oc instanceof MetaField && extendsChainReaches(oc, entityField)) { - local = (MetaField) oc; break; - } - } - if (local == null) { missing = true; break; } - computed.add(shortNameOf(local)); - } - if (missing) { + if (!(extended.getParent() instanceof MetaObject)) continue; + + java.util.List computed = computePassthroughKey(obj, identity); + if (computed == null) { throw new MetaDataException( "ERR_IDENTITY_KEY_MISMATCH" + ": identity '" + identity.getName() + "' on projection '" + obj.getName() @@ -4756,6 +4740,41 @@ static void validateIdentityPassthrough(MetaRoot root) { } } + /** + * The key a projection's pass-through identity addresses a row by: for each field of + * the extended entity identity, in its order, the name of the projection field that + * {@code extends} that entity field. Derived on read and never written back into the + * tree, so a key renamed on the projection ({@code regNo} extending {@code Invoice.id}) + * is found even when the identity omits {@code @fields} (the form the loader + * recommends). An explicit {@code @fields} must equal it, which + * {@link #validateIdentityPassthrough} enforces. + * + *

Returns {@code null} when the identity is not a resolvable pass-through: it has no + * extended entity identity, or some extended field has no pass-through field here.

+ */ + public static java.util.List computePassthroughKey(MetaObject projection, MetaIdentity identity) { + MetaData extended = identity.getSuperData(); + if (!(extended instanceof MetaIdentity)) return null; + MetaData entityNode = extended.getParent(); + if (!(entityNode instanceof MetaObject)) return null; + MetaObject entity = (MetaObject) entityNode; + + java.util.List computed = new java.util.ArrayList<>(); + for (String fn : ((MetaIdentity) extended).getFields()) { + MetaField entityField = findFieldByName(entity, fn); + if (entityField == null) return null; + MetaField local = null; + for (MetaData oc : projection.getChildren(MetaData.class, false)) { + if (oc instanceof MetaField && extendsChainReaches(oc, entityField)) { + local = (MetaField) oc; break; + } + } + if (local == null) return null; + computed.add(shortNameOf(local)); + } + return computed; + } + private static MetaField findFieldByName(MetaObject entity, String name) { for (MetaData c : entity.getChildren(MetaData.class, true)) { if (c instanceof MetaField && nameMatches(c, name)) return (MetaField) c; diff --git a/server/python/tests/integration/generated_projection_app.py b/server/python/tests/integration/generated_projection_app.py index e46c07a59..fb7f976ce 100644 --- a/server/python/tests/integration/generated_projection_app.py +++ b/server/python/tests/integration/generated_projection_app.py @@ -3,7 +3,7 @@ Peer of ``generated_router_app.py``, for the view-only projection corpus (``fixtures/api-contract-conformance/projection/``). Runs the REAL generators (``render_router`` + ``render_filter_allowlist``) for each corpus projection -(``InvoiceSummary``, ``InvoiceLedger``, ``InvoiceStub``), writes the emitted modules +(``InvoiceSummary``, ``InvoiceLedger``, ``InvoiceRegister``, ``InvoiceStub``), writes the emitted modules to a temp package, imports the generated routers UNMODIFIED, and mounts them. The generated router is the artifact under test, and for this corpus that is @@ -51,6 +51,8 @@ {"number": "id", "reference": "reference", "discount": "discount", "weight": "weight"}, "number", ), + # Keyed on `regNo`, the identity omitting `@fields` (derived); the view has NO `id` column. + "InvoiceRegister": ({"regNo": "id", "reference": "reference"}, "regNo"), # No declared identity; the view carries an `id` column all the same. "InvoiceStub": ({"id": "id", "reference": "reference"}, None), } diff --git a/server/typescript/packages/codegen-ts/src/templates/queries.ts b/server/typescript/packages/codegen-ts/src/templates/queries.ts index 346bcefce..726efff7b 100644 --- a/server/typescript/packages/codegen-ts/src/templates/queries.ts +++ b/server/typescript/packages/codegen-ts/src/templates/queries.ts @@ -2,8 +2,13 @@ // Each returns a ts-poet Code block; composed into a file by queries-file.ts. import { code, imp, type Code } from "ts-poet"; -import { type MetaObject, stripPackage } from "@metaobjectsdev/metadata"; -import { IDENTITY_ATTR_FIELDS } from "@metaobjectsdev/metadata"; +import { + type MetaObject, + stripPackage, + IDENTITY_ATTR_FIELDS, + OBJECT_SUBTYPE_PROJECTION, + computedIdentityFields, +} from "@metaobjectsdev/metadata"; import type { RenderContext } from "../render-context.js"; import { supportsReturning } from "../dialect-module.js"; import { @@ -46,7 +51,18 @@ export function getPkInfo(entity: MetaObject, ctx: RenderContext): { fieldName: * create re-read keys on ALL of them so a composite PK re-reads the exact written row * (via the insert's returning() values), not any row sharing the first key component. */ export function getPkFields(entity: MetaObject): string[] { - const rawFields = entity.primaryIdentity()?.attr(IDENTITY_ATTR_FIELDS); + const identity = entity.primaryIdentity(); + // A projection's identity passes the base entity's key through, and an `@fields` it omits + // is DERIVED: the local fields that extend the base key's fields, which the loader + // computes and never writes back. Reading the effective `@fields` instead yields the BASE + // field names, so a key renamed in the projection (`regNo` extending `Invoice.id`) named + // a field the projection does not have. An explicit `@fields` equals the computed key + // (the loader rejects one that disagrees), so the explicit form is unchanged. + if (identity !== undefined && entity.subType === OBJECT_SUBTYPE_PROJECTION) { + const computed = computedIdentityFields(identity); + if (computed !== undefined) return computed; + } + const rawFields = identity?.attr(IDENTITY_ATTR_FIELDS); if (Array.isArray(rawFields)) return rawFields.filter((f): f is string => typeof f === "string"); return typeof rawFields === "string" ? [rawFields] : []; } diff --git a/server/typescript/packages/codegen-ts/test/projection/routes-file.test.ts b/server/typescript/packages/codegen-ts/test/projection/routes-file.test.ts index 637e91cb5..933345022 100644 --- a/server/typescript/packages/codegen-ts/test/projection/routes-file.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/routes-file.test.ts @@ -539,6 +539,43 @@ describe("renderRoutesFile — a served report (FR-044 Plan 3)", () => { expect(queries).toContain("eq(productCardView.code, code)"); }); + test("a projection whose key is RENAMED and whose identity omits @fields addresses the renamed field", async () => { + const root = await loadMetadata([ + { + "object.entity": { + name: "Product", + children: [ + { "source.rdb": { "@table": "products" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "title" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + ], + }, + }, + { + "object.projection": { + name: "ProductCard", + children: [ + { "source.rdb": { "@kind": "view", "@table": "v_product_card" } }, + { "field.long": { name: "number", extends: "Product.id" } }, + { "field.string": { name: "title", extends: "Product.title" } }, + // No @fields: the key is derived from the pass-through field, never restated. + { "identity.primary": { name: "pk", extends: "Product.pk" } }, + ], + }, + }, + ]); + const projection = declared(root, "ProductCard"); + expect(hasItemRoute(projection)).toBe(true); + expect(itemRouteField(projection)).toBe("number"); + const ctx = makeRenderContext({ + dialect: "sqlite", loadedRoot: root, outDir: "/x", dbImport: "~/db", apiPrefix: "", + pkMap: buildPkMap(root), relationMap: buildRelationMap(root), + }); + expect(renderRoutesFile(projection, ctx)).toContain(' idColumn: "number",\n });'); + expect(renderQueriesFile(projection, ctx)).toContain("eq(productCardView.number, number)"); + }); + test("a keyed-on-`id` projection passes no idColumn and ends its options at `dialect`", async () => { const { projection, ctx } = await loadKeyedProjectionFixture(); expect(itemRouteField(projection)).toBe("id"); diff --git a/server/typescript/packages/integration-tests/src/api-contract-projection-generated-server.ts b/server/typescript/packages/integration-tests/src/api-contract-projection-generated-server.ts index 7d88014f6..9d8409d7b 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-projection-generated-server.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-projection-generated-server.ts @@ -100,6 +100,9 @@ export const db = drizzle(pool); -- InvoiceLedger: its key is the field \`number\`, and the view has NO id column. CREATE OR REPLACE VIEW "v_invoice_ledger" AS SELECT "id" AS "number", "reference", "discount", "weight" FROM "invoices"; + -- InvoiceRegister: key on \`regNo\`, identity omits @fields; no id column either. + CREATE OR REPLACE VIEW "v_invoice_register" AS + SELECT "id" AS "reg_no", "reference" FROM "invoices"; -- InvoiceStub: no declared identity; the view carries an id column all the same. CREATE OR REPLACE VIEW "v_invoice_stub" AS SELECT "id", "reference" FROM "invoices"; @@ -112,6 +115,9 @@ export const db = drizzle(pool); const ledgerRoutes = (await import( pathToFileURL(join(tmp, "InvoiceLedger.routes.ts")).href )) as { invoiceLedgerRoutes: (f: FastifyInstance) => Promise }; + const registerRoutes = (await import( + pathToFileURL(join(tmp, "InvoiceRegister.routes.ts")).href + )) as { invoiceRegisterRoutes: (f: FastifyInstance) => Promise }; const stubRoutes = (await import( pathToFileURL(join(tmp, "InvoiceStub.routes.ts")).href )) as { invoiceStubRoutes: (f: FastifyInstance) => Promise }; @@ -120,6 +126,7 @@ export const db = drizzle(pool); const fastify = Fastify(); await fastify.register(routes.invoiceSummaryRoutes); await fastify.register(ledgerRoutes.invoiceLedgerRoutes); + await fastify.register(registerRoutes.invoiceRegisterRoutes); await fastify.register(stubRoutes.invoiceStubRoutes); await fastify.ready(); const baseUrl = await fastify.listen({ host: "127.0.0.1", port: 0 }); From faf500bd22b09d5da495f74d3096149f14fb9c08 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Fri, 9 Oct 2026 10:43:30 -0400 Subject: [PATCH 2/2] no-mistakes(document): docs(projection): clarify when to omit @fields on projection identities --- docs/features/source-kinds.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/features/source-kinds.md b/docs/features/source-kinds.md index 326693ea2..416efffda 100644 --- a/docs/features/source-kinds.md +++ b/docs/features/source-kinds.md @@ -124,6 +124,17 @@ owning entity's own columns, declare it as an `object.projection`, not an `objec present, MUST extend an entity identity; the example below omits it (a keyless read model). +> **Projection identities and the `@fields` attribute.** When a projection declares an +> identity by extending an entity's identity, the `@fields` attribute is optional. When +> omitted, the loader derives the identity key from the projection's fields that pass +> through the base identity's key field(s). When a projection passes the key through on a +> renamed field, omitting `@fields` (the recommended form) lets the loader automatically +> compute the key — if you instead declare `@fields` explicitly and name a different field, +> the declaration is honored but diverges from what the key actually is. Use the derived +> form (omit `@fields`) whenever the projection's key field is derived from or passed +> through from the base identity, and use explicit `@fields` only when the key is redefined +> to an unrelated field. + **The view's SQL is generated — never hand-write it.** The `CREATE VIEW` body is derived from the projection's `origin.*` children (passthrough columns, aggregates, collections) and emitted by the Node `meta migrate` (schema migrations are Node-owned