|
| 1 | +# `api-contract-conformance/tph/` — FR-017 TPH polymorphic CRUD corpus |
| 2 | + |
| 3 | +Cross-port REST contract for **table-per-hierarchy (TPH) polymorphic CRUD** over |
| 4 | +HTTP. A discriminator-bearing base entity (`@discriminator`) and its concrete |
| 5 | +subtypes (`@discriminatorValue`) share ONE physical table; the generated routes |
| 6 | +expose a polymorphic collection at the base path plus a full per-subtype CRUD set |
| 7 | +scoped by the discriminator. |
| 8 | + |
| 9 | +``` |
| 10 | +GET {prefix}/auths → polymorphic list (union; each row tagged by `type`) |
| 11 | +GET {prefix}/auths/:id → polymorphic get (one row of whatever subtype) |
| 12 | +GET {prefix}/auths/<sub> → per-subtype list (filtered to that subtype) |
| 13 | +GET {prefix}/auths/<sub>/:id → per-subtype get (404 if the row is a different subtype) |
| 14 | +POST {prefix}/auths/<sub> → per-subtype create (discriminator injected from URL, NOT body) |
| 15 | +PATCH {prefix}/auths/<sub>/:id → per-subtype update (404 cross-subtype; discriminator never changes) |
| 16 | +DELETE {prefix}/auths/<sub>/:id → per-subtype delete (404 cross-subtype) |
| 17 | +``` |
| 18 | + |
| 19 | +The per-subtype URL segment is the **`@discriminatorValue` lowercased** — |
| 20 | +`"Bridge"` → `/auths/bridge`, `"PriorAuth"` → `/auths/priorauth` — NOT the subtype |
| 21 | +entity name. There is intentionally **no polymorphic `POST /auths`** (you can't |
| 22 | +create an abstract base). |
| 23 | + |
| 24 | +## The three cross-port invariants (must hold byte-identically) |
| 25 | + |
| 26 | +1. **Single-table TPH storage.** One physical `auths` table; every subtype-only |
| 27 | + column (`quantity` / `copay_amount` / `approver`) is **nullable** (a row of any |
| 28 | + other subtype stores NULL there), even when the field is `@required`. |
| 29 | +2. **Polymorphic GET / per-subtype POST URLs.** `GET /auths` returns the union; |
| 30 | + `POST /auths/bridge` etc. create per subtype. The URL paths are identical |
| 31 | + cross-port. |
| 32 | +3. **Response shape carries the discriminator by value.** Every returned row |
| 33 | + carries its `type` field set to the subtype's discriminator value. |
| 34 | + |
| 35 | +## Files |
| 36 | + |
| 37 | +``` |
| 38 | +tph/ |
| 39 | +├── README.md # this file |
| 40 | +├── meta.json # Auth (base, @discriminator "type") + BridgeAuth / CopayAuth / PriorAuthAuth |
| 41 | +├── seed.json # 3 seed rows (one per subtype) into the single `auths` table |
| 42 | +└── scenarios/ |
| 43 | + ├── tph-polymorphic-list-and-get.yaml |
| 44 | + ├── tph-per-subtype-list-and-create.yaml |
| 45 | + ├── tph-per-subtype-update-and-delete.yaml |
| 46 | + └── tph-cross-subtype-404.yaml |
| 47 | +``` |
| 48 | + |
| 49 | +`meta.json` declares the `acme::auth` package: |
| 50 | + |
| 51 | +| Entity | Discriminator | Own fields (beyond inherited `id` / `type` / `reference`) | |
| 52 | +|---|---|---| |
| 53 | +| `Auth` (base) | `@discriminator: "type"`, `@table "auths"` | `id` (long, pk), `type` (enum `Bridge`/`Copay`/`PriorAuth`), `reference` (string, required) | |
| 54 | +| `BridgeAuth` | `@discriminatorValue: "Bridge"` | `quantity` (int, required → nullable in the single table) | |
| 55 | +| `CopayAuth` | `@discriminatorValue: "Copay"` | `copayAmount` (decimal 10,2) | |
| 56 | +| `PriorAuthAuth` | `@discriminatorValue: "PriorAuth"` | `approver` (string) | |
| 57 | + |
| 58 | +`seed.json` is applied fresh before every scenario (truncate-then-insert into the |
| 59 | +single `auths` table). Subtype-only columns are `null` for rows of other subtypes. |
| 60 | + |
| 61 | +## Scenario shape + assertions |
| 62 | + |
| 63 | +Identical to the parent `api-contract-conformance` corpus |
| 64 | +([`../README.md`](../README.md)). No new assertion shapes are needed — |
| 65 | +`ids` / `length` / `row` (subset key-match) / `error` / `empty` cover TPH. |
| 66 | +Decimal subtype values are **not** asserted over the API wire (cross-port numeric |
| 67 | +formatting differs); the runtime-layer decimal contract is pinned by |
| 68 | +`persistence-conformance` instead. The `row` assertions pin the discriminator |
| 69 | +value (`type`) and the integer/string subtype-only columns. |
| 70 | + |
| 71 | +## Both lanes (the cross-port gate) |
| 72 | + |
| 73 | +Each port runs these scenarios in **two lanes**, matching the m2m/SP-F fan-out: |
| 74 | + |
| 75 | +1. **Reference lane** — a hand-rolled server mounting the polymorphic + |
| 76 | + per-subtype routes directly. |
| 77 | +2. **Generated lane** — the port's EMITTED TPH routes booted over HTTP (the |
| 78 | + deployed artifact, not a stand-in). |
| 79 | + |
| 80 | +TS reference runner: `server/typescript/packages/integration-tests/test/api-contract-tph.test.ts` |
| 81 | +(both lanes; one Testcontainers Postgres per scenario per lane). Other ports |
| 82 | +mirror against this corpus in their Tier 4 slice. |
| 83 | + |
| 84 | +## Why this is separate from `persistence-conformance/tph-*` |
| 85 | + |
| 86 | +This corpus exercises the **URL grammar + HTTP wire shape** of polymorphic CRUD. |
| 87 | +The runtime persistence half (single-table insert/find/update semantics, decimal |
| 88 | +normalization, no-cross-subtype-update) is pinned by the `persistence-conformance` |
| 89 | +`tph-*` query scenarios. A port can land routes-only or runtime-only first, so the |
| 90 | +two corpora gate independently. |
0 commit comments