Skip to content

Commit 8b0ff07

Browse files
dmealingclaude
andcommitted
feat(codegen-ts): allow standalone read-only view-entities (no extends)
renderProjectionDecl only needed the view name from extractViewSpec, yet extractViewSpec requires a base entity to extend (for join/DDL resolution). That coupling forced every projection to extend a writable entity and inherit ALL its fields — blocking views over non-entity-backed tables and views that deliberately expose a narrowed/safe column set. Decouple: derive the view name directly via the new projectionViewName() (reads the read-only source @table), so the read model generates from the projection's own fields. The join-backed view-DDL path (extractViewSpec) still requires extends — standalone views hand-author (or separately generate) their SQL. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b501836 commit 8b0ff07

3 files changed

Lines changed: 85 additions & 3 deletions

File tree

server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,23 @@ function viewName(projection: MetaObject, ctx: ExtractContext): string {
5454
return explicit ?? viewNameFromProjection(projection.name, ctx.columnNamingStrategy);
5555
}
5656

57+
/**
58+
* The physical view name for a projection — its read-only source `@table`, else
59+
* derived from the projection name.
60+
*
61+
* Exposed for the read-model generator (`renderProjectionDecl`), which needs the
62+
* view name but NOT the join/DDL resolution. Read-model generation works for a
63+
* standalone read-only view-entity (explicit columns, no `extends`); only
64+
* {@link extractViewSpec} — which generates the join-backed view DDL — requires a
65+
* base entity to extend.
66+
*/
67+
export function projectionViewName(
68+
projection: MetaObject,
69+
columnNamingStrategy: ColumnNamingStrategy,
70+
): string {
71+
return viewName(projection, { columnNamingStrategy });
72+
}
73+
5774
function baseEntityFor(
5875
projection: MetaObject,
5976
root: MetaRoot,

server/typescript/packages/codegen-ts/src/templates/projection-decl.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import { code, imp, joinCode, type Code } from "ts-poet";
1111
import { MetaField, MetaObject, type MetaRoot } from "@metaobjectsdev/metadata";
12-
import { extractViewSpec } from "../projection/extract-view-spec.js";
12+
import { projectionViewName } from "../projection/extract-view-spec.js";
1313
import { columnNameFromField, toSnakeCase, pluralize } from "../naming.js";
1414
import { GENERATED_HEADER } from "../constants.js";
1515
import type { ColumnNamingStrategy } from "../metaobjects-config.js";
@@ -72,7 +72,12 @@ export function renderProjectionDecl(
7272
const viewSym = imp(`${viewFn}@${viewModule}`);
7373
const z = imp("z@zod");
7474

75-
const spec = extractViewSpec(projection, root, { columnNamingStrategy });
75+
// Read-model generation needs only the view name — NOT the join/DDL
76+
// resolution. Deriving it directly (instead of via extractViewSpec) lets a
77+
// standalone read-only view-entity — explicit columns, no `extends` — generate
78+
// its read model. The join-backed view DDL (extractViewSpec) still requires a
79+
// base; standalone views hand-author (or separately generate) their SQL.
80+
const viewName = projectionViewName(projection, columnNamingStrategy);
7681

7782
// Collect fields: inherited from extends parent first, then projection-declared.
7883
const allFields: MetaField[] = [];
@@ -107,7 +112,6 @@ export function renderProjectionDecl(
107112
const projName = projection.name;
108113
const camelName = projName.charAt(0).toLowerCase() + projName.slice(1);
109114
const path = pathFromProjectionName(projName);
110-
const viewName = spec.viewName;
111115

112116
const sections: Code[] = [
113117
code`

server/typescript/packages/codegen-ts/test/templates/projection-decl.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,3 +242,64 @@ describe("renderProjectionDecl emits Drizzle view + Zod read schema + constants"
242242
expect(code).toContain("ProgramSummaryFilter");
243243
});
244244
});
245+
246+
// ---------------------------------------------------------------------------
247+
// Standalone read-only view-entity (NO `extends`).
248+
// The read model only needs the view name + the entity's own fields — not the
249+
// join/DDL resolution. A view whose base is not a writable metaobjects entity
250+
// (e.g. one over legacy tables), or one that maps a deliberately narrowed set of
251+
// columns (excluding a sensitive field), is authored as a standalone projection:
252+
// explicit columns + source.rdb kind=view, no extends. Its SQL is hand-authored.
253+
// ---------------------------------------------------------------------------
254+
async function loadStandaloneProjection() {
255+
const json = JSON.stringify({
256+
"metadata.root": {
257+
package: "test",
258+
children: [
259+
{
260+
"object.entity": {
261+
name: "LobbyRoster",
262+
children: [
263+
{ "source.rdb": { "@kind": "view", "@table": "v_lobby_roster" } },
264+
{ "field.string": { name: "sessionId" } },
265+
{ "field.string": { name: "displayName" } },
266+
{ "field.string": { name: "status" } },
267+
{ "identity.primary": { "@fields": "sessionId" } },
268+
{ "layout.dataGrid": { name: "default", "@fields": "displayName,status" } },
269+
],
270+
},
271+
},
272+
],
273+
},
274+
});
275+
const result = await new MetaDataLoader().load([new InMemoryStringSource(json)]);
276+
if (result.errors.length > 0) throw new Error(result.errors.map((e) => e.message).join("\n"));
277+
const root = result.root;
278+
const projection = root.objects().find((o) => o.name === "LobbyRoster")!;
279+
return { root, projection };
280+
}
281+
282+
describe("renderProjectionDecl — standalone view-entity (no extends)", () => {
283+
test("generates the read model from own fields without throwing", async () => {
284+
const { root, projection } = await loadStandaloneProjection();
285+
const code = renderProjectionDecl(projection, root, {
286+
columnNamingStrategy: "snake_case",
287+
dialect: "postgres",
288+
});
289+
290+
// View name comes from the read-only source @table (no join resolution).
291+
expect(code).toContain("pgView");
292+
expect(code).toContain('"v_lobby_roster"');
293+
expect(code).toContain(".existing()");
294+
// Own fields populate the schema + constants.
295+
expect(code).toContain("export const LobbyRosterSchema");
296+
expect(code).toContain("sessionId:");
297+
expect(code).toContain("displayName:");
298+
expect(code).toContain("status:");
299+
expect(code).toContain('$entity: "LobbyRoster"');
300+
expect(code).toContain('"/lobby-rosters"');
301+
// Still read-only — no write artifacts.
302+
expect(code).not.toContain("LobbyRosterInsert");
303+
expect(code).not.toContain("LobbyRosterUpdate");
304+
});
305+
});

0 commit comments

Comments
 (0)