feat(utils): Support free text configuration (#6942)
**What** - Add support for searchable entity properties to configure default free text search when providing a `q` filter - `@Searchable` decorator to mark a property or relation to be searchable form the current entity FIXES CORE-1910
This commit is contained in:
1 parent
a0005faa14
commit
21990fcd4b
19 files changed
+424
-205
No files matched your search
@@ -1,21 +1,20 @@
|
|||||||
import {
|
import {
|
||||||
BeforeCreate,
|
BeforeCreate,
|
||||||
Cascade,
|
|
||||||
Collection,
|
Collection,
|
||||||
Entity,
|
Entity,
|
||||||
Filter,
|
Filter,
|
||||||
Formula,
|
Formula,
|
||||||
OnInit,
|
|
||||||
OnLoad,
|
|
||||||
OneToMany,
|
OneToMany,
|
||||||
|
OnInit,
|
||||||
OptionalProps,
|
OptionalProps,
|
||||||
PrimaryKey,
|
PrimaryKey,
|
||||||
Property,
|
Property,
|
||||||
} from "@mikro-orm/core"
|
} from "@mikro-orm/core"
|
||||||
import {
|
import {
|
||||||
DALUtils,
|
|
||||||
createPsqlIndexStatementHelper,
|
createPsqlIndexStatementHelper,
|
||||||
|
DALUtils,
|
||||||
generateEntityId,
|
generateEntityId,
|
||||||
|
Searchable,
|
||||||
} from "@medusajs/utils"
|
} from "@medusajs/utils"
|
||||||
|
|
||||||
import { DAL } from "@medusajs/types"
|
import { DAL } from "@medusajs/types"
|
||||||
@@ -64,6 +63,7 @@ export class InventoryItem {
|
|||||||
deleted_at: Date | null = null
|
deleted_at: Date | null = null
|
||||||
|
|
||||||
@InventoryItemSkuIndex.MikroORMIndex()
|
@InventoryItemSkuIndex.MikroORMIndex()
|
||||||
|
@Searchable()
|
||||||
@Property({ columnType: "text", nullable: true })
|
@Property({ columnType: "text", nullable: true })
|
||||||
sku: string | null = null
|
sku: string | null = null
|
||||||
|
|
||||||
@@ -94,9 +94,11 @@ export class InventoryItem {
|
|||||||
@Property({ columnType: "boolean" })
|
@Property({ columnType: "boolean" })
|
||||||
requires_shipping: boolean = true
|
requires_shipping: boolean = true
|
||||||
|
|
||||||
|
@Searchable()
|
||||||
@Property({ columnType: "text", nullable: true })
|
@Property({ columnType: "text", nullable: true })
|
||||||
description: string | null = null
|
description: string | null = null
|
||||||
|
|
||||||
|
@Searchable()
|
||||||
@Property({ columnType: "text", nullable: true })
|
@Property({ columnType: "text", nullable: true })
|
||||||
title: string | null = null
|
title: string | null = null
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export * from "./inventory-level"
|
export * from "./inventory-level"
|
||||||
export * from "./inventory-item"
|
|
||||||
export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils"
|
export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils"
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { Context, DAL } from "@medusajs/types"
|
|
||||||
import { InventoryItem, InventoryLevel } from "@models"
|
|
||||||
|
|
||||||
import { SqlEntityManager } from "@mikro-orm/postgresql"
|
|
||||||
import { mikroOrmBaseRepositoryFactory } from "@medusajs/utils"
|
|
||||||
|
|
||||||
export class InventoryItemRepository extends mikroOrmBaseRepositoryFactory<InventoryItem>(
|
|
||||||
InventoryItem
|
|
||||||
) {
|
|
||||||
async find(
|
|
||||||
findOptions: DAL.FindOptions<InventoryItem & { q?: string }> = {
|
|
||||||
where: {},
|
|
||||||
},
|
|
||||||
context: Context = {}
|
|
||||||
): Promise<InventoryItem[]> {
|
|
||||||
const findOptions_ = { ...findOptions }
|
|
||||||
findOptions_.options ??= {}
|
|
||||||
|
|
||||||
this.applyFreeTextSearchFilters<InventoryItem>(
|
|
||||||
findOptions_,
|
|
||||||
this.getFreeTextSearchConstraints
|
|
||||||
)
|
|
||||||
|
|
||||||
return await super.find(findOptions_, context)
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAndCount(
|
|
||||||
findOptions: DAL.FindOptions<InventoryItem & { q?: string }> = {
|
|
||||||
where: {},
|
|
||||||
},
|
|
||||||
context: Context = {}
|
|
||||||
): Promise<[InventoryItem[], number]> {
|
|
||||||
const findOptions_ = { ...findOptions }
|
|
||||||
findOptions_.options ??= {}
|
|
||||||
|
|
||||||
this.applyFreeTextSearchFilters<InventoryItem>(
|
|
||||||
findOptions_,
|
|
||||||
this.getFreeTextSearchConstraints
|
|
||||||
)
|
|
||||||
|
|
||||||
return await super.findAndCount(findOptions_, context)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getFreeTextSearchConstraints(q: string) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
description: {
|
|
||||||
$ilike: `%${q}%`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: {
|
|
||||||
$ilike: `%${q}%`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sku: {
|
|
||||||
$ilike: `%${q}%`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -234,6 +234,31 @@ moduleIntegrationTestRunner({
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("list", () => {
|
describe("list", () => {
|
||||||
|
it("should list all product that match the free text search", async () => {
|
||||||
|
const data = buildProductOnlyData({
|
||||||
|
title: "test product",
|
||||||
|
})
|
||||||
|
const data2 = buildProductOnlyData({
|
||||||
|
title: "space X",
|
||||||
|
})
|
||||||
|
|
||||||
|
const products = await service.create([data, data2])
|
||||||
|
|
||||||
|
const result = await service.list({
|
||||||
|
q: "test",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].title).toEqual("test product")
|
||||||
|
|
||||||
|
const result2 = await service.list({
|
||||||
|
q: "space",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result2).toHaveLength(1)
|
||||||
|
expect(result2[0].title).toEqual("space X")
|
||||||
|
})
|
||||||
|
|
||||||
describe("soft deleted", function () {
|
describe("soft deleted", function () {
|
||||||
let product
|
let product
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,18 @@ import {
|
|||||||
Entity,
|
Entity,
|
||||||
Filter,
|
Filter,
|
||||||
Index,
|
Index,
|
||||||
OnInit,
|
|
||||||
OneToMany,
|
OneToMany,
|
||||||
|
OnInit,
|
||||||
PrimaryKey,
|
PrimaryKey,
|
||||||
Property,
|
Property,
|
||||||
} from "@mikro-orm/core"
|
} from "@mikro-orm/core"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DALUtils,
|
|
||||||
createPsqlIndexStatementHelper,
|
createPsqlIndexStatementHelper,
|
||||||
|
DALUtils,
|
||||||
generateEntityId,
|
generateEntityId,
|
||||||
kebabCase,
|
kebabCase,
|
||||||
|
Searchable,
|
||||||
} from "@medusajs/utils"
|
} from "@medusajs/utils"
|
||||||
import Product from "./product"
|
import Product from "./product"
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ class ProductCollection {
|
|||||||
@PrimaryKey({ columnType: "text" })
|
@PrimaryKey({ columnType: "text" })
|
||||||
id!: string
|
id!: string
|
||||||
|
|
||||||
|
@Searchable()
|
||||||
@Property({ columnType: "text" })
|
@Property({ columnType: "text" })
|
||||||
title: string
|
title: string
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
DALUtils,
|
|
||||||
createPsqlIndexStatementHelper,
|
createPsqlIndexStatementHelper,
|
||||||
|
DALUtils,
|
||||||
generateEntityId,
|
generateEntityId,
|
||||||
optionalNumericSerializer,
|
optionalNumericSerializer,
|
||||||
|
Searchable,
|
||||||
} from "@medusajs/utils"
|
} from "@medusajs/utils"
|
||||||
import {
|
import {
|
||||||
BeforeCreate,
|
BeforeCreate,
|
||||||
@@ -12,8 +13,8 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
Index,
|
Index,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
OnInit,
|
|
||||||
OneToMany,
|
OneToMany,
|
||||||
|
OnInit,
|
||||||
PrimaryKey,
|
PrimaryKey,
|
||||||
Property,
|
Property,
|
||||||
} from "@mikro-orm/core"
|
} from "@mikro-orm/core"
|
||||||
@@ -76,9 +77,11 @@ class ProductVariant {
|
|||||||
@PrimaryKey({ columnType: "text" })
|
@PrimaryKey({ columnType: "text" })
|
||||||
id!: string
|
id!: string
|
||||||
|
|
||||||
|
@Searchable()
|
||||||
@Property({ columnType: "text" })
|
@Property({ columnType: "text" })
|
||||||
title: string
|
title: string
|
||||||
|
|
||||||
|
@Searchable()
|
||||||
@Property({ columnType: "text", nullable: true })
|
@Property({ columnType: "text", nullable: true })
|
||||||
sku?: string | null
|
sku?: string | null
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
generateEntityId,
|
generateEntityId,
|
||||||
kebabCase,
|
kebabCase,
|
||||||
ProductUtils,
|
ProductUtils,
|
||||||
|
Searchable,
|
||||||
} from "@medusajs/utils"
|
} from "@medusajs/utils"
|
||||||
import ProductCategory from "./product-category"
|
import ProductCategory from "./product-category"
|
||||||
import ProductCollection from "./product-collection"
|
import ProductCollection from "./product-collection"
|
||||||
@@ -64,6 +65,7 @@ class Product {
|
|||||||
@PrimaryKey({ columnType: "text" })
|
@PrimaryKey({ columnType: "text" })
|
||||||
id!: string
|
id!: string
|
||||||
|
|
||||||
|
@Searchable()
|
||||||
@Property({ columnType: "text" })
|
@Property({ columnType: "text" })
|
||||||
title: string
|
title: string
|
||||||
|
|
||||||
@@ -73,7 +75,11 @@ class Product {
|
|||||||
@Property({ columnType: "text", nullable: true })
|
@Property({ columnType: "text", nullable: true })
|
||||||
subtitle?: string | null
|
subtitle?: string | null
|
||||||
|
|
||||||
@Property({ columnType: "text", nullable: true })
|
@Searchable()
|
||||||
|
@Property({
|
||||||
|
columnType: "text",
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
description?: string | null
|
description?: string | null
|
||||||
|
|
||||||
@Property({ columnType: "boolean", default: false })
|
@Property({ columnType: "boolean", default: false })
|
||||||
@@ -91,6 +97,7 @@ class Product {
|
|||||||
})
|
})
|
||||||
options = new Collection<ProductOption>(this)
|
options = new Collection<ProductOption>(this)
|
||||||
|
|
||||||
|
@Searchable()
|
||||||
@OneToMany(() => ProductVariant, (variant) => variant.product, {
|
@OneToMany(() => ProductVariant, (variant) => variant.product, {
|
||||||
cascade: ["soft-remove"] as any,
|
cascade: ["soft-remove"] as any,
|
||||||
})
|
})
|
||||||
@@ -120,6 +127,7 @@ class Product {
|
|||||||
@Property({ columnType: "text", nullable: true })
|
@Property({ columnType: "text", nullable: true })
|
||||||
material?: string | null
|
material?: string | null
|
||||||
|
|
||||||
|
@Searchable()
|
||||||
@ManyToOne(() => ProductCollection, {
|
@ManyToOne(() => ProductCollection, {
|
||||||
columnType: "text",
|
columnType: "text",
|
||||||
nullable: true,
|
nullable: true,
|
||||||
|
|||||||
@@ -15,40 +15,6 @@ export class ProductRepository extends DALUtils.mikroOrmBaseRepositoryFactory<Pr
|
|||||||
super(...arguments)
|
super(...arguments)
|
||||||
}
|
}
|
||||||
|
|
||||||
async find(
|
|
||||||
findOptions: DAL.FindOptions<Product & { q?: string }> = { where: {} },
|
|
||||||
context: Context = {}
|
|
||||||
): Promise<Product[]> {
|
|
||||||
const findOptions_ = { ...findOptions }
|
|
||||||
findOptions_.options ??= {}
|
|
||||||
|
|
||||||
await this.mutateNotInCategoriesConstraints(findOptions_)
|
|
||||||
|
|
||||||
this.applyFreeTextSearchFilters<Product>(
|
|
||||||
findOptions_,
|
|
||||||
this.getFreeTextSearchConstraints
|
|
||||||
)
|
|
||||||
|
|
||||||
return await super.find(findOptions_, context)
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAndCount(
|
|
||||||
findOptions: DAL.FindOptions<Product & { q?: string }> = { where: {} },
|
|
||||||
context: Context = {}
|
|
||||||
): Promise<[Product[], number]> {
|
|
||||||
const findOptions_ = { ...findOptions }
|
|
||||||
findOptions_.options ??= {}
|
|
||||||
|
|
||||||
await this.mutateNotInCategoriesConstraints(findOptions_)
|
|
||||||
|
|
||||||
this.applyFreeTextSearchFilters<Product>(
|
|
||||||
findOptions_,
|
|
||||||
this.getFreeTextSearchConstraints
|
|
||||||
)
|
|
||||||
|
|
||||||
return await super.findAndCount(findOptions_, context)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In order to be able to have a strict not in categories, and prevent a product
|
* In order to be able to have a strict not in categories, and prevent a product
|
||||||
* to be return in the case it also belongs to other categories, we need to
|
* to be return in the case it also belongs to other categories, we need to
|
||||||
@@ -88,42 +54,4 @@ export class ProductRepository extends DALUtils.mikroOrmBaseRepositoryFactory<Pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getFreeTextSearchConstraints(q: string) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
description: {
|
|
||||||
$ilike: `%${q}%`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: {
|
|
||||||
$ilike: `%${q}%`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
collection: {
|
|
||||||
title: {
|
|
||||||
$ilike: `%${q}%`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
$or: [
|
|
||||||
{
|
|
||||||
title: {
|
|
||||||
$ilike: `%${q}%`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sku: {
|
|
||||||
$ilike: `%${q}%`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
createPsqlIndexStatementHelper,
|
createPsqlIndexStatementHelper,
|
||||||
generateEntityId,
|
generateEntityId,
|
||||||
|
Searchable,
|
||||||
} from "@medusajs/utils"
|
} from "@medusajs/utils"
|
||||||
|
|
||||||
import { StockLocationAddress } from "./stock-location-address"
|
import { StockLocationAddress } from "./stock-location-address"
|
||||||
@@ -43,6 +44,7 @@ export class StockLocation {
|
|||||||
@Property({ columnType: "timestamptz", nullable: true })
|
@Property({ columnType: "timestamptz", nullable: true })
|
||||||
deleted_at: Date | null = null
|
deleted_at: Date | null = null
|
||||||
|
|
||||||
|
@Searchable()
|
||||||
@Property({ columnType: "text" })
|
@Property({ columnType: "text" })
|
||||||
name: string
|
name: string
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils"
|
export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils"
|
||||||
export { StockLocationRepository } from "./stock-location"
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Context, DAL } from "@medusajs/types"
|
|
||||||
|
|
||||||
import { StockLocation } from "@models"
|
|
||||||
import { mikroOrmBaseRepositoryFactory } from "@medusajs/utils"
|
|
||||||
|
|
||||||
export class StockLocationRepository extends mikroOrmBaseRepositoryFactory<StockLocation>(
|
|
||||||
StockLocation
|
|
||||||
) {
|
|
||||||
async find(
|
|
||||||
findOptions: DAL.FindOptions<StockLocation & { q?: string }> = {
|
|
||||||
where: {},
|
|
||||||
},
|
|
||||||
context: Context
|
|
||||||
): Promise<StockLocation[]> {
|
|
||||||
const findOptions_ = { ...findOptions }
|
|
||||||
findOptions_.options ??= {}
|
|
||||||
|
|
||||||
this.applyFreeTextSearchFilters<StockLocation>(
|
|
||||||
findOptions_,
|
|
||||||
this.getFreeTextSearchConstraints
|
|
||||||
)
|
|
||||||
|
|
||||||
return await super.find(findOptions_, context)
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAndCount(
|
|
||||||
findOptions: DAL.FindOptions<StockLocation & { q?: string }> = {
|
|
||||||
where: {},
|
|
||||||
},
|
|
||||||
context: Context
|
|
||||||
): Promise<[StockLocation[], number]> {
|
|
||||||
const findOptions_ = { ...findOptions }
|
|
||||||
findOptions_.options ??= {}
|
|
||||||
|
|
||||||
this.applyFreeTextSearchFilters<StockLocation>(
|
|
||||||
findOptions_,
|
|
||||||
this.getFreeTextSearchConstraints
|
|
||||||
)
|
|
||||||
|
|
||||||
return await super.findAndCount(findOptions_, context)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getFreeTextSearchConstraints(q: string) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: {
|
|
||||||
$ilike: `%${q}%`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
export * from "./mikro-orm/big-number-field"
|
export * from "./mikro-orm/big-number-field"
|
||||||
export * from "./mikro-orm/mikro-orm-create-connection"
|
export * from "./mikro-orm/mikro-orm-create-connection"
|
||||||
|
export * from "./mikro-orm/mikro-orm-free-text-search-filter"
|
||||||
export * from "./mikro-orm/mikro-orm-repository"
|
export * from "./mikro-orm/mikro-orm-repository"
|
||||||
export * from "./mikro-orm/mikro-orm-soft-deletable-filter"
|
export * from "./mikro-orm/mikro-orm-soft-deletable-filter"
|
||||||
export * from "./mikro-orm/mikro-orm-serializer"
|
export * from "./mikro-orm/mikro-orm-serializer"
|
||||||
export * from "./mikro-orm/utils"
|
export * from "./mikro-orm/utils"
|
||||||
|
export * from "./mikro-orm/decorators/searchable"
|
||||||
export * from "./repositories"
|
export * from "./repositories"
|
||||||
export * from "./utils"
|
export * from "./utils"
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
PrimaryKey,
|
PrimaryKey,
|
||||||
Property,
|
Property,
|
||||||
} from "@mikro-orm/core"
|
} from "@mikro-orm/core"
|
||||||
|
import { Searchable } from "../decorators/searchable"
|
||||||
|
|
||||||
// Circular dependency one level
|
// Circular dependency one level
|
||||||
@Entity()
|
@Entity()
|
||||||
@@ -279,6 +280,60 @@ class Entity2WithUnDecoratedProp {
|
|||||||
entity1: Entity1WithUnDecoratedProp
|
entity1: Entity1WithUnDecoratedProp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Searchable fields
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
class SearchableEntity1 {
|
||||||
|
constructor(props: { id: string; deleted_at: Date | null }) {
|
||||||
|
this.id = props.id
|
||||||
|
this.deleted_at = props.deleted_at
|
||||||
|
}
|
||||||
|
|
||||||
|
@PrimaryKey()
|
||||||
|
id: string
|
||||||
|
|
||||||
|
@Property()
|
||||||
|
deleted_at: Date | null
|
||||||
|
|
||||||
|
@Searchable()
|
||||||
|
@Property()
|
||||||
|
searchableField: string
|
||||||
|
|
||||||
|
@Searchable()
|
||||||
|
@OneToMany(() => SearchableEntity2, (entity2) => entity2.entity1)
|
||||||
|
entity2 = new Collection<SearchableEntity2>(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
class SearchableEntity2 {
|
||||||
|
constructor(props: {
|
||||||
|
id: string
|
||||||
|
deleted_at: Date | null
|
||||||
|
entity1: SearchableEntity1
|
||||||
|
}) {
|
||||||
|
this.id = props.id
|
||||||
|
this.deleted_at = props.deleted_at
|
||||||
|
this.entity1 = props.entity1
|
||||||
|
this.entity1_id = props.entity1.id
|
||||||
|
}
|
||||||
|
|
||||||
|
@PrimaryKey()
|
||||||
|
id: string
|
||||||
|
|
||||||
|
@Property()
|
||||||
|
deleted_at: Date | null
|
||||||
|
|
||||||
|
@Searchable()
|
||||||
|
@Property()
|
||||||
|
searchableField: string
|
||||||
|
|
||||||
|
@ManyToOne(() => SearchableEntity1, { mapToPk: true })
|
||||||
|
entity1_id: string
|
||||||
|
|
||||||
|
@ManyToOne(() => SearchableEntity1, { persist: false })
|
||||||
|
entity1: SearchableEntity1
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
RecursiveEntity1,
|
RecursiveEntity1,
|
||||||
RecursiveEntity2,
|
RecursiveEntity2,
|
||||||
@@ -291,4 +346,6 @@ export {
|
|||||||
InternalCircularDependencyEntity1,
|
InternalCircularDependencyEntity1,
|
||||||
Entity1WithUnDecoratedProp,
|
Entity1WithUnDecoratedProp,
|
||||||
Entity2WithUnDecoratedProp,
|
Entity2WithUnDecoratedProp,
|
||||||
|
SearchableEntity1,
|
||||||
|
SearchableEntity2,
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { MikroORM } from "@mikro-orm/core"
|
||||||
|
import { SearchableEntity1, SearchableEntity2 } from "../__fixtures__/utils"
|
||||||
|
import { mikroOrmFreeTextSearchFilterOptionsFactory } from "../mikro-orm-free-text-search-filter"
|
||||||
|
|
||||||
|
describe("mikroOrmFreeTextSearchFilterOptionsFactory", () => {
|
||||||
|
let orm
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
orm = await MikroORM.init({
|
||||||
|
entities: [SearchableEntity1, SearchableEntity2],
|
||||||
|
dbName: "test",
|
||||||
|
type: "postgresql",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return a filter function that filters entities based on the free text search value", async () => {
|
||||||
|
const entityManager = orm.em.fork()
|
||||||
|
const freeTextSearchValue = "search"
|
||||||
|
|
||||||
|
const models = [SearchableEntity1, SearchableEntity2]
|
||||||
|
|
||||||
|
let filterConstraints = mikroOrmFreeTextSearchFilterOptionsFactory(
|
||||||
|
models
|
||||||
|
).cond(
|
||||||
|
{
|
||||||
|
value: freeTextSearchValue,
|
||||||
|
fromEntity: SearchableEntity1.name,
|
||||||
|
},
|
||||||
|
"read",
|
||||||
|
entityManager
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(filterConstraints).toEqual({
|
||||||
|
$or: [
|
||||||
|
{
|
||||||
|
searchableField: {
|
||||||
|
$ilike: `%${freeTextSearchValue}%`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entity2: {
|
||||||
|
$or: [
|
||||||
|
{
|
||||||
|
searchableField: {
|
||||||
|
$ilike: `%${freeTextSearchValue}%`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
filterConstraints = mikroOrmFreeTextSearchFilterOptionsFactory(models).cond(
|
||||||
|
{
|
||||||
|
value: freeTextSearchValue,
|
||||||
|
fromEntity: SearchableEntity2.name,
|
||||||
|
},
|
||||||
|
"read",
|
||||||
|
entityManager
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(filterConstraints).toEqual({
|
||||||
|
$or: [
|
||||||
|
{
|
||||||
|
searchableField: {
|
||||||
|
$ilike: `%${freeTextSearchValue}%`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { MetadataStorage } from "@mikro-orm/core"
|
||||||
|
|
||||||
|
export function Searchable() {
|
||||||
|
return function (target, propertyName) {
|
||||||
|
const meta = MetadataStorage.getMetadataFromDecorator(target.constructor)
|
||||||
|
const prop = meta.properties[propertyName] || {}
|
||||||
|
prop["searchable"] = true
|
||||||
|
meta.properties[prop.name] = prop
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ModuleServiceInitializeOptions } from "@medusajs/types"
|
import { ModuleServiceInitializeOptions } from "@medusajs/types"
|
||||||
import { TSMigrationGenerator } from "@mikro-orm/migrations"
|
import { TSMigrationGenerator } from "@mikro-orm/migrations"
|
||||||
import { isString } from "../../common"
|
import { isString } from "../../common"
|
||||||
|
import { FilterDef } from "@mikro-orm/core/typings"
|
||||||
|
|
||||||
// Monkey patch due to the compilation version issue which prevents us from creating a proper class that extends the TSMigrationGenerator
|
// Monkey patch due to the compilation version issue which prevents us from creating a proper class that extends the TSMigrationGenerator
|
||||||
const originalCreateStatement = TSMigrationGenerator.prototype.createStatement
|
const originalCreateStatement = TSMigrationGenerator.prototype.createStatement
|
||||||
@@ -67,8 +68,15 @@ TSMigrationGenerator.prototype.createStatement = function (
|
|||||||
|
|
||||||
export { TSMigrationGenerator }
|
export { TSMigrationGenerator }
|
||||||
|
|
||||||
|
export type Filter = {
|
||||||
|
name?: string
|
||||||
|
} & Omit<FilterDef, "name">
|
||||||
|
|
||||||
export async function mikroOrmCreateConnection(
|
export async function mikroOrmCreateConnection(
|
||||||
database: ModuleServiceInitializeOptions["database"] & { connection?: any },
|
database: ModuleServiceInitializeOptions["database"] & {
|
||||||
|
connection?: any
|
||||||
|
filters?: Record<string, Filter>
|
||||||
|
},
|
||||||
entities: any[],
|
entities: any[],
|
||||||
pathToMigrations: string
|
pathToMigrations: string
|
||||||
) {
|
) {
|
||||||
@@ -100,6 +108,7 @@ export async function mikroOrmCreateConnection(
|
|||||||
driverOptions,
|
driverOptions,
|
||||||
tsNode: process.env.APP_ENV === "development",
|
tsNode: process.env.APP_ENV === "development",
|
||||||
type: "postgresql",
|
type: "postgresql",
|
||||||
|
filters: database.filters ?? {},
|
||||||
migrations: {
|
migrations: {
|
||||||
path: pathToMigrations,
|
path: pathToMigrations,
|
||||||
generator: TSMigrationGenerator,
|
generator: TSMigrationGenerator,
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { EntityClass, EntityProperty } from "@mikro-orm/core/typings"
|
||||||
|
import { EntityMetadata, EntitySchema, ReferenceType } from "@mikro-orm/core"
|
||||||
|
import { SqlEntityManager } from "@mikro-orm/postgresql"
|
||||||
|
import type { FindOneOptions, FindOptions } from "@mikro-orm/core/drivers"
|
||||||
|
|
||||||
|
export const FreeTextSearchFilterKey = "freeTextSearch"
|
||||||
|
|
||||||
|
interface FilterArgument {
|
||||||
|
value: string
|
||||||
|
fromEntity: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEntityProperties(entity: EntityClass<any> | EntitySchema): {
|
||||||
|
[key: string]: EntityProperty<any>
|
||||||
|
} {
|
||||||
|
return (
|
||||||
|
(entity as EntityClass<any>)?.prototype.__meta?.properties ??
|
||||||
|
(entity as EntitySchema).meta?.properties
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function retrieveRelationsConstraints(
|
||||||
|
relation: {
|
||||||
|
targetMeta?: EntityMetadata
|
||||||
|
searchable?: boolean
|
||||||
|
mapToPk?: boolean
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
},
|
||||||
|
models: (EntityClass<any> | EntitySchema)[],
|
||||||
|
searchValue: string,
|
||||||
|
visited: Set<string> = new Set(),
|
||||||
|
shouldStop: boolean = false
|
||||||
|
) {
|
||||||
|
if (shouldStop || !relation.searchable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const relationClassName = relation.targetMeta!.className
|
||||||
|
|
||||||
|
visited.add(relationClassName)
|
||||||
|
|
||||||
|
const relationFreeTextSearchWhere: any = []
|
||||||
|
|
||||||
|
const relationClass = models.find((m) => m.name === relation.type)!
|
||||||
|
const relationProperties = getEntityProperties(relationClass)
|
||||||
|
|
||||||
|
for (const propertyConfiguration of Object.values(relationProperties)) {
|
||||||
|
if (
|
||||||
|
!(propertyConfiguration as any).searchable ||
|
||||||
|
propertyConfiguration.reference !== ReferenceType.SCALAR
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
relationFreeTextSearchWhere.push({
|
||||||
|
[propertyConfiguration.name]: {
|
||||||
|
$ilike: `%${searchValue}%`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const innerRelations: EntityProperty[] =
|
||||||
|
(relationClass as EntityClass<any>)?.prototype.__meta?.relations ??
|
||||||
|
(relationClass as EntitySchema).meta?.relations
|
||||||
|
|
||||||
|
for (const innerRelation of innerRelations) {
|
||||||
|
const branchVisited = new Set(Array.from(visited))
|
||||||
|
const innerRelationClassName = innerRelation.targetMeta!.className
|
||||||
|
const isSelfCircularDependency =
|
||||||
|
innerRelationClassName === relationClassName
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isSelfCircularDependency &&
|
||||||
|
branchVisited.has(innerRelationClassName)
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
branchVisited.add(innerRelationClassName)
|
||||||
|
|
||||||
|
const innerRelationName = !innerRelation.mapToPk
|
||||||
|
? innerRelation.name
|
||||||
|
: relation.targetMeta!.relations.find(
|
||||||
|
(r) => r.type === innerRelation.type && !r.mapToPk
|
||||||
|
)?.name
|
||||||
|
|
||||||
|
if (!innerRelationName) {
|
||||||
|
throw new Error(
|
||||||
|
`Unable to retrieve the counter part relation definition for the mapToPk relation ${innerRelation.name} on entity ${relation.name}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const relationConstraints = retrieveRelationsConstraints(
|
||||||
|
{
|
||||||
|
name: innerRelationName,
|
||||||
|
targetMeta: innerRelation.targetMeta,
|
||||||
|
searchable: (innerRelation as any).searchable,
|
||||||
|
mapToPk: innerRelation.mapToPk,
|
||||||
|
type: innerRelation.type,
|
||||||
|
},
|
||||||
|
models,
|
||||||
|
searchValue,
|
||||||
|
branchVisited,
|
||||||
|
isSelfCircularDependency
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!relationConstraints?.length) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
relationFreeTextSearchWhere.push({
|
||||||
|
[innerRelationName]: {
|
||||||
|
$or: relationConstraints,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return relationFreeTextSearchWhere
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mikroOrmFreeTextSearchFilterOptionsFactory = (
|
||||||
|
models: (EntityClass<any> | EntitySchema)[]
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
cond: (
|
||||||
|
freeTextSearchArgs: FilterArgument,
|
||||||
|
operation: string,
|
||||||
|
manager: SqlEntityManager,
|
||||||
|
options?: (FindOptions<any, any> | FindOneOptions<any, any>) & {
|
||||||
|
visited?: Set<EntityClass<any>>
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
if (!freeTextSearchArgs || !freeTextSearchArgs.value) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { value, fromEntity } = freeTextSearchArgs
|
||||||
|
|
||||||
|
if (options?.visited?.size) {
|
||||||
|
/**
|
||||||
|
* When being in select in strategy, the filter gets applied to all queries even the ones that are not related to the entity
|
||||||
|
*/
|
||||||
|
const hasFilterAlreadyBeenAppliedForEntity = [
|
||||||
|
...options.visited.values(),
|
||||||
|
].some((v) => v.constructor.name === freeTextSearchArgs.fromEntity)
|
||||||
|
if (hasFilterAlreadyBeenAppliedForEntity) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityMetadata = manager.getDriver().getMetadata().get(fromEntity)
|
||||||
|
|
||||||
|
const freeTextSearchWhere = retrieveRelationsConstraints(
|
||||||
|
{
|
||||||
|
targetMeta: entityMetadata,
|
||||||
|
mapToPk: false,
|
||||||
|
searchable: true,
|
||||||
|
type: fromEntity,
|
||||||
|
name: entityMetadata.name!,
|
||||||
|
},
|
||||||
|
models,
|
||||||
|
value
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!freeTextSearchWhere.length) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
$or: freeTextSearchWhere,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
default: true,
|
||||||
|
args: false,
|
||||||
|
entity: models.map((m) => m.name) as string[],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,19 +2,20 @@ import {
|
|||||||
BaseFilterable,
|
BaseFilterable,
|
||||||
Context,
|
Context,
|
||||||
FilterQuery,
|
FilterQuery,
|
||||||
FindConfig,
|
|
||||||
FilterQuery as InternalFilterQuery,
|
FilterQuery as InternalFilterQuery,
|
||||||
|
FindConfig,
|
||||||
ModulesSdkTypes,
|
ModulesSdkTypes,
|
||||||
|
UpsertWithReplaceConfig,
|
||||||
} from "@medusajs/types"
|
} from "@medusajs/types"
|
||||||
import { EntitySchema } from "@mikro-orm/core"
|
import { EntitySchema } from "@mikro-orm/core"
|
||||||
import { EntityClass } from "@mikro-orm/core/typings"
|
import { EntityClass } from "@mikro-orm/core/typings"
|
||||||
import {
|
import {
|
||||||
MedusaError,
|
|
||||||
doNotForceTransaction,
|
doNotForceTransaction,
|
||||||
isDefined,
|
isDefined,
|
||||||
isObject,
|
isObject,
|
||||||
isString,
|
isString,
|
||||||
lowerCaseFirst,
|
lowerCaseFirst,
|
||||||
|
MedusaError,
|
||||||
shouldForceTransaction,
|
shouldForceTransaction,
|
||||||
} from "../common"
|
} from "../common"
|
||||||
import { buildQuery } from "./build-query"
|
import { buildQuery } from "./build-query"
|
||||||
@@ -23,7 +24,7 @@ import {
|
|||||||
InjectTransactionManager,
|
InjectTransactionManager,
|
||||||
MedusaContext,
|
MedusaContext,
|
||||||
} from "./decorators"
|
} from "./decorators"
|
||||||
import { UpsertWithReplaceConfig } from "@medusajs/types"
|
import { FreeTextSearchFilterKey } from "../dal"
|
||||||
|
|
||||||
type SelectorAndData = {
|
type SelectorAndData = {
|
||||||
selector: FilterQuery<any> | BaseFilterable<FilterQuery<any>>
|
selector: FilterQuery<any> | BaseFilterable<FilterQuery<any>>
|
||||||
@@ -53,6 +54,20 @@ export function internalModuleServiceFactory<
|
|||||||
this[propertyRepositoryName] = container[injectedRepositoryName]
|
this[propertyRepositoryName] = container[injectedRepositoryName]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static applyFreeTextSearchFilter(
|
||||||
|
filters: FilterQuery,
|
||||||
|
config: FindConfig<any>
|
||||||
|
): void {
|
||||||
|
if (isDefined(filters?.q)) {
|
||||||
|
config.filters ??= {}
|
||||||
|
config.filters[FreeTextSearchFilterKey] = {
|
||||||
|
value: filters.q,
|
||||||
|
fromEntity: model.name,
|
||||||
|
}
|
||||||
|
delete filters.q
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static retrievePrimaryKeys(entity: EntityClass<any> | EntitySchema<any>) {
|
static retrievePrimaryKeys(entity: EntityClass<any> | EntitySchema<any>) {
|
||||||
return (
|
return (
|
||||||
(entity as EntitySchema<any>).meta?.primaryKeys ??
|
(entity as EntitySchema<any>).meta?.primaryKeys ??
|
||||||
@@ -149,6 +164,8 @@ export function internalModuleServiceFactory<
|
|||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
): Promise<TEntity[]> {
|
): Promise<TEntity[]> {
|
||||||
AbstractService_.applyDefaultOrdering(config)
|
AbstractService_.applyDefaultOrdering(config)
|
||||||
|
AbstractService_.applyFreeTextSearchFilter(filters, config)
|
||||||
|
|
||||||
const queryOptions = buildQuery(filters, config)
|
const queryOptions = buildQuery(filters, config)
|
||||||
|
|
||||||
return await this[propertyRepositoryName].find(
|
return await this[propertyRepositoryName].find(
|
||||||
@@ -164,6 +181,8 @@ export function internalModuleServiceFactory<
|
|||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
): Promise<[TEntity[], number]> {
|
): Promise<[TEntity[], number]> {
|
||||||
AbstractService_.applyDefaultOrdering(config)
|
AbstractService_.applyDefaultOrdering(config)
|
||||||
|
AbstractService_.applyFreeTextSearchFilter(filters, config)
|
||||||
|
|
||||||
const queryOptions = buildQuery(filters, config)
|
const queryOptions = buildQuery(filters, config)
|
||||||
|
|
||||||
return await this[propertyRepositoryName].findAndCount(
|
return await this[propertyRepositoryName].findAndCount(
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import {
|
|||||||
import { PostgreSqlDriver, SqlEntityManager } from "@mikro-orm/postgresql"
|
import { PostgreSqlDriver, SqlEntityManager } from "@mikro-orm/postgresql"
|
||||||
import { asValue } from "awilix"
|
import { asValue } from "awilix"
|
||||||
import { ContainerRegistrationKeys, MedusaError } from "../../common"
|
import { ContainerRegistrationKeys, MedusaError } from "../../common"
|
||||||
import { mikroOrmCreateConnection } from "../../dal"
|
import {
|
||||||
|
FreeTextSearchFilterKey,
|
||||||
|
mikroOrmCreateConnection,
|
||||||
|
mikroOrmFreeTextSearchFilterOptionsFactory,
|
||||||
|
} from "../../dal"
|
||||||
import { loadDatabaseConfig } from "../load-module-database-config"
|
import { loadDatabaseConfig } from "../load-module-database-config"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,6 +21,7 @@ import { loadDatabaseConfig } from "../load-module-database-config"
|
|||||||
* @param moduleName
|
* @param moduleName
|
||||||
* @param container
|
* @param container
|
||||||
* @param options
|
* @param options
|
||||||
|
* @param filters
|
||||||
* @param moduleDeclaration
|
* @param moduleDeclaration
|
||||||
* @param entities
|
* @param entities
|
||||||
* @param pathToMigrations
|
* @param pathToMigrations
|
||||||
@@ -39,6 +44,9 @@ export async function mikroOrmConnectionLoader({
|
|||||||
logger?: Logger
|
logger?: Logger
|
||||||
pathToMigrations: string
|
pathToMigrations: string
|
||||||
}) {
|
}) {
|
||||||
|
const freeTextSearchGlobalFilter =
|
||||||
|
mikroOrmFreeTextSearchFilterOptionsFactory(entities)
|
||||||
|
|
||||||
let manager = (
|
let manager = (
|
||||||
options as ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
|
options as ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
|
||||||
)?.manager
|
)?.manager
|
||||||
@@ -62,7 +70,12 @@ export async function mikroOrmConnectionLoader({
|
|||||||
shouldSwallowError
|
shouldSwallowError
|
||||||
)
|
)
|
||||||
return await loadShared({
|
return await loadShared({
|
||||||
database: dbConfig,
|
database: {
|
||||||
|
...dbConfig,
|
||||||
|
filters: {
|
||||||
|
[FreeTextSearchFilterKey as string]: freeTextSearchGlobalFilter,
|
||||||
|
},
|
||||||
|
},
|
||||||
container,
|
container,
|
||||||
entities,
|
entities,
|
||||||
pathToMigrations,
|
pathToMigrations,
|
||||||
@@ -87,7 +100,12 @@ export async function mikroOrmConnectionLoader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
manager ??= await loadDefault({
|
manager ??= await loadDefault({
|
||||||
database: dbConfig,
|
database: {
|
||||||
|
...dbConfig,
|
||||||
|
filters: {
|
||||||
|
[FreeTextSearchFilterKey as string]: freeTextSearchGlobalFilter,
|
||||||
|
},
|
||||||
|
},
|
||||||
entities,
|
entities,
|
||||||
pathToMigrations,
|
pathToMigrations,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in new issue
Block a user