feat(): prefilter top level promotion rules in db (#13524)

* feat(): promotion pre filtering rule from db

* wip

* feat(): promotion pre filtering rule from db

* improve test readability

* resolve conflict

* fix automatic flag

* add index on attribute and operator

* add index on attribute and operator

* finalize

* cleanup

* cleanup

* cleanup

* cleanup

* Create purple-cars-design.md

* fixes

* fixes

* simplify filters

* fix filter

* fix filter

* further improvements

* fixes

* fixes

* fixes

* fix exclusion

* fix comment

* fix comment
This commit is contained in:
Adrien de Peretti
2025-09-18 14:34:03 +02:00
committed by GitHub
parent 76497fd40a
commit 57897c232e
10 changed files with 861 additions and 17 deletions
@@ -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": [
@@ -0,0 +1,13 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20250916120552 extends Migration {
override async up(): Promise<void> {
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<void> {
this.addSql(`drop index if exists "IDX_promotion_rule_attribute_operator";`)
}
}
@@ -0,0 +1,19 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20250917143818 extends Migration {
override async up(): Promise<void> {
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<void> {
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";`);
}
}
@@ -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
@@ -30,6 +30,13 @@ const PromotionRule = model
}),
}
)
.indexes([
{
on: ["attribute", "operator"],
unique: false,
where: "deleted_at IS NULL",
},
])
.cascades({
delete: ["values"],
})
@@ -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
@@ -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<any> = { 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,
@@ -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<any> | null {
const {
items = [],
shipping_methods: shippingMethods = [],
...restContext
} = context
let flattenItemsPropsValuesArray = flattenObjectToKeyValuePairs(
items
) as Record<keyof ComputeActionItemLine & string, any>
flattenItemsPropsValuesArray = Object.fromEntries(
Object.entries(flattenItemsPropsValuesArray).map(([k, v]) => [
`items.${k}`,
v,
])
)
let flattenShippingMethodsPropsValuesArray = flattenObjectToKeyValuePairs(
shippingMethods
) as Record<keyof ComputeActionShippingLine & string, any>
flattenShippingMethodsPropsValuesArray = Object.fromEntries(
Object.entries(flattenShippingMethodsPropsValuesArray).map(([k, v]) => [
`shipping_methods.${k}`,
v,
])
)
const flattenRestContextPropsValuesArray = flattenObjectToKeyValuePairs(
restContext
) as Record<keyof ComputeActionContext & string, any>
const attributeValueMap = new Map<string, Set<any>>()
;[
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,
}
}