From 5dd2bddaaf74325e2da80e99b3be72baa412250a Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 8 Nov 2019 18:43:24 +0200 Subject: [PATCH 1/9] cache --- examples/cache.ts | 25 ++ lib/{cache.ts => cache/CacheOptions.ts} | 21 +- lib/cache/caches/Cache.ts | 6 + lib/cache/caches/ClassCache.ts | 36 +++ lib/cache/caches/InstanceCache.ts | 53 ++++ lib/cache/expirations/AbsoluteExpiration.ts | 27 ++ lib/cache/expirations/Expiration.ts | 3 + lib/cache/expirations/SlidingExpiration.ts | 35 +++ lib/cache/factories/cacheFactory.ts | 48 ++++ lib/cache/factories/expirationFactory.ts | 25 ++ lib/cache/factories/storeFactory.ts | 21 ++ lib/cache/hash/hash.ts | 9 + lib/cache/index.ts | 68 +++++ lib/cache/storages/MemoryStorage.ts | 32 +++ lib/cache/storages/Storage.ts | 6 + lib/interfaces/class.ts | 3 + lib/interfaces/factory.ts | 3 + package.json | 3 +- test/cache.spec.ts | 286 ++++++++++++++++++++ test/utils.ts | 6 + 20 files changed, 705 insertions(+), 11 deletions(-) create mode 100644 examples/cache.ts rename lib/{cache.ts => cache/CacheOptions.ts} (65%) create mode 100644 lib/cache/caches/Cache.ts create mode 100644 lib/cache/caches/ClassCache.ts create mode 100644 lib/cache/caches/InstanceCache.ts create mode 100644 lib/cache/expirations/AbsoluteExpiration.ts create mode 100644 lib/cache/expirations/Expiration.ts create mode 100644 lib/cache/expirations/SlidingExpiration.ts create mode 100644 lib/cache/factories/cacheFactory.ts create mode 100644 lib/cache/factories/expirationFactory.ts create mode 100644 lib/cache/factories/storeFactory.ts create mode 100644 lib/cache/hash/hash.ts create mode 100644 lib/cache/index.ts create mode 100644 lib/cache/storages/MemoryStorage.ts create mode 100644 lib/cache/storages/Storage.ts create mode 100644 lib/interfaces/class.ts create mode 100644 lib/interfaces/factory.ts create mode 100644 test/cache.spec.ts diff --git a/examples/cache.ts b/examples/cache.ts new file mode 100644 index 0000000..e5bd9d8 --- /dev/null +++ b/examples/cache.ts @@ -0,0 +1,25 @@ +import { cache } from '../lib/cache/'; + +class Service { + + @cache(1000, { scope: 'class' }) + public method(n: number): number { + return n ** 2; + } + + @cache(1000, { scope: 'class' }) + public asyncMethod(n: number): Promise { + return new Promise((resolve) => { + setTimeout(() => resolve(n ** 2), 10); + }); + } + +} + +const s = new Service(); +s.asyncMethod(4).then(res => console.log(res)); // first call => no cache +console.log(s.method(4)); // first call => no cache + +new Service().asyncMethod(3).then(res => console.log(res)); // first call => no cache +new Service().asyncMethod(3).then(res => console.log(res)); // second call => from cache +new Service().asyncMethod(4).then(res => console.log(res)); // second call => from cache diff --git a/lib/cache.ts b/lib/cache/CacheOptions.ts similarity index 65% rename from lib/cache.ts rename to lib/cache/CacheOptions.ts index 7c7958a..ad3f96b 100644 --- a/lib/cache.ts +++ b/lib/cache/CacheOptions.ts @@ -4,13 +4,12 @@ export type CacheOptions = { * `absolute` (default) strategy expires after the the set amount of time. * `sliding` strategy expires after the method hasn't been called in a set amount of time. */ - expiration: 'absolute' | 'sliding', // TODO: allow custom policy to be injected + expiration?: 'absolute' | 'sliding', // TODO: allow custom policy to be injected /** * The cache scope. * The `class` (default) scope defines a single method scope for all class instances. * The `instance` scope defines a per-instance method scope. - * The hash key is calculated using `object-hash` of current arguments list. */ scope?: 'class' | 'instance', @@ -27,11 +26,13 @@ export type CacheOptions = { size?: number, }; -/** - * Caches the result of a method. - * @param timeout cache timeout in milliseconds. - * @param options (optional) caching options. - */ -export function cache(timeout: number, options?: CacheOptions) { - throw new Error('Not implemented.'); -} +export const DEFAULT_EXPIRATION = 'absolute'; +export const DEFAULT_SCOPE = 'class'; +export const DEFAULT_STORAGE = 'memory'; +export const DEFAULT_SIZE = null; +export const DEFAULT_OPTIONS: CacheOptions = { + expiration: DEFAULT_EXPIRATION, + scope: DEFAULT_SCOPE, + storage: DEFAULT_STORAGE, + size: DEFAULT_SIZE, +}; diff --git a/lib/cache/caches/Cache.ts b/lib/cache/caches/Cache.ts new file mode 100644 index 0000000..f149318 --- /dev/null +++ b/lib/cache/caches/Cache.ts @@ -0,0 +1,6 @@ +import { ClassType } from '../../interfaces/class'; + +export interface Cache { + set(key: K, value: V, instance: ClassType): void; + get(key: K, instance: ClassType): V; +} diff --git a/lib/cache/caches/ClassCache.ts b/lib/cache/caches/ClassCache.ts new file mode 100644 index 0000000..4312b58 --- /dev/null +++ b/lib/cache/caches/ClassCache.ts @@ -0,0 +1,36 @@ +import { Expiration } from '../expirations/Expiration'; +import { HashService } from '../hash/hash'; +import { Storage } from '../storages/Storage'; +import { Cache } from './Cache'; + +export class ClassCache implements Cache { + + constructor( + private readonly storage: Storage, + private readonly expiration: Expiration, + private readonly hash: HashService, + ) { } + + public set(key: K, value: V): this { + const keyHash = this.hash.hash(key); + + this.storage.set(keyHash, value); + this.expiration.add(keyHash, () => this.delete(keyHash)); + + return this; + } + + public get(key: K): V { + const keyHash = this.hash.hash(key); + + this.expiration.add(keyHash, () => this.delete(keyHash)); + return this.storage.get(keyHash); + } + + private delete(key: string): this { + this.storage.delete(key); + + return this; + } + +} diff --git a/lib/cache/caches/InstanceCache.ts b/lib/cache/caches/InstanceCache.ts new file mode 100644 index 0000000..0128fd5 --- /dev/null +++ b/lib/cache/caches/InstanceCache.ts @@ -0,0 +1,53 @@ +import { ClassType } from '../../interfaces/class'; +import { Expiration } from '../expirations/Expiration'; +import { HashService } from '../hash/hash'; +import { Storage } from '../storages/Storage'; +import { Cache } from './Cache'; + +export class InstanceCache implements Cache { + + private readonly instanceStorage = new WeakMap(); + + constructor( + private readonly storage: () => Storage, + private readonly expiration: () => Expiration, + private readonly hash: HashService, + ) { } + + public set(key: K, value: V, instance: ClassType): this { + const keyHash = this.hash.hash(key); + const [storage, expiration] = this.instanceData(instance); + + storage.set(keyHash, value); + expiration.add(keyHash, () => this.delete(keyHash, instance)); + + return this; + } + + public get(key: K, instance: ClassType): V { + const keyHash = this.hash.hash(key); + const [storage, expiration] = this.instanceData(instance); + + expiration.add(keyHash, () => this.delete(keyHash, instance)); + return storage.get(keyHash); + } + + private delete(key: string, instance: ClassType): this { + const [storage] = this.instanceData(instance); + + storage.delete(key); + + return this; + } + + private instanceData(instance: ClassType): [Storage, Expiration] { + if (!this.instanceStorage.has(instance)) { + const storage = this.storage(); + const expiration = this.expiration(); + this.instanceStorage.set(instance, [storage, expiration]); + } + + return this.instanceStorage.get(instance); + } + +} diff --git a/lib/cache/expirations/AbsoluteExpiration.ts b/lib/cache/expirations/AbsoluteExpiration.ts new file mode 100644 index 0000000..b5bcd6c --- /dev/null +++ b/lib/cache/expirations/AbsoluteExpiration.ts @@ -0,0 +1,27 @@ +import { Expiration } from './Expiration'; + +export class AbsoluteExpiration implements Expiration { + + private readonly expirations = new Set(); + + constructor( + private readonly timeout: number, + ) { } + + public add(key: string, clear: () => any): void { + if (this.expirations.has(key)) { + return; + } + + this.expirations.add(key); + setTimeout(this.clear(key, clear), this.timeout); + } + + private clear(key: string, clear: () => any): () => void { + return () => { + clear(); + this.expirations.delete(key); + }; + } + +} diff --git a/lib/cache/expirations/Expiration.ts b/lib/cache/expirations/Expiration.ts new file mode 100644 index 0000000..d8ae260 --- /dev/null +++ b/lib/cache/expirations/Expiration.ts @@ -0,0 +1,3 @@ +export interface Expiration { + add(key: string, clear: () => any): void; +} diff --git a/lib/cache/expirations/SlidingExpiration.ts b/lib/cache/expirations/SlidingExpiration.ts new file mode 100644 index 0000000..fff76e1 --- /dev/null +++ b/lib/cache/expirations/SlidingExpiration.ts @@ -0,0 +1,35 @@ +import { Expiration } from './Expiration'; + +export class SlidingExpiration implements Expiration { + + private readonly expirations = new Map any]>(); + + constructor( + private readonly timeout: number, + ) { } + + public add(key: string, clear: () => any): void { + this.expirations.has(key) ? this.update(key, clear) : this.addKey(key, clear); + } + + private addKey(key: string, clear: () => any): void { + const timeoutId = setTimeout( + () => { + clear(); + this.expirations.delete(key); + }, + this.timeout, + ); + + this.expirations.set(key, [timeoutId as any, clear]); + } + + private update(key: string, clear: () => any): void { + const [timeoutId] = this.expirations.get(key); + clearTimeout(timeoutId as any); + + this.expirations.delete(key); + this.addKey(key, clear); + } + +} diff --git a/lib/cache/factories/cacheFactory.ts b/lib/cache/factories/cacheFactory.ts new file mode 100644 index 0000000..554462e --- /dev/null +++ b/lib/cache/factories/cacheFactory.ts @@ -0,0 +1,48 @@ +import { CacheOptions } from '..'; +import { DEFAULT_OPTIONS, DEFAULT_SCOPE } from '../CacheOptions'; +import { Cache } from '../caches/Cache'; +import { ClassCache } from '../caches/ClassCache'; +import { InstanceCache } from '../caches/InstanceCache'; +import { HashService } from '../hash/hash'; +import { expirationFactory } from './expirationFactory'; +import { storeFactory } from './storeFactory'; + +const cacheFactories: ReadonlyMap< + 'class' | 'instance', + (timeout: number, options: CacheOptions) => Cache +> = new Map<'class' | 'instance', (timeout: number, options: CacheOptions) => Cache>() + .set('class', classCacheFactory) + .set('instance', instanceCacheFactory); + +export function cacheFactory( + timeout: number, + options: CacheOptions = DEFAULT_OPTIONS, +): Cache { + const scope = options.scope || DEFAULT_SCOPE; + + const factory = cacheFactories.get(scope); + + if (!factory) { + throw new Error(`@cahce Scope type is not suported: ${scope}.`); + } + + return factory(timeout, options); +} + +function classCacheFactory(timeout: number, options: CacheOptions): ClassCache { + const storage = storeFactory(options); + const expiration = expirationFactory(timeout, options); + const hash = new HashService(); + + return new ClassCache(storage, expiration, hash); +} + +function instanceCacheFactory(timeout: number, options: CacheOptions): InstanceCache { + const hash = new HashService(); + + return new InstanceCache( + () => storeFactory(options), + () => expirationFactory(timeout, options), + hash, + ); +} diff --git a/lib/cache/factories/expirationFactory.ts b/lib/cache/factories/expirationFactory.ts new file mode 100644 index 0000000..cc5799a --- /dev/null +++ b/lib/cache/factories/expirationFactory.ts @@ -0,0 +1,25 @@ +import { CacheOptions } from '..'; +import { DEFAULT_EXPIRATION } from '../CacheOptions'; +import { AbsoluteExpiration } from '../expirations/AbsoluteExpiration'; +import { Expiration } from '../expirations/Expiration'; +import { SlidingExpiration } from '../expirations/SlidingExpiration'; + +const expirationFactories: ReadonlyMap<'absolute' | 'sliding', (timeout: number) => Expiration> = + new Map<'absolute' | 'sliding', (timeout: number) => Expiration>() + .set('absolute', timeout => new AbsoluteExpiration(timeout)) + .set('sliding', timeout => new SlidingExpiration(timeout)); + +export function expirationFactory( + timeout: number, + options: CacheOptions, +): Expiration { + const expirationType = options.expiration || DEFAULT_EXPIRATION; + + const factory = expirationFactories.get(expirationType); + + if (!factory) { + throw new Error(`@cache Expiration type is not supported: ${expirationType}.`); + } + + return factory(timeout); +} diff --git a/lib/cache/factories/storeFactory.ts b/lib/cache/factories/storeFactory.ts new file mode 100644 index 0000000..5f21889 --- /dev/null +++ b/lib/cache/factories/storeFactory.ts @@ -0,0 +1,21 @@ +import { CacheOptions } from '..'; +import { DEFAULT_SIZE, DEFAULT_STORAGE } from '../CacheOptions'; +import { MemoryStorage } from '../storages/MemoryStorage'; +import { Storage } from '../storages/Storage'; + +const storeFactories: ReadonlyMap<'memory', (limit: number) => Storage> = + new Map<'memory', (limit: number) => Storage>() + .set('memory', limit => new MemoryStorage(limit)); + +export function storeFactory(options: CacheOptions): Storage { + const limit = options.size || DEFAULT_SIZE; + const storageType = options.storage || DEFAULT_STORAGE; + + const factory = storeFactories.get(storageType); + + if (!factory) { + throw new Error(`@cache Storage type is not supported: ${storageType}.`); + } + + return factory(limit); +} diff --git a/lib/cache/hash/hash.ts b/lib/cache/hash/hash.ts new file mode 100644 index 0000000..d895eb1 --- /dev/null +++ b/lib/cache/hash/hash.ts @@ -0,0 +1,9 @@ +import * as hash from 'object-hash'; + +export class HashService { + + public hash(value: any): string { + return hash(value, { ignoreUnknown: false }); + } + +} diff --git a/lib/cache/index.ts b/lib/cache/index.ts new file mode 100644 index 0000000..e8d98d7 --- /dev/null +++ b/lib/cache/index.ts @@ -0,0 +1,68 @@ +import { CacheOptions, DEFAULT_OPTIONS } from './CacheOptions'; +import { cacheFactory } from './factories/cacheFactory'; + +export { CacheOptions }; + +interface CacheOptionsAndTimeout extends CacheOptions { + timeout: number; +} + +/** + * Caches the result of a method. + * @param timeout cache timeout in milliseconds. + * @param options (optional) caching options. + */ +export function cache(timeout: number): MethodDecorator; +export function cache(options: CacheOptionsAndTimeout): MethodDecorator; +export function cache(timeout: number, options?: CacheOptions): MethodDecorator; +export function cache( + timeoutOrOptions: number | CacheOptionsAndTimeout, + optionsOrVoid: CacheOptions = DEFAULT_OPTIONS, +): MethodDecorator { + + const { timeout, options } = parseParameters(timeoutOrOptions, optionsOrVoid); + const cacheService = cacheFactory(timeout, options); + + return function (_: any, __: any, descriptor: PropertyDescriptor) { + const method = descriptor.value; + + descriptor.value = async function (...args: any[]) { + const cachedValue = cacheService.get(args, this); + if (cachedValue) { + return cachedValue; + } + + try { + const value = method(...args); + cacheService.set(args, value, this); + return value; + } catch (error) { + return Promise.reject(error); + } + }; + + return descriptor; + }; +} + +interface Parameters { + timeout: number; + options: CacheOptions; +} + +function parseParameters( + timeoutOrOptions: number | CacheOptionsAndTimeout, + optionsOrVoid: CacheOptions, +): Parameters { + if (typeof timeoutOrOptions === 'number') { + return { + timeout: timeoutOrOptions, + options: { ...DEFAULT_OPTIONS, ...optionsOrVoid || {} }, + }; + } + + return { + timeout: timeoutOrOptions.timeout, + options: { ...DEFAULT_OPTIONS, ...timeoutOrOptions }, + }; +} diff --git a/lib/cache/storages/MemoryStorage.ts b/lib/cache/storages/MemoryStorage.ts new file mode 100644 index 0000000..82cf8bf --- /dev/null +++ b/lib/cache/storages/MemoryStorage.ts @@ -0,0 +1,32 @@ +import { Storage } from './Storage'; + +export class MemoryStorage implements Storage { + + private readonly storage = new Map(); + + constructor(private readonly limit?: number) { } + + public set(key: string, value: V): Storage { + if (typeof this.limit === 'number' && this.storage.size >= this.limit) { + return this; + } + + this.storage.set(key, value); + return this; + } + + public get(key: string): V { + return this.storage.get(key); + } + + public has(key: string): boolean { + return this.storage.has(key); + } + + public delete(key: string): Storage { + this.storage.delete(key); + + return this; + } + +} diff --git a/lib/cache/storages/Storage.ts b/lib/cache/storages/Storage.ts new file mode 100644 index 0000000..3878135 --- /dev/null +++ b/lib/cache/storages/Storage.ts @@ -0,0 +1,6 @@ +export interface Storage { + set(key: string, value: V): Storage; + get(key: string): V; + has(key: string): boolean; + delete(key: string): Storage; +} diff --git a/lib/interfaces/class.ts b/lib/interfaces/class.ts new file mode 100644 index 0000000..baa0161 --- /dev/null +++ b/lib/interfaces/class.ts @@ -0,0 +1,3 @@ +export type ClassType = { + new(...args: any[]): T; +}; diff --git a/lib/interfaces/factory.ts b/lib/interfaces/factory.ts new file mode 100644 index 0000000..a6a4809 --- /dev/null +++ b/lib/interfaces/factory.ts @@ -0,0 +1,3 @@ +export interface Factory { + create(): T; +} diff --git a/package.json b/package.json index fb711ae..fc33bba 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@types/chai": "4.1.7", "@types/chai-as-promised": "7.1.0", "@types/mocha": "5.2.5", + "@types/object-hash": "^1.2.0", "@types/sinon": "7.0.4", "chai": "4.2.0", "chai-as-promised": "7.1.1", @@ -63,4 +64,4 @@ "tslint-config-airbnb": "5.11.1", "typescript": "3.2.4" } -} \ No newline at end of file +} diff --git a/test/cache.spec.ts b/test/cache.spec.ts new file mode 100644 index 0000000..ee3a153 --- /dev/null +++ b/test/cache.spec.ts @@ -0,0 +1,286 @@ +import { expect } from 'chai'; + +import { cache, CacheOptions } from '../lib'; +import { delay, executionTime } from './utils'; + +describe('@cache', () => { + + const delayTime = 5; + const timePrecision = 1; + + async function asyncFunction(n: number): Promise { + await delay(delayTime); + return n + 1; + } + + const classFactory = (timeout: number, options?: CacheOptions) => { + class Test { + + @cache(timeout, options) + public method(n: number): Promise { + return asyncFunction(n); + } + } + + return Test; + }; + + it('should cache value if method is called with same arguments', async () => { + const timeout = 1000; + const options: CacheOptions = { size: 3 }; + const instance = new (classFactory(timeout, options)); + + await instance.method(42); + const time = await executionTime(() => instance.method(42)); + + expect(time).to.be.approximately(0, timePrecision); + }); + + it('should not cache value if method is called with different arguments', async () => { + const timeout = 1000; + const options: CacheOptions = { size: 3 }; + const instance = new (classFactory(timeout, options)); + + const initialTime = await executionTime(() => instance.method(42)); + const time = await executionTime(() => instance.method(24)); + + expect(time).to.be.approximately(delayTime, timePrecision); + }); + + describe('options values', () => { + + const timeout = 1000; + + it('should work without options', () => { + expect(() => new (classFactory(timeout))).to.not.throw(); + }); + + it('should work with correct options', () => { + const options: CacheOptions = { + scope: 'instance', + expiration: 'sliding', + storage: 'memory', + size: 300, + }; + + expect(() => new (classFactory(timeout, options))).to.not.throw(); + }); + + describe('should throw for wrong options', () => { + + it('should throw if expiration is not a valid value', () => { + const options: CacheOptions = { expiration: 'abc' as any }; + const expectedError = '@cache Expiration type is not supported: abc.'; + expect(() => new (classFactory(timeout, options))).to.throw(expectedError); + }); + + it('should throw if scope is not a valid value', () => { + const options: CacheOptions = { scope: 'xyz' as any }; + const expectedError = '@cahce Scope type is not suported: xyz.'; + expect(() => new (classFactory(timeout, options))).to.throw(expectedError); + }); + + it('should throw if storage is not a valid value', () => { + const options: CacheOptions = { storage: 'qwe' as any }; + const expectedError = '@cache Storage type is not supported: qwe.'; + expect(() => new (classFactory(timeout, options))).to.throw(expectedError); + }); + + }); + + }); + + describe("it shouldn't change method behaivor", () => { + + it('should return same value as without decorator', async () => { + const instance = new (classFactory(1000)); + expect(await asyncFunction(42)).to.be.equals(await instance.method(42)); + }); + + describe('result should be same at multiple calls', () => { + + it('for async methods', async () => { + const instance = new (classFactory(1000)); + const promises = Array.from({ length: 10 }, () => instance.method(42)); + const values = await Promise.all(promises); + + expect(new Set(values).size).to.be.equals(1); + }); + + }); + + }); + + describe('options behaivor', () => { + + describe('expiration', () => { + + describe('absolute', () => { + + const options: CacheOptions = { expiration: 'absolute' }; + + it('should return cached value', async () => { + const timeout = 20; + const instance = new (classFactory(timeout, options)); + await instance.method(42); + + const time = await executionTime(() => instance.method(42)); + expect(time).to.be.approximately(0, timePrecision); + }); + + it('should exprie after given timeout', async () => { + const timeout = delayTime + 5; + const instance = new (classFactory(timeout, options)); + await instance.method(42); + + await delay(timeout + 1); + + const time = await executionTime(() => instance.method(42)); + expect(time).to.be.approximately(delayTime, timePrecision); + }); + + it('should not refresh if was call before expire', async () => { + const timeout = 2 * delayTime; + const instance = new (classFactory(timeout, options)); + await instance.method(42); + + await delay(timeout / 2); + await instance.method(42); + await delay(timeout / 2 + 1); + + const time = await executionTime(() => instance.method(42)); + expect(time).to.be.approximately(delayTime, timePrecision); + }); + + }); + + describe('sliding', () => { + + const options: CacheOptions = { expiration: 'sliding', scope: 'class' }; + + it('should return cached value', async () => { + const timeout = 20; + const instance = new (classFactory(timeout, options)); + await instance.method(42); + + const time = await executionTime(() => instance.method(42)); + expect(time).to.be.approximately(0, timePrecision); + }); + + it('should expire after given timeout', async () => { + const timeout = delayTime + 5; + const instance = new (classFactory(timeout, options)); + await instance.method(42); + + await delay(timeout + 1); + + const time = await executionTime(() => instance.method(42)); + expect(time).to.be.approximately(delayTime, timePrecision); + }); + + it('should refresh if was call before expire', async () => { + const timeout = 3 * delayTime; + const instance = new (classFactory(timeout, options)); + await instance.method(42); + + await delay(timeout / 3); + await instance.method(42); + await delay(2 * timeout / 3); + + const time = await executionTime(() => instance.method(42)); + expect(time).to.be.approximately(0, timePrecision); + }); + + }); + + }); + + describe('scope', () => { + + describe('class', () => { + + const timeout = 1000; + const options: CacheOptions = { scope: 'class' }; + + it('should return cached value for same instance of class', async () => { + const instance = new (classFactory(timeout, options)); + await instance.method(42); + + const time = await executionTime(() => instance.method(42)); + expect(time).to.be.approximately(0, timePrecision); + }); + + it('should return cached value for every instances of class', async () => { + const constructor = classFactory(timeout, options); + await new constructor().method(42); + + const time = await executionTime(() => new constructor().method(42)); + expect(time).to.be.approximately(0, timePrecision); + }); + + }); + + describe('instance', () => { + + const timeout = 1000; + const options: CacheOptions = { scope: 'instance' }; + + it('should return cached value for same instance of class', async () => { + const instance = new (classFactory(timeout, options)); + await instance.method(42); + + const time = await executionTime(() => instance.method(42)); + expect(time).to.be.approximately(0, timePrecision); + }); + + it('should not return cached value for differenct instances of class', async () => { + const constructor = classFactory(timeout, options); + await new constructor().method(42); + + const time = await executionTime(() => new constructor().method(42)); + expect(time).to.be.approximately(delayTime, timePrecision); + }); + + }); + + }); + + describe('size', () => { + + const timeout = 1000; + + it('should cache value if storage limit is not reached', async () => { + const options: CacheOptions = { size: 3 }; + const instance = new (classFactory(timeout, options)); + + await Promise.all([instance.method(42), instance.method(24)]); + const times = await Promise.all([ + executionTime(() => instance.method(42)), + executionTime(() => instance.method(24)), + ]); + + times.forEach(time => expect(time).to.be.approximately(0, timePrecision)); + }); + + it('should not cache value if storage limit is reached', async () => { + const options: CacheOptions = { size: 1 }; + const instance = new (classFactory(timeout, options)); + + await Promise.all([ + instance.method(42), + instance.method(24), + ]); + const [firstTime, secondTime] = await Promise.all([ + executionTime(() => instance.method(42)), + executionTime(() => instance.method(24)), + ]); + + expect(firstTime).to.be.approximately(0, timePrecision); + expect(secondTime).to.be.approximately(delayTime, timePrecision); + }); + + }); + + }); + +}); diff --git a/test/utils.ts b/test/utils.ts index 850f631..d4cfa68 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -11,3 +11,9 @@ export function repeat(func: () => void, count: number) { return results; } + +export async function executionTime(func: () => Promise): Promise { + const begin = Date.now(); + await func(); + return Date.now() - begin; +} From b477af04e1f48a6b56c6cce1988177425f9ea7bd Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 12 Nov 2019 17:58:47 +0200 Subject: [PATCH 2/9] cahce fixes --- examples/cache.ts | 15 +-- lib/cache/caches/Cache.ts | 4 +- lib/cache/caches/ClassCache.ts | 26 ++--- lib/cache/caches/InstanceCache.ts | 20 ++-- lib/cache/expirations/AbsoluteExpiration.ts | 6 +- lib/cache/expirations/Expiration.ts | 2 +- lib/cache/expirations/SlidingExpiration.ts | 14 +-- lib/cache/factories/cacheFactory.ts | 10 +- lib/cache/factories/expirationFactory.ts | 12 +-- lib/cache/factories/storeFactory.ts | 10 +- lib/cache/hash/hash.ts | 9 -- lib/cache/index.ts | 6 +- lib/cache/storages/MemoryStorage.ts | 44 +++++--- lib/cache/storages/Storage.ts | 7 +- lib/utils/hash/hash.ts | 9 ++ package.json | 4 +- test/cache.spec.ts | 109 ++++++++++++-------- test/utils.ts | 2 +- 18 files changed, 164 insertions(+), 145 deletions(-) delete mode 100644 lib/cache/hash/hash.ts create mode 100644 lib/utils/hash/hash.ts diff --git a/examples/cache.ts b/examples/cache.ts index e5bd9d8..7d1d3d3 100644 --- a/examples/cache.ts +++ b/examples/cache.ts @@ -2,11 +2,6 @@ import { cache } from '../lib/cache/'; class Service { - @cache(1000, { scope: 'class' }) - public method(n: number): number { - return n ** 2; - } - @cache(1000, { scope: 'class' }) public asyncMethod(n: number): Promise { return new Promise((resolve) => { @@ -16,10 +11,8 @@ class Service { } -const s = new Service(); -s.asyncMethod(4).then(res => console.log(res)); // first call => no cache -console.log(s.method(4)); // first call => no cache +const service = new Service(); -new Service().asyncMethod(3).then(res => console.log(res)); // first call => no cache -new Service().asyncMethod(3).then(res => console.log(res)); // second call => from cache -new Service().asyncMethod(4).then(res => console.log(res)); // second call => from cache +service.asyncMethod(3).then(res => console.log(res)); // first call => no cache +service.asyncMethod(3).then(res => console.log(res)); // second call => from cache +service.asyncMethod(4).then(res => console.log(res)); // first call => no cache diff --git a/lib/cache/caches/Cache.ts b/lib/cache/caches/Cache.ts index f149318..8bf5622 100644 --- a/lib/cache/caches/Cache.ts +++ b/lib/cache/caches/Cache.ts @@ -1,6 +1,6 @@ import { ClassType } from '../../interfaces/class'; export interface Cache { - set(key: K, value: V, instance: ClassType): void; - get(key: K, instance: ClassType): V; + set(key: K, value: V, instance: ClassType): Promise; + get(key: K, instance: ClassType): Promise; } diff --git a/lib/cache/caches/ClassCache.ts b/lib/cache/caches/ClassCache.ts index 4312b58..4f327da 100644 --- a/lib/cache/caches/ClassCache.ts +++ b/lib/cache/caches/ClassCache.ts @@ -1,5 +1,5 @@ import { Expiration } from '../expirations/Expiration'; -import { HashService } from '../hash/hash'; +import { HashService } from '../../utils/hash/hash'; import { Storage } from '../storages/Storage'; import { Cache } from './Cache'; @@ -11,26 +11,22 @@ export class ClassCache implements Cache { private readonly hash: HashService, ) { } - public set(key: K, value: V): this { - const keyHash = this.hash.hash(key); + public async set(key: K, value: V): Promise { + const keyHash = this.hash.calculate(key); - this.storage.set(keyHash, value); - this.expiration.add(keyHash, () => this.delete(keyHash)); - - return this; + await this.storage.set(keyHash, value); + this.expiration.add(keyHash, key => this.delete(key)); } - public get(key: K): V { - const keyHash = this.hash.hash(key); + public async get(key: K): Promise { + const keyHash = this.hash.calculate(key); - this.expiration.add(keyHash, () => this.delete(keyHash)); - return this.storage.get(keyHash); + this.expiration.add(keyHash, key => this.delete(key)); + return this.storage.get(keyHash); } - private delete(key: string): this { - this.storage.delete(key); - - return this; + private async delete(key: string): Promise { + await this.storage.delete(key); } } diff --git a/lib/cache/caches/InstanceCache.ts b/lib/cache/caches/InstanceCache.ts index 0128fd5..8b963ce 100644 --- a/lib/cache/caches/InstanceCache.ts +++ b/lib/cache/caches/InstanceCache.ts @@ -1,6 +1,6 @@ import { ClassType } from '../../interfaces/class'; import { Expiration } from '../expirations/Expiration'; -import { HashService } from '../hash/hash'; +import { HashService } from '../../utils/hash/hash'; import { Storage } from '../storages/Storage'; import { Cache } from './Cache'; @@ -14,30 +14,26 @@ export class InstanceCache implements Cache { private readonly hash: HashService, ) { } - public set(key: K, value: V, instance: ClassType): this { - const keyHash = this.hash.hash(key); + public async set(key: K, value: V, instance: ClassType): Promise { + const keyHash = this.hash.calculate(key); const [storage, expiration] = this.instanceData(instance); - storage.set(keyHash, value); + await storage.set(keyHash, value); expiration.add(keyHash, () => this.delete(keyHash, instance)); - - return this; } - public get(key: K, instance: ClassType): V { - const keyHash = this.hash.hash(key); + public async get(key: K, instance: ClassType): Promise { + const keyHash = this.hash.calculate(key); const [storage, expiration] = this.instanceData(instance); expiration.add(keyHash, () => this.delete(keyHash, instance)); return storage.get(keyHash); } - private delete(key: string, instance: ClassType): this { + private async delete(key: string, instance: ClassType): Promise { const [storage] = this.instanceData(instance); - storage.delete(key); - - return this; + await storage.delete(key); } private instanceData(instance: ClassType): [Storage, Expiration] { diff --git a/lib/cache/expirations/AbsoluteExpiration.ts b/lib/cache/expirations/AbsoluteExpiration.ts index b5bcd6c..d697779 100644 --- a/lib/cache/expirations/AbsoluteExpiration.ts +++ b/lib/cache/expirations/AbsoluteExpiration.ts @@ -8,7 +8,7 @@ export class AbsoluteExpiration implements Expiration { private readonly timeout: number, ) { } - public add(key: string, clear: () => any): void { + public add(key: string, clear: (key: string) => unknown): void { if (this.expirations.has(key)) { return; } @@ -17,10 +17,10 @@ export class AbsoluteExpiration implements Expiration { setTimeout(this.clear(key, clear), this.timeout); } - private clear(key: string, clear: () => any): () => void { + private clear(key: string, clear: (key: string) => unknown): () => void { return () => { - clear(); this.expirations.delete(key); + clear(key); }; } diff --git a/lib/cache/expirations/Expiration.ts b/lib/cache/expirations/Expiration.ts index d8ae260..c721dd2 100644 --- a/lib/cache/expirations/Expiration.ts +++ b/lib/cache/expirations/Expiration.ts @@ -1,3 +1,3 @@ export interface Expiration { - add(key: string, clear: () => any): void; + add(key: string, clear: (key: string) => unknown): void; } diff --git a/lib/cache/expirations/SlidingExpiration.ts b/lib/cache/expirations/SlidingExpiration.ts index fff76e1..3653462 100644 --- a/lib/cache/expirations/SlidingExpiration.ts +++ b/lib/cache/expirations/SlidingExpiration.ts @@ -2,30 +2,30 @@ import { Expiration } from './Expiration'; export class SlidingExpiration implements Expiration { - private readonly expirations = new Map any]>(); + private readonly expirations = new Map(); constructor( private readonly timeout: number, ) { } - public add(key: string, clear: () => any): void { + public add(key: string, clear: (key: string) => unknown): void { this.expirations.has(key) ? this.update(key, clear) : this.addKey(key, clear); } - private addKey(key: string, clear: () => any): void { + private addKey(key: string, clear: (key: string) => unknown): void { const timeoutId = setTimeout( () => { - clear(); this.expirations.delete(key); + clear(key); }, this.timeout, ); - this.expirations.set(key, [timeoutId as any, clear]); + this.expirations.set(key, timeoutId as any); } - private update(key: string, clear: () => any): void { - const [timeoutId] = this.expirations.get(key); + private update(key: string, clear: (key: string) => unknown): void { + const timeoutId = this.expirations.get(key); clearTimeout(timeoutId as any); this.expirations.delete(key); diff --git a/lib/cache/factories/cacheFactory.ts b/lib/cache/factories/cacheFactory.ts index 554462e..fa79212 100644 --- a/lib/cache/factories/cacheFactory.ts +++ b/lib/cache/factories/cacheFactory.ts @@ -1,9 +1,8 @@ import { CacheOptions } from '..'; -import { DEFAULT_OPTIONS, DEFAULT_SCOPE } from '../CacheOptions'; +import { HashService } from '../../utils/hash/hash'; import { Cache } from '../caches/Cache'; import { ClassCache } from '../caches/ClassCache'; import { InstanceCache } from '../caches/InstanceCache'; -import { HashService } from '../hash/hash'; import { expirationFactory } from './expirationFactory'; import { storeFactory } from './storeFactory'; @@ -14,11 +13,8 @@ const cacheFactories: ReadonlyMap< .set('class', classCacheFactory) .set('instance', instanceCacheFactory); -export function cacheFactory( - timeout: number, - options: CacheOptions = DEFAULT_OPTIONS, -): Cache { - const scope = options.scope || DEFAULT_SCOPE; +export function cacheFactory(timeout: number, options: CacheOptions): Cache { + const { scope } = options; const factory = cacheFactories.get(scope); diff --git a/lib/cache/factories/expirationFactory.ts b/lib/cache/factories/expirationFactory.ts index cc5799a..ecaa447 100644 --- a/lib/cache/factories/expirationFactory.ts +++ b/lib/cache/factories/expirationFactory.ts @@ -1,5 +1,4 @@ import { CacheOptions } from '..'; -import { DEFAULT_EXPIRATION } from '../CacheOptions'; import { AbsoluteExpiration } from '../expirations/AbsoluteExpiration'; import { Expiration } from '../expirations/Expiration'; import { SlidingExpiration } from '../expirations/SlidingExpiration'; @@ -9,16 +8,13 @@ const expirationFactories: ReadonlyMap<'absolute' | 'sliding', (timeout: number) .set('absolute', timeout => new AbsoluteExpiration(timeout)) .set('sliding', timeout => new SlidingExpiration(timeout)); -export function expirationFactory( - timeout: number, - options: CacheOptions, -): Expiration { - const expirationType = options.expiration || DEFAULT_EXPIRATION; +export function expirationFactory(timeout: number, options: CacheOptions): Expiration { + const { expiration } = options; - const factory = expirationFactories.get(expirationType); + const factory = expirationFactories.get(expiration); if (!factory) { - throw new Error(`@cache Expiration type is not supported: ${expirationType}.`); + throw new Error(`@cache Expiration type is not supported: ${expiration}.`); } return factory(timeout); diff --git a/lib/cache/factories/storeFactory.ts b/lib/cache/factories/storeFactory.ts index 5f21889..968aec5 100644 --- a/lib/cache/factories/storeFactory.ts +++ b/lib/cache/factories/storeFactory.ts @@ -1,5 +1,4 @@ import { CacheOptions } from '..'; -import { DEFAULT_SIZE, DEFAULT_STORAGE } from '../CacheOptions'; import { MemoryStorage } from '../storages/MemoryStorage'; import { Storage } from '../storages/Storage'; @@ -8,14 +7,13 @@ const storeFactories: ReadonlyMap<'memory', (limit: number) => Storage> = .set('memory', limit => new MemoryStorage(limit)); export function storeFactory(options: CacheOptions): Storage { - const limit = options.size || DEFAULT_SIZE; - const storageType = options.storage || DEFAULT_STORAGE; + const { size, storage } = options; - const factory = storeFactories.get(storageType); + const factory = storeFactories.get(storage); if (!factory) { - throw new Error(`@cache Storage type is not supported: ${storageType}.`); + throw new Error(`@cache Storage type is not supported: ${storage}.`); } - return factory(limit); + return factory(size); } diff --git a/lib/cache/hash/hash.ts b/lib/cache/hash/hash.ts deleted file mode 100644 index d895eb1..0000000 --- a/lib/cache/hash/hash.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as hash from 'object-hash'; - -export class HashService { - - public hash(value: any): string { - return hash(value, { ignoreUnknown: false }); - } - -} diff --git a/lib/cache/index.ts b/lib/cache/index.ts index e8d98d7..2135500 100644 --- a/lib/cache/index.ts +++ b/lib/cache/index.ts @@ -27,13 +27,13 @@ export function cache( const method = descriptor.value; descriptor.value = async function (...args: any[]) { - const cachedValue = cacheService.get(args, this); + const cachedValue = await cacheService.get(args, this); if (cachedValue) { return cachedValue; } try { - const value = method(...args); + const value = await method(...args); cacheService.set(args, value, this); return value; } catch (error) { @@ -57,7 +57,7 @@ function parseParameters( if (typeof timeoutOrOptions === 'number') { return { timeout: timeoutOrOptions, - options: { ...DEFAULT_OPTIONS, ...optionsOrVoid || {} }, + options: { ...DEFAULT_OPTIONS, ...optionsOrVoid }, }; } diff --git a/lib/cache/storages/MemoryStorage.ts b/lib/cache/storages/MemoryStorage.ts index 82cf8bf..09fd0ce 100644 --- a/lib/cache/storages/MemoryStorage.ts +++ b/lib/cache/storages/MemoryStorage.ts @@ -3,30 +3,48 @@ import { Storage } from './Storage'; export class MemoryStorage implements Storage { private readonly storage = new Map(); + private keys: string[]; + private readonly hasLimit: boolean; - constructor(private readonly limit?: number) { } - - public set(key: string, value: V): Storage { - if (typeof this.limit === 'number' && this.storage.size >= this.limit) { - return this; + constructor(private readonly limit?: number) { + this.hasLimit = typeof this.limit === 'number'; + if (this.hasLimit) { + this.keys = []; } + } + + public set(key: string, value: V): Promise { + this.checkSize(key); this.storage.set(key, value); - return this; - } - public get(key: string): V { - return this.storage.get(key); + return Promise.resolve(); } - public has(key: string): boolean { - return this.storage.has(key); + public get(key: string): Promise { + return Promise.resolve(this.storage.get(key)); } - public delete(key: string): Storage { + public delete(key: string = this.keys[0]): Promise { this.storage.delete(key); - return this; + if (this.hasLimit) { + this.keys = this.keys.filter(value => value !== key); + } + + return Promise.resolve(); + } + + private checkSize(key: string): void { + if (!this.hasLimit) { + return; + } + + if (this.storage.size >= this.limit) { + this.delete(); + } + + this.keys.push(key); } } diff --git a/lib/cache/storages/Storage.ts b/lib/cache/storages/Storage.ts index 3878135..92f749e 100644 --- a/lib/cache/storages/Storage.ts +++ b/lib/cache/storages/Storage.ts @@ -1,6 +1,5 @@ export interface Storage { - set(key: string, value: V): Storage; - get(key: string): V; - has(key: string): boolean; - delete(key: string): Storage; + set(key: string, value: V): Promise; + get(key: string): Promise; + delete(key: string): Promise; } diff --git a/lib/utils/hash/hash.ts b/lib/utils/hash/hash.ts new file mode 100644 index 0000000..9605660 --- /dev/null +++ b/lib/utils/hash/hash.ts @@ -0,0 +1,9 @@ +import * as hash from 'object-hash'; + +export class HashService { + + public calculate(value: any): string { + return hash(value); + } + +} diff --git a/package.json b/package.json index fc33bba..644eff4 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@types/chai": "4.1.7", "@types/chai-as-promised": "7.1.0", "@types/mocha": "5.2.5", - "@types/object-hash": "^1.2.0", + "@types/object-hash": "1.3.0", "@types/sinon": "7.0.4", "chai": "4.2.0", "chai-as-promised": "7.1.1", @@ -64,4 +64,4 @@ "tslint-config-airbnb": "5.11.1", "typescript": "3.2.4" } -} +} \ No newline at end of file diff --git a/test/cache.spec.ts b/test/cache.spec.ts index ee3a153..4619386 100644 --- a/test/cache.spec.ts +++ b/test/cache.spec.ts @@ -5,30 +5,27 @@ import { delay, executionTime } from './utils'; describe('@cache', () => { - const delayTime = 5; - const timePrecision = 1; + const delayTime = 8; + const timePrecision = 2; - async function asyncFunction(n: number): Promise { - await delay(delayTime); - return n + 1; - } - - const classFactory = (timeout: number, options?: CacheOptions) => { + const factory = (timeout: number, options?: CacheOptions) => { class Test { @cache(timeout, options) - public method(n: number): Promise { - return asyncFunction(n); + public async method(n: number): Promise { + await delay(delayTime); + return n + 1; } + } return Test; }; - it('should cache value if method is called with same arguments', async () => { + it('should use cached value if method is called with same arguments', async () => { const timeout = 1000; const options: CacheOptions = { size: 3 }; - const instance = new (classFactory(timeout, options)); + const instance = new (factory(timeout, options)); await instance.method(42); const time = await executionTime(() => instance.method(42)); @@ -36,23 +33,37 @@ describe('@cache', () => { expect(time).to.be.approximately(0, timePrecision); }); - it('should not cache value if method is called with different arguments', async () => { + it('should not use cached value if method is called with different arguments', async () => { const timeout = 1000; const options: CacheOptions = { size: 3 }; - const instance = new (classFactory(timeout, options)); + const instance = new (factory(timeout, options)); - const initialTime = await executionTime(() => instance.method(42)); + await instance.method(42); const time = await executionTime(() => instance.method(24)); expect(time).to.be.approximately(delayTime, timePrecision); }); + it('should propagate error if method reject', () => { + const errorMessage = 'any error'; + class Test { + + @cache({ timeout: 100, scope: 'instance' }) + public async method() { + throw new Error(errorMessage); + } + } + + const instance = new Test(); + expect(instance.method()).to.be.rejectedWith(errorMessage); + }); + describe('options values', () => { const timeout = 1000; it('should work without options', () => { - expect(() => new (classFactory(timeout))).to.not.throw(); + expect(() => new (factory(timeout))).to.not.throw(); }); it('should work with correct options', () => { @@ -63,7 +74,23 @@ describe('@cache', () => { size: 300, }; - expect(() => new (classFactory(timeout, options))).to.not.throw(); + expect(() => new (factory(timeout, options))).to.not.throw(); + }); + + it('should work with one parameter', () => { + const test = () => { + class Test { + + @cache({ timeout: 100, scope: 'instance' }) + public async method() { + return 42; + } + } + + return Test; + }; + + expect(test).not.to.throw(); }); describe('should throw for wrong options', () => { @@ -71,19 +98,19 @@ describe('@cache', () => { it('should throw if expiration is not a valid value', () => { const options: CacheOptions = { expiration: 'abc' as any }; const expectedError = '@cache Expiration type is not supported: abc.'; - expect(() => new (classFactory(timeout, options))).to.throw(expectedError); + expect(() => new (factory(timeout, options))).to.throw(expectedError); }); it('should throw if scope is not a valid value', () => { const options: CacheOptions = { scope: 'xyz' as any }; const expectedError = '@cahce Scope type is not suported: xyz.'; - expect(() => new (classFactory(timeout, options))).to.throw(expectedError); + expect(() => new (factory(timeout, options))).to.throw(expectedError); }); it('should throw if storage is not a valid value', () => { const options: CacheOptions = { storage: 'qwe' as any }; const expectedError = '@cache Storage type is not supported: qwe.'; - expect(() => new (classFactory(timeout, options))).to.throw(expectedError); + expect(() => new (factory(timeout, options))).to.throw(expectedError); }); }); @@ -93,14 +120,14 @@ describe('@cache', () => { describe("it shouldn't change method behaivor", () => { it('should return same value as without decorator', async () => { - const instance = new (classFactory(1000)); - expect(await asyncFunction(42)).to.be.equals(await instance.method(42)); + const instance = new (factory(1000)); + expect(await instance.method(42)).to.be.equals(43); }); describe('result should be same at multiple calls', () => { it('for async methods', async () => { - const instance = new (classFactory(1000)); + const instance = new (factory(1000)); const promises = Array.from({ length: 10 }, () => instance.method(42)); const values = await Promise.all(promises); @@ -121,7 +148,7 @@ describe('@cache', () => { it('should return cached value', async () => { const timeout = 20; - const instance = new (classFactory(timeout, options)); + const instance = new (factory(timeout, options)); await instance.method(42); const time = await executionTime(() => instance.method(42)); @@ -130,7 +157,7 @@ describe('@cache', () => { it('should exprie after given timeout', async () => { const timeout = delayTime + 5; - const instance = new (classFactory(timeout, options)); + const instance = new (factory(timeout, options)); await instance.method(42); await delay(timeout + 1); @@ -141,7 +168,7 @@ describe('@cache', () => { it('should not refresh if was call before expire', async () => { const timeout = 2 * delayTime; - const instance = new (classFactory(timeout, options)); + const instance = new (factory(timeout, options)); await instance.method(42); await delay(timeout / 2); @@ -156,11 +183,11 @@ describe('@cache', () => { describe('sliding', () => { - const options: CacheOptions = { expiration: 'sliding', scope: 'class' }; + const options: CacheOptions = { expiration: 'sliding' }; it('should return cached value', async () => { const timeout = 20; - const instance = new (classFactory(timeout, options)); + const instance = new (factory(timeout, options)); await instance.method(42); const time = await executionTime(() => instance.method(42)); @@ -168,11 +195,11 @@ describe('@cache', () => { }); it('should expire after given timeout', async () => { - const timeout = delayTime + 5; - const instance = new (classFactory(timeout, options)); + const timeout = delayTime + 2; + const instance = new (factory(timeout, options)); await instance.method(42); - await delay(timeout + 1); + await delay(timeout + delayTime + 1); const time = await executionTime(() => instance.method(42)); expect(time).to.be.approximately(delayTime, timePrecision); @@ -180,7 +207,7 @@ describe('@cache', () => { it('should refresh if was call before expire', async () => { const timeout = 3 * delayTime; - const instance = new (classFactory(timeout, options)); + const instance = new (factory(timeout, options)); await instance.method(42); await delay(timeout / 3); @@ -203,7 +230,7 @@ describe('@cache', () => { const options: CacheOptions = { scope: 'class' }; it('should return cached value for same instance of class', async () => { - const instance = new (classFactory(timeout, options)); + const instance = new (factory(timeout, options)); await instance.method(42); const time = await executionTime(() => instance.method(42)); @@ -211,7 +238,7 @@ describe('@cache', () => { }); it('should return cached value for every instances of class', async () => { - const constructor = classFactory(timeout, options); + const constructor = factory(timeout, options); await new constructor().method(42); const time = await executionTime(() => new constructor().method(42)); @@ -226,7 +253,7 @@ describe('@cache', () => { const options: CacheOptions = { scope: 'instance' }; it('should return cached value for same instance of class', async () => { - const instance = new (classFactory(timeout, options)); + const instance = new (factory(timeout, options)); await instance.method(42); const time = await executionTime(() => instance.method(42)); @@ -234,7 +261,7 @@ describe('@cache', () => { }); it('should not return cached value for differenct instances of class', async () => { - const constructor = classFactory(timeout, options); + const constructor = factory(timeout, options); await new constructor().method(42); const time = await executionTime(() => new constructor().method(42)); @@ -251,7 +278,7 @@ describe('@cache', () => { it('should cache value if storage limit is not reached', async () => { const options: CacheOptions = { size: 3 }; - const instance = new (classFactory(timeout, options)); + const instance = new (factory(timeout, options)); await Promise.all([instance.method(42), instance.method(24)]); const times = await Promise.all([ @@ -262,9 +289,9 @@ describe('@cache', () => { times.forEach(time => expect(time).to.be.approximately(0, timePrecision)); }); - it('should not cache value if storage limit is reached', async () => { + it('should remove oldes value if storage limit is reached', async () => { const options: CacheOptions = { size: 1 }; - const instance = new (classFactory(timeout, options)); + const instance = new (factory(timeout, options)); await Promise.all([ instance.method(42), @@ -275,8 +302,8 @@ describe('@cache', () => { executionTime(() => instance.method(24)), ]); - expect(firstTime).to.be.approximately(0, timePrecision); - expect(secondTime).to.be.approximately(delayTime, timePrecision); + expect(firstTime).to.be.approximately(delayTime, timePrecision); + expect(secondTime).to.be.approximately(0, timePrecision); }); }); diff --git a/test/utils.ts b/test/utils.ts index d4cfa68..c264458 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -12,7 +12,7 @@ export function repeat(func: () => void, count: number) { return results; } -export async function executionTime(func: () => Promise): Promise { +export async function executionTime(func: () => Promise): Promise { const begin = Date.now(); await func(); return Date.now() - begin; From 74e69243632b79b07c7a66ab6f2a17350a7a55aa Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 12 Nov 2019 19:42:45 +0200 Subject: [PATCH 3/9] cache decorator improve tests --- test/cache.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/cache.spec.ts b/test/cache.spec.ts index 4619386..f6b80fe 100644 --- a/test/cache.spec.ts +++ b/test/cache.spec.ts @@ -167,13 +167,13 @@ describe('@cache', () => { }); it('should not refresh if was call before expire', async () => { - const timeout = 2 * delayTime; + const timeout = delayTime + 1; const instance = new (factory(timeout, options)); await instance.method(42); await delay(timeout / 2); await instance.method(42); - await delay(timeout / 2 + 1); + await delay(timeout / 2); const time = await executionTime(() => instance.method(42)); expect(time).to.be.approximately(delayTime, timePrecision); @@ -195,7 +195,7 @@ describe('@cache', () => { }); it('should expire after given timeout', async () => { - const timeout = delayTime + 2; + const timeout = delayTime; const instance = new (factory(timeout, options)); await instance.method(42); From 1299d6c36bd27e8148d7f88fc6066ea2328778ca Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 13 Nov 2019 15:48:27 +0200 Subject: [PATCH 4/9] cache move scope logic from cache to new class cache manager --- examples/cache.ts | 6 +-- lib/cache/cacheManager/CacheManager.ts | 6 +++ lib/cache/cacheManager/ClassCacheManager.ts | 23 +++++++++ .../cacheManager/InstanceCacheManager.ts | 26 ++++++++++ lib/cache/cacheManager/factory.ts | 27 ++++++++++ lib/cache/caches/Cache.ts | 38 ++++++++++++-- lib/cache/caches/ClassCache.ts | 32 ------------ lib/cache/caches/InstanceCache.ts | 49 ------------------- lib/cache/caches/factory.ts | 13 +++++ lib/cache/expirations/AbsoluteExpiration.ts | 12 ++--- lib/cache/expirations/Expiration.ts | 2 +- lib/cache/expirations/SlidingExpiration.ts | 12 ++--- .../factory.ts} | 6 +-- lib/cache/factories/cacheFactory.ts | 44 ----------------- lib/cache/index.ts | 33 ++++++------- lib/cache/storages/MemoryStorage.ts | 27 +++++----- lib/cache/storages/Storage.ts | 5 +- .../storeFactory.ts => storages/factory.ts} | 6 +-- lib/utils/hash/{hash.ts => index.ts} | 0 test/cache.spec.ts | 22 +++++++-- 20 files changed, 202 insertions(+), 187 deletions(-) create mode 100644 lib/cache/cacheManager/CacheManager.ts create mode 100644 lib/cache/cacheManager/ClassCacheManager.ts create mode 100644 lib/cache/cacheManager/InstanceCacheManager.ts create mode 100644 lib/cache/cacheManager/factory.ts delete mode 100644 lib/cache/caches/ClassCache.ts delete mode 100644 lib/cache/caches/InstanceCache.ts create mode 100644 lib/cache/caches/factory.ts rename lib/cache/{factories/expirationFactory.ts => expirations/factory.ts} (76%) delete mode 100644 lib/cache/factories/cacheFactory.ts rename lib/cache/{factories/storeFactory.ts => storages/factory.ts} (71%) rename lib/utils/hash/{hash.ts => index.ts} (100%) diff --git a/examples/cache.ts b/examples/cache.ts index 7d1d3d3..58bbbf9 100644 --- a/examples/cache.ts +++ b/examples/cache.ts @@ -13,6 +13,6 @@ class Service { const service = new Service(); -service.asyncMethod(3).then(res => console.log(res)); // first call => no cache -service.asyncMethod(3).then(res => console.log(res)); // second call => from cache -service.asyncMethod(4).then(res => console.log(res)); // first call => no cache +service.asyncMethod(3).then(res => console.log(res)); // no cache +service.asyncMethod(3).then(res => console.log(res)); // from cache +service.asyncMethod(4).then(res => console.log(res)); // no cache because another argument diff --git a/lib/cache/cacheManager/CacheManager.ts b/lib/cache/cacheManager/CacheManager.ts new file mode 100644 index 0000000..64f2d7e --- /dev/null +++ b/lib/cache/cacheManager/CacheManager.ts @@ -0,0 +1,6 @@ +import { ClassType } from '../../interfaces/class'; +import { Cache } from '../caches/Cache'; + +export interface CacheManager { + get(instance: ClassType): Cache; +} diff --git a/lib/cache/cacheManager/ClassCacheManager.ts b/lib/cache/cacheManager/ClassCacheManager.ts new file mode 100644 index 0000000..0a444e9 --- /dev/null +++ b/lib/cache/cacheManager/ClassCacheManager.ts @@ -0,0 +1,23 @@ +import { Cache } from '../caches/Cache'; +import { CacheManager } from './CacheManager'; +import { cacheFactory } from '../caches/factory'; +import { CacheOptions } from '../CacheOptions'; + +export class ClassCacheManager implements CacheManager { + + private cacheInstance: Cache = null; + + constructor( + private readonly timeout: number, + private readonly options: Readonly, + ) { } + + public get(): Cache { + if (!this.cacheInstance) { + this.cacheInstance = cacheFactory(this.timeout, this.options); + } + + return this.cacheInstance; + } + +} diff --git a/lib/cache/cacheManager/InstanceCacheManager.ts b/lib/cache/cacheManager/InstanceCacheManager.ts new file mode 100644 index 0000000..fad18cf --- /dev/null +++ b/lib/cache/cacheManager/InstanceCacheManager.ts @@ -0,0 +1,26 @@ +import { Cache } from '../caches/Cache'; +import { CacheManager } from './CacheManager'; +import { cacheFactory } from '../caches/factory'; +import { CacheOptions } from '../CacheOptions'; +import { ClassType } from '../../interfaces/class'; + +export class InstanceCacheManager implements CacheManager { + + private cacheByInstances = new WeakMap>(); + + constructor( + private readonly timeout: number, + private readonly options: Readonly, + ) { } + + public get(instance: ClassType): Cache { + const shouldCreateCache = !this.cacheByInstances.has(instance); + if (shouldCreateCache) { + const cache = cacheFactory(this.timeout, this.options); + this.cacheByInstances.set(instance, cache); + } + + return this.cacheByInstances.get(instance); + } + +} diff --git a/lib/cache/cacheManager/factory.ts b/lib/cache/cacheManager/factory.ts new file mode 100644 index 0000000..020096b --- /dev/null +++ b/lib/cache/cacheManager/factory.ts @@ -0,0 +1,27 @@ +import { CacheOptions } from '../CacheOptions'; +import { CacheManager } from './CacheManager'; +import { ClassCacheManager } from './ClassCacheManager'; +import { InstanceCacheManager } from './InstanceCacheManager'; + +const cacheManagerFactories: ReadonlyMap< + 'class' | 'instance', + (timeout: number, options: CacheOptions) => CacheManager +> = new Map<'class' | 'instance', (timeout: number, options: CacheOptions) => CacheManager>() + .set('class', (timeout, options) => new ClassCacheManager(timeout, options)) + .set('instance', (timeout, options) => new InstanceCacheManager(timeout, options)); + +export function cacheManagerFactory( + timeout: number, + options: CacheOptions, +): CacheManager { + + const { scope } = options; + + const factory = cacheManagerFactories.get(scope); + + if (!factory) { + throw new Error(`@cache invalid scope option: ${scope}.`); + } + + return factory(timeout, options); +} diff --git a/lib/cache/caches/Cache.ts b/lib/cache/caches/Cache.ts index 8bf5622..ccf3d81 100644 --- a/lib/cache/caches/Cache.ts +++ b/lib/cache/caches/Cache.ts @@ -1,6 +1,36 @@ -import { ClassType } from '../../interfaces/class'; +import { Expiration } from '../expirations/Expiration'; +import { HashService } from '../../utils/hash'; +import { Storage } from '../storages/Storage'; + +export class Cache { + + constructor( + private readonly storage: Storage, + private readonly expiration: Expiration, + private readonly hash: HashService, + ) { } + + public async set(key: K, value: V): Promise { + const keyHash = this.hash.calculate(key); + + await this.storage.set(keyHash, value); + this.expiration.add(keyHash, key => this.delete(key)); + } + + public async get(key: K): Promise { + const keyHash = this.hash.calculate(key); + + this.expiration.add(keyHash, key => this.delete(key)); + return this.storage.get(keyHash); + } + + public async has(key: K): Promise { + const keyHash = this.hash.calculate(key); + return this.storage.has(keyHash); + } + + private async delete(key: string): Promise { + await this.storage.delete(key); + } -export interface Cache { - set(key: K, value: V, instance: ClassType): Promise; - get(key: K, instance: ClassType): Promise; } diff --git a/lib/cache/caches/ClassCache.ts b/lib/cache/caches/ClassCache.ts deleted file mode 100644 index 4f327da..0000000 --- a/lib/cache/caches/ClassCache.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Expiration } from '../expirations/Expiration'; -import { HashService } from '../../utils/hash/hash'; -import { Storage } from '../storages/Storage'; -import { Cache } from './Cache'; - -export class ClassCache implements Cache { - - constructor( - private readonly storage: Storage, - private readonly expiration: Expiration, - private readonly hash: HashService, - ) { } - - public async set(key: K, value: V): Promise { - const keyHash = this.hash.calculate(key); - - await this.storage.set(keyHash, value); - this.expiration.add(keyHash, key => this.delete(key)); - } - - public async get(key: K): Promise { - const keyHash = this.hash.calculate(key); - - this.expiration.add(keyHash, key => this.delete(key)); - return this.storage.get(keyHash); - } - - private async delete(key: string): Promise { - await this.storage.delete(key); - } - -} diff --git a/lib/cache/caches/InstanceCache.ts b/lib/cache/caches/InstanceCache.ts deleted file mode 100644 index 8b963ce..0000000 --- a/lib/cache/caches/InstanceCache.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ClassType } from '../../interfaces/class'; -import { Expiration } from '../expirations/Expiration'; -import { HashService } from '../../utils/hash/hash'; -import { Storage } from '../storages/Storage'; -import { Cache } from './Cache'; - -export class InstanceCache implements Cache { - - private readonly instanceStorage = new WeakMap(); - - constructor( - private readonly storage: () => Storage, - private readonly expiration: () => Expiration, - private readonly hash: HashService, - ) { } - - public async set(key: K, value: V, instance: ClassType): Promise { - const keyHash = this.hash.calculate(key); - const [storage, expiration] = this.instanceData(instance); - - await storage.set(keyHash, value); - expiration.add(keyHash, () => this.delete(keyHash, instance)); - } - - public async get(key: K, instance: ClassType): Promise { - const keyHash = this.hash.calculate(key); - const [storage, expiration] = this.instanceData(instance); - - expiration.add(keyHash, () => this.delete(keyHash, instance)); - return storage.get(keyHash); - } - - private async delete(key: string, instance: ClassType): Promise { - const [storage] = this.instanceData(instance); - - await storage.delete(key); - } - - private instanceData(instance: ClassType): [Storage, Expiration] { - if (!this.instanceStorage.has(instance)) { - const storage = this.storage(); - const expiration = this.expiration(); - this.instanceStorage.set(instance, [storage, expiration]); - } - - return this.instanceStorage.get(instance); - } - -} diff --git a/lib/cache/caches/factory.ts b/lib/cache/caches/factory.ts new file mode 100644 index 0000000..c1a3cbf --- /dev/null +++ b/lib/cache/caches/factory.ts @@ -0,0 +1,13 @@ +import { HashService } from '../../utils/hash'; +import { CacheOptions } from '../CacheOptions'; +import { expirationFactory } from '../expirations/factory'; +import { storageFactory } from '../storages/factory'; +import { Cache } from './Cache'; + +export function cacheFactory(timeout: number, options: CacheOptions): Cache { + const storage = storageFactory(options); + const expiration = expirationFactory(timeout, options); + const hash = new HashService(); + + return new Cache(storage, expiration, hash); +} diff --git a/lib/cache/expirations/AbsoluteExpiration.ts b/lib/cache/expirations/AbsoluteExpiration.ts index d697779..2a4ff26 100644 --- a/lib/cache/expirations/AbsoluteExpiration.ts +++ b/lib/cache/expirations/AbsoluteExpiration.ts @@ -8,20 +8,18 @@ export class AbsoluteExpiration implements Expiration { private readonly timeout: number, ) { } - public add(key: string, clear: (key: string) => unknown): void { + public add(key: string, clearCallback: (key: string) => unknown): void { if (this.expirations.has(key)) { return; } this.expirations.add(key); - setTimeout(this.clear(key, clear), this.timeout); + setTimeout(() => this.clear(key, clearCallback), this.timeout); } - private clear(key: string, clear: (key: string) => unknown): () => void { - return () => { - this.expirations.delete(key); - clear(key); - }; + private clear(key: string, clearCallback: (key: string) => unknown): void { + this.expirations.delete(key); + clearCallback(key); } } diff --git a/lib/cache/expirations/Expiration.ts b/lib/cache/expirations/Expiration.ts index c721dd2..9342232 100644 --- a/lib/cache/expirations/Expiration.ts +++ b/lib/cache/expirations/Expiration.ts @@ -1,3 +1,3 @@ export interface Expiration { - add(key: string, clear: (key: string) => unknown): void; + add(key: string, clearCallback: (key: string) => unknown): void; } diff --git a/lib/cache/expirations/SlidingExpiration.ts b/lib/cache/expirations/SlidingExpiration.ts index 3653462..82b92fe 100644 --- a/lib/cache/expirations/SlidingExpiration.ts +++ b/lib/cache/expirations/SlidingExpiration.ts @@ -8,15 +8,15 @@ export class SlidingExpiration implements Expiration { private readonly timeout: number, ) { } - public add(key: string, clear: (key: string) => unknown): void { - this.expirations.has(key) ? this.update(key, clear) : this.addKey(key, clear); + public add(key: string, clearCallback: (key: string) => unknown): void { + this.expirations.has(key) ? this.update(key, clearCallback) : this.addKey(key, clearCallback); } - private addKey(key: string, clear: (key: string) => unknown): void { + private addKey(key: string, clearCallback: (key: string) => unknown): void { const timeoutId = setTimeout( () => { this.expirations.delete(key); - clear(key); + clearCallback(key); }, this.timeout, ); @@ -24,12 +24,12 @@ export class SlidingExpiration implements Expiration { this.expirations.set(key, timeoutId as any); } - private update(key: string, clear: (key: string) => unknown): void { + private update(key: string, clearCallback: (key: string) => unknown): void { const timeoutId = this.expirations.get(key); clearTimeout(timeoutId as any); this.expirations.delete(key); - this.addKey(key, clear); + this.addKey(key, clearCallback); } } diff --git a/lib/cache/factories/expirationFactory.ts b/lib/cache/expirations/factory.ts similarity index 76% rename from lib/cache/factories/expirationFactory.ts rename to lib/cache/expirations/factory.ts index ecaa447..3775b2e 100644 --- a/lib/cache/factories/expirationFactory.ts +++ b/lib/cache/expirations/factory.ts @@ -1,7 +1,7 @@ import { CacheOptions } from '..'; -import { AbsoluteExpiration } from '../expirations/AbsoluteExpiration'; -import { Expiration } from '../expirations/Expiration'; -import { SlidingExpiration } from '../expirations/SlidingExpiration'; +import { AbsoluteExpiration } from './AbsoluteExpiration'; +import { Expiration } from './Expiration'; +import { SlidingExpiration } from './SlidingExpiration'; const expirationFactories: ReadonlyMap<'absolute' | 'sliding', (timeout: number) => Expiration> = new Map<'absolute' | 'sliding', (timeout: number) => Expiration>() diff --git a/lib/cache/factories/cacheFactory.ts b/lib/cache/factories/cacheFactory.ts deleted file mode 100644 index fa79212..0000000 --- a/lib/cache/factories/cacheFactory.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { CacheOptions } from '..'; -import { HashService } from '../../utils/hash/hash'; -import { Cache } from '../caches/Cache'; -import { ClassCache } from '../caches/ClassCache'; -import { InstanceCache } from '../caches/InstanceCache'; -import { expirationFactory } from './expirationFactory'; -import { storeFactory } from './storeFactory'; - -const cacheFactories: ReadonlyMap< - 'class' | 'instance', - (timeout: number, options: CacheOptions) => Cache -> = new Map<'class' | 'instance', (timeout: number, options: CacheOptions) => Cache>() - .set('class', classCacheFactory) - .set('instance', instanceCacheFactory); - -export function cacheFactory(timeout: number, options: CacheOptions): Cache { - const { scope } = options; - - const factory = cacheFactories.get(scope); - - if (!factory) { - throw new Error(`@cahce Scope type is not suported: ${scope}.`); - } - - return factory(timeout, options); -} - -function classCacheFactory(timeout: number, options: CacheOptions): ClassCache { - const storage = storeFactory(options); - const expiration = expirationFactory(timeout, options); - const hash = new HashService(); - - return new ClassCache(storage, expiration, hash); -} - -function instanceCacheFactory(timeout: number, options: CacheOptions): InstanceCache { - const hash = new HashService(); - - return new InstanceCache( - () => storeFactory(options), - () => expirationFactory(timeout, options), - hash, - ); -} diff --git a/lib/cache/index.ts b/lib/cache/index.ts index 2135500..f38118f 100644 --- a/lib/cache/index.ts +++ b/lib/cache/index.ts @@ -1,11 +1,10 @@ import { CacheOptions, DEFAULT_OPTIONS } from './CacheOptions'; -import { cacheFactory } from './factories/cacheFactory'; +import { cacheFactory } from './caches/factory'; +import { cacheManagerFactory } from './cacheManager/factory'; export { CacheOptions }; -interface CacheOptionsAndTimeout extends CacheOptions { - timeout: number; -} +type TimeoutCacheOptions = CacheOptions & { timeout: number; }; /** * Caches the result of a method. @@ -13,28 +12,30 @@ interface CacheOptionsAndTimeout extends CacheOptions { * @param options (optional) caching options. */ export function cache(timeout: number): MethodDecorator; -export function cache(options: CacheOptionsAndTimeout): MethodDecorator; +export function cache(options: TimeoutCacheOptions): MethodDecorator; export function cache(timeout: number, options?: CacheOptions): MethodDecorator; export function cache( - timeoutOrOptions: number | CacheOptionsAndTimeout, + timeoutOrOptions: number | TimeoutCacheOptions, optionsOrVoid: CacheOptions = DEFAULT_OPTIONS, ): MethodDecorator { const { timeout, options } = parseParameters(timeoutOrOptions, optionsOrVoid); - const cacheService = cacheFactory(timeout, options); + const cacheManager = cacheManagerFactory(timeout, options); return function (_: any, __: any, descriptor: PropertyDescriptor) { const method = descriptor.value; descriptor.value = async function (...args: any[]) { - const cachedValue = await cacheService.get(args, this); - if (cachedValue) { - return cachedValue; + const cacheService = cacheManager.get(this); + const wasCached = await cacheService.has(args); + + if (wasCached) { + return cacheService.get(args); } try { const value = await method(...args); - cacheService.set(args, value, this); + cacheService.set(args, value); return value; } catch (error) { return Promise.reject(error); @@ -45,15 +46,11 @@ export function cache( }; } -interface Parameters { - timeout: number; - options: CacheOptions; -} - function parseParameters( - timeoutOrOptions: number | CacheOptionsAndTimeout, + timeoutOrOptions: number | TimeoutCacheOptions, optionsOrVoid: CacheOptions, -): Parameters { +) { + if (typeof timeoutOrOptions === 'number') { return { timeout: timeoutOrOptions, diff --git a/lib/cache/storages/MemoryStorage.ts b/lib/cache/storages/MemoryStorage.ts index 09fd0ce..a007baf 100644 --- a/lib/cache/storages/MemoryStorage.ts +++ b/lib/cache/storages/MemoryStorage.ts @@ -3,36 +3,41 @@ import { Storage } from './Storage'; export class MemoryStorage implements Storage { private readonly storage = new Map(); - private keys: string[]; + private readonly hasLimit: boolean; + private readonly keysStorage: Set; constructor(private readonly limit?: number) { this.hasLimit = typeof this.limit === 'number'; if (this.hasLimit) { - this.keys = []; + this.keysStorage = new Set(); } } - public set(key: string, value: V): Promise { + public async set(key: string, value: V): Promise { this.checkSize(key); this.storage.set(key, value); - return Promise.resolve(); + return this; + } + + public async get(key: string): Promise { + return this.storage.get(key); } - public get(key: string): Promise { - return Promise.resolve(this.storage.get(key)); + public async has(key: string): Promise { + return this.storage.has(key); } - public delete(key: string = this.keys[0]): Promise { + public async delete(key: string): Promise { this.storage.delete(key); if (this.hasLimit) { - this.keys = this.keys.filter(value => value !== key); + this.keysStorage.delete(key); } - return Promise.resolve(); + return this; } private checkSize(key: string): void { @@ -41,10 +46,10 @@ export class MemoryStorage implements Storage { } if (this.storage.size >= this.limit) { - this.delete(); + this.delete(this.keysStorage.keys().next().value); } - this.keys.push(key); + this.keysStorage.add(key); } } diff --git a/lib/cache/storages/Storage.ts b/lib/cache/storages/Storage.ts index 92f749e..a06fc8e 100644 --- a/lib/cache/storages/Storage.ts +++ b/lib/cache/storages/Storage.ts @@ -1,5 +1,6 @@ export interface Storage { - set(key: string, value: V): Promise; + set(key: string, value: V): Promise; get(key: string): Promise; - delete(key: string): Promise; + has(key: string): Promise; + delete(key: string): Promise; } diff --git a/lib/cache/factories/storeFactory.ts b/lib/cache/storages/factory.ts similarity index 71% rename from lib/cache/factories/storeFactory.ts rename to lib/cache/storages/factory.ts index 968aec5..56321ae 100644 --- a/lib/cache/factories/storeFactory.ts +++ b/lib/cache/storages/factory.ts @@ -1,12 +1,12 @@ import { CacheOptions } from '..'; -import { MemoryStorage } from '../storages/MemoryStorage'; -import { Storage } from '../storages/Storage'; +import { MemoryStorage } from './MemoryStorage'; +import { Storage } from './Storage'; const storeFactories: ReadonlyMap<'memory', (limit: number) => Storage> = new Map<'memory', (limit: number) => Storage>() .set('memory', limit => new MemoryStorage(limit)); -export function storeFactory(options: CacheOptions): Storage { +export function storageFactory(options: CacheOptions): Storage { const { size, storage } = options; const factory = storeFactories.get(storage); diff --git a/lib/utils/hash/hash.ts b/lib/utils/hash/index.ts similarity index 100% rename from lib/utils/hash/hash.ts rename to lib/utils/hash/index.ts diff --git a/test/cache.spec.ts b/test/cache.spec.ts index f6b80fe..7c5e972 100644 --- a/test/cache.spec.ts +++ b/test/cache.spec.ts @@ -98,19 +98,21 @@ describe('@cache', () => { it('should throw if expiration is not a valid value', () => { const options: CacheOptions = { expiration: 'abc' as any }; const expectedError = '@cache Expiration type is not supported: abc.'; - expect(() => new (factory(timeout, options))).to.throw(expectedError); + const instance = new (factory(timeout, options)); + expect(instance.method(42)).to.be.rejectedWith(expectedError); }); it('should throw if scope is not a valid value', () => { const options: CacheOptions = { scope: 'xyz' as any }; - const expectedError = '@cahce Scope type is not suported: xyz.'; + const expectedError = '@cache invalid scope option: xyz.'; expect(() => new (factory(timeout, options))).to.throw(expectedError); }); it('should throw if storage is not a valid value', () => { const options: CacheOptions = { storage: 'qwe' as any }; const expectedError = '@cache Storage type is not supported: qwe.'; - expect(() => new (factory(timeout, options))).to.throw(expectedError); + const instance = new (factory(timeout, options)); + expect(instance.method(42)).to.be.rejectedWith(expectedError); }); }); @@ -167,7 +169,7 @@ describe('@cache', () => { }); it('should not refresh if was call before expire', async () => { - const timeout = delayTime + 1; + const timeout = delayTime + 2; const instance = new (factory(timeout, options)); await instance.method(42); @@ -245,6 +247,18 @@ describe('@cache', () => { expect(time).to.be.approximately(0, timePrecision); }); + it('should not use cached value if is another class', async () => { + const firstConstructor = factory(timeout, options); + const secondConstructor = factory(timeout, options); + + const firstInstance = new firstConstructor(); + const secondInstance = new secondConstructor(); + + await firstInstance.method(42); + const time = await executionTime(() => secondInstance.method(42)); + expect(time).to.be.gte(delayTime); + }); + }); describe('instance', () => { From 7c0c11d50925bb4449c122294a6210075ec9e8f0 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 14 Nov 2019 10:52:47 +0200 Subject: [PATCH 5/9] cache replace factory functions to class rename CacheManager to CacheProvider remove keysStorageIn MemoryStorage (now is using keys from map) --- lib/cache/CacheOptions.ts | 12 +++---- lib/cache/cacheManager/ClassCacheManager.ts | 23 ------------ .../cacheManager/InstanceCacheManager.ts | 26 -------------- lib/cache/cacheManager/factory.ts | 27 -------------- .../CacheProvider.ts} | 2 +- lib/cache/cacheProvider/ClassCacheProvider.ts | 19 ++++++++++ .../cacheProvider/InstanceCacheProvider.ts | 22 ++++++++++++ lib/cache/cacheProvider/factory.ts | 35 +++++++++++++++++++ lib/cache/caches/factory.ts | 25 ++++++++----- lib/cache/expirations/factory.ts | 35 +++++++++++++------ lib/cache/index.ts | 30 +++++++++------- lib/cache/storages/MemoryStorage.ts | 28 +++------------ lib/cache/storages/factory.ts | 27 ++++++++------ test/{ => cache}/cache.spec.ts | 4 +-- 14 files changed, 163 insertions(+), 152 deletions(-) delete mode 100644 lib/cache/cacheManager/ClassCacheManager.ts delete mode 100644 lib/cache/cacheManager/InstanceCacheManager.ts delete mode 100644 lib/cache/cacheManager/factory.ts rename lib/cache/{cacheManager/CacheManager.ts => cacheProvider/CacheProvider.ts} (76%) create mode 100644 lib/cache/cacheProvider/ClassCacheProvider.ts create mode 100644 lib/cache/cacheProvider/InstanceCacheProvider.ts create mode 100644 lib/cache/cacheProvider/factory.ts rename test/{ => cache}/cache.spec.ts (99%) diff --git a/lib/cache/CacheOptions.ts b/lib/cache/CacheOptions.ts index ad3f96b..43a33dc 100644 --- a/lib/cache/CacheOptions.ts +++ b/lib/cache/CacheOptions.ts @@ -26,13 +26,9 @@ export type CacheOptions = { size?: number, }; -export const DEFAULT_EXPIRATION = 'absolute'; -export const DEFAULT_SCOPE = 'class'; -export const DEFAULT_STORAGE = 'memory'; -export const DEFAULT_SIZE = null; export const DEFAULT_OPTIONS: CacheOptions = { - expiration: DEFAULT_EXPIRATION, - scope: DEFAULT_SCOPE, - storage: DEFAULT_STORAGE, - size: DEFAULT_SIZE, + expiration: 'absolute', + scope: 'class', + storage: 'memory', + size: null, }; diff --git a/lib/cache/cacheManager/ClassCacheManager.ts b/lib/cache/cacheManager/ClassCacheManager.ts deleted file mode 100644 index 0a444e9..0000000 --- a/lib/cache/cacheManager/ClassCacheManager.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Cache } from '../caches/Cache'; -import { CacheManager } from './CacheManager'; -import { cacheFactory } from '../caches/factory'; -import { CacheOptions } from '../CacheOptions'; - -export class ClassCacheManager implements CacheManager { - - private cacheInstance: Cache = null; - - constructor( - private readonly timeout: number, - private readonly options: Readonly, - ) { } - - public get(): Cache { - if (!this.cacheInstance) { - this.cacheInstance = cacheFactory(this.timeout, this.options); - } - - return this.cacheInstance; - } - -} diff --git a/lib/cache/cacheManager/InstanceCacheManager.ts b/lib/cache/cacheManager/InstanceCacheManager.ts deleted file mode 100644 index fad18cf..0000000 --- a/lib/cache/cacheManager/InstanceCacheManager.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Cache } from '../caches/Cache'; -import { CacheManager } from './CacheManager'; -import { cacheFactory } from '../caches/factory'; -import { CacheOptions } from '../CacheOptions'; -import { ClassType } from '../../interfaces/class'; - -export class InstanceCacheManager implements CacheManager { - - private cacheByInstances = new WeakMap>(); - - constructor( - private readonly timeout: number, - private readonly options: Readonly, - ) { } - - public get(instance: ClassType): Cache { - const shouldCreateCache = !this.cacheByInstances.has(instance); - if (shouldCreateCache) { - const cache = cacheFactory(this.timeout, this.options); - this.cacheByInstances.set(instance, cache); - } - - return this.cacheByInstances.get(instance); - } - -} diff --git a/lib/cache/cacheManager/factory.ts b/lib/cache/cacheManager/factory.ts deleted file mode 100644 index 020096b..0000000 --- a/lib/cache/cacheManager/factory.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { CacheOptions } from '../CacheOptions'; -import { CacheManager } from './CacheManager'; -import { ClassCacheManager } from './ClassCacheManager'; -import { InstanceCacheManager } from './InstanceCacheManager'; - -const cacheManagerFactories: ReadonlyMap< - 'class' | 'instance', - (timeout: number, options: CacheOptions) => CacheManager -> = new Map<'class' | 'instance', (timeout: number, options: CacheOptions) => CacheManager>() - .set('class', (timeout, options) => new ClassCacheManager(timeout, options)) - .set('instance', (timeout, options) => new InstanceCacheManager(timeout, options)); - -export function cacheManagerFactory( - timeout: number, - options: CacheOptions, -): CacheManager { - - const { scope } = options; - - const factory = cacheManagerFactories.get(scope); - - if (!factory) { - throw new Error(`@cache invalid scope option: ${scope}.`); - } - - return factory(timeout, options); -} diff --git a/lib/cache/cacheManager/CacheManager.ts b/lib/cache/cacheProvider/CacheProvider.ts similarity index 76% rename from lib/cache/cacheManager/CacheManager.ts rename to lib/cache/cacheProvider/CacheProvider.ts index 64f2d7e..dbd5c6b 100644 --- a/lib/cache/cacheManager/CacheManager.ts +++ b/lib/cache/cacheProvider/CacheProvider.ts @@ -1,6 +1,6 @@ import { ClassType } from '../../interfaces/class'; import { Cache } from '../caches/Cache'; -export interface CacheManager { +export interface CacheProvider { get(instance: ClassType): Cache; } diff --git a/lib/cache/cacheProvider/ClassCacheProvider.ts b/lib/cache/cacheProvider/ClassCacheProvider.ts new file mode 100644 index 0000000..0d36066 --- /dev/null +++ b/lib/cache/cacheProvider/ClassCacheProvider.ts @@ -0,0 +1,19 @@ +import { Cache } from '../caches/Cache'; +import { CacheFactory } from '../caches/factory'; +import { CacheProvider } from './CacheProvider'; + +export class ClassCacheProvider implements CacheProvider { + + private cache: Cache = null; + + constructor(private readonly cacheFactory: CacheFactory) { } + + public get(): Cache { + if (!this.cache) { + this.cache = this.cacheFactory.create(); + } + + return this.cache; + } + +} diff --git a/lib/cache/cacheProvider/InstanceCacheProvider.ts b/lib/cache/cacheProvider/InstanceCacheProvider.ts new file mode 100644 index 0000000..711e0cd --- /dev/null +++ b/lib/cache/cacheProvider/InstanceCacheProvider.ts @@ -0,0 +1,22 @@ +import { ClassType } from '../../interfaces/class'; +import { Cache } from '../caches/Cache'; +import { CacheFactory } from '../caches/factory'; +import { CacheProvider } from './CacheProvider'; + +export class InstanceCacheProvider implements CacheProvider { + + private instanceCaches = new WeakMap>(); + + constructor(private readonly cacheFactory: CacheFactory) { } + + public get(instance: ClassType): Cache { + const hasCache = !this.instanceCaches.has(instance); + if (hasCache) { + const cache = this.cacheFactory.create(); + this.instanceCaches.set(instance, cache); + } + + return this.instanceCaches.get(instance); + } + +} diff --git a/lib/cache/cacheProvider/factory.ts b/lib/cache/cacheProvider/factory.ts new file mode 100644 index 0000000..e2f1e16 --- /dev/null +++ b/lib/cache/cacheProvider/factory.ts @@ -0,0 +1,35 @@ +import { Factory } from '../../interfaces/factory'; +import { CacheFactory } from '../caches/factory'; +import { CacheProvider } from './CacheProvider'; +import { ClassCacheProvider } from './ClassCacheProvider'; +import { InstanceCacheProvider } from './InstanceCacheProvider'; + +export class CacheProviderFactory implements Factory { + + constructor( + private readonly scope: 'class' | 'instance', + private readonly cacheFactory: CacheFactory, + ) { } + + public create() { + switch (this.scope) { + case 'class': + return this.classCacheProvider(); + + case 'instance': + return this.instanceCacheProvider(); + + default: + throw new Error(`@cache invalid scope option: ${this.scope}.`); + } + } + + private classCacheProvider(): ClassCacheProvider { + return new ClassCacheProvider(this.cacheFactory); + } + + private instanceCacheProvider(): InstanceCacheProvider { + return new InstanceCacheProvider(this.cacheFactory); + } + +} diff --git a/lib/cache/caches/factory.ts b/lib/cache/caches/factory.ts index c1a3cbf..856678f 100644 --- a/lib/cache/caches/factory.ts +++ b/lib/cache/caches/factory.ts @@ -1,13 +1,22 @@ +import { Factory } from '../../interfaces/factory'; import { HashService } from '../../utils/hash'; -import { CacheOptions } from '../CacheOptions'; -import { expirationFactory } from '../expirations/factory'; -import { storageFactory } from '../storages/factory'; +import { ExpirationFactory } from '../expirations/factory'; +import { StorageFactory } from '../storages/factory'; import { Cache } from './Cache'; -export function cacheFactory(timeout: number, options: CacheOptions): Cache { - const storage = storageFactory(options); - const expiration = expirationFactory(timeout, options); - const hash = new HashService(); +export class CacheFactory implements Factory> { + + constructor( + private readonly hash: HashService, + private readonly expirationFactory: ExpirationFactory, + private readonly storageFactory: StorageFactory, + ) { } + + public create(): Cache { + const storage = this.storageFactory.create(); + const expiration = this.expirationFactory.create(); + + return new Cache(storage, expiration, this.hash); + } - return new Cache(storage, expiration, hash); } diff --git a/lib/cache/expirations/factory.ts b/lib/cache/expirations/factory.ts index 3775b2e..f6ec58a 100644 --- a/lib/cache/expirations/factory.ts +++ b/lib/cache/expirations/factory.ts @@ -1,21 +1,34 @@ -import { CacheOptions } from '..'; +import { Factory } from '../../interfaces/factory'; import { AbsoluteExpiration } from './AbsoluteExpiration'; import { Expiration } from './Expiration'; import { SlidingExpiration } from './SlidingExpiration'; -const expirationFactories: ReadonlyMap<'absolute' | 'sliding', (timeout: number) => Expiration> = - new Map<'absolute' | 'sliding', (timeout: number) => Expiration>() - .set('absolute', timeout => new AbsoluteExpiration(timeout)) - .set('sliding', timeout => new SlidingExpiration(timeout)); +export class ExpirationFactory implements Factory { -export function expirationFactory(timeout: number, options: CacheOptions): Expiration { - const { expiration } = options; + constructor( + private readonly timeout: number, + private readonly expiration: 'absolute' | 'sliding', + ) { } - const factory = expirationFactories.get(expiration); + public create(): Expiration { + switch (this.expiration) { + case 'absolute': + return this.absoluteExpirtation(); - if (!factory) { - throw new Error(`@cache Expiration type is not supported: ${expiration}.`); + case 'sliding': + return this.slidingExpiration(); + + default: + throw new Error(`@cache Expiration type is not supported: ${this.expiration}.`); + } + } + + private absoluteExpirtation(): AbsoluteExpiration { + return new AbsoluteExpiration(this.timeout); + } + + private slidingExpiration(): SlidingExpiration { + return new SlidingExpiration(this.timeout); } - return factory(timeout); } diff --git a/lib/cache/index.ts b/lib/cache/index.ts index f38118f..4c37cdd 100644 --- a/lib/cache/index.ts +++ b/lib/cache/index.ts @@ -1,6 +1,9 @@ import { CacheOptions, DEFAULT_OPTIONS } from './CacheOptions'; -import { cacheFactory } from './caches/factory'; -import { cacheManagerFactory } from './cacheManager/factory'; +import { ExpirationFactory } from './expirations/factory'; +import { StorageFactory } from './storages/factory'; +import { CacheFactory } from './caches/factory'; +import { HashService } from '../utils/hash'; +import { CacheProviderFactory } from './cacheProvider/factory'; export { CacheOptions }; @@ -20,26 +23,27 @@ export function cache( ): MethodDecorator { const { timeout, options } = parseParameters(timeoutOrOptions, optionsOrVoid); - const cacheManager = cacheManagerFactory(timeout, options); + + const hashService = new HashService(); + const expirationFactory = new ExpirationFactory(timeout, options.expiration); + const storageFactory = new StorageFactory(options.size, options.storage); + const cacheFactory = new CacheFactory(hashService, expirationFactory, storageFactory); + const cacheProvider = new CacheProviderFactory(options.scope, cacheFactory).create(); return function (_: any, __: any, descriptor: PropertyDescriptor) { const method = descriptor.value; descriptor.value = async function (...args: any[]) { - const cacheService = cacheManager.get(this); - const wasCached = await cacheService.has(args); + const cacheService = cacheProvider.get(this); + const isCached = await cacheService.has(args); - if (wasCached) { + if (isCached) { return cacheService.get(args); } - try { - const value = await method(...args); - cacheService.set(args, value); - return value; - } catch (error) { - return Promise.reject(error); - } + const value = await method(...args); + cacheService.set(args, value); + return value; }; return descriptor; diff --git a/lib/cache/storages/MemoryStorage.ts b/lib/cache/storages/MemoryStorage.ts index a007baf..ecf9cc0 100644 --- a/lib/cache/storages/MemoryStorage.ts +++ b/lib/cache/storages/MemoryStorage.ts @@ -4,18 +4,10 @@ export class MemoryStorage implements Storage { private readonly storage = new Map(); - private readonly hasLimit: boolean; - private readonly keysStorage: Set; - - constructor(private readonly limit?: number) { - this.hasLimit = typeof this.limit === 'number'; - if (this.hasLimit) { - this.keysStorage = new Set(); - } - } + constructor(private readonly limit?: number) { } public async set(key: string, value: V): Promise { - this.checkSize(key); + this.checkSize(); this.storage.set(key, value); @@ -33,23 +25,13 @@ export class MemoryStorage implements Storage { public async delete(key: string): Promise { this.storage.delete(key); - if (this.hasLimit) { - this.keysStorage.delete(key); - } - return this; } - private checkSize(key: string): void { - if (!this.hasLimit) { - return; - } - - if (this.storage.size >= this.limit) { - this.delete(this.keysStorage.keys().next().value); + private checkSize(): void { + if (typeof this.limit === 'number' && this.storage.size >= this.limit) { + this.delete(this.storage.keys().next().value); } - - this.keysStorage.add(key); } } diff --git a/lib/cache/storages/factory.ts b/lib/cache/storages/factory.ts index 56321ae..e5c8bf7 100644 --- a/lib/cache/storages/factory.ts +++ b/lib/cache/storages/factory.ts @@ -1,19 +1,26 @@ -import { CacheOptions } from '..'; +import { Factory } from '../../interfaces/factory'; import { MemoryStorage } from './MemoryStorage'; import { Storage } from './Storage'; -const storeFactories: ReadonlyMap<'memory', (limit: number) => Storage> = - new Map<'memory', (limit: number) => Storage>() - .set('memory', limit => new MemoryStorage(limit)); +export class StorageFactory implements Factory { -export function storageFactory(options: CacheOptions): Storage { - const { size, storage } = options; + constructor( + private readonly limit: number, + private readonly storage: 'memory', + ) { } - const factory = storeFactories.get(storage); + public create(): Storage { + switch (this.storage) { + case 'memory': + return this.memoryStorage(); - if (!factory) { - throw new Error(`@cache Storage type is not supported: ${storage}.`); + default: + throw new Error(`@cache Storage type is not supported: ${this.storage}.`); + } + } + + private memoryStorage(): MemoryStorage { + return new MemoryStorage(this.limit); } - return factory(size); } diff --git a/test/cache.spec.ts b/test/cache/cache.spec.ts similarity index 99% rename from test/cache.spec.ts rename to test/cache/cache.spec.ts index 7c5e972..e3d2966 100644 --- a/test/cache.spec.ts +++ b/test/cache/cache.spec.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; -import { cache, CacheOptions } from '../lib'; -import { delay, executionTime } from './utils'; +import { cache, CacheOptions } from '../../lib'; +import { delay, executionTime } from '../utils'; describe('@cache', () => { From 20437a4101db06f3fe3361de802cb29c754fd37d Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 15 Nov 2019 17:57:08 +0200 Subject: [PATCH 6/9] cache improve code in factories and in SlidingExpiration add unit tests --- lib/cache/cacheProvider/factory.ts | 12 +- lib/cache/expirations/SlidingExpiration.ts | 6 +- lib/cache/expirations/factory.ts | 12 +- lib/cache/storages/factory.ts | 6 +- .../cacheProvider/ClassCacheProvider.spec.ts | 46 ++++++ .../InstanceCacheProvider.spec.ts | 51 ++++++ test/cache/cacheProvider/factory.spec.ts | 40 +++++ test/cache/caches/Cache.spec.ts | 148 ++++++++++++++++++ test/cache/caches/factory.spec.ts | 48 ++++++ .../expirations/AbsoluteExpiration.spec.ts | 93 +++++++++++ .../expirations/SlidingExpiration.spec.ts | 102 ++++++++++++ test/cache/expirations/factory.spec.ts | 36 +++++ test/cache/storages/MemoryStorage.spec.ts | 83 ++++++++++ test/cache/storages/factory.spec.ts | 30 ++++ test/indext.ts | 4 + 15 files changed, 691 insertions(+), 26 deletions(-) create mode 100644 test/cache/cacheProvider/ClassCacheProvider.spec.ts create mode 100644 test/cache/cacheProvider/InstanceCacheProvider.spec.ts create mode 100644 test/cache/cacheProvider/factory.spec.ts create mode 100644 test/cache/caches/Cache.spec.ts create mode 100644 test/cache/caches/factory.spec.ts create mode 100644 test/cache/expirations/AbsoluteExpiration.spec.ts create mode 100644 test/cache/expirations/SlidingExpiration.spec.ts create mode 100644 test/cache/expirations/factory.spec.ts create mode 100644 test/cache/storages/MemoryStorage.spec.ts create mode 100644 test/cache/storages/factory.spec.ts diff --git a/lib/cache/cacheProvider/factory.ts b/lib/cache/cacheProvider/factory.ts index e2f1e16..3e1097e 100644 --- a/lib/cache/cacheProvider/factory.ts +++ b/lib/cache/cacheProvider/factory.ts @@ -14,22 +14,14 @@ export class CacheProviderFactory implements Factory { public create() { switch (this.scope) { case 'class': - return this.classCacheProvider(); + return new ClassCacheProvider(this.cacheFactory); case 'instance': - return this.instanceCacheProvider(); + return new InstanceCacheProvider(this.cacheFactory); default: throw new Error(`@cache invalid scope option: ${this.scope}.`); } } - private classCacheProvider(): ClassCacheProvider { - return new ClassCacheProvider(this.cacheFactory); - } - - private instanceCacheProvider(): InstanceCacheProvider { - return new InstanceCacheProvider(this.cacheFactory); - } - } diff --git a/lib/cache/expirations/SlidingExpiration.ts b/lib/cache/expirations/SlidingExpiration.ts index 82b92fe..9f944d6 100644 --- a/lib/cache/expirations/SlidingExpiration.ts +++ b/lib/cache/expirations/SlidingExpiration.ts @@ -9,7 +9,11 @@ export class SlidingExpiration implements Expiration { ) { } public add(key: string, clearCallback: (key: string) => unknown): void { - this.expirations.has(key) ? this.update(key, clearCallback) : this.addKey(key, clearCallback); + if (this.expirations.has(key)) { + this.update(key, clearCallback); + } else { + this.addKey(key, clearCallback); + } } private addKey(key: string, clearCallback: (key: string) => unknown): void { diff --git a/lib/cache/expirations/factory.ts b/lib/cache/expirations/factory.ts index f6ec58a..9f5d9f2 100644 --- a/lib/cache/expirations/factory.ts +++ b/lib/cache/expirations/factory.ts @@ -13,22 +13,14 @@ export class ExpirationFactory implements Factory { public create(): Expiration { switch (this.expiration) { case 'absolute': - return this.absoluteExpirtation(); + return new AbsoluteExpiration(this.timeout); case 'sliding': - return this.slidingExpiration(); + return new SlidingExpiration(this.timeout); default: throw new Error(`@cache Expiration type is not supported: ${this.expiration}.`); } } - private absoluteExpirtation(): AbsoluteExpiration { - return new AbsoluteExpiration(this.timeout); - } - - private slidingExpiration(): SlidingExpiration { - return new SlidingExpiration(this.timeout); - } - } diff --git a/lib/cache/storages/factory.ts b/lib/cache/storages/factory.ts index e5c8bf7..b51a0c4 100644 --- a/lib/cache/storages/factory.ts +++ b/lib/cache/storages/factory.ts @@ -12,15 +12,11 @@ export class StorageFactory implements Factory { public create(): Storage { switch (this.storage) { case 'memory': - return this.memoryStorage(); + return new MemoryStorage(this.limit); default: throw new Error(`@cache Storage type is not supported: ${this.storage}.`); } } - private memoryStorage(): MemoryStorage { - return new MemoryStorage(this.limit); - } - } diff --git a/test/cache/cacheProvider/ClassCacheProvider.spec.ts b/test/cache/cacheProvider/ClassCacheProvider.spec.ts new file mode 100644 index 0000000..1976ff5 --- /dev/null +++ b/test/cache/cacheProvider/ClassCacheProvider.spec.ts @@ -0,0 +1,46 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { ClassCacheProvider } from '../../../lib/cache/cacheProvider/ClassCacheProvider'; +import { CacheFactory } from '../../../lib/cache/caches/factory'; + +describe('@cache ClassCacheProvider', () => { + + let cacheFactoryStub: sinon.SinonStubbedInstance; + let service: ClassCacheProvider; + + beforeEach(() => { + cacheFactoryStub = sinon.createStubInstance(CacheFactory); + + service = new ClassCacheProvider(cacheFactoryStub as any); + }); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(ClassCacheProvider)); + + it('should init cache property to null', () => expect(service['cache']).to.be.null); + + }); + + describe('get', () => { + + it('should call CacheFactory.create to create an instance at first call', () => { + const cacheInstance = {} as any; + cacheFactoryStub.create.returns(cacheInstance); + + expect(service.get()).to.be.equals(cacheInstance); + expect(cacheFactoryStub.create.calledOnce).to.be.true; + expect(service['cache']).to.be.equals(cacheInstance); + }); + + it('should return existent instance of cache if is not first call', () => { + const response = service['cache'] = {} as any; + + expect(cacheFactoryStub.create.called).to.be.false; + expect(service.get()).to.be.equals(response); + }); + + }); + +}); diff --git a/test/cache/cacheProvider/InstanceCacheProvider.spec.ts b/test/cache/cacheProvider/InstanceCacheProvider.spec.ts new file mode 100644 index 0000000..f415441 --- /dev/null +++ b/test/cache/cacheProvider/InstanceCacheProvider.spec.ts @@ -0,0 +1,51 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { InstanceCacheProvider } from '../../../lib/cache/cacheProvider/InstanceCacheProvider'; +import { CacheFactory } from '../../../lib/cache/caches/factory'; + +describe('@cache InstanceCacheProvider', () => { + + let cacheFactoryStub: sinon.SinonStubbedInstance; + let service: InstanceCacheProvider; + + beforeEach(() => { + cacheFactoryStub = sinon.createStubInstance(CacheFactory); + + service = new InstanceCacheProvider(cacheFactoryStub as any); + }); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(InstanceCacheProvider)); + + it('should init instancesCaches property', () => { + expect(service['instanceCaches']).to.be.instanceOf(WeakMap); + }); + + }); + + describe('get', () => { + + it('should create new cache instance if was called first time with this instance', () => { + const result = {} as any; + const instance = {} as any; + cacheFactoryStub.create.returns(result); + + expect(service.get(instance)).to.be.equals(result); + expect(service['instanceCaches'].get(instance)).to.be.equals(result); + expect(cacheFactoryStub.create.calledOnce).to.be.true; + }); + + it('should return already created cache for current isntance', () => { + const result = {} as any; + const instance = {} as any; + service['instanceCaches'].set(instance, result); + + expect(service.get(instance)).to.be.equals(result); + expect(cacheFactoryStub.create.called).to.be.false; + }); + + }); + +}); diff --git a/test/cache/cacheProvider/factory.spec.ts b/test/cache/cacheProvider/factory.spec.ts new file mode 100644 index 0000000..28887f9 --- /dev/null +++ b/test/cache/cacheProvider/factory.spec.ts @@ -0,0 +1,40 @@ +import { expect } from 'chai'; + +import { ClassCacheProvider } from '../../../lib/cache/cacheProvider/ClassCacheProvider'; +import { CacheProviderFactory } from '../../../lib/cache/cacheProvider/factory'; +import { InstanceCacheProvider } from '../../../lib/cache/cacheProvider/InstanceCacheProvider'; + +describe('@cache CacheProviderFactory', () => { + + describe('constructor', () => { + + it('should create', () => { + expect(new CacheProviderFactory('class', undefined)).to.be.instanceOf(CacheProviderFactory); + }); + + }); + + describe('create', () => { + + it('should create instanceof ClassCacheProvider if scope is "class"', () => { + const instance = new CacheProviderFactory('class', undefined); + expect(instance.create()).to.be.instanceOf(ClassCacheProvider); + }); + + it('should create instanceof InstanceCacheProvider if scope is "instance"', () => { + const instance = new CacheProviderFactory('instance', undefined); + expect(instance.create()).to.be.instanceOf(InstanceCacheProvider); + }); + + it('should throw error if scope options is not a valid one', () => { + const scope = '123' as any; + const message = `@cache invalid scope option: ${scope}.`; + + const instance = new CacheProviderFactory(scope, undefined); + + expect(() => instance.create()).to.throw(message); + }); + + }); + +}); diff --git a/test/cache/caches/Cache.spec.ts b/test/cache/caches/Cache.spec.ts new file mode 100644 index 0000000..e1cb28b --- /dev/null +++ b/test/cache/caches/Cache.spec.ts @@ -0,0 +1,148 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { Cache } from '../../../lib/cache/caches/Cache'; +import { AbsoluteExpiration } from '../../../lib/cache/expirations/AbsoluteExpiration'; +import { Expiration } from '../../../lib/cache/expirations/Expiration'; +import { MemoryStorage } from '../../../lib/cache/storages/MemoryStorage'; +import { Storage } from '../../../lib/cache/storages/Storage'; +import { HashService } from '../../../lib/utils/hash'; + +describe('@cache Cache', () => { + + let hashStub: sinon.SinonStubbedInstance; + let storageStub: sinon.SinonStubbedInstance; + let expirationStub: sinon.SinonStubbedInstance; + let service: Cache; + + beforeEach(() => { + hashStub = sinon.createStubInstance(HashService); + storageStub = sinon.createStubInstance(MemoryStorage); + expirationStub = sinon.createStubInstance(AbsoluteExpiration); + + service = new Cache(storageStub, expirationStub, hashStub); + }); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(Cache)); + + }); + + describe('set', () => { + + it('should call hash.calculate to obtain arguments hash', async () => { + await service.set(['key'], 'value'); + + expect(hashStub.calculate.calledOnce).to.be.true; + }); + + it('should call storage.set to store given hashed key and data', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.set(['key'], 'value'); + + expect(storageStub.set.calledOnce).to.be.true; + expect(storageStub.set.calledWithExactly(key, 'value')).to.be.true; + }); + + it('should call expiration.add to make cache expirable', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.set(['key'], 'value'); + + expect(expirationStub.add.calledOnce).to.be.true; + expect(expirationStub.add.calledWith(key, sinon.match.func)).to.be.true; + }); + + describe('function passed to expiration', () => { + + it('should call storage.delete', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.set(['key'], 'value'); + + const callback = expirationStub.add.firstCall.args[1]; + + await callback(key); + + expect(storageStub.delete.calledOnce).to.be.true; + expect(storageStub.delete.calledWith(key)).to.be.true; + }); + + }); + + }); + + describe('has', () => { + + it('should call hash.calculate to obtain arguments hash', async () => { + await service.has(['key']); + + expect(hashStub.calculate.calledOnce).to.be.true; + }); + + it('should call storage has to check if key exists', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.has(['key']); + + expect(storageStub.has.calledOnce).to.be.true; + expect(storageStub.has.calledWith(key)).to.be.true; + }); + + }); + + describe('get', () => { + + it('should call hash.calculate to obtain arguments hash', async () => { + await service.get(['key']); + + expect(hashStub.calculate.calledOnce).to.be.true; + }); + + it('should call expiration.add to update cache expiration', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.get(['key']); + + expect(expirationStub.add.calledOnce).to.be.true; + expect(expirationStub.add.calledWith(key, sinon.match.func)).to.be.true; + }); + + it('should call storage.get to obtain cached value', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.get(['key']); + + expect(storageStub.get.calledOnce).to.be.true; + expect(storageStub.get.calledWith(key)).to.be.true; + }); + + describe('function passed to expiration', () => { + + it('should call storage.delete', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.get(['key']); + + const callback = expirationStub.add.firstCall.args[1]; + + await callback(key); + + expect(storageStub.delete.calledOnce).to.be.true; + expect(storageStub.delete.calledWith(key)).to.be.true; + }); + + }); + + }); + +}); diff --git a/test/cache/caches/factory.spec.ts b/test/cache/caches/factory.spec.ts new file mode 100644 index 0000000..034f75f --- /dev/null +++ b/test/cache/caches/factory.spec.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { Cache } from '../../../lib/cache/caches/Cache'; +import { CacheFactory } from '../../../lib/cache/caches/factory'; +import { ExpirationFactory } from '../../../lib/cache/expirations/factory'; +import { StorageFactory } from '../../../lib/cache/storages/factory'; + +describe('@cache CacheFactory', () => { + + let expirationFactoryStub: sinon.SinonStubbedInstance; + let storageFactoryStub: sinon.SinonStubbedInstance; + let service: CacheFactory; + + beforeEach(() => { + expirationFactoryStub = sinon.createStubInstance(ExpirationFactory); + storageFactoryStub = sinon.createStubInstance(StorageFactory); + + service = new CacheFactory(undefined, expirationFactoryStub as any, storageFactoryStub as any); + }); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(CacheFactory)); + + }); + + describe('create', () => { + + it('should call expirationFactory.create to obtain expiration', () => { + service.create(); + + expect(expirationFactoryStub.create.calledOnce).to.be.true; + }); + + it('should call storageFactory.create to obtain storage', () => { + service.create(); + + expect(storageFactoryStub.create.calledOnce).to.be.true; + }); + + it('should return instance of Cahce', () => { + expect(service.create()).to.be.instanceOf(Cache); + }); + + }); + +}); diff --git a/test/cache/expirations/AbsoluteExpiration.spec.ts b/test/cache/expirations/AbsoluteExpiration.spec.ts new file mode 100644 index 0000000..8204577 --- /dev/null +++ b/test/cache/expirations/AbsoluteExpiration.spec.ts @@ -0,0 +1,93 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { AbsoluteExpiration } from '../../../lib/cache/expirations/AbsoluteExpiration'; +import { delay } from '../../utils'; + +describe('@cache AbsoluteExpiration', () => { + + const timeout = 10; + let service: AbsoluteExpiration; + + beforeEach(() => service = new AbsoluteExpiration(timeout)); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(AbsoluteExpiration)); + + it('should init expirations', () => { + expect(service['expirations']).to.be.instanceOf(Set); + }); + + }); + + describe('add', () => { + + describe('new key', () => { + + it('should add key to expirations', () => { + const size = service['expirations'].size; + + service.add('key', () => { }); + + expect(service['expirations'].size).to.be.equals(size + 1); + expect(service['expirations'].has('key')).to.be.true; + }); + + it('should remove key from expirations after timeout ms', async () => { + const size = service['expirations'].size; + + service.add('key', () => { }); + + await delay(timeout); + + expect(service['expirations'].size).to.be.equals(size); + expect(service['expirations'].has('key')).to.be.false; + }); + + it('should call clearCallback after timeout ms', async () => { + const spy = sinon.spy(); + + service.add('key', spy); + + await delay(timeout); + + expect(spy.calledOnce).to.be.true; + }); + + }); + + describe('existing key', () => { + + it('should not update expiration', async () => { + const spy = sinon.spy(); + service['expirations'].add('key'); + + service.add('key', spy); + + await delay(timeout); + + expect(spy.called).to.be.false; + }); + + it('should call initial callback', async () => { + const firstSpy = sinon.spy(); + const secondSpy = sinon.spy(); + + service.add('key', firstSpy); + + await delay(timeout / 2); + + service.add('key', secondSpy); + + await delay(timeout / 2); + + expect(firstSpy.calledOnce).to.be.true; + expect(secondSpy.called).to.be.false; + }); + + }); + + }); + +}); diff --git a/test/cache/expirations/SlidingExpiration.spec.ts b/test/cache/expirations/SlidingExpiration.spec.ts new file mode 100644 index 0000000..395ce25 --- /dev/null +++ b/test/cache/expirations/SlidingExpiration.spec.ts @@ -0,0 +1,102 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { SlidingExpiration } from '../../../lib/cache/expirations/SlidingExpiration'; +import { delay } from '../../utils'; + +describe('@cache SlidingExpiration', () => { + + const timeout = 10; + let service: SlidingExpiration; + + beforeEach(() => service = new SlidingExpiration(timeout)); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(SlidingExpiration)); + + it('should init expirations storage', () => { + expect(service['expirations']).to.be.instanceOf(Map); + }); + + }); + + describe('add', () => { + + describe('new key', () => { + + it('should add key in expirations', () => { + const size = service['expirations'].size; + + service.add('key', () => { }); + + expect(service['expirations'].size).to.be.equals(size + 1); + expect(service['expirations'].has('key')).to.be.true; + }); + + it('should remove key from expirations after timeout ms', async () => { + const size = service['expirations'].size; + + service.add('key', () => { }); + await delay(timeout); + + expect(service['expirations'].size).to.be.equals(size); + expect(service['expirations'].has('key')).to.be.false; + }); + + it('should call clearCallback after timeout ms', async () => { + const spy = sinon.spy(); + + service.add('key', spy); + await delay(timeout); + + expect(spy.calledOnce).to.be.true; + }); + + }); + + describe('existing key', () => { + + beforeEach(() => service['expirations'].set('key', setTimeout(() => { }, timeout) as any)); + + it('should update timer for key in expirations', () => { + const timer = service['expirations'].get('key'); + + service.add('key', () => { }); + + expect(service['expirations'].get('key')).not.to.be.equals(timer); + }); + + it('should remove existing expiration', async () => { + const expirationSpy = sinon.spy(); + service['expirations'].set('key', setTimeout(() => expirationSpy(), timeout) as any); + + service.add('key', () => { }); + + await delay(timeout); + + expect(expirationSpy.called).to.be.false; + }); + + it('should clear key in expirations after timeout ms', async () => { + service.add('key', () => { }); + + await delay(timeout); + + expect(service['expirations'].get('key')).to.be.undefined; + }); + + it('should call clearCallback after timeout ms', async () => { + const spy = sinon.spy(); + + service.add('key', spy); + await delay(timeout); + + expect(spy.calledOnce).to.be.true; + }); + + }); + + }); + +}); diff --git a/test/cache/expirations/factory.spec.ts b/test/cache/expirations/factory.spec.ts new file mode 100644 index 0000000..f34cb1e --- /dev/null +++ b/test/cache/expirations/factory.spec.ts @@ -0,0 +1,36 @@ +import { expect } from 'chai'; + +import { AbsoluteExpiration } from '../../../lib/cache/expirations/AbsoluteExpiration'; +import { ExpirationFactory } from '../../../lib/cache/expirations/factory'; +import { SlidingExpiration } from '../../../lib/cache/expirations/SlidingExpiration'; + +describe('@cache ExpirationFactory', () => { + + describe('constructor', () => { + + it('should create', () => { + expect(new ExpirationFactory(3, 'absolute')).to.be.instanceOf(ExpirationFactory); + }); + + }); + + describe('create', () => { + + it('should create instance of AbsoluteExpiration if expiration is "absolute"', () => { + expect(new ExpirationFactory(3, 'absolute').create()).to.be.instanceOf(AbsoluteExpiration); + }); + + it('should create instance of SlidingExpiration if expiration is "sliding"', () => { + expect(new ExpirationFactory(3, 'sliding').create()).to.be.instanceOf(SlidingExpiration); + }); + + it('should throw error if expiration parameter is not valid', () => { + const expiration = '123' as any; + const message = `@cache Expiration type is not supported: ${expiration}.`; + + expect(() => new ExpirationFactory(3, expiration).create()).to.throw(message); + }); + + }); + +}); diff --git a/test/cache/storages/MemoryStorage.spec.ts b/test/cache/storages/MemoryStorage.spec.ts new file mode 100644 index 0000000..1a1ab37 --- /dev/null +++ b/test/cache/storages/MemoryStorage.spec.ts @@ -0,0 +1,83 @@ +import { expect } from 'chai'; + +import { MemoryStorage } from '../../../lib/cache/storages/MemoryStorage'; + +describe('@cache MemoryStorage', () => { + + let service: MemoryStorage; + + beforeEach(() => service = new MemoryStorage()); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(MemoryStorage)); + + it('should init storage', () => expect(service['storage']).to.be.instanceOf(Map)); + + }); + + describe('set', () => { + + it('should remove oldest key if limit is reached', () => { + const service = new MemoryStorage(2); + service['storage'].set('a', 1).set('b', 2); + + service.set('c', 3); + + expect(service['storage'].size).to.be.equals(2); + expect(Array.from(service['storage'].keys())).to.be.deep.equals(['b', 'c']); + }); + + it('should set key and value to storage', () => { + const key = 'key'; + const value = 42; + service.set(key, value); + + expect(service['storage'].get(key)).to.be.equals(value); + }); + + it('should return self instance', async () => { + expect(await service.set('key', 'value')).to.be.equals(service); + }); + + }); + + describe('get', () => { + + it('should return value from storage', async () => { + service['storage'].set('key', 'value'); + expect(await service.get('key')).to.be.equals(service['storage'].get('key')); + }); + + }); + + describe('has', () => { + + it('should return true if key is in storage', async () => { + service['storage'].set('key', 'value'); + expect(await service.has('key')).to.be.true; + }); + + it('should retunr false if key is not in storage', async () => { + expect(await service.has('key')).to.be.false; + }); + + }); + + describe('delete', () => { + + it('should delete key from storage', async () => { + service['storage'].set('key', '123'); + + await service.delete('key'); + + expect(service['storage'].has('key')).to.be.false; + }); + + it('should return self instance', async () => { + expect(await service.delete('key')).to.be.equals(service); + }); + + }); + +}); diff --git a/test/cache/storages/factory.spec.ts b/test/cache/storages/factory.spec.ts new file mode 100644 index 0000000..cfa44fa --- /dev/null +++ b/test/cache/storages/factory.spec.ts @@ -0,0 +1,30 @@ +import { expect } from 'chai'; + +import { StorageFactory } from '../../../lib/cache/storages/factory'; +import { MemoryStorage } from '../../../lib/cache/storages/MemoryStorage'; + +describe('@cache StorageFactory', () => { + + describe('constructor', () => { + + it('should create', () => { + expect(new StorageFactory(3, 'memory')).to.be.instanceOf(StorageFactory); + }); + + }); + + describe('create', () => { + + it('should return instance of StorageFactory if storage is "memory"', () => { + expect(new StorageFactory(3, 'memory').create()).to.be.instanceOf(MemoryStorage); + }); + + it('should throw error if storate is not an valid storage', () => { + const storage = 'memory3'; + const message = `@cache Storage type is not supported: ${storage}.`; + expect(() => new StorageFactory(3, 'memory3' as any).create()).to.throw(message); + }); + + }); + +}); diff --git a/test/indext.ts b/test/indext.ts index 6dfdc7f..08b3dac 100644 --- a/test/indext.ts +++ b/test/indext.ts @@ -1,4 +1,8 @@ import * as chai from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; +import * as sinonChai from 'sinon-chai'; +import * as chaiSpies from 'chai-spies'; chai.use(chaiAsPromised); +chai.use(sinonChai); +chai.use(chaiSpies); From 34a533c8ce57c1ee6293bd79fb042a4a5ba50e33 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 18 Nov 2019 15:13:14 +0200 Subject: [PATCH 7/9] cache improve unit tests --- .../cacheProvider/InstanceCacheProvider.ts | 4 +- lib/cache/index.ts | 17 +++-- test/cache/cache.spec.ts | 6 +- .../cacheProvider/ClassCacheProvider.spec.ts | 22 ++++-- .../InstanceCacheProvider.spec.ts | 6 +- test/cache/caches/Cache.spec.ts | 69 ++++++++++++++++++- .../expirations/AbsoluteExpiration.spec.ts | 25 ------- .../expirations/SlidingExpiration.spec.ts | 40 +---------- test/cache/storages/MemoryStorage.spec.ts | 11 ++- test/uitls/hash.spec.ts | 57 +++++++++++++++ 10 files changed, 165 insertions(+), 92 deletions(-) create mode 100644 test/uitls/hash.spec.ts diff --git a/lib/cache/cacheProvider/InstanceCacheProvider.ts b/lib/cache/cacheProvider/InstanceCacheProvider.ts index 711e0cd..6333bb0 100644 --- a/lib/cache/cacheProvider/InstanceCacheProvider.ts +++ b/lib/cache/cacheProvider/InstanceCacheProvider.ts @@ -10,8 +10,8 @@ export class InstanceCacheProvider implements CacheProvider { constructor(private readonly cacheFactory: CacheFactory) { } public get(instance: ClassType): Cache { - const hasCache = !this.instanceCaches.has(instance); - if (hasCache) { + const hasCache = this.instanceCaches.has(instance); + if (!hasCache) { const cache = this.cacheFactory.create(); this.instanceCaches.set(instance, cache); } diff --git a/lib/cache/index.ts b/lib/cache/index.ts index 4c37cdd..63edd7a 100644 --- a/lib/cache/index.ts +++ b/lib/cache/index.ts @@ -4,6 +4,7 @@ import { StorageFactory } from './storages/factory'; import { CacheFactory } from './caches/factory'; import { HashService } from '../utils/hash'; import { CacheProviderFactory } from './cacheProvider/factory'; +import { CacheProvider } from './cacheProvider/CacheProvider'; export { CacheOptions }; @@ -23,12 +24,7 @@ export function cache( ): MethodDecorator { const { timeout, options } = parseParameters(timeoutOrOptions, optionsOrVoid); - - const hashService = new HashService(); - const expirationFactory = new ExpirationFactory(timeout, options.expiration); - const storageFactory = new StorageFactory(options.size, options.storage); - const cacheFactory = new CacheFactory(hashService, expirationFactory, storageFactory); - const cacheProvider = new CacheProviderFactory(options.scope, cacheFactory).create(); + const cacheProvider = createCacheProvider(timeout, options); return function (_: any, __: any, descriptor: PropertyDescriptor) { const method = descriptor.value; @@ -67,3 +63,12 @@ function parseParameters( options: { ...DEFAULT_OPTIONS, ...timeoutOrOptions }, }; } + +function createCacheProvider(timeout: number, options: CacheOptions): CacheProvider { + const hashService = new HashService(); + const expirationFactory = new ExpirationFactory(timeout, options.expiration); + const storageFactory = new StorageFactory(options.size, options.storage); + const cacheFactory = new CacheFactory(hashService, expirationFactory, storageFactory); + + return new CacheProviderFactory(options.scope, cacheFactory).create(); +} diff --git a/test/cache/cache.spec.ts b/test/cache/cache.spec.ts index e3d2966..108bfa9 100644 --- a/test/cache/cache.spec.ts +++ b/test/cache/cache.spec.ts @@ -6,7 +6,7 @@ import { delay, executionTime } from '../utils'; describe('@cache', () => { const delayTime = 8; - const timePrecision = 2; + const timePrecision = 3; const factory = (timeout: number, options?: CacheOptions) => { class Test { @@ -123,7 +123,7 @@ describe('@cache', () => { it('should return same value as without decorator', async () => { const instance = new (factory(1000)); - expect(await instance.method(42)).to.be.equals(43); + expect(await instance.method(42)).to.equals(43); }); describe('result should be same at multiple calls', () => { @@ -133,7 +133,7 @@ describe('@cache', () => { const promises = Array.from({ length: 10 }, () => instance.method(42)); const values = await Promise.all(promises); - expect(new Set(values).size).to.be.equals(1); + expect(new Set(values).size).to.equals(1); }); }); diff --git a/test/cache/cacheProvider/ClassCacheProvider.spec.ts b/test/cache/cacheProvider/ClassCacheProvider.spec.ts index 1976ff5..6d9a945 100644 --- a/test/cache/cacheProvider/ClassCacheProvider.spec.ts +++ b/test/cache/cacheProvider/ClassCacheProvider.spec.ts @@ -19,8 +19,6 @@ describe('@cache ClassCacheProvider', () => { it('should create', () => expect(service).to.be.instanceOf(ClassCacheProvider)); - it('should init cache property to null', () => expect(service['cache']).to.be.null); - }); describe('get', () => { @@ -29,16 +27,30 @@ describe('@cache ClassCacheProvider', () => { const cacheInstance = {} as any; cacheFactoryStub.create.returns(cacheInstance); - expect(service.get()).to.be.equals(cacheInstance); + service.get(); + expect(cacheFactoryStub.create.calledOnce).to.be.true; - expect(service['cache']).to.be.equals(cacheInstance); + }); + + it('should return instance create from cahceFactory', () => { + const cacheInstance = {} as any; + cacheFactoryStub.create.returns(cacheInstance); + + expect(service.get()).to.equals(cacheInstance); }); it('should return existent instance of cache if is not first call', () => { const response = service['cache'] = {} as any; + expect(service.get()).to.equals(response); + }); + + it('should not call CacheFactory.create if instance of cache service exists', () => { + service['cache'] = {} as any; + + service.get(); + expect(cacheFactoryStub.create.called).to.be.false; - expect(service.get()).to.be.equals(response); }); }); diff --git a/test/cache/cacheProvider/InstanceCacheProvider.spec.ts b/test/cache/cacheProvider/InstanceCacheProvider.spec.ts index f415441..9d1cf06 100644 --- a/test/cache/cacheProvider/InstanceCacheProvider.spec.ts +++ b/test/cache/cacheProvider/InstanceCacheProvider.spec.ts @@ -32,8 +32,8 @@ describe('@cache InstanceCacheProvider', () => { const instance = {} as any; cacheFactoryStub.create.returns(result); - expect(service.get(instance)).to.be.equals(result); - expect(service['instanceCaches'].get(instance)).to.be.equals(result); + expect(service.get(instance)).to.equals(result); + expect(service['instanceCaches'].get(instance)).to.equals(result); expect(cacheFactoryStub.create.calledOnce).to.be.true; }); @@ -42,7 +42,7 @@ describe('@cache InstanceCacheProvider', () => { const instance = {} as any; service['instanceCaches'].set(instance, result); - expect(service.get(instance)).to.be.equals(result); + expect(service.get(instance)).to.equals(result); expect(cacheFactoryStub.create.called).to.be.false; }); diff --git a/test/cache/caches/Cache.spec.ts b/test/cache/caches/Cache.spec.ts index e1cb28b..6806d76 100644 --- a/test/cache/caches/Cache.spec.ts +++ b/test/cache/caches/Cache.spec.ts @@ -44,6 +44,14 @@ describe('@cache Cache', () => { await service.set(['key'], 'value'); expect(storageStub.set.calledOnce).to.be.true; + }); + + it('should call storage.set to store given hashed key with correct parameters', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.set(['key'], 'value'); + expect(storageStub.set.calledWithExactly(key, 'value')).to.be.true; }); @@ -54,6 +62,14 @@ describe('@cache Cache', () => { await service.set(['key'], 'value'); expect(expirationStub.add.calledOnce).to.be.true; + }); + + it('should call expiration.add with correct parameters', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.set(['key'], 'value'); + expect(expirationStub.add.calledWith(key, sinon.match.func)).to.be.true; }); @@ -70,6 +86,18 @@ describe('@cache Cache', () => { await callback(key); expect(storageStub.delete.calledOnce).to.be.true; + }); + + it('should call storage.delte with correct parameters', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.set(['key'], 'value'); + + const callback = expirationStub.add.firstCall.args[1]; + + await callback(key); + expect(storageStub.delete.calledWith(key)).to.be.true; }); @@ -91,7 +119,15 @@ describe('@cache Cache', () => { await service.has(['key']); - expect(storageStub.has.calledOnce).to.be.true; + expect(storageStub.has.calledWith(key)).to.be.true; + }); + + it('should call storage has with correct parameters', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.has(['key']); + expect(storageStub.has.calledWith(key)).to.be.true; }); @@ -111,6 +147,15 @@ describe('@cache Cache', () => { await service.get(['key']); + expect(expirationStub.add.calledOnce).to.be.true; + }); + + it('should call expiration.add with correct parameters', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.get(['key']); + expect(expirationStub.add.calledOnce).to.be.true; expect(expirationStub.add.calledWith(key, sinon.match.func)).to.be.true; }); @@ -122,12 +167,20 @@ describe('@cache Cache', () => { await service.get(['key']); expect(storageStub.get.calledOnce).to.be.true; + }); + + it('should call storage.get with correct parameters', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.get(['key']); + expect(storageStub.get.calledWith(key)).to.be.true; }); describe('function passed to expiration', () => { - it('should call storage.delete', async () => { + it('should call storage.delete once', async () => { const key = 'key'; hashStub.calculate.returns(key); @@ -138,6 +191,18 @@ describe('@cache Cache', () => { await callback(key); expect(storageStub.delete.calledOnce).to.be.true; + }); + + it('should call storage.delete with correct parameters', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.get(['key']); + + const callback = expirationStub.add.firstCall.args[1]; + + await callback(key); + expect(storageStub.delete.calledWith(key)).to.be.true; }); diff --git a/test/cache/expirations/AbsoluteExpiration.spec.ts b/test/cache/expirations/AbsoluteExpiration.spec.ts index 8204577..ecef181 100644 --- a/test/cache/expirations/AbsoluteExpiration.spec.ts +++ b/test/cache/expirations/AbsoluteExpiration.spec.ts @@ -15,36 +15,12 @@ describe('@cache AbsoluteExpiration', () => { it('should create', () => expect(service).to.be.instanceOf(AbsoluteExpiration)); - it('should init expirations', () => { - expect(service['expirations']).to.be.instanceOf(Set); - }); - }); describe('add', () => { describe('new key', () => { - it('should add key to expirations', () => { - const size = service['expirations'].size; - - service.add('key', () => { }); - - expect(service['expirations'].size).to.be.equals(size + 1); - expect(service['expirations'].has('key')).to.be.true; - }); - - it('should remove key from expirations after timeout ms', async () => { - const size = service['expirations'].size; - - service.add('key', () => { }); - - await delay(timeout); - - expect(service['expirations'].size).to.be.equals(size); - expect(service['expirations'].has('key')).to.be.false; - }); - it('should call clearCallback after timeout ms', async () => { const spy = sinon.spy(); @@ -83,7 +59,6 @@ describe('@cache AbsoluteExpiration', () => { await delay(timeout / 2); expect(firstSpy.calledOnce).to.be.true; - expect(secondSpy.called).to.be.false; }); }); diff --git a/test/cache/expirations/SlidingExpiration.spec.ts b/test/cache/expirations/SlidingExpiration.spec.ts index 395ce25..f2209b7 100644 --- a/test/cache/expirations/SlidingExpiration.spec.ts +++ b/test/cache/expirations/SlidingExpiration.spec.ts @@ -15,35 +15,12 @@ describe('@cache SlidingExpiration', () => { it('should create', () => expect(service).to.be.instanceOf(SlidingExpiration)); - it('should init expirations storage', () => { - expect(service['expirations']).to.be.instanceOf(Map); - }); - }); describe('add', () => { describe('new key', () => { - it('should add key in expirations', () => { - const size = service['expirations'].size; - - service.add('key', () => { }); - - expect(service['expirations'].size).to.be.equals(size + 1); - expect(service['expirations'].has('key')).to.be.true; - }); - - it('should remove key from expirations after timeout ms', async () => { - const size = service['expirations'].size; - - service.add('key', () => { }); - await delay(timeout); - - expect(service['expirations'].size).to.be.equals(size); - expect(service['expirations'].has('key')).to.be.false; - }); - it('should call clearCallback after timeout ms', async () => { const spy = sinon.spy(); @@ -59,14 +36,6 @@ describe('@cache SlidingExpiration', () => { beforeEach(() => service['expirations'].set('key', setTimeout(() => { }, timeout) as any)); - it('should update timer for key in expirations', () => { - const timer = service['expirations'].get('key'); - - service.add('key', () => { }); - - expect(service['expirations'].get('key')).not.to.be.equals(timer); - }); - it('should remove existing expiration', async () => { const expirationSpy = sinon.spy(); service['expirations'].set('key', setTimeout(() => expirationSpy(), timeout) as any); @@ -78,18 +47,11 @@ describe('@cache SlidingExpiration', () => { expect(expirationSpy.called).to.be.false; }); - it('should clear key in expirations after timeout ms', async () => { - service.add('key', () => { }); - - await delay(timeout); - - expect(service['expirations'].get('key')).to.be.undefined; - }); - it('should call clearCallback after timeout ms', async () => { const spy = sinon.spy(); service.add('key', spy); + await delay(timeout); expect(spy.calledOnce).to.be.true; diff --git a/test/cache/storages/MemoryStorage.spec.ts b/test/cache/storages/MemoryStorage.spec.ts index 1a1ab37..a63935e 100644 --- a/test/cache/storages/MemoryStorage.spec.ts +++ b/test/cache/storages/MemoryStorage.spec.ts @@ -12,8 +12,6 @@ describe('@cache MemoryStorage', () => { it('should create', () => expect(service).to.be.instanceOf(MemoryStorage)); - it('should init storage', () => expect(service['storage']).to.be.instanceOf(Map)); - }); describe('set', () => { @@ -24,7 +22,6 @@ describe('@cache MemoryStorage', () => { service.set('c', 3); - expect(service['storage'].size).to.be.equals(2); expect(Array.from(service['storage'].keys())).to.be.deep.equals(['b', 'c']); }); @@ -33,11 +30,11 @@ describe('@cache MemoryStorage', () => { const value = 42; service.set(key, value); - expect(service['storage'].get(key)).to.be.equals(value); + expect(service['storage'].get(key)).to.equals(value); }); it('should return self instance', async () => { - expect(await service.set('key', 'value')).to.be.equals(service); + expect(await service.set('key', 'value')).to.equals(service); }); }); @@ -46,7 +43,7 @@ describe('@cache MemoryStorage', () => { it('should return value from storage', async () => { service['storage'].set('key', 'value'); - expect(await service.get('key')).to.be.equals(service['storage'].get('key')); + expect(await service.get('key')).to.equals(service['storage'].get('key')); }); }); @@ -75,7 +72,7 @@ describe('@cache MemoryStorage', () => { }); it('should return self instance', async () => { - expect(await service.delete('key')).to.be.equals(service); + expect(await service.delete('key')).to.equals(service); }); }); diff --git a/test/uitls/hash.spec.ts b/test/uitls/hash.spec.ts new file mode 100644 index 0000000..8bc4da6 --- /dev/null +++ b/test/uitls/hash.spec.ts @@ -0,0 +1,57 @@ +import { expect } from 'chai'; +import { HashService } from '../../lib/utils/hash'; + +describe('HashService', () => { + + let service: HashService; + + beforeEach(() => service = new HashService()); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(HashService)); + + }); + + describe('calculate', () => { + + it('should return string', () => expect(service.calculate({})).to.be.a('string')); + + it('should return same hash for different objects with same values', () => { + const firstObject = { a: 1, b: { x: 3 }, c: [] }; + const secondObject = { a: 1, b: { x: 3 }, c: [] }; + + expect(service.calculate(firstObject)).to.equals(service.calculate(secondObject)); + }); + + it('should not deppend on keys order', () => { + const firstObject = { a: 1, b: 2 }; + const secondObject = { b: 2, a: 1 }; + + expect(service.calculate(firstObject)).to.equals(service.calculate(secondObject)); + }); + + it('should return different hash for objects with different keys', () => { + const firstObject = { a: 1 }; + const secondObject = { b: 1 }; + + expect(service.calculate(firstObject)).not.equals(service.calculate(secondObject)); + }); + + it('should return different hash for objects with different values', () => { + const firstObject = [1]; + const secondObject = [2]; + + expect(service.calculate(firstObject)).not.equals(service.calculate(secondObject)); + }); + + it('should return different hash for object with different key/value pairs', () => { + const firstObject = { a: 1 }; + const secondObject = { b: 2 }; + + expect(service.calculate(firstObject)).not.equals(service.calculate(secondObject)); + }); + + }); + +}); From b76c809523670e8056584370ce680bf912686340 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 18 Nov 2019 16:43:06 +0200 Subject: [PATCH 8/9] cache improve unit tests --- .../cacheProvider/InstanceCacheProvider.ts | 4 +- lib/cache/index.ts | 17 +++-- test/cache/cache.spec.ts | 6 +- .../cacheProvider/ClassCacheProvider.spec.ts | 27 ++++++-- .../InstanceCacheProvider.spec.ts | 29 +++++--- test/cache/caches/Cache.spec.ts | 68 ++++++++++++++++++- .../expirations/AbsoluteExpiration.spec.ts | 25 ------- .../expirations/SlidingExpiration.spec.ts | 40 +---------- test/cache/storages/MemoryStorage.spec.ts | 41 ++++++----- test/uitls/hash.spec.ts | 57 ++++++++++++++++ 10 files changed, 200 insertions(+), 114 deletions(-) create mode 100644 test/uitls/hash.spec.ts diff --git a/lib/cache/cacheProvider/InstanceCacheProvider.ts b/lib/cache/cacheProvider/InstanceCacheProvider.ts index 711e0cd..6333bb0 100644 --- a/lib/cache/cacheProvider/InstanceCacheProvider.ts +++ b/lib/cache/cacheProvider/InstanceCacheProvider.ts @@ -10,8 +10,8 @@ export class InstanceCacheProvider implements CacheProvider { constructor(private readonly cacheFactory: CacheFactory) { } public get(instance: ClassType): Cache { - const hasCache = !this.instanceCaches.has(instance); - if (hasCache) { + const hasCache = this.instanceCaches.has(instance); + if (!hasCache) { const cache = this.cacheFactory.create(); this.instanceCaches.set(instance, cache); } diff --git a/lib/cache/index.ts b/lib/cache/index.ts index 4c37cdd..63edd7a 100644 --- a/lib/cache/index.ts +++ b/lib/cache/index.ts @@ -4,6 +4,7 @@ import { StorageFactory } from './storages/factory'; import { CacheFactory } from './caches/factory'; import { HashService } from '../utils/hash'; import { CacheProviderFactory } from './cacheProvider/factory'; +import { CacheProvider } from './cacheProvider/CacheProvider'; export { CacheOptions }; @@ -23,12 +24,7 @@ export function cache( ): MethodDecorator { const { timeout, options } = parseParameters(timeoutOrOptions, optionsOrVoid); - - const hashService = new HashService(); - const expirationFactory = new ExpirationFactory(timeout, options.expiration); - const storageFactory = new StorageFactory(options.size, options.storage); - const cacheFactory = new CacheFactory(hashService, expirationFactory, storageFactory); - const cacheProvider = new CacheProviderFactory(options.scope, cacheFactory).create(); + const cacheProvider = createCacheProvider(timeout, options); return function (_: any, __: any, descriptor: PropertyDescriptor) { const method = descriptor.value; @@ -67,3 +63,12 @@ function parseParameters( options: { ...DEFAULT_OPTIONS, ...timeoutOrOptions }, }; } + +function createCacheProvider(timeout: number, options: CacheOptions): CacheProvider { + const hashService = new HashService(); + const expirationFactory = new ExpirationFactory(timeout, options.expiration); + const storageFactory = new StorageFactory(options.size, options.storage); + const cacheFactory = new CacheFactory(hashService, expirationFactory, storageFactory); + + return new CacheProviderFactory(options.scope, cacheFactory).create(); +} diff --git a/test/cache/cache.spec.ts b/test/cache/cache.spec.ts index e3d2966..108bfa9 100644 --- a/test/cache/cache.spec.ts +++ b/test/cache/cache.spec.ts @@ -6,7 +6,7 @@ import { delay, executionTime } from '../utils'; describe('@cache', () => { const delayTime = 8; - const timePrecision = 2; + const timePrecision = 3; const factory = (timeout: number, options?: CacheOptions) => { class Test { @@ -123,7 +123,7 @@ describe('@cache', () => { it('should return same value as without decorator', async () => { const instance = new (factory(1000)); - expect(await instance.method(42)).to.be.equals(43); + expect(await instance.method(42)).to.equals(43); }); describe('result should be same at multiple calls', () => { @@ -133,7 +133,7 @@ describe('@cache', () => { const promises = Array.from({ length: 10 }, () => instance.method(42)); const values = await Promise.all(promises); - expect(new Set(values).size).to.be.equals(1); + expect(new Set(values).size).to.equals(1); }); }); diff --git a/test/cache/cacheProvider/ClassCacheProvider.spec.ts b/test/cache/cacheProvider/ClassCacheProvider.spec.ts index 1976ff5..52e77aa 100644 --- a/test/cache/cacheProvider/ClassCacheProvider.spec.ts +++ b/test/cache/cacheProvider/ClassCacheProvider.spec.ts @@ -19,26 +19,39 @@ describe('@cache ClassCacheProvider', () => { it('should create', () => expect(service).to.be.instanceOf(ClassCacheProvider)); - it('should init cache property to null', () => expect(service['cache']).to.be.null); - }); describe('get', () => { it('should call CacheFactory.create to create an instance at first call', () => { + service.get(); + + expect(cacheFactoryStub.create.calledOnce).to.be.true; + }); + + it('should return instance create from cahceFactory', () => { const cacheInstance = {} as any; cacheFactoryStub.create.returns(cacheInstance); - expect(service.get()).to.be.equals(cacheInstance); - expect(cacheFactoryStub.create.calledOnce).to.be.true; - expect(service['cache']).to.be.equals(cacheInstance); + expect(service.get()).to.equals(cacheInstance); }); it('should return existent instance of cache if is not first call', () => { - const response = service['cache'] = {} as any; + const cacheInstance = {} as any; + cacheFactoryStub.create.returns(cacheInstance); + service.get(); + + expect(service.get()).to.equals(cacheInstance); + }); + + it('should not call CacheFactory.create if instance of cache service exists', () => { + cacheFactoryStub.create.returns({} as any); + service.get(); + cacheFactoryStub.create.reset(); + + service.get(); expect(cacheFactoryStub.create.called).to.be.false; - expect(service.get()).to.be.equals(response); }); }); diff --git a/test/cache/cacheProvider/InstanceCacheProvider.spec.ts b/test/cache/cacheProvider/InstanceCacheProvider.spec.ts index f415441..d055ed3 100644 --- a/test/cache/cacheProvider/InstanceCacheProvider.spec.ts +++ b/test/cache/cacheProvider/InstanceCacheProvider.spec.ts @@ -19,10 +19,6 @@ describe('@cache InstanceCacheProvider', () => { it('should create', () => expect(service).to.be.instanceOf(InstanceCacheProvider)); - it('should init instancesCaches property', () => { - expect(service['instanceCaches']).to.be.instanceOf(WeakMap); - }); - }); describe('get', () => { @@ -32,17 +28,32 @@ describe('@cache InstanceCacheProvider', () => { const instance = {} as any; cacheFactoryStub.create.returns(result); - expect(service.get(instance)).to.be.equals(result); - expect(service['instanceCaches'].get(instance)).to.be.equals(result); - expect(cacheFactoryStub.create.calledOnce).to.be.true; + expect(service.get(instance)).to.equals(result); }); it('should return already created cache for current isntance', () => { const result = {} as any; + cacheFactoryStub.create.returns(result); const instance = {} as any; - service['instanceCaches'].set(instance, result); + service.get(instance); + + expect(service.get(instance)).to.equals(result); + }); + + it('should create new cahce with cacheFactory.create', () => { + service.get({} as any); + + expect(cacheFactoryStub.create.calledOnce).to.be.true; + }); + + it('should not create new cache cache instance if already exists', () => { + cacheFactoryStub.create.returns({} as any); + const instance = {} as any; + service.get(instance); + cacheFactoryStub.create.reset(); + + service.get(instance); - expect(service.get(instance)).to.be.equals(result); expect(cacheFactoryStub.create.called).to.be.false; }); diff --git a/test/cache/caches/Cache.spec.ts b/test/cache/caches/Cache.spec.ts index e1cb28b..55dd6cd 100644 --- a/test/cache/caches/Cache.spec.ts +++ b/test/cache/caches/Cache.spec.ts @@ -44,6 +44,14 @@ describe('@cache Cache', () => { await service.set(['key'], 'value'); expect(storageStub.set.calledOnce).to.be.true; + }); + + it('should call storage.set to store given hashed key with correct parameters', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.set(['key'], 'value'); + expect(storageStub.set.calledWithExactly(key, 'value')).to.be.true; }); @@ -54,6 +62,14 @@ describe('@cache Cache', () => { await service.set(['key'], 'value'); expect(expirationStub.add.calledOnce).to.be.true; + }); + + it('should call expiration.add with correct parameters', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.set(['key'], 'value'); + expect(expirationStub.add.calledWith(key, sinon.match.func)).to.be.true; }); @@ -70,6 +86,18 @@ describe('@cache Cache', () => { await callback(key); expect(storageStub.delete.calledOnce).to.be.true; + }); + + it('should call storage.delte with correct parameters', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.set(['key'], 'value'); + + const callback = expirationStub.add.firstCall.args[1]; + + await callback(key); + expect(storageStub.delete.calledWith(key)).to.be.true; }); @@ -91,7 +119,15 @@ describe('@cache Cache', () => { await service.has(['key']); - expect(storageStub.has.calledOnce).to.be.true; + expect(storageStub.has.calledWith(key)).to.be.true; + }); + + it('should call storage has with correct parameters', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.has(['key']); + expect(storageStub.has.calledWith(key)).to.be.true; }); @@ -112,6 +148,14 @@ describe('@cache Cache', () => { await service.get(['key']); expect(expirationStub.add.calledOnce).to.be.true; + }); + + it('should call expiration.add with correct parameters', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.get(['key']); + expect(expirationStub.add.calledWith(key, sinon.match.func)).to.be.true; }); @@ -122,12 +166,20 @@ describe('@cache Cache', () => { await service.get(['key']); expect(storageStub.get.calledOnce).to.be.true; + }); + + it('should call storage.get with correct parameters', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.get(['key']); + expect(storageStub.get.calledWith(key)).to.be.true; }); describe('function passed to expiration', () => { - it('should call storage.delete', async () => { + it('should call storage.delete once', async () => { const key = 'key'; hashStub.calculate.returns(key); @@ -138,6 +190,18 @@ describe('@cache Cache', () => { await callback(key); expect(storageStub.delete.calledOnce).to.be.true; + }); + + it('should call storage.delete with correct parameters', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.get(['key']); + + const callback = expirationStub.add.firstCall.args[1]; + + await callback(key); + expect(storageStub.delete.calledWith(key)).to.be.true; }); diff --git a/test/cache/expirations/AbsoluteExpiration.spec.ts b/test/cache/expirations/AbsoluteExpiration.spec.ts index 8204577..ecef181 100644 --- a/test/cache/expirations/AbsoluteExpiration.spec.ts +++ b/test/cache/expirations/AbsoluteExpiration.spec.ts @@ -15,36 +15,12 @@ describe('@cache AbsoluteExpiration', () => { it('should create', () => expect(service).to.be.instanceOf(AbsoluteExpiration)); - it('should init expirations', () => { - expect(service['expirations']).to.be.instanceOf(Set); - }); - }); describe('add', () => { describe('new key', () => { - it('should add key to expirations', () => { - const size = service['expirations'].size; - - service.add('key', () => { }); - - expect(service['expirations'].size).to.be.equals(size + 1); - expect(service['expirations'].has('key')).to.be.true; - }); - - it('should remove key from expirations after timeout ms', async () => { - const size = service['expirations'].size; - - service.add('key', () => { }); - - await delay(timeout); - - expect(service['expirations'].size).to.be.equals(size); - expect(service['expirations'].has('key')).to.be.false; - }); - it('should call clearCallback after timeout ms', async () => { const spy = sinon.spy(); @@ -83,7 +59,6 @@ describe('@cache AbsoluteExpiration', () => { await delay(timeout / 2); expect(firstSpy.calledOnce).to.be.true; - expect(secondSpy.called).to.be.false; }); }); diff --git a/test/cache/expirations/SlidingExpiration.spec.ts b/test/cache/expirations/SlidingExpiration.spec.ts index 395ce25..f2209b7 100644 --- a/test/cache/expirations/SlidingExpiration.spec.ts +++ b/test/cache/expirations/SlidingExpiration.spec.ts @@ -15,35 +15,12 @@ describe('@cache SlidingExpiration', () => { it('should create', () => expect(service).to.be.instanceOf(SlidingExpiration)); - it('should init expirations storage', () => { - expect(service['expirations']).to.be.instanceOf(Map); - }); - }); describe('add', () => { describe('new key', () => { - it('should add key in expirations', () => { - const size = service['expirations'].size; - - service.add('key', () => { }); - - expect(service['expirations'].size).to.be.equals(size + 1); - expect(service['expirations'].has('key')).to.be.true; - }); - - it('should remove key from expirations after timeout ms', async () => { - const size = service['expirations'].size; - - service.add('key', () => { }); - await delay(timeout); - - expect(service['expirations'].size).to.be.equals(size); - expect(service['expirations'].has('key')).to.be.false; - }); - it('should call clearCallback after timeout ms', async () => { const spy = sinon.spy(); @@ -59,14 +36,6 @@ describe('@cache SlidingExpiration', () => { beforeEach(() => service['expirations'].set('key', setTimeout(() => { }, timeout) as any)); - it('should update timer for key in expirations', () => { - const timer = service['expirations'].get('key'); - - service.add('key', () => { }); - - expect(service['expirations'].get('key')).not.to.be.equals(timer); - }); - it('should remove existing expiration', async () => { const expirationSpy = sinon.spy(); service['expirations'].set('key', setTimeout(() => expirationSpy(), timeout) as any); @@ -78,18 +47,11 @@ describe('@cache SlidingExpiration', () => { expect(expirationSpy.called).to.be.false; }); - it('should clear key in expirations after timeout ms', async () => { - service.add('key', () => { }); - - await delay(timeout); - - expect(service['expirations'].get('key')).to.be.undefined; - }); - it('should call clearCallback after timeout ms', async () => { const spy = sinon.spy(); service.add('key', spy); + await delay(timeout); expect(spy.calledOnce).to.be.true; diff --git a/test/cache/storages/MemoryStorage.spec.ts b/test/cache/storages/MemoryStorage.spec.ts index 1a1ab37..adb4721 100644 --- a/test/cache/storages/MemoryStorage.spec.ts +++ b/test/cache/storages/MemoryStorage.spec.ts @@ -12,32 +12,31 @@ describe('@cache MemoryStorage', () => { it('should create', () => expect(service).to.be.instanceOf(MemoryStorage)); - it('should init storage', () => expect(service['storage']).to.be.instanceOf(Map)); - }); describe('set', () => { - it('should remove oldest key if limit is reached', () => { - const service = new MemoryStorage(2); - service['storage'].set('a', 1).set('b', 2); + it('should set key and value to storage', async () => { + const key = 'key'; + const value = 42; - service.set('c', 3); + await service.set(key, value); - expect(service['storage'].size).to.be.equals(2); - expect(Array.from(service['storage'].keys())).to.be.deep.equals(['b', 'c']); + expect(await service.get(key)).to.equals(value); }); - it('should set key and value to storage', () => { - const key = 'key'; - const value = 42; - service.set(key, value); + it('should remove oldest key if limit is reached', async () => { + const service = new MemoryStorage(2); + await service.set('a', 1); + await service.set('b', 2); - expect(service['storage'].get(key)).to.be.equals(value); + await service.set('c', 3); + + expect(await service.has('a')).to.be.false; }); it('should return self instance', async () => { - expect(await service.set('key', 'value')).to.be.equals(service); + expect(await service.set('key', 'value')).to.equals(service); }); }); @@ -45,8 +44,8 @@ describe('@cache MemoryStorage', () => { describe('get', () => { it('should return value from storage', async () => { - service['storage'].set('key', 'value'); - expect(await service.get('key')).to.be.equals(service['storage'].get('key')); + await service.set('key', 'value'); + expect(await service.get('key')).to.equals('value'); }); }); @@ -54,11 +53,11 @@ describe('@cache MemoryStorage', () => { describe('has', () => { it('should return true if key is in storage', async () => { - service['storage'].set('key', 'value'); + await service.set('key', 'value'); expect(await service.has('key')).to.be.true; }); - it('should retunr false if key is not in storage', async () => { + it('should return false if key is not in storage', async () => { expect(await service.has('key')).to.be.false; }); @@ -67,15 +66,15 @@ describe('@cache MemoryStorage', () => { describe('delete', () => { it('should delete key from storage', async () => { - service['storage'].set('key', '123'); + await service.set('key', '123'); await service.delete('key'); - expect(service['storage'].has('key')).to.be.false; + expect(await service.has('key')).to.be.false; }); it('should return self instance', async () => { - expect(await service.delete('key')).to.be.equals(service); + expect(await service.delete('key')).to.equals(service); }); }); diff --git a/test/uitls/hash.spec.ts b/test/uitls/hash.spec.ts new file mode 100644 index 0000000..8bc4da6 --- /dev/null +++ b/test/uitls/hash.spec.ts @@ -0,0 +1,57 @@ +import { expect } from 'chai'; +import { HashService } from '../../lib/utils/hash'; + +describe('HashService', () => { + + let service: HashService; + + beforeEach(() => service = new HashService()); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(HashService)); + + }); + + describe('calculate', () => { + + it('should return string', () => expect(service.calculate({})).to.be.a('string')); + + it('should return same hash for different objects with same values', () => { + const firstObject = { a: 1, b: { x: 3 }, c: [] }; + const secondObject = { a: 1, b: { x: 3 }, c: [] }; + + expect(service.calculate(firstObject)).to.equals(service.calculate(secondObject)); + }); + + it('should not deppend on keys order', () => { + const firstObject = { a: 1, b: 2 }; + const secondObject = { b: 2, a: 1 }; + + expect(service.calculate(firstObject)).to.equals(service.calculate(secondObject)); + }); + + it('should return different hash for objects with different keys', () => { + const firstObject = { a: 1 }; + const secondObject = { b: 1 }; + + expect(service.calculate(firstObject)).not.equals(service.calculate(secondObject)); + }); + + it('should return different hash for objects with different values', () => { + const firstObject = [1]; + const secondObject = [2]; + + expect(service.calculate(firstObject)).not.equals(service.calculate(secondObject)); + }); + + it('should return different hash for object with different key/value pairs', () => { + const firstObject = { a: 1 }; + const secondObject = { b: 2 }; + + expect(service.calculate(firstObject)).not.equals(service.calculate(secondObject)); + }); + + }); + +}); From a1f5bf152b6fe32c8341974bf626421de96fc16e Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 20 Nov 2019 15:59:56 +0200 Subject: [PATCH 9/9] cache change factories, not create is with parameters improve unit tests --- lib/cache/cacheProvider/factory.ts | 9 +- lib/cache/caches/factory.ts | 6 +- lib/cache/expirations/factory.ts | 9 +- lib/cache/index.ts | 18 +- lib/cache/storages/MemoryStorage.ts | 4 +- lib/cache/storages/factory.ts | 9 +- lib/interfaces/factory.ts | 4 +- test/cache/cache.spec.ts | 216 ++++++++++-------- .../cacheProvider/ClassCacheProvider.spec.ts | 4 +- test/cache/cacheProvider/factory.spec.ts | 25 +- test/cache/caches/Cache.spec.ts | 52 +---- test/cache/caches/factory.spec.ts | 27 ++- .../expirations/AbsoluteExpiration.spec.ts | 37 ++- .../expirations/SlidingExpiration.spec.ts | 25 +- test/cache/expirations/factory.spec.ts | 15 +- test/cache/storages/factory.spec.ts | 15 +- test/uitls/hash.spec.ts | 14 ++ test/utils.ts | 6 - 18 files changed, 287 insertions(+), 208 deletions(-) diff --git a/lib/cache/cacheProvider/factory.ts b/lib/cache/cacheProvider/factory.ts index 3e1097e..3df1405 100644 --- a/lib/cache/cacheProvider/factory.ts +++ b/lib/cache/cacheProvider/factory.ts @@ -4,15 +4,14 @@ import { CacheProvider } from './CacheProvider'; import { ClassCacheProvider } from './ClassCacheProvider'; import { InstanceCacheProvider } from './InstanceCacheProvider'; -export class CacheProviderFactory implements Factory { +export class CacheProviderFactory implements Factory { constructor( - private readonly scope: 'class' | 'instance', private readonly cacheFactory: CacheFactory, ) { } - public create() { - switch (this.scope) { + public create(scope: 'class' | 'instance') { + switch (scope) { case 'class': return new ClassCacheProvider(this.cacheFactory); @@ -20,7 +19,7 @@ export class CacheProviderFactory implements Factory { return new InstanceCacheProvider(this.cacheFactory); default: - throw new Error(`@cache invalid scope option: ${this.scope}.`); + throw new Error(`@cache invalid scope option: ${scope}.`); } } diff --git a/lib/cache/caches/factory.ts b/lib/cache/caches/factory.ts index 856678f..815f82b 100644 --- a/lib/cache/caches/factory.ts +++ b/lib/cache/caches/factory.ts @@ -10,11 +10,13 @@ export class CacheFactory implements Factory> { private readonly hash: HashService, private readonly expirationFactory: ExpirationFactory, private readonly storageFactory: StorageFactory, + private readonly expiration: 'absolute' | 'sliding', + private readonly storage: 'memory', ) { } public create(): Cache { - const storage = this.storageFactory.create(); - const expiration = this.expirationFactory.create(); + const storage = this.storageFactory.create(this.storage); + const expiration = this.expirationFactory.create(this.expiration); return new Cache(storage, expiration, this.hash); } diff --git a/lib/cache/expirations/factory.ts b/lib/cache/expirations/factory.ts index 9f5d9f2..d0a3ad1 100644 --- a/lib/cache/expirations/factory.ts +++ b/lib/cache/expirations/factory.ts @@ -3,15 +3,14 @@ import { AbsoluteExpiration } from './AbsoluteExpiration'; import { Expiration } from './Expiration'; import { SlidingExpiration } from './SlidingExpiration'; -export class ExpirationFactory implements Factory { +export class ExpirationFactory implements Factory { constructor( private readonly timeout: number, - private readonly expiration: 'absolute' | 'sliding', ) { } - public create(): Expiration { - switch (this.expiration) { + public create(expiration: 'absolute' | 'sliding'): Expiration { + switch (expiration) { case 'absolute': return new AbsoluteExpiration(this.timeout); @@ -19,7 +18,7 @@ export class ExpirationFactory implements Factory { return new SlidingExpiration(this.timeout); default: - throw new Error(`@cache Expiration type is not supported: ${this.expiration}.`); + throw new Error(`@cache Expiration type is not supported: ${expiration}.`); } } diff --git a/lib/cache/index.ts b/lib/cache/index.ts index 63edd7a..8bd8e83 100644 --- a/lib/cache/index.ts +++ b/lib/cache/index.ts @@ -31,8 +31,8 @@ export function cache( descriptor.value = async function (...args: any[]) { const cacheService = cacheProvider.get(this); - const isCached = await cacheService.has(args); + const isCached = await cacheService.has(args); if (isCached) { return cacheService.get(args); } @@ -64,11 +64,17 @@ function parseParameters( }; } -function createCacheProvider(timeout: number, options: CacheOptions): CacheProvider { +function createCacheProvider( + timeout: number, + { expiration, scope, size, storage }: CacheOptions, +): CacheProvider { + const hashService = new HashService(); - const expirationFactory = new ExpirationFactory(timeout, options.expiration); - const storageFactory = new StorageFactory(options.size, options.storage); - const cacheFactory = new CacheFactory(hashService, expirationFactory, storageFactory); + const expirationFactory = new ExpirationFactory(timeout); + const storageFactory = new StorageFactory(size); + + const cacheFactory = + new CacheFactory(hashService, expirationFactory, storageFactory, expiration, storage); - return new CacheProviderFactory(options.scope, cacheFactory).create(); + return new CacheProviderFactory(cacheFactory).create(scope); } diff --git a/lib/cache/storages/MemoryStorage.ts b/lib/cache/storages/MemoryStorage.ts index ecf9cc0..c4adf18 100644 --- a/lib/cache/storages/MemoryStorage.ts +++ b/lib/cache/storages/MemoryStorage.ts @@ -4,7 +4,7 @@ export class MemoryStorage implements Storage { private readonly storage = new Map(); - constructor(private readonly limit?: number) { } + constructor(private readonly limit: number = Infinity) { } public async set(key: string, value: V): Promise { this.checkSize(); @@ -29,7 +29,7 @@ export class MemoryStorage implements Storage { } private checkSize(): void { - if (typeof this.limit === 'number' && this.storage.size >= this.limit) { + if (this.storage.size >= this.limit) { this.delete(this.storage.keys().next().value); } } diff --git a/lib/cache/storages/factory.ts b/lib/cache/storages/factory.ts index b51a0c4..2caa02b 100644 --- a/lib/cache/storages/factory.ts +++ b/lib/cache/storages/factory.ts @@ -2,20 +2,19 @@ import { Factory } from '../../interfaces/factory'; import { MemoryStorage } from './MemoryStorage'; import { Storage } from './Storage'; -export class StorageFactory implements Factory { +export class StorageFactory implements Factory { constructor( private readonly limit: number, - private readonly storage: 'memory', ) { } - public create(): Storage { - switch (this.storage) { + public create(storage: 'memory'): Storage { + switch (storage) { case 'memory': return new MemoryStorage(this.limit); default: - throw new Error(`@cache Storage type is not supported: ${this.storage}.`); + throw new Error(`@cache Storage type is not supported: ${storage}.`); } } diff --git a/lib/interfaces/factory.ts b/lib/interfaces/factory.ts index a6a4809..b23d485 100644 --- a/lib/interfaces/factory.ts +++ b/lib/interfaces/factory.ts @@ -1,3 +1,3 @@ -export interface Factory { - create(): T; +export interface Factory { + create(...args: A): T; } diff --git a/test/cache/cache.spec.ts b/test/cache/cache.spec.ts index 108bfa9..8d0b31c 100644 --- a/test/cache/cache.spec.ts +++ b/test/cache/cache.spec.ts @@ -1,18 +1,23 @@ import { expect } from 'chai'; +import * as sinon from 'sinon'; import { cache, CacheOptions } from '../../lib'; -import { delay, executionTime } from '../utils'; +import { delay } from '../utils'; describe('@cache', () => { - const delayTime = 8; - const timePrecision = 3; + const delayTime = 2; + const timeout = 6; + let methodStub: sinon.SinonStub<[], unknown>; - const factory = (timeout: number, options?: CacheOptions) => { + beforeEach(() => methodStub = sinon.stub()); + + const factory = (timeout: number, options?: CacheOptions, methodSpy = methodStub) => { class Test { @cache(timeout, options) public async method(n: number): Promise { + methodSpy(); await delay(delayTime); return n + 1; } @@ -23,25 +28,25 @@ describe('@cache', () => { }; it('should use cached value if method is called with same arguments', async () => { - const timeout = 1000; - const options: CacheOptions = { size: 3 }; - const instance = new (factory(timeout, options)); + const instance = new (factory(timeout)); + + await instance.method(42); + methodStub.reset(); await instance.method(42); - const time = await executionTime(() => instance.method(42)); - expect(time).to.be.approximately(0, timePrecision); + expect(methodStub.called).to.be.false; }); it('should not use cached value if method is called with different arguments', async () => { - const timeout = 1000; - const options: CacheOptions = { size: 3 }; - const instance = new (factory(timeout, options)); + const instance = new (factory(timeout)); await instance.method(42); - const time = await executionTime(() => instance.method(24)); + methodStub.reset(); - expect(time).to.be.approximately(delayTime, timePrecision); + await instance.method(24); + + expect(methodStub.calledOnce).to.be.true; }); it('should propagate error if method reject', () => { @@ -60,10 +65,8 @@ describe('@cache', () => { describe('options values', () => { - const timeout = 1000; - it('should work without options', () => { - expect(() => new (factory(timeout))).to.not.throw(); + expect(() => new (factory(timeout))().method(42)).to.not.throw(); }); it('should work with correct options', () => { @@ -74,7 +77,7 @@ describe('@cache', () => { size: 300, }; - expect(() => new (factory(timeout, options))).to.not.throw(); + expect(() => new (factory(timeout, options))().method(42)).to.not.throw(); }); it('should work with one parameter', () => { @@ -90,28 +93,31 @@ describe('@cache', () => { return Test; }; - expect(test).not.to.throw(); + expect(() => new (test())().method()).not.to.throw(); }); describe('should throw for wrong options', () => { it('should throw if expiration is not a valid value', () => { const options: CacheOptions = { expiration: 'abc' as any }; - const expectedError = '@cache Expiration type is not supported: abc.'; const instance = new (factory(timeout, options)); + + const expectedError = '@cache Expiration type is not supported: abc.'; expect(instance.method(42)).to.be.rejectedWith(expectedError); }); it('should throw if scope is not a valid value', () => { const options: CacheOptions = { scope: 'xyz' as any }; + const expectedError = '@cache invalid scope option: xyz.'; - expect(() => new (factory(timeout, options))).to.throw(expectedError); + expect(() => new (factory(timeout, options))().method(42)).to.throw(expectedError); }); it('should throw if storage is not a valid value', () => { const options: CacheOptions = { storage: 'qwe' as any }; - const expectedError = '@cache Storage type is not supported: qwe.'; const instance = new (factory(timeout, options)); + + const expectedError = '@cache Storage type is not supported: qwe.'; expect(instance.method(42)).to.be.rejectedWith(expectedError); }); @@ -122,7 +128,8 @@ describe('@cache', () => { describe("it shouldn't change method behaivor", () => { it('should return same value as without decorator', async () => { - const instance = new (factory(1000)); + const instance = new (factory(timeout)); + expect(await instance.method(42)).to.equals(43); }); @@ -147,38 +154,41 @@ describe('@cache', () => { describe('absolute', () => { const options: CacheOptions = { expiration: 'absolute' }; + let instance: InstanceType>; + + beforeEach(() => instance = new (factory(timeout, options))); it('should return cached value', async () => { - const timeout = 20; - const instance = new (factory(timeout, options)); await instance.method(42); - const time = await executionTime(() => instance.method(42)); - expect(time).to.be.approximately(0, timePrecision); + methodStub.reset(); + await instance.method(42); + + expect(methodStub.called).to.be.false; }); - it('should exprie after given timeout', async () => { - const timeout = delayTime + 5; - const instance = new (factory(timeout, options)); + it('should expire after given timeout', async () => { await instance.method(42); + methodStub.reset(); - await delay(timeout + 1); + await delay(timeout * 2); - const time = await executionTime(() => instance.method(42)); - expect(time).to.be.approximately(delayTime, timePrecision); + await instance.method(42); + + expect(methodStub.calledOnce).to.be.true; }); it('should not refresh if was call before expire', async () => { - const timeout = delayTime + 2; - const instance = new (factory(timeout, options)); await instance.method(42); await delay(timeout / 2); await instance.method(42); + methodStub.reset(); await delay(timeout / 2); - const time = await executionTime(() => instance.method(42)); - expect(time).to.be.approximately(delayTime, timePrecision); + await instance.method(42); + + expect(methodStub.calledOnce).to.be.true; }); }); @@ -186,38 +196,39 @@ describe('@cache', () => { describe('sliding', () => { const options: CacheOptions = { expiration: 'sliding' }; + let instance: InstanceType>; + + beforeEach(() => instance = new (factory(timeout, options))); it('should return cached value', async () => { - const timeout = 20; - const instance = new (factory(timeout, options)); await instance.method(42); + methodStub.reset(); - const time = await executionTime(() => instance.method(42)); - expect(time).to.be.approximately(0, timePrecision); + await instance.method(42); + + expect(methodStub.called).to.be.false; }); it('should expire after given timeout', async () => { - const timeout = delayTime; - const instance = new (factory(timeout, options)); await instance.method(42); + methodStub.reset(); - await delay(timeout + delayTime + 1); + await delay(timeout * 2); - const time = await executionTime(() => instance.method(42)); - expect(time).to.be.approximately(delayTime, timePrecision); + await instance.method(42); + expect(methodStub.calledOnce).to.be.true; }); it('should refresh if was call before expire', async () => { - const timeout = 3 * delayTime; - const instance = new (factory(timeout, options)); await instance.method(42); - await delay(timeout / 3); + await delay(timeout / 2); await instance.method(42); - await delay(2 * timeout / 3); + methodStub.reset(); + await delay(timeout / 2); - const time = await executionTime(() => instance.method(42)); - expect(time).to.be.approximately(0, timePrecision); + await instance.method(42); + expect(methodStub.called).to.be.false; }); }); @@ -228,58 +239,67 @@ describe('@cache', () => { describe('class', () => { - const timeout = 1000; const options: CacheOptions = { scope: 'class' }; + let constructor: ReturnType; + let instance: InstanceType; + + beforeEach(() => { + constructor = factory(timeout, options); + instance = new constructor(); + }); it('should return cached value for same instance of class', async () => { - const instance = new (factory(timeout, options)); await instance.method(42); + methodStub.reset(); - const time = await executionTime(() => instance.method(42)); - expect(time).to.be.approximately(0, timePrecision); + await instance.method(42); + expect(methodStub.called).to.be.false; }); it('should return cached value for every instances of class', async () => { - const constructor = factory(timeout, options); await new constructor().method(42); + methodStub.reset(); - const time = await executionTime(() => new constructor().method(42)); - expect(time).to.be.approximately(0, timePrecision); + await instance.method(42); + expect(methodStub.called).to.be.false; }); it('should not use cached value if is another class', async () => { - const firstConstructor = factory(timeout, options); - const secondConstructor = factory(timeout, options); - - const firstInstance = new firstConstructor(); - const secondInstance = new secondConstructor(); + const anotherConstructor = factory(timeout, options); + await new anotherConstructor().method(42); + methodStub.reset(); - await firstInstance.method(42); - const time = await executionTime(() => secondInstance.method(42)); - expect(time).to.be.gte(delayTime); + await instance.method(42); + expect(methodStub.calledOnce).to.be.true; }); }); describe('instance', () => { - const timeout = 1000; const options: CacheOptions = { scope: 'instance' }; + let constructor: ReturnType; + let instance: InstanceType; + + beforeEach(() => { + constructor = factory(timeout, options); + instance = new constructor(); + }); it('should return cached value for same instance of class', async () => { - const instance = new (factory(timeout, options)); await instance.method(42); + methodStub.reset(); - const time = await executionTime(() => instance.method(42)); - expect(time).to.be.approximately(0, timePrecision); + await instance.method(42); + expect(methodStub.called).to.be.false; }); it('should not return cached value for differenct instances of class', async () => { - const constructor = factory(timeout, options); await new constructor().method(42); + methodStub.reset(); - const time = await executionTime(() => new constructor().method(42)); - expect(time).to.be.approximately(delayTime, timePrecision); + await instance.method(42); + expect(methodStub.calledOnce).to.be.true; }); }); @@ -288,36 +308,38 @@ describe('@cache', () => { describe('size', () => { - const timeout = 1000; + const options: CacheOptions = { size: 2 }; + let instance: InstanceType>; + + beforeEach(() => instance = new (factory(timeout, options))); it('should cache value if storage limit is not reached', async () => { - const options: CacheOptions = { size: 3 }; - const instance = new (factory(timeout, options)); + await instance.method(1); + await instance.method(2); + methodStub.reset(); - await Promise.all([instance.method(42), instance.method(24)]); - const times = await Promise.all([ - executionTime(() => instance.method(42)), - executionTime(() => instance.method(24)), - ]); + await instance.method(2); + expect(methodStub.called).to.be.false; + }); - times.forEach(time => expect(time).to.be.approximately(0, timePrecision)); + it('should cache value if storage is reached', async () => { + await instance.method(1); + await instance.method(2); + await instance.method(3); + methodStub.reset(); + + await instance.method(3); + expect(methodStub.called).to.be.false; }); - it('should remove oldes value if storage limit is reached', async () => { - const options: CacheOptions = { size: 1 }; - const instance = new (factory(timeout, options)); + it('should remove oldest value if storage limit is reached', async () => { + await instance.method(1); + await instance.method(2); + await instance.method(3); + methodStub.reset(); - await Promise.all([ - instance.method(42), - instance.method(24), - ]); - const [firstTime, secondTime] = await Promise.all([ - executionTime(() => instance.method(42)), - executionTime(() => instance.method(24)), - ]); - - expect(firstTime).to.be.approximately(delayTime, timePrecision); - expect(secondTime).to.be.approximately(0, timePrecision); + await instance.method(1); + expect(methodStub.calledOnce).to.be.true; }); }); diff --git a/test/cache/cacheProvider/ClassCacheProvider.spec.ts b/test/cache/cacheProvider/ClassCacheProvider.spec.ts index 455a1ba..ececbec 100644 --- a/test/cache/cacheProvider/ClassCacheProvider.spec.ts +++ b/test/cache/cacheProvider/ClassCacheProvider.spec.ts @@ -55,7 +55,9 @@ describe('@cache ClassCacheProvider', () => { }); it('should not call CacheFactory.create if instance of cache service exists', () => { - service['cache'] = {} as any; + cacheFactoryStub.create.returns({} as any); + service.get(); + cacheFactoryStub.create.reset(); service.get(); diff --git a/test/cache/cacheProvider/factory.spec.ts b/test/cache/cacheProvider/factory.spec.ts index 28887f9..869e798 100644 --- a/test/cache/cacheProvider/factory.spec.ts +++ b/test/cache/cacheProvider/factory.spec.ts @@ -1,38 +1,43 @@ import { expect } from 'chai'; +import * as sinon from 'sinon'; import { ClassCacheProvider } from '../../../lib/cache/cacheProvider/ClassCacheProvider'; import { CacheProviderFactory } from '../../../lib/cache/cacheProvider/factory'; import { InstanceCacheProvider } from '../../../lib/cache/cacheProvider/InstanceCacheProvider'; +import { CacheFactory } from '../../../lib/cache/caches/factory'; describe('@cache CacheProviderFactory', () => { + let cacheFactoryStub: sinon.SinonStubbedInstance; + let service: CacheProviderFactory; + + beforeEach(() => { + cacheFactoryStub = sinon.createStubInstance(CacheFactory); + + service = new CacheProviderFactory(cacheFactoryStub as any); + }); + describe('constructor', () => { - it('should create', () => { - expect(new CacheProviderFactory('class', undefined)).to.be.instanceOf(CacheProviderFactory); - }); + it('should create', () => expect(service).to.be.instanceOf(CacheProviderFactory)); }); describe('create', () => { it('should create instanceof ClassCacheProvider if scope is "class"', () => { - const instance = new CacheProviderFactory('class', undefined); - expect(instance.create()).to.be.instanceOf(ClassCacheProvider); + expect(service.create('class')).to.be.instanceOf(ClassCacheProvider); }); it('should create instanceof InstanceCacheProvider if scope is "instance"', () => { - const instance = new CacheProviderFactory('instance', undefined); - expect(instance.create()).to.be.instanceOf(InstanceCacheProvider); + expect(service.create('instance')).to.be.instanceOf(InstanceCacheProvider); }); it('should throw error if scope options is not a valid one', () => { const scope = '123' as any; const message = `@cache invalid scope option: ${scope}.`; - const instance = new CacheProviderFactory(scope, undefined); - - expect(() => instance.create()).to.throw(message); + expect(() => service.create(scope)).to.throw(message); }); }); diff --git a/test/cache/caches/Cache.spec.ts b/test/cache/caches/Cache.spec.ts index 55dd6cd..f516177 100644 --- a/test/cache/caches/Cache.spec.ts +++ b/test/cache/caches/Cache.spec.ts @@ -10,6 +10,7 @@ import { HashService } from '../../../lib/utils/hash'; describe('@cache Cache', () => { + const key = 'key'; let hashStub: sinon.SinonStubbedInstance; let storageStub: sinon.SinonStubbedInstance; let expirationStub: sinon.SinonStubbedInstance; @@ -20,6 +21,8 @@ describe('@cache Cache', () => { storageStub = sinon.createStubInstance(MemoryStorage); expirationStub = sinon.createStubInstance(AbsoluteExpiration); + hashStub.calculate.returns(key); + service = new Cache(storageStub, expirationStub, hashStub); }); @@ -38,18 +41,12 @@ describe('@cache Cache', () => { }); it('should call storage.set to store given hashed key and data', async () => { - const key = 'key'; - hashStub.calculate.returns(key); - await service.set(['key'], 'value'); expect(storageStub.set.calledOnce).to.be.true; }); it('should call storage.set to store given hashed key with correct parameters', async () => { - const key = 'key'; - hashStub.calculate.returns(key); - await service.set(['key'], 'value'); expect(storageStub.set.calledWithExactly(key, 'value')).to.be.true; @@ -65,9 +62,6 @@ describe('@cache Cache', () => { }); it('should call expiration.add with correct parameters', async () => { - const key = 'key'; - hashStub.calculate.returns(key); - await service.set(['key'], 'value'); expect(expirationStub.add.calledWith(key, sinon.match.func)).to.be.true; @@ -76,9 +70,6 @@ describe('@cache Cache', () => { describe('function passed to expiration', () => { it('should call storage.delete', async () => { - const key = 'key'; - hashStub.calculate.returns(key); - await service.set(['key'], 'value'); const callback = expirationStub.add.firstCall.args[1]; @@ -89,9 +80,6 @@ describe('@cache Cache', () => { }); it('should call storage.delte with correct parameters', async () => { - const key = 'key'; - hashStub.calculate.returns(key); - await service.set(['key'], 'value'); const callback = expirationStub.add.firstCall.args[1]; @@ -114,18 +102,12 @@ describe('@cache Cache', () => { }); it('should call storage has to check if key exists', async () => { - const key = 'key'; - hashStub.calculate.returns(key); - await service.has(['key']); expect(storageStub.has.calledWith(key)).to.be.true; }); it('should call storage has with correct parameters', async () => { - const key = 'key'; - hashStub.calculate.returns(key); - await service.has(['key']); expect(storageStub.has.calledWith(key)).to.be.true; @@ -142,36 +124,24 @@ describe('@cache Cache', () => { }); it('should call expiration.add to update cache expiration', async () => { - const key = 'key'; - hashStub.calculate.returns(key); - await service.get(['key']); expect(expirationStub.add.calledOnce).to.be.true; }); it('should call expiration.add with correct parameters', async () => { - const key = 'key'; - hashStub.calculate.returns(key); - await service.get(['key']); expect(expirationStub.add.calledWith(key, sinon.match.func)).to.be.true; }); it('should call storage.get to obtain cached value', async () => { - const key = 'key'; - hashStub.calculate.returns(key); - await service.get(['key']); expect(storageStub.get.calledOnce).to.be.true; }); it('should call storage.get with correct parameters', async () => { - const key = 'key'; - hashStub.calculate.returns(key); - await service.get(['key']); expect(storageStub.get.calledWith(key)).to.be.true; @@ -179,27 +149,21 @@ describe('@cache Cache', () => { describe('function passed to expiration', () => { - it('should call storage.delete once', async () => { - const key = 'key'; - hashStub.calculate.returns(key); + let callback: (key: string) => unknown; + beforeEach(async () => { await service.get(['key']); - const callback = expirationStub.add.firstCall.args[1]; + callback = expirationStub.add.firstCall.args[1]; + }); + it('should call storage.delete once', async () => { await callback(key); expect(storageStub.delete.calledOnce).to.be.true; }); it('should call storage.delete with correct parameters', async () => { - const key = 'key'; - hashStub.calculate.returns(key); - - await service.get(['key']); - - const callback = expirationStub.add.firstCall.args[1]; - await callback(key); expect(storageStub.delete.calledWith(key)).to.be.true; diff --git a/test/cache/caches/factory.spec.ts b/test/cache/caches/factory.spec.ts index 034f75f..a89a23f 100644 --- a/test/cache/caches/factory.spec.ts +++ b/test/cache/caches/factory.spec.ts @@ -5,18 +5,29 @@ import { Cache } from '../../../lib/cache/caches/Cache'; import { CacheFactory } from '../../../lib/cache/caches/factory'; import { ExpirationFactory } from '../../../lib/cache/expirations/factory'; import { StorageFactory } from '../../../lib/cache/storages/factory'; +import { HashService } from '../../../lib/utils/hash'; describe('@cache CacheFactory', () => { + const expiration = 'absolute'; + const storage = 'memory'; let expirationFactoryStub: sinon.SinonStubbedInstance; let storageFactoryStub: sinon.SinonStubbedInstance; + let hashStub: sinon.SinonStubbedInstance; let service: CacheFactory; beforeEach(() => { expirationFactoryStub = sinon.createStubInstance(ExpirationFactory); storageFactoryStub = sinon.createStubInstance(StorageFactory); - - service = new CacheFactory(undefined, expirationFactoryStub as any, storageFactoryStub as any); + hashStub = sinon.createStubInstance(HashService); + + service = new CacheFactory( + hashStub, + expirationFactoryStub as any, + storageFactoryStub as any, + expiration, + storage, + ); }); describe('constructor', () => { @@ -33,12 +44,24 @@ describe('@cache CacheFactory', () => { expect(expirationFactoryStub.create.calledOnce).to.be.true; }); + it('should call expirationFactory.create with correct parameters', () => { + service.create(); + + expect(expirationFactoryStub.create.calledWith(expiration)).to.be.true; + }); + it('should call storageFactory.create to obtain storage', () => { service.create(); expect(storageFactoryStub.create.calledOnce).to.be.true; }); + it('should call storageFactory.create with correct parameters', () => { + service.create(); + + expect(storageFactoryStub.create.calledWith(storage)).to.be.true; + }); + it('should return instance of Cahce', () => { expect(service.create()).to.be.instanceOf(Cache); }); diff --git a/test/cache/expirations/AbsoluteExpiration.spec.ts b/test/cache/expirations/AbsoluteExpiration.spec.ts index ecef181..9cb3e2d 100644 --- a/test/cache/expirations/AbsoluteExpiration.spec.ts +++ b/test/cache/expirations/AbsoluteExpiration.spec.ts @@ -31,13 +31,24 @@ describe('@cache AbsoluteExpiration', () => { expect(spy.calledOnce).to.be.true; }); + it('should call clearCallback with given key', async () => { + const spy = sinon.spy(); + const key = 'key'; + + service.add('key', spy); + + await delay(timeout); + + expect(spy.calledWith(key)).to.be.true; + }); + }); describe('existing key', () => { it('should not update expiration', async () => { const spy = sinon.spy(); - service['expirations'].add('key'); + service.add('key', () => { }); service.add('key', spy); @@ -47,18 +58,32 @@ describe('@cache AbsoluteExpiration', () => { }); it('should call initial callback', async () => { - const firstSpy = sinon.spy(); - const secondSpy = sinon.spy(); + const spy = sinon.spy(); + + service.add('key', spy); + + await delay(timeout / 2); + + service.add('key', () => { }); + + await delay(timeout / 2); + + expect(spy.calledOnce).to.be.true; + }); + + it('should call initial callback with given key', async () => { + const spy = sinon.spy(); + const key = 'key'; - service.add('key', firstSpy); + service.add(key, spy); await delay(timeout / 2); - service.add('key', secondSpy); + service.add(key, () => { }); await delay(timeout / 2); - expect(firstSpy.calledOnce).to.be.true; + expect(spy.calledWith(key)).to.be.true; }); }); diff --git a/test/cache/expirations/SlidingExpiration.spec.ts b/test/cache/expirations/SlidingExpiration.spec.ts index f2209b7..e17e5bc 100644 --- a/test/cache/expirations/SlidingExpiration.spec.ts +++ b/test/cache/expirations/SlidingExpiration.spec.ts @@ -30,15 +30,22 @@ describe('@cache SlidingExpiration', () => { expect(spy.calledOnce).to.be.true; }); + it('should call clearCallback with given key', async () => { + const spy = sinon.spy(); + + service.add('key', spy); + await delay(timeout); + + expect(spy.calledWith('key')).to.be.true; + }); + }); describe('existing key', () => { - beforeEach(() => service['expirations'].set('key', setTimeout(() => { }, timeout) as any)); - it('should remove existing expiration', async () => { const expirationSpy = sinon.spy(); - service['expirations'].set('key', setTimeout(() => expirationSpy(), timeout) as any); + service.add('key', expirationSpy); service.add('key', () => { }); @@ -49,6 +56,7 @@ describe('@cache SlidingExpiration', () => { it('should call clearCallback after timeout ms', async () => { const spy = sinon.spy(); + service.add('key', () => { }); service.add('key', spy); @@ -57,6 +65,17 @@ describe('@cache SlidingExpiration', () => { expect(spy.calledOnce).to.be.true; }); + it('should call clearCallback with given key', async () => { + const spy = sinon.spy(); + service.add('key', () => { }); + + service.add('key', spy); + + await delay(timeout); + + expect(spy.calledWith('key')).to.be.true; + }); + }); }); diff --git a/test/cache/expirations/factory.spec.ts b/test/cache/expirations/factory.spec.ts index f34cb1e..0b23a5d 100644 --- a/test/cache/expirations/factory.spec.ts +++ b/test/cache/expirations/factory.spec.ts @@ -6,29 +6,32 @@ import { SlidingExpiration } from '../../../lib/cache/expirations/SlidingExpirat describe('@cache ExpirationFactory', () => { + const timeout = 42; + let service: ExpirationFactory; + + beforeEach(() => service = new ExpirationFactory(timeout)); + describe('constructor', () => { - it('should create', () => { - expect(new ExpirationFactory(3, 'absolute')).to.be.instanceOf(ExpirationFactory); - }); + it('should create', () => expect(service).to.be.instanceOf(ExpirationFactory)); }); describe('create', () => { it('should create instance of AbsoluteExpiration if expiration is "absolute"', () => { - expect(new ExpirationFactory(3, 'absolute').create()).to.be.instanceOf(AbsoluteExpiration); + expect(service.create('absolute')).to.be.instanceOf(AbsoluteExpiration); }); it('should create instance of SlidingExpiration if expiration is "sliding"', () => { - expect(new ExpirationFactory(3, 'sliding').create()).to.be.instanceOf(SlidingExpiration); + expect(service.create('sliding')).to.be.instanceOf(SlidingExpiration); }); it('should throw error if expiration parameter is not valid', () => { const expiration = '123' as any; const message = `@cache Expiration type is not supported: ${expiration}.`; - expect(() => new ExpirationFactory(3, expiration).create()).to.throw(message); + expect(() => service.create(expiration)).to.throw(message); }); }); diff --git a/test/cache/storages/factory.spec.ts b/test/cache/storages/factory.spec.ts index cfa44fa..fa70a78 100644 --- a/test/cache/storages/factory.spec.ts +++ b/test/cache/storages/factory.spec.ts @@ -5,24 +5,27 @@ import { MemoryStorage } from '../../../lib/cache/storages/MemoryStorage'; describe('@cache StorageFactory', () => { + const limit = 42; + let service: StorageFactory; + + beforeEach(() => service = new StorageFactory(limit)); + describe('constructor', () => { - it('should create', () => { - expect(new StorageFactory(3, 'memory')).to.be.instanceOf(StorageFactory); - }); + it('should create', () => expect(service).to.be.instanceOf(StorageFactory)); }); describe('create', () => { it('should return instance of StorageFactory if storage is "memory"', () => { - expect(new StorageFactory(3, 'memory').create()).to.be.instanceOf(MemoryStorage); + expect(service.create('memory')).to.be.instanceOf(MemoryStorage); }); it('should throw error if storate is not an valid storage', () => { - const storage = 'memory3'; + const storage = 'memory3' as any; const message = `@cache Storage type is not supported: ${storage}.`; - expect(() => new StorageFactory(3, 'memory3' as any).create()).to.throw(message); + expect(() => service.create(storage)).to.throw(message); }); }); diff --git a/test/uitls/hash.spec.ts b/test/uitls/hash.spec.ts index 8bc4da6..951144b 100644 --- a/test/uitls/hash.spec.ts +++ b/test/uitls/hash.spec.ts @@ -52,6 +52,20 @@ describe('HashService', () => { expect(service.calculate(firstObject)).not.equals(service.calculate(secondObject)); }); + it('should return same keys for objects what are deep equals', () => { + const firstArray = [{ a: 1 }]; + const secondArray = [{ a: 1 }]; + + expect(service.calculate(firstArray)).to.equals(service.calculate(secondArray)); + }); + + it('should return different keys for objects with different keys at more deep level', () => { + const firstArray = [{ a: 1 }]; + const secondArray = [{ b: 1 }]; + + expect(service.calculate(firstArray)).not.equals(service.calculate(secondArray)); + }); + }); }); diff --git a/test/utils.ts b/test/utils.ts index c264458..850f631 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -11,9 +11,3 @@ export function repeat(func: () => void, count: number) { return results; } - -export async function executionTime(func: () => Promise): Promise { - const begin = Date.now(); - await func(); - return Date.now() - begin; -}