chore(): Further improve promotions computation (#13556)
* chore(): Further improve promotions * chore(): Further improve promotions * chore(): Further improve promotions * chore(): Further improve promotions * chore(): Further improve promotions * Create lazy-lemons-occur.md * chore(): Further improve promotions * WIP * fix * improve: * fix attribute tests * fix tests * union
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@medusajs/promotion": patch
|
||||
---
|
||||
|
||||
Chore(): Further promotions pre filtering improvements
|
||||
@@ -140,7 +140,7 @@ medusaIntegrationTestRunner({
|
||||
currency_code: "usd",
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product_id",
|
||||
attribute: "items.product_id",
|
||||
operator: "in",
|
||||
values: [product.id],
|
||||
},
|
||||
@@ -2571,7 +2571,7 @@ medusaIntegrationTestRunner({
|
||||
apply_to_quantity: 1,
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product_id",
|
||||
attribute: "items.product_id",
|
||||
operator: PromotionRuleOperator.IN,
|
||||
values: [product.id],
|
||||
},
|
||||
@@ -4353,7 +4353,7 @@ medusaIntegrationTestRunner({
|
||||
currency_code: "eur",
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product_id",
|
||||
attribute: "items.product_id",
|
||||
operator: "in",
|
||||
values: [product.id],
|
||||
},
|
||||
|
||||
@@ -82,7 +82,7 @@ medusaIntegrationTestRunner({
|
||||
currency_code: "usd",
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product_id",
|
||||
attribute: "items.product_id",
|
||||
operator: "eq",
|
||||
values: "prod_mat",
|
||||
},
|
||||
|
||||
@@ -1418,7 +1418,7 @@ medusaIntegrationTestRunner({
|
||||
max_quantity: 1,
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product_id",
|
||||
attribute: "items.product_id",
|
||||
operator: "eq",
|
||||
values: [product.id],
|
||||
},
|
||||
|
||||
@@ -60,7 +60,7 @@ medusaIntegrationTestRunner({
|
||||
max_quantity: 1,
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product_id",
|
||||
attribute: "items.product_id",
|
||||
operator: "eq",
|
||||
values: "prod_tshirt",
|
||||
},
|
||||
@@ -83,7 +83,7 @@ medusaIntegrationTestRunner({
|
||||
max_quantity: 1,
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product_id",
|
||||
attribute: "items.product_id",
|
||||
operator: "eq",
|
||||
values: "prod_tshirt",
|
||||
},
|
||||
|
||||
@@ -469,7 +469,7 @@ medusaIntegrationTestRunner({
|
||||
max_quantity: 100,
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "variant_id",
|
||||
attribute: "items.variant_id",
|
||||
operator: "in",
|
||||
values: [product.variants[0].id, product_2.variants[0].id],
|
||||
},
|
||||
@@ -877,7 +877,7 @@ medusaIntegrationTestRunner({
|
||||
max_quantity: 100,
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "variant_id",
|
||||
attribute: "items.variant_id",
|
||||
operator: "in",
|
||||
values: [product.variants[0].id],
|
||||
},
|
||||
|
||||
+292
-103
File diff suppressed because it is too large
Load Diff
+24
-24
@@ -266,7 +266,7 @@ moduleIntegrationTestRunner({
|
||||
const createdPromotion = await createDefaultPromotion(service, {
|
||||
rules: [
|
||||
{
|
||||
attribute: "customer_group_id",
|
||||
attribute: "customer.customer_group.id",
|
||||
operator: "in",
|
||||
values: ["VIP", "top100"],
|
||||
},
|
||||
@@ -285,7 +285,7 @@ moduleIntegrationTestRunner({
|
||||
type: "standard",
|
||||
rules: [
|
||||
expect.objectContaining({
|
||||
attribute: "customer_group_id",
|
||||
attribute: "customer.customer_group.id",
|
||||
operator: "in",
|
||||
values: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -305,7 +305,7 @@ moduleIntegrationTestRunner({
|
||||
const createdPromotion = await createDefaultPromotion(service, {
|
||||
rules: [
|
||||
{
|
||||
attribute: "customer_group_id",
|
||||
attribute: "customer.customer_group.id",
|
||||
operator: "eq",
|
||||
values: "VIP",
|
||||
},
|
||||
@@ -324,7 +324,7 @@ moduleIntegrationTestRunner({
|
||||
type: "standard",
|
||||
rules: [
|
||||
expect.objectContaining({
|
||||
attribute: "customer_group_id",
|
||||
attribute: "customer.customer_group.id",
|
||||
operator: "eq",
|
||||
values: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -469,14 +469,14 @@ moduleIntegrationTestRunner({
|
||||
buy_rules_min_quantity: 1,
|
||||
buy_rules: [
|
||||
{
|
||||
attribute: "product_collection.id",
|
||||
attribute: "items.product_collection.id",
|
||||
operator: "eq",
|
||||
values: ["pcol_towel"],
|
||||
},
|
||||
],
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product.id",
|
||||
attribute: "items.product.id",
|
||||
operator: "eq",
|
||||
values: ["prod_mat"],
|
||||
},
|
||||
@@ -498,14 +498,14 @@ moduleIntegrationTestRunner({
|
||||
allocation: "each",
|
||||
buy_rules: [
|
||||
{
|
||||
attribute: "product_collection.id",
|
||||
attribute: "items.product_collection.id",
|
||||
operator: "eq",
|
||||
values: ["pcol_towel"],
|
||||
},
|
||||
],
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product.id",
|
||||
attribute: "items.product.id",
|
||||
operator: "eq",
|
||||
values: ["prod_mat"],
|
||||
},
|
||||
@@ -528,14 +528,14 @@ moduleIntegrationTestRunner({
|
||||
buy_rules_min_quantity: 1,
|
||||
buy_rules: [
|
||||
{
|
||||
attribute: "product_collection.id",
|
||||
attribute: "items.product_collection.id",
|
||||
operator: "eq",
|
||||
values: ["pcol_towel"],
|
||||
},
|
||||
],
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product.id",
|
||||
attribute: "items.product.id",
|
||||
operator: "eq",
|
||||
values: "prod_mat",
|
||||
},
|
||||
@@ -551,14 +551,14 @@ moduleIntegrationTestRunner({
|
||||
buy_rules_min_quantity: 1,
|
||||
target_rules: [
|
||||
expect.objectContaining({
|
||||
attribute: "product.id",
|
||||
attribute: "items.product.id",
|
||||
operator: "eq",
|
||||
values: [expect.objectContaining({ value: "prod_mat" })],
|
||||
}),
|
||||
],
|
||||
buy_rules: [
|
||||
expect.objectContaining({
|
||||
attribute: "product_collection.id",
|
||||
attribute: "items.product_collection.id",
|
||||
operator: "eq",
|
||||
values: [expect.objectContaining({ value: "pcol_towel" })],
|
||||
}),
|
||||
@@ -980,7 +980,7 @@ moduleIntegrationTestRunner({
|
||||
it("should successfully add rules to a promotion", async () => {
|
||||
const promotionRules = await service.addPromotionRules(promotion.id, [
|
||||
{
|
||||
attribute: "customer_group_id",
|
||||
attribute: "customer.customer_group.id",
|
||||
operator: "in",
|
||||
values: ["VIP", "top100"],
|
||||
},
|
||||
@@ -989,7 +989,7 @@ moduleIntegrationTestRunner({
|
||||
expect(promotionRules).toEqual([
|
||||
expect.objectContaining({
|
||||
id: promotionRules[0].id,
|
||||
attribute: "customer_group_id",
|
||||
attribute: "customer.customer_group.id",
|
||||
operator: "in",
|
||||
values: expect.arrayContaining([
|
||||
expect.objectContaining({ value: "VIP" }),
|
||||
@@ -1041,7 +1041,7 @@ moduleIntegrationTestRunner({
|
||||
promotion.id,
|
||||
[
|
||||
{
|
||||
attribute: "customer_group_id",
|
||||
attribute: "customer.customer_group.id",
|
||||
operator: "in",
|
||||
values: ["VIP", "top100"],
|
||||
},
|
||||
@@ -1051,7 +1051,7 @@ moduleIntegrationTestRunner({
|
||||
expect(promotionRules).toEqual([
|
||||
expect.objectContaining({
|
||||
id: promotionRules[0].id,
|
||||
attribute: "customer_group_id",
|
||||
attribute: "customer.customer_group.id",
|
||||
operator: "in",
|
||||
values: expect.arrayContaining([
|
||||
expect.objectContaining({ value: "VIP" }),
|
||||
@@ -1075,14 +1075,14 @@ moduleIntegrationTestRunner({
|
||||
buy_rules_min_quantity: 1,
|
||||
buy_rules: [
|
||||
{
|
||||
attribute: "product_collection.id",
|
||||
attribute: "items.product_collection.id",
|
||||
operator: "eq",
|
||||
values: ["pcol_towel"],
|
||||
},
|
||||
],
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product.id",
|
||||
attribute: "items.product.id",
|
||||
operator: "in",
|
||||
values: ["prod_1", "prod_2"],
|
||||
},
|
||||
@@ -1125,7 +1125,7 @@ moduleIntegrationTestRunner({
|
||||
promotion.id,
|
||||
[
|
||||
{
|
||||
attribute: "product.id",
|
||||
attribute: "items.product.id",
|
||||
operator: "in",
|
||||
values: ["prod_3", "prod_4"],
|
||||
},
|
||||
@@ -1135,7 +1135,7 @@ moduleIntegrationTestRunner({
|
||||
expect(promotionRules).toEqual([
|
||||
expect.objectContaining({
|
||||
id: promotionRules[0].id,
|
||||
attribute: "product.id",
|
||||
attribute: "items.product.id",
|
||||
operator: "in",
|
||||
values: expect.arrayContaining([
|
||||
expect.objectContaining({ value: "prod_3" }),
|
||||
@@ -1153,7 +1153,7 @@ moduleIntegrationTestRunner({
|
||||
promotion = await createDefaultPromotion(service, {
|
||||
rules: [
|
||||
{
|
||||
attribute: "customer_group_id",
|
||||
attribute: "customer.customer_group.id",
|
||||
operator: "in",
|
||||
values: ["VIP", "top100"],
|
||||
},
|
||||
@@ -1219,7 +1219,7 @@ moduleIntegrationTestRunner({
|
||||
application_method: {
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "customer_group_id",
|
||||
attribute: "customer.customer_group.id",
|
||||
operator: "in",
|
||||
values: ["VIP", "top100"],
|
||||
},
|
||||
@@ -1295,14 +1295,14 @@ moduleIntegrationTestRunner({
|
||||
buy_rules_min_quantity: 1,
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "product.id",
|
||||
attribute: "items.product.id",
|
||||
operator: "in",
|
||||
values: ["prod_1", "prod_2"],
|
||||
},
|
||||
],
|
||||
buy_rules: [
|
||||
{
|
||||
attribute: "product_collection",
|
||||
attribute: "items.product_collection.id",
|
||||
operator: "eq",
|
||||
values: ["pcol_towel"],
|
||||
},
|
||||
|
||||
@@ -850,6 +850,15 @@
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_rule_attribute_operator\" ON \"promotion_rule\" (attribute, operator) WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_promotion_rule_attribute_operator_id",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_promotion_rule_attribute_operator_id\" ON \"promotion_rule\" (operator, attribute, id) WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "promotion_rule_pkey",
|
||||
"columnNames": [
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20250919122137 extends Migration {
|
||||
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_promotion_rule_attribute_operator_id" ON "promotion_rule" (operator, attribute, id) WHERE deleted_at IS NULL;`);
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(`drop index if exists "IDX_promotion_rule_attribute_operator_id";`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -36,6 +36,12 @@ const PromotionRule = model
|
||||
unique: false,
|
||||
where: "deleted_at IS NULL",
|
||||
},
|
||||
{
|
||||
name: "IDX_promotion_rule_attribute_operator_id",
|
||||
on: ["operator", "attribute", "id"],
|
||||
unique: false,
|
||||
where: "deleted_at IS NULL",
|
||||
},
|
||||
])
|
||||
.cascades({
|
||||
delete: ["values"],
|
||||
|
||||
+73
-25
@@ -69,7 +69,6 @@ export async function buildPromotionRuleQueryFilterFromContext(
|
||||
})
|
||||
})
|
||||
|
||||
// count the number of attributes in the map
|
||||
const numberOfAttributes = attributeValueMap.size
|
||||
if (numberOfAttributes > 10) {
|
||||
const manager = (sharedContext.transactionManager ??
|
||||
@@ -96,18 +95,9 @@ export async function buildPromotionRuleQueryFilterFromContext(
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// For each attribute, check if the values don't satisfy the rules
|
||||
attributeValueMap.forEach((valueSet, attribute) => {
|
||||
const values = Array.from(valueSet)
|
||||
const stringValues = values
|
||||
@@ -121,7 +111,6 @@ export async function buildPromotionRuleQueryFilterFromContext(
|
||||
})
|
||||
.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
|
||||
@@ -165,17 +154,25 @@ export async function buildPromotionRuleQueryFilterFromContext(
|
||||
// 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) =>
|
||||
// If context has no attributes, exclude all promotions that have any rules (promotion rules, target rules, or buy rules)
|
||||
const noRulesSubquery = (alias: string) =>
|
||||
`
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM promotion_promotion_rule ppr
|
||||
WHERE ppr.promotion_id = ${alias}.id
|
||||
${alias}.id NOT IN (
|
||||
SELECT DISTINCT ppr.promotion_id
|
||||
FROM promotion_promotion_rule ppr
|
||||
UNION
|
||||
SELECT DISTINCT am.promotion_id
|
||||
FROM promotion_application_method am
|
||||
JOIN application_method_target_rules amtr ON am.id = amtr.application_method_id
|
||||
UNION
|
||||
SELECT DISTINCT am2.promotion_id
|
||||
FROM promotion_application_method am2
|
||||
JOIN application_method_buy_rules ambr ON am2.id = ambr.application_method_id
|
||||
)
|
||||
`.trim()
|
||||
|
||||
return {
|
||||
[raw((alias) => notExistsSubquery(alias))]: true,
|
||||
[raw((alias) => noRulesSubquery(alias))]: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,18 +185,69 @@ export async function buildPromotionRuleQueryFilterFromContext(
|
||||
return null
|
||||
}
|
||||
|
||||
const notExistsSubquery = (alias: string) =>
|
||||
const attributeKeys = Array.from(attributeValueMap.keys())
|
||||
.map((attr) => `'${attr.replace(/'/g, "''")}'`)
|
||||
.join(",")
|
||||
|
||||
// Use anti-join approach for better performance than NOT IN
|
||||
const antiJoinSubquery = (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})
|
||||
SELECT 1 FROM (
|
||||
-- Promotions with rules for attributes not in context
|
||||
SELECT ppr.promotion_id
|
||||
FROM promotion_promotion_rule ppr
|
||||
JOIN promotion_rule pr ON ppr.promotion_rule_id = pr.id
|
||||
WHERE pr.attribute NOT IN (${attributeKeys})
|
||||
|
||||
UNION
|
||||
|
||||
SELECT am.promotion_id
|
||||
FROM promotion_application_method am
|
||||
JOIN application_method_target_rules amtr ON am.id = amtr.application_method_id
|
||||
JOIN promotion_rule pr ON amtr.promotion_rule_id = pr.id
|
||||
WHERE pr.attribute NOT IN (${attributeKeys})
|
||||
|
||||
UNION
|
||||
|
||||
SELECT am2.promotion_id
|
||||
FROM promotion_application_method am2
|
||||
JOIN application_method_buy_rules ambr ON am2.id = ambr.application_method_id
|
||||
JOIN promotion_rule pr ON ambr.promotion_rule_id = pr.id
|
||||
WHERE pr.attribute NOT IN (${attributeKeys})
|
||||
|
||||
UNION
|
||||
|
||||
-- Promotions with unsatisfiable rules for context attributes
|
||||
SELECT ppr.promotion_id
|
||||
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 pr.attribute IN (${attributeKeys}) AND (${joinedConditions})
|
||||
|
||||
UNION
|
||||
|
||||
SELECT am.promotion_id
|
||||
FROM promotion_application_method am
|
||||
JOIN application_method_target_rules amtr ON am.id = amtr.application_method_id
|
||||
JOIN promotion_rule pr ON amtr.promotion_rule_id = pr.id
|
||||
LEFT JOIN promotion_rule_value prv ON prv.promotion_rule_id = pr.id
|
||||
WHERE pr.attribute IN (${attributeKeys}) AND (${joinedConditions})
|
||||
|
||||
UNION
|
||||
|
||||
SELECT am2.promotion_id
|
||||
FROM promotion_application_method am2
|
||||
JOIN application_method_buy_rules ambr ON am2.id = ambr.application_method_id
|
||||
JOIN promotion_rule pr ON ambr.promotion_rule_id = pr.id
|
||||
LEFT JOIN promotion_rule_value prv ON prv.promotion_rule_id = pr.id
|
||||
WHERE pr.attribute IN (${attributeKeys}) AND (${joinedConditions})
|
||||
) excluded_promotions
|
||||
WHERE excluded_promotions.promotion_id = ${alias}.id
|
||||
)
|
||||
`.trim()
|
||||
|
||||
return {
|
||||
[raw((alias) => notExistsSubquery(alias))]: true,
|
||||
[raw((alias) => antiJoinSubquery(alias))]: true,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user