chore: improve mikro orm serializer circular ref and link serialization (#9411)

This commit is contained in:
Adrien de Peretti
2024-10-03 08:22:11 +02:00
committed by GitHub
parent 2d1cf12dac
commit 225d00cd09
12 changed files with 142 additions and 105 deletions

View File

@@ -1277,16 +1277,10 @@ medusaIntegrationTestRunner({
expect.objectContaining({ expect.objectContaining({
id: expect.stringMatching(/^optval_*/), id: expect.stringMatching(/^optval_*/),
value: "large", value: "large",
option: expect.objectContaining({
title: "size",
}),
}), }),
expect.objectContaining({ expect.objectContaining({
id: expect.stringMatching(/^optval_*/), id: expect.stringMatching(/^optval_*/),
value: "green", value: "green",
option: expect.objectContaining({
title: "color",
}),
}), }),
]), ]),
}), }),
@@ -1557,9 +1551,6 @@ medusaIntegrationTestRunner({
expect.objectContaining({ expect.objectContaining({
id: expect.stringMatching(/^optval_*/), id: expect.stringMatching(/^optval_*/),
value: "large", value: "large",
option: expect.objectContaining({
title: "size",
}),
}), }),
]), ]),
origin_country: null, origin_country: null,
@@ -2660,9 +2651,6 @@ medusaIntegrationTestRunner({
updatedProduct.variants.find((v) => v.id === baseVariant.id).options updatedProduct.variants.find((v) => v.id === baseVariant.id).options
).toEqual([ ).toEqual([
expect.objectContaining({ expect.objectContaining({
option: expect.objectContaining({
title: "size",
}),
value: "small", value: "small",
}), }),
]) ])
@@ -2692,15 +2680,9 @@ medusaIntegrationTestRunner({
expect(updatedOptions).toEqual( expect(updatedOptions).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
option: expect.objectContaining({
title: "size",
}),
value: "small", value: "small",
}), }),
expect.objectContaining({ expect.objectContaining({
option: expect.objectContaining({
title: "color",
}),
value: "green", value: "green",
}), }),
]) ])

View File

@@ -1,8 +1,8 @@
import { import {
RegionTypes,
BigNumberInput, BigNumberInput,
HttpTypes, HttpTypes,
PricingTypes, PricingTypes,
RegionTypes,
} from "@medusajs/framework/types" } from "@medusajs/framework/types"
import { MedusaError, upperCaseFirst } from "@medusajs/framework/utils" import { MedusaError, upperCaseFirst } from "@medusajs/framework/utils"
@@ -24,7 +24,7 @@ export const normalizeForExport = (
variants.forEach((v) => { variants.forEach((v) => {
const toPush = { const toPush = {
...normalizeProductForExport(product), ...normalizeProductForExport(product),
...normalizeVariantForExport(v, regionsMap), ...normalizeVariantForExport(v, regionsMap, product),
} as any } as any
delete toPush["Product Variants"] delete toPush["Product Variants"]
@@ -101,7 +101,8 @@ const normalizeVariantForExport = (
variant: HttpTypes.AdminProductVariant & { variant: HttpTypes.AdminProductVariant & {
price_set?: PricingTypes.PriceSetDTO price_set?: PricingTypes.PriceSetDTO
}, },
regionsMap: Map<string, RegionTypes.RegionDTO> regionsMap: Map<string, RegionTypes.RegionDTO>,
product: HttpTypes.AdminProduct
): object => { ): object => {
const flattenedPrices = variant.price_set?.prices const flattenedPrices = variant.price_set?.prices
?.sort((a, b) => b.currency_code!.localeCompare(a.currency_code!)) ?.sort((a, b) => b.currency_code!.localeCompare(a.currency_code!))
@@ -133,9 +134,14 @@ const normalizeVariantForExport = (
return acc return acc
}, {}) }, {})
const options = product.options ?? []
const flattenedOptions = variant.options?.reduce( const flattenedOptions = variant.options?.reduce(
(acc: Record<string, string>, option, idx) => { (acc: Record<string, string>, option, idx) => {
acc[beautifyKey(`variant_option_${idx + 1}_name`)] = option.option?.title! const prodOptions = options.find(
(prodOption) => prodOption.id === option.option_id
)
acc[beautifyKey(`variant_option_${idx + 1}_name`)] = prodOptions?.title!
acc[beautifyKey(`variant_option_${idx + 1}_value`)] = option.value acc[beautifyKey(`variant_option_${idx + 1}_value`)] = option.value
return acc return acc
}, },

View File

@@ -36,6 +36,7 @@ export const getAllProductsStep = createStep(
}, },
fields: data.select, fields: data.select,
}) })
allProducts.push(...products) allProducts.push(...products)
if (products.length < pageSize) { if (products.length < pageSize) {

View File

@@ -8,8 +8,8 @@ import {
Platform, Platform,
Reference, Reference,
ReferenceType, ReferenceType,
SerializeOptions,
SerializationContext, SerializationContext,
SerializeOptions,
Utils, Utils,
} from "@mikro-orm/core" } from "@mikro-orm/core"
@@ -64,20 +64,26 @@ function isPopulated<T extends object>(
} }
/** /**
* Customer property filtering for the serialization which takes into account the parent entity to filter out circular references if configured for. * Custom property filtering for the serialization which takes into account circular references to not return them.
* @param propName * @param propName
* @param meta * @param meta
* @param options * @param options
* @param parent * @param parents
*/ */
function filterEntityPropToSerialize( function filterEntityPropToSerialize({
propName: string, propName,
meta: EntityMetadata, meta,
options,
parents,
}: {
propName: string
meta: EntityMetadata
options: SerializeOptions<object, any> & { options: SerializeOptions<object, any> & {
preventCircularRef?: boolean preventCircularRef?: boolean
} = {}, }
parent?: object parents?: string[]
): boolean { }): boolean {
parents ??= []
const isVisibleRes = isVisible(meta, propName, options) const isVisibleRes = isVisible(meta, propName, options)
const prop = meta.properties[propName] const prop = meta.properties[propName]
@@ -86,12 +92,16 @@ function filterEntityPropToSerialize(
prop && prop &&
options.preventCircularRef && options.preventCircularRef &&
isVisibleRes && isVisibleRes &&
parent &&
prop.reference !== ReferenceType.SCALAR prop.reference !== ReferenceType.SCALAR
) { ) {
// mapToPk would represent a foreign key and we want to keep them // mapToPk would represent a foreign key and we want to keep them
return !!prop.mapToPk || parent.constructor.name !== prop.type if (!!prop.mapToPk) {
return true
}
return !parents.some((parent) => parent === prop.type)
} }
return isVisibleRes return isVisibleRes
} }
@@ -99,8 +109,10 @@ export class EntitySerializer {
static serialize<T extends object, P extends string = never>( static serialize<T extends object, P extends string = never>(
entity: T, entity: T,
options: SerializeOptions<T, P> & { preventCircularRef?: boolean } = {}, options: SerializeOptions<T, P> & { preventCircularRef?: boolean } = {},
parent?: object parents: string[] = []
): EntityDTO<Loaded<T, P>> { ): EntityDTO<Loaded<T, P>> {
const parents_ = Array.from(new Set(parents))
const wrapped = helper(entity) const wrapped = helper(entity)
const meta = wrapped.__meta const meta = wrapped.__meta
let contextCreated = false let contextCreated = false
@@ -116,20 +128,39 @@ export class EntitySerializer {
contextCreated = true contextCreated = true
} }
const root = wrapped.__serializationContext.root! const root = wrapped.__serializationContext
.root! as SerializationContext<any> & {
visitedSerialized?: Map<string, any>
}
const ret = {} as EntityDTO<Loaded<T, P>> const ret = {} as EntityDTO<Loaded<T, P>>
const keys = new Set<string>(meta.primaryKeys) const keys = new Set<string>(meta.primaryKeys)
Object.keys(entity).forEach((prop) => keys.add(prop)) Object.keys(entity).forEach((prop) => keys.add(prop))
const visited = root.visited.has(entity)
const visited = root.visited.has(entity)
if (!visited) { if (!visited) {
root.visited.add(entity) root.visited.add(entity)
} }
// Virtually augment the serialization context
root.visitedSerialized ??= new Map()
const primaryKeysValues = Array.from(keys)
.map((key) => entity[key])
.join("-")
if (root.visitedSerialized.has(primaryKeysValues)) {
return root.visitedSerialized.get(primaryKeysValues)
}
;[...keys] ;[...keys]
/** Medusa Custom properties filtering **/ /** Medusa Custom properties filtering **/
.filter((prop) => .filter((prop) =>
filterEntityPropToSerialize(prop, meta, options, parent) filterEntityPropToSerialize({
propName: prop,
meta,
options,
parents: parents_,
})
) )
.map((prop) => { .map((prop) => {
const cycle = root.visit(meta.className, prop) const cycle = root.visit(meta.className, prop)
@@ -141,7 +172,8 @@ export class EntitySerializer {
const val = this.processProperty<T>( const val = this.processProperty<T>(
prop as keyof T & string, prop as keyof T & string,
entity, entity,
options options,
parents_
) )
if (!cycle) { if (!cycle) {
@@ -189,7 +221,7 @@ export class EntitySerializer {
.forEach( .forEach(
(prop) => (prop) =>
(ret[this.propertyName(meta, prop.name, wrapped.__platform)] = (ret[this.propertyName(meta, prop.name, wrapped.__platform)] =
this.processProperty(prop.name, entity, options)) this.processProperty(prop.name, entity, options, parents_))
) )
// decorated get methods // decorated get methods
@@ -206,10 +238,11 @@ export class EntitySerializer {
this.processProperty( this.processProperty(
prop.getterName as keyof T & string, prop.getterName as keyof T & string,
entity, entity,
options options,
parents_
)) ))
) )
root.visitedSerialized.set(primaryKeysValues, ret)
return ret return ret
} }
@@ -233,8 +266,11 @@ export class EntitySerializer {
private static processProperty<T extends object>( private static processProperty<T extends object>(
prop: keyof T & string, prop: keyof T & string,
entity: T, entity: T,
options: SerializeOptions<T, any> options: SerializeOptions<T, any>,
parents: string[] = []
): T[keyof T] | undefined { ): T[keyof T] | undefined {
const parents_ = [...parents, entity.constructor.name]
const parts = prop.split(".") const parts = prop.split(".")
prop = parts[0] as string & keyof T prop = parts[0] as string & keyof T
const wrapped = helper(entity) const wrapped = helper(entity)
@@ -258,11 +294,17 @@ export class EntitySerializer {
} }
if (Utils.isCollection(entity[prop])) { if (Utils.isCollection(entity[prop])) {
return this.processCollection(prop, entity, options) return this.processCollection(prop, entity, options, parents_)
} }
if (Utils.isEntity(entity[prop], true)) { if (Utils.isEntity(entity[prop], true)) {
return this.processEntity(prop, entity, wrapped.__platform, options) return this.processEntity(
prop,
entity,
wrapped.__platform,
options,
parents_
)
} }
/* istanbul ignore next */ /* istanbul ignore next */
@@ -314,8 +356,11 @@ export class EntitySerializer {
prop: keyof T & string, prop: keyof T & string,
entity: T, entity: T,
platform: Platform, platform: Platform,
options: SerializeOptions<T, any> options: SerializeOptions<T, any>,
parents: string[] = []
): T[keyof T] | undefined { ): T[keyof T] | undefined {
const parents_ = [...parents, entity.constructor.name]
const child = Reference.unwrapReference(entity[prop] as T) const child = Reference.unwrapReference(entity[prop] as T)
const wrapped = helper(child) const wrapped = helper(child)
const populated = const populated =
@@ -326,8 +371,7 @@ export class EntitySerializer {
return this.serialize( return this.serialize(
child, child,
this.extractChildOptions(options, prop), this.extractChildOptions(options, prop),
/** passing the entity as the parent for circular filtering **/ parents_
entity
) as T[keyof T] ) as T[keyof T]
} }
@@ -339,8 +383,10 @@ export class EntitySerializer {
private static processCollection<T extends object>( private static processCollection<T extends object>(
prop: keyof T & string, prop: keyof T & string,
entity: T, entity: T,
options: SerializeOptions<T, any> options: SerializeOptions<T, any>,
parents: string[] = []
): T[keyof T] | undefined { ): T[keyof T] | undefined {
const parents_ = [...parents, entity.constructor.name]
const col = entity[prop] as unknown as Collection<T> const col = entity[prop] as unknown as Collection<T>
if (!col.isInitialized()) { if (!col.isInitialized()) {
@@ -352,8 +398,7 @@ export class EntitySerializer {
return this.serialize( return this.serialize(
item, item,
this.extractChildOptions(options, prop), this.extractChildOptions(options, prop),
/** passing the entity as the parent for circular filtering **/ parents_
entity
) )
} }
@@ -367,34 +412,36 @@ export const mikroOrmSerializer = <TOutput extends object>(
options?: Parameters<typeof EntitySerializer.serialize>[1] & { options?: Parameters<typeof EntitySerializer.serialize>[1] & {
preventCircularRef?: boolean preventCircularRef?: boolean
} }
): TOutput => { ): Promise<TOutput> => {
options ??= {} return new Promise<TOutput>((resolve) => {
options ??= {}
const data_ = (Array.isArray(data) ? data : [data]).filter(Boolean) const data_ = (Array.isArray(data) ? data : [data]).filter(Boolean)
const forSerialization: unknown[] = [] const forSerialization: unknown[] = []
const notForSerialization: unknown[] = [] const notForSerialization: unknown[] = []
data_.forEach((object) => { data_.forEach((object) => {
if (object.__meta) { if (object.__meta) {
return forSerialization.push(object) 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 notForSerialization.push(object) resolve(Array.isArray(data) ? result : result[0])
}) })
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

@@ -82,7 +82,7 @@ describe("EntityBuilder | enum", () => {
id: user1.id, id: user1.id,
}) })
expect(mikroOrmSerializer<InstanceType<typeof User>>(user)).toEqual({ expect(await mikroOrmSerializer<InstanceType<typeof User>>(user)).toEqual({
id: user1.id, id: user1.id,
username: "User 1", username: "User 1",
role: "admin", role: "admin",

View File

@@ -92,7 +92,7 @@ describe("hasOne - belongTo", () => {
} }
) )
expect(mikroOrmSerializer<InstanceType<typeof User>>(user)).toEqual({ expect(await mikroOrmSerializer<InstanceType<typeof User>>(user)).toEqual({
id: user1.id, id: user1.id,
username: "User 1", username: "User 1",
created_at: expect.any(Date), created_at: expect.any(Date),
@@ -137,7 +137,7 @@ describe("hasOne - belongTo", () => {
} }
) )
expect(mikroOrmSerializer<InstanceType<typeof User>>(user)).toEqual({ expect(await mikroOrmSerializer<InstanceType<typeof User>>(user)).toEqual({
id: user1.id, id: user1.id,
username: "User 1", username: "User 1",
created_at: expect.any(Date), created_at: expect.any(Date),

View File

@@ -129,7 +129,9 @@ describe("manyToMany - manyToMany", () => {
} }
) )
const serializedSquad = mikroOrmSerializer<InstanceType<typeof Team>>(team) const serializedSquad = await mikroOrmSerializer<InstanceType<typeof Team>>(
team
)
expect(serializedSquad.users).toHaveLength(2) expect(serializedSquad.users).toHaveLength(2)
expect(serializedSquad).toEqual({ expect(serializedSquad).toEqual({
@@ -166,7 +168,9 @@ describe("manyToMany - manyToMany", () => {
} }
) )
const serializedUser = mikroOrmSerializer<InstanceType<typeof User>>(user) const serializedUser = await mikroOrmSerializer<InstanceType<typeof User>>(
user
)
expect(serializedUser.squads).toHaveLength(1) expect(serializedUser.squads).toHaveLength(1)
expect(serializedUser).toEqual({ expect(serializedUser).toEqual({

View File

@@ -104,7 +104,7 @@ describe("manyToOne - belongTo", () => {
} }
) )
expect(mikroOrmSerializer<InstanceType<typeof Team>>(team)).toEqual({ expect(await mikroOrmSerializer<InstanceType<typeof Team>>(team)).toEqual({
id: team1.id, id: team1.id,
name: "Team 1", name: "Team 1",
created_at: expect.any(Date), created_at: expect.any(Date),
@@ -130,7 +130,7 @@ describe("manyToOne - belongTo", () => {
} }
) )
expect(mikroOrmSerializer<InstanceType<typeof User>>(user)).toEqual({ expect(await mikroOrmSerializer<InstanceType<typeof User>>(user)).toEqual({
id: user1.id, id: user1.id,
username: "User 1", username: "User 1",
created_at: expect.any(Date), created_at: expect.any(Date),

View File

@@ -60,20 +60,20 @@ async function start({ port, directory, types }) {
const app = express() const app = express()
const http_ = http.createServer(async (req, res) => { const http_ = http.createServer(async (req, res) => {
if (traceRequestHandler) { await new Promise((resolve) => {
await traceRequestHandler( res.on("finish", resolve)
async () => { if (traceRequestHandler) {
return new Promise((resolve) => { void traceRequestHandler(
res.on("finish", resolve) async () => {
app(req, res) app(req, res)
}) },
}, req,
req, res
res )
) } else {
} else { app(req, res)
app(req, res) }
} })
}) })
try { try {

View File

@@ -22,7 +22,7 @@ export function getModuleService(
) )
} }
return class LinkService extends LinkModuleService<unknown> { return class LinkService extends LinkModuleService {
override __joinerConfig(): ModuleJoinerConfig { override __joinerConfig(): ModuleJoinerConfig {
return joinerConfig_ as ModuleJoinerConfig return joinerConfig_ as ModuleJoinerConfig
} }

View File

@@ -35,9 +35,9 @@ type InjectedDependencies = {
[Modules.EVENT_BUS]?: IEventBusModuleService [Modules.EVENT_BUS]?: IEventBusModuleService
} }
export default class LinkModuleService<TLink> implements ILinkModule { export default class LinkModuleService implements ILinkModule {
protected baseRepository_: DAL.RepositoryService protected baseRepository_: DAL.RepositoryService
protected readonly linkService_: LinkService<TLink> protected readonly linkService_: LinkService<any>
protected readonly eventBusModuleService_?: IEventBusModuleService protected readonly eventBusModuleService_?: IEventBusModuleService
protected readonly entityName_: string protected readonly entityName_: string
protected readonly serviceName_: string protected readonly serviceName_: string
@@ -151,7 +151,7 @@ export default class LinkModuleService<TLink> implements ILinkModule {
const rows = await this.linkService_.list(filters, config, sharedContext) const rows = await this.linkService_.list(filters, config, sharedContext)
return await this.baseRepository_.serialize<object[]>(rows) return rows.map((row) => row.toJSON())
} }
@InjectManager() @InjectManager()
@@ -170,7 +170,7 @@ export default class LinkModuleService<TLink> implements ILinkModule {
sharedContext sharedContext
) )
return [await this.baseRepository_.serialize<object[]>(rows), count] return [rows.map((row) => row.toJSON()), count]
} }
@InjectTransactionManager(shouldForceTransaction, "baseRepository_") @InjectTransactionManager(shouldForceTransaction, "baseRepository_")
@@ -219,7 +219,7 @@ export default class LinkModuleService<TLink> implements ILinkModule {
})) }))
) )
return await this.baseRepository_.serialize<object[]>(links) return links.map((row) => row.toJSON())
} }
@InjectTransactionManager(shouldForceTransaction, "baseRepository_") @InjectTransactionManager(shouldForceTransaction, "baseRepository_")
@@ -244,7 +244,7 @@ export default class LinkModuleService<TLink> implements ILinkModule {
const links = await this.linkService_.dismiss(data, sharedContext) const links = await this.linkService_.dismiss(data, sharedContext)
return await this.baseRepository_.serialize<object[]>(links) return links.map((row) => row.toJSON())
} }
@InjectTransactionManager(shouldForceTransaction, "baseRepository_") @InjectTransactionManager(shouldForceTransaction, "baseRepository_")

View File

@@ -4,8 +4,8 @@ import { PaymentModuleService } from "@services"
import { moduleIntegrationTestRunner } from "medusa-test-utils" import { moduleIntegrationTestRunner } from "medusa-test-utils"
import { import {
createPaymentCollections, createPaymentCollections,
createPaymentSessions,
createPayments, createPayments,
createPaymentSessions,
} from "../../../__fixtures__" } from "../../../__fixtures__"
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -519,9 +519,6 @@ moduleIntegrationTestRunner<IPaymentModuleService>({
data: {}, data: {},
status: "authorized", status: "authorized",
authorized_at: expect.any(Date), authorized_at: expect.any(Date),
payment_collection: expect.objectContaining({
id: expect.any(String),
}),
payment_collection_id: expect.any(String), payment_collection_id: expect.any(String),
}), }),
}) })