Documentation for repositories.

This commit is contained in:
Yossi Kolesnicov 2018-07-24 14:11:20 +03:00
Родитель 350f495c85
Коммит ed326be24e
6 изменённых файлов: 137 добавлений и 26 удалений

Просмотреть файл

@ -1,5 +1,19 @@
/**
* The modes for creating sub-models in a model.
*/
export enum DataAvailability{
/**
* All sub-models are created. Those that are not cached in Repositories will be fetched from backend.
*/
deep,
/**
* Only sub-models of the requested model are fetched. Sub-models of sub-models won't be fetched from backend.
*/
flat,
/**
* Only sub-models that are cached will be created. Data for sub-models won't be fetched from backend.
*/
available
}

Просмотреть файл

@ -1,7 +1,17 @@
import {DataAvailability} from "./data-availability.enum";
/**
* Configuration for fetching data for modeling
*/
export interface DataOptions{
/**
* If true, Paris will first look for cached data, before fetching data from backend.
*/
allowCache?:boolean,
/**
* The {DataAvailability} mode for fetching data.
*/
availability?: DataAvailability
}

Просмотреть файл

@ -3,7 +3,7 @@ import {ParisConfig} from "../config/paris-config";
import {DataQuery} from "../dataset/data-query";
/**
* Configuration for a model field decorator
* Configuration for a model EntityField decorator
*/
export interface FieldConfig{
/**

Просмотреть файл

@ -3,6 +3,12 @@ import {FieldConfig} from "./entity-field.config";
import {entityFieldsService} from "../services/entity-fields.service";
import {Field} from "./entity-field";
/**
* All properties of models (Entity/ValueObject) that should be handled by Paris should be decorated with `EntityField`.
* When Paris creates an instance of a model, it maps the raw data arrived from backend to class properties, through EntityFields.
*
* @param {FieldConfig} fieldConfig
*/
export function EntityField(fieldConfig?:FieldConfig):PropertyDecorator {
return function (entityPrototype: DataEntityType, propertyKey: string | symbol) {
let propertyConstructor:Function = (<any>Reflect).getMetadata("design:type", entityPrototype, propertyKey);

Просмотреть файл

@ -25,7 +25,12 @@ import {EntityErrorEvent, EntityErrorTypes} from "../events/entity-error.event";
import {catchError, map, mergeMap, tap} from "rxjs/operators";
import {IReadonlyRepository} from "./repository.interface";
import {FIELD_DATA_SELF} from "../entity/entity-field.config";
import {EntityId} from "../models/entity-id.type";
/**
* A Repository is a service through which all of an Entity's data is fetched, cached and saved back to the backend
* `ReadonlyRepository` is the base class for all Repositories, and the class used for Repositories that are readonly.
*/
export class ReadonlyRepository<TEntity extends ModelBase<TRawData>, TRawData = object> implements IReadonlyRepository<TEntity>{
protected _errorSubject$: Subject<EntityErrorEvent>;
error$: Observable<EntityErrorEvent>;
@ -53,6 +58,11 @@ export class ReadonlyRepository<TEntity extends ModelBase<TRawData>, TRawData =
return this.entityBackendConfig.baseUrl instanceof Function ? this.entityBackendConfig.baseUrl(this.config, query) : this.entityBackendConfig.baseUrl;
}
/**
* An Observable for all the items of this entity. If the Entity has already loaded all possible items (if `loadAll` is set to `true`, for example), those items are returned.
* Otherwise, a query with no DataQuery will be performed to the backend and the data will be fetched.
* @returns {Observable<Array<TEntity extends ModelBase<TRawData>>>}
*/
get allItems$(): Observable<Array<TEntity>> {
if (this._allValues)
return merge(of(this._allValues), this._allItemsSubject$.asObservable());
@ -71,10 +81,19 @@ export class ReadonlyRepository<TEntity extends ModelBase<TRawData>, TRawData =
return this._cache;
}
/**
* The base URL for this Repository's API calls (not including base URL - the domain)
* @returns {string}
*/
get endpointName():string{
return this.entityBackendConfig.endpoint instanceof Function ? this.entityBackendConfig.endpoint(this.config) : this.entityBackendConfig.endpoint;
}
/**
* Returns the full URL for an API call
* @param {DataQuery} query
* @returns {string}
*/
getEndpointUrl(query?: DataQuery): string{
return `${this.getBaseUrl(query)}/${this.endpointName}`;
}
@ -93,6 +112,11 @@ export class ReadonlyRepository<TEntity extends ModelBase<TRawData>, TRawData =
);
}
/**
* Creates a new instance of the Repository's entity.
* All fields will be undefined, except those that have defaultValue or those that are arrays, which will have an empty array as value.
* @returns {TEntity}
*/
createNewItem(): TEntity {
let defaultData:{ [index:string]:any } = {};
this.entity.fieldsArray.forEach((field:Field) => {
@ -105,10 +129,34 @@ export class ReadonlyRepository<TEntity extends ModelBase<TRawData>, TRawData =
return new this.entityConstructor(defaultData);
}
/**
* Creates a full model of this Repository's Entity. Any sub-models that need to be fetched from backend will be fetched (if options.availability === DataAvailability.deep).
* This method is used internally when modeling entities and value objects, but may be used externally as well, in case an item should be created programmatically from raw data.
* @param {TRawData} rawData The raw data for the entity, as it arrives from backend
* @param {DataOptions} options
* @param {DataQuery} query
* @returns {Observable<TEntity extends ModelBase<TRawData>>}
*/
createItem(rawData: TRawData, options: DataOptions = { allowCache: true, availability: DataAvailability.available }, query?: DataQuery): Observable<TEntity> {
return ReadonlyRepository.getModelData<TEntity>(rawData, this.entity, this.config, this.paris, options, query);
}
/**
* Gets multiple items from backend.
* The backend may add paging information, such as count, page, etc, so a DataSet object is returned rather than just an Array.
*
* @example <caption>Get all Todo items</caption>
* repository.query()
* .subscribe((todoItems:DataSet<TodoItem>) => console.log('Current items: ', todoItems.items));
*
* @example <caption>Get all Todo items, sorted by name</caption>
* repository.query({ sortBy: { field: 'name' }})
* .subscribe((todoItems:DataSet<TodoItem>) => console.log('Items by name: ', todoItems.items));
*
* @param {DataQuery} query
* @param {DataOptions} dataOptions
* @returns {Observable<DataSet<TEntity extends ModelBase<TRawData>>>}
*/
query(query?: DataQuery, dataOptions: DataOptions = defaultDataOptions): Observable<DataSet<TEntity>> {
if (this.entityConstructor.entityConfig && !this.entityConstructor.entityConfig.supportsGetMethod(EntityGetMethod.query))
throw new Error(`Can't query ${this.entityConstructor.singularName}, query isn't supported.`);
@ -116,6 +164,13 @@ export class ReadonlyRepository<TEntity extends ModelBase<TRawData>, TRawData =
return this.paris.callQuery(this.entityConstructor, this.entityBackendConfig, query, dataOptions);
}
/**
* Same as {@link ReadonlyRepository#query|query}, but returns a single item rather than a {DataSet}.
* Useful for when we require to fetch a single model from backend, but it's either a ValueObject (so we can't refer to it by ID) or it's fetched by a more complex data query.
* @param {DataQuery} query
* @param {DataOptions} dataOptions
* @returns {Observable<TEntity extends ModelBase<TRawData>>}
*/
queryItem(query: DataQuery, dataOptions: DataOptions = defaultDataOptions): Observable<TEntity> {
let httpOptions:HttpOptions = this.entityBackendConfig.parseDataQuery ? { params: this.entityBackendConfig.parseDataQuery(query) } : queryToHttpOptions(query);
@ -154,15 +209,28 @@ export class ReadonlyRepository<TEntity extends ModelBase<TRawData>, TRawData =
return getItem$;
}
/**
* Clears all cached data in this Repository
*/
clearCache():void {
this.cache.clear();
}
/**
* Clears the cached values in the Repository, if they were set as a result of using allItems$ or `loadAll: true`.
*/
clearAllValues():void {
this._allValues = null;
}
getItemById(itemId: string | number, options: DataOptions = defaultDataOptions, params?:{ [index:string]:any }): Observable<TEntity> {
/**
* Fetches an item from backend, for the specified ID, or from cache, if it's available.
* @param {string | number} itemId
* @param {DataOptions} options
* @param {{[p: string]: any}} params
* @returns {Observable<TEntity extends ModelBase<TRawData>>}
*/
getItemById(itemId: EntityId, options: DataOptions = defaultDataOptions, params?:{ [index:string]:any }): Observable<TEntity> {
if (!this.entityConstructor.entityConfig.supportsGetMethod(EntityGetMethod.getItem))
throw new Error(`Can't get ${this.entityConstructor.singularName}, getItem isn't supported.`);
@ -219,7 +287,7 @@ export class ReadonlyRepository<TEntity extends ModelBase<TRawData>, TRawData =
/**
* Creates a JSON object that can be saved to server, with the reverse logic of getItemModelData
* @param {T} item
* @param {TEntity} item
* @returns {Index}
*/
serializeItem(item:TEntity, serializationData?:any): TRawData {
@ -229,8 +297,9 @@ export class ReadonlyRepository<TEntity extends ModelBase<TRawData>, TRawData =
}
/**
* Populates the item dataset with any sub @model. For example, if an ID is found for a property whose type is an entity,
* Populates the item dataset with any sub-model. For example, if an ID is found for a property whose type is an entity,
* the property's value will be an instance of that entity, for the ID, not the ID.
* This method does the actual heavy lifting required for modeling an Entity or ValueObject - parses the fields, models sub-models, etc.
* @param {Index} rawData
* @param {EntityConfigBase} entity
* @param {ParisConfig} config
@ -436,7 +505,7 @@ export class ReadonlyRepository<TEntity extends ModelBase<TRawData>, TRawData =
}
/**
* Serializes an object value
* Serializes an an entity into raw data, so it can be sent back to backend.
* @param item
* @returns {Index}
*/

Просмотреть файл

@ -17,23 +17,23 @@ import {AjaxError} from "rxjs/ajax";
import {catchError, map, mergeMap, tap} from "rxjs/operators";
import {DataSet} from "../dataset/dataset";
export class Repository<T extends ModelBase> extends ReadonlyRepository<T> implements IRepository<T> {
export class Repository<TEntity extends ModelBase> extends ReadonlyRepository<TEntity> implements IRepository<TEntity> {
save$: Observable<SaveEntityEvent>;
remove$: Observable<RemoveEntitiesEvent>;
private _saveSubject$: Subject<SaveEntityEvent>;
private _removeSubject$: Subject<RemoveEntitiesEvent>;
constructor(entity: EntityConfig<T>,
constructor(entity: EntityConfig<TEntity>,
config: ParisConfig,
entityConstructor: DataEntityConstructor<T>,
entityConstructor: DataEntityConstructor<TEntity>,
dataStore: DataStoreService,
paris: Paris) {
super(entity, entity, config, entityConstructor, dataStore, paris);
const getAllItems$: Observable<Array<T>> = defer(() => this.query().pipe(map((dataSet:DataSet<T>) => dataSet.items)));
const getAllItems$: Observable<Array<TEntity>> = defer(() => this.query().pipe(map((dataSet:DataSet<TEntity>) => dataSet.items)));
this._allItemsSubject$ = new Subject<Array<T>>();
this._allItemsSubject$ = new Subject<Array<TEntity>>();
this._allItems$ = merge(getAllItems$, this._allItemsSubject$.asObservable());
this._saveSubject$ = new Subject();
@ -48,7 +48,7 @@ export class Repository<T extends ModelBase> extends ReadonlyRepository<T> imple
* @param {any} serializationData Any data to pass to serialize or serializeItem
* @returns {Observable<T extends EntityModelBase>}
*/
save(item: T, options?:HttpOptions, serializationData?:any): Observable<T> {
save(item: TEntity, options?:HttpOptions, serializationData?:any): Observable<TEntity> {
if (!this.entityBackendConfig.endpoint)
throw new Error(`Entity ${this.entityConstructor.entityConfig.singularName || this.entityConstructor.name} can't be saved - it doesn't specify an endpoint.`);
@ -65,7 +65,7 @@ export class Repository<T extends ModelBase> extends ReadonlyRepository<T> imple
}),
mergeMap((savedItemData: Index) =>
savedItemData ? this.createItem(savedItemData) : of(null)),
tap((savedItem: T) => {
tap((savedItem: TEntity) => {
if (savedItem && this._allValues) {
this._allValues = [...this._allValues, savedItem];
this._allItemsSubject$.next(this._allValues);
@ -80,7 +80,7 @@ export class Repository<T extends ModelBase> extends ReadonlyRepository<T> imple
}
}
private getSaveMethod(item:T):RequestMethod{
private getSaveMethod(item:TEntity):RequestMethod{
return this.entityBackendConfig.saveMethod
? this.entityBackendConfig.saveMethod instanceof Function ? this.entityBackendConfig.saveMethod(item, this.config) : this.entityBackendConfig.saveMethod
: item.id === undefined ? "POST" : "PUT";
@ -91,7 +91,7 @@ export class Repository<T extends ModelBase> extends ReadonlyRepository<T> imple
* @param {Array<T extends EntityModelBase>} items
* @returns {Observable<Array<T extends EntityModelBase>>}
*/
saveItems(items:Array<T>, options?:HttpOptions):Observable<Array<T>>{
saveItems(items:Array<TEntity>, options?:HttpOptions):Observable<Array<TEntity>>{
if (!this.entityBackendConfig.endpoint)
throw new Error(`${this.entity.pluralName} can't be saved - it doesn't specify an endpoint.`);
@ -103,16 +103,16 @@ export class Repository<T extends ModelBase> extends ReadonlyRepository<T> imple
(saveMethod === "POST" ? newItems : existingItems).items.push(this.serializeItem(item));
});
let saveItemsArray:Array<Observable<Array<T>>> = [newItems, existingItems]
let saveItemsArray:Array<Observable<Array<TEntity>>> = [newItems, existingItems]
.filter((saveItems:SaveItems) => saveItems.items.length)
.map((saveItems:SaveItems) => this.doSaveItems(saveItems.items, saveItems.method, options));
return combineLatest.apply(this, saveItemsArray).pipe(
map((savedItems:Array<Array<T>>) => _.flatMap(savedItems)),
tap((savedItems:Array<T>) => {
map((savedItems:Array<Array<TEntity>>) => _.flatMap(savedItems)),
tap((savedItems:Array<TEntity>) => {
if (savedItems && savedItems.length && this._allValues) {
let itemsAdded: Array<T> = [];
savedItems.forEach((item:T) => {
let itemsAdded: Array<TEntity> = [];
savedItems.forEach((item:TEntity) => {
const originalItemIndex:number = _.findIndex(this._allValues,_item => item.id === _item.id);
if (!!~originalItemIndex)
this._allValues[originalItemIndex] = item;
@ -134,7 +134,7 @@ export class Repository<T extends ModelBase> extends ReadonlyRepository<T> imple
* @param {"PUT" | "POST"} method
* @returns {Observable<Array<T extends EntityModelBase>>}
*/
private doSaveItems(itemsData:Array<any>, method:"PUT" | "POST", options?:HttpOptions):Observable<Array<T>>{
private doSaveItems(itemsData:Array<any>, method:"PUT" | "POST", options?:HttpOptions):Observable<Array<TEntity>>{
const saveHttpOptions:HttpOptions = this.entity.parseSaveItemsQuery
? this.entity.parseSaveItemsQuery(itemsData, options, this.entity, this.config)
: Object.assign({}, options, {data: {items: itemsData}});
@ -150,13 +150,19 @@ export class Repository<T extends ModelBase> extends ReadonlyRepository<T> imple
return of([]);
let itemsData:Array<any> = savedItemsData instanceof Array ? savedItemsData : savedItemsData.items;
let itemCreators:Array<Observable<T>> = itemsData.map(savedItemData => this.createItem(savedItemData));
let itemCreators:Array<Observable<TEntity>> = itemsData.map(savedItemData => this.createItem(savedItemData));
return combineLatest.apply(this, itemCreators);
})
)
}
removeItem(item:T, options?:HttpOptions):Observable<T>{
/**
* Sends a DELETE request to the backend for deleting an item.
* @param {TEntity} item
* @param {HttpOptions} options
* @returns {Observable<TEntity extends ModelBase>}
*/
removeItem(item:TEntity, options?:HttpOptions):Observable<TEntity>{
if (!item)
return of(null);
@ -181,7 +187,7 @@ export class Repository<T extends ModelBase> extends ReadonlyRepository<T> imple
}),
tap(() => {
if (this._allValues) {
let itemIndex:number = _.findIndex(this._allValues, (_item:T) => _item.id === item.id);
let itemIndex:number = _.findIndex(this._allValues, (_item:TEntity) => _item.id === item.id);
if (~itemIndex)
this._allValues.splice(itemIndex, 1);
@ -198,7 +204,13 @@ export class Repository<T extends ModelBase> extends ReadonlyRepository<T> imple
}
}
remove(items:Array<T>, options?:HttpOptions):Observable<Array<T>>{
/**
* Sends a DELETE request to the backend for deleting multiple entities.
* @param {Array<TEntity extends ModelBase>} items
* @param {HttpOptions} options
* @returns {Observable<Array<TEntity extends ModelBase>>}
*/
remove(items:Array<TEntity>, options?:HttpOptions):Observable<Array<TEntity>>{
if (!items)
throw new Error(`No ${this.entity.pluralName.toLowerCase()} specified for removing.`);
@ -231,8 +243,8 @@ export class Repository<T extends ModelBase> extends ReadonlyRepository<T> imple
}),
tap(() => {
if (this._allValues) {
items.forEach((item:T) => {
let itemIndex:number = _.findIndex(this._allValues, (_item:T) => _item.id === item.id);
items.forEach((item:TEntity) => {
let itemIndex:number = _.findIndex(this._allValues, (_item:TEntity) => _item.id === item.id);
if (~itemIndex)
this._allValues.splice(itemIndex, 1);
});