fix(index): logical operators (#13137)

This commit is contained in:
Carlos R. L. Rodrigues
2025-08-07 07:34:50 -03:00
committed by GitHub
parent a52708769d
commit 9725bff25d
11 changed files with 373 additions and 125 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/index": patch
"@medusajs/link-modules": patch
---
fix(index): handle $and and $or operators

View File

@@ -6,6 +6,7 @@ import {
ContainerRegistrationKeys, ContainerRegistrationKeys,
defaultCurrencies, defaultCurrencies,
defineLink, defineLink,
Modules,
} from "@medusajs/utils" } from "@medusajs/utils"
import { setTimeout } from "timers/promises" import { setTimeout } from "timers/promises"
import { import {
@@ -35,6 +36,7 @@ async function populateData(api: any) {
origin_country: "USA", origin_country: "USA",
shipping_profile_id: shippingProfile.id, shipping_profile_id: shippingProfile.id,
options: [{ title: "Denominations", values: ["100"] }], options: [{ title: "Denominations", values: ["100"] }],
material: "test-material",
variants: [ variants: [
{ {
title: `Test variant 1`, title: `Test variant 1`,
@@ -61,6 +63,7 @@ async function populateData(api: any) {
status: "published", status: "published",
shipping_profile_id: shippingProfile.id, shipping_profile_id: shippingProfile.id,
options: [{ title: "Colors", values: ["Red"] }], options: [{ title: "Colors", values: ["Red"] }],
material: "extra-material",
variants: new Array(2).fill(0).map((_, i) => ({ variants: new Array(2).fill(0).map((_, i) => ({
title: `extra variant ${i}`, title: `extra variant ${i}`,
sku: `extra-variant-${i}`, sku: `extra-variant-${i}`,
@@ -81,9 +84,16 @@ async function populateData(api: any) {
}, },
] ]
await api.post("/admin/products/batch", { create: payload }, adminHeaders) const response = await api.post(
"/admin/products/batch",
{ create: payload },
adminHeaders
)
const products = response.data.created
await setTimeout(4000) await setTimeout(4000)
return products
} }
process.env.ENABLE_INDEX_MODULE = "true" process.env.ENABLE_INDEX_MODULE = "true"
@@ -117,7 +127,22 @@ medusaIntegrationTestRunner({
}) })
it("should use query.index to query the index module and hydrate the data", async () => { it("should use query.index to query the index module and hydrate the data", async () => {
await populateData(api) const products = await populateData(api)
const brandModule = appContainer.resolve("brand")
const link = appContainer.resolve(ContainerRegistrationKeys.LINK)
const brand = await brandModule.createBrands({
name: "Medusa Brand",
})
await link.create({
[Modules.PRODUCT]: {
product_id: products.find((p) => p.title === "Extra product").id,
},
brand: {
brand_id: brand.id,
},
})
const query = appContainer.resolve( const query = appContainer.resolve(
ContainerRegistrationKeys.QUERY ContainerRegistrationKeys.QUERY
@@ -132,6 +157,8 @@ medusaIntegrationTestRunner({
"description", "description",
"status", "status",
"title", "title",
"brand.name",
"brand.id",
"variants.sku", "variants.sku",
"variants.barcode", "variants.barcode",
"variants.material", "variants.material",
@@ -142,8 +169,28 @@ medusaIntegrationTestRunner({
"variants.inventory_items.inventory.description", "variants.inventory_items.inventory.description",
], ],
filters: { filters: {
"variants.sku": { $like: "%-1" }, $and: [
"variants.prices.amount": { $gt: 30 }, { status: "published" },
{ material: { $ilike: "%material%" } },
{
$or: [
{
brand: {
name: { $ilike: "%brand" },
},
},
{ title: { $ilike: "%duct%" } },
],
},
{
variants: {
$and: [
{ sku: { $like: "%-1" } },
{ "prices.amount": { $gt: 30 } },
],
},
},
],
}, },
pagination: { pagination: {
take: 10, take: 10,
@@ -171,6 +218,10 @@ medusaIntegrationTestRunner({
description: "extra description", description: "extra description",
title: "Extra product", title: "Extra product",
status: "published", status: "published",
brand: {
id: expect.any(String),
name: "Medusa Brand",
},
variants: [ variants: [
{ {
sku: "extra-variant-0", sku: "extra-variant-0",
@@ -247,6 +298,7 @@ medusaIntegrationTestRunner({
description: "test-product-description", description: "test-product-description",
title: "Test Product", title: "Test Product",
status: "published", status: "published",
brand: undefined,
variants: [ variants: [
{ {
sku: "test-variant-1", sku: "test-variant-1",

View File

@@ -1,3 +1,5 @@
import { defineConfig } from "@medusajs/utils"
const { Modules } = require("@medusajs/utils") const { Modules } = require("@medusajs/utils")
const DB_HOST = process.env.DB_HOST const DB_HOST = process.env.DB_HOST
@@ -35,7 +37,7 @@ const customFulfillmentProviderCalculated = {
id: "test-provider-calculated", id: "test-provider-calculated",
} }
module.exports = { module.exports = defineConfig({
admin: { admin: {
disable: true, disable: true,
}, },
@@ -51,11 +53,13 @@ module.exports = {
featureFlags: { featureFlags: {
medusa_v2: enableMedusaV2, medusa_v2: enableMedusaV2,
}, },
modules: { modules: [
testingModule: { {
key: "testingModule",
resolve: "__tests__/__fixtures__/testing-module", resolve: "__tests__/__fixtures__/testing-module",
}, },
[Modules.AUTH]: { {
key: "auth",
resolve: "@medusajs/auth", resolve: "@medusajs/auth",
options: { options: {
providers: [ providers: [
@@ -66,53 +70,98 @@ module.exports = {
], ],
}, },
}, },
[Modules.USER]: { {
key: Modules.USER,
scope: "internal", scope: "internal",
resolve: "@medusajs/user", resolve: "@medusajs/user",
options: { options: {
jwt_secret: "test", jwt_secret: "test",
}, },
}, },
[Modules.CACHE]: { {
key: Modules.CACHE,
resolve: "@medusajs/cache-inmemory", resolve: "@medusajs/cache-inmemory",
options: { ttl: 0 }, // Cache disabled options: { ttl: 0 }, // Cache disabled
}, },
[Modules.LOCKING]: true, {
[Modules.STOCK_LOCATION]: { key: Modules.LOCKING,
resolve: "@medusajs/locking",
},
{
key: Modules.STOCK_LOCATION,
resolve: "@medusajs/stock-location", resolve: "@medusajs/stock-location",
options: {}, options: {},
}, },
[Modules.INVENTORY]: { {
key: Modules.INVENTORY,
resolve: "@medusajs/inventory", resolve: "@medusajs/inventory",
options: {}, options: {},
}, },
[Modules.PRODUCT]: true, {
[Modules.PRICING]: true, key: Modules.PRODUCT,
[Modules.PROMOTION]: true, resolve: "@medusajs/product",
[Modules.REGION]: true, },
[Modules.CUSTOMER]: true, {
[Modules.SALES_CHANNEL]: true, key: Modules.PRICING,
[Modules.CART]: true, resolve: "@medusajs/pricing",
[Modules.WORKFLOW_ENGINE]: true, },
[Modules.API_KEY]: true, {
[Modules.STORE]: true, key: Modules.PROMOTION,
[Modules.TAX]: { resolve: "@medusajs/promotion",
},
{
key: Modules.REGION,
resolve: "@medusajs/region",
},
{
key: Modules.CUSTOMER,
resolve: "@medusajs/customer",
},
{
key: Modules.SALES_CHANNEL,
resolve: "@medusajs/sales-channel",
},
{
key: Modules.CART,
resolve: "@medusajs/cart",
},
{
key: Modules.WORKFLOW_ENGINE,
resolve: "@medusajs/workflow-engine-inmemory",
},
{
key: Modules.API_KEY,
resolve: "@medusajs/api-key",
},
{
key: Modules.STORE,
resolve: "@medusajs/store",
},
{
key: Modules.TAX,
resolve: "@medusajs/tax", resolve: "@medusajs/tax",
options: { options: {
providers: [customTaxProviderRegistration], providers: [customTaxProviderRegistration],
}, },
}, },
[Modules.CURRENCY]: true, {
[Modules.ORDER]: true, key: Modules.CURRENCY,
[Modules.PAYMENT]: { resolve: "@medusajs/currency",
},
{
key: Modules.ORDER,
resolve: "@medusajs/order",
},
{
key: Modules.PAYMENT,
resolve: "@medusajs/payment", resolve: "@medusajs/payment",
/** @type {import('@medusajs/payment').PaymentModuleOptions}*/
options: { options: {
providers: [customPaymentProvider], providers: [customPaymentProvider],
}, },
}, },
[Modules.FULFILLMENT]: { {
/** @type {import('@medusajs/fulfillment').FulfillmentModuleOptions} */ key: Modules.FULFILLMENT,
resolve: "@medusajs/fulfillment",
options: { options: {
providers: [ providers: [
customFulfillmentProvider, customFulfillmentProvider,
@@ -120,8 +169,8 @@ module.exports = {
], ],
}, },
}, },
[Modules.NOTIFICATION]: { {
/** @type {import('@medusajs/types').LocalNotificationServiceOptions} */ key: Modules.NOTIFICATION,
options: { options: {
providers: [ providers: [
{ {
@@ -135,10 +184,15 @@ module.exports = {
], ],
}, },
}, },
[Modules.INDEX]: process.env.ENABLE_INDEX_MODULE {
? { key: Modules.INDEX,
resolve: "@medusajs/index", resolve: "@medusajs/index",
} disable: process.env.ENABLE_INDEX_MODULE !== "true",
: false,
}, },
} {
key: "brand",
resolve: "src/modules/brand",
disable: process.env.ENABLE_INDEX_MODULE !== "true",
},
],
})

View File

@@ -0,0 +1,21 @@
import ProductModule from "@medusajs/medusa/product"
import { defineLink } from "@medusajs/utils"
import BrandModule from "../modules/brand"
const link =
process.env.ENABLE_INDEX_MODULE === "true"
? defineLink(
{
linkable: ProductModule.linkable.product.id,
filterable: ["description", "material"],
isList: true,
},
{
linkable: BrandModule.linkable.brand.id,
filterable: ["id", "name"],
isList: false,
}
)
: {}
export default link

View File

@@ -0,0 +1,8 @@
import { Module } from "@medusajs/utils"
import { BrandModuleService } from "./service"
export const BRAND_MODULE = "brand"
export default Module(BRAND_MODULE, {
service: BrandModuleService,
})

View File

@@ -0,0 +1,16 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20250805184935 extends Migration {
override async up(): Promise<void> {
this.addSql(
`create table if not exists "brand" ("id" text not null, "name" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "brand_pkey" primary key ("id"));`
)
this.addSql(
`CREATE INDEX IF NOT EXISTS "IDX_brand_deleted_at" ON "brand" (deleted_at) WHERE deleted_at IS NULL;`
)
}
override async down(): Promise<void> {
this.addSql(`drop table if exists "brand" cascade;`)
}
}

View File

@@ -0,0 +1,6 @@
import { model } from "@medusajs/utils"
export const Brand = model.define("brand", {
id: model.id({ prefix: "brand" }).primaryKey(),
name: model.text(),
})

View File

@@ -0,0 +1,6 @@
import { MedusaService } from "@medusajs/utils"
import { Brand } from "./models/brand"
export class BrandModuleService extends MedusaService({
Brand,
}) {}

View File

@@ -1206,7 +1206,12 @@ function buildSchemaFromFilterableLinks(
}) })
.join("\n") .join("\n")
return `extend type ${entity} ${events} { return `
type ${entity} ${events} {
id: ID!
}
extend type ${entity} {
${fieldDefinitions} ${fieldDefinitions}
}` }`
}) })

View File

@@ -9,6 +9,9 @@ import { Knex } from "@mikro-orm/knex"
import { OrderBy, QueryFormat, QueryOptions, Select } from "@types" import { OrderBy, QueryFormat, QueryOptions, Select } from "@types"
import { getPivotTableName, normalizeTableName } from "./normalze-table-name" import { getPivotTableName, normalizeTableName } from "./normalze-table-name"
const AND_OPERATOR = "$and"
const OR_OPERATOR = "$or"
function escapeJsonPathString(val: string): string { function escapeJsonPathString(val: string): string {
// Escape for JSONPath string // Escape for JSONPath string
return val.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/'/g, "\\'") return val.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/'/g, "\\'")
@@ -102,7 +105,25 @@ export class QueryBuilder {
} }
private getStructureKeys(structure) { private getStructureKeys(structure) {
return Object.keys(structure ?? {}).filter((key) => key !== "entity") const collectKeys = (obj: any, keys = new Set<string>()) => {
if (!isObject(obj)) {
return keys
}
Object.keys(obj).forEach((key) => {
if (key === AND_OPERATOR || key === OR_OPERATOR) {
if (Array.isArray(obj[key])) {
obj[key].forEach((item) => collectKeys(item, keys))
}
} else if (key !== "entity") {
keys.add(key)
}
})
return keys
}
return [...collectKeys(structure ?? {})]
} }
private getEntity( private getEntity(
@@ -123,6 +144,10 @@ export class QueryBuilder {
} }
private getGraphQLType(path, field) { private getGraphQLType(path, field) {
if (field === AND_OPERATOR || field === OR_OPERATOR) {
return "JSON"
}
const entity = this.getEntity(path)?.ref?.entity! const entity = this.getEntity(path)?.ref?.entity!
const fieldRef = this.entityMap[entity]._fields[field] const fieldRef = this.entityMap[entity]._fields[field]
@@ -209,12 +234,14 @@ export class QueryBuilder {
private parseWhere( private parseWhere(
aliasMapping: { [path: string]: string }, aliasMapping: { [path: string]: string },
obj: object, obj: object,
builder: Knex.QueryBuilder builder: Knex.QueryBuilder,
parentPath: string = ""
) { ) {
const keys = Object.keys(obj) const keys = Object.keys(obj)
const getPathAndField = (key: string) => { const getPathAndField = (key: string) => {
const path = key.split(".") const fullKey = parentPath ? `${parentPath}.${key}` : key
const path = fullKey.split(".")
const field = [path.pop()] const field = [path.pop()]
while (!aliasMapping[path.join(".")] && path.length > 0) { while (!aliasMapping[path.join(".")] && path.length > 0) {
@@ -241,34 +268,65 @@ export class QueryBuilder {
} }
keys.forEach((key) => { keys.forEach((key) => {
const pathAsArray = (parentPath ? `${parentPath}.${key}` : key).split(".")
const fieldOrLogicalOperator = pathAsArray.pop()
let value = obj[key] let value = obj[key]
if ((key === "$and" || key === "$or") && !Array.isArray(value)) { if (
(fieldOrLogicalOperator === AND_OPERATOR ||
fieldOrLogicalOperator === OR_OPERATOR) &&
!Array.isArray(value)
) {
value = [value] value = [value]
} }
if (key === "$and" && Array.isArray(value)) { if (fieldOrLogicalOperator === AND_OPERATOR && Array.isArray(value)) {
builder.where((qb) => { builder.where((qb) => {
value.forEach((cond) => { value.forEach((cond) => {
qb.andWhere((subBuilder) => qb.andWhere((subBuilder) =>
this.parseWhere(aliasMapping, cond, subBuilder) this.parseWhere(
aliasMapping,
cond,
subBuilder,
pathAsArray.join(".")
)
) )
}) })
}) })
} else if (key === "$or" && Array.isArray(value)) { } else if (
fieldOrLogicalOperator === OR_OPERATOR &&
Array.isArray(value)
) {
builder.where((qb) => { builder.where((qb) => {
value.forEach((cond) => { value.forEach((cond) => {
qb.orWhere((subBuilder) => qb.orWhere((subBuilder) =>
this.parseWhere(aliasMapping, cond, subBuilder) this.parseWhere(
aliasMapping,
cond,
subBuilder,
pathAsArray.join(".")
)
) )
}) })
}) })
} else if (isObject(value) && !Array.isArray(value)) { } else if (
isObject(value) &&
!Array.isArray(value) &&
fieldOrLogicalOperator !== AND_OPERATOR &&
fieldOrLogicalOperator !== OR_OPERATOR
) {
const currentPath = parentPath ? `${parentPath}.${key}` : key
const subKeys = Object.keys(value)
const hasOperators = subKeys.some((subKey) => OPERATOR_MAP[subKey])
if (hasOperators) {
const { field, attr } = getPathAndField(key)
const subKeys = Object.keys(value) const subKeys = Object.keys(value)
subKeys.forEach((subKey) => { subKeys.forEach((subKey) => {
let operator = OPERATOR_MAP[subKey] let operator = OPERATOR_MAP[subKey]
if (operator) { if (operator) {
const { field, attr } = getPathAndField(key)
const nested = new Array(field.length).join("->?") const nested = new Array(field.length).join("->?")
const subValue = this.transformValueToType( const subValue = this.transformValueToType(
@@ -329,6 +387,7 @@ export class QueryBuilder {
} else { } else {
const potentialIdFields = field[field.length - 1] const potentialIdFields = field[field.length - 1]
const hasId = potentialIdFields === "id" const hasId = potentialIdFields === "id"
if (hasId) { if (hasId) {
builder.whereRaw(`(${aliasMapping[attr]}.id) ${operator} ?`, [ builder.whereRaw(`(${aliasMapping[attr]}.id) ${operator} ?`, [
...val, ...val,
@@ -341,6 +400,7 @@ export class QueryBuilder {
operator, operator,
val[0] val[0]
) )
builder.whereRaw(`${aliasMapping[attr]}.data${nested} @@ ?`, [ builder.whereRaw(`${aliasMapping[attr]}.data${nested} @@ ?`, [
jsonPath, jsonPath,
]) ])
@@ -350,6 +410,9 @@ export class QueryBuilder {
throw new Error(`Unsupported operator: ${subKey}`) throw new Error(`Unsupported operator: ${subKey}`)
} }
}) })
} else {
this.parseWhere(aliasMapping, value, builder, currentPath)
}
} else { } else {
const { field, attr } = getPathAndField(key) const { field, attr } = getPathAndField(key)
const nested = new Array(field.length).join("->?") const nested = new Array(field.length).join("->?")
@@ -667,7 +730,6 @@ export class QueryBuilder {
selectParts[currentAliasPath + ".id"] = `${alias}.id` selectParts[currentAliasPath + ".id"] = `${alias}.id`
const children = this.getStructureKeys(structure) const children = this.getStructureKeys(structure)
for (const child of children) { for (const child of children) {
const childStructure = structure[child] as Select const childStructure = structure[child] as Select
@@ -859,6 +921,7 @@ export class QueryBuilder {
) )
}), }),
] ]
innerQueryBuilder.whereRaw( innerQueryBuilder.whereRaw(
`(${searchWhereParts.join(" OR ")})`, `(${searchWhereParts.join(" OR ")})`,
Array(searchWhereParts.length).fill(textSearchQuery) Array(searchWhereParts.length).fill(textSearchQuery)

View File

@@ -224,6 +224,7 @@ export default class LinkModuleService implements ILinkModule {
} }
@InjectTransactionManager() @InjectTransactionManager()
@EmitEvents()
async dismiss( async dismiss(
primaryKeyOrBulkData: string | string[] | [string | string[], string][], primaryKeyOrBulkData: string | string[] | [string | string[], string][],
foreignKeyData?: string, foreignKeyData?: string,
@@ -245,6 +246,16 @@ export default class LinkModuleService implements ILinkModule {
const links = await this.linkService_.dismiss(data, sharedContext) const links = await this.linkService_.dismiss(data, sharedContext)
moduleEventBuilderFactory({
action: CommonEvents.DETACHED,
object: this.entityName_,
source: this.serviceName_,
eventName: this.entityName_ + "." + CommonEvents.DETACHED,
})({
data: links.map((link) => link.id),
sharedContext,
})
return (await this.baseRepository_.serialize(links)) as unknown[] return (await this.baseRepository_.serialize(links)) as unknown[]
} }