Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions packages/time-series/lib/commands/QUERYLABELS.spec.ts
Original file line number Diff line number Diff line change
@@ -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]
});
});
23 changes: 23 additions & 0 deletions packages/time-series/lib/commands/QUERYLABELS.ts
Original file line number Diff line number Diff line change
@@ -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<BlobStringReply>,
3: undefined as unknown as () => SetReply<BlobStringReply>
}
} as const satisfies Command;
76 changes: 76 additions & 0 deletions packages/time-series/lib/commands/QUERYLABELS_VALUES.spec.ts
Original file line number Diff line number Diff line change
@@ -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]
});
});
24 changes: 24 additions & 0 deletions packages/time-series/lib/commands/QUERYLABELS_VALUES.ts
Original file line number Diff line number Diff line change
@@ -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<BlobStringReply>,
3: undefined as unknown as () => SetReply<BlobStringReply>
}
} as const satisfies Command;
21 changes: 21 additions & 0 deletions packages/time-series/lib/commands/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends RawLabelValue> = ArrayReply<TuplesReply<[
Expand Down
28 changes: 28 additions & 0 deletions packages/time-series/lib/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import MREVRANGE_WITHLABELS_GROUPBY from './MREVRANGE_WITHLABELS_GROUPBY';
import MREVRANGE_WITHLABELS from './MREVRANGE_WITHLABELS';
import MREVRANGE from './MREVRANGE';
import QUERYINDEX from './QUERYINDEX';
import QUERYLABELS from './QUERYLABELS';
import QUERYLABELS_VALUES from './QUERYLABELS_VALUES';
import READ from './READ';
import RANGE_MULTIAGGR from './RANGE_MULTIAGGR';
import RANGE from './RANGE';
Expand Down Expand Up @@ -550,6 +552,32 @@ export default {
* @param filter - Filter to match time series labels
*/
queryIndex: QUERYINDEX,
/**
* Returns all label names present on time series matching a filter (or all indexed series when the filter is omitted).
* Added since Redis 8.10.
* @param filter - Optional filter expression(s) (same language as `TS.QUERYINDEX`); omit to query all indexed series
*/
QUERYLABELS,
/**
* Returns all label names present on time series matching a filter (or all indexed series when the filter is omitted).
* Added since Redis 8.10.
* @param filter - Optional filter expression(s) (same language as `TS.QUERYINDEX`); omit to query all indexed series
*/
queryLabels: QUERYLABELS,
/**
* Returns all values assigned to a label across time series matching a filter (or all indexed series when the filter is omitted).
* Added since Redis 8.10.
* @param label - Label name whose values are collected; matched byte-exactly
* @param filter - Optional filter expression(s) (same language as `TS.QUERYINDEX`); omit to query all indexed series
*/
QUERYLABELS_VALUES,
/**
* Returns all values assigned to a label across time series matching a filter (or all indexed series when the filter is omitted).
* Added since Redis 8.10.
* @param label - Label name whose values are collected; matched byte-exactly
* @param filter - Optional filter expression(s) (same language as `TS.QUERYINDEX`); omit to query all indexed series
*/
queryLabelValues: QUERYLABELS_VALUES,
/**
* Reads a batch of samples with timestamp at or after a cursor, in ascending timestamp order.
* With `BLOCK`, waits until at least `minCount` samples qualify or the timeout elapses.
Expand Down