Skip to content

Commit c463db4

Browse files
dmealingclaude
andcommitted
feat(codegen-ts-tanstack): FR-017 Tier 3 #1 — polymorphic + per-subtype TanStack hooks
A TPH discriminator base now emits one `.hooks.ts` carrying: - A query-key factory with both polymorphic and per-subtype scopes (all keys rooted at <base>Keys.all(), so a per-subtype mutation invalidates both the per-subtype and the polymorphic lists). - Polymorphic reads use<Base> / use<Plural> returning the discriminated union. No polymorphic create/update/delete (you can't instantiate an abstract base). - Per concrete subtype: use<Plural> / use<Sub> (scoped to the value's REST sub-path /<base>/<value>) + useCreate/useUpdate/useDelete<Sub>. Create input is Omit<<Sub>, "<disc>"> (the discriminator is injected server-side, Tier 2); update is Partial of that. Mutations invalidate <base>Keys.all(). The tanstack-query generator filter skips TPH subtypes (their hooks live in the base file). Exposed isTphDiscriminatorBase / tphConcreteSubtypes / isTphSubtype from @metaobjectsdev/codegen-ts for the per-framework packages. Tests: tph-hooks.test.ts (filter-skip + polymorphic + per-subtype content); tanstack-query regression green. (Targeted suite 28/28; the full-suite reds under the concurrent machine-load spike were all TimeoutError, none in TPH.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 754686a commit c463db4

4 files changed

Lines changed: 318 additions & 4 deletions

File tree

server/typescript/packages/codegen-ts-tanstack/src/tanstack-query.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { MetaObject } from "@metaobjectsdev/metadata";
2-
import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, emitsInstanceArtifacts } from "@metaobjectsdev/codegen-ts";
2+
import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, emitsInstanceArtifacts, isTphSubtype } from "@metaobjectsdev/codegen-ts";
33
import { renderHooksFile } from "./templates/hooks-file.js";
44

55
export interface TanstackQueryOpts {
@@ -22,7 +22,9 @@ export const tanstackQuery = function tanstackQuery(opts?: TanstackQueryOpts): G
2222
// they contribute shape via inheritance only and have no instance to query),
2323
// the metadata opt-out, and the optional user filter. Projections still pass
2424
// here and get read-only hooks via renderHooksFile's isProjection branch.
25-
filter: (e: MetaObject) => emitsInstanceArtifacts(e) && e.ownAttr("emitTanstack") !== false && userFilter(e),
25+
// FR-017 Tier 3: TPH subtypes get no standalone hooks file — their per-subtype
26+
// hooks live in the discriminator base's hooks file (polymorphic + per-subtype).
27+
filter: (e: MetaObject) => emitsInstanceArtifacts(e) && e.ownAttr("emitTanstack") !== false && !isTphSubtype(e) && userFilter(e),
2628
generate: perEntity(async (entity, ctx) => {
2729
if (!ctx.renderContext) {
2830
throw new Error(

server/typescript/packages/codegen-ts-tanstack/src/templates/hooks-file.ts

Lines changed: 192 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import { code, imp, joinCode, type Code } from "ts-poet";
2-
import type { MetaObject } from "@metaobjectsdev/metadata";
2+
import {
3+
type MetaObject,
4+
OBJECT_ATTR_DISCRIMINATOR,
5+
OBJECT_ATTR_DISCRIMINATOR_VALUE,
6+
} from "@metaobjectsdev/metadata";
37
import type { RenderContext } from "@metaobjectsdev/codegen-ts";
4-
import { GENERATED_HEADER, isProjection, pluralize, entityModuleSpecifier } from "@metaobjectsdev/codegen-ts";
8+
import {
9+
GENERATED_HEADER,
10+
isProjection,
11+
pluralize,
12+
entityModuleSpecifier,
13+
isTphDiscriminatorBase,
14+
tphConcreteSubtypes,
15+
} from "@metaobjectsdev/codegen-ts";
516

617
/**
718
* Render <Entity>.hooks.ts — query-key factory + 2 query hooks + (for non-projections) 3 mutation hooks.
@@ -29,6 +40,11 @@ export function renderHooksFile(entity: MetaObject, ctx: RenderContext): string
2940
entity.name,
3041
ctx.extStyle,
3142
);
43+
// FR-017 Tier 3: a TPH discriminator base gets a polymorphic + per-subtype
44+
// hooks file (the subtype entities are filtered out of this generator).
45+
if (isTphDiscriminatorBase(entity, ctx.loadedRoot)) {
46+
return renderTphHooksFile(entity, ctx, entityModule);
47+
}
3248
if (isProjection(entity)) {
3349
return renderReadOnlyHooksFile(entity, entityModule);
3450
}
@@ -233,3 +249,177 @@ export function useDelete${entityName}(
233249
`// Source metadata: ${entityName} (${entity.fqn()})\n`;
234250
return header + entityImports.toString() + body.toString();
235251
}
252+
253+
// ---------------------------------------------------------------------------
254+
// FR-017 Tier 3 — TPH discriminator base: polymorphic + per-subtype hooks.
255+
// ---------------------------------------------------------------------------
256+
257+
function renderTphHooksFile(base: MetaObject, ctx: RenderContext, baseModule: string): string {
258+
const baseName = base.name;
259+
const lcBase = baseName.charAt(0).toLowerCase() + baseName.slice(1);
260+
const keysVar = `${lcBase}Keys`;
261+
const discField = base.ownAttr(OBJECT_ATTR_DISCRIMINATOR) as string;
262+
263+
const useQuerySym = imp("useQuery@@tanstack/react-query");
264+
const useMutationSym = imp("useMutation@@tanstack/react-query");
265+
const useQueryClientSym = imp("useQueryClient@@tanstack/react-query");
266+
const useQueryOptionsSym = imp("t:UseQueryOptions@@tanstack/react-query");
267+
const useMutationOptionsSym = imp("t:UseMutationOptions@@tanstack/react-query");
268+
const useQueryResultSym = imp("t:UseQueryResult@@tanstack/react-query");
269+
const useMutationResultSym = imp("t:UseMutationResult@@tanstack/react-query");
270+
const useEntityFetcherSym = imp("useEntityFetcher@@metaobjectsdev/tanstack");
271+
const buildFilterQsSym = imp("buildFilterQs@@metaobjectsdev/runtime-web");
272+
273+
const subtypes = tphConcreteSubtypes(base, ctx.loadedRoot);
274+
275+
// `${baseName}` imports BOTH the constants value (for $path/$apiPrefix) and the
276+
// discriminated-union type (declaration merge). Each subtype contributes its
277+
// interface type.
278+
const subImportLines = subtypes
279+
.map((s) => {
280+
const m = entityModuleSpecifier(ctx.selfTarget, ctx.entityModuleTarget, s.package, s.name, ctx.extStyle);
281+
return `import { type ${s.name} } from ${JSON.stringify(m)};`;
282+
})
283+
.join("\n");
284+
const entityImports: Code = code`
285+
import { ${baseName}, type ${baseName}Filter } from ${JSON.stringify(baseModule)};
286+
${subImportLines}
287+
`;
288+
289+
const queryKeys: Code = code`
290+
export const ${keysVar} = {
291+
all: () => [${JSON.stringify(lcBase)}] as const,
292+
lists: () => [...${keysVar}.all(), "list"] as const,
293+
list: (filter?: ${baseName}Filter) => [...${keysVar}.lists(), filter ?? {}] as const,
294+
details: () => [...${keysVar}.all(), "detail"] as const,
295+
detail: (id: number) => [...${keysVar}.details(), id] as const,
296+
subtypeLists: (sub: string) => [...${keysVar}.all(), sub, "list"] as const,
297+
subtypeList: (sub: string, filter?: ${baseName}Filter) => [...${keysVar}.subtypeLists(sub), filter ?? {}] as const,
298+
subtypeDetails:(sub: string) => [...${keysVar}.all(), sub, "detail"] as const,
299+
subtypeDetail: (sub: string, id: number) => [...${keysVar}.subtypeDetails(sub), id] as const,
300+
};
301+
`;
302+
303+
// Polymorphic reads — return the discriminated union.
304+
const polymorphic: Code = code`
305+
export function use${baseName}(
306+
id: number,
307+
opts?: Omit<${useQueryOptionsSym}<${baseName}>, "queryKey" | "queryFn">,
308+
): ${useQueryResultSym}<${baseName}> {
309+
const fetcher = ${useEntityFetcherSym}();
310+
return ${useQuerySym}<${baseName}>({
311+
queryKey: ${keysVar}.detail(id),
312+
queryFn: () => fetcher<${baseName}>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/\${id}\`),
313+
...opts,
314+
});
315+
}
316+
317+
export function use${pluralize(baseName)}(
318+
filter?: ${baseName}Filter,
319+
opts?: Omit<${useQueryOptionsSym}<${baseName}[]>, "queryKey" | "queryFn">,
320+
): ${useQueryResultSym}<${baseName}[]> {
321+
const fetcher = ${useEntityFetcherSym}();
322+
const qs = filter ? "?" + ${buildFilterQsSym}(filter as Record<string, unknown>) : "";
323+
return ${useQuerySym}<${baseName}[]>({
324+
queryKey: ${keysVar}.list(filter),
325+
queryFn: () => fetcher<${baseName}[]>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}\${qs}\`),
326+
...opts,
327+
});
328+
}
329+
`;
330+
331+
// Per-subtype hooks — scoped to each discriminator value's REST sub-path.
332+
const subtypeSections: Code[] = subtypes.map((sub) => {
333+
const value = sub.ownAttr(OBJECT_ATTR_DISCRIMINATOR_VALUE) as string;
334+
const seg = value.toLowerCase();
335+
const valueLit = JSON.stringify(value);
336+
const createInput = `Omit<${sub.name}, ${JSON.stringify(discField)}>`;
337+
const updateInput = `Partial<${createInput}>`;
338+
const subPath = `\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/${seg}\``;
339+
return code`
340+
export function use${pluralize(sub.name)}(
341+
filter?: ${baseName}Filter,
342+
opts?: Omit<${useQueryOptionsSym}<${sub.name}[]>, "queryKey" | "queryFn">,
343+
): ${useQueryResultSym}<${sub.name}[]> {
344+
const fetcher = ${useEntityFetcherSym}();
345+
const qs = filter ? "?" + ${buildFilterQsSym}(filter as Record<string, unknown>) : "";
346+
return ${useQuerySym}<${sub.name}[]>({
347+
queryKey: ${keysVar}.subtypeList(${valueLit}, filter),
348+
queryFn: () => fetcher<${sub.name}[]>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/${seg}\${qs}\`),
349+
...opts,
350+
});
351+
}
352+
353+
export function use${sub.name}(
354+
id: number,
355+
opts?: Omit<${useQueryOptionsSym}<${sub.name}>, "queryKey" | "queryFn">,
356+
): ${useQueryResultSym}<${sub.name}> {
357+
const fetcher = ${useEntityFetcherSym}();
358+
return ${useQuerySym}<${sub.name}>({
359+
queryKey: ${keysVar}.subtypeDetail(${valueLit}, id),
360+
queryFn: () => fetcher<${sub.name}>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/${seg}/\${id}\`),
361+
...opts,
362+
});
363+
}
364+
365+
export function useCreate${sub.name}(
366+
opts?: Omit<${useMutationOptionsSym}<${sub.name}, Error, ${createInput}>, "mutationFn">,
367+
): ${useMutationResultSym}<${sub.name}, Error, ${createInput}> {
368+
const fetcher = ${useEntityFetcherSym}();
369+
const qc = ${useQueryClientSym}();
370+
return ${useMutationSym}<${sub.name}, Error, ${createInput}>({
371+
mutationFn: (input) => fetcher<${sub.name}>(${subPath}, {
372+
method: "POST",
373+
headers: { "Content-Type": "application/json" },
374+
body: JSON.stringify(input),
375+
}),
376+
...opts,
377+
onSuccess: (...args) => {
378+
qc.invalidateQueries({ queryKey: ${keysVar}.all() });
379+
opts?.onSuccess?.(...args);
380+
},
381+
});
382+
}
383+
384+
export function useUpdate${sub.name}(
385+
opts?: Omit<${useMutationOptionsSym}<${sub.name}, Error, { id: number; input: ${updateInput} }>, "mutationFn">,
386+
): ${useMutationResultSym}<${sub.name}, Error, { id: number; input: ${updateInput} }> {
387+
const fetcher = ${useEntityFetcherSym}();
388+
const qc = ${useQueryClientSym}();
389+
return ${useMutationSym}({
390+
mutationFn: ({ id, input }) => fetcher<${sub.name}>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/${seg}/\${id}\`, {
391+
method: "PATCH",
392+
headers: { "Content-Type": "application/json" },
393+
body: JSON.stringify(input),
394+
}),
395+
...opts,
396+
onSuccess: (...args) => {
397+
qc.invalidateQueries({ queryKey: ${keysVar}.all() });
398+
opts?.onSuccess?.(...args);
399+
},
400+
});
401+
}
402+
403+
export function useDelete${sub.name}(
404+
opts?: Omit<${useMutationOptionsSym}<void, Error, number>, "mutationFn">,
405+
): ${useMutationResultSym}<void, Error, number> {
406+
const fetcher = ${useEntityFetcherSym}();
407+
const qc = ${useQueryClientSym}();
408+
return ${useMutationSym}({
409+
mutationFn: (id) => fetcher<void>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/${seg}/\${id}\`, { method: "DELETE" }),
410+
...opts,
411+
onSuccess: (...args) => {
412+
qc.invalidateQueries({ queryKey: ${keysVar}.all() });
413+
opts?.onSuccess?.(...args);
414+
},
415+
});
416+
}
417+
`;
418+
});
419+
420+
const body: Code = joinCode([queryKeys, polymorphic, ...subtypeSections], { on: "\n" });
421+
const header =
422+
`// ${GENERATED_HEADER}-tanstack — DO NOT EDIT.\n` +
423+
`// Source metadata: ${baseName} (${base.fqn()}) — TPH discriminator base\n`;
424+
return header + entityImports.toString() + body.toString();
425+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// FR-017 Tier 3 — TanStack hooks for a TPH discriminator base.
2+
//
3+
// The base entity's hooks file carries polymorphic reads (useAuth / useAuths
4+
// returning the union) plus a full per-subtype hook set (list / get / create /
5+
// update / delete) targeting the per-subtype REST sub-paths. Subtype entities
6+
// get NO standalone hooks file.
7+
8+
import { describe, test, expect } from "bun:test";
9+
import {
10+
MetaDataLoader,
11+
InMemoryStringSource,
12+
type MetaObject,
13+
type MetaRoot,
14+
} from "@metaobjectsdev/metadata";
15+
import { tanstackQuery } from "../src/tanstack-query.js";
16+
import { renderHooksFile } from "../src/templates/hooks-file.js";
17+
import { makeRenderContext, buildPkMap, buildRelationMap } from "@metaobjectsdev/codegen-ts";
18+
19+
async function loadTph(): Promise<{ root: MetaRoot; base: MetaObject; bridge: MetaObject }> {
20+
const loader = new MetaDataLoader();
21+
const { root, errors } = await loader.load([
22+
new InMemoryStringSource(
23+
JSON.stringify({
24+
"metadata.root": {
25+
package: "demo",
26+
children: [
27+
{
28+
"object.entity": {
29+
name: "Auth",
30+
"@discriminator": "type",
31+
children: [
32+
{ "source.rdb": { "@table": "auths" } },
33+
{ "field.enum": { name: "type", "@values": ["Bridge", "Copay"] } },
34+
{ "field.long": { name: "id" } },
35+
{ "identity.primary": { "@fields": "id", "@generation": "increment" } },
36+
],
37+
},
38+
},
39+
{
40+
"object.entity": {
41+
name: "BridgeAuth",
42+
extends: "Auth",
43+
"@discriminatorValue": "Bridge",
44+
children: [{ "field.int": { name: "quantity" } }],
45+
},
46+
},
47+
{
48+
"object.entity": {
49+
name: "CopayAuth",
50+
extends: "Auth",
51+
"@discriminatorValue": "Copay",
52+
children: [
53+
{ "field.decimal": { name: "copayAmount", "@precision": 10, "@scale": 2 } },
54+
],
55+
},
56+
},
57+
],
58+
},
59+
}),
60+
{ id: "auth.json" },
61+
),
62+
]);
63+
if (errors.length > 0) throw new Error(errors.map((e) => e.message).join("; "));
64+
const base = root.objects().find((o) => o.name === "Auth")! as MetaObject;
65+
const bridge = root.objects().find((o) => o.name === "BridgeAuth")! as MetaObject;
66+
return { root, base, bridge };
67+
}
68+
69+
function ctxFor(root: MetaRoot) {
70+
return makeRenderContext({
71+
dialect: "postgres",
72+
loadedRoot: root,
73+
outDir: "/x",
74+
dbImport: "../db",
75+
extStyle: "none",
76+
pkMap: buildPkMap(root),
77+
relationMap: buildRelationMap(root),
78+
});
79+
}
80+
81+
describe("FR-017 Tier 3 — TPH TanStack hooks", () => {
82+
test("generator filter skips TPH subtypes", async () => {
83+
const { base, bridge } = await loadTph();
84+
const gen = tanstackQuery();
85+
expect(gen.filter!(bridge)).toBe(false);
86+
expect(gen.filter!(base)).toBe(true);
87+
});
88+
89+
test("base hooks file emits polymorphic reads returning the union, no base create", async () => {
90+
const { root, base } = await loadTph();
91+
const out = renderHooksFile(base, ctxFor(root));
92+
expect(out).toContain("export const authKeys");
93+
expect(out).toContain("export function useAuth(");
94+
expect(out).toContain("export function useAuths(");
95+
// Polymorphic reads return the Auth union.
96+
expect(out).toContain("UseQueryResult<Auth>");
97+
expect(out).toContain("UseQueryResult<Auth[]>");
98+
// No polymorphic create/update/delete on the abstract-shaped base.
99+
expect(out).not.toContain("export function useCreateAuth(");
100+
});
101+
102+
test("base hooks file carries per-subtype hooks against the sub-paths", async () => {
103+
const { root, base } = await loadTph();
104+
const out = renderHooksFile(base, ctxFor(root));
105+
expect(out).toContain("export function useBridgeAuths(");
106+
expect(out).toContain("export function useBridgeAuth(");
107+
expect(out).toContain("export function useCreateBridgeAuth(");
108+
expect(out).toContain("export function useUpdateBridgeAuth(");
109+
expect(out).toContain("export function useDeleteBridgeAuth(");
110+
// Per-subtype CopayAuth too.
111+
expect(out).toContain("export function useCreateCopayAuth(");
112+
// Sub-path derived from the lowercased discriminator value.
113+
expect(out).toContain("/bridge");
114+
expect(out).toContain("/copay");
115+
// Create input omits the discriminator.
116+
expect(out).toContain('Omit<BridgeAuth, "type">');
117+
});
118+
});

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ export type { DocPageNode, DocPagePlacement } from "./docs-paths.js";
6161

6262
export { isProjection, isWriteThrough } from "./projection/projection-detector.js";
6363
export { isAbstract, emitsInstanceArtifacts, emitsWriteArtifacts } from "./instance-artifacts.js";
64+
// FR-017 TPH helpers — used by the per-framework codegen packages (tanstack,
65+
// react) to dispatch polymorphic/per-subtype emission and skip subtype files.
66+
export { isTphDiscriminatorBase, tphConcreteSubtypes, collectTphSubtypeFields } from "./templates/tph-discriminator.js";
67+
export { isTphSubtype } from "./templates/zod-validators.js";
6468
export { extractViewSpec } from "./projection/extract-view-spec.js";
6569
export type { ExtractContext } from "./projection/extract-view-spec.js";
6670
export { emitViewDdl } from "./projection/view-ddl-emit.js";

0 commit comments

Comments
 (0)