feat(utils): custom serialization that allows for non self ref (#6836)
* feat(utils): custom serialization that allows for non self ref * Create fast-teachers-greet.md * cleanup + tests * cleanup + tests * fix import * fix map to pk relations
This commit is contained in:
committed by
GitHub
parent
4cf71af07d
commit
e0b02a1012
5
.changeset/fast-teachers-greet.md
Normal file
5
.changeset/fast-teachers-greet.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@medusajs/utils": patch
|
||||
---
|
||||
|
||||
feat(utils): custom serialization that allows for non self ref
|
||||
@@ -2,6 +2,7 @@ export * from "./mikro-orm/big-number-field"
|
||||
export * from "./mikro-orm/mikro-orm-create-connection"
|
||||
export * from "./mikro-orm/mikro-orm-repository"
|
||||
export * from "./mikro-orm/mikro-orm-soft-deletable-filter"
|
||||
export * from "./mikro-orm/mikro-orm-serializer"
|
||||
export * from "./mikro-orm/utils"
|
||||
export * from "./repositories"
|
||||
export * from "./utils"
|
||||
|
||||
@@ -82,6 +82,7 @@ class Entity2 {
|
||||
this.id = props.id
|
||||
this.deleted_at = props.deleted_at
|
||||
this.entity1 = props.entity1
|
||||
this.entity1_id = props.entity1.id
|
||||
}
|
||||
|
||||
@PrimaryKey()
|
||||
@@ -90,7 +91,10 @@ class Entity2 {
|
||||
@Property()
|
||||
deleted_at: Date | null
|
||||
|
||||
@ManyToOne(() => Entity1)
|
||||
@ManyToOne(() => Entity1, { mapToPk: true })
|
||||
entity1_id: string
|
||||
|
||||
@ManyToOne(() => Entity1, { persist: false })
|
||||
entity1: Entity1
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { MikroORM } from "@mikro-orm/core"
|
||||
import { Entity1, Entity2 } from "../__fixtures__/utils"
|
||||
import { mikroOrmSerializer } from "../mikro-orm-serializer"
|
||||
|
||||
describe("mikroOrmSerializer", () => {
|
||||
beforeEach(async () => {
|
||||
await MikroORM.init({
|
||||
entities: [Entity1, Entity2],
|
||||
dbName: "test",
|
||||
type: "postgresql",
|
||||
})
|
||||
})
|
||||
|
||||
it("should serialize an entity", async () => {
|
||||
const entity1 = new Entity1({ id: "1", deleted_at: null })
|
||||
const entity2 = new Entity2({
|
||||
id: "2",
|
||||
deleted_at: null,
|
||||
entity1: entity1,
|
||||
})
|
||||
entity1.entity2.add(entity2)
|
||||
|
||||
const serialized = await mikroOrmSerializer(entity1, {
|
||||
preventCircularRef: false,
|
||||
})
|
||||
|
||||
expect(serialized).toEqual({
|
||||
id: "1",
|
||||
deleted_at: null,
|
||||
entity2: [
|
||||
{
|
||||
id: "2",
|
||||
deleted_at: null,
|
||||
entity1: {
|
||||
id: "1",
|
||||
deleted_at: null,
|
||||
},
|
||||
entity1_id: "1",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("should serialize an array of entities", async () => {
|
||||
const entity1 = new Entity1({ id: "1", deleted_at: null })
|
||||
const entity2 = new Entity2({
|
||||
id: "2",
|
||||
deleted_at: null,
|
||||
entity1: entity1,
|
||||
})
|
||||
entity1.entity2.add(entity2)
|
||||
|
||||
const serialized = await mikroOrmSerializer([entity1, entity1], {
|
||||
preventCircularRef: false,
|
||||
})
|
||||
|
||||
const expectation = {
|
||||
id: "1",
|
||||
deleted_at: null,
|
||||
entity2: [
|
||||
{
|
||||
id: "2",
|
||||
deleted_at: null,
|
||||
entity1: {
|
||||
id: "1",
|
||||
deleted_at: null,
|
||||
},
|
||||
entity1_id: "1",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
expect(serialized).toEqual([expectation, expectation])
|
||||
})
|
||||
|
||||
it("should serialize an entity preventing circular relation reference", async () => {
|
||||
const entity1 = new Entity1({ id: "1", deleted_at: null })
|
||||
const entity2 = new Entity2({
|
||||
id: "2",
|
||||
deleted_at: null,
|
||||
entity1: entity1,
|
||||
})
|
||||
entity1.entity2.add(entity2)
|
||||
|
||||
const serialized = await mikroOrmSerializer(entity1)
|
||||
|
||||
expect(serialized).toEqual({
|
||||
id: "1",
|
||||
deleted_at: null,
|
||||
entity2: [
|
||||
{
|
||||
id: "2",
|
||||
deleted_at: null,
|
||||
entity1_id: "1",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -26,7 +26,8 @@ import {
|
||||
getSoftDeletedCascadedEntitiesIdsMappedBy,
|
||||
transactionWrapper,
|
||||
} from "../utils"
|
||||
import { mikroOrmSerializer, mikroOrmUpdateDeletedAtRecursively } from "./utils"
|
||||
import { mikroOrmUpdateDeletedAtRecursively } from "./utils"
|
||||
import { mikroOrmSerializer } from "./mikro-orm-serializer"
|
||||
|
||||
export class MikroOrmBase<T = any> {
|
||||
readonly manager_: any
|
||||
|
||||
394
packages/utils/src/dal/mikro-orm/mikro-orm-serializer.ts
Normal file
394
packages/utils/src/dal/mikro-orm/mikro-orm-serializer.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import {
|
||||
Collection,
|
||||
EntityDTO,
|
||||
EntityMetadata,
|
||||
helper,
|
||||
IPrimaryKey,
|
||||
Loaded,
|
||||
Platform,
|
||||
Reference,
|
||||
ReferenceType,
|
||||
SerializationContext,
|
||||
Utils,
|
||||
} from "@mikro-orm/core"
|
||||
import { SerializeOptions } from "@mikro-orm/core/serialization/EntitySerializer"
|
||||
|
||||
function isVisible<T extends object>(
|
||||
meta: EntityMetadata<T>,
|
||||
propName: string,
|
||||
options: SerializeOptions<T, any> & { preventCircularRef?: boolean } = {}
|
||||
): boolean {
|
||||
if (options.populate === true) {
|
||||
return options.populate
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(options.populate) &&
|
||||
options.populate?.find(
|
||||
(item) => item === propName || item.startsWith(propName + ".")
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (options.exclude?.find((item) => item === propName)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const prop = meta.properties[propName]
|
||||
const visible = prop && !prop.hidden
|
||||
const prefixed = prop && !prop.primary && propName.startsWith("_") // ignore prefixed properties, if it's not a PK
|
||||
|
||||
return visible && !prefixed
|
||||
}
|
||||
|
||||
function isPopulated<T extends object>(
|
||||
entity: T,
|
||||
propName: string,
|
||||
options: SerializeOptions<T, any>
|
||||
): boolean {
|
||||
if (
|
||||
typeof options.populate !== "boolean" &&
|
||||
options.populate?.find(
|
||||
(item) => item === propName || item.startsWith(propName + ".")
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (typeof options.populate === "boolean") {
|
||||
return options.populate
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer property filtering for the serialization which takes into account the parent entity to filter out circular references if configured for.
|
||||
* @param prop
|
||||
* @param meta
|
||||
* @param options
|
||||
* @param parent
|
||||
*/
|
||||
function filterEntityPropToSerialize(
|
||||
prop: string,
|
||||
meta: EntityMetadata,
|
||||
options: SerializeOptions<object, any> & {
|
||||
preventCircularRef?: boolean
|
||||
} = {},
|
||||
parent?: object
|
||||
): boolean {
|
||||
const isVisibleRes = isVisible(meta, prop, options)
|
||||
if (options.preventCircularRef && isVisibleRes && parent) {
|
||||
return (
|
||||
// mapToPk would represent a foreign key and we want to keep them
|
||||
meta.properties[prop].mapToPk ||
|
||||
parent.constructor.name !== meta.properties[prop].type
|
||||
)
|
||||
}
|
||||
return isVisibleRes
|
||||
}
|
||||
|
||||
export class EntitySerializer {
|
||||
static serialize<T extends object, P extends string = never>(
|
||||
entity: T,
|
||||
options: SerializeOptions<T, P> & { preventCircularRef?: boolean } = {},
|
||||
parent?: object
|
||||
): EntityDTO<Loaded<T, P>> {
|
||||
const wrapped = helper(entity)
|
||||
const meta = wrapped.__meta
|
||||
let contextCreated = false
|
||||
|
||||
if (!wrapped.__serializationContext.root) {
|
||||
const root = new SerializationContext<T>()
|
||||
SerializationContext.propagate(
|
||||
root,
|
||||
entity,
|
||||
(meta, prop) =>
|
||||
meta.properties[prop]?.reference !== ReferenceType.SCALAR
|
||||
)
|
||||
contextCreated = true
|
||||
}
|
||||
|
||||
const root = wrapped.__serializationContext.root!
|
||||
const ret = {} as EntityDTO<Loaded<T, P>>
|
||||
const keys = new Set<string>(meta.primaryKeys)
|
||||
Object.keys(entity).forEach((prop) => keys.add(prop))
|
||||
const visited = root.visited.has(entity)
|
||||
|
||||
if (!visited) {
|
||||
root.visited.add(entity)
|
||||
}
|
||||
|
||||
;[...keys]
|
||||
/** Medusa Custom properties filtering **/
|
||||
.filter((prop) =>
|
||||
filterEntityPropToSerialize(prop, meta, options, parent)
|
||||
)
|
||||
.map((prop) => {
|
||||
const cycle = root.visit(meta.className, prop)
|
||||
|
||||
if (cycle && visited) {
|
||||
return [prop, undefined]
|
||||
}
|
||||
|
||||
const val = this.processProperty<T>(
|
||||
prop as keyof T & string,
|
||||
entity,
|
||||
options
|
||||
)
|
||||
|
||||
if (!cycle) {
|
||||
root.leave(meta.className, prop)
|
||||
}
|
||||
|
||||
if (options.skipNull && Utils.isPlainObject(val)) {
|
||||
Utils.dropUndefinedProperties(val, null)
|
||||
}
|
||||
|
||||
return [prop, val]
|
||||
})
|
||||
.filter(
|
||||
([, value]) =>
|
||||
typeof value !== "undefined" && !(value === null && options.skipNull)
|
||||
)
|
||||
.forEach(
|
||||
([prop, value]) =>
|
||||
(ret[
|
||||
this.propertyName(
|
||||
meta,
|
||||
prop as keyof T & string,
|
||||
wrapped.__platform
|
||||
)
|
||||
] = value as T[keyof T & string])
|
||||
)
|
||||
|
||||
if (contextCreated) {
|
||||
root.close()
|
||||
}
|
||||
|
||||
if (!wrapped.isInitialized()) {
|
||||
return ret
|
||||
}
|
||||
|
||||
// decorated getters
|
||||
meta.props
|
||||
.filter(
|
||||
(prop) =>
|
||||
prop.getter &&
|
||||
prop.getterName === undefined &&
|
||||
typeof entity[prop.name] !== "undefined" &&
|
||||
isVisible(meta, prop.name, options)
|
||||
)
|
||||
.forEach(
|
||||
(prop) =>
|
||||
(ret[this.propertyName(meta, prop.name, wrapped.__platform)] =
|
||||
this.processProperty(prop.name, entity, options))
|
||||
)
|
||||
|
||||
// decorated get methods
|
||||
meta.props
|
||||
.filter(
|
||||
(prop) =>
|
||||
prop.getterName &&
|
||||
(entity[prop.getterName] as unknown) instanceof Function &&
|
||||
isVisible(meta, prop.name, options)
|
||||
)
|
||||
.forEach(
|
||||
(prop) =>
|
||||
(ret[this.propertyName(meta, prop.name, wrapped.__platform)] =
|
||||
this.processProperty(
|
||||
prop.getterName as keyof T & string,
|
||||
entity,
|
||||
options
|
||||
))
|
||||
)
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
private static propertyName<T>(
|
||||
meta: EntityMetadata<T>,
|
||||
prop: keyof T & string,
|
||||
platform?: Platform
|
||||
): string {
|
||||
/* istanbul ignore next */
|
||||
if (meta.properties[prop]?.serializedName) {
|
||||
return meta.properties[prop].serializedName as keyof T & string
|
||||
}
|
||||
|
||||
if (meta.properties[prop]?.primary && platform) {
|
||||
return platform.getSerializedPrimaryKeyField(prop) as keyof T & string
|
||||
}
|
||||
|
||||
return prop
|
||||
}
|
||||
|
||||
private static processProperty<T extends object>(
|
||||
prop: keyof T & string,
|
||||
entity: T,
|
||||
options: SerializeOptions<T, any>
|
||||
): T[keyof T] | undefined {
|
||||
const parts = prop.split(".")
|
||||
prop = parts[0] as string & keyof T
|
||||
const wrapped = helper(entity)
|
||||
const property = wrapped.__meta.properties[prop]
|
||||
const serializer = property?.serializer
|
||||
|
||||
// getter method
|
||||
if ((entity[prop] as unknown) instanceof Function) {
|
||||
const returnValue = (
|
||||
entity[prop] as unknown as () => T[keyof T & string]
|
||||
)()
|
||||
if (!options.ignoreSerializers && serializer) {
|
||||
return serializer(returnValue)
|
||||
}
|
||||
return returnValue
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (!options.ignoreSerializers && serializer) {
|
||||
return serializer(entity[prop])
|
||||
}
|
||||
|
||||
if (Utils.isCollection(entity[prop])) {
|
||||
return this.processCollection(prop, entity, options)
|
||||
}
|
||||
|
||||
if (Utils.isEntity(entity[prop], true)) {
|
||||
return this.processEntity(prop, entity, wrapped.__platform, options)
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (property?.reference === ReferenceType.EMBEDDED) {
|
||||
if (Array.isArray(entity[prop])) {
|
||||
return (entity[prop] as object[]).map((item) =>
|
||||
helper(item).toJSON()
|
||||
) as T[keyof T]
|
||||
}
|
||||
|
||||
if (Utils.isObject(entity[prop])) {
|
||||
return helper(entity[prop]).toJSON() as T[keyof T]
|
||||
}
|
||||
}
|
||||
|
||||
const customType = property?.customType
|
||||
|
||||
if (customType) {
|
||||
return customType.toJSON(entity[prop], wrapped.__platform)
|
||||
}
|
||||
|
||||
return wrapped.__platform.normalizePrimaryKey(
|
||||
entity[prop] as unknown as IPrimaryKey
|
||||
) as unknown as T[keyof T]
|
||||
}
|
||||
|
||||
private static extractChildOptions<T extends object, U extends object>(
|
||||
options: SerializeOptions<T, any>,
|
||||
prop: keyof T & string
|
||||
): SerializeOptions<U, any> {
|
||||
const extractChildElements = (items: string[]) => {
|
||||
return items
|
||||
.filter((field) => field.startsWith(`${prop}.`))
|
||||
.map((field) => field.substring(prop.length + 1))
|
||||
}
|
||||
|
||||
return {
|
||||
...options,
|
||||
populate: Array.isArray(options.populate)
|
||||
? extractChildElements(options.populate)
|
||||
: options.populate,
|
||||
exclude: Array.isArray(options.exclude)
|
||||
? extractChildElements(options.exclude)
|
||||
: options.exclude,
|
||||
} as SerializeOptions<U, any>
|
||||
}
|
||||
|
||||
private static processEntity<T extends object>(
|
||||
prop: keyof T & string,
|
||||
entity: T,
|
||||
platform: Platform,
|
||||
options: SerializeOptions<T, any>
|
||||
): T[keyof T] | undefined {
|
||||
const child = Reference.unwrapReference(entity[prop] as T)
|
||||
const wrapped = helper(child)
|
||||
const populated =
|
||||
isPopulated(child, prop, options) && wrapped.isInitialized()
|
||||
const expand = populated || options.forceObject || !wrapped.__managed
|
||||
|
||||
if (expand) {
|
||||
return this.serialize(
|
||||
child,
|
||||
this.extractChildOptions(options, prop),
|
||||
/** passing the entity as the parent for circular filtering **/
|
||||
entity
|
||||
) as T[keyof T]
|
||||
}
|
||||
|
||||
return platform.normalizePrimaryKey(
|
||||
wrapped.getPrimaryKey() as IPrimaryKey
|
||||
) as T[keyof T]
|
||||
}
|
||||
|
||||
private static processCollection<T extends object>(
|
||||
prop: keyof T & string,
|
||||
entity: T,
|
||||
options: SerializeOptions<T, any>
|
||||
): T[keyof T] | undefined {
|
||||
const col = entity[prop] as unknown as Collection<T>
|
||||
|
||||
if (!col.isInitialized()) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return col.getItems(false).map((item) => {
|
||||
if (isPopulated(item, prop, options)) {
|
||||
return this.serialize(
|
||||
item,
|
||||
this.extractChildOptions(options, prop),
|
||||
/** passing the entity as the parent for circular filtering **/
|
||||
entity
|
||||
)
|
||||
}
|
||||
|
||||
return helper(item).getPrimaryKey()
|
||||
}) as unknown as T[keyof T]
|
||||
}
|
||||
}
|
||||
|
||||
export const mikroOrmSerializer = <TOutput extends object>(
|
||||
data: any,
|
||||
options?: Parameters<typeof EntitySerializer.serialize>[1] & {
|
||||
preventCircularRef?: boolean
|
||||
}
|
||||
): TOutput => {
|
||||
options ??= {}
|
||||
|
||||
const data_ = (Array.isArray(data) ? data : [data]).filter(Boolean)
|
||||
|
||||
const forSerialization: unknown[] = []
|
||||
const notForSerialization: unknown[] = []
|
||||
|
||||
data_.forEach((object) => {
|
||||
if (object.__meta) {
|
||||
return forSerialization.push(object)
|
||||
}
|
||||
|
||||
return notForSerialization.push(object)
|
||||
})
|
||||
|
||||
let result: any = forSerialization.map((entity) =>
|
||||
EntitySerializer.serialize(entity, {
|
||||
forceObject: true,
|
||||
populate: true,
|
||||
preventCircularRef: true,
|
||||
...options,
|
||||
} as SerializeOptions<any, any>)
|
||||
) as TOutput[]
|
||||
|
||||
if (notForSerialization.length) {
|
||||
result = result.concat(notForSerialization)
|
||||
}
|
||||
|
||||
return Array.isArray(data) ? result : result[0]
|
||||
}
|
||||
@@ -142,36 +142,3 @@ export const mikroOrmUpdateDeletedAtRecursively = async <
|
||||
await performCascadingSoftDeletion(manager, entity, value)
|
||||
}
|
||||
}
|
||||
|
||||
export const mikroOrmSerializer = async <TOutput extends object>(
|
||||
data: any,
|
||||
options?: any
|
||||
): Promise<TOutput> => {
|
||||
options ??= {}
|
||||
|
||||
const data_ = (Array.isArray(data) ? data : [data]).filter(Boolean)
|
||||
|
||||
const forSerialization: unknown[] = []
|
||||
const notForSerialization: unknown[] = []
|
||||
|
||||
data_.forEach((object) => {
|
||||
if (object.__meta) {
|
||||
return forSerialization.push(object)
|
||||
}
|
||||
|
||||
return notForSerialization.push(object)
|
||||
})
|
||||
|
||||
const { serialize } = await import("@mikro-orm/core")
|
||||
let result: any = serialize(forSerialization, {
|
||||
forceObject: true,
|
||||
populate: true,
|
||||
...options,
|
||||
}) as TOutput[]
|
||||
|
||||
if (notForSerialization.length) {
|
||||
result = result.concat(notForSerialization)
|
||||
}
|
||||
|
||||
return Array.isArray(data) ? result : result[0]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user