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,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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user