@metaobjectsdev/codegen-ts-tanstack 0.5.0-rc.1

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.
Files changed (42) hide show
  1. package/LICENSE +189 -0
  2. package/README.md +41 -0
  3. package/dist/grid-filter-validate.d.ts +20 -0
  4. package/dist/grid-filter-validate.d.ts.map +1 -0
  5. package/dist/grid-filter-validate.js +37 -0
  6. package/dist/grid-filter-validate.js.map +1 -0
  7. package/dist/index.d.ts +4 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +5 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/tanstack-grid-hook.d.ts +15 -0
  12. package/dist/tanstack-grid-hook.d.ts.map +1 -0
  13. package/dist/tanstack-grid-hook.js +38 -0
  14. package/dist/tanstack-grid-hook.js.map +1 -0
  15. package/dist/tanstack-grid.d.ts +13 -0
  16. package/dist/tanstack-grid.d.ts.map +1 -0
  17. package/dist/tanstack-grid.js +36 -0
  18. package/dist/tanstack-grid.js.map +1 -0
  19. package/dist/tanstack-query.d.ts +15 -0
  20. package/dist/tanstack-query.d.ts.map +1 -0
  21. package/dist/tanstack-query.js +31 -0
  22. package/dist/tanstack-query.js.map +1 -0
  23. package/dist/templates/columns-file.d.ts +4 -0
  24. package/dist/templates/columns-file.d.ts.map +1 -0
  25. package/dist/templates/columns-file.js +142 -0
  26. package/dist/templates/columns-file.js.map +1 -0
  27. package/dist/templates/grid-hook-file.d.ts +4 -0
  28. package/dist/templates/grid-hook-file.d.ts.map +1 -0
  29. package/dist/templates/grid-hook-file.js +125 -0
  30. package/dist/templates/grid-hook-file.js.map +1 -0
  31. package/dist/templates/hooks-file.d.ts +20 -0
  32. package/dist/templates/hooks-file.d.ts.map +1 -0
  33. package/dist/templates/hooks-file.js +208 -0
  34. package/dist/templates/hooks-file.js.map +1 -0
  35. package/package.json +48 -0
  36. package/src/index.ts +4 -0
  37. package/src/tanstack-grid-hook.ts +50 -0
  38. package/src/tanstack-grid.ts +44 -0
  39. package/src/tanstack-query.ts +39 -0
  40. package/src/templates/columns-file.ts +192 -0
  41. package/src/templates/grid-hook-file.ts +155 -0
  42. package/src/templates/hooks-file.ts +235 -0
@@ -0,0 +1,44 @@
1
+ import type { MetaObject } from "@metaobjectsdev/metadata";
2
+ import { LAYOUT_SUBTYPE_DATA_GRID } from "@metaobjectsdev/metadata";
3
+ import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath } from "@metaobjectsdev/codegen-ts";
4
+ import { renderColumnsFile } from "./templates/columns-file.js";
5
+
6
+ export interface TanstackGridOpts {
7
+ filter?: (entity: MetaObject) => boolean;
8
+ target?: string;
9
+ }
10
+
11
+ function hasDataGridLayout(entity: MetaObject): boolean {
12
+ // layouts() is effective — own + inherited layouts (from extends:/super:).
13
+ return entity.layouts().some((l) => l.subType === LAYOUT_SUBTYPE_DATA_GRID);
14
+ }
15
+
16
+ /**
17
+ * Per-entity opt-out via `@emitTanstack: false`. Per-entity opt-IN: presence of
18
+ * at least one `dataGrid` layout on the object. If both pass and the user-supplied
19
+ * filter passes, the generator emits.
20
+ */
21
+ export const tanstackGrid = function tanstackGrid(opts?: TanstackGridOpts): Generator {
22
+ const userFilter = opts?.filter ?? (() => true);
23
+ const generator: Generator = {
24
+ name: "tanstack-grid",
25
+ // Always set: AND-composes opt-out, user filter, and dataGrid layout presence.
26
+ filter: (e: MetaObject) =>
27
+ e.ownAttr("emitTanstack") !== false
28
+ && userFilter(e)
29
+ && hasDataGridLayout(e),
30
+ generate: perEntity(async (entity, ctx) => {
31
+ if (!ctx.renderContext) {
32
+ throw new Error("tanstack-grid: renderContext is required (provided by runGen)");
33
+ }
34
+ return {
35
+ path: entityOutputPath(ctx.renderContext.outputLayout, entity.package, `${entity.name}.columns.tsx`),
36
+ content: await formatTs(renderColumnsFile(entity, ctx.renderContext)),
37
+ };
38
+ }),
39
+ };
40
+ if (opts?.target) {
41
+ generator.target = opts.target;
42
+ }
43
+ return generator;
44
+ } as GeneratorFactory<TanstackGridOpts | void>;
@@ -0,0 +1,39 @@
1
+ import type { MetaObject } from "@metaobjectsdev/metadata";
2
+ import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath } from "@metaobjectsdev/codegen-ts";
3
+ import { renderHooksFile } from "./templates/hooks-file.js";
4
+
5
+ export interface TanstackQueryOpts {
6
+ filter?: (entity: MetaObject) => boolean;
7
+ target?: string;
8
+ }
9
+
10
+ /**
11
+ * Per-entity generator that emits <Entity>.hooks.ts — a query-key factory
12
+ * plus 2 query hooks and 3 mutation hooks backed by useEntityFetcher().
13
+ *
14
+ * Per-entity opt-out via `@emitTanstack: false` is honored. If the user
15
+ * supplies their own filter, both must pass (AND).
16
+ */
17
+ export const tanstackQuery = function tanstackQuery(opts?: TanstackQueryOpts): Generator {
18
+ const userFilter = opts?.filter ?? (() => true);
19
+ const generator: Generator = {
20
+ name: "tanstack-query",
21
+ // AND-composes metadata opt-out with optional user filter.
22
+ filter: (e: MetaObject) => e.ownAttr("emitTanstack") !== false && userFilter(e),
23
+ generate: perEntity(async (entity, ctx) => {
24
+ if (!ctx.renderContext) {
25
+ throw new Error(
26
+ "tanstack-query: renderContext is required (provided by runGen)",
27
+ );
28
+ }
29
+ return {
30
+ path: entityOutputPath(ctx.renderContext.outputLayout, entity.package, `${entity.name}.hooks.ts`),
31
+ content: await formatTs(renderHooksFile(entity, ctx.renderContext)),
32
+ };
33
+ }),
34
+ };
35
+ if (opts?.target) {
36
+ generator.target = opts.target;
37
+ }
38
+ return generator;
39
+ } as GeneratorFactory<TanstackQueryOpts | void>;
@@ -0,0 +1,192 @@
1
+ import { code, imp, joinCode, type Code } from "ts-poet";
2
+ import type { MetaObject, MetaField } from "@metaobjectsdev/metadata";
3
+ import {
4
+ LAYOUT_SUBTYPE_DATA_GRID,
5
+ LAYOUT_DATA_GRID_ATTR_PAGE_SIZE,
6
+ LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_FIELD,
7
+ LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_ORDER,
8
+ LAYOUT_DATA_GRID_ATTR_FILTERABLE,
9
+ LAYOUT_DATA_GRID_ATTR_FILTER,
10
+ LAYOUT_DATA_GRID_ATTR_COLUMNS,
11
+ } from "@metaobjectsdev/metadata";
12
+ import type { RenderContext } from "@metaobjectsdev/codegen-ts";
13
+ import { GENERATED_HEADER, entityModuleSpecifier } from "@metaobjectsdev/codegen-ts";
14
+
15
+ interface ColumnSpec {
16
+ id: string;
17
+ header: string; // humanized label
18
+ viewKind: string; // field's view subtype, falls back to "text"
19
+ sortable?: boolean;
20
+ width?: number;
21
+ renderer?: string;
22
+ }
23
+
24
+ interface GridSpec {
25
+ name: string;
26
+ pageSize: number;
27
+ defaultSortField?: string;
28
+ defaultSortOrder?: "asc" | "desc";
29
+ filterable: boolean;
30
+ filter?: Record<string, unknown>; // desugared, load-time-validated @filter object
31
+ columns: ColumnSpec[];
32
+ }
33
+
34
+ function humanize(s: string): string {
35
+ return s
36
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
37
+ .replace(/^./, (c) => c.toUpperCase());
38
+ }
39
+
40
+ function fieldViewKind(field: MetaField): string {
41
+ const view = field.ownViews()[0];
42
+ return view?.subType ?? "text";
43
+ }
44
+
45
+ function fieldLabel(field: MetaField): string {
46
+ const view = field.ownViews()[0];
47
+ const label = view?.ownAttr("label");
48
+ if (typeof label === "string") return label;
49
+ return humanize(field.name);
50
+ }
51
+
52
+ /**
53
+ * Extract grid specs from an entity's dataGrid layouts, resolving column
54
+ * metadata against the entity's field list.
55
+ *
56
+ * Column set: read from @columns stringArray attr on the layout. If absent,
57
+ * fall back to all fields on the entity (pre-E-T2 behaviour, kept for
58
+ * backwards compat with metadata not yet migrated by E-T4).
59
+ */
60
+ function extractGrids(entity: MetaObject): GridSpec[] {
61
+ // fields() and layouts() are both effective (own + inherited via extends:/super:).
62
+ const fieldsByName = new Map(
63
+ entity.fields().map((f) => [f.name, f] as const),
64
+ );
65
+
66
+ const grids: GridSpec[] = [];
67
+ for (const layout of entity.layouts()) {
68
+ if (layout.subType !== LAYOUT_SUBTYPE_DATA_GRID) continue;
69
+
70
+ // @columns is a stringArray attr on the layout (set by E-T4 migration).
71
+ // Fall back to all entity fields if not present.
72
+ const columnsAttr = layout.ownAttr(LAYOUT_DATA_GRID_ATTR_COLUMNS);
73
+ const columnNames: string[] = Array.isArray(columnsAttr)
74
+ ? (columnsAttr as unknown[]).filter((x): x is string => typeof x === "string")
75
+ : [...fieldsByName.keys()];
76
+
77
+ const columns: ColumnSpec[] = columnNames.flatMap((name) => {
78
+ const field = fieldsByName.get(name);
79
+ if (!field) return []; // columns ref that doesn't exist on entity; defensive skip
80
+ const spec: ColumnSpec = {
81
+ id: name,
82
+ header: fieldLabel(field),
83
+ viewKind: fieldViewKind(field),
84
+ };
85
+ return [spec];
86
+ });
87
+
88
+ const sortField = layout.ownAttr(LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_FIELD);
89
+ const sortOrder = layout.ownAttr(LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_ORDER);
90
+
91
+ const filterAttr = layout.ownAttr(LAYOUT_DATA_GRID_ATTR_FILTER);
92
+ const grid: GridSpec = {
93
+ name: layout.name || "default",
94
+ pageSize: (layout.ownAttr(LAYOUT_DATA_GRID_ATTR_PAGE_SIZE) as number | undefined) ?? 25,
95
+ filterable: layout.ownAttr(LAYOUT_DATA_GRID_ATTR_FILTERABLE) === true,
96
+ columns,
97
+ };
98
+ if (typeof sortField === "string") grid.defaultSortField = sortField;
99
+ if (sortOrder === "asc" || sortOrder === "desc") grid.defaultSortOrder = sortOrder;
100
+ if (typeof filterAttr === "object" && filterAttr !== null && !Array.isArray(filterAttr)) {
101
+ grid.filter = filterAttr as Record<string, unknown>;
102
+ }
103
+ grids.push(grid);
104
+ }
105
+ return grids;
106
+ }
107
+
108
+ function renderColumnDef(col: ColumnSpec): string {
109
+ const parts: string[] = [];
110
+ parts.push(` id: ${JSON.stringify(col.id)}`);
111
+ parts.push(` accessorKey: ${JSON.stringify(col.id)}`);
112
+ parts.push(` header: ${JSON.stringify(col.header)}`);
113
+ const meta: string[] = [`view: ${JSON.stringify(col.viewKind)}`];
114
+ if (col.sortable !== undefined) meta.push(`sortable: ${col.sortable}`);
115
+ if (col.width !== undefined) meta.push(`width: ${col.width}`);
116
+ if (col.renderer !== undefined) meta.push(`renderer: ${JSON.stringify(col.renderer)}`);
117
+ parts.push(` meta: { ${meta.join(", ")} }`);
118
+ return ` {\n${parts.join(",\n")}\n }`;
119
+ }
120
+
121
+ export function renderColumnsFile(entity: MetaObject, ctx: RenderContext): string {
122
+ const entityName = entity.name;
123
+ const lcEntity = entityName.charAt(0).toLowerCase() + entityName.slice(1);
124
+ const grids = extractGrids(entity);
125
+
126
+ const ColumnDefSym = imp("t:ColumnDef@@tanstack/react-table");
127
+
128
+ // Track whether any grid emits a filter const so we know to import <Entity>Filter.
129
+ let hasFilterConst = false;
130
+
131
+ const sections = grids.map((grid) => {
132
+ const gridConstName = `${lcEntity}${capitalize(grid.name)}Grid`;
133
+ const columnsConstName = `${lcEntity}${capitalize(grid.name)}Columns`;
134
+
135
+ const sortBlock = grid.defaultSortField && grid.defaultSortOrder
136
+ ? ` defaultSort: { field: ${JSON.stringify(grid.defaultSortField)}, order: ${JSON.stringify(grid.defaultSortOrder)} as const },\n`
137
+ : "";
138
+ const gridConst = code`
139
+ export const ${gridConstName} = {
140
+ name: ${JSON.stringify(grid.name)},
141
+ pageSize: ${grid.pageSize},
142
+ ${sortBlock} filterable: ${grid.filterable},
143
+ };
144
+ `;
145
+ const colsLines = grid.columns.map(renderColumnDef).join(",\n");
146
+ const colsConst = code`
147
+ export const ${columnsConstName}: ${ColumnDefSym}<${entityName}Row>[] = [
148
+ ${colsLines},
149
+ ];
150
+ `;
151
+
152
+ // Emit per-grid filter const when @filter is set. The value is already a
153
+ // desugared, load-time-validated object — emit it verbatim.
154
+ let filterConstCode: Code | null = null;
155
+ if (grid.filter !== undefined) {
156
+ const filterConstName = `${lcEntity}${capitalize(grid.name)}Filter`;
157
+ hasFilterConst = true;
158
+ filterConstCode = code`
159
+ export const ${filterConstName}: ${entityName}Filter = ${JSON.stringify(grid.filter, null, 2)};
160
+ `;
161
+ }
162
+
163
+ return filterConstCode
164
+ ? code`${gridConst}\n${colsConst}\n${filterConstCode}`
165
+ : code`${gridConst}\n${colsConst}`;
166
+ });
167
+
168
+ const header =
169
+ `// ${GENERATED_HEADER}-tanstack — DO NOT EDIT.\n` +
170
+ `// Source metadata: ${entityName} (${entity.fqn()})\n`;
171
+
172
+ // Import the entity's own file. Same target → relative "./Entity"; cross
173
+ // target → importBase-qualified package path.
174
+ const entityModule = entityModuleSpecifier(
175
+ ctx.selfTarget,
176
+ ctx.entityModuleTarget,
177
+ entity.package,
178
+ entityName,
179
+ ctx.extStyle,
180
+ );
181
+ // Import <Entity>Row always; import <Entity>Filter only when a filter const is emitted.
182
+ const entityImportCode = hasFilterConst
183
+ ? code`import type { ${entityName} as ${entityName}Row, ${entityName}Filter } from ${JSON.stringify(entityModule)};`
184
+ : code`import type { ${entityName} as ${entityName}Row } from ${JSON.stringify(entityModule)};`;
185
+
186
+ const body: Code = joinCode(sections, { on: "\n" });
187
+ return header + entityImportCode.toString() + "\n" + body.toString();
188
+ }
189
+
190
+ function capitalize(s: string): string {
191
+ return s.charAt(0).toUpperCase() + s.slice(1);
192
+ }
@@ -0,0 +1,155 @@
1
+ // Generated grid-hook template — emits one use<Entity><Grid>Grid() per
2
+ // layout[dataGrid] declared on the entity. The hook owns
3
+ // {sorting, pagination, columnFilters, search} state and runs the query
4
+ // against the entity's CRUD list route with withCount=1 (opt-in envelope).
5
+ //
6
+ // The hook returns the controlled-<EntityGrid> prop shape, so a consumer
7
+ // page is just:
8
+ // const grid = useSubscriberDefaultGrid();
9
+ // <EntityGrid {...grid} columns={subscriberDefaultColumns} grid={subscriberDefaultGrid} />
10
+
11
+ import { code, imp, joinCode, type Code } from "ts-poet";
12
+ import type { MetaObject, MetaLayout } from "@metaobjectsdev/metadata";
13
+ import { LAYOUT_SUBTYPE_DATA_GRID } from "@metaobjectsdev/metadata";
14
+ import type { RenderContext } from "@metaobjectsdev/codegen-ts";
15
+ import { GENERATED_HEADER, entityModuleSpecifier, siblingSpecifier } from "@metaobjectsdev/codegen-ts";
16
+
17
+ interface GridSpec {
18
+ name: string; // e.g. "default", "activeOnly"
19
+ pageSize: number;
20
+ hasFilterPreset: boolean;
21
+ defaultSortField?: string | undefined;
22
+ defaultSortOrder?: "asc" | "desc" | undefined;
23
+ }
24
+
25
+ function extractGrids(entity: MetaObject): GridSpec[] {
26
+ const out: GridSpec[] = [];
27
+ for (const l of entity.layouts() as MetaLayout[]) {
28
+ if (l.subType !== LAYOUT_SUBTYPE_DATA_GRID) continue;
29
+ out.push({
30
+ name: l.name || "default",
31
+ pageSize: l.pageSize ?? 25,
32
+ hasFilterPreset: l.filter !== undefined,
33
+ defaultSortField: l.defaultSortField,
34
+ defaultSortOrder: l.defaultSortOrder as "asc" | "desc" | undefined,
35
+ });
36
+ }
37
+ return out;
38
+ }
39
+
40
+ function capitalize(s: string): string {
41
+ return s.charAt(0).toUpperCase() + s.slice(1);
42
+ }
43
+
44
+ export function renderGridHookFile(entity: MetaObject, ctx: RenderContext): string {
45
+ const entityName = entity.name;
46
+ const lcEntity = entityName.charAt(0).toLowerCase() + entityName.slice(1);
47
+ const grids = extractGrids(entity);
48
+
49
+ if (grids.length === 0) return "";
50
+
51
+ // Import the entity's own file. Same target → relative "./Entity"; cross
52
+ // target → importBase-qualified package path.
53
+ const entityModule = entityModuleSpecifier(
54
+ ctx.selfTarget,
55
+ ctx.entityModuleTarget,
56
+ entity.package,
57
+ entityName,
58
+ ctx.extStyle,
59
+ );
60
+
61
+ // ts-poet symbols (imp()) — typed and deduped on emit.
62
+ const useStateSym = imp("useState@react");
63
+ const useMemoSym = imp("useMemo@react");
64
+ const useQuerySym = imp("useQuery@@tanstack/react-query");
65
+ const SortingStateSym = imp("t:SortingState@@tanstack/react-table");
66
+ const PaginationStateSym = imp("t:PaginationState@@tanstack/react-table");
67
+ const ColumnFiltersStateSym = imp("t:ColumnFiltersState@@tanstack/react-table");
68
+ const useEntityFetcherSym = imp("useEntityFetcher@@metaobjectsdev/tanstack");
69
+ const buildFilterQsSym = imp("buildFilterQs@@metaobjectsdev/runtime-web");
70
+
71
+ const entityImports: Code = code`
72
+ import { ${entityName} } from ${JSON.stringify(entityModule)};
73
+ import type { ${entityName} as ${entityName}Row } from ${JSON.stringify(entityModule)};
74
+ `;
75
+
76
+ // Collect names of all filter preset consts needed.
77
+ const filterPresetImports: string[] = grids
78
+ .filter((g) => g.hasFilterPreset)
79
+ .map((g) => `${lcEntity}${capitalize(g.name)}Filter`);
80
+
81
+ // Columns file is a same-target sibling of the grid-hook (both emitted to
82
+ // selfTarget) — always relative, package-layout aware.
83
+ const columnsModule = siblingSpecifier(ctx.selfTarget, entity.package, `${entityName}.columns`, ctx.extStyle);
84
+ const filterPresetImportCode: Code =
85
+ filterPresetImports.length > 0
86
+ ? code`import { ${filterPresetImports.join(", ")} } from ${JSON.stringify(columnsModule)};\n`
87
+ : code``;
88
+
89
+ const sections: Code[] = grids.map((grid) => {
90
+ const cap = capitalize(grid.name);
91
+ const hookName = `use${entityName}${cap}Grid`;
92
+ const presetConst = grid.hasFilterPreset ? `${lcEntity}${cap}Filter` : null;
93
+
94
+ const initialSorting = grid.defaultSortField
95
+ ? `[{ id: ${JSON.stringify(grid.defaultSortField)}, desc: ${JSON.stringify(grid.defaultSortOrder === "desc")} }]`
96
+ : `[]`;
97
+ const initialPagination = `{ pageIndex: 0, pageSize: ${grid.pageSize} }`;
98
+
99
+ return code`
100
+ export function ${hookName}() {
101
+ const [sorting, setSorting] = ${useStateSym}<${SortingStateSym}>(${initialSorting});
102
+ const [pagination, setPagination] = ${useStateSym}<${PaginationStateSym}>(${initialPagination});
103
+ const [columnFilters, setColumnFilters] = ${useStateSym}<${ColumnFiltersStateSym}>([]);
104
+ const [search, setSearch] = ${useStateSym}<string>("");
105
+
106
+ const fetcher = ${useEntityFetcherSym}();
107
+
108
+ const qs = ${useMemoSym}(() => {
109
+ const filterObj: Record<string, unknown> = ${presetConst ? `{ ...${presetConst} }` : `{}`};
110
+ for (const f of columnFilters) {
111
+ filterObj[f.id] = f.value as unknown;
112
+ }
113
+ const sort = sorting.length > 0
114
+ ? \`\${sorting[0].id}:\${sorting[0].desc ? "desc" : "asc"}\`
115
+ : undefined;
116
+ return ${buildFilterQsSym}({
117
+ ...filterObj,
118
+ ...(sort !== undefined ? { sort } : {}),
119
+ limit: pagination.pageSize,
120
+ offset: pagination.pageIndex * pagination.pageSize,
121
+ ...(search !== "" ? { search } : {}),
122
+ withCount: 1,
123
+ });
124
+ }, [sorting, pagination, columnFilters, search]);
125
+
126
+ const query = ${useQuerySym}<{ rows: ${entityName}Row[]; total: number }>({
127
+ queryKey: [${JSON.stringify(lcEntity)}, "grid", ${JSON.stringify(grid.name)}, qs],
128
+ queryFn: () => fetcher<{ rows: ${entityName}Row[]; total: number }>(
129
+ \`\${${entityName}.$apiPrefix}\${${entityName}.$path}?\${qs}\`,
130
+ ),
131
+ });
132
+
133
+ return {
134
+ data: query.data?.rows ?? [],
135
+ rowCount: query.data?.total ?? 0,
136
+ state: { sorting, pagination, columnFilters },
137
+ onSortingChange: setSorting,
138
+ onPaginationChange: setPagination,
139
+ onColumnFiltersChange: setColumnFilters,
140
+ search,
141
+ onSearchChange: setSearch,
142
+ isLoading: query.isLoading,
143
+ error: query.error,
144
+ };
145
+ }
146
+ `;
147
+ });
148
+
149
+ const header =
150
+ `// ${GENERATED_HEADER}-tanstack — DO NOT EDIT.\n` +
151
+ `// Source metadata: ${entityName} (${entity.fqn()})\n`;
152
+
153
+ const body = joinCode(sections, { on: "\n" });
154
+ return header + entityImports.toString() + filterPresetImportCode.toString() + body.toString();
155
+ }
@@ -0,0 +1,235 @@
1
+ import { code, imp, joinCode, type Code } from "ts-poet";
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";
5
+
6
+ /**
7
+ * Render <Entity>.hooks.ts — query-key factory + 2 query hooks + (for non-projections) 3 mutation hooks.
8
+ *
9
+ * Projections (view-backed, read-only) emit only:
10
+ * - <camel>Keys query-key factory
11
+ * - use<Entity>(id) — useQuery on GET :id
12
+ * - use<Entities>(filter) — useQuery on list
13
+ *
14
+ * Full (writable) entities additionally emit:
15
+ * - useCreate<Entity>
16
+ * - useUpdate<Entity>
17
+ * - useDelete<Entity>
18
+ *
19
+ * All hooks call useEntityFetcher() (from @metaobjectsdev/tanstack) for
20
+ * the underlying HTTP. Mutations aggressively invalidate <entity>Keys.all().
21
+ */
22
+ export function renderHooksFile(entity: MetaObject, ctx: RenderContext): string {
23
+ // Import the entity's own file. Same target → relative "./Entity"; cross
24
+ // target → importBase-qualified package path.
25
+ const entityModule = entityModuleSpecifier(
26
+ ctx.selfTarget,
27
+ ctx.entityModuleTarget,
28
+ entity.package,
29
+ entity.name,
30
+ ctx.extStyle,
31
+ );
32
+ if (isProjection(entity)) {
33
+ return renderReadOnlyHooksFile(entity, entityModule);
34
+ }
35
+ return renderFullHooksFile(entity, entityModule);
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Read-only path (projections)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function renderReadOnlyHooksFile(entity: MetaObject, entityModule: string): string {
43
+ const entityName = entity.name;
44
+ const entityNamePlural = pluralize(entityName);
45
+ const lcEntity = entityName.charAt(0).toLowerCase() + entityName.slice(1);
46
+ const keysVar = `${lcEntity}Keys`;
47
+
48
+ const useQuerySym = imp("useQuery@@tanstack/react-query");
49
+ const useQueryOptionsSym = imp("t:UseQueryOptions@@tanstack/react-query");
50
+ const useQueryResultSym = imp("t:UseQueryResult@@tanstack/react-query");
51
+ const useEntityFetcherSym = imp("useEntityFetcher@@metaobjectsdev/tanstack");
52
+ const buildFilterQsSym = imp("buildFilterQs@@metaobjectsdev/runtime-web");
53
+
54
+ const entityImports: Code = code`
55
+ import {
56
+ ${entityName},
57
+ type ${entityName} as ${entityName}Row,
58
+ type ${entityName}Filter,
59
+ } from ${JSON.stringify(entityModule)};
60
+ `;
61
+
62
+ const queryKeys: Code = code`
63
+ export const ${keysVar} = {
64
+ all: () => [${JSON.stringify(lcEntity)}] as const,
65
+ lists: () => [...${keysVar}.all(), "list"] as const,
66
+ list: (filter?: ${entityName}Filter) => [...${keysVar}.lists(), filter ?? {}] as const,
67
+ details: () => [...${keysVar}.all(), "detail"] as const,
68
+ detail: (id: number) => [...${keysVar}.details(), id] as const,
69
+ };
70
+ `;
71
+
72
+ const queries: Code = code`
73
+ export function use${entityName}(
74
+ id: number,
75
+ opts?: Omit<${useQueryOptionsSym}<${entityName}Row>, "queryKey" | "queryFn">,
76
+ ): ${useQueryResultSym}<${entityName}Row> {
77
+ const fetcher = ${useEntityFetcherSym}();
78
+ return ${useQuerySym}<${entityName}Row>({
79
+ queryKey: ${keysVar}.detail(id),
80
+ queryFn: () => fetcher<${entityName}Row>(\`\${${entityName}.$apiPrefix}\${${entityName}.$path}/\${id}\`),
81
+ ...opts,
82
+ });
83
+ }
84
+
85
+ export function use${entityNamePlural}(
86
+ filter?: ${entityName}Filter,
87
+ opts?: Omit<${useQueryOptionsSym}<${entityName}Row[]>, "queryKey" | "queryFn">,
88
+ ): ${useQueryResultSym}<${entityName}Row[]> {
89
+ const fetcher = ${useEntityFetcherSym}();
90
+ const qs = filter ? "?" + ${buildFilterQsSym}(filter as Record<string, unknown>) : "";
91
+ return ${useQuerySym}<${entityName}Row[]>({
92
+ queryKey: ${keysVar}.list(filter),
93
+ queryFn: () => fetcher<${entityName}Row[]>(\`\${${entityName}.$apiPrefix}\${${entityName}.$path}\${qs}\`),
94
+ ...opts,
95
+ });
96
+ }
97
+ `;
98
+
99
+ const body: Code = joinCode([queryKeys, queries], { on: "\n" });
100
+
101
+ const header =
102
+ `// ${GENERATED_HEADER}-tanstack — DO NOT EDIT.\n` +
103
+ `// Source metadata: ${entityName} (${entity.fqn()})\n`;
104
+ return header + entityImports.toString() + body.toString();
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Full path (writable entities — table-backed or write-through)
109
+ // ---------------------------------------------------------------------------
110
+
111
+ function renderFullHooksFile(entity: MetaObject, entityModule: string): string {
112
+ const entityName = entity.name;
113
+ const entityNamePlural = pluralize(entityName);
114
+ const lcEntity = entityName.charAt(0).toLowerCase() + entityName.slice(1);
115
+ const keysVar = `${lcEntity}Keys`;
116
+
117
+ const useMutationSym = imp("useMutation@@tanstack/react-query");
118
+ const useQuerySym = imp("useQuery@@tanstack/react-query");
119
+ const useQueryClientSym = imp("useQueryClient@@tanstack/react-query");
120
+ const useQueryOptionsSym = imp("t:UseQueryOptions@@tanstack/react-query");
121
+ const useMutationOptionsSym = imp("t:UseMutationOptions@@tanstack/react-query");
122
+ const useQueryResultSym = imp("t:UseQueryResult@@tanstack/react-query");
123
+ const useMutationResultSym = imp("t:UseMutationResult@@tanstack/react-query");
124
+ const useEntityFetcherSym = imp("useEntityFetcher@@metaobjectsdev/tanstack");
125
+ const buildFilterQsSym = imp("buildFilterQs@@metaobjectsdev/runtime-web");
126
+
127
+ const entityImports: Code = code`
128
+ import {
129
+ ${entityName},
130
+ type ${entityName} as ${entityName}Row,
131
+ type ${entityName}Insert,
132
+ type ${entityName}Update,
133
+ type ${entityName}Filter,
134
+ } from ${JSON.stringify(entityModule)};
135
+ `;
136
+
137
+ const queryKeys: Code = code`
138
+ export const ${keysVar} = {
139
+ all: () => [${JSON.stringify(lcEntity)}] as const,
140
+ lists: () => [...${keysVar}.all(), "list"] as const,
141
+ list: (filter?: ${entityName}Filter) => [...${keysVar}.lists(), filter ?? {}] as const,
142
+ details: () => [...${keysVar}.all(), "detail"] as const,
143
+ detail: (id: number) => [...${keysVar}.details(), id] as const,
144
+ };
145
+ `;
146
+
147
+ const queries: Code = code`
148
+ export function use${entityName}(
149
+ id: number,
150
+ opts?: Omit<${useQueryOptionsSym}<${entityName}Row>, "queryKey" | "queryFn">,
151
+ ): ${useQueryResultSym}<${entityName}Row> {
152
+ const fetcher = ${useEntityFetcherSym}();
153
+ return ${useQuerySym}<${entityName}Row>({
154
+ queryKey: ${keysVar}.detail(id),
155
+ queryFn: () => fetcher<${entityName}Row>(\`\${${entityName}.$apiPrefix}\${${entityName}.$path}/\${id}\`),
156
+ ...opts,
157
+ });
158
+ }
159
+
160
+ export function use${entityNamePlural}(
161
+ filter?: ${entityName}Filter,
162
+ opts?: Omit<${useQueryOptionsSym}<${entityName}Row[]>, "queryKey" | "queryFn">,
163
+ ): ${useQueryResultSym}<${entityName}Row[]> {
164
+ const fetcher = ${useEntityFetcherSym}();
165
+ const qs = filter ? "?" + ${buildFilterQsSym}(filter as Record<string, unknown>) : "";
166
+ return ${useQuerySym}<${entityName}Row[]>({
167
+ queryKey: ${keysVar}.list(filter),
168
+ queryFn: () => fetcher<${entityName}Row[]>(\`\${${entityName}.$apiPrefix}\${${entityName}.$path}\${qs}\`),
169
+ ...opts,
170
+ });
171
+ }
172
+ `;
173
+
174
+ const mutations: Code = code`
175
+ export function useCreate${entityName}(
176
+ opts?: Omit<${useMutationOptionsSym}<${entityName}Row, Error, ${entityName}Insert>, "mutationFn">,
177
+ ): ${useMutationResultSym}<${entityName}Row, Error, ${entityName}Insert> {
178
+ const fetcher = ${useEntityFetcherSym}();
179
+ const qc = ${useQueryClientSym}();
180
+ return ${useMutationSym}<${entityName}Row, Error, ${entityName}Insert>({
181
+ mutationFn: (input) => fetcher<${entityName}Row>(\`\${${entityName}.$apiPrefix}\${${entityName}.$path}\`, {
182
+ method: "POST",
183
+ headers: { "Content-Type": "application/json" },
184
+ body: JSON.stringify(input),
185
+ }),
186
+ ...opts,
187
+ onSuccess: (...args) => {
188
+ qc.invalidateQueries({ queryKey: ${keysVar}.all() });
189
+ opts?.onSuccess?.(...args);
190
+ },
191
+ });
192
+ }
193
+
194
+ export function useUpdate${entityName}(
195
+ opts?: Omit<${useMutationOptionsSym}<${entityName}Row, Error, { id: number; input: ${entityName}Update }>, "mutationFn">,
196
+ ): ${useMutationResultSym}<${entityName}Row, Error, { id: number; input: ${entityName}Update }> {
197
+ const fetcher = ${useEntityFetcherSym}();
198
+ const qc = ${useQueryClientSym}();
199
+ return ${useMutationSym}({
200
+ mutationFn: ({ id, input }) => fetcher<${entityName}Row>(\`\${${entityName}.$apiPrefix}\${${entityName}.$path}/\${id}\`, {
201
+ method: "PATCH",
202
+ headers: { "Content-Type": "application/json" },
203
+ body: JSON.stringify(input),
204
+ }),
205
+ ...opts,
206
+ onSuccess: (...args) => {
207
+ qc.invalidateQueries({ queryKey: ${keysVar}.all() });
208
+ opts?.onSuccess?.(...args);
209
+ },
210
+ });
211
+ }
212
+
213
+ export function useDelete${entityName}(
214
+ opts?: Omit<${useMutationOptionsSym}<void, Error, number>, "mutationFn">,
215
+ ): ${useMutationResultSym}<void, Error, number> {
216
+ const fetcher = ${useEntityFetcherSym}();
217
+ const qc = ${useQueryClientSym}();
218
+ return ${useMutationSym}({
219
+ mutationFn: (id) => fetcher<void>(\`\${${entityName}.$apiPrefix}\${${entityName}.$path}/\${id}\`, { method: "DELETE" }),
220
+ ...opts,
221
+ onSuccess: (...args) => {
222
+ qc.invalidateQueries({ queryKey: ${keysVar}.all() });
223
+ opts?.onSuccess?.(...args);
224
+ },
225
+ });
226
+ }
227
+ `;
228
+
229
+ const body: Code = joinCode([queryKeys, queries, mutations], { on: "\n" });
230
+
231
+ const header =
232
+ `// ${GENERATED_HEADER}-tanstack — DO NOT EDIT.\n` +
233
+ `// Source metadata: ${entityName} (${entity.fqn()})\n`;
234
+ return header + entityImports.toString() + body.toString();
235
+ }