mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Allow CachedHandler to cache on multiple object fields
This commit is contained in:
parent
f54c34d1e0
commit
9b15b1d7e1
@ -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" },
|
||||
|
@ -1,52 +1,103 @@
|
||||
import { AsyncHandler } from './AsyncHandler';
|
||||
|
||||
type NestedMap<TOut> = TOut | WeakMap<object, NestedMap<TOut>>;
|
||||
|
||||
/**
|
||||
* 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<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 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();
|
||||
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<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 this.source.canHandle(input);
|
||||
}
|
||||
|
||||
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) {
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
|
@ -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<AsyncHandler<{ entry: { key: string }}, string>>;
|
||||
let handler: CachedHandler<{ entry: { key: string }}, string>;
|
||||
let source: jest.Mocked<AsyncHandler<any, string>>;
|
||||
let handler: CachedHandler<any, string>;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
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<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> => {
|
||||
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<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.');
|
||||
});
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user