diff --git a/packages/query-mikro-orm/__tests__/query/build-mikro-orm-query.spec.ts b/packages/query-mikro-orm/__tests__/query/build-mikro-orm-query.spec.ts new file mode 100644 index 000000000..fc403b46b --- /dev/null +++ b/packages/query-mikro-orm/__tests__/query/build-mikro-orm-query.spec.ts @@ -0,0 +1,107 @@ +import { buildMikroOrmQuery } from '../../src' + +interface E { + id: string + name: string + age: number + active: boolean +} + +describe('buildMikroOrmQuery', () => { + it('returns empty object for undefined filter', () => { + expect(buildMikroOrmQuery(undefined)).toEqual({}) + }) + + it('returns empty object for empty filter', () => { + expect(buildMikroOrmQuery({})).toEqual({}) + }) + + it('translates eq', () => { + expect(buildMikroOrmQuery({ name: { eq: 'foo' } })).toEqual({ name: { $eq: 'foo' } }) + }) + + it('translates is', () => { + expect(buildMikroOrmQuery({ active: { is: true } })).toEqual({ active: { $eq: true } }) + }) + + it('translates neq', () => { + expect(buildMikroOrmQuery({ name: { neq: 'foo' } })).toEqual({ name: { $ne: 'foo' } }) + }) + + it('translates isNot', () => { + expect(buildMikroOrmQuery({ active: { isNot: null } })).toEqual({ active: { $ne: null } }) + }) + + it('translates gt/gte/lt/lte', () => { + expect(buildMikroOrmQuery({ age: { gt: 1, gte: 2, lt: 10, lte: 9 } })).toEqual({ + age: { $gt: 1, $gte: 2, $lt: 10, $lte: 9 } + }) + }) + + it('translates in/notIn', () => { + expect(buildMikroOrmQuery({ id: { in: ['a', 'b'], notIn: ['c'] } })).toEqual({ + id: { $in: ['a', 'b'], $nin: ['c'] } + }) + }) + + it('translates like/notLike', () => { + expect(buildMikroOrmQuery({ name: { like: 'a%', notLike: 'b%' } })).toEqual({ + name: { $like: 'a%', $not: { $like: 'b%' } } + }) + }) + + it('translates iLike/notILike', () => { + expect(buildMikroOrmQuery({ name: { iLike: 'a%', notILike: 'b%' } })).toEqual({ + name: { $ilike: 'a%', $not: { $ilike: 'b%' } } + }) + }) + + it('translates and', () => { + expect( + buildMikroOrmQuery({ + and: [{ age: { gt: 3 } }, { age: { lt: 7 } }] + }) + ).toEqual({ + $and: [{ age: { $gt: 3 } }, { age: { $lt: 7 } }] + }) + }) + + it('translates or', () => { + expect( + buildMikroOrmQuery({ + or: [{ age: { eq: 1 } }, { age: { eq: 10 } }] + }) + ).toEqual({ + $or: [{ age: { $eq: 1 } }, { age: { $eq: 10 } }] + }) + }) + + it('translates nested and/or', () => { + expect( + buildMikroOrmQuery({ + or: [{ and: [{ age: { gt: 1 } }, { age: { lt: 5 } }] }, { name: { eq: 'foo' } }] + }) + ).toEqual({ + $or: [{ $and: [{ age: { $gt: 1 } }, { age: { $lt: 5 } }] }, { name: { $eq: 'foo' } }] + }) + }) + + it('throws when and/or is mixed with field comparisons', () => { + expect(() => + buildMikroOrmQuery({ + and: [{ age: { gt: 1 } }], + name: { eq: 'foo' } + } as never) + ).toThrow('filter must contain either only `and` or `or` property, or other properties') + }) + + it('passes through unknown comparison keys recursively', () => { + expect( + buildMikroOrmQuery({ + relation: { nested: { eq: 1 } } + } as never) + ).toEqual({ + relation: { nested: { $eq: 1 } } + }) + }) +}) diff --git a/packages/query-mikro-orm/src/index.ts b/packages/query-mikro-orm/src/index.ts index add1c7b83..e662eb461 100644 --- a/packages/query-mikro-orm/src/index.ts +++ b/packages/query-mikro-orm/src/index.ts @@ -1,4 +1,5 @@ export { MikroOrmAssembler } from './assemblers' export { NestjsQueryMikroOrmModule } from './module' export { createMikroOrmQueryServiceProviders, EntityServiceOptions } from './providers' +export { buildMikroOrmQuery } from './query' export { MikroOrmQueryService } from './services' diff --git a/packages/query-mikro-orm/src/query/build-mikro-orm-query.ts b/packages/query-mikro-orm/src/query/build-mikro-orm-query.ts new file mode 100644 index 000000000..f2bcfba1a --- /dev/null +++ b/packages/query-mikro-orm/src/query/build-mikro-orm-query.ts @@ -0,0 +1,95 @@ +import { FilterQuery } from '@mikro-orm/core' +import { OperatorMap } from '@mikro-orm/core/typings' +import { Filter, FilterComparisons } from '@ptc-org/nestjs-query-core' + +function expandFilterComparison(k: string, v: unknown): [string, unknown] { + if (k === 'eq' || k === 'is') { + return ['$eq', v as string] satisfies ['$eq', OperatorMap['$eq']] + } + + if (k === 'neq' || k === 'isNot') { + return ['$ne', v as string] satisfies ['$ne', OperatorMap['$ne']] + } + + if (k === 'gt') { + return ['$gt', v as string] satisfies ['$gt', OperatorMap['$gt']] + } + + if (k === 'gte') { + return ['$gte', v as string] satisfies ['$gte', OperatorMap['$gte']] + } + + if (k === 'lt') { + return ['$lt', v as string] satisfies ['$lt', OperatorMap['$lt']] + } + + if (k === 'lte') { + return ['$lte', v as string] satisfies ['$lte', OperatorMap['$lte']] + } + + if (k === 'in') { + return ['$in', v as string[]] satisfies ['$in', OperatorMap['$in']] + } + + if (k === 'notIn') { + return ['$nin', v as string[]] satisfies ['$nin', OperatorMap['$nin']] + } + + if (k === 'like') { + return ['$like', v as string] satisfies ['$like', OperatorMap['$like']] + } + + if (k === 'notLike') { + return ['$not', { $like: v as string }] + } + + if (k === 'iLike') { + return ['$ilike', v as string] satisfies ['$ilike', OperatorMap['$ilike']] + } + + if (k === 'notILike') { + return ['$not', { $ilike: v as string }] + } + + // eslint-disable-next-line @typescript-eslint/no-use-before-define + return [k, expandFilter(v as FilterComparisons)] +} + +function expandFilter(comparisons: FilterComparisons): FilterQuery { + const entries = Object.entries(comparisons).map(([k, v]) => expandFilterComparison(k, v)) + return Object.fromEntries(entries) as FilterQuery +} + +/** + * Translate a nestjs-query `Filter` into a MikroORM `FilterQuery`. + * + * Pure function — no entity metadata, no repository, no assembler. + * The input is expected to already reference entity fields (run any DTO→Entity + * conversion such as an assembler's `convertQuery` before calling this). + * + * Consumers outside nestjs-query resolvers (custom resolvers, domain services) + * use this to reuse the same filter dialect the `@Authorize` pipeline produces. + */ +export function buildMikroOrmQuery(filter: Filter | undefined): FilterQuery { + if (!filter) { + return {} as FilterQuery + } + + if ((filter.and || filter.or) && Object.keys(filter).length > 1) { + throw new Error('filter must contain either only `and` or `or` property, or other properties') + } + + if (filter.and) { + return { + $and: filter.and.map((f) => buildMikroOrmQuery(f)) + } as FilterQuery + } + + if (filter.or) { + return { + $or: filter.or.map((f) => buildMikroOrmQuery(f)) + } as FilterQuery + } + + return expandFilter(filter as FilterComparisons) +} diff --git a/packages/query-mikro-orm/src/query/index.ts b/packages/query-mikro-orm/src/query/index.ts new file mode 100644 index 000000000..34cd0ab8d --- /dev/null +++ b/packages/query-mikro-orm/src/query/index.ts @@ -0,0 +1 @@ +export { buildMikroOrmQuery } from './build-mikro-orm-query' diff --git a/packages/query-mikro-orm/src/services/mikro-orm-query.service.ts b/packages/query-mikro-orm/src/services/mikro-orm-query.service.ts index aff81d13c..affa12fcf 100644 --- a/packages/query-mikro-orm/src/services/mikro-orm-query.service.ts +++ b/packages/query-mikro-orm/src/services/mikro-orm-query.service.ts @@ -1,15 +1,4 @@ -import { - Collection, - EntityData, - EntityKey, - EntityRepository, - FilterQuery, - QueryOrder, - QueryOrderMap, - Reference, - wrap -} from '@mikro-orm/core' -import { OperatorMap } from '@mikro-orm/core/typings' +import { Collection, EntityKey, EntityRepository, FilterQuery, QueryOrder, QueryOrderMap, Reference, wrap } from '@mikro-orm/core' import { AggregateOptions, AggregateQuery, @@ -23,7 +12,6 @@ import { DeleteManyResponse, DeleteOneOptions, Filter, - FilterComparisons, FindByIdOptions, FindRelationOptions, GetByIdOptions, @@ -38,6 +26,8 @@ import { UpdateOneOptions } from '@ptc-org/nestjs-query-core' +import { buildMikroOrmQuery } from '../query' + export class MikroOrmQueryService extends NoOpQueryService { constructor( protected repo: EntityRepository, @@ -352,84 +342,7 @@ export class MikroOrmQueryService)?.filter ?? filter - - if ((convertedFilter?.and || convertedFilter?.or) && Object.keys(convertedFilter).length > 1) { - throw new Error('filter must contain either only `and` or `or` property, or other properties') - } - - if (convertedFilter?.and) { - return { - $and: convertedFilter.and.map((f) => this.convertFilter(f as Filter | Filter)) - } as FilterQuery - } - - if (convertedFilter?.or) { - return { - $or: convertedFilter.or.map((f) => this.convertFilter(f as Filter | Filter)) - } as FilterQuery - } - - return this.expandFilter(convertedFilter) - } - - protected expandFilter(comparisons: FilterComparisons): FilterQuery { - const filters = Object.entries(comparisons).map(([k, v]) => { - return this.expandFilterComparison(k, v) - }) - - return Object.fromEntries(filters) as FilterQuery - } - - protected expandFilterComparison(k: string, v: unknown): [string, unknown] { - if (k === 'eq' || k === 'is') { - return ['$eq', v as string] satisfies ['$eq', OperatorMap['$eq']] - } - - if (k === 'neq' || k === 'isNot') { - return ['$ne', v as string] satisfies ['$ne', OperatorMap['$ne']] - } - - if (k === 'gt') { - return ['$gt', v as string] satisfies ['$gt', OperatorMap['$gt']] - } - - if (k === 'gte') { - return ['$gte', v as string] satisfies ['$gte', OperatorMap['$gte']] - } - - if (k === 'lt') { - return ['$lt', v as string] satisfies ['$lt', OperatorMap['$lt']] - } - - if (k === 'lte') { - return ['$lte', v as string] satisfies ['$lte', OperatorMap['$lte']] - } - - if (k === 'in') { - return ['$in', v as string[]] satisfies ['$in', OperatorMap['$in']] - } - - if (k === 'notIn') { - return ['$nin', v as string[]] satisfies ['$nin', OperatorMap['$nin']] - } - - if (k === 'like') { - return ['$like', v as string] satisfies ['$like', OperatorMap['$like']] - } - - if (k === 'notLike') { - return ['$not', { $like: v as string }] - } - - if (k === 'iLike') { - return ['$ilike', v as string] satisfies ['$ilike', OperatorMap['$ilike']] - } - - if (k === 'notILike') { - return ['$not', { $ilike: v as string }] - } - - return [k, this.expandFilter(v as FilterComparisons)] + return buildMikroOrmQuery(convertedFilter as Filter) } async findRelation(