Skip to content

Commit 7ac54a3

Browse files
dmealingclaude
andcommitted
feat(codegen-ts): FR-017 Tier 1 — TPH discriminated union + per-subtype Zod pin
First implementation tier of FR-017 (spec at b312e5b): TS types-only TPH codegen. Subsequent tiers (Drizzle TPH table, polymorphic queries, per-subtype REST routes, TanStack hooks, per-port fan-out) are deferred and tracked in the FR-017 spec's §Tiered delivery table. What ships in this commit: 1. **New template `tph-discriminator.ts`** — for an entity carrying @Discriminator AND at least one concrete subtype declaring @discriminatorValue extending it, emits three artifacts: - `export type <Base> = <Sub1> | <Sub2> | ...` discriminated union. - One `is<Subtype>(value): value is <Subtype>` type guard per subtype. - `parse<Base>(row: unknown): <Base>` dispatcher that reads the discriminator off the raw row (via `z.object({ <field>: z.enum([...]) })`) and parses with the matching subtype's Zod schema. Returns null when the entity is not a discriminator-bearing base, or has no concrete subtypes (the FR-014 "string-no-subtypes" refactor-in-progress shape is unaffected). 2. **`zod-validators.ts` discriminator pin** — when an entity carries @discriminatorValue, its Zod schema replaces the discriminator field's type expression with `z.literal("<value>")`. The pin lives on the Insert schema only; the Update schema omits the discriminator entirely (clients never change a Bridge into a Copay). 3. **`entity-file.ts` wires the union block** — runs after the entity's own sections (Drizzle, inferred types, Zod, constants, allowlists, filter type). The section is omitted cleanly when the helper returns null. Tests: 5 new tests in test/templates/tph-discriminator.test.ts. Full codegen-ts: 565 pass / 0 fail. No regressions in metadata (1514/2 — both fails pre-existing template-doc fixtures from upstream commit 40c39a3). What does NOT ship in this commit (per FR-017 §Tiered delivery): - Drizzle TPH table union-of-columns emission (Tier 2). - Polymorphic queriesFile + per-subtype query helpers (Tier 2). - Per-subtype REST routes POST /auths/bridge (Tier 2). - TanStack hooks / forms / grids (Tier 3). - Java / Kotlin / C# / Python (Tier 4). - API-contract + persistence-conformance scenarios (Tier 5). No new stable-name generator added under ADR-0021 — the union block lives inside the existing `entity` generator. The canonical manifest is unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b312e5b commit 7ac54a3

4 files changed

Lines changed: 308 additions & 0 deletions

File tree

server/typescript/packages/codegen-ts/src/templates/entity-file.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { renderZodValidators } from "./zod-validators.js";
1515
import { renderEntityConstants } from "./entity-constants.js";
1616
import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.js";
1717
import { renderFilterType } from "./filter-type.js";
18+
import { renderTphDiscriminatorUnion } from "./tph-discriminator.js";
1819
import { GENERATED_HEADER } from "../constants.js";
1920
import { isProjection } from "../projection/projection-detector.js";
2021
import { renderProjectionDecl } from "./projection-decl.js";
@@ -77,6 +78,11 @@ export function renderEntityFile(
7778

7879
// --- Vanilla / write-through entity path ---
7980
const enumAliases = renderEnumTypeAliases(entity);
81+
// FR-017 Tier 1: when this entity carries @discriminator AND has concrete
82+
// subtypes, append the discriminated-union type alias, type guards, and
83+
// the parse<Base>(row) dispatcher. Returns null otherwise (no subtypes, or
84+
// not a discriminator-bearing entity); the section is suppressed cleanly.
85+
const tphBlock = renderTphDiscriminatorUnion(entity, ctx.loadedRoot);
8086
const sections: Code[] = [
8187
renderDrizzleSchema(entity, ctx),
8288
renderInferredTypes(entity),
@@ -85,6 +91,7 @@ export function renderEntityFile(
8591
renderEntityConstants(entity, ctx.apiPrefix),
8692
...(allowlists ? [renderFilterAllowlist(entity), renderSortAllowlist(entity)] : []),
8793
renderFilterType(entity),
94+
...(tphBlock !== null ? [tphBlock] : []),
8895
];
8996

9097
// Render ts-poet body first (ts-poet hoists imp()-tracked imports to the top),
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// FR-017 Tier 1 — TS discriminated-union + type guards + dispatcher emission.
2+
//
3+
// For an entity that carries `@discriminator`, this template emits:
4+
// 1. `export type <Base> = <Sub1> | <Sub2> | ...` — discriminated union of
5+
// every concrete subtype declaring @discriminatorValue against this base.
6+
// 2. `export function is<Sub>(value: <Base>): value is <Sub>` — one type
7+
// guard per subtype, checking the discriminator field's value.
8+
// 3. `export function parse<Base>(row: unknown): <Base>` — runtime dispatcher
9+
// that reads the discriminator off the raw row and parses with the
10+
// matching subtype's Zod schema.
11+
//
12+
// When the entity does NOT carry @discriminator, returns null. When the entity
13+
// carries @discriminator but has no concrete subtypes yet (refactor-in-progress
14+
// shape — covered by FR-014 fixture `tph-discriminator-string-no-subtypes`),
15+
// returns null too: there are no subtype names to union.
16+
17+
import { code, joinCode, imp, type Code } from "ts-poet";
18+
import {
19+
type MetaObject,
20+
type MetaRoot,
21+
OBJECT_ATTR_DISCRIMINATOR,
22+
OBJECT_ATTR_DISCRIMINATOR_VALUE,
23+
OBJECT_SUBTYPE_ENTITY,
24+
} from "@metaobjectsdev/metadata";
25+
26+
interface SubtypeBinding {
27+
subtype: MetaObject;
28+
value: string;
29+
}
30+
31+
/** Render the TPH union + guards + dispatcher block, or null when the entity
32+
* is not a discriminator-bearing base with at least one concrete subtype. */
33+
export function renderTphDiscriminatorUnion(
34+
base: MetaObject,
35+
root: MetaRoot,
36+
): Code | null {
37+
const discFieldName = base.ownAttr(OBJECT_ATTR_DISCRIMINATOR);
38+
if (typeof discFieldName !== "string" || discFieldName === "") return null;
39+
40+
const subtypes = collectConcreteSubtypes(base, root);
41+
if (subtypes.length === 0) return null;
42+
43+
const baseName = base.name;
44+
45+
// 1. Union type alias. Subtype names are imported lazily via ts-poet `imp()`
46+
// so they resolve cross-module without manual import wiring.
47+
const unionMembers: Code[] = subtypes.map((b) => {
48+
const sub = imp(`t:${b.subtype.name}@./${b.subtype.name}.js`);
49+
return code`${sub}`;
50+
});
51+
const unionType = code`export type ${baseName} = ${joinCode(unionMembers, { on: " | " })};`;
52+
53+
// 2. Type guards.
54+
const guards: Code[] = subtypes.map((b) => {
55+
const sub = imp(`t:${b.subtype.name}@./${b.subtype.name}.js`);
56+
return code`
57+
/** True when value is a ${b.subtype.name} (discriminated by ${discFieldName} === "${b.value}"). */
58+
export function is${b.subtype.name}(value: ${baseName}): value is ${sub} {
59+
return value.${discFieldName} === "${b.value}";
60+
}`;
61+
});
62+
63+
// 3. Dispatcher. The head-read uses z.object so the discriminator is read
64+
// without committing the row to any subtype yet.
65+
const z = imp("z@zod");
66+
const enumLiterals = subtypes.map((b) => JSON.stringify(b.value)).join(", ");
67+
68+
const caseBranches: Code[] = subtypes.map((b) => {
69+
const schema = imp(`${b.subtype.name}Schema@./${b.subtype.name}.js`);
70+
return code` case ${JSON.stringify(b.value)}: return ${schema}.parse(row);`;
71+
});
72+
73+
const dispatcher = code`
74+
/**
75+
* Parse a row from the ${baseName} table, dispatching by the
76+
* \`${discFieldName}\` discriminator value to the matching subtype's
77+
* Zod schema. Throws on unknown discriminator values.
78+
*/
79+
export function parse${baseName}(row: unknown): ${baseName} {
80+
const head = ${z}.object({ ${discFieldName}: ${z}.enum([${enumLiterals}]) }).parse(row);
81+
switch (head.${discFieldName}) {
82+
${joinCode(caseBranches, { on: "\n" })}
83+
}
84+
}
85+
`;
86+
87+
return code`
88+
${unionType}
89+
90+
${joinCode(guards, { on: "\n" })}
91+
92+
${dispatcher}
93+
`;
94+
}
95+
96+
/** Walk every top-level object.entity in the root and return the concrete
97+
* subtypes whose @discriminatorValue is bound to this base via extends.
98+
* Abstract intermediates are skipped (they don't have polymorphic instances). */
99+
function collectConcreteSubtypes(base: MetaObject, root: MetaRoot): SubtypeBinding[] {
100+
const bindings: SubtypeBinding[] = [];
101+
for (const obj of root.objects()) {
102+
if (obj.subType !== OBJECT_SUBTYPE_ENTITY) continue;
103+
if (obj.isAbstract === true) continue;
104+
if (obj === base) continue;
105+
106+
const value = obj.ownAttr(OBJECT_ATTR_DISCRIMINATOR_VALUE);
107+
if (typeof value !== "string" || value === "") continue;
108+
109+
// Walk this entity's extends chain looking for `base`.
110+
let cursor = obj.superResolved;
111+
let found = false;
112+
while (cursor !== undefined) {
113+
if (cursor === base) {
114+
found = true;
115+
break;
116+
}
117+
cursor = cursor.superResolved;
118+
}
119+
if (!found) continue;
120+
121+
bindings.push({ subtype: obj, value });
122+
}
123+
// Stable order by subtype name so emission is deterministic.
124+
bindings.sort((a, b) => a.subtype.name.localeCompare(b.subtype.name));
125+
return bindings;
126+
}

server/typescript/packages/codegen-ts/src/templates/zod-validators.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,34 @@ import {
2424
AUTO_SET_ON_CREATE, AUTO_SET_ON_UPDATE,
2525
VALIDATOR_ATTR_MAX, VALIDATOR_ATTR_MIN, VALIDATOR_ATTR_PATTERN,
2626
GENERATION_INCREMENT, GENERATION_UUID,
27+
OBJECT_ATTR_DISCRIMINATOR, OBJECT_ATTR_DISCRIMINATOR_VALUE,
2728
} from "@metaobjectsdev/metadata";
2829
import { enumValues, zodEnumExpr } from "../enum-meta.js";
2930
import { renderDocsFor } from "./jsdoc.js";
3031

32+
/**
33+
* FR-017 Tier 1 — when this object is a TPH subtype (@discriminatorValue set
34+
* and an ancestor carries @discriminator), return the discriminator-field-name
35+
* → pinned-literal-value pair. Subtypes emit `<field>: z.literal("<value>")`
36+
* instead of the inherited field's normal type expression. Returns undefined
37+
* when the object is not a TPH subtype.
38+
*/
39+
function tphDiscriminatorPin(obj: MetaObject): { fieldName: string; value: string } | undefined {
40+
const value = obj.ownAttr(OBJECT_ATTR_DISCRIMINATOR_VALUE);
41+
if (typeof value !== "string" || value === "") return undefined;
42+
43+
// Walk the extends chain to find the root carrying @discriminator.
44+
let cursor = obj.superResolved;
45+
while (cursor !== undefined) {
46+
const fieldName = cursor.ownAttr(OBJECT_ATTR_DISCRIMINATOR);
47+
if (typeof fieldName === "string" && fieldName !== "") {
48+
return { fieldName, value };
49+
}
50+
cursor = cursor.superResolved;
51+
}
52+
return undefined;
53+
}
54+
3155
/** Auto-generated PK field names that should be omitted from InsertSchema. */
3256
function autoGenPkFieldNames(obj: MetaObject): Set<string> {
3357
const out = new Set<string>();
@@ -55,6 +79,7 @@ function autoGenPkFieldNames(obj: MetaObject): Set<string> {
5579
export function renderInsertSchemaOnly(obj: MetaObject): Code {
5680
const z = imp("z@zod");
5781
const autoGenPkFields = autoGenPkFieldNames(obj);
82+
const tphPin = tphDiscriminatorPin(obj);
5883

5984
const insertFieldLines: Code[] = [];
6085
for (const child of obj.fields()) {
@@ -64,6 +89,14 @@ export function renderInsertSchemaOnly(obj: MetaObject): Code {
6489
// create-shape schema entirely.
6590
if (child.ownAttr(FIELD_ATTR_READ_ONLY) === true) continue;
6691

92+
// FR-017 Tier 1: TPH subtype pins its discriminator field to z.literal(...).
93+
if (tphPin !== undefined && child.name === tphPin.fieldName) {
94+
insertFieldLines.push(
95+
code` ${child.name}: z.literal(${JSON.stringify(tphPin.value)})`,
96+
);
97+
continue;
98+
}
99+
67100
const autoSet = child.ownAttr(FIELD_ATTR_AUTO_SET);
68101

69102
if (autoSet === AUTO_SET_ON_CREATE || autoSet === AUTO_SET_ON_UPDATE) {
@@ -89,6 +122,7 @@ ${joinCode(insertFieldLines, { on: ",\n" })}
89122
export function renderZodValidators(obj: MetaObject): Code {
90123
const z = imp("z@zod");
91124
const autoGenPkFields = autoGenPkFieldNames(obj);
125+
const tphPin = tphDiscriminatorPin(obj);
92126

93127
const insertFieldLines: Code[] = [];
94128
const updateFieldLines: Code[] = [];
@@ -100,6 +134,17 @@ export function renderZodValidators(obj: MetaObject): Code {
100134
// contract at the boundary with a 400 response).
101135
if (child.ownAttr(FIELD_ATTR_READ_ONLY) === true) continue;
102136

137+
// FR-017 Tier 1: TPH subtype pins its discriminator field to z.literal(...).
138+
// The discriminator is implicit on subtype rows (controlled by URL / insert
139+
// path) — the app never writes it via the body and never updates it.
140+
// Insert: pinned literal. Update: omitted entirely (clients can't change subtype).
141+
if (tphPin !== undefined && child.name === tphPin.fieldName) {
142+
insertFieldLines.push(
143+
code` ${child.name}: z.literal(${JSON.stringify(tphPin.value)})`,
144+
);
145+
continue;
146+
}
147+
103148
const autoSet = child.ownAttr(FIELD_ATTR_AUTO_SET);
104149

105150
// Insert schema: @autoSet fields use transform (always override client input).
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// FR-017 Tier 1 — TS discriminated union + per-subtype Zod emission.
2+
//
3+
// For an entity with @discriminator + concrete subtypes carrying
4+
// @discriminatorValue, entityFile additionally emits:
5+
// - The union type `Auth = BridgeAuth | CopayAuth | ...`
6+
// - Type guards `isBridgeAuth(a): a is BridgeAuth`
7+
// - The `parseAuth(row)` dispatcher
8+
//
9+
// For each subtype, the subtype's Zod schema pins the discriminator field
10+
// via z.literal("<value>"), so a subtype schema rejects a row of a different
11+
// subtype.
12+
13+
import { describe, expect, test } from "bun:test";
14+
import {
15+
MetaDataLoader,
16+
InMemoryStringSource,
17+
type MetaObject,
18+
} from "@metaobjectsdev/metadata";
19+
import { renderTphDiscriminatorUnion } from "../../src/templates/tph-discriminator.js";
20+
import { renderZodValidators } from "../../src/templates/zod-validators.js";
21+
22+
async function loadTph() {
23+
const loader = new MetaDataLoader();
24+
const { root, errors } = await loader.load([
25+
new InMemoryStringSource(
26+
JSON.stringify({
27+
"metadata.root": {
28+
package: "demo",
29+
children: [
30+
{
31+
"object.entity": {
32+
name: "Auth",
33+
"@discriminator": "type",
34+
children: [
35+
{ "source.rdb": { "@table": "auths" } },
36+
{
37+
"field.enum": {
38+
name: "type",
39+
"@values": ["Bridge", "Copay"],
40+
},
41+
},
42+
{ "field.long": { name: "id" } },
43+
{ "identity.primary": { "@fields": "id" } },
44+
],
45+
},
46+
},
47+
{
48+
"object.entity": {
49+
name: "BridgeAuth",
50+
extends: "Auth",
51+
"@discriminatorValue": "Bridge",
52+
children: [{ "field.int": { name: "quantity" } }],
53+
},
54+
},
55+
{
56+
"object.entity": {
57+
name: "CopayAuth",
58+
extends: "Auth",
59+
"@discriminatorValue": "Copay",
60+
children: [
61+
{
62+
"field.decimal": {
63+
name: "copayAmount",
64+
"@precision": 10,
65+
"@scale": 2,
66+
},
67+
},
68+
],
69+
},
70+
},
71+
],
72+
},
73+
}),
74+
{ id: "auth.json" },
75+
),
76+
]);
77+
if (errors.length > 0) throw new Error(errors.map((e) => e.message).join("; "));
78+
const base = root.objects().find((o) => o.name === "Auth")! as MetaObject;
79+
const bridge = root.objects().find((o) => o.name === "BridgeAuth")! as MetaObject;
80+
const copay = root.objects().find((o) => o.name === "CopayAuth")! as MetaObject;
81+
return { root, base, bridge, copay };
82+
}
83+
84+
describe("FR-017 Tier 1 — renderTphDiscriminatorUnion", () => {
85+
test("emits the discriminated union + type guards + parser dispatcher on the base entity", async () => {
86+
const { root, base } = await loadTph();
87+
const out = renderTphDiscriminatorUnion(base, root).toString();
88+
89+
// Union type spans every concrete subtype, base does not appear in members.
90+
expect(out).toContain("export type Auth = BridgeAuth | CopayAuth");
91+
92+
// Type guards per subtype.
93+
expect(out).toContain("export function isBridgeAuth(value: Auth)");
94+
expect(out).toContain("export function isCopayAuth(value: Auth)");
95+
expect(out).toContain('value.type === "Bridge"');
96+
expect(out).toContain('value.type === "Copay"');
97+
98+
// Dispatcher function reads the discriminator without committing to a
99+
// subtype yet, then dispatches via switch.
100+
expect(out).toContain("export function parseAuth(row: unknown): Auth");
101+
expect(out).toContain('case "Bridge":');
102+
expect(out).toContain('case "Copay":');
103+
});
104+
105+
test("returns null for an entity that has no @discriminator", async () => {
106+
const { root, bridge } = await loadTph();
107+
// bridge is a subtype, not the discriminator-bearing root.
108+
expect(renderTphDiscriminatorUnion(bridge, root)).toBeNull();
109+
});
110+
});
111+
112+
describe("FR-017 Tier 1 — subtype Zod pins discriminator via z.literal", () => {
113+
test("BridgeAuth's Zod schema pins type to z.literal(\"Bridge\")", async () => {
114+
const { bridge } = await loadTph();
115+
const out = renderZodValidators(bridge).toString();
116+
expect(out).toContain('z.literal("Bridge")');
117+
});
118+
119+
test("CopayAuth's Zod schema pins type to z.literal(\"Copay\")", async () => {
120+
const { copay } = await loadTph();
121+
const out = renderZodValidators(copay).toString();
122+
expect(out).toContain('z.literal("Copay")');
123+
});
124+
125+
test("BridgeAuth's schema still emits the subtype-only field", async () => {
126+
const { bridge } = await loadTph();
127+
const out = renderZodValidators(bridge).toString();
128+
expect(out).toContain("quantity:");
129+
});
130+
});

0 commit comments

Comments
 (0)