feat: Allow CachedHandler to cache on multiple object fields

This commit is contained in:
Joachim Van Herwegen 2022-11-17 14:30:47 +01:00
parent f54c34d1e0
commit 9b15b1d7e1
3 changed files with 93 additions and 20 deletions

View File

@ -5,7 +5,7 @@
"comment": "Generates the Notification objects and caches them based on the topic.", "comment": "Generates the Notification objects and caches them based on the topic.",
"@id": "urn:solid-server:default:BaseNotificationGenerator", "@id": "urn:solid-server:default:BaseNotificationGenerator",
"@type": "CachedHandler", "@type": "CachedHandler",
"field": "topic", "fields": [ "topic" ],
"source": { "source": {
"@type": "StateNotificationGenerator", "@type": "StateNotificationGenerator",
"resourceSet": { "@id": "urn:solid-server:default:CachedResourceSet" }, "resourceSet": { "@id": "urn:solid-server:default:CachedResourceSet" },

View File

@ -1,52 +1,103 @@
import { AsyncHandler } from './AsyncHandler'; import { AsyncHandler } from './AsyncHandler';
type NestedMap<TOut> = TOut | WeakMap<object, NestedMap<TOut>>;
/** /**
* Caches output data from the source handler based on the input object. * 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, * 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`. * so has as actual required typing `(keyof TIn)[]`.
* *
* A {@link WeakMap} is used internally so strict object equality determines cache hits, * A {@link WeakMap} is used internally so strict object equality determines cache hits,
* and data will be removed once the key stops existing. * and data will be removed once the key stops existing.
* This also means that the cache key needs to be an object. * 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<TIn, TOut = void> extends AsyncHandler<TIn, TOut> { export class CachedHandler<TIn extends Record<string, any>, TOut = void> extends AsyncHandler<TIn, TOut> {
private readonly source: AsyncHandler<TIn, TOut>; private readonly source: AsyncHandler<TIn, TOut>;
private readonly field?: string; private readonly fields?: [keyof TIn, ...(keyof TIn)[]];
private readonly cache: WeakMap<any, TOut>; private readonly cache: WeakMap<object, NestedMap<TOut>>;
public constructor(source: AsyncHandler<TIn, TOut>, field?: string) { public constructor(source: AsyncHandler<TIn, TOut>, fields?: string[]) {
super(); super();
this.source = source; 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(); this.cache = new WeakMap();
} }
public async canHandle(input: TIn): Promise<void> { public async canHandle(input: TIn): Promise<void> {
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;
} }
return this.source.canHandle(input); return this.source.canHandle(input);
} }
public async handle(input: TIn): Promise<TOut> { public async handle(input: TIn): Promise<TOut> {
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) { if (result) {
return result; return result;
} }
result = await this.source.handle(input); result = await this.source.handle(input);
this.cache.set(key, result); map.set(key, result);
return 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<object, NestedMap<TOut>>, ensure = false):
WeakMap<object, TOut> | undefined {
if (keys.length === 1) {
return cache as WeakMap<object, TOut>;
}
const key = keys[0];
let nextCache = cache.get(key) as WeakMap<object, NestedMap<TOut>> | undefined;
if (!nextCache) {
if (!ensure) {
return;
}
nextCache = new WeakMap<object, NestedMap<TOut>>();
cache.set(key, nextCache);
}
return this.findDestination(input, keys.slice(1), nextCache, ensure);
} }
} }

View File

@ -4,10 +4,10 @@ import {
} from '../../../../src/util/handlers/CachedHandler'; } from '../../../../src/util/handlers/CachedHandler';
describe('A CachedHandler', (): void => { describe('A CachedHandler', (): void => {
const input = { entry: { key: 'value' }}; const input: any = { field1: { key: 'value' }, field2: { key: 'value' }, field3: { key: 'value2' }};
const output = 'response'; const output = 'response';
let source: jest.Mocked<AsyncHandler<{ entry: { key: string }}, string>>; let source: jest.Mocked<AsyncHandler<any, string>>;
let handler: CachedHandler<{ entry: { key: string }}, string>; let handler: CachedHandler<any, string>;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
source = { source = {
@ -51,12 +51,34 @@ describe('A CachedHandler', (): void => {
expect(source.canHandle).toHaveBeenCalledTimes(0); expect(source.canHandle).toHaveBeenCalledTimes(0);
}); });
it('cannot handle input with multiple keys if the first key is already missing.', async(): Promise<void> => {
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<void> => { it('can use a specific field of the input as key.', async(): Promise<void> => {
handler = new CachedHandler(source, 'entry'); handler = new CachedHandler(source, [ 'field1' ]);
const copy = { ...input }; const copy = { ...input };
await expect(handler.handle(input)).resolves.toBe(output); await expect(handler.handle(input)).resolves.toBe(output);
await expect(handler.handle(copy)).resolves.toBe(output); await expect(handler.handle(copy)).resolves.toBe(output);
expect(source.handle).toHaveBeenCalledTimes(1); expect(source.handle).toHaveBeenCalledTimes(1);
}); });
it('can use multiple fields of the object as keys.', async(): Promise<void> => {
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<void> => {
expect((): any => new CachedHandler(source, []))
.toThrow('The fields parameter needs to have at least 1 entry if defined.');
});
}); });