From c80d6724a9727994dd598695172daf17a4026262 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 22 Jul 2026 14:14:10 +0300 Subject: [PATCH] feat(time-series): add TS.QUERYLABELS (LABELS and VALUES forms) Add client support for TS.QUERYLABELS, introduced in RedisTimeSeries 8.10. The command returns label metadata for time series matching a filter: the set of all label names (LABELS), or the set of all values of one label name (VALUES label). It completes the drill-down flow alongside TS.QUERYINDEX. Two methods are exposed so the mandatory label argument of the VALUES form is enforced by the signature: - queryLabels(filter?) -> TS.QUERYLABELS LABELS [FILTER ...] - queryLabelValues(label, filter?) -> TS.QUERYLABELS VALUES label [FILTER ...] The command is keyless and read-only; FILTER is optional in both forms and omitting it queries all indexed series. Filter expressions reuse the existing TS.QUERYINDEX representation and are sent verbatim. The reply is a flat array under RESP2 and a set under RESP3; an empty reply is a valid success. Not eligible for client-side caching (server advertises the dont_cache tip). Passing an explicitly empty filter array raises a local usage error rather than silently widening to all series. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/commands/QUERYLABELS.spec.ts | 90 +++++++++++++++++++ .../time-series/lib/commands/QUERYLABELS.ts | 23 +++++ .../lib/commands/QUERYLABELS_VALUES.spec.ts | 76 ++++++++++++++++ .../lib/commands/QUERYLABELS_VALUES.ts | 24 +++++ packages/time-series/lib/commands/helpers.ts | 21 +++++ packages/time-series/lib/commands/index.ts | 28 ++++++ 6 files changed, 262 insertions(+) create mode 100644 packages/time-series/lib/commands/QUERYLABELS.spec.ts create mode 100644 packages/time-series/lib/commands/QUERYLABELS.ts create mode 100644 packages/time-series/lib/commands/QUERYLABELS_VALUES.spec.ts create mode 100644 packages/time-series/lib/commands/QUERYLABELS_VALUES.ts 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