From 9b15b1d7e1e905ee03a0ec50e2e8b9daf69c1f00 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Thu, 17 Nov 2022 14:30:47 +0100 Subject: [PATCH] feat: Allow CachedHandler to cache on multiple object fields --- config/http/notifications/base/handler.json | 2 +- src/util/handlers/CachedHandler.ts | 81 +++++++++++++++---- test/unit/util/handlers/CachedHandler.test.ts | 30 ++++++- 3 files changed, 93 insertions(+), 20 deletions(-) diff --git a/config/http/notifications/base/handler.json b/config/http/notifications/base/handler.json index 90aa2b521..bd05b325b 100644 --- a/config/http/notifications/base/handler.json +++ b/config/http/notifications/base/handler.json @@ -5,7 +5,7 @@ "comment": "Generates the Notification objects and caches them based on the topic.", "@id": "urn:solid-server:default:BaseNotificationGenerator", "@type": "CachedHandler", - "field": "topic", + "fields": [ "topic" ], "source": { "@type": "StateNotificationGenerator", "resourceSet": { "@id": "urn:solid-server:default:CachedResourceSet" }, diff --git a/src/util/handlers/CachedHandler.ts b/src/util/handlers/CachedHandler.ts index bd25ccdd0..2f019dfb1 100644 --- a/src/util/handlers/CachedHandler.ts +++ b/src/util/handlers/CachedHandler.ts @@ -1,52 +1,103 @@ import { AsyncHandler } from './AsyncHandler'; +type NestedMap = TOut | WeakMap>; + /** * Caches output data from the source handler based on the input object. - * The `field` parameter can be used to instead use a specific entry from the input object as cache key, - * so has as actual required typing `keyof TIn`. + * The `fields` parameter can be used to instead use one or more specific entries from the input object as cache key, + * so has as actual required typing `(keyof TIn)[]`. * * A {@link WeakMap} is used internally so strict object equality determines cache hits, * and data will be removed once the key stops existing. * This also means that the cache key needs to be an object. - * Errors will be thrown in case a primitve is used. + * Errors will be thrown in case a primitive is used. */ -export class CachedHandler extends AsyncHandler { +export class CachedHandler, TOut = void> extends AsyncHandler { private readonly source: AsyncHandler; - private readonly field?: string; + private readonly fields?: [keyof TIn, ...(keyof TIn)[]]; - private readonly cache: WeakMap; + private readonly cache: WeakMap>; - public constructor(source: AsyncHandler, field?: string) { + public constructor(source: AsyncHandler, fields?: string[]) { super(); this.source = source; - this.field = field; + if (fields) { + if (fields.length === 0) { + throw new Error('The fields parameter needs to have at least 1 entry if defined.'); + } + // This is the first of many casts in this class. + // All of them are 100% correct though and are a consequence + // of the cache depth depending on the length of `fields` + // and the Node.js array functions not always having strict enough typings. + this.fields = fields as [keyof TIn, ...(keyof TIn)[]]; + } this.cache = new WeakMap(); } public async canHandle(input: TIn): Promise { - const key = this.getKey(input); + const keys = this.getKeys(input); + const map = this.findDestination(input, keys, this.cache); - if (this.cache.has(key)) { + if (map?.has(keys.pop()!)) { return; } + return this.source.canHandle(input); } public async handle(input: TIn): Promise { - const key = this.getKey(input); + const keys = this.getKeys(input); + const map = this.findDestination(input, keys, this.cache, true)!; - let result = this.cache.get(key); + const key = keys.pop()!; + let result = map.get(key); if (result) { return result; } result = await this.source.handle(input); - this.cache.set(key, result); + map.set(key, result); return result; } - protected getKey(input: TIn): any { - return this.field ? input[this.field as keyof TIn] : input; + /** + * Extracts the values that will be used as keys from the input object. + * In case the `fields` value was undefined, this will return an array containing the input object itself. + */ + protected getKeys(input: TIn): [object, ...object[]] { + if (!this.fields) { + return [ input ]; + } + + return this.fields.map((field): object => input[field]) as [object, ...object[]]; + } + + /** + * Returns the `WeakMap` that contains actual objects that were cached, + * so the last `WeakMap` in the chain of maps. + * + * Returns `undefined` if no such map exists because earlier keys were not cached. + * + * Will always return a map if `ensure` is set to true, + * in such a case the intermediate maps will be created and added to the previous map. + */ + protected findDestination(input: TIn, keys: object[], cache: WeakMap>, ensure = false): + WeakMap | undefined { + if (keys.length === 1) { + return cache as WeakMap; + } + + const key = keys[0]; + let nextCache = cache.get(key) as WeakMap> | undefined; + if (!nextCache) { + if (!ensure) { + return; + } + nextCache = new WeakMap>(); + cache.set(key, nextCache); + } + + return this.findDestination(input, keys.slice(1), nextCache, ensure); } } diff --git a/test/unit/util/handlers/CachedHandler.test.ts b/test/unit/util/handlers/CachedHandler.test.ts index 14e8c9a08..391fec320 100644 --- a/test/unit/util/handlers/CachedHandler.test.ts +++ b/test/unit/util/handlers/CachedHandler.test.ts @@ -4,10 +4,10 @@ import { } from '../../../../src/util/handlers/CachedHandler'; describe('A CachedHandler', (): void => { - const input = { entry: { key: 'value' }}; + const input: any = { field1: { key: 'value' }, field2: { key: 'value' }, field3: { key: 'value2' }}; const output = 'response'; - let source: jest.Mocked>; - let handler: CachedHandler<{ entry: { key: string }}, string>; + let source: jest.Mocked>; + let handler: CachedHandler; beforeEach(async(): Promise => { source = { @@ -51,12 +51,34 @@ describe('A CachedHandler', (): void => { expect(source.canHandle).toHaveBeenCalledTimes(0); }); + it('cannot handle input with multiple keys if the first key is already missing.', async(): Promise => { + handler = new CachedHandler(source, [ 'field1', 'field3' ]); + + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + expect(source.canHandle).toHaveBeenCalledTimes(1); + }); + it('can use a specific field of the input as key.', async(): Promise => { - handler = new CachedHandler(source, 'entry'); + handler = new CachedHandler(source, [ 'field1' ]); const copy = { ...input }; await expect(handler.handle(input)).resolves.toBe(output); await expect(handler.handle(copy)).resolves.toBe(output); expect(source.handle).toHaveBeenCalledTimes(1); }); + + it('can use multiple fields of the object as keys.', async(): Promise => { + handler = new CachedHandler(source, [ 'field1', 'field3' ]); + + const copy = { ...input }; + copy.field2 = { other: 'field' }; + await expect(handler.handle(input)).resolves.toBe(output); + await expect(handler.handle(copy)).resolves.toBe(output); + expect(source.handle).toHaveBeenCalledTimes(1); + }); + + it('rejects empty field arrays.', async(): Promise => { + expect((): any => new CachedHandler(source, [])) + .toThrow('The fields parameter needs to have at least 1 entry if defined.'); + }); });