Skip to content

Commit f487278

Browse files
dmealingclaude
andcommitted
fix(#213): emit the entity read-view's replica view (isWriteThrough → one emitter)
#213 hole 2 (the missing-view half): a write-through entity (FR-024 §7 — a writable table source PLUS a non-primary read-only view source + derived origin.* fields) never got its replica view emitted. `isWriteThrough` was defined, exported, and tested but had ZERO call sites; buildProjectionViews walked isProjection only, so migrate never generated or owned the view — reads had nowhere to resolve the derived columns from. Now the entity is a second host for the ONE canonical view emitter ("one emitter, two hosts"): - extract-view-spec: a write-through entity IS its own base (isWriteThrough → base = the entity), and buildSelectSpec exposes its EFFECTIVE field set (o.* incl. extends-inherited fields) — stored fields project from the base alias, derived (origin.*) fields from the joins. A plain projection keeps its declared-own-set exposure + extends-anchored base, unchanged. - build-projection-views: a second walk over isWriteThrough entities feeds them through a shared emitViewFor() tail (the projection loop now uses it too — no duplication). Only a plain `view` read source synthesizes DDL (matview/proc/tableFunction stay hand-managed, same as projections). isProjection and isWriteThrough are mutually exclusive, so the walks never overlap. Together with the write-side exclusions (6a58732), a `SELECT o.*, extra` legacy view now models cleanly as an entity read-view: writes → the table (no derived columns), reads → the emitted view. The #206 docs on-ramp becomes drivable. Verified: new view-emission test (view produced; SELECT = o.* stored fields from the base + the derived join column; derived col never projects from o.*); codegen-ts 962 pass; typecheck exit 0; canonical schema.postgres.sql drift gate byte-identical (its view is a projection, not write-through — no overlap). Follow-up verification (before any release): a migrate round-trip PG/SQLite gate for a write-through entity (emit → apply → introspect → re-diff EMPTY) + a persistence-conformance write-through fixture — the emitter is already round-trip-gated for projections, but the entity host wants its own gate. Known limitation: a flattened field.object on a write-through entity is not yet handled in the entity-host SELECT (scalars + derived joins are). The cross-port §7 codegen-half (other ports' ORM/DTO derived-field exclusions + read routing + the entity read TYPE) remains a separate coordinated issue. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent 6a58732 commit f487278

3 files changed

Lines changed: 130 additions & 23 deletions

File tree

server/typescript/packages/codegen-ts/src/projection/build-projection-views.ts

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
resolveTableName,
2222
resolveTableSchema,
2323
} from "@metaobjectsdev/metadata";
24-
import { isProjection } from "./projection-detector.js";
24+
import { isProjection, isWriteThrough } from "./projection-detector.js";
2525
import { extractViewSpec } from "./extract-view-spec.js";
2626
import { emitViewDdl } from "./view-ddl-emit.js";
2727
import type { JoinNode, ViewSpec } from "./view-spec.js";
@@ -119,24 +119,54 @@ export function buildProjectionViews(
119119
);
120120
if (readOnlySource?.effectiveKind !== SOURCE_KIND_VIEW) continue;
121121
if (!viewIsDerived(projection)) continue;
122-
const spec = extractViewSpec(projection, root, { columnNamingStrategy });
123-
const baseTableName = joinTables[spec.joinTree.baseEntity];
124-
if (!baseTableName) continue; // unresolved base — skip (loader/codegen surface the error elsewhere)
125-
const body = emitViewDdl(spec, { dialect, baseTableName, joinTables, bodyOnly: true });
126-
const schema = resolveTableSchema(projection);
127-
const dependsOn = collectDependsOn(spec, baseTableName, joinTables);
128-
const columns = collectViewColumns(spec, baseTableName, joinTables);
129-
out.push({
130-
name: spec.viewName,
131-
sql: body,
132-
dependsOn,
133-
columns,
134-
...(schema !== undefined ? { schema } : {}),
135-
});
122+
emitViewFor(projection, root, joinTables, dialect, columnNamingStrategy, out);
123+
}
124+
125+
// #213 hole 2 — a write-through ENTITY (FR-024 §7 read-view: a writable table
126+
// source PLUS a non-primary read-only view source, with derived origin.* fields)
127+
// hosts its OWN view. Emit it through the SAME canonical emitter — "one emitter,
128+
// two hosts". `isWriteThrough` had zero call sites, so this replica view was never
129+
// generated or owned by migrate. Unlike a projection there is no extends anchor
130+
// and no viewIsDerived gate: the entity IS the base (extractViewSpec detects the
131+
// write-through host), and it always has a derived view to emit. Only a plain
132+
// `view` read source synthesizes CREATE VIEW DDL — a matview/proc/tableFunction
133+
// read source is hand-managed, exactly as for projections above.
134+
for (const entity of root.objects().filter(isWriteThrough)) {
135+
const readOnlySource = entity.ownChildren().find(
136+
(c): c is MetaSource => c instanceof MetaSource && c.isReadOnly(),
137+
);
138+
if (readOnlySource?.effectiveKind !== SOURCE_KIND_VIEW) continue;
139+
emitViewFor(entity, root, joinTables, dialect, columnNamingStrategy, out);
136140
}
137141
return out;
138142
}
139143

144+
/** Extract + emit one host's (projection or write-through entity) view body and
145+
* push it onto `out`. The single tail shared by both walks in buildProjectionViews. */
146+
function emitViewFor(
147+
host: MetaObject,
148+
root: MetaRoot,
149+
joinTables: Record<string, string>,
150+
dialect: "postgres" | "sqlite",
151+
columnNamingStrategy: ColumnNamingStrategy,
152+
out: ExpectedView[],
153+
): void {
154+
const spec = extractViewSpec(host, root, { columnNamingStrategy });
155+
const baseTableName = joinTables[spec.joinTree.baseEntity];
156+
if (!baseTableName) return; // unresolved base — skip (loader/codegen surface the error elsewhere)
157+
const body = emitViewDdl(spec, { dialect, baseTableName, joinTables, bodyOnly: true });
158+
const schema = resolveTableSchema(host);
159+
const dependsOn = collectDependsOn(spec, baseTableName, joinTables);
160+
const columns = collectViewColumns(spec, baseTableName, joinTables);
161+
out.push({
162+
name: spec.viewName,
163+
sql: body,
164+
dependsOn,
165+
columns,
166+
...(schema !== undefined ? { schema } : {}),
167+
});
168+
}
169+
140170
/**
141171
* The SELECT list as PHYSICAL (table, column) pairs, in emitted order.
142172
*

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

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,14 @@ import {
4444
stripPackage,
4545
type AggregateFunction,
4646
} from "@metaobjectsdev/metadata";
47-
import { type MetaData, type MetaRoot, MetaObject } from "@metaobjectsdev/metadata";
47+
import { type MetaData, type MetaField, type MetaRoot, MetaObject } from "@metaobjectsdev/metadata";
4848
import {
4949
columnNameFromField,
5050
viewNameFromProjection,
5151
} from "../naming.js";
52+
// #213 — a write-through ENTITY (FR-024 §7 read-view) hosts its own view; the base
53+
// is the entity itself, not an extends-anchored projection.
54+
import { isWriteThrough } from "./projection-detector.js";
5255
// #209 — the SAME NOT-NULL predicate that drives a column's `.notNull()` decides
5356
// whether a belongs-to join is INNER (required FK) vs LEFT OUTER (nullable FK).
5457
import { isRequired } from "../column-mapper.js";
@@ -625,12 +628,17 @@ function buildSelectSpec(
625628
// is removed with the B4b cutover: base columns are declared explicitly as
626629
// extends-bound fields (`{ field.int: { name: id, extends: "Program.id" } }`).
627630

628-
// Fields explicitly declared on the projection.
629-
// ADR-0039: own — the projection's DECLARED field set IS the exposure
630-
// (FR-024/ADR-0028); iterate own fields + each field's own origin. origin.*
631-
// NEVER inherits (ADR-0029), so the origin attr reads below are own (category 4).
632-
for (const field of projection.ownChildren()) {
633-
if (field.type !== TYPE_FIELD) continue;
631+
// #213 — an entity read-view HOST (base === projection, FR-024 §7) exposes its
632+
// EFFECTIVE field set: the `o.*` includes fields inherited via extends (a
633+
// BaseEntity id/createdAt). A plain projection exposes only its DECLARED (own)
634+
// fields — the declared set IS the exposure (FR-024/ADR-0028). Either way, each
635+
// field's own origin decides passthrough-from-base vs derived-from-join. origin.*
636+
// NEVER inherits (ADR-0029), so the origin reads below are own (category 4).
637+
const declaredFields: MetaField[] =
638+
base === projection
639+
? base.fields()
640+
: projection.ownChildren().filter((c): c is MetaField => c.type === TYPE_FIELD);
641+
for (const field of declaredFields) {
634642
const origin = field.ownChildren().find((c) => c.type === TYPE_ORIGIN);
635643
const dbCol = sourceColumnNameFor(field, ctx);
636644

@@ -863,7 +871,10 @@ export function extractViewSpec(
863871
root: MetaRoot,
864872
ctx: ExtractContext,
865873
): ViewSpec {
866-
const base = baseEntityFor(projection, root);
874+
// #213 — a write-through entity (FR-024 §7) IS its own base: stored fields SELECT
875+
// from the base alias (o.*), derived (origin.*) fields from the joins. A plain
876+
// projection anchors its base via an extends binding (baseEntityFor).
877+
const base = isWriteThrough(projection) ? projection : baseEntityFor(projection, root);
867878
const usedAliases = new Set<string>();
868879
const baseAlias = shortAliasFor(base.name, usedAliases);
869880
const joinTree = buildJoinTree(projection, base, root, usedAliases, baseAlias, ctx);
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// #213 hole 2 — the FR-024 §7 entity read-view's replica-view DDL must be EMITTED.
2+
// `isWriteThrough` had zero call sites; buildProjectionViews walked isProjection only,
3+
// so a write-through entity's view was never generated/owned. Now it is: base = the
4+
// entity itself, its stored fields SELECT from the base alias (o.*), its derived
5+
// (origin.*) fields SELECT from the join — "one emitter, two hosts".
6+
7+
import { describe, test, expect } from "bun:test";
8+
import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
9+
import { buildProjectionViews } from "../../src/projection/build-projection-views.js";
10+
11+
async function loadWriteThroughOrder() {
12+
const json = JSON.stringify({
13+
"metadata.root": {
14+
package: "acme",
15+
children: [
16+
{ "object.entity": { name: "Customer", children: [
17+
{ "source.rdb": { "@table": "customers" } },
18+
{ "field.long": { name: "id" } },
19+
{ "field.string": { name: "name" } },
20+
{ "identity.primary": { name: "pk", "@fields": "id" } },
21+
] } },
22+
{ "object.entity": { name: "Order", children: [
23+
{ "source.rdb": { "@role": "primary", "@table": "orders" } },
24+
{ "source.rdb": { "@role": "replica", "@kind": "view", "@table": "v_order_with_customer" } },
25+
{ "field.long": { name: "id" } },
26+
{ "field.long": { name: "customerId", "@required": true } },
27+
{ "field.string": { name: "customerName", children: [
28+
{ "origin.passthrough": { "@from": "Customer.name", "@via": "Order.customer" } } ] } },
29+
{ "relationship.association": { name: "customer", "@objectRef": "Customer", "@cardinality": "one" } },
30+
{ "identity.primary": { name: "pk", "@fields": "id" } },
31+
{ "identity.reference": { name: "ref_customer", "@fields": "customerId", "@references": "Customer" } },
32+
] } },
33+
],
34+
},
35+
});
36+
const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(json)]);
37+
expect(errors).toEqual([]);
38+
return root;
39+
}
40+
41+
describe("#213 — write-through entity emits its replica view", () => {
42+
test("a view named after the read source is produced", async () => {
43+
const views = buildProjectionViews(await loadWriteThroughOrder(), { dialect: "postgres", columnNamingStrategy: "snake_case" });
44+
const v = views.find((x) => x.name === "v_order_with_customer");
45+
expect(v).toBeDefined();
46+
});
47+
48+
test("the SELECT is o.* (stored fields from the base) + the derived join column", async () => {
49+
const views = buildProjectionViews(await loadWriteThroughOrder(), { dialect: "postgres", columnNamingStrategy: "snake_case" });
50+
const sql = views.find((x) => x.name === "v_order_with_customer")!.sql;
51+
expect(sql).toContain("FROM orders");
52+
// stored fields project from the base alias
53+
expect(sql).toMatch(/\bid AS id\b/);
54+
expect(sql).toMatch(/customer_id AS customer_id/);
55+
// the derived extra projects from the joined customers table
56+
expect(sql).toContain("name AS customer_name");
57+
expect(sql).toContain("JOIN customers");
58+
});
59+
60+
test("the derived column does NOT project from the base (it is the joined value)", async () => {
61+
const views = buildProjectionViews(await loadWriteThroughOrder(), { dialect: "postgres", columnNamingStrategy: "snake_case" });
62+
const sql = views.find((x) => x.name === "v_order_with_customer")!.sql;
63+
// customer_name comes from the join alias, never `o.customer_name`.
64+
expect(sql).not.toMatch(/o\.customer_name/);
65+
});
66+
});

0 commit comments

Comments
 (0)