feat(): Update transformer middleware and API (#6647)

**What**
Update all transform middleware to support the new API
- deprecate `defaultRelations`
- deprecate `allowedRelations`
- Add `defaults` and `allowed` in replacement for `defaultFields` and `allowedFields` respectively
- in the `defaults` it is possible to specify a field such as `*variants` in order to be recognized as a relation only without specifying any property
- add support for `remoteQueryConfig` assigned to req like we have for `listConfig` and `retrieveConfig`
- add support to override `allowed|allowedFields` if a previous middleware have set it up on the req.allowed
- The api now accepts `fields` as the only accepted fields to manage the requested props and relations, the `expand` property have been deprecated. New supported symbols have been added in complement of the fields
  - `+` (e.g `/store/products?fields=+description`) to specify that description should be added as part of the returned data among the other defined fields
  - `-` (e.g `/store/products?fields=-description`) to specify that description should be removed as part of the returned data
  - `*` (e.g `/store/products?fields=*variants`) to specify that the variants relations should be added as part of the returned data among the other defined fields without having to specify which property of the variants should be returned. In the `defaults` config of the transform middleware it is also possible to use this symbol
  - In the case no symbol is provided, it will replace the default fields and mean that only the specified fields must be returned

About the allowed validation, all fields in the `defaults` configuration must be present in the `allowed` configuration. 
In case the `defaults` contains full relation selection (e.g `*product.variants`) it should be present in the `allowed` as `product.variants`. In case in the `defaults` you add `product.variants.id`, it will be allowed if the `allowed` configuration includes either `product.variants.id` as full match or `product.variants` as it means that we allow all properties from `product.variants`

Also, support for `*` selection on the remote query/joiner have been added


**Note**
All v2 end points refactoring can be done separately
This commit is contained in:
Adrien de Peretti
2024-03-18 09:37:59 +01:00
committed by GitHub
parent bb87db8342
commit e77a02aca5
30 changed files with 1186 additions and 351 deletions

View File

@@ -1157,7 +1157,7 @@ describe("/admin/product-categories", () => {
expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
message: "Relations [products] are not valid",
message: "Requested fields [products] are not valid",
type: "invalid_data",
})
})
@@ -1291,7 +1291,7 @@ describe("/admin/product-categories", () => {
expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
message: "Relations [products] are not valid",
message: "Requested fields [products] are not valid",
type: "invalid_data",
})
})

View File

@@ -1071,9 +1071,9 @@ medusaIntegrationTestRunner({
expect(res.data.product.id).toEqual(productId)
expect(keysInResponse).toEqual(
expect.arrayContaining([
// fields
"id",
"created_at",
// fields
"updated_at",
"deleted_at",
"title",

View File

@@ -240,7 +240,7 @@ describe("/store/order-edits", () => {
.catch((e) => e)
expect(err.response.data.message).toBe(
"Fields [internal_note] are not valid"
"Requested fields [internal_note] are not valid"
)
})
})

View File

@@ -215,9 +215,12 @@ describe("/store/carts", () => {
"/store/orders?display_id=111&email=test@email.com&fields=status,email"
)
expect(Object.keys(response.data.order)).toHaveLength(22)
expect(Object.keys(response.data.order)).toHaveLength(24)
expect(Object.keys(response.data.order)).toEqual(
expect.arrayContaining([
"id",
"created_at",
// fields
"status",
"email",
@@ -252,9 +255,11 @@ describe("/store/carts", () => {
const response = await api.get("/store/orders/order_test?fields=status")
expect(Object.keys(response.data.order)).toHaveLength(21)
expect(Object.keys(response.data.order)).toHaveLength(22)
expect(Object.keys(response.data.order)).toEqual(
expect.arrayContaining([
"id",
// fields
"status",
@@ -292,6 +297,7 @@ describe("/store/carts", () => {
expect(Object.keys(response.data.order).sort()).toEqual(
[
"id",
// fields
"status",

View File

@@ -1,10 +1,11 @@
import { ProductCategory } from "@medusajs/medusa"
import {ProductCategory} from "@medusajs/medusa"
import path from "path"
import startServerWithEnvironment from "../../../environment-helpers/start-server-with-environment"
import { useApi } from "../../../environment-helpers/use-api"
import { useDb } from "../../../environment-helpers/use-db"
import { simpleProductCategoryFactory } from "../../../factories"
import startServerWithEnvironment
from "../../../environment-helpers/start-server-with-environment"
import {useApi} from "../../../environment-helpers/use-api"
import {useDb} from "../../../environment-helpers/use-db"
import {simpleProductCategoryFactory} from "../../../factories"
jest.setTimeout(30000)
@@ -138,7 +139,7 @@ describe("/store/product-categories", () => {
expect(error.response.status).toEqual(400)
expect(error.response.data.type).toEqual("invalid_data")
expect(error.response.data.message).toEqual(
"Fields [mpath] are not valid"
"Requested fields [mpath] are not valid"
)
})

View File

@@ -112,8 +112,9 @@ describe("/store/products", () => {
response.data.products.find((p) => p.id === testProductId1)
)
expect(testProductIndex).toBe(3)
expect(testProduct1Index).toBe(4)
// Since they have the same variant titles for rank 2, the order is not guaranteed
expect([3, 4]).toContain(testProductIndex)
expect([3, 4]).toContain(testProduct1Index)
})
it("returns a list of ordered products by variants title ASC", async () => {
@@ -262,9 +263,12 @@ describe("/store/products", () => {
expect(response.status).toEqual(200)
expect(Object.keys(response.data.products[0])).toHaveLength(8)
expect(Object.keys(response.data.products[0])).toHaveLength(10)
expect(Object.keys(response.data.products[0])).toEqual(
expect.arrayContaining([
"id",
"created_at",
// fields
"handle",
// relations

View File

@@ -7,19 +7,6 @@ import { createMedusaContainer } from "@medusajs/utils"
const axios = require("axios").default
const keepTables = [
/*"store",*/
/* "staged_job",
"shipping_profile",
"fulfillment_provider",
"payment_provider",
"country",
"region_country",
"currency",
"migrations",
"mikro_orm_migrations",*/
]
const DB_HOST = process.env.DB_HOST
const DB_USERNAME = process.env.DB_USERNAME
const DB_PASSWORD = process.env.DB_PASSWORD

View File

@@ -44,8 +44,6 @@ export const defaultAdminProductsOptionFields = ["id", "title"]
export const retrieveOptionConfig = {
defaultFields: defaultAdminProductsOptionFields,
defaultRelations: [],
allowedRelations: [],
isList: false,
}
@@ -55,7 +53,7 @@ export const listOptionConfig = {
isList: true,
}
export const allowedAdminProductRelations = [
/* export const allowedAdminProductRelations = [
"variants",
// TODO: Add in next iteration
// "variants.prices",
@@ -68,21 +66,21 @@ export const allowedAdminProductRelations = [
"tags",
"type",
"collection",
]
]*/
// TODO: This is what we had in the v1 list. Do we still want to expand that much by default? Also this doesn't work in v2 it seems.
export const defaultAdminProductRelations = [
/* export const defaultAdminProductRelations = [
"variants",
"variants.prices",
"variants.options",
"profiles",
// "variants.prices",
// "variants.options",
// "profiles",
"images",
"options",
"options.values",
// "options.values",
"tags",
"type",
"collection",
]
]*/
export const defaultAdminProductFields = [
"id",
@@ -143,9 +141,7 @@ export const defaultAdminProductFields = [
]
export const retrieveTransformQueryConfig = {
defaultFields: defaultAdminProductFields,
defaultRelations: defaultAdminProductRelations,
allowedRelations: allowedAdminProductRelations,
defaults: defaultAdminProductFields,
isList: false,
}

View File

@@ -54,11 +54,9 @@ export const GET = async (
entryPoint: "product",
variables: {
filters: filterableFields,
order: req.listConfig.order,
skip: req.listConfig.skip,
take: req.listConfig.take,
...req.remoteQueryConfig.pagination,
},
fields: req.listConfig.select as string[],
fields: req.remoteQueryConfig.fields,
})
const { rows: products, metadata } = await remoteQuery(queryObject)

View File

@@ -12,7 +12,7 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const query = remoteQueryObjectFromString({
entryPoint: Modules.CART,
fields: defaultStoreCartFields,
fields: req.remoteQueryConfig.fields,
})
const [cart] = await remoteQuery(query, { cart: variables })

View File

@@ -75,38 +75,10 @@ export const defaultStoreCartFields = [
"payment_collection.payment_sessions",
]
export const defaultStoreCartRelations = [
"items",
"items.tax_lines",
"items.adjustments",
"region",
"customer",
"customer.groups",
"shipping_address",
"billing_address",
"shipping_methods",
"shipping_methods.tax_lines",
"shipping_methods.adjustments",
]
export const allowedRelations = [
"items",
"items.tax_lines",
"items.adjustments",
"region",
"customer",
"customer.groups",
"shipping_address",
"billing_address",
"shipping_methods",
"shipping_methods.tax_lines",
"shipping_methods.adjustments",
"sales_channel",
]
const allowedFields = [...defaultStoreCartFields]
export const retrieveTransformQueryConfig = {
defaultFields: defaultStoreCartFields,
defaultRelations: defaultStoreCartRelations,
allowedRelations: defaultStoreCartRelations,
defaults: defaultStoreCartFields,
allowed: allowedFields,
isList: false,
}

View File

@@ -0,0 +1,622 @@
import { NextFunction, Request, Response } from "express"
import { transformQuery } from "../transform-query"
import { extendedFindParamsMixin } from "../../../types/common"
import { MedusaError } from "medusa-core-utils"
describe("transformQuery", () => {
afterEach(() => {
jest.clearAllMocks()
})
it("should transform the input query", async () => {
let mockRequest = {
query: {},
} as Request
const mockResponse = {} as Response
const nextFunction: NextFunction = jest.fn()
const expectations = ({
offset,
limit,
inputOrder,
transformedOrder,
}: {
offset: number
limit: number
inputOrder: string | undefined
transformedOrder: Record<string, "ASC" | "DESC">
relations?: string[]
}) => {
expect(mockRequest.validatedQuery).toEqual({
offset,
limit,
order: inputOrder,
})
expect(mockRequest.filterableFields).toEqual({})
expect(mockRequest.allowedProperties).toEqual([
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
"metadata",
"metadata.parent",
"metadata.children",
"metadata.product",
])
expect(mockRequest.listConfig).toEqual({
take: limit,
skip: offset,
select: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
relations: [
"metadata",
"metadata.parent",
"metadata.children",
"metadata.product",
],
order: transformedOrder,
})
expect(mockRequest.remoteQueryConfig).toEqual({
fields: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
pagination: {
order: transformedOrder,
skip: offset,
take: limit,
},
})
}
let queryConfig: any = {
defaultFields: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
defaultRelations: [
"metadata",
"metadata.parent",
"metadata.children",
"metadata.product",
],
isList: true,
}
let middleware = transformQuery(extendedFindParamsMixin(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expectations({
limit: 20,
offset: 0,
inputOrder: undefined,
transformedOrder: {
created_at: "DESC",
},
})
//////////////////////////////
mockRequest = {
query: {
limit: "10",
offset: "5",
order: "created_at",
},
} as unknown as Request
middleware = transformQuery(extendedFindParamsMixin(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expectations({
limit: 10,
offset: 5,
inputOrder: "created_at",
transformedOrder: { created_at: "ASC" },
})
//////////////////////////////
mockRequest = {
query: {
limit: "10",
offset: "5",
order: "created_at",
},
} as unknown as Request
queryConfig = {
defaults: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
isList: true,
}
middleware = transformQuery(extendedFindParamsMixin(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expectations({
limit: 10,
offset: 5,
inputOrder: "created_at",
transformedOrder: { created_at: "ASC" },
})
})
it("should transform the input query taking into account the fields symbols (+,- or no symbol)", async () => {
let mockRequest = {
query: {
fields: "id",
},
} as unknown as Request
const mockResponse = {} as Response
const nextFunction: NextFunction = jest.fn()
let queryConfig: any = {
defaultFields: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
defaultRelations: [
"metadata",
"metadata.parent",
"metadata.children",
"metadata.product",
],
isList: true,
}
let middleware = transformQuery(extendedFindParamsMixin(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(mockRequest.listConfig).toEqual(
expect.objectContaining({
select: ["id", "created_at"],
})
)
//////////////////////////////
mockRequest = {
query: {
fields: "+test_prop,-prop-test-something",
},
} as unknown as Request
queryConfig = {
defaultFields: [
"id",
"prop-test-something",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
defaultRelations: [
"metadata",
"metadata.parent",
"metadata.children",
"metadata.product",
],
isList: true,
}
middleware = transformQuery(extendedFindParamsMixin(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(mockRequest.listConfig).toEqual(
expect.objectContaining({
select: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
"test_prop",
],
})
)
//////////////////////////////
mockRequest = {
query: {
fields: "+test_prop,-updated_at",
},
} as unknown as Request
queryConfig = {
defaults: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
isList: true,
}
middleware = transformQuery(extendedFindParamsMixin(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(mockRequest.listConfig).toEqual(
expect.objectContaining({
select: [
"id",
"created_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
"test_prop",
],
})
)
})
it(`should transform the input and manage the allowed fields and relations properly without error`, async () => {
let mockRequest = {
query: {
fields: "*product.variants,+product.id",
},
} as unknown as Request
const mockResponse = {} as Response
const nextFunction: NextFunction = jest.fn()
let queryConfig: any = {
defaults: [
"id",
"created_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
allowed: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
"product",
"product.variants",
],
isList: true,
}
let middleware = transformQuery(extendedFindParamsMixin(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(mockRequest.listConfig).toEqual(
expect.objectContaining({
select: [
"id",
"created_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
"product.id",
],
relations: [
"metadata",
"metadata.parent",
"metadata.children",
"metadata.product",
"product",
"product.variants",
],
})
)
expect(mockRequest.remoteQueryConfig).toEqual(
expect.objectContaining({
fields: [
"id",
"created_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
"product.id",
"product.variants.*",
],
})
)
})
it("should throw when attempting to transform the input if disallowed fields are requested", async () => {
let mockRequest = {
query: {
fields: "+test_prop",
},
} as unknown as Request
const mockResponse = {} as Response
const nextFunction: NextFunction = jest.fn()
let queryConfig: any = {
defaultFields: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
defaultRelations: [
"metadata",
"metadata.parent",
"metadata.children",
"metadata.product",
],
allowedFields: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
isList: true,
}
let middleware = transformQuery(extendedFindParamsMixin(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(nextFunction).toHaveBeenCalledWith(
new MedusaError(
MedusaError.Types.INVALID_DATA,
`Requested fields [test_prop] are not valid`
)
)
//////////////////////////////
mockRequest = {
query: {
expand: "product",
},
} as unknown as Request
queryConfig = {
defaultFields: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
defaultRelations: [
"metadata",
"metadata.parent",
"metadata.children",
"metadata.product",
],
allowedFields: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
allowedRelations: [
"metadata",
"metadata.parent",
"metadata.children",
"metadata.product",
],
isList: true,
}
middleware = transformQuery(extendedFindParamsMixin(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(nextFunction).toHaveBeenCalledWith(
new MedusaError(
MedusaError.Types.INVALID_DATA,
`Requested fields [product] are not valid`
)
)
//////////////////////////////
mockRequest = {
query: {
fields: "*product",
},
} as unknown as Request
queryConfig = {
defaults: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
allowed: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
isList: true,
}
middleware = transformQuery(extendedFindParamsMixin(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(nextFunction).toHaveBeenCalledWith(
new MedusaError(
MedusaError.Types.INVALID_DATA,
`Requested fields [product] are not valid`
)
)
//////////////////////////////
mockRequest = {
query: {
fields: "*product.variants",
},
} as unknown as Request
queryConfig = {
defaults: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
allowed: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
"product",
],
isList: true,
}
middleware = transformQuery(extendedFindParamsMixin(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(nextFunction).toHaveBeenCalledWith(
new MedusaError(
MedusaError.Types.INVALID_DATA,
`Requested fields [product.variants] are not valid`
)
)
//////////////////////////////
mockRequest = {
query: {
fields: "product",
},
} as unknown as Request
queryConfig = {
defaults: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
allowed: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
isList: true,
}
middleware = transformQuery(extendedFindParamsMixin(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(nextFunction).toHaveBeenCalledWith(
new MedusaError(
MedusaError.Types.INVALID_DATA,
`Requested fields [product] are not valid`
)
)
})
})

View File

@@ -1,4 +1,3 @@
import { buildSelects, objectToStringPath } from "@medusajs/utils"
import { ValidatorOptions } from "class-validator"
import { NextFunction, Request, Response } from "express"
import { omit } from "lodash"
@@ -24,10 +23,7 @@ export function transformQuery<
TEntity extends BaseEntity
>(
plainToClass: ClassConstructor<T>,
queryConfig?: Omit<
QueryConfig<TEntity>,
"allowedRelations" | "allowedFields"
>,
queryConfig: QueryConfig<TEntity> = {},
config: ValidatorOptions = {}
): (req: Request, res: Response, next: NextFunction) => Promise<void> {
return async (req: Request, res: Response, next: NextFunction) => {
@@ -40,12 +36,41 @@ export function transformQuery<
)
req.validatedQuery = validated
req.filterableFields = getFilterableFields(validated)
req.allowedProperties = getAllowedProperties(
validated,
req.includes ?? {},
queryConfig
attachListOrRetrieveConfig<TEntity>(req, {
...queryConfig,
allowed:
req.allowed ?? queryConfig.allowed ?? queryConfig.allowedFields ?? [],
})
/**
* TODO: the bellow allowedProperties should probably need to be reworked which would create breaking changes everywhere
* cleanResponseData is used. It is in fact, what is expected to be returned which IMO
* should correspond to the select/relations
*
* Kept it as it is to maintain backward compatibility
*/
const queryConfigRes = !queryConfig.isList
? req.retrieveConfig
: req.listConfig
const includesRelations = Object.keys(req.includes ?? {})
req.allowedProperties = Array.from(
new Set(
[
...(req.validatedQuery.fields
? queryConfigRes.select ?? []
: req.allowed ??
queryConfig.allowed ??
queryConfig.allowedFields ??
(queryConfig.defaults as string[]) ??
queryConfig.defaultFields ??
[]),
...(req.validatedQuery.expand || includesRelations.length
? [...(validated.expand?.split(",") || []), ...includesRelations] // For backward compatibility, the includes takes precedence over the relations for the returnable fields
: queryConfig.allowedRelations ?? queryConfigRes.relations ?? []), // For backward compatibility, the allowedRelations takes precedence over the relations for the returnable fields
].filter(Boolean)
)
)
attachListOrRetrieveConfig<TEntity>(req, queryConfig)
next()
} catch (e) {
@@ -59,6 +84,8 @@ export function transformQuery<
* @param plainToClass
* @param queryConfig
* @param config
*
* @deprecated use `transformQuery` instead
*/
export function transformStoreQuery<
T extends RequestQueryFields,
@@ -68,28 +95,7 @@ export function transformStoreQuery<
queryConfig?: QueryConfig<TEntity>,
config: ValidatorOptions = {}
): (req: Request, res: Response, next: NextFunction) => Promise<void> {
return async (req: Request, res: Response, next: NextFunction) => {
try {
normalizeQuery()(req, res, () => void 0)
const validated: T = await validator<T, Record<string, unknown>>(
plainToClass,
req.query,
config
)
req.validatedQuery = validated
req.filterableFields = getFilterableFields(validated)
req.allowedProperties = getStoreAllowedProperties(
validated,
req.includes ?? {},
queryConfig
)
attachListOrRetrieveConfig<TEntity>(req, queryConfig)
next()
} catch (e) {
next(e)
}
}
return transformQuery(plainToClass, queryConfig, config)
}
/**
@@ -100,6 +106,9 @@ function getFilterableFields<T extends RequestQueryFields>(obj: T): T {
const result = omit(obj, [
"limit",
"offset",
/**
* @deprecated
*/
"expand",
"fields",
"order",
@@ -108,80 +117,22 @@ function getFilterableFields<T extends RequestQueryFields>(obj: T): T {
}
/**
* build and attach the `retrieveConfig` or `listConfig`
* build and attach the `retrieveConfig` or `listConfig` and remoteQueryConfig to the request object
* @param req
* @param queryConfig
*/
function attachListOrRetrieveConfig<TEntity extends BaseEntity>(
req: Request,
queryConfig?: QueryConfig<TEntity>
queryConfig: QueryConfig<TEntity> = {}
) {
const validated = req.validatedQuery
if (queryConfig?.isList) {
req.listConfig = prepareListQuery(
validated,
queryConfig
) as FindConfig<unknown>
} else {
req.retrieveConfig = prepareRetrieveQuery(
validated,
queryConfig
) as FindConfig<unknown>
}
}
/**
* Build the store allowed props based on the custom fields query params, the allowed props config and the includes options.
* This can be used later with `cleanResponseData` in order to clean up the returned objects to the client.
* @param queryConfig
* @param validated
* @param includesOptions
*/
function getStoreAllowedProperties<TEntity extends BaseEntity>(
validated: RequestQueryFields,
includesOptions: Record<string, boolean>,
queryConfig?: QueryConfig<TEntity>
): string[] {
const allowed: string[] = []
const includeKeys = Object.keys(includesOptions)
const fields = validated.fields
? validated.fields?.split(",")
: queryConfig?.allowedFields || []
const expand =
validated.expand || includeKeys.length
? [...(validated.expand?.split(",") || []), ...includeKeys]
: queryConfig?.allowedRelations || []
allowed.push(...fields, ...objectToStringPath(buildSelects(expand)))
return allowed
}
/**
* Build the admin allowed props based on the custom fields query params, the defaults and the includes options.
* Since admin can access everything, it is only in order to return what the user asked for through fields and expand query params.
* This can be used later with `cleanResponseData` in order to clean up the returned objects to the client.
* @param queryConfig
* @param validated
* @param includesOptions
*/
function getAllowedProperties<TEntity extends BaseEntity>(
validated: RequestQueryFields,
includesOptions: Record<string, boolean>,
queryConfig?: QueryConfig<TEntity>
): string[] {
const allowed: (string | keyof TEntity)[] = []
const includeKeys = Object.keys(includesOptions)
const fields = validated.fields
? validated.fields?.split(",")
: queryConfig?.defaultFields || []
const expand =
validated.expand || includeKeys.length
? [...(validated.expand?.split(",") || []), ...includeKeys]
: queryConfig?.defaultRelations || []
allowed.push(...fields, ...objectToStringPath(buildSelects(expand)))
return allowed as string[]
const config = queryConfig.isList
? prepareListQuery(validated, queryConfig)
: prepareRetrieveQuery(validated, queryConfig)
req.listConfig = ("listConfig" in config &&
config.listConfig) as FindConfig<any>
req.retrieveConfig = ("retrieveConfig" in config &&
config.retrieveConfig) as FindConfig<any>
req.remoteQueryConfig = config.remoteQueryConfig
}

View File

@@ -30,6 +30,15 @@ describe("GET /admin/orders", () => {
it("calls orderService retrieve", () => {
expect(OrderServiceMock.retrieveWithTotals).toHaveBeenCalledTimes(1)
const expectedRelations = [
...defaultAdminOrdersRelations,
"sales_channel",
]
expect(
OrderServiceMock.retrieveWithTotals.mock.calls[0][1].relations
).toHaveLength(expectedRelations.length)
expect(OrderServiceMock.retrieveWithTotals).toHaveBeenCalledWith(
IdMap.getId("test-order"),
{
@@ -50,7 +59,7 @@ describe("GET /admin/orders", () => {
}
),
// TODO [MEDUSA_FF_SALES_CHANNELS]: Remove when sales channel flag is removed entirely
relations: [...defaultAdminOrdersRelations, "sales_channel"].sort(),
relations: expect.arrayContaining(expectedRelations),
},
{
includes: undefined,

View File

@@ -26,6 +26,9 @@ describe("GET /admin/products/:id", () => {
it("calls get product from productService", () => {
expect(ProductServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(
ProductServiceMock.retrieve.mock.calls[0][1].relations
).toHaveLength(11)
expect(ProductServiceMock.retrieve).toHaveBeenCalledWith(
IdMap.getId("product1"),
{
@@ -55,7 +58,7 @@ describe("GET /admin/products/:id", () => {
"deleted_at",
"metadata",
],
relations: [
relations: expect.arrayContaining([
"collection",
"images",
"options",
@@ -67,7 +70,7 @@ describe("GET /admin/products/:id", () => {
"variants",
"variants.options",
"variants.prices",
],
]),
}
)
})

View File

@@ -45,11 +45,14 @@ describe("GET /admin/regions/:region_id", () => {
it("calls service addCountry", () => {
expect(RegionServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(
RegionServiceMock.retrieve.mock.calls[0][1].relations
).toHaveLength(defaultRelations.length)
expect(RegionServiceMock.retrieve).toHaveBeenCalledWith(
IdMap.getId("testRegion"),
{
select: defaultFields,
relations: defaultRelations,
relations: expect.arrayContaining(defaultRelations),
}
)
})

View File

@@ -48,11 +48,14 @@ describe("GET /admin/regions", () => {
it("calls service list", () => {
expect(RegionServiceMock.listAndCount).toHaveBeenCalledTimes(1)
expect(
RegionServiceMock.listAndCount.mock.calls[0][1].relations
).toHaveLength(defaultRelations.length)
expect(RegionServiceMock.listAndCount).toHaveBeenCalledWith(
{},
{
select: defaultFields,
relations: defaultRelations,
relations: expect.arrayContaining(defaultRelations),
take: 50,
skip: 0,
order: { created_at: "DESC" },
@@ -84,11 +87,14 @@ describe("GET /admin/regions", () => {
it("calls service list", () => {
expect(RegionServiceMock.listAndCount).toHaveBeenCalledTimes(1)
expect(
RegionServiceMock.listAndCount.mock.calls[0][1].relations
).toHaveLength(defaultRelations.length)
expect(RegionServiceMock.listAndCount).toHaveBeenCalledWith(
{},
{
select: defaultFields,
relations: defaultRelations,
relations: expect.arrayContaining(defaultRelations),
take: 20,
skip: 10,
order: { created_at: "DESC" },

View File

@@ -112,7 +112,7 @@ export default async (req, res) => {
rel.includes("variant")
)
const select = [...req.retrieveConfig.select]
const select = [...(req.retrieveConfig.select ?? [])]
const salesChannelsEnabled = featureFlagRouter.isFeatureEnabled(
SalesChannelFeatureFlag.key
)

View File

@@ -23,9 +23,9 @@ describe("Get region by id", () => {
{
relations: [
"countries",
"currency",
"fulfillment_providers",
"payment_providers",
"fulfillment_providers",
"currency",
],
select: [
"id",

View File

@@ -15,15 +15,18 @@ describe("List regions", () => {
it("calls list from region service", () => {
expect(RegionServiceMock.listAndCount).toHaveBeenCalledTimes(1)
expect(
RegionServiceMock.listAndCount.mock.calls[0][1].relations
).toHaveLength(4)
expect(RegionServiceMock.listAndCount).toHaveBeenCalledWith(
{},
{
relations: [
relations: expect.arrayContaining([
"countries",
"currency",
"fulfillment_providers",
"payment_providers",
],
]),
select: [
"id",
"name",

View File

@@ -1,10 +1,10 @@
import { FlagRouter } from "@medusajs/utils"
import { EntityManager } from "typeorm"
import {
OrderDescriptor,
OrderExportBatchJob,
OrderExportBatchJobContext,
orderExportPropertiesDescriptors,
OrderDescriptor,
OrderExportBatchJob,
OrderExportBatchJobContext,
orderExportPropertiesDescriptors,
} from "."
import { AdminPostBatchesReq } from "../../../api"
import { AbstractBatchJobStrategy, IFileService } from "../../../interfaces"
@@ -101,7 +101,7 @@ class OrderExportStrategy extends AbstractBatchJobStrategy {
...context
} = batchJob.context as OrderExportBatchJobContext
const listConfig = prepareListQuery(
const { listConfig } = prepareListQuery(
{
limit,
offset,

View File

@@ -1,5 +1,5 @@
import { MedusaContainer } from "@medusajs/types"
import { FlagRouter, MedusaV2Flag, createContainerLike } from "@medusajs/utils"
import { createContainerLike, FlagRouter, MedusaV2Flag } from "@medusajs/utils"
import { humanizeAmount } from "medusa-core-utils"
import { EntityManager } from "typeorm"
import { defaultAdminProductRelations } from "../../../api"
@@ -114,7 +114,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
...context
} = (batchJob?.context ?? {}) as ProductExportBatchJobContext
const listConfig = prepareListQuery(
const { listConfig } = prepareListQuery(
{
limit,
offset,

View File

@@ -104,9 +104,29 @@ export interface CustomFindOptions<TModel, InKeys extends keyof TModel> {
}
export type QueryConfig<TEntity extends BaseEntity> = {
/**
* Default fields and relations to return
*/
defaults?: (keyof TEntity | string)[]
/**
* @deprecated Use `defaults` instead
*/
defaultFields?: (keyof TEntity | string)[]
/**
* @deprecated Use `defaultFields` instead and the relations will be inferred
*/
defaultRelations?: string[]
/**
* Fields and relations that are allowed to be requested
*/
allowed?: string[]
/**
* @deprecated Use `allowed` instead
*/
allowedFields?: string[]
/**
* @deprecated Use `allowedFields` instead and the relations will be inferred
*/
allowedRelations?: string[]
defaultLimit?: number
isList?: boolean
@@ -120,10 +140,13 @@ export type QueryConfig<TEntity extends BaseEntity> = {
export type RequestQueryFields = {
/**
* Comma-separated relations that should be expanded in the returned data.
* @deprecated Use `fields` instead and the relations will be inferred
*/
expand?: string
/**
* Comma-separated fields that should be included in the returned data.
* if a field is prefixed with `+` it will be added to the default fields, using `-` will remove it from the default fields.
* without prefix it will replace the entire default fields.
*/
fields?: string
/**
@@ -511,6 +534,7 @@ export class AddressCreatePayload {
export class FindParams {
/**
* {@inheritDoc RequestQueryFields.expand}
* @deprecated
*/
@IsString()
@IsOptional()

View File

@@ -12,11 +12,46 @@ declare global {
scope: MedusaContainer
validatedQuery: RequestQueryFields & Record<string, unknown>
validatedBody: unknown
listConfig: FindConfig<unknown>
retrieveConfig: FindConfig<unknown>
filterableFields: Record<string, unknown>
/**
* TODO: shouldn't this correspond to returnable fields instead of allowed fields? also it is used by the cleanResponseData util
*/
allowedProperties: string[]
/**
* An object containing the select, relation, skip, take and order to be used with medusa internal services
*/
listConfig: FindConfig<unknown>
/**
* An object containing the select, relation to be used with medusa internal services
*/
retrieveConfig: FindConfig<unknown>
/**
* An object containing fields and variables to be used with the remoteQuery
*/
remoteQueryConfig: {
fields: string[]
pagination: {
order?: Record<string, string>
skip?: number
take?: number
}
}
/**
* An object containing the fields that are filterable e.g `{ id: Any<String> }`
*/
filterableFields: Record<string, unknown>
includes?: Record<string, boolean>
/**
* An array of fields and relations that are allowed to be queried, this can be set by the
* consumer as part of a middleware and it will take precedence over the defaultAllowedFields
* @deprecated use `allowed` instead
*/
allowedFields?: string[]
/**
* An array of fields and relations that are allowed to be queried, this can be set by the
* consumer as part of a middleware and it will take precedence over the defaultAllowedFields set
* by the api
*/
allowed?: string[]
errors: string[]
requestId?: string
}

View File

@@ -1,14 +1,45 @@
import type { Customer, User } from "../models"
import type { NextFunction, Request, Response } from "express"
import { MedusaContainer } from "@medusajs/types"
import { RequestQueryFields } from "@medusajs/types"
import { MedusaContainer, RequestQueryFields } from "@medusajs/types"
import { FindConfig } from "./common"
export interface MedusaRequest<Body = unknown> extends Request {
validatedBody: Body
validatedQuery: RequestQueryFields & Record<string, unknown>
/**
* TODO: shouldn't this correspond to returnable fields instead of allowed fields? also it is used by the cleanResponseData util
*/
allowedProperties: string[]
/**
* An object containing the select, relation, skip, take and order to be used with medusa internal services
*/
listConfig: FindConfig<unknown>
/**
* An object containing the select, relation to be used with medusa internal services
*/
retrieveConfig: FindConfig<unknown>
/**
* An object containing fields and variables to be used with the remoteQuery
*/
remoteQueryConfig: { fields: string[]; pagination: { order?: Record<string, string>, skip?: number, take?: number } }
/**
* An object containing the fields that are filterable e.g `{ id: Any<String> }`
*/
filterableFields: Record<string, unknown>
includes?: Record<string, boolean>
/**
* An array of fields and relations that are allowed to be queried, this can be set by the
* consumer as part of a middleware and it will take precedence over the defaultAllowedFields
* @deprecated use `allowed` instead
*/
allowedFields?: string[]
/**
* An array of fields and relations that are allowed to be queried, this can be set by the
* consumer as part of a middleware and it will take precedence over the defaultAllowedFields set
* by the api
*/
allowed?: string[]
errors: string[]
scope: MedusaContainer
session?: any

View File

@@ -2,6 +2,7 @@ import { pick } from "lodash"
import { FindConfig, QueryConfig, RequestQueryFields } from "../types/common"
import { isDefined, MedusaError } from "medusa-core-utils"
import { BaseEntity } from "../interfaces"
import { getSetDifference, stringToSelectRelationObject } from "@medusajs/utils"
export function pickByConfig<TModel extends BaseEntity>(
obj: TModel | TModel[],
@@ -19,93 +20,146 @@ export function pickByConfig<TModel extends BaseEntity>(
return obj
}
export function getRetrieveConfig<TModel extends BaseEntity>(
defaultFields: (keyof TModel)[],
defaultRelations: string[],
fields?: (keyof TModel)[],
expand?: string[]
): FindConfig<TModel> {
let includeFields: (keyof TModel)[] = []
if (isDefined(fields)) {
includeFields = Array.from(new Set([...fields, "id"])).map((field) => {
return typeof field === "string" ? field.trim() : field
}) as (keyof TModel)[]
}
let expandFields: string[] = []
if (isDefined(expand)) {
expandFields = expand.map((expandRelation) => expandRelation.trim())
}
return {
select: includeFields.length ? includeFields : defaultFields,
relations: isDefined(expand) ? expandFields : defaultRelations,
}
}
export function getListConfig<TModel extends BaseEntity>(
defaultFields: (keyof TModel)[],
defaultRelations: string[],
fields?: (keyof TModel)[],
expand?: string[],
limit = 50,
offset = 0,
order: { [k: string | symbol]: "DESC" | "ASC" } = {}
): FindConfig<TModel> {
let includeFields: (keyof TModel)[] = []
if (isDefined(fields)) {
const fieldSet = new Set(fields)
// Ensure created_at is included, since we are sorting on this
fieldSet.add("created_at")
fieldSet.add("id")
includeFields = Array.from(fieldSet) as (keyof TModel)[]
}
let expandFields: string[] = []
if (isDefined(expand)) {
expandFields = expand
}
const orderBy = order
if (!Object.keys(order).length) {
orderBy["created_at"] = "DESC"
}
return {
select: includeFields.length ? includeFields : defaultFields,
relations: isDefined(expand) ? expandFields : defaultRelations,
skip: offset,
take: limit,
order: orderBy,
}
}
export function prepareListQuery<
T extends RequestQueryFields,
TEntity extends BaseEntity
>(validated: T, queryConfig?: QueryConfig<TEntity>) {
const { order, fields, expand, limit, offset } = validated
>(validated: T, queryConfig: QueryConfig<TEntity> = {}) {
const { order, fields, limit = 50, expand, offset = 0 } = validated
let {
allowed = [],
defaults = [],
defaultFields = [],
defaultLimit,
allowedFields = [],
allowedRelations = [],
defaultRelations = [],
isList,
} = queryConfig
let expandRelations: string[] | undefined = undefined
if (isDefined(expand)) {
expandRelations = expand.split(",").filter((v) => v)
}
allowedFields = allowed.length ? allowed : allowedFields
defaultFields = defaults.length ? defaults : defaultFields
// e.g *product.variants meaning that we want all fields from the product.variants
// in that case it wont be part of the select but it will be part of the relations.
// For the remote query we will have to add the fields to the fields array as product.variants.*
const starFields: Set<string> = new Set()
let allFields = new Set(defaultFields) as Set<string>
let expandFields: (keyof TEntity)[] | undefined = undefined
if (isDefined(fields)) {
expandFields = (fields.split(",") as (keyof TEntity)[]).filter((v) => v)
const customFields = fields.split(",").filter(Boolean)
const shouldReplaceDefaultFields =
!customFields.length ||
customFields.some((field) => {
return !(
field.startsWith("-") ||
field.startsWith("+") ||
field.startsWith("*")
)
})
if (shouldReplaceDefaultFields) {
allFields = new Set(customFields.map((f) => f.replace(/^[+-]/, "")))
} else {
customFields.forEach((field) => {
if (field.startsWith("+")) {
allFields.add(field.replace(/^\+/, ""))
} else if (field.startsWith("-")) {
allFields.delete(field.replace(/^-/, ""))
} else {
allFields.add(field)
}
})
}
// TODO: Maintain backward compatibility, remove in future. the created at was only added in the list query for default order
if (queryConfig.isList) {
allFields.add("created_at")
}
allFields.add("id")
}
if (expandFields?.length && queryConfig?.allowedFields?.length) {
validateFields(expandFields as string[], queryConfig.allowedFields)
allFields.forEach((field) => {
if (field.startsWith("*")) {
starFields.add(field.replace(/^\*/, ""))
allFields.delete(field)
}
})
const allAllowedFields = new Set(allowedFields) // In case there is no allowedFields, allow all fields
const notAllowedFields: string[] = []
if (allowedFields.length) {
;[...allFields, ...Array.from(starFields)].forEach((field) => {
const hasAllowedField = allowedFields.includes(field)
if (hasAllowedField) {
return
}
// Select full relation in that case it must match an allowed field fully
// e.g product.variants in that case we must have a product.variants in the allowedFields
if (starFields.has(field)) {
if (hasAllowedField) {
return
}
notAllowedFields.push(field)
return
}
const fieldStartsWithAllowedField = allowedFields.some((allowedField) =>
field.startsWith(allowedField)
)
if (!fieldStartsWithAllowedField) {
notAllowedFields.push(field)
return
}
})
}
if (expandRelations?.length && queryConfig?.allowedRelations?.length) {
validateRelations(expandRelations, queryConfig.allowedRelations)
if (allFields.size && notAllowedFields.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Requested fields [${Array.from(notAllowedFields).join(
", "
)}] are not valid`
)
}
let orderBy: { [k: symbol]: "DESC" | "ASC" } | undefined
const { select, relations } = stringToSelectRelationObject(
Array.from(allFields)
)
// TODO: maintain backward compatibility, remove in the future
let allRelations = new Set([
...relations,
...defaultRelations,
...Array.from(starFields),
])
if (isDefined(expand)) {
allRelations = new Set(expand.split(",").filter(Boolean))
}
const allAllowedRelations = new Set([
...Array.from(allAllowedFields),
...allowedRelations,
])
const notAllowedRelations = !allowedRelations.length
? new Set()
: getSetDifference(allRelations, allAllowedRelations)
if (allRelations.size && notAllowedRelations.size) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Requested fields [${Array.from(notAllowedRelations).join(
", "
)}] are not valid`
)
}
// End of expand compatibility
let orderBy: { [k: symbol]: "DESC" | "ASC" } | undefined = {}
if (isDefined(order)) {
let orderField = order
if (order.startsWith("-")) {
@@ -125,82 +179,52 @@ export function prepareListQuery<
`Order field ${orderField} is not valid`
)
}
} else {
orderBy["created_at"] = "DESC"
}
return getListConfig<TEntity>(
queryConfig?.defaultFields as (keyof TEntity)[],
(queryConfig?.defaultRelations ?? []) as string[],
expandFields,
expandRelations,
limit ?? queryConfig?.defaultLimit,
offset ?? 0,
orderBy
)
return {
listConfig: {
select: select.length ? select : undefined,
relations: Array.from(allRelations),
skip: offset,
take: limit ?? defaultLimit,
order: orderBy,
},
remoteQueryConfig: {
// Add starFields that are relations only on which we want all properties with a dedicated format to the remote query
fields: [
...Array.from(allFields),
...Array.from(starFields).map((f) => `${f}.*`),
],
pagination: isList
? {
skip: offset,
take: limit ?? defaultLimit,
order: orderBy,
}
: {},
},
}
}
export function prepareRetrieveQuery<
T extends RequestQueryFields,
TEntity extends BaseEntity
>(validated: T, queryConfig?: QueryConfig<TEntity>) {
const { fields, expand } = validated
let expandRelations: string[] | undefined = undefined
if (isDefined(expand)) {
expandRelations = expand.split(",").filter((v) => v)
}
let expandFields: (keyof TEntity)[] | undefined = undefined
if (isDefined(fields)) {
expandFields = (fields.split(",") as (keyof TEntity)[]).filter((v) => v)
}
if (expandFields?.length && queryConfig?.allowedFields?.length) {
validateFields(expandFields as string[], queryConfig.allowedFields)
}
if (expandRelations?.length && queryConfig?.allowedRelations?.length) {
validateRelations(expandRelations, queryConfig.allowedRelations)
}
return getRetrieveConfig<TEntity>(
queryConfig?.defaultFields as (keyof TEntity)[],
(queryConfig?.defaultRelations ?? []) as string[],
expandFields,
expandRelations
const { listConfig, remoteQueryConfig } = prepareListQuery(
validated,
queryConfig
)
}
function validateRelations(
relations: string[],
allowed: string[]
): void | never {
const disallowedRelationsFound: string[] = []
relations?.forEach((field) => {
if (!allowed.includes(field as string)) {
disallowedRelationsFound.push(field)
}
})
if (disallowedRelationsFound.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Relations [${disallowedRelationsFound.join(", ")}] are not valid`
)
}
}
function validateFields(fields: string[], allowed: string[]): void | never {
const disallowedFieldsFound: string[] = []
fields?.forEach((field) => {
if (!allowed.includes(field as string)) {
disallowedFieldsFound.push(field)
}
})
if (disallowedFieldsFound.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Fields [${disallowedFieldsFound.join(", ")}] are not valid`
)
return {
retrieveConfig: {
select: listConfig.select,
relations: listConfig.relations,
},
remoteQueryConfig: {
fields: remoteQueryConfig.fields,
pagination: {},
},
}
}

View File

@@ -90,6 +90,10 @@ export class RemoteQuery {
let relations: string[] = []
data.fields?.forEach((field: string) => {
if (field === "*") {
// Select all, so we don't specify any field and rely on relation only
return
}
fields.add(prefix ? `${prefix}.${field}` : field)
})
args[prefix] = data.args

View File

@@ -192,6 +192,151 @@ describe("RemoteJoiner", () => {
)
})
it("should filter the fields and attach the values correctly taking into account the * fields selection", () => {
const data = {
id: "prod_01H1PN579TJ707BRK938E2ME2N",
title: "7468915",
handle: "7468915",
subtitle: null,
description: null,
collection_id: null,
collection: null,
type_id: "ptyp_01GX66TMARS55DBNYE31DDT8ZV",
type: {
id: "ptyp_01GX66TMARS55DBNYE31DDT8ZV",
value: "test-type-1",
},
options: [
{
id: "opt_01H1PN57AQE8G3FK365EYNH917",
title: "4108194",
product_id: "prod_01H1PN579TJ707BRK938E2ME2N",
product: "prod_01H1PN579TJ707BRK938E2ME2N",
values: [
{
id: "optval_01H1PN57EAMXYFRGSJJJE9P0TJ",
value: "4108194",
option_id: "opt_01H1PN57AQE8G3FK365EYNH917",
option: "opt_01H1PN57AQE8G3FK365EYNH917",
variant_id: "variant_01H1PN57E99TMZAGNEZBSS3FM3",
variant: "variant_01H1PN57E99TMZAGNEZBSS3FM3",
},
],
},
],
variants: [
{
id: "variant_01H1PN57E99TMZAGNEZBSS3FM3",
product_id: "prod_01H1PN579TJ707BRK938E2ME2N",
product: "prod_01H1PN579TJ707BRK938E2ME2N",
options: [
{
id: "optval_01H1PN57EAMXYFRGSJJJE9P0TJ",
value: "4108194",
option_id: "opt_01H1PN57AQE8G3FK365EYNH917",
option: "opt_01H1PN57AQE8G3FK365EYNH917",
variant_id: "variant_01H1PN57E99TMZAGNEZBSS3FM3",
variant: "variant_01H1PN57E99TMZAGNEZBSS3FM3",
},
],
},
],
tags: [],
images: [],
}
const fields = [
"id",
"title",
"subtitle",
"description",
"handle",
"images",
"tags",
"type",
"collection",
"options",
"variants_id",
]
const expands = {
collection: {
fields: ["id", "title", "handle"],
},
images: {
fields: ["url"],
},
options: {
fields: ["title", "values"],
expands: {
values: {
fields: ["id", "value"],
},
},
},
tags: {
fields: ["value"],
},
type: {
fields: ["value"],
},
variants: {
fields: ["*"],
expands: {
options: {
fields: ["id", "value"],
},
},
},
}
const filteredFields = (RemoteJoiner as any).filterFields(
data,
fields,
expands
)
expect(filteredFields).toEqual(
expect.objectContaining({
id: "prod_01H1PN579TJ707BRK938E2ME2N",
title: "7468915",
subtitle: null,
description: null,
handle: "7468915",
images: [],
tags: [],
type: {
value: "test-type-1",
},
collection: null,
options: [
{
title: "4108194",
values: [
{
id: "optval_01H1PN57EAMXYFRGSJJJE9P0TJ",
value: "4108194",
},
],
},
],
variants: [
{
id: "variant_01H1PN57E99TMZAGNEZBSS3FM3",
product_id: "prod_01H1PN579TJ707BRK938E2ME2N",
product: "prod_01H1PN579TJ707BRK938E2ME2N",
options: [
{
id: "optval_01H1PN57EAMXYFRGSJJJE9P0TJ",
value: "4108194",
},
],
},
],
})
)
})
it("Simple query of a service, its id and no fields specified", async () => {
const query = {
service: "user",

View File

@@ -37,20 +37,26 @@ export class RemoteJoiner {
data: any,
fields: string[],
expands?: RemoteNestedExpands
): Record<string, unknown> {
): Record<string, unknown> | undefined {
if (!fields || !data) {
return data
}
const filteredData = fields.reduce((acc: any, field: string) => {
const fieldValue = data?.[field]
let filteredData: Record<string, unknown> = {}
if (isDefined(fieldValue)) {
acc[field] = data?.[field]
}
if (fields.includes("*")) {
filteredData = data
} else {
filteredData = fields.reduce((acc: any, field: string) => {
const fieldValue = data?.[field]
return acc
}, {})
if (isDefined(fieldValue)) {
acc[field] = data?.[field]
}
return acc
}, {})
}
if (expands) {
for (const key of Object.keys(expands ?? {})) {

View File

@@ -103,6 +103,11 @@ export async function mikroOrmCreateConnection(
migrations: {
path: pathToMigrations,
generator: TSMigrationGenerator,
silent: !(
database.debug ??
process.env.NODE_ENV?.startsWith("dev") ??
false
),
},
pool: database.pool as any,
})