|
| 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 | +} |
0 commit comments