From 1366e2efadd5b5c44eb1b2504f605067aef54e6d Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Fri, 3 May 2024 18:09:04 +0200 Subject: [PATCH] fix: Retrieve ancestors and/or descendants on product categories (#7226) --- .../api/__tests__/admin/product-category.ts | 309 +++--------------- .../orchestration/src/joiner/remote-joiner.ts | 1 + .../product-categories/[id]/products/route.ts | 10 +- .../admin/product-categories/[id]/route.ts | 18 +- .../admin/product-categories/helpers.ts | 24 -- .../admin/product-categories/query-config.ts | 23 +- .../api-v2/admin/product-categories/route.ts | 33 +- .../store/product-categories/[id]/route.ts | 11 +- .../medusa/src/api-v2/utils/refetch-entity.ts | 5 +- packages/medusa/src/utils/get-query-config.ts | 4 +- .../src/repositories/product-category.ts | 94 +++--- 11 files changed, 150 insertions(+), 382 deletions(-) delete mode 100644 packages/medusa/src/api-v2/admin/product-categories/helpers.ts diff --git a/integration-tests/api/__tests__/admin/product-category.ts b/integration-tests/api/__tests__/admin/product-category.ts index a485d35f1ab0..4f0ea66acf35 100644 --- a/integration-tests/api/__tests__/admin/product-category.ts +++ b/integration-tests/api/__tests__/admin/product-category.ts @@ -1,7 +1,6 @@ import { ModuleRegistrationName } from "@medusajs/modules-sdk" import { IProductModuleService } from "@medusajs/types" import { medusaIntegrationTestRunner } from "medusa-test-utils" -import { In } from "typeorm" import { breaking } from "../../../helpers/breaking" import { adminHeaders, @@ -16,7 +15,7 @@ let { Product } = {} medusaIntegrationTestRunner({ env: { MEDUSA_FF_PRODUCT_CATEGORIES: true, - // MEDUSA_FF_MEDUSA_V2: true, + MEDUSA_FF_MEDUSA_V2: true, }, testSuite: ({ dbConnection, getContainer, api }) => { let appContainer @@ -478,6 +477,37 @@ medusaIntegrationTestRunner({ }), ]) }) + + it("adds all ancestors to categories in a nested way", async () => { + const response = await api.get( + `/admin/product-categories/${productCategoryChild1.id}?include_ancestors_tree=true`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.product_category).toEqual( + expect.objectContaining({ + id: productCategoryChild1.id, + name: "rank 1", + rank: 1, + parent_category: expect.objectContaining({ + id: productCategoryChild.id, + name: "cashmere", + rank: 0, + parent_category: expect.objectContaining({ + id: productCategory.id, + name: "sweater", + rank: 0, + parent_category: expect.objectContaining({ + id: productCategoryParent.id, + name: "Mens", + rank: 0, + }), + }), + }), + }) + ) + }) }) describe("POST /admin/product-categories", () => { @@ -525,25 +555,6 @@ medusaIntegrationTestRunner({ }) }) - // TODO: Remove in V2, unnecessary test - it("throws an error when description is not a string", async () => { - const payload = { - name: "test", - handle: "test", - description: null, - } - - const error = await api - .post(`/admin/product-categories`, payload, adminHeaders) - .catch((e) => e) - - expect(error.response.status).toEqual(400) - expect(error.response.data.type).toEqual("invalid_data") - // expect(error.response.data.message).toEqual( - // "description must be a string" - // ) - }) - it("successfully creates a product category", async () => { const payload = { name: "test", @@ -570,16 +581,9 @@ medusaIntegrationTestRunner({ is_active: false, created_at: expect.any(String), updated_at: expect.any(String), - ...breaking( - () => ({ - parent_category: expect.objectContaining({ - id: productCategory.id, - }), - }), - () => ({ - parent_category_id: productCategory.id, - }) - ), + parent_category: expect.objectContaining({ + id: productCategory.id, + }), category_children: [], rank: 0, }), @@ -671,7 +675,8 @@ medusaIntegrationTestRunner({ }) }) - describe("DELETE /admin/product-categories/:id", () => { + // TODO: Should be migrate to V2 + describe.skip("DELETE /admin/product-categories/:id", () => { beforeEach(async () => { productCategoryParent = await simpleProductCategoryFactory( dbConnection, @@ -1221,245 +1226,7 @@ medusaIntegrationTestRunner({ }) }) - // TODO: Remove in V2, endpoint changed - describe("POST /admin/product-categories/:id/products/batch", () => { - beforeEach(async () => { - productCategory = await simpleProductCategoryFactory(dbConnection, { - id: "test-category", - name: "test category", - }) - }) - - it("should add products to a product category", async () => { - const testProduct1 = await simpleProductFactory(dbConnection, { - id: "test-product-1", - title: "test product 1", - }) - - const testProduct2 = await simpleProductFactory(dbConnection, { - id: "test-product-2", - title: "test product 2", - }) - - const payload = { - product_ids: [{ id: testProduct1.id }, { id: testProduct2.id }], - } - - const response = await api.post( - `/admin/product-categories/${productCategory.id}/products/batch`, - payload, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.product_category).toEqual( - expect.objectContaining({ - id: productCategory.id, - created_at: expect.any(String), - updated_at: expect.any(String), - }) - ) - - const products = await dbConnection.manager.find(Product, { - where: { id: In([testProduct1.id, testProduct2.id]) }, - relations: ["categories"], - }) - - expect(products[0].categories).toEqual([ - expect.objectContaining({ - id: productCategory.id, - }), - ]) - - expect(products[1].categories).toEqual([ - expect.objectContaining({ - id: productCategory.id, - }), - ]) - }) - - it("throws error when product ID is invalid", async () => { - const payload = { - product_ids: [{ id: "product-id-invalid" }], - } - - const error = await api - .post( - `/admin/product-categories/${productCategory.id}/products/batch`, - payload, - adminHeaders - ) - .catch((e) => e) - - expect(error.response.status).toEqual(400) - expect(error.response.data).toEqual({ - errors: ["Products product-id-invalid do not exist"], - message: - "Provided request body contains errors. Please check the data and retry the request", - }) - }) - - it("throws error when category ID is invalid", async () => { - const payload = { product_ids: [] } - - const error = await api - .post( - `/admin/product-categories/invalid-category-id/products/batch`, - payload, - adminHeaders - ) - .catch((e) => e) - - expect(error.response.status).toEqual(404) - expect(error.response.data).toEqual({ - message: "ProductCategory with id: invalid-category-id was not found", - type: "not_found", - }) - }) - - it("throws error trying to expand not allowed relations", async () => { - const payload = { product_ids: [] } - - const error = await api - .post( - `/admin/product-categories/invalid-category-id/products/batch?expand=products`, - payload, - adminHeaders - ) - .catch((e) => e) - - expect(error.response.status).toEqual(400) - expect(error.response.data).toEqual({ - message: "Requested fields [products] are not valid", - type: "invalid_data", - }) - }) - }) - - // TODO: Remove in v2, endpoint changed - describe("DELETE /admin/product-categories/:id/products/batch", () => { - let testProduct1, testProduct2 - - beforeEach(async () => { - testProduct1 = await simpleProductFactory(dbConnection, { - id: "test-product-1", - title: "test product 1", - }) - - testProduct2 = await simpleProductFactory(dbConnection, { - id: "test-product-2", - title: "test product 2", - }) - - productCategory = await simpleProductCategoryFactory(dbConnection, { - id: "test-category", - name: "test category", - products: [testProduct1, testProduct2], - }) - }) - - it("should remove products from a product category", async () => { - const payload = { - product_ids: [{ id: testProduct2.id }], - } - - const response = await api.delete( - `/admin/product-categories/${productCategory.id}/products/batch`, - { - ...adminHeaders, - data: payload, - } - ) - - expect(response.status).toEqual(200) - expect(response.data.product_category).toEqual( - expect.objectContaining({ - id: productCategory.id, - created_at: expect.any(String), - updated_at: expect.any(String), - }) - ) - - const products = await dbConnection.manager.find(Product, { - where: { id: In([testProduct1.id, testProduct2.id]) }, - relations: ["categories"], - }) - - expect(products[0].categories).toEqual([ - expect.objectContaining({ - id: productCategory.id, - }), - ]) - - expect(products[1].categories).toEqual([]) - }) - - it("throws error when product ID is invalid", async () => { - const payload = { - product_ids: [{ id: "product-id-invalid" }], - } - - const error = await api - .delete( - `/admin/product-categories/${productCategory.id}/products/batch`, - { - ...adminHeaders, - data: payload, - } - ) - .catch((e) => e) - - expect(error.response.status).toEqual(400) - expect(error.response.data).toEqual({ - errors: ["Products product-id-invalid do not exist"], - message: - "Provided request body contains errors. Please check the data and retry the request", - }) - }) - - it("throws error when category ID is invalid", async () => { - const payload = { product_ids: [] } - - const error = await api - .delete( - `/admin/product-categories/invalid-category-id/products/batch`, - { - ...adminHeaders, - data: payload, - } - ) - .catch((e) => e) - - expect(error.response.status).toEqual(404) - expect(error.response.data).toEqual({ - message: "ProductCategory with id: invalid-category-id was not found", - type: "not_found", - }) - }) - - it("throws error trying to expand not allowed relations", async () => { - const payload = { product_ids: [] } - - const error = await api - .delete( - `/admin/product-categories/invalid-category-id/products/batch?expand=products`, - { - ...adminHeaders, - data: payload, - } - ) - .catch((e) => e) - - expect(error.response.status).toEqual(400) - expect(error.response.data).toEqual({ - message: "Requested fields [products] are not valid", - type: "invalid_data", - }) - }) - }) - - // Skipping because the test is for V2 only - describe.skip("POST /admin/product-categories/:id/products", () => { + describe("POST /admin/product-categories/:id/products", () => { beforeEach(async () => { productCategory = await productModuleService.createCategory({ name: "category parent", diff --git a/packages/core/orchestration/src/joiner/remote-joiner.ts b/packages/core/orchestration/src/joiner/remote-joiner.ts index ebb61f978f43..d6935f7eeab3 100644 --- a/packages/core/orchestration/src/joiner/remote-joiner.ts +++ b/packages/core/orchestration/src/joiner/remote-joiner.ts @@ -369,6 +369,7 @@ export class RemoteJoiner { uniqueIds, relationship ) + const isObj = isDefined(response.path) let resData = isObj ? response.data[response.path!] : response.data diff --git a/packages/medusa/src/api-v2/admin/product-categories/[id]/products/route.ts b/packages/medusa/src/api-v2/admin/product-categories/[id]/products/route.ts index 460fa664c12c..f39e2fe00c55 100644 --- a/packages/medusa/src/api-v2/admin/product-categories/[id]/products/route.ts +++ b/packages/medusa/src/api-v2/admin/product-categories/[id]/products/route.ts @@ -7,7 +7,7 @@ import { AuthenticatedMedusaRequest, MedusaResponse, } from "../../../../../types/routing" -import { refetchCategory } from "../../helpers" +import { refetchEntity } from "../../../../utils/refetch-entity" export const POST = async ( req: AuthenticatedMedusaRequest, @@ -24,11 +24,11 @@ export const POST = async ( throw errors[0].error } - const category = await refetchCategory( - req.params.id, + const category = await refetchEntity( + "product_category", + id, req.scope, - req.remoteQueryConfig.fields, - req.filterableFields + req.remoteQueryConfig.fields ) res.status(200).json({ product_category: category }) diff --git a/packages/medusa/src/api-v2/admin/product-categories/[id]/route.ts b/packages/medusa/src/api-v2/admin/product-categories/[id]/route.ts index 2ba5eed3c144..8473e13649a8 100644 --- a/packages/medusa/src/api-v2/admin/product-categories/[id]/route.ts +++ b/packages/medusa/src/api-v2/admin/product-categories/[id]/route.ts @@ -4,7 +4,7 @@ import { AuthenticatedMedusaRequest, MedusaResponse, } from "../../../../types/routing" -import { refetchCategory } from "../helpers" +import { refetchEntities } from "../../../utils/refetch-entity" import { AdminProductCategoryParamsType, AdminUpdateProductCategoryType, @@ -14,11 +14,11 @@ export const GET = async ( req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { - const category = await refetchCategory( - req.params.id, + const [category] = await refetchEntities( + "product_category", + { id: req.params.id, ...req.filterableFields }, req.scope, - req.remoteQueryConfig.fields, - req.filterableFields + req.remoteQueryConfig.fields ) res.json({ product_category: category }) @@ -40,11 +40,11 @@ export const POST = async ( throw errors[0].error } - const category = await refetchCategory( - req.params.id, + const [category] = await refetchEntities( + "product_category", + { id, ...req.filterableFields }, req.scope, - req.remoteQueryConfig.fields, - req.filterableFields + req.remoteQueryConfig.fields ) res.status(200).json({ product_category: category }) diff --git a/packages/medusa/src/api-v2/admin/product-categories/helpers.ts b/packages/medusa/src/api-v2/admin/product-categories/helpers.ts deleted file mode 100644 index f59126784940..000000000000 --- a/packages/medusa/src/api-v2/admin/product-categories/helpers.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { MedusaContainer } from "@medusajs/types" -import { - ContainerRegistrationKeys, - remoteQueryObjectFromString, -} from "@medusajs/utils" - -export const refetchCategory = async ( - categoryId: string, - scope: MedusaContainer, - fields: string[], - filterableFields: Record = {} -) => { - const remoteQuery = scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) - const queryObject = remoteQueryObjectFromString({ - entryPoint: "product_category", - variables: { - filters: { ...filterableFields, id: categoryId }, - }, - fields: fields, - }) - - const categorys = await remoteQuery(queryObject) - return categorys[0] -} diff --git a/packages/medusa/src/api-v2/admin/product-categories/query-config.ts b/packages/medusa/src/api-v2/admin/product-categories/query-config.ts index 5524e119ae25..2af811df9b8b 100644 --- a/packages/medusa/src/api-v2/admin/product-categories/query-config.ts +++ b/packages/medusa/src/api-v2/admin/product-categories/query-config.ts @@ -10,17 +10,36 @@ export const defaults = [ "created_at", "updated_at", "metadata", - "*parent_category", - "*category_children", + "parent_category", + "category_children", +] + +export const allowed = [ + "id", + "name", + "description", + "handle", + "is_active", + "is_internal", + "rank", + "parent_category_id", + "created_at", + "updated_at", + "metadata", + "category_children", + "parent_category", + "products", ] export const retrieveProductCategoryConfig = { defaults, + allowed, isList: false, } export const listProductCategoryConfig = { defaults, + allowed, defaultLimit: 50, isList: true, } diff --git a/packages/medusa/src/api-v2/admin/product-categories/route.ts b/packages/medusa/src/api-v2/admin/product-categories/route.ts index 4b4d2570bad8..0c73e1b39c47 100644 --- a/packages/medusa/src/api-v2/admin/product-categories/route.ts +++ b/packages/medusa/src/api-v2/admin/product-categories/route.ts @@ -3,15 +3,11 @@ import { AdminProductCategoryListResponse, AdminProductCategoryResponse, } from "@medusajs/types" -import { - ContainerRegistrationKeys, - remoteQueryObjectFromString, -} from "@medusajs/utils" import { AuthenticatedMedusaRequest, MedusaResponse, } from "../../../types/routing" -import { refetchCategory } from "./helpers" +import { refetchEntities } from "../../utils/refetch-entity" import { AdminCreateProductCategoryType, AdminProductCategoriesParamsType, @@ -21,18 +17,13 @@ export const GET = async ( req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { - const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) - - const queryObject = remoteQueryObjectFromString({ - entryPoint: "product_category", - variables: { - filters: req.filterableFields, - ...req.remoteQueryConfig.pagination, - }, - fields: req.remoteQueryConfig.fields, - }) - - const { rows: product_categories, metadata } = await remoteQuery(queryObject) + const { rows: product_categories, metadata } = await refetchEntities( + "product_category", + req.filterableFields, + req.scope, + req.remoteQueryConfig.fields, + req.remoteQueryConfig.pagination + ) res.json({ product_categories, @@ -57,11 +48,11 @@ export const POST = async ( throw errors[0].error } - const category = await refetchCategory( - result.id, + const [category] = await refetchEntities( + "product_category", + { id: result.id, ...req.filterableFields }, req.scope, - req.remoteQueryConfig.fields, - req.filterableFields + req.remoteQueryConfig.fields ) res.status(200).json({ product_category: category }) diff --git a/packages/medusa/src/api-v2/store/product-categories/[id]/route.ts b/packages/medusa/src/api-v2/store/product-categories/[id]/route.ts index 2816e4ff370b..9af38012b7be 100644 --- a/packages/medusa/src/api-v2/store/product-categories/[id]/route.ts +++ b/packages/medusa/src/api-v2/store/product-categories/[id]/route.ts @@ -1,21 +1,22 @@ import { StoreProductCategoryResponse } from "@medusajs/types" +import { MedusaError } from "@medusajs/utils" import { AuthenticatedMedusaRequest, MedusaResponse, } from "../../../../types/routing" -import { refetchCategory } from "../helpers" +import { refetchEntities } from "../../../utils/refetch-entity" import { StoreProductCategoryParamsType } from "../validators" -import { MedusaError } from "@medusajs/utils" export const GET = async ( req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { - const category = await refetchCategory( - req.params.id, + const category = await refetchEntities( + "product_category", + { id: req.params.id, ...req.filterableFields }, req.scope, req.remoteQueryConfig.fields, - req.filterableFields + req.remoteQueryConfig.pagination ) if (!category) { diff --git a/packages/medusa/src/api-v2/utils/refetch-entity.ts b/packages/medusa/src/api-v2/utils/refetch-entity.ts index b49406d1d22b..ac8bbc5a18aa 100644 --- a/packages/medusa/src/api-v2/utils/refetch-entity.ts +++ b/packages/medusa/src/api-v2/utils/refetch-entity.ts @@ -9,7 +9,8 @@ export const refetchEntities = async ( entryPoint: string, idOrFilter: string | object, scope: MedusaContainer, - fields: string[] + fields: string[], + pagination: object = {} ) => { const remoteQuery = scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) const filters = isString(idOrFilter) ? { id: idOrFilter } : idOrFilter @@ -23,7 +24,7 @@ export const refetchEntities = async ( delete filters.context } - let variables = { filters, ...context } + let variables = { filters, ...context, ...pagination } const queryObject = remoteQueryObjectFromString({ entryPoint, diff --git a/packages/medusa/src/utils/get-query-config.ts b/packages/medusa/src/utils/get-query-config.ts index 283d8eaecbf7..0258d23d7752 100644 --- a/packages/medusa/src/utils/get-query-config.ts +++ b/packages/medusa/src/utils/get-query-config.ts @@ -4,11 +4,11 @@ import { stringToSelectRelationObject, } from "@medusajs/utils" import { pick } from "lodash" -import { isDefined, MedusaError } from "medusa-core-utils" +import { MedusaError, isDefined } from "medusa-core-utils" import { BaseEntity } from "../interfaces" -import { FindConfig, QueryConfig, RequestQueryFields } from "../types/common" import { featureFlagRouter } from "../loaders/feature-flags" import MedusaV2 from "../loaders/feature-flags/medusa-v2" +import { FindConfig, QueryConfig, RequestQueryFields } from "../types/common" export function pickByConfig( obj: TModel | TModel[], diff --git a/packages/modules/product/src/repositories/product-category.ts b/packages/modules/product/src/repositories/product-category.ts index 1d6fe6b8df9c..e9217688a5b1 100644 --- a/packages/modules/product/src/repositories/product-category.ts +++ b/packages/modules/product/src/repositories/product-category.ts @@ -29,44 +29,77 @@ export const tempReorderRank = 99999 // eslint-disable-next-line max-len export class ProductCategoryRepository extends DALUtils.MikroOrmBaseTreeRepository { - async find( + buildFindOptions( findOptions: DAL.FindOptions = { where: {} }, - transformOptions: ProductCategoryTransformOptions = {}, - context: Context = {} - ): Promise { - const manager = super.getActiveManager(context) - + familyOptions: ProductCategoryTransformOptions = {} + ) { const findOptions_ = { ...findOptions } - const { includeDescendantsTree, includeAncestorsTree } = transformOptions findOptions_.options ??= {} + const fields = (findOptions_.options.fields ??= []) + const populate = (findOptions_.options.populate ??= []) // Ref: Building descendants // mpath and parent_category_id needs to be added to the query for the tree building to be done accurately - if (includeDescendantsTree || includeAncestorsTree) { + if ( + familyOptions.includeDescendantsTree || + familyOptions.includeAncestorsTree + ) { fields.indexOf("mpath") === -1 && fields.push("mpath") fields.indexOf("parent_category_id") === -1 && fields.push("parent_category_id") } + const shouldExpandParent = + familyOptions.includeAncestorsTree || fields.includes("parent_category") + + if (shouldExpandParent) { + populate.indexOf("parent_category") === -1 && + populate.push("parent_category") + } + + const shouldExpandChildren = + familyOptions.includeDescendantsTree || + fields.includes("category_children") + + if (shouldExpandChildren) { + populate.indexOf("category_children") === -1 && + populate.push("category_children") + } + Object.assign(findOptions_.options, { strategy: LoadStrategy.SELECT_IN, }) + return findOptions_ + } + + async find( + findOptions: DAL.FindOptions = { where: {} }, + transformOptions: ProductCategoryTransformOptions = {}, + context: Context = {} + ): Promise { + const manager = super.getActiveManager(context) + + const findOptions_ = this.buildFindOptions(findOptions, transformOptions) + const productCategories = await manager.find( ProductCategory, findOptions_.where as MikroFilterQuery, findOptions_.options as MikroOptions ) - if (!includeDescendantsTree && !includeAncestorsTree) { + if ( + !transformOptions.includeDescendantsTree && + !transformOptions.includeAncestorsTree + ) { return productCategories } - return this.buildProductCategoriesWithTree( + return await this.buildProductCategoriesWithTree( { - descendants: includeDescendantsTree, - ancestors: includeAncestorsTree, + descendants: transformOptions.includeDescendantsTree, + ancestors: transformOptions.includeAncestorsTree, }, productCategories, findOptions_ @@ -84,12 +117,6 @@ export class ProductCategoryRepository extends DALUtils.MikroOrmBaseTreeReposito ): Promise { const manager = super.getActiveManager(context) - const hasPopulateParentCategory = ( - findOptions.options?.populate ?? ([] as any) - ).find((pop) => pop.field === "parent_category") - - include.ancestors = include.ancestors || hasPopulateParentCategory - const mpaths: any[] = [] const parentMpaths = new Set() for (const cat of productCategories) { @@ -179,42 +206,27 @@ export class ProductCategoryRepository extends DALUtils.MikroOrmBaseTreeReposito context: Context = {} ): Promise<[ProductCategory[], number]> { const manager = super.getActiveManager(context) - - const findOptions_ = { ...findOptions } - const { includeDescendantsTree, includeAncestorsTree } = transformOptions - findOptions_.options ??= {} - const fields = (findOptions_.options.fields ??= []) - - // Ref: Building descendants - // mpath and parent_category_id needs to be added to the query for the tree building to be done accurately - if (includeDescendantsTree) { - fields.indexOf("mpath") === -1 && fields.push("mpath") - fields.indexOf("parent_category_id") === -1 && - fields.push("parent_category_id") - } - - Object.assign(findOptions_.options, { - strategy: LoadStrategy.SELECT_IN, - }) + + const findOptions_ = this.buildFindOptions(findOptions, transformOptions) const [productCategories, count] = await manager.findAndCount( ProductCategory, findOptions_.where as MikroFilterQuery, findOptions_.options as MikroOptions ) - if (!includeDescendantsTree) { - return [productCategories, count] - } - if (!includeDescendantsTree && !includeAncestorsTree) { + if ( + !transformOptions.includeDescendantsTree && + !transformOptions.includeAncestorsTree + ) { return [productCategories, count] } return [ await this.buildProductCategoriesWithTree( { - descendants: includeDescendantsTree, - ancestors: includeAncestorsTree, + descendants: transformOptions.includeDescendantsTree, + ancestors: transformOptions.includeAncestorsTree, }, productCategories, findOptions_