From 07252691c59f0e945730e987d0f1a97976b4c7f0 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Thu, 10 Apr 2025 11:39:21 +0200 Subject: [PATCH] chore(pricing): Pricing retrieval improvements (#12128) **What** I have removed the check for the context key where it was fetching all attributes available and then stripping out the one that does not exists.. On big dataset these would remove multiple hundreds of ms of query execution --- .changeset/breezy-ligers-look.md | 5 + .../flatten-object-to-key-value-pairs.spec.ts | 220 +++++++++++- .../flatten-object-to-key-value-pairs.ts | 330 +++++++++++++----- .../pricing-module/calculate-price.spec.ts | 165 +++++++++ .../migrations/.snapshot-medusa-pricing.json | 9 + .../src/migrations/Migration20250409122219.ts | 13 + .../modules/pricing/src/models/price-rule.ts | 4 + .../pricing/src/repositories/pricing.ts | 52 +-- 8 files changed, 671 insertions(+), 127 deletions(-) create mode 100644 .changeset/breezy-ligers-look.md create mode 100644 packages/modules/pricing/src/migrations/Migration20250409122219.ts 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, - ]) - } }