Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
4 changes: 2 additions & 2 deletions docs/CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/features/source-kinds.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 19 additions & 12 deletions fixtures/api-contract-conformance/projection/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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/
Expand All @@ -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
Expand All @@ -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`:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
}
9 changes: 9 additions & 0 deletions fixtures/api-contract-conformance/projection/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,9 @@ private static (Assembly Assembly, IReadOnlyList<string> 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
Expand Down
2 changes: 1 addition & 1 deletion server/csharp/MetaObjects/Loader/ValidationPasses.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1507,7 +1507,7 @@ public static IReadOnlyList<MetaError> ValidateIdentityPassthrough(MetaData root
private static List<string>? IdentityEffectiveFields(MetaData identity)
=> NormalizeIdentityFields(identity.Attr(IDENTITY_ATTR_FIELDS));

private static (MetaData Entity, List<string> ComputedFields, List<string> Missing)?
internal static (MetaData Entity, List<string> ComputedFields, List<string> Missing)?
ResolveIdentityPassthrough(MetaData identity)
{
var extended = identity.SuperData;
Expand Down
14 changes: 13 additions & 1 deletion server/csharp/MetaObjects/Meta/MetaIdentity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,23 @@ namespace MetaObjects.Meta;
// (MetaSource / MetaRelationship) were reconciled to.
public class MetaIdentity(TypeId typeId, string name) : MetaData(typeId, name)
{
/// <summary>The field names that form this identity key.</summary>
/// <summary>
/// The field names that form this identity key. A projection's identity passes the base
/// entity's key through, and an <c>@fields</c> 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 <c>@fields</c> there would give
/// the BASE field names, so a key renamed on the projection (<c>regNo</c> extending
/// <c>Invoice.id</c>) would name a field the projection does not have. An explicit
/// <c>@fields</c> must equal the computed key (the loader rejects one that disagrees), so
/// that form is unchanged.
/// </summary>
public IReadOnlyList<string> 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<string> list ? list : [];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject
// (it's part of the primary key). autoIncrement only applies to the
// single-field case; a composite tuple can't be auto-generated, so the
// generator falls back to the LLM/DB-side default.
val primaryFieldNames = primary?.fields.orEmpty()
val primaryFieldNames = primary?.let { KotlinGenUtil.keyFields(entity, it) }.orEmpty()
val primaryFieldSet = primaryFieldNames.toSet()
val singlePrimaryFieldName = primaryFieldNames.singleOrNull()
// Views inherit PKs from underlying tables β€” never emit autoIncrement on a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.metaobjects.index.Index
import com.metaobjects.index.LookupIndex
import com.metaobjects.generator.GeneratorException
import com.metaobjects.loader.MetaDataLoader
import com.metaobjects.loader.ValidationPhase
import com.metaobjects.`object`.MetaObject
import com.metaobjects.origin.AggregateOrigin
import com.metaobjects.origin.MetaOrigin
Expand Down Expand Up @@ -612,6 +613,21 @@ public object KotlinGenUtil {
* allowed); otherwise nullable. MVP heuristic β€” refined when richer required-detection
* lands (see fr-003 spec).
*/
/**
* The field names [identity] addresses a row of [entity] by. A projection's identity
* passes the base entity's key through, and an `@fields` it omits is DERIVED, so reading
* `identity.fields` gives the BASE entity's field names: a key renamed on the projection
* (`regNo` extending `Invoice.id`) then names a column the projection's table does not
* have. The loader's own derivation ([ValidationPhase.computePassthroughKey]) finds the
* projection's field; an explicit `@fields` must equal it, so that form is unchanged.
*/
fun keyFields(entity: MetaObject, identity: MetaIdentity): List<String> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ open class KotlinRelationsGenerator : MultiFileDirectGeneratorBase<MetaObject>()
val primary = entity.getIdentities(true)
.filterIsInstance<MetaIdentity>()
.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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ open class KotlinRepositoryGenerator : MultiFileDirectGeneratorBase<MetaObject>(
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
val primary = entity.getIdentities(true)
.filterIsInstance<MetaIdentity>()
.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:
Expand Down Expand Up @@ -1876,7 +1876,7 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
.filterIsInstance<MetaIdentity>()
.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
Expand Down
Loading
Loading