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.",
|
"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" },
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user