diff --git a/packages/nestjs-cache/README.md b/packages/nestjs-cache/README.md index 355a52b5..b3326f9d 100644 --- a/packages/nestjs-cache/README.md +++ b/packages/nestjs-cache/README.md @@ -24,7 +24,7 @@ and improving user experience by minimizing data retrieval times. - [Introduction](#introduction) - [Installation](#installation) - [Basic Setup in a NestJS Project](#basic-setup-in-a-nestjs-project) - - [Using the RestFull endpoints to access cache](#using-the-restFull-endpoints-to-access-cache) + - [Using the RestFull endpoints to access cache](#using-the-restfull-endpoints-to-access-cache) - [How-to Guides](#how-to-guides) - [Registering CacheModule Synchronously](#registering-cachemodule-synchronously) - [Registering CacheModule Asynchronously](#registering-cachemodule-asynchronously) @@ -32,16 +32,6 @@ and improving user experience by minimizing data retrieval times. - [Registering CacheModule Asynchronously for Multiple Entities](#registering-cachemodule-asynchronously-for-multiple-entities) - [Using the CacheService to Access Cache](#using-the-cacheservice-to-access-cache) - [Reference](#reference) - - [CacheModule API Reference](#cachemodule-api-reference) - - [register(options: CacheOptions)](#registeroptions-cacheoptions) - - [registerAsync(options: CacheAsyncOptions)](#registerasyncoptions-cacheasyncoptions) - - [forRoot(options: CacheOptions)](#forrootoptions-cacheoptions) - - [forRootAsync(options: CacheAsyncOptions)](#forrootasyncoptions-cacheasyncoptions) - - [forFeature(options: CacheOptions)](#forfeatureoptions-cacheoptions) - - [CacheOptionsInterface](#cacheoptionsinterface) - - [CacheModule Classes and Interfaces](#cachemodule-classes-and-interfaces) - - [CacheEntityInterface](#cacheentityinterface) - - [CacheServiceInterface](#cacheserviceinterface) - [Explanation](#explanation) - [Conceptual Overview of Caching](#conceptual-overview-of-caching) - [What is Caching?](#what-is-caching) @@ -50,8 +40,7 @@ and improving user experience by minimizing data retrieval times. - [When to Use NestJS Cache](#when-to-use-nestjs-cache) - [How CacheOptionsInterface is Used in the Controller and Endpoints](#how-cacheoptionsinterface-is-used-in-the-controller-and-endpoints) - [Design Choices in CacheModule](#design-choices-in-cachemodule) - - [Synchronous vs Asynchronous Registration](#synchronous-vs-asynchronous-registration) - - [Global vs Feature-Specific Registration](#global-vs-feature-specific-registration) + - [Synchronous vs Asynchronous Registration](#global-vs-synchronous-vs-asynchronous-registration) ## Tutorials @@ -87,7 +76,7 @@ yarn add @concepta/nestjs-typeorm-ext yarn add @concepta/nestjs-cache ``` -On this documentation we will use `sqlite3` as database, but you can use +On this documentation we will use `sqlite3` as database, but you can use whatever you want ```sh @@ -189,12 +178,13 @@ import { User } from './user.entity'; export class UserModule {} ``` -2. **User Cache Module**: Let's create the entity `UserCache` and the +1. **User Cache Module**: Let's create the entity `UserCache` and the `UserCacheModule` that imports our `CacheModule` passing all configurations needed. Please note that `CacheSqliteEntity` and `CachePostgresEntity` are provided by the Rockets NestJS Cache module, so you can use them to create your cache entity. They have a unique index with the following properties: - `'key', 'type', 'assignee.id'` and it will throw a `CacheEntityAlreadyExistsException` if duplicated: + `'key', 'type', 'assignee.id'` and it will throw a + `CacheEntityAlreadyExistsException` if duplicated: ```typescript import { Entity, ManyToOne } from 'typeorm'; @@ -238,7 +228,7 @@ import { UserCache } from './user-cache.entity'; export class UserCacheModule {} ``` -3. **App Module**:And let's create our app module to connect everything. +1. **App Module**:And let's create our app module to connect everything. ```ts import { Module } from '@nestjs/common'; @@ -267,7 +257,7 @@ export class AppModule {} #### Using the RestFull endpoints to access cache -After setting up the basic configuration, you can start using the caching +After setting up the basic configuration, you can start using the caching functionality in your application. ```ts @@ -280,6 +270,7 @@ The code above will generate a route for the client to have access, the module will generate the following endpoint `/cache/user`. This endpoint will be referencing whatever entity was associated in the entities section, as you can see below. + ```ts entities: { userCache: { @@ -290,9 +281,9 @@ entities: { This will make the following endpoints available: -1. **Create (POST)**: To create a new cache entry, the request body should -match the `CacheCreatableInterface`; Properties `key, type and assignee.id` -are unique and will throw a `CacheEntityAlreadyExistsException` error on +1. **Create (POST)**: To create a new cache entry, the request body should +match the `CacheCreatableInterface`; Properties `key, type and assignee.id` +are unique and will throw a `CacheEntityAlreadyExistsException` error on attempt to insert duplicated data: ```ts @@ -301,7 +292,7 @@ export interface CacheCreatableInterface extends Pick { }); }); - it('POST /cache/user', async () => { + it('POST /cache/user creating user with success', async () => { const payload: CacheCreatableInterface = { key: 'dashboard-1', type: 'filter', @@ -177,18 +178,6 @@ describe('CacheAssignmentController (e2e)', () => { expect(res.body.key).toBe(payload.key); expect(res.body.assignee.id).toBe(user.id); }); - - payload.data = '{ "name": "John Doe" }'; - payload.expiresIn = null; - await supertest(app.getHttpServer()) - .post('/cache/user') - .send(payload) - .expect(201) - .then((res) => { - expect(res.body.key).toBe(payload.key); - expect(res.body.data).toBe(payload.data); - expect(res.body.assignee.id).toBe(user.id); - }); }); it('POST /cache/user null after create', async () => { @@ -238,7 +227,7 @@ describe('CacheAssignmentController (e2e)', () => { .expect(400); }); - it('POST /cache/user Update', async () => { + it('PATCH /cache/user Update', async () => { const payload: CacheCreatableInterface = { key: 'dashboard-1', type: 'filter', @@ -246,25 +235,103 @@ describe('CacheAssignmentController (e2e)', () => { expiresIn: '1d', assignee: { id: user.id }, }; + + let cacheId = ''; + await supertest(app.getHttpServer()) .post('/cache/user') .send(payload) .expect(201) .then((res) => { + cacheId = res.body.id; + expect(typeof res.body.id).toEqual('string'); expect(res.body.key).toBe(payload.key); expect(res.body.assignee.id).toBe(user.id); }); + payload.data = '{ "name": "John Doe" }'; payload.expiresIn = null; + await supertest(app.getHttpServer()) - .post('/cache/user/') + .patch(`/cache/user/${cacheId}`) .send(payload) - .expect(201) + .expect(200) + .then((res) => { + expect(res.body.key).toBe(payload.key); + expect(res.body.data).toBe(payload.data); + expect(res.body.assignee.id).toBe(user.id); + }); + + const url = + `/cache/user/` + + `?filter[0]=key||$eq||${payload.key}` + + `&filter[1]=type||$eq||${payload.type}` + + `&filter[2]=assignee.id||$eq||${payload.assignee.id}`; + + // Assuming your endpoint can filter by key and type + await supertest(app.getHttpServer()) + .get(url) + .expect(200) + .then((res) => { + const response = res.body[0]; + assert.strictEqual(response.assignee.id, user.id); + assert.strictEqual(response.key, payload.key); + assert.strictEqual(response.type, payload.type); + assert.strictEqual(response.data, payload.data); + }); + }); + + it('PUT /cache/user', async () => { + const payload: CacheCreatableInterface = { + key: 'dashboard-1', + type: 'filter', + data: '{}', + expiresIn: '1d', + assignee: { id: user.id }, + }; + + const cacheId = randomUUID(); + + await supertest(app.getHttpServer()) + .put(`/cache/user/${cacheId}`) + .send(payload) + .expect(200) + .then((res) => { + expect(res.body.id).toBe(cacheId); + expect(res.body.key).toBe(payload.key); + expect(res.body.assignee.id).toBe(user.id); + }); + + payload.data = '{ "name": "John Doe" }'; + payload.expiresIn = null; + + await supertest(app.getHttpServer()) + .put(`/cache/user/${cacheId}`) + .send(payload) + .expect(200) .then((res) => { expect(res.body.key).toBe(payload.key); expect(res.body.data).toBe(payload.data); expect(res.body.assignee.id).toBe(user.id); }); + + const url = + `/cache/user/` + + `?filter[0]=key||$eq||${payload.key}` + + `&filter[1]=type||$eq||${payload.type}` + + `&filter[2]=assignee.id||$eq||${payload.assignee.id}`; + + // Assuming your endpoint can filter by key and type + await supertest(app.getHttpServer()) + .get(url) + .expect(200) + .then((res) => { + const response = res.body[0]; + assert.strictEqual(response.assignee.id, user.id); + assert.strictEqual(response.key, payload.key); + assert.strictEqual(response.type, payload.type); + assert.strictEqual(response.data, payload.data); + }); }); it('DELETE /cache/user/:id', async () => { diff --git a/packages/nestjs-cache/src/controllers/cache-crud.controller.ts b/packages/nestjs-cache/src/controllers/cache-crud.controller.ts index 31df075c..ba5b5870 100644 --- a/packages/nestjs-cache/src/controllers/cache-crud.controller.ts +++ b/packages/nestjs-cache/src/controllers/cache-crud.controller.ts @@ -1,5 +1,5 @@ -import { Inject, Param } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { Inject, NotFoundException, Param } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { AccessControlCreateOne, AccessControlDeleteOne, @@ -14,6 +14,7 @@ import { CrudDeleteOne, CrudReadMany, CrudReadOne, + CrudReplaceOne, CrudRequest, CrudRequestInterface, CrudUpdateOne, @@ -64,7 +65,7 @@ export class CacheCrudController CacheInterface, CacheCreatableInterface, CacheUpdatableInterface, - never + CacheCreatableInterface > { /** @@ -130,42 +131,18 @@ export class CacheCrudController cacheCreateDto.expiresIn ?? this.settings.expiresIn, ); - const existingCache = await this.cacheService.get( - assignment, - cacheCreateDto, - ); - - // update or create - if (existingCache) { - crudRequest.parsed.search.$and?.push({ - id: { - $eq: existingCache.id, - }, - }); - // call crud service to create - const response = await this.getCrudService(assignment).updateOne( - crudRequest, - { - id: existingCache.id, - ...cacheCreateDto, - expirationDate, - }, - ); - return response; - } else { - // call crud service to create - return this.getCrudService(assignment).createOne(crudRequest, { - ...cacheCreateDto, - expirationDate, - }); - } + // call crud service to create + return this.getCrudService(assignment).createOne(crudRequest, { + ...cacheCreateDto, + expirationDate, + }); } /** * Create one * * @param crudRequest - the CRUD request object - * @param cacheUpdateDto - cache create dto + * @param cacheUpdateDto - cache update dto * @param assignment - The cache assignment */ @CrudUpdateOne() @@ -219,6 +196,48 @@ export class CacheCrudController } } + /** + * Do a Upsert operation for cache + * + * @param crudRequest - the CRUD request object + * @param cacheUpdateDto - cache update dto + * @param assignment - The cache assignment + */ + @ApiOkResponse({ + type: CacheDto, + }) + @CrudReplaceOne() + @AccessControlCreateOne(CacheResource.One) + async replaceOne( + @CrudRequest() crudRequest: CrudRequestInterface, + @CrudBody() cacheUpdateDto: CacheUpdateDto, + @Param('assignment') assignment: ReferenceAssignment, + ) { + let cache; + try { + cache = await this.getOne(crudRequest, assignment); + } catch (error) { + // error is NOT a not found exception? + if (error instanceof NotFoundException !== true) { + // rethrow it + throw error; + } + } + if (cache && cache?.id) { + const expirationDate = getExpirationDate( + cacheUpdateDto.expiresIn ?? this.settings.expiresIn, + ); + + // call crud service to create + return this.getCrudService(assignment).replaceOne(crudRequest, { + ...cacheUpdateDto, + expirationDate, + }); + } else { + return this.createOne(crudRequest, cacheUpdateDto, assignment); + } + } + /** * Get the entity key for the given assignment. * diff --git a/packages/nestjs-cache/src/interfaces/cache-service.interface.ts b/packages/nestjs-cache/src/interfaces/cache-service.interface.ts index 00eb3407..37292890 100644 --- a/packages/nestjs-cache/src/interfaces/cache-service.interface.ts +++ b/packages/nestjs-cache/src/interfaces/cache-service.interface.ts @@ -2,6 +2,7 @@ import { ReferenceAssignment } from '@concepta/ts-core'; import { QueryOptionsInterface } from '@concepta/typeorm-common'; import { CacheClearInterface, + CacheCreatableInterface, CacheCreateInterface, CacheDeleteInterface, CacheGetOneInterface, @@ -15,6 +16,12 @@ export interface CacheServiceInterface CacheUpdateInterface, CacheGetOneInterface, CacheClearInterface { + updateOrCreate( + assignment: ReferenceAssignment, + cache: CacheCreatableInterface, + options?: QueryOptionsInterface, + ): Promise; + getAssignedCaches( assignment: ReferenceAssignment, cache: Pick, diff --git a/packages/nestjs-cache/src/services/cache.service.ts b/packages/nestjs-cache/src/services/cache.service.ts index ac10b617..481b3b88 100644 --- a/packages/nestjs-cache/src/services/cache.service.ts +++ b/packages/nestjs-cache/src/services/cache.service.ts @@ -239,6 +239,19 @@ export class CacheService implements CacheServiceInterface { } } + async updateOrCreate( + assignment: ReferenceAssignment, + cache: CacheCreateDto, + queryOptions?: QueryOptionsInterface, + ): Promise { + const existingCache = await this.get(assignment, cache, queryOptions); + if (existingCache) { + return await this.update(assignment, cache, queryOptions); + } else { + return await this.create(assignment, cache, queryOptions); + } + } + // Should this be on nestjs-common? protected async validateDto>( type: Type,