Skip to content

Commit 4af1d18

Browse files
dmealingclaude
andcommitted
fix(codegen): projection view resolves @from/@via/@references package-aware (#244)
The projection view-spec resolved origin @from/@via/@Of and extends anchors by BARE entity name (findObject(stripPackage(...))), so a fully-qualified reference to an entity in package A silently bound a same-bare-named entity in package B — load-order-dependent, producing an invalid view that JOINs the wrong table. It now resolves package-aware via resolveObjectRef/resolutionKey() (FQN binds exactly; bare binds the referrer package then root-level), keying the join tree + joinTables on resolutionKey(). Completes the #244 fix (the FK-target half landed in the prior commit). Generated view SQL is byte-identical for single-package models (aliases derive from the short name); 1011 codegen-ts + 141 golden + view-lifecycle green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJRi8FtEW24z9HKGUL1xh8
1 parent 8ccc7c2 commit 4af1d18

6 files changed

Lines changed: 169 additions & 60 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10-
**npm-only** — `migrate-ts` (schema migrations are TS-owned, ADR-0015); PyPI / NuGet /
11-
Maven Central are unchanged.
10+
**npm-only** — `migrate-ts` + `codegen-ts` (schema migrations and projection-view codegen
11+
are TS-owned, ADR-0015); PyPI / NuGet / Maven Central are unchanged.
1212

1313
### Fixed — a projection/view over a rebuilt table is no longer stranded mid-migration (#243)
1414

@@ -22,17 +22,24 @@ only column-altering changes triggered it), and the D1 cascade drops/recreates e
2222
over an affected table in the correct order — no double-emit. Postgres is unaffected
2323
(FK/CHECK changes there are `ALTER … ADD/DROP CONSTRAINT`, no table rebuild).
2424

25-
### Fixed — foreign-key targets resolve by fully-qualified name; duplicate generated SQL names are refused
26-
27-
The schema builder resolved an FK's target entity by **bare** name, so when two packages
28-
declared a same-named entity the last one loaded won an internal map and a cross-package FK
29-
could silently point at the **wrong** table. FK targets now resolve by fully-qualified name
30-
(a bare `@references` is qualified with the referrer's own package, mirroring the loader's
31-
`resolutionKey()` contract); an ambiguous bare name resolves to nothing rather than
32-
guessing. Separately, two distinct metadata objects that generate the **same** database
33-
name (schema-qualified — table/table, view/view, or table/view, across packages) now fail
34-
at build time with `ERR_DUPLICATE_SQL_NAME` naming both owners, instead of emitting an
35-
un-appliable migration the database would reject.
25+
### Fixed — cross-package references bind by fully-qualified name; duplicate generated SQL names are refused (#244)
26+
27+
When two packages declared a same-bare-named `object.entity`, a fully-qualified
28+
`@references` / projection `origin.passthrough @from` / `@via` did **not** reliably bind the
29+
qualified target — the package qualifier was discarded and the bare name resolved against a
30+
global slot, so which entity won depended on metadata **file load order**. This produced a
31+
silent wrong-table foreign key (the serious case: no error, an FK against the wrong table)
32+
and/or an invalid projection view whose `SELECT`/`JOIN` read the wrong package's table
33+
(`column … does not exist` at apply). Both the schema builder (`migrate-ts`, FK targets) and
34+
the projection view-spec (`codegen-ts`, `@from`/`@via`/`@of`/`extends` join resolution) now
35+
resolve object references package-aware via the loader's `resolveObjectRef` /
36+
`resolutionKey()` contract — an FQN binds exactly, a bare ref binds the referrer's own
37+
package then a root-level object, and an ambiguous bare name resolves to nothing rather than
38+
guessing. Generated view SQL (aliases, column order) is byte-identical for existing
39+
single-package models. Separately, two distinct metadata objects that generate the **same**
40+
database name (schema-qualified — table/table, view/view, or table/view, across packages)
41+
now fail at build time with `ERR_DUPLICATE_SQL_NAME` naming both owners, instead of emitting
42+
an un-appliable migration the database would reject.
3643

3744
## [0.20.8] — 2026-07-28
3845

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,11 @@ export function buildProjectionViews(
8888
const dialect: "postgres" | "sqlite" = opts.dialect === "d1" ? "sqlite" : opts.dialect;
8989
const columnNamingStrategy = opts.columnNamingStrategy ?? "snake_case";
9090

91+
// Keyed by resolutionKey() (FQN), NOT bare name — two packages may declare a
92+
// same-named entity, and the view-spec's join targets are FQN so they bind the exact
93+
// one (#244). Keying by bare name let a cross-package same-name entity overwrite the map.
9194
const joinTables: Record<string, string> = {};
92-
for (const obj of root.objects()) joinTables[obj.name] = resolveTableName(obj);
95+
for (const obj of root.objects()) joinTables[obj.resolutionKey()] = resolveTableName(obj);
9396

9497
const out: ExpectedView[] = [];
9598
for (const projection of root.objects().filter(isProjection)) {
@@ -266,15 +269,15 @@ function collectSqlDependsOn(
266269
(c) => c instanceof MetaSource && c.isWritable(),
267270
);
268271
if (hasWritableSource) {
269-
const t = joinTables[host.name];
272+
const t = joinTables[host.resolutionKey()];
270273
if (t !== undefined) tables.add(t);
271274
}
272275
// Extends-bound anchor tables (a projection's read-model base).
273276
for (const child of host.ownChildren()) {
274277
if (child.type !== TYPE_IDENTITY && child.type !== TYPE_FIELD) continue;
275278
const owner = refNamedOwner(child, root);
276279
if (owner === undefined) continue;
277-
const t = joinTables[owner.name];
280+
const t = joinTables[owner.resolutionKey()];
278281
if (t !== undefined) tables.add(t);
279282
}
280283
return [...tables];

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

Lines changed: 68 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import {
4242
FIELD_ATTR_COLUMN,
4343
OBJECT_PROJECTION_ATTR_FILTER,
4444
findReferenceBetween,
45-
stripPackage,
45+
resolveObjectRef,
4646
type AggregateFunction,
4747
} from "@metaobjectsdev/metadata";
4848
import { type MetaData, type MetaField, type MetaRoot, MetaObject } from "@metaobjectsdev/metadata";
@@ -290,11 +290,34 @@ export function projectionViewName(
290290
export function refNamedOwner(node: MetaData, root: MetaRoot): MetaObject | undefined {
291291
const ref = (node as { superRef?: string }).superRef;
292292
if (ref === undefined) return undefined;
293-
const lastSep = ref.lastIndexOf("::");
294-
const tail = lastSep === -1 ? ref : ref.slice(lastSep + 2);
295-
const dot = tail.indexOf(".");
293+
// The entity portion is everything before the FIRST "." (packages use "::", the
294+
// member path uses ".").
295+
const dot = ref.indexOf(".");
296296
if (dot <= 0) return undefined;
297-
return root.findObject(tail.slice(0, dot)) ?? undefined;
297+
const entityRef = ref.slice(0, dot);
298+
// An FQN `extends` binds the EXACT package's entity (#244); a bare ref keeps the prior
299+
// findObject behavior (a projection extends its base by bare name in the same package).
300+
if (entityRef.includes("::")) return resolveEntityRef(root, entityRef, "");
301+
return root.findObject(entityRef) ?? undefined;
302+
}
303+
304+
/** Effective package of an object, taken from its resolution key ("<pkg>::<Name>"). */
305+
function packageOf(obj: MetaData): string {
306+
const key = obj.resolutionKey();
307+
const i = key.lastIndexOf("::");
308+
return i >= 0 ? key.slice(0, i) : "";
309+
}
310+
311+
/**
312+
* Resolve an object reference (bare or fully-qualified) to its MetaObject, package-aware
313+
* per the ADR-0042 contract (`resolveObjectRef`): an FQN binds exactly, a bare ref binds
314+
* the referrer's package then a root-level object. Replaces the load-order-dependent
315+
* `root.findObject(stripPackage(ref))` the view-spec used to do, which discarded the
316+
* package qualifier and let a same-bare-named entity in another package win (#244).
317+
*/
318+
function resolveEntityRef(root: MetaRoot, ref: string, referrerPkg: string): MetaObject | undefined {
319+
const node = resolveObjectRef(root, ref, referrerPkg).node;
320+
return node instanceof MetaObject ? node : undefined;
298321
}
299322

300323
function baseEntityFor(
@@ -370,20 +393,20 @@ function primaryKeyColumn(entity: MetaObject, ctx: ExtractContext): string | und
370393
* `@of` to name the aggregated entity, so it is derived from the last `@via` hop.
371394
* Returns undefined if any hop fails to resolve (a prior loader error already fired).
372395
*/
373-
function viaTerminalEntity(via: string, root: MetaRoot): string | undefined {
396+
function viaTerminalEntity(via: string, root: MetaRoot, referrerPkg: string): string | undefined {
374397
const segments = via.split(".");
375398
const rawEntity = segments[0];
376399
if (!rawEntity) return undefined;
377-
let currentObj = root.findObject(stripPackage(rawEntity));
400+
let currentObj = resolveEntityRef(root, rawEntity, referrerPkg);
378401
if (!currentObj) return undefined;
379-
let terminal = currentObj.name;
402+
let terminal = currentObj.resolutionKey();
380403
for (const relName of segments.slice(1)) {
381404
const resolved = resolveHop(currentObj, relName);
382405
if (!resolved) return undefined;
383-
const target = root.findObject(stripPackage(resolved.targetName));
406+
const target = resolveEntityRef(root, resolved.targetName, packageOf(currentObj));
384407
if (!target) return undefined;
385408
currentObj = target;
386-
terminal = target.name;
409+
terminal = target.resolutionKey();
387410
}
388411
return terminal;
389412
}
@@ -474,7 +497,11 @@ function resolveExprNode(
474497
}
475498

476499
function shortAliasFor(entityName: string, used: Set<string>): string {
477-
const base = (entityName[0] ?? "x").toLowerCase();
500+
// Derive from the SHORT name — an entity ref may now be a resolutionKey ("pkg::Name",
501+
// #244); the alias must stay the first letter of the entity, so existing single-package
502+
// view SQL is byte-identical (a changed alias would churn `verify --db` fingerprints).
503+
const short = entityName.includes("::") ? entityName.slice(entityName.lastIndexOf("::") + 2) : entityName;
504+
const base = (short[0] ?? "x").toLowerCase();
478505
if (!used.has(base)) { used.add(base); return base; }
479506
let i = 0;
480507
let candidate: string;
@@ -517,6 +544,9 @@ function buildJoinTree(
517544
ctx: ExtractContext,
518545
): JoinTree {
519546
const allPaths: Path[] = [];
547+
// Referrer package for resolving a bare @via head: the origins are authored on the
548+
// projection, so bare refs bind package-locally to it (FQN refs ignore this).
549+
const projPkg = packageOf(projection);
520550

521551
// ADR-0039: own — a projection's DECLARED field set IS the exposure (FR-024/
522552
// ADR-0028, inclusive + fail-closed); iterate the projection's own fields and
@@ -546,10 +576,10 @@ function buildJoinTree(
546576
const rawEntity = segments[0];
547577
const relSegments = segments.slice(1);
548578
if (!rawEntity) continue;
549-
// @via may be package-qualified ("pkg::Entity.rel"); the joinTree + findObject
550-
// key on bare names (so they line up with bare passthrough @from lookups).
551-
const entityName = stripPackage(rawEntity);
552-
let currentObj = root.findObject(entityName);
579+
// @via may be package-qualified ("pkg::Entity.rel"). Resolve package-aware and key
580+
// the joinTree on resolutionKey() (FQN) so a same-bare-named entity in another
581+
// package can't win — the passthrough @from lookups key on the same FQN (#244).
582+
let currentObj = resolveEntityRef(root, rawEntity, projPkg);
553583
if (!currentObj) continue;
554584

555585
const path: Path = [];
@@ -560,11 +590,10 @@ function buildJoinTree(
560590
const resolved = resolveHop(currentObj, relName);
561591
if (!resolved) break;
562592
const { targetName, cardinality } = resolved;
563-
// @objectRef/@references may be package-qualified ("pkg::Entity") — the directory
564-
// loader qualifies a same-package ref even when authored bare — but findObject
565-
// keys on the BARE name (like the @via/@of/@from paths above). Strip first, or
566-
// the join (and every aggregate traversing it) is silently dropped.
567-
const target = root.findObject(stripPackage(targetName));
593+
// @objectRef/@references may be package-qualified ("pkg::Entity"); resolve it
594+
// package-aware relative to the hop's source entity (the loader qualifies a
595+
// same-package ref even when authored bare), so the join binds the exact target.
596+
const target = resolveEntityRef(root, targetName, packageOf(currentObj));
568597
if (!target) break;
569598

570599
const ref = findReferenceBetween(currentObj as MetaObject, target);
@@ -608,7 +637,7 @@ function buildJoinTree(
608637
pkColumn: joinColumnFor(pkHolder, resolvedPkField, ctx),
609638
referenceHolder,
610639
joinType,
611-
targetEntity: stripPackage(targetName),
640+
targetEntity: target.resolutionKey(),
612641
});
613642
currentObj = target;
614643
}
@@ -646,7 +675,7 @@ function buildJoinTree(
646675
}
647676

648677
return {
649-
baseEntity: base.name,
678+
baseEntity: base.resolutionKey(),
650679
baseAlias,
651680
joins: Array.from(trieRoot.children.values()).map(toJoinNode),
652681
};
@@ -683,6 +712,8 @@ function buildSelectSpec(
683712
usedAliases: Set<string>,
684713
): SelectSpec {
685714
const columns: SelectColumn[] = [];
715+
// Referrer package for resolving bare @from/@of/@via entity refs (FQN refs ignore it).
716+
const projPkg = packageOf(projection);
686717

687718
// FR-024 (ADR-0028): the projection's DECLARED field set IS the exposure —
688719
// the inclusive list, fail-closed by construction. The pre-FR-024 loop that
@@ -721,13 +752,13 @@ function buildSelectSpec(
721752
const from = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_FROM) as string;
722753
const dotIdx = from.indexOf(".");
723754
if (dotIdx < 1) continue;
724-
// @from is package-qualified ("pkg::Entity.field"); the joinTree + findObject
725-
// key on the BARE entity name, so strip the package before resolving.
726-
const entityName = stripPackage(from.slice(0, dotIdx));
755+
// @from may be package-qualified ("pkg::Entity.field"). Resolve package-aware and
756+
// key the alias lookup on the SAME resolutionKey() the joinTree stored (#244).
727757
const fieldName = from.slice(dotIdx + 1);
728-
const targetEntity = root.findObject(entityName);
729-
const sourceAlias = findAliasInTree(joinTree, entityName);
730-
if (!targetEntity || sourceAlias === undefined) continue;
758+
const targetEntity = resolveEntityRef(root, from.slice(0, dotIdx), projPkg);
759+
if (!targetEntity) continue;
760+
const sourceAlias = findAliasInTree(joinTree, targetEntity.resolutionKey());
761+
if (sourceAlias === undefined) continue;
731762
// EFFECTIVE fields — the source column may be inherited via `extends`
732763
// (e.g. created_at/updated_at/created_by from an audited base), which
733764
// ownChildren() would miss.
@@ -751,9 +782,9 @@ function buildSelectSpec(
751782
// hop, and @filter is the (required) quantified predicate.
752783
const via = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA) as string | undefined;
753784
if (!via) continue;
754-
const relatedName = viaTerminalEntity(via, root);
785+
const relatedName = viaTerminalEntity(via, root, projPkg);
755786
if (!relatedName) continue;
756-
const relatedEntity = root.findObject(relatedName);
787+
const relatedEntity = resolveEntityRef(root, relatedName, projPkg);
757788
const sourceAlias = findAliasInTree(joinTree, relatedName);
758789
if (!relatedEntity || sourceAlias === undefined) continue;
759790
const joinedPk = primaryKeyColumn(relatedEntity, ctx);
@@ -779,11 +810,11 @@ function buildSelectSpec(
779810
if (!of_) continue;
780811
const dotIdx = of_.indexOf(".");
781812
if (dotIdx < 1) continue;
782-
const entityName = stripPackage(of_.slice(0, dotIdx));
783813
const fieldName = of_.slice(dotIdx + 1);
784-
const targetEntity = root.findObject(entityName);
785-
const sourceAlias = findAliasInTree(joinTree, entityName);
786-
if (!targetEntity || sourceAlias === undefined) continue;
814+
const targetEntity = resolveEntityRef(root, of_.slice(0, dotIdx), projPkg);
815+
if (!targetEntity) continue;
816+
const sourceAlias = findAliasInTree(joinTree, targetEntity.resolutionKey());
817+
if (sourceAlias === undefined) continue;
787818
const targetField = targetEntity.fields().find((f) => f.name === fieldName);
788819
if (!targetField) continue;
789820

@@ -837,9 +868,8 @@ function buildSelectSpec(
837868
if (!of_) continue;
838869
const dotIdx = of_.indexOf(".");
839870
if (dotIdx < 1) continue;
840-
const childName = stripPackage(of_.slice(0, dotIdx));
841871
const ofFieldName = of_.slice(dotIdx + 1);
842-
const childEntity = root.findObject(childName);
872+
const childEntity = resolveEntityRef(root, of_.slice(0, dotIdx), projPkg);
843873
if (!childEntity) continue;
844874
const ofField = childEntity.fields().find((f) => f.name === ofFieldName);
845875
if (!ofField) continue;
@@ -867,9 +897,10 @@ function buildSelectSpec(
867897
: undefined;
868898
columns.push({
869899
kind: "first",
900+
// FQN so it lines up with the resolutionKey()-keyed joinTables in build-projection-views (#244).
870901
fieldName: field.name,
871902
dbColAlias: dbCol,
872-
childEntity: childEntity.name,
903+
childEntity: childEntity.resolutionKey(),
873904
childAlias,
874905
sourceColumn: sourceColumnNameFor(ofField, ctx),
875906
referenceHolder,

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,10 @@ describe("extractViewSpec — flat passthrough via extends", () => {
8181
const spec = extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" });
8282

8383
expect(spec.viewName).toBe("v_program_summary");
84-
expect(spec.joinTree.baseEntity).toBe("Program");
84+
expect(spec.joinTree.baseEntity).toBe("test::Program");
8585
expect(spec.joinTree.joins.length).toBe(1);
8686
expect(spec.joinTree.joins[0]!.relationship).toBe("weeks");
87-
expect(spec.joinTree.joins[0]!.targetEntity).toBe("Week");
87+
expect(spec.joinTree.joins[0]!.targetEntity).toBe("test::Week");
8888
expect(spec.joinTree.joins[0]!.fkColumn).toBe("program_id");
8989

9090
// Select columns: id + title (inherited from extends), weekCount (aggregate)
@@ -162,11 +162,11 @@ describe("extractViewSpec — @via over a reference-only FK (FR-024)", () => {
162162
const spec = extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" });
163163

164164
expect(spec.viewName).toBe("v_enrollment");
165-
expect(spec.joinTree.baseEntity).toBe("Enrollment");
165+
expect(spec.joinTree.baseEntity).toBe("test::Enrollment");
166166
expect(spec.joinTree.joins.length).toBe(1);
167167
const join = spec.joinTree.joins[0]!;
168168
expect(join.relationship).toBe("ref_program"); // the reference name IS the hop
169-
expect(join.targetEntity).toBe("Program");
169+
expect(join.targetEntity).toBe("test::Program");
170170
expect(join.fkColumn).toBe("program_id"); // FK on Enrollment (the holder/source)
171171
expect(join.cardinality).toBe("one"); // a forward FK is inherently to-one
172172
expect(join.referenceHolder).toBe("source");
@@ -258,7 +258,7 @@ describe("extractViewSpec — multi-level via path", () => {
258258

259259
expect(spec.joinTree.joins.length).toBe(1);
260260
expect(spec.joinTree.joins[0]!.children.length).toBe(1);
261-
expect(spec.joinTree.joins[0]!.children[0]!.targetEntity).toBe("Workout");
261+
expect(spec.joinTree.joins[0]!.children[0]!.targetEntity).toBe("test::Workout");
262262
});
263263
});
264264

@@ -838,7 +838,7 @@ describe("extractViewSpec — #195 correlated first", () => {
838838
const col = spec.selectSpec.columns.find((c) => c.fieldName === "currentLabel")!;
839839
expect(col.kind).toBe("first");
840840
if (col.kind === "first") {
841-
expect(col.childEntity).toBe("ChildA");
841+
expect(col.childEntity).toBe("test::ChildA");
842842
expect(col.sourceColumn).toBe("label");
843843
expect(col.referenceHolder).toBe("target"); // FK (parent_id) lives on the child
844844
expect(col.fkColumn).toBe("parent_id");

0 commit comments

Comments
 (0)