From e0b02a1012981c29830d7779f59ebe805bbfd137 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Wed, 27 Mar 2024 12:31:04 +0100 Subject: [PATCH] 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 --- .changeset/fast-teachers-greet.md | 5 + packages/utils/src/dal/index.ts | 1 + .../src/dal/mikro-orm/__fixtures__/utils.ts | 6 +- .../__tests__/mikro-orm-serializer.spec.ts | 99 +++++ .../src/dal/mikro-orm/mikro-orm-repository.ts | 3 +- .../src/dal/mikro-orm/mikro-orm-serializer.ts | 394 ++++++++++++++++++ packages/utils/src/dal/mikro-orm/utils.ts | 33 -- 7 files changed, 506 insertions(+), 35 deletions(-) create mode 100644 .changeset/fast-teachers-greet.md create mode 100644 packages/utils/src/dal/mikro-orm/__tests__/mikro-orm-serializer.spec.ts create mode 100644 packages/utils/src/dal/mikro-orm/mikro-orm-serializer.ts diff --git a/.changeset/fast-teachers-greet.md b/.changeset/fast-teachers-greet.md new file mode 100644 index 0000000000..f0cbdb50f3 --- /dev/null +++ b/.changeset/fast-teachers-greet.md @@ -0,0 +1,5 @@ +--- +"@medusajs/utils": patch +--- + +feat(utils): custom serialization that allows for non self ref diff --git a/packages/utils/src/dal/index.ts b/packages/utils/src/dal/index.ts index 95092cd0a3..d4f1b360df 100644 --- a/packages/utils/src/dal/index.ts +++ b/packages/utils/src/dal/index.ts @@ -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" diff --git a/packages/utils/src/dal/mikro-orm/__fixtures__/utils.ts b/packages/utils/src/dal/mikro-orm/__fixtures__/utils.ts index 5065c13a5e..d256dde8ca 100644 --- a/packages/utils/src/dal/mikro-orm/__fixtures__/utils.ts +++ b/packages/utils/src/dal/mikro-orm/__fixtures__/utils.ts @@ -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 } diff --git a/packages/utils/src/dal/mikro-orm/__tests__/mikro-orm-serializer.spec.ts b/packages/utils/src/dal/mikro-orm/__tests__/mikro-orm-serializer.spec.ts new file mode 100644 index 0000000000..462ca803bf --- /dev/null +++ b/packages/utils/src/dal/mikro-orm/__tests__/mikro-orm-serializer.spec.ts @@ -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", + }, + ], + }) + }) +}) diff --git a/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts b/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts index 66296b3104..b68ba2b0eb 100644 --- a/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts +++ b/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts @@ -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 { readonly manager_: any diff --git a/packages/utils/src/dal/mikro-orm/mikro-orm-serializer.ts b/packages/utils/src/dal/mikro-orm/mikro-orm-serializer.ts new file mode 100644 index 0000000000..75ea666d11 --- /dev/null +++ b/packages/utils/src/dal/mikro-orm/mikro-orm-serializer.ts @@ -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( + meta: EntityMetadata, + propName: string, + options: SerializeOptions & { 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( + entity: T, + propName: string, + options: SerializeOptions +): 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 & { + 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( + entity: T, + options: SerializeOptions & { preventCircularRef?: boolean } = {}, + parent?: object + ): EntityDTO> { + const wrapped = helper(entity) + const meta = wrapped.__meta + let contextCreated = false + + if (!wrapped.__serializationContext.root) { + const root = new SerializationContext() + SerializationContext.propagate( + root, + entity, + (meta, prop) => + meta.properties[prop]?.reference !== ReferenceType.SCALAR + ) + contextCreated = true + } + + const root = wrapped.__serializationContext.root! + const ret = {} as EntityDTO> + const keys = new Set(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( + 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( + meta: EntityMetadata, + 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( + prop: keyof T & string, + entity: T, + options: SerializeOptions + ): 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( + options: SerializeOptions, + prop: keyof T & string + ): SerializeOptions { + 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 + } + + private static processEntity( + prop: keyof T & string, + entity: T, + platform: Platform, + options: SerializeOptions + ): 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( + prop: keyof T & string, + entity: T, + options: SerializeOptions + ): T[keyof T] | undefined { + const col = entity[prop] as unknown as Collection + + 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 = ( + data: any, + options?: Parameters[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) + ) as TOutput[] + + if (notForSerialization.length) { + result = result.concat(notForSerialization) + } + + return Array.isArray(data) ? result : result[0] +} diff --git a/packages/utils/src/dal/mikro-orm/utils.ts b/packages/utils/src/dal/mikro-orm/utils.ts index 693d8dda31..a80c5b80d3 100644 --- a/packages/utils/src/dal/mikro-orm/utils.ts +++ b/packages/utils/src/dal/mikro-orm/utils.ts @@ -142,36 +142,3 @@ export const mikroOrmUpdateDeletedAtRecursively = async < await performCascadingSoftDeletion(manager, entity, value) } } - -export const mikroOrmSerializer = async ( - data: any, - options?: any -): Promise => { - 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] -}