feat(index): $nin and $not operators (#13289)

* feat(index): add $not and $nin operators

* logical operator

* test

* test

* types

* logical

* schema ID

* types and $ilike fix

* index type
This commit is contained in:
Carlos R. L. Rodrigues authored and GitHub committed 2025-08-28 11:56:17 +02:00
1 parent b3fb0f7634
commit f764b3a364
8 files changed
+264 -20

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@medusajs/index": patch
"@medusajs/types": patch
---
feat(index): $nin and $not operators
@@ -160,14 +160,14 @@ medusaIntegrationTestRunner({
...(indexEngine as any).moduleOptions_, ...(indexEngine as any).moduleOptions_,
schema: ` schema: `
type Product @Listeners(values: ["product.created", "product.updated", "product.deleted"]) { type Product @Listeners(values: ["product.created", "product.updated", "product.deleted"]) {
id: String id: ID
title: String title: String
handle: String handle: String
variants: [ProductVariant] variants: [ProductVariant]
} }
type ProductVariant @Listeners(values: ["variant.created", "variant.updated", "variant.deleted"]) { type ProductVariant @Listeners(values: ["variant.created", "variant.updated", "variant.deleted"]) {
id: String id: ID
product_id: String product_id: String
sku: String sku: String
description: String description: String
@@ -5,8 +5,12 @@ export type IndexOperatorMap<T> = {
$gt?: T $gt?: T
$gte?: T $gte?: T
$ne?: T $ne?: T
$in?: T $in?: T[]
$nin?: T[]
$is?: T $is?: T
$like?: T $like?: T
$ilike?: T $ilike?: T
$and?: T[]
$or?: T[]
$not?: T | T[]
} }
@@ -1,6 +1,6 @@
export const schema = ` export const schema = `
type Product @Listeners(values: ["product.created", "product.updated", "product.deleted"]) { type Product @Listeners(values: ["product.created", "product.updated", "product.deleted"]) {
id: String id: ID
title: String title: String
created_at: DateTime created_at: DateTime
@@ -18,7 +18,7 @@ export const schema = `
} }
type ProductVariant @Listeners(values: ["variant.created", "variant.updated", "variant.deleted"]) { type ProductVariant @Listeners(values: ["variant.created", "variant.updated", "variant.deleted"]) {
id: String id: ID
product_id: String product_id: String
sku: String sku: String
prices: [Price] prices: [Price]
@@ -1,13 +1,13 @@
export const updateRemovedSchema = ` export const updateRemovedSchema = `
type Product @Listeners(values: ["product.created", "product.updated", "product.deleted"]) { type Product @Listeners(values: ["product.created", "product.updated", "product.deleted"]) {
id: String id: ID
title: String title: String
handle: String handle: String
variants: [ProductVariant] variants: [ProductVariant]
} }
type ProductVariant @Listeners(values: ["variant.created", "variant.updated", "variant.deleted"]) { type ProductVariant @Listeners(values: ["variant.created", "variant.updated", "variant.deleted"]) {
id: String id: ID
product_id: String product_id: String
sku: String sku: String
description: String description: String
@@ -1,6 +1,6 @@
export const updatedSchema = ` export const updatedSchema = `
type Product @Listeners(values: ["product.created", "product.updated", "product.deleted"]) { type Product @Listeners(values: ["product.created", "product.updated", "product.deleted"]) {
id: String id: ID
title: String title: String
handle: String handle: String
deep: InternalNested deep: InternalNested
@@ -17,7 +17,7 @@ export const updatedSchema = `
} }
type ProductVariant @Listeners(values: ["variant.created", "variant.updated", "variant.deleted"]) { type ProductVariant @Listeners(values: ["variant.created", "variant.updated", "variant.deleted"]) {
id: String id: ID
product_id: String product_id: String
sku: String sku: String
prices: [Price] prices: [Price]
@@ -135,6 +135,7 @@ describe("IndexModuleService query", function () {
name: "Product", name: "Product",
data: { data: {
id: "prod_1", id: "prod_1",
title: "Product 1",
}, },
}, },
{ {
@@ -296,7 +297,24 @@ describe("IndexModuleService query", function () {
}, },
}) })
const { data: dataNot } = await module.query({
fields: ["product.*", "product.variants.*", "product.variants.prices.*"],
filters: {
product: {
variants: {
sku: {
$not: {
$eq: null,
},
},
},
},
},
})
expect(data.length).toEqual(1) expect(data.length).toEqual(1)
expect(dataNot.length).toEqual(1)
expect(dataNot).toEqual(data)
const { data: data2 } = await module.query({ const { data: data2 } = await module.query({
fields: ["product.*", "product.variants.*", "product.variants.prices.*"], fields: ["product.*", "product.variants.*", "product.variants.prices.*"],
@@ -340,6 +358,7 @@ describe("IndexModuleService query", function () {
}, },
{ {
id: "prod_1", id: "prod_1",
title: "Product 1",
variants: [ variants: [
{ {
id: "var_2", id: "var_2",
@@ -399,6 +418,7 @@ describe("IndexModuleService query", function () {
}, },
{ {
id: "prod_1", id: "prod_1",
title: "Product 1",
variants: [ variants: [
{ {
id: "var_2", id: "var_2",
@@ -456,6 +476,7 @@ describe("IndexModuleService query", function () {
}, },
{ {
id: "prod_1", id: "prod_1",
title: "Product 1",
variants: [ variants: [
{ {
id: "var_1", id: "var_1",
@@ -499,6 +520,7 @@ describe("IndexModuleService query", function () {
expect(dataAsc).toEqual([ expect(dataAsc).toEqual([
{ {
id: "prod_1", id: "prod_1",
title: "Product 1",
variants: [ variants: [
{ {
id: "var_2", id: "var_2",
@@ -597,6 +619,7 @@ describe("IndexModuleService query", function () {
expect(data).toEqual([ expect(data).toEqual([
{ {
id: "prod_1", id: "prod_1",
title: "Product 1",
variants: [ variants: [
{ {
id: "var_1", id: "var_1",
@@ -646,6 +669,7 @@ describe("IndexModuleService query", function () {
expect(data).toEqual([ expect(data).toEqual([
{ {
id: "prod_1", id: "prod_1",
title: "Product 1",
variants: [ variants: [
{ {
id: "var_1", id: "var_1",
@@ -782,6 +806,7 @@ describe("IndexModuleService query", function () {
expect(data).toEqual([ expect(data).toEqual([
{ {
id: "prod_1", id: "prod_1",
title: "Product 1",
variants: [ variants: [
{ {
id: "var_1", id: "var_1",
@@ -922,6 +947,7 @@ describe("IndexModuleService query", function () {
expect(data).toEqual([ expect(data).toEqual([
{ {
id: "prod_1", id: "prod_1",
title: "Product 1",
variants: [ variants: [
{ {
id: "var_1", id: "var_1",
@@ -937,4 +963,165 @@ describe("IndexModuleService query", function () {
}, },
]) ])
}) })
it("should query products filtering product not in [X]", async () => {
const expected = [
{
id: "prod_2",
title: "Product 2 title",
deep: {
a: 1,
obj: {
b: 15,
},
},
},
]
const { data } = await module.query({
fields: ["product.*"],
filters: {
product: {
$not: [
{
id: {
$in: ["prod_1"],
},
},
],
},
},
})
expect(data).toEqual(expected)
})
it("should query products filtering product not in [X] using $nin", async () => {
const expected = [
{
id: "prod_2",
title: "Product 2 title",
deep: {
a: 1,
obj: {
b: 15,
},
},
},
]
const { data } = await module.query({
fields: ["product.*"],
filters: {
product: {
id: {
$nin: ["prod_1"],
},
},
},
})
expect(data).toEqual(expected)
})
it("should query products with variants.sku not in [X] and title eq", async () => {
const expected = [
{
id: "prod_2",
title: "Product 2 title",
deep: {
a: 1,
obj: {
b: 15,
},
},
},
]
const { data } = await module.query({
fields: ["product.*", "variants.*"],
filters: {
product: {
variants: {
sku: {
$nin: ["sku 123"],
},
},
title: {
$eq: "Product 2 title",
},
},
},
})
expect(data).toEqual(expected)
})
it("should query products filtering title like and not equal specific value", async () => {
const expected = [
{
id: "prod_2",
title: "Product 2 title",
deep: {
a: 1,
obj: {
b: 15,
},
},
},
]
const { data } = await module.query({
fields: ["product.*"],
filters: {
product: {
$and: [
{
title: {
$like: "Product%",
},
},
{
$not: {
title: {
$eq: "Product 1",
},
},
},
],
},
},
})
expect(data).toEqual(expected)
})
it("should query products filtering title using $ilike", async () => {
const expected = [
{
id: "prod_2",
title: "Product 2 title",
},
]
const { data } = await module.query({
fields: ["product.id", "product.title"],
filters: {
product: {
title: {
$ilike: "PROdUCt 2%",
},
},
},
})
expect(data).toEqual(expected)
const { data: sensitive } = await module.query({
fields: ["product.id", "product.title"],
filters: {
product: {
title: {
$like: "PROdUCt 2%",
},
},
},
})
expect(sensitive).toEqual([])
})
}) })
@@ -11,6 +11,7 @@ import { getPivotTableName, normalizeTableName } from "./normalze-table-name"
const AND_OPERATOR = "$and" const AND_OPERATOR = "$and"
const OR_OPERATOR = "$or" const OR_OPERATOR = "$or"
const NOT_OPERATOR = "$not"
function escapeJsonPathString(val: string): string { function escapeJsonPathString(val: string): string {
// Escape for JSONPath string // Escape for JSONPath string
@@ -23,10 +24,15 @@ function buildSafeJsonPathQuery(
value: any value: any
): string { ): string {
let jsonPathOperator = operator let jsonPathOperator = operator
let caseInsensitiveFlag = ""
if (operator === "=") { if (operator === "=") {
jsonPathOperator = "==" jsonPathOperator = "=="
} else if (operator.toUpperCase().includes("LIKE")) { } else if (operator.toUpperCase().includes("LIKE")) {
jsonPathOperator = "like_regex" jsonPathOperator = "like_regex"
if (operator.toUpperCase() === "ILIKE") {
caseInsensitiveFlag = ' flag "i"'
}
} else if (operator === "IS") { } else if (operator === "IS") {
jsonPathOperator = "==" jsonPathOperator = "=="
} else if (operator === "IS NOT") { } else if (operator === "IS NOT") {
@@ -46,7 +52,7 @@ function buildSafeJsonPathQuery(
} }
} }
return `$.${field} ${jsonPathOperator} ${value}` return `$.${field} ${jsonPathOperator} ${value}${caseInsensitiveFlag}`
} }
export const OPERATOR_MAP = { export const OPERATOR_MAP = {
@@ -57,6 +63,7 @@ export const OPERATOR_MAP = {
$gte: ">=", $gte: ">=",
$ne: "!=", $ne: "!=",
$in: "IN", $in: "IN",
$nin: "NOT IN",
$is: "IS", $is: "IS",
$like: "LIKE", $like: "LIKE",
$ilike: "ILIKE", $ilike: "ILIKE",
@@ -104,6 +111,10 @@ export class QueryBuilder {
this.idsOnly = args.idsOnly ?? false this.idsOnly = args.idsOnly ?? false
} }
private isLogicalOperator(key: string) {
return key === AND_OPERATOR || key === OR_OPERATOR || key === NOT_OPERATOR
}
private getStructureKeys(structure) { private getStructureKeys(structure) {
const collectKeys = (obj: any, keys = new Set<string>()) => { const collectKeys = (obj: any, keys = new Set<string>()) => {
if (!isObject(obj)) { if (!isObject(obj)) {
@@ -111,7 +122,7 @@ export class QueryBuilder {
} }
Object.keys(obj).forEach((key) => { Object.keys(obj).forEach((key) => {
if (key === AND_OPERATOR || key === OR_OPERATOR) { if (this.isLogicalOperator(key)) {
if (Array.isArray(obj[key])) { if (Array.isArray(obj[key])) {
obj[key].forEach((item) => collectKeys(item, keys)) obj[key].forEach((item) => collectKeys(item, keys))
} }
@@ -144,7 +155,7 @@ export class QueryBuilder {
} }
private getGraphQLType(path, field) { private getGraphQLType(path, field) {
if (field === AND_OPERATOR || field === OR_OPERATOR) { if (this.isLogicalOperator(field)) {
return "JSON" return "JSON"
} }
@@ -269,12 +280,11 @@ export class QueryBuilder {
keys.forEach((key) => { keys.forEach((key) => {
const pathAsArray = (parentPath ? `${parentPath}.${key}` : key).split(".") const pathAsArray = (parentPath ? `${parentPath}.${key}` : key).split(".")
const fieldOrLogicalOperator = pathAsArray.pop() const fieldOrLogicalOperator = pathAsArray.pop()!
let value = obj[key] let value = obj[key]
if ( if (
(fieldOrLogicalOperator === AND_OPERATOR || this.isLogicalOperator(fieldOrLogicalOperator) &&
fieldOrLogicalOperator === OR_OPERATOR) &&
!Array.isArray(value) !Array.isArray(value)
) { ) {
value = [value] value = [value]
@@ -309,11 +319,35 @@ export class QueryBuilder {
) )
}) })
}) })
} else if (
fieldOrLogicalOperator === NOT_OPERATOR &&
(Array.isArray(value) || isObject(value))
) {
builder.whereNot((qb) => {
if (Array.isArray(value)) {
value.forEach((cond) => {
qb.andWhere((subBuilder) =>
this.parseWhere(
aliasMapping,
cond,
subBuilder,
pathAsArray.join(".")
)
)
})
} else {
this.parseWhere(
aliasMapping,
value as any,
qb,
pathAsArray.join(".")
)
}
})
} else if ( } else if (
isObject(value) && isObject(value) &&
!Array.isArray(value) && !Array.isArray(value) &&
fieldOrLogicalOperator !== AND_OPERATOR && !this.isLogicalOperator(fieldOrLogicalOperator)
fieldOrLogicalOperator !== OR_OPERATOR
) { ) {
const currentPath = parentPath ? `${parentPath}.${key}` : key const currentPath = parentPath ? `${parentPath}.${key}` : key
@@ -335,7 +369,10 @@ export class QueryBuilder {
value[subKey] value[subKey]
) )
let val = operator === "IN" ? subValue : [subValue] let val =
operator === "IN" || operator === "NOT IN"
? subValue
: [subValue]
if (operator === "=" && subValue === null) { if (operator === "=" && subValue === null) {
operator = "IS" operator = "IS"
} else if (operator === "!=" && subValue === null) { } else if (operator === "!=" && subValue === null) {
@@ -355,7 +392,7 @@ export class QueryBuilder {
)}'::jsonb` )}'::jsonb`
) )
} }
} else if (operator === "IN") { } else if (operator === "IN" || operator === "NOT IN") {
if (val && !Array.isArray(val)) { if (val && !Array.isArray(val)) {
val = [val] val = [val]
} }
@@ -365,9 +402,12 @@ export class QueryBuilder {
const inPlaceholders = val.map(() => "?").join(",") const inPlaceholders = val.map(() => "?").join(",")
const hasId = field[field.length - 1] === "id" const hasId = field[field.length - 1] === "id"
const isNegated = operator === "NOT IN"
if (hasId) { if (hasId) {
builder.whereRaw( builder.whereRaw(
`${aliasMapping[attr]}.id IN (${inPlaceholders})`, `${aliasMapping[attr]}.id ${
isNegated ? "NOT IN" : "IN"
} (${inPlaceholders})`,
val val
) )
} else { } else {
@@ -379,11 +419,18 @@ export class QueryBuilder {
}) })
) )
if (isNegated) {
builder.whereRaw(
`NOT EXISTS (SELECT 1 FROM unnest(ARRAY[${inPlaceholders}]::JSONB[]) AS v(val) WHERE ${aliasMapping[attr]}.data${nested} @> v.val)`,
jsonbValues
)
} else {
builder.whereRaw( builder.whereRaw(
`${aliasMapping[attr]}.data${nested} @> ANY(ARRAY[${inPlaceholders}]::JSONB[])`, `${aliasMapping[attr]}.data${nested} @> ANY(ARRAY[${inPlaceholders}]::JSONB[])`,
jsonbValues jsonbValues
) )
} }
}
} else { } else {
const potentialIdFields = field[field.length - 1] const potentialIdFields = field[field.length - 1]
const hasId = potentialIdFields === "id" const hasId = potentialIdFields === "id"