diff --git a/.changeset/breezy-ligers-look.md b/.changeset/breezy-ligers-look.md new file mode 100644 index 0000000000..03b56d0c62 --- /dev/null +++ b/.changeset/breezy-ligers-look.md @@ -0,0 +1,5 @@ +--- +"@medusajs/pricing": patch +--- + +chore(pricing): Pricing retrieval improvements diff --git a/packages/core/utils/src/common/__tests__/flatten-object-to-key-value-pairs.spec.ts b/packages/core/utils/src/common/__tests__/flatten-object-to-key-value-pairs.spec.ts index 404a0cf8dd..6f88850841 100644 --- a/packages/core/utils/src/common/__tests__/flatten-object-to-key-value-pairs.spec.ts +++ b/packages/core/utils/src/common/__tests__/flatten-object-to-key-value-pairs.spec.ts @@ -18,7 +18,14 @@ describe("flattenObjectToKeyValuePairs", function () { }, { product_id: "product-2", - product: { id: "product-2" }, + product: { + id: "product-2", + something: [ + { + id: "test", + }, + ], + }, }, ], } @@ -31,6 +38,217 @@ describe("flattenObjectToKeyValuePairs", function () { "customer.groups.name": ["test", "test 2"], "items.product_id": ["product-1", "product-2"], "items.product.id": ["product-1", "product-2"], + "items.product.something.id": ["test"], + }) + }) + + it("should handle complex nested objects", function () { + const cart = { + id: "cart_01JRDH08QD8CZ0KJDVE410KM1J", + currency_code: "usd", + email: "tony@stark-industries.com", + region_id: "reg_01JRDH08ENY3276P6133BVXGWJ", + created_at: "2025-04-09T14:59:24.526Z", + updated_at: "2025-04-09T14:59:24.526Z", + completed_at: null, + total: 1500, + subtotal: 1428.5714285714287, + tax_total: 71.42857142857143, + discount_total: 0, + discount_subtotal: 0, + discount_tax_total: 0, + original_total: 1500, + original_tax_total: 71.42857142857143, + item_total: 1500, + item_subtotal: 1428.5714285714287, + item_tax_total: 71.42857142857143, + original_item_total: 1500, + original_item_subtotal: 1428.5714285714287, + original_item_tax_total: 71.42857142857143, + shipping_total: 0, + shipping_subtotal: 0, + shipping_tax_total: 0, + original_shipping_tax_total: 0, + original_shipping_subtotal: 0, + original_shipping_total: 0, + credit_line_subtotal: 0, + credit_line_tax_total: 0, + credit_line_total: 0, + metadata: null, + sales_channel_id: "sc_01JRDH08KWX1AR5SB0A3THWWQQ", + shipping_address_id: "caaddr_01JRDH08QDXHV9SJXKHT04TXK0", + customer_id: "cus_01JRDH08ATYB5AMFEZDTWCQWNK", + items: [ + { + id: "cali_01JRDH08QDQH3CB1DE4S79HREC", + thumbnail: null, + variant_id: "variant_01JRDH08GJCZQB4GZCDDTYMD1V", + product_id: "prod_01JRDH08FPZ6QBZQ096B310RM7", + product_type_id: null, + product_title: "Medusa T-Shirt", + product_description: null, + product_subtitle: null, + product_type: null, + product_collection: null, + product_handle: "t-shirt", + variant_sku: "SHIRT-S-BLACK", + variant_barcode: null, + variant_title: "S / Black", + requires_shipping: true, + metadata: {}, + created_at: "2025-04-09T14:59:24.526Z", + updated_at: "2025-04-09T14:59:24.526Z", + title: "S / Black", + quantity: 1, + unit_price: 1500, + compare_at_unit_price: null, + is_tax_inclusive: true, + tax_lines: [ + { + id: "calitxl_01JRDH08RJEQ4WXXDTJYWV7B4M", + description: "CA Default Rate", + code: "CADEFAULT", + rate: 5, + provider_id: "system", + }, + ], + adjustments: [], + product: { + id: "prod_01JRDH08FPZ6QBZQ096B310RM7", + collection_id: null, + type_id: null, + categories: [], + tags: [], + }, + }, + ], + shipping_methods: [], + shipping_address: { + id: "caaddr_01JRDH08QDXHV9SJXKHT04TXK0", + first_name: null, + last_name: null, + company: null, + address_1: "test address 1", + address_2: "test address 2", + city: "SF", + postal_code: "94016", + country_code: "US", + province: "CA", + phone: null, + }, + billing_address: null, + credit_lines: [], + customer: { + id: "cus_01JRDH08ATYB5AMFEZDTWCQWNK", + email: "tony@stark-industries.com", + groups: [], + }, + region: { + id: "reg_01JRDH08ENY3276P6133BVXGWJ", + name: "US", + currency_code: "usd", + automatic_taxes: true, + countries: [ + { + iso_2: "us", + iso_3: "usa", + num_code: "840", + name: "UNITED STATES", + display_name: "United States", + region_id: "reg_01JRDH08ENY3276P6133BVXGWJ", + metadata: null, + created_at: "2025-04-09T14:59:20.275Z", + updated_at: "2025-04-09T14:59:24.250Z", + deleted_at: null, + }, + ], + }, + promotions: [], + } + + const keyValueParis = flattenObjectToKeyValuePairs(cart) + console.log(JSON.stringify(keyValueParis, null, 2)) + expect(keyValueParis).toEqual({ + id: "cart_01JRDH08QD8CZ0KJDVE410KM1J", + currency_code: "usd", + email: "tony@stark-industries.com", + region_id: "reg_01JRDH08ENY3276P6133BVXGWJ", + created_at: "2025-04-09T14:59:24.526Z", + updated_at: "2025-04-09T14:59:24.526Z", + total: 1500, + subtotal: 1428.5714285714287, + tax_total: 71.42857142857143, + discount_total: 0, + discount_subtotal: 0, + discount_tax_total: 0, + original_total: 1500, + original_tax_total: 71.42857142857143, + item_total: 1500, + "items.adjustments": [], + item_subtotal: 1428.5714285714287, + item_tax_total: 71.42857142857143, + original_item_total: 1500, + original_item_subtotal: 1428.5714285714287, + original_item_tax_total: 71.42857142857143, + shipping_total: 0, + shipping_subtotal: 0, + shipping_tax_total: 0, + original_shipping_tax_total: 0, + original_shipping_subtotal: 0, + original_shipping_total: 0, + credit_line_subtotal: 0, + credit_line_tax_total: 0, + credit_line_total: 0, + sales_channel_id: "sc_01JRDH08KWX1AR5SB0A3THWWQQ", + shipping_address_id: "caaddr_01JRDH08QDXHV9SJXKHT04TXK0", + customer_id: "cus_01JRDH08ATYB5AMFEZDTWCQWNK", + "items.id": ["cali_01JRDH08QDQH3CB1DE4S79HREC"], + "items.variant_id": ["variant_01JRDH08GJCZQB4GZCDDTYMD1V"], + "items.product_id": ["prod_01JRDH08FPZ6QBZQ096B310RM7"], + "items.product_title": ["Medusa T-Shirt"], + "items.product_handle": ["t-shirt"], + "items.variant_sku": ["SHIRT-S-BLACK"], + "items.variant_title": ["S / Black"], + "items.requires_shipping": [true], + "items.created_at": ["2025-04-09T14:59:24.526Z"], + "items.updated_at": ["2025-04-09T14:59:24.526Z"], + "items.title": ["S / Black"], + "items.quantity": [1], + "items.unit_price": [1500], + "items.is_tax_inclusive": [true], + "items.tax_lines.id": ["calitxl_01JRDH08RJEQ4WXXDTJYWV7B4M"], + "items.tax_lines.description": ["CA Default Rate"], + "items.tax_lines.code": ["CADEFAULT"], + "items.tax_lines.rate": [5], + "items.tax_lines.provider_id": ["system"], + "items.product.id": ["prod_01JRDH08FPZ6QBZQ096B310RM7"], + "items.product.categories": [], + "items.product.tags": [], + shipping_methods: [], + "shipping_address.id": "caaddr_01JRDH08QDXHV9SJXKHT04TXK0", + "shipping_address.address_1": "test address 1", + "shipping_address.address_2": "test address 2", + "shipping_address.city": "SF", + "shipping_address.postal_code": "94016", + "shipping_address.country_code": "US", + "shipping_address.province": "CA", + credit_lines: [], + "customer.id": "cus_01JRDH08ATYB5AMFEZDTWCQWNK", + "customer.email": "tony@stark-industries.com", + "customer.groups": [], + "region.id": "reg_01JRDH08ENY3276P6133BVXGWJ", + "region.name": "US", + "region.currency_code": "usd", + "region.automatic_taxes": true, + "region.countries.iso_2": ["us"], + "region.countries.iso_3": ["usa"], + "region.countries.num_code": ["840"], + "region.countries.name": ["UNITED STATES"], + "region.countries.display_name": ["United States"], + "region.countries.region_id": ["reg_01JRDH08ENY3276P6133BVXGWJ"], + "region.countries.created_at": ["2025-04-09T14:59:20.275Z"], + "region.countries.updated_at": ["2025-04-09T14:59:24.250Z"], + promotions: [], }) }) }) diff --git a/packages/core/utils/src/common/flatten-object-to-key-value-pairs.ts b/packages/core/utils/src/common/flatten-object-to-key-value-pairs.ts index 8a05cae81a..4b7aa29bd1 100644 --- a/packages/core/utils/src/common/flatten-object-to-key-value-pairs.ts +++ b/packages/core/utils/src/common/flatten-object-to-key-value-pairs.ts @@ -27,116 +27,284 @@ type NestedObject = { "product.categories.id": ["test-category"], "product.categories.name": ["Test Category"] } + + Null and undefined values are excluded from the result. */ export function flattenObjectToKeyValuePairs(obj: NestedObject): NestedObject { const result: NestedObject = {} - // Find all paths that contain arrays of objects - function findArrayPaths( - obj: unknown, - currentPath: string[] = [] - ): string[][] { - const paths: string[][] = [] - - if (!obj || typeof obj !== "object") { - return paths + function shouldPreserveArray(value: any[], path: string[]): boolean { + if (!Array.isArray(value) || value.length === 0) { + return false } - // If it's an array of objects, add this path - if (Array.isArray(obj) && obj.length > 0 && isObject(obj[0])) { - paths.push(currentPath) + if (value.some((item) => isObject(item) && !Array.isArray(item))) { + return true } - // Check all properties - if (isObject(obj)) { - Object.entries(obj as Record).forEach(([key, value]) => { - const newPath = [...currentPath, key] - paths.push(...findArrayPaths(value, newPath)) - }) + if (value.some((item) => Array.isArray(item))) { + return true } - return paths - } + if (path.length > 1) { + return true + } - // Extract array values at a specific path - function getArrayValues(obj: unknown, path: string[]): unknown[] { - const arrayObj = path.reduce((acc: unknown, key: string) => { - if (acc && isObject(acc)) { - return (acc as Record)[key] + if (value.length > 1) { + const firstType = typeof value[0] + const allSameType = value.every( + (item) => + typeof item === firstType && !isObject(item) && !Array.isArray(item) + ) + if (allSameType) { + return true } - return undefined - }, obj) + } - if (!Array.isArray(arrayObj)) return [] - - return arrayObj + return false } - // Process non-array paths - function processRegularPaths(obj: unknown, prefix = ""): void { - if (!obj || typeof obj !== "object") { - result[prefix] = obj + /** + * Normalize array values - unwrap single-item arrays and handle empty arrays + */ + function normalizeArrayValue(value: any, path: string[]): any { + if (!Array.isArray(value)) { + return value + } + + if ( + value.length === 1 && + Array.isArray(value[0]) && + value[0].length === 0 + ) { + return [] + } + + if (shouldPreserveArray(value, path)) { + return value + } + + if (value.length === 1 && !isObject(value[0]) && !Array.isArray(value[0])) { + return value[0] + } + + return value + } + + /** + * Recursively process an object/array and flatten it + */ + function processPath(value: any, currentPath: string[] = []): void { + // Handle null, undefined, or primitive values + if (!value || typeof value !== "object") { + if (value !== null && value !== undefined && currentPath.length > 0) { + result[currentPath.join(".")] = value + } return } - if (Array.isArray(obj)) return + if (Array.isArray(value)) { + if (value.length === 0) { + if (currentPath.length > 0) { + result[currentPath.join(".")] = [] + } + return + } - Object.entries(obj as Record).forEach(([key, value]) => { - const newPrefix = prefix ? `${prefix}.${key}` : key - if (value && isObject(value) && !Array.isArray(value)) { - processRegularPaths(value, newPrefix) - } else if (!Array.isArray(value)) { - result[newPrefix] = value + if (value.some((item) => isObject(item) && !Array.isArray(item))) { + extractPropertiesFromArray(value, currentPath) + } else if (value.some((item) => Array.isArray(item))) { + const allValues: any[] = [] + const flattenedObjects: Record[] = [] + + const flattenArray = (arr: any[], collector: any[] = []): void => { + arr.forEach((item) => { + if (Array.isArray(item)) { + flattenArray(item, collector) + } else if (isObject(item)) { + collector.push(item) + } else if (item !== null && item !== undefined) { + allValues.push(item) + } + }) + } + + flattenArray(value, flattenedObjects) + + if (flattenedObjects.length > 0) { + extractPropertiesFromArray(flattenedObjects, currentPath) + } else if (allValues.length > 0) { + result[currentPath.join(".")] = normalizeArrayValue( + allValues, + currentPath + ) + } + } else { + const cleanedValues = value.filter((v) => v !== null && v !== undefined) + if (cleanedValues.length > 0) { + result[currentPath.join(".")] = normalizeArrayValue( + cleanedValues, + currentPath + ) + } + } + return + } + + Object.entries(value).forEach(([key, propValue]) => { + const newPath = [...currentPath, key] + + if (propValue === null || propValue === undefined) { + return + } else if (Array.isArray(propValue)) { + if (propValue.length === 0) { + result[newPath.join(".")] = [] + } else { + processPath(propValue, newPath) + } + } else if (isObject(propValue)) { + processPath(propValue, newPath) + } else { + result[newPath.join(".")] = propValue } }) } - // Process the object - processRegularPaths(obj) + /** + * Extract all properties from an array of objects and store them + */ + function extractPropertiesFromArray( + array: any[], + basePath: string[] = [] + ): void { + if (!array.length) return - // Find and process array paths - const arrayPaths = findArrayPaths(obj) - arrayPaths.forEach((path) => { - const pathStr = path.join(".") - const arrayObjects = getArrayValues(obj, path) + // Collect all unique keys from all objects in the array + const allKeys = new Set() + array.forEach((item) => { + if (isObject(item) && !Array.isArray(item)) { + Object.keys(item).forEach((key) => allKeys.add(key)) + } + }) - if (Array.isArray(arrayObjects) && arrayObjects.length > 0) { - // Get all possible keys from the array objects - const keys = new Set() - arrayObjects.forEach((item) => { - if (item && isObject(item)) { - Object.keys(item as object).forEach((k) => keys.add(k)) - } - }) + allKeys.forEach((key) => { + const valuePath = [...basePath, key] + const values: any[] = [] - // Process each key - keys.forEach((key) => { - const values = arrayObjects - .map((item) => { - if (item && isObject(item)) { - return (item as Record)[key] - } - return undefined - }) - .filter((v) => v !== undefined) - - if (values.length > 0) { - const newPath = `${pathStr}.${key}` - if (values.every((v) => isObject(v) && !Array.isArray(v))) { - // If these are all objects, recursively process them - const subObj = { [key]: values } - const subResult = flattenObjectToKeyValuePairs(subObj) - Object.entries(subResult).forEach(([k, v]) => { - const finalPath = `${pathStr}.${k}` - result[finalPath] = v - }) - } else { - result[newPath] = values + array.forEach((item) => { + if (isObject(item) && !Array.isArray(item) && key in item) { + const itemValue = item[key] + if (itemValue !== null && itemValue !== undefined) { + values.push(itemValue) } } }) - } - }) + + if (values.length === 0) return + + if (values.every((v) => isObject(v) && !Array.isArray(v))) { + extractNestedObjectProperties(values, valuePath) + } else if (values.some((v) => Array.isArray(v))) { + if (values.every((v) => Array.isArray(v) && v.length === 0)) { + result[valuePath.join(".")] = [] + } else { + const flattenedArray: any[] = [] + for (const arrayValue of values) { + if (Array.isArray(arrayValue)) { + if (arrayValue.some((v) => isObject(v) && !Array.isArray(v))) { + extractPropertiesFromArray(arrayValue, valuePath) + } else { + flattenedArray.push(...arrayValue) + } + } else { + flattenedArray.push(arrayValue) + } + } + + if ( + flattenedArray.length > 0 && + !flattenedArray.some((v) => isObject(v) && !Array.isArray(v)) + ) { + result[valuePath.join(".")] = normalizeArrayValue( + flattenedArray, + valuePath + ) + } + } + } else { + result[valuePath.join(".")] = normalizeArrayValue(values, valuePath) + } + }) + } + + /** + * Extract properties from nested objects and add them to the result + */ + function extractNestedObjectProperties( + objects: any[], + basePath: string[] = [] + ): void { + if (!objects.length) return + + // Collect all unique keys from all objects + const allNestedKeys = new Set() + objects.forEach((obj) => { + if (isObject(obj) && !Array.isArray(obj)) { + Object.keys(obj).forEach((key) => allNestedKeys.add(key)) + } + }) + + allNestedKeys.forEach((nestedKey) => { + const nestedPath = [...basePath, nestedKey] + const nestedValues: any[] = [] + + objects.forEach((obj) => { + if (isObject(obj) && !Array.isArray(obj) && nestedKey in obj) { + const nestedValue = obj[nestedKey] + if (nestedValue !== null && nestedValue !== undefined) { + nestedValues.push(nestedValue) + } + } + }) + + if (nestedValues.length === 0) return + + if (nestedValues.every((v) => isObject(v) && !Array.isArray(v))) { + extractNestedObjectProperties(nestedValues, nestedPath) + } else if (nestedValues.some((v) => Array.isArray(v))) { + if (nestedValues.every((v) => Array.isArray(v) && v.length === 0)) { + result[nestedPath.join(".")] = [] + } else { + const allArrayItems: any[] = [] + for (const arrayValue of nestedValues) { + if (Array.isArray(arrayValue)) { + allArrayItems.push(...arrayValue) + } else { + allArrayItems.push(arrayValue) + } + } + + if ( + allArrayItems.some((item) => isObject(item) && !Array.isArray(item)) + ) { + extractPropertiesFromArray(allArrayItems, nestedPath) + } else { + result[nestedPath.join(".")] = normalizeArrayValue( + allArrayItems, + nestedPath + ) + } + } + } else { + result[nestedPath.join(".")] = normalizeArrayValue( + nestedValues, + nestedPath + ) + } + }) + } + + processPath(obj) return result } diff --git a/packages/modules/pricing/integration-tests/__tests__/services/pricing-module/calculate-price.spec.ts b/packages/modules/pricing/integration-tests/__tests__/services/pricing-module/calculate-price.spec.ts index 138f590390..3b093b49ec 100644 --- a/packages/modules/pricing/integration-tests/__tests__/services/pricing-module/calculate-price.spec.ts +++ b/packages/modules/pricing/integration-tests/__tests__/services/pricing-module/calculate-price.spec.ts @@ -286,6 +286,171 @@ moduleIntegrationTestRunner({ }) }) + it("should successfully calculate prices with complex context", async () => { + const context = { + id: "cart_01JRDH08QD8CZ0KJDVE410KM1J", + currency_code: "PLN", + email: "tony@stark-industries.com", + region_id: "reg_01JRDH08ENY3276P6133BVXGWJ", + created_at: "2025-04-09T14:59:24.526Z", + updated_at: "2025-04-09T14:59:24.526Z", + completed_at: null, + total: 1500, + subtotal: 1428.5714285714287, + tax_total: 71.42857142857143, + discount_total: 0, + discount_subtotal: 0, + discount_tax_total: 0, + original_total: 1500, + original_tax_total: 71.42857142857143, + item_total: 1500, + item_subtotal: 1428.5714285714287, + item_tax_total: 71.42857142857143, + original_item_total: 1500, + original_item_subtotal: 1428.5714285714287, + original_item_tax_total: 71.42857142857143, + shipping_total: 0, + shipping_subtotal: 0, + shipping_tax_total: 0, + original_shipping_tax_total: 0, + original_shipping_subtotal: 0, + original_shipping_total: 0, + credit_line_subtotal: 0, + credit_line_tax_total: 0, + credit_line_total: 0, + metadata: null, + sales_channel_id: "sc_01JRDH08KWX1AR5SB0A3THWWQQ", + shipping_address_id: "caaddr_01JRDH08QDXHV9SJXKHT04TXK0", + customer_id: "cus_01JRDH08ATYB5AMFEZDTWCQWNK", + items: [ + { + id: "cali_01JRDH08QDQH3CB1DE4S79HREC", + thumbnail: null, + variant_id: "variant_01JRDH08GJCZQB4GZCDDTYMD1V", + product_id: "prod_01JRDH08FPZ6QBZQ096B310RM7", + product_type_id: null, + product_title: "Medusa T-Shirt", + product_description: null, + product_subtitle: null, + product_type: null, + product_collection: null, + product_handle: "t-shirt", + variant_sku: "SHIRT-S-BLACK", + variant_barcode: null, + variant_title: "S / Black", + requires_shipping: true, + metadata: {}, + created_at: "2025-04-09T14:59:24.526Z", + updated_at: "2025-04-09T14:59:24.526Z", + title: "S / Black", + quantity: 1, + unit_price: 1500, + compare_at_unit_price: null, + is_tax_inclusive: true, + tax_lines: [ + { + id: "calitxl_01JRDH08RJEQ4WXXDTJYWV7B4M", + description: "CA Default Rate", + code: "CADEFAULT", + rate: 5, + provider_id: "system", + }, + ], + adjustments: [], + product: { + id: "prod_01JRDH08FPZ6QBZQ096B310RM7", + collection_id: null, + type_id: null, + categories: [], + tags: [], + }, + }, + ], + shipping_methods: [], + shipping_address: { + id: "caaddr_01JRDH08QDXHV9SJXKHT04TXK0", + first_name: null, + last_name: null, + company: null, + address_1: "test address 1", + address_2: "test address 2", + city: "SF", + postal_code: "94016", + country_code: "US", + province: "CA", + phone: null, + }, + billing_address: null, + credit_lines: [], + customer: { + id: "cus_01JRDH08ATYB5AMFEZDTWCQWNK", + email: "tony@stark-industries.com", + groups: [], + }, + region: { + id: "reg_01JRDH08ENY3276P6133BVXGWJ", + name: "US", + currency_code: "usd", + automatic_taxes: true, + countries: [ + { + iso_2: "us", + iso_3: "usa", + num_code: "840", + name: "UNITED STATES", + display_name: "United States", + region_id: "reg_01JRDH08ENY3276P6133BVXGWJ", + metadata: null, + created_at: "2025-04-09T14:59:20.275Z", + updated_at: "2025-04-09T14:59:24.250Z", + deleted_at: null, + }, + ], + }, + promotions: [], + } + + const calculatedPrice = await service.calculatePrices( + { id: ["price-set-EUR", "price-set-PLN"] }, + { context: context as any } + ) + + expect(calculatedPrice).toEqual([ + { + id: "price-set-PLN", + is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, + calculated_amount: 1000, + raw_calculated_amount: { + value: "1000", + precision: 20, + }, + is_original_price_price_list: false, + is_original_price_tax_inclusive: false, + original_amount: 1000, + raw_original_amount: { + value: "1000", + precision: 20, + }, + currency_code: "PLN", + calculated_price: { + id: "price-PLN", + price_list_id: null, + price_list_type: null, + min_quantity: 1, + max_quantity: 10, + }, + original_price: { + id: "price-PLN", + price_list_id: null, + price_list_type: null, + min_quantity: 1, + max_quantity: 10, + }, + }, + ]) + }) + it("should throw an error when currency code is not set", async () => { let result = service.calculatePrices( { id: ["price-set-EUR", "price-set-PLN"] }, diff --git a/packages/modules/pricing/src/migrations/.snapshot-medusa-pricing.json b/packages/modules/pricing/src/migrations/.snapshot-medusa-pricing.json index 26e174520d..574e7c7df7 100644 --- a/packages/modules/pricing/src/migrations/.snapshot-medusa-pricing.json +++ b/packages/modules/pricing/src/migrations/.snapshot-medusa-pricing.json @@ -795,6 +795,15 @@ "unique": false, "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_price_rule_price_id_attribute_operator_unique\" ON \"price_rule\" (price_id, attribute, operator) WHERE deleted_at IS NULL" }, + { + "keyName": "IDX_price_rule_attribute", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_price_rule_attribute\" ON \"price_rule\" (attribute) WHERE deleted_at IS NULL" + }, { "keyName": "IDX_price_rule_attribute_value", "columnNames": [], diff --git a/packages/modules/pricing/src/migrations/Migration20250409122219.ts b/packages/modules/pricing/src/migrations/Migration20250409122219.ts new file mode 100644 index 0000000000..2a5e17ddb0 --- /dev/null +++ b/packages/modules/pricing/src/migrations/Migration20250409122219.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20250409122219 extends Migration { + + override async up(): Promise { + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_price_rule_attribute" ON "price_rule" (attribute) WHERE deleted_at IS NULL;`); + } + + override async down(): Promise { + this.addSql(`drop index if exists "IDX_price_rule_attribute";`); + } + +} diff --git a/packages/modules/pricing/src/models/price-rule.ts b/packages/modules/pricing/src/models/price-rule.ts index 73b3bd3a42..4f738950ba 100644 --- a/packages/modules/pricing/src/models/price-rule.ts +++ b/packages/modules/pricing/src/models/price-rule.ts @@ -18,6 +18,10 @@ const PriceRule = model where: "deleted_at IS NULL", unique: true, }, + { + on: ["attribute"], + where: "deleted_at IS NULL", + }, { on: ["attribute", "value"], where: "deleted_at IS NULL", diff --git a/packages/modules/pricing/src/repositories/pricing.ts b/packages/modules/pricing/src/repositories/pricing.ts index cfb57a1de0..44f59c55bc 100644 --- a/packages/modules/pricing/src/repositories/pricing.ts +++ b/packages/modules/pricing/src/repositories/pricing.ts @@ -4,7 +4,6 @@ import { MedusaError, MikroOrmBase, PriceListStatus, - promiseAll, } from "@medusajs/framework/utils" import { @@ -16,8 +15,6 @@ import { } from "@medusajs/framework/types" import { Knex, SqlEntityManager } from "@mikro-orm/postgresql" -// Simple cache implementation - export class PricingRepository extends MikroOrmBase implements PricingRepositoryService @@ -53,31 +50,20 @@ export class PricingRepository } // Generate flatten key-value pairs for rule matching - const [ruleAttributes, priceListRuleAttributes] = - await this.getAttributesFromRuleTables(knex) - - const allowedRuleAttributes = [ - ...ruleAttributes, - ...priceListRuleAttributes, - ] - const flattenedKeyValuePairs = flattenObjectToKeyValuePairs(context) + // First filter by value presence const flattenedContext = Object.entries(flattenedKeyValuePairs).filter( - ([key, value]) => { + ([, value]) => { const isValuePresent = !Array.isArray(value) && isPresent(value) const isArrayPresent = Array.isArray(value) && value.flat(1).length - return ( - allowedRuleAttributes.includes(key) && - (isValuePresent || isArrayPresent) - ) + return isValuePresent || isArrayPresent } ) const hasComplexContext = flattenedContext.length > 0 - // Base query with efficient index lookups const query = knex .select({ id: "price.id", @@ -97,7 +83,6 @@ export class PricingRepository .andWhere("price.currency_code", currencyCode) .whereNull("price.deleted_at") - // Apply quantity filter if (quantity !== undefined) { query.andWhere(function (this: Knex.QueryBuilder) { this.where(function (this: Knex.QueryBuilder) { @@ -118,7 +103,6 @@ export class PricingRepository }) } - // Efficient price list join with index usage query.leftJoin("price_list as pl", function (this: Knex.JoinClause) { this.on("pl.id", "=", "price.price_list_id") .andOn("pl.status", "=", knex.raw("?", [PriceListStatus.ACTIVE])) @@ -133,9 +117,7 @@ export class PricingRepository }) }) - // OPTIMIZATION: Only add complex rule filtering when necessary if (hasComplexContext) { - // For price rules - direct check that ALL rules match const priceRuleConditions = knex.raw( ` ( @@ -188,7 +170,6 @@ export class PricingRepository }) ) - // For price list rules - direct check that ALL rules match const priceListRuleConditions = knex.raw( ` ( @@ -231,7 +212,6 @@ export class PricingRepository }) }) } else { - // Simple case - just get prices with no rules or price lists with no rules query.where(function (this: Knex.QueryBuilder) { this.where("price.rules_count", 0).orWhere(function ( this: Knex.QueryBuilder @@ -241,31 +221,13 @@ export class PricingRepository }) } - // Optimized ordering to help query planner and preserve price list precedence query .orderByRaw("price.price_list_id IS NOT NULL DESC") - .orderByRaw("price.rules_count + COALESCE(pl.rules_count, 0) DESC") // More specific rules first - .orderBy("pl.id", "asc") // Order by price list ID to ensure first created price list takes precedence - .orderBy("price.amount", "asc") // For non-price list prices, cheaper ones first + .orderByRaw("price.rules_count + COALESCE(pl.rules_count, 0) DESC") + .orderBy("pl.id", "asc") + .orderBy("price.amount", "asc") - // Execute the optimized query + console.log(query.toString()) return await query } - - // Helper method to get attributes from rule tables - private async getAttributesFromRuleTables(knex: Knex) { - // Using distinct queries for better performance - const priceRuleAttributesQuery = knex("price_rule") - .distinct("attribute") - .pluck("attribute") - - const priceListRuleAttributesQuery = knex("price_list_rule") - .distinct("attribute") - .pluck("attribute") - - return await promiseAll([ - priceRuleAttributesQuery, - priceListRuleAttributesQuery, - ]) - } }