Skip to content

Commit 62e77df

Browse files
dmealingclaude
andcommitted
feat(#208): suppress derivation-classification for @sql/@Unmanaged sources
The load-bearing suppression rule (§6). In build-projection-views.ts, both the projection loop and the write-through host loop now classify DDL-ownership BEFORE viewIsDerived: - @Unmanaged read source -> skip (external; Flyway/hand-migration owns the DDL); - @SQL read source -> push a VERBATIM ExpectedView via emitSqlView (supplied); - neither -> the existing viewIsDerived/extractViewSpec derived path. This closes the silent-wrong-synthesis hole: a hand-bodied view carrying an extends-bound identity/fields (pure shape / row identity) previously flipped viewIsDerived true and got a trivial base-table passthrough SELECT synthesized in place of the author's irreducible SQL. emitSqlView rides the unchanged fingerprint/adopt-view/drop pipeline: sql=verbatim body, name=physicalName (FR-016), schema=resolveTableSchema, dependsOn=extends-bound anchor tables (D7, via exported refNamedOwner), columns OMITTED (opaque body -> diff fails safe to gated drop+create). @SQL on a non-view kind hard-errors (D4). ExpectedView.columns is now optional (migrate-ts already reads absent columns as "unknown"). TS-only (ADR-0015). codegen-ts: 990 pass, typecheck clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent e3eed8d commit 62e77df

3 files changed

Lines changed: 145 additions & 5 deletions

File tree

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

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
resolveTableSchema,
2323
} from "@metaobjectsdev/metadata";
2424
import { isProjection, isWriteThrough } from "./projection-detector.js";
25-
import { extractViewSpec } from "./extract-view-spec.js";
25+
import { extractViewSpec, refNamedOwner } from "./extract-view-spec.js";
2626
import { emitViewDdl } from "./view-ddl-emit.js";
2727
import type { JoinNode, ViewSpec } from "./view-spec.js";
2828
import type { ColumnNamingStrategy } from "../metaobjects-config.js";
@@ -64,8 +64,12 @@ export interface ExpectedView {
6464
* projection lands last, so the change stays non-destructive. (Re-canonicalizing
6565
* to, say, alphabetical order would be strictly WORSE — it would scatter an
6666
* appended field into the middle and force a destructive drop+create.)
67+
*
68+
* OMITTED for an `@sql` (#208) view: the body is opaque — the tool never parses it,
69+
* so its output columns are unknown. migrate-ts reads absent columns as "unknown"
70+
* and fails safe to a gated drop+create instead of an illegal CREATE OR REPLACE.
6771
*/
68-
columns: ExpectedViewColumn[];
72+
columns?: ExpectedViewColumn[];
6973
}
7074

7175
export interface BuildProjectionViewsOptions {
@@ -117,7 +121,16 @@ export function buildProjectionViews(
117121
const readOnlySource = projection.ownChildren().find(
118122
(c): c is MetaSource => c instanceof MetaSource && c.isReadOnly(),
119123
);
120-
if (readOnlySource?.effectiveKind !== SOURCE_KIND_VIEW) continue;
124+
if (readOnlySource === undefined) continue;
125+
// #208 suppression rule (§6) — classify DDL ownership BEFORE viewIsDerived, so an
126+
// escape-valve view carrying extends-bound identity/fields (pure shape / row
127+
// identity) is never mis-synthesized into a wrong base-table passthrough SELECT.
128+
if (readOnlySource.isUnmanaged) continue; // external: Flyway/hand-migration owns it (§7)
129+
if (readOnlySource.sqlBody !== undefined) { // supplied: the author owns the body
130+
emitSqlView(projection, readOnlySource, root, joinTables, out);
131+
continue;
132+
}
133+
if (readOnlySource.effectiveKind !== SOURCE_KIND_VIEW) continue;
121134
if (!viewIsDerived(projection)) continue;
122135
emitViewFor(projection, root, joinTables, dialect, columnNamingStrategy, out);
123136
}
@@ -135,7 +148,15 @@ export function buildProjectionViews(
135148
const readOnlySource = entity.ownChildren().find(
136149
(c): c is MetaSource => c instanceof MetaSource && c.isReadOnly(),
137150
);
138-
if (readOnlySource?.effectiveKind !== SOURCE_KIND_VIEW) continue;
151+
if (readOnlySource === undefined) continue;
152+
// #208 suppression rule (§6) — same DDL-ownership classification as the projection
153+
// loop above: an @unmanaged replica view is skipped, an @sql one is verbatim.
154+
if (readOnlySource.isUnmanaged) continue;
155+
if (readOnlySource.sqlBody !== undefined) {
156+
emitSqlView(entity, readOnlySource, root, joinTables, out);
157+
continue;
158+
}
159+
if (readOnlySource.effectiveKind !== SOURCE_KIND_VIEW) continue;
139160
emitViewFor(entity, root, joinTables, dialect, columnNamingStrategy, out);
140161
}
141162
return out;
@@ -167,6 +188,72 @@ function emitViewFor(
167188
});
168189
}
169190

191+
/**
192+
* #208 §7 — an `@sql` read source declares a hand-written view body. Push it VERBATIM
193+
* into the exact same `ExpectedView` pipeline the synthesized body rides — almost no
194+
* new machinery:
195+
* - buildExpectedSchema Pass 4 fingerprints `v.sql` (hash of the body);
196+
* - emit stamps the COMMENT marker; author re-indentation does not re-stamp;
197+
* - `columns` is OMITTED (the body is opaque — the tool never parses it), so the diff
198+
* fails safe to a gated drop+create instead of a wrong-but-confident OR REPLACE;
199+
* - a pre-existing unstamped view at this name → `replace-view` blocked pending
200+
* `migrate --allow adopt-view` (the one-time adoption ceremony).
201+
* `dependsOn` is derived from the host's extends-bound anchor entities (D7) — no
202+
* `@dependsOn` attr.
203+
*/
204+
function emitSqlView(
205+
host: MetaObject,
206+
source: MetaSource,
207+
root: MetaRoot,
208+
joinTables: Record<string, string>,
209+
out: ExpectedView[],
210+
): void {
211+
// D4 — v1 migrate lowering accepts @sql only on a plain @kind: view. matview / proc /
212+
// tableFunction need genuinely new introspection (pg_matviews, pg_get_functiondef,
213+
// COMMENT-on-matview, a REFRESH story) and stay hand-managed for now — an actionable
214+
// hard error, same tiering as SQLite's @schema rejection.
215+
if (source.effectiveKind !== SOURCE_KIND_VIEW) {
216+
throw new Error(
217+
`@sql is not yet migrate-managed on @kind: "${source.effectiveKind}" ` +
218+
`(source of "${host.name}"). Mark the source @unmanaged, or track the ` +
219+
`matview/callable managed path as a follow-up (#208 D4).`,
220+
);
221+
}
222+
const schema = resolveTableSchema(host);
223+
const dependsOn = collectSqlDependsOn(host, root, joinTables);
224+
out.push({
225+
name: source.physicalName, // FR-016 four-step physical name
226+
sql: source.sqlBody!, // verbatim — never parsed, never re-wrapped
227+
dependsOn,
228+
// columns OMITTED → "unknown" → gated drop+create fail-safe.
229+
...(schema !== undefined ? { schema } : {}),
230+
});
231+
}
232+
233+
/**
234+
* D7 — the physical tables an `@sql` view depends on, derived from the host's
235+
* extends-bound anchor entities (its OWN identity/field `superRef`s). The `extends`
236+
* bindings that already anchor the read model's shape ARE the dependency declaration;
237+
* no separate `@dependsOn` attr. Deduped, so a view anchored on one base yields one
238+
* table. migrate-ts uses this to drop+recreate the view around a column-altering change
239+
* on a source table (Postgres blocks ALTER on a column a view depends on).
240+
*/
241+
function collectSqlDependsOn(
242+
host: MetaObject,
243+
root: MetaRoot,
244+
joinTables: Readonly<Record<string, string>>,
245+
): string[] {
246+
const tables = new Set<string>();
247+
for (const child of host.ownChildren()) {
248+
if (child.type !== TYPE_IDENTITY && child.type !== TYPE_FIELD) continue;
249+
const owner = refNamedOwner(child, root);
250+
if (owner === undefined) continue;
251+
const t = joinTables[owner.name];
252+
if (t !== undefined) tables.add(t);
253+
}
254+
return [...tables];
255+
}
256+
170257
/**
171258
* The SELECT list as PHYSICAL (table, column) pairs, in emitted order.
172259
*

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ export function projectionViewName(
287287
* loader's `_refNamedOwner`: the ref names the anchor, never the physical
288288
* declaring ancestor of an inherited child.
289289
*/
290-
function refNamedOwner(node: MetaData, root: MetaRoot): MetaObject | undefined {
290+
export function refNamedOwner(node: MetaData, root: MetaRoot): MetaObject | undefined {
291291
const ref = (node as { superRef?: string }).superRef;
292292
if (ref === undefined) return undefined;
293293
const lastSep = ref.lastIndexOf("::");

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,56 @@ describe("buildProjectionViews — package-qualified relationship @objectRef", (
200200
expect(v!.sql).toMatch(/COUNT\(DISTINCT [a-z0-9]+\.id\) AS week_count/);
201201
});
202202
});
203+
204+
describe("buildProjectionViews — #208 @sql / @unmanaged DDL-ownership escape valves", () => {
205+
// A recursive read-model that origin.* cannot express: the projection carries an
206+
// extends-bound identity (row identity for the read model) + an extends-bound field
207+
// (pure shape), which historically flips viewIsDerived() true and makes the tool
208+
// synthesize a trivial base-table passthrough SELECT — the silent-wrong-synthesis
209+
// hole (§1.2). The suppression rule (§6) classifies DDL-ownership BEFORE viewIsDerived.
210+
const RECURSIVE_BODY =
211+
"WITH RECURSIVE t AS (SELECT id, parent_id FROM nodes WHERE parent_id IS NULL " +
212+
"UNION ALL SELECT n.id, n.parent_id FROM nodes n JOIN t ON n.parent_id = t.id) SELECT * FROM t";
213+
214+
const model = (source: Record<string, unknown>) => [
215+
{
216+
"object.entity": {
217+
name: "Node",
218+
children: [
219+
{ "source.rdb": { "@table": "nodes" } },
220+
{ "field.int": { name: "id" } },
221+
{ "field.int": { name: "parentId", "@column": "parent_id" } },
222+
{ "identity.primary": { name: "pk", "@fields": "id" } },
223+
],
224+
},
225+
},
226+
{
227+
"object.projection": {
228+
name: "NodeTree",
229+
children: [
230+
{ "source.rdb": source },
231+
{ "field.int": { name: "id", extends: "Node.id" } },
232+
{ "identity.primary": { extends: "Node.pk" } },
233+
],
234+
},
235+
},
236+
];
237+
238+
test("an @sql projection with extends-bound identity gets its verbatim body, not a synthesized SELECT", async () => {
239+
const root = await load(model({ "@kind": "view", "@view": "v_node_tree", "@sql": RECURSIVE_BODY }));
240+
const views = buildProjectionViews(root, { dialect: "postgres", columnNamingStrategy: "snake_case" });
241+
const v = views.find((vv) => vv.name === "v_node_tree");
242+
expect(v).toBeDefined();
243+
expect(v!.sql).toBe(RECURSIVE_BODY); // verbatim body, NOT a synthesized "SELECT nodes.id AS id …"
244+
expect(v!.sql).toContain("WITH RECURSIVE");
245+
expect(v!.columns).toBeUndefined(); // opaque — column list unknown → diff fails safe to drop+create
246+
// dependsOn is derived from the extends-bound anchor entity (Node → nodes), D7.
247+
expect(v!.dependsOn).toEqual(["nodes"]);
248+
});
249+
250+
test("an @unmanaged projection produces no ExpectedView (skipped entirely)", async () => {
251+
const root = await load(model({ "@kind": "view", "@view": "v_node_tree", "@unmanaged": true }));
252+
const views = buildProjectionViews(root, { dialect: "postgres", columnNamingStrategy: "snake_case" });
253+
expect(views.find((vv) => vv.name === "v_node_tree")).toBeUndefined();
254+
});
255+
});

0 commit comments

Comments
 (0)