diff --git a/redisinsight/api/bruno/RedisInsight/Array/Create Array (Contiguous).bru b/redisinsight/api/bruno/RedisInsight/Array/Create Array (Contiguous).bru index ed1804081d..a7084cf18b 100644 --- a/redisinsight/api/bruno/RedisInsight/Array/Create Array (Contiguous).bru +++ b/redisinsight/api/bruno/RedisInsight/Array/Create Array (Contiguous).bru @@ -35,7 +35,7 @@ docs { |-------|------|-------------| | `keyName` | string | The key name for the new Array | | `mode` | string | Must be `contiguous` for this request shape | - | `startIndex` | string | Start index as a numeric string (`0` to `18446744073709551615`) | + | `startIndex` | string | Start index as a numeric string (`0` to `18446744073709551614`) | | `values` | string[] | Value(s) to write starting at `startIndex` | | `expire` | number (optional) | TTL in seconds | @@ -47,7 +47,7 @@ docs { | Status | Condition | |--------|-----------| - | `400` | Validation failed, e.g. a non-canonical index: `startIndex must be an integer string between 0 and 18446744073709551615` | + | `400` | Validation failed, e.g. a non-canonical index: `startIndex must be an integer string between 0 and 18446744073709551614` | | `403` | User has no permissions | | `409` | Key with this name already exists | } diff --git a/redisinsight/api/bruno/RedisInsight/Array/Create Array (Sparse).bru b/redisinsight/api/bruno/RedisInsight/Array/Create Array (Sparse).bru index 1a67fc62a4..5365e929f1 100644 --- a/redisinsight/api/bruno/RedisInsight/Array/Create Array (Sparse).bru +++ b/redisinsight/api/bruno/RedisInsight/Array/Create Array (Sparse).bru @@ -38,7 +38,7 @@ docs { | `keyName` | string | The key name for the new Array | | `mode` | string | Must be `sparse` for this request shape | | `elements` | array | Index/value pairs to write | - | `elements[].index` | string | Element index as a numeric string (`0` to `18446744073709551615`) | + | `elements[].index` | string | Element index as a numeric string (`0` to `18446744073709551614`) | | `elements[].value` | string | Element value | | `expire` | number (optional) | TTL in seconds | @@ -50,7 +50,7 @@ docs { | Status | Condition | |--------|-----------| - | `400` | Validation failed, e.g. a non-canonical index: `elements.0.index must be an integer string between 0 and 18446744073709551615` | + | `400` | Validation failed, e.g. a non-canonical index: `elements.0.index must be an integer string between 0 and 18446744073709551614` | | `403` | User has no permissions | | `409` | Key with this name already exists | } diff --git a/redisinsight/api/bruno/RedisInsight/Array/Get Count.bru b/redisinsight/api/bruno/RedisInsight/Array/Get Count.bru new file mode 100644 index 0000000000..4c60af5647 --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Get Count.bru @@ -0,0 +1,45 @@ +meta { + name: Get Count + type: http + seq: 4 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/array/get-count + body: json + auth: inherit +} + +body:json { + { + "keyName": "readings" + } +} + +docs { + # ARCOUNT + + Returns the number of populated (non-empty) elements in the array. + Unsigned 64-bit integer returned as a numeric string. + + ## Response + + ``` + { + "keyName": "readings", + "count": "5" + } + ``` + + ## Errors + + | Status | When | + |--------|------| + | `400` | Key holds a non-array type. | + | `403` | User has no permission for `ARCOUNT`. | + | `404` | Key does not exist. | +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/Get Element (empty slot).bru b/redisinsight/api/bruno/RedisInsight/Array/Get Element (empty slot).bru new file mode 100644 index 0000000000..4047ee936b --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Get Element (empty slot).bru @@ -0,0 +1,52 @@ +meta { + name: Get Element (empty slot) + type: http + seq: 8 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/array/get-element + body: json + auth: inherit +} + +body:json { + { + "keyName": "readings", + "index": "3" + } +} + +docs { + # ARGET — empty slot + + Same endpoint as **Get Element**, pre-filled to query an **unset index** + on the seeded `readings` key. With the `Seed Sample Data` fixture + populating indexes `0`, `1`, `5`, asking for index `3` exercises the + gap-preserving semantics of Redis arrays. + + ## Why this preset exists + + A common reviewer assumption is "missing index → 404". For arrays it + isn't: the key exists and the array's logical length still spans the + requested index, so the API returns `200 OK` with a `null` `value`. + Only a missing **key** returns 404. + + ## Response + + ``` + { + "keyName": "readings", + "value": null + } + ``` + + ## Compare with + + - **Get Element** (`index: "1"`) → `"value": "20.4"` (slot is set). + - **Get Element** (`keyName: "no-such-key"`) → `404 Not Found` (key missing). +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/Get Element.bru b/redisinsight/api/bruno/RedisInsight/Array/Get Element.bru new file mode 100644 index 0000000000..71baa2db9a --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Get Element.bru @@ -0,0 +1,53 @@ +meta { + name: Get Element + type: http + seq: 6 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/array/get-element + body: json + auth: inherit +} + +body:json { + { + "keyName": "readings", + "index": "1" + } +} + +docs { + # ARGET + + Get the value at a single index. Returns `null` for an empty slot or an + index past the array length. + + ## Request body + + | Field | Type | Notes | + |-----------|--------|--------------------------------------------------------| + | `keyName` | string | The Array key name. | + | `index` | string | Unsigned 64-bit integer as string. | + + ## Response + + ``` + { + "keyName": "readings", + "value": "20.4" + } + ``` + + ## Errors + + | Status | When | + |--------|------| + | `400` | Key holds a non-array type. | + | `403` | User has no permission for `ARGET`. | + | `404` | Key does not exist. | +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/Get Elements.bru b/redisinsight/api/bruno/RedisInsight/Array/Get Elements.bru new file mode 100644 index 0000000000..6a0560d746 --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Get Elements.bru @@ -0,0 +1,53 @@ +meta { + name: Get Elements + type: http + seq: 7 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/array/get-elements + body: json + auth: inherit +} + +body:json { + { + "keyName": "readings", + "indexes": ["0", "3", "5"] + } +} + +docs { + # ARMGET + + Get values at multiple indexes in one round-trip. Returns an array of + `value | null`, one entry per requested index, in request order. + + ## Request body + + | Field | Type | Notes | + |-----------|----------|------------------------------------------------------| + | `keyName` | string | The Array key name. | + | `indexes` | string[] | 1 to 1,000,000 indexes; each unsigned 64-bit as string. | + + ## Response + + ``` + { + "keyName": "readings", + "elements": ["20.1", null, "21.4"] + } + ``` + + ## Errors + + | Status | When | + |--------|------| + | `400` | `indexes` is empty or exceeds 1,000,000 entries, any index is not a valid unsigned 64-bit integer string, or the key holds a non-array type. | + | `403` | User has no permission for `ARMGET`. | + | `404` | Key does not exist. | +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/Get Length.bru b/redisinsight/api/bruno/RedisInsight/Array/Get Length.bru new file mode 100644 index 0000000000..8c6e1c9c8f --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Get Length.bru @@ -0,0 +1,45 @@ +meta { + name: Get Length + type: http + seq: 3 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/array/get-length + body: json + auth: inherit +} + +body:json { + { + "keyName": "readings" + } +} + +docs { + # ARLEN + + Returns the logical length of the array — highest set index + 1 (includes + gaps). Unsigned 64-bit integer returned as a numeric string. + + ## Response + + ``` + { + "keyName": "readings", + "length": "7" + } + ``` + + ## Errors + + | Status | When | + |--------|------| + | `400` | Key holds a non-array type. | + | `403` | User has no permission for `ARLEN`. | + | `404` | Key does not exist. | +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/Get Next Index.bru b/redisinsight/api/bruno/RedisInsight/Array/Get Next Index.bru new file mode 100644 index 0000000000..838347edba --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Get Next Index.bru @@ -0,0 +1,45 @@ +meta { + name: Get Next Index + type: http + seq: 5 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/array/get-next-index + body: json + auth: inherit +} + +body:json { + { + "keyName": "readings" + } +} + +docs { + # ARNEXT + + Returns the next index that `ARINSERT` would use. Read-only. + Unsigned 64-bit integer returned as a numeric string. + + ## Response + + ``` + { + "keyName": "readings", + "index": "7" + } + ``` + + ## Errors + + | Status | When | + |--------|------| + | `400` | Key holds a non-array type. | + | `403` | User has no permission for `ARNEXT`. | + | `404` | Key does not exist. | +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/Get Range.bru b/redisinsight/api/bruno/RedisInsight/Array/Get Range.bru new file mode 100644 index 0000000000..91eafa52c4 --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Get Range.bru @@ -0,0 +1,55 @@ +meta { + name: Get Range + type: http + seq: 1 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/array/get-range + body: json + auth: inherit +} + +body:json { + { + "keyName": "readings", + "start": "0", + "end": "6" + } +} + +docs { + # ARGETRANGE + + Read an inclusive range of elements from an Array key. Empty slots are + returned as `null`. `start` must be less than or equal to `end`. + + ## Request body + + | Field | Type | Notes | + |-----------|--------|-----------------------------------------------------------| + | `keyName` | string | The Array key name. | + | `start` | string | Inclusive start index. Unsigned 64-bit integer as string. | + | `end` | string | Inclusive end index. Unsigned 64-bit integer as string. Must be ≥ `start`. | + + ## Response + + ``` + { + "keyName": "readings", + "elements": ["20.1", "20.4", "20.9", null, null, "21.4", "21.9"] + } + ``` + + ## Errors + + | Status | When | + |--------|------| + | `400` | `start` is greater than `end`, range exceeds 1,000,000 elements per call, or the key holds a non-array type. | + | `403` | User has no permission for `ARGETRANGE`. | + | `404` | Key does not exist. | +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/Scan.bru b/redisinsight/api/bruno/RedisInsight/Array/Scan.bru new file mode 100644 index 0000000000..ed5f80c396 --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Scan.bru @@ -0,0 +1,64 @@ +meta { + name: Scan + type: http + seq: 2 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/array/scan + body: json + auth: inherit +} + +body:json { + { + "keyName": "readings", + "start": "0", + "end": "6", + "limit": 100 + } +} + +docs { + # ARSCAN + + Read a range of populated elements from an Array key, skipping empty slots. + Cheaper than `Get Range` for sparse arrays. `start` must be less than or + equal to `end`. + + ## Request body + + | Field | Type | Notes | + |-----------|------------------|-----------------------------------------------------------| + | `keyName` | string | The Array key name. | + | `start` | string | Inclusive start index. Unsigned 64-bit integer as string. | + | `end` | string | Inclusive end index. Unsigned 64-bit integer as string. Must be ≥ `start`. | + | `limit` | number, optional | Maps to ARSCAN `LIMIT`. | + + ## Response + + ``` + { + "keyName": "readings", + "elements": [ + { "index": "0", "value": "20.1" }, + { "index": "1", "value": "20.4" }, + { "index": "2", "value": "20.9" }, + { "index": "5", "value": "21.4" }, + { "index": "6", "value": "21.9" } + ] + } + ``` + + ## Errors + + | Status | When | + |--------|------| + | `400` | `start` is greater than `end`, range exceeds 1,000,000 elements per call, or the key holds a non-array type. | + | `403` | User has no permission for `ARSCAN`. | + | `404` | Key does not exist. | +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/Seed Sample Data.bru b/redisinsight/api/bruno/RedisInsight/Array/Seed Sample Data.bru new file mode 100644 index 0000000000..f0105a4a33 --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Seed Sample Data.bru @@ -0,0 +1,75 @@ +meta { + name: Seed Sample Data + type: http + seq: 0 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/bulk-actions/import + body: multipartForm + auth: inherit +} + +body:multipart-form { + file: @file(Array/fixtures/readings.txt) +} + +docs { + # Seed Sample Data (Array) + + Loads the `readings` sample array used by the rest of the Array presets + by reusing the existing **bulk-actions import** pipeline (`POST + /bulk-actions/import`) — the same generic loader used for `.txt` files + of raw Redis CLI commands. + + ## How it works + + - The fixture lives at `Array/fixtures/readings.txt`. Bruno resolves + `@file()` paths relative to the **collection root** + (`redisinsight/api/bruno/RedisInsight/`), not the `.bru` file's + directory. + - Each line is one Redis CLI command, parsed via `splitCliCommandLine` + and dispatched through a Redis pipeline by the server. + - The fixture first `DEL`s the key, then `ARSET`s a sparse layout + (indexes 0, 1, 5) — matching the example responses in the other + Array presets. + + Requires Redis 8.8+ on the target database (for the `ARSET` command). + + ## Response + + Returns `200 OK` with a `BulkActionOverview`, e.g.: + + ```json + { + "summary": { + "processed": 4, + "succeed": 4, + "failed": 0, + "errors": [], + "keys": [] + }, + "status": "completed" + } + ``` + + ## Errors + + | Status | When | + |--------|------| + | `400` | Fixture is malformed or unreadable. | + | `500` | Generic import failure. | + + Per-command failures (e.g. `ARSET` on a pre-8.8 server) do **not** fail + the whole request — they're reported in `summary.failed`. + + ## Follow-up + + Once seeded, run the other presets in the Array folder: + **Get Length** → **Get Count** → **Get Next Index** → **Get Range** → + **Scan** → **Get Element** → **Get Elements**. +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/fixtures/readings.txt b/redisinsight/api/bruno/RedisInsight/Array/fixtures/readings.txt new file mode 100644 index 0000000000..1160790215 --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/fixtures/readings.txt @@ -0,0 +1,4 @@ +DEL readings +ARSET readings 0 "20.1" +ARSET readings 1 "20.4" +ARSET readings 5 "99.9" diff --git a/redisinsight/api/bruno/RedisInsight/Array/folder.bru b/redisinsight/api/bruno/RedisInsight/Array/folder.bru index bc1a22235a..3a409c6c5f 100644 --- a/redisinsight/api/bruno/RedisInsight/Array/folder.bru +++ b/redisinsight/api/bruno/RedisInsight/Array/folder.bru @@ -1,4 +1,3 @@ meta { name: Array } - diff --git a/redisinsight/api/src/common/decorators/array-index/array-index.decorator.spec.ts b/redisinsight/api/src/common/decorators/array-index/array-index.decorator.spec.ts index e455646831..da523c7099 100644 --- a/redisinsight/api/src/common/decorators/array-index/array-index.decorator.spec.ts +++ b/redisinsight/api/src/common/decorators/array-index/array-index.decorator.spec.ts @@ -18,7 +18,7 @@ const validateSingle = async (index: any) => { }; describe('IsArrayIndex', () => { - it.each(['0', '42', '18446744073709551615'])( + it.each(['0', '42', '18446744073709551614'])( 'should pass for valid index %p', async (input) => { expect(await validateSingle(input)).toHaveLength(0); @@ -30,6 +30,7 @@ describe('IsArrayIndex', () => { '1.5', 'abc', '', + '18446744073709551615', '18446744073709551616', 42, undefined, @@ -45,7 +46,7 @@ describe('IsArrayIndex', () => { // Consumers may match on this exact format — changing it is breaking. expect(errors[0].constraints).toEqual({ ArrayIndexValidator: - 'index must be an integer string between 0 and 18446744073709551615', + 'index must be an integer string between 0 and 18446744073709551614', }); }); @@ -58,7 +59,7 @@ describe('IsArrayIndex', () => { it('should pass with { each: true } when all elements are valid', async () => { const dto = new MultiIndexDto(); - dto.indexes = ['1', '2', '18446744073709551615']; + dto.indexes = ['1', '2', '18446744073709551614']; expect(await validate(dto)).toHaveLength(0); }); diff --git a/redisinsight/api/src/common/transformers/redis-string/redis-string-to-ascii.transformer.spec.ts b/redisinsight/api/src/common/transformers/redis-string/redis-string-to-ascii.transformer.spec.ts new file mode 100644 index 0000000000..f1a0873fa6 --- /dev/null +++ b/redisinsight/api/src/common/transformers/redis-string/redis-string-to-ascii.transformer.spec.ts @@ -0,0 +1,53 @@ +import { instanceToPlain } from 'class-transformer'; +import { RedisStringToASCIITransformer } from './redis-string-to-ascii.transformer'; + +class ScalarDto { + @RedisStringToASCIITransformer() + value: unknown; +} + +class ArrayDto { + @RedisStringToASCIITransformer({ each: true }) + values: unknown[]; +} + +const toPlain = (Cls: new () => T, payload: Partial) => + instanceToPlain(Object.assign(new Cls(), payload)); + +describe('RedisStringToASCIITransformer', () => { + describe('scalar', () => { + it('converts Buffer values to an ASCII-safe string', () => { + expect(toPlain(ScalarDto, { value: Buffer.from('hello') })).toEqual({ + value: 'hello', + }); + }); + + it('returns non-Buffer scalars unchanged', () => { + expect(toPlain(ScalarDto, { value: 'hello' })).toEqual({ + value: 'hello', + }); + }); + + it('passes null through without throwing', () => { + expect(toPlain(ScalarDto, { value: null })).toEqual({ value: null }); + }); + + it('passes undefined through without throwing', () => { + expect(() => toPlain(ScalarDto, { value: undefined })).not.toThrow(); + }); + }); + + describe('each: true', () => { + it('returns an empty array unchanged', () => { + expect(toPlain(ArrayDto, { values: [] })).toEqual({ values: [] }); + }); + + it('preserves null/undefined slots alongside converted entries', () => { + const input = [Buffer.from('a'), null, undefined, 'b']; + + expect(toPlain(ArrayDto, { values: input })).toEqual({ + values: ['a', null, undefined, 'b'], + }); + }); + }); +}); diff --git a/redisinsight/api/src/common/transformers/redis-string/redis-string-to-buffer.transformer.spec.ts b/redisinsight/api/src/common/transformers/redis-string/redis-string-to-buffer.transformer.spec.ts new file mode 100644 index 0000000000..29315b4a72 --- /dev/null +++ b/redisinsight/api/src/common/transformers/redis-string/redis-string-to-buffer.transformer.spec.ts @@ -0,0 +1,62 @@ +import { instanceToPlain } from 'class-transformer'; +import { RedisStringToBufferTransformer } from './redis-string-to-buffer.transformer'; + +class ScalarDto { + @RedisStringToBufferTransformer() + value: unknown; +} + +class ArrayDto { + @RedisStringToBufferTransformer({ each: true }) + values: unknown[]; +} + +const toPlain = (Cls: new () => T, payload: Partial) => + instanceToPlain(Object.assign(new Cls(), payload)); + +describe('RedisStringToBufferTransformer', () => { + describe('scalar', () => { + it('returns Buffer values unchanged', () => { + const buf = Buffer.from('hello'); + + expect(toPlain(ScalarDto, { value: buf })).toEqual({ value: buf }); + }); + + it('converts strings via Buffer.from', () => { + expect(toPlain(ScalarDto, { value: 'hello' })).toEqual({ + value: Buffer.from('hello'), + }); + }); + + it('passes null through without throwing', () => { + expect(toPlain(ScalarDto, { value: null })).toEqual({ value: null }); + }); + + it('passes undefined through without throwing', () => { + // Undefined is dropped during plain serialization; the assertion is that + // the call itself does not throw (pre-fix it raised TypeError). + expect(() => toPlain(ScalarDto, { value: undefined })).not.toThrow(); + }); + }); + + describe('each: true', () => { + it('returns an empty array unchanged', () => { + expect(toPlain(ArrayDto, { values: [] })).toEqual({ values: [] }); + }); + + it('converts each entry independently and preserves null/undefined slots', () => { + const buf = Buffer.from('keep'); + const input = [buf, 'text', null, undefined]; + + expect(toPlain(ArrayDto, { values: input })).toEqual({ + values: [buf, Buffer.from('text'), null, undefined], + }); + }); + + it('does not throw on an array of only null/undefined entries', () => { + expect(() => + toPlain(ArrayDto, { values: [null, undefined, null] }), + ).not.toThrow(); + }); + }); +}); diff --git a/redisinsight/api/src/common/transformers/redis-string/redis-string-to-buffer.transformer.ts b/redisinsight/api/src/common/transformers/redis-string/redis-string-to-buffer.transformer.ts index 98ee2fff89..e0620a91c3 100644 --- a/redisinsight/api/src/common/transformers/redis-string/redis-string-to-buffer.transformer.ts +++ b/redisinsight/api/src/common/transformers/redis-string/redis-string-to-buffer.transformer.ts @@ -7,6 +7,14 @@ const SingleRedisStringToBuffer = ({ value }) => { return value; } + // Preserve null/undefined so callers can express "absent" entries (e.g. + // ARGETRANGE returns null for empty slots in a gap-preserving response). + // Mirrors the behaviour of the sibling ASCII / UTF8 transformers, which + // also short-circuit on non-Buffer values rather than throwing. + if (value === null || value === undefined) { + return value; + } + return Buffer.from(value); }; diff --git a/redisinsight/api/src/common/transformers/redis-string/redis-string-to-utf8.transformer.spec.ts b/redisinsight/api/src/common/transformers/redis-string/redis-string-to-utf8.transformer.spec.ts new file mode 100644 index 0000000000..7d303d6f12 --- /dev/null +++ b/redisinsight/api/src/common/transformers/redis-string/redis-string-to-utf8.transformer.spec.ts @@ -0,0 +1,53 @@ +import { instanceToPlain } from 'class-transformer'; +import { RedisStringToUTF8Transformer } from './redis-string-to-utf8.transformer'; + +class ScalarDto { + @RedisStringToUTF8Transformer() + value: unknown; +} + +class ArrayDto { + @RedisStringToUTF8Transformer({ each: true }) + values: unknown[]; +} + +const toPlain = (Cls: new () => T, payload: Partial) => + instanceToPlain(Object.assign(new Cls(), payload)); + +describe('RedisStringToUTF8Transformer', () => { + describe('scalar', () => { + it('decodes Buffer values as UTF-8', () => { + expect(toPlain(ScalarDto, { value: Buffer.from('名字') })).toEqual({ + value: '名字', + }); + }); + + it('returns non-Buffer scalars unchanged', () => { + expect(toPlain(ScalarDto, { value: 'hello' })).toEqual({ + value: 'hello', + }); + }); + + it('passes null through without throwing', () => { + expect(toPlain(ScalarDto, { value: null })).toEqual({ value: null }); + }); + + it('passes undefined through without throwing', () => { + expect(() => toPlain(ScalarDto, { value: undefined })).not.toThrow(); + }); + }); + + describe('each: true', () => { + it('returns an empty array unchanged', () => { + expect(toPlain(ArrayDto, { values: [] })).toEqual({ values: [] }); + }); + + it('preserves null/undefined slots alongside decoded entries', () => { + const input = [Buffer.from('a'), null, undefined, 'b']; + + expect(toPlain(ArrayDto, { values: input })).toEqual({ + values: ['a', null, undefined, 'b'], + }); + }); + }); +}); diff --git a/redisinsight/api/src/common/utils/array-index.helper.spec.ts b/redisinsight/api/src/common/utils/array-index.helper.spec.ts index 88b4e322c4..244f49b2d2 100644 --- a/redisinsight/api/src/common/utils/array-index.helper.spec.ts +++ b/redisinsight/api/src/common/utils/array-index.helper.spec.ts @@ -5,8 +5,8 @@ import { } from 'src/common/utils'; describe('array-index.helper', () => { - it('should expose max unsigned 64-bit value', () => { - expect(ARRAY_INDEX_MAX).toEqual(BigInt('18446744073709551615')); + it('should expose the max valid Redis array index (2^64 - 2)', () => { + expect(ARRAY_INDEX_MAX).toEqual(BigInt('18446744073709551614')); }); describe('parseArrayIndex', () => { @@ -15,8 +15,9 @@ describe('array-index.helper', () => { { input: '7', expected: '7' }, { input: '007', expected: '7' }, // leading zeros normalized { input: ' 42 ', expected: '42' }, // outer whitespace trimmed - { input: '18446744073709551615', expected: '18446744073709551615' }, // max u64 - { input: '18446744073709551616', expected: null }, // max + 1 + { input: '18446744073709551614', expected: '18446744073709551614' }, // max valid (2^64 - 2) + { input: '18446744073709551615', expected: null }, // 2^64 - 1 — reserved + { input: '18446744073709551616', expected: null }, // 2^64 { input: '184467440737095516150', expected: null }, // 21 digits — length guard { input: '00000000000000000000042', expected: null }, // >20 chars — guard trumps normalization { input: '-1', expected: null }, diff --git a/redisinsight/api/src/common/utils/array-index.helper.ts b/redisinsight/api/src/common/utils/array-index.helper.ts index f1a680d780..adcadf697a 100644 --- a/redisinsight/api/src/common/utils/array-index.helper.ts +++ b/redisinsight/api/src/common/utils/array-index.helper.ts @@ -1,25 +1,20 @@ /** - * Redis array indexes are unsigned 64-bit integers (0 … 2^64−1) and exceed - * Number.MAX_SAFE_INTEGER, so they travel as numeric strings end-to-end — - * never parseInt/Number, no JS-side arithmetic on indexes. - * - * Mirrored in redisinsight/ui/src/utils/arrayIndex.ts — keep semantics and - * tests in sync. + * Redis array indexes travel as numeric strings end-to-end (u64 exceeds + * Number.MAX_SAFE_INTEGER) — never parseInt/Number. + * Mirrored in redisinsight/ui/src/utils/arrayIndex.ts — keep in sync. */ -// 2^64 - 1; BigInt() call (not a literal) — this tsconfig targets es2019, -// where BigInt literals are a syntax error (TS2737). -export const ARRAY_INDEX_MAX = BigInt('18446744073709551615'); +// Max valid Redis array index — half-open [0, 2^64−1), so 2^64−2. +export const ARRAY_INDEX_MAX = BigInt('18446744073709551614'); const ARRAY_INDEX_REGEX = /^\d+$/; -// Max u64 is 20 digits; longer all-digit inputs can't be valid and a length -// guard keeps BigInt() from parsing arbitrarily large request payloads. +// 20-digit guard so BigInt() can't parse arbitrarily long payloads. const ARRAY_INDEX_MAX_LENGTH = 20; /** * Returns the canonical decimal string for a valid index ("007" → "7"), * or null for anything else (empty or whitespace-only, negative, - * fractional, exponent, hex, > 2^64−1, non-string input). + * fractional, exponent, hex, > 2^64−2, non-string input). */ export const parseArrayIndex = (input: unknown): string | null => { if (typeof input !== 'string') { diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index e6a8668621..913ea949c5 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -78,6 +78,8 @@ export default { DATABASE_ALREADY_EXISTS: 'The database already exists.', INCORRECT_CLUSTER_CURSOR_FORMAT: 'Incorrect cluster cursor format.', + ARRAY_RANGE_TOO_LARGE: (max: number) => + `Requested range exceeds the maximum of ${numberWithSpaces(max)} elements per call. Narrow the range and try again.`, REMOVING_MULTIPLE_ELEMENTS_NOT_SUPPORT: () => 'Removing multiple elements is available for Redis databases v. 6.2 or later.', SCAN_PER_KEY_TYPE_NOT_SUPPORT: () => diff --git a/redisinsight/api/src/modules/browser/__mocks__/array.ts b/redisinsight/api/src/modules/browser/__mocks__/array.ts new file mode 100644 index 0000000000..e0019ab13a --- /dev/null +++ b/redisinsight/api/src/modules/browser/__mocks__/array.ts @@ -0,0 +1,91 @@ +import { + GetArrayCountResponse, + GetArrayElementDto, + GetArrayElementResponse, + GetArrayLengthResponse, + GetArrayMultiElementsDto, + GetArrayMultiElementsResponse, + GetArrayNextIndexResponse, + GetArrayRangeDto, + GetArrayRangeResponse, + GetArrayScanDto, + GetArrayScanResponse, +} from 'src/modules/browser/array/dto'; +import { mockKeyDto } from 'src/modules/browser/__mocks__/keys'; + +export const mockArrayIndex = '0'; +export const mockArrayElement1 = Buffer.from('20.1'); +export const mockArrayElement2 = Buffer.from('20.4'); + +// Sparse range fixture: indexes 0,1 populated, 2,3 empty. +export const mockArrayRangeWithGaps: (Buffer | null)[] = [ + mockArrayElement1, + mockArrayElement2, + null, + null, +]; + +export const mockGetArrayRangeDto: GetArrayRangeDto = { + keyName: mockKeyDto.keyName, + start: '0', + end: '3', +}; + +export const mockGetArrayRangeResponse: GetArrayRangeResponse = { + keyName: mockKeyDto.keyName, + elements: mockArrayRangeWithGaps, +}; + +export const mockGetArrayScanDto: GetArrayScanDto = { + keyName: mockKeyDto.keyName, + start: '0', + end: '6', +}; + +export const mockGetArrayScanResponse: GetArrayScanResponse = { + keyName: mockKeyDto.keyName, + elements: [ + { index: '0', value: mockArrayElement1 }, + { index: '1', value: mockArrayElement2 }, + ], +}; + +export const mockGetArrayElementDto: GetArrayElementDto = { + keyName: mockKeyDto.keyName, + index: mockArrayIndex, +}; + +export const mockGetArrayElementResponse: GetArrayElementResponse = { + keyName: mockKeyDto.keyName, + value: mockArrayElement1, +}; + +export const mockGetArrayMultiElementsDto: GetArrayMultiElementsDto = { + keyName: mockKeyDto.keyName, + indexes: ['0', '1', '3'], +}; + +export const mockGetArrayMultiElementsResponse: GetArrayMultiElementsResponse = + { + keyName: mockKeyDto.keyName, + elements: [mockArrayElement1, mockArrayElement2, null], + }; + +export const mockArrayLength = '7'; +export const mockArrayCount = '5'; +export const mockArrayNextIndex = '7'; + +export const mockGetArrayLengthResponse: GetArrayLengthResponse = { + keyName: mockKeyDto.keyName, + length: mockArrayLength, +}; + +export const mockGetArrayCountResponse: GetArrayCountResponse = { + keyName: mockKeyDto.keyName, + count: mockArrayCount, +}; + +export const mockGetArrayNextIndexResponse: GetArrayNextIndexResponse = { + keyName: mockKeyDto.keyName, + index: mockArrayNextIndex, +}; diff --git a/redisinsight/api/src/modules/browser/__mocks__/index.ts b/redisinsight/api/src/modules/browser/__mocks__/index.ts index b17d270834..15bce83948 100644 --- a/redisinsight/api/src/modules/browser/__mocks__/index.ts +++ b/redisinsight/api/src/modules/browser/__mocks__/index.ts @@ -4,3 +4,4 @@ export * from './z-set'; export * from './set'; export * from './list'; export * from './hash'; +export * from './array'; diff --git a/redisinsight/api/src/modules/browser/array/array.controller.ts b/redisinsight/api/src/modules/browser/array/array.controller.ts index 4d6a4890a1..a425b24e85 100644 --- a/redisinsight/api/src/modules/browser/array/array.controller.ts +++ b/redisinsight/api/src/modules/browser/array/array.controller.ts @@ -1,20 +1,35 @@ import { Body, Controller, + HttpCode, Post, UseInterceptors, UsePipes, ValidationPipe, } from '@nestjs/common'; -import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiRedisParams } from 'src/decorators/api-redis-params.decorator'; import { ApiQueryRedisStringEncoding } from 'src/common/decorators'; import { ClientMetadata } from 'src/common/models'; import { BrowserSerializeInterceptor } from 'src/common/interceptors'; import { BrowserClientMetadata } from 'src/modules/browser/decorators/browser-client-metadata.decorator'; import { BrowserBaseController } from 'src/modules/browser/browser.base.controller'; +import { KeyDto } from 'src/modules/browser/keys/dto'; import { ArrayService } from 'src/modules/browser/array/array.service'; -import { CreateArrayWithExpireDto } from 'src/modules/browser/array/dto'; +import { + CreateArrayWithExpireDto, + GetArrayCountResponse, + GetArrayElementDto, + GetArrayElementResponse, + GetArrayLengthResponse, + GetArrayMultiElementsDto, + GetArrayMultiElementsResponse, + GetArrayNextIndexResponse, + GetArrayRangeDto, + GetArrayRangeResponse, + GetArrayScanDto, + GetArrayScanResponse, +} from 'src/modules/browser/array/dto'; @ApiTags('Browser: Array') @UseInterceptors(BrowserSerializeInterceptor) @@ -36,4 +51,121 @@ export class ArrayController extends BrowserBaseController { ): Promise { return this.arrayService.createArray(clientMetadata, dto); } + + // The key name can be very large, so it is better to send it in the request body + @Post('/get-range') + @HttpCode(200) + @ApiOperation({ + description: + 'Read a range of elements from the array stored at key (ARGETRANGE). ' + + 'Empty slots are returned as null. The range is inclusive; passing ' + + 'start > end returns elements in reverse index order.', + }) + @ApiRedisParams() + @ApiOkResponse({ type: GetArrayRangeResponse }) + @ApiQueryRedisStringEncoding() + async getRange( + @BrowserClientMetadata() clientMetadata: ClientMetadata, + @Body() dto: GetArrayRangeDto, + ): Promise { + return this.arrayService.getRange(clientMetadata, dto); + } + + @Post('/scan') + @HttpCode(200) + @ApiOperation({ + description: + 'Scan a range of populated elements from the array stored at key (ARSCAN). ' + + 'Empty slots are skipped. The range is inclusive; passing start > end ' + + 'returns pairs in reverse index order.', + }) + @ApiRedisParams() + @ApiOkResponse({ type: GetArrayScanResponse }) + @ApiQueryRedisStringEncoding() + async scan( + @BrowserClientMetadata() clientMetadata: ClientMetadata, + @Body() dto: GetArrayScanDto, + ): Promise { + return this.arrayService.scan(clientMetadata, dto); + } + + @Post('/get-length') + @HttpCode(200) + @ApiOperation({ + description: + 'Get the logical length of the array (highest set index + 1, includes gaps) — ARLEN.', + }) + @ApiRedisParams() + @ApiOkResponse({ type: GetArrayLengthResponse }) + @ApiQueryRedisStringEncoding() + async getLength( + @BrowserClientMetadata() clientMetadata: ClientMetadata, + @Body() dto: KeyDto, + ): Promise { + return this.arrayService.getLength(clientMetadata, dto); + } + + @Post('/get-count') + @HttpCode(200) + @ApiOperation({ + description: 'Get the count of populated (non-empty) elements — ARCOUNT.', + }) + @ApiRedisParams() + @ApiOkResponse({ type: GetArrayCountResponse }) + @ApiQueryRedisStringEncoding() + async getCount( + @BrowserClientMetadata() clientMetadata: ClientMetadata, + @Body() dto: KeyDto, + ): Promise { + return this.arrayService.getCount(clientMetadata, dto); + } + + @Post('/get-next-index') + @HttpCode(200) + @ApiOperation({ + description: + 'Get the next index that ARINSERT would use (read-only) — ARNEXT.', + }) + @ApiRedisParams() + @ApiOkResponse({ type: GetArrayNextIndexResponse }) + @ApiQueryRedisStringEncoding() + async getNextIndex( + @BrowserClientMetadata() clientMetadata: ClientMetadata, + @Body() dto: KeyDto, + ): Promise { + return this.arrayService.getNextIndex(clientMetadata, dto); + } + + @Post('/get-element') + @HttpCode(200) + @ApiOperation({ + description: + 'Get the value at a single index, or null for an empty slot / out of range — ARGET.', + }) + @ApiRedisParams() + @ApiOkResponse({ type: GetArrayElementResponse }) + @ApiQueryRedisStringEncoding() + async getElement( + @BrowserClientMetadata() clientMetadata: ClientMetadata, + @Body() dto: GetArrayElementDto, + ): Promise { + return this.arrayService.getElement(clientMetadata, dto); + } + + @Post('/get-elements') + @HttpCode(200) + @ApiOperation({ + description: + 'Get values at multiple indexes in one round-trip — ARMGET. ' + + 'Returns an array of bulk|null, one per requested index, in request order.', + }) + @ApiRedisParams() + @ApiOkResponse({ type: GetArrayMultiElementsResponse }) + @ApiQueryRedisStringEncoding() + async getMultiElements( + @BrowserClientMetadata() clientMetadata: ClientMetadata, + @Body() dto: GetArrayMultiElementsDto, + ): Promise { + return this.arrayService.getMultiElements(clientMetadata, dto); + } } diff --git a/redisinsight/api/src/modules/browser/array/array.service.spec.ts b/redisinsight/api/src/modules/browser/array/array.service.spec.ts index 639cb8cc2f..026b4f93de 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.spec.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.spec.ts @@ -1,8 +1,21 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ConflictException, ForbiddenException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; import { when } from 'jest-when'; import { ReplyError } from 'src/models/redis-client'; -import { mockBrowserClientMetadata, mockRedisNoPermError } from 'src/__mocks__'; +import { + mockBrowserClientMetadata, + mockRedisNoPermError, + mockRedisWrongTypeError, +} from 'src/__mocks__'; +import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory'; +import { mockDatabaseClientFactory } from 'src/__mocks__/databases-client'; +import { mockStandaloneRedisClient } from 'src/__mocks__/redis-client'; +import ERROR_MESSAGES from 'src/constants/error-messages'; import { BrowserToolArrayCommands, BrowserToolKeysCommands, @@ -11,10 +24,26 @@ import { createContiguousArrayDtoFactory, createSparseArrayDtoFactory, } from 'src/modules/browser/array/__tests__/array.factory'; -import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory'; -import { mockDatabaseClientFactory } from 'src/__mocks__/databases-client'; -import { mockStandaloneRedisClient } from 'src/__mocks__/redis-client'; -import ERROR_MESSAGES from 'src/constants/error-messages'; +import { + mockArrayCount, + mockArrayElement1, + mockArrayLength, + mockArrayNextIndex, + mockArrayRangeWithGaps, + mockGetArrayCountResponse, + mockGetArrayElementDto, + mockGetArrayElementResponse, + mockGetArrayLengthResponse, + mockGetArrayMultiElementsDto, + mockGetArrayMultiElementsResponse, + mockGetArrayNextIndexResponse, + mockGetArrayRangeDto, + mockGetArrayRangeResponse, + mockGetArrayScanDto, + mockGetArrayScanResponse, + mockKeyDto, +} from 'src/modules/browser/__mocks__'; +import { ARRAY_RANGE_MAX_ELEMENTS } from 'src/modules/browser/array/constants'; import { ArrayService } from 'src/modules/browser/array/array.service'; describe('ArrayService', () => { @@ -35,6 +64,10 @@ describe('ArrayService', () => { service = module.get(ArrayService); client.sendCommand = jest.fn().mockResolvedValue(undefined); client.sendPipeline = jest.fn().mockResolvedValue(undefined); + // Key exists by default for read paths; specific tests override. + when(client.sendCommand) + .calledWith([BrowserToolKeysCommands.Exists, mockKeyDto.keyName]) + .mockResolvedValue(1); }); it('should be defined', () => { @@ -139,4 +172,499 @@ describe('ArrayService', () => { expect(client.sendPipeline).not.toHaveBeenCalled(); }); }); + + describe('getRange', () => { + beforeEach(() => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ArGetRange, + mockGetArrayRangeDto.keyName, + mockGetArrayRangeDto.start, + mockGetArrayRangeDto.end, + ]) + .mockResolvedValue(mockArrayRangeWithGaps); + }); + + it('should return range elements (with nulls for gaps)', async () => { + const result = await service.getRange( + mockBrowserClientMetadata, + mockGetArrayRangeDto, + ); + expect(result).toEqual(mockGetArrayRangeResponse); + }); + + it('should reject when key does not exist', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([BrowserToolKeysCommands.Exists, mockKeyDto.keyName]) + .mockResolvedValue(0); + await expect( + service.getRange(mockBrowserClientMetadata, mockGetArrayRangeDto), + ).rejects.toThrow(new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST)); + }); + + it('should reject when range exceeds the 1M cap', async () => { + await expect( + service.getRange(mockBrowserClientMetadata, { + ...mockGetArrayRangeDto, + start: '0', + end: String(ARRAY_RANGE_MAX_ELEMENTS), + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should forward reversed ranges (start > end) to Redis as-is', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ArGetRange, + mockGetArrayRangeDto.keyName, + '5', + '0', + ]) + .mockResolvedValue(mockArrayRangeWithGaps); + + await service.getRange(mockBrowserClientMetadata, { + ...mockGetArrayRangeDto, + start: '5', + end: '0', + }); + + expect(mockStandaloneRedisClient.sendCommand).toHaveBeenCalledWith([ + BrowserToolArrayCommands.ArGetRange, + mockGetArrayRangeDto.keyName, + '5', + '0', + ]); + }); + + it('should rethrow BadRequest on WrongType', async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'ARGETRANGE', + }; + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ArGetRange, + expect.anything(), + expect.anything(), + expect.anything(), + ]) + .mockRejectedValue(replyError); + await expect( + service.getRange(mockBrowserClientMetadata, mockGetArrayRangeDto), + ).rejects.toThrow(BadRequestException); + }); + + it('should map ACL error to Forbidden', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'ARGETRANGE', + }; + mockStandaloneRedisClient.sendCommand.mockRejectedValue(replyError); + await expect( + service.getRange(mockBrowserClientMetadata, mockGetArrayRangeDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('scan', () => { + const flatReply = [ + Buffer.from('0'), + mockArrayElement1, + Buffer.from('1'), + Buffer.from('20.4'), + ]; + + beforeEach(() => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + ]) + .mockResolvedValue(flatReply); + }); + + it('should pair flat reply into structured elements', async () => { + const result = await service.scan( + mockBrowserClientMetadata, + mockGetArrayScanDto, + ); + expect(result).toEqual(mockGetArrayScanResponse); + }); + + it('should pair nested [[index, value], ...] reply (Redis 8.8 shape)', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + ]) + .mockResolvedValue([ + [Buffer.from('0'), mockArrayElement1], + [Buffer.from('1'), Buffer.from('20.4')], + ]); + const result = await service.scan( + mockBrowserClientMetadata, + mockGetArrayScanDto, + ); + expect(result).toEqual(mockGetArrayScanResponse); + }); + + it('should drop nested entries with a nil half', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + ]) + .mockResolvedValue([ + [Buffer.from('0'), mockArrayElement1], + [Buffer.from('1'), null], + [Buffer.from('2')], + ]); + const result = await service.scan( + mockBrowserClientMetadata, + mockGetArrayScanDto, + ); + expect(result.elements).toHaveLength(1); + expect(result.elements[0].index).toBe('0'); + }); + + it('should append LIMIT when provided', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + 'LIMIT', + 50, + ]) + .mockResolvedValue([Buffer.from('0'), mockArrayElement1]); + + const result = await service.scan(mockBrowserClientMetadata, { + ...mockGetArrayScanDto, + limit: 50, + }); + expect(result.elements).toHaveLength(1); + }); + + it('should treat an explicit null limit the same as omitted', async () => { + const result = await service.scan(mockBrowserClientMetadata, { + ...mockGetArrayScanDto, + limit: null as unknown as number, + }); + + expect(mockStandaloneRedisClient.sendCommand).toHaveBeenCalledWith([ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + ]); + expect(result).toEqual(mockGetArrayScanResponse); + }); + + it('should drop pairs whose value or index is null/undefined', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + ]) + .mockResolvedValue([ + Buffer.from('0'), + mockArrayElement1, + Buffer.from('1'), + null, + Buffer.from('2'), + ] as (Buffer | string | null)[]); + + const result = await service.scan( + mockBrowserClientMetadata, + mockGetArrayScanDto, + ); + expect(result.elements).toHaveLength(1); + expect(result.elements[0].index).toBe('0'); + }); + + it('should reject when key does not exist', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([BrowserToolKeysCommands.Exists, mockKeyDto.keyName]) + .mockResolvedValue(0); + await expect( + service.scan(mockBrowserClientMetadata, mockGetArrayScanDto), + ).rejects.toThrow(NotFoundException); + }); + + // No span cap on scan: ARSCAN skips empty slots server-side and the + // sparse-array use case routinely spans far more indexes than it + // returns. The DTO caps `limit` at ARRAY_RANGE_MAX_ELEMENTS to keep + // result-set size bounded — exercised via DTO validation tests, not + // here (the service trusts the DTO). + + it('should forward reversed ranges (start > end) to Redis as-is', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + '5', + '0', + ]) + .mockResolvedValue(flatReply); + + await service.scan(mockBrowserClientMetadata, { + ...mockGetArrayScanDto, + start: '5', + end: '0', + }); + + expect(mockStandaloneRedisClient.sendCommand).toHaveBeenCalledWith([ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + '5', + '0', + ]); + }); + + it('should rethrow BadRequest on WrongType', async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'ARSCAN', + }; + when(mockStandaloneRedisClient.sendCommand) + .calledWith(expect.arrayContaining([BrowserToolArrayCommands.ArScan])) + .mockRejectedValue(replyError); + await expect( + service.scan(mockBrowserClientMetadata, mockGetArrayScanDto), + ).rejects.toThrow(BadRequestException); + }); + + it('should map ACL error to Forbidden', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'ARSCAN', + }; + mockStandaloneRedisClient.sendCommand.mockRejectedValue(replyError); + await expect( + service.scan(mockBrowserClientMetadata, mockGetArrayScanDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe.each([ + { + name: 'getLength', + command: BrowserToolArrayCommands.ArLen, + reply: 7, + expected: mockGetArrayLengthResponse, + stringValue: mockArrayLength, + call: (svc: ArrayService) => + svc.getLength(mockBrowserClientMetadata, mockKeyDto), + }, + { + name: 'getCount', + command: BrowserToolArrayCommands.ArCount, + reply: 5, + expected: mockGetArrayCountResponse, + stringValue: mockArrayCount, + call: (svc: ArrayService) => + svc.getCount(mockBrowserClientMetadata, mockKeyDto), + }, + { + name: 'getNextIndex', + command: BrowserToolArrayCommands.ArNext, + reply: 7, + expected: mockGetArrayNextIndexResponse, + stringValue: mockArrayNextIndex, + call: (svc: ArrayService) => + svc.getNextIndex(mockBrowserClientMetadata, mockKeyDto), + }, + ])('$name', ({ command, reply, expected, call }) => { + beforeEach(() => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([command, mockKeyDto.keyName]) + .mockResolvedValue(reply); + }); + + it('should return the value as a string', async () => { + const result = await call(service); + expect(result).toEqual(expected); + }); + + it('should reject when key does not exist', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([BrowserToolKeysCommands.Exists, mockKeyDto.keyName]) + .mockResolvedValue(0); + await expect(call(service)).rejects.toThrow(NotFoundException); + }); + + it('should rethrow BadRequest on WrongType', async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: command.toUpperCase(), + }; + when(mockStandaloneRedisClient.sendCommand) + .calledWith([command, mockKeyDto.keyName]) + .mockRejectedValue(replyError); + await expect(call(service)).rejects.toThrow(BadRequestException); + }); + + it('should map ACL error to Forbidden', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: command.toUpperCase(), + }; + mockStandaloneRedisClient.sendCommand.mockRejectedValue(replyError); + await expect(call(service)).rejects.toThrow(ForbiddenException); + }); + }); + + describe('getNextIndex (exhausted)', () => { + it('should surface null index when ARNEXT returns nil', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([BrowserToolArrayCommands.ArNext, mockKeyDto.keyName]) + .mockResolvedValue(null); + + const result = await service.getNextIndex( + mockBrowserClientMetadata, + mockKeyDto, + ); + + expect(result).toEqual({ keyName: mockKeyDto.keyName, index: null }); + }); + }); + + describe('getElement', () => { + beforeEach(() => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ArGet, + mockGetArrayElementDto.keyName, + mockGetArrayElementDto.index, + ]) + .mockResolvedValue(mockArrayElement1); + }); + + it('should return the element value', async () => { + const result = await service.getElement( + mockBrowserClientMetadata, + mockGetArrayElementDto, + ); + expect(result).toEqual(mockGetArrayElementResponse); + }); + + it('should return null for an empty slot', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ArGet, + mockGetArrayElementDto.keyName, + mockGetArrayElementDto.index, + ]) + .mockResolvedValue(null); + const result = await service.getElement( + mockBrowserClientMetadata, + mockGetArrayElementDto, + ); + expect(result.value).toBeNull(); + }); + + it('should reject when key does not exist', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([BrowserToolKeysCommands.Exists, mockKeyDto.keyName]) + .mockResolvedValue(0); + await expect( + service.getElement(mockBrowserClientMetadata, mockGetArrayElementDto), + ).rejects.toThrow(NotFoundException); + }); + + it('should rethrow BadRequest on WrongType', async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'ARGET', + }; + when(mockStandaloneRedisClient.sendCommand) + .calledWith(expect.arrayContaining([BrowserToolArrayCommands.ArGet])) + .mockRejectedValue(replyError); + await expect( + service.getElement(mockBrowserClientMetadata, mockGetArrayElementDto), + ).rejects.toThrow(BadRequestException); + }); + + it('should map ACL error to Forbidden', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'ARGET', + }; + mockStandaloneRedisClient.sendCommand.mockRejectedValue(replyError); + await expect( + service.getElement(mockBrowserClientMetadata, mockGetArrayElementDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('getMultiElements', () => { + beforeEach(() => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ArMGet, + mockGetArrayMultiElementsDto.keyName, + ...mockGetArrayMultiElementsDto.indexes, + ]) + .mockResolvedValue([mockArrayElement1, Buffer.from('20.4'), null]); + }); + + it('should return values aligned with requested indexes', async () => { + const result = await service.getMultiElements( + mockBrowserClientMetadata, + mockGetArrayMultiElementsDto, + ); + expect(result).toEqual(mockGetArrayMultiElementsResponse); + }); + + it('should reject when key does not exist', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([BrowserToolKeysCommands.Exists, mockKeyDto.keyName]) + .mockResolvedValue(0); + await expect( + service.getMultiElements( + mockBrowserClientMetadata, + mockGetArrayMultiElementsDto, + ), + ).rejects.toThrow(NotFoundException); + }); + + it('should rethrow BadRequest on WrongType', async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'ARMGET', + }; + when(mockStandaloneRedisClient.sendCommand) + .calledWith(expect.arrayContaining([BrowserToolArrayCommands.ArMGet])) + .mockRejectedValue(replyError); + await expect( + service.getMultiElements( + mockBrowserClientMetadata, + mockGetArrayMultiElementsDto, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('should map ACL error to Forbidden', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'ARMGET', + }; + mockStandaloneRedisClient.sendCommand.mockRejectedValue(replyError); + await expect( + service.getMultiElements( + mockBrowserClientMetadata, + mockGetArrayMultiElementsDto, + ), + ).rejects.toThrow(ForbiddenException); + }); + }); }); diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index fc9f829932..ec8ec0e001 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -1,21 +1,45 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { RedisErrorCodes } from 'src/constants'; +import ERROR_MESSAGES from 'src/constants/error-messages'; import { catchAclError, catchMultiTransactionError } from 'src/utils'; import { ClientMetadata } from 'src/common/models'; -import { - ArrayCreationMode, - CreateArrayWithExpireDto, -} from 'src/modules/browser/array/dto'; +import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory'; import { BrowserToolArrayCommands, BrowserToolKeysCommands, } from 'src/modules/browser/constants/browser-tool-commands'; -import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory'; import { RedisClient, RedisClientCommand, RedisClientCommandReply, } from 'src/modules/redis/client'; -import { checkIfKeyExists } from 'src/modules/browser/utils'; +import { + checkIfKeyExists, + checkIfKeyNotExists, +} from 'src/modules/browser/utils'; +import { KeyDto } from 'src/modules/browser/keys/dto'; +import { ARRAY_RANGE_MAX_ELEMENTS } from 'src/modules/browser/array/constants'; +import { + toIndexString, + toRequiredIndexString, +} from 'src/modules/browser/array/utils'; +import { + ArrayCreationMode, + ArrayElement, + CreateArrayWithExpireDto, + GetArrayCountResponse, + GetArrayElementDto, + GetArrayElementResponse, + GetArrayLengthResponse, + GetArrayMultiElementsDto, + GetArrayMultiElementsResponse, + GetArrayNextIndexResponse, + GetArrayRangeDto, + GetArrayRangeResponse, + GetArrayScanDto, + GetArrayScanResponse, +} from 'src/modules/browser/array/dto'; @Injectable() export class ArrayService { @@ -52,6 +76,52 @@ export class ArrayService { } } + private assertValidRange(start: string, end: string): void { + const startBig = BigInt(start); + const endBig = BigInt(end); + const span = + (startBig > endBig ? startBig - endBig : endBig - startBig) + BigInt(1); + + if (span > BigInt(ARRAY_RANGE_MAX_ELEMENTS)) { + throw new BadRequestException( + ERROR_MESSAGES.ARRAY_RANGE_TOO_LARGE(ARRAY_RANGE_MAX_ELEMENTS), + ); + } + } + + public async getRange( + clientMetadata: ClientMetadata, + dto: GetArrayRangeDto, + ): Promise { + try { + this.logger.debug('Getting array range.', clientMetadata); + const { keyName, start, end } = dto; + + this.assertValidRange(start, end); + + const client = + await this.databaseClientFactory.getOrCreateClient(clientMetadata); + await checkIfKeyNotExists(keyName, client); + + const elements = (await client.sendCommand([ + BrowserToolArrayCommands.ArGetRange, + keyName, + start, + end, + ])) as (Buffer | string | null)[]; + + this.logger.debug('Succeed to get array range.', clientMetadata); + return plainToInstance(GetArrayRangeResponse, { keyName, elements }); + } catch (error) { + this.logger.error('Failed to get array range.', error, clientMetadata); + if (error instanceof BadRequestException) throw error; + if (error?.message?.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } + public async createSimpleArray( client: RedisClient, dto: CreateArrayWithExpireDto, @@ -81,15 +151,239 @@ export class ArrayService { const { keyName, mode, startIndex = '0', values = [], elements = [] } = dto; if (mode === ArrayCreationMode.Contiguous) { - // ARSET key startIndex value [value ...] — contiguous run from startIndex return [BrowserToolArrayCommands.ArSet, keyName, startIndex, ...values]; } - // ARMSET key index value [index value ...] — sparse index/value pairs return [ BrowserToolArrayCommands.ArMSet, keyName, ...elements.flatMap(({ index, value }) => [index, value]), ]; } + + public async scan( + clientMetadata: ClientMetadata, + dto: GetArrayScanDto, + ): Promise { + try { + this.logger.debug('Scanning array range.', clientMetadata); + const { keyName, start, end, limit } = dto; + + // No |end - start| span cap here (unlike ARGETRANGE): ARSCAN skips + // empty slots server-side and the sparse-array use case routinely + // spans far more indexes than it returns. LIMIT (capped on the DTO) + // is the natural backpressure on result-set size. + const client = + await this.databaseClientFactory.getOrCreateClient(clientMetadata); + await checkIfKeyNotExists(keyName, client); + + const baseArgs = [ + BrowserToolArrayCommands.ArScan as string, + keyName, + start, + end, + ] as const; + // typeof 'number' so an explicit JSON null is treated as omitted. + const hasLimit = typeof limit === 'number'; + const reply = (await client.sendCommand( + hasLimit ? [...baseArgs, 'LIMIT', limit] : [...baseArgs], + )) as unknown[]; + + // ARSCAN wire shape varies by Redis version / client: Redis 8.8 + // returns nested [[index, value], ...] entries, while some earlier + // builds surface a flat [index, value, index, value, ...] reply. + // Detect by sniffing the first element and normalize both. Pairs + // with a nil half are dropped (populated-only contract). + const elements: ArrayElement[] = []; + if (Array.isArray(reply[0])) { + for (const entry of reply as unknown[][]) { + if (!entry || entry.length < 2) continue; + const rawIndex = entry[0]; + const value = entry[1]; + if (rawIndex == null || value == null) continue; + elements.push({ + index: toRequiredIndexString(rawIndex), + value: value as Buffer | string, + }); + } + } else { + for (let i = 0; i < reply.length; i += 2) { + const rawIndex = reply[i]; + const value = reply[i + 1]; + if (rawIndex == null || value == null) continue; + elements.push({ + index: toRequiredIndexString(rawIndex), + value: value as Buffer | string, + }); + } + } + + this.logger.debug('Succeed to scan array range.', clientMetadata); + return plainToInstance(GetArrayScanResponse, { keyName, elements }); + } catch (error) { + this.logger.error('Failed to scan array range.', error, clientMetadata); + if (error instanceof BadRequestException) throw error; + if (error?.message?.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } + + public async getLength( + clientMetadata: ClientMetadata, + dto: KeyDto, + ): Promise { + try { + this.logger.debug('Getting array length.', clientMetadata); + const { keyName } = dto; + const client = + await this.databaseClientFactory.getOrCreateClient(clientMetadata); + await checkIfKeyNotExists(keyName, client); + + const reply = await client.sendCommand([ + BrowserToolArrayCommands.ArLen, + keyName, + ]); + + this.logger.debug('Succeed to get array length.', clientMetadata); + return plainToInstance(GetArrayLengthResponse, { + keyName, + length: toRequiredIndexString(reply), + }); + } catch (error) { + this.logger.error('Failed to get array length.', error, clientMetadata); + if (error?.message?.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } + + public async getCount( + clientMetadata: ClientMetadata, + dto: KeyDto, + ): Promise { + try { + this.logger.debug('Getting array count.', clientMetadata); + const { keyName } = dto; + const client = + await this.databaseClientFactory.getOrCreateClient(clientMetadata); + await checkIfKeyNotExists(keyName, client); + + const reply = await client.sendCommand([ + BrowserToolArrayCommands.ArCount, + keyName, + ]); + + this.logger.debug('Succeed to get array count.', clientMetadata); + return plainToInstance(GetArrayCountResponse, { + keyName, + count: toRequiredIndexString(reply), + }); + } catch (error) { + this.logger.error('Failed to get array count.', error, clientMetadata); + if (error?.message?.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } + + public async getNextIndex( + clientMetadata: ClientMetadata, + dto: KeyDto, + ): Promise { + try { + this.logger.debug('Getting array next index.', clientMetadata); + const { keyName } = dto; + const client = + await this.databaseClientFactory.getOrCreateClient(clientMetadata); + await checkIfKeyNotExists(keyName, client); + + const reply = await client.sendCommand([ + BrowserToolArrayCommands.ArNext, + keyName, + ]); + + this.logger.debug('Succeed to get array next index.', clientMetadata); + return plainToInstance(GetArrayNextIndexResponse, { + keyName, + index: toIndexString(reply), + }); + } catch (error) { + this.logger.error( + 'Failed to get array next index.', + error, + clientMetadata, + ); + if (error?.message?.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } + + public async getElement( + clientMetadata: ClientMetadata, + dto: GetArrayElementDto, + ): Promise { + try { + this.logger.debug('Getting array element.', clientMetadata); + const { keyName, index } = dto; + const client = + await this.databaseClientFactory.getOrCreateClient(clientMetadata); + await checkIfKeyNotExists(keyName, client); + + const value = (await client.sendCommand([ + BrowserToolArrayCommands.ArGet, + keyName, + index, + ])) as Buffer | string | null; + + this.logger.debug('Succeed to get array element.', clientMetadata); + return plainToInstance(GetArrayElementResponse, { keyName, value }); + } catch (error) { + this.logger.error('Failed to get array element.', error, clientMetadata); + if (error?.message?.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } + + public async getMultiElements( + clientMetadata: ClientMetadata, + dto: GetArrayMultiElementsDto, + ): Promise { + try { + this.logger.debug('Getting array multi elements.', clientMetadata); + const { keyName, indexes } = dto; + const client = + await this.databaseClientFactory.getOrCreateClient(clientMetadata); + await checkIfKeyNotExists(keyName, client); + + const elements = (await client.sendCommand([ + BrowserToolArrayCommands.ArMGet, + keyName, + ...indexes, + ])) as (Buffer | string | null)[]; + + this.logger.debug('Succeed to get array multi elements.', clientMetadata); + return plainToInstance(GetArrayMultiElementsResponse, { + keyName, + elements, + }); + } catch (error) { + this.logger.error( + 'Failed to get array multi elements.', + error, + clientMetadata, + ); + if (error?.message?.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } } diff --git a/redisinsight/api/src/modules/browser/array/constants.ts b/redisinsight/api/src/modules/browser/array/constants.ts new file mode 100644 index 0000000000..112b3cce99 --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/constants.ts @@ -0,0 +1,6 @@ +// Hard cap on result-set size shared by ARGETRANGE (applied as |end - start| +// + 1 span, since the reply is dense), ARMGET (applied as @ArrayMaxSize on +// the indexes list), and ARSCAN (applied as @Max on the optional LIMIT). +// Mirrored from the server-side ARGETRANGE limit so callers get a clear 400. +// https://redis.io/docs/latest/develop/data-types/arrays/#limits +export const ARRAY_RANGE_MAX_ELEMENTS = 1_000_000; diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-count.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-count.response.ts new file mode 100644 index 0000000000..b7c9315643 --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-count.response.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyResponse } from 'src/modules/browser/keys/dto'; + +export class GetArrayCountResponse extends KeyResponse { + @ApiProperty({ + description: + 'Count of populated (non-empty) elements. Unsigned 64-bit integer as string.', + type: String, + example: '5', + }) + count: string; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-element.dto.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-element.dto.ts new file mode 100644 index 0000000000..2396b5d82d --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-element.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyDto } from 'src/modules/browser/keys/dto'; +import { IsArrayIndex } from 'src/common/decorators'; + +export class GetArrayElementDto extends KeyDto { + @ApiProperty({ + description: + 'Index of the element to read. Unsigned 64-bit integer as string.', + type: String, + example: '42', + }) + @IsArrayIndex() + index: string; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-element.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-element.response.ts new file mode 100644 index 0000000000..2f7fff5457 --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-element.response.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyResponse } from 'src/modules/browser/keys/dto'; +import { RedisStringType } from 'src/common/decorators'; +import { REDIS_STRING_SCHEMA } from 'src/common/decorators/redis-string-schema.decorator'; +import { RedisString } from 'src/common/constants'; + +export class GetArrayElementResponse extends KeyResponse { + @ApiProperty({ + description: + 'Value stored at the requested index, or null if the slot is empty.', + ...REDIS_STRING_SCHEMA, + nullable: true, + }) + @RedisStringType() + value: RedisString | null; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-length.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-length.response.ts new file mode 100644 index 0000000000..9c4dcb07eb --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-length.response.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyResponse } from 'src/modules/browser/keys/dto'; + +export class GetArrayLengthResponse extends KeyResponse { + @ApiProperty({ + description: + 'Logical length of the array (highest set index + 1, includes gaps). ' + + 'Unsigned 64-bit integer as string.', + type: String, + example: '7', + }) + length: string; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.dto.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.dto.ts new file mode 100644 index 0000000000..4fd263874d --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyDto } from 'src/modules/browser/keys/dto'; +import { IsArrayIndex } from 'src/common/decorators'; +import { ArrayMaxSize, ArrayMinSize, IsArray } from 'class-validator'; +import { ARRAY_RANGE_MAX_ELEMENTS } from 'src/modules/browser/array/constants'; + +export class GetArrayMultiElementsDto extends KeyDto { + @ApiProperty({ + description: + 'Indexes to read. Each index is an unsigned 64-bit integer as string. ' + + `At most ${ARRAY_RANGE_MAX_ELEMENTS.toLocaleString('en-US')} indexes ` + + 'per call — ARMGET is O(N) in the number of indexes, so the same ' + + 'per-call cap as ARGETRANGE/ARSCAN applies.', + type: String, + isArray: true, + example: ['0', '5', '42'], + }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(ARRAY_RANGE_MAX_ELEMENTS) + @IsArrayIndex({ each: true }) + indexes: string[]; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.response.ts new file mode 100644 index 0000000000..b93f142e6b --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.response.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyResponse } from 'src/modules/browser/keys/dto'; +import { RedisStringType } from 'src/common/decorators'; +import { REDIS_STRING_SCHEMA } from 'src/common/decorators/redis-string-schema.decorator'; +import { RedisString } from 'src/common/constants'; + +export class GetArrayMultiElementsResponse extends KeyResponse { + @ApiProperty({ + description: + 'Values for each requested index, in request order. ' + + 'Empty slots are returned as null.', + type: 'array', + // Each item mirrors the project-wide RedisString schema (string | Buffer + // object under ?encoding=buffer); `nullable: true` on the item is the + // OAS 3.0 way to express `(string | null)[]`. + items: { oneOf: REDIS_STRING_SCHEMA.oneOf, nullable: true }, + }) + @RedisStringType({ each: true }) + elements: (RedisString | null)[]; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-next-index.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-next-index.response.ts new file mode 100644 index 0000000000..6bb29f0ebc --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-next-index.response.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyResponse } from 'src/modules/browser/keys/dto'; + +export class GetArrayNextIndexResponse extends KeyResponse { + @ApiProperty({ + description: + 'Next index that ARINSERT would use. Unsigned 64-bit integer as string. ' + + 'Null when the array is exhausted and no further insertion is possible.', + type: String, + example: '7', + nullable: true, + }) + index: string | null; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-range.dto.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-range.dto.ts new file mode 100644 index 0000000000..e46dbf1b9d --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-range.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyDto } from 'src/modules/browser/keys/dto'; +import { IsArrayIndex } from 'src/common/decorators'; + +export class GetArrayRangeDto extends KeyDto { + @ApiProperty({ + description: + 'Start index of the range (inclusive). Unsigned 64-bit integer as string.', + type: String, + example: '0', + }) + @IsArrayIndex() + start: string; + + @ApiProperty({ + description: + 'End index of the range (inclusive). Unsigned 64-bit integer as string. ' + + 'If end < start, elements are returned in reverse index order.', + type: String, + example: '99', + }) + @IsArrayIndex() + end: string; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-range.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-range.response.ts new file mode 100644 index 0000000000..5cb57154d0 --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-range.response.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyResponse } from 'src/modules/browser/keys/dto'; +import { RedisStringType } from 'src/common/decorators'; +import { REDIS_STRING_SCHEMA } from 'src/common/decorators/redis-string-schema.decorator'; +import { RedisString } from 'src/common/constants'; + +export class GetArrayRangeResponse extends KeyResponse { + @ApiProperty({ + description: + 'Values for each index in the requested range, in order. ' + + 'Empty slots are returned as null.', + type: 'array', + // Each item mirrors the project-wide RedisString schema (string | Buffer + // object under ?encoding=buffer); `nullable: true` on the item is the + // OAS 3.0 way to express `(string | null)[]`. + items: { oneOf: REDIS_STRING_SCHEMA.oneOf, nullable: true }, + }) + @RedisStringType({ each: true }) + elements: (RedisString | null)[]; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts new file mode 100644 index 0000000000..39acde8be2 --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts @@ -0,0 +1,43 @@ +import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger'; +import { KeyDto } from 'src/modules/browser/keys/dto'; +import { IsArrayIndex } from 'src/common/decorators'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ARRAY_RANGE_MAX_ELEMENTS } from 'src/modules/browser/array/constants'; + +export class GetArrayScanDto extends KeyDto { + @ApiProperty({ + description: + 'Start index of the range (inclusive). Unsigned 64-bit integer as string.', + type: String, + example: '0', + }) + @IsArrayIndex() + start: string; + + @ApiProperty({ + description: + 'End index of the range (inclusive). Unsigned 64-bit integer as string. ' + + 'When start > end, pairs are returned in reverse index order.', + type: String, + example: '99', + }) + @IsArrayIndex() + end: string; + + @ApiPropertyOptional({ + description: + 'Maximum number of populated elements to return (1..' + + `${ARRAY_RANGE_MAX_ELEMENTS.toLocaleString('en-US')}). ` + + 'Maps to the ARSCAN LIMIT option.', + type: Number, + minimum: 1, + maximum: ARRAY_RANGE_MAX_ELEMENTS, + }) + @IsOptional() + @IsInt() + @Min(1) + @Max(ARRAY_RANGE_MAX_ELEMENTS) + @Type(() => Number) + limit?: number; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-scan.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-scan.response.ts new file mode 100644 index 0000000000..30dccd8529 --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-scan.response.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyResponse } from 'src/modules/browser/keys/dto'; +import { ApiRedisString, RedisStringType } from 'src/common/decorators'; +import { RedisString } from 'src/common/constants'; +import { Type } from 'class-transformer'; + +export class ArrayElement { + @ApiProperty({ + description: + 'Index of the populated element. Unsigned 64-bit integer as string.', + type: String, + }) + index: string; + + @ApiRedisString('Value stored at this index.') + @RedisStringType() + value: RedisString; +} + +export class GetArrayScanResponse extends KeyResponse { + @ApiProperty({ + description: + 'Populated elements within the requested range. Empty slots are skipped.', + type: () => ArrayElement, + isArray: true, + }) + @Type(() => ArrayElement) + elements: ArrayElement[]; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/index.ts b/redisinsight/api/src/modules/browser/array/dto/index.ts index c04de7a539..f7a7b7092b 100644 --- a/redisinsight/api/src/modules/browser/array/dto/index.ts +++ b/redisinsight/api/src/modules/browser/array/dto/index.ts @@ -1 +1,12 @@ export * from './create.array-with-expire.dto'; +export * from './get.array-range.dto'; +export * from './get.array-range.response'; +export * from './get.array-scan.dto'; +export * from './get.array-scan.response'; +export * from './get.array-element.dto'; +export * from './get.array-element.response'; +export * from './get.array-multi-elements.dto'; +export * from './get.array-multi-elements.response'; +export * from './get.array-length.response'; +export * from './get.array-count.response'; +export * from './get.array-next-index.response'; diff --git a/redisinsight/api/src/modules/browser/array/utils.spec.ts b/redisinsight/api/src/modules/browser/array/utils.spec.ts new file mode 100644 index 0000000000..1c6d928793 --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/utils.spec.ts @@ -0,0 +1,43 @@ +import { + toIndexString, + toRequiredIndexString, +} from 'src/modules/browser/array/utils'; + +describe('toIndexString', () => { + it('preserves a decimal string as-is', () => { + expect(toIndexString('18446744073709551610')).toBe('18446744073709551610'); + }); + + it('stringifies bigint without precision loss', () => { + expect(toIndexString(BigInt('18446744073709551610'))).toBe( + '18446744073709551610', + ); + }); + + it('stringifies number replies', () => { + expect(toIndexString(42)).toBe('42'); + }); + + it('decodes Buffer replies as utf8', () => { + expect(toIndexString(Buffer.from('42'))).toBe('42'); + }); + + it('returns null for nil replies instead of the string "null"', () => { + expect(toIndexString(null)).toBeNull(); + expect(toIndexString(undefined)).toBeNull(); + }); +}); + +describe('toRequiredIndexString', () => { + it('returns a decimal string for non-nil replies', () => { + expect(toRequiredIndexString(7)).toBe('7'); + expect(toRequiredIndexString('7')).toBe('7'); + }); + + it('throws for nil replies', () => { + expect(() => toRequiredIndexString(null)).toThrow( + 'Unexpected nil reply where a value was required.', + ); + expect(() => toRequiredIndexString(undefined)).toThrow(); + }); +}); diff --git a/redisinsight/api/src/modules/browser/array/utils.ts b/redisinsight/api/src/modules/browser/array/utils.ts new file mode 100644 index 0000000000..2593c1df15 --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/utils.ts @@ -0,0 +1,21 @@ +// Normalize an integer/bulk reply to a decimal string so u64 indexes survive +// the wire. Returns null for nil replies (e.g. ARNEXT when the cursor is +// exhausted) so callers can distinguish absence from a real value. +export const toIndexString = (value: unknown): string | null => { + if (value === null || value === undefined) return null; + if (typeof value === 'string') return value; + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'number') return String(value); + if (Buffer.isBuffer(value)) return value.toString('utf8'); + return String(value); +}; + +// Strict variant for callers where the upstream key/type check guarantees +// Redis cannot return nil (ARLEN / ARCOUNT / ARSCAN element index). +export const toRequiredIndexString = (value: unknown): string => { + const result = toIndexString(value); + if (result === null) { + throw new Error('Unexpected nil reply where a value was required.'); + } + return result; +}; diff --git a/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts b/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts index 5848ac3ff1..5352907612 100644 --- a/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts +++ b/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts @@ -115,9 +115,15 @@ export enum BrowserToolVectorSetCommands { } export enum BrowserToolArrayCommands { - ARGet = 'arget', ArSet = 'arset', ArMSet = 'armset', + ArGet = 'arget', + ArMGet = 'armget', + ArLen = 'arlen', + ArCount = 'arcount', + ArGetRange = 'argetrange', + ArScan = 'arscan', + ArNext = 'arnext', } export type BrowserToolCommands = diff --git a/redisinsight/api/src/modules/browser/keys/dto/get.array-key-info.response.ts b/redisinsight/api/src/modules/browser/keys/dto/get.array-key-info.response.ts new file mode 100644 index 0000000000..f45de64259 --- /dev/null +++ b/redisinsight/api/src/modules/browser/keys/dto/get.array-key-info.response.ts @@ -0,0 +1,32 @@ +import { ApiProperty, PickType } from '@nestjs/swagger'; +import { GetKeyInfoResponse } from 'src/modules/browser/keys/dto/get.keys-info.response'; + +/** + * Key-info response for the Array data type. `length` (ARLEN) and `count` + * (ARCOUNT) are decimal strings because the u64 index space exceeds + * Number.MAX_SAFE_INTEGER. + */ +export class GetArrayKeyInfoResponse extends PickType(GetKeyInfoResponse, [ + 'name', + 'type', + 'ttl', + 'size', +] as const) { + @ApiProperty({ + type: String, + description: + 'Logical length of the array (highest set index + 1, includes gaps).' + + ' Unsigned 64-bit integer as a decimal string.', + example: '7', + }) + length: string; + + @ApiProperty({ + type: String, + description: + 'Populated element count (excludes empty slots, ARCOUNT).' + + ' Unsigned 64-bit integer as a decimal string.', + example: '5', + }) + count: string; +} diff --git a/redisinsight/api/src/modules/browser/keys/dto/index.ts b/redisinsight/api/src/modules/browser/keys/dto/index.ts index f501b184ef..b317f191f5 100644 --- a/redisinsight/api/src/modules/browser/keys/dto/index.ts +++ b/redisinsight/api/src/modules/browser/keys/dto/index.ts @@ -3,6 +3,7 @@ export * from './delete.keys.response'; export * from './get.keys.dto'; export * from './get.keys-info.dto'; export * from './get.keys-info.response'; +export * from './get.array-key-info.response'; export * from './get.keys-with-details.response'; export * from './key.dto'; export * from './key-with-expire.dto'; diff --git a/redisinsight/api/src/modules/browser/keys/key-info/key-info.provider.spec.ts b/redisinsight/api/src/modules/browser/keys/key-info/key-info.provider.spec.ts index ffe324936f..fa1cc7f860 100644 --- a/redisinsight/api/src/modules/browser/keys/key-info/key-info.provider.spec.ts +++ b/redisinsight/api/src/modules/browser/keys/key-info/key-info.provider.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { KeyInfoProvider } from 'src/modules/browser/keys/key-info/key-info.provider'; import { RedisDataType } from 'src/modules/browser/keys/dto'; import { UnsupportedKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/unsupported.key-info.strategy'; +import { ArrayKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/array.key-info.strategy'; import { GraphKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/graph.key-info.strategy'; import { HashKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/hash.key-info.strategy'; import { ListKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/list.key-info.strategy'; @@ -20,6 +21,7 @@ describe('KeyInfoProvider', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ KeyInfoProvider, + ArrayKeyInfoStrategy, GraphKeyInfoStrategy, HashKeyInfoStrategy, ListKeyInfoStrategy, @@ -43,6 +45,7 @@ describe('KeyInfoProvider', () => { input: 'unknown' as RedisDataType, strategy: UnsupportedKeyInfoStrategy, }, + { input: RedisDataType.Array, strategy: ArrayKeyInfoStrategy }, { input: RedisDataType.Graph, strategy: GraphKeyInfoStrategy }, { input: RedisDataType.Hash, strategy: HashKeyInfoStrategy }, { input: RedisDataType.List, strategy: ListKeyInfoStrategy }, diff --git a/redisinsight/api/src/modules/browser/keys/key-info/key-info.provider.ts b/redisinsight/api/src/modules/browser/keys/key-info/key-info.provider.ts index 5579067dbd..2171443a6f 100644 --- a/redisinsight/api/src/modules/browser/keys/key-info/key-info.provider.ts +++ b/redisinsight/api/src/modules/browser/keys/key-info/key-info.provider.ts @@ -1,3 +1,4 @@ +import { ArrayKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/array.key-info.strategy'; import { GraphKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/graph.key-info.strategy'; import { HashKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/hash.key-info.strategy'; import { ListKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/list.key-info.strategy'; @@ -16,6 +17,7 @@ import { Injectable } from '@nestjs/common'; @Injectable() export class KeyInfoProvider { constructor( + private readonly arrayKeyInfoStrategy: ArrayKeyInfoStrategy, private readonly graphKeyInfoStrategy: GraphKeyInfoStrategy, private readonly hashKeyInfoStrategy: HashKeyInfoStrategy, private readonly listKeyInfoStrategy: ListKeyInfoStrategy, @@ -31,6 +33,8 @@ export class KeyInfoProvider { getStrategy(type?: string): KeyInfoStrategy { switch (type) { + case RedisDataType.Array: + return this.arrayKeyInfoStrategy; case RedisDataType.Graph: return this.graphKeyInfoStrategy; case RedisDataType.Hash: diff --git a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts new file mode 100644 index 0000000000..2634df5195 --- /dev/null +++ b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts @@ -0,0 +1,190 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; +import { mockStandaloneRedisClient } from 'src/__mocks__'; +import { + BrowserToolArrayCommands, + BrowserToolKeysCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { + GetArrayKeyInfoResponse, + RedisDataType, +} from 'src/modules/browser/keys/dto'; +import { ArrayKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/array.key-info.strategy'; +import { MAX_KEY_SIZE } from 'src/modules/browser/keys/key-info/constants'; + +const getKeyInfoResponse: GetArrayKeyInfoResponse = { + name: 'testArray', + type: 'array', + ttl: -1, + size: 50, + length: '10', + count: '7', +}; + +describe('ArrayKeyInfoStrategy', () => { + let strategy: ArrayKeyInfoStrategy; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ArrayKeyInfoStrategy], + }).compile(); + + strategy = module.get(ArrayKeyInfoStrategy); + }); + + describe('getInfo', () => { + const key = getKeyInfoResponse.name; + const { ttl, size } = getKeyInfoResponse; + const rawLength = 10; + const rawCount = 7; + + describe('when includeSize is true', () => { + it('should return ttl, length, count, and size in single pipeline', async () => { + when(mockStandaloneRedisClient.sendPipeline) + .calledWith([ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolArrayCommands.ArLen, key], + [BrowserToolArrayCommands.ArCount, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValueOnce([ + [null, ttl], + [null, rawLength], + [null, rawCount], + [null, size], + ]); + + const result = await strategy.getInfo( + mockStandaloneRedisClient, + key, + RedisDataType.Array, + true, + ); + + expect(result).toEqual(getKeyInfoResponse); + }); + + it('should preserve u64 length and count as decimal strings', async () => { + // ARLEN can exceed Number.MAX_SAFE_INTEGER for sparse arrays; the + // strategy must surface the value as a string so precision is kept + // when the client passes back bigint or string replies. + const hugeLength = BigInt('18446744073709551610'); + const hugeCount = '18446744073709551500'; + when(mockStandaloneRedisClient.sendPipeline) + .calledWith([ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolArrayCommands.ArLen, key], + [BrowserToolArrayCommands.ArCount, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValueOnce([ + [null, ttl], + [null, hugeLength], + [null, hugeCount], + [null, size], + ]); + + const result = await strategy.getInfo( + mockStandaloneRedisClient, + key, + RedisDataType.Array, + true, + ); + + expect(result).toEqual({ + ...getKeyInfoResponse, + length: '18446744073709551610', + count: '18446744073709551500', + }); + }); + }); + + describe('when includeSize is false', () => { + it('should skip MEMORY USAGE when count exceeds MAX_KEY_SIZE', async () => { + when(mockStandaloneRedisClient.sendPipeline) + .calledWith([ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolArrayCommands.ArLen, key], + [BrowserToolArrayCommands.ArCount, key], + ]) + .mockResolvedValueOnce([ + [null, ttl], + [null, rawLength], + [null, MAX_KEY_SIZE + 1], + ]); + + const result = await strategy.getInfo( + mockStandaloneRedisClient, + key, + RedisDataType.Array, + false, + ); + + expect(result).toEqual({ + ...getKeyInfoResponse, + count: String(MAX_KEY_SIZE + 1), + size: -1, + }); + }); + + it('should still issue MEMORY USAGE for sparse arrays where length is huge but count is small', async () => { + when(mockStandaloneRedisClient.sendPipeline) + .calledWith([ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolArrayCommands.ArLen, key], + [BrowserToolArrayCommands.ArCount, key], + ]) + .mockResolvedValueOnce([ + [null, ttl], + [null, MAX_KEY_SIZE * 10], + [null, rawCount], + ]); + when(mockStandaloneRedisClient.sendPipeline) + .calledWith([ + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValueOnce([[null, size]]); + + const result = await strategy.getInfo( + mockStandaloneRedisClient, + key, + RedisDataType.Array, + false, + ); + + expect(result).toEqual({ + ...getKeyInfoResponse, + length: String(MAX_KEY_SIZE * 10), + }); + }); + + it('should issue MEMORY USAGE separately when count is small', async () => { + when(mockStandaloneRedisClient.sendPipeline) + .calledWith([ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolArrayCommands.ArLen, key], + [BrowserToolArrayCommands.ArCount, key], + ]) + .mockResolvedValueOnce([ + [null, ttl], + [null, rawLength], + [null, rawCount], + ]); + when(mockStandaloneRedisClient.sendPipeline) + .calledWith([ + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValueOnce([[null, size]]); + + const result = await strategy.getInfo( + mockStandaloneRedisClient, + key, + RedisDataType.Array, + false, + ); + + expect(result).toEqual(getKeyInfoResponse); + }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts new file mode 100644 index 0000000000..dbd5a152cc --- /dev/null +++ b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts @@ -0,0 +1,84 @@ +import { + GetArrayKeyInfoResponse, + RedisDataType, +} from 'src/modules/browser/keys/dto'; +import { + BrowserToolArrayCommands, + BrowserToolKeysCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { RedisString } from 'src/common/constants'; +import { KeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/key-info.strategy'; +import { RedisClient } from 'src/modules/redis/client'; +import { MAX_KEY_SIZE } from 'src/modules/browser/keys/key-info/constants'; +import { toRequiredIndexString } from 'src/modules/browser/array/utils'; + +/** + * Key-info strategy for the Array data type. Returns TTL / size plus + * `length` (ARLEN, includes gaps) and `count` (ARCOUNT, populated only) — + * the two diverge for sparse arrays. + * + * Uses a dedicated `GetArrayKeyInfoResponse` so `length` / `count` stay + * decimal strings; the shared `GetKeyInfoResponse.length: number` would + * silently lose precision for u64 indexes. + */ +export class ArrayKeyInfoStrategy extends KeyInfoStrategy { + public async getInfo( + client: RedisClient, + key: RedisString, + type: string, + includeSize: boolean, + ): Promise { + this.logger.debug(`Getting ${RedisDataType.Array} type info.`); + + if (includeSize !== false) { + const [ + [, ttl = null], + [, rawLength = null], + [, rawCount = null], + [, size = null], + ] = (await client.sendPipeline([ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolArrayCommands.ArLen, key], + [BrowserToolArrayCommands.ArCount, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ])) as [any, any][]; + + return { + name: key, + type, + ttl, + size, + length: toRequiredIndexString(rawLength), + count: toRequiredIndexString(rawCount), + }; + } + + const [[, ttl = null], [, rawLength = null], [, rawCount = null]] = + (await client.sendPipeline([ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolArrayCommands.ArLen, key], + [BrowserToolArrayCommands.ArCount, key], + ])) as [any, any][]; + + // Sparse arrays can have huge `length` (ARLEN, total addressable slots) + // while `count` (ARCOUNT, populated slots) stays small. MEMORY USAGE cost + // scales with stored data, so gate on `count` — otherwise a sparse key + // with few populated slots would report `size: -1` despite being cheap. + let size = -1; + if (rawCount != null && Number(rawCount) < MAX_KEY_SIZE) { + const sizeData = (await client.sendPipeline([ + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ])) as [any, number][]; + size = sizeData && sizeData[0] && sizeData[0][1]; + } + + return { + name: key, + type, + ttl, + size, + length: toRequiredIndexString(rawLength), + count: toRequiredIndexString(rawCount), + }; + } +} diff --git a/redisinsight/api/src/modules/browser/keys/key-info/strategies/key-info.strategy.ts b/redisinsight/api/src/modules/browser/keys/key-info/strategies/key-info.strategy.ts index 7a98e1485e..574a4b9d67 100644 --- a/redisinsight/api/src/modules/browser/keys/key-info/strategies/key-info.strategy.ts +++ b/redisinsight/api/src/modules/browser/keys/key-info/strategies/key-info.strategy.ts @@ -1,8 +1,13 @@ import { Injectable, Logger } from '@nestjs/common'; -import { GetKeyInfoResponse } from 'src/modules/browser/keys/dto'; +import { + GetArrayKeyInfoResponse, + GetKeyInfoResponse, +} from 'src/modules/browser/keys/dto'; import { RedisString } from 'src/common/constants'; import { RedisClient } from 'src/modules/redis/client'; +export type KeyInfoResponse = GetKeyInfoResponse | GetArrayKeyInfoResponse; + @Injectable() export abstract class KeyInfoStrategy { protected readonly logger = new Logger(this.constructor.name); @@ -12,5 +17,5 @@ export abstract class KeyInfoStrategy { key: RedisString, type: string, includeSize: boolean, - ): Promise; + ): Promise; } diff --git a/redisinsight/api/src/modules/browser/keys/keys.controller.ts b/redisinsight/api/src/modules/browser/keys/keys.controller.ts index 2939c6ddac..172ac73e5e 100644 --- a/redisinsight/api/src/modules/browser/keys/keys.controller.ts +++ b/redisinsight/api/src/modules/browser/keys/keys.controller.ts @@ -9,7 +9,14 @@ import { UsePipes, ValidationPipe, } from '@nestjs/common'; -import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBody, + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, + getSchemaPath, +} from '@nestjs/swagger'; import { KeysService } from 'src/modules/browser/keys/keys.service'; import { ApiRedisParams } from 'src/decorators/api-redis-params.decorator'; import { BrowserClientMetadata } from 'src/modules/browser/decorators/browser-client-metadata.decorator'; @@ -18,6 +25,7 @@ import { ClientMetadata } from 'src/common/models'; import { DeleteKeysDto, DeleteKeysResponse, + GetArrayKeyInfoResponse, GetKeyInfoDto, GetKeysDto, GetKeysWithDetailsResponse, @@ -80,15 +88,21 @@ export class KeysController { @ApiOperation({ description: 'Get key info' }) @ApiRedisParams() @ApiBody({ type: GetKeyInfoDto }) + @ApiExtraModels(GetKeyInfoResponse, GetArrayKeyInfoResponse) @ApiOkResponse({ description: 'Keys info', - type: GetKeyInfoResponse, + schema: { + oneOf: [ + { $ref: getSchemaPath(GetKeyInfoResponse) }, + { $ref: getSchemaPath(GetArrayKeyInfoResponse) }, + ], + }, }) @ApiQueryRedisStringEncoding() async getKeyInfo( @BrowserClientMetadata() clientMetadata: ClientMetadata, @Body() dto: GetKeyInfoDto, - ): Promise { + ): Promise { return await this.keysService.getKeyInfo( clientMetadata, dto.keyName, diff --git a/redisinsight/api/src/modules/browser/keys/keys.module.ts b/redisinsight/api/src/modules/browser/keys/keys.module.ts index 11c2e5f781..b09d244c80 100644 --- a/redisinsight/api/src/modules/browser/keys/keys.module.ts +++ b/redisinsight/api/src/modules/browser/keys/keys.module.ts @@ -6,6 +6,7 @@ import { ClusterScannerStrategy } from 'src/modules/browser/keys/scanner/strateg import { Scanner } from 'src/modules/browser/keys/scanner/scanner'; import { KeysService } from 'src/modules/browser/keys/keys.service'; import { KeyInfoProvider } from 'src/modules/browser/keys/key-info/key-info.provider'; +import { ArrayKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/array.key-info.strategy'; import { GraphKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/graph.key-info.strategy'; import { HashKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/hash.key-info.strategy'; import { ListKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/list.key-info.strategy'; @@ -42,6 +43,7 @@ export class KeysModule { StandaloneScannerStrategy, ClusterScannerStrategy, // key info strategies + ArrayKeyInfoStrategy, GraphKeyInfoStrategy, HashKeyInfoStrategy, ListKeyInfoStrategy, diff --git a/redisinsight/api/src/modules/browser/keys/keys.service.ts b/redisinsight/api/src/modules/browser/keys/keys.service.ts index 7286e1ec5a..5d0e7a4f23 100644 --- a/redisinsight/api/src/modules/browser/keys/keys.service.ts +++ b/redisinsight/api/src/modules/browser/keys/keys.service.ts @@ -13,6 +13,7 @@ import { catchAclError } from 'src/utils'; import ERROR_MESSAGES from 'src/constants/error-messages'; import { DeleteKeysResponse, + GetArrayKeyInfoResponse, GetKeyInfoResponse, GetKeysDto, GetKeysInfoDto, @@ -145,7 +146,7 @@ export class KeysService { clientMetadata: ClientMetadata, key: RedisString, includeSize: boolean = false, - ): Promise { + ): Promise { try { this.logger.debug('Getting key info.', clientMetadata); const client = @@ -183,6 +184,14 @@ export class KeysService { result, ); + // Array keys carry u64 `length` / `count` as decimal strings — they + // have a dedicated response class so the wire format and the + // generated client both preserve precision. Split the + // plainToInstance call so each branch is monomorphic and + // class-transformer keeps the array-only string fields. + if (type === RedisDataType.Array) { + return plainToInstance(GetArrayKeyInfoResponse, result); + } return plainToInstance(GetKeyInfoResponse, result); } catch (error) { this.logger.error('Failed to get key info.', error, clientMetadata); diff --git a/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts b/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts index 207fe3e708..1454f2a4a4 100644 --- a/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts +++ b/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts @@ -46,9 +46,16 @@ export abstract class IoredisClient extends RedisClient { client.addBuiltinCommand(BrowserToolVectorSetCommands.VSetAttr); client.addBuiltinCommand(BrowserToolVectorSetCommands.VRem); client.addBuiltinCommand(BrowserToolVectorSetCommands.VSim); - // Array commands + // Array commands — must be registered for sendPipeline. client.addBuiltinCommand(BrowserToolArrayCommands.ArSet); client.addBuiltinCommand(BrowserToolArrayCommands.ArMSet); + client.addBuiltinCommand(BrowserToolArrayCommands.ArGet); + client.addBuiltinCommand(BrowserToolArrayCommands.ArMGet); + client.addBuiltinCommand(BrowserToolArrayCommands.ArLen); + client.addBuiltinCommand(BrowserToolArrayCommands.ArCount); + client.addBuiltinCommand(BrowserToolArrayCommands.ArGetRange); + client.addBuiltinCommand(BrowserToolArrayCommands.ArScan); + client.addBuiltinCommand(BrowserToolArrayCommands.ArNext); } static prepareCommandOptions(options: IRedisClientCommandOptions): any { diff --git a/redisinsight/api/src/modules/redis/client/redis.client.spec.ts b/redisinsight/api/src/modules/redis/client/redis.client.spec.ts index 9014b38dd2..7201219de8 100644 --- a/redisinsight/api/src/modules/redis/client/redis.client.spec.ts +++ b/redisinsight/api/src/modules/redis/client/redis.client.spec.ts @@ -33,7 +33,7 @@ describe('RedisClient', () => { expect(result).toBe(true); expect(client.call).toHaveBeenCalledWith( - ['command', 'info', BrowserToolArrayCommands.ARGet], + ['command', 'info', BrowserToolArrayCommands.ArGet], { replyEncoding: 'utf8' }, ); }); diff --git a/redisinsight/api/src/modules/redis/client/redis.client.ts b/redisinsight/api/src/modules/redis/client/redis.client.ts index e45abf3d29..32fcdc07f6 100644 --- a/redisinsight/api/src/modules/redis/client/redis.client.ts +++ b/redisinsight/api/src/modules/redis/client/redis.client.ts @@ -208,7 +208,7 @@ export abstract class RedisClient extends EventEmitter2 { try { const reply = (await this.call( - ['command', 'info', BrowserToolArrayCommands.ARGet], + ['command', 'info', BrowserToolArrayCommands.ArGet], { replyEncoding: 'utf8' }, )) as RedisClientCommandReply[]; diff --git a/redisinsight/ui/src/utils/arrayIndex.ts b/redisinsight/ui/src/utils/arrayIndex.ts index e027875140..57cbd65137 100644 --- a/redisinsight/ui/src/utils/arrayIndex.ts +++ b/redisinsight/ui/src/utils/arrayIndex.ts @@ -1,26 +1,21 @@ /** - * Redis array indexes are unsigned 64-bit integers (0 … 2^64−1) and exceed - * Number.MAX_SAFE_INTEGER, so they stay numeric strings end-to-end — - * never parseInt/Number, no JS-side arithmetic on indexes; Redux stores - * them as strings. - * - * Mirrored in redisinsight/api/src/common/utils/array-index.helper.ts — - * keep semantics and tests in sync. + * Redis array indexes travel as numeric strings end-to-end (u64 exceeds + * Number.MAX_SAFE_INTEGER) — never parseInt/Number; Redux stores them as + * strings. Mirrored in redisinsight/api/src/common/utils/array-index.helper.ts + * — keep in sync. */ -// 2^64 - 1; BigInt() call form (not a literal) for parity with the API -// mirror, whose tsconfig targets es2019 (where BigInt literals are TS2737). -export const ARRAY_INDEX_MAX = BigInt('18446744073709551615') +// Max valid Redis array index — half-open [0, 2^64−1), so 2^64−2. +export const ARRAY_INDEX_MAX = BigInt('18446744073709551614') const ARRAY_INDEX_REGEX = /^\d+$/ -// Max u64 is 20 digits; longer all-digit inputs can't be valid and a length -// guard keeps BigInt() from parsing arbitrarily large request payloads. +// 20-digit guard so BigInt() can't parse arbitrarily long payloads. const ARRAY_INDEX_MAX_LENGTH = 20 /** * Returns the canonical decimal string for a valid index ("007" → "7"), * or null for anything else (empty or whitespace-only, negative, - * fractional, exponent, hex, > 2^64−1, non-string input). + * fractional, exponent, hex, > 2^64−2, non-string input). */ export const parseArrayIndex = (input: unknown): string | null => { if (typeof input !== 'string') { diff --git a/redisinsight/ui/src/utils/tests/arrayIndex.spec.ts b/redisinsight/ui/src/utils/tests/arrayIndex.spec.ts index 757f6bed85..70fbcfa2f7 100644 --- a/redisinsight/ui/src/utils/tests/arrayIndex.spec.ts +++ b/redisinsight/ui/src/utils/tests/arrayIndex.spec.ts @@ -5,8 +5,8 @@ import { } from 'uiSrc/utils' describe('arrayIndex', () => { - it('should expose max unsigned 64-bit value', () => { - expect(ARRAY_INDEX_MAX).toEqual(BigInt('18446744073709551615')) + it('should expose the max valid Redis array index (2^64 - 2)', () => { + expect(ARRAY_INDEX_MAX).toEqual(BigInt('18446744073709551614')) }) describe('parseArrayIndex', () => { @@ -15,8 +15,9 @@ describe('arrayIndex', () => { { input: '7', expected: '7' }, { input: '007', expected: '7' }, // leading zeros normalized { input: ' 42 ', expected: '42' }, // outer whitespace trimmed - { input: '18446744073709551615', expected: '18446744073709551615' }, // max u64 - { input: '18446744073709551616', expected: null }, // max + 1 + { input: '18446744073709551614', expected: '18446744073709551614' }, // max valid (2^64 - 2) + { input: '18446744073709551615', expected: null }, // 2^64 - 1 — reserved + { input: '18446744073709551616', expected: null }, // 2^64 { input: '184467440737095516150', expected: null }, // 21 digits — length guard { input: '00000000000000000000042', expected: null }, // >20 chars — guard trumps normalization { input: '-1', expected: null },