diff --git a/.changeset/purple-cars-design.md b/.changeset/purple-cars-design.md new file mode 100644 index 0000000000..b94ab682bd --- /dev/null +++ b/.changeset/purple-cars-design.md @@ -0,0 +1,5 @@ +--- +"@medusajs/promotion": patch +--- + +Feat(): promo prepare top level rules filter diff --git a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts index a642ea41ee..b14d9fe9a9 100644 --- a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts +++ b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts @@ -1,4 +1,7 @@ -import { IPromotionModuleService } from "@medusajs/framework/types" +import { + CreatePromotionDTO, + IPromotionModuleService, +} from "@medusajs/framework/types" import { ApplicationMethodType, Modules, @@ -22,6 +25,540 @@ moduleIntegrationTestRunner({ await createCampaigns(MikroOrmWrapper.forkManager()) }) + it("should prefilter promotions by applicable rules", async () => { + // 1. Promotion with NO rules (should always apply if automatic) + await createDefaultPromotion(service, { + code: "NO_RULES_PROMO", + is_automatic: true, + rules: [], // No global rules - always applicable + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 100000, + value: 100, + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: ["prod_tshirt0"], // Only applies to product 0 + }, + ], + }, + }) + + // 2. Promotion matching customer group VIP1 + await createDefaultPromotion(service, { + code: "CUSTOMER_GROUP_PROMO", + is_automatic: true, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP1"], // Matches our test customer + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 100000, + value: 100, + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: ["prod_tshirt1"], // Only applies to product 1 + }, + ], + }, + }) + + // 3. Promotion with subtotal rule (should match items with subtotal > 50) + await createDefaultPromotion(service, { + code: "SUBTOTAL_PROMO", + is_automatic: true, + rules: [ + { + attribute: "items.subtotal", + operator: "gt", + values: ["50"], // All our items have subtotal > 50 + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 100000, + value: 100, + target_rules: [], // No target rules - applies to all items + }, + }) + + // 4. Promotion matching customer.id + await createDefaultPromotion(service, { + code: "CUSTOMER_ID_PROMO", + is_automatic: true, + rules: [ + { + attribute: "customer.id", + operator: "in", + values: ["customer"], // Matches our test customer + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 100000, + value: 250, // Different value to distinguish + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: ["prod_tshirt9"], // Only applies to product 9 + }, + ], + }, + }) + + // 5. Promotion that should NOT match (different customer group) + await createDefaultPromotion(service, { + code: "NO_MATCH_PROMO", + is_automatic: true, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP99"], // Different customer group - won't match + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 100000, + value: 100, + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: ["prod_tshirt0"], + }, + ], + }, + }) + + // 6. Non-automatic promotion (should be excluded from automatic processing) + await createDefaultPromotion(service, { + code: "NON_AUTO_PROMO", + is_automatic: false, // Not automatic + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP1"], // Would match but not automatic + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 100000, + value: 100, + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: ["prod_tshirt0"], + }, + ], + }, + }) + + // 6. Non-automatic promotion that do not match any rules (should be excluded from automatic processing and internal pre filtering) + await createDefaultPromotion(service, { + code: "NON_AUTO_PROMO_2", + is_automatic: false, // Not automatic + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP99"], // Would not match our customer group + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 100000, + value: 100, + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: ["prod_tshirt0"], + }, + ], + }, + }) + + // Spy on the internal promotion service to verify prefiltering + let prefilterCallCount = 0 + let prefilteredPromotions: any[] = [] + const originalPromotionServiceList = (service as any).promotionService_ + .list + + ;(service as any).promotionService_.list = async (...args: any[]) => { + const result = await originalPromotionServiceList.bind( + (service as any).promotionService_ + )(...args) + + if (prefilterCallCount === 0) { + prefilteredPromotions = result + } + prefilterCallCount++ + + return result + } + + // Test Context: Customer with specific attributes + const testContext = { + currency_code: "usd", + customer: { + id: "customer", // Matches CUSTOMER_ID_PROMO + customer_group: { + id: "VIP1", // Matches CUSTOMER_GROUP_PROMO + }, + }, + region_id: "region_1", + items: [ + { + id: "item_tshirt0", + quantity: 1, + subtotal: 100, // > 50, matches SUBTOTAL_PROMO + product: { id: "prod_tshirt0" }, // Matches NO_RULES_PROMO target + }, + { + id: "item_tshirt1", + quantity: 1, + subtotal: 100, // > 50, matches SUBTOTAL_PROMO + product: { id: "prod_tshirt1" }, // Matches CUSTOMER_GROUP_PROMO target + }, + { + id: "item_tshirt9", + quantity: 5, + subtotal: 750, // > 50, matches SUBTOTAL_PROMO + product: { id: "prod_tshirt9" }, // Matches CUSTOMER_ID_PROMO target + }, + { + id: "item_unknown", + quantity: 1, + subtotal: 110, // > 50, matches SUBTOTAL_PROMO + product: { id: "prod_unknown" }, // No specific target rules match + }, + ] as any, + } + + const actions = await service.computeActions([], testContext) + + ;(service as any).promotionService_.list = originalPromotionServiceList + + // 1. Verify prefiltering worked - should include matching promotions + expect(prefilteredPromotions).toHaveLength(4) + const prefilteredCodes = prefilteredPromotions.map((p) => p.code) + expect(prefilteredCodes).toEqual( + expect.arrayContaining([ + "NO_RULES_PROMO", // No rules - always included + "CUSTOMER_GROUP_PROMO", // customer.customer_group.id = VIP1 + "SUBTOTAL_PROMO", // items.subtotal > 50 + "CUSTOMER_ID_PROMO", // customer.id = customer + ]) + ) + + expect(actions).toHaveLength(4) + + const actionsByCode = JSON.parse(JSON.stringify(actions)).reduce( + (acc, action) => { + if (!acc[action.code]) acc[action.code] = [] + acc[action.code].push(action) + return acc + }, + {} as Record + ) + + // NO_RULES_PROMO: Applies to item_tshirt0 (product.id = prod_tshirt0) + expect(actionsByCode["NO_RULES_PROMO"]).toEqual([ + expect.objectContaining({ + action: "addItemAdjustment", + item_id: "item_tshirt0", + amount: 100, + code: "NO_RULES_PROMO", + }), + ]) + + // CUSTOMER_GROUP_PROMO: Applies to item_tshirt1 (product.id = prod_tshirt1) + expect(actionsByCode["CUSTOMER_GROUP_PROMO"]).toEqual([ + expect.objectContaining({ + action: "addItemAdjustment", + item_id: "item_tshirt1", + amount: 100, + code: "CUSTOMER_GROUP_PROMO", + }), + ]) + + // SUBTOTAL_PROMO: (no target rules) + expect(actionsByCode["SUBTOTAL_PROMO"]).toHaveLength(1) + expect(actionsByCode["SUBTOTAL_PROMO"]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ item_id: "item_unknown", amount: 100 }), + ]) + ) + + // CUSTOMER_ID_PROMO: Applies to item_tshirt9 (product.id = prod_tshirt9) + expect(actionsByCode["CUSTOMER_ID_PROMO"]).toEqual([ + expect.objectContaining({ + action: "addItemAdjustment", + item_id: "item_tshirt9", + amount: 750, + code: "CUSTOMER_ID_PROMO", + }), + ]) + }) + + it("should handle prefiltering of many automatic promotions targetting customers and regions with only one that is relevant", async () => { + const promotionToCreate: CreatePromotionDTO[] = [] + // I ve also tested with 20k and the compute actions takes 200/300ms + for (let i = 0; i < 100; i++) { + promotionToCreate.push({ + code: "CUSTOMER_PROMO_" + i, + is_automatic: true, + rules: [ + { + attribute: "customer.id", + operator: "eq", + values: ["customer" + i], // Matches our test customer1 + }, + { + attribute: "region_id", + operator: "eq", + values: ["region_1"], // matches our region + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 100000, + value: 100, + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: ["prod_tshirt0"], // Only applies to product 0 + }, + ], + }, + type: "standard", + status: PromotionStatus.ACTIVE, + campaign_id: "campaign-id-1", + }) + } + + await service.createPromotions(promotionToCreate) + + // Spy on the internal promotion service to verify prefiltering + let prefilterCallCount = 0 + let prefilteredPromotions: any[] = [] + const originalPromotionServiceList = (service as any).promotionService_ + .list + + ;(service as any).promotionService_.list = async (...args: any[]) => { + const result = await originalPromotionServiceList.bind( + (service as any).promotionService_ + )(...args) + + if (prefilterCallCount === 0) { + prefilteredPromotions = result + } + prefilterCallCount++ + + return result + } + + // Test Context: Customer with specific attributes + const testContext = { + currency_code: "usd", + customer: { + id: "customer1", // Matches CUSTOMER_PROMO_1 + }, + region_id: "region_1", + items: [ + { + id: "item_tshirt0", + quantity: 1, + subtotal: 100, + product: { id: "prod_tshirt0" }, // Matches CUSTOMER_PROMO_1 target + }, + { + id: "item_tshirt1", + quantity: 1, + subtotal: 100, + product: { id: "prod_tshirt1" }, + }, + { + id: "item_tshirt9", + quantity: 5, + subtotal: 750, + product: { id: "prod_tshirt9" }, + }, + { + id: "item_unknown", + quantity: 1, + subtotal: 110, + product: { id: "prod_unknown" }, + }, + ] as any, + } + + const actions = await service.computeActions([], testContext) + + ;(service as any).promotionService_.list = originalPromotionServiceList + + // 1. Verify prefiltering worked - should include matching promotion + // We expect the prefilter to have return a single promotion that is being satisfied by the + // context with the given customer id and region id + expect(prefilteredPromotions).toHaveLength(1) + const prefilteredCodes = prefilteredPromotions.map((p) => p.code) + expect(prefilteredCodes).toEqual( + expect.arrayContaining([ + "CUSTOMER_PROMO_1", // customer.id = customer1 + ]) + ) + + expect(actions).toHaveLength(1) + + const actionsByCode = JSON.parse(JSON.stringify(actions)).reduce( + (acc, action) => { + if (!acc[action.code]) acc[action.code] = [] + acc[action.code].push(action) + return acc + }, + {} as Record + ) + + // CUSTOMER_PROMO_1: Applies to item_tshirt0 (product.id = prod_tshirt0) + expect(actionsByCode["CUSTOMER_PROMO_1"]).toEqual([ + expect.objectContaining({ + action: "addItemAdjustment", + item_id: "item_tshirt0", + amount: 100, + code: "CUSTOMER_PROMO_1", + }), + ]) + }) + + it("should handle prefiltering of many automatic promotions targetting customers and regions while no context attribute can satisfies any of those rules", async () => { + const promotionToCreate: CreatePromotionDTO[] = [] + for (let i = 0; i < 100; i++) { + promotionToCreate.push({ + code: "CUSTOMER_PROMO_" + i, + is_automatic: true, + rules: [ + { + attribute: "customer.id", + operator: "eq", + values: ["customer" + i], // Matches our test customer1 + }, + { + attribute: "region_id", + operator: "eq", + values: ["region_1"], // matches our region + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 100000, + value: 100, + target_rules: [ + { + attribute: "product.id", + operator: "eq", + values: ["prod_tshirt0"], // Only applies to product 0 + }, + ], + }, + type: "standard", + status: PromotionStatus.ACTIVE, + campaign_id: "campaign-id-1", + }) + } + + await service.createPromotions(promotionToCreate) + + // Spy on the internal promotion service to verify prefiltering + let prefilterCallCount = 0 + let prefilteredPromotions: any[] = [] + const originalPromotionServiceList = (service as any).promotionService_ + .list + + ;(service as any).promotionService_.list = async (...args: any[]) => { + const result = await originalPromotionServiceList.bind( + (service as any).promotionService_ + )(...args) + + if (prefilterCallCount === 0) { + prefilteredPromotions = result + } + prefilterCallCount++ + + return result + } + + // Test Context + const testContext = { + currency_code: "usd", + items: [ + { + id: "item_tshirt0", + quantity: 1, + subtotal: 100, + product: { id: "prod_tshirt0" }, // Matches CUSTOMER_PROMO_1 target + }, + { + id: "item_tshirt1", + quantity: 1, + subtotal: 100, + product: { id: "prod_tshirt1" }, + }, + { + id: "item_tshirt9", + quantity: 5, + subtotal: 750, + product: { id: "prod_tshirt9" }, + }, + { + id: "item_unknown", + quantity: 1, + subtotal: 110, + product: { id: "prod_unknown" }, + }, + ] as any, + } + + const actions = await service.computeActions([], testContext) + + ;(service as any).promotionService_.list = originalPromotionServiceList + + // 1. Verify prefiltering worked - should include matching promotion + // We expect the prefilter to have return 0 promotion that is being satisfied by the + expect(prefilteredPromotions).toHaveLength(0) + + expect(actions).toHaveLength(0) + }) + it("should return empty array when promotion is not active (draft or inactive)", async () => { const promotion = await createDefaultPromotion(service, { status: PromotionStatus.DRAFT, @@ -2865,7 +3402,7 @@ moduleIntegrationTestRunner({ ]) }) - it("should compute the correct shipping_method amendments when promotion is automatic and prevent_auto_promotions is false", async () => { + it("should compute the correct shipping_method amendments when promotion is automatic and prevent_auto_promotions is true", async () => { await createDefaultPromotion(service, { rules: [ { diff --git a/packages/modules/promotion/src/migrations/.snapshot-medusa-promotion.json b/packages/modules/promotion/src/migrations/.snapshot-medusa-promotion.json index 3563e14ab0..a14640fb00 100644 --- a/packages/modules/promotion/src/migrations/.snapshot-medusa-promotion.json +++ b/packages/modules/promotion/src/migrations/.snapshot-medusa-promotion.json @@ -460,6 +460,15 @@ "unique": false, "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_unique_promotion_code\" ON \"promotion\" (code) WHERE deleted_at IS NULL" }, + { + "keyName": "IDX_promotion_is_automatic", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_is_automatic\" ON \"promotion\" (is_automatic) WHERE deleted_at IS NULL" + }, { "keyName": "promotion_pkey", "columnNames": [ @@ -832,6 +841,15 @@ "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_rule_deleted_at\" ON \"promotion_rule\" (deleted_at) WHERE deleted_at IS NULL" }, + { + "keyName": "IDX_promotion_rule_attribute_operator", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_rule_attribute_operator\" ON \"promotion_rule\" (attribute, operator) WHERE deleted_at IS NULL" + }, { "keyName": "promotion_rule_pkey", "columnNames": [ @@ -1131,6 +1149,24 @@ "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_rule_value_deleted_at\" ON \"promotion_rule_value\" (deleted_at) WHERE deleted_at IS NULL" }, + { + "keyName": "IDX_promotion_rule_value_rule_id_value", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_rule_value_rule_id_value\" ON \"promotion_rule_value\" (promotion_rule_id, value) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_promotion_rule_value_value", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_rule_value_value\" ON \"promotion_rule_value\" (value) WHERE deleted_at IS NULL" + }, { "keyName": "promotion_rule_value_pkey", "columnNames": [ diff --git a/packages/modules/promotion/src/migrations/Migration20250916120552.ts b/packages/modules/promotion/src/migrations/Migration20250916120552.ts new file mode 100644 index 0000000000..cfd1f87396 --- /dev/null +++ b/packages/modules/promotion/src/migrations/Migration20250916120552.ts @@ -0,0 +1,13 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20250916120552 extends Migration { + override async up(): Promise { + this.addSql( + `CREATE INDEX IF NOT EXISTS "IDX_promotion_rule_attribute_operator" ON "promotion_rule" (attribute, operator) WHERE deleted_at IS NULL;` + ) + } + + override async down(): Promise { + this.addSql(`drop index if exists "IDX_promotion_rule_attribute_operator";`) + } +} diff --git a/packages/modules/promotion/src/migrations/Migration20250917143818.ts b/packages/modules/promotion/src/migrations/Migration20250917143818.ts new file mode 100644 index 0000000000..7294c8397c --- /dev/null +++ b/packages/modules/promotion/src/migrations/Migration20250917143818.ts @@ -0,0 +1,19 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20250917143818 extends Migration { + + override async up(): Promise { + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_promotion_is_automatic" ON "promotion" (is_automatic) WHERE deleted_at IS NULL;`); + + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_promotion_rule_value_rule_id_value" ON "promotion_rule_value" (promotion_rule_id, value) WHERE deleted_at IS NULL;`); + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_promotion_rule_value_value" ON "promotion_rule_value" (value) WHERE deleted_at IS NULL;`); + } + + override async down(): Promise { + this.addSql(`drop index if exists "IDX_promotion_is_automatic";`); + + this.addSql(`drop index if exists "IDX_promotion_rule_value_rule_id_value";`); + this.addSql(`drop index if exists "IDX_promotion_rule_value_value";`); + } + +} diff --git a/packages/modules/promotion/src/models/promotion-rule-value.ts b/packages/modules/promotion/src/models/promotion-rule-value.ts index 366d71daee..f97ea08d23 100644 --- a/packages/modules/promotion/src/models/promotion-rule-value.ts +++ b/packages/modules/promotion/src/models/promotion-rule-value.ts @@ -1,15 +1,30 @@ import { model } from "@medusajs/framework/utils" import PromotionRule from "./promotion-rule" -const PromotionRuleValue = model.define( - { name: "PromotionRuleValue", tableName: "promotion_rule_value" }, - { - id: model.id({ prefix: "prorulval" }).primaryKey(), - value: model.text(), - promotion_rule: model.belongsTo(() => PromotionRule, { - mappedBy: "values", - }), - } -) +const PromotionRuleValue = model + .define( + { name: "PromotionRuleValue", tableName: "promotion_rule_value" }, + { + id: model.id({ prefix: "prorulval" }).primaryKey(), + value: model.text(), + promotion_rule: model.belongsTo(() => PromotionRule, { + mappedBy: "values", + }), + } + ) + .indexes([ + { + name: "IDX_promotion_rule_value_rule_id_value", + on: ["promotion_rule_id", "value"], + unique: false, + where: "deleted_at IS NULL", + }, + { + name: "IDX_promotion_rule_value_value", + on: ["value"], + unique: false, + where: "deleted_at IS NULL", + }, + ]) export default PromotionRuleValue diff --git a/packages/modules/promotion/src/models/promotion-rule.ts b/packages/modules/promotion/src/models/promotion-rule.ts index 2c6f4347ee..1d3b2c172a 100644 --- a/packages/modules/promotion/src/models/promotion-rule.ts +++ b/packages/modules/promotion/src/models/promotion-rule.ts @@ -30,6 +30,13 @@ const PromotionRule = model }), } ) + .indexes([ + { + on: ["attribute", "operator"], + unique: false, + where: "deleted_at IS NULL", + }, + ]) .cascades({ delete: ["values"], }) diff --git a/packages/modules/promotion/src/models/promotion.ts b/packages/modules/promotion/src/models/promotion.ts index de9ed84195..60dee90f4a 100644 --- a/packages/modules/promotion/src/models/promotion.ts +++ b/packages/modules/promotion/src/models/promotion.ts @@ -39,6 +39,12 @@ const Promotion = model where: "deleted_at IS NULL", unique: true, }, + { + name: "IDX_promotion_is_automatic", + on: ["is_automatic"], + unique: false, + where: "deleted_at IS NULL", + }, ]) export default Promotion diff --git a/packages/modules/promotion/src/services/promotion-module.ts b/packages/modules/promotion/src/services/promotion-module.ts index bafd86223b..03e81d1cf2 100644 --- a/packages/modules/promotion/src/services/promotion-module.ts +++ b/packages/modules/promotion/src/services/promotion-module.ts @@ -62,6 +62,7 @@ import { } from "@utils" import { joinerConfig } from "../joiner-config" import { CreatePromotionRuleValueDTO } from "../types/promotion-rule-value" +import { buildPromotionRuleQueryFilterFromContext } from "../utils/compute-actions/build-promotion-rule-query-filter-from-context" type InjectedDependencies = { baseRepository: DAL.RepositoryService @@ -461,11 +462,40 @@ export default class PromotionModuleService const uniquePromotionCodes = Array.from(new Set(promotionCodesToApply)) - const queryFilter = preventAutoPromotions - ? { code: uniquePromotionCodes } - : { - $or: [{ code: uniquePromotionCodes }, { is_automatic: true }], - } + let queryFilter: DAL.FilterQuery = { code: uniquePromotionCodes } + + if (!preventAutoPromotions) { + const rulePrefilteringFilters = + buildPromotionRuleQueryFilterFromContext(applicationContext) + + let prefilteredAutomaticPromotionIds: string[] = [] + + if (rulePrefilteringFilters) { + const promotions = await this.promotionService_.list( + { + $and: [{ is_automatic: true }, rulePrefilteringFilters], + }, + { select: ["id"] }, + sharedContext + ) + + prefilteredAutomaticPromotionIds = promotions.map( + (promotion) => promotion.id! + ) + } + + const automaticPromotionFilter = rulePrefilteringFilters + ? { + id: { $in: prefilteredAutomaticPromotionIds }, + } + : { is_automatic: true } + + queryFilter = automaticPromotionFilter + ? { + $or: [{ code: uniquePromotionCodes }, automaticPromotionFilter], + } + : queryFilter + } const promotions = await this.listActivePromotions_( queryFilter, diff --git a/packages/modules/promotion/src/utils/compute-actions/build-promotion-rule-query-filter-from-context.ts b/packages/modules/promotion/src/utils/compute-actions/build-promotion-rule-query-filter-from-context.ts new file mode 100644 index 0000000000..5ad1dba346 --- /dev/null +++ b/packages/modules/promotion/src/utils/compute-actions/build-promotion-rule-query-filter-from-context.ts @@ -0,0 +1,176 @@ +import { + ComputeActionContext, + ComputeActionItemLine, + ComputeActionShippingLine, + DAL, + PromotionTypes, +} from "@medusajs/framework/types" +import { flattenObjectToKeyValuePairs } from "@medusajs/framework/utils" +import { raw } from "@mikro-orm/postgresql" + +/** + * Builds a query filter for promotion rules based on the context. + * This is used to prefilter promotions before computing actions. + * The idea is that we first retrieve from the database the promotions where all rules can be + * satisfied by the given context. We exclude promotions that have any rule that cannot be satisfied. + * + * @param context + * @returns + */ +export function buildPromotionRuleQueryFilterFromContext( + context: PromotionTypes.ComputeActionContext +): DAL.FilterQuery | null { + const { + items = [], + shipping_methods: shippingMethods = [], + ...restContext + } = context + + let flattenItemsPropsValuesArray = flattenObjectToKeyValuePairs( + items + ) as Record + flattenItemsPropsValuesArray = Object.fromEntries( + Object.entries(flattenItemsPropsValuesArray).map(([k, v]) => [ + `items.${k}`, + v, + ]) + ) + + let flattenShippingMethodsPropsValuesArray = flattenObjectToKeyValuePairs( + shippingMethods + ) as Record + flattenShippingMethodsPropsValuesArray = Object.fromEntries( + Object.entries(flattenShippingMethodsPropsValuesArray).map(([k, v]) => [ + `shipping_methods.${k}`, + v, + ]) + ) + + const flattenRestContextPropsValuesArray = flattenObjectToKeyValuePairs( + restContext + ) as Record + + const attributeValueMap = new Map>() + + ;[ + flattenItemsPropsValuesArray, + flattenShippingMethodsPropsValuesArray, + flattenRestContextPropsValuesArray, + ].forEach((flattenedArray) => { + Object.entries(flattenedArray).forEach(([prop, value]) => { + if (!attributeValueMap.has(prop)) { + attributeValueMap.set(prop, new Set()) + } + + const values = Array.isArray(value) ? value : [value] + values.forEach((v) => attributeValueMap.get(prop)!.add(v)) + }) + }) + + // Build conditions for a NOT EXISTS subquery to exclude promotions with unsatisfiable rules + const sqlConditions: string[] = [] + + // First, check for rules where the attribute doesn't exist in context at all + // These rules can never be satisfied + sqlConditions.push( + `pr.attribute NOT IN (${Array.from(attributeValueMap.keys()) + .map((attr) => `'${attr.replace(/'/g, "''")}'`) + .join(",")})` + ) + + // Then, for attributes that exist in context, check if the values don't satisfy the rules + attributeValueMap.forEach((valueSet, attribute) => { + const values = Array.from(valueSet) + const stringValues = values + .map((v) => `'${v.toString().replace(/'/g, "''")}'`) + .join(",") + + const numericValues = values + .map((v) => { + const num = Number(v) + return !isNaN(num) ? num : null + }) + .filter((v) => v !== null) as number[] + + // Escape attribute name to prevent SQL injection + const escapedAttribute = `'${attribute.replace(/'/g, "''")}'` + + // For 'in' and 'eq' operators - rule is unsatisfiable if NO rule values overlap with context + // This requires checking that ALL rule values for a given rule are not in context + if (stringValues.length) { + sqlConditions.push( + `(pr.attribute = ${escapedAttribute} AND pr.operator IN ('in', 'eq') AND pr.id NOT IN ( + SELECT DISTINCT prv_inner.promotion_rule_id + FROM promotion_rule_value prv_inner + WHERE prv_inner.value IN (${stringValues}) + ))` + ) + } + + if (numericValues.length) { + const minValue = Math.min(...numericValues) + const maxValue = Math.max(...numericValues) + + // For gt - rule is unsatisfiable if rule_value >= context_max_value + sqlConditions.push( + `(pr.attribute = ${escapedAttribute} AND pr.operator = 'gt' AND CAST(prv.value AS DECIMAL) >= ${maxValue})` + ) + + // For gte - rule is unsatisfiable if rule_value > context_max_value + sqlConditions.push( + `(pr.attribute = ${escapedAttribute} AND pr.operator = 'gte' AND prv.value NOT IN (${stringValues}) AND CAST(prv.value AS DECIMAL) > ${maxValue})` + ) + + // For lt - rule is unsatisfiable if rule_value <= context_min_value + sqlConditions.push( + `(pr.attribute = ${escapedAttribute} AND pr.operator = 'lt' AND CAST(prv.value AS DECIMAL) <= ${minValue})` + ) + + // For lte - rule is unsatisfiable if rule_value < context_min_value + sqlConditions.push( + `(pr.attribute = ${escapedAttribute} AND pr.operator = 'lte' AND prv.value NOT IN (${stringValues}) AND CAST(prv.value AS DECIMAL) < ${minValue})` + ) + } + }) + + // Handle the case where context has no attributes at all, it means + // that any promotion that have a rule cant be satisfied by the context + if (attributeValueMap.size === 0) { + // If context has no attributes, exclude all promotions that have any rules + const notExistsSubquery = (alias: string) => + ` + NOT EXISTS ( + SELECT 1 FROM promotion_promotion_rule ppr + WHERE ppr.promotion_id = ${alias}.id + ) + `.trim() + + return { + [raw((alias) => notExistsSubquery(alias))]: true, + } + } + + const joinedConditions = sqlConditions.join(" OR ") + const queryEstimatedSize = joinedConditions.length + const maxQuerySize = 2147483648 * 0.9 + + if (queryEstimatedSize > maxQuerySize) { + // generated query could be too long + return null + } + + const notExistsSubquery = (alias: string) => + ` + NOT EXISTS ( + SELECT 1 FROM promotion_promotion_rule ppr + JOIN promotion_rule pr ON ppr.promotion_rule_id = pr.id + LEFT JOIN promotion_rule_value prv ON prv.promotion_rule_id = pr.id + WHERE ppr.promotion_id = ${alias}.id + AND (${joinedConditions}) + ) + `.trim() + + return { + [raw((alias) => notExistsSubquery(alias))]: true, + } +}