diff --git a/packages/time-series/lib/commands/QUERYLABELS.spec.ts b/packages/time-series/lib/commands/QUERYLABELS.spec.ts new file mode 100644 index 00000000000..1bb19e8c7a1 --- /dev/null +++ b/packages/time-series/lib/commands/QUERYLABELS.spec.ts @@ -0,0 +1,90 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import QUERYLABELS from './QUERYLABELS'; +import { parseArgs } from '@redis/client/lib/commands/generic-transformers'; + +describe('TS.QUERYLABELS LABELS', () => { + describe('transformArguments', () => { + it('without filter', () => { + assert.deepEqual( + parseArgs(QUERYLABELS), + ['TS.QUERYLABELS', 'LABELS'] + ); + }); + + it('single filter', () => { + assert.deepEqual( + parseArgs(QUERYLABELS, 'type=sensor'), + ['TS.QUERYLABELS', 'LABELS', 'FILTER', 'type=sensor'] + ); + }); + + it('multiple filters', () => { + assert.deepEqual( + parseArgs(QUERYLABELS, ['type=sensor', 'location=Kitchen']), + ['TS.QUERYLABELS', 'LABELS', 'FILTER', 'type=sensor', 'location=Kitchen'] + ); + }); + + it('throws on an explicitly empty filter list', () => { + assert.throws(() => parseArgs(QUERYLABELS, [])); + }); + }); + + testUtils.testWithClient('client.ts.queryLabels (with filter)', async client => { + await Promise.all([ + client.ts.create('ts1', { LABELS: { type: 'sensor', location: 'LivingRoom', sensortype: 'temp' } }), + client.ts.create('ts2', { LABELS: { type: 'sensor', location: 'Kitchen', sensortype: 'temp' } }), + client.ts.create('ts3', { LABELS: { type: 'gauge', location: 'BedRoom' } }) + ]); + + const reply = await client.ts.queryLabels(['type=sensor']); + assert.deepEqual( + [...reply].sort(), + ['location', 'sensortype', 'type'] + ); + }, { + ...GLOBAL.SERVERS.OPEN, + minimumDockerVersion: [8, 10] + }); + + testUtils.testWithClient('client.ts.queryLabels (no filter)', async client => { + await client.ts.create('ts1', { LABELS: { type: 'sensor', location: 'LivingRoom' } }); + + const reply = await client.ts.queryLabels(); + assert.deepEqual( + [...reply].sort(), + ['location', 'type'] + ); + }, { + ...GLOBAL.SERVERS.OPEN, + minimumDockerVersion: [8, 10] + }); + + testUtils.testWithClient('client.ts.queryLabels (empty result is not an error)', async client => { + await client.ts.create('ts1', { LABELS: { type: 'sensor' } }); + + const reply = await client.ts.queryLabels(['type=nonexistent']); + assert.deepEqual([...reply], []); + }, { + ...GLOBAL.SERVERS.OPEN, + minimumDockerVersion: [8, 10] + }); + + testUtils.testWithClient('client.ts.queryLabels (RESP2 flat array)', async client => { + await client.ts.create('ts1', { LABELS: { type: 'sensor', location: 'LivingRoom' } }); + + const reply = await client.ts.queryLabels(['type=sensor']); + assert.deepEqual( + [...reply].sort(), + ['location', 'type'] + ); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + ...GLOBAL.SERVERS.OPEN.clientOptions, + RESP: 2 + }, + minimumDockerVersion: [8, 10] + }); +}); diff --git a/packages/time-series/lib/commands/QUERYLABELS.ts b/packages/time-series/lib/commands/QUERYLABELS.ts new file mode 100644 index 00000000000..b62f14e264c --- /dev/null +++ b/packages/time-series/lib/commands/QUERYLABELS.ts @@ -0,0 +1,23 @@ +import { CommandParser } from '@redis/client/dist/lib/client/parser'; +import { ArrayReply, BlobStringReply, SetReply, Command } from '@redis/client/dist/lib/RESP/types'; +import { RedisVariadicArgument } from '@redis/client/dist/lib/commands/generic-transformers'; +import { parseQueryLabelsFilterArgument } from './helpers'; + +/** + * `TS.QUERYLABELS LABELS [FILTER filterExpr ...]` — the set of all label names + * present on at least one matching (and readable) time series. Added in + * RedisTimeSeries 8.10. Keyless; in cluster mode the server performs the + * cluster-wide fan-out and returns a single merged, deduplicated reply. + */ +export default { + NOT_KEYED_COMMAND: true, + IS_READ_ONLY: true, + parseCommand(parser: CommandParser, filter?: RedisVariadicArgument) { + parser.push('TS.QUERYLABELS', 'LABELS'); + parseQueryLabelsFilterArgument(parser, filter); + }, + transformReply: { + 2: undefined as unknown as () => ArrayReply, + 3: undefined as unknown as () => SetReply + } +} as const satisfies Command; diff --git a/packages/time-series/lib/commands/QUERYLABELS_VALUES.spec.ts b/packages/time-series/lib/commands/QUERYLABELS_VALUES.spec.ts new file mode 100644 index 00000000000..a8f5371d75e --- /dev/null +++ b/packages/time-series/lib/commands/QUERYLABELS_VALUES.spec.ts @@ -0,0 +1,76 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import QUERYLABELS_VALUES from './QUERYLABELS_VALUES'; +import { parseArgs } from '@redis/client/lib/commands/generic-transformers'; + +describe('TS.QUERYLABELS VALUES', () => { + describe('transformArguments', () => { + it('without filter', () => { + assert.deepEqual( + parseArgs(QUERYLABELS_VALUES, 'location'), + ['TS.QUERYLABELS', 'VALUES', 'location'] + ); + }); + + it('single filter', () => { + assert.deepEqual( + parseArgs(QUERYLABELS_VALUES, 'location', 'type=sensor'), + ['TS.QUERYLABELS', 'VALUES', 'location', 'FILTER', 'type=sensor'] + ); + }); + + it('multiple filters', () => { + assert.deepEqual( + parseArgs(QUERYLABELS_VALUES, 'location', ['type=sensor', 'sensortype=temp']), + ['TS.QUERYLABELS', 'VALUES', 'location', 'FILTER', 'type=sensor', 'sensortype=temp'] + ); + }); + + it('throws on an explicitly empty filter list', () => { + assert.throws(() => parseArgs(QUERYLABELS_VALUES, 'location', [])); + }); + }); + + testUtils.testWithClient('client.ts.queryLabelValues (with filter)', async client => { + await Promise.all([ + client.ts.create('ts1', { LABELS: { type: 'sensor', location: 'LivingRoom' } }), + client.ts.create('ts2', { LABELS: { type: 'sensor', location: 'Kitchen' } }), + client.ts.create('ts3', { LABELS: { type: 'gauge', location: 'BedRoom' } }) + ]); + + const reply = await client.ts.queryLabelValues('location', ['type=sensor']); + assert.deepEqual( + [...reply].sort(), + ['Kitchen', 'LivingRoom'] + ); + }, { + ...GLOBAL.SERVERS.OPEN, + minimumDockerVersion: [8, 10] + }); + + testUtils.testWithClient('client.ts.queryLabelValues (no filter)', async client => { + await Promise.all([ + client.ts.create('ts1', { LABELS: { location: 'LivingRoom' } }), + client.ts.create('ts2', { LABELS: { location: 'Kitchen' } }) + ]); + + const reply = await client.ts.queryLabelValues('location'); + assert.deepEqual( + [...reply].sort(), + ['Kitchen', 'LivingRoom'] + ); + }, { + ...GLOBAL.SERVERS.OPEN, + minimumDockerVersion: [8, 10] + }); + + testUtils.testWithClient('client.ts.queryLabelValues (unknown label is empty, not an error)', async client => { + await client.ts.create('ts1', { LABELS: { type: 'sensor', location: 'LivingRoom' } }); + + const reply = await client.ts.queryLabelValues('nope', ['type=sensor']); + assert.deepEqual([...reply], []); + }, { + ...GLOBAL.SERVERS.OPEN, + minimumDockerVersion: [8, 10] + }); +}); diff --git a/packages/time-series/lib/commands/QUERYLABELS_VALUES.ts b/packages/time-series/lib/commands/QUERYLABELS_VALUES.ts new file mode 100644 index 00000000000..62157f2ca53 --- /dev/null +++ b/packages/time-series/lib/commands/QUERYLABELS_VALUES.ts @@ -0,0 +1,24 @@ +import { CommandParser } from '@redis/client/dist/lib/client/parser'; +import { RedisArgument, ArrayReply, BlobStringReply, SetReply, Command } from '@redis/client/dist/lib/RESP/types'; +import { RedisVariadicArgument } from '@redis/client/dist/lib/commands/generic-transformers'; +import { parseQueryLabelsFilterArgument } from './helpers'; + +/** + * `TS.QUERYLABELS VALUES label [FILTER filterExpr ...]` — the set of all values + * assigned to `label` across matching (and readable) time series. Added in + * RedisTimeSeries 8.10. The `label` is matched byte-exactly and is not + * normalized. Keyless; in cluster mode the server performs the cluster-wide + * fan-out and returns a single merged, deduplicated reply. + */ +export default { + NOT_KEYED_COMMAND: true, + IS_READ_ONLY: true, + parseCommand(parser: CommandParser, label: RedisArgument, filter?: RedisVariadicArgument) { + parser.push('TS.QUERYLABELS', 'VALUES', label); + parseQueryLabelsFilterArgument(parser, filter); + }, + transformReply: { + 2: undefined as unknown as () => ArrayReply, + 3: undefined as unknown as () => SetReply + } +} as const satisfies Command; diff --git a/packages/time-series/lib/commands/helpers.ts b/packages/time-series/lib/commands/helpers.ts index 7c31a441d86..daa92f8c8fb 100644 --- a/packages/time-series/lib/commands/helpers.ts +++ b/packages/time-series/lib/commands/helpers.ts @@ -308,6 +308,27 @@ export function parseSelectedLabelsArguments( parser.pushVariadic(selectedLabels); } +/** + * Pushes the optional `FILTER filterExpr [filterExpr ...]` tail shared by the + * `TS.QUERYLABELS` forms. Omitting `filter` queries all indexed series; passing + * an explicitly empty array is a local usage error rather than a silent widen to + * all series (the server also rejects a bare `FILTER` token). Expressions are + * sent verbatim — not parsed, reordered, or deduplicated. + */ +export function parseQueryLabelsFilterArgument( + parser: CommandParser, + filter?: RedisVariadicArgument +) { + if (filter === undefined) return; + + if (Array.isArray(filter) && filter.length === 0) { + throw new Error('FILTER was given an empty filter expression list; omit it to query all indexed series'); + } + + parser.push('FILTER'); + parser.pushVariadic(filter); +} + export type RawLabelValue = BlobStringReply | NullReply; export type RawLabels = ArrayReply