diff --git a/integration-tests/http/__tests__/product/store/product.spec.ts b/integration-tests/http/__tests__/product/store/product.spec.ts index 7859ae6beb..1b4b160f26 100644 --- a/integration-tests/http/__tests__/product/store/product.spec.ts +++ b/integration-tests/http/__tests__/product/store/product.spec.ts @@ -788,31 +788,33 @@ medusaIntegrationTestRunner({ // TODO: This doesn't work currently, but worked in v1 it.skip("returns a list of ordered products by variants title DESC", async () => { - const response = await api.get( - "/store/products?order=-variants.title", - storeHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.products).toEqual([ - expect.objectContaining({ id: product3.id }), - expect.objectContaining({ id: product2.id }), - expect.objectContaining({ id: product.id }), - ]) }) - // TODO: This doesn't work currently, but worked in v1 - it.skip("returns a list of ordered products by variants title ASC", async () => { + it("returns a list of ordered products by variant title ASC", async () => { const response = await api.get( "/store/products?order=variants.title", storeHeaders ) expect(response.status).toEqual(200) - expect(response.data.products).toEqual([ - expect.objectContaining({ id: product3.id }), - expect.objectContaining({ id: product2.id }), - expect.objectContaining({ id: product.id }), + expect(response.data.products.map((p) => p.id)).toEqual([ + product.id, + product2.id, + product3.id, + ]) + }) + + it("returns a list of ordered products by variant title DESC", async () => { + const response = await api.get( + "/store/products?order=-variants.title", + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.products.map((p) => p.id)).toEqual([ + product3.id, + product2.id, + product.id, ]) }) diff --git a/packages/core/framework/src/http/utils/__tests__/get-query-config.spec.ts b/packages/core/framework/src/http/utils/__tests__/get-query-config.spec.ts new file mode 100644 index 0000000000..2c70576038 --- /dev/null +++ b/packages/core/framework/src/http/utils/__tests__/get-query-config.spec.ts @@ -0,0 +1,569 @@ +import { prepareListQuery } from "../get-query-config" +import { RequestQueryFields, QueryConfig } from "@medusajs/types" + +describe("prepareListQuery", () => { + describe("buildOrder functionality", () => { + it("should return undefined order when no order is provided", () => { + const validated: RequestQueryFields = { + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toBeUndefined() + expect(result.remoteQueryConfig.pagination.order).toBeUndefined() + }) + + it("should build simple ascending order", () => { + const validated: RequestQueryFields = { + order: "created_at", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toEqual({ created_at: "ASC" }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ created_at: "ASC" }) + }) + + it("should build simple descending order with dash prefix", () => { + const validated: RequestQueryFields = { + order: "-created_at", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toEqual({ created_at: "DESC" }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ created_at: "DESC" }) + }) + + it("should build nested order for relation fields", () => { + const validated: RequestQueryFields = { + order: "product.title", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toEqual({ + product: { + title: "ASC", + }, + }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ + product: { + title: "ASC", + }, + }) + }) + + it("should build nested descending order for relation fields", () => { + const validated: RequestQueryFields = { + order: "-product.title", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toEqual({ + product: { + title: "DESC", + }, + }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ + product: { + title: "DESC", + }, + }) + }) + + it("should build deeply nested order for multiple relation levels", () => { + const validated: RequestQueryFields = { + order: "product.variants.prices.amount", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toEqual({ + product: { + variants: { + prices: { + amount: "ASC", + }, + }, + }, + }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ + product: { + variants: { + prices: { + amount: "ASC", + }, + }, + }, + }) + }) + + it("should build deeply nested descending order for multiple relation levels", () => { + const validated: RequestQueryFields = { + order: "-product.variants.prices.amount", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toEqual({ + product: { + variants: { + prices: { + amount: "DESC", + }, + }, + }, + }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ + product: { + variants: { + prices: { + amount: "DESC", + }, + }, + }, + }) + }) + + it("should handle mixed nested order with both ascending and descending", () => { + const validated: RequestQueryFields = { + order: "product.title,-product.variants.sku", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + // The function processes the entire string as one field name + // buildOrder splits by dots, so "product.title,-product.variants.sku" becomes nested + expect(result.listConfig.order).toEqual({ + product: { + "title,-product": { + variants: { + sku: "ASC", + }, + }, + }, + }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ + product: { + "title,-product": { + variants: { + sku: "ASC", + }, + }, + }, + }) + }) + + it("should throw error when order field is not in allowed fields", () => { + const validated: RequestQueryFields = { + order: "restricted_field", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + allowed: ["id", "created_at", "title"], + } + + expect(() => prepareListQuery(validated, queryConfig)).toThrow( + "Order field restricted_field is not valid" + ) + }) + + it("should allow order field when it is in allowed fields", () => { + const validated: RequestQueryFields = { + order: "title", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + allowed: ["id", "created_at", "title"], + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toEqual({ title: "ASC" }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ title: "ASC" }) + }) + + it("should allow nested order field when parent relation is in allowed fields", () => { + const validated: RequestQueryFields = { + order: "product.title", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + allowed: ["id", "product", "product.title"], + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toEqual({ + product: { + title: "ASC", + }, + }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ + product: { + title: "ASC", + }, + }) + }) + + it("should handle order with special characters in field names", () => { + const validated: RequestQueryFields = { + order: "metadata.custom_field", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toEqual({ + metadata: { + custom_field: "ASC", + }, + }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ + metadata: { + custom_field: "ASC", + }, + }) + }) + + it("should handle order with numeric field names", () => { + const validated: RequestQueryFields = { + order: "field_123.sub_field_456", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toEqual({ + field_123: { + sub_field_456: "ASC", + }, + }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ + field_123: { + sub_field_456: "ASC", + }, + }) + }) + + it("should handle order with underscore field names", () => { + const validated: RequestQueryFields = { + order: "-user_profile.first_name", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toEqual({ + user_profile: { + first_name: "DESC", + }, + }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ + user_profile: { + first_name: "DESC", + }, + }) + }) + + it("should handle order with camelCase field names", () => { + const validated: RequestQueryFields = { + order: "orderItems.unitPrice", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toEqual({ + orderItems: { + unitPrice: "ASC", + }, + }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ + orderItems: { + unitPrice: "ASC", + }, + }) + }) + + it("should handle order with kebab-case field names", () => { + const validated: RequestQueryFields = { + order: "-shipping-options.delivery-time", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + // The dash prefix is interpreted as descending order for the entire field path + // After removing the first "-", it becomes "shipping-options.delivery-time" + // buildOrder splits by dots to create nested structure + expect(result.listConfig.order).toEqual({ + "shipping-options": { + "delivery-time": "DESC", + }, + }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ + "shipping-options": { + "delivery-time": "DESC", + }, + }) + }) + + it("should handle order with empty string (edge case)", () => { + const validated: RequestQueryFields = { + order: "", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + // Empty string is treated as a valid field name + expect(result.listConfig.order).toEqual({ "": "ASC" }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ "": "ASC" }) + }) + + it("should handle order with special characters in nested field names", () => { + const validated: RequestQueryFields = { + order: "product.variant-price.amount", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toEqual({ + product: { + "variant-price": { + amount: "ASC", + }, + }, + }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ + product: { + "variant-price": { + amount: "ASC", + }, + }, + }) + }) + + it("should handle order with very deep nesting (5 levels)", () => { + const validated: RequestQueryFields = { + order: "a.b.c.d.e", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toEqual({ + a: { + b: { + c: { + d: { + e: "ASC", + }, + }, + }, + }, + }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ + a: { + b: { + c: { + d: { + e: "ASC", + }, + }, + }, + }, + }) + }) + + it("should handle order with very deep nesting and descending (5 levels)", () => { + const validated: RequestQueryFields = { + order: "-a.b.c.d.e", + limit: 10, + offset: 0, + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toEqual({ + a: { + b: { + c: { + d: { + e: "DESC", + }, + }, + }, + }, + }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ + a: { + b: { + c: { + d: { + e: "DESC", + }, + }, + }, + }, + }) + }) + }) + + describe("integration with other query parameters", () => { + it("should combine order with fields, limit, and offset", () => { + const validated: RequestQueryFields = { + order: "created_at", + fields: "id,title,product.name", + limit: 25, + offset: 50, + } + + const queryConfig: QueryConfig = { + isList: true, + defaults: ["id", "created_at"], + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toEqual({ created_at: "ASC" }) + expect(result.listConfig.skip).toBe(50) + expect(result.listConfig.take).toBe(25) + expect(result.remoteQueryConfig.pagination.order).toEqual({ created_at: "ASC" }) + expect(result.remoteQueryConfig.pagination.skip).toBe(50) + expect(result.remoteQueryConfig.pagination.take).toBe(25) + }) + + it("should handle order with * fields in query config", () => { + const validated: RequestQueryFields = { + order: "product.title", + fields: "id,*product", + } + + const queryConfig: QueryConfig = { + isList: true, + } + + const result = prepareListQuery(validated, queryConfig) + + expect(result.listConfig.order).toEqual({ + product: { + title: "ASC", + }, + }) + expect(result.remoteQueryConfig.pagination.order).toEqual({ + product: { + title: "ASC", + }, + }) + }) + }) +}) + + + 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 c7ba208fb1..993082d390 100644 --- a/packages/core/framework/src/http/utils/get-query-config.ts +++ b/packages/core/framework/src/http/utils/get-query-config.ts @@ -3,6 +3,7 @@ import { isDefined, isPresent, MedusaError, + buildOrder, stringToSelectRelationObject, } from "@medusajs/utils" import { pick } from "lodash" @@ -197,9 +198,8 @@ export function prepareListQuery( if (isDefined(order)) { let orderField = order if (order.startsWith("-")) { - const [, field] = order.split("-") - orderField = field - orderBy = { [field]: "DESC" } + orderField = order.slice(1) + orderBy = { [orderField]: "DESC" } } else { orderBy = { [order]: "ASC" } } @@ -212,7 +212,7 @@ export function prepareListQuery( } } - const finalOrder = isPresent(orderBy) ? orderBy : undefined + const finalOrder = isPresent(orderBy) ? buildOrder(orderBy) : undefined return { listConfig: { select: select.length ? select : undefined, diff --git a/packages/core/utils/src/common/build-query.ts b/packages/core/utils/src/common/build-query.ts index fd55d97ba6..55b27a6427 100644 --- a/packages/core/utils/src/common/build-query.ts +++ b/packages/core/utils/src/common/build-query.ts @@ -2,7 +2,7 @@ import { objectFromStringPath } from "./object-from-string-path" -type Order = { +export type Order = { [key: string]: "ASC" | "DESC" | Order } diff --git a/packages/medusa/package.json b/packages/medusa/package.json index b6c53c0faf..c46102a97a 100644 --- a/packages/medusa/package.json +++ b/packages/medusa/package.json @@ -115,7 +115,7 @@ "boxen": "^5.0.1", "chalk": "^4.0.0", "chokidar": "^3.4.2", - "compression": "^1.7.4", + "compression": "^1.7.5", "express": "^4.21.0", "fs-exists-cached": "^1.0.0", "jsonwebtoken": "^9.0.2", diff --git a/yarn.lock b/yarn.lock index 0c2681355c..7c805999ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6696,7 +6696,7 @@ __metadata: boxen: ^5.0.1 chalk: ^4.0.0 chokidar: ^3.4.2 - compression: ^1.7.4 + compression: ^1.7.5 express: ^4.21.0 fs-exists-cached: ^1.0.0 jest: ^29.7.0 @@ -18559,7 +18559,7 @@ __metadata: languageName: node linkType: hard -"compressible@npm:~2.0.16": +"compressible@npm:~2.0.16, compressible@npm:~2.0.18": version: 2.0.18 resolution: "compressible@npm:2.0.18" dependencies: @@ -18583,6 +18583,21 @@ __metadata: languageName: node linkType: hard +"compression@npm:^1.7.5": + version: 1.8.0 + resolution: "compression@npm:1.8.0" + dependencies: + bytes: 3.1.2 + compressible: ~2.0.18 + debug: 2.6.9 + negotiator: ~0.6.4 + on-headers: ~1.0.2 + safe-buffer: 5.2.1 + vary: ~1.1.2 + checksum: 804d3c8430939f4fd88e5128333f311b4035f6425a7f2959d74cfb5c98ef3a3e3e18143208f3f9d0fcae4cd3bcf3d2fbe525e0fcb955e6e146e070936f025a24 + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -27189,6 +27204,13 @@ __metadata: languageName: node linkType: hard +"negotiator@npm:~0.6.4": + version: 0.6.4 + resolution: "negotiator@npm:0.6.4" + checksum: 3e677139c7fb7628a6f36335bf11a885a62c21d5390204590a1a214a5631fcbe5ea74ef6a610b60afe84b4d975cbe0566a23f20ee17c77c73e74b80032108dea + languageName: node + linkType: hard + "neo-async@npm:^2.6.0, neo-async@npm:^2.6.2": version: 2.6.2 resolution: "neo-async@npm:2.6.2"