├── .coveralls.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── demo ├── demo.ts └── tsconfig.json ├── dist ├── memoize.d.ts ├── memoize.js └── memoize.js.map ├── jsr.json ├── package-lock.json ├── package.json ├── qodana.yaml ├── src └── memoize.ts ├── test ├── test.ts └── tsconfig.json └── tsconfig.json /.coveralls.yml: -------------------------------------------------------------------------------- 1 | repo_token: FDacC2hbfUbTvJeQeWITl0uJw4xiFF5Qa 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # don't ever lint node_modules 2 | node_modules 3 | # don't lint build output (make sure it's set to your correct build folder name) 4 | dist 5 | # don't lint nyc coverage output 6 | coverage 7 | # don't lint demo 8 | demo 9 | # don't lint configuration files 10 | .eslintrc.cjs 11 | karma.conf.cjs 12 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | plugins: ["@typescript-eslint"], 5 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 6 | rules: { 7 | "@typescript-eslint/no-explicit-any": "off", 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: ["push", "pull_request"] 4 | 5 | jobs: 6 | test: 7 | name: Run units tests 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - name: Setup Node.js 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: "16" 17 | 18 | - name: Install 19 | run: npm ci 20 | 21 | - name: Lint 22 | run: npm run lint 23 | 24 | - name: Test with Coveralls 25 | run: npm run test:coverage 26 | 27 | - name: Coveralls 28 | uses: coverallsapp/github-action@v2 29 | with: 30 | github-token: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.idea 3 | /test/test.js 4 | coverage/ 5 | /demo/demo.js 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged 5 | npm run lint -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "arrowParens": "always" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Edwin Martin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Test](https://github.com/edwinm/memoize-cache-decorator/actions/workflows/test.yml/badge.svg)](https://github.com/edwinm/memoize-cache-decorator/actions/workflows/test.yml) [![Coverage Status](https://coveralls.io/repos/github/edwinm/memoize-cache-decorator/badge.svg?branch=master)](https://coveralls.io/github/edwinm/memoize-cache-decorator?branch=master) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=edwinm_memoize-cache-decorator&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=edwinm_memoize-cache-decorator) [![Socket Badge](https://socket.dev/api/badge/npm/package/memoize-cache-decorator)](https://socket.dev/npm/package/memoize-cache-decorator) [![Snyk test results](https://snyk.io/test/github/edwinm/memoize-cache-decorator/badge.svg)](https://snyk.io/test/github/edwinm/memoize-cache-decorator) [![CodeFactor](https://www.codefactor.io/repository/github/edwinm/memoize-cache-decorator/badge)](https://www.codefactor.io/repository/github/edwinm/memoize-cache-decorator) [![npm version](https://badge.fury.io/js/memoize-cache-decorator.svg)](https://www.npmjs.com/package/memoize-cache-decorator) [![GitHub](https://img.shields.io/github/license/edwinm/memoize-cache-decorator.svg)](https://github.com/edwinm/memoize-cache-decorator/blob/master/LICENSE) 2 | 3 | # memoize-cache-decorator 4 | 5 | > Add the memoize decorator to your class methods to have the results cached 6 | > for future calls. 7 | 8 | This is an easy, clean and reliable way to prevent repeating unnecessary resource intensive 9 | tasks and improve the performance of your code. 10 | 11 | Examples of resource intensive tasks that can be cached are: 12 | heavy calculations, network requests, file system operations and database operations. 13 | 14 | With support for: 15 | 16 | - Both Node.js and browsers 17 | - Methods and getter functions 18 | - Async functions 19 | - Static functions 20 | - Cache expiration 21 | - Clearing the cache on two levels 22 | - Custom resolver function 23 | - TypeScript 24 | 25 | Since TypeScript decorators are used, the source has to be TypeScript. 26 | Also, decorators can only be used for class methods and getters. 27 | Plain JavaScript decorators are planned for the future. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | npm install --save-dev memoize-cache-decorator 33 | ``` 34 | 35 | ## Deno 36 | 37 | In Deno, use: 38 | 39 | ```js 40 | import { memoize } from "jsr:@edwinm/memoize-decorator@2"; 41 | ``` 42 | 43 | See also [@edwinm/memoize-decorator@2 on JSR](https://jsr.io/@edwinm/memoize-decorator@2.0.0). 44 | 45 | ## Usage 46 | 47 | ```ts 48 | class Example { 49 | @memoize() 50 | myFunction() { 51 | // … 52 | } 53 | } 54 | ``` 55 | 56 | Simple example: 57 | 58 | ```ts 59 | import { memoize } from "memoize-cache-decorator"; 60 | 61 | class Example { 62 | @memoize() 63 | myFunction() { 64 | // Heavy function getting data from disk, database or a server 65 | // For this example we return a random number 66 | return Math.random(); 67 | } 68 | } 69 | 70 | const example = new Example(); 71 | 72 | // Instead of a different random number for each call, the first, 73 | // cached number is returned each time. 74 | 75 | console.log(example.myFunction()); 76 | //=> 0.7649863352328616 77 | console.log(example.myFunction()); 78 | //=> 0.7649863352328616 79 | console.log(example.myFunction()); 80 | //=> 0.7649863352328616 81 | ``` 82 | 83 | In practice, the function would probably do a fetch, read a file or do a database call. 84 | Here's another, more realistic example: 85 | 86 | ```ts 87 | import { memoize } from "memoize-cache-decorator"; 88 | 89 | class Example { 90 | @memoize({ ttl: 5 * 60 * 1000 }) 91 | async getData(path: string) { 92 | try { 93 | const response = await fetch(path, { 94 | headers: { 95 | Accept: "application/json", 96 | }, 97 | }); 98 | return response.json(); 99 | } catch (error) { 100 | console.error( 101 | `While fetching ${path}, the following error occured`, 102 | error 103 | ); 104 | return error; 105 | } 106 | } 107 | } 108 | 109 | const example = new Example(); 110 | 111 | const data = await example.getData("/path-to-data"); 112 | ``` 113 | 114 | Now, every time `getData` is called with this path, it returns the data without 115 | fetching it over the network every time. 116 | It will do a fetch over the network again after 5 minutes or when `clearFunction(example.getData)` is called. 117 | 118 | ## API 119 | 120 | ### @memoize(config) 121 | 122 | Memoize the class method or getter below it. 123 | 124 | #### Type: \[optional\] `Config` 125 | 126 | ```ts 127 | interface Config { 128 | resolver?: (...args: any[]) => string | number; 129 | ttl?: number; 130 | } 131 | ``` 132 | 133 | ##### resolver \[optional\] function 134 | 135 | Function to convert function arguments to a unique key. 136 | 137 | Without a `resolver` function, the arguments are converted to a key with 138 | a save version of JSON stringify. 139 | This works fine when the arguments are primitives like strings, numbers and booleans. 140 | This is undesirable when passing in objects with irrelevant data, like DOM elements. 141 | Use `resolver` to provide a function to calculate a unique key yourself. 142 | 143 | Example: 144 | 145 | ```ts 146 | import { memoize } from "memoize-cache-decorator"; 147 | 148 | class Example { 149 | @memoize({ resolver: (el) => el.id }) 150 | myFunction(el) { 151 | // el is some complex object 152 | return fetch(`/rest/example/${el.id}`); 153 | } 154 | } 155 | ``` 156 | 157 | ##### ttl \[optional\] number 158 | 159 | With ttl (time to live), the cache will never live longer than 160 | the given number of milliseconds. 161 | 162 | ```ts 163 | import { memoize } from "memoize-cache-decorator"; 164 | 165 | class Example { 166 | // The result is cached for at most 10 minutes 167 | @memoize({ ttl: 10 * 60 * 1000 }) 168 | getComments() { 169 | return fetch(`/rest/example/comments`); 170 | } 171 | } 172 | ``` 173 | 174 | ### clear(instance, fn, arguments) 175 | 176 | ##### instance object 177 | 178 | ##### fn function 179 | 180 | ##### arguments \[optional\] arguments of fn 181 | 182 | Clears the cache belonging to a memoized function for a specific instance and specific arguments. 183 | 184 | Call `clear` with as arguments the instance, memoized function and memoized function arguments. 185 | 186 | ```ts 187 | import { memoize, clear } from "memoize-cache-decorator"; 188 | 189 | class Example { 190 | @memoize() 191 | getDirection(direction: string) { 192 | return fetch(`/rest/example/direction/${direction}`); 193 | } 194 | 195 | southUpdated() { 196 | // The next time getComments("south") is called in this instance, data will 197 | // be fetched from the server again. But only for this instance. 198 | clear(this, this.getDirection, "south"); 199 | } 200 | } 201 | ``` 202 | 203 | ### clearFunction(fn) 204 | 205 | ##### fn function 206 | 207 | Clears all caches belonging to a memoized function. 208 | All caches are cleared for the given function for all instances and for all arguments. 209 | 210 | Call `clearFunction` with as argument the memoized function. 211 | 212 | ```ts 213 | import { memoize, clearFunction } from "memoize-cache-decorator"; 214 | 215 | class Example { 216 | @memoize() 217 | getComments() { 218 | return fetch(`/rest/example/comments`); 219 | } 220 | 221 | commentsUpdated() { 222 | // The next time getComments() is called, comments will 223 | // be fetched from the server again. 224 | clearFunction(this.getComments); 225 | } 226 | } 227 | ``` 228 | 229 | ## Tests 230 | 231 | ```shell 232 | npm test 233 | ``` 234 | 235 | ## Related 236 | 237 | - [Wikipedia on Memoization](https://en.wikipedia.org/wiki/Memoization) 238 | 239 | ## License 240 | 241 | MIT © 2023 [Edwin Martin](https://bitstorm.org/) 242 | -------------------------------------------------------------------------------- /demo/demo.ts: -------------------------------------------------------------------------------- 1 | import { memoize } from "../dist/memoize.js"; 2 | 3 | class Demo { 4 | @memoize() 5 | myFunction() { 6 | return Math.random(); 7 | } 8 | } 9 | 10 | const example = new Demo(); 11 | 12 | console.log(example.myFunction()); 13 | console.log(example.myFunction()); 14 | console.log(example.myFunction()); 15 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "emitDecoratorMetadata": true, 5 | "experimentalDecorators": true, 6 | "lib": ["es6"], 7 | "module": "es6", 8 | "target": "es6", 9 | "moduleResolution": "node" 10 | }, 11 | "files": ["demo.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /dist/memoize.d.ts: -------------------------------------------------------------------------------- 1 | /**! 2 | @preserve memoize-decorator 2.0.0 3 | @copyright 2023 Edwin Martin 4 | @license MIT 5 | */ 6 | export interface Config { 7 | resolver?: (...args: any[]) => string | number; 8 | ttl?: number; 9 | } 10 | export declare function memoize(config?: Config): (target: object, propertyName: string, propertyDescriptor: PropertyDescriptor) => PropertyDescriptor; 11 | export declare function clearFunction(fn: (...args: any) => any): void; 12 | export declare function clear(instance: object, fn: (...args: any) => any, ...args: any[]): void; 13 | -------------------------------------------------------------------------------- /dist/memoize.js: -------------------------------------------------------------------------------- 1 | /**! 2 | @preserve memoize-decorator 2.0.0 3 | @copyright 2023 Edwin Martin 4 | @license MIT 5 | */ 6 | import stringify from "json-stringify-safe"; 7 | // cacheMap maps every function to a map with caches 8 | const cacheMap = new Map(); 9 | // instanceMap maps every instance to a unique id 10 | const instanceMap = new Map(); 11 | let instanceIdCounter = 1; 12 | export function memoize(config = {}) { 13 | return function (target, propertyName, propertyDescriptor) { 14 | const prop = propertyDescriptor.value ? "value" : "get"; 15 | const originalFunction = propertyDescriptor[prop]; 16 | // functionCacheMap maps every instance plus arguments to a CacheObject 17 | const functionCacheMap = new Map(); 18 | propertyDescriptor[prop] = function (...args) { 19 | let instanceId = instanceMap.get(this); 20 | if (!instanceId) { 21 | instanceId = ++instanceIdCounter; 22 | instanceMap.set(this, instanceId); 23 | } 24 | const key = config.resolver 25 | ? config.resolver.apply(this, args) 26 | : stringify(args); 27 | const cacheKey = `${instanceId}:${key}`; 28 | if (functionCacheMap.has(cacheKey)) { 29 | const { result, timeout } = functionCacheMap.get(cacheKey); 30 | if (!config.ttl || timeout > Date.now()) { 31 | return result; 32 | } 33 | } 34 | const newResult = originalFunction.apply(this, args); 35 | functionCacheMap.set(cacheKey, { 36 | result: newResult, 37 | timeout: config.ttl ? Date.now() + config.ttl : Infinity, 38 | }); 39 | return newResult; 40 | }; 41 | cacheMap.set(propertyDescriptor[prop], { 42 | functionCacheMap, 43 | resolver: config.resolver, 44 | }); 45 | return propertyDescriptor; 46 | }; 47 | } 48 | // Clear all caches for a specific function for all instances 49 | export function clearFunction(fn) { 50 | const functionCache = cacheMap.get(fn); 51 | if (functionCache) { 52 | functionCache.functionCacheMap.clear(); 53 | } 54 | } 55 | // Clear the cache for an instance and for specific arguments 56 | export function clear(instance, fn, ...args) { 57 | const functionCache = cacheMap.get(fn); 58 | const instanceId = instanceMap.get(instance); 59 | if (!functionCache || !instanceId) { 60 | return; 61 | } 62 | const key = functionCache.resolver 63 | ? functionCache.resolver.apply(instance, args) 64 | : stringify(args); 65 | const cacheKey = `${instanceId}:${key}`; 66 | functionCache.functionCacheMap.delete(cacheKey); 67 | } 68 | //# sourceMappingURL=memoize.js.map -------------------------------------------------------------------------------- /dist/memoize.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"memoize.js","sourceRoot":"","sources":["../src/memoize.ts"],"names":[],"mappings":"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"} -------------------------------------------------------------------------------- /jsr.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@edwinm/memoize-decorator", 3 | "version": "2.0.0", 4 | "exports": "./src/memoize.ts" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "memoize-cache-decorator", 3 | "version": "2.0.1", 4 | "description": "Cache the result of a method or getter for better performance. Supports timeout and clearing the cache.", 5 | "type": "module", 6 | "license": "MIT", 7 | "main": "dist/memoize.js", 8 | "types": "dist/memoize.d.ts", 9 | "sideEffects": false, 10 | "scripts": { 11 | "prepare": "husky install", 12 | "build": "tsc", 13 | "test": "tsc -p test/tsconfig.json -outDir test && node --experimental-vm-modules node_modules/jest/bin/jest.js test/test.js", 14 | "test:coverage": "tsc -p test/tsconfig.json -outDir test && node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage test/test.js && coveralls < coverage/lcov.info", 15 | "demo": "tsc -p demo/tsconfig.json -outDir demo && node demo/demo.js", 16 | "prettier": "prettier --config .prettierrc.json src/*.ts **/*.{json,cjs,yml} --write", 17 | "lint": "eslint . --ext .ts" 18 | }, 19 | "keywords": [ 20 | "memoize", 21 | "decorator", 22 | "cache", 23 | "clear", 24 | "reset", 25 | "timeout", 26 | "ttl", 27 | "expire", 28 | "typescript" 29 | ], 30 | "author": { 31 | "name": "Edwin Martin", 32 | "email": "edwin@bitstorm.org", 33 | "url": "https://bitstorm.org/" 34 | }, 35 | "files": [ 36 | "src/memoize.ts", 37 | "dist/memoize.js", 38 | "dist/memoize.d.ts", 39 | "dist/memoize.js.map" 40 | ], 41 | "repository": { 42 | "type": "git", 43 | "url": "https://github.com/edwinm/memoize-cache-decorator.git" 44 | }, 45 | "devDependencies": { 46 | "@types/jest": "^29.5.2", 47 | "@types/json-stringify-safe": "^5.0.0", 48 | "@types/node": "^20.4.0", 49 | "@typescript-eslint/eslint-plugin": "^5.61.0", 50 | "@typescript-eslint/parser": "^5.61.0", 51 | "coveralls": "^3.1.1", 52 | "eslint": "^8.44.0", 53 | "eslint-config-prettier": "^8.8.0", 54 | "husky": "^8.0.3", 55 | "jest": "^29.5.0", 56 | "json-stringify-safe": "^5.0.1", 57 | "prettier": "^2.8.8", 58 | "pretty-quick": "^3.1.3", 59 | "typescript": "^5.1.6" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /qodana.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | #-------------------------------------------------------------------------------# 3 | # Qodana analysis is configured by qodana.yaml file # 4 | # https://www.jetbrains.com/help/qodana/qodana-yaml.html # 5 | #-------------------------------------------------------------------------------# 6 | version: "1.0" 7 | 8 | #Specify inspection profile for code analysis 9 | profile: 10 | name: qodana.starter 11 | 12 | #Enable inspections 13 | #include: 14 | # - name: 15 | 16 | #Disable inspections 17 | #exclude: 18 | # - name: 19 | # paths: 20 | # - 21 | 22 | #The following options are only applied in CI/CD environment 23 | #These options are ignored during local run 24 | 25 | #Execute shell command before Qodana execution 26 | #bootstrap: sh ./prepare-qodana.sh 27 | 28 | #Install IDE plugins before Qodana execution 29 | #plugins: 30 | # - id: #(plugin id can be found at https://plugins.jetbrains.com) 31 | 32 | #Specify Qodana linter for analysis 33 | linter: jetbrains/qodana-js:latest 34 | -------------------------------------------------------------------------------- /src/memoize.ts: -------------------------------------------------------------------------------- 1 | /**! 2 | @preserve memoize-decorator 2.0.1 3 | @copyright 2023 Edwin Martin 4 | @license MIT 5 | */ 6 | 7 | import stringify from "json-stringify-safe"; 8 | 9 | // cacheMap maps every function to a map with caches 10 | const cacheMap = new Map< 11 | (...args: any) => any, 12 | { 13 | functionCacheMap: Map; 14 | resolver?: (...args: any[]) => string | number; 15 | } 16 | >(); 17 | // instanceMap maps every instance to a unique id 18 | const instanceMap = new Map(); 19 | let instanceIdCounter = 1; 20 | 21 | export interface Config { 22 | resolver?: (...args: any[]) => string | number; 23 | ttl?: number; 24 | } 25 | 26 | interface CacheObject { 27 | result: any; 28 | timeout: number; 29 | } 30 | 31 | export function memoize( 32 | config: Config = {} 33 | ): ( 34 | target: object, 35 | propertyName: string, 36 | propertyDescriptor: PropertyDescriptor 37 | ) => PropertyDescriptor { 38 | return function ( 39 | target: object, 40 | propertyName: string, 41 | propertyDescriptor: PropertyDescriptor 42 | ): PropertyDescriptor { 43 | const prop = propertyDescriptor.value ? "value" : "get"; 44 | 45 | const originalFunction = propertyDescriptor[prop]; 46 | 47 | // functionCacheMap maps every instance plus arguments to a CacheObject 48 | const functionCacheMap = new Map(); 49 | 50 | propertyDescriptor[prop] = function (...args: any[]) { 51 | let instanceId = instanceMap.get(this); 52 | if (!instanceId) { 53 | instanceId = ++instanceIdCounter; 54 | instanceMap.set(this, instanceId); 55 | } 56 | 57 | const key = config.resolver 58 | ? config.resolver.apply(this, args) 59 | : stringify(args); 60 | 61 | const cacheKey = `${instanceId}:${key}`; 62 | 63 | if (functionCacheMap.has(cacheKey)) { 64 | const { result, timeout } = functionCacheMap.get(cacheKey)!; 65 | if (!config.ttl || timeout > Date.now()) { 66 | return result; 67 | } 68 | } 69 | const newResult = originalFunction.apply(this, args); 70 | functionCacheMap.set(cacheKey, { 71 | result: newResult, 72 | timeout: config.ttl ? Date.now() + config.ttl : Infinity, 73 | }); 74 | return newResult; 75 | }; 76 | 77 | cacheMap.set(propertyDescriptor[prop], { 78 | functionCacheMap, 79 | resolver: config.resolver, 80 | }); 81 | 82 | return propertyDescriptor; 83 | }; 84 | } 85 | 86 | // Clear all caches for a specific function for all instances 87 | export function clearFunction(fn: (...args: any) => any) { 88 | const functionCache = cacheMap.get(fn); 89 | 90 | if (functionCache) { 91 | functionCache.functionCacheMap.clear(); 92 | } 93 | } 94 | 95 | // Clear the cache for an instance and for specific arguments 96 | export function clear( 97 | instance: object, 98 | fn: (...args: any) => any, 99 | ...args: any[] 100 | ) { 101 | const functionCache = cacheMap.get(fn); 102 | const instanceId = instanceMap.get(instance); 103 | if (!functionCache || !instanceId) { 104 | return; 105 | } 106 | 107 | const key = functionCache.resolver 108 | ? functionCache.resolver.apply(instance, args) 109 | : stringify(args); 110 | 111 | const cacheKey = `${instanceId}:${key}`; 112 | 113 | functionCache.functionCacheMap.delete(cacheKey); 114 | } 115 | -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | import { memoize, clearFunction, clear } from "../"; 2 | 3 | interface IObject { 4 | id: number; 5 | irrelevant: string; 6 | } 7 | 8 | class Example { 9 | a: number; 10 | static s: number; 11 | 12 | constructor() { 13 | this.a = 10; 14 | } 15 | 16 | @memoize() 17 | getProjects(id: number, direction: string) { 18 | return `getProjects(${id}, "${direction}"); a=${this.a}`; 19 | } 20 | 21 | @memoize() 22 | getA(id: number, direction: string) { 23 | return `getA(${id}, "${direction}"); a=${this.a}`; 24 | } 25 | 26 | @memoize({ resolver: (el: IObject) => el.id }) 27 | setElement(el: IObject) { 28 | return `setElement(el); el.id=${el.id}; el.irrelevant=${el.irrelevant}`; 29 | } 30 | 31 | @memoize() 32 | get aa() { 33 | return `get a; a=${this.a}`; 34 | } 35 | 36 | @memoize({ ttl: 40 }) 37 | expiring40() { 38 | return `a=${this.a}`; 39 | } 40 | 41 | @memoize({ ttl: 60 }) 42 | expiring60() { 43 | return `a=${this.a}`; 44 | } 45 | 46 | @memoize({ ttl: 40 }) 47 | expiringArg(str: string) { 48 | return `arg=${this.a}-${str}`; 49 | } 50 | 51 | @memoize({ ttl: 30 }) 52 | expiring30() { 53 | return `a=${this.a}`; 54 | } 55 | 56 | @memoize({ ttl: 30 }) 57 | expiring30Arg(str: string) { 58 | return `arg=${this.a}-${str}`; 59 | } 60 | 61 | @memoize({ ttl: 30 }) 62 | async expiring20Async() { 63 | return new Promise((resolve) => { 64 | setTimeout(() => { 65 | resolve(`a=${this.a}`); 66 | }, 5); 67 | }); 68 | } 69 | 70 | @memoize({ ttl: 30 }) 71 | static expiring30Static(str: string) { 72 | return `arg=${this.s}-${str}`; 73 | } 74 | } 75 | 76 | let example; 77 | 78 | beforeEach(() => { 79 | example = new Example(); 80 | }); 81 | 82 | it("Test function call", () => { 83 | expect(example.getProjects(20, "south")).toEqual( 84 | 'getProjects(20, "south"); a=10' 85 | ); 86 | }); 87 | 88 | it("Test memoize", () => { 89 | expect(example.getA(20, "south")).toEqual('getA(20, "south"); a=10'); 90 | example.a++; 91 | expect(example.getA(20, "south")).toEqual('getA(20, "south"); a=10'); 92 | example.a++; 93 | expect(example.getA(20, "south")).toEqual('getA(20, "south"); a=10'); 94 | example.a++; 95 | expect(example.getA(21, "south")).toEqual('getA(21, "south"); a=13'); 96 | expect(example.getA(20, "south")).toEqual('getA(20, "south"); a=10'); 97 | }); 98 | 99 | it("Test getter", () => { 100 | expect(example.aa).toEqual("get a; a=10"); 101 | example.a++; 102 | expect(example.aa).toEqual("get a; a=10"); 103 | }); 104 | 105 | it("Test resolver", () => { 106 | let div: IObject; 107 | div = { id: 20, irrelevant: "Shinano" }; 108 | expect(example.setElement(div)).toEqual( 109 | "setElement(el); el.id=20; el.irrelevant=Shinano" 110 | ); 111 | div = { id: 20, irrelevant: "Tone" }; 112 | expect(example.setElement(div)).toEqual( 113 | "setElement(el); el.id=20; el.irrelevant=Shinano" 114 | ); 115 | div = { id: 20, irrelevant: "Ishikari" }; 116 | expect(example.setElement(div)).toEqual( 117 | "setElement(el); el.id=20; el.irrelevant=Shinano" 118 | ); 119 | div = { id: 21, irrelevant: "Teshio" }; 120 | expect(example.setElement(div)).toEqual( 121 | "setElement(el); el.id=21; el.irrelevant=Teshio" 122 | ); 123 | }); 124 | 125 | it("Test ttl", async () => { 126 | expect(example.expiring40()).toEqual("a=10"); 127 | expect(example.expiring60()).toEqual("a=10"); 128 | example.a++; 129 | expect(example.expiring40()).toEqual("a=10"); 130 | expect(example.expiring60()).toEqual("a=10"); 131 | await new Promise((resolve) => setTimeout(resolve, 20)); 132 | example.a++; 133 | expect(example.expiring40()).toEqual("a=10"); 134 | expect(example.expiring60()).toEqual("a=10"); 135 | example.a++; 136 | expect(example.expiring40()).toEqual("a=10"); 137 | expect(example.expiring60()).toEqual("a=10"); 138 | await new Promise((resolve) => setTimeout(resolve, 30)); 139 | example.a++; 140 | expect(example.expiring40()).toEqual("a=14"); 141 | expect(example.expiring60()).toEqual("a=10"); 142 | example.a++; 143 | expect(example.expiring40()).toEqual("a=14"); 144 | expect(example.expiring60()).toEqual("a=10"); 145 | await new Promise((resolve) => setTimeout(resolve, 20)); 146 | expect(example.expiring40()).toEqual("a=14"); 147 | expect(example.expiring60()).toEqual("a=15"); 148 | }); 149 | 150 | it("Test ttl with args", async () => { 151 | expect(example.expiringArg("a")).toEqual("arg=10-a"); 152 | example.a++; 153 | await new Promise((resolve) => setTimeout(resolve, 20)); 154 | expect(example.expiringArg("a")).toEqual("arg=10-a"); 155 | expect(example.expiringArg("b")).toEqual("arg=11-b"); 156 | example.a++; 157 | await new Promise((resolve) => setTimeout(resolve, 10)); 158 | expect(example.expiringArg("a")).toEqual("arg=10-a"); 159 | expect(example.expiringArg("b")).toEqual("arg=11-b"); 160 | example.a++; 161 | await new Promise((resolve) => setTimeout(resolve, 20)); 162 | expect(example.expiringArg("a")).toEqual("arg=13-a"); 163 | expect(example.expiringArg("b")).toEqual("arg=11-b"); 164 | example.a++; 165 | await new Promise((resolve) => setTimeout(resolve, 20)); 166 | expect(example.expiringArg("a")).toEqual("arg=13-a"); 167 | expect(example.expiringArg("b")).toEqual("arg=14-b"); 168 | }); 169 | 170 | it("Test ttl with two instances", async () => { 171 | const example2 = new Example(); 172 | expect(example.expiring40()).toEqual("a=10"); 173 | example.a++; 174 | example2.a++; 175 | await new Promise((resolve) => setTimeout(resolve, 30)); 176 | expect(example.expiring40()).toEqual("a=10"); 177 | expect(example2.expiring40()).toEqual("a=11"); 178 | example.a++; 179 | example2.a++; 180 | await new Promise((resolve) => setTimeout(resolve, 20)); 181 | expect(example.expiring40()).toEqual("a=12"); 182 | expect(example2.expiring40()).toEqual("a=11"); 183 | }); 184 | 185 | it("Test ttl with aync function", async () => { 186 | const result1 = await example.expiring20Async(); 187 | expect(result1).toEqual("a=10"); 188 | example.a++; 189 | await new Promise((resolve) => setTimeout(resolve, 10)); 190 | // Cache is not expired and should return old value 191 | const result2 = await example.expiring20Async(); 192 | expect(result2).toEqual("a=10"); 193 | example.a++; 194 | await new Promise((resolve) => setTimeout(resolve, 30)); 195 | // Now cache is expired and should return new value 196 | const result3 = await example.expiring20Async(); 197 | expect(result3).toEqual("a=12"); 198 | }); 199 | 200 | it("Test ttl with static function", async () => { 201 | Example.s = 0; 202 | expect(Example.expiring30Static("10")).toEqual("arg=0-10"); 203 | Example.s++; 204 | await new Promise((resolve) => setTimeout(resolve, 10)); 205 | // Cache is not expired and should return old value 206 | expect(Example.expiring30Static("10")).toEqual("arg=0-10"); 207 | Example.s++; 208 | await new Promise((resolve) => setTimeout(resolve, 30)); 209 | // Now cache is expired and should return new value 210 | expect(Example.expiring30Static("10")).toEqual("arg=2-10"); 211 | }); 212 | 213 | it("Test clearFunction", () => { 214 | expect(example.getA(20, "south")).toEqual('getA(20, "south"); a=10'); 215 | example.a++; 216 | expect(example.getA(20, "south")).toEqual('getA(20, "south"); a=10'); 217 | example.a++; 218 | clearFunction(example.getA); 219 | expect(example.getA(20, "south")).toEqual('getA(20, "south"); a=12'); 220 | example.a++; 221 | expect(example.getA(20, "south")).toEqual('getA(20, "south"); a=12'); 222 | }); 223 | 224 | it("Test clear", () => { 225 | expect(example.getA(20, "south")).toEqual('getA(20, "south"); a=10'); 226 | example.a++; 227 | expect(example.getA(20, "south")).toEqual('getA(20, "south"); a=10'); 228 | example.a++; 229 | clear(example, example.getA, 20, "north"); 230 | expect(example.getA(20, "south")).toEqual('getA(20, "south"); a=10'); 231 | clear(example, example.getA, 20, "south"); 232 | expect(example.getA(20, "south")).toEqual('getA(20, "south"); a=12'); 233 | example.a++; 234 | expect(example.getA(20, "south")).toEqual('getA(20, "south"); a=12'); 235 | }); 236 | 237 | it("Test clear with ttl and two instances", async () => { 238 | const example2 = new Example(); 239 | expect(example.expiring60()).toEqual("a=10"); 240 | expect(example2.expiring60()).toEqual("a=10"); 241 | example.a++; 242 | example2.a++; 243 | await new Promise((resolve) => setTimeout(resolve, 20)); 244 | expect(example.expiring60()).toEqual("a=10"); 245 | expect(example2.expiring60()).toEqual("a=10"); 246 | example.a++; 247 | example2.a++; 248 | clear(example2, example.expiring60); 249 | expect(example.expiring60()).toEqual("a=10"); 250 | expect(example2.expiring60()).toEqual("a=12"); 251 | }); 252 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "emitDecoratorMetadata": true, 5 | "experimentalDecorators": true, 6 | "lib": ["es6"], 7 | "module": "es6", 8 | "target": "es6", 9 | "moduleResolution": "node" 10 | }, 11 | "files": ["test.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "lib": ["es6"], 5 | "target": "es6", 6 | "module": "nodenext", 7 | "moduleResolution": "nodenext", 8 | "declaration": true, 9 | "sourceMap": true, 10 | "strict": true, 11 | "emitDecoratorMetadata": true, 12 | "experimentalDecorators": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noUncheckedIndexedAccess": true, 15 | "allowSyntheticDefaultImports": true 16 | }, 17 | "files": ["src/memoize.ts"] 18 | } 19 | --------------------------------------------------------------------------------