diff --git a/src/app/core/cache/object-cache.actions.ts b/src/app/core/cache/object-cache.actions.ts index 88b4730b3f..c18a20ffd6 100644 --- a/src/app/core/cache/object-cache.actions.ts +++ b/src/app/core/cache/object-cache.actions.ts @@ -13,7 +13,9 @@ export const ObjectCacheActionTypes = { REMOVE: type('dspace/core/cache/object/REMOVE'), RESET_TIMESTAMPS: type('dspace/core/cache/object/RESET_TIMESTAMPS'), ADD_PATCH: type('dspace/core/cache/object/ADD_PATCH'), - APPLY_PATCH: type('dspace/core/cache/object/APPLY_PATCH') + APPLY_PATCH: type('dspace/core/cache/object/APPLY_PATCH'), + ADD_DEPENDENTS: type('dspace/core/cache/object/ADD_DEPENDENTS'), + REMOVE_DEPENDENTS: type('dspace/core/cache/object/REMOVE_DEPENDENTS') }; /** @@ -126,13 +128,55 @@ export class ApplyPatchObjectCacheAction implements Action { } } +/** + * An NgRx action to add dependent request UUIDs to a cached object + */ +export class AddDependentsObjectCacheAction implements Action { + type = ObjectCacheActionTypes.ADD_DEPENDENTS; + payload: { + href: string; + dependentRequestUUIDs: string[]; + }; + + /** + * Create a new AddDependentsObjectCacheAction + * + * @param href the self link of a cached object + * @param dependentRequestUUIDs the UUID of the request that depends on this object + */ + constructor(href: string, dependentRequestUUIDs: string[]) { + this.payload = { + href, + dependentRequestUUIDs, + }; + } +} + +/** + * An NgRx action to remove all dependent request UUIDs from a cached object + */ +export class RemoveDependentsObjectCacheAction implements Action { + type = ObjectCacheActionTypes.REMOVE_DEPENDENTS; + payload: string; + + /** + * Create a new RemoveDependentsObjectCacheAction + * + * @param href the self link of a cached object for which to remove all dependent request UUIDs + */ + constructor(href: string) { + this.payload = href; + } +} /** * A type to encompass all ObjectCacheActions */ export type ObjectCacheAction = AddToObjectCacheAction - | RemoveFromObjectCacheAction - | ResetObjectCacheTimestampsAction - | AddPatchObjectCacheAction - | ApplyPatchObjectCacheAction; + | RemoveFromObjectCacheAction + | ResetObjectCacheTimestampsAction + | AddPatchObjectCacheAction + | ApplyPatchObjectCacheAction + | AddDependentsObjectCacheAction + | RemoveDependentsObjectCacheAction; diff --git a/src/app/core/cache/object-cache.reducer.spec.ts b/src/app/core/cache/object-cache.reducer.spec.ts index 82e2da58b1..e346a00b61 100644 --- a/src/app/core/cache/object-cache.reducer.spec.ts +++ b/src/app/core/cache/object-cache.reducer.spec.ts @@ -2,11 +2,13 @@ import * as deepFreeze from 'deep-freeze'; import { Operation } from 'fast-json-patch'; import { Item } from '../shared/item.model'; import { + AddDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, + RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction, - ResetObjectCacheTimestampsAction + ResetObjectCacheTimestampsAction, } from './object-cache.actions'; import { objectCacheReducer } from './object-cache.reducer'; @@ -42,20 +44,22 @@ describe('objectCacheReducer', () => { timeCompleted: new Date().getTime(), msToLive: 900000, requestUUIDs: [requestUUID1], + dependentRequestUUIDs: [], patches: [], isDirty: false, }, [selfLink2]: { data: { type: Item.type, - self: requestUUID2, + self: selfLink2, foo: 'baz', - _links: { self: { href: requestUUID2 } } + _links: { self: { href: selfLink2 } } }, alternativeLinks: [altLink3, altLink4], timeCompleted: new Date().getTime(), msToLive: 900000, - requestUUIDs: [selfLink2], + requestUUIDs: [requestUUID2], + dependentRequestUUIDs: [requestUUID1], patches: [], isDirty: false } @@ -189,4 +193,20 @@ describe('objectCacheReducer', () => { expect((newState[selfLink1].data as any).name).toEqual(newName); }); + it('should add dependent requests on ADD_DEPENDENTS', () => { + let newState = objectCacheReducer(testState, new AddDependentsObjectCacheAction(selfLink1, ['new', 'newer', 'newest'])); + expect(newState[selfLink1].dependentRequestUUIDs).toEqual(['new', 'newer', 'newest']); + + newState = objectCacheReducer(newState, new AddDependentsObjectCacheAction(selfLink2, ['more'])); + expect(newState[selfLink2].dependentRequestUUIDs).toEqual([requestUUID1, 'more']); + }); + + it('should clear dependent requests on REMOVE_DEPENDENTS', () => { + let newState = objectCacheReducer(testState, new RemoveDependentsObjectCacheAction(selfLink1)); + expect(newState[selfLink1].dependentRequestUUIDs).toEqual([]); + + newState = objectCacheReducer(newState, new RemoveDependentsObjectCacheAction(selfLink2)); + expect(newState[selfLink2].dependentRequestUUIDs).toEqual([]); + }); + }); diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index 1a42408f72..dc3f50db68 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -1,12 +1,13 @@ /* eslint-disable max-classes-per-file */ import { + AddDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, ObjectCacheAction, - ObjectCacheActionTypes, + ObjectCacheActionTypes, RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction, - ResetObjectCacheTimestampsAction + ResetObjectCacheTimestampsAction, } from './object-cache.actions'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { CacheEntry } from './cache-entry'; @@ -69,6 +70,12 @@ export class ObjectCacheEntry implements CacheEntry { */ requestUUIDs: string[]; + /** + * A list of UUIDs for the requests that depend on this object. + * When this object is invalidated, these requests will be invalidated as well. + */ + dependentRequestUUIDs: string[]; + /** * An array of patches that were made on the client side to this entry, but haven't been sent to the server yet */ @@ -134,6 +141,14 @@ export function objectCacheReducer(state = initialState, action: ObjectCacheActi return applyPatchObjectCache(state, action as ApplyPatchObjectCacheAction); } + case ObjectCacheActionTypes.ADD_DEPENDENTS: { + return addDependentsObjectCacheState(state, action as AddDependentsObjectCacheAction); + } + + case ObjectCacheActionTypes.REMOVE_DEPENDENTS: { + return removeDependentsObjectCacheState(state, action as RemoveDependentsObjectCacheAction); + } + default: { return state; } @@ -159,6 +174,7 @@ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheActio timeCompleted: action.payload.timeCompleted, msToLive: action.payload.msToLive, requestUUIDs: [action.payload.requestUUID, ...(existing.requestUUIDs || [])], + dependentRequestUUIDs: existing.dependentRequestUUIDs || [], isDirty: isNotEmpty(existing.patches), patches: existing.patches || [], alternativeLinks: [...(existing.alternativeLinks || []), ...newAltLinks] @@ -252,3 +268,49 @@ function applyPatchObjectCache(state: ObjectCacheState, action: ApplyPatchObject } return newState; } + +/** + * Add a list of dependent request UUIDs to a cached object, used when defining new dependencies + * + * @param state the current state + * @param action an AddDependentsObjectCacheAction + * @return the new state, with the dependent requests of the cached object updated + */ +function addDependentsObjectCacheState(state: ObjectCacheState, action: AddDependentsObjectCacheAction): ObjectCacheState { + const href = action.payload.href; + const newState = Object.assign({}, state); + + if (hasValue(newState[href])) { + newState[href] = Object.assign({}, newState[href], { + dependentRequestUUIDs: [ + ...new Set([ + ...newState[href]?.dependentRequestUUIDs || [], + ...action.payload.dependentRequestUUIDs, + ]) + ] + }); + } + + return newState; +} + + +/** + * Remove all dependent request UUIDs from a cached object, used to clear out-of-date depedencies + * + * @param state the current state + * @param action an AddDependentsObjectCacheAction + * @return the new state, with the dependent requests of the cached object updated + */ +function removeDependentsObjectCacheState(state: ObjectCacheState, action: RemoveDependentsObjectCacheAction): ObjectCacheState { + const href = action.payload; + const newState = Object.assign({}, state); + + if (hasValue(newState[href])) { + newState[href] = Object.assign({}, newState[href], { + dependentRequestUUIDs: [] + }); + } + + return newState; +} diff --git a/src/app/core/cache/object-cache.service.spec.ts b/src/app/core/cache/object-cache.service.spec.ts index f18c262524..6af797be29 100644 --- a/src/app/core/cache/object-cache.service.spec.ts +++ b/src/app/core/cache/object-cache.service.spec.ts @@ -11,10 +11,12 @@ import { coreReducers} from '../core.reducers'; import { RestRequestMethod } from '../data/rest-request-method'; import { Item } from '../shared/item.model'; import { + AddDependentsObjectCacheAction, + RemoveDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, - RemoveFromObjectCacheAction + RemoveFromObjectCacheAction, } from './object-cache.actions'; import { Patch } from './object-cache.reducer'; import { ObjectCacheService } from './object-cache.service'; @@ -25,6 +27,7 @@ import { storeModuleConfig } from '../../app.reducer'; import { TestColdObservable } from 'jasmine-marbles/src/test-observables'; import { IndexName } from '../index/index-name.model'; import { CoreState } from '../core-state.model'; +import { TestScheduler } from 'rxjs/testing'; describe('ObjectCacheService', () => { let service: ObjectCacheService; @@ -38,6 +41,7 @@ describe('ObjectCacheService', () => { let altLink1; let altLink2; let requestUUID; + let requestUUID2; let alternativeLink; let timestamp; let timestamp2; @@ -55,6 +59,7 @@ describe('ObjectCacheService', () => { altLink1 = 'https://alternative.link/endpoint/1234'; altLink2 = 'https://alternative.link/endpoint/5678'; requestUUID = '4d3a4ce8-a375-4b98-859b-39f0a014d736'; + requestUUID2 = 'c0f486c1-c4d3-4a03-b293-ca5b71ff0054'; alternativeLink = 'https://rest.api/endpoint/5e4f8a5-be98-4c51-9fd8-6bfedcbd59b7/item'; timestamp = new Date().getTime(); timestamp2 = new Date().getTime() - 200; @@ -71,13 +76,17 @@ describe('ObjectCacheService', () => { data: objectToCache, timeCompleted: timestamp, msToLive: msToLive, - alternativeLinks: [altLink1, altLink2] + alternativeLinks: [altLink1, altLink2], + requestUUIDs: [requestUUID], + dependentRequestUUIDs: [], }; cacheEntry2 = { data: objectToCache, timeCompleted: timestamp2, msToLive: msToLive2, - alternativeLinks: [altLink2] + alternativeLinks: [altLink2], + requestUUIDs: [requestUUID2], + dependentRequestUUIDs: [], }; invalidCacheEntry = Object.assign({}, cacheEntry, { msToLive: -1 }); operations = [{ op: 'replace', path: '/name', value: 'random string' } as Operation]; @@ -343,4 +352,122 @@ describe('ObjectCacheService', () => { expect(store.dispatch).toHaveBeenCalledWith(new ApplyPatchObjectCacheAction(selfLink)); }); }); + + describe('request dependencies', () => { + beforeEach(() => { + const state = Object.assign({}, initialState, { + core: Object.assign({}, initialState.core, { + 'cache/object': { + ['objectWithoutDependents']: { + dependentRequestUUIDs: [], + }, + ['objectWithDependents']: { + dependentRequestUUIDs: [requestUUID], + }, + [selfLink]: cacheEntry, + }, + 'index': { + 'object/alt-link-to-self-link': { + [anotherLink]: selfLink, + ['objectWithoutDependentsAlt']: 'objectWithoutDependents', + ['objectWithDependentsAlt']: 'objectWithDependents', + } + } + }) + }); + mockStore.setState(state); + }); + + describe('addDependency', () => { + it('should dispatch an ADD_DEPENDENTS action', () => { + service.addDependency(selfLink, 'objectWithoutDependents'); + expect(store.dispatch).toHaveBeenCalledOnceWith(new AddDependentsObjectCacheAction('objectWithoutDependents', [requestUUID])); + }); + + it('should resolve alt links', () => { + service.addDependency(anotherLink, 'objectWithoutDependentsAlt'); + expect(store.dispatch).toHaveBeenCalledOnceWith(new AddDependentsObjectCacheAction('objectWithoutDependents', [requestUUID])); + }); + + it('should not dispatch if either href cannot be resolved to a cached self link', () => { + service.addDependency(selfLink, 'unknown'); + service.addDependency('unknown', 'objectWithoutDependents'); + service.addDependency('nothing', 'matches'); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should not dispatch if either href is undefined', () => { + service.addDependency(selfLink, undefined); + service.addDependency(undefined, 'objectWithoutDependents'); + service.addDependency(undefined, undefined); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should not dispatch if the dependency exists already', () => { + service.addDependency(selfLink, 'objectWithDependents'); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should work with observable hrefs', () => { + service.addDependency(observableOf(selfLink), observableOf('objectWithoutDependents')); + expect(store.dispatch).toHaveBeenCalledOnceWith(new AddDependentsObjectCacheAction('objectWithoutDependents', [requestUUID])); + }); + + it('should only dispatch once for the first value of either observable href', () => { + const testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + + testScheduler.run(({ cold: tsCold, flush }) => { + const href$ = tsCold('--y-n-n', { + y: selfLink, + n: 'NOPE' + }); + const dependsOnHref$ = tsCold('-y-n-n', { + y: 'objectWithoutDependents', + n: 'NOPE' + }); + + service.addDependency(href$, dependsOnHref$); + flush(); + + expect(store.dispatch).toHaveBeenCalledOnceWith(new AddDependentsObjectCacheAction('objectWithoutDependents', [requestUUID])); + }); + }); + + it('should not dispatch if either of the hrefs emits undefined', () => { + const testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + + testScheduler.run(({ cold: tsCold, flush }) => { + const undefined$ = tsCold('--u'); + + service.addDependency(selfLink, undefined$); + service.addDependency(undefined$, 'objectWithoutDependents'); + service.addDependency(undefined$, undefined$); + flush(); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + }); + + describe('removeDependents', () => { + it('should dispatch a REMOVE_DEPENDENTS action', () => { + service.removeDependents('objectWithDependents'); + expect(store.dispatch).toHaveBeenCalledOnceWith(new RemoveDependentsObjectCacheAction('objectWithDependents')); + }); + + it('should resolve alt links', () => { + service.removeDependents('objectWithDependentsAlt'); + expect(store.dispatch).toHaveBeenCalledOnceWith(new RemoveDependentsObjectCacheAction('objectWithDependents')); + }); + + it('should not dispatch if the href cannot be resolved to a cached self link', () => { + service.removeDependents('unknown'); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index cdf87e5c1a..9ca0216210 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -4,23 +4,15 @@ import { applyPatch, Operation } from 'fast-json-patch'; import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { distinctUntilChanged, filter, map, mergeMap, switchMap, take } from 'rxjs/operators'; -import { hasValue, isNotEmpty, isEmpty } from '../../shared/empty.util'; +import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { CoreState } from '../core-state.model'; import { coreSelector } from '../core.selectors'; import { RestRequestMethod } from '../data/rest-request-method'; -import { - selfLinkFromAlternativeLinkSelector, - selfLinkFromUuidSelector -} from '../index/index.selectors'; +import { selfLinkFromAlternativeLinkSelector, selfLinkFromUuidSelector } from '../index/index.selectors'; import { GenericConstructor } from '../shared/generic-constructor'; import { getClassForType } from './builders/build-decorators'; import { LinkService } from './builders/link.service'; -import { - AddPatchObjectCacheAction, - AddToObjectCacheAction, - ApplyPatchObjectCacheAction, - RemoveFromObjectCacheAction -} from './object-cache.actions'; +import { AddDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; import { ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer'; import { AddToSSBAction } from './server-sync-buffer.actions'; @@ -339,4 +331,97 @@ export class ObjectCacheService { this.store.dispatch(new ApplyPatchObjectCacheAction(selfLink)); } + /** + * Add a new dependency between two cached objects. + * When {@link dependsOnHref$} is invalidated, {@link href$} will be invalidated as well. + * + * This method should be called _after_ requests have been sent; + * it will only work if both objects are already present in the cache. + * + * If either object is undefined, the dependency will not be added + * + * @param href$ the href of an object to add a dependency to + * @param dependsOnHref$ the href of the new dependency + */ + addDependency(href$: string | Observable, dependsOnHref$: string | Observable) { + if (hasNoValue(href$) || hasNoValue(dependsOnHref$)) { + return; + } + + if (typeof href$ === 'string') { + href$ = observableOf(href$); + } + if (typeof dependsOnHref$ === 'string') { + dependsOnHref$ = observableOf(dependsOnHref$); + } + + observableCombineLatest([ + href$, + dependsOnHref$.pipe( + switchMap(dependsOnHref => this.resolveSelfLink(dependsOnHref)) + ), + ]).pipe( + switchMap(([href, dependsOnSelfLink]: [string, string]) => { + const dependsOnSelfLink$ = observableOf(dependsOnSelfLink); + + return observableCombineLatest([ + dependsOnSelfLink$, + dependsOnSelfLink$.pipe( + switchMap(selfLink => this.getBySelfLink(selfLink)), + map(oce => oce?.dependentRequestUUIDs || []), + ), + this.getByHref(href).pipe( + // only add the latest request to keep dependency index from growing indefinitely + map((entry: ObjectCacheEntry) => entry?.requestUUIDs?.[0]), + ) + ]); + }), + take(1), + ).subscribe(([dependsOnSelfLink, currentDependents, newDependent]: [string, string[], string]) => { + // don't dispatch if either href is invalid or if the new dependency already exists + if (hasValue(dependsOnSelfLink) && hasValue(newDependent) && !currentDependents.includes(newDependent)) { + this.store.dispatch(new AddDependentsObjectCacheAction(dependsOnSelfLink, [newDependent])); + } + }); + } + + /** + * Clear all dependent requests associated with a cache entry. + * + * @href the href of a cached object + */ + removeDependents(href: string) { + this.resolveSelfLink(href).pipe( + take(1), + ).subscribe((selfLink: string) => { + if (hasValue(selfLink)) { + this.store.dispatch(new RemoveDependentsObjectCacheAction(selfLink)); + } + }); + } + + + /** + * Resolve the self link of an existing cached object from an arbitrary href + * + * @param href any href + * @return an observable of the self link corresponding to the given href. + * Will emit the given href if it was a self link, another href + * if the given href was an alt link, or undefined if there is no + * cached object for this href. + */ + private resolveSelfLink(href: string): Observable { + return this.getBySelfLink(href).pipe( + switchMap((oce: ObjectCacheEntry) => { + if (isNotEmpty(oce)) { + return [href]; + } else { + return this.store.pipe( + select(selfLinkFromAlternativeLinkSelector(href)), + ); + } + }), + ); + } + } diff --git a/src/app/core/data/base/base-data.service.spec.ts b/src/app/core/data/base/base-data.service.spec.ts index cd8b8c3e32..17532f477a 100644 --- a/src/app/core/data/base/base-data.service.spec.ts +++ b/src/app/core/data/base/base-data.service.spec.ts @@ -10,7 +10,7 @@ import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.s import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { FindListOptions } from '../find-list-options.model'; -import { Observable, of as observableOf } from 'rxjs'; +import { Observable, of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; @@ -20,6 +20,7 @@ import { RemoteData } from '../remote-data'; import { RequestEntryState } from '../request-entry-state.model'; import { fakeAsync, tick } from '@angular/core/testing'; import { BaseDataService } from './base-data.service'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; const endpoint = 'https://rest.api/core'; @@ -65,7 +66,13 @@ describe('BaseDataService', () => { }, getByHref: () => { /* empty */ - } + }, + addDependency: () => { + /* empty */ + }, + removeDependents: () => { + /* empty */ + }, } as any; selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; linksToFollow = [ @@ -558,7 +565,8 @@ describe('BaseDataService', () => { beforeEach(() => { getByHrefSpy = spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ - requestUUIDs: ['request1', 'request2', 'request3'] + requestUUIDs: ['request1', 'request2', 'request3'], + dependentRequestUUIDs: ['request4', 'request5'] })); }); @@ -570,6 +578,8 @@ describe('BaseDataService', () => { expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1'); expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2'); expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request3'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request4'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request5'); done(); }); }); @@ -582,6 +592,8 @@ describe('BaseDataService', () => { expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1'); expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2'); expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request3'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request4'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request5'); })); it('should return an Observable that only emits true once all requests are stale', () => { @@ -591,9 +603,13 @@ describe('BaseDataService', () => { case 'request1': return cold('--(t|)', BOOLEAN); case 'request2': - return cold('----(t|)', BOOLEAN); - case 'request3': return cold('------(t|)', BOOLEAN); + case 'request3': + return cold('---(t|)', BOOLEAN); + case 'request4': + return cold('-(t|)', BOOLEAN); + case 'request5': + return cold('----(t|)', BOOLEAN); } }); @@ -607,9 +623,9 @@ describe('BaseDataService', () => { it('should only fire for the current state of the object (instead of tracking it)', () => { testScheduler.run(({ cold, flush }) => { getByHrefSpy.and.returnValue(cold('a---b---c---', { - a: { requestUUIDs: ['request1'] }, // this is the state at the moment we're invalidating the cache - b: { requestUUIDs: ['request2'] }, // we shouldn't keep tracking the state - c: { requestUUIDs: ['request3'] }, // because we may invalidate when we shouldn't + a: { requestUUIDs: ['request1'], dependentRequestUUIDs: [] }, // this is the state at the moment we're invalidating the cache + b: { requestUUIDs: ['request2'], dependentRequestUUIDs: [] }, // we shouldn't keep tracking the state + c: { requestUUIDs: ['request3'], dependentRequestUUIDs: [] }, // because we may invalidate when we shouldn't })); service.invalidateByHref('some-href'); @@ -624,4 +640,42 @@ describe('BaseDataService', () => { }); }); }); + + describe('addDependency', () => { + let addDependencySpy; + + beforeEach(() => { + addDependencySpy = spyOn(objectCache, 'addDependency'); + }); + + it('should call objectCache.addDependency with the object\'s self link', () => { + addDependencySpy.and.callFake((href$: Observable, dependsOn$: Observable) => { + observableCombineLatest([href$, dependsOn$]).subscribe(([href, dependsOn]) => { + expect(href).toBe('object-href'); + expect(dependsOn).toBe('dependsOnHref'); + }); + }); + + (service as any).addDependency( + createSuccessfulRemoteDataObject$({ _links: { self: { href: 'object-href' } } }), + observableOf('dependsOnHref') + ); + expect(addDependencySpy).toHaveBeenCalled(); + }); + + it('should call objectCache.addDependency without an href if request failed', () => { + addDependencySpy.and.callFake((href$: Observable, dependsOn$: Observable) => { + observableCombineLatest([href$, dependsOn$]).subscribe(([href, dependsOn]) => { + expect(href).toBe(undefined); + expect(dependsOn).toBe('dependsOnHref'); + }); + }); + + (service as any).addDependency( + createFailedRemoteDataObject$('something went wrong'), + observableOf('dependsOnHref') + ); + expect(addDependencySpy).toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/core/data/base/base-data.service.ts b/src/app/core/data/base/base-data.service.ts index d8caa6c1c8..740e7ef13a 100644 --- a/src/app/core/data/base/base-data.service.ts +++ b/src/app/core/data/base/base-data.service.ts @@ -23,6 +23,7 @@ import { PaginatedList } from '../paginated-list.model'; import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALDataService } from './hal-data-service.interface'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; /** * Common functionality for data services. @@ -352,19 +353,55 @@ export class BaseDataService implements HALDataServic } /** - * Invalidate a cached object by its href - * @param href the href to invalidate + * Shorthand method to add a dependency to a cached object + * ``` + * const out$ = this.findByHref(...); // or another method that sends a request + * this.addDependency(out$, dependsOnHref); + * ``` + * When {@link dependsOnHref$} is invalidated, {@link object$} will be invalidated as well. + * + * + * @param object$ the cached object + * @param dependsOnHref$ the href of the object it should depend on */ - public invalidateByHref(href: string): Observable { + protected addDependency(object$: Observable>>, dependsOnHref$: string | Observable) { + this.objectCache.addDependency( + object$.pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + return [rd.payload._links.self.href]; + } else { + // undefined href will be skipped in objectCache.addDependency + return [undefined]; + } + }), + ), + dependsOnHref$ + ); + } + + /** + * Invalidate an existing DSpaceObject by marking all requests it is included in as stale + * @param href The self link of the object to be invalidated + * @return An Observable that will emit `true` once all requests are stale + */ + invalidateByHref(href: string): Observable { const done$ = new AsyncSubject(); this.objectCache.getByHref(href).pipe( take(1), - switchMap((oce: ObjectCacheEntry) => observableFrom(oce.requestUUIDs).pipe( - mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)), - toArray(), - )), + switchMap((oce: ObjectCacheEntry) => { + return observableFrom([ + ...oce.requestUUIDs, + ...oce.dependentRequestUUIDs + ]).pipe( + mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)), + toArray(), + ); + }), ).subscribe(() => { + this.objectCache.removeDependents(href); done$.next(true); done$.complete(); }); diff --git a/src/app/core/data/base/patch-data.spec.ts b/src/app/core/data/base/patch-data.spec.ts index 53f2083dab..a55b1229b8 100644 --- a/src/app/core/data/base/patch-data.spec.ts +++ b/src/app/core/data/base/patch-data.spec.ts @@ -178,7 +178,12 @@ describe('PatchDataImpl', () => { describe('patch', () => { const dso = { - uuid: 'dso-uuid' + uuid: 'dso-uuid', + _links: { + self: { + href: 'dso-href', + } + } }; const operations = [ Object.assign({ @@ -188,14 +193,23 @@ describe('PatchDataImpl', () => { }) as Operation ]; - beforeEach((done) => { - service.patch(dso, operations).subscribe(() => { - done(); - }); + it('should send a PatchRequest', () => { + service.patch(dso, operations); + expect(requestService.send).toHaveBeenCalledWith(jasmine.any(PatchRequest)); }); - it('should send a PatchRequest', () => { - expect(requestService.send).toHaveBeenCalledWith(jasmine.any(PatchRequest)); + it('should invalidate the cached object if successfully patched', () => { + spyOn(rdbService, 'buildFromRequestUUIDAndAwait'); + spyOn(service, 'invalidateByHref'); + + service.patch(dso, operations); + + expect(rdbService.buildFromRequestUUIDAndAwait).toHaveBeenCalled(); + expect((rdbService.buildFromRequestUUIDAndAwait as jasmine.Spy).calls.argsFor(0)[0]).toBe(requestService.generateRequestId()); + const callback = (rdbService.buildFromRequestUUIDAndAwait as jasmine.Spy).calls.argsFor(0)[1]; + callback(); + + expect(service.invalidateByHref).toHaveBeenCalledWith('dso-href'); }); }); diff --git a/src/app/core/data/base/patch-data.ts b/src/app/core/data/base/patch-data.ts index 732f71636e..e30c394a34 100644 --- a/src/app/core/data/base/patch-data.ts +++ b/src/app/core/data/base/patch-data.ts @@ -101,7 +101,7 @@ export class PatchDataImpl extends IdentifiableDataSe this.requestService.send(request); }); - return this.rdbService.buildFromRequestUUID(requestId); + return this.rdbService.buildFromRequestUUIDAndAwait(requestId, () => this.invalidateByHref(object._links.self.href)); } /** diff --git a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts index ff9b542e44..ae44d590a4 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts @@ -2,7 +2,7 @@ import { AuthorizationDataService } from './authorization-data.service'; import { SiteDataService } from '../site-data.service'; import { Site } from '../../shared/site.model'; import { EPerson } from '../../eperson/models/eperson.model'; -import { of as observableOf } from 'rxjs'; +import { of as observableOf, combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { FeatureID } from './feature-id'; import { hasValue } from '../../../shared/empty.util'; import { RequestParam } from '../../cache/models/request-param.model'; @@ -12,10 +12,12 @@ import { createPaginatedList } from '../../../shared/testing/utils.test'; import { Feature } from '../../shared/feature.model'; import { FindListOptions } from '../find-list-options.model'; import { testSearchDataImplementation } from '../base/search-data.spec'; +import { getMockObjectCacheService } from '../../../shared/mocks/object-cache.service.mock'; describe('AuthorizationDataService', () => { let service: AuthorizationDataService; let siteService: SiteDataService; + let objectCache; let site: Site; let ePerson: EPerson; @@ -38,7 +40,8 @@ describe('AuthorizationDataService', () => { siteService = jasmine.createSpyObj('siteService', { find: observableOf(site), }); - service = new AuthorizationDataService(requestService, undefined, undefined, undefined, siteService); + objectCache = getMockObjectCacheService(); + service = new AuthorizationDataService(requestService, undefined, objectCache, undefined, siteService); } beforeEach(() => { @@ -110,6 +113,43 @@ describe('AuthorizationDataService', () => { expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePersonUuid, FeatureID.LoginOnBehalfOf), true, true); }); }); + + describe('dependencies', () => { + let addDependencySpy; + + beforeEach(() => { + (service.searchBy as any).and.returnValue(observableOf('searchBy RD$')); + addDependencySpy = spyOn(service as any, 'addDependency'); + }); + + it('should add a dependency on the objectUrl', (done) => { + addDependencySpy.and.callFake((href$: Observable, dependsOn$: Observable) => { + observableCombineLatest([href$, dependsOn$]).subscribe(([href, dependsOn]) => { + expect(href).toBe('searchBy RD$'); + expect(dependsOn).toBe('object-href'); + }); + }); + + service.searchByObject(FeatureID.AdministratorOf, 'object-href').subscribe(() => { + expect(addDependencySpy).toHaveBeenCalled(); + done(); + }); + }); + + it('should add a dependency on the Site object if no objectUrl is given', (done) => { + addDependencySpy.and.callFake((object$: Observable, dependsOn$: Observable) => { + observableCombineLatest([object$, dependsOn$]).subscribe(([object, dependsOn]) => { + expect(object).toBe('searchBy RD$'); + expect(dependsOn).toBe('test-site-href'); + }); + }); + + service.searchByObject(FeatureID.AdministratorOf).subscribe(() => { + expect(addDependencySpy).toHaveBeenCalled(); + done(); + }); + }); + }); }); describe('isAuthorized', () => { diff --git a/src/app/core/data/feature-authorization/authorization-data.service.ts b/src/app/core/data/feature-authorization/authorization-data.service.ts index a9aded4bfe..c43d335234 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.ts @@ -11,10 +11,10 @@ import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link- import { RemoteData } from '../remote-data'; import { PaginatedList } from '../paginated-list.model'; import { catchError, map, switchMap } from 'rxjs/operators'; -import { hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; import { RequestParam } from '../../cache/models/request-param.model'; import { AuthorizationSearchParams } from './authorization-search-params'; -import { addSiteObjectUrlIfEmpty, oneAuthorizationMatchesFeature } from './authorization-utils'; +import { oneAuthorizationMatchesFeature } from './authorization-utils'; import { FeatureID } from './feature-id'; import { getFirstCompletedRemoteData } from '../../shared/operators'; import { FindListOptions } from '../find-list-options.model'; @@ -96,12 +96,28 @@ export class AuthorizationDataService extends BaseDataService imp * {@link HALLink}s should be automatically resolved */ searchByObject(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return observableOf(new AuthorizationSearchParams(objectUrl, ePersonUuid, featureId)).pipe( - addSiteObjectUrlIfEmpty(this.siteService), + const objectUrl$ = observableOf(objectUrl).pipe( + switchMap((url) => { + if (hasNoValue(url)) { + return this.siteService.find().pipe( + map((site) => site.self) + ); + } else { + return observableOf(url); + } + }), + ); + + const out$ = objectUrl$.pipe( + map((url: string) => new AuthorizationSearchParams(url, ePersonUuid, featureId)), switchMap((params: AuthorizationSearchParams) => { return this.searchBy(this.searchByObjectPath, this.createSearchOptions(params.objectUrl, options, params.ePersonUuid, params.featureId), useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); }) ); + + this.addDependency(out$, objectUrl$); + + return out$; } /** diff --git a/src/app/core/eperson/group-data.service.spec.ts b/src/app/core/eperson/group-data.service.spec.ts index ed0803ce6e..6fdc302cff 100644 --- a/src/app/core/eperson/group-data.service.spec.ts +++ b/src/app/core/eperson/group-data.service.spec.ts @@ -26,10 +26,9 @@ import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson.mock'; import { createPaginatedList, createRequestEntry$ } from '../../shared/testing/utils.test'; import { CoreState } from '../core-state.model'; import { FindListOptions } from '../data/find-list-options.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { getMockLinkService } from '../../shared/mocks/link-service.mock'; import { of as observableOf } from 'rxjs'; import { ObjectCacheEntry } from '../cache/object-cache.reducer'; +import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; describe('GroupDataService', () => { let service: GroupDataService; @@ -42,7 +41,7 @@ describe('GroupDataService', () => { let groups$; let halService; let rdbService; - let objectCache: ObjectCacheService; + let objectCache; function init() { restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson'; groupsEndpoint = `${restEndpointURL}/groups`; @@ -50,7 +49,7 @@ describe('GroupDataService', () => { groups$ = createSuccessfulRemoteDataObject$(createPaginatedList(groups)); rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups': groups$ }); halService = new HALEndpointServiceStub(restEndpointURL); - objectCache = new ObjectCacheService(store, getMockLinkService()); + objectCache = getMockObjectCacheService(); TestBed.configureTestingModule({ imports: [ CommonModule, @@ -114,8 +113,9 @@ describe('GroupDataService', () => { describe('addSubGroupToGroup', () => { beforeEach(() => { - spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ - requestUUIDs: ['request1', 'request2'] + objectCache.getByHref.and.returnValue(observableOf({ + requestUUIDs: ['request1', 'request2'], + dependentRequestUUIDs: [], } as ObjectCacheEntry)); spyOn((service as any).deleteData, 'invalidateByHref'); service.addSubGroupToGroup(GroupMock, GroupMock2).subscribe(); @@ -143,8 +143,9 @@ describe('GroupDataService', () => { describe('deleteSubGroupFromGroup', () => { beforeEach(() => { - spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ - requestUUIDs: ['request1', 'request2'] + objectCache.getByHref.and.returnValue(observableOf({ + requestUUIDs: ['request1', 'request2'], + dependentRequestUUIDs: [], } as ObjectCacheEntry)); spyOn((service as any).deleteData, 'invalidateByHref'); service.deleteSubGroupFromGroup(GroupMock, GroupMock2).subscribe(); @@ -168,8 +169,9 @@ describe('GroupDataService', () => { describe('addMemberToGroup', () => { beforeEach(() => { - spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ - requestUUIDs: ['request1', 'request2'] + objectCache.getByHref.and.returnValue(observableOf({ + requestUUIDs: ['request1', 'request2'], + dependentRequestUUIDs: [], } as ObjectCacheEntry)); spyOn((service as any).deleteData, 'invalidateByHref'); service.addMemberToGroup(GroupMock, EPersonMock2).subscribe(); @@ -198,8 +200,9 @@ describe('GroupDataService', () => { describe('deleteMemberFromGroup', () => { beforeEach(() => { - spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ - requestUUIDs: ['request1', 'request2'] + objectCache.getByHref.and.returnValue(observableOf({ + requestUUIDs: ['request1', 'request2'], + dependentRequestUUIDs: [], } as ObjectCacheEntry)); spyOn((service as any).deleteData, 'invalidateByHref'); service.deleteMemberFromGroup(GroupMock, EPersonMock).subscribe(); diff --git a/src/app/shared/mocks/object-cache.service.mock.ts b/src/app/shared/mocks/object-cache.service.mock.ts index 515c2abe86..8cceaefc3d 100644 --- a/src/app/shared/mocks/object-cache.service.mock.ts +++ b/src/app/shared/mocks/object-cache.service.mock.ts @@ -13,6 +13,8 @@ export function getMockObjectCacheService(): ObjectCacheService { 'hasByUUID', 'hasByHref', 'getRequestUUIDBySelfLink', + 'addDependency', + 'removeDependents', ]); }