Skip to content

Commit 7c0f94c

Browse files
authored
force inner joins to avoid entities being hidden by deleted related entities (#16141)
1 parent 28b64a8 commit 7c0f94c

3 files changed

Lines changed: 164 additions & 6 deletions

File tree

.changeset/modern-fans-cough.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@medusajs/order": patch
3+
"@medusajs/utils": patch
4+
---
5+
6+
Always use left joins to avoid entities being hidden in query by a soft-deleted related entity

packages/core/utils/src/dal/mikro-orm/integration-tests/__tests__/mikro-orm-repository.spec.ts

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { BigNumberRawValue } from "@medusajs/types"
21
import {
32
BeforeCreate,
43
Collection,
54
Entity,
65
EntityManager,
6+
Filter,
77
ManyToMany,
88
ManyToOne,
99
MikroORM,
@@ -15,15 +15,18 @@ import {
1515
wrap,
1616
} from "@medusajs/deps/mikro-orm/core"
1717
import { defineConfig } from "@medusajs/deps/mikro-orm/postgresql"
18+
import { BigNumberRawValue } from "@medusajs/types"
1819
import BigNumber from "bignumber.js"
1920
import { dropDatabase } from "pg-god"
2021
import { MikroOrmBigNumberProperty } from "../../big-number-field"
2122
import { mikroOrmBaseRepositoryFactory } from "../../mikro-orm-repository"
23+
import { mikroOrmSoftDeletableFilterOptions } from "../../mikro-orm-soft-deletable-filter"
2224
import { getDatabaseURL, pgGodCredentials } from "../__fixtures__/database"
2325

2426
const dbName = "mikroorm-integration-1"
2527

2628
jest.setTimeout(300000)
29+
@Filter(mikroOrmSoftDeletableFilterOptions)
2730
@Entity()
2831
class Entity1 {
2932
@PrimaryKey()
@@ -243,6 +246,45 @@ class Entity6 {
243246
}
244247
}
245248

249+
// Entity with a non-nullable foreign key
250+
@Entity()
251+
class Entity7 {
252+
@PrimaryKey()
253+
id: string
254+
255+
@Property()
256+
title: string
257+
258+
@Unique()
259+
@Property()
260+
handle: string
261+
262+
@Property({ nullable: true })
263+
deleted_at: Date | null
264+
265+
@ManyToOne(() => Entity1, {
266+
columnType: "text",
267+
nullable: false,
268+
mapToPk: true,
269+
fieldName: "entity1_id",
270+
deleteRule: "set null",
271+
})
272+
entity1_id: string
273+
274+
@ManyToOne(() => Entity1, { persist: false, nullable: false })
275+
entity1: Entity1 | null
276+
277+
@OnInit()
278+
@BeforeCreate()
279+
onInit() {
280+
if (!this.id) {
281+
this.id = Math.random().toString(36).substring(7)
282+
}
283+
284+
this.entity1_id ??= this.entity1?.id!
285+
}
286+
}
287+
246288
// Entity whose primary key is a custom (non-id) column
247289
@Entity()
248290
class CustomPkEntity {
@@ -278,6 +320,7 @@ const Entity3Repository = mikroOrmBaseRepositoryFactory(Entity3)
278320
const Entity4Repository = mikroOrmBaseRepositoryFactory(Entity4)
279321
const Entity5Repository = mikroOrmBaseRepositoryFactory(Entity5)
280322
const Entity6Repository = mikroOrmBaseRepositoryFactory(Entity6)
323+
const Entity7Repository = mikroOrmBaseRepositoryFactory(Entity7)
281324
const CustomPkEntityRepository = mikroOrmBaseRepositoryFactory(CustomPkEntity)
282325
const CompositePkEntityRepository =
283326
mikroOrmBaseRepositoryFactory(CompositePkEntity)
@@ -301,6 +344,9 @@ describe("mikroOrmRepository", () => {
301344
const manager5 = () => {
302345
return new Entity5Repository({ manager: manager.fork() })
303346
}
347+
const manager7 = () => {
348+
return new Entity7Repository({ manager: manager.fork() })
349+
}
304350
// @ts-expect-error
305351
const manager6 = () => {
306352
return new Entity6Repository({ manager: manager.fork() })
@@ -327,10 +373,14 @@ describe("mikroOrmRepository", () => {
327373
Entity4,
328374
Entity5,
329375
Entity6,
376+
Entity7,
330377
CustomPkEntity,
331378
CompositePkEntity,
332379
],
333380
clientUrl: getDatabaseURL(dbName),
381+
// Match Medusa's production config: soft-delete filters are not
382+
// auto-joined onto referenced entities unless they are populated.
383+
autoJoinRefsForFilters: false,
334384
})
335385
)
336386

@@ -390,6 +440,33 @@ describe("mikroOrmRepository", () => {
390440
expect(updatedEntity1[0].entity3.getItems()).toHaveLength(0)
391441
})
392442

443+
it("should return rows even when a populated relation is soft-deleted", async () => {
444+
await manager1().upsertWithReplace([{ id: "1", title: "en1" }])
445+
await manager7().upsertWithReplace([
446+
{ id: "2", title: "en2", handle: "handle-2", entity1: { id: "1" } },
447+
{ id: "3", title: "en3", handle: "handle-3", entity1: { id: "1" } },
448+
])
449+
450+
const softDeleteManager = orm.em.fork()
451+
await new Entity1Repository({ manager: softDeleteManager }).softDelete(
452+
["1"],
453+
{ transactionManager: softDeleteManager }
454+
)
455+
await softDeleteManager.flush()
456+
457+
const afterSoftDelete = await manager7().find({
458+
where: {},
459+
options: {
460+
fields: ["entity1.id"],
461+
},
462+
})
463+
expect(afterSoftDelete).toHaveLength(2)
464+
expect(afterSoftDelete[0].id).toBe("2")
465+
expect(afterSoftDelete[0].entity1).toBeNull()
466+
expect(afterSoftDelete[1].id).toBe("3")
467+
expect(afterSoftDelete[1].entity1).toBeNull()
468+
})
469+
393470
describe("upsert with replace", () => {
394471
it("should successfully create a flat entity", async () => {
395472
const entity1 = { id: "1", title: "en1", amount: 100 }
@@ -1690,7 +1767,7 @@ describe("mikroOrmRepository", () => {
16901767
expect(e5InsertCalls).toBe(2) // One batch insert for Entity5s, one for Entity6s
16911768

16921769
// Check that the expected batch sizes exist (order may vary)
1693-
const e5BatchSizes = qbInsertSpy.mock.calls.map(call => call[0].length)
1770+
const e5BatchSizes = qbInsertSpy.mock.calls.map((call) => call[0].length)
16941771
expect(e5BatchSizes).toContain(800) // entity5 25 * 8 * 4
16951772
expect(e5BatchSizes).toContain(2400) // entity6 25 * 8 * 4 * 3
16961773

@@ -1722,7 +1799,7 @@ describe("mikroOrmRepository", () => {
17221799
expect(e3InsertCalls).toBe(3) // One batch insert for Entity3s, one for Entity4s and one pivot entity3 -> entity5
17231800

17241801
// Check that the expected batch sizes exist (order may vary)
1725-
const e3BatchSizes = qbInsertSpy.mock.calls.map(call => call[0].length)
1802+
const e3BatchSizes = qbInsertSpy.mock.calls.map((call) => call[0].length)
17261803
expect(e3BatchSizes).toContain(200) // entity3: 25 * 8
17271804
expect(e3BatchSizes).toContain(800) // pivot entity3 -> entity5: 25 * 8 * 4
17281805
expect(e3BatchSizes).toContain(1000) // entity4: 25 * 8 * 5
@@ -1753,7 +1830,9 @@ describe("mikroOrmRepository", () => {
17531830
expect(mainInsertCalls).toBe(3) // One batch insert for Entity1s, one for Entity2s, one for Entity3s
17541831

17551832
// Check that the expected batch sizes exist (order may vary)
1756-
const mainBatchSizes = qbInsertSpy.mock.calls.map(call => call[0].length)
1833+
const mainBatchSizes = qbInsertSpy.mock.calls.map(
1834+
(call) => call[0].length
1835+
)
17571836
expect(mainBatchSizes).toContain(25) // entity1: 25
17581837
expect(mainBatchSizes).toContain(200) // entity3: 25 * 8
17591838
expect(mainBatchSizes).toContain(250) // entity2: 25 * 10
@@ -1833,7 +1912,9 @@ describe("mikroOrmRepository", () => {
18331912
// Should use batch inserts for new entities and pivot relationships
18341913
expect(updateInsertCalls).toBe(2) // pivot Entity1 - Entity3 (with conflict resolution) + new Entity2s
18351914
// Check that the expected batch sizes exist (order may vary)
1836-
const updateBatchSizes = qbInsertSpy.mock.calls.map(call => call[0].length)
1915+
const updateBatchSizes = qbInsertSpy.mock.calls.map(
1916+
(call) => call[0].length
1917+
)
18371918
expect(updateBatchSizes).toContain(100) // pivot Entity1 - Entity3: 25 parents × 4 entity3s each (uses onConflict().ignore())
18381919
expect(updateBatchSizes).toContain(50) // New Entity2s: 25 parents × 2 new each
18391920

packages/core/utils/src/dal/mikro-orm/mikro-orm-repository.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,18 @@ import {
2525
} from "@medusajs/types"
2626
import {
2727
arrayDifference,
28+
isObject,
2829
isString,
2930
MedusaError,
3031
promiseAll,
3132
} from "../../common"
3233
import { toMikroORMEntity } from "../../dml"
3334
import { buildQuery } from "../../modules-sdk/build-query"
34-
import { augmentFindOptionsWithCrossModuleJoins } from "./cross-module-query"
3535
import { transactionWrapper } from "../utils"
36+
import { augmentFindOptionsWithCrossModuleJoins } from "./cross-module-query"
3637
import { dbErrorMapper } from "./db-error-mapper"
3738
import { mikroOrmSerializer } from "./mikro-orm-serializer"
39+
import { SoftDeletableFilterKey } from "./mikro-orm-soft-deletable-filter"
3840
import { pruneFindOptionsAgainstMetadata } from "./prune-find-options-against-metadata"
3941
import { mikroOrmUpdateDeletedAtRecursively } from "./utils"
4042

@@ -1547,9 +1549,78 @@ export function mikroOrmBaseRepositoryFactory<const T extends object>(
15471549
findOptions: findOptions_,
15481550
})
15491551

1552+
// Soft-deleted related entities must not exclude parent entities. Force LEFT JOIN
1553+
// for populated relations so softDeletable filters on related tables do not
1554+
// remove owning rows (MikroORM defaults to INNER JOIN for non-nullable FKs).
1555+
const softDeletableFilter = (
1556+
findOptions_.options.filters as Record<string, unknown> | undefined
1557+
)?.[SoftDeletableFilterKey]
1558+
const withDeleted =
1559+
isObject(softDeletableFilter) &&
1560+
(softDeletableFilter as { withDeleted?: boolean }).withDeleted === true
1561+
if (!withDeleted) {
1562+
// Override populate to force LEFT JOINs.
1563+
findOptions_.options.populate = this.forceLeftJoinPopulate(
1564+
findOptions_.options.populate,
1565+
findOptions_.options.fields
1566+
)
1567+
}
1568+
15501569
return findOptions_
15511570
}
15521571

1572+
/**
1573+
* Ensure every populated relation uses a LEFT JOIN.
1574+
* Infers populate from nested fields when populate is omitted, then rewrites
1575+
* all populate hints (including nested children) to `joinType: "left join"`.
1576+
*/
1577+
private forceLeftJoinPopulate(populate: unknown, fields?: unknown): any {
1578+
let populateHints = populate
1579+
1580+
if (
1581+
(!Array.isArray(populateHints) || !populateHints.length) &&
1582+
Array.isArray(fields)
1583+
) {
1584+
const relationPaths = new Set<string>()
1585+
for (const field of fields) {
1586+
if (!isString(field) || !field.includes(".")) {
1587+
continue
1588+
}
1589+
1590+
// Drop the trailing scalar (e.g. "entity1.id" -> "entity1").
1591+
relationPaths.add(field.split(".").slice(0, -1).join("."))
1592+
}
1593+
1594+
if (relationPaths.size) {
1595+
populateHints = [...relationPaths]
1596+
}
1597+
}
1598+
1599+
if (!Array.isArray(populateHints)) {
1600+
return populateHints
1601+
}
1602+
1603+
return populateHints.map((item) => {
1604+
if (isString(item)) {
1605+
return { field: item, joinType: "left join" }
1606+
}
1607+
1608+
if (item && typeof item === "object") {
1609+
// This is an internal representation of populate in mikro-orm.
1610+
// In v7, this is exposed publicly.
1611+
return {
1612+
...item,
1613+
joinType: "left join",
1614+
children: item.children
1615+
? this.forceLeftJoinPopulate(item.children)
1616+
: item.children,
1617+
}
1618+
}
1619+
1620+
return item
1621+
})
1622+
}
1623+
15531624
private normalizeFilters(
15541625
filters:
15551626
| string

0 commit comments

Comments
 (0)