diff --git a/.changeset/lazy-zoos-listen.md b/.changeset/lazy-zoos-listen.md new file mode 100644 index 00000000000..21e7c89fd36 --- /dev/null +++ b/.changeset/lazy-zoos-listen.md @@ -0,0 +1,9 @@ +--- +"@reactioncommerce/api-core": minor +"@reactioncommerce/api-plugin-accounts": minor +"@reactioncommerce/api-plugin-orders": minor +"@reactioncommerce/api-plugin-products": minor +"@reactioncommerce/api-utils": minor +--- + +Filter feature. This new feature provides a common function that can be used in a new query endpoint to get filtered results from any collection. diff --git a/packages/api-core/src/graphql/schema.graphql b/packages/api-core/src/graphql/schema.graphql index 1d3fb6633b0..4a73c345e67 100644 --- a/packages/api-core/src/graphql/schema.graphql +++ b/packages/api-core/src/graphql/schema.graphql @@ -34,6 +34,100 @@ enum MassUnit { oz } +"Relational Operator Types used in filtering inside a single condition" +enum RelationalOperatorTypes{ + "Begins With used with String types to filter based on the beginning of the string" + beginsWith + + "Ends With used with String types to filter based on the end of the string" + endsWith + + "Equal to" + eq + + "Greater Than" + gt + + "Greater Than or Equal" + gte + + "In used with Array types to filter based on the array containing the value" + in + + "Less Than" + lt + + "Less Than or Equal" + lte + + "Not Equal to" + ne + + "Not In used with Array types to filter based on the array not containing the value" + nin + + "Regex used with String types to filter based on the regex pattern" + regex +} + +"Single Condition for filter, use exactly one of the optional input value type" +input SingleConditionInput { + "Value to filter if it is Boolean input" + booleanValue: Boolean + + "Flag to set if the regex is case insensitive" + caseSensitive: Boolean + + "Value to filter if it is Date input" + dateValue: DateTime + + "Value to filter if it is Float Array input" + floatArrayValue: [Float] + + "Value to filter if it is Float input" + floatValue: Float + + "Value to filter if it is Integer Array input" + integerArrayValue: [Int] + + "Value to filter if it is Integer input" + integerValue: Int + + "Field name" + key: String! + + "Logical NOT operator to negate the condition" + logicalNot: Boolean + + "Relational Operator to join the key and value" + relationalOperator: RelationalOperatorTypes! + + "Value to filter if it is String Array input" + stringArrayValue: [String] + + "Value to filter if it is String input" + stringValue: String +} + +"Filter with One level of conditions (use either 'any' or 'all' not both)" +input ConditionsArray { + "Array of single-conditions" + all: [SingleConditionInput] + + "Array of single-conditions" + any: [SingleConditionInput] +} + +"Filter with nested conditions of input (use either 'any' or 'all' not both)" +input FilterConditionsInput { + "Array holding Nested conditions (use either 'any' or 'all' not both)" + all: [ConditionsArray] + + "Array holding Nested conditions (use either 'any' or 'all' not both)" + any: [ConditionsArray] +} + + "A list of URLs for various sizes of an image" type ImageSizes { "Use this URL to get a large resolution file for this image" diff --git a/packages/api-plugin-accounts/package.json b/packages/api-plugin-accounts/package.json index 4a0869bc977..71d0bd35c3f 100644 --- a/packages/api-plugin-accounts/package.json +++ b/packages/api-plugin-accounts/package.json @@ -26,7 +26,7 @@ }, "sideEffects": false, "dependencies": { - "@reactioncommerce/api-utils": "^1.16.9", + "@reactioncommerce/api-utils": "~1.17.1", "@reactioncommerce/db-version-check": "^1.0.0", "@reactioncommerce/logger": "^1.1.3", "@reactioncommerce/random": "^1.0.2", diff --git a/packages/api-plugin-accounts/src/queries/filterAccounts.js b/packages/api-plugin-accounts/src/queries/filterAccounts.js new file mode 100644 index 00000000000..6fdaab29650 --- /dev/null +++ b/packages/api-plugin-accounts/src/queries/filterAccounts.js @@ -0,0 +1,24 @@ +import generateFilterQuery from "@reactioncommerce/api-utils/generateFilterQuery.js"; + +/** + * @name filterAccounts + * @method + * @memberof GraphQL/Accounts + * @summary Query the Accounts collection for a list of customers/accounts + * @param {Object} context - an object containing the per-request state + * @param {Object} conditions - object containing the filter conditions + * @param {String} shopId - shopID to filter by + * @returns {Promise} Accounts object Promise + */ +export default async function filterAccounts(context, conditions, shopId) { + const { collections: { Accounts } } = context; + + if (!shopId) { + throw new Error("shopId is required"); + } + await context.validatePermissions("reaction:legacy:accounts", "read", { shopId }); + + const { filterQuery } = generateFilterQuery(context, "Account", conditions, shopId); + + return Accounts.find(filterQuery); +} diff --git a/packages/api-plugin-accounts/src/queries/filterCustomers.js b/packages/api-plugin-accounts/src/queries/filterCustomers.js new file mode 100644 index 00000000000..a9964d15550 --- /dev/null +++ b/packages/api-plugin-accounts/src/queries/filterCustomers.js @@ -0,0 +1,25 @@ +import generateFilterQuery from "@reactioncommerce/api-utils/generateFilterQuery.js"; + +/** + * @name filterCustomers + * @method + * @memberof GraphQL/Customers + * @summary Query the Accounts collection for a list of customers/accounts + * @param {Object} context - an object containing the per-request state + * @param {Object} conditions - object containing the filter conditions + * @param {String} shopId - shopID to filter by + * @returns {Promise} Accounts object Promise + */ +export default async function filterCustomers(context, conditions, shopId) { + const { collections: { Accounts } } = context; + + if (!shopId) { + throw new Error("shopId is required"); + } + await context.validatePermissions("reaction:legacy:accounts", "read", { shopId }); + + const { filterQuery } = generateFilterQuery(context, "Account", conditions, shopId); + + filterQuery.groups = { $in: [null, []] }; // filter out non-customer accounts + return Accounts.find(filterQuery); +} diff --git a/packages/api-plugin-accounts/src/queries/index.js b/packages/api-plugin-accounts/src/queries/index.js index 0ac5fb9d56b..4309cf66768 100644 --- a/packages/api-plugin-accounts/src/queries/index.js +++ b/packages/api-plugin-accounts/src/queries/index.js @@ -7,8 +7,12 @@ import groupsByAccount from "./groupsByAccount.js"; import groupsById from "./groupsById.js"; import invitations from "./invitations.js"; import userAccount from "./userAccount.js"; +import filterAccounts from "./filterAccounts.js"; +import filterCustomers from "./filterCustomers.js"; export default { + filterAccounts, + filterCustomers, accountByUserId, accounts, customers, diff --git a/packages/api-plugin-accounts/src/resolvers/Query/filterAccounts.js b/packages/api-plugin-accounts/src/resolvers/Query/filterAccounts.js new file mode 100644 index 00000000000..bfab2f45c0f --- /dev/null +++ b/packages/api-plugin-accounts/src/resolvers/Query/filterAccounts.js @@ -0,0 +1,31 @@ +import getPaginatedResponse from "@reactioncommerce/api-utils/graphql/getPaginatedResponse.js"; +import wasFieldRequested from "@reactioncommerce/api-utils/graphql/wasFieldRequested.js"; + +/** + * @name Query/accounts + * @method + * @memberof Accounts/Query + * @summary Query for a list of accounts + * @param {Object} _ - unused + * @param {Object} args - an object of all arguments that were sent by the client + * @param {String} args.shopId - id of shop to query + * @param {Object} args.conditions - object containing the filter conditions + * @param {Object} context - an object containing the per-request state + * @param {Object} info Info about the GraphQL request + * @returns {Promise} Accounts + */ +export default async function filterAccounts(_, args, context, info) { + const { + shopId, + conditions, + ...connectionArgs + } = args; + + const query = await context.queries.filterAccounts(context, conditions, shopId); + + return getPaginatedResponse(query, connectionArgs, { + includeHasNextPage: wasFieldRequested("pageInfo.hasNextPage", info), + includeHasPreviousPage: wasFieldRequested("pageInfo.hasPreviousPage", info), + includeTotalCount: wasFieldRequested("totalCount", info) + }); +} diff --git a/packages/api-plugin-accounts/src/resolvers/Query/filterCustomers.js b/packages/api-plugin-accounts/src/resolvers/Query/filterCustomers.js new file mode 100644 index 00000000000..490037732e4 --- /dev/null +++ b/packages/api-plugin-accounts/src/resolvers/Query/filterCustomers.js @@ -0,0 +1,31 @@ +import getPaginatedResponse from "@reactioncommerce/api-utils/graphql/getPaginatedResponse.js"; +import wasFieldRequested from "@reactioncommerce/api-utils/graphql/wasFieldRequested.js"; + +/** + * @name Query/accounts + * @method + * @memberof Customers/Query + * @summary Query for a list of customers + * @param {Object} _ - unused + * @param {Object} args - an object of all arguments that were sent by the client + * @param {String} args.shopId - id of shop to query + * @param {Object} args.conditions - object containing the filter conditions + * @param {Object} context - an object containing the per-request state + * @param {Object} info Info about the GraphQL request + * @returns {Promise} Accounts + */ +export default async function filterCustomers(_, args, context, info) { + const { + shopId, + conditions, + ...connectionArgs + } = args; + + const query = await context.queries.filterCustomers(context, conditions, shopId); + + return getPaginatedResponse(query, connectionArgs, { + includeHasNextPage: wasFieldRequested("pageInfo.hasNextPage", info), + includeHasPreviousPage: wasFieldRequested("pageInfo.hasPreviousPage", info), + includeTotalCount: wasFieldRequested("totalCount", info) + }); +} diff --git a/packages/api-plugin-accounts/src/resolvers/Query/index.js b/packages/api-plugin-accounts/src/resolvers/Query/index.js index 2676a0568a3..96033bee33d 100644 --- a/packages/api-plugin-accounts/src/resolvers/Query/index.js +++ b/packages/api-plugin-accounts/src/resolvers/Query/index.js @@ -5,8 +5,12 @@ import group from "./group.js"; import groups from "./groups.js"; import invitations from "./invitations.js"; import viewer from "./viewer.js"; +import filterAccounts from "./filterAccounts.js"; +import filterCustomers from "./filterCustomers.js"; export default { + filterAccounts, + filterCustomers, account, accounts, customers, diff --git a/packages/api-plugin-accounts/src/schemas/account.graphql b/packages/api-plugin-accounts/src/schemas/account.graphql index 45c6e91f150..785ddbb0c90 100644 --- a/packages/api-plugin-accounts/src/schemas/account.graphql +++ b/packages/api-plugin-accounts/src/schemas/account.graphql @@ -496,6 +496,66 @@ extend type Query { id: ID! ): Account + "Query to get a filtered list of Accounts" + filterAccounts( + "Shop ID" + shopId: ID!, + + "Input Conditions for fliter (use either 'any' or 'all' not both)" + conditions: FilterConditionsInput, + + "Return only results that come after this cursor. Use this with `first` to specify the number of results to return." + after: ConnectionCursor, + + "Return only results that come before this cursor. Use this with `last` to specify the number of results to return." + before: ConnectionCursor, + + "Return at most this many results. This parameter may be used with either `after` or `offset` parameters." + first: ConnectionLimitInt, + + "Return at most this many results. This parameter may be used with the `before` parameter." + last: ConnectionLimitInt, + + "Return only results that come after the Nth result. This parameter may be used with the `first` parameter." + offset: Int + + "Return results sorted in this order" + sortOrder: SortOrder = desc, + + "By default, accounts are sorted by createdAt. Set this to sort by one of the other allowed fields" + sortBy: AccountSortByField = createdAt + ): AccountConnection + + "Query to get a filtered list of Customers" + filterCustomers( + "Shop ID" + shopId: ID!, + + "Input Conditions for fliter (use either 'any' or 'all' not both)" + conditions: FilterConditionsInput, + + "Return only results that come after this cursor. Use this with `first` to specify the number of results to return." + after: ConnectionCursor, + + "Return only results that come before this cursor. Use this with `last` to specify the number of results to return." + before: ConnectionCursor, + + "Return at most this many results. This parameter may be used with either `after` or `offset` parameters." + first: ConnectionLimitInt, + + "Return at most this many results. This parameter may be used with the `before` parameter." + last: ConnectionLimitInt, + + "Return only results that come after the Nth result. This parameter may be used with the `first` parameter." + offset: Int + + "Return results sorted in this order" + sortOrder: SortOrder = desc, + + "By default, customers are sorted by createdAt. Set this to sort by one of the other allowed fields" + sortBy: AccountSortByField = createdAt + ): AccountConnection + "Returns accounts optionally filtered by account groups" accounts( "Return only accounts in any of these groups" diff --git a/packages/api-plugin-orders/package.json b/packages/api-plugin-orders/package.json index 940f4b8b9a2..a5f0da740ea 100644 --- a/packages/api-plugin-orders/package.json +++ b/packages/api-plugin-orders/package.json @@ -26,7 +26,7 @@ }, "sideEffects": false, "dependencies": { - "@reactioncommerce/api-utils": "^1.16.5", + "@reactioncommerce/api-utils": "~1.17.1", "@reactioncommerce/logger": "^1.1.4", "@reactioncommerce/random": "^1.0.2", "@reactioncommerce/reaction-error": "^1.0.1", diff --git a/packages/api-plugin-orders/src/queries/filterOrders.js b/packages/api-plugin-orders/src/queries/filterOrders.js new file mode 100644 index 00000000000..0e4dd714f54 --- /dev/null +++ b/packages/api-plugin-orders/src/queries/filterOrders.js @@ -0,0 +1,25 @@ +import generateFilterQuery from "@reactioncommerce/api-utils/generateFilterQuery.js"; + +/** + * @name filterOrders + * @method + * @memberof GraphQL/Orders + * @summary Query the Orders collection for a list of orders + * @param {Object} context - an object containing the per-request state + * @param {Object} conditions - object containing the filter conditions + * @param {String} shopId - shopID to filter by + * @returns {Promise} Orders object Promise + */ +export default async function filterOrders(context, conditions, shopId) { + const { collections: { Orders } } = context; + + if (!shopId) { + throw new Error("shopId is required"); + } + + await context.validatePermissions("reaction:legacy:orders", "read", { shopId }); + + const { filterQuery } = generateFilterQuery(context, "Order", conditions, shopId); + + return Orders.find(filterQuery); +} diff --git a/packages/api-plugin-orders/src/queries/index.js b/packages/api-plugin-orders/src/queries/index.js index d86caab24bb..7d1bfd3d93c 100644 --- a/packages/api-plugin-orders/src/queries/index.js +++ b/packages/api-plugin-orders/src/queries/index.js @@ -4,8 +4,10 @@ import orders from "./orders.js"; import ordersByAccountId from "./ordersByAccountId.js"; import refunds from "./refunds.js"; import refundsByPaymentId from "./refundsByPaymentId.js"; +import filterOrders from "./filterOrders.js"; export default { + filterOrders, orderById, orderByReferenceId, orders, diff --git a/packages/api-plugin-orders/src/resolvers/Query/filterOrders.js b/packages/api-plugin-orders/src/resolvers/Query/filterOrders.js new file mode 100644 index 00000000000..7e35413bfad --- /dev/null +++ b/packages/api-plugin-orders/src/resolvers/Query/filterOrders.js @@ -0,0 +1,31 @@ +import getPaginatedResponse from "@reactioncommerce/api-utils/graphql/getPaginatedResponse.js"; +import wasFieldRequested from "@reactioncommerce/api-utils/graphql/wasFieldRequested.js"; + +/** + * @name Query/orders + * @method + * @memberof Orders/Query + * @summary Query for a list of orders + * @param {Object} _ - unused + * @param {Object} args - an object of all arguments that were sent by the client + * @param {String} args.shopId - id of shop to query + * @param {Object} args.conditions - object containing the filter conditions + * @param {Object} context - an object containing the per-request state + * @param {Object} info Info about the GraphQL request + * @returns {Promise} Orders + */ +export default async function filterOrders(_, args, context, info) { + const { + shopId, + conditions, + ...connectionArgs + } = args; + + const query = await context.queries.filterOrders(context, conditions, shopId); + + return getPaginatedResponse(query, connectionArgs, { + includeHasNextPage: wasFieldRequested("pageInfo.hasNextPage", info), + includeHasPreviousPage: wasFieldRequested("pageInfo.hasPreviousPage", info), + includeTotalCount: wasFieldRequested("totalCount", info) + }); +} diff --git a/packages/api-plugin-orders/src/resolvers/Query/index.js b/packages/api-plugin-orders/src/resolvers/Query/index.js index d86caab24bb..7d1bfd3d93c 100644 --- a/packages/api-plugin-orders/src/resolvers/Query/index.js +++ b/packages/api-plugin-orders/src/resolvers/Query/index.js @@ -4,8 +4,10 @@ import orders from "./orders.js"; import ordersByAccountId from "./ordersByAccountId.js"; import refunds from "./refunds.js"; import refundsByPaymentId from "./refundsByPaymentId.js"; +import filterOrders from "./filterOrders.js"; export default { + filterOrders, orderById, orderByReferenceId, orders, diff --git a/packages/api-plugin-orders/src/schemas/schema.graphql b/packages/api-plugin-orders/src/schemas/schema.graphql index c0d935743ef..439d7ace624 100644 --- a/packages/api-plugin-orders/src/schemas/schema.graphql +++ b/packages/api-plugin-orders/src/schemas/schema.graphql @@ -1,4 +1,34 @@ extend type Query { + "Query to get a filtered list of Orders" + filterOrders( + "Shop ID" + shopId: ID!, + + "Input Conditions for fliter (use either 'any' or 'all' not both)" + conditions: FilterConditionsInput, + + "Return only results that come after this cursor. Use this with `first` to specify the number of results to return." + after: ConnectionCursor, + + "Return only results that come before this cursor. Use this with `last` to specify the number of results to return." + before: ConnectionCursor, + + "Return at most this many results. This parameter may be used with either `after` or `offset` parameters." + first: ConnectionLimitInt, + + "Return at most this many results. This parameter may be used with the `before` parameter." + last: ConnectionLimitInt, + + "Return only results that come after the Nth result. This parameter may be used with the `first` parameter." + offset: Int + + "Return results sorted in this order" + sortOrder: SortOrder = desc, + + "By default, orders are sorted by createdAt. Set this to sort by one of the other allowed fields" + sortBy: OrdersSortByField = createdAt + ): OrderConnection + "Get an order by its ID" orderById( "The order ID" diff --git a/packages/api-plugin-products/README.md b/packages/api-plugin-products/README.md index 1642f84021b..c8c9bc30559 100644 --- a/packages/api-plugin-products/README.md +++ b/packages/api-plugin-products/README.md @@ -8,6 +8,159 @@ Products plugin for the [Reaction API](https://github.com/reactioncommerce/reaction) +### Example on how to use Filter Conditions + +We have a query endpoint defined in this plugin which allows us to query products collection based on the input GraphQL conditions object. This query endpoint is defined as `filterProducts.js` and it calls the `generateFilterQuery` function from the `api-utils` plugin to generate the MongoDB filter query. + +The `generateFilterQuery` function expects the input GraphQL conditions object to be in the format of the `FilterConditionsInput` input type defined in the GraphQL Schemas (in api-core plugin) along with other parameters like `context`, `collectionName` and `shopId`. + +Please go through a general introduction of how to use this function which can be found in the [api-utils README](https://github.com/reactioncommerce/reaction/tree/trunk/packages/api-utils/docs) before going through the examples below on how to use this function in the context of the `products` plugin. + +In the query endpoint, we pass the `FilterConditionsInput` input type object as the `conditions` argument. This object is passed to the `generateFilterQuery` function along with other parameters like `context`, `collectionName` and `shopId` to generate the MongoDB filter query. The `generateFilterQuery` function is generic and can be used to generate filter queries for any collection. Since the parametes like `context`, `collectionName` and `shopId` are pretty self-explanatory, we shall focus on explaining the various ways in which the `conditions` object can be used. + +1. Single condition. +Here we are querying products collection for entries with the handle as 'mens-waterproof-outdoor-rain-jacket'. Since it is single condition, using either `all` or `any` will not make difference. + +```js +import generateFilterQuery from "@reactioncommerce/api-utils/generateFilterQuery.js"; + +const conditions = { + all: [ + { + any:[ + { + key: "handle", + stringValue: "mens-waterproof-outdoor-rain-jacket", + relationalOperator: eq, + logicalNot: false + } + ] + } + ] + } + + const { filterQuery } = generateFilterQuery(context, "Product", conditions, shopId); + + return Products.find(filterQuery); +``` +
+ +2. Two conditions. + +Here we are querying products collection for entries which have either the handle as 'mens-waterproof-outdoor-rain-jacket' or 'title' begins with the text 'men'. Since we are using the `any` to connect the conditions, it translates to a mongo DB `$or` condition. Please note that the top level `all` condition is only to maintain the structure of the input GraphQL conditions object. It does not impact the results of the inner query. + + +```js +import generateFilterQuery from "@reactioncommerce/api-utils/generateFilterQuery.js"; + +const conditions = { + all: [ + { + any:[ + { + key: "handle", + stringValue: "mens-waterproof-outdoor-rain-jacket", + relationalOperator: eq, + logicalNot: false + }, + { + key: "title", + stringValue: "men", + relationalOperator: beginsWith, + logicalNot: false + caseSensitive: false + } + ] + } + ] + } + + const { filterQuery } = generateFilterQuery(context, "Product", conditions, shopId); + + return Products.find(filterQuery); +``` +
+ +3. Multiple conditions. + +Here we are querying products collection for entries which confirms to multiple conditions. +We have 3 distinct group of conditions in the inner level and the results of all these 3 are joined at the top level with `all` meaning `$and` in MongoDB. + +The first group looks for entries matching either of the conditions `handle` as 'mens-waterproof-outdoor-rain-jacket' or `title` begins with the text 'men'. Since we are using the `any` to connect the conditions, it translates to a mongo DB `$or` condition. + +The second group looks for entries matching the `_id` in the array `["DZwLHk4EAzitRni8F", "Hn4BRaBvLkYffMq36"]` and `isDeleted` as `false` and `workflow.status` as `new`. Since we are using the `all` to connect the conditions, it translates to a mongo DB `$and` condition. + +The third group looks for entries matching the `price.min` greater than 19.99 and `type` as `simple`. Since we are using the `all` to connect the conditions, it translates to a mongo DB `$and` condition. + +As explained above, the final results are joined at the top level with `all` meaning `$and` in MongoDB. + +```js +import generateFilterQuery from "@reactioncommerce/api-utils/generateFilterQuery.js"; + +const conditions = { + all: [ + { + any:[ + { + key: "handle", + stringValue: "mens-waterproof-outdoor-rain-jacket", + relationalOperator: eq, + logicalNot: false + }, + { + key: "title", + stringValue: "men", + relationalOperator: beginsWith, + logicalNot: false + caseSensitive: false + } + ] + }, + { + all:[ + { + key: "_id", + stringArrayValue: ["DZwLHk4EAzitRni8F", "Hn4BRaBvLkYffMq36"], + relationalOperator: in, + logicalNot: false + }, + { + key: "isDeleted", + booleanValue: false, + relationalOperator: eq, + logicalNot: false + }, + { + key: "workflow.status", + stringValue: "new", + relationalOperator: eq, + logicalNot: false + } + ] + }, + { + all:[ + { + key: "price.min", + floatValue: 19.99, + relationalOperator: gte, + logicalNot: false + }, + { + key: "type", + stringValue: "simple", + relationalOperator: eq, + logicalNot: false + } + ] + } + ] + } + + const { filterQuery } = generateFilterQuery(context, "Product", conditions, shopId); + + return Products.find(filterQuery); +``` ## Developer Certificate of Origin We use the [Developer Certificate of Origin (DCO)](https://developercertificate.org/) in lieu of a Contributor License Agreement for all contributions to Reaction Commerce open source projects. We request that contributors agree to the terms of the DCO and indicate that agreement by signing all commits made to Reaction Commerce projects by adding a line with your name and email address to every Git commit message contributed: ``` diff --git a/packages/api-plugin-products/package.json b/packages/api-plugin-products/package.json index e61f74406ad..25952ec9191 100644 --- a/packages/api-plugin-products/package.json +++ b/packages/api-plugin-products/package.json @@ -26,7 +26,7 @@ }, "sideEffects": false, "dependencies": { - "@reactioncommerce/api-utils": "^1.16.5", + "@reactioncommerce/api-utils": "~1.17.1", "@reactioncommerce/logger": "^1.1.3", "@reactioncommerce/random": "^1.0.2", "@reactioncommerce/reaction-error": "^1.0.1", diff --git a/packages/api-plugin-products/src/queries/filterProducts.js b/packages/api-plugin-products/src/queries/filterProducts.js new file mode 100644 index 00000000000..3944075da71 --- /dev/null +++ b/packages/api-plugin-products/src/queries/filterProducts.js @@ -0,0 +1,25 @@ +import generateFilterQuery from "@reactioncommerce/api-utils/generateFilterQuery.js"; + +/** + * @name filterProducts + * @method + * @memberof GraphQL/Products + * @summary Query the Products collection for a list of products + * @param {Object} context - an object containing the per-request state + * @param {Object} conditions - object containing the filter conditions + * @param {String} shopId - shopID to filter by + * @returns {Promise} Products object Promise + */ +export default async function filterProducts(context, conditions, shopId) { + const { collections: { Products } } = context; + + if (!shopId) { + throw new Error("shopId is required"); + } + + await context.validatePermissions("reaction:legacy:products", "read", { shopId }); + + const { filterQuery } = generateFilterQuery(context, "Product", conditions, shopId); + + return Products.find(filterQuery); +} diff --git a/packages/api-plugin-products/src/queries/index.js b/packages/api-plugin-products/src/queries/index.js index 10a623e8f7e..64f5c844918 100644 --- a/packages/api-plugin-products/src/queries/index.js +++ b/packages/api-plugin-products/src/queries/index.js @@ -1,7 +1,9 @@ import product from "./product.js"; import products from "./products.js"; +import filterProducts from "./filterProducts.js"; export default { + filterProducts, product, products }; diff --git a/packages/api-plugin-products/src/resolvers/Query/filterProducts.js b/packages/api-plugin-products/src/resolvers/Query/filterProducts.js new file mode 100644 index 00000000000..b5b7071c4da --- /dev/null +++ b/packages/api-plugin-products/src/resolvers/Query/filterProducts.js @@ -0,0 +1,31 @@ +import getPaginatedResponse from "@reactioncommerce/api-utils/graphql/getPaginatedResponse.js"; +import wasFieldRequested from "@reactioncommerce/api-utils/graphql/wasFieldRequested.js"; + +/** + * @name Query/products + * @method + * @memberof Products/Query + * @summary Query for a list of products + * @param {Object} _ - unused + * @param {Object} args - an object of all arguments that were sent by the client + * @param {String} args.shopId - id of shop to query + * @param {Object} args.conditions - object containing the filter conditions + * @param {Object} context - an object containing the per-request state + * @param {Object} info Info about the GraphQL request + * @returns {Promise} Products + */ +export default async function filterProducts(_, args, context, info) { + const { + shopId, + conditions, + ...connectionArgs + } = args; + + const query = await context.queries.filterProducts(context, conditions, shopId); + + return getPaginatedResponse(query, connectionArgs, { + includeHasNextPage: wasFieldRequested("pageInfo.hasNextPage", info), + includeHasPreviousPage: wasFieldRequested("pageInfo.hasPreviousPage", info), + includeTotalCount: wasFieldRequested("totalCount", info) + }); +} diff --git a/packages/api-plugin-products/src/resolvers/Query/index.js b/packages/api-plugin-products/src/resolvers/Query/index.js index 10a623e8f7e..8f101b0bcd3 100644 --- a/packages/api-plugin-products/src/resolvers/Query/index.js +++ b/packages/api-plugin-products/src/resolvers/Query/index.js @@ -1,7 +1,9 @@ import product from "./product.js"; import products from "./products.js"; +import filterProducts from "./filterProducts.js"; export default { product, - products + products, + filterProducts }; diff --git a/packages/api-plugin-products/src/schemas/product.graphql b/packages/api-plugin-products/src/schemas/product.graphql index 490816860d9..76b6fb273ac 100644 --- a/packages/api-plugin-products/src/schemas/product.graphql +++ b/packages/api-plugin-products/src/schemas/product.graphql @@ -625,6 +625,36 @@ extend type Mutation { } extend type Query { + "Query to get a filtered list of Products" + filterProducts( + "Shop ID" + shopId: ID!, + + "Input Conditions for fliter (use either 'any' or 'all' not both)" + conditions: FilterConditionsInput, + + "Return only results that come after this cursor. Use this with `first` to specify the number of results to return." + after: ConnectionCursor, + + "Return only results that come before this cursor. Use this with `last` to specify the number of results to return." + before: ConnectionCursor, + + "Return at most this many results. This parameter may be used with either `after` or `offset` parameters." + first: ConnectionLimitInt, + + "Return at most this many results. This parameter may be used with the `before` parameter." + last: ConnectionLimitInt, + + "Return only results that come after the Nth result. This parameter may be used with the `first` parameter." + offset: Int + + "Return results sorted in this order" + sortOrder: SortOrder = desc, + + "By default, products are sorted by createdAt. Set this to sort by one of the other allowed fields" + sortBy: ProductSortByField = createdAt + ): ProductConnection + "Query for a single Product" product( "Product ID" diff --git a/packages/api-utils/docs/README.md b/packages/api-utils/docs/README.md index cdb22096034..1924f4d1d5b 100644 --- a/packages/api-utils/docs/README.md +++ b/packages/api-utils/docs/README.md @@ -5,3 +5,4 @@ - [getAbsoluteUrl](./getAbsoluteUrl.md) - [getPaginatedResponseFromAggregate](./getPaginatedResponseFromAggregate.md) - [tagsForCatalogProducts](./tagsForCatalogProducts.md) +- [generateFilterQuery](./generateFilterQuery.md) diff --git a/packages/api-utils/docs/generateFilterQuery.md b/packages/api-utils/docs/generateFilterQuery.md new file mode 100644 index 00000000000..da84b2ddcd1 --- /dev/null +++ b/packages/api-utils/docs/generateFilterQuery.md @@ -0,0 +1,96 @@ + +# generateFilterQuery + +A function that generates a MongoDB filter query from an input GraphQL conditions object. It expects the input GraphQL conditions object to be in the format of the `FilterConditionsInput` input type (detailed below) defined in the GraphQL Schemas along with other parameters like `context`, `collectionName` and `shopId`. + +As seen in the format below, +* The input object is a nested object where you could use either `all` or `any` keys. +* Both the top level `all` & `any` keys are an array of objects with one more level of `all` or `any` keys. +* The inner `all` or `any` keys is an array of objects with the structure defined by `SingleConditionInput`. +* The `SingleConditionInput` object has the fields which define a single condition to filter on. +
+* The `all` key is equivalent of the `$and` operator in MongoDB and the `any` key is equivalent of the `$or` operator in MongoDB. +* In the `SingleConditionInput` object, mandatory fields are the `key`, `relationalOperator` and exactly any ONE of the value fields. The `key` is the field name to filter on. The `relationalOperator` is the relational operator to use to filter on the field (predefined as enum values). The `stringValue`, `integerValue`, `floatValue`, `booleanValue`, `dateValue`, `stringArrayValue`, `integerArrayValue`, `floatArrayValue` are the values to filter on (use exactly one of this) depending on the key. +* Finally there are two more optional fields `caseSensitive` and `logicalNot`. The `caseSensitive` is a boolean flag to set if the regex is case sensitive. The `logicalNot` is a boolean flag to set if the condition is to be negated. + +FilterConditionsInput format below (from GraphQL Schemas). __Example__ follows the format below: + + +```graphql + +input FilterConditionsInput { + all: [ConditionsArray] + + any: [ConditionsArray] +} + + +input ConditionsArray { + all: [SingleConditionInput] + + any: [SingleConditionInput] +} + + +input SingleConditionInput { + booleanValue: Boolean + caseSensitive: Boolean + dateValue: DateTime + floatArrayValue: [Float] + floatValue: Float + integerArrayValue: [Int] + integerValue: Int + key: String! + logicalNot: Boolean + relationalOperator: RelationalOperatorTypes! + stringArrayValue: [String] + stringValue: String +} + + +enum RelationalOperatorTypes{ + beginsWith + endsWith + eq + gt + gte + in + lt + lte + ne + nin + regex +} + +``` + + +## Example + +Example of invoking the function with a simple conditions object. Here we are querying for products collection for entries with the handle as 'mens-waterproof-outdoor-rain-jacket'. Since it is single condition, using either `all` or `any` will not make difference. + +```js +import generateFilterQuery from "@reactioncommerce/api-utils/generateFilterQuery.js"; + +const conditions = { + all: [ + { + any:[ + { + key: "handle", + stringValue: "mens-waterproof-outdoor-rain-jacket", + relationalOperator: eq, + logicalNot: false + } + ] + } + ] + } + + const { filterQuery } = generateFilterQuery(context, "Product", conditions, shopId); + + return Products.find(filterQuery); +``` + +Please refer to readme in the respective plugins for more detailed examples (example: api-plugin-products). + diff --git a/packages/api-utils/lib/collectCollectionFields.js b/packages/api-utils/lib/collectCollectionFields.js new file mode 100644 index 00000000000..4c9e9b6ab4f --- /dev/null +++ b/packages/api-utils/lib/collectCollectionFields.js @@ -0,0 +1,28 @@ +import SimpleSchema from "simpl-schema"; +/** + * @name collectCollectionFields + * @method + * @memberof GraphQL/Filter + * @summary collects all the fields of the specific collection along with metadata + * @param {Object} context - an object containing the per-request state + * @param {String} collectionName - name of the collection + * @returns {Object} - Object with each field as key and type as value + */ +export default function collectCollectionFields(context, collectionName) { + const currentSchema = context.simpleSchemas[collectionName]; + const mergedSchemaObject = currentSchema.mergedSchema(); + const allKeys = Object.keys(mergedSchemaObject); + const returnFieldTypes = {}; + allKeys.forEach((element) => { + const definitionObj = currentSchema.getDefinition(element); + const definition = definitionObj.type[0].type; + if (!SimpleSchema.isSimpleSchema(definition)) { // skip SimpleSchema definition names + if (typeof definition === "function") { + returnFieldTypes[element] = `SimpleSchema.${definition.name}`; + } else { + returnFieldTypes[element] = definition; + } + } + }); + return returnFieldTypes; +} diff --git a/packages/api-utils/lib/generateFilterQuery.js b/packages/api-utils/lib/generateFilterQuery.js new file mode 100644 index 00000000000..15850f43666 --- /dev/null +++ b/packages/api-utils/lib/generateFilterQuery.js @@ -0,0 +1,546 @@ +import SimpleSchema from "simpl-schema"; +import _ from "lodash"; +import collectCollectionFields from "./collectCollectionFields.js"; + +const SingleConditionSchema = new SimpleSchema({ + "key": { + type: String + }, + "stringValue": { + type: String, + optional: true + }, + "integerValue": { + type: SimpleSchema.Integer, + optional: true + }, + "floatValue": { + type: Number, + optional: true + }, + "booleanValue": { + type: Boolean, + optional: true + }, + "dateValue": { + type: Date, + optional: true + }, + "stringArrayValue": { + type: Array, + optional: true + }, + "stringArrayValue.$": { + type: String + }, + "integerArrayValue": { + type: Array, + optional: true + }, + "integerArrayValue.$": { + type: SimpleSchema.Integer + }, + "floatArrayValue": { + type: Array, + optional: true + }, + "floatArrayValue.$": { + type: Number + }, + "relationalOperator": { + type: String, + allowedValues: ["eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "regex", "beginsWith", "endsWith"] + }, + "logicalNot": { + type: Boolean, + optional: true + }, + "caseSensitive": { + type: Boolean, + optional: true + } +}); + +const ConditionsSchema = new SimpleSchema({ + "any": { + type: Array, + optional: true + }, + "any.$": { + type: SingleConditionSchema + }, + "all": { + type: Array, + optional: true + }, + "all.$": { + type: SingleConditionSchema + } +}); + +const ConditionsArraySchema = new SimpleSchema({ + "any": { + type: Array, + optional: true + }, + "any.$": { + type: ConditionsSchema + }, + "all": { + type: Array, + optional: true + }, + "all.$": { + type: ConditionsSchema + } +}); + +const validCombos = { + "SimpleSchema.String": { + relationalOperator: ["eq", "ne", "in", "nin", "regex", "beginsWith", "endsWith"], + typeOf: ["string"] + }, + "SimpleSchema.Integer": { + relationalOperator: ["eq", "ne", "gt", "gte", "lt", "lte", "in", "nin"], + typeOf: ["number"] + }, + "SimpleSchema.Number": { + relationalOperator: ["eq", "ne", "gt", "gte", "lt", "lte", "in", "nin"], + typeOf: ["number"] + }, + "SimpleSchema.Array": { + relationalOperator: ["in", "nin", "eq", "ne"], + typeOf: ["array"] + }, + "SimpleSchema.Boolean": { + relationalOperator: ["eq", "ne"], + typeOf: ["boolean"] + }, + "SimpleSchema.Date": { + relationalOperator: ["eq", "ne", "gt", "gte", "lt", "lte"], + typeOf: ["date"] + } +}; + +const REL_OPS_KEYS = ["any", "all"]; + +const FIELD_KEYS = [ + "key", "stringValue", "booleanValue", "integerValue", "floatValue", "dateValue", + "stringArrayValue", "integerArrayValue", "floatArrayValue", + "relationalOperator", "caseSensitive", "logicalNot" +]; + +const keyMap = { + all: "$and", + any: "$or" +}; + +/** + * @name verifyAllFieldKeys + * @method + * @memberof GraphQL/Filter + * @summary Verifies if the input array of keys are all field keys + * @param {String[]} keys - array of key to be verified + * @returns {Boolean} - verfication result + */ +function verifyAllFieldKeys(keys) { + // verify all keys in input array are valid and present in FIELD_KEYS + for (const key of keys) { + if (!FIELD_KEYS.includes(key)) { + return false; + } + } + return true; +} + +/** + * @name verifyAllRelOpKeys + * @method + * @memberof GraphQL/Filter + * @summary Verifies if the input array of keys are all Relational operator keys + * @param {String[]} keys - array of key to be verified + * @returns {Boolean} - verfication result + */ +function verifyAllRelOpKeys(keys) { + // verify all keys in input array are valid and present in REL_OPS_KEYS + for (const key of keys) { + if (!REL_OPS_KEYS.includes(key)) { + return false; + } + } + return true; +} + + +/** + * @name checkIfCompoundCondition + * @method + * @memberof GraphQL/Filter + * @summary Checks if the input filter condition is a compound condition + * @param {Object} filterQuery - condition object to be verified + * @returns {Boolean} - verfication result + */ +function checkIfCompoundCondition(filterQuery) { + const allKeys = Object.keys(filterQuery); + if (!allKeys || allKeys.length === 0) { + throw new Error("Filter condition must have at least one key"); + } + + if (allKeys.length > 1) { // compound condition will have only one key (all/any) + return false; + } + + const isTopLevelRelOpKeys = verifyAllRelOpKeys(allKeys); // verify if the key is a valid relational operator key + if (!isTopLevelRelOpKeys) { + return false; + } + + const filterConditions = filterQuery[allKeys[0]]; // get the array of filter conditions for the relational operator + if (!filterConditions || !Array.isArray(filterConditions) || filterConditions.length === 0) { + throw new Error("Filter condition array must have at least one condition"); + } + + const conditionKeys = []; // collect all the keys in the next level of the filter condition + for (const condition of filterConditions) { + const keys = Object.keys(condition); + conditionKeys.push(...keys); + } + + const allAreRelOpKeys = verifyAllRelOpKeys(conditionKeys); // verify the next level is also relational operator keys + if (!allAreRelOpKeys) { + return false; + } + + return true; +} + + +/** + * @name collectAtomicFilters + * @method + * @memberof GraphQL/Filter + * @summary Collects atomic filters from a filter query + * @param {Object} filter - an object containing the filters to apply + * @returns {Array} - array of atomic filters + */ +function collectAtomicFilters(filter) { + const atomicFilters = []; + if (!filter) return atomicFilters; + + const isCompoundCondition = checkIfCompoundCondition(filter); + if (!isCompoundCondition) { + const currKey = Object.keys(filter)[0]; + const filters = filter[currKey]; + for (const eachFilter of filters) { + atomicFilters.push(eachFilter); + } + return atomicFilters; + } + + for (const fqKey of Object.keys(filter)) { + if (fqKey === "any" || fqKey === "all") { + const fq = filter[fqKey]; + if (Array.isArray(fq)) { + for (const fqItem of fq) { + atomicFilters.push(...collectAtomicFilters(fqItem)); + } + } + } + } + return atomicFilters; +} + +/** + * @name countInputValueFields + * @method + * @memberof GraphQL/Filter + * @summary Counts the number of fields received with the input value + * @param {Object} inputValue - input value object + * @returns {Number} - number of fields in the input value + */ +function countInputValueFields(inputValue) { + let count = 0; + for (const key of Object.keys(inputValue)) { + if (inputValue[key] !== null && inputValue[key] !== undefined) { + count += 1; + } + } + return count; +} + +/** + * @name validateConditions + * @method + * @memberof GraphQL/Filter + * @summary Validates the Filter conditions + * @param {Object} allConditions - array of conditions to validate + * @param {Object} allCollectionFields - array of fields from collection with metadata + * @returns {undefined} + */ +function validateConditions(allConditions, allCollectionFields) { + for (const condition of allConditions) { + const { + key, stringValue, integerValue, floatValue, booleanValue, dateValue, + stringArrayValue, integerArrayValue, floatArrayValue, relationalOperator + } = condition; // logicalNot, caseSensitive are optional + const expectedValueType = allCollectionFields[key]; + + const inputValuesObject = { stringValue, integerValue, floatValue, booleanValue, dateValue, stringArrayValue, integerArrayValue, floatArrayValue }; + const inputValuesCount = countInputValueFields(inputValuesObject); + if (inputValuesCount > 1) { + throw new Error(`Only one value must be provided for the condition with key: ${key}`); + } + + // if key not in list of collection fields, throw error + if (!Object.keys(allCollectionFields).includes(key)) { + throw new Error(`Invalid key: ${key}`); + } + + // if expectedValueType does not match the type of value, throw error + if (expectedValueType === "SimpleSchema.String" && stringValue === undefined && stringArrayValue === undefined) { + throw new Error(`Key '${key}' expects either stringValue & stringArrayValue`); + } else if (expectedValueType === "SimpleSchema.Integer" && integerValue === undefined && integerArrayValue === undefined) { + throw new Error(`Key '${key}' expects either integerValue & integerArrayValue`); + } else if (expectedValueType === "SimpleSchema.Number" && floatValue === undefined && floatArrayValue === undefined) { + throw new Error(`Key '${key}' expects either floatValue & floatArrayValue`); + } else if (expectedValueType === "SimpleSchema.Boolean" && booleanValue === undefined) { + throw new Error(`Key '${key}' expects booleanValue`); + } else if (expectedValueType === "SimpleSchema.Date" && dateValue === undefined) { + throw new Error(`Key '${key}' expects dateValue`); + } // array can be compared with any of the above types, skipping this check + + if (validCombos[expectedValueType].relationalOperator.indexOf(relationalOperator) === -1) { + throw new Error(`Invalid relational operator '${relationalOperator}' for : ${expectedValueType}`); + } + + if (expectedValueType === "SimpleSchema.Array" && stringArrayValue?.length === 0 && integerArrayValue?.length === 0 && floatArrayValue?.length === 0) { + throw new Error("Array value cannot be empty"); + } + } +} + + +/** + * @name simpleConditionToQuery + * @method + * @memberof GraphQL/Filter + * @summary Converts a simple condition to a MongoDB query + * @param {Object} condition The condition to convert + * @param {String} condition.key The key to convert + * @param {String} condition.stringValue The value in String format + * @param {Number} condition.integerValue The value in Integer format + * @param {Number} condition.floatValue The value in Integer format + * @param {Boolean} condition.booleanValue The value in Boolean format + * @param {String} condition.dateValue The value in Date/String format + * @param {String[]} [condition.stringArrayValue] The value in String Array format + * @param {Number[]} [condition.integerArrayValue] The value in Integer Array format + * @param {Number[]} [condition.floatArrayValue] The value in Integer Array format + * @param {String} condition.relationalOperator The relational operator to use + * @param {String} condition.logicalNot Whether to negate the condition + * @param {String} condition.caseSensitive Whether regex search is caseSensitive + * @returns {Object} The MongoDB query + */ +function simpleConditionToQuery(condition) { + const { + key, stringValue, integerValue, floatValue, booleanValue, dateValue, + stringArrayValue, integerArrayValue, floatArrayValue, + relationalOperator, logicalNot, caseSensitive + } = condition; + const query = {}; + const valueToUse = stringValue || integerValue || floatValue || booleanValue || dateValue || + stringArrayValue || integerArrayValue || floatArrayValue; + + let tempQuery; + switch (relationalOperator) { + case "eq": + if (booleanValue !== undefined) { + tempQuery = { $eq: booleanValue }; + } else { + tempQuery = { $eq: valueToUse }; + } + break; + case "ne": + if (booleanValue !== undefined) { + tempQuery = { $ne: booleanValue }; + } else { + tempQuery = { $ne: valueToUse }; + } + break; + case "gt": + tempQuery = { $gt: valueToUse }; + break; + case "gte": + tempQuery = { $gte: valueToUse }; + break; + case "lt": + tempQuery = { $lt: valueToUse }; + break; + case "lte": + tempQuery = { $lte: valueToUse }; + break; + case "in": + tempQuery = { $in: valueToUse }; + break; + case "nin": + tempQuery = { $nin: valueToUse }; + break; + case "regex": + tempQuery = { $regex: valueToUse }; + if (!caseSensitive) { + tempQuery.$options = "i"; + } else { + tempQuery.$options = ""; + } + break; + case "beginsWith": + tempQuery = { $regex: `^${valueToUse}` }; + if (!caseSensitive) { + tempQuery.$options = "i"; + } + break; + case "endsWith": + tempQuery = { $regex: `${valueToUse}$` }; + if (!caseSensitive) { + tempQuery.$options = "i"; + } + break; + default: + throw new Error(`Invalid relational operator: ${relationalOperator}`); + } + + query[key] = logicalNot ? { $not: tempQuery } : tempQuery; + + return query; +} + + +/** + * @name processArrayElements + * @method + * @memberof GraphQL/Filter + * @summary Process a simple/single condition by calling simpleConditionToQuery + * @param {Object} element - simple/single condition to be processed + * @returns {Boolean} - query object for a single condition + */ +function processArrayElements(element) { + const allKeys = Object.keys(element); + if (allKeys.length !== 1) { + throw new Error("Invalid input. Array element must have exactly one key"); + } + + const relOp = allKeys[0]; + if (!REL_OPS_KEYS.includes(relOp)) { + throw new Error(`Invalid relational operator: ${relOp}`); + } + + const value = element[relOp]; + if (!Array.isArray(value)) { + throw new Error("Invalid input. Value must be an array"); + } + + const subQueryArray = []; + for (const item of value) { + const fieldKeys = Object.keys(item); + const validFieldKeys = verifyAllFieldKeys(fieldKeys); + if (!validFieldKeys) { + throw new Error("Invalid input. Invalid key in array element"); + } + const singleQueryObject = simpleConditionToQuery(item); + subQueryArray.push(singleQueryObject); + } + + const queryObject = {}; + queryObject[keyMap[relOp]] = subQueryArray; + return queryObject; +} + +/** + * @name processFilterConditions + * @method + * @memberof GraphQL/Filter + * @summary This function is recursively called for all compound conditions + * till it reaches the simple/single condition when it calls processArrayElements + * @param {Object} filterCondition - filter condition to be processed + * @returns {Boolean} - final query object + */ +function processFilterConditions(filterCondition) { + const isCompoundCondition = checkIfCompoundCondition(filterCondition); + + let returnObject; + if (isCompoundCondition) { + const allKeys = Object.keys(filterCondition); + const singleKey = allKeys[0]; + const subConditions = filterCondition[singleKey]; + const subQueryArray = []; + for (const subCondition of subConditions) { + const subQuery = processFilterConditions(subCondition); + subQueryArray.push(subQuery); + } + const key = keyMap[singleKey]; + const query = {}; + query[key] = subQueryArray; + returnObject = query; + } else { + const singleQueryObject = processArrayElements(filterCondition); + returnObject = singleQueryObject; + } + return returnObject; +} + + +/** + * @name generateQuery + * @method + * @memberof GraphQL/Filter + * @summary Builds a selector for given collection, given a set of filters + * @param {Object} filterQuery - an object containing the filters to apply + * @param {String} shopId - the shop ID + * @returns {Object} - selector + */ +function generateQuery(filterQuery, shopId) { + if (!filterQuery) return {}; + + if (_.size(filterQuery) === 0) return {}; + + const keysTopLevel = Object.keys(filterQuery); + if (keysTopLevel.length !== 1) { + throw new Error("Filter condition must have exactly one key at top level"); + } + const topLevelKey = keysTopLevel[0]; + if (!REL_OPS_KEYS.includes(topLevelKey)) { + throw new Error(`Invalid top level key: ${topLevelKey}. Expected one of: ${REL_OPS_KEYS.join(", ")}`); + } + + const selectorObject = processFilterConditions(filterQuery); + + // If a shopId was provided, add it + if (shopId) { + selectorObject.shopId = shopId; + } + + return selectorObject; +} + +/** + * @name generateFilterQuery + * @method + * @memberof GraphQL/Filter + * @summary Generates a filter Query for the collection in params based on incoming conditions + * @param {Object} context - an object containing the per-request state + * @param {String} collectionName - Collection against which to run the query + * @param {Object} conditions - the conditions for the filter + * @param {String} shopId - shopID to filter by + * @returns {Object} Filter query object + */ +export default function generateFilterQuery(context, collectionName, conditions, shopId) { + ConditionsArraySchema.validate(conditions); + + const allConditions = collectAtomicFilters(conditions); + const allCollectionFields = collectCollectionFields(context, collectionName); + validateConditions(allConditions, allCollectionFields); + + const selector = generateQuery(conditions, shopId); + return { + filterQuery: selector + }; +} diff --git a/packages/api-utils/lib/generateFilterQuery.test.js b/packages/api-utils/lib/generateFilterQuery.test.js new file mode 100644 index 00000000000..dbda4c44d7d --- /dev/null +++ b/packages/api-utils/lib/generateFilterQuery.test.js @@ -0,0 +1,265 @@ +import generateFilterQuery from "./generateFilterQuery.js"; +import mockCollection from "./tests/mockCollection.js"; +import mockContext from "./tests/mockContext.js"; + +mockContext.collections.Products = mockCollection("Products"); + +jest.mock("./collectCollectionFields", () => jest.fn().mockImplementation(() => ({ + "_id": "SimpleSchema.String", + "ancestors": "SimpleSchema.Array", + "ancestors.$": "SimpleSchema.String", + "createdAt": "SimpleSchema.Date", + "currentProductHash": "SimpleSchema.String", + "description": "SimpleSchema.String", + "facebookMsg": "SimpleSchema.String", + "googleplusMsg": "SimpleSchema.String", + "handle": "SimpleSchema.String", + "hashtags": "SimpleSchema.Array", + "hashtags.$": "SimpleSchema.String", + "isDeleted": "SimpleSchema.Boolean", + "isVisible": "SimpleSchema.Boolean", + "metaDescription": "SimpleSchema.String", + "metafields": "SimpleSchema.Array", + "metafields.$.key": "SimpleSchema.String", + "metafields.$.namespace": "SimpleSchema.String", + "metafields.$.scope": "SimpleSchema.String", + "metafields.$.value": "SimpleSchema.String", + "metafields.$.valueType": "SimpleSchema.String", + "metafields.$.description": "SimpleSchema.String", + "originCountry": "SimpleSchema.String", + "pageTitle": "SimpleSchema.String", + "parcel.containers": "SimpleSchema.String", + "parcel.length": "SimpleSchema.Number", + "parcel.width": "SimpleSchema.Number", + "parcel.height": "SimpleSchema.Number", + "parcel.weight": "SimpleSchema.Number", + "pinterestMsg": "SimpleSchema.String", + "productType": "SimpleSchema.String", + "publishedAt": "SimpleSchema.Date", + "publishedProductHash": "SimpleSchema.String", + "shopId": "SimpleSchema.String", + "shouldAppearInSitemap": "SimpleSchema.Boolean", + "supportedFulfillmentTypes": "SimpleSchema.Array", + "supportedFulfillmentTypes.$": "SimpleSchema.String", + "template": "SimpleSchema.String", + "title": "SimpleSchema.String", + "twitterMsg": "SimpleSchema.String", + "type": "SimpleSchema.String", + "updatedAt": "SimpleSchema.Date", + "vendor": "SimpleSchema.String", + "workflow.status": "SimpleSchema.String", + "workflow.workflow": "SimpleSchema.Array", + "workflow.workflow.$": "SimpleSchema.String", + "price.range": "SimpleSchema.String", + "price.min": "SimpleSchema.Number", + "price.max": "SimpleSchema.Number" +}))); + + +test("returns the correct Query when single condition is given", () => { + const shopId = "SHOP123"; + const collectionName = "Product"; + const conditions = { + all: [{ + all: [ + { + key: "handle", + stringValue: "mens-waterproof-outdoor-rain-jacket", + relationalOperator: "eq", + logicalNot: false + } + ] + }] + }; + + const { filterQuery } = generateFilterQuery(mockContext, collectionName, conditions, shopId); + + const expectedResult = { + $and: [{ + $and: [ + { + handle: { + $eq: "mens-waterproof-outdoor-rain-jacket" + } + } + ] + }], + shopId: "SHOP123" + }; + + expect(filterQuery).toStrictEqual(expectedResult); +}); + +test("returns the correct Query when two conditions are given", () => { + const shopId = "SHOP123"; + const collectionName = "Product"; + const conditions = { + any: [ + { + all: [ + { + key: "handle", + stringValue: "mens-waterproof-outdoor-rain-jacket", + relationalOperator: "eq", + logicalNot: false + }, + { + key: "_id", + stringValue: "DZwLHk4EAzitRni8F", + relationalOperator: "eq", + logicalNot: false + } + ] + } + ] + }; + + const { filterQuery } = generateFilterQuery(mockContext, collectionName, conditions, shopId); + + const expectedResult = { + $or: [ + { + $and: [ + { + handle: { + $eq: "mens-waterproof-outdoor-rain-jacket" + } + }, + { + _id: { + $eq: "DZwLHk4EAzitRni8F" + } + } + ] + } + ], + shopId: "SHOP123" + }; + expect(filterQuery).toStrictEqual(expectedResult); +}); + + +test("returns the correct Query when multiple conditions are given", () => { + const shopId = "SHOP123"; + const collectionName = "Product"; + const conditions = { + all: [ + { + any: [ + { + key: "handle", + stringValue: "mens-waterproof-outdoor-rain-jacket", + relationalOperator: "eq", + logicalNot: false + }, + { + key: "title", + stringValue: "men", + relationalOperator: "beginsWith", + logicalNot: false, + caseSensitive: false + } + ] + }, + { + all: [ + { + key: "_id", + stringArrayValue: ["DZwLHk4EAzitRni8F", "Hn4BRaBvLkYffMq36"], + relationalOperator: "in", + logicalNot: false + }, + { + key: "isDeleted", + booleanValue: false, + relationalOperator: "eq", + logicalNot: false + }, + { + key: "workflow.status", + stringValue: "new", + relationalOperator: "eq", + logicalNot: false + } + ] + }, + { + all: [ + { + key: "price.min", + floatValue: 19.99, + relationalOperator: "gte", + logicalNot: false + }, + { + key: "type", + stringValue: "simple", + relationalOperator: "eq", + logicalNot: false + } + ] + } + ] + }; + + const { filterQuery } = generateFilterQuery(mockContext, collectionName, conditions, shopId); + + const expectedResult = { + $and: [ + { + $or: [ + { + handle: { + $eq: "mens-waterproof-outdoor-rain-jacket" + } + }, + { + title: { + $regex: "^men", + $options: "i" + } + } + ] + }, + { + $and: [ + { + _id: { + $in: [ + "DZwLHk4EAzitRni8F", + "Hn4BRaBvLkYffMq36" + ] + } + }, + { + isDeleted: { + $eq: false + } + }, + { + "workflow.status": { + $eq: "new" + } + } + ] + }, + { + $and: [ + { + "price.min": { + $gte: 19.99 + } + }, + { + type: { + $eq: "simple" + } + } + ] + } + ], + shopId: "SHOP123" + }; + + expect(filterQuery).toStrictEqual(expectedResult); +}); diff --git a/packages/api-utils/package.json b/packages/api-utils/package.json index 9c8336ca98c..8968d847782 100644 --- a/packages/api-utils/package.json +++ b/packages/api-utils/package.json @@ -51,7 +51,8 @@ "graphql-relay": "^0.9.0", "lodash": "^4.17.15", "ramda": "^0.28.0", - "transliteration": "^2.1.9" + "transliteration": "^2.1.9", + "simpl-schema": "^1.12.0" }, "devDependencies": { "@babel/core": "^7.9.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6db6eebd2d..9c19e3ff727 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -332,7 +332,7 @@ importers: specifiers: '@babel/core': ^7.7.7 '@babel/preset-env': ^7.7.7 - '@reactioncommerce/api-utils': ^1.16.9 + '@reactioncommerce/api-utils': ~1.17.1 '@reactioncommerce/babel-remove-es-create-require': ~1.0.0 '@reactioncommerce/data-factory': ~1.0.1 '@reactioncommerce/db-version-check': ^1.0.0 @@ -817,7 +817,7 @@ importers: '@babel/preset-env': ^7.7.7 '@reactioncommerce/api-plugin-catalogs': ^1.0.0 '@reactioncommerce/api-plugin-shops': ^1.0.0 - '@reactioncommerce/api-utils': ^1.16.5 + '@reactioncommerce/api-utils': ~1.17.1 '@reactioncommerce/babel-remove-es-create-require': ~1.0.0 '@reactioncommerce/data-factory': ^1.0.1 '@reactioncommerce/logger': ^1.1.4 @@ -968,7 +968,7 @@ importers: specifiers: '@babel/core': ^7.7.7 '@babel/preset-env': ^7.7.7 - '@reactioncommerce/api-utils': ^1.16.5 + '@reactioncommerce/api-utils': ~1.17.1 '@reactioncommerce/babel-remove-es-create-require': ~1.0.0 '@reactioncommerce/data-factory': ~1.0.1 '@reactioncommerce/logger': ^1.1.3 @@ -1331,6 +1331,7 @@ importers: graphql-relay: ^0.9.0 lodash: ^4.17.15 ramda: ^0.28.0 + simpl-schema: ^1.12.0 transliteration: ^2.1.9 dependencies: '@jest/globals': 26.6.2 @@ -1344,6 +1345,7 @@ importers: graphql-relay: 0.9.0_graphql@14.7.0 lodash: 4.17.21 ramda: 0.28.0 + simpl-schema: 1.12.3 transliteration: 2.3.5 devDependencies: '@babel/core': 7.19.0