diff --git a/.changeset/eleven-apricots-look.md b/.changeset/eleven-apricots-look.md new file mode 100644 index 0000000000..cfc7562d67 --- /dev/null +++ b/.changeset/eleven-apricots-look.md @@ -0,0 +1,6 @@ +--- +"@medusajs/index": patch +"@medusajs/link-modules": patch +--- + +fix(index): handle $and and $or operators diff --git a/integration-tests/modules/__tests__/index/query-index.spec.ts b/integration-tests/modules/__tests__/index/query-index.spec.ts index a55d9db35d..8f2ebb8861 100644 --- a/integration-tests/modules/__tests__/index/query-index.spec.ts +++ b/integration-tests/modules/__tests__/index/query-index.spec.ts @@ -6,6 +6,7 @@ import { ContainerRegistrationKeys, defaultCurrencies, defineLink, + Modules, } from "@medusajs/utils" import { setTimeout } from "timers/promises" import { @@ -35,6 +36,7 @@ async function populateData(api: any) { origin_country: "USA", shipping_profile_id: shippingProfile.id, options: [{ title: "Denominations", values: ["100"] }], + material: "test-material", variants: [ { title: `Test variant 1`, @@ -61,6 +63,7 @@ async function populateData(api: any) { status: "published", shipping_profile_id: shippingProfile.id, options: [{ title: "Colors", values: ["Red"] }], + material: "extra-material", variants: new Array(2).fill(0).map((_, i) => ({ title: `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) + + return products } 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 () => { - 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( ContainerRegistrationKeys.QUERY @@ -132,6 +157,8 @@ medusaIntegrationTestRunner({ "description", "status", "title", + "brand.name", + "brand.id", "variants.sku", "variants.barcode", "variants.material", @@ -142,8 +169,28 @@ medusaIntegrationTestRunner({ "variants.inventory_items.inventory.description", ], filters: { - "variants.sku": { $like: "%-1" }, - "variants.prices.amount": { $gt: 30 }, + $and: [ + { status: "published" }, + { material: { $ilike: "%material%" } }, + { + $or: [ + { + brand: { + name: { $ilike: "%brand" }, + }, + }, + { title: { $ilike: "%duct%" } }, + ], + }, + { + variants: { + $and: [ + { sku: { $like: "%-1" } }, + { "prices.amount": { $gt: 30 } }, + ], + }, + }, + ], }, pagination: { take: 10, @@ -171,6 +218,10 @@ medusaIntegrationTestRunner({ description: "extra description", title: "Extra product", status: "published", + brand: { + id: expect.any(String), + name: "Medusa Brand", + }, variants: [ { sku: "extra-variant-0", @@ -247,6 +298,7 @@ medusaIntegrationTestRunner({ description: "test-product-description", title: "Test Product", status: "published", + brand: undefined, variants: [ { sku: "test-variant-1", diff --git a/integration-tests/modules/medusa-config.js b/integration-tests/modules/medusa-config.ts similarity index 59% rename from integration-tests/modules/medusa-config.js rename to integration-tests/modules/medusa-config.ts index 20a52e3eea..17a34203f5 100644 --- a/integration-tests/modules/medusa-config.js +++ b/integration-tests/modules/medusa-config.ts @@ -1,3 +1,5 @@ +import { defineConfig } from "@medusajs/utils" + const { Modules } = require("@medusajs/utils") const DB_HOST = process.env.DB_HOST @@ -35,7 +37,7 @@ const customFulfillmentProviderCalculated = { id: "test-provider-calculated", } -module.exports = { +module.exports = defineConfig({ admin: { disable: true, }, @@ -51,11 +53,13 @@ module.exports = { featureFlags: { medusa_v2: enableMedusaV2, }, - modules: { - testingModule: { + modules: [ + { + key: "testingModule", resolve: "__tests__/__fixtures__/testing-module", }, - [Modules.AUTH]: { + { + key: "auth", resolve: "@medusajs/auth", options: { providers: [ @@ -66,53 +70,98 @@ module.exports = { ], }, }, - [Modules.USER]: { + { + key: Modules.USER, scope: "internal", resolve: "@medusajs/user", options: { jwt_secret: "test", }, }, - [Modules.CACHE]: { + { + key: Modules.CACHE, resolve: "@medusajs/cache-inmemory", 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", options: {}, }, - [Modules.INVENTORY]: { + { + key: Modules.INVENTORY, resolve: "@medusajs/inventory", options: {}, }, - [Modules.PRODUCT]: true, - [Modules.PRICING]: true, - [Modules.PROMOTION]: true, - [Modules.REGION]: true, - [Modules.CUSTOMER]: true, - [Modules.SALES_CHANNEL]: true, - [Modules.CART]: true, - [Modules.WORKFLOW_ENGINE]: true, - [Modules.API_KEY]: true, - [Modules.STORE]: true, - [Modules.TAX]: { + { + key: Modules.PRODUCT, + resolve: "@medusajs/product", + }, + { + key: Modules.PRICING, + resolve: "@medusajs/pricing", + }, + { + key: Modules.PROMOTION, + 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", options: { providers: [customTaxProviderRegistration], }, }, - [Modules.CURRENCY]: true, - [Modules.ORDER]: true, - [Modules.PAYMENT]: { + { + key: Modules.CURRENCY, + resolve: "@medusajs/currency", + }, + { + key: Modules.ORDER, + resolve: "@medusajs/order", + }, + { + key: Modules.PAYMENT, resolve: "@medusajs/payment", - /** @type {import('@medusajs/payment').PaymentModuleOptions}*/ options: { providers: [customPaymentProvider], }, }, - [Modules.FULFILLMENT]: { - /** @type {import('@medusajs/fulfillment').FulfillmentModuleOptions} */ + { + key: Modules.FULFILLMENT, + resolve: "@medusajs/fulfillment", options: { providers: [ customFulfillmentProvider, @@ -120,8 +169,8 @@ module.exports = { ], }, }, - [Modules.NOTIFICATION]: { - /** @type {import('@medusajs/types').LocalNotificationServiceOptions} */ + { + key: Modules.NOTIFICATION, options: { providers: [ { @@ -135,10 +184,15 @@ module.exports = { ], }, }, - [Modules.INDEX]: process.env.ENABLE_INDEX_MODULE - ? { - resolve: "@medusajs/index", - } - : false, - }, -} + { + key: Modules.INDEX, + resolve: "@medusajs/index", + disable: process.env.ENABLE_INDEX_MODULE !== "true", + }, + { + key: "brand", + resolve: "src/modules/brand", + disable: process.env.ENABLE_INDEX_MODULE !== "true", + }, + ], +}) diff --git a/integration-tests/modules/src/links/product-brand.ts b/integration-tests/modules/src/links/product-brand.ts new file mode 100644 index 0000000000..29a76062eb --- /dev/null +++ b/integration-tests/modules/src/links/product-brand.ts @@ -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 diff --git a/integration-tests/modules/src/modules/brand/index.ts b/integration-tests/modules/src/modules/brand/index.ts new file mode 100644 index 0000000000..3aa6d33015 --- /dev/null +++ b/integration-tests/modules/src/modules/brand/index.ts @@ -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, +}) diff --git a/integration-tests/modules/src/modules/brand/migrations/Migration20250805184935.ts b/integration-tests/modules/src/modules/brand/migrations/Migration20250805184935.ts new file mode 100644 index 0000000000..3ee115b029 --- /dev/null +++ b/integration-tests/modules/src/modules/brand/migrations/Migration20250805184935.ts @@ -0,0 +1,16 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20250805184935 extends Migration { + override async up(): Promise { + 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 { + this.addSql(`drop table if exists "brand" cascade;`) + } +} diff --git a/integration-tests/modules/src/modules/brand/models/brand.ts b/integration-tests/modules/src/modules/brand/models/brand.ts new file mode 100644 index 0000000000..6c3795fb0a --- /dev/null +++ b/integration-tests/modules/src/modules/brand/models/brand.ts @@ -0,0 +1,6 @@ +import { model } from "@medusajs/utils" + +export const Brand = model.define("brand", { + id: model.id({ prefix: "brand" }).primaryKey(), + name: model.text(), +}) diff --git a/integration-tests/modules/src/modules/brand/service.ts b/integration-tests/modules/src/modules/brand/service.ts new file mode 100644 index 0000000000..83434782ab --- /dev/null +++ b/integration-tests/modules/src/modules/brand/service.ts @@ -0,0 +1,6 @@ +import { MedusaService } from "@medusajs/utils" +import { Brand } from "./models/brand" + +export class BrandModuleService extends MedusaService({ + Brand, +}) {} diff --git a/packages/modules/index/src/utils/build-config.ts b/packages/modules/index/src/utils/build-config.ts index a761177406..2ad8cd861f 100644 --- a/packages/modules/index/src/utils/build-config.ts +++ b/packages/modules/index/src/utils/build-config.ts @@ -1206,7 +1206,12 @@ function buildSchemaFromFilterableLinks( }) .join("\n") - return `extend type ${entity} ${events} { + return ` + type ${entity} ${events} { + id: ID! + } + + extend type ${entity} { ${fieldDefinitions} }` }) diff --git a/packages/modules/index/src/utils/query-builder.ts b/packages/modules/index/src/utils/query-builder.ts index fd94fa83b1..d7a9a770ce 100644 --- a/packages/modules/index/src/utils/query-builder.ts +++ b/packages/modules/index/src/utils/query-builder.ts @@ -9,6 +9,9 @@ import { Knex } from "@mikro-orm/knex" import { OrderBy, QueryFormat, QueryOptions, Select } from "@types" import { getPivotTableName, normalizeTableName } from "./normalze-table-name" +const AND_OPERATOR = "$and" +const OR_OPERATOR = "$or" + function escapeJsonPathString(val: string): string { // Escape for JSONPath string return val.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/'/g, "\\'") @@ -102,7 +105,25 @@ export class QueryBuilder { } private getStructureKeys(structure) { - return Object.keys(structure ?? {}).filter((key) => key !== "entity") + const collectKeys = (obj: any, keys = new Set()) => { + 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( @@ -123,6 +144,10 @@ export class QueryBuilder { } private getGraphQLType(path, field) { + if (field === AND_OPERATOR || field === OR_OPERATOR) { + return "JSON" + } + const entity = this.getEntity(path)?.ref?.entity! const fieldRef = this.entityMap[entity]._fields[field] @@ -209,12 +234,14 @@ export class QueryBuilder { private parseWhere( aliasMapping: { [path: string]: string }, obj: object, - builder: Knex.QueryBuilder + builder: Knex.QueryBuilder, + parentPath: string = "" ) { const keys = Object.keys(obj) const getPathAndField = (key: string) => { - const path = key.split(".") + const fullKey = parentPath ? `${parentPath}.${key}` : key + const path = fullKey.split(".") const field = [path.pop()] while (!aliasMapping[path.join(".")] && path.length > 0) { @@ -241,115 +268,151 @@ export class QueryBuilder { } keys.forEach((key) => { + const pathAsArray = (parentPath ? `${parentPath}.${key}` : key).split(".") + const fieldOrLogicalOperator = pathAsArray.pop() let value = obj[key] - if ((key === "$and" || key === "$or") && !Array.isArray(value)) { + if ( + (fieldOrLogicalOperator === AND_OPERATOR || + fieldOrLogicalOperator === OR_OPERATOR) && + !Array.isArray(value) + ) { value = [value] } - if (key === "$and" && Array.isArray(value)) { + if (fieldOrLogicalOperator === AND_OPERATOR && Array.isArray(value)) { builder.where((qb) => { value.forEach((cond) => { 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) => { value.forEach((cond) => { 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) - subKeys.forEach((subKey) => { - let operator = OPERATOR_MAP[subKey] - if (operator) { - const { field, attr } = getPathAndField(key) - const nested = new Array(field.length).join("->?") + const hasOperators = subKeys.some((subKey) => OPERATOR_MAP[subKey]) - const subValue = this.transformValueToType( - attr, - field, - value[subKey] - ) + if (hasOperators) { + const { field, attr } = getPathAndField(key) - let val = operator === "IN" ? subValue : [subValue] - if (operator === "=" && subValue === null) { - operator = "IS" - } else if (operator === "!=" && subValue === null) { - operator = "IS NOT" - } + const subKeys = Object.keys(value) + subKeys.forEach((subKey) => { + let operator = OPERATOR_MAP[subKey] + if (operator) { + const nested = new Array(field.length).join("->?") - if (operator === "=") { - const hasId = field[field.length - 1] === "id" - if (hasId) { - builder.whereRaw(`${aliasMapping[attr]}.id = ?`, subValue) + const subValue = this.transformValueToType( + attr, + field, + value[subKey] + ) + + let val = operator === "IN" ? subValue : [subValue] + if (operator === "=" && subValue === null) { + operator = "IS" + } else if (operator === "!=" && subValue === null) { + operator = "IS NOT" + } + + if (operator === "=") { + const hasId = field[field.length - 1] === "id" + if (hasId) { + builder.whereRaw(`${aliasMapping[attr]}.id = ?`, subValue) + } else { + builder.whereRaw( + `${aliasMapping[attr]}.data @> '${getPathOperation( + attr, + field as string[], + subValue + )}'::jsonb` + ) + } + } else if (operator === "IN") { + if (val && !Array.isArray(val)) { + val = [val] + } + if (!val || val.length === 0) { + return + } + + const inPlaceholders = val.map(() => "?").join(",") + const hasId = field[field.length - 1] === "id" + if (hasId) { + builder.whereRaw( + `${aliasMapping[attr]}.id IN (${inPlaceholders})`, + val + ) + } else { + const targetField = field[field.length - 1] as string + + const jsonbValues = val.map((item) => + JSON.stringify({ + [targetField]: item === null ? null : item, + }) + ) + + builder.whereRaw( + `${aliasMapping[attr]}.data${nested} @> ANY(ARRAY[${inPlaceholders}]::JSONB[])`, + jsonbValues + ) + } } else { - builder.whereRaw( - `${aliasMapping[attr]}.data @> '${getPathOperation( - attr, - field as string[], - subValue - )}'::jsonb` - ) - } - } else if (operator === "IN") { - if (val && !Array.isArray(val)) { - val = [val] - } - if (!val || val.length === 0) { - return - } + const potentialIdFields = field[field.length - 1] + const hasId = potentialIdFields === "id" - const inPlaceholders = val.map(() => "?").join(",") - const hasId = field[field.length - 1] === "id" - if (hasId) { - builder.whereRaw( - `${aliasMapping[attr]}.id IN (${inPlaceholders})`, - val - ) - } else { - const targetField = field[field.length - 1] as string + if (hasId) { + builder.whereRaw(`(${aliasMapping[attr]}.id) ${operator} ?`, [ + ...val, + ]) + } else { + const targetField = field[field.length - 1] as string - const jsonbValues = val.map((item) => - JSON.stringify({ - [targetField]: item === null ? null : item, - }) - ) + const jsonPath = buildSafeJsonPathQuery( + targetField, + operator, + val[0] + ) - builder.whereRaw( - `${aliasMapping[attr]}.data${nested} @> ANY(ARRAY[${inPlaceholders}]::JSONB[])`, - jsonbValues - ) + builder.whereRaw(`${aliasMapping[attr]}.data${nested} @@ ?`, [ + jsonPath, + ]) + } } } else { - const potentialIdFields = field[field.length - 1] - const hasId = potentialIdFields === "id" - if (hasId) { - builder.whereRaw(`(${aliasMapping[attr]}.id) ${operator} ?`, [ - ...val, - ]) - } else { - const targetField = field[field.length - 1] as string - - const jsonPath = buildSafeJsonPathQuery( - targetField, - operator, - val[0] - ) - builder.whereRaw(`${aliasMapping[attr]}.data${nested} @@ ?`, [ - jsonPath, - ]) - } + throw new Error(`Unsupported operator: ${subKey}`) } - } else { - throw new Error(`Unsupported operator: ${subKey}`) - } - }) + }) + } else { + this.parseWhere(aliasMapping, value, builder, currentPath) + } } else { const { field, attr } = getPathAndField(key) const nested = new Array(field.length).join("->?") @@ -667,7 +730,6 @@ export class QueryBuilder { selectParts[currentAliasPath + ".id"] = `${alias}.id` const children = this.getStructureKeys(structure) - for (const child of children) { const childStructure = structure[child] as Select @@ -859,6 +921,7 @@ export class QueryBuilder { ) }), ] + innerQueryBuilder.whereRaw( `(${searchWhereParts.join(" OR ")})`, Array(searchWhereParts.length).fill(textSearchQuery) diff --git a/packages/modules/link-modules/src/services/link-module-service.ts b/packages/modules/link-modules/src/services/link-module-service.ts index b61e56f58f..e9bf03127c 100644 --- a/packages/modules/link-modules/src/services/link-module-service.ts +++ b/packages/modules/link-modules/src/services/link-module-service.ts @@ -224,6 +224,7 @@ export default class LinkModuleService implements ILinkModule { } @InjectTransactionManager() + @EmitEvents() async dismiss( primaryKeyOrBulkData: string | string[] | [string | string[], string][], foreignKeyData?: string, @@ -245,6 +246,16 @@ export default class LinkModuleService implements ILinkModule { 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[] }