From a833c3c98c191fa5590acbfb701bf25e035cc14e Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Wed, 25 Jun 2025 09:51:37 +0200 Subject: [PATCH] fix(utils): build query withDeleted remove auto detection (#12788) **What** Currently, filtering data providing a `deleted_at` value will automatically apply the `withDeleted` flag which in turns remove the default constraint apply to all queries `deleted_at: null`. The problem is that it does not account for the value assign to `deleted_at` leading to inconsistent behaviour depending on the value. e.g filtering with `deleted_at: { $eq: null }` where the expectation is to only filter the non deleted record will end up returning deleted record as well by applying the `withDeleted` filters. This pr revert this auto detection if favor of the user providing `withDeleted` explicitly, as it is already supported , plus the filters. Further more, some integration tests demonstrate how to filter deleted records (e.g product) from the api. While the api did not properly support it, this pr adds support to pass with_deleted flags to the query and being handled accordingly to our api support. Validators have been updated and product list end point benefit from it. Also, the list config type was already accepting such value which I have translated to the remote query config. Also, since the previous pr was adjusting the product types, I ve adjusted them to match the expectation --- .../__tests__/product/admin/product.spec.ts | 7 +++-- packages/core/framework/src/http/types.ts | 1 + .../src/http/utils/get-query-config.ts | 11 +++++++- .../src/http/utils/refetch-entities.ts | 5 ++-- .../src/http/utils/validate-query.ts | 6 +++-- packages/core/types/src/common/common.ts | 7 ++++- packages/core/types/src/product/common.ts | 27 ++++++++++--------- .../modules-sdk/__tests__/build-query.spec.ts | 2 +- .../core/utils/src/modules-sdk/build-query.ts | 26 ++++-------------- .../medusa/src/api/admin/products/route.ts | 4 ++- packages/medusa/src/api/utils/validators.ts | 6 +++++ .../product-module-service/products.spec.ts | 11 +++++--- 12 files changed, 67 insertions(+), 46 deletions(-) diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index f92456b01c..11322c4a9f 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -449,7 +449,7 @@ medusaIntegrationTestRunner({ // BREAKING: Comparison operators changed, so eg. `gt` became `$gt` const response = await api .get( - `/admin/products?deleted_at[$gt]=01-26-1990&q=test`, + `/admin/products?deleted_at[$gt]=01-26-1990&with_deleted=true&q=test`, adminHeaders ) .catch((err) => { @@ -519,7 +519,10 @@ medusaIntegrationTestRunner({ it("returns a list of deleted products", async () => { const response = await api - .get(`/admin/products?deleted_at[$gt]=01-26-1990`, adminHeaders) + .get( + `/admin/products?deleted_at[$gt]=01-26-1990&with_deleted=true`, + adminHeaders + ) .catch((err) => { console.log(err) }) diff --git a/packages/core/framework/src/http/types.ts b/packages/core/framework/src/http/types.ts index 990c81812a..64be1afd67 100644 --- a/packages/core/framework/src/http/types.ts +++ b/packages/core/framework/src/http/types.ts @@ -141,6 +141,7 @@ export interface MedusaRequest< queryConfig: { fields: string[] pagination: { order?: Record; skip: number; take?: number } + withDeleted?: boolean } /** diff --git a/packages/core/framework/src/http/utils/get-query-config.ts b/packages/core/framework/src/http/utils/get-query-config.ts index 993082d390..574a3afc4b 100644 --- a/packages/core/framework/src/http/utils/get-query-config.ts +++ b/packages/core/framework/src/http/utils/get-query-config.ts @@ -101,7 +101,13 @@ export function prepareListQuery( defaultLimit = 50, isList, } = queryConfig - const { order, fields, limit = defaultLimit, offset = 0 } = validated + const { + order, + fields, + limit = defaultLimit, + offset = 0, + with_deleted, + } = validated // 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. @@ -220,6 +226,7 @@ export function prepareListQuery( skip: offset, take: limit, order: finalOrder, + withDeleted: with_deleted, }, remoteQueryConfig: { // Add starFields that are relations only on which we want all properties with a dedicated format to the remote query @@ -234,6 +241,7 @@ export function prepareListQuery( order: finalOrder, } : {}, + withDeleted: with_deleted, }, } } @@ -255,6 +263,7 @@ export function prepareRetrieveQuery( remoteQueryConfig: { fields: remoteQueryConfig.fields, pagination: {}, + withDeleted: remoteQueryConfig.withDeleted, }, } } diff --git a/packages/core/framework/src/http/utils/refetch-entities.ts b/packages/core/framework/src/http/utils/refetch-entities.ts index d277addae5..2e92cf9aa4 100644 --- a/packages/core/framework/src/http/utils/refetch-entities.ts +++ b/packages/core/framework/src/http/utils/refetch-entities.ts @@ -11,7 +11,8 @@ export const refetchEntities = async ( idOrFilter: string | object, scope: MedusaContainer, fields: string[], - pagination?: MedusaRequest["queryConfig"]["pagination"] + pagination?: MedusaRequest["queryConfig"]["pagination"], + withDeleted?: boolean ) => { const remoteQuery = scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) const filters = isString(idOrFilter) ? { id: idOrFilter } : idOrFilter @@ -25,7 +26,7 @@ export const refetchEntities = async ( delete filters.context } - const variables = { filters, ...context, ...pagination } + const variables = { filters, ...context, ...pagination, withDeleted } const queryObject = remoteQueryObjectFromString({ entryPoint, diff --git a/packages/core/framework/src/http/utils/validate-query.ts b/packages/core/framework/src/http/utils/validate-query.ts index 726e88eaf4..a82dcb856c 100644 --- a/packages/core/framework/src/http/utils/validate-query.ts +++ b/packages/core/framework/src/http/utils/validate-query.ts @@ -77,9 +77,10 @@ export function validateAndTransformQuery( } delete req.allowed - const query = normalizeQuery(req) + const query = normalizeQuery(req) as Record const validated = await zodValidator(zodSchema, query) + const cnf = queryConfig.isList ? prepareListQuery(validated, { ...queryConfig, allowed, restricted }) : prepareRetrieveQuery(validated, { @@ -88,7 +89,8 @@ export function validateAndTransformQuery( restricted, }) - req.validatedQuery = validated + const { with_deleted, ...validatedQueryFilters } = validated + req.validatedQuery = validatedQueryFilters req.filterableFields = getFilterableFields(req.validatedQuery) req.queryConfig = cnf.remoteQueryConfig as any req.remoteQueryConfig = req.queryConfig diff --git a/packages/core/types/src/common/common.ts b/packages/core/types/src/common/common.ts index 6aaa36f4ee..16a1185d7f 100644 --- a/packages/core/types/src/common/common.ts +++ b/packages/core/types/src/common/common.ts @@ -80,7 +80,7 @@ export interface FindConfig { /** * An array of strings, each being relation names of the entity to retrieve in the result. - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. */ @@ -150,6 +150,11 @@ export type RequestQueryFields = { * The field to sort the data by. By default, the sort order is ascending. To change the order to descending, prefix the field name with `-`. */ order?: string + + /** + * Whether to include deleted records in the result. + */ + with_deleted?: boolean } /** diff --git a/packages/core/types/src/product/common.ts b/packages/core/types/src/product/common.ts index edad5a1cba..3552df4c30 100644 --- a/packages/core/types/src/product/common.ts +++ b/packages/core/types/src/product/common.ts @@ -688,27 +688,30 @@ export interface FilterableProductProps /** * The status to filter products by */ - status?: ProductStatus | ProductStatus[] + status?: + | ProductStatus + | ProductStatus[] + | OperatorMap /** * The titles to filter products by. */ - title?: string | string[] + title?: string | string[] | OperatorMap /** * The handles to filter products by. */ - handle?: string | string[] + handle?: string | string[] | OperatorMap /** * The skus to filter products by. */ - sku?: string | string[] + sku?: string | string[] | OperatorMap /** * The IDs to filter products by. */ - id?: string | string[] + id?: string | string[] | OperatorMap /** * The external IDs to filter products by. */ - external_id?: string | string[] + external_id?: string | string[] | OperatorMap /** * Filters only or excluding gift card products */ @@ -736,27 +739,27 @@ export interface FilterableProductProps /** * Filter a product by the ID of the associated type */ - type_id?: string | string[] + type_id?: string | string[] | OperatorMap /** * Filter a product by the IDs of their associated categories. */ - categories?: { id: OperatorMap } | { id: OperatorMap } + categories?: { id: string | string[] | OperatorMap } /** * Filters a product by the IDs of their associated collections. */ - collection_id?: string | string[] | OperatorMap + collection_id?: string | string[] | OperatorMap /** * Filters a product based on when it was created */ - created_at?: OperatorMap + created_at?: string | OperatorMap /** * Filters a product based on when it was updated */ - updated_at?: OperatorMap + updated_at?: string | OperatorMap /** * Filters soft-deleted products based on the date they were deleted at. */ - deleted_at?: OperatorMap + deleted_at?: string | OperatorMap } /** diff --git a/packages/core/utils/src/modules-sdk/__tests__/build-query.spec.ts b/packages/core/utils/src/modules-sdk/__tests__/build-query.spec.ts index b30c3c994f..17f601337b 100644 --- a/packages/core/utils/src/modules-sdk/__tests__/build-query.spec.ts +++ b/packages/core/utils/src/modules-sdk/__tests__/build-query.spec.ts @@ -78,7 +78,7 @@ describe("buildQuery", () => { test("should handle withDeleted flag", () => { const filters = { deleted_at: "some-value" } - const result = buildQuery(filters) + const result = buildQuery(filters, { withDeleted: true }) expect(result.options.filters).toHaveProperty(SoftDeletableFilterKey) expect(result.options.filters?.[SoftDeletableFilterKey]).toEqual({ withDeleted: true, diff --git a/packages/core/utils/src/modules-sdk/build-query.ts b/packages/core/utils/src/modules-sdk/build-query.ts index 315ede012e..88afe1f761 100644 --- a/packages/core/utils/src/modules-sdk/build-query.ts +++ b/packages/core/utils/src/modules-sdk/build-query.ts @@ -3,13 +3,6 @@ import { deduplicate, isObject } from "../common" import { SoftDeletableFilterKey } from "../dal/mikro-orm/mikro-orm-soft-deletable-filter" -// Following convention here is fine, we can make it configurable if needed. -const DELETED_AT_FIELD_NAME = "deleted_at" - -type FilterFlags = { - withDeleted?: boolean -} - export function buildQuery( filters: Record = {}, config: FindConfig> & { @@ -17,8 +10,7 @@ export function buildQuery( } = {} ): Required> { const where = {} as DAL.FilterQuery - const filterFlags: FilterFlags = {} - buildWhere(filters, where, filterFlags) + buildWhere(filters, where) delete config.primaryKeyFields @@ -41,7 +33,7 @@ export function buildQuery( >["options"]["orderBy"] } - if (config.withDeleted || filterFlags.withDeleted) { + if (config.withDeleted) { findOptions.filters ??= {} findOptions.filters[SoftDeletableFilterKey] = { withDeleted: true, @@ -63,16 +55,8 @@ export function buildQuery( return { where, options: findOptions } as Required> } -function buildWhere( - filters: Record = {}, - where = {}, - flags: FilterFlags = {} -) { +function buildWhere(filters: Record = {}, where = {}) { for (let [prop, value] of Object.entries(filters)) { - if (prop === DELETED_AT_FIELD_NAME) { - flags.withDeleted = true - } - if (["$or", "$and"].includes(prop)) { if (!Array.isArray(value)) { throw new Error(`Expected array for ${prop} but got ${value}`) @@ -80,7 +64,7 @@ function buildWhere( where[prop] = value.map((val) => { const deepWhere = {} - buildWhere(val, deepWhere, flags) + buildWhere(val, deepWhere) return deepWhere }) continue @@ -93,7 +77,7 @@ function buildWhere( if (isObject(value)) { where[prop] = {} - buildWhere(value, where[prop], flags) + buildWhere(value, where[prop]) continue } diff --git a/packages/medusa/src/api/admin/products/route.ts b/packages/medusa/src/api/admin/products/route.ts index fe68f9c81f..49a3a534bd 100644 --- a/packages/medusa/src/api/admin/products/route.ts +++ b/packages/medusa/src/api/admin/products/route.ts @@ -43,7 +43,8 @@ async function getProducts( req.filterableFields, req.scope, selectFields, - req.queryConfig.pagination + req.queryConfig.pagination, + req.queryConfig.withDeleted ) res.json({ @@ -75,6 +76,7 @@ async function getProductsWithIndexEngine( fields: req.queryConfig.fields ?? [], filters: filters, pagination: req.queryConfig.pagination, + withDeleted: req.queryConfig.withDeleted, }) res.json({ diff --git a/packages/medusa/src/api/utils/validators.ts b/packages/medusa/src/api/utils/validators.ts index fdccc57c3f..b26c7a0e2d 100644 --- a/packages/medusa/src/api/utils/validators.ts +++ b/packages/medusa/src/api/utils/validators.ts @@ -92,6 +92,12 @@ export const createFindParams = ({ order: order ? z.string().optional().default(order) : z.string().optional(), + with_deleted: z.preprocess((val) => { + if (val && typeof val === "string") { + return val === "true" ? true : val === "false" ? false : val + } + return val + }, z.boolean().optional()), }) ) } diff --git a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts index e28d441ed0..ed05dd058d 100644 --- a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts +++ b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts @@ -1277,9 +1277,14 @@ moduleIntegrationTestRunner({ await service.softDeleteProducts([products[0].id]) - const softDeleted = await service.listProducts({ - deleted_at: { $gt: "01-01-2022" }, - }) + const softDeleted = await service.listProducts( + { + deleted_at: { $gt: "01-01-2022" }, + }, + { + withDeleted: true, + } + ) expect(softDeleted).toHaveLength(1) })