Skip to content

Commit b4255c5

Browse files
dmealingclaude
andcommitted
fix(codegen-ts): join an unenforced required reference LEFT OUTER in projection views
#209 made a belongs-to hop INNER when its FK field is @required, since every base row then has a match. That holds only when a constraint enforces the reference. Under @Enforce: false the column can name a row that does not exist, and INNER silently drops the base row from the view. Found reviewing an adopter's view migration: an account ref_id that holds a user id or a group id joined INNER to the user table. The hop is INNER only when the reference is also enforced; otherwise LEFT OUTER. The nested-chain demotion is unchanged. MetaReferenceIdentity's doc comment, which said projection join inference ignores @Enforce, is corrected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1mRRB1K2WdYy82kntMQdx
1 parent 85ec8df commit b4255c5

4 files changed

Lines changed: 32 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ here.**
1010

1111
## [Unreleased]
1212

13+
### Fixed
14+
15+
- **A projection view keeps rows whose unenforced reference has no target.** A belongs-to hop
16+
whose FK field is `@required` was joined `INNER`, on the reasoning that every row has a
17+
match. That holds only for an enforced reference: under `@enforce: false` there is no FK
18+
constraint, and a row can name a target that does not exist, so `INNER` dropped it from the
19+
view. Such a hop is now `LEFT OUTER`. **Upgrading:** the next `meta migrate` rewrites any
20+
view that joins through an unenforced required reference; review it — the view returns the
21+
rows it had been dropping.
22+
1323
## [1.0.11] — 2026-10-01
1424

1525
_npm `1.0.11` (all 14 `@metaobjectsdev/*` packages in lockstep), PyPI `1.0.11`, NuGet `1.0.11` and

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -911,9 +911,14 @@ function buildJoinTree(
911911
// for (and keeps `verify --db` fingerprints aligned). A nullable belongs-to
912912
// FK, or ANY has-many hop (FK on the child — a base row may have zero
913913
// children), stays LEFT OUTER so no base row is dropped.
914+
// And only for an ENFORCED reference: `@enforce: false` declares a logical reference
915+
// with no FK constraint, so a NOT NULL column can still name a row that does not
916+
// exist, and INNER would silently drop that base row (an adopter's account `ref_id`
917+
// holds a user id OR a group id). Unenforced stays LEFT OUTER.
914918
const fkFieldObj = (fkHolder as MetaObject).findField(fkField);
915919
const selfInner =
916-
referenceHolder === "source" && fkFieldObj !== undefined && isRequired(fkFieldObj);
920+
referenceHolder === "source" && fkFieldObj !== undefined && isRequired(fkFieldObj) &&
921+
ref.referenceIdentity.enforce;
917922
// Nested-chain safety: joins render flat + left-associative, so an INNER hop
918923
// BELOW any LEFT ancestor drops the base row (its ON references a column the
919924
// LEFT ancestor NULLed). An INNER only survives when the ENTIRE ancestor chain

‎server/typescript/packages/codegen-ts/test/projection/join-type-derivation.test.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ async function load(children: unknown[]) {
2121
// Order --belongs-to--> Customer (FK `customerId` on Order). A projection passes
2222
// through Customer.name via the single-hop `customer` relationship, forcing a join
2323
// from orders to customers.
24-
const belongsToModel = (customerIdRequired: boolean) => [
24+
const belongsToModel = (customerIdRequired: boolean, enforce = true) => [
2525
{ "object.entity": { name: "Customer", children: [
2626
{ "source.rdb": { "@table": "customers" } },
2727
{ "field.long": { name: "id" } },
@@ -33,7 +33,7 @@ const belongsToModel = (customerIdRequired: boolean) => [
3333
{ "field.long": { name: "id" } },
3434
{ "field.long": { name: "customerId", ...(customerIdRequired ? { "@required": true } : {}) } },
3535
{ "identity.primary": { name: "pk", "@fields": "id" } },
36-
{ "identity.reference": { name: "ref_customer", "@fields": "customerId", "@references": "Customer" } },
36+
{ "identity.reference": { name: "ref_customer", "@fields": "customerId", "@references": "Customer", ...(enforce ? {} : { "@enforce": false }) } },
3737
{ "relationship.association": { name: "customer", "@objectRef": "Customer", "@cardinality": "one" } },
3838
] } },
3939
{ "object.projection": { name: "OrderView", children: [
@@ -52,6 +52,17 @@ describe("#209 — belongs-to join type derived from FK optionality", () => {
5252
expect(v!.sql).not.toContain("LEFT OUTER JOIN customers");
5353
});
5454

55+
// INNER is lossless only when every base row HAS a match — which only a database-enforced
56+
// reference guarantees. `@enforce: false` declares a logical reference with no FK
57+
// constraint, so a row may name a target that does not exist (an adopter's account
58+
// `ref_id` holds a user id OR a group id). INNER dropped those rows from the view.
59+
test("required but UNENFORCED FK → LEFT OUTER JOIN (no constraint guarantees the match)", async () => {
60+
const root = await load(belongsToModel(true, false));
61+
const [v] = buildProjectionViews(root, { dialect: "postgres", columnNamingStrategy: "snake_case" });
62+
expect(v!.sql).toContain("LEFT OUTER JOIN customers");
63+
expect(v!.sql).not.toContain("INNER JOIN customers");
64+
});
65+
5566
test("nullable FK → LEFT OUTER JOIN (preserved)", async () => {
5667
const root = await load(belongsToModel(false));
5768
const [v] = buildProjectionViews(root, { dialect: "postgres", columnNamingStrategy: "snake_case" });

‎server/typescript/packages/metadata/src/core/identity/meta-identity.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,9 @@ export class MetaReferenceIdentity extends MetaIdentity {
9494
* Whether the reference is physically enforced by the backend.
9595
* Default true (hard FK constraint emitted). Explicit `@enforce: false`
9696
* marks the reference as logical-only — drizzle-schema skips `.references()`
97-
* and migrate-ts's expected schema omits the FK descriptor. relations()
98-
* block and projection JOIN inference are unaffected.
97+
* and migrate-ts's expected schema omits the FK descriptor. The relations()
98+
* block is unaffected; projection JOIN inference keeps an unenforced required
99+
* belongs-to hop LEFT OUTER, since no constraint guarantees the target row.
99100
*/
100101
get enforce(): boolean {
101102
// ADR-0039: resolving — @enforce may be inherited via extends.

0 commit comments

Comments
 (0)