Skip to content

Commit

Permalink
feat(core): add support for filters/scopes (#663)
Browse files Browse the repository at this point in the history
Allows to define filters that will be automatically applied to the conditions.
Filter can be defined at the entity level, dynamically via EM or in the ORM
configuration.

```typescript
@entity()
@filter({ name: 'expensive', cond: { price: { $gt: 1000 } } })
@filter({ name: 'long', cond: { 'length(text)': { $gt: 10000 } } })
@filter({ name: 'hasAuthor', cond: { author: { $ne: null } }, default: true })
@filter({ name: 'writtenBy', cond: args => ({ author: { name: args.name } }) })
export class Book {
  ...
}

const books1 = await orm.em.find(Book, {}, {
  filters: ['long', 'expensive'],
});
const books2 = await orm.em.find(Book, {}, {
  filters: { hasAuthor: false, long: true, writtenBy: { name: 'God' } },
});
```

EM filter API:

```typescript
// bound to entity, enabled by default
em.addFilter('writtenBy', args => ({ author: args.id }), Book);

// global, enabled by default, for all entities
em.addFilter('tenant', args => { ... });

// global, enabled by default, for only specified entities
em.addFilter('tenant', args => { ... }, [Author, Book]);
...

// set params (probably in some middleware)
em.setFilterParams('tenant', { tenantId: 123 });
em.setFilterParams('writtenBy', { id: 321 });

...

// usage
em.find(Book, {}); // same as `{ tenantId: 123 }`
em.find(Book, {}, { filters: ['writtenBy'] }); // same as `{ author: 321, tenantId: 123 }`
em.find(Book, {}, { filters: { tenant: false } }); // disabled tenant filter, so truly `{}`
em.find(Book, {}, { filters: false }); // disabled all filters, so truly `{}`
```

Closes #385
  • Loading branch information
B4nan committed Aug 9, 2020
1 parent c01b338 commit c1025b9
Show file tree
Hide file tree
Showing 34 changed files with 622 additions and 113 deletions.
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -149,6 +149,7 @@ There is also auto-generated [CHANGELOG.md](CHANGELOG.md) file based on commit m
- [Transactions](https://mikro-orm.io/transactions/)
- [Cascading persist and remove](https://mikro-orm.io/cascading/)
- [Composite and Foreign Keys as Primary Key](https://mikro-orm.io/composite-keys/)
- [Filters](https://mikro-orm.io/filters/)
- [Using `QueryBuilder`](https://mikro-orm.io/query-builder/)
- [Preloading Deeply Nested Structures via populate](https://mikro-orm.io/nested-populate/)
- [Property Validation](https://mikro-orm.io/property-validation/)
Expand Down
144 changes: 144 additions & 0 deletions docs/docs/filters.md
@@ -0,0 +1,144 @@
---
title: Filters
---

MikroORM has the ability to pre-define filter criteria and attach those filters
to given entities. The application can then decide at runtime whether certain
filters should be enabled and what their parameter values should be. Filters
can be used like database views, but they are parameterized inside the application.

> Filter can be defined at the entity level, dynamically via EM (global filters)
> or in the ORM configuration.
Filters are applied to those methods of `EntityManager`: `find()`, `findOne()`,
`findAndCount()`, `findOneOrFail()`, `count()`, `nativeUpdate()` and `nativeDelete()`.

> The `cond` parameter can be a callback, possibly asynchronous.
```typescript
@Entity()
@Filter({ name: 'expensive', cond: { price: { $gt: 1000 } } })
@Filter({ name: 'long', cond: { 'length(text)': { $gt: 10000 } } })
@Filter({ name: 'hasAuthor', cond: { author: { $ne: null } }, default: true })
@Filter({ name: 'writtenBy', cond: args => ({ author: { name: args.name } }) })
export class Book {
...
}

const books1 = await orm.em.find(Book, {}, {
filters: ['long', 'expensive'],
});
const books2 = await orm.em.find(Book, {}, {
filters: { hasAuthor: false, long: true, writtenBy: { name: 'God' } },
});
```

## Parameters

You can define the `cond` dynamically as a callback. This callback can be also
asynchronous. It will get two arguments:

- `args` - dictionary of parameters provided by user
- `type` - type of operation that is being filtered, one of `'read'`, `'update'`, `'delete'`

```typescript
@Entity()
@Filter({ name: 'writtenBy', cond: async (args, type) => {
if (type === 'update') {
return {}; // do not apply when updating
}

return { author: { name: args.name } };
} })
export class Book {
...
}

const books = await orm.em.find(Book, {}, {
filters: { writtenBy: { name: 'God' } },
});
```

## Global filters

We can also register filters dynamically via `EntityManager` API. We call such filters
global. They are enabled by default (unless disabled via last parameter in `addFilter()`
method), and applied to all entities. You can limit the global filter to only specified
entities.

> Filters as well as filter params set on the EM will be copied to all its forks.
```typescript
// bound to entity, enabled by default
em.addFilter('writtenBy', args => ({ author: args.id }), Book);

// global, enabled by default, for all entities
em.addFilter('tenant', args => { ... });

// global, enabled by default, for only specified entities
em.addFilter('tenant', args => { ... }, [Author, Book]);
...

// set params (probably in some middleware)
em.setFilterParams('tenant', { tenantId: 123 });
em.setFilterParams('writtenBy', { id: 321 });
```

Global filters can be also registered via ORM configuration:

```typescript
MikroORM.init({
filters: { tenant: { cond: args => ({ tenant: args.tenant }), entity: ['Author', 'User'] } },
...
})
```

## Using filters

We can control what filters will be applied via `filter` parameter in `FindOptions`.
We can either provide an array of names of filters you want to enable, or options
object, where we can also disable a filter (that was enabled by default), or pass some
parameters to those that are expecting them.

> By passing `filters: false` we can also disable all the filters for given call.
```typescript
em.find(Book, {}); // same as `{ tenantId: 123 }`
em.find(Book, {}, { filters: ['writtenBy'] }); // same as `{ author: 321, tenantId: 123 }`
em.find(Book, {}, { filters: { tenant: false } }); // disabled tenant filter, so truly `{}`
em.find(Book, {}, { filters: false }); // disabled all filters, so truly `{}`
```

## Filters and populating of relationships

When populating relationships, filters will be applied only to the root entity of
given query, but not to those that are auto-joined. On the other hand, this means that
when you use the default loading strategy - `LoadStrategy.SELECT_IN` - filters will
be applied to every entity populated this way, as the child entities will become
root entities in their respective load calls.

## Naming of filters

When toggling filters via `FindOptions`, we do not care about the entity name. This
means that when you have multiple filters defined on different entities, but with
the same name, they will be controlled via single toggle in the `FindOptions`.

```typescript
@Entity()
@Filter({ name: 'tenant', cond: args => ({ tenant: args.tenant }) })
export class Author {
...
}

@Entity()
@Filter({ name: 'tenant', cond: args => ({ tenant: args.tenant }) })
export class Book {
...
}

// this will apply the tenant filter to both Author and Book entities (with SELECT_IN loading strategy)
const authors = await orm.em.find(Author, {}, {
populate: ['books'],
filters: { tenant: 123 },
});
```
1 change: 1 addition & 0 deletions docs/docs/index.md
Expand Up @@ -20,6 +20,7 @@ hide_title: true
- [Unit of Work](unit-of-work.md)
- [Transactions](transactions.md)
- [Cascading persist and remove](cascading.md)
- [Filters](filters.md)
- [Deployment](deployment.md)
- Advanced Features
- [Smart Nested Populate](nested-populate.md)
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/query-builder.md
Expand Up @@ -154,7 +154,7 @@ console.log(qb.getQuery());
```

This is currently available only for filtering (`where`) and sorting (`orderBy`), only
the root entity will be selected. To populate its relationships, you can use [EntityLoader](nested-populate.md).
the root entity will be selected. To populate its relationships, you can use [`em.populate()`](nested-populate.md).

## Explicit Joining

Expand Down
1 change: 1 addition & 0 deletions docs/sidebars.js
Expand Up @@ -16,6 +16,7 @@ module.exports = {
'transactions',
'cascading',
'composite-keys',
'filters',
'deployment',
'decorators',
],
Expand Down
111 changes: 90 additions & 21 deletions packages/core/src/EntityManager.ts
@@ -1,11 +1,11 @@
import { v4 as uuid } from 'uuid';
import { inspect } from 'util';

import { Configuration, RequestContext, SmartQueryHelper, Utils, ValidationError } from './utils';
import { Configuration, RequestContext, QueryHelper, Utils, ValidationError } from './utils';
import { EntityAssigner, EntityFactory, EntityLoader, EntityRepository, EntityValidator, IdentifiedReference, LoadStrategy, Reference, ReferenceType, wrap } from './entity';
import { LockMode, UnitOfWork } from './unit-of-work';
import { EntityManagerType, FindOneOptions, FindOptions, IDatabaseDriver, Populate, PopulateMap, PopulateOptions } from './drivers';
import { AnyEntity, Constructor, Dictionary, EntityData, EntityMetadata, EntityName, FilterQuery, IPrimaryKey, Primary } from './typings';
import { CountOptions, DeleteOptions, EntityManagerType, FindOneOptions, FindOptions, IDatabaseDriver, Populate, PopulateMap, PopulateOptions, UpdateOptions } from './drivers';
import { AnyEntity, Dictionary, EntityData, EntityMetadata, EntityName, FilterDef, FilterQuery, IPrimaryKey, Primary } from './typings';
import { QueryOrderMap } from './enums';
import { MetadataStorage } from './metadata';
import { Transaction } from './connections';
Expand All @@ -24,6 +24,8 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
private readonly unitOfWork = new UnitOfWork(this);
private readonly entityFactory = new EntityFactory(this.unitOfWork, this);
private readonly eventManager = new EventManager(this.config.get('subscribers'));
private filters: Dictionary<FilterDef<any>> = {};
private filterParams: Dictionary<Dictionary> = {};
private transactionContext?: Transaction;

constructor(readonly config: Configuration,
Expand Down Expand Up @@ -81,10 +83,11 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
* Finds all entities matching your `where` query.
*/
async find<T>(entityName: EntityName<T>, where: FilterQuery<T>, populate?: Populate<T> | FindOptions<T>, orderBy?: QueryOrderMap, limit?: number, offset?: number): Promise<T[]> {
const options = Utils.isObject<FindOptions<T>>(populate) ? populate : { populate, orderBy, limit, offset };
entityName = Utils.className(entityName);
where = SmartQueryHelper.processWhere(where, entityName, this.metadata);
where = QueryHelper.processWhere(where, entityName, this.metadata);
where = await this.applyFilters(entityName, where, options.filters ?? {}, 'read');
this.validator.validateParams(where);
const options = Utils.isObject<FindOptions<T>>(populate) ? populate : { populate, orderBy, limit, offset };
options.orderBy = options.orderBy || {};
options.populate = this.preparePopulate<T>(entityName, options.populate, options.strategy);
const results = await this.driver.find<T>(entityName, where, options, this.transactionContext);
Expand All @@ -101,11 +104,71 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
}

const unique = Utils.unique(ret);
await this.entityLoader.populate<T>(entityName, unique, options.populate as PopulateOptions<T>[], where, options.orderBy, options.refresh);
await this.entityLoader.populate<T>(entityName, unique, options.populate as PopulateOptions<T>[], { ...options, where });

return unique;
}

addFilter<T1>(name: string, cond: FilterQuery<T1> | ((args: Dictionary) => FilterQuery<T1>), entityName?: EntityName<T1> | [EntityName<T1>], enabled?: boolean): void;
addFilter<T1, T2>(name: string, cond: FilterQuery<T1 | T2> | ((args: Dictionary) => FilterQuery<T1 | T2>), entityName?: [EntityName<T1>, EntityName<T2>], enabled?: boolean): void;
addFilter<T1, T2, T3>(name: string, cond: FilterQuery<T1 | T2 | T3> | ((args: Dictionary) => FilterQuery<T1 | T2 | T3>), entityName?: [EntityName<T1>, EntityName<T2>, EntityName<T3>], enabled?: boolean): void;
addFilter(name: string, cond: FilterQuery<AnyEntity> | ((args: Dictionary) => FilterQuery<AnyEntity>), entityName?: EntityName<AnyEntity> | EntityName<AnyEntity>[], enabled = true): void {
const options: FilterDef<AnyEntity> = { name, cond, default: enabled };

if (entityName) {
options.entity = Utils.asArray(entityName).map(n => Utils.className(n));
}

this.filters[name] = options;
}

setFilterParams(name: string, args: Dictionary): void {
this.filterParams[name] = args;
}

protected async applyFilters<T>(entityName: string, where: FilterQuery<T>, options: Dictionary<boolean | Dictionary> | string[] | boolean, type: 'read' | 'update' | 'delete'): Promise<FilterQuery<T>> {
const meta = this.metadata.get<T>(entityName, false, false);
const filters: FilterDef<any>[] = [];
const ret = {};

if (!meta) {
return where;
}

filters.push(...QueryHelper.getActiveFilters(entityName, options, this.config.get('filters')));
filters.push(...QueryHelper.getActiveFilters(entityName, options, this.filters));
filters.push(...QueryHelper.getActiveFilters(entityName, options, meta.filters));

if (filters.length === 0) {
return where;
}

if (Utils.isPrimaryKey(where) && meta.primaryKeys.length === 1) {
where = { [meta.primaryKeys[0]]: where } as FilterQuery<T>;
}

for (const filter of filters) {
let cond: Dictionary;

if (filter.cond instanceof Function) {
const args = Utils.isPlainObject(options[filter.name]) ? options[filter.name] : this.filterParams[filter.name];

if (!args) {
throw new Error(`No arguments provided for filter '${filter.name}'`);
}

cond = await filter.cond(args, type);
} else {
cond = filter.cond;
}

const cond2 = QueryHelper.processWhere(cond, entityName, this.metadata);
Utils.merge(ret, cond2, where);
}

return Object.assign(where, ret);
}

/**
* Calls `em.find()` and `em.count()` with the same arguments (where applicable) and returns the results as tuple
* where first element is the array of entities and the second is the count.
Expand Down Expand Up @@ -148,8 +211,9 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
entityName = Utils.className(entityName);
const options = Utils.isObject<FindOneOptions<T>>(populate) ? populate : { populate, orderBy };
const meta = this.metadata.get<T>(entityName);
where = QueryHelper.processWhere(where as FilterQuery<T>, entityName, this.metadata);
where = await this.applyFilters(entityName, where, options.filters ?? {}, 'read');
this.validator.validateEmptyWhere(where);
where = SmartQueryHelper.processWhere(where as FilterQuery<T>, entityName, this.metadata);
this.checkLockRequirements(options.lockMode, meta);
let entity = this.getUnitOfWork().tryGetById<T>(entityName, where);
const isOptimisticLocking = !Utils.isDefined(options.lockMode) || options.lockMode === LockMode.OPTIMISTIC;
Expand Down Expand Up @@ -230,7 +294,7 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
*/
async nativeInsert<T>(entityName: EntityName<T>, data: EntityData<T>): Promise<Primary<T>> {
entityName = Utils.className(entityName);
data = SmartQueryHelper.processParams(data);
data = QueryHelper.processParams(data);
this.validator.validateParams(data, 'insert data');
const res = await this.driver.nativeInsert(entityName, data, this.transactionContext);

Expand All @@ -240,10 +304,11 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
/**
* Fires native update query. Calling this has no side effects on the context (identity map).
*/
async nativeUpdate<T>(entityName: EntityName<T>, where: FilterQuery<T>, data: EntityData<T>): Promise<number> {
async nativeUpdate<T>(entityName: EntityName<T>, where: FilterQuery<T>, data: EntityData<T>, options: UpdateOptions<T> = {}): Promise<number> {
entityName = Utils.className(entityName);
data = SmartQueryHelper.processParams(data);
where = SmartQueryHelper.processWhere(where as FilterQuery<T>, entityName, this.metadata);
data = QueryHelper.processParams(data);
where = QueryHelper.processWhere(where as FilterQuery<T>, entityName, this.metadata);
where = await this.applyFilters(entityName, where, options.filters ?? {}, 'update');
this.validator.validateParams(data, 'update data');
this.validator.validateParams(where, 'update condition');
const res = await this.driver.nativeUpdate(entityName, where, data, this.transactionContext);
Expand All @@ -254,9 +319,10 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
/**
* Fires native delete query. Calling this has no side effects on the context (identity map).
*/
async nativeDelete<T>(entityName: EntityName<T>, where: FilterQuery<T>): Promise<number> {
async nativeDelete<T>(entityName: EntityName<T>, where: FilterQuery<T>, options: DeleteOptions<T> = {}): Promise<number> {
entityName = Utils.className(entityName);
where = SmartQueryHelper.processWhere(where as FilterQuery<T>, entityName, this.metadata);
where = QueryHelper.processWhere(where as FilterQuery<T>, entityName, this.metadata);
where = await this.applyFilters(entityName, where, options.filters ?? {}, 'delete');
this.validator.validateParams(where, 'delete condition');
const res = await this.driver.nativeDelete(entityName, where, this.transactionContext);

Expand Down Expand Up @@ -367,9 +433,10 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
/**
* Returns total number of entities matching your `where` query.
*/
async count<T>(entityName: EntityName<T>, where: FilterQuery<T> = {}): Promise<number> {
async count<T>(entityName: EntityName<T>, where: FilterQuery<T> = {}, options: CountOptions<T> = {}): Promise<number> {
entityName = Utils.className(entityName);
where = SmartQueryHelper.processWhere(where, entityName, this.metadata);
where = QueryHelper.processWhere(where, entityName, this.metadata);
where = await this.applyFilters(entityName, where, options.filters ?? {}, 'read');
this.validator.validateParams(where);

return this.driver.count(entityName, where, this.transactionContext);
Expand Down Expand Up @@ -480,9 +547,9 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
return ret;
}

async populate<T>(entities: T, populate: string | Populate<T>, where?: FilterQuery<T>, orderBy?: QueryOrderMap, refresh?: boolean, validate?: boolean): Promise<T>;
async populate<T>(entities: T[], populate: string | Populate<T>, where?: FilterQuery<T>, orderBy?: QueryOrderMap, refresh?: boolean, validate?: boolean): Promise<T[]>;
async populate<T>(entities: T | T[], populate: string | Populate<T>, where: FilterQuery<T> = {}, orderBy: QueryOrderMap = {}, refresh = false, validate = true): Promise<T | T[]> {
async populate<T extends AnyEntity<T>>(entities: T, populate: string | Populate<T>, where?: FilterQuery<T>, orderBy?: QueryOrderMap, refresh?: boolean, validate?: boolean): Promise<T>;
async populate<T extends AnyEntity<T>>(entities: T[], populate: string | Populate<T>, where?: FilterQuery<T>, orderBy?: QueryOrderMap, refresh?: boolean, validate?: boolean): Promise<T[]>;
async populate<T extends AnyEntity<T>>(entities: T | T[], populate: string | Populate<T>, where: FilterQuery<T> = {}, orderBy: QueryOrderMap = {}, refresh = false, validate = true): Promise<T | T[]> {
const entitiesArray = Utils.asArray(entities);

if (entitiesArray.length === 0) {
Expand All @@ -491,9 +558,9 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {

populate = Utils.isString(populate) ? Utils.asArray(populate) : populate;

const entityName = (entitiesArray[0] as unknown as Constructor<any>).constructor.name;
const entityName = entitiesArray[0].constructor.name;
const preparedPopulate = this.preparePopulate<T>(entityName, populate);
await this.entityLoader.populate(entityName, entitiesArray, preparedPopulate, where, orderBy, refresh, validate);
await this.entityLoader.populate(entityName, entitiesArray, preparedPopulate, { where, orderBy, refresh, validate });

return entities;
}
Expand All @@ -506,6 +573,8 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
*/
fork(clear = true, useContext = false): D[typeof EntityManagerType] {
const em = new (this.constructor as typeof EntityManager)(this.config, this.driver, this.metadata, useContext);
em.filters = { ...this.filters };
em.filterParams = Utils.copy(this.filterParams);

if (!clear) {
Object.values(this.getUnitOfWork().getIdentityMap()).forEach(entity => em.merge(entity));
Expand Down Expand Up @@ -575,7 +644,7 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
}

const preparedPopulate = this.preparePopulate(entityName, options.populate, options.strategy);
await this.entityLoader.populate(entityName, [entity], preparedPopulate, where, options.orderBy || {}, options.refresh);
await this.entityLoader.populate(entityName, [entity], preparedPopulate, { ...options, where });

return entity;
}
Expand Down

0 comments on commit c1025b9

Please sign in to comment.