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:
Adrien de Peretti
2024-03-27 12:31:04 +01:00
committed by GitHub
parent 4cf71af07d
commit e0b02a1012
7 changed files with 506 additions and 35 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/utils": patch
---
feat(utils): custom serialization that allows for non self ref

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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",
},
],
})
})
})

View File

@@ -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

View 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]
}

View File

@@ -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]
}