feat: Normalize known DB errors to a MedusaError when possible (#6922)
Before we would swallow the error and return a generic error to the user. This will provide more information to the caller if it is one of the known errors.
This commit is contained in:
83
packages/utils/src/dal/mikro-orm/db-error-mapper.ts
Normal file
83
packages/utils/src/dal/mikro-orm/db-error-mapper.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
ForeignKeyConstraintViolationException,
|
||||
InvalidFieldNameException,
|
||||
NotFoundError,
|
||||
NotNullConstraintViolationException,
|
||||
UniqueConstraintViolationException,
|
||||
} from "@mikro-orm/core"
|
||||
import { MedusaError, upperCaseFirst } from "../../common"
|
||||
|
||||
export const dbErrorMapper = (err: Error) => {
|
||||
if (err instanceof NotFoundError) {
|
||||
console.log(err)
|
||||
throw new MedusaError(MedusaError.Types.NOT_FOUND, err.message)
|
||||
}
|
||||
|
||||
if (err instanceof UniqueConstraintViolationException) {
|
||||
const info = getConstraintInfo(err)
|
||||
if (!info) {
|
||||
throw err
|
||||
}
|
||||
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`${upperCaseFirst(info.table)} with ${info.keys
|
||||
.map((key, i) => `${key}: ${info.values[i]}`)
|
||||
.join(", ")} already exists.`
|
||||
)
|
||||
}
|
||||
|
||||
if (err instanceof NotNullConstraintViolationException) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Cannot set field '${(err as any).column}' of ${upperCaseFirst(
|
||||
(err as any).table
|
||||
)} to null`
|
||||
)
|
||||
}
|
||||
|
||||
if (err instanceof InvalidFieldNameException) {
|
||||
const userFriendlyMessage = err.message.match(/(column.*)/)?.[0]
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
userFriendlyMessage ?? err.message
|
||||
)
|
||||
}
|
||||
|
||||
if (err instanceof ForeignKeyConstraintViolationException) {
|
||||
const info = getConstraintInfo(err)
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`You tried to set relationship ${info?.keys.map(
|
||||
(key, i) => `${key}: ${info.values[i]}`
|
||||
)}, but such entity does not exist`
|
||||
)
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
|
||||
const getConstraintInfo = (err: any) => {
|
||||
const detail = err.detail as string
|
||||
if (!detail) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [keys, values] = detail.match(/\([^\(.]*\)/g) || []
|
||||
|
||||
if (!keys || !values) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
table: err.table.split("_").join(" "),
|
||||
keys: keys
|
||||
.substring(1, keys.length - 1)
|
||||
.split(",")
|
||||
.map((k) => k.trim()),
|
||||
values: values
|
||||
.substring(1, values.length - 1)
|
||||
.split(",")
|
||||
.map((v) => v.trim()),
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
OnInit,
|
||||
PrimaryKey,
|
||||
Property,
|
||||
Unique,
|
||||
wrap,
|
||||
} from "@mikro-orm/core"
|
||||
import { mikroOrmBaseRepositoryFactory } from "../../mikro-orm-repository"
|
||||
@@ -101,6 +102,7 @@ class Entity3 {
|
||||
@PrimaryKey()
|
||||
id: string
|
||||
|
||||
@Unique()
|
||||
@Property()
|
||||
title: string
|
||||
|
||||
@@ -124,44 +126,44 @@ const Entity2Repository = mikroOrmBaseRepositoryFactory<Entity2>(Entity2)
|
||||
const Entity3Repository = mikroOrmBaseRepositoryFactory<Entity3>(Entity3)
|
||||
|
||||
describe("mikroOrmRepository", () => {
|
||||
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)
|
||||
})
|
||||
|
||||
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" }
|
||||
|
||||
@@ -585,7 +587,7 @@ describe("mikroOrmRepository", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("should successfully update, create, and delete subentities an entity with a many-to-many relation", async () => {
|
||||
it("should successfully create subentities and delete pivot relationships on a many-to-many relation", async () => {
|
||||
const entity1 = {
|
||||
id: "1",
|
||||
title: "en1",
|
||||
@@ -598,8 +600,11 @@ describe("mikroOrmRepository", () => {
|
||||
let resp = await manager1().upsertWithReplace([entity1], {
|
||||
relations: ["entity3"],
|
||||
})
|
||||
|
||||
entity1.title = "newen1"
|
||||
entity1.entity3 = [{ id: "4", title: "newen3-1" }, { title: "en3-4" }]
|
||||
|
||||
// We don't do many-to-many updates, so id: 4 entity should remain unchanged
|
||||
resp = await manager1().upsertWithReplace([entity1], {
|
||||
relations: ["entity3"],
|
||||
})
|
||||
@@ -619,6 +624,7 @@ describe("mikroOrmRepository", () => {
|
||||
expect(listedEntities[0].entity3.getItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
// title: "en3-1",
|
||||
title: "newen3-1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
@@ -673,8 +679,11 @@ describe("mikroOrmRepository", () => {
|
||||
const mainEntity = await manager1().upsertWithReplace([entity1], {
|
||||
relations: ["entity3"],
|
||||
})
|
||||
|
||||
entity1.title = "newen1"
|
||||
entity1.entity3 = [{ id: "4", title: "newen3-1" }, { title: "en3-4" }]
|
||||
|
||||
// We don't do many-to-many updates, so id: 4 entity should remain unchanged
|
||||
await manager1().upsertWithReplace([entity1], {
|
||||
relations: ["entity3"],
|
||||
})
|
||||
@@ -703,6 +712,7 @@ describe("mikroOrmRepository", () => {
|
||||
expect(listedEntities[0].entity3.getItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
// title: "en3-1",
|
||||
title: "newen3-1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
@@ -774,5 +784,73 @@ describe("mikroOrmRepository", () => {
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
// it("should correctly handle many-to-many upserts with a uniqueness constriant on a non-primary key", async () => {
|
||||
// const entity1 = {
|
||||
// id: "1",
|
||||
// title: "en1",
|
||||
// entity3: [{ title: "en3-1" }, { title: "en3-2" }] as any,
|
||||
// }
|
||||
|
||||
// await manager1().upsertWithReplace([entity1], {
|
||||
// relations: ["entity3"],
|
||||
// })
|
||||
|
||||
// await manager1().upsertWithReplace([{ ...entity1, id: "2" }], {
|
||||
// relations: ["entity3"],
|
||||
// })
|
||||
|
||||
// const listedEntities = await manager1().find({
|
||||
// where: {},
|
||||
// options: { populate: ["entity3"] },
|
||||
// })
|
||||
|
||||
// expect(listedEntities).toHaveLength(2)
|
||||
// expect(listedEntities[0].entity3.getItems()).toEqual(
|
||||
// listedEntities[1].entity3.getItems()
|
||||
// )
|
||||
// })
|
||||
})
|
||||
|
||||
describe("error mapping", () => {
|
||||
it("should map UniqueConstraintViolationException to MedusaError on upsertWithReplace", async () => {
|
||||
const entity3 = { title: "en3" }
|
||||
await manager3().upsertWithReplace([entity3])
|
||||
const err = await manager3()
|
||||
.upsertWithReplace([entity3])
|
||||
.catch((e) => e.message)
|
||||
|
||||
expect(err).toEqual("Entity3 with title: en3 already exists.")
|
||||
})
|
||||
|
||||
it("should map NotNullConstraintViolationException MedusaError on upsertWithReplace", async () => {
|
||||
const entity3 = { title: null }
|
||||
const err = await manager3()
|
||||
.upsertWithReplace([entity3])
|
||||
.catch((e) => e.message)
|
||||
|
||||
expect(err).toEqual("Cannot set field 'title' of Entity3 to null")
|
||||
})
|
||||
|
||||
it("should map InvalidFieldNameException MedusaError on upsertWithReplace", async () => {
|
||||
const entity3 = { othertitle: "en3" }
|
||||
const err = await manager3()
|
||||
.upsertWithReplace([entity3])
|
||||
.catch((e) => e.message)
|
||||
|
||||
expect(err).toEqual(
|
||||
'column "othertitle" of relation "entity3" does not exist'
|
||||
)
|
||||
})
|
||||
it("should map ForeignKeyConstraintViolationException MedusaError on upsertWithReplace", async () => {
|
||||
const entity2 = { title: "en2", entity1: { id: "1" } }
|
||||
const err = await manager2()
|
||||
.upsertWithReplace([entity2])
|
||||
.catch((e) => e.message)
|
||||
|
||||
expect(err).toEqual(
|
||||
"You tried to set relationship entity1_id: 1, but such entity does not exist"
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,7 +27,6 @@ import { SqlEntityManager } from "@mikro-orm/postgresql"
|
||||
import {
|
||||
MedusaError,
|
||||
arrayDifference,
|
||||
deepCopy,
|
||||
isString,
|
||||
promiseAll,
|
||||
} from "../../common"
|
||||
@@ -38,6 +37,7 @@ import {
|
||||
} from "../utils"
|
||||
import { mikroOrmUpdateDeletedAtRecursively } from "./utils"
|
||||
import { mikroOrmSerializer } from "./mikro-orm-serializer"
|
||||
import { dbErrorMapper } from "./db-error-mapper"
|
||||
|
||||
export class MikroOrmBase<T = any> {
|
||||
readonly manager_: any
|
||||
@@ -67,8 +67,13 @@ export class MikroOrmBase<T = any> {
|
||||
transaction?: TManager
|
||||
} = {}
|
||||
): Promise<any> {
|
||||
// @ts-ignore
|
||||
return await transactionWrapper.bind(this)(task, options)
|
||||
const freshManager = this.getFreshManager
|
||||
? this.getFreshManager()
|
||||
: this.manager_
|
||||
|
||||
return await transactionWrapper(freshManager, task, options).catch(
|
||||
dbErrorMapper
|
||||
)
|
||||
}
|
||||
|
||||
async serialize<TOutput extends object | object[]>(
|
||||
@@ -262,6 +267,23 @@ export function mikroOrmBaseRepositoryFactory<T extends object = object>(
|
||||
constructor(...args: any[]) {
|
||||
// @ts-ignore
|
||||
super(...arguments)
|
||||
|
||||
return new Proxy(this, {
|
||||
get: (target, prop) => {
|
||||
if (typeof target[prop] === "function") {
|
||||
return (...args) => {
|
||||
const res = target[prop].bind(target)(...args)
|
||||
if (res instanceof Promise) {
|
||||
return res.catch(dbErrorMapper)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
return target[prop]
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
static buildUniqueCompositeKeyValue(keys: string[], data: object) {
|
||||
@@ -474,7 +496,8 @@ export function mikroOrmBaseRepositoryFactory<T extends object = object>(
|
||||
)
|
||||
|
||||
if (nonexistentRelations.length) {
|
||||
throw new Error(
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Nonexistent relations were passed during upsert: ${nonexistentRelations}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isObject } from "../common"
|
||||
|
||||
export async function transactionWrapper<TManager = unknown>(
|
||||
this: any,
|
||||
task: (transactionManager: unknown) => Promise<any>,
|
||||
manager: any,
|
||||
task: (transactionManager: any) => Promise<any>,
|
||||
{
|
||||
transaction,
|
||||
isolationLevel,
|
||||
@@ -28,12 +28,8 @@ export async function transactionWrapper<TManager = unknown>(
|
||||
Object.assign(options, { isolationLevel })
|
||||
}
|
||||
|
||||
const freshManager = this.getFreshManager
|
||||
? this.getFreshManager()
|
||||
: this.manager_
|
||||
const transactionMethod =
|
||||
freshManager.transaction ?? freshManager.transactional
|
||||
return await transactionMethod.bind(freshManager)(task, options)
|
||||
const transactionMethod = manager.transaction ?? manager.transactional
|
||||
return await transactionMethod.bind(manager)(task, options)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user