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:
Adrien de Peretti
2024-04-05 15:59:40 +02:00
committed by GitHub
parent a0005faa14
commit 21990fcd4b
19 changed files with 424 additions and 205 deletions
@@ -1,21 +1,20 @@
import {
BeforeCreate,
Cascade,
Collection,
Entity,
Filter,
Formula,
OnInit,
OnLoad,
OneToMany,
OnInit,
OptionalProps,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import {
DALUtils,
createPsqlIndexStatementHelper,
DALUtils,
generateEntityId,
Searchable,
} from "@medusajs/utils"
import { DAL } from "@medusajs/types"
@@ -64,6 +63,7 @@ export class InventoryItem {
deleted_at: Date | null = null
@InventoryItemSkuIndex.MikroORMIndex()
@Searchable()
@Property({ columnType: "text", nullable: true })
sku: string | null = null
@@ -94,9 +94,11 @@ export class InventoryItem {
@Property({ columnType: "boolean" })
requires_shipping: boolean = true
@Searchable()
@Property({ columnType: "text", nullable: true })
description: string | null = null
@Searchable()
@Property({ columnType: "text", nullable: true })
title: string | null = null
@@ -1,3 +1,2 @@
export * from "./inventory-level"
export * from "./inventory-item"
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", () => {
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 () {
let product
@@ -4,17 +4,18 @@ import {
Entity,
Filter,
Index,
OnInit,
OneToMany,
OnInit,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import {
DALUtils,
createPsqlIndexStatementHelper,
DALUtils,
generateEntityId,
kebabCase,
Searchable,
} from "@medusajs/utils"
import Product from "./product"
@@ -34,6 +35,7 @@ class ProductCollection {
@PrimaryKey({ columnType: "text" })
id!: string
@Searchable()
@Property({ columnType: "text" })
title: string
@@ -1,8 +1,9 @@
import {
DALUtils,
createPsqlIndexStatementHelper,
DALUtils,
generateEntityId,
optionalNumericSerializer,
Searchable,
} from "@medusajs/utils"
import {
BeforeCreate,
@@ -12,8 +13,8 @@ import {
Filter,
Index,
ManyToOne,
OnInit,
OneToMany,
OnInit,
PrimaryKey,
Property,
} from "@mikro-orm/core"
@@ -76,9 +77,11 @@ class ProductVariant {
@PrimaryKey({ columnType: "text" })
id!: string
@Searchable()
@Property({ columnType: "text" })
title: string
@Searchable()
@Property({ columnType: "text", nullable: true })
sku?: string | null
+9 -1
View File
@@ -19,6 +19,7 @@ import {
generateEntityId,
kebabCase,
ProductUtils,
Searchable,
} from "@medusajs/utils"
import ProductCategory from "./product-category"
import ProductCollection from "./product-collection"
@@ -64,6 +65,7 @@ class Product {
@PrimaryKey({ columnType: "text" })
id!: string
@Searchable()
@Property({ columnType: "text" })
title: string
@@ -73,7 +75,11 @@ class Product {
@Property({ columnType: "text", nullable: true })
subtitle?: string | null
@Property({ columnType: "text", nullable: true })
@Searchable()
@Property({
columnType: "text",
nullable: true,
})
description?: string | null
@Property({ columnType: "boolean", default: false })
@@ -91,6 +97,7 @@ class Product {
})
options = new Collection<ProductOption>(this)
@Searchable()
@OneToMany(() => ProductVariant, (variant) => variant.product, {
cascade: ["soft-remove"] as any,
})
@@ -120,6 +127,7 @@ class Product {
@Property({ columnType: "text", nullable: true })
material?: string | null
@Searchable()
@ManyToOne(() => ProductCollection, {
columnType: "text",
nullable: true,
@@ -15,40 +15,6 @@ export class ProductRepository extends DALUtils.mikroOrmBaseRepositoryFactory<Pr
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
* 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 {
createPsqlIndexStatementHelper,
generateEntityId,
Searchable,
} from "@medusajs/utils"
import { StockLocationAddress } from "./stock-location-address"
@@ -43,6 +44,7 @@ export class StockLocation {
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date | null = null
@Searchable()
@Property({ columnType: "text" })
name: string
@@ -1,2 +1 @@
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}%`,
},
},
]
}
}
+2
View File
@@ -1,8 +1,10 @@
export * from "./mikro-orm/big-number-field"
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-soft-deletable-filter"
export * from "./mikro-orm/mikro-orm-serializer"
export * from "./mikro-orm/utils"
export * from "./mikro-orm/decorators/searchable"
export * from "./repositories"
export * from "./utils"
@@ -6,6 +6,7 @@ import {
PrimaryKey,
Property,
} from "@mikro-orm/core"
import { Searchable } from "../decorators/searchable"
// Circular dependency one level
@Entity()
@@ -279,6 +280,60 @@ class Entity2WithUnDecoratedProp {
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 {
RecursiveEntity1,
RecursiveEntity2,
@@ -291,4 +346,6 @@ export {
InternalCircularDependencyEntity1,
Entity1WithUnDecoratedProp,
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 { TSMigrationGenerator } from "@mikro-orm/migrations"
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
const originalCreateStatement = TSMigrationGenerator.prototype.createStatement
@@ -67,8 +68,15 @@ TSMigrationGenerator.prototype.createStatement = function (
export { TSMigrationGenerator }
export type Filter = {
name?: string
} & Omit<FilterDef, "name">
export async function mikroOrmCreateConnection(
database: ModuleServiceInitializeOptions["database"] & { connection?: any },
database: ModuleServiceInitializeOptions["database"] & {
connection?: any
filters?: Record<string, Filter>
},
entities: any[],
pathToMigrations: string
) {
@@ -100,6 +108,7 @@ export async function mikroOrmCreateConnection(
driverOptions,
tsNode: process.env.APP_ENV === "development",
type: "postgresql",
filters: database.filters ?? {},
migrations: {
path: pathToMigrations,
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,
Context,
FilterQuery,
FindConfig,
FilterQuery as InternalFilterQuery,
FindConfig,
ModulesSdkTypes,
UpsertWithReplaceConfig,
} from "@medusajs/types"
import { EntitySchema } from "@mikro-orm/core"
import { EntityClass } from "@mikro-orm/core/typings"
import {
MedusaError,
doNotForceTransaction,
isDefined,
isObject,
isString,
lowerCaseFirst,
MedusaError,
shouldForceTransaction,
} from "../common"
import { buildQuery } from "./build-query"
@@ -23,7 +24,7 @@ import {
InjectTransactionManager,
MedusaContext,
} from "./decorators"
import { UpsertWithReplaceConfig } from "@medusajs/types"
import { FreeTextSearchFilterKey } from "../dal"
type SelectorAndData = {
selector: FilterQuery<any> | BaseFilterable<FilterQuery<any>>
@@ -53,6 +54,20 @@ export function internalModuleServiceFactory<
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>) {
return (
(entity as EntitySchema<any>).meta?.primaryKeys ??
@@ -149,6 +164,8 @@ export function internalModuleServiceFactory<
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
AbstractService_.applyDefaultOrdering(config)
AbstractService_.applyFreeTextSearchFilter(filters, config)
const queryOptions = buildQuery(filters, config)
return await this[propertyRepositoryName].find(
@@ -164,6 +181,8 @@ export function internalModuleServiceFactory<
@MedusaContext() sharedContext: Context = {}
): Promise<[TEntity[], number]> {
AbstractService_.applyDefaultOrdering(config)
AbstractService_.applyFreeTextSearchFilter(filters, config)
const queryOptions = buildQuery(filters, config)
return await this[propertyRepositoryName].findAndCount(
@@ -8,7 +8,11 @@ import {
import { PostgreSqlDriver, SqlEntityManager } from "@mikro-orm/postgresql"
import { asValue } from "awilix"
import { ContainerRegistrationKeys, MedusaError } from "../../common"
import { mikroOrmCreateConnection } from "../../dal"
import {
FreeTextSearchFilterKey,
mikroOrmCreateConnection,
mikroOrmFreeTextSearchFilterOptionsFactory,
} from "../../dal"
import { loadDatabaseConfig } from "../load-module-database-config"
/**
@@ -17,6 +21,7 @@ import { loadDatabaseConfig } from "../load-module-database-config"
* @param moduleName
* @param container
* @param options
* @param filters
* @param moduleDeclaration
* @param entities
* @param pathToMigrations
@@ -39,6 +44,9 @@ export async function mikroOrmConnectionLoader({
logger?: Logger
pathToMigrations: string
}) {
const freeTextSearchGlobalFilter =
mikroOrmFreeTextSearchFilterOptionsFactory(entities)
let manager = (
options as ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
)?.manager
@@ -62,7 +70,12 @@ export async function mikroOrmConnectionLoader({
shouldSwallowError
)
return await loadShared({
database: dbConfig,
database: {
...dbConfig,
filters: {
[FreeTextSearchFilterKey as string]: freeTextSearchGlobalFilter,
},
},
container,
entities,
pathToMigrations,
@@ -87,7 +100,12 @@ export async function mikroOrmConnectionLoader({
}
manager ??= await loadDefault({
database: dbConfig,
database: {
...dbConfig,
filters: {
[FreeTextSearchFilterKey as string]: freeTextSearchGlobalFilter,
},
},
entities,
pathToMigrations,
})