Skip to content

Commit 4cbecf6

Browse files
authored
feat(hash field expiration): Added hash field expiration commands (#2907)
* [CAE-686] Added hash field expiration commands * [CAE-686] Improve HSETEX return type * [CAE-686] Minor pushTuples change, renamed HSETEX test * [CAE-686] Changed hsetex function signature for better consistency with other commands * [CAE-686] Fixed hsetex test * [CAE-686] Bumped docker version to 8.0-M05-pre, enabled and fixed tests
1 parent 2ff5cb8 commit 4cbecf6

File tree

8 files changed

+399
-1
lines changed

8 files changed

+399
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { strict as assert } from 'node:assert';
2+
import testUtils, { GLOBAL } from '../test-utils';
3+
import { BasicCommandParser } from '../client/parser';
4+
import HGETDEL from './HGETDEL';
5+
6+
describe('HGETDEL parseCommand', () => {
7+
it('hGetDel parseCommand base', () => {
8+
const parser = new BasicCommandParser;
9+
HGETDEL.parseCommand(parser, 'key', 'field');
10+
assert.deepEqual(parser.redisArgs, ['HGETDEL', 'key', 'FIELDS', '1', 'field']);
11+
});
12+
13+
it('hGetDel parseCommand variadic', () => {
14+
const parser = new BasicCommandParser;
15+
HGETDEL.parseCommand(parser, 'key', ['field1', 'field2']);
16+
assert.deepEqual(parser.redisArgs, ['HGETDEL', 'key', 'FIELDS', '2', 'field1', 'field2']);
17+
});
18+
});
19+
20+
21+
describe('HGETDEL call', () => {
22+
testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetDel empty single field', async client => {
23+
assert.deepEqual(
24+
await client.hGetDel('key', 'filed1'),
25+
[null]
26+
);
27+
}, GLOBAL.SERVERS.OPEN);
28+
29+
testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetDel empty multiple fields', async client => {
30+
assert.deepEqual(
31+
await client.hGetDel('key', ['filed1', 'field2']),
32+
[null, null]
33+
);
34+
}, GLOBAL.SERVERS.OPEN);
35+
36+
testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetDel partially populated multiple fields', async client => {
37+
await client.hSet('key', 'field1', 'value1')
38+
assert.deepEqual(
39+
await client.hGetDel('key', ['field1', 'field2']),
40+
['value1', null]
41+
);
42+
43+
assert.deepEqual(
44+
await client.hGetDel('key', 'field1'),
45+
[null]
46+
);
47+
}, GLOBAL.SERVERS.OPEN);
48+
});
+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { CommandParser } from '../client/parser';
2+
import { RedisVariadicArgument } from './generic-transformers';
3+
import { RedisArgument, ArrayReply, BlobStringReply, NullReply, Command } from '../RESP/types';
4+
5+
export default {
6+
parseCommand(parser: CommandParser, key: RedisArgument, fields: RedisVariadicArgument) {
7+
parser.push('HGETDEL');
8+
parser.pushKey(key);
9+
parser.push('FIELDS')
10+
parser.pushVariadicWithLength(fields);
11+
},
12+
transformReply: undefined as unknown as () => ArrayReply<BlobStringReply | NullReply>
13+
} as const satisfies Command;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { strict as assert } from 'node:assert';
2+
import testUtils,{ GLOBAL } from '../test-utils';
3+
import { BasicCommandParser } from '../client/parser';
4+
import HGETEX from './HGETEX';
5+
import { setTimeout } from 'timers/promises';
6+
7+
describe('HGETEX parseCommand', () => {
8+
it('hGetEx parseCommand base', () => {
9+
const parser = new BasicCommandParser;
10+
HGETEX.parseCommand(parser, 'key', 'field');
11+
assert.deepEqual(parser.redisArgs, ['HGETEX', 'key', 'FIELDS', '1', 'field']);
12+
});
13+
14+
it('hGetEx parseCommand expiration PERSIST string', () => {
15+
const parser = new BasicCommandParser;
16+
HGETEX.parseCommand(parser, 'key', 'field', {expiration: 'PERSIST'});
17+
assert.deepEqual(parser.redisArgs, ['HGETEX', 'key', 'PERSIST', 'FIELDS', '1', 'field']);
18+
});
19+
20+
it('hGetEx parseCommand expiration PERSIST obj', () => {
21+
const parser = new BasicCommandParser;
22+
HGETEX.parseCommand(parser, 'key', 'field', {expiration: {type: 'PERSIST'}});
23+
assert.deepEqual(parser.redisArgs, ['HGETEX', 'key', 'PERSIST', 'FIELDS', '1', 'field']);
24+
});
25+
26+
it('hGetEx parseCommand expiration EX obj', () => {
27+
const parser = new BasicCommandParser;
28+
HGETEX.parseCommand(parser, 'key', 'field', {expiration: {type: 'EX', value: 1000}});
29+
assert.deepEqual(parser.redisArgs, ['HGETEX', 'key', 'EX', '1000', 'FIELDS', '1', 'field']);
30+
});
31+
32+
it('hGetEx parseCommand expiration EXAT obj variadic', () => {
33+
const parser = new BasicCommandParser;
34+
HGETEX.parseCommand(parser, 'key', ['field1', 'field2'], {expiration: {type: 'EXAT', value: 1000}});
35+
assert.deepEqual(parser.redisArgs, ['HGETEX', 'key', 'EXAT', '1000', 'FIELDS', '2', 'field1', 'field2']);
36+
});
37+
});
38+
39+
40+
describe('HGETEX call', () => {
41+
testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetEx empty single field', async client => {
42+
assert.deepEqual(
43+
await client.hGetEx('key', 'field1', {expiration: 'PERSIST'}),
44+
[null]
45+
);
46+
}, GLOBAL.SERVERS.OPEN);
47+
48+
testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetEx empty multiple fields', async client => {
49+
assert.deepEqual(
50+
await client.hGetEx('key', ['field1', 'field2'], {expiration: 'PERSIST'}),
51+
[null, null]
52+
);
53+
}, GLOBAL.SERVERS.OPEN);
54+
55+
testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetEx set expiry', async client => {
56+
await client.hSet('key', 'field', 'value')
57+
assert.deepEqual(
58+
await client.hGetEx('key', 'field', {expiration: {type: 'PX', value: 50}}),
59+
['value']
60+
);
61+
await setTimeout(100)
62+
assert.deepEqual(
63+
await client.hGet('key', 'field'),
64+
null
65+
);
66+
}, GLOBAL.SERVERS.OPEN);
67+
68+
testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'gGetEx set expiry PERSIST', async client => {
69+
await client.hSet('key', 'field', 'value')
70+
await client.hGetEx('key', 'field', {expiration: {type: 'PX', value: 50}})
71+
await client.hGetEx('key', 'field', {expiration: 'PERSIST'})
72+
await setTimeout(100)
73+
assert.deepEqual(
74+
await client.hGet('key', 'field'),
75+
'value'
76+
)
77+
}, GLOBAL.SERVERS.OPEN);
78+
});
+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { CommandParser } from '../client/parser';
2+
import { RedisVariadicArgument } from './generic-transformers';
3+
import { ArrayReply, Command, BlobStringReply, NullReply, RedisArgument } from '../RESP/types';
4+
5+
export interface HGetExOptions {
6+
expiration?: {
7+
type: 'EX' | 'PX' | 'EXAT' | 'PXAT';
8+
value: number;
9+
} | {
10+
type: 'PERSIST';
11+
} | 'PERSIST';
12+
}
13+
14+
export default {
15+
parseCommand(
16+
parser: CommandParser,
17+
key: RedisArgument,
18+
fields: RedisVariadicArgument,
19+
options?: HGetExOptions
20+
) {
21+
parser.push('HGETEX');
22+
parser.pushKey(key);
23+
24+
if (options?.expiration) {
25+
if (typeof options.expiration === 'string') {
26+
parser.push(options.expiration);
27+
} else if (options.expiration.type === 'PERSIST') {
28+
parser.push('PERSIST');
29+
} else {
30+
parser.push(
31+
options.expiration.type,
32+
options.expiration.value.toString()
33+
);
34+
}
35+
}
36+
37+
parser.push('FIELDS')
38+
39+
parser.pushVariadicWithLength(fields);
40+
},
41+
transformReply: undefined as unknown as () => ArrayReply<BlobStringReply | NullReply>
42+
} as const satisfies Command;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { strict as assert } from 'node:assert';
2+
import testUtils,{ GLOBAL } from '../test-utils';
3+
import { BasicCommandParser } from '../client/parser';
4+
import HSETEX from './HSETEX';
5+
6+
describe('HSETEX parseCommand', () => {
7+
it('hSetEx parseCommand base', () => {
8+
const parser = new BasicCommandParser;
9+
HSETEX.parseCommand(parser, 'key', ['field', 'value']);
10+
assert.deepEqual(parser.redisArgs, ['HSETEX', 'key', 'FIELDS', '1', 'field', 'value']);
11+
});
12+
13+
it('hSetEx parseCommand base empty obj', () => {
14+
const parser = new BasicCommandParser;
15+
assert.throws(() => {HSETEX.parseCommand(parser, 'key', {})});
16+
});
17+
18+
it('hSetEx parseCommand base one key obj', () => {
19+
const parser = new BasicCommandParser;
20+
HSETEX.parseCommand(parser, 'key', {'k': 'v'});
21+
assert.deepEqual(parser.redisArgs, ['HSETEX', 'key', 'FIELDS', '1', 'k', 'v']);
22+
});
23+
24+
it('hSetEx parseCommand array', () => {
25+
const parser = new BasicCommandParser;
26+
HSETEX.parseCommand(parser, 'key', ['field1', 'value1', 'field2', 'value2']);
27+
assert.deepEqual(parser.redisArgs, ['HSETEX', 'key', 'FIELDS', '2', 'field1', 'value1', 'field2', 'value2']);
28+
});
29+
30+
it('hSetEx parseCommand array invalid args, throws an error', () => {
31+
const parser = new BasicCommandParser;
32+
assert.throws(() => {HSETEX.parseCommand(parser, 'key', ['field1', 'value1', 'field2'])});
33+
});
34+
35+
it('hSetEx parseCommand array in array', () => {
36+
const parser1 = new BasicCommandParser;
37+
HSETEX.parseCommand(parser1, 'key', [['field1', 'value1'], ['field2', 'value2']]);
38+
assert.deepEqual(parser1.redisArgs, ['HSETEX', 'key', 'FIELDS', '2', 'field1', 'value1', 'field2', 'value2']);
39+
40+
const parser2 = new BasicCommandParser;
41+
HSETEX.parseCommand(parser2, 'key', [['field1', 'value1'], ['field2', 'value2'], ['field3', 'value3']]);
42+
assert.deepEqual(parser2.redisArgs, ['HSETEX', 'key', 'FIELDS', '3', 'field1', 'value1', 'field2', 'value2', 'field3', 'value3']);
43+
});
44+
45+
it('hSetEx parseCommand map', () => {
46+
const parser1 = new BasicCommandParser;
47+
HSETEX.parseCommand(parser1, 'key', new Map([['field1', 'value1'], ['field2', 'value2']]));
48+
assert.deepEqual(parser1.redisArgs, ['HSETEX', 'key', 'FIELDS', '2', 'field1', 'value1', 'field2', 'value2']);
49+
});
50+
51+
it('hSetEx parseCommand obj', () => {
52+
const parser1 = new BasicCommandParser;
53+
HSETEX.parseCommand(parser1, 'key', {field1: "value1", field2: "value2"});
54+
assert.deepEqual(parser1.redisArgs, ['HSETEX', 'key', 'FIELDS', '2', 'field1', 'value1', 'field2', 'value2']);
55+
});
56+
57+
it('hSetEx parseCommand options FNX KEEPTTL', () => {
58+
const parser = new BasicCommandParser;
59+
HSETEX.parseCommand(parser, 'key', ['field', 'value'], {mode: 'FNX', expiration: 'KEEPTTL'});
60+
assert.deepEqual(parser.redisArgs, ['HSETEX', 'key', 'FNX', 'KEEPTTL', 'FIELDS', '1', 'field', 'value']);
61+
});
62+
63+
it('hSetEx parseCommand options FXX EX 500', () => {
64+
const parser = new BasicCommandParser;
65+
HSETEX.parseCommand(parser, 'key', ['field', 'value'], {mode: 'FXX', expiration: {type: 'EX', value: 500}});
66+
assert.deepEqual(parser.redisArgs, ['HSETEX', 'key', 'FXX', 'EX', '500', 'FIELDS', '1', 'field', 'value']);
67+
});
68+
});
69+
70+
71+
describe('HSETEX call', () => {
72+
testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hSetEx calls', async client => {
73+
assert.deepEqual(
74+
await client.hSetEx('key_hsetex_call', ['field1', 'value1'], {expiration: {type: "EX", value: 500}, mode: "FNX"}),
75+
1
76+
);
77+
78+
assert.deepEqual(
79+
await client.hSetEx('key_hsetex_call', ['field1', 'value1', 'field2', 'value2'], {expiration: {type: "EX", value: 500}, mode: "FXX"}),
80+
0
81+
);
82+
83+
assert.deepEqual(
84+
await client.hSetEx('key_hsetex_call', ['field1', 'value1', 'field2', 'value2'], {expiration: {type: "EX", value: 500}, mode: "FNX"}),
85+
0
86+
);
87+
88+
assert.deepEqual(
89+
await client.hSetEx('key_hsetex_call', ['field2', 'value2'], {expiration: {type: "EX", value: 500}, mode: "FNX"}),
90+
1
91+
);
92+
93+
assert.deepEqual(
94+
await client.hSetEx('key_hsetex_call', ['field1', 'value1', 'field2', 'value2'], {expiration: {type: "EX", value: 500}, mode: "FXX"}),
95+
1
96+
);
97+
}, GLOBAL.SERVERS.OPEN);
98+
});
+110
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { BasicCommandParser, CommandParser } from '../client/parser';
2+
import { Command, NumberReply, RedisArgument } from '../RESP/types';
3+
4+
export interface HSetExOptions {
5+
expiration?: {
6+
type: 'EX' | 'PX' | 'EXAT' | 'PXAT';
7+
value: number;
8+
} | {
9+
type: 'KEEPTTL';
10+
} | 'KEEPTTL';
11+
mode?: 'FNX' | 'FXX'
12+
}
13+
14+
export type HashTypes = RedisArgument | number;
15+
16+
type HSETEXObject = Record<string | number, HashTypes>;
17+
18+
type HSETEXMap = Map<HashTypes, HashTypes>;
19+
20+
type HSETEXTuples = Array<[HashTypes, HashTypes]> | Array<HashTypes>;
21+
22+
export default {
23+
parseCommand(
24+
parser: CommandParser,
25+
key: RedisArgument,
26+
fields: HSETEXObject | HSETEXMap | HSETEXTuples,
27+
options?: HSetExOptions
28+
) {
29+
parser.push('HSETEX');
30+
parser.pushKey(key);
31+
32+
if (options?.mode) {
33+
parser.push(options.mode)
34+
}
35+
if (options?.expiration) {
36+
if (typeof options.expiration === 'string') {
37+
parser.push(options.expiration);
38+
} else if (options.expiration.type === 'KEEPTTL') {
39+
parser.push('KEEPTTL');
40+
} else {
41+
parser.push(
42+
options.expiration.type,
43+
options.expiration.value.toString()
44+
);
45+
}
46+
}
47+
48+
parser.push('FIELDS')
49+
if (fields instanceof Map) {
50+
pushMap(parser, fields);
51+
} else if (Array.isArray(fields)) {
52+
pushTuples(parser, fields);
53+
} else {
54+
pushObject(parser, fields);
55+
}
56+
},
57+
transformReply: undefined as unknown as () => NumberReply<0 | 1>
58+
} as const satisfies Command;
59+
60+
61+
function pushMap(parser: CommandParser, map: HSETEXMap): void {
62+
parser.push(map.size.toString())
63+
for (const [key, value] of map.entries()) {
64+
parser.push(
65+
convertValue(key),
66+
convertValue(value)
67+
);
68+
}
69+
}
70+
71+
function pushTuples(parser: CommandParser, tuples: HSETEXTuples): void {
72+
const tmpParser = new BasicCommandParser
73+
_pushTuples(tmpParser, tuples)
74+
75+
if (tmpParser.redisArgs.length%2 != 0) {
76+
throw Error('invalid number of arguments, expected key value ....[key value] pairs, got key without value')
77+
}
78+
79+
parser.push((tmpParser.redisArgs.length/2).toString())
80+
parser.push(...tmpParser.redisArgs)
81+
}
82+
83+
function _pushTuples(parser: CommandParser, tuples: HSETEXTuples): void {
84+
for (const tuple of tuples) {
85+
if (Array.isArray(tuple)) {
86+
_pushTuples(parser, tuple);
87+
continue;
88+
}
89+
parser.push(convertValue(tuple));
90+
}
91+
}
92+
93+
function pushObject(parser: CommandParser, object: HSETEXObject): void {
94+
const len = Object.keys(object).length
95+
if (len == 0) {
96+
throw Error('object without keys')
97+
}
98+
99+
parser.push(len.toString())
100+
for (const key of Object.keys(object)) {
101+
parser.push(
102+
convertValue(key),
103+
convertValue(object[key])
104+
);
105+
}
106+
}
107+
108+
function convertValue(value: HashTypes): RedisArgument {
109+
return typeof value === 'number' ? value.toString() : value;
110+
}

0 commit comments

Comments
 (0)