feat: Modify the abstract repository upsert to handle subresources as per convention (#6813)
* feat: Modify the abstract repository upsert method to handle subresources correctly * fix: Preserve the upsertWithResponse order in the response, and return all the data * fix: Create integration tests folder for mikro orm utils that run against the DB * fix: Remove many-to-one creation and additional changes based on PR review
This commit is contained in:
6
.changeset/warm-pumas-crash.md
Normal file
6
.changeset/warm-pumas-crash.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@medusajs/types": minor
|
||||
"@medusajs/utils": minor
|
||||
---
|
||||
|
||||
Added an upsertWithReplace method to the mikro orm repository
|
||||
@@ -70,5 +70,17 @@ export type FindOptions<T = any> = {
|
||||
options?: OptionsQuery<T, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
* An object used to specify the configuration of how the upsert should be performed.
|
||||
*/
|
||||
export type UpsertWithReplaceConfig<T> = {
|
||||
/**
|
||||
* The relationships that will be updated/created/deleted as part of the upsert
|
||||
*/
|
||||
relations?: (keyof T)[]
|
||||
}
|
||||
|
||||
export * from "./repository-service"
|
||||
export * from "./entity"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FilterQuery as InternalFilterQuery,
|
||||
FilterQuery,
|
||||
FindOptions,
|
||||
UpsertWithReplaceConfig,
|
||||
} from "./index"
|
||||
|
||||
/**
|
||||
@@ -70,6 +71,12 @@ export interface RepositoryService<T = any> extends BaseRepositoryService<T> {
|
||||
): Promise<[T[], Record<string, unknown[]>]>
|
||||
|
||||
upsert(data: any[], context?: Context): Promise<T[]>
|
||||
|
||||
upsertWithReplace(
|
||||
data: any[],
|
||||
config?: UpsertWithReplaceConfig<T>,
|
||||
context?: Context
|
||||
): Promise<T[]>
|
||||
}
|
||||
|
||||
export interface TreeRepositoryService<T = any>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BaseFilterable,
|
||||
FilterQuery as InternalFilterQuery,
|
||||
FilterQuery,
|
||||
UpsertWithReplaceConfig,
|
||||
} from "../dal"
|
||||
|
||||
export interface InternalModuleService<
|
||||
@@ -78,4 +79,10 @@ export interface InternalModuleService<
|
||||
|
||||
upsert(data: any[], sharedContext?: Context): Promise<TEntity[]>
|
||||
upsert(data: any, sharedContext?: Context): Promise<TEntity>
|
||||
|
||||
upsertWithReplace(
|
||||
data: any[],
|
||||
config?: UpsertWithReplaceConfig<TEntity>,
|
||||
sharedContext?: Context
|
||||
): Promise<TEntity[]>
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"prepublishOnly": "cross-env NODE_ENV=production tsc --build",
|
||||
"build": "rimraf dist && tsc --build",
|
||||
"watch": "tsc --build --watch",
|
||||
"test": "jest --silent --bail --maxWorkers=50% --forceExit"
|
||||
"test": "jest --silent --bail --maxWorkers=50% --forceExit --testPathIgnorePatterns='/integration-tests/' -- src/**/__tests__/**/*.ts",
|
||||
"test:integration": "jest --silent --bail --maxWorkers=50% --forceExit -- src/**/integration-tests/__tests__/**/*.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from "@mikro-orm/core"
|
||||
|
||||
// Circular dependency one level
|
||||
|
||||
@Entity()
|
||||
class RecursiveEntity1 {
|
||||
constructor(props: { id: string; deleted_at: Date | null }) {
|
||||
|
||||
@@ -0,0 +1,778 @@
|
||||
import {
|
||||
BeforeCreate,
|
||||
Collection,
|
||||
Entity,
|
||||
EntityManager,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
MikroORM,
|
||||
OneToMany,
|
||||
OnInit,
|
||||
PrimaryKey,
|
||||
Property,
|
||||
wrap,
|
||||
} from "@mikro-orm/core"
|
||||
import { mikroOrmBaseRepositoryFactory } from "../../mikro-orm-repository"
|
||||
import { dropDatabase } from "pg-god"
|
||||
|
||||
const DB_HOST = process.env.DB_HOST ?? "localhost"
|
||||
const DB_USERNAME = process.env.DB_USERNAME ?? ""
|
||||
const DB_PASSWORD = process.env.DB_PASSWORD
|
||||
const DB_NAME = "mikroorm-integration-1"
|
||||
|
||||
const pgGodCredentials = {
|
||||
user: DB_USERNAME,
|
||||
password: DB_PASSWORD,
|
||||
host: DB_HOST,
|
||||
}
|
||||
|
||||
export function getDatabaseURL(): string {
|
||||
return `postgres://${DB_USERNAME}${
|
||||
DB_PASSWORD ? `:${DB_PASSWORD}` : ""
|
||||
}@${DB_HOST}/${DB_NAME}`
|
||||
}
|
||||
|
||||
jest.setTimeout(300000)
|
||||
@Entity()
|
||||
class Entity1 {
|
||||
@PrimaryKey()
|
||||
id: string
|
||||
|
||||
@Property()
|
||||
title: string
|
||||
|
||||
@Property({ nullable: true })
|
||||
deleted_at: Date | null
|
||||
|
||||
@OneToMany(() => Entity2, (entity2) => entity2.entity1)
|
||||
entity2 = new Collection<Entity2>(this)
|
||||
|
||||
@ManyToMany(() => Entity3, "entity1", {
|
||||
owner: true,
|
||||
pivotTable: "entity_1_3",
|
||||
})
|
||||
entity3 = new Collection<Entity3>(this)
|
||||
|
||||
@OnInit()
|
||||
@BeforeCreate()
|
||||
onInit() {
|
||||
if (!this.id) {
|
||||
this.id = Math.random().toString(36).substring(7)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Entity()
|
||||
class Entity2 {
|
||||
@PrimaryKey()
|
||||
id: string
|
||||
|
||||
@Property()
|
||||
title: string
|
||||
|
||||
@Property({ nullable: true })
|
||||
deleted_at: Date | null
|
||||
|
||||
@ManyToOne(() => Entity1, {
|
||||
columnType: "text",
|
||||
nullable: true,
|
||||
mapToPk: true,
|
||||
fieldName: "entity1_id",
|
||||
onDelete: "set null",
|
||||
})
|
||||
entity1_id: string
|
||||
|
||||
@ManyToOne(() => Entity1, { persist: false, nullable: true })
|
||||
entity1: Entity1 | null
|
||||
|
||||
@OnInit()
|
||||
@BeforeCreate()
|
||||
onInit() {
|
||||
if (!this.id) {
|
||||
this.id = Math.random().toString(36).substring(7)
|
||||
}
|
||||
|
||||
this.entity1_id ??= this.entity1?.id!
|
||||
}
|
||||
}
|
||||
|
||||
@Entity()
|
||||
class Entity3 {
|
||||
@PrimaryKey()
|
||||
id: string
|
||||
|
||||
@Property()
|
||||
title: string
|
||||
|
||||
@Property({ nullable: true })
|
||||
deleted_at: Date | null
|
||||
|
||||
@ManyToMany(() => Entity1, (entity1) => entity1.entity3)
|
||||
entity1 = new Collection<Entity1>(this)
|
||||
|
||||
@OnInit()
|
||||
@BeforeCreate()
|
||||
onInit() {
|
||||
if (!this.id) {
|
||||
this.id = Math.random().toString(36).substring(7)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Entity1Repository = mikroOrmBaseRepositoryFactory<Entity1>(Entity1)
|
||||
const Entity2Repository = mikroOrmBaseRepositoryFactory<Entity2>(Entity2)
|
||||
const Entity3Repository = mikroOrmBaseRepositoryFactory<Entity3>(Entity3)
|
||||
|
||||
describe("mikroOrmRepository", () => {
|
||||
describe("upsert with replace", () => {
|
||||
let orm!: MikroORM
|
||||
let manager!: EntityManager
|
||||
const manager1 = () => {
|
||||
return new Entity1Repository({ manager: manager.fork() })
|
||||
}
|
||||
const manager2 = () => {
|
||||
return new Entity2Repository({ manager: manager.fork() })
|
||||
}
|
||||
const manager3 = () => {
|
||||
return new Entity3Repository({ manager: manager.fork() })
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await dropDatabase(
|
||||
{ databaseName: DB_NAME, errorIfNonExist: false },
|
||||
pgGodCredentials
|
||||
)
|
||||
|
||||
orm = await MikroORM.init({
|
||||
entities: [Entity1, Entity2],
|
||||
clientUrl: getDatabaseURL(),
|
||||
type: "postgresql",
|
||||
})
|
||||
|
||||
const generator = orm.getSchemaGenerator()
|
||||
await generator.ensureDatabase()
|
||||
await generator.createSchema()
|
||||
|
||||
manager = orm.em.fork()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const generator = orm.getSchemaGenerator()
|
||||
await generator.dropSchema()
|
||||
await orm.close(true)
|
||||
})
|
||||
|
||||
it("should successfully create a flat entity", async () => {
|
||||
const entity1 = { id: "1", title: "en1" }
|
||||
|
||||
const resp = await manager1().upsertWithReplace([entity1])
|
||||
const listedEntities = await manager1().find()
|
||||
|
||||
expect(listedEntities).toHaveLength(1)
|
||||
expect(listedEntities[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "1",
|
||||
title: "en1",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should successfully update a flat entity", async () => {
|
||||
const entity1 = { id: "1", title: "en1" }
|
||||
|
||||
await manager1().upsertWithReplace([entity1])
|
||||
entity1.title = "newen1"
|
||||
await manager1().upsertWithReplace([entity1])
|
||||
const listedEntities = await manager1().find()
|
||||
|
||||
expect(listedEntities).toHaveLength(1)
|
||||
expect(listedEntities[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "1",
|
||||
title: "newen1",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should successfully do a partial update a flat entity", async () => {
|
||||
const entity1 = { id: "1", title: "en1" }
|
||||
|
||||
await manager1().upsertWithReplace([entity1])
|
||||
entity1.title = undefined as any
|
||||
await manager1().upsertWithReplace([entity1])
|
||||
const listedEntities = await manager1().find()
|
||||
|
||||
expect(listedEntities).toHaveLength(1)
|
||||
expect(wrap(listedEntities[0]).toPOJO()).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "1",
|
||||
title: "en1",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw if a sub-entity is passed in a many-to-one relation", async () => {
|
||||
const entity2 = {
|
||||
id: "2",
|
||||
title: "en2",
|
||||
entity1: { title: "en1" },
|
||||
}
|
||||
|
||||
const errMsg = await manager2()
|
||||
.upsertWithReplace([entity2])
|
||||
.catch((e) => e.message)
|
||||
|
||||
expect(errMsg).toEqual(
|
||||
"Many-to-one relation entity1 must be set with an ID"
|
||||
)
|
||||
})
|
||||
|
||||
it("should successfully create the parent entity of a many-to-one", async () => {
|
||||
const entity2 = {
|
||||
id: "2",
|
||||
title: "en2",
|
||||
}
|
||||
|
||||
await manager2().upsertWithReplace([entity2], {
|
||||
relations: [],
|
||||
})
|
||||
const listedEntities = await manager2().find({
|
||||
where: { id: "2" },
|
||||
options: { populate: ["entity1"] },
|
||||
})
|
||||
|
||||
expect(listedEntities).toHaveLength(1)
|
||||
expect(listedEntities[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "2",
|
||||
title: "en2",
|
||||
entity1: null,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should set an entity to parent entity of a many-to-one relation", async () => {
|
||||
const entity1 = {
|
||||
id: "1",
|
||||
title: "en1",
|
||||
}
|
||||
|
||||
const entity2 = {
|
||||
id: "2",
|
||||
title: "en2",
|
||||
entity1: { id: "1" },
|
||||
}
|
||||
|
||||
await manager1().upsertWithReplace([entity1])
|
||||
await manager2().upsertWithReplace([entity2])
|
||||
|
||||
const listedEntities = await manager2().find({
|
||||
where: { id: "2" },
|
||||
options: { populate: ["entity1"] },
|
||||
})
|
||||
|
||||
expect(listedEntities).toHaveLength(1)
|
||||
expect(JSON.parse(JSON.stringify(listedEntities[0]))).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "2",
|
||||
title: "en2",
|
||||
entity1: expect.objectContaining({
|
||||
title: "en1",
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should successfully unset an entity of a many-to-one relation", async () => {
|
||||
const entity1 = {
|
||||
id: "1",
|
||||
title: "en1",
|
||||
}
|
||||
|
||||
const entity2 = {
|
||||
id: "2",
|
||||
title: "en2",
|
||||
entity1: { id: "1" },
|
||||
}
|
||||
|
||||
await manager1().upsertWithReplace([entity1])
|
||||
await manager2().upsertWithReplace([entity2])
|
||||
|
||||
entity2.entity1 = null as any
|
||||
await manager2().upsertWithReplace([entity2])
|
||||
|
||||
const listedEntities = await manager2().find({
|
||||
where: { id: "2" },
|
||||
options: { populate: ["entity1"] },
|
||||
})
|
||||
|
||||
const listedEntity1 = await manager1().find({
|
||||
where: {},
|
||||
})
|
||||
|
||||
expect(listedEntities).toHaveLength(1)
|
||||
expect(listedEntities[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "2",
|
||||
title: "en2",
|
||||
entity1: null,
|
||||
})
|
||||
)
|
||||
|
||||
expect(listedEntity1).toHaveLength(1)
|
||||
expect(listedEntity1[0].title).toEqual("en1")
|
||||
})
|
||||
|
||||
it("should only create the parent entity of a one-to-many if relation is not included", async () => {
|
||||
const entity1 = {
|
||||
id: "1",
|
||||
title: "en1",
|
||||
entity2: [{ title: "en2-1" }, { title: "en2-2" }],
|
||||
}
|
||||
|
||||
await manager1().upsertWithReplace([entity1], {
|
||||
relations: [],
|
||||
})
|
||||
const listedEntities = await manager1().find({
|
||||
where: { id: "1" },
|
||||
options: { populate: ["entity2"] },
|
||||
})
|
||||
|
||||
expect(listedEntities).toHaveLength(1)
|
||||
expect(listedEntities[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "1",
|
||||
title: "en1",
|
||||
})
|
||||
)
|
||||
expect(listedEntities[0].entity2.getItems()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should successfully create an entity with a sub-entity one-to-many relation", async () => {
|
||||
const entity1 = {
|
||||
id: "1",
|
||||
title: "en1",
|
||||
entity2: [{ title: "en2-1" }, { title: "en2-2" }],
|
||||
}
|
||||
|
||||
await manager1().upsertWithReplace([entity1], {
|
||||
relations: ["entity2"],
|
||||
})
|
||||
const listedEntities = await manager1().find({
|
||||
where: { id: "1" },
|
||||
options: { populate: ["entity2"] },
|
||||
})
|
||||
|
||||
expect(listedEntities).toHaveLength(1)
|
||||
expect(listedEntities[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "1",
|
||||
title: "en1",
|
||||
})
|
||||
)
|
||||
expect(listedEntities[0].entity2.getItems()).toHaveLength(2)
|
||||
expect(listedEntities[0].entity2.getItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "en2-1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "en2-2",
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should only update the parent entity of a one-to-many if relation is not included", async () => {
|
||||
const entity1 = {
|
||||
id: "1",
|
||||
title: "en1",
|
||||
entity2: [{ title: "en2-1" }, { title: "en2-2" }],
|
||||
}
|
||||
|
||||
await manager1().upsertWithReplace([entity1], {
|
||||
relations: ["entity2"],
|
||||
})
|
||||
entity1.entity2.push({ title: "en2-3" })
|
||||
await manager1().upsertWithReplace([entity1], {
|
||||
relations: [],
|
||||
})
|
||||
|
||||
const listedEntities = await manager1().find({
|
||||
where: { id: "1" },
|
||||
options: { populate: ["entity2"] },
|
||||
})
|
||||
|
||||
expect(listedEntities).toHaveLength(1)
|
||||
expect(listedEntities[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "1",
|
||||
title: "en1",
|
||||
})
|
||||
)
|
||||
expect(listedEntities[0].entity2.getItems()).toHaveLength(2)
|
||||
expect(listedEntities[0].entity2.getItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "en2-1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "en2-2",
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should successfully update, create, and delete subentities an entity with a one-to-many relation", async () => {
|
||||
const entity1 = {
|
||||
id: "1",
|
||||
title: "en1",
|
||||
entity2: [
|
||||
{ id: "2", title: "en2-1" },
|
||||
{ id: "3", title: "en2-2" },
|
||||
] as any[],
|
||||
}
|
||||
|
||||
await manager1().upsertWithReplace([entity1], {
|
||||
relations: ["entity2"],
|
||||
})
|
||||
|
||||
entity1.entity2 = [{ id: "2", title: "newen2-1" }, { title: "en2-3" }]
|
||||
|
||||
await manager1().upsertWithReplace([entity1], {
|
||||
relations: ["entity2"],
|
||||
})
|
||||
|
||||
const listedEntities = await manager1().find({
|
||||
where: { id: "1" },
|
||||
options: { populate: ["entity2"] },
|
||||
})
|
||||
|
||||
expect(listedEntities).toHaveLength(1)
|
||||
expect(listedEntities[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "1",
|
||||
title: "en1",
|
||||
})
|
||||
)
|
||||
expect(listedEntities[0].entity2.getItems()).toHaveLength(2)
|
||||
expect(listedEntities[0].entity2.getItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "newen2-1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "en2-3",
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should only create the parent entity of a many-to-many if relation is not included", async () => {
|
||||
const entity1 = {
|
||||
id: "1",
|
||||
title: "en1",
|
||||
entity3: [{ title: "en3-1" }, { title: "en3-2" }],
|
||||
}
|
||||
|
||||
await manager1().upsertWithReplace([entity1], {
|
||||
relations: [],
|
||||
})
|
||||
const listedEntities = await manager1().find({
|
||||
where: { id: "1" },
|
||||
options: { populate: ["entity3"] },
|
||||
})
|
||||
|
||||
expect(listedEntities).toHaveLength(1)
|
||||
expect(listedEntities[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "1",
|
||||
title: "en1",
|
||||
})
|
||||
)
|
||||
expect(listedEntities[0].entity3.getItems()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should successfully create an entity with a sub-entity many-to-many relation", async () => {
|
||||
const entity1 = {
|
||||
id: "1",
|
||||
title: "en1",
|
||||
entity3: [{ title: "en3-1" }, { title: "en3-2" }],
|
||||
}
|
||||
|
||||
await manager1().upsertWithReplace([entity1], {
|
||||
relations: ["entity3"],
|
||||
})
|
||||
const listedEntity1 = await manager1().find({
|
||||
where: { id: "1" },
|
||||
options: { populate: ["entity3"] },
|
||||
})
|
||||
|
||||
const listedEntity3 = await manager3().find({
|
||||
where: { title: "en3-1" },
|
||||
options: { populate: ["entity1"] },
|
||||
})
|
||||
|
||||
expect(listedEntity1).toHaveLength(1)
|
||||
expect(listedEntity1[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "1",
|
||||
title: "en1",
|
||||
})
|
||||
)
|
||||
expect(listedEntity1[0].entity3.getItems()).toHaveLength(2)
|
||||
expect(listedEntity1[0].entity3.getItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "en3-1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "en3-2",
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
expect(listedEntity3).toHaveLength(1)
|
||||
expect(listedEntity3[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
title: "en3-1",
|
||||
})
|
||||
)
|
||||
expect(listedEntity3[0].entity1.getItems()).toHaveLength(1)
|
||||
expect(listedEntity3[0].entity1.getItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "en1",
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should only update the parent entity of a many-to-many if relation is not included", async () => {
|
||||
const entity1 = {
|
||||
id: "1",
|
||||
title: "en1",
|
||||
entity3: [{ title: "en3-1" }, { title: "en3-2" }],
|
||||
}
|
||||
|
||||
await manager1().upsertWithReplace([entity1], {
|
||||
relations: ["entity3"],
|
||||
})
|
||||
entity1.title = "newen1"
|
||||
entity1.entity3.push({ title: "en3-3" })
|
||||
await manager1().upsertWithReplace([entity1], {
|
||||
relations: [],
|
||||
})
|
||||
|
||||
const listedEntities = await manager1().find({
|
||||
where: { id: "1" },
|
||||
options: { populate: ["entity3"] },
|
||||
})
|
||||
|
||||
expect(listedEntities).toHaveLength(1)
|
||||
expect(listedEntities[0].title).toEqual("newen1")
|
||||
|
||||
expect(listedEntities[0].entity3.getItems()).toHaveLength(2)
|
||||
expect(listedEntities[0].entity3.getItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "en3-1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "en3-2",
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should successfully update, create, and delete subentities an entity with a many-to-many relation", async () => {
|
||||
const entity1 = {
|
||||
id: "1",
|
||||
title: "en1",
|
||||
entity3: [
|
||||
{ id: "4", title: "en3-1" },
|
||||
{ id: "5", title: "en3-2" },
|
||||
] as any,
|
||||
}
|
||||
|
||||
let resp = await manager1().upsertWithReplace([entity1], {
|
||||
relations: ["entity3"],
|
||||
})
|
||||
entity1.title = "newen1"
|
||||
entity1.entity3 = [{ id: "4", title: "newen3-1" }, { title: "en3-4" }]
|
||||
resp = await manager1().upsertWithReplace([entity1], {
|
||||
relations: ["entity3"],
|
||||
})
|
||||
|
||||
const listedEntities = await manager1().find({
|
||||
where: { id: "1" },
|
||||
options: { populate: ["entity3"] },
|
||||
})
|
||||
|
||||
const listedEntity3 = await manager3().find({
|
||||
where: {},
|
||||
})
|
||||
|
||||
expect(listedEntities).toHaveLength(1)
|
||||
expect(listedEntities[0].title).toEqual("newen1")
|
||||
expect(listedEntities[0].entity3.getItems()).toHaveLength(2)
|
||||
expect(listedEntities[0].entity3.getItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "newen3-1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "en3-4",
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
// Many-to-many don't get deleted afterwards, even if they were disassociated
|
||||
expect(listedEntity3).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("should successfully remove relationship when an empty array is passed in a many-to-many relation", async () => {
|
||||
const entity1 = {
|
||||
id: "1",
|
||||
title: "en1",
|
||||
entity3: [
|
||||
{ id: "4", title: "en3-1" },
|
||||
{ id: "5", title: "en3-2" },
|
||||
] as any,
|
||||
}
|
||||
|
||||
await manager1().upsertWithReplace([entity1], {
|
||||
relations: ["entity3"],
|
||||
})
|
||||
entity1.title = "newen1"
|
||||
entity1.entity3 = []
|
||||
await manager1().upsertWithReplace([entity1], {
|
||||
relations: ["entity3"],
|
||||
})
|
||||
|
||||
const listedEntities = await manager1().find({
|
||||
where: { id: "1" },
|
||||
options: { populate: ["entity3"] },
|
||||
})
|
||||
|
||||
expect(listedEntities).toHaveLength(1)
|
||||
expect(listedEntities[0].title).toEqual("newen1")
|
||||
expect(listedEntities[0].entity3.getItems()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should correctly handle sub-entity upserts", async () => {
|
||||
const entity1 = {
|
||||
id: "1",
|
||||
title: "en1",
|
||||
entity3: [
|
||||
{ id: "4", title: "en3-1" },
|
||||
{ id: "5", title: "en3-2" },
|
||||
] as any,
|
||||
}
|
||||
|
||||
const mainEntity = await manager1().upsertWithReplace([entity1], {
|
||||
relations: ["entity3"],
|
||||
})
|
||||
entity1.title = "newen1"
|
||||
entity1.entity3 = [{ id: "4", title: "newen3-1" }, { title: "en3-4" }]
|
||||
await manager1().upsertWithReplace([entity1], {
|
||||
relations: ["entity3"],
|
||||
})
|
||||
|
||||
// The sub-entity upsert should happen after the main was created
|
||||
await manager2().upsertWithReplace([
|
||||
{ id: "2", title: "en2", entity1_id: mainEntity[0].id },
|
||||
])
|
||||
|
||||
const listedEntities = await manager1().find({
|
||||
where: { id: "1" },
|
||||
options: { populate: ["entity2", "entity3"] },
|
||||
})
|
||||
|
||||
expect(listedEntities).toHaveLength(1)
|
||||
expect(listedEntities[0].title).toEqual("newen1")
|
||||
expect(listedEntities[0].entity2.getItems()).toHaveLength(1)
|
||||
expect(listedEntities[0].entity2.getItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "en2",
|
||||
}),
|
||||
])
|
||||
)
|
||||
expect(listedEntities[0].entity3.getItems()).toHaveLength(2)
|
||||
expect(listedEntities[0].entity3.getItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "newen3-1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "en3-4",
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should return the complete dependency tree as a response, with IDs populated", async () => {
|
||||
const entity1 = {
|
||||
id: "1",
|
||||
title: "en1",
|
||||
entity2: [{ title: "en2" }],
|
||||
entity3: [{ title: "en3-1" }, { title: "en3-2" }] as any,
|
||||
}
|
||||
|
||||
const [createResp] = await manager1().upsertWithReplace([entity1], {
|
||||
relations: ["entity2", "entity3"],
|
||||
})
|
||||
createResp.title = "newen1"
|
||||
const [updateResp] = await manager1().upsertWithReplace([createResp], {
|
||||
relations: ["entity2", "entity3"],
|
||||
})
|
||||
|
||||
expect(createResp.id).toEqual("1")
|
||||
expect(createResp.title).toEqual("newen1")
|
||||
expect(createResp.entity2).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
title: "en2",
|
||||
}),
|
||||
])
|
||||
)
|
||||
expect(createResp.entity3).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
title: "en3-1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
title: "en3-2",
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
expect(updateResp.id).toEqual("1")
|
||||
expect(updateResp.title).toEqual("newen1")
|
||||
expect(updateResp.entity2).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
title: "en2",
|
||||
}),
|
||||
])
|
||||
)
|
||||
expect(updateResp.entity3).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
title: "en3-1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
title: "en3-2",
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,21 +6,31 @@ import {
|
||||
FilterQuery as InternalFilterQuery,
|
||||
RepositoryService,
|
||||
RepositoryTransformOptions,
|
||||
UpsertWithReplaceConfig,
|
||||
} from "@medusajs/types"
|
||||
import {
|
||||
EntityManager,
|
||||
EntitySchema,
|
||||
LoadStrategy,
|
||||
ReferenceType,
|
||||
RequiredEntityData,
|
||||
wrap,
|
||||
} from "@mikro-orm/core"
|
||||
import { FindOptions as MikroOptions } from "@mikro-orm/core/drivers/IDatabaseDriver"
|
||||
import {
|
||||
EntityClass,
|
||||
EntityName,
|
||||
EntityProperty,
|
||||
FilterQuery as MikroFilterQuery,
|
||||
} from "@mikro-orm/core/typings"
|
||||
import { SqlEntityManager } from "@mikro-orm/postgresql"
|
||||
import { isString } from "../../common"
|
||||
import {
|
||||
MedusaError,
|
||||
arrayDifference,
|
||||
deepCopy,
|
||||
isString,
|
||||
promiseAll,
|
||||
} from "../../common"
|
||||
import { buildQuery } from "../../modules-sdk"
|
||||
import {
|
||||
getSoftDeletedCascadedEntitiesIdsMappedBy,
|
||||
@@ -116,6 +126,16 @@ export class MikroOrmBaseRepository<T extends object = object>
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
|
||||
upsertWithReplace(
|
||||
data: unknown[],
|
||||
config: UpsertWithReplaceConfig<T> = {
|
||||
relations: [],
|
||||
},
|
||||
context: Context = {}
|
||||
): Promise<T[]> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
|
||||
async softDelete(
|
||||
idsOrFilter: string[] | InternalFilterQuery,
|
||||
sharedContext: Context = {}
|
||||
@@ -420,6 +440,307 @@ export function mikroOrmBaseRepositoryFactory<T extends object = object>(
|
||||
// TODO return the all, created, updated entities
|
||||
return upsertedEntities
|
||||
}
|
||||
|
||||
// UpsertWithReplace does several things to simplify module implementation.
|
||||
// For each entry of your base entity, it will go through all one-to-many and many-to-many relations, and it will do a diff between what is passed and what is in the database.
|
||||
// For each relation, it create new entries (without an ID), it will associate existing entries (with only an ID), and it will update existing entries (with an ID and other fields).
|
||||
// Finally, it will delete the relation entries that were omitted in the new data.
|
||||
// The response is a POJO of the data that was written to the DB, including all new IDs. The order is preserved with the input.
|
||||
// Limitations: We expect that IDs are used as primary keys, and we don't support composite keys.
|
||||
// We only support 1-level depth of upserts. We don't support custom fields on the many-to-many pivot tables for now
|
||||
async upsertWithReplace(
|
||||
data: any[],
|
||||
config: UpsertWithReplaceConfig<T> = {
|
||||
relations: [],
|
||||
},
|
||||
context: Context = {}
|
||||
): Promise<T[]> {
|
||||
if (!data.length) {
|
||||
return []
|
||||
}
|
||||
// We want to convert a potential ORM model to a POJO
|
||||
const normalizedData: any[] = await this.serialize(data)
|
||||
|
||||
const manager = this.getActiveManager<SqlEntityManager>(context)
|
||||
// Handle the relations
|
||||
const allRelations = manager
|
||||
.getDriver()
|
||||
.getMetadata()
|
||||
.get(entity.name).relations
|
||||
|
||||
const nonexistentRelations = arrayDifference(
|
||||
(config.relations as any) ?? [],
|
||||
allRelations.map((r) => r.name)
|
||||
)
|
||||
|
||||
if (nonexistentRelations.length) {
|
||||
throw new Error(
|
||||
`Nonexistent relations were passed during upsert: ${nonexistentRelations}`
|
||||
)
|
||||
}
|
||||
|
||||
// We want to response with all the data including the IDs in the same order as the input. We also include data that was passed but not processed.
|
||||
const reconstructedResponse: any[] = []
|
||||
const originalDataMap = new Map<string, T>()
|
||||
|
||||
// Create only the top-level entity without the relations first
|
||||
const toUpsert = normalizedData.map((entry) => {
|
||||
// Make a copy of the data and remove undefined fields. The data is already a POJO due to the serialization above
|
||||
const entryCopy = JSON.parse(JSON.stringify(entry))
|
||||
const reconstructedEntry: any = {}
|
||||
|
||||
allRelations?.forEach((relation) => {
|
||||
reconstructedEntry[relation.name] = this.handleRelationAssignment_(
|
||||
relation,
|
||||
entryCopy
|
||||
)
|
||||
})
|
||||
|
||||
const mainEntity = this.getEntityWithId(manager, entity.name, entryCopy)
|
||||
reconstructedResponse.push({ ...mainEntity, ...reconstructedEntry })
|
||||
originalDataMap.set(mainEntity.id, entry)
|
||||
|
||||
return mainEntity
|
||||
})
|
||||
|
||||
const upsertedTopLevelEntities = await this.upsertMany_(
|
||||
manager,
|
||||
entity.name,
|
||||
toUpsert
|
||||
)
|
||||
|
||||
await promiseAll(
|
||||
upsertedTopLevelEntities
|
||||
.map((entityEntry, i) => {
|
||||
const originalEntry = originalDataMap.get((entityEntry as any).id)!
|
||||
const reconstructedEntry = reconstructedResponse[i]
|
||||
|
||||
return allRelations?.map(async (relation) => {
|
||||
const relationName = relation.name as keyof T
|
||||
if (!config.relations?.includes(relationName)) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Handle ONE_TO_ONE
|
||||
// One to one and Many to one are handled outside of the assignment as they need to happen before the main entity is created
|
||||
if (
|
||||
relation.reference === ReferenceType.ONE_TO_ONE ||
|
||||
relation.reference === ReferenceType.MANY_TO_ONE
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
reconstructedEntry[relationName] =
|
||||
await this.assignCollectionRelation_(
|
||||
manager,
|
||||
{ ...originalEntry, id: (entityEntry as any).id },
|
||||
relation
|
||||
)
|
||||
return
|
||||
})
|
||||
})
|
||||
.flat()
|
||||
)
|
||||
|
||||
// // We want to populate the identity map with the data that was written to the DB, and return an entity object
|
||||
// return reconstructedResponse.map((r) =>
|
||||
// manager.create(entity, r, { persist: false })
|
||||
// )
|
||||
|
||||
return reconstructedResponse
|
||||
}
|
||||
|
||||
// FUTURE: We can make this performant by only aggregating the operations, but only executing them at the end.
|
||||
protected async assignCollectionRelation_(
|
||||
manager: SqlEntityManager,
|
||||
data: T,
|
||||
relation: EntityProperty
|
||||
) {
|
||||
const dataForRelation = data[relation.name]
|
||||
// If the field is not set, we ignore it. Null and empty arrays are a valid input and are handled below
|
||||
if (dataForRelation === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Make sure the data is correctly initialized with IDs before using it
|
||||
const normalizedData = dataForRelation.map((normalizedItem) => {
|
||||
return this.getEntityWithId(manager, relation.type, normalizedItem)
|
||||
})
|
||||
|
||||
if (relation.reference === ReferenceType.MANY_TO_MANY) {
|
||||
const currentPivotColumn = relation.inverseJoinColumns[0]
|
||||
const parentPivotColumn = relation.joinColumns[0]
|
||||
|
||||
if (!normalizedData.length) {
|
||||
await manager.nativeDelete(relation.pivotEntity, {
|
||||
[parentPivotColumn]: (data as any).id,
|
||||
})
|
||||
|
||||
return normalizedData
|
||||
}
|
||||
|
||||
// TODO: Currently we will also do an update of the data on the other side of the many-to-many relationship. Reevaluate if we should avoid that.
|
||||
await this.upsertMany_(manager, relation.type, normalizedData)
|
||||
|
||||
const pivotData = normalizedData.map((currModel) => {
|
||||
return {
|
||||
[parentPivotColumn]: (data as any).id,
|
||||
[currentPivotColumn]: currModel.id,
|
||||
}
|
||||
})
|
||||
|
||||
const qb = manager.qb(relation.pivotEntity)
|
||||
await qb.insert(pivotData).onConflict().ignore().execute()
|
||||
|
||||
await manager.nativeDelete(relation.pivotEntity, {
|
||||
[parentPivotColumn]: (data as any).id,
|
||||
[currentPivotColumn]: {
|
||||
$nin: pivotData.map((d) => d[currentPivotColumn]),
|
||||
},
|
||||
})
|
||||
|
||||
return normalizedData
|
||||
}
|
||||
|
||||
if (relation.reference === ReferenceType.ONE_TO_MANY) {
|
||||
const joinColumns =
|
||||
relation.targetMeta?.properties[relation.mappedBy]?.joinColumns
|
||||
|
||||
const joinColumnsConstraints = {}
|
||||
joinColumns?.forEach((joinColumn, index) => {
|
||||
const referencedColumnName = relation.referencedColumnNames[index]
|
||||
joinColumnsConstraints[joinColumn] = data[referencedColumnName]
|
||||
})
|
||||
|
||||
if (normalizedData.length) {
|
||||
normalizedData.forEach((normalizedDataItem: any) => {
|
||||
Object.assign(normalizedDataItem, {
|
||||
...joinColumnsConstraints,
|
||||
})
|
||||
})
|
||||
|
||||
await this.upsertMany_(manager, relation.type, normalizedData)
|
||||
}
|
||||
|
||||
await manager.nativeDelete(relation.type, {
|
||||
...joinColumnsConstraints,
|
||||
id: { $nin: normalizedData.map((d: any) => d.id) },
|
||||
})
|
||||
|
||||
return normalizedData
|
||||
}
|
||||
|
||||
return normalizedData
|
||||
}
|
||||
|
||||
protected handleRelationAssignment_(
|
||||
relation: EntityProperty<any>,
|
||||
entryCopy: T
|
||||
) {
|
||||
const originalData = entryCopy[relation.name]
|
||||
delete entryCopy[relation.name]
|
||||
|
||||
if (originalData === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// If it is a many-to-one we ensure the ID is set for when we want to set/unset an association
|
||||
if (relation.reference === ReferenceType.MANY_TO_ONE) {
|
||||
if (originalData === null) {
|
||||
entryCopy[relation.joinColumns[0]] = null
|
||||
return null
|
||||
}
|
||||
|
||||
// The relation can either be a primitive or the entity object, depending on how it's defined on the model
|
||||
let relationId
|
||||
if (isString(originalData)) {
|
||||
relationId = originalData
|
||||
} else if ("id" in originalData) {
|
||||
relationId = originalData.id
|
||||
}
|
||||
|
||||
// We don't support creating many-to-one relations, so we want to throw if someone doesn't pass the ID
|
||||
if (!relationId) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Many-to-one relation ${relation.name} must be set with an ID`
|
||||
)
|
||||
}
|
||||
|
||||
entryCopy[relation.joinColumns[0]] = relationId
|
||||
return originalData
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Returns a POJO object with the ID populated from the entity model hooks
|
||||
protected getEntityWithId(
|
||||
manager: SqlEntityManager,
|
||||
entityName: string,
|
||||
data: any
|
||||
): Record<string, any> & { id: string } {
|
||||
const created = manager.create(entityName, data, {
|
||||
managed: false,
|
||||
persist: false,
|
||||
})
|
||||
|
||||
return { id: (created as any).id, ...data }
|
||||
}
|
||||
|
||||
protected async upsertManyToOneRelations_(
|
||||
manager: SqlEntityManager,
|
||||
upsertsPerType: Record<string, any[]>
|
||||
) {
|
||||
const typesToUpsert = Object.entries(upsertsPerType)
|
||||
if (!typesToUpsert.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return (
|
||||
await promiseAll(
|
||||
typesToUpsert.map(([type, data]) => {
|
||||
return this.upsertMany_(manager, type, data)
|
||||
})
|
||||
)
|
||||
).flat()
|
||||
}
|
||||
|
||||
protected async upsertMany_(
|
||||
manager: SqlEntityManager,
|
||||
entityName: string,
|
||||
entries: any[]
|
||||
) {
|
||||
const existingEntities: any[] = await manager.find(
|
||||
entityName,
|
||||
{
|
||||
id: { $in: entries.map((d) => d.id) },
|
||||
},
|
||||
{
|
||||
populate: [],
|
||||
disableIdentityMap: true,
|
||||
}
|
||||
)
|
||||
const existingEntitiesMap = new Map(
|
||||
existingEntities.map((e) => [e.id, e])
|
||||
)
|
||||
|
||||
const orderedEntities: T[] = []
|
||||
|
||||
await promiseAll(
|
||||
entries.map(async (data) => {
|
||||
orderedEntities.push(data)
|
||||
const existingEntity = existingEntitiesMap.get(data.id)
|
||||
if (existingEntity) {
|
||||
await manager.nativeUpdate(entityName, { id: data.id }, data)
|
||||
} else {
|
||||
await manager.insert(entityName, data)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return orderedEntities
|
||||
}
|
||||
}
|
||||
|
||||
return MikroOrmAbstractBaseRepository_ as unknown as {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
InjectTransactionManager,
|
||||
MedusaContext,
|
||||
} from "./decorators"
|
||||
import { UpsertWithReplaceConfig } from "@medusajs/types"
|
||||
|
||||
type SelectorAndData = {
|
||||
selector: FilterQuery<any> | BaseFilterable<FilterQuery<any>>
|
||||
@@ -458,6 +459,34 @@ export function internalModuleServiceFactory<
|
||||
)
|
||||
return Array.isArray(data) ? entities : entities[0]
|
||||
}
|
||||
|
||||
upsertWithReplace(
|
||||
data: any[],
|
||||
config?: UpsertWithReplaceConfig<TEntity>,
|
||||
sharedContext?: Context
|
||||
): Promise<TEntity[]>
|
||||
upsertWithReplace(
|
||||
data: any,
|
||||
config?: UpsertWithReplaceConfig<TEntity>,
|
||||
sharedContext?: Context
|
||||
): Promise<TEntity>
|
||||
|
||||
@InjectTransactionManager(propertyRepositoryName)
|
||||
async upsertWithReplace(
|
||||
data: any | any[],
|
||||
config: UpsertWithReplaceConfig<TEntity> = {
|
||||
relations: [],
|
||||
},
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<TEntity | TEntity[]> {
|
||||
const data_ = Array.isArray(data) ? data : [data]
|
||||
const entities = await this[propertyRepositoryName].upsertWithReplace(
|
||||
data_,
|
||||
config,
|
||||
sharedContext
|
||||
)
|
||||
return Array.isArray(data) ? entities : entities[0]
|
||||
}
|
||||
}
|
||||
|
||||
return AbstractService_ as unknown as new <TEntity extends {}>(
|
||||
|
||||
Reference in New Issue
Block a user