|
| 1 | +// Issue #209 — a synthesized view's joins were ALL hardcoded to LEFT OUTER JOIN. |
| 2 | +// A join over a REQUIRED (NOT NULL) belongs-to reference is semantically INNER; |
| 3 | +// lowering it to LEFT OUTER silently returns extra rows (a base row with a dangling |
| 4 | +// FK appears with NULL joined columns) and produces a different result set than the |
| 5 | +// INNER-join view it stands in for. The join type is now DERIVED from the FK's |
| 6 | +// optionality: a required belongs-to FK → INNER; a nullable one → LEFT OUTER; a |
| 7 | +// has-many (inverse-FK) join stays LEFT OUTER (a base row with zero children must |
| 8 | +// survive). |
| 9 | + |
| 10 | +import { describe, test, expect } from "bun:test"; |
| 11 | +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; |
| 12 | +import { buildProjectionViews } from "../../src/projection/build-projection-views.js"; |
| 13 | + |
| 14 | +async function load(children: unknown[]) { |
| 15 | + const json = JSON.stringify({ "metadata.root": { package: "acme", children } }); |
| 16 | + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(json)]); |
| 17 | + expect(errors).toEqual([]); |
| 18 | + return root; |
| 19 | +} |
| 20 | + |
| 21 | +// Order --belongs-to--> Customer (FK `customerId` on Order). A projection passes |
| 22 | +// through Customer.name via the single-hop `customer` relationship, forcing a join |
| 23 | +// from orders to customers. |
| 24 | +const belongsToModel = (customerIdRequired: boolean) => [ |
| 25 | + { "object.entity": { name: "Customer", children: [ |
| 26 | + { "source.rdb": { "@table": "customers" } }, |
| 27 | + { "field.long": { name: "id" } }, |
| 28 | + { "field.string": { name: "name" } }, |
| 29 | + { "identity.primary": { name: "pk", "@fields": "id" } }, |
| 30 | + ] } }, |
| 31 | + { "object.entity": { name: "Order", children: [ |
| 32 | + { "source.rdb": { "@table": "orders" } }, |
| 33 | + { "field.long": { name: "id" } }, |
| 34 | + { "field.long": { name: "customerId", ...(customerIdRequired ? { "@required": true } : {}) } }, |
| 35 | + { "identity.primary": { name: "pk", "@fields": "id" } }, |
| 36 | + { "identity.reference": { name: "ref_customer", "@fields": "customerId", "@references": "Customer" } }, |
| 37 | + { "relationship.association": { name: "customer", "@objectRef": "Customer", "@cardinality": "one" } }, |
| 38 | + ] } }, |
| 39 | + { "object.projection": { name: "OrderView", children: [ |
| 40 | + { "source.rdb": { "@kind": "view", "@table": "v_order" } }, |
| 41 | + { "field.long": { name: "id", extends: "Order.id" } }, |
| 42 | + { "field.string": { name: "customerName", children: [{ "origin.passthrough": { "@from": "Customer.name", "@via": "Order.customer" } }] } }, |
| 43 | + { "identity.primary": { name: "pk", extends: "Order.pk" } }, |
| 44 | + ] } }, |
| 45 | +]; |
| 46 | + |
| 47 | +describe("#209 — belongs-to join type derived from FK optionality", () => { |
| 48 | + test("required FK → INNER JOIN (not LEFT OUTER)", async () => { |
| 49 | + const root = await load(belongsToModel(true)); |
| 50 | + const [v] = buildProjectionViews(root, { dialect: "postgres", columnNamingStrategy: "snake_case" }); |
| 51 | + expect(v!.sql).toContain("INNER JOIN customers"); |
| 52 | + expect(v!.sql).not.toContain("LEFT OUTER JOIN customers"); |
| 53 | + }); |
| 54 | + |
| 55 | + test("nullable FK → LEFT OUTER JOIN (preserved)", async () => { |
| 56 | + const root = await load(belongsToModel(false)); |
| 57 | + const [v] = buildProjectionViews(root, { dialect: "postgres", columnNamingStrategy: "snake_case" }); |
| 58 | + expect(v!.sql).toContain("LEFT OUTER JOIN customers"); |
| 59 | + expect(v!.sql).not.toContain("INNER JOIN customers"); |
| 60 | + }); |
| 61 | +}); |
| 62 | + |
| 63 | +describe("#209 — has-many (inverse-FK) join stays LEFT OUTER", () => { |
| 64 | + // Program 1→many Week (FK on Week). Even though Week.programId is required, the |
| 65 | + // Program→weeks join must stay LEFT OUTER: a program with zero weeks must survive |
| 66 | + // (the aggregate COALESCEs to 0, not drops the row). |
| 67 | + test("required inverse FK still emits LEFT OUTER (base row with no children survives)", async () => { |
| 68 | + const root = await load([ |
| 69 | + { "object.entity": { name: "Program", children: [ |
| 70 | + { "source.rdb": { "@table": "programs" } }, |
| 71 | + { "field.int": { name: "id" } }, |
| 72 | + { "identity.primary": { name: "pk", "@fields": "id" } }, |
| 73 | + { "relationship.association": { name: "weeks", "@objectRef": "Week", "@cardinality": "many" } }, |
| 74 | + ] } }, |
| 75 | + { "object.entity": { name: "Week", children: [ |
| 76 | + { "source.rdb": { "@table": "weeks" } }, |
| 77 | + { "field.int": { name: "id" } }, |
| 78 | + { "field.int": { name: "programId", "@required": true } }, |
| 79 | + { "identity.primary": { name: "pk", "@fields": "id" } }, |
| 80 | + { "identity.reference": { name: "ref_program", "@fields": "programId", "@references": "Program" } }, |
| 81 | + ] } }, |
| 82 | + { "object.projection": { name: "ProgramSummary", children: [ |
| 83 | + { "source.rdb": { "@kind": "view", "@table": "v_program_summary" } }, |
| 84 | + { "field.int": { name: "id", extends: "Program.id" } }, |
| 85 | + { "field.int": { name: "weekCount", children: [{ "origin.aggregate": { "@agg": "count", "@of": "Week.id", "@via": "Program.weeks" } }] } }, |
| 86 | + { "identity.primary": { name: "pk", extends: "Program.pk" } }, |
| 87 | + ] } }, |
| 88 | + ]); |
| 89 | + const [v] = buildProjectionViews(root, { dialect: "postgres", columnNamingStrategy: "snake_case" }); |
| 90 | + expect(v!.sql).toContain("LEFT OUTER JOIN weeks"); |
| 91 | + expect(v!.sql).not.toContain("INNER JOIN weeks"); |
| 92 | + }); |
| 93 | +}); |
0 commit comments