Skip to content

Commit

Permalink
feat(core): add FindOptions.exclude (#5024)
Browse files Browse the repository at this point in the history
Adds the `exclude` option, which will omit the provided properties and
select everything else:

```ts
const author = await em.findOne(Author, '...', {
  exclude: ['email', 'books.price'],
  populate: ['books'], // unlike with `fields`, you need to explicitly populate the relation here
});
```
  • Loading branch information
B4nan committed Dec 17, 2023
1 parent e4967d1 commit fe239cf
Show file tree
Hide file tree
Showing 23 changed files with 642 additions and 183 deletions.
39 changes: 23 additions & 16 deletions docs/docs/entity-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,46 +246,53 @@ console.log(author.books[0].tags[0].isInitialized()); // true, because it was po

> This feature is fully available only for SQL drivers. In MongoDB always we need to query from the owning side - so in the example above, first load book tag by name, then associated book, then the author. Another option is to denormalize the schema.
### Fetching Partial Entities
### Partial loading

> This feature is supported only for `SELECT_IN` loading strategy.
When fetching single entity, we can choose to select only parts of an entity via `options.fields`:
To fetch only some database columns, you can use the `fields` option:

```ts
const author = await em.findOne(Author, '...', { fields: ['name', 'born'] });
const author = await em.findOne(Author, '...', {
fields: ['name', 'born'],
});
console.log(author.id); // PK is always selected
console.log(author.name); // Jon Snow
console.log(author.email); // undefined
```

From v4.4 it is also possible to specify fields for nested relations:
This works also for nested relations:

```ts
const author = await em.findOne(Author, '...', { fields: ['name', 'books.title', 'books.author', 'books.price'] });
const author = await em.findOne(Author, '...', {
fields: ['name', 'books.title', 'books.author', 'books.price'],
});
```

Or with an alternative object syntax:
Primary keys are always selected even if you omit them. On the other hand, you are responsible for selecting the foreign keys—if you omit such property, the relation might not be loaded properly. In the following example the books would not be linked the author, because you did not specify the `books.author` field to be loaded.

```ts
const author = await em.findOne(Author, '...', { fields: ['name', { books: ['title', 'author', 'price'] }] });
// this will load both author and book entities, but they won't be connected due to the missing FK in select
const author = await em.findOne(Author, '...', {
fields: ['name', 'books.title', 'books.price'],
});
```

It is also possible to use multiple levels:
> The Same problem can occur in mongo with M:N collections—those are stored as array property on the owning entity, so you need to make sure to mark such properties too.
```ts
const author = await em.findOne(Author, '...', { fields: ['name', { books: ['title', 'price', 'author', { author: ['email'] }] }] });
const author = await em.findOne(Author, '...', {
fields: ['name', 'books.title', 'books.author', 'books.price'],
});
```

Primary keys are always selected even if we omit them. On the other hand, we are responsible for selecting the FKs - if we omit such property, the relation might not be loaded properly. In the following example the books would not be linked the author, because we did not specify the `books.author` field to be loaded.
Alternatively, you can use the `exclude` option, which will omit the provided properties and select everything else:

```ts
// this will load both author and book entities, but they won't be connected due to the missing FK in select
const author = await em.findOne(Author, '...', { fields: ['name', { books: ['title', 'price'] });
const author = await em.findOne(Author, '...', {
exclude: ['email', 'books.price'],
populate: ['books'], // unlike with `fields`, you need to explicitly populate the relation here
});
```

> Same problem can occur in mongo with M:N collections - those are stored as array property on the owning entity, so we need to make sure to mark such properties too.
### Fetching Paginated Results

If we are going to paginate our results, we can use `em.findAndCount()` that will return total count of entities before applying limit and offset.
Expand Down
123 changes: 84 additions & 39 deletions packages/core/src/EntityManager.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/core/src/decorators/Entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@ export type EntityOptions<T> = {
virtual?: boolean;
// we need to use `em: any` here otherwise an expression would not be assignable with more narrow type like `SqlEntityManager`
// also return type is unknown as it can be either QB instance (which we cannot type here) or array of POJOs (e.g. for mongodb)
expression?: string | ((em: any, where: FilterQuery<T>, options: FindOptions<T, any, any>) => object);
expression?: string | ((em: any, where: FilterQuery<T>, options: FindOptions<T, any, any, any>) => object);
repository?: () => Constructor;
};
10 changes: 5 additions & 5 deletions packages/core/src/drivers/DatabaseDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ export abstract class DatabaseDriver<C extends Connection> implements IDatabaseD
this.logger = this.config.getLogger();
}

abstract find<T extends object, P extends string = never, F extends string = '*'>(entityName: string, where: FilterQuery<T>, options?: FindOptions<T, P, F>): Promise<EntityData<T>[]>;
abstract find<T extends object, P extends string = never, F extends string = '*', E extends string = never>(entityName: string, where: FilterQuery<T>, options?: FindOptions<T, P, F, E>): Promise<EntityData<T>[]>;

abstract findOne<T extends object, P extends string = never, F extends string = '*'>(entityName: string, where: FilterQuery<T>, options?: FindOneOptions<T, P, F>): Promise<EntityData<T> | null>;
abstract findOne<T extends object, P extends string = never, F extends string = '*', E extends string = never>(entityName: string, where: FilterQuery<T>, options?: FindOneOptions<T, P, F, E>): Promise<EntityData<T> | null>;

abstract nativeInsert<T extends object>(entityName: string, data: EntityDictionary<T>, options?: NativeInsertUpdateOptions<T>): Promise<QueryResult<T>>;

Expand All @@ -75,7 +75,7 @@ export abstract class DatabaseDriver<C extends Connection> implements IDatabaseD
}

/* istanbul ignore next */
async findVirtual<T extends object>(entityName: string, where: FilterQuery<T>, options: FindOptions<T, any, any>): Promise<EntityData<T>[]> {
async findVirtual<T extends object>(entityName: string, where: FilterQuery<T>, options: FindOptions<T, any, any, any>): Promise<EntityData<T>[]> {
throw new Error(`Virtual entities are not supported by ${this.constructor.name} driver.`);
}

Expand All @@ -88,7 +88,7 @@ export abstract class DatabaseDriver<C extends Connection> implements IDatabaseD
throw new Error(`Aggregations are not supported by ${this.constructor.name} driver`);
}

async loadFromPivotTable<T extends object, O extends object>(prop: EntityProperty, owners: Primary<O>[][], where?: FilterQuery<any>, orderBy?: OrderDefinition<T>, ctx?: Transaction, options?: FindOptions<T, any, any>, pivotJoin?: boolean): Promise<Dictionary<T[]>> {
async loadFromPivotTable<T extends object, O extends object>(prop: EntityProperty, owners: Primary<O>[][], where?: FilterQuery<any>, orderBy?: OrderDefinition<T>, ctx?: Transaction, options?: FindOptions<T, any, any, any>, pivotJoin?: boolean): Promise<Dictionary<T[]>> {
throw new Error(`${this.constructor.name} does not use pivot tables`);
}

Expand Down Expand Up @@ -171,7 +171,7 @@ export abstract class DatabaseDriver<C extends Connection> implements IDatabaseD
return this.dependencies;
}

protected processCursorOptions<T extends object, P extends string>(meta: EntityMetadata<T>, options: FindOptions<T, P, any>, orderBy: OrderDefinition<T>): { orderBy: OrderDefinition<T>[]; where: FilterQuery<T> } {
protected processCursorOptions<T extends object, P extends string>(meta: EntityMetadata<T>, options: FindOptions<T, P, any, any>, orderBy: OrderDefinition<T>): { orderBy: OrderDefinition<T>[]; where: FilterQuery<T> } {
const { first, last, before, after, overfetch } = options;
const limit = first || last;
const isLast = !first && !!last;
Expand Down
40 changes: 23 additions & 17 deletions packages/core/src/drivers/IDatabaseDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ export interface IDatabaseDriver<C extends Connection = Connection> {
/**
* Finds selection of entities
*/
find<T extends object, P extends string = never, F extends string = '*'>(entityName: string, where: FilterQuery<T>, options?: FindOptions<T, P, F>): Promise<EntityData<T>[]>;
find<T extends object, P extends string = never, F extends string = '*', E extends string = never>(entityName: string, where: FilterQuery<T>, options?: FindOptions<T, P, F, E>): Promise<EntityData<T>[]>;

/**
* Finds single entity (table row, document)
*/
findOne<T extends object, P extends string = never, F extends string = '*'>(entityName: string, where: FilterQuery<T>, options?: FindOneOptions<T, P, F>): Promise<EntityData<T> | null>;
findOne<T extends object, P extends string = never, F extends string = '*', E extends string = never>(entityName: string, where: FilterQuery<T>, options?: FindOneOptions<T, P, F, E>): Promise<EntityData<T> | null>;

findVirtual<T extends object>(entityName: string, where: FilterQuery<T>, options: FindOptions<T, any, any>): Promise<EntityData<T>[]>;
findVirtual<T extends object>(entityName: string, where: FilterQuery<T>, options: FindOptions<T, any, any, any>): Promise<EntityData<T>[]>;

nativeInsert<T extends object>(entityName: string, data: EntityDictionary<T>, options?: NativeInsertUpdateOptions<T>): Promise<QueryResult<T>>;

Expand All @@ -62,7 +62,7 @@ export interface IDatabaseDriver<C extends Connection = Connection> {
/**
* When driver uses pivot tables for M:N, this method will load identifiers for given collections from them
*/
loadFromPivotTable<T extends object, O extends object>(prop: EntityProperty, owners: Primary<O>[][], where?: FilterQuery<T>, orderBy?: OrderDefinition<T>, ctx?: Transaction, options?: FindOptions<T, any, any>, pivotJoin?: boolean): Promise<Dictionary<T[]>>;
loadFromPivotTable<T extends object, O extends object>(prop: EntityProperty, owners: Primary<O>[][], where?: FilterQuery<T>, orderBy?: OrderDefinition<T>, ctx?: Transaction, options?: FindOptions<T, any, any, any>, pivotJoin?: boolean): Promise<Dictionary<T[]>>;

getPlatform(): Platform;

Expand Down Expand Up @@ -94,25 +94,31 @@ export type EntityField<T, P extends string = '*'> = keyof T | '*' | AutoPath<T,

export type OrderDefinition<T> = (QueryOrderMap<T> & { 0?: never }) | QueryOrderMap<T>[];

export interface FindAllOptions<T, P extends string = never, F extends string = '*'> extends FindOptions<T, P, F> {
export interface FindAllOptions<T, P extends string = never, F extends string = '*', E extends string = never> extends FindOptions<T, P, F, E> {
where?: FilterQuery<T>;
}

export type FilterOptions = Dictionary<boolean | Dictionary> | string[] | boolean;

export interface FindOptions<T, P extends string = never, F extends string = '*'> {
populate?: Populate<T, P>;
populateWhere?: ObjectQuery<T> | PopulateHint | `${PopulateHint}`;
populateOrderBy?: OrderDefinition<T>;
fields?: readonly AutoPath<T, F, '*'>[];
orderBy?: OrderDefinition<T>;
export interface FindOptions<
Entity,
Hint extends string = never,
Fields extends string = '*',
Excludes extends string = never,
> {
populate?: Populate<Entity, Hint>;
populateWhere?: ObjectQuery<Entity> | PopulateHint | `${PopulateHint}`;
populateOrderBy?: OrderDefinition<Entity>;
fields?: readonly AutoPath<Entity, Fields, '*'>[];
exclude?: readonly AutoPath<Entity, Excludes>[];
orderBy?: OrderDefinition<Entity>;
cache?: boolean | number | [string, number];
limit?: number;
offset?: number;
/** Fetch items `before` this cursor. */
before?: string | { startCursor: string | null } | FilterObject<T>;
before?: string | { startCursor: string | null } | FilterObject<Entity>;
/** Fetch items `after` this cursor. */
after?: string | { endCursor: string | null } | FilterObject<T>;
after?: string | { endCursor: string | null } | FilterObject<Entity>;
/** Fetch `first` N items. */
first?: number;
/** Fetch `last` N items. */
Expand All @@ -126,7 +132,7 @@ export interface FindOptions<T, P extends string = never, F extends string = '*'
flags?: QueryFlag[];
/** sql only */
groupBy?: string | string[];
having?: QBFilterQuery<T>;
having?: QBFilterQuery<Entity>;
/** sql only */
strategy?: LoadStrategy | `${LoadStrategy}`;
flushMode?: FlushMode | `${FlushMode}`;
Expand All @@ -147,15 +153,15 @@ export interface FindOptions<T, P extends string = never, F extends string = '*'
logging?: LoggingOptions;
}

export interface FindByCursorOptions<T extends object, P extends string = never, F extends string = '*'> extends Omit<FindOptions<T, P, F>, 'limit' | 'offset'> {
export interface FindByCursorOptions<T extends object, P extends string = never, F extends string = '*', E extends string = never> extends Omit<FindOptions<T, P, F, E>, 'limit' | 'offset'> {
}

export interface FindOneOptions<T extends object, P extends string = never, F extends string = '*'> extends Omit<FindOptions<T, P, F>, 'limit' | 'lockMode'> {
export interface FindOneOptions<T extends object, P extends string = never, F extends string = '*', E extends string = never> extends Omit<FindOptions<T, P, F, E>, 'limit' | 'lockMode'> {
lockMode?: LockMode;
lockVersion?: number | Date;
}

export interface FindOneOrFailOptions<T extends object, P extends string = never, F extends string = '*'> extends FindOneOptions<T, P, F> {
export interface FindOneOrFailOptions<T extends object, P extends string = never, F extends string = '*', E extends string = never> extends FindOneOptions<T, P, F, E> {
failHandler?: (entityName: string, where: Dictionary | IPrimaryKey | any) => Error;
strict?: boolean;
}
Expand Down
15 changes: 11 additions & 4 deletions packages/core/src/entity/EntityLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ import type { Platform } from '../platforms/Platform';
import { helper } from './wrap';
import type { LoggingOptions } from '../logging/Logger';

export type EntityLoaderOptions<Entity, Fields extends string = '*'> = {
export type EntityLoaderOptions<Entity, Fields extends string = '*', Excludes extends string = never> = {
where?: FilterQuery<Entity>;
populateWhere?: PopulateHint | `${PopulateHint}`;
fields?: readonly EntityField<Entity, Fields>[];
exclude?: readonly EntityField<Entity, Excludes>[];
orderBy?: QueryOrderMap<Entity> | QueryOrderMap<Entity>[];
refresh?: boolean;
validate?: boolean;
Expand Down Expand Up @@ -86,6 +87,7 @@ export class EntityLoader {
const context = helper(e).__serializationContext;
context.populate ??= populate as PopulateOptions<Entity>[];
context.fields ??= options.fields ? [...options.fields as string[]] : undefined;
context.exclude ??= options.exclude ? [...options.exclude as string[]] : undefined;
visited.add(e);
});

Expand Down Expand Up @@ -345,6 +347,7 @@ export class EntityLoader {
filters, convertCustomTypes, lockMode, populateWhere, logging,
orderBy: [...Utils.asArray(options.orderBy), ...Utils.asArray(prop.orderBy)] as QueryOrderMap<Entity>[],
populate: populate.children as never ?? populate.all ?? [],
exclude: Array.isArray(options.exclude) ? Utils.extractChildElements(options.exclude, prop.name) as any : options.exclude,
strategy, fields, schema, connectionType,
// @ts-ignore not a public option, will be propagated to the populate call
refresh: refresh && !children.every(item => options.visited.has(item)),
Expand Down Expand Up @@ -410,12 +413,14 @@ export class EntityLoader {
.filter(orderBy => Utils.isObject(orderBy[prop.name]))
.map(orderBy => orderBy[prop.name]);
const { refresh, filters, ignoreLazyScalarProperties, populateWhere, connectionType, logging } = options;
const exclude = Array.isArray(options.exclude) ? Utils.extractChildElements(options.exclude, prop.name) as any : options.exclude;
const filtered = Utils.unique(children.filter(e => !(options as Dictionary).visited.has(e)));

await this.populate<Entity>(prop.type, filtered, populate.children ?? populate.all as any, {
where: await this.extractChildCondition(options, prop, false) as FilterQuery<Entity>,
orderBy: innerOrderBy as QueryOrderMap<Entity>[],
fields,
exclude,
validate: false,
lookup: false,
filters,
Expand All @@ -435,11 +440,13 @@ export class EntityLoader {
const refresh = options.refresh;
const where = await this.extractChildCondition(options, prop, true);
const fields = this.buildFields(options.fields, prop);
const options2 = { ...options } as unknown as FindOptions<Entity, any, any>;
const exclude = Array.isArray(options.exclude) ? Utils.extractChildElements(options.exclude, prop.name) : options.exclude;
const options2 = { ...options } as unknown as FindOptions<Entity, any, any, any>;
delete options2.limit;
delete options2.offset;
options2.fields = fields as any;
options2.populate = (populate?.children ?? []) as never;
options2.fields = fields;
options2.exclude = exclude;
options2.populate = (populate?.children ?? []);

if (prop.customType) {
ids.forEach((id, idx) => ids[idx] = QueryHelper.processCustomType<Entity>(prop, id as FilterQuery<Entity>, this.driver.getPlatform()) as Primary<Entity>[]);
Expand Down
Loading

0 comments on commit fe239cf

Please sign in to comment.