Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 202 additions & 0 deletions src/FastCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 성공
});
});
});
});
176 changes: 176 additions & 0 deletions src/FastCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,32 @@ export interface SortedSetOperations {
replaceAll(entries: Array<{ score: number; value: string }>): Promise<void>;
}

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<string>): 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<string, unknown>): MultiOperations;
mapGet(key: string, field: string): MultiOperations;
mapGetAll(key: string, fields: Array<string>): MultiOperations;
mapRemove(key: string, field: string): MultiOperations;
mapRemoveAll(key: string, fields: Array<string>): MultiOperations;
setAdd(key: string, ...values: Array<string>): MultiOperations;
setRemove(key: string, ...values: Array<string>): MultiOperations;
setContains(key: string, value: string): MultiOperations;
setLength(key: string): MultiOperations;
expire(key: string, seconds: number): MultiOperations;
exec(): Promise<Array<any>>;
}

// todo: rename fastCache to redisCache
export class FastCache {
static create(opts?: FastCacheOpts): FastCache {
Expand Down Expand Up @@ -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<string>): 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<string, unknown>): 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<string>): 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<string>): MultiOperations => {
operations.push(() => {
multiClient.hdel(key, ...fields);
});
return multiOperations;
},
setAdd: (key: string, ...values: Array<string>): MultiOperations => {
operations.push(() => {
multiClient.sadd(key, ...values);
});
return multiOperations;
},
setRemove: (key: string, ...values: Array<string>): 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<Array<any>> => {
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<unknown>): Promise<unknown> {
const cached = await this.get(key);
if (cached) {
Expand Down