From c43891ff482f1df14c3d36d0d46b7eb9ef7b6b33 Mon Sep 17 00:00:00 2001 From: j-zzi Date: Tue, 12 Aug 2025 14:51:58 +0900 Subject: [PATCH 1/2] feat: add multi operation --- src/FastCache.ts | 176 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/src/FastCache.ts b/src/FastCache.ts index 2955f5e..d6d973a 100644 --- a/src/FastCache.ts +++ b/src/FastCache.ts @@ -76,6 +76,32 @@ export interface SortedSetOperations { replaceAll(entries: Array<{ score: number; value: string }>): Promise; } +export interface MultiOperations { + set(key: string, value: string, ttl?: number): MultiOperations; + get(key: string): MultiOperations; + remove(key: string): MultiOperations; + listPush(key: string, value: string): MultiOperations; + listPop(key: string): MultiOperations; + listUnshift(key: string, value: string): MultiOperations; + listShift(key: string): MultiOperations; + listSetAll(key: string, values: Array): MultiOperations; + listGetAll(key: string, start: number, stop: number): MultiOperations; + listRemoveAll(key: string, start: number, stop: number): MultiOperations; + listLength(key: string): MultiOperations; + mapSet(key: string, field: string, value: string): MultiOperations; + mapSetAll(key: string, obj: Record): MultiOperations; + mapGet(key: string, field: string): MultiOperations; + mapGetAll(key: string, fields: Array): MultiOperations; + mapRemove(key: string, field: string): MultiOperations; + mapRemoveAll(key: string, fields: Array): MultiOperations; + setAdd(key: string, ...values: Array): MultiOperations; + setRemove(key: string, ...values: Array): MultiOperations; + setContains(key: string, value: string): MultiOperations; + setLength(key: string): MultiOperations; + expire(key: string, seconds: number): MultiOperations; + exec(): Promise>; +} + // todo: rename fastCache to redisCache export class FastCache { static create(opts?: FastCacheOpts): FastCache { @@ -331,6 +357,156 @@ export class FastCache { //--------------------------------------------------------- + public multi(): MultiOperations { + const multiClient = this.client.multi(); + const operations: Array<() => void> = []; + + const multiOperations: MultiOperations = { + set: (key: string, value: string, ex?: number): MultiOperations => { + operations.push(() => { + multiClient.set(key, value, 'EX', ex ?? this.ttl); + }); + return multiOperations; + }, + get: (key: string): MultiOperations => { + operations.push(() => { + multiClient.get(key); + }); + return multiOperations; + }, + remove: (key: string): MultiOperations => { + operations.push(() => { + multiClient.del(key); + }); + return multiOperations; + }, + listPush: (key: string, value: string): MultiOperations => { + operations.push(() => { + multiClient.rpush(key, value); + }); + return multiOperations; + }, + listPop: (key: string): MultiOperations => { + operations.push(() => { + multiClient.rpop(key); + }); + return multiOperations; + }, + listUnshift: (key: string, value: string): MultiOperations => { + operations.push(() => { + multiClient.lpush(key, value); + }); + return multiOperations; + }, + listShift: (key: string): MultiOperations => { + operations.push(() => { + multiClient.lpop(key); + }); + return multiOperations; + }, + listSetAll: (key: string, values: Array): MultiOperations => { + operations.push(() => { + multiClient.lpush(key, ...values); + }); + return multiOperations; + }, + listGetAll: (key: string, start = 0, stop = -1): MultiOperations => { + operations.push(() => { + multiClient.lrange(key, start, stop); + }); + return multiOperations; + }, + listRemoveAll: (key: string, start = -1, stop = 0): MultiOperations => { + operations.push(() => { + multiClient.ltrim(key, start, stop); + }); + return multiOperations; + }, + listLength: (key: string): MultiOperations => { + operations.push(() => { + multiClient.llen(key); + }); + return multiOperations; + }, + mapSet: (key: string, field: string, value: string): MultiOperations => { + operations.push(() => { + multiClient.hset(key, field, value); + }); + return multiOperations; + }, + mapSetAll: (key: string, obj: Record): MultiOperations => { + operations.push(() => { + multiClient.hmset(key, obj); + }); + return multiOperations; + }, + mapGet: (key: string, field: string): MultiOperations => { + operations.push(() => { + multiClient.hget(key, field); + }); + return multiOperations; + }, + mapGetAll: (key: string, fields: Array): MultiOperations => { + operations.push(() => { + multiClient.hmget(key, ...fields); + }); + return multiOperations; + }, + mapRemove: (key: string, field: string): MultiOperations => { + operations.push(() => { + multiClient.hdel(key, field); + }); + return multiOperations; + }, + mapRemoveAll: (key: string, fields: Array): MultiOperations => { + operations.push(() => { + multiClient.hdel(key, ...fields); + }); + return multiOperations; + }, + setAdd: (key: string, ...values: Array): MultiOperations => { + operations.push(() => { + multiClient.sadd(key, ...values); + }); + return multiOperations; + }, + setRemove: (key: string, ...values: Array): MultiOperations => { + operations.push(() => { + multiClient.srem(key, ...values); + }); + return multiOperations; + }, + setContains: (key: string, value: string): MultiOperations => { + operations.push(() => { + multiClient.sismember(key, value); + }); + return multiOperations; + }, + setLength: (key: string): MultiOperations => { + operations.push(() => { + multiClient.scard(key); + }); + return multiOperations; + }, + expire: (key: string, seconds: number): MultiOperations => { + operations.push(() => { + multiClient.expire(key, seconds); + }); + return multiOperations; + }, + exec: async (): Promise> => { + operations.forEach((operation) => operation()); + const result = await multiClient.exec(); + // ioredis의 multi.exec() 결과에서 실제 값만 추출 + return result ? result.map(([, value]) => value) : []; + }, + }; + + return multiOperations; + } + + //--------------------------------------------------------- + public async withCache(key: string, executor: () => Promise): Promise { const cached = await this.get(key); if (cached) { From ea1de024a08ea30741ac6e14a7ab3bc6c93dbe91 Mon Sep 17 00:00:00 2001 From: j-zzi Date: Tue, 12 Aug 2025 14:52:02 +0900 Subject: [PATCH 2/2] feat: add multi test --- src/FastCache.spec.ts | 202 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/src/FastCache.spec.ts b/src/FastCache.spec.ts index d93d392..a4c0f8a 100644 --- a/src/FastCache.spec.ts +++ b/src/FastCache.spec.ts @@ -526,4 +526,206 @@ describe('FastCache', () => { }); }); }); + + describe('multi', () => { + describe('기본 키-값 조작', () => { + test('set과 get을 함께 실행할 수 있다', async () => { + const results = await cache.multi().set('test:key', 'test:value', 3600).get('test:key').exec(); + + expect(results).toHaveLength(2); + expect(results[0]).toEqual('OK'); // set 결과 + expect(results[1]).toEqual('test:value'); // get 결과 + + // 실제로 저장되었는지 확인 + const value = await cache.get('test:key'); + expect(value).toBe('test:value'); + }); + + test('remove를 포함한 명령을 실행할 수 있다', async () => { + // 먼저 키를 설정 + await cache.set('test:remove', 'value'); + + const results = await cache.multi().get('test:remove').remove('test:remove').get('test:remove').exec(); + + expect(results).toHaveLength(3); + expect(results[0]).toEqual('value'); // 첫 번째 get + expect(results[1]).toEqual(1); // remove (삭제된 키 개수) + expect(results[2]).toEqual(null); // 두 번째 get (삭제 후) + }); + }); + + describe('list 조작', () => { + test('list 관련 명령을 실행할 수 있다', async () => { + const results = await cache + .multi() + .listPush('test:list', 'item1') + .listPush('test:list', 'item2') + .listUnshift('test:list', 'item0') + .listPop('test:list') + .listShift('test:list') + .listSetAll('test:list', ['item3', 'item4']) + .listGetAll('test:list', 0, -1) + .listRemoveAll('test:list', 2, 3) + .listLength('test:list') + .exec(); + + expect(results).toHaveLength(9); + expect(results[0]).toEqual(1); // rpush item1 + expect(results[1]).toEqual(2); // rpush item2 + expect(results[2]).toEqual(3); // lpush item0 + expect(results[3]).toEqual('item2'); // rpop + expect(results[4]).toEqual('item0'); // lpop + expect(results[5]).toEqual(3); // lset + expect(results[6]).toEqual(['item4', 'item3', 'item1']); // lrange + expect(results[7]).toEqual('OK'); // ltrim + expect(results[8]).toEqual(1); // llen + + // 최종 상태 확인 + const finalList = await cache.list('test:list').getAll(0, -1); + expect(finalList).toEqual(['item1']); + }); + }); + + describe('map 조작', () => { + test('map 관련 명령을 실행할 수 있다', async () => { + const results = await cache + .multi() + .mapSet('test:hash', 'field1', 'value1') + .mapSet('test:hash', 'field2', 'value2') + .mapGet('test:hash', 'field1') + .mapGet('test:hash', 'field2') + .mapGet('test:hash', 'nonexistent') + .mapRemove('test:hash', 'field1') + .exec(); + + expect(results).toHaveLength(6); + expect(results[0]).toEqual(1); // hset field1 + expect(results[1]).toEqual(1); // hset field2 + expect(results[2]).toEqual('value1'); // hget field1 + expect(results[3]).toEqual('value2'); // hget field2 + expect(results[4]).toEqual(null); // hget nonexistent + expect(results[5]).toEqual(1); // hdel field1 + + // 최종 상태 확인 + const remainingValue = await cache.map('test:hash').get('field2'); + expect(remainingValue).toBe('value2'); + }); + + test('map 일괄 조작 명령을 실행할 수 있다', async () => { + const results = await cache + .multi() + .mapSetAll('test:hash2', { + name: 'John', + email: 'john@example.com', + age: '30', + }) + .mapGetAll('test:hash2', ['name', 'email', 'age']) + .mapRemoveAll('test:hash2', ['email', 'age']) + .exec(); + + expect(results).toHaveLength(3); + expect(results[0]).toEqual('OK'); // hmset + expect(results[1]).toEqual(['John', 'john@example.com', '30']); // hmget + expect(results[2]).toEqual(2); // hdel (삭제된 필드 개수) + + // 최종 상태 확인 + const remainingValue = await cache.map('test:hash2').get('name'); + expect(remainingValue).toBe('John'); + }); + }); + + describe('set 조작', () => { + test('set 관련 명령을 실행할 수 있다', async () => { + const results = await cache + .multi() + .setAdd('test:set', 'member1', 'member2', 'member3') + .setAdd('test:set', 'member2', 'member4') // 중복 제거됨 + .setContains('test:set', 'member1') + .setContains('test:set', 'nonexistent') + .setLength('test:set') + .setRemove('test:set', 'member1', 'member2') + .exec(); + + expect(results).toHaveLength(6); + expect(results[0]).toEqual(3); // sadd (3개 추가) + expect(results[1]).toEqual(1); // sadd (1개 추가, 1개 중복) + expect(results[2]).toEqual(1); // sismember (존재) + expect(results[3]).toEqual(0); // sismember (존재하지 않음) + expect(results[4]).toEqual(4); // scard (총 4개) + expect(results[5]).toEqual(2); // srem (2개 제거) + + // 최종 상태 확인 + const finalLength = await cache.setOf('test:set').length(); + expect(finalLength).toBe(2); + }); + }); + + describe('만료 시간 설정', () => { + test('expire 명령을 실행할 수 있다', async () => { + await cache.set('test:expire', 'value'); + + const results = await cache.multi().expire('test:expire', 3600).get('test:expire').exec(); + + expect(results).toHaveLength(2); + expect(results[0]).toEqual(1); // expire (설정 성공) + expect(results[1]).toEqual('value'); // get + + // TTL 확인 (대략적인 값) + const ttl = await client.ttl('test:expire'); + expect(ttl).toBeGreaterThan(3500); // 3600에 가까운 값 + }); + }); + + describe('복합 명령', () => { + test('여러 타입의 명령을 함께 실행할 수 있다', async () => { + const results = await cache + .multi() + .set('user:123', 'John Doe', 3600) + .mapSet('user:123:profile', 'name', 'John Doe') + .mapSet('user:123:profile', 'email', 'john@example.com') + .setAdd('user:123:tags', 'premium', 'verified') + .listPush('user:123:activities', 'login:2024-01-01') + .expire('user:123:profile', 7200) + .exec(); + + expect(results).toHaveLength(6); + expect(results[0]).toEqual('OK'); // set + expect(results[1]).toEqual(1); // hset name + expect(results[2]).toEqual(1); // hset email + expect(results[3]).toEqual(2); // sadd + expect(results[4]).toEqual(1); // rpush + expect(results[5]).toEqual(1); // expire + + // 모든 데이터가 올바르게 저장되었는지 확인 + const [userValue, profile, tags, activities] = await Promise.all([ + cache.get('user:123'), + cache.map('user:123:profile').getAll(['name', 'email']), + cache.setOf('user:123:tags').length(), + cache.list('user:123:activities').getAll(0, -1), + ]); + + expect(userValue).toBe('John Doe'); + expect(profile).toEqual(['John Doe', 'john@example.com']); + expect(tags).toBe(2); // premium, verified + expect(activities).toEqual(['login:2024-01-01']); + }); + }); + + describe('에러 처리', () => { + test('명령이 없을 때 exec()를 호출하면 빈 배열을 반환한다', async () => { + const results = await cache.multi().exec(); + expect(results).toEqual([]); + }); + + test('잘못된 명령이 있어도 다른 명령은 실행된다', async () => { + // 존재하지 않는 키에 대해 get 실행 + const results = await cache.multi().set('test:valid', 'value').get('test:nonexistent').get('test:valid').exec(); + + expect(results).toHaveLength(3); + expect(results[0]).toEqual('OK'); // set 성공 + expect(results[1]).toEqual(null); // get 실패 (null 반환) + expect(results[2]).toEqual('value'); // get 성공 + }); + }); + }); });