Skip to content

[CAE-686] Added hash field expiration commands #2907

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Mar 19, 2025
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
48 changes: 48 additions & 0 deletions packages/client/lib/commands/HGETDEL.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { strict as assert } from 'node:assert';
import testUtils, { GLOBAL } from '../test-utils';
import { BasicCommandParser } from '../client/parser';
import HGETDEL from './HGETDEL';

describe('HGETDEL parseCommand', () => {
it('hGetDel parseCommand base', () => {
const parser = new BasicCommandParser;
HGETDEL.parseCommand(parser, 'key', 'field');
assert.deepEqual(parser.redisArgs, ['HGETDEL', 'key', 'FIELDS', '1', 'field']);
});

it('hGetDel parseCommand variadic', () => {
const parser = new BasicCommandParser;
HGETDEL.parseCommand(parser, 'key', ['field1', 'field2']);
assert.deepEqual(parser.redisArgs, ['HGETDEL', 'key', 'FIELDS', '2', 'field1', 'field2']);
});
});


describe('HGETDEL call', () => {
testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetDel empty single field', async client => {
assert.deepEqual(
await client.hGetDel('key', 'filed1'),
[null]
);
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetDel empty multiple fields', async client => {
assert.deepEqual(
await client.hGetDel('key', ['filed1', 'field2']),
[null, null]
);
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetDel partially populated multiple fields', async client => {
await client.hSet('key', 'field1', 'value1')
assert.deepEqual(
await client.hGetDel('key', ['field1', 'field2']),
['value1', null]
);

assert.deepEqual(
await client.hGetDel('key', 'field1'),
[null]
);
}, GLOBAL.SERVERS.OPEN);
});
13 changes: 13 additions & 0 deletions packages/client/lib/commands/HGETDEL.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { CommandParser } from '../client/parser';
import { RedisVariadicArgument } from './generic-transformers';
import { RedisArgument, ArrayReply, BlobStringReply, NullReply, Command } from '../RESP/types';

export default {
parseCommand(parser: CommandParser, key: RedisArgument, fields: RedisVariadicArgument) {
parser.push('HGETDEL');
parser.pushKey(key);
parser.push('FIELDS')
parser.pushVariadicWithLength(fields);
},
transformReply: undefined as unknown as () => ArrayReply<BlobStringReply | NullReply>
} as const satisfies Command;
78 changes: 78 additions & 0 deletions packages/client/lib/commands/HGETEX.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { strict as assert } from 'node:assert';
import testUtils,{ GLOBAL } from '../test-utils';
import { BasicCommandParser } from '../client/parser';
import HGETEX from './HGETEX';
import { setTimeout } from 'timers/promises';

describe('HGETEX parseCommand', () => {
it('hGetEx parseCommand base', () => {
const parser = new BasicCommandParser;
HGETEX.parseCommand(parser, 'key', 'field');
assert.deepEqual(parser.redisArgs, ['HGETEX', 'key', 'FIELDS', '1', 'field']);
});

it('hGetEx parseCommand expiration PERSIST string', () => {
const parser = new BasicCommandParser;
HGETEX.parseCommand(parser, 'key', 'field', {expiration: 'PERSIST'});
assert.deepEqual(parser.redisArgs, ['HGETEX', 'key', 'PERSIST', 'FIELDS', '1', 'field']);
});

it('hGetEx parseCommand expiration PERSIST obj', () => {
const parser = new BasicCommandParser;
HGETEX.parseCommand(parser, 'key', 'field', {expiration: {type: 'PERSIST'}});
assert.deepEqual(parser.redisArgs, ['HGETEX', 'key', 'PERSIST', 'FIELDS', '1', 'field']);
});

it('hGetEx parseCommand expiration EX obj', () => {
const parser = new BasicCommandParser;
HGETEX.parseCommand(parser, 'key', 'field', {expiration: {type: 'EX', value: 1000}});
assert.deepEqual(parser.redisArgs, ['HGETEX', 'key', 'EX', '1000', 'FIELDS', '1', 'field']);
});

it('hGetEx parseCommand expiration EXAT obj variadic', () => {
const parser = new BasicCommandParser;
HGETEX.parseCommand(parser, 'key', ['field1', 'field2'], {expiration: {type: 'EXAT', value: 1000}});
assert.deepEqual(parser.redisArgs, ['HGETEX', 'key', 'EXAT', '1000', 'FIELDS', '2', 'field1', 'field2']);
});
});


describe('HGETEX call', () => {
testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetEx empty single field', async client => {
assert.deepEqual(
await client.hGetEx('key', 'field1', {expiration: 'PERSIST'}),
[null]
);
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetEx empty multiple fields', async client => {
assert.deepEqual(
await client.hGetEx('key', ['field1', 'field2'], {expiration: 'PERSIST'}),
[null, null]
);
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hGetEx set expiry', async client => {
await client.hSet('key', 'field', 'value')
assert.deepEqual(
await client.hGetEx('key', 'field', {expiration: {type: 'PX', value: 50}}),
['value']
);
await setTimeout(100)
assert.deepEqual(
await client.hGet('key', 'field'),
null
);
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'gGetEx set expiry PERSIST', async client => {
await client.hSet('key', 'field', 'value')
await client.hGetEx('key', 'field', {expiration: {type: 'PX', value: 50}})
await client.hGetEx('key', 'field', {expiration: 'PERSIST'})
await setTimeout(100)
assert.deepEqual(
await client.hGet('key', 'field'),
'value'
)
}, GLOBAL.SERVERS.OPEN);
});
42 changes: 42 additions & 0 deletions packages/client/lib/commands/HGETEX.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { CommandParser } from '../client/parser';
import { RedisVariadicArgument } from './generic-transformers';
import { ArrayReply, Command, BlobStringReply, NullReply, RedisArgument } from '../RESP/types';

export interface HGetExOptions {
expiration?: {
type: 'EX' | 'PX' | 'EXAT' | 'PXAT';
value: number;
} | {
type: 'PERSIST';
} | 'PERSIST';
}

export default {
parseCommand(
parser: CommandParser,
key: RedisArgument,
fields: RedisVariadicArgument,
options?: HGetExOptions
) {
parser.push('HGETEX');
parser.pushKey(key);

if (options?.expiration) {
if (typeof options.expiration === 'string') {
parser.push(options.expiration);
} else if (options.expiration.type === 'PERSIST') {
parser.push('PERSIST');
} else {
parser.push(
options.expiration.type,
options.expiration.value.toString()
);
}
}

parser.push('FIELDS')

parser.pushVariadicWithLength(fields);
},
transformReply: undefined as unknown as () => ArrayReply<BlobStringReply | NullReply>
} as const satisfies Command;
98 changes: 98 additions & 0 deletions packages/client/lib/commands/HSETEX.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { strict as assert } from 'node:assert';
import testUtils,{ GLOBAL } from '../test-utils';
import { BasicCommandParser } from '../client/parser';
import HSETEX from './HSETEX';

describe('HSETEX parseCommand', () => {
it('hSetEx parseCommand base', () => {
const parser = new BasicCommandParser;
HSETEX.parseCommand(parser, 'key', ['field', 'value']);
assert.deepEqual(parser.redisArgs, ['HSETEX', 'key', 'FIELDS', '1', 'field', 'value']);
});

it('hSetEx parseCommand base empty obj', () => {
const parser = new BasicCommandParser;
assert.throws(() => {HSETEX.parseCommand(parser, 'key', {})});
});

it('hSetEx parseCommand base one key obj', () => {
const parser = new BasicCommandParser;
HSETEX.parseCommand(parser, 'key', {'k': 'v'});
assert.deepEqual(parser.redisArgs, ['HSETEX', 'key', 'FIELDS', '1', 'k', 'v']);
});

it('hSetEx parseCommand array', () => {
const parser = new BasicCommandParser;
HSETEX.parseCommand(parser, 'key', ['field1', 'value1', 'field2', 'value2']);
assert.deepEqual(parser.redisArgs, ['HSETEX', 'key', 'FIELDS', '2', 'field1', 'value1', 'field2', 'value2']);
});

it('hSetEx parseCommand array invalid args, throws an error', () => {
const parser = new BasicCommandParser;
assert.throws(() => {HSETEX.parseCommand(parser, 'key', ['field1', 'value1', 'field2'])});
});

it('hSetEx parseCommand array in array', () => {
const parser1 = new BasicCommandParser;
HSETEX.parseCommand(parser1, 'key', [['field1', 'value1'], ['field2', 'value2']]);
assert.deepEqual(parser1.redisArgs, ['HSETEX', 'key', 'FIELDS', '2', 'field1', 'value1', 'field2', 'value2']);

const parser2 = new BasicCommandParser;
HSETEX.parseCommand(parser2, 'key', [['field1', 'value1'], ['field2', 'value2'], ['field3', 'value3']]);
assert.deepEqual(parser2.redisArgs, ['HSETEX', 'key', 'FIELDS', '3', 'field1', 'value1', 'field2', 'value2', 'field3', 'value3']);
});

it('hSetEx parseCommand map', () => {
const parser1 = new BasicCommandParser;
HSETEX.parseCommand(parser1, 'key', new Map([['field1', 'value1'], ['field2', 'value2']]));
assert.deepEqual(parser1.redisArgs, ['HSETEX', 'key', 'FIELDS', '2', 'field1', 'value1', 'field2', 'value2']);
});

it('hSetEx parseCommand obj', () => {
const parser1 = new BasicCommandParser;
HSETEX.parseCommand(parser1, 'key', {field1: "value1", field2: "value2"});
assert.deepEqual(parser1.redisArgs, ['HSETEX', 'key', 'FIELDS', '2', 'field1', 'value1', 'field2', 'value2']);
});

it('hSetEx parseCommand options FNX KEEPTTL', () => {
const parser = new BasicCommandParser;
HSETEX.parseCommand(parser, 'key', ['field', 'value'], {mode: 'FNX', expiration: 'KEEPTTL'});
assert.deepEqual(parser.redisArgs, ['HSETEX', 'key', 'FNX', 'KEEPTTL', 'FIELDS', '1', 'field', 'value']);
});

it('hSetEx parseCommand options FXX EX 500', () => {
const parser = new BasicCommandParser;
HSETEX.parseCommand(parser, 'key', ['field', 'value'], {mode: 'FXX', expiration: {type: 'EX', value: 500}});
assert.deepEqual(parser.redisArgs, ['HSETEX', 'key', 'FXX', 'EX', '500', 'FIELDS', '1', 'field', 'value']);
});
});


describe('HSETEX call', () => {
testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'hSetEx calls', async client => {
assert.deepEqual(
await client.hSetEx('key_hsetex_call', ['field1', 'value1'], {expiration: {type: "EX", value: 500}, mode: "FNX"}),
1
);

assert.deepEqual(
await client.hSetEx('key_hsetex_call', ['field1', 'value1', 'field2', 'value2'], {expiration: {type: "EX", value: 500}, mode: "FXX"}),
0
);

assert.deepEqual(
await client.hSetEx('key_hsetex_call', ['field1', 'value1', 'field2', 'value2'], {expiration: {type: "EX", value: 500}, mode: "FNX"}),
0
);

assert.deepEqual(
await client.hSetEx('key_hsetex_call', ['field2', 'value2'], {expiration: {type: "EX", value: 500}, mode: "FNX"}),
1
);

assert.deepEqual(
await client.hSetEx('key_hsetex_call', ['field1', 'value1', 'field2', 'value2'], {expiration: {type: "EX", value: 500}, mode: "FXX"}),
1
);
}, GLOBAL.SERVERS.OPEN);
});
110 changes: 110 additions & 0 deletions packages/client/lib/commands/HSETEX.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { BasicCommandParser, CommandParser } from '../client/parser';
import { Command, NumberReply, RedisArgument } from '../RESP/types';

export interface HSetExOptions {
expiration?: {
type: 'EX' | 'PX' | 'EXAT' | 'PXAT';
value: number;
} | {
type: 'KEEPTTL';
} | 'KEEPTTL';
mode?: 'FNX' | 'FXX'
}

export type HashTypes = RedisArgument | number;

type HSETEXObject = Record<string | number, HashTypes>;

type HSETEXMap = Map<HashTypes, HashTypes>;

type HSETEXTuples = Array<[HashTypes, HashTypes]> | Array<HashTypes>;

export default {
parseCommand(
parser: CommandParser,
key: RedisArgument,
fields: HSETEXObject | HSETEXMap | HSETEXTuples,
options?: HSetExOptions
) {
parser.push('HSETEX');
parser.pushKey(key);

if (options?.mode) {
parser.push(options.mode)
}
if (options?.expiration) {
if (typeof options.expiration === 'string') {
parser.push(options.expiration);
} else if (options.expiration.type === 'KEEPTTL') {
parser.push('KEEPTTL');
} else {
parser.push(
options.expiration.type,
options.expiration.value.toString()
);
}
}

parser.push('FIELDS')
if (fields instanceof Map) {
pushMap(parser, fields);
} else if (Array.isArray(fields)) {
pushTuples(parser, fields);
} else {
pushObject(parser, fields);
}
},
transformReply: undefined as unknown as () => NumberReply<0 | 1>
} as const satisfies Command;


function pushMap(parser: CommandParser, map: HSETEXMap): void {
parser.push(map.size.toString())
for (const [key, value] of map.entries()) {
parser.push(
convertValue(key),
convertValue(value)
);
}
}

function pushTuples(parser: CommandParser, tuples: HSETEXTuples): void {
const tmpParser = new BasicCommandParser
_pushTuples(tmpParser, tuples)

if (tmpParser.redisArgs.length%2 != 0) {
throw Error('invalid number of arguments, expected key value ....[key value] pairs, got key without value')
}

parser.push((tmpParser.redisArgs.length/2).toString())
parser.push(...tmpParser.redisArgs)
}

function _pushTuples(parser: CommandParser, tuples: HSETEXTuples): void {
for (const tuple of tuples) {
if (Array.isArray(tuple)) {
_pushTuples(parser, tuple);
continue;
}
parser.push(convertValue(tuple));
}
}

function pushObject(parser: CommandParser, object: HSETEXObject): void {
const len = Object.keys(object).length
if (len == 0) {
throw Error('object without keys')
}

parser.push(len.toString())
for (const key of Object.keys(object)) {
parser.push(
convertValue(key),
convertValue(object[key])
);
}
}

function convertValue(value: HashTypes): RedisArgument {
return typeof value === 'number' ? value.toString() : value;
}
Loading
Loading