Skip to content

Commit 659c7a4

Browse files
dmealingclaude
andcommitted
docs(agent-context): document TPH/discriminator inheritance in all ports + Kotlin snapshot gate
An AI session reading the codegen skill references concluded discriminator-based (TPH — table-per-hierarchy) inheritance persistence "isn't supported" — because only references/csharp.md documented it. TPH is fully supported and codegen- emitted in all 5 ports (single shared table + discriminator + polymorphic subtype CRUD, conformance-gated), but the Kotlin/Java/Python/TS codegen skill references had ZERO mentions, so an agent saw no support and wrongly said so. Root-cause doc fix: - Add source-grounded TPH coverage to metaobjects-codegen/references/{kotlin,java, python,typescript}.md (each port's real idiom: Kotlin Exposed single-table + polymorphic controller, Java Spring-JPA, Python SQLAlchemy-polymorphic + FastAPI, TS Drizzle single-table + Fastify) — grounded in the actual generators (KotlinTphPlan / TphPlan / tph_plan.py / tph-discriminator.ts + runtime-ts/src/tph.ts). - Add a "Discriminator inheritance (TPH)" note to metaobjects-authoring/SKILL.md. - Add a "Discriminator-based inheritance (TPH) with persistence" section to docs/features/abstracts-and-inheritance.md (YAML example + per-port ORM mapping + runtime subtype contract). - Regenerate fixtures/agent-context-conformance/ goldens (agent-context-conformance test green). NOTE: the all-or-nothing regen also corrects PRE-EXISTING stale verify/AGENTS/CLAUDE goldens (the live-DB-warning content from 49269da was never regenerated) — unrelated to TPH, swept in because goldens regenerate as a set. Kotlin snapshot ("finish that work off"): add the entity-with-tph byte-level snapshot fixture to codegen-kotlin so the generated AuthTable.kt (single table, discriminator col, nullable subtype cols) + polymorphic AuthController.kt (discriminator inject-from-URL, subtype-scoped CRUD, cross-subtype 404, immutable discriminator) are gated at the unit level, not just integration. KotlinCodegenSnapshotTest 13/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ew1XfYSbEAezxjs9opynAe
1 parent 8b4378c commit 659c7a4

57 files changed

Lines changed: 1601 additions & 50 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

agent-context/skills/metaobjects-authoring/SKILL.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,44 @@ across files (same `package` + same `name` → merged; last-writer-wins on attr
580580
conflicts, structural children accumulate). Use `extends` to share shape between
581581
distinct entities; use `overlay` to split one entity's declaration across files.
582582

583+
## Discriminator inheritance (TPH)
584+
585+
When several concrete entities are variants of one thing and should share a
586+
**single table** (table-per-hierarchy / single-table inheritance), model it with a
587+
**discriminator** rather than one table per variant:
588+
589+
- The **base** `object.entity` declares `@discriminator` naming a discriminator
590+
field — typically a `field.enum` whose `@values` are the subtype tags.
591+
- Each concrete **subtype** `extends` the base and declares `@discriminatorValue`
592+
(one of those enum members).
593+
594+
All subtypes persist to the base's single table (subtype-only columns fold in
595+
nullable). You author only the metadata; codegen emits the polymorphic surface —
596+
per-subtype routes at `/<base>/<discriminatorValue lowercased>` where create
597+
**injects** the discriminator from the URL, reads/updates/deletes are **scoped** to
598+
the subtype (cross-subtype → 404), and the discriminator is **immutable**.
599+
Supported + conformance-gated in all five ports (the repo's
600+
`docs/features/abstracts-and-inheritance.md` has the full example and per-port
601+
mapping).
602+
603+
```yaml
604+
- object.entity:
605+
name: Auth # TPH base — owns the single `auths` table
606+
discriminator: type
607+
children:
608+
- source.rdb: { table: auths }
609+
- field.long: { name: id }
610+
- field.enum: { name: type, values: ["Bridge", "Copay"] }
611+
- identity.primary: { fields: id }
612+
613+
- object.entity:
614+
name: BridgeAuth # subtype — folded into `auths`, tagged type="Bridge"
615+
extends: Auth
616+
discriminatorValue: Bridge
617+
children:
618+
- field.int: { name: quantity, required: true }
619+
```
620+
583621
---
584622
585623
For non-trivial schema design, use `/superpowers:brainstorming` if installed;

agent-context/skills/metaobjects-codegen/references/java.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,29 @@ first group together:
8282

8383
| Generator | Output |
8484
|---|---|
85-
| `SpringControllerGenerator` | `<Entity>Controller.java` per writable entity (`source.rdb` `@kind="table"`) — Spring Web MVC, five CRUD endpoints on the cross-port REST contract (`?sort`, `?limit`/`?offset`, `?withCount=1` envelope, 404/400 envelopes) |
86-
| `SpringDtoGenerator` | `<Entity>Dto.java` as a Java 21 `record`; wrapped primitives (`Long`/`Integer`/`Boolean`) so missing JSON props deserialise to `null`; currency = `Long` (integer minor units) |
87-
| `SpringRepositoryGenerator` | `<Entity>Repository.java` — a hand-stubbed `interface` the consumer implements with their persistence layer (Spring Data JPA / jOOQ / JDBC) |
85+
| `SpringControllerGenerator` | `<Entity>Controller.java` per writable entity (`source.rdb` `@kind="table"`) — Spring Web MVC, five CRUD endpoints on the cross-port REST contract (`?sort`, `?limit`/`?offset`, `?withCount=1` envelope, 404/400 envelopes). A TPH `@discriminator` base emits ONE controller: polymorphic `GET /<base>(+/{id})` plus a per-subtype CRUD set at `/<base>/<discriminatorValue lowercased>` — create injects the discriminator from the URL (never the body); get/update/delete scoped to the subtype (cross-subtype → 404); discriminator immutable. |
86+
| `SpringDtoGenerator` | `<Entity>Dto.java` as a Java 21 `record`; wrapped primitives (`Long`/`Integer`/`Boolean`) so missing JSON props deserialise to `null`; currency = `Long` (integer minor units). A TPH `@discriminator` base's DTO is the **union** of every subtype's columns (subtype-only fields folded nullable, validation dropped), so one wire shape backs the polymorphic + per-subtype endpoints. |
87+
| `SpringRepositoryGenerator` | `<Entity>Repository.java` — a hand-stubbed `interface` the consumer implements with their persistence layer (Spring Data JPA / jOOQ / JDBC). For a TPH base the interface is polymorphic + per-subtype-scoped (`listByType`/`findByIdAndType`/`createWithType`/`updateByIdAndType`/`deleteByIdAndType`) over the single table; subtype entities emit no own controller/DTO/repository — they fold into the base. |
8888
| `SpringPayloadGenerator` | a Java 21 `record` per template payload VO |
8989
| `SpringOutputParserGenerator` | the `template.output` parser-on-receipt (see the prompts reference) |
9090
| `SpringFilterAllowlistGenerator` | per-entity filter allowlist |
9191

9292
Metadata lives under `src/main/metaobjects/` in the same canonical JSON the other
9393
ports read — fused-key form, `source.rdb` + `@table`, `@column` for a renamed
9494
physical column.
95+
96+
## Discriminator inheritance (TPH)
97+
98+
`codegen-spring` fully supports **table-per-hierarchy (TPH) inheritance**. `TphPlan`
99+
is the shared descriptor every TPH-aware generator reads: an `object.entity`
100+
carrying `@discriminator` (naming a `field.enum`) is the base; concrete entities
101+
that `extends` it and declare `@discriminatorValue` are its subtypes, all persisted
102+
to the base's **single** table (single-table inheritance). `SpringDtoGenerator`
103+
emits the base DTO as the union of subtype columns (each folded nullable);
104+
`SpringControllerGenerator` mounts polymorphic reads + per-subtype CRUD scoped by
105+
the discriminator (inject on create, subtype-scope + cross-subtype 404 on
106+
get/update/delete, immutable discriminator); `SpringRepositoryGenerator` emits the
107+
polymorphic + per-subtype-scoped repository seam the consumer implements against
108+
Spring Data JPA / JDBC. Conformance-gated by `fixtures/api-contract-conformance/tph`
109+
(HTTP wire shape) and `fixtures/persistence-conformance/tph-*` (single-table
110+
runtime semantics).

agent-context/skills/metaobjects-codegen/references/kotlin.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,10 @@ All live in `metaobjects-codegen-kotlin` under
9494

9595
| Generator | Output |
9696
|---|---|
97-
| `KotlinEntityGenerator` | `<Entity>.kt``@Serializable data class` per `object.entity` / `object.value` |
98-
| `KotlinExposedTableGenerator` | `<Entity>Table.kt` — Exposed `Table` object (PK + FK + `@storage` columns) for entities with `source.rdb` |
97+
| `KotlinEntityGenerator` | `<Entity>.kt``@Serializable data class` per `object.entity` / `object.value`. A TPH `@discriminator` base's data class is the **union** of every subtype's columns (each folded nullable, validation dropped) so one wire shape backs the polymorphic + per-subtype endpoints. |
98+
| `KotlinExposedTableGenerator` | `<Entity>Table.kt` — Exposed `Table` object (PK + FK + `@storage` columns) for entities with `source.rdb`. A TPH `@discriminator` base emits ONE `Table` for the whole hierarchy — every subtype-only column folded in `.nullable()` (a row of another subtype stores null there) — single-table inheritance; subtype entities emit no table of their own. |
9999
| `KotlinRelationsGenerator` | `<Entity>Relations.kt` — extension fns for `@cardinality="many"` query helpers |
100-
| `KotlinSpringControllerGenerator` | `<Entity>Controller.kt` — Spring `@RestController`, five CRUD endpoints on the cross-port REST contract, for writable entities (`source.rdb` `@kind="table"`) |
100+
| `KotlinSpringControllerGenerator` | `<Entity>Controller.kt` — Spring `@RestController`, five CRUD endpoints on the cross-port REST contract, for writable entities (`source.rdb` `@kind="table"`). A TPH `@discriminator` base emits ONE controller: polymorphic `GET /<base>(+/{id})` plus a per-subtype CRUD set at `/<base>/<discriminatorValue lowercased>` — create injects the discriminator from the URL (never the body); get/update/delete are scoped to the subtype (cross-subtype → 404); the discriminator is immutable. |
101101
| `KotlinPayloadGenerator` | `<Template>Payload.kt``@Serializable` payload data class from a template's `@payloadRef` |
102102
| `KotlinOutputParserGenerator` | the `template.output` parser-on-receipt (see the prompts reference) |
103103
| `KotlinValidatorGenerator` | `MetadataStartupValidator.kt` + `ExposedTableValidator.kt` (once per project) |
@@ -108,3 +108,19 @@ All live in `metaobjects-codegen-kotlin` under
108108
Metadata lives under `src/main/metaobjects/` in the same canonical JSON the other
109109
ports read — fused-key form, `source.rdb` + `@table`, `@column` for a renamed
110110
physical column.
111+
112+
## Discriminator inheritance (TPH)
113+
114+
`codegen-kotlin` fully supports **table-per-hierarchy (TPH) inheritance**.
115+
`KotlinTphPlan` is the shared descriptor every TPH-aware generator reads: an
116+
`object.entity` carrying `@discriminator` (naming a `field.enum`) is the base;
117+
concrete entities that `extends` it and declare `@discriminatorValue` are its
118+
subtypes, all persisted to the base's **single** Exposed `Table` (single-table
119+
inheritance). `KotlinExposedTableGenerator` folds each subtype's own columns into
120+
that table as `.nullable()`; `KotlinEntityGenerator` builds the base data class as
121+
the union of subtype columns; `KotlinFilterAllowlistGenerator` unions the
122+
subtypes' filterable columns; `KotlinSpringControllerGenerator` mounts the
123+
polymorphic reads + per-subtype CRUD scoped by the discriminator (inject on create,
124+
subtype-scope + cross-subtype 404 on get/update/delete, immutable discriminator).
125+
Conformance-gated by `fixtures/api-contract-conformance/tph` (HTTP wire shape) and
126+
`fixtures/persistence-conformance/tph-*` (single-table runtime semantics).

agent-context/skills/metaobjects-codegen/references/python.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,28 @@ a renamed physical column).
3737

3838
| Stable name | Output |
3939
|---|---|
40-
| `entity` | one **Pydantic model** per `object.entity` / projection (the `entity-model` generator): typed fields from the metadata, nullability from `@required`, `@maxLength`/validators, enum fields → a Python `Enum`. This is the typed data model. |
41-
| `routes` | a **FastAPI `APIRouter`** per writable entity (`source.rdb @kind="table"`) on the cross-port REST contract (`?filter[field][op]=`, `?sort=field:asc`, `?limit`/`?offset`, `?withCount=1` envelope, 400/404 envelopes). The router declares a repository **`Protocol`** you implement and inject. |
40+
| `entity` | one **Pydantic model** per `object.entity` / projection (the `entity-model` generator): typed fields from the metadata, nullability from `@required`, `@maxLength`/validators, enum fields → a Python `Enum`. This is the typed data model. A TPH concrete subtype (`@discriminatorValue`) pins the inherited `@discriminator` field to a `Literal[...]` so the model rejects a foreign-subtype tag. |
41+
| `routes` | a **FastAPI `APIRouter`** per writable entity (`source.rdb @kind="table"`) on the cross-port REST contract (`?filter[field][op]=`, `?sort=field:asc`, `?limit`/`?offset`, `?withCount=1` envelope, 400/404 envelopes). The router declares a repository **`Protocol`** you implement and inject. A TPH `@discriminator` base emits ONE polymorphic router: `GET /<base>(+/{id})` plus a per-subtype CRUD set at `/<base>/<discriminatorValue lowercased>` — create injects the discriminator from the URL (never the body); get/update/delete scoped to the subtype (cross-subtype → 404); discriminator immutable. Its repository `Protocol` is subtype-keyed (`subtype=None` for the polymorphic base) so your implementation applies the single-table discriminator scope. |
4242
| `filter-allowlist` | per-entity filter allowlist (FR-009 — the server-side field+operator allowlist the routes validate against). |
4343
| `payload` / `output-parser` / `output-prompt` / `extractor` / `render-helper` / `trace-helper` | the `template.output` prompt-pillar artifacts — see the **prompts** reference. |
4444
| `template` | the generic Mustache `template` primitive. |
4545

46+
## Discriminator inheritance (TPH)
47+
48+
Python codegen fully supports **table-per-hierarchy (TPH) inheritance**
49+
(`tph_plan.py` is the shared descriptor): an `object.entity` carrying
50+
`@discriminator` (naming a `field.enum`) is the base; concrete entities that
51+
`extends` it and declare `@discriminatorValue` are its subtypes, all persisted to
52+
the base's **single** table (single-table inheritance). The `entity` generator
53+
pins each subtype's inherited discriminator to a `Literal`; the `routes` generator
54+
emits the polymorphic router + per-subtype CRUD scoped by the discriminator (inject
55+
on create, subtype-scope + cross-subtype 404 on get/update/delete, immutable
56+
discriminator). Because Python owns no ORM (see below), your repository — keyed by
57+
subtype — applies the single-table discriminator scope (idiomatically a
58+
SQLAlchemy polymorphic/single-table mapping). Conformance-gated by
59+
`fixtures/api-contract-conformance/tph` (HTTP wire shape) and
60+
`fixtures/persistence-conformance/tph-*` (single-table runtime semantics).
61+
4662
## No ORM — you own persistence (unlike the C# port)
4763

4864
Python codegen emits the **Pydantic models + the FastAPI routers**, but **no ORM /

agent-context/skills/metaobjects-codegen/references/typescript.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,31 @@ From `@metaobjectsdev/codegen-ts/generators` (server-side, framework-neutral):
6969

7070
| Generator | Emits per entity |
7171
|---|---|
72-
| `entityFile()` | `<Entity>.ts` — Drizzle table + FK `.references()` + `relations()` + inferred types + Zod insert/update schemas + `<Entity>FilterAllowlist` / `<Entity>SortAllowlist` |
72+
| `entityFile()` | `<Entity>.ts` — Drizzle table + FK `.references()` + `relations()` + inferred types + Zod insert/update schemas + `<Entity>FilterAllowlist` / `<Entity>SortAllowlist`. A TPH `@discriminator` base folds every subtype's columns into ONE Drizzle table (subtype-only columns nullable, no default — single-table inheritance) and emits a discriminated-union type + per-subtype Zod schemas + a `parse<Base>` dispatcher; subtype entities emit no table of their own. |
7373
| `queriesFile()` | `<Entity>.queries.ts` — typed CRUD (`findPostById`, `listPosts`, `createPost`, `updatePost`, `deletePostById`) |
74-
| `routesFile()` | `<Entity>.routes.ts` — Fastify CRUD routes on the cross-port REST contract. `routesFileHono()` is the Hono/Workers variant |
74+
| `routesFile()` | `<Entity>.routes.ts` — Fastify CRUD routes on the cross-port REST contract. `routesFileHono()` is the Hono/Workers variant. A TPH `@discriminator` base mounts polymorphic `GET /<base>(+/:id)` plus a per-subtype CRUD set at `<basePath>/<discriminatorValue lowercased>` — create omits the discriminator (the URL names the subtype; the runtime injects it); get/update/delete scoped to the subtype (cross-subtype → 404); discriminator immutable via the runtime `discriminator` option. |
7575
| `barrel()` | `index.ts` re-exporting each `<Entity>.ts` (one-shot, not per-entity) |
7676
| `promptRender()` | `render<Name>()` per `template.prompt` |
7777
| `outputParser()` | `<Name>.output.ts` (`parse*` / `safeParse*`) per `template.output` |
7878

79+
## Discriminator inheritance (TPH)
80+
81+
The TS reference implementation fully supports **table-per-hierarchy (TPH)
82+
inheritance** (`tph-discriminator.ts` is the shared descriptor): an `object.entity`
83+
carrying `@discriminator` (naming a `field.enum`) is the base; concrete entities
84+
that `extends` it and declare `@discriminatorValue` are its subtypes, all persisted
85+
to the base's **single** Drizzle table (single-table inheritance). `entityFile()`
86+
folds each subtype's columns into that table nullable and emits the
87+
discriminated-union type + per-subtype Zod schemas + a `parse<Base>` dispatcher;
88+
`routesFile()` mounts polymorphic reads + per-subtype CRUD scoped by the
89+
discriminator. At runtime, `@metaobjectsdev/runtime-ts`'s ObjectManager enforces the
90+
subtype contract: it injects the discriminator on create, scopes every
91+
read/update/delete to the subtype (a foreign-subtype row is invisible), and treats
92+
the discriminator as immutable — mirroring the generated per-subtype route's
93+
cross-subtype 404. Conformance-gated by `fixtures/api-contract-conformance/tph`
94+
(HTTP wire shape) and `fixtures/persistence-conformance/tph-*` (single-table
95+
runtime semantics).
96+
7997
## Docs — `meta docs` (one door, two surfaces)
8098

8199
Documentation is NOT a `meta gen` generator. The single door is the `meta docs`

0 commit comments

Comments
 (0)