Skip to content

Commit 384e58b

Browse files
dmealingclaude
andcommitted
feat(codegen-ts): FR-015 callable wrapper generator (storedProc / tableFunction)
Emits a typed wrapper function per entity backed by a stored procedure or table function. One file per callable entity at <outDir>/<package>/<Entity>.callable.ts: import { sql } from "drizzle-orm"; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { PhaseSummaryArgs } from "./PhaseSummaryArgs.js"; import { PhaseSummarySchema, type PhaseSummary } from "./PhaseSummary.js"; export async function callPhaseSummary( db: NodePgDatabase, args: PhaseSummaryArgs, ): Promise<PhaseSummary[]> { const r = await db.execute( sql`SELECT * FROM fn_phase_summary(${args.caseId}, ${args.asOfDate})`, ); return r.rows.map((row) => PhaseSummarySchema.parse(row as unknown)); } Args bind in the @parameterRef value-object's field declaration order. Zero- arg procs (no @parameterRef) emit a no-args wrapper with the same shape. Drizzle's sql tag handles parameter binding so injection is impossible. Files: - templates/callable-file.ts — renderCallableFile + isCallableEntity helper. - generators/callable-file.ts — factory registered via `callableFile()` in a user's metaobjects.config.ts. Filter skips non-callable entities so vanilla tables/views never emit a noisy empty file. - generators/index.ts — re-exports the factory. Tests: 3 new tests in test/templates/callable-file.test.ts covering stored proc + table function + zero-arg cases. Full codegen-ts: 414 pass / 4 pre-existing extract-codegen fails unrelated to this change. Deferred (per the metadata-codegen plan): @exposeAsRoute opt-in REST route emission + matching TanStack hook. The wrapper is the foundation; REST exposure can be added as an additive layer when an adopter actually needs HTTP-exposed procs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a6daacc commit 384e58b

4 files changed

Lines changed: 297 additions & 0 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// FR-015 callable-wrapper generator factory.
2+
//
3+
// One file per entity backed by a stored procedure or table function:
4+
// <outDir>/<package>/<Entity>.callable.ts
5+
//
6+
// Non-callable entities are skipped (filter narrows the iteration set so
7+
// vanilla tables/views never emit a noisy empty file).
8+
9+
import type { MetaObject } from "@metaobjectsdev/metadata";
10+
import { perEntity, type Generator, type GeneratorFactory } from "../generator.js";
11+
import { renderCallableFile, isCallableEntity } from "../templates/callable-file.js";
12+
import { formatTs } from "../format.js";
13+
import { entityOutputPath } from "../import-path.js";
14+
15+
export interface CallableFileOpts {
16+
filter?: (entity: MetaObject) => boolean;
17+
target?: string;
18+
}
19+
20+
export const callableFile = function callableFile(opts?: CallableFileOpts): Generator {
21+
const userFilter = opts?.filter;
22+
const filter: (e: MetaObject) => boolean = userFilter
23+
? (e) => isCallableEntity(e) && userFilter(e)
24+
: isCallableEntity;
25+
26+
const generator: Generator = {
27+
name: "callable-file",
28+
filter,
29+
generate: perEntity(async (entity, ctx) => {
30+
return {
31+
path: entityOutputPath(
32+
ctx.config.outputLayout ?? "flat",
33+
entity.package,
34+
`${entity.name}.callable.ts`,
35+
),
36+
content: await formatTs(renderCallableFile(entity)),
37+
};
38+
}),
39+
};
40+
if (opts?.target) {
41+
generator.target = opts.target;
42+
}
43+
return generator;
44+
} as GeneratorFactory<CallableFileOpts>;

server/typescript/packages/codegen-ts/src/generators/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { entityFile, type EntityFileOpts } from "./entity-file.js";
22
export { queriesFile, type QueriesFileOpts } from "./queries-file.js";
3+
export { callableFile, type CallableFileOpts } from "./callable-file.js";
34
export { routesFile, type RoutesFileOpts } from "./routes-file.js";
45
export { routesFileHono, type RoutesFileHonoOpts } from "./routes-file-hono.js";
56
export { barrel, type BarrelOpts } from "./barrel.js";
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// FR-015 — render a typed wrapper function for an entity backed by a stored
2+
// procedure or table function. One generated file per callable entity:
3+
//
4+
// import { sql } from "drizzle-orm";
5+
// import type { NodePgDatabase } from "drizzle-orm/node-postgres";
6+
// import type { PhaseSummaryArgs } from "./PhaseSummaryArgs.js";
7+
// import { PhaseSummarySchema, type PhaseSummary } from "./PhaseSummary.js";
8+
//
9+
// export async function callPhaseSummary(
10+
// db: NodePgDatabase,
11+
// args: PhaseSummaryArgs,
12+
// ): Promise<PhaseSummary[]> {
13+
// const r = await db.execute(
14+
// sql`SELECT * FROM fn_phase_summary(${args.caseId}, ${args.asOfDate})`,
15+
// );
16+
// return r.rows.map((row) => PhaseSummarySchema.parse(row));
17+
// }
18+
//
19+
// Zero-argument procs (no @parameterRef) emit a no-args version that drops the
20+
// `args` parameter and calls `fn_x()`.
21+
22+
import {
23+
type MetaObject,
24+
MetaSource,
25+
SOURCE_ATTR_PARAMETER_REF,
26+
SOURCE_KIND_STORED_PROC,
27+
SOURCE_KIND_TABLE_FUNCTION,
28+
TYPE_FIELD,
29+
TYPE_SOURCE,
30+
OBJECT_SUBTYPE_VALUE,
31+
} from "@metaobjectsdev/metadata";
32+
import { GENERATED_HEADER } from "../constants.js";
33+
34+
const CALLABLE_KINDS: ReadonlySet<string> = new Set([
35+
SOURCE_KIND_STORED_PROC,
36+
SOURCE_KIND_TABLE_FUNCTION,
37+
]);
38+
39+
/** Return true when this entity is backed by a callable source (stored
40+
* procedure or table function). Used by the generator factory to filter. */
41+
export function isCallableEntity(entity: MetaObject): boolean {
42+
const src = callableSource(entity);
43+
return src !== undefined;
44+
}
45+
46+
function callableSource(entity: MetaObject): MetaSource | undefined {
47+
for (const child of entity.ownChildren()) {
48+
if (child.type !== TYPE_SOURCE) continue;
49+
if (!(child instanceof MetaSource)) continue;
50+
if (CALLABLE_KINDS.has(child.effectiveKind)) return child;
51+
}
52+
return undefined;
53+
}
54+
55+
/** Render the full file content for an entity's callable wrapper. Caller
56+
* is responsible for formatting (prettier / biome) and writing to disk. */
57+
export function renderCallableFile(entity: MetaObject): string {
58+
const source = callableSource(entity);
59+
if (source === undefined) {
60+
throw new Error(
61+
`renderCallableFile: entity "${entity.name}" has no source.rdb with @kind: "storedProc" or "tableFunction"`,
62+
);
63+
}
64+
65+
const procName = source.physicalName;
66+
const argsRef = source.ownAttr(SOURCE_ATTR_PARAMETER_REF);
67+
const argsObjectName = typeof argsRef === "string" && argsRef !== "" ? argsRef : undefined;
68+
69+
// Resolve the parameter value-object (when set) — same root as the entity.
70+
const root = entity.root();
71+
const argsObject =
72+
argsObjectName !== undefined
73+
? (root.ownChildren().find(
74+
(c) =>
75+
c.subType === OBJECT_SUBTYPE_VALUE && c.name === argsObjectName,
76+
) as MetaObject | undefined)
77+
: undefined;
78+
79+
// Build the parameter list for the SQL call site. Declaration order = arg
80+
// order. Empty when argsObject is undefined (zero-arg proc).
81+
const paramFieldNames: string[] = argsObject
82+
? argsObject.ownChildren()
83+
.filter((c) => c.type === TYPE_FIELD)
84+
.map((f) => f.name)
85+
: [];
86+
87+
const sqlArgList = paramFieldNames.length === 0
88+
? ""
89+
: paramFieldNames.map((n) => `\${args.${n}}`).join(", ");
90+
91+
const fnName = `call${entity.name}`;
92+
const projectionType = entity.name;
93+
const projectionSchemaName = `${entity.name}Schema`;
94+
95+
// Imports: entity (Zod schema + type) + drizzle sql + the args value-object
96+
// when applicable.
97+
const argsImport = argsObject
98+
? `import type { ${argsObjectName} } from "./${argsObjectName}.js";\n`
99+
: "";
100+
101+
const signature = argsObject
102+
? `db: NodePgDatabase, args: ${argsObjectName}`
103+
: `db: NodePgDatabase`;
104+
105+
return `// ${GENERATED_HEADER}
106+
import { sql } from "drizzle-orm";
107+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
108+
${argsImport}import { ${projectionSchemaName}, type ${projectionType} } from "./${entity.name}.js";
109+
110+
/**
111+
* FR-015: typed wrapper around the \`${procName}\` ${source.effectiveKind === SOURCE_KIND_STORED_PROC ? "stored procedure" : "table function"}.
112+
* Drizzle passes a parameterised SELECT — args bind in declaration order from
113+
* the @parameterRef value-object${argsObject ? `, here ${argsObjectName}` : ""}.
114+
*/
115+
export async function ${fnName}(${signature}): Promise<${projectionType}[]> {
116+
const r = await db.execute(
117+
sql\`SELECT * FROM ${procName}(${sqlArgList})\`,
118+
);
119+
return r.rows.map((row) => ${projectionSchemaName}.parse(row as unknown));
120+
}
121+
`;
122+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// FR-015 — codegen-ts callable-file emission test.
2+
// Generated wrapper for an entity backed by a stored procedure or table
3+
// function: typed args (from @parameterRef object.value) + projection return.
4+
5+
import { describe, expect, test } from "bun:test";
6+
import {
7+
MetaDataLoader,
8+
InMemoryStringSource,
9+
} from "@metaobjectsdev/metadata";
10+
import { renderCallableFile } from "../../src/templates/callable-file.js";
11+
12+
async function loadFirstEntity(doc: unknown) {
13+
const loader = new MetaDataLoader();
14+
const { root, errors } = await loader.load([
15+
new InMemoryStringSource(JSON.stringify(doc), { id: "demo.json" }),
16+
]);
17+
if (errors.length > 0) throw new Error(`load errors: ${errors.map((e) => e.message).join("; ")}`);
18+
const procEntity = root.objects().find((o) => o.name === "PhaseSummary")!;
19+
return { root, procEntity };
20+
}
21+
22+
describe("renderCallableFile", () => {
23+
test("storedProc with @parameterRef emits typed wrapper function", async () => {
24+
const { procEntity } = await loadFirstEntity({
25+
"metadata.root": {
26+
package: "demo",
27+
children: [
28+
{
29+
"object.value": {
30+
name: "PhaseSummaryArgs",
31+
children: [
32+
{ "field.int": { name: "caseId", "@required": true } },
33+
{ "field.timestamp": { name: "asOfDate" } },
34+
],
35+
},
36+
},
37+
{
38+
"object.entity": {
39+
name: "PhaseSummary",
40+
children: [
41+
{
42+
"source.rdb": {
43+
"@kind": "storedProc",
44+
"@proc": "fn_phase_summary",
45+
"@parameterRef": "PhaseSummaryArgs",
46+
},
47+
},
48+
{ "field.long": { name: "phaseId" } },
49+
{ "field.string": { name: "phaseName" } },
50+
{ "identity.primary": { "@fields": "phaseId" } },
51+
],
52+
},
53+
},
54+
],
55+
},
56+
});
57+
58+
const out = renderCallableFile(procEntity);
59+
expect(out).toContain("export async function callPhaseSummary");
60+
expect(out).toContain("args: PhaseSummaryArgs");
61+
expect(out).toContain("fn_phase_summary");
62+
expect(out).toContain("args.caseId");
63+
expect(out).toContain("args.asOfDate");
64+
expect(out).toContain("Promise<PhaseSummary[]>");
65+
});
66+
67+
test("tableFunction with @parameterRef emits the same shape with the @function name", async () => {
68+
const { procEntity } = await loadFirstEntity({
69+
"metadata.root": {
70+
package: "demo",
71+
children: [
72+
{
73+
"object.value": {
74+
name: "PhaseSummaryArgs",
75+
children: [{ "field.int": { name: "id" } }],
76+
},
77+
},
78+
{
79+
"object.entity": {
80+
name: "PhaseSummary",
81+
children: [
82+
{
83+
"source.rdb": {
84+
"@kind": "tableFunction",
85+
"@function": "fn_phase_listing",
86+
"@parameterRef": "PhaseSummaryArgs",
87+
},
88+
},
89+
{ "field.long": { name: "id" } },
90+
{ "identity.primary": { "@fields": "id" } },
91+
],
92+
},
93+
},
94+
],
95+
},
96+
});
97+
const out = renderCallableFile(procEntity);
98+
expect(out).toContain("fn_phase_listing");
99+
expect(out).toContain("args.id");
100+
});
101+
102+
test("storedProc with NO @parameterRef emits a zero-arg wrapper", async () => {
103+
const { procEntity } = await loadFirstEntity({
104+
"metadata.root": {
105+
package: "demo",
106+
children: [
107+
{
108+
"object.entity": {
109+
name: "PhaseSummary",
110+
children: [
111+
{
112+
"source.rdb": {
113+
"@kind": "storedProc",
114+
"@proc": "fn_ping",
115+
},
116+
},
117+
{ "field.long": { name: "id" } },
118+
{ "identity.primary": { "@fields": "id" } },
119+
],
120+
},
121+
},
122+
],
123+
},
124+
});
125+
const out = renderCallableFile(procEntity);
126+
expect(out).toContain("callPhaseSummary(db");
127+
expect(out).not.toContain("args:");
128+
expect(out).toContain("fn_ping()");
129+
});
130+
});

0 commit comments

Comments
 (0)