From b1e09f17fdc639bccecf7b3496476446fc1e1fda Mon Sep 17 00:00:00 2001 From: Pablo Reyes Date: Fri, 4 Oct 2019 09:06:05 -0300 Subject: [PATCH] Feature: Json Ripper and Data Providers (#221) * fix/removeRelationshipLikeJsonApiSpecification * Json Ripper and Data Provider tested. Package json updated, last version of Jest used * jest types updated * getAll done for new JsonRipper * ripper ready for get() * circle ci require 1 node * json ripper fixed for hasOne = null * proposal: collection have resources[] or basicresources[] (#224) * Feature/without memory service (#226) * ContentType added (#227) * feature/dexie-work-with-elements-and-collections (#228) * Improve typing and test cahememory (#229) * fix/get-with-include (#230) --- README.md | 2 +- .../authors/components/authors.component.html | 6 + .../authors/components/authors.component.ts | 2 +- demo/app/books/components/book.component.ts | 2 +- .../app/books/components/books.component.html | 10 +- demo/app/books/components/books.component.ts | 11 +- jest.base.config.js | 2 +- package.json | 9 +- src/common.ts | 34 +- src/core.spec.ts | 2 +- src/core.ts | 43 +- src/data-providers/data-provider.ts | 17 + src/data-providers/dexie-data-provider.ts | 73 +++ src/decorators/autoregister.ts | 2 - src/document-collection.spec.ts | 2 - src/document-collection.ts | 134 ++++- src/document-resource.spec.ts | 105 +++- src/document-resource.ts | 36 +- src/document.ts | 28 +- src/interfaces/cacheable.ts | 9 +- src/interfaces/data-collection.ts | 9 +- src/interfaces/data-object.ts | 9 +- src/interfaces/data-resource.ts | 16 +- src/interfaces/has-cache-data.ts | 3 + src/interfaces/params-collection.ts | 15 + src/package.json | 2 +- src/resource.spec.ts | 133 +++-- src/resource.ts | 91 ++-- src/service.spec.ts | 505 ++++++++++++------ src/service.ts | 271 +++++----- src/services/base.ts | 8 +- src/services/cacheable-helper..ts | 15 + src/services/cachememory.spec.ts | 174 ++++++ src/services/cachememory.ts | 186 ++++--- .../cachestore-duplicate-resources.spec.ts | 6 +- src/services/cachestore.spec.ts | 59 -- src/services/cachestore.ts | 418 --------------- src/services/converter.spec.ts | 4 + src/services/converter.ts | 25 +- src/services/json-ripper.spec.ts | 258 +++++++++ src/services/json-ripper.ts | 179 +++++++ src/services/path-builder.spec.ts | 4 +- src/services/path-collection-builder.spec.ts | 9 +- src/services/path-collection-builder.ts | 13 +- .../resource-relationships-converter.spec.ts | 7 - .../resource-relationships-converter.ts | 37 +- src/sources/http.service.ts | 8 +- src/sources/store.service.ts | 27 +- .../factories}/authors.service.ts | 6 +- .../factories}/books.service.ts | 8 +- .../factories}/photos.service.ts | 4 +- .../factories}/test-factory.ts | 37 +- .../get-resource-with-parameters.spec.ts | 4 +- src/{test => tests}/get-resource.spec.ts | 59 +- src/{test => tests}/globals-test.ts | 0 src/tsconfig-build.json | 2 + tsconfig.json | 1 + yarn.lock | 114 ++-- 58 files changed, 2000 insertions(+), 1255 deletions(-) create mode 100644 src/data-providers/data-provider.ts create mode 100644 src/data-providers/dexie-data-provider.ts create mode 100644 src/interfaces/has-cache-data.ts create mode 100644 src/services/cacheable-helper..ts create mode 100644 src/services/cachememory.spec.ts delete mode 100644 src/services/cachestore.spec.ts delete mode 100644 src/services/cachestore.ts create mode 100644 src/services/json-ripper.spec.ts create mode 100644 src/services/json-ripper.ts rename src/{test-factory => tests/factories}/authors.service.ts (84%) rename src/{test-factory => tests/factories}/books.service.ts (78%) rename src/{test-factory => tests/factories}/photos.service.ts (82%) rename src/{test-factory => tests/factories}/test-factory.ts (90%) rename src/{test => tests}/get-resource-with-parameters.spec.ts (95%) rename src/{test => tests}/get-resource.spec.ts (86%) rename src/{test => tests}/globals-test.ts (100%) diff --git a/README.md b/README.md index 7f5d5158..e3efcefa 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ export class AuthorsComponent { #### Collection sort -Ex: `name` is a authors attribute, and makes a query like `/authors?sort=name,job_title` +Example: `name` is a authors attribute, and makes a query like `/authors?sort=name,job_title` ```typescript let authors = authorsService.all({ diff --git a/demo/app/authors/components/authors.component.html b/demo/app/authors/components/authors.component.html index 83bd5917..f729b51c 100644 --- a/demo/app/authors/components/authors.component.html +++ b/demo/app/authors/components/authors.component.html @@ -8,12 +8,15 @@

Authors

Date of birth Date of dead Books + Photos {{ author.id }} {{ author.attributes.name }} + {{ author.cache_last_update | date:'H:MM:SS' }} + {{ author.source }} {{ author.attributes.date_of_birth | date }} {{ author.attributes.date_of_death | date }} @@ -24,6 +27,9 @@

Authors

and {{ author.relationships.books.data.length}} more... + + {{ author.relationships.photos.data.length }} + diff --git a/demo/app/authors/components/authors.component.ts b/demo/app/authors/components/authors.component.ts index 14807983..2b13af84 100644 --- a/demo/app/authors/components/authors.component.ts +++ b/demo/app/authors/components/authors.component.ts @@ -1,3 +1,4 @@ +import { Photo } from './../../../../src/tests/factories/photos.service'; import { BooksService } from './../../books/books.service'; import { Component } from '@angular/core'; import { DocumentCollection } from 'ngx-jsonapi'; @@ -23,7 +24,6 @@ export class AuthorsComponent { .subscribe( authors => { this.authors = authors; - console.info('success authors controller', authors, 'page', page || 1, authors.page.number); }, error => console.error('Could not load authors :(', error) ); diff --git a/demo/app/books/components/book.component.ts b/demo/app/books/components/book.component.ts index 81cadedc..1f9842ce 100644 --- a/demo/app/books/components/book.component.ts +++ b/demo/app/books/components/book.component.ts @@ -19,7 +19,7 @@ export class BookComponent { private route: ActivatedRoute ) { route.params.subscribe(({ id }) => { - let book$ = booksService.get(id, { include: ['author', 'photos'] }).subscribe( + booksService.get(id, { include: ['author', 'photos'] }).subscribe( book => { this.book = book; console.log('success book', this.book); diff --git a/demo/app/books/components/books.component.html b/demo/app/books/components/books.component.html index d06d8a47..e3cb68bd 100644 --- a/demo/app/books/components/books.component.html +++ b/demo/app/books/components/books.component.html @@ -11,14 +11,18 @@

Books

- + {{ book.id }} {{ book.attributes.title }} {{ book.attributes.date_published | date }} - {{ book.relationships.author.data.attributes.name }} #{{ book.relationships.author.data.id }} - {{ photo.id }}, + + {{ book.relationships.author.data.attributes.name }} + + + {{ photo.id }}, + DELETE diff --git a/demo/app/books/components/books.component.ts b/demo/app/books/components/books.component.ts index 8b223dbc..9fc8234d 100644 --- a/demo/app/books/components/books.component.ts +++ b/demo/app/books/components/books.component.ts @@ -24,10 +24,13 @@ export class BooksComponent { page: { number: page || 1 }, include: ['author', 'photos'] }) - .subscribe(books => { - this.books = books; - console.info('success books controll', this.books); - }, (error): void => console.info('error books controll', error)); + .subscribe( + books => { + this.books = books; + // console.info('success books controll', this.books); + }, + (error): void => console.info('error books controll', error) + ); }); } diff --git a/jest.base.config.js b/jest.base.config.js index 4c3bf491..6e437259 100644 --- a/jest.base.config.js +++ b/jest.base.config.js @@ -5,7 +5,7 @@ module.exports = { '/setup-jest.ts' ], setupFiles: [ - '/src/test/globals-test.ts' + '/src/tests/globals-test.ts' ], transform: { '^.+\\.(ts|js|html)$': 'ts-jest' diff --git a/package.json b/package.json index 98205aef..7a8dbde6 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "demo:test": "jest --config ./jest.demo.config.js", "demo:build": "yarn build && yarn run copy:dist && yarn cli build --prod --base-href \"/\" --output-path \"./demo-dist\"", "demo:release": "yarn demo:build && ts-node ./build/demo-release.ts", - "ci": "yarn run build && yarn run test --coverage && cat ./coverage/lcov.info | coveralls", + "ci": "yarn run build && yarn run test -w 1 --coverage && cat ./coverage/lcov.info | coveralls", "watch:tests": "chokidar 'src/**/*.ts' --initial -c 'nyc --reporter=text --reporter=html yarn test'", "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0", "release": "yarn run build && cd dist && yarn publish", @@ -107,9 +107,7 @@ "@types/faker": "^4.1.5", "@types/fs-extra": "^2.1.0", "@types/glob": "^5.0.33", - "@types/jasmine": "^2.8.8", - "@types/jasminewd2": "^2.0.2", - "@types/jest": "^20.0.2", + "@types/jest": "^24.0.18", "@types/lodash": "^4.14.80", "@types/node": "^7.0.5", "@types/ora": "^1.3.1", @@ -158,6 +156,5 @@ "dependencies": { "dexie": "^2.0.4", "rxjs": "^6.2" - }, - "version": "0.0.0" + } } diff --git a/src/common.ts b/src/common.ts index 9ec20d37..ffa48f8a 100644 --- a/src/common.ts +++ b/src/common.ts @@ -2,23 +2,49 @@ import { ICacheable } from './interfaces/cacheable'; import { Core } from './core'; import { DocumentResource } from './document-resource'; import { DocumentCollection } from './document-collection'; +import { Resource } from './resource'; -export function isLive(cacheable: ICacheable, ttl: number = null): boolean { - let ttl_in_seconds = typeof ttl === 'number' ? ttl : cacheable.ttl || 0; +export function isLive(cacheable: ICacheable, ttl?: number): boolean { + let ttl_in_seconds = ttl && typeof ttl === 'number' ? ttl : cacheable.ttl || 0; - return Date.now() <= cacheable.cache_last_update + ttl_in_seconds * 1000; + return Date.now() < cacheable.cache_last_update + ttl_in_seconds * 1000; +} + +// @todo test required for hasMany and hasOne +export function relationshipsAreBuilded(resource: Resource, includes: Array): boolean { + if (includes.length === 0) { + return true; + } + + for (let relationship_alias in resource.relationships) { + if (includes.includes(relationship_alias) && !resource.relationships[relationship_alias].builded) { + return false; + } + } + + return true; } export function isCollection(document: DocumentResource | DocumentCollection): document is DocumentCollection { + if (!document.data) { + return false; + } + return !('id' in document.data); } export function isResource(document: DocumentResource | DocumentCollection): document is DocumentResource { + if (!document.data) { + return false; + } + return 'id' in document.data; } // NOTE: Checks that the service passed to the method is registered (method needs to have service's type or a resource as first arg) -export function serviceIsRegistered(target: Object, key: string | symbol, descriptor: PropertyDescriptor): PropertyDescriptor | null { +// changes "PropertyDescriptor | null" type for "any" to avoid typescript error in decorators property decorators +// (see https://stackoverflow.com/questions/37694322/typescript-ts1241-unable-to-resolve-signature-of-method-decorator-when-called-a) +export function serviceIsRegistered(target: Object, key: string | symbol, descriptor: PropertyDescriptor): any { const original = descriptor.value; descriptor.value = function() { diff --git a/src/core.spec.ts b/src/core.spec.ts index fbf50ce4..1bee9490 100644 --- a/src/core.spec.ts +++ b/src/core.spec.ts @@ -42,7 +42,7 @@ class CustomResourceService extends Service { describe('core methods', () => { let core: Core; it('should crete core service instance', () => { - spyOn(JsonapiStore.prototype, 'constructor'); + spyOn(JsonapiStore.prototype, 'constructor'); core = new Core( new JsonapiConfig(), new JsonapiStore(), diff --git a/src/core.ts b/src/core.ts index d8346035..13471a68 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,3 +1,4 @@ +import { CacheMemory } from './services/cachememory'; import { Injectable, Optional, isDevMode } from '@angular/core'; import { serviceIsRegistered } from './common'; import { PathBuilder } from './services/path-builder'; @@ -6,7 +7,7 @@ import { Resource } from './resource'; import { JsonapiConfig } from './jsonapi-config'; import { Http as JsonapiHttpImported } from './sources/http.service'; import { StoreService as JsonapiStore } from './sources/store.service'; -import { IDataObject } from './interfaces/data-object'; +import { IDocumentResource } from './interfaces/data-object'; import { noop } from 'rxjs/internal/util/noop'; import { Observable, throwError } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; @@ -51,7 +52,12 @@ export class Core { return Core.exec(path, 'get'); } - public static exec(path: string, method: string, data?: IDataObject, call_loadings_error: boolean = true): Observable { + public static exec( + path: string, + method: string, + data?: IDocumentResource, + call_loadings_error: boolean = true + ): Observable { Core.me.refreshLoadings(1); return Core.injectedServices.JsonapiHttp.exec(path, method, data).pipe( @@ -85,35 +91,43 @@ export class Core { } // @todo this function could return an empty value, fix required - public getResourceService(type: string): Service { + public getResourceService(type: string): Service | undefined { return this.resourceServices[type]; } + public getResourceServiceOrFail(type: string): Service { + let service = this.resourceServices[type]; + if (!service) { + throw new Error('The requested service has not been registered, please use register() method or @Autoregister() decorator'); + } + + return service; + } + @serviceIsRegistered public static removeCachedResource(resource_type: string, resource_id: string): void { - Core.me.getResourceService(resource_type).cachememory.removeResource(resource_id); + CacheMemory.getInstance().removeResource(resource_type, resource_id); if (Core.injectedServices.rsJsonapiConfig.cachestore_support) { - // TODO: FE-85 ---> agregar removeResource en cacheStorage - Core.me.getResourceService(resource_type).cachestore.removeResource(resource_id, resource_type); + // TODO: FE-85 ---> add method on JsonRipper } } @serviceIsRegistered public static setCachedResource(resource: Resource): void { - Core.me.getResourceService(resource.type).cachememory.setResource(resource, true); + CacheMemory.getInstance().setResource(resource, true); if (Core.injectedServices.rsJsonapiConfig.cachestore_support) { - Core.me.getResourceService(resource.type).cachestore.setResource(resource); + // TODO: FE-85 ---> add method on JsonRipper } } @serviceIsRegistered public static deprecateCachedCollections(type: string): void { - let service = Core.me.getResourceService(type); + let service = Core.me.getResourceServiceOrFail(type); let path = new PathBuilder(); path.applyParams(service); - service.cachememory.deprecateCollections(path.getForCache()); + CacheMemory.getInstance().deprecateCollections(path.getForCache()); if (Core.injectedServices.rsJsonapiConfig.cachestore_support) { - service.cachestore.deprecateCollections(path.getForCache()); + // TODO: FE-85 ---> add method on JsonRipper } } @@ -134,13 +148,18 @@ export class Core { // just an helper public duplicateResource(resource: R, ...relations_alias_to_duplicate_too: Array): R { - let newresource = this.getResourceService(resource.type).new(); + let newresource = this.getResourceServiceOrFail(resource.type).new(); newresource.id = 'new_' + Math.floor(Math.random() * 10000).toString(); newresource.attributes = { ...newresource.attributes, ...resource.attributes }; for (const alias in resource.relationships) { let relationship = resource.relationships[alias]; + if (!relationship.data) { + newresource.relationships[alias] = resource.relationships[alias]; + continue; + } + if ('id' in relationship.data) { // relation hasOne if (relations_alias_to_duplicate_too.indexOf(alias) > -1) { diff --git a/src/data-providers/data-provider.ts b/src/data-providers/data-provider.ts new file mode 100644 index 00000000..e755dcae --- /dev/null +++ b/src/data-providers/data-provider.ts @@ -0,0 +1,17 @@ +export interface IObject { + [key: string]: any; +} + +export interface IElement { + key: string; + content: IObject; +} + +export type TableNameType = 'collections' | 'elements'; + +export interface IDataProvider { + getElement(key: string, table_name: TableNameType): Promise>; + getElements(keys: Array, table_name: TableNameType): Promise>; + saveElements(elements: Array, table_name: TableNameType): Promise; + updateElements(key_start_with: string, new_data: IObject, table_name: TableNameType): Promise; +} diff --git a/src/data-providers/dexie-data-provider.ts b/src/data-providers/dexie-data-provider.ts new file mode 100644 index 00000000..ffa66c5e --- /dev/null +++ b/src/data-providers/dexie-data-provider.ts @@ -0,0 +1,73 @@ +import { IDataProvider, IObject, IElement } from './data-provider'; +import Dexie from 'dexie'; + +export class DexieDataProvider implements IDataProvider { + private static db: Dexie; + + public constructor() { + if (DexieDataProvider.db) { + return; + } + DexieDataProvider.db = new Dexie('dexie_data_provider'); + DexieDataProvider.db.version(1).stores({ + collections: '', + elements: '' + }); + } + + public async getElement(key: string, table_name = 'elements'): Promise> { + await DexieDataProvider.db.open(); + const data = await DexieDataProvider.db.table(table_name).get(key); + if (data === undefined) { + throw new Error(key + ' not found.'); + } + + return data; + } + + public async getElements(keys: Array, table_name = 'elements'): Promise> { + let data = {}; + await DexieDataProvider.db + .table(table_name) + .where(':id') + .anyOf(keys) + .each(element => { + data[element.data.type + '.' + element.data.id] = element; + }); + + // we need to maintain same order, database return ordered by key + return keys.map(key => { + return data[key]; + }); + } + + // @todo implement dexie.modify(changes) + // @todo test + public async updateElements(key_start_with: string, changes: IObject, table_name = 'elements'): Promise { + return DexieDataProvider.db.open().then(async () => { + if (key_start_with === '') { + return DexieDataProvider.db.table(table_name).clear(); + } else { + return DexieDataProvider.db + .table(table_name) + .where(':id') + .startsWith(key_start_with) + .delete() + .then(() => undefined); + } + }); + } + + public async saveElements(elements: Array, table_name = 'elements'): Promise { + let keys: Array = []; + let items = elements.map(element => { + keys.push(element.key); + + return element.content; + }); + + return DexieDataProvider.db.open().then(() => { + DexieDataProvider.db.table(table_name).bulkPut(items, keys); + }); + } +} diff --git a/src/decorators/autoregister.ts b/src/decorators/autoregister.ts index 3a864c82..645e0638 100644 --- a/src/decorators/autoregister.ts +++ b/src/decorators/autoregister.ts @@ -1,5 +1,3 @@ -import { Service } from '../service'; - export function Autoregister() { return (target): any => { const original = target; diff --git a/src/document-collection.spec.ts b/src/document-collection.spec.ts index e03b6530..1ec62091 100644 --- a/src/document-collection.spec.ts +++ b/src/document-collection.spec.ts @@ -1,7 +1,5 @@ import { DocumentCollection } from './document-collection'; import { Resource } from './resource'; -import { IDataCollection } from './interfaces/data-collection'; -import { Converter } from './services/converter'; describe('document-collection', () => { let collection = new DocumentCollection(); diff --git a/src/document-collection.ts b/src/document-collection.ts index ce5d27d7..402c1caf 100644 --- a/src/document-collection.ts +++ b/src/document-collection.ts @@ -1,32 +1,43 @@ +import { CacheableHelper } from './services/cacheable-helper.'; +import { IParamsCollection } from './interfaces/params-collection'; import { Resource } from './resource'; import { Page } from './services/page'; -import { Document } from './document'; +import { Document, SourceType } from './document'; import { ICacheable } from './interfaces/cacheable'; import { Converter } from './services/converter'; -import { IDataCollection } from './interfaces/data-collection'; - -export class DocumentCollection extends Document implements ICacheable { - public data: Array = []; +import { IDataCollection, ICacheableDataCollection } from './interfaces/data-collection'; +import { IDataResource, IBasicDataResource } from './interfaces/data-resource'; +import { isDevMode } from '@angular/core'; + +// used for collections on relationships, for parent document use DocumentCollection +export class RelatedDocumentCollection extends Document implements ICacheable { + public data: Array = []; + // public data: Array = []; public page = new Page(); public ttl = 0; + public content: 'ids' | 'collection' = 'ids'; public trackBy(iterated_resource: Resource): string { return iterated_resource.id; } - public find(id: string): R { + public find(id: string): R | null { + if (this.content === 'ids') { + return null; + } + // this is the best way: https://jsperf.com/fast-array-foreach for (let i = 0; i < this.data.length; i++) { if (this.data[i].id === id) { - return this.data[i]; + return this.data[i]; } } return null; } - public fill(data_collection: IDataCollection): void { - let included_resources = Converter.buildIncluded(data_collection); + public fill(data_collection: IDataCollection | ICacheableDataCollection): void { + Converter.buildIncluded(data_collection); // sometimes get Cannot set property 'number' of undefined (page) if (this.page && data_collection.meta) { @@ -38,15 +49,21 @@ export class DocumentCollection extends Document // convert and add new dataresoures to final collection let new_ids = {}; - this.data = []; + this.data.length = 0; this.builded = data_collection.data && data_collection.data.length === 0; for (let dataresource of data_collection.data) { - let res = this.find(dataresource.id) || Converter.getService(dataresource.type).getOrCreateResource(dataresource.id); - res.fill({ data: dataresource } /* , included_resources */); // @todo check with included resources? - new_ids[dataresource.id] = dataresource.id; - this.data.push(res); - if (Object.keys(res.attributes).length > 0) { - this.builded = true; + try { + let res = this.getResourceOrFail(dataresource); + res.fill({ data: dataresource }); + new_ids[dataresource.id] = dataresource.id; + (>this.data).push(res); + if (Object.keys(res.attributes).length > 0) { + this.builded = true; + } + } catch (error) { + this.content = 'ids'; + this.builded = false; + this.data.push({ id: dataresource.id, type: dataresource.type }); } } @@ -59,19 +76,52 @@ export class DocumentCollection extends Document } this.meta = data_collection.meta || {}; + + if ('cache_last_update' in data_collection) { + this.cache_last_update = data_collection.cache_last_update; + } + } + + private getResourceOrFail(dataresource: IDataResource): Resource { + let res = this.find(dataresource.id); + + if (res !== null) { + return res; + } + + let service = Converter.getService(dataresource.type); + + // remove when getService return null or catch errors + // this prvent a fill on undefinied service :/ + if (!service) { + if (isDevMode()) { + console.warn( + 'The relationship ' + + 'relation_alias?' + + ' (type ' + + dataresource.type + + ') cant be generated because service for this type has not been injected.' + ); + } + + throw new Error('Cant create service for ' + dataresource.type); + } + // END remove when getService return null or catch errors + + return service.getOrCreateResource(dataresource.id); } public replaceOrAdd(resource: R): void { let res = this.find(resource.id); if (res === null) { - this.data.push(resource); + (>this.data).push(resource); } else { res = resource; } } public hasMorePages(): boolean | null { - if (this.page.size < 1) { + if (!this.page.size || this.page.size < 1) { return null; } @@ -88,13 +138,12 @@ export class DocumentCollection extends Document public setLoadedAndPropagate(value: boolean): void { this.setLoaded(value); - this.data.forEach(resource => { - for (let relationship_alias in resource.relationships) { - let relationship = resource.relationships[relationship_alias]; - if (relationship instanceof DocumentCollection) { - relationship.setLoaded(value); - } - } + + if (this.content === 'ids') { + return; + } + (>this.data).forEach(resource => { + CacheableHelper.propagateLoaded(resource.relationships, value); }); } @@ -104,13 +153,42 @@ export class DocumentCollection extends Document public setBuildedAndPropagate(value: boolean): void { this.setBuilded(value); - this.data.forEach(resource => { + if (this.content === 'ids') { + return; + } + (>this.data).forEach(resource => { resource.setLoaded(value); }); } - /** @todo generate interface */ - public setSource(value: 'new' | 'memory' | 'store' | 'server'): void { + public setSource(value: SourceType): void { this.source = value; } + + public setSourceAndPropagate(value: SourceType): void { + this.setSource(value); + this.data.forEach(resource => { + if (resource instanceof Resource) { + resource.setSource(value); + } + }); + } + + public toObject(params?: IParamsCollection): IDataCollection { + if (!this.builded) { + return { data: this.data }; + } + + let data = (>this.data).map(resource => { + return resource.toObject(params).data; + }); + + return { + data: data + }; + } +} +export class DocumentCollection extends RelatedDocumentCollection { + public data: Array = []; + public content: 'collection' = 'collection'; } diff --git a/src/document-resource.spec.ts b/src/document-resource.spec.ts index b36bdf15..24080fde 100644 --- a/src/document-resource.spec.ts +++ b/src/document-resource.spec.ts @@ -1,26 +1,75 @@ import { DocumentResource } from './document-resource'; import { Resource } from './resource'; import { Page } from './services/page'; -import { Document } from './document'; -import { IDataObject } from './interfaces/data-object'; +import { Core } from './core'; +import { StoreService as JsonapiStore } from './sources/store.service'; +import { Http as JsonapiHttpImported } from './sources/http.service'; +import { HttpClient, HttpEvent, HttpHandler, HttpRequest, HttpResponse } from '@angular/common/http'; +import { JsonapiConfig } from './jsonapi-config'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { Author, AuthorsService } from './tests/factories/authors.service'; +import { Book, BooksService } from './tests/factories/books.service'; +import { delay, map, toArray, tap } from 'rxjs/operators'; -describe('document resource', () => { +class HttpHandlerMock implements HttpHandler { + public handle(req: HttpRequest): Observable> { + let subject = new BehaviorSubject(new HttpResponse()); + + return subject.asObservable(); + } +} + +describe('service basic methods', () => { + let core = new Core( + new JsonapiConfig(), + new JsonapiStore(), + new JsonapiHttpImported(new HttpClient(new HttpHandlerMock()), new JsonapiConfig()) + ); + let service = new AuthorsService(); + service.register(); + + it('a new resource has a type', () => { + const resource = service.new(); + expect(resource instanceof Author).toBeTruthy(); + expect(resource.type).toEqual('authors'); + }); + + it('a new resource with id has a type', () => { + const resource = service.createResource('31'); + expect(resource instanceof Author).toBeTruthy(); + expect(resource.id).toEqual('31'); + expect(resource.type).toEqual('authors'); + }); +}); + +let test_response_subject = new BehaviorSubject(new HttpResponse()); +class DynamicHttpHandlerMock implements HttpHandler { + public handle(req: HttpRequest): Observable> { + return test_response_subject.asObservable().pipe(delay(0)); + } +} +describe('document resource general', () => { let document_resource = new DocumentResource(); it('should be created', () => { expect(document_resource.builded).toBe(false); expect(document_resource.content).toBe('id'); }); it('data property should have a new resource instance', () => { - let Resource_spy = spyOn(Resource, 'constructor'); let resource = new Resource(); expect(document_resource.data).toEqual(resource); }); - it('page property should have a new page instance', () => { - let page = new Page(); - expect(document_resource.page).toEqual(page); +}); + +describe('document resource fill() method', () => { + let document_resource = new DocumentResource(); + let booksService = new BooksService(); + beforeEach(async () => { + booksService = new BooksService(); + booksService.register(); + await booksService.clearCacheMemory(); }); - it('fill mehotd should call Reource class fill mehtod with the passed IDataObject parameter and fill meta property', () => { - let Resource_fill_spy = spyOn(document_resource.data, 'fill'); + + it('fill() with only ids generate content=id and empty relationships', () => { document_resource.fill({ data: { type: 'data', @@ -28,12 +77,42 @@ describe('document resource', () => { }, meta: { meta: 'meta' } }); - expect(Resource_fill_spy).toHaveBeenCalled(); + expect((document_resource.data).relationships).toMatchObject({}); + expect(document_resource.builded).toBeFalsy(); + expect(document_resource.content).toBe('id'); expect(document_resource.meta).toEqual({ meta: 'meta' }); }); - it('if passed IDataObject has no meta property, fill mehotd should should assign an empty Object', () => { - document_resource.meta = null; - let Resource_fill_spy = spyOn(document_resource.data, 'fill'); + + it('fill() with only ids generate content=id and empty relationships, and we call fill() again with complete data', () => { + document_resource = new DocumentResource(); + document_resource.unsetData(); + + // fill with more data + document_resource.fill({ + data: { + type: 'books', + id: '4', + attributes: { + name: 'Ray' + }, + relationships: { + author: { + data: null + }, + books: { + data: [] + } + } + } + }); + // expect(document_resource.builded).toBeTruthy(); + // expect(document_resource.content).toBe('resource'); + // expect('xxxxxx').toBe('uuuuuuuuuuuuuuuuuuuuuuuuuu'); + }); + + it('if passed IDocumentResource has no meta property, fill mehotd should should assign an empty Object', () => { + delete document_resource.meta; + let Resource_fill_spy = spyOn(document_resource.data, 'fill'); document_resource.fill({ data: { type: 'data', diff --git a/src/document-resource.ts b/src/document-resource.ts index d7d9843d..587e992b 100644 --- a/src/document-resource.ts +++ b/src/document-resource.ts @@ -1,21 +1,39 @@ +import { CacheMemory } from './services/cachememory'; +import { Converter } from './services/converter'; import { Resource } from './resource'; import { Page } from './services/page'; import { Document } from './document'; -import { IDataObject } from './interfaces/data-object'; +import { IDocumentResource } from './interfaces/data-object'; export class DocumentResource extends Document { - public data: R = new Resource(); // @todo? - // @todo #209 - // new Resource(); cannot be a Resource or null, for example a book without an author (books.relationships.authors.data), or data missing - // public data: R | null | undefined; - + public data: R | null | undefined = new Resource(); public builded = false; public content: 'id' | 'resource' = 'id'; - public page = new Page(); + public fill(data_resource: IDocumentResource | null): void { + this.builded = false; + this.content = 'id'; + + if (data_resource === null) { + this.data = null; + + return; + } + + if (!this.data) { + this.data = CacheMemory.getInstance().getOrCreateResource(data_resource.data.type, data_resource.data.id); + } + + if (this.data.fill(data_resource)) { + this.builded = true; + this.content = 'resource'; + } - public fill(data_resource: IDataObject): void { - this.data.fill(data_resource); this.meta = data_resource.meta || {}; } + + public unsetData(): void { + this.data = undefined; + this.builded = false; + } } diff --git a/src/document.ts b/src/document.ts index 1dd19916..4e94177a 100644 --- a/src/document.ts +++ b/src/document.ts @@ -1,15 +1,35 @@ +import { IBasicDataResource } from './interfaces/data-resource'; import { IDocumentData } from './interfaces/document'; import { Resource } from './resource'; -export class Document implements IDocumentData { - public data: Array | Resource; +export type SourceType = 'new' | 'memory' | 'store' | 'server'; + +interface IDocumentHasIds { + data: Array; + content: 'ids'; +} +interface IDocumentHasResources { + data: Array; + content: 'collection'; +} +interface IDocumentHasId { + data: IBasicDataResource; + content: 'id'; +} +interface IDocumentHasResource { + data: Resource; + content: 'resource'; +} +export /* abstract */ class Document + implements IDocumentData, IDocumentHasResources, IDocumentHasIds, IDocumentHasId, IDocumentHasResource { + public data: any; public builded = false; - public content: 'ids' | 'collection' | 'id' | 'resource'; + public content: any; // deprecated since 2.2.0. Use loaded. public is_loading = true; public loaded = false; - public source: 'new' | 'memory' | 'store' | 'server' = 'new'; + public source: SourceType = 'new'; public cache_last_update = 0; public meta: { [key: string]: any; diff --git a/src/interfaces/cacheable.ts b/src/interfaces/cacheable.ts index 7f4bb38e..1e5bc9c4 100644 --- a/src/interfaces/cacheable.ts +++ b/src/interfaces/cacheable.ts @@ -1,8 +1,11 @@ -export interface ICacheable { +import { SourceType } from './../document'; +import { IHasCacheData } from './has-cache-data'; + +// deprecated since 2.2.0. Use loaded. +export interface ICacheable extends IHasCacheData { // deprecated since 2.2.0. Use loaded. is_loading: boolean; loaded: boolean; - source: 'new' | 'memory' | 'store' | 'server'; - cache_last_update: number; + source: SourceType; ttl?: number; } diff --git a/src/interfaces/data-collection.ts b/src/interfaces/data-collection.ts index c8ef59f0..041c7d20 100644 --- a/src/interfaces/data-collection.ts +++ b/src/interfaces/data-collection.ts @@ -1,9 +1,12 @@ -import { IDataResource } from './data-resource'; -import { IDocument, IDocumentData } from '../interfaces/document'; +import { IHasCacheData } from './has-cache-data'; +import { IDataResource, ICacheableDataResource } from './data-resource'; +import { IDocumentData } from '../interfaces/document'; import { IPage } from './page'; export interface IDataCollection extends IDocumentData { data: Array; page?: IPage; - _lastupdate_time?: number; // used when come from Store +} +export interface ICacheableDataCollection extends IDataCollection, IHasCacheData { + data: Array; } diff --git a/src/interfaces/data-object.ts b/src/interfaces/data-object.ts index 7178e1c3..7e0baf8e 100644 --- a/src/interfaces/data-object.ts +++ b/src/interfaces/data-object.ts @@ -1,6 +1,9 @@ -import { IDocument, IDocumentData } from './document'; -import { IDataResource } from './data-resource'; +import { IDocumentData } from './document'; +import { IDataResource, ICacheableDataResource } from './data-resource'; -export interface IDataObject extends IDocumentData { +export interface IDocumentResource extends IDocumentData { data: IDataResource; } +export interface ICacheableDocumentResource extends IDocumentResource { + data: ICacheableDataResource; +} diff --git a/src/interfaces/data-resource.ts b/src/interfaces/data-resource.ts index 1c064249..2f6cece9 100644 --- a/src/interfaces/data-resource.ts +++ b/src/interfaces/data-resource.ts @@ -1,12 +1,18 @@ +import { IHasCacheData } from './has-cache-data'; +import { IObject } from './../data-providers/data-provider'; import { IAttributes } from '../interfaces'; import { ILinks } from '../interfaces/links'; -export interface IDataResource { - type: string; +export interface IBasicDataResource { id: string; + type: string; +} + +export interface IDataResource extends IBasicDataResource { attributes?: IAttributes; - relationships?: object; + relationships?: IObject; links?: ILinks; - meta?: object; - _lastupdate_time?: number; // used when come from Store + meta?: IObject; } + +export interface ICacheableDataResource extends IDataResource, IHasCacheData {} diff --git a/src/interfaces/has-cache-data.ts b/src/interfaces/has-cache-data.ts new file mode 100644 index 00000000..7b4435bb --- /dev/null +++ b/src/interfaces/has-cache-data.ts @@ -0,0 +1,3 @@ +export interface IHasCacheData { + cache_last_update: number; +} diff --git a/src/interfaces/params-collection.ts b/src/interfaces/params-collection.ts index cc3cccb7..bfa33331 100644 --- a/src/interfaces/params-collection.ts +++ b/src/interfaces/params-collection.ts @@ -7,9 +7,24 @@ export interface IParamsCollection extends IParams { smartfilter?: object; sort?: Array; page?: IPage; + fields?: object; store_cache_method?: 'individual' | 'compact'; // solution for big collections /** @deprecated since 2.2 */ storage_ttl?: number; /** @deprecated since 2.2 */ cachehash?: string; // solution for when we have different resources with a same id } + +export interface IBuildedParamsCollection extends IParams { + remotefilter: object; + /** @deprecated since 2.2, we have rxjs pipes */ + smartfilter: object; + sort: Array; + page: IPage; + fields: object; + store_cache_method: 'individual' | 'compact'; // solution for big collections + /** @deprecated since 2.2 */ + storage_ttl: number; + /** @deprecated since 2.2 */ + cachehash: string; // solution for when we have different resources with a same id +} diff --git a/src/package.json b/src/package.json index d29090e5..01ec171b 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "ngx-jsonapi", - "version": "2.1.6", + "version": "2.1.7", "description": "JSON API library for Angular", "module": "ngx-jsonapi/@ngx-jsonapi/ngx-jsonapi.es5.js", "es2015": "ngx-jsonapi/@ngx-jsonapi/ngx-jsonapi.js", diff --git a/src/resource.spec.ts b/src/resource.spec.ts index 2a8c152b..91991b0b 100644 --- a/src/resource.spec.ts +++ b/src/resource.spec.ts @@ -1,5 +1,6 @@ +import { TestFactory } from './tests/factories/test-factory'; import { DocumentCollection } from './document-collection'; -import { IDataObject } from './interfaces/data-object'; +import { IDocumentResource } from './interfaces/data-object'; import { IParamsResource } from './interfaces/params-resource'; import { DocumentResource } from './document-resource'; import { Core } from './core'; @@ -64,7 +65,7 @@ describe('resource', () => { expect(exec_spy).toHaveBeenCalledWith('1234', 'PATCH', second_expected_resource_in_save, true); }); - it('toObject method should parse the resouce in a new IDataObject', () => { + it('toObject method should parse the resouce in a new IDocumentResource', () => { let mocked_service_data: { [key: string]: any } = { parseToServer: false }; spyOn(Resource.prototype, 'getService').and.returnValue(mocked_service_data); let new_resource: Resource = new Resource(); @@ -85,7 +86,7 @@ describe('resource', () => { ttl: 0 // id: '', }; - let to_object_resource: IDataObject = new_resource.toObject(params); + let to_object_resource: IDocumentResource = new_resource.toObject(params); expect(to_object_resource.data.id).toBe('1'); expect(to_object_resource.data.type).toBe('main'); expect(to_object_resource.data.attributes.main_attribute).toBe('123456789'); @@ -95,6 +96,9 @@ describe('resource', () => { expect(to_object_resource.included[0].type).toBe('resource_relationship'); expect(to_object_resource.included[0].attributes.first).toBe('1'); }); +}); + +describe('resource.toObject() method', () => { it('(toObject) If the service has a parseToServer method, ir should be applied in toObject method', () => { let mocked_service_data = { parseToServer: (attr: { [key: string]: any }): { [key: string]: any } => { @@ -125,6 +129,7 @@ describe('resource', () => { let to_object_resource = new_resource.toObject(params); expect(to_object_resource.data.attributes.main_attribute).toBe(123456789); }); + it('(toObject) If a relationship is not a document resource or document collection instance, a warn should be reaised', () => { let console_warn_spy = spyOn(console, 'warn'); let mocked_service_data: { [key: string]: any } = { parseToServer: false }; @@ -147,14 +152,15 @@ describe('resource', () => { ttl: 0 // id: '', }; - let to_object_resource: IDataObject = new_resource.toObject(params); + let to_object_resource: IDocumentResource = new_resource.toObject(params); expect(to_object_resource).toBeTruthy(); expect(console_warn_spy).toHaveBeenCalled(); }); + it('(toObject) If a relationship is not in the include param, it should not be included in the resulting include field', () => { + spyOn(Resource.prototype, 'getService').and.returnValue({}); + let console_warn_spy = spyOn(console, 'warn'); - let mocked_service_data: { [key: string]: any } = { parseToServer: false }; - spyOn(Resource.prototype, 'getService').and.returnValue(mocked_service_data); let new_resource: Resource = new Resource(); new_resource.type = 'main'; new_resource.id = '1'; @@ -173,58 +179,53 @@ describe('resource', () => { ttl: 0 // id: '', }; - let to_object_resource: IDataObject = new_resource.toObject(params); + let to_object_resource: IDocumentResource = new_resource.toObject(params); expect(to_object_resource).toBeTruthy(); expect(to_object_resource.included).toBeFalsy(); }); - it('(toObject) If a relationship is empty, it should not be included in the resulting resource realtionships', () => { - let console_warn_spy = spyOn(console, 'warn'); - let mocked_service_data: { [key: string]: any } = { parseToServer: false }; - spyOn(Resource.prototype, 'getService').and.returnValue(mocked_service_data); - let new_resource: Resource = new Resource(); - new_resource.type = 'main'; - new_resource.id = '1'; - new_resource.attributes = { main_attribute: '123456789' }; - new_resource.relationships = { - resource_relationship: new DocumentResource() - }; - let params: IParamsResource = { - beforepath: '', - include: [], - ttl: 0 - // id: '', - }; - let to_object_resource: IDataObject = new_resource.toObject(params); - expect(to_object_resource).toBeTruthy(); - expect(to_object_resource.data.relationships).toEqual({}); - }); - it('(toObject) If a hasMany relationship is empty, it should be removed from the resulting relationships', () => { - let mocked_service_data: { [key: string]: any } = { parseToServer: false }; - spyOn(Resource.prototype, 'getService').and.returnValue(mocked_service_data); - let new_resource: Resource = new Resource(); - new_resource.type = 'main'; - new_resource.id = '1'; - new_resource.attributes = { main_attribute: '123456789' }; - new_resource.relationships = { - resource_relationships: new DocumentCollection() - }; - let resource_relationships: DocumentCollection = new DocumentCollection(); + it('(toObject) hasMany empty and untouched relationship should be removed from the resulting relationships', () => { + spyOn(Resource.prototype, 'getService').and.returnValue({}); + + let book = TestFactory.getBook('5'); + let params: IParamsResource = { beforepath: '', include: ['resource_relationships'], ttl: 0 - // id: '', }; - let to_object_resource: IDataObject = new_resource.toObject(params); - expect(to_object_resource).toBeTruthy(); - expect(to_object_resource.data.relationships).toEqual({}); - expect(to_object_resource.included).toBeFalsy(); + let book_object = book.toObject(params); + expect(book_object.data.relationships.photos).toBeUndefined(); + expect(book_object.included).toBeFalsy(); + }); + + it('(toObject) hasMany empty and builded relationship should be return a emtpy relationship', () => { + spyOn(Resource.prototype, 'getService').and.returnValue({}); + + let book = TestFactory.getBook('1'); + book.addRelationship(TestFactory.getPhoto('5'), 'photos'); + expect(book.toObject().data.relationships.photos.data[0].id).toBe('5'); + + book.removeRelationship('photos', '5'); + expect(book.relationships.photos.builded).toBe(true); + expect(book.relationships.photos.content).toBe('collection'); + expect(book.toObject().data.relationships.photos.data).toEqual([]); + }); + + it('(toObject) hasMany whith only ids and builded relationship should be return a relationship with ids', () => { + spyOn(Resource.prototype, 'getService').and.returnValue({}); + + let book = TestFactory.getBook('1'); + book.relationships.photos.fill({ data: [{ id: '4', type: 'photos' }] }); + expect(book.relationships.photos.builded).toBe(false); + expect(book.relationships.photos.content).toBe('ids'); + expect(book.toObject().data.relationships.photos.data.length).toBe(1); + expect(book.toObject().data.relationships.photos.data[0]).toMatchObject({ id: '4', type: 'photos' }); }); it('(toObject) hasMany relationships that are OK should be included in the resulting relationships', () => { - let mocked_service_data: { [key: string]: any } = { parseToServer: false }; - spyOn(Resource.prototype, 'getService').and.returnValue(mocked_service_data); + spyOn(Resource.prototype, 'getService').and.returnValue({}); + let new_resource: Resource = new Resource(); new_resource.type = 'main'; new_resource.id = '1'; @@ -245,13 +246,47 @@ describe('resource', () => { ttl: 0 // id: '', }; - let to_object_resource: IDataObject = new_resource.toObject(params); + let to_object_resource: IDocumentResource = new_resource.toObject(params); expect(to_object_resource).toBeTruthy(); expect((to_object_resource.data.relationships as any).resource_relationships.data[0].id).toBe('123'); expect(to_object_resource.included[0].id).toBe('123'); }); + it('(toObject) hasOne empty data and untouched relationship should be removed from the resulting relationships', () => { + spyOn(Resource.prototype, 'getService').and.returnValue({}); + + let book = TestFactory.getBook('5'); + let book_object = book.toObject(); + expect(book_object.data.relationships.author).toBeUndefined(); + expect(book_object.included).toBeFalsy(); + }); + + it('(toObject) hasOne data null relationship should be return a data nulled relationship', () => { + spyOn(Resource.prototype, 'getService').and.returnValue({}); + + let book = TestFactory.getBook('5'); + book.addRelationship(TestFactory.getAuthor('1'), 'author'); + expect(book.toObject().data.relationships.author.data.id).toBe('1'); + book.removeRelationship('author', '1'); + expect(book.relationships.author.data).toBeNull(); + let book_object = book.toObject(); + expect(book_object.data.relationships.author.data).toBeNull(); + expect(book_object.included).toBeFalsy(); + }); + + it('(toObject) hasOne data filled relationship should be return a simple object relationship', () => { + spyOn(Resource.prototype, 'getService').and.returnValue({}); + + let book = TestFactory.getBook('5'); + book.addRelationship(TestFactory.getAuthor('1'), 'author'); + let book_object = book.toObject(); + expect(book_object.data.relationships.author.data.id).toBe('1'); + expect(book_object.included).toBeFalsy(); + }); +}); + +describe('resource.save() method', () => { it('if set, te save method should send the "meta" property when saving a resource', async () => { let resource = new Resource(); spyOn(resource, 'getService').and.returnValue(false); @@ -354,4 +389,8 @@ describe('resource', () => { }; expect(exec_spy).toHaveBeenCalledWith('1234', 'PATCH', expected_resource_in_save, true); }); + + // @todo fill from store to more new version of resource + // for example store has more lationships, but we are filling a resource created from server. + // is possible this scenario? }); diff --git a/src/resource.ts b/src/resource.ts index 930f1663..65d1c964 100644 --- a/src/resource.ts +++ b/src/resource.ts @@ -1,10 +1,14 @@ +import { CacheMemory } from './services/cachememory'; +import { IDataResource } from './interfaces/data-resource'; +import { JsonRipper } from './services/json-ripper'; +import { CacheableHelper } from './services/cacheable-helper.'; import { Core } from './core'; import { IResourcesByType } from './interfaces/resources-by-type'; import { Service } from './service'; import { Base } from './services/base'; import { PathBuilder } from './services/path-builder'; import { Converter } from './services/converter'; -import { IDataObject } from './interfaces/data-object'; +import { IDocumentResource, ICacheableDocumentResource } from './interfaces/data-object'; import { IAttributes, IParamsResource, ILinks } from './interfaces'; import { DocumentCollection } from './document-collection'; import { DocumentResource } from './document-resource'; @@ -13,6 +17,7 @@ import { isArray } from 'util'; import { Observable, Subject, of } from 'rxjs'; import { ResourceRelationshipsConverter } from './services/resource-relationships-converter'; import { IRelationships } from './interfaces/relationship'; +import { SourceType } from './document'; export class Resource implements ICacheable { public id: string = ''; @@ -26,7 +31,7 @@ export class Resource implements ICacheable { public is_saving = false; public is_loading = false; public loaded = true; - public source: 'new' | 'memory' | 'store' | 'server' = 'new'; + public source: SourceType = 'new'; public cache_last_update = 0; public ttl = 0; @@ -41,12 +46,12 @@ export class Resource implements ICacheable { } } - public toObject(params?: IParamsResource): IDataObject { + public toObject(params?: IParamsResource): IDocumentResource { params = { ...{}, ...Base.ParamsResource, ...params }; let relationships = {}; - let included = []; - let included_ids = []; // just for control don't repeat any resource + let included: Array = []; + let included_ids: Array = []; // just for control don't repeat any resource // REALTIONSHIPS for (const relation_alias in this.relationships) { @@ -68,15 +73,15 @@ export class Resource implements ICacheable { // no se agregó aún a included && se ha pedido incluir con el parms.include let temporal_id = resource.type + '_' + resource.id; - if (included_ids.indexOf(temporal_id) === -1 && params.include.indexOf(relation_alias) !== -1) { + if (included_ids.indexOf(temporal_id) === -1 && params.include && params.include.indexOf(relation_alias) !== -1) { included_ids.push(temporal_id); included.push(resource.toObject({}).data); } } } else { // @TODO PABLO: agregué el check de null porque sino fallan las demás condiciones, además es para eliminar la relacxión del back - if (relationship.data === null) { - relationships[relation_alias] = { data: null }; + if (relationship.data === null || relationship.data === undefined) { + relationships[relation_alias] = { data: relationship.data }; continue; } if (!(relationship instanceof DocumentResource)) { @@ -84,7 +89,7 @@ export class Resource implements ICacheable { } let relationship_data = relationship.data; - if (!('id' in relationship.data) && Object.keys(relationship.data).length > 0) { + if (relationship.data && !('id' in relationship.data) && Object.keys(relationship.data).length > 0) { console.warn(relation_alias + ' defined with hasMany:false, but I have a collection'); } @@ -103,7 +108,7 @@ export class Resource implements ICacheable { // no se agregó aún a included && se ha pedido incluir con el parms.include let temporal_id = relationship_data.type + '_' + relationship_data.id; - if (included_ids.indexOf(temporal_id) === -1 && params.include.indexOf(relation_alias) !== -1) { + if (included_ids.indexOf(temporal_id) === -1 && params.include && params.include.indexOf(relation_alias) !== -1) { included_ids.push(temporal_id); included.push(relationship_data.toObject({}).data); } @@ -119,7 +124,7 @@ export class Resource implements ICacheable { attributes = this.attributes; } - let ret: IDataObject = { + let ret: IDocumentResource = { data: { type: this.type, id: this.id, @@ -145,26 +150,25 @@ export class Resource implements ICacheable { return ret; } - public fill(data_object: IDataObject, included_resources?: IResourcesByType): void { - included_resources = included_resources || Converter.buildIncluded(data_object); - + public fill(data_object: IDocumentResource | ICacheableDocumentResource): boolean { this.id = data_object.data.id || ''; // WARNING: leaving previous line for a tiem because this can produce undesired behavior // this.attributes = data_object.data.attributes || this.attributes; this.attributes = { ...(this.attributes || {}), ...data_object.data.attributes }; - // NOTE: fix if stored resource has no relationships property - if (!this.relationships) { - this.relationships = new (Converter.getService(data_object.data.type)).resource().relationships; - } - this.is_new = false; + + // NOTE: fix if stored resource has no relationships property let service = Converter.getService(data_object.data.type); + if (!this.relationships && service) { + this.relationships = new service.resource().relationships; + } + // wee need a registered service if (!service) { - return; + return false; } // only ids? @@ -176,12 +180,18 @@ export class Resource implements ICacheable { } } + if ('cache_last_update' in data_object.data) { + this.cache_last_update = data_object.data.cache_last_update; + } + new ResourceRelationshipsConverter( Converter.getService, data_object.data.relationships || {}, this.relationships, - included_resources + Converter.buildIncluded(data_object) ).buildRelationships(); + + return true; } public addRelationship(resource: T, type_alias?: string) { @@ -208,13 +218,6 @@ export class Resource implements ICacheable { }); } - /** - * @deprecated - */ - public addRelationshipsArray(resources: Array, type_alias: string): void { - this.addRelationships(resources, type_alias); - } - public removeRelationship(type_alias: string, id: string): boolean { if (!(type_alias in this.relationships)) { return false; @@ -226,6 +229,10 @@ export class Resource implements ICacheable { let relation = this.relationships[type_alias]; if (relation instanceof DocumentCollection) { relation.data = relation.data.filter(resource => resource.id !== id); + if (relation.data.length === 0) { + // used by toObject() when hasMany is empty + relation.builded = true; + } } else { relation.data = null; } @@ -238,10 +245,10 @@ export class Resource implements ICacheable { } public hasOneRelated(resource: string): boolean { - return ( + return Boolean( this.relationships[resource] && - (this.relationships[resource].data).type && - (this.relationships[resource].data).type !== '' + (this.relationships[resource].data).type && + (this.relationships[resource].data).type !== '' ); } @@ -255,7 +262,7 @@ export class Resource implements ICacheable { @return This resource like a service */ public getService(): Service { - return Converter.getService(this.type); + return Converter.getServiceOrFail(this.type); } public save(params?: IParamsResource): Observable { @@ -282,16 +289,17 @@ export class Resource implements ICacheable { success => { this.is_saving = false; - // foce reload cache (for example, we add a new element) + // force reload collections cache (example: we add a new element) if (!this.id) { - this.getService().cachememory.deprecateCollections(path.get()); - this.getService().cachestore.deprecateCollections(path.get()); + CacheMemory.getInstance().deprecateCollections(path.get()); + let jsonripper = new JsonRipper(); + jsonripper.deprecateCollection(path.get()); } // is a resource? if ('id' in success.data) { this.id = success.data.id; - this.fill(success); + this.fill(success); } else if (isArray(success.data)) { console.warn('Server return a collection when we save()', success.data); } @@ -316,20 +324,15 @@ export class Resource implements ICacheable { public setLoadedAndPropagate(value: boolean): void { this.setLoaded(value); - for (let relationship_alias in this.relationships) { - let relationship = this.relationships[relationship_alias]; - if (relationship instanceof DocumentCollection) { - relationship.setLoaded(value); - } - } + CacheableHelper.propagateLoaded(this.relationships, value); } /** @todo generate interface */ - public setSource(value: 'new' | 'memory' | 'store' | 'server'): void { + public setSource(value: SourceType): void { this.source = value; } - public setSourceAndPropagate(value: 'new' | 'memory' | 'store' | 'server'): void { + public setSourceAndPropagate(value: SourceType): void { this.setSource(value); for (let relationship_alias in this.relationships) { let relationship = this.relationships[relationship_alias]; diff --git a/src/service.spec.ts b/src/service.spec.ts index 4f6c4646..fbd582ba 100644 --- a/src/service.spec.ts +++ b/src/service.spec.ts @@ -1,34 +1,33 @@ +import { IDocumentResource } from './interfaces/data-object'; +import { CacheMemory } from './services/cachememory'; import { Core } from './core'; import { StoreService as JsonapiStore } from './sources/store.service'; import { Http as JsonapiHttpImported } from './sources/http.service'; import { HttpClient, HttpEvent, HttpHandler, HttpRequest, HttpResponse } from '@angular/common/http'; import { JsonapiConfig } from './jsonapi-config'; import { BehaviorSubject, Observable } from 'rxjs'; -import { TestFactory } from './test-factory/test-factory'; -import { Author, AuthorsService } from './test-factory/authors.service'; -import { delay, filter, first } from 'rxjs/operators'; +import { TestFactory } from './tests/factories/test-factory'; +import { Author, AuthorsService } from './tests/factories/authors.service'; +import { Book, BooksService } from './tests/factories/books.service'; +import { delay, map, toArray, tap } from 'rxjs/operators'; + +// @todo disable PhotoService class HttpHandlerMock implements HttpHandler { public handle(req: HttpRequest): Observable> { - let subject = new BehaviorSubject(new HttpResponse()); - - return subject.asObservable(); + return test_response_subject.asObservable().pipe(delay(0)); } } +let test_response_subject = new BehaviorSubject(new HttpResponse()); -describe('service methods', () => { - let core; - let service; - - beforeEach(() => { - core = new Core( - new JsonapiConfig(), - new JsonapiStore(), - new JsonapiHttpImported(new HttpClient(new HttpHandlerMock()), new JsonapiConfig()) - ); - service = new AuthorsService(); - service.register(); - }); +describe('service basic methods', () => { + let core = new Core( + new JsonapiConfig(), + new JsonapiStore(), + new JsonapiHttpImported(new HttpClient(new HttpHandlerMock()), new JsonapiConfig()) + ); + let service = new AuthorsService(); + service.register(); it('a new resource has a type', () => { const resource = service.new(); @@ -42,223 +41,383 @@ describe('service methods', () => { expect(resource.id).toEqual('31'); expect(resource.type).toEqual('authors'); }); -}); - -let test_response_subject = new BehaviorSubject(new HttpResponse()); -class DynamicHttpHandlerMock implements HttpHandler { - public handle(req: HttpRequest): Observable> { - return test_response_subject.asObservable().pipe(delay(100)); - } -} + it('getOrCreateResource()', () => { + // @todo + }); +}); -describe('Requesting not cached collections. All() method:', () => { +describe('service.all()', () => { let core: Core; - let authorsService: AuthorsService; - - beforeEach(() => { + let booksService: BooksService; + beforeEach(async () => { core = new Core( new JsonapiConfig(), new JsonapiStore(), - new JsonapiHttpImported(new HttpClient(new DynamicHttpHandlerMock()), new JsonapiConfig()) + new JsonapiHttpImported(new HttpClient(new HttpHandlerMock()), new JsonapiConfig()) ); - authorsService = new AuthorsService(); - authorsService.register(); - authorsService.clearCacheMemory(); + booksService = new BooksService(); + booksService.register(); + await booksService.clearCacheMemory(); + test_response_subject.complete(); + test_response_subject = new BehaviorSubject(new HttpResponse()); }); - it(`should emit before the collection is loaded or builded`, done => { + it(`without cached collection emits source ^new-server|`, async () => { let http_request_spy = spyOn(HttpClient.prototype, 'request').and.callThrough(); - test_response_subject.next(new HttpResponse({ body: TestFactory.getCollectionDocumentData(Author) })); + test_response_subject.next(new HttpResponse({ body: TestFactory.getCollectionDocumentData(Book) })); + + let expected = [ + // expected emits + { builded: false, loaded: false, source: 'new' }, + { builded: true, loaded: true, source: 'server' } + ]; - authorsService + let emmits = await booksService .all() - .pipe(first()) - .subscribe(authors => { - expect(authors.is_loading).toBe(true); - expect(authors.loaded).toBe(false); - expect(authors.builded).toBe(false); - expect(authors.source).toBe('new'); - done(); - }); + .pipe( + tap(emmit => { + if (emmit.data.length > 0) { + expect(emmit.data[0].relationships).toHaveProperty('photos'); + expect(emmit.data[0].relationships).toHaveProperty('author'); + } + }), + map(emmit => { + return { builded: emmit.builded, loaded: emmit.loaded, source: emmit.source }; + }), + toArray() + ) + .toPromise(); + expect(emmits).toMatchObject(expected); + expect(http_request_spy).toHaveBeenCalledTimes(1); }); - // NOTE: observable has 200 ms to emit the full collection (fake http delays 100 ms) - it(`when resources are loaded and builded, should emit the resource as loaded and builded from server`, done => { + it(`with cached on memory (live) collection emits source ^memory|`, async () => { + // caching collection + test_response_subject.next(new HttpResponse({ body: TestFactory.getCollectionDocumentData(Book) })); + booksService.collections_ttl = 5; // live + await booksService.all().toPromise(); + let http_request_spy = spyOn(HttpClient.prototype, 'request').and.callThrough(); - test_response_subject.next(new HttpResponse({ body: TestFactory.getCollectionDocumentData(Author) })); + let expected = [ + // expected emits + { builded: true, loaded: true, source: 'memory' } + ]; - authorsService + let emmits = await booksService .all() .pipe( - filter(authors => { - return authors.builded && authors.loaded && !authors.is_loading; // builded, loaded, is_loading checks - }) + map(emmit => { + return { builded: emmit.builded, loaded: emmit.loaded, source: emmit.source }; + }), + toArray() ) - .subscribe(authors => { - expect(http_request_spy).toHaveBeenCalled(); - expect(authors.source).toBe('server'); - expect(authors.data.length).toBeGreaterThan(0); - done(); - }); + .toPromise(); + expect(emmits).toMatchObject(expected); + expect(http_request_spy).toHaveBeenCalledTimes(0); }); -}); -describe('Requesting cached collections from memory. All() method should:', () => { - let core: Core; - let authorsService: AuthorsService; + it(`with cached on memory (dead) collection emits source ^memory-server|`, async () => { + // caching collection + test_response_subject.next(new HttpResponse({ body: TestFactory.getCollectionDocumentData(Book) })); + booksService.collections_ttl = 0; // dead + await booksService.all().toPromise(); - beforeAll(done => { - core = new Core( - new JsonapiConfig(), - new JsonapiStore(), - new JsonapiHttpImported(new HttpClient(new DynamicHttpHandlerMock()), new JsonapiConfig()) - ); - authorsService = new AuthorsService(); - authorsService.collections_ttl = 60; // in seconds - authorsService.register(); - authorsService.clearCacheMemory(); - authorsService.cachestore.deprecateCollections(''); // deprecate all collection cached from previous tests in cachestore + let http_request_spy = spyOn(HttpClient.prototype, 'request').and.callThrough(); + let expected = [ + // expected emits + { builded: true, loaded: false, source: 'memory' }, + { builded: true, loaded: true, source: 'server' } + ]; - // caching resources - test_response_subject.next(new HttpResponse({ body: TestFactory.getCollectionDocumentData(Author) })); - authorsService + let emmits = await booksService .all() .pipe( - filter(authors => { - return authors.builded && authors.loaded && !authors.is_loading; // builded, loaded, is_loading checks - }) + tap(emmit => { + if (emmit.data.length > 0) { + expect(emmit.data[0].relationships).toHaveProperty('photos'); + expect(emmit.data[0].relationships).toHaveProperty('author'); + } + }), + map(emmit => { + return { builded: emmit.builded, loaded: emmit.loaded, source: emmit.source }; + }), + toArray() ) - .subscribe(authors => { - expect(authors.source).toBe('server'); - expect(authors.data.length).toBeGreaterThan(0); - done(); - }); + .toPromise(); + expect(emmits).toMatchObject(expected); + expect(http_request_spy).toHaveBeenCalledTimes(1); }); - it(`return alive collections from memory...`, done => { - expect(authorsService.collections_ttl).toBeGreaterThan(0); // to prevent from removeing or changing this value to 0 + it(`with cached on store (live) collection emits source ^new-store|`, async () => { + // caching collection + test_response_subject.next(new HttpResponse({ body: TestFactory.getCollectionDocumentData(Book) })); + booksService.collections_ttl = 5; // live + await booksService.all().toPromise(); + CacheMemory.getInstance().deprecateCollections(''); // kill only memory cache + let http_request_spy = spyOn(HttpClient.prototype, 'request').and.callThrough(); - test_response_subject.next(new HttpResponse({ body: TestFactory.getCollectionDocumentData(Author) })); + let expected = [ + // expected emits + // source_resource: 'server' because we dont touch child elements + { builded: true, loaded: false, source: 'new', source_resource: 'server' }, + { builded: true, loaded: true, source: 'store', source_resource: 'store' } + ]; - authorsService.all().subscribe(authors => { - expect(http_request_spy).not.toHaveBeenCalled(); - expect(authors.source).toBe('memory'); - expect(authors.data.length).toBeGreaterThan(0); - done(); - }); + let emmits = await booksService + .all() + .pipe( + tap(emmit => { + if (emmit.data.length > 0) { + expect(emmit.data[0].relationships).toHaveProperty('photos'); + expect(emmit.data[0].relationships).toHaveProperty('author'); + } + }), + map(emmit => { + return { builded: emmit.builded, loaded: emmit.loaded, source: emmit.source, source_resource: emmit.data[0].source }; + }), + toArray() + ) + .toPromise(); + expect(emmits).toMatchObject(expected); + expect(http_request_spy).toHaveBeenCalledTimes(0); }); - it(`return alive collections from memory (that were reviously saved in memory from store)...`, done => { - // TODO: test should not know about cachememory methods or properties, but service.clearChacheMemory deprecates store too... - // should we create clearCacheStore and sepatrate it from clearChacheMemory? - (authorsService.cachememory as any).collections = {}; + it(`with cached on store (dead) collection emits source ^new-store-server|`, async () => { + // caching collection + test_response_subject.next(new HttpResponse({ body: TestFactory.getCollectionDocumentData(Book) })); + booksService.collections_ttl = 0; // dead + await booksService.all().toPromise(); + booksService.clearCacheMemory(); // kill only memory cache - expect(authorsService.collections_ttl).toBeGreaterThan(0); // to prevent from removeing or changing this value to 0 let http_request_spy = spyOn(HttpClient.prototype, 'request').and.callThrough(); - test_response_subject.next(new HttpResponse({ body: TestFactory.getCollectionDocumentData(Author) })); + let expected = [ + // expected emits + { builded: true, loaded: false, source: 'new' }, + // { builded: true, loaded: false, source: 'store' }, // @todo + { builded: true, loaded: true, source: 'server' } + ]; - authorsService + let emmits = await booksService .all() .pipe( - filter(authors => { - return authors.builded && authors.loaded && !authors.is_loading; // builded, loaded, is_loading checks - }) + map(emmit => { + return { builded: emmit.builded, loaded: emmit.loaded, source: emmit.source }; + }), + toArray() ) - .subscribe(authors => { - expect(http_request_spy).not.toHaveBeenCalled(); - expect(authors.source).toBe('store'); - expect(authors.data.length).toBeGreaterThan(0); - // done(); - authorsService - .all() - .pipe( - filter(memory_authors => { - // builded, loaded, is_loading checks - return memory_authors.builded && memory_authors.loaded && !memory_authors.is_loading; - }) - ) - .subscribe(memory_authors => { - expect(http_request_spy).not.toHaveBeenCalled(); - expect(memory_authors.source).toBe('memory'); - expect(memory_authors.data.length).toBeGreaterThan(0); - done(); - }); - }); + .toPromise(); + expect(emmits).toMatchObject(expected); + expect(http_request_spy).toHaveBeenCalledTimes(1); }); }); -describe('Requesting cached collections from store. All() method:', () => { +describe('service.all() and next service.get()', () => { let core: Core; let authorsService: AuthorsService; - - beforeAll(done => { + beforeEach(async () => { core = new Core( new JsonapiConfig(), new JsonapiStore(), - new JsonapiHttpImported(new HttpClient(new DynamicHttpHandlerMock()), new JsonapiConfig()) + new JsonapiHttpImported(new HttpClient(new HttpHandlerMock()), new JsonapiConfig()) ); authorsService = new AuthorsService(); - authorsService.collections_ttl = 61; // in seconds authorsService.register(); - authorsService.clearCacheMemory(); - authorsService.cachestore.deprecateCollections(''); // deprecate all collection cached from previous tests in cachestore + await authorsService.clearCacheMemory(); + }); - // caching resources + it(`with cached collection on memory and next request get() with new include`, async () => { + let http_request_spy = spyOn(HttpClient.prototype, 'request').and.callThrough(); test_response_subject.next(new HttpResponse({ body: TestFactory.getCollectionDocumentData(Author) })); - authorsService - .all() + + let expected = [ + // expected emits + { builded: false, loaded: false, source: 'new' }, + { builded: true, loaded: true, source: 'server' } + ]; + + let authors = await authorsService.all({ include: ['books'] }).toPromise(); + let author = await authorsService.get(authors.data[0].id, { include: ['photos', 'books'] }).toPromise(); + + // @todo + }); + + it(`with cached collection on store and next request get() with new include`, async () => { + // + }); + + it(`with cached collection on memory and next request get() without include`, async () => { + // + }); + + it(`with cached collection on store and next request get() without include`, async () => { + // + }); +}); + +describe('service.get()', () => { + let core: Core; + let booksService: BooksService; + let authorsService: AuthorsService; + beforeEach(async () => { + core = new Core( + new JsonapiConfig(), + new JsonapiStore(), + new JsonapiHttpImported(new HttpClient(new HttpHandlerMock()), new JsonapiConfig()) + ); + booksService = new BooksService(); + booksService.register(); + await booksService.clearCacheMemory(); + authorsService = new AuthorsService(); + authorsService.register(); + await authorsService.clearCacheMemory(); + test_response_subject.complete(); + test_response_subject = new BehaviorSubject(new HttpResponse()); + }); + + it(`no cached resource emits source ^new-server|`, async () => { + // @todo + }); + + it(`memory cached (live) resource emits source ^memory|`, async () => { + test_response_subject.next(new HttpResponse({ body: TestFactory.getResourceDocumentData(Book) })); + // caching resource + await booksService.get('1').toPromise(); + + let http_request_spy = spyOn(HttpClient.prototype, 'request').and.callThrough(); + let expected = [ + // expected emits + { loaded: true, source: 'memory' } + ]; + let emmits = await booksService + .get('1', { ttl: 1000 }) .pipe( - filter(authors => { - return authors.builded && authors.loaded && !authors.is_loading; // builded, loaded, is_loading checks - }) + map(emmit => { + return { loaded: emmit.loaded, source: emmit.source }; + }), + toArray() ) - .subscribe(authors => { - expect(authors.source).toBe('server'); - expect(authors.data.length).toBeGreaterThan(0); - done(); - }); + .toPromise(); + expect(emmits).toMatchObject(expected); + expect(http_request_spy).toHaveBeenCalledTimes(0); }); - beforeEach(() => { - // TODO: test should not know about cachememory methods or properties, but service.clearChacheMemory deprecates store too... - // should we create clearCacheStore and sepatrate it from clearChacheMemory? - (authorsService.cachememory as any).collections = {}; - }); + it(`on memory (live) resource + include existent has-one-relationship emits source ^memory-server|`, async () => { + let body_resource = TestFactory.getResourceDocumentData(Book); + body_resource.data.relationships = { author: { data: { id: '1', type: 'authors' } } }; + test_response_subject.next(new HttpResponse({ body: body_resource })); + // caching resource + await booksService.get('1').toPromise(); - it(`emit before it's loaded or builded`, done => { let http_request_spy = spyOn(HttpClient.prototype, 'request').and.callThrough(); - test_response_subject.next(new HttpResponse({ body: TestFactory.getCollectionDocumentData(Author) })); + let expected = [ + // expected emits + { loaded: false, source: 'memory' }, + { loaded: true, source: 'server' } + ]; + let emmits = await booksService + .get('1', { ttl: 1000, include: ['author'] }) + .pipe( + map(emmit => { + return { loaded: emmit.loaded, source: emmit.source }; + }), + toArray() + ) + .toPromise(); + expect(emmits).toMatchObject(expected); + expect(http_request_spy).toHaveBeenCalledTimes(1); + }); - authorsService - .all() - .pipe(first()) - .subscribe(authors => { - expect(authors.is_loading).toBe(true); - expect(authors.loaded).toBe(false); - expect(authors.builded).toBe(false); - expect(authors.source).toBe('new'); - done(); - }); + it(`on memory (live) resource + include existent has-many-relationship emits source ^memory-server|`, async () => { + let body_resource = TestFactory.getResourceDocumentData(Author); + body_resource.data.id = '555'; + body_resource.data.relationships = { books: { data: [{ id: '555', type: 'books' }] } }; + test_response_subject.next(new HttpResponse({ body: body_resource })); + // caching resource + await authorsService.get('555').toPromise(); + + let expected = [ + // expected emits + { loaded: false, source: 'memory' }, + { loaded: true, source: 'server' } + ]; + let emmits = await authorsService + .get('555', { ttl: 1000, include: ['books'] }) + .pipe( + map(emmit => { + return { loaded: emmit.loaded, source: emmit.source }; + }), + toArray() + ) + .toPromise(); + expect(emmits).toMatchObject(expected); }); - it(`return alive collections from store...`, done => { - expect(authorsService.collections_ttl).toBeGreaterThan(0); // to prevent from removeing or changing this value to 0 + it(`with cached on memory (live) resource + include empty has-one-relationship emits source ^memory|`, async () => { + let body_resource = TestFactory.getResourceDocumentData(Book); + body_resource.data.relationships = { photos: { data: [] } }; + test_response_subject.next(new HttpResponse({ body: body_resource })); + // caching resource + await booksService.get('1').toPromise(); + let http_request_spy = spyOn(HttpClient.prototype, 'request').and.callThrough(); - test_response_subject.next(new HttpResponse({ body: TestFactory.getCollectionDocumentData(Author) })); + let expected = [ + // expected emits + { loaded: true, source: 'memory' } + ]; + let emmits = await booksService + .get('1', { ttl: 1000, include: ['photos'] }) + .pipe( + tap(emmit => { + // expect(emmit.data[0].relationships).toHaveProperty('author'); + }), + map(emmit => { + return { loaded: emmit.loaded, source: emmit.source }; + }), + toArray() + ) + .toPromise(); + expect(emmits).toMatchObject(expected); + expect(http_request_spy).toHaveBeenCalledTimes(0); + }); - authorsService - .all() + it(`with cached on memory (live) resource + include empty has-many-relationship emits source ^memory|`, async () => { + let body_resource = TestFactory.getResourceDocumentData(Author); + body_resource.data.id = '556'; + body_resource.data.relationships = { books: { data: [] } }; + test_response_subject.next(new HttpResponse({ body: body_resource })); + // caching resource + await authorsService.get('556').toPromise(); + + let expected = [ + // expected emits + { loaded: true, source: 'memory' } + ]; + let emmits = await authorsService + .get('556', { ttl: 1000, include: ['books'] }) .pipe( - filter(authors => { - return authors.builded && authors.loaded && !authors.is_loading; // builded, loaded, is_loading checks - }) + map(emmit => { + return { loaded: emmit.loaded, source: emmit.source }; + }), + toArray() ) - .subscribe(authors => { - expect(http_request_spy).not.toHaveBeenCalled(); - expect(authors.source).toBe('store'); - expect(authors.data.length).toBeGreaterThan(0); - done(); - }); + .toPromise(); + expect(emmits).toMatchObject(expected); + }); + + it(`with cached on memory (dead) resource emits source ^memory-server|`, async () => { + // @todo + }); + + it(`with cached on store (live) resource emits source ^new-store|`, async () => { + // @todo + }); + + it(`with cached on store (live) resource but with new include emits source ^store-server|`, async () => { + // @todo + }); + + it(`with cached on store (dead) resource emits source ^new-store-server|`, async () => { + // @todo }); }); diff --git a/src/service.ts b/src/service.ts index 04859262..1c2b6efa 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1,21 +1,22 @@ +import { first } from 'rxjs/operators'; import { Core } from './core'; +import { IBuildedParamsCollection } from './interfaces/params-collection'; import { Base } from './services/base'; import { Resource } from './resource'; import { PathBuilder } from './services/path-builder'; import { Converter } from './services/converter'; import { CacheMemory } from './services/cachememory'; -import { CacheStore } from './services/cachestore'; import { IParamsCollection, IParamsResource, IAttributes } from './interfaces'; import { DocumentCollection } from './document-collection'; -import { isLive } from './common'; +import { isLive, relationshipsAreBuilded } from './common'; import { Observable, BehaviorSubject, Subject } from 'rxjs'; -import { IDataObject } from './interfaces/data-object'; +import { IDocumentResource } from './interfaces/data-object'; import { PathCollectionBuilder } from './services/path-collection-builder'; -import { IDataCollection } from './interfaces/data-collection'; +import { IDataCollection, ICacheableDataCollection } from './interfaces/data-collection'; +import { JsonRipper } from './services/json-ripper'; +import { DexieDataProvider } from './data-providers/dexie-data-provider'; export class Service { - public cachememory: CacheMemory; - public cachestore: CacheStore; public type: string; public resource = Resource; public collections_ttl: number; @@ -29,9 +30,6 @@ export class Service { if (Core.me === null) { throw new Error('Error: you are trying register `' + this.type + '` before inject JsonapiCore somewhere, almost one time.'); } - // only when service is registered, not cloned object - this.cachememory = new CacheMemory(); - this.cachestore = new CacheStore(); return Core.me.registerService(this); } @@ -65,68 +63,89 @@ export class Service { return this.path || this.type; } + // if you change this logic, maybe you need to change all() public get(id: string, params: IParamsResource = {}): Observable { params = { ...Base.ParamsResource, ...params }; - // http request let path = new PathBuilder(); path.applyParams(this, params); path.appendPath(id); - // CACHEMEMORY let resource: R = this.getOrCreateResource(id); - resource.is_loading = true; - resource.loaded = false; + resource.setLoaded(false); let subject = new BehaviorSubject(resource); - // when fields is set, get resource form server - if (isLive(resource, params.ttl) && Object.keys(params.fields).length === 0) { + if (Object.keys(params.fields || []).length > 0) { + // memory/store cache dont suppont fields + this.getGetFromServer(path, resource, subject); + } else if (isLive(resource, params.ttl) && relationshipsAreBuilded(resource, params.include || [])) { + // data on memory and its live resource.setLoaded(true); - resource.source = 'memory'; - setTimeout(() => { - subject.complete(); - }); - } else if (Core.injectedServices.rsJsonapiConfig.cachestore_support) { - // CACHESTORE - this.getService() - .cachestore.getResource(resource, params.include) + setTimeout(() => subject.complete(), 0); + } else if (resource.cache_last_update === 0) { + // we dont have any data on memory + this.getGetFromLocal(params, path, resource) .then(() => { - // when fields is set, get resource form server - if (!isLive(resource, params.ttl) || Object.keys(params.fields).length > 0) { - subject.next(resource); - throw new Error('No está viva la caché de IndexedDB'); - } - resource.setLoadedAndPropagate(true); - resource.source = 'store'; subject.next(resource); - subject.complete(); + setTimeout(() => subject.complete(), 0); }) .catch(() => { + resource.setLoaded(false); this.getGetFromServer(path, resource, subject); }); } else { this.getGetFromServer(path, resource, subject); } - subject.next(resource); return subject.asObservable(); } + // if you change this logic, maybe you need to change getAllFromLocal() + private async getGetFromLocal(params: IParamsCollection = {}, path: PathBuilder, resource: R): Promise { + // STORE + if (!Core.injectedServices.rsJsonapiConfig.cachestore_support) { + throw new Error('We cant handle this request'); + } + + resource.setLoaded(false); + + // STORE (individual) + let json_ripper = new JsonRipper(); + let success = await json_ripper.getResource(JsonRipper.getResourceKey(resource), path.includes); + + resource.fill(success); + resource.setSource('store'); + + // when fields is set, get resource form server + if (isLive(resource, params.ttl)) { + resource.setLoadedAndPropagate(true); + // resource.setBuildedAndPropagate(true); + + return; + } + } + + // if you change this logic, maybe you need to change getAllFromServer() protected getGetFromServer(path, resource: R, subject: Subject): void { Core.get(path.get()).subscribe( success => { - resource.fill(success); + resource.fill(success); + resource.cache_last_update = Date.now(); resource.setLoadedAndPropagate(true); resource.setSourceAndPropagate('server'); - this.getService().cachememory.setResource(resource, true); + + // this.getService().cachememory.setResource(resource, true); if (Core.injectedServices.rsJsonapiConfig.cachestore_support) { - this.getService().cachestore.setResource(resource); + let json_ripper = new JsonRipper(); + json_ripper.saveResource(resource, path.includes); } subject.next(resource); - subject.complete(); + setTimeout(() => subject.complete(), 0); }, error => { + resource.setLoadedAndPropagate(true); + subject.next(resource); subject.error(error); } ); @@ -138,42 +157,51 @@ export class Service { public getOrCreateCollection(path: PathCollectionBuilder): DocumentCollection { const service = this.getService(); - const collection = >service.cachememory.getOrCreateCollection(path.getForCache()); + const collection = >CacheMemory.getInstance().getOrCreateCollection(path.getForCache()); collection.ttl = service.collections_ttl; + if (collection.source !== 'new') { + collection.source = 'memory'; + } return collection; } public getOrCreateResource(id: string): R { - let service = Converter.getService(this.type); - if (service.cachememory && id in service.cachememory.resources) { - return service.cachememory.resources[id]; - } else { - let resource = service.new(); + let service = Converter.getServiceOrFail(this.type); + let resource: R; + + resource = CacheMemory.getInstance().getResource(this.type, id); + if (resource === null) { + resource = service.new(); resource.id = id; - service.cachememory.setResource(resource, false); + CacheMemory.getInstance().setResource(resource, false); + } - return resource; + if (resource.source !== 'new') { + resource.source = 'memory'; } + + return resource; } public createResource(id: string): R { - let service = Converter.getService(this.type); + let service = Converter.getServiceOrFail(this.type); let resource = service.new(); resource.id = id; - service.cachememory.setResource(resource, false); + CacheMemory.getInstance().setResource(resource, false); return resource; } - public clearCacheMemory(): boolean { + public async clearCacheMemory(): Promise { let path = new PathBuilder(); path.applyParams(this); - return ( - this.getService().cachememory.deprecateCollections(path.getForCache()) && - this.getService().cachestore.deprecateCollections(path.getForCache()) - ); + CacheMemory.getInstance().deprecateCollections(path.getForCache()); + + let json_ripper = new JsonRipper(); + + return json_ripper.deprecateCollection(path.getForCache()).then(() => true); } public parseToServer(attributes: IAttributes): void { @@ -196,7 +224,7 @@ export class Service { Core.delete(path.get()).subscribe( success => { - this.getService().cachememory.removeResource(id); + CacheMemory.getInstance().removeResource(this.type, id); subject.next(); subject.complete(); }, @@ -208,85 +236,78 @@ export class Service { return subject.asObservable(); } + // if you change this logic, maybe you need to change get() public all(params: IParamsCollection = {}): Observable> { - params = { ...Base.ParamsCollection, ...params }; + let builded_params: IBuildedParamsCollection = { ...Base.ParamsCollection, ...params }; let path = new PathCollectionBuilder(); - path.applyParams(this, params); + path.applyParams(this, builded_params); - // make request let temporary_collection = this.getOrCreateCollection(path); - // if (temporary_collection.ttl === 61) { - // console.log('path --->', path); - // console.log('temporary_collection --->', temporary_collection); - // } - temporary_collection.page.number = params.page.number * 1; + temporary_collection.page.number = builded_params.page.number * 1; let subject = new BehaviorSubject>(temporary_collection); - // when fields is set, get resource form server - if (isLive(temporary_collection, params.ttl) && Object.keys(params.fields).length === 0) { - temporary_collection.source = 'memory'; - subject.next(temporary_collection); + if (Object.keys(builded_params.fields).length > 0) { + // memory/store cache dont suppont fields + this.getAllFromServer(path, builded_params, temporary_collection, subject); + } else if (isLive(temporary_collection, builded_params.ttl)) { + // data on memory and its live setTimeout(() => subject.complete(), 0); - } else if (Core.injectedServices.rsJsonapiConfig.cachestore_support && params.store_cache_method === 'individual') { - // STORE (individual) - temporary_collection.setLoaded(false); - - this.getService() - .cachestore.fillCollectionFromStore(path.getForCache(), path.includes, temporary_collection) - .subscribe( - () => { - temporary_collection.source = 'store'; - - // when load collection from store, we save collection on memory - this.getService().cachememory.setCollection(path.getForCache(), temporary_collection); - - // when fields is set, get resource form server - if (isLive(temporary_collection, params.ttl) && Object.keys(params.fields).length === 0) { - temporary_collection.setLoadedAndPropagate(true); - temporary_collection.setBuildedAndPropagate(true); - subject.next(temporary_collection); - subject.complete(); - } else { - this.getAllFromServer(path, params, temporary_collection, subject); - } - }, - err => { - this.getAllFromServer(path, params, temporary_collection, subject); - } - ); - } else if (Core.injectedServices.rsJsonapiConfig.cachestore_support && params.store_cache_method === 'compact') { - // STORE (compact) - temporary_collection.setLoaded(false); - - Core.injectedServices.JsonapiStoreService.getDataObject('collection', path.getForCache() + '.compact').subscribe( - success => { - temporary_collection.source = 'store'; - temporary_collection.fill(success); - temporary_collection.cache_last_update = success._lastupdate_time; - - // when fields is set, get resource form server - if (isLive(temporary_collection, params.ttl) && Object.keys(params.fields).length === 0) { - temporary_collection.setLoadedAndPropagate(true); - temporary_collection.setBuildedAndPropagate(true); - subject.next(temporary_collection); - subject.complete(); - } else { - this.getAllFromServer(path, params, temporary_collection, subject); - } - }, - err => { - this.getAllFromServer(path, params, temporary_collection, subject); - } - ); + } else if (temporary_collection.cache_last_update === 0) { + // we dont have any data on memory + temporary_collection.source = 'new'; + this.getAllFromLocal(builded_params, path, temporary_collection) + .then(() => { + subject.next(temporary_collection); + setTimeout(() => subject.complete(), 0); + }) + .catch(() => { + temporary_collection.setLoaded(false); + this.getAllFromServer(path, builded_params, temporary_collection, subject); + }); } else { - this.getAllFromServer(path, params, temporary_collection, subject); + this.getAllFromServer(path, builded_params, temporary_collection, subject); } return subject.asObservable(); } + // if you change this logic, maybe you need to change getGetFromLocal() + private async getAllFromLocal( + params: IParamsCollection = {}, + path: PathCollectionBuilder, + temporary_collection: DocumentCollection + ): Promise { + // STORE + if (!Core.injectedServices.rsJsonapiConfig.cachestore_support) { + throw new Error('We cant handle this request'); + } + + temporary_collection.setLoaded(false); + + let success: ICacheableDataCollection; + if (params.store_cache_method === 'compact') { + // STORE (compact) + success = await Core.injectedServices.JsonapiStoreService.getDataObject('collection', path.getForCache() + '.compact'); + } else { + // STORE (individual) + let json_ripper = new JsonRipper(); + success = await json_ripper.getCollection(path.getForCache(), path.includes); + } + temporary_collection.fill(success); + temporary_collection.setSourceAndPropagate('store'); + + // when fields is set, get resource form server + if (isLive(temporary_collection, params.ttl)) { + temporary_collection.setLoadedAndPropagate(true); + temporary_collection.setBuildedAndPropagate(true); + + return; + } + } + + // if you change this logic, maybe you need to change getGetFromServer() protected getAllFromServer( path: PathBuilder, params: IParamsCollection, @@ -294,7 +315,6 @@ export class Service { subject: BehaviorSubject> ) { temporary_collection.setLoaded(false); - subject.next(temporary_collection); Core.get(path.get()).subscribe( success => { // this create a new ID for every resource (for caching proposes) @@ -309,20 +329,17 @@ export class Service { } temporary_collection.fill(success); temporary_collection.cache_last_update = Date.now(); - temporary_collection.source = 'server'; + temporary_collection.setSourceAndPropagate('server'); temporary_collection.setLoadedAndPropagate(true); - this.getService().cachememory.setCollection(path.getForCache(), temporary_collection); - if (Core.injectedServices.rsJsonapiConfig.cachestore_support) { - // setCollection takes 1 ms per item - this.getService().cachestore.setCollection(path.getForCache(), temporary_collection, params.include); - if (params.store_cache_method === 'compact') { - Core.injectedServices.JsonapiStoreService.saveCollection(path.getForCache() + '.compact', success); - } + // this.getService().cachememory.setCollection(path.getForCache(), temporary_collection); + let json_ripper = new JsonRipper(); + json_ripper.saveCollection(path.getForCache(), temporary_collection, path.includes); + if (Core.injectedServices.rsJsonapiConfig.cachestore_support && params.store_cache_method === 'compact') { + Core.injectedServices.JsonapiStoreService.saveCollection(path.getForCache() + '.compact', success); } - subject.next(temporary_collection); - subject.complete(); + setTimeout(() => subject.complete(), 0); }, error => { temporary_collection.setLoadedAndPropagate(true); diff --git a/src/services/base.ts b/src/services/base.ts index 78d85945..a2c901b2 100644 --- a/src/services/base.ts +++ b/src/services/base.ts @@ -1,4 +1,4 @@ -import { IParamsCollection, IParamsResource } from '../interfaces'; +import { IBuildedParamsCollection, IParamsCollection, IParamsResource } from '../interfaces'; import { Page } from './page'; import { Resource } from '../resource'; import { DocumentCollection } from '../document-collection'; @@ -6,15 +6,15 @@ import { DocumentCollection } from '../document-collection'; export class Base { public static ParamsResource: IParamsResource = { beforepath: '', - ttl: null, + ttl: undefined, include: [], fields: {}, id: '' }; - public static ParamsCollection: IParamsCollection = { + public static ParamsCollection: IBuildedParamsCollection = { beforepath: '', - ttl: null, + ttl: undefined, include: [], remotefilter: {}, fields: {}, diff --git a/src/services/cacheable-helper..ts b/src/services/cacheable-helper..ts new file mode 100644 index 00000000..46bf691e --- /dev/null +++ b/src/services/cacheable-helper..ts @@ -0,0 +1,15 @@ +import { DocumentCollection } from './../document-collection'; +import { IRelationships } from './../interfaces/relationship'; + +export class CacheableHelper { + public static propagateLoaded(relationships: IRelationships, value: boolean): void { + for (let relationship_alias in relationships) { + let relationship = relationships[relationship_alias]; + if (relationship instanceof DocumentCollection) { + // we need to add builded, becuase we dont save objects with content='ids'. + // these relationships are broken (without any data on data) + relationship.setLoaded(value && relationship.builded); + } + } + } +} diff --git a/src/services/cachememory.spec.ts b/src/services/cachememory.spec.ts new file mode 100644 index 00000000..9daabfd4 --- /dev/null +++ b/src/services/cachememory.spec.ts @@ -0,0 +1,174 @@ +import { Author, AuthorsService } from './../tests/factories/authors.service'; +import { CacheMemory } from './cachememory'; +import { TestFactory } from '../tests/factories/test-factory'; + +import { Core } from '../core'; +import { StoreService as JsonapiStore } from '../sources/store.service'; +import { Http as JsonapiHttpImported } from '../sources/http.service'; +import { HttpClient, HttpEvent, HttpHandler, HttpRequest, HttpResponse } from '@angular/common/http'; +import { JsonapiConfig } from '../jsonapi-config'; + +import { BehaviorSubject, Observable } from 'rxjs'; + +class HttpHandlerMock implements HttpHandler { + public handle(req: HttpRequest): Observable> { + let subject = new BehaviorSubject(new HttpResponse()); + + return subject.asObservable(); + } +} + +describe('Cache Memory deprecation and live conditions', () => { + it('collections cache_last_update', async () => { + let cachememory = CacheMemory.getInstance(); + let collection = TestFactory.getCollection(Author); + cachememory.setCollection('authors', collection); + + let collection_on_memory = cachememory.getOrCreateCollection('authors'); + expect(collection.cache_last_update).toBeGreaterThan(0); + expect(collection_on_memory.cache_last_update).toBe(collection.cache_last_update); + }); + + it('deprecateCollections(``) deprecate all', async () => { + let cachememory = CacheMemory.getInstance(); + let collection = TestFactory.getCollection(Author); + cachememory.setCollection('authors', collection); + + let collection_on_memory = cachememory.getOrCreateCollection('authors'); + expect(collection_on_memory.cache_last_update).toBe(collection.cache_last_update); + expect(collection_on_memory.cache_last_update).toBeGreaterThan(0); + + cachememory.deprecateCollections('extrange_type'); + collection_on_memory = cachememory.getOrCreateCollection('authors'); + expect(collection_on_memory.cache_last_update).toBe(collection.cache_last_update); + + cachememory.deprecateCollections(''); + collection_on_memory = cachememory.getOrCreateCollection('authors'); + expect(collection_on_memory.cache_last_update).toBe(0); + }); + + it('deprecateCollections(`some_type`) deprecate only some_type collections', async () => { + let cachememory = CacheMemory.getInstance(); + let collection = TestFactory.getCollection(Author); + cachememory.setCollection('authors', collection); + + let collection_on_memory = cachememory.getOrCreateCollection('authors'); + expect(collection_on_memory.cache_last_update).toBe(collection.cache_last_update); + expect(collection_on_memory.cache_last_update).toBeGreaterThan(0); + + cachememory.deprecateCollections('extrange_type'); + collection_on_memory = cachememory.getOrCreateCollection('authors'); + expect(collection_on_memory.cache_last_update).toBe(collection.cache_last_update); + + cachememory.deprecateCollections('auth'); + collection_on_memory = cachememory.getOrCreateCollection('authors'); + expect(collection_on_memory.cache_last_update).toBe(0); + }); + + it('getResource() should return the stored resource', () => { + let cachememory = CacheMemory.getInstance(); + let author = TestFactory.getAuthor(); + cachememory.setResource(author, true); + + let author_on_memory = cachememory.getResource('authors', author.id); + expect(author_on_memory).toBeTruthy(); + }); + + it('getResource() should return null when the requested resource does not exist', () => { + let cachememory = CacheMemory.getInstance(); + let author_on_memory = cachememory.getResource('authors', 'some non stored author'); + expect(author_on_memory).toBe(null); + }); + + it('getResourceOrFail() should return the stored resource if exists', () => { + let cachememory = CacheMemory.getInstance(); + let author = TestFactory.getAuthor(); + cachememory.setResource(author, true); + + let author_on_memory = cachememory.getResourceOrFail('authors', author.id); + expect(author_on_memory).toBeTruthy(); + }); + + it('getResourceOrFail() should throw an error if the requested resource does not exist', () => { + let cachememory = CacheMemory.getInstance(); + expect(() => { + cachememory.getResourceOrFail('authors', 'new_' + Math.floor(Math.random() * 6)); + }).toThrow(new Error('The requested resource does not exist in cache memory')); + }); + + it('getOrCreateResource() should return the requested resource', () => { + let cachememory = CacheMemory.getInstance(); + let author = TestFactory.getAuthor(); + cachememory.setResource(author, true); + + let author_on_memory = cachememory.getOrCreateResource('authors', author.id); + expect(author_on_memory).toEqual(author); + }); + + it('getOrCreateResource() should throw an error if the requested service does not exist', () => { + let core = new Core( + new JsonapiConfig(), + new JsonapiStore(), + new JsonapiHttpImported(new HttpClient(new HttpHandlerMock()), new JsonapiConfig()) + ); + // let service = new AuthorsService(); + + let cachememory = CacheMemory.getInstance(); + expect(() => { + cachememory.getOrCreateResource('authors', 'new_' + Math.floor(Math.random() * 6)); + }).toThrow(new Error('The requested service has not been registered, please use register() method or @Autoregister() decorator')); + }); + + it('getOrCreateResource() should return a new resource when the requested resource does not exist', () => { + let core = new Core( + new JsonapiConfig(), + new JsonapiStore(), + new JsonapiHttpImported(new HttpClient(new HttpHandlerMock()), new JsonapiConfig()) + ); + let service = new AuthorsService(); + + let cachememory = CacheMemory.getInstance(); + let author_on_memory = cachememory.getOrCreateResource('authors', 'new_' + Math.floor(Math.random() * 6)); + expect(author_on_memory.is_new).toBeTruthy(); + }); + + it('resource cache_last_update with update_lastupdate', async () => { + let cachememory = CacheMemory.getInstance(); + let author = TestFactory.getAuthor(); + cachememory.setResource(author, true); + + let author_on_memory = cachememory.getOrCreateResource('authors', author.id); + expect(author.cache_last_update).toBeGreaterThan(0); + expect(author_on_memory.cache_last_update).toBe(author.cache_last_update); + }); + + it('resource cache_last_update without update_lastupdate', async () => { + let cachememory = CacheMemory.getInstance(); + let author = TestFactory.getAuthor(); + cachememory.setResource(author); + + let author_on_memory = cachememory.getOrCreateResource('authors', author.id); + expect(author.cache_last_update).toBe(0); + expect(author_on_memory.cache_last_update).toBe(author.cache_last_update); + }); + + it('removeResource()', async () => { + let cachememory = CacheMemory.getInstance(); + let author = TestFactory.getAuthor(); + cachememory.setResource(author, true); + + let author_on_memory = cachememory.getOrCreateResource('authors', author.id); + expect(author.cache_last_update).toBeGreaterThan(0); + expect(author_on_memory.cache_last_update).toBe(author.cache_last_update); + + cachememory.removeResource('authors', author.id); + let removed_author = cachememory.getResource('authors', author.id); + expect(removed_author).toBe(null); + }); + + it('removeResource() with fake id should not fail', async () => { + let cachememory = CacheMemory.getInstance(); + cachememory.removeResource('authors', 'some fake id'); + expect(true).toBeTruthy(); + }); +}); diff --git a/src/services/cachememory.ts b/src/services/cachememory.ts index a3e7c9e2..cdd36842 100644 --- a/src/services/cachememory.ts +++ b/src/services/cachememory.ts @@ -5,20 +5,38 @@ import { DocumentCollection } from '../document-collection'; import { IObjectsById } from '../interfaces'; export class CacheMemory { - public resources: IObjectsById = {}; + private resources: IObjectsById = {}; private collections: { [url: string]: DocumentCollection } = {}; - private collections_lastupdate: { [url: string]: number } = {}; + private static instance: CacheMemory; - public isCollectionExist(url: string): boolean { - return url in this.collections && this.collections[url].source !== 'new' ? true : false; + private constructor() {} + + public static getInstance(): CacheMemory { + if (!CacheMemory.instance) { + CacheMemory.instance = new CacheMemory(); + } + + return CacheMemory.instance; } - public isCollectionLive(url: string, ttl: number): boolean { - return Date.now() <= this.collections_lastupdate[url] + ttl * 1000; + public getResource(type: string, id: string): Resource | null { + if (this.getKey(type, id) in this.resources) { + return this.resources[this.getKey(type, id)]; + } + + return null; } - public isResourceLive(id: string, ttl: number): boolean { - return this.resources[id] && Date.now() <= this.resources[id].cache_last_update + ttl * 1000; + public getResourceOrFail(type: string, id: string): Resource { + if (this.getKey(type, id) in this.resources) { + return this.resources[this.getKey(type, id)]; + } + + throw new Error('The requested resource does not exist in cache memory'); + } + + private getKey(type: string, id: string): string { + return type + '.' + id; } public getOrCreateCollection(url: string): DocumentCollection { @@ -42,110 +60,120 @@ export class CacheMemory { } this.collections[url].data = collection.data; this.collections[url].page = collection.page; - // this.collections_lastupdate[url] = Date.now(); - this.collections_lastupdate[url] = collection.cache_last_update; + this.collections[url].cache_last_update = collection.cache_last_update; } public getOrCreateResource(type: string, id: string): Resource { - if (Converter.getService(type).cachememory && id in Converter.getService(type).cachememory.resources) { - return Converter.getService(type).cachememory.resources[id]; - } else { - let resource = Converter.getService(type).new(); - resource.id = id; - // needed for a lot of request (all and get, tested on multinexo.com) - this.setResource(resource, false); - + let resource = this.getResource(type, id); + if (resource !== null) { return resource; } + + resource = Converter.getServiceOrFail(type).new(); + resource.id = id; + // needed for a lot of request (all and get, tested on multinexo.com) + this.setResource(resource, false); + + return resource; } public setResource(resource: Resource, update_lastupdate = false): void { - if (resource.id in this.resources) { - this.addResourceOrFill(resource); + if (this.getKey(resource.type, resource.id) in this.resources) { + this.fillExistentResource(resource); } else { - this.resources[resource.id] = resource; + this.resources[this.getKey(resource.type, resource.id)] = resource; } - this.resources[resource.id].cache_last_update = update_lastupdate ? Date.now() : 0; + this.resources[this.getKey(resource.type, resource.id)].cache_last_update = update_lastupdate ? Date.now() : 0; } public deprecateCollections(path_includes: string = ''): boolean { - for (let collection_key in this.collections_lastupdate) { + for (let collection_key in this.collections) { if (collection_key.includes(path_includes)) { - this.collections_lastupdate[collection_key] = 0; + this.collections[collection_key].cache_last_update = 0; } } return true; } - public removeResource(id: string): void { + public removeResource(type: string, id: string): void { + let resource = this.getResource(type, id); + if (!resource) { + return; + } Base.forEach(this.collections, (value, url) => { - value.data.splice(value.data.findIndex(resource => resource.id === id), 1); + value.data.splice( + value.data.findIndex( + (resource_on_collection: Resource) => resource_on_collection.type === type && resource_on_collection.id === id + ), + 1 + ); }); - this.resources[id].attributes = {}; // just for confirm deletion on view + resource.attributes = {}; // just for confirm deletion on view + // this.resources[id].relationships = {}; // just for confirm deletion on view - for (let relationship in this.resources[id].relationships) { - if (this.resources[id].relationships[relationship].data === null) { + for (let relationship in resource.relationships) { + if (resource.relationships[relationship].data === null || resource.relationships[relationship].data === undefined) { continue; } - if (this.resources[id].relationships[relationship].data.constructor === Array) { - this.resources[id].relationships[relationship].data = []; // just in case that there is a for loop using it - } else if (this.resources[id].relationships[relationship].data.constructor === Object) { - delete this.resources[id].relationships[relationship].data; + if (resource.relationships[relationship].data instanceof Array) { + resource.relationships[relationship].data = []; // just in case that there is a for loop using it + } else if (resource.relationships[relationship].data instanceof Object) { + delete resource.relationships[relationship].data; } } - delete this.resources[id]; + delete this.resources[this.getKey(type, id)]; } - private addResourceOrFill(source: Resource): void { - let destination = this.resources[source.id]; + private fillExistentResource(source: Resource): void { + let destination = this.getResourceOrFail(source.type, source.id); - destination.attributes = source.attributes; + destination.attributes = { ...destination.attributes, ...source.attributes }; destination.relationships = destination.relationships || source.relationships; // remove relationships on destination resource - for (let type_alias in destination.relationships) { - // problem with no declared services - if (destination.relationships[type_alias].data === undefined) { - continue; - } - - if (!(type_alias in source.relationships)) { - delete destination.relationships[type_alias]; - } else { - // relation is a collection - let collection = destination.relationships[type_alias]; - // TODO: talkto Pablo, this could be and Object... (following IF statement added by Maxi) - if (!Array.isArray(collection.data)) { - continue; - } - for (let resource of collection.data) { - if (collection.find(resource.id) === null) { - delete destination.relationships[type_alias]; - } - } - } - } - - // add source relationships to destination - for (let type_alias in source.relationships) { - // problem with no declared services - if (source.relationships[type_alias].data === undefined) { - continue; - } - - if (source.relationships[type_alias].data === null) { - // TODO: FE-92 --- check and improve conditions when building has-one relationships - destination.relationships[type_alias].data = null; - continue; - } - - if ('id' in source.relationships[type_alias].data) { - destination.addRelationship(source.relationships[type_alias].data, type_alias); - } else { - destination.addRelationships(>source.relationships[type_alias].data, type_alias); - } - } + // for (let type_alias in destination.relationships) { + // // problem with no declared services + // if (destination.relationships[type_alias].data === undefined) { + // continue; + // } + + // if (!(type_alias in source.relationships)) { + // delete destination.relationships[type_alias]; + // } else { + // // relation is a collection + // let collection = destination.relationships[type_alias]; + // // TODO: talkto Pablo, this could be and Object... (following IF statement added by Maxi) + // if (!Array.isArray(collection.data)) { + // continue; + // } + // for (let resource of collection.data) { + // if (collection.find(resource.id) === null) { + // delete destination.relationships[type_alias]; + // } + // } + // } + // } + + // // add source relationships to destination + // for (let type_alias in source.relationships) { + // // problem with no declared services + // if (source.relationships[type_alias].data === undefined) { + // continue; + // } + + // if (source.relationships[type_alias].data === null) { + // // TODO: FE-92 --- check and improve conditions when building has-one relationships + // destination.relationships[type_alias].data = null; + // continue; + // } + + // if ('id' in source.relationships[type_alias].data) { + // destination.addRelationship(source.relationships[type_alias].data, type_alias); + // } else { + // destination.addRelationships(>source.relationships[type_alias].data, type_alias); + // } + // } } } diff --git a/src/services/cachestore-duplicate-resources.spec.ts b/src/services/cachestore-duplicate-resources.spec.ts index dba03efc..b3b12a28 100644 --- a/src/services/cachestore-duplicate-resources.spec.ts +++ b/src/services/cachestore-duplicate-resources.spec.ts @@ -1,4 +1,3 @@ -import { CacheStore } from './cachestore'; import { Core } from '../core'; import { Converter } from '../services/converter'; import { Resource } from '../resource'; @@ -10,6 +9,7 @@ import { JsonapiConfig } from '../jsonapi-config'; import { Http as JsonapiHttpImported } from '../sources/http.service'; // import { StoreService } from '../sources/store.service'; +// @deprecated ? class HttpHandlerMock implements HttpHandler { public handle(req: HttpRequest): Observable> { let subject = new BehaviorSubject(new HttpResponse()); @@ -36,8 +36,6 @@ describe('Cachestore filler', () => { rsJsonapiConfig: new JsonapiConfig() }; - let cachestore = new CacheStore(); - let data_collection: IDataCollection = { data: [ { @@ -73,7 +71,7 @@ describe('Cachestore filler', () => { }) ); - await (cachestore as any).fillCollectionWithArrrayAndResourcesOnStore(data_collection, [], collection); + // await (cachestore as any).fillCollectionWithArrrayAndResourcesOnStore(data_collection, [], collection); expect(collection.data.length).toBe(1); expect(collection.data[0].attributes.name).toBe('Cool name'); diff --git a/src/services/cachestore.spec.ts b/src/services/cachestore.spec.ts deleted file mode 100644 index 1128b5fd..00000000 --- a/src/services/cachestore.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { CacheStore } from './cachestore'; -import { Core } from '../core'; -import { HttpClient, HttpHandler, HttpRequest, HttpEvent, HttpResponse } from '@angular/common/http'; -import { BehaviorSubject, Observable } from 'rxjs'; -import { JsonapiConfig } from '../jsonapi-config'; -import { Http as JsonapiHttpImported } from '../sources/http.service'; - -class HttpHandlerMock implements HttpHandler { - public handle(req: HttpRequest): Observable> { - let subject = new BehaviorSubject(new HttpResponse()); - - return subject.asObservable(); - } -} - -export class StoreService { - public constructor() { - /**/ - } - public getDataResources(something) { - return; - } - - public removeObjectsWithKey(some_key) { - return; - } - - public deprecateObjectsWithKey(some_key) { - return; - } -} - -describe('Cachestore test', () => { - let cachestore: CacheStore; - beforeEach(() => { - (Core.injectedServices as any) = { - JsonapiStoreService: new StoreService(), - JsonapiHttp: new JsonapiHttpImported(new HttpClient(new HttpHandlerMock()), new JsonapiConfig()), - rsJsonapiConfig: new JsonapiConfig() - }; - - cachestore = new CacheStore(); - }); - it('removeResource should call cache store removeObjectsWithKey method with the correct formatted string', async () => { - let removeObjectsWithKey_spy = spyOn(Core.injectedServices.JsonapiStoreService, 'removeObjectsWithKey'); - - cachestore.removeResource('1', 'resources'); - - expect(removeObjectsWithKey_spy).toHaveBeenCalledWith('jsonapi.resources.1'); - }); - - /* - it('deprecateCollections should call JsonapiStoreService deprecateObjectsWithKey with the corresponding string', () => { - let deprecateObjectsWithKey_spy = spyOn(Core.injectedServices.JsonapiStoreService, 'deprecateCollections'); - cachestore.deprecateCollections('some_string'); - expect(deprecateObjectsWithKey_spy).toHaveBeenCalledWith('some_string'); - }); - */ -}); diff --git a/src/services/cachestore.ts b/src/services/cachestore.ts deleted file mode 100644 index b90b1324..00000000 --- a/src/services/cachestore.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { IObjectsById } from '../interfaces'; -import { IDataResource } from '../interfaces/data-resource'; -import { IDataCollection } from '../interfaces/data-collection'; -import { Core } from '../core'; -import { Base } from './base'; -import { Resource } from '../resource'; -import { Converter } from './converter'; -import { DocumentCollection } from '../document-collection'; -import { Observable, Subject } from 'rxjs'; -import { Page } from './page'; -import { DocumentResource } from '../document-resource'; - -export class CacheStore { - public async getResource(resource: Resource, include: Array = []): Promise { - let mypromise: Promise = new Promise((resolve, reject): void => { - Core.injectedServices.JsonapiStoreService.getDataObject(resource.type, resource.id).subscribe( - success => { - try { - resource.fill({ data: success }); - - // include some times is a collection :S - let include_promises: Array> = []; - - // NOTE: fix to resources stored without relationships - if (include.length > 0 && !resource.relationships) { - resource.relationships = new (resource.getService()).resource().relationships; - } - - for (let resource_alias of include) { - this.fillRelationshipFromStore(resource, resource_alias, include_promises); - } - - resource.cache_last_update = success._lastupdate_time; - - // no debo esperar a que se resuelvan los include - if (include_promises.length === 0) { - resolve(success); - } else { - // esperamos las promesas de los include antes de dar el resolve - Promise.all(include_promises) - .then(success3 => { - resolve(success3); - }) - .catch(error3 => { - reject(error3); - }); - } - } catch (e) { - reject(); - } - }, - () => { - reject(); - } - ); - }); - - return mypromise; - } - - public setResource(resource: Resource) { - Core.injectedServices.JsonapiStoreService.saveResource(resource.type, resource.id, resource.toObject().data); - } - - public setCollection(url: string, collection: DocumentCollection, include: Array): void { - let tmp: IDataCollection = { data: [], page: new Page() }; - let resources_for_save: IObjectsById = {}; - for (let resource of collection.data) { - this.setResource(resource); - tmp.data.push({ id: resource.id, type: resource.type }); - - for (let resource_type_alias of include) { - // TODO: FE-92 ---> improve null has-one relatioships checks - if (resource.relationships[resource_type_alias].data === null) { - continue; - } - if ('id' in resource.relationships[resource_type_alias].data) { - // hasOne - let ress = resource.relationships[resource_type_alias].data; - resources_for_save[resource_type_alias + ress.id] = ress; - } else { - // hasMany - let collection2 = >resource.relationships[resource_type_alias].data; - for (let inc_resource of collection2) { - resources_for_save[resource_type_alias + inc_resource.id] = inc_resource; - } - } - } - } - - tmp.page = collection.page; - Core.injectedServices.JsonapiStoreService.saveCollection(url, tmp); - - // TODO: WORKING new collections don't have cache last update, so it's set here - collection.cache_last_update = collection.cache_last_update || Date.now(); - - Base.forEach(resources_for_save, resource_for_save => { - if (!('is_new' in resource_for_save)) { - // console.warn('No se pudo guardar en la cache', resource_for_save.type, 'por no se ser Resource.', resource_for_save); - - return; - } - - if (Object.keys(resource_for_save.attributes).length === 0) { - console.warn('No se pudo guardar en la cache', resource_for_save.type, 'por no tener attributes.', resource_for_save); - - return; - } - - this.setResource(resource_for_save); - }); - } - - public removeResource(id: string, type: string): void { - console.warn('removeResource with ToDo!'); - Core.injectedServices.JsonapiStoreService.removeObjectsWithKey(`jsonapi.${type}.${id}`); - } - - public deprecateCollections(path_start_with: string): boolean { - Core.injectedServices.JsonapiStoreService.deprecateCollection(path_start_with); - - return true; - } - - public fillCollectionFromStore(url: string, include: Array, collection: DocumentCollection): Observable { - let subject = new Subject(); - - Core.injectedServices.JsonapiStoreService.getDataObject('collection', url).subscribe( - (data_collection: IDataCollection) => { - // build collection from store and resources from memory - if (this.fillCollectionWithArrrayAndResourcesOnMemory(data_collection.data, collection)) { - collection.source = 'store'; // collection from storeservice, resources from memory - collection.builded = true; - collection.setLoaded(true); - collection.cache_last_update = data_collection._lastupdate_time; - subject.next(collection); - setTimeout(() => subject.complete()); - - return; - } - - this.fillCollectionWithArrrayAndResourcesOnStore(data_collection, include, collection) - .then(() => { - // just for precaution, we not rewrite server data - if (collection.source !== 'new') { - console.warn('ts-angular-json: esto no debería pasar. buscar eEa2ASd2#', collection); - throw new Error('ts-angular-json: esto no debería pasar. buscar eEa2ASd2#'); - } - collection.source = 'store'; // collection and resources from storeservice - collection.cache_last_update = data_collection._lastupdate_time; - collection.builded = true; - collection.setLoaded(true); - subject.next(collection); - setTimeout(() => subject.complete()); - }) - .catch(err => subject.error(err)); - }, - err => subject.error(err) - ); - - return subject; - } - - private fillCollectionWithArrrayAndResourcesOnMemory(dataresources: Array, collection: DocumentCollection): boolean { - let all_ok = true; - for (let dataresource of dataresources) { - let resource = this.getResourceFromMemory(dataresource); - if (resource.is_new) { - all_ok = false; - break; - } - collection.replaceOrAdd(resource); - } - - return all_ok; - } - - private getResourceFromMemory(dataresource: IDataResource): Resource { - let cachememory = Converter.getService(dataresource.type).cachememory; - let resource = cachememory.getOrCreateResource(dataresource.type, dataresource.id); - - return resource; - } - - private getStoreKeysFromDataCollection(datacollection: IDataCollection): Array { - return datacollection.data.map(dataresource => { - return dataresource.type + '.' + dataresource.id; - }); - } - - /* - private async getDataResourcesFromDataCollection(datacollection: IDataCollection): Promise> { - return Core.injectedServices.JsonapiStoreService.getDataResources( - this.getStoreKeysFromDataCollection(datacollection) - ); - } - */ - - private async fillCollectionWithArrrayAndResourcesOnStore( - datacollection: IDataCollection, - include: Array, - collection: DocumentCollection - ): Promise { - let promise = new Promise((resolve: (value: void) => void, reject: (value: any) => void): void => { - let resources_by_id: IObjectsById = {}; - let store_keys = this.getStoreKeysFromDataCollection(datacollection); - - // get resources for collection fill - Core.injectedServices.JsonapiStoreService.getDataResources(store_keys) - .then(store_data_resources => { - let include_promises: Array> = []; - let included_related_keys: Array = []; - - for (let key in store_data_resources) { - let data_resource = store_data_resources[key]; - - resources_by_id[data_resource.id] = this.getResourceFromMemory(data_resource); - resources_by_id[data_resource.id].fill({ data: data_resource }); - - // collect related store_keys - Base.forEach(include, resource_alias => { - let relationship = resources_by_id[data_resource.id].relationships[resource_alias]; - if (relationship instanceof DocumentResource) { - included_related_keys.push(relationship.data.type + '.' + relationship.data.id); - } else if (relationship instanceof DocumentCollection) { - included_related_keys.push(...this.getStoreKeysFromDataCollection(relationship)); - } - }); - - // include some times is a collection :S - /* - Base.forEach(include, resource_alias => { - this.fillRelationshipFromStore(resources_by_id[data_resource.id], resource_alias, include_promises); - }); - */ - } - - // no debo esperar a que se resuelvan los include - if (include_promises.length === 0 && included_related_keys.length === 0) { - if (datacollection.page) { - collection.page.number = datacollection.page.number; - } - - for (let dataresource of datacollection.data) { - let resource: Resource = resources_by_id[dataresource.id]; - if (collection.data.indexOf(resource) !== -1) { - continue; - } - collection.data.push(resource); - } - - resolve(null); - } else { - // request from store all related resources requested on include - let related_promises = []; - Core.injectedServices.JsonapiStoreService.getDataResources(included_related_keys) - .then(store_include_data_resources => { - // move data to memory - for (let key in store_include_data_resources) { - let data_resource = store_include_data_resources[key]; - - let resource = this.getResourceFromMemory(data_resource); - resource.fill({ data: data_resource }); - } - // all related included resources are on cacheMemory - for (let key in resources_by_id) { - let resource = resources_by_id[key]; - - Base.forEach(include, resource_alias => { - this.fillRelationshipFromStore(resource, resource_alias, related_promises); - }); - } - }) - .then(async () => { - // we wait to resolution of eath included type - return Promise.all(related_promises); - }) - .then(success3 => { - if (datacollection.page) { - collection.page.number = datacollection.page.number; - } - - for (let dataresource of datacollection.data) { - let resource: Resource = resources_by_id[dataresource.id]; - collection.data.push(resource); - } - - resolve(null); - }) - .catch(error3 => { - reject(error3); - }); - } - }) - .catch(err => { - reject(err); - }); - }); - - return promise; - } - - private fillRelationshipFromStore(resource: Resource, resource_alias: string, include_promises: Array) { - if (resource_alias.includes('.')) { - let included_resource_alias_parts = resource_alias.split('.'); - let datadocument = resource.relationships[included_resource_alias_parts[0]].data; - if (datadocument instanceof DocumentResource) { - return this.fillRelationshipFromStore(datadocument.data, included_resource_alias_parts[1], include_promises); - } else if (datadocument instanceof DocumentCollection) { - for (let related_resource of datadocument.data) { - this.fillRelationshipFromStore(related_resource, included_resource_alias_parts[1], include_promises); - } - - return; - } - } - - // TODO: FE-92 ---> improve null has-one relatioships checks - if (!resource.relationships[resource_alias] || resource.relationships[resource_alias].data === null) { - return; - } - - let relationship = resource.relationships[resource_alias]; - - if (relationship instanceof DocumentResource) { - // hasOne - let related_resource = relationship.data; - - // @todo related with #209 - // this fix problem with a relationships without child or without data (books.author) - if (related_resource.type !== '') { - resource.addRelationship(this.getResourceAndPushPromise(include_promises, related_resource), resource_alias); - } - - /* - // code by maxi - if ( - !('attributes' in related_resource) || - (Object.keys(related_resource.attributes).length === 0 && related_resource.attributes.constructor === Object) - ) { - // no está cargado aún - let builded_resource = this.getResourceFromMemory(related_resource); - if (builded_resource.is_new) { - // no está en memoria, la pedimos a store - include_promises.push(this.getResource(builded_resource)); - } else if (isDevMode()) { - console.warn('ts-angular-json: esto no debería pasar #isdjf2l1a'); - } - resource.addRelationship(builded_resource, resource_alias); - } - */ - } else if (relationship instanceof DocumentCollection) { - // hayMany - /* - // code by maxi - let builded_resources: Array = []; - for (let related_resource of (resource.relationships[resource_alias]).data) { - await this.getResource(related_resource).then(builded_resource => builded_resources.push(builded_resource)); - } - resource.addRelationships(builded_resources, resource_alias); - */ - - relationship.data.forEach(data_resource => { - if (Object.keys(data_resource.attributes).length === 0) { - // @todo problem when you get /#/authors/22 - throw new Error('Resource is required by include, but I dont have info of this resource. Store broken?'); - } - - this.getResourceFromMemory(data_resource); - }); - - // ToDo we have same before - // let resources_by_id: IObjectsById = {}; - // let required_store_keys: Array = (resource.relationships[resource_alias]).data.map(dataresource => { - // resources_by_id[dataresource.id] = this.getResourceFromMemory(dataresource); - - // return dataresource.type + '.' + dataresource.id; - // }); - - /* - if ((resource.relationships[resource_alias]).data.length > 0) { - console.log(resource_alias, required_store_keys); - debugger; - } - */ - - // fill directly from store - /* - include_promises.push(this.fillCollectionWithArrrayAndResourcesOnStore( - relationship, - [], - relationship - )); - */ - // END fill directly from store - - // if (related_collection.data.length > 0) { - // resource.addRelationship(this.getResourceAndPushPromise(include_promises, related_collection.data[1]), resource_alias); - // } - - /* - related_collection.data.forEach(related_resource => { - resource.addRelationship(this.getResourceAndPushPromise(include_promises, related_resource), resource_alias); - }); - */ - } else { - console.warn('Related resource cant be processed.'); - throw new Error('Related resource cant be processed.'); - } - } - - private getResourceAndPushPromise(include_promises: Array, related_resource: IDataResource): Resource { - let builded_resource = this.getResourceFromMemory(related_resource); - include_promises.push(this.getResource(builded_resource)); - - return builded_resource; - } -} diff --git a/src/services/converter.spec.ts b/src/services/converter.spec.ts index 2b20b67d..4ca95b54 100644 --- a/src/services/converter.spec.ts +++ b/src/services/converter.spec.ts @@ -32,4 +32,8 @@ describe('Converter', () => { expect(converted.sometype.AR.id).toBe('AR'); expect(converted.sometype.AR.type).toBe('sometype'); }); + + it('procreate() dont remove relationship properties when is not present or empty on data', () => { + // @todo + }); }); diff --git a/src/services/converter.ts b/src/services/converter.ts index b1196967..d3105248 100644 --- a/src/services/converter.ts +++ b/src/services/converter.ts @@ -1,9 +1,10 @@ +import { CacheMemory } from './cachememory'; // import * as angular from 'angular'; import { Core } from '../core'; import { Resource } from '../resource'; import { Service } from '../service'; import { IResourcesByType, IObjectsById } from '../interfaces'; -import { IDataObject } from '../interfaces/data-object'; +import { IDocumentResource } from '../interfaces/data-object'; import { IDataCollection } from '../interfaces/data-collection'; import { IDataResource } from '../interfaces/data-resource'; import { isDevMode } from '@angular/core'; @@ -49,14 +50,20 @@ export class Converter { } } - public static getService(type: string): Service { + public static getService(type: string): Service | undefined { let resource_service = Core.me.getResourceService(type); return resource_service; } - public static buildIncluded(document_from: IDataCollection | IDataObject): IResourcesByType { - if ('included' in document_from) { + public static getServiceOrFail(type: string): Service { + let resource_service = Core.me.getResourceServiceOrFail(type); + + return resource_service; + } + + public static buildIncluded(document_from: IDataCollection | IDocumentResource): IResourcesByType { + if ('included' in document_from && document_from.included) { return Converter.json_array2resources_array_by_type(document_from.included); } @@ -69,15 +76,9 @@ export class Converter { console.error('Jsonapi Resource is not correct', data); } - let resource: Resource; - if (data.id in Converter.getService(data.type).cachememory.resources) { - resource = Converter.getService(data.type).cachememory.resources[data.id]; - } else { - resource = Converter.getService(data.type).getOrCreateResource(data.id); - } + let resource: Resource = CacheMemory.getInstance().getOrCreateResource(data.type, data.id); + resource.fill({ data: data }); - resource.attributes = data.attributes || {}; - resource.relationships = <{ [key: string]: any }>data.relationships || resource.relationships; resource.is_new = false; return resource; diff --git a/src/services/json-ripper.spec.ts b/src/services/json-ripper.spec.ts new file mode 100644 index 00000000..7af58cd7 --- /dev/null +++ b/src/services/json-ripper.spec.ts @@ -0,0 +1,258 @@ +import { Resource } from '../resource'; +import { JsonRipper } from './json-ripper'; +import { DocumentCollection } from '../document-collection'; +import { TestFactory } from '../tests/factories/test-factory'; + +describe('JsonRipper for resources', () => { + let book = TestFactory.getBook('5'); + book.attributes.title = 'Fahrenheit 451'; + book.addRelationship(TestFactory.getAuthor('2'), 'author'); + // @todo maxi: factory dont work? + // book.addRelationship(TestFactory.getPhoto('2')); + // book.addRelationship(TestFactory.getPhoto('1')); + + it('A resource is converted to objects for a DataProvider', () => { + let mocked_service_data: { [key: string]: any } = { parseToServer: false }; + spyOn(Resource.prototype, 'getService').and.returnValue(mocked_service_data); + + let obj = JsonRipper.toResourceElements('some.key', book); + expect(obj.length).toBe(1); + expect(obj[0].key).toBe('some.key'); + expect(obj[0].content.data).toMatchObject({ + attributes: { title: 'Fahrenheit 451' }, + id: '5', + type: 'books', + relationships: { + author: { + data: { id: '2', type: 'authors' } + } + } + }); + + // hasManyRelationships + // expect(obj[2].content.data.relationships.books.data.length).toBe(2); + // expect(Object.keys(obj[2].content.data.relationships.books.data[0]).length).toBe(2); // id and type + }); + + it('A resource with include is converted to objects for a DataProvider', () => { + let mocked_service_data: { [key: string]: any } = { parseToServer: false }; + spyOn(Resource.prototype, 'getService').and.returnValue(mocked_service_data); + + let obj = JsonRipper.toResourceElements('some.key', book, ['author']); + expect(obj.length).toBe(2); + expect(obj[0].key).toBe('some.key'); + expect(obj[1].content.data).toMatchObject({ + id: '2', + type: 'authors', + attributes: { + name: /.+/ + }, + relationships: {} + }); + }); + + it('A ripped resource saved via DataProvider is converted to a Json', async done => { + let mocked_service_data: { [key: string]: any } = { parseToServer: false }; + spyOn(Resource.prototype, 'getService').and.returnValue(mocked_service_data); + + let jsonRipper = new JsonRipper(); + await jsonRipper.saveResource(book); + let json = await jsonRipper.getResource(JsonRipper.getResourceKey(book)); + expect(json.data).toMatchObject({ + attributes: { title: /.+/ }, + id: '5', + type: 'books', + relationships: { + author: { + data: { id: /.+/, type: 'authors' } + } + } + }); + + done(); + }, 500); + + it('A ripped resource maintain cache_last_update property', async () => { + let mocked_service_data: { [key: string]: any } = { parseToServer: false }; + spyOn(Resource.prototype, 'getService').and.returnValue(mocked_service_data); + + let jsonRipper = new JsonRipper(); + await jsonRipper.saveResource(book); + let json = await jsonRipper.getResource(JsonRipper.getResourceKey(book)); + expect(json.data.cache_last_update).toBeGreaterThanOrEqual(Date.now() - 100); + }); + + it('A ripped resource with include saved via DataProvider is converted to a Json', async done => { + let mocked_service_data: { [key: string]: any } = { parseToServer: false }; + spyOn(Resource.prototype, 'getService').and.returnValue(mocked_service_data); + + let jsonRipper = new JsonRipper(); + await jsonRipper.saveResource(book, ['author']); + let json = await jsonRipper.getResource(JsonRipper.getResourceKey(book), ['author']); + expect(json.included.length).toEqual(1); + expect(json.included[0]).toMatchObject({ + id: '2', + type: 'authors', + attributes: {}, + relationships: {} + }); + + done(); + }, 500); + + it('A ripped resource with hasOne = null saved via DataProvider is converted to a Json', async () => { + let mocked_service_data: { [key: string]: any } = { parseToServer: false }; + spyOn(Resource.prototype, 'getService').and.returnValue(mocked_service_data); + + let jsonRipper = new JsonRipper(); + book.relationships.author.data = null; + await jsonRipper.saveResource(book, ['author']); + let json = await jsonRipper.getResource(JsonRipper.getResourceKey(book), ['author']); + expect(json.included.length).toEqual(0); + expect(json.data.relationships.author.data).toEqual(null); + // expect(json.included[0]).toMatchObject({ + // id: '2', + // type: 'authors', + // attributes: {}, + // relationships: {} + // }); + }); + + it('Requesting DataProvider not cached resource thrown an error', done => { + let jsonRipper = new JsonRipper(); + jsonRipper + .getResource('extrange_type.id') + .then() + .catch(data => { + done(); + }); + }, 50); +}); + +describe('JsonRipper for collections', () => { + let authors = new DocumentCollection(); + authors.data.push(TestFactory.getAuthor('2')); + let author1 = TestFactory.getAuthor('1'); + author1.attributes.name = 'Ray Bradbury'; + authors.data.push(author1); + let book1 = TestFactory.getBook('1'); + book1.addRelationship(author1, 'author'); + author1.addRelationship(book1); + author1.addRelationship(TestFactory.getBook('2')); + + /* Is private now + it('A collection is converted to objects for a DataProvider', () => { + spyOn(Resource.prototype, 'getService').and.returnValue({}); + + let obj = JsonRipper.collectionToElement('some/url', authors); + expect(obj.length).toBe(3); + expect(obj[0].key).toBe('some/url'); + expect(obj[0].content.keys).toMatchObject(['authors.2', 'authors.1']); // unsorted resources is intentional + expect(obj[2].content.data).toMatchObject({ + attributes: { name: 'Ray Bradbury' }, + id: '1', + type: 'authors', + relationships: { + books: { + data: [{ id: '1', type: 'books' }, { id: '2', type: 'books' }] + } + } + }); + + // hasManyRelationships + expect(obj[2].content.data.relationships.books.data.length).toBe(2); + expect(Object.keys(obj[2].content.data.relationships.books.data[0]).length).toBe(2); // id and type + }); + + it('A collection with include is converted to objects for a DataProvider', () => { + spyOn(Resource.prototype, 'getService').and.returnValue({}); + + let obj = JsonRipper.collectionToElement('some/url/include', authors, ['books']); + expect(obj.length).toBe(5); + expect(obj[0].key).toBe('some/url/include'); + expect(obj[0].content.keys).toMatchObject(['authors.2', 'authors.1']); + expect(obj[4].content.data).toMatchObject({ + id: '2', + type: 'books', + attributes: {}, + relationships: {} + }); + }); + */ + + it('A ripped collection saved via DataProvider is converted to a Json', async done => { + spyOn(Resource.prototype, 'getService').and.returnValue({}); + + let jsonRipper = new JsonRipper(); + jsonRipper.saveCollection('some/url', authors); + + let json = await jsonRipper.getCollection('some/url'); + expect(json.data.length).toEqual(2); + expect(json.data[1]).toMatchObject({ + attributes: { name: 'Ray Bradbury' }, + id: '1', + type: 'authors', + relationships: { + books: { + data: [{ id: '1', type: 'books' }, { id: '2', type: 'books' }] + } + } + }); + + done(); + }); + + it('A ripped collection maintain cache_last_update property', async () => { + spyOn(Resource.prototype, 'getService').and.returnValue({}); + + let jsonRipper = new JsonRipper(); + jsonRipper.saveCollection('some/url', authors); + let json = await jsonRipper.getCollection('some/url'); + expect(json.cache_last_update).toBeGreaterThanOrEqual(Date.now() - 100); + }); + + it('A ripped collection with include saved via DataProvider is converted to a Json', async () => { + spyOn(Resource.prototype, 'getService').and.returnValue({}); + + let jsonRipper = new JsonRipper(); + jsonRipper.saveCollection('some/url/include', authors, ['books']); + + let json = await jsonRipper.getCollection('some/url/include', ['books']); + expect(json.data.length).toEqual(2); + expect(json.included.length).toEqual(2); + + expect(json.included[0]).toMatchObject({ + id: '1', + type: 'books', + attributes: {}, + relationships: { + author: { + data: { id: /.+/, type: 'authors' } + } + } + }); + }); + + it('A ripped collection returns _lastupdate_time on collection and resources property', async () => { + spyOn(Resource.prototype, 'getService').and.returnValue({}); + + let jsonRipper = new JsonRipper(); + jsonRipper.saveCollection('some/url/include', authors, ['books']); + + let json = await jsonRipper.getCollection('some/url/include', ['books']); + expect(json.cache_last_update).toBeGreaterThan(0); + + // collection.fill responsability to fill, but ripper need to comunicate last update + expect(json.data[1].cache_last_update).toBeGreaterThan(0); + }, 50); + + it('Requesting a DataProvider not cached collection thrown an error', async done => { + let jsonRipper = new JsonRipper(); + jsonRipper + .getCollection('some/bad/url') + .then() + .catch(data => { + done(); + }); + }); +}); diff --git a/src/services/json-ripper.ts b/src/services/json-ripper.ts new file mode 100644 index 00000000..56962cf4 --- /dev/null +++ b/src/services/json-ripper.ts @@ -0,0 +1,179 @@ +import { ICacheableDataCollection } from './../interfaces/data-collection'; +import { ICacheableDocumentResource } from './../interfaces/data-object'; +import { Resource } from './../resource'; +import { DocumentResource } from './../document-resource'; +import { DexieDataProvider } from '../data-providers/dexie-data-provider'; +import { IDataProvider, IElement } from './../data-providers/data-provider'; +import { DocumentCollection } from '../document-collection'; + +interface IStoredCollection { + updated_at: number; + keys: Array; +} +export class JsonRipper { + private dataProvider: IDataProvider; + + public constructor() { + this.dataProvider = new DexieDataProvider(); + } + + public async getResource(key: string, include: Array = []): Promise { + let stored_resource = (await this.getDataResources([key])).shift(); + + if (stored_resource === undefined) { + throw new Error(`Resource ${key} don't found.`); + } + + if (include.length === 0) { + return stored_resource; + } + + let included_keys: Array = []; + include.forEach(relationship_alias => { + // @NOTE: typescript doesn't detect throwError added a few lines above when stored_resource === undefnied + if (!stored_resource || !stored_resource.data.relationships || !stored_resource.data.relationships[relationship_alias]) { + // this is a classic problem when relationship property is missing on included resources + throw new Error('We dont have relation_alias on stored data resource'); + } + + const relationship = stored_resource.data.relationships[relationship_alias].data; + if (relationship instanceof Array) { + relationship.forEach(related_resource => { + included_keys.push(JsonRipper.getResourceKey(related_resource)); + }); + } else if (relationship && 'id' in relationship) { + included_keys.push(JsonRipper.getResourceKey(relationship)); + } + }); + + let included_resources = await this.getDataResources(included_keys); + + return { + ...stored_resource, + included: included_resources.map(document_resource => document_resource.data) + }; + } + + public async getCollection(url: string, include: Array = []): Promise { + let stored_collection = await this.getDataCollection(url); + let data_resources = await this.getDataResources(stored_collection.keys); + + let ret = { + data: data_resources.map(data_resource => data_resource.data), + cache_last_update: stored_collection.updated_at + }; + + if (include.length === 0) { + return ret; + } + + let included_keys: Array = []; + include.forEach(relationship_alias => { + data_resources.forEach(resource => { + if (!resource.data.relationships || !resource.data.relationships[relationship_alias]) { + return; + } + + const relationship = resource.data.relationships[relationship_alias].data; + if (relationship instanceof Array) { + relationship.forEach(related_resource => { + included_keys.push(JsonRipper.getResourceKey(related_resource)); + }); + } else if ('id' in relationship) { + included_keys.push(JsonRipper.getResourceKey(relationship)); + } + }); + }); + + let included_resources = await this.getDataResources(included_keys); + + return { + ...ret, + included: included_resources.map(document_resource => document_resource.data) + }; + } + + private async getDataCollection(url: string): Promise { + return >this.dataProvider.getElement(url, 'collections'); + } + + private async getDataResources(keys: Array): Promise> { + return >>this.dataProvider.getElements(keys, 'elements'); + } + + public saveCollection(url: string, collection: DocumentCollection, include: Array = []): void { + this.dataProvider.saveElements(JsonRipper.collectionToElement(url, collection), 'collections'); + this.dataProvider.saveElements(JsonRipper.collectionResourcesToElements(collection, include), 'elements'); + } + + public async saveResource(resource: Resource, include = []): Promise { + return this.dataProvider.saveElements( + JsonRipper.toResourceElements(JsonRipper.getResourceKey(resource), resource, include), + 'elements' + ); + } + + private static collectionToElement(url: string, collection: DocumentCollection): Array { + let collection_element = { + key: url, + content: { updated_at: Date.now(), keys: >[] } + }; + collection.data.forEach(resource => { + let key = JsonRipper.getResourceKey(resource); + collection_element.content.keys.push(key); + }); + + return [collection_element]; + } + + private static collectionResourcesToElements(collection: DocumentCollection, include: Array = []): Array { + let elements: Array = []; + collection.data.forEach(resource => { + let key = JsonRipper.getResourceKey(resource); + elements.push(...JsonRipper.toResourceElements(key, resource, include)); + }); + + return elements; + } + + public static toResourceElements(key: string, resource: Resource, include: Array = []): Array { + let elements: Array = [ + { + key: key, + content: resource.toObject() + } + ]; + elements[0].content.data.cache_last_update = Date.now(); + + include.forEach(relationship_alias => { + const relationship = resource.relationships[relationship_alias]; + if (relationship instanceof DocumentCollection) { + relationship.data.forEach(related_resource => { + elements.push(JsonRipper.getElement(related_resource)); + }); + } else if (relationship instanceof DocumentResource) { + if (relationship.data === null || relationship.data === undefined) { + return; + } + elements.push(JsonRipper.getElement(relationship.data)); + } + }); + + return elements; + } + + public static getResourceKey(resource: Resource): string { + return resource.type + '.' + resource.id; + } + + private static getElement(resource: Resource): IElement { + return { + key: JsonRipper.getResourceKey(resource), + content: resource.toObject() + }; + } + + public async deprecateCollection(key_start_with: string): Promise { + return this.dataProvider.updateElements(key_start_with, {}, 'collections'); + } +} diff --git a/src/services/path-builder.spec.ts b/src/services/path-builder.spec.ts index 7dc2e701..1ff99982 100644 --- a/src/services/path-builder.spec.ts +++ b/src/services/path-builder.spec.ts @@ -45,7 +45,7 @@ describe('Path Builder', () => { expect(appendPath_spy).toHaveBeenCalledWith(testService.getPath()); }); it('applyParams method should call setInclude with params.include (if exists) to assign them to the includes array', () => { - let setInclude_spy = spyOn(path_builder, 'setInclude'); + let setInclude_spy = spyOn(path_builder, 'setInclude'); path_builder.applyParams(testService, { beforepath: 'pre/' }); expect(setInclude_spy).not.toHaveBeenCalled(); path_builder.applyParams(testService, { beforepath: 'pre/', include: ['include'] }); @@ -68,7 +68,7 @@ describe('Path Builder', () => { path_builder.paths = ['test', 'path']; (path_builder as any).get_params = ['and', 'test', 'params']; let path = path_builder.getForCache(); - // this creates test/pathand/test/params instead of test/path/and/test/params <= is this on purpose? + // this creates test/pathand/tests/params instead of test/path/and/tests/params <= is this on purpose? expect(path).toBe(path_builder.paths.join('/') + (path_builder as any).get_params.join('/')); }); it('if get_params length is 0, getForCache shouldn t add them to the resulting string', () => { diff --git a/src/services/path-collection-builder.spec.ts b/src/services/path-collection-builder.spec.ts index 5c93028d..bf0966d0 100644 --- a/src/services/path-collection-builder.spec.ts +++ b/src/services/path-collection-builder.spec.ts @@ -54,7 +54,7 @@ describe('Path Builder', () => { expect(parseToServer_null_spy).not.toHaveBeenCalled(); }); it('if remotefilters are provided, applyParams should call addParam with paramsurl.toparams result as parameter', () => { - let addParam_parent_spy = spyOn(path_collection_builder, 'addParam'); + let addParam_parent_spy = spyOn(path_collection_builder, 'addParam'); let toparams_parent_spy = spyOn(UrlParamsBuilder.prototype, 'toparams'); path_collection_builder.applyParams(testService, { remotefilter: { status: 'test_status' } }); let test_params = new UrlParamsBuilder().toparams({ status: 'test_status' }); @@ -63,7 +63,6 @@ describe('Path Builder', () => { }); it('if fields are provided, they should be formatted and included in get_params', () => { - let addParam_parent_spy = spyOn(path_collection_builder, 'addParam').and.callThrough(); path_collection_builder.applyParams(testService, { fields: { test: ['test_attribute', 'other_test_attribute'] } }); expect((path_collection_builder as any).get_params.indexOf('fields[test]=test_attribute,other_test_attribute')).toBeGreaterThan(-1); }); @@ -71,7 +70,7 @@ describe('Path Builder', () => { it('if page params are provided, applyParams should call addParam one or two times with the page number and size', () => { Core.injectedServices.rsJsonapiConfig.parameters.page.number = 'page_index'; Core.injectedServices.rsJsonapiConfig.parameters.page.size = 'page_size'; - let addParam_parent_spy = spyOn(path_collection_builder, 'addParam'); + let addParam_parent_spy = spyOn(path_collection_builder, 'addParam'); path_collection_builder.applyParams(testService, { page: { number: 2 } }); expect(addParam_parent_spy).toHaveBeenCalledTimes(1); expect(addParam_parent_spy).toHaveBeenCalledWith('page_index=2'); @@ -83,7 +82,7 @@ describe('Path Builder', () => { it('if page number param is 1, applyParams should not call addParam with page number', () => { Core.injectedServices.rsJsonapiConfig.parameters.page.number = 'page_index'; Core.injectedServices.rsJsonapiConfig.parameters.page.size = 'page_size'; - let addParam_parent_spy = spyOn(path_collection_builder, 'addParam'); + let addParam_parent_spy = spyOn(path_collection_builder, 'addParam'); path_collection_builder.applyParams(testService, { page: { number: 1 } }); expect(addParam_parent_spy).not.toHaveBeenCalled(); path_collection_builder.applyParams(testService, { page: { number: 1, size: 10 } }); @@ -92,7 +91,7 @@ describe('Path Builder', () => { expect(addParam_parent_spy).toHaveBeenCalledWith('page_size=10'); }); it('if sort params are provided, applyParams method should join the array with "," and call addParam with the resulting string', () => { - let addParam_parent_spy = spyOn(path_collection_builder, 'addParam'); + let addParam_parent_spy = spyOn(path_collection_builder, 'addParam'); path_collection_builder.applyParams(testService, { sort: ['test', 'sort'] }); expect(addParam_parent_spy).toHaveBeenCalledWith('sort=test,sort'); }); diff --git a/src/services/path-collection-builder.ts b/src/services/path-collection-builder.ts index 8db95b7d..5e6b065f 100644 --- a/src/services/path-collection-builder.ts +++ b/src/services/path-collection-builder.ts @@ -17,10 +17,10 @@ export class PathCollectionBuilder extends PathBuilder { } if (params.page) { if (params.page.number > 1) { - this.addParam(Core.injectedServices.rsJsonapiConfig.parameters.page.number + '=' + params.page.number); + this.addParam(this.getPageConfig().number + '=' + params.page.number); } if (params.page.size) { - this.addParam(Core.injectedServices.rsJsonapiConfig.parameters.page.size + '=' + params.page.size); + this.addParam(this.getPageConfig().size + '=' + params.page.size); } } if (params.sort && params.sort.length) { @@ -35,6 +35,15 @@ export class PathCollectionBuilder extends PathBuilder { } } + private getPageConfig(): { number: string; size: string } { + return ( + (Core.injectedServices.rsJsonapiConfig.parameters && Core.injectedServices.rsJsonapiConfig.parameters.page) || { + number: 'number', + size: 'size' + } + ); + } + protected addParam(param: string): void { this.get_params.push(param); } diff --git a/src/services/resource-relationships-converter.spec.ts b/src/services/resource-relationships-converter.spec.ts index ede814ea..b1654e2d 100644 --- a/src/services/resource-relationships-converter.spec.ts +++ b/src/services/resource-relationships-converter.spec.ts @@ -1,6 +1,5 @@ import { ResourceRelationshipsConverter } from './resource-relationships-converter'; import { DocumentCollection } from '../document-collection'; -import { CacheStore } from '../services/cachestore'; import { CacheMemory } from '../services/cachememory'; import { Converter } from './converter'; import { Service } from '../service'; @@ -43,9 +42,6 @@ const test_services = { function getService(type: string) { let service = test_services[type]; - service.cachememory = new CacheMemory(); - service.cachememory.resources = {}; - service.cachestore = new CacheStore(); return service; } @@ -70,7 +66,6 @@ describe('ResourceRelationshipsConverter', () => { }); it('should set builded to true when a hasOne relationsihp is builded', () => { - spyOn(CacheStore.prototype, 'setResource'); resource_relationships_converter.buildRelationships(); expect((resource_relationships_converter as any).relationships_dest.resource.builded).toBeTruthy(); }); @@ -79,7 +74,6 @@ describe('ResourceRelationshipsConverter', () => { using relationships_from data`, () => { // set up spy spyOn(Converter, 'getService').and.callFake(getService); - let cachestoreSetResourceSpy = spyOn(CacheStore.prototype, 'setResource'); // set up fake dest_resource (rememeber that ids must match with relationships_from resources) let mock_resource_with_relationships = new MockResource(); @@ -154,6 +148,5 @@ describe('ResourceRelationshipsConverter', () => { // expect(related_collection_first_resource.attributes.description).toBe('first in collection'); // expect(related_collection_second_resource.attributes.name).toBe('second'); // expect(related_collection_second_resource.attributes.description).toBe('second in collection'); - expect(cachestoreSetResourceSpy).toHaveBeenCalled(); // must save loaded resources in cachestore }); }); diff --git a/src/services/resource-relationships-converter.ts b/src/services/resource-relationships-converter.ts index f46d2732..e82e5dd0 100644 --- a/src/services/resource-relationships-converter.ts +++ b/src/services/resource-relationships-converter.ts @@ -1,6 +1,7 @@ +import { CacheMemory } from './cachememory'; import { IResourcesByType } from '../interfaces'; import { IDataCollection } from '../interfaces/data-collection'; -import { IDataObject } from '../interfaces/data-object'; +import { IDocumentResource } from '../interfaces/data-object'; import { IDataResource } from '../interfaces/data-resource'; import { Resource } from '../resource'; import { DocumentCollection } from '../document-collection'; @@ -29,7 +30,7 @@ export class ResourceRelationshipsConverter { public buildRelationships(): void { // recorro los relationships levanto el service correspondiente for (const relation_alias in this.relationships_from) { - let relation_from_value: IDataCollection & IDataObject = this.relationships_from[relation_alias]; + let relation_from_value: IDataCollection & IDocumentResource = this.relationships_from[relation_alias]; if (this.relationships_dest[relation_alias] && relation_from_value.data === null) { // TODO: FE-92 --- check and improve conditions when building has-one relationships @@ -55,26 +56,9 @@ export class ResourceRelationshipsConverter { } private __buildRelationshipHasMany(relation_from_value: IDataCollection, relation_alias: string) { - let relation_type = relation_from_value.data[0] ? relation_from_value.data[0].type : ''; - if (relation_type === '') { - return; - } - - relation_alias = relation_alias || relation_type; - if (!this.getService(relation_type)) { - if (isDevMode()) { - console.warn( - 'The relationship ' + relation_alias + ' (type', - relation_type, - ') cant be generated because service for this type has not been injected.' - ); - } - - return; - } - if (relation_from_value.data.length === 0) { this.relationships_dest[relation_alias] = new DocumentCollection(); + this.relationships_dest[relation_alias].builded = true; return; } @@ -82,7 +66,7 @@ export class ResourceRelationshipsConverter { (this.relationships_dest[relation_alias]).fill(relation_from_value); } - private __buildRelationshipHasOne(relation_data_from: IDataObject, relation_alias: string): void { + private __buildRelationshipHasOne(relation_data_from: IDocumentResource, relation_alias: string): void { // new related resource <> cached related resource <> ? delete! if (!('type' in relation_data_from.data)) { this.relationships_dest[relation_alias].data = []; @@ -115,7 +99,7 @@ export class ResourceRelationshipsConverter { } } - private __buildRelationship(resource_data_from: IDataResource): Resource { + private __buildRelationship(resource_data_from: IDataResource): Resource | undefined { if ( resource_data_from.type in this.included_resources && resource_data_from.id in this.included_resources[resource_data_from.type] @@ -124,15 +108,16 @@ export class ResourceRelationshipsConverter { let data = this.included_resources[resource_data_from.type][resource_data_from.id]; // Store the include in cache - this.getService(resource_data_from.type).cachememory.setResource(data, true); - this.getService(resource_data_from.type).cachestore.setResource(data); + CacheMemory.getInstance().setResource(data, true); + // this.getService(resource_data_from.type).cachestore.setResource(data); return data; } else { // OPTIONAL: return cached Resource let service = this.getService(resource_data_from.type); - if (service && resource_data_from.id in service.cachememory.resources) { - return service.cachememory.resources[resource_data_from.id]; + let resource = CacheMemory.getInstance().getResource(resource_data_from.type, resource_data_from.id); + if (resource) { + return resource; } } } diff --git a/src/sources/http.service.ts b/src/sources/http.service.ts index e59a1009..08e31afd 100644 --- a/src/sources/http.service.ts +++ b/src/sources/http.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { IDataObject } from '../interfaces/data-object'; +import { IDocumentResource } from '../interfaces/data-object'; import { HttpClient, HttpHeaders, HttpEvent } from '@angular/common/http'; import { JsonapiConfig } from '../jsonapi-config'; import { share, tap } from 'rxjs/operators'; @@ -13,7 +13,7 @@ export class Http { public constructor(private http: HttpClient, private rsJsonapiConfig: JsonapiConfig) {} - public exec(path: string, method: string, data?: IDataObject): Observable { + public exec(path: string, method: string, data?: IDocumentResource): Observable { let req = { body: data || null, headers: new HttpHeaders({ @@ -27,7 +27,7 @@ export class Http { if (!this.get_requests[path]) { let obs = this.http.request(method, this.rsJsonapiConfig.url + path, req).pipe( tap(() => { - this.get_requests[path] = undefined; + delete this.get_requests[path]; }), share() ); @@ -41,7 +41,7 @@ export class Http { return this.http.request(method, this.rsJsonapiConfig.url + path, req).pipe( tap(() => { - this.get_requests[path] = undefined; + delete this.get_requests[path]; }), share() ); diff --git a/src/sources/store.service.ts b/src/sources/store.service.ts index 864da7b4..b5a22f4e 100644 --- a/src/sources/store.service.ts +++ b/src/sources/store.service.ts @@ -1,5 +1,6 @@ +import { ICacheableDataCollection } from './../interfaces/data-collection'; +import { ICacheableDataResource } from './../interfaces/data-resource'; import Dexie from 'dexie'; -import { Subject, Observable } from 'rxjs'; import { IDataResource } from '../interfaces/data-resource'; import { IDataCollection } from '../interfaces/data-collection'; import { IObjectsById } from '../interfaces'; @@ -28,25 +29,19 @@ export class StoreService /* implements IStoreService */ { this.checkIfIsTimeToClean(); } - public getDataObject(type: 'collection', url: string): Observable; - public getDataObject(type: string, id: string): Observable; - public getDataObject(type: 'collection' | string, id_or_url: string): Observable { - let subject = new Subject(); + public async getDataObject(type: 'collection', url: string): Promise; + public async getDataObject(type: string, id: string): Promise; + public async getDataObject(type: 'collection' | string, id_or_url: string): Promise { // we use different tables for resources and collections const table_name = type === 'collection' ? 'collections' : 'elements'; - this.db.open().then(async () => { - let item = await this.db.table(table_name).get(type + '.' + id_or_url); - if (item === undefined) { - subject.error(null); - } else { - subject.next(item); - } - - subject.complete(); - }); + await this.db.open(); + let item = await this.db.table(table_name).get(type + '.' + id_or_url); + if (item === undefined) { + throw new Error(); + } - return subject.asObservable(); + return item; } public async getDataResources(keys: Array): Promise> { diff --git a/src/test-factory/authors.service.ts b/src/tests/factories/authors.service.ts similarity index 84% rename from src/test-factory/authors.service.ts rename to src/tests/factories/authors.service.ts index 763d590a..1db90280 100644 --- a/src/test-factory/authors.service.ts +++ b/src/tests/factories/authors.service.ts @@ -1,6 +1,6 @@ -import { Resource } from '../resource'; -import { DocumentCollection } from '../document-collection'; -import { Service } from '../service'; +import { Resource } from '../../resource'; +import { DocumentCollection } from '../../document-collection'; +import { Service } from '../../service'; import { Book } from './books.service'; import { Photo } from './photos.service'; diff --git a/src/test-factory/books.service.ts b/src/tests/factories/books.service.ts similarity index 78% rename from src/test-factory/books.service.ts rename to src/tests/factories/books.service.ts index db63d40e..72621690 100644 --- a/src/test-factory/books.service.ts +++ b/src/tests/factories/books.service.ts @@ -1,7 +1,7 @@ -import { Resource } from '../resource'; -import { DocumentCollection } from '../document-collection'; -import { DocumentResource } from '../document-resource'; -import { Service } from '../service'; +import { Resource } from '../../resource'; +import { DocumentCollection } from '../../document-collection'; +import { DocumentResource } from '../../document-resource'; +import { Service } from '../../service'; import { Author } from './authors.service'; import { Photo } from './photos.service'; diff --git a/src/test-factory/photos.service.ts b/src/tests/factories/photos.service.ts similarity index 82% rename from src/test-factory/photos.service.ts rename to src/tests/factories/photos.service.ts index 28efc90d..b6ee1fa4 100644 --- a/src/test-factory/photos.service.ts +++ b/src/tests/factories/photos.service.ts @@ -1,5 +1,5 @@ -import { Resource } from '../resource'; -import { Service } from '../service'; +import { Resource } from '../../resource'; +import { Service } from '../../service'; export class Photo extends Resource { public attributes = { diff --git a/src/test-factory/test-factory.ts b/src/tests/factories/test-factory.ts similarity index 90% rename from src/test-factory/test-factory.ts rename to src/tests/factories/test-factory.ts index e102d332..1bad9969 100644 --- a/src/test-factory/test-factory.ts +++ b/src/tests/factories/test-factory.ts @@ -1,12 +1,11 @@ -import { Resource } from '../resource'; -import { IDocumentData } from '../interfaces/document'; -import { IDataResource } from '../interfaces/data-resource'; -import { DocumentCollection } from '../document-collection'; -import { DocumentResource } from '../document-resource'; -import { Service } from '../service'; -import { Author, AuthorsService } from './authors.service'; -import { Book, BooksService } from './books.service'; -import { Photo, PhotosService } from './photos.service'; +import { Resource } from '../../resource'; +import { IDocumentData } from '../../interfaces/document'; +import { IDataResource } from '../../interfaces/data-resource'; +import { DocumentCollection } from '../../document-collection'; +import { DocumentResource } from '../../document-resource'; +import { Author } from './authors.service'; +import { Book } from './books.service'; +import { Photo } from './photos.service'; import * as faker from 'faker'; export class TestFactory { @@ -20,10 +19,7 @@ export class TestFactory { public static getResourceDocumentData(document_class: typeof Resource, include: Array = []): IDocumentData { let main_resource: Resource = this[`get${document_class.name}`](); - let document_data: IDocumentData = { - data: main_resource - }; - + let document_data: IDocumentData = main_resource.toObject(); this.fillDocumentDataIncludedRelatioships(document_data, include); return document_data; @@ -32,9 +28,7 @@ export class TestFactory { public static getCollectionDocumentData(document_class: typeof Resource, size = 2, include: Array = []): IDocumentData { let main_collection: DocumentCollection = this.getCollection(document_class, size, include); - let document_data: IDocumentData = { - data: main_collection.data - }; + let document_data: IDocumentData = main_collection.toObject(); this.fillDocumentDataIncludedRelatioships(document_data, include); return document_data; @@ -56,7 +50,7 @@ export class TestFactory { // resource.attributes.title = faker.name.title(); // // // NOTE: add author - // (resource.relationships.author.data) = this.getDataResourceWithType('author'); + // (resource.relationships.author.data) = this.getDataResourceWithType('authors'); // if (include.includes('author')) { // this.includeHasOneFromService(resource, 'author', Photo); // } @@ -75,17 +69,17 @@ export class TestFactory { let book: Book = new Book(); book.id = this.getId(id); book.ttl = ttl; - this.fillBookAttirbutes(book); + this.fillBookAttributes(book); // NOTE: add author - (book.relationships.author.data) = this.getDataResourceWithType('authors'); if (include.includes('author')) { + (book.relationships.author.data) = this.getDataResourceWithType('authors'); this.includeFromService(book, 'author', Photo); } // NOTE: add photos - (book.relationships.photos.data as Array).concat(this.getDataResourcesWithType('photos', 2)); if (include.includes('photos')) { + (book.relationships.photos.data as Array).concat(this.getDataResourcesWithType('photos', 2)); this.includeFromService(book, 'photos', Photo); } @@ -131,6 +125,7 @@ export class TestFactory { } collection.setBuilded(true); collection.setLoaded(true); + collection.cache_last_update = Date.now(); return collection; } @@ -147,7 +142,7 @@ export class TestFactory { } // TODO: create a dynamic attribute filler by data type and merge 3 methods in 1 - private static fillBookAttirbutes(book: Book): Book { + private static fillBookAttributes(book: Book): Book { book.attributes.title = faker.name.title(); book.attributes.date_published = faker.date.past(); book.attributes.created_at = faker.date.past(); diff --git a/src/test/get-resource-with-parameters.spec.ts b/src/tests/get-resource-with-parameters.spec.ts similarity index 95% rename from src/test/get-resource-with-parameters.spec.ts rename to src/tests/get-resource-with-parameters.spec.ts index ec3386ae..fb88cf9e 100644 --- a/src/test/get-resource-with-parameters.spec.ts +++ b/src/tests/get-resource-with-parameters.spec.ts @@ -114,7 +114,9 @@ describe('core methods', () => { expect(resource.type).toBe('test_resources'); expect(resource.id).toBe('1'); expect(resource.attributes.name).toBe('test_name'); - expect(resource.attributes.optional).toBeFalsy(); + // @todo why? memory will not remove attributes if are not sent by server + // for example two different requests with different list of fields (one request remove attributes of the another resource) + // expect(resource.attributes.optional).toBeFalsy(); let request = { body: null, diff --git a/src/test/get-resource.spec.ts b/src/tests/get-resource.spec.ts similarity index 86% rename from src/test/get-resource.spec.ts rename to src/tests/get-resource.spec.ts index bed041c0..5d4dc53a 100644 --- a/src/test/get-resource.spec.ts +++ b/src/tests/get-resource.spec.ts @@ -8,15 +8,15 @@ import { Http as JsonapiHttpImported } from '../sources/http.service'; import { JsonapiConfig } from '../jsonapi-config'; import { StoreService as JsonapiStore } from '../sources/store.service'; import { Core } from '../core'; -import { Observable, BehaviorSubject, of as observableOf } from 'rxjs'; -import { delay, filter } from 'rxjs/operators'; +import { Observable, BehaviorSubject } from 'rxjs'; import { Service } from '../service'; +import { map, toArray, tap } from 'rxjs/operators'; let test_response_subject = new BehaviorSubject(new HttpResponse()); class HttpHandlerMock implements HttpHandler { public handle(req: HttpRequest): Observable> { - return test_response_subject.asObservable().pipe(delay(100)); + return test_response_subject.asObservable(); } } @@ -58,7 +58,7 @@ describe('core methods', () => { }); it('getResourceService should return the instantiated service from resourceServices related to the type passed as arument', async () => { let test_service = new TestService(); - let test_service_instance = core.getResourceService('test_resources'); + let test_service_instance = core.getResourceServiceOrFail('test_resources'); expect(test_service_instance).toBeTruthy(); expect(test_service_instance.type).toBe('test_resources'); expect(test_service_instance).toEqual(test_service); @@ -70,29 +70,37 @@ describe('core methods', () => { test_resource.attributes = { name: 'test_name' }; let test_service = new TestService(); let http_request_spy = spyOn(HttpClient.prototype, 'request').and.callThrough(); - test_response_subject.next(new HttpResponse({ body: { data: test_resource } })); + test_response_subject.next(new HttpResponse({ body: test_resource.toObject() })); - await test_service + let resource: Resource; + let emmits = await test_service .get('1') - .toPromise() - .then(resource => { - expect(resource.type).toBe('test_resources'); - expect(resource.id).toBe('1'); - expect(resource.attributes.name).toBe('test_name'); - - let headers = new HttpHeaders({ - 'Content-Type': 'application/vnd.api+json', - Accept: 'application/vnd.api+json' - }); - let request = { - body: null, - headers: expect.any(Object) - }; - expect(http_request_spy).toHaveBeenCalledWith('get', 'http://yourdomain/api/v1/test_resources/1', request); - }); + .pipe( + tap(emmit => { + resource = emmit; + }), + map(emmit => { + return { loaded: emmit.loaded, source: emmit.source }; + }), + toArray() + ) + .toPromise(); + expect(emmits).toMatchObject([ + // expected emits + { loaded: false, source: 'new' }, + { loaded: true, source: 'server' } + ]); + expect(resource.type).toBe('test_resources'); + expect(resource.id).toBe('1'); + expect(resource.attributes.name).toBe('test_name'); + expect(http_request_spy).toHaveBeenCalledTimes(1); + expect(http_request_spy).toHaveBeenCalledWith('get', 'http://yourdomain/api/v1/test_resources/1', { + body: null, + headers: expect.any(Object) + }); }); - it(`the resource should have the correct hasOne and hasMany relationships correspondig to the back end response's included resources, + it(`resource should have the correct hasOne and hasMany relationships correspondig to the back end response's included resources, including nested relationships`, async () => { let test_resource = new TestResource(); test_resource.type = 'test_resources'; @@ -124,8 +132,7 @@ describe('core methods', () => { let included = [test_resource_has_one_relationship, test_resource_has_many_relationship_1, test_resource_nested_relationship]; let test_service = new TestService(); - test_service.clearCacheMemory(); - test_service.cachememory.resources = {}; + await test_service.clearCacheMemory(); Core.injectedServices.JsonapiStoreService.clearCache(); test_response_subject.next(new HttpResponse({ body: { data: test_resource, included: included } })); @@ -154,7 +161,7 @@ describe('core methods', () => { }); }); - it(`the resource should have the correct hasOne and hasMany relationships correspondig to the back end response's included resources`, async () => { + it(`resource should have the correct hasOne and hasMany relationships correspondig to the back end response's included resources`, async () => { let test_resource = new TestResource(); test_resource.type = 'test_resources'; test_resource.id = '1'; diff --git a/src/test/globals-test.ts b/src/tests/globals-test.ts similarity index 100% rename from src/test/globals-test.ts rename to src/tests/globals-test.ts diff --git a/src/tsconfig-build.json b/src/tsconfig-build.json index a7b738af..07df7bd8 100644 --- a/src/tsconfig-build.json +++ b/src/tsconfig-build.json @@ -1,8 +1,10 @@ { "compilerOptions": { + "strictNullChecks": true, "baseUrl": ".", "declaration": true, "stripInternal": true, + "emitDecoratorMetadata": true, "experimentalDecorators": true, "module": "es2015", "moduleResolution": "node", diff --git a/tsconfig.json b/tsconfig.json index 781cdc1c..c1a23c3d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "strictNullChecks": true, "sourceMap": true, "declaration": false, "moduleResolution": "node", diff --git a/yarn.lock b/yarn.lock index 7dc61583..b2bad2b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -642,27 +642,17 @@ "@types/istanbul-lib-coverage" "*" "@types/istanbul-lib-report" "*" -"@types/jasmine@*": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@types/jasmine/-/jasmine-3.4.0.tgz#018c56db42400c092aae47de21f710b7f04e4b06" - integrity sha512-6pUnBg6DuSB55xnxJ5+gW9JOkFrPsXkYAuqqEE8oyrpgDiPQ+TZ+1Zt4S+CHcRJcxyNYXeIXG4vHSzdF6y9Uvw== - -"@types/jasmine@^2.8.8": - version "2.8.16" - resolved "https://registry.yarnpkg.com/@types/jasmine/-/jasmine-2.8.16.tgz#a6cb24b1149d65293bd616923500014838e14e7d" - integrity sha512-056oRlBBp7MDzr+HoU5su099s/s7wjZ3KcHxLfv+Byqb9MwdLUvsfLgw1VS97hsh3ddxSPyQu+olHMnoVTUY6g== +"@types/jest-diff@*": + version "20.0.1" + resolved "https://registry.yarnpkg.com/@types/jest-diff/-/jest-diff-20.0.1.tgz#35cc15b9c4f30a18ef21852e255fdb02f6d59b89" + integrity sha512-yALhelO3i0hqZwhjtcr6dYyaLoCHbAMshwtj6cGxTvHZAKXHsYGdff6E8EPw3xLKY0ELUTQ69Q1rQiJENnccMA== -"@types/jasminewd2@^2.0.2": - version "2.0.6" - resolved "https://registry.yarnpkg.com/@types/jasminewd2/-/jasminewd2-2.0.6.tgz#2f57a8d9875a6c9ef328a14bd070ba14a055ac39" - integrity sha512-2ZOKrxb8bKRmP/po5ObYnRDgFE4i+lQiEB27bAMmtMWLgJSqlIDqlLx6S0IRorpOmOPRQ6O80NujTmQAtBkeNw== +"@types/jest@^24.0.18": + version "24.0.18" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-24.0.18.tgz#9c7858d450c59e2164a8a9df0905fc5091944498" + integrity sha512-jcDDXdjTcrQzdN06+TSVsPPqxvsZA/5QkYfIZlq1JMw7FdP5AZylbOc+6B/cuDurctRe+MziUMtQ3xQdrbjqyQ== dependencies: - "@types/jasmine" "*" - -"@types/jest@^20.0.2": - version "20.0.8" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-20.0.8.tgz#7f8c97f73d20d3bf5448fbe33661a342002b5954" - integrity sha512-+vFMPCwOffrTy685X9Kj+Iz83I56Q8j0JK6xvsm6TA5qxbtPUJZcXtJY05WMGlhCKp/9qbpRCwyOp6GkMuyuLg== + "@types/jest-diff" "*" "@types/lodash@^4.14.80": version "4.14.138" @@ -2101,7 +2091,7 @@ chokidar@^1.4.2, chokidar@^1.6.0, chokidar@^1.7.0: optionalDependencies: fsevents "^1.0.0" -chokidar@^2.0.2, chokidar@^2.0.3, chokidar@^2.1.6: +chokidar@^2.0.2, chokidar@^2.0.3, chokidar@^2.1.8: version "2.1.8" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" integrity sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg== @@ -3302,9 +3292,9 @@ ejs@^2.5.7: integrity sha512-kS/gEPzZs3Y1rRsbGX4UOSjtP/CeJP0CxSNZHYxGfVM/VgLcv0ZqM7C45YyTj2DI2g7+P9Dd24C+IMIg6D0nYQ== electron-to-chromium@^1.3.47: - version "1.3.256" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.256.tgz#7f680d5f04df8e2bd9cb9758026d70f5ff1808a9" - integrity sha512-GHY1r2mO56BRMng6rkxxJvsWKtqy9k/IlSBrAV/VKwZKpTydVUJnOwajTNnl5uutJpthHgZy+HeofK5K6PqEgQ== + version "1.3.260" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.260.tgz#ffd686b4810bab0e1a428e7af5f08c21fe7c1fa2" + integrity sha512-wGt+OivF1C1MPwaSv3LJ96ebNbLAWlx3HndivDDWqwIVSQxmhL17Y/YmwUdEMtS/bPyommELt47Dct0/VZNQBQ== elegant-spinner@^1.0.1: version "1.0.1" @@ -4246,11 +4236,11 @@ fs-extra@^5.0.0: universalify "^0.1.0" fs-minipass@^1.2.5: - version "1.2.6" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.6.tgz#2c5cc30ded81282bfe8a0d7c7c1853ddeb102c07" - integrity sha512-crhvyXcMejjv3Z5d2Fa9sf5xLYVCF5O1c71QxbVnbLsmYMBEvDAftewesN/HhY03YRoA7zOMxjNGrF5svGaaeQ== + version "1.2.7" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7" + integrity sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA== dependencies: - minipass "^2.2.1" + minipass "^2.6.0" fs-write-stream-atomic@^1.0.8: version "1.0.10" @@ -5092,10 +5082,10 @@ ipaddr.js@^1.9.0: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== -is-absolute-url@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-3.0.1.tgz#e315cbdcbbc3d6789532d591954ac78a0e5049f6" - integrity sha512-c2QjUwuMxLsld90sj3xYzpFYWJtuxkIn1f5ua9RTEYJt/vV2IsM+Py00/6qjV7qExgifUvt7qfyBGBBKm+2iBg== +is-absolute-url@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-3.0.2.tgz#554f2933e7385cc46e94351977ca2081170a206e" + integrity sha512-+5g/wLlcm1AcxSP7014m6GvbPHswDx980vD/3bZaap8aGV9Yfs7Q6y6tfaupgZ5O74Byzc8dGrSCJ+bFXx0KdA== is-accessor-descriptor@^0.1.6: version "0.1.6" @@ -7183,7 +7173,7 @@ log-update@^2.3.0: cli-cursor "^2.0.0" wrap-ansi "^3.0.1" -loglevel@^1.6.3: +loglevel@^1.6.4: version "1.6.4" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.4.tgz#f408f4f006db8354d0577dcf6d33485b3cb90d56" integrity sha512-p0b6mOGKcGa+7nnmKbpzR6qloPbrgLcnio++E+14Vo/XffOGwZtRpUhr8dTH/x2oCMmEoIU0Zwm3ZauhvYD17g== @@ -7551,18 +7541,18 @@ minimist@~0.0.1: resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" integrity sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8= -minipass@^2.2.1, minipass@^2.3.5: - version "2.5.1" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.5.1.tgz#cf435a9bf9408796ca3a3525a8b851464279c9b8" - integrity sha512-dmpSnLJtNQioZFI5HfQ55Ad0DzzsMAb+HfokwRTNXwEQjepbTkl5mtIlSVxGIkOkxlpX7wIn5ET/oAd9fZ/Y/Q== +minipass@^2.2.1, minipass@^2.3.5, minipass@^2.6.0: + version "2.6.1" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.6.1.tgz#5f44a94f0d0cd1a347fd65274f32122f68b76acd" + integrity sha512-B+5oiJnCKYgeJxBiy5FJi/s/0x56Oe0WdaAGlxNLHhhfdvkfgbKKEkLIiikYoxZLA3OKzVrXvN25fuhKH7FZxg== dependencies: safe-buffer "^5.1.2" yallist "^3.0.0" minizlib@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.2.1.tgz#dd27ea6136243c7c880684e8672bb3a45fd9b614" - integrity sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA== + version "1.2.2" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.2.2.tgz#6f0ccc82fa53e1bf2ff145f220d2da9fa6e3a166" + integrity sha512-hR3At21uSrsjjDTWrbu0IMLTpnkpv8IIMFDFaoz43Tmu4LkmAXfH44vNNzpTnf+OAQQCHrb91y/wc2J4x5XgSQ== dependencies: minipass "^2.2.1" @@ -8670,7 +8660,7 @@ pn@^1.1.0: resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA== -portfinder@^1.0.13, portfinder@^1.0.21: +portfinder@^1.0.13, portfinder@^1.0.24: version "1.0.24" resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.24.tgz#11efbc6865f12f37624b6531ead1d809ed965cfa" integrity sha512-ekRl7zD2qxYndYflwiryJwMioBI7LI7rVXg3EnLK3sjkouT5eOuhS3gS255XxBksa30VG8UPZYZCdgfGOfkSUg== @@ -9728,7 +9718,7 @@ selenium-webdriver@3.6.0, selenium-webdriver@^3.0.1: tmp "0.0.30" xml2js "^0.4.17" -selfsigned@^1.10.4: +selfsigned@^1.10.6: version "1.10.6" resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.10.6.tgz#7b3cd37ed9c2034261a173af1a1aae27d8169b67" integrity sha512-i3+CeqxL7DpAazgVpAGdKMwHuL63B5nhJMh9NQ7xmChGkA3jNFflq6Jyo1LLJYcr3idWiNOPWHCrm4zMayLG4w== @@ -9970,10 +9960,10 @@ snapdragon@^0.8.1: source-map-resolve "^0.5.0" use "^3.1.0" -sockjs-client@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.3.0.tgz#12fc9d6cb663da5739d3dc5fb6e8687da95cb177" - integrity sha512-R9jxEzhnnrdxLCNln0xg5uGHqMnkhPSTzUZH2eXcR03S/On9Yvoq2wyUZILRUhZCNVu2PmwWVoyuiPz8th8zbg== +sockjs-client@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.4.0.tgz#c9f2568e19c8fd8173b4997ea3420e0bb306c7d5" + integrity sha512-5zaLyO8/nri5cua0VtOrFXBPK1jbL4+1cebT/mmKA1E1ZXOvJrII75bPu0l0k843G/+iAbhEqzyKr0w/eCCj7g== dependencies: debug "^3.2.5" eventsource "^1.0.7" @@ -10804,9 +10794,9 @@ ts-jest@^24.0.0: yargs-parser "10.x" ts-mockito@^2.3.1: - version "2.4.2" - resolved "https://registry.yarnpkg.com/ts-mockito/-/ts-mockito-2.4.2.tgz#e3b383a3cbfbf5225dff7365d98ddc32af75b846" - integrity sha512-3AqLVXxjfdwlo2eC+xrzFsc5rsPtKBBhJZAnxWmyBmgT/PC+K26RIxiT2QLKcqjcJqZnuGZkwfPMx2gN31lFnw== + version "2.5.0" + resolved "https://registry.yarnpkg.com/ts-mockito/-/ts-mockito-2.5.0.tgz#ad853051f2d116dfcaf6de6b0a1df2c82eda2d1f" + integrity sha512-b3qUeMfghRq5k5jw3xNJcnU9RKhqKnRn0k9v9QkN+YpuawrFuMIiGwzFZCpdi5MHy26o7YPnK8gag2awURl3nA== dependencies: lodash "^4.17.5" @@ -11244,9 +11234,9 @@ webdriver-js-extender@2.1.0: selenium-webdriver "^3.0.1" webdriver-manager@^12.0.6: - version "12.1.6" - resolved "https://registry.yarnpkg.com/webdriver-manager/-/webdriver-manager-12.1.6.tgz#9e5410c506d1a7e0a7aa6af91ba3d5bb37f362b6" - integrity sha512-B1mOycNCrbk7xODw7Jgq/mdD3qzPxMaTsnKIQDy2nXlQoyjTrJTTD0vRpEZI9b8RibPEyQvh9zIZ0M1mpOxS3w== + version "12.1.7" + resolved "https://registry.yarnpkg.com/webdriver-manager/-/webdriver-manager-12.1.7.tgz#ed4eaee8f906b33c146e869b55e850553a1b1162" + integrity sha512-XINj6b8CYuUYC93SG3xPkxlyUc3IJbD6Vvo75CVGuG9uzsefDzWQrhz0Lq8vbPxtb4d63CZdYophF8k8Or/YiA== dependencies: adm-zip "^0.4.9" chalk "^1.1.1" @@ -11270,7 +11260,7 @@ webidl-conversions@^4.0.0, webidl-conversions@^4.0.2: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== -webpack-dev-middleware@^3.1.3, webpack-dev-middleware@^3.7.0: +webpack-dev-middleware@^3.1.3, webpack-dev-middleware@^3.7.1: version "3.7.1" resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.7.1.tgz#1167aea02afa034489869b8368fe9fed1aea7d09" integrity sha512-5MWu9SH1z3hY7oHOV6Kbkz5x7hXbxK56mGHNqHTe6d+ewxOwKUxoUJBs7QIaJb33lPjl9bJZ3X0vCoooUzC36A== @@ -11282,13 +11272,13 @@ webpack-dev-middleware@^3.1.3, webpack-dev-middleware@^3.7.0: webpack-log "^2.0.0" webpack-dev-server@^3.1.4: - version "3.8.0" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.8.0.tgz#06cc4fc2f440428508d0e9770da1fef10e5ef28d" - integrity sha512-Hs8K9yI6pyMvGkaPTeTonhD6JXVsigXDApYk9JLW4M7viVBspQvb1WdAcWxqtmttxNW4zf2UFLsLNe0y87pIGQ== + version "3.8.1" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.8.1.tgz#485b64c4aadc23f601e72114b40c1b1fea31d9f1" + integrity sha512-9F5DnfFA9bsrhpUCAfQic/AXBVHvq+3gQS+x6Zj0yc1fVVE0erKh2MV4IV12TBewuTrYeeTIRwCH9qLMvdNvTw== dependencies: ansi-html "0.0.7" bonjour "^3.5.0" - chokidar "^2.1.6" + chokidar "^2.1.8" compression "^1.7.4" connect-history-api-fallback "^1.6.0" debug "^4.1.1" @@ -11299,23 +11289,23 @@ webpack-dev-server@^3.1.4: import-local "^2.0.0" internal-ip "^4.3.0" ip "^1.1.5" - is-absolute-url "^3.0.0" + is-absolute-url "^3.0.2" killable "^1.0.1" - loglevel "^1.6.3" + loglevel "^1.6.4" opn "^5.5.0" p-retry "^3.0.1" - portfinder "^1.0.21" + portfinder "^1.0.24" schema-utils "^1.0.0" - selfsigned "^1.10.4" + selfsigned "^1.10.6" semver "^6.3.0" serve-index "^1.9.1" sockjs "0.3.19" - sockjs-client "1.3.0" + sockjs-client "1.4.0" spdy "^4.0.1" strip-ansi "^3.0.1" supports-color "^6.1.0" url "^0.11.0" - webpack-dev-middleware "^3.7.0" + webpack-dev-middleware "^3.7.1" webpack-log "^2.0.0" ws "^6.2.1" yargs "12.0.5"