|
| 1 | +// REFERENCE TEMPLATE — copy this into your repo (e.g. codegen/generators/entity.ts) and own it. |
| 2 | +// Then import it LOCALLY in metaobjects.config.ts: |
| 3 | +// import { entityFile } from "./codegen/generators/entity"; |
| 4 | +// |
| 5 | +// use-when: ALWAYS — this is the entity-module generator. It owns the shape of each |
| 6 | +// generated <Entity>.ts (the Drizzle table, Zod schemas, inferred types, |
| 7 | +// constants, filter allowlists). Start here and adapt the assembly. |
| 8 | +// emits: <target>/<Entity>.ts per concrete object — and the shared-enums module once. |
| 9 | +// Dispatches: abstract/value → interface + Zod; projection → read-only view decl; |
| 10 | +// write-through entity → full Drizzle table path. |
| 11 | +// customize: reorder/drop sections in `sections` below; change the header; swap a sub-renderer |
| 12 | +// for your own (each render* is an engine primitive you call). To deeply own one |
| 13 | +// section (e.g. the Drizzle emit), copy/replace that sub-render call with your code. |
| 14 | +// composes-with: queries.ts, routes.ts, barrel.ts (they import the files this emits). |
| 15 | +// |
| 16 | +// The composition (`renderEntity`) is the relocated body of the built-in entity composer — |
| 17 | +// byte-identical to start, now YOURS to change. It imports only public engine primitives. |
| 18 | + |
| 19 | +import { joinCode, type Code } from "ts-poet"; |
| 20 | +import type { MetaObject } from "@metaobjectsdev/metadata"; |
| 21 | +import { |
| 22 | + perEntity, |
| 23 | + type EmittedFile, |
| 24 | + type GenContext, |
| 25 | + type Generator, |
| 26 | + type GeneratorFactory, |
| 27 | + type RenderContext, |
| 28 | + // sub-renderers (engine primitives) — the LEGO blocks this composition assembles: |
| 29 | + renderDrizzleSchema, |
| 30 | + renderInferredTypes, |
| 31 | + renderEnumTypeAliases, |
| 32 | + renderZodValidators, |
| 33 | + renderEntityConstants, |
| 34 | + renderFilterAllowlist, |
| 35 | + renderSortAllowlist, |
| 36 | + renderFilterType, |
| 37 | + renderTphDiscriminatorUnion, |
| 38 | + isTphDiscriminatorBase, |
| 39 | + renderProjectionDecl, |
| 40 | + renderValueObjectFile, |
| 41 | + renderSharedEnumsFile, |
| 42 | + SHARED_ENUMS_BASENAME, |
| 43 | + // predicates + helpers: |
| 44 | + isProjection, |
| 45 | + isAbstract, |
| 46 | + hasWritableRdbSource, |
| 47 | + // engine plumbing: |
| 48 | + formatTs, |
| 49 | + entityOutputPath, |
| 50 | + GENERATED_HEADER, |
| 51 | +} from "@metaobjectsdev/codegen-ts"; |
| 52 | + |
| 53 | +export interface RenderEntityOpts { |
| 54 | + readonly allowlists?: boolean; |
| 55 | +} |
| 56 | + |
| 57 | +// --- composition (OWNED) — assembles one <Entity>.ts. Change this to change the output. --- |
| 58 | +function renderEntity(entity: MetaObject, ctx: RenderContext, opts?: RenderEntityOpts): string { |
| 59 | + const runtime = ctx.selfTarget.runtime; |
| 60 | + const allowlists = runtime ? (opts?.allowlists ?? true) : false; |
| 61 | + |
| 62 | + // Abstract → shape only (interface + Zod), never a table. |
| 63 | + if (isAbstract(entity)) { |
| 64 | + return renderValueObjectFile(entity, ctx.apiPrefix, ctx); |
| 65 | + } |
| 66 | + // Projection → read-only view declaration + read schema. |
| 67 | + if (isProjection(entity)) { |
| 68 | + return renderProjectionDecl(entity, ctx.loadedRoot, { |
| 69 | + columnNamingStrategy: ctx.columnNamingStrategy, |
| 70 | + dialect: ctx.dialect, |
| 71 | + apiPrefix: ctx.apiPrefix, |
| 72 | + timestampMode: ctx.timestampMode, |
| 73 | + allowlists, |
| 74 | + ctx, |
| 75 | + includeViewDecl: runtime, |
| 76 | + }); |
| 77 | + } |
| 78 | + // Value-only / contract target → interface + Zod, no Drizzle table. |
| 79 | + if (!runtime || !hasWritableRdbSource(entity)) { |
| 80 | + return renderValueObjectFile(entity, ctx.apiPrefix, ctx); |
| 81 | + } |
| 82 | + |
| 83 | + // Write-through entity → the full Drizzle table file. Reorder/drop sections freely. |
| 84 | + const enumAliases = renderEnumTypeAliases(entity, ctx); |
| 85 | + const tphBlock = renderTphDiscriminatorUnion(entity, ctx.loadedRoot); |
| 86 | + const tphBase = tphBlock !== null && isTphDiscriminatorBase(entity, ctx.loadedRoot); |
| 87 | + const sections: Code[] = [ |
| 88 | + renderDrizzleSchema(entity, ctx), |
| 89 | + renderInferredTypes(entity, tphBase, ctx), |
| 90 | + ...(enumAliases !== null ? [enumAliases] : []), |
| 91 | + renderZodValidators(entity, ctx), |
| 92 | + renderEntityConstants(entity, ctx.apiPrefix), |
| 93 | + ...(allowlists ? [renderFilterAllowlist(entity), renderSortAllowlist(entity)] : []), |
| 94 | + renderFilterType(entity), |
| 95 | + ...(tphBlock !== null ? [tphBlock] : []), |
| 96 | + ]; |
| 97 | + |
| 98 | + const body = joinCode(sections, { on: "\n" }).toString(); |
| 99 | + const header = |
| 100 | + `// ${GENERATED_HEADER} — DO NOT EDIT.\n` + |
| 101 | + `// Source metadata: ${entity.name} (${entity.fqn()})\n` + |
| 102 | + `// Customize via ${entity.name}.extra.ts in this directory.\n`; |
| 103 | + return header + body; |
| 104 | +} |
| 105 | + |
| 106 | +export interface EntityFileOpts { |
| 107 | + filter?: (entity: MetaObject) => boolean; |
| 108 | + target?: string; |
| 109 | + allowlists?: boolean; |
| 110 | +} |
| 111 | + |
| 112 | +export const entityFile = function entityFile(opts?: EntityFileOpts): Generator { |
| 113 | + const allowlists = opts?.allowlists ?? true; |
| 114 | + const perEntityEmit = perEntity(async (entity, ctx) => { |
| 115 | + if (!ctx.renderContext) { |
| 116 | + throw new Error("entity-file: renderContext is required (provided by runGen)"); |
| 117 | + } |
| 118 | + if (isAbstract(entity) && !ctx.renderContext.emitAbstractShapes) { |
| 119 | + return []; |
| 120 | + } |
| 121 | + return { |
| 122 | + path: entityOutputPath(ctx.config.outputLayout ?? "flat", entity.package, `${entity.name}.ts`), |
| 123 | + content: await formatTs(renderEntity(entity, ctx.renderContext, { allowlists })), |
| 124 | + }; |
| 125 | + }); |
| 126 | + |
| 127 | + const generator: Generator = { |
| 128 | + name: "entity-file", |
| 129 | + emitsEntityModule: true, |
| 130 | + generate: async (ctx: GenContext): Promise<EmittedFile[]> => { |
| 131 | + const files = await perEntityEmit(ctx); |
| 132 | + // FR-019: emit the shared-enums module once per run (null → no file). |
| 133 | + const sharedEnums = renderSharedEnumsFile(ctx.loadedRoot); |
| 134 | + if (sharedEnums !== null) { |
| 135 | + files.push({ path: `${SHARED_ENUMS_BASENAME}.ts`, content: await formatTs(sharedEnums) }); |
| 136 | + } |
| 137 | + return files; |
| 138 | + }, |
| 139 | + }; |
| 140 | + if (opts?.filter) { |
| 141 | + generator.filter = opts.filter; |
| 142 | + } |
| 143 | + if (opts?.target) { |
| 144 | + generator.target = opts.target; |
| 145 | + } |
| 146 | + return generator; |
| 147 | +} as GeneratorFactory<EntityFileOpts>; |
0 commit comments