UNPKG

@loopback/repository

Version:

Define and implement a common set of interfaces for interacting with databases

857 lines (799 loc) 26.8 kB
// Copyright IBM Corp. and LoopBack contributors 2018,2020. All Rights Reserved. // Node module: @loopback/repository // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT import {Getter} from '@loopback/core'; import { Filter, FilterExcludingWhere, InclusionFilter, Where, } from '@loopback/filter'; import assert from 'assert'; import legacy from 'loopback-datasource-juggler'; import { AnyObject, Command, Count, DataObject, DeepPartial, NamedParameters, Options, PositionalParameters, } from '../common-types'; import {EntityNotFoundError, InvalidBodyError} from '../errors'; import { Entity, Model, PropertyType, rejectNavigationalPropertiesInData, } from '../model'; import { BelongsToAccessor, BelongsToDefinition, HasManyDefinition, HasManyRepositoryFactory, HasManyThroughRepositoryFactory, HasOneDefinition, HasOneRepositoryFactory, InclusionResolver, ReferencesManyAccessor, ReferencesManyDefinition, createBelongsToAccessor, createHasManyRepositoryFactory, createHasManyThroughRepositoryFactory, createHasOneRepositoryFactory, createReferencesManyAccessor, includeRelatedModels, } from '../relations'; import {IsolationLevel, Transaction} from '../transaction'; import {isTypeResolver, resolveType} from '../type-resolver'; import { EntityCrudRepository, TransactionalEntityRepository, } from './repository'; export namespace juggler { export import DataSource = legacy.DataSource; export import ModelBase = legacy.ModelBase; export import ModelBaseClass = legacy.ModelBaseClass; export import PersistedModel = legacy.PersistedModel; export import KeyValueModel = legacy.KeyValueModel; export import PersistedModelClass = legacy.PersistedModelClass; // eslint-disable-next-line @typescript-eslint/no-shadow export import Transaction = legacy.Transaction; // eslint-disable-next-line @typescript-eslint/no-shadow export import IsolationLevel = legacy.IsolationLevel; } function isModelClass( propertyType: PropertyType | undefined, ): propertyType is typeof Model { return ( !isTypeResolver(propertyType) && typeof propertyType === 'function' && typeof (propertyType as typeof Model).definition === 'object' && propertyType.toString().startsWith('class ') ); } /** * This is a bridge to the legacy DAO class. The function mixes DAO methods * into a model class and attach it to a given data source * @param modelClass - Model class * @param ds - Data source * @returns {} The new model class with DAO (CRUD) operations */ export function bindModel<T extends juggler.ModelBaseClass>( modelClass: T, ds: juggler.DataSource, ): T { const BoundModelClass = class extends modelClass {}; BoundModelClass.attachTo(ds); return BoundModelClass; } /** * Ensure the value is a promise * @param p - Promise or void */ export function ensurePromise<T>(p: legacy.PromiseOrVoid<T>): Promise<T> { if (p && p instanceof Promise) { return p; } else { return Promise.reject(new Error('The value should be a Promise: ' + p)); } } /** * Default implementation of CRUD repository using legacy juggler model * and data source */ export class DefaultCrudRepository< T extends Entity, ID, Relations extends object = {}, > implements EntityCrudRepository<T, ID, Relations> { modelClass: juggler.PersistedModelClass; public readonly inclusionResolvers: Map< string, InclusionResolver<T, Entity> > = new Map(); /** * Constructor of DefaultCrudRepository * @param entityClass - LoopBack 4 entity class * @param dataSource - Legacy juggler data source */ constructor( // entityClass should have type "typeof T", but that's not supported by TSC public entityClass: typeof Entity & {prototype: T}, public dataSource: juggler.DataSource, ) { const definition = entityClass.definition; assert( !!definition, `Entity ${entityClass.name} must have valid model definition.`, ); assert( definition.idProperties().length > 0, `Entity ${entityClass.name} must have at least one id/pk property.`, ); this.modelClass = this.ensurePersistedModel(entityClass); } // Create an internal legacy Model attached to the datasource private ensurePersistedModel( entityClass: typeof Model, ): typeof juggler.PersistedModel { const definition = entityClass.definition; assert( !!definition, `Entity ${entityClass.name} must have valid model definition.`, ); const dataSource = this.dataSource; const model = dataSource.getModel(definition.name); if (model) { // The backing persisted model has been already defined. return model as typeof juggler.PersistedModel; } return this.definePersistedModel(entityClass); } /** * Creates a legacy persisted model class, attaches it to the datasource and * returns it. This method can be overridden in sub-classes to acess methods * and properties in the generated model class. * @param entityClass - LB4 Entity constructor */ protected definePersistedModel( entityClass: typeof Model, ): typeof juggler.PersistedModel { const dataSource = this.dataSource; const definition = entityClass.definition; // To handle circular reference back to the same model, // we create a placeholder model that will be replaced by real one later dataSource.getModel(definition.name, true /* forceCreate */); // We need to convert property definitions from PropertyDefinition // to plain data object because of a juggler limitation const properties: {[name: string]: object} = {}; // We need to convert PropertyDefinition into the definition that // the juggler understands Object.entries(definition.properties).forEach(([key, value]) => { // always clone value so that we do not modify the original model definition // ensures that model definitions can be reused with multiple datasources if (value.type === 'array' || value.type === Array) { value = Object.assign({}, value, { type: [value.itemType && this.resolvePropertyType(value.itemType)], }); delete value.itemType; } else { value = Object.assign({}, value, { type: this.resolvePropertyType(value.type), }); } properties[key] = Object.assign({}, value); }); const modelClass = dataSource.createModel<juggler.PersistedModelClass>( definition.name, properties, Object.assign( // settings that users can override {strict: true}, // user-defined settings definition.settings, // settings enforced by the framework {strictDelete: false}, ), ); modelClass.attachTo(dataSource); return modelClass; } private resolvePropertyType(type: PropertyType): PropertyType { const resolved = resolveType(type); return isModelClass(resolved) ? this.ensurePersistedModel(resolved) : resolved; } /** * @deprecated * Function to create a constrained relation repository factory * * Use `this.createHasManyRepositoryFactoryFor()` instead * * @param relationName - Name of the relation defined on the source model * @param targetRepo - Target repository instance */ protected _createHasManyRepositoryFactoryFor< Target extends Entity, TargetID, ForeignKeyType, >( relationName: string, targetRepositoryGetter: Getter<EntityCrudRepository<Target, TargetID>>, ): HasManyRepositoryFactory<Target, ForeignKeyType> { return this.createHasManyRepositoryFactoryFor( relationName, targetRepositoryGetter, ); } /** * Function to create a constrained relation repository factory * * @example * ```ts * class CustomerRepository extends DefaultCrudRepository< * Customer, * typeof Customer.prototype.id, * CustomerRelations * > { * public readonly orders: HasManyRepositoryFactory<Order, typeof Customer.prototype.id>; * * constructor( * protected db: juggler.DataSource, * orderRepository: EntityCrudRepository<Order, typeof Order.prototype.id>, * ) { * super(Customer, db); * this.orders = this._createHasManyRepositoryFactoryFor( * 'orders', * orderRepository, * ); * } * } * ``` * * @param relationName - Name of the relation defined on the source model * @param targetRepo - Target repository instance */ protected createHasManyRepositoryFactoryFor< Target extends Entity, TargetID, ForeignKeyType, >( relationName: string, targetRepositoryGetter: Getter<EntityCrudRepository<Target, TargetID>>, ): HasManyRepositoryFactory<Target, ForeignKeyType> { const meta = this.entityClass.definition.relations[relationName]; return createHasManyRepositoryFactory<Target, TargetID, ForeignKeyType>( meta as HasManyDefinition, targetRepositoryGetter, ); } /** * Function to create a constrained hasManyThrough relation repository factory * * @example * ```ts * class CustomerRepository extends DefaultCrudRepository< * Customer, * typeof Customer.prototype.id, * CustomerRelations * > { * public readonly cartItems: HasManyRepositoryFactory<CartItem, typeof Customer.prototype.id>; * * constructor( * protected db: juggler.DataSource, * cartItemRepository: EntityCrudRepository<CartItem, typeof, CartItem.prototype.id>, * throughRepository: EntityCrudRepository<Through, typeof Through.prototype.id>, * ) { * super(Customer, db); * this.cartItems = this.createHasManyThroughRepositoryFactoryFor( * 'cartItems', * cartItemRepository, * ); * } * } * ``` * * @param relationName - Name of the relation defined on the source model * @param targetRepo - Target repository instance * @param throughRepo - Through repository instance */ protected createHasManyThroughRepositoryFactoryFor< Target extends Entity, TargetID, Through extends Entity, ThroughID, ForeignKeyType, >( relationName: string, targetRepositoryGetter: | Getter<EntityCrudRepository<Target, TargetID>> | { [repoType: string]: Getter<EntityCrudRepository<Target, TargetID>>; }, throughRepositoryGetter: Getter<EntityCrudRepository<Through, ThroughID>>, ): HasManyThroughRepositoryFactory< Target, TargetID, Through, ForeignKeyType > { const meta = this.entityClass.definition.relations[relationName]; return createHasManyThroughRepositoryFactory< Target, TargetID, Through, ThroughID, ForeignKeyType >( meta as HasManyDefinition, targetRepositoryGetter, throughRepositoryGetter, ); } /** * @deprecated * Function to create a belongs to accessor * * Use `this.createBelongsToAccessorFor()` instead * * @param relationName - Name of the relation defined on the source model * @param targetRepo - Target repository instance */ protected _createBelongsToAccessorFor<Target extends Entity, TargetId>( relationName: string, targetRepositoryGetter: | Getter<EntityCrudRepository<Target, TargetId>> | { [repoType: string]: Getter<EntityCrudRepository<Target, TargetId>>; }, ): BelongsToAccessor<Target, ID> { return this.createBelongsToAccessorFor( relationName, targetRepositoryGetter, ); } /** * Function to create a belongs to accessor * * @param relationName - Name of the relation defined on the source model * @param targetRepo - Target repository instance */ protected createBelongsToAccessorFor<Target extends Entity, TargetId>( relationName: string, targetRepositoryGetter: | Getter<EntityCrudRepository<Target, TargetId>> | { [repoType: string]: Getter<EntityCrudRepository<Target, TargetId>>; }, ): BelongsToAccessor<Target, ID> { const meta = this.entityClass.definition.relations[relationName]; return createBelongsToAccessor<Target, TargetId, T, ID>( meta as BelongsToDefinition, targetRepositoryGetter, this, ); } /** * @deprecated * Function to create a constrained hasOne relation repository factory * * @param relationName - Name of the relation defined on the source model * @param targetRepo - Target repository instance */ protected _createHasOneRepositoryFactoryFor< Target extends Entity, TargetID, ForeignKeyType, >( relationName: string, targetRepositoryGetter: | Getter<EntityCrudRepository<Target, TargetID>> | { [repoType: string]: Getter<EntityCrudRepository<Target, TargetID>>; }, ): HasOneRepositoryFactory<Target, ForeignKeyType> { return this.createHasOneRepositoryFactoryFor( relationName, targetRepositoryGetter, ); } /** * Function to create a constrained hasOne relation repository factory * * @param relationName - Name of the relation defined on the source model * @param targetRepo - Target repository instance */ protected createHasOneRepositoryFactoryFor< Target extends Entity, TargetID, ForeignKeyType, >( relationName: string, targetRepositoryGetter: | Getter<EntityCrudRepository<Target, TargetID>> | { [repoType: string]: Getter<EntityCrudRepository<Target, TargetID>>; }, ): HasOneRepositoryFactory<Target, ForeignKeyType> { const meta = this.entityClass.definition.relations[relationName]; return createHasOneRepositoryFactory<Target, TargetID, ForeignKeyType>( meta as HasOneDefinition, targetRepositoryGetter, ); } /** * @deprecated * Function to create a references many accessor * * Use `this.createReferencesManyAccessorFor()` instead * * @param relationName - Name of the relation defined on the source model * @param targetRepo - Target repository instance */ protected _createReferencesManyAccessorFor<Target extends Entity, TargetId>( relationName: string, targetRepoGetter: Getter<EntityCrudRepository<Target, TargetId>>, ): ReferencesManyAccessor<Target, ID> { return this.createReferencesManyAccessorFor(relationName, targetRepoGetter); } /** * Function to create a references many accessor * * @param relationName - Name of the relation defined on the source model * @param targetRepo - Target repository instance */ protected createReferencesManyAccessorFor<Target extends Entity, TargetId>( relationName: string, targetRepoGetter: Getter<EntityCrudRepository<Target, TargetId>>, ): ReferencesManyAccessor<Target, ID> { const meta = this.entityClass.definition.relations[relationName]; return createReferencesManyAccessor<Target, TargetId, T, ID>( meta as ReferencesManyDefinition, targetRepoGetter, this, ); } async create(entity: DataObject<T>, options?: Options): Promise<T> { // perform persist hook const data = await this.entityToData(entity, options); const model = await ensurePromise(this.modelClass.create(data, options)); return this.toEntity(model); } async createAll(entities: DataObject<T>[], options?: Options): Promise<T[]> { // perform persist hook const data = await Promise.all( entities.map(e => this.entityToData(e, options)), ); const models = await ensurePromise( this.modelClass.createAll(data, options), ); return this.toEntities(models); } async save(entity: T, options?: Options): Promise<T> { const id = this.entityClass.getIdOf(entity); if (id == null) { return this.create(entity, options); } else { await this.replaceById(id, entity, options); return new this.entityClass(entity.toObject()) as T; } } async find( filter?: Filter<T>, options?: Options, ): Promise<(T & Relations)[]> { const include = filter?.include; const models = await ensurePromise( this.modelClass.find(this.normalizeFilter(filter), options), ); const entities = this.toEntities(models); return this.includeRelatedModels(entities, include, options); } async findOne( filter?: Filter<T>, options?: Options, ): Promise<(T & Relations) | null> { const model = await ensurePromise( this.modelClass.findOne(this.normalizeFilter(filter), options), ); if (!model) return null; const entity = this.toEntity(model); const include = filter?.include; const resolved = await this.includeRelatedModels( [entity], include, options, ); return resolved[0]; } async findById( id: ID, filter?: FilterExcludingWhere<T>, options?: Options, ): Promise<T & Relations> { const include = filter?.include; const model = await ensurePromise( this.modelClass.findById(id, this.normalizeFilter(filter), options), ); if (!model) { throw new EntityNotFoundError(this.entityClass, id); } const entity = this.toEntity(model); const resolved = await this.includeRelatedModels( [entity], include, options, ); return resolved[0]; } update(entity: T, options?: Options): Promise<void> { return this.updateById(entity.getId(), entity, options); } async delete(entity: T, options?: Options): Promise<void> { // perform persist hook await this.entityToData(entity, options); return this.deleteById(entity.getId(), options); } async updateAll( data: DataObject<T>, where?: Where<T>, options?: Options, ): Promise<Count> { where = where ?? {}; const persistedData = await this.entityToData(data, options); const result = await ensurePromise( this.modelClass.updateAll(where, persistedData, options), ); return {count: result.count}; } async updateById( id: ID, data: DataObject<T>, options?: Options, ): Promise<void> { if (!Object.keys(data).length) { throw new InvalidBodyError(this.entityClass, id); } if (id === undefined) { throw new Error('Invalid Argument: id cannot be undefined'); } const idProp = this.modelClass.definition.idName(); const where = {} as Where<T>; (where as AnyObject)[idProp] = id; const result = await this.updateAll(data, where, options); if (result.count === 0) { throw new EntityNotFoundError(this.entityClass, id); } } async replaceById( id: ID, data: DataObject<T>, options?: Options, ): Promise<void> { try { const payload = await this.entityToData(data, options); await ensurePromise(this.modelClass.replaceById(id, payload, options)); } catch (err) { if (err.statusCode === 404) { throw new EntityNotFoundError(this.entityClass, id); } throw err; } } async deleteAll(where?: Where<T>, options?: Options): Promise<Count> { const result = await ensurePromise( this.modelClass.deleteAll(where, options), ); return {count: result.count}; } async deleteById(id: ID, options?: Options): Promise<void> { const result = await ensurePromise(this.modelClass.deleteById(id, options)); if (result.count === 0) { throw new EntityNotFoundError(this.entityClass, id); } } async count(where?: Where<T>, options?: Options): Promise<Count> { const result = await ensurePromise(this.modelClass.count(where, options)); return {count: result}; } exists(id: ID, options?: Options): Promise<boolean> { return ensurePromise(this.modelClass.exists(id, options)); } /** * Execute a SQL command. * * **WARNING:** In general, it is always better to perform database actions * through repository methods. Directly executing SQL may lead to unexpected * results, corrupted data, security vulnerabilities and other issues. * * @example * * ```ts * // MySQL * const result = await repo.execute( * 'SELECT * FROM Products WHERE size > ?', * [42] * ); * * // PostgreSQL * const result = await repo.execute( * 'SELECT * FROM Products WHERE size > $1', * [42] * ); * ``` * * @param command A parameterized SQL command or query. * Check your database documentation for information on which characters to * use as parameter placeholders. * @param parameters List of parameter values to use. * @param options Additional options, for example `transaction`. * @returns A promise which resolves to the command output as returned by the * database driver. The output type (data structure) is database specific and * often depends on the command executed. */ execute( command: Command, parameters: NamedParameters | PositionalParameters, options?: Options, ): Promise<AnyObject>; /** * Execute a MongoDB command. * * **WARNING:** In general, it is always better to perform database actions * through repository methods. Directly executing MongoDB commands may lead * to unexpected results and other issues. * * @example * * ```ts * const result = await repo.execute('MyCollection', 'aggregate', [ * {$lookup: { * // ... * }}, * {$unwind: '$data'}, * {$out: 'tempData'} * ]); * ``` * * @param collectionName The name of the collection to execute the command on. * @param command The command name. See * [Collection API docs](http://mongodb.github.io/node-mongodb-native/3.6/api/Collection.html) * for the list of commands supported by the MongoDB client. * @param parameters Command parameters (arguments), as described in MongoDB API * docs for individual collection methods. * @returns A promise which resolves to the command output as returned by the * database driver. */ execute( collectionName: string, command: string, ...parameters: PositionalParameters ): Promise<AnyObject>; /** * Execute a raw database command using a connector that's not described * by LoopBack's `execute` API yet. * * **WARNING:** In general, it is always better to perform database actions * through repository methods. Directly executing database commands may lead * to unexpected results and other issues. * * @param args Command and parameters, please consult your connector's * documentation to learn about supported commands and their parameters. * @returns A promise which resolves to the command output as returned by the * database driver. */ execute(...args: PositionalParameters): Promise<AnyObject>; async execute(...args: PositionalParameters): Promise<AnyObject> { return ensurePromise(this.dataSource.execute(...args)); } protected toEntity<R extends T>(model: juggler.PersistedModel): R { return new this.entityClass(model.toObject()) as R; } protected toEntities<R extends T>(models: juggler.PersistedModel[]): R[] { return models.map(m => this.toEntity<R>(m)); } /** * Register an inclusion resolver for the related model name. * * @param relationName - Name of the relation defined on the source model * @param resolver - Resolver function for getting related model entities */ registerInclusionResolver( relationName: string, resolver: InclusionResolver<T, Entity>, ) { this.inclusionResolvers.set(relationName, resolver); } /** * Returns model instances that include related models of this repository * that have a registered resolver. * * @param entities - An array of entity instances or data * @param include -Inclusion filter * @param options - Options for the operations */ protected async includeRelatedModels( entities: T[], include?: InclusionFilter[], options?: Options, ): Promise<(T & Relations)[]> { return includeRelatedModels<T, Relations>(this, entities, include, options); } /** * This function works as a persist hook. * It converts an entity from the CRUD operations' caller * to a persistable data that can will be stored in the * back-end database. * * User can extend `DefaultCrudRepository` then override this * function to execute custom persist hook. * @param entity The entity passed from CRUD operations' caller. * @param options */ protected async entityToData<R extends T>( entity: R | DataObject<R>, options = {}, ): Promise<legacy.ModelData<legacy.PersistedModel>> { return this.ensurePersistable(entity, options); } /** Converts an entity object to a JSON object to check if it contains navigational property. * Throws an error if `entity` contains navigational property. * * @param entity The entity passed from CRUD operations' caller. * @param options */ protected ensurePersistable<R extends T>( entity: R | DataObject<R>, options = {}, ): legacy.ModelData<legacy.PersistedModel> { // FIXME(bajtos) Ideally, we should call toJSON() to convert R to data object // Unfortunately that breaks replaceById for MongoDB connector, where we // would call replaceId with id *argument* set to ObjectID value but // id *property* set to string value. /* const data: AnyObject = typeof entity.toJSON === 'function' ? entity.toJSON() : {...entity}; */ const data: DeepPartial<R> = new this.entityClass(entity); rejectNavigationalPropertiesInData(this.entityClass, data); return data; } /** * Removes juggler's "include" filter as it does not apply to LoopBack 4 * relations. * * @param filter - Query filter */ protected normalizeFilter(filter?: Filter<T>): legacy.Filter | undefined { if (!filter) return undefined; return {...filter, include: undefined} as legacy.Filter; } } /** * Default implementation of CRUD repository using legacy juggler model * and data source with beginTransaction() method for connectors which * support Transactions */ export class DefaultTransactionalRepository< T extends Entity, ID, Relations extends object = {}, > extends DefaultCrudRepository<T, ID, Relations> implements TransactionalEntityRepository<T, ID, Relations> { async beginTransaction( options?: IsolationLevel | Options, ): Promise<Transaction> { const dsOptions: juggler.IsolationLevel | Options = options ?? {}; // juggler.Transaction still has the Promise/Callback variants of the // Transaction methods // so we need it cast it back return (await this.dataSource.beginTransaction(dsOptions)) as Transaction; } }