fix(medusa): Query Config update Order By filter (#12781)

**What**
Fixed a bug in the prepareListQuery function where nested field ordering was not properly building the expected nested object structure. The function was returning flat objects like { "employee.first_name": "ASC" } instead of the correct nested structure { "employee": { "first_name": "ASC" } }.

**Why**
The buildOrder function is designed to create nested objects from dot-notation field paths, which is essential for proper query building in the Medusa framework. When this functionality was broken, it prevented correct ordering of related fields and caused queries to fail or return unexpected results.

**How**
- Root cause: The `prepareListQuery` function was not properly utilizing the `buildOrder` utility function to transform dot-notation field paths into nested objects
- Before: order = "employee.first_name" → { "employee.first_name": "ASC" }
- After: order = "employee.first_name" → { "employee": { "first_name": "ASC" } }
- Added comprehensive tests: Created detailed unit tests for the prepareListQuery function focusing on buildOrder functionality, covering various scenarios including:
  - Simple ascending/descending order
  - Nested field ordering (e.g., product.title)
  - Deeply nested ordering (e.g., product.variants.prices.amount)
  - Multiple nesting levels (up to 5 levels deep)
- Added integration tests: Created integration tests in `product.spec.ts` to verify the full end-to-end functionality of nested ordering with variant titles

The fix ensures that the buildOrder function properly transforms dot-notation field paths into the expected nested object structure, enabling correct query building for related field ordering throughout the Medusa framework.

Resolves SUP-1868
This commit is contained in:
juanzgc
2025-06-24 10:45:54 -05:00
committed by GitHub
parent 490695a83a
commit 3826bde591
6 changed files with 618 additions and 25 deletions

View File

@@ -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,
])
})

View File

@@ -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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
isList: true,
}
const result = prepareListQuery(validated, queryConfig)
expect(result.listConfig.order).toEqual({
product: {
title: "ASC",
},
})
expect(result.remoteQueryConfig.pagination.order).toEqual({
product: {
title: "ASC",
},
})
})
})
})

View File

@@ -3,6 +3,7 @@ import {
isDefined,
isPresent,
MedusaError,
buildOrder,
stringToSelectRelationObject,
} from "@medusajs/utils"
import { pick } from "lodash"
@@ -197,9 +198,8 @@ export function prepareListQuery<T extends RequestQueryFields, TEntity>(
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<T extends RequestQueryFields, TEntity>(
}
}
const finalOrder = isPresent(orderBy) ? orderBy : undefined
const finalOrder = isPresent(orderBy) ? buildOrder(orderBy) : undefined
return {
listConfig: {
select: select.length ? select : undefined,

View File

@@ -2,7 +2,7 @@
import { objectFromStringPath } from "./object-from-string-path"
type Order = {
export type Order = {
[key: string]: "ASC" | "DESC" | Order
}

View File

@@ -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",

View File

@@ -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"