// FR-017 Tier 1 — TS discriminated-union + type guards + dispatcher emission. // // For an entity that carries `@discriminator`, this template emits: // 1. `export type = | | ...` — discriminated union of // every concrete subtype declaring @discriminatorValue against this base. // 2. `export function is(value: ): value is ` — one type // guard per subtype, checking the discriminator field's value. // 3. `export function parse(row: unknown): ` — runtime dispatcher // that reads the discriminator off the raw row and parses with the // matching subtype's Zod schema. // // When the entity does NOT carry @discriminator, returns null. When the entity // carries @discriminator but has no concrete subtypes yet (refactor-in-progress // shape — covered by FR-014 fixture `tph-discriminator-string-no-subtypes`), // returns null too: there are no subtype names to union. import { code, joinCode, imp, type Code } from "ts-poet"; import { type MetaObject, type MetaField, type MetaRoot, OBJECT_ATTR_DISCRIMINATOR, OBJECT_ATTR_DISCRIMINATOR_VALUE, OBJECT_SUBTYPE_ENTITY, } from "@metaobjectsdev/metadata"; interface SubtypeBinding { subtype: MetaObject; value: string; } /** One concrete subtype in a {@link TphPlan}. */ export interface TphSubtypePlan { /** The concrete subtype entity. */ entity: MetaObject; /** Its `@discriminatorValue`. */ value: string; /** The per-subtype REST route segment (e.g. `"bridge"`). The ONE place this * rule is derived — see {@link tphRouteSegment}. */ routeSegment: string; } /** * The single source of truth for a TPH base's polymorphic shape: the * discriminator field name, the concrete subtypes (stable name-sorted order), * each subtype's `@discriminatorValue`, and its per-subtype route segment. * * Every generator in the stack (entity, queries, routes, hooks, grid, forms) * derives its TPH behavior from this one model rather than re-walking the root * and re-deriving the segment / write-shape independently — so the route-segment * rule and subtype set can never drift between, say, the generated routes and * the generated hooks that call them. */ export interface TphPlan { base: MetaObject; discriminatorField: string; subtypes: TphSubtypePlan[]; } // Memoized per base instance — the plan is pure over the (immutable, fully // resolved) post-load model, and a base belongs to exactly one root, so caching // by the base node identity is safe and erases the repeated root walks. const _tphPlanCache = new WeakMap(); /** The per-subtype REST route segment for a discriminator value. The ONE place * this rule lives: `routesFile` and the TanStack hooks both read it through the * plan, so generated hooks can't call a URL the generated routes don't serve. */ export function tphRouteSegment(discriminatorValue: string): string { return discriminatorValue.toLowerCase(); } /** The {@link TphPlan} for a discriminator base, or `null` when `base` is not a * discriminator base (no `@discriminator`, or no concrete subtypes). */ export function tphPlan(base: MetaObject, root: MetaRoot): TphPlan | null { const cached = _tphPlanCache.get(base); if (cached !== undefined) return cached; // ADR-0039: own — TPH super-resolution walk (mirrors C# TphPlan). @discriminator // must be read own to identify WHICH hierarchy level owns it (the discriminator // base); a subtype must not be treated as a base by inheriting it. const discriminatorField = base.ownAttr(OBJECT_ATTR_DISCRIMINATOR); let plan: TphPlan | null = null; if (typeof discriminatorField === "string" && discriminatorField !== "") { const bindings = collectConcreteSubtypes(base, root); if (bindings.length > 0) { plan = { base, discriminatorField, subtypes: bindings.map((b) => ({ entity: b.subtype, value: b.value, routeSegment: tphRouteSegment(b.value), })), }; } } _tphPlanCache.set(base, plan); return plan; } /** True when this entity is a TPH discriminator base — it carries * `@discriminator` AND at least one concrete subtype declares * `@discriminatorValue` extending it. This is the predicate the generator * stack uses to switch into single-table-inheritance emission. */ export function isTphDiscriminatorBase(obj: MetaObject, root: MetaRoot): boolean { return tphPlan(obj, root) !== null; } /** The concrete subtypes bound to this discriminator base, in stable * (name-sorted) order. Returns `[]` when `base` is not a discriminator base. */ export function tphConcreteSubtypes(base: MetaObject, root: MetaRoot): MetaObject[] { return tphPlan(base, root)?.subtypes.map((s) => s.entity) ?? []; } /** * The subtype-only fields that must be folded into the base's single TPH table. * For each concrete subtype, every effective field NOT already on the base is * collected (effective, so fields declared on abstract intermediate levels of a * multi-level hierarchy are captured too). Deduplicated by field name across * subtypes — two subtypes sharing a column name contribute one column. The * caller emits each as a nullable column (rows of other subtypes store NULL). */ export function collectTphSubtypeFields(base: MetaObject, root: MetaRoot): MetaField[] { // ADR-0039: own — TPH super-resolution walk; @discriminator identifies the base level. const discFieldName = base.ownAttr(OBJECT_ATTR_DISCRIMINATOR); if (typeof discFieldName !== "string" || discFieldName === "") return []; const baseFieldNames = new Set(base.fields().map((f) => f.name)); const seen = new Set(); const out: MetaField[] = []; for (const { subtype } of collectConcreteSubtypes(base, root)) { for (const f of subtype.fields()) { if (baseFieldNames.has(f.name)) continue; // base column — already emitted if (seen.has(f.name)) continue; // shared subtype column — emit once seen.add(f.name); out.push(f); } } return out; } /** Render the TPH union + guards + dispatcher block, or null when the entity * is not a discriminator-bearing base with at least one concrete subtype. */ export function renderTphDiscriminatorUnion( base: MetaObject, root: MetaRoot, ): Code | null { // ADR-0039: own — TPH super-resolution walk; @discriminator identifies the base level. const discFieldName = base.ownAttr(OBJECT_ATTR_DISCRIMINATOR); if (typeof discFieldName !== "string" || discFieldName === "") return null; const subtypes = collectConcreteSubtypes(base, root); if (subtypes.length === 0) return null; const baseName = base.name; // 1. Union type alias. Subtype names are imported lazily via ts-poet `imp()` // so they resolve cross-module without manual import wiring. const unionMembers: Code[] = subtypes.map((b) => { const sub = imp(`t:${b.subtype.name}@./${b.subtype.name}.js`); return code`${sub}`; }); const unionType = code`export type ${baseName} = ${joinCode(unionMembers, { on: " | " })};`; // 2. Type guards. const guards: Code[] = subtypes.map((b) => { const sub = imp(`t:${b.subtype.name}@./${b.subtype.name}.js`); return code` /** True when value is a ${b.subtype.name} (discriminated by ${discFieldName} === "${b.value}"). */ export function is${b.subtype.name}(value: ${baseName}): value is ${sub} { return value.${discFieldName} === "${b.value}"; }`; }); // 3. Dispatcher. The head-read uses z.object so the discriminator is read // without committing the row to any subtype yet. const z = imp("z@zod"); const enumLiterals = subtypes.map((b) => JSON.stringify(b.value)).join(", "); const caseBranches: Code[] = subtypes.map((b) => { const schema = imp(`${b.subtype.name}Schema@./${b.subtype.name}.js`); return code` case ${JSON.stringify(b.value)}: return ${schema}.parse(row);`; }); const dispatcher = code` /** * Parse a row from the ${baseName} table, dispatching by the * \`${discFieldName}\` discriminator value to the matching subtype's * Zod schema. Throws on unknown discriminator values. */ export function parse${baseName}(row: unknown): ${baseName} { const head = ${z}.object({ ${discFieldName}: ${z}.enum([${enumLiterals}]) }).parse(row); switch (head.${discFieldName}) { ${joinCode(caseBranches, { on: "\n" })} } } `; return code` ${unionType} ${joinCode(guards, { on: "\n" })} ${dispatcher} `; } /** Walk every top-level object.entity in the root and return the concrete * subtypes whose @discriminatorValue is bound to this base via extends. * Abstract intermediates are skipped (they don't have polymorphic instances). */ function collectConcreteSubtypes(base: MetaObject, root: MetaRoot): SubtypeBinding[] { const bindings: SubtypeBinding[] = []; for (const obj of root.objects()) { if (obj.subType !== OBJECT_SUBTYPE_ENTITY) continue; if (obj.isAbstract === true) continue; if (obj === base) continue; // ADR-0039: own — each concrete subtype declares its OWN @discriminatorValue; // it must NOT inherit a parent's value (super-resolution walk, category 3). const value = obj.ownAttr(OBJECT_ATTR_DISCRIMINATOR_VALUE); if (typeof value !== "string" || value === "") continue; // Walk this entity's extends chain looking for `base`. let cursor = obj.superResolved; let found = false; while (cursor !== undefined) { if (cursor === base) { found = true; break; } cursor = cursor.superResolved; } if (!found) continue; bindings.push({ subtype: obj, value }); } // Stable order by subtype name so emission is deterministic. bindings.sort((a, b) => a.subtype.name.localeCompare(b.subtype.name)); return bindings; }