@metaobjectsdev/codegen-ts-tanstack 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,14 @@
1
- import { code, imp, joinCode, type Code } from "ts-poet";
1
+ import { code, imp, joinCode, Import, type Code } from "ts-poet";
2
2
  import type { MetaObject } from "@metaobjectsdev/metadata";
3
- import type { RenderContext } from "@metaobjectsdev/codegen-ts";
4
- import { GENERATED_HEADER, isProjection, pluralize, entityModuleSpecifier } from "@metaobjectsdev/codegen-ts";
3
+ import type { RenderContext, RelationEntry } from "@metaobjectsdev/codegen-ts";
4
+ import {
5
+ GENERATED_HEADER,
6
+ isProjection,
7
+ pluralize,
8
+ entityModuleSpecifier,
9
+ isTphDiscriminatorBase,
10
+ tphPlan,
11
+ } from "@metaobjectsdev/codegen-ts";
5
12
 
6
13
  /**
7
14
  * Render <Entity>.hooks.ts — query-key factory + 2 query hooks + (for non-projections) 3 mutation hooks.
@@ -29,21 +36,125 @@ export function renderHooksFile(entity: MetaObject, ctx: RenderContext): string
29
36
  entity.name,
30
37
  ctx.extStyle,
31
38
  );
39
+ // FR-017 Tier 3: a TPH discriminator base gets a polymorphic + per-subtype
40
+ // hooks file (the subtype entities are filtered out of this generator).
41
+ if (isTphDiscriminatorBase(entity, ctx.loadedRoot)) {
42
+ return renderTphHooksFile(entity, ctx, entityModule);
43
+ }
32
44
  if (isProjection(entity)) {
33
- return renderReadOnlyHooksFile(entity, entityModule);
45
+ return renderReadOnlyHooksFile(entity, entityModule, ctx);
46
+ }
47
+ return renderFullHooksFile(entity, entityModule, ctx);
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // FR-018 — M:N collection hook(s).
52
+ //
53
+ // For each many-to-many relationship the source declares (`@cardinality: "many"`
54
+ // + `@through`), emit `use<Source><Relation>(sourceId, opts?)` — a useQuery that
55
+ // fetches the REST sub-resource `GET /<source-plural>/{sourceId}/<relationName>`
56
+ // (the exact URL mountM2mRoute serves) and returns the typed target collection
57
+ // (`Target[]`). The query is enabled only when sourceId is present, so callers
58
+ // can pass `undefined` before the parent row loads. A symmetric self-join is
59
+ // still ONE collection hook (the server unions both junction columns on read).
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /** The M:N relation entries for an entity (cardinality 'many' + a junction). */
63
+ function m2mEntriesFor(entity: MetaObject, ctx: RenderContext): RelationEntry[] {
64
+ return (ctx.relationMap.get(entity.name) ?? []).filter(
65
+ (e) => e.cardinality === "many" && e.junctionEntity !== undefined,
66
+ );
67
+ }
68
+
69
+ /** The `relation: (relation, sourceId) => ...` query-key factory line, included
70
+ * in the keys factory ONLY when the entity has M:N relationships. */
71
+ function m2mKeyLine(keysVar: string): string {
72
+ return (
73
+ ` relation: (relation: string, sourceId: number | undefined) =>\n` +
74
+ ` [...${keysVar}.all(), "relation", relation, sourceId ?? null] as const,`
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Render `use<Source><Relation>(sourceId, opts?)` per M:N relationship. Returns
80
+ * null when the entity has no M:N relationships (no extra hooks emitted).
81
+ */
82
+ function renderM2mHooks(
83
+ entity: MetaObject,
84
+ ctx: RenderContext,
85
+ keysVar: string,
86
+ entries: RelationEntry[],
87
+ ): Code | null {
88
+ if (entries.length === 0) return null;
89
+
90
+ const useQuerySym = imp("useQuery@@tanstack/react-query");
91
+ const useQueryOptionsSym = imp("t:UseQueryOptions@@tanstack/react-query");
92
+ const useQueryResultSym = imp("t:UseQueryResult@@tanstack/react-query");
93
+ const useEntityFetcherSym = imp("useEntityFetcher@@metaobjectsdev/tanstack");
94
+
95
+ const source = entity.name;
96
+
97
+ // Distinct target row types, imported (aliased) from each target's entity
98
+ // module. ts-poet's imp() tracks + hoists these into the import block. The
99
+ // <Target>RelRow alias avoids colliding with the source file's own
100
+ // `type <Source> as <Source>Row` import on a self-join (source === target).
101
+ const targetTypeSym = new Map<string, Import>();
102
+ for (const e of entries) {
103
+ if (targetTypeSym.has(e.targetEntity)) continue;
104
+ const mod = entityModuleSpecifier(
105
+ ctx.selfTarget,
106
+ ctx.entityModuleTarget,
107
+ ctx.packageOf.get(e.targetEntity),
108
+ e.targetEntity,
109
+ ctx.extStyle,
110
+ );
111
+ // `import { type <Target> as <Target>RelRow } from "<mod>"` — the RelRow
112
+ // alias avoids colliding with the source file's own `type <Source> as
113
+ // <Source>Row` import on a self-join (source === target).
114
+ targetTypeSym.set(
115
+ e.targetEntity,
116
+ Import.importsName(`${e.targetEntity}RelRow`, mod, true, e.targetEntity),
117
+ );
34
118
  }
35
- return renderFullHooksFile(entity, entityModule);
119
+
120
+ const hooks = entries.map((e) => {
121
+ const targetSym = targetTypeSym.get(e.targetEntity)!;
122
+ const hookName = `use${source}${capitalize(e.name)}`;
123
+ const relLit = JSON.stringify(e.name);
124
+ return code`
125
+ export function ${hookName}(
126
+ sourceId: number | undefined,
127
+ opts?: Omit<${useQueryOptionsSym}<${targetSym}[]>, "queryKey" | "queryFn">,
128
+ ): ${useQueryResultSym}<${targetSym}[]> {
129
+ const fetcher = ${useEntityFetcherSym}();
130
+ return ${useQuerySym}<${targetSym}[]>({
131
+ queryKey: ${keysVar}.relation(${relLit}, sourceId),
132
+ queryFn: () => fetcher<${targetSym}[]>(\`\${${source}.$apiPrefix}\${${source}.$path}/\${sourceId}/${e.name}\`),
133
+ enabled: sourceId != null && (opts?.enabled ?? true),
134
+ ...opts,
135
+ });
136
+ }
137
+ `;
138
+ });
139
+
140
+ return joinCode(hooks, { on: "\n" });
141
+ }
142
+
143
+ function capitalize(s: string): string {
144
+ return s.charAt(0).toUpperCase() + s.slice(1);
36
145
  }
37
146
 
38
147
  // ---------------------------------------------------------------------------
39
148
  // Read-only path (projections)
40
149
  // ---------------------------------------------------------------------------
41
150
 
42
- function renderReadOnlyHooksFile(entity: MetaObject, entityModule: string): string {
151
+ function renderReadOnlyHooksFile(entity: MetaObject, entityModule: string, ctx: RenderContext): string {
43
152
  const entityName = entity.name;
44
153
  const entityNamePlural = pluralize(entityName);
45
154
  const lcEntity = entityName.charAt(0).toLowerCase() + entityName.slice(1);
46
155
  const keysVar = `${lcEntity}Keys`;
156
+ const m2mEntries = m2mEntriesFor(entity, ctx);
157
+ const relationKeyLine = m2mEntries.length > 0 ? `\n${m2mKeyLine(keysVar)}` : "";
47
158
 
48
159
  const useQuerySym = imp("useQuery@@tanstack/react-query");
49
160
  const useQueryOptionsSym = imp("t:UseQueryOptions@@tanstack/react-query");
@@ -65,7 +176,7 @@ export const ${keysVar} = {
65
176
  lists: () => [...${keysVar}.all(), "list"] as const,
66
177
  list: (filter?: ${entityName}Filter) => [...${keysVar}.lists(), filter ?? {}] as const,
67
178
  details: () => [...${keysVar}.all(), "detail"] as const,
68
- detail: (id: number) => [...${keysVar}.details(), id] as const,
179
+ detail: (id: number) => [...${keysVar}.details(), id] as const,${relationKeyLine}
69
180
  };
70
181
  `;
71
182
 
@@ -96,7 +207,8 @@ export function use${entityNamePlural}(
96
207
  }
97
208
  `;
98
209
 
99
- const body: Code = joinCode([queryKeys, queries], { on: "\n" });
210
+ const m2mHooks = renderM2mHooks(entity, ctx, keysVar, m2mEntries);
211
+ const body: Code = joinCode(m2mHooks ? [queryKeys, queries, m2mHooks] : [queryKeys, queries], { on: "\n" });
100
212
 
101
213
  const header =
102
214
  `// ${GENERATED_HEADER}-tanstack — DO NOT EDIT.\n` +
@@ -108,11 +220,13 @@ export function use${entityNamePlural}(
108
220
  // Full path (writable entities — table-backed or write-through)
109
221
  // ---------------------------------------------------------------------------
110
222
 
111
- function renderFullHooksFile(entity: MetaObject, entityModule: string): string {
223
+ function renderFullHooksFile(entity: MetaObject, entityModule: string, ctx: RenderContext): string {
112
224
  const entityName = entity.name;
113
225
  const entityNamePlural = pluralize(entityName);
114
226
  const lcEntity = entityName.charAt(0).toLowerCase() + entityName.slice(1);
115
227
  const keysVar = `${lcEntity}Keys`;
228
+ const m2mEntries = m2mEntriesFor(entity, ctx);
229
+ const relationKeyLine = m2mEntries.length > 0 ? `\n${m2mKeyLine(keysVar)}` : "";
116
230
 
117
231
  const useMutationSym = imp("useMutation@@tanstack/react-query");
118
232
  const useQuerySym = imp("useQuery@@tanstack/react-query");
@@ -140,7 +254,7 @@ export const ${keysVar} = {
140
254
  lists: () => [...${keysVar}.all(), "list"] as const,
141
255
  list: (filter?: ${entityName}Filter) => [...${keysVar}.lists(), filter ?? {}] as const,
142
256
  details: () => [...${keysVar}.all(), "detail"] as const,
143
- detail: (id: number) => [...${keysVar}.details(), id] as const,
257
+ detail: (id: number) => [...${keysVar}.details(), id] as const,${relationKeyLine}
144
258
  };
145
259
  `;
146
260
 
@@ -171,6 +285,8 @@ export function use${entityNamePlural}(
171
285
  }
172
286
  `;
173
287
 
288
+ const m2mHooks = renderM2mHooks(entity, ctx, keysVar, m2mEntries);
289
+
174
290
  const mutations: Code = code`
175
291
  export function useCreate${entityName}(
176
292
  opts?: Omit<${useMutationOptionsSym}<${entityName}Row, Error, ${entityName}Insert>, "mutationFn">,
@@ -226,10 +342,192 @@ export function useDelete${entityName}(
226
342
  }
227
343
  `;
228
344
 
229
- const body: Code = joinCode([queryKeys, queries, mutations], { on: "\n" });
345
+ const body: Code = joinCode(
346
+ m2mHooks ? [queryKeys, queries, m2mHooks, mutations] : [queryKeys, queries, mutations],
347
+ { on: "\n" },
348
+ );
230
349
 
231
350
  const header =
232
351
  `// ${GENERATED_HEADER}-tanstack — DO NOT EDIT.\n` +
233
352
  `// Source metadata: ${entityName} (${entity.fqn()})\n`;
234
353
  return header + entityImports.toString() + body.toString();
235
354
  }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // FR-017 Tier 3 — TPH discriminator base: polymorphic + per-subtype hooks.
358
+ // ---------------------------------------------------------------------------
359
+
360
+ function renderTphHooksFile(base: MetaObject, ctx: RenderContext, baseModule: string): string {
361
+ const baseName = base.name;
362
+ const lcBase = baseName.charAt(0).toLowerCase() + baseName.slice(1);
363
+ const keysVar = `${lcBase}Keys`;
364
+ // Single source of truth for discriminator field + subtypes + route segments.
365
+ const plan = tphPlan(base, ctx.loadedRoot)!;
366
+ const discField = plan.discriminatorField;
367
+
368
+ const useQuerySym = imp("useQuery@@tanstack/react-query");
369
+ const useMutationSym = imp("useMutation@@tanstack/react-query");
370
+ const useQueryClientSym = imp("useQueryClient@@tanstack/react-query");
371
+ const useQueryOptionsSym = imp("t:UseQueryOptions@@tanstack/react-query");
372
+ const useMutationOptionsSym = imp("t:UseMutationOptions@@tanstack/react-query");
373
+ const useQueryResultSym = imp("t:UseQueryResult@@tanstack/react-query");
374
+ const useMutationResultSym = imp("t:UseMutationResult@@tanstack/react-query");
375
+ const useEntityFetcherSym = imp("useEntityFetcher@@metaobjectsdev/tanstack");
376
+ const buildFilterQsSym = imp("buildFilterQs@@metaobjectsdev/runtime-web");
377
+
378
+ const subtypes = plan.subtypes;
379
+
380
+ // `${baseName}` imports BOTH the constants value (for $path/$apiPrefix) and the
381
+ // discriminated-union type (declaration merge). Each subtype contributes its
382
+ // interface type AND its own filter type (discriminator-excluded — the route
383
+ // pins it), so per-subtype hooks filter on the fields the per-subtype
384
+ // allowlist actually permits.
385
+ const subImportLines = subtypes
386
+ .map((s) => {
387
+ const m = entityModuleSpecifier(ctx.selfTarget, ctx.entityModuleTarget, s.entity.package, s.entity.name, ctx.extStyle);
388
+ return `import { type ${s.entity.name}, type ${s.entity.name}Filter } from ${JSON.stringify(m)};`;
389
+ })
390
+ .join("\n");
391
+ const entityImports: Code = code`
392
+ import { ${baseName}, type ${baseName}Filter } from ${JSON.stringify(baseModule)};
393
+ ${subImportLines}
394
+ `;
395
+
396
+ const queryKeys: Code = code`
397
+ export const ${keysVar} = {
398
+ all: () => [${JSON.stringify(lcBase)}] as const,
399
+ lists: () => [...${keysVar}.all(), "list"] as const,
400
+ list: (filter?: ${baseName}Filter) => [...${keysVar}.lists(), filter ?? {}] as const,
401
+ details: () => [...${keysVar}.all(), "detail"] as const,
402
+ detail: (id: number) => [...${keysVar}.details(), id] as const,
403
+ subtypeLists: (sub: string) => [...${keysVar}.all(), sub, "list"] as const,
404
+ // filter is loosely typed here (cache-key identity only); the per-subtype
405
+ // hooks below type it precisely as <Sub>Filter.
406
+ subtypeList: (sub: string, filter?: unknown) => [...${keysVar}.subtypeLists(sub), filter ?? {}] as const,
407
+ subtypeDetails:(sub: string) => [...${keysVar}.all(), sub, "detail"] as const,
408
+ subtypeDetail: (sub: string, id: number) => [...${keysVar}.subtypeDetails(sub), id] as const,
409
+ };
410
+ `;
411
+
412
+ // Polymorphic reads — return the discriminated union.
413
+ const polymorphic: Code = code`
414
+ export function use${baseName}(
415
+ id: number,
416
+ opts?: Omit<${useQueryOptionsSym}<${baseName}>, "queryKey" | "queryFn">,
417
+ ): ${useQueryResultSym}<${baseName}> {
418
+ const fetcher = ${useEntityFetcherSym}();
419
+ return ${useQuerySym}<${baseName}>({
420
+ queryKey: ${keysVar}.detail(id),
421
+ queryFn: () => fetcher<${baseName}>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/\${id}\`),
422
+ ...opts,
423
+ });
424
+ }
425
+
426
+ export function use${pluralize(baseName)}(
427
+ filter?: ${baseName}Filter,
428
+ opts?: Omit<${useQueryOptionsSym}<${baseName}[]>, "queryKey" | "queryFn">,
429
+ ): ${useQueryResultSym}<${baseName}[]> {
430
+ const fetcher = ${useEntityFetcherSym}();
431
+ const qs = filter ? "?" + ${buildFilterQsSym}(filter as Record<string, unknown>) : "";
432
+ return ${useQuerySym}<${baseName}[]>({
433
+ queryKey: ${keysVar}.list(filter),
434
+ queryFn: () => fetcher<${baseName}[]>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}\${qs}\`),
435
+ ...opts,
436
+ });
437
+ }
438
+ `;
439
+
440
+ // Per-subtype hooks — scoped to each discriminator value's REST sub-path.
441
+ const subtypeSections: Code[] = subtypes.map(({ entity: subEntity, value, routeSegment: seg }) => {
442
+ const subName = subEntity.name;
443
+ const valueLit = JSON.stringify(value);
444
+ const createInput = `Omit<${subName}, ${JSON.stringify(discField)}>`;
445
+ const updateInput = `Partial<${createInput}>`;
446
+ const subPath = `\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/${seg}\``;
447
+ return code`
448
+ export function use${pluralize(subName)}(
449
+ filter?: ${subName}Filter,
450
+ opts?: Omit<${useQueryOptionsSym}<${subName}[]>, "queryKey" | "queryFn">,
451
+ ): ${useQueryResultSym}<${subName}[]> {
452
+ const fetcher = ${useEntityFetcherSym}();
453
+ const qs = filter ? "?" + ${buildFilterQsSym}(filter as Record<string, unknown>) : "";
454
+ return ${useQuerySym}<${subName}[]>({
455
+ queryKey: ${keysVar}.subtypeList(${valueLit}, filter),
456
+ queryFn: () => fetcher<${subName}[]>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/${seg}\${qs}\`),
457
+ ...opts,
458
+ });
459
+ }
460
+
461
+ export function use${subName}(
462
+ id: number,
463
+ opts?: Omit<${useQueryOptionsSym}<${subName}>, "queryKey" | "queryFn">,
464
+ ): ${useQueryResultSym}<${subName}> {
465
+ const fetcher = ${useEntityFetcherSym}();
466
+ return ${useQuerySym}<${subName}>({
467
+ queryKey: ${keysVar}.subtypeDetail(${valueLit}, id),
468
+ queryFn: () => fetcher<${subName}>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/${seg}/\${id}\`),
469
+ ...opts,
470
+ });
471
+ }
472
+
473
+ export function useCreate${subName}(
474
+ opts?: Omit<${useMutationOptionsSym}<${subName}, Error, ${createInput}>, "mutationFn">,
475
+ ): ${useMutationResultSym}<${subName}, Error, ${createInput}> {
476
+ const fetcher = ${useEntityFetcherSym}();
477
+ const qc = ${useQueryClientSym}();
478
+ return ${useMutationSym}<${subName}, Error, ${createInput}>({
479
+ mutationFn: (input) => fetcher<${subName}>(${subPath}, {
480
+ method: "POST",
481
+ headers: { "Content-Type": "application/json" },
482
+ body: JSON.stringify(input),
483
+ }),
484
+ ...opts,
485
+ onSuccess: (...args) => {
486
+ qc.invalidateQueries({ queryKey: ${keysVar}.all() });
487
+ opts?.onSuccess?.(...args);
488
+ },
489
+ });
490
+ }
491
+
492
+ export function useUpdate${subName}(
493
+ opts?: Omit<${useMutationOptionsSym}<${subName}, Error, { id: number; input: ${updateInput} }>, "mutationFn">,
494
+ ): ${useMutationResultSym}<${subName}, Error, { id: number; input: ${updateInput} }> {
495
+ const fetcher = ${useEntityFetcherSym}();
496
+ const qc = ${useQueryClientSym}();
497
+ return ${useMutationSym}({
498
+ mutationFn: ({ id, input }) => fetcher<${subName}>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/${seg}/\${id}\`, {
499
+ method: "PATCH",
500
+ headers: { "Content-Type": "application/json" },
501
+ body: JSON.stringify(input),
502
+ }),
503
+ ...opts,
504
+ onSuccess: (...args) => {
505
+ qc.invalidateQueries({ queryKey: ${keysVar}.all() });
506
+ opts?.onSuccess?.(...args);
507
+ },
508
+ });
509
+ }
510
+
511
+ export function useDelete${subName}(
512
+ opts?: Omit<${useMutationOptionsSym}<void, Error, number>, "mutationFn">,
513
+ ): ${useMutationResultSym}<void, Error, number> {
514
+ const fetcher = ${useEntityFetcherSym}();
515
+ const qc = ${useQueryClientSym}();
516
+ return ${useMutationSym}({
517
+ mutationFn: (id) => fetcher<void>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/${seg}/\${id}\`, { method: "DELETE" }),
518
+ ...opts,
519
+ onSuccess: (...args) => {
520
+ qc.invalidateQueries({ queryKey: ${keysVar}.all() });
521
+ opts?.onSuccess?.(...args);
522
+ },
523
+ });
524
+ }
525
+ `;
526
+ });
527
+
528
+ const body: Code = joinCode([queryKeys, polymorphic, ...subtypeSections], { on: "\n" });
529
+ const header =
530
+ `// ${GENERATED_HEADER}-tanstack — DO NOT EDIT.\n` +
531
+ `// Source metadata: ${baseName} (${base.fqn()}) — TPH discriminator base\n`;
532
+ return header + entityImports.toString() + body.toString();
533
+ }